From 5d77a13faedb71254f8136e83604223febe9f56b Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Thu, 5 Mar 2026 09:05:15 +0100 Subject: [PATCH 1/6] Show full community hierarchy in sidebar search --- ...n-sidebar-search-list-element.component.ts | 4 ++- ...n-sidebar-search-list-element.component.ts | 14 ++++++++++ ...y-sidebar-search-list-element.component.ts | 16 ++++++++++- ...ebar-search-list-element.component.spec.ts | 12 +++++++++ .../sidebar-search-list-element.component.ts | 27 ++++++++++++++++--- 5 files changed, 67 insertions(+), 6 deletions(-) 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.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.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.spec.ts b/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec.ts index 226c1be33ef..70917f718ac 100644 --- a/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec.ts +++ b/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec.ts @@ -11,6 +11,9 @@ import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; import { HALResource } from '../../../core/shared/hal-resource.model'; import { ChildHALResource } from '../../../core/shared/child-hal-resource.model'; import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { DSOBreadcrumbsService } from '../../../core/breadcrumbs/dso-breadcrumbs.service'; +import { Breadcrumb } from '../../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { of as observableOf } from 'rxjs'; export function createSidebarSearchListElementTests( componentClass: any, @@ -33,12 +36,21 @@ export function createSidebarSearchListElementTests( [object.indexableObject.getParentLinkKey()]: createSuccessfulRemoteDataObject$(parent) }) }); + const breadcrumbs: Breadcrumb[] = []; + if (expectedParentTitle) { + breadcrumbs.push(new Breadcrumb(expectedParentTitle, '')); + } + breadcrumbs.push(new Breadcrumb(expectedTitle, '')); + const 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 ], 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..31de89da9a5 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,7 +1,7 @@ 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'; @@ -12,6 +12,8 @@ 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'; @Component({ selector: 'ds-sidebar-search-list-element', @@ -22,7 +24,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 +38,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,10 +62,26 @@ 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 + * 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 { + // For communities and collections, build hierarchical path + const typeValue = (this.dso as any).type?.value ?? (this.dso as any).type; + if (this.dso && (typeValue === DSpaceObjectType.COMMUNITY.toLowerCase() || typeValue === DSpaceObjectType.COLLECTION.toLowerCase())) { + return this.dsoBreadcrumbsService.getBreadcrumbs(this.dso as any, '').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(' / ') + : undefined; + }) + ); + } + + // 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; From 53b5f7fb12e3b9a8708dc2f60e7b076259b9aac7 Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Thu, 5 Mar 2026 12:02:13 +0100 Subject: [PATCH 2/6] Add tooltip to parent path --- .../sidebar-search-list-element.component.html | 1 + 1 file changed, 1 insertion(+) 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..31de2c7a1f9 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,5 +1,6 @@
From 84ad89de34d28624b48ef0ac3265e9b318526fdf Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Thu, 5 Mar 2026 12:24:04 +0100 Subject: [PATCH 3/6] Add shareReplay, type guard, and hierarchical path tests --- ...ebar-search-list-element.component.spec.ts | 6 +- ...ebar-search-list-element.component.spec.ts | 6 +- ...ebar-search-list-element.component.spec.ts | 75 +++++++++++++++++++ .../sidebar-search-list-element.component.ts | 27 +++++-- 4 files changed, 104 insertions(+), 10 deletions(-) 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..c3f76e8ef07 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(), { @@ -35,3 +35,7 @@ const parent = Object.assign(new Community(), { describe('CollectionSidebarSearchListElementComponent', createSidebarSearchListElementTests(CollectionSidebarSearchListElementComponent, object, parent, 'parent title', 'title', 'description') ); + +describe('CollectionSidebarSearchListElementComponent - hierarchical path', + createHierarchicalParentTitleTests(CollectionSidebarSearchListElementComponent, object, 'title') +); 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..6e3a8f4d2fc 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'; @@ -34,3 +34,7 @@ const parent = Object.assign(new Community(), { describe('CommunitySidebarSearchListElementComponent', createSidebarSearchListElementTests(CommunitySidebarSearchListElementComponent, object, parent, 'parent title', 'title', 'description') ); + +describe('CommunitySidebarSearchListElementComponent - hierarchical path', + createHierarchicalParentTitleTests(CommunitySidebarSearchListElementComponent, object, 'title') +); diff --git a/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec.ts b/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec.ts index 70917f718ac..59d7cd4afb4 100644 --- a/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec.ts +++ b/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec.ts @@ -82,3 +82,78 @@ 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 + * ' / ' 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(() => { + const linkService = jasmine.createSpyObj('linkService', { resolveLink: {} }); + 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 " / " as the parent title', (done) => { + component.parentTitle$.subscribe((title) => { + expect(title).toEqual('Root / Parent'); + 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 31de89da9a5..ab0abf30ce8 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 @@ -6,7 +6,7 @@ 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'; @@ -61,23 +61,33 @@ export class SidebarSearchListElementComponent, K exte return this.context === Context.SideBarSearchModalCurrent; } + /** + * 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 { - // For communities and collections, build hierarchical path - const typeValue = (this.dso as any).type?.value ?? (this.dso as any).type; + // 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; if (this.dso && (typeValue === DSpaceObjectType.COMMUNITY.toLowerCase() || typeValue === DSpaceObjectType.COLLECTION.toLowerCase())) { - return this.dsoBreadcrumbsService.getBreadcrumbs(this.dso as any, '').pipe( + // For communities and collections, build hierarchical path via breadcrumbs + return this.dsoBreadcrumbsService.getBreadcrumbs(this.dso as unknown as ChildHALResource & DSpaceObject, '').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(' / ') : undefined; - }) + }), + shareReplay({ bufferSize: 1, refCount: true }), ); } @@ -85,7 +95,8 @@ export class SidebarSearchListElementComponent, K exte return this.getParent().pipe( map((parentRD: RemoteData) => { return hasValue(parentRD) && hasValue(parentRD.payload) ? this.dsoNameService.getName(parentRD.payload) : undefined; - }) + }), + shareReplay({ bufferSize: 1, refCount: true }), ); } @@ -93,8 +104,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) ); From 7c9d380edc7a63ddfd1d083d40af137e3ce8a120 Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Thu, 5 Mar 2026 12:43:45 +0100 Subject: [PATCH 4/6] refactor: unwrap parentTitle$ once, extract BREADCRUMB_SEPARATOR, fix tests --- ...ebar-search-list-element.component.spec.ts | 2 +- ...ebar-search-list-element.component.spec.ts | 2 +- ...sidebar-search-list-element.component.html | 13 +++-- ...ebar-search-list-element.component.spec.ts | 50 ++++++++++++++++--- .../sidebar-search-list-element.component.ts | 5 +- 5 files changed, 60 insertions(+), 12 deletions(-) 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 c3f76e8ef07..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 @@ -33,7 +33,7 @@ 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', 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 6e3a8f4d2fc..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 @@ -32,7 +32,7 @@ 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', 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 31de2c7a1f9..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,7 +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) @@ -41,7 +52,7 @@ export function createSidebarSearchListElementTests( breadcrumbs.push(new Breadcrumb(expectedParentTitle, '')); } breadcrumbs.push(new Breadcrumb(expectedTitle, '')); - const dsoBreadcrumbsService = jasmine.createSpyObj('dsoBreadcrumbsService', { + dsoBreadcrumbsService = jasmine.createSpyObj('dsoBreadcrumbsService', { getBreadcrumbs: observableOf(breadcrumbs) }); TestBed.configureTestingModule({ @@ -73,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); }); @@ -86,7 +109,7 @@ 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 - * ' / ' and must delegate to {@link DSOBreadcrumbsService#getBreadcrumbs} rather than the simple + * {@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) @@ -112,7 +135,22 @@ export function createHierarchicalParentTitleTests( const breadcrumbs = [rootBreadcrumb, parentBreadcrumb, currentBreadcrumb]; beforeEach(waitForAsync(() => { - const linkService = jasmine.createSpyObj('linkService', { resolveLink: {} }); + // 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 observable for the parent link so that + // even if the type-check guard ever regresses, the fallback getParent() path + // does not crash with "Cannot read properties of undefined (reading 'pipe')". + const parentLinkKey = (object.indexableObject as ChildHALResource).getParentLinkKey() as string; + const linkService = jasmine.createSpyObj('linkService', { + resolveLink: Object.assign(new HALResource(), { + [parentLinkKey]: observableOf(undefined) + }) + }); dsoBreadcrumbsService = jasmine.createSpyObj('dsoBreadcrumbsService', { getBreadcrumbs: observableOf(breadcrumbs) }); @@ -139,9 +177,9 @@ export function createHierarchicalParentTitleTests( fixture.detectChanges(); }); - it('should join multiple ancestor breadcrumbs with " / " as the parent title', (done) => { + it('should join multiple ancestor breadcrumbs with BREADCRUMB_SEPARATOR as the parent title', (done) => { component.parentTitle$.subscribe((title) => { - expect(title).toEqual('Root / Parent'); + expect(title).toEqual(['Root', 'Parent'].join(BREADCRUMB_SEPARATOR)); 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 ab0abf30ce8..bbc757af1df 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 @@ -15,6 +15,9 @@ 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', templateUrl: './sidebar-search-list-element.component.html' @@ -84,7 +87,7 @@ export class SidebarSearchListElementComponent, K exte // 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(' / ') + ? parentBreadcrumbs.map(crumb => crumb.text).join(BREADCRUMB_SEPARATOR) : undefined; }), shareReplay({ bufferSize: 1, refCount: true }), From 323bb09608d4af87c3c32144c02a555b821e45c2 Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Thu, 5 Mar 2026 13:20:51 +0100 Subject: [PATCH 5/6] test: use createNoContentRemoteDataObject$ for safe fallback mock --- .../sidebar-search-list-element.component.spec.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec.ts b/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec.ts index b21d6439b49..f7747a40894 100644 --- a/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec.ts +++ b/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec.ts @@ -7,7 +7,7 @@ import { SearchResult } from '../../search/models/search-result.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { TruncatableService } from '../../truncatable/truncatable.service'; import { LinkService } from '../../../core/cache/builders/link.service'; -import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; +import { createSuccessfulRemoteDataObject$, createNoContentRemoteDataObject$ } from '../../remote-data.utils'; import { HALResource } from '../../../core/shared/hal-resource.model'; import { ChildHALResource } from '../../../core/shared/child-hal-resource.model'; import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; @@ -142,13 +142,13 @@ export function createHierarchicalParentTitleTests( (object.indexableObject as any).type = staticType; } - // Set up the linkService with a safe observable for the parent link so that - // even if the type-check guard ever regresses, the fallback getParent() path - // does not crash with "Cannot read properties of undefined (reading 'pipe')". + // 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]: observableOf(undefined) + [parentLinkKey]: createNoContentRemoteDataObject$() }) }); dsoBreadcrumbsService = jasmine.createSpyObj('dsoBreadcrumbsService', { From ef0c8da0d20e1c0918fa90421790645a762761e3 Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Wed, 11 Mar 2026 07:59:16 +0100 Subject: [PATCH 6/6] Removed unsafe type cast --- .../sidebar-search-list-element.component.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 bbc757af1df..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 @@ -80,9 +80,10 @@ export class SidebarSearchListElementComponent, K exte 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; - if (this.dso && (typeValue === DSpaceObjectType.COMMUNITY.toLowerCase() || typeValue === DSpaceObjectType.COLLECTION.toLowerCase())) { + 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(this.dso as unknown as ChildHALResource & DSpaceObject, '').pipe( + 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);