diff --git a/src/components/common/Input.tsx b/src/components/common/Input.tsx index 1c76180..bb27340 100644 --- a/src/components/common/Input.tsx +++ b/src/components/common/Input.tsx @@ -15,6 +15,7 @@ export enum InputType { hidden = "hidden", image = "image", month = "month", + multiselect = "multiselect", number = "number", password = "password", radio = "radio", @@ -45,8 +46,13 @@ export interface InputProps { step?: number; hidden?: boolean; autoComplete?: string; - onChange?: (e: React.ChangeEvent) => void; + onChange?: ( + e: React.ChangeEvent< + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement + >, + ) => void; maxLength?: number; + options?: { value: string; label: string }[]; } function Input(props: InputProps) { @@ -64,6 +70,7 @@ function Input(props: InputProps) { hidden, autoComplete, onChange, + options, ...rest } = props; @@ -94,6 +101,7 @@ function Input(props: InputProps) { type === InputType.password && showPasswordIcon === faEye ? InputType.text : type; + const divEyeIconClassName = readOnly ? "fullHeight disabledIcon" : "fullHeight"; @@ -109,7 +117,9 @@ function Input(props: InputProps) { {label} )} +
+ {/* TEXTAREA */} {type === InputType.textarea && ( + /> )} - {type !== InputType.textarea && ( + + {/* MULTISELECT */} + {type === InputType.multiselect && ( + + )} + + {/* ALL OTHER INPUT TYPES */} + {type !== InputType.textarea && type !== InputType.multiselect && ( )} + + {/* PASSWORD TOGGLE */} {type === InputType.password && (
)}
+
); diff --git a/src/modules/manager/workflowTemplates/components/CapabilityEditors/TaskCoreEditor.tsx b/src/modules/manager/workflowTemplates/components/CapabilityEditors/TaskCoreEditor.tsx index 8c284da..652a5ca 100644 --- a/src/modules/manager/workflowTemplates/components/CapabilityEditors/TaskCoreEditor.tsx +++ b/src/modules/manager/workflowTemplates/components/CapabilityEditors/TaskCoreEditor.tsx @@ -27,6 +27,7 @@ export const TaskCoreEditor: React.FC = ({ const { t: tTaskType } = useTranslation(Namespaces.TaskTypes); const [fieldErrors, setFieldErrors] = useState>({}); const prevErrorsRef = useRef>({}); + const hasAssignedDefaultName = useRef(false); // Generate a unique default name const nameExists = (tasks: TaskDefinition[], candidate: string): boolean => { @@ -57,27 +58,33 @@ export const TaskCoreEditor: React.FC = ({ [allowedTasks, task.type, tTaskType], ); + useEffect(() => { + if (!hasAssignedDefaultName.current && !task.config.name) { + hasAssignedDefaultName.current = true; + + const displayName = allowedTasks.find( + (t) => t.taskType === task.type, + )?.displayName; + + if (displayName) { + const newName = formatNewTaskName(allTasks); + + onChange({ + ...task, + config: { + ...task.config, + name: newName, + }, + }); + } + } + }, [allTasks, allowedTasks, formatNewTaskName, onChange, task]); + const runValidation = useCallback(() => { const errors: Record = {}; - // If the task doesn't have a name, generate a default one via onChange - if (!task.config.name) { - const newName = formatNewTaskName(allTasks); - - onChange({ - ...task, - config: { - ...task.config, - name: newName, - }, - }); - - // Stop here — next render will validate again - return { errors: {}, isValid: true }; - } - // Name required - if (!(task.config.name as string).trim()) { + if (!(task.config.name as string)?.trim()) { errors["name"] = "Name cannot be empty"; } @@ -85,8 +92,8 @@ export const TaskCoreEditor: React.FC = ({ 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(), + (t.config.name as string)?.trim().toLowerCase() === + (task.config.name as string)?.trim().toLowerCase(), ); if (duplicate) { @@ -103,35 +110,58 @@ export const TaskCoreEditor: React.FC = ({ `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, formatNewTaskName, onChange]); + }, [task, allTasks]); + + const prevInitialValidationRef = useRef<{ + isValid: boolean; + errors: Record; + } | null>(null); // Validate when task changes (new task selected / created) useEffect(() => { - const { errors, isValid } = runValidation(); + const result = runValidation(); - setFieldErrors(errors); - prevErrorsRef.current = errors; + const prev = prevInitialValidationRef.current; - onValidate({ isValid, errors }); - }, [task.config.guid, runValidation, onValidate]); + 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); - // Validate when fields change - useEffect(() => { - const { errors, isValid } = runValidation(); - - 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; + if (changed) { + setFieldErrors(result.errors); + prevErrorsRef.current = result.errors; + onValidate(result); + prevInitialValidationRef.current = result; } - }, [task.config.name, task.config.description, runValidation, onValidate]); + }, [ + task.config.guid, + task.config.name, + task.config.description, + task.config.predecessors, + runValidation, + onValidate, + ]); return (
@@ -152,6 +182,25 @@ export const TaskCoreEditor: React.FC = ({ InputType.textarea, fieldErrors["description"], )} + + {renderTaskField( + task, + onChange, + "predecessors", + "Predecessors", + InputType.multiselect, + fieldErrors["predecessors"], + "", + 0, + { + options: allTasks + .filter((t) => t.config.guid !== task.config.guid) + .map((t) => ({ + value: t.config.guid as string, + label: t.config.name as string, + })), + }, + )}
); }; diff --git a/src/modules/manager/workflowTemplates/components/TasksEditor.tsx b/src/modules/manager/workflowTemplates/components/TasksEditor.tsx index 0b89fb6..e02dab3 100644 --- a/src/modules/manager/workflowTemplates/components/TasksEditor.tsx +++ b/src/modules/manager/workflowTemplates/components/TasksEditor.tsx @@ -1,5 +1,8 @@ -import { useState } from "react"; -import { TaskDefinition } from "../services/WorkflowTemplateService"; +import { useEffect, useRef, useState } from "react"; +import { + TaskDefinition, + TaskMetadata, +} from "../services/WorkflowTemplateService"; import { TaskCoreEditor } from "./CapabilityEditors/TaskCoreEditor"; export interface TaskValidationResult { @@ -33,15 +36,22 @@ export const TaskEditor: React.FC = ({ ) => { 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; }); }; + const prevAllValidRef = useRef(null); + + useEffect(() => { + const allValid = Object.values(validationMap).every((r) => r.isValid); + + // Only notify parent when the value actually changes + if (prevAllValidRef.current !== allValid) { + prevAllValidRef.current = allValid; + onValidate(task.config.guid as string, allValid); + } + }, [validationMap, task.config.guid, onValidate]); + return ( <> { - const handleChange = (e: React.ChangeEvent) => { + const handleChange = ( + e: React.ChangeEvent< + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement + >, + ) => { + const newValue = e.target.value; + onChange({ ...task, config: { ...task.config, - [field]: e.target.value, + [field]: newValue, }, }); }; @@ -31,32 +40,44 @@ export const renderTaskField = ( false, placeholder ?? "", maxLength ?? 0, + extraProps, ); }; export const renderTaskInput = ( name: string, label: string, - value: string | number | undefined, + value: string | number | readonly string[] | undefined, error: string | undefined, type: InputType = InputType.text, - onChange: (e: React.ChangeEvent) => void, + onChange: ( + e: React.ChangeEvent, + ) => void, readOnly: boolean = false, placeholder: string = "", maxLength: number = 0, + extraProps?: { + options?: { value: string; label: string }[]; + }, ) => { + const normalisedValue = + type === InputType.multiselect + ? ((value as string[]) ?? []) + : (value ?? ""); + return ( ); };