]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Fix: refactor nested sorting in filterable dropdowns (#11214)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Wed, 29 Oct 2025 02:54:39 +0000 (19:54 -0700)
committerGitHub <noreply@github.com>
Wed, 29 Oct 2025 02:54:39 +0000 (19:54 -0700)
src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts
src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts

index 06bc18b0cfb97062ede347b1fedb90ba2119c99b..5b643dc9ffa1bb89e0d7b126405b8a5c36502430 100644 (file)
@@ -564,6 +564,167 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
     ])
   })
 
+  it('keeps children with their parent when parent has document count', () => {
+    const parent: Tag = {
+      id: 10,
+      name: 'Parent Tag',
+      orderIndex: 0,
+      document_count: 2,
+    }
+    const child: Tag = {
+      id: 11,
+      name: 'Child Tag',
+      parent: parent.id,
+      orderIndex: 1,
+      document_count: 0,
+    }
+    const otherRoot: Tag = {
+      id: 20,
+      name: 'Other Tag',
+      orderIndex: 2,
+      document_count: 0,
+    }
+
+    component.selectionModel.items = [parent, child, otherRoot]
+    component.selectionModel = selectionModel
+    component.documentCounts = [
+      { id: parent.id, document_count: 2 },
+      { id: otherRoot.id, document_count: 0 },
+    ]
+    selectionModel.apply()
+
+    expect(component.selectionModel.items).toEqual([
+      nullItem,
+      parent,
+      child,
+      otherRoot,
+    ])
+  })
+
+  it('keeps selected branches ahead of document-based ordering', () => {
+    const selectedRoot: Tag = {
+      id: 30,
+      name: 'Selected Root',
+      orderIndex: 0,
+      document_count: 0,
+    }
+    const otherRoot: Tag = {
+      id: 40,
+      name: 'Other Root',
+      orderIndex: 1,
+      document_count: 2,
+    }
+
+    component.selectionModel.items = [selectedRoot, otherRoot]
+    component.selectionModel = selectionModel
+    selectionModel.set(selectedRoot.id, ToggleableItemState.Selected)
+    component.documentCounts = [
+      { id: selectedRoot.id, document_count: 0 },
+      { id: otherRoot.id, document_count: 2 },
+    ]
+    selectionModel.apply()
+
+    expect(component.selectionModel.items).toEqual([
+      nullItem,
+      selectedRoot,
+      otherRoot,
+    ])
+  })
+
+  it('uses fallback document counts when selection data is missing', () => {
+    const fallbackRoot: Tag = {
+      id: 50,
+      name: 'Fallback Root',
+      orderIndex: 0,
+      document_count: 3,
+    }
+    const fallbackChild: Tag = {
+      id: 51,
+      name: 'Fallback Child',
+      parent: fallbackRoot.id,
+      orderIndex: 1,
+      document_count: 0,
+    }
+    const otherRoot: Tag = {
+      id: 60,
+      name: 'Other Root',
+      orderIndex: 2,
+      document_count: 0,
+    }
+
+    component.selectionModel = selectionModel
+    selectionModel.items = [fallbackRoot, fallbackChild, otherRoot]
+    component.documentCounts = [{ id: otherRoot.id, document_count: 0 }]
+
+    selectionModel.apply()
+
+    expect(selectionModel.items).toEqual([
+      nullItem,
+      fallbackRoot,
+      fallbackChild,
+      otherRoot,
+    ])
+  })
+
+  it('handles special and non-numeric ids when promoting branches', () => {
+    const rootWithDocs: Tag = {
+      id: 70,
+      name: 'Root With Docs',
+      orderIndex: 0,
+      document_count: 1,
+    }
+    const miscItem: any = { id: 'misc', name: 'Misc Item' }
+
+    component.selectionModel = selectionModel
+    selectionModel.intersection = Intersection.Exclude
+    selectionModel.items = [rootWithDocs, miscItem as any]
+    component.documentCounts = [{ id: rootWithDocs.id, document_count: 1 }]
+
+    selectionModel.apply()
+
+    expect(selectionModel.items.map((item) => item.id)).toEqual([
+      NEGATIVE_NULL_FILTER_VALUE,
+      rootWithDocs.id,
+      'misc',
+    ])
+  })
+
+  it('memoizes root document counts between lookups', () => {
+    const memoRoot: Tag = { id: 80, name: 'Memo Root' }
+    selectionModel.items = [memoRoot]
+    selectionModel.documentCounts = [{ id: memoRoot.id, document_count: 9 }]
+
+    const getRootDocCount = (selectionModel as any).createRootDocCounter()
+
+    expect(getRootDocCount(memoRoot.id)).toEqual(9)
+    selectionModel.documentCounts = []
+    expect(getRootDocCount(memoRoot.id)).toEqual(9)
+  })
+
+  it('falls back to model stored document counts if selection data missing entry', () => {
+    const rootWithoutSelection: Tag = {
+      id: 90,
+      name: 'Fallback Root',
+      document_count: 4,
+    }
+    selectionModel.items = [rootWithoutSelection]
+    selectionModel.documentCounts = []
+
+    const getRootDocCount = (selectionModel as any).createRootDocCounter()
+
+    expect(getRootDocCount(rootWithoutSelection.id)).toEqual(4)
+  })
+
+  it('defaults to zero document count when neither selection nor model provide it', () => {
+    const rootWithoutCounts: Tag = { id: 91, name: 'Fallback Zero Root' }
+    selectionModel.items = [rootWithoutCounts]
+    selectionModel.documentCounts = []
+
+    const getRootDocCount = (selectionModel as any).createRootDocCounter()
+
+    expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
+  })
+
   it('should set support create, keep open model and call createRef method', fakeAsync(() => {
     component.selectionModel.items = items
     component.icon = 'tag-fill'
index 3ca6edc5c8f7084a85b817a314317137f7030a27..1e15930d1cee3e12bd310671c1a45e88a58a4ca7 100644 (file)
@@ -32,6 +32,14 @@ export interface ChangedItems {
   itemsToRemove: MatchingModel[]
 }
 
+type BranchSummary = {
+  items: MatchingModel[]
+  firstIndex: number
+  special: boolean
+  selected: boolean
+  hasDocs: boolean
+}
+
 export enum LogicalOperator {
   And = 'and',
   Or = 'or',
@@ -114,6 +122,13 @@ export class FilterableDropdownSelectionModel {
           b.id == NEGATIVE_NULL_FILTER_VALUE)
       ) {
         return 1
+      }
+
+      // Preserve hierarchical order when provided (e.g., Tags)
+      const ao = (a as any)['orderIndex']
+      const bo = (b as any)['orderIndex']
+      if (ao !== undefined && bo !== undefined) {
+        return ao - bo
       } else if (
         this.getNonTemporary(a.id) == ToggleableItemState.NotSelected &&
         this.getNonTemporary(b.id) != ToggleableItemState.NotSelected
@@ -136,17 +151,14 @@ export class FilterableDropdownSelectionModel {
         this.getDocumentCount(a.id) < this.getDocumentCount(b.id)
       ) {
         return 1
-      }
-
-      // Preserve hierarchical order when provided (e.g., Tags)
-      const ao = (a as any)['orderIndex']
-      const bo = (b as any)['orderIndex']
-      if (ao !== undefined && bo !== undefined) {
-        return ao - bo
       } else {
         return a.name.localeCompare(b.name)
       }
     })
+
+    if (this._documentCounts.length) {
+      this.promoteBranchesWithDocumentCounts()
+    }
   }
 
   private selectionStates = new Map<number, ToggleableItemState>()
@@ -380,6 +392,180 @@ export class FilterableDropdownSelectionModel {
     return this._documentCounts.find((c) => c.id === id)?.document_count
   }
 
+  private promoteBranchesWithDocumentCounts() {
+    const parentById = this.buildParentById()
+    const findRootId = this.createRootFinder(parentById)
+    const getRootDocCount = this.createRootDocCounter()
+    const summaries = this.buildBranchSummaries(findRootId, getRootDocCount)
+    const orderedBranches = this.orderBranchesByPriority(summaries)
+
+    this._items = orderedBranches.flatMap((summary) => summary.items)
+  }
+
+  private buildParentById(): Map<number, number | null> {
+    const parentById = new Map<number, number | null>()
+
+    for (const item of this._items) {
+      if (typeof item?.id === 'number') {
+        const parentValue = (item as any)['parent']
+        parentById.set(
+          item.id,
+          typeof parentValue === 'number' ? parentValue : null
+        )
+      }
+    }
+
+    return parentById
+  }
+
+  private createRootFinder(
+    parentById: Map<number, number | null>
+  ): (id: number) => number {
+    const rootMemo = new Map<number, number>()
+
+    const findRootId = (id: number): number => {
+      const cached = rootMemo.get(id)
+      if (cached !== undefined) {
+        return cached
+      }
+
+      const parentId = parentById.get(id)
+      if (parentId === undefined || parentId === null) {
+        rootMemo.set(id, id)
+        return id
+      }
+
+      const rootId = findRootId(parentId)
+      rootMemo.set(id, rootId)
+      return rootId
+    }
+
+    return findRootId
+  }
+
+  private createRootDocCounter(): (rootId: number) => number {
+    const docCountMemo = new Map<number, number>()
+
+    return (rootId: number): number => {
+      const cached = docCountMemo.get(rootId)
+      if (cached !== undefined) {
+        return cached
+      }
+
+      const explicit = this.getDocumentCount(rootId)
+      if (typeof explicit === 'number') {
+        docCountMemo.set(rootId, explicit)
+        return explicit
+      }
+
+      const rootItem = this._items.find((i) => i.id === rootId)
+      const fallback =
+        typeof (rootItem as any)?.['document_count'] === 'number'
+          ? (rootItem as any)['document_count']
+          : 0
+
+      docCountMemo.set(rootId, fallback)
+      return fallback
+    }
+  }
+
+  private buildBranchSummaries(
+    findRootId: (id: number) => number,
+    getRootDocCount: (rootId: number) => number
+  ): Map<string, BranchSummary> {
+    const summaries = new Map<string, BranchSummary>()
+
+    for (const [index, item] of this._items.entries()) {
+      const { key, special, rootId } = this.describeBranchItem(
+        item,
+        index,
+        findRootId
+      )
+
+      let summary = summaries.get(key)
+      if (!summary) {
+        summary = {
+          items: [],
+          firstIndex: index,
+          special,
+          selected: false,
+          hasDocs:
+            special || rootId === null ? false : getRootDocCount(rootId) > 0,
+        }
+        summaries.set(key, summary)
+      }
+
+      summary.items.push(item)
+
+      if (this.shouldMarkSummarySelected(summary, item)) {
+        summary.selected = true
+      }
+    }
+
+    return summaries
+  }
+
+  private describeBranchItem(
+    item: MatchingModel,
+    index: number,
+    findRootId: (id: number) => number
+  ): { key: string; special: boolean; rootId: number | null } {
+    if (item?.id === null) {
+      return { key: 'null', special: true, rootId: null }
+    }
+
+    if (item?.id === NEGATIVE_NULL_FILTER_VALUE) {
+      return { key: 'neg-null', special: true, rootId: null }
+    }
+
+    if (typeof item?.id === 'number') {
+      const rootId = findRootId(item.id)
+      return { key: `root-${rootId}`, special: false, rootId }
+    }
+
+    return { key: `misc-${index}`, special: false, rootId: null }
+  }
+
+  private shouldMarkSummarySelected(
+    summary: BranchSummary,
+    item: MatchingModel
+  ): boolean {
+    if (summary.special) {
+      return false
+    }
+
+    if (typeof item?.id !== 'number') {
+      return false
+    }
+
+    return this.getNonTemporary(item.id) !== ToggleableItemState.NotSelected
+  }
+
+  private orderBranchesByPriority(
+    summaries: Map<string, BranchSummary>
+  ): BranchSummary[] {
+    return Array.from(summaries.values()).sort((a, b) => {
+      const rankDiff = this.branchRank(a) - this.branchRank(b)
+      if (rankDiff !== 0) {
+        return rankDiff
+      }
+      if (a.hasDocs !== b.hasDocs) {
+        return a.hasDocs ? -1 : 1
+      }
+      return a.firstIndex - b.firstIndex
+    })
+  }
+
+  private branchRank(summary: BranchSummary): number {
+    if (summary.special) {
+      return -1
+    }
+    if (summary.selected) {
+      return 0
+    }
+    return 1
+  }
+
   init(map: Map<number, ToggleableItemState>) {
     this.temporarySelectionStates = map
     this.apply()