mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Enhancement: global search tweaks (#6674)
This commit is contained in:
		| @@ -6,15 +6,19 @@ | ||||
|             <div class="form-control form-control-sm"> | ||||
|                 <input class="bg-transparent border-0 w-100 h-100" #searchInput type="text" name="query" | ||||
|                     placeholder="Search" aria-label="Search" i18n-placeholder | ||||
|                     autocomplete="off" spellcheck="false" | ||||
|                     [(ngModel)]="query" (ngModelChange)="this.queryDebounce.next($event)" (keydown)="searchInputKeyDown($event)"> | ||||
|                     autocomplete="off" | ||||
|                     spellcheck="false" | ||||
|                     [(ngModel)]="query" | ||||
|                     (ngModelChange)="this.queryDebounce.next($event)" | ||||
|                     (keydown)="searchInputKeyDown($event)" | ||||
|                     ngbDropdownAnchor> | ||||
|                 <div class="position-absolute top-50 end-0 translate-middle"> | ||||
|                     @if (loading) { | ||||
|                         <div class="spinner-border spinner-border-sm text-muted mt-1"></div> | ||||
|                     } | ||||
|                 </div> | ||||
|             </div> | ||||
|             @if (query && (searchResults?.documents.length === searchService.searchResultObjectLimit || searchService.searchDbOnly)) { | ||||
|             @if (query) { | ||||
|                 <button class="btn btn-sm btn-outline-secondary" type="button" (click)="runAdvanedSearch()"> | ||||
|                     <ng-container i18n>Advanced search</ng-container> | ||||
|                     <i-bs width="1em" height="1em" name="arrow-right-short"></i-bs> | ||||
| @@ -25,7 +29,7 @@ | ||||
|  | ||||
|     <ng-template #resultItemTemplate let-item="item" let-nameProp="nameProp" let-type="type" let-icon="icon" let-date="date"> | ||||
|         <div #resultItem ngbDropdownItem class="py-2 d-flex align-items-center focus-ring border-0 cursor-pointer" tabindex="-1" | ||||
|         (click)="primaryAction(type, item)" | ||||
|         (click)="primaryAction(type, item, $event)" | ||||
|         (mouseenter)="onItemHover($event)"> | ||||
|             <i-bs width="1.2em" height="1.2em" name="{{icon}}" class="me-2 text-muted"></i-bs> | ||||
|             <div class="text-truncate"> | ||||
| @@ -36,7 +40,7 @@ | ||||
|             </div> | ||||
|             <div class="btn-group ms-auto"> | ||||
|                 <button #primaryButton type="button" class="btn btn-sm btn-outline-primary d-flex" | ||||
|                 (click)="primaryAction(type, item); $event.stopImmediatePropagation()" | ||||
|                 (click)="primaryAction(type, item, $event); $event.stopImmediatePropagation()" | ||||
|                 (keydown)="onButtonKeyDown($event)" | ||||
|                 [disabled]="disablePrimaryButton(type, item)" | ||||
|                 (mouseenter)="onButtonHover($event)"> | ||||
| @@ -56,7 +60,7 @@ | ||||
|                 </button> | ||||
|                 @if (type !== DataType.SavedView && type !== DataType.Workflow && type !== DataType.CustomField && type !== DataType.Group && type !== DataType.User && type !== DataType.MailAccount && type !== DataType.MailRule) { | ||||
|                     <button #secondaryButton type="button" class="btn btn-sm btn-outline-primary d-flex" | ||||
|                     (click)="secondaryAction(type, item); $event.stopImmediatePropagation()" | ||||
|                     (click)="secondaryAction(type, item, $event); $event.stopImmediatePropagation()" | ||||
|                     (keydown)="onButtonKeyDown($event)" | ||||
|                     [disabled]="disableSecondaryButton(type, item)" | ||||
|                     (mouseenter)="onButtonHover($event)"> | ||||
|   | ||||
| @@ -36,6 +36,7 @@ import { WorkflowEditDialogComponent } from '../../common/edit-dialog/workflow-e | ||||
| import { ElementRef } from '@angular/core' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { DataType } from 'src/app/data/datatype' | ||||
| import { queryParamsFromFilterRules } from 'src/app/utils/query-params' | ||||
|  | ||||
| const searchResults = { | ||||
|   total: 11, | ||||
| @@ -248,10 +249,7 @@ describe('GlobalSearchComponent', () => { | ||||
|     expect(blurSpy).toHaveBeenCalled() | ||||
|  | ||||
|     component.searchResults = { total: 1 } as any | ||||
|     component.resultsDropdown.close() | ||||
|     const openSpy = jest.spyOn(component.resultsDropdown, 'open') | ||||
|     component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })) | ||||
|     expect(openSpy).toHaveBeenCalled() | ||||
|     component.resultsDropdown.open() | ||||
|  | ||||
|     component.searchInputKeyDown( | ||||
|       new KeyboardEvent('keydown', { key: 'ArrowDown' }) | ||||
| @@ -260,6 +258,13 @@ describe('GlobalSearchComponent', () => { | ||||
|     const closeSpy = jest.spyOn(component.resultsDropdown, 'close') | ||||
|     component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'Escape' })) | ||||
|     expect(closeSpy).toHaveBeenCalled() | ||||
|  | ||||
|     component.searchResults = searchResults as any | ||||
|     component.resultsDropdown.open() | ||||
|     component.query = 'test' | ||||
|     const advancedSearchSpy = jest.spyOn(component, 'runAdvanedSearch') | ||||
|     component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })) | ||||
|     expect(advancedSearchSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should search on query debounce', fakeAsync(() => { | ||||
| @@ -276,7 +281,6 @@ describe('GlobalSearchComponent', () => { | ||||
|   it('should support primary action', () => { | ||||
|     const object = { id: 1 } | ||||
|     const routerSpy = jest.spyOn(router, 'navigate') | ||||
|     const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') | ||||
|     const modalSpy = jest.spyOn(modalService, 'open') | ||||
|  | ||||
|     let modal: NgbModalRef | ||||
| @@ -289,23 +293,41 @@ describe('GlobalSearchComponent', () => { | ||||
|     expect(routerSpy).toHaveBeenCalledWith(['/view', object.id]) | ||||
|  | ||||
|     component.primaryAction(DataType.Correspondent, object) | ||||
|     expect(qfSpy).toHaveBeenCalledWith([ | ||||
|       { rule_type: FILTER_HAS_CORRESPONDENT_ANY, value: object.id.toString() }, | ||||
|     expect(routerSpy).toHaveBeenCalledWith([ | ||||
|       '/documents', | ||||
|       queryParamsFromFilterRules([ | ||||
|         { | ||||
|           rule_type: FILTER_HAS_CORRESPONDENT_ANY, | ||||
|           value: object.id.toString(), | ||||
|         }, | ||||
|       ]), | ||||
|     ]) | ||||
|  | ||||
|     component.primaryAction(DataType.DocumentType, object) | ||||
|     expect(qfSpy).toHaveBeenCalledWith([ | ||||
|       { rule_type: FILTER_HAS_DOCUMENT_TYPE_ANY, value: object.id.toString() }, | ||||
|     expect(routerSpy).toHaveBeenCalledWith([ | ||||
|       '/documents', | ||||
|       queryParamsFromFilterRules([ | ||||
|         { | ||||
|           rule_type: FILTER_HAS_DOCUMENT_TYPE_ANY, | ||||
|           value: object.id.toString(), | ||||
|         }, | ||||
|       ]), | ||||
|     ]) | ||||
|  | ||||
|     component.primaryAction(DataType.StoragePath, object) | ||||
|     expect(qfSpy).toHaveBeenCalledWith([ | ||||
|       { rule_type: FILTER_HAS_STORAGE_PATH_ANY, value: object.id.toString() }, | ||||
|     expect(routerSpy).toHaveBeenCalledWith([ | ||||
|       '/documents', | ||||
|       queryParamsFromFilterRules([ | ||||
|         { rule_type: FILTER_HAS_STORAGE_PATH_ANY, value: object.id.toString() }, | ||||
|       ]), | ||||
|     ]) | ||||
|  | ||||
|     component.primaryAction(DataType.Tag, object) | ||||
|     expect(qfSpy).toHaveBeenCalledWith([ | ||||
|       { rule_type: FILTER_HAS_TAGS_ANY, value: object.id.toString() }, | ||||
|     expect(routerSpy).toHaveBeenCalledWith([ | ||||
|       '/documents', | ||||
|       queryParamsFromFilterRules([ | ||||
|         { rule_type: FILTER_HAS_TAGS_ANY, value: object.id.toString() }, | ||||
|       ]), | ||||
|     ]) | ||||
|  | ||||
|     component.primaryAction(DataType.User, object) | ||||
| @@ -450,13 +472,6 @@ describe('GlobalSearchComponent', () => { | ||||
|     expect(focusSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should prevent event propagation for keyboard events on buttons that are not arrows', () => { | ||||
|     const event = { stopImmediatePropagation: jest.fn(), key: 'Enter' } | ||||
|     const stopPropagationSpy = jest.spyOn(event, 'stopImmediatePropagation') | ||||
|     component.onButtonKeyDown(event as any) | ||||
|     expect(stopPropagationSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should support explicit advanced search', () => { | ||||
|     const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') | ||||
|     component.query = 'test' | ||||
| @@ -465,4 +480,25 @@ describe('GlobalSearchComponent', () => { | ||||
|       { rule_type: FILTER_FULLTEXT_QUERY, value: 'test' }, | ||||
|     ]) | ||||
|   }) | ||||
|  | ||||
|   it('should support open in new window', () => { | ||||
|     const openSpy = jest.spyOn(window, 'open') | ||||
|     const event = new Event('click') | ||||
|     event['ctrlKey'] = true | ||||
|     component.primaryAction(DataType.Document, { id: 2 }, event as any) | ||||
|     expect(openSpy).toHaveBeenCalledWith('/documents/2', '_blank') | ||||
|  | ||||
|     component.searchResults = searchResults as any | ||||
|     component.resultsDropdown.open() | ||||
|     fixture.detectChanges() | ||||
|  | ||||
|     const button = component.primaryButtons.get(0).nativeElement | ||||
|     const keyboardEvent = new KeyboardEvent('keydown', { | ||||
|       key: 'Enter', | ||||
|       ctrlKey: true, | ||||
|     }) | ||||
|     const dispatchSpy = jest.spyOn(button, 'dispatchEvent') | ||||
|     button.dispatchEvent(keyboardEvent) | ||||
|     expect(dispatchSpy).toHaveBeenCalledTimes(2) // once for keydown, second for click | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -41,6 +41,7 @@ import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog | ||||
| import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component' | ||||
| import { WorkflowEditDialogComponent } from '../../common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component' | ||||
| import { HotKeyService } from 'src/app/services/hot-key.service' | ||||
| import { queryParamsFromFilterRules } from 'src/app/utils/query-params' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-global-search', | ||||
| @@ -87,7 +88,7 @@ export class GlobalSearchComponent implements OnInit { | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   ngOnInit() { | ||||
|   public ngOnInit() { | ||||
|     this.hotkeyService | ||||
|       .addShortcut({ keys: '/', description: $localize`Global search` }) | ||||
|       .subscribe(() => { | ||||
| @@ -104,17 +105,22 @@ export class GlobalSearchComponent implements OnInit { | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   public primaryAction(type: string, object: ObjectWithId) { | ||||
|   public primaryAction( | ||||
|     type: string, | ||||
|     object: ObjectWithId, | ||||
|     event: PointerEvent = null | ||||
|   ) { | ||||
|     const newWindow = event?.metaKey || event?.ctrlKey | ||||
|     this.reset(true) | ||||
|     let filterRuleType: number | ||||
|     let editDialogComponent: any | ||||
|     let size: string = 'md' | ||||
|     switch (type) { | ||||
|       case DataType.Document: | ||||
|         this.router.navigate(['/documents', object.id]) | ||||
|         this.navigateOrOpenInNewWindow(['/documents', object.id], newWindow) | ||||
|         return | ||||
|       case DataType.SavedView: | ||||
|         this.router.navigate(['/view', object.id]) | ||||
|         this.navigateOrOpenInNewWindow(['/view', object.id], newWindow) | ||||
|         return | ||||
|       case DataType.Correspondent: | ||||
|         filterRuleType = FILTER_HAS_CORRESPONDENT_ANY | ||||
| @@ -154,9 +160,10 @@ export class GlobalSearchComponent implements OnInit { | ||||
|     } | ||||
|  | ||||
|     if (filterRuleType) { | ||||
|       this.documentListViewService.quickFilter([ | ||||
|       let params = queryParamsFromFilterRules([ | ||||
|         { rule_type: filterRuleType, value: object.id.toString() }, | ||||
|       ]) | ||||
|       this.navigateOrOpenInNewWindow(['/documents', params], newWindow) | ||||
|     } else if (editDialogComponent) { | ||||
|       const modalRef: NgbModalRef = this.modalService.open( | ||||
|         editDialogComponent, | ||||
| @@ -213,6 +220,7 @@ export class GlobalSearchComponent implements OnInit { | ||||
|  | ||||
|   private reset(close: boolean = false) { | ||||
|     this.queryDebounce.next(null) | ||||
|     this.query = null | ||||
|     this.searchResults = null | ||||
|     this.currentItemIndex = -1 | ||||
|     if (close) { | ||||
| @@ -233,7 +241,7 @@ export class GlobalSearchComponent implements OnInit { | ||||
|     item.nativeElement.focus() | ||||
|   } | ||||
|  | ||||
|   onItemHover(event: MouseEvent) { | ||||
|   public onItemHover(event: MouseEvent) { | ||||
|     const item: ElementRef = this.resultItems | ||||
|       .toArray() | ||||
|       .find((item) => item.nativeElement === event.currentTarget) | ||||
| @@ -241,7 +249,7 @@ export class GlobalSearchComponent implements OnInit { | ||||
|     this.setCurrentItem() | ||||
|   } | ||||
|  | ||||
|   onButtonHover(event: MouseEvent) { | ||||
|   public onButtonHover(event: MouseEvent) { | ||||
|     ;(event.currentTarget as HTMLElement).focus() | ||||
|   } | ||||
|  | ||||
| @@ -262,19 +270,14 @@ export class GlobalSearchComponent implements OnInit { | ||||
|       event.preventDefault() | ||||
|       this.currentItemIndex = this.searchResults.total - 1 | ||||
|       this.setCurrentItem() | ||||
|     } else if ( | ||||
|       event.key === 'Enter' && | ||||
|       this.searchResults?.total === 1 && | ||||
|       this.resultsDropdown.isOpen() | ||||
|     ) { | ||||
|       this.primaryButtons.first.nativeElement.click() | ||||
|       this.searchInput.nativeElement.blur() | ||||
|     } else if ( | ||||
|       event.key === 'Enter' && | ||||
|       this.searchResults?.total && | ||||
|       !this.resultsDropdown.isOpen() | ||||
|     ) { | ||||
|       this.resultsDropdown.open() | ||||
|     } else if (event.key === 'Enter') { | ||||
|       if (this.searchResults?.total === 1 && this.resultsDropdown.isOpen()) { | ||||
|         this.primaryButtons.first.nativeElement.click() | ||||
|         this.searchInput.nativeElement.blur() | ||||
|       } else if (this.query?.length) { | ||||
|         this.runAdvanedSearch() | ||||
|         this.reset(true) | ||||
|       } | ||||
|     } else if (event.key === 'Escape' && !this.resultsDropdown.isOpen()) { | ||||
|       if (this.query?.length) { | ||||
|         this.reset(true) | ||||
| @@ -284,7 +287,7 @@ export class GlobalSearchComponent implements OnInit { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   dropdownKeyDown(event: KeyboardEvent) { | ||||
|   public dropdownKeyDown(event: KeyboardEvent) { | ||||
|     if ( | ||||
|       this.searchResults?.total && | ||||
|       this.resultsDropdown.isOpen() && | ||||
| @@ -327,14 +330,9 @@ export class GlobalSearchComponent implements OnInit { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   onButtonKeyDown(event: KeyboardEvent) { | ||||
|     // prevents ngBootstrap issue with keydown events | ||||
|     if ( | ||||
|       !['ArrowDown', 'ArrowUp', 'ArrowRight', 'ArrowLeft', 'Escape'].includes( | ||||
|         event.key | ||||
|       ) | ||||
|     ) { | ||||
|       event.stopImmediatePropagation() | ||||
|   public onButtonKeyDown(event: KeyboardEvent) { | ||||
|     if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { | ||||
|       event.target.dispatchEvent(new MouseEvent('click', { ctrlKey: true })) | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -373,10 +371,19 @@ export class GlobalSearchComponent implements OnInit { | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   runAdvanedSearch() { | ||||
|   public runAdvanedSearch() { | ||||
|     this.documentListViewService.quickFilter([ | ||||
|       { rule_type: FILTER_FULLTEXT_QUERY, value: this.query }, | ||||
|     ]) | ||||
|     this.reset(true) | ||||
|   } | ||||
|  | ||||
|   private navigateOrOpenInNewWindow(commands: any, newWindow: boolean = false) { | ||||
|     if (newWindow) { | ||||
|       const url = this.router.serializeUrl(this.router.createUrlTree(commands)) | ||||
|       window.open(url, '_blank') | ||||
|     } else { | ||||
|       this.router.navigate(commands) | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -332,7 +332,8 @@ textarea, | ||||
|   } | ||||
| } | ||||
|  | ||||
| .input-group .form-control-sm { | ||||
| .input-group .form-control-sm, | ||||
| .input-group .btn-sm { | ||||
|   // accommodate larger font size on mobile | ||||
|   padding-top: .15rem; | ||||
|   padding-bottom: .15rem; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 shamoon
					shamoon