Improvements to the validation engine

This commit is contained in:
Colin Dawson 2026-02-16 20:35:56 +00:00
parent 4b3f65ec20
commit 226d402578
5 changed files with 54 additions and 30 deletions

View File

@ -89,6 +89,8 @@ export const AssigneesOfITaskAssigneeEditor: React.FC<CapabilityEditorProps> = (
onChange(clone); onChange(clone);
} }
const guid = task.config.guid as string;
return ( return (
<div> <div>
Select assigness (you can select either a role or a contact) Select assigness (you can select either a role or a contact)
@ -113,7 +115,7 @@ export const AssigneesOfITaskAssigneeEditor: React.FC<CapabilityEditorProps> = (
name="role" name="role"
label="Role" label="Role"
value={assignee.role} value={assignee.role}
error={fieldErrors?.[`assignees[${index}].role`]} error={fieldErrors?.[`${guid}.assignees[${index}].role`]}
onChange={(name: string, val: GeneralIdRef | null) => onChange={(name: string, val: GeneralIdRef | null) =>
updateAssignee(index, { ...assignee, role: val }) updateAssignee(index, { ...assignee, role: val })
} }
@ -125,7 +127,7 @@ export const AssigneesOfITaskAssigneeEditor: React.FC<CapabilityEditorProps> = (
name="contact" name="contact"
label="Contact" label="Contact"
value={assignee.contact} value={assignee.contact}
error={fieldErrors?.[`assignees[${index}].contact`]} error={fieldErrors?.[`${guid}.assignees[${index}].contact`]}
onChange={(name: string, val: GeneralIdRef | null) => onChange={(name: string, val: GeneralIdRef | null) =>
updateAssignee(index, { ...assignee, contact: val }) updateAssignee(index, { ...assignee, contact: val })
} }
@ -136,7 +138,7 @@ export const AssigneesOfITaskAssigneeEditor: React.FC<CapabilityEditorProps> = (
name="raci" name="raci"
label="RACI" label="RACI"
value={assignee.raci} value={assignee.raci}
error={fieldErrors?.[`assignees[${index}].raci`]} error={fieldErrors?.[`${guid}.assignees[${index}].raci`]}
onChange={(name: string, val: string) => onChange={(name: string, val: string) =>
updateAssignee(index, { ...assignee, raci: val }) updateAssignee(index, { ...assignee, raci: val })
} }
@ -167,22 +169,30 @@ const runValidation = (
tasks: TaskDefinition[], tasks: TaskDefinition[],
): Record<string, string> => { ): Record<string, string> => {
const errors: Record<string, string> = {}; const errors: Record<string, string> = {};
const guid = task.config.guid as string;
const assignees = task.config.assignees as ITaskAssignee[] | undefined; const assignees = task.config.assignees as ITaskAssignee[] | undefined;
if (!assignees || assignees.length === 0) { if (!assignees || assignees.length === 0) {
errors["assignees"] = "At least one assignee is required."; errors[`${guid}.assignees`] = "At least one assignee is required.";
return errors; return errors;
} }
assignees.forEach((a, i) => { assignees.forEach((a, i) => {
if (!a.role && !a.contact) { const noContactSelected = !a.contact || a.contact?.id === BigInt(0);
//errors[`assignees[${i}]`] = "Either role or contact must be selected.";
errors[`assignees`] = "Either role or contact must be selected."; if (noContactSelected) {
errors[`${guid}.assignees[${i}].contact`] = "A contact must be selected.";
}
if (!a.role && noContactSelected) {
errors[`${guid}.assignees[${i}].role`] =
"Either role or contact must be selected.";
errors[`${guid}.assignees[${i}].contact`] =
"Either role or contact must be selected.";
} }
if (!a.raci) { if (!a.raci) {
//errors[`assignees[${i}].raci`] = "RACI is required."; errors[`${guid}.assignees[${i}].raci`] = "RACI is required.";
errors[`assignees`] = "RACI is required.";
} }
}); });

View File

@ -8,7 +8,7 @@ import { SelectableList } from "../../../../components/common/SelectableList";
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons"; import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { sortTasksTopologically } from "./workflowGraphUtils"; import { sortTasksTopologically } from "./workflowGraphUtils";
import { useCapabilityDefaults } from "./useCapabilityDefaults"; import { useCapabilityDefaults, validateTask } from "./useCapabilityDefaults";
interface TaskListProps { interface TaskListProps {
tasks: TaskDefinition[]; tasks: TaskDefinition[];
@ -17,6 +17,7 @@ interface TaskListProps {
onChange: (tasks: TaskDefinition[]) => void; onChange: (tasks: TaskDefinition[]) => void;
selectedTask: TaskDefinition | null; selectedTask: TaskDefinition | null;
onSelectTask: (task: TaskDefinition | null) => void; onSelectTask: (task: TaskDefinition | null) => void;
onValidate: (taskId: string, isValid: boolean) => void;
} }
const TaskList: React.FC<TaskListProps> = ({ const TaskList: React.FC<TaskListProps> = ({
@ -26,6 +27,7 @@ const TaskList: React.FC<TaskListProps> = ({
onChange, onChange,
selectedTask, selectedTask,
onSelectTask, onSelectTask,
onValidate,
}) => { }) => {
const runDefaults = useCapabilityDefaults(tasksMetadata); const runDefaults = useCapabilityDefaults(tasksMetadata);
@ -43,7 +45,12 @@ const TaskList: React.FC<TaskListProps> = ({
runDefaults(capability, newTask, tasks); runDefaults(capability, newTask, tasks);
}); });
onChange([...tasks, newTask]); const updatedTasks = [...tasks, newTask];
const errors = validateTask(newTask, updatedTasks, tasksMetadata);
onValidate(newTask.config.guid as string, Object.keys(errors).length === 0);
onChange(updatedTasks);
onSelectTask(newTask); onSelectTask(newTask);
}; };

View File

@ -4,6 +4,7 @@ import {
TaskMetadata, TaskMetadata,
} from "../services/WorkflowTemplateService"; } from "../services/WorkflowTemplateService";
import { capabilityEditorRegistry } from "./capabilityEditorRegistry"; import { capabilityEditorRegistry } from "./capabilityEditorRegistry";
import { validateTask } from "./useCapabilityDefaults";
interface TaskEditorProps { interface TaskEditorProps {
task: TaskDefinition; task: TaskDefinition;
@ -24,24 +25,6 @@ export const TaskEditor: React.FC<TaskEditorProps> = ({
{}, {},
); );
const validateTask = (task: TaskDefinition, tasks: TaskDefinition[]) => {
const taskMeta = tasksMetadata.find((t) => t.taskType === task.type);
const errors: Record<string, string> = {};
for (const capability of taskMeta?.capabilities ?? []) {
const entry = capabilityEditorRegistry[capability];
if (!entry?.ValidationRunner) {
continue;
}
const validationErrors = entry.ValidationRunner(task, tasks);
Object.assign(errors, validationErrors);
}
return errors;
};
const handleTaskChange = (updatedTask: TaskDefinition) => { const handleTaskChange = (updatedTask: TaskDefinition) => {
// Update the task list // Update the task list
const updatedTasks = tasks.map((t) => const updatedTasks = tasks.map((t) =>
@ -49,7 +32,7 @@ export const TaskEditor: React.FC<TaskEditorProps> = ({
); );
// Run validation // Run validation
const errors = validateTask(updatedTask, updatedTasks); const errors = validateTask(updatedTask, updatedTasks, tasksMetadata);
setFieldErrors(errors); setFieldErrors(errors);
// Bubble validity up // Bubble validity up

View File

@ -75,6 +75,7 @@ const TasksTab: React.FC<TasksTabProps> = ({
onChange={handleTasksChange} onChange={handleTasksChange}
selectedTask={selectedTask} selectedTask={selectedTask}
onSelectTask={setSelectedTask} onSelectTask={setSelectedTask}
onValidate={onValidate}
/> />
</div> </div>
{selectedTask && ( {selectedTask && (

View File

@ -35,9 +35,32 @@ export interface capabilityEditorRegistryEntry {
ValidationRunner?: ( ValidationRunner?: (
task: TaskDefinition, task: TaskDefinition,
tasks: TaskDefinition[], tasks: TaskDefinition[],
tasksMetadata: TaskMetadata[],
) => Record<string, string>; ) => Record<string, string>;
} }
export function validateTask(
task: TaskDefinition,
tasks: TaskDefinition[],
tasksMetadata: TaskMetadata[],
): Record<string, string> {
const taskMeta = tasksMetadata.find((t) => t.taskType === task.type);
const errors: Record<string, string> = {};
for (const capability of taskMeta?.capabilities ?? []) {
const entry = capabilityEditorRegistry[capability];
if (!entry?.ValidationRunner) {
continue;
}
const validationErrors = entry.ValidationRunner(task, tasks);
Object.assign(errors, validationErrors);
}
return errors;
}
export function useCapabilityDefaults(taskMetadata: TaskMetadata[]) { export function useCapabilityDefaults(taskMetadata: TaskMetadata[]) {
const { t: tTaskType } = useTranslation(Namespaces.TaskTypes); const { t: tTaskType } = useTranslation(Namespaces.TaskTypes);