From e9d036727955d7af8ef219fef87f4a8cb0310e37 Mon Sep 17 00:00:00 2001 From: FrancescoMolinaro Date: Fri, 17 Jul 2026 12:19:28 +0200 Subject: [PATCH 1/7] [DURACOM-508] port homepage configuration components and services --- src/app/core/data-services-map.ts | 2 + src/app/core/data/paginated-list.model.ts | 2 + src/app/core/data/section-data.service.ts | 56 ++++ src/app/core/layout/models/section.model.ts | 110 ++++++++ .../layout/models/section.resource-type.ts | 9 + src/app/core/provide-core.ts | 2 + .../services/internal-link.service.spec.ts | 74 +++++ .../core/services/internal-link.service.ts | 86 ++++++ src/app/core/shared/context.model.ts | 2 + .../models/search-filter-config.model.ts | 28 ++ src/app/home-page/home-page.component.html | 134 +++++++--- src/app/home-page/home-page.component.ts | 141 +++++++++- .../abstract-browse-elements.component.ts | 122 +++++++++ .../browse-most-elements.component.html | 13 + .../browse-most-elements.component.scss | 0 .../browse-most-elements.component.spec.ts | 108 ++++++++ .../browse-most-elements.component.ts | 80 ++++++ .../default-browse-elements.component.html | 27 ++ .../default-browse-elements.component.scss | 0 .../default-browse-elements.component.ts | 32 +++ ...hemed-default-browse-elements.component.ts | 51 ++++ .../themed-browse-most-elements.component.ts | 50 ++++ .../browse-section.component.html | 10 + .../browse-section.component.spec.ts | 74 +++++ .../browse-section.component.ts | 30 +++ .../themed-browse-section.component.ts | 37 +++ .../counters-section.component.html | 46 ++++ .../counters-section.component.scss | 13 + .../counters-section.component.spec.ts | 37 +++ .../counters-section.component.ts | 121 +++++++++ .../themed-counters-section.component.ts | 39 +++ .../facet-section.component.html | 19 ++ .../facet-section.component.spec.ts | 216 +++++++++++++++ .../facet-section/facet-section.component.ts | 114 ++++++++ .../themed-facet-section.component.ts | 37 +++ .../multi-column-top-section.component.html | 21 ++ ...multi-column-top-section.component.spec.ts | 29 ++ .../multi-column-top-section.component.ts | 83 ++++++ ...emed-multi-column-top-section.component.ts | 37 +++ .../search-section.component.html | 60 +++++ .../search-section.component.spec.ts | 253 ++++++++++++++++++ .../search-section.component.ts | 160 +++++++++++ .../themed-search-section.component.ts | 37 +++ .../text-section/text-section.component.html | 26 ++ .../text-section/text-section.component.scss | 14 + .../text-section.component.spec.ts | 72 +++++ .../text-section/text-section.component.ts | 51 ++++ .../themed-text-section.component.ts | 41 +++ .../themed-top-section.component.ts | 41 +++ .../top-section/top-section.component.html | 17 ++ .../top-section/top-section.component.spec.ts | 157 +++++++++++ .../top-section/top-section.component.ts | 61 +++++ .../search-form/search-form.component.ts | 13 +- .../themed-search-form.component.ts | 3 + .../search/search-configuration.service.ts | 48 +++- .../search-filters/search-filter.service.ts | 22 ++ src/app/shared/search/search.utils.ts | 25 ++ src/config/default-app-config.ts | 1 + src/config/homepage-config.interface.ts | 4 + src/config/layout-config.interfaces.ts | 3 + src/environments/environment.test.ts | 1 + .../app/home-page/home-page.component.ts | 12 + 62 files changed, 3171 insertions(+), 43 deletions(-) create mode 100644 src/app/core/data/section-data.service.ts create mode 100644 src/app/core/layout/models/section.model.ts create mode 100644 src/app/core/layout/models/section.resource-type.ts create mode 100644 src/app/core/services/internal-link.service.spec.ts create mode 100644 src/app/core/services/internal-link.service.ts create mode 100644 src/app/shared/browse-most-elements/abstract-browse-elements.component.ts create mode 100644 src/app/shared/browse-most-elements/browse-most-elements.component.html create mode 100644 src/app/shared/browse-most-elements/browse-most-elements.component.scss create mode 100644 src/app/shared/browse-most-elements/browse-most-elements.component.spec.ts create mode 100644 src/app/shared/browse-most-elements/browse-most-elements.component.ts create mode 100644 src/app/shared/browse-most-elements/default-browse-elements/default-browse-elements.component.html create mode 100644 src/app/shared/browse-most-elements/default-browse-elements/default-browse-elements.component.scss create mode 100644 src/app/shared/browse-most-elements/default-browse-elements/default-browse-elements.component.ts create mode 100644 src/app/shared/browse-most-elements/default-browse-elements/themed-default-browse-elements.component.ts create mode 100644 src/app/shared/browse-most-elements/themed-browse-most-elements.component.ts create mode 100644 src/app/shared/explore/section-component/browse-section/browse-section.component.html create mode 100644 src/app/shared/explore/section-component/browse-section/browse-section.component.spec.ts create mode 100644 src/app/shared/explore/section-component/browse-section/browse-section.component.ts create mode 100644 src/app/shared/explore/section-component/browse-section/themed-browse-section.component.ts create mode 100644 src/app/shared/explore/section-component/counters-section/counters-section.component.html create mode 100644 src/app/shared/explore/section-component/counters-section/counters-section.component.scss create mode 100644 src/app/shared/explore/section-component/counters-section/counters-section.component.spec.ts create mode 100644 src/app/shared/explore/section-component/counters-section/counters-section.component.ts create mode 100644 src/app/shared/explore/section-component/counters-section/themed-counters-section.component.ts create mode 100644 src/app/shared/explore/section-component/facet-section/facet-section.component.html create mode 100644 src/app/shared/explore/section-component/facet-section/facet-section.component.spec.ts create mode 100644 src/app/shared/explore/section-component/facet-section/facet-section.component.ts create mode 100644 src/app/shared/explore/section-component/facet-section/themed-facet-section.component.ts create mode 100644 src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.html create mode 100644 src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.spec.ts create mode 100644 src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.ts create mode 100644 src/app/shared/explore/section-component/multi-column-top-section/themed-multi-column-top-section.component.ts create mode 100644 src/app/shared/explore/section-component/search-section/search-section.component.html create mode 100644 src/app/shared/explore/section-component/search-section/search-section.component.spec.ts create mode 100644 src/app/shared/explore/section-component/search-section/search-section.component.ts create mode 100644 src/app/shared/explore/section-component/search-section/themed-search-section.component.ts create mode 100644 src/app/shared/explore/section-component/text-section/text-section.component.html create mode 100644 src/app/shared/explore/section-component/text-section/text-section.component.scss create mode 100644 src/app/shared/explore/section-component/text-section/text-section.component.spec.ts create mode 100644 src/app/shared/explore/section-component/text-section/text-section.component.ts create mode 100644 src/app/shared/explore/section-component/text-section/themed-text-section.component.ts create mode 100644 src/app/shared/explore/section-component/top-section/themed-top-section.component.ts create mode 100644 src/app/shared/explore/section-component/top-section/top-section.component.html create mode 100644 src/app/shared/explore/section-component/top-section/top-section.component.spec.ts create mode 100644 src/app/shared/explore/section-component/top-section/top-section.component.ts diff --git a/src/app/core/data-services-map.ts b/src/app/core/data-services-map.ts index ea7d3b51996..82fe7a4f490 100644 --- a/src/app/core/data-services-map.ts +++ b/src/app/core/data-services-map.ts @@ -16,6 +16,7 @@ import { GROUP } from './eperson/models/group.resource-type'; import { WORKFLOWITEM } from './eperson/models/workflowitem.resource-type'; import { WORKSPACEITEM } from './eperson/models/workspaceitem.resource-type'; import { FEEDBACK } from './feedback/models/feedback.resource-type'; +import { SECTION } from './layout/models/section.resource-type'; import { METADATA_FIELD } from './metadata/metadata-field.resource-type'; import { METADATA_SCHEMA } from './metadata/metadata-schema.resource-type'; import { QUALITY_ASSURANCE_EVENT_OBJECT } from './notifications/qa/models/quality-assurance-event-object.resource-type'; @@ -142,4 +143,5 @@ export const LAZY_DATA_SERVICES: LazyDataServicesMap = new Map([ [AUDIT.value, () => import('./data/audit-data.service').then(m => m.AuditDataService)], [EditItem.type.value, () => import('./submission/edititem-data.service').then(m => m.EditItemDataService)], [METADATA_SECURITY_TYPE.value, () => import('./submission/metadatasecurityconfig-data.service').then(m => m.MetadataSecurityConfigurationService)], + [SECTION.value, () => import('./data/section-data.service').then(m => m.SectionDataService)], ]); diff --git a/src/app/core/data/paginated-list.model.ts b/src/app/core/data/paginated-list.model.ts index 3e97cd5ab09..f7434912df7 100644 --- a/src/app/core/data/paginated-list.model.ts +++ b/src/app/core/data/paginated-list.model.ts @@ -103,6 +103,8 @@ export class PaginatedList extends CacheableObject { prev?: HALLink; next?: HALLink; last?: HALLink; + missing?: HALLink; + more?: HALLink; }; get elementsPerPage(): number { diff --git a/src/app/core/data/section-data.service.ts b/src/app/core/data/section-data.service.ts new file mode 100644 index 00000000000..2cf4fe138b1 --- /dev/null +++ b/src/app/core/data/section-data.service.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@angular/core'; +import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; +import { Observable } from 'rxjs'; + +import { DSONameService } from '../breadcrumbs/dso-name.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { Section } from '../layout/models/section.model'; +import { FollowLinkConfig } from '../shared/follow-link-config.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { FindAllData } from './base/find-all-data'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { SearchDataImpl } from './base/search-data'; +import { FindListOptions } from './find-list-options.model'; +import { PaginatedList } from './paginated-list.model'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; + +/** + * A service responsible for fetching data from the REST API on the sections endpoint. + */ +@Injectable({ providedIn: 'root' }) +export class SectionDataService extends IdentifiableDataService
{ + + protected linkPath = 'sections'; + private findAllData: FindAllData
; + private searchData: SearchDataImpl
; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected dsoNameService: DSONameService, + ) { + super('sections', requestService, rdbService, objectCache, halService); + + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + + /** + * Find all the configured sections. + */ + findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig
[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Find all the configured sections. + */ + findVisibleSections(): Observable>> { + return this.searchData.searchBy('visibleTopBarSections'); + } + +} diff --git a/src/app/core/layout/models/section.model.ts b/src/app/core/layout/models/section.model.ts new file mode 100644 index 00000000000..e5c3bf11177 --- /dev/null +++ b/src/app/core/layout/models/section.model.ts @@ -0,0 +1,110 @@ +import { + autoserialize, + deserialize, +} from 'cerialize'; + +import { typedObject } from '../../cache/builders/build-decorators'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { HALLink } from '../../shared/hal-link.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { SECTION } from './section.resource-type'; + +/** + * Describes a type of Section. + */ +@typedObject +export class Section extends CacheableObject { + static type = SECTION; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The identifier of this Section. + */ + @autoserialize + id: string; + + @autoserialize + componentRows: SectionComponent[][]; + + @autoserialize + nestedSections: Section[]; + + /** + * The {@link HALLink}s for this section + */ + @deserialize + _links: { + self: HALLink, + }; + +} + +export interface SectionComponent { + componentType: string; + style: string; +} + +export interface BrowseSection extends SectionComponent { + browseNames: string[]; + componentType: 'browse'; +} + +export interface TopSection extends SectionComponent { + discoveryConfigurationName: string; + sortField: string; + order: string; + titleKey: string; + componentType: 'top'; + numberOfItems: number; + showThumbnails: boolean; + template: TopSectionTemplateType; +} + +export interface SearchSection extends SectionComponent { + discoveryConfigurationName: string; + componentType: 'search'; + searchType: string; + initialStatements: number; + displayTitle: boolean; +} + +export interface FacetSection extends SectionComponent { + discoveryConfigurationName: string; + componentType: 'facet'; + facetsPerRow: number; +} + +export interface TextRowSection extends SectionComponent { + content: string; + contentType: string; + componentType: 'text-row'; +} + +export interface MultiColumnTopSection extends SectionComponent { + discoveryConfigurationName: string; + sortField: string; + order: string; + titleKey: string; + columnList: TopSectionColumn[]; + componentType: 'multi-column-top'; +} + +export interface TopSectionColumn { + style: string; + metadataField: string; + titleKey: string; +} + +/** + * Represents the type of template to use for the section + */ +export enum TopSectionTemplateType { + DEFAULT = 'default', // CRIS default template +} diff --git a/src/app/core/layout/models/section.resource-type.ts b/src/app/core/layout/models/section.resource-type.ts new file mode 100644 index 00000000000..bed97d5a9cd --- /dev/null +++ b/src/app/core/layout/models/section.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for Section + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const SECTION = new ResourceType('section'); diff --git a/src/app/core/provide-core.ts b/src/app/core/provide-core.ts index 2b83615c0c1..97acb2d846e 100644 --- a/src/app/core/provide-core.ts +++ b/src/app/core/provide-core.ts @@ -36,6 +36,7 @@ import { import { EPerson } from './eperson/models/eperson.model'; import { Group } from './eperson/models/group.model'; import { Feedback } from './feedback/models/feedback.model'; +import { Section } from './layout/models/section.model'; import { MetadataField } from './metadata/metadata-field.model'; import { MetadataSchema } from './metadata/metadata-schema.model'; import { QualityAssuranceEventObject } from './notifications/qa/models/quality-assurance-event.model'; @@ -232,4 +233,5 @@ export const models = CorrectionType, SupervisionOrder, SubmissionCustomUrl, + Section, ]; diff --git a/src/app/core/services/internal-link.service.spec.ts b/src/app/core/services/internal-link.service.spec.ts new file mode 100644 index 00000000000..4acb476e30e --- /dev/null +++ b/src/app/core/services/internal-link.service.spec.ts @@ -0,0 +1,74 @@ +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; + +import { InternalLinkService } from './internal-link.service'; +import { NativeWindowService } from './window.service'; + +describe('InternalLinkService', () => { + let service: InternalLinkService; + + beforeEach(waitForAsync(() => { + return TestBed.configureTestingModule({ + providers: [ + InternalLinkService, + { provide: NativeWindowService, useValue: { nativeWindow: { location: { origin: 'https://currentdomain' } } } }, + ], + }).compileComponents(); + })); + + beforeEach(() => { + service = TestBed.inject(InternalLinkService); + }); + + describe('isLinkInternal', () => { + it('should return true for internal link starting with "/"', () => { + const result = service.isLinkInternal('/my-link'); + expect(result).toBe(true); + }); + + it('should return true for internal link starting with currentURL', () => { + const result = service.isLinkInternal('https://currentdomain/my-link'); + expect(result).toBe(true); + }); + + it('should return true for internal link starting with "currentdomain"', () => { + const result = service.isLinkInternal('currentdomain/my-link'); + expect(result).toBe(true); + }); + + it('should return false for external link', () => { + const result = service.isLinkInternal('https://externaldomain/my-link'); + expect(result).toBe(false); + }); + + it('should return true for internal link without leading "/"', () => { + const result = service.isLinkInternal('my-link'); + expect(result).toBe(true); + }); + }); + + describe('transformInternalLink', () => { + it('should transform internal link by removing currentURL', () => { + const result = service.getRelativePath('https://currentdomain/my-link'); + expect(result).toBe('/my-link'); + }); + + it('should transform internal link by adding leading "/" if missing', () => { + const result = service.getRelativePath('currentdomain/my-link'); + expect(result).toBe('/my-link'); + }); + + it('should return unchanged link for external link', () => { + const result = service.getRelativePath('https://externalDomain/my-link'); + expect(result).toBe('/https://externalDomain/my-link'); + }); + + it('should return unchanged link for internal link with leading "/"', () => { + const result = service.getRelativePath('/my-link'); + expect(result).toBe('/my-link'); + }); + }); + +}); diff --git a/src/app/core/services/internal-link.service.ts b/src/app/core/services/internal-link.service.ts new file mode 100644 index 00000000000..e08ca2bc11e --- /dev/null +++ b/src/app/core/services/internal-link.service.ts @@ -0,0 +1,86 @@ +import { + Inject, + Injectable, +} from '@angular/core'; + +import { + NativeWindowRef, + NativeWindowService, +} from './window.service'; + +/** + * LinkService provides utility functions for working with links, such as checking if a link is internal + * and transforming internal links based on the current URL. + */ +@Injectable({ providedIn: 'root' }) +export class InternalLinkService { + currentURL = this._window.nativeWindow?.location?.origin; + + constructor( + @Inject(NativeWindowService) protected _window: NativeWindowRef, + ) { + + } + + /** + * Check if the provided link is internal, i.e., it starts with a '/' or matches the current URL. + * + * @param link The link to be checked. + * @returns A boolean indicating whether the link is internal. + */ + public isLinkInternal(link: string): boolean { + // Create a Domain object for the provided link + const currentDomain = new URL(this.currentURL).hostname; + + return link.startsWith('/') + || link.startsWith(this.currentURL) + || link.startsWith(currentDomain) + || link === currentDomain + || !link.includes('://'); + } + + /** + * Get the relative path for an internal link based on the current URL. + * + * @param link The internal link to be transformed. + * @returns The relative path for the given internal link. + */ + public getRelativePath(link: string): string { + // Obtaining the base URL, disregarding query parameters + const baseUrl = link.split('?')[0]; + const currentDomain = new URL(this.currentURL).hostname; + + if (baseUrl.startsWith(this.currentURL) || baseUrl.startsWith(currentDomain)) { + const base = baseUrl.startsWith(this.currentURL) ? this.currentURL : currentDomain; + const currentSegments = baseUrl.substring(base.length); + return currentSegments.startsWith('/') ? currentSegments : `/${currentSegments}`; + } + + return baseUrl.startsWith('/') ? baseUrl : `/${baseUrl}`; + } + + /** + * Parse the query parameters from a given URL link. + * + * @param link The URL link containing query parameters. + * @returns An object containing the parsed query parameters. + */ + public getQueryParams(link: string): Record { + const queryParams: Record = {}; + + const queryStringStartIndex = link.indexOf('?'); + if (queryStringStartIndex !== -1) { + const paramsString = link.substring(queryStringStartIndex + 1); + const paramsArray = paramsString.split('&'); + + paramsArray.forEach(param => { + const [key, value] = param.split('='); + if (key && value) { + queryParams[key] = decodeURIComponent(value.replace(/\+/g, ' ')); + } + }); + } + + return queryParams; + } +} diff --git a/src/app/core/shared/context.model.ts b/src/app/core/shared/context.model.ts index 8775272f34c..0c7b1cd52ad 100644 --- a/src/app/core/shared/context.model.ts +++ b/src/app/core/shared/context.model.ts @@ -50,4 +50,6 @@ export enum Context { */ AddMetadata = 'addMetadata', EditMetadata = 'editMetadata', + + BrowseMostElements = 'browseMostElements' } diff --git a/src/app/core/shared/search/models/search-filter-config.model.ts b/src/app/core/shared/search/models/search-filter-config.model.ts index 82c1fe00ba2..bd298d2cedb 100644 --- a/src/app/core/shared/search/models/search-filter-config.model.ts +++ b/src/app/core/shared/search/models/search-filter-config.model.ts @@ -1,3 +1,4 @@ +import { FacetValue } from '@dspace/core/shared/search/models/facet-value.model'; import { autoserialize, autoserializeAs, @@ -55,6 +56,13 @@ export class SearchFilterConfig implements CacheableObject { @autoserializeAs(Boolean, 'openByDefault') isOpenByDefault: boolean; + /** + * Defines the list of available operators + */ + @autoserialize + operators: OperatorConfig[]; + + /** * Minimum value possible for this facet in the repository */ @@ -67,6 +75,12 @@ export class SearchFilterConfig implements CacheableObject { @autoserialize minValue: string; + /** + * The embedded facet values. + */ + @autoserialize + _embedded: { values: FacetValue[] }; + /** * The {@link HALLink}s for this SearchFilterConfig */ @@ -83,3 +97,17 @@ export class SearchFilterConfig implements CacheableObject { return 'f.' + this.name; } } + +/** + * Interface to model sort option's configuration. + */ +export interface SortOption { + name: string; +} + +/** + * Interface to model operator's configuration. + */ +export interface OperatorConfig { + operator: string; +} diff --git a/src/app/home-page/home-page.component.html b/src/app/home-page/home-page.component.html index ac53ab33a7f..77dcaf1170b 100644 --- a/src/app/home-page/home-page.component.html +++ b/src/app/home-page/home-page.component.html @@ -1,35 +1,103 @@ -@if (homeHeaderMetadataValue$ | async; as homeHeaderMetadataValue) { -
- -
-} +@if (!isDynamicHomePageEnabled) { + @if (homeHeaderMetadataValue$ | async; as homeHeaderMetadataValue) { +
+ +
+ } - - - -@if (showDiscoverFilters) { - - - -} -@if (!showDiscoverFilters) { -
- -
-} - - - - - - - @if (recentSubmissionspageSize>0) { - + + + + @if (showDiscoverFilters) { + + + + } + @if (!showDiscoverFilters) { +
+ +
+ } + + + + + + + @if (recentSubmissionspageSize>0) { + + } + +} @else { + @if ((site$.value && hasHomeHeaderMetadata)) { +
+ +
+ } @else { + + } -
+ + + @if (site$.value) { + @for (sectionComponentRow of ( sectionComponents | async ); track sectionComponentRow) { +
+
+
+ @for (sectionComponent of sectionComponentRow; track sectionComponent) { +
+ @switch (sectionComponent.componentType) { + @case ('top') { + + } + @case ('browse') { + + } + @case ('search') { + + } + @case ('facet') { + + } + @case ('counters') { + + } + @case ('text-row') { + + } + } +
+ } +
+
+
+ } + } + + + +} + + diff --git a/src/app/home-page/home-page.component.ts b/src/app/home-page/home-page.component.ts index cf75770b9e8..146ca800644 100644 --- a/src/app/home-page/home-page.component.ts +++ b/src/app/home-page/home-page.component.ts @@ -1,31 +1,60 @@ import { AsyncPipe, + isPlatformServer, NgTemplateOutlet, } from '@angular/common'; import { Component, Inject, + OnDestroy, OnInit, + PLATFORM_ID, } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { APP_CONFIG, AppConfig, } from '@dspace/config/app-config.interface'; +import { NotifyInfoService } from '@dspace/core/coar-notify/notify-info/notify-info.service'; +import { SectionDataService } from '@dspace/core/data/section-data.service'; +import { SiteDataService } from '@dspace/core/data/site-data.service'; +import { + SectionComponent, + TextRowSection, +} from '@dspace/core/layout/models/section.model'; import { LocaleService } from '@dspace/core/locale/locale.service'; +import { + LinkDefinition, + LinkHeadService, +} from '@dspace/core/services/link-head.service'; +import { ServerResponseService } from '@dspace/core/services/server-response.service'; +import { getFirstSucceededRemoteDataPayload } from '@dspace/core/shared/operators'; import { Site } from '@dspace/core/shared/site.model'; +import { + isEmpty, + isNotEmpty, +} from '@dspace/shared/utils/empty.util'; import { TranslateModule } from '@ngx-translate/core'; import { + BehaviorSubject, combineLatest, Observable, + of, } from 'rxjs'; import { map, + switchMap, take, } from 'rxjs/operators'; import { SuggestionsPopupComponent } from '../notifications/suggestions/popup/suggestions-popup.component'; import { ThemedConfigurationSearchPageComponent } from '../search-page/themed-configuration-search-page.component'; +import { ThemedBrowseSectionComponent } from '../shared/explore/section-component/browse-section/themed-browse-section.component'; +import { ThemedCountersSectionComponent } from '../shared/explore/section-component/counters-section/themed-counters-section.component'; +import { ThemedFacetSectionComponent } from '../shared/explore/section-component/facet-section/themed-facet-section.component'; +import { ThemedSearchSectionComponent } from '../shared/explore/section-component/search-section/themed-search-section.component'; +import { ThemedTextSectionComponent } from '../shared/explore/section-component/text-section/themed-text-section.component'; +import { ThemedTopSectionComponent } from '../shared/explore/section-component/top-section/themed-top-section.component'; import { MarkdownViewerComponent } from '../shared/markdown-viewer/markdown-viewer.component'; import { ThemedSearchFormComponent } from '../shared/search-form/themed-search-form.component'; import { HomeCoarComponent } from './home-coar/home-coar.component'; @@ -44,33 +73,84 @@ import { ThemedTopLevelCommunityListComponent } from './top-level-community-list NgTemplateOutlet, RecentItemListComponent, SuggestionsPopupComponent, + ThemedBrowseSectionComponent, ThemedConfigurationSearchPageComponent, + ThemedCountersSectionComponent, + ThemedFacetSectionComponent, ThemedHomeNewsComponent, ThemedSearchFormComponent, + ThemedSearchSectionComponent, + ThemedTextSectionComponent, ThemedTopLevelCommunityListComponent, + ThemedTopSectionComponent, TranslateModule, ], }) -export class HomePageComponent implements OnInit { +export class HomePageComponent implements OnInit, OnDestroy { - site$: Observable; + site$: BehaviorSubject = new BehaviorSubject(null); recentSubmissionspageSize: number; showDiscoverFilters: boolean; homeHeaderMetadataValue$: Observable; + isDynamicHomePageEnabled: boolean; + sectionId = 'site'; + + /** + * Two-dimensional array (rows and columns) of section components + */ + sectionComponents: Observable; + + hasHomeHeaderMetadata: boolean; + + homeHeaderSection: TextRowSection = { + content: 'cris.cms.home-header', + contentType: 'text-metadata', + componentType: 'text-row', + style: '', + }; + + /** + * An array of LinkDefinition objects representing inbox links for the home page. + */ + inboxLinks: LinkDefinition[] = []; constructor( @Inject(APP_CONFIG) protected appConfig: AppConfig, - protected route: ActivatedRoute, + @Inject(PLATFORM_ID) private platformId: string, + private route: ActivatedRoute, + private sectionDataService: SectionDataService, + private siteService: SiteDataService, private locale: LocaleService, + private responseService: ServerResponseService, + private notifyInfoService: NotifyInfoService, + protected linkHeadService: LinkHeadService, ) { this.recentSubmissionspageSize = this.appConfig.homePage.recentSubmissions.pageSize; this.showDiscoverFilters = this.appConfig.homePage.showDiscoverFilters; + this.isDynamicHomePageEnabled = this.appConfig.homePage.enableDynamicLayout; + + this.notifyInfoService.isCoarConfigEnabled().pipe( + switchMap((coarLdnEnabled: boolean) => { + if (coarLdnEnabled) { + return this.notifyInfoService.getCoarLdnLocalInboxUrls(); + } else { + return of([]); + } + }), + ).subscribe((coarRestApiUrls: string[]) => { + if (coarRestApiUrls.length > 0) { + this.initPageLinks(coarRestApiUrls); + } + }); } ngOnInit(): void { - this.site$ = this.route.data.pipe( + this.route.data.pipe( map((data) => data.site as Site), - ); + take(1), + ).subscribe((site: Site) => { + this.site$.next(site); + }); this.homeHeaderMetadataValue$ = combineLatest({ site: this.site$, @@ -79,6 +159,57 @@ export class HomePageComponent implements OnInit { take(1), map(({ site, language }) => site?.firstMetadataValue('dspace.cms.home-header', { language })), ); + + this.sectionComponents = this.sectionDataService.findById('site').pipe( + getFirstSucceededRemoteDataPayload(), + map((section) => section.componentRows), + ); + + combineLatest([this.siteService.find().pipe(take(1)), this.locale.getCurrentLanguageCode()]).subscribe( + ([site, language]: [Site, string]) => { + this.hasHomeHeaderMetadata = !isEmpty(site?.firstMetadataValue('cris.cms.home-header', + { language })); + }, + ); + } + + componentClass(sectionComponent: SectionComponent) { + const defaultCol = 'col-12'; + return (isNotEmpty(sectionComponent.style) && sectionComponent.style.includes('col')) ? + sectionComponent.style : `${defaultCol} ${sectionComponent.style}`; } + /** + * Initializes page links for COAR REST API URLs. + * @param coarRestApiUrls An array of COAR REST API URLs. + */ + private initPageLinks(coarRestApiUrls: string[]): void { + const rel = this.notifyInfoService.getInboxRelationLink(); + let links = ''; + coarRestApiUrls.forEach((coarRestApiUrl: string) => { + // Add link to head + const tag: LinkDefinition = { + href: coarRestApiUrl, + rel: rel, + }; + this.inboxLinks.push(tag); + this.linkHeadService.addTag(tag); + + links = links + (isNotEmpty(links) ? ', ' : '') + `<${coarRestApiUrl}> ; rel="${rel}"`; + }); + + if (isPlatformServer(this.platformId)) { + // Add link to response header + this.responseService.setHeader('Link', links); + } + } + + /** + * It removes the inbox links from the head of the html. + */ + ngOnDestroy(): void { + this.inboxLinks.forEach((link: LinkDefinition) => { + this.linkHeadService.removeTag(`href='${link.href}'`); + }); + } } diff --git a/src/app/shared/browse-most-elements/abstract-browse-elements.component.ts b/src/app/shared/browse-most-elements/abstract-browse-elements.component.ts new file mode 100644 index 00000000000..3f6cb321fec --- /dev/null +++ b/src/app/shared/browse-most-elements/abstract-browse-elements.component.ts @@ -0,0 +1,122 @@ +import { isPlatformServer } from '@angular/common'; +import { + Component, + inject, + Input, + OnChanges, + OnInit, + PLATFORM_ID, +} from '@angular/core'; +import { APP_CONFIG } from '@dspace/config/app-config.interface'; +import { getItemPageRoute } from '@dspace/core/router/utils/dso-route.utils'; +import { followLink } from '@dspace/core/shared/follow-link-config.model'; +import { PaginatedSearchOptions } from '@dspace/core/shared/search/models/paginated-search-options.model'; +import { SearchResult } from '@dspace/core/shared/search/models/search-result.model'; +import { + BehaviorSubject, + mergeMap, + Observable, +} from 'rxjs'; + +import { SearchManager } from '../../core/browse/search-manager'; +import { PaginatedList } from '../../core/data/paginated-list.model'; +import { RemoteData } from '../../core/data/remote-data'; +import { TopSection } from '../../core/layout/models/section.model'; +import { Context } from '../../core/shared/context.model'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { Item } from '../../core/shared/item.model'; +import { + getAllCompletedRemoteData, + getPaginatedListPayload, + getRemoteDataPayload, + toDSpaceObjectListRD, +} from '../../core/shared/operators'; +import { CollectionElementLinkType } from '../object-collection/collection-element-link.type'; + + +@Component({ + template: '', +}) +export abstract class AbstractBrowseElementsComponent implements OnInit, OnChanges { + + protected readonly appConfig = inject(APP_CONFIG); + protected readonly platformId = inject(PLATFORM_ID); + protected readonly searchManager = inject(SearchManager); + + protected abstract followThumbnailLink: boolean; // to be overridden + + /** + * The context of listable object + */ + @Input() context: Context; + + /** + * The pagination options + */ + @Input() paginatedSearchOptions: PaginatedSearchOptions; + + /** + * Optional projection to use during the search + */ + @Input() projection; + + /** + * Whether to show the badge label or not + */ + @Input() showLabel: boolean; + + /** + * Whether to show the thumbnail preview + */ + @Input() showThumbnails = this.appConfig.browseBy.showThumbnails; + + /** + * TopSection object + */ + @Input() topSection: TopSection; + + public collectionElementLinkTypeEnum = CollectionElementLinkType; + + paginatedSearchOptions$: BehaviorSubject; + + searchResults$: Observable>>>; + + searchResultArray$: Observable; + + ngOnChanges() { + this.paginatedSearchOptions$?.next(this.paginatedSearchOptions); + } + + ngOnInit() { + if (isPlatformServer(this.platformId)) { + return; + } + const followLinks = []; + if (this.followThumbnailLink) { + followLinks.push(followLink('thumbnail')); + } + + this.paginatedSearchOptions = Object.assign(new PaginatedSearchOptions({}), this.paginatedSearchOptions, { + projection: this.projection, + }); + + this.paginatedSearchOptions$ = new BehaviorSubject(this.paginatedSearchOptions); + + this.searchResults$ = this.paginatedSearchOptions$.asObservable().pipe( + mergeMap((paginatedSearchOptions) => + this.searchManager.search(paginatedSearchOptions, null, true, true, ...followLinks), + ), + getAllCompletedRemoteData(), + ); + + this.searchResultArray$ = this.searchResults$.pipe( + toDSpaceObjectListRD(), + getRemoteDataPayload(), + getPaginatedListPayload(), + ); + } + + getItemPageRoute(item: DSpaceObject | Item) { + return getItemPageRoute(item as Item); + } +} diff --git a/src/app/shared/browse-most-elements/browse-most-elements.component.html b/src/app/shared/browse-most-elements/browse-most-elements.component.html new file mode 100644 index 00000000000..cfdd8ea2b67 --- /dev/null +++ b/src/app/shared/browse-most-elements/browse-most-elements.component.html @@ -0,0 +1,13 @@ +
+ @switch ((sectionTemplateType | lowercase)) { + @default { + + } + } +
diff --git a/src/app/shared/browse-most-elements/browse-most-elements.component.scss b/src/app/shared/browse-most-elements/browse-most-elements.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/shared/browse-most-elements/browse-most-elements.component.spec.ts b/src/app/shared/browse-most-elements/browse-most-elements.component.spec.ts new file mode 100644 index 00000000000..111a0a6cc47 --- /dev/null +++ b/src/app/shared/browse-most-elements/browse-most-elements.component.spec.ts @@ -0,0 +1,108 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { ItemSearchResult } from '@dspace/core/shared/object-collection/item-search-result.model'; +import { of } from 'rxjs'; + +import { TopSectionTemplateType } from '../../core/layout/models/section.model'; +import { Item } from '../../core/shared/item.model'; +import { BrowseMostElementsComponent } from './browse-most-elements.component'; +import { ThemedDefaultBrowseElementsComponent } from './default-browse-elements/themed-default-browse-elements.component'; + +describe('BrowseMostElementsComponent', () => { + let component: BrowseMostElementsComponent; + let fixture: ComponentFixture; + + + const mockResultObject: ItemSearchResult = new ItemSearchResult(); + mockResultObject.hitHighlights = {}; + + mockResultObject.indexableObject = Object.assign(new Item(), { + bundles: of({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title', + }, + ], + 'dc.type': [ + { + language: null, + value: 'Article', + }, + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald', + }, + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26', + }, + ], + }, + }); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [BrowseMostElementsComponent], + schemas: [NO_ERRORS_SCHEMA], + }).overrideComponent(BrowseMostElementsComponent, { remove: { imports: [ThemedDefaultBrowseElementsComponent] } }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BrowseMostElementsComponent); + component = fixture.componentInstance; + component.topSection = { + template: TopSectionTemplateType.DEFAULT, + } as any; + fixture.detectChanges(); + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + describe('when the templateType is DEFAULT', () => { + beforeEach(() => { + component.topSection = { + template: TopSectionTemplateType.DEFAULT, + } as any; + fixture.detectChanges(); + }); + + it('should display ds-themed-default-browse-elements', () => { + const defaultElement = fixture.debugElement.query(By.css('ds-default-browse-elements')); + expect(defaultElement).toBeTruthy(); + }); + + it('should not display ds-themed-images-browse-elements', () => { + const imageElement = fixture.debugElement.query(By.css('ds-images-browse-elements')); + expect(imageElement).toBeNull(); + }); + }); + + describe('when the templateType is not recognized', () => { + beforeEach(() => { + component.topSection = { + template: 'not recognized' as any, + } as any; + fixture.detectChanges(); + }); + + it('should display ds-themed-default-browse-elements', () => { + const defaultElement = fixture.debugElement.query(By.css('ds-default-browse-elements')); + expect(defaultElement).toBeTruthy(); + }); + }); +}); diff --git a/src/app/shared/browse-most-elements/browse-most-elements.component.ts b/src/app/shared/browse-most-elements/browse-most-elements.component.ts new file mode 100644 index 00000000000..e97eaa3e140 --- /dev/null +++ b/src/app/shared/browse-most-elements/browse-most-elements.component.ts @@ -0,0 +1,80 @@ +import { + AsyncPipe, + LowerCasePipe, +} from '@angular/common'; +import { + Component, + Input, + OnChanges, + OnInit, +} from '@angular/core'; +import { PaginatedSearchOptions } from '@dspace/core/shared/search/models/paginated-search-options.model'; +import { BehaviorSubject } from 'rxjs'; + +import { + TopSection, + TopSectionTemplateType, +} from '../../core/layout/models/section.model'; +import { Context } from '../../core/shared/context.model'; +import { ThemedDefaultBrowseElementsComponent } from './default-browse-elements/themed-default-browse-elements.component'; + +@Component({ + selector: 'ds-base-browse-most-elements', + styleUrls: ['./browse-most-elements.component.scss'], + templateUrl: './browse-most-elements.component.html', + imports: [ + AsyncPipe, + LowerCasePipe, + ThemedDefaultBrowseElementsComponent, + ], +}) + +export class BrowseMostElementsComponent implements OnInit, OnChanges { + + /** + * The pagination options + */ + @Input() paginatedSearchOptions: PaginatedSearchOptions; + + /** + * The context of listable object + */ + @Input() context: Context; + + /** + * Optional projection to use during the search + */ + @Input() projection; + + /** + * Whether to show the badge label or not + */ + @Input() showLabel: boolean; + + /** + * Whether to show the metrics badges + */ + @Input() showMetrics: boolean; + + /** + * Whether to show the thumbnail preview + */ + @Input() showThumbnails: boolean; + + /* + * The top section object + */ + @Input() topSection: TopSection; + + paginatedSearchOptions$ = new BehaviorSubject(null); + + sectionTemplateType: TopSectionTemplateType; + + ngOnInit(): void { + this.sectionTemplateType = this.topSection?.template ?? TopSectionTemplateType.DEFAULT; + } + + ngOnChanges() { // trigger change detection on child components + this.paginatedSearchOptions$.next(this.paginatedSearchOptions); + } +} diff --git a/src/app/shared/browse-most-elements/default-browse-elements/default-browse-elements.component.html b/src/app/shared/browse-most-elements/default-browse-elements/default-browse-elements.component.html new file mode 100644 index 00000000000..a66021d98df --- /dev/null +++ b/src/app/shared/browse-most-elements/default-browse-elements/default-browse-elements.component.html @@ -0,0 +1,27 @@ +
+ @if ((searchResults$ | async); as searchResults) { + @if (searchResults?.hasSucceeded) { +
    + @for (object of searchResults?.payload?.page; track object; let i = $index; let last = $last) { +
  • + +
  • + } +
+ } + @if (searchResults?.hasFailed) { +
+ {{ 'remote.error' | translate }} +
+ } + } + @if ((searchResults$ | async) === null || (searchResults$ | async) === undefined) { + + } +
diff --git a/src/app/shared/browse-most-elements/default-browse-elements/default-browse-elements.component.scss b/src/app/shared/browse-most-elements/default-browse-elements/default-browse-elements.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/shared/browse-most-elements/default-browse-elements/default-browse-elements.component.ts b/src/app/shared/browse-most-elements/default-browse-elements/default-browse-elements.component.ts new file mode 100644 index 00000000000..d97bfde0046 --- /dev/null +++ b/src/app/shared/browse-most-elements/default-browse-elements/default-browse-elements.component.ts @@ -0,0 +1,32 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + OnChanges, + OnInit, +} from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; + +import { ThemedLoadingComponent } from '../../loading/themed-loading.component'; +import { ListableObjectComponentLoaderComponent } from '../../object-collection/shared/listable-object/listable-object-component-loader.component'; +import { AbstractBrowseElementsComponent } from '../abstract-browse-elements.component'; + +@Component({ + selector: 'ds-base-default-browse-elements', + templateUrl: './default-browse-elements.component.html', + styleUrls: ['./default-browse-elements.component.scss'], + imports: [ + AsyncPipe, + ListableObjectComponentLoaderComponent, + ThemedLoadingComponent, + TranslateModule, + ], +}) +export class DefaultBrowseElementsComponent extends AbstractBrowseElementsComponent implements OnInit, OnChanges { + + protected followThumbnailLink: boolean; + + ngOnInit() { + this.followThumbnailLink = this.showThumbnails ?? this.appConfig.browseBy.showThumbnails; + super.ngOnInit(); + } +} diff --git a/src/app/shared/browse-most-elements/default-browse-elements/themed-default-browse-elements.component.ts b/src/app/shared/browse-most-elements/default-browse-elements/themed-default-browse-elements.component.ts new file mode 100644 index 00000000000..34bbfcb59b1 --- /dev/null +++ b/src/app/shared/browse-most-elements/default-browse-elements/themed-default-browse-elements.component.ts @@ -0,0 +1,51 @@ +import { + Component, + Input, +} from '@angular/core'; +import { TopSection } from '@dspace/core/layout/models/section.model'; +import { Context } from '@dspace/core/shared/context.model'; +import { PaginatedSearchOptions } from '@dspace/core/shared/search/models/paginated-search-options.model'; + +import { ThemedComponent } from '../../theme-support/themed.component'; +import { DefaultBrowseElementsComponent } from './default-browse-elements.component'; + +/** + * Themed component for the DefaultBrowseElementsComponent. + */ +@Component({ + selector: 'ds-default-browse-elements', + styleUrls: [], + templateUrl: './../../theme-support/themed.component.html', +}) +export class ThemedDefaultBrowseElementsComponent extends ThemedComponent { + + // AbstractBrowseElementsComponent I/O variables + + @Input() paginatedSearchOptions: PaginatedSearchOptions; + + @Input() context: Context; + + @Input() topSection: TopSection; + + // DefaultBrowseElementsComponent I/O variables + + @Input() projection: string; + + @Input() showThumbnails: boolean; + + @Input() showLabel: boolean; + + protected inAndOutputNames: (keyof DefaultBrowseElementsComponent & keyof this)[] = ['paginatedSearchOptions', 'context', 'showThumbnails', 'showLabel', 'projection']; + + protected getComponentName(): string { + return 'DefaultBrowseElementsComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`./../../../../themes/${themeName}/app/shared/browse-most-elements/default-browse-elements/default-browse-elements.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./default-browse-elements.component`); + } +} diff --git a/src/app/shared/browse-most-elements/themed-browse-most-elements.component.ts b/src/app/shared/browse-most-elements/themed-browse-most-elements.component.ts new file mode 100644 index 00000000000..ccb0a48108e --- /dev/null +++ b/src/app/shared/browse-most-elements/themed-browse-most-elements.component.ts @@ -0,0 +1,50 @@ +import { Context } from 'node:vm'; + +import { + Component, + Input, +} from '@angular/core'; +import { PaginatedSearchOptions } from '@dspace/core/shared/search/models/paginated-search-options.model'; + +import { TopSection } from '../../core/layout/models/section.model'; +import { ThemedComponent } from '../theme-support/themed.component'; +import { BrowseMostElementsComponent } from './browse-most-elements.component'; + +/** + * Themed wrapper for BrowseMostElementsComponent + */ +@Component({ + selector: 'ds-browse-most-elements', + styleUrls: [], + templateUrl: '../theme-support/themed.component.html', +}) +export class ThemedBrowseMostElementsComponent extends ThemedComponent { + + @Input() context: Context; + + @Input() paginatedSearchOptions: PaginatedSearchOptions; + + @Input() projection: string; + + @Input() showLabel: boolean; + + @Input() showMetrics: boolean; + + @Input() showThumbnails: boolean; + + @Input() topSection: TopSection; + + protected inAndOutputNames: (keyof BrowseMostElementsComponent & keyof this)[] = ['context', 'paginatedSearchOptions', 'projection', 'showLabel', 'showMetrics', 'showThumbnails', 'topSection']; + + protected getComponentName(): string { + return 'BrowseMostElementsComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/browse-most-elements/browse-most-elements.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./browse-most-elements.component`); + } +} diff --git a/src/app/shared/explore/section-component/browse-section/browse-section.component.html b/src/app/shared/explore/section-component/browse-section/browse-section.component.html new file mode 100644 index 00000000000..1f8c06f1ff5 --- /dev/null +++ b/src/app/shared/explore/section-component/browse-section/browse-section.component.html @@ -0,0 +1,10 @@ +
+

{{ 'explore.browse-section.title' | translate }}

+ @for (name of browseSection.browseNames; track name) { + + } +
diff --git a/src/app/shared/explore/section-component/browse-section/browse-section.component.spec.ts b/src/app/shared/explore/section-component/browse-section/browse-section.component.spec.ts new file mode 100644 index 00000000000..824043573ea --- /dev/null +++ b/src/app/shared/explore/section-component/browse-section/browse-section.component.spec.ts @@ -0,0 +1,74 @@ +import { CommonModule } from '@angular/common'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + inject, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; +import { + BrowserModule, + By, +} from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; + +import { BrowseSectionComponent } from './browse-section.component'; + +describe('BrowseSectionComponent', () => { + let component: BrowseSectionComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, RouterTestingModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), BrowseSectionComponent], + providers: [BrowseSectionComponent], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BrowseSectionComponent); + component = fixture.componentInstance; + + component.sectionId = 'publication'; + component.browseSection = { + browseNames: ['rodept', 'author', 'title', 'type'], + componentType: 'browse', + style: 'col-md-4', + }; + + fixture.detectChanges(); + }); + + it('should create BrowseSectionComponent', inject([BrowseSectionComponent], (comp: BrowseSectionComponent) => { + expect(comp).toBeDefined(); + })); + + it('should show one link foreach browse names', waitForAsync(() => { + fixture.whenStable().then(() => { + const browseLinks = fixture.debugElement.queryAll(By.css('a.lead')); + expect(browseLinks.length).toEqual(4); + expect(browseLinks[0].nativeElement.href).toContain('/browse/rodept'); + expect(browseLinks[1].nativeElement.href).toContain('/browse/author'); + expect(browseLinks[2].nativeElement.href).toContain('/browse/title'); + expect(browseLinks[3].nativeElement.href).toContain('/browse/type'); + }); + })); + +}); diff --git a/src/app/shared/explore/section-component/browse-section/browse-section.component.ts b/src/app/shared/explore/section-component/browse-section/browse-section.component.ts new file mode 100644 index 00000000000..ab8b019852f --- /dev/null +++ b/src/app/shared/explore/section-component/browse-section/browse-section.component.ts @@ -0,0 +1,30 @@ + +import { + Component, + Input, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; + +import { BrowseSection } from '../../../../core/layout/models/section.model'; + +/** + * Component representing the Browse component section. + */ +@Component({ + selector: 'ds-base-browse-section', + templateUrl: './browse-section.component.html', + imports: [ + RouterLink, + TranslateModule, + ], +}) +export class BrowseSectionComponent { + + @Input() + sectionId: string; + + @Input() + browseSection: BrowseSection; + +} diff --git a/src/app/shared/explore/section-component/browse-section/themed-browse-section.component.ts b/src/app/shared/explore/section-component/browse-section/themed-browse-section.component.ts new file mode 100644 index 00000000000..57844cea1b0 --- /dev/null +++ b/src/app/shared/explore/section-component/browse-section/themed-browse-section.component.ts @@ -0,0 +1,37 @@ +import { + Component, + Input, +} from '@angular/core'; +import { BrowseSection } from '@dspace/core/layout/models/section.model'; + +import { ThemedComponent } from '../../../theme-support/themed.component'; +import { BrowseSectionComponent } from './browse-section.component'; + +@Component({ + selector: 'ds-browse-section', + styleUrls: [], + templateUrl: '../../../theme-support/themed.component.html', +}) +export class ThemedBrowseSectionComponent extends ThemedComponent { + + @Input() + sectionId: string; + + @Input() + browseSection: BrowseSection; + + protected inAndOutputNames: (keyof BrowseSectionComponent & keyof this)[] = ['sectionId', 'browseSection']; + + protected getComponentName(): string { + return 'BrowseSectionComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../../themes/${themeName}/app/shared/explore/section-component/browse-section/browse-section.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./browse-section.component`); + } + +} diff --git a/src/app/shared/explore/section-component/counters-section/counters-section.component.html b/src/app/shared/explore/section-component/counters-section/counters-section.component.html new file mode 100644 index 00000000000..d6d292d7ff3 --- /dev/null +++ b/src/app/shared/explore/section-component/counters-section/counters-section.component.html @@ -0,0 +1,46 @@ +
+
+
+ @if (isLoading$ | async) { +
+ +
+ } + @for (counter of (counterData$ | async); track counter) { + @if (counter.link) { + @if (internalLinkService.isLinkInternal(counter.link)) { + + + + } + @if (!internalLinkService.isLinkInternal(counter.link)) { + + + + } + } @else { +
+ +
+ {{ 'explore.counters-section.' + counter.label | translate }} +
+
+ {{ counter.count }} +
+
+ } + +
+ +
+ {{ 'explore.counters-section.' + counter.label | translate }} +
+
+ {{ counter.count }} +
+
+
+ } +
+
+
diff --git a/src/app/shared/explore/section-component/counters-section/counters-section.component.scss b/src/app/shared/explore/section-component/counters-section/counters-section.component.scss new file mode 100644 index 00000000000..c08f994bbe7 --- /dev/null +++ b/src/app/shared/explore/section-component/counters-section/counters-section.component.scss @@ -0,0 +1,13 @@ +.counters-section { + min-width: 120px; + max-width: 140px; + color: var(--bs-gray-800); + + &:hover { + color: #{darken($gray-800, 20%)}; + } +} + +.counters-label { + line-height: 1.25; +} diff --git a/src/app/shared/explore/section-component/counters-section/counters-section.component.spec.ts b/src/app/shared/explore/section-component/counters-section/counters-section.component.spec.ts new file mode 100644 index 00000000000..6350b812975 --- /dev/null +++ b/src/app/shared/explore/section-component/counters-section/counters-section.component.spec.ts @@ -0,0 +1,37 @@ +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { SearchManager } from '@dspace/core/browse/search-manager'; +import { NativeWindowService } from '@dspace/core/services/window.service'; +import { NativeWindowMockFactory } from '@dspace/core/testing/mock-native-window-ref'; + +import { ThemedLoadingComponent } from '../../../loading/themed-loading.component'; +import { CountersSectionComponent } from './counters-section.component'; + +xdescribe('CountersSectionComponent', () => { + let component: CountersSectionComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [CountersSectionComponent], + providers: [ + { provide: SearchManager, useValue: {} }, + { provide: NativeWindowService, useFactory: NativeWindowMockFactory }, + ], + }) + .overrideComponent(CountersSectionComponent, { remove: { imports: [ThemedLoadingComponent] } }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CountersSectionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/explore/section-component/counters-section/counters-section.component.ts b/src/app/shared/explore/section-component/counters-section/counters-section.component.ts new file mode 100644 index 00000000000..89a8af79792 --- /dev/null +++ b/src/app/shared/explore/section-component/counters-section/counters-section.component.ts @@ -0,0 +1,121 @@ +import { + AsyncPipe, + isPlatformServer, + NgClass, + NgTemplateOutlet, +} from '@angular/common'; +import { + Component, + Inject, + Input, + OnInit, + PLATFORM_ID, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { SearchManager } from '@dspace/core/browse/search-manager'; +import { SectionComponent } from '@dspace/core/layout/models/section.model'; +import { PaginationComponentOptions } from '@dspace/core/pagination/pagination-component-options.model'; +import { InternalLinkService } from '@dspace/core/services/internal-link.service'; +import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; +import { getFirstSucceededRemoteDataPayload } from '@dspace/core/shared/operators'; +import { PaginatedSearchOptions } from '@dspace/core/shared/search/models/paginated-search-options.model'; +import { SearchObjects } from '@dspace/core/shared/search/models/search-objects.model'; +import { TranslateModule } from '@ngx-translate/core'; +import { + BehaviorSubject, + forkJoin, + Observable, +} from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { ThemedLoadingComponent } from '../../../loading/themed-loading.component'; + + +@Component({ + selector: 'ds-base-counters-section', + styleUrls: ['./counters-section.component.scss'], + templateUrl: './counters-section.component.html', + imports: [ + AsyncPipe, + NgClass, + NgTemplateOutlet, + RouterLink, + ThemedLoadingComponent, + TranslateModule, + ], +}) +export class CountersSectionComponent implements OnInit { + + @Input() + sectionId: string; + + @Input() + countersSection: CountersSection; + + counterData: CounterData[] = []; + counterData$: Observable; + isLoading$ = new BehaviorSubject(true); + + pagination: PaginationComponentOptions; + + + constructor( + public internalLinkService: InternalLinkService, + private searchService: SearchManager, + @Inject(PLATFORM_ID) private platformId: any, + ) { + + } + + ngOnInit() { + if (isPlatformServer(this.platformId)) { + return; + } + + this.pagination = Object.assign(new PaginationComponentOptions(), { + id: 'counters-pagination' + this.sectionId, + pageSize: 1, + currentPage: 1, + }); + + this.counterData$ = forkJoin( + this.countersSection.counterSettingsList.map((counterSettings: CountersSettings) => + this.searchService.search(new PaginatedSearchOptions({ + configuration: counterSettings.discoveryConfigurationName, + pagination: this.pagination })).pipe( + getFirstSucceededRemoteDataPayload(), + map((rs: SearchObjects) => rs.totalElements), + map((total: number) => { + return { + count: total.toString(), + label: counterSettings.entityName, + icon: counterSettings.icon, + link: counterSettings.link, + + }; + }), + ))); + this.counterData$.subscribe(() => this.isLoading$.next(false)); + } +} + + + +export interface CountersSection extends SectionComponent { + componentType: 'counters'; + counterSettingsList: CountersSettings[]; +} + +export interface CountersSettings { + discoveryConfigurationName: string; + entityName: string; + icon: string; + link: string; +} + +export interface CounterData { + label: string; + count: string; + icon: string; + link: string; +} diff --git a/src/app/shared/explore/section-component/counters-section/themed-counters-section.component.ts b/src/app/shared/explore/section-component/counters-section/themed-counters-section.component.ts new file mode 100644 index 00000000000..83b14555595 --- /dev/null +++ b/src/app/shared/explore/section-component/counters-section/themed-counters-section.component.ts @@ -0,0 +1,39 @@ +import { + Component, + Input, +} from '@angular/core'; + +import { ThemedComponent } from '../../../theme-support/themed.component'; +import { + CountersSection, + CountersSectionComponent, +} from './counters-section.component'; + +@Component({ + selector: 'ds-counters-section', + styleUrls: [], + templateUrl: '../../../theme-support/themed.component.html', +}) +export class ThemedCountersSectionComponent extends ThemedComponent { + + @Input() + sectionId: string; + + @Input() + countersSection: CountersSection; + + protected inAndOutputNames: (keyof CountersSectionComponent & keyof this)[] = ['sectionId', 'countersSection']; + + protected getComponentName(): string { + return 'CountersSectionComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../../themes/${themeName}/app/shared/explore/section-component/counters-section/counters-section.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./counters-section.component`); + } + +} diff --git a/src/app/shared/explore/section-component/facet-section/facet-section.component.html b/src/app/shared/explore/section-component/facet-section/facet-section.component.html new file mode 100644 index 00000000000..e84f2496453 --- /dev/null +++ b/src/app/shared/explore/section-component/facet-section/facet-section.component.html @@ -0,0 +1,19 @@ +
+

{{ 'explore.facet-section.title' | translate }}

+
+ @for (facet of (facets$ | async); track facet) { +
+
{{'explore.index.' + facet.name | translate}}
+ @for (facetValue of facet._embedded.values; track facetValue) { +
+ + {{facetValue.label}} + + {{facetValue.count}} +
+ } +
+ } +
+
diff --git a/src/app/shared/explore/section-component/facet-section/facet-section.component.spec.ts b/src/app/shared/explore/section-component/facet-section/facet-section.component.spec.ts new file mode 100644 index 00000000000..e2e5e2fa147 --- /dev/null +++ b/src/app/shared/explore/section-component/facet-section/facet-section.component.spec.ts @@ -0,0 +1,216 @@ +import { CommonModule } from '@angular/common'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + BrowserModule, + By, +} from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; +import { authReducer } from '@dspace/core/auth/auth.reducer'; +import { RemoteData } from '@dspace/core/data/remote-data'; +import { FacetValue } from '@dspace/core/shared/search/models/facet-value.model'; +import { FilterType } from '@dspace/core/shared/search/models/filter-type.model'; +import { SearchFilterConfig } from '@dspace/core/shared/search/models/search-filter-config.model'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { StoreModule } from '@ngrx/store'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { SearchService } from 'src/app/shared/search/search.service'; +import { SearchConfigurationService } from 'src/app/shared/search/search-configuration.service'; + +import { storeModuleConfig } from '../../../../app.reducer'; +import { FacetSectionComponent } from './facet-section.component'; + +describe('FacetSectionComponent', () => { + let component: FacetSectionComponent; + let fixture: ComponentFixture; + + let searchServiceStub: any; + let searchConfigurationStub: any; + + const dateIssuedValue: FacetValue = { + label: '1996 - 1999', + value: '1996 - 1999', + count: 35, + _links: { + self: { href: 'di-selectedValue-self-link' }, + search: { href: '' }, + }, + }; + + const authorFirstValue: FacetValue = { + label: 'First Author', + value: 'First Author', + count: 20, + _links: { + self: { href: 'fa-selectedValue-self-link' }, + search: { href: '' }, + }, + }; + + const authorSecondValue: FacetValue = { + label: 'Second Author', + value: 'Second Author', + count: 15, + _links: { + self: { href: 'sa-selectedValue-self-link1' }, + search: { href: '' }, + }, + }; + + const mockAuthorFilterConfig = Object.assign(new SearchFilterConfig(), { + name: 'author', + filterType: FilterType.text, + _embedded: { + values: [authorFirstValue, authorSecondValue], + }, + }); + + const mockSubjectFilterConfig = Object.assign(new SearchFilterConfig(), { + name: 'subject', + filterType: FilterType.hierarchy, + _embedded: { + values: [], + }, + }); + + const mockDateIssuedFilterConfig = Object.assign(new SearchFilterConfig(), { + name: 'dateIssued', + filterType: FilterType.range, + _embedded: { + values: [dateIssuedValue], + }, + }); + const barChartFacetValue: FacetValue = { + label: '2007', + value: '2007', + count: 13, + _links: { + self: { href: 'fa-selectedValue-self-link' }, + search: { href: '' }, + }, + }; + const mockGraphBarChartFilterConfig = Object.assign(new SearchFilterConfig(), { + name: 'dateIssued', + filterType: FilterType['chart.bar'], + _embedded: { + values: [barChartFacetValue], + }, + }); + const pieChartFacetValue: FacetValue = { + label: 'Other', + value: 'Other', + count: 13, + _links: { + self: { href: 'fa-selectedValue-self-link' }, + search: { href: '' }, + }, + }; + const mockGraphPieChartFilterConfig = Object.assign(new SearchFilterConfig(), { + name: 'dateIssued', + filterType: FilterType['chart.pie'], + _embedded: { + values: [pieChartFacetValue], + }, + }); + beforeEach(waitForAsync(() => { + + searchServiceStub = { + getSearchLink(): string { + return '/search'; + }, + }; + searchConfigurationStub = { + searchFacets(scope?: string, configurationName?: string): Observable> { + return createSuccessfulRemoteDataObject$([mockAuthorFilterConfig, mockSubjectFilterConfig, mockDateIssuedFilterConfig, mockGraphBarChartFilterConfig, mockGraphPieChartFilterConfig]); + }, + }; + + searchServiceStub = { + getSearchLink(): string { + return '/search'; + }, + }; + + TestBed.configureTestingModule({ + imports: [CommonModule, + BrowserModule, + RouterTestingModule, + StoreModule.forRoot({ auth: authReducer }, storeModuleConfig), + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), FacetSectionComponent], + providers: [ + { provide: SearchService, useValue: searchServiceStub }, + { provide: SearchConfigurationService, useValue: searchConfigurationStub }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + })); + + beforeEach(() => { + fixture = TestBed.createComponent(FacetSectionComponent); + component = fixture.componentInstance; + + component.sectionId = 'publications'; + component.facetSection = { + discoveryConfigurationName: 'publication', + componentType: 'facet', + style: 'col-md-12', + facetsPerRow: 4, + }; + + fixture.detectChanges(); + }); + + it('should create FacetSectionComponent', () => { + expect(component).toBeDefined(); + }); + + it('should create a facet section foreach not empty filter configs', () => { + const facets = fixture.debugElement.queryAll(By.css('.col-lg-3.mb-4')); + expect(facets.length).toEqual(4); + + const authorFacet = facets[0]; + expect(authorFacet.children.length).toEqual(3); + + const authorSpan = authorFacet.children[0]; + expect(authorSpan.name).toEqual('h5'); + expect(authorSpan.nativeElement.textContent).toEqual('explore.index.author'); + + const firstAuthor = authorFacet.children[1]; + expect(firstAuthor.name).toEqual('div'); + expect(firstAuthor.query(By.css('a')).nativeElement.href).toContain('search?configuration=publication&page=1&f.author=First%20Author,equals'); + expect(firstAuthor.query(By.css('span.badge.bg-secondary')).nativeElement.textContent).toEqual('20'); + + const secondAuthor = authorFacet.children[2]; + expect(secondAuthor.name).toEqual('div'); + expect(secondAuthor.query(By.css('a')).nativeElement.href).toContain('search?configuration=publication&page=1&f.author=Second%20Author,equals'); + expect(secondAuthor.query(By.css('span.badge.bg-secondary')).nativeElement.textContent).toEqual('15'); + + const dateIssuedFacet = facets[1]; + expect(dateIssuedFacet.children.length).toEqual(2); + + const dateIssuedSpan = dateIssuedFacet.children[0]; + expect(dateIssuedSpan.name).toEqual('h5'); + expect(dateIssuedSpan.nativeElement.textContent).toEqual('explore.index.dateIssued'); + + const dateIssued = dateIssuedFacet.children[1]; + expect(dateIssued.name).toEqual('div'); + expect(dateIssued.query(By.css('a')).nativeElement.href).toContain('search?configuration=publication&page=1&f.dateIssued.min=1996&f.dateIssued.max=1999'); + expect(dateIssued.query(By.css('span.badge.bg-secondary')).nativeElement.textContent).toEqual('35'); + }); + +}); diff --git a/src/app/shared/explore/section-component/facet-section/facet-section.component.ts b/src/app/shared/explore/section-component/facet-section/facet-section.component.ts new file mode 100644 index 00000000000..05cd9d918ba --- /dev/null +++ b/src/app/shared/explore/section-component/facet-section/facet-section.component.ts @@ -0,0 +1,114 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + Input, + OnInit, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { FacetSection } from '@dspace/core/layout/models/section.model'; +import { getFirstSucceededRemoteDataPayload } from '@dspace/core/shared/operators'; +import { FacetValue } from '@dspace/core/shared/search/models/facet-value.model'; +import { FilterType } from '@dspace/core/shared/search/models/filter-type.model'; +import { SearchFilterConfig } from '@dspace/core/shared/search/models/search-filter-config.model'; +import { TranslateModule } from '@ngx-translate/core'; +import { BehaviorSubject } from 'rxjs'; + +import { SearchService } from '../../../search/search.service'; +import { getFacetValueForTypeAndLabel } from '../../../search/search.utils'; +import { SearchConfigurationService } from '../../../search/search-configuration.service'; + + +/** + * Component representing the Facet component section. + */ +@Component({ + selector: 'ds-base-facet-section', + templateUrl: './facet-section.component.html', + imports: [ + AsyncPipe, + RouterLink, + TranslateModule, + ], +}) +export class FacetSectionComponent implements OnInit { + + @Input() + sectionId: string; + + @Input() + facetSection: FacetSection; + + discoveryConfiguration: string; + + facets: SearchFilterConfig[] = []; + facets$ = new BehaviorSubject(this.facets); + + constructor( + private searchConfigService: SearchConfigurationService, + private searchService: SearchService, + ) { + + } + + ngOnInit() { + this.discoveryConfiguration = this.facetSection.discoveryConfigurationName; + this.searchConfigService.searchFacets(null, this.discoveryConfiguration) + .pipe(getFirstSucceededRemoteDataPayload()) + .subscribe((facetConfigs) => { + for (const config of facetConfigs) { + if (config._embedded.values.length > 0) { + this.facets.push(config); + this.facets$.next(this.facets); + } + } + }); + } + + /** + * Returns the queryParams for the search related to the given facet. + * + * @param facet the facet + * @param facetValue the FacetValue + */ + getSearchQueryParams(facet: SearchFilterConfig, facetValue: FacetValue) { + const queryParams = { + configuration: this.facetSection.discoveryConfigurationName, + page: 1, + }; + this.addFacetValuesToQueryParams(facet, facetValue, queryParams); + return queryParams; + } + + private addFacetValuesToQueryParams(facet: SearchFilterConfig, facetValue: FacetValue, queryParams) { + if (this.isRangeFacet(facet.filterType, facetValue.label)) { + const dates = facetValue.label.split('-'); + queryParams[facet.paramName + '.min'] = dates[0].trim(); + queryParams[facet.paramName + '.max'] = dates[1].trim(); + return; + } + queryParams[facet.paramName] = getFacetValueForTypeAndLabel(facetValue, facet); + } + + /** + * Return the appropriate col-* classes for the facet, based on the number of facets per row + * @param facet + */ + getFacetsBoxCol(facet) { + const facetsPerRow = this.facetSection.facetsPerRow ? this.facetSection.facetsPerRow : 4; + const colSizeLg = Math.min(Math.ceil(12 / facetsPerRow), 12); + const colSizeMd = Math.min(Math.ceil(12 / facetsPerRow * 2), 12); // double width on medium screens + + return `col-12 col-md-${colSizeMd} col-lg-${colSizeLg}`; + } + + /** + * Return the search page link + */ + getSearchLink(): string[] { + return [this.searchService.getSearchLink()]; + } + + private isRangeFacet(filterType: FilterType, value: string) { + return filterType === FilterType.range && value.split('-').length === 2; + } +} diff --git a/src/app/shared/explore/section-component/facet-section/themed-facet-section.component.ts b/src/app/shared/explore/section-component/facet-section/themed-facet-section.component.ts new file mode 100644 index 00000000000..295b96dcb01 --- /dev/null +++ b/src/app/shared/explore/section-component/facet-section/themed-facet-section.component.ts @@ -0,0 +1,37 @@ +import { + Component, + Input, +} from '@angular/core'; +import { FacetSection } from '@dspace/core/layout/models/section.model'; + +import { ThemedComponent } from '../../../theme-support/themed.component'; +import { FacetSectionComponent } from './facet-section.component'; + +@Component({ + selector: 'ds-facet-section', + styleUrls: [], + templateUrl: '../../../theme-support/themed.component.html', +}) +export class ThemedFacetSectionComponent extends ThemedComponent { + + @Input() + sectionId: string; + + @Input() + facetSection: FacetSection; + + protected inAndOutputNames: (keyof FacetSectionComponent & keyof this)[] = ['sectionId', 'facetSection']; + + protected getComponentName(): string { + return 'FacetSectionComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../../themes/${themeName}/app/shared/explore/section-component/facet-section/facet-section.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./facet-section.component`); + } + +} diff --git a/src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.html b/src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.html new file mode 100644 index 00000000000..7cf0c1b8186 --- /dev/null +++ b/src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.html @@ -0,0 +1,21 @@ +
+
+ @for (column of getColumns(); track column) { +
{{ 'explore.index.' + column.titleKey | translate }}
+ } +
+ +
+ @for (topObject of ( topObjects | async ); track topObject; let last = $last) { +
+ @for (column of getColumns(); track column) { + + } +
+ } +
+
diff --git a/src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.spec.ts b/src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.spec.ts new file mode 100644 index 00000000000..9d0e2e09df2 --- /dev/null +++ b/src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.spec.ts @@ -0,0 +1,29 @@ +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; + +import { MultiColumnTopSectionComponent } from './multi-column-top-section.component'; + +xdescribe('MultiColumnTopSectionComponent', () => { + let component: MultiColumnTopSectionComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [MultiColumnTopSectionComponent], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MultiColumnTopSectionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.ts b/src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.ts new file mode 100644 index 00000000000..07e97e543c8 --- /dev/null +++ b/src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.ts @@ -0,0 +1,83 @@ +import { + Component, + Input, + OnInit, +} from '@angular/core'; +import { SearchManager } from '@dspace/core/browse/search-manager'; +import { + SortDirection, + SortOptions, +} from '@dspace/core/cache/models/sort-options.model'; +import { + MultiColumnTopSection, + TopSectionColumn, +} from '@dspace/core/layout/models/section.model'; +import { PaginationComponentOptions } from '@dspace/core/pagination/pagination-component-options.model'; +import { getItemPageRoute } from '@dspace/core/router/utils/dso-route.utils'; +import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { Metadata } from '@dspace/core/shared/metadata.utils'; +import { getFirstSucceededRemoteDataPayload } from '@dspace/core/shared/operators'; +import { PaginatedSearchOptions } from '@dspace/core/shared/search/models/paginated-search-options.model'; +import { SearchObjects } from '@dspace/core/shared/search/models/search-objects.model'; +import { SearchResult } from '@dspace/core/shared/search/models/search-result.model'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + + +@Component({ + selector: 'ds-base-multi-column-top-section', + templateUrl: './multi-column-top-section.component.html', +}) +export class MultiColumnTopSectionComponent implements OnInit { + + @Input() + sectionId: string; + + @Input() + topSection: MultiColumnTopSection; + + topObjects: Observable; + + constructor(private searchService: SearchManager) { + + } + + ngOnInit() { + const order = this.topSection.order; + const sortDirection = order && order.toUpperCase() === 'ASC' ? SortDirection.ASC : SortDirection.DESC; + const pagination: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'search-object-pagination', + pageSize: 50, + currentPage: 1, + }); + + this.topObjects = this.searchService.search(new PaginatedSearchOptions({ + configuration: this.topSection.discoveryConfigurationName, + pagination: pagination, + sort: new SortOptions(this.topSection.sortField, sortDirection), + })).pipe( + getFirstSucceededRemoteDataPayload(), + map((response: SearchObjects) => response.page + .map((searchResult: SearchResult) => searchResult._embedded.indexableObject), + ), + ); + } + + + /** + * Get the item page url + * @param item The item for which the url is requested + */ + getItemPage(item: DSpaceObject): string { + return getItemPageRoute((item as Item)); + } + + getColumns(): TopSectionColumn[] { + return this.topSection.columnList; + } + + getColumnValue(topObject: DSpaceObject, column: TopSectionColumn): string { + return Metadata.firstValue(topObject.metadata, column.metadataField); + } +} diff --git a/src/app/shared/explore/section-component/multi-column-top-section/themed-multi-column-top-section.component.ts b/src/app/shared/explore/section-component/multi-column-top-section/themed-multi-column-top-section.component.ts new file mode 100644 index 00000000000..4ce7a051e81 --- /dev/null +++ b/src/app/shared/explore/section-component/multi-column-top-section/themed-multi-column-top-section.component.ts @@ -0,0 +1,37 @@ +import { + Component, + Input, +} from '@angular/core'; + +import { MultiColumnTopSection } from '../../../../core/layout/models/section.model'; +import { ThemedComponent } from '../../../theme-support/themed.component'; +import { MultiColumnTopSectionComponent } from './multi-column-top-section.component'; + +@Component({ + selector: 'ds-multi-column-top-section', + styleUrls: [], + templateUrl: '../../../theme-support/themed.component.html', +}) +export class ThemedMultiColumnTopSectionComponent extends ThemedComponent { + + @Input() + sectionId: string; + + @Input() + topSection: MultiColumnTopSection; + + protected inAndOutputNames: (keyof MultiColumnTopSectionComponent & keyof this)[] = ['sectionId', 'topSection']; + + protected getComponentName(): string { + return 'MultiColumnTopSectionComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../../themes/${themeName}/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./multi-column-top-section.component`); + } + +} diff --git a/src/app/shared/explore/section-component/search-section/search-section.component.html b/src/app/shared/explore/section-component/search-section/search-section.component.html new file mode 100644 index 00000000000..7df5955e47a --- /dev/null +++ b/src/app/shared/explore/section-component/search-section/search-section.component.html @@ -0,0 +1,60 @@ +
+ @if (searchSection.displayTitle) { +

{{ 'explore.search-section.' + sectionId + '.title' | translate }}

+ } + @if (searchSection.searchType !== 'basic' || !searchSection.searchType) { +
+ @for (queryStatement of queryArray.controls; track queryStatement; let i = $index; let isLast = $last) { +
+
+
+ +
+
+ +
+
+ @if (!isLast) { + + } + @if (isLast) { + + } +
+
+
+ } +
+ + +
+
+ } + + @if (searchSection.searchType === 'basic') { +
+
+ @if (searchSection.searchType === 'basic') { + > + } +
+
+ } +
diff --git a/src/app/shared/explore/section-component/search-section/search-section.component.spec.ts b/src/app/shared/explore/section-component/search-section/search-section.component.spec.ts new file mode 100644 index 00000000000..22a67940617 --- /dev/null +++ b/src/app/shared/explore/section-component/search-section/search-section.component.spec.ts @@ -0,0 +1,253 @@ +import { CommonModule } from '@angular/common'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + inject, + TestBed, + tick, + waitForAsync, +} from '@angular/core/testing'; +import { + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; +import { + BrowserModule, + By, +} from '@angular/platform-browser'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { RemoteData } from '@dspace/core/data/remote-data'; +import { SearchConfig } from '@dspace/core/shared/search/search-filters/search-config.model'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { SearchService } from 'src/app/shared/search/search.service'; +import { SearchConfigurationService } from 'src/app/shared/search/search-configuration.service'; + +import { ThemedSearchFormComponent } from '../../../search-form/themed-search-form.component'; +import { SearchSectionComponent } from './search-section.component'; + +describe('SearchSectionComponent', () => { + let component: SearchSectionComponent; + let fixture: ComponentFixture; + + let searchServiceStub: any; + let searchConfigurationStub: any; + let router: any; + + const firstFilterConfig: any = { + filter: 'author', + hasFacets: true, + operators: [], + openByDefault: true, + pageSize: 5, + type: 'text', + }; + + const secondFilterConfig: any = { + filter: 'subject', + hasFacets: true, + operators: [], + openByDefault: true, + pageSize: 5, + type: 'text', + }; + + beforeEach(waitForAsync(() => { + + searchServiceStub = { + getSearchLink(): string { + return '/search'; + }, + }; + searchConfigurationStub = { + getSearchConfigurationFor( scope?: string, configurationName?: string ): Observable> { + const config = new SearchConfig(); + config.filters = [firstFilterConfig, secondFilterConfig]; + return createSuccessfulRemoteDataObject$(config); + }, + }; + + router = { + navigate: jasmine.createSpy('navigate'), + }; + + TestBed.configureTestingModule({ + imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, RouterTestingModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), SearchSectionComponent], + providers: [SearchSectionComponent, + { provide: SearchService, useValue: searchServiceStub }, + { provide: SearchConfigurationService, useValue: searchConfigurationStub }, + { provide: Router, useValue: router }], + schemas: [NO_ERRORS_SCHEMA], + }).overrideComponent(SearchSectionComponent, { remove: { imports: [ThemedSearchFormComponent] } }).compileComponents(); + + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SearchSectionComponent); + component = fixture.componentInstance; + + component.sectionId = 'publications'; + component.searchSection = { + discoveryConfigurationName: 'publication', + componentType: 'search', + style: 'col-md-8', + searchType: 'advanced', + initialStatements: 3, + displayTitle: false, + }; + + fixture.detectChanges(); + }); + + it('should create SearchSetionComponent', inject([SearchSectionComponent], (comp: SearchSectionComponent) => { + expect(comp).toBeDefined(); + })); + + it('should create an empty form with three rows', () => { + const formRows = fixture.debugElement.queryAll(By.css('[data-test="form-row"]')); + expect(formRows.length).toEqual(3); + + for (const formRow of formRows) { + const filterSelect = formRow.query(By.css('#filter')); + expect(filterSelect).not.toBeNull(); + const filterOptions = filterSelect.children; + expect(filterOptions.length).toEqual(3); + expect(filterOptions.map((el) => el.nativeElement.value)).toEqual(['all','author','subject']); + + const queryInput = formRow.query(By.css('#query')); + expect(queryInput).not.toBeNull(); + expect(queryInput.nativeElement.value).toEqual(''); + + const isLastRow = formRows.indexOf(formRow) === 2; + if ( isLastRow ) { + expect(formRow.query(By.css('#addButton'))).not.toBeNull(); + expect(formRow.query(By.css('#operation'))).toBeNull(); + } else { + const operationSelect = formRow.query(By.css('#operation')); + expect(operationSelect).not.toBeNull(); + const operationOptions = operationSelect.children; + expect(operationOptions.length).toEqual(3); + expect(operationOptions.map((el) => el.nativeElement.value)).toEqual(['AND','OR','NOT']); + expect(formRow.query(By.css('#addButton'))).toBeNull(); + } + } + + expect(fixture.debugElement.queryAll(By.css('#resetButton'))).not.toBeNull(); + expect(fixture.debugElement.queryAll(By.css('#searchButton'))).not.toBeNull(); + }); + + describe('when you click on the add button', () => { + beforeEach(fakeAsync(() => { + fixture.debugElement.query(By.css('#addButton')).nativeElement.click(); + tick(); + fixture.detectChanges(); + })); + + it('should add a row in the form', () => { + const formRows = fixture.debugElement.queryAll(By.css('[data-test="form-row"]')); + expect(formRows.length).toEqual(4); + }); + }); + + describe('when you click on the reset button', () => { + + beforeEach(() => { + const firstFormRow = fixture.debugElement.queryAll(By.css('[data-test="form-row"]'))[0]; + const filterSelect = firstFormRow.query(By.css('#filter')); + filterSelect.nativeElement.value = 'author'; + const queryInput = firstFormRow.query(By.css('#query')); + queryInput.nativeElement.value = 'Adam'; + fixture.detectChanges(); + }); + + beforeEach(fakeAsync(() => { + fixture.debugElement.query(By.css('#resetButton')).nativeElement.click(); + tick(); + fixture.detectChanges(); + })); + + it('should reset the form', () => { + const formRows = fixture.debugElement.queryAll(By.css('[data-test="form-row"]')); + expect(formRows.length).toEqual(3); + const firstFormRow = fixture.debugElement.queryAll(By.css('[data-test="form-row"]'))[0]; + const filterSelect = firstFormRow.query(By.css('#filter')); + expect(filterSelect.nativeElement.value).toEqual('all'); + const queryInput = firstFormRow.query(By.css('#query')); + expect(queryInput.nativeElement.value).toEqual(''); + }); + }); + + describe('when you click on the search button', () => { + + beforeEach(() => { + const firstFormRow = fixture.debugElement.queryAll(By.css('[data-test="form-row"]'))[0]; + const filterSelect = firstFormRow.query(By.css('#filter')).nativeElement; + filterSelect.value = 'author'; + filterSelect.dispatchEvent(new Event('change')); + const firstQueryInput = firstFormRow.query(By.css('#query')).nativeElement; + firstQueryInput.value = 'Adam'; + firstQueryInput.dispatchEvent(new Event('input')); + const operationInput = firstFormRow.query(By.css('#operation')).nativeElement; + operationInput.value = 'OR'; + operationInput.dispatchEvent(new Event('change')); + const secondFormRow = fixture.debugElement.queryAll(By.css('[data-test="form-row"]'))[1]; + + const secondQueryInput = secondFormRow.query(By.css('#query')).nativeElement; + secondQueryInput.value = 'test'; + secondQueryInput.dispatchEvent(new Event('input')); + fixture.detectChanges(); + }); + + beforeEach(fakeAsync(() => { + fixture.debugElement.query(By.css('#searchButton')).nativeElement.click(); + tick(); + fixture.detectChanges(); + })); + + it('should redirect to the search page with the composed query', () => { + expect(router.navigate).toHaveBeenCalledWith(['/search'], { + queryParams: { page: 1, configuration: 'publication', query: 'author:(Adam) OR (test)' }, + }); + }); + }); + + describe('when basic search is configured', () => { + beforeEach(() => { + fixture = TestBed.createComponent(SearchSectionComponent); + component = fixture.componentInstance; + + component.sectionId = 'publications'; + component.searchSection = { + discoveryConfigurationName: 'publication', + componentType: 'search', + style: 'col-md-8', + searchType: 'basic', + initialStatements: 3, + displayTitle: false, + }; + + fixture.detectChanges(); + }); + + it('should display basic search form', () => { + expect(fixture.debugElement.query(By.css('ds-search-form'))) + .toBeTruthy(); + }); + + }); + +}); diff --git a/src/app/shared/explore/section-component/search-section/search-section.component.ts b/src/app/shared/explore/section-component/search-section/search-section.component.ts new file mode 100644 index 00000000000..3c62eb6d215 --- /dev/null +++ b/src/app/shared/explore/section-component/search-section/search-section.component.ts @@ -0,0 +1,160 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + Input, + OnInit, +} from '@angular/core'; +import { + FormArray, + FormBuilder, + FormGroup, + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; +import { Router } from '@angular/router'; +import { SearchSection } from '@dspace/core/layout/models/section.model'; +import { getFirstSucceededRemoteDataPayload } from '@dspace/core/shared/operators'; +import { SearchConfig } from '@dspace/core/shared/search/search-filters/search-config.model'; +import { TranslateModule } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { SearchService } from '../../../search/search.service'; +import { SearchConfigurationService } from '../../../search/search-configuration.service'; +import { ThemedSearchFormComponent } from '../../../search-form/themed-search-form.component'; + +/** + * Component representing the Search component section. + */ +@Component({ + selector: 'ds-base-search-section', + templateUrl: './search-section.component.html', + imports: [ + AsyncPipe, + FormsModule, + ReactiveFormsModule, + ThemedSearchFormComponent, + TranslateModule, + ], +}) +export class SearchSectionComponent implements OnInit { + + @Input() + sectionId: string; + + @Input() + searchSection: SearchSection; + + // The search form + searchForm: FormGroup; + + filters: Observable; + + allFilter = 'all'; + + operations = ['AND', 'OR', 'NOT']; + + constructor(private formBuilder: FormBuilder, + private router: Router, + private searchService: SearchService, + private searchConfigurationService: SearchConfigurationService, + ) { + + } + + get queryArray(): FormArray { + return this.searchForm.get('queryArray') as FormArray; + } + + ngOnInit() { + + this.filters = this.searchConfigurationService.getSearchConfigurationFor(null, this.searchSection.discoveryConfigurationName).pipe( + getFirstSucceededRemoteDataPayload(), + map((searchFilterConfig: SearchConfig) => { + return [this.allFilter].concat(searchFilterConfig.filters + .filter((filterConfig) => !filterConfig.filter.startsWith('graph')) + .map((filterConfig) => filterConfig.filter)); + }), + ); + + this.searchForm = this.formBuilder.group(({ + queryArray: this.formBuilder.array([]), + })); + + const statements = this.searchSection.initialStatements ? this.searchSection.initialStatements : 3; + for (let i = 0; i < statements; i++) { + this.addQueryStatement(); + } + } + + /** + * Navigate to the search page with the composed query. + * @param data the query statements + */ + onSubmit(data: { queryArray: QueryStatement[] }) { + const query = this.composeQuery(data.queryArray); + const configurationName = this.searchSection.discoveryConfigurationName; + this.router.navigate([this.searchService.getSearchLink()], { + queryParams: { + page: 1, + configuration: configurationName, + query: query, + }, + }); + } + + /** + * Reset the form. + */ + onReset() { + this.queryArray.controls.splice(0, this.queryArray.controls.length); + const statements = this.searchSection.initialStatements ? this.searchSection.initialStatements : 3; + for (let i = 0; i < statements; i++) { + this.addQueryStatement(); + } + } + + /** + * Initialize the form group. + */ + createFormGroup(): FormGroup { + return this.formBuilder.group({ + filter: this.allFilter, + query: '', + operation: this.operations[0], + }); + } + + addQueryStatement(): void { + this.queryArray.push(this.createFormGroup()); + } + + /** + * Compose the search query starting from the user input. + * + * @param statements the query statements entered by the user + */ + composeQuery(statements: QueryStatement[]): string { + let query = ''; + + for (const statement of statements) { + if (statement.query !== '') { + const statementFilter = statement.filter !== this.allFilter ? statement.filter + ':' : ''; + query = query + ' ' + statementFilter + '(' + statement.query + ') ' + statement.operation; + } + } + + // Remove last operation + const lastOperationIndex = query.lastIndexOf(' '); + return query.substring(0, lastOperationIndex).trim(); + } +} + +/** + * Interface related to the form model. + */ +interface QueryStatement { + filter: string; + query: string; + operation: string; +} diff --git a/src/app/shared/explore/section-component/search-section/themed-search-section.component.ts b/src/app/shared/explore/section-component/search-section/themed-search-section.component.ts new file mode 100644 index 00000000000..727cffdb3a6 --- /dev/null +++ b/src/app/shared/explore/section-component/search-section/themed-search-section.component.ts @@ -0,0 +1,37 @@ +import { + Component, + Input, +} from '@angular/core'; +import { SearchSection } from '@dspace/core/layout/models/section.model'; + +import { ThemedComponent } from '../../../theme-support/themed.component'; +import { SearchSectionComponent } from './search-section.component'; + +@Component({ + selector: 'ds-search-section', + styleUrls: [], + templateUrl: '../../../theme-support/themed.component.html', +}) +export class ThemedSearchSectionComponent extends ThemedComponent { + + @Input() + sectionId: string; + + @Input() + searchSection: SearchSection; + + protected inAndOutputNames: (keyof SearchSectionComponent & keyof this)[] = ['sectionId', 'searchSection']; + + protected getComponentName(): string { + return 'SearchSectionComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../../themes/${themeName}/app/shared/explore/section-component/search-section/search-section.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./search-section.component`); + } + +} diff --git a/src/app/shared/explore/section-component/text-section/text-section.component.html b/src/app/shared/explore/section-component/text-section/text-section.component.html new file mode 100644 index 00000000000..d605b9f92fc --- /dev/null +++ b/src/app/shared/explore/section-component/text-section/text-section.component.html @@ -0,0 +1,26 @@ + +@switch (textRowSection?.contentType) { + @case ('image') { +
+ } + @case ('text-key') { +
{{ ('explore.text-section.' + textRowSection.content) | translate }}
+ } + @case ('text-raw') { +
{{ textRowSection.content }}
+ } + @case ('text-metadata') { +
+ +
+ } + @case ('custom') { +
+ +
+ } +} + + + diff --git a/src/app/shared/explore/section-component/text-section/text-section.component.scss b/src/app/shared/explore/section-component/text-section/text-section.component.scss new file mode 100644 index 00000000000..bd109285403 --- /dev/null +++ b/src/app/shared/explore/section-component/text-section/text-section.component.scss @@ -0,0 +1,14 @@ +:host ::ng-deep { + + .text-section-home-news { + a { + color: var(--ds-home-news-link-color); + + &:hover { + color: var(--ds-home-news-link-hover-color); + } + } + } +} + + diff --git a/src/app/shared/explore/section-component/text-section/text-section.component.spec.ts b/src/app/shared/explore/section-component/text-section/text-section.component.spec.ts new file mode 100644 index 00000000000..17c4d89a91b --- /dev/null +++ b/src/app/shared/explore/section-component/text-section/text-section.component.spec.ts @@ -0,0 +1,72 @@ +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { LocaleService } from '@dspace/core/locale/locale.service'; +import { Site } from '@dspace/core/shared/site.model'; +import { of } from 'rxjs'; +import { SearchService } from 'src/app/shared/search/search.service'; + +import { MarkdownViewerComponent } from '../../../markdown-viewer/markdown-viewer.component'; +import { TextSectionComponent } from './text-section.component'; + +describe('TextSectionComponent', () => { + let component: TextSectionComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TextSectionComponent], + providers: [ + { provide: SearchService, useValue: {} }, + { provide: LocaleService, useValue: { getCurrentLanguageCode: () => of('en') } }, + ], + }) + .overrideComponent(TextSectionComponent, { remove: { imports: [MarkdownViewerComponent] } }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TextSectionComponent); + component = fixture.componentInstance; + component.site = Object.assign(new Site(), { + id: 'test-site', + _links: { + self: { href: 'test-site-href' }, + }, + metadata: { + 'cms.homepage.footer': [ + { + language: 'en', + value: '1234', + }, + ], + 'dc.description': [ + { + language: 'en_US', + value: 'desc', + }, + ], + }, + }); + component.textRowSection = { + content: 'cms.homepage.footer', + contentType: 'text-metadata', + componentType: 'text-row', + style: '', + }; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + // FIXME: complete scenarios + it('should render text-metadata with ds-markdown-viewer', () => { + component.sectionId = 'site'; + fixture.detectChanges(); + const dsMarkdownViewer = fixture.debugElement.query(By.css('[data-test="ds-markdown-viewer"]')); + expect(dsMarkdownViewer).toBeTruthy(); + }); +}); diff --git a/src/app/shared/explore/section-component/text-section/text-section.component.ts b/src/app/shared/explore/section-component/text-section/text-section.component.ts new file mode 100644 index 00000000000..865bad51d96 --- /dev/null +++ b/src/app/shared/explore/section-component/text-section/text-section.component.ts @@ -0,0 +1,51 @@ +import { + AsyncPipe, + NgTemplateOutlet, +} from '@angular/common'; +import { + Component, + Input, +} from '@angular/core'; +import { TextRowSection } from '@dspace/core/layout/models/section.model'; +import { LocaleService } from '@dspace/core/locale/locale.service'; +import { Site } from '@dspace/core/shared/site.model'; +import { TranslateModule } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { MarkdownViewerComponent } from '../../../markdown-viewer/markdown-viewer.component'; + + +@Component({ + selector: 'ds-base-text-section', + templateUrl: './text-section.component.html', + styleUrls: ['./text-section.component.scss'], + imports: [ + AsyncPipe, + MarkdownViewerComponent, + NgTemplateOutlet, + TranslateModule, + ], +}) +export class TextSectionComponent { + + @Input() + sectionId: string; + + @Input() + textRowSection: TextRowSection; + + @Input() + site: Site; + + constructor( + private locale: LocaleService, + ) { + } + + metadataValue(content: string): Observable { + return this.locale.getCurrentLanguageCode().pipe( + map(language => this.site?.firstMetadataValue(content, { language }) ?? ''), + ); + } +} diff --git a/src/app/shared/explore/section-component/text-section/themed-text-section.component.ts b/src/app/shared/explore/section-component/text-section/themed-text-section.component.ts new file mode 100644 index 00000000000..5bfa90051eb --- /dev/null +++ b/src/app/shared/explore/section-component/text-section/themed-text-section.component.ts @@ -0,0 +1,41 @@ +import { + Component, + Input, +} from '@angular/core'; +import { TextRowSection } from '@dspace/core/layout/models/section.model'; +import { Site } from '@dspace/core/shared/site.model'; + +import { ThemedComponent } from '../../../theme-support/themed.component'; +import { TextSectionComponent } from './text-section.component'; + +@Component({ + selector: 'ds-text-section', + styleUrls: [], + templateUrl: '../../../theme-support/themed.component.html', +}) +export class ThemedTextSectionComponent extends ThemedComponent { + + @Input() + sectionId: string; + + @Input() + textRowSection: TextRowSection; + + @Input() + site: Site; + + protected inAndOutputNames: (keyof TextSectionComponent & keyof this)[] = ['sectionId', 'textRowSection', 'site']; + + protected getComponentName(): string { + return 'TextSectionComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../../themes/${themeName}/app/shared/explore/section-component/text-section/text-section.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./text-section.component`); + } + +} diff --git a/src/app/shared/explore/section-component/top-section/themed-top-section.component.ts b/src/app/shared/explore/section-component/top-section/themed-top-section.component.ts new file mode 100644 index 00000000000..a3dd704daad --- /dev/null +++ b/src/app/shared/explore/section-component/top-section/themed-top-section.component.ts @@ -0,0 +1,41 @@ +import { + Component, + Input, +} from '@angular/core'; +import { TopSection } from '@dspace/core/layout/models/section.model'; +import { Context } from '@dspace/core/shared/context.model'; + +import { ThemedComponent } from '../../../theme-support/themed.component'; +import { TopSectionComponent } from './top-section.component'; + +@Component({ + selector: 'ds-top-section', + styleUrls: [], + templateUrl: '../../../theme-support/themed.component.html', +}) +export class ThemedTopSectionComponent extends ThemedComponent { + + @Input() + sectionId: string; + + @Input() + topSection: TopSection; + + @Input() + context: Context; + + protected inAndOutputNames: (keyof TopSectionComponent & keyof this)[] = ['sectionId', 'topSection', 'context']; + + protected getComponentName(): string { + return 'TopSectionComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../../themes/${themeName}/app/shared/explore/section-component/top-section/top-section.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./top-section.component`); + } + +} diff --git a/src/app/shared/explore/section-component/top-section/top-section.component.html b/src/app/shared/explore/section-component/top-section/top-section.component.html new file mode 100644 index 00000000000..5f0daba1b46 --- /dev/null +++ b/src/app/shared/explore/section-component/top-section/top-section.component.html @@ -0,0 +1,17 @@ +
+
+ @if (topSection.titleKey) { +
{{ 'explore.index.' + topSection.titleKey | translate }}
+ } + @if (!topSection.titleKey) { +
{{ 'explore.index.' + topSection.sortField | translate }}
+ } +
+ +
+
+
diff --git a/src/app/shared/explore/section-component/top-section/top-section.component.spec.ts b/src/app/shared/explore/section-component/top-section/top-section.component.spec.ts new file mode 100644 index 00000000000..696897bbddf --- /dev/null +++ b/src/app/shared/explore/section-component/top-section/top-section.component.spec.ts @@ -0,0 +1,157 @@ +import { CommonModule } from '@angular/common'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + inject, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; +import { + BrowserModule, + By, +} from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TopSectionTemplateType } from '@dspace/core/layout/models/section.model'; +import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; +import { SearchResult } from '@dspace/core/shared/search/models/search-result.model'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { SearchService } from 'src/app/shared/search/search.service'; + +import { ThemedBrowseMostElementsComponent } from '../../../browse-most-elements/themed-browse-most-elements.component'; +import { TopSectionComponent } from './top-section.component'; + +describe('TopSectionComponent', () => { + let component: TopSectionComponent; + let fixture: ComponentFixture; + + let searchServiceStub: any; + + const firstSearchResult = Object.assign(new SearchResult(), { + _embedded: { + indexableObject: Object.assign(new DSpaceObject(), { + id: 'd317835d-7b06-4219-91e2-1191900cb897', + uuid: 'd317835d-7b06-4219-91e2-1191900cb897', + name: 'My first publication', + metadata: { + 'dspace.entity.type': [ + { value: 'Publication' }, + ], + }, + }), + }, + }); + + const secondSearchResult = Object.assign(new SearchResult(), { + _embedded: { + indexableObject: Object.assign(new DSpaceObject(), { + id: '0c34d491-b5ed-4a78-8b29-83d0bad80e5a', + uuid: '0c34d491-b5ed-4a78-8b29-83d0bad80e5a', + name: 'This is a publication', + }), + }, + }); + + beforeEach(waitForAsync(() => { + searchServiceStub = jasmine.createSpyObj('SearchService', { + search: jasmine.createSpy('search'), + getSearchLink: jasmine.createSpy('getSearchLink'), + }); + + TestBed.configureTestingModule({ + imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, RouterTestingModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), TopSectionComponent], + providers: [TopSectionComponent, + { provide: SearchService, useValue: searchServiceStub }], + schemas: [NO_ERRORS_SCHEMA], + }).overrideComponent(TopSectionComponent, { remove: { imports: [ThemedBrowseMostElementsComponent] } }).compileComponents(); + + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TopSectionComponent); + component = fixture.componentInstance; + searchServiceStub.search.and.returnValue(createSuccessfulRemoteDataObject$({ page: [firstSearchResult, secondSearchResult] })); + searchServiceStub.getSearchLink.and.returnValue('/search'); + component.sectionId = 'publications'; + component.topSection = { + discoveryConfigurationName: 'publication', + componentType: 'top', + style: 'col-md-6', + order: 'desc', + sortField: 'dc.date.accessioned', + numberOfItems: 5, + titleKey: undefined, + showThumbnails: false, + template: TopSectionTemplateType.DEFAULT, + }; + + fixture.detectChanges(); + }); + + it('should create TopSectionComponent', inject([TopSectionComponent], (comp: TopSectionComponent) => { + expect(comp).toBeDefined(); + })); + + // it('should create a top section with two entries', () => { + // + // const cardElement = fixture.debugElement.query(By.css('.card.mb-4')); + // expect(cardElement).not.toBeNull(); + // expect(cardElement.query(By.css('.card-header')).nativeElement.textContent).toEqual('explore.index.dc.date.accessioned'); + // + // const links = cardElement.queryAll(By.css('a')); + // expect(links.length).toEqual(2); + // expect(links[0].nativeElement.href).toContain('/entities/publication/d317835d-7b06-4219-91e2-1191900cb897'); + // expect(links[0].nativeElement.textContent).toEqual('My first publication'); + // expect(links[1].nativeElement.href).toContain('items/0c34d491-b5ed-4a78-8b29-83d0bad80e5a'); + // expect(links[1].nativeElement.textContent).toEqual('This is a publication'); + // + // }); + + describe('Top section with title key defined', () => { + beforeEach(() => { + fixture = TestBed.createComponent(TopSectionComponent); + component = fixture.componentInstance; + searchServiceStub.search.and.returnValue(createSuccessfulRemoteDataObject$({ page: [firstSearchResult, secondSearchResult] })); + searchServiceStub.getSearchLink.and.returnValue('/search'); + component.sectionId = 'publications'; + component.topSection = { + discoveryConfigurationName: 'publication', + componentType: 'top', + style: 'col-md-6', + order: 'desc', + sortField: 'dc.date.foo', + numberOfItems: 5, + titleKey: 'lastPublications', + showThumbnails: false, + template: TopSectionTemplateType.DEFAULT, + }; + + fixture.detectChanges(); + }); + + it('should create a top section with title', () => { + + const cardElement = fixture.debugElement.query(By.css('.card.mb-4')); + expect(cardElement).not.toBeNull(); + expect(cardElement.query(By.css('.card-header')).nativeElement.textContent).toEqual('explore.index.lastPublications'); + + + }); + }); + +}); diff --git a/src/app/shared/explore/section-component/top-section/top-section.component.ts b/src/app/shared/explore/section-component/top-section/top-section.component.ts new file mode 100644 index 00000000000..70f38e40d91 --- /dev/null +++ b/src/app/shared/explore/section-component/top-section/top-section.component.ts @@ -0,0 +1,61 @@ + +import { + Component, + Input, + OnInit, +} from '@angular/core'; +import { + SortDirection, + SortOptions, +} from '@dspace/core/cache/models/sort-options.model'; +import { TopSection } from '@dspace/core/layout/models/section.model'; +import { PaginationComponentOptions } from '@dspace/core/pagination/pagination-component-options.model'; +import { Context } from '@dspace/core/shared/context.model'; +import { PaginatedSearchOptions } from '@dspace/core/shared/search/models/paginated-search-options.model'; +import { TranslateModule } from '@ngx-translate/core'; + +import { ThemedBrowseMostElementsComponent } from '../../../browse-most-elements/themed-browse-most-elements.component'; + + +/** + * Component representing the Top component section. + */ +@Component({ + selector: 'ds-base-top-section', + templateUrl: './top-section.component.html', + imports: [ + ThemedBrowseMostElementsComponent, + TranslateModule, + ], +}) +export class TopSectionComponent implements OnInit { + + @Input() + sectionId: string; + + @Input() + topSection: TopSection; + + @Input() + context: Context = Context.BrowseMostElements; + + paginatedSearchOptions: PaginatedSearchOptions; + + ngOnInit() { + const order = this.topSection.order; + const numberOfItems = this.topSection.numberOfItems; + const sortDirection = order && order.toUpperCase() === 'ASC' ? SortDirection.ASC : SortDirection.DESC; + const pagination: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'search-object-pagination', + pageSize: numberOfItems, + currentPage: 1, + }); + + this.paginatedSearchOptions = new PaginatedSearchOptions({ + configuration: this.topSection.discoveryConfigurationName, + pagination: pagination, + sort: new SortOptions(this.topSection.sortField, sortDirection), + }); + } + +} diff --git a/src/app/shared/search-form/search-form.component.ts b/src/app/shared/search-form/search-form.component.ts index 0bd6f2d8ebc..b34c1197684 100644 --- a/src/app/shared/search-form/search-form.component.ts +++ b/src/app/shared/search-form/search-form.component.ts @@ -61,8 +61,12 @@ export class SearchFormComponent implements OnChanges { /** * The currently selected scope object's UUID */ - @Input() - scope = ''; + @Input() scope = ''; + + /** + * Discovery configuration to be used in search + */ + @Input() configuration: string; /** * Hides the scope in the url, this can be useful when you hardcode the scope in another way @@ -128,6 +132,11 @@ export class SearchFormComponent implements OnChanges { if (isNotEmpty(this.scope)) { data = Object.assign(data, { scope: this.scope }); } + + if (isNotEmpty(this.configuration)) { + data = { ...data, configuration: this.configuration }; + } + this.updateSearch(data); this.submitSearch.emit(data); } diff --git a/src/app/shared/search-form/themed-search-form.component.ts b/src/app/shared/search-form/themed-search-form.component.ts index e6db53c3258..3d7b8089796 100644 --- a/src/app/shared/search-form/themed-search-form.component.ts +++ b/src/app/shared/search-form/themed-search-form.component.ts @@ -35,6 +35,8 @@ export class ThemedSearchFormComponent extends ThemedComponent = new EventEmitter(); protected inAndOutputNames: (keyof SearchFormComponent & keyof this)[] = [ @@ -48,6 +50,7 @@ export class ThemedSearchFormComponent extends ThemedComponent>} The found filter configuration */ - getConfig(scope?: string, configurationName?: string): Observable> { - const href$ = this.halService.getEndpoint(this.facetLinkPathPrefix).pipe( - map((url: string) => this.getConfigUrl(url, scope, configurationName)), + private getFilterConfigByLink(link: string, scope?: string, configurationName?: string): Observable> { + const href$ = this.halService.getEndpoint(link).pipe( + map((url: string) => { + const args: string[] = []; + + if (isNotEmpty(scope)) { + args.push(`scope=${scope}`); + } + + if (isNotEmpty(configurationName)) { + args.push(`configuration=${configurationName}`); + } + + if (isNotEmpty(args)) { + url = new URLCombiner(url, `?${args.join('&')}`).toString(); + } + + return url; + }), ); href$.pipe(take(1)).subscribe((url: string) => { @@ -571,6 +594,25 @@ export class SearchConfigurationService implements OnDestroy { ); } + /** + * Request the filter configuration for a given scope or the whole repository + * @param {string} scope UUID of the object for which config the filter config is requested, when no scope is provided the configuration for the whole repository is loaded + * @param {string} configurationName the name of the configuration + * @returns {Observable>} The found filter configuration + */ + getConfig(scope?: string, configurationName?: string): Observable> { + return this.getFilterConfigByLink(this.facetLinkPathPrefix, scope, configurationName); + } + + /** + * Request the filter configuration for a given scope or the whole repository + * @param {string} scope UUID of the object for which config the filter config is requested, when no scope is provided the configuration for the whole repository is loaded + * @param {string} configurationName the name of the configuration + * @returns {Observable>} The found filter configuration + */ + searchFacets(scope?: string, configurationName?: string): Observable> { + return this.getFilterConfigByLink(this.searchFacetLinkPath, scope, configurationName); + } /** * Calculates the {@link Params} of the search after removing a filter with a certain value and resets the page number. * diff --git a/src/app/shared/search/search-filters/search-filter.service.ts b/src/app/shared/search/search-filters/search-filter.service.ts index 52141379f8a..0833f87154a 100644 --- a/src/app/shared/search/search-filters/search-filter.service.ts +++ b/src/app/shared/search/search-filters/search-filter.service.ts @@ -23,6 +23,7 @@ import { Store, } from '@ngrx/store'; import { + combineLatest, combineLatest as observableCombineLatest, Observable, of, @@ -190,6 +191,27 @@ export class SearchFilterService { return `${new EmphasizePipe().transform(facet.value, query)} (${facet.count})`; } + /** + * Requests the active filter values set for a given filter + * @param {SearchFilterConfig} filterConfig The configuration for which the filters are active + * @returns {Observable} Emits the active filters for the given filter configuration + */ + getSelectedValuesForFilter(filterConfig: SearchFilterConfig): Observable { + const values$ = this.routeService.getQueryParameterValues(filterConfig.paramName); + const prefixValues$ = this.routeService.getQueryParamsWithPrefix(filterConfig.paramName + '.').pipe( + map((params: Params) => [].concat(...Object.values(params))), + ); + return combineLatest([values$, prefixValues$]).pipe( + map(([values, prefixValues]) => { + if (isNotEmpty(values)) { + return values; + } + return prefixValues; + }, + ), + ); + } + /** * Checks if the state of a given filter is currently collapsed or not * @param {string} filterName The filtername for which the collapsed state is checked diff --git a/src/app/shared/search/search.utils.ts b/src/app/shared/search/search.utils.ts index f5caa0a26db..7cdeaa5b65e 100644 --- a/src/app/shared/search/search.utils.ts +++ b/src/app/shared/search/search.utils.ts @@ -23,6 +23,31 @@ export function getFacetValueForType(facetValue: FacetValue, searchFilterConfig: return addOperatorToFilterValue(facetValue.value, 'equals'); } +/** + * Get a facet's value by matching the label with its parameter in the search href, this will include the operator of the facet value + * If the {@link FacetValue} doesn't contain a search link, its raw label will be returned as a fallback + * @param facetValue + * @param searchFilterConfig + */ +export function getFacetValueForTypeAndLabel(facetValue: FacetValue, searchFilterConfig: SearchFilterConfig): string { + return _createValue(searchFilterConfig.paramName, facetValue._links, facetValue.label, facetValue.authorityKey); +} + +function _createValue(paramName: string, facetValueLinks, value, authorityKey) { + const regex = new RegExp(`[?|&]${escapeRegExp(encodeURIComponent(paramName))}=(${escapeRegExp(encodeURIComponent(value))}[^&]*)`, 'g'); + if (isNotEmpty(facetValueLinks)) { + const values = regex.exec(facetValueLinks.search.href); + if (isNotEmpty(values)) { + return decodeURIComponent(values[1]); + } + } + if (authorityKey) { + return addOperatorToFilterValue(authorityKey, 'authority'); + } + + return addOperatorToFilterValue(value, 'equals'); +} + /** * Escape a string to be used in a JS regular expression * diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index fd703d4d024..7d5e11d9918 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -375,6 +375,7 @@ export class DefaultAppConfig implements AppConfig { pageSize: 5, }, showDiscoverFilters: false, + enableDynamicLayout: true, }; // Item Config diff --git a/src/config/homepage-config.interface.ts b/src/config/homepage-config.interface.ts index 47d75e60743..16cc3d20414 100644 --- a/src/config/homepage-config.interface.ts +++ b/src/config/homepage-config.interface.ts @@ -28,4 +28,8 @@ export interface HomeConfig extends Config { * Enable or disable the Discover filters on the homepage */ showDiscoverFilters: boolean; + /** + * Whether to enable dynamic home page rendering. + */ + enableDynamicLayout: boolean; } diff --git a/src/config/layout-config.interfaces.ts b/src/config/layout-config.interfaces.ts index 709292a0068..a440acf8f60 100644 --- a/src/config/layout-config.interfaces.ts +++ b/src/config/layout-config.interfaces.ts @@ -67,6 +67,9 @@ export interface LayoutConfig extends Config { * Each entry defines how entities of a specific type should be visually represented with icons and styles. */ authorityRef: AuthorityRefConfig[]; + /** + * Whether to show download links as attachments. + */ showDownloadLinkAsAttachment: boolean; /** * Configuration for advanced attachment rendering features. diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index 0ae82e957a1..329a0247247 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -282,6 +282,7 @@ export const environment: BuildConfig = { pageSize: 5, }, showDiscoverFilters: false, + enableDynamicLayout: true, }, item: { edit: { diff --git a/src/themes/custom/app/home-page/home-page.component.ts b/src/themes/custom/app/home-page/home-page.component.ts index 3d8f9f72ae9..9e125948ace 100644 --- a/src/themes/custom/app/home-page/home-page.component.ts +++ b/src/themes/custom/app/home-page/home-page.component.ts @@ -13,6 +13,12 @@ import { RecentItemListComponent } from '../../../../app/home-page/recent-item-l import { ThemedTopLevelCommunityListComponent } from '../../../../app/home-page/top-level-community-list/themed-top-level-community-list.component'; import { SuggestionsPopupComponent } from '../../../../app/notifications/suggestions/popup/suggestions-popup.component'; import { ThemedConfigurationSearchPageComponent } from '../../../../app/search-page/themed-configuration-search-page.component'; +import { ThemedBrowseSectionComponent } from '../../../../app/shared/explore/section-component/browse-section/themed-browse-section.component'; +import { ThemedCountersSectionComponent } from '../../../../app/shared/explore/section-component/counters-section/themed-counters-section.component'; +import { ThemedFacetSectionComponent } from '../../../../app/shared/explore/section-component/facet-section/themed-facet-section.component'; +import { ThemedSearchSectionComponent } from '../../../../app/shared/explore/section-component/search-section/themed-search-section.component'; +import { ThemedTextSectionComponent } from '../../../../app/shared/explore/section-component/text-section/themed-text-section.component'; +import { ThemedTopSectionComponent } from '../../../../app/shared/explore/section-component/top-section/themed-top-section.component'; import { ThemedSearchFormComponent } from '../../../../app/shared/search-form/themed-search-form.component'; @Component({ @@ -28,10 +34,16 @@ import { ThemedSearchFormComponent } from '../../../../app/shared/search-form/th NgTemplateOutlet, RecentItemListComponent, SuggestionsPopupComponent, + ThemedBrowseSectionComponent, ThemedConfigurationSearchPageComponent, + ThemedCountersSectionComponent, + ThemedFacetSectionComponent, ThemedHomeNewsComponent, ThemedSearchFormComponent, + ThemedSearchSectionComponent, + ThemedTextSectionComponent, ThemedTopLevelCommunityListComponent, + ThemedTopSectionComponent, TranslateModule, ], }) From 6a4efac607dc7f99f07c14f7146508a325bf3f26 Mon Sep 17 00:00:00 2001 From: FrancescoMolinaro Date: Fri, 17 Jul 2026 13:03:20 +0200 Subject: [PATCH 2/7] [DURACOM-508] port explore components --- src/app/app-routes.ts | 4 + src/app/app.menus.ts | 2 + .../explore-i18n-breadcrumb.resolver.ts | 32 ++++ .../explore-page/explore-page.component.html | 43 +++++ .../explore-page.component.spec.ts | 156 ++++++++++++++++++ .../explore-page/explore-page.component.ts | 84 ++++++++++ src/app/explore-page/explore-routes.ts | 15 ++ .../menu/providers/explore.menu.spec.ts | 39 +++++ src/app/shared/menu/providers/explore.menu.ts | 57 +++++++ src/assets/i18n/en.json5 | 40 +++++ 10 files changed, 472 insertions(+) create mode 100644 src/app/explore-page/explore-i18n-breadcrumb.resolver.ts create mode 100644 src/app/explore-page/explore-page.component.html create mode 100644 src/app/explore-page/explore-page.component.spec.ts create mode 100644 src/app/explore-page/explore-page.component.ts create mode 100644 src/app/explore-page/explore-routes.ts create mode 100644 src/app/shared/menu/providers/explore.menu.spec.ts create mode 100644 src/app/shared/menu/providers/explore.menu.ts diff --git a/src/app/app-routes.ts b/src/app/app-routes.ts index 6190ed263a3..a5cc4c6e614 100644 --- a/src/app/app-routes.ts +++ b/src/app/app-routes.ts @@ -302,6 +302,10 @@ export const APP_ROUTES: Route[] = [ .then((m) => m.ROUTES), canActivate: [notAuthenticatedGuard], }, + { + path: 'explore', + loadChildren: () => import('./explore-page/explore-routes').then((m) => m.ROUTES), + }, { path: '**', pathMatch: 'full', component: ThemedPageNotFoundComponent, data: { title: '404.page-not-found' } }, ], }, diff --git a/src/app/app.menus.ts b/src/app/app.menus.ts index 9cec10f95e9..481afa13fd9 100644 --- a/src/app/app.menus.ts +++ b/src/app/app.menus.ts @@ -24,6 +24,7 @@ import { EditMenuProvider } from './shared/menu/providers/edit.menu'; import { EditCMSMetadataMenuProvider } from './shared/menu/providers/edit-cms-metadata.menu'; import { EditItemMenuProvider } from './shared/menu/providers/edit-item-details.menu'; import { EditUserAgreementMenuProvider } from './shared/menu/providers/edit-user-agreement.menu'; +import { ExploreMenuProvider } from './shared/menu/providers/explore.menu'; import { ExportMenuProvider } from './shared/menu/providers/export.menu'; import { HealthMenuProvider } from './shared/menu/providers/health.menu'; import { ImportMenuProvider } from './shared/menu/providers/import.menu'; @@ -60,6 +61,7 @@ export const MENUS = buildMenuStructure({ CommunityListMenuProvider, BrowseMenuProvider, StatisticsMenuProvider, + ExploreMenuProvider, ], [MenuID.ADMIN]: [ NewMenuProvider, diff --git a/src/app/explore-page/explore-i18n-breadcrumb.resolver.ts b/src/app/explore-page/explore-i18n-breadcrumb.resolver.ts new file mode 100644 index 00000000000..644586f504f --- /dev/null +++ b/src/app/explore-page/explore-i18n-breadcrumb.resolver.ts @@ -0,0 +1,32 @@ +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; +import { BreadcrumbConfig } from '@dspace/core/breadcrumbs/models/breadcrumb-config.model'; +import { currentPathFromSnapshot } from '@dspace/core/router/utils/route.utils'; + +import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service'; +import { hasNoValue } from '../utils/empty.util'; + + +/** + * This class resolves a BreadcrumbConfig object with an i18n key string for a route + * It adds the metadata field of the current explore page + */ +export const exploreI18nBreadcrumbResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + breadcrumbService: I18nBreadcrumbsService = inject(I18nBreadcrumbsService), +): BreadcrumbConfig => { + const extendedBreadcrumbKey = route.data.breadcrumbKey + '.' + route.params.id; + route.data = Object.assign({}, route.data, { breadcrumbKey: extendedBreadcrumbKey }); + + const key = route.data.breadcrumbKey; + if (hasNoValue(key)) { + throw new Error('You provided an i18nBreadcrumbResolver for url \"' + route.url + '\" but no breadcrumbKey in the route\'s data'); + } + const fullPath = currentPathFromSnapshot(route); + return { provider: breadcrumbService, key: key, url: fullPath }; +}; diff --git a/src/app/explore-page/explore-page.component.html b/src/app/explore-page/explore-page.component.html new file mode 100644 index 00000000000..20b0109d28d --- /dev/null +++ b/src/app/explore-page/explore-page.component.html @@ -0,0 +1,43 @@ +
+ @for (sectionComponents of ( sectionComponentRows | async ); track sectionComponents) { +
+ @for (sectionComponent of sectionComponents; track sectionComponent) { +
+ @switch (sectionComponent.componentType) { + @case ('top') { + + } + @case ('browse') { + + } + @case ('search') { + + } + @case ('facet') { + + } + @case ('text-row') { + + } + @case ('counters') { + + } + } +
+ } +
+ } +
diff --git a/src/app/explore-page/explore-page.component.spec.ts b/src/app/explore-page/explore-page.component.spec.ts new file mode 100644 index 00000000000..639b15e2ef7 --- /dev/null +++ b/src/app/explore-page/explore-page.component.spec.ts @@ -0,0 +1,156 @@ +import { CommonModule } from '@angular/common'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + inject, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; +import { + BrowserModule, + By, +} from '@angular/platform-browser'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { SectionDataService } from '@dspace/core/data/section-data.service'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { + Observable, + of, +} from 'rxjs'; + +import { RemoteData } from '../core/data/remote-data'; +import { + BrowseSection, + FacetSection, + SearchSection, + Section, + TopSection, + TopSectionTemplateType, +} from '../core/layout/models/section.model'; +import { ThemedBrowseSectionComponent } from '../shared/explore/section-component/browse-section/themed-browse-section.component'; +import { ThemedCountersSectionComponent } from '../shared/explore/section-component/counters-section/themed-counters-section.component'; +import { ThemedFacetSectionComponent } from '../shared/explore/section-component/facet-section/themed-facet-section.component'; +import { ThemedSearchSectionComponent } from '../shared/explore/section-component/search-section/themed-search-section.component'; +import { ThemedTextSectionComponent } from '../shared/explore/section-component/text-section/themed-text-section.component'; +import { ThemedTopSectionComponent } from '../shared/explore/section-component/top-section/themed-top-section.component'; +import { ExplorePageComponent } from './explore-page.component'; + +describe('ExploreComponent', () => { + let component: ExplorePageComponent; + let fixture: ComponentFixture; + + let sectionDataServiceStub: any; + let route: any; + + const browseComponent: BrowseSection = { + browseNames: ['rodept', 'author', 'title', 'type'], + componentType: 'browse', + style: 'col-md-4', + }; + + const topComponent: TopSection = { + discoveryConfigurationName: 'publication', + componentType: 'top', + style: 'col-md-6', + order: 'desc', + sortField: 'dc.date.accessioned', + numberOfItems: 5, + titleKey: 'lastPublications', + showThumbnails: false, + template: TopSectionTemplateType.DEFAULT, + }; + + const searchComponent: SearchSection = { + discoveryConfigurationName: 'publication', + componentType: 'search', + style: 'col-md-8', + searchType: 'advanced', + initialStatements: 3, + displayTitle: true, + }; + + const facetComponent: FacetSection = { + discoveryConfigurationName: 'publication', + componentType: 'facet', + style: 'col-md-12', + facetsPerRow: 4, + }; + + beforeEach(waitForAsync(() => { + + sectionDataServiceStub = { + findById(id: string): Observable> { + if (id === 'publications') { + const section = new Section(); + section.id = 'publications'; + section.componentRows = [[browseComponent, searchComponent], [topComponent], [facetComponent]]; + return createSuccessfulRemoteDataObject$(section); + } else { + return of(null); + } + }, + }; + + route = { + params: of({ id: 'publications' }), + }; + + TestBed.configureTestingModule({ + imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, ExplorePageComponent, BrowserModule, RouterTestingModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + ], + providers: [ExplorePageComponent, + { provide: SectionDataService, useValue: sectionDataServiceStub }, + { provide: ActivatedRoute, useValue: route }], + schemas: [NO_ERRORS_SCHEMA], + }).overrideComponent(ExplorePageComponent, { remove: { imports: [ThemedTopSectionComponent, ThemedBrowseSectionComponent, ThemedSearchSectionComponent, ThemedFacetSectionComponent, ThemedTextSectionComponent, ThemedCountersSectionComponent] } }).compileComponents(); + + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ExplorePageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create ExploreComponent', inject([ExplorePageComponent], (comp: ExplorePageComponent) => { + expect(comp).toBeDefined(); + })); + + it('should place the sections on three rows', () => { + const container = fixture.debugElement.query(By.css('.container')); + expect(container.children.length).toEqual(3); + + const firstRow = container.children[0]; + expect(firstRow.children.length).toEqual(2); + expect(firstRow.children[0].children[0].name).toEqual('ds-browse-section'); + expect(firstRow.children[1].children[0].name).toEqual('ds-search-section'); + + const secondRow = container.children[1]; + expect(secondRow.children.length).toEqual(1); + expect(secondRow.children[0].children[0].name).toEqual('ds-top-section'); + + const thirdRow = container.children[2]; + expect(thirdRow.children.length).toEqual(1); + expect(thirdRow.children[0].children[0].name).toEqual('ds-facet-section'); + + expect(component.sectionId).toEqual('publications'); + }); + +}); diff --git a/src/app/explore-page/explore-page.component.ts b/src/app/explore-page/explore-page.component.ts new file mode 100644 index 00000000000..aa7adfa4718 --- /dev/null +++ b/src/app/explore-page/explore-page.component.ts @@ -0,0 +1,84 @@ +import { + AsyncPipe, + NgClass, +} from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; +import { + ActivatedRoute, + Params, +} from '@angular/router'; +import { SectionDataService } from '@dspace/core/data/section-data.service'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { SectionComponent } from '../core/layout/models/section.model'; +import { getFirstSucceededRemoteDataPayload } from '../core/shared/operators'; +import { ThemedBrowseSectionComponent } from '../shared/explore/section-component/browse-section/themed-browse-section.component'; +import { ThemedCountersSectionComponent } from '../shared/explore/section-component/counters-section/themed-counters-section.component'; +import { ThemedFacetSectionComponent } from '../shared/explore/section-component/facet-section/themed-facet-section.component'; +import { ThemedSearchSectionComponent } from '../shared/explore/section-component/search-section/themed-search-section.component'; +import { ThemedTextSectionComponent } from '../shared/explore/section-component/text-section/themed-text-section.component'; +import { ThemedTopSectionComponent } from '../shared/explore/section-component/top-section/themed-top-section.component'; + +/** + * Component representing the explore section. + */ +@Component({ + selector: 'ds-explore', + templateUrl: './explore-page.component.html', + imports: [ + AsyncPipe, + NgClass, + ThemedBrowseSectionComponent, + ThemedCountersSectionComponent, + ThemedFacetSectionComponent, + ThemedSearchSectionComponent, + ThemedTextSectionComponent, + ThemedTopSectionComponent, + ], +}) +export class ExplorePageComponent implements OnInit { + + /** + * The id of the current section. + */ + sectionId: string; + + /** + * Resolved section components splitted in rows. + */ + sectionComponentRows: Observable; + + constructor( + private route: ActivatedRoute, + private sectionDataService: SectionDataService ) {} + + ngOnInit() { + this.route.params.subscribe((params) => this.setupSectionComponents(params)); + } + + /** + * Setup the section components of the explore page based on the section id. + * + * @param params the route params + */ + setupSectionComponents( params: Params ) { + this.sectionId = params.id; + this.sectionComponentRows = this.sectionDataService.findById(params.id ).pipe( + getFirstSucceededRemoteDataPayload(), + map ( (section) => section.componentRows), + ); + } + + /** + * Check if style contains 'col' or 'col-x' + * @param style the style of the cell (a list of classes separated by space) + */ + hasColClass(style) { + return style?.split(' ').filter((c) => (c === 'col' || c.startsWith('col-'))).length > 0; + } + +} diff --git a/src/app/explore-page/explore-routes.ts b/src/app/explore-page/explore-routes.ts new file mode 100644 index 00000000000..5a3ebe02ce7 --- /dev/null +++ b/src/app/explore-page/explore-routes.ts @@ -0,0 +1,15 @@ +import { Route } from '@angular/router'; + +import { endUserAgreementCurrentUserGuard } from '../core/end-user-agreement/end-user-agreement-current-user.guard'; +import { exploreI18nBreadcrumbResolver } from './explore-i18n-breadcrumb.resolver'; +import { ExplorePageComponent } from './explore-page.component'; + +export const ROUTES: Route[] = [ + { + path: ':id', + component: ExplorePageComponent, + resolve: { breadcrumb: exploreI18nBreadcrumbResolver }, + data: { title: 'explore.title', breadcrumbKey: 'explore', showSocialButtons: true }, + canActivate: [endUserAgreementCurrentUserGuard], + }, +]; diff --git a/src/app/shared/menu/providers/explore.menu.spec.ts b/src/app/shared/menu/providers/explore.menu.spec.ts new file mode 100644 index 00000000000..249534d876b --- /dev/null +++ b/src/app/shared/menu/providers/explore.menu.spec.ts @@ -0,0 +1,39 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { TestBed } from '@angular/core/testing'; +import { APP_CONFIG } from '@dspace/config/app-config.interface'; +import { SectionDataService } from '@dspace/core/data/section-data.service'; +import { createPaginatedList } from '@dspace/core/testing/utils.test'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; + +import { environment } from '../../../../environments/environment'; +import { ExploreMenuProvider } from './explore.menu'; + +describe('ExploreMenuProvider', () => { + + let provider: ExploreMenuProvider; + let sectionDataServiceStub = { + findVisibleSections: () => createSuccessfulRemoteDataObject$(createPaginatedList([])), + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + ExploreMenuProvider, + { provide: APP_CONFIG, useValue: environment }, + { provide: SectionDataService, useValue: sectionDataServiceStub }, + ], + }); + provider = TestBed.inject(ExploreMenuProvider); + }); + + it('should be created', () => { + expect(provider).toBeTruthy(); + }); +}); diff --git a/src/app/shared/menu/providers/explore.menu.ts b/src/app/shared/menu/providers/explore.menu.ts new file mode 100644 index 00000000000..c4b121dd87d --- /dev/null +++ b/src/app/shared/menu/providers/explore.menu.ts @@ -0,0 +1,57 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { Injectable } from '@angular/core'; +import { PaginatedList } from '@dspace/core/data/paginated-list.model'; +import { RemoteData } from '@dspace/core/data/remote-data'; +import { SectionDataService } from '@dspace/core/data/section-data.service'; +import { Section } from '@dspace/core/layout/models/section.model'; +import { getFirstSucceededRemoteData } from '@dspace/core/shared/operators'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { MenuItemType } from '../menu-item-type.model'; +import { + AbstractMenuProvider, + PartialMenuSection, +} from '../menu-provider.model'; + +/** + * Menu provider to create the explore menu sections in the public navbar + */ +@Injectable() +export class ExploreMenuProvider extends AbstractMenuProvider { + constructor( + protected sectionDataService: SectionDataService, + ) { + super(); + } + + /** + * Retrieves subsections by fetching the browse definitions from the backend and mapping them to partial menu sections. + */ + getSections(): Observable { + return this.sectionDataService.findVisibleSections().pipe( + getFirstSucceededRemoteData(), + map((rd: RemoteData>) => { + return [ + ...rd.payload.page.map((browseDef) => { + return { + visible: true, + model: { + type: MenuItemType.LINK, + text: `menu.section.explore_${browseDef.id}`, + link: `/explore/${browseDef.id}`, + }, + }; + }), + ]; + }), + ); + } +} diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 9613e02b72a..802f4d09f56 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2067,6 +2067,36 @@ "error.validation.metadata.qualifier.max-length": "This field may not contain more than 64 characters", + "explore.browse-section.title": "Browse", + + "explore.facet-section.title": "Discover", + + "explore.index.all": "All", + + "explore.index.author": "Author", + + "explore.index.birthDate": "Birthday", + + "explore.index.dateIssued": "Date issued", + + "explore.index.dc.title": "Title", + + "explore.index.familyName": "Family name", + + "explore.index.givenName": "Given name", + + "explore.index.jobTitle": "Job title", + + "explore.index.title": "Title", + + "explore.index.type": "Type", + + "explore.search-section.search-button": "Search", + + "explore.search-section.reset-button": "Reset", + + "explore.title": "Explore section", + "feed.description": "Syndication feed", "file-download-link.restricted": "Restricted bitstream", @@ -3706,6 +3736,16 @@ "menu.section.browse_global_by_subject": "By Subject", + "menu.section.explore_infrastructure": "Infrastructure", + + "menu.section.explore_publications": "Research Outputs", + + "menu.section.explore_researchoutputs": "Research Outputs", + + "menu.section.explore_researcherprofiles": "People", + + "menu.section.explore_orgunits": "Organizations", + "menu.section.browse_global_by_srsc": "By Subject Category", "menu.section.browse_global_by_nsi": "By Norwegian Science Index", From b19be465757b7895089677bafa48009fdb84240d Mon Sep 17 00:00:00 2001 From: FrancescoMolinaro Date: Fri, 17 Jul 2026 15:08:45 +0200 Subject: [PATCH 3/7] [Duracom-508] port comm/coll menu config --- config/config.example.yml | 7 +++++++ .../shared/menu/providers/community-list.menu.spec.ts | 2 ++ src/app/shared/menu/providers/community-list.menu.ts | 11 ++++++++++- src/config/default-app-config.ts | 4 ++++ src/config/layout-config.interfaces.ts | 11 +++++++++++ src/environments/environment.test.ts | 3 +++ 6 files changed, 37 insertions(+), 1 deletion(-) diff --git a/config/config.example.yml b/config/config.example.yml index 6f6c67d1ae6..46248a1b96f 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -403,6 +403,8 @@ homePage: pageSize: 5 # Enable or disable the Discover filters on the homepage showDiscoverFilters: false + # Enable or disable the dynamic layout configuration on the homepage + enableDynamicLayout: true # Item Config item: @@ -756,6 +758,11 @@ layout: type: attribute - name: checksum type: attribute + # Navbar layout configuration + navbar: + # If true, show the "Community and Collections" link in the navbar; otherwise, show it in the admin sidebar + showCommunityCollection: true + # Configuration for customization of search results searchResults: diff --git a/src/app/shared/menu/providers/community-list.menu.spec.ts b/src/app/shared/menu/providers/community-list.menu.spec.ts index fc212bbef72..013c5b85e98 100644 --- a/src/app/shared/menu/providers/community-list.menu.spec.ts +++ b/src/app/shared/menu/providers/community-list.menu.spec.ts @@ -7,6 +7,7 @@ */ import { TestBed } from '@angular/core/testing'; +import { APP_CONFIG } from '@dspace/config/app-config.interface'; import { MenuItemType } from '../menu-item-type.model'; import { PartialMenuSection } from '../menu-provider.model'; @@ -31,6 +32,7 @@ describe('CommunityListMenuProvider', () => { TestBed.configureTestingModule({ providers: [ CommunityListMenuProvider, + { provide: APP_CONFIG, useValue: { layout: { navbar: { showCommunityCollection: true } } } }, ], }); provider = TestBed.inject(CommunityListMenuProvider); diff --git a/src/app/shared/menu/providers/community-list.menu.ts b/src/app/shared/menu/providers/community-list.menu.ts index 79e893c974d..b594edddfb5 100644 --- a/src/app/shared/menu/providers/community-list.menu.ts +++ b/src/app/shared/menu/providers/community-list.menu.ts @@ -6,7 +6,11 @@ * http://www.dspace.org/license/ */ -import { Injectable } from '@angular/core'; +import { + inject, + Injectable, +} from '@angular/core'; +import { APP_CONFIG } from '@dspace/config/app-config.interface'; import { Observable, of, @@ -23,7 +27,12 @@ import { */ @Injectable() export class CommunityListMenuProvider extends AbstractMenuProvider { + protected appConfig = inject(APP_CONFIG); + public getSections(): Observable { + if (!this.appConfig.layout.navbar.showCommunityCollection) { + return of([]); + } return of([ { visible: true, diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index 7d5e11d9918..c33fb772a5e 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -816,6 +816,10 @@ export class DefaultAppConfig implements AppConfig { }, ], }, + navbar: { + // If true, show the "Community and Collections" link in the navbar; otherwise, show it in the admin sidebar + showCommunityCollection: true, + }, }; // Search result configuration for authority metadata processing diff --git a/src/config/layout-config.interfaces.ts b/src/config/layout-config.interfaces.ts index a440acf8f60..2973728bcf0 100644 --- a/src/config/layout-config.interfaces.ts +++ b/src/config/layout-config.interfaces.ts @@ -76,4 +76,15 @@ export interface LayoutConfig extends Config { * Controls pagination and metadata display for bitstream attachments. */ advancedAttachmentRendering: AdvancedAttachmentRenderingConfig; + /** + * Configuration for the navbar layout customization. + */ + navbar: NavbarConfig; +} + +/** + * Interface describing the structure of the navbar layout. + */ +export interface NavbarConfig extends Config { + showCommunityCollection: boolean; } diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index 329a0247247..a3d461ee95c 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -604,6 +604,9 @@ export const environment: BuildConfig = { }, ], }, + navbar: { + showCommunityCollection: true, + }, }, searchResult: { From 938d71c44a5155b04c04b4fbdb3bbb597b0b1556 Mon Sep 17 00:00:00 2001 From: FrancescoMolinaro Date: Fri, 17 Jul 2026 16:13:41 +0200 Subject: [PATCH 4/7] [DURACOM-508] add inline documentation --- src/app/core/data/paginated-list.model.ts | 2 - src/app/core/data/section-data.service.ts | 3 +- src/app/core/layout/models/section.model.ts | 51 +++++++++++++++++++ .../explore-i18n-breadcrumb.resolver.ts | 5 +- .../explore-page/explore-page.component.ts | 31 +++++++---- src/app/explore-page/explore-routes.ts | 5 ++ src/app/home-page/home-page.component.ts | 24 ++++++++- .../abstract-browse-elements.component.ts | 14 +++++ .../browse-most-elements.component.ts | 7 +++ .../default-browse-elements.component.ts | 5 ++ .../browse-section.component.ts | 5 +- .../themed-browse-section.component.ts | 3 ++ .../counters-section.component.ts | 30 +++++++++++ .../themed-counters-section.component.ts | 3 ++ .../facet-section/facet-section.component.ts | 6 +++ .../themed-facet-section.component.ts | 3 ++ .../multi-column-top-section.component.ts | 16 ++++++ ...emed-multi-column-top-section.component.ts | 3 ++ .../search-section.component.ts | 18 +++++-- .../themed-search-section.component.ts | 3 ++ .../text-section/text-section.component.ts | 13 +++++ .../themed-text-section.component.ts | 3 ++ .../themed-top-section.component.ts | 3 ++ .../top-section/top-section.component.ts | 4 ++ 24 files changed, 238 insertions(+), 22 deletions(-) diff --git a/src/app/core/data/paginated-list.model.ts b/src/app/core/data/paginated-list.model.ts index f7434912df7..3e97cd5ab09 100644 --- a/src/app/core/data/paginated-list.model.ts +++ b/src/app/core/data/paginated-list.model.ts @@ -103,8 +103,6 @@ export class PaginatedList extends CacheableObject { prev?: HALLink; next?: HALLink; last?: HALLink; - missing?: HALLink; - more?: HALLink; }; get elementsPerPage(): number { diff --git a/src/app/core/data/section-data.service.ts b/src/app/core/data/section-data.service.ts index 2cf4fe138b1..cc18de3271b 100644 --- a/src/app/core/data/section-data.service.ts +++ b/src/app/core/data/section-data.service.ts @@ -47,7 +47,8 @@ export class SectionDataService extends IdentifiableDataService
{ } /** - * Find all the configured sections. + * Finds all sections configured to be visible in the top navigation bar. + * Uses the 'visibleTopBarSections' search endpoint on the backend. */ findVisibleSections(): Observable>> { return this.searchData.searchBy('visibleTopBarSections'); diff --git a/src/app/core/layout/models/section.model.ts b/src/app/core/layout/models/section.model.ts index e5c3bf11177..6f6a4f6f97c 100644 --- a/src/app/core/layout/models/section.model.ts +++ b/src/app/core/layout/models/section.model.ts @@ -46,59 +46,110 @@ export class Section extends CacheableObject { } +/** + * Base interface for all section component configurations returned by the REST API. + */ export interface SectionComponent { + /** The type discriminator identifying which component to render. */ componentType: string; + /** CSS classes applied to the section container (e.g., Bootstrap grid classes). */ style: string; } +/** + * Configuration for a browse section that displays links to browse indices. + */ export interface BrowseSection extends SectionComponent { + /** List of browse index names to render as navigation links. */ browseNames: string[]; componentType: 'browse'; } +/** + * Configuration for a top section that displays the most recent/popular items + * from a discovery configuration. + */ export interface TopSection extends SectionComponent { + /** Name of the discovery configuration to query. */ discoveryConfigurationName: string; + /** Metadata field to sort results by. */ sortField: string; + /** Sort direction ('ASC' or 'DESC'). */ order: string; + /** i18n key for the section title. */ titleKey: string; componentType: 'top'; + /** Maximum number of items to display. */ numberOfItems: number; + /** Whether to show item thumbnails. */ showThumbnails: boolean; + /** Template type to use for rendering the items. */ template: TopSectionTemplateType; } +/** + * Configuration for an advanced search section with multiple query statements and filter fields. + */ export interface SearchSection extends SectionComponent { + /** Name of the discovery configuration providing available filters. */ discoveryConfigurationName: string; componentType: 'search'; + /** Type of search interface to render. */ searchType: string; + /** Number of query statement rows to display initially. */ initialStatements: number; + /** Whether to display the section title. */ displayTitle: boolean; } +/** + * Configuration for a section displaying search facets from a discovery configuration. + */ export interface FacetSection extends SectionComponent { + /** Name of the discovery configuration to fetch facet values from. */ discoveryConfigurationName: string; componentType: 'facet'; + /** Number of facet boxes to display per row in the grid. */ facetsPerRow: number; } +/** + * Configuration for a text content section that renders static or metadata-based content. + */ export interface TextRowSection extends SectionComponent { + /** The content string (can be a metadata key or raw content depending on contentType). */ content: string; + /** The type of content: e.g., 'text-metadata' for metadata lookups or 'text-raw' for static content. */ contentType: string; componentType: 'text-row'; } +/** + * Configuration for a table-like section showing top items with multiple metadata columns. + */ export interface MultiColumnTopSection extends SectionComponent { + /** Name of the discovery configuration to query. */ discoveryConfigurationName: string; + /** Metadata field to sort results by. */ sortField: string; + /** Sort direction ('ASC' or 'DESC'). */ order: string; + /** i18n key for the section title. */ titleKey: string; + /** List of column definitions specifying which metadata fields to display. */ columnList: TopSectionColumn[]; componentType: 'multi-column-top'; } +/** + * Column configuration for {@link MultiColumnTopSection} defining which metadata to display. + */ export interface TopSectionColumn { + /** CSS classes for column width styling. */ style: string; + /** Metadata field to extract the column value from. */ metadataField: string; + /** i18n key for the column header. */ titleKey: string; } diff --git a/src/app/explore-page/explore-i18n-breadcrumb.resolver.ts b/src/app/explore-page/explore-i18n-breadcrumb.resolver.ts index 644586f504f..b4c248ad2e2 100644 --- a/src/app/explore-page/explore-i18n-breadcrumb.resolver.ts +++ b/src/app/explore-page/explore-i18n-breadcrumb.resolver.ts @@ -12,8 +12,9 @@ import { hasNoValue } from '../utils/empty.util'; /** - * This class resolves a BreadcrumbConfig object with an i18n key string for a route - * It adds the metadata field of the current explore page + * Resolves a BreadcrumbConfig object with an i18n key string for an explore route. + * Extends the base breadcrumb key with the explore page's ID route parameter + * (e.g., 'explore' becomes 'explore.publications') to provide page-specific breadcrumb labels. */ export const exploreI18nBreadcrumbResolver: ResolveFn> = ( route: ActivatedRouteSnapshot, diff --git a/src/app/explore-page/explore-page.component.ts b/src/app/explore-page/explore-page.component.ts index aa7adfa4718..41350d0d18d 100644 --- a/src/app/explore-page/explore-page.component.ts +++ b/src/app/explore-page/explore-page.component.ts @@ -24,7 +24,11 @@ import { ThemedTextSectionComponent } from '../shared/explore/section-component/ import { ThemedTopSectionComponent } from '../shared/explore/section-component/top-section/themed-top-section.component'; /** - * Component representing the explore section. + * Main container component for dynamic explore pages. + * + * Fetches section configuration from the backend based on the current route parameter + * and renders the configured sections as a responsive grid of section components. + * Supported section types include: top, browse, search, facet, text-row, and counters. */ @Component({ selector: 'ds-explore', @@ -43,13 +47,14 @@ import { ThemedTopSectionComponent } from '../shared/explore/section-component/t export class ExplorePageComponent implements OnInit { /** - * The id of the current section. - */ + * Identifier for the current explore section, derived from the ':id' route parameter. + */ sectionId: string; /** - * Resolved section components splitted in rows. - */ + * Observable emitting a 2D array of section components organized in rows for grid layout. + * Each inner array represents a row of components to be rendered side-by-side. + */ sectionComponentRows: Observable; constructor( @@ -61,10 +66,11 @@ export class ExplorePageComponent implements OnInit { } /** - * Setup the section components of the explore page based on the section id. - * - * @param params the route params - */ + * Fetches the section configuration from the backend and resolves + * the section's component rows into the {@link sectionComponentRows} observable. + * + * @param params the route params containing the explore section 'id' + */ setupSectionComponents( params: Params ) { this.sectionId = params.id; this.sectionComponentRows = this.sectionDataService.findById(params.id ).pipe( @@ -74,8 +80,11 @@ export class ExplorePageComponent implements OnInit { } /** - * Check if style contains 'col' or 'col-x' - * @param style the style of the cell (a list of classes separated by space) + * Checks if a style string already contains a Bootstrap column class (e.g. 'col' or 'col-*'). + * Used to determine whether a default column class should be applied to a grid cell. + * + * @param style the style of the cell (a space-separated list of CSS classes) + * @returns true if the style contains a Bootstrap column class */ hasColClass(style) { return style?.split(' ').filter((c) => (c === 'col' || c.startsWith('col-'))).length > 0; diff --git a/src/app/explore-page/explore-routes.ts b/src/app/explore-page/explore-routes.ts index 5a3ebe02ce7..7c6aa888f06 100644 --- a/src/app/explore-page/explore-routes.ts +++ b/src/app/explore-page/explore-routes.ts @@ -1,3 +1,8 @@ +/** + * Route definitions for the explore module. + * Maps ':id' paths to the {@link ExplorePageComponent}, resolving breadcrumbs + * via {@link exploreI18nBreadcrumbResolver} and guarding access with the end-user agreement. + */ import { Route } from '@angular/router'; import { endUserAgreementCurrentUserGuard } from '../core/end-user-agreement/end-user-agreement-current-user.guard'; diff --git a/src/app/home-page/home-page.component.ts b/src/app/home-page/home-page.component.ts index 146ca800644..f5faa945e93 100644 --- a/src/app/home-page/home-page.component.ts +++ b/src/app/home-page/home-page.component.ts @@ -62,6 +62,14 @@ import { ThemedHomeNewsComponent } from './home-news/themed-home-news.component' import { RecentItemListComponent } from './recent-item-list/recent-item-list.component'; import { ThemedTopLevelCommunityListComponent } from './top-level-community-list/themed-top-level-community-list.component'; +/** + * The home page component. + * + * Supports both a static layout and a dynamic layout driven by section configurations + * fetched from the REST API. When dynamic layout is enabled (via `enableDynamicLayout` config), + * it renders section components similar to the explore page (top, browse, search, facet, text-row, counters). + * Also handles COAR Notify inbox link headers and site metadata rendering. + */ @Component({ selector: 'ds-base-home-page', styleUrls: ['./home-page.component.scss'], @@ -92,16 +100,23 @@ export class HomePageComponent implements OnInit, OnDestroy { recentSubmissionspageSize: number; showDiscoverFilters: boolean; homeHeaderMetadataValue$: Observable; + + /** Whether the dynamic section-based home page layout is enabled via app config. */ isDynamicHomePageEnabled: boolean; + + /** The section identifier used when fetching the home page layout ('site'). */ sectionId = 'site'; /** - * Two-dimensional array (rows and columns) of section components + * Observable emitting a 2D array (rows × columns) of section components + * to render when dynamic home page layout is enabled. */ sectionComponents: Observable; + /** Whether the site has a home header metadata value in the current language. */ hasHomeHeaderMetadata: boolean; + /** Default text-row section configuration for the home header CMS metadata. */ homeHeaderSection: TextRowSection = { content: 'cris.cms.home-header', contentType: 'text-metadata', @@ -173,6 +188,13 @@ export class HomePageComponent implements OnInit, OnDestroy { ); } + /** + * Returns Bootstrap column classes for a section component. + * If the section's style already contains a 'col' class, uses it as-is; + * otherwise defaults to 'col-12' prepended to any existing style. + * + * @param sectionComponent the section component to compute classes for + */ componentClass(sectionComponent: SectionComponent) { const defaultCol = 'col-12'; return (isNotEmpty(sectionComponent.style) && sectionComponent.style.includes('col')) ? diff --git a/src/app/shared/browse-most-elements/abstract-browse-elements.component.ts b/src/app/shared/browse-most-elements/abstract-browse-elements.component.ts index 3f6cb321fec..3cd88b50e7c 100644 --- a/src/app/shared/browse-most-elements/abstract-browse-elements.component.ts +++ b/src/app/shared/browse-most-elements/abstract-browse-elements.component.ts @@ -34,6 +34,12 @@ import { import { CollectionElementLinkType } from '../object-collection/collection-element-link.type'; +/** + * Abstract base class for browse elements components. + * + * Handles searching via the {@link SearchManager} and rendering paginated search results. + * Subclasses must specify whether to follow thumbnail links by setting {@link followThumbnailLink}. + */ @Component({ template: '', }) @@ -77,10 +83,13 @@ export abstract class AbstractBrowseElementsComponent implements OnInit, OnChang public collectionElementLinkTypeEnum = CollectionElementLinkType; + /** BehaviorSubject emitting the current search options, triggers re-search on change. */ paginatedSearchOptions$: BehaviorSubject; + /** Observable of the raw paginated search results from the SearchManager. */ searchResults$: Observable>>>; + /** Observable emitting the flat array of DSpaceObjects extracted from search results. */ searchResultArray$: Observable; ngOnChanges() { @@ -116,6 +125,11 @@ export abstract class AbstractBrowseElementsComponent implements OnInit, OnChang ); } + /** + * Returns the route path for an item's detail page. + * + * @param item the DSpaceObject (item) to generate a route for + */ getItemPageRoute(item: DSpaceObject | Item) { return getItemPageRoute(item as Item); } diff --git a/src/app/shared/browse-most-elements/browse-most-elements.component.ts b/src/app/shared/browse-most-elements/browse-most-elements.component.ts index e97eaa3e140..2c69b3ffb21 100644 --- a/src/app/shared/browse-most-elements/browse-most-elements.component.ts +++ b/src/app/shared/browse-most-elements/browse-most-elements.component.ts @@ -18,6 +18,11 @@ import { import { Context } from '../../core/shared/context.model'; import { ThemedDefaultBrowseElementsComponent } from './default-browse-elements/themed-default-browse-elements.component'; +/** + * Container component that delegates rendering of browse elements to the appropriate + * template component based on the {@link TopSection} configuration. + * Currently supports the default template via {@link DefaultBrowseElementsComponent}. + */ @Component({ selector: 'ds-base-browse-most-elements', styleUrls: ['./browse-most-elements.component.scss'], @@ -66,8 +71,10 @@ export class BrowseMostElementsComponent implements OnInit, OnChanges { */ @Input() topSection: TopSection; + /** BehaviorSubject re-emitting paginatedSearchOptions to trigger child component updates. */ paginatedSearchOptions$ = new BehaviorSubject(null); + /** The resolved template type determining which child component renders the results. */ sectionTemplateType: TopSectionTemplateType; ngOnInit(): void { diff --git a/src/app/shared/browse-most-elements/default-browse-elements/default-browse-elements.component.ts b/src/app/shared/browse-most-elements/default-browse-elements/default-browse-elements.component.ts index d97bfde0046..dee0ee4fd81 100644 --- a/src/app/shared/browse-most-elements/default-browse-elements/default-browse-elements.component.ts +++ b/src/app/shared/browse-most-elements/default-browse-elements/default-browse-elements.component.ts @@ -10,6 +10,11 @@ import { ThemedLoadingComponent } from '../../loading/themed-loading.component'; import { ListableObjectComponentLoaderComponent } from '../../object-collection/shared/listable-object/listable-object-component-loader.component'; import { AbstractBrowseElementsComponent } from '../abstract-browse-elements.component'; +/** + * Default template implementation for browsing top elements. + * Extends {@link AbstractBrowseElementsComponent} using the standard DSpace + * listable object rendering with optional thumbnail support. + */ @Component({ selector: 'ds-base-default-browse-elements', templateUrl: './default-browse-elements.component.html', diff --git a/src/app/shared/explore/section-component/browse-section/browse-section.component.ts b/src/app/shared/explore/section-component/browse-section/browse-section.component.ts index ab8b019852f..c20fb59f309 100644 --- a/src/app/shared/explore/section-component/browse-section/browse-section.component.ts +++ b/src/app/shared/explore/section-component/browse-section/browse-section.component.ts @@ -4,10 +4,9 @@ import { Input, } from '@angular/core'; import { RouterLink } from '@angular/router'; +import { BrowseSection } from '@dspace/core/layout/models/section.model'; import { TranslateModule } from '@ngx-translate/core'; -import { BrowseSection } from '../../../../core/layout/models/section.model'; - /** * Component representing the Browse component section. */ @@ -21,9 +20,11 @@ import { BrowseSection } from '../../../../core/layout/models/section.model'; }) export class BrowseSectionComponent { + /** Unique identifier for this section instance. */ @Input() sectionId: string; + /** Configuration object defining the browse indices to display as links. */ @Input() browseSection: BrowseSection; diff --git a/src/app/shared/explore/section-component/browse-section/themed-browse-section.component.ts b/src/app/shared/explore/section-component/browse-section/themed-browse-section.component.ts index 57844cea1b0..6d210784f58 100644 --- a/src/app/shared/explore/section-component/browse-section/themed-browse-section.component.ts +++ b/src/app/shared/explore/section-component/browse-section/themed-browse-section.component.ts @@ -7,6 +7,9 @@ import { BrowseSection } from '@dspace/core/layout/models/section.model'; import { ThemedComponent } from '../../../theme-support/themed.component'; import { BrowseSectionComponent } from './browse-section.component'; +/** + * Themed wrapper for {@link BrowseSectionComponent}. + */ @Component({ selector: 'ds-browse-section', styleUrls: [], diff --git a/src/app/shared/explore/section-component/counters-section/counters-section.component.ts b/src/app/shared/explore/section-component/counters-section/counters-section.component.ts index 89a8af79792..14e07d797e8 100644 --- a/src/app/shared/explore/section-component/counters-section/counters-section.component.ts +++ b/src/app/shared/explore/section-component/counters-section/counters-section.component.ts @@ -31,6 +31,11 @@ import { map } from 'rxjs/operators'; import { ThemedLoadingComponent } from '../../../loading/themed-loading.component'; +/** + * Component that displays entity counts (e.g., number of publications, researchers) + * fetched from discovery search configurations. Each counter shows the total number + * of elements for a configured discovery query, rendered with an icon and optional link. + */ @Component({ selector: 'ds-base-counters-section', styleUrls: ['./counters-section.component.scss'], @@ -46,14 +51,21 @@ import { ThemedLoadingComponent } from '../../../loading/themed-loading.componen }) export class CountersSectionComponent implements OnInit { + /** Unique identifier for this section instance. */ @Input() sectionId: string; + /** Configuration object defining the counters to display. */ @Input() countersSection: CountersSection; + /** Resolved array of counter data populated after fetching from discovery. */ counterData: CounterData[] = []; + + /** Observable emitting the array of resolved counter data. */ counterData$: Observable; + + /** Subject indicating whether counter data is still being loaded. */ isLoading$ = new BehaviorSubject(true); pagination: PaginationComponentOptions; @@ -101,21 +113,39 @@ export class CountersSectionComponent implements OnInit { +/** + * Configuration for a counters section defining which discovery queries to count. + */ export interface CountersSection extends SectionComponent { componentType: 'counters'; + /** List of counter settings, each defining a discovery query and display metadata. */ counterSettingsList: CountersSettings[]; } +/** + * Settings for an individual counter within a {@link CountersSection}. + */ export interface CountersSettings { + /** Discovery configuration name used to query the total count. */ discoveryConfigurationName: string; + /** Display label for the entity type (e.g., 'publications', 'researchers'). */ entityName: string; + /** CSS icon class to display alongside the counter (e.g., 'fas fa-book'). */ icon: string; + /** URL to navigate to when the counter is clicked. */ link: string; } +/** + * Resolved counter data ready for rendering in the template. + */ export interface CounterData { + /** Display label for the counter. */ label: string; + /** The total count as a string. */ count: string; + /** CSS icon class for display. */ icon: string; + /** URL to navigate to when the counter is clicked. */ link: string; } diff --git a/src/app/shared/explore/section-component/counters-section/themed-counters-section.component.ts b/src/app/shared/explore/section-component/counters-section/themed-counters-section.component.ts index 83b14555595..b2cf7e5a744 100644 --- a/src/app/shared/explore/section-component/counters-section/themed-counters-section.component.ts +++ b/src/app/shared/explore/section-component/counters-section/themed-counters-section.component.ts @@ -9,6 +9,9 @@ import { CountersSectionComponent, } from './counters-section.component'; +/** + * Themed wrapper for {@link CountersSectionComponent}. + */ @Component({ selector: 'ds-counters-section', styleUrls: [], diff --git a/src/app/shared/explore/section-component/facet-section/facet-section.component.ts b/src/app/shared/explore/section-component/facet-section/facet-section.component.ts index 05cd9d918ba..e80a9828fae 100644 --- a/src/app/shared/explore/section-component/facet-section/facet-section.component.ts +++ b/src/app/shared/explore/section-component/facet-section/facet-section.component.ts @@ -32,15 +32,21 @@ import { SearchConfigurationService } from '../../../search/search-configuration }) export class FacetSectionComponent implements OnInit { + /** Unique identifier for this section instance. */ @Input() sectionId: string; + /** Configuration object defining the facet display settings. */ @Input() facetSection: FacetSection; + /** The discovery configuration name used to fetch facets. */ discoveryConfiguration: string; + /** Array of loaded search filter configs that have at least one facet value. */ facets: SearchFilterConfig[] = []; + + /** Subject emitting the current facets array as new facets are loaded. */ facets$ = new BehaviorSubject(this.facets); constructor( diff --git a/src/app/shared/explore/section-component/facet-section/themed-facet-section.component.ts b/src/app/shared/explore/section-component/facet-section/themed-facet-section.component.ts index 295b96dcb01..6be740cb115 100644 --- a/src/app/shared/explore/section-component/facet-section/themed-facet-section.component.ts +++ b/src/app/shared/explore/section-component/facet-section/themed-facet-section.component.ts @@ -7,6 +7,9 @@ import { FacetSection } from '@dspace/core/layout/models/section.model'; import { ThemedComponent } from '../../../theme-support/themed.component'; import { FacetSectionComponent } from './facet-section.component'; +/** + * Themed wrapper for {@link FacetSectionComponent}. + */ @Component({ selector: 'ds-facet-section', styleUrls: [], diff --git a/src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.ts b/src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.ts index 07e97e543c8..7c3073297d8 100644 --- a/src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.ts +++ b/src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.ts @@ -25,18 +25,25 @@ import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; +/** + * Component that displays a table of top items from a discovery search, + * with configurable columns showing different metadata fields per item. + */ @Component({ selector: 'ds-base-multi-column-top-section', templateUrl: './multi-column-top-section.component.html', }) export class MultiColumnTopSectionComponent implements OnInit { + /** Unique identifier for this section instance. */ @Input() sectionId: string; + /** Configuration object defining discovery query, sort, and column layout. */ @Input() topSection: MultiColumnTopSection; + /** Observable emitting the array of top DSpaceObjects fetched from the search. */ topObjects: Observable; constructor(private searchService: SearchManager) { @@ -73,10 +80,19 @@ export class MultiColumnTopSectionComponent implements OnInit { return getItemPageRoute((item as Item)); } + /** + * Returns the configured column definitions for the table. + */ getColumns(): TopSectionColumn[] { return this.topSection.columnList; } + /** + * Returns the first metadata value for a given column's metadata field on the object. + * + * @param topObject the DSpace object (item) to extract metadata from + * @param column the column definition specifying which metadata field to read + */ getColumnValue(topObject: DSpaceObject, column: TopSectionColumn): string { return Metadata.firstValue(topObject.metadata, column.metadataField); } diff --git a/src/app/shared/explore/section-component/multi-column-top-section/themed-multi-column-top-section.component.ts b/src/app/shared/explore/section-component/multi-column-top-section/themed-multi-column-top-section.component.ts index 4ce7a051e81..b55f824ba39 100644 --- a/src/app/shared/explore/section-component/multi-column-top-section/themed-multi-column-top-section.component.ts +++ b/src/app/shared/explore/section-component/multi-column-top-section/themed-multi-column-top-section.component.ts @@ -7,6 +7,9 @@ import { MultiColumnTopSection } from '../../../../core/layout/models/section.mo import { ThemedComponent } from '../../../theme-support/themed.component'; import { MultiColumnTopSectionComponent } from './multi-column-top-section.component'; +/** + * Themed wrapper for {@link MultiColumnTopSectionComponent}. + */ @Component({ selector: 'ds-multi-column-top-section', styleUrls: [], diff --git a/src/app/shared/explore/section-component/search-section/search-section.component.ts b/src/app/shared/explore/section-component/search-section/search-section.component.ts index 3c62eb6d215..71ebbc1a923 100644 --- a/src/app/shared/explore/section-component/search-section/search-section.component.ts +++ b/src/app/shared/explore/section-component/search-section/search-section.component.ts @@ -39,19 +39,24 @@ import { ThemedSearchFormComponent } from '../../../search-form/themed-search-fo }) export class SearchSectionComponent implements OnInit { + /** Unique identifier for this section instance. */ @Input() sectionId: string; + /** Configuration object defining the search section behaviour. */ @Input() searchSection: SearchSection; - // The search form + /** Reactive form containing the array of query statements. */ searchForm: FormGroup; + /** Observable emitting the available filter field names (including 'all'). */ filters: Observable; + /** Constant representing the "all fields" filter option. */ allFilter = 'all'; + /** Available boolean operations for combining query statements. */ operations = ['AND', 'OR', 'NOT']; constructor(private formBuilder: FormBuilder, @@ -115,7 +120,8 @@ export class SearchSectionComponent implements OnInit { } /** - * Initialize the form group. + * Creates a new FormGroup representing a single query statement + * with default filter, empty query, and first boolean operation. */ createFormGroup(): FormGroup { return this.formBuilder.group({ @@ -125,6 +131,9 @@ export class SearchSectionComponent implements OnInit { }); } + /** + * Appends a new empty query statement to the form array. + */ addQueryStatement(): void { this.queryArray.push(this.createFormGroup()); } @@ -151,10 +160,13 @@ export class SearchSectionComponent implements OnInit { } /** - * Interface related to the form model. + * Interface representing a single query statement in the advanced search form. */ interface QueryStatement { + /** The metadata field to search in, or 'all' for all fields. */ filter: string; + /** The search query text entered by the user. */ query: string; + /** Boolean operation (AND, OR, NOT) to combine with the next statement. */ operation: string; } diff --git a/src/app/shared/explore/section-component/search-section/themed-search-section.component.ts b/src/app/shared/explore/section-component/search-section/themed-search-section.component.ts index 727cffdb3a6..c2c9a6a692e 100644 --- a/src/app/shared/explore/section-component/search-section/themed-search-section.component.ts +++ b/src/app/shared/explore/section-component/search-section/themed-search-section.component.ts @@ -7,6 +7,9 @@ import { SearchSection } from '@dspace/core/layout/models/section.model'; import { ThemedComponent } from '../../../theme-support/themed.component'; import { SearchSectionComponent } from './search-section.component'; +/** + * Themed wrapper for {@link SearchSectionComponent}. + */ @Component({ selector: 'ds-search-section', styleUrls: [], diff --git a/src/app/shared/explore/section-component/text-section/text-section.component.ts b/src/app/shared/explore/section-component/text-section/text-section.component.ts index 865bad51d96..6586efe549c 100644 --- a/src/app/shared/explore/section-component/text-section/text-section.component.ts +++ b/src/app/shared/explore/section-component/text-section/text-section.component.ts @@ -16,6 +16,10 @@ import { map } from 'rxjs/operators'; import { MarkdownViewerComponent } from '../../../markdown-viewer/markdown-viewer.component'; +/** + * Component that renders a text section, supporting localized metadata content + * from the Site object or static markdown/HTML content rendering. + */ @Component({ selector: 'ds-base-text-section', templateUrl: './text-section.component.html', @@ -29,12 +33,15 @@ import { MarkdownViewerComponent } from '../../../markdown-viewer/markdown-viewe }) export class TextSectionComponent { + /** Unique identifier for this section instance. */ @Input() sectionId: string; + /** Configuration object defining the text content and content type. */ @Input() textRowSection: TextRowSection; + /** The site object used to resolve metadata-based content. */ @Input() site: Site; @@ -43,6 +50,12 @@ export class TextSectionComponent { ) { } + /** + * Resolves a localized metadata value from the site object based on the current language. + * + * @param content the metadata key to look up on the site + * @returns observable emitting the localized metadata value, or empty string if not found + */ metadataValue(content: string): Observable { return this.locale.getCurrentLanguageCode().pipe( map(language => this.site?.firstMetadataValue(content, { language }) ?? ''), diff --git a/src/app/shared/explore/section-component/text-section/themed-text-section.component.ts b/src/app/shared/explore/section-component/text-section/themed-text-section.component.ts index 5bfa90051eb..51af784408d 100644 --- a/src/app/shared/explore/section-component/text-section/themed-text-section.component.ts +++ b/src/app/shared/explore/section-component/text-section/themed-text-section.component.ts @@ -8,6 +8,9 @@ import { Site } from '@dspace/core/shared/site.model'; import { ThemedComponent } from '../../../theme-support/themed.component'; import { TextSectionComponent } from './text-section.component'; +/** + * Themed wrapper for {@link TextSectionComponent}. + */ @Component({ selector: 'ds-text-section', styleUrls: [], diff --git a/src/app/shared/explore/section-component/top-section/themed-top-section.component.ts b/src/app/shared/explore/section-component/top-section/themed-top-section.component.ts index a3dd704daad..7fa1ca5aafd 100644 --- a/src/app/shared/explore/section-component/top-section/themed-top-section.component.ts +++ b/src/app/shared/explore/section-component/top-section/themed-top-section.component.ts @@ -8,6 +8,9 @@ import { Context } from '@dspace/core/shared/context.model'; import { ThemedComponent } from '../../../theme-support/themed.component'; import { TopSectionComponent } from './top-section.component'; +/** + * Themed wrapper for {@link TopSectionComponent}. + */ @Component({ selector: 'ds-top-section', styleUrls: [], diff --git a/src/app/shared/explore/section-component/top-section/top-section.component.ts b/src/app/shared/explore/section-component/top-section/top-section.component.ts index 70f38e40d91..0722cbb92b9 100644 --- a/src/app/shared/explore/section-component/top-section/top-section.component.ts +++ b/src/app/shared/explore/section-component/top-section/top-section.component.ts @@ -30,15 +30,19 @@ import { ThemedBrowseMostElementsComponent } from '../../../browse-most-elements }) export class TopSectionComponent implements OnInit { + /** Unique identifier for this section instance. */ @Input() sectionId: string; + /** Configuration object defining discovery query, sort, and display options. */ @Input() topSection: TopSection; + /** The context in which items are rendered (defaults to BrowseMostElements). */ @Input() context: Context = Context.BrowseMostElements; + /** Paginated search options built from the topSection configuration, passed to the browse component. */ paginatedSearchOptions: PaginatedSearchOptions; ngOnInit() { From 18512bd8b0331370bd3e2114b144e2f48a285bd0 Mon Sep 17 00:00:00 2001 From: FrancescoMolinaro Date: Fri, 17 Jul 2026 17:16:58 +0200 Subject: [PATCH 5/7] [DURACOM-508] make explore pages configurable --- config/config.example.yml | 3 + .../explore-pages-enabled.guard.ts | 31 +++++++ src/app/explore-page/explore-routes.ts | 3 +- .../menu/providers/explore.menu.spec.ts | 89 +++++++++++++++++-- src/app/shared/menu/providers/explore.menu.ts | 20 ++++- src/config/default-app-config.ts | 1 + src/config/layout-config.interfaces.ts | 6 ++ src/environments/environment.test.ts | 1 + 8 files changed, 142 insertions(+), 12 deletions(-) create mode 100644 src/app/explore-page/explore-pages-enabled.guard.ts diff --git a/config/config.example.yml b/config/config.example.yml index 46248a1b96f..a39de92c687 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -711,6 +711,9 @@ accessibility: # Configuration for layout customization of metadata rendering in Item page # Currently only the authority reference config is available, more will follow with the integration of the so called CRIS layout. layout: + # Enable or disable the explore pages feature (/explore/:id routes and their navbar menu entries). + # When false, explore routes redirect to 404 and the explore menu is hidden. + enableExplorePages: true # Configuration of icons and styles to be used for each authority controlled link authorityRef: - entityType: DEFAULT diff --git a/src/app/explore-page/explore-pages-enabled.guard.ts b/src/app/explore-page/explore-pages-enabled.guard.ts new file mode 100644 index 00000000000..45c58dda080 --- /dev/null +++ b/src/app/explore-page/explore-pages-enabled.guard.ts @@ -0,0 +1,31 @@ +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivateFn, + Router, + RouterStateSnapshot, + UrlTree, +} from '@angular/router'; +import { + APP_CONFIG, + AppConfig, +} from '@dspace/config/app-config.interface'; + +import { getPageNotFoundRoute } from '../core/router/core-routing-paths'; + +/** + * Route guard that checks whether explore pages are enabled in the application config. + * When `layout.enableExplorePages` is false, navigating to any explore route + * will redirect to the 404 page. + */ +export const explorePagesEnabledGuard: CanActivateFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + appConfig: AppConfig = inject(APP_CONFIG), + router: Router = inject(Router), +): boolean | UrlTree => { + if (appConfig.layout.enableExplorePages) { + return true; + } + return router.parseUrl(getPageNotFoundRoute()); +}; diff --git a/src/app/explore-page/explore-routes.ts b/src/app/explore-page/explore-routes.ts index 7c6aa888f06..896c2d11c5e 100644 --- a/src/app/explore-page/explore-routes.ts +++ b/src/app/explore-page/explore-routes.ts @@ -8,6 +8,7 @@ import { Route } from '@angular/router'; import { endUserAgreementCurrentUserGuard } from '../core/end-user-agreement/end-user-agreement-current-user.guard'; import { exploreI18nBreadcrumbResolver } from './explore-i18n-breadcrumb.resolver'; import { ExplorePageComponent } from './explore-page.component'; +import { explorePagesEnabledGuard } from './explore-pages-enabled.guard'; export const ROUTES: Route[] = [ { @@ -15,6 +16,6 @@ export const ROUTES: Route[] = [ component: ExplorePageComponent, resolve: { breadcrumb: exploreI18nBreadcrumbResolver }, data: { title: 'explore.title', breadcrumbKey: 'explore', showSocialButtons: true }, - canActivate: [endUserAgreementCurrentUserGuard], + canActivate: [explorePagesEnabledGuard, endUserAgreementCurrentUserGuard], }, ]; diff --git a/src/app/shared/menu/providers/explore.menu.spec.ts b/src/app/shared/menu/providers/explore.menu.spec.ts index 249534d876b..3b01805cb66 100644 --- a/src/app/shared/menu/providers/explore.menu.spec.ts +++ b/src/app/shared/menu/providers/explore.menu.spec.ts @@ -12,28 +12,101 @@ import { SectionDataService } from '@dspace/core/data/section-data.service'; import { createPaginatedList } from '@dspace/core/testing/utils.test'; import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; -import { environment } from '../../../../environments/environment'; +import { MenuItemType } from '../menu-item-type.model'; +import { PartialMenuSection } from '../menu-provider.model'; import { ExploreMenuProvider } from './explore.menu'; describe('ExploreMenuProvider', () => { let provider: ExploreMenuProvider; - let sectionDataServiceStub = { - findVisibleSections: () => createSuccessfulRemoteDataObject$(createPaginatedList([])), - }; + let sectionDataServiceStub: any; + + const mockSections = [ + { id: 'publications', componentRows: [], nestedSections: [] }, + { id: 'researchers', componentRows: [], nestedSections: [] }, + ]; + + function configureTestingModule(enableExplorePages: boolean) { + sectionDataServiceStub = { + findVisibleSections: jasmine.createSpy('findVisibleSections').and.returnValue( + createSuccessfulRemoteDataObject$(createPaginatedList(mockSections)), + ), + }; - beforeEach(() => { TestBed.configureTestingModule({ providers: [ ExploreMenuProvider, - { provide: APP_CONFIG, useValue: environment }, + { provide: APP_CONFIG, useValue: { layout: { enableExplorePages } } }, { provide: SectionDataService, useValue: sectionDataServiceStub }, ], }); provider = TestBed.inject(ExploreMenuProvider); + } + + describe('when enableExplorePages is true', () => { + beforeEach(() => { + configureTestingModule(true); + }); + + it('should be created', () => { + expect(provider).toBeTruthy(); + }); + + it('should call findVisibleSections on the SectionDataService', (done) => { + provider.getSections().subscribe(() => { + expect(sectionDataServiceStub.findVisibleSections).toHaveBeenCalled(); + done(); + }); + }); + + it('should return menu sections for each visible section', (done) => { + const expectedSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.explore_publications', + link: '/explore/publications', + }, + }, + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.explore_researchers', + link: '/explore/researchers', + }, + }, + ]; + + provider.getSections().subscribe((sections) => { + expect(sections).toEqual(expectedSections); + done(); + }); + }); }); - it('should be created', () => { - expect(provider).toBeTruthy(); + describe('when enableExplorePages is false', () => { + beforeEach(() => { + configureTestingModule(false); + }); + + it('should be created', () => { + expect(provider).toBeTruthy(); + }); + + it('should return an empty array', (done) => { + provider.getSections().subscribe((sections) => { + expect(sections).toEqual([]); + done(); + }); + }); + + it('should not call findVisibleSections on the SectionDataService', (done) => { + provider.getSections().subscribe(() => { + expect(sectionDataServiceStub.findVisibleSections).not.toHaveBeenCalled(); + done(); + }); + }); }); }); diff --git a/src/app/shared/menu/providers/explore.menu.ts b/src/app/shared/menu/providers/explore.menu.ts index c4b121dd87d..c3dead21611 100644 --- a/src/app/shared/menu/providers/explore.menu.ts +++ b/src/app/shared/menu/providers/explore.menu.ts @@ -6,13 +6,20 @@ * http://www.dspace.org/license/ */ -import { Injectable } from '@angular/core'; +import { + inject, + Injectable, +} from '@angular/core'; +import { APP_CONFIG } from '@dspace/config/app-config.interface'; import { PaginatedList } from '@dspace/core/data/paginated-list.model'; import { RemoteData } from '@dspace/core/data/remote-data'; import { SectionDataService } from '@dspace/core/data/section-data.service'; import { Section } from '@dspace/core/layout/models/section.model'; import { getFirstSucceededRemoteData } from '@dspace/core/shared/operators'; -import { Observable } from 'rxjs'; +import { + Observable, + of, +} from 'rxjs'; import { map } from 'rxjs/operators'; import { MenuItemType } from '../menu-item-type.model'; @@ -22,10 +29,13 @@ import { } from '../menu-provider.model'; /** - * Menu provider to create the explore menu sections in the public navbar + * Menu provider to create the explore menu sections in the public navbar. + * Returns an empty menu when `layout.enableExplorePages` is false. */ @Injectable() export class ExploreMenuProvider extends AbstractMenuProvider { + protected appConfig = inject(APP_CONFIG); + constructor( protected sectionDataService: SectionDataService, ) { @@ -34,8 +44,12 @@ export class ExploreMenuProvider extends AbstractMenuProvider { /** * Retrieves subsections by fetching the browse definitions from the backend and mapping them to partial menu sections. + * Returns an empty array when explore pages are disabled in the app configuration. */ getSections(): Observable { + if (!this.appConfig.layout.enableExplorePages) { + return of([]); + } return this.sectionDataService.findVisibleSections().pipe( getFirstSucceededRemoteData(), map((rd: RemoteData>) => { diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index c33fb772a5e..b7e829a07db 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -746,6 +746,7 @@ export class DefaultAppConfig implements AppConfig { // These styles are used in components like MetadataLinkViewComponent to display entity type indicators // alongside metadata values, providing visual cues about the type of referenced entity. layout: LayoutConfig = { + enableExplorePages: true, authorityRef: [ { entityType: 'DEFAULT', diff --git a/src/config/layout-config.interfaces.ts b/src/config/layout-config.interfaces.ts index 2973728bcf0..97a8dc49b3d 100644 --- a/src/config/layout-config.interfaces.ts +++ b/src/config/layout-config.interfaces.ts @@ -62,6 +62,12 @@ export interface AuthorityRefConfig extends Config { * @see AppConfig */ export interface LayoutConfig extends Config { + /** + * Whether explore pages (e.g., /explore/:id) and the explore menu in the navbar are enabled. + * When false, explore routes will redirect to 404 and menu entries will be hidden. + */ + enableExplorePages: boolean; + /** * Array of authority reference configurations for different entity types. * Each entry defines how entities of a specific type should be visually represented with icons and styles. diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index a3d461ee95c..59931334274 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -535,6 +535,7 @@ export const environment: BuildConfig = { // Configuration for layout customization of metadata rendering in Item page layout: { + enableExplorePages: true, authorityRef: [ { entityType: 'DEFAULT', From 86eea4ecdc648e8a134d12b5aec112f4f4e7a768 Mon Sep 17 00:00:00 2001 From: FrancescoMolinaro Date: Mon, 3 Aug 2026 12:58:44 +0200 Subject: [PATCH 6/7] [DURACOM-508] add labels for explore/home page components --- src/assets/i18n/en.json5 | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 802f4d09f56..2f0d99bb24a 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2069,6 +2069,12 @@ "explore.browse-section.title": "Browse", + "explore.counters-section.rprofiles": "People", + + "explore.counters-section.publications": "Research outputs", + + "explore.counters-section.project_funding": "Projects", + "explore.facet-section.title": "Discover", "explore.index.all": "All", @@ -2079,6 +2085,10 @@ "explore.index.dateIssued": "Date issued", + "explore.index.dateissued": "Date issued", + + "explore.index.dc.date.accessioned": "Recent Additions", + "explore.index.dc.title": "Title", "explore.index.familyName": "Family name", @@ -2091,10 +2101,42 @@ "explore.index.type": "Type", + "explore.index.rodept": "Department", + + "explore.index.subject": "Subject", + + "explore.index.rsoTitle": "Title", + + "explore.index.pjtitle": "Title", + + "explore.index.rpdept": "Department", + + "explore.index.rpname": "Name", + + "explore.search-section.fundings_and_projects.title": "Search Projects", + + "explore.search-section.orgunits.title": "Search Organizations", + + "explore.search-section.publications.title": "Search Research Outputs", + + "explore.search-section.researchoutputs.title": "Search Research Outputs", + + "explore.search-section.researcherprofiles.title": "Search People", + "explore.search-section.search-button": "Search", "explore.search-section.reset-button": "Reset", + "explore.fundings_and_projects.breadcrumbs": "Projects", + + "explore.orgunits.breadcrumbs": "Organizations", + + "explore.publications.breadcrumbs": "Research Outputs", + + "explore.researchoutputs.breadcrumbs": "Research Outputs", + + "explore.researcherprofiles.breadcrumbs": "People", + "explore.title": "Explore section", "feed.description": "Syndication feed", @@ -3754,6 +3796,8 @@ "menu.section.browse_global_communities_and_collections": "Communities & Collections", + "menu.section.explore_fundings_and_projects": "Fundings & Projects", + "menu.section.browse_global_geospatial_map": "By Geolocation (Map)", "menu.section.control_panel": "Control Panel", From b79a1b28518a022b674efc474e4beccc17bdef57 Mon Sep 17 00:00:00 2001 From: FrancescoMolinaro Date: Wed, 2 Sep 2026 17:01:13 +0200 Subject: [PATCH 7/7] [DURACOM-508] refactor, fix home/explore config issue, fix nested menus, fix comm/coll menu, improve tests --- config/config.example.yml | 4 +- src/app/app.menus.ts | 2 + src/app/core/data/section-data.service.ts | 10 - .../explore-page/explore-page.component.html | 5 + .../explore-page.component.spec.ts | 24 ++- .../explore-page/explore-page.component.ts | 4 +- src/app/home-page/home-page.component.html | 82 +++++---- src/app/home-page/home-page.component.spec.ts | 172 ++++++++++++++++++ src/app/home-page/home-page.component.ts | 110 ++++------- ...multi-column-top-section.component.spec.ts | 40 +++- .../multi-column-top-section.component.ts | 25 ++- ...emed-multi-column-top-section.component.ts | 6 +- .../search-section.component.html | 8 +- .../search-section.component.spec.ts | 24 +-- .../top-section/top-section.component.spec.ts | 25 ++- .../top-section/top-section.component.ts | 9 +- .../admin-community-list.menu.spec.ts | 56 ++++++ .../providers/admin-community-list.menu.ts | 32 ++++ .../providers/community-list.menu.spec.ts | 23 ++- .../menu/providers/community-list.menu.ts | 29 ++- .../menu/providers/explore.menu.spec.ts | 66 +++++++ src/app/shared/menu/providers/explore.menu.ts | 78 ++++++-- src/config/default-app-config.ts | 4 +- src/environments/environment.test.ts | 4 +- .../app/home-page/home-page.component.ts | 2 + 25 files changed, 644 insertions(+), 200 deletions(-) create mode 100644 src/app/home-page/home-page.component.spec.ts create mode 100644 src/app/shared/menu/providers/admin-community-list.menu.spec.ts create mode 100644 src/app/shared/menu/providers/admin-community-list.menu.ts diff --git a/config/config.example.yml b/config/config.example.yml index a39de92c687..e263ce2ce1c 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -404,7 +404,7 @@ homePage: # Enable or disable the Discover filters on the homepage showDiscoverFilters: false # Enable or disable the dynamic layout configuration on the homepage - enableDynamicLayout: true + enableDynamicLayout: false # Item Config item: @@ -713,7 +713,7 @@ accessibility: layout: # Enable or disable the explore pages feature (/explore/:id routes and their navbar menu entries). # When false, explore routes redirect to 404 and the explore menu is hidden. - enableExplorePages: true + enableExplorePages: false # Configuration of icons and styles to be used for each authority controlled link authorityRef: - entityType: DEFAULT diff --git a/src/app/app.menus.ts b/src/app/app.menus.ts index 481afa13fd9..75282fb22c2 100644 --- a/src/app/app.menus.ts +++ b/src/app/app.menus.ts @@ -9,6 +9,7 @@ import { buildMenuStructure } from './shared/menu/menu.structure'; import { MenuID } from './shared/menu/menu-id.model'; import { MenuRoute } from './shared/menu/menu-route.model'; import { AccessControlMenuProvider } from './shared/menu/providers/access-control.menu'; +import { AdminCommunityListMenuProvider } from './shared/menu/providers/admin-community-list.menu'; import { AdminSearchMenuProvider } from './shared/menu/providers/admin-search.menu'; import { AuditLogsMenuProvider } from './shared/menu/providers/audit-item.menu'; import { AuditOverviewMenuProvider } from './shared/menu/providers/audit-overview.menu'; @@ -64,6 +65,7 @@ export const MENUS = buildMenuStructure({ ExploreMenuProvider, ], [MenuID.ADMIN]: [ + AdminCommunityListMenuProvider, NewMenuProvider, EditMenuProvider, ImportMenuProvider, diff --git a/src/app/core/data/section-data.service.ts b/src/app/core/data/section-data.service.ts index cc18de3271b..6417ca43c69 100644 --- a/src/app/core/data/section-data.service.ts +++ b/src/app/core/data/section-data.service.ts @@ -6,12 +6,9 @@ import { DSONameService } from '../breadcrumbs/dso-name.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { Section } from '../layout/models/section.model'; -import { FollowLinkConfig } from '../shared/follow-link-config.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { FindAllData } from './base/find-all-data'; import { IdentifiableDataService } from './base/identifiable-data.service'; import { SearchDataImpl } from './base/search-data'; -import { FindListOptions } from './find-list-options.model'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; @@ -23,7 +20,6 @@ import { RequestService } from './request.service'; export class SectionDataService extends IdentifiableDataService
{ protected linkPath = 'sections'; - private findAllData: FindAllData
; private searchData: SearchDataImpl
; constructor( @@ -39,12 +35,6 @@ export class SectionDataService extends IdentifiableDataService
{ this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); } - /** - * Find all the configured sections. - */ - findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig
[]): Observable>> { - return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } /** * Finds all sections configured to be visible in the top navigation bar. diff --git a/src/app/explore-page/explore-page.component.html b/src/app/explore-page/explore-page.component.html index 20b0109d28d..69ae899ec39 100644 --- a/src/app/explore-page/explore-page.component.html +++ b/src/app/explore-page/explore-page.component.html @@ -10,6 +10,11 @@ [sectionId]="sectionId" [topSection]="$any(sectionComponent)"> } + @case ('multi-column-top') { + + } @case ('browse') { { facetsPerRow: 4, }; + const multiColumnTopComponent: MultiColumnTopSection = { + discoveryConfigurationName: 'publication', + componentType: 'multi-column-top', + style: 'col-md-12', + order: 'desc', + sortField: 'dc.date.accessioned', + titleKey: 'lastPublications', + columnList: [], + }; + beforeEach(waitForAsync(() => { sectionDataServiceStub = { @@ -94,7 +106,7 @@ describe('ExploreComponent', () => { if (id === 'publications') { const section = new Section(); section.id = 'publications'; - section.componentRows = [[browseComponent, searchComponent], [topComponent], [facetComponent]]; + section.componentRows = [[browseComponent, searchComponent], [topComponent], [facetComponent], [multiColumnTopComponent]]; return createSuccessfulRemoteDataObject$(section); } else { return of(null); @@ -119,7 +131,7 @@ describe('ExploreComponent', () => { { provide: SectionDataService, useValue: sectionDataServiceStub }, { provide: ActivatedRoute, useValue: route }], schemas: [NO_ERRORS_SCHEMA], - }).overrideComponent(ExplorePageComponent, { remove: { imports: [ThemedTopSectionComponent, ThemedBrowseSectionComponent, ThemedSearchSectionComponent, ThemedFacetSectionComponent, ThemedTextSectionComponent, ThemedCountersSectionComponent] } }).compileComponents(); + }).overrideComponent(ExplorePageComponent, { remove: { imports: [ThemedTopSectionComponent, ThemedMultiColumnTopSectionComponent, ThemedBrowseSectionComponent, ThemedSearchSectionComponent, ThemedFacetSectionComponent, ThemedTextSectionComponent, ThemedCountersSectionComponent] } }).compileComponents(); })); @@ -133,9 +145,9 @@ describe('ExploreComponent', () => { expect(comp).toBeDefined(); })); - it('should place the sections on three rows', () => { + it('should place the sections on four rows', () => { const container = fixture.debugElement.query(By.css('.container')); - expect(container.children.length).toEqual(3); + expect(container.children.length).toEqual(4); const firstRow = container.children[0]; expect(firstRow.children.length).toEqual(2); @@ -150,6 +162,10 @@ describe('ExploreComponent', () => { expect(thirdRow.children.length).toEqual(1); expect(thirdRow.children[0].children[0].name).toEqual('ds-facet-section'); + const fourthRow = container.children[3]; + expect(fourthRow.children.length).toEqual(1); + expect(fourthRow.children[0].children[0].name).toEqual('ds-multi-column-top-section'); + expect(component.sectionId).toEqual('publications'); }); diff --git a/src/app/explore-page/explore-page.component.ts b/src/app/explore-page/explore-page.component.ts index 41350d0d18d..3804a0dcd71 100644 --- a/src/app/explore-page/explore-page.component.ts +++ b/src/app/explore-page/explore-page.component.ts @@ -19,6 +19,7 @@ import { getFirstSucceededRemoteDataPayload } from '../core/shared/operators'; import { ThemedBrowseSectionComponent } from '../shared/explore/section-component/browse-section/themed-browse-section.component'; import { ThemedCountersSectionComponent } from '../shared/explore/section-component/counters-section/themed-counters-section.component'; import { ThemedFacetSectionComponent } from '../shared/explore/section-component/facet-section/themed-facet-section.component'; +import { ThemedMultiColumnTopSectionComponent } from '../shared/explore/section-component/multi-column-top-section/themed-multi-column-top-section.component'; import { ThemedSearchSectionComponent } from '../shared/explore/section-component/search-section/themed-search-section.component'; import { ThemedTextSectionComponent } from '../shared/explore/section-component/text-section/themed-text-section.component'; import { ThemedTopSectionComponent } from '../shared/explore/section-component/top-section/themed-top-section.component'; @@ -28,7 +29,7 @@ import { ThemedTopSectionComponent } from '../shared/explore/section-component/t * * Fetches section configuration from the backend based on the current route parameter * and renders the configured sections as a responsive grid of section components. - * Supported section types include: top, browse, search, facet, text-row, and counters. + * Supported section types include: top, multi-column-top, browse, search, facet, text-row, and counters. */ @Component({ selector: 'ds-explore', @@ -39,6 +40,7 @@ import { ThemedTopSectionComponent } from '../shared/explore/section-component/t ThemedBrowseSectionComponent, ThemedCountersSectionComponent, ThemedFacetSectionComponent, + ThemedMultiColumnTopSectionComponent, ThemedSearchSectionComponent, ThemedTextSectionComponent, ThemedTopSectionComponent, diff --git a/src/app/home-page/home-page.component.html b/src/app/home-page/home-page.component.html index 77dcaf1170b..042a64f659d 100644 --- a/src/app/home-page/home-page.component.html +++ b/src/app/home-page/home-page.component.html @@ -1,40 +1,6 @@ @if (!isDynamicHomePageEnabled) { - @if (homeHeaderMetadataValue$ | async; as homeHeaderMetadataValue) { -
- -
- } - - - - - @if (showDiscoverFilters) { - - - - } - @if (!showDiscoverFilters) { -
- -
- } - - - - - - - @if (recentSubmissionspageSize>0) { - - } - -} @else { + +} @else if (hasConfiguredSections$ | async) { @if ((site$.value && hasHomeHeaderMetadata)) {
} + @case ('multi-column-top') { + + } @case ('browse') { +} @else { + } + + @if (homeHeaderMetadataValue$ | async; as homeHeaderMetadataValue) { +
+ +
+ } + + + + + @if (showDiscoverFilters) { + + + + } + @if (!showDiscoverFilters) { +
+ +
+ } + + + + + + + @if (recentSubmissionspageSize>0) { + + } + +
diff --git a/src/app/home-page/home-page.component.spec.ts b/src/app/home-page/home-page.component.spec.ts new file mode 100644 index 00000000000..be420469e5b --- /dev/null +++ b/src/app/home-page/home-page.component.spec.ts @@ -0,0 +1,172 @@ +import { + AsyncPipe, + NgTemplateOutlet, +} from '@angular/common'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { APP_CONFIG } from '@dspace/config/app-config.interface'; +import { SectionDataService } from '@dspace/core/data/section-data.service'; +import { SiteDataService } from '@dspace/core/data/site-data.service'; +import { Section } from '@dspace/core/layout/models/section.model'; +import { LocaleService } from '@dspace/core/locale/locale.service'; +import { Site } from '@dspace/core/shared/site.model'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject$, +} from '@dspace/core/utilities/remote-data.utils'; +import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { HomePageComponent } from './home-page.component'; + +describe('HomePageComponent', () => { + let component: HomePageComponent; + let fixture: ComponentFixture; + + let sectionDataService: jasmine.SpyObj; + let siteService: jasmine.SpyObj; + let localeService: jasmine.SpyObj; + + const site = Object.assign(new Site(), { + id: 'test-site', + firstMetadataValue: () => undefined, + }); + + const buildConfig = (enableDynamicLayout: boolean) => ({ + homePage: { + recentSubmissions: { pageSize: 5 }, + showDiscoverFilters: false, + enableDynamicLayout, + }, + }); + + + const setup = (enableDynamicLayout: boolean, findByIdResult: any) => { + sectionDataService = jasmine.createSpyObj('SectionDataService', ['findById']); + sectionDataService.findById.and.returnValue(findByIdResult); + + siteService = jasmine.createSpyObj('SiteDataService', ['find']); + siteService.find.and.returnValue(of(site)); + + localeService = jasmine.createSpyObj('LocaleService', ['getCurrentLanguageCode']); + localeService.getCurrentLanguageCode.and.returnValue(of('en')); + + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + HomePageComponent, + ], + providers: [ + { provide: APP_CONFIG, useValue: buildConfig(enableDynamicLayout) }, + { provide: SectionDataService, useValue: sectionDataService }, + { provide: SiteDataService, useValue: siteService }, + { provide: LocaleService, useValue: localeService }, + { provide: ActivatedRoute, useValue: { data: of({ site }) } }, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(HomePageComponent, { + set: { + imports: [AsyncPipe, NgTemplateOutlet, TranslateModule], + schemas: [NO_ERRORS_SCHEMA], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(HomePageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }; + + const sectionWith = (componentRows: any[][]): Section => + Object.assign(new Section(), { id: 'site', componentRows }); + + describe('when dynamic layout is disabled (static mode)', () => { + beforeEach(() => { + setup(false, createSuccessfulRemoteDataObject$(sectionWith([[{ componentType: 'top', style: '' }]]))); + }); + + it('should render the static home page', () => { + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('ds-home-news')).toBeTruthy(); + expect(el.querySelector('#home-header-wrapper')).toBeFalsy(); + }); + + it('should not request the section configuration', () => { + expect(sectionDataService.findById).not.toHaveBeenCalled(); + }); + }); + + describe('when dynamic layout is enabled and sections are configured', () => { + beforeEach(() => { + setup(true, createSuccessfulRemoteDataObject$(sectionWith([[{ componentType: 'top', style: '' }]]))); + }); + + it('should report configured sections', (done) => { + component.hasConfiguredSections$.subscribe((has) => { + expect(has).toBeTrue(); + done(); + }); + }); + + it('should request the section configuration', () => { + expect(sectionDataService.findById).toHaveBeenCalledWith('site'); + }); + + it('should render the dynamic section layout', () => { + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('ds-top-section')).toBeTruthy(); + }); + }); + + describe('when dynamic layout is enabled but no sections are configured', () => { + beforeEach(() => { + setup(true, createSuccessfulRemoteDataObject$(sectionWith([]))); + }); + + it('should report no configured sections', (done) => { + component.hasConfiguredSections$.subscribe((has) => { + expect(has).toBeFalse(); + done(); + }); + }); + + it('should fall back to the static home page', () => { + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('ds-home-news')).toBeTruthy(); + expect(el.querySelector('ds-top-section')).toBeFalsy(); + }); + }); + + describe('when dynamic layout is enabled but rows contain only empty columns', () => { + beforeEach(() => { + setup(true, createSuccessfulRemoteDataObject$(sectionWith([[]]))); + }); + + it('should report no configured sections', (done) => { + component.hasConfiguredSections$.subscribe((has) => { + expect(has).toBeFalse(); + done(); + }); + }); + }); + + describe('when dynamic layout is enabled but the section config fails to load', () => { + beforeEach(() => { + setup(true, createFailedRemoteDataObject$('error', 500)); + }); + + it('should report no configured sections and fall back to static', (done) => { + component.hasConfiguredSections$.subscribe((has) => { + expect(has).toBeFalse(); + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('ds-home-news')).toBeTruthy(); + done(); + }); + }); + }); +}); diff --git a/src/app/home-page/home-page.component.ts b/src/app/home-page/home-page.component.ts index f5faa945e93..8ccbc02c753 100644 --- a/src/app/home-page/home-page.component.ts +++ b/src/app/home-page/home-page.component.ts @@ -1,21 +1,17 @@ import { AsyncPipe, - isPlatformServer, NgTemplateOutlet, } from '@angular/common'; import { Component, Inject, - OnDestroy, OnInit, - PLATFORM_ID, } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { APP_CONFIG, AppConfig, } from '@dspace/config/app-config.interface'; -import { NotifyInfoService } from '@dspace/core/coar-notify/notify-info/notify-info.service'; import { SectionDataService } from '@dspace/core/data/section-data.service'; import { SiteDataService } from '@dspace/core/data/site-data.service'; import { @@ -24,11 +20,9 @@ import { } from '@dspace/core/layout/models/section.model'; import { LocaleService } from '@dspace/core/locale/locale.service'; import { - LinkDefinition, - LinkHeadService, -} from '@dspace/core/services/link-head.service'; -import { ServerResponseService } from '@dspace/core/services/server-response.service'; -import { getFirstSucceededRemoteDataPayload } from '@dspace/core/shared/operators'; + getFirstCompletedRemoteData, + getRemoteDataPayload, +} from '@dspace/core/shared/operators'; import { Site } from '@dspace/core/shared/site.model'; import { isEmpty, @@ -43,7 +37,7 @@ import { } from 'rxjs'; import { map, - switchMap, + shareReplay, take, } from 'rxjs/operators'; @@ -52,6 +46,7 @@ import { ThemedConfigurationSearchPageComponent } from '../search-page/themed-co import { ThemedBrowseSectionComponent } from '../shared/explore/section-component/browse-section/themed-browse-section.component'; import { ThemedCountersSectionComponent } from '../shared/explore/section-component/counters-section/themed-counters-section.component'; import { ThemedFacetSectionComponent } from '../shared/explore/section-component/facet-section/themed-facet-section.component'; +import { ThemedMultiColumnTopSectionComponent } from '../shared/explore/section-component/multi-column-top-section/themed-multi-column-top-section.component'; import { ThemedSearchSectionComponent } from '../shared/explore/section-component/search-section/themed-search-section.component'; import { ThemedTextSectionComponent } from '../shared/explore/section-component/text-section/themed-text-section.component'; import { ThemedTopSectionComponent } from '../shared/explore/section-component/top-section/themed-top-section.component'; @@ -68,7 +63,9 @@ import { ThemedTopLevelCommunityListComponent } from './top-level-community-list * Supports both a static layout and a dynamic layout driven by section configurations * fetched from the REST API. When dynamic layout is enabled (via `enableDynamicLayout` config), * it renders section components similar to the explore page (top, browse, search, facet, text-row, counters). - * Also handles COAR Notify inbox link headers and site metadata rendering. + * Also handles site metadata rendering. + * + * COAR Notify inbox link headers are handled by the {@link HomeCoarComponent} rendered in the template. */ @Component({ selector: 'ds-base-home-page', @@ -86,6 +83,7 @@ import { ThemedTopLevelCommunityListComponent } from './top-level-community-list ThemedCountersSectionComponent, ThemedFacetSectionComponent, ThemedHomeNewsComponent, + ThemedMultiColumnTopSectionComponent, ThemedSearchFormComponent, ThemedSearchSectionComponent, ThemedTextSectionComponent, @@ -94,7 +92,7 @@ import { ThemedTopLevelCommunityListComponent } from './top-level-community-list TranslateModule, ], }) -export class HomePageComponent implements OnInit, OnDestroy { +export class HomePageComponent implements OnInit { site$: BehaviorSubject = new BehaviorSubject(null); recentSubmissionspageSize: number; @@ -113,50 +111,35 @@ export class HomePageComponent implements OnInit, OnDestroy { */ sectionComponents: Observable; + /** + * Emits `true` when the dynamic home page has at least one configured section + * component to render, `false` otherwise (no sections configured, empty rows, + * or the section configuration could not be retrieved). When `false`, the home + * page falls back to the default (static) layout. + */ + hasConfiguredSections$: Observable; + /** Whether the site has a home header metadata value in the current language. */ hasHomeHeaderMetadata: boolean; /** Default text-row section configuration for the home header CMS metadata. */ homeHeaderSection: TextRowSection = { - content: 'cris.cms.home-header', + content: 'dspace.cms.home-header', contentType: 'text-metadata', componentType: 'text-row', style: '', }; - /** - * An array of LinkDefinition objects representing inbox links for the home page. - */ - inboxLinks: LinkDefinition[] = []; - constructor( @Inject(APP_CONFIG) protected appConfig: AppConfig, - @Inject(PLATFORM_ID) private platformId: string, private route: ActivatedRoute, private sectionDataService: SectionDataService, private siteService: SiteDataService, private locale: LocaleService, - private responseService: ServerResponseService, - private notifyInfoService: NotifyInfoService, - protected linkHeadService: LinkHeadService, ) { this.recentSubmissionspageSize = this.appConfig.homePage.recentSubmissions.pageSize; this.showDiscoverFilters = this.appConfig.homePage.showDiscoverFilters; this.isDynamicHomePageEnabled = this.appConfig.homePage.enableDynamicLayout; - - this.notifyInfoService.isCoarConfigEnabled().pipe( - switchMap((coarLdnEnabled: boolean) => { - if (coarLdnEnabled) { - return this.notifyInfoService.getCoarLdnLocalInboxUrls(); - } else { - return of([]); - } - }), - ).subscribe((coarRestApiUrls: string[]) => { - if (coarRestApiUrls.length > 0) { - this.initPageLinks(coarRestApiUrls); - } - }); } ngOnInit(): void { @@ -175,14 +158,25 @@ export class HomePageComponent implements OnInit, OnDestroy { map(({ site, language }) => site?.firstMetadataValue('dspace.cms.home-header', { language })), ); - this.sectionComponents = this.sectionDataService.findById('site').pipe( - getFirstSucceededRemoteDataPayload(), - map((section) => section.componentRows), - ); + if (this.isDynamicHomePageEnabled) { + this.sectionComponents = this.sectionDataService.findById('site').pipe( + getFirstCompletedRemoteData(), + getRemoteDataPayload(), + map((section) => section?.componentRows ?? []), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + this.hasConfiguredSections$ = this.sectionComponents.pipe( + map((rows: SectionComponent[][]) => isNotEmpty(rows) && rows.some((row: SectionComponent[]) => isNotEmpty(row))), + ); + } else { + this.sectionComponents = of([]); + this.hasConfiguredSections$ = of(false); + } combineLatest([this.siteService.find().pipe(take(1)), this.locale.getCurrentLanguageCode()]).subscribe( ([site, language]: [Site, string]) => { - this.hasHomeHeaderMetadata = !isEmpty(site?.firstMetadataValue('cris.cms.home-header', + this.hasHomeHeaderMetadata = !isEmpty(site?.firstMetadataValue('dspace.cms.home-header', { language })); }, ); @@ -200,38 +194,4 @@ export class HomePageComponent implements OnInit, OnDestroy { return (isNotEmpty(sectionComponent.style) && sectionComponent.style.includes('col')) ? sectionComponent.style : `${defaultCol} ${sectionComponent.style}`; } - - /** - * Initializes page links for COAR REST API URLs. - * @param coarRestApiUrls An array of COAR REST API URLs. - */ - private initPageLinks(coarRestApiUrls: string[]): void { - const rel = this.notifyInfoService.getInboxRelationLink(); - let links = ''; - coarRestApiUrls.forEach((coarRestApiUrl: string) => { - // Add link to head - const tag: LinkDefinition = { - href: coarRestApiUrl, - rel: rel, - }; - this.inboxLinks.push(tag); - this.linkHeadService.addTag(tag); - - links = links + (isNotEmpty(links) ? ', ' : '') + `<${coarRestApiUrl}> ; rel="${rel}"`; - }); - - if (isPlatformServer(this.platformId)) { - // Add link to response header - this.responseService.setHeader('Link', links); - } - } - - /** - * It removes the inbox links from the head of the html. - */ - ngOnDestroy(): void { - this.inboxLinks.forEach((link: LinkDefinition) => { - this.linkHeadService.removeTag(`href='${link.href}'`); - }); - } } diff --git a/src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.spec.ts b/src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.spec.ts index 9d0e2e09df2..34fd4e0ea6d 100644 --- a/src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.spec.ts +++ b/src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.spec.ts @@ -3,16 +3,39 @@ import { TestBed, waitForAsync, } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { SearchManager } from '@dspace/core/browse/search-manager'; +import { MultiColumnTopSection } from '@dspace/core/layout/models/section.model'; +import { PaginatedSearchOptions } from '@dspace/core/shared/search/models/paginated-search-options.model'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { TranslateModule } from '@ngx-translate/core'; import { MultiColumnTopSectionComponent } from './multi-column-top-section.component'; -xdescribe('MultiColumnTopSectionComponent', () => { +describe('MultiColumnTopSectionComponent', () => { let component: MultiColumnTopSectionComponent; let fixture: ComponentFixture; + let searchManager: jasmine.SpyObj; + + const topSection: MultiColumnTopSection = { + discoveryConfigurationName: 'publication', + componentType: 'multi-column-top', + style: 'col-md-12', + order: 'desc', + sortField: 'dc.date.accessioned', + titleKey: 'lastPublications', + columnList: [], + }; beforeEach(waitForAsync(() => { + searchManager = jasmine.createSpyObj('SearchManager', ['search']); + searchManager.search.and.returnValue(createSuccessfulRemoteDataObject$({ page: [] } as any)); + TestBed.configureTestingModule({ - imports: [MultiColumnTopSectionComponent], + imports: [MultiColumnTopSectionComponent, RouterTestingModule, TranslateModule.forRoot()], + providers: [ + { provide: SearchManager, useValue: searchManager }, + ], }) .compileComponents(); })); @@ -20,10 +43,23 @@ xdescribe('MultiColumnTopSectionComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(MultiColumnTopSectionComponent); component = fixture.componentInstance; + component.topSection = topSection; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should generate a unique pagination id per instance', () => { + const second = TestBed.createComponent(MultiColumnTopSectionComponent).componentInstance; + expect(component.paginationId).toMatch(/^search-object-pagination-/); + expect(second.paginationId).toMatch(/^search-object-pagination-/); + expect(component.paginationId).not.toEqual(second.paginationId); + }); + + it('should use its unique pagination id in the search options', () => { + const options = searchManager.search.calls.mostRecent().args[0] as PaginatedSearchOptions; + expect(options.pagination.id).toEqual(component.paginationId); + }); }); diff --git a/src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.ts b/src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.ts index 7c3073297d8..8f8a90e8011 100644 --- a/src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.ts +++ b/src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.ts @@ -1,8 +1,13 @@ +import { + AsyncPipe, + NgClass, +} from '@angular/common'; import { Component, Input, OnInit, } from '@angular/core'; +import { RouterLink } from '@angular/router'; import { SearchManager } from '@dspace/core/browse/search-manager'; import { SortDirection, @@ -21,8 +26,10 @@ import { getFirstSucceededRemoteDataPayload } from '@dspace/core/shared/operator import { PaginatedSearchOptions } from '@dspace/core/shared/search/models/paginated-search-options.model'; import { SearchObjects } from '@dspace/core/shared/search/models/search-objects.model'; import { SearchResult } from '@dspace/core/shared/search/models/search-result.model'; +import { TranslateModule } from '@ngx-translate/core'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; +import { v4 as uuidv4 } from 'uuid'; /** @@ -32,20 +39,32 @@ import { map } from 'rxjs/operators'; @Component({ selector: 'ds-base-multi-column-top-section', templateUrl: './multi-column-top-section.component.html', + imports: [ + AsyncPipe, + NgClass, + RouterLink, + TranslateModule, + ], }) export class MultiColumnTopSectionComponent implements OnInit { /** Unique identifier for this section instance. */ @Input() - sectionId: string; + sectionId: string; /** Configuration object defining discovery query, sort, and column layout. */ @Input() - topSection: MultiColumnTopSection; + topSection: MultiColumnTopSection; /** Observable emitting the array of top DSpaceObjects fetched from the search. */ topObjects: Observable; + /** + * Unique pagination id for this section instance. + * Generated per instance so multiple top sections on the same page do not share pagination state. + */ + paginationId = `search-object-pagination-${uuidv4()}`; + constructor(private searchService: SearchManager) { } @@ -54,7 +73,7 @@ export class MultiColumnTopSectionComponent implements OnInit { const order = this.topSection.order; const sortDirection = order && order.toUpperCase() === 'ASC' ? SortDirection.ASC : SortDirection.DESC; const pagination: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { - id: 'search-object-pagination', + id: this.paginationId, pageSize: 50, currentPage: 1, }); diff --git a/src/app/shared/explore/section-component/multi-column-top-section/themed-multi-column-top-section.component.ts b/src/app/shared/explore/section-component/multi-column-top-section/themed-multi-column-top-section.component.ts index b55f824ba39..98970bbf9c0 100644 --- a/src/app/shared/explore/section-component/multi-column-top-section/themed-multi-column-top-section.component.ts +++ b/src/app/shared/explore/section-component/multi-column-top-section/themed-multi-column-top-section.component.ts @@ -2,8 +2,8 @@ import { Component, Input, } from '@angular/core'; +import { MultiColumnTopSection } from '@dspace/core/layout/models/section.model'; -import { MultiColumnTopSection } from '../../../../core/layout/models/section.model'; import { ThemedComponent } from '../../../theme-support/themed.component'; import { MultiColumnTopSectionComponent } from './multi-column-top-section.component'; @@ -18,10 +18,10 @@ import { MultiColumnTopSectionComponent } from './multi-column-top-section.compo export class ThemedMultiColumnTopSectionComponent extends ThemedComponent { @Input() - sectionId: string; + sectionId: string; @Input() - topSection: MultiColumnTopSection; + topSection: MultiColumnTopSection; protected inAndOutputNames: (keyof MultiColumnTopSectionComponent & keyof this)[] = ['sectionId', 'topSection']; diff --git a/src/app/shared/explore/section-component/search-section/search-section.component.html b/src/app/shared/explore/section-component/search-section/search-section.component.html index 7df5955e47a..1bb25fd8452 100644 --- a/src/app/shared/explore/section-component/search-section/search-section.component.html +++ b/src/app/shared/explore/section-component/search-section/search-section.component.html @@ -8,18 +8,18 @@

{{ 'explore.search-section.' + sectionId + '.title' | translate }}

- @for (filter of (filters | async); track filter) { }
- +
@if (!isLast) { - @for (operation of operations; track operation) { } @@ -52,7 +52,7 @@

{{ 'explore.search-section.' + sectionId + '.title' | translate }}

> + [searchPlaceholder]="'search.search-form.placeholder' | translate"> }
diff --git a/src/app/shared/explore/section-component/search-section/search-section.component.spec.ts b/src/app/shared/explore/section-component/search-section/search-section.component.spec.ts index 22a67940617..a7f38e86913 100644 --- a/src/app/shared/explore/section-component/search-section/search-section.component.spec.ts +++ b/src/app/shared/explore/section-component/search-section/search-section.component.spec.ts @@ -122,22 +122,22 @@ describe('SearchSectionComponent', () => { expect(formRows.length).toEqual(3); for (const formRow of formRows) { - const filterSelect = formRow.query(By.css('#filter')); + const filterSelect = formRow.query(By.css('[id^="filter"]')); expect(filterSelect).not.toBeNull(); const filterOptions = filterSelect.children; expect(filterOptions.length).toEqual(3); expect(filterOptions.map((el) => el.nativeElement.value)).toEqual(['all','author','subject']); - const queryInput = formRow.query(By.css('#query')); + const queryInput = formRow.query(By.css('[id^="query"]')); expect(queryInput).not.toBeNull(); expect(queryInput.nativeElement.value).toEqual(''); const isLastRow = formRows.indexOf(formRow) === 2; if ( isLastRow ) { expect(formRow.query(By.css('#addButton'))).not.toBeNull(); - expect(formRow.query(By.css('#operation'))).toBeNull(); + expect(formRow.query(By.css('[id^="operation"]'))).toBeNull(); } else { - const operationSelect = formRow.query(By.css('#operation')); + const operationSelect = formRow.query(By.css('[id^="operation"]')); expect(operationSelect).not.toBeNull(); const operationOptions = operationSelect.children; expect(operationOptions.length).toEqual(3); @@ -167,9 +167,9 @@ describe('SearchSectionComponent', () => { beforeEach(() => { const firstFormRow = fixture.debugElement.queryAll(By.css('[data-test="form-row"]'))[0]; - const filterSelect = firstFormRow.query(By.css('#filter')); + const filterSelect = firstFormRow.query(By.css('[id^="filter"]')); filterSelect.nativeElement.value = 'author'; - const queryInput = firstFormRow.query(By.css('#query')); + const queryInput = firstFormRow.query(By.css('[id^="query"]')); queryInput.nativeElement.value = 'Adam'; fixture.detectChanges(); }); @@ -184,9 +184,9 @@ describe('SearchSectionComponent', () => { const formRows = fixture.debugElement.queryAll(By.css('[data-test="form-row"]')); expect(formRows.length).toEqual(3); const firstFormRow = fixture.debugElement.queryAll(By.css('[data-test="form-row"]'))[0]; - const filterSelect = firstFormRow.query(By.css('#filter')); + const filterSelect = firstFormRow.query(By.css('[id^="filter"]')); expect(filterSelect.nativeElement.value).toEqual('all'); - const queryInput = firstFormRow.query(By.css('#query')); + const queryInput = firstFormRow.query(By.css('[id^="query"]')); expect(queryInput.nativeElement.value).toEqual(''); }); }); @@ -195,18 +195,18 @@ describe('SearchSectionComponent', () => { beforeEach(() => { const firstFormRow = fixture.debugElement.queryAll(By.css('[data-test="form-row"]'))[0]; - const filterSelect = firstFormRow.query(By.css('#filter')).nativeElement; + const filterSelect = firstFormRow.query(By.css('[id^="filter"]')).nativeElement; filterSelect.value = 'author'; filterSelect.dispatchEvent(new Event('change')); - const firstQueryInput = firstFormRow.query(By.css('#query')).nativeElement; + const firstQueryInput = firstFormRow.query(By.css('[id^="query"]')).nativeElement; firstQueryInput.value = 'Adam'; firstQueryInput.dispatchEvent(new Event('input')); - const operationInput = firstFormRow.query(By.css('#operation')).nativeElement; + const operationInput = firstFormRow.query(By.css('[id^="operation"]')).nativeElement; operationInput.value = 'OR'; operationInput.dispatchEvent(new Event('change')); const secondFormRow = fixture.debugElement.queryAll(By.css('[data-test="form-row"]'))[1]; - const secondQueryInput = secondFormRow.query(By.css('#query')).nativeElement; + const secondQueryInput = secondFormRow.query(By.css('[id^="query"]')).nativeElement; secondQueryInput.value = 'test'; secondQueryInput.dispatchEvent(new Event('input')); fixture.detectChanges(); diff --git a/src/app/shared/explore/section-component/top-section/top-section.component.spec.ts b/src/app/shared/explore/section-component/top-section/top-section.component.spec.ts index 696897bbddf..7b8207b194b 100644 --- a/src/app/shared/explore/section-component/top-section/top-section.component.spec.ts +++ b/src/app/shared/explore/section-component/top-section/top-section.component.spec.ts @@ -107,20 +107,17 @@ describe('TopSectionComponent', () => { expect(comp).toBeDefined(); })); - // it('should create a top section with two entries', () => { - // - // const cardElement = fixture.debugElement.query(By.css('.card.mb-4')); - // expect(cardElement).not.toBeNull(); - // expect(cardElement.query(By.css('.card-header')).nativeElement.textContent).toEqual('explore.index.dc.date.accessioned'); - // - // const links = cardElement.queryAll(By.css('a')); - // expect(links.length).toEqual(2); - // expect(links[0].nativeElement.href).toContain('/entities/publication/d317835d-7b06-4219-91e2-1191900cb897'); - // expect(links[0].nativeElement.textContent).toEqual('My first publication'); - // expect(links[1].nativeElement.href).toContain('items/0c34d491-b5ed-4a78-8b29-83d0bad80e5a'); - // expect(links[1].nativeElement.textContent).toEqual('This is a publication'); - // - // }); + it('should generate a unique pagination id per instance', () => { + const second = TestBed.createComponent(TopSectionComponent).componentInstance; + expect(component.paginationId).toMatch(/^search-object-pagination-/); + expect(second.paginationId).toMatch(/^search-object-pagination-/); + expect(component.paginationId).not.toEqual(second.paginationId); + }); + + it('should use its unique pagination id in the paginated search options', () => { + expect(component.paginatedSearchOptions.pagination.id).toEqual(component.paginationId); + }); + describe('Top section with title key defined', () => { beforeEach(() => { diff --git a/src/app/shared/explore/section-component/top-section/top-section.component.ts b/src/app/shared/explore/section-component/top-section/top-section.component.ts index 0722cbb92b9..60a37b40f59 100644 --- a/src/app/shared/explore/section-component/top-section/top-section.component.ts +++ b/src/app/shared/explore/section-component/top-section/top-section.component.ts @@ -13,6 +13,7 @@ import { PaginationComponentOptions } from '@dspace/core/pagination/pagination-c import { Context } from '@dspace/core/shared/context.model'; import { PaginatedSearchOptions } from '@dspace/core/shared/search/models/paginated-search-options.model'; import { TranslateModule } from '@ngx-translate/core'; +import { v4 as uuidv4 } from 'uuid'; import { ThemedBrowseMostElementsComponent } from '../../../browse-most-elements/themed-browse-most-elements.component'; @@ -45,12 +46,18 @@ export class TopSectionComponent implements OnInit { /** Paginated search options built from the topSection configuration, passed to the browse component. */ paginatedSearchOptions: PaginatedSearchOptions; + /** + * Unique pagination id for this section instance. + * Generated per instance so multiple top sections on the same page do not share pagination state. + */ + paginationId = `search-object-pagination-${uuidv4()}`; + ngOnInit() { const order = this.topSection.order; const numberOfItems = this.topSection.numberOfItems; const sortDirection = order && order.toUpperCase() === 'ASC' ? SortDirection.ASC : SortDirection.DESC; const pagination: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { - id: 'search-object-pagination', + id: this.paginationId, pageSize: numberOfItems, currentPage: 1, }); diff --git a/src/app/shared/menu/providers/admin-community-list.menu.spec.ts b/src/app/shared/menu/providers/admin-community-list.menu.spec.ts new file mode 100644 index 00000000000..3a2fa7bd2e5 --- /dev/null +++ b/src/app/shared/menu/providers/admin-community-list.menu.spec.ts @@ -0,0 +1,56 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { TestBed } from '@angular/core/testing'; +import { APP_CONFIG } from '@dspace/config/app-config.interface'; + +import { MenuItemType } from '../menu-item-type.model'; +import { PartialMenuSection } from '../menu-provider.model'; +import { AdminCommunityListMenuProvider } from './admin-community-list.menu'; + +describe('AdminCommunityListMenuProvider', () => { + const expectedSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.LINK, + text: `menu.section.browse_global_communities_and_collections`, + link: `/community-list`, + }, + icon: 'diagram-project', + }, + ]; + + const createProvider = (showCommunityCollection: boolean): AdminCommunityListMenuProvider => { + TestBed.configureTestingModule({ + providers: [ + AdminCommunityListMenuProvider, + { provide: APP_CONFIG, useValue: { layout: { navbar: { showCommunityCollection } } } }, + ], + }); + return TestBed.inject(AdminCommunityListMenuProvider); + }; + + it('should be created', () => { + expect(createProvider(true)).toBeTruthy(); + }); + + it('getSections should return the community list section when showCommunityCollection is disabled (navbar fallback)', (done) => { + createProvider(false).getSections().subscribe((sections) => { + expect(sections).toEqual(expectedSections); + done(); + }); + }); + + it('getSections should return no sections when showCommunityCollection is enabled (shown in navbar instead)', (done) => { + createProvider(true).getSections().subscribe((sections) => { + expect(sections).toEqual([]); + done(); + }); + }); +}); diff --git a/src/app/shared/menu/providers/admin-community-list.menu.ts b/src/app/shared/menu/providers/admin-community-list.menu.ts new file mode 100644 index 00000000000..e693d1e1b22 --- /dev/null +++ b/src/app/shared/menu/providers/admin-community-list.menu.ts @@ -0,0 +1,32 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { Injectable } from '@angular/core'; +import { + Observable, + of, +} from 'rxjs'; + +import { PartialMenuSection } from '../menu-provider.model'; +import { CommunityListMenuProvider } from './community-list.menu'; + +/** + * Menu provider to create the "Communities & Collections" menu section in the admin sidebar. + * + * It is a distinct provider class (extending {@link CommunityListMenuProvider}) so the menu infrastructure resolves it + * as a separate instance with its own {@link menuID}, independent of the public navbar provider. + * + * The section is shown only when the {@link showInNavbar} flag is disabled, so that administrators can still reach the + * community list when it is hidden from the public navbar. + */ +@Injectable() +export class AdminCommunityListMenuProvider extends CommunityListMenuProvider { + public getSections(): Observable { + return of(!this.showInNavbar ? this.communityListSection() : []); + } +} diff --git a/src/app/shared/menu/providers/community-list.menu.spec.ts b/src/app/shared/menu/providers/community-list.menu.spec.ts index 013c5b85e98..d17f497f52f 100644 --- a/src/app/shared/menu/providers/community-list.menu.spec.ts +++ b/src/app/shared/menu/providers/community-list.menu.spec.ts @@ -26,26 +26,31 @@ describe('CommunityListMenuProvider', () => { }, ]; - let provider: CommunityListMenuProvider; - - beforeEach(() => { + const createProvider = (showCommunityCollection: boolean): CommunityListMenuProvider => { TestBed.configureTestingModule({ providers: [ CommunityListMenuProvider, - { provide: APP_CONFIG, useValue: { layout: { navbar: { showCommunityCollection: true } } } }, + { provide: APP_CONFIG, useValue: { layout: { navbar: { showCommunityCollection } } } }, ], }); - provider = TestBed.inject(CommunityListMenuProvider); - }); + return TestBed.inject(CommunityListMenuProvider); + }; it('should be created', () => { - expect(provider).toBeTruthy(); + expect(createProvider(true)).toBeTruthy(); }); - it('getSections should return expected menu sections', (done) => { - provider.getSections().subscribe((sections) => { + it('getSections should return the community list section when showCommunityCollection is enabled', (done) => { + createProvider(true).getSections().subscribe((sections) => { expect(sections).toEqual(expectedSections); done(); }); }); + + it('getSections should return no sections when showCommunityCollection is disabled', (done) => { + createProvider(false).getSections().subscribe((sections) => { + expect(sections).toEqual([]); + done(); + }); + }); }); diff --git a/src/app/shared/menu/providers/community-list.menu.ts b/src/app/shared/menu/providers/community-list.menu.ts index b594edddfb5..c7d43c13663 100644 --- a/src/app/shared/menu/providers/community-list.menu.ts +++ b/src/app/shared/menu/providers/community-list.menu.ts @@ -23,17 +23,28 @@ import { } from '../menu-provider.model'; /** - * Menu provider to create the "Communities & Collections" menu section in the public navbar + * Menu provider to create the "Communities & Collections" menu section in the public navbar. + * + * The section is shown only when the {@link https://wiki.lyrasis.org/display/DSDOC9x/ layout.navbar.showCommunityCollection} + * config flag is enabled. When it is disabled, {@link AdminCommunityListMenuProvider} surfaces the same section in the + * admin sidebar instead, so the community list stays reachable. */ @Injectable() export class CommunityListMenuProvider extends AbstractMenuProvider { protected appConfig = inject(APP_CONFIG); - public getSections(): Observable { - if (!this.appConfig.layout.navbar.showCommunityCollection) { - return of([]); - } - return of([ + /** + * Whether the "Communities & Collections" link is configured to appear in the public navbar. + */ + protected get showInNavbar(): boolean { + return this.appConfig.layout.navbar.showCommunityCollection; + } + + /** + * The "Communities & Collections" menu section linking to the community list page. + */ + protected communityListSection(): PartialMenuSection[] { + return [ { visible: true, model: { @@ -43,6 +54,10 @@ export class CommunityListMenuProvider extends AbstractMenuProvider { }, icon: 'diagram-project', }, - ] as PartialMenuSection[]); + ]; + } + + public getSections(): Observable { + return of(this.showInNavbar ? this.communityListSection() : []); } } diff --git a/src/app/shared/menu/providers/explore.menu.spec.ts b/src/app/shared/menu/providers/explore.menu.spec.ts index 3b01805cb66..a86df6f4b67 100644 --- a/src/app/shared/menu/providers/explore.menu.spec.ts +++ b/src/app/shared/menu/providers/explore.menu.spec.ts @@ -41,6 +41,7 @@ describe('ExploreMenuProvider', () => { ], }); provider = TestBed.inject(ExploreMenuProvider); + provider.menuProviderId = 'explore'; } describe('when enableExplorePages is true', () => { @@ -86,6 +87,71 @@ describe('ExploreMenuProvider', () => { }); }); + describe('when a visible section has nested sections', () => { + const nestedMockSections = [ + { id: 'publications', componentRows: [], nestedSections: [] }, + { + id: 'people', + componentRows: [], + nestedSections: [ + { id: 'researchers', componentRows: [], nestedSections: [] }, + { id: 'staff', componentRows: [], nestedSections: [] }, + ], + }, + ]; + + beforeEach(() => { + sectionDataServiceStub = { + findVisibleSections: jasmine.createSpy('findVisibleSections').and.returnValue( + createSuccessfulRemoteDataObject$(createPaginatedList(nestedMockSections)), + ), + }; + + TestBed.configureTestingModule({ + providers: [ + ExploreMenuProvider, + { provide: APP_CONFIG, useValue: { layout: { enableExplorePages: true } } }, + { provide: SectionDataService, useValue: sectionDataServiceStub }, + ], + }); + provider = TestBed.inject(ExploreMenuProvider); + provider.menuProviderId = 'explore'; + }); + + it('should render a flat link for a section without nested sections', (done) => { + provider.getSections().subscribe((sections) => { + const flat = sections.find((s) => s.model.type === MenuItemType.LINK && (s.model as any).link === '/explore/publications'); + expect(flat).toBeDefined(); + expect(flat.parentID).toBeUndefined(); + done(); + }); + }); + + it('should render an expandable top section for a section with nested sections', (done) => { + provider.getSections().subscribe((sections) => { + const top = sections.find((s) => s.id === 'explore_people'); + expect(top).toBeDefined(); + expect(top.model.type).toEqual(MenuItemType.TEXT); + expect((top.model as any).text).toEqual('menu.section.explore_people'); + expect(top.alwaysRenderExpandable).toBeTrue(); + done(); + }); + }); + + it('should render a child link for each nested section, linked to the parent', (done) => { + provider.getSections().subscribe((sections) => { + const children = sections.filter((s) => s.parentID === 'explore_people'); + expect(children.length).toEqual(2); + children.forEach((child) => { + expect(child.model.type).toEqual(MenuItemType.LINK); + expect(child.alwaysRenderExpandable).toBeFalse(); + }); + expect(children.map((c) => (c.model as any).link)).toEqual(['/explore/researchers', '/explore/staff']); + done(); + }); + }); + }); + describe('when enableExplorePages is false', () => { beforeEach(() => { configureTestingModule(false); diff --git a/src/app/shared/menu/providers/explore.menu.ts b/src/app/shared/menu/providers/explore.menu.ts index c3dead21611..8d4b33dbe07 100644 --- a/src/app/shared/menu/providers/explore.menu.ts +++ b/src/app/shared/menu/providers/explore.menu.ts @@ -16,6 +16,7 @@ import { RemoteData } from '@dspace/core/data/remote-data'; import { SectionDataService } from '@dspace/core/data/section-data.service'; import { Section } from '@dspace/core/layout/models/section.model'; import { getFirstSucceededRemoteData } from '@dspace/core/shared/operators'; +import { isEmpty } from '@dspace/shared/utils/empty.util'; import { Observable, of, @@ -43,7 +44,12 @@ export class ExploreMenuProvider extends AbstractMenuProvider { } /** - * Retrieves subsections by fetching the browse definitions from the backend and mapping them to partial menu sections. + * Retrieves the explore menu sections by fetching the visible sections from the backend. + * + * A section without nested sections is rendered as a flat link. A section that has nested sections is rendered as an + * expandable navbar section: a top (text) section plus a link for each nested section, so it renders as a + * `ds-expandable-navbar-section`. + * * Returns an empty array when explore pages are disabled in the app configuration. */ getSections(): Observable { @@ -53,19 +59,65 @@ export class ExploreMenuProvider extends AbstractMenuProvider { return this.sectionDataService.findVisibleSections().pipe( getFirstSucceededRemoteData(), map((rd: RemoteData>) => { - return [ - ...rd.payload.page.map((browseDef) => { - return { - visible: true, - model: { - type: MenuItemType.LINK, - text: `menu.section.explore_${browseDef.id}`, - link: `/explore/${browseDef.id}`, - }, - }; - }), - ]; + return rd.payload.page.reduce((sections: PartialMenuSection[], section: Section) => { + return [ + ...sections, + ...this.sectionToMenuSections(section), + ]; + }, [] as PartialMenuSection[]); }), ); } + + /** + * Convert a single visible {@link Section} into one or more {@link PartialMenuSection}s. + * - Without nested sections: a single flat link section. + * - With nested sections: an expandable top section plus one child link per nested section. + * + * @param section the visible section to convert + */ + protected sectionToMenuSections(section: Section): PartialMenuSection[] { + if (isEmpty(section.nestedSections)) { + return [this.exploreLinkSection(section.id)]; + } + + const parentID = `${this.menuProviderId}_${section.id}`; + const childSections: PartialMenuSection[] = section.nestedSections.map((nestedSection: Section) => ({ + ...this.exploreLinkSection(nestedSection.id), + id: `${parentID}_${nestedSection.id}`, + parentID, + alwaysRenderExpandable: false, + })); + + const topSection: PartialMenuSection = { + id: parentID, + visible: true, + model: { + type: MenuItemType.TEXT, + text: `menu.section.explore_${section.id}`, + }, + alwaysRenderExpandable: true, + }; + + return [ + ...childSections, + topSection, + ]; + } + + /** + * Build a flat link menu section pointing to the explore page of the given section id. + * + * @param id the section id used for both the i18n label and the route + */ + protected exploreLinkSection(id: string): PartialMenuSection { + return { + visible: true, + model: { + type: MenuItemType.LINK, + text: `menu.section.explore_${id}`, + link: `/explore/${id}`, + }, + }; + } } diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index b7e829a07db..a44984a4994 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -375,7 +375,7 @@ export class DefaultAppConfig implements AppConfig { pageSize: 5, }, showDiscoverFilters: false, - enableDynamicLayout: true, + enableDynamicLayout: false, }; // Item Config @@ -746,7 +746,7 @@ export class DefaultAppConfig implements AppConfig { // These styles are used in components like MetadataLinkViewComponent to display entity type indicators // alongside metadata values, providing visual cues about the type of referenced entity. layout: LayoutConfig = { - enableExplorePages: true, + enableExplorePages: false, authorityRef: [ { entityType: 'DEFAULT', diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index 59931334274..3124c6b35c4 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -282,7 +282,7 @@ export const environment: BuildConfig = { pageSize: 5, }, showDiscoverFilters: false, - enableDynamicLayout: true, + enableDynamicLayout: false, }, item: { edit: { @@ -535,7 +535,7 @@ export const environment: BuildConfig = { // Configuration for layout customization of metadata rendering in Item page layout: { - enableExplorePages: true, + enableExplorePages: false, authorityRef: [ { entityType: 'DEFAULT', diff --git a/src/themes/custom/app/home-page/home-page.component.ts b/src/themes/custom/app/home-page/home-page.component.ts index 9e125948ace..7b60150442b 100644 --- a/src/themes/custom/app/home-page/home-page.component.ts +++ b/src/themes/custom/app/home-page/home-page.component.ts @@ -16,6 +16,7 @@ import { ThemedConfigurationSearchPageComponent } from '../../../../app/search-p import { ThemedBrowseSectionComponent } from '../../../../app/shared/explore/section-component/browse-section/themed-browse-section.component'; import { ThemedCountersSectionComponent } from '../../../../app/shared/explore/section-component/counters-section/themed-counters-section.component'; import { ThemedFacetSectionComponent } from '../../../../app/shared/explore/section-component/facet-section/themed-facet-section.component'; +import { ThemedMultiColumnTopSectionComponent } from '../../../../app/shared/explore/section-component/multi-column-top-section/themed-multi-column-top-section.component'; import { ThemedSearchSectionComponent } from '../../../../app/shared/explore/section-component/search-section/themed-search-section.component'; import { ThemedTextSectionComponent } from '../../../../app/shared/explore/section-component/text-section/themed-text-section.component'; import { ThemedTopSectionComponent } from '../../../../app/shared/explore/section-component/top-section/themed-top-section.component'; @@ -39,6 +40,7 @@ import { ThemedSearchFormComponent } from '../../../../app/shared/search-form/th ThemedCountersSectionComponent, ThemedFacetSectionComponent, ThemedHomeNewsComponent, + ThemedMultiColumnTopSectionComponent, ThemedSearchFormComponent, ThemedSearchSectionComponent, ThemedTextSectionComponent,