From 63ac6457a146fd47ef06944f03d281b0af922132 Mon Sep 17 00:00:00 2001 From: Sine Jespersen Date: Fri, 29 Aug 2025 13:07:16 +0200 Subject: [PATCH 01/11] 5316: add poc how to make dynamic admin forms --- .../components/slide/content/content-form.jsx | 17 +- .../slide/content/feed-selector.jsx | 2 +- .../slide/content/file-dropzone.jsx | 2 +- .../slide/content/file-selector.jsx | 22 +- .../slide/preview/slide-preview.jsx | 2 +- assets/admin/components/slide/slide-form.jsx | 10 +- assets/client/components/slide.jsx | 2 +- assets/shared/admin-util/helper.js | 14 + assets/shared/slide-utils/templates-admin.js | 70 +++++ .../{templates.js => templates-slide.js} | 0 assets/shared/templates/image-text.json | 245 +-------------- assets/shared/templates/image-text.jsx | 241 +-------------- .../templates/image-text/admin-form.jsx | 283 ++++++++++++++++++ .../shared/templates/image-text/template.jsx | 244 +++++++++++++++ .../templates/image-text/translations.json | 46 +++ assets/template/index.jsx | 2 +- 16 files changed, 697 insertions(+), 505 deletions(-) create mode 100644 assets/shared/admin-util/helper.js create mode 100644 assets/shared/slide-utils/templates-admin.js rename assets/shared/slide-utils/{templates.js => templates-slide.js} (100%) create mode 100644 assets/shared/templates/image-text/admin-form.jsx create mode 100644 assets/shared/templates/image-text/template.jsx create mode 100644 assets/shared/templates/image-text/translations.json diff --git a/assets/admin/components/slide/content/content-form.jsx b/assets/admin/components/slide/content/content-form.jsx index 77dbb3dd..2b9edbd1 100644 --- a/assets/admin/components/slide/content/content-form.jsx +++ b/assets/admin/components/slide/content/content-form.jsx @@ -8,6 +8,7 @@ import FileSelector from "./file-selector"; import StationSelector from "./station/station-selector"; import RadioButtons from "../../util/forms/radio-buttons"; import CheckboxOptions from "../../util/forms/checkbox-options"; +import getInputFiles from "../../../../shared/admin-util/helper"; /** * Render form elements for content form. @@ -29,20 +30,6 @@ function ContentForm({ onChange = null, mediaData = {}, }) { - const getInputFiles = (field) => { - const inputFiles = []; - - if (Array.isArray(field)) { - field.forEach((mediaId) => { - if (Object.prototype.hasOwnProperty.call(mediaData, mediaId)) { - inputFiles.push(mediaData[mediaId]); - } - }); - } - - return inputFiles; - }; - /** * @param {object} formData - The data for form input. * @returns {object | string} - Returns a rendered jsx object. @@ -78,7 +65,7 @@ function ContentForm({ )} {}} + onFileChange={() => {}} // Todo perhaps an error instead of an empty default /> ); }; diff --git a/assets/admin/components/slide/content/file-dropzone.jsx b/assets/admin/components/slide/content/file-dropzone.jsx index 277c3ca3..b8df6e5a 100644 --- a/assets/admin/components/slide/content/file-dropzone.jsx +++ b/assets/admin/components/slide/content/file-dropzone.jsx @@ -40,7 +40,7 @@ function FileDropzone({ onFilesAdded, acceptedMimetypes = null }) { <> {/* TODO: Fix styling for dropzone: https://react-dropzone.js.org/#section-styling-dropzone */} {/* eslint-disable react/jsx-props-no-spreading */} -
+
{t("file-dropzone.drag-and-drop-text")} diff --git a/assets/admin/components/slide/content/file-selector.jsx b/assets/admin/components/slide/content/file-selector.jsx index fa2042ac..8e8267d3 100644 --- a/assets/admin/components/slide/content/file-selector.jsx +++ b/assets/admin/components/slide/content/file-selector.jsx @@ -107,19 +107,21 @@ function FileSelector({ /> {enableMediaLibrary && ( <> - - {/* +
+ + {/* TODO: Make this configurable. It should always align with sizes in https://github.com/os2display/display-api-service/blob/develop/src/Entity/Tenant/Media.php */} -
- {t("file-selector.max-size")}: 200 MB +
+ {t("file-selector.max-size")}: 200 MB +
+ {renderAdminForm( + idFromUrl(selectedTemplate.id), + slide.content, + handleContent, + handleMedia, + mediaData, + )} {contentFormElements.map((formElement) => ( {formElement.input === "feed" && ( diff --git a/assets/client/components/slide.jsx b/assets/client/components/slide.jsx index bf5b76d5..fe2a75f7 100644 --- a/assets/client/components/slide.jsx +++ b/assets/client/components/slide.jsx @@ -1,6 +1,6 @@ import ErrorBoundary from "./error-boundary.jsx"; import logger from "../logger/logger"; -import { renderSlide } from "../../shared/slide-utils/templates.js"; +import { renderSlide } from "../../shared/slide-utils/templates-slide.js"; import "./slide.scss"; /** diff --git a/assets/shared/admin-util/helper.js b/assets/shared/admin-util/helper.js new file mode 100644 index 00000000..781482ea --- /dev/null +++ b/assets/shared/admin-util/helper.js @@ -0,0 +1,14 @@ +const getInputFiles = (field, mediaData) => { + const inputFiles = []; + if (Array.isArray(field)) { + field.forEach((mediaId) => { + if (Object.prototype.hasOwnProperty.call(mediaData, mediaId)) { + inputFiles.push(mediaData[mediaId]); + } + }); + } + + return inputFiles; +}; + +export default getInputFiles; diff --git a/assets/shared/slide-utils/templates-admin.js b/assets/shared/slide-utils/templates-admin.js new file mode 100644 index 00000000..a0bcfbf2 --- /dev/null +++ b/assets/shared/slide-utils/templates-admin.js @@ -0,0 +1,70 @@ +// Load templates. +// @see https://vite.dev/guide/features.html#glob-import +// @see docs/custom-templates.md +// Eager loading because no other code piece imports the templates otherwise. +const templateModules = import.meta.glob("../templates/*.jsx", { eager: true }); +const customTemplatesModules = import.meta.glob("../custom-templates/*.jsx", { + eager: true, +}); + +function duckTypingAdminFormModule(module) { + return ( + typeof module.id === "function" && + typeof module.config === "function" && + typeof module.renderAdminForm === "function" + ); +} + +function findAdminFormModule(modules, templateUlid) { + for (const key of Object.keys(modules)) { + const module = modules[key].default; + + if (duckTypingAdminFormModule(module)) { + if (module.id() === templateUlid) { + return module; + } + } + } + + return null; +} + +function getAdminModule(templateUlid) { + if (!templateUlid) { + return null; + } + + const module = + findAdminFormModule(templateModules, templateUlid) ?? + findAdminFormModule(customTemplatesModules, templateUlid) ?? + null; + + if (module === null) { + throw new Error(`Cannot find admin template with '${templateUlid}'`); + } + + return module; +} + +function renderAdminForm( + templateUlid, + formStateObject, + onChange, + handleMedia, + mediaData, +) { + const module = getAdminModule(templateUlid); + + if (!module) { + return null; + } + + return module.renderAdminForm( + formStateObject, + onChange, + handleMedia, + mediaData, + ); +} + +export { renderAdminForm }; diff --git a/assets/shared/slide-utils/templates.js b/assets/shared/slide-utils/templates-slide.js similarity index 100% rename from assets/shared/slide-utils/templates.js rename to assets/shared/slide-utils/templates-slide.js diff --git a/assets/shared/templates/image-text.json b/assets/shared/templates/image-text.json index 5b1a4d8f..946e93b5 100644 --- a/assets/shared/templates/image-text.json +++ b/assets/shared/templates/image-text.json @@ -1,248 +1,5 @@ { "title": "Billede og tekst", "id": "01FP2SNGFN0BZQH03KCBXHKYHG", - "options": {}, - "adminForm": [ - { - "key": "image-text-form-1", - "input": "header", - "text": "Skabelon: Tekst og billede", - "name": "header1", - "formGroupClasses": "h4 mb-3" - }, - { - "key": "image-text-form-2", - "input": "header-h3", - "text": "Indhold", - "name": "header2", - "formGroupClasses": "h5 mb-3" - }, - { - "key": "image-text-form-3", - "input": "textarea", - "name": "title", - "label": "Overskrift på slide", - "helpText": "Her kan du skrive overskriften til slidet", - "formGroupClasses": "col-md-6" - }, - { - "key": "image-text-form-4", - "input": "rich-text-input", - "name": "text", - "label": "Tekst på slide", - "helpText": "Her kan du skrive teksten til slidet", - "formGroupClasses": "col-md" - }, - { - "key": "image-text-form-81", - "input": "select", - "required": true, - "label": "Tekststørrelse", - "formGroupClasses": "col-md-6 mb-3", - "options": [ - { - "key": "fontsize1", - "title": "xs", - "value": "font-size-xs" - }, - { - "key": "fontsize2", - "title": "s", - "value": "font-size-s" - }, - { - "key": "fontsize3", - "title": "m", - "value": "font-size-m" - }, - { - "key": "fontsize4", - "title": "l", - "value": "font-size-lg" - }, - { - "key": "fontsize5", - "title": "xl", - "value": "font-size-xl" - } - ], - "name": "fontSize" - }, - - { - "key": "image-text-form-5", - "multipleImages": true, - "input": "image", - "name": "image", - "label": "Billeder", - "helpText": "Hvis du tilføjer mere end et billede bliver de vist i (varighed / antal_billeder) sekunder hver." - }, - { - "key": "form-media-contain", - "input": "checkbox", - "name": "mediaContain", - "label": "Deaktivér billedbeskæring", - "helpText": "Billedet vil blive vist ubeskåret med tomme områder omkring" - }, - { - "key": "image-text-form-6", - "input": "header-h3", - "text": "Opsætning", - "name": "header3", - "formGroupClasses": "h5 mt-3 mb-3" - }, - { - "key": "image-text-form-7", - "input": "duration", - "name": "duration", - "min": "1", - "type": "number", - "label": "Varighed (i sekunder)", - "helpText": "Her skal du skrive varigheden på slidet.", - "required": true, - "formGroupClasses": "col-md-6 mb-3" - }, - { - "key": "image-text-form-8", - "input": "select", - "required": true, - "label": "Hvor skal tekstboksen være placeret", - "formGroupClasses": "col-md-6 mb-3", - "options": [ - { - "key": "placement1", - "title": "Toppen", - "value": "top" - }, - { - "key": "placement2", - "title": "Højre", - "value": "right" - }, - { - "key": "placement3", - "title": "Bunden", - "value": "bottom" - }, - { - "key": "placement4", - "title": "Venstre", - "value": "left" - } - ], - "name": "boxAlign" - }, - { - "key": "image-text-form-9", - "input": "checkbox", - "label": "Margin rundt om boks", - "name": "boxMargin", - "formGroupClasses": "col-md-6 mb-3" - }, - { - "key": "image-text-form-10", - "input": "checkbox", - "label": "Animeret streg under overskrift", - "name": "separator", - "formGroupClasses": "col-md-6 mb-3" - }, - { - "key": "image-text-form-11", - "input": "checkbox", - "label": "Gør tekstboksen mindre", - "name": "halfSize", - "formGroupClasses": "col-md-6 mb-3" - }, - { - "key": "image-text-form-12", - "input": "checkbox", - "label": "Alternativt layout uden tekstboks", - "helpText": "Denne kan ikke kombineres med den animerede tekst under overskriften", - "name": "reversed", - "formGroupClasses": "col-md-6 mb-3" - }, - { - "key": "image-text-form-13", - "input": "checkbox", - "label": "Skyggeeffekt på tekstboksen", - "name": "shadow", - "formGroupClasses": "col-md-6 mb-3" - }, - { - "key": "image-text-form-14", - "input": "checkbox", - "label": "Vis logo fra tema", - "name": "showLogo", - "formGroupClasses": "col-md-6 mb-3" - }, - { - "key": "image-text-form-15", - "input": "select", - "required": false, - "label": "Logostørrelse", - "formGroupClasses": "col-md-6 mb-3", - "options": [ - { - "key": "logosize1", - "title": "s", - "value": "logo-size-s" - }, - { - "key": "logosize2", - "title": "m", - "value": "logo-size-m" - }, - { - "key": "logosize3", - "title": "l", - "value": "logo-size-l" - } - ], - "name": "logoSize" - }, - { - "key": "image-text-form-16", - "input": "select", - "required": false, - "label": "Logoposition", - "formGroupClasses": "col-md-6 mb-3", - "options": [ - { - "key": "logoposition1", - "title": "Top/venstre", - "value": "logo-position-top-left" - }, - { - "key": "logoposition2", - "title": "Top/højre", - "value": "logo-position-top-right" - }, - { - "key": "logoposition3", - "title": "Bund/venstre", - "value": "logo-position-bottom-left" - }, - { - "key": "logoposition4", - "title": "Bund/højre", - "value": "logo-position-bottom-right" - } - ], - "name": "logoPosition" - }, - { - "key": "image-text-form-17", - "input": "checkbox", - "label": "Margin om logo", - "name": "logoMargin", - "formGroupClasses": "col-md-6 mb-3" - }, - { - "key": "image-text-form-18", - "input": "checkbox", - "label": "Deaktiver fade ved flere billeder", - "name": "disableImageFade", - "formGroupClasses": "col-md-6 mb-3" - } - ] + "options": {} } diff --git a/assets/shared/templates/image-text.jsx b/assets/shared/templates/image-text.jsx index de32bd6d..3c8189ef 100644 --- a/assets/shared/templates/image-text.jsx +++ b/assets/shared/templates/image-text.jsx @@ -1,15 +1,8 @@ -import { createRef, useEffect, useRef, useState } from "react"; -import parse from "html-react-parser"; -import DOMPurify from "dompurify"; -import { CSSTransition, TransitionGroup } from "react-transition-group"; -import BaseSlideExecution from "../slide-utils/base-slide-execution.js"; -import { - getAllMediaUrlsFromField, - ThemeStyles, -} from "../slide-utils/slide-util.jsx"; +import imageTextConfig from "./image-text.json"; +import ImageTextAdmin from "./image-text/admin-form"; +import ImageText from "./image-text/template"; import "../slide-utils/global-styles.css"; import "./image-text/image-text.scss"; -import imageTextConfig from "./image-text.json"; function id() { return imageTextConfig.id; @@ -31,227 +24,15 @@ function renderSlide(slide, run, slideDone) { ); } -/** - * @param {object} props Props. - * @param {object} props.slide The slide. - * @param {object} props.content The slide content. - * @param {boolean} props.run Whether or not the slide should start running. - * @param {Function} props.slideDone Function to invoke when the slide is done playing. - * @param {string} props.executionId Unique id for the instance. - * @returns {JSX.Element} The component. - */ -function ImageText({ slide, content, run, slideDone, executionId }) { - const imageTimeoutRef = useRef(); - const [images, setImages] = useState([]); - const [currentImage, setCurrentImage] = useState(); - const logo = slide?.theme?.logo; - const { - showLogo, - logoSize, - logoPosition, - logoMargin, - mediaContain, - disableImageFade, - } = content; - - const logoUrl = showLogo && logo?.assets?.uri ? logo.assets.uri : ""; - - const logoClasses = ["logo"]; - - if (logoMargin) { - logoClasses.push("logo-margin"); - } - if (logoSize) { - logoClasses.push(logoSize); - } - if (logoPosition) { - logoClasses.push(logoPosition); - } - - // Styling from content - const { - separator, - boxAlign, - reversed, - boxMargin, - halfSize, - fontSize, - shadow, - } = content || {}; - - let boxClasses = "box"; - - // Styling objects - const rootStyle = {}; - const imageTextStyle = {}; - - // Content from content - const { title, text, textColor, boxColor, duration = 15000 } = content; - - const sanitizedText = DOMPurify.sanitize(text); - - // Display separator depends on whether the slide is reversed. - const displaySeparator = separator && !reversed; - - // Set background image. - if (!(images?.length > 0)) { - boxClasses = `${boxClasses} full-screen`; - } - - // Set box colors. - if (boxColor) { - imageTextStyle.backgroundColor = boxColor; - } - if (textColor) { - imageTextStyle.color = textColor; - } - - const rootClasses = ["template-image-text", fontSize]; - - // Position text-box. - if (boxAlign === "left" || boxAlign === "right") { - rootClasses.push("column"); - } - if (boxAlign === "bottom" || boxAlign === "right") { - rootClasses.push("flex-end"); - } - if (reversed) { - rootClasses.push("reversed"); - } - if (boxMargin || reversed) { - rootClasses.push("box-margin"); - } - if (halfSize && !reversed) { - rootClasses.push("half-size"); - } - if (separator && !reversed) { - rootClasses.push("animated-header"); - } - if (shadow) { - rootClasses.push("shadow"); - } - - const changeImage = (newIndex) => { - if (newIndex < images.length) { - setCurrentImage(images[newIndex]); - - if (newIndex < images.length - 1) { - imageTimeoutRef.current = setTimeout( - () => changeImage(newIndex + 1), - duration / images.length, - ); - } - } - }; - - useEffect(() => { - if (slide?.mediaData) { - const imageUrls = getAllMediaUrlsFromField( - slide.mediaData, - content.image, - ); - - if (imageUrls?.length > 0) { - const newImages = imageUrls.map((url) => { - return { - url, - nodeRef: createRef(), - }; - }); - - setImages(newImages); - } - } - }, [slide]); - - const startTheShow = () => { - if (images?.length > 0 && !currentImage) { - setCurrentImage(images[0]); - } - - // If there are multiple images, we are going to loop through these WITHIN the set duration. - if (images?.length > 1) { - // Kickoff the display of multiple images with the zero indexed - changeImage(0); - } - }; - - useEffect(() => { - if (!currentImage) { - startTheShow(); - } - }, [images]); - - /** Setup slide run function. */ - const slideExecution = new BaseSlideExecution(slide, slideDone); - - useEffect(() => { - if (run) { - startTheShow(); - slideExecution.start(duration); - } - - return function cleanup() { - slideExecution.stop(); - - if (imageTimeoutRef.current) { - clearTimeout(imageTimeoutRef.current); - } - }; - }, [run]); - +function renderAdminForm(formStateObject, onChange, handleMedia, mediaData) { return ( - <> -
- - {currentImage && ( - -
- - )} - - {(title || text) && ( -
- {title && ( -

- {title} - {/* Todo theme the color of the below */} - {displaySeparator &&
} -

- )} - {sanitizedText && ( -
{parse(sanitizedText)}
- )} -
- )} - - {showLogo && logoUrl && ( - - )} -
- - {slide?.theme?.cssStyles && ( - - )} - + ); } -export default { id, config, renderSlide }; +export default { id, config, renderSlide, renderAdminForm }; diff --git a/assets/shared/templates/image-text/admin-form.jsx b/assets/shared/templates/image-text/admin-form.jsx new file mode 100644 index 00000000..5b14dd05 --- /dev/null +++ b/assets/shared/templates/image-text/admin-form.jsx @@ -0,0 +1,283 @@ +import { useEffect } from "react"; +import RichText from "../../../admin/components/util/forms/rich-text/rich-text.jsx"; +import Select from "../../../admin/components/util/forms/select"; +import FileSelector from "../../../admin/components/slide/content/file-selector.jsx"; +import getInputFiles from "../../admin-util/helper.js"; +import FormCheckbox from "../../../admin/components/util/forms/form-checkbox"; +import FormInput from "../../../admin/components/util/forms/form-input"; +import i18next from "i18next"; +import adminTranslations from "./translations.json"; +import { useTranslation } from "react-i18next"; + +function ImageTextAdmin({ + formStateObject, + onChange, + handleMedia, + mediaData = {}, +}) { + const { t } = useTranslation("image-text-admin"); + + useEffect(() => { + const currentLang = i18next.language; + if (!i18next.hasResourceBundle(currentLang, "image-text-admin")) { + i18next.addResourceBundle( + currentLang, + "image-text-admin", + adminTranslations["image-text-admin"], + true, + true, + ); + } + }, []); + + return ( + <> +

{t("image-text-header")}

+
+ {t("content-sub-header")} + + +