chore: ✨ install laravel-dynamic-pages

This commit is contained in:
Alexis Gousseau 2026-06-04 12:36:36 +02:00
parent 76eebc3afb
commit 5eaba3c929
No known key found for this signature in database
GPG key ID: 7F3C786EE12DBB72
29 changed files with 5234 additions and 2 deletions

View file

@ -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
View file

@ -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",

View file

@ -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);
});

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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}
/>
);
}

View file

@ -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" };
}

View file

@ -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" };
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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}
/>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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[];
};

View 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),
}}
/>
)}
</>
);
}