From 4ed3545e4292f7ad6748662677dc9ac5f2681da5 Mon Sep 17 00:00:00 2001 From: Colin Dawson Date: Mon, 16 Mar 2026 15:33:02 +0000 Subject: [PATCH] Added capability editor for outcome completion rules --- .../AssigneesOfITaskAssigneeEditor.tsx | 367 ++++++++++++++++-- 1 file changed, 336 insertions(+), 31 deletions(-) diff --git a/src/modules/manager/workflowTemplates/components/CapabilityEditors/AssigneesOfITaskAssigneeEditor.tsx b/src/modules/manager/workflowTemplates/components/CapabilityEditors/AssigneesOfITaskAssigneeEditor.tsx index 92e49a7..40662fa 100644 --- a/src/modules/manager/workflowTemplates/components/CapabilityEditors/AssigneesOfITaskAssigneeEditor.tsx +++ b/src/modules/manager/workflowTemplates/components/CapabilityEditors/AssigneesOfITaskAssigneeEditor.tsx @@ -4,7 +4,10 @@ import { GeneralIdRef, MakeGeneralIdRef, } from "../../../../../utils/GeneralIdRef"; -import { TaskDefinition } from "../../services/WorkflowTemplateService"; +import { + TaskDefinition, + TaskMetadata, +} from "../../services/WorkflowTemplateService"; import { CapabilityEditorProps, capabilityEditorRegistryEntry, @@ -31,6 +34,195 @@ export interface IAssigneesCapability { assignees: ITaskAssignee[]; } +export interface ICompletionRule { + operator: string; + children?: ICompletionRule[]; + raciFilter?: string; + requiredOutcome?: TOutcome; + threshold?: number; +} + +const defaultAssigneesCompletionRule: ICompletionRule = { + operator: "All", +}; + +const completionRuleOperators = [ + "All", + "Any", + "CountAtLeast", + "CountExactly", + "FilterByRaci", + "FilterByOutcome", + "And", + "Or", +]; + +const raciOptions = ["Responsible", "Accountable", "Consulted", "Informed"]; + +function formatCompletionRule(rule?: ICompletionRule): string { + return JSON.stringify(rule ?? defaultAssigneesCompletionRule, null, 2); +} + +interface CompletionRuleEditorProps { + rule: ICompletionRule; + onChange: (rule: ICompletionRule) => void; + onRemove?: () => void; + outcomeOptions: string[]; + depth?: number; +} + +const CompletionRuleEditor: React.FC = ({ + rule, + onChange, + onRemove, + outcomeOptions, + depth = 0, +}) => { + const children = rule.children ?? []; + const showRaciFilter = + rule.operator === "FilterByRaci" || !!rule.raciFilter?.trim(); + const showRequiredOutcome = + rule.operator === "FilterByOutcome" || rule.requiredOutcome !== undefined; + const showThreshold = + rule.operator === "CountAtLeast" || + rule.operator === "CountExactly" || + rule.threshold !== undefined; + + function updateChild(index: number, child: ICompletionRule) { + const nextChildren = [...children]; + nextChildren[index] = child; + onChange({ ...rule, children: nextChildren }); + } + + function removeChild(index: number) { + const nextChildren = children.filter((_, i) => i !== index); + onChange({ + ...rule, + children: nextChildren.length ? nextChildren : undefined, + }); + } + + function addChild() { + onChange({ + ...rule, + children: [...children, { operator: "All" }], + }); + } + + return ( +
+
+
+ + +
+ {onRemove && ( +
+ +
+ )} +
+ + {showRaciFilter && ( +
+ + +
+ )} + + {showRequiredOutcome && outcomeOptions.length > 0 && ( +
+ + +
+ )} + + {showThreshold && ( +
+ + { + const value = e.target.value; + onChange({ + ...rule, + threshold: value ? Number(value) : undefined, + }); + }} + /> +
+ )} + +
+ {children.map((child, index) => ( + updateChild(index, updated)} + onRemove={() => removeChild(index)} + outcomeOptions={outcomeOptions} + depth={depth + 1} + /> + ))} + +
+
+ ); +}; + // --------------------------- // Editor Component // --------------------------- @@ -38,12 +230,17 @@ export interface IAssigneesCapability { export const AssigneesOfITaskAssigneeEditor: React.FC = ( props, ) => { - const { task, onChange, fieldErrors } = props; + const { task, onChange, fieldErrors, taskMetadata } = props; const assignees = (task.config.assignees ?? []) as ITaskAssignee[]; + const assigneesCompletionRule = task.config.assigneesCompletionRule as + | ICompletionRule + | undefined; + const getOutcomeId = (value: string) => value.split(".").pop() ?? value; + const outcomeOptions = taskMetadata.outcomes.map(getOutcomeId); function updateAssignee(index: number, updated: ITaskAssignee) { const clone = structuredClone(task); - const list = clone.config.assignees ?? []; + const list = (clone.config.assignees ?? []) as ITaskAssignee[]; list[index] = updated; clone.config.assignees = list; onChange(clone); @@ -51,7 +248,7 @@ export const AssigneesOfITaskAssigneeEditor: React.FC = ( function addAssignee() { const clone = structuredClone(task); - const list = clone.config.assignees ?? []; + const list = (clone.config.assignees ?? []) as ITaskAssignee[]; list.push({ role: null, user: null, @@ -63,11 +260,28 @@ export const AssigneesOfITaskAssigneeEditor: React.FC = ( function removeAssignee(index: number) { const clone = structuredClone(task); - const list = clone.config.assignees ?? []; + const list = (clone.config.assignees ?? []) as ITaskAssignee[]; clone.config.assignees = list.filter((_, i) => i !== index); onChange(clone); } + React.useEffect(() => { + if (assigneesCompletionRule) { + return; + } + + const clone = structuredClone(task); + clone.config.assigneesCompletionRule = + defaultAssigneesCompletionRule as ICompletionRule; + onChange(clone); + }, [assigneesCompletionRule, onChange, task]); + + function updateCompletionRule(updatedRule: ICompletionRule) { + const clone = structuredClone(task); + clone.config.assigneesCompletionRule = updatedRule; + onChange(clone); + } + const guid = task.config.guid as string; const domain = MakeGeneralIdRef(getCurrentUser()?.domainid); @@ -153,13 +367,22 @@ export const AssigneesOfITaskAssigneeEditor: React.FC = ( + .filter(() => Object.keys(fieldErrors ?? {}).some( (key) => key === `${guid}.assignees`, ), ) .join("; ")} /> +
+ + + +
); }; @@ -171,43 +394,124 @@ export const AssigneesOfITaskAssigneeEditor: React.FC = ( const runValidation = ( task: TaskDefinition, tasks: TaskDefinition[], + tasksMetadata: TaskMetadata[] = [], ): Promise> => { const errors: Record = {}; const guid = task.config.guid as string; const assignees = task.config.assignees as ITaskAssignee[] | undefined; + const assigneesCompletionRule = task.config.assigneesCompletionRule as + | ICompletionRule + | undefined; - if (!assignees || assignees.length === 0) { - errors[`${guid}.assignees`] = "At least one assignee is required."; - return Promise.resolve(errors); - } + const getOutcomeId = (value: string) => value.split(".").pop() ?? value; + const matchingTaskMetadata = tasksMetadata.find( + (m) => m.taskType === task.type, + ); + const validOutcomes = new Set( + ((matchingTaskMetadata?.outcomes as string[] | undefined) ?? []).map( + getOutcomeId, + ), + ); - assignees.forEach((a, i) => { - const noUserSelected = !a.user || a.user?.id === BigInt(0); - const noRoleSelected = !a.role || a.role?.id === BigInt(0); + const validateCompletionRule = ( + rule: ICompletionRule | undefined, + path: string, + ) => { + if (!rule) { + errors[path] = "Assignees completion rule is required."; + return; + } - if (!noUserSelected && !noRoleSelected) { - errors[`${guid}.assignees[${i}].user`] = - "Cannot select both a user and a role."; - errors[`${guid}.assignees[${i}].role`] = - "Cannot select both a user and a role."; - } else { - if (!(!noUserSelected || !noRoleSelected)) { - if (noUserSelected) { - errors[`${guid}.assignees[${i}].user`] = "A user must be selected."; - } + if (!rule.operator?.trim()) { + errors[`${path}.operator`] = "Operator is required."; + } - if (noRoleSelected) { - errors[`${guid}.assignees[${i}].role`] = "A role must be selected."; - } + if ( + rule.operator === "FilterByRaci" && + (!rule.raciFilter || !rule.raciFilter.trim()) + ) { + errors[`${path}.raciFilter`] = "raciFilter is required for FilterByRaci."; + } + + if ( + rule.operator === "FilterByOutcome" && + (!rule.requiredOutcome || !String(rule.requiredOutcome).trim()) + ) { + errors[`${path}.requiredOutcome`] = + "requiredOutcome is required for FilterByOutcome."; + } + + if ( + (rule.operator === "CountAtLeast" || rule.operator === "CountExactly") && + rule.threshold === undefined + ) { + errors[`${path}.threshold`] = + "Threshold is required for count operators."; + } + + if (rule.threshold !== undefined) { + const isValidThreshold = + Number.isInteger(rule.threshold) && rule.threshold > 0; + if (!isValidThreshold) { + errors[`${path}.threshold`] = "Threshold must be a positive integer."; } } - if (!a.raci) { - errors[`${guid}.assignees[${i}].raci`] = "RACI is required."; + if (rule.requiredOutcome !== undefined && validOutcomes.size > 0) { + const outcomeId = getOutcomeId(String(rule.requiredOutcome)); + if (!validOutcomes.has(outcomeId)) { + errors[`${path}.requiredOutcome`] = + "requiredOutcome must be one of the configured outcomes."; + } } - }); - return errors; + if (rule.children !== undefined) { + if (!Array.isArray(rule.children)) { + errors[`${path}.children`] = "Children must be an array when provided."; + } else { + rule.children.forEach((child, index) => { + validateCompletionRule(child, `${path}.children[${index}]`); + }); + } + } + }; + + if (!assignees || assignees.length === 0) { + errors[`${guid}.assignees`] = "At least one assignee is required."; + } else { + assignees.forEach((a, i) => { + const noUserSelected = !a.user || a.user?.id === BigInt(0); + const noRoleSelected = !a.role || a.role?.id === BigInt(0); + + if (!noUserSelected && !noRoleSelected) { + errors[`${guid}.assignees[${i}].user`] = + "Cannot select both a user and a role."; + errors[`${guid}.assignees[${i}].role`] = + "Cannot select both a user and a role."; + } else { + if (!(!noUserSelected || !noRoleSelected)) { + if (noUserSelected) { + errors[`${guid}.assignees[${i}].user`] = "A user must be selected."; + } + + if (noRoleSelected) { + errors[`${guid}.assignees[${i}].role`] = "A role must be selected."; + } + } + } + + if (!a.raci) { + errors[`${guid}.assignees[${i}].raci`] = "RACI is required."; + } + }); + } + + validateCompletionRule( + assigneesCompletionRule, + `${guid}.assigneesCompletionRule`, + ); + + return Promise.resolve(errors); }; // --------------------------- @@ -220,7 +524,8 @@ export function defaultsAssignment( ctx: defaultsContext, ) { task.config.assignees = [{ raci: "Responsible" } as ITaskAssignee]; - //task.config.assignees = []; + task.config.assigneesCompletionRule = + defaultAssigneesCompletionRule as ICompletionRule; } // ---------------------------