Added the approval IOutcome<verdict> capability to the codebase.

This commit is contained in:
Colin Dawson 2026-02-22 23:33:30 +00:00
parent 5a76155e8d
commit 0426099a59
13 changed files with 339 additions and 0 deletions

View File

@ -0,0 +1,8 @@
{
"Approved": "Утврђено",
"ApprovedWithComments": "Усвојено уз коментаре",
"None": "Ниједан.",
"Pending": "У очекивању",
"Rejected": "Одбијено",
"Reviewed": "Прегледано."
}

View File

@ -0,0 +1,8 @@
{
"Approved": "Schváleno",
"ApprovedWithComments": "Schváleno s poznámkami",
"None": "",
"Pending": "Čeká se na další kroky.",
"Rejected": "Odmítnuto.",
"Reviewed": "Přezkoumáno"
}

View File

@ -0,0 +1,8 @@
{
"Approved": "Genehmigt",
"ApprovedWithComments": "Genehmigt mit Anmerkungen",
"None": "Nichts.",
"Pending": "Wird noch bearbeitet.",
"Rejected": "Abgelehnt",
"Reviewed": "Überprüft."
}

View File

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

View File

@ -0,0 +1,8 @@
{
"Approved": "Aprobado",
"ApprovedWithComments": "Aprobado con comentarios.",
"None": "Ninguno",
"Pending": "Pendiente",
"Rejected": "Rechazado",
"Reviewed": "Revisado"
}

View File

@ -0,0 +1,8 @@
{
"Approved": "Approuvé",
"ApprovedWithComments": "Approuvé avec des observations.",
"None": "Aucun",
"Pending": "En attente",
"Rejected": "Rejeté",
"Reviewed": "Revisé"
}

View File

@ -0,0 +1,8 @@
{
"Approved": "승인됨",
"ApprovedWithComments": "의견을 반영하여 승인되었습니다.",
"None": "",
"Pending": "대기 중",
"Rejected": "거절당했습니다.",
"Reviewed": "리뷰 완료"
}

View File

@ -0,0 +1,8 @@
{
"Approved": "Goedkeurd",
"ApprovedWithComments": "Goedkeurd met opmerkingen",
"None": "Niet van toepassing.",
"Pending": "In afwachting",
"Rejected": "Afgekeurd",
"Reviewed": "Beoordeeld."
}

View File

@ -0,0 +1,8 @@
{
"Approved": "Zatwierdzone",
"ApprovedWithComments": "Zatwierdzone z komentarzami.",
"None": "Nie ma nic.",
"Pending": "W oczekiwaniu.",
"Rejected": "Odrzucono.",
"Reviewed": "Przegłoszono."
}

View File

@ -0,0 +1,8 @@
{
"Approved": "Aprovado",
"ApprovedWithComments": "Aprovado com comentários.",
"None": "Nenhum.",
"Pending": "A aguardar",
"Rejected": "Rejeitado",
"Reviewed": "Revisado"
}

View File

@ -0,0 +1,7 @@
{
"Approved": "Схвалено",
"ApprovedWithComments": "Схвалено з коментарями",
"Pending": "У підготовці.",
"Rejected": "Відхилено",
"Reviewed": "Оглянуто."
}

View File

@ -0,0 +1,64 @@
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

@ -0,0 +1,188 @@
import Button, { ButtonType } from "../../../../../components/common/Button";
import ErrorBlock from "../../../../../components/common/ErrorBlock";
import VerdictPicker from "../../../../../components/pickers/VerdictPicker";
import ValidationErrorIcon from "../../../../../components/validationErrorIcon";
import { TaskDefinition } from "../../services/WorkflowTemplateService";
import TaskPicker from "../TaskPicker";
import {
CapabilityEditorProps,
capabilityEditorRegistryEntry,
defaultsContext,
} from "../useCapabilityDefaults";
export interface IOutcomeAction {
verdict: string;
task: string | null;
}
export const outcomeEditor: React.FC<CapabilityEditorProps> = (props) => {
const { task, tasks, onChange, fieldErrors } = props;
function addOutcome() {
const clone = structuredClone(task);
const list = (clone.config.outcomeActions ?? []) as IOutcomeAction[];
list.push({
verdict: "None",
task: null,
});
clone.config.outcomeActions = list;
onChange(clone);
}
function updateOutcome(index: number, updated: IOutcomeAction) {
const clone = structuredClone(task);
const list = (clone.config.outcomeActions ?? []) as IOutcomeAction[];
list[index] = updated;
clone.config.outcomeActions = list;
onChange(clone);
}
function removeOutcome(index: number) {
const clone = structuredClone(task);
const list = clone.config.outcomeActions ?? [];
clone.config.outcomeActions = list.filter((_, i) => i !== index);
onChange(clone);
}
const outcomeActions = (task.config.outcomeActions ?? []) as IOutcomeAction[];
const guid = task.config.guid as string;
const otherTasks = tasks.filter((t) => (t.config.guid as string) !== guid);
return (
<div>
<table>
<thead>
<tr>
<th></th>
<th>Verdict</th>
<th>Task</th>
<th>
<Button onClick={addOutcome} buttonType={ButtonType.secondary}>
Add Outcome
</Button>
</th>
</tr>
</thead>
<tbody>
{outcomeActions.map((outcomeAction, index) => (
<tr key={index} className="align-top">
<td className="form-group">
<ValidationErrorIcon
visible={
!!fieldErrors?.[
`${guid}.outcomeActions[${index}].verdict`
] ||
!!fieldErrors?.[`${guid}.outcomeActions[${index}].task`]
}
/>
</td>
<td>
<VerdictPicker
includeLabel={false}
name="verdict"
label="Verdict"
value={outcomeAction.verdict}
error={
fieldErrors?.[`${guid}.outcomeActions[${index}].verdict`]
}
onChange={(name: string, val: string) =>
updateOutcome(index, { ...outcomeAction, verdict: val })
}
/>
</td>
<td>
<TaskPicker
includeLabel={false}
name="task"
label="Task"
value={(outcomeAction.task as string) ?? null}
tasks={otherTasks}
error={fieldErrors?.[`${guid}.outcomeActions[${index}].task`]}
onChange={(name, val) =>
updateOutcome(index, { ...outcomeAction, task: val })
}
/>
</td>
<td className="form-group">
<Button
onClick={() => removeOutcome(index)}
buttonType={ButtonType.secondary}
>
Remove
</Button>
</td>
</tr>
))}
</tbody>
</table>
<ErrorBlock
error={Object.values(fieldErrors ?? {})
.filter((_, i) =>
Object.keys(fieldErrors ?? {}).some(
(key) => key === `${guid}.outcomeActions[${i}]`,
),
)
.join("; ")}
/>
</div>
);
};
const runValidation = (
task: TaskDefinition,
tasks: TaskDefinition[],
): Record<string, string> => {
const errors: Record<string, string> = {};
const guid = task.config.guid as string;
const outcomeActions =
(task.config.outcomeActions as IOutcomeAction[]) ?? undefined;
if (!outcomeActions) {
// No outcome actions is valid, it just means there are no outcomes configured
return errors;
}
// --- Rule 1: Task must be selected ---
outcomeActions.forEach((o, index) => {
if (!o.task) {
errors[`${guid}.outcomeActions[${index}].task`] = "Can not be empty";
}
});
// --- Rule 2: Detect duplicates (verdict + 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}`;
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}`;
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";
}
});
return errors;
};
export function defaultsAssignment(
task: TaskDefinition,
tasks: TaskDefinition[],
ctx: defaultsContext,
) {}
export const outcomeOfApprovalVerdictRegistryEntry: capabilityEditorRegistryEntry =
{
Editor: outcomeEditor,
DefaultsAssignment: defaultsAssignment,
ValidationRunner: runValidation,
};