diff --git a/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/person/person-sidebar-search-list-element.component.ts b/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/person/person-sidebar-search-list-element.component.ts index edae6c9ec9b..a9ba8c333d2 100644 --- a/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/person/person-sidebar-search-list-element.component.ts +++ b/src/app/entity-groups/research-entities/item-list-elements/sidebar-search-list-elements/person/person-sidebar-search-list-element.component.ts @@ -10,6 +10,7 @@ import { TruncatableService } from '../../../../../shared/truncatable/truncatabl import { LinkService } from '../../../../../core/cache/builders/link.service'; import { TranslateService } from '@ngx-translate/core'; import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service'; +import { DSOBreadcrumbsService } from '../../../../../core/breadcrumbs/dso-breadcrumbs.service'; @listableObjectComponent('PersonSearchResult', ViewMode.ListElement, Context.SideBarSearchModal) @listableObjectComponent('PersonSearchResult', ViewMode.ListElement, Context.SideBarSearchModalCurrent) @@ -26,8 +27,9 @@ export class PersonSidebarSearchListElementComponent extends SidebarSearchListEl protected linkService: LinkService, protected translateService: TranslateService, public dsoNameService: DSONameService, + protected dsoBreadcrumbsService: DSOBreadcrumbsService, ) { - super(truncatableService, linkService, dsoNameService); + super(truncatableService, linkService, dsoNameService, dsoBreadcrumbsService); } /** diff --git a/src/app/shared/object-list/sidebar-search-list-element/collection/collection-sidebar-search-list-element.component.spec.ts b/src/app/shared/object-list/sidebar-search-list-element/collection/collection-sidebar-search-list-element.component.spec.ts index be3ee7d1bb9..089cd710d6a 100644 --- a/src/app/shared/object-list/sidebar-search-list-element/collection/collection-sidebar-search-list-element.component.spec.ts +++ b/src/app/shared/object-list/sidebar-search-list-element/collection/collection-sidebar-search-list-element.component.spec.ts @@ -2,7 +2,7 @@ import { CollectionSidebarSearchListElementComponent } from './collection-sideba import { CollectionSearchResult } from '../../../object-collection/shared/collection-search-result.model'; import { Collection } from '../../../../core/shared/collection.model'; import { Community } from '../../../../core/shared/community.model'; -import { createSidebarSearchListElementTests } from '../sidebar-search-list-element.component.spec'; +import { createHierarchicalParentTitleTests, createSidebarSearchListElementTests } from '../sidebar-search-list-element.component.spec'; const object = Object.assign(new CollectionSearchResult(), { indexableObject: Object.assign(new Collection(), { @@ -33,5 +33,9 @@ const parent = Object.assign(new Community(), { }); describe('CollectionSidebarSearchListElementComponent', - createSidebarSearchListElementTests(CollectionSidebarSearchListElementComponent, object, parent, 'parent title', 'title', 'description') + createSidebarSearchListElementTests(CollectionSidebarSearchListElementComponent, object, parent, 'parent title', 'title', 'description', [], true) +); + +describe('CollectionSidebarSearchListElementComponent - hierarchical path', + createHierarchicalParentTitleTests(CollectionSidebarSearchListElementComponent, object, 'title') ); diff --git a/src/app/shared/object-list/sidebar-search-list-element/collection/collection-sidebar-search-list-element.component.ts b/src/app/shared/object-list/sidebar-search-list-element/collection/collection-sidebar-search-list-element.component.ts index 13703240f43..d09f2468f62 100644 --- a/src/app/shared/object-list/sidebar-search-list-element/collection/collection-sidebar-search-list-element.component.ts +++ b/src/app/shared/object-list/sidebar-search-list-element/collection/collection-sidebar-search-list-element.component.ts @@ -5,6 +5,10 @@ import { listableObjectComponent } from '../../../object-collection/shared/lista import { Context } from '../../../../core/shared/context.model'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { SidebarSearchListElementComponent } from '../sidebar-search-list-element.component'; +import { TruncatableService } from '../../../truncatable/truncatable.service'; +import { LinkService } from '../../../../core/cache/builders/link.service'; +import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; +import { DSOBreadcrumbsService } from '../../../../core/breadcrumbs/dso-breadcrumbs.service'; @listableObjectComponent(CollectionSearchResult, ViewMode.ListElement, Context.SideBarSearchModal) @listableObjectComponent(CollectionSearchResult, ViewMode.ListElement, Context.SideBarSearchModalCurrent) @@ -16,6 +20,16 @@ import { SidebarSearchListElementComponent } from '../sidebar-search-list-elemen * Component displaying a list element for a {@link CollectionSearchResult} within the context of a sidebar search modal */ export class CollectionSidebarSearchListElementComponent extends SidebarSearchListElementComponent { + + constructor( + protected truncatableService: TruncatableService, + protected linkService: LinkService, + public dsoNameService: DSONameService, + protected dsoBreadcrumbsService: DSOBreadcrumbsService, + ) { + super(truncatableService, linkService, dsoNameService, dsoBreadcrumbsService); + } + /** * Get the description of the Collection by returning its abstract */ diff --git a/src/app/shared/object-list/sidebar-search-list-element/community/community-sidebar-search-list-element.component.spec.ts b/src/app/shared/object-list/sidebar-search-list-element/community/community-sidebar-search-list-element.component.spec.ts index d6bcfc85810..51eab97db3b 100644 --- a/src/app/shared/object-list/sidebar-search-list-element/community/community-sidebar-search-list-element.component.spec.ts +++ b/src/app/shared/object-list/sidebar-search-list-element/community/community-sidebar-search-list-element.component.spec.ts @@ -1,5 +1,5 @@ import { Community } from '../../../../core/shared/community.model'; -import { createSidebarSearchListElementTests } from '../sidebar-search-list-element.component.spec'; +import { createHierarchicalParentTitleTests, createSidebarSearchListElementTests } from '../sidebar-search-list-element.component.spec'; import { CommunitySidebarSearchListElementComponent } from './community-sidebar-search-list-element.component'; import { CommunitySearchResult } from '../../../object-collection/shared/community-search-result.model'; @@ -32,5 +32,9 @@ const parent = Object.assign(new Community(), { }); describe('CommunitySidebarSearchListElementComponent', - createSidebarSearchListElementTests(CommunitySidebarSearchListElementComponent, object, parent, 'parent title', 'title', 'description') + createSidebarSearchListElementTests(CommunitySidebarSearchListElementComponent, object, parent, 'parent title', 'title', 'description', [], true) +); + +describe('CommunitySidebarSearchListElementComponent - hierarchical path', + createHierarchicalParentTitleTests(CommunitySidebarSearchListElementComponent, object, 'title') ); diff --git a/src/app/shared/object-list/sidebar-search-list-element/community/community-sidebar-search-list-element.component.ts b/src/app/shared/object-list/sidebar-search-list-element/community/community-sidebar-search-list-element.component.ts index 14c8ec09cd7..187842c6c7e 100644 --- a/src/app/shared/object-list/sidebar-search-list-element/community/community-sidebar-search-list-element.component.ts +++ b/src/app/shared/object-list/sidebar-search-list-element/community/community-sidebar-search-list-element.component.ts @@ -5,17 +5,31 @@ import { ViewMode } from '../../../../core/shared/view-mode.model'; import { SidebarSearchListElementComponent } from '../sidebar-search-list-element.component'; import { CommunitySearchResult } from '../../../object-collection/shared/community-search-result.model'; import { Community } from '../../../../core/shared/community.model'; +import { TruncatableService } from '../../../truncatable/truncatable.service'; +import { LinkService } from '../../../../core/cache/builders/link.service'; +import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; +import { DSOBreadcrumbsService } from '../../../../core/breadcrumbs/dso-breadcrumbs.service'; @listableObjectComponent(CommunitySearchResult, ViewMode.ListElement, Context.SideBarSearchModal) @listableObjectComponent(CommunitySearchResult, ViewMode.ListElement, Context.SideBarSearchModalCurrent) @Component({ - selector: 'ds-collection-sidebar-search-list-element', + selector: 'ds-community-sidebar-search-list-element', templateUrl: '../sidebar-search-list-element.component.html' }) /** * Component displaying a list element for a {@link CommunitySearchResult} within the context of a sidebar search modal */ export class CommunitySidebarSearchListElementComponent extends SidebarSearchListElementComponent { + + constructor( + protected truncatableService: TruncatableService, + protected linkService: LinkService, + public dsoNameService: DSONameService, + protected dsoBreadcrumbsService: DSOBreadcrumbsService, + ) { + super(truncatableService, linkService, dsoNameService, dsoBreadcrumbsService); + } + /** * Get the description of the Community by returning its abstract */ diff --git a/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.html b/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.html index 040f528768d..102aa9c2bf7 100644 --- a/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.html +++ b/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.html @@ -1,6 +1,14 @@ -
+ +
+
+ +
+
{ let component; let fixture: ComponentFixture; let linkService; + let dsoBreadcrumbsService; beforeEach(waitForAsync(() => { + // Propagate the class-level static ResourceType onto the instance so that + // the community/collection branch in getParentTitle() is reached correctly. + const staticType: ResourceType | undefined = (object.indexableObject.constructor as any).type; + if (staticType) { + (object.indexableObject as any).type = staticType; + } + linkService = jasmine.createSpyObj('linkService', { resolveLink: Object.assign(new HALResource(), { [object.indexableObject.getParentLinkKey()]: createSuccessfulRemoteDataObject$(parent) }) }); + const breadcrumbs: Breadcrumb[] = []; + if (expectedParentTitle) { + breadcrumbs.push(new Breadcrumb(expectedParentTitle, '')); + } + breadcrumbs.push(new Breadcrumb(expectedTitle, '')); + dsoBreadcrumbsService = jasmine.createSpyObj('dsoBreadcrumbsService', { + getBreadcrumbs: observableOf(breadcrumbs) + }); TestBed.configureTestingModule({ declarations: [componentClass, VarDirective], imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], providers: [ { provide: TruncatableService, useValue: {} }, { provide: LinkService, useValue: linkService }, + { provide: DSOBreadcrumbsService, useValue: dsoBreadcrumbsService }, DSONameService, ...extraProviders ], @@ -61,6 +84,18 @@ export function createSidebarSearchListElementTests( }); }); + if (assertBreadcrumbsUsed) { + it('should delegate to DSOBreadcrumbsService.getBreadcrumbs to resolve the parent title', (done) => { + component.parentTitle$.subscribe(() => { + expect(dsoBreadcrumbsService.getBreadcrumbs).toHaveBeenCalledWith( + object.indexableObject, + '' + ); + done(); + }); + }); + } + it('should contain the correct title', () => { expect(component.dsoTitle).toEqual(expectedTitle); }); @@ -70,3 +105,93 @@ export function createSidebarSearchListElementTests( }); }; } + +/** + * Shared test suite that verifies the hierarchical parent-path behaviour for community/collection + * list elements: when the DSO has multiple ancestor breadcrumbs the component must join them with + * {@link BREADCRUMB_SEPARATOR} and must delegate to {@link DSOBreadcrumbsService#getBreadcrumbs} rather than the simple + * parent link. + * + * @param componentClass The component under test (community or collection sidebar element) + * @param object A {@link SearchResult} whose `indexableObject` is a Community/Collection + * @param expectedTitle The dc.title of the current item (last breadcrumb) + * @param extraProviders Any additional providers required by the component + */ +export function createHierarchicalParentTitleTests( + componentClass: any, + object: SearchResult, + expectedTitle: string, + extraProviders: any[] = [] +) { + return () => { + let component; + let fixture: ComponentFixture; + let dsoBreadcrumbsService; + + // Three-level hierarchy: Root → Parent → Current + const rootBreadcrumb = new Breadcrumb('Root', ''); + const parentBreadcrumb = new Breadcrumb('Parent', ''); + const currentBreadcrumb = new Breadcrumb(expectedTitle, ''); + const breadcrumbs = [rootBreadcrumb, parentBreadcrumb, currentBreadcrumb]; + + beforeEach(waitForAsync(() => { + // Propagate the class-level static ResourceType onto the instance so that + // the community/collection branch in getParentTitle() is reached correctly. + const staticType: ResourceType | undefined = (object.indexableObject.constructor as any).type; + if (staticType) { + (object.indexableObject as any).type = staticType; + } + + // Set up the linkService with a safe RemoteData observable for the parent link so that + // even if the type-check guard ever regresses, the fallback getParent() path resolves + // cleanly via the find() predicate (statusCode === 204) without a TypeError. + const parentLinkKey = (object.indexableObject as ChildHALResource).getParentLinkKey() as string; + const linkService = jasmine.createSpyObj('linkService', { + resolveLink: Object.assign(new HALResource(), { + [parentLinkKey]: createNoContentRemoteDataObject$() + }) + }); + dsoBreadcrumbsService = jasmine.createSpyObj('dsoBreadcrumbsService', { + getBreadcrumbs: observableOf(breadcrumbs) + }); + + TestBed.configureTestingModule({ + declarations: [componentClass, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: TruncatableService, useValue: {} }, + { provide: LinkService, useValue: linkService }, + { provide: DSOBreadcrumbsService, useValue: dsoBreadcrumbsService }, + DSONameService, + ...extraProviders + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(componentClass); + component = fixture.componentInstance; + component.object = object; + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should join multiple ancestor breadcrumbs with BREADCRUMB_SEPARATOR as the parent title', (done) => { + component.parentTitle$.subscribe((title) => { + expect(title).toEqual(['Root', 'Parent'].join(BREADCRUMB_SEPARATOR)); + done(); + }); + }); + + it('should call DSOBreadcrumbsService.getBreadcrumbs to build the hierarchy path', (done) => { + component.parentTitle$.subscribe(() => { + expect(dsoBreadcrumbsService.getBreadcrumbs).toHaveBeenCalledWith( + object.indexableObject, + '' + ); + done(); + }); + }); + }; +} diff --git a/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.ts b/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.ts index 0ffe2d58b44..663d5e5d888 100644 --- a/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.ts +++ b/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.ts @@ -1,17 +1,22 @@ import { SearchResult } from '../../search/models/search-result.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { SearchResultListElementComponent } from '../search-result-list-element/search-result-list-element.component'; -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { hasValue, isNotEmpty } from '../../empty.util'; import { Observable, of as observableOf } from 'rxjs'; import { TruncatableService } from '../../truncatable/truncatable.service'; import { LinkService } from '../../../core/cache/builders/link.service'; -import { find, map } from 'rxjs/operators'; +import { find, map, shareReplay } from 'rxjs/operators'; import { ChildHALResource } from '../../../core/shared/child-hal-resource.model'; import { followLink } from '../../utils/follow-link-config.model'; import { RemoteData } from '../../../core/data/remote-data'; import { Context } from '../../../core/shared/context.model'; import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { DSOBreadcrumbsService } from '../../../core/breadcrumbs/dso-breadcrumbs.service'; +import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model'; + +/** Separator used when joining hierarchical breadcrumb labels into a single parent-title string. */ +export const BREADCRUMB_SEPARATOR = ' / '; @Component({ selector: 'ds-sidebar-search-list-element', @@ -22,7 +27,7 @@ import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; * It displays the name of the parent, title and description of the object. All of which are customizable in the child * component by overriding the relevant methods of this component */ -export class SidebarSearchListElementComponent, K extends DSpaceObject> extends SearchResultListElementComponent { +export class SidebarSearchListElementComponent, K extends DSpaceObject> extends SearchResultListElementComponent implements OnInit { /** * Observable for the title of the parent object (displayed above the object's title) */ @@ -36,6 +41,7 @@ export class SidebarSearchListElementComponent, K exte public constructor(protected truncatableService: TruncatableService, protected linkService: LinkService, public dsoNameService: DSONameService, + protected dsoBreadcrumbsService: DSOBreadcrumbsService, ) { super(truncatableService, dsoNameService, null); } @@ -59,14 +65,42 @@ export class SidebarSearchListElementComponent, K exte } /** - * Get the title of the object's parent - * Retrieve the parent by using the object's parent link and retrieving its 'dc.title' metadata + * Type guard that narrows a {@link DSpaceObject} to {@link ChildHALResource} & {@link DSpaceObject}, + * which is the signature expected by {@link DSOBreadcrumbsService#getBreadcrumbs}. + */ + private isChildHALResource(dso: DSpaceObject): dso is ChildHALResource & DSpaceObject { + return typeof (dso as unknown as ChildHALResource).getParentLinkKey === 'function'; + } + + /** + * Get the title of the object's parent(s) + * For communities and collections, show the full hierarchical path excluding the current item + * For other objects, show just the immediate parent */ getParentTitle(): Observable { + // Fallback handles cases where type is a raw string rather than a ResourceType instance + const typeValue = this.dso.type?.value ?? (this.dso as any).type; + const dso: DSpaceObject = this.dso; + if (dso && this.isChildHALResource(dso) && (typeValue === DSpaceObjectType.COMMUNITY.toLowerCase() || typeValue === DSpaceObjectType.COLLECTION.toLowerCase())) { + // For communities and collections, build hierarchical path via breadcrumbs + return this.dsoBreadcrumbsService.getBreadcrumbs(dso, '').pipe( + map(breadcrumbs => { + // Remove the last breadcrumb (current item) and join the rest with ' / ' + const parentBreadcrumbs = breadcrumbs.slice(0, -1); + return parentBreadcrumbs.length > 0 + ? parentBreadcrumbs.map(crumb => crumb.text).join(BREADCRUMB_SEPARATOR) + : undefined; + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); + } + + // For other DSO types, use the simple parent return this.getParent().pipe( map((parentRD: RemoteData) => { return hasValue(parentRD) && hasValue(parentRD.payload) ? this.dsoNameService.getName(parentRD.payload) : undefined; - }) + }), + shareReplay({ bufferSize: 1, refCount: true }), ); } @@ -74,8 +108,8 @@ export class SidebarSearchListElementComponent, K exte * Get the parent of the object */ getParent(): Observable> { - if (typeof (this.dso as any).getParentLinkKey === 'function') { - const propertyName = (this.dso as any).getParentLinkKey(); + if (this.isChildHALResource(this.dso)) { + const propertyName = this.dso.getParentLinkKey() as string; return this.linkService.resolveLink(this.dso, followLink(propertyName))[propertyName].pipe( find((parentRD: RemoteData) => parentRD.hasSucceeded || parentRD.statusCode === 204) );