feat: ✨ add new dynamic page list type

This commit is contained in:
Alexis Gousseau 2025-11-07 19:00:09 +01:00
parent 8c62774672
commit bd268f0bfc
43 changed files with 1465 additions and 720 deletions

View file

@ -8,6 +8,7 @@ enum DynamicFieldTypes: int
{
use BaseEnum;
// Name must be one word in lowercase.
case string = 1;
case text = 2;
case number = 3;
@ -15,6 +16,8 @@ enum DynamicFieldTypes: int
case url = 5;
case email = 6;
case image = 7;
case list = 8;
// case color = 9;
/**
* Labels definition.
@ -29,6 +32,7 @@ enum DynamicFieldTypes: int
self::url->name => 'enums.dynamic-field-types.url',
self::email->name => 'enums.dynamic-field-types.email',
self::image->name => 'enums.dynamic-field-types.image',
self::list->name => 'enums.dynamic-field-types.list',
];
/**
@ -42,6 +46,7 @@ enum DynamicFieldTypes: int
self::url->name => 'required|url:https',
self::email->name => 'required|email:rfc,strict,dns,spoof,filter',
self::image->name => 'required|file|mimes:jpg,jpeg,png,pdf|max:5120|dimensions:width=100,height=100',
self::list->name => 'required|array|min:1|max:10',
];
/**
@ -53,4 +58,18 @@ enum DynamicFieldTypes: int
{
return self::VALIDATIONS[$this->name];
}
/**
* Is array definition.
*/
private const ISARRAY = [
self::string->name => false,
self::text->name => false,
self::number->name => false,
self::boolean->name => false,
self::url->name => false,
self::email->name => false,
self::image->name => false,
self::list->name => true,
];
}

View file

@ -134,9 +134,11 @@ class DynamicPageController extends Controller
*/
public function edit(DynamicPage $dynamic_page): \Inertia\Response
{
$dynamicPageModel = $dynamic_page->load(['parentPage', 'config']);
$dynamicPageModels = DynamicPage::getPublishedDynamicPages();
$pageConfigModels = PageConfig::getPublishedPageConfigs();
$dynamicPageModel = $dynamic_page->load(['parentPage', 'config']);
$dynamicPageModels = DynamicPage::getPublishedDynamicPages()
->filter(fn ($model) => $model->getKey() !== $dynamic_page->getKey())
->values();
$pageConfigModels = PageConfig::getPublishedPageConfigs();
return Inertia::render('DynamicPages/Edit', [
'data' => CommonResource::make($dynamicPageModel),
'dynamicPageModels' => $dynamicPageModels,

View file

@ -54,6 +54,7 @@ class StoreDynamicPageRequest extends UpdateDynamicFieldRequest
];
if ($this->config_id) {
$rules = \array_merge(
['content' => 'required|array|min:1'],
parent::rules(),
$rules,
);

View file

@ -28,16 +28,48 @@ class StorePageConfigRequest extends FormRequest
public function rules(): array
{
return [
'label' => 'required|string|between:3,255',
'view' => 'sometimes|nullable|string|between:3,255',
'config' => 'required|array|min:1',
'config.*' => 'required|array|size:5',
'config.*.key' => 'required|string|between:3,255|regex:/^[a-z0-9]+(?:[-_][a-z0-9]+)*$/',
'config.*.order' => 'required|numeric|min:1',
'config.*.type' => ['required', new Enum(DynamicFieldTypes::class)],
'config.*.label' => 'required|string|between:3,255',
'config.*.validation' => 'required|string|between:3,255',
'label' => 'required|string|between:3,255',
'view' => 'sometimes|nullable|string|between:3,255',
'config' => ['required', 'array', 'min:1'],
] + $this->configRules('config.*');
}
/**
* Recursively generates rules for a given key.
*
* @return array
*/
protected function configRules(string $prefix, int $depth = 1): array
{
$rules = [
"{$prefix}" => 'required|array|between:5,6',
"{$prefix}.key" => [
'required',
'string',
'between:3,255',
'regex:/^[a-z0-9]+(?:[-_][a-z0-9]+)*$/',
function ($attribute, $value, $fail) use ($prefix) {
$parentPath = implode('.', array_slice(explode('.', $attribute), 0, -2));
$levelItems = data_get($this->input(), $parentPath, []);
$keysAtLevel = collect($levelItems)->pluck('key')->filter()->toArray();
if (count(array_filter($keysAtLevel, fn($k) => $k === $value)) > 1) {
$fail("The {$attribute} must be unique at its level.");
}
}
],
"{$prefix}.order" => 'required|numeric|min:1',
"{$prefix}.type" => ['required', new Enum(DynamicFieldTypes::class)],
"{$prefix}.label" => 'required|string|between:3,255',
"{$prefix}.validation" => 'required|string|between:3,255',
];
if ($depth < 10) {
$rules["{$prefix}.children"] = ['sometimes', 'array'];
$rules += $this->configRules("{$prefix}.children.*", $depth + 1);
}
return $rules;
}
/**
@ -48,15 +80,33 @@ class StorePageConfigRequest extends FormRequest
public function attributes(): array
{
return [
'label' => trans('validation.custom.label'),
'view' => trans('validation.custom.view'),
'config' => trans('validation.custom.config'),
'config.*' => trans('validation.custom.config'),
'config.*.key' => trans('validation.custom.key'),
'config.*.order' => trans('validation.custom.order'),
'config.*.type' => trans('validation.custom.type'),
'config.*.label' => trans('validation.custom.label'),
'config.*.validation' => trans('validation.custom.validation'),
'label' => trans('validation.custom.label'),
'view' => trans('validation.custom.view'),
'config' => trans('validation.custom.config'),
] + $this->configAttributes('config.*');
}
/**
* Recursively generates attributes for a given key.
*
* @return array
*/
protected function configAttributes(string $prefix, int $depth = 1): array
{
$rules = [
"{$prefix}" => trans('validation.custom.config'),
"{$prefix}.key" => trans('validation.custom.key'),
"{$prefix}.order" => trans('validation.custom.order'),
"{$prefix}.type" => trans('validation.custom.type'),
"{$prefix}.label" => trans('validation.custom.label'),
"{$prefix}.validation" => trans('validation.custom.validation'),
];
if ($depth < 10) {
$rules["{$prefix}.children"] = trans('validation.custom.children');
$rules += $this->configAttributes("{$prefix}.children.*", $depth + 1);
}
return $rules;
}
}

View file

@ -38,7 +38,6 @@ abstract class UpdateDynamicFieldRequest extends FormRequest
protected function prepareForValidation(): void
{
$this->configModel = PageConfig::query()->whereKey($this->input('config_id'))->firstOrFail();
$this->mergeInertiaPicture('content');
}
/**
@ -53,83 +52,99 @@ abstract class UpdateDynamicFieldRequest extends FormRequest
// phpcs:enable
$rules = [];
foreach ($this->configModel->config as $input) {
$ruleKey = "$this->fieldName.$input[key]";
switch ($input['type']) {
case DynamicFieldTypes::string->value:
$rules = array_merge($rules, [
$ruleKey => $this->compileValidate('required|string|min:3', $input)
]);
break;
case DynamicFieldTypes::text->value:
$mergeRules = $this->compileValidate('required|string|min:15', $input);
$mergeRules = collect($mergeRules)
->map(function ($rule) {
// TODO: check pour utiliser la rule wysiwyg length.
if (\strpos($rule, 'max:') !== false) {
$max = \intval(str_replace('max:', '', $rule));
return function ($attribute, $value, $fail) use ($max) {
if (\strlen(\strip_tags($value)) > $max) {
$fail("$attribute ne doit pas excéder $max caractères");
}
};
}
return $rule;
})->all();
$rules = array_merge($rules, [
$ruleKey => $mergeRules
]);
break;
case DynamicFieldTypes::number->value:
$rules = array_merge($rules, [
$ruleKey => $this->compileValidate('required|numeric', $input)
]);
break;
case DynamicFieldTypes::boolean->value:
$all = $this->all();
$nested = ToolboxHelper::dotStringToNestedArray(
$ruleKey,
$this->input($ruleKey) ? true : false
);
$all = ToolboxHelper::arrayMergeRecursiveDistinct($all, $nested);
$this->merge($all);
$rules = array_merge($rules, [
$ruleKey => $this->compileValidate('boolean', $input)
]);
break;
case DynamicFieldTypes::url->value:
$rules = array_merge($rules, [
$ruleKey => [
'required',
'url',
// @phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassBeforeLastUsed
function ($attribute, $value, $fail) {
try {
ToolboxHelper::assertNoInternalUrls($attribute, $value);
ToolboxHelper::assertNoInternalBoUrls($attribute, $value);
} catch (ValidationException $e) {
$fail(collect($e->errors())->first()[0]);
}
}
]
]);
break;
case DynamicFieldTypes::email->value:
$rules = array_merge($rules, [
$ruleKey => $this->compileValidate('required|email|min:3', $input)
]);
break;
case DynamicFieldTypes::image->value:
$defaultRules = 'required|file|mimes:jpg,jpeg,png,pdf|max:5120|dimensions:width=100,height=100';
$imgRules = $this->compileValidate($defaultRules, $input);
$rules = array_merge($rules, [
$ruleKey => $this->pictureRules($ruleKey, implode("|", $imgRules)),
]);
break;
} //end switch
$rules = array_merge($rules, $this->recursiveRules($input, $this->fieldName));
} //end foreach
return $rules;
}
private function recursiveRules(&$input, $prefix): array
{
$rules = [];
$ruleKey = "$prefix.$input[key]";
switch ($input['type']) {
case DynamicFieldTypes::string->value:
$rules = array_merge($rules, [
$ruleKey => $this->compileValidate('required|string|min:3', $input)
]);
break;
case DynamicFieldTypes::text->value:
$mergeRules = $this->compileValidate('required|string|min:15', $input);
$mergeRules = collect($mergeRules)
->map(function ($rule) {
// TODO: check pour utiliser la rule wysiwyg length.
if (\strpos($rule, 'max:') !== false) {
$max = \intval(str_replace('max:', '', $rule));
return function ($attribute, $value, $fail) use ($max) {
if (\strlen(\strip_tags($value)) > $max) {
$fail("$attribute ne doit pas excéder $max caractères");
}
};
}
return $rule;
})->all();
$rules = array_merge($rules, [
$ruleKey => $mergeRules
]);
break;
case DynamicFieldTypes::number->value:
$rules = array_merge($rules, [
$ruleKey => $this->compileValidate('required|numeric', $input)
]);
break;
case DynamicFieldTypes::boolean->value:
$all = $this->all();
$nested = ToolboxHelper::dotStringToNestedArray(
$ruleKey,
$this->input($ruleKey) ? true : false
);
$all = ToolboxHelper::arrayMergeRecursiveDistinct($all, $nested);
$this->merge($all);
$rules = array_merge($rules, [
$ruleKey => $this->compileValidate('boolean', $input)
]);
break;
case DynamicFieldTypes::url->value:
$rules = array_merge($rules, [
$ruleKey => [
'required',
'url',
// @phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassBeforeLastUsed
function ($attribute, $value, $fail) {
try {
ToolboxHelper::assertNoInternalUrls($attribute, $value);
ToolboxHelper::assertNoInternalBoUrls($attribute, $value);
} catch (ValidationException $e) {
$fail(collect($e->errors())->first()[0]);
}
}
]
]);
break;
case DynamicFieldTypes::email->value:
$rules = array_merge($rules, [
$ruleKey => $this->compileValidate('required|email|min:3', $input)
]);
break;
case DynamicFieldTypes::image->value:
$defaultRules = 'required|file|mimes:jpg,jpeg,png,pdf|max:5120|dimensions:width=100,height=100';
$imgRules = $this->compileValidate($defaultRules, $input);
$rules = array_merge($rules, [
$ruleKey => $this->pictureRules($ruleKey, implode("|", $imgRules)),
]);
break;
case DynamicFieldTypes::list->value:
$rules = array_merge($rules, [
"$ruleKey.*.order" => "required|numeric|min:1",
$ruleKey => $this->compileValidate('min:1|max:10', $input),
]);
foreach ($input['children'] as $key => $children) {
$rules = array_merge($rules, $this->recursiveRules($children, $ruleKey . ".*"));
}
break;
} //end switch
return $rules;
}
/**
* Get custom attributes for validator errors.
*
@ -139,10 +154,20 @@ abstract class UpdateDynamicFieldRequest extends FormRequest
{
$attributes = [];
foreach ($this->configModel->config as $input) {
$attributeKey = "$this->fieldName.$input[key]";
$attributes = array_merge($attributes, [
$attributeKey => Str::lower($input['label']),
]);
$attributes = array_merge($attributes, $this->recursiveAttributes($input, $this->fieldName));
}
return $attributes;
}
public function recursiveAttributes(&$input, $prefix): array
{
$attributes = [];
$attributeKey = sprintf("%s.%s", $prefix, $input['key']);
$attributes = array_merge($attributes, [$attributeKey => Str::lower($input['label'])]);
if (isset($input['children'])) {
foreach ($input['children'] as $key => $children) {
$attributes = array_merge($attributes, $this->recursiveAttributes($children, $attributeKey . ".*"));
}
}
return $attributes;
}

View file

@ -118,7 +118,8 @@ class DynamicPage extends Model implements TranslatableModel
static::updated(function (self $model) {
self::resetCache();
self::setAllChildPageStatus($model);
DynamicPageHelper::removeImgs($model);
// TODO: update this after recursive deleting images.
// DynamicPageHelper::removeImgs($model);
});
static::deleted(function () {
self::resetCache();
@ -138,7 +139,8 @@ class DynamicPage extends Model implements TranslatableModel
});
static::forceDeleted(function (self $model) {
self::resetCache();
DynamicPageHelper::removeImgs($model, true);
// TODO: update this after recursive deleting images.
// DynamicPageHelper::removeImgs($model, true);
});
}

View file

@ -32,7 +32,7 @@ class PageConfig extends Model implements TranslatableModel
*
* @var string $defaultView
*/
public static $defaultView = 'front.pages.dynamic-page-default';
public static $defaultView = 'front.pages.dynamic-pages.default';
/**
* The attributes that are mass assignable.

View file

@ -32,12 +32,12 @@ class DynamicPageRouteServiceProvider extends ServiceProvider
$locale = app()->getLocale();
$slug = $dynamicPageModel->getTranslation("slug", $locale);
$segments = self::buildFullUrlFromGroups($dynamicPageModel, $dynamicPageModels->keyBy('id'), $locale);
$segmentsSlug = implode('.', $segments);
$segmentsSlug = $segments ? implode('.', $segments) : "";
$segmentsPath = implode('/', $segments);
$definitivePath = sprintf("%s/%s", sprintf("/%s", $segmentsPath), $slug);
Route::get($definitivePath, function () use ($dynamicPageModel) {
return view($dynamicPageModel->getView(), compact('dynamicPageModel'));
})->name("front.$segmentsSlug.$slug");
})->name("front.$segmentsSlug$slug");
});
});
}

View file

@ -42,7 +42,7 @@ return [
*
*/
'path' => env('TELESCOPE_PATH', 'telescope'),
'path' => env('TELESCOPE_PATH', 'admin/telescope'),
/**
*--------------------------------------------------------------------------

View file

@ -80,16 +80,17 @@ return [
"close" => "Close",
"config_modal_title" => "Preview",
"config_modal_descripion" => "Contents of the selected configuration",
"config_new_field" => "Add a new field",
"config_delete_field" => "Delete this field",
"config_dnd_field" => "Move this field",
"config_empty_warning" => "No data recorded",
"config_none_selected" => "No configuration selected",
"config_select_fields" => "Overwrite fields with those of an existing language",
"config_save_warning" => "The configuration of related pages in :locale must 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:",
"config_complete_label" => "Copy the complete label of the input",
"entries_count" => ":length entry filled in|:length entries filled in",
"new_field" => "Add a new field",
"delete_field" => "Delete this field",
"dnd_field" => "Move this field",
"empty_warning" => "No data recorded",
"page_locked" => "This page is currently locked",
"unsupported_type" => "Unsupported type:",
"missing_translations" => "The translation of the :field for the language :locales is missing.|The translation

View file

@ -18,6 +18,8 @@ return [
'boolean' => 'Boolean',
'url' => 'Link',
'email' => 'E-mail',
'image' => 'Image',
'list' => 'Item list'
],
'contactFormType' => [
'collaborator' => [

View file

@ -142,6 +142,7 @@ return [
"url" => "The :attribute format is invalid.",
"uuid" => "The :attribute must be a valid UUID.",
"parent_page" => "A parent page is currently unpublished !",
"key_already_present" => "The key is already present in this range.",
"attributes" => [
"address" => "address",
"age" => "age",
@ -237,7 +238,7 @@ return [
"reference" => "reference",
"locked" => "locked",
"field" => "field",
"key" => "key",
"key" => "unique key",
"contactCount" => [
"type" => "form type",
"submissions" => "count of soumissions"

View file

@ -79,17 +79,18 @@ return [
"close" => "Fermer",
"config_modal_title" => "Prévisualisation",
"config_modal_descripion" => "Contenu de la configuration sélectionnée",
"config_new_field" => "Ajouter un nouveau champ",
"config_delete_field" => "Supprimer ce champ",
"config_dnd_field" => "Déplacer ce champ",
"config_empty_warning" => "Aucune donnée enregistrée",
"config_none_selected" => "Aucune configuration sélectionnée",
"config_select_fields" => "Écraser les champs avec ceux d'une langue déjà existante",
"config_save_warning" => "La configuration des pages associées dans la langue :locale devra être mise à jour
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" => "Copier le libellé complet de l'input:",
"config_complete_label" => "Copier le libellé complet de l'input",
"entries_count" => ":length entrée renseignée|:length entrées renseignées",
"new_field" => "Ajouter un nouveau champ",
"delete_field" => "Supprimer ce champ",
"dnd_field" => "Déplacer ce champ",
"empty_warning" => "Aucune donnée enregistrée",
"page_locked" => "Cette page est actuellement vérouillée",
"unsupported_type" => "Type non supporté:",
"missing_translations" => "La traduction du champs :field pour la langue :locales est manquante.|La traduction

View file

@ -18,7 +18,8 @@ return [
'boolean' => 'Booléen',
'url' => 'Lien',
'email' => 'E-mail',
'image' => 'Image'
'image' => 'Image',
'list' => 'Liste de champs'
],
'contactFormType' => [
'collaborator' => [

View file

@ -142,8 +142,9 @@ return [
"uploaded" => "Le fichier du champ :attribute n'a pu être téléversé.",
"uppercase" => "Le champ :attribute doit être en majuscules.",
"url" => "Le format de l'URL de :attribute n'est pas valide.",
"uuid" => "Le champ :attribute doit être un UUID valide",
"uuid" => "Le champ :attribute doit être un UUID valide.",
"parent_page" => "Une page parente est actuellement non publiée !",
"key_already_present" => "La clé est déjà présente dans cette étendue.",
"attributes" => [
"address" => "adresse",
"age" => "âge",
@ -229,7 +230,7 @@ return [
"published" => "publié",
"published_at" => "publié le",
"status" => "statut",
"path" => "chemin",
"path" => "chemin d'accès",
"label" => "libellé",
"view" => "vue spécifique",
"parent_page" => "page parente",
@ -239,7 +240,7 @@ return [
"reference" => "référence",
"locked" => "vérouillé",
"field" => "champs",
"key" => "clé",
"key" => "clé unique",
"contactCount" => [
"type" => "type de formulaire",
"submissions" => "nombre de soumissions"

33
package-lock.json generated
View file

@ -10,6 +10,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@inertiajs/react": "^2.0.17",
"@ldc-web/cookie-notice": "^6.1.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6",
@ -2777,6 +2778,37 @@
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-accordion": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
"integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
@ -2834,7 +2866,6 @@
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",

View file

@ -66,6 +66,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@inertiajs/react": "^2.0.17",
"@ldc-web/cookie-notice": "^6.1.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6",

View file

@ -5,32 +5,35 @@ import SimpleString from "@/back/Components/Inputs/SimpleString";
import SimpleNumber from "@/back/Components/Inputs/SimpleNumber";
import SimpleSwitch from "@/back/Components/Inputs/SimpleSwitch";
import SimpleWysiwyg from "@/back/Components/Inputs/SimpleWysiwyg";
import { DynamicFieldTypes, DynamicField } from "@/back/types/global";
import ImageCropper from "@/back/Components/Inputs/ImageCropper";
import DynamicList from "@/back/Components/DynamicPages/DynamicList";
import { ConfigField, DynamicFieldTypes, DynamicInternalField } from "@/back/types/global";
import str from "@/hooks/use-string";
import useTrans from "@/hooks/use-translations";
import ImageCropper from "./Inputs/ImageCropper";
import {
extractLaravelValidationValue,
extractMinMaxFromValidation,
extractRequiredValidation,
} from "@/hooks/use-laravel-validation";
type DynamicInputsProps = {
name: string;
config: DynamicField[];
content: Record<string, string | boolean | number> | null;
setData: (name: string, value: string | boolean | File | null) => void;
config: ConfigField[];
content: DynamicInternalField | null;
setData: (name: string, value: string | boolean | File | object | null) => void;
reset: (name?: string) => void;
};
type MinMax = {
min?: number;
max?: number;
};
export default function DynamicInputs({
name,
config,
content = {},
content = null,
setData,
reset,
}: DynamicInputsProps) {
const enumDynamicFieldTypes = usePage().props.enumDynamicFieldTypes as DynamicFieldTypes;
// Translations.
const transAssociateTo = useTrans("back.form_associate_to");
const transUnsupportedType = useTrans("back.unsupported_type");
@ -44,96 +47,15 @@ export default function DynamicInputs({
return str(`${transAssociateTo.split("|")[0].replace(":attribute", fieldLabel)}`).ucFirst().value();
};
/**
* Get limits from laravel validation.
* @param string validation
* @return MinMax
*/
const extractMinMaxFromValidation = (validation: string): MinMax => {
const result: MinMax = {};
// Regex for between match.
const betweenMatch = validation.match(/between:(\d+),(\d+)/);
if (betweenMatch) {
result.min = Number(betweenMatch[1]);
result.max = Number(betweenMatch[2]);
return result;
}
// Regex for minimum match.
const minMatch = validation.match(/min:(\d+)/);
if (minMatch) {
result.min = Number(minMatch[1]);
}
// Regex for maximum match.
const maxMatch = validation.match(/max:(\d+)/);
if (maxMatch) {
result.max = Number(maxMatch[1]);
}
return result;
}
/**
* Get required from laravel validation.
* @param string validation
* @return boolean
*/
const extractRequiredValidation = (validation: string): boolean => {
return validation.split("|").includes("required");
};
/**
* Extracts a number value from a Laravel validation rule.
*
* Supports both:
* - Simple rules like "max:255"
* - Complex rules like "dimensions:width=350,height=250"
*
* @param validation Full validation string (e.g. "required|max:255|dimensions:width=350,height=250")
* @param ruleName Rule to look for (e.g. "max" or "dimensions")
* @param key Optional. If the rule contains key=value pairs (e.g. "width=350"), specify the key
* @returns number|null
*/
const extractLaravelValidationValue = (
validation: string,
ruleName: string,
key?: string
): string | null => {
const rules = validation.split("|");
const rule = rules.find(r => r.startsWith(`${ruleName}:`));
if (!rule) return null;
const [, rawValue] = rule.split(":");
if (key) {
// Complex case like dimensions:width=350,height=250
const pairs = rawValue.split(",");
const match = pairs.find(pair => pair.startsWith(`${key}=`));
if (match) {
const [, value] = match.split("=");
return value;
}
} else {
return rawValue;
}
return null;
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 items-start gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 items-start gap-4">
{Object.entries(config).map(([id, field]) => {
const typeEnum = Object.values(enumDynamicFieldTypes).find(
(def) => def.value === field.type,
);
const fieldName = `${name}.${field.key}`;
const fieldValue = content?.[field.key] ?? "";
const fieldValue = content?.[field.key as keyof typeof content] ?? "";
switch (field.type) {
// String input.
@ -205,6 +127,7 @@ export default function DynamicInputs({
setData={setData}
/>
);
// Image input.
case enumDynamicFieldTypes.image.value:
return (
<ImageCropper
@ -238,6 +161,21 @@ export default function DynamicInputs({
setData={setData}
/>
);
// List input.
case enumDynamicFieldTypes.list.value:
return (
<DynamicList
key={id}
name={fieldName}
parentName={name}
content={content}
field={field}
value={Array.isArray(fieldValue) ? fieldValue : []}
enumLabel={enumDynamicFieldTypes.list.label}
setData={setData}
reset={reset}
/>
);
default:
return (
<p

View file

@ -0,0 +1,234 @@
// * Packages
import { CSS } from "@dnd-kit/utilities";
import { GripVerticalIcon, TrashIcon } from "lucide-react";
import { closestCenter, DndContext, 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";
import { Card, CardContent } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
// * Other
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
import DynamicInputs from "@/back/Components/DynamicPages/DynamicInputs";
import useTrans from "@/hooks/use-translations";
import { extractRequiredValidation } from "@/hooks/use-laravel-validation";
import AddNewFieldBtn from "@/back/Components//PageConfigs/AddNewFieldBtn";
import { ConfigField, DynamicInternalField } from "@/back/types/global";
type DynamicListProps = {
name: string,
parentName: string,
value: DynamicInternalField[],
enumLabel: string,
field: ConfigField,
content: DynamicInternalField | null;
setData: (name: string, value: string | boolean | File | object | null) => void;
reset: (name?: string) => void;
}
export default function DynamicList({
name,
parentName,
value,
enumLabel,
field,
content,
setData,
reset,
}: DynamicListProps) {
// Notice user there must be children config.
if (!field.children && field.children!.length > 0) {
return (
<p className="text-muted-foreground text-sm">
Des enfants sont nécessaires pour ce type {enumLabel}
</p>
);
}
// Drag&Drop.
const sensors = useSensors(useSensor(PointerSensor));
// Set local id.
const localValue = value.map((elmt, index) => {
return { ...elmt, id: elmt.id ?? index + 1 };
});
const sortedListItems = [...localValue].sort((a, b) => a.order - b.order);
const isRequired = extractRequiredValidation(field.validation);
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 (Array.isArray(value)) {
newObj[key] = removeIds(value);
}
// Sinon, on garde la valeur telle quelle
else {
newObj[key] = value;
}
}
return newObj;
});
};
return (
<div className="col-span-1 md:col-span-2">
<Label htmlFor={parentName}>{field.label + (isRequired ? " *" : "")}</Label>
{value.length > 0 && (
<Accordion
type="single"
collapsible
className="w-full"
>
<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);
}}
>
<SortableContext
items={sortedListItems.map((item) => item.id)}
strategy={verticalListSortingStrategy}
>
{sortedListItems.map((item, itemIndex) => (
<SortableItem
key={item.id}
id={item.id}
item={item}
itemIndex={itemIndex}
name={name}
parentName={parentName}
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);
}}
/>
))}
</SortableContext>
</DndContext>
</Accordion>
)}
<AddNewFieldBtn array={sortedListItems} 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);
}} />
</div>
)
}
type SortableItemProps = {
id: number;
item: any;
itemIndex: number;
name: string;
parentName: string;
field: any;
setData: (name: string, value: string | boolean | File | object | null) => void;
reset: (name?: string) => void;
removeField: (parentName: string, fieldKey: string, index: number) => void;
}
function SortableItem({
id,
item,
itemIndex,
name,
parentName,
field,
setData,
reset,
removeField,
}: SortableItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 50 : 0,
};
// Translations.
const transDragAndDrop = useTrans("back.dnd_field");
const transDelete = useTrans("back.delete_field");
return (
<div ref={setNodeRef} style={style} className="relative mb-3">
<AccordionItem value={`item-${itemIndex}`}>
<Card className="errors-classes">
<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">
<div className="flex-grow flex items-stretch">
{/* Drag handle */}
<BtnOptionnalLinkWithTooltip
content={<GripVerticalIcon className="w-4 h-4 m-auto" />}
tooltipContent={transDragAndDrop}
className="cursor-grab h-full w-full"
type="button"
variant="outline"
size="icon"
{...listeners}
{...attributes}
/>
</div>
<BtnOptionnalLinkWithTooltip
content={<TrashIcon className="w-4 h-4" />}
tooltipContent={transDelete}
type="button"
variant="destructive"
size="icon"
onClick={() => removeField(parentName, field.key, itemIndex)}
/>
</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">
Élément n°{itemIndex + 1}
</AccordionTrigger>
</div>
</div>
<AccordionContent className="pt-4 pb-1 px-1">
<DynamicInputs
name={`${name}.${itemIndex}`}
config={field.children}
content={item}
setData={setData}
reset={reset}
/>
</AccordionContent>
</CardContent>
</Card>
</AccordionItem>
</div>
);
}

View file

@ -22,6 +22,7 @@ type EnumSelectProps = {
required?: boolean;
disabled?: boolean;
selected?: string;
parentErrors?: Record<string, string>,
// eslint-disable-next-line no-unused-vars
setData: (name: string, value: string) => void;
};
@ -34,9 +35,11 @@ export default function EnumSelect({
selected = "",
required = true,
disabled = false,
parentErrors = {},
setData,
}: EnumSelectProps) {
const { errors } = usePage().props;
const localErrors = parentErrors ?? errors;
const [selectedValue, setSelected] = useState(selected);
const updateData = (name: string, value: string) => {
setSelected(value);
@ -56,7 +59,7 @@ export default function EnumSelect({
className={cn(
"w-full",
{ "text-muted-foreground": !selectedValue },
{ "border-destructive/50": errors[name] },
{ "border-destructive/50": localErrors[name] },
)}
>
<SelectValue placeholder={placeholder} />
@ -72,7 +75,7 @@ export default function EnumSelect({
))}
</SelectContent>
</Select>
<InputError message={errors[name]} />
<InputError message={localErrors[name]} />
</div>
);
}

View file

@ -99,7 +99,7 @@ export default function ImageCropper(props: ImageCropperProps) {
);
const [isDialogOpen, setDialogOpen] = useState(false)
const [accept, setAccept] = useState({
const [accept, setAccept] = useState<Record<string, string[]>>({
"image/*": [],
});
const [extensions, setExtensions] = useState<string>();
@ -153,7 +153,7 @@ export default function ImageCropper(props: ImageCropperProps) {
for (const [mime, extSet] of Object.entries(result)) {
accept[mime] = Array.from(extSet);
}
setAccept(accept as any);
setAccept(accept);
return accept;
};
useEffect(() => {

View file

@ -47,6 +47,7 @@ export default function SimpleNumber({
useEffect(() => {
setInputValue(safeValue);
setData(name, String(safeValue));
}, [safeValue]);
return (

View file

@ -25,6 +25,7 @@ type SimpleStringProps = {
disabled?: boolean;
pattern?: string;
checkVisibility?: boolean;
parentErrors?: Record<string, string>,
// eslint-disable-next-line no-unused-vars
setData: (name: string, value: string) => void;
};
@ -42,11 +43,13 @@ export default function SimpleString({
disabled = false,
pattern,
checkVisibility = false,
parentErrors = {},
setData,
}: SimpleStringProps) {
const [isVisible, setIsVisible] = useState(checkVisibility);
const inputRef = useRef<HTMLInputElement>(null);
const { errors } = usePage().props;
const localErrors = parentErrors ?? errors;
useEffect(() => {
if (inputRef.current && inputRef.current.value !== value) {
@ -80,7 +83,7 @@ export default function SimpleString({
type === "password" || checkVisibility
? "w-[calc(100%-40px-0.25rem)]"
: "w-full",
{ "border-destructive/50": errors[name] },
{ "border-destructive/50": localErrors[name] },
)}
pattern={pattern ?? undefined}
/>
@ -98,7 +101,7 @@ export default function SimpleString({
</Button>
)}
</div>
<InputError message={errors[name]} />
<InputError message={localErrors[name]} />
</div>
);
}

View file

@ -139,7 +139,7 @@ export default function SimpleWysiwyg({
<div className={cn("col-span-2", { "wysiwyg-error": errors[name] }, className)}>
<Label htmlFor={name}>{label + (required ? " *" : "")}</Label>
{wordCounter && <WordCounter value={content.replace(/<[^>]*>/g, "")} />}
<div className="my-1.5">
<div className={cn("mt-1.5", { "mb-1.5": errors[name] })}>
<RichTextEditor
// Force re-render.
key={version}

View file

@ -182,9 +182,9 @@ export default function AppSidebar() {
},
{
title: useTrans("back.telescope"),
link: route("telescope"),
link: route().has("telescope") ? route("telescope") : undefined,
icon: TelescopeIcon,
show: appEnv.mode === "local",
show: appEnv.mode === "local" && route().has("telescope"),
},
],
},
@ -254,8 +254,8 @@ export default function AppSidebar() {
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
{navGroup.links.map(
(navLink) => {
{navGroup.links.map((navLink) => {
if (navLink.show) {
const sidebarButton = (
<SidebarMenuButton
className={`${route().current(`back.${navLink.model}.*`)
@ -284,50 +284,48 @@ export default function AppSidebar() {
)}
</SidebarMenuButton>
);
return (navLink.show && (
<SidebarMenuItem
key={navLink.title}
className={`transition-colors rounded-md ${route().current(
`back.${navLink.model}.*`,
) ? "bg-secondary text-secondary-foreground" : "bg-opacity-0"
}`}
>
{isCollapsed ? (
<Tooltip>
<TooltipTrigger asChild>
{sidebarButton}
</TooltipTrigger>
<TooltipContent side="right">
<p>{navLink.title}</p>
</TooltipContent>
</Tooltip>
) : (
sidebarButton
)}
{navLink.model && navLink.actions?.(navLink.model) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
className={`${route().current(
`back.${navLink.model}.*`,
) ? "text-secondary-foreground" : ""
} !top-0 !right-0 px-4 h-full`}
>
<MoreHorizontalIcon />
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
side="right"
align="start"
return <SidebarMenuItem
key={navLink.title}
className={`transition-colors rounded-md ${route().current(
`back.${navLink.model}.*`,
) ? "bg-secondary text-secondary-foreground" : "bg-opacity-0"
}`}
>
{isCollapsed ? (
<Tooltip>
<TooltipTrigger asChild>
{sidebarButton}
</TooltipTrigger>
<TooltipContent side="right">
<p>{navLink.title}</p>
</TooltipContent>
</Tooltip>
) : (
sidebarButton
)}
{navLink.model && navLink.actions?.(navLink.model) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
className={`${route().current(
`back.${navLink.model}.*`,
) ? "text-secondary-foreground" : ""
} !top-0 !right-0 px-4 h-full`}
>
{navLink.actions(navLink.model)}
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarMenuItem>
));
},
)}
<MoreHorizontalIcon />
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
side="right"
align="start"
>
{navLink.actions(navLink.model)}
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarMenuItem>
}
})}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>

View file

@ -0,0 +1,47 @@
// * Packages
import { PlusIcon } from "lucide-react"
import { VariantProps } from "class-variance-authority";
// * Components shadcn
import { Button, buttonVariants } from "@/components/ui/button"
// * Other
import useTrans from "@/hooks/use-translations"
import { ConfigInternalField, DynamicInternalField } from "@/back/types/global";
type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
const AddNewFieldBtn = ({
array,
variant = "default",
onClick,
}: {
array: ConfigInternalField[] | DynamicInternalField[] | undefined,
variant?: ButtonVariant,
onClick: () => void,
}) => {
const transNewField = useTrans("back.new_field");
const transEmptyWarning = useTrans("back.empty_warning");
const transEntriesCount = useTrans("back.entries_count", {
params: {
length: String(array?.length ?? 0),
},
choice: (array?.length ?? 0) > 1 ? 2 : 1
});
return (
<div className="flex flex-col md:flex-row justify-center md:justify-between items-center">
<Button
type="button"
variant={variant}
onClick={onClick}
>
<PlusIcon className="w-4 h-4 m-auto" />
{transNewField}
</Button>
<p className="text-sm text-muted-foreground text-center my-2 xl:my-0">
{(array?.length ?? 0) > 0 ? transEntriesCount : transEmptyWarning}
</p>
</div>
)
}
export default AddNewFieldBtn;

View file

@ -1,5 +1,7 @@
// * Packages
import { usePage } from "@inertiajs/react";
import { CornerDownRightIcon } from "lucide-react";
import { ReactNode } from "react";
// * Components shadcn
import {
Table,
@ -12,10 +14,10 @@ import {
// * Other
import str from "@/hooks/use-string";
import useTrans from "@/hooks/use-translations";
import { DynamicField, DynamicFieldTypes } from "@/back//types/global";
import { ConfigField, DynamicFieldTypes } from "@/back/types/global";
type ConfigTableProps = {
config: DynamicField[];
config: ConfigField[];
};
export default function ConfigTable({ config }: ConfigTableProps) {
@ -24,6 +26,35 @@ export default function ConfigTable({ config }: ConfigTableProps) {
return Object.values(enumDynamicFieldTypes).find((def) => def.value === value);
};
const GetChildrenRow = (fields: ConfigField[], depth: number = 0): ReactNode => {
console.log(depth)
return Object.entries(fields).map(
([id, field]) => (
<>
<TableRow key={id}>
<TableCell>
<div className="flex justify-start items-center gap-1.5">
{depth > 0 && <CornerDownRightIcon size={18} />}
{depth > 0 && <span className="text-muted-foreground">#{depth}</span>}
{getEnumDynamicFieldType(field.type)?.label}
</div>
</TableCell>
<TableCell>
{field.label}
<code className="text-xs ms-1">
({field.key})
</code>
</TableCell>
<TableCell>
{field.validation}
</TableCell>
</TableRow>
{field.children && GetChildrenRow(field.children, depth + 1)}
</>
)
)
}
return (
<Table>
<TableHeader>
@ -40,24 +71,7 @@ export default function ConfigTable({ config }: ConfigTableProps) {
</TableRow>
</TableHeader>
<TableBody>
{Object.entries(config).map(
([id, field]) => (
<TableRow key={id}>
<TableCell>
{getEnumDynamicFieldType(field.type)?.label}
</TableCell>
<TableCell>
{field.label}
<code className="text-xs ms-1">
({field.key})
</code>
</TableCell>
<TableCell>
{field.validation}
</TableCell>
</TableRow>
),
)}
{GetChildrenRow(config)}
</TableBody>
</Table>
);

View file

@ -1,7 +1,6 @@
// * Packages
import { useState, useEffect, useMemo, useRef } from "react";
import { usePage } from "@inertiajs/react";
import { PlusIcon } from "lucide-react";
import {
DndContext,
closestCenter,
@ -16,25 +15,24 @@ import {
} from "@dnd-kit/sortable";
import type { DragEndEvent } from "@dnd-kit/core";
// * Components shadcn
import { Button } from "@/components/ui/button";
import { Accordion } from "@/components/ui/accordion";
// * Other
import SortableField from "@/back/Components/PageConfigs/SortableField";
import FieldGroup from "@/back/Components/PageConfigs/FieldGroup";
import AddNewFieldBtn from "@/back/Components/PageConfigs/AddNewFieldBtn";
import InputError from "@/back/Components/InputError";
import str from "@/hooks/use-string";
import { AppEnv, DynamicField, DynamicFieldTypes, PageConfigModel } from "@/back/types/global";
import { ConfigField, ConfigInternalField, DynamicFieldTypes } from "@/back/types/global";
import useTrans from "@/hooks/use-translations";
import str from "@/hooks/use-string";
type DynamicFieldsProps = {
name: string;
value: DynamicField[];
value: ConfigField[];
required?: boolean;
disabled?: boolean;
// eslint-disable-next-line no-unused-vars
setData: (name: string, value: DynamicField[]) => void;
setData: (name: string, value: ConfigField[]) => void;
};
type InternalField = DynamicField & { id: number };
export default function DynamicFields({
name,
value = [],
@ -42,22 +40,42 @@ export default function DynamicFields({
disabled = false,
setData,
}: DynamicFieldsProps) {
const { locale } = usePage().props.appEnv as AppEnv;
const data = usePage().props.data as { data: PageConfigModel|null};
const originalConfigKeys = (data.data && data.data.config && data.data.config[locale]) ? data.data.config[locale].map(input => input.key) : [];
const { errors } = usePage().props;
const enumDynamicFieldTypes = usePage().props
.enumDynamicFieldTypes as DynamicFieldTypes;
const [localErrors, setLocalErrors] = useState<Record<string, string>>(errors)
const enumDynamicFieldTypes = usePage().props.enumDynamicFieldTypes as DynamicFieldTypes
const [openIndex, setOpenIndex] = useState<string>("");
// Unique stable id for each field.
const nextId = useRef(1);
// Sensitive fields.
var [originalConfigKeys, setOriginalConfigKeys] = useState<string[]>([]);
const addIdsRecursively = (
fields: ConfigField[],
parentKey: string = name,
startId = 0,
): ConfigInternalField[] => {
let currentId = startId;
const withIds = fields.map(field => {
const id = currentId++;
const [fields, setFields] = useState<InternalField[]>(() =>
(value ?? []).map((field) => ({
id: nextId.current++,
...field,
})),
);
// Add the key in sensitive array.
const currentParentKey = `${parentKey}.${field.key}`;
setOriginalConfigKeys(prev => [...prev, currentParentKey]);
const children = field.children
? addIdsRecursively(field.children, currentParentKey, currentId)
: undefined;
// Si on a ajouté des enfants, il faut récupérer le dernier ID utilisé
if (field.children?.length) {
currentId += field.children.length;
}
return children ? { ...field, id, children } : { ...field, id };
});
return withIds as ConfigInternalField[];
}
const [fields, setFields] = useState<ConfigInternalField[]>(() => addIdsRecursively(value ?? []));
// Local changes of fields (avoid loop between useEffects).
const prevValueRef = useRef(JSON.stringify(value ?? []));
@ -69,12 +87,20 @@ export default function DynamicFields({
label: useTrans("validation.custom.label"),
type: useTrans("validation.custom.type"),
validation: useTrans("validation.custom.validation"),
children: useTrans("validation.custom.children"),
};
const transJunction = useTrans("validation.custom.of_the.male");
const transField = useTrans("validation.custom.field");
const transDelete = useTrans("back.config_delete_field");
const transDragAndDrop = useTrans("back.config_dnd_field");
const transConfigEmptyWarning = useTrans("back.config_empty_warning");
const transDelete = useTrans("back.delete_field");
const transDragAndDrop = useTrans("back.dnd_field");
const transNewField = useTrans("back.new_field");
const transEmptyWarning = useTrans("back.empty_warning");
const transEntriesCount = useTrans("back.entries_count", {
params: {
length: String(fields.length ?? 0),
},
choice: (fields.length ?? 0) > 1 ? 2 : 1
});
/**
* Get a translation for a specific input from trans constant.
@ -100,19 +126,17 @@ export default function DynamicFields({
* @returns void
*/
const addField = (): void => {
const { label } = enumDynamicFieldTypes.string;
const maxOrder =
Math.max(0, ...fields.map((field) => field.order ?? 0)) + 1;
const maxOrder = Math.max(0, ...fields.map((field) => field.order ?? 0)) + 1;
const maxIndex = Math.max(0, ...fields.map((field) => field.id ?? 0)) + 1;
const newField: InternalField = {
id: nextId.current++,
key: `field_${maxOrder}`,
type: 1,
label,
const newField: ConfigInternalField = {
id: maxIndex,
key: `${enumDynamicFieldTypes.string.name}_`,
type: enumDynamicFieldTypes.string.value,
label: "",
validation: enumDynamicFieldTypes.string.validation,
order: maxOrder,
};
setFields((prev) => [...prev, newField]);
};
@ -124,21 +148,29 @@ export default function DynamicFields({
*/
const updateField = (
fieldId: number,
updatedField: Partial<DynamicField>,
updatedField: Partial<ConfigField>,
): void => {
setFields((prev) =>
prev.map((field) => {
if (field.id !== fieldId) return field;
const merged = { ...field, ...updatedField };
// Merge the data.
const merged = Object.assign({}, field, updatedField);
// Only if updated field is type.
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;
const originalMatchingKey = Object.keys(enumDynamicFieldTypes).find(
(k) => enumDynamicFieldTypes[k].value === field.type,
);
const editableKey = field.key.split(`${originalMatchingKey ?? enumDynamicFieldTypes.string.name}_`)[1]
if (matchingKey) {
merged.validation = enumDynamicFieldTypes[matchingKey].validation;
merged.type = enumDynamicFieldTypes[matchingKey].value;
merged.key = `${matchingKey}_${editableKey}`;
}
}
return merged;
}),
@ -151,9 +183,17 @@ export default function DynamicFields({
* @returns void
*/
const removeField = (fieldId: number): void => {
const targetField = fields.filter((field) => field.id === fieldId)[0];
updateSensitiveField(targetField.key);
setFields((prev) => prev.filter((field) => field.id !== fieldId));
};
const updateSensitiveField = (keyToRemove: string): void => {
setOriginalConfigKeys(prev => prev.filter((key) => key !== keyToRemove));
setLocalErrors({});
};
/**
* Save the new order.
* @param event DragEndEvent
@ -163,99 +203,86 @@ export default function DynamicFields({
const { active, over } = event;
if (!over || active.id === over.id) return;
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;
const oldIndex = sortedFields.findIndex((f) => f.id === Number(active.id));
const newIndex = sortedFields.findIndex((f) => f.id === Number(over.id));
if (oldIndex < 0 || newIndex < 0) return;
setFields((prev) =>
arrayMove(prev, oldIndex, newIndex).map((fld, idx) => ({
setFields((prev) => {
const moved = arrayMove(prev, oldIndex, newIndex);
return moved.map((fld, idx) => ({
...fld,
order: idx + 1,
})),
);
}));
});
};
const removeIds = (fields: ConfigInternalField[]): ConfigField[] => {
return fields.map(({ id, children, ...rest }) => ({
...rest,
...(children ? { children: removeIds(children) } : {}),
})) as ConfigField[];
}
// Export to parent only if payload differs with prevValueRef.
useEffect(() => {
const payload = fields.map(({ id, ...rest }) => rest);
const payload = removeIds(fields);
const payloadStr = JSON.stringify(payload);
if (payloadStr !== prevValueRef.current) {
setData(name, payload);
prevValueRef.current = payloadStr;
}
}, [fields, name, setData]);
// Synchronize incoming value from parent and preserve ids if key exist.
useEffect(() => {
if (!value) return;
const valueStr = JSON.stringify(value ?? []);
// 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 ?? nextId.current++,
...field,
order: field.order ?? i + 1,
} as InternalField;
}),
);
prevValueRef.current = valueStr;
}, [value]);
}, [fields]);
return (
<div className="flex flex-col w-full gap-4">
{sortedFields.length ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
<div className="flex flex-col w-full">
{sortedFields.length > 0 && (
<Accordion
type="single"
className="w-full"
value={openIndex}
onValueChange={setOpenIndex}
collapsible
>
<SortableContext
items={sortedFields.map((field) => field.id)}
strategy={verticalListSortingStrategy}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
{sortedFields.map((field, index) => (
<SortableField
key={field.id}
id={field.id}
fieldKey={field.key}
index={index}
field={field}
removeField={() => removeField(field.id)}
updateField={(_, updatedInput) =>
updateField(field.id, updatedInput)
}
required={required}
disabled={disabled}
isSensitiveField={originalConfigKeys.includes(field.key)}
getTransInput={getTransInput}
transDelete={transDelete}
transDragAndDrop={transDragAndDrop}
/>
))}
</SortableContext>
</DndContext>
) : (
<p className="text-sm text-muted-foreground text-center">
{transConfigEmptyWarning}
</p>
<SortableContext
items={sortedFields.map((field) => String(field.id))}
strategy={verticalListSortingStrategy}
>
{sortedFields.map((field, index) => (
<FieldGroup
key={field.id}
currentId={field.id}
currentIndex={index}
currentFieldKey={name}
parentFieldKey={name}
currentField={field}
removeField={() => removeField(field.id)}
updateField={(_, updatedInput) =>
updateField(field.id, updatedInput)
}
currentRequired={required}
currentDisabled={disabled}
sensitiveField={originalConfigKeys}
getTransInput={getTransInput}
transDelete={transDelete}
transDragAndDrop={transDragAndDrop}
updateSensitiveField={updateSensitiveField}
localErrors={localErrors}
setLocalErrors={setLocalErrors}
parentAccordionState={openIndex}
setParentAccordionState={setOpenIndex}
/>
))}
</SortableContext>
</DndContext>
</Accordion>
)}
<div className="flex justify-center lg:justify-start items-center">
<Button type="button" onClick={addField} className="self-start">
<PlusIcon className="w-4 h-4 mr-2" />
{useTrans("back.config_new_field")}
</Button>
</div>
<InputError message={errors?.[name]} />
<AddNewFieldBtn array={fields} onClick={addField} />
<InputError message={localErrors[name]} />
</div>
);
}

View file

@ -0,0 +1,388 @@
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/global";
import useTrans from "@/hooks/use-translations";
import str from "@/hooks/use-string";
import { cn } from "@/lib/utils";
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[];
updateSensitiveField: (key: string) => void;
localErrors: Record<string, string>;
setLocalErrors: Dispatch<SetStateAction<Record<string, string>>>;
parentAccordionState: string;
setParentAccordionState: (key: string) => void;
}
export default function FieldGroup({
currentId,
currentIndex,
currentFieldKey,
parentFieldKey,
currentField,
currentRequired,
currentDisabled,
getTransInput,
updateField,
removeField,
transDelete,
transDragAndDrop,
sensitiveField,
updateSensitiveField,
localErrors,
setLocalErrors,
parentAccordionState,
setParentAccordionState,
}: FieldGroupProps) {
const enumDynamicFieldTypes = usePage().props.enumDynamicFieldTypes as DynamicFieldTypes;
const [openIndex, setOpenIndex] = useState<string>("");
// === Sortable pour cet item (utile pour les enfants et pour les racines si parent a fourni context)
const sortableId = String(currentId);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: sortableId });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 50 : 0,
};
// calcul de la partie enum (ex: "string_") à partir du type actuel
const currentEnum = Object.values(enumDynamicFieldTypes).find(
(def) => def.value === currentField.type
);
const enumCurrentBase = `${currentEnum?.name}_`;
const [editableKey, setEditableKey] = useState<string>(() => {
return (currentField.key.startsWith(enumCurrentBase)) ? currentField.key.split(enumCurrentBase)[1] : currentField.key;
});
// completeKey : clé finale qui sera envoyée au backend.
const completeKey = `${enumCurrentBase}${editableKey}`;
const [copied, setCopied] = useState(false);
const MAX_DEPTH = 10;
const depth = (currentFieldKey.match(/children/g) || []).length;
const canAddChild = depth < MAX_DEPTH;
// Translations
const transNewField = useTrans("back.new_field");
const transEmptyWarning = useTrans("back.empty_warning");
const transEntriesCount = useTrans("back.entries_count", {
params: {
length: String(currentField.children?.length ?? 0),
},
choice: (currentField.children?.length ?? 0) > 1 ? 2 : 1
});
// copie dans le presse-papier
const copyToClipBoard = (data: string) => navigator.clipboard.writeText(data);
const handleClick = () => {
copyToClipBoard(completeKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
// capteur DnD pour les sous-éléments (list items)
const sensors = useSensors(useSensor(PointerSensor));
// === gestion du réordonnancement des enfants (pour les sous-champs list)
const handleDragEnd = (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 });
};
// === création d'un enfant : on génère une clé basée sur la clé complète du parent
const handleAddChild = () => {
// déterminer suffixe numérique pour le nouveau champ
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 });
};
const handleDeleteChild = (childId: number) => {
const children = (currentField.children ?? []);
const targetChild = children.filter((child) => child.id === childId)[0];
updateSensitiveField(`${parentFieldKey}.${targetChild.key}`);
const childrenUpdated = children.filter(
(c) => c.id !== childId
);
updateField(currentId, { children: childrenUpdated });
};
const updatingType = (value: string) => {
const selectedType = Object.values(enumDynamicFieldTypes).find(
(def) => def.value === parseInt(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"
disable={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(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={(_, value) => {
const newCompleteKey = `${enumCurrentBase}${value}`;
if (sensitiveField.includes(`${parentFieldKey}.${newCompleteKey}`)) {
setLocalErrors(prev => ({
...prev,
[`${currentFieldKey}.${currentIndex}.key`]: useTrans('validation.key_already_present'),
}));
} else {
setLocalErrors((prev) => {
const { [`${currentFieldKey}.${currentIndex}.key`]: _, ...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>
{/* === SOUS-CHAMPS (si type is array) === */}
{currentEnum?.isarray && (
<>
{canAddChild ? (
<Accordion
type="single"
className="w-full mt-4"
value={openIndex}
onValueChange={setOpenIndex}
collapsible
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<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}
updateSensitiveField={updateSensitiveField}
localErrors={localErrors}
setLocalErrors={setLocalErrors}
parentAccordionState={openIndex}
setParentAccordionState={setOpenIndex}
/>
))}
</SortableContext>
</DndContext>
<AddNewFieldBtn array={currentField.children} onClick={handleAddChild} />
</Accordion>
) : (
<p className="text-destructive italic mt-2">
Profondeur maximale atteinte ({MAX_DEPTH})
</p>
)}
</>
)}
</AccordionContent>
</CardContent>
</Card>
</AccordionItem>
</div>
);
}

View file

@ -1,229 +0,0 @@
// * Packages
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";
// * Other
import EnumSelect from "@/back/Components/Inputs/EnumSelect";
import SimpleString from "@/back/Components/Inputs/SimpleString";
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
import { DynamicField, DynamicFieldTypes } from "@/back/types/global";
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: number;
index: number;
field: DynamicField;
fieldKey: string;
removeField: (id: number) => void;
updateField: (id: number, updatedField: Partial<DynamicField>) => void;
required?: boolean;
disabled?: boolean;
isSensitiveField?: boolean;
getTransInput: (
field: "type" | "key" | "label" | "validation",
id: number,
) => string;
transDelete: string;
transDragAndDrop: string;
};
/* eslint-enable no-unused-vars, @typescript-eslint/no-unused-vars */
export default function SortableField({
id,
index,
field,
fieldKey,
removeField,
updateField,
required=true,
disabled=false,
isSensitiveField=false,
getTransInput,
transDelete,
transDragAndDrop,
}: SortableFieldProps) {
// Enum.
const enumDynamicFieldTypes = usePage().props
.enumDynamicFieldTypes as DynamicFieldTypes;
const getEnumDynamicFieldType = (value: number) => {
return Object.values(enumDynamicFieldTypes).find(
(def) => def.value === value,
);
};
// 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;
// Copy.
const [copied, setCopied] = useState(false);
const handleClick = () => {
copyToClipBoard(completeKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
useEffect(() => {
if (isTypeChanged) {
setEditableKey(splitAndGetEditableKey(editableKey));
}
setIsTypeChanged(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isTypeChanged]);
useEffect(() => {
updateField(id, { key: completeKey });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fieldKey, editableKey, completeKey]);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : 1,
};
const copyToClipBoard = (data: string) => {
navigator.clipboard.writeText(data);
};
return (
<div
ref={setNodeRef}
style={style}
className="flex flex-col lg:flex-row items-start w-full gap-4 border-b lg:border-input pb-4"
>
{/* Actions */}
<div className="flex flex-row gap-2 w-full lg:w-auto h-full">
{/* Drag button */}
<div className="flex-grow flex items-stretch lg:flex-none lg:h-full">
<BtnOptionnalLinkWithTooltip
content={<GripVerticalIcon className="w-4 h-4 m-auto" />}
tooltipContent={transDragAndDrop}
className="cursor-grab h-full w-full lg:w-[40px]"
type="button"
variant="outline"
size="icon"
{...attributes}
{...listeners}
/>
</div>
{/* Copy & Delete group */}
<div className="flex flex-row gap-2 lg:flex-col lg:h-full lg:justify-between">
{/* Copy key button */}
<BtnOptionnalLinkWithTooltip
content={
<>
<CopyIcon
className={`size-4 ${copied ? "hidden" : "block"}`}
/>
<CheckIcon
className={`size-4 ${copied ? "block" : "hidden"}`}
/>
</>
}
tooltipContent={
<>
<h4 className="font-semibold">
{str(useTrans("back.config_complete_label")).ucFirst().value()}
</h4>
<p className="text-muted-foreground mt-1">
{completeKey}
</p>
</>
}
type="button"
variant="ghost"
size="icon"
onClick={handleClick}
className="lg:h-full"
/>
{/* Delete button */}
<BtnOptionnalLinkWithTooltip
content={<TrashIcon className="w-4 h-4" />}
tooltipContent={transDelete}
type="button"
variant="destructive"
size="icon"
onClick={() => removeField(id)}
className="lg:h-full"
/>
</div>
</div>
{/* Inputs */}
<div className="grid grid-cols-1 gap-4 border-l-0 lg:border-l lg:border-input lg:ps-4 w-full">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 w-full">
{/* Input label */}
<SimpleString
name={`config.${fieldKey}.label`}
label={getTransInput("label", index + 1)}
placeholder="ex: John Doe"
value={field.label}
setData={(_, value) =>
updateField(id, { label: value })
}
/>
{!isSensitiveField && (
<>
{/* Input key */}
<SimpleString
name={`config.${fieldKey}.key`}
label={getTransInput("key", index + 1)}
placeholder="ex: John Doe"
value={editableKey}
setData={(_, value) => {
setEditableKey(value);
}}
/>
{/* Input type */}
<EnumSelect
name={`config.${fieldKey}.type`}
label={getTransInput("type", index + 1)}
placeholder={`ex: ${enumDynamicFieldTypes?.string?.label}`}
selected={String(field.type)}
options={enumDynamicFieldTypes}
setData={(_, value) => {
setIsTypeChanged(true);
updateField(id, { type: Number(value) });
}}
required={required}
disabled={disabled}
/>
</>
)}
{/* Input validation */}
<SimpleString
name={`config.${fieldKey}.validation`}
label={getTransInput("validation", index + 1)}
placeholder="ex: required|string"
value={field.validation}
setData={(_, value) =>
updateField(id, { validation: value })
}
required={required}
disabled={disabled}
/>
</div>
</div>
</div>
);
}

View file

@ -21,7 +21,7 @@ import SimpleSwitch from "@/back/Components/Inputs/SimpleSwitch";
import SimpleWysiwyg from "@/back/Components/Inputs/SimpleWysiwyg";
import EnumSelect from "@/back/Components/Inputs/EnumSelect";
import ModelSelect from "@/back/Components/Inputs/ModelSelect";
import DynamicInputs from "@/back/Components/DynamicInputs";
import DynamicInputs from "@/back/Components/DynamicPages/DynamicInputs";
import FieldsetWrapper from "@/back/Components/FieldsetWrapper";
import ConfigTable from "@/back/Components/PageConfigs/ConfigTable";
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
@ -467,9 +467,10 @@ export default function Form({
name="content"
config={getConfig(Number(data.config_id))}
content={
typeof data.content === "object" ? data.content : {}
typeof data.content === "object" ? data.content : null
}
setData={setData}
// @ts-ignore
reset={reset}
/>
)}

View file

@ -40,7 +40,7 @@ import {
} from "@/back/types/global";
import str from "@/hooks/use-string";
import useTrans from "@/hooks/use-translations";
import DynamicStatusBadge from "@/back/Components/DynamicStatusBadge";
import DynamicStatusBadge from "@/back/Components/DynamicPages/DynamicStatusBadge";
export default function Index({
dynamicPageModels,

View file

@ -16,7 +16,7 @@ import {
import AuthenticatedLayout from "@/back/Layouts/AuthenticatedLayout";
import DetailsTable from "@/back/Components/Table/DetailsTable";
import ModelActions from "@/back/Components/ModelActions";
import DynamicStatusBadge from "@/back/Components/DynamicStatusBadge";
import DynamicStatusBadge from "@/back/Components/DynamicPages/DynamicStatusBadge";
import { Views } from "@/lib/utils";
import { useFormatedDate } from "@/hooks/use-formated-value";
import {

View file

@ -141,7 +141,8 @@ export default function Form({
}
const resetConfig = (localeData: string) => {
setData("config", model?.config ? model?.config[str(localeData).lowerCase().value()] ?? [] : []);
const customLocaleData = str(localeData).lowerCase().value()
setData("config", model?.config ? model?.config[customLocaleData] ?? [] : []);
}
/**

View file

@ -67,21 +67,17 @@ type DynamicPageModel = {
parent_page: DynamicPageModel | null;
children_page: DynamicPageModel[];
};
type DynamicField = {
key: string;
type: number;
label: string;
validation: string;
order: number;
};
type DynamicFieldTypes = Record<
string,
{ label: string; name: string; value: number; validation: string }
{ label: string; name: string; value: number; validation: string, isarray: boolean }
>;
type DynamicInternalField = {
id?: number,
value: string | number | boolean | InternalValue[]
order: number,
}
// Page config type, with policies.
// Page config types
type PageConfigModel = {
policies: ModelPolicy;
id: number;
@ -94,6 +90,18 @@ type PageConfigModel = {
deleted_at: Date;
pages: DynamicPageModel[];
};
type ConfigField = {
key: string;
type: number;
label: string;
validation: string;
order: number;
children?: DynamicField[];
};
type ConfigInternalField = Omit<DynamicField, "children"> & {
id: number;
children?: ConfigInternalField[];
};
// Default model, with policies.
type DefaultModel = {

View file

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View file

@ -0,0 +1,83 @@
type MinMax = {
min?: number;
max?: number;
};
/**
* Get limits from laravel validation.
* @param string validation
* @return MinMax
*/
export const extractMinMaxFromValidation = (validation: string): MinMax => {
const result: MinMax = {};
// Regex for between match.
const betweenMatch = validation.match(/between:(\d+),(\d+)/);
if (betweenMatch) {
result.min = Number(betweenMatch[1]);
result.max = Number(betweenMatch[2]);
return result;
}
// Regex for minimum match.
const minMatch = validation.match(/min:(\d+)/);
if (minMatch) {
result.min = Number(minMatch[1]);
}
// Regex for maximum match.
const maxMatch = validation.match(/max:(\d+)/);
if (maxMatch) {
result.max = Number(maxMatch[1]);
}
return result;
}
/**
* Get required from laravel validation.
* @param string validation
* @return boolean
*/
export const extractRequiredValidation = (validation: string): boolean => {
return validation.split("|").includes("required");
};
/**
* Extracts a number value from a Laravel validation rule.
*
* Supports both:
* - Simple rules like "max:255"
* - Complex rules like "dimensions:width=350,height=250"
*
* @param validation Full validation string (e.g. "required|max:255|dimensions:width=350,height=250")
* @param ruleName Rule to look for (e.g. "max" or "dimensions")
* @param key Optional. If the rule contains key=value pairs (e.g. "width=350"), specify the key
* @returns number|null
*/
export const extractLaravelValidationValue = (
validation: string,
ruleName: string,
key?: string
): string | null => {
const rules = validation.split("|");
const rule = rules.find(r => r.startsWith(`${ruleName}:`));
if (!rule) return null;
const [, rawValue] = rule.split(":");
if (key) {
// Complex case like dimensions:width=350,height=250
const pairs = rawValue.split(",");
const match = pairs.find(pair => pair.startsWith(`${key}=`));
if (match) {
const [, value] = match.split("=");
return value;
}
} else {
return rawValue;
}
return null;
};

View file

@ -32,7 +32,7 @@ const useTrans = (key: string, options: UseTransOptions = {}): string => {
/**
* Get the value for the right key.
* @param value Record<string, any>
* @param value Record<string, string | object>
* @param index number
*/
function recursiveFunction(

View file

@ -1,66 +0,0 @@
@extends('front.layouts.layout')
@section('title', 'Rechercher sur LDC.fr')
@section('description', 'Rechercher sur LDC.fr')
@section('content')
<h1>{{ $dynamicPageModel->title }}</h1>
<code>{{ $dynamicPageModel->slug }}</code>
<p>{{ $dynamicPageModel->status->label() }}</p>
<p>{{ $dynamicPageModel->isLocked }}</p>
<hr>
@if (!is_null($dynamicPageModel->config_id))
@foreach ($dynamicPageModel->content as $key => $value)
@php
$filteredArray = collect($dynamicPageModel->config->config)->filter(function ($item) use ($key) {
return $item['key'] === $key;
});
@endphp
@switch($filteredArray->count() ? $filteredArray->first()['type'] : 0)
@case(\App\Enums\DynamicFieldTypes::string->value())
<p>string: {{ $value }}</p>
@break
@case(\App\Enums\DynamicFieldTypes::text->value())
<p>text:</p>
{!! $value !!}
@break
@case(\App\Enums\DynamicFieldTypes::number->value())
<p>number: {{ $value }}</p>
@break
@case(\App\Enums\DynamicFieldTypes::boolean->value())
<p>boolean: {{ $value }}</p>
@break
@case(\App\Enums\DynamicFieldTypes::url->value())
<p>url: <a href="{{ $value }}" target="_blank">{{ $value }}</a></p>
@break
@case(\App\Enums\DynamicFieldTypes::image->value())
<p>image: <a href="{{ $value }}" target="_blank">{{ $value }}</a></p>
@break
@default
<p>type non reconnu: {{ $value }}</p>
@endswitch
<hr>
@endforeach
@else
{!! $dynamicPageModel->content !!}
@endif
<small>{{ $dynamicPageModel->created_at }}</small>
<small>{{ $dynamicPageModel->updated_at }}</small>
<small>{{ $dynamicPageModel->deleted_at }}</small>
<div>
@foreach(config('laravellocalization.supportedLocales') as $localeCode => $localeData)
@php $slug = $dynamicPageModel->getTranslation('slug', $localeCode, false); @endphp
@if($slug)
<a href="{{ LaravelLocalization::getLocalizedURL($localeCode, $slug) }}" class="btn btn-primary">
{{ strtoupper($localeCode) }} version
</a>
@endif
@endforeach
</div>
@endsection

View file

@ -0,0 +1,32 @@
@extends('front.layouts.layout')
@section('title', 'Rechercher sur LDC.fr')
@section('description', 'Rechercher sur LDC.fr')
@section('content')
<h1>title (Titre): {{ $dynamicPageModel->title }}</h1>
<code>slug (Chemin): {{ $dynamicPageModel->slug }}</code>
<p>status (Statut): {{ $dynamicPageModel->status->label() }}</p>
<p>isLocked (Vérouillé): {{ $dynamicPageModel->isLocked ? "true": "false" }}</p>
@if (!is_null($dynamicPageModel->config_id))
@include('front.pages.dynamic-pages.switch-input', [
'config' => $dynamicPageModel->config->config,
'content' => $dynamicPageModel->content,
])
@else
{!! $dynamicPageModel->content !!}
@endif
<p>created_at (Date de création): {{ $dynamicPageModel->created_at }}</p>
<p>updated_at (Date de mise à jour): {{ $dynamicPageModel->updated_at }}</p>
<p>deleted_at (Date de suppression): {{ $dynamicPageModel->deleted_at }}</p>
<div>
@foreach(config('laravellocalization.supportedLocales') as $localeCode => $localeData)
@php $slug = $dynamicPageModel->getTranslation('slug', $localeCode, false); @endphp
@if($slug)
<a href="{{ LaravelLocalization::getLocalizedURL($localeCode, $slug) }}" class="btn btn-primary">
{{ strtoupper($localeCode) }} version
</a>
@endif
@endforeach
</div>
@endsection

View file

@ -0,0 +1,44 @@
@foreach ($content as $key => $value)
@php
$filteredArray = collect($config)->filter(function ($item) use ($key) {
return $item['key'] === $key;
});
@endphp
@switch($filteredArray->count() ? $filteredArray->first()['type'] : 0)
@case(\App\Enums\DynamicFieldTypes::string->value())
<p>{{ $filteredArray->first()['key'] }} ({{ $filteredArray->first()['label'] }}): {{ $value }}</p>
@break
@case(\App\Enums\DynamicFieldTypes::text->value())
<p>{{ $filteredArray->first()['key'] }} ({{ $filteredArray->first()['label'] }}):</p>
{!! $value !!}
@break
@case(\App\Enums\DynamicFieldTypes::number->value())
<p>{{ $filteredArray->first()['key'] }} ({{ $filteredArray->first()['label'] }}): {{ $value }}</p>
@break
@case(\App\Enums\DynamicFieldTypes::boolean->value())
<p{{ $filteredArray->first()['key'] }} ({{ $filteredArray->first()['label'] }}): {{ $value }}</p>
@break
@case(\App\Enums\DynamicFieldTypes::url->value())
<p>{{ $filteredArray->first()['key'] }} ({{ $filteredArray->first()['label'] }}): <a href="{{ $value }}" target="_blank">{{ $value }}</a></p>
@break
@case(\App\Enums\DynamicFieldTypes::image->value())
<p>{{ $filteredArray->first()['key'] }} ({{ $filteredArray->first()['label'] }}): <a href="{{ $value }}" target="_blank">{{ $value }}</a></p>
@break
@case(\App\Enums\DynamicFieldTypes::list->value())
<p>{{ $filteredArray->first()['key'] }} ({{ $filteredArray->first()['label'] }}):</p>
@foreach ($value as $childKey => $childValue)
@php unset($childValue['order']); @endphp
@include('front.pages.dynamic-pages.switch-input', ['config' => $filteredArray->first()['children'], 'content' => $childValue])
@endforeach
@break
@default
<p>type non reconnu: {{ $value }}</p>
@endswitch
@endforeach

View file

@ -94,7 +94,21 @@ export default {
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))'
}
}
},
keyframes: {
'accordion-down': {
from: { height: 0 },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: 0 },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
}
},
safelist: [