diff --git a/public/locales/fr/priority.json b/public/locales/fr/priority.json index bb49528..b8fe615 100644 --- a/public/locales/fr/priority.json +++ b/public/locales/fr/priority.json @@ -1,4 +1,5 @@ { "High": "Élevé", - "Low": "Faible" + "Low": "Faible", + "Normal": "Normalement" } \ No newline at end of file diff --git a/public/locales/ko/priority.json b/public/locales/ko/priority.json index b64f7d6..c9b70c3 100644 --- a/public/locales/ko/priority.json +++ b/public/locales/ko/priority.json @@ -1,4 +1,5 @@ { + "High": "높은", "Low": "로우", "Normal": "정상" } \ No newline at end of file diff --git a/src/components/common/SelectableList.tsx b/src/components/common/SelectableList.tsx index 3ed2837..86201c9 100644 --- a/src/components/common/SelectableList.tsx +++ b/src/components/common/SelectableList.tsx @@ -5,12 +5,34 @@ export interface SelectableListProps { selectedValue?: T | null; renderLabel: (item: T) => React.ReactNode; onSelect: (item: T) => void; + getChildren?: (item: T) => T[] | undefined; } export const SelectableList = ( props: SelectableListProps, ): 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(null); const isFocusedRef = useRef(false); @@ -21,39 +43,41 @@ export const SelectableList = ( const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { 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 = ( 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 (
    ( 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 = ( aria-selected={isSelected} onClick={() => onSelect(item)} className={className} + style={{ paddingLeft: `${depth * 16}px` }} > {renderLabel(item)} diff --git a/src/modules/manager/workflowTemplates/components/CapabilityEditors/StageOfGeneralTaskEditor.tsx b/src/modules/manager/workflowTemplates/components/CapabilityEditors/StageOfGeneralTaskEditor.tsx new file mode 100644 index 0000000..c63439b --- /dev/null +++ b/src/modules/manager/workflowTemplates/components/CapabilityEditors/StageOfGeneralTaskEditor.tsx @@ -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 = ( + props, +) => { + const { task, onChange, onValidate, fieldErrors } = props; + + const [tasksMetadata, setTasksMetadata] = React.useState([]); + + 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 ( + <> + + + ); +}; + +const runValidation = ( + task: TaskDefinition, + tasks: TaskDefinition[], +): Record => { + const errors: Record = {}; + + 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, + }; diff --git a/src/modules/manager/workflowTemplates/components/TaskList.tsx b/src/modules/manager/workflowTemplates/components/TaskList.tsx index bde3ba3..e93bd2a 100644 --- a/src/modules/manager/workflowTemplates/components/TaskList.tsx +++ b/src/modules/manager/workflowTemplates/components/TaskList.tsx @@ -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; - tasksMetadata: TaskMetadata[]; onChange: (tasks: TaskDefinition[]) => void; selectedTask: TaskDefinition | null; onSelectTask: (task: TaskDefinition | null) => void; @@ -22,14 +21,50 @@ interface TaskListProps { const TaskList: React.FC = ({ tasks, validTasksList, - tasksMetadata, onChange, selectedTask, onSelectTask, onValidate, }) => { + const [tasksMetadata, setTasksMetadata] = React.useState([]); + + 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(); + 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 = ({
    { if (x) { diff --git a/src/modules/manager/workflowTemplates/components/TasksEditor.tsx b/src/modules/manager/workflowTemplates/components/TasksEditor.tsx index 099328c..214aafe 100644 --- a/src/modules/manager/workflowTemplates/components/TasksEditor.tsx +++ b/src/modules/manager/workflowTemplates/components/TasksEditor.tsx @@ -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 = ({ task, tasks, - tasksMetadata, + siblingTasks, onChange, onValidate, onDelete, }) => { + const [tasksMetadata, setTasksMetadata] = React.useState([]); + + useEffect(() => { + const fetchTaskMetadata = async () => { + const meta = await templateVersionsService.getTaskMetadata("GeneralTask"); + setTasksMetadata(meta); + }; + + fetchTaskMetadata(); + }, []); + const [fieldErrors, setFieldErrors] = React.useState>( {}, ); @@ -56,17 +67,17 @@ const TaskEditorComponent: React.FC = ({ 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( diff --git a/src/modules/manager/workflowTemplates/components/TasksTab.tsx b/src/modules/manager/workflowTemplates/components/TasksTab.tsx index 8664cd9..24c0cb3 100644 --- a/src/modules/manager/workflowTemplates/components/TasksTab.tsx +++ b/src/modules/manager/workflowTemplates/components/TasksTab.tsx @@ -26,16 +26,6 @@ const TasksTab: React.FC = ({ }) => { const tasks = data.tasks; const [selectedTask, setSelectedTask] = useState(null); - const [tasksMetadata, setTasksMetadata] = React.useState([]); - - 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 = ({ [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; + 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 = ({ = ({ {selectedTask && (
    ": outcomeOfApprovalVerdictRegistryEntry, // IFormTemplate: null, //ToDo implement this IBypassable: bypassableEditorRegistryEntry, - // "IStage": null, //ToDo implement this + "IStage": stageOfGeneralTaskEditorRegistryEntry, // "IStage": null, //ToDo implement this }; diff --git a/src/modules/manager/workflowTemplates/services/WorkflowTemplateService.ts b/src/modules/manager/workflowTemplates/services/WorkflowTemplateService.ts index 17fd298..e134474 100644 --- a/src/modules/manager/workflowTemplates/services/WorkflowTemplateService.ts +++ b/src/modules/manager/workflowTemplates/services/WorkflowTemplateService.ts @@ -6,7 +6,6 @@ import { MakeGeneralIdRefParams, } from "../../../../utils/GeneralIdRef"; import MapToJson from "../../../../utils/MapToJson"; -import { CustomFieldValue } from "../../glossary/services/glossaryService"; const apiEndpoint = "/WorkflowTemplate";