diff --git a/docs/architecture/zoneless-migration.md b/docs/architecture/zoneless-migration.md index 95bfd0670..265a14a6d 100644 --- a/docs/architecture/zoneless-migration.md +++ b/docs/architecture/zoneless-migration.md @@ -130,20 +130,20 @@ picker), so each marks itself on the form's `events` through ### libs/playlist (14) -- [ ] `libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts` -- [ ] `libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts` -- [ ] `libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts` -- [ ] `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` -- [ ] `libs/playlist/import/feature/src/lib/text-import/text-import.component.ts` -- [ ] `libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts` -- [ ] `libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts` +- [x] `libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts` +- [x] `libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts` +- [x] `libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts` +- [x] `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` +- [x] `libs/playlist/import/feature/src/lib/text-import/text-import.component.ts` +- [x] `libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts` +- [x] `libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts` - [x] `libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts` - [x] `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` -- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts` -- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` -- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` -- [ ] `libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts` -- [ ] `libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts` +- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts` +- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` +- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` +- [x] `libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts` +- [x] `libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts` `libs/playlist/m3u/feature-player` (2) goes with the playback PR. @@ -176,9 +176,9 @@ the field a signal (or a `computed`), or writes it through one. | --- | --- | --- | --- | | [x] | `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` `onChannelNumberInput`/`clearChannelNumberInput` | 2 s `window.setTimeout` hides the channel-number overlay through plain `showChannelNumberOverlay`/`channelNumberInput` | playback | | [x] | same file, `applySettings` and the settings `effect()` | IndexedDB `storage.get(...).subscribe` and an effect assign plain `playerSettings`, which picks the player in the template | playback | -| [ ] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` `checkPortalStatus` | plain `portalStatus` assigned after `await` in `ngOnInit` (PWA only: skipped when source health is supported) | playlist | -| [ ] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` (EPG clear and EPG file pick handlers) | plain `playlist` reassigned after `await` | playlist | -| [ ] | `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` (device-id derivation) | `form.patchValue` after `await`; template getters read `control.value`, which is not signal-backed | playlist | +| [x] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` `checkPortalStatus` | plain `portalStatus` assigned after `await` in `ngOnInit` (PWA only: skipped when source health is supported) | playlist | +| [x] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` (EPG clear and EPG file pick handlers) | plain `playlist` reassigned after `await` | playlist | +| [x] | `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` (device-id derivation) | `form.patchValue` after `await`; no fix needed: while derivation is on `hasManualDeviceIds` is false whatever the IDs are, the controls write their own DOM values, and typing goes through value-accessor listeners that mark the view | playlist | | [x] | `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` (favorites load) | `favorites` Map filled in a `subscribe` without `markForCheck`; the component is OnPush already, so this is a latent bug today | portal | | [x] | `libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts` (favorites load) | same pattern; the neighbouring `favoriteMarks.changes$` handler does call `markForCheck` | portal | | [x] | same file, programme dialog `afterClosed` | deletes from `epgPrograms`/`currentProgramsProgress` after `await` without marking | portal | diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.render.spec.ts b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.render.spec.ts new file mode 100644 index 000000000..38350f156 --- /dev/null +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.render.spec.ts @@ -0,0 +1,86 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Store } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; +import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services'; +import { AddPlaylistDialogComponent } from './add-playlist-dialog.component'; + +/** + * The dialog is OnPush and its Add button reads `form.valid` of the real URL + * import child. The fixture renders on its own (`autoDetectChanges`): a + * forced `detectChanges()` would hide a button that does not follow the + * child form. + */ +describe('AddPlaylistDialogComponent with the real URL form', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + AddPlaylistDialogComponent, + TranslateModule.forRoot(), + NoopAnimationsModule, + ], + providers: [ + { provide: DataService, useValue: { sendIpcEvent: jest.fn() } }, + { provide: MatDialogRef, useValue: { close: jest.fn() } }, + { provide: Store, useValue: { dispatch: jest.fn() } }, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { provide: MAT_DIALOG_DATA, useValue: null }, + { + provide: RuntimeCapabilitiesService, + useValue: { isElectron: true }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(AddPlaylistDialogComponent); + fixture.autoDetectChanges(); + await settle(); + }); + + async function settle(): Promise { + await fixture.whenStable(); + // The render a form change schedules runs in the next macrotask. + await new Promise((resolve) => setTimeout(resolve)); + } + + const addButton = () => + Array.from( + ( + fixture.nativeElement as HTMLElement + ).querySelectorAll('button[mat-flat-button]') + ).find((button) => + button.textContent?.includes('HOME.URL_UPLOAD.ADD_PLAYLIST') + ); + const urlInput = () => + (fixture.nativeElement as HTMLElement).querySelector( + 'input[formcontrolname="playlistUrl"]' + ); + + it('enables Add once the user types a playlist URL', async () => { + expect(addButton()?.disabled).toBe(true); + + const input = urlInput(); + if (!input) throw new Error('Expected the URL input'); + input.value = 'https://example.com/list.m3u'; + input.dispatchEvent(new Event('input')); + await settle(); + + expect(addButton()?.disabled).toBe(false); + }); + + it('enables Add when the child form is filled without a template event', async () => { + expect(addButton()?.disabled).toBe(true); + + // As an auto-detect prefill does: a patch from outside the child. + fixture.componentInstance + .urlUpload() + ?.form.patchValue({ playlistUrl: 'https://example.com/list.m3u' }); + await settle(); + + expect(addButton()?.disabled).toBe(false); + }); +}); diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts index 05090e988..88ded3ec6 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts @@ -72,7 +72,7 @@ const METHOD_BY_CANDIDATE_KIND: Record< selector: 'app-add-playlist', templateUrl: './add-playlist-dialog.component.html', styleUrl: './add-playlist-dialog.component.scss', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, }) export class AddPlaylistDialogComponent { diff --git a/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts index 1dcf80b52..0b86cb5af 100644 --- a/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts +++ b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts @@ -147,7 +147,7 @@ function maskUrlQueryPasswords(url: string): string { selector: 'app-auto-import', templateUrl: './auto-import.component.html', styleUrl: './auto-import.component.scss', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ MatButtonModule, MatFormFieldModule, diff --git a/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts b/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts index ae51aa9c2..d621b9410 100644 --- a/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts +++ b/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts @@ -18,7 +18,7 @@ const KB = 1024; imports: [DragDropFileUploadDirective, MatIconModule, TranslatePipe], selector: 'app-file-upload', templateUrl: './file-upload.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styleUrls: ['./file-upload.component.scss'], }) export class FileUploadComponent { diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts index e373891b4..8cd65d5fd 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts @@ -75,7 +75,7 @@ interface StalkerSettledIdentity { ], selector: 'app-stalker-portal-import', templateUrl: './stalker-portal-import.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ` :host { diff --git a/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts b/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts index 5eb4f7fd5..f767cffb7 100644 --- a/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts +++ b/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts @@ -17,7 +17,7 @@ import { TranslatePipe } from '@ngx-translate/core'; selector: 'app-text-import', templateUrl: './text-import.component.html', styleUrls: ['./text-import.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [MatInputModule, ReactiveFormsModule, TranslatePipe], }) export class TextImportComponent { diff --git a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts index ce06b6834..59338b8bf 100644 --- a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts +++ b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts @@ -19,7 +19,7 @@ import { RuntimeCapabilitiesService } from '@iptvnator/services'; @Component({ selector: 'app-url-upload', templateUrl: './url-upload.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ MatFormFieldModule, MatInputModule, diff --git a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts index 83870b92d..ca7c148f9 100644 --- a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts +++ b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts @@ -60,7 +60,7 @@ function xtreamServerUrlValidator( ], selector: 'app-xtream-code-import', templateUrl: './xtream-code-import.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ` :host { diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts index 6dc6cf9a4..d5de21666 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts @@ -103,7 +103,7 @@ const SOURCE_CARDS: readonly SourceCard[] = [ './empty-state.responsive.scss', './empty-state.themes.scss', ], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [MatButtonModule, MatIcon, TranslatePipe], }) export class EmptyStateComponent { diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info-epg-sources.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info-epg-sources.component.spec.ts new file mode 100644 index 000000000..1088ebc61 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info-epg-sources.component.spec.ts @@ -0,0 +1,126 @@ +import { TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Store } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; +import { of, Subject } from 'rxjs'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; +import { + DatabaseService, + PlaylistsService, + RuntimeCapabilitiesService, + SettingsStore, +} from '@iptvnator/services'; +import { Playlist } from '@iptvnator/shared/interfaces'; +import { PlaylistInfoComponent } from './playlist-info.component'; +import { STALKER_PLAYLIST_CONNECTION_EDITOR } from './stalker-playlist-connection-editor.token'; + +/** + * The component is OnPush: the playlist is replaced after the awaited EPG + * cleanup, outside any template event, so the rendered source list has to + * follow without a zone-triggered tick. + */ +describe('PlaylistInfoComponent playlist EPG source rows', () => { + const keepUrl = 'https://playlist.example.com/keep.xml'; + const removeUrl = 'https://playlist.example.com/remove.xml'; + const playlist = { + id: 'playlist-1', + _id: 'playlist-1', + title: 'My Playlist', + count: 1, + importDate: '2026-04-01T00:00:00.000Z', + autoRefresh: false, + url: 'https://example.com/playlist.m3u', + epgUrls: [keepUrl, removeUrl], + detectedEpgUrls: [keepUrl, removeUrl], + } as Playlist & { id: string }; + + function render() { + TestBed.configureTestingModule({ + imports: [PlaylistInfoComponent, TranslateModule.forRoot()], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: playlist }, + { + provide: PlaylistsService, + useValue: { getPlaylistById: jest.fn(() => of(playlist)) }, + }, + { provide: DatabaseService, useValue: {} }, + { + provide: EpgRuntimeBridgeService, + useValue: { + supportsDataManagement: true, + supportsFilePicker: true, + pickEpgFile: jest + .fn() + .mockResolvedValue('/home/user/epg/guide.xml.gz'), + clearEpgDataForSource: jest + .fn() + .mockResolvedValue({ success: true }), + }, + }, + { provide: Store, useValue: { dispatch: jest.fn() } }, + { + provide: SettingsStore, + useValue: { getSettings: jest.fn(() => ({ epgUrl: [] })) }, + }, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { + provide: MatDialogRef, + useValue: { beforeClosed: () => new Subject() }, + }, + { + provide: RuntimeCapabilitiesService, + useValue: { supportsDesktopFileSave: false }, + }, + { provide: STALKER_PLAYLIST_CONNECTION_EDITOR, useValue: {} }, + ], + }); + const fixture = TestBed.createComponent(PlaylistInfoComponent); + fixture.detectChanges(); + return fixture; + } + + it('drops a removed source row once its cleanup resolves', async () => { + const fixture = render(); + const renderedSources = () => + Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll( + '.playlist-epg-source-row input[readonly]' + ), + (input) => (input as HTMLInputElement).value + ); + expect(renderedSources()).toEqual([keepUrl, removeUrl]); + + // From here the fixture renders on its own: a forced detectChanges() + // after the cleanup would hide a row update that schedules no render. + fixture.autoDetectChanges(); + await fixture.componentInstance.removePlaylistEpgSource(removeUrl); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve)); + + expect(renderedSources()).toEqual([keepUrl]); + }); + + // The native file pick resolves after an await, with no template event. + // The main Save button and the field must follow without a forced render. + it('enables Save after a native EPG file pick without a forced render', async () => { + const fixture = render(); + fixture.autoDetectChanges(); + await fixture.whenStable(); + const host = fixture.nativeElement as HTMLElement; + const save = () => + host.querySelector('button[type="submit"]'); + const field = () => + host.querySelector( + '.playlist-epg-source-form .playlist-epg-source-row input' + ); + expect(save()?.disabled).toBe(true); + + await fixture.componentInstance.browsePlaylistEpgSourceInput(0); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve)); + + expect(field()?.value).toBe('/home/user/epg/guide.xml.gz'); + expect(save()?.disabled).toBe(false); + }); +}); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts index 97192e68c..35a010ded 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts @@ -166,7 +166,7 @@ type DesktopFileSaveBridge = Pick< `, ], providers: [DatePipe], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ ClipboardModule, MatButton, @@ -300,8 +300,20 @@ export class PlaylistInfoComponent { ) as UntypedFormArray; } - /** Playlist object */ - playlist: Playlist & { id: string }; + /** + * Playlist object. Signal-backed because it is replaced after awaited + * hydration and EPG source edits, which no template event marks for an + * OnPush check. + */ + private readonly playlistState = signal( + this.playlistData + ); + get playlist(): Playlist & { id: string } { + return this.playlistState(); + } + set playlist(value: Playlist & { id: string }) { + this.playlistState.set(value); + } /** Form group with playlist details */ playlistDetails!: UntypedFormGroup; @@ -311,7 +323,6 @@ export class PlaylistInfoComponent { this.dialogRef?.beforeClosed().subscribe(() => { this.dialogClosing = true; }); - this.playlist = this.playlistData; this.createForm(); this.connectionTest = createXtreamConnectionTestState( this.playlistDetails diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html index 844252b24..f97919a39 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html @@ -64,8 +64,8 @@ } @else if (showsAutoRefresh) { { fixture.detectChanges(); expect( - (fixture.nativeElement as HTMLElement).querySelector( - '.refresh-btn' - ) + (fixture.nativeElement as HTMLElement).querySelector('.refresh-btn') ).toBeNull(); }); @@ -149,9 +147,7 @@ describe('PlaylistItemComponent', () => { fixture.detectChanges(); expect( - (fixture.nativeElement as HTMLElement).querySelector( - '.refresh-btn' - ) + (fixture.nativeElement as HTMLElement).querySelector('.refresh-btn') ).not.toBeNull(); }); @@ -173,9 +169,7 @@ describe('PlaylistItemComponent', () => { fixture.detectChanges(); expect( - (fixture.nativeElement as HTMLElement).querySelector( - '.refresh-btn' - ) + (fixture.nativeElement as HTMLElement).querySelector('.refresh-btn') ).not.toBeNull(); fixture.destroy(); @@ -195,12 +189,40 @@ describe('PlaylistItemComponent', () => { fixture.detectChanges(); expect( - (fixture.nativeElement as HTMLElement).querySelector( - '.refresh-btn' - ) + (fixture.nativeElement as HTMLElement).querySelector('.refresh-btn') ).toBeNull(); }); + // OnPush: the status arrives after an await outside any template event, + // so it must reach the view without a zone-triggered tick. + it('renders the portal status that resolves after the first render', async () => { + fixture.destroy(); + fixture = TestBed.createComponent(PlaylistItemComponent); + component = fixture.componentInstance; + component.item = { + title: 'Xtream Source', + _id: 'xtream-source', + count: 10, + importDate: Date.now().toString(), + autoRefresh: false, + serverUrl: 'https://example.com', + username: 'demo', + password: 'secret', + }; + // The fixture renders on its own: a forced detectChanges() after the + // await would hide a status that does not schedule a render. + fixture.autoDetectChanges(); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve)); + + const statusDot = (fixture.nativeElement as HTMLElement).querySelector( + '.status-dot' + ); + expect(statusDot?.getAttribute('aria-label')).toBe( + 'Portal status: active' + ); + }); + it('renders cancel and progress UI for long-running playlist actions', () => { fixture.componentRef.setInput('isDeleting', true); fixture.componentRef.setInput( diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts index 033de62c5..8cbe29077 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts @@ -17,6 +17,7 @@ import { inject, input, output, + signal, ChangeDetectionStrategy, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; @@ -39,7 +40,7 @@ import type { PlaylistMeta } from '@iptvnator/shared/interfaces'; selector: 'app-playlist-item', templateUrl: './playlist-item.component.html', styleUrls: ['./playlist-item.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ SourceHealthIndicatorComponent, DatePipe, @@ -70,7 +71,7 @@ export class PlaylistItemComponent implements OnInit { readonly removeClicked = output(); readonly cancelBusyActionClicked = output(); - portalStatus: PortalStatus = 'unavailable'; + readonly portalStatus = signal('unavailable'); private readonly portalStatusService = inject(PortalStatusService); readonly runtime = inject(RuntimeCapabilitiesService); readonly sourceIcons = SOURCE_TYPE_ICONS; @@ -125,21 +126,22 @@ export class PlaylistItemComponent implements OnInit { private async checkPortalStatus() { if (this.item.serverUrl && this.item.username && this.item.password) { - this.portalStatus = + this.portalStatus.set( await this.portalStatusService.checkPortalStatus( this.item.serverUrl, this.item.username, this.item.password - ); + ) + ); } } getStatusClass(): string { - return this.portalStatusService.getStatusClass(this.portalStatus); + return this.portalStatusService.getStatusClass(this.portalStatus()); } getStatusIcon(): string { - return this.portalStatusService.getStatusIcon(this.portalStatus); + return this.portalStatusService.getStatusIcon(this.portalStatus()); } onPlaylistClick(): void { diff --git a/libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts b/libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts index b23f785c1..43558e4f4 100644 --- a/libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts +++ b/libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts @@ -181,7 +181,7 @@ export interface SourceCleanupDialogData extends SourceCleanupContext { } `, - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ` :host { diff --git a/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts b/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts index 3637c056a..4573a2b26 100644 --- a/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts +++ b/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts @@ -29,7 +29,7 @@ import { PlaylistMeta, sourceHealthType } from '@iptvnator/shared/interfaces'; role="img" > }`, - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ` :host {