fix(xtream): scope category bulk actions to search results (#1534)

This commit is contained in:
4gray authored and GitHub committed 2026-09-05 12:39:57 +02:00
1 parent 1c56b3ea02
commit eba68d687e
27 files changed
+461 -52

No files matched your search

@@ -0,0 +1,7 @@
---
type: fix
area: xtream
issues: [812, 816]
---
Xtream category management now selects or deselects only matching categories while searching, preserving the visibility of all other categories. Button labels and availability reflect the search results, and the counter clearly shows the total selected.
+8
View File
@@ -166,6 +166,14 @@ agent-browser connect ws://127.0.0.1:9222/devtools/page/<iptvnator-page-id>
agent-browser screenshot /tmp/iptvnator-cdp.png
```
## Xtream Category Management
The Electron Live TV, Movies, and Series category dialog applies Select/Deselect
to search results while a filter is active and to the whole type otherwise.
Button states use the matching group; "Total selected" counts the whole catalog.
Save persists the complete draft, Close discards it, and refresh restores hidden
categories by provider ID and type. See `docs/architecture/category-management.md`.
## Portal Connectivity Preference
- Desktop Settings > General > Portal connections exposes default-on
+8
View File
@@ -761,6 +761,14 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- Non-EPG SQLite work: `database.worker.ts` (see `docs/architecture/sqlite-db-worker.md`). Catalog deletes and inserts commit in row-budgeted transactions of ~5,000 rows (`database/operations/catalog-deletion.ts`: per-category row counts → category groups → set-based `DELETE`s scoped to the captured category ids, never playlist-wide, since the worker interleaves requests between commits and a newer import's categories must survive an older refresh; never 100-row autocommit batches, which flush FTS5 segments and re-append index pages to the WAL on every commit, and never one giant transaction, which would starve the main-process and EPG-worker connections past their 5 s `busy_timeout`). Progress events are throttled to one per 100 ms per operation with summed `increment`s (`operation-progress-throttle.ts`); phase starts, totals reached and terminal events are never held back
- Playlist refresh: `playlist-refresh.worker.ts`; explicit cancellation is main-process-owned and terminates the one-shot worker before acknowledging `PLAYLIST_CANCEL_REFRESH` (see `docs/architecture/m3u-playlist-module.md`)
### Xtream Category Management
The Electron Live TV, Movies, and Series category dialog applies Select/Deselect
to search results while a filter is active and to the whole type otherwise.
Button states use the matching group; "Total selected" counts the whole catalog.
Save persists the complete draft, Close discards it, and refresh restores hidden
categories by provider ID and type. See `docs/architecture/category-management.md`.
### Key Features
**Playlist Support**:
@@ -16,6 +16,111 @@ import {
} from './electron-test-fixtures';
test.describe('Electron Xtream Category Management', () => {
for (const section of ['Live TV', 'Movies', 'Series']) {
test(`bulk edits only filtered ${section} categories and saves or discards the draft`, async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
name: `Filtered ${section}`,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, section);
let dialog = await openManageCategoriesDialog(app.mainWindow);
const rows = dialog.locator('.category-item');
const names = (
await rows.locator('.category-name').allTextContents()
).map((name) => name.trim());
// The default mock catalog has several matches and nonmatches in every type.
const matched = names.filter((name) =>
name.toLowerCase().includes('a')
);
const outside = names.filter(
(name) => !name.toLowerCase().includes('a')
);
expect(matched.length).toBeGreaterThan(1);
expect(outside.length).toBeGreaterThan(1);
const checkbox = (name: string) =>
dialog.getByRole('checkbox', { name, exact: true });
await checkbox(outside[0]).uncheck();
await checkbox(matched[0]).uncheck();
const search = () => dialog.locator('input[type="search"]');
await search().fill('a');
const select = () =>
dialog.getByRole('button', {
name: 'Select Filtered',
exact: true,
});
const deselect = () =>
dialog.getByRole('button', {
name: 'Deselect Filtered',
exact: true,
});
await expect(rows).toHaveCount(matched.length);
await expect(select()).toBeEnabled();
await expect(deselect()).toBeEnabled();
await select().click();
await expect(select()).toBeDisabled();
await expect(dialog.locator('.selection-info')).toHaveText(
`Total selected: ${names.length - 1} / ${names.length}`
);
await deselect().click();
await expect(deselect()).toBeDisabled();
await expect(dialog.locator('.selection-info')).toHaveText(
`Total selected: ${outside.length - 1} / ${names.length}`
);
await search().fill('no matching mock category');
await expect(select()).toBeDisabled();
await expect(deselect()).toBeDisabled();
await expect(dialog.locator('.empty-message')).toHaveText(
'No matching categories found'
);
await search().fill('');
await expect(checkbox(outside[0])).not.toBeChecked();
await expect(checkbox(outside[1])).toBeChecked();
for (const name of matched)
await expect(checkbox(name)).not.toBeChecked();
await dialog
.getByRole('button', { name: 'Save', exact: true })
.click();
await expect(dialog).not.toBeVisible();
dialog = await openManageCategoriesDialog(app.mainWindow);
await expect(checkbox(outside[0])).not.toBeChecked();
await expect(checkbox(outside[1])).toBeChecked();
for (const name of matched)
await expect(checkbox(name)).not.toBeChecked();
await search().fill('A');
await select().click();
await dialog
.getByRole('button', { name: 'Close', exact: true })
.click();
await expect(dialog).not.toBeVisible();
dialog = await openManageCategoriesDialog(app.mainWindow);
for (const name of matched)
await expect(checkbox(name)).not.toBeChecked();
await search().fill('a');
await select().click();
await dialog
.getByRole('button', { name: 'Save', exact: true })
.click();
await expect(dialog).not.toBeVisible();
dialog = await openManageCategoriesDialog(app.mainWindow);
for (const name of matched)
await expect(checkbox(name)).toBeChecked();
await expect(checkbox(outside[0])).not.toBeChecked();
await expect(checkbox(outside[1])).toBeChecked();
await dialog
.getByRole('button', { name: 'Close', exact: true })
.click();
} finally {
await closeElectronApp(app);
}
});
}
test('hides and restores categories, supports search and bulk actions, and persists hidden selections after refresh', async ({
dataDir,
request,
+3 -1
View File
@@ -996,9 +996,11 @@
"CATEGORY_MANAGEMENT": {
"TITLE": "إدارة الفئات",
"LOADING": "جارٍ تحميل الفئات...",
"SELECTED": "محدد",
"SELECTED": "إجمالي المحدد",
"SELECT_ALL": "تحديد الكل",
"DESELECT_ALL": "إلغاء تحديد الكل",
"SELECT_FILTERED": "تحديد نتائج البحث",
"DESELECT_FILTERED": "إلغاء تحديد نتائج البحث",
"SEARCH_PLACEHOLDER": "بحث في الفئات...",
"NO_RESULTS": "لم يتم العثور على فئات مطابقة",
"NO_CATEGORIES": "لا توجد فئات متاحة",
+3 -1
View File
@@ -996,9 +996,11 @@
"CATEGORY_MANAGEMENT": {
"TITLE": "تدبير الفئات",
"LOADING": "جاري تشريج الفئات...",
"SELECTED": "متختار",
"SELECTED": "المجموع المختار",
"SELECT_ALL": "اختار الكل",
"DESELECT_ALL": "حيد الاختيار من الكل",
"SELECT_FILTERED": "اختار نتائج البحث",
"DESELECT_FILTERED": "حيد الاختيار من نتائج البحث",
"SEARCH_PLACEHOLDER": "قلب على فئات...",
"NO_RESULTS": "ما تلقاتش حتى فئة مطابقة",
"NO_CATEGORIES": "ما كاينش فئات متوفرة",
+3 -1
View File
@@ -996,9 +996,11 @@
"CATEGORY_MANAGEMENT": {
"TITLE": "Кіраваць катэгорыямі",
"LOADING": "Загрузка катэгорый...",
"SELECTED": "Выбрана",
"SELECTED": "Усяго выбрана",
"SELECT_ALL": "Выбраць усе",
"DESELECT_ALL": "Зняць выбар",
"SELECT_FILTERED": "Выбраць знойдзеныя",
"DESELECT_FILTERED": "Зняць выбар са знойдзеных",
"SEARCH_PLACEHOLDER": "Пошук катэгорый...",
"NO_RESULTS": "Адпаведных катэгорый не знойдзена",
"NO_CATEGORIES": "Няма даступных катэгорый",
+3 -1
View File
@@ -996,9 +996,11 @@
"CATEGORY_MANAGEMENT": {
"TITLE": "Kategorien verwalten",
"LOADING": "Kategorien werden geladen …",
"SELECTED": "Ausgewählt",
"SELECTED": "Insgesamt ausgewählt",
"SELECT_ALL": "Alle auswählen",
"DESELECT_ALL": "Alle abwählen",
"SELECT_FILTERED": "Gefilterte auswählen",
"DESELECT_FILTERED": "Gefilterte abwählen",
"SEARCH_PLACEHOLDER": "Kategorien suchen …",
"NO_RESULTS": "Keine passenden Kategorien gefunden",
"NO_CATEGORIES": "Keine Kategorien verfügbar",
+3 -1
View File
@@ -996,9 +996,11 @@
"CATEGORY_MANAGEMENT": {
"TITLE": "Διαχείριση κατηγοριών",
"LOADING": "Φόρτωση κατηγοριών...",
"SELECTED": "Επιλεγμένα",
"SELECTED": "Σύνολο επιλεγμένων",
"SELECT_ALL": "Επιλογή όλων",
"DESELECT_ALL": "Αποεπιλογή όλων",
"SELECT_FILTERED": "Επιλογή αποτελεσμάτων",
"DESELECT_FILTERED": "Αποεπιλογή αποτελεσμάτων",
"SEARCH_PLACEHOLDER": "Αναζήτηση κατηγοριών...",
"NO_RESULTS": "Δεν βρέθηκαν αντίστοιχες κατηγορίες",
"NO_CATEGORIES": "Δεν υπάρχουν διαθέσιμες κατηγορίες",
+3 -1
View File
@@ -996,9 +996,11 @@
"CATEGORY_MANAGEMENT": {
"TITLE": "Manage Categories",
"LOADING": "Loading categories...",
"SELECTED": "Selected",
"SELECTED": "Total selected",
"SELECT_ALL": "Select All",
"DESELECT_ALL": "Deselect All",
"SELECT_FILTERED": "Select Filtered",
"DESELECT_FILTERED": "Deselect Filtered",
"SEARCH_PLACEHOLDER": "Search categories...",
"NO_RESULTS": "No matching categories found",
"NO_CATEGORIES": "No categories available",
+3 -1
View File
@@ -996,9 +996,11 @@
"CATEGORY_MANAGEMENT": {
"TITLE": "Gestionar categorías",
"LOADING": "Cargando categorías…",
"SELECTED": "Seleccionadas",
"SELECTED": "Total seleccionado",
"SELECT_ALL": "Seleccionar todo",
"DESELECT_ALL": "Deseleccionar todo",
"SELECT_FILTERED": "Seleccionar resultados",
"DESELECT_FILTERED": "Deseleccionar resultados",
"SEARCH_PLACEHOLDER": "Buscar categorías…",
"NO_RESULTS": "No se encontraron categorías coincidentes",
"NO_CATEGORIES": "No hay categorías disponibles",
+3 -1
View File
@@ -996,9 +996,11 @@
"CATEGORY_MANAGEMENT": {
"TITLE": "Gérer les catégories",
"LOADING": "Chargement des catégories…",
"SELECTED": "Sélectionnées",
"SELECTED": "Total sélectionné",
"SELECT_ALL": "Tout sélectionner",
"DESELECT_ALL": "Tout désélectionner",
"SELECT_FILTERED": "Sélectionner les résultats",
"DESELECT_FILTERED": "Désélectionner les résultats",
"SEARCH_PLACEHOLDER": "Rechercher des catégories…",
"NO_RESULTS": "Aucune catégorie correspondante trouvée",
"NO_CATEGORIES": "Aucune catégorie disponible",
+3 -1
View File
@@ -996,9 +996,11 @@
"CATEGORY_MANAGEMENT": {
"TITLE": "Kategóriák kezelése",
"LOADING": "Kategóriák betöltése…",
"SELECTED": "Kijelölve",
"SELECTED": "Összesen kijelölve",
"SELECT_ALL": "Összes kijelölése",
"DESELECT_ALL": "Összes kijelölésének megszüntetése",
"SELECT_FILTERED": "Találatok kijelölése",
"DESELECT_FILTERED": "Találatok kijelölésének megszüntetése",
"SEARCH_PLACEHOLDER": "Keresés a kategóriák között…",
"NO_RESULTS": "Nem található a keresésnek megfelelő kategória",
"NO_CATEGORIES": "Nincs elérhető kategória",
+3 -1
View File
@@ -996,9 +996,11 @@
"CATEGORY_MANAGEMENT": {
"TITLE": "Gestisci categorie",
"LOADING": "Caricamento categorie...",
"SELECTED": "Selezionate",
"SELECTED": "Totale selezionate",
"SELECT_ALL": "Seleziona tutte",
"DESELECT_ALL": "Deseleziona tutte",
"SELECT_FILTERED": "Seleziona risultati",
"DESELECT_FILTERED": "Deseleziona risultati",
"SEARCH_PLACEHOLDER": "Cerca categorie...",
"NO_RESULTS": "Nessuna categoria corrispondente",
"NO_CATEGORIES": "Nessuna categoria disponibile",
+3 -1
View File
@@ -996,9 +996,11 @@
"CATEGORY_MANAGEMENT": {
"TITLE": "カテゴリーを管理",
"LOADING": "カテゴリーを読み込み中...",
"SELECTED": "選択済み",
"SELECTED": "選択済みの合計",
"SELECT_ALL": "すべて選択",
"DESELECT_ALL": "すべて解除",
"SELECT_FILTERED": "検索結果を選択",
"DESELECT_FILTERED": "検索結果の選択を解除",
"SEARCH_PLACEHOLDER": "カテゴリーを検索...",
"NO_RESULTS": "一致するカテゴリーが見つかりません",
"NO_CATEGORIES": "利用可能なカテゴリーはありません",
+3 -1
View File
@@ -996,9 +996,11 @@
"CATEGORY_MANAGEMENT": {
"TITLE": "카테고리 관리",
"LOADING": "카테고리를 불러오는 중...",
"SELECTED": "선택됨",
"SELECTED": "총 선택 수",
"SELECT_ALL": "모두 선택",
"DESELECT_ALL": "모두 선택 해제",
"SELECT_FILTERED": "검색 결과 선택",
"DESELECT_FILTERED": "검색 결과 선택 해제",
"SEARCH_PLACEHOLDER": "카테고리 검색...",
"NO_RESULTS": "일치하는 카테고리를 찾을 수 없습니다",
"NO_CATEGORIES": "사용 가능한 카테고리가 없습니다",
+3 -1
View File
@@ -996,9 +996,11 @@
"CATEGORY_MANAGEMENT": {
"TITLE": "Categorieën beheren",
"LOADING": "Categorieën laden...",
"SELECTED": "Geselecteerd",
"SELECTED": "Totaal geselecteerd",
"SELECT_ALL": "Alles selecteren",
"DESELECT_ALL": "Alles deselecteren",
"SELECT_FILTERED": "Resultaten selecteren",
"DESELECT_FILTERED": "Resultaten deselecteren",
"SEARCH_PLACEHOLDER": "Zoek categorieën...",
"NO_RESULTS": "Geen overeenkomende categorieën gevonden",
"NO_CATEGORIES": "Geen categorieën beschikbaar",
+3 -1
View File
@@ -996,9 +996,11 @@
"CATEGORY_MANAGEMENT": {
"TITLE": "Zarządzaj kategoriami",
"LOADING": "Ładowanie kategorii...",
"SELECTED": "Zaznaczone",
"SELECTED": "Łącznie zaznaczone",
"SELECT_ALL": "Zaznacz wszystkie",
"DESELECT_ALL": "Odznacz wszystkie",
"SELECT_FILTERED": "Zaznacz wyniki",
"DESELECT_FILTERED": "Odznacz wyniki",
"SEARCH_PLACEHOLDER": "Szukaj kategorii...",
"NO_RESULTS": "Nie znaleziono pasujących kategorii",
"NO_CATEGORIES": "Brak dostępnych kategorii",
+3 -1
View File
@@ -996,9 +996,11 @@
"CATEGORY_MANAGEMENT": {
"TITLE": "Gerenciar categorias",
"LOADING": "Carregando categorias...",
"SELECTED": "Selecionadas",
"SELECTED": "Total selecionado",
"SELECT_ALL": "Selecionar todas",
"DESELECT_ALL": "Desmarcar todas",
"SELECT_FILTERED": "Selecionar resultados",
"DESELECT_FILTERED": "Desmarcar resultados",
"SEARCH_PLACEHOLDER": "Pesquisar categorias...",
"NO_RESULTS": "Nenhuma categoria correspondente encontrada",
"NO_CATEGORIES": "Nenhuma categoria disponível",
+3 -1
View File
@@ -996,9 +996,11 @@
"CATEGORY_MANAGEMENT": {
"TITLE": "Управление категориями",
"LOADING": "Загрузка категорий…",
"SELECTED": "Выбрано",
"SELECTED": "Всего выбрано",
"SELECT_ALL": "Выбрать всё",
"DESELECT_ALL": "Снять выделение",
"SELECT_FILTERED": "Выбрать найденные",
"DESELECT_FILTERED": "Снять выбор с найденных",
"SEARCH_PLACEHOLDER": "Поиск категорий…",
"NO_RESULTS": "Подходящих категорий не найдено",
"NO_CATEGORIES": "Нет доступных категорий",
+3 -1
View File
@@ -996,9 +996,11 @@
"CATEGORY_MANAGEMENT": {
"TITLE": "Kategorileri Yönet",
"LOADING": "Kategoriler yükleniyor...",
"SELECTED": "Seçildi",
"SELECTED": "Toplam seçilen",
"SELECT_ALL": "Tümünü Seç",
"DESELECT_ALL": "Tümünü Kaldır",
"SELECT_FILTERED": "Sonuçları seç",
"DESELECT_FILTERED": "Sonuçların seçimini kaldır",
"SEARCH_PLACEHOLDER": "Kategorileri ara...",
"NO_RESULTS": "Eşleşen kategori bulunamadı",
"NO_CATEGORIES": "Kategori mevcut değil",
+3 -1
View File
@@ -996,9 +996,11 @@
"CATEGORY_MANAGEMENT": {
"TITLE": "管理分类",
"LOADING": "正在加载分类…",
"SELECTED": "已选择",
"SELECTED": "已选总数",
"SELECT_ALL": "全选",
"DESELECT_ALL": "取消全选",
"SELECT_FILTERED": "选择搜索结果",
"DESELECT_FILTERED": "取消选择搜索结果",
"SEARCH_PLACEHOLDER": "搜索分类…",
"NO_RESULTS": "未找到匹配的分类",
"NO_CATEGORIES": "没有可用的分类",
+3 -1
View File
@@ -996,9 +996,11 @@
"CATEGORY_MANAGEMENT": {
"TITLE": "管理類別",
"LOADING": "正在載入類別...",
"SELECTED": "已選取",
"SELECTED": "已選總數",
"SELECT_ALL": "全部選取",
"DESELECT_ALL": "全部取消選取",
"SELECT_FILTERED": "選取搜尋結果",
"DESELECT_FILTERED": "取消選取搜尋結果",
"SEARCH_PLACEHOLDER": "搜尋類別...",
"NO_RESULTS": "找不到符合的類別",
"NO_CATEGORIES": "沒有可用的類別",
+33 -5
View File
@@ -12,7 +12,7 @@ Xtream API playlists often contain many categories, some of which may be empty,
4. User sees all categories with checkboxes (checked = visible, unchecked = hidden)
5. User can:
- Individually toggle categories
- Use "Select All" / "Deselect All" buttons
- Use "Select All" / "Deselect All" for the whole list, or "Select Filtered" / "Deselect Filtered" for search results
- Search/filter categories by name
6. On save, visibility preferences are persisted to the database
7. Hidden categories no longer appear in the sidebar
@@ -59,9 +59,9 @@ ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0
- Path: `libs/portal/xtream/feature/src/lib/category-management-dialog/`
- Features:
- Checkbox list of all categories
- Select All / Deselect All buttons
- Scope-aware bulk selection buttons
- Search/filter with clearable input
- Shows selected count vs total
- Shows total selected count vs the full catalog size, independently of the search filter
- Saves changes to database on confirm
**Integration Points**
@@ -93,6 +93,28 @@ ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0
categories through `getAllXtreamCategories()` so users can select categories
again.
### Filtered Bulk Selection
The Electron dialog applies bulk actions to the currently displayed categories
for Live TV, Movies, and Series. Search is case-insensitive. With an empty
search, actions apply to the entire list for that content type. Changing or
clearing the search preserves all pending selections, including both selected
and unselected categories outside the current results.
The two action buttons use localized filtered labels while searching. Select is
disabled when every result is selected; Deselect is disabled when no result is
selected. Both are disabled when there are no results. Partial selection enables
both actions. The buttons have no static checkbox icon that could be mistaken
for a group selection state; each category's checkbox reflects its own state.
The counter is explicitly labelled "Total selected" and always uses the full
catalog for both numerator and denominator.
Save writes the complete draft using local database category IDs; closing the
dialog without saving discards it. The existing PWA path does not expose this
SQLite-backed dialog. Regression coverage lives in the dialog component spec
and `apps/electron-backend-e2e/src/category-management.e2e.ts` (all three types,
save/reopen, discard, no matches, and both existing refresh paths).
### Visibility Preservation During Refresh
When a user refreshes an Xtream playlist, hidden category preferences are preserved through the following mechanism:
@@ -103,7 +125,11 @@ When a user refreshes an Xtream playlist, hidden category preferences are preser
4. **Restoration**: Categories matching the saved xtreamIds are inserted with `hidden = true`, preserving the user's visibility preferences
5. **ID normalization**: Xtream category IDs arrive from the API as strings, while SQLite stores `categories.xtream_id` as an integer. Restoration must normalize incoming `category_id` values before matching them against saved hidden-category xtreamIds.
This ensures that users don't lose their category visibility customizations when refreshing playlists to get updated content.
Restoration matches stable provider IDs within each playlist and content type;
renamed categories keep their preference when their ID is unchanged. A provider
that changes IDs creates new visible categories, even if names are reused.
Pending preferences survive a failed refresh and are replayed after a successful
import. The source-list refresh and workspace-header refresh both use this path.
### Debugging Note
@@ -170,9 +196,11 @@ global.d.ts # TypeScript types for IPC methods
"CATEGORY_MANAGEMENT": {
"TITLE": "Manage Categories",
"LOADING": "Loading categories...",
"SELECTED": "Selected",
"SELECTED": "Total selected",
"SELECT_ALL": "Select All",
"DESELECT_ALL": "Deselect All",
"SELECT_FILTERED": "Select Filtered",
"DESELECT_FILTERED": "Deselect Filtered",
"SEARCH_PLACEHOLDER": "Search categories...",
"NO_RESULTS": "No matching categories found",
"NO_CATEGORIES": "No categories available",
@@ -16,18 +16,30 @@
<button
mat-stroked-button
(click)="selectAll()"
[disabled]="allSelected()"
[disabled]="
isSaving() ||
filteredCategories().length === 0 ||
allFilteredSelected()
"
>
<mat-icon>check_box</mat-icon>
{{ 'XTREAM.CATEGORY_MANAGEMENT.SELECT_ALL' | translate }}
{{
(searchTerm()
? 'XTREAM.CATEGORY_MANAGEMENT.SELECT_FILTERED'
: 'XTREAM.CATEGORY_MANAGEMENT.SELECT_ALL'
) | translate
}}
</button>
<button
mat-stroked-button
(click)="deselectAll()"
[disabled]="selectedCount() === 0"
[disabled]="isSaving() || filteredSelectedCount() === 0"
>
<mat-icon>check_box_outline_blank</mat-icon>
{{ 'XTREAM.CATEGORY_MANAGEMENT.DESELECT_ALL' | translate }}
{{
(searchTerm()
? 'XTREAM.CATEGORY_MANAGEMENT.DESELECT_FILTERED'
: 'XTREAM.CATEGORY_MANAGEMENT.DESELECT_ALL'
) | translate
}}
</button>
</div>
</div>
@@ -40,8 +52,7 @@
[value]="searchTerm()"
(input)="searchTerm.set($any($event.target).value)"
[placeholder]="
'XTREAM.CATEGORY_MANAGEMENT.SEARCH_PLACEHOLDER'
| translate
'XTREAM.CATEGORY_MANAGEMENT.SEARCH_PLACEHOLDER' | translate
"
/>
@if (searchTerm()) {
@@ -65,6 +76,7 @@
>
<mat-checkbox
[checked]="category.selected"
[aria-label]="category.name"
(click)="$event.stopPropagation()"
(change)="toggleCategory(category)"
>
@@ -0,0 +1,204 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { DatabaseService, XCategoryFromDb } from '@iptvnator/services';
import {
CategoryManagementDialogComponent,
CategoryManagementDialogData,
} from './category-management-dialog.component';
// Local database IDs deliberately differ from provider IDs.
const categories: XCategoryFromDb[] = [
{ id: 11, xtream_id: 101, name: 'FR News', hidden: false },
{ id: 12, xtream_id: 102, name: 'FR Sports', hidden: true },
{ id: 13, xtream_id: 103, name: 'DE News', hidden: false },
{ id: 14, xtream_id: 104, name: 'DE Sports', hidden: true },
].map((category) => ({
...category,
playlist_id: 'mock-playlist',
type: 'live',
}));
describe('CategoryManagementDialogComponent', () => {
let fixture: ComponentFixture<CategoryManagementDialogComponent>;
let component: CategoryManagementDialogComponent;
const db = {
getAllXtreamCategories: jest.fn(),
updateCategoryVisibility: jest.fn(),
};
const dialogRef = { close: jest.fn() };
const data: CategoryManagementDialogData = {
playlistId: 'mock-playlist',
contentType: 'live',
itemCounts: new Map(),
};
beforeEach(async () => {
jest.clearAllMocks();
db.getAllXtreamCategories.mockResolvedValue(categories);
db.updateCategoryVisibility.mockResolvedValue(undefined);
data.contentType = 'live';
await TestBed.configureTestingModule({
imports: [
CategoryManagementDialogComponent,
TranslateModule.forRoot(),
],
providers: [
{ provide: DatabaseService, useValue: db },
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: MAT_DIALOG_DATA, useValue: data },
],
}).compileComponents();
fixture = TestBed.createComponent(CategoryManagementDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
});
function selectedIds() {
return component
.categories()
.filter((c) => c.selected)
.map((c) => c.id);
}
function bulkButtons(): HTMLButtonElement[] {
fixture.detectChanges();
return Array.from(
fixture.nativeElement.querySelectorAll('.bulk-actions button')
);
}
it('selects only matches and preserves both states outside the filter', () => {
component.searchTerm.set('fr');
component.selectAll();
expect(selectedIds()).toEqual([11, 12, 13]);
component.clearSearch();
expect(selectedIds()).toEqual([11, 12, 13]);
});
it('deselects only matches and keeps edits when the filter changes', () => {
component.searchTerm.set('FR');
component.deselectAll();
expect(selectedIds()).toEqual([13]);
component.searchTerm.set('DE');
component.selectAll();
expect(selectedIds()).toEqual([13, 14]);
component.clearSearch();
expect(selectedIds()).toEqual([13, 14]);
});
it('uses the matching group for action states and the whole list for the counter', () => {
component.searchTerm.set('FR');
expect(bulkButtons().map((button) => button.disabled)).toEqual([
false,
false,
]);
component.selectAll();
expect(bulkButtons().map((button) => button.disabled)).toEqual([
true,
false,
]);
expect(component.selectedCount()).toBe(3);
expect(component.totalCount()).toBe(4);
component.deselectAll();
expect(bulkButtons().map((button) => button.disabled)).toEqual([
false,
true,
]);
expect(component.selectedCount()).toBe(1);
expect(bulkButtons()[0].textContent).toContain('SELECT_FILTERED');
expect(bulkButtons()[1].textContent).toContain('DESELECT_FILTERED');
});
it('disables both actions with no matches and leaves every selection intact', () => {
component.searchTerm.set('no matches');
expect(bulkButtons().map((button) => button.disabled)).toEqual([
true,
true,
]);
expect(
fixture.nativeElement.querySelector('.empty-message').textContent
).toContain('NO_RESULTS');
component.selectAll();
component.deselectAll();
expect(selectedIds()).toEqual([11, 13]);
});
it('applies bulk actions to the entire list with an empty filter', () => {
component.selectAll();
expect(selectedIds()).toEqual([11, 12, 13, 14]);
expect(bulkButtons()[0].disabled).toBe(true);
component.deselectAll();
expect(selectedIds()).toEqual([]);
expect(bulkButtons()[1].disabled).toBe(true);
expect(bulkButtons()[0].textContent).toContain('SELECT_ALL');
});
it('disables both actions for an empty catalog', () => {
component.categories.set([]);
expect(bulkButtons().map((button) => button.disabled)).toEqual([
true,
true,
]);
});
it('renders the actual partial selection as individually checked categories', () => {
component.searchTerm.set('FR');
fixture.detectChanges();
const checkboxes: HTMLInputElement[] = Array.from(
fixture.nativeElement.querySelectorAll('mat-checkbox input')
);
expect(checkboxes.map((checkbox) => checkbox.checked)).toEqual([
true,
false,
]);
checkboxes[1].click();
fixture.detectChanges();
expect(selectedIds()).toEqual([11, 12, 13]);
expect(bulkButtons()[0].disabled).toBe(true);
});
it('saves the full selection using local IDs even while filtered', async () => {
component.searchTerm.set('FR');
component.deselectAll();
await component.save();
expect(db.updateCategoryVisibility.mock.calls).toEqual([
[[11, 12, 14], true],
[[13], false],
]);
expect(dialogRef.close).toHaveBeenCalledWith(true);
});
it('discards pending bulk changes on cancel', () => {
component.searchTerm.set('FR');
component.selectAll();
component.cancel();
expect(db.updateCategoryVisibility).not.toHaveBeenCalled();
expect(dialogRef.close).toHaveBeenCalledWith(false);
expect(categories.map((c) => c.hidden)).toEqual([
false,
true,
false,
true,
]);
});
it.each([
['live', 'live'],
['vod', 'movies'],
['series', 'series'],
] as const)(
'loads all %s categories including hidden categories',
async (contentType, dbType) => {
data.contentType = contentType;
await component.ngOnInit();
expect(db.getAllXtreamCategories).toHaveBeenLastCalledWith(
'mock-playlist',
dbType
);
expect(selectedIds()).toEqual([11, 13]);
}
);
});
@@ -71,14 +71,14 @@ export class CategoryManagementDialogComponent implements OnInit {
readonly totalCount = computed(() => this.categories().length);
readonly allSelected = computed(
() =>
this.categories().length > 0 &&
this.categories().every((c) => c.selected)
readonly filteredSelectedCount = computed(
() => this.filteredCategories().filter((c) => c.selected).length
);
readonly someSelected = computed(
() => this.categories().some((c) => c.selected) && !this.allSelected()
readonly allFilteredSelected = computed(
() =>
this.filteredCategories().length > 0 &&
this.filteredSelectedCount() === this.filteredCategories().length
);
async ngOnInit(): Promise<void> {
@@ -155,31 +155,30 @@ export class CategoryManagementDialogComponent implements OnInit {
}
selectAll(): void {
this.categories.update((cats) =>
cats.map((c) => ({ ...c, selected: true }))
);
this.setFilteredSelection(true);
}
deselectAll(): void {
this.categories.update((cats) =>
cats.map((c) => ({ ...c, selected: false }))
);
this.setFilteredSelection(false);
}
toggleAll(): void {
if (this.allSelected()) {
this.deselectAll();
} else {
this.selectAll();
}
private setFilteredSelection(selected: boolean): void {
const ids = new Set(this.filteredCategories().map((c) => c.id));
this.categories.update((cats) =>
cats.map((c) => (ids.has(c.id) ? { ...c, selected } : c))
);
}
async save(): Promise<void> {
this.isSaving.set(true);
try {
const categories = this.categories();
const toHide = categories.filter((c) => !c.selected).map((c) => c.id);
const toShow = categories.filter((c) => c.selected).map((c) => c.id);
const toHide = categories
.filter((c) => !c.selected)
.map((c) => c.id);
const toShow = categories
.filter((c) => c.selected)
.map((c) => c.id);
if (toHide.length > 0) {
await this.dbService.updateCategoryVisibility(toHide, true);