Started adding support for stages

This commit is contained in:
Colin Dawson 2026-02-25 17:28:47 +00:00
parent 70e4258071
commit 4706b78d88
9 changed files with 339 additions and 54 deletions

View File

@ -1,4 +1,5 @@
{
"High": "Élevé",
"Low": "Faible"
"Low": "Faible",
"Normal": "Normalement"
}

View File

@ -1,4 +1,5 @@
{
"High": "높은",
"Low": "로우",
"Normal": "정상"
}

View File

@ -5,12 +5,34 @@ export interface SelectableListProps<T> {
selectedValue?: T | null;
renderLabel: (item: T) => React.ReactNode;
onSelect: (item: T) => void;
getChildren?: (item: T) => T[] | undefined;
}
export const SelectableList = <T,>(
props: SelectableListProps<T>,
): JSX.Element => {
const { items, selectedValue, renderLabel, onSelect } = props;
const { items, selectedValue, renderLabel, onSelect, getChildren } = props;
const flattenedItems = React.useMemo(() => {
const flattened: { item: T; depth: number }[] = [];
const walk = (source: T[], depth: number) => {
source.forEach((item) => {
flattened.push({ item, depth });
const children = getChildren?.(item) ?? [];
if (children.length > 0) {
walk(children, depth + 1);
}
});
};
walk(items, 0);
return flattened;
}, [items, getChildren]);
const flatItems = flattenedItems.map((entry) => entry.item);
const listRef = useRef<HTMLUListElement | null>(null);
const isFocusedRef = useRef(false);
@ -21,39 +43,41 @@ export const SelectableList = <T,>(
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLUListElement>) => {
if (!isFocusedRef.current) return;
if (!items.length) return;
if (!flatItems.length) return;
const currentIndex = selectedValue ? items.indexOf(selectedValue) : -1;
const currentIndex = selectedValue
? flatItems.indexOf(selectedValue)
: -1;
if (e.key === "ArrowDown") {
e.preventDefault();
const nextIndex =
currentIndex < items.length - 1 ? currentIndex + 1 : 0;
onSelect(items[nextIndex]);
currentIndex < flatItems.length - 1 ? currentIndex + 1 : 0;
onSelect(flatItems[nextIndex]);
}
if (e.key === "ArrowUp") {
e.preventDefault();
const prevIndex =
currentIndex > 0 ? currentIndex - 1 : items.length - 1;
onSelect(items[prevIndex]);
currentIndex > 0 ? currentIndex - 1 : flatItems.length - 1;
onSelect(flatItems[prevIndex]);
}
},
[items, selectedValue, onSelect],
[flatItems, selectedValue, onSelect],
);
useEffect(() => {
if (!isFocusedRef.current) return;
if (!selectedValue) return;
const index = items.indexOf(selectedValue);
const index = flatItems.indexOf(selectedValue);
if (index < 0) return;
const el = itemRefs.current[index];
if (el) {
el.focus({ preventScroll: false });
}
}, [items, selectedValue]);
}, [flatItems, selectedValue]);
// Separate effect for scrolling - only when selection changes
useEffect(() => {
@ -64,14 +88,14 @@ export const SelectableList = <T,>(
if (!selectedValue) return;
const index = items.indexOf(selectedValue);
const index = flatItems.indexOf(selectedValue);
if (index < 0) return;
const el = itemRefs.current[index];
if (el) {
el.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [selectedValue, items]);
}, [selectedValue, flatItems]);
return (
<ul
@ -80,13 +104,13 @@ export const SelectableList = <T,>(
tabIndex={0}
role="listbox"
aria-activedescendant={
selectedValue ? `option-${items.indexOf(selectedValue)}` : undefined
selectedValue ? `option-${flatItems.indexOf(selectedValue)}` : undefined
}
onFocus={() => (isFocusedRef.current = true)}
onBlur={() => (isFocusedRef.current = false)}
onKeyDown={handleKeyDown}
>
{items.map((item, index) => {
{flattenedItems.map(({ item, depth }, index) => {
const isSelected = selectedValue === item;
const className = isSelected ? "selected" : "";
@ -100,6 +124,7 @@ export const SelectableList = <T,>(
aria-selected={isSelected}
onClick={() => onSelect(item)}
className={className}
style={{ paddingLeft: `${depth * 16}px` }}
>
{renderLabel(item)}
</li>

View File

@ -0,0 +1,91 @@
import React, { useEffect } from "react";
import templateVersionsService, {
TaskDefinition,
TaskMetadata,
} from "../../services/WorkflowTemplateService";
import AddTaskButton from "../AddTaskButton";
import {
CapabilityEditorProps,
capabilityEditorRegistryEntry,
defaultsContext,
TaskValidationResult,
useCapabilityDefaults,
validateTask,
} from "../useCapabilityDefaults";
export const StageOfGeneralTaskEditor: React.FC<CapabilityEditorProps> = (
props,
) => {
const { task, onChange, onValidate, fieldErrors } = props;
const [tasksMetadata, setTasksMetadata] = React.useState<TaskMetadata[]>([]);
useEffect(() => {
const fetchTaskMetadata = async () => {
const meta = await templateVersionsService.getTaskMetadata("GeneralTask");
setTasksMetadata(meta);
};
fetchTaskMetadata();
}, []);
const runDefaults = useCapabilityDefaults(tasksMetadata);
const handleAddTask = (selectedType: TaskMetadata) => {
const newTask: TaskDefinition = {
type: selectedType.taskType,
config: {
guid: crypto.randomUUID(),
},
};
var childTasks = task.config.tasks as TaskDefinition[]; //Type assertion to satisfy the compiler, we know this will be the correct type.
//Assign the default values for the task here.
selectedType.capabilities.forEach((capability) => {
runDefaults(capability, newTask, childTasks);
});
const updatedTasks = [...childTasks, newTask];
const errors = validateTask(newTask, updatedTasks, tasksMetadata);
const isValid = Object.keys(errors).length === 0;
task.config.tasks = updatedTasks;
onValidate({ errors: errors, isValid: isValid } as TaskValidationResult);
onChange(task);
};
return (
<>
<AddTaskButton tasksMetadata={tasksMetadata} onAdd={handleAddTask} />
</>
);
};
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,
) {
task.config.tasks = [] as TaskDefinition[];
}
export const stageOfGeneralTaskEditorRegistryEntry: capabilityEditorRegistryEntry =
{
Editor: StageOfGeneralTaskEditor,
DefaultsAssignment: defaultsAssignment,
ValidationRunner: runValidation,
};

View File

@ -1,5 +1,5 @@
import React from "react";
import {
import React, { useEffect } from "react";
import templateVersionsService, {
TaskDefinition,
TaskMetadata,
} from "../services/WorkflowTemplateService";
@ -12,7 +12,6 @@ import ValidationErrorIcon from "../../../../components/validationErrorIcon";
interface TaskListProps {
tasks: TaskDefinition[];
validTasksList: Record<string, boolean>;
tasksMetadata: TaskMetadata[];
onChange: (tasks: TaskDefinition[]) => void;
selectedTask: TaskDefinition | null;
onSelectTask: (task: TaskDefinition | null) => void;
@ -22,14 +21,50 @@ interface TaskListProps {
const TaskList: React.FC<TaskListProps> = ({
tasks,
validTasksList,
tasksMetadata,
onChange,
selectedTask,
onSelectTask,
onValidate,
}) => {
const [tasksMetadata, setTasksMetadata] = React.useState<TaskMetadata[]>([]);
useEffect(() => {
const fetchTaskMetadata = async () => {
const meta = await templateVersionsService.getTaskMetadata("GeneralTask");
setTasksMetadata(meta);
};
fetchTaskMetadata();
}, []);
const runDefaults = useCapabilityDefaults(tasksMetadata);
const taskMetadataByType = React.useMemo(() => {
const map = new Map<string, TaskMetadata>();
tasksMetadata.forEach((meta) => {
map.set(meta.taskType, meta);
});
return map;
}, [tasksMetadata]);
const getChildTasks = React.useCallback(
(task: TaskDefinition) => {
const meta = taskMetadataByType.get(task.type);
const isStageTask =
meta?.capabilities?.some((capability) =>
capability.startsWith("IStage<"),
) ?? false;
if (!isStageTask) {
return [];
}
const childTasks = (task.config.tasks as TaskDefinition[]) ?? [];
return sortTasksTopologically(childTasks);
},
[taskMetadataByType],
);
const handleAddTask = (selectedType: TaskMetadata) => {
const newTask: TaskDefinition = {
type: selectedType.taskType,
@ -64,6 +99,7 @@ const TaskList: React.FC<TaskListProps> = ({
<div className="task-list-content">
<SelectableList
items={sortedTasks}
getChildren={getChildTasks}
selectedValue={selectedTask}
renderLabel={(x) => {
if (x) {

View File

@ -1,5 +1,5 @@
import React, { useCallback, useMemo } from "react";
import {
import React, { useCallback, useEffect, useMemo } from "react";
import templateVersionsService, {
TaskDefinition,
TaskMetadata,
} from "../services/WorkflowTemplateService";
@ -11,7 +11,7 @@ import ConfirmButton from "../../../../components/common/ConfirmButton";
interface TaskEditorProps {
task: TaskDefinition;
tasks: TaskDefinition[];
tasksMetadata: TaskMetadata[];
siblingTasks: TaskDefinition[];
onChange: (updatedTask: TaskDefinition) => void;
onValidate: (taskId: string, isValid: boolean) => void;
onDelete: (taskId: string) => void;
@ -20,11 +20,22 @@ interface TaskEditorProps {
const TaskEditorComponent: React.FC<TaskEditorProps> = ({
task,
tasks,
tasksMetadata,
siblingTasks,
onChange,
onValidate,
onDelete,
}) => {
const [tasksMetadata, setTasksMetadata] = React.useState<TaskMetadata[]>([]);
useEffect(() => {
const fetchTaskMetadata = async () => {
const meta = await templateVersionsService.getTaskMetadata("GeneralTask");
setTasksMetadata(meta);
};
fetchTaskMetadata();
}, []);
const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>(
{},
);
@ -56,17 +67,17 @@ const TaskEditorComponent: React.FC<TaskEditorProps> = ({
const handleTaskChange = useCallback(
(updatedTask: TaskDefinition) => {
// Update the task list
const updatedTasks = tasks.map((t) =>
// Update the sibling task list (validation runs on siblings only)
const updatedSiblings = siblingTasks.map((t) =>
t.config.guid === updatedTask.config.guid ? updatedTask : t,
);
runValidation(updatedTask, updatedTasks, tasksMetadata);
runValidation(updatedTask, updatedSiblings, tasksMetadata);
// Bubble updated task up
onChange(updatedTask);
},
[tasks, tasksMetadata, runValidation, onChange],
[siblingTasks, tasksMetadata, runValidation, onChange],
);
const taskMeta = useMemo(

View File

@ -26,16 +26,6 @@ const TasksTab: React.FC<TasksTabProps> = ({
}) => {
const tasks = data.tasks;
const [selectedTask, setSelectedTask] = useState<TaskDefinition | null>(null);
const [tasksMetadata, setTasksMetadata] = React.useState<TaskMetadata[]>([]);
useEffect(() => {
const fetchTaskMetadata = async () => {
const meta = await templateVersionsService.getTaskMetadata("GeneralTask");
setTasksMetadata(meta);
};
fetchTaskMetadata();
}, []);
useEffect(() => {
// Don't override user selection
@ -68,37 +58,165 @@ const TasksTab: React.FC<TasksTabProps> = ({
[onTasksChange],
);
const findTaskAndSiblings = (
targetGuid: string,
source: TaskDefinition[],
): { task: TaskDefinition | null; siblings: TaskDefinition[] } => {
for (const task of source) {
if (task.config.guid === targetGuid) {
return { task, siblings: source };
}
const childTasks = (task.config.tasks as TaskDefinition[]) ?? [];
if (childTasks.length === 0) continue;
const result = findTaskAndSiblings(targetGuid, childTasks);
if (result.task) {
return result;
}
}
return { task: null, siblings: [] };
};
const handleTaskEditorChange = React.useCallback(
(updatedTask: TaskDefinition) => {
const newTasks = tasks.map((t) =>
t.config.guid === updatedTask.config.guid ? updatedTask : t,
const { siblings } = findTaskAndSiblings(
updatedTask.config.guid as string,
tasks,
);
if (siblings.length === 0) {
return;
}
const updateTaskRecursive = (
source: TaskDefinition[],
): TaskDefinition[] => {
return source.map((t) => {
if (t.config.guid === updatedTask.config.guid) {
return updatedTask;
}
const childTasks = (t.config.tasks as TaskDefinition[]) ?? [];
if (childTasks.length === 0) {
return t;
}
const updatedChildren = updateTaskRecursive(childTasks);
if (updatedChildren === childTasks) {
return t;
}
return {
...t,
config: {
...t.config,
tasks: updatedChildren,
},
};
});
};
const newTasks = updateTaskRecursive(tasks);
handleTasksChange(newTasks);
// Use the updated object from the array, not the raw updatedTask
const updatedFromArray = newTasks.find(
(t) => t.config.guid === updatedTask.config.guid,
const { task: updatedFromArray } = findTaskAndSiblings(
updatedTask.config.guid as string,
newTasks,
);
setSelectedTask(updatedFromArray!);
if (updatedFromArray) {
setSelectedTask(updatedFromArray);
}
},
[tasks, handleTasksChange],
);
const handleTaskDelete = React.useCallback(
(taskId: string) => {
const newTasks = tasks.filter((t) => t.config.guid !== taskId);
const removeTaskRecursive = (
source: TaskDefinition[],
): { tasks: TaskDefinition[]; removed: boolean } => {
let removed = false;
for (const t of newTasks) {
// If any task has a dependency on the deleted task, remove that dependency
if (t.config.predecessors) {
t.config.predecessors = (t.config.predecessors as string[]).filter(
(d) => d !== taskId,
);
const updated = source.flatMap((task) => {
if (task.config.guid === taskId) {
removed = true;
return [];
}
const childTasks = (task.config.tasks as TaskDefinition[]) ?? [];
if (childTasks.length === 0) {
return [task];
}
const childResult = removeTaskRecursive(childTasks);
if (!childResult.removed) {
return [task];
}
removed = true;
return [
{
...task,
config: {
...task.config,
tasks: childResult.tasks,
},
},
];
});
return { tasks: updated, removed };
};
const removePredecessorRecursive = (
source: TaskDefinition[],
): TaskDefinition[] => {
return source.map((task) => {
const childTasks = (task.config.tasks as TaskDefinition[]) ?? [];
const updatedChildren =
childTasks.length > 0
? removePredecessorRecursive(childTasks)
: childTasks;
const predecessors = task.config.predecessors as string[] | undefined;
const updatedPredecessors = predecessors
? predecessors.filter((d) => d !== taskId)
: predecessors;
const childrenChanged = updatedChildren !== childTasks;
const predecessorsChanged = updatedPredecessors !== predecessors;
if (!childrenChanged && !predecessorsChanged) {
return task;
}
const updatedConfig = { ...task.config } as Record<string, unknown>;
if (childrenChanged) {
updatedConfig.tasks = updatedChildren;
}
if (predecessorsChanged) {
updatedConfig.predecessors = updatedPredecessors;
}
return {
...task,
config: updatedConfig,
};
});
};
const deleteResult = removeTaskRecursive(tasks);
if (!deleteResult.removed) {
return;
}
const newTasks = removePredecessorRecursive(deleteResult.tasks);
onValidate(taskId, true); // Clear validation state for deleted task
setSelectedTask(null);
handleTasksChange(newTasks);
@ -112,7 +230,6 @@ const TasksTab: React.FC<TasksTabProps> = ({
<TaskList
tasks={tasks}
validTasksList={taskValidation}
tasksMetadata={tasksMetadata}
onChange={handleTasksChange}
selectedTask={selectedTask}
onSelectTask={setSelectedTask}
@ -122,9 +239,12 @@ const TasksTab: React.FC<TasksTabProps> = ({
{selectedTask && (
<div>
<TaskEditor
tasksMetadata={tasksMetadata}
task={selectedTask}
tasks={tasks}
siblingTasks={
findTaskAndSiblings(selectedTask.config.guid as string, tasks)
.siblings
}
onChange={handleTaskEditorChange}
onValidate={onValidate}
onDelete={handleTaskDelete}

View File

@ -5,6 +5,7 @@ import { capabilityEditorRegistryEntry } from "./useCapabilityDefaults";
import { outcomeOfApprovalVerdictRegistryEntry } from "./CapabilityEditors/OutcomeOfApprovalVerdictRegistryEntry";
import { budgetEditorRegistryEntry } from "./CapabilityEditors/BudgetEditorRegistryEntry";
import { bypassableEditorRegistryEntry } from "./CapabilityEditors/BypassableEditor";
import { stageOfGeneralTaskEditorRegistryEntry } from "./CapabilityEditors/StageOfGeneralTaskEditor";
export const capabilityEditorRegistry: Record<
string,
@ -17,6 +18,6 @@ export const capabilityEditorRegistry: Record<
"IOutcome<ApprovalVerdict>": outcomeOfApprovalVerdictRegistryEntry,
// IFormTemplate: null, //ToDo implement this
IBypassable: bypassableEditorRegistryEntry,
// "IStage<GeneralTaskAttribute>": null, //ToDo implement this
"IStage<GeneralTaskAttribute>": stageOfGeneralTaskEditorRegistryEntry,
// "IStage<ApprovalTaskAttribute>": null, //ToDo implement this
};

View File

@ -6,7 +6,6 @@ import {
MakeGeneralIdRefParams,
} from "../../../../utils/GeneralIdRef";
import MapToJson from "../../../../utils/MapToJson";
import { CustomFieldValue } from "../../glossary/services/glossaryService";
const apiEndpoint = "/WorkflowTemplate";