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 +}