Added capability editor for outcome completion rules

This commit is contained in:
Colin Dawson 2026-03-16 15:33:02 +00:00
parent 4c8ff7378b
commit 4ed3545e42

View File

@ -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<TOutcome = string> {
operator: string;
children?: ICompletionRule<TOutcome>[];
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<CompletionRuleEditorProps> = ({
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 (
<div className="border rounded p-3 mb-2">
<div className="d-flex justify-content-between align-items-center gap-2">
<div className="form-group mb-2 flex-fill">
<label htmlFor={`rule-operator-${depth}`}>Operator</label>
<select
id={`rule-operator-${depth}`}
className="form-control"
value={rule.operator}
onChange={(e) => onChange({ ...rule, operator: e.target.value })}
>
{completionRuleOperators.map((op) => (
<option key={op} value={op}>
{op}
</option>
))}
</select>
</div>
{onRemove && (
<div className="form-group mb-2">
<Button onClick={onRemove} buttonType={ButtonType.secondary}>
Remove Rule
</Button>
</div>
)}
</div>
{showRaciFilter && (
<div className="form-group mb-2">
<label htmlFor={`rule-raci-${depth}`}>RACI Filter</label>
<select
id={`rule-raci-${depth}`}
className="form-control"
value={rule.raciFilter ?? ""}
onChange={(e) =>
onChange({
...rule,
raciFilter: e.target.value || undefined,
})
}
>
<option value="" />
{raciOptions.map((raci) => (
<option key={raci} value={raci}>
{raci}
</option>
))}
</select>
</div>
)}
{showRequiredOutcome && outcomeOptions.length > 0 && (
<div className="form-group mb-2">
<label htmlFor={`rule-outcome-${depth}`}>Required Outcome</label>
<select
id={`rule-outcome-${depth}`}
className="form-control"
value={rule.requiredOutcome ?? ""}
onChange={(e) =>
onChange({
...rule,
requiredOutcome: e.target.value || undefined,
})
}
>
<option value="" />
{outcomeOptions.map((outcome) => (
<option key={outcome} value={outcome}>
{outcome}
</option>
))}
</select>
</div>
)}
{showThreshold && (
<div className="form-group mb-2">
<label htmlFor={`rule-threshold-${depth}`}>Threshold</label>
<input
id={`rule-threshold-${depth}`}
className="form-control"
type="number"
min={1}
value={rule.threshold ?? ""}
onChange={(e) => {
const value = e.target.value;
onChange({
...rule,
threshold: value ? Number(value) : undefined,
});
}}
/>
</div>
)}
<div className="mt-2">
{children.map((child, index) => (
<CompletionRuleEditor
key={`${depth}-${index}`}
rule={child}
onChange={(updated) => updateChild(index, updated)}
onRemove={() => removeChild(index)}
outcomeOptions={outcomeOptions}
depth={depth + 1}
/>
))}
<Button onClick={addChild} buttonType={ButtonType.secondary}>
Add Child Rule
</Button>
</div>
</div>
);
};
// ---------------------------
// Editor Component
// ---------------------------
@ -38,12 +230,17 @@ export interface IAssigneesCapability {
export const AssigneesOfITaskAssigneeEditor: React.FC<CapabilityEditorProps> = (
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<CapabilityEditorProps> = (
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<CapabilityEditorProps> = (
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<CapabilityEditorProps> = (
</table>
<ErrorBlock
error={Object.values(fieldErrors ?? {})
.filter((_, i) =>
.filter(() =>
Object.keys(fieldErrors ?? {}).some(
(key) => key === `${guid}.assignees`,
),
)
.join("; ")}
/>
<div className="form-group mt-3">
<label className="label">Assignees Completion Rule</label>
<CompletionRuleEditor
rule={assigneesCompletionRule ?? defaultAssigneesCompletionRule}
onChange={updateCompletionRule}
outcomeOptions={outcomeOptions}
/>
<ErrorBlock error={fieldErrors?.[`${guid}.assigneesCompletionRule`]} />
</div>
</div>
);
};
@ -171,16 +394,91 @@ export const AssigneesOfITaskAssigneeEditor: React.FC<CapabilityEditorProps> = (
const runValidation = (
task: TaskDefinition,
tasks: TaskDefinition[],
tasksMetadata: TaskMetadata[] = [],
): Promise<Record<string, string>> => {
const errors: Record<string, string> = {};
const guid = task.config.guid as string;
const assignees = task.config.assignees as ITaskAssignee[] | undefined;
const assigneesCompletionRule = task.config.assigneesCompletionRule as
| ICompletionRule
| undefined;
const getOutcomeId = (value: string) => value.split(".").pop() ?? value;
const matchingTaskMetadata = tasksMetadata.find(
(m) => m.taskType === task.type,
);
const validOutcomes = new Set<string>(
((matchingTaskMetadata?.outcomes as string[] | undefined) ?? []).map(
getOutcomeId,
),
);
const validateCompletionRule = (
rule: ICompletionRule | undefined,
path: string,
) => {
if (!rule) {
errors[path] = "Assignees completion rule is required.";
return;
}
if (!rule.operator?.trim()) {
errors[`${path}.operator`] = "Operator is required.";
}
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 (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.";
}
}
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.";
return Promise.resolve(errors);
}
} else {
assignees.forEach((a, i) => {
const noUserSelected = !a.user || a.user?.id === BigInt(0);
const noRoleSelected = !a.role || a.role?.id === BigInt(0);
@ -206,8 +504,14 @@ const runValidation = (
errors[`${guid}.assignees[${i}].raci`] = "RACI is required.";
}
});
}
return errors;
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;
}
// ---------------------------