mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
fix(collections): one confirmed Clear recently viewed action (#1865)
A playlist's own recently viewed page (/workspace/<provider>/<id>/recent, opened from the dashboard's recently viewed rails) showed two clear buttons: the page's "Clear recently viewed <type>", which confirms through createClearCollectionAction, and a header delete_sweep button that cleared every tab of the playlist at once without asking. Remove the header bulk action end to end, and the unreachable, unconfirmed clear button of the M3U channel list's recent view, so every clear goes through createClearCollectionAction. Drop the two i18n keys only those buttons used. E2E: a shared helper asserts one clear control per page and one confirmation per press; the M3U, Xtream and Stalker recent tests cancel first (rows stay), then clear once on the playlist's own recently viewed page. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
015ea203b7
commit
8fabb88106
39 files changed
+261
-327
No files matched your search
+1
-14
@@ -121,7 +121,7 @@
|
||||
playlist is a global action, not playlist-scoped, so it must
|
||||
remain reachable (e.g. a fresh user configuring the app before
|
||||
importing their first source). Only the per-playlist shortcut
|
||||
and bulk actions are hidden on Settings. -->
|
||||
is hidden on Settings. -->
|
||||
<div class="header-actions header-actions--leading">
|
||||
<button
|
||||
type="button"
|
||||
@@ -201,19 +201,6 @@
|
||||
<mat-icon>{{ action.icon }}</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (headerBulkAction(); as bulk) {
|
||||
<button
|
||||
type="button"
|
||||
class="header-shortcut header-bulk-action"
|
||||
mat-icon-button
|
||||
[disabled]="bulk.disabled"
|
||||
(click)="onHeaderBulkActionRequested()"
|
||||
[matTooltip]="bulk.tooltip"
|
||||
[attr.aria-label]="bulk.ariaLabel"
|
||||
>
|
||||
<mat-icon>{{ bulk.icon }}</mat-icon>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
+2
-11
@@ -17,10 +17,7 @@ import {
|
||||
WorkspaceHeaderAction,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
WorkspaceHeaderBulkAction,
|
||||
WorkspaceHeaderSidebarToggle,
|
||||
} from '../../services/helpers/workspace-shell-constants';
|
||||
import { WorkspaceHeaderSidebarToggle } from '../../services/helpers/workspace-shell-constants';
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-shell-header',
|
||||
@@ -61,7 +58,6 @@ export class WorkspaceShellHeaderComponent {
|
||||
readonly searchScopeLabel = input('');
|
||||
readonly searchStatusLabel = input('');
|
||||
readonly headerShortcut = input<WorkspaceHeaderAction | null>(null);
|
||||
readonly headerBulkAction = input<WorkspaceHeaderBulkAction | null>(null);
|
||||
readonly headerSidebarToggle = input<WorkspaceHeaderSidebarToggle | null>(
|
||||
null
|
||||
);
|
||||
@@ -76,7 +72,7 @@ export class WorkspaceShellHeaderComponent {
|
||||
);
|
||||
/**
|
||||
* When true the playlist switcher + the "+ Add source" / refresh /
|
||||
* bulk-action buttons are hidden — those controls scope to a
|
||||
* shortcut buttons are hidden — those controls scope to a
|
||||
* playlist, but Settings is a global page, so leaving them visible
|
||||
* implies (falsely) that switching the playlist changes which
|
||||
* settings you're editing. Driven from the shell facade's existing
|
||||
@@ -131,7 +127,6 @@ export class WorkspaceShellHeaderComponent {
|
||||
readonly shortcutsRequested = output<void>();
|
||||
readonly addPlaylistRequested = output<void>();
|
||||
readonly headerShortcutRequested = output<void>();
|
||||
readonly headerBulkActionRequested = output<void>();
|
||||
readonly headerSidebarToggleRequested = output<void>();
|
||||
readonly parentalLockToggleRequested = output<void>();
|
||||
readonly refreshPlaylistRequested = output<void>();
|
||||
@@ -217,10 +212,6 @@ export class WorkspaceShellHeaderComponent {
|
||||
this.headerShortcutRequested.emit();
|
||||
}
|
||||
|
||||
onHeaderBulkActionRequested(): void {
|
||||
this.headerBulkActionRequested.emit();
|
||||
}
|
||||
|
||||
onHeaderSidebarToggleRequested(): void {
|
||||
this.headerSidebarToggleRequested.emit();
|
||||
}
|
||||
|
||||
-12
@@ -15,11 +15,6 @@ export const SEARCH_SETTINGS_PLACEHOLDER =
|
||||
export const SEARCH_LOADED_ONLY_STATUS =
|
||||
'WORKSPACE.SHELL.SEARCH_STATUS_LOADED_ONLY';
|
||||
|
||||
export const CLEAR_RECENTLY_VIEWED_TOOLTIP =
|
||||
'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION';
|
||||
export const CLEAR_RECENTLY_VIEWED_ARIA =
|
||||
'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION_ARIA';
|
||||
|
||||
export const RAIL_TOOLTIP_KEYS: Readonly<
|
||||
Partial<Record<PortalRailSection, string>>
|
||||
> = {
|
||||
@@ -39,13 +34,6 @@ export const RAIL_TOOLTIP_KEYS: Readonly<
|
||||
|
||||
export type XtreamImportPhaseTone = 'remote' | 'local' | null;
|
||||
|
||||
export interface WorkspaceHeaderBulkAction {
|
||||
icon: string;
|
||||
tooltip: string;
|
||||
ariaLabel: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header toggle for the live-channel rail of the current route. It exists in
|
||||
* both rail states so the control never moves: inside the rail the chevron
|
||||
|
||||
-12
@@ -65,18 +65,6 @@ export function syncSearchQueryParam(
|
||||
return true;
|
||||
}
|
||||
|
||||
export function bumpRefreshQueryParam(router: Router, currentUrl: string): void {
|
||||
const routePath = getRoutePath(currentUrl);
|
||||
const queryParams = {
|
||||
...router.parseUrl(currentUrl).queryParams,
|
||||
refresh: Date.now().toString(),
|
||||
};
|
||||
|
||||
const queryString = toQueryString(queryParams);
|
||||
const nextUrl = queryString ? `${routePath}?${queryString}` : routePath;
|
||||
void router.navigateByUrl(nextUrl, { replaceUrl: true });
|
||||
}
|
||||
|
||||
export function getProviderFromPlaylist(playlist: {
|
||||
serverUrl?: string;
|
||||
macAddress?: string;
|
||||
|
||||
+2
-112
@@ -1,10 +1,9 @@
|
||||
import { computed, inject, Injectable } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { firstValueFrom, startWith } from 'rxjs';
|
||||
import { startWith } from 'rxjs';
|
||||
import {
|
||||
PlaylistInfoComponent,
|
||||
PlaylistRefreshActionService,
|
||||
@@ -16,8 +15,6 @@ import {
|
||||
WorkspaceHeaderContextService,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import {
|
||||
isStalkerAccountPlaylist,
|
||||
isXtreamAccountPlaylist,
|
||||
@@ -25,24 +22,15 @@ import {
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
WorkspaceAccountInfoData,
|
||||
WorkspacePortalContext,
|
||||
WORKSPACE_SHELL_ACTIONS,
|
||||
} from '@iptvnator/workspace/shell/util';
|
||||
import {
|
||||
CLEAR_RECENTLY_VIEWED_ARIA,
|
||||
CLEAR_RECENTLY_VIEWED_TOOLTIP,
|
||||
WorkspaceHeaderBulkAction,
|
||||
WorkspaceHeaderSidebarToggle,
|
||||
} from './helpers/workspace-shell-constants';
|
||||
import { bumpRefreshQueryParam } from './helpers/workspace-shell-route-utils';
|
||||
import { WorkspaceHeaderSidebarToggle } from './helpers/workspace-shell-constants';
|
||||
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceShellHeaderService {
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly workspaceActions = inject(WORKSPACE_SHELL_ACTIONS);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
@@ -128,33 +116,6 @@ export class WorkspaceShellHeaderService {
|
||||
)
|
||||
);
|
||||
readonly isRefreshingPlaylist = this.playlistRefreshAction.isRefreshing;
|
||||
readonly headerBulkAction = computed<WorkspaceHeaderBulkAction | null>(
|
||||
() => {
|
||||
this.languageTick();
|
||||
|
||||
const context = this.routeState.currentContext();
|
||||
const section = this.routeState.currentSection();
|
||||
|
||||
if (!context || section !== 'recent') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
context.provider !== 'xtreams' &&
|
||||
context.provider !== 'stalker' &&
|
||||
context.provider !== 'playlists'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
icon: 'delete_sweep',
|
||||
tooltip: this.translateText(CLEAR_RECENTLY_VIEWED_TOOLTIP),
|
||||
ariaLabel: this.translateText(CLEAR_RECENTLY_VIEWED_ARIA),
|
||||
disabled: this.isRecentCleanupDisabled(context.provider),
|
||||
};
|
||||
}
|
||||
);
|
||||
readonly playlistSubtitle = computed(() => {
|
||||
this.languageTick();
|
||||
|
||||
@@ -200,61 +161,6 @@ export class WorkspaceShellHeaderService {
|
||||
this.workspaceActions.openGlobalRecent();
|
||||
}
|
||||
|
||||
async runHeaderBulkAction(): Promise<void> {
|
||||
const context = this.routeState.currentContext();
|
||||
const section = this.routeState.currentSection();
|
||||
|
||||
if (!context || section !== 'recent') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.provider === 'xtreams') {
|
||||
this.xtreamStore.clearRecentItems({ id: context.playlistId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.provider === 'stalker') {
|
||||
const updatedPlaylist = await firstValueFrom(
|
||||
this.playlistsService.clearPortalRecentlyViewed(
|
||||
context.playlistId
|
||||
)
|
||||
);
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: {
|
||||
_id: context.playlistId,
|
||||
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
|
||||
} as PlaylistMeta,
|
||||
})
|
||||
);
|
||||
bumpRefreshQueryParam(
|
||||
this.router,
|
||||
this.routeState.currentUrl()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.provider === 'playlists') {
|
||||
const updatedPlaylist = await firstValueFrom(
|
||||
this.playlistsService.clearM3uRecentlyViewed(
|
||||
context.playlistId
|
||||
)
|
||||
);
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: {
|
||||
_id: context.playlistId,
|
||||
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
|
||||
} as PlaylistMeta,
|
||||
})
|
||||
);
|
||||
bumpRefreshQueryParam(
|
||||
this.router,
|
||||
this.routeState.currentUrl()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
toggleLiveSidebar(): void {
|
||||
const surface = this.liveSidebarSurface();
|
||||
if (surface) {
|
||||
@@ -343,22 +249,6 @@ export class WorkspaceShellHeaderService {
|
||||
this.playlistRefreshAction.refresh(playlist);
|
||||
}
|
||||
|
||||
private isRecentCleanupDisabled(
|
||||
provider: WorkspacePortalContext['provider']
|
||||
): boolean {
|
||||
if (provider === 'xtreams') {
|
||||
return this.xtreamStore.recentItems().length === 0;
|
||||
}
|
||||
|
||||
if (provider === 'playlists') {
|
||||
return (
|
||||
this.routeState.activePlaylist()?.recentlyViewed?.length ?? 0
|
||||
) === 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private translateText(
|
||||
key: string,
|
||||
params?: Record<string, string | number>
|
||||
|
||||
-37
@@ -19,7 +19,6 @@ import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
DownloadsService,
|
||||
PlaylistsService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
@@ -138,10 +137,6 @@ describe('WorkspaceShellFacade', () => {
|
||||
isActive: jest.Mock;
|
||||
lastSuccessfulNavigation: () => { trigger: string };
|
||||
};
|
||||
let playlistsService: {
|
||||
clearPortalRecentlyViewed: jest.Mock;
|
||||
clearM3uRecentlyViewed: jest.Mock;
|
||||
};
|
||||
let workspaceActions: {
|
||||
openAddPlaylistDialog: jest.Mock;
|
||||
openGlobalSearch: jest.Mock;
|
||||
@@ -225,14 +220,6 @@ describe('WorkspaceShellFacade', () => {
|
||||
isActive: jest.fn(),
|
||||
lastSuccessfulNavigation: () => ({ trigger: navigationTrigger }),
|
||||
};
|
||||
playlistsService = {
|
||||
clearPortalRecentlyViewed: jest
|
||||
.fn()
|
||||
.mockReturnValue(of({ recentlyViewed: [] })),
|
||||
clearM3uRecentlyViewed: jest
|
||||
.fn()
|
||||
.mockReturnValue(of({ recentlyViewed: [] })),
|
||||
};
|
||||
workspaceActions = {
|
||||
openAddPlaylistDialog: jest.fn(),
|
||||
openGlobalSearch: jest.fn(),
|
||||
@@ -328,10 +315,6 @@ describe('WorkspaceShellFacade', () => {
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: runtime,
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: playlistsService,
|
||||
},
|
||||
{
|
||||
provide: DownloadsService,
|
||||
useValue: {
|
||||
@@ -647,26 +630,6 @@ describe('WorkspaceShellFacade', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('clears stalker recent items and refreshes the route', async () => {
|
||||
facade.currentUrl.set('/workspace/stalker/pl-1/recent');
|
||||
router.navigateByUrl.mockClear();
|
||||
|
||||
await facade.runHeaderBulkAction();
|
||||
|
||||
expect(playlistsService.clearPortalRecentlyViewed).toHaveBeenCalledWith(
|
||||
'pl-1'
|
||||
);
|
||||
expect(storeDispatch).toHaveBeenCalled();
|
||||
expect(router.navigateByUrl).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/^\/workspace\/stalker\/pl-1\/recent\?refresh=/
|
||||
),
|
||||
{
|
||||
replaceUrl: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes loaded-only status for stalker itv searches', () => {
|
||||
facade.currentUrl.set('/workspace/stalker/pl-1/itv?q=cnn');
|
||||
searchSync.syncSearchFromRoute();
|
||||
|
||||
-7
@@ -28,8 +28,6 @@ import { WorkspaceShellSearchService } from './workspace-shell-search.service';
|
||||
import { WorkspaceShellXtreamImportService } from './workspace-shell-xtream-import.service';
|
||||
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
|
||||
|
||||
export type { WorkspaceHeaderBulkAction } from './helpers/workspace-shell-constants';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceShellFacade {
|
||||
private readonly router = inject(Router);
|
||||
@@ -134,7 +132,6 @@ export class WorkspaceShellFacade {
|
||||
readonly canOpenAccountInfo = this.header.canOpenAccountInfo;
|
||||
readonly canRefreshPlaylist = this.header.canRefreshPlaylist;
|
||||
readonly isRefreshingPlaylist = this.header.isRefreshingPlaylist;
|
||||
readonly headerBulkAction = this.header.headerBulkAction;
|
||||
readonly headerSidebarToggle = this.header.headerSidebarToggle;
|
||||
readonly playlistSubtitle = this.header.playlistSubtitle;
|
||||
readonly activeDownloadsCount = computed(() =>
|
||||
@@ -210,10 +207,6 @@ export class WorkspaceShellFacade {
|
||||
);
|
||||
}
|
||||
|
||||
runHeaderBulkAction(): Promise<void> {
|
||||
return this.header.runHeaderBulkAction();
|
||||
}
|
||||
|
||||
toggleLiveSidebar(): void {
|
||||
this.header.toggleLiveSidebar();
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
[searchScopeLabel]="facade.searchScopeLabel()"
|
||||
[searchStatusLabel]="facade.searchStatusLabel()"
|
||||
[headerShortcut]="facade.headerShortcut()"
|
||||
[headerBulkAction]="facade.headerBulkAction()"
|
||||
[headerSidebarToggle]="facade.headerSidebarToggle()"
|
||||
[canRefreshPlaylist]="facade.canRefreshPlaylist()"
|
||||
[isRefreshingPlaylist]="facade.isRefreshingPlaylist()"
|
||||
@@ -64,7 +63,6 @@
|
||||
(shortcutsRequested)="keyboardShortcuts.openShortcutsDialog()"
|
||||
(addPlaylistRequested)="facade.openAddPlaylistDialog()"
|
||||
(headerShortcutRequested)="facade.runHeaderShortcut()"
|
||||
(headerBulkActionRequested)="facade.runHeaderBulkAction()"
|
||||
(headerSidebarToggleRequested)="facade.toggleLiveSidebar()"
|
||||
(parentalLockToggleRequested)="facade.toggleParentalLock()"
|
||||
(downloadsRequested)="facade.openDownloadsShortcut()"
|
||||
|
||||
+1
-8
@@ -20,10 +20,7 @@ import {
|
||||
} from '@iptvnator/workspace/shell/util';
|
||||
import { TrafficLightsClearanceDirective } from './traffic-lights-clearance.directive';
|
||||
import { WorkspaceShellComponent } from './workspace-shell.component';
|
||||
import {
|
||||
WorkspaceHeaderBulkAction,
|
||||
WorkspaceShellFacade,
|
||||
} from './services/workspace-shell.facade';
|
||||
import { WorkspaceShellFacade } from './services/workspace-shell.facade';
|
||||
import { WorkspaceKeyboardShortcutsService } from '../workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service';
|
||||
|
||||
@Component({
|
||||
@@ -71,7 +68,6 @@ class MockWorkspaceShellHeaderComponent {
|
||||
readonly isContextDrawerOpen = input(false);
|
||||
readonly contextDrawerToggleAriaKey = input('');
|
||||
readonly contextDrawerTooltipKey = input('');
|
||||
readonly headerBulkAction = input<WorkspaceHeaderBulkAction | null>(null);
|
||||
readonly headerSidebarToggle = input<unknown>(null);
|
||||
readonly parentalLockState = input<'off' | 'locked' | 'unlocked'>('off');
|
||||
readonly backRequested = output<void>();
|
||||
@@ -83,7 +79,6 @@ class MockWorkspaceShellHeaderComponent {
|
||||
readonly headerShortcutRequested = output<void>();
|
||||
readonly refreshPlaylistRequested = output<void>();
|
||||
readonly downloadsRequested = output<void>();
|
||||
readonly headerBulkActionRequested = output<void>();
|
||||
readonly headerSidebarToggleRequested = output<void>();
|
||||
readonly parentalLockToggleRequested = output<void>();
|
||||
readonly playlistInfoRequested = output<void>();
|
||||
@@ -177,7 +172,6 @@ class MockWorkspaceShellFacade {
|
||||
readonly hasNoPlaylists = signal(false);
|
||||
readonly isDownloadsView = signal(false);
|
||||
readonly activeDownloadsCount = signal(3);
|
||||
readonly headerBulkAction = signal<WorkspaceHeaderBulkAction | null>(null);
|
||||
readonly headerSidebarToggle = signal(null);
|
||||
toggleLiveSidebar = jest.fn();
|
||||
readonly parentalLockState = signal<'off' | 'locked' | 'unlocked'>('off');
|
||||
@@ -226,7 +220,6 @@ class MockWorkspaceShellFacade {
|
||||
runHeaderShortcut = jest.fn();
|
||||
refreshCurrentPlaylist = jest.fn();
|
||||
openDownloadsShortcut = jest.fn();
|
||||
runHeaderBulkAction = jest.fn();
|
||||
openPlaylistInfo = jest.fn();
|
||||
openAccountInfo = jest.fn();
|
||||
openAccountInfoFor = jest.fn();
|
||||
|
||||
Reference in new issue
Block a user