Proyecto App Linux
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
export default {
|
||||
displayName: 'workspace-shell-util',
|
||||
preset: '../../../../jest.preset.js',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
coverageDirectory: '../../../../coverage/libs/workspace/shell/util',
|
||||
transform: {
|
||||
'^.+\\.(ts|mjs|js|html)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment',
|
||||
],
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "workspace-shell-util",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/workspace/shell/util/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:workspace", "domain:workspace", "type:util"],
|
||||
"targets": {
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './lib/settings-context.service';
|
||||
export * from './lib/navigation/workspace-shell-route.utils';
|
||||
export * from './lib/navigation/workspace-search-capability';
|
||||
export * from './lib/workspace-shell-actions';
|
||||
export * from './lib/workspace-startup-preferences.service';
|
||||
@@ -0,0 +1,25 @@
|
||||
import { PortalRailSection } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
WorkspacePortalContext,
|
||||
WorkspaceShellSearchMode,
|
||||
} from './workspace-shell-route.utils';
|
||||
|
||||
export type WorkspaceSearchBehavior =
|
||||
| 'disabled'
|
||||
| 'local-filter'
|
||||
| 'remote-search'
|
||||
| 'advanced-only'
|
||||
| 'degraded-loaded-only';
|
||||
|
||||
export interface WorkspaceSearchCapability {
|
||||
enabled: boolean;
|
||||
behavior: WorkspaceSearchBehavior;
|
||||
context: WorkspacePortalContext | null;
|
||||
section: PortalRailSection | null;
|
||||
searchMode: WorkspaceShellSearchMode;
|
||||
placeholderKey: string;
|
||||
scopeLabel: string;
|
||||
statusLabel: string;
|
||||
minLength: number;
|
||||
advancedRouteTarget: readonly string[] | null;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
parseWorkspaceShellRoute,
|
||||
usesWorkspaceRouteQuerySearch,
|
||||
} from './workspace-shell-route.utils';
|
||||
|
||||
describe('workspace-shell-route.utils', () => {
|
||||
it('parses top-level workspace pages', () => {
|
||||
expect(parseWorkspaceShellRoute('/workspace')).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'dashboard',
|
||||
context: null,
|
||||
contextPanel: 'none',
|
||||
searchMode: 'advanced-only',
|
||||
usesQuerySearch: false,
|
||||
})
|
||||
);
|
||||
expect(parseWorkspaceShellRoute('/workspace/sources?q=matrix')).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'sources',
|
||||
context: null,
|
||||
contextPanel: 'sources',
|
||||
searchMode: 'local-filter',
|
||||
usesQuerySearch: true,
|
||||
})
|
||||
);
|
||||
expect(parseWorkspaceShellRoute('/workspace/settings')).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'settings',
|
||||
context: null,
|
||||
contextPanel: 'settings',
|
||||
})
|
||||
);
|
||||
expect(parseWorkspaceShellRoute('/workspace/global-favorites')).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'global-favorites',
|
||||
})
|
||||
);
|
||||
expect(parseWorkspaceShellRoute('/workspace/global-recent')).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'global-recent',
|
||||
searchMode: 'local-filter',
|
||||
usesQuerySearch: true,
|
||||
})
|
||||
);
|
||||
expect(parseWorkspaceShellRoute('/workspace/downloads')).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'downloads',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('parses Xtream portal routes and detects context-panel/query-search state', () => {
|
||||
expect(
|
||||
parseWorkspaceShellRoute('/workspace/xtreams/pl-1/vod/42')
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'portal',
|
||||
context: {
|
||||
provider: 'xtreams',
|
||||
playlistId: 'pl-1',
|
||||
},
|
||||
section: 'vod',
|
||||
contextPanel: 'category',
|
||||
searchMode: 'local-filter',
|
||||
usesQuerySearch: true,
|
||||
isPortalFavoritesAllScope: false,
|
||||
})
|
||||
);
|
||||
|
||||
expect(
|
||||
parseWorkspaceShellRoute(
|
||||
'/workspace/xtreams/pl-1/favorites?scope=all&q=neo'
|
||||
)
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
section: 'favorites',
|
||||
contextPanel: 'none',
|
||||
searchMode: 'local-filter',
|
||||
usesQuerySearch: true,
|
||||
isPortalFavoritesAllScope: true,
|
||||
})
|
||||
);
|
||||
|
||||
expect(
|
||||
parseWorkspaceShellRoute('/workspace/xtreams/pl-1/downloads')
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
section: 'downloads',
|
||||
contextPanel: 'collection',
|
||||
searchMode: 'local-filter',
|
||||
usesQuerySearch: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('parses Stalker portal routes and detects context-panel/query-search state', () => {
|
||||
expect(
|
||||
parseWorkspaceShellRoute('/workspace/stalker/pl-2/itv')
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'portal',
|
||||
context: {
|
||||
provider: 'stalker',
|
||||
playlistId: 'pl-2',
|
||||
},
|
||||
section: 'itv',
|
||||
contextPanel: 'category',
|
||||
searchMode: 'remote-search',
|
||||
usesQuerySearch: true,
|
||||
})
|
||||
);
|
||||
|
||||
expect(
|
||||
parseWorkspaceShellRoute('/workspace/stalker/pl-2/recent?q=test')
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
section: 'recent',
|
||||
contextPanel: 'none',
|
||||
searchMode: 'local-filter',
|
||||
usesQuerySearch: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('parses M3U portal routes without provider-specific workspace state', () => {
|
||||
expect(
|
||||
parseWorkspaceShellRoute('/workspace/playlists/pl-3/favorites')
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'portal',
|
||||
context: {
|
||||
provider: 'playlists',
|
||||
playlistId: 'pl-3',
|
||||
},
|
||||
section: 'favorites',
|
||||
contextPanel: 'none',
|
||||
searchMode: 'local-filter',
|
||||
usesQuerySearch: true,
|
||||
isPortalFavoritesAllScope: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns unknown for non-workspace URLs', () => {
|
||||
expect(parseWorkspaceShellRoute('/xtreams/pl-1/search')).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'unknown',
|
||||
context: null,
|
||||
section: null,
|
||||
searchMode: 'none',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes query-search eligibility as a pure helper', () => {
|
||||
expect(
|
||||
usesWorkspaceRouteQuerySearch(
|
||||
{ provider: 'xtreams', playlistId: '1' },
|
||||
'favorites'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
usesWorkspaceRouteQuerySearch(
|
||||
{ provider: 'xtreams', playlistId: '1' },
|
||||
'vod'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
usesWorkspaceRouteQuerySearch(
|
||||
{ provider: 'stalker', playlistId: '1' },
|
||||
'recent'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
usesWorkspaceRouteQuerySearch(
|
||||
{ provider: 'playlists', playlistId: '1' },
|
||||
'favorites'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
import {
|
||||
PortalProvider,
|
||||
PortalRailSection,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
|
||||
export type WorkspaceShellPageKind =
|
||||
| 'dashboard'
|
||||
| 'downloads'
|
||||
| 'global-favorites'
|
||||
| 'global-recent'
|
||||
| 'portal'
|
||||
| 'settings'
|
||||
| 'sources'
|
||||
| 'unknown';
|
||||
|
||||
export type WorkspaceShellContextPanel =
|
||||
| 'none'
|
||||
| 'sources'
|
||||
| 'settings'
|
||||
| 'category'
|
||||
| 'collection';
|
||||
|
||||
export interface WorkspacePortalContext {
|
||||
provider: PortalProvider;
|
||||
playlistId: string;
|
||||
}
|
||||
|
||||
export type WorkspaceShellSearchMode =
|
||||
| 'none'
|
||||
| 'local-filter'
|
||||
| 'remote-search'
|
||||
| 'advanced-only';
|
||||
|
||||
export interface WorkspaceShellRoute {
|
||||
kind: WorkspaceShellPageKind;
|
||||
context: WorkspacePortalContext | null;
|
||||
section: PortalRailSection | null;
|
||||
isPortalFavoritesAllScope: boolean;
|
||||
searchMode: WorkspaceShellSearchMode;
|
||||
usesQuerySearch: boolean;
|
||||
contextPanel: WorkspaceShellContextPanel;
|
||||
}
|
||||
|
||||
const PORTAL_PROVIDERS: readonly PortalProvider[] = [
|
||||
'xtreams',
|
||||
'stalker',
|
||||
'playlists',
|
||||
];
|
||||
|
||||
const PORTAL_SECTIONS: readonly PortalRailSection[] = [
|
||||
'all',
|
||||
'downloads',
|
||||
'favorites',
|
||||
'groups',
|
||||
'itv',
|
||||
'library',
|
||||
'live',
|
||||
'recent',
|
||||
'recently-added',
|
||||
'search',
|
||||
'series',
|
||||
'vod',
|
||||
];
|
||||
|
||||
const XTREAM_ROUTE_QUERY_SEARCH_SECTIONS = new Set<PortalRailSection>([
|
||||
'favorites',
|
||||
'downloads',
|
||||
'recent',
|
||||
'recently-added',
|
||||
]);
|
||||
|
||||
const STALKER_ROUTE_QUERY_SEARCH_SECTIONS = new Set<PortalRailSection>([
|
||||
'favorites',
|
||||
'recent',
|
||||
]);
|
||||
|
||||
const XTREAM_CATEGORY_CONTEXT_SECTIONS = new Set<PortalRailSection>([
|
||||
'vod',
|
||||
'series',
|
||||
'live',
|
||||
]);
|
||||
|
||||
const STALKER_CATEGORY_CONTEXT_SECTIONS = new Set<PortalRailSection>([
|
||||
'vod',
|
||||
'series',
|
||||
'itv',
|
||||
]);
|
||||
|
||||
function normalizePath(url: string): {
|
||||
segments: string[];
|
||||
queryParams: URLSearchParams;
|
||||
} {
|
||||
const [path, query = ''] = url.split('?');
|
||||
return {
|
||||
segments: path.split('/').filter(Boolean),
|
||||
queryParams: new URLSearchParams(query),
|
||||
};
|
||||
}
|
||||
|
||||
function asPortalProvider(value?: string): PortalProvider | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PORTAL_PROVIDERS.includes(value as PortalProvider)
|
||||
? (value as PortalProvider)
|
||||
: null;
|
||||
}
|
||||
|
||||
function asPortalRailSection(value?: string): PortalRailSection | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PORTAL_SECTIONS.includes(value as PortalRailSection)
|
||||
? (value as PortalRailSection)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function usesWorkspaceRouteQuerySearch(
|
||||
context: WorkspacePortalContext | null,
|
||||
section: PortalRailSection | null
|
||||
): boolean {
|
||||
return resolvePortalSearchMode(context, section) !== 'none';
|
||||
}
|
||||
|
||||
function resolvePortalSearchMode(
|
||||
context: WorkspacePortalContext | null,
|
||||
section: PortalRailSection | null
|
||||
): Exclude<WorkspaceShellSearchMode, 'advanced-only'> {
|
||||
if (!context || !section) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
if (context.provider === 'playlists') {
|
||||
return section === 'all' ||
|
||||
section === 'groups' ||
|
||||
section === 'favorites' ||
|
||||
section === 'recent'
|
||||
? 'local-filter'
|
||||
: 'none';
|
||||
}
|
||||
|
||||
if (context.provider === 'xtreams') {
|
||||
if (section === 'search') {
|
||||
return 'remote-search';
|
||||
}
|
||||
|
||||
return section === 'vod' ||
|
||||
section === 'series' ||
|
||||
section === 'live' ||
|
||||
XTREAM_ROUTE_QUERY_SEARCH_SECTIONS.has(section)
|
||||
? 'local-filter'
|
||||
: 'none';
|
||||
}
|
||||
|
||||
if (context.provider === 'stalker') {
|
||||
if (
|
||||
section === 'vod' ||
|
||||
section === 'series' ||
|
||||
section === 'itv' ||
|
||||
section === 'search'
|
||||
) {
|
||||
return 'remote-search';
|
||||
}
|
||||
|
||||
return STALKER_ROUTE_QUERY_SEARCH_SECTIONS.has(section) ||
|
||||
section === 'downloads'
|
||||
? 'local-filter'
|
||||
: 'none';
|
||||
}
|
||||
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function resolveRouteSearchMode(
|
||||
kind: WorkspaceShellPageKind,
|
||||
context: WorkspacePortalContext | null,
|
||||
section: PortalRailSection | null
|
||||
): WorkspaceShellSearchMode {
|
||||
if (kind === 'dashboard') {
|
||||
return 'advanced-only';
|
||||
}
|
||||
|
||||
if (
|
||||
kind === 'sources' ||
|
||||
kind === 'downloads' ||
|
||||
kind === 'global-favorites' ||
|
||||
kind === 'global-recent'
|
||||
) {
|
||||
return 'local-filter';
|
||||
}
|
||||
|
||||
if (kind === 'portal') {
|
||||
return resolvePortalSearchMode(context, section);
|
||||
}
|
||||
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function resolveContextPanel(
|
||||
kind: WorkspaceShellPageKind,
|
||||
context: WorkspacePortalContext | null,
|
||||
section: PortalRailSection | null
|
||||
): WorkspaceShellContextPanel {
|
||||
if (kind === 'sources') {
|
||||
return 'sources';
|
||||
}
|
||||
|
||||
if (kind === 'settings') {
|
||||
return 'settings';
|
||||
}
|
||||
|
||||
if (!context || !section) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
if (
|
||||
(context.provider === 'xtreams' &&
|
||||
XTREAM_CATEGORY_CONTEXT_SECTIONS.has(section)) ||
|
||||
(context.provider === 'stalker' &&
|
||||
STALKER_CATEGORY_CONTEXT_SECTIONS.has(section))
|
||||
) {
|
||||
return 'category';
|
||||
}
|
||||
|
||||
if (
|
||||
(context.provider === 'xtreams' || context.provider === 'stalker') &&
|
||||
section === 'downloads'
|
||||
) {
|
||||
return 'collection';
|
||||
}
|
||||
|
||||
return 'none';
|
||||
}
|
||||
|
||||
export function parseWorkspaceShellRoute(url: string): WorkspaceShellRoute {
|
||||
const { segments, queryParams } = normalizePath(url);
|
||||
|
||||
if (segments[0] !== 'workspace') {
|
||||
return {
|
||||
kind: 'unknown',
|
||||
context: null,
|
||||
section: null,
|
||||
isPortalFavoritesAllScope: false,
|
||||
searchMode: 'none',
|
||||
usesQuerySearch: false,
|
||||
contextPanel: 'none',
|
||||
};
|
||||
}
|
||||
|
||||
const sectionSegment = segments[3];
|
||||
const provider = asPortalProvider(segments[1]);
|
||||
|
||||
if (provider && segments[2]) {
|
||||
const context: WorkspacePortalContext = {
|
||||
provider,
|
||||
playlistId: segments[2],
|
||||
};
|
||||
const section = asPortalRailSection(sectionSegment);
|
||||
|
||||
return {
|
||||
kind: 'portal',
|
||||
context,
|
||||
section,
|
||||
isPortalFavoritesAllScope:
|
||||
section === 'favorites' &&
|
||||
(provider === 'xtreams' || provider === 'stalker') &&
|
||||
queryParams.get('scope') === 'all',
|
||||
searchMode: resolveRouteSearchMode('portal', context, section),
|
||||
usesQuerySearch: usesWorkspaceRouteQuerySearch(context, section),
|
||||
contextPanel: resolveContextPanel('portal', context, section),
|
||||
};
|
||||
}
|
||||
|
||||
const page = segments[1];
|
||||
const kind: WorkspaceShellPageKind =
|
||||
!page || page === 'dashboard'
|
||||
? 'dashboard'
|
||||
: page === 'sources'
|
||||
? 'sources'
|
||||
: page === 'settings'
|
||||
? 'settings'
|
||||
: page === 'global-favorites'
|
||||
? 'global-favorites'
|
||||
: page === 'global-recent'
|
||||
? 'global-recent'
|
||||
: page === 'downloads'
|
||||
? 'downloads'
|
||||
: 'unknown';
|
||||
const searchMode = resolveRouteSearchMode(kind, null, null);
|
||||
|
||||
return {
|
||||
kind,
|
||||
context: null,
|
||||
section: null,
|
||||
isPortalFavoritesAllScope: false,
|
||||
searchMode,
|
||||
usesQuerySearch:
|
||||
searchMode === 'local-filter' || searchMode === 'remote-search',
|
||||
contextPanel: resolveContextPanel(kind, null, null),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export interface SettingsNavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SettingsContextService {
|
||||
readonly sections = signal<SettingsNavItem[]>([]);
|
||||
readonly activeSection = signal<string>('general');
|
||||
readonly pendingScrollTarget = signal<string | null>(null);
|
||||
|
||||
setSections(items: SettingsNavItem[]): void {
|
||||
this.sections.set(items);
|
||||
}
|
||||
|
||||
setActiveSection(id: string): void {
|
||||
this.activeSection.set(id);
|
||||
}
|
||||
|
||||
navigateToSection(id: string): void {
|
||||
this.activeSection.set(id);
|
||||
this.pendingScrollTarget.set(id);
|
||||
}
|
||||
|
||||
clearPendingScrollTarget(): void {
|
||||
this.pendingScrollTarget.set(null);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.sections.set([]);
|
||||
this.activeSection.set('general');
|
||||
this.pendingScrollTarget.set(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
import type { XtreamAccountInfoDialogData } from 'shared-interfaces';
|
||||
|
||||
export type WorkspacePlaylistType =
|
||||
| 'xtream'
|
||||
| 'url'
|
||||
| 'text'
|
||||
| 'file'
|
||||
| 'stalker';
|
||||
export type PlaylistCategory = 'm3u' | 'xtream' | 'stalker';
|
||||
export type M3uSubType = 'url' | 'file' | 'text';
|
||||
|
||||
export type WorkspaceAccountInfoData = XtreamAccountInfoDialogData;
|
||||
|
||||
export interface WorkspaceShellActions {
|
||||
openAddPlaylistDialog(type?: WorkspacePlaylistType): void;
|
||||
openGlobalSearch(initialQuery?: string): void;
|
||||
openGlobalRecent(): void;
|
||||
openAccountInfo(data: WorkspaceAccountInfoData): void;
|
||||
}
|
||||
|
||||
export const WORKSPACE_SHELL_ACTIONS =
|
||||
new InjectionToken<WorkspaceShellActions>('WORKSPACE_SHELL_ACTIONS');
|
||||
@@ -0,0 +1,104 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { PlaylistsService, SettingsStore } from 'services';
|
||||
import { StartupBehavior } from 'shared-interfaces';
|
||||
import { WorkspaceStartupPreferencesService } from './workspace-startup-preferences.service';
|
||||
|
||||
describe('WorkspaceStartupPreferencesService', () => {
|
||||
let service: WorkspaceStartupPreferencesService;
|
||||
let playlistsService: { getAllPlaylists: jest.Mock };
|
||||
let settingsStore: {
|
||||
loadSettings: jest.Mock;
|
||||
showDashboard: ReturnType<typeof signal<boolean>>;
|
||||
startupBehavior: ReturnType<typeof signal<StartupBehavior>>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
|
||||
playlistsService = {
|
||||
getAllPlaylists: jest.fn().mockReturnValue(
|
||||
of([{ _id: 'playlist-1' }])
|
||||
),
|
||||
};
|
||||
settingsStore = {
|
||||
loadSettings: jest.fn().mockResolvedValue(undefined),
|
||||
showDashboard: signal(true),
|
||||
startupBehavior: signal(StartupBehavior.FirstView),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
WorkspaceStartupPreferencesService,
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: playlistsService,
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: settingsStore,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(WorkspaceStartupPreferencesService);
|
||||
});
|
||||
|
||||
it('resolves the first view to dashboard when dashboard is enabled', async () => {
|
||||
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
|
||||
'/workspace/dashboard'
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the first view to sources when dashboard is hidden', async () => {
|
||||
settingsStore.showDashboard.set(false);
|
||||
|
||||
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
|
||||
'/workspace/sources'
|
||||
);
|
||||
});
|
||||
|
||||
it('restores the last route when restore-last-view is enabled', async () => {
|
||||
settingsStore.startupBehavior.set(StartupBehavior.RestoreLastView);
|
||||
service.persistLastRestorablePath('/workspace/global-recent?q=matrix');
|
||||
|
||||
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
|
||||
'/workspace/global-recent'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to sources when the stored dashboard route is hidden', async () => {
|
||||
settingsStore.showDashboard.set(false);
|
||||
settingsStore.startupBehavior.set(StartupBehavior.RestoreLastView);
|
||||
service.persistLastRestorablePath('/workspace/dashboard');
|
||||
|
||||
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
|
||||
'/workspace/sources'
|
||||
);
|
||||
});
|
||||
|
||||
it('canonicalizes detail routes to their section root', () => {
|
||||
expect(
|
||||
service.getRestorablePath(
|
||||
'/workspace/xtreams/playlist-1/vod/123/456?q=matrix'
|
||||
)
|
||||
).toBe('/workspace/xtreams/playlist-1/vod');
|
||||
});
|
||||
|
||||
it('ignores non-restorable routes', () => {
|
||||
expect(service.getRestorablePath('/workspace/settings')).toBeNull();
|
||||
expect(service.getRestorablePath('/workspace')).toBeNull();
|
||||
expect(service.getRestorablePath('/unknown')).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to the first available view when the stored playlist no longer exists', async () => {
|
||||
settingsStore.startupBehavior.set(StartupBehavior.RestoreLastView);
|
||||
playlistsService.getAllPlaylists.mockReturnValue(of([]));
|
||||
service.persistLastRestorablePath('/workspace/xtreams/missing/live');
|
||||
|
||||
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
|
||||
'/workspace/dashboard'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { PlaylistsService, SettingsStore } from 'services';
|
||||
import {
|
||||
StartupBehavior,
|
||||
} from 'shared-interfaces';
|
||||
import { parseWorkspaceShellRoute } from './navigation/workspace-shell-route.utils';
|
||||
|
||||
const LAST_RESTORABLE_ROUTE_STORAGE_KEY = 'workspace-last-restorable-route-v1';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class WorkspaceStartupPreferencesService {
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
|
||||
async resolveInitialWorkspacePath(): Promise<string> {
|
||||
await this.settingsStore.loadSettings();
|
||||
|
||||
const showDashboard = this.showDashboard();
|
||||
const firstViewPath = this.getFirstAvailableWorkspacePath(showDashboard);
|
||||
|
||||
if (this.startupBehavior() !== StartupBehavior.RestoreLastView) {
|
||||
return firstViewPath;
|
||||
}
|
||||
|
||||
return (
|
||||
(await this.getValidatedLastRestorablePath(showDashboard)) ??
|
||||
firstViewPath
|
||||
);
|
||||
}
|
||||
|
||||
async resolveDashboardPath(): Promise<string> {
|
||||
await this.settingsStore.loadSettings();
|
||||
|
||||
return this.showDashboard()
|
||||
? '/workspace/dashboard'
|
||||
: '/workspace/sources';
|
||||
}
|
||||
|
||||
getFirstAvailableWorkspacePath(showDashboard = this.showDashboard()): string {
|
||||
return showDashboard ? '/workspace/dashboard' : '/workspace/sources';
|
||||
}
|
||||
|
||||
showDashboard(): boolean {
|
||||
return this.settingsStore.showDashboard?.() ?? true;
|
||||
}
|
||||
|
||||
startupBehavior(): StartupBehavior {
|
||||
return (
|
||||
this.settingsStore.startupBehavior?.() ??
|
||||
StartupBehavior.FirstView
|
||||
);
|
||||
}
|
||||
|
||||
persistLastRestorablePath(url: string): void {
|
||||
const canonicalPath = this.getRestorablePath(url);
|
||||
if (!canonicalPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(
|
||||
LAST_RESTORABLE_ROUTE_STORAGE_KEY,
|
||||
canonicalPath
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage write failures.
|
||||
}
|
||||
}
|
||||
|
||||
getRestorablePath(url: string): string | null {
|
||||
const [path] = url.split('?');
|
||||
if (path === '/workspace' || path === '/workspace/') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const route = parseWorkspaceShellRoute(url);
|
||||
|
||||
switch (route.kind) {
|
||||
case 'dashboard':
|
||||
return '/workspace/dashboard';
|
||||
case 'downloads':
|
||||
return '/workspace/downloads';
|
||||
case 'global-favorites':
|
||||
return '/workspace/global-favorites';
|
||||
case 'global-recent':
|
||||
return '/workspace/global-recent';
|
||||
case 'sources':
|
||||
return '/workspace/sources';
|
||||
case 'portal':
|
||||
if (!route.context || !route.section) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'/workspace',
|
||||
route.context.provider,
|
||||
route.context.playlistId,
|
||||
route.section,
|
||||
].join('/');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getValidatedLastRestorablePath(
|
||||
showDashboard = this.showDashboard()
|
||||
): Promise<string | null> {
|
||||
const storedPath = this.readLastRestorablePath();
|
||||
if (!storedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canonicalPath = this.getRestorablePath(storedPath);
|
||||
if (!canonicalPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!showDashboard && canonicalPath === '/workspace/dashboard') {
|
||||
return this.getFirstAvailableWorkspacePath(false);
|
||||
}
|
||||
|
||||
const route = parseWorkspaceShellRoute(canonicalPath);
|
||||
if (route.kind !== 'portal' || !route.context) {
|
||||
return canonicalPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const playlists = await firstValueFrom(
|
||||
this.playlistsService.getAllPlaylists()
|
||||
);
|
||||
|
||||
return playlists.some(
|
||||
(playlist) => playlist._id === route.context?.playlistId
|
||||
)
|
||||
? canonicalPath
|
||||
: this.getFirstAvailableWorkspacePath(showDashboard);
|
||||
} catch {
|
||||
return this.getFirstAvailableWorkspacePath(showDashboard);
|
||||
}
|
||||
}
|
||||
|
||||
private readLastRestorablePath(): string | null {
|
||||
try {
|
||||
const value = localStorage.getItem(
|
||||
LAST_RESTORABLE_ROUTE_STORAGE_KEY
|
||||
);
|
||||
return value && value.trim().length > 0 ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user