+
@if (item.id && tags) {
-
+ @if (getTag(item.id)?.parent) {
+
+
+
+ @for (p of getParentChain(item.id); track p.id) {
+ {{p.name}}
+
+ }
+
+
+ }
+
}
diff --git a/src-ui/src/app/components/common/input/tags/tags.component.scss b/src-ui/src/app/components/common/input/tags/tags.component.scss
index 342342f25..52292d5cb 100644
--- a/src-ui/src/app/components/common/input/tags/tags.component.scss
+++ b/src-ui/src/app/components/common/input/tags/tags.component.scss
@@ -20,3 +20,33 @@
}
}
}
+
+// Dropdown hierarchy reveal for ng-select options
+::ng-deep .ng-dropdown-panel .ng-option {
+ overflow-x: scroll;
+
+ .tag-option-row {
+ font-size: 1rem;
+ width: max-content;
+ }
+
+ .hierarchy-reveal {
+ overflow: hidden;
+ max-width: 0;
+ transition: max-width 200ms ease;
+ }
+
+ .parents .badge {
+ white-space: nowrap;
+ }
+}
+
+::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-reveal,
+::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-reveal {
+ max-width: 1000px;
+}
+
+::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-indicator,
+::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-indicator {
+ background: transparent;
+}
diff --git a/src-ui/src/app/components/common/input/tags/tags.component.spec.ts b/src-ui/src/app/components/common/input/tags/tags.component.spec.ts
index ceac0ea1b..3c69fbade 100644
--- a/src-ui/src/app/components/common/input/tags/tags.component.spec.ts
+++ b/src-ui/src/app/components/common/input/tags/tags.component.spec.ts
@@ -177,4 +177,59 @@ describe('TagsComponent', () => {
component.onFilterDocuments()
expect(emitSpy).toHaveBeenCalledWith([tags[2]])
})
+
+ it('should remove all descendants from selection', () => {
+ const c: Tag = { id: 4, name: 'c' }
+ const b: Tag = { id: 3, name: 'b', children: [c] }
+ const a: Tag = { id: 2, name: 'a' }
+ const root: Tag = { id: 1, name: 'root', children: [a, b] }
+
+ const inputIDs = [2, 3, 4, 99]
+ const result = (component as any).removeChildren(inputIDs, root)
+ expect(result).toEqual([99])
+ })
+
+ it('should append all parents recursively', () => {
+ const root: Tag = { id: 1, name: 'root' }
+ const mid: Tag = { id: 2, name: 'mid', parent: 1 }
+ const leaf: Tag = { id: 3, name: 'leaf', parent: 2 }
+ component.tags = [root, mid, leaf]
+
+ component.value = []
+ component.onAdd(leaf)
+ expect(component.value).toEqual([2, 1])
+
+ // Calling onAdd on a root should not change value
+ component.onAdd(root)
+ expect(component.value).toEqual([2, 1])
+ })
+
+ it('should return ancestors from root to parent using getParentChain', () => {
+ const root: Tag = { id: 1, name: 'root' }
+ const mid: Tag = { id: 2, name: 'mid', parent: 1 }
+ const leaf: Tag = { id: 3, name: 'leaf', parent: 2 }
+ component.tags = [root, mid, leaf]
+
+ expect(component.getParentChain(3).map((t) => t.id)).toEqual([1, 2])
+ expect(component.getParentChain(2).map((t) => t.id)).toEqual([1])
+ expect(component.getParentChain(1).map((t) => t.id)).toEqual([])
+ // Non-existent id
+ expect(component.getParentChain(999).map((t) => t.id)).toEqual([])
+ })
+
+ it('should handle cyclic parents via guard in getParentChain', () => {
+ const one: Tag = { id: 1, name: 'one', parent: 2 }
+ const two: Tag = { id: 2, name: 'two', parent: 1 }
+ component.tags = [one, two]
+
+ const chain = component.getParentChain(1)
+ // Guard avoids infinite loop; chain contains both nodes once
+ expect(chain.map((t) => t.id)).toEqual([1, 2])
+ })
+
+ it('should stop when parent does not exist in getParentChain', () => {
+ const lone: Tag = { id: 5, name: 'lone', parent: 999 }
+ component.tags = [lone]
+ expect(component.getParentChain(5)).toEqual([])
+ })
})
diff --git a/src-ui/src/app/components/common/input/tags/tags.component.ts b/src-ui/src/app/components/common/input/tags/tags.component.ts
index 3ad4106b1..323d3ddf1 100644
--- a/src-ui/src/app/components/common/input/tags/tags.component.ts
+++ b/src-ui/src/app/components/common/input/tags/tags.component.ts
@@ -100,6 +100,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
@Input()
horizontal: boolean = false
+ @Input()
+ multiple: boolean = true
+
@Output()
filterDocuments = new EventEmitter
()
@@ -124,13 +127,40 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
let index = this.value.indexOf(tagID)
if (index > -1) {
+ const tag = this.getTag(tagID)
+
+ // remove tag
let oldValue = this.value
oldValue.splice(index, 1)
+
+ // remove children
+ oldValue = this.removeChildren(oldValue, tag)
+
this.value = [...oldValue]
this.onChange(this.value)
}
}
+ private removeChildren(tagIDs: number[], tag: Tag) {
+ if (tag.children?.length) {
+ const childIDs = tag.children.map((child) => child.id)
+ tagIDs = tagIDs.filter((id) => !childIDs.includes(id))
+ for (const child of tag.children) {
+ tagIDs = this.removeChildren(tagIDs, child)
+ }
+ }
+ return tagIDs
+ }
+
+ public onAdd(tag: Tag) {
+ if (tag.parent) {
+ // add all parents recursively
+ const parent = this.getTag(tag.parent)
+ this.value = [...this.value, parent.id]
+ this.onAdd(parent)
+ }
+ }
+
createTag(name: string = null, add: boolean = false) {
var modal = this.modalService.open(TagEditDialogComponent, {
backdrop: 'static',
@@ -166,6 +196,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
addTag(id) {
this.value = [...this.value, id]
+ this.onAdd(this.getTag(id))
this.onChange(this.value)
}
@@ -180,4 +211,20 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
this.tags.filter((t) => this.value.includes(t.id))
)
}
+
+ getParentChain(id: number): Tag[] {
+ // Returns ancestors from root → immediate parent for a tag id
+ const chain: Tag[] = []
+ let current = this.getTag(id)
+ const guard = new Set()
+ while (current?.parent) {
+ if (guard.has(current.parent)) break
+ guard.add(current.parent)
+ const parent = this.getTag(current.parent)
+ if (!parent) break
+ chain.unshift(parent)
+ current = parent
+ }
+ return chain
+ }
}
diff --git a/src-ui/src/app/components/common/tag/tag.component.html b/src-ui/src/app/components/common/tag/tag.component.html
index df1767db8..ce2175eae 100644
--- a/src-ui/src/app/components/common/tag/tag.component.html
+++ b/src-ui/src/app/components/common/tag/tag.component.html
@@ -1,4 +1,8 @@
@if (tag) {
+ @if (showParents && tag.parent) {
+
+ >
+ }
@if (!clickable) {
{{tag.name}}
}
diff --git a/src-ui/src/app/components/common/tag/tag.component.ts b/src-ui/src/app/components/common/tag/tag.component.ts
index d922b62ac..c2f97f2a8 100644
--- a/src-ui/src/app/components/common/tag/tag.component.ts
+++ b/src-ui/src/app/components/common/tag/tag.component.ts
@@ -50,4 +50,7 @@ export class TagComponent {
@Input()
clickable: boolean = false
+
+ @Input()
+ showParents: boolean = false
}
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/components/manage/correspondent-list/correspondent-list.component.ts b/src-ui/src/app/components/manage/correspondent-list/correspondent-list.component.ts
index 4ba7de689..0131ac992 100644
--- a/src-ui/src/app/components/manage/correspondent-list/correspondent-list.component.ts
+++ b/src-ui/src/app/components/manage/correspondent-list/correspondent-list.component.ts
@@ -1,4 +1,4 @@
-import { NgClass, TitleCasePipe } from '@angular/common'
+import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
import { Component, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
@@ -30,6 +30,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
FormsModule,
ReactiveFormsModule,
NgClass,
+ NgTemplateOutlet,
NgbDropdownModule,
NgbPaginationModule,
NgxBootstrapIconsModule,
diff --git a/src-ui/src/app/components/manage/document-type-list/document-type-list.component.ts b/src-ui/src/app/components/manage/document-type-list/document-type-list.component.ts
index cbb2c576e..21a4779e9 100644
--- a/src-ui/src/app/components/manage/document-type-list/document-type-list.component.ts
+++ b/src-ui/src/app/components/manage/document-type-list/document-type-list.component.ts
@@ -1,4 +1,4 @@
-import { NgClass, TitleCasePipe } from '@angular/common'
+import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
import { Component, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
@@ -28,6 +28,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
FormsModule,
ReactiveFormsModule,
NgClass,
+ NgTemplateOutlet,
NgbDropdownModule,
NgbPaginationModule,
NgxBootstrapIconsModule,
diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.html b/src-ui/src/app/components/manage/management-list/management-list.component.html
index 7e8f46511..43b2f25cd 100644
--- a/src-ui/src/app/components/manage/management-list/management-list.component.html
+++ b/src-ui/src/app/components/manage/management-list/management-list.component.html
@@ -54,61 +54,7 @@
}
@for (object of data; track object) {
-
-
-
-
-
-
- |
- |
- {{ getMatching(object) }} |
- {{ object.document_count }} |
- @for (column of extraColumns; track column) {
-
- @if (column.rendersHtml) {
-
- } @else if (column.monospace) {
- {{ column.valueFn.call(null, object) }}
- } @else {
- {{ column.valueFn.call(null, object) }}
- }
- |
- }
-
-
- |
-
+
}
@@ -129,3 +75,72 @@
}
}
+
+