From cf138f9c6db2465e4ec02bc5cb7af7bfa1b6c39b Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Fri, 17 Jul 2026 21:10:43 +0200 Subject: [PATCH 1/9] feat(ARC-3792): objects grid block https://meemoo.atlassian.net/browse/ARC-3792 --- ui/src/client.ts | 1 + .../ContentBlockRenderer.const.tsx | 5 +- .../BlockButtons/BlockButtons.editorconfig.ts | 87 +++--- .../BlockObjectsGrid.editorconfig.ts | 176 +++++++++++ .../BlockObjectsGrid/BlockObjectsGrid.scss | 197 ++++++++++++ .../BlockObjectsGrid.search-filters.ts | 285 ++++++++++++++++++ .../BlockObjectsGrid.service.ts | 274 +++++++++++++++++ .../BlockObjectsGrid/BlockObjectsGrid.tsx | 174 +++++++++++ .../BlockObjectsGrid.types.ts | 56 ++++ .../hooks/useGetObjectsGridItems.tsx | 27 ++ .../blocks/BlockObjectsGrid/index.ts | 6 + .../content-page/components/blocks/README.md | 59 +++- .../const/content-block-config-map.ts | 2 + .../const/content-block-initial-state-map.ts | 2 + .../const/get-content-block-type-options.ts | 8 + .../services/content-page.converters.test.ts | 84 ++++++ .../services/content-page.converters.ts | 12 +- .../content-page/types/content-block.types.ts | 1 + .../content-page/views/ContentPageEdit.tsx | 29 +- ui/src/react-admin/modules/index.ts | 1 + .../react-admin/modules/shared/types/index.ts | 1 + ui/src/shared/helpers/admin-core-config.tsx | 1 + ui/src/shared/translations/hetArchief/nl.json | 18 ++ 23 files changed, 1462 insertions(+), 44 deletions(-) create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.editorconfig.ts create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.scss create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.search-filters.ts create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.service.ts create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.tsx create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.types.ts create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/hooks/useGetObjectsGridItems.tsx create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/index.ts create mode 100644 ui/src/react-admin/modules/content-page/services/content-page.converters.test.ts diff --git a/ui/src/client.ts b/ui/src/client.ts index 1588c6387..9b1f2d303 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/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.const.tsx b/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.const.tsx index 53a9fb6a6..8a9751276 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 5fb34b71a..3ce69f4f1 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 000000000..1fa27286c --- /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 000000000..09c6f586e --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.scss @@ -0,0 +1,197 @@ +@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); a fixed + // landscape tile always spans 2 of these columns. + --c-objects-grid-columns: 2; + + @media (min-width: variables.$g-bp1) { + --c-objects-grid-columns: 3; + } + + @media (min-width: variables.$g-bp2) { + --c-objects-grid-columns: 4; + } + + @media (min-width: variables.$g-bp3) { + --c-objects-grid-columns: 5; + } + + @media (min-width: variables.$g-bp4) { + --c-objects-grid-columns: 6; + } + + &__title { + color: colors.$color-gray-1000; + font-family: SofiaPro, sans-serif; + font-size: 2.4rem; + font-weight: 800; + margin-bottom: 2.2rem; + } + + &__grid { + display: grid; + grid-template-columns: repeat(var(--c-objects-grid-columns), minmax(0, 1fr)); + gap: 1.6rem; + list-style: none; + margin: 0; + padding: 0; + } + + &__tile { + display: flex; + min-width: 0; + + // Fixed positions are shown as a landscape tile that occupies 2 standard positions. + &--fixed { + grid-column: span 2; + } + } + + &__tile-link { + display: flex; + flex-direction: column; + width: 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; + } + } + + &__tile-media { + position: relative; + width: 100%; + overflow: hidden; + background-color: colors.$color-gray-100; + + // Standard position: portrait 9:16. + aspect-ratio: 9 / 16; + + .c-block-objects-grid__tile--fixed & { + // Fixed position: landscape 16:9. + aspect-ratio: 16 / 9; + } + + &--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 + ); + } + } + + &__tile-image { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + display: block; + transition: transform 0.5s ease-out; + } + + &__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) { + &__tile-link:hover .c-block-objects-grid__tile-image { + transform: scale(1.1); + } + } + + &__tile-titlebar { + display: flex; + align-items: center; + gap: 0.8rem; + + // Black bar with white text/icons (see FA) — contrast ratio 21:1. + background-color: colors.$color-gray-1000; + color: colors.$white; + padding: 0.8rem 1.2rem; + } + + &__tile-type-icon { + flex-shrink: 0; + font-size: 1.6rem; + line-height: 1; + } + + &__tile-title { + flex: 1 1 auto; + min-width: 0; + font-family: SofiaPro, sans-serif; + font-size: 1.4rem; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__tile-maintainer { + flex-shrink: 0; + font-size: 1.2rem; + font-weight: 400; + max-width: 40%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__footer { + display: flex; + justify-content: center; + margin-top: 2.4rem; + } + + &__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). + &__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.search-filters.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.search-filters.ts new file mode 100644 index 000000000..95f8d047f --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.search-filters.ts @@ -0,0 +1,285 @@ +import { format, parseISO } from 'date-fns'; + +/** + * ie-objects search-API filter model + hetarchief `/zoeken` URL → filter translation. + * + * This is a faithful, UI-free PORT of the hetarchief-client logic (it cannot be imported: the + * client depends on `@meemoo/admin-core-ui`, not the other way around). Kept in sync with: + * - hetarchief-client `shared/types/ie-objects.ts` (IeObjectsSearchFilterField / …Operator) + * - hetarchief-client `visitor-space/utils/elastic-filters` (mapFiltersToElastic) + * - hetarchief-client `visitor-space/utils/map-filters` (mapAdvancedToElastic) + * - hetarchief-client `visitor-space/const/advanced-filters.consts` (FILTERS_OPTIONS_CONFIG) + * - hetarchief-client `visitor-space/const/advanced-filter-array-param` (url codec) + */ + +// Mirrors IeObjectsSearchFilterField. +export enum IeObjectsSearchFilterField { + RELEASE_DATE = 'releaseDate', + CREATED = 'created', + CREATOR = 'creator', + DESCRIPTION = 'description', + DURATION = 'duration', + SPACIAL_COVERAGE = 'spacialCoverage', + TEMPORAL_COVERAGE = 'temporalCoverage', + FORMAT = 'format', + GENRE = 'genre', + KEYWORD = 'keyword', + LANGUAGE = 'language', + MEDIUM = 'medium', + NAME = 'name', + PUBLISHED = 'published', + PUBLISHER = 'publisher', + NEWSPAPER_SERIES_NAME = 'newspaperSeriesName', + LOCATION_CREATED = 'locationCreated', + MENTIONS = 'mentions', + QUERY = 'query', + MAINTAINER_ID = 'maintainer', + CONSULTABLE_ONLY_ON_LOCATION = 'isConsultableOnlyOnLocation', + CONSULTABLE_MEDIA = 'isConsultableMedia', + CONSULTABLE_PUBLIC_DOMAIN = 'isConsultablePublicDomain', + REUSABILITY = 'reusability', + RIGHTS = 'rights', + CAST = 'cast', + IDENTIFIER = 'identifier', + OBJECT_TYPE = 'objectType', +} + +// Mirrors IeObjectsSearchOperator. +export enum IeObjectsSearchOperator { + CONTAINS = 'contains', + CONTAINS_NOT = 'containsNot', + GTE = 'gte', + IS = 'is', + IS_NOT = 'isNot', + LTE = 'lte', +} + +export interface SearchFilter { + field: IeObjectsSearchFilterField; + operator: IeObjectsSearchOperator; + value?: string; + multiValue?: string[]; +} + +// The client joins multi-value ranges with SEPARATOR and label-suffixes values with this delimiter. +const SEPARATOR = '--'; +export const FILTER_LABEL_VALUE_DELIMITER = '---'; + +// --- Advanced filter model (mirrors FilterProperty / Operator) -------------------------------- + +enum FilterProperty { + CAST = 'CAST', + CREATED_AT = 'CREATED_AT', + CREATOR = 'CREATOR', + DESCRIPTION = 'DESCRIPTION', + DURATION = 'DURATION', + GENRE = 'GENRE', + IDENTIFIER = 'IDENTIFIER', + KEYWORDS = 'KEYWORDS', + LANGUAGE = 'LANGUAGE', + MEDIA_TYPE = 'MEDIA_TYPE', + MEDIUM = 'MEDIUM', + OBJECT_TYPE = 'OBJECT_TYPE', + PUBLISHED_AT = 'PUBLISHED_AT', + PUBLISHER = 'PUBLISHER', + RELEASE_DATE = 'RELEASE_DATE', + RIGHTS = 'RIGHTS', + SPACIAL_COVERAGE = 'SPACIAL_COVERAGE', + TEMPORAL_COVERAGE = 'TEMPORAL_COVERAGE', + TITLE = 'TITLE', + NEWSPAPER_SERIES_NAME = 'NEWSPAPER_SERIES_NAME', + LOCATION_CREATED = 'LOCATION_CREATED', + MENTIONS = 'MENTIONS', +} + +enum Operator { + CONTAINS = 'CONTAINS', + CONTAINS_NOT = 'CONTAINS_NOT', + EQUALS = 'EQUALS', + EQUALS_NOT = 'EQUALS_NOT', + LESS_THAN_OR_EQUAL = 'LESS_THAN_OR_EQUAL', + GREATER_THAN_OR_EQUAL = 'GREATER_THAN_OR_EQUAL', + BETWEEN = 'BETWEEN', + EXACT = 'EXACT', +} + +interface AdvancedFilter { + prop: FilterProperty; + op: Operator; + val?: string; +} + +// 2-letter url acronyms (mirrors advanced-filter-array-param). +const FILTER_NAME_WITH_ACRONYM: [FilterProperty, string][] = [ + [FilterProperty.CAST, 'cs'], + [FilterProperty.CREATED_AT, 'ca'], + [FilterProperty.CREATOR, 'ct'], + [FilterProperty.DESCRIPTION, 'de'], + [FilterProperty.DURATION, 'du'], + [FilterProperty.GENRE, 'ge'], + [FilterProperty.IDENTIFIER, 'id'], + [FilterProperty.KEYWORDS, 'kw'], + [FilterProperty.LANGUAGE, 'la'], + [FilterProperty.MEDIA_TYPE, 'ty'], + [FilterProperty.MEDIUM, 'me'], + [FilterProperty.OBJECT_TYPE, 'ot'], + [FilterProperty.PUBLISHED_AT, 'pa'], + [FilterProperty.PUBLISHER, 'pu'], + [FilterProperty.RELEASE_DATE, 'rd'], + [FilterProperty.RIGHTS, 'ri'], + [FilterProperty.SPACIAL_COVERAGE, 'sc'], + [FilterProperty.TEMPORAL_COVERAGE, 'tc'], + [FilterProperty.TITLE, 'ti'], + [FilterProperty.NEWSPAPER_SERIES_NAME, 'ns'], + [FilterProperty.LOCATION_CREATED, 'lc'], + [FilterProperty.MENTIONS, 'mn'], +]; + +const FILTER_OPERATOR_WITH_ACRONYM: [Operator, string][] = [ + [Operator.CONTAINS, 'co'], + [Operator.CONTAINS_NOT, 'nc'], + [Operator.EQUALS, 'eq'], + [Operator.EQUALS_NOT, 'ne'], + [Operator.LESS_THAN_OR_EQUAL, 'lt'], + [Operator.GREATER_THAN_OR_EQUAL, 'gt'], + [Operator.BETWEEN, 'bt'], + [Operator.EXACT, 'ex'], +]; + +const acronymToFilterProperty = (acronym: string): FilterProperty | undefined => + FILTER_NAME_WITH_ACRONYM.find(([, acr]) => acr === acronym)?.[0]; + +const acronymToOperator = (acronym: string): Operator | undefined => + FILTER_OPERATOR_WITH_ACRONYM.find(([, acr]) => acr === acronym)?.[0]; + +/** + * Decode a single `advanced`/`duration`/`releaseDate` url param value into advanced filters. + * Mirrors AdvancedFilterArrayParam.decode (renderKey/uuid omitted — not needed server-side). + */ +const decodeAdvancedFilterParam = (stringified: string | null): AdvancedFilter[] => { + if (!stringified) { + return []; + } + return stringified + .split(',') + .map((filter): AdvancedFilter | null => { + const prop = acronymToFilterProperty(filter.slice(0, 2)); + const op = acronymToOperator(filter.slice(2, 4)); + if (!prop || !op) { + return null; + } + return { prop, op, val: decodeURIComponent(filter.slice(4)) }; + }) + .filter((filter): filter is AdvancedFilter => filter !== null); +}; + +// Data-only port of FILTERS_OPTIONS_CONFIG: (property, operator) → filter template(s). +const dateFilters = (field: IeObjectsSearchFilterField) => ({ + [Operator.GREATER_THAN_OR_EQUAL]: [{ field, operator: IeObjectsSearchOperator.GTE }], + [Operator.LESS_THAN_OR_EQUAL]: [{ field, operator: IeObjectsSearchOperator.LTE }], + [Operator.BETWEEN]: [ + { field, operator: IeObjectsSearchOperator.GTE }, + { field, operator: IeObjectsSearchOperator.LTE }, + ], + [Operator.EQUALS]: [ + { field, operator: IeObjectsSearchOperator.GTE }, + { field, operator: IeObjectsSearchOperator.LTE }, + ], +}); + +const containsAndEqualsFilters = (field: IeObjectsSearchFilterField) => ({ + [Operator.CONTAINS]: [{ field, operator: IeObjectsSearchOperator.CONTAINS }], + [Operator.CONTAINS_NOT]: [{ field, operator: IeObjectsSearchOperator.CONTAINS_NOT }], + [Operator.EQUALS]: [{ field, operator: IeObjectsSearchOperator.IS }], + [Operator.EQUALS_NOT]: [{ field, operator: IeObjectsSearchOperator.IS_NOT }], +}); + +const equalsFilters = (field: IeObjectsSearchFilterField) => ({ + [Operator.EQUALS]: [{ field, operator: IeObjectsSearchOperator.IS }], + [Operator.EQUALS_NOT]: [{ field, operator: IeObjectsSearchOperator.IS_NOT }], +}); + +const containsFilters = (field: IeObjectsSearchFilterField) => ({ + [Operator.CONTAINS]: [{ field, operator: IeObjectsSearchOperator.CONTAINS }], + [Operator.CONTAINS_NOT]: [{ field, operator: IeObjectsSearchOperator.CONTAINS_NOT }], +}); + +const FILTERS_OPTIONS_CONFIG: Partial< + Record>> +> = { + [FilterProperty.RELEASE_DATE]: dateFilters(IeObjectsSearchFilterField.RELEASE_DATE), + [FilterProperty.CREATED_AT]: dateFilters(IeObjectsSearchFilterField.CREATED), + [FilterProperty.PUBLISHED_AT]: dateFilters(IeObjectsSearchFilterField.PUBLISHED), + [FilterProperty.DURATION]: { + [Operator.GREATER_THAN_OR_EQUAL]: [ + { field: IeObjectsSearchFilterField.DURATION, operator: IeObjectsSearchOperator.GTE }, + ], + [Operator.LESS_THAN_OR_EQUAL]: [ + { field: IeObjectsSearchFilterField.DURATION, operator: IeObjectsSearchOperator.LTE }, + ], + }, + [FilterProperty.DESCRIPTION]: containsFilters(IeObjectsSearchFilterField.DESCRIPTION), + [FilterProperty.GENRE]: equalsFilters(IeObjectsSearchFilterField.GENRE), + [FilterProperty.LANGUAGE]: equalsFilters(IeObjectsSearchFilterField.LANGUAGE), + [FilterProperty.RIGHTS]: equalsFilters(IeObjectsSearchFilterField.RIGHTS), + [FilterProperty.MEDIUM]: equalsFilters(IeObjectsSearchFilterField.MEDIUM), + [FilterProperty.SPACIAL_COVERAGE]: containsAndEqualsFilters( + IeObjectsSearchFilterField.SPACIAL_COVERAGE + ), + [FilterProperty.TEMPORAL_COVERAGE]: containsAndEqualsFilters( + IeObjectsSearchFilterField.TEMPORAL_COVERAGE + ), + [FilterProperty.OBJECT_TYPE]: containsAndEqualsFilters(IeObjectsSearchFilterField.OBJECT_TYPE), + [FilterProperty.PUBLISHER]: containsAndEqualsFilters(IeObjectsSearchFilterField.PUBLISHER), + [FilterProperty.TITLE]: containsAndEqualsFilters(IeObjectsSearchFilterField.NAME), + [FilterProperty.CAST]: containsAndEqualsFilters(IeObjectsSearchFilterField.CAST), + [FilterProperty.IDENTIFIER]: equalsFilters(IeObjectsSearchFilterField.IDENTIFIER), + [FilterProperty.KEYWORDS]: containsAndEqualsFilters(IeObjectsSearchFilterField.KEYWORD), + [FilterProperty.CREATOR]: containsAndEqualsFilters(IeObjectsSearchFilterField.CREATOR), + [FilterProperty.LOCATION_CREATED]: containsAndEqualsFilters( + IeObjectsSearchFilterField.LOCATION_CREATED + ), + [FilterProperty.NEWSPAPER_SERIES_NAME]: containsAndEqualsFilters( + IeObjectsSearchFilterField.NEWSPAPER_SERIES_NAME + ), + [FilterProperty.MENTIONS]: containsAndEqualsFilters(IeObjectsSearchFilterField.MENTIONS), +}; + +const getMetadataSearchFilters = (prop: FilterProperty, operator: Operator): SearchFilter[] => + FILTERS_OPTIONS_CONFIG[prop]?.[operator] || []; + +/** Faithful port of mapAdvancedToElastic: turns one advanced filter into ES filter(s). */ +const mapAdvancedToElastic = (item: AdvancedFilter): SearchFilter[] => { + const values = (item.val || '').split(SEPARATOR); + const filters = item.prop && item.op ? getMetadataSearchFilters(item.prop, item.op) : []; + + return filters.map((filter, i): SearchFilter => { + switch (item.prop) { + case FilterProperty.CREATED_AT: + case FilterProperty.PUBLISHED_AT: + case FilterProperty.RELEASE_DATE: { + if (item.op === Operator.EQUALS && values.length === 1) { + // Manually create a range of equal values: ARC-3191 + values[i] = values[0]; + } + const parsed = parseISO(values[i]); + values[i] = (parsed && format(parsed, 'yyyy-MM-dd')) || values[i]; + break; + } + case FilterProperty.DURATION: + // Add milliseconds since elasticsearch requires it: ARC-2549 + values[i] = `${values[0]}.00`; + break; + default: + break; + } + return { ...filter, value: values[i] }; + }); +}; + +/** + * Convert one hetarchief `/zoeken` url param value (comma-separated, acronym-encoded advanced + * filters — used by `advanced`, `duration`, `releaseDate`) into ES search filters. + */ +export const mapAdvancedFilterParamToElastic = (paramValue: string | null): SearchFilter[] => + decodeAdvancedFilterParam(paramValue).flatMap(mapAdvancedToElastic); 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 000000000..2499e659a --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.service.ts @@ -0,0 +1,274 @@ +import { stringifyUrl } from 'query-string'; +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 { + FILTER_LABEL_VALUE_DELIMITER, + IeObjectsSearchFilterField, + IeObjectsSearchOperator, + mapAdvancedFilterParamToElastic, + type SearchFilter, +} from './BlockObjectsGrid.search-filters'; +import { type ObjectsGridItem, ObjectsGridItemType } 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; + +interface IeObjectsSearchBody { + filters: SearchFilter[]; + size: number; + page: number; +} + +/** + * 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, +}); + +const stripLabel = (value: string): string => value.split(FILTER_LABEL_VALUE_DELIMITER)[0]; + +/** + * Convert a raw hetarchief.be `/zoeken` search URL into the ie-objects search body. + * + * Mirrors the client-side `mapFiltersToElastic` (hetarchief-client visitor-space/utils/elastic-filters): + * each search-page url param is translated to the matching API filter field/operator. The advanced + * range params (`duration`, `releaseDate`, `advanced`) are decoded via the ported advanced-filter + * logic in `BlockObjectsGrid.search-filters.ts`. + */ +export const parseSearchQueryToSearchBody = ( + searchQuery: string, + size: number +): IeObjectsSearchBody => { + const filters: SearchFilter[] = []; + // The API pagination is 1-based (default 1), same as the hetarchief search url. + let page = 1; + + const add = (filter: SearchFilter) => { + if (filter.value || filter.multiValue?.length) { + filters.push(filter); + } + }; + + try { + const params = new URL(searchQuery).searchParams; + const single = (key: string) => params.get(key) || ''; + const multi = (key: string) => params.getAll(key).filter(Boolean); + + const pageParam = Number(params.get('page')); + if (Number.isFinite(pageParam) && pageParam > 0) { + page = pageParam; + } + + // Free-text search terms (searchbar) → query / contains. + for (const term of multi('zoekterm')) { + add({ + field: IeObjectsSearchFilterField.QUERY, + operator: IeObjectsSearchOperator.CONTAINS, + value: term, + }); + } + + // Media type tab → format / is ("all" means no filter). + const format = single('format'); + if (format && format !== 'all') { + add({ + field: IeObjectsSearchFilterField.FORMAT, + operator: IeObjectsSearchOperator.IS, + value: format, + }); + } + + add({ + field: IeObjectsSearchFilterField.MEDIUM, + operator: IeObjectsSearchOperator.IS, + multiValue: multi('medium'), + }); + add({ + field: IeObjectsSearchFilterField.CREATOR, + operator: IeObjectsSearchOperator.CONTAINS, + value: single('creator'), + }); + add({ + field: IeObjectsSearchFilterField.NEWSPAPER_SERIES_NAME, + operator: IeObjectsSearchOperator.IS, + value: single('newspaperSeriesName'), + }); + add({ + field: IeObjectsSearchFilterField.LOCATION_CREATED, + operator: IeObjectsSearchOperator.IS, + value: single('locationCreated'), + }); + add({ + field: IeObjectsSearchFilterField.MENTIONS, + operator: IeObjectsSearchOperator.IS, + value: single('mentions'), + }); + add({ + field: IeObjectsSearchFilterField.GENRE, + operator: IeObjectsSearchOperator.IS, + multiValue: multi('genre'), + }); + add({ + field: IeObjectsSearchFilterField.KEYWORD, + operator: IeObjectsSearchOperator.IS, + multiValue: multi('keywords'), + }); + add({ + field: IeObjectsSearchFilterField.LANGUAGE, + operator: IeObjectsSearchOperator.IS, + multiValue: multi('language').map(stripLabel), + }); + // `aanbieders` (maintainers) → maintainer / is, values encoded as `OR-id---label`. + add({ + field: IeObjectsSearchFilterField.MAINTAINER_ID, + operator: IeObjectsSearchOperator.IS, + multiValue: multi('aanbieders').map(stripLabel), + }); + add({ + field: IeObjectsSearchFilterField.REUSABILITY, + operator: IeObjectsSearchOperator.IS, + multiValue: multi('herbruikbaarheid').map(stripLabel), + }); + if (single('onLocation')) { + add({ + field: IeObjectsSearchFilterField.CONSULTABLE_ONLY_ON_LOCATION, + operator: IeObjectsSearchOperator.IS, + value: 'true', + }); + } + if (single('media')) { + add({ + field: IeObjectsSearchFilterField.CONSULTABLE_MEDIA, + operator: IeObjectsSearchOperator.IS, + value: 'true', + }); + } + if (single('publicDomain')) { + add({ + field: IeObjectsSearchFilterField.CONSULTABLE_PUBLIC_DOMAIN, + operator: IeObjectsSearchOperator.IS, + value: 'true', + }); + } + + // Advanced range filters (duration / releaseDate / free-form advanced), acronym-encoded in the url. + for (const key of ['duration', 'releaseDate', 'advanced']) { + for (const filter of mapAdvancedFilterParamToElastic(params.get(key))) { + add(filter); + } + } + } catch { + // Invalid URL (already blocked by the editor validator) → return an empty search. + } + + return { filters, size, page }; +}; + +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 [fixedObjects, objects] = await Promise.all([ + getFixedObjects(fixedItems), + searchQuery + ? searchIeObjects(parseSearchQueryToSearchBody(searchQuery, limit)) + : 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 000000000..e43d84e85 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.tsx @@ -0,0 +1,174 @@ +import { AvoCoreContentPickerType } from '@viaa/avo2-types'; +import clsx from 'clsx'; +import type { ComponentProps, FunctionComponent, ReactElement } 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'; + +const getObjectDetailPath = (schemaIdentifier: string): string => + `${OBJECT_DETAIL_PATH_PREFIX}/${encodeURIComponent(schemaIdentifier)}`; + +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, +}): ReactElement => { + const fixedItems = elements.map((element) => element.mediaItem).filter((item) => item?.value); + const { data, isLoading, isError } = useGetObjectsGridItems(searchQuery, fixedItems); + + const renderTile = (item: ObjectsGridItem, isFixed: boolean): ReactElement => { + const iconName = item.type ? TYPE_ICON_NAME[item.type] : undefined; + const isAudio = item.type === ObjectsGridItemType.Audio; + + return ( +
  • + +
    + {item.thumbnailUrl ? ( + + ) : ( + // No thumbnail (e.g. audio): decorative placeholder, the link already carries the name. + + )} +
    +
    + {iconName && } + {item.name} + {item.maintainerName && ( + {item.maintainerName} + )} +
    +
    +
  • + ); + }; + + const fixedObjects = data?.fixedObjects ?? []; + const objects = data?.objects ?? []; + const hasObjects = fixedObjects.length > 0 || objects.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 && ( +
      + {fixedObjects.map((item) => renderTile(item, true))} + {objects.map((item) => renderTile(item, false))} +
    + )} + + {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 000000000..0f4e69503 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.types.ts @@ -0,0 +1,56 @@ +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; +} 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 000000000..05f2ac721 --- /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 000000000..b22e47d82 --- /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 ebdc28287..0bd4cfbc8 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 470d2a55d..c00063094 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 a864713a5..93abe0c9d 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 94bcf7c77..4f7e13985 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 000000000..15bd5cde3 --- /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 4ee2bc251..c59dfc550 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 5135bb255..9991d5c43 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 8d2bd01ef..d7ee7e939 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 540ced09a..0505f0d96 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 7e8af39b6..67b0295c0 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 c9e68078d..ed90e5dce 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 b72f70321..d8d89b94e 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", From 858c8d9b4857f4090b9b1ecb0507fd1c5261e81b Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Fri, 17 Jul 2026 21:14:59 +0200 Subject: [PATCH 2/9] fix(ARC-3792): fix the height of the tiles https://meemoo.atlassian.net/browse/ARC-3792 --- .../BlockObjectsGrid/BlockObjectsGrid.scss | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) 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 index 09c6f586e..d658c713b 100644 --- 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 @@ -2,24 +2,22 @@ @use "../../../../shared/styles/settings/colors" as colors; .c-block-objects-grid { - // Number of grid columns per breakpoint. The count is responsive (see FA); a fixed - // landscape tile always spans 2 of these columns. + // 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; - @media (min-width: variables.$g-bp1) { - --c-objects-grid-columns: 3; - } + // 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: 4; + --c-objects-grid-columns: 3; + --c-objects-grid-row-height: 28rem; } @media (min-width: variables.$g-bp3) { - --c-objects-grid-columns: 5; - } - - @media (min-width: variables.$g-bp4) { - --c-objects-grid-columns: 6; + --c-objects-grid-columns: 4; + --c-objects-grid-row-height: 32rem; } &__title { @@ -33,6 +31,7 @@ &__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; @@ -43,7 +42,8 @@ display: flex; min-width: 0; - // Fixed positions are shown as a landscape tile that occupies 2 standard positions. + // 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; } @@ -53,6 +53,7 @@ display: flex; flex-direction: column; width: 100%; + height: 100%; min-width: 0; text-decoration: none; color: inherit; @@ -70,13 +71,10 @@ overflow: hidden; background-color: colors.$color-gray-100; - // Standard position: portrait 9:16. - aspect-ratio: 9 / 16; - - .c-block-objects-grid__tile--fixed & { - // Fixed position: landscape 16:9. - aspect-ratio: 16 / 9; - } + // 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; From 07897c35b5d56bcb9446d5b4e610415a4b798c50 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Sat, 18 Jul 2026 00:13:27 +0200 Subject: [PATCH 3/9] feat(ARC-3792): improve mobile view https://meemoo.atlassian.net/browse/ARC-3792 --- .gitignore | 2 + .../BlockObjectsGrid/BlockObjectsGrid.scss | 76 ++++++++++++------- .../BlockObjectsGrid/BlockObjectsGrid.tsx | 27 +++++-- 3 files changed, 72 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index 54881ef68..f3782db44 100644 --- a/.gitignore +++ b/.gitignore @@ -255,3 +255,5 @@ schema.graphql /.husky api/.claude/ + +CLAUDE.md 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 index d658c713b..0f705336d 100644 --- 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 @@ -20,7 +20,7 @@ --c-objects-grid-row-height: 32rem; } - &__title { + .c-block-objects-grid__title { color: colors.$color-gray-1000; font-family: SofiaPro, sans-serif; font-size: 2.4rem; @@ -28,7 +28,7 @@ margin-bottom: 2.2rem; } - &__grid { + .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); @@ -38,7 +38,7 @@ padding: 0; } - &__tile { + .c-block-objects-grid__tile { display: flex; min-width: 0; @@ -49,7 +49,7 @@ } } - &__tile-link { + .c-block-objects-grid__tile-link { display: flex; flex-direction: column; width: 100%; @@ -65,7 +65,7 @@ } } - &__tile-media { + .c-block-objects-grid__tile-media { position: relative; width: 100%; overflow: hidden; @@ -93,7 +93,7 @@ } } - &__tile-image { + .c-block-objects-grid__tile-image { width: 100%; height: 100%; object-fit: cover; @@ -102,7 +102,7 @@ transition: transform 0.5s ease-out; } - &__tile-placeholder { + .c-block-objects-grid__tile-placeholder { display: flex; align-items: center; justify-content: center; @@ -114,56 +114,80 @@ // Desktop-only zoom-in on hover (see FA). Pointer devices only, never affects keyboard focus. @media (hover: hover) { - &__tile-link:hover .c-block-objects-grid__tile-image { + .c-block-objects-grid__tile-link:hover .c-block-objects-grid__tile-image { transform: scale(1.1); } } - &__tile-titlebar { + .c-block-objects-grid__tile-titlebar { display: flex; - align-items: center; + 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: 0.8rem 1.2rem; + padding: 1.6rem; } - &__tile-type-icon { + .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; - font-size: 1.6rem; + 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; + } } - &__tile-title { - flex: 1 1 auto; - min-width: 0; + .c-block-objects-grid__tile-title { font-family: SofiaPro, sans-serif; font-size: 1.4rem; - font-weight: 700; + font-weight: 800; + line-height: 2rem; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + + @media (min-width: variables.$g-bp2) { + font-size: 1.6rem; + } } - &__tile-maintainer { - flex-shrink: 0; - font-size: 1.2rem; + .c-block-objects-grid__tile-maintainer { + font-size: 1.4rem; font-weight: 400; - max-width: 40%; + line-height: 2rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + + @media (min-width: variables.$g-bp2) { + font-size: 1.6rem; + } } - &__footer { + .c-block-objects-grid__footer { display: flex; justify-content: center; margin-top: 2.4rem; } - &__cta { + .c-block-objects-grid__cta { display: inline-flex; align-items: center; padding: 1.2rem 2.4rem; @@ -181,7 +205,7 @@ } // Visually hidden but available to screen readers (for the aria-live status messages). - &__sr-only { + .c-block-objects-grid__sr-only { position: absolute; width: 1px; height: 1px; 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 index e43d84e85..be82386e2 100644 --- 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 @@ -81,11 +81,13 @@ export const BlockObjectsGrid: FunctionComponent = ({ )}
    +
    + {item.name} + {item.maintainerName && ( + {item.maintainerName} + )} +
    {iconName && } - {item.name} - {item.maintainerName && ( - {item.maintainerName} - )}
    @@ -94,7 +96,19 @@ export const BlockObjectsGrid: FunctionComponent = ({ const fixedObjects = data?.fixedObjects ?? []; const objects = data?.objects ?? []; - const hasObjects = fixedObjects.length > 0 || objects.length > 0; + + // 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 hasObjects = orderedTiles.length > 0; return (
    = ({ {hasObjects && (
      - {fixedObjects.map((item) => renderTile(item, true))} - {objects.map((item) => renderTile(item, false))} + {orderedTiles.map(({ item, isFixed }) => renderTile(item, isFixed))}
    )} From b42cdd111ca83b1308663f3bd1c2d909b037bf78 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Mon, 20 Jul 2026 10:08:44 +0200 Subject: [PATCH 4/9] feat(ARC-3792): convert fe search url to be search api url via admin-core-config https://meemoo.atlassian.net/browse/ARC-3792 --- .../react-admin/core/config/config.types.ts | 20 ++ .../BlockObjectsGrid.search-filters.ts | 285 ------------------ .../BlockObjectsGrid.service.ts | 151 +--------- 3 files changed, 30 insertions(+), 426 deletions(-) delete mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.search-filters.ts diff --git a/ui/src/react-admin/core/config/config.types.ts b/ui/src/react-admin/core/config/config.types.ts index 0b00f6779..f90ecd733 100644 --- a/ui/src/react-admin/core/config/config.types.ts +++ b/ui/src/react-admin/core/config/config.types.ts @@ -56,6 +56,19 @@ 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; +} + export interface AdminConfig { // Core module configurations flowplayer: { @@ -88,6 +101,13 @@ export interface AdminConfig { queryCache: { clear: (key: string) => Promise; }; + // 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. + search?: { + searchUrlToApiUrl: (searchQuery: string, size: number) => IeObjectsSearchBody; + }; }; components: { loader: { diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.search-filters.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.search-filters.ts deleted file mode 100644 index 95f8d047f..000000000 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.search-filters.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { format, parseISO } from 'date-fns'; - -/** - * ie-objects search-API filter model + hetarchief `/zoeken` URL → filter translation. - * - * This is a faithful, UI-free PORT of the hetarchief-client logic (it cannot be imported: the - * client depends on `@meemoo/admin-core-ui`, not the other way around). Kept in sync with: - * - hetarchief-client `shared/types/ie-objects.ts` (IeObjectsSearchFilterField / …Operator) - * - hetarchief-client `visitor-space/utils/elastic-filters` (mapFiltersToElastic) - * - hetarchief-client `visitor-space/utils/map-filters` (mapAdvancedToElastic) - * - hetarchief-client `visitor-space/const/advanced-filters.consts` (FILTERS_OPTIONS_CONFIG) - * - hetarchief-client `visitor-space/const/advanced-filter-array-param` (url codec) - */ - -// Mirrors IeObjectsSearchFilterField. -export enum IeObjectsSearchFilterField { - RELEASE_DATE = 'releaseDate', - CREATED = 'created', - CREATOR = 'creator', - DESCRIPTION = 'description', - DURATION = 'duration', - SPACIAL_COVERAGE = 'spacialCoverage', - TEMPORAL_COVERAGE = 'temporalCoverage', - FORMAT = 'format', - GENRE = 'genre', - KEYWORD = 'keyword', - LANGUAGE = 'language', - MEDIUM = 'medium', - NAME = 'name', - PUBLISHED = 'published', - PUBLISHER = 'publisher', - NEWSPAPER_SERIES_NAME = 'newspaperSeriesName', - LOCATION_CREATED = 'locationCreated', - MENTIONS = 'mentions', - QUERY = 'query', - MAINTAINER_ID = 'maintainer', - CONSULTABLE_ONLY_ON_LOCATION = 'isConsultableOnlyOnLocation', - CONSULTABLE_MEDIA = 'isConsultableMedia', - CONSULTABLE_PUBLIC_DOMAIN = 'isConsultablePublicDomain', - REUSABILITY = 'reusability', - RIGHTS = 'rights', - CAST = 'cast', - IDENTIFIER = 'identifier', - OBJECT_TYPE = 'objectType', -} - -// Mirrors IeObjectsSearchOperator. -export enum IeObjectsSearchOperator { - CONTAINS = 'contains', - CONTAINS_NOT = 'containsNot', - GTE = 'gte', - IS = 'is', - IS_NOT = 'isNot', - LTE = 'lte', -} - -export interface SearchFilter { - field: IeObjectsSearchFilterField; - operator: IeObjectsSearchOperator; - value?: string; - multiValue?: string[]; -} - -// The client joins multi-value ranges with SEPARATOR and label-suffixes values with this delimiter. -const SEPARATOR = '--'; -export const FILTER_LABEL_VALUE_DELIMITER = '---'; - -// --- Advanced filter model (mirrors FilterProperty / Operator) -------------------------------- - -enum FilterProperty { - CAST = 'CAST', - CREATED_AT = 'CREATED_AT', - CREATOR = 'CREATOR', - DESCRIPTION = 'DESCRIPTION', - DURATION = 'DURATION', - GENRE = 'GENRE', - IDENTIFIER = 'IDENTIFIER', - KEYWORDS = 'KEYWORDS', - LANGUAGE = 'LANGUAGE', - MEDIA_TYPE = 'MEDIA_TYPE', - MEDIUM = 'MEDIUM', - OBJECT_TYPE = 'OBJECT_TYPE', - PUBLISHED_AT = 'PUBLISHED_AT', - PUBLISHER = 'PUBLISHER', - RELEASE_DATE = 'RELEASE_DATE', - RIGHTS = 'RIGHTS', - SPACIAL_COVERAGE = 'SPACIAL_COVERAGE', - TEMPORAL_COVERAGE = 'TEMPORAL_COVERAGE', - TITLE = 'TITLE', - NEWSPAPER_SERIES_NAME = 'NEWSPAPER_SERIES_NAME', - LOCATION_CREATED = 'LOCATION_CREATED', - MENTIONS = 'MENTIONS', -} - -enum Operator { - CONTAINS = 'CONTAINS', - CONTAINS_NOT = 'CONTAINS_NOT', - EQUALS = 'EQUALS', - EQUALS_NOT = 'EQUALS_NOT', - LESS_THAN_OR_EQUAL = 'LESS_THAN_OR_EQUAL', - GREATER_THAN_OR_EQUAL = 'GREATER_THAN_OR_EQUAL', - BETWEEN = 'BETWEEN', - EXACT = 'EXACT', -} - -interface AdvancedFilter { - prop: FilterProperty; - op: Operator; - val?: string; -} - -// 2-letter url acronyms (mirrors advanced-filter-array-param). -const FILTER_NAME_WITH_ACRONYM: [FilterProperty, string][] = [ - [FilterProperty.CAST, 'cs'], - [FilterProperty.CREATED_AT, 'ca'], - [FilterProperty.CREATOR, 'ct'], - [FilterProperty.DESCRIPTION, 'de'], - [FilterProperty.DURATION, 'du'], - [FilterProperty.GENRE, 'ge'], - [FilterProperty.IDENTIFIER, 'id'], - [FilterProperty.KEYWORDS, 'kw'], - [FilterProperty.LANGUAGE, 'la'], - [FilterProperty.MEDIA_TYPE, 'ty'], - [FilterProperty.MEDIUM, 'me'], - [FilterProperty.OBJECT_TYPE, 'ot'], - [FilterProperty.PUBLISHED_AT, 'pa'], - [FilterProperty.PUBLISHER, 'pu'], - [FilterProperty.RELEASE_DATE, 'rd'], - [FilterProperty.RIGHTS, 'ri'], - [FilterProperty.SPACIAL_COVERAGE, 'sc'], - [FilterProperty.TEMPORAL_COVERAGE, 'tc'], - [FilterProperty.TITLE, 'ti'], - [FilterProperty.NEWSPAPER_SERIES_NAME, 'ns'], - [FilterProperty.LOCATION_CREATED, 'lc'], - [FilterProperty.MENTIONS, 'mn'], -]; - -const FILTER_OPERATOR_WITH_ACRONYM: [Operator, string][] = [ - [Operator.CONTAINS, 'co'], - [Operator.CONTAINS_NOT, 'nc'], - [Operator.EQUALS, 'eq'], - [Operator.EQUALS_NOT, 'ne'], - [Operator.LESS_THAN_OR_EQUAL, 'lt'], - [Operator.GREATER_THAN_OR_EQUAL, 'gt'], - [Operator.BETWEEN, 'bt'], - [Operator.EXACT, 'ex'], -]; - -const acronymToFilterProperty = (acronym: string): FilterProperty | undefined => - FILTER_NAME_WITH_ACRONYM.find(([, acr]) => acr === acronym)?.[0]; - -const acronymToOperator = (acronym: string): Operator | undefined => - FILTER_OPERATOR_WITH_ACRONYM.find(([, acr]) => acr === acronym)?.[0]; - -/** - * Decode a single `advanced`/`duration`/`releaseDate` url param value into advanced filters. - * Mirrors AdvancedFilterArrayParam.decode (renderKey/uuid omitted — not needed server-side). - */ -const decodeAdvancedFilterParam = (stringified: string | null): AdvancedFilter[] => { - if (!stringified) { - return []; - } - return stringified - .split(',') - .map((filter): AdvancedFilter | null => { - const prop = acronymToFilterProperty(filter.slice(0, 2)); - const op = acronymToOperator(filter.slice(2, 4)); - if (!prop || !op) { - return null; - } - return { prop, op, val: decodeURIComponent(filter.slice(4)) }; - }) - .filter((filter): filter is AdvancedFilter => filter !== null); -}; - -// Data-only port of FILTERS_OPTIONS_CONFIG: (property, operator) → filter template(s). -const dateFilters = (field: IeObjectsSearchFilterField) => ({ - [Operator.GREATER_THAN_OR_EQUAL]: [{ field, operator: IeObjectsSearchOperator.GTE }], - [Operator.LESS_THAN_OR_EQUAL]: [{ field, operator: IeObjectsSearchOperator.LTE }], - [Operator.BETWEEN]: [ - { field, operator: IeObjectsSearchOperator.GTE }, - { field, operator: IeObjectsSearchOperator.LTE }, - ], - [Operator.EQUALS]: [ - { field, operator: IeObjectsSearchOperator.GTE }, - { field, operator: IeObjectsSearchOperator.LTE }, - ], -}); - -const containsAndEqualsFilters = (field: IeObjectsSearchFilterField) => ({ - [Operator.CONTAINS]: [{ field, operator: IeObjectsSearchOperator.CONTAINS }], - [Operator.CONTAINS_NOT]: [{ field, operator: IeObjectsSearchOperator.CONTAINS_NOT }], - [Operator.EQUALS]: [{ field, operator: IeObjectsSearchOperator.IS }], - [Operator.EQUALS_NOT]: [{ field, operator: IeObjectsSearchOperator.IS_NOT }], -}); - -const equalsFilters = (field: IeObjectsSearchFilterField) => ({ - [Operator.EQUALS]: [{ field, operator: IeObjectsSearchOperator.IS }], - [Operator.EQUALS_NOT]: [{ field, operator: IeObjectsSearchOperator.IS_NOT }], -}); - -const containsFilters = (field: IeObjectsSearchFilterField) => ({ - [Operator.CONTAINS]: [{ field, operator: IeObjectsSearchOperator.CONTAINS }], - [Operator.CONTAINS_NOT]: [{ field, operator: IeObjectsSearchOperator.CONTAINS_NOT }], -}); - -const FILTERS_OPTIONS_CONFIG: Partial< - Record>> -> = { - [FilterProperty.RELEASE_DATE]: dateFilters(IeObjectsSearchFilterField.RELEASE_DATE), - [FilterProperty.CREATED_AT]: dateFilters(IeObjectsSearchFilterField.CREATED), - [FilterProperty.PUBLISHED_AT]: dateFilters(IeObjectsSearchFilterField.PUBLISHED), - [FilterProperty.DURATION]: { - [Operator.GREATER_THAN_OR_EQUAL]: [ - { field: IeObjectsSearchFilterField.DURATION, operator: IeObjectsSearchOperator.GTE }, - ], - [Operator.LESS_THAN_OR_EQUAL]: [ - { field: IeObjectsSearchFilterField.DURATION, operator: IeObjectsSearchOperator.LTE }, - ], - }, - [FilterProperty.DESCRIPTION]: containsFilters(IeObjectsSearchFilterField.DESCRIPTION), - [FilterProperty.GENRE]: equalsFilters(IeObjectsSearchFilterField.GENRE), - [FilterProperty.LANGUAGE]: equalsFilters(IeObjectsSearchFilterField.LANGUAGE), - [FilterProperty.RIGHTS]: equalsFilters(IeObjectsSearchFilterField.RIGHTS), - [FilterProperty.MEDIUM]: equalsFilters(IeObjectsSearchFilterField.MEDIUM), - [FilterProperty.SPACIAL_COVERAGE]: containsAndEqualsFilters( - IeObjectsSearchFilterField.SPACIAL_COVERAGE - ), - [FilterProperty.TEMPORAL_COVERAGE]: containsAndEqualsFilters( - IeObjectsSearchFilterField.TEMPORAL_COVERAGE - ), - [FilterProperty.OBJECT_TYPE]: containsAndEqualsFilters(IeObjectsSearchFilterField.OBJECT_TYPE), - [FilterProperty.PUBLISHER]: containsAndEqualsFilters(IeObjectsSearchFilterField.PUBLISHER), - [FilterProperty.TITLE]: containsAndEqualsFilters(IeObjectsSearchFilterField.NAME), - [FilterProperty.CAST]: containsAndEqualsFilters(IeObjectsSearchFilterField.CAST), - [FilterProperty.IDENTIFIER]: equalsFilters(IeObjectsSearchFilterField.IDENTIFIER), - [FilterProperty.KEYWORDS]: containsAndEqualsFilters(IeObjectsSearchFilterField.KEYWORD), - [FilterProperty.CREATOR]: containsAndEqualsFilters(IeObjectsSearchFilterField.CREATOR), - [FilterProperty.LOCATION_CREATED]: containsAndEqualsFilters( - IeObjectsSearchFilterField.LOCATION_CREATED - ), - [FilterProperty.NEWSPAPER_SERIES_NAME]: containsAndEqualsFilters( - IeObjectsSearchFilterField.NEWSPAPER_SERIES_NAME - ), - [FilterProperty.MENTIONS]: containsAndEqualsFilters(IeObjectsSearchFilterField.MENTIONS), -}; - -const getMetadataSearchFilters = (prop: FilterProperty, operator: Operator): SearchFilter[] => - FILTERS_OPTIONS_CONFIG[prop]?.[operator] || []; - -/** Faithful port of mapAdvancedToElastic: turns one advanced filter into ES filter(s). */ -const mapAdvancedToElastic = (item: AdvancedFilter): SearchFilter[] => { - const values = (item.val || '').split(SEPARATOR); - const filters = item.prop && item.op ? getMetadataSearchFilters(item.prop, item.op) : []; - - return filters.map((filter, i): SearchFilter => { - switch (item.prop) { - case FilterProperty.CREATED_AT: - case FilterProperty.PUBLISHED_AT: - case FilterProperty.RELEASE_DATE: { - if (item.op === Operator.EQUALS && values.length === 1) { - // Manually create a range of equal values: ARC-3191 - values[i] = values[0]; - } - const parsed = parseISO(values[i]); - values[i] = (parsed && format(parsed, 'yyyy-MM-dd')) || values[i]; - break; - } - case FilterProperty.DURATION: - // Add milliseconds since elasticsearch requires it: ARC-2549 - values[i] = `${values[0]}.00`; - break; - default: - break; - } - return { ...filter, value: values[i] }; - }); -}; - -/** - * Convert one hetarchief `/zoeken` url param value (comma-separated, acronym-encoded advanced - * filters — used by `advanced`, `duration`, `releaseDate`) into ES search filters. - */ -export const mapAdvancedFilterParamToElastic = (paramValue: string | null): SearchFilter[] => - decodeAdvancedFilterParam(paramValue).flatMap(mapAdvancedToElastic); 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 index 2499e659a..767c718b3 100644 --- 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 @@ -1,15 +1,10 @@ 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 { - FILTER_LABEL_VALUE_DELIMITER, - IeObjectsSearchFilterField, - IeObjectsSearchOperator, - mapAdvancedFilterParamToElastic, - type SearchFilter, -} from './BlockObjectsGrid.search-filters'; import { type ObjectsGridItem, ObjectsGridItemType } from './BlockObjectsGrid.types'; /** @@ -19,12 +14,6 @@ import { type ObjectsGridItem, ObjectsGridItemType } from './BlockObjectsGrid.ty */ export const DEFAULT_OBJECTS_GRID_LIMIT = 24; -interface IeObjectsSearchBody { - filters: SearchFilter[]; - size: number; - page: number; -} - /** * 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. @@ -64,143 +53,23 @@ const mapRawToGridItem = (raw: RawIeObject): ObjectsGridItem => ({ thumbnailUrl: raw.thumbnailUrl, }); -const stripLabel = (value: string): string => value.split(FILTER_LABEL_VALUE_DELIMITER)[0]; - /** * Convert a raw hetarchief.be `/zoeken` search URL into the ie-objects search body. * - * Mirrors the client-side `mapFiltersToElastic` (hetarchief-client visitor-space/utils/elastic-filters): - * each search-page url param is translated to the matching API filter field/operator. The advanced - * range params (`duration`, `releaseDate`, `advanced`) are decoded via the ported advanced-filter - * logic in `BlockObjectsGrid.search-filters.ts`. + * 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`. */ export const parseSearchQueryToSearchBody = ( searchQuery: string, size: number ): IeObjectsSearchBody => { - const filters: SearchFilter[] = []; - // The API pagination is 1-based (default 1), same as the hetarchief search url. - let page = 1; - - const add = (filter: SearchFilter) => { - if (filter.value || filter.multiValue?.length) { - filters.push(filter); - } - }; - - try { - const params = new URL(searchQuery).searchParams; - const single = (key: string) => params.get(key) || ''; - const multi = (key: string) => params.getAll(key).filter(Boolean); - - const pageParam = Number(params.get('page')); - if (Number.isFinite(pageParam) && pageParam > 0) { - page = pageParam; - } - - // Free-text search terms (searchbar) → query / contains. - for (const term of multi('zoekterm')) { - add({ - field: IeObjectsSearchFilterField.QUERY, - operator: IeObjectsSearchOperator.CONTAINS, - value: term, - }); - } - - // Media type tab → format / is ("all" means no filter). - const format = single('format'); - if (format && format !== 'all') { - add({ - field: IeObjectsSearchFilterField.FORMAT, - operator: IeObjectsSearchOperator.IS, - value: format, - }); - } - - add({ - field: IeObjectsSearchFilterField.MEDIUM, - operator: IeObjectsSearchOperator.IS, - multiValue: multi('medium'), - }); - add({ - field: IeObjectsSearchFilterField.CREATOR, - operator: IeObjectsSearchOperator.CONTAINS, - value: single('creator'), - }); - add({ - field: IeObjectsSearchFilterField.NEWSPAPER_SERIES_NAME, - operator: IeObjectsSearchOperator.IS, - value: single('newspaperSeriesName'), - }); - add({ - field: IeObjectsSearchFilterField.LOCATION_CREATED, - operator: IeObjectsSearchOperator.IS, - value: single('locationCreated'), - }); - add({ - field: IeObjectsSearchFilterField.MENTIONS, - operator: IeObjectsSearchOperator.IS, - value: single('mentions'), - }); - add({ - field: IeObjectsSearchFilterField.GENRE, - operator: IeObjectsSearchOperator.IS, - multiValue: multi('genre'), - }); - add({ - field: IeObjectsSearchFilterField.KEYWORD, - operator: IeObjectsSearchOperator.IS, - multiValue: multi('keywords'), - }); - add({ - field: IeObjectsSearchFilterField.LANGUAGE, - operator: IeObjectsSearchOperator.IS, - multiValue: multi('language').map(stripLabel), - }); - // `aanbieders` (maintainers) → maintainer / is, values encoded as `OR-id---label`. - add({ - field: IeObjectsSearchFilterField.MAINTAINER_ID, - operator: IeObjectsSearchOperator.IS, - multiValue: multi('aanbieders').map(stripLabel), - }); - add({ - field: IeObjectsSearchFilterField.REUSABILITY, - operator: IeObjectsSearchOperator.IS, - multiValue: multi('herbruikbaarheid').map(stripLabel), - }); - if (single('onLocation')) { - add({ - field: IeObjectsSearchFilterField.CONSULTABLE_ONLY_ON_LOCATION, - operator: IeObjectsSearchOperator.IS, - value: 'true', - }); - } - if (single('media')) { - add({ - field: IeObjectsSearchFilterField.CONSULTABLE_MEDIA, - operator: IeObjectsSearchOperator.IS, - value: 'true', - }); - } - if (single('publicDomain')) { - add({ - field: IeObjectsSearchFilterField.CONSULTABLE_PUBLIC_DOMAIN, - operator: IeObjectsSearchOperator.IS, - value: 'true', - }); - } - - // Advanced range filters (duration / releaseDate / free-form advanced), acronym-encoded in the url. - for (const key of ['duration', 'releaseDate', 'advanced']) { - for (const filter of mapAdvancedFilterParamToElastic(params.get(key))) { - add(filter); - } - } - } catch { - // Invalid URL (already blocked by the editor validator) → return an empty search. + const searchUrlToApiUrl = AdminConfigManager.getConfig().services.search?.searchUrlToApiUrl; + if (!searchUrlToApiUrl) { + return { filters: [], size, page: 1 }; } - - return { filters, size, page }; + return searchUrlToApiUrl(searchQuery, size); }; const searchIeObjects = async (body: IeObjectsSearchBody): Promise => { From f5396269ee7c09f1c62d33f611fde8e5c05b8f73 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Mon, 20 Jul 2026 10:09:10 +0200 Subject: [PATCH 5/9] fix(ARC-3792): strip domain from search url for "show more" button https://meemoo.atlassian.net/browse/ARC-3792 --- .../blocks/BlockObjectsGrid/BlockObjectsGrid.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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 index be82386e2..cffac445e 100644 --- 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 @@ -21,6 +21,18 @@ const OBJECT_DETAIL_PATH_PREFIX = '/pid'; 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; + } +}; + type IconNameType = ComponentProps['name']; // Object type → icon name (icons are registered in the admin-core icon config). Types without a @@ -164,7 +176,7 @@ export const BlockObjectsGrid: FunctionComponent = ({ Date: Mon, 20 Jul 2026 10:21:06 +0200 Subject: [PATCH 6/9] feat(ARC-3792): rename config option to clientSearchUrlToApiSearchUrl https://meemoo.atlassian.net/browse/ARC-3792 --- ui/src/react-admin/core/config/config.types.ts | 10 +++++----- .../BlockObjectsGrid/BlockObjectsGrid.service.ts | 7 ++++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/ui/src/react-admin/core/config/config.types.ts b/ui/src/react-admin/core/config/config.types.ts index f90ecd733..0b911b458 100644 --- a/ui/src/react-admin/core/config/config.types.ts +++ b/ui/src/react-admin/core/config/config.types.ts @@ -101,12 +101,12 @@ export interface AdminConfig { queryCache: { clear: (key: string) => Promise; }; - // 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. search?: { - searchUrlToApiUrl: (searchQuery: string, size: number) => IeObjectsSearchBody; + // 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, size: number) => IeObjectsSearchBody; }; }; components: { 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 index 767c718b3..b8ab2716e 100644 --- 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 @@ -65,11 +65,12 @@ export const parseSearchQueryToSearchBody = ( searchQuery: string, size: number ): IeObjectsSearchBody => { - const searchUrlToApiUrl = AdminConfigManager.getConfig().services.search?.searchUrlToApiUrl; - if (!searchUrlToApiUrl) { + const clientSearchUrlToApiSearchUrl = + AdminConfigManager.getConfig().services.search?.clientSearchUrlToApiSearchUrl; + if (!clientSearchUrlToApiSearchUrl) { return { filters: [], size, page: 1 }; } - return searchUrlToApiUrl(searchQuery, size); + return clientSearchUrlToApiSearchUrl(searchQuery, size); }; const searchIeObjects = async (body: IeObjectsSearchBody): Promise => { From 7e1011c5fad39f460a82d2d22ed93f705a7b05f8 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Mon, 20 Jul 2026 10:53:21 +0200 Subject: [PATCH 7/9] feat(ARC-3792): set random order for object grid items request https://meemoo.atlassian.net/browse/ARC-3792 --- ui/src/react-admin/core/config/config.types.ts | 6 ++++-- .../BlockObjectsGrid/BlockObjectsGrid.service.ts | 12 ++++++++---- .../BlockObjectsGrid/BlockObjectsGrid.types.ts | 9 +++++++++ 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/ui/src/react-admin/core/config/config.types.ts b/ui/src/react-admin/core/config/config.types.ts index 0b911b458..3bb0a8fcc 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'; @@ -67,6 +66,9 @@ export interface IeObjectsSearchBody { filters: SearchFilter[]; size: number; page: number; + requestedAggs?: string[]; + orderProp?: OrderProperty; + orderDirection?: 'asc' | 'desc'; } export interface AdminConfig { 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 index b8ab2716e..3b849afc6 100644 --- 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 @@ -5,7 +5,7 @@ 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 } from './BlockObjectsGrid.types'; +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 @@ -60,6 +60,8 @@ const mapRawToGridItem = (raw: RawIeObject): ObjectsGridItem => ({ * 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, @@ -121,11 +123,13 @@ export const getObjectsGridItems = async ( limit = DEFAULT_OBJECTS_GRID_LIMIT ): Promise => { try { + const apiSearchQueryParams = parseSearchQueryToSearchBody(searchQuery, limit); + // Grid needs to show random items, except for the fixed items + apiSearchQueryParams.orderProp = OrderProperty.RANDOM; + const [fixedObjects, objects] = await Promise.all([ getFixedObjects(fixedItems), - searchQuery - ? searchIeObjects(parseSearchQueryToSearchBody(searchQuery, limit)) - : Promise.resolve([]), + searchQuery ? searchIeObjects(apiSearchQueryParams) : Promise.resolve([]), ]); // Don't show the same object twice if it is both pinned and returned by the query. 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 index 0f4e69503..e279cd387 100644 --- 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 @@ -1,3 +1,4 @@ +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'; @@ -54,3 +55,11 @@ export interface BlockObjectsGridProps extends DefaultComponentProps { // 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', +} From b47b24fcca488cc9666bd9c5fb5b872099dbd663 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Mon, 20 Jul 2026 11:22:30 +0200 Subject: [PATCH 8/9] fix(ARC-3792): fetch correct number of objects for desktop grid view https://meemoo.atlassian.net/browse/ARC-3792 --- ui/src/react-admin/core/config/config.types.ts | 2 +- .../BlockObjectsGrid/BlockObjectsGrid.service.ts | 12 +++++------- .../blocks/BlockObjectsGrid/BlockObjectsGrid.tsx | 11 ++++++++++- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/ui/src/react-admin/core/config/config.types.ts b/ui/src/react-admin/core/config/config.types.ts index 3bb0a8fcc..1838028c4 100644 --- a/ui/src/react-admin/core/config/config.types.ts +++ b/ui/src/react-admin/core/config/config.types.ts @@ -108,7 +108,7 @@ export interface AdminConfig { // 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, size: number) => IeObjectsSearchBody; + clientSearchUrlToApiSearchUrl: (searchQuery: string) => IeObjectsSearchBody; }; }; components: { 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 index 3b849afc6..4696b48d9 100644 --- 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 @@ -63,16 +63,13 @@ const mapRawToGridItem = (raw: RawIeObject): ObjectsGridItem => ({ * * 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, - size: number -): IeObjectsSearchBody => { +export const parseSearchQueryToSearchBody = (searchQuery: string): IeObjectsSearchBody => { const clientSearchUrlToApiSearchUrl = AdminConfigManager.getConfig().services.search?.clientSearchUrlToApiSearchUrl; if (!clientSearchUrlToApiSearchUrl) { - return { filters: [], size, page: 1 }; + return { filters: [], size: 20, page: 1 }; } - return clientSearchUrlToApiSearchUrl(searchQuery, size); + return clientSearchUrlToApiSearchUrl(searchQuery); }; const searchIeObjects = async (body: IeObjectsSearchBody): Promise => { @@ -123,9 +120,10 @@ export const getObjectsGridItems = async ( limit = DEFAULT_OBJECTS_GRID_LIMIT ): Promise => { try { - const apiSearchQueryParams = parseSearchQueryToSearchBody(searchQuery, limit); + 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), 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 index cffac445e..2242665b8 100644 --- 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 @@ -18,6 +18,10 @@ import { useGetObjectsGridItems } from './hooks/useGetObjectsGridItems'; // 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; + const getObjectDetailPath = (schemaIdentifier: string): string => `${OBJECT_DETAIL_PATH_PREFIX}/${encodeURIComponent(schemaIdentifier)}`; @@ -52,7 +56,12 @@ export const BlockObjectsGrid: FunctionComponent = ({ backgroundColor, }): ReactElement => { const fixedItems = elements.map((element) => element.mediaItem).filter((item) => item?.value); - const { data, isLoading, isError } = useGetObjectsGridItems(searchQuery, fixedItems); + // 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 + ); const renderTile = (item: ObjectsGridItem, isFixed: boolean): ReactElement => { const iconName = item.type ? TYPE_ICON_NAME[item.type] : undefined; From 610eb9576ca7a4379059bcc9290e40f3232006b3 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Mon, 20 Jul 2026 12:02:37 +0200 Subject: [PATCH 9/9] fix(ARC-3792): style newspaper pages at the top, max 60% https://meemoo.atlassian.net/browse/ARC-3792 --- .../BlockObjectsGrid/BlockObjectsGrid.scss | 38 +++++- .../BlockObjectsGrid/BlockObjectsGrid.tsx | 123 ++++++++++++++---- 2 files changed, 131 insertions(+), 30 deletions(-) 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 index 0f705336d..8b392b089 100644 --- 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 @@ -91,15 +91,39 @@ 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; + } + } } - .c-block-objects-grid__tile-image { - width: 100%; - height: 100%; - object-fit: cover; - object-position: center; - display: block; - transition: transform 0.5s ease-out; + // 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 { 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 index 2242665b8..e2a565c83 100644 --- 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 @@ -1,6 +1,7 @@ import { AvoCoreContentPickerType } from '@viaa/avo2-types'; import clsx from 'clsx'; -import type { ComponentProps, FunctionComponent, ReactElement } from 'react'; +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'; @@ -22,20 +23,9 @@ const OBJECT_DETAIL_PATH_PREFIX = '/pid'; // https://meemoo.atlassian.net/wiki/spaces/HA2/pages/6217171023/FA+Objecten+grid#Gedrag-van-het-contentblok const OBJECT_GRID_MAX_ITEMS = 16; -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; - } -}; +// Column-count breakpoints, mirrored from BlockObjectsGrid.scss ($g-bp2/$g-bp3). +const BREAKPOINT_TABLET = 700; +const BREAKPOINT_DESKTOP = 900; type IconNameType = ComponentProps['name']; @@ -54,7 +44,7 @@ export const BlockObjectsGrid: FunctionComponent = ({ searchQuery, elements = [], backgroundColor, -}): ReactElement => { +}): 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( @@ -63,9 +53,92 @@ export const BlockObjectsGrid: FunctionComponent = ({ 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; - const isAudio = item.type === ObjectsGridItemType.Audio; return (
  • = ({ )} >
    {item.thumbnailUrl ? ( - + ) : ( // No thumbnail (e.g. audio): decorative placeholder, the link already carries the name.