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",
Raci: "raci",
TaskTypes: "taskTypes",
Verdict: "verdict",
enumValues: "enumValues",
} as const;
export type Namespace = (typeof Namespaces)[keyof typeof Namespaces];

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,7 @@
import Button, { ButtonType } from "../../../../../components/common/Button";
import ErrorBlock from "../../../../../components/common/ErrorBlock";
import { InputType } from "../../../../../components/common/Input";
import VerdictPicker from "../../../../../components/pickers/VerdictPicker";
import ValidationErrorIcon from "../../../../../components/validationErrorIcon";
import { TaskDefinition } from "../../services/WorkflowTemplateService";
import { renderTaskField } from "../taskEditorHelpers";
import TaskPicker from "../TaskPicker";
import {
@ -12,20 +9,33 @@ import {
capabilityEditorRegistryEntry,
defaultsContext,
} 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 {
verdict: string;
outcome: string;
task: string | null;
}
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() {
const clone = structuredClone(task);
const list = (clone.config.outcomeActions ?? []) as IOutcomeAction[];
list.push({
verdict: "None",
outcome: outcomeOptions[0]._id as string, // default to first outcome
task: null,
});
clone.config.outcomeActions = list;
@ -43,7 +53,7 @@ export const outcomeEditor: React.FC<CapabilityEditorProps> = (props) => {
function removeOutcome(index: number) {
const clone = structuredClone(task);
const list = clone.config.outcomeActions ?? [];
const list = (clone.config.outcomeActions ?? []) as IOutcomeAction[];
clone.config.outcomeActions = list.filter((_, i) => i !== index);
onChange(clone);
}
@ -60,7 +70,7 @@ export const outcomeEditor: React.FC<CapabilityEditorProps> = (props) => {
<thead>
<tr>
<th></th>
<th>Verdict</th>
<th>{t(taskMetadata.outcomeLabel)}</th>
<th>Task</th>
<th>
<Button onClick={addOutcome} buttonType={ButtonType.secondary}>
@ -76,24 +86,29 @@ export const outcomeEditor: React.FC<CapabilityEditorProps> = (props) => {
<ValidationErrorIcon
visible={
!!fieldErrors?.[
`${guid}.outcomeActions[${index}].verdict`
`${guid}.outcomeActions[${index}].outcome`
] ||
!!fieldErrors?.[`${guid}.outcomeActions[${index}].task`]
}
/>
</td>
<td>
<VerdictPicker
includeLabel={false}
name="verdict"
label="Verdict"
value={outcomeAction.verdict}
<Select
name={"outcome"}
label={t(taskMetadata.outcomeLabel)}
error={
fieldErrors?.[`${guid}.outcomeActions[${index}].verdict`]
fieldErrors?.[`${guid}.outcomeActions[${index}].outcome`]
}
onChange={(name: string, val: string) =>
updateOutcome(index, { ...outcomeAction, verdict: val })
value={getOutcomeId(outcomeAction.outcome)}
options={outcomeOptions}
includeBlankFirstEntry={false}
onChange={(e) =>
updateOutcome(index, {
...outcomeAction,
outcome: e.target.value,
})
}
includeLabel={false}
/>
</td>
<td>
@ -133,18 +148,6 @@ export const outcomeEditor: React.FC<CapabilityEditorProps> = (props) => {
.join("; ")}
/>
</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>();
outcomeActions.forEach((o) => {
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);
});
// Any key with count > 1 is invalid
outcomeActions.forEach((o, index) => {
if (!o.task) return;
const key = `${o.verdict}|${o.task}`;
const key = `${o.outcome}|${o.task}`;
if ((keyCounts.get(key) ?? 0) > 1) {
const base = `${guid}.outcomeActions[${index}]`;
errors[`${base}.verdict`] = "Duplicate verdict/task combination";
errors[`${base}.task`] = "Duplicate verdict/task combination";
errors[`${base}.outcome`] = "Duplicate outcome/task combination";
errors[`${base}.task`] = "Duplicate outcome/task combination";
}
});
@ -204,6 +207,7 @@ export function defaultsAssignment(
export const outcomeOfApprovalVerdictRegistryEntry: capabilityEditorRegistryEntry =
{
match: (cap) => cap.startsWith("IOutcome<"),
Editor: outcomeEditor,
DefaultsAssignment: defaultsAssignment,
ValidationRunner: runValidation,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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