ldc-groupe/resources/ts/back/Components/DynamicPages/DynamicList.tsx
2025-11-19 14:35:32 +01:00

339 lines
No EOL
12 KiB
TypeScript

// * Packages
import { CSS } from "@dnd-kit/utilities";
import { GripVerticalIcon, TrashIcon } from "lucide-react";
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useState } from "react";
import { usePage } from "@inertiajs/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/global";
import { cn } from "@/lib/utils";
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;
}
// Allows to get recursive list inputs.
export default function DynamicList({
name,
parentName,
value,
enumLabel,
field,
content,
isChildren,
setData,
reset,
}: DynamicListProps) {
// Notice user there must be children config.
if (!field.children && field.children!.length > 0) {
return (
<p className="text-muted-foreground text-sm">
{useTrans('back.list_children_needed', {
params: { enumLabel }
})}
</p>
);
}
// 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>("");
/**
* Get all elements without each unique identification values.
* @param fields DynamicInternalField[]
* @returns Omit<DynamicInternalField, 'id'>[]
*/
const removeIds = (fields: DynamicInternalField[]): Omit<DynamicInternalField, 'id'>[] => {
return fields.map(({ id, ...rest }) => {
const newObj: any = {};
for (const key in rest) {
const value = rest[key as keyof typeof rest];
// If it is an array, we treat it recursively.
if (Array.isArray(value)) {
newObj[key] = removeIds(value);
}
// Otherwise, we keep the value as it is.
else {
newObj[key] = value;
}
}
return newObj;
});
};
/**
* Remove a specific element from the list.
* @param parentName string
* @param fieldKey string
* @param index number
* @return void
*/
const handleRemoveField = (parentName: string, fieldKey: string, index: number) => {
// Get the current list of elements.
const list = Array.isArray(content?.[fieldKey as keyof typeof content])
// @ts-ignore
? [...content[fieldKey as keyof typeof content]]
: [];
// Remove the targeted element.
list.splice(index, 1);
// Save the new list updated.
setData(`${parentName}.${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((field) => field.order ?? 0)) + 1;
const maxIndex = Math.max(0, ...sortedListItems.map((field) => field.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={handleRemoveField}
parentAccordionState={openIndex}
setParentAccordionState={setOpenIndex}
localErrors={localErrors}
/>
))}
</SortableContext>
</DndContext>
</Accordion>
)}
<AddNewFieldBtn
array={sortedListItems}
limitMax={configLimitMax}
onClick={handleAddNewField}
variant={isChildren ? "secondary" : "default"}
/>
</div>
)
}
type SortableItemProps = {
id: number;
item: any;
itemIndex: number;
name: string;
parentName: string;
field: any;
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>;
}
// 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 !== ""}
{...listeners}
{...attributes}
/>
</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. */}
<DynamicInputs
name={`${name}.${itemIndex}`}
config={field.children}
content={item}
setData={setData}
reset={reset}
parentErrors={localErrors}
isChildren={true}
/>
</AccordionContent>
</CardContent>
</Card>
</AccordionItem>
</div>
);
}