mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-07-30 18:27:45 -05:00
Feature: global search, keyboard shortcuts / hotkey support (#6449)
This commit is contained in:
99
src-ui/src/app/services/hot-key.service.spec.ts
Normal file
99
src-ui/src/app/services/hot-key.service.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
import { EventManager } from '@angular/platform-browser'
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
|
||||
import { HotKeyService } from './hot-key.service'
|
||||
import { NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
|
||||
describe('HotKeyService', () => {
|
||||
let service: HotKeyService
|
||||
let eventManager: EventManager
|
||||
let document: Document
|
||||
let modalService: NgbModal
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [HotKeyService, EventManager],
|
||||
imports: [NgbModalModule],
|
||||
})
|
||||
service = TestBed.inject(HotKeyService)
|
||||
eventManager = TestBed.inject(EventManager)
|
||||
document = TestBed.inject(DOCUMENT)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
})
|
||||
|
||||
it('should support adding a shortcut', () => {
|
||||
const callback = jest.fn()
|
||||
const addEventListenerSpy = jest.spyOn(eventManager, 'addEventListener')
|
||||
|
||||
const observable = service
|
||||
.addShortcut({ keys: 'control.a' })
|
||||
.subscribe(() => {
|
||||
callback()
|
||||
})
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalled()
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'a', ctrlKey: true })
|
||||
)
|
||||
expect(callback).toHaveBeenCalled()
|
||||
|
||||
//coverage
|
||||
observable.unsubscribe()
|
||||
})
|
||||
|
||||
it('should support adding a shortcut with a description, show modal', () => {
|
||||
const addEventListenerSpy = jest.spyOn(eventManager, 'addEventListener')
|
||||
service
|
||||
.addShortcut({ keys: 'control.a', description: 'Select all' })
|
||||
.subscribe()
|
||||
expect(addEventListenerSpy).toHaveBeenCalled()
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: '?', shiftKey: true })
|
||||
)
|
||||
expect(modalSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore keydown events from input elements that dont have a modifier key', () => {
|
||||
// constructor adds a shortcut for shift.?
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
const input = document.createElement('input')
|
||||
const textArea = document.createElement('textarea')
|
||||
const event = new KeyboardEvent('keydown', { key: '?', shiftKey: true })
|
||||
jest.spyOn(event, 'target', 'get').mockReturnValue(input)
|
||||
document.dispatchEvent(event)
|
||||
jest.spyOn(event, 'target', 'get').mockReturnValue(textArea)
|
||||
document.dispatchEvent(event)
|
||||
expect(modalSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should dismiss all modals on escape and not fire event', () => {
|
||||
const callback = jest.fn()
|
||||
service
|
||||
.addShortcut({ keys: 'escape', description: 'Escape' })
|
||||
.subscribe(callback)
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: '?', shiftKey: true })
|
||||
)
|
||||
expect(modalSpy).toHaveBeenCalled()
|
||||
const dismissAllSpy = jest.spyOn(modalService, 'dismissAll')
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
expect(dismissAllSpy).toHaveBeenCalled()
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not fire event on escape when open dropdowns ', () => {
|
||||
const callback = jest.fn()
|
||||
service
|
||||
.addShortcut({ keys: 'escape', description: 'Escape' })
|
||||
.subscribe(callback)
|
||||
const dropdown = document.createElement('div')
|
||||
dropdown.classList.add('dropdown-menu', 'show')
|
||||
document.body.appendChild(dropdown)
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
98
src-ui/src/app/services/hot-key.service.ts
Normal file
98
src-ui/src/app/services/hot-key.service.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
import { Inject, Injectable } from '@angular/core'
|
||||
import { EventManager } from '@angular/platform-browser'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Observable } from 'rxjs'
|
||||
import { HotkeyDialogComponent } from '../components/common/hotkey-dialog/hotkey-dialog.component'
|
||||
|
||||
export interface ShortcutOptions {
|
||||
element?: any
|
||||
keys: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class HotKeyService {
|
||||
private defaults: Partial<ShortcutOptions> = {
|
||||
element: this.document,
|
||||
}
|
||||
|
||||
private hotkeys: Map<string, string> = new Map()
|
||||
|
||||
constructor(
|
||||
private eventManager: EventManager,
|
||||
@Inject(DOCUMENT) private document: Document,
|
||||
private modalService: NgbModal
|
||||
) {
|
||||
this.addShortcut({ keys: 'shift.?' }).subscribe(() => {
|
||||
this.openHelpModal()
|
||||
})
|
||||
}
|
||||
|
||||
public addShortcut(options: ShortcutOptions) {
|
||||
const optionsWithDefaults = { ...this.defaults, ...options }
|
||||
const event = `keydown.${optionsWithDefaults.keys}`
|
||||
|
||||
if (optionsWithDefaults.description) {
|
||||
this.hotkeys.set(
|
||||
optionsWithDefaults.keys,
|
||||
optionsWithDefaults.description
|
||||
)
|
||||
}
|
||||
|
||||
return new Observable((observer) => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (
|
||||
!(e.altKey || e.metaKey || e.ctrlKey) &&
|
||||
(e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement)
|
||||
) {
|
||||
// Ignore keydown events from input elements that dont have a modifier key
|
||||
return
|
||||
}
|
||||
|
||||
this.modalService.dismissAll()
|
||||
if (
|
||||
e.key === 'Escape' &&
|
||||
(this.modalService.hasOpenModals() ||
|
||||
this.document.getElementsByClassName('dropdown-menu show').length >
|
||||
0)
|
||||
) {
|
||||
// If there is a modal open or menu open, ignore the keydown event
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
observer.next(e)
|
||||
}
|
||||
|
||||
const dispose = this.eventManager.addEventListener(
|
||||
optionsWithDefaults.element,
|
||||
event,
|
||||
handler
|
||||
)
|
||||
|
||||
let disposeMeta
|
||||
if (event.includes('control')) {
|
||||
disposeMeta = this.eventManager.addEventListener(
|
||||
optionsWithDefaults.element,
|
||||
event.replace('control', 'meta'),
|
||||
handler
|
||||
)
|
||||
}
|
||||
|
||||
return () => {
|
||||
dispose()
|
||||
if (disposeMeta) disposeMeta()
|
||||
this.hotkeys.delete(optionsWithDefaults.keys)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private openHelpModal() {
|
||||
const modal = this.modalService.open(HotkeyDialogComponent)
|
||||
modal.componentInstance.hotkeys = this.hotkeys
|
||||
}
|
||||
}
|
@@ -135,6 +135,7 @@ describe('OpenDocumentsService', () => {
|
||||
expect(openDocumentsService.hasDirty()).toBeFalsy()
|
||||
openDocumentsService.setDirty(documents[0], true)
|
||||
expect(openDocumentsService.hasDirty()).toBeTruthy()
|
||||
expect(openDocumentsService.isDirty(documents[0])).toBeTruthy()
|
||||
let openModal
|
||||
modalService.activeInstances.subscribe((instances) => {
|
||||
openModal = instances[0]
|
||||
|
@@ -90,6 +90,10 @@ export class OpenDocumentsService {
|
||||
return this.dirtyDocuments.size > 0
|
||||
}
|
||||
|
||||
isDirty(doc: Document): boolean {
|
||||
return this.dirtyDocuments.has(doc.id)
|
||||
}
|
||||
|
||||
closeDocument(doc: Document): Observable<boolean> {
|
||||
let index = this.openDocuments.findIndex((d) => d.id == doc.id)
|
||||
if (index == -1) return of(true)
|
||||
|
@@ -6,10 +6,13 @@ import { Subscription } from 'rxjs'
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { SearchService } from './search.service'
|
||||
import { SettingsService } from '../settings.service'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
|
||||
let httpTestingController: HttpTestingController
|
||||
let service: SearchService
|
||||
let subscription: Subscription
|
||||
let settingsService: SettingsService
|
||||
const endpoint = 'search/autocomplete'
|
||||
|
||||
describe('SearchService', () => {
|
||||
@@ -20,6 +23,7 @@ describe('SearchService', () => {
|
||||
})
|
||||
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
service = TestBed.inject(SearchService)
|
||||
})
|
||||
|
||||
@@ -36,4 +40,18 @@ describe('SearchService', () => {
|
||||
)
|
||||
expect(req.request.method).toEqual('GET')
|
||||
})
|
||||
|
||||
it('should call correct api endpoint on globalSearch', () => {
|
||||
const query = 'apple'
|
||||
service.globalSearch(query).subscribe()
|
||||
httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}search/?query=${query}`
|
||||
)
|
||||
|
||||
settingsService.set(SETTINGS_KEYS.SEARCH_DB_ONLY, true)
|
||||
subscription = service.globalSearch(query).subscribe()
|
||||
httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}search/?query=${query}&db_only=true`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
@@ -1,15 +1,48 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Observable } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { DocumentService } from './document.service'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import { Group } from 'src/app/data/group'
|
||||
import { MailAccount } from 'src/app/data/mail-account'
|
||||
import { MailRule } from 'src/app/data/mail-rule'
|
||||
import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { User } from 'src/app/data/user'
|
||||
import { Workflow } from 'src/app/data/workflow'
|
||||
import { SettingsService } from '../settings.service'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { SavedView } from 'src/app/data/saved-view'
|
||||
|
||||
export interface GlobalSearchResult {
|
||||
total: number
|
||||
documents: Document[]
|
||||
saved_views: SavedView[]
|
||||
correspondents: Correspondent[]
|
||||
document_types: DocumentType[]
|
||||
storage_paths: StoragePath[]
|
||||
tags: Tag[]
|
||||
users: User[]
|
||||
groups: Group[]
|
||||
mail_accounts: MailAccount[]
|
||||
mail_rules: MailRule[]
|
||||
custom_fields: CustomField[]
|
||||
workflows: Workflow[]
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SearchService {
|
||||
constructor(private http: HttpClient) {}
|
||||
public readonly searchResultObjectLimit: number = 3 // documents/views.py GlobalSearchView > OBJECT_LIMIT
|
||||
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
private settingsService: SettingsService
|
||||
) {}
|
||||
|
||||
autocomplete(term: string): Observable<string[]> {
|
||||
return this.http.get<string[]>(
|
||||
@@ -17,4 +50,19 @@ export class SearchService {
|
||||
{ params: new HttpParams().set('term', term) }
|
||||
)
|
||||
}
|
||||
|
||||
globalSearch(query: string): Observable<GlobalSearchResult> {
|
||||
let params = new HttpParams().set('query', query)
|
||||
if (this.searchDbOnly) {
|
||||
params = params.set('db_only', true)
|
||||
}
|
||||
return this.http.get<GlobalSearchResult>(
|
||||
`${environment.apiBaseUrl}search/`,
|
||||
{ params }
|
||||
)
|
||||
}
|
||||
|
||||
public get searchDbOnly(): boolean {
|
||||
return this.settingsService.get(SETTINGS_KEYS.SEARCH_DB_ONLY)
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user