ldc-ovoteam/resources/ts/back/Components/PageConfigs/FieldGroup.tsx
Jérémy Munsch cd2d8ed397
refactor(i18n): replace GetTranslation with trans function
- Updated translation utility to improve consistency and reduce redundancy.
- Removed deprecated useTrans hook and related code.
- Introduced a new Zustand store for managing translations and locale settings.
- Cleaned up unused hooks and files related to localization.
2026-10-19 17:43:33 +02:00

520 lines
19 KiB
TypeScript

import {
closestCenter,
DndContext,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { usePage } from "@inertiajs/react";
import {
CheckIcon,
CopyIcon,
GripVerticalIcon,
InfoIcon,
TrashIcon,
} from "lucide-react";
import { useState, type Dispatch, type SetStateAction } from "react";
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
import EnumSelect from "@/back/Components/Inputs/EnumSelect";
import SimpleString from "@/back/Components/Inputs/SimpleString";
import AddNewFieldBtn from "@/back/Components/PageConfigs/AddNewFieldBtn";
import type {
ConfigField,
ConfigInternalField,
DynamicFieldType,
} from "@/back/types/dynamic-pages";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Card, CardContent } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import str from "@/hooks/use-string";
import trans from "@/hooks/use-translations";
import { cn } from "@/lib/utils";
interface 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;
}
export default function FieldGroup({
currentId,
currentIndex,
currentFieldKey,
parentFieldKey,
currentField,
currentRequired,
currentDisabled,
getTransInput,
updateField,
removeField,
transDelete,
transDragAndDrop,
sensitiveField,
removeSensitiveField,
localErrors,
setLocalErrors,
parentAccordionState,
setParentAccordionState,
limitMaxChildren,
}: Readonly<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 DynamicFieldType;
// 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 ?? enumDynamicFieldTypes.string.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 = trans(
"uwil-dynamic-pages::trans.key_already_present",
);
const transTypeField = trans("uwil-dynamic-pages::trans.type_field");
// 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 = () => {
void 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)) + 1;
const maxIndex = Math.max(0, ...existing.map((field) => field.id)) + 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.find((child) => child.id === childId);
if (!targetChild) return;
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-${String(currentId)}`}>
<Card
className={cn({
"border-destructive/50 dark:border-destructive":
parentAccordionState !== `item-${String(currentId)}` &&
Object.keys(localErrors).some((key) =>
key.startsWith(`${currentFieldKey}.${String(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 !== ""}
{...attributes}
{...listeners}
/>
</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(
trans(
"uwil-dynamic-pages::trans.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-${String(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
: trans("uwil-dynamic-pages::trans.field_number", {
params: { nbr: String(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}.${String(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}.${String(currentIndex)}.key`}
label={
<div className="flex items-center gap-1.5">
<span>{getTransInput("key", currentIndex + 1)}</span>
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon size={15} />
</TooltipTrigger>
<TooltipContent>
<div className="space-y-1 text-center">
<p>
{trans(
"uwil-dynamic-pages::trans.respect_format_page",
)}
</p>
<code className="bg-muted px-2 py-1 rounded text-xs">
name_key
</code>
</div>
</TooltipContent>
</Tooltip>
</div>
}
placeholder="ex: field_1"
value={editableKey}
setData={(_unused, value) => {
const newCompleteKey = `${enumCurrentBase}${value}`;
if (
sensitiveField.includes(
`${parentFieldKey}.${newCompleteKey}`,
)
) {
setLocalErrors((prev) => ({
...prev,
[`${currentFieldKey}.${String(currentIndex)}.key`]:
transKeyAlreadyPresent,
}));
} else {
setLocalErrors((prev) => {
const {
[`${currentFieldKey}.${String(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}.${String(currentIndex)}.type`}
label={getTransInput("type", currentIndex + 1)}
placeholder={transTypeField}
selected={currentField.type}
options={enumDynamicFieldTypes}
setData={(_, value) => {
updatingType(String(value));
}}
required={currentRequired}
disabled={currentDisabled}
parentErrors={localErrors}
/>
</>
)}
<SimpleString
name={`${currentFieldKey}.${String(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?.value === enumDynamicFieldTypes.list.value && (
<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}.${String(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>
);
}