feat: ✨ add new dynamic page list type
This commit is contained in:
parent
8c62774672
commit
bd268f0bfc
43 changed files with 1465 additions and 720 deletions
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ class StoreDynamicPageRequest extends UpdateDynamicFieldRequest
|
|||
];
|
||||
if ($this->config_id) {
|
||||
$rules = \array_merge(
|
||||
['content' => 'required|array|min:1'],
|
||||
parent::rules(),
|
||||
$rules,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ return [
|
|||
*
|
||||
*/
|
||||
|
||||
'path' => env('TELESCOPE_PATH', 'telescope'),
|
||||
'path' => env('TELESCOPE_PATH', 'admin/telescope'),
|
||||
|
||||
/**
|
||||
*--------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ return [
|
|||
'boolean' => 'Boolean',
|
||||
'url' => 'Link',
|
||||
'email' => 'E-mail',
|
||||
'image' => 'Image',
|
||||
'list' => 'Item list'
|
||||
],
|
||||
'contactFormType' => [
|
||||
'collaborator' => [
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ return [
|
|||
'boolean' => 'Booléen',
|
||||
'url' => 'Lien',
|
||||
'email' => 'E-mail',
|
||||
'image' => 'Image'
|
||||
'image' => 'Image',
|
||||
'list' => 'Liste de champs'
|
||||
],
|
||||
'contactFormType' => [
|
||||
'collaborator' => [
|
||||
|
|
|
|||
|
|
@ -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
33
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
234
resources/ts/back/Components/DynamicPages/DynamicList.tsx
Normal file
234
resources/ts/back/Components/DynamicPages/DynamicList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export default function SimpleNumber({
|
|||
|
||||
useEffect(() => {
|
||||
setInputValue(safeValue);
|
||||
setData(name, String(safeValue));
|
||||
}, [safeValue]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
47
resources/ts/back/Components/PageConfigs/AddNewFieldBtn.tsx
Normal file
47
resources/ts/back/Components/PageConfigs/AddNewFieldBtn.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
388
resources/ts/back/Components/PageConfigs/FieldGroup.tsx
Normal file
388
resources/ts/back/Components/PageConfigs/FieldGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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] ?? [] : []);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
30
resources/ts/back/types/global.d.ts
vendored
30
resources/ts/back/types/global.d.ts
vendored
|
|
@ -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 = {
|
||||
|
|
|
|||
66
resources/ts/components/ui/accordion.tsx
Normal file
66
resources/ts/components/ui/accordion.tsx
Normal 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 }
|
||||
83
resources/ts/hooks/use-laravel-validation.tsx
Normal file
83
resources/ts/hooks/use-laravel-validation.tsx
Normal 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;
|
||||
};
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
32
resources/views/front/pages/dynamic-pages/default.blade.php
Normal file
32
resources/views/front/pages/dynamic-pages/default.blade.php
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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: [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue