Refactored the code so that the capability editors have even less duplicated code

This commit is contained in:
Colin Dawson 2026-02-14 23:39:03 +00:00
parent e7a084c301
commit 86ebdc8b72
5 changed files with 98 additions and 85 deletions

View File

@ -1,25 +1,15 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback } from "react";
import { InputType } from "../../../../../components/common/Input"; import { InputType } from "../../../../../components/common/Input";
import { import { TaskDefinition } from "../../services/WorkflowTemplateService";
TaskDefinition,
TaskMetadata,
} from "../../services/WorkflowTemplateService";
import { renderTaskField } from "../taskEditorHelpers"; import { renderTaskField } from "../taskEditorHelpers";
import { TaskValidationResult } from "../TasksEditor"; import { CapabilityEditorProps } from "../TasksEditor";
import { Namespaces } from "../../../../../i18n/i18n"; import { Namespaces } from "../../../../../i18n/i18n";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getAllDescendants } from "../workflowGraphUtils"; import { getAllDescendants } from "../workflowGraphUtils";
import { useValidation } from "../useValidation";
import { useCapabilityDefaults } from "../useCapabilityDefaults";
interface TaskCoreEditorProps { export const TaskCoreEditor: React.FC<CapabilityEditorProps> = ({
task: TaskDefinition;
allTasks: TaskDefinition[];
allowedTasks: TaskMetadata[];
onChange: (updated: TaskDefinition) => void;
onValidate: (result: TaskValidationResult) => void;
shouldAssignDefaults: boolean;
}
export const TaskCoreEditor: React.FC<TaskCoreEditorProps> = ({
task, task,
allTasks, allTasks,
allowedTasks, allowedTasks,
@ -28,8 +18,6 @@ export const TaskCoreEditor: React.FC<TaskCoreEditorProps> = ({
shouldAssignDefaults, shouldAssignDefaults,
}) => { }) => {
const { t: tTaskType } = useTranslation(Namespaces.TaskTypes); const { t: tTaskType } = useTranslation(Namespaces.TaskTypes);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const prevErrorsRef = useRef<Record<string, string>>({});
// Generate a unique default name // Generate a unique default name
const nameExists = (tasks: TaskDefinition[], candidate: string): boolean => { const nameExists = (tasks: TaskDefinition[], candidate: string): boolean => {
@ -49,7 +37,7 @@ export const TaskCoreEditor: React.FC<TaskCoreEditorProps> = ({
if (!displayName) return "New Task"; if (!displayName) return "New Task";
const base = `${tTaskType(displayName)} `; const base = `${tTaskType(displayName)} `;
let index = 1; let index = 0;
while (nameExists(tasks, `${base}${index}`)) { while (nameExists(tasks, `${base}${index}`)) {
index++; index++;
@ -60,49 +48,33 @@ export const TaskCoreEditor: React.FC<TaskCoreEditorProps> = ({
[allowedTasks, task.type, tTaskType], [allowedTasks, task.type, tTaskType],
); );
const assignDefaults = ( const assignDefaults = useCallback(
newConfig: Record<string, unknown>, (newConfig: Record<string, unknown>) => {
task: TaskDefinition, const displayName = allowedTasks.find(
allTasks: TaskDefinition[], (t) => t.taskType === task.type,
allowedTasks: TaskMetadata[], )?.displayName;
formatNewTaskName: (tasks: TaskDefinition[]) => string,
) => {
const displayName = allowedTasks.find(
(t) => t.taskType === task.type,
)?.displayName;
// Assign default name // Assign default name
if (displayName) { if (displayName) {
newConfig.name = formatNewTaskName(allTasks); newConfig.name = formatNewTaskName(allTasks);
} }
// Assign default predecessor (the task immediately before this one) // Assign default predecessor (the task immediately before this one)
const index = allTasks.findIndex((t) => t.config.guid === task.config.guid); const index = allTasks.findIndex(
(t) => t.config.guid === task.config.guid,
);
if (index > 0) { if (index > 0) {
const previousTask = allTasks[index - 1]; const previousTask = allTasks[index - 1];
newConfig.predecessors = [previousTask.config.guid as string]; newConfig.predecessors = [previousTask.config.guid as string];
} }
}; },
[formatNewTaskName, task, allTasks, allowedTasks],
);
useEffect(() => { useCapabilityDefaults(shouldAssignDefaults, task, onChange, assignDefaults, [
if (!shouldAssignDefaults) return;
const newConfig = { ...task.config };
assignDefaults(newConfig, task, allTasks, allowedTasks, formatNewTaskName);
onChange({
...task,
config: newConfig,
});
}, [
shouldAssignDefaults,
task,
allTasks, allTasks,
allowedTasks, allowedTasks,
formatNewTaskName,
onChange,
]); ]);
const runValidation = useCallback(() => { const runValidation = useCallback(() => {
@ -156,30 +128,7 @@ export const TaskCoreEditor: React.FC<TaskCoreEditorProps> = ({
return { errors, isValid }; return { errors, isValid };
}, [task, allTasks]); }, [task, allTasks]);
const prevInitialValidationRef = useRef<{ const { fieldErrors } = useValidation(runValidation, onValidate, [
isValid: boolean;
errors: Record<string, string>;
} | null>(null);
// Validate when task changes (new task selected / created)
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;
}
}, [
task.config.guid, task.config.guid,
task.config.name, task.config.name,
task.config.description, task.config.description,
@ -211,7 +160,7 @@ export const TaskCoreEditor: React.FC<TaskCoreEditorProps> = ({
"name", "name",
"Name", "Name",
InputType.text, InputType.text,
fieldErrors["name"], fieldErrors,
)} )}
{renderTaskField( {renderTaskField(
@ -220,7 +169,7 @@ export const TaskCoreEditor: React.FC<TaskCoreEditorProps> = ({
"description", "description",
"Description", "Description",
InputType.textarea, InputType.textarea,
fieldErrors["description"], fieldErrors,
)} )}
{renderTaskField( {renderTaskField(
@ -229,7 +178,7 @@ export const TaskCoreEditor: React.FC<TaskCoreEditorProps> = ({
"predecessors", "predecessors",
"Predecessors", "Predecessors",
InputType.multiselect, InputType.multiselect,
fieldErrors["predecessors"], fieldErrors,
"", "",
0, 0,
{ {

View File

@ -5,6 +5,15 @@ import {
} from "../services/WorkflowTemplateService"; } from "../services/WorkflowTemplateService";
import { TaskCoreEditor } from "./CapabilityEditors/TaskCoreEditor"; import { TaskCoreEditor } from "./CapabilityEditors/TaskCoreEditor";
export interface CapabilityEditorProps {
task: TaskDefinition;
allTasks: TaskDefinition[];
allowedTasks: TaskMetadata[];
onChange: (updated: TaskDefinition) => void;
onValidate: (result: TaskValidationResult) => void;
shouldAssignDefaults: boolean;
}
export interface TaskValidationResult { export interface TaskValidationResult {
isValid: boolean; isValid: boolean;
errors: Record<string, string>; errors: Record<string, string>;

View File

@ -7,7 +7,7 @@ export const renderTaskField = (
field: string, field: string,
label: string, label: string,
type: InputType, type: InputType,
error?: string, errors: Record<string, string>,
placeholder?: string, placeholder?: string,
maxLength?: number, maxLength?: number,
extraProps?: { extraProps?: {
@ -34,7 +34,7 @@ export const renderTaskField = (
field, field,
label, label,
(task.config as any)[field], (task.config as any)[field],
error, errors[field],
type, type,
handleChange, handleChange,
false, false,

View File

@ -0,0 +1,23 @@
import { useEffect } from "react";
import { TaskDefinition } from "../services/WorkflowTemplateService";
export function useCapabilityDefaults(
shouldAssignDefaults: boolean,
task: TaskDefinition,
onChange: (updated: TaskDefinition) => void,
assignDefaults: (newConfig: Record<string, unknown>) => void,
deps: unknown[],
) {
useEffect(() => {
if (!shouldAssignDefaults) return;
const newConfig = { ...task.config };
assignDefaults(newConfig);
onChange({
...task,
config: newConfig,
});
}, [shouldAssignDefaults, task, onChange, assignDefaults, ...deps]);
}

View File

@ -0,0 +1,32 @@
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 };
}