402 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|