fix(workspace): lead header Back to a parent route when the page opened the session (#1830)

* fix(workspace): lead header Back to a parent route when the page opened the session

Settings, Discover, actor and in-portal search registered a header Back
that only ran Location.back(). As the first entry of the session (deep
link, reload, restored view) that did nothing in Electron and left the
app in a browser.

WorkspaceBackNavigationService.back(resolveParent) keeps Location.back()
while the previous entry is an in-app one, and while that is unknown
because the Navigation API is missing. Otherwise it opens the page's
parent with replaceUrl, so history Back cannot return to the page:

- Settings: the first workspace view (resolveDashboardPath()).
- Discover: the catalog section it lists (vod for movies, series for TV).
- Actor and search: the portal root, which redirects to its default
  section within the same navigation.

The web E2E opens these pages in a fresh tab: a page.goto in the same
tab leaves the previous document behind, often at the parent's URL, so
history Back passed without the fix. Electron covers settings after a
window reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workspace): lead first-entry Back to the parent without the Navigation API

Review follow-ups (Greptile):

- Without the Navigation API (older Safari and Firefox) back() always
  called Location.back(), so a page that opened the session still left
  the app. The service now tracks the router's in-app history depth there
  (trackRouterHistoryDepth): first navigation 0, push +1, replacement
  keeps it, a traversal restores the depth recorded for its entry. Depth
  0 opens the parent; an unknown depth (an entry from before a reload)
  keeps Location.back().
- Stalker's Discover (movie/tv section), actor and search pages now have
  tests that they hand the service the parent under the portal :id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workspace): adopt the router navigation the Back depth tracker missed

Review follow-up (Codex, Greptile): the lazy workspace shell creates the
Back service after the first NavigationStart, so the tracker saw only its
NavigationEnd, left the depth unknown and counted the next push as the
first entry. It now adopts the router's current or last successful
navigation when it starts: a first navigation is depth 0, a later one
leaves the depth unknown (browser history Back), and a late start of the
adopted navigation is not counted again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-07 00:24:00 +02:00
1 parent 93c5f1a051
commit acb8cb0318
23 files changed
+940 -55

No files matched your search

@@ -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.
@@ -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);
}
});
});
+18
View File
@@ -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');
+61 -15
View File
@@ -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<void> {
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<Page> {
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);
});
}
+4 -1
View File
@@ -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
+28 -3
View File
@@ -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
@@ -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<DestroyRef, 'onDestroy'>
): () => number | null {
let depth: number | null = null;
let pending: number | null = null;
let started = false;
const depthByNavigationId = new Map<number, number>();
// 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;
}
@@ -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<RouterEvent>();
/** The parts of a router Navigation the depth tracker reads. */
type NavigationLike = {
id?: number;
previousNavigation?: unknown;
extras: { replaceUrl?: boolean };
};
const currentNavigation = signal<NavigationLike | null>(null);
const lastSuccessfulNavigation = signal<NavigationLike | null>(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<RouterEvent>();
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<string | null>(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();
});
});
});
});
@@ -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<readonly WorkspaceBackTarget[]>([]);
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<WorkspaceBackParent>
): 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<WorkspaceBackParent>
): Promise<void> {
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 });
}
}
}
@@ -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({});
@@ -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
@@ -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 */
@@ -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 */
@@ -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 {
@@ -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<string, string>;
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<WorkspaceBackParent> {
const resolveParent = back.mock.calls.at(-1)?.[0] as
| (() => WorkspaceBackParent | Promise<WorkspaceBackParent>)
| 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',
]);
});
});
@@ -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 {
@@ -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<string, string>;
/** 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<DiscoverTitle[] | null>((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<WorkspaceBackParent>;
expect(await resolveParent()).toEqual([
'/workspace',
'xtreams',
'pl-1',
section,
]);
}
);
});
@@ -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 {
@@ -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) {
@@ -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(
@@ -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
@@ -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();
@@ -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()
),
});
}