Workong on making the outcome editor completely data driven

This commit is contained in:
Colin Dawson 2026-03-12 17:29:52 +00:00
parent ecb306c698
commit f3798c6988
17 changed files with 83 additions and 128 deletions

View File

@ -0,0 +1,13 @@
{
"ApprovalVerdict": {
"Approved": "Approved",
"ApprovedWithComments": "Approved with Comments",
"None": "None",
"Pending": "Pending",
"Rejected": "Rejected",
"Reviewed": "Reviewed"
},
"DefaultOutcome": {
"Complete": "Complete"
}
}

View File

@ -1,8 +0,0 @@
{
"Approved": "Approved",
"ApprovedWithComments": "Approved with Comments",
"None": "None",
"Pending": "Pending",
"Rejected": "Rejected",
"Reviewed": "Reviewed"
}

View File

@ -1,64 +0,0 @@
import React, { useEffect, useState, useCallback } from "react";
import Select from "../common/Select";
import Option from "../common/option";
import { useTranslation } from "react-i18next";
import { Namespaces } from "../../i18n/i18n";
interface VerdictPickerProps {
name: string;
label: string;
error?: string;
value: string;
onChange?: (name: string, value: string) => void;
includeLabel?: boolean;
}
export default function VerdictPicker({
name,
label,
error,
value,
onChange,
includeLabel = true,
}: VerdictPickerProps) {
const [options, setOptions] = useState<Option[] | undefined>(undefined);
const { t } = useTranslation(Namespaces.Verdict);
useEffect(() => {
async function load() {
const opts: Option[] = [
{ _id: "None", name: t("None") },
{ _id: "Pending", name: t("Pending") },
{ _id: "ApprovedWithComments", name: t("ApprovedWithComments") },
{ _id: "Approved", name: t("Approved") },
{ _id: "Rejected", name: t("Rejected") },
{ _id: "Reviewed", name: t("Reviewed") },
];
setOptions(opts);
}
load();
}, [t]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const input = e.currentTarget;
if (onChange) onChange(input.name, input.value);
},
[onChange],
);
return (
<Select
name={name}
label={label}
error={error}
value={value}
options={options}
includeBlankFirstEntry={false}
onChange={handleChange}
includeLabel={includeLabel}
/>
);
}

View File

@ -14,7 +14,7 @@ export const Namespaces = {
Priority: "priority", Priority: "priority",
Raci: "raci", Raci: "raci",
TaskTypes: "taskTypes", TaskTypes: "taskTypes",
Verdict: "verdict", enumValues: "enumValues",
} as const; } as const;
export type Namespace = (typeof Namespaces)[keyof typeof Namespaces]; export type Namespace = (typeof Namespaces)[keyof typeof Namespaces];

View File

@ -237,6 +237,7 @@ export function defaultsAssignment(
export const assigneesOfIApprovalTaskAssigneeRegistryEntry: capabilityEditorRegistryEntry = export const assigneesOfIApprovalTaskAssigneeRegistryEntry: capabilityEditorRegistryEntry =
{ {
match: (cap) => cap === "IAssignees<ApprovalTaskAssignee>",
Editor: AssigneesOfIApprovalTaskAssigneeEditor, Editor: AssigneesOfIApprovalTaskAssigneeEditor,
DefaultsAssignment: defaultsAssignment, DefaultsAssignment: defaultsAssignment,
ValidationRunner: runValidation, ValidationRunner: runValidation,

View File

@ -229,6 +229,7 @@ export function defaultsAssignment(
export const assigneesOfITaskAssigneeRegistryEntry: capabilityEditorRegistryEntry = export const assigneesOfITaskAssigneeRegistryEntry: capabilityEditorRegistryEntry =
{ {
match: (cap) => cap === "IAssignees<TaskAssignee>",
Editor: AssigneesOfITaskAssigneeEditor, Editor: AssigneesOfITaskAssigneeEditor,
DefaultsAssignment: defaultsAssignment, DefaultsAssignment: defaultsAssignment,
ValidationRunner: runValidation, ValidationRunner: runValidation,

View File

@ -88,6 +88,7 @@ export function defaultsAssignment(
} }
export const budgetEditorRegistryEntry: capabilityEditorRegistryEntry = { export const budgetEditorRegistryEntry: capabilityEditorRegistryEntry = {
match: (cap) => cap === "IBudget",
Editor: BudgetEditor, Editor: BudgetEditor,
DefaultsAssignment: defaultsAssignment, DefaultsAssignment: defaultsAssignment,
ValidationRunner: runValidation, ValidationRunner: runValidation,

View File

@ -42,6 +42,7 @@ export function defaultsAssignment(
} }
export const bypassableEditorRegistryEntry: capabilityEditorRegistryEntry = { export const bypassableEditorRegistryEntry: capabilityEditorRegistryEntry = {
match: (cap) => cap === "IBypassable",
Editor: BypassableEditor, Editor: BypassableEditor,
DefaultsAssignment: defaultsAssignment, DefaultsAssignment: defaultsAssignment,
ValidationRunner: runValidation, ValidationRunner: runValidation,

View File

@ -1,10 +1,7 @@
import Button, { ButtonType } from "../../../../../components/common/Button"; import Button, { ButtonType } from "../../../../../components/common/Button";
import ErrorBlock from "../../../../../components/common/ErrorBlock"; import ErrorBlock from "../../../../../components/common/ErrorBlock";
import { InputType } from "../../../../../components/common/Input";
import VerdictPicker from "../../../../../components/pickers/VerdictPicker";
import ValidationErrorIcon from "../../../../../components/validationErrorIcon"; import ValidationErrorIcon from "../../../../../components/validationErrorIcon";
import { TaskDefinition } from "../../services/WorkflowTemplateService"; import { TaskDefinition } from "../../services/WorkflowTemplateService";
import { renderTaskField } from "../taskEditorHelpers";
import TaskPicker from "../TaskPicker"; import TaskPicker from "../TaskPicker";
import { import {
@ -12,20 +9,33 @@ import {
capabilityEditorRegistryEntry, capabilityEditorRegistryEntry,
defaultsContext, defaultsContext,
} from "../useCapabilityDefaults"; } from "../useCapabilityDefaults";
import { useTranslation } from "react-i18next";
import { Namespaces } from "../../../../../i18n/i18n";
import Select from "../../../../../components/common/Select";
import Option from "../../../../../components/common/option";
export interface IOutcomeAction { export interface IOutcomeAction {
verdict: string; outcome: string;
task: string | null; task: string | null;
} }
export const outcomeEditor: React.FC<CapabilityEditorProps> = (props) => { export const outcomeEditor: React.FC<CapabilityEditorProps> = (props) => {
const { task, tasks, onChange, fieldErrors } = props; const { task, tasks, onChange, fieldErrors, taskMetadata } = props;
const { t } = useTranslation(Namespaces.Common);
const { t: tEnum } = useTranslation(Namespaces.enumValues);
const getOutcomeId = (value: string) => value.split(".").pop() ?? value;
const outcomeOptions: Option[] = taskMetadata.outcomes.map((outcome) => ({
_id: getOutcomeId(outcome),
name: tEnum(outcome),
}));
function addOutcome() { function addOutcome() {
const clone = structuredClone(task); const clone = structuredClone(task);
const list = (clone.config.outcomeActions ?? []) as IOutcomeAction[]; const list = (clone.config.outcomeActions ?? []) as IOutcomeAction[];
list.push({ list.push({
verdict: "None", outcome: outcomeOptions[0]._id as string, // default to first outcome
task: null, task: null,
}); });
clone.config.outcomeActions = list; clone.config.outcomeActions = list;
@ -43,7 +53,7 @@ export const outcomeEditor: React.FC<CapabilityEditorProps> = (props) => {
function removeOutcome(index: number) { function removeOutcome(index: number) {
const clone = structuredClone(task); const clone = structuredClone(task);
const list = clone.config.outcomeActions ?? []; const list = (clone.config.outcomeActions ?? []) as IOutcomeAction[];
clone.config.outcomeActions = list.filter((_, i) => i !== index); clone.config.outcomeActions = list.filter((_, i) => i !== index);
onChange(clone); onChange(clone);
} }
@ -60,7 +70,7 @@ export const outcomeEditor: React.FC<CapabilityEditorProps> = (props) => {
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
<th>Verdict</th> <th>{t(taskMetadata.outcomeLabel)}</th>
<th>Task</th> <th>Task</th>
<th> <th>
<Button onClick={addOutcome} buttonType={ButtonType.secondary}> <Button onClick={addOutcome} buttonType={ButtonType.secondary}>
@ -76,24 +86,29 @@ export const outcomeEditor: React.FC<CapabilityEditorProps> = (props) => {
<ValidationErrorIcon <ValidationErrorIcon
visible={ visible={
!!fieldErrors?.[ !!fieldErrors?.[
`${guid}.outcomeActions[${index}].verdict` `${guid}.outcomeActions[${index}].outcome`
] || ] ||
!!fieldErrors?.[`${guid}.outcomeActions[${index}].task`] !!fieldErrors?.[`${guid}.outcomeActions[${index}].task`]
} }
/> />
</td> </td>
<td> <td>
<VerdictPicker <Select
includeLabel={false} name={"outcome"}
name="verdict" label={t(taskMetadata.outcomeLabel)}
label="Verdict"
value={outcomeAction.verdict}
error={ error={
fieldErrors?.[`${guid}.outcomeActions[${index}].verdict`] fieldErrors?.[`${guid}.outcomeActions[${index}].outcome`]
} }
onChange={(name: string, val: string) => value={getOutcomeId(outcomeAction.outcome)}
updateOutcome(index, { ...outcomeAction, verdict: val }) options={outcomeOptions}
includeBlankFirstEntry={false}
onChange={(e) =>
updateOutcome(index, {
...outcomeAction,
outcome: e.target.value,
})
} }
includeLabel={false}
/> />
</td> </td>
<td> <td>
@ -133,18 +148,6 @@ export const outcomeEditor: React.FC<CapabilityEditorProps> = (props) => {
.join("; ")} .join("; ")}
/> />
</div> </div>
{renderTaskField(
task,
onChange,
"overrideDefaultTaskProgression",
"Override Default Task Progression",
InputType.checkbox,
fieldErrors,
undefined, //placeholder
undefined, //maxLength
undefined, //extraProps
"Checking this will override the default task progression, allowing manual control over task flow. If unchecked, the default task progression will be followed if no outcome actions are triggered.", //title
)}
</> </>
); );
}; };
@ -170,23 +173,23 @@ const runValidation = (
} }
}); });
// --- Rule 2: Detect duplicates (verdict + task) --- // --- Rule 2: Detect duplicates (outcome + task) ---
const keyCounts = new Map<string, number>(); const keyCounts = new Map<string, number>();
outcomeActions.forEach((o) => { outcomeActions.forEach((o) => {
if (!o.task) return; // skip empties; already handled above if (!o.task) return; // skip empties; already handled above
const key = `${o.verdict}|${o.task}`; const key = `${o.outcome}|${o.task}`;
keyCounts.set(key, (keyCounts.get(key) ?? 0) + 1); keyCounts.set(key, (keyCounts.get(key) ?? 0) + 1);
}); });
// Any key with count > 1 is invalid // Any key with count > 1 is invalid
outcomeActions.forEach((o, index) => { outcomeActions.forEach((o, index) => {
if (!o.task) return; if (!o.task) return;
const key = `${o.verdict}|${o.task}`; const key = `${o.outcome}|${o.task}`;
if ((keyCounts.get(key) ?? 0) > 1) { if ((keyCounts.get(key) ?? 0) > 1) {
const base = `${guid}.outcomeActions[${index}]`; const base = `${guid}.outcomeActions[${index}]`;
errors[`${base}.verdict`] = "Duplicate verdict/task combination"; errors[`${base}.outcome`] = "Duplicate outcome/task combination";
errors[`${base}.task`] = "Duplicate verdict/task combination"; errors[`${base}.task`] = "Duplicate outcome/task combination";
} }
}); });
@ -204,6 +207,7 @@ export function defaultsAssignment(
export const outcomeOfApprovalVerdictRegistryEntry: capabilityEditorRegistryEntry = export const outcomeOfApprovalVerdictRegistryEntry: capabilityEditorRegistryEntry =
{ {
match: (cap) => cap.startsWith("IOutcome<"),
Editor: outcomeEditor, Editor: outcomeEditor,
DefaultsAssignment: defaultsAssignment, DefaultsAssignment: defaultsAssignment,
ValidationRunner: runValidation, ValidationRunner: runValidation,

View File

@ -117,6 +117,7 @@ export function createStageEditorRegistryEntry(
taskType: string, taskType: string,
): capabilityEditorRegistryEntry { ): capabilityEditorRegistryEntry {
return { return {
match: (cap) => cap === `IStage<${taskType}Attribute>`,
Editor: createStageEditor(taskType), Editor: createStageEditor(taskType),
DefaultsAssignment: defaultsAssignment, DefaultsAssignment: defaultsAssignment,
ValidationRunner: createValidationRunner(taskType), ValidationRunner: createValidationRunner(taskType),

View File

@ -42,6 +42,7 @@ export function defaultsAssignment(
} }
export const tagsEditorRegistryEntry: capabilityEditorRegistryEntry = { export const tagsEditorRegistryEntry: capabilityEditorRegistryEntry = {
match: (cap) => cap === "ITags",
Editor: TagsEditor, Editor: TagsEditor,
DefaultsAssignment: defaultsAssignment, DefaultsAssignment: defaultsAssignment,
ValidationRunner: runValidation, ValidationRunner: runValidation,

View File

@ -140,6 +140,7 @@ export function defaultsAssignment(
} }
export const taskCoreEditorRegistryEntry: capabilityEditorRegistryEntry = { export const taskCoreEditorRegistryEntry: capabilityEditorRegistryEntry = {
match: (cap) => cap === "ITask",
Editor: TaskCoreEditor, Editor: TaskCoreEditor,
DefaultsAssignment: defaultsAssignment, DefaultsAssignment: defaultsAssignment,
ValidationRunner: runValidation, ValidationRunner: runValidation,

View File

@ -7,7 +7,7 @@ interface TaskPickerProps {
name: string; name: string;
label: string; label: string;
error?: string; error?: string;
value?: TaskDefinition; // taskId value?: string | null;
onChange?: (name: string, value: string) => void; onChange?: (name: string, value: string) => void;
tasks: TaskDefinition[]; tasks: TaskDefinition[];
includeLabel?: boolean; includeLabel?: boolean;

View File

@ -107,7 +107,8 @@ const TaskEditorComponent: React.FC<TaskEditorProps> = ({
return ( return (
<div> <div>
{taskMeta?.capabilities.map((capability) => { {taskMeta?.capabilities.map((capability) => {
const entry = capabilityEditorRegistry[capability]; const entry = capabilityEditorRegistry.find((r) => r.match(capability));
if (!entry) { if (!entry) {
console.log(`No editor entry found for capability ${capability}.`); console.log(`No editor entry found for capability ${capability}.`);
return null; return null;
@ -124,6 +125,7 @@ const TaskEditorComponent: React.FC<TaskEditorProps> = ({
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
onValidateTask={onValidate} onValidateTask={onValidate}
onTaskAdded={onTaskAdded} onTaskAdded={onTaskAdded}
taskMetadata={taskMeta}
/> />
); );
})} })}

View File

@ -2,26 +2,23 @@ import { tagsEditorRegistryEntry } from "./CapabilityEditors/TagsEditor";
import { assigneesOfITaskAssigneeRegistryEntry } from "./CapabilityEditors/AssigneesOfITaskAssigneeEditor"; import { assigneesOfITaskAssigneeRegistryEntry } from "./CapabilityEditors/AssigneesOfITaskAssigneeEditor";
import { taskCoreEditorRegistryEntry } from "./CapabilityEditors/TaskCoreEditor"; import { taskCoreEditorRegistryEntry } from "./CapabilityEditors/TaskCoreEditor";
import { capabilityEditorRegistryEntry } from "./useCapabilityDefaults"; import { capabilityEditorRegistryEntry } from "./useCapabilityDefaults";
import { outcomeOfApprovalVerdictRegistryEntry } from "./CapabilityEditors/OutcomeOfApprovalVerdictRegistryEntry"; import { outcomeOfApprovalVerdictRegistryEntry } from "./CapabilityEditors/OutcomeEditor";
import { budgetEditorRegistryEntry } from "./CapabilityEditors/BudgetEditorRegistryEntry"; import { budgetEditorRegistryEntry } from "./CapabilityEditors/BudgetEditorRegistryEntry";
import { bypassableEditorRegistryEntry } from "./CapabilityEditors/BypassableEditor"; import { bypassableEditorRegistryEntry } from "./CapabilityEditors/BypassableEditor";
import { createStageEditorRegistryEntry } from "./CapabilityEditors/StageEditor"; import { createStageEditorRegistryEntry } from "./CapabilityEditors/StageEditor";
import { assigneesOfIApprovalTaskAssigneeRegistryEntry } from "./CapabilityEditors/AssigneesOfIApprovalTaskAssigneeEditor"; import { assigneesOfIApprovalTaskAssigneeRegistryEntry } from "./CapabilityEditors/AssigneesOfIApprovalTaskAssigneeEditor";
export const capabilityEditorRegistry: Record< type CapabilityEditorRegistry = Array<capabilityEditorRegistryEntry>;
string,
capabilityEditorRegistryEntry export const capabilityEditorRegistry: CapabilityEditorRegistry = [
> = { taskCoreEditorRegistryEntry,
ITask: taskCoreEditorRegistryEntry, tagsEditorRegistryEntry,
ITags: tagsEditorRegistryEntry, budgetEditorRegistryEntry,
IBudget: budgetEditorRegistryEntry, assigneesOfITaskAssigneeRegistryEntry,
"IAssignees<TaskAssignee>": assigneesOfITaskAssigneeRegistryEntry,
"IAssignees<ApprovalTaskAssignee>":
assigneesOfIApprovalTaskAssigneeRegistryEntry, assigneesOfIApprovalTaskAssigneeRegistryEntry,
"IOutcome<ApprovalVerdict>": outcomeOfApprovalVerdictRegistryEntry, outcomeOfApprovalVerdictRegistryEntry,
// IFormTemplate: null, //ToDo implement this // IFormTemplate: null, //ToDo implement this
IBypassable: bypassableEditorRegistryEntry, bypassableEditorRegistryEntry,
"IStage<GeneralTaskAttribute>": createStageEditorRegistryEntry("GeneralTask"), createStageEditorRegistryEntry("GeneralTask"),
"IStage<ApprovalTaskAttribute>":
createStageEditorRegistryEntry("ApprovalTask"), createStageEditorRegistryEntry("ApprovalTask"),
}; ];

View File

@ -20,6 +20,7 @@ export interface CapabilityEditorProps {
onValidateTask?: (taskId: string, isValid: boolean) => void; onValidateTask?: (taskId: string, isValid: boolean) => void;
onTaskAdded?: (taskGuid: string) => void; onTaskAdded?: (taskGuid: string) => void;
fieldErrors: Record<string, string>; fieldErrors: Record<string, string>;
taskMetadata: TaskMetadata;
} }
export interface defaultsContext { export interface defaultsContext {
@ -28,6 +29,7 @@ export interface defaultsContext {
} }
export interface capabilityEditorRegistryEntry { export interface capabilityEditorRegistryEntry {
match: (capability: string) => boolean;
Editor: React.FC<any>; Editor: React.FC<any>;
DefaultsAssignment?: ( DefaultsAssignment?: (
task: TaskDefinition, task: TaskDefinition,
@ -51,7 +53,7 @@ export async function validateTask(
const errors: Record<string, string> = {}; const errors: Record<string, string> = {};
for (const capability of taskMeta?.capabilities ?? []) { for (const capability of taskMeta?.capabilities ?? []) {
const entry = capabilityEditorRegistry[capability]; const entry = capabilityEditorRegistry.find((r) => r.match(capability));
if (!entry?.ValidationRunner) { if (!entry?.ValidationRunner) {
continue; continue;
@ -72,7 +74,7 @@ export function useCapabilityDefaults(taskMetadata: TaskMetadata[]) {
const runDefaults = React.useCallback( const runDefaults = React.useCallback(
(capability: string, task: TaskDefinition, tasks: TaskDefinition[]) => { (capability: string, task: TaskDefinition, tasks: TaskDefinition[]) => {
const entry = capabilityEditorRegistry[capability]; const entry = capabilityEditorRegistry.find((r) => r.match(capability));
if (!entry?.DefaultsAssignment) return; if (!entry?.DefaultsAssignment) return;
entry.DefaultsAssignment(task, tasks, { entry.DefaultsAssignment(task, tasks, {

View File

@ -47,6 +47,8 @@ export interface TaskMetadata {
taskType: string; taskType: string;
displayName: string; displayName: string;
capabilities: string[]; capabilities: string[];
outcomeLabel: string;
outcomes: string[];
} }
export async function getTemplates( export async function getTemplates(