diff --git a/composer.json b/composer.json index ec4f804..0abb967 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 3c3eb7c..4a3dc31 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/resources/ts/back/Components/DynamicPages/CustomInputs/.gitkeep b/resources/ts/back/Components/DynamicPages/CustomInputs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/resources/ts/back/Components/DynamicPages/DynamicInputRegistry.tsx b/resources/ts/back/Components/DynamicPages/DynamicInputRegistry.tsx new file mode 100644 index 0000000..fb653d4 --- /dev/null +++ b/resources/ts/back/Components/DynamicPages/DynamicInputRegistry.tsx @@ -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 | 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 = 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 | 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; + +Object.entries(modules).forEach(([path, module]) => { + const name = path + .replace("./CustomInputs/", "") + .replace(".tsx", "") + .toLowerCase(); + DynamicInputRegistry.add(name, module.default); +}); diff --git a/resources/ts/back/Components/DynamicPages/DynamicInputs.tsx b/resources/ts/back/Components/DynamicPages/DynamicInputs.tsx new file mode 100644 index 0000000..705e422 --- /dev/null +++ b/resources/ts/back/Components/DynamicPages/DynamicInputs.tsx @@ -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 | 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 ( +
+ {/* 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 ( + + ); + // Textarea input. + case enumDynamicFieldTypes.textarea.value: + return ( + + ); + // Wysiwyg input. + case enumDynamicFieldTypes.wysiwyg.value: + return ( +
+ +
+ ); + // Numbers input. + case enumDynamicFieldTypes.number.value: + return ( + + ); + // Switch input. + case enumDynamicFieldTypes.boolean.value: + return ( +
+ +
+ ); + // Url input. + case enumDynamicFieldTypes.url.value: + return ( + + ); + // Image input. + case enumDynamicFieldTypes.image.value: + return ( + + ); + // Email input. + case enumDynamicFieldTypes.email.value: + return ( + + ); + // List input. + case enumDynamicFieldTypes.list.value: + return ( + + ); + // File input. + case enumDynamicFieldTypes.file.value: + return ( + + ); + // VimeoId input + case enumDynamicFieldTypes.vimeoid.value: + return ( + + ); + // YoutubeId input + case enumDynamicFieldTypes.youtubeid.value: + return ( + + ); + // Date picker. + case enumDynamicFieldTypes.date.value: + return ( + + ); + // Color picker. + case enumDynamicFieldTypes.color.value: + return ( + + ); + // Model select. + case enumDynamicFieldTypes.model.value: + return ( + + ); + // Static route select. + case enumDynamicFieldTypes.staticroute.value: + return ( + + ); + default: { + // Check in custom types. + const customInput = DynamicInputRegistry.get( + typeEnum?.name ?? "type", + fieldName, + fieldValue, + field, + setData, + parentErrors, + ); + if (customInput) { + return {customInput}; + } + // Show message if no type is matching. + return ( +

+ {transUnsupportedType} {typeEnum?.label ?? field.type} +

+ ); + } + } + })} +
+ ); +} diff --git a/resources/ts/back/Components/DynamicPages/DynamicList.tsx b/resources/ts/back/Components/DynamicPages/DynamicList.tsx new file mode 100644 index 0000000..658a56a --- /dev/null +++ b/resources/ts/back/Components/DynamicPages/DynamicList.tsx @@ -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; +}; +/* 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 ( +
+ + + key.startsWith(`${name}.${itemIndex}`), + ), + })} + > + +
+
+ {/* Define a drag-and-drop button to swap the order between the current element and another one. */} +
+ } + 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 */ + /> +
+ {/* Define a delete button to destroy the current element. */} + } + tooltipContent={transDelete} + type="button" + variant="destructive" + size="icon" + onClick={() => { + if (parentAccordionState === `item-${itemIndex}`) { + setParentAccordionState(""); + } + removeField(parentName, field.key, itemIndex); + }} + /> +
+ {/* Define the trigger button to open or close the current accordion. */} +
+ + Élément n°{itemIndex + 1} + +
+
+ + {/* Get specific input from the config value. */} + {field.children?.length && ( + + )} + +
+
+
+
+ ); +} + +/* 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>(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(""); + + // Notice user there must be children config. + if (!field.children && field.children!.length > 0) { + return ( +

+ {GetTranslation("uwil-backend::back.list_children_needed", { + params: { enumLabel }, + })} +

+ ); + } + + /** + * 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 ( +
+ + {value.length > 0 && ( + + {/* + The main provider for dnd-kit. + It manages the entire drag-and-drop system: sensors, movement, collision detection, and lifecycle callbacks (onDragStart, onDragEnd, etc.). + */} + + {/* + Defines a reorderable list area. + It registers sortable items and applies the chosen sorting strategy (vertical, horizontal, grid) to handle item repositioning. + */} + item.id)} + strategy={verticalListSortingStrategy} + > + {/* Use the recursive component for each elements. */} + {sortedListItems.map((item, itemIndex) => ( + + handleRemoveField(currentParentName, fieldKey, index) + } + parentAccordionState={openIndex} + setParentAccordionState={setOpenIndex} + localErrors={localErrors} + /> + ))} + + + + )} + +
+ ); +} diff --git a/resources/ts/back/Components/DynamicPages/DynamicPageDetails.tsx b/resources/ts/back/Components/DynamicPages/DynamicPageDetails.tsx new file mode 100644 index 0000000..81e272d --- /dev/null +++ b/resources/ts/back/Components/DynamicPages/DynamicPageDetails.tsx @@ -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; + +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 = ( +

+ {GetTranslation("uwil-dynamic-pages::trans.element", { + params: { count: String(count) }, + choice: count > 1 ? 2 : 1, + })} +

+ ); + } else if (fieldValue !== null && fieldValue !== undefined) { + cellContent = ( +

+ {typeof fieldValue === "boolean" + ? GetTranslation( + fieldValue + ? "uwil-backend::back.yes" + : "uwil-backend::back.no", + ) + : fieldValue} +

+ ); + } else { + cellContent = ( +

{tableNotKnownTrans}

+ ); + } + + return ( +
+

{field.label}

+ {cellContent} + {!currentHasChildren &&
} +
+ ); + }; + + return ( + + {hasChildren ? ( + + {rowContent(hasChildren)} + + ) : ( +
{rowContent()}
+ )} + + {hasChildren && + Array.isArray(currentValue[field.key as keyof typeof currentValue]) && + Object.values(currentValue[field.key as keyof typeof currentValue]) + .length > 0 && ( + +
+ {Object.values( + currentValue[ + field.key as keyof typeof currentValue + ] as ChildItem[], + ).map((childItem: ChildItem) => ( +
+ {RenderRows( + childItem, + field.children!, + false, + `${accordionKey}-${childItem.id}`, + )} +
+ ))} +
+
+ )} +
+ ); + }); +}; + +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 ( + + {/* HEADER */} +
+
{transLabel}
+
{transValue}
+
+
+ + {/* ROWS */} + {RenderRows(value, config, true)} + + ); +} diff --git a/resources/ts/back/Components/DynamicPages/DynamicStatusBadge.tsx b/resources/ts/back/Components/DynamicPages/DynamicStatusBadge.tsx new file mode 100644 index 0000000..54e38d5 --- /dev/null +++ b/resources/ts/back/Components/DynamicPages/DynamicStatusBadge.tsx @@ -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 ( + + + {enumValue?.label} + + ); +} diff --git a/resources/ts/back/Components/DynamicPages/DynamicWrapperModelSelect.tsx b/resources/ts/back/Components/DynamicPages/DynamicWrapperModelSelect.tsx new file mode 100644 index 0000000..e2a8a25 --- /dev/null +++ b/resources/ts/back/Components/DynamicPages/DynamicWrapperModelSelect.tsx @@ -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 ( + + ); +} diff --git a/resources/ts/back/Components/Layout/Sidebar/Links/DynamicPages.tsx b/resources/ts/back/Components/Layout/Sidebar/Links/DynamicPages.tsx new file mode 100644 index 0000000..1b3ec43 --- /dev/null +++ b/resources/ts/back/Components/Layout/Sidebar/Links/DynamicPages.tsx @@ -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 && ( + + + + ), + auth.policies.dynamicPages.viewTrash && ( + + + + ), + ], + }), + }; + + return { link, group: "pages" }; +} diff --git a/resources/ts/back/Components/Layout/Sidebar/Links/PageConfigs.tsx b/resources/ts/back/Components/Layout/Sidebar/Links/PageConfigs.tsx new file mode 100644 index 0000000..39a1494 --- /dev/null +++ b/resources/ts/back/Components/Layout/Sidebar/Links/PageConfigs.tsx @@ -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 && ( + + + + ), + auth.policies.pageConfigs.viewTrash && ( + + + + ), + ], + }), + }; + + return { link, group: "admin" }; +} diff --git a/resources/ts/back/Components/PageConfigs/AddNewFieldBtn.tsx b/resources/ts/back/Components/PageConfigs/AddNewFieldBtn.tsx new file mode 100644 index 0000000..5634aee --- /dev/null +++ b/resources/ts/back/Components/PageConfigs/AddNewFieldBtn.tsx @@ -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["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 ( +
+ {/* Check if the limit is achieved */} + {limitMax && array.length >= limitMax ? ( +

+ {transNoMoreChildren} +

+ ) : ( + + )} + {/* Indicate the current number of elements. */} +

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

+
+ ); +} diff --git a/resources/ts/back/Components/PageConfigs/ConfigDetails.tsx b/resources/ts/back/Components/PageConfigs/ConfigDetails.tsx new file mode 100644 index 0000000..c1a01bf --- /dev/null +++ b/resources/ts/back/Components/PageConfigs/ConfigDetails.tsx @@ -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) => ( +
+

+ {getEnumDynamicFieldType(field.type)?.label} +

+

+ {field.label} + {`(${field.key})`} +

+
+ {field.validation || "—"} +
+ {!targetHasChildren &&
} +
+ ); + + return ( + + {hasChildren ? ( + + {rowContent(hasChildren)} + + ) : ( +
{rowContent()}
+ )} + + {hasChildren && ( + +
+ {RenderRows(field.children!, false)} +
+
+ )} +
+ ); + }); + + return ( + + {/* HEADER */} +
+
{transType}
+
{transLabel}
+
{transValidation}
+
+
+ + {/* ROWS */} + {RenderRows(config, true)} + + ); +} diff --git a/resources/ts/back/Components/PageConfigs/DynamicFields.tsx b/resources/ts/back/Components/PageConfigs/DynamicFields.tsx new file mode 100644 index 0000000..fe1f88e --- /dev/null +++ b/resources/ts/back/Components/PageConfigs/DynamicFields.tsx @@ -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>(errors); + + // Enums. + const enumDynamicFieldTypes = usePage().props + .enumDynamicFieldTypes as DynamicFieldType; + + // Accordion open index. + const [openIndex, setOpenIndex] = useState(""); + + // Local changes of fields (avoid loop between useEffects). + const prevValueRef = useRef(JSON.stringify(value ?? [])); + + // Sensitive fields. + const [originalConfigKeys, setOriginalConfigKeys] = useState([]); + 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(() => + 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 + * @returns void + */ + const updateField = ( + fieldId: number, + updatedField: Partial, + ): 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 ( +
+ {sortedFields.length > 0 && ( + + + String(field.id))} + strategy={verticalListSortingStrategy} + > + {sortedFields.map((field, index) => ( + removeField(field.id)} + updateField={(_, updatedInput) => + updateField(field.id, updatedInput) + } + currentRequired={required} + currentDisabled={disabled} + sensitiveField={originalConfigKeys} + getTransInput={getTransInput} + transDelete={transDelete} + transDragAndDrop={transDragAndDrop} + removeSensitiveField={removeSensitiveField} + localErrors={localErrors} + setLocalErrors={setLocalErrors} + parentAccordionState={openIndex} + setParentAccordionState={setOpenIndex} + limitMaxChildren={MAX_DEPTH} + /> + ))} + + + + )} + + +
+ ); +} diff --git a/resources/ts/back/Components/PageConfigs/ExportConfigJson.tsx b/resources/ts/back/Components/PageConfigs/ExportConfigJson.tsx new file mode 100644 index 0000000..1dd37e3 --- /dev/null +++ b/resources/ts/back/Components/PageConfigs/ExportConfigJson.tsx @@ -0,0 +1,56 @@ +// * Packages +import { FileDownIcon } from "lucide-react"; +// * Other +import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip"; + +type ExportConfigJsonProps = { + // 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({ + data, + filename = "config.json", + tooltipContent, + disabled = false, +}: ExportConfigJsonProps) { + 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 ( + } + tooltipContent={tooltipContent} + type="button" + variant="outline" + size="icon" + disabled={disabled} + onClick={handleExport} + /> + ); +} diff --git a/resources/ts/back/Components/PageConfigs/FieldGroup.tsx b/resources/ts/back/Components/PageConfigs/FieldGroup.tsx new file mode 100644 index 0000000..aaf6f7b --- /dev/null +++ b/resources/ts/back/Components/PageConfigs/FieldGroup.tsx @@ -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) => void; + removeField: (id: number) => void; + transDelete: string; + transDragAndDrop?: string; + sensitiveField: string[]; + removeSensitiveField: (key: string) => void; + localErrors: Record; + setLocalErrors: Dispatch>>; + 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(""); + + // 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(() => { + 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 ( +
+ + + key.startsWith(`${currentFieldKey}.${currentIndex}`), + ), + })} + > + + {/* HEADER */} +
+
+
+ } + 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 */ + /> +
+
+ + + + + } + tooltipContent={ +
+

+ {str( + useTrans( + "uwil-dynamic-pages::trans.config_complete_label", + ), + ) + .ucFirst() + .value()} +

+

{completeKey}

+
+ } + type="button" + variant="ghost" + size="icon" + onClick={handleClick} + /> + } + tooltipContent={transDelete} + type="button" + variant="destructive" + size="icon" + onClick={() => { + if (parentAccordionState === `item-${currentId}`) { + setParentAccordionState(""); + } + removeField(currentId); + }} + /> +
+
+
+ + {currentField.label.length + ? currentField.label + : GetTranslation("uwil-dynamic-pages::trans.field_number", { + params: { nbr: String(currentIndex + 1) }, + })} + +
+
+ {/* CONTENT */} + +
+ + updateField(currentId, { label: value }) + } + parentErrors={localErrors} + /> + {!sensitiveField.includes( + `${parentFieldKey}.${completeKey}`, + ) && ( + <> + + {getTransInput("key", currentIndex + 1)} + + + + + +
+

+ {GetTranslation( + "uwil-dynamic-pages::trans.respect_format_page", + )} +

+ + name_key + +
+
+
+
+ } + 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} + /> + { + updatingType(value); + }} + required={currentRequired} + disabled={currentDisabled} + parentErrors={localErrors} + /> + + )} + + updateField(currentId, { + validation: value, + }) + } + required={currentRequired} + disabled={currentDisabled} + parentErrors={localErrors} + /> +
+ {/* CHILDREN */} + {currentEnum?.value === enumDynamicFieldTypes.list.value && ( + + + + String(c.id), + )} + strategy={verticalListSortingStrategy} + > + {(currentField.children ?? []).map( + (child: ConfigInternalField, i: number) => ( + , + ) => { + const newChildren = ( + currentField.children ?? [] + ).map((c) => + c.id === childId + ? { + ...c, + ...updatedChild, + } + : c, + ); + updateField(currentId, { + children: newChildren, + }); + }} + removeField={handleDeleteChild} + transDelete={transDelete} + transDragAndDrop={transDragAndDrop} + sensitiveField={sensitiveField} + removeSensitiveField={removeSensitiveField} + localErrors={localErrors} + setLocalErrors={setLocalErrors} + parentAccordionState={openIndex} + setParentAccordionState={setOpenIndex} + limitMaxChildren={limitMaxChildren} + /> + ), + )} + + + + + )} + + + + +
+ ); +} diff --git a/resources/ts/back/Components/PageConfigs/ImportConfigJson.tsx b/resources/ts/back/Components/PageConfigs/ImportConfigJson.tsx new file mode 100644 index 0000000..a424047 --- /dev/null +++ b/resources/ts/back/Components/PageConfigs/ImportConfigJson.tsx @@ -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 = 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([]); + + // Modale state. + const [isOpen, setIsOpen] = useState(false); + // Event state. + const [isDragging, setIsDragging] = useState(false); + // Overwrite state. + const [overwrite, setOverwrite] = useState(false); + + // Local errors. + const [globalErrors, setGlobalErrors] = useState>({}); + const [editorErrors, setEditorErrors] = useState>({}); + + // Input file reference. + const inputRef = useRef(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: , + edit: , + delete: , + copy: , + ok: , + cancel: , + chevron: , + }; + + // 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 + */ + const processFile = async (file: File): Promise => { + 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): void => { + const file = e.target.files?.[0]; + if (file) processFile(file); + }; + + /** + * Action when droping file. + * + * @param event + * @return void + */ + const handleDrop = (event: DragEvent): 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 */} + + + {/* Modal */} + + + + + {useGetTranslation( + "uwil-dynamic-pages::trans.json_editor.modal_title", + )} + + + {useGetTranslation( + "uwil-dynamic-pages::trans.json_editor.modal_description", + )} + + +
+ {/* JSON editor */} + setJsonText(val as JsonObject[])} + icons={icons} + translations={translations} + showIconTooltips + collapseAnimationTime={200} + maxWidth="100%" + searchFilter="all" + /> +
+ + + setOverwrite(value)} + /> +
+ + +
+
+
+
+ + ); +} diff --git a/resources/ts/back/Pages/DynamicPages/Create.tsx b/resources/ts/back/Pages/DynamicPages/Create.tsx new file mode 100644 index 0000000..32097d2 --- /dev/null +++ b/resources/ts/back/Pages/DynamicPages/Create.tsx @@ -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 && ( +
+ } + tooltipContent={

{transBackToList}

} + /> +
+ ) + ); + }; + + return ( + +
+ + ); +} diff --git a/resources/ts/back/Pages/DynamicPages/Edit.tsx b/resources/ts/back/Pages/DynamicPages/Edit.tsx new file mode 100644 index 0000000..60855c1 --- /dev/null +++ b/resources/ts/back/Pages/DynamicPages/Edit.tsx @@ -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 && ( +
+ } + tooltipContent={

{transBackToList}

} + /> +
+ ) + ); + }; + + return ( + + + + + ); +} diff --git a/resources/ts/back/Pages/DynamicPages/Form.tsx b/resources/ts/back/Pages/DynamicPages/Form.tsx new file mode 100644 index 0000000..9dde5b0 --- /dev/null +++ b/resources/ts/back/Pages/DynamicPages/Form.tsx @@ -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; + + // 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 ( + + + } + description={useTrans("uwil-backend::back.form_subtitle_global")} + className="sm:grid-cols-2 gap-4" + > + + {(!data?.isLocked || isConceptor) && ( + + )} + + } + description={useTrans( + "uwil-dynamic-pages::trans.form_subtitle_page_accessibility", + )} + className="sm:grid-cols-2 gap-4" + > + {isConceptor && ( +
+ { + setData("page_id", null); + setData("slug", ""); + // @ts-expect-error TS2345: 'name' type is dynamic, TypeScript cannot infer the correct key. + setData(name, value); + }} + /> +
+ )} + {!data?.isRoot && ( +
+ + +
+ )} +
+ +
+
+ } + description={useTrans("uwil-dynamic-pages::trans.form_subtitle_page")} + className="gap-4" + > +
+
+ + {isConceptor && Number(data.config_id) !== defaultConfigId && ( + + + + + + + {modalTitle} + {modalDescription} + +
+ +
+ + } + tooltipContent={anchorAccessConfig} + variant="secondary" + /> + + + + +
+
+ )} +
+
+ {Number(data.config_id) === defaultConfigId ? ( + + ) : ( + + )} +
+ {isConceptor && ( + } + description={transFormSubtitleIsLocked} + inline + > + + + )} +
+ +
+ + ); +} diff --git a/resources/ts/back/Pages/DynamicPages/Index.tsx b/resources/ts/back/Pages/DynamicPages/Index.tsx new file mode 100644 index 0000000..18b8977 --- /dev/null +++ b/resources/ts/back/Pages/DynamicPages/Index.tsx @@ -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 } }) => ( + + ), + cell: ({ row }: { row: Row }) => ( +
+ + {row.original.isRoot && ( + + + + + + {GetTranslation("uwil-backend::back.page_root")} + + + )} +
+ ), + }, + { + accessorKey: "parent_page", + header: ({ column }: { column: { id: string } }) => ( + + ), + cell: ({ row }: { row: Row }) => { + if (row.getValue("parent_page")) { + const dynamicPageModel = row.getValue( + "parent_page", + ) as DynamicPageModel; + const cellValue = dynamicPageModel.title[appEnv.locale]; + let value = {cellValue}; + if (viewAny) { + value = ( + + + {value} + + } + tooltipContent={ +

+ {GetTranslation("uwil-dynamic-pages::trans.access_model", { + params: { + model: GetTranslation( + `uwil-dynamic-pages::models.classes.${modelName}`, + { + choice: 1, + }, + ), + }, + })} +

+ } + type="button" + size="default" + variant="secondary" + /> + ); + } + if (dynamicPageModel.deleted_at !== null) { + value = ( +
+ {value} + + + + + + {GetTranslation("uwil-backend::back.group_archived")} + + +
+ ); + } + return value; + } + return ( +

+ {GetTranslation("uwil-backend::back.table_not_known")} +

+ ); + }, + }, + { + accessorKey: "config", + header: ({ column }: { column: { id: string } }) => ( + + ), + cell: ({ row }: { row: Row }) => { + if (row.getValue("config")) { + const pageConfigModel = row.getValue("config") as PageConfigModel; + const cellValue = pageConfigModel.label[appEnv.locale]; + let value = {cellValue}; + if (viewAnyPageConfigs) { + value = ( + + + {value} + + } + tooltipContent={ +

+ {GetTranslation("uwil-dynamic-pages::trans.access_model", { + params: { + model: GetTranslation( + "uwil-dynamic-pages::models.classes.page-configs", + { + choice: 1, + }, + ), + }, + })} +

+ } + type="button" + size="default" + variant="secondary" + /> + ); + } + if (pageConfigModel.deleted_at !== null) { + value = ( +
+ {value} + + + + + + {GetTranslation("uwil-backend::back.config_archived")} + + +
+ ); + } + return value; + } + return ( +

+ {GetTranslation("uwil-backend::back.table_not_known")} +

+ ); + }, + }, + { + accessorKey: "status", + header: ({ column }: { column: { id: string } }) => ( + + ), + cell: ({ row }: { row: Row }) => ( +
+ + {row.original.isLocked && ( + + + + + + {GetTranslation("uwil-dynamic-pages::trans.page_locked")} + + + )} +
+ ), + }, + { + accessorKey: "updated_at", + header: ({ column }: { column: { id: string } }) => ( + + ), + cell: ({ row }: { row: Row }) => ( + + {GetFormatedDate(row.getValue("updated_at"))} + + ), + }, + ]; +}; + +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 && ( +
+ } + tooltipContent={

{transBackToList}

} + /> +
+ ) + ); + }; + 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 && ( +
+ {viewTrash && ( + } + tooltipContent={

{transAccessToArchived}

} + variant="outline" + /> + )} + {create && ( + } + tooltipContent={

{transAddEntry}

} + /> + )} +
+ ) + ); + }; + + return ( + + {isArchiveRoute && } + + + + ); +} diff --git a/resources/ts/back/Pages/DynamicPages/Show.tsx b/resources/ts/back/Pages/DynamicPages/Show.tsx new file mode 100644 index 0000000..1725dea --- /dev/null +++ b/resources/ts/back/Pages/DynamicPages/Show.tsx @@ -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 }) => ( + + ), + }, + { + accessorKey: "parent_page", + header: str( + GetTranslation("uwil-dynamic-pages::trans.attributes.parent_page"), + ) + .ucFirst() + .value(), + cell: ({ value }: { value: DynamicPageModelWithRelations }) => + auth.policies.dynamicPages.viewAny ? ( + + + {value.title[appEnv.locale]} + + } + tooltipContent={ +

+ {GetTranslation("uwil-dynamic-pages::trans.access_model", { + params: { + model: GetTranslation( + `uwil-dynamic-pages::models.classes.${modelName}`, + { + choice: 1, + }, + ), + }, + })} +

+ } + type="button" + size="default" + variant="secondary" + /> + ) : ( + {value.title[appEnv.locale]} + ), + }, + { + accessorKey: "config", + header: str(GetTranslation("uwil-dynamic-pages::trans.attributes.config")) + .ucFirst() + .value(), + cell: ({ value }: { value: PageConfigModel }) => + auth.policies.pageConfigs.viewAny ? ( + + + {value.label[appEnv.locale]} + + } + tooltipContent={ +

+ {GetTranslation("uwil-dynamic-pages::trans.access_model", { + params: { + model: GetTranslation( + "uwil-dynamic-pages::models.classes.page-configs", + { + choice: 1, + }, + ), + }, + })} +

+ } + type="button" + size="default" + variant="secondary" + /> + ) : ( + {value.label[appEnv.locale]} + ), + }, + { + accessorKey: "content", + header: str( + GetTranslation("uwil-dynamic-pages::trans.attributes.content"), + ) + .ucFirst() + .value(), + cell: ({ value }: { value: TranslationField }) => { + if ( + typeof value[appEnv.locale] === "object" && + dynamicPageModel.config?.config?.[appEnv.locale] + ) { + return ( + + ); + } + if ( + typeof value[appEnv.locale] === "string" && + String(value[appEnv.locale]).trim().length > 0 + ) { + return

{String(value[appEnv.locale])}

; + } + return ( + + {GetTranslation("uwil-backend::back.table_not_known")} + + ); + }, + }, + { + accessorKey: "status", + header: str(GetTranslation(`uwil-dynamic-pages::trans.attributes.status`)) + .ucFirst() + .value(), + cell: ({ value }: { value: number }) => ( + + ), + }, + { + accessorKey: "isLocked", + header: str(GetTranslation("uwil-dynamic-pages::trans.attributes.locked")) + .ucFirst() + .value(), + cell: ({ value }: { value: boolean }) => ( + + {value ? ( + + ) : ( + + )} + {str( + GetTranslation( + value ? "uwil-backend::back.yes" : "uwil-backend::back.no", + ), + ) + .ucFirst() + .value()} + + ), + }, + { + accessorKey: "isRoot", + header: str(GetTranslation("uwil-dynamic-pages::trans.attributes.root")) + .ucFirst() + .value(), + cell: ({ value }: { value: boolean }) => ( + + {value ? ( + + ) : ( + + )} + {str( + GetTranslation( + value ? "uwil-backend::back.yes" : "uwil-backend::back.no", + ), + ) + .ucFirst() + .value()} + + ), + }, + { + accessorKey: "created_at", + header: str( + GetTranslation("uwil-dynamic-pages::trans.attributes.created_at"), + ) + .ucFirst() + .value(), + cell: ({ value }: { value: string }) => ( + {GetFormatedDate(value)} + ), + }, + { + accessorKey: "updated_at", + header: str( + GetTranslation("uwil-dynamic-pages::trans.attributes.updated_at"), + ) + .ucFirst() + .value(), + cell: ({ value }: { value: string }) => ( + {GetFormatedDate(value)} + ), + }, + { + accessorKey: "deleted_at", + header: str( + GetTranslation("uwil-dynamic-pages::trans.attributes.deleted_at"), + ) + .ucFirst() + .value(), + cell: ({ value }: { value: string }) => ( + {GetFormatedDate(value)} + ), + }, + ]; +}; + +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 && ( +
+ } + tooltipContent={

{transBackToList}

} + /> +
+ ) + ); + }; + + return ( + + + {/* @ts-expect-error Weird error type on cell. */} + + + ); +} diff --git a/resources/ts/back/Pages/PageConfigs/Create.tsx b/resources/ts/back/Pages/PageConfigs/Create.tsx new file mode 100644 index 0000000..fd8bc6d --- /dev/null +++ b/resources/ts/back/Pages/PageConfigs/Create.tsx @@ -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 && ( +
+ } + tooltipContent={

{transBackToList}

} + /> +
+ ) + ); + }; + + return ( + +
+ + ); +} diff --git a/resources/ts/back/Pages/PageConfigs/Edit.tsx b/resources/ts/back/Pages/PageConfigs/Edit.tsx new file mode 100644 index 0000000..aa9dd11 --- /dev/null +++ b/resources/ts/back/Pages/PageConfigs/Edit.tsx @@ -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 && ( +
+ } + tooltipContent={

{transBackToList}

} + /> +
+ ) + ); + }; + + return ( + + + + + ); +} diff --git a/resources/ts/back/Pages/PageConfigs/Form.tsx b/resources/ts/back/Pages/PageConfigs/Form.tsx new file mode 100644 index 0000000..652e844 --- /dev/null +++ b/resources/ts/back/Pages/PageConfigs/Form.tsx @@ -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(1); + + /** Get configs in others locales */ + const [selectedConfig, setSelectedConfig] = useState(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 ( + + + } + description={useTrans("uwil-backend::back.form_subtitle_global")} + className="sm:grid-cols-2 gap-4" + > + + + + {str( + useTrans("uwil-backend::back.form_associate_to", { + params: { + attribute: useTrans( + "uwil-dynamic-pages::trans.attributes.view", + ), + }, + choice: 2, + }), + ) + .ucFirst() + .value()} + + + + + + +
+

+ {useTrans( + "uwil-dynamic-pages::trans.respect_format_page", + )} +

+ + front/Pages/FileName + +
+
+
+ + } + placeholder="ex: John Doe" + pattern="^front\/Pages\/[A-Z][a-zA-Z0-9]+$" + required={false} + value={data.view ?? ""} + setData={setData} + /> +
+ } + description={useTrans("uwil-dynamic-pages::trans.form_subtitle_config")} + > + {/* Import configuration from json file. */} + {isConceptor && ( +
+ { + const newConfig = !overwrite + ? [...data.config, ...value] + : value; + setData("config", newConfig as ConfigField[]); + setResetState((prev) => prev + 1); + }} + /> +
+ )} + {/* Show only on editing page. */} + {route().current(`${configBackend.routes.name}${modelName}.edit`) && ( +
+ {/* Select config from another locale */} + + {/* Button to export config */} + {isConceptor && ( + + )} +
+ )} + +
+
+ {mustBeNotified() ? ( + + +
+ +
+
+ + + {transDialogTitle} + +
+

{transDialogParagraph}

+

{transDialogParagraphList}

+
+
    + {model?.pages.map((page) => ( +
  • + {page.title[appEnv.locale] ?? + page.title[appEnv.fallbackLocale]} +
  • + ))} +
+
+
+
+
+ + + + + + + + +
+
+ ) : ( + + )} +
+ + ); +} diff --git a/resources/ts/back/Pages/PageConfigs/Index.tsx b/resources/ts/back/Pages/PageConfigs/Index.tsx new file mode 100644 index 0000000..65287d9 --- /dev/null +++ b/resources/ts/back/Pages/PageConfigs/Index.tsx @@ -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 } }) => ( + + ), + cell: ({ row }: { row: Row }) => ( + + ), + }, + { + accessorKey: "view", + header: ({ column }: { column: { id: string } }) => ( + + ), + cell: ({ row }: { row: Row }) => + row.getValue("view") ? ( + {row.getValue("view")} + ) : ( +

+ {GetTranslation("uwil-backend::back.table_not_known")} +

+ ), + }, + { + accessorKey: "updated_at", + header: ({ column }: { column: { id: string } }) => ( + + ), + cell: ({ row }: { row: Row }) => ( + + {GetFormatedDate(row.getValue("updated_at"))} + + ), + }, + ]; +}; + +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 && ( +
+ } + tooltipContent={

{transCrudBackList}

} + /> +
+ ) + ); + }; + 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 && ( +
+ {viewTrash && ( + } + tooltipContent={

{transAccessTo}

} + variant="outline" + /> + )} + {create && ( + } + tooltipContent={

{transCrudAddEntry}

} + /> + )} +
+ ) + ); + }; + + return ( + + {isArchiveRoute && } + + + + ); +} diff --git a/resources/ts/back/Pages/PageConfigs/Show.tsx b/resources/ts/back/Pages/PageConfigs/Show.tsx new file mode 100644 index 0000000..4d89c1f --- /dev/null +++ b/resources/ts/back/Pages/PageConfigs/Show.tsx @@ -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 }) => ( + + ), + }, + { + accessorKey: "view", + header: str(GetTranslation("uwil-dynamic-pages::trans.attributes.view")) + .ucFirst() + .value(), + cell: ({ value }: { value: string | null }) => {value}, + }, + { + accessorKey: "config", + header: str(GetTranslation("uwil-dynamic-pages::trans.attributes.config")) + .ucFirst() + .value(), + cell: ({ value }: { value: TranslationField }) => + value[appEnv.locale] ? ( + + ) : ( +

+ {GetTranslation("uwil-backend::back.table_not_known")} +

+ ), + }, + { + accessorKey: "created_at", + header: str( + GetTranslation("uwil-dynamic-pages::trans.attributes.created_at"), + ) + .ucFirst() + .value(), + cell: ({ value }: { value: string }) => ( + {GetFormatedDate(value)} + ), + }, + { + accessorKey: "updated_at", + header: str( + GetTranslation("uwil-dynamic-pages::trans.attributes.updated_at"), + ) + .ucFirst() + .value(), + cell: ({ value }: { value: string }) => ( + {GetFormatedDate(value)} + ), + }, + { + accessorKey: "deleted_at", + header: str( + GetTranslation("uwil-dynamic-pages::trans.attributes.deleted_at"), + ) + .ucFirst() + .value(), + cell: ({ value }: { value: string }) => ( + {GetFormatedDate(value)} + ), + }, + ]; +}; + +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 && ( +
+ } + tooltipContent={

{transCrudBackToList}

} + /> +
+ ) + ); + }; + + return ( + + + {/* @ts-expect-error Weird error type on cell. */} + + + ); +} diff --git a/resources/ts/back/types/dynamic-pages.d.ts b/resources/ts/back/types/dynamic-pages.d.ts new file mode 100644 index 0000000..be89574 --- /dev/null +++ b/resources/ts/back/types/dynamic-pages.d.ts @@ -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 | 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 & { + id: number; + children?: ConfigInternalField[]; +}; + +type PageConfigModel = DefaultModel & { + label: TranslationField; + config: TranslationField; + view: string | null; + status: number; +}; + +// Relations. +type PageConfigModelWithRelations = PageConfigModel & { + pages: DynamicPageModel[]; +}; + +type DynamicPageModelWithRelations = DynamicPageModel & { + config: PageConfigModel | null; + parent_page: DynamicPageModel | null; + children_page: DynamicPageModel[]; +}; diff --git a/resources/ts/front/Pages/DynamicPageDefault.tsx b/resources/ts/front/Pages/DynamicPageDefault.tsx new file mode 100644 index 0000000..0422565 --- /dev/null +++ b/resources/ts/front/Pages/DynamicPageDefault.tsx @@ -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 {String(value)}; + } + + switch (configField.type) { + // Wysiwyg case. + case enumDynamicFieldTypes.wysiwyg.value: + return ( +
+ ); + // Number case. + case enumDynamicFieldTypes.number.value: + return {Number(value).toLocaleString(locale)}; + // Boolean case. + case enumDynamicFieldTypes.boolean.value: + return ( + + {value ? : } + + ); + // Link case. + case enumDynamicFieldTypes.url.value: + return ( + + {String(value)} + + ); + // E-mail case. + case enumDynamicFieldTypes.email.value: + return ( + + {String(value)} + + ); + // Image case. + case enumDynamicFieldTypes.image.value: + return ( + {key} + ); + // File case. + case enumDynamicFieldTypes.file.value: + return ( + + {String(value).split("/").pop()} + + ); + // List case. + case enumDynamicFieldTypes.list.value: { + const currentValue = value as { + order: string; + [key: string]: string; + }[]; + return ( +
    + {currentValue.map((item, index) => ( + // eslint-disable-next-line react/no-array-index-key +
  • + {Object.entries(item).map((content) => ( +
      + {content[0]}: {content[1]} +
    + ))} +
  • + ))} +
+ ); + } + // Vimeo ID case. + case enumDynamicFieldTypes.vimeoid.value: + return ( + + {String(value).split("/").pop()} + + ); + // Youtube ID case. + case enumDynamicFieldTypes.youtubeid.value: + return ( + + {String(value).split("/").pop()} + + ); + // Date case. + case enumDynamicFieldTypes.date.value: + return ( + {new Date(String(value)).toLocaleDateString(locale)} + ); + // Color case. + case enumDynamicFieldTypes.color.value: + return ( + + + {String(value)} + + ); + // Model case. + case enumDynamicFieldTypes.model.value: { + const reservedValue = currentLocaleContent?.[ + `reserved_${key}` as keyof typeof currentLocaleContent + ] as unknown as string; + return ( + + {String(value)}{" "} + ({String(reservedValue)}) + + ); + } + // Static route case. + case enumDynamicFieldTypes.staticroute.value: + return value ? ( + + {String(value)} + + ) : ( +

/

+ ); + // Default for string/textarea. + default: + return {String(value)}; + } + }; + + return ( + <> + +

+ {currentLocaleTitle} +

+ + {useFormatedDate(String(dynamicPageModel.updated_at))} + + {currentLocaleConfig && currentLocaleContent ? ( + + + {Object.entries(currentLocaleContent) + .filter(([key]) => !key.startsWith("reserved_model_")) + .map(([key, value]) => ( + + + + + ))} + +
+ {key} + {renderField(key, value)}
+ ) : ( +
+ )} + + ); +}