]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Fix: add root tag filtering for tag list page consistency, fix toggle all (#11208)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Tue, 28 Oct 2025 18:04:22 +0000 (11:04 -0700)
committerGitHub <noreply@github.com>
Tue, 28 Oct 2025 18:04:22 +0000 (11:04 -0700)
src-ui/src/app/components/manage/management-list/management-list.component.spec.ts
src-ui/src/app/components/manage/management-list/management-list.component.ts
src-ui/src/app/components/manage/tag-list/tag-list.component.spec.ts
src-ui/src/app/components/manage/tag-list/tag-list.component.ts
src/documents/filters.py
src/documents/tests/test_tag_hierarchy.py

index 95927849acd5df5d270e75cc4bfcc1d82f4f93a4..9c64f57309e44d6aa1491d0cd2ed01cabf6a2cb7 100644 (file)
@@ -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])
+  })
 })
index 60c524c280fa07ff2d60e413797d01b3052f4c74..7cc3eaf4b7773b7765a858453b79ccc5ecc7d05b 100644 (file)
@@ -297,13 +297,19 @@ export abstract class ManagementListComponent<T extends MatchingModel>
   }
 
   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()
index b4694e14554e8e4669044b7572fe452af42d2c0a..91cf092641aff895c8eeccb69f0c3dd0e5dcdddb 100644 (file)
@@ -17,6 +17,7 @@ describe('TagListComponent', () => {
   let component: TagListComponent
   let fixture: ComponentFixture<TagListComponent>
   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)
+  })
 })
index 77d42e020ec0f7e2baab58db4ca562184046197e..bf5ad1b50765c46ffb4ea7dd03c6b9967e9b5be2 100644 (file)
@@ -61,9 +61,29 @@ export class TagListComponent extends ManagementListComponent<Tag> {
     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
+  }
 }
index 87274f9fad3deab6511d9e344cf115ea8ba2bd65..3a8d4d327fe4d700cfe27b6d2daa8455759c9f9e 100644 (file)
@@ -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:
index 708e3e534d6d8276993025f4ec29738baf8abddc..e748225cd6dd64f222916ced861017c371511c4a 100644 (file)
@@ -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"])