diff --git a/.gitignore b/.gitignore index 54881ef6..f3782db4 100644 --- a/.gitignore +++ b/.gitignore @@ -255,3 +255,5 @@ schema.graphql /.husky api/.claude/ + +CLAUDE.md diff --git a/ui/src/client.ts b/ui/src/client.ts index 1588c638..9b1f2d30 100644 --- a/ui/src/client.ts +++ b/ui/src/client.ts @@ -18,6 +18,7 @@ export { BlockImageTitleTextButtonWrapper } from '~content-blocks/BlockImageTitl export { BlockIntro } from '~content-blocks/BlockIntro'; export { BlockKlaar } from '~content-blocks/BlockKlaar'; export { BlockMaintainersGrid } from '~content-blocks/BlockMaintainersGrid'; +export { BlockObjectsGrid } from '~content-blocks/BlockObjectsGrid'; export { BlockOverviewNewspaperTitles } from '~content-blocks/BlockOverviewNewspaperTitles'; export { BlockOverviewWithCarousel } from '~content-blocks/BlockOverviewWithCarousel'; export { BlockPageOverviewWrapper } from '~content-blocks/BlockPageOverview/BlockPageOverview.wrapper'; diff --git a/ui/src/react-admin/core/config/config.types.ts b/ui/src/react-admin/core/config/config.types.ts index 0b00f677..1838028c 100644 --- a/ui/src/react-admin/core/config/config.types.ts +++ b/ui/src/react-admin/core/config/config.types.ts @@ -5,14 +5,13 @@ import type { AvoUserCommonUser, } from '@viaa/avo2-types'; import type { ComponentType, FC, FunctionComponent, MouseEvent, ReactNode } from 'react'; - +import type { OrderProperty } from '~content-blocks/BlockObjectsGrid/BlockObjectsGrid.types.ts'; import type { ContentBlockType } from '~modules/content-page/types/content-block.types'; import type { ContentPageInfo, ContentPageWidth, } from '~modules/content-page/types/content-pages.types'; import type { App, Locale } from '~modules/translations/translations.core.types'; - import type { UserBulkAction } from '~modules/user/user.types'; import type { FlowPlayerWrapperProps } from '~shared/components/FlowPlayerWrapper/FlowPlayerWrapper.types'; @@ -56,6 +55,22 @@ export interface LinkInfo { export type NavigateFunction = (to: string, options?: { replace?: boolean }) => Promise; +export interface SearchFilter { + field: string; + operator: string; + value?: string; + multiValue?: string[]; +} + +export interface IeObjectsSearchBody { + filters: SearchFilter[]; + size: number; + page: number; + requestedAggs?: string[]; + orderProp?: OrderProperty; + orderDirection?: 'asc' | 'desc'; +} + export interface AdminConfig { // Core module configurations flowplayer: { @@ -88,6 +103,13 @@ export interface AdminConfig { queryCache: { clear: (key: string) => Promise; }; + search?: { + // Converts a hetarchief search-page url (as stored on eg. the ObjectsGrid content-page + // block) into an ie-objects search API request body. Lives in the config so the client's + // own url-filter-mapping logic (used by its search page) can be reused here, without the + // admin-core needing to depend on the client package. + clientSearchUrlToApiSearchUrl: (searchQuery: string) => IeObjectsSearchBody; + }; }; components: { loader: { 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 53a9fb6a..8a975127 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 @@ -21,6 +21,7 @@ import { BlockImageTitleTextButtonWrapper } from '~content-blocks/BlockImageTitl import { BlockIntro } from '~content-blocks/BlockIntro'; import { BlockKlaar } from '~content-blocks/BlockKlaar'; import { BlockMaintainersGrid } from '~content-blocks/BlockMaintainersGrid'; +import { BlockObjectsGrid } from '~content-blocks/BlockObjectsGrid'; import { BlockOverviewNewspaperTitles } from '~content-blocks/BlockOverviewNewspaperTitles'; import { BlockOverviewWithCarousel } from '~content-blocks/BlockOverviewWithCarousel/BlockOverviewWithCarousel.tsx'; import { BlockPageOverviewWrapper } from '~content-blocks/BlockPageOverview'; @@ -87,6 +88,7 @@ export function GET_BLOCK_COMPONENT( [ContentBlockType.Breadcrumbs]: BlockBreadcrumbs, [ContentBlockType.HetArchiefImageTextBackground]: BlockHetArchiefImageTextBackground, [ContentBlockType.OverviewWithCarousel]: BlockOverviewWithCarousel, + [ContentBlockType.ObjectsGrid]: BlockObjectsGrid, // Avo specific blocks [ContentBlockType.MediaGrid]: loadComponentFromConfig(ContentBlockType.MediaGrid), @@ -104,7 +106,7 @@ export function GET_BLOCK_COMPONENT( */ export const REPEATABLE_CONTENT_BLOCKS = [ ContentBlockType.AnchorLinks, - ContentBlockType.Buttons, + // ContentBlockType.Buttons, // migrated to mechanism B (fieldGroup `elements`) - ARC-3779 ContentBlockType.CTAs, ContentBlockType.ImageGrid, ContentBlockType.MediaGrid, @@ -142,6 +144,7 @@ export const NAVIGABLE_CONTENT_BLOCKS = [ ContentBlockType.OverviewNewspaperTitles, ContentBlockType.ContentEncloseGrid, ContentBlockType.Breadcrumbs, + ContentBlockType.ObjectsGrid, ]; /** diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockButtons/BlockButtons.editorconfig.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockButtons/BlockButtons.editorconfig.ts index 5fb34b71..3ce69f4f 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockButtons/BlockButtons.editorconfig.ts +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockButtons/BlockButtons.editorconfig.ts @@ -11,12 +11,14 @@ import { ContentBlockEditor, ContentBlockType } from '../../../types/content-blo import { ALIGN_FIELD, BLOCK_FIELD_DEFAULTS, BLOCK_STATE_DEFAULTS, TEXT_FIELD } from '../defaults'; -export const INITIAL_BUTTONS_COMPONENTS_STATE = (): ButtonsBlockComponentState[] => [ - { - label: '', - type: 'primary', - }, -]; +const INITIAL_BUTTON_ELEMENT_STATE = (): ButtonsBlockComponentState => ({ + label: '', + type: 'primary', +}); + +export const INITIAL_BUTTONS_COMPONENTS_STATE = (): { elements: ButtonsBlockComponentState[] } => ({ + elements: [INITIAL_BUTTON_ELEMENT_STATE()], +}); export const INITIAL_BUTTONS_BLOCK_STATE = (): DefaultContentBlockState => BLOCK_STATE_DEFAULTS({ @@ -31,40 +33,55 @@ export const BUTTONS_BLOCK_CONFIG = (position = 0): ContentBlockConfig => ({ name: tText('admin/content-block/helpers/generators/buttons___knoppen'), type: ContentBlockType.Buttons, components: { - name: tText('admin/content-block/helpers/generators/buttons___knop'), - limits: { - max: 3, - }, state: INITIAL_BUTTONS_COMPONENTS_STATE(), fields: { - type: { - label: tText('admin/content-block/helpers/generators/buttons___type'), - editorType: ContentBlockEditor.Select, - editorProps: { - options: AdminConfigManager.getConfig().components.buttonTypes(), - }, - }, - label: TEXT_FIELD( - { - label: tText('admin/content-block/helpers/generators/buttons___tekst'), + // Mechanism B: the buttons repeat as a fieldGroup under `elements`. + // See components/blocks/README.md. + elements: { + label: tText('admin/content-block/helpers/generators/buttons___knop'), + type: 'fieldGroup', + max: 3, + repeat: { + defaultState: INITIAL_BUTTON_ELEMENT_STATE(), + addButtonLabel: tText( + 'admin/content-block/components/content-block-form/content-block-form___voeg-label-to', + { label: tText('admin/content-block/helpers/generators/buttons___knop') } + ), + deleteButtonLabel: tText( + 'admin/content-block/components/content-block-form/content-block-form___verwijder-sectie' + ), }, - tText('admin/content-block/helpers/generators/buttons___knoptekst-is-verplicht') - ), - altTitle: TEXT_FIELD({ - label: tText('admin/content-block/helpers/generators/buttons___alt-title-text'), - validator: undefined, - }), - icon: { - label: tText('admin/content-block/helpers/generators/buttons___icoon'), - editorType: ContentBlockEditor.IconPicker, - editorProps: { - options: GET_ADMIN_ICON_OPTIONS(), + fields: { + type: { + label: tText('admin/content-block/helpers/generators/buttons___type'), + editorType: ContentBlockEditor.Select, + editorProps: { + options: AdminConfigManager.getConfig().components.buttonTypes(), + }, + }, + label: TEXT_FIELD( + { + label: tText('admin/content-block/helpers/generators/buttons___tekst'), + }, + tText('admin/content-block/helpers/generators/buttons___knoptekst-is-verplicht') + ), + altTitle: TEXT_FIELD({ + label: tText('admin/content-block/helpers/generators/buttons___alt-title-text'), + validator: undefined, + }), + icon: { + label: tText('admin/content-block/helpers/generators/buttons___icoon'), + editorType: ContentBlockEditor.IconPicker, + editorProps: { + options: GET_ADMIN_ICON_OPTIONS(), + }, + }, + buttonAction: { + label: tText('admin/content-block/helpers/generators/buttons___knop-actie'), + editorType: ContentBlockEditor.ContentPicker, + }, }, }, - buttonAction: { - label: tText('admin/content-block/helpers/generators/buttons___knop-actie'), - editorType: ContentBlockEditor.ContentPicker, - }, }, }, block: { diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.editorconfig.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.editorconfig.ts new file mode 100644 index 00000000..1fa27286 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.editorconfig.ts @@ -0,0 +1,176 @@ +import { AvoCoreContentPickerType } from '@viaa/avo2-types'; +import { BLOCK_FIELD_DEFAULTS, BLOCK_STATE_DEFAULTS, TEXT_FIELD } from '~content-blocks/defaults'; +import { GET_HEADING_TYPE_OPTIONS } from '~modules/content-page/const/get-heading-type-options'; +import { + type ContentBlockConfig, + ContentBlockEditor, + type ContentBlockField, + ContentBlockType, + type DefaultContentBlockState, + type HeadingTypeOption, +} from '~modules/content-page/types/content-block.types'; +import type { PickerItem } from '~modules/shared/types/content-picker'; +import { tHtml, tText } from '~shared/helpers/translation-functions'; +import { HET_ARCHIEF } from '~shared/types'; + +// The grid can hold at most 3 "fixed positions" that are always shown (see FA). +const MAX_FIXED_POSITIONS = 3; + +export interface ObjectsGridFixedPositionState { + // A single ie-object (referenced by its pid / fragmentId) that is pinned to a fixed position. + mediaItem: PickerItem; +} + +export interface ObjectsGridBlockComponentState { + title: string; + titleType: HeadingTypeOption; + // A hetarchief.be search url, e.g. https://hetarchief.be/zoeken?aanbieders=OR-...&page=1 + searchQuery: string; + // Optional (0 to 3) objects that are always shown, in the order they are entered. + elements: ObjectsGridFixedPositionState[]; +} + +const INITIAL_OBJECTS_GRID_FIXED_POSITION_STATE = (): ObjectsGridFixedPositionState => ({ + mediaItem: { + label: '', + type: AvoCoreContentPickerType.IE_OBJECT, + value: '', + }, +}); + +export const INITIAL_OBJECTS_GRID_COMPONENTS_STATE = (): ObjectsGridBlockComponentState => ({ + title: '', + titleType: 'h2', + searchQuery: '', + elements: [], +}); + +export const INITIAL_OBJECTS_GRID_BLOCK_STATE = (): DefaultContentBlockState => ({ + ...BLOCK_STATE_DEFAULTS({ + padding: { + top: 'top-large', + bottom: 'bottom-large', + }, + }), +}); + +export const OBJECTS_GRID_BLOCK_CONFIG = (position = 0): ContentBlockConfig => ({ + position, + name: tText( + 'modules/content-page/components/blocks/block-objects-grid/block-objects-grid___objecten-grid', + undefined, + [HET_ARCHIEF] + ), + type: ContentBlockType.ObjectsGrid, + components: { + state: INITIAL_OBJECTS_GRID_COMPONENTS_STATE(), + fields: { + // Optional title, shown at the top of the block. + title: TEXT_FIELD({ + label: tText( + 'modules/content-page/components/blocks/block-objects-grid/block-objects-grid___titel', + undefined, + [HET_ARCHIEF] + ), + validator: undefined, + }), + titleType: { + label: tText( + 'modules/content-page/components/blocks/block-objects-grid/block-objects-grid___titel-grootte', + undefined, + [HET_ARCHIEF] + ), + editorType: ContentBlockEditor.Select, + editorProps: { + options: GET_HEADING_TYPE_OPTIONS(), + }, + }, + // Required search url on hetarchief.be that fills the grid. + searchQuery: TEXT_FIELD({ + label: tText( + 'modules/content-page/components/blocks/block-objects-grid/block-objects-grid___zoekfilter-url', + undefined, + [HET_ARCHIEF] + ), + note: tHtml( + 'modules/content-page/components/blocks/block-objects-grid/block-objects-grid___geef-een-zoek-url-van-hetarchief-be-in-bijvoorbeeld-https-hetarchief-be-zoeken-aanbieders-or-page-1', + undefined, + [HET_ARCHIEF] + ), + validator: (value: string) => { + const errors: string[] = []; + + if (!value) { + errors.push( + tText( + 'modules/content-page/components/blocks/block-objects-grid/block-objects-grid___een-zoekfilter-is-verplicht', + undefined, + [HET_ARCHIEF] + ) + ); + return errors; + } + + try { + new URL(value); + } catch { + errors.push( + tText( + 'modules/content-page/components/blocks/block-objects-grid/block-objects-grid___gelieve-een-geldige-url-in-te-vullen', + undefined, + [HET_ARCHIEF] + ) + ); + } + + return errors; + }, + }), + // Up to 3 optional fixed positions, each pinned to a specific ie-object. + elements: { + label: tText( + 'modules/content-page/components/blocks/block-objects-grid/block-objects-grid___vaste-positie', + undefined, + [HET_ARCHIEF] + ), + type: 'fieldGroup', + min: 0, + max: MAX_FIXED_POSITIONS, + fields: { + mediaItem: { + label: tText( + 'modules/content-page/components/blocks/block-objects-grid/block-objects-grid___object-pid-of-fragment-id', + undefined, + [HET_ARCHIEF] + ), + editorType: ContentBlockEditor.ContentPicker, + editorProps: { + allowedTypes: [AvoCoreContentPickerType.IE_OBJECT], + hideTypeDropdown: true, + }, + } as ContentBlockField, + }, + repeat: { + defaultState: INITIAL_OBJECTS_GRID_FIXED_POSITION_STATE(), + addButtonLabel: tText( + 'modules/content-page/components/blocks/block-objects-grid/block-objects-grid___voeg-een-vaste-positie-toe', + undefined, + [HET_ARCHIEF] + ), + deleteButtonLabel: tText( + 'modules/content-page/components/blocks/block-objects-grid/block-objects-grid___verwijder-vaste-positie', + undefined, + [HET_ARCHIEF] + ), + }, + }, + }, + }, + block: { + state: INITIAL_OBJECTS_GRID_BLOCK_STATE(), + fields: { + // Includes the block-wide background color (see FA), padding, margin, user groups and anchor. + ...BLOCK_FIELD_DEFAULTS(), + }, + }, +}); diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.scss new file mode 100644 index 00000000..8b392b08 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.scss @@ -0,0 +1,243 @@ +@use "../../../../shared/styles/settings/variables" as variables; +@use "../../../../shared/styles/settings/colors" as colors; + +.c-block-objects-grid { + // Number of grid columns per breakpoint. The count is responsive (see FA) but capped at 4 + // columns; a fixed landscape tile always spans 2 of these columns. + --c-objects-grid-columns: 2; + + // All tiles share this height (standard tiles are 1 column wide, fixed tiles 2), so every + // tile lines up regardless of type. + --c-objects-grid-row-height: 22rem; + + @media (min-width: variables.$g-bp2) { + --c-objects-grid-columns: 3; + --c-objects-grid-row-height: 28rem; + } + + @media (min-width: variables.$g-bp3) { + --c-objects-grid-columns: 4; + --c-objects-grid-row-height: 32rem; + } + + .c-block-objects-grid__title { + color: colors.$color-gray-1000; + font-family: SofiaPro, sans-serif; + font-size: 2.4rem; + font-weight: 800; + margin-bottom: 2.2rem; + } + + .c-block-objects-grid__grid { + display: grid; + grid-template-columns: repeat(var(--c-objects-grid-columns), minmax(0, 1fr)); + grid-auto-rows: var(--c-objects-grid-row-height); + gap: 1.6rem; + list-style: none; + margin: 0; + padding: 0; + } + + .c-block-objects-grid__tile { + display: flex; + min-width: 0; + + // Fixed positions are shown as a landscape tile that occupies 2 standard positions + // (2 columns), at the same height as every other tile. + &--fixed { + grid-column: span 2; + } + } + + .c-block-objects-grid__tile-link { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-width: 0; + text-decoration: none; + color: inherit; + + // Clear, high-contrast focus indicator for keyboard navigation (no keyboard traps). + &:focus-visible { + outline: 2px solid colors.$color-ocean-green; + outline-offset: 2px; + } + } + + .c-block-objects-grid__tile-media { + position: relative; + width: 100%; + overflow: hidden; + background-color: colors.$color-gray-100; + + // Fill the tile height above the title bar; the image is cropped with object-fit: cover, + // so a 1-column (portrait) and a 2-column (landscape) tile end up exactly the same height. + flex: 1 1 auto; + min-height: 0; + + &--audio { + display: flex; + align-items: center; + justify-content: center; + background-color: colors.$color-gray-900; + + // Fixed waveform look for audio, which has no thumbnail. + background-image: repeating-linear-gradient( + 90deg, + colors.$color-gray-400 0, + colors.$color-gray-400 2px, + transparent 2px, + transparent 8px + ); + } + + .c-block-objects-grid__tile-image { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + display: block; + transition: transform 0.5s ease-out; + } + + + + &--newspaper { + .c-block-objects-grid__tile-image { + object-position: top; + } + } + } + + // Only limit newspapers if the tile is double width for fixed ie objects + .c-block-objects-grid__tile--fixed { + // Newspaper scans are tall pages; crop from the top so the masthead stays visible + // instead of centering on the middle of the page. + .c-block-objects-grid__tile-media--newspaper { + .c-block-objects-grid__tile-image { + width: 60%; + height: 100%; + margin: 0 auto; + object-position: top; + display: block; + transition: transform 0.5s ease-out; + } + } + } + + .c-block-objects-grid__tile-placeholder { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + font-size: 4rem; + color: colors.$white; + } + + // Desktop-only zoom-in on hover (see FA). Pointer devices only, never affects keyboard focus. + @media (hover: hover) { + .c-block-objects-grid__tile-link:hover .c-block-objects-grid__tile-image { + transform: scale(1.1); + } + } + + .c-block-objects-grid__tile-titlebar { + display: flex; + align-items: flex-start; + gap: 0.8rem; + height: 9.6rem; + flex-shrink: 0; + + // Black bar with white text/icons (see FA) — contrast ratio 21:1. + background-color: colors.$color-gray-1000; + color: colors.$white; + padding: 1.6rem; + } + + .c-block-objects-grid__tile-titlebar-left { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0; + } + + .c-block-objects-grid__tile-type-icon { + flex-shrink: 0; + width: 2.2rem; + height: 2.2rem; + font-size: 2.2rem; + line-height: 1; + + @media (min-width: variables.$g-bp2) { + width: 2.4rem; + height: 2.4rem; + font-size: 2.4rem; + } + } + + .c-block-objects-grid__tile-title { + font-family: SofiaPro, sans-serif; + font-size: 1.4rem; + font-weight: 800; + line-height: 2rem; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + + @media (min-width: variables.$g-bp2) { + font-size: 1.6rem; + } + } + + .c-block-objects-grid__tile-maintainer { + font-size: 1.4rem; + font-weight: 400; + line-height: 2rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + @media (min-width: variables.$g-bp2) { + font-size: 1.6rem; + } + } + + .c-block-objects-grid__footer { + display: flex; + justify-content: center; + margin-top: 2.4rem; + } + + .c-block-objects-grid__cta { + display: inline-flex; + align-items: center; + padding: 1.2rem 2.4rem; + border-radius: 0.4rem; + background-color: colors.$color-gray-1000; + color: colors.$white; + font-family: SofiaPro, sans-serif; + font-weight: 700; + text-decoration: none; + + &:focus-visible { + outline: 2px solid colors.$color-ocean-green; + outline-offset: 2px; + } + } + + // Visually hidden but available to screen readers (for the aria-live status messages). + .c-block-objects-grid__sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; + } +} diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.service.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.service.ts new file mode 100644 index 00000000..4696b48d --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.service.ts @@ -0,0 +1,146 @@ +import { stringifyUrl } from 'query-string'; +import { AdminConfigManager } from '~core/config/config.class'; +import type { IeObjectsSearchBody } from '~core/config/config.types'; +import { CustomError } from '~shared/helpers/custom-error'; +import { fetchWithLogout, fetchWithLogoutJson } from '~shared/helpers/fetch-with-logout'; +import { getProxyUrl } from '~shared/helpers/get-proxy-url-from-admin-core-config'; +import type { PickerItem } from '~shared/types/content-picker'; +import { type ObjectsGridItem, ObjectsGridItemType, OrderProperty } from './BlockObjectsGrid.types'; + +/** + * How many query-result objects to request. The grid shows 4 rows and the number of columns + * is responsive (see the FA), so we fetch enough to fill the widest layout; the grid clamps + * what is actually rendered via CSS. + */ +export const DEFAULT_OBJECTS_GRID_LIMIT = 24; + +/** + * Shape of a single item as returned by both `POST /ie-objects` (search, in `.items`) and + * `GET /ie-objects?schemaIdentifiers=…` (array). Only the fields the grid needs are declared. + */ +interface RawIeObject { + schemaIdentifier: string; + name?: string; + maintainerName?: string; + // dcterms format, e.g. "video" | "audio" | "newspaper". + dctermsFormat?: string; + thumbnailUrl?: string; +} + +const mapFormatToType = (format?: string): ObjectsGridItemType | undefined => { + switch ((format || '').toLowerCase()) { + case 'video': + case 'film': + return ObjectsGridItemType.Video; + case 'audio': + return ObjectsGridItemType.Audio; + case 'newspaper': + case 'krant': + return ObjectsGridItemType.Newspaper; + case 'image': + case 'photo': + return ObjectsGridItemType.Image; + default: + return undefined; + } +}; + +const mapRawToGridItem = (raw: RawIeObject): ObjectsGridItem => ({ + schemaIdentifier: raw.schemaIdentifier, + name: raw.name || '', + maintainerName: raw.maintainerName, + type: mapFormatToType(raw.dctermsFormat), + thumbnailUrl: raw.thumbnailUrl, +}); + +/** + * Convert a raw hetarchief.be `/zoeken` search URL into the ie-objects search body. + * + * The actual url-param-to-filter mapping logic lives client-side (it mirrors the search page's + * own filter logic, which the admin-core cannot import — the client depends on the admin-core, + * not the other way around), and is passed in through the admin-core config as + * `services.search.searchUrlToApiUrl`. + * + * Notice: this doesn't work inside the demo app, since the client doesn't pass us the conversion url function + */ +export const parseSearchQueryToSearchBody = (searchQuery: string): IeObjectsSearchBody => { + const clientSearchUrlToApiSearchUrl = + AdminConfigManager.getConfig().services.search?.clientSearchUrlToApiSearchUrl; + if (!clientSearchUrlToApiSearchUrl) { + return { filters: [], size: 20, page: 1 }; + } + return clientSearchUrlToApiSearchUrl(searchQuery); +}; + +const searchIeObjects = async (body: IeObjectsSearchBody): Promise => { + const response: { items?: RawIeObject[] } = await fetchWithLogoutJson( + `${getProxyUrl()}/ie-objects`, + { + method: 'POST', + body: JSON.stringify(body), + } + ); + return (response.items || []).map(mapRawToGridItem); +}; + +/** + * Resolve the pinned "fixed position" objects (by their schemaIdentifier / pid) to full objects, + * in a single `GET /ie-objects?schemaIdentifiers=…` call, preserving the admin-configured order. + */ +const getFixedObjects = async (fixedItems: PickerItem[]): Promise => { + const identifiers = fixedItems.map((item) => item.value).filter(Boolean); + + if (identifiers.length === 0) { + return []; + } + + const url = stringifyUrl({ + url: `${getProxyUrl()}/ie-objects`, + query: { schemaIdentifiers: identifiers, resolveThumbnailUrl: 'true' }, + }); + const response = await fetchWithLogout(url); + const raw: RawIeObject[] = response.ok ? await response.json() : []; + const bySchemaIdentifier = new Map(raw.map((item) => [item.schemaIdentifier, item])); + + // Keep the admin-configured order, drop identifiers that resolved to nothing. + return identifiers + .map((id) => bySchemaIdentifier.get(id)) + .filter((item): item is RawIeObject => Boolean(item)) + .map(mapRawToGridItem); +}; + +export interface ObjectsGridData { + fixedObjects: ObjectsGridItem[]; + objects: ObjectsGridItem[]; +} + +export const getObjectsGridItems = async ( + searchQuery: string, + fixedItems: PickerItem[], + limit = DEFAULT_OBJECTS_GRID_LIMIT +): Promise => { + try { + const apiSearchQueryParams = parseSearchQueryToSearchBody(searchQuery); + // Grid needs to show random items, except for the fixed items + apiSearchQueryParams.orderProp = OrderProperty.RANDOM; + apiSearchQueryParams.size = limit; + + const [fixedObjects, objects] = await Promise.all([ + getFixedObjects(fixedItems), + searchQuery ? searchIeObjects(apiSearchQueryParams) : Promise.resolve([]), + ]); + + // Don't show the same object twice if it is both pinned and returned by the query. + const fixedIds = new Set(fixedObjects.map((item) => item.schemaIdentifier)); + return { + fixedObjects, + objects: objects.filter((item) => !fixedIds.has(item.schemaIdentifier)), + }; + } catch (err) { + throw new CustomError('Failed to fetch objects for the objects-grid block', err, { + searchQuery, + fixedItems, + limit, + }); + } +}; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.tsx new file mode 100644 index 00000000..e2a565c8 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.tsx @@ -0,0 +1,285 @@ +import { AvoCoreContentPickerType } from '@viaa/avo2-types'; +import clsx from 'clsx'; +import type { ComponentProps, FunctionComponent, ReactElement, ReactNode } from 'react'; +import { useEffect, useState } from 'react'; +import { BlockHeading } from '~content-blocks/BlockHeading'; +import { SmartLink } from '~modules/shared/components/SmartLink/SmartLink'; +import { Icon } from '~shared/components/Icon'; +import { tText } from '~shared/helpers/translation-functions'; +import { HET_ARCHIEF } from '~shared/types'; +import './BlockObjectsGrid.scss'; +import { + type BlockObjectsGridProps, + type ObjectsGridItem, + ObjectsGridItemType, +} from './BlockObjectsGrid.types'; +import { useGetObjectsGridItems } from './hooks/useGetObjectsGridItems'; + +// NOTE (client route): ie-object detail pages live under this path on hetarchief.be. Adjust +// the prefix if the client application uses a different detail route for objects. +const OBJECT_DETAIL_PATH_PREFIX = '/pid'; + +// 4 rows of 4 items per row when there are no fixed items present +// https://meemoo.atlassian.net/wiki/spaces/HA2/pages/6217171023/FA+Objecten+grid#Gedrag-van-het-contentblok +const OBJECT_GRID_MAX_ITEMS = 16; + +// Column-count breakpoints, mirrored from BlockObjectsGrid.scss ($g-bp2/$g-bp3). +const BREAKPOINT_TABLET = 700; +const BREAKPOINT_DESKTOP = 900; + +type IconNameType = ComponentProps['name']; + +// Object type → icon name (icons are registered in the admin-core icon config). Types without a +// dedicated icon (e.g. image) simply render no type-icon. +const TYPE_ICON_NAME: Partial> = { + [ObjectsGridItemType.Video]: 'video', + [ObjectsGridItemType.Audio]: 'audio', + [ObjectsGridItemType.Newspaper]: 'newspaper', +}; + +export const BlockObjectsGrid: FunctionComponent = ({ + className, + title, + titleType = 'h2', + searchQuery, + elements = [], + backgroundColor, +}): ReactNode => { + const fixedItems = elements.map((element) => element.mediaItem).filter((item) => item?.value); + // Items to fetch is: max - 2 * fixed items, because fixed items are double width + const { data, isLoading, isError } = useGetObjectsGridItems( + searchQuery, + fixedItems, + OBJECT_GRID_MAX_ITEMS - fixedItems.length * 2 + ); + + // Tracks viewport width so the tablet/mobile breakpoints can hide tiles that would + // otherwise leave the last row half-filled (desktop always fetches an exact 4 rows). + const [windowWidth, setWindowWidth] = useState(() => + typeof window === 'undefined' ? BREAKPOINT_DESKTOP : window.innerWidth + ); + + useEffect(() => { + const handleResize = (): void => setWindowWidth(window.innerWidth); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + // Number of grid columns and rows shown per breakpoint (see FA linked above). + const getColumnsForWidth = (width: number): number => { + if (width >= BREAKPOINT_DESKTOP) { + return 4; + } + if (width >= BREAKPOINT_TABLET) { + return 3; + } + return 2; + }; + const MAX_ROWS_FOR_COLUMNS: Record = { + 4: 4, // desktop + 3: 3, // tablet + 2: 2, // mobile + }; + + type OrderedTile = { item: ObjectsGridItem; isFixed: boolean }; + + // Packs tiles into rows the same way the CSS grid renders them (auto-flow row, not dense): + // a fixed (2-column-wide) tile that doesn't fit the remaining space in a row wraps to the + // next row, leaving the remainder of the current row empty. + const packTilesIntoRows = (tiles: OrderedTile[], columns: number): OrderedTile[][] => { + const rows: OrderedTile[][] = []; + let currentRow: OrderedTile[] = []; + let usedColumns = 0; + + tiles.forEach((tile) => { + const width = tile.isFixed ? 2 : 1; + if (usedColumns + width > columns) { + rows.push(currentRow); + currentRow = []; + usedColumns = 0; + } + currentRow.push(tile); + usedColumns += width; + }); + if (currentRow.length > 0) { + rows.push(currentRow); + } + return rows; + }; + + // Limits the tiles to what fits in the max number of rows for this breakpoint, dropping + // tiles from an incomplete trailing row so the last visible row is always fully filled. + const getVisibleTiles = (tiles: OrderedTile[], columns: number): OrderedTile[] => { + const maxRows = MAX_ROWS_FOR_COLUMNS[columns] ?? tiles.length; + const visibleRows = packTilesIntoRows(tiles, columns).slice(0, maxRows); + + const lastRow = visibleRows[visibleRows.length - 1]; + const lastRowUsedColumns = lastRow?.reduce((sum, tile) => sum + (tile.isFixed ? 2 : 1), 0) ?? 0; + if (lastRow && lastRowUsedColumns < columns) { + visibleRows.pop(); + } + + return visibleRows.flat(); + }; + + const getObjectDetailPath = (schemaIdentifier: string): string => + `${OBJECT_DETAIL_PATH_PREFIX}/${encodeURIComponent(schemaIdentifier)}`; + + // The searchQuery is a full url pointing to the search page on hetarchief.be, but since this + // admin-core module is embedded on that same site, we want a relative link instead of an + // absolute one pointing to a specific domain (eg qas./int./prod.hetarchief.be). + const stripDomain = (url: string): string => { + try { + const parsed = new URL(url); + return `${parsed.pathname}${parsed.search}${parsed.hash}`; + } catch { + return url; + } + }; + + const renderTile = (item: ObjectsGridItem, isFixed: boolean): ReactElement => { + const iconName = item.type ? TYPE_ICON_NAME[item.type] : undefined; + + return ( +
  • + +
    + {item.thumbnailUrl ? ( + + ) : ( + // No thumbnail (e.g. audio): decorative placeholder, the link already carries the name. + + )} +
    +
    +
    + {item.name} + {item.maintainerName && ( + {item.maintainerName} + )} +
    + {iconName && } +
    +
    +
  • + ); + }; + + const fixedObjects = data?.fixedObjects ?? []; + const objects = data?.objects ?? []; + + // Interleave order (see functional analysis): the first fixed object, then 2 random results, + // then the remaining fixed objects, then the rest of the random results. + // e.g. F1, R, R, F2, F3, R, R, … — with fixed tiles spanning 2 columns this puts F1 on row 1 + // and F2/F3 on row 2. + const [firstFixed, ...restFixed] = fixedObjects; + const orderedTiles: { item: ObjectsGridItem; isFixed: boolean }[] = [ + ...(firstFixed ? [{ item: firstFixed, isFixed: true }] : []), + ...objects.slice(0, 2).map((item) => ({ item, isFixed: false })), + ...restFixed.map((item) => ({ item, isFixed: true })), + ...objects.slice(2).map((item) => ({ item, isFixed: false })), + ]; + + const columns = getColumnsForWidth(windowWidth); + const visibleTiles = getVisibleTiles(orderedTiles, columns); + const hasObjects = visibleTiles.length > 0; + + return ( +
    + {title && ( + + {title} + + )} + + {/* Screen-reader status for the dynamic (async) content. */} + + {isLoading && + tText( + 'modules/content-page/components/blocks/block-objects-grid/block-objects-grid___objecten-worden-geladen', + undefined, + [HET_ARCHIEF] + )} + {isError && + tText( + 'modules/content-page/components/blocks/block-objects-grid/block-objects-grid___er-konden-geen-objecten-geladen-worden', + undefined, + [HET_ARCHIEF] + )} + {!isLoading && + !isError && + !hasObjects && + tText( + 'modules/content-page/components/blocks/block-objects-grid/block-objects-grid___er-zijn-geen-objecten-gevonden', + undefined, + [HET_ARCHIEF] + )} + + + {hasObjects && ( +
      + {visibleTiles.map(({ item, isFixed }) => renderTile(item, isFixed))} +
    + )} + + {searchQuery && ( +
    + + {tText( + 'modules/content-page/components/blocks/block-objects-grid/block-objects-grid___toon-alle', + undefined, + [HET_ARCHIEF] + )} + +
    + )} +
    + ); +}; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.types.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.types.ts new file mode 100644 index 00000000..e279cd38 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.types.ts @@ -0,0 +1,65 @@ +import { SearchFilter } from '~core/config/config.types.ts'; +import type { HeadingTypeOption } from '~modules/content-page/types/content-block.types'; +import type { DefaultComponentProps } from '~modules/shared/types/components'; +import type { PickerItem } from '~modules/shared/types/content-picker'; + +/** + * The object types that can appear in the grid. Drives which type-icon is shown in the + * title bar, and whether the audio "waveform" fallback is rendered instead of a thumbnail. + */ +export enum ObjectsGridItemType { + Video = 'video', + Audio = 'audio', + Newspaper = 'newspaper', + Image = 'image', +} + +/** + * A single IE-object as rendered in the grid. + * + * NOTE (backend contract): this is the normalised shape the component renders. The raw + * search response is mapped to this in `BlockObjectsGrid.service.ts`. If the search proxy + * returns different field names, adjust the mapping there — the component only depends on + * this interface. + */ +export interface ObjectsGridItem { + // pid / fragmentId, used for the detail-page link and as the React key. + schemaIdentifier: string; + // Object title, shown in the title bar. + name: string; + // Provider / maintainer name, shown in the title bar. + maintainerName?: string; + // Object type, drives the type-icon. Falls back to a generic icon when unknown. + type?: ObjectsGridItemType; + // Thumbnail (video / newspaper / image). Absent for audio → waveform fallback is shown. + thumbnailUrl?: string; +} + +/** + * A single configured "fixed position": a pinned ie-object shown before the query results. + * `mediaItem.value` holds the object's schemaIdentifier (pid / fragmentId). + */ +export interface ObjectsGridFixedPosition { + mediaItem: PickerItem; +} + +export interface BlockObjectsGridProps extends DefaultComponentProps { + // Optional heading shown above the grid. + title?: string; + titleType?: HeadingTypeOption; + // Raw hetarchief.be search URL that fills the grid, e.g. + // https://hetarchief.be/zoeken?aanbieders=OR-...&page=1 + searchQuery: string; + // 0..3 fixed positions, always shown first (as landscape tiles on rows 1-2). + elements?: ObjectsGridFixedPosition[]; + // Block-wide background color (from the block-level "Blok-opties"). + backgroundColor?: string; +} + +export enum OrderProperty { + RELEVANCE = 'relevance', + CREATED = 'created', + ARCHIVED = 'archived', + NAME = 'name', + RANDOM = 'random', +} diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/hooks/useGetObjectsGridItems.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/hooks/useGetObjectsGridItems.tsx new file mode 100644 index 00000000..05f2ac72 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/hooks/useGetObjectsGridItems.tsx @@ -0,0 +1,27 @@ +import { useQuery } from '@tanstack/react-query'; +import type { PickerItem } from '~modules/shared/types/content-picker'; +import { QUERY_KEYS } from '~shared/types'; +import { + DEFAULT_OBJECTS_GRID_LIMIT, + getObjectsGridItems, + type ObjectsGridData, +} from '../BlockObjectsGrid.service'; + +export const useGetObjectsGridItems = ( + searchQuery: string, + fixedItems: PickerItem[], + limit = DEFAULT_OBJECTS_GRID_LIMIT +) => { + return useQuery({ + queryKey: [ + QUERY_KEYS.GET_OBJECTS_GRID_ITEMS, + searchQuery, + limit, + fixedItems.map((item) => item.value).join(','), + ], + queryFn: () => getObjectsGridItems(searchQuery, fixedItems, limit), + // FA: a different random selection should be shown on every load, so don't serve stale data. + staleTime: 0, + enabled: Boolean(searchQuery) || fixedItems.length > 0, + }); +}; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/index.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/index.ts new file mode 100644 index 00000000..b22e47d8 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/index.ts @@ -0,0 +1,6 @@ +export { BlockObjectsGrid } from './BlockObjectsGrid'; +export { + INITIAL_OBJECTS_GRID_BLOCK_STATE, + INITIAL_OBJECTS_GRID_COMPONENTS_STATE, + OBJECTS_GRID_BLOCK_CONFIG, +} from './BlockObjectsGrid.editorconfig'; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/README.md b/ui/src/react-admin/modules/content-page/components/blocks/README.md index ebdc2828..0bd4cfbc 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/README.md +++ b/ui/src/react-admin/modules/content-page/components/blocks/README.md @@ -113,10 +113,61 @@ that already rely on it — **do not extend it**: don't add new block types to `ADD_/REMOVE_COMPONENTS_STATE` actions in new work. (They're marked `@deprecated` for this reason.) -We deliberately have **not** migrated the legacy blocks to B: their state is -already persisted in the DB as bare arrays, so a switch would require a data -migration of all saved content pages plus prop changes in every affected preview -component. +We are migrating the legacy blocks to B **lazily**, one block at a time (see +below) — so new work should always target B. + +--- + +## Migrating a block from A → B + +No big-bang DB migration is needed. Stored data is upgraded **in memory on load** +and re-persisted as B on the next **save**, block by block. + +### The lazy data migration (already in place) + +[`../../services/content-page.converters.ts`](../../services/content-page.converters.ts) +is the single load/save choke-point (used by the editor *and* the public page +render). On load, `convertDbContentBlockToContentBlockConfig` wraps a legacy +array under `elements` — but only for blocks whose editorconfig has already been +switched to B: + +```ts +const configExpectsArray = Array.isArray(cleanConfig.components.state); +const componentState = Array.isArray(rawComponentState) + ? configExpectsArray + ? rawComponentState // block still on A → untouched + : { ...cleanConfig.components.state, elements: rawComponentState } // legacy A data, config now B → migrate + : { ...cleanConfig.components.state, ...rawComponentState }; +``` + +So a block becomes "migrated" the moment its editorconfig returns an object +state. On save, `convertContentPageInfoToDbContentPage` writes `components.state` +verbatim, persisting the B shape. Pages that are never re-opened keep working via +the load-time wrap. + +### Per-block recipe + +Standardize the fieldGroup key as **`elements`** — then preview components need no +change (they already receive `props.elements`). For each block: + +1. **editorconfig** — change `state` from a bare array to + `{ elements: [ SINGLE_ELEMENT() ] }`, and wrap the existing `fields` into one + `elements` field with `type: 'fieldGroup'`, `repeat: { defaultState, addButtonLabel, deleteButtonLabel }`, + and `min`/`max` carried over from the old `components.limits`. Drop + `components.name` and `components.limits`. +2. **Remove the type from `REPEATABLE_CONTENT_BLOCKS`** + ([`../ContentBlockRenderer/ContentBlockRenderer.const.tsx`](../ContentBlockRenderer/ContentBlockRenderer.const.tsx)). +3. **Preview component** — no change (still reads `props.elements`). + +`BlockButtons` is the reference example of a migrated block. Validation of the +nested `elements` group already works via `validateFieldGroup`. **`BlockRichText` +is an anomaly** (object state while still in `REPEATABLE_CONTENT_BLOCKS`, and its +preview tolerates both shapes) — migrate it individually. + +Mechanism-A code (the `REPEATABLE_CONTENT_BLOCKS` wrap, `ADD_/REMOVE_COMPONENTS_STATE` +actions, the array branches, `ContentBlockComponentsLimits`, and the converter +shim) can only be removed once **every** stored page has been re-saved as B — +e.g. via a one-time batch re-save. Until then the shim stays. --- 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 470d2a55..c0006309 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 @@ -24,6 +24,7 @@ import { KLAAR_BLOCK_CONFIG } from '~content-blocks/BlockKlaar'; import { MAINTAINERS_GRID_BLOCK_CONFIG } from '~content-blocks/BlockMaintainersGrid'; import { MEDIA_GRID_BLOCK_CONFIG } from '~content-blocks/BlockMediaGrid'; import { OVERVIEW_NEWSPAPER_TITLES_BLOCK_CONFIG } from '~content-blocks/BlockOverviewNewspaperTitles'; +import { OBJECTS_GRID_BLOCK_CONFIG } from '~content-blocks/BlockObjectsGrid'; import { CONTENT_OVERVIEW_WITH_CAROUSEL_CONFIG } from '~content-blocks/BlockOverviewWithCarousel'; import { QUOTE_BLOCK_CONFIG } from '~content-blocks/BlockQuote'; import { @@ -87,4 +88,5 @@ export const CONTENT_BLOCK_CONFIG_MAP: Record< [ContentBlockType.Breadcrumbs]: CONTENT_BREADCRUMBS_CONFIG, [ContentBlockType.ScrollDownNudge]: CONTENT_SCROLL_DOWN_NUDGE_CONFIG, [ContentBlockType.OverviewWithCarousel]: CONTENT_OVERVIEW_WITH_CAROUSEL_CONFIG, + [ContentBlockType.ObjectsGrid]: OBJECTS_GRID_BLOCK_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 a864713a..93abe0c9 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 @@ -24,6 +24,7 @@ import { INITIAL_KLAAR_COMPONENTS_STATE } from '~content-blocks/BlockKlaar'; import { INITIAL_MAINTAINERS_GRID_BLOCK_STATE } from '~content-blocks/BlockMaintainersGrid'; import { INITIAL_MEDIA_GRID_COMPONENTS_STATE } from '~content-blocks/BlockMediaGrid'; import { INITIAL_OVERVIEW_NEWSPAPER_TITLES_BLOCK_STATE } from '~content-blocks/BlockOverviewNewspaperTitles'; +import { INITIAL_OBJECTS_GRID_BLOCK_STATE } from '~content-blocks/BlockObjectsGrid'; import { INITIAL_CONTENT_OVERVIEW_WITH_CAROUSEL_BLOCK_STATE } from '~content-blocks/BlockOverviewWithCarousel/BlockOverviewWithCarousel.editorconfig.ts'; import { INITIAL_PAGE_OVERVIEW_COMPONENTS_STATE } from '~content-blocks/BlockPageOverview'; import { INITIAL_QUOTE_COMPONENTS_STATE } from '~content-blocks/BlockQuote'; @@ -90,4 +91,5 @@ export const CONTENT_BLOCK_INITIAL_STATE_MAP: { [ContentBlockType.Breadcrumbs]: INITIAL_BREADCRUMBS_BLOCK_STATE, [ContentBlockType.ScrollDownNudge]: INITIAL_SCROLL_DOWN_NUDGE_BLOCK_STATE, [ContentBlockType.OverviewWithCarousel]: INITIAL_CONTENT_OVERVIEW_WITH_CAROUSEL_BLOCK_STATE, + [ContentBlockType.ObjectsGrid]: INITIAL_OBJECTS_GRID_BLOCK_STATE, }; 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 94bcf7c7..4f7e1398 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 @@ -184,6 +184,14 @@ export const GET_CONTENT_BLOCK_TYPE_OPTIONS: () => SelectOption[] = () = ), value: ContentBlockType.OverviewWithCarousel, }, + { + label: tText( + 'modules/content-page/const/get-content-block-type-options___objecten-grid', + {}, + [HET_ARCHIEF] + ), + value: ContentBlockType.ObjectsGrid, + }, ]; // Only show the content blocks that the client enabled through the config object diff --git a/ui/src/react-admin/modules/content-page/services/content-page.converters.test.ts b/ui/src/react-admin/modules/content-page/services/content-page.converters.test.ts new file mode 100644 index 00000000..15bd5cde --- /dev/null +++ b/ui/src/react-admin/modules/content-page/services/content-page.converters.test.ts @@ -0,0 +1,84 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { AdminConfigManager } from '~core/config/config.class'; +import { ContentBlockType } from '~modules/content-page/types/content-block.types'; +import { convertDbContentBlockToContentBlockConfig } from './content-page.converters'; + +beforeAll(() => { + AdminConfigManager.setConfig({ + services: { + i18n: { + tText: (key: string) => key, + tHtml: (key: string) => key, + }, + // biome-ignore lint/suspicious/noExplicitAny: minimal stub for test + toastService: { showToast: () => {}, hideToast: () => {} } as any, + }, + components: { + buttonTypes: () => [], + }, + env: { + DATABASE_APPLICATION_TYPE: 'avo', + }, + // biome-ignore lint/suspicious/noExplicitAny: minimal stub for test + } as any); +}); + +describe('A->B lazy migration in converter', () => { + it('wraps a legacy-array Buttons block (migrated to B) under `elements`', () => { + const legacyButtons = [ + { label: 'One', type: 'primary' }, + { label: 'Two', type: 'secondary' }, + ]; + const [config] = convertDbContentBlockToContentBlockConfig([ + { + type: ContentBlockType.Buttons, + name: 'Knoppen', + position: 0, + // biome-ignore lint/suspicious/noExplicitAny: test fixture + components: legacyButtons as any, + // biome-ignore lint/suspicious/noExplicitAny: test fixture + block: {} as any, + }, + ]); + + const state = config.components.state as { elements: unknown[] }; + expect(Array.isArray(config.components.state)).toBe(false); + expect(state.elements).toEqual(legacyButtons); + }); + + it('leaves a still-array block (CTAs, not yet migrated) as an array', () => { + const legacyCtas = [{ heading: 'A' }, { heading: 'B' }]; + const [config] = convertDbContentBlockToContentBlockConfig([ + { + type: ContentBlockType.CTAs, + name: 'CTAs', + position: 0, + // biome-ignore lint/suspicious/noExplicitAny: test fixture + components: legacyCtas as any, + // biome-ignore lint/suspicious/noExplicitAny: test fixture + block: {} as any, + }, + ]); + + expect(Array.isArray(config.components.state)).toBe(true); + expect(config.components.state).toHaveLength(2); + }); + + it('merges an already-B Buttons object onto defaults (idempotent re-save)', () => { + const migrated = { elements: [{ label: 'One', type: 'primary' }] }; + const [config] = convertDbContentBlockToContentBlockConfig([ + { + type: ContentBlockType.Buttons, + name: 'Knoppen', + position: 0, + // biome-ignore lint/suspicious/noExplicitAny: test fixture + components: migrated as any, + // biome-ignore lint/suspicious/noExplicitAny: test fixture + block: {} as any, + }, + ]); + + const state = config.components.state as { elements: unknown[] }; + expect(state.elements).toEqual(migrated.elements); + }); +}); diff --git a/ui/src/react-admin/modules/content-page/services/content-page.converters.ts b/ui/src/react-admin/modules/content-page/services/content-page.converters.ts index 4ee2bc25..c59dfc55 100644 --- a/ui/src/react-admin/modules/content-page/services/content-page.converters.ts +++ b/ui/src/react-admin/modules/content-page/services/content-page.converters.ts @@ -75,9 +75,19 @@ export function convertDbContentBlockToContentBlockConfig( } const cleanConfig = configForType(contentBlock.position); + // Lazy migration of the repetition mechanism A -> B (see components/blocks/README.md). + // Mechanism A stores `components` as a bare array; mechanism B stores it as an object + // with the repeated entries under an `elements` field group. A block is considered + // "migrated" once its editorconfig returns an object state (B). When that block still + // has legacy array data in the DB, we wrap it under `elements` here; on the next save + // it is persisted in shape B. Blocks whose editorconfig is still array-shaped (A) are + // left untouched. const rawComponentState = components; + const configExpectsArray = Array.isArray(cleanConfig.components.state); const componentState = Array.isArray(rawComponentState) - ? rawComponentState + ? configExpectsArray + ? rawComponentState // block not yet migrated -> keep shape A untouched + : { ...cleanConfig.components.state, elements: rawComponentState } // legacy A data, config is now B -> migrate : { ...cleanConfig.components.state, ...rawComponentState }; return { 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 5135bb25..9991d5c4 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 @@ -187,6 +187,7 @@ export enum ContentBlockType { AvoImageTextBackground = 'AVO_IMAGE_TEXT_BACKGROUND', // Avo ScrollDownNudge = 'SCROLL_DOWN_NUDGE', OverviewWithCarousel = 'OVERVIEW_WITH_CAROUSEL', + ObjectsGrid = 'OBJECTS_GRID', } export enum ContentBlockEditor { diff --git a/ui/src/react-admin/modules/content-page/views/ContentPageEdit.tsx b/ui/src/react-admin/modules/content-page/views/ContentPageEdit.tsx index 8d2bd01e..d7ee7e93 100644 --- a/ui/src/react-admin/modules/content-page/views/ContentPageEdit.tsx +++ b/ui/src/react-admin/modules/content-page/views/ContentPageEdit.tsx @@ -26,6 +26,7 @@ import { contentEditReducer, } from '~modules/content-page/helpers/content-edit.reducer'; import { useContentTypes } from '~modules/content-page/hooks/useContentTypes'; +import { convertDbContentBlockToContentBlockConfig } from '~modules/content-page/services/content-page.converters'; import { ContentPageService } from '~modules/content-page/services/content-page.service'; import type { ContentBlockComponentState, @@ -35,6 +36,8 @@ import type { ContentBlockStateOption, ContentBlockStateType, ContentBlockType, + DbContentBlock, + DefaultContentBlockState, RepeatedContentBlockComponentState, SingleContentBlockComponentState, } from '~modules/content-page/types/content-block.types'; @@ -231,17 +234,37 @@ export const ContentPageEdit: FC = ({ type: ToastType.SPINNER, }); + // Re-hydrate the pasted block through the DB->config converter so it gets fresh + // field definitions (validators/isVisible functions are lost across the clipboard + // JSON round-trip) and is upgraded to the current repetition mechanism (A->B). + // See components/blocks/README.md. + const pasted = newBlockConfig as unknown as ContentBlockConfig; + const [rehydratedBlock] = convertDbContentBlockToContentBlockConfig([ + { + type: pasted.type, + name: pasted.name, + anchor: pasted.anchor, + position: pasted.position ?? 0, + // A pasted block is a ContentBlockConfig (components = { state, fields }); fall + // back to a raw DB shape (components = state) for any other pasted payload. + components: (pasted.components?.state ?? + pasted.components) as ContentBlockComponentState, + block: (pasted.block?.state ?? pasted.block) as DefaultContentBlockState, + } as DbContentBlock, + ]); + const blockToAdd = (rehydratedBlock ?? newBlockConfig) as Partial; + // Remove id before duplicating - delete newBlockConfig.id; + delete blockToAdd.id; // Ensure block is added at the bottom of the page - newBlockConfig.position = ( + blockToAdd.position = ( contentPageState?.currentContentPageInfo?.content_blocks || [] ).length; // Duplicate the assets used in this content block, so it is no longer linked to the original content block const newBlockConfigWithDuplicatedAssets = - await ContentPageService.duplicateContentImages(newBlockConfig); + await ContentPageService.duplicateContentImages(blockToAdd); // Temp id until this block is saved into the database newBlockConfigWithDuplicatedAssets.id = TEMP_BLOCK_ID_PREFIX + Date.now(); diff --git a/ui/src/react-admin/modules/index.ts b/ui/src/react-admin/modules/index.ts index 540ced09..0505f0d9 100644 --- a/ui/src/react-admin/modules/index.ts +++ b/ui/src/react-admin/modules/index.ts @@ -116,6 +116,7 @@ export * from './content-page/components/blocks/BlockIntro/BlockIntro'; export * from './content-page/components/blocks/BlockKlaar/BlockKlaar'; export * from './content-page/components/blocks/BlockMaintainersGrid/BlockMaintainersGrid'; export * from './content-page/components/blocks/BlockMediaGrid/BlockMediaGrid.editorconfig'; +export * from './content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid'; export * from './content-page/components/blocks/BlockOverviewNewspaperTitles/BlockOverviewNewspaperTitles'; export * from './content-page/components/blocks/BlockPageOverview/BlockPageOverview'; export * from './content-page/components/blocks/BlockQuote/BlockQuote'; diff --git a/ui/src/react-admin/modules/shared/types/index.ts b/ui/src/react-admin/modules/shared/types/index.ts index 7e8af39b..67b0295c 100644 --- a/ui/src/react-admin/modules/shared/types/index.ts +++ b/ui/src/react-admin/modules/shared/types/index.ts @@ -38,6 +38,7 @@ export enum QUERY_KEYS { GET_ALL_LANGUAGES = 'GET_ALL_LANGUAGES', GET_IE_OBJECTS_NEWSPAPER_TITLES = 'GET_IE_OBJECTS_NEWSPAPER_TITLES', GET_IE_OBJECT = 'GET_IE_OBJECT', + GET_OBJECTS_GRID_ITEMS = 'GET_OBJECTS_GRID_ITEMS', GET_TABLE_COLUMN_PREFERENCE = 'GET_TABLE_COLUMN_PREFERENCE', GET_NAMES_BY_PROFILE_IDS = 'GET_NAMES_BY_PROFILE_IDS', } diff --git a/ui/src/shared/helpers/admin-core-config.tsx b/ui/src/shared/helpers/admin-core-config.tsx index c9e68078..ed90e5dc 100644 --- a/ui/src/shared/helpers/admin-core-config.tsx +++ b/ui/src/shared/helpers/admin-core-config.tsx @@ -97,6 +97,7 @@ export function getAdminCoreConfigForLocalTestApp(navigateFunc: NavigateFunction // ContentBlockType.ContentEncloseGrid, // ContentBlockType.Breadcrumbs, ContentBlockType.OverviewWithCarousel, + ContentBlockType.ObjectsGrid, ], defaultPageWidth: ContentPageWidth.LARGE, onSaveContentPage: async (contentPageInfo: ContentPageInfo) => { diff --git a/ui/src/shared/translations/hetArchief/nl.json b/ui/src/shared/translations/hetArchief/nl.json index b72f7032..d8d89b94 100644 --- a/ui/src/shared/translations/hetArchief/nl.json +++ b/ui/src/shared/translations/hetArchief/nl.json @@ -698,6 +698,23 @@ "modules/content-page/components/blocks/block-overview-newspaper-titles/overview-newspaper-titles___knop-type-kleur": "Knop type kleur", "modules/content-page/components/blocks/block-overview-newspaper-titles/overview-newspaper-titles___knoptekst-is-verplicht": "Knoptekst is verplicht", "modules/content-page/components/blocks/block-overview-newspaper-titles/overview-newspaper-titles___krantentitels": "Krantentitels", + "modules/content-page/components/blocks/block-objects-grid/block-objects-grid___een-zoekfilter-is-verplicht": "Een zoekfilter is verplicht", + "modules/content-page/components/blocks/block-objects-grid/block-objects-grid___er-konden-geen-objecten-geladen-worden": "Er konden geen objecten geladen worden.", + "modules/content-page/components/blocks/block-objects-grid/block-objects-grid___er-zijn-geen-objecten-gevonden": "Er zijn geen objecten gevonden.", + "modules/content-page/components/blocks/block-objects-grid/block-objects-grid___ga-naar-de-detailpagina-van-title": "Ga naar de detailpagina van {{title}}", + "modules/content-page/components/blocks/block-objects-grid/block-objects-grid___geef-een-zoek-url-van-hetarchief-be-in-bijvoorbeeld-https-hetarchief-be-zoeken-aanbieders-or-page-1": "Geef een zoek-URL van hetarchief.be in, bijvoorbeeld https://hetarchief.be/zoeken?aanbieders=OR-...&page=1", + "modules/content-page/components/blocks/block-objects-grid/block-objects-grid___gelieve-een-geldige-url-in-te-vullen": "Gelieve een geldige URL in te vullen", + "modules/content-page/components/blocks/block-objects-grid/block-objects-grid___object-pid-of-fragment-id": "Object (pid of fragment id)", + "modules/content-page/components/blocks/block-objects-grid/block-objects-grid___objecten-grid": "Objecten grid", + "modules/content-page/components/blocks/block-objects-grid/block-objects-grid___objecten-worden-geladen": "Objecten worden geladen…", + "modules/content-page/components/blocks/block-objects-grid/block-objects-grid___titel": "Titel", + "modules/content-page/components/blocks/block-objects-grid/block-objects-grid___titel-grootte": "Titel grootte", + "modules/content-page/components/blocks/block-objects-grid/block-objects-grid___toon-alle": "Toon alle", + "modules/content-page/components/blocks/block-objects-grid/block-objects-grid___toon-alle-objecten-voor-deze-zoekopdracht": "Toon alle objecten voor deze zoekopdracht", + "modules/content-page/components/blocks/block-objects-grid/block-objects-grid___vaste-positie": "Vaste positie", + "modules/content-page/components/blocks/block-objects-grid/block-objects-grid___verwijder-vaste-positie": "Verwijder vaste positie", + "modules/content-page/components/blocks/block-objects-grid/block-objects-grid___voeg-een-vaste-positie-toe": "Voeg een vaste positie toe", + "modules/content-page/components/blocks/block-objects-grid/block-objects-grid___zoekfilter-url": "Zoekfilter (URL)", "modules/content-page/components/blocks/block-page-overview/block-page-overview___afbeelding-uitlijning": "Uitlijning afbeelding", "modules/content-page/components/blocks/block-page-overview/block-page-overview___item-alignment": "item aligneren", "modules/content-page/components/blocks/block-page-overview/block-page-overview___toon-de-label-tussen-titels": "Toon de label tussen titels", @@ -733,6 +750,7 @@ "modules/content-page/const/content-page___vertalingen": "Vertalingen", "modules/content-page/const/get-content-block-type-options___breadcrumbs": "Breadcrumbs", "modules/content-page/const/get-content-block-type-options___content-enclose-grid": "Content insluiten grid", + "modules/content-page/const/get-content-block-type-options___objecten-grid": "Objecten grid", "modules/content-page/const/get-content-block-type-options___overzicht-krantentitels": "Overzicht krantentitels", "modules/content-page/const/get-content-block-type-options___scroll-down-nudge": "Nudge om te scrollen", "modules/content-page/const/get-heading-type-options___groot": "Groot",