342 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|