From eaa3e0e2bd6fee5957ec315c446364ea228911b9 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Apr 2026 10:10:52 +0200 Subject: [PATCH] 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 --- libs/workspace/dashboard/ui/jest.config.ts | 21 +++ libs/workspace/dashboard/ui/project.json | 8 + .../continue-watching-widget.component.html | 12 +- .../continue-watching-widget.component.scss | 7 + ...continue-watching-widget.component.spec.ts | 48 ++++++ .../continue-watching-widget.component.ts | 6 + .../global-favorites-widget.component.html | 35 ++-- .../global-favorites-widget.component.scss | 7 + .../global-favorites-widget.component.spec.ts | 88 ++++++++++ .../global-favorites-widget.component.ts | 8 +- .../recently-added-widget.component.html | 11 +- .../recently-added-widget.component.scss | 6 + .../recently-added-widget.component.spec.ts | 50 ++++++ .../recently-added-widget.component.ts | 2 + .../recently-watched-widget.component.html | 40 +++-- .../recently-watched-widget.component.scss | 7 + .../recently-watched-widget.component.spec.ts | 58 +++++++ .../recently-watched-widget.component.ts | 2 + .../source-stats-widget.component.html | 84 +++++----- .../source-stats-widget.component.scss | 7 + .../source-stats-widget.component.spec.ts | 44 +++++ .../widgets/source-stats-widget.component.ts | 8 +- libs/workspace/dashboard/ui/src/test-setup.ts | 6 + .../workspace/dashboard/ui/tsconfig.spec.json | 17 ++ .../workspace-shell-header.component.html | 17 ++ .../workspace-shell-header.component.ts | 8 +- .../workspace-shell-rail.component.html | 6 +- .../workspace-shell-rail.component.spec.ts | 15 ++ .../workspace-shell-rail.component.ts | 3 + .../services/workspace-shell.facade.spec.ts | 95 ++++++++++- .../services/workspace-shell.facade.ts | 127 +++++++++++++-- .../workspace-shell.component.html | 35 +++- .../workspace-shell.component.scss | 111 ++++++++++++- .../workspace-shell.component.spec.ts | 24 +++ libs/workspace/shell/util/src/index.ts | 1 + ...kspace-startup-preferences.service.spec.ts | 104 ++++++++++++ .../workspace-startup-preferences.service.ts | 153 ++++++++++++++++++ 37 files changed, 1178 insertions(+), 103 deletions(-) create mode 100644 libs/workspace/dashboard/ui/jest.config.ts create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.spec.ts create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.spec.ts create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.spec.ts create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/recently-watched-widget.component.spec.ts create mode 100644 libs/workspace/dashboard/ui/src/lib/widgets/source-stats-widget.component.spec.ts create mode 100644 libs/workspace/dashboard/ui/src/test-setup.ts create mode 100644 libs/workspace/dashboard/ui/tsconfig.spec.json create mode 100644 libs/workspace/shell/util/src/lib/workspace-startup-preferences.service.spec.ts create mode 100644 libs/workspace/shell/util/src/lib/workspace-startup-preferences.service.ts diff --git a/libs/workspace/dashboard/ui/jest.config.ts b/libs/workspace/dashboard/ui/jest.config.ts new file mode 100644 index 000000000..d2fa01536 --- /dev/null +++ b/libs/workspace/dashboard/ui/jest.config.ts @@ -0,0 +1,21 @@ +export default { + displayName: 'workspace-dashboard-ui', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/workspace/dashboard/ui', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/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', + ], +}; diff --git a/libs/workspace/dashboard/ui/project.json b/libs/workspace/dashboard/ui/project.json index b61565f1f..51b734732 100644 --- a/libs/workspace/dashboard/ui/project.json +++ b/libs/workspace/dashboard/ui/project.json @@ -6,6 +6,14 @@ "projectType": "library", "tags": ["scope:workspace", "domain:workspace", "type:ui"], "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/workspace/dashboard/ui/jest.config.ts", + "tsConfig": "libs/workspace/dashboard/ui/tsconfig.spec.json" + } + }, "lint": { "executor": "@nx/eslint:lint" } diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.html index a960a4381..aa2fdb1a8 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.html +++ b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.html @@ -2,8 +2,16 @@ [eyebrow]="'WORKSPACE.DASHBOARD.FOCUS' | translate" [title]="'WORKSPACE.DASHBOARD.CONTINUE_WATCHING' | translate" > - @if (lastItem(); as item) { - + @if (data.globalRecentLoading()) { +
+ +
+ } @else if (lastItem(); as item) { +
@if (hasImage(item)) { diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.scss b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.scss index f9132617e..588624475 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.scss +++ b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.scss @@ -13,6 +13,13 @@ --_series-accent: oklch(68% 0.16 55); } +.continue-watching-widget__loading { + min-height: 220px; + display: grid; + place-items: center; + padding: 16px 0; +} + // ── Hero card ───────────────────────────────────────────────────────────────── .hero { diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.spec.ts b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.spec.ts new file mode 100644 index 000000000..0a151b59b --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.spec.ts @@ -0,0 +1,48 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { DashboardDataService } from 'workspace-dashboard-data-access'; +import { ContinueWatchingWidgetComponent } from './continue-watching-widget.component'; + +describe('ContinueWatchingWidgetComponent', () => { + let fixture: ComponentFixture; + + const dataServiceMock = { + globalRecentLoading: signal(true), + globalRecentItems: signal([]), + quickRecent: signal([]), + getRecentItemLink: jest.fn(() => ['/workspace', 'global-recent']), + getRecentItemNavigationState: jest.fn(() => undefined), + getPlaylistLink: jest.fn(() => ['/workspace', 'sources']), + getPlaylistProvider: jest.fn(() => 'M3U'), + }; + + beforeEach(async () => { + dataServiceMock.globalRecentLoading.set(true); + dataServiceMock.globalRecentItems.set([]); + dataServiceMock.quickRecent.set([]); + + await TestBed.configureTestingModule({ + imports: [ContinueWatchingWidgetComponent, TranslateModule.forRoot()], + providers: [ + provideRouter([]), + { + provide: DashboardDataService, + useValue: dataServiceMock, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ContinueWatchingWidgetComponent); + }); + + it('shows a spinner while recent history is still loading', () => { + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('mat-progress-spinner') + ).not.toBeNull(); + expect(fixture.nativeElement.querySelector('.focus-empty')).toBeNull(); + }); +}); diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.ts index 9f77c7702..7c893a4ab 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.ts +++ b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.ts @@ -2,6 +2,7 @@ import { Component, computed, inject } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { RouterLink } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { startWith } from 'rxjs'; @@ -16,6 +17,7 @@ import { DashboardWidgetShellComponent } from './dashboard-widget-shell.componen imports: [ MatButtonModule, MatIcon, + MatProgressSpinnerModule, RouterLink, DashboardWidgetShellComponent, TranslatePipe, @@ -44,6 +46,10 @@ export class ContinueWatchingWidgetComponent { return this.data.getRecentItemLink(item); } + getState(item: GlobalRecentItem): Record | undefined { + return this.data.getRecentItemNavigationState(item); + } + typeIcon(type: GlobalRecentItem['type']): string { if (type === 'live') return 'live_tv'; if (type === 'movie') return 'movie'; diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.html index 45b57a451..a6c7dfab0 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.html +++ b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.html @@ -80,15 +80,28 @@ - + @if (data.globalFavoritesLoading()) { +
+ +
+ } @else { + + } diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.scss b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.scss index d8a1d6c94..b1620d2c7 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.scss +++ b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.scss @@ -38,3 +38,10 @@ font-weight: 600; color: var(--mat-sys-primary); } + +.global-favorites-widget__loading { + min-height: 220px; + display: grid; + place-items: center; + padding: 16px 0; +} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.spec.ts b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.spec.ts new file mode 100644 index 000000000..b71186306 --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.spec.ts @@ -0,0 +1,88 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { DashboardDataService } from 'workspace-dashboard-data-access'; +import { GlobalFavoritesWidgetComponent } from './global-favorites-widget.component'; + +describe('GlobalFavoritesWidgetComponent', () => { + let fixture: ComponentFixture; + const globalFavoritesLoading = signal(true); + const globalFavoritesLoaded = signal(false); + const globalFavoriteItems = signal([]); + + const dataServiceMock = { + globalFavoriteItems, + globalFavoritesLoading, + globalFavoritesLoaded, + reloadGlobalFavorites: jest.fn().mockResolvedValue(undefined), + isTypeInKind: jest.fn( + (type: 'live' | 'movie' | 'series', kind: string) => + kind === 'all' || + (kind === 'channels' && type === 'live') || + (kind === 'vod' && type === 'movie') || + (kind === 'series' && type === 'series') + ), + getFavoriteItemProviderLabel: jest.fn(() => 'Xtream'), + getFavoriteItemTypeLabel: jest.fn(() => 'Live TV'), + getGlobalFavoriteLink: jest.fn(() => ['/workspace', 'global-favorites']), + getGlobalFavoriteNavigationState: jest.fn(() => undefined), + removeGlobalFavorite: jest.fn(), + }; + + beforeEach(async () => { + globalFavoritesLoading.set(true); + globalFavoritesLoaded.set(false); + globalFavoriteItems.set([]); + jest.clearAllMocks(); + + await TestBed.configureTestingModule({ + imports: [ + GlobalFavoritesWidgetComponent, + TranslateModule.forRoot(), + ], + providers: [ + provideRouter([]), + { + provide: DashboardDataService, + useValue: dataServiceMock, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(GlobalFavoritesWidgetComponent); + fixture.componentRef.setInput('widget', { + id: 'global-favorites', + type: 'global-favorites', + enabled: true, + order: 0, + size: 'medium', + } as any); + }); + + it('shows a spinner while global favorites are still loading', () => { + fixture.detectChanges(); + + expect(dataServiceMock.reloadGlobalFavorites).toHaveBeenCalled(); + expect( + fixture.nativeElement.querySelector('mat-progress-spinner') + ).not.toBeNull(); + expect( + fixture.nativeElement.querySelector('.empty-state') + ).toBeNull(); + }); + + it('shows the empty state only after the initial load completes', () => { + globalFavoritesLoading.set(false); + globalFavoritesLoaded.set(true); + + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('mat-progress-spinner') + ).toBeNull(); + expect( + fixture.nativeElement.querySelector('.empty-state') + ).not.toBeNull(); + }); +}); diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.ts index e543c35af..ce7bc7420 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.ts +++ b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.ts @@ -1,4 +1,5 @@ import { + ChangeDetectionStrategy, Component, OnInit, computed, @@ -11,6 +12,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatIcon } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { startWith } from 'rxjs'; @@ -35,11 +37,13 @@ const PAGE_SIZE = 20; @Component({ selector: 'app-global-favorites-widget', + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ MatButtonModule, MatButtonToggleModule, MatMenuModule, MatIcon, + MatProgressSpinnerModule, MatTooltipModule, DashboardActivityItemsComponent, DashboardWidgetShellComponent, @@ -97,10 +101,8 @@ export class GlobalFavoritesWidgetComponent implements OnInit { })) ); - constructor() {} - ngOnInit(): void { - this.data.reloadGlobalFavorites().then(() => { + void this.data.reloadGlobalFavorites().then(() => { this.visibleItemLimit.set(INITIAL_PAGE_SIZE); }); } diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.html index 535788fe1..88561673a 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.html +++ b/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.html @@ -61,11 +61,12 @@ @if (loading()) { -