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 ce1137d2a..6107438da 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
@@ -114,6 +114,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
diff --git a/src-ui/src/app/components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component.html b/src-ui/src/app/components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component.html
index 1c7dad499..3951143ac 100644
--- a/src-ui/src/app/components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component.html
+++ b/src-ui/src/app/components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component.html
@@ -15,12 +15,17 @@
}
-
- @if (isTag) {
-
- } @else {
-
{{item.name}}
+
+ @if (isTag && getDepth() > 0) {
+
}
+
+ @if (isTag) {
+
+ } @else {
+
{{item.name}}
+ }
+
@if (!hideCount) {
{{currentCount}}
diff --git a/src-ui/src/app/components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component.scss b/src-ui/src/app/components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component.scss
index 41fc6acc4..0f4b8ecc6 100644
--- a/src-ui/src/app/components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component.scss
+++ b/src-ui/src/app/components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component.scss
@@ -2,3 +2,19 @@
min-width: 1em;
min-height: 1em;
}
+
+.name-cell {
+ padding-left: calc(calc(var(--depth) - 2) * 1rem);
+ display: flex;
+ align-items: center;
+
+ .indicator {
+ display: inline-block;
+ width: .8rem;
+ height: .8rem;
+ border-left: 1px solid var(--bs-secondary);
+ border-bottom: 1px solid var(--bs-secondary);
+ margin-right: .25rem;
+ margin-left: .5rem;
+ }
+}
diff --git a/src-ui/src/app/components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component.ts b/src-ui/src/app/components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component.ts
index 6c3bc3c9d..933f619fe 100644
--- a/src-ui/src/app/components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component.ts
+++ b/src-ui/src/app/components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component.ts
@@ -1,6 +1,7 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { MatchingModel } from 'src/app/data/matching-model'
+import { Tag } from 'src/app/data/tag'
import { TagComponent } from '../../tag/tag.component'
export enum ToggleableItemState {
@@ -18,7 +19,7 @@ export enum ToggleableItemState {
})
export class ToggleableDropdownButtonComponent {
@Input()
- item: MatchingModel
+ item: MatchingModel | Tag
@Input()
state: ToggleableItemState
@@ -45,6 +46,10 @@ export class ToggleableDropdownButtonComponent {
return 'is_inbox_tag' in this.item
}
+ getDepth(): number {
+ return (this.item as Tag).depth ?? 0
+ }
+
get currentCount(): number {
return this.count ?? this.item.document_count
}
diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts
index 3fddb4b68..457e1888d 100644
--- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts
+++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts
@@ -1204,7 +1204,7 @@ describe('BulkEditorComponent', () => {
expect(tagListAllSpy).toHaveBeenCalled()
expect(tagSelectionModelToggleSpy).toHaveBeenCalledWith(newTag.id)
- expect(component.tagSelectionModel.items).toEqual(
+ expect(component.tagSelectionModel.items).toMatchObject(
[{ id: null, name: 'Not assigned' }].concat(tags.results as any)
)
})
diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts
index 4e7380144..96c180263 100644
--- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts
+++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts
@@ -37,6 +37,7 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
+import { flattenTags } from 'src/app/utils/flatten-tags'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
@@ -164,7 +165,10 @@ export class BulkEditorComponent
this.tagService
.listAll()
.pipe(first())
- .subscribe((result) => (this.tagSelectionModel.items = result.results))
+ .subscribe(
+ (result) =>
+ (this.tagSelectionModel.items = flattenTags(result.results))
+ )
}
if (
this.permissionService.currentUserCan(
@@ -648,7 +652,7 @@ export class BulkEditorComponent
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newTag, tags }) => {
- this.tagSelectionModel.items = tags.results
+ this.tagSelectionModel.items = flattenTags(tags.results)
this.tagSelectionModel.toggle(newTag.id)
})
}
diff --git a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts
index 4e8a797cc..72d3f948c 100644
--- a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts
+++ b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts
@@ -589,7 +589,7 @@ describe('FilterEditorComponent', () => {
expect(component.tagSelectionModel.logicalOperator).toEqual(
LogicalOperator.And
)
- expect(component.tagSelectionModel.getSelectedItems()).toEqual(tags)
+ expect(component.tagSelectionModel.getSelectedItems()).toMatchObject(tags)
// coverage
component.filterRules = [
{
@@ -615,7 +615,7 @@ describe('FilterEditorComponent', () => {
expect(component.tagSelectionModel.logicalOperator).toEqual(
LogicalOperator.Or
)
- expect(component.tagSelectionModel.getSelectedItems()).toEqual(tags)
+ expect(component.tagSelectionModel.getSelectedItems()).toMatchObject(tags)
// coverage
component.filterRules = [
{
@@ -652,7 +652,7 @@ describe('FilterEditorComponent', () => {
expect(component.tagSelectionModel.logicalOperator).toEqual(
LogicalOperator.And
)
- expect(component.tagSelectionModel.getExcludedItems()).toEqual(tags)
+ expect(component.tagSelectionModel.getExcludedItems()).toMatchObject(tags)
// coverage
component.filterRules = [
{
diff --git a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts
index 803a1ca2c..9e83797a6 100644
--- a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts
+++ b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts
@@ -97,6 +97,7 @@ import {
CustomFieldQueryExpression,
} from 'src/app/utils/custom-field-query-element'
import { filterRulesDiffer } from 'src/app/utils/filter-rules'
+import { flattenTags } from 'src/app/utils/flatten-tags'
import {
CustomFieldQueriesModel,
CustomFieldsQueryDropdownComponent,
@@ -1134,7 +1135,7 @@ export class FilterEditorComponent
) {
this.loadingCountTotal++
this.tagService.listAll().subscribe((result) => {
- this.tagSelectionModel.items = result.results
+ this.tagSelectionModel.items = flattenTags(result.results)
this.maybeCompleteLoading()
})
}
diff --git a/src-ui/src/app/data/tag.ts b/src-ui/src/app/data/tag.ts
index 164ee8589..927191add 100644
--- a/src-ui/src/app/data/tag.ts
+++ b/src-ui/src/app/data/tag.ts
@@ -10,4 +10,8 @@ export interface Tag extends MatchingModel {
parent?: number // Tag ID
children?: Tag[] // read-only
+
+ // UI-only: computed depth and order for hierarchical dropdowns
+ depth?: number
+ orderIndex?: number
}
diff --git a/src-ui/src/app/utils/flatten-tags.ts b/src-ui/src/app/utils/flatten-tags.ts
new file mode 100644
index 000000000..6a54add8d
--- /dev/null
+++ b/src-ui/src/app/utils/flatten-tags.ts
@@ -0,0 +1,34 @@
+import { Tag } from '../data/tag'
+
+export function flattenTags(all: Tag[]): Tag[] {
+ const map = new Map
(
+ all.map((t) => [t.id, { ...t, children: [] }])
+ )
+ // rebuild children
+ for (const t of map.values()) {
+ if (t.parent) {
+ const p = map.get(t.parent)
+ p && p.children.push(t)
+ }
+ }
+ const roots = Array.from(map.values()).filter((t) => !t.parent)
+ const sortByName = (a: Tag, b: Tag) =>
+ (a.name || '').localeCompare(b.name || '', undefined, {
+ sensitivity: 'base',
+ numeric: true,
+ })
+ const ordered: Tag[] = []
+ let idx = 0
+ const walk = (node: Tag, depth: number) => {
+ node.depth = depth
+ node.orderIndex = idx++
+ ordered.push(node)
+ if (node.children?.length) {
+ for (const child of [...node.children].sort(sortByName)) {
+ walk(child, depth + 1)
+ }
+ }
+ }
+ roots.sort(sortByName).forEach((r) => walk(r, 0))
+ return ordered
+}