diff --git a/config/config.example.yml b/config/config.example.yml index 8d71dc9b1ad..66bb41c946c 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -408,6 +408,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: false # Item Config item: @@ -714,6 +716,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: false # Configuration of icons and styles to be used for each authority controlled link authorityRef: - entityType: DEFAULT @@ -761,6 +766,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/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 73190c57caf..e9ade14318c 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'; @@ -25,6 +26,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'; @@ -61,8 +63,10 @@ export const MENUS = buildMenuStructure({ CommunityListMenuProvider, BrowseMenuProvider, StatisticsMenuProvider, + ExploreMenuProvider, ], [MenuID.ADMIN]: [ + AdminCommunityListMenuProvider, NewMenuProvider, EditMenuProvider, ImportMenuProvider, diff --git a/src/app/core/data-services-map.ts b/src/app/core/data-services-map.ts new file mode 100644 index 00000000000..e69de29bb2d 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..6417ca43c69 --- /dev/null +++ b/src/app/core/data/section-data.service.ts @@ -0,0 +1,47 @@ +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 { HALEndpointService } from '../shared/hal-endpoint.service'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { SearchDataImpl } from './base/search-data'; +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 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); + } + + + /** + * 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 new file mode 100644 index 00000000000..6f6a4f6f97c --- /dev/null +++ b/src/app/core/layout/models/section.model.ts @@ -0,0 +1,161 @@ +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, + }; + +} + +/** + * 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; +} + +/** + * 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 850df2513fd..d97d3de3473 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/explore-page/explore-i18n-breadcrumb.resolver.ts b/src/app/explore-page/explore-i18n-breadcrumb.resolver.ts new file mode 100644 index 00000000000..b4c248ad2e2 --- /dev/null +++ b/src/app/explore-page/explore-i18n-breadcrumb.resolver.ts @@ -0,0 +1,33 @@ +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'; + + +/** + * 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, + 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..69ae899ec39 --- /dev/null +++ b/src/app/explore-page/explore-page.component.html @@ -0,0 +1,48 @@ +
+ @for (sectionComponents of ( sectionComponentRows | async ); track sectionComponents) { +
+ @for (sectionComponent of sectionComponents; track sectionComponent) { +
+ @switch (sectionComponent.componentType) { + @case ('top') { + + } + @case ('multi-column-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..5854499f5ad --- /dev/null +++ b/src/app/explore-page/explore-page.component.spec.ts @@ -0,0 +1,172 @@ +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, + MultiColumnTopSection, + 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 { 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'; +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, + }; + + 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 = { + findById(id: string): Observable> { + if (id === 'publications') { + const section = new Section(); + section.id = 'publications'; + section.componentRows = [[browseComponent, searchComponent], [topComponent], [facetComponent], [multiColumnTopComponent]]; + 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, ThemedMultiColumnTopSectionComponent, 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 four rows', () => { + const container = fixture.debugElement.query(By.css('.container')); + expect(container.children.length).toEqual(4); + + 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'); + + 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 new file mode 100644 index 00000000000..3804a0dcd71 --- /dev/null +++ b/src/app/explore-page/explore-page.component.ts @@ -0,0 +1,95 @@ +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 { 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'; + +/** + * 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, multi-column-top, browse, search, facet, text-row, and counters. + */ +@Component({ + selector: 'ds-explore', + templateUrl: './explore-page.component.html', + imports: [ + AsyncPipe, + NgClass, + ThemedBrowseSectionComponent, + ThemedCountersSectionComponent, + ThemedFacetSectionComponent, + ThemedMultiColumnTopSectionComponent, + ThemedSearchSectionComponent, + ThemedTextSectionComponent, + ThemedTopSectionComponent, + ], +}) +export class ExplorePageComponent implements OnInit { + + /** + * Identifier for the current explore section, derived from the ':id' route parameter. + */ + sectionId: string; + + /** + * 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( + private route: ActivatedRoute, + private sectionDataService: SectionDataService ) {} + + ngOnInit() { + this.route.params.subscribe((params) => this.setupSectionComponents(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( + getFirstSucceededRemoteDataPayload(), + map ( (section) => section.componentRows), + ); + } + + /** + * 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-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 new file mode 100644 index 00000000000..896c2d11c5e --- /dev/null +++ b/src/app/explore-page/explore-routes.ts @@ -0,0 +1,21 @@ +/** + * 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'; +import { exploreI18nBreadcrumbResolver } from './explore-i18n-breadcrumb.resolver'; +import { ExplorePageComponent } from './explore-page.component'; +import { explorePagesEnabledGuard } from './explore-pages-enabled.guard'; + +export const ROUTES: Route[] = [ + { + path: ':id', + component: ExplorePageComponent, + resolve: { breadcrumb: exploreI18nBreadcrumbResolver }, + data: { title: 'explore.title', breadcrumbKey: 'explore', showSocialButtons: true }, + canActivate: [explorePagesEnabledGuard, endUserAgreementCurrentUserGuard], + }, +]; diff --git a/src/app/home-page/home-page.component.html b/src/app/home-page/home-page.component.html index ac53ab33a7f..042a64f659d 100644 --- a/src/app/home-page/home-page.component.html +++ b/src/app/home-page/home-page.component.html @@ -1,35 +1,113 @@ -@if (homeHeaderMetadataValue$ | async; as homeHeaderMetadataValue) { -
- -
-} +@if (!isDynamicHomePageEnabled) { + +} @else if (hasConfiguredSections$ | async) { + @if ((site$.value && hasHomeHeaderMetadata)) { +
+ +
+ } @else { + + + } - - - -@if (showDiscoverFilters) { - - - -} -@if (!showDiscoverFilters) { -
- -
+ + @if (site$.value) { + @for (sectionComponentRow of ( sectionComponents | async ); track sectionComponentRow) { +
+
+
+ @for (sectionComponent of sectionComponentRow; track sectionComponent) { +
+ @switch (sectionComponent.componentType) { + @case ('top') { + + } + @case ('multi-column-top') { + + } + @case ('browse') { + + } + @case ('search') { + + } + @case ('facet') { + + } + @case ('counters') { + + } + @case ('text-row') { + + } + } +
+ } +
+
+
+ } + } + + + +} @else { + } - - - - - - - @if (recentSubmissionspageSize>0) { - + + + + @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 cf75770b9e8..8ccbc02c753 100644 --- a/src/app/home-page/home-page.component.ts +++ b/src/app/home-page/home-page.component.ts @@ -12,20 +12,44 @@ import { APP_CONFIG, AppConfig, } 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 { + SectionComponent, + TextRowSection, +} from '@dspace/core/layout/models/section.model'; import { LocaleService } from '@dspace/core/locale/locale.service'; +import { + getFirstCompletedRemoteData, + getRemoteDataPayload, +} 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, + shareReplay, 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 { 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'; 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'; @@ -33,6 +57,16 @@ 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 site metadata rendering. + * + * COAR Notify inbox link headers are handled by the {@link HomeCoarComponent} rendered in the template. + */ @Component({ selector: 'ds-base-home-page', styleUrls: ['./home-page.component.scss'], @@ -44,33 +78,77 @@ import { ThemedTopLevelCommunityListComponent } from './top-level-community-list NgTemplateOutlet, RecentItemListComponent, SuggestionsPopupComponent, + ThemedBrowseSectionComponent, ThemedConfigurationSearchPageComponent, + ThemedCountersSectionComponent, + ThemedFacetSectionComponent, ThemedHomeNewsComponent, + ThemedMultiColumnTopSectionComponent, ThemedSearchFormComponent, + ThemedSearchSectionComponent, + ThemedTextSectionComponent, ThemedTopLevelCommunityListComponent, + ThemedTopSectionComponent, TranslateModule, ], }) export class HomePageComponent implements OnInit { - site$: Observable; + site$: BehaviorSubject = new BehaviorSubject(null); 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'; + + /** + * Observable emitting a 2D array (rows × columns) of section components + * to render when dynamic home page layout is enabled. + */ + 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: 'dspace.cms.home-header', + contentType: 'text-metadata', + componentType: 'text-row', + style: '', + }; + constructor( @Inject(APP_CONFIG) protected appConfig: AppConfig, - protected route: ActivatedRoute, + private route: ActivatedRoute, + private sectionDataService: SectionDataService, + private siteService: SiteDataService, private locale: LocaleService, ) { this.recentSubmissionspageSize = this.appConfig.homePage.recentSubmissions.pageSize; this.showDiscoverFilters = this.appConfig.homePage.showDiscoverFilters; + this.isDynamicHomePageEnabled = this.appConfig.homePage.enableDynamicLayout; } 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 +157,41 @@ export class HomePageComponent implements OnInit { take(1), map(({ site, language }) => site?.firstMetadataValue('dspace.cms.home-header', { language })), ); + + 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('dspace.cms.home-header', + { language })); + }, + ); } + /** + * 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')) ? + sectionComponent.style : `${defaultCol} ${sectionComponent.style}`; + } } 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..3cd88b50e7c --- /dev/null +++ b/src/app/shared/browse-most-elements/abstract-browse-elements.component.ts @@ -0,0 +1,136 @@ +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'; + + +/** + * 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: '', +}) +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; + + /** 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() { + 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(), + ); + } + + /** + * 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.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..2c69b3ffb21 --- /dev/null +++ b/src/app/shared/browse-most-elements/browse-most-elements.component.ts @@ -0,0 +1,87 @@ +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'; + +/** + * 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'], + 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; + + /** 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 { + 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..dee0ee4fd81 --- /dev/null +++ b/src/app/shared/browse-most-elements/default-browse-elements/default-browse-elements.component.ts @@ -0,0 +1,37 @@ +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'; + +/** + * 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', + 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..c20fb59f309 --- /dev/null +++ b/src/app/shared/explore/section-component/browse-section/browse-section.component.ts @@ -0,0 +1,31 @@ + +import { + Component, + Input, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { BrowseSection } from '@dspace/core/layout/models/section.model'; +import { TranslateModule } from '@ngx-translate/core'; + +/** + * Component representing the Browse component section. + */ +@Component({ + selector: 'ds-base-browse-section', + templateUrl: './browse-section.component.html', + imports: [ + RouterLink, + TranslateModule, + ], +}) +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 new file mode 100644 index 00000000000..6d210784f58 --- /dev/null +++ b/src/app/shared/explore/section-component/browse-section/themed-browse-section.component.ts @@ -0,0 +1,40 @@ +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'; + +/** + * Themed wrapper for {@link BrowseSectionComponent}. + */ +@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..14e07d797e8 --- /dev/null +++ b/src/app/shared/explore/section-component/counters-section/counters-section.component.ts @@ -0,0 +1,151 @@ +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 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'], + templateUrl: './counters-section.component.html', + imports: [ + AsyncPipe, + NgClass, + NgTemplateOutlet, + RouterLink, + ThemedLoadingComponent, + TranslateModule, + ], +}) +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; + + + 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)); + } +} + + + +/** + * 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 new file mode 100644 index 00000000000..b2cf7e5a744 --- /dev/null +++ b/src/app/shared/explore/section-component/counters-section/themed-counters-section.component.ts @@ -0,0 +1,42 @@ +import { + Component, + Input, +} from '@angular/core'; + +import { ThemedComponent } from '../../../theme-support/themed.component'; +import { + CountersSection, + CountersSectionComponent, +} from './counters-section.component'; + +/** + * Themed wrapper for {@link CountersSectionComponent}. + */ +@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..e80a9828fae --- /dev/null +++ b/src/app/shared/explore/section-component/facet-section/facet-section.component.ts @@ -0,0 +1,120 @@ +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 { + + /** 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( + 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..6be740cb115 --- /dev/null +++ b/src/app/shared/explore/section-component/facet-section/themed-facet-section.component.ts @@ -0,0 +1,40 @@ +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'; + +/** + * Themed wrapper for {@link FacetSectionComponent}. + */ +@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..34fd4e0ea6d --- /dev/null +++ b/src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.spec.ts @@ -0,0 +1,65 @@ +import { + ComponentFixture, + 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'; + +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, RouterTestingModule, TranslateModule.forRoot()], + providers: [ + { provide: SearchManager, useValue: searchManager }, + ], + }) + .compileComponents(); + })); + + 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 new file mode 100644 index 00000000000..8f8a90e8011 --- /dev/null +++ b/src/app/shared/explore/section-component/multi-column-top-section/multi-column-top-section.component.ts @@ -0,0 +1,118 @@ +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, + 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 { TranslateModule } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { v4 as uuidv4 } from 'uuid'; + + +/** + * 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', + imports: [ + AsyncPipe, + NgClass, + RouterLink, + TranslateModule, + ], +}) +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; + + /** + * 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) { + + } + + ngOnInit() { + const order = this.topSection.order; + const sortDirection = order && order.toUpperCase() === 'ASC' ? SortDirection.ASC : SortDirection.DESC; + const pagination: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: this.paginationId, + 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)); + } + + /** + * 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 new file mode 100644 index 00000000000..98970bbf9c0 --- /dev/null +++ b/src/app/shared/explore/section-component/multi-column-top-section/themed-multi-column-top-section.component.ts @@ -0,0 +1,40 @@ +import { + Component, + Input, +} from '@angular/core'; +import { MultiColumnTopSection } from '@dspace/core/layout/models/section.model'; + +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: [], + 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..1bb25fd8452 --- /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..a7f38e86913 --- /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('[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('[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('[id^="operation"]'))).toBeNull(); + } else { + const operationSelect = formRow.query(By.css('[id^="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('[id^="filter"]')); + filterSelect.nativeElement.value = 'author'; + const queryInput = firstFormRow.query(By.css('[id^="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('[id^="filter"]')); + expect(filterSelect.nativeElement.value).toEqual('all'); + const queryInput = firstFormRow.query(By.css('[id^="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('[id^="filter"]')).nativeElement; + filterSelect.value = 'author'; + filterSelect.dispatchEvent(new Event('change')); + const firstQueryInput = firstFormRow.query(By.css('[id^="query"]')).nativeElement; + firstQueryInput.value = 'Adam'; + firstQueryInput.dispatchEvent(new Event('input')); + 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('[id^="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..71ebbc1a923 --- /dev/null +++ b/src/app/shared/explore/section-component/search-section/search-section.component.ts @@ -0,0 +1,172 @@ +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 { + + /** Unique identifier for this section instance. */ + @Input() + sectionId: string; + + /** Configuration object defining the search section behaviour. */ + @Input() + searchSection: SearchSection; + + /** 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, + 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(); + } + } + + /** + * Creates a new FormGroup representing a single query statement + * with default filter, empty query, and first boolean operation. + */ + createFormGroup(): FormGroup { + return this.formBuilder.group({ + filter: this.allFilter, + query: '', + operation: this.operations[0], + }); + } + + /** + * Appends a new empty query statement to the form array. + */ + 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 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 new file mode 100644 index 00000000000..c2c9a6a692e --- /dev/null +++ b/src/app/shared/explore/section-component/search-section/themed-search-section.component.ts @@ -0,0 +1,40 @@ +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'; + +/** + * Themed wrapper for {@link SearchSectionComponent}. + */ +@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..6586efe549c --- /dev/null +++ b/src/app/shared/explore/section-component/text-section/text-section.component.ts @@ -0,0 +1,64 @@ +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 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', + styleUrls: ['./text-section.component.scss'], + imports: [ + AsyncPipe, + MarkdownViewerComponent, + NgTemplateOutlet, + TranslateModule, + ], +}) +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; + + constructor( + private locale: LocaleService, + ) { + } + + /** + * 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 new file mode 100644 index 00000000000..51af784408d --- /dev/null +++ b/src/app/shared/explore/section-component/text-section/themed-text-section.component.ts @@ -0,0 +1,44 @@ +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'; + +/** + * Themed wrapper for {@link TextSectionComponent}. + */ +@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..7fa1ca5aafd --- /dev/null +++ b/src/app/shared/explore/section-component/top-section/themed-top-section.component.ts @@ -0,0 +1,44 @@ +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'; + +/** + * Themed wrapper for {@link TopSectionComponent}. + */ +@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..7b8207b194b --- /dev/null +++ b/src/app/shared/explore/section-component/top-section/top-section.component.spec.ts @@ -0,0 +1,154 @@ +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 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(() => { + 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..60a37b40f59 --- /dev/null +++ b/src/app/shared/explore/section-component/top-section/top-section.component.ts @@ -0,0 +1,72 @@ + +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 { v4 as uuidv4 } from 'uuid'; + +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 { + + /** 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; + + /** + * 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: this.paginationId, + 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/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 fc212bbef72..d17f497f52f 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'; @@ -25,25 +26,31 @@ describe('CommunityListMenuProvider', () => { }, ]; - let provider: CommunityListMenuProvider; - - beforeEach(() => { + const createProvider = (showCommunityCollection: boolean): CommunityListMenuProvider => { TestBed.configureTestingModule({ providers: [ CommunityListMenuProvider, + { 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 79e893c974d..c7d43c13663 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, @@ -19,12 +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 { - public getSections(): Observable { - return of([ + protected appConfig = inject(APP_CONFIG); + + /** + * 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: { @@ -34,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 new file mode 100644 index 00000000000..a86df6f4b67 --- /dev/null +++ b/src/app/shared/menu/providers/explore.menu.spec.ts @@ -0,0 +1,178 @@ +/** + * 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 { MenuItemType } from '../menu-item-type.model'; +import { PartialMenuSection } from '../menu-provider.model'; +import { ExploreMenuProvider } from './explore.menu'; + +describe('ExploreMenuProvider', () => { + + let provider: ExploreMenuProvider; + 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)), + ), + }; + + TestBed.configureTestingModule({ + providers: [ + ExploreMenuProvider, + { provide: APP_CONFIG, useValue: { layout: { enableExplorePages } } }, + { provide: SectionDataService, useValue: sectionDataServiceStub }, + ], + }); + provider = TestBed.inject(ExploreMenuProvider); + provider.menuProviderId = 'explore'; + } + + 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(); + }); + }); + }); + + 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); + }); + + 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 new file mode 100644 index 00000000000..8d4b33dbe07 --- /dev/null +++ b/src/app/shared/menu/providers/explore.menu.ts @@ -0,0 +1,123 @@ +/** + * 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 { + 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 { isEmpty } from '@dspace/shared/utils/empty.util'; +import { + Observable, + of, +} 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. + * Returns an empty menu when `layout.enableExplorePages` is false. + */ +@Injectable() +export class ExploreMenuProvider extends AbstractMenuProvider { + protected appConfig = inject(APP_CONFIG); + + constructor( + protected sectionDataService: SectionDataService, + ) { + super(); + } + + /** + * 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 { + if (!this.appConfig.layout.enableExplorePages) { + return of([]); + } + return this.sectionDataService.findVisibleSections().pipe( + getFirstSucceededRemoteData(), + map((rd: RemoteData>) => { + 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/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/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 6551143ff13..bee43ef41fd 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2093,6 +2093,78 @@ "error.validation.metadata.qualifier.max-length": "This field may not contain more than 64 characters", + "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", + + "explore.index.author": "Author", + + "explore.index.birthDate": "Birthday", + + "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", + + "explore.index.givenName": "Given name", + + "explore.index.jobTitle": "Job title", + + "explore.index.title": "Title", + + "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", "file-download-link.restricted": "Restricted bitstream", @@ -3732,6 +3804,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", @@ -3740,6 +3822,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", diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index fd703d4d024..a44984a4994 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: false, }; // Item Config @@ -745,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: false, authorityRef: [ { entityType: 'DEFAULT', @@ -815,6 +817,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/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..97a8dc49b3d 100644 --- a/src/config/layout-config.interfaces.ts +++ b/src/config/layout-config.interfaces.ts @@ -62,15 +62,35 @@ 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. */ authorityRef: AuthorityRefConfig[]; + /** + * Whether to show download links as attachments. + */ showDownloadLinkAsAttachment: boolean; /** * Configuration for advanced attachment rendering features. * 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 0ae82e957a1..3124c6b35c4 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: false, }, item: { edit: { @@ -534,6 +535,7 @@ export const environment: BuildConfig = { // Configuration for layout customization of metadata rendering in Item page layout: { + enableExplorePages: false, authorityRef: [ { entityType: 'DEFAULT', @@ -603,6 +605,9 @@ export const environment: BuildConfig = { }, ], }, + navbar: { + showCommunityCollection: true, + }, }, searchResult: { 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..7b60150442b 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,13 @@ 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 { 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'; import { ThemedSearchFormComponent } from '../../../../app/shared/search-form/themed-search-form.component'; @Component({ @@ -28,10 +35,17 @@ import { ThemedSearchFormComponent } from '../../../../app/shared/search-form/th NgTemplateOutlet, RecentItemListComponent, SuggestionsPopupComponent, + ThemedBrowseSectionComponent, ThemedConfigurationSearchPageComponent, + ThemedCountersSectionComponent, + ThemedFacetSectionComponent, ThemedHomeNewsComponent, + ThemedMultiColumnTopSectionComponent, ThemedSearchFormComponent, + ThemedSearchSectionComponent, + ThemedTextSectionComponent, ThemedTopLevelCommunityListComponent, + ThemedTopSectionComponent, TranslateModule, ], })