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

@@ -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()
),
});
}