mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
23 files changed
+940
-55
No files matched your search
@@ -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 {
|
||||
|
||||
+39
-4
@@ -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
|
||||
|
||||
+50
-3
@@ -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();
|
||||
|
||||
|
||||
+18
-5
@@ -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()
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user