Added capability editor for outcome completion rules
This commit is contained in:
parent
4c8ff7378b
commit
4ed3545e42
@ -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,43 +394,124 @@ 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;
|
||||
|
||||
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<string>(
|
||||
((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;
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
|
||||
Loading…
Reference in New Issue
Block a user