mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat: add loading state and spinner to source stats widget
- Introduced a loading state in the source stats widget with a spinner. - Updated styles for the loading state in the SCSS file. - Created unit tests for the source stats widget to verify loading behavior. - Enhanced the source stats widget component to include a progress spinner. - Added a new service for managing workspace startup preferences. - Implemented logic to determine the initial workspace path based on user settings. - Updated workspace shell header to include a refresh playlist button. - Refactored workspace shell rail component to support dynamic brand link and tooltip. - Enhanced workspace shell facade to manage playlist refresh actions. - Improved loading overlay UI in the workspace shell component. Entire-Checkpoint: 271bd0162027
This commit is contained in:
1 parent
26fa697afe
commit
eaa3e0e2bd
37 files changed
+1178
-103
No files matched your search
@@ -2,3 +2,4 @@ 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,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user