]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Fix ngbDropdown stealing keyboard events
authorshamoon <4887959+shamoon@users.noreply.github.com>
Sat, 4 May 2024 16:47:27 +0000 (09:47 -0700)
committershamoon <4887959+shamoon@users.noreply.github.com>
Sat, 4 May 2024 16:47:27 +0000 (09:47 -0700)
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

index 8df6cabff52e1f920a9518299527b31ead9c62cf..317303de7fa38c0e8f99e4e801dc445868cd0d07 100644 (file)
@@ -36,7 +36,8 @@
             </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.stopPropagation()"
+                (click)="primaryAction(type, item); $event.stopImmediatePropagation()"
+                (keydown)="onButtonKeyDown($event)"
                 [disabled]="disablePrimaryButton(type, item)"
                 (mouseenter)="onButtonHover($event)">
                     @if (type === DataType.Document) {
@@ -55,7 +56,8 @@
                 </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.stopPropagation()"
+                    (click)="secondaryAction(type, item); $event.stopImmediatePropagation()"
+                    (keydown)="onButtonKeyDown($event)"
                     [disabled]="disableSecondaryButton(type, item)"
                     (mouseenter)="onButtonHover($event)">
                         @if (type === DataType.Document) {
         </div>
     </ng-template>
 
-    <div ngbDropdownMenu class="w-100 mh-75 overflow-y-scroll shadow-lg" (keydown)="dropdownKeyDown($event)">
-        @if (searchResults?.total === 0) {
-            <h6 class="dropdown-header" i18n="@@searchResults.noResults">No results</h6>
-        } @else {
-            @if (searchResults?.documents.length) {
-                <h6 class="dropdown-header" i18n="@@searchResults.documents">Documents</h6>
-                @for (document of searchResults.documents; track document.id) {
-                    <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: document, nameProp: 'title', type: DataType.Document, icon: 'file-text', date: document.added}"></ng-container>
+    <div ngbDropdownMenu class="w-100 mh-75 overflow-y-scroll shadow-lg">
+        <div (keydown)="dropdownKeyDown($event)">
+            @if (searchResults?.total === 0) {
+                <h6 class="dropdown-header" i18n="@@searchResults.noResults">No results</h6>
+            } @else {
+                @if (searchResults?.documents.length) {
+                    <h6 class="dropdown-header" i18n="@@searchResults.documents">Documents</h6>
+                    @for (document of searchResults.documents; track document.id) {
+                        <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: document, nameProp: 'title', type: DataType.Document, icon: 'file-text', date: document.added}"></ng-container>
+                    }
                 }
-            }
-            @if (searchResults?.saved_views.length) {
-                <h6 class="dropdown-header" i18n="@@searchResults.saved_views">Saved Views</h6>
-                @for (saved_view of searchResults.saved_views; track saved_view.id) {
-                    <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: saved_view, nameProp: 'name', type: DataType.SavedView, icon: 'funnel'}"></ng-container>
+                @if (searchResults?.saved_views.length) {
+                    <h6 class="dropdown-header" i18n="@@searchResults.saved_views">Saved Views</h6>
+                    @for (saved_view of searchResults.saved_views; track saved_view.id) {
+                        <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: saved_view, nameProp: 'name', type: DataType.SavedView, icon: 'funnel'}"></ng-container>
+                    }
                 }
-            }
 
-            @if (searchResults?.tags.length) {
-                <h6 class="dropdown-header" i18n="@@searchResults.tags">Tags</h6>
-                @for (tag of searchResults.tags; track tag.id) {
-                    <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: tag, nameProp: 'name', type: DataType.Tag, icon: 'tag'}"></ng-container>
+                @if (searchResults?.tags.length) {
+                    <h6 class="dropdown-header" i18n="@@searchResults.tags">Tags</h6>
+                    @for (tag of searchResults.tags; track tag.id) {
+                        <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: tag, nameProp: 'name', type: DataType.Tag, icon: 'tag'}"></ng-container>
+                    }
                 }
-            }
 
-            @if (searchResults?.correspondents.length) {
-                <h6 class="dropdown-header" i18n="@@searchResults.correspondents">Correspondents</h6>
-                @for (correspondent of searchResults.correspondents; track correspondent.id) {
-                    <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: correspondent, nameProp: 'name', type: DataType.Correspondent, icon: 'person'}"></ng-container>
+                @if (searchResults?.correspondents.length) {
+                    <h6 class="dropdown-header" i18n="@@searchResults.correspondents">Correspondents</h6>
+                    @for (correspondent of searchResults.correspondents; track correspondent.id) {
+                        <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: correspondent, nameProp: 'name', type: DataType.Correspondent, icon: 'person'}"></ng-container>
+                    }
                 }
-            }
 
-            @if (searchResults?.document_types.length) {
-                <h6 class="dropdown-header" i18n="@@searchResults.documentTypes">Document types</h6>
-                @for (documentType of searchResults.document_types; track documentType.id) {
-                    <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: documentType, nameProp: 'name', type: DataType.DocumentType, icon: 'file-earmark'}"></ng-container>
+                @if (searchResults?.document_types.length) {
+                    <h6 class="dropdown-header" i18n="@@searchResults.documentTypes">Document types</h6>
+                    @for (documentType of searchResults.document_types; track documentType.id) {
+                        <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: documentType, nameProp: 'name', type: DataType.DocumentType, icon: 'file-earmark'}"></ng-container>
+                    }
                 }
-            }
 
-            @if (searchResults?.storage_paths.length) {
-                <h6 class="dropdown-header" i18n="@@searchResults.storagePaths">Storage paths</h6>
-                @for (storagePath of searchResults.storage_paths; track storagePath.id) {
-                    <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: storagePath, nameProp: 'name', type: DataType.StoragePath, icon: 'folder'}"></ng-container>
+                @if (searchResults?.storage_paths.length) {
+                    <h6 class="dropdown-header" i18n="@@searchResults.storagePaths">Storage paths</h6>
+                    @for (storagePath of searchResults.storage_paths; track storagePath.id) {
+                        <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: storagePath, nameProp: 'name', type: DataType.StoragePath, icon: 'folder'}"></ng-container>
+                    }
                 }
-            }
 
-            @if (searchResults?.users.length) {
-                <h6 class="dropdown-header" i18n="@@searchResults.users">Users</h6>
-                @for (user of searchResults.users; track user.id) {
-                    <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: user, nameProp: 'username', type: DataType.User, icon: 'person-square'}"></ng-container>
+                @if (searchResults?.users.length) {
+                    <h6 class="dropdown-header" i18n="@@searchResults.users">Users</h6>
+                    @for (user of searchResults.users; track user.id) {
+                        <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: user, nameProp: 'username', type: DataType.User, icon: 'person-square'}"></ng-container>
+                    }
                 }
-            }
 
-            @if (searchResults?.groups.length) {
-                <h6 class="dropdown-header" i18n="@@searchResults.groups">Groups</h6>
-                @for (group of searchResults.groups; track group.id) {
-                    <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: group, nameProp: 'name', type: DataType.Group, icon: 'people'}"></ng-container>
+                @if (searchResults?.groups.length) {
+                    <h6 class="dropdown-header" i18n="@@searchResults.groups">Groups</h6>
+                    @for (group of searchResults.groups; track group.id) {
+                        <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: group, nameProp: 'name', type: DataType.Group, icon: 'people'}"></ng-container>
+                    }
                 }
-            }
 
-            @if (searchResults?.custom_fields.length) {
-                <h6 class="dropdown-header" i18n="@@searchResults.customFields">Custom fields</h6>
-                @for (customField of searchResults.custom_fields; track customField.id) {
-                    <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: customField, nameProp: 'name', type: DataType.CustomField, icon: 'ui-radios'}"></ng-container>
+                @if (searchResults?.custom_fields.length) {
+                    <h6 class="dropdown-header" i18n="@@searchResults.customFields">Custom fields</h6>
+                    @for (customField of searchResults.custom_fields; track customField.id) {
+                        <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: customField, nameProp: 'name', type: DataType.CustomField, icon: 'ui-radios'}"></ng-container>
+                    }
                 }
-            }
 
-            @if (searchResults?.mail_accounts.length) {
-                <h6 class="dropdown-header" i18n="@@searchResults.mailAccounts">Mail accounts</h6>
-                @for (mailAccount of searchResults.mail_accounts; track mailAccount.id) {
-                    <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailAccount, nameProp: 'name', type: DataType.MailAccount, icon: 'envelope-at'}"></ng-container>
+                @if (searchResults?.mail_accounts.length) {
+                    <h6 class="dropdown-header" i18n="@@searchResults.mailAccounts">Mail accounts</h6>
+                    @for (mailAccount of searchResults.mail_accounts; track mailAccount.id) {
+                        <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailAccount, nameProp: 'name', type: DataType.MailAccount, icon: 'envelope-at'}"></ng-container>
+                    }
                 }
-            }
 
-            @if (searchResults?.mail_rules.length) {
-                <h6 class="dropdown-header" i18n="@@searchResults.mailRules">Mail rules</h6>
-                @for (mailRule of searchResults.mail_rules; track mailRule.id) {
-                    <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailRule, nameProp: 'name', type: DataType.MailRule, icon: 'envelope'}"></ng-container>
+                @if (searchResults?.mail_rules.length) {
+                    <h6 class="dropdown-header" i18n="@@searchResults.mailRules">Mail rules</h6>
+                    @for (mailRule of searchResults.mail_rules; track mailRule.id) {
+                        <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailRule, nameProp: 'name', type: DataType.MailRule, icon: 'envelope'}"></ng-container>
+                    }
                 }
-            }
 
-            @if (searchResults?.workflows.length) {
-                <h6 class="dropdown-header" i18n="@@searchResults.workflows">Workflows</h6>
-                @for (workflow of searchResults.workflows; track workflow.id) {
-                    <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: workflow, nameProp: 'name', type: DataType.Workflow, icon: 'boxes'}"></ng-container>
+                @if (searchResults?.workflows.length) {
+                    <h6 class="dropdown-header" i18n="@@searchResults.workflows">Workflows</h6>
+                    @for (workflow of searchResults.workflows; track workflow.id) {
+                        <ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: workflow, nameProp: 'name', type: DataType.Workflow, icon: 'boxes'}"></ng-container>
+                    }
                 }
             }
-        }
-
+        </div>
     </div>
 </div>
index a58db61dc1b1f35aeaf2ac4ddf7454a6f1229f22..96f61bca15eee944cf58e555cc11b1e568ec43b5 100644 (file)
@@ -442,6 +442,13 @@ 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'
index f2f1915131628aaf68294b50c40fb91905d6566e..6d342566d657ddddd174b323c2803cbf15406f96 100644 (file)
@@ -292,6 +292,7 @@ export class GlobalSearchComponent implements OnInit {
     ) {
       if (event.key === 'ArrowDown') {
         event.preventDefault()
+        event.stopImmediatePropagation()
         if (this.currentItemIndex < this.searchResults.total - 1) {
           this.currentItemIndex++
           this.setCurrentItem()
@@ -301,6 +302,7 @@ export class GlobalSearchComponent implements OnInit {
         }
       } else if (event.key === 'ArrowUp') {
         event.preventDefault()
+        event.stopImmediatePropagation()
         if (this.currentItemIndex > 0) {
           this.currentItemIndex--
           this.setCurrentItem()
@@ -310,14 +312,25 @@ export class GlobalSearchComponent implements OnInit {
         }
       } else if (event.key === 'ArrowRight') {
         event.preventDefault()
+        event.stopImmediatePropagation()
         this.secondaryButtons.get(this.domIndex)?.nativeElement.focus()
       } else if (event.key === 'ArrowLeft') {
         event.preventDefault()
+        event.stopImmediatePropagation()
         this.primaryButtons.get(this.domIndex).nativeElement.focus()
       }
     }
   }
 
+  onButtonKeyDown(event: KeyboardEvent) {
+    // prevents ngBootstrap issue with keydown events
+    if (
+      !['ArrowDown', 'ArrowUp', 'ArrowRight', 'ArrowLeft'].includes(event.key)
+    ) {
+      event.stopImmediatePropagation()
+    }
+  }
+
   public onDropdownOpenChange(open: boolean) {
     if (!open) {
       this.reset()