chore: ✨ install laravel-dynamic-pages
This commit is contained in:
parent
76eebc3afb
commit
5eaba3c929
29 changed files with 5234 additions and 2 deletions
|
|
@ -18,7 +18,8 @@
|
|||
"tightenco/ziggy": "^2.6",
|
||||
"uwil/admin-secure-laravel": "^5.1",
|
||||
"uwil/laravel-backend": "^5.0",
|
||||
"uwil/laravel-common": "^6.0"
|
||||
"uwil/laravel-common": "^6.0",
|
||||
"uwil/laravel-dynamic-pages": "^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"driftingly/rector-laravel": "^2.4",
|
||||
|
|
|
|||
71
composer.lock
generated
71
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "82c82abc3d4c45ef80cf9a29ccd07f64",
|
||||
"content-hash": "61d5a52d2eb6b7cf6f13257b4203446b",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
|
|
@ -7758,6 +7758,75 @@
|
|||
"relative": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "uwil/laravel-dynamic-pages",
|
||||
"version": "2.0.0",
|
||||
"dist": {
|
||||
"type": "path",
|
||||
"url": "packages/laravel-dynamic-pages",
|
||||
"reference": "a7dfaf1ccaa42d33e00d265efa436afa8c8791eb"
|
||||
},
|
||||
"require": {
|
||||
"illuminate/support": "~13",
|
||||
"laravel/framework": "~13",
|
||||
"php": "^8.5",
|
||||
"uwil/laravel-backend": "^5.0",
|
||||
"uwil/laravel-common": "^6.0",
|
||||
"uwil/laravel-i18n": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"driftingly/rector-laravel": "^2.3",
|
||||
"larastan/larastan": "^3.9",
|
||||
"laravel/pint": "^1.29",
|
||||
"orchestra/testbench": "^11.0",
|
||||
"slevomat/coding-standard": "^8.3",
|
||||
"squizlabs/php_codesniffer": "^3.7"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Uwil\\LaravelDynamicPages\\DynamicPagesServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Uwil\\LaravelDynamicPages\\": "src/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"post-autoload-dump": [
|
||||
"@php ./vendor/bin/testbench package:discover --ansi"
|
||||
],
|
||||
"phpcs": [
|
||||
"./vendor/bin/phpcs"
|
||||
],
|
||||
"phpstan": [
|
||||
"./vendor/bin/phpstan"
|
||||
],
|
||||
"rector": [
|
||||
"./vendor/bin/rector"
|
||||
]
|
||||
},
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "UWIL Laravel Dynamic Pages",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"pages",
|
||||
"uwil"
|
||||
],
|
||||
"support": {
|
||||
"email": "digital@ldc.fr",
|
||||
"issues": "https://lab.s-web.fr/ldc-web/projets/laravel-dynamic-pages/-/issues"
|
||||
},
|
||||
"transport-options": {
|
||||
"symlink": true,
|
||||
"relative": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "uwil/laravel-i18n",
|
||||
"version": "3.0.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
// * Packages
|
||||
import { ReactNode } from "react";
|
||||
// * Other
|
||||
import { ConfigField } from "@/back/types/dynamic-pages";
|
||||
|
||||
/* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */
|
||||
export type DynamicInputProps = {
|
||||
fieldName: string;
|
||||
fieldValue: string | number | boolean | File | Date;
|
||||
field: ConfigField;
|
||||
setData: (
|
||||
name: string,
|
||||
value: string | number | boolean | File | object | null,
|
||||
) => void;
|
||||
errors: Record<string, string> | null | undefined;
|
||||
};
|
||||
/* eslint-enable no-unused-vars, @typescript-eslint/no-unused-vars */
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
export type InputResolver = (props: DynamicInputProps) => ReactNode;
|
||||
|
||||
export default class DynamicInputRegistry {
|
||||
private static resolvers: Map<string, InputResolver> = new Map();
|
||||
|
||||
static add(name: string, resolver: InputResolver) {
|
||||
this.resolvers.set(name, resolver);
|
||||
}
|
||||
|
||||
static get(
|
||||
targetName: string,
|
||||
fieldName: string,
|
||||
fieldValue: string | number | boolean | File | Date,
|
||||
field: ConfigField,
|
||||
/* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */
|
||||
setData: (
|
||||
name: string,
|
||||
value: string | number | boolean | File | object | null,
|
||||
) => void,
|
||||
/* eslint-enable no-unused-vars, @typescript-eslint/no-unused-vars */
|
||||
errors?: Record<string, string> | null | undefined,
|
||||
): ReactNode | null {
|
||||
const resolver = this.resolvers.get(targetName.toLowerCase());
|
||||
return resolver
|
||||
? resolver({ fieldName, fieldValue, field, setData, errors })
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
// Add all custom inputs presents in files to the registry.
|
||||
// @ts-expect-error Glob error.
|
||||
const modules = import.meta.glob("./CustomInputs/*.tsx", {
|
||||
eager: true,
|
||||
}) as Record<string, { default: InputResolver }>;
|
||||
|
||||
Object.entries(modules).forEach(([path, module]) => {
|
||||
const name = path
|
||||
.replace("./CustomInputs/", "")
|
||||
.replace(".tsx", "")
|
||||
.toLowerCase();
|
||||
DynamicInputRegistry.add(name, module.default);
|
||||
});
|
||||
351
resources/ts/back/Components/DynamicPages/DynamicInputs.tsx
Normal file
351
resources/ts/back/Components/DynamicPages/DynamicInputs.tsx
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
// * Packages
|
||||
import { usePage } from "@inertiajs/react";
|
||||
import { Fragment } from "react/jsx-runtime";
|
||||
// * Other
|
||||
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/Wysiwyg/SimpleWysiwyg";
|
||||
import CompleteInputFile from "@/back/Components/Inputs/CompleteInputFile";
|
||||
import DynamicList from "@/back/Components/DynamicPages/DynamicList";
|
||||
import SimpleTextarea from "@/back/Components/Inputs/SimpleTextarea";
|
||||
import SimpleDatePicker from "@/back/Components/Inputs/SimpleDatePicker";
|
||||
import SimpleColorPicker from "@/back/Components/Inputs/SimpleColorPicker";
|
||||
import SimpleYoutubeId from "@/back/Components/Inputs/SimpleYoutubeId";
|
||||
import SimpleVimeoId from "@/back/Components/Inputs/SimpleVimeoId";
|
||||
import StaticRouteSelect from "@/back/Components/Inputs/StaticRouteSelect";
|
||||
import DynamicWrapperModelSelect from "@/back/Components/DynamicPages/DynamicWrapperModelSelect";
|
||||
import DynamicInputRegistry from "@/back/Components/DynamicPages/DynamicInputRegistry";
|
||||
import {
|
||||
ConfigField,
|
||||
DynamicFieldType,
|
||||
DynamicInternalField,
|
||||
} from "@/back/types/dynamic-pages";
|
||||
import str from "@/hooks/use-string";
|
||||
import useTrans from "@/hooks/use-translations";
|
||||
import {
|
||||
extractLaravelValidationValue,
|
||||
extractMinMaxFromValidation,
|
||||
extractRequiredValidation,
|
||||
} from "@/hooks/use-laravel-validation";
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
type DynamicInputsProps = {
|
||||
name: string;
|
||||
config: ConfigField[];
|
||||
content: DynamicInternalField | null;
|
||||
isChildren?: boolean;
|
||||
setData: (
|
||||
name: string,
|
||||
value: string | number | boolean | File | object | null,
|
||||
) => void;
|
||||
reset: (name?: string) => void;
|
||||
parentErrors?: Record<string, string> | null;
|
||||
};
|
||||
/* eslint-enable no-unused-vars */
|
||||
|
||||
// Allows to get specific input from enum value.
|
||||
export default function DynamicInputs({
|
||||
name,
|
||||
config,
|
||||
content = null,
|
||||
isChildren = false,
|
||||
setData,
|
||||
reset,
|
||||
parentErrors,
|
||||
}: DynamicInputsProps) {
|
||||
// Enums.
|
||||
const enumDynamicFieldTypes = usePage().props
|
||||
.enumDynamicFieldTypes as DynamicFieldType;
|
||||
|
||||
// Translations.
|
||||
const transAssociateTo = useTrans("uwil-backend::back.form_associate_to");
|
||||
const transUnsupportedType = useTrans(
|
||||
"uwil-dynamic-pages::trans.unsupported_type",
|
||||
);
|
||||
|
||||
/**
|
||||
* Get a translation for a specific field label.
|
||||
* Need to recreate useTrans hook to avoid hook error.
|
||||
* @param fieldLabel string
|
||||
* @returns string
|
||||
*/
|
||||
const getTrans = (fieldLabel: string): string => {
|
||||
return str(
|
||||
`${transAssociateTo.split("|")[0].replace(":attribute", fieldLabel)}`,
|
||||
)
|
||||
.ucFirst()
|
||||
.value();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 items-start gap-4">
|
||||
{/* Loop on all config elements. */}
|
||||
{Object.entries(config).map(([id, field]) => {
|
||||
// Get the enum value from the element type value.
|
||||
const typeEnum = Object.values(enumDynamicFieldTypes).find(
|
||||
(def) => def.value === field.type,
|
||||
);
|
||||
|
||||
// Set the name of the current field.
|
||||
const fieldName = `${name}.${field.key}`;
|
||||
|
||||
// Set the content value from the config key.
|
||||
const fieldValue = content?.[field.key as keyof typeof content] ?? "";
|
||||
|
||||
switch (field.type) {
|
||||
// String input.
|
||||
case enumDynamicFieldTypes.string.value:
|
||||
return (
|
||||
<SimpleString
|
||||
key={id}
|
||||
name={fieldName}
|
||||
label={getTrans(field.label)}
|
||||
required={extractRequiredValidation(field.validation)}
|
||||
value={String(fieldValue)}
|
||||
setData={setData}
|
||||
parentErrors={parentErrors}
|
||||
/>
|
||||
);
|
||||
// Textarea input.
|
||||
case enumDynamicFieldTypes.textarea.value:
|
||||
return (
|
||||
<SimpleTextarea
|
||||
key={id}
|
||||
name={fieldName}
|
||||
label={getTrans(field.label)}
|
||||
required={extractRequiredValidation(field.validation)}
|
||||
value={String(fieldValue)}
|
||||
setData={setData}
|
||||
parentErrors={parentErrors}
|
||||
/>
|
||||
);
|
||||
// Wysiwyg input.
|
||||
case enumDynamicFieldTypes.wysiwyg.value:
|
||||
return (
|
||||
<div key={id} className="col-span-1 md:col-span-2">
|
||||
<SimpleWysiwyg
|
||||
name={fieldName}
|
||||
tableName="dynamic_pages"
|
||||
required={extractRequiredValidation(field.validation)}
|
||||
label={getTrans(field.label)}
|
||||
value={String(fieldValue)}
|
||||
setData={setData}
|
||||
parentErrors={parentErrors}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
// Numbers input.
|
||||
case enumDynamicFieldTypes.number.value:
|
||||
return (
|
||||
<SimpleNumber
|
||||
key={id}
|
||||
name={fieldName}
|
||||
label={getTrans(field.label)}
|
||||
limits={extractMinMaxFromValidation(field.validation)}
|
||||
required={extractRequiredValidation(field.validation)}
|
||||
value={Number(fieldValue)}
|
||||
setData={setData}
|
||||
/>
|
||||
);
|
||||
// Switch input.
|
||||
case enumDynamicFieldTypes.boolean.value:
|
||||
return (
|
||||
<div key={id} className="grid grid-cols-1 gap-1.5">
|
||||
<SimpleSwitch
|
||||
label={`${getTrans(field.label)} ${extractRequiredValidation(field.validation) ? " *" : ""}`}
|
||||
name={fieldName}
|
||||
checked={Boolean(fieldValue)}
|
||||
setData={setData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
// Url input.
|
||||
case enumDynamicFieldTypes.url.value:
|
||||
return (
|
||||
<SimpleString
|
||||
key={id}
|
||||
name={fieldName}
|
||||
label={getTrans(field.label)}
|
||||
type="url"
|
||||
placeholder="ex: https://..."
|
||||
required={extractRequiredValidation(field.validation)}
|
||||
value={String(fieldValue)}
|
||||
setData={setData}
|
||||
/>
|
||||
);
|
||||
// Image input.
|
||||
case enumDynamicFieldTypes.image.value:
|
||||
return (
|
||||
<CompleteInputFile
|
||||
key={id}
|
||||
fileName="fichier"
|
||||
name={fieldName}
|
||||
label={getTrans(field.label)}
|
||||
setData={setData}
|
||||
reset={reset}
|
||||
value={fieldValue as string | File | null}
|
||||
required={extractRequiredValidation(field.validation)}
|
||||
maxFileSize={extractLaravelValidationValue(
|
||||
field.validation,
|
||||
"max",
|
||||
)}
|
||||
allowedFormats={extractLaravelValidationValue(
|
||||
field.validation,
|
||||
"mimes",
|
||||
)}
|
||||
validationRules={{
|
||||
width: extractLaravelValidationValue(
|
||||
field.validation,
|
||||
"dimensions",
|
||||
"width",
|
||||
),
|
||||
height: extractLaravelValidationValue(
|
||||
field.validation,
|
||||
"dimensions",
|
||||
"height",
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
// Email input.
|
||||
case enumDynamicFieldTypes.email.value:
|
||||
return (
|
||||
<SimpleString
|
||||
key={id}
|
||||
name={fieldName}
|
||||
label={getTrans(field.label)}
|
||||
type="email"
|
||||
placeholder="ex: john.doe@ldc.fr"
|
||||
required={extractRequiredValidation(field.validation)}
|
||||
value={String(fieldValue)}
|
||||
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}
|
||||
isChildren={isChildren}
|
||||
/>
|
||||
);
|
||||
// File input.
|
||||
case enumDynamicFieldTypes.file.value:
|
||||
return (
|
||||
<CompleteInputFile
|
||||
key={id}
|
||||
name={fieldName}
|
||||
label={getTrans(field.label)}
|
||||
setData={setData}
|
||||
reset={reset}
|
||||
value={fieldValue as string | File | null}
|
||||
required={extractRequiredValidation(field.validation)}
|
||||
maxFileSize={extractLaravelValidationValue(
|
||||
field.validation,
|
||||
"max",
|
||||
)}
|
||||
allowedFormats={extractLaravelValidationValue(
|
||||
field.validation,
|
||||
"mimes",
|
||||
)}
|
||||
inputType="file"
|
||||
/>
|
||||
);
|
||||
// VimeoId input
|
||||
case enumDynamicFieldTypes.vimeoid.value:
|
||||
return (
|
||||
<SimpleVimeoId
|
||||
name={fieldName}
|
||||
setData={setData}
|
||||
value={String(fieldValue)}
|
||||
required={extractRequiredValidation(field.validation)}
|
||||
/>
|
||||
);
|
||||
// YoutubeId input
|
||||
case enumDynamicFieldTypes.youtubeid.value:
|
||||
return (
|
||||
<SimpleYoutubeId
|
||||
name={fieldName}
|
||||
setData={setData}
|
||||
value={String(fieldValue)}
|
||||
required={extractRequiredValidation(field.validation)}
|
||||
/>
|
||||
);
|
||||
// Date picker.
|
||||
case enumDynamicFieldTypes.date.value:
|
||||
return (
|
||||
<SimpleDatePicker
|
||||
name={fieldName}
|
||||
setData={setData}
|
||||
date={fieldValue as Date}
|
||||
hasTime
|
||||
label={getTrans(field.label)}
|
||||
/>
|
||||
);
|
||||
// Color picker.
|
||||
case enumDynamicFieldTypes.color.value:
|
||||
return (
|
||||
<SimpleColorPicker
|
||||
name={fieldName}
|
||||
setData={setData}
|
||||
value={String(fieldValue)}
|
||||
label={getTrans(field.label)}
|
||||
required={extractRequiredValidation(field.validation)}
|
||||
/>
|
||||
);
|
||||
// Model select.
|
||||
case enumDynamicFieldTypes.model.value:
|
||||
return (
|
||||
<DynamicWrapperModelSelect
|
||||
field={field}
|
||||
fieldName={fieldName}
|
||||
fieldValue={fieldValue}
|
||||
setData={setData}
|
||||
getTrans={getTrans}
|
||||
/>
|
||||
);
|
||||
// Static route select.
|
||||
case enumDynamicFieldTypes.staticroute.value:
|
||||
return (
|
||||
<StaticRouteSelect
|
||||
name={fieldName}
|
||||
label={getTrans(field.label)}
|
||||
selected={fieldValue as number}
|
||||
setData={setData}
|
||||
required={extractRequiredValidation(field.validation)}
|
||||
/>
|
||||
);
|
||||
default: {
|
||||
// Check in custom types.
|
||||
const customInput = DynamicInputRegistry.get(
|
||||
typeEnum?.name ?? "type",
|
||||
fieldName,
|
||||
fieldValue,
|
||||
field,
|
||||
setData,
|
||||
parentErrors,
|
||||
);
|
||||
if (customInput) {
|
||||
return <Fragment key={id}>{customInput}</Fragment>;
|
||||
}
|
||||
// Show message if no type is matching.
|
||||
return (
|
||||
<p key={id} className="text-muted-foreground text-sm">
|
||||
{transUnsupportedType} {typeEnum?.label ?? field.type}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
380
resources/ts/back/Components/DynamicPages/DynamicList.tsx
Normal file
380
resources/ts/back/Components/DynamicPages/DynamicList.tsx
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
// * Packages
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { usePage } from "@inertiajs/react";
|
||||
import { useState } from "react";
|
||||
import { GripVerticalIcon, TrashIcon } from "lucide-react";
|
||||
// * 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 AddNewFieldBtn from "@/back/Components/PageConfigs/AddNewFieldBtn";
|
||||
import DynamicInputs from "@/back/Components/DynamicPages/DynamicInputs";
|
||||
import useTrans from "@/hooks/use-translations";
|
||||
import {
|
||||
extractMinMaxFromValidation,
|
||||
extractRequiredValidation,
|
||||
} from "@/hooks/use-laravel-validation";
|
||||
import { ConfigField, DynamicInternalField } from "@/back/types/dynamic-pages";
|
||||
import { cn, GetTranslation } from "@/lib/utils";
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
type SortableItemProps = {
|
||||
id: number;
|
||||
item: DynamicInternalField | null;
|
||||
itemIndex: number;
|
||||
name: string;
|
||||
parentName: string;
|
||||
field: ConfigField;
|
||||
setData: (
|
||||
name: string,
|
||||
value: string | boolean | File | object | null,
|
||||
) => void;
|
||||
reset: (name?: string) => void;
|
||||
removeField: (parentName: string, fieldKey: string, index: number) => void;
|
||||
parentAccordionState: string;
|
||||
setParentAccordionState: (key: string) => void;
|
||||
localErrors: Record<string, string>;
|
||||
};
|
||||
/* eslint-enable no-unused-vars */
|
||||
|
||||
// Recursive component.
|
||||
function SortableItem({
|
||||
id,
|
||||
item,
|
||||
itemIndex,
|
||||
name,
|
||||
parentName,
|
||||
field,
|
||||
setData,
|
||||
reset,
|
||||
removeField,
|
||||
parentAccordionState,
|
||||
setParentAccordionState,
|
||||
localErrors,
|
||||
}: SortableItemProps) {
|
||||
// Provides the necessary props and helpers to make this item draggable and reorderable.
|
||||
// Includes event listeners, accessibility attributes, DOM ref, drag transform/transition values,
|
||||
// and a flag indicating whether the item is dragging.
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
// Style applied to the sortable item:
|
||||
// - handles drag movement (transform),
|
||||
// - animations (transition),
|
||||
// - and visual feedback (opacity, zIndex) while dragging.
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
zIndex: isDragging ? 50 : 0,
|
||||
};
|
||||
|
||||
// Translations.
|
||||
const transDragAndDrop = useTrans("uwil-dynamic-pages::trans.dnd_field");
|
||||
const transDelete = useTrans("uwil-dynamic-pages::trans.delete_field");
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="relative mb-3">
|
||||
<AccordionItem value={`item-${itemIndex}`}>
|
||||
<Card
|
||||
className={cn({
|
||||
"border-destructive/50 dark:border-destructive":
|
||||
parentAccordionState !== `item-${itemIndex}` &&
|
||||
Object.keys(localErrors).some((key) =>
|
||||
key.startsWith(`${name}.${itemIndex}`),
|
||||
),
|
||||
})}
|
||||
>
|
||||
<CardContent className="flex flex-col p-3">
|
||||
<div className="flex flex-col lg:flex-row-reverse gap-1.5">
|
||||
<div className="flex flex-row gap-1.5 w-full h-full">
|
||||
{/* Define a drag-and-drop button to swap the order between the current element and another one. */}
|
||||
<div className="flex-grow flex items-stretch">
|
||||
<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"
|
||||
disabled={parentAccordionState !== ""}
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
/* eslint-enable react/jsx-props-no-spreading */
|
||||
/>
|
||||
</div>
|
||||
{/* Define a delete button to destroy the current element. */}
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
content={<TrashIcon className="w-4 h-4" />}
|
||||
tooltipContent={transDelete}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
if (parentAccordionState === `item-${itemIndex}`) {
|
||||
setParentAccordionState("");
|
||||
}
|
||||
removeField(parentName, field.key, itemIndex);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Define the trigger button to open or close the current accordion. */}
|
||||
<div className="w-full lg:w-2/3 xl:w-1/2">
|
||||
<AccordionTrigger className="inline-flex items-center justify-between gap-2 whitespace-nowrap rounded-md text-sm font-medium bg-secondary text-secondary-foreground hover:bg-secondary/80 h-10 px-4 py-2 w-full">
|
||||
Élément n°{itemIndex + 1}
|
||||
</AccordionTrigger>
|
||||
</div>
|
||||
</div>
|
||||
<AccordionContent className="pt-4 pb-1 px-1">
|
||||
{/* Get specific input from the config value. */}
|
||||
{field.children?.length && (
|
||||
<DynamicInputs
|
||||
name={`${name}.${itemIndex}`}
|
||||
config={field.children}
|
||||
content={item}
|
||||
setData={setData}
|
||||
reset={reset}
|
||||
parentErrors={localErrors}
|
||||
isChildren
|
||||
/>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
type DynamicListProps = {
|
||||
name: string;
|
||||
parentName: string;
|
||||
value: DynamicInternalField[];
|
||||
enumLabel: string;
|
||||
field: ConfigField;
|
||||
content: DynamicInternalField | null;
|
||||
isChildren: boolean;
|
||||
setData: (
|
||||
name: string,
|
||||
value: string | boolean | File | object | null,
|
||||
) => void;
|
||||
reset: (name?: string) => void;
|
||||
};
|
||||
/* eslint-enable no-unused-vars */
|
||||
|
||||
// Allows to get recursive list inputs.
|
||||
export default function DynamicList({
|
||||
name,
|
||||
parentName,
|
||||
value,
|
||||
enumLabel,
|
||||
field,
|
||||
content,
|
||||
isChildren,
|
||||
setData,
|
||||
reset,
|
||||
}: DynamicListProps) {
|
||||
// Errors.
|
||||
const { errors } = usePage().props;
|
||||
const [localErrors, setLocalErrors] =
|
||||
useState<Record<string, string>>(errors);
|
||||
|
||||
// Drag & drop module for user interactions (mouse, touch, keyboard or other).
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
// Define a local identifier for each element to enable unique identification during drag and drop.
|
||||
const localValue = value.map((elmt, index) => {
|
||||
return { ...elmt, id: elmt.id ?? index + 1 };
|
||||
});
|
||||
|
||||
// Order all elements depending on order values.
|
||||
const sortedListItems = [...localValue].sort((a, b) => a.order - b.order);
|
||||
|
||||
// Extract some values from laravel rules validation.
|
||||
const isRequired = extractRequiredValidation(field.validation);
|
||||
const configLimitMax = extractMinMaxFromValidation(field.validation).max ?? 1;
|
||||
|
||||
// Accordion open index.
|
||||
const [openIndex, setOpenIndex] = useState<string>("");
|
||||
|
||||
// Notice user there must be children config.
|
||||
if (!field.children && field.children!.length > 0) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{GetTranslation("uwil-backend::back.list_children_needed", {
|
||||
params: { enumLabel },
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific element from the list.
|
||||
* @param currentParentName string
|
||||
* @param fieldKey string
|
||||
* @param index number
|
||||
* @return void
|
||||
*/
|
||||
const handleRemoveField = (
|
||||
currentParentName: string,
|
||||
fieldKey: string,
|
||||
index: number,
|
||||
) => {
|
||||
// Get the current list of elements.
|
||||
const list = Array.isArray(content?.[fieldKey as keyof typeof content])
|
||||
? // @ts-expect-error Weird error type.
|
||||
[...content[fieldKey as keyof typeof content]]
|
||||
: [];
|
||||
// Remove the targeted element.
|
||||
list.splice(index, 1);
|
||||
// Save the new list updated.
|
||||
setData(`${currentParentName}.${fieldKey}`, list);
|
||||
// Reset all errors because of alter elements list.
|
||||
setLocalErrors({});
|
||||
};
|
||||
|
||||
/**
|
||||
* Reorder the elements list.
|
||||
* @param event DragEndEvent
|
||||
* @return void
|
||||
*/
|
||||
const handleOnDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
// Check the unique identification value.
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
// Get the two elements that are switchable by drag and drop.
|
||||
const oldIndex = sortedListItems.findIndex((i) => i.id === active.id);
|
||||
const newIndex = sortedListItems.findIndex((i) => i.id === over.id);
|
||||
|
||||
// Reorder all elements list to update visually all elements.
|
||||
const reorderedList = arrayMove(sortedListItems, oldIndex, newIndex).map(
|
||||
(item, index) => ({
|
||||
...item,
|
||||
order: index + 1,
|
||||
}),
|
||||
);
|
||||
|
||||
// Save the new ordered list elements.
|
||||
setData(`${parentName}.${field.key}`, reorderedList);
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a new field to the list.
|
||||
* @return void
|
||||
*/
|
||||
const handleAddNewField = () => {
|
||||
// Set new element with unique identification and order.
|
||||
const newItem = Object.fromEntries(
|
||||
(field.children ?? []).map((child) => [child.key, ""]),
|
||||
);
|
||||
const maxOrder =
|
||||
Math.max(
|
||||
0,
|
||||
...sortedListItems.map((currentField) => currentField.order ?? 0),
|
||||
) + 1;
|
||||
const maxIndex =
|
||||
Math.max(
|
||||
0,
|
||||
...sortedListItems.map((currentField) => currentField.id ?? 0),
|
||||
) + 1;
|
||||
const newIndex = value.length;
|
||||
const newItemWithOrder = { ...newItem, id: maxIndex, order: maxOrder };
|
||||
|
||||
// Save the list with the new element.
|
||||
setData(`${parentName}.${field.key}.${newIndex}`, newItemWithOrder);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<Label htmlFor={parentName}>
|
||||
{field.label + (isRequired ? " *" : "")}
|
||||
</Label>
|
||||
{value.length > 0 && (
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="w-full"
|
||||
value={openIndex}
|
||||
onValueChange={setOpenIndex}
|
||||
>
|
||||
{/*
|
||||
The main provider for dnd-kit.
|
||||
It manages the entire drag-and-drop system: sensors, movement, collision detection, and lifecycle callbacks (onDragStart, onDragEnd, etc.).
|
||||
*/}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleOnDragEnd}
|
||||
>
|
||||
{/*
|
||||
Defines a reorderable list area.
|
||||
It registers sortable items and applies the chosen sorting strategy (vertical, horizontal, grid) to handle item repositioning.
|
||||
*/}
|
||||
<SortableContext
|
||||
items={sortedListItems.map((item) => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{/* Use the recursive component for each elements. */}
|
||||
{sortedListItems.map((item, itemIndex) => (
|
||||
<SortableItem
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
item={item}
|
||||
itemIndex={itemIndex}
|
||||
name={name}
|
||||
parentName={parentName}
|
||||
field={field}
|
||||
setData={setData}
|
||||
reset={reset}
|
||||
removeField={(currentParentName, fieldKey, index) =>
|
||||
handleRemoveField(currentParentName, fieldKey, index)
|
||||
}
|
||||
parentAccordionState={openIndex}
|
||||
setParentAccordionState={setOpenIndex}
|
||||
localErrors={localErrors}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</Accordion>
|
||||
)}
|
||||
<AddNewFieldBtn
|
||||
array={sortedListItems}
|
||||
limitMax={configLimitMax}
|
||||
onClick={handleAddNewField}
|
||||
variant={isChildren ? "secondary" : "default"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
resources/ts/back/Components/DynamicPages/DynamicPageDetails.tsx
Normal file
157
resources/ts/back/Components/DynamicPages/DynamicPageDetails.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
// * Packages
|
||||
import { ReactNode } from "react";
|
||||
// * Components shadcn
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
// * Other
|
||||
import str from "@/hooks/use-string";
|
||||
import useTrans from "@/hooks/use-translations";
|
||||
import { ConfigField } from "@/back/types/dynamic-pages";
|
||||
import { cn, GetTranslation } from "@/lib/utils";
|
||||
|
||||
type ChildItem = Record<string, string | number | object | ChildItem[]>;
|
||||
|
||||
const RenderRows = (
|
||||
currentValue: string | number | object,
|
||||
currentConfig: ConfigField[],
|
||||
hasBorders: boolean = false,
|
||||
path: string = "",
|
||||
): ReactNode => {
|
||||
const tableNotKnownTrans = str(useTrans("uwil-backend::back.table_not_known"))
|
||||
.ucFirst()
|
||||
.value();
|
||||
|
||||
return currentConfig.map((field, index) => {
|
||||
const hasChildren = !!field.children?.length;
|
||||
const accordionKey = path
|
||||
? `${path}.${field.key}-${index}`
|
||||
: `${field.key}-${index}`;
|
||||
|
||||
const rowContent = (currentHasChildren: boolean = false) => {
|
||||
const cellClasses = "break-words whitespace-normal";
|
||||
const placeholderClasses =
|
||||
"text-sm text-muted-foreground break-words whitespace-normal";
|
||||
const fieldValue = currentValue[field.key as keyof typeof currentValue];
|
||||
|
||||
let cellContent: ReactNode;
|
||||
if (currentHasChildren) {
|
||||
const count = Array.isArray(fieldValue)
|
||||
? Object.values(fieldValue).length
|
||||
: 0;
|
||||
cellContent = (
|
||||
<p className={placeholderClasses}>
|
||||
{GetTranslation("uwil-dynamic-pages::trans.element", {
|
||||
params: { count: String(count) },
|
||||
choice: count > 1 ? 2 : 1,
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
} else if (fieldValue !== null && fieldValue !== undefined) {
|
||||
cellContent = (
|
||||
<p className={cellClasses}>
|
||||
{typeof fieldValue === "boolean"
|
||||
? GetTranslation(
|
||||
fieldValue
|
||||
? "uwil-backend::back.yes"
|
||||
: "uwil-backend::back.no",
|
||||
)
|
||||
: fieldValue}
|
||||
</p>
|
||||
);
|
||||
} else {
|
||||
cellContent = (
|
||||
<p className={placeholderClasses}>{tableNotKnownTrans}</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_1fr_auto] items-center w-full gap-4">
|
||||
<p className={cellClasses}>{field.label}</p>
|
||||
{cellContent}
|
||||
{!currentHasChildren && <div className="w-[16px]" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AccordionItem
|
||||
key={accordionKey}
|
||||
value={accordionKey}
|
||||
className="border-none"
|
||||
>
|
||||
{hasChildren ? (
|
||||
<AccordionTrigger
|
||||
className={cn(
|
||||
"gap-0 px-4",
|
||||
{ "rounded-none border-b mb-2": hasBorders },
|
||||
{ "py-2": !hasBorders },
|
||||
)}
|
||||
>
|
||||
{rowContent(hasChildren)}
|
||||
</AccordionTrigger>
|
||||
) : (
|
||||
<div className="px-4 py-2">{rowContent()}</div>
|
||||
)}
|
||||
|
||||
{hasChildren &&
|
||||
Array.isArray(currentValue[field.key as keyof typeof currentValue]) &&
|
||||
Object.values(currentValue[field.key as keyof typeof currentValue])
|
||||
.length > 0 && (
|
||||
<AccordionContent className="px-4">
|
||||
<div className="space-y-1">
|
||||
{Object.values(
|
||||
currentValue[
|
||||
field.key as keyof typeof currentValue
|
||||
] as ChildItem[],
|
||||
).map((childItem: ChildItem) => (
|
||||
<div key={String(childItem.id)} className="border rounded-md">
|
||||
{RenderRows(
|
||||
childItem,
|
||||
field.children!,
|
||||
false,
|
||||
`${accordionKey}-${childItem.id}`,
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
)}
|
||||
</AccordionItem>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
type DynamicPageDetailsProps = {
|
||||
value: string | object;
|
||||
config: ConfigField[];
|
||||
};
|
||||
|
||||
export default function DynamicPageDetails({
|
||||
value,
|
||||
config,
|
||||
}: DynamicPageDetailsProps) {
|
||||
const transLabel = str(useTrans("uwil-dynamic-pages::trans.attributes.label"))
|
||||
.ucFirst()
|
||||
.value();
|
||||
const transValue = str(useTrans("uwil-dynamic-pages::trans.attributes.value"))
|
||||
.ucFirst()
|
||||
.value();
|
||||
|
||||
return (
|
||||
<Accordion type="multiple" className="w-full divide-y">
|
||||
{/* HEADER */}
|
||||
<div className="grid grid-cols-[1fr_1fr_auto] text-sm font-medium border-b gap-4 px-4 py-2">
|
||||
<div>{transLabel}</div>
|
||||
<div>{transValue}</div>
|
||||
<div className="w-[16px]" />
|
||||
</div>
|
||||
|
||||
{/* ROWS */}
|
||||
{RenderRows(value, config, true)}
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
// * Packages
|
||||
import { usePage } from "@inertiajs/react";
|
||||
import { CheckIcon, PencilLineIcon, XIcon } from "lucide-react";
|
||||
// * Components shadcn
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
// * Other
|
||||
import { LaravelEnum } from "@/types/global";
|
||||
|
||||
type DynamicStatusBadgeProps = {
|
||||
value: number;
|
||||
};
|
||||
|
||||
// Get a stylised badge for a specific enum value.
|
||||
export default function DynamicStatusBadge({ value }: DynamicStatusBadgeProps) {
|
||||
// Get all enum values, labels and names.
|
||||
const { enumDynamicPageStatus } = usePage().props as unknown as {
|
||||
enumDynamicPageStatus: LaravelEnum;
|
||||
};
|
||||
|
||||
// Set specific icon and style classes for each enum value.
|
||||
const statusStyles = {
|
||||
[enumDynamicPageStatus.draft.value]: {
|
||||
color: "bg-gray-200 dark:bg-gray-900",
|
||||
Icon: PencilLineIcon,
|
||||
},
|
||||
[enumDynamicPageStatus.unpublished.value]: {
|
||||
color: "bg-red-200 dark:bg-red-900",
|
||||
Icon: XIcon,
|
||||
},
|
||||
[enumDynamicPageStatus.published?.value ?? "default"]: {
|
||||
color: "bg-green-200 dark:bg-green-900",
|
||||
Icon: CheckIcon,
|
||||
},
|
||||
};
|
||||
|
||||
// Get the current enum value.
|
||||
const enumValue = Object.values(enumDynamicPageStatus).find(
|
||||
(status) => status.value === value,
|
||||
);
|
||||
|
||||
// Get the color and icon for the current enum value.
|
||||
const { color, Icon } =
|
||||
statusStyles[enumValue?.value as keyof typeof statusStyles];
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={`border-0 ${color}`}>
|
||||
<Icon size={15} className="mr-1" />
|
||||
{enumValue?.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
// * Packages
|
||||
import { useEffect, useRef } from "react";
|
||||
// * Other
|
||||
import { ModelSelect } from "@/back/Components/Inputs/ModelSelect";
|
||||
import {
|
||||
extractExcludedIdFromExists,
|
||||
extractRequiredValidation,
|
||||
extractTableFromExists,
|
||||
} from "@/hooks/use-laravel-validation";
|
||||
import { ConfigField } from "@/back/types/dynamic-pages";
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
type DynamicWrapperModelSelectProps = {
|
||||
field: ConfigField;
|
||||
fieldName: string;
|
||||
fieldValue: string | number | boolean | File | Date;
|
||||
setData: (
|
||||
name: string,
|
||||
value: string | number | boolean | File | object | null,
|
||||
) => void;
|
||||
getTrans: (fieldLabel: string) => string;
|
||||
};
|
||||
/* eslint-enable no-unused-vars */
|
||||
|
||||
export default function DynamicWrapperModelSelect({
|
||||
field,
|
||||
fieldName,
|
||||
fieldValue,
|
||||
setData,
|
||||
getTrans,
|
||||
}: DynamicWrapperModelSelectProps) {
|
||||
// Extract only the model name from the validation.
|
||||
const targetModel =
|
||||
extractTableFromExists(field.validation) || "dynamic_pages";
|
||||
|
||||
// Add a custom prefix in the last part of the name.
|
||||
const newName = fieldName.replace(/([^.\s]+)$/, "reserved_$1");
|
||||
|
||||
// Set a state at false.
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
// Set the model name in the custom key only once.
|
||||
useEffect(() => {
|
||||
if (initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
|
||||
// If the value isn't already present in content.
|
||||
setData(newName, targetModel);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [newName, targetModel]);
|
||||
|
||||
return (
|
||||
<ModelSelect
|
||||
name={fieldName}
|
||||
multiple={false}
|
||||
closeOnSelect
|
||||
responsive
|
||||
targetModel={targetModel}
|
||||
label={getTrans(field.label)}
|
||||
exclude={extractExcludedIdFromExists(field.validation)}
|
||||
placeholder="ex: John Doe"
|
||||
defaultValues={fieldValue ? [Number(fieldValue)] : []}
|
||||
required={extractRequiredValidation(field.validation)}
|
||||
setData={setData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
// * Packages
|
||||
import { StickyNoteIcon } from "lucide-react";
|
||||
import { route } from "ziggy-js";
|
||||
import { Link } from "@inertiajs/react";
|
||||
// * Components shadcn
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
// * Other
|
||||
import {
|
||||
filterActions,
|
||||
NavListLinksProps,
|
||||
} from "@/back/Components/Layout/Sidebar/AppSidebar";
|
||||
import { SidebarGroupLink } from "@/back/Components/Layout/Sidebar/SidebarRegistry";
|
||||
import { AuthProp, ConfigBackend } from "@/back/types/global";
|
||||
import str from "@/hooks/use-string";
|
||||
import { GetTranslation } from "@/lib/utils";
|
||||
import Views from "@/back/lib/actions";
|
||||
import useTrans from "@/hooks/use-translations";
|
||||
|
||||
export default function DynamicPages(
|
||||
auth: AuthProp,
|
||||
configBackend: ConfigBackend,
|
||||
): SidebarGroupLink {
|
||||
const modelName = "dynamic-pages";
|
||||
|
||||
// Translations.
|
||||
const transDynamicPages = GetTranslation(
|
||||
`uwil-dynamic-pages::models.classes.${modelName}`,
|
||||
{
|
||||
choice: 2,
|
||||
},
|
||||
);
|
||||
const transCreate = str(
|
||||
useTrans(`uwil-backend::crud.actions.${Views.CREATION.label}`),
|
||||
)
|
||||
.ucFirst()
|
||||
.value();
|
||||
const transArchives = str(useTrans("uwil-backend::back.archives"))
|
||||
.ucFirst()
|
||||
.value();
|
||||
|
||||
const link: NavListLinksProps = {
|
||||
title: str(transDynamicPages).ucFirst().value(),
|
||||
model: modelName,
|
||||
icon: StickyNoteIcon,
|
||||
show: auth.policies.dynamicPages.viewAny,
|
||||
actions: (model: string) =>
|
||||
filterActions({
|
||||
auth,
|
||||
configBackend,
|
||||
model,
|
||||
items: [
|
||||
auth.policies.dynamicPages.create && (
|
||||
<DropdownMenuItem key="create" className="p-0">
|
||||
<Button variant="ghost" asChild>
|
||||
<Link
|
||||
href={route(`${configBackend.routes.name}${model}.create`)}
|
||||
className="w-full"
|
||||
>
|
||||
{/* eslint-disable-next-line react/jsx-pascal-case */}
|
||||
<Views.CREATION.icon />
|
||||
{transCreate}
|
||||
</Link>
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
auth.policies.dynamicPages.viewTrash && (
|
||||
<DropdownMenuItem className="p-0">
|
||||
<Button variant="ghost" asChild>
|
||||
<Link
|
||||
href={route(`${configBackend.routes.name}${model}.trash`)}
|
||||
className="w-full"
|
||||
>
|
||||
{/* eslint-disable-next-line react/jsx-pascal-case */}
|
||||
<Views.ARCHIVATION.icon />
|
||||
{transArchives}
|
||||
</Link>
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
return { link, group: "pages" };
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
// * Packages
|
||||
import { CogIcon } from "lucide-react";
|
||||
import { route } from "ziggy-js";
|
||||
import { Link } from "@inertiajs/react";
|
||||
// * Components shadcn
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
// * Other
|
||||
import {
|
||||
filterActions,
|
||||
NavListLinksProps,
|
||||
} from "@/back/Components/Layout/Sidebar/AppSidebar";
|
||||
import { SidebarGroupLink } from "@/back/Components/Layout/Sidebar/SidebarRegistry";
|
||||
import { AuthProp, ConfigBackend } from "@/back/types/global";
|
||||
import str from "@/hooks/use-string";
|
||||
import { GetTranslation } from "@/lib/utils";
|
||||
import Views from "@/back/lib/actions";
|
||||
import useTrans from "@/hooks/use-translations";
|
||||
|
||||
export default function PageConfigs(
|
||||
auth: AuthProp,
|
||||
configBackend: ConfigBackend,
|
||||
): SidebarGroupLink {
|
||||
const modelName = "page-configs";
|
||||
|
||||
// Translations.
|
||||
const transPageConfigs = GetTranslation(
|
||||
`uwil-dynamic-pages::models.classes.${modelName}`,
|
||||
{
|
||||
choice: 2,
|
||||
},
|
||||
);
|
||||
const transCreate = str(
|
||||
useTrans(`uwil-backend::crud.actions.${Views.CREATION.label}`),
|
||||
)
|
||||
.ucFirst()
|
||||
.value();
|
||||
const transArchives = str(useTrans("uwil-backend::back.archives"))
|
||||
.ucFirst()
|
||||
.value();
|
||||
|
||||
const link: NavListLinksProps = {
|
||||
title: str(transPageConfigs).ucFirst().value(),
|
||||
model: modelName,
|
||||
icon: CogIcon,
|
||||
show: auth.policies.pageConfigs.viewAny,
|
||||
actions: (model: string) =>
|
||||
filterActions({
|
||||
auth,
|
||||
configBackend,
|
||||
model,
|
||||
items: [
|
||||
auth.policies.pageConfigs.create && (
|
||||
<DropdownMenuItem key="create" className="p-0">
|
||||
<Button variant="ghost" asChild>
|
||||
<Link
|
||||
href={route(`${configBackend.routes.name}${model}.create`)}
|
||||
className="w-full"
|
||||
>
|
||||
{/* eslint-disable-next-line react/jsx-pascal-case */}
|
||||
<Views.CREATION.icon />
|
||||
{transCreate}
|
||||
</Link>
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
auth.policies.pageConfigs.viewTrash && (
|
||||
<DropdownMenuItem className="p-0">
|
||||
<Button variant="ghost" asChild>
|
||||
<Link
|
||||
href={route(`${configBackend.routes.name}${model}.trash`)}
|
||||
className="w-full"
|
||||
>
|
||||
{/* eslint-disable-next-line react/jsx-pascal-case */}
|
||||
<Views.ARCHIVATION.icon />
|
||||
{transArchives}
|
||||
</Link>
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
return { link, group: "admin" };
|
||||
}
|
||||
70
resources/ts/back/Components/PageConfigs/AddNewFieldBtn.tsx
Normal file
70
resources/ts/back/Components/PageConfigs/AddNewFieldBtn.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// * 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/dynamic-pages";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AddNewFieldBtnProps = {
|
||||
className: string;
|
||||
array: ConfigInternalField[] | DynamicInternalField[] | undefined;
|
||||
variant?: VariantProps<typeof buttonVariants>["variant"];
|
||||
limitMax?: number | undefined;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
// Get a button with some informations.
|
||||
export default function AddNewFieldBtn({
|
||||
className = "",
|
||||
array = [],
|
||||
variant = "default",
|
||||
limitMax,
|
||||
onClick,
|
||||
}: AddNewFieldBtnProps) {
|
||||
// Translations.
|
||||
const transNoMoreChildren = useTrans(
|
||||
"uwil-dynamic-pages::trans.no_more_children",
|
||||
);
|
||||
const transNewField = useTrans("uwil-dynamic-pages::trans.new_field");
|
||||
const transEmptyWarning = useTrans("uwil-dynamic-pages::trans.empty_warning");
|
||||
const transEntriesCount = useTrans(
|
||||
"uwil-dynamic-pages::trans.entries_count",
|
||||
{
|
||||
params: {
|
||||
length: String(array?.length ?? 0),
|
||||
},
|
||||
choice: (array?.length ?? 0) > 1 ? 2 : 1,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col lg:flex-row justify-center lg:justify-between items-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Check if the limit is achieved */}
|
||||
{limitMax && array.length >= limitMax ? (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{transNoMoreChildren}
|
||||
</p>
|
||||
) : (
|
||||
<Button type="button" variant={variant} onClick={onClick}>
|
||||
<PlusIcon className="w-4 h-4 m-auto" />
|
||||
{transNewField}
|
||||
</Button>
|
||||
)}
|
||||
{/* Indicate the current number of elements. */}
|
||||
<p className="text-sm text-muted-foreground text-center my-2 xl:my-0">
|
||||
{(array?.length ?? 0) > 0 ? transEntriesCount : transEmptyWarning}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
resources/ts/back/Components/PageConfigs/ConfigDetails.tsx
Normal file
111
resources/ts/back/Components/PageConfigs/ConfigDetails.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// * Packages
|
||||
import { usePage } from "@inertiajs/react";
|
||||
import { ReactNode } from "react";
|
||||
// * Components shadcn
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
// * Other
|
||||
import str from "@/hooks/use-string";
|
||||
import useTrans from "@/hooks/use-translations";
|
||||
import { ConfigField, DynamicFieldType } from "@/back/types/dynamic-pages";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ConfigDetailsProps = {
|
||||
config: ConfigField[];
|
||||
};
|
||||
|
||||
export default function ConfigDetails({ config }: ConfigDetailsProps) {
|
||||
const enumDynamicFieldTypes = usePage().props
|
||||
.enumDynamicFieldTypes as DynamicFieldType;
|
||||
const getEnumDynamicFieldType = (value: number) => {
|
||||
return Object.values(enumDynamicFieldTypes).find(
|
||||
(def) => def.value === value,
|
||||
);
|
||||
};
|
||||
|
||||
const transType = str(useTrans("uwil-dynamic-pages::trans.attributes.type"))
|
||||
.ucFirst()
|
||||
.value();
|
||||
const transLabel = str(useTrans("uwil-dynamic-pages::trans.attributes.label"))
|
||||
.ucFirst()
|
||||
.value();
|
||||
const transValidation = str(
|
||||
useTrans("uwil-dynamic-pages::trans.attributes.validation"),
|
||||
)
|
||||
.ucFirst()
|
||||
.value();
|
||||
|
||||
const RenderRows = (
|
||||
fields: ConfigField[],
|
||||
hasBorders: boolean = false,
|
||||
): ReactNode =>
|
||||
fields.map((field, index) => {
|
||||
const hasChildren = !!field.children?.length;
|
||||
const accordionKey = `${field.key}-${index}`;
|
||||
|
||||
const rowContent = (targetHasChildren: boolean = false) => (
|
||||
<div className="grid grid-cols-[1fr_1fr_1fr_auto] items-center w-full gap-4">
|
||||
<p className="break-words">
|
||||
{getEnumDynamicFieldType(field.type)?.label}
|
||||
</p>
|
||||
<p className="break-words whitespace-normal">
|
||||
{field.label}
|
||||
<code className="text-xs ms-1 text-muted-foreground break-words break-all whitespace-normal">{`(${field.key})`}</code>
|
||||
</p>
|
||||
<div className="text-sm text-muted-foreground break-words">
|
||||
{field.validation || "—"}
|
||||
</div>
|
||||
{!targetHasChildren && <div className="w-[16px]" />}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AccordionItem
|
||||
key={accordionKey}
|
||||
value={accordionKey}
|
||||
className="border-none"
|
||||
>
|
||||
{hasChildren ? (
|
||||
<AccordionTrigger
|
||||
className={cn(
|
||||
"gap-0 px-4",
|
||||
{ "rounded-none border-b mb-2": hasBorders },
|
||||
{ "py-2": !hasBorders },
|
||||
)}
|
||||
>
|
||||
{rowContent(hasChildren)}
|
||||
</AccordionTrigger>
|
||||
) : (
|
||||
<div className="px-4 py-2">{rowContent()}</div>
|
||||
)}
|
||||
|
||||
{hasChildren && (
|
||||
<AccordionContent>
|
||||
<div className="border rounded-md space-y-1">
|
||||
{RenderRows(field.children!, false)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
)}
|
||||
</AccordionItem>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Accordion type="multiple" className="w-full divide-y">
|
||||
{/* HEADER */}
|
||||
<div className="grid grid-cols-[1fr_1fr_1fr_auto] text-sm font-medium border-b gap-4 px-4 py-2">
|
||||
<div>{transType}</div>
|
||||
<div>{transLabel}</div>
|
||||
<div>{transValidation}</div>
|
||||
<div className="w-[16px]" />
|
||||
</div>
|
||||
|
||||
{/* ROWS */}
|
||||
{RenderRows(config, true)}
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
335
resources/ts/back/Components/PageConfigs/DynamicFields.tsx
Normal file
335
resources/ts/back/Components/PageConfigs/DynamicFields.tsx
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
// * Packages
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { usePage } from "@inertiajs/react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import type { DragEndEvent } from "@dnd-kit/core";
|
||||
// * Components shadcn
|
||||
import { Accordion } from "@/components/ui/accordion";
|
||||
// * Other
|
||||
import FieldGroup from "@/back/Components/PageConfigs/FieldGroup";
|
||||
import AddNewFieldBtn from "@/back/Components/PageConfigs/AddNewFieldBtn";
|
||||
import InputError from "@/back/Components/InputError";
|
||||
import {
|
||||
ConfigField,
|
||||
ConfigInternalField,
|
||||
DynamicFieldType,
|
||||
} from "@/back/types/dynamic-pages";
|
||||
import useTrans from "@/hooks/use-translations";
|
||||
import str from "@/hooks/use-string";
|
||||
|
||||
type DynamicFieldsProps = {
|
||||
name: string;
|
||||
value: ConfigField[];
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
selectedConfig: string | null;
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
setData: (name: string, value: ConfigField[]) => void;
|
||||
};
|
||||
|
||||
export default function DynamicFields({
|
||||
name,
|
||||
value = [],
|
||||
required = true,
|
||||
disabled = false,
|
||||
selectedConfig = null,
|
||||
setData,
|
||||
}: DynamicFieldsProps) {
|
||||
// Errors.
|
||||
const { errors } = usePage().props;
|
||||
const [localErrors, setLocalErrors] =
|
||||
useState<Record<string, string>>(errors);
|
||||
|
||||
// Enums.
|
||||
const enumDynamicFieldTypes = usePage().props
|
||||
.enumDynamicFieldTypes as DynamicFieldType;
|
||||
|
||||
// Accordion open index.
|
||||
const [openIndex, setOpenIndex] = useState<string>("");
|
||||
|
||||
// Local changes of fields (avoid loop between useEffects).
|
||||
const prevValueRef = useRef(JSON.stringify(value ?? []));
|
||||
|
||||
// Sensitive fields.
|
||||
const [originalConfigKeys, setOriginalConfigKeys] = useState<string[]>([]);
|
||||
const addIdsRecursively = (
|
||||
fields: ConfigField[],
|
||||
parentKey: string = name,
|
||||
startId = 0,
|
||||
): ConfigInternalField[] => {
|
||||
let currentId = startId;
|
||||
|
||||
const withIds = fields.map((field) => {
|
||||
currentId += 1;
|
||||
const id = currentId;
|
||||
|
||||
// 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;
|
||||
|
||||
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 ?? []),
|
||||
);
|
||||
|
||||
// Sorted fields by order.
|
||||
const sortedFields = useMemo(
|
||||
() => [...fields].sort((a, b) => (a.order ?? 1) - (b.order ?? 1)),
|
||||
[fields],
|
||||
);
|
||||
|
||||
// Drag & drop module for user interactions (mouse, touch, keyboard or other).
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
// Translations needed.
|
||||
const trans = {
|
||||
key: useTrans("uwil-dynamic-pages::trans.attributes.key"),
|
||||
label: useTrans("uwil-dynamic-pages::trans.attributes.label"),
|
||||
type: useTrans("uwil-dynamic-pages::trans.attributes.type"),
|
||||
validation: useTrans("uwil-dynamic-pages::trans.attributes.validation"),
|
||||
children: useTrans("uwil-dynamic-pages::trans.attributes.children"),
|
||||
};
|
||||
const transJunction = useTrans(
|
||||
"uwil-dynamic-pages::trans.attributes.of_the.male",
|
||||
);
|
||||
const transField = useTrans("uwil-dynamic-pages::trans.attributes.field");
|
||||
const transDelete = useTrans("uwil-dynamic-pages::trans.delete_field");
|
||||
const transDragAndDrop = useTrans("uwil-dynamic-pages::trans.dnd_field");
|
||||
|
||||
// Set the depth limit of each list elements.
|
||||
const MAX_DEPTH = 10;
|
||||
|
||||
/**
|
||||
* Get a translation for a specific input from trans constant.
|
||||
* @param field string
|
||||
* @param id number
|
||||
* @returns string
|
||||
*/
|
||||
const getTransInput = (field: keyof typeof trans, id: number): string => {
|
||||
const transFieldValue = trans[field] ?? trans.label;
|
||||
return str(`${transFieldValue} ${transJunction} ${transField} n°${id}`)
|
||||
.ucFirst()
|
||||
.value();
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a new field.
|
||||
* @returns void
|
||||
*/
|
||||
const addField = (): void => {
|
||||
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: ConfigInternalField = {
|
||||
id: maxIndex,
|
||||
key: `${enumDynamicFieldTypes.string.name}_`,
|
||||
type: enumDynamicFieldTypes.string.value,
|
||||
label: "",
|
||||
validation: enumDynamicFieldTypes.string.validation,
|
||||
order: maxOrder,
|
||||
};
|
||||
setFields((prev) => [...prev, newField]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a specific field.
|
||||
* @param fieldId string
|
||||
* @param updatedField Partial<ConfigField>
|
||||
* @returns void
|
||||
*/
|
||||
const updateField = (
|
||||
fieldId: number,
|
||||
updatedField: Partial<ConfigField>,
|
||||
): void => {
|
||||
setFields((prev) =>
|
||||
prev.map((field) => {
|
||||
if (field.id !== fieldId) return field;
|
||||
|
||||
// Merge the data.
|
||||
const merged = { ...field, ...updatedField };
|
||||
|
||||
// Only if updated field is type.
|
||||
if (updatedField.type !== undefined) {
|
||||
const matchingKey = Object.keys(enumDynamicFieldTypes).find(
|
||||
(k) => enumDynamicFieldTypes[k].value === updatedField.type,
|
||||
);
|
||||
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;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove sensitive field list.
|
||||
* @param keyToRemove string
|
||||
*/
|
||||
const removeSensitiveField = (keyToRemove: string): void => {
|
||||
setOriginalConfigKeys((prev) => prev.filter((key) => key !== keyToRemove));
|
||||
// Reset all errors because of alter elements list.
|
||||
setLocalErrors({});
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a specific field.
|
||||
* @param fieldId string
|
||||
* @returns void
|
||||
*/
|
||||
const removeField = (fieldId: number): void => {
|
||||
const targetField = fields.filter((field) => field.id === fieldId)[0];
|
||||
|
||||
removeSensitiveField(targetField.key);
|
||||
setFields((prev) => prev.filter((field) => field.id !== fieldId));
|
||||
};
|
||||
|
||||
/**
|
||||
* Save the new order.
|
||||
* @param event DragEndEvent
|
||||
* @return void
|
||||
*/
|
||||
const handleDragEnd = (event: DragEndEvent): void => {
|
||||
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 < 0 || newIndex < 0) return;
|
||||
|
||||
setFields((prev) => {
|
||||
const moved = arrayMove(prev, oldIndex, newIndex);
|
||||
return moved.map((fld, idx) => ({
|
||||
...fld,
|
||||
order: idx + 1,
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove all local ids.
|
||||
* @param currentFields ConfigInternalField[]
|
||||
* @returns
|
||||
*/
|
||||
const removeIds = (currentFields: ConfigInternalField[]): ConfigField[] => {
|
||||
// Delete the id, keep only the children if they are present, and keep the rest.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
return currentFields.map(({ id, children, ...rest }) => ({
|
||||
...rest,
|
||||
...(children ? { children: removeIds(children) } : {}),
|
||||
})) as ConfigField[];
|
||||
};
|
||||
|
||||
// Export to parent only if payload differs with prevValueRef.
|
||||
useEffect(() => {
|
||||
const payload = removeIds(fields);
|
||||
const payloadStr = JSON.stringify(payload);
|
||||
if (payloadStr !== prevValueRef.current) {
|
||||
setData(name, payload);
|
||||
prevValueRef.current = payloadStr;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fields]);
|
||||
|
||||
// Update fields when copying config from another locale.
|
||||
useEffect(() => {
|
||||
setFields(() => addIdsRecursively(value ?? []));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedConfig]);
|
||||
|
||||
// Update errors from parent.
|
||||
useEffect(() => {
|
||||
setLocalErrors(errors);
|
||||
}, [errors]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
{sortedFields.length > 0 && (
|
||||
<Accordion
|
||||
type="single"
|
||||
className="w-full"
|
||||
value={openIndex}
|
||||
onValueChange={setOpenIndex}
|
||||
collapsible
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<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}
|
||||
removeSensitiveField={removeSensitiveField}
|
||||
localErrors={localErrors}
|
||||
setLocalErrors={setLocalErrors}
|
||||
parentAccordionState={openIndex}
|
||||
setParentAccordionState={setOpenIndex}
|
||||
limitMaxChildren={MAX_DEPTH}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</Accordion>
|
||||
)}
|
||||
<AddNewFieldBtn
|
||||
className={localErrors?.[name] ? "mb-3" : ""}
|
||||
array={fields}
|
||||
limitMax={MAX_DEPTH}
|
||||
onClick={addField}
|
||||
/>
|
||||
<InputError message={localErrors?.[name]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
// * Packages
|
||||
import { FileDownIcon } from "lucide-react";
|
||||
// * Other
|
||||
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
|
||||
|
||||
type ExportConfigJsonProps<T> = {
|
||||
// The data in string.
|
||||
data: T;
|
||||
// Name of the downloaded file.
|
||||
filename?: string;
|
||||
// String present in the tooltip.
|
||||
tooltipContent: string;
|
||||
// Disable the button or not.
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export default function ExportConfigJson<T>({
|
||||
data,
|
||||
filename = "config.json",
|
||||
tooltipContent,
|
||||
disabled = false,
|
||||
}: ExportConfigJsonProps<T>) {
|
||||
const handleExport = () => {
|
||||
try {
|
||||
// Transform data into json structure.
|
||||
const json = JSON.stringify(data, null);
|
||||
|
||||
// Create blob from the json.
|
||||
const blob = new Blob([json], {
|
||||
type: "application/json;charset=utf-8",
|
||||
});
|
||||
|
||||
// Creates a link on the fly and opens it.
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error("Error during JSON export:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
content={<FileDownIcon className="w-4 h-4 m-auto" />}
|
||||
tooltipContent={tooltipContent}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
onClick={handleExport}
|
||||
/>
|
||||
);
|
||||
}
|
||||
520
resources/ts/back/Components/PageConfigs/FieldGroup.tsx
Normal file
520
resources/ts/back/Components/PageConfigs/FieldGroup.tsx
Normal file
|
|
@ -0,0 +1,520 @@
|
|||
// * Packages
|
||||
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,
|
||||
InfoIcon,
|
||||
} 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";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
// * 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,
|
||||
DynamicFieldType,
|
||||
} from "@/back/types/dynamic-pages";
|
||||
import useTrans from "@/hooks/use-translations";
|
||||
import str from "@/hooks/use-string";
|
||||
import { cn, GetTranslation } from "@/lib/utils";
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
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[];
|
||||
removeSensitiveField: (key: string) => void;
|
||||
localErrors: Record<string, string>;
|
||||
setLocalErrors: Dispatch<SetStateAction<Record<string, string>>>;
|
||||
parentAccordionState: string;
|
||||
setParentAccordionState: (key: string) => void;
|
||||
limitMaxChildren: number;
|
||||
};
|
||||
/* eslint-enable no-unused-vars */
|
||||
|
||||
export default function FieldGroup({
|
||||
currentId,
|
||||
currentIndex,
|
||||
currentFieldKey,
|
||||
parentFieldKey,
|
||||
currentField,
|
||||
currentRequired,
|
||||
currentDisabled,
|
||||
getTransInput,
|
||||
updateField,
|
||||
removeField,
|
||||
transDelete,
|
||||
transDragAndDrop,
|
||||
sensitiveField,
|
||||
removeSensitiveField,
|
||||
localErrors,
|
||||
setLocalErrors,
|
||||
parentAccordionState,
|
||||
setParentAccordionState,
|
||||
limitMaxChildren,
|
||||
}: FieldGroupProps) {
|
||||
// Drag & drop module for user interactions (mouse, touch, keyboard or other).
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
// Provides the necessary props and helpers to make this item draggable and reorderable.
|
||||
// Includes event listeners, accessibility attributes, DOM ref, drag transform/transition values,
|
||||
// and a flag indicating whether the item is dragging.
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: String(currentId) });
|
||||
|
||||
// Style applied to the sortable item:
|
||||
// - handles drag movement (transform),
|
||||
// - animations (transition),
|
||||
// - and visual feedback (opacity, zIndex) while dragging.
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
zIndex: isDragging ? 50 : 0,
|
||||
};
|
||||
|
||||
// Get all enum values, labels and names.
|
||||
const enumDynamicFieldTypes = usePage().props
|
||||
.enumDynamicFieldTypes as DynamicFieldType;
|
||||
|
||||
// Set the current state of the accordion.
|
||||
const [openIndex, setOpenIndex] = useState<string>("");
|
||||
|
||||
// Get the current enum value.
|
||||
const currentEnum = Object.values(enumDynamicFieldTypes).find(
|
||||
(def) => def.value === currentField.type,
|
||||
);
|
||||
// Set a prefix related to the current enum.
|
||||
const enumCurrentBase = `${currentEnum?.name}_`;
|
||||
|
||||
// Set the editable part of the current key.
|
||||
const [editableKey, setEditableKey] = useState<string>(() => {
|
||||
return currentField.key.startsWith(enumCurrentBase)
|
||||
? currentField.key.replace(enumCurrentBase, "")
|
||||
: currentField.key;
|
||||
});
|
||||
|
||||
//* Translations
|
||||
const transKeyAlreadyPresent = useTrans(
|
||||
"uwil-dynamic-pages::trans.key_already_present",
|
||||
);
|
||||
const transTypeField = useTrans("uwil-dynamic-pages::trans.type_field");
|
||||
|
||||
// Set the complete key (prefix + editable) that will be sent to the backend.
|
||||
const completeKey = `${enumCurrentBase}${editableKey}`;
|
||||
|
||||
// Set the state of copying key.
|
||||
const [copied, setCopied] = useState(false);
|
||||
// Copy the complete key in the clipboard.
|
||||
const handleClick = () => {
|
||||
navigator.clipboard.writeText(completeKey);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
/**
|
||||
* Reorder the elements list.
|
||||
* @param event DragEndEvent
|
||||
* @return void
|
||||
*/
|
||||
const handleOnDragEnd = (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 });
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a new field to the list.
|
||||
* @return void
|
||||
*/
|
||||
const handleAddNewChild = () => {
|
||||
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 });
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a specific element from the list.
|
||||
* @param childId number
|
||||
* @return void
|
||||
*/
|
||||
const handleDeleteChild = (childId: number) => {
|
||||
const children = currentField.children ?? [];
|
||||
const targetChild = children.filter((child) => child.id === childId)[0];
|
||||
removeSensitiveField(`${parentFieldKey}.${targetChild.key}`);
|
||||
const childrenUpdated = children.filter((c) => c.id !== childId);
|
||||
updateField(currentId, { children: childrenUpdated });
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the input type.
|
||||
* @param value string
|
||||
* @return void
|
||||
*/
|
||||
const updatingType = (value: string) => {
|
||||
const selectedType = Object.values(enumDynamicFieldTypes).find(
|
||||
(def) => def.value === Number(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"
|
||||
disabled={parentAccordionState !== ""}
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/* eslint-enable react/jsx-props-no-spreading */
|
||||
/>
|
||||
</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(
|
||||
"uwil-dynamic-pages::trans.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
|
||||
: GetTranslation("uwil-dynamic-pages::trans.field_number", {
|
||||
params: { nbr: String(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={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{getTransInput("key", currentIndex + 1)}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon size={15} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="space-y-1 text-center">
|
||||
<p>
|
||||
{GetTranslation(
|
||||
"uwil-dynamic-pages::trans.respect_format_page",
|
||||
)}
|
||||
</p>
|
||||
<code className="bg-muted px-2 py-1 rounded text-xs">
|
||||
name_key
|
||||
</code>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
placeholder="ex: field_1"
|
||||
value={editableKey}
|
||||
setData={(_unused, value) => {
|
||||
const newCompleteKey = `${enumCurrentBase}${value}`;
|
||||
if (
|
||||
sensitiveField.includes(
|
||||
`${parentFieldKey}.${newCompleteKey}`,
|
||||
)
|
||||
) {
|
||||
setLocalErrors((prev) => ({
|
||||
...prev,
|
||||
[`${currentFieldKey}.${currentIndex}.key`]:
|
||||
transKeyAlreadyPresent,
|
||||
}));
|
||||
} else {
|
||||
setLocalErrors((prev) => {
|
||||
const {
|
||||
[`${currentFieldKey}.${currentIndex}.key`]:
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
removedKey,
|
||||
...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={transTypeField}
|
||||
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>
|
||||
{/* CHILDREN */}
|
||||
{currentEnum?.value === enumDynamicFieldTypes.list.value && (
|
||||
<Accordion
|
||||
type="single"
|
||||
className="w-full mt-4"
|
||||
value={openIndex}
|
||||
onValueChange={setOpenIndex}
|
||||
collapsible
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleOnDragEnd}
|
||||
>
|
||||
<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}
|
||||
removeSensitiveField={removeSensitiveField}
|
||||
localErrors={localErrors}
|
||||
setLocalErrors={setLocalErrors}
|
||||
parentAccordionState={openIndex}
|
||||
setParentAccordionState={setOpenIndex}
|
||||
limitMaxChildren={limitMaxChildren}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<AddNewFieldBtn
|
||||
array={currentField.children}
|
||||
limitMax={limitMaxChildren}
|
||||
variant="secondary"
|
||||
onClick={handleAddNewChild}
|
||||
/>
|
||||
</Accordion>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
408
resources/ts/back/Components/PageConfigs/ImportConfigJson.tsx
Normal file
408
resources/ts/back/Components/PageConfigs/ImportConfigJson.tsx
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
// * Packages
|
||||
import { ChangeEvent, DragEvent, useRef, useState } from "react";
|
||||
import {
|
||||
JsonEditor,
|
||||
IconReplacements,
|
||||
LocalisedStrings,
|
||||
} from "json-edit-react";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
CopyIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
Trash2Icon,
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import z from "zod";
|
||||
// * Components shadcn
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
// * Other
|
||||
import SimpleSwitch from "@/back/Components/Inputs/SimpleSwitch";
|
||||
import InputError from "@/back/Components/InputError";
|
||||
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import str from "@/hooks/use-string";
|
||||
import useTrans from "@/hooks/use-translations";
|
||||
|
||||
type JsonPrimitive = string | number | boolean | null;
|
||||
|
||||
type JsonObject = {
|
||||
[key: string]: JsonPrimitive | JsonObject | JsonObject[];
|
||||
};
|
||||
|
||||
type ImportConfigJsonProps = {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
onConfirm: (data: JsonObject[], overwrite: boolean) => void;
|
||||
};
|
||||
|
||||
// Zod schemas.
|
||||
const jsonObjectSchema: z.ZodType<JsonObject> = z.lazy(() =>
|
||||
z.record(
|
||||
z.string(),
|
||||
z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.null(),
|
||||
jsonObjectSchema,
|
||||
z.array(jsonObjectSchema),
|
||||
]),
|
||||
),
|
||||
);
|
||||
const jsonArraySchema = z.array(jsonObjectSchema);
|
||||
|
||||
export default function ImportConfigJson({ onConfirm }: ImportConfigJsonProps) {
|
||||
// Json content.
|
||||
const [jsonText, setJsonText] = useState<JsonObject[]>([]);
|
||||
|
||||
// Modale state.
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
// Event state.
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||
// Overwrite state.
|
||||
const [overwrite, setOverwrite] = useState<boolean>(false);
|
||||
|
||||
// Local errors.
|
||||
const [globalErrors, setGlobalErrors] = useState<Record<string, string>>({});
|
||||
const [editorErrors, setEditorErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Input file reference.
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Maximum file size accepted (2MB).
|
||||
const maxSize = 2 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Get translation of specific key.
|
||||
*
|
||||
* @param key string
|
||||
* @return string
|
||||
*/
|
||||
const useGetTranslation = (key: string): string =>
|
||||
str(useTrans(key)).ucFirst().value();
|
||||
|
||||
const transJsonEditorMaxSize = useTrans(
|
||||
"uwil-dynamic-pages::trans.json_editor.validation.max_size",
|
||||
{
|
||||
params: { value: "2MB" },
|
||||
},
|
||||
);
|
||||
const transJsonEditorMimes = useTrans(
|
||||
"uwil-dynamic-pages::trans.json_editor.validation.mimes",
|
||||
{
|
||||
params: { values: ".json" },
|
||||
},
|
||||
);
|
||||
const transJsonEditorInvalidFileJson = useTrans(
|
||||
"uwil-dynamic-pages::trans.json_editor.validation.invalid_file_json",
|
||||
);
|
||||
const transInvalidContentJson = useTrans(
|
||||
"uwil-dynamic-pages::trans.json_editor.validation.invalid_content_json",
|
||||
);
|
||||
|
||||
// Custom icons.
|
||||
const icons: IconReplacements = {
|
||||
add: <PlusIcon className="w-4 h-4" />,
|
||||
edit: <PencilIcon className="w-4 h-4" />,
|
||||
delete: <Trash2Icon className="w-4 h-4" />,
|
||||
copy: <CopyIcon className="w-4 h-4" />,
|
||||
ok: <CheckIcon className="w-4 h-4" />,
|
||||
cancel: <XIcon className="w-4 h-4" />,
|
||||
chevron: <ChevronDownIcon className="cursor-pointer w-4 h-4" />,
|
||||
};
|
||||
|
||||
// Custom translations.
|
||||
const translations: LocalisedStrings = {
|
||||
ITEM_SINGLE: useGetTranslation(
|
||||
"uwil-dynamic-pages::trans.json_editor.item_single",
|
||||
),
|
||||
ITEMS_MULTIPLE: useGetTranslation(
|
||||
"uwil-dynamic-pages::trans.json_editor.item_multiple",
|
||||
),
|
||||
KEY_NEW: useGetTranslation("uwil-dynamic-pages::trans.json_editor.key_new"),
|
||||
KEY_SELECT: useGetTranslation(
|
||||
"uwil-dynamic-pages::trans.json_editor.key_select",
|
||||
),
|
||||
NO_KEY_OPTIONS: useGetTranslation(
|
||||
"uwil-dynamic-pages::trans.json_editor.no_key_options",
|
||||
),
|
||||
ERROR_KEY_EXISTS: useGetTranslation(
|
||||
"uwil-dynamic-pages::trans.json_editor.error_key_exists",
|
||||
),
|
||||
ERROR_INVALID_JSON: useGetTranslation(
|
||||
"uwil-dynamic-pages::trans.json_editor.error_invalid_json",
|
||||
),
|
||||
ERROR_UPDATE: useGetTranslation(
|
||||
"uwil-dynamic-pages::trans.json_editor.error_update",
|
||||
),
|
||||
ERROR_DELETE: useGetTranslation(
|
||||
"uwil-dynamic-pages::trans.json_editor.error_delete",
|
||||
),
|
||||
ERROR_ADD: useGetTranslation(
|
||||
"uwil-dynamic-pages::trans.json_editor.error_add",
|
||||
),
|
||||
DEFAULT_STRING: useGetTranslation(
|
||||
"uwil-dynamic-pages::trans.json_editor.default_string",
|
||||
),
|
||||
DEFAULT_NEW_KEY: useGetTranslation(
|
||||
"uwil-dynamic-pages::trans.json_editor.default_new_key",
|
||||
),
|
||||
SHOW_LESS: useGetTranslation(
|
||||
"uwil-dynamic-pages::trans.json_editor.show_less",
|
||||
),
|
||||
EMPTY_STRING: useGetTranslation(
|
||||
"uwil-dynamic-pages::trans.json_editor.empty_string",
|
||||
),
|
||||
TOOLTIP_COPY: useGetTranslation(
|
||||
"uwil-dynamic-pages::trans.json_editor.copy",
|
||||
),
|
||||
TOOLTIP_EDIT: useGetTranslation(
|
||||
"uwil-dynamic-pages::trans.json_editor.edit",
|
||||
),
|
||||
TOOLTIP_DELETE: useGetTranslation(
|
||||
"uwil-dynamic-pages::trans.json_editor.delete",
|
||||
),
|
||||
TOOLTIP_ADD: useGetTranslation("uwil-dynamic-pages::trans.json_editor.add"),
|
||||
};
|
||||
|
||||
/**
|
||||
* Simulates click on input file.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
const openFilePicker = (): void => {
|
||||
inputRef.current?.click();
|
||||
};
|
||||
/**
|
||||
* Set json from content file.
|
||||
*
|
||||
* @param file File
|
||||
* @return Promise<void>
|
||||
*/
|
||||
const processFile = async (file: File): Promise<void> => {
|
||||
if (file.size > maxSize) {
|
||||
setGlobalErrors((prev) => ({
|
||||
...prev,
|
||||
"global-error": transJsonEditorMaxSize,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.name.endsWith(".json")) {
|
||||
setGlobalErrors((prev) => ({
|
||||
...prev,
|
||||
"global-error": transJsonEditorMimes,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text);
|
||||
|
||||
// Validate json with zod.
|
||||
jsonArraySchema.parse(parsed);
|
||||
|
||||
setJsonText(parsed);
|
||||
setGlobalErrors({});
|
||||
setIsOpen(true);
|
||||
} catch {
|
||||
setGlobalErrors((prev) => ({
|
||||
...prev,
|
||||
"global-error": transJsonEditorInvalidFileJson,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset component.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
const resetComponent = (): void => {
|
||||
setIsOpen(false);
|
||||
setTimeout(() => {
|
||||
setJsonText([]);
|
||||
setGlobalErrors({});
|
||||
setEditorErrors({});
|
||||
setOverwrite(false);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
/**
|
||||
* Action when changing file.
|
||||
*
|
||||
* @param event
|
||||
* @return void
|
||||
*/
|
||||
const handleFileChange = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) processFile(file);
|
||||
};
|
||||
|
||||
/**
|
||||
* Action when droping file.
|
||||
*
|
||||
* @param event
|
||||
* @return void
|
||||
*/
|
||||
const handleDrop = (event: DragEvent<HTMLDivElement>): void => {
|
||||
event.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
const file = event.dataTransfer.files?.[0];
|
||||
if (file) processFile(file);
|
||||
};
|
||||
|
||||
/**
|
||||
* Confirm the json.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
const handleConfirm = (): void => {
|
||||
try {
|
||||
// Validate json with zod.
|
||||
jsonArraySchema.parse(jsonText);
|
||||
onConfirm(jsonText, overwrite);
|
||||
resetComponent();
|
||||
} catch {
|
||||
setEditorErrors((prev) => ({
|
||||
...prev,
|
||||
"editor-error": transInvalidContentJson,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Dropzone */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex border border-dashed overflow-hidden whitespace-normal [&_svg]:!pointer-events-auto [&_svg]:!size-auto [&_svg]:!shrink text-muted-foreground w-full h-36",
|
||||
{ "text-accent-foreground bg-accent": isDragging },
|
||||
{
|
||||
"border-destructive mb-1.5": Object.values(globalErrors).length,
|
||||
},
|
||||
)}
|
||||
onClick={openFilePicker}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
// @ts-expect-error function return are not compatible for ts
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<UploadIcon />
|
||||
<p className="text-center break-normal w-full mt-2">
|
||||
{useTrans("uwil-dynamic-pages::trans.json_editor.select-file")}
|
||||
</p>
|
||||
<small>
|
||||
{str(
|
||||
useTrans("uwil-dynamic-pages::trans.json_editor.allowed_format", {
|
||||
params: { format: ".json" },
|
||||
}),
|
||||
)
|
||||
.ucFirst()
|
||||
.value()}
|
||||
.{" "}
|
||||
{str(
|
||||
useTrans("uwil-dynamic-pages::trans.json_editor.rules_weight", {
|
||||
params: { size: "2MB" },
|
||||
}),
|
||||
)
|
||||
.ucFirst()
|
||||
.value()}
|
||||
</small>
|
||||
</div>
|
||||
</Button>
|
||||
<InputError message={globalErrors["global-error"]} />
|
||||
{/* Modal */}
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{useGetTranslation(
|
||||
"uwil-dynamic-pages::trans.json_editor.modal_title",
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{useGetTranslation(
|
||||
"uwil-dynamic-pages::trans.json_editor.modal_description",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="rounded-md w-full max-h-[350px] overflow-scroll">
|
||||
{/* JSON editor */}
|
||||
<JsonEditor
|
||||
data={jsonText}
|
||||
setData={(val) => setJsonText(val as JsonObject[])}
|
||||
icons={icons}
|
||||
translations={translations}
|
||||
showIconTooltips
|
||||
collapseAnimationTime={200}
|
||||
maxWidth="100%"
|
||||
searchFilter="all"
|
||||
/>
|
||||
</div>
|
||||
<InputError message={editorErrors["editor-error"]} />
|
||||
<DialogFooter className="sm:justify-between">
|
||||
<SimpleSwitch
|
||||
label={useGetTranslation(
|
||||
"uwil-dynamic-pages::trans.json_editor.overwrite",
|
||||
)}
|
||||
name="overwrite"
|
||||
checked={overwrite}
|
||||
setData={(_, value) => setOverwrite(value)}
|
||||
/>
|
||||
<div className="flex justify-center items-center gap-1.5">
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
content={useGetTranslation(
|
||||
"uwil-dynamic-pages::trans.json_editor.cancel",
|
||||
)}
|
||||
tooltipContent={useGetTranslation(
|
||||
"uwil-dynamic-pages::trans.json_editor.tooltip_cancel",
|
||||
)}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="default"
|
||||
onClick={resetComponent}
|
||||
/>
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
content={useGetTranslation(
|
||||
"uwil-dynamic-pages::trans.json_editor.validate",
|
||||
)}
|
||||
tooltipContent={useGetTranslation(
|
||||
"uwil-dynamic-pages::trans.json_editor.tooltip_validate",
|
||||
)}
|
||||
type="button"
|
||||
size="default"
|
||||
onClick={handleConfirm}
|
||||
/>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
66
resources/ts/back/Pages/DynamicPages/Create.tsx
Normal file
66
resources/ts/back/Pages/DynamicPages/Create.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// * Packages
|
||||
import { usePage } from "@inertiajs/react";
|
||||
import { route } from "ziggy-js";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
// * Other
|
||||
import AuthenticatedLayout from "@/back/Layouts/AuthenticatedLayout";
|
||||
import Form from "@/back/Pages/DynamicPages/Form";
|
||||
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
|
||||
import useTrans from "@/hooks/use-translations";
|
||||
import { Actions, Methods } from "@/back/lib/enums";
|
||||
import { AuthProp, ConfigBackend, SingleResource } from "@/back/types/global";
|
||||
import { DynamicPageModel } from "@/back/types/dynamic-pages";
|
||||
|
||||
export default function Create({
|
||||
data,
|
||||
modelName,
|
||||
}: {
|
||||
data: SingleResource;
|
||||
modelName: string;
|
||||
}) {
|
||||
const routeName = (usePage().props.configBackend as ConfigBackend).routes
|
||||
.name;
|
||||
const { viewAny } = (usePage().props.auth as AuthProp).policies.dynamicPages;
|
||||
const dynamicPageModel = data.data as DynamicPageModel;
|
||||
|
||||
// Translations.
|
||||
const transBackToList = useTrans("uwil-backend::back.crud_back_to_list");
|
||||
const transTitle = useTrans(
|
||||
route().current(`${routeName}${modelName}.duplicate`)
|
||||
? "uwil-backend::crud.helpers.duplication_model"
|
||||
: "uwil-backend::crud.helpers.creation_model",
|
||||
{
|
||||
params: {
|
||||
model: useTrans(`uwil-dynamic-pages::models.classes.${modelName}`, {
|
||||
choice: 1,
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const beforeNode = () => {
|
||||
return (
|
||||
viewAny && (
|
||||
<div className="flex justify-end items-center gap-x-1.5">
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
link={route(`${routeName}${modelName}.index`)}
|
||||
content={<ArrowLeftIcon />}
|
||||
tooltipContent={<p>{transBackToList}</p>}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout title={transTitle} beforeNode={beforeNode()}>
|
||||
<Form
|
||||
model={dynamicPageModel}
|
||||
modelName={modelName}
|
||||
modelRoute={modelName}
|
||||
action={Actions.STORE}
|
||||
method={Methods.POST}
|
||||
/>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
74
resources/ts/back/Pages/DynamicPages/Edit.tsx
Normal file
74
resources/ts/back/Pages/DynamicPages/Edit.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// * Packages
|
||||
import { usePage } from "@inertiajs/react";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import { route } from "ziggy-js";
|
||||
// * Other
|
||||
import AuthenticatedLayout from "@/back/Layouts/AuthenticatedLayout";
|
||||
import ModelActions from "@/back/Components/ModelActions";
|
||||
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
|
||||
import Form from "@/back/Pages/DynamicPages/Form";
|
||||
import useTrans from "@/hooks/use-translations";
|
||||
import { Actions, Methods } from "@/back/lib/enums";
|
||||
import { Views } from "@/back/lib/actions";
|
||||
import { AuthProp, ConfigBackend, SingleResource } from "@/back/types/global";
|
||||
import { DynamicPageModel } from "@/back/types/dynamic-pages";
|
||||
|
||||
export default function Edit({
|
||||
data,
|
||||
modelName,
|
||||
}: {
|
||||
data: SingleResource;
|
||||
modelName: string;
|
||||
}) {
|
||||
const routeName = (usePage().props.configBackend as ConfigBackend).routes
|
||||
.name;
|
||||
const { viewAny } = (usePage().props.auth as AuthProp).policies.dynamicPages;
|
||||
const dynamicPageModel = data.data as DynamicPageModel;
|
||||
const actionModels = [
|
||||
Views.VISUALIZATION,
|
||||
Views.DUPLICATION,
|
||||
Views.ARCHIVATION,
|
||||
];
|
||||
|
||||
// Translations.
|
||||
const transBackToList = useTrans("uwil-backend::back.crud_back_to_list");
|
||||
const transTitle = useTrans("uwil-backend::crud.helpers.edition_model", {
|
||||
params: {
|
||||
model: useTrans(`uwil-dynamic-pages::models.classes.${modelName}`, {
|
||||
choice: 1,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const beforeNode = () => {
|
||||
return (
|
||||
viewAny && (
|
||||
<div className="flex justify-end items-center gap-x-1.5">
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
link={route(`${routeName}${modelName}.index`)}
|
||||
content={<ArrowLeftIcon />}
|
||||
tooltipContent={<p>{transBackToList}</p>}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout title={transTitle} beforeNode={beforeNode()}>
|
||||
<ModelActions
|
||||
actions={actionModels}
|
||||
modelRoute={modelName}
|
||||
model={dynamicPageModel}
|
||||
modelUpdatedAt={dynamicPageModel.updated_at?.toString()}
|
||||
/>
|
||||
<Form
|
||||
model={dynamicPageModel}
|
||||
modelName={modelName}
|
||||
modelRoute={modelName}
|
||||
action={Actions.UPDATE}
|
||||
method={Methods.PATCH}
|
||||
/>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
411
resources/ts/back/Pages/DynamicPages/Form.tsx
Normal file
411
resources/ts/back/Pages/DynamicPages/Form.tsx
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
// * Packages
|
||||
import { useForm, usePage } from "@inertiajs/react";
|
||||
import {
|
||||
LinkIcon,
|
||||
BookTextIcon,
|
||||
CogIcon,
|
||||
EyeIcon,
|
||||
ShieldAlertIcon,
|
||||
} from "lucide-react";
|
||||
import { route } from "ziggy-js";
|
||||
// * Components shadcn
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
// * Other
|
||||
import SimpleString from "@/back/Components/Inputs/SimpleString";
|
||||
import SimpleSwitch from "@/back/Components/Inputs/SimpleSwitch";
|
||||
import SimpleWysiwyg from "@/back/Components/Inputs/Wysiwyg/SimpleWysiwyg";
|
||||
import EnumSelect from "@/back/Components/Inputs/EnumSelect";
|
||||
import DynamicInputs from "@/back/Components/DynamicPages/DynamicInputs";
|
||||
import FieldsetWrapper from "@/back/Components/FieldsetWrapper";
|
||||
import ConfigDetails from "@/back/Components/PageConfigs/ConfigDetails";
|
||||
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
|
||||
import GoToFrontView from "@/back/Components/GoToFrontView";
|
||||
import useTrans from "@/hooks/use-translations";
|
||||
import InputError from "@/back/Components/InputError";
|
||||
import str from "@/hooks/use-string";
|
||||
import { AppEnv, LaravelEnum } from "@/types/global";
|
||||
import { AuthProp, ConfigBackend, UserModel } from "@/back/types/global";
|
||||
import {
|
||||
ConfigField,
|
||||
DynamicPageModel,
|
||||
PageConfigModel,
|
||||
} from "@/back/types/dynamic-pages";
|
||||
import SubmitButton from "@/back/Components/SubmitButton";
|
||||
import useCrudSubmit from "@/back/hooks/use-crud-submit";
|
||||
import Slugifycator from "@/back/Components/Inputs/Slugifycator";
|
||||
import { ModelSelect } from "@/back/Components/Inputs/ModelSelect";
|
||||
|
||||
export default function Form({
|
||||
model,
|
||||
modelName,
|
||||
modelRoute,
|
||||
action,
|
||||
method,
|
||||
}: {
|
||||
model?: DynamicPageModel;
|
||||
modelName: string;
|
||||
modelRoute: string;
|
||||
action: string;
|
||||
method: number;
|
||||
}) {
|
||||
/** Global */
|
||||
const { locale, url } = usePage().props.appEnv as AppEnv;
|
||||
const routeName = (usePage().props.configBackend as ConfigBackend).routes
|
||||
.name;
|
||||
const dynamicPageUrl = usePage().props.dynamicPageUrl as string;
|
||||
const userModel = (usePage().props.auth as AuthProp).user;
|
||||
const enumUserRole = usePage().props.enumUserRole as UserModel["role"][];
|
||||
const getEnumRoleValue = (name: string) =>
|
||||
enumUserRole.find((role) => role.name === name)?.value;
|
||||
const isConceptor = userModel.role.value === getEnumRoleValue("conceptor");
|
||||
const enumDynamicPageStatus = usePage().props
|
||||
.enumDynamicPageStatus as LaravelEnum;
|
||||
const pageConfigModels = usePage().props
|
||||
.pageConfigModels as Array<PageConfigModel>;
|
||||
|
||||
// Models filtered based on the presence of the locale.
|
||||
const filteredPageConfigModels = pageConfigModels.filter(
|
||||
(pageConfigModel) =>
|
||||
pageConfigModel.label?.[locale] !== undefined &&
|
||||
pageConfigModel.config?.[locale] !== undefined,
|
||||
);
|
||||
|
||||
// Set empty config by default.
|
||||
const defaultConfigId = 0;
|
||||
const configOptions = [
|
||||
{
|
||||
id: defaultConfigId,
|
||||
label: {},
|
||||
config: {},
|
||||
policies: {
|
||||
view: true,
|
||||
update: true,
|
||||
duplicate: true,
|
||||
delete: true,
|
||||
},
|
||||
view: null,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
deleted_at: new Date(),
|
||||
} as PageConfigModel,
|
||||
...filteredPageConfigModels,
|
||||
];
|
||||
configOptions[defaultConfigId].label[locale] = useTrans(
|
||||
"uwil-dynamic-pages::trans.config_none_selected",
|
||||
);
|
||||
|
||||
// Set translations.
|
||||
const contentAssociateToModel = str(
|
||||
useTrans("uwil-backend::back.form_associate_to", {
|
||||
params: {
|
||||
attribute: useTrans("uwil-dynamic-pages::trans.attributes.content"),
|
||||
},
|
||||
choice: 1,
|
||||
}),
|
||||
)
|
||||
.ucFirst()
|
||||
.value();
|
||||
const modalTitle = useTrans("uwil-dynamic-pages::trans.config_modal_title");
|
||||
const modalDescription = useTrans(
|
||||
"uwil-dynamic-pages::trans.config_modal_descripion",
|
||||
);
|
||||
const modalClose = useTrans("uwil-backend::back.close");
|
||||
const anchorAccessConfig = useTrans(
|
||||
"uwil-dynamic-pages::trans.access_model",
|
||||
{
|
||||
params: {
|
||||
model: useTrans("uwil-dynamic-pages::models.classes.page-configs", {
|
||||
choice: 1,
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
const transValidationRootLabel = useTrans(
|
||||
"uwil-dynamic-pages::trans.attributes.root",
|
||||
);
|
||||
const transValidationParentPageLabel = useTrans(
|
||||
"uwil-backend::back.form_associate_to",
|
||||
{
|
||||
params: {
|
||||
attribute: useTrans("uwil-dynamic-pages::trans.attributes.parent_page"),
|
||||
},
|
||||
choice: 2,
|
||||
},
|
||||
);
|
||||
const transAssociateToStatus = useTrans(
|
||||
"uwil-backend::back.form_associate_to",
|
||||
{
|
||||
params: {
|
||||
attribute: useTrans("uwil-dynamic-pages::trans.attributes.status"),
|
||||
},
|
||||
choice: 1,
|
||||
},
|
||||
);
|
||||
const transFormTitleIsLocked = useTrans(
|
||||
"uwil-dynamic-pages::trans.form_title_isLocked",
|
||||
);
|
||||
const transFormSubtitleIsLocked = useTrans(
|
||||
"uwil-dynamic-pages::trans.form_subtitle_isLocked",
|
||||
);
|
||||
|
||||
const getConfig = (id: number) =>
|
||||
configOptions.find((objet) => objet.id === id)?.config[
|
||||
locale
|
||||
] as ConfigField[];
|
||||
|
||||
/** Form section */
|
||||
const { post, setData, data, reset } = useForm({
|
||||
title: model?.title ? model?.title[locale] : "",
|
||||
slug: model?.slug ? model?.slug[locale] : "",
|
||||
content: model?.content ? model?.content[locale] : "",
|
||||
page_id: model?.page_id ?? null,
|
||||
config_id: model?.config_id ?? configOptions[defaultConfigId].id,
|
||||
status: model?.status ?? enumDynamicPageStatus.draft.value,
|
||||
isLocked: model?.isLocked ?? false,
|
||||
isRoot: model?.isRoot ?? false,
|
||||
});
|
||||
const defaultIsRootValue = !data.isRoot && data.page_id ? [data.page_id] : [];
|
||||
const submit = useCrudSubmit({
|
||||
method,
|
||||
modelRoute,
|
||||
modelName,
|
||||
action,
|
||||
model,
|
||||
post,
|
||||
data,
|
||||
modelNameTrans: useTrans(
|
||||
`uwil-dynamic-pages::models.classes.${modelName}`,
|
||||
{
|
||||
choice: 1,
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="grid grid-cols-2 gap-4">
|
||||
<InputError all />
|
||||
<FieldsetWrapper
|
||||
title={useTrans("uwil-backend::back.form_title_global")}
|
||||
icon={<BookTextIcon className="mr-1" />}
|
||||
description={useTrans("uwil-backend::back.form_subtitle_global")}
|
||||
className="sm:grid-cols-2 gap-4"
|
||||
>
|
||||
<SimpleString
|
||||
name="title"
|
||||
label={str(
|
||||
useTrans("uwil-backend::back.form_associate_to", {
|
||||
params: {
|
||||
attribute: useTrans(
|
||||
"uwil-dynamic-pages::trans.attributes.title",
|
||||
),
|
||||
},
|
||||
choice: 1,
|
||||
}),
|
||||
)
|
||||
.ucFirst()
|
||||
.value()}
|
||||
value={data.title}
|
||||
setData={setData}
|
||||
/>
|
||||
{(!data?.isLocked || isConceptor) && (
|
||||
<EnumSelect
|
||||
name="status"
|
||||
options={enumDynamicPageStatus}
|
||||
label={str(transAssociateToStatus).ucFirst().value()}
|
||||
placeholder={`ex: ${enumDynamicPageStatus.draft.label}`}
|
||||
selected={data.status}
|
||||
setData={setData}
|
||||
/>
|
||||
)}
|
||||
</FieldsetWrapper>
|
||||
<FieldsetWrapper
|
||||
title={useTrans(
|
||||
"uwil-dynamic-pages::trans.form_title_page_accessibility",
|
||||
)}
|
||||
icon={<LinkIcon className="mr-1" />}
|
||||
description={useTrans(
|
||||
"uwil-dynamic-pages::trans.form_subtitle_page_accessibility",
|
||||
)}
|
||||
className="sm:grid-cols-2 gap-4"
|
||||
>
|
||||
{isConceptor && (
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<SimpleSwitch
|
||||
label={str(transValidationRootLabel).ucFirst().value()}
|
||||
name="isRoot"
|
||||
checked={data.isRoot}
|
||||
setData={(name, value) => {
|
||||
setData("page_id", null);
|
||||
setData("slug", "");
|
||||
// @ts-expect-error TS2345: 'name' type is dynamic, TypeScript cannot infer the correct key.
|
||||
setData(name, value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!data?.isRoot && (
|
||||
<div className="col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<ModelSelect
|
||||
name="page_id"
|
||||
targetModel="dynamic_pages"
|
||||
multiple={false}
|
||||
field="title"
|
||||
closeOnSelect
|
||||
label={str(transValidationParentPageLabel).ucFirst().value()}
|
||||
placeholder="ex: "
|
||||
defaultValues={defaultIsRootValue}
|
||||
exclude={model?.id}
|
||||
required={false}
|
||||
setData={setData}
|
||||
disabled={data.isRoot}
|
||||
/>
|
||||
<Slugifycator
|
||||
value={!data.isRoot ? data.slug : ""}
|
||||
targetValue={data.title}
|
||||
setData={setData}
|
||||
disabled={data.isRoot}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="col-span-2">
|
||||
<GoToFrontView
|
||||
modelName={useTrans(
|
||||
`uwil-dynamic-pages::models.classes.${modelName}`,
|
||||
{
|
||||
choice: 1,
|
||||
},
|
||||
)}
|
||||
pathField={
|
||||
data.isRoot
|
||||
? ""
|
||||
: (dynamicPageUrl ?? "").replace(url, "").replace(/^\/+/, "")
|
||||
}
|
||||
isAvailable={
|
||||
model?.status === enumDynamicPageStatus.published.value
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</FieldsetWrapper>
|
||||
<FieldsetWrapper
|
||||
title={useTrans("uwil-dynamic-pages::trans.form_title_page")}
|
||||
icon={<CogIcon className="mr-1" />}
|
||||
description={useTrans("uwil-dynamic-pages::trans.form_subtitle_page")}
|
||||
className="gap-4"
|
||||
>
|
||||
<div className="grid sm:grid-cols-2 gap-4 border-b border-input pb-4">
|
||||
<div className="flex justify-start items-end gap-1.5">
|
||||
<ModelSelect
|
||||
name="config_id"
|
||||
targetModel="page_configs"
|
||||
multiple={false}
|
||||
closeOnSelect
|
||||
field="label"
|
||||
responsive
|
||||
label={str(
|
||||
useTrans("uwil-backend::back.form_associate_to", {
|
||||
params: {
|
||||
attribute: useTrans(
|
||||
"uwil-dynamic-pages::trans.attributes.config",
|
||||
),
|
||||
},
|
||||
choice: 2,
|
||||
}),
|
||||
)
|
||||
.ucFirst()
|
||||
.value()}
|
||||
required={false}
|
||||
placeholder="ex: John Doe"
|
||||
defaultValues={data.config_id ? [data.config_id] : []}
|
||||
setData={setData}
|
||||
/>
|
||||
{isConceptor && Number(data.config_id) !== defaultConfigId && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary" type="button" size="icon">
|
||||
<EyeIcon />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl w-full max-h-[100vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{modalTitle}</DialogTitle>
|
||||
<DialogDescription>{modalDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[25rem] overflow-y-auto">
|
||||
<ConfigDetails config={getConfig(Number(data.config_id))} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
link={route(
|
||||
`${routeName}page-configs.show`,
|
||||
data.config_id,
|
||||
)}
|
||||
content={<CogIcon />}
|
||||
tooltipContent={anchorAccessConfig}
|
||||
variant="secondary"
|
||||
/>
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">{modalClose}</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{Number(data.config_id) === defaultConfigId ? (
|
||||
<SimpleWysiwyg
|
||||
name="content"
|
||||
tableName="dynamic_pages"
|
||||
label={contentAssociateToModel}
|
||||
value={String(typeof data.content === "string" ? data.content : "")}
|
||||
setData={setData}
|
||||
/>
|
||||
) : (
|
||||
<DynamicInputs
|
||||
name="content"
|
||||
config={getConfig(Number(data.config_id))}
|
||||
content={typeof data.content === "object" ? data.content : null}
|
||||
setData={setData}
|
||||
// @ts-expect-error Types diff between custom reset and useForm reset.
|
||||
reset={reset}
|
||||
/>
|
||||
)}
|
||||
</FieldsetWrapper>
|
||||
{isConceptor && (
|
||||
<FieldsetWrapper
|
||||
className="gap-4"
|
||||
title={transFormTitleIsLocked}
|
||||
icon={<ShieldAlertIcon className="mr-1" />}
|
||||
description={transFormSubtitleIsLocked}
|
||||
inline
|
||||
>
|
||||
<SimpleSwitch
|
||||
name="isLocked"
|
||||
checked={data.isLocked}
|
||||
setData={setData}
|
||||
/>
|
||||
</FieldsetWrapper>
|
||||
)}
|
||||
<div className="col-span-2 text-center">
|
||||
<SubmitButton
|
||||
modelTrans={useTrans(
|
||||
`uwil-dynamic-pages::models.classes.${modelName}`,
|
||||
{
|
||||
choice: 1,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
388
resources/ts/back/Pages/DynamicPages/Index.tsx
Normal file
388
resources/ts/back/Pages/DynamicPages/Index.tsx
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
// * Packages
|
||||
import { route } from "ziggy-js";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ArrowLeftIcon,
|
||||
CogIcon,
|
||||
HouseIcon,
|
||||
LockIcon,
|
||||
PlusIcon,
|
||||
StickyNoteIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
import { usePage } from "@inertiajs/react";
|
||||
import { Row } from "@tanstack/react-table";
|
||||
// * Components shadcn
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
// * Other
|
||||
import AuthenticatedLayout from "@/back/Layouts/AuthenticatedLayout";
|
||||
import DataTable from "@/back/Components/Table/DataTable";
|
||||
import DataTableSearch from "@/back/Components/Table/DataTableSearch";
|
||||
import DataTableFilterButton from "@/back/Components/Table/DataTableFilterButton";
|
||||
import SoftdeleteWarning from "@/back/Components/SoftdeleteWarning";
|
||||
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
|
||||
import DynamicStatusBadge from "@/back/Components/DynamicPages/DynamicStatusBadge";
|
||||
import RenderTranslationCell from "@/back/Components/Table/RenderTranslationCell";
|
||||
import { GetFormatedDate, GetTranslation } from "@/lib/utils";
|
||||
import { Views } from "@/back/lib/actions";
|
||||
import { AppEnv } from "@/types/global";
|
||||
import { AuthProp, ConfigBackend, Resource } from "@/back/types/global";
|
||||
import { DynamicPageModel, PageConfigModel } from "@/back/types/dynamic-pages";
|
||||
import str from "@/hooks/use-string";
|
||||
import useTrans from "@/hooks/use-translations";
|
||||
|
||||
const getColumns = (
|
||||
appEnv: AppEnv,
|
||||
modelName: string,
|
||||
routeName: string,
|
||||
viewAny: boolean,
|
||||
viewAnyPageConfigs: boolean,
|
||||
) => {
|
||||
return [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }: { column: { id: string } }) => (
|
||||
<DataTableFilterButton
|
||||
label={str(
|
||||
GetTranslation(`uwil-dynamic-pages::trans.attributes.${column.id}`),
|
||||
)
|
||||
.ucFirst()
|
||||
.value()}
|
||||
field={column.id}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }: { row: Row<DynamicPageModel> }) => (
|
||||
<div className="flex justify-center items-center">
|
||||
<RenderTranslationCell value={row.getValue("title")} />
|
||||
{row.original.isRoot && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HouseIcon size={15} className="ms-1" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{GetTranslation("uwil-backend::back.page_root")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "parent_page",
|
||||
header: ({ column }: { column: { id: string } }) => (
|
||||
<DataTableFilterButton
|
||||
label={str(
|
||||
GetTranslation(`uwil-dynamic-pages::trans.attributes.${column.id}`),
|
||||
)
|
||||
.ucFirst()
|
||||
.value()}
|
||||
field={column.id}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }: { row: Row<DynamicPageModel> }) => {
|
||||
if (row.getValue("parent_page")) {
|
||||
const dynamicPageModel = row.getValue(
|
||||
"parent_page",
|
||||
) as DynamicPageModel;
|
||||
const cellValue = dynamicPageModel.title[appEnv.locale];
|
||||
let value = <span>{cellValue}</span>;
|
||||
if (viewAny) {
|
||||
value = (
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
link={route(`${routeName}${modelName}.show`, dynamicPageModel)}
|
||||
content={
|
||||
<>
|
||||
<StickyNoteIcon />
|
||||
{value}
|
||||
</>
|
||||
}
|
||||
tooltipContent={
|
||||
<p>
|
||||
{GetTranslation("uwil-dynamic-pages::trans.access_model", {
|
||||
params: {
|
||||
model: GetTranslation(
|
||||
`uwil-dynamic-pages::models.classes.${modelName}`,
|
||||
{
|
||||
choice: 1,
|
||||
},
|
||||
),
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
}
|
||||
type="button"
|
||||
size="default"
|
||||
variant="secondary"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (dynamicPageModel.deleted_at !== null) {
|
||||
value = (
|
||||
<div className="flex flex-row justify-center items-center">
|
||||
<Badge variant="outline">{value}</Badge>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TriangleAlertIcon
|
||||
size={15}
|
||||
className="text-destructive ms-1"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{GetTranslation("uwil-backend::back.group_archived")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
return (
|
||||
<p className="text-muted-foreground italic m-0">
|
||||
{GetTranslation("uwil-backend::back.table_not_known")}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "config",
|
||||
header: ({ column }: { column: { id: string } }) => (
|
||||
<DataTableFilterButton
|
||||
label={str(
|
||||
GetTranslation(`uwil-dynamic-pages::trans.attributes.${column.id}`),
|
||||
)
|
||||
.ucFirst()
|
||||
.value()}
|
||||
field={column.id}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }: { row: Row<DynamicPageModel> }) => {
|
||||
if (row.getValue("config")) {
|
||||
const pageConfigModel = row.getValue("config") as PageConfigModel;
|
||||
const cellValue = pageConfigModel.label[appEnv.locale];
|
||||
let value = <span>{cellValue}</span>;
|
||||
if (viewAnyPageConfigs) {
|
||||
value = (
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
link={route(`${routeName}page-configs.show`, pageConfigModel)}
|
||||
content={
|
||||
<>
|
||||
<CogIcon />
|
||||
{value}
|
||||
</>
|
||||
}
|
||||
tooltipContent={
|
||||
<p>
|
||||
{GetTranslation("uwil-dynamic-pages::trans.access_model", {
|
||||
params: {
|
||||
model: GetTranslation(
|
||||
"uwil-dynamic-pages::models.classes.page-configs",
|
||||
{
|
||||
choice: 1,
|
||||
},
|
||||
),
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
}
|
||||
type="button"
|
||||
size="default"
|
||||
variant="secondary"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (pageConfigModel.deleted_at !== null) {
|
||||
value = (
|
||||
<div className="flex flex-row justify-center items-center">
|
||||
<Badge variant="outline">{value}</Badge>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TriangleAlertIcon
|
||||
size={15}
|
||||
className="text-destructive ms-1"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{GetTranslation("uwil-backend::back.config_archived")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
return (
|
||||
<p className="text-muted-foreground italic m-0">
|
||||
{GetTranslation("uwil-backend::back.table_not_known")}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }: { column: { id: string } }) => (
|
||||
<DataTableFilterButton
|
||||
label={str(
|
||||
GetTranslation(`uwil-dynamic-pages::trans.attributes.${column.id}`),
|
||||
)
|
||||
.ucFirst()
|
||||
.value()}
|
||||
field={column.id}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }: { row: Row<DynamicPageModel> }) => (
|
||||
<div className="flex justify-center items-center">
|
||||
<DynamicStatusBadge value={row.getValue("status")} />
|
||||
{row.original.isLocked && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<LockIcon size={15} className="ms-1" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{GetTranslation("uwil-dynamic-pages::trans.page_locked")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: ({ column }: { column: { id: string } }) => (
|
||||
<DataTableFilterButton
|
||||
label={str(
|
||||
GetTranslation(`uwil-dynamic-pages::trans.attributes.${column.id}`),
|
||||
)
|
||||
.ucFirst()
|
||||
.value()}
|
||||
field={column.id}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }: { row: Row<DynamicPageModel> }) => (
|
||||
<Badge variant="secondary">
|
||||
{GetFormatedDate(row.getValue("updated_at"))}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export default function Index({
|
||||
dynamicPageModels,
|
||||
searchFields,
|
||||
modelName,
|
||||
}: {
|
||||
dynamicPageModels: Resource;
|
||||
searchFields: string;
|
||||
modelName: string;
|
||||
}) {
|
||||
const { auth, appEnv, configBackend } = usePage().props as unknown as {
|
||||
auth: AuthProp;
|
||||
appEnv: AppEnv;
|
||||
configBackend: ConfigBackend;
|
||||
};
|
||||
const { viewAny, viewTrash, create } = auth.policies.dynamicPages;
|
||||
const { viewAny: viewAnyPageConfigs } = auth.policies.pageConfigs;
|
||||
const routeName = configBackend.routes.name;
|
||||
const isArchiveRoute = route().current(`${routeName}${modelName}.trash`);
|
||||
const actionModels = isArchiveRoute
|
||||
? [Views.RESTORATION, Views.FORCEDELETION]
|
||||
: [
|
||||
Views.VISUALIZATION,
|
||||
Views.EDITION,
|
||||
Views.DUPLICATION,
|
||||
Views.ARCHIVATION,
|
||||
];
|
||||
|
||||
const columns = getColumns(
|
||||
appEnv,
|
||||
modelName,
|
||||
routeName,
|
||||
viewAny,
|
||||
viewAnyPageConfigs,
|
||||
);
|
||||
|
||||
const transModel = useTrans(
|
||||
`uwil-dynamic-pages::models.classes.${modelName}`,
|
||||
{ choice: 2 },
|
||||
);
|
||||
const transArchiveTitle = useTrans(
|
||||
"uwil-backend::crud.helpers.archives_model",
|
||||
{
|
||||
params: { model: transModel },
|
||||
},
|
||||
);
|
||||
const transTitle = str(isArchiveRoute ? transArchiveTitle : transModel)
|
||||
.ucFirst()
|
||||
.value();
|
||||
const transBackToList = useTrans("uwil-backend::back.crud_back_to_list");
|
||||
const beforeNode = () => {
|
||||
return (
|
||||
isArchiveRoute &&
|
||||
viewAny && (
|
||||
<div className="flex justify-end items-center gap-x-1.5">
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
link={route(`${routeName}${modelName}.index`)}
|
||||
content={<ArrowLeftIcon />}
|
||||
tooltipContent={<p>{transBackToList}</p>}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
const transAccessToArchived = useTrans("uwil-backend::back.crud_access_to", {
|
||||
params: {
|
||||
target: useTrans("uwil-backend::back.archived_entries"),
|
||||
},
|
||||
});
|
||||
const transAddEntry = useTrans("uwil-backend::back.crud_add_entry");
|
||||
const afterNode = () => {
|
||||
return (
|
||||
!isArchiveRoute && (
|
||||
<div className="flex justify-end items-center gap-x-1.5">
|
||||
{viewTrash && (
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
link={route(`${routeName}${modelName}.trash`)}
|
||||
content={<ArchiveIcon />}
|
||||
tooltipContent={<p>{transAccessToArchived}</p>}
|
||||
variant="outline"
|
||||
/>
|
||||
)}
|
||||
{create && (
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
link={route(`${routeName}${modelName}.create`)}
|
||||
content={<PlusIcon />}
|
||||
tooltipContent={<p>{transAddEntry}</p>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
title={transTitle}
|
||||
beforeNode={beforeNode()}
|
||||
afterNode={afterNode()}
|
||||
>
|
||||
{isArchiveRoute && <SoftdeleteWarning />}
|
||||
<DataTableSearch
|
||||
modelRoute={modelName}
|
||||
prefixRoute={routeName}
|
||||
searchFields={searchFields}
|
||||
isArchiveRoute={isArchiveRoute}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
modelRoute={modelName}
|
||||
modelsPaginate={dynamicPageModels}
|
||||
actions={actionModels}
|
||||
/>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
314
resources/ts/back/Pages/DynamicPages/Show.tsx
Normal file
314
resources/ts/back/Pages/DynamicPages/Show.tsx
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
// * Packages
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CheckIcon,
|
||||
CogIcon,
|
||||
StickyNoteIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { usePage } from "@inertiajs/react";
|
||||
import { route } from "ziggy-js";
|
||||
// * Components shadcn
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
// * Other
|
||||
import AuthenticatedLayout from "@/back/Layouts/AuthenticatedLayout";
|
||||
import DetailsTable from "@/back/Components/Table/DetailsTable";
|
||||
import ModelActions from "@/back/Components/ModelActions";
|
||||
import DynamicStatusBadge from "@/back/Components/DynamicPages/DynamicStatusBadge";
|
||||
import RecursiveContent from "@/back/Components/DynamicPages/DynamicPageDetails";
|
||||
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
|
||||
import RenderTranslationCell from "@/back/Components/Table/RenderTranslationCell";
|
||||
import { GetFormatedDate, GetTranslation } from "@/lib/utils";
|
||||
import { Views } from "@/back/lib/actions";
|
||||
import { AppEnv } from "@/types/global";
|
||||
import { AuthProp, ConfigBackend, SingleResource } from "@/back/types/global";
|
||||
import {
|
||||
DynamicPageModelWithRelations,
|
||||
PageConfigModel,
|
||||
} from "@/back/types/dynamic-pages";
|
||||
import { TranslationField } from "@/back/types/i18n";
|
||||
import str from "@/hooks/use-string";
|
||||
import useTrans from "@/hooks/use-translations";
|
||||
|
||||
const getColumns = (
|
||||
appEnv: AppEnv,
|
||||
auth: AuthProp,
|
||||
modelName: string,
|
||||
routeName: string,
|
||||
dynamicPageModel: DynamicPageModelWithRelations,
|
||||
) => {
|
||||
return [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: str(GetTranslation("uwil-dynamic-pages::trans.attributes.title"))
|
||||
.ucFirst()
|
||||
.value(),
|
||||
cell: ({ value }: { value: TranslationField }) => (
|
||||
<RenderTranslationCell value={value} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "parent_page",
|
||||
header: str(
|
||||
GetTranslation("uwil-dynamic-pages::trans.attributes.parent_page"),
|
||||
)
|
||||
.ucFirst()
|
||||
.value(),
|
||||
cell: ({ value }: { value: DynamicPageModelWithRelations }) =>
|
||||
auth.policies.dynamicPages.viewAny ? (
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
link={route(`${routeName}${modelName}.show`, value)}
|
||||
content={
|
||||
<>
|
||||
<StickyNoteIcon />
|
||||
<span>{value.title[appEnv.locale]}</span>
|
||||
</>
|
||||
}
|
||||
tooltipContent={
|
||||
<p>
|
||||
{GetTranslation("uwil-dynamic-pages::trans.access_model", {
|
||||
params: {
|
||||
model: GetTranslation(
|
||||
`uwil-dynamic-pages::models.classes.${modelName}`,
|
||||
{
|
||||
choice: 1,
|
||||
},
|
||||
),
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
}
|
||||
type="button"
|
||||
size="default"
|
||||
variant="secondary"
|
||||
/>
|
||||
) : (
|
||||
<span>{value.title[appEnv.locale]}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "config",
|
||||
header: str(GetTranslation("uwil-dynamic-pages::trans.attributes.config"))
|
||||
.ucFirst()
|
||||
.value(),
|
||||
cell: ({ value }: { value: PageConfigModel }) =>
|
||||
auth.policies.pageConfigs.viewAny ? (
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
link={route(`${routeName}page-configs.show`, value)}
|
||||
content={
|
||||
<>
|
||||
<CogIcon />
|
||||
<span>{value.label[appEnv.locale]}</span>
|
||||
</>
|
||||
}
|
||||
tooltipContent={
|
||||
<p>
|
||||
{GetTranslation("uwil-dynamic-pages::trans.access_model", {
|
||||
params: {
|
||||
model: GetTranslation(
|
||||
"uwil-dynamic-pages::models.classes.page-configs",
|
||||
{
|
||||
choice: 1,
|
||||
},
|
||||
),
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
}
|
||||
type="button"
|
||||
size="default"
|
||||
variant="secondary"
|
||||
/>
|
||||
) : (
|
||||
<span>{value.label[appEnv.locale]}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "content",
|
||||
header: str(
|
||||
GetTranslation("uwil-dynamic-pages::trans.attributes.content"),
|
||||
)
|
||||
.ucFirst()
|
||||
.value(),
|
||||
cell: ({ value }: { value: TranslationField<object | string> }) => {
|
||||
if (
|
||||
typeof value[appEnv.locale] === "object" &&
|
||||
dynamicPageModel.config?.config?.[appEnv.locale]
|
||||
) {
|
||||
return (
|
||||
<RecursiveContent
|
||||
value={value[appEnv.locale]}
|
||||
config={dynamicPageModel.config.config[appEnv.locale]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof value[appEnv.locale] === "string" &&
|
||||
String(value[appEnv.locale]).trim().length > 0
|
||||
) {
|
||||
return <p>{String(value[appEnv.locale])}</p>;
|
||||
}
|
||||
return (
|
||||
<span className="text-sm italic text-muted-foreground">
|
||||
{GetTranslation("uwil-backend::back.table_not_known")}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: str(GetTranslation(`uwil-dynamic-pages::trans.attributes.status`))
|
||||
.ucFirst()
|
||||
.value(),
|
||||
cell: ({ value }: { value: number }) => (
|
||||
<DynamicStatusBadge value={value} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "isLocked",
|
||||
header: str(GetTranslation("uwil-dynamic-pages::trans.attributes.locked"))
|
||||
.ucFirst()
|
||||
.value(),
|
||||
cell: ({ value }: { value: boolean }) => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`border-0 ${value ? "bg-green-200 dark:bg-green-900" : "bg-red-200 dark:bg-red-900"}`}
|
||||
>
|
||||
{value ? (
|
||||
<CheckIcon size={15} className="mr-1" />
|
||||
) : (
|
||||
<XIcon size={15} className="mr-1" />
|
||||
)}
|
||||
{str(
|
||||
GetTranslation(
|
||||
value ? "uwil-backend::back.yes" : "uwil-backend::back.no",
|
||||
),
|
||||
)
|
||||
.ucFirst()
|
||||
.value()}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "isRoot",
|
||||
header: str(GetTranslation("uwil-dynamic-pages::trans.attributes.root"))
|
||||
.ucFirst()
|
||||
.value(),
|
||||
cell: ({ value }: { value: boolean }) => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`border-0 ${value ? "bg-green-200 dark:bg-green-900" : "bg-red-200 dark:bg-red-900"}`}
|
||||
>
|
||||
{value ? (
|
||||
<CheckIcon size={15} className="mr-1" />
|
||||
) : (
|
||||
<XIcon size={15} className="mr-1" />
|
||||
)}
|
||||
{str(
|
||||
GetTranslation(
|
||||
value ? "uwil-backend::back.yes" : "uwil-backend::back.no",
|
||||
),
|
||||
)
|
||||
.ucFirst()
|
||||
.value()}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: str(
|
||||
GetTranslation("uwil-dynamic-pages::trans.attributes.created_at"),
|
||||
)
|
||||
.ucFirst()
|
||||
.value(),
|
||||
cell: ({ value }: { value: string }) => (
|
||||
<Badge variant="secondary">{GetFormatedDate(value)}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: str(
|
||||
GetTranslation("uwil-dynamic-pages::trans.attributes.updated_at"),
|
||||
)
|
||||
.ucFirst()
|
||||
.value(),
|
||||
cell: ({ value }: { value: string }) => (
|
||||
<Badge variant="secondary">{GetFormatedDate(value)}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "deleted_at",
|
||||
header: str(
|
||||
GetTranslation("uwil-dynamic-pages::trans.attributes.deleted_at"),
|
||||
)
|
||||
.ucFirst()
|
||||
.value(),
|
||||
cell: ({ value }: { value: string }) => (
|
||||
<Badge variant="secondary">{GetFormatedDate(value)}</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export default function Show({
|
||||
data,
|
||||
modelName,
|
||||
}: {
|
||||
data: SingleResource;
|
||||
modelName: string;
|
||||
}) {
|
||||
const { auth, appEnv, configBackend } = usePage().props as unknown as {
|
||||
auth: AuthProp;
|
||||
appEnv: AppEnv;
|
||||
configBackend: ConfigBackend;
|
||||
};
|
||||
const { viewAny } = auth.policies.dynamicPages;
|
||||
const routeName = configBackend.routes.name;
|
||||
const dynamicPageModel = data.data as DynamicPageModelWithRelations;
|
||||
const actionModels = [Views.EDITION, Views.DUPLICATION, Views.ARCHIVATION];
|
||||
const columns = getColumns(
|
||||
appEnv,
|
||||
auth,
|
||||
modelName,
|
||||
routeName,
|
||||
dynamicPageModel,
|
||||
);
|
||||
const transTitle = useTrans(
|
||||
"uwil-backend::crud.helpers.visualization_model",
|
||||
{
|
||||
params: {
|
||||
model: useTrans(`uwil-dynamic-pages::models.classes.${modelName}`, {
|
||||
choice: 1,
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const transBackToList = useTrans("uwil-backend::back.crud_back_to_list");
|
||||
const beforeNode = () => {
|
||||
return (
|
||||
viewAny && (
|
||||
<div className="flex justify-end items-center gap-x-1.5">
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
link={route(`${routeName}${modelName}.index`)}
|
||||
content={<ArrowLeftIcon />}
|
||||
tooltipContent={<p>{transBackToList}</p>}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout title={transTitle} beforeNode={beforeNode()}>
|
||||
<ModelActions
|
||||
actions={actionModels}
|
||||
modelRoute={modelName}
|
||||
model={dynamicPageModel}
|
||||
modelUpdatedAt={dynamicPageModel.updated_at?.toString()}
|
||||
/>
|
||||
{/* @ts-expect-error Weird error type on cell. */}
|
||||
<DetailsTable columns={columns} model={dynamicPageModel} />
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
66
resources/ts/back/Pages/PageConfigs/Create.tsx
Normal file
66
resources/ts/back/Pages/PageConfigs/Create.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// * Packages
|
||||
import { usePage } from "@inertiajs/react";
|
||||
import { route } from "ziggy-js";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
// * Other
|
||||
import AuthenticatedLayout from "@/back/Layouts/AuthenticatedLayout";
|
||||
import Form from "@/back/Pages/PageConfigs/Form";
|
||||
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
|
||||
import useTrans from "@/hooks/use-translations";
|
||||
import { Actions, Methods } from "@/back/lib/enums";
|
||||
import { AuthProp, ConfigBackend, SingleResource } from "@/back/types/global";
|
||||
import { PageConfigModelWithRelations } from "@/back/types/dynamic-pages";
|
||||
|
||||
export default function Create({
|
||||
data,
|
||||
modelName,
|
||||
}: {
|
||||
data: SingleResource;
|
||||
modelName: string;
|
||||
}) {
|
||||
const routeName = (usePage().props.configBackend as ConfigBackend).routes
|
||||
.name;
|
||||
const { viewAny } = (usePage().props.auth as AuthProp).policies.pageConfigs;
|
||||
const pageConfigModel = data.data as PageConfigModelWithRelations;
|
||||
|
||||
// Translations.
|
||||
const transBackToList = useTrans("uwil-backend::back.crud_back_to_list");
|
||||
const transTitle = useTrans(
|
||||
route().current(`${routeName}${modelName}.duplicate`)
|
||||
? "uwil-backend::crud.helpers.duplication_model"
|
||||
: "uwil-backend::crud.helpers.creation_model",
|
||||
{
|
||||
params: {
|
||||
model: useTrans(`uwil-dynamic-pages::models.classes.${modelName}`, {
|
||||
choice: 1,
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const beforeNode = () => {
|
||||
return (
|
||||
viewAny && (
|
||||
<div className="flex justify-end items-center gap-x-1.5">
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
link={route(`${routeName}${modelName}.index`)}
|
||||
content={<ArrowLeftIcon />}
|
||||
tooltipContent={<p>{transBackToList}</p>}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout title={transTitle} beforeNode={beforeNode()}>
|
||||
<Form
|
||||
model={pageConfigModel}
|
||||
modelName={modelName}
|
||||
modelRoute={modelName}
|
||||
action={Actions.STORE}
|
||||
method={Methods.POST}
|
||||
/>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
74
resources/ts/back/Pages/PageConfigs/Edit.tsx
Normal file
74
resources/ts/back/Pages/PageConfigs/Edit.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// * Packages
|
||||
import { usePage } from "@inertiajs/react";
|
||||
import { route } from "ziggy-js";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
// * Other
|
||||
import AuthenticatedLayout from "@/back/Layouts/AuthenticatedLayout";
|
||||
import ModelActions from "@/back/Components/ModelActions";
|
||||
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
|
||||
import Form from "@/back/Pages/PageConfigs/Form";
|
||||
import useTrans from "@/hooks/use-translations";
|
||||
import { Actions, Methods } from "@/back/lib/enums";
|
||||
import { Views } from "@/back/lib/actions";
|
||||
import { AuthProp, ConfigBackend, SingleResource } from "@/back/types/global";
|
||||
import { PageConfigModelWithRelations } from "@/back/types/dynamic-pages";
|
||||
|
||||
export default function Edit({
|
||||
data,
|
||||
modelName,
|
||||
}: {
|
||||
data: SingleResource;
|
||||
modelName: string;
|
||||
}) {
|
||||
const routeName = (usePage().props.configBackend as ConfigBackend).routes
|
||||
.name;
|
||||
const { viewAny } = (usePage().props.auth as AuthProp).policies.pageConfigs;
|
||||
const pageConfigModel = data.data as PageConfigModelWithRelations;
|
||||
const actionModels = [
|
||||
Views.VISUALIZATION,
|
||||
Views.DUPLICATION,
|
||||
Views.ARCHIVATION,
|
||||
];
|
||||
|
||||
// Translations.
|
||||
const transBackToList = useTrans("uwil-backend::back.crud_back_to_list");
|
||||
const transTitle = useTrans("uwil-backend::crud.helpers.edition_model", {
|
||||
params: {
|
||||
model: useTrans(`uwil-dynamic-pages::models.classes.${modelName}`, {
|
||||
choice: 1,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const beforeNode = () => {
|
||||
return (
|
||||
viewAny && (
|
||||
<div className="flex justify-end items-center gap-x-1.5">
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
link={route(`${routeName}${modelName}.index`)}
|
||||
content={<ArrowLeftIcon />}
|
||||
tooltipContent={<p>{transBackToList}</p>}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout title={transTitle} beforeNode={beforeNode()}>
|
||||
<ModelActions
|
||||
actions={actionModels}
|
||||
modelRoute={modelName}
|
||||
model={pageConfigModel}
|
||||
modelUpdatedAt={pageConfigModel.updated_at?.toString()}
|
||||
/>
|
||||
<Form
|
||||
model={pageConfigModel}
|
||||
modelName={modelName}
|
||||
modelRoute={modelName}
|
||||
action={Actions.UPDATE}
|
||||
method={Methods.PATCH}
|
||||
/>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
391
resources/ts/back/Pages/PageConfigs/Form.tsx
Normal file
391
resources/ts/back/Pages/PageConfigs/Form.tsx
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
// * Packages
|
||||
import { useForm, usePage } from "@inertiajs/react";
|
||||
import { BookTextIcon, CogIcon, InfoIcon } from "lucide-react";
|
||||
import { route } from "ziggy-js";
|
||||
import { useEffect, useState } from "react";
|
||||
// * Components shadcn
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
// * Other
|
||||
import PageConfigDynamicFields from "@/back/Components/PageConfigs/DynamicFields";
|
||||
import ExportConfigJson from "@/back/Components/PageConfigs/ExportConfigJson";
|
||||
import ImportConfigJson from "@/back/Components/PageConfigs/ImportConfigJson";
|
||||
import SimpleString from "@/back/Components/Inputs/SimpleString";
|
||||
import FieldsetWrapper from "@/back/Components/FieldsetWrapper";
|
||||
import useTrans from "@/hooks/use-translations";
|
||||
import str from "@/hooks/use-string";
|
||||
import InputError from "@/back/Components/InputError";
|
||||
import { AppEnv } from "@/types/global";
|
||||
import { AuthProp, ConfigBackend, UserModel } from "@/back/types/global";
|
||||
import {
|
||||
PageConfigModelWithRelations,
|
||||
ConfigField,
|
||||
} from "@/back/types/dynamic-pages";
|
||||
import SubmitButton from "@/back/Components/SubmitButton";
|
||||
import useCrudSubmit from "@/back/hooks/use-crud-submit";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function Form({
|
||||
model,
|
||||
modelName,
|
||||
modelRoute,
|
||||
action,
|
||||
method,
|
||||
}: {
|
||||
model?: PageConfigModelWithRelations;
|
||||
modelName: string;
|
||||
modelRoute: string;
|
||||
action: string;
|
||||
method: number;
|
||||
}) {
|
||||
const { appEnv, auth, enumUserRole, configBackend } = usePage()
|
||||
.props as unknown as {
|
||||
appEnv: AppEnv;
|
||||
auth: AuthProp;
|
||||
enumUserRole: UserModel["role"][];
|
||||
configBackend: ConfigBackend;
|
||||
};
|
||||
const getEnumRoleValue = (name: string) =>
|
||||
enumUserRole.find((role) => role.name === name)?.value;
|
||||
const isConceptor = auth.user.role.value === getEnumRoleValue("conceptor");
|
||||
|
||||
// Hack to actualize states.
|
||||
const [resetState, setResetState] = useState<number>(1);
|
||||
|
||||
/** Get configs in others locales */
|
||||
const [selectedConfig, setSelectedConfig] = useState<string | null>(null);
|
||||
const otherConfigs = Object.entries(model!.config ?? {}).filter(
|
||||
([configLocale]) => configLocale !== appEnv.locale,
|
||||
);
|
||||
|
||||
// Translations.
|
||||
const transSelectConfigLocale = useTrans(
|
||||
"uwil-dynamic-pages::trans.config_select_fields",
|
||||
);
|
||||
const transDialogTitle = useTrans("uwil-backend::back.are_you_sure");
|
||||
const transDialogParagraph = useTrans(
|
||||
"uwil-dynamic-pages::trans.config_save_warning",
|
||||
{
|
||||
params: { locale: str(appEnv.locale).upperCase().value() },
|
||||
},
|
||||
);
|
||||
const transDialogParagraphList = useTrans(
|
||||
"uwil-dynamic-pages::trans.config_save_warning_list",
|
||||
{
|
||||
params: {
|
||||
models: useTrans(`uwil-dynamic-pages::models.classes.${modelName}`, {
|
||||
choice: 2,
|
||||
}),
|
||||
},
|
||||
choice: 2,
|
||||
},
|
||||
);
|
||||
const transDialogCancel = useTrans("uwil-backend::back.no_cancel");
|
||||
const transDialogSave = useTrans("uwil-backend::back.yes_update");
|
||||
const transJsonExportTooltip = useTrans(
|
||||
"uwil-dynamic-pages::trans.json_editor.export_tooltip",
|
||||
);
|
||||
const transModelName = useTrans(
|
||||
`uwil-dynamic-pages::models.classes.${modelName}`,
|
||||
{
|
||||
choice: 1,
|
||||
},
|
||||
);
|
||||
const transSubmitBtn = useTrans(
|
||||
`uwil-dynamic-pages::models.classes.${modelName}`,
|
||||
{
|
||||
choice: 1,
|
||||
},
|
||||
);
|
||||
/** Form section */
|
||||
const { post, setData, data } = useForm({
|
||||
label: model?.label ? model?.label[appEnv.locale] : "",
|
||||
view: model?.view,
|
||||
config: model?.config ? (model?.config[appEnv.locale] ?? []) : [],
|
||||
});
|
||||
|
||||
const submit = useCrudSubmit({
|
||||
method,
|
||||
modelRoute,
|
||||
modelName,
|
||||
action,
|
||||
model,
|
||||
post,
|
||||
data,
|
||||
modelNameTrans: useTrans(
|
||||
`uwil-dynamic-pages::models.classes.${modelName}`,
|
||||
{
|
||||
choice: 1,
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
const resetConfig = (localeData: string) => {
|
||||
const customLocaleData = str(localeData).lowerCase().value();
|
||||
setData(
|
||||
"config",
|
||||
model?.config ? (model?.config[customLocaleData] ?? []) : [],
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if auth user must be notified when the config is updated.
|
||||
* @return boolean
|
||||
*/
|
||||
const mustBeNotified = () => {
|
||||
return (
|
||||
!route().current(`${configBackend.routes.name}${modelName}.create`) &&
|
||||
model?.pages &&
|
||||
model?.pages.length &&
|
||||
data.config !== model?.config[appEnv.locale]
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const isEmpty =
|
||||
data.config === null ||
|
||||
(typeof data.config === "object" &&
|
||||
Object.keys(data.config).length === 0);
|
||||
|
||||
if (isEmpty && selectedConfig !== null) {
|
||||
setSelectedConfig(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data.config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedConfig !== null) {
|
||||
const lowerSelectedLocale = str(selectedConfig ?? "FR")
|
||||
.slug()
|
||||
.lowerCase()
|
||||
.value();
|
||||
setData("config", model?.config[lowerSelectedLocale] ?? []);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedConfig]);
|
||||
|
||||
return (
|
||||
<form id="config-form" onSubmit={submit} className="grid grid-cols-2 gap-4">
|
||||
<InputError all />
|
||||
<FieldsetWrapper
|
||||
title={useTrans("uwil-backend::back.form_title_global")}
|
||||
icon={<BookTextIcon className="mr-1" />}
|
||||
description={useTrans("uwil-backend::back.form_subtitle_global")}
|
||||
className="sm:grid-cols-2 gap-4"
|
||||
>
|
||||
<SimpleString
|
||||
name="label"
|
||||
label={str(
|
||||
useTrans("uwil-backend::back.form_associate_to", {
|
||||
params: {
|
||||
attribute: useTrans(
|
||||
"uwil-dynamic-pages::trans.attributes.label",
|
||||
),
|
||||
},
|
||||
choice: 1,
|
||||
}),
|
||||
)
|
||||
.ucFirst()
|
||||
.value()}
|
||||
placeholder="ex: John Doe"
|
||||
value={data.label}
|
||||
setData={setData}
|
||||
/>
|
||||
<SimpleString
|
||||
name="view"
|
||||
label={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>
|
||||
{str(
|
||||
useTrans("uwil-backend::back.form_associate_to", {
|
||||
params: {
|
||||
attribute: useTrans(
|
||||
"uwil-dynamic-pages::trans.attributes.view",
|
||||
),
|
||||
},
|
||||
choice: 2,
|
||||
}),
|
||||
)
|
||||
.ucFirst()
|
||||
.value()}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon size={15} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="space-y-1 text-center">
|
||||
<p>
|
||||
{useTrans(
|
||||
"uwil-dynamic-pages::trans.respect_format_page",
|
||||
)}
|
||||
</p>
|
||||
<code className="bg-muted px-2 py-1 rounded text-xs">
|
||||
front/Pages/FileName
|
||||
</code>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
placeholder="ex: John Doe"
|
||||
pattern="^front\/Pages\/[A-Z][a-zA-Z0-9]+$"
|
||||
required={false}
|
||||
value={data.view ?? ""}
|
||||
setData={setData}
|
||||
/>
|
||||
</FieldsetWrapper>
|
||||
<FieldsetWrapper
|
||||
title={useTrans("uwil-dynamic-pages::trans.form_title_config")}
|
||||
icon={<CogIcon className="mr-1" />}
|
||||
description={useTrans("uwil-dynamic-pages::trans.form_subtitle_config")}
|
||||
>
|
||||
{/* Import configuration from json file. */}
|
||||
{isConceptor && (
|
||||
<div
|
||||
className={cn("mb-3", {
|
||||
"border-b pb-3 mb-3": !route().current(
|
||||
`${configBackend.routes.name}${modelName}.edit`,
|
||||
),
|
||||
})}
|
||||
>
|
||||
<ImportConfigJson
|
||||
onConfirm={(value, overwrite) => {
|
||||
const newConfig = !overwrite
|
||||
? [...data.config, ...value]
|
||||
: value;
|
||||
setData("config", newConfig as ConfigField[]);
|
||||
setResetState((prev) => prev + 1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Show only on editing page. */}
|
||||
{route().current(`${configBackend.routes.name}${modelName}.edit`) && (
|
||||
<div className="flex justify-center items-center border-b pb-3 mb-3 gap-1.5">
|
||||
{/* Select config from another locale */}
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
setSelectedConfig(value);
|
||||
resetConfig(value);
|
||||
}}
|
||||
disabled={!otherConfigs.length}
|
||||
>
|
||||
<SelectTrigger className="text-muted-foreground w-full">
|
||||
<SelectValue placeholder={transSelectConfigLocale} />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
className="w-[var(--radix-select-trigger-width)]"
|
||||
>
|
||||
{otherConfigs.map(([configLocale, config]) => {
|
||||
const strConfigLocale = str(configLocale).upperCase().value();
|
||||
|
||||
const localeFields = Object.values(config)
|
||||
.map((field) => field.label)
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<SelectItem
|
||||
key={strConfigLocale}
|
||||
value={strConfigLocale}
|
||||
className="truncate"
|
||||
title={`${strConfigLocale}: ${localeFields}`}
|
||||
>
|
||||
<b>{strConfigLocale}:</b> {localeFields}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Button to export config */}
|
||||
{isConceptor && (
|
||||
<ExportConfigJson
|
||||
data={data.config ?? []}
|
||||
filename={`${str(data.label ?? "configuration")
|
||||
.slug()
|
||||
.value()}.json`}
|
||||
tooltipContent={transJsonExportTooltip}
|
||||
disabled={!data.config.length}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<PageConfigDynamicFields
|
||||
key={resetState}
|
||||
name="config"
|
||||
value={data.config}
|
||||
setData={setData}
|
||||
selectedConfig={selectedConfig}
|
||||
/>
|
||||
</FieldsetWrapper>
|
||||
<div className="col-span-2 text-center">
|
||||
{mustBeNotified() ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<div className="w-fit mx-auto">
|
||||
<SubmitButton isInDialog modelTrans={transModelName} />
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{transDialogTitle}</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="flex flex-col gap-y-1.5">
|
||||
<p>{transDialogParagraph}</p>
|
||||
<p>{transDialogParagraphList}</p>
|
||||
<div className="max-h-[20rem] overflow-y-auto">
|
||||
<ul className="list-disc pl-6 space-y-1">
|
||||
{model?.pages.map((page) => (
|
||||
<li key={page.id}>
|
||||
{page.title[appEnv.locale] ??
|
||||
page.title[appEnv.fallbackLocale]}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="items-center sm:justify-start">
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary" type="button">
|
||||
{transDialogCancel}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button form="config-form" type="submit">
|
||||
{transDialogSave}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<SubmitButton modelTrans={transSubmitBtn} />
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
189
resources/ts/back/Pages/PageConfigs/Index.tsx
Normal file
189
resources/ts/back/Pages/PageConfigs/Index.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
// * Packages
|
||||
import { route } from "ziggy-js";
|
||||
import { usePage } from "@inertiajs/react";
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import { ArchiveIcon, ArrowLeftIcon, PlusIcon } from "lucide-react";
|
||||
// * Components shadcn
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
// * Other
|
||||
import AuthenticatedLayout from "@/back/Layouts/AuthenticatedLayout";
|
||||
import DataTable from "@/back/Components/Table/DataTable";
|
||||
import DataTableSearch from "@/back/Components/Table/DataTableSearch";
|
||||
import DataTableFilterButton from "@/back/Components/Table/DataTableFilterButton";
|
||||
import SoftdeleteWarning from "@/back/Components/SoftdeleteWarning";
|
||||
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
|
||||
import RenderTranslationCell from "@/back/Components/Table/RenderTranslationCell";
|
||||
import { GetFormatedDate, GetTranslation } from "@/lib/utils";
|
||||
import { Views } from "@/back/lib/actions";
|
||||
import { AuthProp, ConfigBackend, Resource } from "@/back/types/global";
|
||||
import { PageConfigModel } from "@/back/types/dynamic-pages";
|
||||
import str from "@/hooks/use-string";
|
||||
import useTrans from "@/hooks/use-translations";
|
||||
|
||||
const getColumns = () => {
|
||||
return [
|
||||
{
|
||||
accessorKey: "label",
|
||||
header: ({ column }: { column: { id: string } }) => (
|
||||
<DataTableFilterButton
|
||||
label={str(
|
||||
GetTranslation(`uwil-dynamic-pages::trans.attributes.${column.id}`),
|
||||
)
|
||||
.ucFirst()
|
||||
.value()}
|
||||
field={column.id}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }: { row: Row<PageConfigModel> }) => (
|
||||
<RenderTranslationCell value={row.getValue("label")} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "view",
|
||||
header: ({ column }: { column: { id: string } }) => (
|
||||
<DataTableFilterButton
|
||||
label={str(
|
||||
GetTranslation(`uwil-dynamic-pages::trans.attributes.${column.id}`),
|
||||
)
|
||||
.ucFirst()
|
||||
.value()}
|
||||
field={column.id}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }: { row: Row<PageConfigModel> }) =>
|
||||
row.getValue("view") ? (
|
||||
<code>{row.getValue("view")}</code>
|
||||
) : (
|
||||
<p className="text-muted-foreground italic m-0">
|
||||
{GetTranslation("uwil-backend::back.table_not_known")}
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: ({ column }: { column: { id: string } }) => (
|
||||
<DataTableFilterButton
|
||||
label={str(
|
||||
GetTranslation(`uwil-dynamic-pages::trans.attributes.${column.id}`),
|
||||
)
|
||||
.ucFirst()
|
||||
.value()}
|
||||
field={column.id}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }: { row: Row<PageConfigModel> }) => (
|
||||
<Badge variant="secondary">
|
||||
{GetFormatedDate(row.getValue("updated_at"))}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export default function Index({
|
||||
pageConfigModels,
|
||||
searchFields,
|
||||
modelName,
|
||||
}: {
|
||||
pageConfigModels: Resource;
|
||||
searchFields: string;
|
||||
modelName: string;
|
||||
}) {
|
||||
const { auth, configBackend } = usePage().props as unknown as {
|
||||
auth: AuthProp;
|
||||
configBackend: ConfigBackend;
|
||||
};
|
||||
const { viewAny, viewTrash, create } = auth.policies.pageConfigs;
|
||||
const routeName = configBackend.routes.name;
|
||||
const isArchiveRoute = route().current(`${routeName}${modelName}.trash`);
|
||||
const actionModels = isArchiveRoute
|
||||
? [Views.RESTORATION, Views.FORCEDELETION]
|
||||
: [
|
||||
Views.VISUALIZATION,
|
||||
Views.EDITION,
|
||||
Views.DUPLICATION,
|
||||
Views.ARCHIVATION,
|
||||
];
|
||||
const columns = getColumns();
|
||||
|
||||
const transModel = useTrans(
|
||||
`uwil-dynamic-pages::models.classes.${modelName}`,
|
||||
{ choice: 2 },
|
||||
);
|
||||
const transArchiveTitle = useTrans(
|
||||
"uwil-backend::crud.helpers.archives_model",
|
||||
{
|
||||
params: { model: transModel },
|
||||
},
|
||||
);
|
||||
const transTitle = str(isArchiveRoute ? transArchiveTitle : transModel)
|
||||
.ucFirst()
|
||||
.value();
|
||||
|
||||
const transCrudBackList = useTrans("uwil-backend::back.crud_back_to_list");
|
||||
const beforeNode = () => {
|
||||
return (
|
||||
isArchiveRoute &&
|
||||
viewAny && (
|
||||
<div className="flex justify-end items-center gap-x-1.5">
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
link={route(`${routeName}${modelName}.index`)}
|
||||
content={<ArrowLeftIcon />}
|
||||
tooltipContent={<p>{transCrudBackList}</p>}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
const transCrudAddEntry = useTrans("uwil-backend::back.crud_add_entry");
|
||||
const transAccessTo = useTrans("uwil-backend::back.crud_access_to", {
|
||||
params: {
|
||||
target: useTrans("uwil-backend::back.archived_entries"),
|
||||
},
|
||||
});
|
||||
const afterNode = () => {
|
||||
return (
|
||||
!isArchiveRoute && (
|
||||
<div className="flex justify-end items-center gap-x-1.5">
|
||||
{viewTrash && (
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
link={route(`${routeName}${modelName}.trash`)}
|
||||
content={<ArchiveIcon />}
|
||||
tooltipContent={<p>{transAccessTo}</p>}
|
||||
variant="outline"
|
||||
/>
|
||||
)}
|
||||
{create && (
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
link={route(`${routeName}${modelName}.create`)}
|
||||
content={<PlusIcon />}
|
||||
tooltipContent={<p>{transCrudAddEntry}</p>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
title={transTitle}
|
||||
beforeNode={beforeNode()}
|
||||
afterNode={afterNode()}
|
||||
>
|
||||
{isArchiveRoute && <SoftdeleteWarning />}
|
||||
<DataTableSearch
|
||||
modelRoute={modelName}
|
||||
prefixRoute={routeName}
|
||||
searchFields={searchFields}
|
||||
isArchiveRoute={isArchiveRoute}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
modelRoute={modelName}
|
||||
modelsPaginate={pageConfigModels}
|
||||
actions={actionModels}
|
||||
/>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
145
resources/ts/back/Pages/PageConfigs/Show.tsx
Normal file
145
resources/ts/back/Pages/PageConfigs/Show.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
// * Packages
|
||||
import { usePage } from "@inertiajs/react";
|
||||
import { route } from "ziggy-js";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
// * Components shadcn
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
// * Other
|
||||
import AuthenticatedLayout from "@/back/Layouts/AuthenticatedLayout";
|
||||
import DetailsTable from "@/back/Components/Table/DetailsTable";
|
||||
import ModelActions from "@/back/Components/ModelActions";
|
||||
import ConfigDetails from "@/back/Components/PageConfigs/ConfigDetails";
|
||||
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
|
||||
import RenderTranslationCell from "@/back/Components/Table/RenderTranslationCell";
|
||||
import { GetFormatedDate, GetTranslation } from "@/lib/utils";
|
||||
import { Views } from "@/back/lib/actions";
|
||||
import { AppEnv } from "@/types/global";
|
||||
import { AuthProp, ConfigBackend, SingleResource } from "@/back/types/global";
|
||||
import { ConfigField, PageConfigModel } from "@/back/types/dynamic-pages";
|
||||
import { TranslationField } from "@/back/types/i18n";
|
||||
import str from "@/hooks/use-string";
|
||||
import useTrans from "@/hooks/use-translations";
|
||||
|
||||
const getColumns = (appEnv: AppEnv) => {
|
||||
return [
|
||||
{
|
||||
accessorKey: "label",
|
||||
header: str(GetTranslation("uwil-dynamic-pages::trans.attributes.label"))
|
||||
.ucFirst()
|
||||
.value(),
|
||||
cell: ({ value }: { value: TranslationField }) => (
|
||||
<RenderTranslationCell value={value} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "view",
|
||||
header: str(GetTranslation("uwil-dynamic-pages::trans.attributes.view"))
|
||||
.ucFirst()
|
||||
.value(),
|
||||
cell: ({ value }: { value: string | null }) => <code>{value}</code>,
|
||||
},
|
||||
{
|
||||
accessorKey: "config",
|
||||
header: str(GetTranslation("uwil-dynamic-pages::trans.attributes.config"))
|
||||
.ucFirst()
|
||||
.value(),
|
||||
cell: ({ value }: { value: TranslationField<ConfigField[]> }) =>
|
||||
value[appEnv.locale] ? (
|
||||
<ConfigDetails config={value[appEnv.locale]} />
|
||||
) : (
|
||||
<p className="text-muted-foreground italic m-0">
|
||||
{GetTranslation("uwil-backend::back.table_not_known")}
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: str(
|
||||
GetTranslation("uwil-dynamic-pages::trans.attributes.created_at"),
|
||||
)
|
||||
.ucFirst()
|
||||
.value(),
|
||||
cell: ({ value }: { value: string }) => (
|
||||
<Badge variant="secondary">{GetFormatedDate(value)}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: str(
|
||||
GetTranslation("uwil-dynamic-pages::trans.attributes.updated_at"),
|
||||
)
|
||||
.ucFirst()
|
||||
.value(),
|
||||
cell: ({ value }: { value: string }) => (
|
||||
<Badge variant="secondary">{GetFormatedDate(value)}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "deleted_at",
|
||||
header: str(
|
||||
GetTranslation("uwil-dynamic-pages::trans.attributes.deleted_at"),
|
||||
)
|
||||
.ucFirst()
|
||||
.value(),
|
||||
cell: ({ value }: { value: string }) => (
|
||||
<Badge variant="secondary">{GetFormatedDate(value)}</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export default function Show({
|
||||
data,
|
||||
modelName,
|
||||
}: {
|
||||
data: SingleResource;
|
||||
modelName: string;
|
||||
}) {
|
||||
const { auth, appEnv, configBackend } = usePage().props as unknown as {
|
||||
auth: AuthProp;
|
||||
appEnv: AppEnv;
|
||||
configBackend: ConfigBackend;
|
||||
};
|
||||
const { viewAny } = auth.policies.pageConfigs;
|
||||
const pageConfigModel = data.data as PageConfigModel;
|
||||
const actionModels = [Views.EDITION, Views.DUPLICATION, Views.ARCHIVATION];
|
||||
const transTitle = useTrans(
|
||||
"uwil-backend::crud.helpers.visualization_model",
|
||||
{
|
||||
params: {
|
||||
model: useTrans(`uwil-dynamic-pages::models.classes.${modelName}`, {
|
||||
choice: 1,
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
const columns = getColumns(appEnv);
|
||||
|
||||
const transCrudBackToList = useTrans("uwil-backend::back.crud_back_to_list");
|
||||
const beforeNode = () => {
|
||||
return (
|
||||
viewAny && (
|
||||
<div className="flex justify-end items-center gap-x-1.5">
|
||||
<BtnOptionnalLinkWithTooltip
|
||||
link={route(`${configBackend.routes.name}${modelName}.index`)}
|
||||
content={<ArrowLeftIcon />}
|
||||
tooltipContent={<p>{transCrudBackToList}</p>}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout title={transTitle} beforeNode={beforeNode()}>
|
||||
<ModelActions
|
||||
actions={actionModels}
|
||||
modelRoute={modelName}
|
||||
model={pageConfigModel}
|
||||
modelUpdatedAt={pageConfigModel.updated_at?.toString()}
|
||||
/>
|
||||
{/* @ts-expect-error Weird error type on cell. */}
|
||||
<DetailsTable columns={columns} model={pageConfigModel} />
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
65
resources/ts/back/types/dynamic-pages.d.ts
vendored
Normal file
65
resources/ts/back/types/dynamic-pages.d.ts
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// * Other
|
||||
import { DefaultModel } from "@/back/types/global";
|
||||
import { TranslationField } from "@/back/types/i18n";
|
||||
|
||||
// Dynamic Page type, with policies.
|
||||
type DynamicPageModel = DefaultModel & {
|
||||
id_key: string;
|
||||
title: TranslationField;
|
||||
slug: TranslationField;
|
||||
content: TranslationField<string | boolean | number> | null;
|
||||
page_id: number;
|
||||
config_id: number;
|
||||
status: number;
|
||||
isLocked: boolean;
|
||||
isRoot: boolean;
|
||||
};
|
||||
|
||||
type DynamicFieldType = Record<
|
||||
string,
|
||||
{
|
||||
label: string;
|
||||
name: string;
|
||||
value: number;
|
||||
validation: string;
|
||||
}
|
||||
>;
|
||||
|
||||
type DynamicInternalField = {
|
||||
id?: number;
|
||||
value: string | number | boolean | File | Date;
|
||||
order: number;
|
||||
};
|
||||
|
||||
// Page config types
|
||||
type ConfigField = {
|
||||
key: string;
|
||||
type: number;
|
||||
label: string;
|
||||
validation: string;
|
||||
order: number;
|
||||
children?: ConfigField[];
|
||||
};
|
||||
|
||||
type ConfigInternalField = Omit<ConfigField, "children"> & {
|
||||
id: number;
|
||||
children?: ConfigInternalField[];
|
||||
};
|
||||
|
||||
type PageConfigModel = DefaultModel & {
|
||||
label: TranslationField;
|
||||
config: TranslationField<ConfigField[]>;
|
||||
view: string | null;
|
||||
status: number;
|
||||
};
|
||||
|
||||
// Relations.
|
||||
type PageConfigModelWithRelations = PageConfigModel & {
|
||||
pages: DynamicPageModel[];
|
||||
};
|
||||
|
||||
type DynamicPageModelWithRelations = DynamicPageModel & {
|
||||
config: PageConfigModel | null;
|
||||
parent_page: DynamicPageModel | null;
|
||||
children_page: DynamicPageModel[];
|
||||
};
|
||||
239
resources/ts/front/Pages/DynamicPageDefault.tsx
Normal file
239
resources/ts/front/Pages/DynamicPageDefault.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
// * Packages
|
||||
import { ReactNode } from "react";
|
||||
import { Head, usePage } from "@inertiajs/react";
|
||||
import { CheckIcon, XIcon } from "lucide-react";
|
||||
// * Other
|
||||
import {
|
||||
ConfigField,
|
||||
DynamicFieldType,
|
||||
DynamicPageModelWithRelations,
|
||||
} from "@/back/types/dynamic-pages";
|
||||
import { AppEnv } from "@/types/global";
|
||||
import { useFormatedDate } from "@/hooks/use-formated-value";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type DynamicPageDefaultProps = {
|
||||
dynamicPageModel: DynamicPageModelWithRelations;
|
||||
};
|
||||
|
||||
export default function DynamicPageDefault({
|
||||
dynamicPageModel,
|
||||
}: DynamicPageDefaultProps) {
|
||||
const { url, locale, fallbackLocale } = usePage().props.appEnv as AppEnv;
|
||||
const enumDynamicFieldTypes = usePage().props
|
||||
.enumDynamicFieldTypes as DynamicFieldType;
|
||||
|
||||
const currentLocaleContent =
|
||||
dynamicPageModel.content?.[locale] ??
|
||||
dynamicPageModel.content?.[fallbackLocale];
|
||||
|
||||
const currentLocaleConfig = dynamicPageModel.config
|
||||
? (dynamicPageModel.config.config[locale] ??
|
||||
dynamicPageModel.config.config[fallbackLocale])
|
||||
: null;
|
||||
|
||||
const currentLocaleTitle =
|
||||
dynamicPageModel.title[locale] ?? dynamicPageModel.title[fallbackLocale];
|
||||
|
||||
const joinUrl = (...parts: string[]): string =>
|
||||
parts.map((part) => part.replace(/^\/+|\/+$/g, "")).join("/");
|
||||
|
||||
const renderField = (key: string, value: unknown): ReactNode => {
|
||||
const configField = currentLocaleConfig?.find(
|
||||
(item: ConfigField) => item.key === key,
|
||||
);
|
||||
|
||||
if (!configField) {
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
|
||||
switch (configField.type) {
|
||||
// Wysiwyg case.
|
||||
case enumDynamicFieldTypes.wysiwyg.value:
|
||||
return (
|
||||
<div
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: String(value) }}
|
||||
/>
|
||||
);
|
||||
// Number case.
|
||||
case enumDynamicFieldTypes.number.value:
|
||||
return <span>{Number(value).toLocaleString(locale)}</span>;
|
||||
// Boolean case.
|
||||
case enumDynamicFieldTypes.boolean.value:
|
||||
return (
|
||||
<span className={value ? "text-green-600" : "text-red-600"}>
|
||||
{value ? <CheckIcon /> : <XIcon />}
|
||||
</span>
|
||||
);
|
||||
// Link case.
|
||||
case enumDynamicFieldTypes.url.value:
|
||||
return (
|
||||
<a
|
||||
href={String(value)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600"
|
||||
>
|
||||
{String(value)}
|
||||
</a>
|
||||
);
|
||||
// E-mail case.
|
||||
case enumDynamicFieldTypes.email.value:
|
||||
return (
|
||||
<a
|
||||
href={`mailto:${String(value)}`}
|
||||
className="underline text-blue-600"
|
||||
>
|
||||
{String(value)}
|
||||
</a>
|
||||
);
|
||||
// Image case.
|
||||
case enumDynamicFieldTypes.image.value:
|
||||
return (
|
||||
<img
|
||||
src={joinUrl(url, String(value))}
|
||||
alt={key}
|
||||
className="max-w-xs max-h-48 object-contain"
|
||||
/>
|
||||
);
|
||||
// File case.
|
||||
case enumDynamicFieldTypes.file.value:
|
||||
return (
|
||||
<a
|
||||
href={joinUrl(url, String(value))}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600"
|
||||
>
|
||||
{String(value).split("/").pop()}
|
||||
</a>
|
||||
);
|
||||
// List case.
|
||||
case enumDynamicFieldTypes.list.value: {
|
||||
const currentValue = value as {
|
||||
order: string;
|
||||
[key: string]: string;
|
||||
}[];
|
||||
return (
|
||||
<ul>
|
||||
{currentValue.map((item, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<li
|
||||
key={item.order}
|
||||
className={cn({
|
||||
"border-b pb-2 mb-2": index < currentValue.length - 1,
|
||||
})}
|
||||
>
|
||||
{Object.entries(item).map((content) => (
|
||||
<ol>
|
||||
{content[0]}: {content[1]}
|
||||
</ol>
|
||||
))}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
// Vimeo ID case.
|
||||
case enumDynamicFieldTypes.vimeoid.value:
|
||||
return (
|
||||
<a
|
||||
href={`https://player.vimeo.com/video/${String(value)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600"
|
||||
>
|
||||
{String(value).split("/").pop()}
|
||||
</a>
|
||||
);
|
||||
// Youtube ID case.
|
||||
case enumDynamicFieldTypes.youtubeid.value:
|
||||
return (
|
||||
<a
|
||||
href={`https://www.youtube.com/embed/${String(value)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600"
|
||||
>
|
||||
{String(value).split("/").pop()}
|
||||
</a>
|
||||
);
|
||||
// Date case.
|
||||
case enumDynamicFieldTypes.date.value:
|
||||
return (
|
||||
<span>{new Date(String(value)).toLocaleDateString(locale)}</span>
|
||||
);
|
||||
// Color case.
|
||||
case enumDynamicFieldTypes.color.value:
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block w-5 h-5 rounded border"
|
||||
style={{ backgroundColor: String(value) }}
|
||||
/>
|
||||
{String(value)}
|
||||
</span>
|
||||
);
|
||||
// Model case.
|
||||
case enumDynamicFieldTypes.model.value: {
|
||||
const reservedValue = currentLocaleContent?.[
|
||||
`reserved_${key}` as keyof typeof currentLocaleContent
|
||||
] as unknown as string;
|
||||
return (
|
||||
<span>
|
||||
{String(value)}{" "}
|
||||
<span className="text-gray-500">({String(reservedValue)})</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// Static route case.
|
||||
case enumDynamicFieldTypes.staticroute.value:
|
||||
return value ? (
|
||||
<a href={String(value)} className="underline text-blue-600">
|
||||
{String(value)}
|
||||
</a>
|
||||
) : (
|
||||
<p>/</p>
|
||||
);
|
||||
// Default for string/textarea.
|
||||
default:
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title={currentLocaleTitle} />
|
||||
<h1 className="text-4xl font-extrabold tracking-tight lg:text-5xl">
|
||||
{currentLocaleTitle}
|
||||
</h1>
|
||||
<small className="my-5">
|
||||
{useFormatedDate(String(dynamicPageModel.updated_at))}
|
||||
</small>
|
||||
{currentLocaleConfig && currentLocaleContent ? (
|
||||
<table className="w-1/2 mx-auto border-collapse">
|
||||
<tbody>
|
||||
{Object.entries(currentLocaleContent)
|
||||
.filter(([key]) => !key.startsWith("reserved_model_"))
|
||||
.map(([key, value]) => (
|
||||
<tr key={key} className="border-b">
|
||||
<th className="text-left font-medium p-2 w-1/4 align-top">
|
||||
{key}
|
||||
</th>
|
||||
<td className="p-2">{renderField(key, value)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: String(currentLocaleContent),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue