diff --git a/.changes/workspace-back-parent-fallback.md b/.changes/workspace-back-parent-fallback.md new file mode 100644 index 000000000..b6536e7f9 --- /dev/null +++ b/.changes/workspace-back-parent-fallback.md @@ -0,0 +1,6 @@ +--- +type: fix +area: workspace +--- + +Back on Settings, search, Discover and actor pages now works when that page is the first one you opened, for example after a reload or from a link: it takes you to the dashboard or to the playlist's movies or series instead of doing nothing or leaving the app. diff --git a/apps/electron-backend-e2e/src/detail-header-back.e2e.ts b/apps/electron-backend-e2e/src/detail-header-back.e2e.ts index 7be2f2e85..f45655f48 100644 --- a/apps/electron-backend-e2e/src/detail-header-back.e2e.ts +++ b/apps/electron-backend-e2e/src/detail-header-back.e2e.ts @@ -13,6 +13,10 @@ import { test, waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; +import { + expectRendererReloadedOnRoute, + reloadFromMainProcess, +} from './renderer-reload.support'; // --------------------------------------------------------------------------- // A detail page's Back lives in the workspace header's leading slot, not in @@ -29,6 +33,10 @@ import { // header's history fallback while an in-app previous page exists; on a // phone it yields to the drawer toggle, and with nowhere to go the slot is // empty rather than a disabled arrow. +// +// A page with a parent route (settings here) that opened the session, as +// after a reload, has no in-app entry for history Back: its Back leads to +// the parent instead and replaces the page's entry. // --------------------------------------------------------------------------- const widths = [1280, 780, 375]; @@ -346,4 +354,33 @@ test.describe('Portal detail header Back', () => { await closeElectronApp(app); } }); + + test('@electron @settings settings Back after a reload leads to the dashboard', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + + try { + const page = app.mainWindow; + await page.waitForURL(/\/workspace\/dashboard$/); + await openSettings(page); + + // The reloaded document re-boots on the settings route; the + // dashboard entry before it belongs to the old document. + await reloadFromMainProcess(app); + await expectRendererReloadedOnRoute( + page, + /\/workspace\/settings\/general$/ + ); + await expect(page.getByTestId('settings-container')).toBeVisible(); + + await headerBack(page).click(); + await expect(page).toHaveURL(/\/workspace\/dashboard$/); + // The dashboard replaced the settings entry: nothing in this + // document precedes it, so the header offers no Back. + await expect(headerBack(page)).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); }); diff --git a/apps/web-e2e/src/settings.e2e.ts b/apps/web-e2e/src/settings.e2e.ts index b40e6fb61..e440d0cd9 100644 --- a/apps/web-e2e/src/settings.e2e.ts +++ b/apps/web-e2e/src/settings.e2e.ts @@ -49,6 +49,24 @@ test.describe('Settings', () => { await page.waitForURL(/\/workspace\/dashboard$/); }); + test('@settings @web settings opening the session lead to the dashboard', async ({ + page, + }) => { + // The only entry of a new tab's history, as in Electron after a + // deep link: browser Back has nowhere to go. + const firstEntryPage = await page.context().newPage(); + await firstEntryPage.goto('/workspace/settings/general'); + // A cold start in a new tab passes the startup splash first. + await expect(firstEntryPage.locator('.settings-container')).toBeVisible( + { timeout: 15_000 } + ); + + await settingsBack(firstEntryPage).click(); + await firstEntryPage.waitForURL(/\/workspace\/dashboard$/); + // The dashboard replaced the settings entry: no Back leads to it. + await expect(settingsBack(firstEntryPage)).toHaveCount(0); + }); + test('@settings @web Change video player', async ({ page }) => { await openSettings(page); await openSettingsSection(page, 'playback'); diff --git a/apps/web-e2e/src/workspace-header-back.e2e.ts b/apps/web-e2e/src/workspace-header-back.e2e.ts index 6bdb5f175..a763ff4eb 100644 --- a/apps/web-e2e/src/workspace-header-back.e2e.ts +++ b/apps/web-e2e/src/workspace-header-back.e2e.ts @@ -8,8 +8,10 @@ import { /** * Pages reached from a detail or the header search draw no Back arrow of - * their own: they register the workspace header's leading Back, which keeps - * their previous return behaviour (history Back). + * their own: they register the workspace header's leading Back. It goes back + * in history when the page was reached in the app; a page that opened the + * session (a deep link, reload or restored view) leads to its parent + * instead, replacing its own history entry. * Contract: docs/architecture/workspace-shell.md, "Header Back". */ @@ -29,6 +31,24 @@ async function expectOnlyHeaderBack(page: Page): Promise { await expect(headerBack(page)).not.toHaveAttribute('aria-keyshortcuts'); } +/** The portal's own URL, from the VOD list that adding it opens. */ +function portalUrlOf(page: Page): string { + return page.url().replace(/\/vod.*$/, ''); +} + +/** + * Opens `url` as the only entry of a new tab's history, where browser Back + * has nowhere to go, as in Electron after a deep link or restored view. A + * `page.goto` in the same tab would leave the previous document behind, + * often at the very URL of the parent. + */ +async function openAsFirstEntry(page: Page, url: string): Promise { + const firstEntryPage = await page.context().newPage(); + await interceptXtreamRequests(firstEntryPage); + await firstEntryPage.goto(url); + return firstEntryPage; +} + test.beforeEach(async ({ page, request }) => { await request.post(`${MOCK_SERVER}/reset`); await page.goto('/'); @@ -36,7 +56,7 @@ test.beforeEach(async ({ page, request }) => { await addXtreamPortal(page); }); -test('@web @xtream the in-portal search page returns through the header Back', async ({ +test('@web @xtream the in-portal search page reached in the app goes back in history', async ({ page, }) => { await page @@ -62,25 +82,51 @@ test('@web @xtream the in-portal search page returns through the header Back', a await page.waitForURL(/\/workspace\/dashboard$/); }); -for (const { name, path, selector } of [ +for (const { name, path, selector, parent } of [ { - name: 'Discover', + name: 'movie Discover', path: 'discover?type=movie&genre=18&genreLabel=Drama', selector: 'app-discover-view', + parent: 'vod', + }, + { + name: 'series Discover', + path: 'discover?type=tv&genre=18&genreLabel=Drama', + selector: 'app-discover-view', + parent: 'series', + }, + // The portal root: its route config redirects to the default section. + { + name: 'actor', + path: 'actor/287', + selector: 'app-actor-view', + parent: 'vod', + }, + { + name: 'search', + path: 'search?q=Movie', + selector: 'app-search-layout', + parent: 'vod', }, - { name: 'actor', path: 'actor/287', selector: 'app-actor-view' }, ]) { - test(`@web @xtream the ${name} page returns through the header Back`, async ({ + test(`@web @xtream the ${name} page opening the session leads to its parent`, async ({ page, }) => { - const listUrl = page.url(); - const portalUrl = listUrl.replace(/\/vod.*$/, ''); + const portalUrl = portalUrlOf(page); + const firstEntryPage = await openAsFirstEntry( + page, + `${portalUrl}/${path}` + ); + // A cold start in a new tab passes the startup splash first. + await expect(firstEntryPage.locator(selector)).toBeAttached({ + timeout: 15_000, + }); - await page.goto(`${portalUrl}/${path}`); - await expect(page.locator(selector)).toBeAttached(); - - await expectOnlyHeaderBack(page); - await headerBack(page).click(); - await page.waitForURL(listUrl); + await expectOnlyHeaderBack(firstEntryPage); + await headerBack(firstEntryPage).click(); + await firstEntryPage.waitForURL(`${portalUrl}/${parent}`); + // The parent replaced the page's entry, so nothing precedes it and + // the list shows no Back (the history fallback would otherwise). + await expect(headerBack(firstEntryPage)).toHaveCount(0); }); } diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 53e89559c..d6e6c79d4 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -195,7 +195,10 @@ Page-level Back lives only in the workspace header's leading slot (see [Header Back](./workspace-shell.md#header-back)). A routed page, or the shell it renders in, registers it with `registerWorkspaceBack()` instead of drawing an arrow, so Back keeps one position and one look on every page and never -floats over a scroll owner. Without a registration the header falls back to +floats over a scroll owner. A page whose Back is history Back calls +`WorkspaceBackNavigationService.back()` with its parent route rather than +`Location.back()`, so Back still leads somewhere when the page opened the +session. Without a registration the header falls back to browser history while an in-app previous page exists, and shows nothing otherwise. An arrow that returns within a menu, dialog or player panel is not page navigation and stays in that surface; an error state may repeat the diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 6f6772a0a..829e6e5f9 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -146,9 +146,9 @@ label (else the translated "Back"), whether Escape on the page runs it, and | Page | Registered by | Back runs | ≤640 px | | --- | --- | --- | --- | | Portal, collection, offline and recording details | `PortalDetailShellComponent` while `backAvailable()` | the host's `backClicked` | replaces the drawer toggle | -| Xtream and Stalker Discover and actor pages | `DiscoverViewComponent`, `ActorViewComponent` | the route's `Location.back()` | (no drawer) | -| In-portal search, Xtream and Stalker | `SearchLayoutComponent` while `backAvailable()` and no inline detail replaces the results | `Location.back()` | (no drawer) | -| Settings | `WorkspaceSettingsContextPanelComponent`, which exists exactly while the settings route shows | `Location.back()` | beside the drawer toggle | +| Xtream and Stalker Discover and actor pages | `DiscoverViewComponent`, `ActorViewComponent` | the route's history Back; parent: the catalog section Discover lists (`vod` for movies, `series` for TV), the portal's default section for actor | (no drawer) | +| In-portal search, Xtream and Stalker | `SearchLayoutComponent` while `backAvailable()` and no inline detail replaces the results | history Back; parent: the portal's default section | (no drawer) | +| Settings | `WorkspaceSettingsContextPanelComponent`, which exists exactly while the settings route shows | history Back; parent: the first workspace view (`WorkspaceStartupPreferencesService.resolveDashboardPath()`: the dashboard, or sources when it is hidden) | beside the drawer toggle | Detail-page semantics (Escape, browse and watch) are in [Portal Detail Navigation](./portal-detail-navigation.md#detail-scroll-and-focus). @@ -167,6 +167,31 @@ advertises no Escape, because no page handles one for it. Pages that set `backAvailable=false`, such as M3U details, therefore show it too when they were reached by navigation. +**Parent fallback.** "Parent" in the table above: a registered page whose +Back is history Back calls `WorkspaceBackNavigationService.back(resolveParent)` +instead of `Location.back()`. It runs `Location.back()` while the previous +entry is an in-app one, by the same Navigation API test as the history +fallback. Without the API (older Safari and Firefox) the router's history +depth decides (`trackRouterHistoryDepth`): the document's first navigation is +depth 0, a push adds one, a replacement keeps it and a traversal restores the +depth recorded for its entry. The lazy workspace shell creates the service +after the first navigation began, so the tracker adopts the router's current +or last navigation: a first one is depth 0, a later one leaves the depth +unknown. A traversal to an entry from before a reload +leaves the depth unknown and keeps `Location.back()`, which then has a +previous entry. Otherwise the page opened +the session (a deep link, a reload or a restored view), where +`Location.back()` does nothing in Electron and leaves the app in a browser. +The service then navigates to the page's parent with `replaceUrl`, so history +Back cannot return to the page just left; with nothing in-app before it, the +parent shows no history fallback. The resolver returns a URL or router commands, may be asynchronous, and +returns null when the page knows no parent, which keeps `Location.back()`. +Portal pages build their parent with `workspacePortalCommands()` +(`@iptvnator/portal/shared/util`) from the route's `:id`; without a section, +the portal route's `redirectTo` picks the default section within the same +navigation, so the replacement still applies. Detail pages keep their own +return logic (`backClicked`). + When there is nowhere to go, the slot is empty rather than a disabled arrow. Sessions often start on a page that never navigates (an M3U playlist or live TV), where a disabled arrow would stay for the whole session. The cost is one diff --git a/libs/portal/shared/data-access/src/lib/router-history-depth.ts b/libs/portal/shared/data-access/src/lib/router-history-depth.ts new file mode 100644 index 000000000..a41a4786d --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/router-history-depth.ts @@ -0,0 +1,84 @@ +import type { DestroyRef } from '@angular/core'; +import { + NavigationCancel, + NavigationEnd, + NavigationError, + NavigationStart, + type Router, +} from '@angular/router'; + +/** + * In-app history depth from router events, for runtimes without the + * Navigation API (older Safari and Firefox), where the browser does not say + * whether the previous entry belongs to this app session. + * + * The document's first navigation is depth 0, a push adds one, a replacement + * or a navigation that skips the location keeps the depth, and a traversal + * restores the depth recorded for the entry it returns to. Entries from + * before a reload were recorded by another document, so a traversal to one + * leaves the depth unknown (null). + */ +export function trackRouterHistoryDepth( + router: Pick< + Router, + 'events' | 'currentNavigation' | 'lastSuccessfulNavigation' + >, + destroyRef: Pick +): () => number | null { + let depth: number | null = null; + let pending: number | null = null; + let started = false; + const depthByNavigationId = new Map(); + // A Router without events (a partial test double) leaves the depth + // unknown, which keeps browser history Back. + if (!router.events) return () => null; + + // The tracker can start after the first navigation began: the workspace + // shell that creates it is lazy. Adopt the router's state for the events + // it missed. Only a first navigation has a known depth (0); a later one + // leaves it unknown, which keeps browser history Back. + const inFlight = router.currentNavigation?.() ?? null; + const last = router.lastSuccessfulNavigation?.() ?? null; + const adoptedId = inFlight?.id ?? null; + if (inFlight) { + started = true; + pending = inFlight.previousNavigation === null ? 0 : null; + } else if (last) { + started = true; + depth = last.previousNavigation === null ? 0 : null; + if (depth !== null) depthByNavigationId.set(last.id, depth); + } + + const subscription = router.events.subscribe((event) => { + if (event instanceof NavigationStart) { + // Already adopted above; its start may still be on its way. + if (event.id === adoptedId) return; + if (!started) { + pending = 0; + } else if (event.navigationTrigger === 'popstate') { + const restoredId = event.restoredState?.navigationId; + pending = + restoredId === undefined + ? null + : (depthByNavigationId.get(restoredId) ?? null); + } else { + const extras = router.currentNavigation()?.extras; + pending = + extras?.replaceUrl || extras?.skipLocationChange + ? depth + : (depth ?? 0) + 1; + } + started = true; + } else if (event instanceof NavigationEnd) { + depth = pending; + if (depth !== null) depthByNavigationId.set(event.id, depth); + } else if ( + event instanceof NavigationCancel || + event instanceof NavigationError + ) { + pending = depth; + } + }); + destroyRef.onDestroy(() => subscription.unsubscribe()); + return () => depth; +} diff --git a/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.spec.ts b/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.spec.ts index 168642daf..2a5222dbd 100644 --- a/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.spec.ts @@ -1,6 +1,13 @@ import { Location } from '@angular/common'; import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { + Event as RouterEvent, + NavigationEnd, + NavigationStart, + Router, +} from '@angular/router'; +import { Subject } from 'rxjs'; import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util'; import { WORKSPACE_HISTORY_NAVIGATION, @@ -42,15 +49,65 @@ class FakeHistory extends EventTarget { describe('WorkspaceBackNavigationService', () => { const back = jest.fn(); + const navigate = jest.fn().mockResolvedValue(true); + const navigateByUrl = jest.fn().mockResolvedValue(true); + let routerEvents = new Subject(); + /** The parts of a router Navigation the depth tracker reads. */ + type NavigationLike = { + id?: number; + previousNavigation?: unknown; + extras: { replaceUrl?: boolean }; + }; + const currentNavigation = signal(null); + const lastSuccessfulNavigation = signal(null); + let navigationId = 0; + + /** A router navigation as the history depth tracker sees it. */ + function routerNavigation( + options: { + popstateTo?: number; + replaceUrl?: boolean; + } = {} + ): number { + const id = ++navigationId; + currentNavigation.set({ extras: { replaceUrl: options.replaceUrl } }); + routerEvents.next( + new NavigationStart( + id, + `/page-${id}`, + options.popstateTo === undefined ? 'imperative' : 'popstate', + options.popstateTo === undefined + ? null + : { navigationId: options.popstateTo } + ) + ); + routerEvents.next(new NavigationEnd(id, `/page-${id}`, `/page-${id}`)); + currentNavigation.set(null); + return id; + } function createService( history: FakeHistory | null = null ): WorkspaceBackNavigationService { back.mockReset(); + navigate.mockClear(); + navigateByUrl.mockClear(); + routerEvents = new Subject(); + navigationId = 0; TestBed.resetTestingModule(); TestBed.configureTestingModule({ providers: [ { provide: Location, useValue: { back } }, + { + provide: Router, + useValue: { + navigate, + navigateByUrl, + events: routerEvents, + currentNavigation, + lastSuccessfulNavigation, + }, + }, { provide: WORKSPACE_HISTORY_NAVIGATION, useValue: history as unknown as WorkspaceHistoryNavigation, @@ -60,6 +117,11 @@ describe('WorkspaceBackNavigationService', () => { return TestBed.inject(WorkspaceBackNavigationService); } + afterEach(() => { + currentNavigation.set(null); + lastSuccessfulNavigation.set(null); + }); + function createTarget(run = jest.fn()): WorkspaceBackTarget { return { label: signal(null), @@ -202,4 +264,213 @@ describe('WorkspaceBackNavigationService', () => { ); }); }); + + describe('back to a parent', () => { + it('goes back in history while the previous entry is in-app', () => { + const history = new FakeHistory(); + const service = createService(history); + history.push(); + const parent = jest.fn(() => '/workspace/dashboard'); + + service.back(parent); + + expect(back).toHaveBeenCalledTimes(1); + expect(parent).not.toHaveBeenCalled(); + expect(navigateByUrl).not.toHaveBeenCalled(); + }); + + it('opens the parent in place of a page that opened the session', async () => { + const service = createService(new FakeHistory()); + + service.back(() => '/workspace/dashboard'); + await Promise.resolve(); + + // Replacing keeps history Back from returning to the page. + expect(navigateByUrl).toHaveBeenCalledWith('/workspace/dashboard', { + replaceUrl: true, + }); + expect(back).not.toHaveBeenCalled(); + }); + + it('opens the parent after a reload, whose old entries do not count', async () => { + const service = createService(new FakeHistory([false, true])); + + service.back(() => ['/workspace', 'xtreams', 'pl/1', 'vod']); + await Promise.resolve(); + + expect(navigate).toHaveBeenCalledWith( + ['/workspace', 'xtreams', 'pl/1', 'vod'], + { replaceUrl: true } + ); + expect(back).not.toHaveBeenCalled(); + }); + + it('waits for a parent that resolves asynchronously', async () => { + const service = createService(new FakeHistory()); + + service.back(() => Promise.resolve('/workspace/sources')); + await Promise.resolve(); + await Promise.resolve(); + + expect(navigateByUrl).toHaveBeenCalledWith('/workspace/sources', { + replaceUrl: true, + }); + }); + + it('keeps browser history when the page knows no parent', async () => { + const service = createService(new FakeHistory()); + + service.back(() => null); + await Promise.resolve(); + + expect(back).toHaveBeenCalledTimes(1); + expect(navigate).not.toHaveBeenCalled(); + expect(navigateByUrl).not.toHaveBeenCalled(); + }); + + describe('without the Navigation API', () => { + // The router's history depth decides there (older Safari and + // Firefox): a page that opened the session must not leave the app. + it('opens the parent of the page that opened the session', async () => { + const service = createService(null); + routerNavigation(); + + service.back(() => '/workspace/dashboard'); + await Promise.resolve(); + + expect(back).not.toHaveBeenCalled(); + expect(navigateByUrl).toHaveBeenCalledWith( + '/workspace/dashboard', + { replaceUrl: true } + ); + }); + + it('goes back in history after the router pushed a page', () => { + const service = createService(null); + routerNavigation(); + routerNavigation(); + const parent = jest.fn(() => '/workspace/dashboard'); + + service.back(parent); + + expect(back).toHaveBeenCalledTimes(1); + expect(parent).not.toHaveBeenCalled(); + }); + + it('treats a replacement as the same entry', async () => { + const service = createService(null); + routerNavigation(); + routerNavigation({ replaceUrl: true }); + + service.back(() => ['/workspace', 'sources']); + await Promise.resolve(); + + expect(back).not.toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith( + ['/workspace', 'sources'], + { replaceUrl: true } + ); + }); + + it('opens the parent again after Back returned to the first page', async () => { + const service = createService(null); + const first = routerNavigation(); + routerNavigation(); + routerNavigation({ popstateTo: first }); + + service.back(() => '/workspace/dashboard'); + await Promise.resolve(); + + expect(back).not.toHaveBeenCalled(); + expect(navigateByUrl).toHaveBeenCalledTimes(1); + }); + + // The lazy workspace shell creates the service after the first + // navigation started, so the tracker adopts the router's state. + it('adopts a first navigation that ended before the service existed', async () => { + lastSuccessfulNavigation.set({ + id: 1, + previousNavigation: null, + extras: {}, + }); + const service = createService(null); + + service.back(() => '/workspace/dashboard'); + await Promise.resolve(); + + expect(back).not.toHaveBeenCalled(); + expect(navigateByUrl).toHaveBeenCalledTimes(1); + }); + + it('adopts a first navigation whose start fired before the service existed', async () => { + currentNavigation.set({ + id: 1, + previousNavigation: null, + extras: {}, + }); + const service = createService(null); + navigationId = 1; + // Only the end reaches the tracker. + routerEvents.next(new NavigationEnd(1, '/page-1', '/page-1')); + currentNavigation.set(null); + + service.back(() => '/workspace/dashboard'); + await Promise.resolve(); + + expect(back).not.toHaveBeenCalled(); + expect(navigateByUrl).toHaveBeenCalledTimes(1); + }); + + it('adopts a first navigation still in flight and counts the next push', async () => { + currentNavigation.set({ + id: 1, + previousNavigation: null, + extras: {}, + }); + const service = createService(null); + navigationId = 1; + // Its start arrives late; the end commits depth 0. + routerEvents.next(new NavigationStart(1, '/page-1')); + routerEvents.next(new NavigationEnd(1, '/page-1', '/page-1')); + currentNavigation.set(null); + + service.back(() => '/workspace/dashboard'); + await Promise.resolve(); + expect(back).not.toHaveBeenCalled(); + expect(navigateByUrl).toHaveBeenCalledTimes(1); + + routerNavigation(); + service.back(() => '/workspace/dashboard'); + expect(back).toHaveBeenCalledTimes(1); + }); + + it('keeps browser history when created after several navigations', () => { + lastSuccessfulNavigation.set({ + id: 3, + previousNavigation: { id: 2 }, + extras: {}, + }); + const service = createService(null); + const parent = jest.fn(() => '/workspace/dashboard'); + + service.back(parent); + + expect(back).toHaveBeenCalledTimes(1); + expect(parent).not.toHaveBeenCalled(); + }); + + it('keeps browser history on an entry from before a reload', () => { + const service = createService(null); + routerNavigation(); + // Restores an entry recorded by the previous document. + routerNavigation({ popstateTo: 42 }); + const parent = jest.fn(() => '/workspace/dashboard'); + + service.back(parent); + + expect(back).toHaveBeenCalledTimes(1); + expect(parent).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.ts b/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.ts index fd79fd8da..e0731344a 100644 --- a/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.ts +++ b/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.ts @@ -7,7 +7,9 @@ import { InjectionToken, signal, } from '@angular/core'; +import { Router } from '@angular/router'; import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util'; +import { trackRouterHistoryDepth } from './router-history-depth'; /** The parts of the browser's Navigation API the history fallback reads. */ export type WorkspaceHistoryNavigation = Pick< @@ -28,6 +30,12 @@ export const WORKSPACE_HISTORY_NAVIGATION = } ); +/** + * Where a page's Back leads when there is no in-app history: a URL or router + * commands. Null keeps browser history. + */ +export type WorkspaceBackParent = string | readonly string[] | null; + /** * True when the previous history entry belongs to this document, i.e. the * router pushed it in this app session. Entries from before a reload or from @@ -49,8 +57,14 @@ function hasInAppPreviousEntry(history: WorkspaceHistoryNavigation): boolean { @Injectable({ providedIn: 'root' }) export class WorkspaceBackNavigationService { private readonly location = inject(Location); + private readonly router = inject(Router); + private readonly history = inject(WORKSPACE_HISTORY_NAVIGATION); private readonly targets = signal([]); private readonly canGoBackInApp = signal(false); + /** In-app history depth where the Navigation API is missing. */ + private readonly routerDepth = this.history + ? () => null + : trackRouterHistoryDepth(this.router, inject(DestroyRef)); /** * Generic Back to the previous page. It advertises no Escape (no page @@ -73,7 +87,7 @@ export class WorkspaceBackNavigationService { }); constructor() { - const history = inject(WORKSPACE_HISTORY_NAVIGATION); + const history = this.history; if (!history) return; // Fires for router pushes and replacements and for traversals, // including a guard-cancelled Back that the router rewrites. @@ -86,6 +100,29 @@ export class WorkspaceBackNavigationService { ); } + /** + * Back for a page with a parent route. Browser history while the previous + * entry is an in-app one. Otherwise the page opened the session (deep + * link, reload, restored view), where `Location.back()` would do nothing + * in Electron or leave the app in a browser: the parent replaces the + * current entry, so history Back cannot return to the page just left. + * Without the Navigation API the router's history depth decides, and an + * unknown depth (a traversal to an entry from before a reload) keeps + * browser history, which then has a previous entry. + */ + back( + resolveParent: () => WorkspaceBackParent | Promise + ): void { + const inApp = this.history + ? this.canGoBackInApp() + : (this.routerDepth() ?? 1) > 0; + if (inApp) { + this.location.back(); + return; + } + void this.openParent(resolveParent); + } + /** * Returns the release function. It removes only this target: when one * page replaces another (a loading shell by the loaded one), creation and @@ -109,4 +146,17 @@ export class WorkspaceBackNavigationService { target.run(); return true; } + + private async openParent( + resolveParent: () => WorkspaceBackParent | Promise + ): Promise { + const parent = await resolveParent(); + if (parent === null) { + this.location.back(); + } else if (typeof parent === 'string') { + await this.router.navigateByUrl(parent, { replaceUrl: true }); + } else { + await this.router.navigate([...parent], { replaceUrl: true }); + } + } } diff --git a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts index 52059e9aa..4ff19f141 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts @@ -22,6 +22,7 @@ import { routeParamSignal, resolveCurrentPortalPlaylistId, resolveCurrentPortalSection, + workspacePortalCommands, } from './portal-route.utils'; describe('portal-route.utils', () => { @@ -189,6 +190,41 @@ describe('portal-route.utils', () => { ).toBe('playlist-2'); }); + it('builds commands for the portal a nested route belongs to', () => { + const actorRoute = { + pathFromRoot: [ + { snapshot: { params: {}, paramMap: convertToParamMap({}) } }, + { + snapshot: { + params: { id: 'playlist-1' }, + paramMap: convertToParamMap({ id: 'playlist-1' }), + }, + }, + { + snapshot: { + params: { personId: '287' }, + paramMap: convertToParamMap({ personId: '287' }), + }, + }, + ], + } as unknown as ActivatedRoute; + const outsidePortal = { + pathFromRoot: [ + { snapshot: { params: {}, paramMap: convertToParamMap({}) } }, + ], + } as unknown as ActivatedRoute; + + expect(workspacePortalCommands(actorRoute, 'xtreams')).toEqual([ + '/workspace', + 'xtreams', + 'playlist-1', + ]); + expect( + workspacePortalCommands(actorRoute, 'stalker', 'series') + ).toEqual(['/workspace', 'stalker', 'playlist-1', 'series']); + expect(workspacePortalCommands(outsidePortal, 'xtreams')).toBeNull(); + }); + it('creates normalized query-param signals', () => { TestBed.configureTestingModule({}); diff --git a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts index 9a2c81235..050baee42 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts @@ -117,6 +117,27 @@ export function resolveCurrentPortalPlaylistId( return extractPortalPlaylistId(routerUrl, provider); } +/** + * Router commands for the workspace portal that `route` belongs to (the `:id` + * param of an ancestor), or for one of its sections. Without a section the + * portal's route config redirects to its default section. Null outside a + * portal route. + */ +export function workspacePortalCommands( + route: ActivatedRoute, + provider: PortalProvider, + section?: PortalRailSection +): string[] | null { + const playlistId = resolveCurrentRouteParam(route, 'id'); + if (!playlistId) { + return null; + } + + return section + ? ['/workspace', provider, playlistId, section] + : ['/workspace', provider, playlistId]; +} + function resolveCurrentRouteParam( route: ActivatedRoute, key: string diff --git a/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts b/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts index c3c2b69a2..ea3bbd477 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts @@ -1,4 +1,3 @@ -import { Location } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -20,7 +19,11 @@ import { pickTitleMatch, } from '@iptvnator/services'; import { CatalogTitleMatch } from '@iptvnator/shared/interfaces'; -import { createLatestRequestGuard } from '@iptvnator/portal/shared/util'; +import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; +import { + createLatestRequestGuard, + workspacePortalCommands, +} from '@iptvnator/portal/shared/util'; import { ActorViewComponent, ActorViewItem, @@ -53,7 +56,7 @@ import { export class StalkerActorRouteComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); - private readonly location = inject(Location); + private readonly backNavigation = inject(WorkspaceBackNavigationService); private readonly tmdbEnrichment = inject(TmdbEnrichmentService); private readonly titleMatch = inject(CatalogTitleMatchService); @@ -139,8 +142,11 @@ export class StalkerActorRouteComponent { ); } + /** History Back; the portal's default section when it opened the session. */ goBack(): void { - this.location.back(); + this.backNavigation.back(() => + workspacePortalCommands(this.route, 'stalker') + ); } /** The portal ':id' param lives on an ancestor route */ diff --git a/libs/portal/stalker/feature/src/lib/stalker-discover-route.component.ts b/libs/portal/stalker/feature/src/lib/stalker-discover-route.component.ts index b1af8a07f..e2d8ad1a7 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-discover-route.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-discover-route.component.ts @@ -1,4 +1,3 @@ -import { Location } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -17,10 +16,15 @@ import { pickTitleMatch, } from '@iptvnator/services'; import { CatalogTitleMatch } from '@iptvnator/shared/interfaces'; -import { createLatestRequestGuard } from '@iptvnator/portal/shared/util'; +import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; +import { + createLatestRequestGuard, + workspacePortalCommands, +} from '@iptvnator/portal/shared/util'; import { DiscoverViewComponent, TitleResultsScope, + discoverCatalogSection, discoverFacetKey, hasDiscoverFacet, parseDiscoverParams, @@ -58,7 +62,7 @@ interface DiscoverItem extends DiscoverTitle { export class StalkerDiscoverRouteComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); - private readonly location = inject(Location); + private readonly backNavigation = inject(WorkspaceBackNavigationService); private readonly tmdbEnrichment = inject(TmdbEnrichmentService); private readonly titleMatch = inject(CatalogTitleMatchService); @@ -143,8 +147,18 @@ export class StalkerDiscoverRouteComponent { ); } + /** + * History Back; the catalog section this page lists when it opened the + * session. + */ goBack(): void { - this.location.back(); + this.backNavigation.back(() => + workspacePortalCommands( + this.route, + 'stalker', + discoverCatalogSection(this.facets()) + ) + ); } /** The portal ':id' param lives on an ancestor route */ diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts index 0c2a71fd6..ef4c207a6 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts @@ -8,7 +8,6 @@ import { untracked, viewChild, } from '@angular/core'; -import { Location } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; @@ -36,12 +35,14 @@ import { SearchLayoutComponent } from '@iptvnator/portal/shared/ui'; import { StalkerInlineDetailComponent } from '../stalker-inline-detail/stalker-inline-detail.component'; import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; import { isWorkspaceLayoutRoute, PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYBACK_POSITIONS, PORTAL_PLAYER, queryParamSignal, + workspacePortalCommands, } from '@iptvnator/portal/shared/util'; import { createLogger } from '@iptvnator/portal/shared/util'; import { @@ -89,7 +90,7 @@ interface StalkerFilter { }) export class StalkerSearchComponent { private readonly activatedRoute = inject(ActivatedRoute); - private readonly location = inject(Location); + private readonly backNavigation = inject(WorkspaceBackNavigationService); private readonly dataService = inject(DataService); private readonly parentalLock = inject(ParentalLockService); private readonly playlistContext = inject(PlaylistContextFacade); @@ -418,9 +419,14 @@ export class StalkerSearchComponent { this.stalkerStore.setSelectedItem(null); } - /** Leave the search page (e.g. back to the actor page that opened it) */ + /** + * Leave the search page (e.g. back to the actor page that opened it); the + * portal's default section when the page opened the session. + */ goBack(): void { - this.location.back(); + this.backNavigation.back(() => + workspacePortalCommands(this.activatedRoute, 'stalker') + ); } onVodBack(): void { diff --git a/libs/portal/stalker/feature/src/lib/stalker-workspace-back.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-workspace-back.spec.ts new file mode 100644 index 000000000..00043ad11 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-workspace-back.spec.ts @@ -0,0 +1,121 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, provideRouter } from '@angular/router'; +import { of } from 'rxjs'; +import { + WorkspaceBackNavigationService, + WorkspaceBackParent, +} from '@iptvnator/portal/shared/data-access'; +import { + CatalogTitleMatchService, + TmdbEnrichmentService, +} from '@iptvnator/services'; +import { StalkerActorRouteComponent } from './stalker-actor-route.component'; +import { StalkerDiscoverRouteComponent } from './stalker-discover-route.component'; +import { StalkerSearchComponent } from './stalker-search/stalker-search.component'; + +/** + * Stalker's Discover, actor and search pages lead Back to a parent route + * when they opened the session. Their routes are Stalker's own, so the + * Xtream tests do not cover this wiring: each page must hand the service + * the parent under the portal `:id` of its ancestor route. + */ +describe('Stalker workspace Back parents', () => { + const back = jest.fn(); + let queryParams: Record; + + function activatedRoute(): ActivatedRoute { + const params = { id: 'stalker-1' }; + return { + params: of(params), + queryParams: of(queryParams), + snapshot: { queryParams, params, pathFromRoot: [] }, + pathFromRoot: [], + } as unknown as ActivatedRoute; + } + + function configure(): void { + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { provide: ActivatedRoute, useFactory: activatedRoute }, + { provide: WorkspaceBackNavigationService, useValue: { back } }, + { + provide: TmdbEnrichmentService, + useValue: { discoverTitles: jest.fn() }, + }, + { + provide: CatalogTitleMatchService, + useValue: { isAvailable: false, matchTitles: jest.fn() }, + }, + ], + }); + } + + /** The parent the page handed to `back()` on its last call. */ + async function lastParent(): Promise { + const resolveParent = back.mock.calls.at(-1)?.[0] as + | (() => WorkspaceBackParent | Promise) + | undefined; + if (!resolveParent) throw new Error('Expected a back() call'); + return resolveParent(); + } + + beforeEach(() => { + back.mockReset(); + queryParams = {}; + configure(); + }); + + it.each([ + ['movie', 'vod'], + ['tv', 'series'], + ])('leads a %s Discover page to the %s list', async (type, section) => { + queryParams = { type, year: '1990' }; + const page = TestBed.runInInjectionContext( + () => new StalkerDiscoverRouteComponent() + ); + + page.goBack(); + + expect(await lastParent()).toEqual([ + '/workspace', + 'stalker', + 'stalker-1', + section, + ]); + }); + + it('leads an actor page to the portal root, which redirects to its default section', async () => { + const page = TestBed.runInInjectionContext( + () => new StalkerActorRouteComponent() + ); + + page.goBack(); + + expect(await lastParent()).toEqual([ + '/workspace', + 'stalker', + 'stalker-1', + ]); + }); + + it('leads the search page to the portal root', async () => { + // The search page needs the whole portal store to construct; Back + // only uses its route and the back service. + const page = Object.assign( + Object.create(StalkerSearchComponent.prototype), + { + activatedRoute: activatedRoute(), + backNavigation: { back }, + } + ) as StalkerSearchComponent; + + page.goBack(); + + expect(await lastParent()).toEqual([ + '/workspace', + 'stalker', + 'stalker-1', + ]); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts b/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts index 2d448734c..f6cdc3b33 100644 --- a/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts @@ -1,4 +1,3 @@ -import { Location } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -21,7 +20,11 @@ import { pickTitleMatch, } from '@iptvnator/services'; import { CatalogTitleMatch } from '@iptvnator/shared/interfaces'; -import { createLatestRequestGuard } from '@iptvnator/portal/shared/util'; +import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; +import { + createLatestRequestGuard, + workspacePortalCommands, +} from '@iptvnator/portal/shared/util'; import { ActorViewComponent, ActorViewItem, @@ -59,7 +62,7 @@ import { export class XtreamActorRouteComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); - private readonly location = inject(Location); + private readonly backNavigation = inject(WorkspaceBackNavigationService); private readonly xtreamStore = inject(XtreamStore); private readonly tmdbEnrichment = inject(TmdbEnrichmentService); private readonly titleMatch = inject(CatalogTitleMatchService); @@ -161,8 +164,11 @@ export class XtreamActorRouteComponent { this.openPortalSearch(item.title); } + /** History Back; the portal's default section when it opened the session. */ goBack(): void { - this.location.back(); + this.backNavigation.back(() => + workspacePortalCommands(this.route, 'xtreams') + ); } private openPortalSearch(title: string): void { diff --git a/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.spec.ts b/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.spec.ts index 6fcced29c..f1a1c18b2 100644 --- a/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.spec.ts @@ -2,6 +2,10 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { ActivatedRoute, provideRouter } from '@angular/router'; import { of } from 'rxjs'; +import { + WorkspaceBackNavigationService, + WorkspaceBackParent, +} from '@iptvnator/portal/shared/data-access'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { CatalogTitleMatchService, @@ -25,6 +29,8 @@ describe('XtreamDiscoverRouteComponent — catalog readiness', () => { let resolveDiscover: (titles: DiscoverTitle[] | null) => void; let discoverTitles: jest.Mock; + const back = jest.fn(); + let facetParams: Record; /** Creates the component and flushes the effect that starts the load */ function createComponent(): XtreamDiscoverRouteComponent { @@ -49,6 +55,8 @@ describe('XtreamDiscoverRouteComponent — catalog readiness', () => { vodStreams.set([]); serialStreams.set([]); + back.mockReset(); + facetParams = { type: 'movie', year: '1990' }; discoverTitles = jest.fn().mockImplementation( () => new Promise((resolve) => { @@ -61,13 +69,18 @@ describe('XtreamDiscoverRouteComponent — catalog readiness', () => { provideRouter([]), { provide: ActivatedRoute, - useValue: { - queryParams: of({ type: 'movie', year: '1990' }), + useFactory: () => ({ + queryParams: of(facetParams), snapshot: { - queryParams: { type: 'movie', year: '1990' }, + queryParams: facetParams, + params: { id: 'pl-1' }, pathFromRoot: [], }, - }, + }), + }, + { + provide: WorkspaceBackNavigationService, + useValue: { back }, }, { provide: XtreamStore, @@ -205,4 +218,26 @@ describe('XtreamDiscoverRouteComponent — catalog readiness', () => { expect(component.isLoading()).toBe(true); }); + + it.each([ + ['movie', 'vod'], + ['tv', 'series'], + ])( + 'leads a %s Discover page that opened the session to the %s list', + async (type, section) => { + facetParams = { type, year: '1990' }; + const component = createComponent(); + + component.goBack(); + const resolveParent = back.mock.calls[0][0] as () => + WorkspaceBackParent | Promise; + + expect(await resolveParent()).toEqual([ + '/workspace', + 'xtreams', + 'pl-1', + section, + ]); + } + ); }); diff --git a/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.ts b/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.ts index 0399dd2bf..0e9b4d4c6 100644 --- a/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.ts @@ -1,4 +1,3 @@ -import { Location } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -18,10 +17,15 @@ import { pickTitleMatch, } from '@iptvnator/services'; import { CatalogTitleMatch } from '@iptvnator/shared/interfaces'; -import { createLatestRequestGuard } from '@iptvnator/portal/shared/util'; +import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; +import { + createLatestRequestGuard, + workspacePortalCommands, +} from '@iptvnator/portal/shared/util'; import { DiscoverViewComponent, TitleResultsScope, + discoverCatalogSection, discoverFacetKey, hasDiscoverFacet, parseDiscoverParams, @@ -65,7 +69,7 @@ interface DiscoverItem extends DiscoverTitle { export class XtreamDiscoverRouteComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); - private readonly location = inject(Location); + private readonly backNavigation = inject(WorkspaceBackNavigationService); private readonly xtreamStore = inject(XtreamStore); private readonly tmdbEnrichment = inject(TmdbEnrichmentService); private readonly titleMatch = inject(CatalogTitleMatchService); @@ -185,8 +189,18 @@ export class XtreamDiscoverRouteComponent { this.openPortalSearch(item.title); } + /** + * History Back; the catalog section this page lists when it opened the + * session. + */ goBack(): void { - this.location.back(); + this.backNavigation.back(() => + workspacePortalCommands( + this.route, + 'xtreams', + discoverCatalogSection(this.facets()) + ) + ); } private openPortalSearch(title: string): void { diff --git a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts index cfa8c7e44..737eee026 100644 --- a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts +++ b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts @@ -11,7 +11,6 @@ import { signal, viewChild, } from '@angular/core'; -import { Location } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatIconButton } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; @@ -27,10 +26,12 @@ import { TranslatePipe } from '@ngx-translate/core'; import { DatabaseService } from '@iptvnator/services'; import { ContentCardComponent } from '@iptvnator/portal/shared/ui'; import { SearchLayoutComponent } from '@iptvnator/portal/shared/ui'; +import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; import { buildXtreamNavigationTarget, isWorkspaceLayoutRoute, queryParamSignal, + workspacePortalCommands, } from '@iptvnator/portal/shared/util'; import { createLogger } from '@iptvnator/portal/shared/util'; import { SearchFilters } from '@iptvnator/portal/xtream/data-access'; @@ -108,7 +109,7 @@ export class SearchResultsComponent implements AfterViewInit { readonly router = inject(Router); readonly activatedRoute = inject(ActivatedRoute); readonly databaseService = inject(DatabaseService); - private readonly location = inject(Location); + private readonly backNavigation = inject(WorkspaceBackNavigationService); private readonly logger = createLogger('XtreamSearchResults'); readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute); readonly routeSearchTerm = queryParamSignal( @@ -538,8 +539,11 @@ export class SearchResultsComponent implements AfterViewInit { this.dialogRef?.close(); } + /** History Back; the portal's default section when it opened the session. */ goBack(): void { - this.location.back(); + this.backNavigation.back(() => + workspacePortalCommands(this.activatedRoute, 'xtreams') + ); } toggleGroupByPlaylist(value: boolean) { diff --git a/libs/ui/shared-portals/src/lib/discover-view/discover-params.spec.ts b/libs/ui/shared-portals/src/lib/discover-view/discover-params.spec.ts index a47693c57..ace2409a3 100644 --- a/libs/ui/shared-portals/src/lib/discover-view/discover-params.spec.ts +++ b/libs/ui/shared-portals/src/lib/discover-view/discover-params.spec.ts @@ -1,4 +1,5 @@ import { + discoverCatalogSection, discoverFacetKey, hasDiscoverFacet, parseDiscoverParams, @@ -94,6 +95,17 @@ describe('hasDiscoverFacet', () => { }); }); +describe('discoverCatalogSection', () => { + it('maps movies to the VOD section and TV to series', () => { + expect( + discoverCatalogSection(parseDiscoverParams({ type: 'movie' })) + ).toBe('vod'); + expect( + discoverCatalogSection(parseDiscoverParams({ type: 'tv' })) + ).toBe('series'); + }); +}); + describe('discoverFacetKey', () => { it('produces distinct keys for distinct facet sets', () => { const keys = new Set( diff --git a/libs/ui/shared-portals/src/lib/discover-view/discover-params.ts b/libs/ui/shared-portals/src/lib/discover-view/discover-params.ts index 47ea52486..841b959c8 100644 --- a/libs/ui/shared-portals/src/lib/discover-view/discover-params.ts +++ b/libs/ui/shared-portals/src/lib/discover-view/discover-params.ts @@ -67,6 +67,16 @@ export function hasDiscoverFacet(facets: DiscoverRouteParams): boolean { ); } +/** + * The catalog section whose titles a Discover page lists. Its Back leads + * there when the page opened the session. + */ +export function discoverCatalogSection( + facets: DiscoverRouteParams +): 'vod' | 'series' { + return facets.type === 'tv' ? 'series' : 'vod'; +} + /** * Stable identity of one facet set. Used as the staleness-guard token by * the route containers (facets change via query params on the same route diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.spec.ts index 5f57e5cee..a92669eb5 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.spec.ts @@ -1,18 +1,43 @@ import { Location } from '@angular/common'; import { TestBed } from '@angular/core/testing'; -import { provideRouter } from '@angular/router'; +import { provideRouter, Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { of } from 'rxjs'; -import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; +import { + WORKSPACE_HISTORY_NAVIGATION, + WorkspaceBackNavigationService, + WorkspaceHistoryNavigation, +} from '@iptvnator/portal/shared/data-access'; +import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util'; import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings-context'; import { WorkspaceSettingsContextPanelComponent } from './workspace-settings-context-panel.component'; +/** Navigation API state of a session that opened on the settings page. */ +const firstPageHistory = { + currentEntry: { index: 0 }, + entries: () => [{ index: 0, sameDocument: true }], + addEventListener: jest.fn(), + removeEventListener: jest.fn(), +} as unknown as WorkspaceHistoryNavigation; + describe('WorkspaceSettingsContextPanelComponent', () => { - function setup() { + const resolveDashboardPath = jest.fn(); + + // Without a Navigation API (jsdom's default) history is unknown, so + // Back stays the browser's. + function setup(history: WorkspaceHistoryNavigation | null = null) { + resolveDashboardPath + .mockReset() + .mockResolvedValue('/workspace/dashboard'); TestBed.configureTestingModule({ imports: [WorkspaceSettingsContextPanelComponent], providers: [ provideRouter([]), + { provide: WORKSPACE_HISTORY_NAVIGATION, useValue: history }, + { + provide: WorkspaceStartupPreferencesService, + useValue: { resolveDashboardPath }, + }, { provide: TranslateService, useValue: { @@ -66,11 +91,33 @@ describe('WorkspaceSettingsContextPanelComponent', () => { backNavigation.goBack(); expect(back).toHaveBeenCalledTimes(1); + expect(resolveDashboardPath).not.toHaveBeenCalled(); fixture.destroy(); expect(backNavigation.target()).toBeNull(); }); + it('leads to the first workspace view when settings opened the session', async () => { + setup(firstPageHistory); + const back = jest + .spyOn(TestBed.inject(Location), 'back') + .mockImplementation(() => undefined); + const navigateByUrl = jest + .spyOn(TestBed.inject(Router), 'navigateByUrl') + .mockResolvedValue(true); + resolveDashboardPath.mockResolvedValue('/workspace/sources'); + + TestBed.inject(WorkspaceBackNavigationService).goBack(); + await Promise.resolve(); + await Promise.resolve(); + + // Location.back() would do nothing in Electron or leave the PWA. + expect(back).not.toHaveBeenCalled(); + expect(navigateByUrl).toHaveBeenCalledWith('/workspace/sources', { + replaceUrl: true, + }); + }); + it('shows no counts while settings search is idle', () => { const { link } = setup(); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts index cdfb1669c..d7cacdedd 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts @@ -1,10 +1,15 @@ -import { Location } from '@angular/common'; import { Component, inject, ChangeDetectionStrategy } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; import { RouterLink, RouterLinkActive } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; -import { registerWorkspaceBack } from '@iptvnator/portal/shared/data-access'; -import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util'; +import { + registerWorkspaceBack, + WorkspaceBackNavigationService, +} from '@iptvnator/portal/shared/data-access'; +import { + WorkspaceShellContextDrawerService, + WorkspaceStartupPreferencesService, +} from '@iptvnator/workspace/shell/util'; import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings-context'; @Component({ @@ -56,7 +61,10 @@ import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings }) export class WorkspaceSettingsContextPanelComponent { readonly ctx = inject(SettingsContextService); - private readonly location = inject(Location); + private readonly backNavigation = inject(WorkspaceBackNavigationService); + private readonly startupPreferences = inject( + WorkspaceStartupPreferencesService + ); // Root-provided; optional keeps standalone unit tests light. Section // links are real navigations now, so the phone drawer's NavigationEnd // auto-close fires too — the explicit close just makes the drawer react @@ -72,9 +80,14 @@ export class WorkspaceSettingsContextPanelComponent { // The panel exists exactly while the settings route shows, so it // offers the page's Back in the header. On a phone the toggle for // this drawer stays beside it: the drawer holds the sections. + // Opened as the session's first page, Back leads to the first + // workspace view (the dashboard, or sources when it is hidden). registerWorkspaceBack({ phoneDrawerToggle: 'beside', - run: () => this.location.back(), + run: () => + this.backNavigation.back(() => + this.startupPreferences.resolveDashboardPath() + ), }); }