From ac381d2344e0be7355b64731a417f60db9bc8b18 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:24:17 -0700 Subject: [PATCH] Fix: ensure parent + child tags change together in bulk editor (#13972) --- .../filterable-dropdown.component.spec.ts | 36 ++++++++++++++++++- .../filterable-dropdown.component.ts | 26 +++++++++++--- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts index c58244018..7cf2083aa 100644 --- a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts +++ b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts @@ -703,6 +703,40 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => expect(selectionModel.getSelectedItems()).toEqual([other]) }) + it('re-selects ancestors when a child is re-selected while editing', () => { + // https://github.com/paperless-ngx/paperless-ngx/issues/13970 + const inbox: Tag = { id: 200, name: 'Inbox' } + const parent: Tag = { id: 201, name: 'Parent Tag' } + const child: Tag = { id: 202, name: 'Child Tag', parent: parent.id } + + selectionModel.editing = true + selectionModel.items = [inbox, parent, child] + selectionModel.init( + new Map([ + [inbox.id, ToggleableItemState.Selected], + [parent.id, ToggleableItemState.Selected], + [child.id, ToggleableItemState.Selected], + ]) + ) + + // deselecting the parent also deselects the child + selectionModel.toggle(parent.id, false) + expect(selectionModel.getSelectedItems()).toEqual([inbox]) + + // re-selecting the child brings its parent back, so nothing is changed + selectionModel.toggle(child.id, false) + expect( + selectionModel + .getSelectedItems() + .map((item) => item.id) + .sort((a, b) => a - b) + ).toEqual([inbox.id, parent.id, child.id]) + expect(selectionModel.diff()).toEqual({ + itemsToAdd: [], + itemsToRemove: [], + }) + }) + it('un-excluding a parent clears excluded descendants', () => { const root: Tag = { id: 110, name: 'Root Tag' } const child: Tag = { id: 111, name: 'Child Tag', parent: root.id } @@ -739,7 +773,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => const apple: Tag = { id: 55, name: 'Apple' } const zebra: Tag = { id: 56, name: 'Zebra' } - selectionModel.documentCountSortingEnabled = true + selectionModel.editing = true selectionModel.items = [apple, zebra] expect(selectionModel.items.map((item) => item?.id ?? null)).toEqual([ null, diff --git a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts index abd2241eb..fdebc7003 100644 --- a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts +++ b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts @@ -78,7 +78,7 @@ export class FilterableDropdownSelectionModel { new Map() ) - public documentCountSortingEnabled = false + public editing = false private get selectionStates(): ReadonlyMap { return this._selectionStates() @@ -93,7 +93,7 @@ export class FilterableDropdownSelectionModel { public set documentCounts(counts: SelectionDataItem[]) { this._documentCounts.set(counts) - if (this.documentCountSortingEnabled) { + if (this.editing) { this._items.set(this.sortItems(this.items)) } } @@ -242,6 +242,9 @@ export class FilterableDropdownSelectionModel { } states.set(id, newState) } + if (this.editing && states.get(id) == ToggleableItemState.Selected) { + this.addAncestorSelections(states, id) + } } else if ( state == ToggleableItemState.Selected || state == ToggleableItemState.Excluded @@ -327,6 +330,21 @@ export class FilterableDropdownSelectionModel { } } + private addAncestorSelections( + states: Map, + id: number + ) { + const parentById = this.buildParentById(this.items) + const seen = new Set([id]) + let parentID = parentById.get(id) + + while (typeof parentID === 'number' && !seen.has(parentID)) { + seen.add(parentID) + states.set(parentID, ToggleableItemState.Selected) + parentID = parentById.get(parentID) + } + } + private getDescendantIDs(id: number): number[] { const descendants: number[] = [] const queue: number[] = [id] @@ -731,7 +749,7 @@ export class FilterableDropdownComponent model.manyToOne = this.selectionModel.manyToOne model.singleSelect = this._editing && !model.manyToOne } - model.documentCountSortingEnabled = this._editing + model.editing = this._editing model.changed.subscribe((updatedModel) => { this.selectionModelChange.next(updatedModel) }) @@ -769,7 +787,7 @@ export class FilterableDropdownComponent if (this.selectionModel) { this.selectionModel.singleSelect = this._editing && !this.selectionModel.manyToOne - this.selectionModel.documentCountSortingEnabled = this._editing + this.selectionModel.editing = this._editing } }