234 lines
No EOL
8.1 KiB
TypeScript
234 lines
No EOL
8.1 KiB
TypeScript
// * Packages
|
||
import { CSS } from "@dnd-kit/utilities";
|
||
import { GripVerticalIcon, TrashIcon } from "lucide-react";
|
||
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
|
||
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||
// * 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 DynamicInputs from "@/back/Components/DynamicPages/DynamicInputs";
|
||
import useTrans from "@/hooks/use-translations";
|
||
import { extractRequiredValidation } from "@/hooks/use-laravel-validation";
|
||
import AddNewFieldBtn from "@/back/Components//PageConfigs/AddNewFieldBtn";
|
||
import { ConfigField, DynamicInternalField } from "@/back/types/global";
|
||
|
||
type DynamicListProps = {
|
||
name: string,
|
||
parentName: string,
|
||
value: DynamicInternalField[],
|
||
enumLabel: string,
|
||
field: ConfigField,
|
||
content: DynamicInternalField | null;
|
||
setData: (name: string, value: string | boolean | File | object | null) => void;
|
||
reset: (name?: string) => void;
|
||
}
|
||
|
||
export default function DynamicList({
|
||
name,
|
||
parentName,
|
||
value,
|
||
enumLabel,
|
||
field,
|
||
content,
|
||
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">
|
||
Des enfants sont nécessaires pour ce type {enumLabel}
|
||
</p>
|
||
);
|
||
}
|
||
|
||
// Drag&Drop.
|
||
const sensors = useSensors(useSensor(PointerSensor));
|
||
|
||
// Set local id.
|
||
const localValue = value.map((elmt, index) => {
|
||
return { ...elmt, id: elmt.id ?? index + 1 };
|
||
});
|
||
|
||
const sortedListItems = [...localValue].sort((a, b) => a.order - b.order);
|
||
const isRequired = extractRequiredValidation(field.validation);
|
||
|
||
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];
|
||
// Si c’est un tableau, on le traite récursivement
|
||
if (Array.isArray(value)) {
|
||
newObj[key] = removeIds(value);
|
||
}
|
||
// Sinon, on garde la valeur telle quelle
|
||
else {
|
||
newObj[key] = value;
|
||
}
|
||
}
|
||
return newObj;
|
||
});
|
||
};
|
||
|
||
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"
|
||
>
|
||
<DndContext
|
||
sensors={sensors}
|
||
collisionDetection={closestCenter}
|
||
onDragEnd={(event) => {
|
||
const { active, over } = event;
|
||
if (!over || active.id === over.id) return;
|
||
|
||
const oldIndex = sortedListItems.findIndex((i) => i.id === active.id);
|
||
const newIndex = sortedListItems.findIndex((i) => i.id === over.id);
|
||
|
||
const reorderedList = arrayMove(sortedListItems, oldIndex, newIndex)
|
||
.map((item, index) => ({
|
||
...item,
|
||
order: index + 1,
|
||
}));
|
||
|
||
setData(`${parentName}.${field.key}`, reorderedList);
|
||
}}
|
||
>
|
||
<SortableContext
|
||
items={sortedListItems.map((item) => item.id)}
|
||
strategy={verticalListSortingStrategy}
|
||
>
|
||
{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={(parentName: string, fieldKey: string, index: number) => {
|
||
const list = Array.isArray(content?.[fieldKey as keyof typeof content])
|
||
// @ts-ignore
|
||
? [...content[fieldKey as keyof typeof content]]
|
||
: [];
|
||
list.splice(index, 1);
|
||
setData(`${parentName}.${fieldKey}`, list);
|
||
}}
|
||
/>
|
||
))}
|
||
</SortableContext>
|
||
</DndContext>
|
||
</Accordion>
|
||
)}
|
||
<AddNewFieldBtn array={sortedListItems} onClick={() => {
|
||
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 };
|
||
setData(`${parentName}.${field.key}.${newIndex}`, newItemWithOrder);
|
||
}} />
|
||
</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;
|
||
}
|
||
|
||
function SortableItem({
|
||
id,
|
||
item,
|
||
itemIndex,
|
||
name,
|
||
parentName,
|
||
field,
|
||
setData,
|
||
reset,
|
||
removeField,
|
||
}: SortableItemProps) {
|
||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||
|
||
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="errors-classes">
|
||
<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">
|
||
<div className="flex-grow flex items-stretch">
|
||
{/* Drag handle */}
|
||
<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"
|
||
{...listeners}
|
||
{...attributes}
|
||
/>
|
||
</div>
|
||
<BtnOptionnalLinkWithTooltip
|
||
content={<TrashIcon className="w-4 h-4" />}
|
||
tooltipContent={transDelete}
|
||
type="button"
|
||
variant="destructive"
|
||
size="icon"
|
||
onClick={() => removeField(parentName, field.key, itemIndex)}
|
||
/>
|
||
</div>
|
||
<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">
|
||
<DynamicInputs
|
||
name={`${name}.${itemIndex}`}
|
||
config={field.children}
|
||
content={item}
|
||
setData={setData}
|
||
reset={reset}
|
||
/>
|
||
</AccordionContent>
|
||
</CardContent>
|
||
</Card>
|
||
</AccordionItem>
|
||
</div>
|
||
);
|
||
} |