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
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:
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+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