ldc-groupe/resources/ts/back/Components/DynamicPages/DynamicList.tsx
2026-02-12 16:13:54 +01:00

402 lines
15 KiB
TypeScript

// * Packages
import { CSS } from "@dnd-kit/utilities";
import {
closestCenter,
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { usePage } from "@inertiajs/react";
import { useState } from "react";
import { GripVerticalIcon, TrashIcon } from "lucide-react";
// * Components shadcn
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Card, CardContent } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
// * Other
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
import AddNewFieldBtn from "@/back/Components/PageConfigs/AddNewFieldBtn";
import DynamicInputs from "@/back/Components/DynamicPages/DynamicInputs";
import useTrans from "@/hooks/use-translations";
import {
extractMinMaxFromValidation,
extractRequiredValidation,
} from "@/hooks/use-laravel-validation";
import { ConfigField, DynamicInternalField } from "@/back/types/dynamic-pages";
import { cn, GetTranslation } from "@/lib/utils";
/* eslint-disable no-unused-vars */
type SortableItemProps = {
id: number;
item: DynamicInternalField | null;
itemIndex: number;
name: string;
parentName: string;
field: ConfigField;
setData: (
name: string,
value: string | boolean | File | object | null,
) => void;
reset: (name?: string) => void;
removeField: (parentName: string, fieldKey: string, index: number) => void;
parentAccordionState: string;
setParentAccordionState: (key: string) => void;
localErrors: Record<string, string>;
};
/* eslint-enable no-unused-vars */
// Recursive component.
function SortableItem({
id,
item,
itemIndex,
name,
parentName,
field,
setData,
reset,
removeField,
parentAccordionState,
setParentAccordionState,
localErrors,
}: SortableItemProps) {
// Provides the necessary props and helpers to make this item draggable and reorderable.
// Includes event listeners, accessibility attributes, DOM ref, drag transform/transition values,
// and a flag indicating whether the item is dragging.
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
// Style applied to the sortable item:
// - handles drag movement (transform),
// - animations (transition),
// - and visual feedback (opacity, zIndex) while dragging.
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 50 : 0,
};
// Translations.
const transDragAndDrop = useTrans("back.dnd_field");
const transDelete = useTrans("back.delete_field");
return (
<div ref={setNodeRef} style={style} className="relative mb-3">
<AccordionItem value={`item-${itemIndex}`}>
<Card
className={cn({
"border-destructive/50 dark:border-destructive":
parentAccordionState !== `item-${itemIndex}` &&
Object.keys(localErrors).some((key) =>
key.startsWith(`${name}.${itemIndex}`),
),
})}
>
<CardContent className="flex flex-col p-3">
<div className="flex flex-col lg:flex-row-reverse gap-1.5">
<div className="flex flex-row gap-1.5 w-full h-full">
{/* Define a drag-and-drop button to swap the order between the current element and another one. */}
<div className="flex-grow flex items-stretch">
<BtnOptionnalLinkWithTooltip
content={
<GripVerticalIcon className="w-4 h-4 m-auto" />
}
tooltipContent={transDragAndDrop}
className="cursor-grab h-full w-full"
type="button"
variant="outline"
size="icon"
disabled={parentAccordionState !== ""}
/* eslint-disable react/jsx-props-no-spreading */
{...listeners}
{...attributes}
/* eslint-enable react/jsx-props-no-spreading */
/>
</div>
{/* Define a delete button to destroy the current element. */}
<BtnOptionnalLinkWithTooltip
content={<TrashIcon className="w-4 h-4" />}
tooltipContent={transDelete}
type="button"
variant="destructive"
size="icon"
onClick={() => {
if (
parentAccordionState ===
`item-${itemIndex}`
) {
setParentAccordionState("");
}
removeField(
parentName,
field.key,
itemIndex,
);
}}
/>
</div>
{/* Define the trigger button to open or close the current accordion. */}
<div className="w-full lg:w-2/3 xl:w-1/2">
<AccordionTrigger className="inline-flex items-center justify-between gap-2 whitespace-nowrap rounded-md text-sm font-medium bg-secondary text-secondary-foreground hover:bg-secondary/80 h-10 px-4 py-2 w-full">
Élément n°{itemIndex + 1}
</AccordionTrigger>
</div>
</div>
<AccordionContent className="pt-4 pb-1 px-1">
{/* Get specific input from the config value. */}
{field.children?.length && (
<DynamicInputs
name={`${name}.${itemIndex}`}
config={field.children}
content={item}
setData={setData}
reset={reset}
parentErrors={localErrors}
isChildren
/>
)}
</AccordionContent>
</CardContent>
</Card>
</AccordionItem>
</div>
);
}
/* eslint-disable no-unused-vars */
type DynamicListProps = {
name: string;
parentName: string;
value: DynamicInternalField[];
enumLabel: string;
field: ConfigField;
content: DynamicInternalField | null;
isChildren: boolean;
setData: (
name: string,
value: string | boolean | File | object | null,
) => void;
reset: (name?: string) => void;
};
/* eslint-enable no-unused-vars */
// Allows to get recursive list inputs.
export default function DynamicList({
name,
parentName,
value,
enumLabel,
field,
content,
isChildren,
setData,
reset,
}: DynamicListProps) {
// Errors.
const { errors } = usePage().props;
const [localErrors, setLocalErrors] =
useState<Record<string, string>>(errors);
// Drag & drop module for user interactions (mouse, touch, keyboard or other).
const sensors = useSensors(useSensor(PointerSensor));
// Define a local identifier for each element to enable unique identification during drag and drop.
const localValue = value.map((elmt, index) => {
return { ...elmt, id: elmt.id ?? index + 1 };
});
// Order all elements depending on order values.
const sortedListItems = [...localValue].sort((a, b) => a.order - b.order);
// Extract some values from laravel rules validation.
const isRequired = extractRequiredValidation(field.validation);
const configLimitMax =
extractMinMaxFromValidation(field.validation).max ?? 1;
// Accordion open index.
const [openIndex, setOpenIndex] = useState<string>("");
// Notice user there must be children config.
if (!field.children && field.children!.length > 0) {
return (
<p className="text-muted-foreground text-sm">
{GetTranslation("back.list_children_needed", {
params: { enumLabel },
})}
</p>
);
}
/**
* Remove a specific element from the list.
* @param currentParentName string
* @param fieldKey string
* @param index number
* @return void
*/
const handleRemoveField = (
currentParentName: string,
fieldKey: string,
index: number,
) => {
// Get the current list of elements.
const list = Array.isArray(content?.[fieldKey as keyof typeof content])
? // @ts-expect-error Weird error type.
[...content[fieldKey as keyof typeof content]]
: [];
// Remove the targeted element.
list.splice(index, 1);
// Save the new list updated.
setData(`${currentParentName}.${fieldKey}`, list);
// Reset all errors because of alter elements list.
setLocalErrors({});
};
/**
* Reorder the elements list.
* @param event DragEndEvent
* @return void
*/
const handleOnDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
// Check the unique identification value.
if (!over || active.id === over.id) return;
// Get the two elements that are switchable by drag and drop.
const oldIndex = sortedListItems.findIndex((i) => i.id === active.id);
const newIndex = sortedListItems.findIndex((i) => i.id === over.id);
// Reorder all elements list to update visually all elements.
const reorderedList = arrayMove(
sortedListItems,
oldIndex,
newIndex,
).map((item, index) => ({
...item,
order: index + 1,
}));
// Save the new ordered list elements.
setData(`${parentName}.${field.key}`, reorderedList);
};
/**
* Add a new field to the list.
* @return void
*/
const handleAddNewField = () => {
// Set new element with unique identification and order.
const newItem = Object.fromEntries(
(field.children ?? []).map((child) => [child.key, ""]),
);
const maxOrder =
Math.max(
0,
...sortedListItems.map(
(currentField) => currentField.order ?? 0,
),
) + 1;
const maxIndex =
Math.max(
0,
...sortedListItems.map((currentField) => currentField.id ?? 0),
) + 1;
const newIndex = value.length;
const newItemWithOrder = { ...newItem, id: maxIndex, order: maxOrder };
// Save the list with the new element.
setData(`${parentName}.${field.key}.${newIndex}`, newItemWithOrder);
};
return (
<div className="col-span-1 md:col-span-2">
<Label htmlFor={parentName}>
{field.label + (isRequired ? " *" : "")}
</Label>
{value.length > 0 && (
<Accordion
type="single"
collapsible
className="w-full"
value={openIndex}
onValueChange={setOpenIndex}
>
{/*
The main provider for dnd-kit.
It manages the entire drag-and-drop system: sensors, movement, collision detection, and lifecycle callbacks (onDragStart, onDragEnd, etc.).
*/}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleOnDragEnd}
>
{/*
Defines a reorderable list area.
It registers sortable items and applies the chosen sorting strategy (vertical, horizontal, grid) to handle item repositioning.
*/}
<SortableContext
items={sortedListItems.map((item) => item.id)}
strategy={verticalListSortingStrategy}
>
{/* Use the recursive component for each elements. */}
{sortedListItems.map((item, itemIndex) => (
<SortableItem
key={item.id}
id={item.id}
item={item}
itemIndex={itemIndex}
name={name}
parentName={parentName}
field={field}
setData={setData}
reset={reset}
removeField={(
currentParentName,
fieldKey,
index,
) =>
handleRemoveField(
currentParentName,
fieldKey,
index,
)
}
parentAccordionState={openIndex}
setParentAccordionState={setOpenIndex}
localErrors={localErrors}
/>
))}
</SortableContext>
</DndContext>
</Accordion>
)}
<AddNewFieldBtn
array={sortedListItems}
limitMax={configLimitMax}
onClick={handleAddNewField}
variant={isChildren ? "secondary" : "default"}
/>
</div>
);
}