From 0ce67a1db8957d6bd9a77bded3d4daf19fc71000 Mon Sep 17 00:00:00 2001 From: Naomi Gassler Date: Thu, 20 Aug 2026 21:28:58 +0200 Subject: [PATCH] Show search results error instead of a blank page when the search configuration request fails SearchComponent derives searchSortOptions$ from SearchConfigurationService.getConfigurationSearchConfig(), which pipes through getAllSucceededRemoteDataPayload(). When that request fails the observable never emits, so the combineLatest in ngOnInit never fires and initialized$ stays false. Both @if branches in search.component.html are gated on initialized$, so the whole template is skipped and the page renders empty: not even ds-search-results, which already knows how to render an error, is created. Treat a failed configuration as "no sort options" so the component still initializes. PaginationService.getCurrentSort() already falls back to its default when handed an empty default sort, so the empty list is safe, and the outcome of the results request (results, empty state, or error) is what the user sees. This affects every ds-search instance whose configuration is not already cached. /search normally survives because the homepage caches the 'default' configuration, but /mydspace, /access-control/bulk-access and the item and collection mappers request their own configurations only on those pages, so a failure there leaves a blank page. Adds specs asserting the component still initializes, and still requests results, when the search configuration request fails. --- .../shared/search/search.component.spec.ts | 26 +++++++++++++++++++ src/app/shared/search/search.component.ts | 21 ++++++++++++--- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/app/shared/search/search.component.spec.ts b/src/app/shared/search/search.component.spec.ts index 3b9451d16aa..3020c5a1656 100644 --- a/src/app/shared/search/search.component.spec.ts +++ b/src/app/shared/search/search.component.spec.ts @@ -41,6 +41,7 @@ import { } from '@dspace/core/shared/search/search-filters/search-config.model'; import { SidebarServiceStub } from '@dspace/core/testing/sidebar-service.stub'; import { + createFailedRemoteDataObject$, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$, } from '@dspace/core/utilities/remote-data.utils'; @@ -200,6 +201,7 @@ export function configureSearchComponentTestingModule(compType, additionalDeclar getConfigurationSortOptions: sortOptionsList, getConfig: filtersConfigRD$, getConfigurationSearchConfig: of(searchConfig), + getSearchConfigurationFor: createSuccessfulRemoteDataObject$(searchConfig), getCurrentConfiguration: of('default'), getCurrentScope: of('test-id'), getCurrentSort: of(sortOptionsList[0]), @@ -450,6 +452,30 @@ describe('SearchComponent', () => { //Check that the last method from which the search depend upon is being called expect(searchManagerStub.search).toHaveBeenCalled(); })); + + describe('when the search configuration request fails', () => { + beforeEach(() => { + searchConfigurationServiceStub.getSearchConfigurationFor.and.returnValue( + createFailedRemoteDataObject$('Error fetching search configuration', 500), + ); + }); + + it('should still initialize, so the search results error can be rendered', fakeAsync(() => { + comp.ngOnInit(); + tick(100); + + let initialized: boolean; + comp.initialized$.subscribe((res) => initialized = res); + expect(initialized).toBeTrue(); + })); + + it('should still request results, whose failure is what the user is shown', fakeAsync(() => { + comp.ngOnInit(); + tick(100); + + expect(searchManagerStub.search).toHaveBeenCalled(); + })); + }); }); }); }); diff --git a/src/app/shared/search/search.component.ts b/src/app/shared/search/search.component.ts index 7d9a76d651c..1af38c23191 100644 --- a/src/app/shared/search/search.component.ts +++ b/src/app/shared/search/search.component.ts @@ -38,7 +38,10 @@ import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; import { followLink } from '@dspace/core/shared/follow-link-config.model'; import { Item } from '@dspace/core/shared/item.model'; import { ListableObject } from '@dspace/core/shared/object-collection/listable-object.model'; -import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators'; +import { + getAllCompletedRemoteData, + getFirstCompletedRemoteData, +} from '@dspace/core/shared/operators'; import { PaginatedSearchOptions } from '@dspace/core/shared/search/models/paginated-search-options.model'; import { SearchFilterConfig } from '@dspace/core/shared/search/models/search-filter-config.model'; import { SearchObjects } from '@dspace/core/shared/search/models/search-objects.model'; @@ -401,9 +404,21 @@ export class SearchComponent implements OnDestroy, OnInit { // Determinate PaginatedSearchOptions and listen to any update on it const configuration$: Observable = this.searchConfigService .getCurrentConfiguration(this.configuration).pipe(distinctUntilChanged()); + // A failed search-configuration request must not stall the whole component. + // getConfigurationSearchConfig() pipes through + // getAllSucceededRemoteDataPayload(), so on failure it never emits: the + // combineLatest below never fires, initialized$ stays false, and the entire + // template is skipped — leaving a blank page in which not even + // ds-search-results (which renders the error) is created. Treat a failed + // configuration as "no sort options" so the component still initializes and + // the search results error can surface. const searchSortOptions$: Observable = combineLatest([configuration$, this.currentScope$]).pipe( - switchMap(([configuration, scope]: [string, string]) => this.searchConfigService.getConfigurationSearchConfig(configuration, scope)), - map((searchConfig: SearchConfig) => this.searchConfigService.getConfigurationSortOptions(searchConfig)), + switchMap(([configuration, scope]: [string, string]) => this.searchConfigService.getSearchConfigurationFor(scope, configuration).pipe( + getAllCompletedRemoteData(), + map((searchConfigRD: RemoteData) => searchConfigRD.hasSucceeded + ? this.searchConfigService.getConfigurationSortOptions(searchConfigRD.payload) + : []), + )), distinctUntilChanged(), ); const sortOption$: Observable = searchSortOptions$.pipe(