- 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.
520 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|