Capability editor engine is now working and should allow me to define lots of editors for different c# interfaces, allowing composition of lots of task types.
This commit is contained in:
parent
86ebdc8b72
commit
a52b05037b
@ -4,12 +4,12 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Namespaces } from "../../../../i18n/i18n";
|
import { Namespaces } from "../../../../i18n/i18n";
|
||||||
|
|
||||||
interface AddTaskButtonProps {
|
interface AddTaskButtonProps {
|
||||||
allowedTasks: TaskMetadata[];
|
tasksMetadata: TaskMetadata[];
|
||||||
onAdd: (selectedType: TaskMetadata) => void;
|
onAdd: (selectedType: TaskMetadata) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AddTaskButton: React.FC<AddTaskButtonProps> = ({
|
const AddTaskButton: React.FC<AddTaskButtonProps> = ({
|
||||||
allowedTasks,
|
tasksMetadata,
|
||||||
onAdd,
|
onAdd,
|
||||||
}) => {
|
}) => {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
@ -30,7 +30,7 @@ const AddTaskButton: React.FC<AddTaskButtonProps> = ({
|
|||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="dropdown-menu show">
|
<div className="dropdown-menu show">
|
||||||
{allowedTasks.map((item) => (
|
{tasksMetadata.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.taskType}
|
key={item.taskType}
|
||||||
className="dropdown-item"
|
className="dropdown-item"
|
||||||
|
|||||||
@ -0,0 +1,33 @@
|
|||||||
|
import { TaskDefinition } from "../../services/WorkflowTemplateService";
|
||||||
|
import {
|
||||||
|
CapabilityEditorProps,
|
||||||
|
capabilityEditorRegistryEntry,
|
||||||
|
defaultsContext,
|
||||||
|
} from "../useCapabilityDefaults";
|
||||||
|
|
||||||
|
export const TagsEditor: React.FC<CapabilityEditorProps> = (props) => {
|
||||||
|
const { task, tasks, onChange, fieldErrors } = props;
|
||||||
|
|
||||||
|
return <>Tags editor goes here</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runValidation = (
|
||||||
|
task: TaskDefinition,
|
||||||
|
tasks: TaskDefinition[],
|
||||||
|
): Record<string, string> => {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function defaultsAssignment(
|
||||||
|
task: TaskDefinition,
|
||||||
|
tasks: TaskDefinition[],
|
||||||
|
ctx: defaultsContext,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
export const tagsEditorRegistryEntry: capabilityEditorRegistryEntry = {
|
||||||
|
Editor: TagsEditor,
|
||||||
|
DefaultsAssignment: defaultsAssignment,
|
||||||
|
ValidationRunner: runValidation,
|
||||||
|
};
|
||||||
@ -1,159 +1,77 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
import { InputType } from "../../../../../components/common/Input";
|
import { InputType } from "../../../../../components/common/Input";
|
||||||
import { TaskDefinition } from "../../services/WorkflowTemplateService";
|
import {
|
||||||
|
TaskDefinition,
|
||||||
|
TaskMetadata,
|
||||||
|
} from "../../services/WorkflowTemplateService";
|
||||||
import { renderTaskField } from "../taskEditorHelpers";
|
import { renderTaskField } from "../taskEditorHelpers";
|
||||||
import { CapabilityEditorProps } from "../TasksEditor";
|
import {
|
||||||
import { Namespaces } from "../../../../../i18n/i18n";
|
CapabilityEditorProps,
|
||||||
import { useTranslation } from "react-i18next";
|
capabilityEditorRegistryEntry,
|
||||||
|
defaultsContext,
|
||||||
|
} from "../useCapabilityDefaults";
|
||||||
|
|
||||||
import { getAllDescendants } from "../workflowGraphUtils";
|
import { getAllDescendants } from "../workflowGraphUtils";
|
||||||
import { useValidation } from "../useValidation";
|
|
||||||
import { useCapabilityDefaults } from "../useCapabilityDefaults";
|
|
||||||
|
|
||||||
export const TaskCoreEditor: React.FC<CapabilityEditorProps> = ({
|
function getAllowedPredecessors(task: TaskDefinition, tasks: TaskDefinition[]) {
|
||||||
task,
|
const currentGuid = task.config.guid as string;
|
||||||
allTasks,
|
const descendants = getAllDescendants(currentGuid, tasks);
|
||||||
allowedTasks,
|
|
||||||
onChange,
|
|
||||||
onValidate,
|
|
||||||
shouldAssignDefaults,
|
|
||||||
}) => {
|
|
||||||
const { t: tTaskType } = useTranslation(Namespaces.TaskTypes);
|
|
||||||
|
|
||||||
// Generate a unique default name
|
const allowedPredecessors = tasks.filter((t) => {
|
||||||
const nameExists = (tasks: TaskDefinition[], candidate: string): boolean => {
|
const guid = t.config.guid as string;
|
||||||
const target = candidate.trim().toLowerCase();
|
if (guid === currentGuid) return false;
|
||||||
|
if (descendants.has(guid)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
return tasks.some(
|
return allowedPredecessors;
|
||||||
(t) => (t.config.name as string)?.trim().toLowerCase() === target,
|
}
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatNewTaskName = useCallback(
|
const formatNewTaskName = (
|
||||||
(tasks: TaskDefinition[]) => {
|
task: TaskDefinition,
|
||||||
const displayName = allowedTasks.find(
|
tasks: TaskDefinition[],
|
||||||
|
taskMetaData: TaskMetadata[],
|
||||||
|
tTaskType: (key: string) => string,
|
||||||
|
) => {
|
||||||
|
const displayName = taskMetaData.find(
|
||||||
(t) => t.taskType === task.type,
|
(t) => t.taskType === task.type,
|
||||||
)?.displayName;
|
)?.displayName;
|
||||||
|
|
||||||
if (!displayName) return "New Task";
|
if (!displayName) return "New Task";
|
||||||
|
|
||||||
const base = `${tTaskType(displayName)} `;
|
const base = `${tTaskType(displayName)} `;
|
||||||
let index = 0;
|
|
||||||
|
|
||||||
while (nameExists(tasks, `${base}${index}`)) {
|
const existingNames = tasks
|
||||||
|
.map((t) => (t.config.name as string)?.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
let index = 1;
|
||||||
|
|
||||||
|
while (existingNames.includes(`${base}${index}`.toLowerCase())) {
|
||||||
index++;
|
index++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${base}${index}`;
|
return `${base}${index}`;
|
||||||
},
|
};
|
||||||
[allowedTasks, task.type, tTaskType],
|
|
||||||
);
|
|
||||||
|
|
||||||
const assignDefaults = useCallback(
|
const setDefaultPredecessors = (
|
||||||
(newConfig: Record<string, unknown>) => {
|
task: TaskDefinition,
|
||||||
const displayName = allowedTasks.find(
|
tasks: TaskDefinition[],
|
||||||
(t) => t.taskType === task.type,
|
): string[] => {
|
||||||
)?.displayName;
|
const allowedPredecessors = getAllowedPredecessors(task, tasks);
|
||||||
|
if (allowedPredecessors.length === 0) return [];
|
||||||
|
|
||||||
// Assign default name
|
return [
|
||||||
if (displayName) {
|
allowedPredecessors[allowedPredecessors.length - 1].config.guid as string,
|
||||||
newConfig.name = formatNewTaskName(allTasks);
|
];
|
||||||
}
|
};
|
||||||
|
|
||||||
// Assign default predecessor (the task immediately before this one)
|
export const TaskCoreEditor: React.FC<CapabilityEditorProps> = (props) => {
|
||||||
const index = allTasks.findIndex(
|
const { task, tasks, onChange, fieldErrors } = props;
|
||||||
(t) => t.config.guid === task.config.guid,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (index > 0) {
|
const allowedPredecessors = getAllowedPredecessors(task, tasks);
|
||||||
const previousTask = allTasks[index - 1];
|
|
||||||
newConfig.predecessors = [previousTask.config.guid as string];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[formatNewTaskName, task, allTasks, allowedTasks],
|
|
||||||
);
|
|
||||||
|
|
||||||
useCapabilityDefaults(shouldAssignDefaults, task, onChange, assignDefaults, [
|
|
||||||
allTasks,
|
|
||||||
allowedTasks,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const runValidation = useCallback(() => {
|
|
||||||
const errors: Record<string, string> = {};
|
|
||||||
|
|
||||||
// Name required
|
|
||||||
if (!(task.config.name as string)?.trim()) {
|
|
||||||
errors["name"] = "Name cannot be empty";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name must be unique
|
|
||||||
const duplicate = allTasks.find(
|
|
||||||
(t) =>
|
|
||||||
t.config.guid !== task.config.guid &&
|
|
||||||
(t.config.name as string)?.trim().toLowerCase() ===
|
|
||||||
(task.config.name as string)?.trim().toLowerCase(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (duplicate) {
|
|
||||||
errors["name"] = "Name must be unique.";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Description max length
|
|
||||||
const descriptionMaxLength = 5000;
|
|
||||||
if (
|
|
||||||
(task.config.description && (task.config.description as string).length) ??
|
|
||||||
0 > descriptionMaxLength
|
|
||||||
) {
|
|
||||||
errors["description"] =
|
|
||||||
`Description can be up to ${descriptionMaxLength} characters long.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Predecessors must not include self
|
|
||||||
if (
|
|
||||||
(task.config.predecessors as string[])?.includes(
|
|
||||||
task.config.guid as string,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
errors["predecessors"] = "A task cannot depend on itself.";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Predecessors must be unique
|
|
||||||
if (task.config.predecessors) {
|
|
||||||
const unique = new Set(task.config.predecessors as string[]);
|
|
||||||
if (unique.size !== (task.config.predecessors as string[]).length) {
|
|
||||||
errors["predecessors"] = "Duplicate predecessors are not allowed.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = Object.keys(errors).length === 0;
|
|
||||||
return { errors, isValid };
|
|
||||||
}, [task, allTasks]);
|
|
||||||
|
|
||||||
const { fieldErrors } = useValidation(runValidation, onValidate, [
|
|
||||||
task.config.guid,
|
|
||||||
task.config.name,
|
|
||||||
task.config.description,
|
|
||||||
task.config.predecessors,
|
|
||||||
runValidation,
|
|
||||||
onValidate,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const currentGuid = task.config.guid as string;
|
|
||||||
const descendants = getAllDescendants(currentGuid, allTasks);
|
|
||||||
|
|
||||||
const allowedPredecessors = allTasks.filter((t) => {
|
|
||||||
const guid = t.config.guid as string;
|
|
||||||
|
|
||||||
// Exclude self
|
|
||||||
if (guid === currentGuid) return false;
|
|
||||||
|
|
||||||
// Exclude descendants (direct or indirect)
|
|
||||||
if (descendants.has(guid)) return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
{renderTaskField(
|
{renderTaskField(
|
||||||
task,
|
task,
|
||||||
onChange,
|
onChange,
|
||||||
@ -162,7 +80,6 @@ export const TaskCoreEditor: React.FC<CapabilityEditorProps> = ({
|
|||||||
InputType.text,
|
InputType.text,
|
||||||
fieldErrors,
|
fieldErrors,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{renderTaskField(
|
{renderTaskField(
|
||||||
task,
|
task,
|
||||||
onChange,
|
onChange,
|
||||||
@ -171,7 +88,6 @@ export const TaskCoreEditor: React.FC<CapabilityEditorProps> = ({
|
|||||||
InputType.textarea,
|
InputType.textarea,
|
||||||
fieldErrors,
|
fieldErrors,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{renderTaskField(
|
{renderTaskField(
|
||||||
task,
|
task,
|
||||||
onChange,
|
onChange,
|
||||||
@ -188,6 +104,69 @@ export const TaskCoreEditor: React.FC<CapabilityEditorProps> = ({
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const runValidation = (
|
||||||
|
task: TaskDefinition,
|
||||||
|
tasks: TaskDefinition[],
|
||||||
|
): Record<string, string> => {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!(task.config.name as string)?.trim()) {
|
||||||
|
errors.name = "Name cannot be empty";
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicate = tasks.find(
|
||||||
|
(t) =>
|
||||||
|
t.config.guid !== task.config.guid &&
|
||||||
|
(t.config.name as string)?.trim().toLowerCase() ===
|
||||||
|
(task.config.name as string)?.trim().toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (duplicate) {
|
||||||
|
errors.name = "Name must be unique.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// const descriptionMaxLength = 5;
|
||||||
|
// if ((task.config.description as string)?.length > descriptionMaxLength) {
|
||||||
|
// errors.description = `Description can be up to ${descriptionMaxLength} characters long.`;
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (
|
||||||
|
(task.config.predecessors as string[])?.includes(task.config.guid as string)
|
||||||
|
) {
|
||||||
|
errors.predecessors = "A task cannot depend on itself.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.config.predecessors) {
|
||||||
|
const unique = new Set(task.config.predecessors as string[]);
|
||||||
|
if (unique.size !== (task.config.predecessors as string[]).length) {
|
||||||
|
errors.predecessors = "Duplicate predecessors are not allowed.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function defaultsAssignment(
|
||||||
|
task: TaskDefinition,
|
||||||
|
tasks: TaskDefinition[],
|
||||||
|
ctx: defaultsContext,
|
||||||
|
) {
|
||||||
|
task.config.name = formatNewTaskName(
|
||||||
|
task,
|
||||||
|
tasks,
|
||||||
|
ctx.taskMetadata,
|
||||||
|
ctx.tTaskType,
|
||||||
|
);
|
||||||
|
task.config.description = "";
|
||||||
|
task.config.predecessors = setDefaultPredecessors(task, tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const taskCoreEditorRegistryEntry: capabilityEditorRegistryEntry = {
|
||||||
|
Editor: TaskCoreEditor,
|
||||||
|
DefaultsAssignment: defaultsAssignment,
|
||||||
|
ValidationRunner: runValidation,
|
||||||
|
};
|
||||||
|
|||||||
@ -8,11 +8,12 @@ import { SelectableList } from "../../../../components/common/SelectableList";
|
|||||||
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons";
|
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { sortTasksTopologically } from "./workflowGraphUtils";
|
import { sortTasksTopologically } from "./workflowGraphUtils";
|
||||||
|
import { useCapabilityDefaults } from "./useCapabilityDefaults";
|
||||||
|
|
||||||
interface TaskListProps {
|
interface TaskListProps {
|
||||||
tasks: TaskDefinition[];
|
tasks: TaskDefinition[];
|
||||||
validTasksList: Record<string, boolean>;
|
validTasksList: Record<string, boolean>;
|
||||||
allowedTasks: TaskMetadata[];
|
tasksMetadata: TaskMetadata[];
|
||||||
onChange: (tasks: TaskDefinition[]) => void;
|
onChange: (tasks: TaskDefinition[]) => void;
|
||||||
selectedTask: TaskDefinition | null;
|
selectedTask: TaskDefinition | null;
|
||||||
onSelectTask: (task: TaskDefinition | null) => void;
|
onSelectTask: (task: TaskDefinition | null) => void;
|
||||||
@ -21,11 +22,13 @@ interface TaskListProps {
|
|||||||
const TaskList: React.FC<TaskListProps> = ({
|
const TaskList: React.FC<TaskListProps> = ({
|
||||||
tasks,
|
tasks,
|
||||||
validTasksList,
|
validTasksList,
|
||||||
allowedTasks,
|
tasksMetadata,
|
||||||
onChange,
|
onChange,
|
||||||
selectedTask,
|
selectedTask,
|
||||||
onSelectTask,
|
onSelectTask,
|
||||||
}) => {
|
}) => {
|
||||||
|
const runDefaults = useCapabilityDefaults(tasksMetadata);
|
||||||
|
|
||||||
const handleAddTask = (selectedType: TaskMetadata) => {
|
const handleAddTask = (selectedType: TaskMetadata) => {
|
||||||
const newTask: TaskDefinition = {
|
const newTask: TaskDefinition = {
|
||||||
type: selectedType.taskType,
|
type: selectedType.taskType,
|
||||||
@ -35,15 +38,20 @@ const TaskList: React.FC<TaskListProps> = ({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
onSelectTask(newTask);
|
//Assign the default values for the task here.
|
||||||
|
selectedType.capabilities.forEach((capability) => {
|
||||||
|
runDefaults(capability, newTask, tasks);
|
||||||
|
});
|
||||||
|
|
||||||
onChange([...tasks, newTask]);
|
onChange([...tasks, newTask]);
|
||||||
|
onSelectTask(newTask);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortedTasks = sortTasksTopologically(tasks);
|
const sortedTasks = sortTasksTopologically(tasks);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<AddTaskButton allowedTasks={allowedTasks} onAdd={handleAddTask} />
|
<AddTaskButton tasksMetadata={tasksMetadata} onAdd={handleAddTask} />
|
||||||
|
|
||||||
<SelectableList
|
<SelectableList
|
||||||
items={sortedTasks}
|
items={sortedTasks}
|
||||||
|
|||||||
@ -1,102 +1,84 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
TaskDefinition,
|
TaskDefinition,
|
||||||
TaskMetadata,
|
TaskMetadata,
|
||||||
} from "../services/WorkflowTemplateService";
|
} from "../services/WorkflowTemplateService";
|
||||||
import { TaskCoreEditor } from "./CapabilityEditors/TaskCoreEditor";
|
import { capabilityEditorRegistry } from "./capabilityEditorRegistry";
|
||||||
|
|
||||||
export interface CapabilityEditorProps {
|
|
||||||
task: TaskDefinition;
|
|
||||||
allTasks: TaskDefinition[];
|
|
||||||
allowedTasks: TaskMetadata[];
|
|
||||||
onChange: (updated: TaskDefinition) => void;
|
|
||||||
onValidate: (result: TaskValidationResult) => void;
|
|
||||||
shouldAssignDefaults: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TaskValidationResult {
|
|
||||||
isValid: boolean;
|
|
||||||
errors: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TaskEditorProps {
|
interface TaskEditorProps {
|
||||||
task: TaskDefinition;
|
task: TaskDefinition;
|
||||||
allTasks: TaskDefinition[];
|
tasks: TaskDefinition[];
|
||||||
allowedTasks: TaskMetadata[];
|
tasksMetadata: TaskMetadata[];
|
||||||
onChange: (updatedTask: TaskDefinition) => void;
|
onChange: (updatedTask: TaskDefinition) => void;
|
||||||
onValidate: (taskId: string, isValid: boolean) => void;
|
onValidate: (taskId: string, isValid: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TaskEditor: React.FC<TaskEditorProps> = ({
|
export const TaskEditor: React.FC<TaskEditorProps> = ({
|
||||||
task,
|
task,
|
||||||
allTasks,
|
tasks,
|
||||||
allowedTasks,
|
tasksMetadata,
|
||||||
onChange,
|
onChange,
|
||||||
onValidate,
|
onValidate,
|
||||||
}) => {
|
}) => {
|
||||||
//region assign defaults
|
const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>(
|
||||||
const hasAssignedDefaultsRef = useRef(false);
|
{},
|
||||||
const [shouldAssignDefaults, setShouldAssignDefaults] = useState(false);
|
);
|
||||||
|
|
||||||
// Reset guard when a new task is loaded
|
const validateTask = (task: TaskDefinition, tasks: TaskDefinition[]) => {
|
||||||
useEffect(() => {
|
const taskMeta = tasksMetadata.find((t) => t.taskType === task.type);
|
||||||
hasAssignedDefaultsRef.current = false;
|
|
||||||
setShouldAssignDefaults(false);
|
|
||||||
}, [task.config.guid]);
|
|
||||||
|
|
||||||
// Decide when to trigger initial defaults (current rule: no name yet)
|
const errors: Record<string, string> = {};
|
||||||
useEffect(() => {
|
|
||||||
if (!hasAssignedDefaultsRef.current && !task.config.name) {
|
for (const capability of taskMeta?.capabilities ?? []) {
|
||||||
hasAssignedDefaultsRef.current = true;
|
const entry = capabilityEditorRegistry[capability];
|
||||||
setShouldAssignDefaults(true);
|
if (!entry?.ValidationRunner) continue;
|
||||||
} else {
|
|
||||||
// ensure we only pulse true once
|
const validationErrors = entry.ValidationRunner(task, tasks);
|
||||||
setShouldAssignDefaults(false);
|
Object.assign(errors, validationErrors);
|
||||||
}
|
}
|
||||||
}, [task.config.name]);
|
return errors;
|
||||||
|
|
||||||
//end region assign defaults
|
|
||||||
|
|
||||||
//region Validation
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const [validationMap, setValidationMap] = useState<
|
|
||||||
Record<string, TaskValidationResult>
|
|
||||||
>({});
|
|
||||||
|
|
||||||
const onCapabilityValidate = (
|
|
||||||
capabilityName: string,
|
|
||||||
result: TaskValidationResult,
|
|
||||||
) => {
|
|
||||||
setValidationMap((prev) => {
|
|
||||||
const updated = { ...prev, [capabilityName]: result };
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const prevAllValidRef = useRef<boolean | null>(null);
|
const handleTaskChange = (updatedTask: TaskDefinition) => {
|
||||||
|
// Update the task list
|
||||||
|
const updatedTasks = tasks.map((t) =>
|
||||||
|
t.config.guid === updatedTask.config.guid ? updatedTask : t,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
// Run validation
|
||||||
const allValid = Object.values(validationMap).every((r) => r.isValid);
|
const errors = validateTask(updatedTask, updatedTasks);
|
||||||
|
setFieldErrors(errors);
|
||||||
|
|
||||||
// Only notify parent when the value actually changes
|
// Bubble validity up
|
||||||
if (prevAllValidRef.current !== allValid) {
|
onValidate(
|
||||||
prevAllValidRef.current = allValid;
|
updatedTask.config.guid as string,
|
||||||
onValidate(task.config.guid as string, allValid);
|
Object.keys(errors).length === 0,
|
||||||
}
|
);
|
||||||
}, [validationMap, task.config.guid, onValidate]);
|
|
||||||
|
|
||||||
//End region validation
|
// Bubble updated task up
|
||||||
|
onChange(updatedTask);
|
||||||
|
};
|
||||||
|
|
||||||
|
const taskMeta = tasksMetadata.find((t) => t.taskType === task.type);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div>
|
||||||
<TaskCoreEditor
|
{taskMeta?.capabilities.map((capability) => {
|
||||||
|
const entry = capabilityEditorRegistry[capability];
|
||||||
|
if (!entry) return null;
|
||||||
|
|
||||||
|
const Editor = entry.Editor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Editor
|
||||||
|
key={capability}
|
||||||
task={task}
|
task={task}
|
||||||
allowedTasks={allowedTasks}
|
tasks={tasks}
|
||||||
allTasks={allTasks}
|
onChange={handleTaskChange}
|
||||||
onChange={onChange}
|
fieldErrors={fieldErrors} // ← THIS IS THE MISSING PIECE
|
||||||
onValidate={(result) => onCapabilityValidate("core", result)}
|
|
||||||
shouldAssignDefaults={shouldAssignDefaults}
|
|
||||||
/>
|
/>
|
||||||
</>
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -26,48 +26,17 @@ const TasksTab: React.FC<TasksTabProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const tasks = data.tasks;
|
const tasks = data.tasks;
|
||||||
const [selectedTask, setSelectedTask] = useState<TaskDefinition | null>(null);
|
const [selectedTask, setSelectedTask] = useState<TaskDefinition | null>(null);
|
||||||
const [allowedTasks, setAllowedTasks] = React.useState<TaskMetadata[]>([]);
|
const [tasksMetadata, setTasksMetadata] = React.useState<TaskMetadata[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTaskMetadata = async () => {
|
const fetchTaskMetadata = async () => {
|
||||||
const meta = await templateVersionsService.getTaskMetadata("GeneralTask");
|
const meta = await templateVersionsService.getTaskMetadata("GeneralTask");
|
||||||
setAllowedTasks(meta);
|
setTasksMetadata(meta);
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchTaskMetadata();
|
fetchTaskMetadata();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (tasks.length === 0) {
|
|
||||||
if (selectedTask !== null) {
|
|
||||||
setSelectedTask(null);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedTask) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedGuid = (selectedTask.config as any)?.guid;
|
|
||||||
if (selectedGuid) {
|
|
||||||
const match = tasks.find(
|
|
||||||
(task) => (task.config as any)?.guid === selectedGuid,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (match && match !== selectedTask) {
|
|
||||||
setSelectedTask(match);
|
|
||||||
} else if (!match) {
|
|
||||||
setSelectedTask(tasks[0]);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tasks.includes(selectedTask)) {
|
|
||||||
setSelectedTask(tasks[0]);
|
|
||||||
}
|
|
||||||
}, [tasks, selectedTask]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Don't override user selection
|
// Don't override user selection
|
||||||
if (selectedTask) return;
|
if (selectedTask) return;
|
||||||
@ -102,7 +71,7 @@ const TasksTab: React.FC<TasksTabProps> = ({
|
|||||||
<TaskList
|
<TaskList
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
validTasksList={taskValidation}
|
validTasksList={taskValidation}
|
||||||
allowedTasks={allowedTasks}
|
tasksMetadata={tasksMetadata}
|
||||||
onChange={handleTasksChange}
|
onChange={handleTasksChange}
|
||||||
selectedTask={selectedTask}
|
selectedTask={selectedTask}
|
||||||
onSelectTask={setSelectedTask}
|
onSelectTask={setSelectedTask}
|
||||||
@ -110,17 +79,22 @@ const TasksTab: React.FC<TasksTabProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
{selectedTask && (
|
{selectedTask && (
|
||||||
<TaskEditor
|
<TaskEditor
|
||||||
allowedTasks={allowedTasks}
|
tasksMetadata={tasksMetadata}
|
||||||
task={selectedTask}
|
task={selectedTask}
|
||||||
allTasks={tasks}
|
tasks={tasks}
|
||||||
onChange={(updatedTask) => {
|
onChange={(updatedTask) => {
|
||||||
const newTasks = tasks.map((t) =>
|
const newTasks = tasks.map((t) =>
|
||||||
(t.config as any).guid === (updatedTask.config as any).guid
|
t.config.guid === updatedTask.config.guid ? updatedTask : t,
|
||||||
? updatedTask
|
|
||||||
: t,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
handleTasksChange(newTasks);
|
handleTasksChange(newTasks);
|
||||||
setSelectedTask(updatedTask);
|
|
||||||
|
// Use the updated object from the array, not the raw updatedTask
|
||||||
|
const updatedFromArray = newTasks.find(
|
||||||
|
(t) => t.config.guid === updatedTask.config.guid,
|
||||||
|
);
|
||||||
|
|
||||||
|
setSelectedTask(updatedFromArray!);
|
||||||
}}
|
}}
|
||||||
onValidate={onValidate}
|
onValidate={onValidate}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -0,0 +1,11 @@
|
|||||||
|
import { tagsEditorRegistryEntry } from "./CapabilityEditors/TagsEditor";
|
||||||
|
import { taskCoreEditorRegistryEntry } from "./CapabilityEditors/TaskCoreEditor";
|
||||||
|
import { capabilityEditorRegistryEntry } from "./useCapabilityDefaults";
|
||||||
|
|
||||||
|
export const capabilityEditorRegistry: Record<
|
||||||
|
string,
|
||||||
|
capabilityEditorRegistryEntry
|
||||||
|
> = {
|
||||||
|
ITask: taskCoreEditorRegistryEntry,
|
||||||
|
ITags: tagsEditorRegistryEntry,
|
||||||
|
};
|
||||||
@ -33,8 +33,8 @@ export const renderTaskField = (
|
|||||||
return renderTaskInput(
|
return renderTaskInput(
|
||||||
field,
|
field,
|
||||||
label,
|
label,
|
||||||
(task.config as any)[field],
|
task.config[field],
|
||||||
errors[field],
|
errors ? errors[field] : null,
|
||||||
type,
|
type,
|
||||||
handleChange,
|
handleChange,
|
||||||
false,
|
false,
|
||||||
|
|||||||
@ -1,23 +1,58 @@
|
|||||||
import { useEffect } from "react";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TaskDefinition } from "../services/WorkflowTemplateService";
|
import { Namespaces } from "../../../../i18n/i18n";
|
||||||
|
import {
|
||||||
|
TaskDefinition,
|
||||||
|
TaskMetadata,
|
||||||
|
} from "../services/WorkflowTemplateService";
|
||||||
|
import React from "react";
|
||||||
|
import { capabilityEditorRegistry } from "./capabilityEditorRegistry";
|
||||||
|
|
||||||
export function useCapabilityDefaults(
|
export interface TaskValidationResult {
|
||||||
shouldAssignDefaults: boolean,
|
isValid: boolean;
|
||||||
task: TaskDefinition,
|
errors: Record<string, string>;
|
||||||
onChange: (updated: TaskDefinition) => void,
|
}
|
||||||
assignDefaults: (newConfig: Record<string, unknown>) => void,
|
|
||||||
deps: unknown[],
|
export interface CapabilityEditorProps {
|
||||||
) {
|
task: TaskDefinition;
|
||||||
useEffect(() => {
|
tasks: TaskDefinition[];
|
||||||
if (!shouldAssignDefaults) return;
|
onChange: (updated: TaskDefinition) => void;
|
||||||
|
onValidate: (result: TaskValidationResult) => void;
|
||||||
const newConfig = { ...task.config };
|
fieldErrors: Record<string, string>;
|
||||||
|
}
|
||||||
assignDefaults(newConfig);
|
|
||||||
|
export interface defaultsContext {
|
||||||
onChange({
|
taskMetadata: TaskMetadata[];
|
||||||
...task,
|
tTaskType: (key: string) => string;
|
||||||
config: newConfig,
|
}
|
||||||
});
|
|
||||||
}, [shouldAssignDefaults, task, onChange, assignDefaults, ...deps]);
|
export interface capabilityEditorRegistryEntry {
|
||||||
|
Editor: React.FC<any>;
|
||||||
|
DefaultsAssignment?: (
|
||||||
|
task: TaskDefinition,
|
||||||
|
tasks: TaskDefinition[],
|
||||||
|
ctx: defaultsContext,
|
||||||
|
) => void;
|
||||||
|
ValidationRunner?: (
|
||||||
|
task: TaskDefinition,
|
||||||
|
tasks: TaskDefinition[],
|
||||||
|
) => Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCapabilityDefaults(taskMetadata: TaskMetadata[]) {
|
||||||
|
const { t: tTaskType } = useTranslation(Namespaces.TaskTypes);
|
||||||
|
|
||||||
|
const runDefaults = React.useCallback(
|
||||||
|
(capability: string, task: TaskDefinition, tasks: TaskDefinition[]) => {
|
||||||
|
const entry = capabilityEditorRegistry[capability];
|
||||||
|
if (!entry?.DefaultsAssignment) return;
|
||||||
|
|
||||||
|
entry.DefaultsAssignment(task, tasks, {
|
||||||
|
taskMetadata,
|
||||||
|
tTaskType,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[tTaskType, taskMetadata],
|
||||||
|
);
|
||||||
|
|
||||||
|
return runDefaults;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { TaskValidationResult } from "./TasksEditor";
|
|
||||||
|
|
||||||
export function useValidation(
|
|
||||||
runValidation: () => TaskValidationResult,
|
|
||||||
onValidate: (result: TaskValidationResult) => void,
|
|
||||||
deps: unknown[],
|
|
||||||
) {
|
|
||||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
|
||||||
const prevErrorsRef = useRef<Record<string, string>>({});
|
|
||||||
const prevInitialValidationRef = useRef<TaskValidationResult | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const result = runValidation();
|
|
||||||
const prev = prevInitialValidationRef.current;
|
|
||||||
|
|
||||||
const changed =
|
|
||||||
!prev ||
|
|
||||||
prev.isValid !== result.isValid ||
|
|
||||||
Object.keys(prev.errors).length !== Object.keys(result.errors).length ||
|
|
||||||
Object.entries(result.errors).some(([k, v]) => prev.errors[k] !== v);
|
|
||||||
|
|
||||||
if (changed) {
|
|
||||||
setFieldErrors(result.errors);
|
|
||||||
prevErrorsRef.current = result.errors;
|
|
||||||
onValidate(result);
|
|
||||||
prevInitialValidationRef.current = result;
|
|
||||||
}
|
|
||||||
}, deps);
|
|
||||||
|
|
||||||
return { fieldErrors };
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user