ldc-laravel-base-project/resources/ts/back/Components/PageConfigs/DynamicFields.tsx
2026-07-09 17:32:17 +02:00

342 lines
10 KiB
TypeScript

import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import type { DragEndEvent } from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { usePage } from "@inertiajs/react";
import { useState, useEffect, useMemo, useRef } from "react";
import InputError from "@/back/Components/InputError";
import AddNewFieldBtn from "@/back/Components/PageConfigs/AddNewFieldBtn";
import FieldGroup from "@/back/Components/PageConfigs/FieldGroup";
import type {
ConfigField,
ConfigInternalField,
DynamicFieldType,
} from "@/back/types/dynamic-pages";
import { Accordion } from "@/components/ui/accordion";
import str from "@/hooks/use-string";
import useTrans from "@/hooks/use-translations";
interface DynamicFieldsProps {
name: string;
value: ConfigField[];
required?: boolean;
disabled?: boolean;
selectedConfig: string | null;
setData: (name: string, value: ConfigField[]) => void;
}
export default function DynamicFields({
name,
value,
required = true,
disabled = false,
selectedConfig,
setData,
}: DynamicFieldsProps) {
// Errors.
const { errors } = usePage().props;
const [localErrors, setLocalErrors] =
useState<Record<string, string>>(errors);
// Enums.
const enumDynamicFieldTypes = usePage().props
.enumDynamicFieldTypes as DynamicFieldType;
// Accordion open index.
const [openIndex, setOpenIndex] = useState<string>("");
// Local changes of fields (avoid loop between useEffects).
const prevValueRef = useRef(JSON.stringify(value));
// Sensitive fields.
const [originalConfigKeys, setOriginalConfigKeys] = useState<string[]>([]);
const addIdsRecursively = (
fields: ConfigField[],
parentKey: string = name,
startId = 0,
): ConfigInternalField[] => {
let currentId = startId;
const withIds = fields.map((field) => {
currentId += 1;
const id = currentId;
// Add the key in sensitive array.
const currentParentKey = `${parentKey}.${field.key}`;
setOriginalConfigKeys((prev) => [...prev, currentParentKey]);
const children = field.children
? addIdsRecursively(field.children, currentParentKey, currentId)
: undefined;
if (field.children?.length) {
currentId += field.children.length;
}
return children ? { ...field, id, children } : { ...field, id };
});
return withIds as ConfigInternalField[];
};
const [fields, setFields] = useState<ConfigInternalField[]>(() =>
addIdsRecursively(value),
);
// Sorted fields by order.
const sortedFields = useMemo(
() => [...fields].sort((a, b) => a.order - b.order),
[fields],
);
// Drag & drop module for user interactions (mouse, touch, keyboard or other).
const sensors = useSensors(useSensor(PointerSensor));
// Translations needed.
const trans = {
key: useTrans("uwil-dynamic-pages::trans.attributes.key"),
label: useTrans("uwil-dynamic-pages::trans.attributes.label"),
type: useTrans("uwil-dynamic-pages::trans.attributes.type"),
validation: useTrans("uwil-dynamic-pages::trans.attributes.validation"),
children: useTrans("uwil-dynamic-pages::trans.attributes.children"),
};
const transJunction = useTrans(
"uwil-dynamic-pages::trans.attributes.of_the.male",
);
const transField = useTrans("uwil-dynamic-pages::trans.attributes.field");
const transDelete = useTrans("uwil-dynamic-pages::trans.delete_field");
const transDragAndDrop = useTrans("uwil-dynamic-pages::trans.dnd_field");
// Set the depth limit of each list elements.
const MAX_DEPTH = 10;
/**
* Get a translation for a specific input from trans constant.
* @param field string
* @param id number
* @returns string
*/
const getTransInput = (field: keyof typeof trans, id: number): string => {
return str(`${trans[field]} ${transJunction} ${transField} n°${String(id)}`)
.ucFirst()
.value();
};
/**
* Add a new field.
* @returns void
*/
const addField = (): void => {
const maxOrder = Math.max(0, ...fields.map((field) => field.order)) + 1;
const maxIndex = Math.max(0, ...fields.map((field) => field.id)) + 1;
const newField: ConfigInternalField = {
id: maxIndex,
key: `${enumDynamicFieldTypes.string.name}_`,
type: enumDynamicFieldTypes.string.value,
label: "",
validation: enumDynamicFieldTypes.string.validation,
order: maxOrder,
};
setFields((prev) => [...prev, newField]);
};
/**
* Update a specific field.
* @param fieldId string
* @param updatedField Partial<ConfigField>
* @returns void
*/
const updateField = (
fieldId: number,
updatedField: Partial<ConfigField>,
): void => {
setFields((prev) =>
prev.map((field): ConfigInternalField => {
if (field.id !== fieldId) return field;
// Merge the data.
const merged: ConfigInternalField = {
...field,
...updatedField,
// Préserve les children typés ConfigInternalField si updatedField n'en fournit pas
children:
(updatedField.children as ConfigInternalField[] | undefined) ??
field.children,
};
// Only if updated field is type.
if (updatedField.type !== undefined) {
const matchingKey = Object.keys(enumDynamicFieldTypes).find(
(k) => enumDynamicFieldTypes[k].value === updatedField.type,
);
const originalMatchingKey = Object.keys(enumDynamicFieldTypes).find(
(k) => enumDynamicFieldTypes[k].value === field.type,
);
const editableKey = field.key.split(
`${originalMatchingKey ?? enumDynamicFieldTypes.string.name}_`,
)[1];
if (matchingKey) {
merged.validation = enumDynamicFieldTypes[matchingKey].validation;
merged.type = enumDynamicFieldTypes[matchingKey].value;
merged.key = `${matchingKey}_${editableKey}`;
}
}
return merged;
}),
);
};
/**
* Remove sensitive field list.
* @param keyToRemove string
*/
const removeSensitiveField = (keyToRemove: string): void => {
setOriginalConfigKeys((prev) => prev.filter((key) => key !== keyToRemove));
// Reset all errors because of alter elements list.
setLocalErrors({});
};
/**
* Remove a specific field.
* @param fieldId string
* @returns void
*/
const removeField = (fieldId: number): void => {
const targetField = fields.find((field) => field.id === fieldId);
if (!targetField) return;
removeSensitiveField(targetField.key);
setFields((prev) => prev.filter((field) => field.id !== fieldId));
};
/**
* Save the new order.
* @param event DragEndEvent
* @return void
*/
const handleDragEnd = (event: DragEndEvent): void => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = sortedFields.findIndex((f) => f.id === Number(active.id));
const newIndex = sortedFields.findIndex((f) => f.id === Number(over.id));
if (oldIndex < 0 || newIndex < 0) return;
setFields((prev) => {
const moved = arrayMove(prev, oldIndex, newIndex);
return moved.map((fld, idx) => ({
...fld,
order: idx + 1,
}));
});
};
/**
* Remove all local ids.
* @param currentFields ConfigInternalField[]
* @returns
*/
const removeIds = (currentFields: ConfigInternalField[]): ConfigField[] => {
// Delete the id, keep only the children if they are present, and keep the rest.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
return currentFields.map(({ id, children, ...rest }) => ({
...rest,
...(children ? { children: removeIds(children) } : {}),
}));
};
// Export to parent only if payload differs with prevValueRef.
useEffect(() => {
const payload = removeIds(fields);
const payloadStr = JSON.stringify(payload);
if (payloadStr !== prevValueRef.current) {
setData(name, payload);
prevValueRef.current = payloadStr;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fields]);
// Update fields when copying config from another locale.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setFields(addIdsRecursively(value));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedConfig]);
// Update errors from parent.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLocalErrors(errors);
}, [errors]);
return (
<div className="flex flex-col w-full">
{sortedFields.length > 0 && (
<Accordion
type="single"
className="w-full"
value={openIndex}
onValueChange={setOpenIndex}
collapsible
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortedFields.map((field) => String(field.id))}
strategy={verticalListSortingStrategy}
>
{sortedFields.map((field, index) => (
<FieldGroup
key={field.id}
currentId={field.id}
currentIndex={index}
currentFieldKey={name}
parentFieldKey={name}
currentField={field}
removeField={() => {
removeField(field.id);
}}
updateField={(_, updatedInput) => {
updateField(field.id, updatedInput);
}}
currentRequired={required}
currentDisabled={disabled}
sensitiveField={originalConfigKeys}
getTransInput={getTransInput}
transDelete={transDelete}
transDragAndDrop={transDragAndDrop}
removeSensitiveField={removeSensitiveField}
localErrors={localErrors}
setLocalErrors={setLocalErrors}
parentAccordionState={openIndex}
setParentAccordionState={setOpenIndex}
limitMaxChildren={MAX_DEPTH}
/>
))}
</SortableContext>
</DndContext>
</Accordion>
)}
<AddNewFieldBtn
className={localErrors[name] ? "mb-3" : ""}
array={fields}
limitMax={MAX_DEPTH}
onClick={addField}
/>
<InputError message={localErrors[name]} />
</div>
);
}