237 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
}
|