mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -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
+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