feat: enhance routing with reactive route parameter signals and update catalog facade for search query handling

This commit is contained in:
4gray committed 2026-04-06 12:58:06 +02:00
1 parent c58c7468e2
commit 5a070f5550
7 files changed
+374 -85

No files matched your search

@@ -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 {
@@ -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<UnifiedCollectionPageComponent>;
let route: ActivatedRoute & {
snapshot: {
paramMap: ReturnType<typeof convertToParamMap>;
queryParamMap: ReturnType<typeof convertToParamMap>;
queryParams: Record<string, unknown>;
params: Record<string, unknown>;
data: Record<string, unknown>;
parent: null;
};
paramMap: ReturnType<
BehaviorSubject<ReturnType<typeof convertToParamMap>>['asObservable']
>;
queryParamMap: ReturnType<
BehaviorSubject<ReturnType<typeof convertToParamMap>>['asObservable']
>;
pathFromRoot: ActivatedRoute[];
};
let routeParamMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let routeQueryParamMap$: BehaviorSubject<
ReturnType<typeof convertToParamMap>
>;
let workspaceParamMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
const playlistsLoaded = signal(false);
const playlists = signal<PlaylistMeta[]>([]);
const favoritesData = {
@@ -81,36 +94,83 @@ describe('UnifiedCollectionPageComponent', () => {
clearRecentItems: jest.fn(),
};
function toParamMapRecord(
values: Record<string, unknown>
): Record<string, string> {
return Object.fromEntries(
Object.entries(values)
.filter(([, value]) => value != null)
.map(([key, value]) => [key, String(value)])
);
}
function setRouteParams(params: Record<string, unknown>): void {
const nextParamMap = convertToParamMap(toParamMapRecord(params));
route.snapshot.params = params;
route.snapshot.paramMap = nextParamMap;
routeParamMap$.next(nextParamMap);
}
function setRouteQueryParams(queryParams: Record<string, unknown>): 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<typeof convertToParamMap>;
queryParamMap: ReturnType<typeof convertToParamMap>;
queryParams: Record<string, unknown>;
params: Record<string, unknown>;
data: Record<string, unknown>;
parent: null;
};
paramMap: ReturnType<
BehaviorSubject<
ReturnType<typeof convertToParamMap>
>['asObservable']
>;
queryParamMap: ReturnType<
BehaviorSubject<
ReturnType<typeof convertToParamMap>
>['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'
);
});
});
@@ -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<string>();
readonly playlistIdInput = input<string | undefined>(undefined, {
alias: 'playlistId',
});
readonly defaultScope = input<CollectionScope>();
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<CollectionScope | null>(
this.route,
'scope',
(value) =>
value === 'all' || value === 'playlist' ? value : null
);
private readonly routeSearchTerm = queryParamSignal(
this.route,
'q',
(value) => (value ?? '').trim()
);
private readonly routePlaylistId = routeParamSignal<string | undefined>(
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<CollectionScope>('playlist');
private readonly persistedScope = computed(() =>
this.scopeService.getScope(this.scopeKey())()
);
readonly scope = linkedSignal<CollectionScope>(() => {
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<CollectionScope>(() =>
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()) {
@@ -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();
});
});
@@ -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<T> = (value: string | null) => T;
type RouteParamNormalizer<T> = (value: string | null) => T;
const PORTAL_SECTIONS = new Set<PortalRailSection>([
'all',
@@ -54,6 +55,37 @@ export function queryParamSignal<T = string>(
);
}
export function routeParamSignal<T = string>(
route: ActivatedRoute,
key: string,
normalizer?: RouteParamNormalizer<T>
): Signal<T> {
const normalize =
normalizer ??
(((
value: string | null
) => (value ?? '') as T) as RouteParamNormalizer<T>);
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(
@@ -46,6 +46,7 @@ export interface PortalCatalogFacade<
readonly playlist: Signal<PortalCatalogPlaylistMeta | null>;
initialize(categoryId?: string | null): void;
setSearchQuery?(query: string): void;
clearSelectedItem(): void;
setPage(page: number): void;
setLimit(limit: number): void;
@@ -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';
<app-unified-collection-page
[mode]="mode()"
[portalType]="portalType()"
[playlistId]="playlistId()"
/>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StalkerCollectionRouteComponent {
private readonly route = inject(ActivatedRoute);
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly portalType = input('stalker');
readonly playlistId = routeParamSignal<string | undefined>(
this.route,
'id',
(value) => value ?? undefined
);
}