mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -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) | ||||
|       ) { | ||||
|         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 | ||||
|   | ||||
| @@ -15,12 +15,17 @@ | ||||
|       <i-bs width="1em" height="1em" name="x"></i-bs> | ||||
|     } | ||||
|   </div> | ||||
|   <div class="me-1"> | ||||
|     @if (isTag) { | ||||
|       <pngx-tag [tag]="item" [clickable]="false"></pngx-tag> | ||||
|     } @else { | ||||
|       <small>{{item.name}}</small> | ||||
|   <div class="me-1 name-cell" [style.--depth]="isTag ? getDepth() + 1 : 1"> | ||||
|     @if (isTag && getDepth() > 0) { | ||||
|       <div class="indicator"></div> | ||||
|     } | ||||
|     <div> | ||||
|       @if (isTag) { | ||||
|         <pngx-tag [tag]="item" [clickable]="false"></pngx-tag> | ||||
|       } @else { | ||||
|         <small>{{item.name}}</small> | ||||
|       } | ||||
|     </div> | ||||
|   </div> | ||||
|   @if (!hideCount) { | ||||
|     <div class="badge bg-light text-dark rounded-pill ms-auto me-1">{{currentCount}}</div> | ||||
|   | ||||
| @@ -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; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
|   } | ||||
|   | ||||
| @@ -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) | ||||
|     ) | ||||
|   }) | ||||
|   | ||||
| @@ -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) | ||||
|       }) | ||||
|   } | ||||
|   | ||||
| @@ -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 = [ | ||||
|       { | ||||
|   | ||||
| @@ -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() | ||||
|       }) | ||||
|     } | ||||
|   | ||||
| @@ -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 | ||||
| } | ||||
|   | ||||
							
								
								
									
										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
	 shamoon
					shamoon