From 1b4a834b1981d951620e17ce75a2bb55fdb0cfe4 Mon Sep 17 00:00:00 2001 From: Colin Dawson Date: Sat, 14 Feb 2026 00:08:26 +0000 Subject: [PATCH] The validation engine is now functional, and will allow the user to get a breadcrumb path to the actual problem, without being cluttered up with error messages. --- public/locales/en/common.json | 1 + src/Sass/horizionalTabs.scss | 5 ++ src/components/common/HorizionalTabs.tsx | 10 ++- src/components/common/Input.tsx | 2 +- src/components/common/Tab.tsx | 8 ++- src/components/common/TabHeader.tsx | 15 ++++- .../WorkflowTemplateDetails.tsx | 20 +++++- .../CapabilityEditors/TaskCoreEditor.tsx | 67 +++++++++++++++++++ .../workflowTemplates/components/TaskList.tsx | 16 ++++- .../components/TasksEditor.tsx | 52 ++++++++++++++ .../workflowTemplates/components/TasksTab.tsx | 38 ++++++++++- .../components/taskEditorHelpers.tsx | 62 +++++++++++++++++ 12 files changed, 284 insertions(+), 12 deletions(-) create mode 100644 src/modules/manager/workflowTemplates/components/CapabilityEditors/TaskCoreEditor.tsx create mode 100644 src/modules/manager/workflowTemplates/components/TasksEditor.tsx create mode 100644 src/modules/manager/workflowTemplates/components/taskEditorHelpers.tsx diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 1122141..55bc9d9 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -181,6 +181,7 @@ "Subject": "Subject", "Support": "Support", "SupportingData": "Supporting Data", + "TasksValidationError": "Tasks configuration is invalid", "TemplateIdCannotBeNull": "Template Id cannot be null", "TemplateUnknown": "Template unknown", "Text": "Text", diff --git a/src/Sass/horizionalTabs.scss b/src/Sass/horizionalTabs.scss index 16d991b..d8a2b2f 100644 --- a/src/Sass/horizionalTabs.scss +++ b/src/Sass/horizionalTabs.scss @@ -32,3 +32,8 @@ background-color: inherit; } } + +.error-icon { + margin-left: 6px; + color: $red; +} diff --git a/src/components/common/HorizionalTabs.tsx b/src/components/common/HorizionalTabs.tsx index 8add5e0..7ad9760 100644 --- a/src/components/common/HorizionalTabs.tsx +++ b/src/components/common/HorizionalTabs.tsx @@ -1,9 +1,10 @@ import React, { useEffect, useState, useCallback, useMemo } from "react"; import { useHashSegment } from "../../utils/HashNavigationContext"; import TabHeader from "./TabHeader"; +import Tab from "./Tab"; interface HorizontalTabsProps { - children: JSX.Element[]; + children: React.ReactElement[]; initialTab?: string; hashSegment?: number; activeTab?: string; @@ -20,6 +21,7 @@ const HorizontalTabs: React.FC = ({ const hashValue = useHashSegment( hashSegment !== undefined ? hashSegment : -1, ); + const [internalActiveTab, setInternalActiveTab] = useState(""); const isControlled = activeTab !== undefined; @@ -71,12 +73,14 @@ const HorizontalTabs: React.FC = ({
    {children.map((child) => { - const { id, label } = child.props; - const tabId = id || label; + const { id, label, hasError } = child.props; + const tabId = id; return ( onClickTabItem(tabId)} /> diff --git a/src/components/common/Input.tsx b/src/components/common/Input.tsx index 5c1441e..1c76180 100644 --- a/src/components/common/Input.tsx +++ b/src/components/common/Input.tsx @@ -117,7 +117,7 @@ function Input(props: InputProps) { name={name} onChange={onChange} disabled={readOnly} - value={showValue || defaultValue} + value={showValue ?? ""} autoComplete={autoComplete} > )} diff --git a/src/components/common/Tab.tsx b/src/components/common/Tab.tsx index fd34f69..85532dc 100644 --- a/src/components/common/Tab.tsx +++ b/src/components/common/Tab.tsx @@ -3,10 +3,16 @@ import React from "react"; interface TabProps { id?: string; label: string; + hasError?: boolean; children: React.ReactNode; } -export default function Tab({ id, label, children }: TabProps): JSX.Element { +export default function Tab({ + id, + label, + hasError = false, + children, +}: TabProps): JSX.Element { return (
    {children} diff --git a/src/components/common/TabHeader.tsx b/src/components/common/TabHeader.tsx index d389168..2eb67e8 100644 --- a/src/components/common/TabHeader.tsx +++ b/src/components/common/TabHeader.tsx @@ -1,17 +1,23 @@ +import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import React, { useCallback } from "react"; interface TabHeaderProps { + id: string; isActive: boolean; - label: string; + label: string | React.ReactNode; + hasError: boolean; onClick: (label: string) => void; } export default function TabHeader({ + id, isActive, label, + hasError, onClick, }: TabHeaderProps) { - const handleClick = useCallback(() => onClick(label), [onClick, label]); + const handleClick = useCallback(() => onClick(id), [onClick, id]); const className = isActive ? "tab-list-item tab-list-active" @@ -20,6 +26,11 @@ export default function TabHeader({ return (
  • {label} + {hasError && ( + + + + )}
  • ); } diff --git a/src/modules/manager/workflowTemplates/WorkflowTemplateDetails.tsx b/src/modules/manager/workflowTemplates/WorkflowTemplateDetails.tsx index b548fab..f7b1061 100644 --- a/src/modules/manager/workflowTemplates/WorkflowTemplateDetails.tsx +++ b/src/modules/manager/workflowTemplates/WorkflowTemplateDetails.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Namespaces } from "../../../i18n/i18n"; import HorizontalTabs from "../../../components/common/HorizionalTabs"; @@ -124,9 +124,22 @@ const WorkflowTemplateDetails: React.FC<{ editMode: boolean }> = ({ form.handleSubmit(e, doSubmit); }; - const { loaded, redirect, errors, data } = form.state; + const [tasksValid, setTasksValid] = useState(true); + + const handleTasksValidate = (isValid: boolean) => { + console.log("Test", isValid); + setTasksValid(isValid); + }; + + const { loaded, redirect, errors: formErrors, data } = form.state; if (redirect) return ; + let errors = { ...formErrors }; + + if (!tasksValid) { + errors["tasks"] = t("TasksValidationError"); + } + // ----------------------------- // Tabs // ----------------------------- @@ -141,12 +154,13 @@ const WorkflowTemplateDetails: React.FC<{ editMode: boolean }> = ({ /> , - + , diff --git a/src/modules/manager/workflowTemplates/components/CapabilityEditors/TaskCoreEditor.tsx b/src/modules/manager/workflowTemplates/components/CapabilityEditors/TaskCoreEditor.tsx new file mode 100644 index 0000000..69cbc00 --- /dev/null +++ b/src/modules/manager/workflowTemplates/components/CapabilityEditors/TaskCoreEditor.tsx @@ -0,0 +1,67 @@ +import { useEffect, useRef, useState } from "react"; +import { InputType } from "../../../../../components/common/Input"; +import { TaskDefinition } from "../../services/WorkflowTemplateService"; +import { renderTaskField } from "../taskEditorHelpers"; +import { TaskValidationResult } from "../TasksEditor"; + +interface TaskCoreEditorProps { + task: TaskDefinition; + allTasks: TaskDefinition[]; + onChange: (updated: TaskDefinition) => void; + onValidate: (result: TaskValidationResult) => void; +} + +export const TaskCoreEditor: React.FC = ({ + task, + allTasks, + onChange, + onValidate, +}) => { + const [fieldErrors, setFieldErrors] = useState>({}); + const prevErrorsRef = useRef>({}); + + useEffect(() => { + const errors: Record = {}; + + // Validation rules + if (task.config.description === "") { + errors["description"] = "Description cannot be empty"; + } + + const isValid = Object.keys(errors).length === 0; + + // Compare with previous errors + const prevErrors = prevErrorsRef.current; + const errorsChanged = + Object.keys(prevErrors).length !== Object.keys(errors).length || + Object.entries(errors).some(([key, value]) => prevErrors[key] !== value); + + if (errorsChanged) { + setFieldErrors(errors); + onValidate({ isValid, errors }); + prevErrorsRef.current = errors; + } + }, [task.config.description, task.config.name, onValidate]); + + return ( +
    + {renderTaskField( + task, + onChange, + "name", + "Name", + InputType.text, + fieldErrors["name"], + )} + + {renderTaskField( + task, + onChange, + "description", + "Description", + InputType.textarea, + fieldErrors["description"], + )} +
    + ); +}; diff --git a/src/modules/manager/workflowTemplates/components/TaskList.tsx b/src/modules/manager/workflowTemplates/components/TaskList.tsx index cecfddc..1f567ea 100644 --- a/src/modules/manager/workflowTemplates/components/TaskList.tsx +++ b/src/modules/manager/workflowTemplates/components/TaskList.tsx @@ -7,9 +7,12 @@ import AddTaskButton from "./AddTaskButton"; import { Namespaces } from "../../../../i18n/i18n"; import { useTranslation } from "react-i18next"; import { SelectableList } from "../../../../components/common/SelectableList"; +import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; interface TaskListProps { tasks: TaskDefinition[]; + validTasksList: Record; taskType: string; onChange: (tasks: TaskDefinition[]) => void; selectedTask: TaskDefinition | null; @@ -18,6 +21,7 @@ interface TaskListProps { const TaskList: React.FC = ({ tasks, + validTasksList, taskType, onChange, selectedTask, @@ -57,7 +61,17 @@ const TaskList: React.FC = ({ x.config.name as string} + renderLabel={(x) => ( + <> + {x.config.name as string} + + {validTasksList[x.config.guid as string] === false && ( + + + + )} + + )} onSelect={(item) => onSelectTask(item)} />
    diff --git a/src/modules/manager/workflowTemplates/components/TasksEditor.tsx b/src/modules/manager/workflowTemplates/components/TasksEditor.tsx new file mode 100644 index 0000000..e49d5b5 --- /dev/null +++ b/src/modules/manager/workflowTemplates/components/TasksEditor.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import { TaskDefinition } from "../services/WorkflowTemplateService"; +import { TaskCoreEditor } from "./CapabilityEditors/TaskCoreEditor"; + +export interface TaskValidationResult { + isValid: boolean; + errors: Record; +} + +interface TaskEditorProps { + task: TaskDefinition; + allTasks: TaskDefinition[]; + onChange: (updatedTask: TaskDefinition) => void; + onValidate: (taskId: string, isValid: boolean) => void; +} + +export const TaskEditor: React.FC = ({ + task, + allTasks, + onChange, + onValidate, +}) => { + const [validationMap, setValidationMap] = useState< + Record + >({}); + + const onCapabilityValidate = ( + capabilityName: string, + result: TaskValidationResult, + ) => { + setValidationMap((prev) => { + const updated = { ...prev, [capabilityName]: result }; + + const allValid = Object.values(updated).every((r) => r.isValid); + + onValidate(task.config.guid as string, allValid); + + return updated; + }); + }; + + return ( + <> + onCapabilityValidate("core", result)} + /> + + ); +}; diff --git a/src/modules/manager/workflowTemplates/components/TasksTab.tsx b/src/modules/manager/workflowTemplates/components/TasksTab.tsx index 13c9731..9876d39 100644 --- a/src/modules/manager/workflowTemplates/components/TasksTab.tsx +++ b/src/modules/manager/workflowTemplates/components/TasksTab.tsx @@ -4,12 +4,14 @@ import { TaskDefinition, } from "../services/WorkflowTemplateService"; import TaskList from "./TaskList"; +import { TaskEditor } from "./TasksEditor"; interface TasksTabProps { data: CreateWorkflowTemplateVersion; errors: Record; isEditMode: boolean; onTasksChange: (name: string, value: TaskDefinition[]) => void; + onValidate: (isValid: boolean) => void; } const TasksTab: React.FC = ({ @@ -17,9 +19,27 @@ const TasksTab: React.FC = ({ errors, isEditMode, onTasksChange, + onValidate, }) => { const tasks = data.tasks; const [selectedTask, setSelectedTask] = useState(null); + const [taskValidation, setTaskValidation] = useState>( + {}, + ); + + const handleTaskValidate = (taskId: string, isValid: boolean) => { + setTaskValidation((prev) => { + const updated = { ...prev, [taskId]: isValid }; + + // Compute overall validity + const allValid = Object.values(updated).every((v) => v === true); + + // Bubble up to parent + onValidate(allValid); + + return updated; + }); + }; useEffect(() => { if (tasks.length === 0) { @@ -63,13 +83,29 @@ const TasksTab: React.FC = ({
    -
    {selectedTask?.config.name as string}
    + {selectedTask && ( + { + const newTasks = tasks.map((t) => + (t.config as any).guid === (updatedTask.config as any).guid + ? updatedTask + : t, + ); + handleTasksChange(newTasks); + setSelectedTask(updatedTask); + }} + onValidate={handleTaskValidate} + /> + )}
); }; diff --git a/src/modules/manager/workflowTemplates/components/taskEditorHelpers.tsx b/src/modules/manager/workflowTemplates/components/taskEditorHelpers.tsx new file mode 100644 index 0000000..42d600a --- /dev/null +++ b/src/modules/manager/workflowTemplates/components/taskEditorHelpers.tsx @@ -0,0 +1,62 @@ +import Input, { InputType } from "../../../../components/common/Input"; +import { TaskDefinition } from "../services/WorkflowTemplateService"; + +export const renderTaskField = ( + task: TaskDefinition, + onChange: (updated: TaskDefinition) => void, + field: string, + label: string, + type: InputType, + error?: string, + placeholder?: string, + maxLength?: number, +) => { + const handleChange = (e: React.ChangeEvent) => { + onChange({ + ...task, + config: { + ...task.config, + [field]: e.target.value, + }, + }); + }; + + return renderTaskInput( + field, + label, + (task.config as any)[field], + error, + type, + handleChange, + false, + placeholder ?? "", + maxLength ?? 0, + ); +}; + +export const renderTaskInput = ( + name: string, + label: string, + value: string | number | undefined, + error: string | undefined, + type: InputType = InputType.text, + onChange: (e: React.ChangeEvent) => void, + readOnly: boolean = false, + placeholder: string = "", + maxLength: number = 0, +) => { + return ( + + ); +};