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",
"Support": "Support",
"SupportingData": "Supporting Data",
"TasksValidationError": "Tasks configuration is invalid",
"TemplateIdCannotBeNull": "Template Id cannot be null",
"TemplateUnknown": "Template unknown",
"Text": "Text",

View File

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

View File

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

View File

@ -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 (
<div data-tab-id={id} data-tab-label={label}>
{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";
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 (
<li className={className} onClick={handleClick}>
{label}
{hasError && (
<span className="error-icon">
<FontAwesomeIcon icon={faExclamationCircle} />
</span>
)}
</li>
);
}

View File

@ -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 <Navigate to={redirect} />;
let errors = { ...formErrors };
if (!tasksValid) {
errors["tasks"] = t("TasksValidationError");
}
// -----------------------------
// Tabs
// -----------------------------
@ -141,12 +154,13 @@ const WorkflowTemplateDetails: React.FC<{ editMode: boolean }> = ({
/>
</Tab>,
<Tab key="tasks" id="tasks" label={t("Tasks")}>
<Tab key="tasks" id="tasks" label={t("Tasks")} hasError={!tasksValid}>
<TasksTab
data={data}
errors={errors}
isEditMode={editMode}
onTasksChange={form.handleTasksChange}
onValidate={handleTasksValidate}
/>
</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 { 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<string, boolean>;
taskType: string;
onChange: (tasks: TaskDefinition[]) => void;
selectedTask: TaskDefinition | null;
@ -18,6 +21,7 @@ interface TaskListProps {
const TaskList: React.FC<TaskListProps> = ({
tasks,
validTasksList,
taskType,
onChange,
selectedTask,
@ -57,7 +61,17 @@ const TaskList: React.FC<TaskListProps> = ({
<SelectableList
items={tasks}
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)}
/>
</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,
} from "../services/WorkflowTemplateService";
import TaskList from "./TaskList";
import { TaskEditor } from "./TasksEditor";
interface TasksTabProps {
data: CreateWorkflowTemplateVersion;
errors: Record<string, string>;
isEditMode: boolean;
onTasksChange: (name: string, value: TaskDefinition[]) => void;
onValidate: (isValid: boolean) => void;
}
const TasksTab: React.FC<TasksTabProps> = ({
@ -17,9 +19,27 @@ const TasksTab: React.FC<TasksTabProps> = ({
errors,
isEditMode,
onTasksChange,
onValidate,
}) => {
const tasks = data.tasks;
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(() => {
if (tasks.length === 0) {
@ -63,13 +83,29 @@ const TasksTab: React.FC<TasksTabProps> = ({
<div className="fit-content-width">
<TaskList
tasks={tasks}
validTasksList={taskValidation}
taskType="GeneralTask"
onChange={handleTasksChange}
selectedTask={selectedTask}
onSelectTask={setSelectedTask}
/>
</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>
);
};

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}
/>
);
};