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:
parent
2f300faf90
commit
1b4a834b19
@ -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",
|
||||||
|
|||||||
@ -32,3 +32,8 @@
|
|||||||
background-color: inherit;
|
background-color: inherit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
margin-left: 6px;
|
||||||
|
color: $red;
|
||||||
|
}
|
||||||
|
|||||||
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>,
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user