From 0661bf08b542167c81caf2a908ef703547f3203c Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Apr 2026 20:27:25 +0200 Subject: [PATCH] feat: implement loading state and skeleton UI for channel list Entire-Checkpoint: e6dee91f3aee --- .../portal-channels-list.component.html | 20 ++- .../portal-channels-list.component.scss | 102 ++++++++++++++ .../portal-channels-list.component.spec.ts | 129 ++++++++++++++++++ .../portal-channels-list.component.ts | 13 +- 4 files changed, 259 insertions(+), 5 deletions(-) create mode 100644 libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html index 17cf69250..57166dd1e 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html @@ -1,5 +1,23 @@
- @if (filteredChannels().length > 0) { + @if (isSelectedTypeContentLoading()) { +
+
+
+ {{ 'CHANNELS.LOADING' | translate }} +
+
+ + +
+ } @else if (filteredChannels().length > 0) { { + let fixture: ComponentFixture; + const selectedChannels = signal([]); + const selectedItem = signal(null); + const epgItems = signal([]); + const selectedTypeContentLoading = signal(true); + const selectedContentType = signal('live'); + const currentPlaylist = signal(null); + const selectedCategoryId = signal(1); + const storeSignals = { + selectItemsFromSelectedCategory: selectedChannels, + selectedItem, + epgItems, + selectedTypeContentLoading, + selectedContentType, + currentPlaylist, + selectedCategoryId, + setSelectedCategory: jest.fn(), + }; + const epgResults$ = new Subject<{ streamId: number; items: unknown[] }>(); + + beforeEach(async () => { + storeSignals.setSelectedCategory.mockClear(); + selectedChannels.set([]); + selectedItem.set(null); + epgItems.set([]); + selectedTypeContentLoading.set(true); + selectedContentType.set('live'); + currentPlaylist.set(null); + selectedCategoryId.set(1); + + await TestBed.configureTestingModule({ + imports: [PortalChannelsListComponent, NoopAnimationsModule], + providers: [ + { + provide: TranslateService, + useValue: { + instant: (key: string) => + key === 'CHANNELS.LOADING' + ? 'Loading channels...' + : key, + get: (key: string) => + of( + key === 'CHANNELS.LOADING' + ? 'Loading channels...' + : key + ), + stream: (key: string) => + of( + key === 'CHANNELS.LOADING' + ? 'Loading channels...' + : key + ), + onLangChange: of(null), + onTranslationChange: of(null), + onDefaultLangChange: of(null), + currentLang: 'en', + defaultLang: 'en', + }, + }, + { + provide: XtreamStore, + useValue: storeSignals, + }, + { + provide: FavoritesService, + useValue: { + getFavorites: jest.fn().mockReturnValue(of([])), + }, + }, + { + provide: EpgQueueService, + useValue: { + epgResult$: epgResults$, + getCached: jest.fn().mockReturnValue(null), + enqueue: jest.fn(), + }, + }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + params: {}, + }, + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(PortalChannelsListComponent); + }); + + it('renders a loading placeholder instead of the empty state while xtream live content is still loading', () => { + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.channels-loading-state') + ).not.toBeNull(); + expect( + fixture.nativeElement.querySelector('.empty-search-state') + ).toBeNull(); + }); + + it('renders the empty state once loading has finished and the selected category has no channels', () => { + selectedTypeContentLoading.set(false); + + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.channels-loading-state') + ).toBeNull(); + expect( + fixture.nativeElement.querySelector('.empty-search-state') + ).not.toBeNull(); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index 570d367c5..e3386a1af 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -69,6 +69,9 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { private readonly favoritesService = inject(FavoritesService); private readonly epgQueueService = inject(EpgQueueService); private readonly route = inject(ActivatedRoute); + readonly isSelectedTypeContentLoading = + this.xtreamStore.selectedTypeContentLoading; + readonly loadingRows = Array.from({ length: 9 }, (_, index) => index); readonly channels = computed(() => { const override = this.channelsOverride(); if (Array.isArray(override)) { @@ -261,11 +264,13 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { toggleFavorite(event: Event, item: XtreamChannelListItem) { event.stopPropagation(); + const playlistId = this.xtreamStore.currentPlaylist()?.id; + if (!playlistId) { + return; + } + this.xtreamStore - .toggleFavorite( - item.xtream_id, - this.xtreamStore.currentPlaylist().id - ) + .toggleFavorite(item.xtream_id, playlistId) .then((result: boolean) => { if (result) { this.favorites.set(item.xtream_id, true);