feat(portal): add category sorting controls (#940)

* feat(portal): add category sort modes

* fix(portal): address category sort review feedback
This commit is contained in:
4gray authored and GitHub committed 2026-05-15 00:28:32 +02:00
1 parent 2a5b1d1da7
commit 8232a86a1e
34 files changed
+579 -51

No files matched your search

+1
View File
@@ -1,4 +1,5 @@
export * from './lib/logger';
export * from './lib/category-sort';
export * from './lib/channel-sort';
export * from './lib/favorites-channel-sort';
export * from './lib/portal-catalog-detail';
@@ -0,0 +1,108 @@
import {
DEFAULT_PORTAL_CATEGORY_SORT_MODE,
WORKSPACE_CATEGORY_SORT_STORAGE_KEY,
isPortalCategorySortMode,
persistPortalCategorySortMode,
restorePortalCategorySortMode,
sortPortalCategoryItems,
} from './category-sort';
describe('portal category sort', () => {
beforeEach(() => {
localStorage.removeItem(WORKSPACE_CATEGORY_SORT_STORAGE_KEY);
});
it('defaults to server sorting and ignores invalid stored values', () => {
expect(DEFAULT_PORTAL_CATEGORY_SORT_MODE).toBe('server');
expect(restorePortalCategorySortMode()).toBe('server');
localStorage.setItem(WORKSPACE_CATEGORY_SORT_STORAGE_KEY, 'random');
expect(restorePortalCategorySortMode()).toBe('server');
});
it('persists and restores valid category sort modes', () => {
persistPortalCategorySortMode('name-desc');
expect(localStorage.getItem(WORKSPACE_CATEGORY_SORT_STORAGE_KEY)).toBe(
'name-desc'
);
expect(restorePortalCategorySortMode()).toBe('name-desc');
});
it('recognizes valid category sort modes', () => {
expect(isPortalCategorySortMode('server')).toBe(true);
expect(isPortalCategorySortMode('name-asc')).toBe(true);
expect(isPortalCategorySortMode('name-desc')).toBe(true);
expect(isPortalCategorySortMode('date-desc')).toBe(false);
});
it('preserves input order for server sorting and supports A-Z/Z-A sorting', () => {
const categories = [
{ category_name: 'Zulu' },
{ category_name: 'Alpha' },
{ name: 'Movies' },
];
expect(
sortPortalCategoryItems(
categories,
'server',
(category) => category.category_name ?? category.name
)
).toBe(categories);
expect(
sortPortalCategoryItems(
categories,
'name-asc',
(category) => category.category_name ?? category.name
).map((category) => category.category_name ?? category.name)
).toEqual(['Alpha', 'Movies', 'Zulu']);
expect(
sortPortalCategoryItems(
categories,
'name-desc',
(category) => category.category_name ?? category.name
).map((category) => category.category_name ?? category.name)
).toEqual(['Zulu', 'Movies', 'Alpha']);
});
it('keeps pinned entries first when name sorting is active', () => {
const categories = [
{ category_id: '*', category_name: 'All Categories' },
{ category_id: 'z', category_name: 'Zulu' },
{ category_id: 'a', category_name: 'Alpha' },
];
expect(
sortPortalCategoryItems(
categories,
'name-desc',
(category) => category.category_name,
(category) => category.category_id === '*'
).map((category) => category.category_name)
).toEqual(['All Categories', 'Zulu', 'Alpha']);
});
it('ignores accidental provider whitespace around names when sorting', () => {
const categories = [
{ category_name: ' DENMARK' },
{ category_name: ' SPORTS | INDIA' },
{ category_name: '24/7 PAK DRAMA' },
{ category_name: 'AFGHANISTAN' },
];
expect(
sortPortalCategoryItems(
categories,
'name-asc',
(category) => category.category_name
).map((category) => category.category_name)
).toEqual([
'24/7 PAK DRAMA',
'AFGHANISTAN',
' DENMARK',
' SPORTS | INDIA',
]);
});
});
@@ -0,0 +1,57 @@
const CATEGORY_SORT_COLLATOR = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
});
export type PortalCategorySortMode = 'server' | 'name-asc' | 'name-desc';
export const DEFAULT_PORTAL_CATEGORY_SORT_MODE: PortalCategorySortMode =
'server';
export const WORKSPACE_CATEGORY_SORT_STORAGE_KEY =
'workspace-category-sort-mode';
export function isPortalCategorySortMode(
value: unknown
): value is PortalCategorySortMode {
return value === 'server' || value === 'name-asc' || value === 'name-desc';
}
export function restorePortalCategorySortMode(
storageKey: string = WORKSPACE_CATEGORY_SORT_STORAGE_KEY,
fallback: PortalCategorySortMode = DEFAULT_PORTAL_CATEGORY_SORT_MODE
): PortalCategorySortMode {
const storedValue = localStorage.getItem(storageKey);
return isPortalCategorySortMode(storedValue) ? storedValue : fallback;
}
export function persistPortalCategorySortMode(
mode: PortalCategorySortMode,
storageKey: string = WORKSPACE_CATEGORY_SORT_STORAGE_KEY
): void {
localStorage.setItem(storageKey, mode);
}
export function sortPortalCategoryItems<T>(
items: readonly T[],
mode: PortalCategorySortMode,
getDisplayName: (item: T) => string | null | undefined,
isPinnedFirst: (item: T) => boolean = () => false
): readonly T[] {
if (mode === 'server') {
return items;
}
const pinnedItems = items.filter(isPinnedFirst);
const sortableItems = items.filter((item) => !isPinnedFirst(item));
return pinnedItems.concat(
sortableItems.sort((a, b) => {
const result = CATEGORY_SORT_COLLATOR.compare(
(getDisplayName(a) ?? '').trim(),
(getDisplayName(b) ?? '').trim()
);
return mode === 'name-asc' ? result : -result;
})
);
}
@@ -183,6 +183,31 @@ describe('withStalkerContent failure states', () => {
});
});
it('preserves server category order while keeping the all category first', async () => {
dataService.sendIpcEvent.mockResolvedValue({
js: [
{ id: 'z', title: 'Zulu' },
{ id: 'a', title: 'Alpha' },
{ id: 'm', title: 'Movies' },
],
});
store.setSelectedContentType('vod');
store.setCurrentPlaylist(PLAYLIST);
void store.isCategoryResourceLoading();
await waitForCondition(
() => dataService.sendIpcEvent.mock.calls.length > 0
);
await flushResources();
expect(
store
.getCategoryResource()
.map((category) => category.category_name)
).toEqual(['PORTALS.ALL_CATEGORIES', 'Zulu', 'Alpha', 'Movies']);
});
it('normalizes content failures into empty collections instead of undefined state', async () => {
dataService.sendIpcEvent.mockRejectedValue(
new Error('get_ordered_list failed')
@@ -265,18 +265,12 @@ export function withStalkerContent() {
return [];
}
const normalizedCategories = response.js
.map(
(item): StalkerCategoryItem => ({
category_name: item.title ?? '',
category_id: String(item.id),
})
)
.sort((left, right) =>
left.category_name.localeCompare(
right.category_name
)
);
const normalizedCategories = response.js.map(
(item): StalkerCategoryItem => ({
category_name: item.title ?? '',
category_id: String(item.id),
})
);
const categories = prependAllCategory(
params.contentType,
params.contentType === 'radio' &&
@@ -303,9 +297,10 @@ export function withStalkerContent() {
error,
});
if (params.contentType === 'radio') {
const fallback = fallbackRadioCategories(
translateService
);
const fallback =
fallbackRadioCategories(
translateService
);
patchState(store, {
radioCategories: fallback,
categoryError: null,
@@ -6,6 +6,14 @@
"projectType": "library",
"tags": ["scope:workspace", "domain:workspace", "type:feature"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/workspace/shell/feature/jest.config.ts",
"tsConfig": "libs/workspace/shell/feature/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
@@ -27,7 +27,7 @@ interface WorkspaceCategoryViewItem {
styleUrl: './workspace-context-category-view.component.scss',
})
export class WorkspaceContextCategoryViewComponent {
readonly items = input<WorkspaceCategoryViewItem[]>([]);
readonly items = input<ReadonlyArray<WorkspaceCategoryViewItem>>([]);
readonly selectedCategoryId = input<string | number | null | undefined>();
readonly itemCounts = input<Map<number, number>>(new Map());
readonly showCounts = input(false);
@@ -18,6 +18,47 @@
<mat-icon>search</mat-icon>
</button>
}
@if (canSearchCategories()) {
<button
type="button"
mat-icon-button
class="context-header__action"
[class.context-header__action--active]="
categorySortMode() !== 'server'
"
[matMenuTriggerFor]="categorySortMenu"
[matTooltip]="
('WORKSPACE.SORT_LABEL' | translate) +
(categorySortLabelKey() | translate)
"
[attr.aria-label]="
'WORKSPACE.CATEGORY_SORT_ARIA' | translate
"
>
<mat-icon>{{ categorySortIcon() }}</mat-icon>
</button>
<mat-menu #categorySortMenu="matMenu">
@for (
option of categorySortOptions;
track option.mode
) {
<button
mat-menu-item
(click)="setCategorySortMode(option.mode)"
>
<mat-icon>{{ option.icon }}</mat-icon>
<span>{{
option.translationKey | translate
}}</span>
@if (categorySortMode() === option.mode) {
<mat-icon class="context-sort-check"
>check</mat-icon
>
}
</button>
}
</mat-menu>
}
@if (isXtreamCategories()) {
<button
type="button"
@@ -80,6 +80,10 @@
text-transform: uppercase;
}
.context-sort-check {
margin-left: auto;
}
.context-header p {
margin: 4px 0 0;
font-size: 0.78rem;
@@ -6,6 +6,7 @@ import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { MatDialog } from '@angular/material/dialog';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { WORKSPACE_CATEGORY_SORT_STORAGE_KEY } from '@iptvnator/portal/shared/util';
import {
XtreamContentLoadState,
XtreamStore,
@@ -17,6 +18,11 @@ const translations: Record<string, string> = {
'WORKSPACE.CONTEXT.RADIO_CATEGORIES': 'Radio Categories',
'WORKSPACE.CONTEXT.XTREAM_SYNCING_LIVE': 'Syncing live categories...',
'WORKSPACE.CONTEXT.XTREAM_SYNCING_MOVIES': 'Syncing movie categories...',
'WORKSPACE.CATEGORY_SORT_ARIA': 'Sort categories',
'WORKSPACE.SORT_LABEL': 'Sort: ',
'WORKSPACE.SORT_NAME_ASC': 'Name A-Z',
'WORKSPACE.SORT_NAME_DESC': 'Name Z-A',
'WORKSPACE.SORT_SERVER': 'Server sorting',
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING':
'Fetching playlist data from source...',
};
@@ -35,13 +41,20 @@ function createRouteSnapshot(
return {
routeConfig: path ? { path } : null,
paramMap: {
has: (param: string) =>
hasCategoryId && param === 'categoryId',
has: (param: string) => hasCategoryId && param === 'categoryId',
},
children,
};
}
function getCategoryLabels(
fixture: ComponentFixture<WorkspaceContextPanelComponent>
): string[] {
return Array.from(
fixture.nativeElement.querySelectorAll('.category-item .nav-item-label')
).map((element: Element) => element.textContent?.trim() ?? '');
}
describe('WorkspaceContextPanelComponent', () => {
let fixture: ComponentFixture<WorkspaceContextPanelComponent>;
const xtreamCategories = signal([
@@ -98,6 +111,7 @@ describe('WorkspaceContextPanelComponent', () => {
};
beforeEach(async () => {
localStorage.removeItem(WORKSPACE_CATEGORY_SORT_STORAGE_KEY);
xtreamCategories.set([
{ id: 1, name: 'News' },
{ id: 2, name: 'Sports' },
@@ -255,6 +269,135 @@ describe('WorkspaceContextPanelComponent', () => {
]);
});
it('keeps xtream categories in server order by default and sorts them from the menu modes', () => {
fixture.componentRef.setInput('section', 'vod');
xtreamSelectedTypeContentState.set('ready');
xtreamCategories.set([
{ id: 1, name: 'Sports' },
{ id: 2, name: 'Movies' },
{ id: 3, name: 'News' },
]);
fixture.detectChanges();
expect(getCategoryLabels(fixture)).toEqual([
'Sports',
'Movies',
'News',
]);
fixture.componentInstance.setCategorySortMode('name-asc');
fixture.detectChanges();
expect(getCategoryLabels(fixture)).toEqual([
'Movies',
'News',
'Sports',
]);
fixture.componentInstance.setCategorySortMode('name-desc');
fixture.detectChanges();
expect(getCategoryLabels(fixture)).toEqual([
'Sports',
'News',
'Movies',
]);
expect(localStorage.getItem(WORKSPACE_CATEGORY_SORT_STORAGE_KEY)).toBe(
'name-desc'
);
});
it('uses translated category sort labels and distinct mode icons', () => {
fixture.componentRef.setInput('section', 'vod');
xtreamSelectedTypeContentState.set('ready');
fixture.detectChanges();
const sortButton = Array.from(
fixture.nativeElement.querySelectorAll('.context-header__action')
).at(1) as HTMLButtonElement | undefined;
expect(sortButton?.getAttribute('aria-label')).toBe('Sort categories');
expect(fixture.componentInstance.categorySortLabelKey()).toBe(
'WORKSPACE.SORT_SERVER'
);
expect(fixture.componentInstance.categorySortIcon()).toBe('dns');
expect(
fixture.componentInstance.categorySortOptions.map((option) => ({
mode: option.mode,
translationKey: option.translationKey,
icon: option.icon,
}))
).toEqual([
{
mode: 'server',
translationKey: 'WORKSPACE.SORT_SERVER',
icon: 'dns',
},
{
mode: 'name-asc',
translationKey: 'WORKSPACE.SORT_NAME_ASC',
icon: 'sort_by_alpha',
},
{
mode: 'name-desc',
translationKey: 'WORKSPACE.SORT_NAME_DESC',
icon: 'arrow_downward',
},
]);
fixture.componentInstance.setCategorySortMode('name-desc');
fixture.detectChanges();
expect(fixture.componentInstance.categorySortLabelKey()).toBe(
'WORKSPACE.SORT_NAME_DESC'
);
expect(fixture.componentInstance.categorySortIcon()).toBe(
'arrow_downward'
);
});
it('applies the category sort menu modes to stalker categories', () => {
fixture.componentRef.setInput('context', {
provider: 'stalker',
playlistId: 'stalker-1',
});
fixture.componentRef.setInput('section', 'series');
stalkerStore.getCategoryResource.set([
{ category_id: '*', category_name: 'All Categories' },
{ category_id: 'z', category_name: 'Zulu' },
{ category_id: 'a', category_name: 'Alpha' },
{ category_id: 'm', category_name: 'Movies' },
]);
fixture.detectChanges();
expect(getCategoryLabels(fixture)).toEqual([
'All Categories',
'Zulu',
'Alpha',
'Movies',
]);
fixture.componentInstance.setCategorySortMode('name-asc');
fixture.detectChanges();
expect(getCategoryLabels(fixture)).toEqual([
'All Categories',
'Alpha',
'Movies',
'Zulu',
]);
fixture.componentInstance.setCategorySortMode('name-desc');
fixture.detectChanges();
expect(getCategoryLabels(fixture)).toEqual([
'All Categories',
'Zulu',
'Movies',
'Alpha',
]);
});
it('preserves the active xtream live item when switching live categories', () => {
fixture.componentRef.setInput('section', 'live');
xtreamSelectedTypeContentState.set('ready');
@@ -13,10 +13,17 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatIconButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltip } from '@angular/material/tooltip';
import { Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
PortalCategorySortMode,
persistPortalCategorySortMode,
restorePortalCategorySortMode,
sortPortalCategoryItems,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { WorkspaceContextCategoryViewComponent } from './components/workspace-context-category-view.component';
import { WorkspaceContextErrorViewComponent } from './components/workspace-context-error-view.component';
@@ -34,11 +41,19 @@ interface XtreamCategoryLike {
category_id?: number | string;
}
interface WorkspaceCategoryLike {
readonly category_id?: string | number;
readonly category_name?: string;
readonly id?: string | number;
readonly name?: string;
}
@Component({
selector: 'app-workspace-context-panel',
imports: [
MatIconButton,
MatIcon,
MatMenuModule,
MatTooltip,
TranslatePipe,
WorkspaceContextCategoryViewComponent,
@@ -100,9 +115,7 @@ export class WorkspaceContextPanelComponent {
this.isXtreamCategoryInteractionEnabled() ? 'ready' : 'loading'
);
readonly canManageXtreamCategories = computed(
() =>
this.isXtreamCategories() &&
this.xtreamSelectedTypeCountsReady()
() => this.isXtreamCategories() && this.xtreamSelectedTypeCountsReady()
);
readonly xtreamStatusText = computed(() => {
if (
@@ -140,39 +153,78 @@ export class WorkspaceContextPanelComponent {
viewChild<ElementRef<HTMLInputElement>>('searchInput');
readonly isSearchOpen = signal(false);
readonly categorySearchTerm = signal('');
readonly categorySortMode = signal<PortalCategorySortMode>(
restorePortalCategorySortMode()
);
readonly categorySortLabelKey = computed(() =>
this.getCategorySortLabelKey(this.categorySortMode())
);
readonly categorySortOptions: ReadonlyArray<{
mode: PortalCategorySortMode;
translationKey: string;
icon: string;
}> = [
{
mode: 'server',
translationKey: 'WORKSPACE.SORT_SERVER',
icon: 'dns',
},
{
mode: 'name-asc',
translationKey: 'WORKSPACE.SORT_NAME_ASC',
icon: 'sort_by_alpha',
},
{
mode: 'name-desc',
translationKey: 'WORKSPACE.SORT_NAME_DESC',
icon: 'arrow_downward',
},
];
readonly categorySortIcon = computed(
() =>
this.categorySortOptions.find(
(option) => option.mode === this.categorySortMode()
)?.icon ?? 'dns'
);
readonly canSearchCategories = computed(
() =>
(this.isXtreamCategories() &&
this.xtreamCategories().length > 0) ||
(this.isStalkerCategories() &&
this.stalkerCategories().length > 0)
(this.isXtreamCategories() && this.xtreamCategories().length > 0) ||
(this.isStalkerCategories() && this.stalkerCategories().length > 0)
);
readonly filteredXtreamCategories = computed(() => {
const cats = this.xtreamCategories();
const term = this.categorySearchTerm().trim().toLowerCase();
if (!term) return cats;
return cats.filter((c) => {
const label =
(c as { category_name?: string }).category_name ??
(c as { name?: string }).name ??
'';
return label.toLowerCase().includes(term);
});
const filtered = term
? cats.filter((category) =>
this.getCategoryLabel(category).toLowerCase().includes(term)
)
: cats;
return sortPortalCategoryItems(
filtered,
this.categorySortMode(),
(category) => this.getCategoryLabel(category),
(category) => this.isAllCategory(category)
);
});
readonly filteredStalkerCategories = computed(() => {
const cats = this.stalkerCategories();
const term = this.categorySearchTerm().trim().toLowerCase();
if (!term) return cats;
return cats.filter((c) => {
const label =
(c as { name?: string }).name ??
(c as { category_name?: string }).category_name ??
'';
return label.toLowerCase().includes(term);
});
const filtered = term
? cats.filter((category) =>
this.getCategoryLabel(category).toLowerCase().includes(term)
)
: cats;
return sortPortalCategoryItems(
filtered,
this.categorySortMode(),
(category) => this.getCategoryLabel(category),
(category) => this.isAllCategory(category)
);
});
readonly title = computed(() => {
@@ -240,6 +292,11 @@ export class WorkspaceContextPanelComponent {
this.searchInput()?.nativeElement.focus();
}
setCategorySortMode(mode: PortalCategorySortMode): void {
this.categorySortMode.set(mode);
persistPortalCategorySortMode(mode);
}
openManageCategories(): void {
if (!this.canManageXtreamCategories()) {
return;
@@ -262,7 +319,8 @@ export class WorkspaceContextPanelComponent {
data: {
playlistId: context.playlistId,
contentType,
itemCounts: this.xtreamStore.getCategoryItemCounts(),
itemCounts:
this.xtreamStore.getCategoryItemCounts(),
},
width: '500px',
maxHeight: '90vh',
@@ -396,4 +454,24 @@ export class WorkspaceContextPanelComponent {
return '';
}
}
private getCategorySortLabelKey(mode: PortalCategorySortMode): string {
switch (mode) {
case 'name-asc':
return 'WORKSPACE.SORT_NAME_ASC';
case 'name-desc':
return 'WORKSPACE.SORT_NAME_DESC';
case 'server':
default:
return 'WORKSPACE.SORT_SERVER';
}
}
private getCategoryLabel(category: WorkspaceCategoryLike): string {
return category.category_name ?? category.name ?? '';
}
private isAllCategory(category: WorkspaceCategoryLike): boolean {
return String(category.category_id ?? category.id) === '*';
}
}
@@ -593,7 +593,10 @@ export class WorkspaceShellFacade {
if (
context?.provider !== 'stalker' ||
!section ||
(section !== 'vod' && section !== 'series' && section !== 'itv')
(section !== 'vod' &&
section !== 'series' &&
section !== 'itv' &&
section !== 'radio')
) {
return;
}