Workong on making the outcome editor completely data driven
This commit is contained in:
parent
ecb306c698
commit
f3798c6988
13
public/locales/en/enumValues.json
Normal file
13
public/locales/en/enumValues.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"ApprovalVerdict": {
|
||||
"Approved": "Approved",
|
||||
"ApprovedWithComments": "Approved with Comments",
|
||||
"None": "None",
|
||||
"Pending": "Pending",
|
||||
"Rejected": "Rejected",
|
||||
"Reviewed": "Reviewed"
|
||||
},
|
||||
"DefaultOutcome": {
|
||||
"Complete": "Complete"
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"Approved": "Approved",
|
||||
"ApprovedWithComments": "Approved with Comments",
|
||||
"None": "None",
|
||||
"Pending": "Pending",
|
||||
"Rejected": "Rejected",
|
||||
"Reviewed": "Reviewed"
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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];
|
||||
|
||||
@ -237,6 +237,7 @@ export function defaultsAssignment(
|
||||
|
||||
export const assigneesOfIApprovalTaskAssigneeRegistryEntry: capabilityEditorRegistryEntry =
|
||||
{
|
||||
match: (cap) => cap === "IAssignees<ApprovalTaskAssignee>",
|
||||
Editor: AssigneesOfIApprovalTaskAssigneeEditor,
|
||||
DefaultsAssignment: defaultsAssignment,
|
||||
ValidationRunner: runValidation,
|
||||
|
||||
@ -229,6 +229,7 @@ export function defaultsAssignment(
|
||||
|
||||
export const assigneesOfITaskAssigneeRegistryEntry: capabilityEditorRegistryEntry =
|
||||
{
|
||||
match: (cap) => cap === "IAssignees<TaskAssignee>",
|
||||
Editor: AssigneesOfITaskAssigneeEditor,
|
||||
DefaultsAssignment: defaultsAssignment,
|
||||
ValidationRunner: runValidation,
|
||||
|
||||
@ -88,6 +88,7 @@ export function defaultsAssignment(
|
||||
}
|
||||
|
||||
export const budgetEditorRegistryEntry: capabilityEditorRegistryEntry = {
|
||||
match: (cap) => cap === "IBudget",
|
||||
Editor: BudgetEditor,
|
||||
DefaultsAssignment: defaultsAssignment,
|
||||
ValidationRunner: runValidation,
|
||||
|
||||
@ -42,6 +42,7 @@ export function defaultsAssignment(
|
||||
}
|
||||
|
||||
export const bypassableEditorRegistryEntry: capabilityEditorRegistryEntry = {
|
||||
match: (cap) => cap === "IBypassable",
|
||||
Editor: BypassableEditor,
|
||||
DefaultsAssignment: defaultsAssignment,
|
||||
ValidationRunner: runValidation,
|
||||
|
||||
@ -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,
|
||||
@ -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),
|
||||
|
||||
@ -42,6 +42,7 @@ export function defaultsAssignment(
|
||||
}
|
||||
|
||||
export const tagsEditorRegistryEntry: capabilityEditorRegistryEntry = {
|
||||
match: (cap) => cap === "ITags",
|
||||
Editor: TagsEditor,
|
||||
DefaultsAssignment: defaultsAssignment,
|
||||
ValidationRunner: runValidation,
|
||||
|
||||
@ -140,6 +140,7 @@ export function defaultsAssignment(
|
||||
}
|
||||
|
||||
export const taskCoreEditorRegistryEntry: capabilityEditorRegistryEntry = {
|
||||
match: (cap) => cap === "ITask",
|
||||
Editor: TaskCoreEditor,
|
||||
DefaultsAssignment: defaultsAssignment,
|
||||
ValidationRunner: runValidation,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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"),
|
||||
];
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -47,6 +47,8 @@ export interface TaskMetadata {
|
||||
taskType: string;
|
||||
displayName: string;
|
||||
capabilities: string[];
|
||||
outcomeLabel: string;
|
||||
outcomes: string[];
|
||||
}
|
||||
|
||||
export async function getTemplates(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user