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>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-04 21:10:45 +02:00
1 parent 2738bc28a1
commit 4b8c1a9545
21 files changed
+529 -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
+21 -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,24 @@ 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, and also when that is unknown because the API is missing: there a
page reached in the app must not jump to its parent. 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
@@ -1,6 +1,7 @@
import { Location } from '@angular/common';
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util';
import {
WORKSPACE_HISTORY_NAVIGATION,
@@ -42,15 +43,20 @@ class FakeHistory extends EventTarget {
describe('WorkspaceBackNavigationService', () => {
const back = jest.fn();
const navigate = jest.fn().mockResolvedValue(true);
const navigateByUrl = jest.fn().mockResolvedValue(true);
function createService(
history: FakeHistory | null = null
): WorkspaceBackNavigationService {
back.mockReset();
navigate.mockClear();
navigateByUrl.mockClear();
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
{ provide: Location, useValue: { back } },
{ provide: Router, useValue: { navigate, navigateByUrl } },
{
provide: WORKSPACE_HISTORY_NAVIGATION,
useValue: history as unknown as WorkspaceHistoryNavigation,
@@ -202,4 +208,80 @@ 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();
});
it('keeps browser history without the Navigation API', () => {
// The history is unknown there, so a reached page must not
// jump to its parent.
const service = createService(null);
const parent = jest.fn(() => '/workspace/dashboard');
service.back(parent);
expect(back).toHaveBeenCalledTimes(1);
expect(parent).not.toHaveBeenCalled();
});
});
});
@@ -7,6 +7,7 @@ import {
InjectionToken,
signal,
} from '@angular/core';
import { Router } from '@angular/router';
import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util';
/** The parts of the browser's Navigation API the history fallback reads. */
@@ -28,6 +29,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,6 +56,8 @@ 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);
@@ -73,7 +82,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 +95,24 @@ export class WorkspaceBackNavigationService {
);
}
/**
* Back for a page with a parent route. Browser history while the previous
* entry is an in-app one, and while that is unknown (no Navigation API).
* 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.
*/
back(
resolveParent: () => WorkspaceBackParent | Promise<WorkspaceBackParent>
): void {
if (!this.history || this.canGoBackInApp()) {
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 +136,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 {
@@ -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()
),
});
}