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.

This commit is contained in:
Colin Dawson 2026-02-14 00:08:26 +00:00
parent 2f300faf90
commit 1b4a834b19
12 changed files with 284 additions and 12 deletions

View File

@ -181,6 +181,7 @@
"Subject": "Subject", "Subject": "Subject",
"Support": "Support", "Support": "Support",
"SupportingData": "Supporting Data", "SupportingData": "Supporting Data",
"TasksValidationError": "Tasks configuration is invalid",
"TemplateIdCannotBeNull": "Template Id cannot be null", "TemplateIdCannotBeNull": "Template Id cannot be null",
"TemplateUnknown": "Template unknown", "TemplateUnknown": "Template unknown",
"Text": "Text", "Text": "Text",

View File

@ -32,3 +32,8 @@
background-color: inherit; background-color: inherit;
} }
} }
.error-icon {
margin-left: 6px;
color: $red;
}

View File

@ -1,9 +1,10 @@
import React, { useEffect, useState, useCallback, useMemo } from "react"; import React, { useEffect, useState, useCallback, useMemo } from "react";
import { useHashSegment } from "../../utils/HashNavigationContext"; import { useHashSegment } from "../../utils/HashNavigationContext";
import TabHeader from "./TabHeader"; import TabHeader from "./TabHeader";
import Tab from "./Tab";
interface HorizontalTabsProps { interface HorizontalTabsProps {
children: JSX.Element[]; children: React.ReactElement<typeof Tab>[];
initialTab?: string; initialTab?: string;
hashSegment?: number; hashSegment?: number;
activeTab?: string; activeTab?: string;
@ -20,6 +21,7 @@ const HorizontalTabs: React.FC<HorizontalTabsProps> = ({
const hashValue = useHashSegment( const hashValue = useHashSegment(
hashSegment !== undefined ? hashSegment : -1, hashSegment !== undefined ? hashSegment : -1,
); );
const [internalActiveTab, setInternalActiveTab] = useState<string>(""); const [internalActiveTab, setInternalActiveTab] = useState<string>("");
const isControlled = activeTab !== undefined; const isControlled = activeTab !== undefined;
@ -71,12 +73,14 @@ const HorizontalTabs: React.FC<HorizontalTabsProps> = ({
<div className="horizionalTabs"> <div className="horizionalTabs">
<ul className="tab-list"> <ul className="tab-list">
{children.map((child) => { {children.map((child) => {
const { id, label } = child.props; const { id, label, hasError } = child.props;
const tabId = id || label; const tabId = id;
return ( return (
<TabHeader <TabHeader
id={id}
key={label} key={label}
label={label} label={label}
hasError={hasError}
isActive={tabId === currentTab} isActive={tabId === currentTab}
onClick={() => onClickTabItem(tabId)} onClick={() => onClickTabItem(tabId)}
/> />

View File

@ -117,7 +117,7 @@ function Input(props: InputProps) {
name={name} name={name}
onChange={onChange} onChange={onChange}
disabled={readOnly} disabled={readOnly}
value={showValue || defaultValue} value={showValue ?? ""}
autoComplete={autoComplete} autoComplete={autoComplete}
></textarea> ></textarea>
)} )}

View File

@ -3,10 +3,16 @@ import React from "react";
interface TabProps { interface TabProps {
id?: string; id?: string;
label: string; label: string;
hasError?: boolean;
children: React.ReactNode; 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 ( return (
<div data-tab-id={id} data-tab-label={label}> <div data-tab-id={id} data-tab-label={label}>
{children} {children}

View File

@ -1,17 +1,23 @@
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
interface TabHeaderProps { interface TabHeaderProps {
id: string;
isActive: boolean; isActive: boolean;
label: string; label: string | React.ReactNode;
hasError: boolean;
onClick: (label: string) => void; onClick: (label: string) => void;
} }
export default function TabHeader({ export default function TabHeader({
id,
isActive, isActive,
label, label,
hasError,
onClick, onClick,
}: TabHeaderProps) { }: TabHeaderProps) {
const handleClick = useCallback(() => onClick(label), [onClick, label]); const handleClick = useCallback(() => onClick(id), [onClick, id]);
const className = isActive const className = isActive
? "tab-list-item tab-list-active" ? "tab-list-item tab-list-active"
@ -20,6 +26,11 @@ export default function TabHeader({
return ( return (
<li className={className} onClick={handleClick}> <li className={className} onClick={handleClick}>
{label} {label}
{hasError && (
<span className="error-icon">
<FontAwesomeIcon icon={faExclamationCircle} />
</span>
)}
</li> </li>
); );
} }

View File

@ -1,4 +1,4 @@
import React, { useEffect } from "react"; import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Namespaces } from "../../../i18n/i18n"; import { Namespaces } from "../../../i18n/i18n";
import HorizontalTabs from "../../../components/common/HorizionalTabs"; import HorizontalTabs from "../../../components/common/HorizionalTabs";
@ -124,9 +124,22 @@ const WorkflowTemplateDetails: React.FC<{ editMode: boolean }> = ({
form.handleSubmit(e, doSubmit); 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 <Navigate to={redirect} />; if (redirect) return <Navigate to={redirect} />;
let errors = { ...formErrors };
if (!tasksValid) {
errors["tasks"] = t("TasksValidationError");
}
// ----------------------------- // -----------------------------
// Tabs // Tabs
// ----------------------------- // -----------------------------
@ -141,12 +154,13 @@ const WorkflowTemplateDetails: React.FC<{ editMode: boolean }> = ({
/> />
</Tab>, </Tab>,
<Tab key="tasks" id="tasks" label={t("Tasks")}> <Tab key="tasks" id="tasks" label={t("Tasks")} hasError={!tasksValid}>
<TasksTab <TasksTab
data={data} data={data}
errors={errors} errors={errors}
isEditMode={editMode} isEditMode={editMode}
onTasksChange={form.handleTasksChange} onTasksChange={form.handleTasksChange}
onValidate={handleTasksValidate}
/> />
</Tab>, </Tab>,

View File

@ -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<TaskCoreEditorProps> = ({
task,
allTasks,
onChange,
onValidate,
}) => {
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const prevErrorsRef = useRef<Record<string, string>>({});
useEffect(() => {
const errors: Record<string, string> = {};
// 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 (
<div>
{renderTaskField(
task,
onChange,
"name",
"Name",
InputType.text,
fieldErrors["name"],
)}
{renderTaskField(
task,
onChange,
"description",
"Description",
InputType.textarea,
fieldErrors["description"],
)}
</div>
);
};

View File

@ -7,9 +7,12 @@ import AddTaskButton from "./AddTaskButton";
import { Namespaces } from "../../../../i18n/i18n"; import { Namespaces } from "../../../../i18n/i18n";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { SelectableList } from "../../../../components/common/SelectableList"; import { SelectableList } from "../../../../components/common/SelectableList";
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
interface TaskListProps { interface TaskListProps {
tasks: TaskDefinition[]; tasks: TaskDefinition[];
validTasksList: Record<string, boolean>;
taskType: string; taskType: string;
onChange: (tasks: TaskDefinition[]) => void; onChange: (tasks: TaskDefinition[]) => void;
selectedTask: TaskDefinition | null; selectedTask: TaskDefinition | null;
@ -18,6 +21,7 @@ interface TaskListProps {
const TaskList: React.FC<TaskListProps> = ({ const TaskList: React.FC<TaskListProps> = ({
tasks, tasks,
validTasksList,
taskType, taskType,
onChange, onChange,
selectedTask, selectedTask,
@ -57,7 +61,17 @@ const TaskList: React.FC<TaskListProps> = ({
<SelectableList <SelectableList
items={tasks} items={tasks}
selectedValue={selectedTask} selectedValue={selectedTask}
renderLabel={(x) => x.config.name as string} renderLabel={(x) => (
<>
{x.config.name as string}
{validTasksList[x.config.guid as string] === false && (
<span className="error-icon">
<FontAwesomeIcon icon={faExclamationCircle} />
</span>
)}
</>
)}
onSelect={(item) => onSelectTask(item)} onSelect={(item) => onSelectTask(item)}
/> />
</div> </div>

View File

@ -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<string, string>;
}
interface TaskEditorProps {
task: TaskDefinition;
allTasks: TaskDefinition[];
onChange: (updatedTask: TaskDefinition) => void;
onValidate: (taskId: string, isValid: boolean) => void;
}
export const TaskEditor: React.FC<TaskEditorProps> = ({
task,
allTasks,
onChange,
onValidate,
}) => {
const [validationMap, setValidationMap] = useState<
Record<string, TaskValidationResult>
>({});
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 (
<>
<TaskCoreEditor
task={task}
allTasks={allTasks}
onChange={onChange}
onValidate={(result) => onCapabilityValidate("core", result)}
/>
</>
);
};

View File

@ -4,12 +4,14 @@ import {
TaskDefinition, TaskDefinition,
} from "../services/WorkflowTemplateService"; } from "../services/WorkflowTemplateService";
import TaskList from "./TaskList"; import TaskList from "./TaskList";
import { TaskEditor } from "./TasksEditor";
interface TasksTabProps { interface TasksTabProps {
data: CreateWorkflowTemplateVersion; data: CreateWorkflowTemplateVersion;
errors: Record<string, string>; errors: Record<string, string>;
isEditMode: boolean; isEditMode: boolean;
onTasksChange: (name: string, value: TaskDefinition[]) => void; onTasksChange: (name: string, value: TaskDefinition[]) => void;
onValidate: (isValid: boolean) => void;
} }
const TasksTab: React.FC<TasksTabProps> = ({ const TasksTab: React.FC<TasksTabProps> = ({
@ -17,9 +19,27 @@ const TasksTab: React.FC<TasksTabProps> = ({
errors, errors,
isEditMode, isEditMode,
onTasksChange, onTasksChange,
onValidate,
}) => { }) => {
const tasks = data.tasks; const tasks = data.tasks;
const [selectedTask, setSelectedTask] = useState<TaskDefinition | null>(null); const [selectedTask, setSelectedTask] = useState<TaskDefinition | null>(null);
const [taskValidation, setTaskValidation] = useState<Record<string, boolean>>(
{},
);
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(() => { useEffect(() => {
if (tasks.length === 0) { if (tasks.length === 0) {
@ -63,13 +83,29 @@ const TasksTab: React.FC<TasksTabProps> = ({
<div className="fit-content-width"> <div className="fit-content-width">
<TaskList <TaskList
tasks={tasks} tasks={tasks}
validTasksList={taskValidation}
taskType="GeneralTask" taskType="GeneralTask"
onChange={handleTasksChange} onChange={handleTasksChange}
selectedTask={selectedTask} selectedTask={selectedTask}
onSelectTask={setSelectedTask} onSelectTask={setSelectedTask}
/> />
</div> </div>
<div>{selectedTask?.config.name as string}</div> {selectedTask && (
<TaskEditor
task={selectedTask}
allTasks={tasks}
onChange={(updatedTask) => {
const newTasks = tasks.map((t) =>
(t.config as any).guid === (updatedTask.config as any).guid
? updatedTask
: t,
);
handleTasksChange(newTasks);
setSelectedTask(updatedTask);
}}
onValidate={handleTaskValidate}
/>
)}
</div> </div>
); );
}; };

View File

@ -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<HTMLInputElement>) => {
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<HTMLInputElement>) => void,
readOnly: boolean = false,
placeholder: string = "",
maxLength: number = 0,
) => {
return (
<Input
includeLabel={true}
type={type}
name={name}
label={label}
value={value ?? ""}
error={error}
maxLength={maxLength}
onChange={onChange}
readOnly={readOnly}
placeHolder={placeholder}
/>
);
};