mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-09-14 21:45:37 -05:00
Heirarchy in doc list dropdowns
This commit is contained in:
@@ -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
|
||||||
|
@@ -15,13 +15,18 @@
|
|||||||
<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 && getDepth() > 0) {
|
||||||
|
<div class="indicator"></div>
|
||||||
|
}
|
||||||
|
<div>
|
||||||
@if (isTag) {
|
@if (isTag) {
|
||||||
<pngx-tag [tag]="item" [clickable]="false"></pngx-tag>
|
<pngx-tag [tag]="item" [clickable]="false"></pngx-tag>
|
||||||
} @else {
|
} @else {
|
||||||
<small>{{item.name}}</small>
|
<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>
|
||||||
}
|
}
|
||||||
|
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -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
|
||||||
}
|
}
|
||||||
|
@@ -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)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
@@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@@ -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 = [
|
||||||
{
|
{
|
||||||
|
@@ -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()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@@ -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
|
||||||
}
|
}
|
||||||
|
34
src-ui/src/app/utils/flatten-tags.ts
Normal file
34
src-ui/src/app/utils/flatten-tags.ts
Normal 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
|
||||||
|
}
|
Reference in New Issue
Block a user