Capability editor engine is now working and should allow me to define lots of editors for different c# interfaces, allowing composition of lots of task types.

This commit is contained in:
Colin Dawson 2026-02-15 16:39:17 +00:00
parent 86ebdc8b72
commit a52b05037b
10 changed files with 312 additions and 322 deletions

View File

@ -4,12 +4,12 @@ import { useTranslation } from "react-i18next";
import { Namespaces } from "../../../../i18n/i18n";
interface AddTaskButtonProps {
allowedTasks: TaskMetadata[];
tasksMetadata: TaskMetadata[];
onAdd: (selectedType: TaskMetadata) => void;
}
const AddTaskButton: React.FC<AddTaskButtonProps> = ({
allowedTasks,
tasksMetadata,
onAdd,
}) => {
const [open, setOpen] = React.useState(false);
@ -30,7 +30,7 @@ const AddTaskButton: React.FC<AddTaskButtonProps> = ({
{open && (
<div className="dropdown-menu show">
{allowedTasks.map((item) => (
{tasksMetadata.map((item) => (
<button
key={item.taskType}
className="dropdown-item"

View File

@ -0,0 +1,33 @@
import { TaskDefinition } from "../../services/WorkflowTemplateService";
import {
CapabilityEditorProps,
capabilityEditorRegistryEntry,
defaultsContext,
} from "../useCapabilityDefaults";
export const TagsEditor: React.FC<CapabilityEditorProps> = (props) => {
const { task, tasks, onChange, fieldErrors } = props;
return <>Tags editor goes here</>;
};
const runValidation = (
task: TaskDefinition,
tasks: TaskDefinition[],
): Record<string, string> => {
const errors: Record<string, string> = {};
return errors;
};
export function defaultsAssignment(
task: TaskDefinition,
tasks: TaskDefinition[],
ctx: defaultsContext,
) {}
export const tagsEditorRegistryEntry: capabilityEditorRegistryEntry = {
Editor: TagsEditor,
DefaultsAssignment: defaultsAssignment,
ValidationRunner: runValidation,
};

View File

@ -1,159 +1,77 @@
import { useCallback } from "react";
import { InputType } from "../../../../../components/common/Input";
import { TaskDefinition } from "../../services/WorkflowTemplateService";
import {
TaskDefinition,
TaskMetadata,
} from "../../services/WorkflowTemplateService";
import { renderTaskField } from "../taskEditorHelpers";
import { CapabilityEditorProps } from "../TasksEditor";
import { Namespaces } from "../../../../../i18n/i18n";
import { useTranslation } from "react-i18next";
import {
CapabilityEditorProps,
capabilityEditorRegistryEntry,
defaultsContext,
} from "../useCapabilityDefaults";
import { getAllDescendants } from "../workflowGraphUtils";
import { useValidation } from "../useValidation";
import { useCapabilityDefaults } from "../useCapabilityDefaults";
export const TaskCoreEditor: React.FC<CapabilityEditorProps> = ({
task,
allTasks,
allowedTasks,
onChange,
onValidate,
shouldAssignDefaults,
}) => {
const { t: tTaskType } = useTranslation(Namespaces.TaskTypes);
function getAllowedPredecessors(task: TaskDefinition, tasks: TaskDefinition[]) {
const currentGuid = task.config.guid as string;
const descendants = getAllDescendants(currentGuid, tasks);
// Generate a unique default name
const nameExists = (tasks: TaskDefinition[], candidate: string): boolean => {
const target = candidate.trim().toLowerCase();
const allowedPredecessors = tasks.filter((t) => {
const guid = t.config.guid as string;
if (guid === currentGuid) return false;
if (descendants.has(guid)) return false;
return true;
});
return tasks.some(
(t) => (t.config.name as string)?.trim().toLowerCase() === target,
);
};
return allowedPredecessors;
}
const formatNewTaskName = useCallback(
(tasks: TaskDefinition[]) => {
const displayName = allowedTasks.find(
const formatNewTaskName = (
task: TaskDefinition,
tasks: TaskDefinition[],
taskMetaData: TaskMetadata[],
tTaskType: (key: string) => string,
) => {
const displayName = taskMetaData.find(
(t) => t.taskType === task.type,
)?.displayName;
if (!displayName) return "New Task";
const base = `${tTaskType(displayName)} `;
let index = 0;
while (nameExists(tasks, `${base}${index}`)) {
const existingNames = tasks
.map((t) => (t.config.name as string)?.trim().toLowerCase())
.filter(Boolean);
let index = 1;
while (existingNames.includes(`${base}${index}`.toLowerCase())) {
index++;
}
return `${base}${index}`;
},
[allowedTasks, task.type, tTaskType],
);
};
const assignDefaults = useCallback(
(newConfig: Record<string, unknown>) => {
const displayName = allowedTasks.find(
(t) => t.taskType === task.type,
)?.displayName;
const setDefaultPredecessors = (
task: TaskDefinition,
tasks: TaskDefinition[],
): string[] => {
const allowedPredecessors = getAllowedPredecessors(task, tasks);
if (allowedPredecessors.length === 0) return [];
// Assign default name
if (displayName) {
newConfig.name = formatNewTaskName(allTasks);
}
return [
allowedPredecessors[allowedPredecessors.length - 1].config.guid as string,
];
};
// Assign default predecessor (the task immediately before this one)
const index = allTasks.findIndex(
(t) => t.config.guid === task.config.guid,
);
export const TaskCoreEditor: React.FC<CapabilityEditorProps> = (props) => {
const { task, tasks, onChange, fieldErrors } = props;
if (index > 0) {
const previousTask = allTasks[index - 1];
newConfig.predecessors = [previousTask.config.guid as string];
}
},
[formatNewTaskName, task, allTasks, allowedTasks],
);
useCapabilityDefaults(shouldAssignDefaults, task, onChange, assignDefaults, [
allTasks,
allowedTasks,
]);
const runValidation = useCallback(() => {
const errors: Record<string, string> = {};
// Name required
if (!(task.config.name as string)?.trim()) {
errors["name"] = "Name cannot be empty";
}
// Name must be unique
const duplicate = allTasks.find(
(t) =>
t.config.guid !== task.config.guid &&
(t.config.name as string)?.trim().toLowerCase() ===
(task.config.name as string)?.trim().toLowerCase(),
);
if (duplicate) {
errors["name"] = "Name must be unique.";
}
// Description max length
const descriptionMaxLength = 5000;
if (
(task.config.description && (task.config.description as string).length) ??
0 > descriptionMaxLength
) {
errors["description"] =
`Description can be up to ${descriptionMaxLength} characters long.`;
}
// Predecessors must not include self
if (
(task.config.predecessors as string[])?.includes(
task.config.guid as string,
)
) {
errors["predecessors"] = "A task cannot depend on itself.";
}
// Predecessors must be unique
if (task.config.predecessors) {
const unique = new Set(task.config.predecessors as string[]);
if (unique.size !== (task.config.predecessors as string[]).length) {
errors["predecessors"] = "Duplicate predecessors are not allowed.";
}
}
const isValid = Object.keys(errors).length === 0;
return { errors, isValid };
}, [task, allTasks]);
const { fieldErrors } = useValidation(runValidation, onValidate, [
task.config.guid,
task.config.name,
task.config.description,
task.config.predecessors,
runValidation,
onValidate,
]);
const currentGuid = task.config.guid as string;
const descendants = getAllDescendants(currentGuid, allTasks);
const allowedPredecessors = allTasks.filter((t) => {
const guid = t.config.guid as string;
// Exclude self
if (guid === currentGuid) return false;
// Exclude descendants (direct or indirect)
if (descendants.has(guid)) return false;
return true;
});
const allowedPredecessors = getAllowedPredecessors(task, tasks);
return (
<div>
<>
{renderTaskField(
task,
onChange,
@ -162,7 +80,6 @@ export const TaskCoreEditor: React.FC<CapabilityEditorProps> = ({
InputType.text,
fieldErrors,
)}
{renderTaskField(
task,
onChange,
@ -171,7 +88,6 @@ export const TaskCoreEditor: React.FC<CapabilityEditorProps> = ({
InputType.textarea,
fieldErrors,
)}
{renderTaskField(
task,
onChange,
@ -188,6 +104,69 @@ export const TaskCoreEditor: React.FC<CapabilityEditorProps> = ({
})),
},
)}
</div>
</>
);
};
const runValidation = (
task: TaskDefinition,
tasks: TaskDefinition[],
): Record<string, string> => {
const errors: Record<string, string> = {};
if (!(task.config.name as string)?.trim()) {
errors.name = "Name cannot be empty";
}
const duplicate = tasks.find(
(t) =>
t.config.guid !== task.config.guid &&
(t.config.name as string)?.trim().toLowerCase() ===
(task.config.name as string)?.trim().toLowerCase(),
);
if (duplicate) {
errors.name = "Name must be unique.";
}
// const descriptionMaxLength = 5;
// if ((task.config.description as string)?.length > descriptionMaxLength) {
// errors.description = `Description can be up to ${descriptionMaxLength} characters long.`;
// }
if (
(task.config.predecessors as string[])?.includes(task.config.guid as string)
) {
errors.predecessors = "A task cannot depend on itself.";
}
if (task.config.predecessors) {
const unique = new Set(task.config.predecessors as string[]);
if (unique.size !== (task.config.predecessors as string[]).length) {
errors.predecessors = "Duplicate predecessors are not allowed.";
}
}
return errors;
};
export function defaultsAssignment(
task: TaskDefinition,
tasks: TaskDefinition[],
ctx: defaultsContext,
) {
task.config.name = formatNewTaskName(
task,
tasks,
ctx.taskMetadata,
ctx.tTaskType,
);
task.config.description = "";
task.config.predecessors = setDefaultPredecessors(task, tasks);
}
export const taskCoreEditorRegistryEntry: capabilityEditorRegistryEntry = {
Editor: TaskCoreEditor,
DefaultsAssignment: defaultsAssignment,
ValidationRunner: runValidation,
};

View File

@ -8,11 +8,12 @@ import { SelectableList } from "../../../../components/common/SelectableList";
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { sortTasksTopologically } from "./workflowGraphUtils";
import { useCapabilityDefaults } from "./useCapabilityDefaults";
interface TaskListProps {
tasks: TaskDefinition[];
validTasksList: Record<string, boolean>;
allowedTasks: TaskMetadata[];
tasksMetadata: TaskMetadata[];
onChange: (tasks: TaskDefinition[]) => void;
selectedTask: TaskDefinition | null;
onSelectTask: (task: TaskDefinition | null) => void;
@ -21,11 +22,13 @@ interface TaskListProps {
const TaskList: React.FC<TaskListProps> = ({
tasks,
validTasksList,
allowedTasks,
tasksMetadata,
onChange,
selectedTask,
onSelectTask,
}) => {
const runDefaults = useCapabilityDefaults(tasksMetadata);
const handleAddTask = (selectedType: TaskMetadata) => {
const newTask: TaskDefinition = {
type: selectedType.taskType,
@ -35,15 +38,20 @@ const TaskList: React.FC<TaskListProps> = ({
},
};
onSelectTask(newTask);
//Assign the default values for the task here.
selectedType.capabilities.forEach((capability) => {
runDefaults(capability, newTask, tasks);
});
onChange([...tasks, newTask]);
onSelectTask(newTask);
};
const sortedTasks = sortTasksTopologically(tasks);
return (
<div>
<AddTaskButton allowedTasks={allowedTasks} onAdd={handleAddTask} />
<AddTaskButton tasksMetadata={tasksMetadata} onAdd={handleAddTask} />
<SelectableList
items={sortedTasks}

View File

@ -1,102 +1,84 @@
import { useEffect, useRef, useState } from "react";
import React from "react";
import {
TaskDefinition,
TaskMetadata,
} from "../services/WorkflowTemplateService";
import { TaskCoreEditor } from "./CapabilityEditors/TaskCoreEditor";
export interface CapabilityEditorProps {
task: TaskDefinition;
allTasks: TaskDefinition[];
allowedTasks: TaskMetadata[];
onChange: (updated: TaskDefinition) => void;
onValidate: (result: TaskValidationResult) => void;
shouldAssignDefaults: boolean;
}
export interface TaskValidationResult {
isValid: boolean;
errors: Record<string, string>;
}
import { capabilityEditorRegistry } from "./capabilityEditorRegistry";
interface TaskEditorProps {
task: TaskDefinition;
allTasks: TaskDefinition[];
allowedTasks: TaskMetadata[];
tasks: TaskDefinition[];
tasksMetadata: TaskMetadata[];
onChange: (updatedTask: TaskDefinition) => void;
onValidate: (taskId: string, isValid: boolean) => void;
}
export const TaskEditor: React.FC<TaskEditorProps> = ({
task,
allTasks,
allowedTasks,
tasks,
tasksMetadata,
onChange,
onValidate,
}) => {
//region assign defaults
const hasAssignedDefaultsRef = useRef(false);
const [shouldAssignDefaults, setShouldAssignDefaults] = useState(false);
const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>(
{},
);
// Reset guard when a new task is loaded
useEffect(() => {
hasAssignedDefaultsRef.current = false;
setShouldAssignDefaults(false);
}, [task.config.guid]);
const validateTask = (task: TaskDefinition, tasks: TaskDefinition[]) => {
const taskMeta = tasksMetadata.find((t) => t.taskType === task.type);
// Decide when to trigger initial defaults (current rule: no name yet)
useEffect(() => {
if (!hasAssignedDefaultsRef.current && !task.config.name) {
hasAssignedDefaultsRef.current = true;
setShouldAssignDefaults(true);
} else {
// ensure we only pulse true once
setShouldAssignDefaults(false);
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);
}
}, [task.config.name]);
//end region assign defaults
//region Validation
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [validationMap, setValidationMap] = useState<
Record<string, TaskValidationResult>
>({});
const onCapabilityValidate = (
capabilityName: string,
result: TaskValidationResult,
) => {
setValidationMap((prev) => {
const updated = { ...prev, [capabilityName]: result };
return updated;
});
return errors;
};
const prevAllValidRef = useRef<boolean | null>(null);
const handleTaskChange = (updatedTask: TaskDefinition) => {
// Update the task list
const updatedTasks = tasks.map((t) =>
t.config.guid === updatedTask.config.guid ? updatedTask : t,
);
useEffect(() => {
const allValid = Object.values(validationMap).every((r) => r.isValid);
// Run validation
const errors = validateTask(updatedTask, updatedTasks);
setFieldErrors(errors);
// Only notify parent when the value actually changes
if (prevAllValidRef.current !== allValid) {
prevAllValidRef.current = allValid;
onValidate(task.config.guid as string, allValid);
}
}, [validationMap, task.config.guid, onValidate]);
// Bubble validity up
onValidate(
updatedTask.config.guid as string,
Object.keys(errors).length === 0,
);
//End region validation
// Bubble updated task up
onChange(updatedTask);
};
const taskMeta = tasksMetadata.find((t) => t.taskType === task.type);
return (
<>
<TaskCoreEditor
<div>
{taskMeta?.capabilities.map((capability) => {
const entry = capabilityEditorRegistry[capability];
if (!entry) return null;
const Editor = entry.Editor;
return (
<Editor
key={capability}
task={task}
allowedTasks={allowedTasks}
allTasks={allTasks}
onChange={onChange}
onValidate={(result) => onCapabilityValidate("core", result)}
shouldAssignDefaults={shouldAssignDefaults}
tasks={tasks}
onChange={handleTaskChange}
fieldErrors={fieldErrors} // ← THIS IS THE MISSING PIECE
/>
</>
);
})}
</div>
);
};

View File

@ -26,48 +26,17 @@ const TasksTab: React.FC<TasksTabProps> = ({
}) => {
const tasks = data.tasks;
const [selectedTask, setSelectedTask] = useState<TaskDefinition | null>(null);
const [allowedTasks, setAllowedTasks] = React.useState<TaskMetadata[]>([]);
const [tasksMetadata, setTasksMetadata] = React.useState<TaskMetadata[]>([]);
useEffect(() => {
const fetchTaskMetadata = async () => {
const meta = await templateVersionsService.getTaskMetadata("GeneralTask");
setAllowedTasks(meta);
setTasksMetadata(meta);
};
fetchTaskMetadata();
}, []);
useEffect(() => {
if (tasks.length === 0) {
if (selectedTask !== null) {
setSelectedTask(null);
}
return;
}
if (!selectedTask) {
return;
}
const selectedGuid = (selectedTask.config as any)?.guid;
if (selectedGuid) {
const match = tasks.find(
(task) => (task.config as any)?.guid === selectedGuid,
);
if (match && match !== selectedTask) {
setSelectedTask(match);
} else if (!match) {
setSelectedTask(tasks[0]);
}
return;
}
if (!tasks.includes(selectedTask)) {
setSelectedTask(tasks[0]);
}
}, [tasks, selectedTask]);
useEffect(() => {
// Don't override user selection
if (selectedTask) return;
@ -102,7 +71,7 @@ const TasksTab: React.FC<TasksTabProps> = ({
<TaskList
tasks={tasks}
validTasksList={taskValidation}
allowedTasks={allowedTasks}
tasksMetadata={tasksMetadata}
onChange={handleTasksChange}
selectedTask={selectedTask}
onSelectTask={setSelectedTask}
@ -110,17 +79,22 @@ const TasksTab: React.FC<TasksTabProps> = ({
</div>
{selectedTask && (
<TaskEditor
allowedTasks={allowedTasks}
tasksMetadata={tasksMetadata}
task={selectedTask}
allTasks={tasks}
tasks={tasks}
onChange={(updatedTask) => {
const newTasks = tasks.map((t) =>
(t.config as any).guid === (updatedTask.config as any).guid
? updatedTask
: t,
t.config.guid === updatedTask.config.guid ? updatedTask : t,
);
handleTasksChange(newTasks);
setSelectedTask(updatedTask);
// Use the updated object from the array, not the raw updatedTask
const updatedFromArray = newTasks.find(
(t) => t.config.guid === updatedTask.config.guid,
);
setSelectedTask(updatedFromArray!);
}}
onValidate={onValidate}
/>

View File

@ -0,0 +1,11 @@
import { tagsEditorRegistryEntry } from "./CapabilityEditors/TagsEditor";
import { taskCoreEditorRegistryEntry } from "./CapabilityEditors/TaskCoreEditor";
import { capabilityEditorRegistryEntry } from "./useCapabilityDefaults";
export const capabilityEditorRegistry: Record<
string,
capabilityEditorRegistryEntry
> = {
ITask: taskCoreEditorRegistryEntry,
ITags: tagsEditorRegistryEntry,
};

View File

@ -33,8 +33,8 @@ export const renderTaskField = (
return renderTaskInput(
field,
label,
(task.config as any)[field],
errors[field],
task.config[field],
errors ? errors[field] : null,
type,
handleChange,
false,

View File

@ -1,23 +1,58 @@
import { useEffect } from "react";
import { TaskDefinition } from "../services/WorkflowTemplateService";
import { useTranslation } from "react-i18next";
import { Namespaces } from "../../../../i18n/i18n";
import {
TaskDefinition,
TaskMetadata,
} from "../services/WorkflowTemplateService";
import React from "react";
import { capabilityEditorRegistry } from "./capabilityEditorRegistry";
export function useCapabilityDefaults(
shouldAssignDefaults: boolean,
task: TaskDefinition,
onChange: (updated: TaskDefinition) => void,
assignDefaults: (newConfig: Record<string, unknown>) => void,
deps: unknown[],
) {
useEffect(() => {
if (!shouldAssignDefaults) return;
const newConfig = { ...task.config };
assignDefaults(newConfig);
onChange({
...task,
config: newConfig,
});
}, [shouldAssignDefaults, task, onChange, assignDefaults, ...deps]);
export interface TaskValidationResult {
isValid: boolean;
errors: Record<string, string>;
}
export interface CapabilityEditorProps {
task: TaskDefinition;
tasks: TaskDefinition[];
onChange: (updated: TaskDefinition) => void;
onValidate: (result: TaskValidationResult) => void;
fieldErrors: Record<string, string>;
}
export interface defaultsContext {
taskMetadata: TaskMetadata[];
tTaskType: (key: string) => string;
}
export interface capabilityEditorRegistryEntry {
Editor: React.FC<any>;
DefaultsAssignment?: (
task: TaskDefinition,
tasks: TaskDefinition[],
ctx: defaultsContext,
) => void;
ValidationRunner?: (
task: TaskDefinition,
tasks: TaskDefinition[],
) => Record<string, string>;
}
export function useCapabilityDefaults(taskMetadata: TaskMetadata[]) {
const { t: tTaskType } = useTranslation(Namespaces.TaskTypes);
const runDefaults = React.useCallback(
(capability: string, task: TaskDefinition, tasks: TaskDefinition[]) => {
const entry = capabilityEditorRegistry[capability];
if (!entry?.DefaultsAssignment) return;
entry.DefaultsAssignment(task, tasks, {
taskMetadata,
tTaskType,
});
},
[tTaskType, taskMetadata],
);
return runDefaults;
}

View File

@ -1,32 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { TaskValidationResult } from "./TasksEditor";
export function useValidation(
runValidation: () => TaskValidationResult,
onValidate: (result: TaskValidationResult) => void,
deps: unknown[],
) {
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const prevErrorsRef = useRef<Record<string, string>>({});
const prevInitialValidationRef = useRef<TaskValidationResult | null>(null);
useEffect(() => {
const result = runValidation();
const prev = prevInitialValidationRef.current;
const changed =
!prev ||
prev.isValid !== result.isValid ||
Object.keys(prev.errors).length !== Object.keys(result.errors).length ||
Object.entries(result.errors).some(([k, v]) => prev.errors[k] !== v);
if (changed) {
setFieldErrors(result.errors);
prevErrorsRef.current = result.errors;
onValidate(result);
prevInitialValidationRef.current = result;
}
}, deps);
return { fieldErrors };
}