From 0b8a7f66ee3fa26687bbd015df0d011cdd59090c Mon Sep 17 00:00:00 2001 From: Robbe Bierebeeck Date: Wed, 26 Aug 2026 17:19:49 +0200 Subject: [PATCH 01/12] refactor(ARC-3813): extract the shared ie-object picker field IE_OBJECT_WITH_SNIPPET_TIME_FIELDS held the object picker inline, so a block that points at an object without a snippet could not reuse it. Moves the field to defaults.ts next to the other field helpers and has the snippet helper call it, with the fields it resets on change as a parameter. No behaviour change: the timeline and video blocks get the same field they had. --- .../components/blocks/defaults.ts | 43 +++++++++++++++++++ .../helpers/snippet-time-fields.ts | 34 ++------------- 2 files changed, 47 insertions(+), 30 deletions(-) diff --git a/ui/src/react-admin/modules/content-page/components/blocks/defaults.ts b/ui/src/react-admin/modules/content-page/components/blocks/defaults.ts index 3fdaf9aaf..028d9be6d 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/defaults.ts +++ b/ui/src/react-admin/modules/content-page/components/blocks/defaults.ts @@ -1,4 +1,5 @@ import type { CheckboxProps, SelectOption } from '@viaa/avo2-components'; +import { AvoCoreContentPickerType } from '@viaa/avo2-types'; import { GET_ALIGN_OPTIONS } from '~modules/content-page/const/get-align-options'; import { GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF, @@ -13,9 +14,12 @@ import type { RichTextEditorWrapperProps } from '~shared/components/RichTextEdit import type { UserGroupSelectProps } from '~shared/components/UserGroupSelect/UserGroupSelect'; import { RICH_TEXT_EDITOR_OPTIONS_FULL_WITHOUT_ALIGN } from '~shared/consts/rich-text-editor.consts'; import { isAvo } from '~shared/helpers/is-avo'; +import { IeObjectType } from '~shared/helpers/map-format-to-type.ts'; import { tText } from '~shared/helpers/translation-functions'; import { validateRequiredValue } from '~shared/helpers/validation.ts'; +import { HET_ARCHIEF } from '~shared/types'; import { SpecialUserGroups } from '~shared/types/authentication.types'; +import type { PickerItem } from '~shared/types/content-picker.ts'; import { Color, type ContentBlockComponentsConfig, @@ -25,6 +29,7 @@ import { type CustomBackground, type DefaultContentBlockState, type GradientColor, + type IsVisibleFunc, type PaddingFieldState, } from '../../types/content-block.types'; @@ -239,3 +244,41 @@ export const COPYRIGHT_STATE = (): CopyrightComponentState => ({ copyrightIconVisible: true, copyrightText: '', }); + +/** + * The object picker every block uses to point at one ie-object: it searches by title and stores the + * pid, so nobody has to type one by hand. + * + * The state key is `mediaItem` wherever this is used, because `generateFieldAttributes` reads + * `state.item || state.mediaItem` to tell a video-still picker which object to fetch stills for. + * A block without a still picker keeps the name anyway, so the proxy can read every block the same + * way. https://meemoo.atlassian.net/browse/ARC-3813 + */ +export const IE_OBJECT_FIELD = ( + allowedObjectTypes: IeObjectType[] = Object.values(IeObjectType), + isVisibleFunc: IsVisibleFunc = () => true, + fieldsToResetOnChange: string[] = [] +): ContentBlockField => ({ + label: tText('modules/content-page/helpers/snippet-time-fields___object', undefined, [ + HET_ARCHIEF, + ]), + editorType: ContentBlockEditor.ContentPicker, + editorProps: { + allowedTypes: [AvoCoreContentPickerType.IE_OBJECT], + hideTypeDropdown: true, + hideTargetSwitch: true, + ieObjectFormats: allowedObjectTypes, + }, + fieldsToResetOnChange, + validator: (value: PickerItem | undefined) => + value?.value + ? [] + : [ + tText( + 'modules/content-page/helpers/snippet-time-fields___een-object-is-verplicht', + undefined, + [HET_ARCHIEF] + ), + ], + isVisible: isVisibleFunc, +}); diff --git a/ui/src/react-admin/modules/content-page/helpers/snippet-time-fields.ts b/ui/src/react-admin/modules/content-page/helpers/snippet-time-fields.ts index 375e8f57b..dc1cc3718 100644 --- a/ui/src/react-admin/modules/content-page/helpers/snippet-time-fields.ts +++ b/ui/src/react-admin/modules/content-page/helpers/snippet-time-fields.ts @@ -1,7 +1,6 @@ import type { TextInputProps } from '@viaa/avo2-components'; -import { AvoCoreContentPickerType } from '@viaa/avo2-types'; import type { HetArchiefVideoBlockComponentState } from '~content-blocks/BlockHetArchiefVideo'; -import { TEXT_FIELD } from '~content-blocks/defaults.ts'; +import { IE_OBJECT_FIELD, TEXT_FIELD } from '~content-blocks/defaults.ts'; import { type ContentBlockComponentState, ContentBlockEditor, @@ -15,7 +14,6 @@ import { IeObjectType } from '~shared/helpers/map-format-to-type.ts'; import { snippetTimeToSeconds } from '~shared/helpers/parsers/duration.ts'; import { tText } from '~shared/helpers/translation-functions.ts'; import { HET_ARCHIEF } from '~shared/types'; -import type { PickerItem } from '~shared/types/content-picker.ts'; /** * Validates one of the two snippet time fields. @@ -148,33 +146,9 @@ export const IE_OBJECT_WITH_SNIPPET_TIME_FIELDS = ( allowedObjectTypes: IeObjectType[] = Object.values(IeObjectType), isVisibleFunc: IsVisibleFunc = () => true ): Record => ({ - // Named `mediaItem` on purpose: generateFieldAttributes reads `state.item || - // state.mediaItem` to tell the still picker below which object to fetch stills for. - mediaItem: { - label: tText('modules/content-page/helpers/snippet-time-fields___object', undefined, [ - HET_ARCHIEF, - ]), - editorType: ContentBlockEditor.ContentPicker, - editorProps: { - allowedTypes: [AvoCoreContentPickerType.IE_OBJECT], - hideTypeDropdown: true, - hideTargetSwitch: true, - // Only video and audio objects can be played - ieObjectFormats: allowedObjectTypes, - }, - fieldsToResetOnChange: ['startTime', 'endTime'], - validator: (value: PickerItem | undefined) => - value?.value - ? [] - : [ - tText( - 'modules/content-page/helpers/snippet-time-fields___een-object-is-verplicht', - undefined, - [HET_ARCHIEF] - ), - ], - isVisible: isVisibleFunc, - }, + // Changing the object clears the times: they are offsets into that object and mean nothing once + // it is a different one. + mediaItem: IE_OBJECT_FIELD(allowedObjectTypes, isVisibleFunc, ['startTime', 'endTime']), startTime: SNIPPET_TIME_FIELD( 'startTime', tText('modules/content-page/helpers/snippet-time-fields___starttijd', undefined, [HET_ARCHIEF]), From cc2a75c4a9bed128a1ed3ab27e61f94b62c7ae7b Mon Sep 17 00:00:00 2001 From: Robbe Bierebeeck Date: Wed, 26 Aug 2026 17:20:12 +0200 Subject: [PATCH 02/12] feat(ARC-3813): add the driekeuzespeler content block A new HET_ARCHIEF content block that offers three curated interests at random, each a tile with its object's thumbnail and a coloured pill. Clicking a tile opens the object in a modal; the shuffle CTA deals three more. Config holds a title, one colour pair per tile position, the shuffle label and 3 to 200 interests. The colours belong to the position rather than the interest, because a shuffle decides which interest lands where. Also adds a ThemeSelect editor type for picking the one theme an interest links to, and a config slot for the host's IIIF viewer so a newspaper opens in the real viewer rather than a flat still. Two parts of the FA are not in here: the secondary CTA needs a theme search filter that does not exist yet, and the hover animation still awaits the example the FA lists as an open question. --- ui/all-translations-het-archief.json | 380 ++++++++++++ ui/all-translations-het-archief.sql | 38 ++ .../react-admin/core/config/config.types.ts | 23 + .../ContentBlockRenderer.const.tsx | 3 + .../BlockDriekeuzespeler.editorconfig.test.ts | 144 +++++ .../BlockDriekeuzespeler.editorconfig.ts | 222 +++++++ .../BlockDriekeuzespeler.helpers.test.ts | 110 ++++ .../BlockDriekeuzespeler.helpers.ts | 74 +++ .../BlockDriekeuzespeler.roundtrip.test.ts | 86 +++ .../BlockDriekeuzespeler.scss | 580 ++++++++++++++++++ .../BlockDriekeuzespeler.service.ts | 78 +++ .../BlockDriekeuzespeler.tsx | 174 ++++++ .../BlockDriekeuzespelerModal.scss | 47 ++ .../BlockDriekeuzespelerModal.test.ts | 30 + .../BlockDriekeuzespelerModal.tsx | 141 +++++ .../hooks/useGetDriekeuzespelerObjects.ts | 22 + .../useGetDriekeuzespelerPlayableObject.ts | 55 ++ .../blocks/BlockDriekeuzespeler/index.ts | 5 + .../const/content-block-config-map.ts | 2 + .../const/content-block-initial-state-map.ts | 2 + .../content-page/const/editor-types.consts.ts | 2 + .../const/get-content-block-type-options.ts | 8 + .../content-page/types/content-block.types.ts | 51 ++ .../components/ThemeSelect/ThemeSelect.tsx | 82 +++ .../modules/shared/hooks/useGetAllThemes.ts | 28 + .../ie-objects-service/ie-objects.service.ts | 14 +- .../shared/styles/mixins/_typography.scss | 5 + .../react-admin/modules/shared/types/index.ts | 2 + ui/src/shared/helpers/admin-core-config.tsx | 1 + ui/src/shared/translations/hetArchief/nl.json | 22 + 30 files changed, 2429 insertions(+), 2 deletions(-) create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.editorconfig.test.ts create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.editorconfig.ts create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.helpers.test.ts create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.helpers.ts create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.roundtrip.test.ts create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.scss create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.service.ts create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.tsx create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespelerModal.scss create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespelerModal.test.ts create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespelerModal.tsx create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/hooks/useGetDriekeuzespelerObjects.ts create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/hooks/useGetDriekeuzespelerPlayableObject.ts create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/index.ts create mode 100644 ui/src/react-admin/modules/shared/components/ThemeSelect/ThemeSelect.tsx create mode 100644 ui/src/react-admin/modules/shared/hooks/useGetAllThemes.ts diff --git a/ui/all-translations-het-archief.json b/ui/all-translations-het-archief.json index 89e41fc8e..5415bfc9d 100644 --- a/ui/all-translations-het-archief.json +++ b/ui/all-translations-het-archief.json @@ -17539,6 +17539,386 @@ "value": "Achtergrondkleur tekstvak is verplicht", "value_type": "TEXT" }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "driekeuzespeler", + "language": "nl", + "value": "Driekeuzespeler", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "driekeuzespeler", + "language": "en", + "value": "Three-choice player", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "titel", + "language": "nl", + "value": "Titel", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "titel", + "language": "en", + "value": "Title", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "titel-is-verplicht", + "language": "nl", + "value": "Titel is verplicht", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "titel-is-verplicht", + "language": "en", + "value": "Title is required", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "label-van-de-shuffle-knop", + "language": "nl", + "value": "Label van de shuffleknop", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "label-van-de-shuffle-knop", + "language": "en", + "value": "Shuffle button label", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "label-van-de-shuffle-knop-is-verplicht", + "language": "nl", + "value": "Label van de shuffleknop is verplicht", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "label-van-de-shuffle-knop-is-verplicht", + "language": "en", + "value": "Shuffle button label is required", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "kleuren-van-de-tegel", + "language": "nl", + "value": "Tegel", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "kleuren-van-de-tegel", + "language": "en", + "value": "Tile", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "kleuren-van-de-interesse-op-tegel", + "language": "nl", + "value": "Kleuren van de interesse op tegel", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "kleuren-van-de-interesse-op-tegel", + "language": "en", + "value": "Colours of the interest on tile", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "interesse", + "language": "nl", + "value": "Interesse", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "interesse", + "language": "en", + "value": "Interest", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "naam-van-de-interesse", + "language": "nl", + "value": "Naam van de interesse", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "naam-van-de-interesse", + "language": "en", + "value": "Name of the interest", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "naam-van-de-interesse-is-verplicht", + "language": "nl", + "value": "Naam van de interesse is verplicht", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "naam-van-de-interesse-is-verplicht", + "language": "en", + "value": "Name of the interest is required", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "gerelateerd-thema", + "language": "nl", + "value": "Gerelateerd thema", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "gerelateerd-thema", + "language": "en", + "value": "Related theme", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "een-thema-is-verplicht", + "language": "nl", + "value": "Een thema is verplicht", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "een-thema-is-verplicht", + "language": "en", + "value": "A theme is required", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "voeg-een-interesse-toe", + "language": "nl", + "value": "Voeg een interesse toe", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "voeg-een-interesse-toe", + "language": "en", + "value": "Add an interest", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "verwijder-deze-interesse", + "language": "nl", + "value": "Verwijder deze interesse", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "verwijder-deze-interesse", + "language": "en", + "value": "Delete this interest", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "toon-me-iets-anders", + "language": "nl", + "value": "Toon me iets anders", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "toon-me-iets-anders", + "language": "en", + "value": "Show me something else", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/const/get-content-block-type-options", + "key": "driekeuzespeler", + "language": "nl", + "value": "Driekeuzespeler", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/const/get-content-block-type-options", + "key": "driekeuzespeler", + "language": "en", + "value": "Three-choice player", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "react-admin/modules/shared/components/theme-select/theme-select", + "key": "kies-een-thema", + "language": "nl", + "value": "Kies een thema", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "react-admin/modules/shared/components/theme-select/theme-select", + "key": "kies-een-thema", + "language": "en", + "value": "Choose a theme", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "react-admin/modules/shared/components/theme-select/theme-select", + "key": "geen-themas-gevonden", + "language": "nl", + "value": "Geen thema's gevonden", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "react-admin/modules/shared/components/theme-select/theme-select", + "key": "geen-themas-gevonden", + "language": "en", + "value": "No themes found", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "react-admin/modules/shared/components/theme-select/theme-select", + "key": "bezig-met-laden", + "language": "nl", + "value": "Bezig met laden", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "react-admin/modules/shared/components/theme-select/theme-select", + "key": "bezig-met-laden", + "language": "en", + "value": "Loading", + "value_type": "TEXT" + }, { "id": "", "app": "HET_ARCHIEF", diff --git a/ui/all-translations-het-archief.sql b/ui/all-translations-het-archief.sql index b5a607ec6..61a8fc073 100644 --- a/ui/all-translations-het-archief.sql +++ b/ui/all-translations-het-archief.sql @@ -1377,6 +1377,44 @@ INSERT INTO app.translations ("component", "location", "key", "value", "value_ty INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-double-banner/block-double-banner', 'tekstkleur', 'Tekstkleur', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Tekstkleur', value_type = 'TEXT'; INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-double-banner/block-double-banner', 'tekstkleur-is-verplicht', 'Tekstkleur is verplicht', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Tekstkleur is verplicht', value_type = 'TEXT'; INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-double-banner/block-double-banner', 'tekstkleur-is-verplicht', 'Tekstkleur is verplicht', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Tekstkleur is verplicht', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'driekeuzespeler', 'Driekeuzespeler', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Driekeuzespeler', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'driekeuzespeler', 'Three-choice player', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Three-choice player', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'titel', 'Titel', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Titel', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'titel', 'Title', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Title', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'titel-is-verplicht', 'Titel is verplicht', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Titel is verplicht', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'titel-is-verplicht', 'Title is required', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Title is required', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'label-van-de-shuffle-knop', 'Label van de shuffleknop', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Label van de shuffleknop', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'label-van-de-shuffle-knop', 'Shuffle button label', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Shuffle button label', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'label-van-de-shuffle-knop-is-verplicht', 'Label van de shuffleknop is verplicht', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Label van de shuffleknop is verplicht', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'label-van-de-shuffle-knop-is-verplicht', 'Shuffle button label is required', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Shuffle button label is required', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'kleuren-van-de-tegel', 'Tegel', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Tegel', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'kleuren-van-de-tegel', 'Tile', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Tile', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'kleuren-van-de-interesse-op-tegel', 'Kleuren van de interesse op tegel', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Kleuren van de interesse op tegel', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'kleuren-van-de-interesse-op-tegel', 'Colours of the interest on tile', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Colours of the interest on tile', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'interesse', 'Interesse', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Interesse', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'interesse', 'Interest', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Interest', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'naam-van-de-interesse', 'Naam van de interesse', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Naam van de interesse', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'naam-van-de-interesse', 'Name of the interest', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Name of the interest', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'naam-van-de-interesse-is-verplicht', 'Naam van de interesse is verplicht', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Naam van de interesse is verplicht', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'naam-van-de-interesse-is-verplicht', 'Name of the interest is required', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Name of the interest is required', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'gerelateerd-thema', 'Gerelateerd thema', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Gerelateerd thema', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'gerelateerd-thema', 'Related theme', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Related theme', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'een-thema-is-verplicht', 'Een thema is verplicht', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Een thema is verplicht', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'een-thema-is-verplicht', 'A theme is required', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'A theme is required', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'voeg-een-interesse-toe', 'Voeg een interesse toe', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Voeg een interesse toe', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'voeg-een-interesse-toe', 'Add an interest', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Add an interest', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'verwijder-deze-interesse', 'Verwijder deze interesse', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Verwijder deze interesse', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'verwijder-deze-interesse', 'Delete this interest', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Delete this interest', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'toon-me-iets-anders', 'Toon me iets anders', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Toon me iets anders', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'toon-me-iets-anders', 'Show me something else', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Show me something else', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/const/get-content-block-type-options', 'driekeuzespeler', 'Driekeuzespeler', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Driekeuzespeler', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/const/get-content-block-type-options', 'driekeuzespeler', 'Three-choice player', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Three-choice player', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'react-admin/modules/shared/components/theme-select/theme-select', 'kies-een-thema', 'Kies een thema', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Kies een thema', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'react-admin/modules/shared/components/theme-select/theme-select', 'kies-een-thema', 'Choose a theme', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Choose a theme', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'react-admin/modules/shared/components/theme-select/theme-select', 'geen-themas-gevonden', 'Geen thema''s gevonden', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Geen thema''s gevonden', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'react-admin/modules/shared/components/theme-select/theme-select', 'geen-themas-gevonden', 'No themes found', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'No themes found', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'react-admin/modules/shared/components/theme-select/theme-select', 'bezig-met-laden', 'Bezig met laden', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Bezig met laden', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'react-admin/modules/shared/components/theme-select/theme-select', 'bezig-met-laden', 'Loading', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Loading', value_type = 'TEXT'; INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-hero-carousel/block-hero-carousel', 'achtergrondafbeelding-krijgt-voorrang-op-achtergrondkleur', 'Achtergrondafbeelding (krijgt voorrang op achtergrondkleur)', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Achtergrondafbeelding (krijgt voorrang op achtergrondkleur)', value_type = 'TEXT'; INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-hero-carousel/block-hero-carousel', 'achtergrondafbeelding-krijgt-voorrang-op-achtergrondkleur', 'Achtergrondafbeelding (krijgt voorrang op achtergrondkleur)', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Achtergrondafbeelding (krijgt voorrang op achtergrondkleur)', value_type = 'TEXT'; INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-hero-carousel/block-hero-carousel', 'animatie-tekst', 'Animatie tekst', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Animatie tekst', value_type = 'TEXT'; diff --git a/ui/src/react-admin/core/config/config.types.ts b/ui/src/react-admin/core/config/config.types.ts index a329828d3..bfed51915 100644 --- a/ui/src/react-admin/core/config/config.types.ts +++ b/ui/src/react-admin/core/config/config.types.ts @@ -117,6 +117,15 @@ export interface AdminConfig { }; defaultAudioStill: string; flowplayer?: FC; // User by avo for + /** + * The IIIF viewer a newspaper opens in, injected by the host the way `flowplayer` is. + * + * It takes only the object's id: the viewer needs the object's page list and a ticket-service + * token per page, and those hooks live in the host app. Admin-core has no equivalent, so a + * block that leaves this unset falls back to the flat IIIF detail image. + * https://meemoo.atlassian.net/browse/ARC-3813 + */ + iiifViewer?: FC; buttonTypes: () => { label: string; value: string }[]; enableMultiLanguage: boolean; }; @@ -183,6 +192,17 @@ export interface AdminConfig { }; } +/** + * What the injected IIIF viewer is handed. Deliberately just the id: everything else the viewer + * needs -- the page list, a ticket-service token per page, the page and overlay state -- is the + * host's to resolve. https://meemoo.atlassian.net/browse/ARC-3813 + */ +export interface IiifViewerConfigProps { + schemaIdentifier: string; + /** Names the viewer for assistive technology, since the object title lives outside it. */ + title?: string; +} + /** * The icons admin-core renders through the client config. Every client maps each of these onto one * of its own icons in `icon.componentProps`, so each client decides what eg: a warning looks like. @@ -208,6 +228,9 @@ export enum AdminCoreIconName { ChevronLeft = 'chevronLeft', Clock = 'clock', Collection = 'collection', + // The fixed icon on the driekeuzespeler's shuffle CTA, "collection-shuffle" in the design. + // https://meemoo.atlassian.net/browse/ARC-3813 + CollectionShuffle = 'collectionShuffle', Copy = 'copy', Delete = 'delete', Edit = 'edit', diff --git a/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.const.tsx b/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.const.tsx index 28f438014..86f18e7d4 100644 --- a/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.const.tsx +++ b/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.const.tsx @@ -7,6 +7,7 @@ import { BlockCardsWithoutDescription } from '~content-blocks/BlockCardsWithoutD import { BlockContentPageMeta } from '~content-blocks/BlockContentPageMeta'; import { BlockCTAsWrapper } from '~content-blocks/BlockCTAs'; import { BlockDoubleBanner } from '~content-blocks/BlockDoubleBanner'; +import { BlockDriekeuzespeler } from '~content-blocks/BlockDriekeuzespeler'; import { BlockEventbrite } from '~content-blocks/BlockEventbrite'; import { BlockHeading } from '~content-blocks/BlockHeading'; import { BlockHeroCarousel } from '~content-blocks/BlockHeroCarousel'; @@ -111,6 +112,7 @@ export function GET_BLOCK_COMPONENT( [ContentBlockType.Timeline]: BlockTimeline, [ContentBlockType.ImageCarousel]: BlockImageCarousel, [ContentBlockType.TitleWithParallax]: BlockTitleWithParallax, + [ContentBlockType.Driekeuzespeler]: BlockDriekeuzespeler, // Avo specific blocks [ContentBlockType.MediaGrid]: loadComponentFromConfig(ContentBlockType.MediaGrid), @@ -190,6 +192,7 @@ export const PLAYABLE_DISPLAY_DATA_BLOCKS = [ ContentBlockType.HetArchiefVideo, ContentBlockType.HeroCarousel, ContentBlockType.Timeline, + ContentBlockType.Driekeuzespeler, ]; /** diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.editorconfig.test.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.editorconfig.test.ts new file mode 100644 index 000000000..0c12d9544 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.editorconfig.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { + ContentBlockField, + ContentBlockFieldGroup, + DriekeuzespelerBlockComponentState, +} from '~modules/content-page/types/content-block.types'; +import { Color, ContentBlockEditor } from '~modules/content-page/types/content-block.types'; +import { + DRIEKEUZESPELER_CONFIG, + DRIEKEUZESPELER_MAX_INTERESTS, + DRIEKEUZESPELER_MIN_INTERESTS, + DRIEKEUZESPELER_TILE_COUNT, + INITIAL_DRIEKEUZESPELER_COMPONENTS_STATE, +} from './BlockDriekeuzespeler.editorconfig'; + +vi.mock('~shared/helpers/translation-functions', () => ({ + tText: (key: string) => key, +})); + +vi.mock('~shared/helpers/is-avo', () => ({ + isAvo: () => false, +})); + +const config = DRIEKEUZESPELER_CONFIG(); +const group = (key: string): ContentBlockFieldGroup => + config.components.fields[key] as ContentBlockFieldGroup; +const field = (key: string): ContentBlockField => + config.components.fields[key] as ContentBlockField; + +describe('DRIEKEUZESPELER_CONFIG', () => { + it('starts with one colour entry per tile and the minimum number of interests', () => { + const state = INITIAL_DRIEKEUZESPELER_COMPONENTS_STATE(); + + expect(state.tileColors).toHaveLength(DRIEKEUZESPELER_TILE_COUNT); + expect(state.interests).toHaveLength(DRIEKEUZESPELER_MIN_INTERESTS); + }); + + it('defaults every tile to no background and black text, as the FA asks', () => { + const state = INITIAL_DRIEKEUZESPELER_COMPONENTS_STATE(); + + for (const entry of state.tileColors) { + expect(entry).toEqual({ + backgroundColor: Color.Transparent, + textColor: Color.Black, + }); + } + }); + + it('renders the fields in the order the FA lists them', () => { + // Object.keys order is the render order (ContentBlockFormGroup), so this is a real UI contract: + // title, the colours of the three tiles, the shuffle label, the interests. + expect(Object.keys(config.components.fields)).toEqual([ + 'title', + 'tileColors', + 'shuffleButtonLabel', + 'interests', + ]); + }); + + it('gives every initial interest empty required fields', () => { + const state = INITIAL_DRIEKEUZESPELER_COMPONENTS_STATE(); + + for (const interest of state.interests) { + expect(interest).toEqual({ name: '', mediaItem: undefined, themeId: '' }); + } + }); + + it('holds the interests between 3 and 200', () => { + expect(group('interests').min).toBe(DRIEKEUZESPELER_MIN_INTERESTS); + expect(group('interests').max).toBe(DRIEKEUZESPELER_MAX_INTERESTS); + expect(DRIEKEUZESPELER_MAX_INTERESTS).toBe(200); + }); + + it('fixes the colour list at three, so the count cannot change', () => { + expect(group('tileColors').min).toBe(DRIEKEUZESPELER_TILE_COUNT); + expect(group('tileColors').max).toBe(DRIEKEUZESPELER_TILE_COUNT); + }); + + it('holds both colours of a tile in the same entry', () => { + // One group per tile, so the admin fills in a tile completely instead of jumping between two + // lists. https://meemoo.atlassian.net/browse/ARC-3813 + expect(Object.keys(group('tileColors').fields ?? {})).toEqual(['backgroundColor', 'textColor']); + }); + + it('marks both repeated sets as field groups, so FieldGenerator iterates them', () => { + // A group without `type: 'fieldGroup'` falls through to the single-field branch, and one + // without `repeat` renders once instead of per entry. + for (const key of ['tileColors', 'interests']) { + expect(group(key).type).toBe('fieldGroup'); + expect(group(key).repeat).toBeDefined(); + } + }); + + it('requires a title and a shuffle label', () => { + expect(field('title').validator?.('')).toEqual(expect.arrayContaining([expect.any(String)])); + expect(field('shuffleButtonLabel').validator?.('')).toEqual( + expect.arrayContaining([expect.any(String)]) + ); + expect(field('title').validator?.('Ontdek')).toEqual([]); + }); + + it('requires a name, an object and a theme on every interest', () => { + const interestFields = group('interests').fields; + + // The object is picked, not typed, so its filled value is a picker item rather than a string. + const filledValues: Record = { + name: 'Wielrennen', + mediaItem: { type: 'IE_OBJECT', value: '086348mc8s' }, + themeId: 'theme-1', + }; + + for (const [key, filled] of Object.entries(filledValues)) { + expect(interestFields[key].validator?.('')).toEqual( + expect.arrayContaining([expect.any(String)]) + ); + expect(interestFields[key].validator?.(filled)).toEqual([]); + } + }); + + it('picks the object with the shared object picker, so nobody types a pid by hand', () => { + // Every block that points at an ie-object uses this field under the `mediaItem` key, which is + // also the key the proxy reads. https://meemoo.atlassian.net/browse/ARC-3813 + const mediaItem = group('interests').fields.mediaItem; + + expect(mediaItem.editorType).toBe(ContentBlockEditor.ContentPicker); + expect(mediaItem.editorProps).toMatchObject({ + allowedTypes: ['IE_OBJECT'], + hideTypeDropdown: true, + }); + }); + + it('picks the theme with the theme select, so an interest links to exactly one theme', () => { + expect(group('interests').fields.themeId.editorType).toBe(ContentBlockEditor.ThemeSelect); + }); + + it('types its own initial state', () => { + // Compile-time check that the state the editor stores matches the state the block renders. + const state: DriekeuzespelerBlockComponentState = + INITIAL_DRIEKEUZESPELER_COMPONENTS_STATE() as DriekeuzespelerBlockComponentState; + + expect(state.title).toBe(''); + expect(state.shuffleButtonLabel).toBe(''); + }); +}); diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.editorconfig.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.editorconfig.ts new file mode 100644 index 000000000..52c3d7727 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.editorconfig.ts @@ -0,0 +1,222 @@ +import { + BACKGROUND_COLOR_FIELD, + BLOCK_FIELD_DEFAULTS, + BLOCK_STATE_DEFAULTS, + FOREGROUND_COLOR_FIELD, + IE_OBJECT_FIELD, + TEXT_FIELD, +} from '~content-blocks/defaults'; +import { + GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF, + GET_FOREGROUND_COLOR_OPTIONS_ARCHIEF, +} from '~modules/content-page/const/get-color-options.ts'; +import { + Color, + type ContentBlockConfig, + ContentBlockEditor, + ContentBlockType, + type DefaultContentBlockState, + type DriekeuzespelerInterestState, + type DriekeuzespelerTileColors, +} from '~modules/content-page/types/content-block.types'; +import { tText } from '~shared/helpers/translation-functions'; +import { validateRequiredValue } from '~shared/helpers/validation.ts'; +import { HET_ARCHIEF } from '~shared/types'; + +/** + * The block always renders three tiles, so the color list is fixed at three entries and the + * interest list needs at least three to fill them. + * https://meemoo.atlassian.net/wiki/spaces/HA2/pages/6218383419 + */ +export const DRIEKEUZESPELER_TILE_COUNT = 3; +export const DRIEKEUZESPELER_MIN_INTERESTS = 3; +export const DRIEKEUZESPELER_MAX_INTERESTS = 200; + +// "geen" and "zwart" are the defaults the FA asks for. +const INITIAL_TILE_COLORS_STATE = (): DriekeuzespelerTileColors => ({ + backgroundColor: Color.Transparent, + textColor: Color.Black, +}); + +const INITIAL_DRIEKEUZESPELER_INTEREST_STATE = (): DriekeuzespelerInterestState => ({ + name: '', + // The object picker fills this in; an empty entry has nothing selected yet. + mediaItem: undefined, + themeId: '', +}); + +// The key order is the order the editor renders the fields in, and it follows the FA. +export const INITIAL_DRIEKEUZESPELER_COMPONENTS_STATE = () => ({ + title: '', + tileColors: Array.from({ length: DRIEKEUZESPELER_TILE_COUNT }, () => INITIAL_TILE_COLORS_STATE()), + shuffleButtonLabel: '', + // Start at the minimum, so a freshly added block is one valid tile-set away from being usable. + interests: Array.from({ length: DRIEKEUZESPELER_MIN_INTERESTS }, () => + INITIAL_DRIEKEUZESPELER_INTEREST_STATE() + ), +}); + +export const INITIAL_DRIEKEUZESPELER_BLOCK_STATE = (): DefaultContentBlockState => ({ + ...BLOCK_STATE_DEFAULTS(), + // The design is a full-bleed band: the background spans the viewport and the tiles span 1222 of + // the 1440 frame. Inside the default 940px content column everything renders at ~77% of the + // design size. https://meemoo.atlassian.net/browse/ARC-3813 + fullWidth: true, +}); + +export const DRIEKEUZESPELER_CONFIG = (position = 0): ContentBlockConfig => ({ + position, + name: tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___driekeuzespeler', + undefined, + [HET_ARCHIEF] + ), + type: ContentBlockType.Driekeuzespeler, + components: { + state: INITIAL_DRIEKEUZESPELER_COMPONENTS_STATE(), + fields: { + title: TEXT_FIELD( + { + label: tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___titel', + undefined, + [HET_ARCHIEF] + ), + }, + tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___titel-is-verplicht', + undefined, + [HET_ARCHIEF] + ) + ), + // One fixed-length group of three, one entry per tile. `repeat` is what makes FieldGenerator + // walk the array and render one sub-form per entry; equal min and max then hide both the add + // and the delete button, so the count is fixed at three and the admin cannot change it. + // + // A tile's colours cannot sit next to its label: the colours belong to the tile position, + // while the label travels with whichever interest a shuffle puts there. That is why there are + // three colour entries and three to two hundred interests. + tileColors: { + // FieldGenerator renders this label with the entry number after it, so it has to name the + // tile and not the interest: "op tegel 1" cannot be read as "interesse 1". The colour + // belongs to the position, and a shuffle decides which interest lands there. + // + // Both colours of a tile sit in one group, so the admin fills in a tile completely before + // moving to the next. The FA lists them as two bullets, but that list is headed + // "mogelijkheden" and describes the two properties -- it does not prescribe the grouping + // in contentbeheer. What the FA does state is kept: three colours for tegel 1, 2 and 3, + // both required, defaults "geen" and "zwart". + label: tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___kleuren-van-de-interesse-op-tegel', + undefined, + [HET_ARCHIEF] + ), + type: 'fieldGroup', + min: DRIEKEUZESPELER_TILE_COUNT, + max: DRIEKEUZESPELER_TILE_COUNT, + repeat: { + // Never used: the add button is hidden because min equals max. It is here because + // FieldGenerator only iterates a group that carries a `repeat` descriptor. + defaultState: INITIAL_TILE_COLORS_STATE(), + }, + fields: { + backgroundColor: BACKGROUND_COLOR_FIELD( + tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___achtergrondkleur', + undefined, + [HET_ARCHIEF] + ), + GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF()[0] + ), + textColor: FOREGROUND_COLOR_FIELD( + tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___tekstkleur', + undefined, + [HET_ARCHIEF] + ), + GET_FOREGROUND_COLOR_OPTIONS_ARCHIEF()[0] + ), + }, + }, + shuffleButtonLabel: TEXT_FIELD( + { + label: tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___label-van-de-shuffle-knop', + undefined, + [HET_ARCHIEF] + ), + }, + tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___label-van-de-shuffle-knop-is-verplicht', + undefined, + [HET_ARCHIEF] + ) + ), + interests: { + label: tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___interesse', + undefined, + [HET_ARCHIEF] + ), + type: 'fieldGroup', + min: DRIEKEUZESPELER_MIN_INTERESTS, + max: DRIEKEUZESPELER_MAX_INTERESTS, + fields: { + name: TEXT_FIELD( + { + label: tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___naam-van-de-interesse', + undefined, + [HET_ARCHIEF] + ), + }, + tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___naam-van-de-interesse-is-verplicht', + undefined, + [HET_ARCHIEF] + ) + ), + // The FA asks for "Pid of fragmentId". That is the value, and the shared object picker + // stores exactly it while letting the admin search by title instead of typing a pid. + // Every other block that points at an ie-object uses the same field under the same + // `mediaItem` key, which is what the proxy reads. + mediaItem: IE_OBJECT_FIELD(), + themeId: { + label: tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___gerelateerd-thema', + undefined, + [HET_ARCHIEF] + ), + editorType: ContentBlockEditor.ThemeSelect, + validator: (value: string) => + validateRequiredValue( + value, + tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___een-thema-is-verplicht', + undefined, + [HET_ARCHIEF] + ) + ), + }, + }, + repeat: { + defaultState: INITIAL_DRIEKEUZESPELER_INTEREST_STATE(), + addButtonLabel: tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___voeg-een-interesse-toe', + undefined, + [HET_ARCHIEF] + ), + deleteButtonLabel: tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___verwijder-deze-interesse', + undefined, + [HET_ARCHIEF] + ), + }, + }, + }, + }, + block: { + state: INITIAL_DRIEKEUZESPELER_BLOCK_STATE(), + fields: BLOCK_FIELD_DEFAULTS(), + }, +}); diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.helpers.test.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.helpers.test.ts new file mode 100644 index 000000000..89f54d33c --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.helpers.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { pickNextSelection, pickRandomIndices } from './BlockDriekeuzespeler.helpers'; + +/** A deterministic stand-in for Math.random that walks the given values and then repeats the last. */ +const seededRandom = (values: number[]): (() => number) => { + let call = 0; + + return () => values[Math.min(call++, values.length - 1)]; +}; + +describe('pickRandomIndices', () => { + it('picks the requested number of indices', () => { + expect(pickRandomIndices(200, 3)).toHaveLength(3); + }); + + it('never repeats an index', () => { + for (let run = 0; run < 200; run++) { + const picked = pickRandomIndices(5, 3); + + expect(new Set(picked).size).toBe(picked.length); + } + }); + + it('only returns indices that exist', () => { + for (let run = 0; run < 200; run++) { + for (const index of pickRandomIndices(4, 3)) { + expect(index).toBeGreaterThanOrEqual(0); + expect(index).toBeLessThan(4); + } + } + }); + + it('returns every index, shuffled, when there are fewer interests than tiles', () => { + expect(pickRandomIndices(2, 3).sort()).toEqual([0, 1]); + }); + + it('returns nothing for an empty list or a zero count', () => { + expect(pickRandomIndices(0, 3)).toEqual([]); + expect(pickRandomIndices(5, 0)).toEqual([]); + }); + + it('can reach every index across many runs, so nothing is unreachable', () => { + const seen = new Set(); + + for (let run = 0; run < 500; run++) { + for (const index of pickRandomIndices(5, 3)) { + seen.add(index); + } + } + + expect([...seen].sort()).toEqual([0, 1, 2, 3, 4]); + }); + + it('is driven entirely by the injected random source', () => { + // All zeroes means "always swap with the first remaining index", which leaves the head of the + // list in place. + expect(pickRandomIndices(5, 3, seededRandom([0]))).toEqual([0, 1, 2]); + }); +}); + +describe('pickNextSelection', () => { + it('never hands back the arrangement the visitor is already looking at', () => { + for (const total of [3, 4, 10, 200]) { + for (let run = 0; run < 200; run++) { + expect(pickNextSelection(total, 3, [0, 1, 2])).not.toEqual([0, 1, 2]); + } + } + }); + + it('keeps working when the list holds exactly as many interests as there are tiles', () => { + // The three interests cannot change here, only the tile each one lands on, so it must still + // return a full selection rather than retry forever. + const next = pickNextSelection(3, 3, [0, 1, 2]); + + expect([...next].sort()).toEqual([0, 1, 2]); + }); + + it('reorders the tiles when the list holds exactly as many interests as there are tiles', () => { + // A source that draws the previous order first and a different one after it. The retry has to + // take the second draw: a tile's colours and thumbnail belong to its position, so a reorder is + // a visibly different block. + expect(pickNextSelection(3, 3, [0, 1, 2], seededRandom([0, 0, 0, 0.99, 0, 0]))).toEqual([ + 2, 1, 0, + ]); + }); + + it('gives up when a single interest leaves nothing to reorder', () => { + expect(pickNextSelection(1, 3, [0])).toEqual([0]); + }); + + it('handles a first shuffle with no previous selection', () => { + expect(pickNextSelection(10, 3, [])).toHaveLength(3); + }); + + it('still returns a full selection when it retries', () => { + // A random source that first reproduces the previous set forces the retry path. + const next = pickNextSelection(4, 3, [0, 1, 2], seededRandom([0, 0, 0, 0.99, 0, 0])); + + expect(next).toHaveLength(3); + expect(new Set(next).size).toBe(3); + }); + + it('gives up after the retry cap instead of looping forever', () => { + // A random source that always reproduces the previous set. Every retry draws the same three + // indices, so only the cap can end this. + const next = pickNextSelection(4, 3, [0, 1, 2], seededRandom([0])); + + expect(next).toEqual([0, 1, 2]); + }); +}); diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.helpers.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.helpers.ts new file mode 100644 index 000000000..276624780 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.helpers.ts @@ -0,0 +1,74 @@ +/** + * Picks `count` distinct indices out of `total`, in random order. + * + * Partial Fisher-Yates: it shuffles only as many positions as it needs, so picking 3 out of 200 + * costs three swaps instead of shuffling the whole list. + * + * When `total` is smaller than `count` every index comes back, still shuffled -- the caller renders + * fewer tiles rather than repeating an interest. The editor keeps this from happening by requiring + * at least three interests, but a saved block can predate that rule. + */ +export function pickRandomIndices( + total: number, + count: number, + random: () => number = Math.random +): number[] { + if (total <= 0 || count <= 0) { + return []; + } + + const indices = Array.from({ length: total }, (_unused, index) => index); + const picks = Math.min(count, total); + + for (let position = 0; position < picks; position++) { + const swapWith = position + Math.floor(random() * (total - position)); + + [indices[position], indices[swapWith]] = [indices[swapWith], indices[position]]; + } + + return indices.slice(0, picks); +} + +/** + * Picks a fresh selection for a shuffle. + * + * The FA keeps no history, so the previous selection is not remembered and can legitimately come + * back. What a shuffle must not do is leave the visitor looking at exactly what they were already + * looking at, so this retries until something moves. + * + * The comparison is by position, not by set. The selection is ordered, and a tile's colours and + * thumbnail belong to its position, so three interests dealt in a different order is a visibly + * different block -- which is what keeps the CTA useful at the configured minimum of three. + * + * The retries are capped. With one interest there is only one arrangement, so an uncapped loop + * would never end. Once the cap is reached the last draw stands: an unchanged block is a poor + * shuffle, but it is better than a hang. + * + * The cap is high because the draws are cheap and the worst honest case is thin: three interests + * have six arrangements, so one in six draws repeats. Twenty retries make a repeat reaching the + * visitor about one in 10^16, while a degenerate list costs twenty trivial draws and no more. + */ +const MAX_SHUFFLE_RETRIES = 20; + +export function pickNextSelection( + total: number, + count: number, + previous: number[], + random: () => number = Math.random +): number[] { + let next = pickRandomIndices(total, count, random); + + if (previous.length === 0) { + return next; + } + + const isUnchanged = (candidate: number[]) => + candidate.length === previous.length && + candidate.every((index, position) => index === previous[position]); + + for (let retry = 0; retry < MAX_SHUFFLE_RETRIES && isUnchanged(next); retry++) { + next = pickRandomIndices(total, count, random); + } + + return next; +} diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.roundtrip.test.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.roundtrip.test.ts new file mode 100644 index 000000000..cd412858c --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.roundtrip.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest'; +import { CONTENT_BLOCK_CONFIG_MAP } from '~modules/content-page/const/content-block-config-map'; +import { CONTENT_BLOCK_INITIAL_STATE_MAP } from '~modules/content-page/const/content-block-initial-state-map'; +import { ContentBlockType } from '~modules/content-page/types/content-block.types'; +import { REPEATABLE_CONTENT_BLOCKS } from '../../ContentBlockRenderer/ContentBlockRenderer.const'; +import { INITIAL_DRIEKEUZESPELER_COMPONENTS_STATE } from './BlockDriekeuzespeler.editorconfig'; + +vi.mock('~shared/helpers/translation-functions', () => ({ + tText: (key: string) => key, +})); + +vi.mock('~shared/helpers/is-avo', () => ({ + isAvo: () => false, +})); + +/** + * A content page stores `components.state` as JSON and reads it back on the next page load. These + * tests cover the ways that round trip can silently lose the block's nested arrays. They are not a + * substitute for opening the editor once against a real database, but they pin the parts that a + * database cannot fix if they are wrong. + */ +describe('Driekeuzespeler state round trip', () => { + it('keeps a filled-in state intact, nested arrays included', () => { + const saved = { + title: 'Waar wil je in duiken?', + shuffleButtonLabel: 'Toon me iets anders', + tileColors: [ + { backgroundColor: '#EFCA6A', textColor: '#000' }, + { backgroundColor: '#BDDEE7', textColor: '#000' }, + { backgroundColor: '#9B6072', textColor: '#FFF' }, + ], + interests: [ + { + name: 'Wielrennen', + mediaItem: { type: 'IE_OBJECT', value: 'abc123' }, + themeId: 'theme-1', + }, + { name: 'Kermis', mediaItem: { type: 'IE_OBJECT', value: 'def456' }, themeId: 'theme-2' }, + { name: 'Stoeten', mediaItem: { type: 'IE_OBJECT', value: 'ghi789' }, themeId: 'theme-3' }, + ], + }; + + const reloaded = JSON.parse(JSON.stringify(saved)); + + expect(reloaded).toEqual(saved); + expect(reloaded.tileColors).toHaveLength(3); + expect(reloaded.interests).toHaveLength(3); + }); + + it('is registered in the config and initial-state maps, so a saved block can be reopened', () => { + expect(CONTENT_BLOCK_CONFIG_MAP[ContentBlockType.Driekeuzespeler]).toBeDefined(); + expect(CONTENT_BLOCK_INITIAL_STATE_MAP[ContentBlockType.Driekeuzespeler]).toBeDefined(); + }); + + it('stays out of REPEATABLE_CONTENT_BLOCKS, so its state is read as an object', () => { + // A block in that list has its whole `components.state` treated as an array and handed to the + // preview as an `elements` prop. This block uses mechanism B, so being listed there would + // silently blank every field. + expect(REPEATABLE_CONTENT_BLOCKS).not.toContain(ContentBlockType.Driekeuzespeler); + }); + + it('starts from an object, not an array, which is what mechanism B requires', () => { + const config = CONTENT_BLOCK_CONFIG_MAP[ContentBlockType.Driekeuzespeler](); + + expect(Array.isArray(config.components.state)).toBe(false); + }); + + it('names its state keys exactly as the preview component reads them', () => { + // The renderer spreads `components.state` straight into the component's props, so a renamed + // key arrives as undefined instead of failing loudly. + expect(Object.keys(INITIAL_DRIEKEUZESPELER_COMPONENTS_STATE()).sort()).toEqual([ + 'interests', + 'shuffleButtonLabel', + 'tileColors', + 'title', + ]); + }); + + it('declares a field for every key in its state, and no key without a field', () => { + const config = CONTENT_BLOCK_CONFIG_MAP[ContentBlockType.Driekeuzespeler](); + + expect(Object.keys(config.components.fields).sort()).toEqual( + Object.keys(config.components.state).sort() + ); + }); +}); diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.scss new file mode 100644 index 000000000..456b315e1 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.scss @@ -0,0 +1,580 @@ +@use "sass:math"; +@use "../../../../shared/styles/settings/variables" as variables; +@use "../../../../shared/styles/settings/colors" as colors; +@use "../../../../shared/styles/mixins/focus" as focus; +@use "../../../../shared/styles/mixins/typography" as typography; + +// Every number below is measured from the designs in the "hetarchief.be — ontdekken" file: +// desktop node 2354:4891 (1440px frame), mobile node 2702:1568 (402px frame). +// https://www.figma.com/design/1yUd3vpjHXcMfI15dTeVYC/hetarchief.be-%E2%80%94-ontdekken?node-id=2354-4891 +// https://www.figma.com/design/1yUd3vpjHXcMfI15dTeVYC/hetarchief.be-%E2%80%94-ontdekken?node-id=2702-1568 +// The three tiles are hand placed, so each one is positioned by its own centre inside a container +// with the design's aspect ratio. Percentages keep the whole composition scaling as one. + +// Desktop: the "Keuzes" frame is 1222.013 x 512, inset inside the 1440 frame and centred. +$tiles-ratio-desktop: math.div(1222.013, 512); +$tiles-width-desktop: 84.862%; + +// Mobile: the tiles stack and overlap, spanning 402 x 842. +$tiles-ratio-mobile: math.div(402, 842); + +// The two outer tiles are tilted; the active tile stands upright. +$tile-tilt: 5deg; + +// Hover behaviour, taken from Bert's reference pen (codepen.io/bertyhell/pen/pvRKvbW). In the pen a +// click makes a card the active one; here hovering does it, which is what the FA describes: the +// tile comes to the foreground and is enlarged. +// +// So there is no separate hover look. A tile is either the active one or it is not, and hovering +// decides which. Without a hover the middle tile is active, exactly as the pen starts out. +// +// The pen holds the sizes in one base width plus two scales rather than two fixed widths, which is +// what lets the active look travel between tiles. The base is derived so the two scales land on the +// design's own rendered sizes: 29.46% inactive and 41.898% active at 1440. +$tile-scale-inactive: 0.9; +$tile-width-desktop-base: math.div(29.46%, $tile-scale-inactive); // 32.733% +$tile-scale-active-desktop: math.div(41.898%, $tile-width-desktop-base); // 1.28 + +// Mobile keeps its own pair: 78.109% inactive and 90.05% active at 402. +$tile-width-mobile-base: math.div(78.109%, $tile-scale-inactive); // 86.788% +$tile-scale-active-mobile: math.div(90.05%, $tile-width-mobile-base); // 1.038 + +// A tile the visitor hovers grows less than the middle tile does at rest. The design fixes the +// resting composition, not the hover, so this is the one number to turn when the growth reads too +// strong or too weak. At 1.1 a side tile goes from 29.46% to 36% of the tile area, and it still +// fits: 56.128% + 42.97% leaves it just inside the stage, so it needs no vertical lift. +// Mobile grows by 4% at rest already, so it is subtle there without a separate number. +$tile-scale-hover-desktop: 1.1; + +// The tilted tiles straighten by 3deg when they become active, as the pen does. Ours tilt 5deg +// rather than the pen's 6deg, so they land at 2deg instead of snapping upright. +$tile-active-tilt: 2deg; + +// When a side tile is active the opposite side tile slides inward and drops behind the middle one. +// The pen calls this "tucked". +$tile-tucked-distance: variables.$g-spacer-unit * 7; + +// The pen's stacking order. The active tile clears everything; a tucked tile sits behind the +// inactive middle one. +$tile-z-inactive: 3; +$tile-z-middle-inactive: 5; +$tile-z-tucked: 1; +$tile-z-active: 30; + +// The pen's shadows: a flat one at rest, a deeper pair on the active tile. +$tile-shadow-inactive: 0 1.4rem 3.2rem rgb(0 0 0 / 10%); +$tile-shadow-active: + 0 2.8rem 6rem rgb(0 0 0 / 22%), + 0 0.8rem 2rem rgb(0 0 0 / 12%); + +// The pen's curve and timings. Becoming active is quicker than returning to rest, which is what +// makes the stack settle instead of snapping back. +$tile-hover-duration: 320ms; +$tile-hover-easing: cubic-bezier(0.22, 1, 0.36, 1); +$tile-rest-duration: 500ms; + +// The focal tile carries a white band along its edge: the 6px border of Figma node 2354:4942. Not a +// spacer multiple -- the spacer unit is 8px and this is a border width, not spacing. +$tile-border-outer: 0.6rem; + +// A 64px radius, which CSS clamps to half the pill's height, so it reads as fully rounded. +$pill-radius: variables.$g-spacer-unit * 8; + +// The type presets leave line-height unset at these sizes, but the design states it, and it is what +// gives the pill and the shuffle button their height: 26px at H5 and 20px at the mobile size, per +// the type styles on the Figma nodes. Line heights follow the type, not the spacer scale. +$pill-line-height: 2.6rem; +$pill-line-height-mobile: 2rem; +$pill-height-mobile: variables.$g-spacer-unit * 6; + +// The "Shapes" layer: the white blobs that sit on the block's background colour behind the tiles. +// This is the "achtergrondkleur met masker" the FA asks for. Every path below is the Figma export, +// unchanged -- only the layer ids and the fill-opacity twin of `fill="white"` are stripped. +// +// Inlined as data uris rather than committed .svg files, the way BlockThemeReels does it: the block +// ships inside admin-core's client.css, and a url() to a file would have to resolve on every host +// that renders a content page. +// +// Desktop is one export covering the whole band (node 2354:4920). Mobile is three separate shapes +// (nodes 2381:6032, 2382:1610 and 2382:1617), each rotated on its own. +$shapes-desktop: url("data:image/svg+xml,%3Csvg width='1355.54' height='704.889' viewBox='0 0 1355.54 704.889' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1141.72 281.596L1064.3 174.686L1278.12 19.8429L1355.54 126.754L1141.72 281.596Z' fill='white'/%3E%3Cpath d='M868.993 648.922C803.069 618.012 774.745 539.402 805.664 473.482C836.582 407.563 915.14 379.068 981.064 409.978C1046.99 440.888 1075.31 519.498 1044.39 585.418C1013.48 651.337 934.916 679.832 868.993 648.922Z' fill='white'/%3E%3Cpath d='M430.688 65.8111L385.098 325.44L255.104 302.544L300.695 42.9157L430.688 65.8111Z' fill='white'/%3E%3Cpath d='M20.4439 131.484C30.5335 59.3767 97.2908 9.1256 169.397 19.224C241.504 29.3224 291.903 95.9811 281.813 168.089C271.724 240.197 204.966 290.448 132.86 280.349C60.7533 270.251 10.3544 203.592 20.4439 131.484Z' fill='white'/%3E%3C/svg%3E"); +$shapes-mobile-top: url("data:image/svg+xml,%3Csvg width='226.044' height='163.997' viewBox='0 0 226.044 163.997' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M15.149 138.048L33.6406 7.55105L98.9799 16.8445L80.4882 147.341L15.149 138.048Z' fill='white'/%3E%3Cpath d='M219.073 98.4495C215.223 134.651 182.693 160.866 146.493 157.011C110.292 153.157 84.0047 120.678 87.8549 84.4773C91.7051 48.2763 124.235 22.061 160.435 25.9157C196.636 29.7703 222.923 62.2485 219.073 98.4495Z' fill='white'/%3E%3C/svg%3E"); +$shapes-mobile-bottom: url("data:image/svg+xml,%3Csvg width='145.161' height='145.068' viewBox='0 0 145.161 145.068' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M138.19 79.52C134.339 115.721 101.81 141.936 65.6091 138.082C29.4085 134.227 3.12121 101.749 6.97141 65.5478C10.8216 29.3467 43.3513 3.13144 79.5519 6.9861C115.752 10.8408 142.04 43.3189 138.19 79.52Z' fill='white'/%3E%3C/svg%3E"); + +// Each shape is placed by the centre of its Figma bounding box and sized to the shape's own +// unrotated size, then rotated about that centre -- which is how Figma composes them, and it keeps +// the rotation from stretching the artwork. +// Desktop: the export is not rotated, so it is placed by its top left corner instead. +$shapes-desktop-left: -7.662%; +$shapes-desktop-top: -16.408%; +$shapes-desktop-width: 110.926%; +$shapes-desktop-height: 137.674%; + +$shape-mobile-top-x: 15.838%; +$shape-mobile-top-y: 4.832%; +$shape-mobile-top-width: 56.23%; +$shape-mobile-top-height: 19.477%; +$shape-mobile-top-tilt: -178.11deg; + +// The middle shape is a plain white rectangle in the design, so it needs no artwork of its own. +$shape-mobile-middle-x: 89.688%; +$shape-mobile-middle-y: 54.429%; +$shape-mobile-middle-width: 18.388%; +$shape-mobile-middle-height: 17.558%; +$shape-mobile-middle-tilt: 54.09deg; + +$shape-mobile-bottom-x: 5.863%; +$shape-mobile-bottom-y: 84.548%; +$shape-mobile-bottom-width: 36.109%; +$shape-mobile-bottom-height: 17.229%; +$shape-mobile-bottom-tilt: -178.11deg; + +.c-driekeuzespeler { + position: relative; + + // The shapes reach past the tile area on purpose, and on mobile they run off the left edge of the + // design. Clipping them here keeps that from adding a horizontal scrollbar to the page. The modal + // is portalled to document.body, so it is not affected. + overflow: hidden; + + &__title { + // H1, 64/64. + @include typography.sofia-pro-heading-2xl; + + // The shapes layer starts above the tile area and reaches into the title, so the title has to + // sit in front of it. + position: relative; + z-index: 1; + margin: 0 0 (variables.$g-spacer-unit * 8); + text-align: center; + + // H3, 40/44 on mobile. + @media (max-width: variables.$g-bp2) { + @include typography.sofia-pro-heading-xl; + + margin-bottom: variables.$g-spacer-unit * 6; + } + } + + // The coordinate box every percentage below resolves against: the design's tile area, 1222 of the + // 1440 frame, centred. The block renders full width, so this takes that share of the viewport and + // the per-tile and per-shape percentages inside land on the design's pixel sizes at 1440. + // + // The shapes and the tiles share it, which is why it is a box of its own: a `ul` may only hold + // `li` children, so the decorative layer cannot live inside the tile list. + &__stage { + position: relative; + aspect-ratio: $tiles-ratio-desktop; + width: $tiles-width-desktop; + margin: 0 auto; + + // The mobile frame is 402 wide and the tiles fill it, so no inset there. + @media (max-width: variables.$g-bp2) { + aspect-ratio: $tiles-ratio-mobile; + width: 100%; + } + } + + // Holds the three absolutely placed tiles, over the shapes layer. + &__tiles { + position: absolute; + z-index: 1; + inset: 0; + margin: 0; + padding: 0; + list-style: none; + } + + // The white blobs on the block's background colour. Decorative only: `aria-hidden` in the markup, + // and no pointer events, so it never swallows a click meant for a tile. + &__shapes { + position: absolute; + z-index: 0; + inset: 0; + pointer-events: none; + } + + &__shape { + position: absolute; + background-repeat: no-repeat; + // The exports carry preserveAspectRatio="none", and each shape is sized to its own aspect + // ratio here, so filling the box reproduces the design rather than distorting it. + background-size: 100% 100%; + } + + // One export covers the whole desktop band, unrotated, so it is placed by its top left corner. + &__shape--desktop { + left: $shapes-desktop-left; + top: $shapes-desktop-top; + width: $shapes-desktop-width; + height: $shapes-desktop-height; + background-image: $shapes-desktop; + + @media (max-width: variables.$g-bp2) { + display: none; + } + } + + // The three mobile shapes. Each is centred on its Figma bounding box and rotated about that + // centre, so the artwork keeps its own proportions. + &__shape--mobile-top, + &__shape--mobile-middle, + &__shape--mobile-bottom { + display: none; + + @media (max-width: variables.$g-bp2) { + display: block; + } + } + + &__shape--mobile-top { + left: $shape-mobile-top-x; + top: $shape-mobile-top-y; + width: $shape-mobile-top-width; + height: $shape-mobile-top-height; + transform: translate(-50%, -50%) rotate($shape-mobile-top-tilt); + background-image: $shapes-mobile-top; + } + + // A plain white rectangle in the design, so a background colour says it exactly -- no artwork + // needed. + &__shape--mobile-middle { + left: $shape-mobile-middle-x; + top: $shape-mobile-middle-y; + width: $shape-mobile-middle-width; + height: $shape-mobile-middle-height; + transform: translate(-50%, -50%) rotate($shape-mobile-middle-tilt); + background-color: colors.$white; + } + + &__shape--mobile-bottom { + left: $shape-mobile-bottom-x; + top: $shape-mobile-bottom-y; + width: $shape-mobile-bottom-width; + height: $shape-mobile-bottom-height; + transform: translate(-50%, -50%) rotate($shape-mobile-bottom-tilt); + background-image: $shapes-mobile-bottom; + } + + &__tile { + // Placed by its centre, so its size can change without moving it. + position: absolute; + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 1; + z-index: $tile-z-inactive; + width: $tile-width-desktop-base; + + // Composed from custom properties so one rule can change one part without restating the rest, + // the way the reference pen does it. + transform: translate(calc(-50% + var(--tile-shift, 0px)), -50%) + rotate(var(--tile-tilt, 0deg)) scale(var(--tile-scale, #{$tile-scale-inactive})); + + // Placeholder ground for a thumbnail that has not loaded, or an object that no longer + // resolves. The block always renders three tiles, so a tile is never dropped. + background-color: colors.$color-gray-100; + box-shadow: $tile-shadow-inactive; + + // Transform and shadow only, so no layout work per frame. + transition: + transform $tile-rest-duration $tile-hover-easing, + box-shadow $tile-rest-duration $tile-hover-easing; + + @media (max-width: variables.$g-bp2) { + width: $tile-width-mobile-base; + } + + // Left tile: hangs lower than the other two and tilts anticlockwise. + &:nth-child(1) { + --tile-tilt: #{-$tile-tilt}; + + left: 15.958%; + top: 56.128%; + } + + &:nth-child(2) { + --tile-tilt: 0deg; + + z-index: $tile-z-middle-inactive; + left: 50%; + top: 50%; + } + + // Right tile: tilts clockwise. + &:nth-child(3) { + --tile-tilt: #{$tile-tilt}; + + left: 86.61%; + top: 50%; + } + + // On mobile the three sit in a centred, overlapping column. + @media (max-width: variables.$g-bp2) { + &:nth-child(1) { + left: 50%; + top: 18.646%; + } + + &:nth-child(2) { + left: 50%; + top: 50%; + } + + &:nth-child(3) { + left: 50%; + top: 81.354%; + } + } + } + + // The white band the design puts on the active tile: the 6px border of Figma node 2354:4942. + // Drawn INSIDE the tile, which is what the design does -- the two 512 boxes in Figma sit exactly + // on the 512-tall tile area, so their edges eat into the image rather than growing the tile. + // + // The design also puts a 2px black hairline inside the band (node 2354:4943), dropped on request: + // with no background colour on the block the white band disappears and the hairline is all that is + // left, which reads as a stray border. https://meemoo.atlassian.net/browse/ARC-3813 + // + // Always present, revealed only on the active tile, so it does not fade in halfway through the + // grow. On a pseudo element so it paints over the thumbnail and the tile button, and takes no + // clicks. + &__tile::after { + content: ""; + box-sizing: border-box; + position: absolute; + inset: 0; + border: $tile-border-outer solid colors.$white; + opacity: 0; + pointer-events: none; + transition: opacity $tile-rest-duration $tile-hover-easing; + } + + // The pen collapses the durations rather than removing the transitions, so the end states stay + // identical and only the movement goes away. + @media (prefers-reduced-motion: reduce) { + &__tile, + &__tile::after, + &__thumbnail { + transition-duration: 1ms; + } + } + + &__thumbnail { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + + // The pen desaturates an inactive card slightly, so the active one reads as the one in focus. + // It puts the filter on the whole card; here it is on the thumbnail alone, because the pill + // carries colours the admin picked and washing those out is not ours to do. + filter: saturate(0.9); + transition: filter $tile-rest-duration $tile-hover-easing; + } + + // The whole tile is the control that opens the modal, so the button fills it. It carries no look + // of its own: the thumbnail behind it and the pill inside it are the tile's appearance. + &__tile-button { + // The tile's tilt and scale on :focus-within is motion, not a focus indicator, and it is gone + // altogether under prefers-reduced-motion. The keyboard user needs a real outline. + // https://meemoo.atlassian.net/wiki/spaces/HA2/pages/6218383419 + @include focus.focus-outline; + + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background: none; + cursor: pointer; + } + + &__pill { + // H5, 24/26. The preset leaves line-height unset, so it has to be stated: it is what makes the + // pill 58px tall in the design (16 + 26 + 16). + @include typography.sofia-pro-heading-lg; + + position: relative; + z-index: 1; + padding: (variables.$g-spacer-unit * 2) (variables.$g-spacer-unit * 3); + border-radius: $pill-radius; + line-height: $pill-line-height; + text-align: center; + + // 18/20 on mobile, in a pill the design fixes at 48px tall. + @media (max-width: variables.$g-bp2) { + @include typography.sofia-pro-heading-md; + + display: flex; + align-items: center; + height: $pill-height-mobile; + padding: 0 (variables.$g-spacer-unit * 3); + line-height: $pill-line-height-mobile; + } + } + + &__shuffle { + @include typography.sofia-pro-heading-lg; + @include focus.focus-outline; + + // The shapes layer reaches below the tile area, and it is a positioned layer, so an unpositioned + // button would paint underneath it. + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + gap: variables.$g-spacer-unit * 1.5; + + // 48px below the tile area in both designs. + margin: (variables.$g-spacer-unit * 6) auto 0; + padding: (variables.$g-spacer-unit * 2) (variables.$g-spacer-unit * 3); + border: none; + border-radius: $pill-radius; + background-color: colors.$color-gray-1000; + color: colors.$white; + line-height: $pill-line-height; + cursor: pointer; + + @media (max-width: variables.$g-bp2) { + @include typography.sofia-pro-heading-md; + + line-height: $pill-line-height-mobile; + } + } + + &__shuffle-icon { + // The icon is a glyph from the meemoocons font, not an svg: the Icon component is a span sized + // `width: 1em; height: 1em; font-size: inherit`. So its size comes from font-size, and setting + // width/height would resize the box while leaving the glyph inheriting the label's size. + // Both designs keep the icon at 24px while the label drops to 18px on mobile. + font-size: variables.$g-spacer-unit * 3; + } +} + +// The shuffle CTA is a near-black pill in the design, on the design's mustard background. On a dark +// block background that pill has no edge against the background at all, which fails the 3:1 that +// WCAG 1.4.11 asks of a control's boundary. The renderer already marks a block whose WCAG text +// colour is white, so the pill flips there. https://meemoo.atlassian.net/browse/ARC-3848 +.c-content-block-preview--dark .c-driekeuzespeler__shuffle { + background-color: colors.$white; + color: colors.$color-gray-1000; +} + +// The active tile: upright, enlarged, in front, with the white band and the deeper shadow. In the +// pen a click puts a card in this state; here hovering or focusing does, so the state has to travel +// between tiles instead of living on the middle one. +// +// Written with full class names rather than `&` interpolation: inside a nested block `&` is the +// entire parent selector, which silently compiles to a descendant selector that matches nothing. +// `:has` drives the siblings with no javascript; BlockHeroCarousel uses the same selector. +@mixin driekeuzespeler-tile-active($scale-desktop: $tile-scale-active-desktop) { + --tile-scale: #{$scale-desktop}; + + z-index: $tile-z-active; + box-shadow: $tile-shadow-active; + + // Becoming active is quicker than returning to rest. + transition-duration: $tile-hover-duration; + + @media (max-width: variables.$g-bp2) { + --tile-scale: #{$tile-scale-active-mobile}; + } + + &::after { + opacity: 1; + transition-duration: $tile-hover-duration; + } + + .c-driekeuzespeler__thumbnail { + filter: saturate(1); + transition-duration: $tile-hover-duration; + } +} + +// A tilted tile straightens part of the way when it becomes active, rather than snapping upright. +@mixin driekeuzespeler-tile-active-left { + --tile-tilt: #{-$tile-active-tilt}; +} + +@mixin driekeuzespeler-tile-active-right { + --tile-tilt: #{$tile-active-tilt}; +} + +// The tucked tile: when a side tile is active, the opposite side tile slides inward and drops behind +// the inactive middle one. +@mixin driekeuzespeler-tile-tucked($direction) { + --tile-shift: #{$direction * $tile-tucked-distance}; + + z-index: $tile-z-tucked; +} + +// No hover and no focus anywhere in the stack: the middle tile is the active one, which is the state +// the design draws and the state the pen starts in. +.c-driekeuzespeler__tiles:not(:has(.c-driekeuzespeler__tile:hover)):not( + :has(.c-driekeuzespeler__tile:focus-within) + ) + .c-driekeuzespeler__tile:nth-child(2) { + @include driekeuzespeler-tile-active; +} + +// Hovering or focusing a tile makes that one active, at the gentler hover scale. +.c-driekeuzespeler__tile:hover, +.c-driekeuzespeler__tile:focus-within { + @include driekeuzespeler-tile-active($tile-scale-hover-desktop); +} + +// The middle tile is the exception: it is already the design's size at rest, so hovering it changes +// nothing about its scale. Shrinking it to the hover scale would read as the focal tile stepping +// backwards under the cursor. +.c-driekeuzespeler__tile:nth-child(2):hover, +.c-driekeuzespeler__tile:nth-child(2):focus-within { + --tile-scale: #{$tile-scale-active-desktop}; +} + +.c-driekeuzespeler__tile:nth-child(1):hover, +.c-driekeuzespeler__tile:nth-child(1):focus-within { + @include driekeuzespeler-tile-active-left; +} + +.c-driekeuzespeler__tile:nth-child(3):hover, +.c-driekeuzespeler__tile:nth-child(3):focus-within { + @include driekeuzespeler-tile-active-right; +} + +// The left tile is active, so the right one tucks in behind the middle. +.c-driekeuzespeler__tiles:has(.c-driekeuzespeler__tile:nth-child(1):hover) + .c-driekeuzespeler__tile:nth-child(3), +.c-driekeuzespeler__tiles:has(.c-driekeuzespeler__tile:nth-child(1):focus-within) + .c-driekeuzespeler__tile:nth-child(3) { + @include driekeuzespeler-tile-tucked(-1); +} + +// The right tile is active, so the left one tucks in. +.c-driekeuzespeler__tiles:has(.c-driekeuzespeler__tile:nth-child(3):hover) + .c-driekeuzespeler__tile:nth-child(1), +.c-driekeuzespeler__tiles:has(.c-driekeuzespeler__tile:nth-child(3):focus-within) + .c-driekeuzespeler__tile:nth-child(1) { + @include driekeuzespeler-tile-tucked(1); +} + +// A touch device has no hover, so a tap must not leave a tile stuck as the active one. The middle +// tile keeps the active look, as it does at rest. +@media (hover: none) { + .c-driekeuzespeler__tile:nth-child(1):hover, + .c-driekeuzespeler__tile:nth-child(3):hover { + --tile-scale: #{$tile-scale-inactive}; + --tile-shift: 0px; + + z-index: $tile-z-inactive; + box-shadow: $tile-shadow-inactive; + + &::after { + opacity: 0; + } + } +} diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.service.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.service.ts new file mode 100644 index 000000000..a35767a6e --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.service.ts @@ -0,0 +1,78 @@ +import { stringifyUrl } from 'query-string'; +import { AdminConfigManager } from '~core/config/config.class'; +import { CustomError } from '~shared/helpers/custom-error'; +import { fetchWithLogout } from '~shared/helpers/fetch-with-logout'; +import { getProxyUrl } from '~shared/helpers/get-proxy-url-from-admin-core-config'; +import { isAudioFormat } from '~shared/helpers/is-audio-video-format.ts'; +import type { IeObjectType } from '~shared/helpers/map-format-to-type'; + +/** + * Shape of one item as `GET /ie-objects?schemaIdentifiers=…` returns it. Only the fields a tile + * needs are declared. + */ +interface RawIeObject { + schemaIdentifier: string; + name?: string; + maintainerName?: string; + dctermsFormat?: IeObjectType; + thumbnailUrl?: string; +} + +/** One resolved object behind a tile. */ +export interface DriekeuzespelerObject { + schemaIdentifier: string; + name: string; + maintainerName?: string; + type?: IeObjectType; + thumbnailUrl?: string; +} + +const mapRawToObject = (raw: RawIeObject): DriekeuzespelerObject => ({ + schemaIdentifier: raw.schemaIdentifier, + name: raw.name || '', + maintainerName: raw.maintainerName, + type: raw.dctermsFormat, + // Audio has no image of its own, so it falls back to the shared audio still, exactly as the + // objects-grid block does. + thumbnailUrl: isAudioFormat(raw.dctermsFormat) + ? AdminConfigManager.getConfig().components.defaultAudioStill + : raw.thumbnailUrl, +}); + +/** + * Resolves the objects for the tiles that are on screen: name, maintainer, format and thumbnail. + * + * This is deliberately the light `GET /ie-objects` route and not `playable-display-data`. The block + * can hold up to 200 interests but shows three, and the playable route resolves every element a + * block references -- including signed play urls, audio waveforms and base64 newspaper images. Only + * the modal needs any of that, and only for the one object the visitor opened. + * + * Returns a map keyed by schemaIdentifier. Ids that resolve to nothing -- removed objects, or + * objects this visitor may not see -- are simply absent, so the caller decides what to do with the + * tile. https://meemoo.atlassian.net/wiki/spaces/HA2/pages/6218383419 + * + * Expects the ids to be filled in already; the hook drops the empty ones so its query key matches + * what is fetched. + */ +export const getDriekeuzespelerObjects = async ( + schemaIdentifiers: string[] +): Promise> => { + if (schemaIdentifiers.length === 0) { + return {}; + } + + try { + const url = stringifyUrl({ + url: `${getProxyUrl()}/ie-objects`, + query: { schemaIdentifiers, resolveThumbnailUrl: 'true' }, + }); + const response = await fetchWithLogout(url); + const raw: RawIeObject[] = response.ok ? await response.json() : []; + + return Object.fromEntries(raw.map((item) => [item.schemaIdentifier, mapRawToObject(item)])); + } catch (err) { + throw new CustomError('Failed to fetch the objects for the driekeuzespeler block', err, { + schemaIdentifiers, + }); + } +}; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.tsx new file mode 100644 index 000000000..72eb7a0ec --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.tsx @@ -0,0 +1,174 @@ +import clsx from 'clsx'; +import type { CSSProperties, FunctionComponent, ReactElement } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; +import { AdminCoreIconName } from '~core/config'; +import type { Color } from '~modules/content-page/types/content-block.types'; +import { Icon } from '~shared/components/Icon/Icon'; +import { tText } from '~shared/helpers/translation-functions'; +import { HET_ARCHIEF } from '~shared/types'; +import type { DefaultComponentProps } from '~shared/types/components'; +import { DRIEKEUZESPELER_TILE_COUNT } from './BlockDriekeuzespeler.editorconfig'; +import { pickNextSelection, pickRandomIndices } from './BlockDriekeuzespeler.helpers'; +import { BlockDriekeuzespelerModal } from './BlockDriekeuzespelerModal'; +import { useGetDriekeuzespelerObjects } from './hooks/useGetDriekeuzespelerObjects'; + +import './BlockDriekeuzespeler.scss'; + +interface DriekeuzespelerInterest { + name: string; + /** The object picker's value: `value` is the pid. Absent while the admin has picked nothing. */ + mediaItem?: { value?: string }; + themeId: string; +} + +export interface BlockDriekeuzespelerProps extends DefaultComponentProps { + /** Id of the content block, added by the content block renderer. Empty for an unsaved block. */ + blockId?: string; + title: string; + /** Exactly three entries, one per tile position. */ + tileColors: { backgroundColor: Color; textColor: Color }[]; + shuffleButtonLabel: string; + interests: DriekeuzespelerInterest[]; +} + +/** + * Shows three of the configured interests, picked at random, each as a tile with the thumbnail of + * its object and a pill carrying the interest name. The shuffle CTA replaces all three. + * + * https://meemoo.atlassian.net/wiki/spaces/HA2/pages/6218383419 + */ +export const BlockDriekeuzespeler: FunctionComponent = ({ + blockId, + title, + tileColors, + shuffleButtonLabel, + interests, + className, +}): ReactElement => { + // Fixed at three, not derived from the colour lists: the stylesheet places the tiles by + // nth-child(1..3), so a fourth tile would render with no position at all. A saved block whose + // colour lists are the wrong length keeps three tiles and falls back per tile below. + const tileCount = DRIEKEUZESPELER_TILE_COUNT; + + // The selection is only made after mount. Randomising during render would make the server and + // the client disagree and break hydration, so the first paint shows the tile skeletons instead. + const [selection, setSelection] = useState(null); + + useEffect(() => { + setSelection(pickRandomIndices(interests.length, tileCount)); + }, [interests.length, tileCount]); + + const shuffle = useCallback(() => { + setSelection((previous) => pickNextSelection(interests.length, tileCount, previous || [])); + }, [interests.length, tileCount]); + + // The interest whose tile is open in the modal, or null when nothing is open. The selection is + // untouched while the modal is open, so closing returns to the same three tiles. + const [openedInterest, setOpenedInterest] = useState(null); + + // Not compacted: tile colors are positional, so dropping a missing interest would shift every + // later tile onto the wrong colour. renderTile handles an empty slot. + const selectedInterests = (selection || []).map((index) => interests[index]); + const { data: objectsById } = useGetDriekeuzespelerObjects( + selectedInterests.filter(Boolean).map((interest) => interest.mediaItem?.value || '') + ); + + const renderTile = (tileIndex: number): ReactElement => { + const interest = selectedInterests[tileIndex]; + // The colours are positional and fixed at three, so they index by tile. + const { backgroundColor, textColor } = tileColors[tileIndex] ?? {}; + const schemaIdentifier = interest?.mediaItem?.value; + const ieObject = schemaIdentifier ? objectsById?.[schemaIdentifier] : undefined; + + return ( +
  • + {/* A thumbnail that has not arrived yet, or an object that no longer resolves, leaves the + tile in its placeholder state rather than removing it: the block always shows three. */} + {!!ieObject?.thumbnailUrl && ( + + )} + {!!interest && ( + // The whole tile is the control that opens the modal, so it is a real button: Enter and + // Space work for free, and the tile's :focus-within styling finally has something to + // react to. The thumbnail is decorative, so the interest name names the button. + + )} +
  • + ); + }; + + return ( +
    + {!!title &&

    {title}

    } + +
    + {/* The white blobs the design lays on the block's background colour, behind the tiles -- + the "achtergrondkleur met masker" of the FA. Purely decorative, so it is hidden from + assistive technology and the artwork lives in the stylesheet. */} + + +
      + {Array.from({ length: tileCount }, (_unused, tileIndex) => renderTile(tileIndex))} +
    +
    + + {/* Shown as soon as there is anything to shuffle. Even with exactly three interests the CTA + still does something: the selection is ordered, so a shuffle moves the interests between + tile positions, and each position carries its own colours and thumbnail. */} + {interests.length > 0 && ( + + )} + + setOpenedInterest(null)} + /> +
    + ); +}; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespelerModal.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespelerModal.scss new file mode 100644 index 000000000..44501fac1 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespelerModal.scss @@ -0,0 +1,47 @@ +@use "../../../../shared/styles/settings/variables" as variables; + +// The FA asks for the same ratio as the video player on an object's detail page, and for the modal +// to adapt to the screen. 16:9 is that player's ratio. +$media-aspect-ratio: 16 / 9; + +.c-driekeuzespeler-modal { + &__media { + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: $media-aspect-ratio; + width: 100%; + + // Keeps a tall newspaper page inside the viewport instead of pushing the metadata panel and + // the close button off screen. + max-height: 70vh; + overflow: hidden; + } + + &__player, + &__newspaper, + &__iiif-viewer { + width: 100%; + height: 100%; + } + + // The viewer sizes itself to its container, so it needs one with a height rather than the + // intrinsic height an image brings. + &__iiif-viewer { + position: relative; + overflow: hidden; + } + + // A newspaper page is portrait, so it fits inside the frame rather than filling it. + &__newspaper { + object-fit: contain; + } + + &__loading { + margin: 0; + } + + &__metadata { + margin-top: variables.$g-spacer-unit * 3; + } +} diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespelerModal.test.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespelerModal.test.ts new file mode 100644 index 000000000..d04cfb49f --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespelerModal.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PLAYABLE_DISPLAY_DATA_BLOCKS } from '~modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.const'; +import { ContentBlockType } from '~modules/content-page/types/content-block.types'; + +vi.mock('~shared/helpers/translation-functions', () => ({ + tText: (key: string) => key, +})); + +vi.mock('~shared/helpers/is-avo', () => ({ + isAvo: () => false, +})); + +/** + * The modal's own rendering needs a DOM harness this package does not set up for blocks, so these + * cover the wiring that decides whether it can work at all: the block type has to match the db + * lookup value, and the renderer has to hand the block its id. + */ +describe('Driekeuzespeler modal wiring', () => { + it('uses the block type value the database lookup already holds', () => { + // The lookup value predates this work: migration 1784294418766 inserted THREE_CHOICES_PLAYER. + // A mismatch here makes every save fail on a foreign key, which is easy to miss locally. + expect(ContentBlockType.Driekeuzespeler).toBe('THREE_CHOICES_PLAYER'); + }); + + it('is listed as a block that receives its blockId', () => { + // Without this the block gets no id, so the modal would fall back to the unsaved-objects path + // and resolve nothing for an ordinary visitor. + expect(PLAYABLE_DISPLAY_DATA_BLOCKS).toContain(ContentBlockType.Driekeuzespeler); + }); +}); diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespelerModal.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespelerModal.tsx new file mode 100644 index 000000000..96edb680e --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespelerModal.tsx @@ -0,0 +1,141 @@ +import { Modal, ModalBody } from '@viaa/avo2-components'; +import type { FunctionComponent, ReactElement } from 'react'; +import React from 'react'; +import { AdminConfigManager } from '~core/config/config.class'; +import { IeObjectFlowPlayerWrapper } from '~modules/content-page/components/IeObjectFlowPlayerWrapper/IeObjectFlowPlayerWrapper.tsx'; +import { IeObjectMetadata } from '~modules/content-page/components/IeObjectMetadata/IeObjectMetadata.tsx'; +import { isAudioVideoFormat } from '~shared/helpers/is-audio-video-format.ts'; +import { tText } from '~shared/helpers/translation-functions'; +import type { UnsavedPlayableDisplayDataObject } from '~shared/services/ie-objects-service/ie-objects.types.ts'; +import { HET_ARCHIEF } from '~shared/types'; +import { useGetDriekeuzespelerPlayableObject } from './hooks/useGetDriekeuzespelerPlayableObject'; + +import './BlockDriekeuzespelerModal.scss'; + +export interface BlockDriekeuzespelerModalProps { + /** The interest whose tile was opened, or null when the modal is closed. */ + interest: { name: string; mediaItem?: { value?: string }; themeId: string } | null; + /** Id of the content block, so the proxy can check the object is one this block references. */ + blockId?: string; + /** Objects of a block that has not been saved yet, for the content page editor. */ + unsavedObjects?: UnsavedPlayableDisplayDataObject[]; + onClose: () => void; +} + +/** + * Plays the object behind one interest, on top of the page. The visitor is never navigated + * anywhere, and closing returns to the same three tiles. + * + * https://meemoo.atlassian.net/wiki/spaces/HA2/pages/6218383419 + */ +export const BlockDriekeuzespelerModal: FunctionComponent = ({ + interest, + blockId, + unsavedObjects, + onClose, +}): ReactElement => { + const isOpen = !!interest; + // The host's IIIF viewer, if it registered one. Read per render rather than at module load, so a + // config set up after this module is imported is still picked up. + const IiifViewer = AdminConfigManager.getConfig().components.iiifViewer; + const { data: ieObject, isFetching } = useGetDriekeuzespelerPlayableObject( + blockId, + interest?.mediaItem?.value, + unsavedObjects + ); + + // Only mount the player while the modal is open, so closing stops playback outright instead of + // leaving audio running behind the page. + const renderMedia = (): ReactElement | null => { + if (!ieObject) { + return null; + } + + if (isAudioVideoFormat(ieObject.dctermsFormat)) { + return ( + + ); + } + + // A newspaper opens in the IIIF viewer, as the FA asks. The viewer needs the object's page list + // and a ticket-service token per page, so it is injected by the host rather than living here. + // https://meemoo.atlassian.net/browse/ARC-3813 + if (IiifViewer) { + return ( +
    + +
    + ); + } + + // No viewer configured, so fall back to the flat IIIF detail image the timeline and carousel + // blocks show. A newspaper comes back as a self-contained data uri. + if (ieObject.newspaperImage) { + return ( + {ieObject.name} + ); + } + + return null; + }; + + return ( + + +
    + {isFetching ? ( + // The FA asks for dynamic content a screen reader picks up, so the wait is announced + // instead of only drawn. `output` carries the status role on its own, which is how + // BlockObjectsGrid does it too. + + {tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___bezig-met-laden', + undefined, + [HET_ARCHIEF] + )} + + ) : ( + renderMedia() + )} +
    + + {!!ieObject && ( + + )} + + {/* TODO(ARC-3813): the secondary CTA "Toon meer over [thema]" is not rendered yet. It has + to link to the search page with this interest's theme filter active, and that filter + does not exist: see goal 2b in the plan. Rendering a link with no working target would + be worse than leaving it out. The theme name itself is resolvable through + ThemesService.fetchThemesByIds once there is somewhere to point it. */} +
    +
    + ); +}; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/hooks/useGetDriekeuzespelerObjects.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/hooks/useGetDriekeuzespelerObjects.ts new file mode 100644 index 000000000..bcaca4c3b --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/hooks/useGetDriekeuzespelerObjects.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query'; +import { QUERY_KEYS } from '~shared/types'; +import { + type DriekeuzespelerObject, + getDriekeuzespelerObjects, +} from '../BlockDriekeuzespeler.service'; + +/** + * Resolves the objects for the tiles currently on screen, keyed by schemaIdentifier. + * + * The ids are part of the query key, so a shuffle to three other interests fetches those and a + * shuffle back to a selection already seen is served from the cache. + */ +export const useGetDriekeuzespelerObjects = (schemaIdentifiers: string[]) => { + const identifiers = schemaIdentifiers.filter(Boolean); + + return useQuery>({ + queryKey: [QUERY_KEYS.GET_IE_OBJECTS_BY_IDS, [...identifiers].sort().join(',')], + queryFn: () => getDriekeuzespelerObjects(identifiers), + enabled: identifiers.length > 0, + }); +}; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/hooks/useGetDriekeuzespelerPlayableObject.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/hooks/useGetDriekeuzespelerPlayableObject.ts new file mode 100644 index 000000000..53f89be4a --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/hooks/useGetDriekeuzespelerPlayableObject.ts @@ -0,0 +1,55 @@ +import { useQuery } from '@tanstack/react-query'; +import { IeObjectsService } from '~shared/services/ie-objects-service/ie-objects.service.ts'; +import type { + PlayableDisplayIeObject, + UnsavedPlayableDisplayDataObject, +} from '~shared/services/ie-objects-service/ie-objects.types.ts'; +import { QUERY_KEYS } from '~shared/types'; + +/** + * Resolves the one object the visitor opened: the signed play url, the maintainer logo, and for a + * newspaper the IIIF detail image. + * + * Only ever asks for a single object. The block can hold 200 interests, and the playable route + * otherwise resolves every object a block references -- signed urls, audio waveforms and base64 + * newspaper images included. The proxy still checks the object is one this block references. + * + * A block being edited has no id yet, so it passes its objects instead, exactly as the timeline and + * carousel blocks do. The proxy honours those only for users who may edit content pages. + */ +export const useGetDriekeuzespelerPlayableObject = ( + blockId: string | undefined, + schemaIdentifier: string | undefined, + unsavedObjects?: UnsavedPlayableDisplayDataObject[] +) => { + const objects = blockId ? undefined : unsavedObjects; + + return useQuery({ + queryKey: [ + QUERY_KEYS.GET_IE_OBJECTS_PLAYABLE_DISPLAY_DATA, + blockId, + schemaIdentifier, + objects && JSON.stringify(objects), + ], + queryFn: async () => { + const entries = await IeObjectsService.getPlayableDisplayData( + blockId, + objects, + schemaIdentifier + ); + + // A saved block narrows to one entry. An unsaved block cannot narrow server side, since the + // objects travel in the request, so pick the matching entry here. + const resolved = entries || []; + + return ( + resolved.find((entry) => entry?.schemaIdentifier === schemaIdentifier) ?? + resolved[0] ?? + null + ); + }, + // Nothing to resolve until a tile has been opened. + enabled: !!schemaIdentifier && (!!blockId || !!objects?.length), + staleTime: 60 * 60 * 1000, // 1 hour, same as the other playable data + }); +}; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/index.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/index.ts new file mode 100644 index 000000000..151d099f4 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/index.ts @@ -0,0 +1,5 @@ +export { + DRIEKEUZESPELER_CONFIG, + INITIAL_DRIEKEUZESPELER_COMPONENTS_STATE, +} from './BlockDriekeuzespeler.editorconfig.ts'; +export { BlockDriekeuzespeler } from './BlockDriekeuzespeler.tsx'; diff --git a/ui/src/react-admin/modules/content-page/const/content-block-config-map.ts b/ui/src/react-admin/modules/content-page/const/content-block-config-map.ts index 4d732d104..6a5bb55f8 100644 --- a/ui/src/react-admin/modules/content-page/const/content-block-config-map.ts +++ b/ui/src/react-admin/modules/content-page/const/content-block-config-map.ts @@ -8,6 +8,7 @@ import { CONTENT_ENCLOSE_BLOCK_CONFIG } from '~content-blocks/BlockContentEnclos import { CONTENT_PAGE_META_BLOCK_CONFIG } from '~content-blocks/BlockContentPageMeta'; import { CTAS_BLOCK_CONFIG } from '~content-blocks/BlockCTAs'; import { DOUBLE_BANNER_BLOCK_CONFIG } from '~content-blocks/BlockDoubleBanner'; +import { DRIEKEUZESPELER_CONFIG } from '~content-blocks/BlockDriekeuzespeler'; import { EVENTBRITE_BLOCK_CONFIG } from '~content-blocks/BlockEventbrite'; import { HEADING_BLOCK_CONFIG } from '~content-blocks/BlockHeading'; import { HERO_CAROUSEL_CONFIG } from '~content-blocks/BlockHeroCarousel'; @@ -111,4 +112,5 @@ export const CONTENT_BLOCK_CONFIG_MAP: Record< [ContentBlockType.HetArchiefVideo]: HETARCHIEF_VIDEO_BLOCK_CONFIG, [ContentBlockType.ImageCarousel]: IMAGE_CAROUSEL_CONFIG, [ContentBlockType.TitleWithParallax]: TITLE_WITH_PARALLAX_CONFIG, + [ContentBlockType.Driekeuzespeler]: DRIEKEUZESPELER_CONFIG, }; diff --git a/ui/src/react-admin/modules/content-page/const/content-block-initial-state-map.ts b/ui/src/react-admin/modules/content-page/const/content-block-initial-state-map.ts index b296726e6..265fecec0 100644 --- a/ui/src/react-admin/modules/content-page/const/content-block-initial-state-map.ts +++ b/ui/src/react-admin/modules/content-page/const/content-block-initial-state-map.ts @@ -8,6 +8,7 @@ import { INITIAL_CONTENT_ENCLOSE_BLOCK_STATE } from '~content-blocks/BlockConten import { INITIAL_CONTENT_PAGE_META_COMPONENTS_STATE } from '~content-blocks/BlockContentPageMeta'; import { INITIAL_CTAS_COMPONENTS_STATE } from '~content-blocks/BlockCTAs'; import { INITIAL_DOUBLE_BANNER_COMPONENTS_STATE } from '~content-blocks/BlockDoubleBanner/BlockDoubleBanner.editorconfig'; +import { INITIAL_DRIEKEUZESPELER_COMPONENTS_STATE } from '~content-blocks/BlockDriekeuzespeler'; import { INITIAL_EVENTBRITE_COMPONENTS_STATE } from '~content-blocks/BlockEventbrite'; import { INITIAL_HEADING_COMPONENTS_STATE } from '~content-blocks/BlockHeading'; import { INITIAL_HERO_CAROUSEL_COMPONENTS_STATE } from '~content-blocks/BlockHeroCarousel'; @@ -114,4 +115,5 @@ export const CONTENT_BLOCK_INITIAL_STATE_MAP: { [ContentBlockType.HetArchiefVideo]: INITIAL_HETARCHIEF_VIDEO_COMPONENTS_STATE, [ContentBlockType.ImageCarousel]: INITIAL_IMAGE_CAROUSEL_COMPONENTS_STATE, [ContentBlockType.TitleWithParallax]: INITIAL_TITLE_WITH_PARALLAX_COMPONENTS_STATE, + [ContentBlockType.Driekeuzespeler]: INITIAL_DRIEKEUZESPELER_COMPONENTS_STATE, }; diff --git a/ui/src/react-admin/modules/content-page/const/editor-types.consts.ts b/ui/src/react-admin/modules/content-page/const/editor-types.consts.ts index 138734cec..165f51a45 100644 --- a/ui/src/react-admin/modules/content-page/const/editor-types.consts.ts +++ b/ui/src/react-admin/modules/content-page/const/editor-types.consts.ts @@ -18,6 +18,7 @@ import FileUpload from '~shared/components/FileUpload/FileUpload'; import { IconPicker } from '~shared/components/IconPicker/IconPicker'; import { MaintainerSelect } from '~shared/components/MaintainerSelect/MaintainerSelect'; import RichTextEditorWrapper from '~shared/components/RichTextEditorWrapper/RichTextEditorWrapper'; +import { ThemeSelect } from '~shared/components/ThemeSelect/ThemeSelect'; import { UploadOrSelectVideoStill } from '~shared/components/UploadOrSelectVideoStill/UploadOrSelectVideoStill'; import { UserGroupSelect } from '~shared/components/UserGroupSelect/UserGroupSelect'; @@ -43,5 +44,6 @@ export const GET_EDITOR_TYPES_MAP = (): Record< UserGroupSelect, RICH_TEXT_EDITOR: RichTextEditorWrapper, MaintainerSelect, + ThemeSelect, UploadOrSelectVideoStill, }); diff --git a/ui/src/react-admin/modules/content-page/const/get-content-block-type-options.ts b/ui/src/react-admin/modules/content-page/const/get-content-block-type-options.ts index 50ba6774f..a187c2db0 100644 --- a/ui/src/react-admin/modules/content-page/const/get-content-block-type-options.ts +++ b/ui/src/react-admin/modules/content-page/const/get-content-block-type-options.ts @@ -274,6 +274,14 @@ export const GET_CONTENT_BLOCK_TYPE_OPTIONS: () => SelectOption[] = () = ), value: ContentBlockType.TitleWithParallax, }, + { + label: tText( + 'modules/content-page/const/get-content-block-type-options___driekeuzespeler', + {}, + [HET_ARCHIEF] + ), + value: ContentBlockType.Driekeuzespeler, + }, ]; // Only show the content blocks that the client enabled through the config object diff --git a/ui/src/react-admin/modules/content-page/types/content-block.types.ts b/ui/src/react-admin/modules/content-page/types/content-block.types.ts index 8183b1469..2faea379d 100644 --- a/ui/src/react-admin/modules/content-page/types/content-block.types.ts +++ b/ui/src/react-admin/modules/content-page/types/content-block.types.ts @@ -217,6 +217,10 @@ export enum ContentBlockType { HetArchiefVideo = 'HETARCHIEF_VIDEO', ImageCarousel = 'IMAGE_CAROUSEL', TitleWithParallax = 'TITLE_WITH_PARALLAX', + // The db lookup value predates this work and reads THREE_CHOICES_PLAYER (migration + // 1784294418766). The member keeps the Dutch name the FA, the designs and the rest of this block + // use. https://meemoo.atlassian.net/browse/ARC-3813 + Driekeuzespeler = 'THREE_CHOICES_PLAYER', } export enum ContentBlockEditor { @@ -237,6 +241,7 @@ export enum ContentBlockEditor { RICH_TEXT_EDITOR = 'RICH_TEXT_EDITOR', UserGroupSelect = 'UserGroupSelect', MaintainerSelect = 'MaintainerSelect', // Used for selecting which maintainers copy right notice should be on the video still https://meemoo.atlassian.net/browse/AVO-3015 + ThemeSelect = 'ThemeSelect', // Used for picking the theme an interest links to https://meemoo.atlassian.net/browse/ARC-3813 UploadOrSelectVideoStill = 'UploadOrSelectVideoStill', // Used for selecting or uploading a video still for a video player https://meemoo.atlassian.net/browse/AVO-3015 } @@ -704,3 +709,49 @@ export interface TitleWithParallaxBlockComponentState { subtitle?: string; image?: string; } + +/** + * One "interesse" of the Driekeuzespeler: the label the visitor sees on a tile, the object that + * opens when the tile is clicked, and the theme its secondary CTA searches for. + * + * https://meemoo.atlassian.net/wiki/spaces/HA2/pages/6218383419 + */ +export interface DriekeuzespelerInterestState { + /** The label shown in the pill on the tile. */ + name: string; + /** + * The ie-object that plays in the modal, as the shared object picker stores it. `value` holds the + * pid the FA asks for. Named `mediaItem` like every other block that points at an object, so the + * proxy can read them all the same way. + */ + mediaItem?: PickerItem; + /** Id of the single theme this interest belongs to. */ + themeId: string; +} + +/** The two colours of one tile position, in one fixed-length repeated field group. */ +export interface DriekeuzespelerTileColors { + backgroundColor: Color; + textColor: Color; +} + +/** + * The Driekeuzespeler picks three random interests out of `interests` and renders one tile each. + * + * The tile colors are block-level and positional: entry 0 styles the leftmost tile, whichever + * interest randomization put there. They deliberately do not live on the interest itself, so a + * shuffle keeps the block's color rhythm intact -- which is also why a tile's colour cannot be + * configured next to its label: the label travels with the interest. + * + * The field order here follows the FA: title, the three background colors, the three text colors, + * the shuffle label, then the interests. + */ +export interface DriekeuzespelerBlockComponentState { + title: string; + /** Exactly three entries, one per tile position. */ + tileColors: DriekeuzespelerTileColors[]; + /** Label of the CTA that reshuffles the three tiles. Its icon is fixed. */ + shuffleButtonLabel: string; + /** Between 3 and 200 entries. */ + interests: DriekeuzespelerInterestState[]; +} diff --git a/ui/src/react-admin/modules/shared/components/ThemeSelect/ThemeSelect.tsx b/ui/src/react-admin/modules/shared/components/ThemeSelect/ThemeSelect.tsx new file mode 100644 index 000000000..f2dc06613 --- /dev/null +++ b/ui/src/react-admin/modules/shared/components/ThemeSelect/ThemeSelect.tsx @@ -0,0 +1,82 @@ +import { FormGroup } from '@viaa/avo2-components'; +import type { FunctionComponent } from 'react'; +import React from 'react'; +import type { ActionMeta } from 'react-select'; +import ReactSelect from 'react-select'; +import { tText } from '~shared/helpers/translation-functions'; +import { useGetAllThemes } from '~shared/hooks/useGetAllThemes'; + +interface ThemeOption { + label: string; + value: string; +} + +export interface ThemeSelectProps { + label: string | undefined; + error: string | undefined; + placeholder?: string; + /** Id of the selected theme, or an empty string when nothing is picked yet. */ + value: string; + required?: boolean; + onChange: (selectedThemeId: string | null) => void; +} + +/** + * Picks exactly one theme. The options show the theme slug, not its Dutch or English name, and are + * ordered alphabetically: https://meemoo.atlassian.net/wiki/spaces/HA2/pages/6218383419 + * + * The slug is unique and language independent, so it identifies a theme in the editor without + * making the admin pick a language. The visitor never sees a slug - the public side renders the + * theme name in the UI language. + */ +export const ThemeSelect: FunctionComponent = ({ + label, + error, + placeholder, + value, + required, + onChange, +}) => { + const { data: themes, isFetching } = useGetAllThemes(); + + const themeOptions: ThemeOption[] = (themes || []).map((theme) => ({ + label: theme.slug, + value: theme.id, + })); + + const handleSelectionChanged = ( + newValue: ThemeOption | null, + actionMeta: ActionMeta + ) => { + if (actionMeta.action === 'select-option' && newValue) { + onChange(newValue.value); + } else { + onChange(null); + } + }; + + return ( + + + classNamePrefix="c-select" + options={themeOptions} + value={themeOptions.find((option) => option.value === value) || null} + isLoading={isFetching} + isClearable + placeholder={ + placeholder ?? + tText('react-admin/modules/shared/components/theme-select/theme-select___kies-een-thema') + } + onChange={handleSelectionChanged} + noOptionsMessage={() => + tText( + 'react-admin/modules/shared/components/theme-select/theme-select___geen-themas-gevonden' + ) + } + loadingMessage={() => + tText('react-admin/modules/shared/components/theme-select/theme-select___bezig-met-laden') + } + /> + + ); +}; diff --git a/ui/src/react-admin/modules/shared/hooks/useGetAllThemes.ts b/ui/src/react-admin/modules/shared/hooks/useGetAllThemes.ts new file mode 100644 index 000000000..9f763e2f6 --- /dev/null +++ b/ui/src/react-admin/modules/shared/hooks/useGetAllThemes.ts @@ -0,0 +1,28 @@ +import { useQuery } from '@tanstack/react-query'; +import { ThemesService } from '~shared/services/themes-service/themes.service'; +import type { Theme } from '~shared/services/themes-service/themes.types'; +import { QUERY_KEYS } from '~shared/types'; + +/** + * The themes endpoint pages its results, but a picker needs the whole list at once. Themes are an + * editorial set that meemoo curates by hand, so the count stays low and one large page is enough. + * Raise this only together with a real paging/search implementation in the picker. + */ +const ALL_THEMES_PAGE_SIZE = 500; + +/** + * All themes, sorted by slug. The Driekeuzespeler picker shows slugs and orders them + * alphabetically: https://meemoo.atlassian.net/wiki/spaces/HA2/pages/6218383419 + */ +export const useGetAllThemes = () => { + return useQuery({ + queryKey: [QUERY_KEYS.GET_ALL_THEMES], + queryFn: async () => { + const response = await ThemesService.fetchThemes(null, 0, ALL_THEMES_PAGE_SIZE); + + // The endpoint can order by slug, but sorting here keeps the picker alphabetical even + // when the response order changes. + return [...response.items].sort((left, right) => left.slug.localeCompare(right.slug)); + }, + }); +}; diff --git a/ui/src/react-admin/modules/shared/services/ie-objects-service/ie-objects.service.ts b/ui/src/react-admin/modules/shared/services/ie-objects-service/ie-objects.service.ts index 0c4156866..2afaa2070 100644 --- a/ui/src/react-admin/modules/shared/services/ie-objects-service/ie-objects.service.ts +++ b/ui/src/react-admin/modules/shared/services/ie-objects-service/ie-objects.service.ts @@ -27,9 +27,19 @@ export class IeObjectsService { */ public static async getPlayableDisplayData( blockId: string | undefined, - unsavedObjects?: UnsavedPlayableDisplayDataObject[] + unsavedObjects?: UnsavedPlayableDisplayDataObject[], + /** + * Resolve only this one object out of the block instead of every object it references. The + * proxy still checks that the block references it, so this narrows the response without + * widening what a visitor can reach. Used by the driekeuzespeler, which can hold 200 + * interests and opens one at a time. + */ + schemaIdentifier?: string ): Promise<(PlayableDisplayIeObject | null)[]> { - const body = blockId ? { blockId } : { objects: unsavedObjects }; + const body = { + ...(blockId ? { blockId } : { objects: unsavedObjects }), + ...(schemaIdentifier ? { schemaIdentifier } : {}), + }; try { return await fetchWithLogoutJson<(PlayableDisplayIeObject | null)[]>( `${IeObjectsService.getBaseUrl()}/playable-display-data`, diff --git a/ui/src/react-admin/modules/shared/styles/mixins/_typography.scss b/ui/src/react-admin/modules/shared/styles/mixins/_typography.scss index 34d2bf24f..4675b4626 100644 --- a/ui/src/react-admin/modules/shared/styles/mixins/_typography.scss +++ b/ui/src/react-admin/modules/shared/styles/mixins/_typography.scss @@ -26,6 +26,11 @@ $font-family: "SofiaPro", sans-serif; // Presets for the combos used across content blocks - always style through one of these. If a new // block needs a size/weight that isn't here, add a preset for it rather than calling // `sofia-pro(...)` straight from the block, so the set stays consistent and reusable. +// The design system's H1: 64/64. Used by the display titles of the full-bleed blocks. +@mixin sofia-pro-heading-2xl($important: false) { + @include sofia-pro(6.4rem, 800, 6.4rem, $important); +} + @mixin sofia-pro-heading-xl($important: false) { @include sofia-pro(3.2rem, 800, 4rem, $important); } diff --git a/ui/src/react-admin/modules/shared/types/index.ts b/ui/src/react-admin/modules/shared/types/index.ts index 8de4c2a31..72ab876db 100644 --- a/ui/src/react-admin/modules/shared/types/index.ts +++ b/ui/src/react-admin/modules/shared/types/index.ts @@ -43,6 +43,8 @@ export enum QUERY_KEYS { GET_TABLE_COLUMN_PREFERENCE = 'GET_TABLE_COLUMN_PREFERENCE', GET_NAMES_BY_PROFILE_IDS = 'GET_NAMES_BY_PROFILE_IDS', GET_THEMES = 'GET_THEMES', + GET_ALL_THEMES = 'GET_ALL_THEMES', + GET_IE_OBJECTS_BY_IDS = 'GET_IE_OBJECTS_BY_IDS', GET_THEMES_BY_IDS = 'GET_THEMES_BY_IDS', GET_THEME_WITH_OBJECTS = 'GET_THEME_WITH_OBJECTS', GET_IE_OBJECT_PLAYER_DURATION = 'GET_IE_OBJECT_PLAYER_DURATION', diff --git a/ui/src/shared/helpers/admin-core-config.tsx b/ui/src/shared/helpers/admin-core-config.tsx index d16876f84..32facd900 100644 --- a/ui/src/shared/helpers/admin-core-config.tsx +++ b/ui/src/shared/helpers/admin-core-config.tsx @@ -132,6 +132,7 @@ export function getAdminCoreConfigForLocalTestApp(navigateFunc: NavigateFunction componentProps: { add: { name: 'add' }, collection: { name: 'collection' }, + collectionShuffle: { name: 'collection-shuffle' }, view: { name: 'view' }, angleDown: { name: 'down' }, angleUp: { name: 'up' }, diff --git a/ui/src/shared/translations/hetArchief/nl.json b/ui/src/shared/translations/hetArchief/nl.json index 68fa2a1a9..06e9a1e26 100644 --- a/ui/src/shared/translations/hetArchief/nl.json +++ b/ui/src/shared/translations/hetArchief/nl.json @@ -688,6 +688,24 @@ "modules/content-page/components/blocks/block-double-banner/block-double-banner___label-is-verplicht": "Label is verplicht", "modules/content-page/components/blocks/block-double-banner/block-double-banner___tekstkleur": "Tekstkleur", "modules/content-page/components/blocks/block-double-banner/block-double-banner___tekstkleur-is-verplicht": "Tekstkleur is verplicht", + "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___achtergrondkleur": "Achtergrondkleur", + "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___kleuren-van-de-interesse-op-tegel": "Kleuren van de interesse op tegel", + "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___bezig-met-laden": "Bezig met laden", + "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___driekeuzespeler": "Driekeuzespeler", + "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___een-thema-is-verplicht": "Een thema is verplicht", + "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___gerelateerd-thema": "Gerelateerd thema", + "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___interesse": "Interesse", + "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___label-van-de-shuffle-knop": "Label van de shuffleknop", + "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___label-van-de-shuffle-knop-is-verplicht": "Label van de shuffleknop is verplicht", + "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___naam-van-de-interesse": "Naam van de interesse", + "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___naam-van-de-interesse-is-verplicht": "Naam van de interesse is verplicht", + "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___object-over-interest": "Over {{interest}}", + "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___tekstkleur": "Tekstkleur", + "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___titel": "Titel", + "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___titel-is-verplicht": "Titel is verplicht", + "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___toon-me-iets-anders": "Toon me iets anders", + "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___verwijder-deze-interesse": "Verwijder deze interesse", + "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___voeg-een-interesse-toe": "Voeg een interesse toe", "modules/content-page/components/blocks/block-hero-carousel/block-hero-carousel-active-slide___afspelen": "Afspelen", "modules/content-page/components/blocks/block-hero-carousel/block-hero-carousel-active-slide___pauzeren": "Pauzeren", "modules/content-page/components/blocks/block-hero-carousel/block-hero-carousel-inactive-slide___afbeelding": "Afbeelding", @@ -911,6 +929,7 @@ "modules/content-page/const/get-content-block-type-options___breadcrumbs": "Breadcrumbs", "modules/content-page/const/get-content-block-type-options___carousel-met-afbeeldingen": "Carousel met afbeeldingen", "modules/content-page/const/get-content-block-type-options___content-enclose-grid": "Content insluiten grid", + "modules/content-page/const/get-content-block-type-options___driekeuzespeler": "Driekeuzespeler", "modules/content-page/const/get-content-block-type-options___dubbele-banner": "Dubbele banner", "modules/content-page/const/get-content-block-type-options___hero-carousel": "Hero carousel", "modules/content-page/const/get-content-block-type-options___highlight-text": "Highlight text", @@ -1181,6 +1200,9 @@ "react-admin/modules/content-page/views/content-page-edit___kopieer-content-pagina": "Kopieer content pagina", "react-admin/modules/shared/components/content-picker/content-picker___custom-navigatie-items": "custom navigatie items", "react-admin/modules/shared/components/content-picker/content-picker___opdrachten": "Opdrachten", + "react-admin/modules/shared/components/theme-select/theme-select___bezig-met-laden": "Bezig met laden", + "react-admin/modules/shared/components/theme-select/theme-select___geen-themas-gevonden": "Geen thema's gevonden", + "react-admin/modules/shared/components/theme-select/theme-select___kies-een-thema": "Kies een thema", "react-admin/modules/shared/components/upload-or-select-video-still/upload-or-select-video-still___annuleer": "Annuleer", "react-admin/modules/shared/components/upload-or-select-video-still/upload-or-select-video-still___eigen-still-uploaden": "Eigen still uploaden", "react-admin/modules/shared/components/upload-or-select-video-still/upload-or-select-video-still___opslaan": "Opslaan", From ae34f5c5cb52162d16fb285d9a211f35e2c325c5 Mon Sep 17 00:00:00 2001 From: Robbe Bierebeeck Date: Fri, 28 Aug 2026 15:00:20 +0200 Subject: [PATCH 03/12] feat(ARC-3813): batch driekeuzespeler tile data and redesign its modal --- .../react-admin/core/config/config.types.ts | 16 +++- .../IeObjectMetadata/IeObjectMetadata.scss | 8 ++ .../IeObjectMetadata/IeObjectMetadata.tsx | 16 +++- .../BlockDriekeuzespeler.service.ts | 78 ------------------ .../BlockDriekeuzespeler.tsx | 29 ++++--- .../BlockDriekeuzespelerModal.scss | 60 +++++++++++++- .../BlockDriekeuzespelerModal.tsx | 81 +++++++++++++------ .../hooks/useGetDriekeuzespelerObjects.ts | 22 ----- .../useGetDriekeuzespelerPlayableObject.ts | 55 ------------- .../useGetDriekeuzespelerPlayableObjects.ts | 52 ++++++++++++ .../ie-objects-service/ie-objects.service.ts | 12 +-- .../ie-objects-service/ie-objects.types.ts | 6 ++ ui/src/shared/translations/hetArchief/nl.json | 1 + 13 files changed, 235 insertions(+), 201 deletions(-) delete mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.service.ts delete mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/hooks/useGetDriekeuzespelerObjects.ts delete mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/hooks/useGetDriekeuzespelerPlayableObject.ts create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/hooks/useGetDriekeuzespelerPlayableObjects.ts diff --git a/ui/src/react-admin/core/config/config.types.ts b/ui/src/react-admin/core/config/config.types.ts index bfed51915..9c16f3cb1 100644 --- a/ui/src/react-admin/core/config/config.types.ts +++ b/ui/src/react-admin/core/config/config.types.ts @@ -193,14 +193,24 @@ export interface AdminConfig { } /** - * What the injected IIIF viewer is handed. Deliberately just the id: everything else the viewer - * needs -- the page list, a ticket-service token per page, the page and overlay state -- is the - * host's to resolve. https://meemoo.atlassian.net/browse/ARC-3813 + * What the injected IIIF viewer is handed. The page and overlay state, and a ticket-service token + * per page, stay the host's to resolve -- a ticket is short-lived and access-checked at request + * time, so it can never be handed over already resolved. The page list itself is optional: a + * caller that already has it (e.g. from the driekeuzespeler's own proactive fetch) passes it along + * so the host can skip re-fetching the object just to rebuild a list it already had; a caller with + * only an id leaves it undefined and the host resolves it the way it always has. + * https://meemoo.atlassian.net/browse/ARC-3813 */ export interface IiifViewerConfigProps { schemaIdentifier: string; /** Names the viewer for assistive technology, since the object title lives outside it. */ title?: string; + /** Every page's raw (un-ticketed) image/thumbnail/alto urls, when the caller already has them. */ + pages?: { + imageUrl: string; + thumbnailUrl: string | null; + altoUrl: string | null; + }[]; } /** diff --git a/ui/src/react-admin/modules/content-page/components/IeObjectMetadata/IeObjectMetadata.scss b/ui/src/react-admin/modules/content-page/components/IeObjectMetadata/IeObjectMetadata.scss index 2b6476578..76266e110 100644 --- a/ui/src/react-admin/modules/content-page/components/IeObjectMetadata/IeObjectMetadata.scss +++ b/ui/src/react-admin/modules/content-page/components/IeObjectMetadata/IeObjectMetadata.scss @@ -65,4 +65,12 @@ $object-meta-gap: 4rem; display: none; } } + + // Separates the primary CTA/title/maintainer from an optional secondaryCta at the end of the row. + &__divider { + flex: 0 0 auto; + align-self: stretch; + width: 1px; + background-color: rgba(colors.$color-gray-1000, 0.12); + } } diff --git a/ui/src/react-admin/modules/content-page/components/IeObjectMetadata/IeObjectMetadata.tsx b/ui/src/react-admin/modules/content-page/components/IeObjectMetadata/IeObjectMetadata.tsx index dcd4e7b55..eeb840883 100644 --- a/ui/src/react-admin/modules/content-page/components/IeObjectMetadata/IeObjectMetadata.tsx +++ b/ui/src/react-admin/modules/content-page/components/IeObjectMetadata/IeObjectMetadata.tsx @@ -2,7 +2,7 @@ import { Button } from '@meemoo/react-components'; import { Icon, type IconName } from '@viaa/avo2-components'; import { AvoCoreContentPickerType } from '@viaa/avo2-types'; import { clsx } from 'clsx'; -import type { FunctionComponent } from 'react'; +import type { FunctionComponent, ReactNode } from 'react'; import React from 'react'; import { SmartLink } from '~shared/components/SmartLink/SmartLink'; import { @@ -62,7 +62,13 @@ export const IeObjectMetadata: FunctionComponent<{ ieObject: PlayableDisplayIeObject; fallbackTitle: string; className?: string; -}> = ({ ieObject, fallbackTitle, className }) => { + /** + * An optional second CTA, rendered after a divider at the end of the same row -- e.g. the + * driekeuzespeler's "Toon meer over [thema]" pill. Purely a slot: this component supplies the + * divider and the layout, the caller supplies (and styles) the content. + */ + secondaryCta?: ReactNode; +}> = ({ ieObject, fallbackTitle, className, secondaryCta }) => { const callToActionLabel = getCallToActionLabel(ieObject.dctermsFormat); return ( @@ -92,6 +98,12 @@ export const IeObjectMetadata: FunctionComponent<{ className="c-ie-object-metadata__maintainer-logo" /> )} + {!!secondaryCta && ( + <> +