fix: 🐛 set id locally for each inputs in page configuration

This commit is contained in:
Alexis Gousseau 2025-10-21 15:35:14 +02:00
parent 2c819672a8
commit a67490f997
4 changed files with 126 additions and 63 deletions

View file

@ -89,6 +89,7 @@ return [
"config_save_warning" => "The configuration of related pages in :locale will be updated accordingly.",
"config_save_warning_list" => "Here is the list of :models concerned:|Here is the list of :models concerned:",
"config_archived" => "This configuration is currently archived",
"config_complete_label" => "Copy the complete label of the input:",
"unsupported_type" => "Unsupported type:",
"missing_translations" => "The translation of the :field for the language :locales is missing.|The translation
of the :field for :locales languages are missing.",

View file

@ -89,7 +89,7 @@ return [
en conséquence.",
"config_save_warning_list" => "Voici la liste des :models concernés:|Voici la liste des :models concernées:",
"config_archived" => "Cette configuration est actuellement archivée",
"config_complete_label" => "Label complet de l'input:",
"config_complete_label" => "Copier le libellé complet de l'input:",
"unsupported_type" => "Type non supporté:",
"missing_translations" => "La traduction du champs :field pour la langue :locales est manquante.|La traduction
du champs :field pour les langues :locales sont manquantes.",

View file

@ -24,21 +24,16 @@ import str from "@/hooks/use-string";
import { DynamicField, DynamicFieldTypes } from "@/back/types/global";
import useTrans from "@/hooks/use-translations";
// Generate unique stable id for each field.
const generateFieldId = (() => {
let count = 0;
return () => `field_id_${++count}`;
})();
type DynamicFieldsProps = {
name: string;
value: DynamicField[];
required?: boolean;
disabled?: boolean;
setData: (name: string, value: any) => void;
// eslint-disable-next-line no-unused-vars
setData: (name: string, value: DynamicField[]) => void;
};
type InternalField = DynamicField & { id: string };
type InternalField = DynamicField & { id: number };
export default function DynamicFields({
name,
@ -47,12 +42,20 @@ export default function DynamicFields({
disabled = false,
setData,
}: DynamicFieldsProps) {
const enumDynamicFieldTypes = usePage().props.enumDynamicFieldTypes as DynamicFieldTypes;
const { errors } = usePage().props;
const enumDynamicFieldTypes = usePage().props
.enumDynamicFieldTypes as DynamicFieldTypes;
// Unique stable id for each field.
const nextId = useRef(1);
// All fields with stable id.
const [fields, setFields] = useState<InternalField[]>(() =>
(value ?? []).map((field) => ({ id: generateFieldId(), ...field }))
(value ?? []).map((field) => ({
id: nextId.current++,
...field,
})),
);
// Local changes of fields (avoid loop between useEffects).
const prevValueRef = useRef(JSON.stringify(value ?? []));
const sensors = useSensors(useSensor(PointerSensor));
@ -78,13 +81,15 @@ export default function DynamicFields({
*/
const getTransInput = (field: keyof typeof trans, id: number): string => {
const transFieldValue = trans[field] ?? trans.label;
return str(`${transFieldValue} ${transJunction} ${transField} n°${id}`).ucFirst().value();
return str(`${transFieldValue} ${transJunction} ${transField} n°${id}`)
.ucFirst()
.value();
};
// Sorted fields by order.
const sortedFields = useMemo(
() => [...fields].sort((a, b) => (a.order ?? 1) - (b.order ?? 1)),
[fields]
[fields],
);
/**
@ -92,17 +97,19 @@ export default function DynamicFields({
* @returns void
*/
const addField = (): void => {
const label = enumDynamicFieldTypes.string.label;
const maxOrder = Math.max(0, ...fields.map((field) => field.order ?? 0)) + 1;
const { label } = enumDynamicFieldTypes.string;
const maxOrder =
Math.max(0, ...fields.map((field) => field.order ?? 0)) + 1;
const newField: InternalField = {
id: generateFieldId(),
id: nextId.current++,
key: `field_${maxOrder}`,
type: 1,
label,
validation: enumDynamicFieldTypes.string.validation,
order: maxOrder,
};
setFields((prev) => [...prev, newField]);
};
@ -112,19 +119,26 @@ export default function DynamicFields({
* @param updatedField Partial<DynamicField>
* @returns void
*/
const updateField = (fieldId: string, updatedField: Partial<DynamicField>): void => {
const updateField = (
fieldId: number,
updatedField: Partial<DynamicField>,
): void => {
setFields((prev) =>
prev.map((field) => {
if (field.id !== fieldId) return field;
const merged = { ...field, ...updatedField };
if (updatedField.type !== undefined) {
const matchingKey = Object.keys(enumDynamicFieldTypes).find(
(k) => enumDynamicFieldTypes[k].value === updatedField.type
(k) =>
enumDynamicFieldTypes[k].value ===
updatedField.type,
);
if (matchingKey) merged.validation = enumDynamicFieldTypes[matchingKey].validation;
if (matchingKey)
merged.validation =
enumDynamicFieldTypes[matchingKey].validation;
}
return merged;
})
}),
);
};
@ -133,7 +147,7 @@ export default function DynamicFields({
* @param fieldId string
* @returns void
*/
const removeField = (fieldId: string): void => {
const removeField = (fieldId: number): void => {
setFields((prev) => prev.filter((field) => field.id !== fieldId));
};
@ -146,15 +160,19 @@ export default function DynamicFields({
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = sortedFields.findIndex((f) => f.id === String(active.id));
const newIndex = sortedFields.findIndex((f) => f.id === String(over.id));
const oldIndex = sortedFields.findIndex(
(f) => f.id === Number(active.id),
);
const newIndex = sortedFields.findIndex(
(f) => f.id === Number(over.id),
);
if (oldIndex === -1 || newIndex === -1) return;
setFields((prev) =>
arrayMove(prev, oldIndex, newIndex).map((fld, idx) => ({
...fld,
order: idx + 1,
}))
})),
);
};
@ -173,18 +191,18 @@ export default function DynamicFields({
if (!value) return;
const valueStr = JSON.stringify(value ?? []);
// Already synchronized.
// Already synchronized.
if (valueStr === prevValueRef.current) return;
setFields((prev) =>
(value ?? []).map((field, i) => {
const existing = prev.find((p) => p.key === field.key);
return {
id: existing?.id ?? generateFieldId(),
id: existing?.id ?? nextId.current++,
...field,
order: field.order ?? i + 1,
} as InternalField;
})
}),
);
prevValueRef.current = valueStr;
@ -205,12 +223,14 @@ export default function DynamicFields({
{sortedFields.map((field, index) => (
<SortableField
key={field.id}
fieldKey={field.key}
id={field.id}
fieldKey={field.key}
index={index}
field={field}
removeField={() => removeField(field.id)}
updateField={(_, updatedInput) => updateField(field.id, updatedInput)}
updateField={(_, updatedInput) =>
updateField(field.id, updatedInput)
}
required={required}
disabled={disabled}
getTransInput={getTransInput}
@ -231,7 +251,7 @@ export default function DynamicFields({
{useTrans("back.config_new_field")}
</Button>
</div>
<InputError message={usePage().props.errors?.[name]} />
<InputError message={errors?.[name]} />
</div>
);
}

View file

@ -1,5 +1,5 @@
// * Packages
import { TrashIcon, GripVerticalIcon, InfoIcon, Copy } from "lucide-react";
import { TrashIcon, GripVerticalIcon, CheckIcon, CopyIcon } from "lucide-react";
import { usePage } from "@inertiajs/react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
@ -19,22 +19,24 @@ import useTrans from "@/hooks/use-translations";
import str from "@/hooks/use-string";
import { useEffect, useState } from "react";
/* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */
type SortableFieldProps = {
id: string;
id: number;
index: number;
field: DynamicField;
fieldKey: string;
removeField: (id: string) => void;
updateField: (id: string, updatedField: Partial<DynamicField>) => void;
removeField: (id: number) => void;
updateField: (id: number, updatedField: Partial<DynamicField>) => void;
required?: boolean;
disabled?: boolean;
getTransInput: (
field: "type" | "key" | "label" | "validation",
id: number
id: number,
) => string;
transDelete: string;
transDragAndDrop: string;
};
/* eslint-enable no-unused-vars, @typescript-eslint/no-unused-vars */
export default function SortableField({
id,
@ -49,32 +51,47 @@ export default function SortableField({
transDelete,
transDragAndDrop,
}: SortableFieldProps) {
const enumDynamicFieldTypes = usePage().props.enumDynamicFieldTypes as DynamicFieldTypes;
// Enum.
const enumDynamicFieldTypes = usePage().props
.enumDynamicFieldTypes as DynamicFieldTypes;
const getEnumDynamicFieldType = (value: number) => {
return Object.values(enumDynamicFieldTypes).find((def) => def.value === value);
return Object.values(enumDynamicFieldTypes).find(
(def) => def.value === value,
);
};
const enumCurrent = getEnumDynamicFieldType(field.type)?.name + "_";
// Key.
const enumCurrent = `${getEnumDynamicFieldType(field.type)?.name}_`;
const splitAndGetEditableKey = (localeEditableKey: string): string => {
const baseKeyArray = localeEditableKey.split(enumCurrent);
return baseKeyArray[baseKeyArray.length - 1];
}
const [editableKey, setEditableKey] = useState(splitAndGetEditableKey(field.key))
const [isTypeChanged, setIsTypeChanged] = useState(false)
const completeKey = enumCurrent + editableKey
};
const [editableKey, setEditableKey] = useState(
splitAndGetEditableKey(field.key),
);
const [isTypeChanged, setIsTypeChanged] = useState(false);
const completeKey = enumCurrent + editableKey;
// Copy.
const [copied, setCopied] = useState(false);
const handleClick = () => {
copyToClipBoard(completeKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
useEffect(() => {
if (isTypeChanged) {
setEditableKey(splitAndGetEditableKey(editableKey))
setEditableKey(splitAndGetEditableKey(editableKey));
}
setIsTypeChanged(false)
}, [isTypeChanged])
setIsTypeChanged(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isTypeChanged]);
useEffect(() => {
updateField(id, { key: completeKey })
}, [fieldKey, editableKey, completeKey])
updateField(id, { key: completeKey });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fieldKey, editableKey, completeKey]);
const {
attributes,
@ -92,8 +109,8 @@ export default function SortableField({
zIndex: isDragging ? 10 : 1,
};
const copyToClipBoard = (data: string) => {
navigator.clipboard.writeText(data)
}
navigator.clipboard.writeText(data);
};
return (
<div
ref={setNodeRef}
@ -105,12 +122,16 @@ export default function SortableField({
{/* Drag button */}
<div className="flex-grow flex items-stretch">
<BtnOptionnalLinkWithTooltip
content={<GripVerticalIcon className="w-4 h-4 m-auto" />}
content={
<GripVerticalIcon className="w-4 h-4 m-auto" />
}
tooltipContent={transDragAndDrop}
className="cursor-grab h-full w-full"
type="button"
variant="outline"
// eslint-disable-next-line react/jsx-props-no-spreading
{...attributes}
// eslint-disable-next-line react/jsx-props-no-spreading
{...listeners}
/>
</div>
@ -137,7 +158,9 @@ export default function SortableField({
label={getTransInput("label", index + 1)}
placeholder="ex: John Doe"
value={field.label}
setData={(_, value) => updateField(id, { label: value })}
setData={(_, value) =>
updateField(id, { label: value })
}
/>
<div className="flex items-end gap-2">
<SimpleString
@ -146,22 +169,41 @@ export default function SortableField({
placeholder="ex: John Doe"
value={editableKey}
setData={(_, value) => {
setEditableKey(value)
// updateField(id, { key: value })
setEditableKey(value);
}}
/>
<div className="flex flex-col items-center gap-1.5">
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="text-blue-500 size-4" />
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleClick}
>
<CopyIcon
className={`size-4 ${copied ? "hidden" : "block"}`}
/>
<CheckIcon
className={`size-4 ${copied ? "block" : "hidden"}`}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
<h4 className="font-semibold">{str(useTrans('back.config_complete_label')).ucFirst().value()}</h4>
<p className="text-muted-foreground mt-1">{completeKey}
<TooltipContent className="text-center">
<h4 className="font-semibold">
{str(
useTrans(
"back.config_complete_label",
),
)
.ucFirst()
.value()}
</h4>
<p className="text-muted-foreground mt-1">
{completeKey}
</p>
</TooltipContent>
</Tooltip>
<Button type="button" variant={"ghost"} size={"icon"} onClick={() => copyToClipBoard(completeKey)}><Copy className="size-4" /></Button>
</div>
</div>
<EnumSelect
@ -171,8 +213,8 @@ export default function SortableField({
selected={String(field.type)}
options={enumDynamicFieldTypes}
setData={(_, value) => {
setIsTypeChanged(true)
updateField(id, { type: Number(value) })
setIsTypeChanged(true);
updateField(id, { type: Number(value) });
}}
required={required}
disabled={disabled}