diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts index 7d17b2822..0f4dd309e 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts @@ -1,5 +1,12 @@ import { NgComponentOutlet } from '@angular/common'; -import { Component, computed, inject, OnInit } from '@angular/core'; +import { + Component, + computed, + DestroyRef, + inject, + OnInit, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatIconButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; @@ -49,6 +56,7 @@ interface CategoryContentItem { }) export class CategoryContentViewComponent implements OnInit { private readonly activatedRoute = inject(ActivatedRoute); + private readonly destroyRef = inject(DestroyRef); private readonly router = inject(Router); private readonly translate = inject(TranslateService); private readonly catalog = inject(PORTAL_CATALOG_FACADE) as PortalCatalogFacade< @@ -102,9 +110,18 @@ export class CategoryContentViewComponent implements OnInit { } ngOnInit(): void { - const { categoryId } = this.activatedRoute.snapshot.params; - this.catalog.initialize(categoryId ?? null); - this.openStalkerItemFromNavigationState(); + this.activatedRoute.paramMap + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((params) => { + this.catalog.initialize(params.get('categoryId')); + this.openStalkerItemFromNavigationState(); + }); + + this.activatedRoute.queryParamMap + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((params) => { + this.catalog.setSearchQuery?.(params.get('q') ?? ''); + }); } onPageChange(event: PageEvent): void { diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts index 68b364243..a667136db 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts @@ -20,7 +20,7 @@ import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag, } from 'm3u-state'; -import { of } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; import { PlaylistMeta } from 'shared-interfaces'; import { UnifiedCollectionPageComponent } from './unified-collection-page.component'; import { UnifiedGridTabComponent } from './unified-grid-tab.component'; @@ -61,13 +61,26 @@ describe('UnifiedCollectionPageComponent', () => { let fixture: ComponentFixture; let route: ActivatedRoute & { snapshot: { + paramMap: ReturnType; queryParamMap: ReturnType; queryParams: Record; params: Record; data: Record; parent: null; }; + paramMap: ReturnType< + BehaviorSubject>['asObservable'] + >; + queryParamMap: ReturnType< + BehaviorSubject>['asObservable'] + >; + pathFromRoot: ActivatedRoute[]; }; + let routeParamMap$: BehaviorSubject>; + let routeQueryParamMap$: BehaviorSubject< + ReturnType + >; + let workspaceParamMap$: BehaviorSubject>; const playlistsLoaded = signal(false); const playlists = signal([]); const favoritesData = { @@ -81,36 +94,83 @@ describe('UnifiedCollectionPageComponent', () => { clearRecentItems: jest.fn(), }; + function toParamMapRecord( + values: Record + ): Record { + return Object.fromEntries( + Object.entries(values) + .filter(([, value]) => value != null) + .map(([key, value]) => [key, String(value)]) + ); + } + + function setRouteParams(params: Record): void { + const nextParamMap = convertToParamMap(toParamMapRecord(params)); + route.snapshot.params = params; + route.snapshot.paramMap = nextParamMap; + routeParamMap$.next(nextParamMap); + } + + function setRouteQueryParams(queryParams: Record): void { + const nextQueryParamMap = convertToParamMap( + toParamMapRecord(queryParams) + ); + route.snapshot.queryParams = queryParams; + route.snapshot.queryParamMap = nextQueryParamMap; + routeQueryParamMap$.next(nextQueryParamMap); + } + beforeEach(async () => { playlistsLoaded.set(false); playlists.set([]); jest.clearAllMocks(); + routeParamMap$ = new BehaviorSubject(convertToParamMap({})); + routeQueryParamMap$ = new BehaviorSubject(convertToParamMap({})); + workspaceParamMap$ = new BehaviorSubject(convertToParamMap({})); + + const workspaceRoute = { + snapshot: { + data: { layout: 'workspace' }, + paramMap: convertToParamMap({}), + params: {}, + }, + paramMap: workspaceParamMap$.asObservable(), + } as ActivatedRoute; route = { snapshot: { + paramMap: convertToParamMap({}), queryParamMap: convertToParamMap({}), queryParams: {}, params: {}, data: {}, parent: null, }, - queryParamMap: of(convertToParamMap({})), - pathFromRoot: [ - { - snapshot: { - data: { layout: 'workspace' }, - }, - }, - ], + paramMap: routeParamMap$.asObservable(), + queryParamMap: routeQueryParamMap$.asObservable(), + pathFromRoot: [], } as ActivatedRoute & { snapshot: { + paramMap: ReturnType; queryParamMap: ReturnType; queryParams: Record; params: Record; data: Record; parent: null; }; + paramMap: ReturnType< + BehaviorSubject< + ReturnType + >['asObservable'] + >; + queryParamMap: ReturnType< + BehaviorSubject< + ReturnType + >['asObservable'] + >; + pathFromRoot: ActivatedRoute[]; }; + route.pathFromRoot = [workspaceRoute, route]; await TestBed.configureTestingModule({ imports: [ @@ -205,9 +265,8 @@ describe('UnifiedCollectionPageComponent', () => { }); it('does not reload when local item state changes on empty playlist favorites', async () => { - route.snapshot.params = { id: 'playlist-1' }; - route.snapshot.queryParams = { scope: 'playlist' }; - route.snapshot.queryParamMap = convertToParamMap({ scope: 'playlist' }); + setRouteParams({ id: 'playlist-1' }); + setRouteQueryParams({ scope: 'playlist' }); playlistsLoaded.set(true); playlists.set([ { @@ -239,4 +298,63 @@ describe('UnifiedCollectionPageComponent', () => { expect(favoritesData.getFavorites).toHaveBeenCalledTimes(1); }); + + it('reloads favorites when the playlist id changes in place', async () => { + setRouteParams({ id: 'playlist-1' }); + setRouteQueryParams({ scope: 'playlist' }); + playlistsLoaded.set(true); + fixture.componentRef.setInput('portalType', 'm3u'); + fixture.componentRef.setInput('defaultScope', undefined); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(favoritesData.getFavorites).toHaveBeenCalledTimes(1); + expect(favoritesData.getFavorites).toHaveBeenLastCalledWith( + 'playlist', + 'playlist-1', + 'm3u' + ); + + setRouteParams({ id: 'playlist-2' }); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(favoritesData.getFavorites).toHaveBeenCalledTimes(2); + expect(favoritesData.getFavorites).toHaveBeenLastCalledWith( + 'playlist', + 'playlist-2', + 'm3u' + ); + }); + + it('reloads recent items when the playlist id changes in place', async () => { + setRouteParams({ id: 'playlist-1' }); + setRouteQueryParams({ scope: 'playlist' }); + playlistsLoaded.set(true); + fixture.componentRef.setInput('mode', 'recent'); + fixture.componentRef.setInput('portalType', 'm3u'); + fixture.componentRef.setInput('defaultScope', undefined); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(recentData.getRecentItems).toHaveBeenCalledTimes(1); + expect(recentData.getRecentItems).toHaveBeenLastCalledWith( + 'playlist', + 'playlist-1', + 'm3u' + ); + + setRouteParams({ id: 'playlist-2' }); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(recentData.getRecentItems).toHaveBeenCalledTimes(2); + expect(recentData.getRecentItems).toHaveBeenLastCalledWith( + 'playlist', + 'playlist-2', + 'm3u' + ); + }); }); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts index 7474396cf..a249ab2c3 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts @@ -2,17 +2,13 @@ import { ChangeDetectionStrategy, Component, computed, - DestroyRef, + effect, inject, - Injector, + linkedSignal, input, - OnInit, signal, + untracked, } from '@angular/core'; -import { - takeUntilDestroyed, - toObservable, -} from '@angular/core/rxjs-interop'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatIconButton } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; @@ -20,7 +16,6 @@ import { MatTooltip } from '@angular/material/tooltip'; import { ActivatedRoute } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslatePipe } from '@ngx-translate/core'; -import { distinctUntilChanged } from 'rxjs'; import { clearNavigationStateKeys, CollectionContentType, @@ -29,6 +24,7 @@ import { isWorkspaceLayoutRoute, OPEN_LIVE_COLLECTION_ITEM_STATE_KEY, queryParamSignal, + routeParamSignal, ScopeToggleService, UnifiedCollectionItem, UnifiedFavoritesDataService, @@ -56,14 +52,15 @@ import { UnifiedGridTabComponent } from './unified-grid-tab.component'; UnifiedLiveTabComponent, ], }) -export class UnifiedCollectionPageComponent implements OnInit { +export class UnifiedCollectionPageComponent { readonly mode = input<'favorites' | 'recent'>('favorites'); readonly portalType = input(); + readonly playlistIdInput = input(undefined, { + alias: 'playlistId', + }); readonly defaultScope = input(); private readonly route = inject(ActivatedRoute); - private readonly destroyRef = inject(DestroyRef); - private readonly injector = inject(Injector); private readonly store = inject(Store); private readonly scopeService = inject(ScopeToggleService); private readonly favoritesData = inject(UnifiedFavoritesDataService); @@ -73,11 +70,25 @@ export class UnifiedCollectionPageComponent implements OnInit { selectPlaylistsLoadingFlag ); readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route); + private readonly queryScope = queryParamSignal( + this.route, + 'scope', + (value) => + value === 'all' || value === 'playlist' ? value : null + ); private readonly routeSearchTerm = queryParamSignal( this.route, 'q', (value) => (value ?? '').trim() ); + private readonly routePlaylistId = routeParamSignal( + this.route, + 'id', + (value) => value ?? undefined + ); + readonly playlistId = computed( + () => this.playlistIdInput() ?? this.routePlaylistId() + ); readonly workspaceSearchTerm = computed(() => this.isWorkspaceLayout ? this.routeSearchTerm() : '' ); @@ -92,19 +103,27 @@ export class UnifiedCollectionPageComponent implements OnInit { readonly skeletonRows = Array.from({ length: 12 }, (_, i) => i); readonly skeletonCards = Array.from({ length: 8 }, (_, i) => i); - readonly playlistId = computed(() => { - let current = this.route.snapshot; - while (current) { - if (current.params['id']) { - return current.params['id'] as string; - } - current = current.parent!; - } - return undefined; - }); - readonly scopeKey = computed(() => this.mode()); - readonly scope = signal('playlist'); + private readonly persistedScope = computed(() => + this.scopeService.getScope(this.scopeKey())() + ); + readonly scope = linkedSignal(() => { + if (!this.showScopeToggle()) { + return 'all'; + } + + const queryScope = this.queryScope(); + if (queryScope) { + return queryScope; + } + + const defaultScope = this.defaultScope(); + if (defaultScope) { + return defaultScope; + } + + return this.persistedScope(); + }); readonly showScopeToggle = computed(() => Boolean(this.playlistId())); readonly effectiveScope = computed(() => this.showScopeToggle() ? this.scope() : 'all' @@ -171,45 +190,17 @@ export class UnifiedCollectionPageComponent implements OnInit { private loadRequestId = 0; - ngOnInit(): void { - const queryScope = this.route.snapshot.queryParams['scope'] as - | CollectionScope - | undefined; - if ( - this.showScopeToggle() && - (queryScope === 'all' || queryScope === 'playlist') - ) { - this.scope.set(queryScope); - } else if (this.defaultScope()) { - this.scope.set(this.defaultScope()!); - } else { - const persisted = this.scopeService.getScope(this.scopeKey()); - this.scope.set(persisted()); - } - - toObservable(this.loadRequest, { - injector: this.injector, - }) - .pipe( - distinctUntilChanged( - (previous, current) => - previous.mode === current.mode && - previous.portalType === current.portalType && - previous.playlistId === current.playlistId && - previous.scope === current.scope && - previous.reloadKey === current.reloadKey - ), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(({ mode, portalType, playlistId, scope }) => { - void this.loadData({ - mode, - portalType, - playlistId, - scope, - }); + private readonly loadEffect = effect(() => { + const { mode, portalType, playlistId, scope } = this.loadRequest(); + untracked(() => { + void this.loadData({ + mode, + portalType, + playlistId, + scope, }); - } + }); + }); onScopeChange(value: CollectionScope): void { if (!this.showScopeToggle()) { diff --git a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts index dfd4eefa9..a4881be07 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts @@ -11,6 +11,7 @@ import { extractPortalSection, isWorkspaceLayoutRoute, queryParamSignal, + routeParamSignal, resolveCurrentPortalPlaylistId, resolveCurrentPortalSection, } from './portal-route.utils'; @@ -226,4 +227,110 @@ describe('portal-route.utils', () => { childInjector.destroy(); }); + + it('creates route-param signals that react to current route param changes', () => { + TestBed.configureTestingModule({}); + + const parentInjector = TestBed.inject(EnvironmentInjector); + const childInjector = createEnvironmentInjector([], parentInjector); + const paramMap$ = new BehaviorSubject( + convertToParamMap({ + id: 'playlist-1', + }) + ); + const route = { + snapshot: { + data: {}, + paramMap: convertToParamMap({ + id: 'playlist-1', + }), + params: { + id: 'playlist-1', + }, + }, + paramMap: paramMap$.asObservable(), + } as unknown as ActivatedRoute; + + const playlistId = runInInjectionContext(childInjector, () => + routeParamSignal(route, 'id', (value) => value ?? '') + ); + + expect(playlistId()).toBe('playlist-1'); + + route.snapshot.paramMap = convertToParamMap({ + id: 'playlist-2', + }); + route.snapshot.params = { + id: 'playlist-2', + }; + paramMap$.next( + convertToParamMap({ + id: 'playlist-2', + }) + ); + + expect(playlistId()).toBe('playlist-2'); + + childInjector.destroy(); + }); + + it('creates route-param signals that react to ancestor route param changes', () => { + TestBed.configureTestingModule({}); + + const parentInjector = TestBed.inject(EnvironmentInjector); + const childInjector = createEnvironmentInjector([], parentInjector); + const parentParamMap$ = new BehaviorSubject( + convertToParamMap({ + id: 'playlist-1', + }) + ); + const childParamMap$ = new BehaviorSubject(convertToParamMap({})); + const parentRoute = { + snapshot: { + data: {}, + paramMap: convertToParamMap({ + id: 'playlist-1', + }), + params: { + id: 'playlist-1', + }, + }, + paramMap: parentParamMap$.asObservable(), + } as ActivatedRoute; + const route = { + snapshot: { + data: {}, + paramMap: convertToParamMap({}), + params: {}, + }, + paramMap: childParamMap$.asObservable(), + pathFromRoot: [] as ActivatedRoute[], + } as ActivatedRoute & { + pathFromRoot: ActivatedRoute[]; + }; + route.pathFromRoot = [parentRoute, route]; + + const playlistId = runInInjectionContext(childInjector, () => + routeParamSignal(route, 'id', (value) => value ?? '') + ); + + expect(playlistId()).toBe('playlist-1'); + + (parentRoute.snapshot as ActivatedRoute['snapshot']).paramMap = + convertToParamMap({ + id: 'playlist-2', + }); + (parentRoute.snapshot as ActivatedRoute['snapshot']).params = { + id: 'playlist-2', + }; + parentParamMap$.next( + convertToParamMap({ + id: 'playlist-2', + }) + ); + + expect(playlistId()).toBe('playlist-2'); + + childInjector.destroy(); + }); }); diff --git a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts index 1cdea2834..9a2c81235 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts @@ -1,13 +1,14 @@ import { Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute } from '@angular/router'; -import { map } from 'rxjs'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { combineLatest, map, of } from 'rxjs'; import { PortalProvider, PortalRailSection, } from './portal-rail-links'; type QueryParamNormalizer = (value: string | null) => T; +type RouteParamNormalizer = (value: string | null) => T; const PORTAL_SECTIONS = new Set([ 'all', @@ -54,6 +55,37 @@ export function queryParamSignal( ); } +export function routeParamSignal( + route: ActivatedRoute, + key: string, + normalizer?: RouteParamNormalizer +): Signal { + const normalize = + normalizer ?? + ((( + value: string | null + ) => (value ?? '') as T) as RouteParamNormalizer); + const routeChain = + Array.isArray(route.pathFromRoot) && route.pathFromRoot.length > 0 + ? route.pathFromRoot + : [route]; + + return toSignal( + combineLatest( + routeChain.map((currentRoute) => + currentRoute.paramMap ?? + of( + currentRoute.snapshot.paramMap ?? + convertToParamMap(currentRoute.snapshot.params ?? {}) + ) + ) + ).pipe(map(() => normalize(resolveCurrentRouteParam(route, key)))), + { + initialValue: normalize(resolveCurrentRouteParam(route, key)), + } + ); +} + export function extractPortalSection( url: string, provider: PortalProvider @@ -76,6 +108,18 @@ export function resolveCurrentPortalPlaylistId( route: ActivatedRoute, routerUrl: string, provider: PortalProvider +): string | null { + const resolved = resolveCurrentRouteParam(route, 'id'); + if (resolved) { + return resolved; + } + + return extractPortalPlaylistId(routerUrl, provider); +} + +function resolveCurrentRouteParam( + route: ActivatedRoute, + key: string ): string | null { const routeChain = Array.isArray(route.pathFromRoot) && route.pathFromRoot.length > 0 @@ -84,15 +128,15 @@ export function resolveCurrentPortalPlaylistId( for (let index = routeChain.length - 1; index >= 0; index -= 1) { const currentRoute = routeChain[index]; - const playlistId = - currentRoute.snapshot.paramMap.get('id') ?? - currentRoute.snapshot.params['id']; - if (playlistId) { - return playlistId; + const value = + currentRoute.snapshot.paramMap?.get(key) ?? + currentRoute.snapshot.params?.[key]; + if (value) { + return value; } } - return extractPortalPlaylistId(routerUrl, provider); + return null; } export function resolveCurrentPortalSection( diff --git a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts index a7f6c4957..debb0ae5a 100644 --- a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts +++ b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts @@ -46,6 +46,7 @@ export interface PortalCatalogFacade< readonly playlist: Signal; initialize(categoryId?: string | null): void; + setSearchQuery?(query: string): void; clearSelectedItem(): void; setPage(page: number): void; setLimit(limit: number): void; diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-route.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-route.component.ts index a3fa11a89..c8fab48e0 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-route.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-route.component.ts @@ -1,9 +1,12 @@ import { + inject, ChangeDetectionStrategy, Component, input, } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { UnifiedCollectionPageComponent } from '@iptvnator/portal/shared/ui'; +import { routeParamSignal } from '@iptvnator/portal/shared/util'; @Component({ selector: 'app-stalker-collection-route', @@ -12,11 +15,19 @@ import { UnifiedCollectionPageComponent } from '@iptvnator/portal/shared/ui'; `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class StalkerCollectionRouteComponent { + private readonly route = inject(ActivatedRoute); + readonly mode = input<'favorites' | 'recent'>('favorites'); readonly portalType = input('stalker'); + readonly playlistId = routeParamSignal( + this.route, + 'id', + (value) => value ?? undefined + ); }