diff --git a/src/app/search-page/configuration-search-page.component.ts b/src/app/search-page/configuration-search-page.component.ts index 96770b27042..e78267c98fe 100644 --- a/src/app/search-page/configuration-search-page.component.ts +++ b/src/app/search-page/configuration-search-page.component.ts @@ -14,6 +14,7 @@ import { AppConfig, } from '@dspace/config/app-config.interface'; import { SearchManager } from '@dspace/core/browse/search-manager'; +import { PaginationService } from '@dspace/core/pagination/pagination.service'; import { RouteService } from '@dspace/core/services/route.service'; import { TranslateModule } from '@ngx-translate/core'; @@ -69,7 +70,8 @@ export class ConfigurationSearchPageComponent extends SearchComponent { @Inject(APP_CONFIG) protected appConfig: AppConfig, @Inject(PLATFORM_ID) public platformId: string, protected searchManager: SearchManager, + protected paginationService: PaginationService, ) { - super(service, sidebarService, windowService, searchConfigService, routeService, router, appConfig, platformId, searchManager); + super(service, sidebarService, windowService, searchConfigService, routeService, router, appConfig, platformId, searchManager, paginationService); } } diff --git a/src/app/shared/search/search.component.spec.ts b/src/app/shared/search/search.component.spec.ts index 3b9451d16aa..eccc715524c 100644 --- a/src/app/shared/search/search.component.spec.ts +++ b/src/app/shared/search/search.component.spec.ts @@ -23,6 +23,7 @@ import { import { CommunityDataService } from '@dspace/core/data/community-data.service'; import { RemoteData } from '@dspace/core/data/remote-data'; import { APP_DATA_SERVICES_MAP } from '@dspace/core/data-services-map-type'; +import { PaginationService } from '@dspace/core/pagination/pagination.service'; import { PaginationComponentOptions } from '@dspace/core/pagination/pagination-component-options.model'; import { getCollectionPageRoute, @@ -31,6 +32,7 @@ import { import { RouteService } from '@dspace/core/services/route.service'; import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; import { Item } from '@dspace/core/shared/item.model'; +import { PageInfo } from '@dspace/core/shared/page-info.model'; import { FilterType } from '@dspace/core/shared/search/models/filter-type.model'; import { PaginatedSearchOptions } from '@dspace/core/shared/search/models/paginated-search-options.model'; import { SearchFilterConfig } from '@dspace/core/shared/search/models/search-filter-config.model'; @@ -39,6 +41,7 @@ import { SearchConfig, SortConfig, } from '@dspace/core/shared/search/search-filters/search-config.model'; +import { PaginationServiceStub } from '@dspace/core/testing/pagination-service.stub'; import { SidebarServiceStub } from '@dspace/core/testing/sidebar-service.stub'; import { createSuccessfulRemoteDataObject, @@ -253,6 +256,7 @@ export function configureSearchComponentTestingModule(compType, additionalDeclar { provide: APP_DATA_SERVICES_MAP, useValue: {} }, { provide: APP_CONFIG, useValue: environment }, { provide: PLATFORM_ID, useValue: 'browser' }, + { provide: PaginationService, useClass: PaginationServiceStub }, ], schemas: [NO_ERRORS_SCHEMA], }).overrideComponent(compType, { @@ -452,4 +456,71 @@ describe('SearchComponent', () => { })); }); }); + + describe('Fallback pagination logic', () => { + let paginationServiceStub: PaginationServiceStub; + + beforeEach(() => { + paginationServiceStub = TestBed.inject(PaginationService) as any; + }); + + afterEach(() => { + searchManagerStub.search.and.returnValue(mockResultsRD$); + paginationServiceStub.updateRoute.calls.reset(); + }); + + it('should navigate to last available page when requested page exceeds total pages', fakeAsync(() => { + // Mock search to return empty results with totalPages = 3 + const emptySearchResults: SearchObjects = Object.assign(new SearchObjects(), { + page: [], + pageInfo: Object.assign(new PageInfo(), { totalPages: 3, totalElements: 25, elementsPerPage: 10, currentPage: 5 }), + }); + const emptyResultsRD = createSuccessfulRemoteDataObject(emptySearchResults); + searchManagerStub.search.and.returnValue(of(emptyResultsRD)); + + fixture.detectChanges(); + tick(100); + + // Reset updateRoute spy to only track calls after the page change + paginationServiceStub.updateRoute.calls.reset(); + + // Emit new search options with currentPage = 5 (exceeding totalPages = 3) + const paginationOptions = Object.assign(new PaginationComponentOptions(), { + id: paginationId, + currentPage: 5, + pageSize: 10, + }); + paginatedSearchOptions$.next(new PaginatedSearchOptions({ pagination: paginationOptions })); + tick(100); + + expect(paginationServiceStub.updateRoute).toHaveBeenCalledWith(paginationId, { page: 3 }); + })); + + it('should NOT navigate when requested page is within total pages', fakeAsync(() => { + // Mock search to return results on page 2 + const searchResultsPage2: SearchObjects = Object.assign(new SearchObjects(), { + page: [mockDso], + pageInfo: Object.assign(new PageInfo(), { totalPages: 3, totalElements: 25, elementsPerPage: 10, currentPage: 2 }), + }); + const resultsRD = createSuccessfulRemoteDataObject(searchResultsPage2); + searchManagerStub.search.and.returnValue(of(resultsRD)); + + fixture.detectChanges(); + tick(100); + + // Reset updateRoute spy + paginationServiceStub.updateRoute.calls.reset(); + + // Emit new search options with currentPage = 2 (within totalPages = 3) + const paginationOptions = Object.assign(new PaginationComponentOptions(), { + id: paginationId, + currentPage: 2, + pageSize: 10, + }); + paginatedSearchOptions$.next(new PaginatedSearchOptions({ pagination: paginationOptions })); + tick(100); + + expect(paginationServiceStub.updateRoute).not.toHaveBeenCalled(); + })); + }); }); diff --git a/src/app/shared/search/search.component.ts b/src/app/shared/search/search.component.ts index 7d9a76d651c..130f431ea72 100644 --- a/src/app/shared/search/search.component.ts +++ b/src/app/shared/search/search.component.ts @@ -26,6 +26,7 @@ import { SearchManager } from '@dspace/core/browse/search-manager'; import { SortOptions } from '@dspace/core/cache/models/sort-options.model'; import { PaginatedList } from '@dspace/core/data/paginated-list.model'; import { RemoteData } from '@dspace/core/data/remote-data'; +import { PaginationService } from '@dspace/core/pagination/pagination.service'; import { COLLECTION_MODULE_PATH, COMMUNITY_MODULE_PATH, @@ -356,6 +357,7 @@ export class SearchComponent implements OnDestroy, OnInit { @Inject(APP_CONFIG) protected appConfig: AppConfig, @Inject(PLATFORM_ID) public platformId: string, protected searchManager: SearchManager, + protected paginationService: PaginationService, ) { this.isXsOrSm$ = this.windowService.isXsOrSm(); } @@ -555,6 +557,15 @@ export class SearchComponent implements OnDestroy, OnInit { ...followLinks, ).pipe(getFirstCompletedRemoteData()) .subscribe((results: RemoteData>) => { + // Fallback logic: if no results and requested page > 1, navigate to the last available page + if (results.hasSucceeded && results.payload?.page?.length === 0 && searchOptionsWithHidden.pagination.currentPage > 1) { + const totalPages = results.payload?.pageInfo?.totalPages; + if (totalPages && totalPages > 0 && searchOptionsWithHidden.pagination.currentPage > totalPages) { + // Update the route to the last available page + this.paginationService.updateRoute(this.paginationId, { page: totalPages }); + return; + } + } if (results.hasSucceeded) { if (this.trackStatistics) { this.service.trackSearch(searchOptionsWithHidden, results.payload);