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

543 lines
26 KiB
TypeScript

// * Packages
import { Dispatch, SetStateAction, useState } from "react";
import {
DndContext,
closestCenter,
useSensor,
useSensors,
PointerSensor,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
arrayMove,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { TrashIcon, GripVerticalIcon, CheckIcon, CopyIcon } from "lucide-react";
import { usePage } from "@inertiajs/react";
// * Components shadcn
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Card, CardContent } from "@/components/ui/card";
// * Other
import EnumSelect from "@/back/Components/Inputs/EnumSelect";
import SimpleString from "@/back/Components/Inputs/SimpleString";
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
import AddNewFieldBtn from "@/back/Components/PageConfigs/AddNewFieldBtn";
import {
ConfigField,
ConfigInternalField,
DynamicFieldTypes,
} from "@/back/types/dynamic-pages";
import useTrans from "@/hooks/use-translations";
import str from "@/hooks/use-string";
import { cn } from "@/lib/utils";
/* eslint-disable no-unused-vars */
type FieldGroupProps = {
currentId: number;
currentIndex: number;
currentFieldKey: string;
parentFieldKey: string;
currentField: ConfigInternalField;
currentRequired: boolean;
currentDisabled: boolean;
getTransInput: (
field: "type" | "key" | "label" | "validation" | "children",
id: number,
) => string;
updateField: (id: number, updatedField: Partial<ConfigField>) => void;
removeField: (id: number) => void;
transDelete: string;
transDragAndDrop?: string;
sensitiveField: string[];
removeSensitiveField: (key: string) => void;
localErrors: Record<string, string>;
setLocalErrors: Dispatch<SetStateAction<Record<string, string>>>;
parentAccordionState: string;
setParentAccordionState: (key: string) => void;
limitMaxChildren: number;
};
/* eslint-enable no-unused-vars */
export default function FieldGroup({
currentId,
currentIndex,
currentFieldKey,
parentFieldKey,
currentField,
currentRequired,
currentDisabled,
getTransInput,
updateField,
removeField,
transDelete,
transDragAndDrop,
sensitiveField,
removeSensitiveField,
localErrors,
setLocalErrors,
parentAccordionState,
setParentAccordionState,
limitMaxChildren,
}: FieldGroupProps) {
// Drag & drop module for user interactions (mouse, touch, keyboard or other).
const sensors = useSensors(useSensor(PointerSensor));
// 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: String(currentId) });
// 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,
};
// Get all enum values, labels and names.
const enumDynamicFieldTypes = usePage().props
.enumDynamicFieldTypes as DynamicFieldTypes;
// Set the current state of the accordion.
const [openIndex, setOpenIndex] = useState<string>("");
// Get the current enum value.
const currentEnum = Object.values(enumDynamicFieldTypes).find(
(def) => def.value === currentField.type,
);
// Set a prefix related to the current enum.
const enumCurrentBase = `${currentEnum?.name}_`;
// Set the editable part of the current key.
const [editableKey, setEditableKey] = useState<string>(() => {
return currentField.key.startsWith(enumCurrentBase)
? currentField.key.replace(enumCurrentBase, "")
: currentField.key;
});
//* Translations
const transKeyAlreadyPresent = useTrans("validation.key_already_present");
// Set the complete key (prefix + editable) that will be sent to the backend.
const completeKey = `${enumCurrentBase}${editableKey}`;
// Set the state of copying key.
const [copied, setCopied] = useState(false);
// Copy the complete key in the clipboard.
const handleClick = () => {
navigator.clipboard.writeText(completeKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
/**
* Reorder the elements list.
* @param event DragEndEvent
* @return void
*/
const handleOnDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex =
currentField.children?.findIndex(
(c) => String(c.id) === String(active.id),
) ?? -1;
const newIndex =
currentField.children?.findIndex(
(c) => String(c.id) === String(over.id),
) ?? -1;
if (oldIndex < 0 || newIndex < 0) return;
const reordered = arrayMove(
currentField.children!,
oldIndex,
newIndex,
).map((child, index) => ({
...child,
order: index + 1,
}));
updateField(currentId, { children: reordered });
};
/**
* Add a new field to the list.
* @return void
*/
const handleAddNewChild = () => {
const existing = currentField.children ?? [];
const maxOrder =
Math.max(0, ...existing.map((field) => field.order ?? 0)) + 1;
const maxIndex =
Math.max(0, ...existing.map((field) => field.id ?? 0)) + 1;
const newChild: ConfigInternalField = {
id: maxIndex,
key: `${enumDynamicFieldTypes.string.name}_`,
label: "",
type: enumDynamicFieldTypes.string.value,
validation: enumDynamicFieldTypes.string.validation,
order: maxOrder,
children: [],
};
const newChildren = [...(currentField.children ?? []), newChild];
updateField(currentId, { children: newChildren });
};
/**
* Remove a specific element from the list.
* @param childId number
* @return void
*/
const handleDeleteChild = (childId: number) => {
const children = currentField.children ?? [];
const targetChild = children.filter((child) => child.id === childId)[0];
removeSensitiveField(`${parentFieldKey}.${targetChild.key}`);
const childrenUpdated = children.filter((c) => c.id !== childId);
updateField(currentId, { children: childrenUpdated });
};
/**
* Update the input type.
* @param value string
* @return void
*/
const updatingType = (value: string) => {
const selectedType = Object.values(enumDynamicFieldTypes).find(
(def) => def.value === Number(value),
);
if (selectedType) {
const resetCommon = {
type: selectedType.value,
validation: selectedType.validation,
key: `${selectedType.name}_${editableKey}`,
children: undefined,
};
updateField(currentId, resetCommon);
}
};
return (
<div ref={setNodeRef} style={style} className="relative mb-3">
<AccordionItem value={`item-${currentId}`}>
<Card
className={cn({
"border-destructive/50 dark:border-destructive":
parentAccordionState !== `item-${currentId}` &&
Object.keys(localErrors).some((key) =>
key.startsWith(
`${currentFieldKey}.${currentIndex}`,
),
),
})}
>
<CardContent className="flex flex-col p-3">
{/* HEADER */}
<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">
<BtnOptionnalLinkWithTooltip
content={
<GripVerticalIcon className="w-4 h-4 m-auto" />
}
tooltipContent={transDragAndDrop}
className="cursor-grab touch-none h-full w-full"
type="button"
variant="outline"
size="icon"
disabled={parentAccordionState !== ""}
/* eslint-disable react/jsx-props-no-spreading */
{...attributes}
{...listeners}
/* eslint-enable react/jsx-props-no-spreading */
/>
</div>
<div className="flex flex-row gap-1.5">
<BtnOptionnalLinkWithTooltip
content={
<>
<CopyIcon
className={`size-4 ${copied ? "hidden" : "block"}`}
/>
<CheckIcon
className={`size-4 ${copied ? "block" : "hidden"}`}
/>
</>
}
tooltipContent={
<div className="text-center">
<p>
{str(
useTrans(
"back.config_complete_label",
),
)
.ucFirst()
.value()}
</p>
<p className="font-semibold mt-1">
{completeKey}
</p>
</div>
}
type="button"
variant="ghost"
size="icon"
onClick={handleClick}
/>
<BtnOptionnalLinkWithTooltip
content={
<TrashIcon className="w-4 h-4" />
}
tooltipContent={transDelete}
type="button"
variant="destructive"
size="icon"
onClick={() => {
if (
parentAccordionState ===
`item-${currentId}`
) {
setParentAccordionState("");
}
removeField(currentId);
}}
/>
</div>
</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">
{currentField.label.length
? currentField.label
: `Champs n°${currentIndex + 1}`}
</AccordionTrigger>
</div>
</div>
{/* CONTENT */}
<AccordionContent className="p-0 px-1">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 pt-4">
<SimpleString
name={`${currentFieldKey}.${currentIndex}.label`}
label={getTransInput(
"label",
currentIndex + 1,
)}
placeholder="ex: Nom"
value={currentField.label}
setData={(_, value) =>
updateField(currentId, { label: value })
}
parentErrors={localErrors}
/>
{!sensitiveField.includes(
`${parentFieldKey}.${completeKey}`,
) && (
<>
<SimpleString
name={`${currentFieldKey}.${currentIndex}.key`}
label={getTransInput(
"key",
currentIndex + 1,
)}
placeholder="ex: field_1"
value={editableKey}
setData={(_unused, value) => {
const newCompleteKey = `${enumCurrentBase}${value}`;
if (
sensitiveField.includes(
`${parentFieldKey}.${newCompleteKey}`,
)
) {
setLocalErrors((prev) => ({
...prev,
[`${currentFieldKey}.${currentIndex}.key`]:
transKeyAlreadyPresent,
}));
} else {
setLocalErrors((prev) => {
const {
[`${currentFieldKey}.${currentIndex}.key`]:
// eslint-disable-next-line @typescript-eslint/no-unused-vars
removedKey,
...rest
} = prev;
return rest;
});
setEditableKey(value);
updateField(currentId, {
key: newCompleteKey,
});
}
}}
pattern="^[a-z0-9]+(?:[\-_][a-z0-9]+)*$"
parentErrors={localErrors}
/>
<EnumSelect
name={`${currentFieldKey}.${currentIndex}.type`}
label={getTransInput(
"type",
currentIndex + 1,
)}
placeholder="Type du champ"
selected={String(currentField.type)}
options={enumDynamicFieldTypes}
setData={(_, value) => {
updatingType(value);
}}
required={currentRequired}
disabled={currentDisabled}
parentErrors={localErrors}
/>
</>
)}
<SimpleString
name={`${currentFieldKey}.${currentIndex}.validation`}
label={getTransInput(
"validation",
currentIndex + 1,
)}
placeholder="ex: required|string"
value={currentField.validation}
setData={(_, value) =>
updateField(currentId, {
validation: value,
})
}
required={currentRequired}
disabled={currentDisabled}
parentErrors={localErrors}
/>
</div>
{/* CHILDREN */}
{currentEnum?.isarray && (
<Accordion
type="single"
className="w-full mt-4"
value={openIndex}
onValueChange={setOpenIndex}
collapsible
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleOnDragEnd}
>
<SortableContext
items={(
currentField.children ?? []
).map((c) => String(c.id))}
strategy={
verticalListSortingStrategy
}
>
{(currentField.children ?? []).map(
(
child: ConfigInternalField,
i: number,
) => (
<FieldGroup
key={child.id}
currentId={child.id}
currentIndex={i}
currentFieldKey={`${currentFieldKey}.${currentIndex}.children`}
parentFieldKey={`${parentFieldKey}.${currentField.key}`}
currentField={child}
currentRequired
currentDisabled={false}
getTransInput={
getTransInput
}
updateField={(
childId: number,
updatedChild: Partial<ConfigField>,
) => {
const newChildren =
(
currentField.children ??
[]
).map((c) =>
c.id ===
childId
? {
...c,
...updatedChild,
}
: c,
);
updateField(
currentId,
{
children:
newChildren,
},
);
}}
removeField={
handleDeleteChild
}
transDelete={
transDelete
}
transDragAndDrop={
transDragAndDrop
}
sensitiveField={
sensitiveField
}
removeSensitiveField={
removeSensitiveField
}
localErrors={
localErrors
}
setLocalErrors={
setLocalErrors
}
parentAccordionState={
openIndex
}
setParentAccordionState={
setOpenIndex
}
limitMaxChildren={
limitMaxChildren
}
/>
),
)}
</SortableContext>
</DndContext>
<AddNewFieldBtn
array={currentField.children}
limitMax={limitMaxChildren}
variant="secondary"
onClick={handleAddNewChild}
/>
</Accordion>
)}
</AccordionContent>
</CardContent>
</Card>
</AccordionItem>
</div>
);
}