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()) { -