diff --git a/app/Enums/DynamicFieldTypes.php b/app/Enums/DynamicFieldTypes.php index 10ecdec0..be4bedbf 100644 --- a/app/Enums/DynamicFieldTypes.php +++ b/app/Enums/DynamicFieldTypes.php @@ -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, + ]; } diff --git a/app/Http/Controllers/Back/DynamicPageController.php b/app/Http/Controllers/Back/DynamicPageController.php index c77dc030..f21313e0 100644 --- a/app/Http/Controllers/Back/DynamicPageController.php +++ b/app/Http/Controllers/Back/DynamicPageController.php @@ -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, diff --git a/app/Http/Requests/Back/DynamicPages/StoreDynamicPageRequest.php b/app/Http/Requests/Back/DynamicPages/StoreDynamicPageRequest.php index 1d419511..64587398 100644 --- a/app/Http/Requests/Back/DynamicPages/StoreDynamicPageRequest.php +++ b/app/Http/Requests/Back/DynamicPages/StoreDynamicPageRequest.php @@ -54,6 +54,7 @@ class StoreDynamicPageRequest extends UpdateDynamicFieldRequest ]; if ($this->config_id) { $rules = \array_merge( + ['content' => 'required|array|min:1'], parent::rules(), $rules, ); diff --git a/app/Http/Requests/Back/PageConfigs/StorePageConfigRequest.php b/app/Http/Requests/Back/PageConfigs/StorePageConfigRequest.php index d1606ca3..ba9dc583 100644 --- a/app/Http/Requests/Back/PageConfigs/StorePageConfigRequest.php +++ b/app/Http/Requests/Back/PageConfigs/StorePageConfigRequest.php @@ -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; } } diff --git a/app/Http/Requests/Back/UpdateDynamicFieldRequest.php b/app/Http/Requests/Back/UpdateDynamicFieldRequest.php index 5709d288..56e8b934 100644 --- a/app/Http/Requests/Back/UpdateDynamicFieldRequest.php +++ b/app/Http/Requests/Back/UpdateDynamicFieldRequest.php @@ -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; } diff --git a/app/Models/DynamicPage.php b/app/Models/DynamicPage.php index 1c18502b..d6b7e12e 100644 --- a/app/Models/DynamicPage.php +++ b/app/Models/DynamicPage.php @@ -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); }); } diff --git a/app/Models/PageConfig.php b/app/Models/PageConfig.php index 29bec220..096935c1 100644 --- a/app/Models/PageConfig.php +++ b/app/Models/PageConfig.php @@ -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. diff --git a/app/Providers/DynamicPageRouteServiceProvider.php b/app/Providers/DynamicPageRouteServiceProvider.php index 6a5f2f3b..ed11a972 100644 --- a/app/Providers/DynamicPageRouteServiceProvider.php +++ b/app/Providers/DynamicPageRouteServiceProvider.php @@ -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"); }); }); } diff --git a/config/telescope.php b/config/telescope.php index 9433750f..3d4c080f 100644 --- a/config/telescope.php +++ b/config/telescope.php @@ -42,7 +42,7 @@ return [ * */ - 'path' => env('TELESCOPE_PATH', 'telescope'), + 'path' => env('TELESCOPE_PATH', 'admin/telescope'), /** *-------------------------------------------------------------------------- diff --git a/lang/en/back.php b/lang/en/back.php index e152477f..f4797733 100644 --- a/lang/en/back.php +++ b/lang/en/back.php @@ -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 diff --git a/lang/en/enums.php b/lang/en/enums.php index a8223b00..8c8c63a4 100644 --- a/lang/en/enums.php +++ b/lang/en/enums.php @@ -18,6 +18,8 @@ return [ 'boolean' => 'Boolean', 'url' => 'Link', 'email' => 'E-mail', + 'image' => 'Image', + 'list' => 'Item list' ], 'contactFormType' => [ 'collaborator' => [ diff --git a/lang/en/validation.php b/lang/en/validation.php index c332667c..b95db73c 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -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" diff --git a/lang/fr/back.php b/lang/fr/back.php index 97c612ad..4726ebd7 100644 --- a/lang/fr/back.php +++ b/lang/fr/back.php @@ -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 diff --git a/lang/fr/enums.php b/lang/fr/enums.php index 728f522d..b247ba8d 100644 --- a/lang/fr/enums.php +++ b/lang/fr/enums.php @@ -18,7 +18,8 @@ return [ 'boolean' => 'Booléen', 'url' => 'Lien', 'email' => 'E-mail', - 'image' => 'Image' + 'image' => 'Image', + 'list' => 'Liste de champs' ], 'contactFormType' => [ 'collaborator' => [ diff --git a/lang/fr/validation.php b/lang/fr/validation.php index 94dfce5d..b427c87b 100644 --- a/lang/fr/validation.php +++ b/lang/fr/validation.php @@ -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" diff --git a/package-lock.json b/package-lock.json index 1d1bb9d4..022e5266 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ab4990c4..21e9b2fa 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/resources/ts/back/Components/DynamicInputs.tsx b/resources/ts/back/Components/DynamicPages/DynamicInputs.tsx similarity index 72% rename from resources/ts/back/Components/DynamicInputs.tsx rename to resources/ts/back/Components/DynamicPages/DynamicInputs.tsx index b8acb66c..471f0be9 100644 --- a/resources/ts/back/Components/DynamicInputs.tsx +++ b/resources/ts/back/Components/DynamicPages/DynamicInputs.tsx @@ -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 | 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 ( -
+
{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 ( ); + // List input. + case enumDynamicFieldTypes.list.value: + return ( + + ); default: return (

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 ( +

+ Des enfants sont nécessaires pour ce type {enumLabel} +

+ ); + } + + // 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[] => { + 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 ( +
+ + {value.length > 0 && ( + + { + 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); + }} + > + item.id)} + strategy={verticalListSortingStrategy} + > + {sortedListItems.map((item, itemIndex) => ( + { + 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); + }} + /> + ))} + + + + )} + { + 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); + }} /> +
+ ) +} + +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 ( +
+ + + +
+
+
+ {/* Drag handle */} + } + tooltipContent={transDragAndDrop} + className="cursor-grab h-full w-full" + type="button" + variant="outline" + size="icon" + {...listeners} + {...attributes} + /> +
+ } + tooltipContent={transDelete} + type="button" + variant="destructive" + size="icon" + onClick={() => removeField(parentName, field.key, itemIndex)} + /> +
+
+ + Élément n°{itemIndex + 1} + +
+
+ + + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/resources/ts/back/Components/DynamicStatusBadge.tsx b/resources/ts/back/Components/DynamicPages/DynamicStatusBadge.tsx similarity index 100% rename from resources/ts/back/Components/DynamicStatusBadge.tsx rename to resources/ts/back/Components/DynamicPages/DynamicStatusBadge.tsx diff --git a/resources/ts/back/Components/Inputs/EnumSelect.tsx b/resources/ts/back/Components/Inputs/EnumSelect.tsx index e5cbaed1..545d9a8b 100644 --- a/resources/ts/back/Components/Inputs/EnumSelect.tsx +++ b/resources/ts/back/Components/Inputs/EnumSelect.tsx @@ -22,6 +22,7 @@ type EnumSelectProps = { required?: boolean; disabled?: boolean; selected?: string; + parentErrors?: Record, // 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] }, )} > @@ -72,7 +75,7 @@ export default function EnumSelect({ ))} - +
); } diff --git a/resources/ts/back/Components/Inputs/ImageCropper.tsx b/resources/ts/back/Components/Inputs/ImageCropper.tsx index 5a9b2c1d..5eaa1cc8 100644 --- a/resources/ts/back/Components/Inputs/ImageCropper.tsx +++ b/resources/ts/back/Components/Inputs/ImageCropper.tsx @@ -99,7 +99,7 @@ export default function ImageCropper(props: ImageCropperProps) { ); const [isDialogOpen, setDialogOpen] = useState(false) - const [accept, setAccept] = useState({ + const [accept, setAccept] = useState>({ "image/*": [], }); const [extensions, setExtensions] = useState(); @@ -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(() => { diff --git a/resources/ts/back/Components/Inputs/SimpleNumber.tsx b/resources/ts/back/Components/Inputs/SimpleNumber.tsx index 97bfcc14..d848c20f 100644 --- a/resources/ts/back/Components/Inputs/SimpleNumber.tsx +++ b/resources/ts/back/Components/Inputs/SimpleNumber.tsx @@ -47,6 +47,7 @@ export default function SimpleNumber({ useEffect(() => { setInputValue(safeValue); + setData(name, String(safeValue)); }, [safeValue]); return ( diff --git a/resources/ts/back/Components/Inputs/SimpleString.tsx b/resources/ts/back/Components/Inputs/SimpleString.tsx index 11b40bea..27870b8e 100644 --- a/resources/ts/back/Components/Inputs/SimpleString.tsx +++ b/resources/ts/back/Components/Inputs/SimpleString.tsx @@ -25,6 +25,7 @@ type SimpleStringProps = { disabled?: boolean; pattern?: string; checkVisibility?: boolean; + parentErrors?: Record, // 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(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({ )}
- + ); } diff --git a/resources/ts/back/Components/Inputs/SimpleWysiwyg.tsx b/resources/ts/back/Components/Inputs/SimpleWysiwyg.tsx index 4ef9543c..7887d650 100644 --- a/resources/ts/back/Components/Inputs/SimpleWysiwyg.tsx +++ b/resources/ts/back/Components/Inputs/SimpleWysiwyg.tsx @@ -139,7 +139,7 @@ export default function SimpleWysiwyg({
{wordCounter && ]*>/g, "")} />} -
+
- {navGroup.links.map( - (navLink) => { + {navGroup.links.map((navLink) => { + if (navLink.show) { const sidebarButton = ( ); - return (navLink.show && ( - - {isCollapsed ? ( - - - {sidebarButton} - - -

{navLink.title}

-
-
- ) : ( - sidebarButton - )} - {navLink.model && navLink.actions?.(navLink.model) && ( - - - - - - - + {isCollapsed ? ( + + + {sidebarButton} + + +

{navLink.title}

+
+
+ ) : ( + sidebarButton + )} + {navLink.model && navLink.actions?.(navLink.model) && ( + + + - {navLink.actions(navLink.model)} -
-
- )} -
- )); - }, - )} + + + + + {navLink.actions(navLink.model)} + + + )} + + } + })}
diff --git a/resources/ts/back/Components/PageConfigs/AddNewFieldBtn.tsx b/resources/ts/back/Components/PageConfigs/AddNewFieldBtn.tsx new file mode 100644 index 00000000..9f7ecf0e --- /dev/null +++ b/resources/ts/back/Components/PageConfigs/AddNewFieldBtn.tsx @@ -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["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 ( +
+ +

+ {(array?.length ?? 0) > 0 ? transEntriesCount : transEmptyWarning} +

+
+ ) +} + +export default AddNewFieldBtn; diff --git a/resources/ts/back/Components/PageConfigs/ConfigTable.tsx b/resources/ts/back/Components/PageConfigs/ConfigTable.tsx index 99430dff..0afbf880 100644 --- a/resources/ts/back/Components/PageConfigs/ConfigTable.tsx +++ b/resources/ts/back/Components/PageConfigs/ConfigTable.tsx @@ -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]) => ( + <> + + +
+ {depth > 0 && } + {depth > 0 && #{depth}} + {getEnumDynamicFieldType(field.type)?.label} +
+
+ + {field.label} + + ({field.key}) + + + + {field.validation} + +
+ {field.children && GetChildrenRow(field.children, depth + 1)} + + ) + ) + } + return ( @@ -40,24 +71,7 @@ export default function ConfigTable({ config }: ConfigTableProps) { - {Object.entries(config).map( - ([id, field]) => ( - - - {getEnumDynamicFieldType(field.type)?.label} - - - {field.label} - - ({field.key}) - - - - {field.validation} - - - ), - )} + {GetChildrenRow(config)}
); diff --git a/resources/ts/back/Components/PageConfigs/DynamicFields.tsx b/resources/ts/back/Components/PageConfigs/DynamicFields.tsx index c74327b8..39b34aa6 100644 --- a/resources/ts/back/Components/PageConfigs/DynamicFields.tsx +++ b/resources/ts/back/Components/PageConfigs/DynamicFields.tsx @@ -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>(errors) + const enumDynamicFieldTypes = usePage().props.enumDynamicFieldTypes as DynamicFieldTypes + const [openIndex, setOpenIndex] = useState(""); - // Unique stable id for each field. - const nextId = useRef(1); + // Sensitive fields. + var [originalConfigKeys, setOriginalConfigKeys] = useState([]); + 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(() => - (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(() => 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, + updatedField: Partial, ): 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 ( -
- {sortedFields.length ? ( - + {sortedFields.length > 0 && ( + - field.id)} - strategy={verticalListSortingStrategy} + - {sortedFields.map((field, index) => ( - removeField(field.id)} - updateField={(_, updatedInput) => - updateField(field.id, updatedInput) - } - required={required} - disabled={disabled} - isSensitiveField={originalConfigKeys.includes(field.key)} - getTransInput={getTransInput} - transDelete={transDelete} - transDragAndDrop={transDragAndDrop} - /> - ))} - - - ) : ( -

- {transConfigEmptyWarning} -

+ String(field.id))} + strategy={verticalListSortingStrategy} + > + {sortedFields.map((field, index) => ( + 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} + /> + ))} + + + )} -
- -
- + +
); } diff --git a/resources/ts/back/Components/PageConfigs/FieldGroup.tsx b/resources/ts/back/Components/PageConfigs/FieldGroup.tsx new file mode 100644 index 00000000..a2967438 --- /dev/null +++ b/resources/ts/back/Components/PageConfigs/FieldGroup.tsx @@ -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) => void; + removeField: (id: number) => void; + transDelete: string; + transDragAndDrop?: string; + sensitiveField: string[]; + updateSensitiveField: (key: string) => void; + localErrors: Record; + setLocalErrors: Dispatch>>; + 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(""); + + // === 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(() => { + 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 ( +
+ + key.startsWith(`${currentFieldKey}.${currentIndex}`)), + })}> + + {/* === HEADER === */} +
+
+
+ } + tooltipContent={transDragAndDrop} + className="cursor-grab touch-none h-full w-full" + type="button" + variant="outline" + size="icon" + disable={parentAccordionState !== ""} + {...attributes} + {...listeners} + /> +
+
+ + + + + } + tooltipContent={ +
+

{str(useTrans("back.config_complete_label")).ucFirst().value()}

+

{completeKey}

+
+ } + type="button" + variant="ghost" + size="icon" + onClick={handleClick} + /> + } + tooltipContent={transDelete} + type="button" + variant="destructive" + size="icon" + onClick={() => { + if (parentAccordionState === `item-${currentId}`) { + setParentAccordionState("") + } + removeField(currentId); + }} + /> +
+
+
+ + {currentField.label.length ? currentField.label : `Champs n°${currentIndex + 1}`} + +
+
+ {/* === CONTENT === */} + +
+ updateField(currentId, { label: value })} + parentErrors={localErrors} + /> + {!sensitiveField.includes(`${parentFieldKey}.${completeKey}`) && ( + <> + { + 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} + /> + { + updatingType(value); + }} + required={currentRequired} + disabled={currentDisabled} + parentErrors={localErrors} + /> + + )} + updateField(currentId, { validation: value })} + required={currentRequired} + disabled={currentDisabled} + parentErrors={localErrors} + /> +
+ {/* === SOUS-CHAMPS (si type is array) === */} + {currentEnum?.isarray && ( + <> + {canAddChild ? ( + + + String(c.id))} + strategy={verticalListSortingStrategy} + > + {(currentField.children ?? []).map((child: ConfigInternalField, i: number) => ( + ) => { + 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} + /> + ))} + + + + + ) : ( +

+ Profondeur maximale atteinte ({MAX_DEPTH}) +

+ )} + + )} +
+
+
+
+
+ ); +} diff --git a/resources/ts/back/Components/PageConfigs/SortableField.tsx b/resources/ts/back/Components/PageConfigs/SortableField.tsx deleted file mode 100644 index fc46c06d..00000000 --- a/resources/ts/back/Components/PageConfigs/SortableField.tsx +++ /dev/null @@ -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) => 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 ( -
- {/* Actions */} -
- {/* Drag button */} -
- } - tooltipContent={transDragAndDrop} - className="cursor-grab h-full w-full lg:w-[40px]" - type="button" - variant="outline" - size="icon" - {...attributes} - {...listeners} - /> -
- {/* Copy & Delete group */} -
- {/* Copy key button */} - - - - - } - tooltipContent={ - <> -

- {str(useTrans("back.config_complete_label")).ucFirst().value()} -

-

- {completeKey} -

- - } - type="button" - variant="ghost" - size="icon" - onClick={handleClick} - className="lg:h-full" - /> - {/* Delete button */} - } - tooltipContent={transDelete} - type="button" - variant="destructive" - size="icon" - onClick={() => removeField(id)} - className="lg:h-full" - /> -
-
- {/* Inputs */} -
-
- {/* Input label */} - - updateField(id, { label: value }) - } - /> - {!isSensitiveField && ( - <> - {/* Input key */} - { - setEditableKey(value); - }} - /> - {/* Input type */} - { - setIsTypeChanged(true); - updateField(id, { type: Number(value) }); - }} - required={required} - disabled={disabled} - /> - - )} - {/* Input validation */} - - updateField(id, { validation: value }) - } - required={required} - disabled={disabled} - /> -
-
-
- ); -} diff --git a/resources/ts/back/Pages/DynamicPages/Form.tsx b/resources/ts/back/Pages/DynamicPages/Form.tsx index d328c993..6c0dee4c 100644 --- a/resources/ts/back/Pages/DynamicPages/Form.tsx +++ b/resources/ts/back/Pages/DynamicPages/Form.tsx @@ -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} /> )} diff --git a/resources/ts/back/Pages/DynamicPages/Index.tsx b/resources/ts/back/Pages/DynamicPages/Index.tsx index 2d2f5657..c36c58ab 100644 --- a/resources/ts/back/Pages/DynamicPages/Index.tsx +++ b/resources/ts/back/Pages/DynamicPages/Index.tsx @@ -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, diff --git a/resources/ts/back/Pages/DynamicPages/Show.tsx b/resources/ts/back/Pages/DynamicPages/Show.tsx index 009743bb..4e815587 100644 --- a/resources/ts/back/Pages/DynamicPages/Show.tsx +++ b/resources/ts/back/Pages/DynamicPages/Show.tsx @@ -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 { diff --git a/resources/ts/back/Pages/PageConfigs/Form.tsx b/resources/ts/back/Pages/PageConfigs/Form.tsx index e5f307ca..0158af00 100644 --- a/resources/ts/back/Pages/PageConfigs/Form.tsx +++ b/resources/ts/back/Pages/PageConfigs/Form.tsx @@ -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] ?? [] : []); } /** diff --git a/resources/ts/back/types/global.d.ts b/resources/ts/back/types/global.d.ts index de179d3d..637032b4 100644 --- a/resources/ts/back/types/global.d.ts +++ b/resources/ts/back/types/global.d.ts @@ -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 & { + id: number; + children?: ConfigInternalField[]; +}; // Default model, with policies. type DefaultModel = { diff --git a/resources/ts/components/ui/accordion.tsx b/resources/ts/components/ui/accordion.tsx new file mode 100644 index 00000000..4a8cca46 --- /dev/null +++ b/resources/ts/components/ui/accordion.tsx @@ -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) { + return +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/resources/ts/hooks/use-laravel-validation.tsx b/resources/ts/hooks/use-laravel-validation.tsx new file mode 100644 index 00000000..5c3da03b --- /dev/null +++ b/resources/ts/hooks/use-laravel-validation.tsx @@ -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; +}; \ No newline at end of file diff --git a/resources/ts/hooks/use-translations.tsx b/resources/ts/hooks/use-translations.tsx index 84752606..f81603ef 100644 --- a/resources/ts/hooks/use-translations.tsx +++ b/resources/ts/hooks/use-translations.tsx @@ -32,7 +32,7 @@ const useTrans = (key: string, options: UseTransOptions = {}): string => { /** * Get the value for the right key. - * @param value Record + * @param value Record * @param index number */ function recursiveFunction( diff --git a/resources/views/front/pages/dynamic-page-default.blade.php b/resources/views/front/pages/dynamic-page-default.blade.php deleted file mode 100644 index 2d1e910e..00000000 --- a/resources/views/front/pages/dynamic-page-default.blade.php +++ /dev/null @@ -1,66 +0,0 @@ -@extends('front.layouts.layout') - -@section('title', 'Rechercher sur LDC.fr') -@section('description', 'Rechercher sur LDC.fr') - -@section('content') -

{{ $dynamicPageModel->title }}

- {{ $dynamicPageModel->slug }} -

{{ $dynamicPageModel->status->label() }}

-

{{ $dynamicPageModel->isLocked }}

-
- @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()) -

string: {{ $value }}

- @break - - @case(\App\Enums\DynamicFieldTypes::text->value()) -

text:

- {!! $value !!} - @break - - @case(\App\Enums\DynamicFieldTypes::number->value()) -

number: {{ $value }}

- @break - - @case(\App\Enums\DynamicFieldTypes::boolean->value()) -

boolean: {{ $value }}

- @break - - @case(\App\Enums\DynamicFieldTypes::url->value()) -

url: {{ $value }}

- @break - - @case(\App\Enums\DynamicFieldTypes::image->value()) -

image: {{ $value }}

- @break - - @default -

type non reconnu: {{ $value }}

- @endswitch -
- @endforeach - @else - {!! $dynamicPageModel->content !!} - @endif - {{ $dynamicPageModel->created_at }} - {{ $dynamicPageModel->updated_at }} - {{ $dynamicPageModel->deleted_at }} -
- @foreach(config('laravellocalization.supportedLocales') as $localeCode => $localeData) - @php $slug = $dynamicPageModel->getTranslation('slug', $localeCode, false); @endphp - @if($slug) - - {{ strtoupper($localeCode) }} version - - @endif - @endforeach -
-@endsection diff --git a/resources/views/front/pages/dynamic-pages/default.blade.php b/resources/views/front/pages/dynamic-pages/default.blade.php new file mode 100644 index 00000000..1f77ef10 --- /dev/null +++ b/resources/views/front/pages/dynamic-pages/default.blade.php @@ -0,0 +1,32 @@ +@extends('front.layouts.layout') + +@section('title', 'Rechercher sur LDC.fr') +@section('description', 'Rechercher sur LDC.fr') + +@section('content') +

title (Titre): {{ $dynamicPageModel->title }}

+ slug (Chemin): {{ $dynamicPageModel->slug }} +

status (Statut): {{ $dynamicPageModel->status->label() }}

+

isLocked (Vérouillé): {{ $dynamicPageModel->isLocked ? "true": "false" }}

+ @if (!is_null($dynamicPageModel->config_id)) + @include('front.pages.dynamic-pages.switch-input', [ + 'config' => $dynamicPageModel->config->config, + 'content' => $dynamicPageModel->content, + ]) + @else + {!! $dynamicPageModel->content !!} + @endif +

created_at (Date de création): {{ $dynamicPageModel->created_at }}

+

updated_at (Date de mise à jour): {{ $dynamicPageModel->updated_at }}

+

deleted_at (Date de suppression): {{ $dynamicPageModel->deleted_at }}

+
+ @foreach(config('laravellocalization.supportedLocales') as $localeCode => $localeData) + @php $slug = $dynamicPageModel->getTranslation('slug', $localeCode, false); @endphp + @if($slug) + + {{ strtoupper($localeCode) }} version + + @endif + @endforeach +
+@endsection diff --git a/resources/views/front/pages/dynamic-pages/switch-input.blade.php b/resources/views/front/pages/dynamic-pages/switch-input.blade.php new file mode 100644 index 00000000..1ba75a60 --- /dev/null +++ b/resources/views/front/pages/dynamic-pages/switch-input.blade.php @@ -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()) +

{{ $filteredArray->first()['key'] }} ({{ $filteredArray->first()['label'] }}): {{ $value }}

+ @break + + @case(\App\Enums\DynamicFieldTypes::text->value()) +

{{ $filteredArray->first()['key'] }} ({{ $filteredArray->first()['label'] }}):

+ {!! $value !!} + @break + + @case(\App\Enums\DynamicFieldTypes::number->value()) +

{{ $filteredArray->first()['key'] }} ({{ $filteredArray->first()['label'] }}): {{ $value }}

+ @break + + @case(\App\Enums\DynamicFieldTypes::boolean->value()) + first()['key'] }} ({{ $filteredArray->first()['label'] }}): {{ $value }}

+ @break + + @case(\App\Enums\DynamicFieldTypes::url->value()) +

{{ $filteredArray->first()['key'] }} ({{ $filteredArray->first()['label'] }}): {{ $value }}

+ @break + + @case(\App\Enums\DynamicFieldTypes::image->value()) +

{{ $filteredArray->first()['key'] }} ({{ $filteredArray->first()['label'] }}): {{ $value }}

+ @break + + @case(\App\Enums\DynamicFieldTypes::list->value()) +

{{ $filteredArray->first()['key'] }} ({{ $filteredArray->first()['label'] }}):

+ @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 +

type non reconnu: {{ $value }}

+ @endswitch +@endforeach \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js index 8f804310..0ff1b63d 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -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: [