Fixed lots of bugs where useEffect were being triggered repeatedly.
This commit is contained in:
parent
291a391faf
commit
1e1293f005
@ -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<HTMLInputElement>) => 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}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className={flexClassName}>
|
||||
{/* TEXTAREA */}
|
||||
{type === InputType.textarea && (
|
||||
<textarea
|
||||
id={name}
|
||||
@ -119,9 +129,41 @@ function Input(props: InputProps) {
|
||||
disabled={readOnly}
|
||||
value={showValue ?? ""}
|
||||
autoComplete={autoComplete}
|
||||
></textarea>
|
||||
/>
|
||||
)}
|
||||
{type !== InputType.textarea && (
|
||||
|
||||
{/* MULTISELECT */}
|
||||
{type === InputType.multiselect && (
|
||||
<select
|
||||
id={name}
|
||||
className={className}
|
||||
name={name}
|
||||
multiple
|
||||
disabled={readOnly}
|
||||
value={(value as string[]) ?? []}
|
||||
onChange={(e) => {
|
||||
const selected = Array.from(e.target.selectedOptions).map(
|
||||
(opt) => opt.value,
|
||||
);
|
||||
onChange?.({
|
||||
...e,
|
||||
target: {
|
||||
...e.target,
|
||||
value: selected,
|
||||
},
|
||||
} as any);
|
||||
}}
|
||||
>
|
||||
{options?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* ALL OTHER INPUT TYPES */}
|
||||
{type !== InputType.textarea && type !== InputType.multiselect && (
|
||||
<input
|
||||
{...rest}
|
||||
id={name}
|
||||
@ -138,6 +180,8 @@ function Input(props: InputProps) {
|
||||
autoComplete={autoComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* PASSWORD TOGGLE */}
|
||||
{type === InputType.password && (
|
||||
<div className={divEyeIconClassName}>
|
||||
<FontAwesomeIcon
|
||||
@ -151,6 +195,7 @@ function Input(props: InputProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ErrorBlock error={error}></ErrorBlock>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -27,6 +27,7 @@ export const TaskCoreEditor: React.FC<TaskCoreEditorProps> = ({
|
||||
const { t: tTaskType } = useTranslation(Namespaces.TaskTypes);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const prevErrorsRef = useRef<Record<string, string>>({});
|
||||
const hasAssignedDefaultName = useRef(false);
|
||||
|
||||
// Generate a unique default name
|
||||
const nameExists = (tasks: TaskDefinition[], candidate: string): boolean => {
|
||||
@ -57,11 +58,15 @@ export const TaskCoreEditor: React.FC<TaskCoreEditorProps> = ({
|
||||
[allowedTasks, task.type, tTaskType],
|
||||
);
|
||||
|
||||
const runValidation = useCallback(() => {
|
||||
const errors: Record<string, string> = {};
|
||||
useEffect(() => {
|
||||
if (!hasAssignedDefaultName.current && !task.config.name) {
|
||||
hasAssignedDefaultName.current = true;
|
||||
|
||||
// If the task doesn't have a name, generate a default one via onChange
|
||||
if (!task.config.name) {
|
||||
const displayName = allowedTasks.find(
|
||||
(t) => t.taskType === task.type,
|
||||
)?.displayName;
|
||||
|
||||
if (displayName) {
|
||||
const newName = formatNewTaskName(allTasks);
|
||||
|
||||
onChange({
|
||||
@ -71,13 +76,15 @@ export const TaskCoreEditor: React.FC<TaskCoreEditorProps> = ({
|
||||
name: newName,
|
||||
},
|
||||
});
|
||||
|
||||
// Stop here — next render will validate again
|
||||
return { errors: {}, isValid: true };
|
||||
}
|
||||
}
|
||||
}, [allTasks, allowedTasks, formatNewTaskName, onChange, task]);
|
||||
|
||||
const runValidation = useCallback(() => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
// 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<TaskCoreEditorProps> = ({
|
||||
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<TaskCoreEditorProps> = ({
|
||||
`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<string, string>;
|
||||
} | 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 (
|
||||
<div>
|
||||
@ -152,6 +182,25 @@ export const TaskCoreEditor: React.FC<TaskCoreEditorProps> = ({
|
||||
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,
|
||||
})),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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<TaskEditorProps> = ({
|
||||
) => {
|
||||
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<boolean | null>(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 (
|
||||
<>
|
||||
<TaskCoreEditor
|
||||
|
||||
@ -10,13 +10,22 @@ export const renderTaskField = (
|
||||
error?: string,
|
||||
placeholder?: string,
|
||||
maxLength?: number,
|
||||
extraProps?: {
|
||||
options?: { value: string; label: string }[];
|
||||
},
|
||||
) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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<HTMLInputElement>) => void,
|
||||
onChange: (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
) => 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 (
|
||||
<Input
|
||||
includeLabel={true}
|
||||
type={type}
|
||||
name={name}
|
||||
label={label}
|
||||
value={value ?? ""}
|
||||
value={normalisedValue}
|
||||
error={error}
|
||||
maxLength={maxLength}
|
||||
onChange={onChange}
|
||||
readOnly={readOnly}
|
||||
placeHolder={placeholder}
|
||||
{...extraProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user