mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(xtream): scope category bulk actions to search results (#1534)
This commit is contained in:
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.
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -996,9 +996,11 @@
|
||||
"CATEGORY_MANAGEMENT": {
|
||||
"TITLE": "إدارة الفئات",
|
||||
"LOADING": "جارٍ تحميل الفئات...",
|
||||
"SELECTED": "محدد",
|
||||
"SELECTED": "إجمالي المحدد",
|
||||
"SELECT_ALL": "تحديد الكل",
|
||||
"DESELECT_ALL": "إلغاء تحديد الكل",
|
||||
"SELECT_FILTERED": "تحديد نتائج البحث",
|
||||
"DESELECT_FILTERED": "إلغاء تحديد نتائج البحث",
|
||||
"SEARCH_PLACEHOLDER": "بحث في الفئات...",
|
||||
"NO_RESULTS": "لم يتم العثور على فئات مطابقة",
|
||||
"NO_CATEGORIES": "لا توجد فئات متاحة",
|
||||
|
||||
@@ -996,9 +996,11 @@
|
||||
"CATEGORY_MANAGEMENT": {
|
||||
"TITLE": "تدبير الفئات",
|
||||
"LOADING": "جاري تشريج الفئات...",
|
||||
"SELECTED": "متختار",
|
||||
"SELECTED": "المجموع المختار",
|
||||
"SELECT_ALL": "اختار الكل",
|
||||
"DESELECT_ALL": "حيد الاختيار من الكل",
|
||||
"SELECT_FILTERED": "اختار نتائج البحث",
|
||||
"DESELECT_FILTERED": "حيد الاختيار من نتائج البحث",
|
||||
"SEARCH_PLACEHOLDER": "قلب على فئات...",
|
||||
"NO_RESULTS": "ما تلقاتش حتى فئة مطابقة",
|
||||
"NO_CATEGORIES": "ما كاينش فئات متوفرة",
|
||||
|
||||
@@ -996,9 +996,11 @@
|
||||
"CATEGORY_MANAGEMENT": {
|
||||
"TITLE": "Кіраваць катэгорыямі",
|
||||
"LOADING": "Загрузка катэгорый...",
|
||||
"SELECTED": "Выбрана",
|
||||
"SELECTED": "Усяго выбрана",
|
||||
"SELECT_ALL": "Выбраць усе",
|
||||
"DESELECT_ALL": "Зняць выбар",
|
||||
"SELECT_FILTERED": "Выбраць знойдзеныя",
|
||||
"DESELECT_FILTERED": "Зняць выбар са знойдзеных",
|
||||
"SEARCH_PLACEHOLDER": "Пошук катэгорый...",
|
||||
"NO_RESULTS": "Адпаведных катэгорый не знойдзена",
|
||||
"NO_CATEGORIES": "Няма даступных катэгорый",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -996,9 +996,11 @@
|
||||
"CATEGORY_MANAGEMENT": {
|
||||
"TITLE": "Διαχείριση κατηγοριών",
|
||||
"LOADING": "Φόρτωση κατηγοριών...",
|
||||
"SELECTED": "Επιλεγμένα",
|
||||
"SELECTED": "Σύνολο επιλεγμένων",
|
||||
"SELECT_ALL": "Επιλογή όλων",
|
||||
"DESELECT_ALL": "Αποεπιλογή όλων",
|
||||
"SELECT_FILTERED": "Επιλογή αποτελεσμάτων",
|
||||
"DESELECT_FILTERED": "Αποεπιλογή αποτελεσμάτων",
|
||||
"SEARCH_PLACEHOLDER": "Αναζήτηση κατηγοριών...",
|
||||
"NO_RESULTS": "Δεν βρέθηκαν αντίστοιχες κατηγορίες",
|
||||
"NO_CATEGORIES": "Δεν υπάρχουν διαθέσιμες κατηγορίες",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -996,9 +996,11 @@
|
||||
"CATEGORY_MANAGEMENT": {
|
||||
"TITLE": "カテゴリーを管理",
|
||||
"LOADING": "カテゴリーを読み込み中...",
|
||||
"SELECTED": "選択済み",
|
||||
"SELECTED": "選択済みの合計",
|
||||
"SELECT_ALL": "すべて選択",
|
||||
"DESELECT_ALL": "すべて解除",
|
||||
"SELECT_FILTERED": "検索結果を選択",
|
||||
"DESELECT_FILTERED": "検索結果の選択を解除",
|
||||
"SEARCH_PLACEHOLDER": "カテゴリーを検索...",
|
||||
"NO_RESULTS": "一致するカテゴリーが見つかりません",
|
||||
"NO_CATEGORIES": "利用可能なカテゴリーはありません",
|
||||
|
||||
@@ -996,9 +996,11 @@
|
||||
"CATEGORY_MANAGEMENT": {
|
||||
"TITLE": "카테고리 관리",
|
||||
"LOADING": "카테고리를 불러오는 중...",
|
||||
"SELECTED": "선택됨",
|
||||
"SELECTED": "총 선택 수",
|
||||
"SELECT_ALL": "모두 선택",
|
||||
"DESELECT_ALL": "모두 선택 해제",
|
||||
"SELECT_FILTERED": "검색 결과 선택",
|
||||
"DESELECT_FILTERED": "검색 결과 선택 해제",
|
||||
"SEARCH_PLACEHOLDER": "카테고리 검색...",
|
||||
"NO_RESULTS": "일치하는 카테고리를 찾을 수 없습니다",
|
||||
"NO_CATEGORIES": "사용 가능한 카테고리가 없습니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -996,9 +996,11 @@
|
||||
"CATEGORY_MANAGEMENT": {
|
||||
"TITLE": "Управление категориями",
|
||||
"LOADING": "Загрузка категорий…",
|
||||
"SELECTED": "Выбрано",
|
||||
"SELECTED": "Всего выбрано",
|
||||
"SELECT_ALL": "Выбрать всё",
|
||||
"DESELECT_ALL": "Снять выделение",
|
||||
"SELECT_FILTERED": "Выбрать найденные",
|
||||
"DESELECT_FILTERED": "Снять выбор с найденных",
|
||||
"SEARCH_PLACEHOLDER": "Поиск категорий…",
|
||||
"NO_RESULTS": "Подходящих категорий не найдено",
|
||||
"NO_CATEGORIES": "Нет доступных категорий",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -996,9 +996,11 @@
|
||||
"CATEGORY_MANAGEMENT": {
|
||||
"TITLE": "管理分类",
|
||||
"LOADING": "正在加载分类…",
|
||||
"SELECTED": "已选择",
|
||||
"SELECTED": "已选总数",
|
||||
"SELECT_ALL": "全选",
|
||||
"DESELECT_ALL": "取消全选",
|
||||
"SELECT_FILTERED": "选择搜索结果",
|
||||
"DESELECT_FILTERED": "取消选择搜索结果",
|
||||
"SEARCH_PLACEHOLDER": "搜索分类…",
|
||||
"NO_RESULTS": "未找到匹配的分类",
|
||||
"NO_CATEGORIES": "没有可用的分类",
|
||||
|
||||
@@ -996,9 +996,11 @@
|
||||
"CATEGORY_MANAGEMENT": {
|
||||
"TITLE": "管理類別",
|
||||
"LOADING": "正在載入類別...",
|
||||
"SELECTED": "已選取",
|
||||
"SELECTED": "已選總數",
|
||||
"SELECT_ALL": "全部選取",
|
||||
"DESELECT_ALL": "全部取消選取",
|
||||
"SELECT_FILTERED": "選取搜尋結果",
|
||||
"DESELECT_FILTERED": "取消選取搜尋結果",
|
||||
"SEARCH_PLACEHOLDER": "搜尋類別...",
|
||||
"NO_RESULTS": "找不到符合的類別",
|
||||
"NO_CATEGORIES": "沒有可用的類別",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+20
-8
@@ -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)"
|
||||
>
|
||||
|
||||
+204
@@ -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]);
|
||||
}
|
||||
);
|
||||
});
|
||||
+19
-20
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user