Heirarchy in doc list dropdowns

This commit is contained in:
shamoon
2025-09-10 08:55:31 -07:00
parent 04a16cb723
commit 6cdee1fca9
10 changed files with 89 additions and 13 deletions

View File

@@ -114,6 +114,13 @@ export class FilterableDropdownSelectionModel {
b.id == NEGATIVE_NULL_FILTER_VALUE) b.id == NEGATIVE_NULL_FILTER_VALUE)
) { ) {
return 1 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 ( } else if (
this.getNonTemporary(a.id) == ToggleableItemState.NotSelected && this.getNonTemporary(a.id) == ToggleableItemState.NotSelected &&
this.getNonTemporary(b.id) != ToggleableItemState.NotSelected this.getNonTemporary(b.id) != ToggleableItemState.NotSelected

View File

@@ -15,12 +15,17 @@
<i-bs width="1em" height="1em" name="x"></i-bs> <i-bs width="1em" height="1em" name="x"></i-bs>
} }
</div> </div>
<div class="me-1"> <div class="me-1 name-cell" [style.--depth]="isTag ? getDepth() + 1 : 1">
@if (isTag) { @if (isTag && getDepth() > 0) {
<pngx-tag [tag]="item" [clickable]="false"></pngx-tag> <div class="indicator"></div>
} @else {
<small>{{item.name}}</small>
} }
<div>
@if (isTag) {
<pngx-tag [tag]="item" [clickable]="false"></pngx-tag>
} @else {
<small>{{item.name}}</small>
}
</div>
</div> </div>
@if (!hideCount) { @if (!hideCount) {
<div class="badge bg-light text-dark rounded-pill ms-auto me-1">{{currentCount}}</div> <div class="badge bg-light text-dark rounded-pill ms-auto me-1">{{currentCount}}</div>

View File

@@ -2,3 +2,19 @@
min-width: 1em; min-width: 1em;
min-height: 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;
}
}

View File

@@ -1,6 +1,7 @@
import { Component, EventEmitter, Input, Output } from '@angular/core' import { Component, EventEmitter, Input, Output } from '@angular/core'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { MatchingModel } from 'src/app/data/matching-model' import { MatchingModel } from 'src/app/data/matching-model'
import { Tag } from 'src/app/data/tag'
import { TagComponent } from '../../tag/tag.component' import { TagComponent } from '../../tag/tag.component'
export enum ToggleableItemState { export enum ToggleableItemState {
@@ -18,7 +19,7 @@ export enum ToggleableItemState {
}) })
export class ToggleableDropdownButtonComponent { export class ToggleableDropdownButtonComponent {
@Input() @Input()
item: MatchingModel item: MatchingModel | Tag
@Input() @Input()
state: ToggleableItemState state: ToggleableItemState
@@ -45,6 +46,10 @@ export class ToggleableDropdownButtonComponent {
return 'is_inbox_tag' in this.item return 'is_inbox_tag' in this.item
} }
getDepth(): number {
return (this.item as Tag).depth ?? 0
}
get currentCount(): number { get currentCount(): number {
return this.count ?? this.item.document_count return this.count ?? this.item.document_count
} }

View File

@@ -1204,7 +1204,7 @@ describe('BulkEditorComponent', () => {
expect(tagListAllSpy).toHaveBeenCalled() expect(tagListAllSpy).toHaveBeenCalled()
expect(tagSelectionModelToggleSpy).toHaveBeenCalledWith(newTag.id) 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) [{ id: null, name: 'Not assigned' }].concat(tags.results as any)
) )
}) })

View File

@@ -37,6 +37,7 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.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 { 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 { 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' import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
@@ -164,7 +165,10 @@ export class BulkEditorComponent
this.tagService this.tagService
.listAll() .listAll()
.pipe(first()) .pipe(first())
.subscribe((result) => (this.tagSelectionModel.items = result.results)) .subscribe(
(result) =>
(this.tagSelectionModel.items = flattenTags(result.results))
)
} }
if ( if (
this.permissionService.currentUserCan( this.permissionService.currentUserCan(
@@ -648,7 +652,7 @@ export class BulkEditorComponent
) )
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newTag, tags }) => { .subscribe(({ newTag, tags }) => {
this.tagSelectionModel.items = tags.results this.tagSelectionModel.items = flattenTags(tags.results)
this.tagSelectionModel.toggle(newTag.id) this.tagSelectionModel.toggle(newTag.id)
}) })
} }

View File

@@ -589,7 +589,7 @@ describe('FilterEditorComponent', () => {
expect(component.tagSelectionModel.logicalOperator).toEqual( expect(component.tagSelectionModel.logicalOperator).toEqual(
LogicalOperator.And LogicalOperator.And
) )
expect(component.tagSelectionModel.getSelectedItems()).toEqual(tags) expect(component.tagSelectionModel.getSelectedItems()).toMatchObject(tags)
// coverage // coverage
component.filterRules = [ component.filterRules = [
{ {
@@ -615,7 +615,7 @@ describe('FilterEditorComponent', () => {
expect(component.tagSelectionModel.logicalOperator).toEqual( expect(component.tagSelectionModel.logicalOperator).toEqual(
LogicalOperator.Or LogicalOperator.Or
) )
expect(component.tagSelectionModel.getSelectedItems()).toEqual(tags) expect(component.tagSelectionModel.getSelectedItems()).toMatchObject(tags)
// coverage // coverage
component.filterRules = [ component.filterRules = [
{ {
@@ -652,7 +652,7 @@ describe('FilterEditorComponent', () => {
expect(component.tagSelectionModel.logicalOperator).toEqual( expect(component.tagSelectionModel.logicalOperator).toEqual(
LogicalOperator.And LogicalOperator.And
) )
expect(component.tagSelectionModel.getExcludedItems()).toEqual(tags) expect(component.tagSelectionModel.getExcludedItems()).toMatchObject(tags)
// coverage // coverage
component.filterRules = [ component.filterRules = [
{ {

View File

@@ -97,6 +97,7 @@ import {
CustomFieldQueryExpression, CustomFieldQueryExpression,
} from 'src/app/utils/custom-field-query-element' } from 'src/app/utils/custom-field-query-element'
import { filterRulesDiffer } from 'src/app/utils/filter-rules' import { filterRulesDiffer } from 'src/app/utils/filter-rules'
import { flattenTags } from 'src/app/utils/flatten-tags'
import { import {
CustomFieldQueriesModel, CustomFieldQueriesModel,
CustomFieldsQueryDropdownComponent, CustomFieldsQueryDropdownComponent,
@@ -1134,7 +1135,7 @@ export class FilterEditorComponent
) { ) {
this.loadingCountTotal++ this.loadingCountTotal++
this.tagService.listAll().subscribe((result) => { this.tagService.listAll().subscribe((result) => {
this.tagSelectionModel.items = result.results this.tagSelectionModel.items = flattenTags(result.results)
this.maybeCompleteLoading() this.maybeCompleteLoading()
}) })
} }

View File

@@ -10,4 +10,8 @@ export interface Tag extends MatchingModel {
parent?: number // Tag ID parent?: number // Tag ID
children?: Tag[] // read-only children?: Tag[] // read-only
// UI-only: computed depth and order for hierarchical dropdowns
depth?: number
orderIndex?: number
} }

View File

@@ -0,0 +1,34 @@
import { Tag } from '../data/tag'
export function flattenTags(all: Tag[]): Tag[] {
const map = new Map<number, Tag>(
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
}