]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Enhancement: global search tweaks (#6674)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Mon, 13 May 2024 16:12:02 +0000 (09:12 -0700)
committerGitHub <noreply@github.com>
Mon, 13 May 2024 16:12:02 +0000 (16:12 +0000)
src-ui/messages.xlf
src-ui/src/app/components/app-frame/global-search/global-search.component.html
src-ui/src/app/components/app-frame/global-search/global-search.component.spec.ts
src-ui/src/app/components/app-frame/global-search/global-search.component.ts
src-ui/src/styles.scss

index 4be198d69a1bb7e0ec3a14c90aad09da6b5d2b63..f3e43d3960d6519767d2fcde3242ad9e36d120ef 100644 (file)
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
-          <context context-type="linenumber">92</context>
+          <context context-type="linenumber">93</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1926290004382723170" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
-          <context context-type="linenumber">51</context>
+          <context context-type="linenumber">55</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
-          <context context-type="linenumber">68</context>
+          <context context-type="linenumber">72</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
         <source>Advanced search</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
-          <context context-type="linenumber">19</context>
+          <context context-type="linenumber">23</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
         <source>Open</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
-          <context context-type="linenumber">45</context>
+          <context context-type="linenumber">49</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
-          <context context-type="linenumber">48</context>
+          <context context-type="linenumber">52</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6329940072345709724" datatype="html">
         <source>Filter documents</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
-          <context context-type="linenumber">54</context>
+          <context context-type="linenumber">58</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3099741642167775297" datatype="html">
         <source>Download</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
-          <context context-type="linenumber">65</context>
+          <context context-type="linenumber">69</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
         <source>No results</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
-          <context context-type="linenumber">79</context>
+          <context context-type="linenumber">83</context>
         </context-group>
       </trans-unit>
       <trans-unit id="searchResults.documents" datatype="html">
         <source>Documents</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
-          <context context-type="linenumber">82</context>
+          <context context-type="linenumber">86</context>
         </context-group>
       </trans-unit>
       <trans-unit id="searchResults.saved_views" datatype="html">
         <source>Saved Views</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
-          <context context-type="linenumber">88</context>
+          <context context-type="linenumber">92</context>
         </context-group>
       </trans-unit>
       <trans-unit id="searchResults.tags" datatype="html">
         <source>Tags</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
-          <context context-type="linenumber">95</context>
+          <context context-type="linenumber">99</context>
         </context-group>
       </trans-unit>
       <trans-unit id="searchResults.correspondents" datatype="html">
         <source>Correspondents</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
-          <context context-type="linenumber">102</context>
+          <context context-type="linenumber">106</context>
         </context-group>
       </trans-unit>
       <trans-unit id="searchResults.documentTypes" datatype="html">
         <source>Document types</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
-          <context context-type="linenumber">109</context>
+          <context context-type="linenumber">113</context>
         </context-group>
       </trans-unit>
       <trans-unit id="searchResults.storagePaths" datatype="html">
         <source>Storage paths</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
-          <context context-type="linenumber">116</context>
+          <context context-type="linenumber">120</context>
         </context-group>
       </trans-unit>
       <trans-unit id="searchResults.users" datatype="html">
         <source>Users</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
-          <context context-type="linenumber">123</context>
+          <context context-type="linenumber">127</context>
         </context-group>
       </trans-unit>
       <trans-unit id="searchResults.groups" datatype="html">
         <source>Groups</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
-          <context context-type="linenumber">130</context>
+          <context context-type="linenumber">134</context>
         </context-group>
       </trans-unit>
       <trans-unit id="searchResults.customFields" datatype="html">
         <source>Custom fields</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
-          <context context-type="linenumber">137</context>
+          <context context-type="linenumber">141</context>
         </context-group>
       </trans-unit>
       <trans-unit id="searchResults.mailAccounts" datatype="html">
         <source>Mail accounts</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
-          <context context-type="linenumber">144</context>
+          <context context-type="linenumber">148</context>
         </context-group>
       </trans-unit>
       <trans-unit id="searchResults.mailRules" datatype="html">
         <source>Mail rules</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
-          <context context-type="linenumber">151</context>
+          <context context-type="linenumber">155</context>
         </context-group>
       </trans-unit>
       <trans-unit id="searchResults.workflows" datatype="html">
         <source>Workflows</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
-          <context context-type="linenumber">158</context>
+          <context context-type="linenumber">162</context>
         </context-group>
       </trans-unit>
       <trans-unit id="83507137894716798" datatype="html">
         <source>Successfully updated object.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
-          <context context-type="linenumber">168</context>
+          <context context-type="linenumber">175</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
-          <context context-type="linenumber">206</context>
+          <context context-type="linenumber">213</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1801333259018423190" datatype="html">
         <source>Error occurred saving object.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
-          <context context-type="linenumber">171</context>
+          <context context-type="linenumber">178</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
-          <context context-type="linenumber">209</context>
+          <context context-type="linenumber">216</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8700121026680200191" datatype="html">
index 317303de7fa38c0e8f99e4e801dc445868cd0d07..eeb11896702901f431a0c267ccaaeaa94bac6964 100644 (file)
@@ -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)">
index 514918584179a4bc49173df7501a1fa132c982dc..54ea735b65da06ecf35e10c24f66be31dea2cc71 100644 (file)
@@ -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
+  })
 })
index 35973501fe5b977a8a305d368ffdbe569e934101..2b1a078c454b634cdd29f59e3027a71b69dd56d6 100644 (file)
@@ -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)
+    }
+  }
 }
index 04b908720fcb0a063b9520f445c23e0c9112bc69..be1262dc8507bb2bf05b43591871776d0c462683 100644 (file)
@@ -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;