Added capability editor for outcome completion rules
This commit is contained in:
parent
4c8ff7378b
commit
4ed3545e42
@ -4,7 +4,10 @@ import {
|
|||||||
GeneralIdRef,
|
GeneralIdRef,
|
||||||
MakeGeneralIdRef,
|
MakeGeneralIdRef,
|
||||||
} from "../../../../../utils/GeneralIdRef";
|
} from "../../../../../utils/GeneralIdRef";
|
||||||
import { TaskDefinition } from "../../services/WorkflowTemplateService";
|
import {
|
||||||
|
TaskDefinition,
|
||||||
|
TaskMetadata,
|
||||||
|
} from "../../services/WorkflowTemplateService";
|
||||||
import {
|
import {
|
||||||
CapabilityEditorProps,
|
CapabilityEditorProps,
|
||||||
capabilityEditorRegistryEntry,
|
capabilityEditorRegistryEntry,
|
||||||
@ -31,6 +34,195 @@ export interface IAssigneesCapability {
|
|||||||
assignees: ITaskAssignee[];
|
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
|
// Editor Component
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
@ -38,12 +230,17 @@ export interface IAssigneesCapability {
|
|||||||
export const AssigneesOfITaskAssigneeEditor: React.FC<CapabilityEditorProps> = (
|
export const AssigneesOfITaskAssigneeEditor: React.FC<CapabilityEditorProps> = (
|
||||||
props,
|
props,
|
||||||
) => {
|
) => {
|
||||||
const { task, onChange, fieldErrors } = props;
|
const { task, onChange, fieldErrors, taskMetadata } = props;
|
||||||
const assignees = (task.config.assignees ?? []) as ITaskAssignee[];
|
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) {
|
function updateAssignee(index: number, updated: ITaskAssignee) {
|
||||||
const clone = structuredClone(task);
|
const clone = structuredClone(task);
|
||||||
const list = clone.config.assignees ?? [];
|
const list = (clone.config.assignees ?? []) as ITaskAssignee[];
|
||||||
list[index] = updated;
|
list[index] = updated;
|
||||||
clone.config.assignees = list;
|
clone.config.assignees = list;
|
||||||
onChange(clone);
|
onChange(clone);
|
||||||
@ -51,7 +248,7 @@ export const AssigneesOfITaskAssigneeEditor: React.FC<CapabilityEditorProps> = (
|
|||||||
|
|
||||||
function addAssignee() {
|
function addAssignee() {
|
||||||
const clone = structuredClone(task);
|
const clone = structuredClone(task);
|
||||||
const list = clone.config.assignees ?? [];
|
const list = (clone.config.assignees ?? []) as ITaskAssignee[];
|
||||||
list.push({
|
list.push({
|
||||||
role: null,
|
role: null,
|
||||||
user: null,
|
user: null,
|
||||||
@ -63,11 +260,28 @@ export const AssigneesOfITaskAssigneeEditor: React.FC<CapabilityEditorProps> = (
|
|||||||
|
|
||||||
function removeAssignee(index: number) {
|
function removeAssignee(index: number) {
|
||||||
const clone = structuredClone(task);
|
const clone = structuredClone(task);
|
||||||
const list = clone.config.assignees ?? [];
|
const list = (clone.config.assignees ?? []) as ITaskAssignee[];
|
||||||
clone.config.assignees = list.filter((_, i) => i !== index);
|
clone.config.assignees = list.filter((_, i) => i !== index);
|
||||||
onChange(clone);
|
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 guid = task.config.guid as string;
|
||||||
|
|
||||||
const domain = MakeGeneralIdRef(getCurrentUser()?.domainid);
|
const domain = MakeGeneralIdRef(getCurrentUser()?.domainid);
|
||||||
@ -153,13 +367,22 @@ export const AssigneesOfITaskAssigneeEditor: React.FC<CapabilityEditorProps> = (
|
|||||||
</table>
|
</table>
|
||||||
<ErrorBlock
|
<ErrorBlock
|
||||||
error={Object.values(fieldErrors ?? {})
|
error={Object.values(fieldErrors ?? {})
|
||||||
.filter((_, i) =>
|
.filter(() =>
|
||||||
Object.keys(fieldErrors ?? {}).some(
|
Object.keys(fieldErrors ?? {}).some(
|
||||||
(key) => key === `${guid}.assignees`,
|
(key) => key === `${guid}.assignees`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.join("; ")}
|
.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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -171,43 +394,124 @@ export const AssigneesOfITaskAssigneeEditor: React.FC<CapabilityEditorProps> = (
|
|||||||
const runValidation = (
|
const runValidation = (
|
||||||
task: TaskDefinition,
|
task: TaskDefinition,
|
||||||
tasks: TaskDefinition[],
|
tasks: TaskDefinition[],
|
||||||
|
tasksMetadata: TaskMetadata[] = [],
|
||||||
): Promise<Record<string, string>> => {
|
): Promise<Record<string, string>> => {
|
||||||
const errors: Record<string, string> = {};
|
const errors: Record<string, string> = {};
|
||||||
const guid = task.config.guid as string;
|
const guid = task.config.guid as string;
|
||||||
const assignees = task.config.assignees as ITaskAssignee[] | undefined;
|
const assignees = task.config.assignees as ITaskAssignee[] | undefined;
|
||||||
|
const assigneesCompletionRule = task.config.assigneesCompletionRule as
|
||||||
|
| ICompletionRule
|
||||||
|
| undefined;
|
||||||
|
|
||||||
if (!assignees || assignees.length === 0) {
|
const getOutcomeId = (value: string) => value.split(".").pop() ?? value;
|
||||||
errors[`${guid}.assignees`] = "At least one assignee is required.";
|
const matchingTaskMetadata = tasksMetadata.find(
|
||||||
return Promise.resolve(errors);
|
(m) => m.taskType === task.type,
|
||||||
}
|
);
|
||||||
|
const validOutcomes = new Set<string>(
|
||||||
|
((matchingTaskMetadata?.outcomes as string[] | undefined) ?? []).map(
|
||||||
|
getOutcomeId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
assignees.forEach((a, i) => {
|
const validateCompletionRule = (
|
||||||
const noUserSelected = !a.user || a.user?.id === BigInt(0);
|
rule: ICompletionRule | undefined,
|
||||||
const noRoleSelected = !a.role || a.role?.id === BigInt(0);
|
path: string,
|
||||||
|
) => {
|
||||||
|
if (!rule) {
|
||||||
|
errors[path] = "Assignees completion rule is required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!noUserSelected && !noRoleSelected) {
|
if (!rule.operator?.trim()) {
|
||||||
errors[`${guid}.assignees[${i}].user`] =
|
errors[`${path}.operator`] = "Operator is required.";
|
||||||
"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) {
|
if (
|
||||||
errors[`${guid}.assignees[${i}].role`] = "A role must be selected.";
|
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) {
|
if (rule.requiredOutcome !== undefined && validOutcomes.size > 0) {
|
||||||
errors[`${guid}.assignees[${i}].raci`] = "RACI is required.";
|
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,
|
ctx: defaultsContext,
|
||||||
) {
|
) {
|
||||||
task.config.assignees = [{ raci: "Responsible" } as ITaskAssignee];
|
task.config.assignees = [{ raci: "Responsible" } as ITaskAssignee];
|
||||||
//task.config.assignees = [];
|
task.config.assigneesCompletionRule =
|
||||||
|
defaultAssigneesCompletionRule as ICompletionRule;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user