From d718d7d29f7cc5212df3bcb570a6af87c34fa9e0 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 28 Oct 2025 11:04:22 -0700 Subject: [PATCH] Fix: add root tag filtering for tag list page consistency, fix toggle all (#11208) --- .../management-list.component.spec.ts | 7 +++ .../management-list.component.ts | 10 +++- .../tag-list/tag-list.component.spec.ts | 56 ++++++++++++++++++- .../manage/tag-list/tag-list.component.ts | 20 +++++++ src/documents/filters.py | 6 ++ src/documents/tests/test_tag_hierarchy.py | 21 +++++++ 6 files changed, 117 insertions(+), 3 deletions(-) diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts b/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts index 95927849ac..9c64f57309 100644 --- a/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts +++ b/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts @@ -361,4 +361,11 @@ describe('ManagementListComponent', () => { const original = component.getOriginalObject({ id: 4 } as Tag) expect(original).toEqual(childTag) }) + + it('getSelectableIDs should return flat ids when not overridden', () => { + const ids = ( + ManagementListComponent.prototype as any + ).getSelectableIDs.call({}, [{ id: 1 }, { id: 5 }] as any) + expect(ids).toEqual([1, 5]) + }) }) diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.ts b/src-ui/src/app/components/manage/management-list/management-list.component.ts index 60c524c280..7cc3eaf4b7 100644 --- a/src-ui/src/app/components/manage/management-list/management-list.component.ts +++ b/src-ui/src/app/components/manage/management-list/management-list.component.ts @@ -297,13 +297,19 @@ export abstract class ManagementListComponent } toggleAll(event: PointerEvent) { - if ((event.target as HTMLInputElement).checked) { - this.selectedObjects = new Set(this.data.map((o) => o.id)) + const checked = (event.target as HTMLInputElement).checked + this.togggleAll = checked + if (checked) { + this.selectedObjects = new Set(this.getSelectableIDs(this.data)) } else { this.clearSelection() } } + protected getSelectableIDs(objects: T[]): number[] { + return objects.map((o) => o.id) + } + clearSelection() { this.togggleAll = false this.selectedObjects.clear() diff --git a/src-ui/src/app/components/manage/tag-list/tag-list.component.spec.ts b/src-ui/src/app/components/manage/tag-list/tag-list.component.spec.ts index b4694e1455..91cf092641 100644 --- a/src-ui/src/app/components/manage/tag-list/tag-list.component.spec.ts +++ b/src-ui/src/app/components/manage/tag-list/tag-list.component.spec.ts @@ -17,6 +17,7 @@ describe('TagListComponent', () => { let component: TagListComponent let fixture: ComponentFixture let tagService: TagService + let listFilteredSpy: jest.SpyInstance beforeEach(async () => { TestBed.configureTestingModule({ @@ -39,7 +40,7 @@ describe('TagListComponent', () => { }).compileComponents() tagService = TestBed.inject(TagService) - jest.spyOn(tagService, 'listFiltered').mockReturnValue( + listFilteredSpy = jest.spyOn(tagService, 'listFiltered').mockReturnValue( of({ count: 3, all: [1, 2, 3], @@ -87,4 +88,57 @@ describe('TagListComponent', () => { const filteredWithName = component.filterData(tags as any) expect(filteredWithName.length).toBe(3) }) + + it('should request only parent tags when no name filter is applied', () => { + expect(tagService.listFiltered).toHaveBeenCalledWith( + 1, + null, + undefined, + undefined, + undefined, + true, + { is_root: true } + ) + }) + + it('should include child tags when a name filter is applied', () => { + listFilteredSpy.mockClear() + component['_nameFilter'] = 'Tag' + component.reloadData() + expect(tagService.listFiltered).toHaveBeenCalledWith( + 1, + null, + undefined, + undefined, + 'Tag', + true, + null + ) + }) + + it('should include child tags when selecting all', () => { + const parent = { + id: 10, + name: 'Parent', + children: [ + { + id: 11, + name: 'Child', + }, + ], + } + + component.data = [parent as any] + const selectEvent = { target: { checked: true } } as unknown as PointerEvent + component.toggleAll(selectEvent) + + expect(component.selectedObjects.has(10)).toBe(true) + expect(component.selectedObjects.has(11)).toBe(true) + + const deselectEvent = { + target: { checked: false }, + } as unknown as PointerEvent + component.toggleAll(deselectEvent) + expect(component.selectedObjects.size).toBe(0) + }) }) diff --git a/src-ui/src/app/components/manage/tag-list/tag-list.component.ts b/src-ui/src/app/components/manage/tag-list/tag-list.component.ts index 77d42e020e..bf5ad1b507 100644 --- a/src-ui/src/app/components/manage/tag-list/tag-list.component.ts +++ b/src-ui/src/app/components/manage/tag-list/tag-list.component.ts @@ -61,9 +61,29 @@ export class TagListComponent extends ManagementListComponent { return $localize`Do you really want to delete the tag "${object.name}"?` } + override reloadData(extraParams: { [key: string]: any } = null) { + const params = this.nameFilter?.length + ? extraParams + : { ...extraParams, is_root: true } + super.reloadData(params) + } + filterData(data: Tag[]) { return this.nameFilter?.length ? [...data] : data.filter((tag) => !tag.parent) } + + protected override getSelectableIDs(tags: Tag[]): number[] { + const ids: number[] = [] + for (const tag of tags.filter(Boolean)) { + if (tag.id != null) { + ids.push(tag.id) + } + if (Array.isArray(tag.children) && tag.children.length) { + ids.push(...this.getSelectableIDs(tag.children)) + } + } + return ids + } } diff --git a/src/documents/filters.py b/src/documents/filters.py index 87274f9fad..3a8d4d327f 100644 --- a/src/documents/filters.py +++ b/src/documents/filters.py @@ -92,6 +92,12 @@ class TagFilterSet(FilterSet): "name": CHAR_KWARGS, } + is_root = BooleanFilter( + label="Is root tag", + field_name="tn_parent", + lookup_expr="isnull", + ) + class DocumentTypeFilterSet(FilterSet): class Meta: diff --git a/src/documents/tests/test_tag_hierarchy.py b/src/documents/tests/test_tag_hierarchy.py index 708e3e534d..e748225cd6 100644 --- a/src/documents/tests/test_tag_hierarchy.py +++ b/src/documents/tests/test_tag_hierarchy.py @@ -229,3 +229,24 @@ class TestTagHierarchy(APITestCase): assert resp_ok.status_code in (200, 202) x.refresh_from_db() assert x.parent_pk == c.id + + def test_is_root_filter_returns_only_root_tags(self): + other_root = Tag.objects.create(name="Other parent") + + response = self.client.get( + "/api/tags/", + {"is_root": "true"}, + ) + + assert response.status_code == 200 + assert response.data["count"] == 2 + + returned_ids = {row["id"] for row in response.data["results"]} + assert self.child.pk not in returned_ids + assert self.parent.pk in returned_ids + assert other_root.pk in returned_ids + + parent_entry = next( + row for row in response.data["results"] if row["id"] == self.parent.pk + ) + assert any(child["id"] == self.child.pk for child in parent_entry["children"]) -- 2.47.3