ldc-groupe/resources/ts/back/Components/PageConfigs/DynamicFields.tsx
2025-10-17 14:52:51 +02:00

237 lines
8.1 KiB
TypeScript

// * Packages
import { useState, useEffect, useMemo, useRef } from "react";
import { usePage } from "@inertiajs/react";
import { PlusIcon } from "lucide-react";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import type { DragEndEvent } from "@dnd-kit/core";
// * Components shadcn
import { Button } from "@/components/ui/button";
// * Other
import SortableField from "@/back/Components/PageConfigs/SortableField";
import InputError from "@/back/Components/InputError";
import str from "@/hooks/use-string";
import { DynamicField, DynamicFieldTypes } from "@/back/types/global";
import useTrans from "@/hooks/use-translations";
// Generate unique stable id for each field.
const generateFieldId = (() => {
let count = 0;
return () => `field_id_${++count}`;
})();
type DynamicFieldsProps = {
name: string;
value: DynamicField[];
required?: boolean;
disabled?: boolean;
setData: (name: string, value: any) => void;
};
type InternalField = DynamicField & { id: string };
export default function DynamicFields({
name,
value = [],
required = true,
disabled = false,
setData,
}: DynamicFieldsProps) {
const enumDynamicFieldTypes = usePage().props.enumDynamicFieldTypes as DynamicFieldTypes;
// All fields with stable id.
const [fields, setFields] = useState<InternalField[]>(() =>
(value ?? []).map((field) => ({ id: generateFieldId(), ...field }))
);
// Local changes of fields (avoid loop between useEffects).
const prevValueRef = useRef(JSON.stringify(value ?? []));
const sensors = useSensors(useSensor(PointerSensor));
// Translations needed.
const trans = {
key: useTrans("validation.custom.key"),
label: useTrans("validation.custom.label"),
type: useTrans("validation.custom.type"),
validation: useTrans("validation.custom.validation"),
};
const transJunction = useTrans("validation.custom.of_the.male");
const transField = useTrans("validation.custom.field");
const transDelete = useTrans("back.config_delete_field");
const transDragAndDrop = useTrans("back.config_dnd_field");
const transConfigEmptyWarning = useTrans("back.config_empty_warning");
/**
* 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 => {
const transFieldValue = trans[field] ?? trans.label;
return str(`${transFieldValue} ${transJunction} ${transField} n°${id}`).ucFirst().value();
};
// Sorted fields by order.
const sortedFields = useMemo(
() => [...fields].sort((a, b) => (a.order ?? 1) - (b.order ?? 1)),
[fields]
);
/**
* Add a new field.
* @returns void
*/
const addField = (): void => {
const label = enumDynamicFieldTypes.string.label;
const maxOrder = Math.max(0, ...fields.map((field) => field.order ?? 0)) + 1;
const newField: InternalField = {
id: generateFieldId(),
key: `field_${maxOrder}`,
type: 1,
label,
validation: enumDynamicFieldTypes.string.validation,
order: maxOrder,
};
setFields((prev) => [...prev, newField]);
};
/**
* Update a specific field.
* @param fieldId string
* @param updatedField Partial<DynamicField>
* @returns void
*/
const updateField = (fieldId: string, updatedField: Partial<DynamicField>): void => {
setFields((prev) =>
prev.map((field) => {
if (field.id !== fieldId) return field;
const merged = { ...field, ...updatedField };
if (updatedField.type !== undefined) {
const matchingKey = Object.keys(enumDynamicFieldTypes).find(
(k) => enumDynamicFieldTypes[k].value === updatedField.type
);
if (matchingKey) merged.validation = enumDynamicFieldTypes[matchingKey].validation;
}
return merged;
})
);
};
/**
* Remove a specific field.
* @param fieldId string
* @returns void
*/
const removeField = (fieldId: string): void => {
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 === String(active.id));
const newIndex = sortedFields.findIndex((f) => f.id === String(over.id));
if (oldIndex === -1 || newIndex === -1) return;
setFields((prev) =>
arrayMove(prev, oldIndex, newIndex).map((fld, idx) => ({
...fld,
order: idx + 1,
}))
);
};
// Export to parent only if payload differs with prevValueRef.
useEffect(() => {
const payload = fields.map(({ id, ...rest }) => rest);
const payloadStr = JSON.stringify(payload);
if (payloadStr !== prevValueRef.current) {
setData(name, payload);
prevValueRef.current = payloadStr;
}
}, [fields, name, setData]);
// Synchronize incoming value from parent and preserve ids if key exist.
useEffect(() => {
if (!value) return;
const valueStr = JSON.stringify(value ?? []);
// Already synchronized.
if (valueStr === prevValueRef.current) return;
setFields((prev) =>
(value ?? []).map((field, i) => {
const existing = prev.find((p) => p.key === field.key);
return {
id: existing?.id ?? generateFieldId(),
...field,
order: field.order ?? i + 1,
} as InternalField;
})
);
prevValueRef.current = valueStr;
}, [value]);
return (
<div className="flex flex-col w-full gap-4">
{sortedFields.length ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortedFields.map((field) => field.id)}
strategy={verticalListSortingStrategy}
>
{sortedFields.map((field, index) => (
<SortableField
key={field.id}
fieldKey={field.key}
id={field.id}
index={index}
field={field}
removeField={() => removeField(field.id)}
updateField={(_, updatedInput) => updateField(field.id, updatedInput)}
required={required}
disabled={disabled}
getTransInput={getTransInput}
transDelete={transDelete}
transDragAndDrop={transDragAndDrop}
/>
))}
</SortableContext>
</DndContext>
) : (
<p className="text-sm text-muted-foreground text-center">
{transConfigEmptyWarning}
</p>
)}
<div className="flex justify-center md:justify-start items-center">
<Button type="button" onClick={addField} className="self-start">
<PlusIcon className="w-4 h-4 mr-2" />
{useTrans("back.config_new_field")}
</Button>
</div>
<InputError message={usePage().props.errors?.[name]} />
</div>
);
}