From bce479e3d1e682148b498d9fc8f6d3b9efce5cf1 Mon Sep 17 00:00:00 2001 From: msutya Date: Wed, 22 Jul 2026 11:11:47 +0200 Subject: [PATCH] QREPO-325 make item page fields and search result labels configurable in qulto theme --- config/config.example.yml | 179 ++++++++++++++++++ .../dynamic/item-page-config.util.spec.ts | 17 ++ .../dynamic/item-page-config.util.ts | 12 ++ .../item-page-field-container.registry.ts | 39 ++++ .../item-page-field-list.component.html | 3 + .../dynamic/item-page-field-list.component.ts | 68 +++++++ .../dynamic/item-page-field.config.ts | 64 +++++++ .../publication/publication.component.html | 113 ++--------- .../publication/publication.component.ts | 49 ++--- ...-search-result-list-element.component.html | 92 +++++++++ ...em-search-result-list-element.component.ts | 61 ++++++ .../item-search-result-list-element.config.ts | 64 +++++++ ...ch-result-list-element.config.util.spec.ts | 43 +++++ ...-search-result-list-element.config.util.ts | 60 ++++++ src/themes/qulto/lazy-listable-components.ts | 2 + .../untyped-item/untyped-item.component.html | 84 +------- .../untyped-item/untyped-item.component.ts | 43 ++--- 17 files changed, 768 insertions(+), 225 deletions(-) create mode 100644 src/themes/qulto/app/item-page/simple/field-components/dynamic/item-page-config.util.spec.ts create mode 100644 src/themes/qulto/app/item-page/simple/field-components/dynamic/item-page-config.util.ts create mode 100644 src/themes/qulto/app/item-page/simple/field-components/dynamic/item-page-field-container.registry.ts create mode 100644 src/themes/qulto/app/item-page/simple/field-components/dynamic/item-page-field-list.component.html create mode 100644 src/themes/qulto/app/item-page/simple/field-components/dynamic/item-page-field-list.component.ts create mode 100644 src/themes/qulto/app/item-page/simple/field-components/dynamic/item-page-field.config.ts create mode 100644 src/themes/qulto/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html create mode 100644 src/themes/qulto/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.ts create mode 100644 src/themes/qulto/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.config.ts create mode 100644 src/themes/qulto/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.config.util.spec.ts create mode 100644 src/themes/qulto/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.config.util.ts diff --git a/config/config.example.yml b/config/config.example.yml index 6f6c67d1ae6..53b4cbe3cd9 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -805,3 +805,182 @@ addToAnyPlugin: showPlusButton: true showCounters: true title: DSpace demo + +# Config-driven item-page field layout (see resolveItemPageLayout / ItemPageFieldListComponent +# under src/themes/qulto/app/item-page/simple/field-components/dynamic/). Keyed by entity type +# (e.g. `Publication`, or `Item` for untyped items) — root-level rather than per-theme, since a +# deployment only ever runs one active theme. Currently only the untyped-item ("Item") page +# consumes this; Publication pages are not migrated yet and still render their field list from HTML. +itemPage: + Item: + leftSide: + #- container: date + - container: metadata-representation-list + itemType: Person + fields: [ dc.contributor.author, dc.creator ] + label: item.page.authors + - container: generic + fields: [ dc.description.version ] + label: item.page.version + - container: generic + fields: [ journal.title ] + label: item.page.journal-title + - container: generic + fields: [ journal.identifier.issn ] + label: item.page.journal-issn + - container: generic + fields: [ journalvolume.identifier.name ] + label: item.page.volume-title + - container: generic + fields: [ dc.publisher ] + label: item.page.publisher + - container: doi + rightSide: + - container: abstract + - container: generic + fields: [ dc.publisher ] + label: item.page.publisher + - container: generic + fields: [ dc.description ] + label: item.page.description + - container: generic + fields: [ dc.subject ] + separator: ', ' + label: item.page.subject + - container: generic + fields: [ dc.identifier.citation ] + label: item.page.citation + - container: geospatial + label: item.page.places + pointFields: [ dcterms.spatial ] + bboxFields: [ dcterms.spatial ] + cluster: true + - container: uri + fields: [ dc.identifier.uri ] + label: item.page.uri + - container: generic + fields: [ dc.description.version ] + label: item.page.version + - container: collections + - container: uri + fields: [ coar.notify.endorsedBy ] + label: item.page.endorsement + - container: uri + fields: [ datacite.relation.isReviewedBy ] + label: item.page.review + - container: uri + fields: [ datacite.relation.isSupplementedBy ] + label: item.page.supplemented + - container: uri + fields: [ datacite.relation.isReferencedBy ] + label: item.page.referenced + - container: cc-license + variant: full + + Publication: + leftSide: + #- container: date + - container: metadata-representation-list + itemType: Person + fields: [ dc.contributor.author, dc.creator ] + label: relationships.isAuthorOf + - container: generic + fields: [ journal.title ] + label: publication.page.journal-title + - container: generic + fields: [ journal.identifier.issn ] + label: publication.page.journal-issn + - container: generic + fields: [ journalvolume.identifier.name ] + label: publication.page.volume-title + - container: generic + fields: [ dc.publisher ] + label: publication.page.publisher + - container: doi + rightSide: + - container: related-items + relationType: isProjectOfPublication + label: item.page.projects + - container: related-items + relationType: isOrgUnitOfPublication + label: item.page.org-units + - container: related-items + relationType: isJournalIssueOfPublication + label: item.page.journal-issue + - container: related-items + relationType: isPartOf + label: relationships.isPartOf + - container: abstract + - container: generic + fields: [ dc.publisher ] + label: item.page.publisher + - container: generic + fields: [ dc.description ] + label: publication.page.description + - container: generic + fields: [ dc.subject ] + separator: ', ' + label: item.page.subject + - container: generic + fields: [ dc.identifier.citation ] + label: item.page.citation + - container: uri + fields: [ dc.identifier.uri ] + label: item.page.uri + - container: collections + - container: uri + fields: [ coar.notify.endorsedBy ] + label: item.page.endorsement + - container: uri + fields: [ datacite.relation.isReviewedBy ] + label: item.page.review + - container: uri + fields: [ datacite.relation.isSupplementedBy ] + label: item.page.supplemented + - container: uri + fields: [ datacite.relation.isReferencedBy ] + label: item.page.referenced + - container: license + - container: geospatial + label: item.page.places + pointFields: [ dcterms.spatial ] + bboxFields: [ dcterms.spatial ] + cluster: true + # fullWidth is disabled by default (this mirrors what used to be a commented-out block in + # publication.component.html) — uncomment to show the isPartOf tabbed related-entities + # search below the two columns: + # fullWidth: + # - container: tabbed-related-entities-search + # relationTypes: + # - { label: isPartOf, filter: isPartOf, configuration: default-relationships } + +# Config-driven fields for the search-result list element's subtitle line (see +# resolveItemSearchResultListElementConfig under src/themes/qulto/app/shared/object-list/ +# search-result-list-element/item-search-result/item-types/item/). Applies to both Publication +# and generic Item search results — one shared layout, not per entity type. `authors.cssClass`/ +# `authors.separator` only control styling; WHICH fields count as "author" is still driven by +# the existing `searchResult.authorMetadata` app config (default-app-config.ts; +# not overridden in this file today). +itemSearchResultListElement: + group: + prefix: '(' + suffix: ')' + separator: ', ' + fields: + - fields: [ dc.publisher ] + cssClass: item-list-publisher + - fields: [ dc.date.issued ] + cssClass: item-list-date + # Freestanding fields — same {fields, cssClass} shape as `group`, but NOT wrapped in + # parens/joined with publisher+date. Use this to add a new field without touching `group`. + inlineFields: + fields: + - fields: [ dc.description.version ] + cssClass: item-list-version + authors: + cssClass: item-list-authors + separator: '; ' + abstract: + fields: [ dc.description.abstract ] + cssClass: item-list-abstract + minLines: 3 diff --git a/src/themes/qulto/app/item-page/simple/field-components/dynamic/item-page-config.util.spec.ts b/src/themes/qulto/app/item-page/simple/field-components/dynamic/item-page-config.util.spec.ts new file mode 100644 index 00000000000..99858deeaac --- /dev/null +++ b/src/themes/qulto/app/item-page/simple/field-components/dynamic/item-page-config.util.spec.ts @@ -0,0 +1,17 @@ +import { resolveItemPageLayout } from './item-page-config.util'; + +describe('resolveItemPageLayout', () => { + it('returns an empty layout when itemPage is not configured', () => { + expect(resolveItemPageLayout({}, 'Publication')).toEqual({}); + }); + + it('returns an empty layout when the entity type is not configured', () => { + const config = { itemPage: { Item: { leftSide: [{ container: 'date' }] } } }; + expect(resolveItemPageLayout(config, 'Publication')).toEqual({}); + }); + + it('returns the configured layout for the entity type', () => { + const layout = { leftSide: [{ container: 'date' }], rightSide: [{ container: 'abstract' }] }; + expect(resolveItemPageLayout({ itemPage: { Item: layout } }, 'Item')).toEqual(layout); + }); +}); diff --git a/src/themes/qulto/app/item-page/simple/field-components/dynamic/item-page-config.util.ts b/src/themes/qulto/app/item-page/simple/field-components/dynamic/item-page-config.util.ts new file mode 100644 index 00000000000..776142f1a0e --- /dev/null +++ b/src/themes/qulto/app/item-page/simple/field-components/dynamic/item-page-config.util.ts @@ -0,0 +1,12 @@ +import { AppConfig } from '@dspace/config/app-config.interface'; + +import { ItemPageLayoutConfig } from './item-page-field.config'; + +/** + * Look up the item-page field layout for one entity type from the root-level `itemPage` + * config key. A deployment only ever runs one active theme, so there is no per-theme + * nesting or `extends` chain to resolve here — just a direct config lookup. + */ +export function resolveItemPageLayout(config: Pick, entityType: string): ItemPageLayoutConfig { + return config.itemPage?.[entityType] ?? {}; +} diff --git a/src/themes/qulto/app/item-page/simple/field-components/dynamic/item-page-field-container.registry.ts b/src/themes/qulto/app/item-page/simple/field-components/dynamic/item-page-field-container.registry.ts new file mode 100644 index 00000000000..340af143e40 --- /dev/null +++ b/src/themes/qulto/app/item-page/simple/field-components/dynamic/item-page-field-container.registry.ts @@ -0,0 +1,39 @@ +import { Type } from '@angular/core'; + +import { CollectionsComponent } from '../../../../../../../app/item-page/field-components/collections/collections.component'; +import { ItemPageAbstractFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component'; +import { ItemPageCcLicenseFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/cc-license/item-page-cc-license-field.component'; +import { ItemPageDateFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/date/item-page-date-field.component'; +import { GenericItemPageFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/generic/generic-item-page-field.component'; +import { GeospatialItemPageFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/geospatial/geospatial-item-page-field.component'; +import { ItemPageLicenseFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/license/item-page-license-field.component'; +import { ItemPageUriFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component'; +import { ThemedMetadataRepresentationListComponent } from '../../../../../../../app/item-page/simple/metadata-representation-list/themed-metadata-representation-list.component'; +import { TabbedRelatedEntitiesSearchComponent } from '../../../../../../../app/item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component'; +import { RelatedItemsComponent } from '../../../../../../../app/item-page/simple/related-items/related-items-component'; +import { ItemPageDoiFieldComponent } from '../specific-field/doi/item-page-doi-field.component'; + +/** + * Maps an `ItemPageFieldConfig.container` key (from `config.yml`) to the component it renders + * and the `@Input()` name the item/parent-item is bound to — most containers take `item`, but + * the two relation-list containers take `parentItem`. + */ +export interface ItemPageFieldContainerDef { + component: Type; + itemInputName: 'item' | 'parentItem'; +} + +export const ITEM_PAGE_FIELD_CONTAINER_REGISTRY: Record = { + 'generic': { component: GenericItemPageFieldComponent, itemInputName: 'item' }, + 'uri': { component: ItemPageUriFieldComponent, itemInputName: 'item' }, + 'abstract': { component: ItemPageAbstractFieldComponent, itemInputName: 'item' }, + 'date': { component: ItemPageDateFieldComponent, itemInputName: 'item' }, + 'license': { component: ItemPageLicenseFieldComponent, itemInputName: 'item' }, + 'cc-license': { component: ItemPageCcLicenseFieldComponent, itemInputName: 'item' }, + 'doi': { component: ItemPageDoiFieldComponent, itemInputName: 'item' }, + 'geospatial': { component: GeospatialItemPageFieldComponent, itemInputName: 'item' }, + 'collections': { component: CollectionsComponent, itemInputName: 'item' }, + 'metadata-representation-list': { component: ThemedMetadataRepresentationListComponent, itemInputName: 'parentItem' }, + 'related-items': { component: RelatedItemsComponent, itemInputName: 'parentItem' }, + 'tabbed-related-entities-search': { component: TabbedRelatedEntitiesSearchComponent, itemInputName: 'item' }, +}; diff --git a/src/themes/qulto/app/item-page/simple/field-components/dynamic/item-page-field-list.component.html b/src/themes/qulto/app/item-page/simple/field-components/dynamic/item-page-field-list.component.html new file mode 100644 index 00000000000..d56cea92dd7 --- /dev/null +++ b/src/themes/qulto/app/item-page/simple/field-components/dynamic/item-page-field-list.component.html @@ -0,0 +1,3 @@ +@for (cfg of visibleConfigs; track $index) { + +} diff --git a/src/themes/qulto/app/item-page/simple/field-components/dynamic/item-page-field-list.component.ts b/src/themes/qulto/app/item-page/simple/field-components/dynamic/item-page-field-list.component.ts new file mode 100644 index 00000000000..2c7cf0e7047 --- /dev/null +++ b/src/themes/qulto/app/item-page/simple/field-components/dynamic/item-page-field-list.component.ts @@ -0,0 +1,68 @@ +import { NgComponentOutlet } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + Input, + Type, +} from '@angular/core'; +import { Item } from '@dspace/core/shared/item.model'; + +import { ItemPageFieldConfig } from './item-page-field.config'; +import { ITEM_PAGE_FIELD_CONTAINER_REGISTRY } from './item-page-field-container.registry'; + +/** + * Renders an ordered list of item-page metadata/relation fields declared in `config.yml` + * (`ItemPageFieldConfig[]`, see `resolveItemPageLayout`). Each entry's `container` key is + * resolved to a component via `ITEM_PAGE_FIELD_CONTAINER_REGISTRY`; every other property on + * the entry is passed through as that component's input. + */ +@Component({ + selector: 'ds-item-page-field-list', + templateUrl: './item-page-field-list.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + NgComponentOutlet, + ], +}) +export class ItemPageFieldListComponent { + @Input() item: Item; + + @Input() configs: ItemPageFieldConfig[] = []; + + /** + * Gate for the `geospatial` container. The base item-type components already compute + * `geospatialItemPageFieldsEnabled` from `environment.geospatialMapViewer`; callers pass + * that through here instead of this component reading `environment` itself, so it stays + * a plain config-driven renderer. + */ + @Input() geospatialEnabled = true; + + get visibleConfigs(): ItemPageFieldConfig[] { + return this.configs.filter((cfg) => + !!ITEM_PAGE_FIELD_CONTAINER_REGISTRY[cfg.container] && + (cfg.container !== 'geospatial' || this.geospatialEnabled), + ); + } + + componentFor(cfg: ItemPageFieldConfig): Type { + return ITEM_PAGE_FIELD_CONTAINER_REGISTRY[cfg.container].component; + } + + /** + * Only forwards explicitly-set config properties: passing `undefined` through + * `ngComponentOutletInputs` would overwrite the target component's own default value + * (e.g. `separator`) instead of leaving it alone. + */ + inputsFor(cfg: ItemPageFieldConfig): Record { + const { container, ...rest } = cfg; + const inputs: Record = { + [ITEM_PAGE_FIELD_CONTAINER_REGISTRY[container].itemInputName]: this.item, + }; + for (const [key, value] of Object.entries(rest)) { + if (value !== undefined) { + inputs[key] = value; + } + } + return inputs; + } +} diff --git a/src/themes/qulto/app/item-page/simple/field-components/dynamic/item-page-field.config.ts b/src/themes/qulto/app/item-page/simple/field-components/dynamic/item-page-field.config.ts new file mode 100644 index 00000000000..42c66c88abe --- /dev/null +++ b/src/themes/qulto/app/item-page/simple/field-components/dynamic/item-page-field.config.ts @@ -0,0 +1,64 @@ +import { Config } from '@dspace/config/config.interface'; + +/** + * A single field/relation rendered on an item page, resolved to one of the container + * components registered in `ITEM_PAGE_FIELD_CONTAINER_REGISTRY`. Properties beyond + * `container` are passed straight through as that component's `@Input()`s, so which + * ones apply depends on the chosen container (e.g. `fields` for `generic`/`uri`, + * `relationType` for `related-items`, `pointFields`/`bboxFields`/`cluster` for `geospatial`). + */ +export interface ItemPageFieldConfig extends Config { + container: string; + label?: string; + fields?: string[]; + separator?: string; + urlRegex?: string; + enableMarkdown?: boolean; + itemType?: string; + metadataFields?: string[]; + incrementBy?: number; + relationType?: string; + relationTypes?: { label: string; filter: string; configuration?: string }[]; + variant?: 'small' | 'full'; + ccLicenseUriField?: string; + ccLicenseNameField?: string; + showName?: boolean; + showDisclaimer?: boolean; + pointFields?: string[]; + bboxFields?: string[]; + cluster?: boolean; + searchEnabled?: boolean; + sideBarWidth?: number; +} + +/** + * Ordered field lists for one entity type's item page. `leftSide`/`rightSide` map to the + * two metadata columns; `fullWidth` renders below both (e.g. kjk's tabbed related-entities + * search). + */ +export interface ItemPageLayoutConfig extends Config { + leftSide?: ItemPageFieldConfig[]; + rightSide?: ItemPageFieldConfig[]; + fullWidth?: ItemPageFieldConfig[]; +} + +/** + * Root `config.yml` `itemPage` key, keyed by entity type (e.g. `Publication`, or `Item` for + * untyped items). Declared once at the top level rather than per-theme: a single deployment + * only ever runs one active theme, so there is no need to namespace this by theme name. + */ +export interface ItemPageConfig extends Config { + [entityType: string]: ItemPageLayoutConfig; +} + +/** + * Augments the core `AppConfig` (rather than editing `src/config/app-config.interface.ts` + * directly) so this config-driven item-page feature — and its types — stay self-contained + * under the qulto theme. TypeScript declaration merging picks this up because + * `tsconfig.app.json`/`tsconfig.spec.json` unconditionally include every `.ts` file under `src/themes`. + */ +declare module '@dspace/config/app-config.interface' { + interface AppConfig { + itemPage?: ItemPageConfig; + } +} diff --git a/src/themes/qulto/app/item-page/simple/item-types/publication/publication.component.html b/src/themes/qulto/app/item-page/simple/item-types/publication/publication.component.html index 05e4e647da5..6d55fee7ed9 100644 --- a/src/themes/qulto/app/item-page/simple/item-types/publication/publication.component.html +++ b/src/themes/qulto/app/item-page/simple/item-types/publication/publication.component.html @@ -32,115 +32,28 @@ @if (showDownloadLinkAsAttachment !== true) { } - - - - - - - - - - - - - + +
@if (showDownloadLinkAsAttachment) { } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @if (geospatialItemPageFieldsEnabled) { - - - } + +
- + @if (fullWidthFields.length > 0) { +
+ +
+ } diff --git a/src/themes/qulto/app/item-page/simple/item-types/publication/publication.component.ts b/src/themes/qulto/app/item-page/simple/item-types/publication/publication.component.ts index dd98063932e..9167ee5acb7 100644 --- a/src/themes/qulto/app/item-page/simple/item-types/publication/publication.component.ts +++ b/src/themes/qulto/app/item-page/simple/item-types/publication/publication.component.ts @@ -8,36 +8,27 @@ import { TranslateModule } from '@ngx-translate/core'; import { Context } from '../../../../../../../app/core/shared/context.model'; import { ViewMode } from '../../../../../../../app/core/shared/view-mode.model'; -import { CollectionsComponent } from '../../../../../../../app/item-page/field-components/collections/collections.component'; import { ThemedMediaViewerComponent } from '../../../../../../../app/item-page/media-viewer/themed-media-viewer.component'; import { MiradorViewerComponent } from '../../../../../../../app/item-page/mirador-viewer/mirador-viewer.component'; import { ThemedFileSectionComponent } from '../../../../../../../app/item-page/simple/field-components/file-section/themed-file-section.component'; -import { ItemPageAbstractFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component'; -import { ItemPageDateFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/date/item-page-date-field.component'; -import { GenericItemPageFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/generic/generic-item-page-field.component'; -import { GeospatialItemPageFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/geospatial/geospatial-item-page-field.component'; -import { ItemPageLicenseFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/license/item-page-license-field.component'; -import { ItemPageUriFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component'; import { ItemComponent } from '../../../../../../../app/item-page/simple/item-types/shared/item.component'; -import { ThemedMetadataRepresentationListComponent } from '../../../../../../../app/item-page/simple/metadata-representation-list/themed-metadata-representation-list.component'; -import { TabbedRelatedEntitiesSearchComponent } from '../../../../../../../app/item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component'; -import { RelatedItemsComponent } from '../../../../../../../app/item-page/simple/related-items/related-items-component'; import { AttachmentSectionComponent } from '../../../../../../../app/shared/bitstream-attachment/section/attachment-section.component'; import { DsoEditMenuComponent } from '../../../../../../../app/shared/dso-page/dso-edit-menu/dso-edit-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../../../../app/shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { listableObjectComponent } from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; import { ThemedResultsBackButtonComponent } from '../../../../../../../app/shared/results-back-button/themed-results-back-button.component'; import { ThemedThumbnailComponent } from '../../../../../../../app/thumbnail/themed-thumbnail.component'; -import { ItemPageDoiFieldComponent } from '../../field-components/specific-field/doi/item-page-doi-field.component'; +import { environment } from '../../../../../../../environments/environment'; +import { resolveItemPageLayout } from '../../field-components/dynamic/item-page-config.util'; +import { ItemPageFieldConfig } from '../../field-components/dynamic/item-page-field.config'; +import { ItemPageFieldListComponent } from '../../field-components/dynamic/item-page-field-list.component'; import { ThemedItemPageTitleFieldComponent } from '../../field-components/specific-field/title/themed-item-page-field.component'; /** - * Qulto publication item page — synced with DS10 base publication template; adds: - * - Smart DOI field (bare `10.xxx` identifiers normalised to `https://doi.org/…`) - * - `ds-item-page-license-field` (generic licence display) - * - geospatial field guard (config-driven) - * - `showDownloadLinkAsAttachment` / attachment section support - * - `isPartOf` related-items block (in addition to the base DS10 relations) + * Qulto publication item page — the left/right column field list is config-driven via + * `config.yml`'s root-level `itemPage.Publication` key (see resolveItemPageLayout / + * ds-item-page-field-list). Only the chrome (title, thumbnail/media-viewer, file-section, + * edit menu, full-page link) is still hardcoded in the template below. */ @listableObjectComponent('Publication', ViewMode.StandalonePage, Context.Any, 'qulto') @Component({ @@ -48,29 +39,31 @@ import { ThemedItemPageTitleFieldComponent } from '../../field-components/specif imports: [ AsyncPipe, AttachmentSectionComponent, - CollectionsComponent, DsoEditMenuComponent, - GenericItemPageFieldComponent, - GeospatialItemPageFieldComponent, - ItemPageAbstractFieldComponent, - ItemPageDateFieldComponent, - ItemPageDoiFieldComponent, - ItemPageLicenseFieldComponent, - ItemPageUriFieldComponent, + ItemPageFieldListComponent, MetadataFieldWrapperComponent, MiradorViewerComponent, - RelatedItemsComponent, RouterLink, - TabbedRelatedEntitiesSearchComponent, ThemedFileSectionComponent, ThemedItemPageTitleFieldComponent, ThemedMediaViewerComponent, - ThemedMetadataRepresentationListComponent, ThemedResultsBackButtonComponent, ThemedThumbnailComponent, TranslateModule, ], }) export class PublicationComponent extends ItemComponent { + leftSideFields: ItemPageFieldConfig[] = []; + rightSideFields: ItemPageFieldConfig[] = []; + + fullWidthFields: ItemPageFieldConfig[] = []; + + override ngOnInit(): void { + super.ngOnInit(); + const layout = resolveItemPageLayout(environment, 'Publication'); + this.leftSideFields = layout.leftSide ?? []; + this.rightSideFields = layout.rightSide ?? []; + this.fullWidthFields = layout.fullWidth ?? []; + } } diff --git a/src/themes/qulto/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html b/src/themes/qulto/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html new file mode 100644 index 00000000000..41253ca3c09 --- /dev/null +++ b/src/themes/qulto/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html @@ -0,0 +1,92 @@ +
+ @if (showThumbnails) { +
+ @if (linkType !== linkTypes.None) { + + + + + } + @if (linkType === linkTypes.None) { + + + + + } +
+ } +
+
+ @if (showLabel) { + + } +
+ + @if (object !== undefined && object !== null) { + + @if (linkType !== linkTypes.None) { + + } + @if (linkType === linkTypes.None) { + + } + + + @if (getPresentFields(config.group).length > 0) { + {{ config.group.prefix }}@for (f of getPresentFields(config.group); track f; let last = $last) { + + @if (!last) { + {{ config.group.separator }} + } + }{{ config.group.suffix }} + } + @if (dso.allMetadataValues(authorMetadata, placeholderFilter).length > 0) { + + @let collapsed = isCollapsed() | async; + + @if (collapsed) { + @for (author of dso.limitedMetadata(authorMetadata, additionalMetadataLimit, placeholderFilter); track author; let last = $last) { + + @if (!last) { + {{ config.authors.separator }} + } + } + } + @if (!collapsed) { + @for (author of dso.allMetadata(authorMetadata, placeholderFilter); track author; let last = $last) { + + + @if (!last) { + {{ config.authors.separator }} + } + + } + } + + + } + @if (getPresentFields(config.inlineFields).length > 0) { + {{ config.inlineFields.prefix }}@for (f of getPresentFields(config.inlineFields); track f; let last = $last) { + + @if (!last) { + {{ config.inlineFields.separator }} + } + }{{ config.inlineFields.suffix }} + } + + + @if (firstMetadataValue(config.abstract.fields); as abstract) { +
+ + + +
+ } +
+ } +
+
diff --git a/src/themes/qulto/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.ts b/src/themes/qulto/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.ts new file mode 100644 index 00000000000..c481966657b --- /dev/null +++ b/src/themes/qulto/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.ts @@ -0,0 +1,61 @@ +import { + AsyncPipe, + NgClass, +} from '@angular/common'; +import { Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { Context } from '@dspace/core/shared/context.model'; +import { ItemSearchResult } from '@dspace/core/shared/object-collection/item-search-result.model'; +import { ViewMode } from '@dspace/core/shared/view-mode.model'; + +import { MetadataDirective } from '../../../../../../../../../app/shared/metadata.directive'; +import { MetadataLinkViewComponent } from '../../../../../../../../../app/shared/metadata-link-view/metadata-link-view.component'; +import { ThemedBadgesComponent } from '../../../../../../../../../app/shared/object-collection/shared/badges/themed-badges.component'; +import { listableObjectComponent } from '../../../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; +import { ItemSearchResultListElementComponent as BaseComponent } from '../../../../../../../../../app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component'; +import { TruncatableComponent } from '../../../../../../../../../app/shared/truncatable/truncatable.component'; +import { TruncatablePartComponent } from '../../../../../../../../../app/shared/truncatable/truncatable-part/truncatable-part.component'; +import { ThemedThumbnailComponent } from '../../../../../../../../../app/thumbnail/themed-thumbnail.component'; +import { environment } from '../../../../../../../../../environments/environment'; +import { + SearchResultGroupConfig, + SearchResultInlineFieldConfig, +} from './item-search-result-list-element.config'; +import { resolveItemSearchResultListElementConfig } from './item-search-result-list-element.config.util'; + +/** + * Qulto search-result list element — the publisher/date group, authors block styling, and + * abstract field are config-driven via `config.yml`'s root-level `itemSearchResultListElement` + * key (see resolveItemSearchResultListElementConfig). Chrome (thumbnail, badges, title, + * truncation) is unchanged from the base template. + */ +@listableObjectComponent('PublicationSearchResult', ViewMode.ListElement, Context.Any, 'qulto') +@listableObjectComponent(ItemSearchResult, ViewMode.ListElement, Context.Any, 'qulto') +@Component({ + selector: 'ds-item-search-result-list-element', + styleUrls: ['../../../../../../../../../app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.scss'], + templateUrl: './item-search-result-list-element.component.html', + imports: [ + AsyncPipe, + MetadataDirective, + MetadataLinkViewComponent, + NgClass, + RouterLink, + ThemedBadgesComponent, + ThemedThumbnailComponent, + TruncatableComponent, + TruncatablePartComponent, + ], +}) +export class ItemSearchResultListElementComponent extends BaseComponent { + readonly config = resolveItemSearchResultListElementConfig(environment); + + /** + * Fields that actually have a value for the current item, for either `group` or + * `inlineFields` — both share the same "only show what's present, joined by separator" + * shape, they only differ in their default prefix/suffix. + */ + getPresentFields(fieldsConfig: SearchResultGroupConfig): SearchResultInlineFieldConfig[] { + return fieldsConfig.fields.filter((f) => !!this.firstMetadataValue(f.fields)); + } +} diff --git a/src/themes/qulto/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.config.ts b/src/themes/qulto/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.config.ts new file mode 100644 index 00000000000..4fe86278db5 --- /dev/null +++ b/src/themes/qulto/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.config.ts @@ -0,0 +1,64 @@ +import { Config } from '@dspace/config/config.interface'; + +/** + * A single inline metadata field rendered as `` + * within the search-result list element's subtitle line. + */ +export interface SearchResultInlineFieldConfig extends Config { + fields: string[]; + cssClass?: string; +} + +/** + * An ordered group of inline fields (e.g. publisher + date). Only fields that actually have + * a value are rendered, joined by `separator`, and the whole group is wrapped in + * `prefix`/`suffix` whenever at least one field is present. + */ +export interface SearchResultGroupConfig extends Config { + fields: SearchResultInlineFieldConfig[]; + separator?: string; + prefix?: string; + suffix?: string; +} + +/** + * Styling for the authors block. Which metadata fields count as "author" is controlled by + * the existing `searchResult.authorMetadata` config — this only covers presentation. + */ +export interface SearchResultAuthorsConfig extends Config { + cssClass?: string; + separator?: string; +} + +export interface SearchResultAbstractConfig extends SearchResultInlineFieldConfig { + minLines?: number; +} + +/** + * Root `config.yml` `itemSearchResultListElement` key. Applies to every entity type this + * component renders (Publication and generic Item search results share one layout) — + * unlike the item-page field lists, there is no per-entity-type keying here. + */ +export interface ItemSearchResultListElementConfig extends Config { + group?: SearchResultGroupConfig; + /** + * Freestanding inline fields, rendered the same way as `group` (same shape/logic — only + * present fields show, joined by `separator`) but without `group`'s parenthesised styling + * by default. Use this to add a new field to the subtitle line without folding it into the + * publisher/date-style `group` cluster. + */ + inlineFields?: SearchResultGroupConfig; + authors?: SearchResultAuthorsConfig; + abstract?: SearchResultAbstractConfig; +} + +/** + * Augments the core `AppConfig` from within the qulto theme (rather than editing + * `src/config/app-config.interface.ts` directly) — `tsconfig.app.json`/`tsconfig.spec.json` + * unconditionally include every `.ts` file under `src/themes`, so this is always picked up. + */ +declare module '@dspace/config/app-config.interface' { + interface AppConfig { + itemSearchResultListElement?: ItemSearchResultListElementConfig; + } +} diff --git a/src/themes/qulto/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.config.util.spec.ts b/src/themes/qulto/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.config.util.spec.ts new file mode 100644 index 00000000000..7149eac5a74 --- /dev/null +++ b/src/themes/qulto/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.config.util.spec.ts @@ -0,0 +1,43 @@ +import { resolveItemSearchResultListElementConfig } from './item-search-result-list-element.config.util'; + +describe('resolveItemSearchResultListElementConfig', () => { + it('falls back to the default publisher/date/authors/abstract layout when unconfigured', () => { + const resolved = resolveItemSearchResultListElementConfig({}); + expect(resolved.group.fields).toEqual([ + { fields: ['dc.publisher'], cssClass: 'item-list-publisher' }, + { fields: ['dc.date.issued'], cssClass: 'item-list-date' }, + ]); + expect(resolved.inlineFields).toEqual({ fields: [], separator: ' ', prefix: ' ', suffix: '' }); + expect(resolved.authors).toEqual({ cssClass: 'item-list-authors', separator: '; ' }); + expect(resolved.abstract).toEqual({ + fields: ['dc.description.abstract'], + cssClass: 'item-list-abstract', + minLines: 3, + }); + }); + + it('uses the configured group/inlineFields/authors/abstract when set', () => { + const group = { fields: [{ fields: ['dc.type'], cssClass: 'item-list-type' }] }; + const inlineFields = { fields: [{ fields: ['dc.description.version'], cssClass: 'item-list-version' }] }; + const authors = { cssClass: 'my-authors', separator: ', ' }; + const abstract = { fields: ['dc.description'], cssClass: 'my-abstract', minLines: 5 }; + const resolved = resolveItemSearchResultListElementConfig({ + itemSearchResultListElement: { group, inlineFields, authors, abstract }, + }); + expect(resolved.group).toBe(group); + expect(resolved.inlineFields).toBe(inlineFields); + expect(resolved.authors).toBe(authors); + expect(resolved.abstract).toBe(abstract); + }); + + it('resolves each of group/inlineFields/authors/abstract independently when only one is configured', () => { + const authors = { cssClass: 'my-authors', separator: ', ' }; + const resolved = resolveItemSearchResultListElementConfig({ + itemSearchResultListElement: { authors }, + }); + expect(resolved.authors).toBe(authors); + expect(resolved.group.prefix).toBe('('); + expect(resolved.inlineFields.fields).toEqual([]); + expect(resolved.abstract.cssClass).toBe('item-list-abstract'); + }); +}); diff --git a/src/themes/qulto/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.config.util.ts b/src/themes/qulto/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.config.util.ts new file mode 100644 index 00000000000..4d37641bbc0 --- /dev/null +++ b/src/themes/qulto/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.config.util.ts @@ -0,0 +1,60 @@ +import { AppConfig } from '@dspace/config/app-config.interface'; + +import { + SearchResultAbstractConfig, + SearchResultAuthorsConfig, + SearchResultGroupConfig, +} from './item-search-result-list-element.config'; + +const DEFAULT_GROUP: SearchResultGroupConfig = { + fields: [ + { fields: ['dc.publisher'], cssClass: 'item-list-publisher' }, + { fields: ['dc.date.issued'], cssClass: 'item-list-date' }, + ], + separator: ', ', + prefix: '(', + suffix: ')', +}; + +// Empty by default — this is a purely additive slot, nothing was hardcoded here before. +// The leading space in `prefix` separates it from the group/authors content that precedes it. +const DEFAULT_INLINE_FIELDS: SearchResultGroupConfig = { + fields: [], + separator: ' ', + prefix: ' ', + suffix: '', +}; + +const DEFAULT_AUTHORS: SearchResultAuthorsConfig = { + cssClass: 'item-list-authors', + separator: '; ', +}; + +const DEFAULT_ABSTRACT: SearchResultAbstractConfig = { + fields: ['dc.description.abstract'], + cssClass: 'item-list-abstract', + minLines: 3, +}; + +export interface ResolvedItemSearchResultListElementConfig { + group: SearchResultGroupConfig; + inlineFields: SearchResultGroupConfig; + authors: SearchResultAuthorsConfig; + abstract: SearchResultAbstractConfig; +} + +/** + * Resolve the item-search-result-list-element field config, falling back to the layout the + * base DSpace template previously hardcoded (publisher/date group, authors, abstract) for + * whichever parts `config.yml`'s `itemSearchResultListElement` key doesn't set. + */ +export function resolveItemSearchResultListElementConfig( + config: Pick, +): ResolvedItemSearchResultListElementConfig { + return { + group: config.itemSearchResultListElement?.group ?? DEFAULT_GROUP, + inlineFields: config.itemSearchResultListElement?.inlineFields ?? DEFAULT_INLINE_FIELDS, + authors: config.itemSearchResultListElement?.authors ?? DEFAULT_AUTHORS, + abstract: config.itemSearchResultListElement?.abstract ?? DEFAULT_ABSTRACT, + }; +} diff --git a/src/themes/qulto/lazy-listable-components.ts b/src/themes/qulto/lazy-listable-components.ts index efe24af86a0..4077c5f005b 100644 --- a/src/themes/qulto/lazy-listable-components.ts +++ b/src/themes/qulto/lazy-listable-components.ts @@ -1,5 +1,6 @@ import { PublicationComponent } from './app/item-page/simple/item-types/publication/publication.component'; import { CommunityListElementComponent } from './app/shared/object-list/community-list-element/community-list-element.component'; +import { ItemSearchResultListElementComponent } from './app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component'; /** * Add components that use the @listableObjectComponent decorator here. @@ -7,5 +8,6 @@ import { CommunityListElementComponent } from './app/shared/object-list/communit */ export const LISTABLE_COMPONENTS = [ CommunityListElementComponent, + ItemSearchResultListElementComponent, PublicationComponent, ]; diff --git a/src/themes/szerep/app/item-page/simple/item-types/untyped-item/untyped-item.component.html b/src/themes/szerep/app/item-page/simple/item-types/untyped-item/untyped-item.component.html index 3f8f2458aa3..1e042849dd8 100644 --- a/src/themes/szerep/app/item-page/simple/item-types/untyped-item/untyped-item.component.html +++ b/src/themes/szerep/app/item-page/simple/item-types/untyped-item/untyped-item.component.html @@ -33,87 +33,21 @@ @if (showDownloadLinkAsAttachment !== true) { } - - - - - - - - - - - - - - + +
@if (showDownloadLinkAsAttachment) { } - - - - - - - - - @if (geospatialItemPageFieldsEnabled) { - - - } - - - - - - - - - - - - - - + + diff --git a/src/themes/szerep/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts b/src/themes/szerep/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts index 68af5cad6f7..4a7a7c4b1cd 100644 --- a/src/themes/szerep/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts +++ b/src/themes/szerep/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts @@ -9,31 +9,27 @@ import { Item } from '@dspace/core/shared/item.model'; import { ViewMode } from '@dspace/core/shared/view-mode.model'; import { TranslateModule } from '@ngx-translate/core'; -import { CollectionsComponent } from '../../../../../../../app/item-page/field-components/collections/collections.component'; import { ThemedMediaViewerComponent } from '../../../../../../../app/item-page/media-viewer/themed-media-viewer.component'; import { MiradorViewerComponent } from '../../../../../../../app/item-page/mirador-viewer/mirador-viewer.component'; import { ThemedFileSectionComponent } from '../../../../../../../app/item-page/simple/field-components/file-section/themed-file-section.component'; -import { ItemPageAbstractFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component'; -import { ItemPageCcLicenseFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/cc-license/item-page-cc-license-field.component'; -import { ItemPageDateFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/date/item-page-date-field.component'; -import { GenericItemPageFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/generic/generic-item-page-field.component'; -import { GeospatialItemPageFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/geospatial/geospatial-item-page-field.component'; import { ThemedItemPageTitleFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/title/themed-item-page-field.component'; -import { ItemPageUriFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component'; import { UntypedItemComponent as BaseComponent } from '../../../../../../../app/item-page/simple/item-types/untyped-item/untyped-item.component'; -import { ThemedMetadataRepresentationListComponent } from '../../../../../../../app/item-page/simple/metadata-representation-list/themed-metadata-representation-list.component'; import { AttachmentSectionComponent } from '../../../../../../../app/shared/bitstream-attachment/section/attachment-section.component'; import { DsoEditMenuComponent } from '../../../../../../../app/shared/dso-page/dso-edit-menu/dso-edit-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../../../../app/shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { listableObjectComponent } from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; import { ThemedResultsBackButtonComponent } from '../../../../../../../app/shared/results-back-button/themed-results-back-button.component'; import { ThemedThumbnailComponent } from '../../../../../../../app/thumbnail/themed-thumbnail.component'; -import { ItemPageDoiFieldComponent } from '../../../../../../../themes/qulto/app/item-page/simple/field-components/specific-field/doi/item-page-doi-field.component'; +import { environment } from '../../../../../../../environments/environment'; +import { resolveItemPageLayout } from '../../../../../../qulto/app/item-page/simple/field-components/dynamic/item-page-config.util'; +import { ItemPageFieldConfig } from '../../../../../../qulto/app/item-page/simple/field-components/dynamic/item-page-field.config'; +import { ItemPageFieldListComponent } from '../../../../../../qulto/app/item-page/simple/field-components/dynamic/item-page-field-list.component'; /** - * SZEREP untyped item page — identical to the base, but renders the Creative - * Commons license field (ds-item-page-cc-license-field, 'full' variant) instead - * of the generic license field. + * SZEREP untyped item page — the left/right column field list is config-driven via + * `config.yml`'s root-level `itemPage.Item` key (see resolveItemPageLayout / ds-item-page-field-list). + * Only the chrome (title, thumbnail/media-viewer, file-section, edit menu, full-page link) + * is still hardcoded in the template below. */ @listableObjectComponent(Item, ViewMode.StandalonePage, Context.Any, 'szerep') @Component({ @@ -44,25 +40,28 @@ import { ItemPageDoiFieldComponent } from '../../../../../../../themes/qulto/app imports: [ AsyncPipe, AttachmentSectionComponent, - CollectionsComponent, DsoEditMenuComponent, - GenericItemPageFieldComponent, - GeospatialItemPageFieldComponent, - ItemPageAbstractFieldComponent, - ItemPageCcLicenseFieldComponent, - ItemPageDateFieldComponent, - ItemPageDoiFieldComponent, - ItemPageUriFieldComponent, + ItemPageFieldListComponent, MetadataFieldWrapperComponent, MiradorViewerComponent, RouterLink, ThemedFileSectionComponent, ThemedItemPageTitleFieldComponent, ThemedMediaViewerComponent, - ThemedMetadataRepresentationListComponent, ThemedResultsBackButtonComponent, ThemedThumbnailComponent, TranslateModule, ], }) -export class UntypedItemComponent extends BaseComponent {} +export class UntypedItemComponent extends BaseComponent { + leftSideFields: ItemPageFieldConfig[] = []; + + rightSideFields: ItemPageFieldConfig[] = []; + + override ngOnInit(): void { + super.ngOnInit(); + const layout = resolveItemPageLayout(environment, 'Item'); + this.leftSideFields = layout.leftSide ?? []; + this.rightSideFields = layout.rightSide ?? []; + } +}