diff --git a/.changes/xtream-filtered-category-selection.md b/.changes/xtream-filtered-category-selection.md new file mode 100644 index 000000000..0cfa027ac --- /dev/null +++ b/.changes/xtream-filtered-category-selection.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index d1616fbb1..72a531a67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,6 +166,14 @@ agent-browser connect ws://127.0.0.1:9222/devtools/page/ 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 diff --git a/CLAUDE.md b/CLAUDE.md index 126d1743c..9b7dace0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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**: diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index 49382b099..181bc1079 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -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, diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 3d636b102..8313398fd 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -996,9 +996,11 @@ "CATEGORY_MANAGEMENT": { "TITLE": "إدارة الفئات", "LOADING": "جارٍ تحميل الفئات...", - "SELECTED": "محدد", + "SELECTED": "إجمالي المحدد", "SELECT_ALL": "تحديد الكل", "DESELECT_ALL": "إلغاء تحديد الكل", + "SELECT_FILTERED": "تحديد نتائج البحث", + "DESELECT_FILTERED": "إلغاء تحديد نتائج البحث", "SEARCH_PLACEHOLDER": "بحث في الفئات...", "NO_RESULTS": "لم يتم العثور على فئات مطابقة", "NO_CATEGORIES": "لا توجد فئات متاحة", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 32cf398a1..78720c325 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -996,9 +996,11 @@ "CATEGORY_MANAGEMENT": { "TITLE": "تدبير الفئات", "LOADING": "جاري تشريج الفئات...", - "SELECTED": "متختار", + "SELECTED": "المجموع المختار", "SELECT_ALL": "اختار الكل", "DESELECT_ALL": "حيد الاختيار من الكل", + "SELECT_FILTERED": "اختار نتائج البحث", + "DESELECT_FILTERED": "حيد الاختيار من نتائج البحث", "SEARCH_PLACEHOLDER": "قلب على فئات...", "NO_RESULTS": "ما تلقاتش حتى فئة مطابقة", "NO_CATEGORIES": "ما كاينش فئات متوفرة", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 77f57d576..7c782b41b 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -996,9 +996,11 @@ "CATEGORY_MANAGEMENT": { "TITLE": "Кіраваць катэгорыямі", "LOADING": "Загрузка катэгорый...", - "SELECTED": "Выбрана", + "SELECTED": "Усяго выбрана", "SELECT_ALL": "Выбраць усе", "DESELECT_ALL": "Зняць выбар", + "SELECT_FILTERED": "Выбраць знойдзеныя", + "DESELECT_FILTERED": "Зняць выбар са знойдзеных", "SEARCH_PLACEHOLDER": "Пошук катэгорый...", "NO_RESULTS": "Адпаведных катэгорый не знойдзена", "NO_CATEGORIES": "Няма даступных катэгорый", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 224cd854f..2f28903cb 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index ead1eba01..f1d87677d 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -996,9 +996,11 @@ "CATEGORY_MANAGEMENT": { "TITLE": "Διαχείριση κατηγοριών", "LOADING": "Φόρτωση κατηγοριών...", - "SELECTED": "Επιλεγμένα", + "SELECTED": "Σύνολο επιλεγμένων", "SELECT_ALL": "Επιλογή όλων", "DESELECT_ALL": "Αποεπιλογή όλων", + "SELECT_FILTERED": "Επιλογή αποτελεσμάτων", + "DESELECT_FILTERED": "Αποεπιλογή αποτελεσμάτων", "SEARCH_PLACEHOLDER": "Αναζήτηση κατηγοριών...", "NO_RESULTS": "Δεν βρέθηκαν αντίστοιχες κατηγορίες", "NO_CATEGORIES": "Δεν υπάρχουν διαθέσιμες κατηγορίες", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 432955f05..d78c86b97 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 29d9bc536..8e39570eb 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 524b76070..d8d07cfb2 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 24a6d9828..c5e7fd203 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 5809d7a18..525e879d8 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 18f3dc14d..3d296016b 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -996,9 +996,11 @@ "CATEGORY_MANAGEMENT": { "TITLE": "カテゴリーを管理", "LOADING": "カテゴリーを読み込み中...", - "SELECTED": "選択済み", + "SELECTED": "選択済みの合計", "SELECT_ALL": "すべて選択", "DESELECT_ALL": "すべて解除", + "SELECT_FILTERED": "検索結果を選択", + "DESELECT_FILTERED": "検索結果の選択を解除", "SEARCH_PLACEHOLDER": "カテゴリーを検索...", "NO_RESULTS": "一致するカテゴリーが見つかりません", "NO_CATEGORIES": "利用可能なカテゴリーはありません", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index c43a4cc8f..36f8cbe76 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -996,9 +996,11 @@ "CATEGORY_MANAGEMENT": { "TITLE": "카테고리 관리", "LOADING": "카테고리를 불러오는 중...", - "SELECTED": "선택됨", + "SELECTED": "총 선택 수", "SELECT_ALL": "모두 선택", "DESELECT_ALL": "모두 선택 해제", + "SELECT_FILTERED": "검색 결과 선택", + "DESELECT_FILTERED": "검색 결과 선택 해제", "SEARCH_PLACEHOLDER": "카테고리 검색...", "NO_RESULTS": "일치하는 카테고리를 찾을 수 없습니다", "NO_CATEGORIES": "사용 가능한 카테고리가 없습니다", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 8ea848b18..a34a0026a 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 12df20fc4..126bc0f75 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 550d63356..aa8c1962a 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index d4eaf73f6..9d1a57504 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -996,9 +996,11 @@ "CATEGORY_MANAGEMENT": { "TITLE": "Управление категориями", "LOADING": "Загрузка категорий…", - "SELECTED": "Выбрано", + "SELECTED": "Всего выбрано", "SELECT_ALL": "Выбрать всё", "DESELECT_ALL": "Снять выделение", + "SELECT_FILTERED": "Выбрать найденные", + "DESELECT_FILTERED": "Снять выбор с найденных", "SEARCH_PLACEHOLDER": "Поиск категорий…", "NO_RESULTS": "Подходящих категорий не найдено", "NO_CATEGORIES": "Нет доступных категорий", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 8f8f17907..0bc978136 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index ce1919afa..664543f28 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -996,9 +996,11 @@ "CATEGORY_MANAGEMENT": { "TITLE": "管理分类", "LOADING": "正在加载分类…", - "SELECTED": "已选择", + "SELECTED": "已选总数", "SELECT_ALL": "全选", "DESELECT_ALL": "取消全选", + "SELECT_FILTERED": "选择搜索结果", + "DESELECT_FILTERED": "取消选择搜索结果", "SEARCH_PLACEHOLDER": "搜索分类…", "NO_RESULTS": "未找到匹配的分类", "NO_CATEGORIES": "没有可用的分类", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index d5707cc40..48e659079 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -996,9 +996,11 @@ "CATEGORY_MANAGEMENT": { "TITLE": "管理類別", "LOADING": "正在載入類別...", - "SELECTED": "已選取", + "SELECTED": "已選總數", "SELECT_ALL": "全部選取", "DESELECT_ALL": "全部取消選取", + "SELECT_FILTERED": "選取搜尋結果", + "DESELECT_FILTERED": "取消選取搜尋結果", "SEARCH_PLACEHOLDER": "搜尋類別...", "NO_RESULTS": "找不到符合的類別", "NO_CATEGORIES": "沒有可用的類別", diff --git a/docs/architecture/category-management.md b/docs/architecture/category-management.md index 77e360629..8a863b44d 100644 --- a/docs/architecture/category-management.md +++ b/docs/architecture/category-management.md @@ -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", diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html index 4d4f30e2e..98d56daee 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html @@ -16,18 +16,30 @@ @@ -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 @@ > diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.spec.ts b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.spec.ts new file mode 100644 index 000000000..5705ecec3 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.spec.ts @@ -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; + 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]); + } + ); +}); diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts index c12ffa3be..8c2fd60e4 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts @@ -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 { @@ -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 { 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);