diff --git a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts index 3c6b256b1..be2db1331 100644 --- a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts +++ b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts @@ -8,12 +8,14 @@ import { defaultXtreamPassword, defaultXtreamUsername, expect, + fillWorkspaceSearch, expectPathname, launchElectronApp, openWorkspaceSection, resetMockServers, test, waitForStalkerCatalog, + waitForPortalDebugEvent, waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; import { @@ -236,6 +238,13 @@ test.describe('Electron Catalog Sorting', () => { vodFixture.categoryName ); await expectCatalogGridReady(app.mainWindow); + const vodSearchTitle = await firstVisibleGridTitle(app.mainWindow); + await expectCatalogScrollResetAfterNextPage(app.mainWindow); + await expectCatalogSearchResetsToFirstPage( + app.mainWindow, + vodSearchTitle + ); + await clearCatalogSearch(app.mainWindow); await expectCatalogScrollResetAfterNextPage(app.mainWindow); await clickFirstGridListCard(app.mainWindow); await expectPathname( @@ -252,6 +261,13 @@ test.describe('Electron Catalog Sorting', () => { seriesFixture.categoryName ); await expectCatalogGridReady(app.mainWindow); + const seriesSearchTitle = await firstVisibleGridTitle(app.mainWindow); + await expectCatalogScrollResetAfterNextPage(app.mainWindow); + await expectCatalogSearchResetsToFirstPage( + app.mainWindow, + seriesSearchTitle + ); + await clearCatalogSearch(app.mainWindow); await expectCatalogScrollResetAfterNextPage(app.mainWindow); await clickFirstGridListCard(app.mainWindow); await expectPathname( @@ -288,6 +304,16 @@ test.describe('Electron Catalog Sorting', () => { vodFixture.categoryName ); await expectCatalogGridReady(app.mainWindow); + const vodSearchTitle = await firstVisibleGridTitle(app.mainWindow); + await expectCatalogScrollResetAfterNextPage(app.mainWindow, { + expectContentChange: false, + }); + await expectStalkerCatalogSearchResetsToFirstPage(app.mainWindow, { + categoryId: vodFixture.categoryId, + title: vodSearchTitle, + type: 'vod', + }); + await clearCatalogSearch(app.mainWindow); await expectCatalogScrollResetAfterNextPage(app.mainWindow, { expectContentChange: false, }); @@ -299,6 +325,16 @@ test.describe('Electron Catalog Sorting', () => { seriesFixture.categoryName ); await expectCatalogGridReady(app.mainWindow); + const seriesSearchTitle = await firstVisibleGridTitle(app.mainWindow); + await expectCatalogScrollResetAfterNextPage(app.mainWindow, { + expectContentChange: false, + }); + await expectStalkerCatalogSearchResetsToFirstPage(app.mainWindow, { + categoryId: seriesFixture.categoryId, + title: seriesSearchTitle, + type: 'series', + }); + await clearCatalogSearch(app.mainWindow); await expectCatalogScrollResetAfterNextPage(app.mainWindow, { expectContentChange: false, }); @@ -401,6 +437,48 @@ async function expectCatalogScrollResetAfterNextPage( return visibleGridTitles(page); } +async function expectCatalogSearchResetsToFirstPage( + page: Page, + title: string +): Promise { + await fillWorkspaceSearch(page, title); + await expectCatalogSearchQuery(page, title); + await expectCatalogPageQuery(page, null); + await expect(catalogGridCardByTitle(page, title).first()).toBeVisible({ + timeout: 20000, + }); +} + +async function expectStalkerCatalogSearchResetsToFirstPage( + page: Page, + options: { categoryId: string; title: string; type: 'series' | 'vod' } +): Promise { + await expectCatalogSearchResetsToFirstPage(page, options.title); + await waitForPortalDebugEvent(page, { + provider: 'stalker', + operation: 'get_ordered_list', + predicate: (event) => { + const requestPayload = event.request as { + params?: Record; + }; + + return ( + requestPayload.params?.['type'] === options.type && + String(requestPayload.params?.['category']) === + options.categoryId && + requestPayload.params?.['search'] === options.title && + String(requestPayload.params?.['p']) === '1' + ); + }, + }); +} + +async function clearCatalogSearch(page: Page): Promise { + await fillWorkspaceSearch(page, ''); + await expectCatalogSearchQuery(page, null); + await expectCatalogPageQuery(page, null); +} + async function expectStalkerDetailBackPreservesCatalogPage( page: Page ): Promise { @@ -416,13 +494,33 @@ async function expectStalkerDetailBackPreservesCatalogPage( async function expectCatalogPageQuery( page: Page, - expectedPage: string + expectedPage: string | null ): Promise { await expect .poll(() => new URL(page.url()).searchParams.get('page')) .toBe(expectedPage); } +async function expectCatalogSearchQuery( + page: Page, + expectedSearch: string | null +): Promise { + await expect + .poll(() => new URL(page.url()).searchParams.get('q')) + .toBe(expectedSearch); +} + +async function firstVisibleGridTitle(page: Page): Promise { + const titles = await visibleGridTitles(page); + const title = titles[0]; + + if (!title) { + throw new Error('Expected at least one visible catalog grid title.'); + } + + return title; +} + async function goBackFromDetail(page: Page): Promise { const backButton = page .locator('app-content-hero .hero__back-button') @@ -460,6 +558,14 @@ function catalogGrid(page: Page) { return page.locator('app-category-content-view app-grid-list').first(); } +function catalogGridCardByTitle(page: Page, title: string) { + return page.locator('.category-content-layout mat-card').filter({ + has: page.locator('.title', { + hasText: new RegExp(`^\\s*${escapeRegex(title)}\\s*$`), + }), + }); +} + async function ensureCatalogCanGoNext(page: Page): Promise { const header = page.locator('.category-content-header'); const nextButton = header.getByRole('button', { name: 'Next page' }); diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts index 3f3e3125e..c38cbd2cb 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts @@ -214,6 +214,64 @@ describe('CategoryContentViewComponent', () => { expect(catalog.setPage).toHaveBeenCalledWith(2); }); + it('preserves the initial search and page query params on direct route loads', () => { + queryParamMap$.next( + convertToParamMap({ + q: 'matrix', + page: '3', + }) + ); + + fixture.detectChanges(); + + expect(catalog.setSearchQuery).toHaveBeenCalledWith('matrix'); + expect(catalog.setPage).toHaveBeenCalledWith(2); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('resets to the first page and removes stale page query params when search changes', () => { + fixture.detectChanges(); + catalog.setPage.mockClear(); + + queryParamMap$.next( + convertToParamMap({ + q: 'matrix', + page: '3', + }) + ); + + expect(catalog.setSearchQuery).toHaveBeenCalledWith('matrix'); + expect(catalog.setPage).toHaveBeenCalledWith(0); + expect(router.navigate).toHaveBeenCalledWith([], { + relativeTo: expect.any(Object), + queryParams: { + page: null, + }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + }); + + it('restores page changes while the search query is unchanged', () => { + queryParamMap$.next( + convertToParamMap({ + q: 'matrix', + }) + ); + fixture.detectChanges(); + catalog.setPage.mockClear(); + + queryParamMap$.next( + convertToParamMap({ + q: 'matrix', + page: '3', + }) + ); + + expect(catalog.setPage).toHaveBeenCalledWith(2); + expect(router.navigate).not.toHaveBeenCalled(); + }); + it('falls back to the first catalog page when the page query param is absent or invalid', () => { fixture.detectChanges(); catalog.setPage.mockClear(); diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts index 0c3634a78..389b92bf7 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts @@ -64,6 +64,8 @@ export class CategoryContentViewComponent implements OnInit { private readonly hostElement = inject(ElementRef); private readonly router = inject(Router); private readonly translate = inject(TranslateService); + private hasAppliedInitialQueryParams = false; + private previousSearchQuery: string | null = null; private readonly catalog = inject( PORTAL_CATALOG_FACADE ) as PortalCatalogFacade< @@ -130,8 +132,31 @@ export class CategoryContentViewComponent implements OnInit { this.activatedRoute.queryParamMap .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((params) => { - this.catalog.setSearchQuery?.(params.get('q') ?? ''); - this.catalog.setPage(this.toPageIndex(params.get('page'))); + const searchQuery = params.get('q') ?? ''; + const pageIndex = this.toPageIndex(params.get('page')); + + this.catalog.setSearchQuery?.(searchQuery); + + if (!this.hasAppliedInitialQueryParams) { + this.hasAppliedInitialQueryParams = true; + this.previousSearchQuery = searchQuery; + this.catalog.setPage(pageIndex); + return; + } + + const didSearchChange = + searchQuery !== this.previousSearchQuery; + this.previousSearchQuery = searchQuery; + + if (didSearchChange) { + this.catalog.setPage(0); + if (params.has('page')) { + this.clearPageQueryParam(); + } + return; + } + + this.catalog.setPage(pageIndex); }); } @@ -172,6 +197,17 @@ export class CategoryContentViewComponent implements OnInit { gridList?.scrollTo?.({ top: 0 }); } + private clearPageQueryParam(): void { + void this.router.navigate([], { + relativeTo: this.activatedRoute, + queryParams: { + page: null, + }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + } + private openStalkerItemFromNavigationState(): void { if (this.catalog.provider !== 'stalker') { return; diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts index 3b7c76c9b..94ec2a0ee 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts @@ -67,6 +67,7 @@ describe('StalkerCatalogFacadeService', () => { getSelectedCategoryName: jest.fn(() => null), setSelectedCategory: jest.fn(), clearSelectedItem: jest.fn(), + setSearchPhrase: jest.fn(), setPage: jest.fn(), setLimit: jest.fn(), setSelectedItem: jest.fn(), @@ -86,6 +87,17 @@ describe('StalkerCatalogFacadeService', () => { }); }); + it('delegates category search query updates to the Stalker store', () => { + const service = TestBed.inject(StalkerCatalogFacadeService); + const store = TestBed.inject(StalkerStore) as unknown as { + setSearchPhrase: jest.Mock; + }; + + service.setSearchQuery('matrix'); + + expect(store.setSearchPhrase).toHaveBeenCalledWith('matrix'); + }); + it('persists matching external playback updates for the current playlist', async () => { TestBed.inject(StalkerCatalogFacadeService); await Promise.resolve(); diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts index 2d509a3f8..af053fc4a 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts @@ -167,6 +167,10 @@ export class StalkerCatalogFacadeService this.stalkerStore.clearSelectedItem(); } + setSearchQuery(query: string): void { + this.stalkerStore.setSearchPhrase(query); + } + setPage(page: number): void { this.stalkerStore.setPage(page); }