fix: 🎨 clean & comment dynamic pages components

This commit is contained in:
Alexis Gousseau 2025-11-17 10:28:43 +01:00
parent ff23b486ce
commit c6b2e8482a
6 changed files with 132 additions and 50 deletions

View file

@ -100,6 +100,7 @@ return [
"new_field" => "Add a new field",
"delete_field" => "Delete this field",
"dnd_field" => "Move this field",
"list_children_needed" => "Children are required for this type :enumLabel",
"empty_warning" => "No data recorded",
"page_locked" => "This page is currently locked",
"unsupported_type" => "Unsupported type:",

View file

@ -100,6 +100,7 @@ return [
"new_field" => "Ajouter un nouveau champ",
"delete_field" => "Supprimer ce champ",
"dnd_field" => "Déplacer ce champ",
"list_children_needed" => "Des enfants sont nécessaires pour ce type :enumLabel",
"empty_warning" => "Aucune donnée enregistrée",
"page_locked" => "Cette page est actuellement vérouillée",
"unsupported_type" => "Type non supporté:",

View file

@ -24,6 +24,7 @@ type DynamicInputsProps = {
reset: (name?: string) => void;
};
// Allows to get specific input from enum value.
export default function DynamicInputs({
name,
config,
@ -31,6 +32,7 @@ export default function DynamicInputs({
setData,
reset,
}: DynamicInputsProps) {
// Enums.
const enumDynamicFieldTypes = usePage().props.enumDynamicFieldTypes as DynamicFieldTypes;
// Translations.
@ -49,12 +51,17 @@ export default function DynamicInputs({
return (
<div className="grid grid-cols-1 md:grid-cols-2 items-start gap-4">
{/* Loop on all config elements. */}
{Object.entries(config).map(([id, field]) => {
// Get the enum value from the element type value.
const typeEnum = Object.values(enumDynamicFieldTypes).find(
(def) => def.value === field.type,
);
// Set the name of the current field.
const fieldName = `${name}.${field.key}`;
// Set the content value from the config key.
const fieldValue = content?.[field.key as keyof typeof content] ?? "";
switch (field.type) {
@ -137,7 +144,7 @@ export default function DynamicInputs({
label={getTrans(field.label)}
setData={setData}
reset={reset}
value={String(fieldValue)}
value={fieldValue as string | File | null}
required={extractRequiredValidation(field.validation)}
maxFileSize={extractLaravelValidationValue(field.validation, 'max')}
allowedFormats={extractLaravelValidationValue(field.validation, 'mimes')}

View file

@ -1,7 +1,7 @@
// * Packages
import { CSS } from "@dnd-kit/utilities";
import { GripVerticalIcon, TrashIcon } from "lucide-react";
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import { closestCenter, DndContext, DragEndEvent, 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";
@ -26,6 +26,7 @@ type DynamicListProps = {
reset: (name?: string) => void;
}
// Allows to get recursive list inputs.
export default function DynamicList({
name,
parentName,
@ -40,34 +41,44 @@ export default function DynamicList({
if (!field.children && field.children!.length > 0) {
return (
<p className="text-muted-foreground text-sm">
Des enfants sont nécessaires pour ce type {enumLabel}
{useTrans('back.list_children_needed', {
params: { enumLabel }
})}
</p>
);
}
// Drag&Drop.
// Drag & drop module for user interactions (mouse, touch, keyboard or other).
const sensors = useSensors(useSensor(PointerSensor));
// Set local id.
// 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;
/**
* Get all elements without each unique identification values.
* @param fields DynamicInternalField[]
* @returns Omit<DynamicInternalField, 'id'>[]
*/
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 it is an array, we treat it recursively.
if (Array.isArray(value)) {
newObj[key] = removeIds(value);
}
// Sinon, on garde la valeur telle quelle
// Otherwise, we keep the value as it is.
else {
newObj[key] = value;
}
@ -76,6 +87,69 @@ export default function DynamicList({
});
};
/**
* Remove a specific element from the list.
* @param parentName string
* @param fieldKey string
* @param index number
* @return void
*/
const handleRemoveField = (parentName: string, fieldKey: string, index: number) => {
// Get the current list of elements.
const list = Array.isArray(content?.[fieldKey as keyof typeof content])
// @ts-ignore
? [...content[fieldKey as keyof typeof content]]
: [];
// Remove the targeted element.
list.splice(index, 1);
// Save the new list updated.
setData(`${parentName}.${fieldKey}`, list);
}
/**
* 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((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 };
// 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>
@ -85,29 +159,24 @@ export default function DynamicList({
collapsible
className="w-full"
>
{/*
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={(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);
}}
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}
@ -119,30 +188,18 @@ export default function DynamicList({
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);
}}
removeField={handleRemoveField}
/>
))}
</SortableContext>
</DndContext>
</Accordion>
)}
<AddNewFieldBtn array={sortedListItems} limitMax={configLimitMax} 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);
}} />
<AddNewFieldBtn
array={sortedListItems}
limitMax={configLimitMax}
onClick={handleAddNewField}
/>
</div>
)
}
@ -159,6 +216,7 @@ type SortableItemProps = {
removeField: (parentName: string, fieldKey: string, index: number) => void;
}
// Recursive component.
function SortableItem({
id,
item,
@ -170,8 +228,15 @@ function SortableItem({
reset,
removeField,
}: 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,
@ -190,8 +255,8 @@ function SortableItem({
<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">
{/* Drag handle */}
<BtnOptionnalLinkWithTooltip
content={<GripVerticalIcon className="w-4 h-4 m-auto" />}
tooltipContent={transDragAndDrop}
@ -203,6 +268,7 @@ function SortableItem({
{...attributes}
/>
</div>
{/* Define a delete button to destroy the current element. */}
<BtnOptionnalLinkWithTooltip
content={<TrashIcon className="w-4 h-4" />}
tooltipContent={transDelete}
@ -212,6 +278,7 @@ function SortableItem({
onClick={() => 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}
@ -219,6 +286,7 @@ function SortableItem({
</div>
</div>
<AccordionContent className="pt-4 pb-1 px-1">
{/* Get specific input from the config value. */}
<DynamicInputs
name={`${name}.${itemIndex}`}
config={field.children}

View file

@ -10,10 +10,14 @@ type DynamicStatusBadgeProps = {
value: number;
};
// Get a stylised badge for a specific enum value.
export default function DynamicStatusBadge({ value }: DynamicStatusBadgeProps) {
// Get all enum values, labels and names.
const { enumDynamicPageStatus } = usePage().props as unknown as {
enumDynamicPageStatus: LaravelEnum;
};
// Set specific icon and style classes for each enum value.
const statusStyles = {
[enumDynamicPageStatus.draft.value]: {
color: "bg-gray-200 dark:bg-gray-900",
@ -28,15 +32,16 @@ export default function DynamicStatusBadge({ value }: DynamicStatusBadgeProps) {
Icon: CheckIcon,
},
};
// Get the current enum value.
const enumValue = Object.values(enumDynamicPageStatus).find(
(status) => status.value === value,
);
// Get the color and icon for the current enum value.
const { color, Icon } = statusStyles[
enumValue?.value as keyof typeof statusStyles
] ?? {
color: "bg-green-200 dark:bg-green-900",
Icon: CheckIcon,
};
];
return (
<Badge variant="outline" className={`border-0 ${color}`}>

View file

@ -15,6 +15,11 @@ import {
DialogFooter,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
// * Other
import SimpleString from "@/back/Components/Inputs/SimpleString";
import SimpleSwitch from "@/back/Components/Inputs/SimpleSwitch";
@ -31,11 +36,6 @@ import str from "@/hooks/use-string";
import { useToast } from "@/hooks/use-toast";
import { Methods } from "@/lib/enums";
import { AppEnv, AuthProp, ConfigField, DynamicPageModel, LaravelEnum, PageConfigModel } from "@/back/types/global";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export default function Form({
model,