diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index d2086dca9..1a490371b 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -545,8 +545,15 @@ async function expectCategoryCentered(row: Locator): Promise { } async function refreshFromWorkspaceHeader(page: Page): Promise { + // Refresh for the active source lives in its switcher row menu. + await page.getByTestId('playlist-switcher-trigger').click(); await page - .getByRole('button', { name: 'Refresh playlist', exact: true }) + .locator( + 'app-playlist-switcher-panel [role="option"][aria-current="true"] .source-row__more' + ) + .click(); + await page + .getByRole('menuitem', { name: 'Refresh playlist', exact: true }) .click(); const dialog = page.locator('mat-dialog-container'); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts index c6afca2b1..509079786 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts @@ -214,6 +214,15 @@ describe('RecentPlaylistsComponent', () => { expect(removal.remove).toHaveBeenCalledWith(M3U, FILE); }); + it('keeps a running delete on screen whatever the filters', () => { + ( + removal.progress as { isDeleting: (id: string) => boolean } + ).isDeleting = (id) => id === 'xtream'; + viewState.setType('m3u'); + fixture.detectChanges(); + expect(ids()).toEqual(['m3u', 'xtream', 'file']); + }); + it('applies the type and status filters together', () => { viewState.setType('m3u'); expect(ids()).toEqual(['m3u', 'file']); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts index d83cb582b..aba62e66e 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts @@ -122,7 +122,7 @@ export class RecentPlaylistsComponent { }; const options = this.sortOptions(); const filter = this.viewState.filter(); - const filtered = filterSources( + const matching = filterSources( this.playlists(), this.statusService.supportsStatusFilter ? filter @@ -130,6 +130,14 @@ export class RecentPlaylistsComponent { this.searchQueryInput(), bucketOf ); + // A running delete keeps its row, whatever the filters: its + // progress and Cancel live there (one started from the switcher + // has nowhere else to show). + const filtered = this.playlists().filter( + (playlist) => + matching.includes(playlist) || + this.removal.progress.isDeleting(playlist._id) + ); const sorted = this.sortService.sortPlaylists( filtered, options, diff --git a/libs/playlist/shared/ui/src/lib/source-removal/source-delete-progress.service.ts b/libs/playlist/shared/ui/src/lib/source-removal/source-delete-progress.service.ts index 802a2a249..b18e7c180 100644 --- a/libs/playlist/shared/ui/src/lib/source-removal/source-delete-progress.service.ts +++ b/libs/playlist/shared/ui/src/lib/source-removal/source-delete-progress.service.ts @@ -77,18 +77,28 @@ export class SourceDeleteProgressService { return this.cancelled.delete(id); } + /** + * Follows a running delete. A terminal event ends what Cancel can stop, + * but the row stays busy: the delete still cleans up (parental lock, + * stored state) before its source leaves the store, so the caller clears + * it once the whole delete has settled. + */ track(id: string, event: DbOperationEvent): void { - if (['completed', 'cancelled', 'error'].includes(event.status)) { - this.clear(id); - return; - } + const terminal = ['completed', 'cancelled', 'error'].includes( + event.status + ); this.operations.update((current) => - new Map(current).set(id, { - operationId: event.operationId, - phase: event.phase, - current: event.current, - total: event.total, - }) + new Map(current).set( + id, + terminal + ? { ...current.get(id), operationId: undefined } + : { + operationId: event.operationId, + phase: event.phase, + current: event.current, + total: event.total, + } + ) ); } diff --git a/libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.spec.ts b/libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.spec.ts index 0b59a40fd..f066a757f 100644 --- a/libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.spec.ts +++ b/libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.spec.ts @@ -336,6 +336,38 @@ describe('SourceRemovalService', () => { expect(toasts).toHaveLength(1); }); + it('keeps a deleted row busy until the whole delete has settled', async () => { + let finish!: (deleted: boolean) => void; + deletePlaylist.mockImplementation( + ( + _playlist: PlaylistMeta, + options: { onEvent: (event: object) => void } + ) => { + options.onEvent({ + operation: 'delete-playlist', + operationId: 'op-2', + status: 'completed', + }); + return new Promise((resolve) => (finish = resolve)); + } + ); + service.remove({ ...playlist('x'), serverUrl: 'http://x.test' }); + expireUndo(); + await flush(); + + // The database is done, its cleanup is not: no Cancel, still busy. + expect(service.progress.isDeleting('x')).toBe(true); + expect(service.progress.canCancel('x')).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + + finish(true); + await flush(); + expect(dispatch).toHaveBeenCalledWith( + PlaylistActions.playlistRemovalCommitted({ playlistId: 'x' }) + ); + expect(service.progress.isDeleting('x')).toBe(false); + }); + it('brings the source back and says so when the delete fails', async () => { deletePlaylist.mockResolvedValue(false); service.remove(playlist('a'));