mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-04-02 13:45:10 -05:00
Feature: global search, keyboard shortcuts / hotkey support (#6449)
This commit is contained in:
parent
40289cd714
commit
c6e7d06bb7
35
docs/api.md
35
docs/api.md
@ -11,7 +11,7 @@ The API provides the following main endpoints:
|
||||
- `/api/correspondents/`: Full CRUD support.
|
||||
- `/api/custom_fields/`: Full CRUD support.
|
||||
- `/api/documents/`: Full CRUD support, except POSTing new documents.
|
||||
See below.
|
||||
See [below](#posting-documents-file-uploads).
|
||||
- `/api/document_types/`: Full CRUD support.
|
||||
- `/api/groups/`: Full CRUD support.
|
||||
- `/api/logs/`: Read-Only.
|
||||
@ -24,6 +24,7 @@ The API provides the following main endpoints:
|
||||
- `/api/tasks/`: Read-only.
|
||||
- `/api/users/`: Full CRUD support.
|
||||
- `/api/workflows/`: Full CRUD support.
|
||||
- `/api/search/` GET, see [below](#global-search).
|
||||
|
||||
All of these endpoints except for the logging endpoint allow you to
|
||||
fetch (and edit and delete where appropriate) individual objects by
|
||||
@ -188,6 +189,38 @@ The REST api provides four different forms of authentication.
|
||||
[configuration](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API)),
|
||||
you can authenticate against the API using Remote User auth.
|
||||
|
||||
## Global search
|
||||
|
||||
A global search endpoint is available at `/api/search/` and requires a search term
|
||||
of > 2 characters e.g. `?query=foo`. This endpoint returns a maximum of 3 results
|
||||
across nearly all objects, e.g. documents, tags, saved views, mail rules, etc.
|
||||
Results are only included if the requesting user has the appropriate permissions.
|
||||
|
||||
Results are returned in the following format:
|
||||
|
||||
```json
|
||||
{
|
||||
total: number
|
||||
documents: []
|
||||
saved_views: []
|
||||
correspondents: []
|
||||
document_types: []
|
||||
storage_paths: []
|
||||
tags: []
|
||||
users: []
|
||||
groups: []
|
||||
mail_accounts: []
|
||||
mail_rules: []
|
||||
custom_fields: []
|
||||
workflows: []
|
||||
}
|
||||
```
|
||||
|
||||
Global search first searches objects by name (or title for documents) matching the query.
|
||||
If the optional `db_only` parameter is set, only document titles will be searched. Otherwise,
|
||||
if the amount of documents returned by a simple title string search is < 3, results from the
|
||||
search index will also be included.
|
||||
|
||||
## Searching for documents
|
||||
|
||||
Full text searching is available on the `/api/documents/` endpoint. Two
|
||||
|
@ -550,6 +550,16 @@ collection.
|
||||
|
||||
## Searching {#basic-usage_searching}
|
||||
|
||||
### Global search
|
||||
|
||||
The top search bar in the web UI performs a "global" search of the various
|
||||
objects Paperless-ngx uses, including documents, tags, workflows, etc. Only
|
||||
objects for which the user has appropriate permissions are returned. For
|
||||
documents, if there are < 3 results, "advanced" search results (which use
|
||||
the document index) will also be included. This can be disabled under settings.
|
||||
|
||||
### Document searches
|
||||
|
||||
Paperless offers an extensive searching mechanism that is designed to
|
||||
allow you to quickly find a document you're looking for (for example,
|
||||
that thing that just broke and you bought a couple months ago, that
|
||||
@ -605,6 +615,12 @@ language](https://whoosh.readthedocs.io/en/latest/querylang.html). For
|
||||
details on what date parsing utilities are available, see [Date
|
||||
parsing](https://whoosh.readthedocs.io/en/latest/dates.html#parsing-date-queries).
|
||||
|
||||
## Keyboard shortcuts / hotkeys
|
||||
|
||||
A list of available hotkeys can be shown on any page using <kbd>Shift</kbd> +
|
||||
<kbd>?</kbd>. The help dialog shows only the keys that are currently available
|
||||
based on which area of Paperless-ngx you are using.
|
||||
|
||||
## The recommended workflow {#usage-recommended-workflow}
|
||||
|
||||
Once you have familiarized yourself with paperless and are ready to use
|
||||
|
@ -45,8 +45,8 @@ test('basic filtering', async ({ page }) => {
|
||||
test('text filtering', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
||||
await page.goto('/documents')
|
||||
await page.getByRole('textbox').click()
|
||||
await page.getByRole('textbox').fill('test')
|
||||
await page.getByRole('main').getByRole('combobox').click()
|
||||
await page.getByRole('main').getByRole('combobox').fill('test')
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/32 documents/)
|
||||
await expect(page).toHaveURL(/title_content=test/)
|
||||
await page.getByRole('button', { name: 'Title & content' }).click()
|
||||
@ -59,12 +59,12 @@ test('text filtering', async ({ page }) => {
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/26 documents/)
|
||||
await page.getByRole('button', { name: 'Advanced search' }).click()
|
||||
await page.getByRole('button', { name: 'ASN' }).click()
|
||||
await page.getByRole('textbox').fill('1123')
|
||||
await page.getByRole('main').getByRole('combobox').nth(1).fill('1123')
|
||||
await expect(page).toHaveURL(/archive_serial_number=1123/)
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
|
||||
await page.locator('select').selectOption('greater')
|
||||
await page.getByRole('textbox').click()
|
||||
await page.getByRole('textbox').fill('1123')
|
||||
await page.getByRole('main').getByRole('combobox').nth(1).click()
|
||||
await page.getByRole('main').getByRole('combobox').nth(1).fill('1123')
|
||||
await expect(page).toHaveURL(/archive_serial_number__gt=1123/)
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/5 documents/)
|
||||
await page.locator('select').selectOption('less')
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -85,6 +85,7 @@ const mock = () => {
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'open', { value: jest.fn() })
|
||||
Object.defineProperty(window, 'localStorage', { value: mock() })
|
||||
Object.defineProperty(window, 'sessionStorage', { value: mock() })
|
||||
Object.defineProperty(window, 'getComputedStyle', {
|
||||
|
@ -5,8 +5,7 @@ import {
|
||||
fakeAsync,
|
||||
tick,
|
||||
} from '@angular/core/testing'
|
||||
import { Router } from '@angular/router'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import { Router, RouterModule } from '@angular/router'
|
||||
import { TourService, TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { Subject } from 'rxjs'
|
||||
import { routes } from './app-routing.module'
|
||||
@ -21,6 +20,10 @@ import { ToastService, Toast } from './services/toast.service'
|
||||
import { SettingsService } from './services/settings.service'
|
||||
import { FileDropComponent } from './components/file-drop/file-drop.component'
|
||||
import { NgxFileDropModule } from 'ngx-file-drop'
|
||||
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { HotKeyService } from './services/hot-key.service'
|
||||
import { PermissionsGuard } from './guards/permissions.guard'
|
||||
import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard'
|
||||
|
||||
describe('AppComponent', () => {
|
||||
let component: AppComponent
|
||||
@ -31,16 +34,18 @@ describe('AppComponent', () => {
|
||||
let toastService: ToastService
|
||||
let router: Router
|
||||
let settingsService: SettingsService
|
||||
let hotKeyService: HotKeyService
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [AppComponent, ToastsComponent, FileDropComponent],
|
||||
providers: [],
|
||||
providers: [PermissionsGuard, DirtySavedViewGuard],
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
TourNgBootstrapModule,
|
||||
RouterTestingModule.withRoutes(routes),
|
||||
RouterModule.forRoot(routes),
|
||||
NgxFileDropModule,
|
||||
NgbModalModule,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
@ -50,6 +55,7 @@ describe('AppComponent', () => {
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
router = TestBed.inject(Router)
|
||||
hotKeyService = TestBed.inject(HotKeyService)
|
||||
fixture = TestBed.createComponent(AppComponent)
|
||||
component = fixture.componentInstance
|
||||
})
|
||||
@ -139,4 +145,20 @@ describe('AppComponent', () => {
|
||||
fileStatusSubject.next(new FileStatus())
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support hotkeys', () => {
|
||||
const addShortcutSpy = jest.spyOn(hotKeyService, 'addShortcut')
|
||||
const routerSpy = jest.spyOn(router, 'navigate')
|
||||
// prevent actual navigation
|
||||
routerSpy.mockReturnValue(new Promise(() => {}))
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
component.ngOnInit()
|
||||
expect(addShortcutSpy).toHaveBeenCalled()
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'h' }))
|
||||
expect(routerSpy).toHaveBeenCalledWith(['/dashboard'])
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'd' }))
|
||||
expect(routerSpy).toHaveBeenCalledWith(['/documents'])
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 's' }))
|
||||
expect(routerSpy).toHaveBeenCalledWith(['/settings'])
|
||||
})
|
||||
})
|
||||
|
@ -12,6 +12,7 @@ import {
|
||||
PermissionsService,
|
||||
PermissionType,
|
||||
} from './services/permissions.service'
|
||||
import { HotKeyService } from './services/hot-key.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-root',
|
||||
@ -31,7 +32,8 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
private tasksService: TasksService,
|
||||
public tourService: TourService,
|
||||
private renderer: Renderer2,
|
||||
private permissionsService: PermissionsService
|
||||
private permissionsService: PermissionsService,
|
||||
private hotKeyService: HotKeyService
|
||||
) {
|
||||
this.settings.updateAppearanceSettings()
|
||||
}
|
||||
@ -123,6 +125,36 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
})
|
||||
|
||||
this.hotKeyService
|
||||
.addShortcut({ keys: 'h', description: $localize`Dashboard` })
|
||||
.subscribe(() => {
|
||||
this.router.navigate(['/dashboard'])
|
||||
})
|
||||
if (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.Document
|
||||
)
|
||||
) {
|
||||
this.hotKeyService
|
||||
.addShortcut({ keys: 'd', description: $localize`Documents` })
|
||||
.subscribe(() => {
|
||||
this.router.navigate(['/documents'])
|
||||
})
|
||||
}
|
||||
if (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.Change,
|
||||
PermissionType.UISettings
|
||||
)
|
||||
) {
|
||||
this.hotKeyService
|
||||
.addShortcut({ keys: 's', description: $localize`Settings` })
|
||||
.subscribe(() => {
|
||||
this.router.navigate(['/settings'])
|
||||
})
|
||||
}
|
||||
|
||||
const prevBtnTitle = $localize`Prev`
|
||||
const nextBtnTitle = $localize`Next`
|
||||
const endBtnTitle = $localize`End`
|
||||
|
@ -122,6 +122,8 @@ import { SplitConfirmDialogComponent } from './components/common/confirm-dialog/
|
||||
import { DocumentHistoryComponent } from './components/document-history/document-history.component'
|
||||
import { DragDropSelectComponent } from './components/common/input/drag-drop-select/drag-drop-select.component'
|
||||
import { CustomFieldDisplayComponent } from './components/common/custom-field-display/custom-field-display.component'
|
||||
import { GlobalSearchComponent } from './components/app-frame/global-search/global-search.component'
|
||||
import { HotkeyDialogComponent } from './components/common/hotkey-dialog/hotkey-dialog.component'
|
||||
import {
|
||||
airplane,
|
||||
archive,
|
||||
@ -163,6 +165,7 @@ import {
|
||||
doorOpen,
|
||||
download,
|
||||
envelope,
|
||||
envelopeAt,
|
||||
exclamationCircleFill,
|
||||
exclamationTriangle,
|
||||
exclamationTriangleFill,
|
||||
@ -196,6 +199,7 @@ import {
|
||||
personFill,
|
||||
personFillLock,
|
||||
personLock,
|
||||
personSquare,
|
||||
plus,
|
||||
plusCircle,
|
||||
questionCircle,
|
||||
@ -206,6 +210,7 @@ import {
|
||||
sortAlphaDown,
|
||||
sortAlphaUpAlt,
|
||||
tagFill,
|
||||
tag,
|
||||
tags,
|
||||
textIndentLeft,
|
||||
textLeft,
|
||||
@ -259,6 +264,7 @@ const icons = {
|
||||
doorOpen,
|
||||
download,
|
||||
envelope,
|
||||
envelopeAt,
|
||||
exclamationCircleFill,
|
||||
exclamationTriangle,
|
||||
exclamationTriangleFill,
|
||||
@ -292,6 +298,7 @@ const icons = {
|
||||
personFill,
|
||||
personFillLock,
|
||||
personLock,
|
||||
personSquare,
|
||||
plus,
|
||||
plusCircle,
|
||||
questionCircle,
|
||||
@ -302,6 +309,7 @@ const icons = {
|
||||
sortAlphaDown,
|
||||
sortAlphaUpAlt,
|
||||
tagFill,
|
||||
tag,
|
||||
tags,
|
||||
textIndentLeft,
|
||||
textLeft,
|
||||
@ -482,6 +490,8 @@ function initializeApp(settings: SettingsService) {
|
||||
DocumentHistoryComponent,
|
||||
DragDropSelectComponent,
|
||||
CustomFieldDisplayComponent,
|
||||
GlobalSearchComponent,
|
||||
HotkeyDialogComponent,
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
|
@ -197,6 +197,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="mt-4" i18n>Global search</h4>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="offset-md-3 col">
|
||||
<pngx-input-check i18n-title title="Search database only (do not include advanced search results)" formControlName="searchDbOnly"></pngx-input-check>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="mt-4" i18n>Notes</h4>
|
||||
|
||||
<div class="row mb-3">
|
||||
|
@ -309,7 +309,7 @@ describe('SettingsComponent', () => {
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(storeSpy).toHaveBeenCalled()
|
||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||
expect(setSpy).toHaveBeenCalledTimes(25)
|
||||
expect(setSpy).toHaveBeenCalledTimes(26)
|
||||
|
||||
// succeed
|
||||
storeSpy.mockReturnValueOnce(of(true))
|
||||
|
@ -100,6 +100,7 @@ export class SettingsComponent
|
||||
defaultPermsEditUsers: new FormControl(null),
|
||||
defaultPermsEditGroups: new FormControl(null),
|
||||
documentEditingRemoveInboxTags: new FormControl(null),
|
||||
searchDbOnly: new FormControl(null),
|
||||
|
||||
notificationsConsumerNewDocument: new FormControl(null),
|
||||
notificationsConsumerSuccess: new FormControl(null),
|
||||
@ -304,6 +305,7 @@ export class SettingsComponent
|
||||
documentEditingRemoveInboxTags: this.settings.get(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS
|
||||
),
|
||||
searchDbOnly: this.settings.get(SETTINGS_KEYS.SEARCH_DB_ONLY),
|
||||
savedViews: {},
|
||||
}
|
||||
}
|
||||
@ -533,6 +535,10 @@ export class SettingsComponent
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS,
|
||||
this.settingsForm.value.documentEditingRemoveInboxTags
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.SEARCH_DB_ONLY,
|
||||
this.settingsForm.value.searchDbOnly
|
||||
)
|
||||
this.settings.setLanguage(this.settingsForm.value.displayLanguage)
|
||||
this.settings
|
||||
.storeSettings()
|
||||
|
@ -24,19 +24,10 @@
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
<div class="search-form-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
<form (ngSubmit)="search()" class="form-inline flex-grow-1">
|
||||
<i-bs width="1em" height="1em" name="search"></i-bs>
|
||||
<input class="form-control form-control-sm" type="text" placeholder="Search documents" aria-label="Search"
|
||||
[formControl]="searchField" [ngbTypeahead]="searchAutoComplete" (keyup)="searchFieldKeyup($event)"
|
||||
(selectItem)="itemSelected($event)" i18n-placeholder>
|
||||
@if (!searchFieldEmpty) {
|
||||
<button type="button" class="btn btn-link btn-sm ps-0 pe-1 position-absolute top-0 end-0" (click)="resetSearchField()">
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||
</button>
|
||||
}
|
||||
</form>
|
||||
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1">
|
||||
<div class="col-12 col-md-7">
|
||||
<pngx-global-search></pngx-global-search>
|
||||
</div>
|
||||
</div>
|
||||
<ul ngbNav class="order-sm-3">
|
||||
<li ngbDropdown class="nav-item dropdown">
|
||||
|
@ -257,59 +257,6 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
.navbar .search-form-container {
|
||||
max-width: 550px;
|
||||
|
||||
form {
|
||||
position: relative;
|
||||
|
||||
> i-bs {
|
||||
position: absolute;
|
||||
left: 0.6rem;
|
||||
top: .35rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
// adjust for smaller font size on non-mobile
|
||||
top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
&:focus-within {
|
||||
form > i-bs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-control::placeholder {
|
||||
color: rgba(255, 255, 255, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.form-control {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
padding-left: 1.8rem;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transition: all .3s ease, padding-left 0s ease, background-color 0s ease; // Safari requires all
|
||||
max-width: 600px;
|
||||
min-width: 300px; // 1/2 max
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
color: var(--bs-light);
|
||||
flex-grow: 1;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.version-check {
|
||||
animation: pulse 2s ease-in-out 0s 1;
|
||||
}
|
||||
|
@ -30,14 +30,13 @@ import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||
import { SearchService } from 'src/app/services/rest/search.service'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
import { CdkDragDrop, DragDropModule } from '@angular/cdk/drag-drop'
|
||||
import { SavedView } from 'src/app/data/saved-view'
|
||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { GlobalSearchComponent } from './global-search/global-search.component'
|
||||
|
||||
const saved_views = [
|
||||
{
|
||||
@ -89,15 +88,17 @@ describe('AppFrameComponent', () => {
|
||||
let toastService: ToastService
|
||||
let messagesService: DjangoMessagesService
|
||||
let openDocumentsService: OpenDocumentsService
|
||||
let searchService: SearchService
|
||||
let documentListViewService: DocumentListViewService
|
||||
let router: Router
|
||||
let savedViewSpy
|
||||
let modalService: NgbModal
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [AppFrameComponent, IfPermissionsDirective],
|
||||
declarations: [
|
||||
AppFrameComponent,
|
||||
IfPermissionsDirective,
|
||||
GlobalSearchComponent,
|
||||
],
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
BrowserModule,
|
||||
@ -159,8 +160,6 @@ describe('AppFrameComponent', () => {
|
||||
toastService = TestBed.inject(ToastService)
|
||||
messagesService = TestBed.inject(DjangoMessagesService)
|
||||
openDocumentsService = TestBed.inject(OpenDocumentsService)
|
||||
searchService = TestBed.inject(SearchService)
|
||||
documentListViewService = TestBed.inject(DocumentListViewService)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
router = TestBed.inject(Router)
|
||||
|
||||
@ -296,62 +295,6 @@ describe('AppFrameComponent', () => {
|
||||
expect(component.canDeactivate()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should call autocomplete endpoint on input', fakeAsync(() => {
|
||||
const autocompleteSpy = jest.spyOn(searchService, 'autocomplete')
|
||||
component.searchAutoComplete(of('hello')).subscribe()
|
||||
tick(250)
|
||||
expect(autocompleteSpy).toHaveBeenCalled()
|
||||
|
||||
component.searchAutoComplete(of('hello world 1')).subscribe()
|
||||
tick(250)
|
||||
expect(autocompleteSpy).toHaveBeenCalled()
|
||||
}))
|
||||
|
||||
it('should handle autocomplete backend failure gracefully', fakeAsync(() => {
|
||||
const serviceAutocompleteSpy = jest.spyOn(searchService, 'autocomplete')
|
||||
serviceAutocompleteSpy.mockReturnValue(
|
||||
throwError(() => new Error('autcomplete failed'))
|
||||
)
|
||||
// serviceAutocompleteSpy.mockReturnValue(of([' world']))
|
||||
let result
|
||||
component.searchAutoComplete(of('hello')).subscribe((res) => {
|
||||
result = res
|
||||
})
|
||||
tick(250)
|
||||
expect(serviceAutocompleteSpy).toHaveBeenCalled()
|
||||
expect(result).toEqual([])
|
||||
}))
|
||||
|
||||
it('should support reset search field', () => {
|
||||
const resetSpy = jest.spyOn(component, 'resetSearchField')
|
||||
const input = (fixture.nativeElement as HTMLDivElement).querySelector(
|
||||
'input'
|
||||
) as HTMLInputElement
|
||||
input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }))
|
||||
expect(resetSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support choosing a search item', () => {
|
||||
expect(component.searchField.value).toEqual('')
|
||||
component.itemSelected({ item: 'hello', preventDefault: () => true })
|
||||
expect(component.searchField.value).toEqual('hello ')
|
||||
component.itemSelected({ item: 'world', preventDefault: () => true })
|
||||
expect(component.searchField.value).toEqual('hello world ')
|
||||
})
|
||||
|
||||
it('should navigate via quickFilter on search', () => {
|
||||
const str = 'hello world '
|
||||
component.searchField.patchValue(str)
|
||||
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
||||
component.search()
|
||||
expect(qfSpy).toHaveBeenCalledWith([
|
||||
{
|
||||
rule_type: FILTER_FULLTEXT_QUERY,
|
||||
value: str.trim(),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should disable global dropzone on start drag + drop, re-enable after', () => {
|
||||
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
|
||||
component.onDragStart(null)
|
||||
|
@ -1,15 +1,7 @@
|
||||
import { Component, HostListener, OnInit } from '@angular/core'
|
||||
import { FormControl } from '@angular/forms'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { from, Observable } from 'rxjs'
|
||||
import {
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
switchMap,
|
||||
first,
|
||||
catchError,
|
||||
} from 'rxjs/operators'
|
||||
import { Observable } from 'rxjs'
|
||||
import { first } from 'rxjs/operators'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||
import {
|
||||
@ -17,11 +9,8 @@ import {
|
||||
DjangoMessagesService,
|
||||
} from 'src/app/services/django-messages.service'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { SearchService } from 'src/app/services/rest/search.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'
|
||||
import {
|
||||
RemoteVersionService,
|
||||
AppRemoteVersion,
|
||||
@ -46,6 +35,7 @@ import {
|
||||
} from '@angular/cdk/drag-drop'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||
import { ObjectWithId } from 'src/app/data/object-with-id'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-app-frame',
|
||||
@ -63,16 +53,12 @@ export class AppFrameComponent
|
||||
|
||||
slimSidebarAnimating: boolean = false
|
||||
|
||||
searchField = new FormControl('')
|
||||
|
||||
constructor(
|
||||
public router: Router,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private openDocumentsService: OpenDocumentsService,
|
||||
private searchService: SearchService,
|
||||
public savedViewService: SavedViewService,
|
||||
private remoteVersionService: RemoteVersionService,
|
||||
private list: DocumentListViewService,
|
||||
public settingsService: SettingsService,
|
||||
public tasksService: TasksService,
|
||||
private readonly toastService: ToastService,
|
||||
@ -164,65 +150,6 @@ export class AppFrameComponent
|
||||
return !this.openDocumentsService.hasDirty()
|
||||
}
|
||||
|
||||
get searchFieldEmpty(): boolean {
|
||||
return this.searchField.value.trim().length == 0
|
||||
}
|
||||
|
||||
resetSearchField() {
|
||||
this.searchField.reset('')
|
||||
}
|
||||
|
||||
searchFieldKeyup(event: KeyboardEvent) {
|
||||
if (event.key == 'Escape') {
|
||||
this.resetSearchField()
|
||||
}
|
||||
}
|
||||
|
||||
searchAutoComplete = (text$: Observable<string>) =>
|
||||
text$.pipe(
|
||||
debounceTime(200),
|
||||
distinctUntilChanged(),
|
||||
map((term) => {
|
||||
if (term.lastIndexOf(' ') != -1) {
|
||||
return term.substring(term.lastIndexOf(' ') + 1)
|
||||
} else {
|
||||
return term
|
||||
}
|
||||
}),
|
||||
switchMap((term) =>
|
||||
term.length < 2
|
||||
? from([[]])
|
||||
: this.searchService.autocomplete(term).pipe(
|
||||
catchError(() => {
|
||||
return from([[]])
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
itemSelected(event) {
|
||||
event.preventDefault()
|
||||
let currentSearch: string = this.searchField.value
|
||||
let lastSpaceIndex = currentSearch.lastIndexOf(' ')
|
||||
if (lastSpaceIndex != -1) {
|
||||
currentSearch = currentSearch.substring(0, lastSpaceIndex + 1)
|
||||
currentSearch += event.item + ' '
|
||||
} else {
|
||||
currentSearch = event.item + ' '
|
||||
}
|
||||
this.searchField.patchValue(currentSearch)
|
||||
}
|
||||
|
||||
search() {
|
||||
this.closeMenu()
|
||||
this.list.quickFilter([
|
||||
{
|
||||
rule_type: FILTER_FULLTEXT_QUERY,
|
||||
value: (this.searchField.value as string).trim(),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
closeDocument(d: Document) {
|
||||
this.openDocumentsService
|
||||
.closeDocument(d)
|
||||
|
@ -0,0 +1,163 @@
|
||||
|
||||
<div ngbDropdown #resultsDropdown="ngbDropdown" (openChange)="onDropdownOpenChange">
|
||||
<form class="form-inline position-relative">
|
||||
<i-bs width="1em" height="1em" name="search"></i-bs>
|
||||
<div class="input-group">
|
||||
<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)">
|
||||
<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)) {
|
||||
<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>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<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)"
|
||||
(mouseenter)="onItemHover($event)">
|
||||
<i-bs width="1.2em" height="1.2em" name="{{icon}}" class="me-2 text-muted"></i-bs>
|
||||
<div class="text-truncate">
|
||||
{{item[nameProp]}}
|
||||
@if (date) {
|
||||
<small class="small text-muted">{{date | customDate}}</small>
|
||||
}
|
||||
</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()"
|
||||
[disabled]="disablePrimaryButton(type, item)"
|
||||
(mouseenter)="onButtonHover($event)">
|
||||
@if (type === DataType.Document) {
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs>
|
||||
<span> <ng-container i18n>Open</ng-container></span>
|
||||
} @else if (type === DataType.SavedView) {
|
||||
<i-bs width="1em" height="1em" name="eye"></i-bs>
|
||||
<span> <ng-container i18n>Open</ng-container></span>
|
||||
} @else if (type === DataType.Workflow || type === DataType.CustomField || type === DataType.Group || type === DataType.User || type === DataType.MailAccount || type === DataType.MailRule) {
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs>
|
||||
<span> <ng-container i18n>Edit</ng-container></span>
|
||||
} @else {
|
||||
<i-bs width="1em" height="1em" name="filter"></i-bs>
|
||||
<span> <ng-container i18n>Filter documents</ng-container></span>
|
||||
}
|
||||
</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()"
|
||||
[disabled]="disableSecondaryButton(type, item)"
|
||||
(mouseenter)="onButtonHover($event)">
|
||||
@if (type === DataType.Document) {
|
||||
<i-bs width="1em" height="1em" name="download"></i-bs>
|
||||
<span> <ng-container i18n>Download</ng-container></span>
|
||||
} @else {
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs>
|
||||
<span> <ng-container i18n>Edit</ng-container></span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</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>
|
||||
}
|
||||
}
|
||||
@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?.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?.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?.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?.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?.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>
|
@ -0,0 +1,97 @@
|
||||
form {
|
||||
position: relative;
|
||||
|
||||
> i-bs[name="search"] {
|
||||
position: absolute;
|
||||
left: 0.6rem;
|
||||
top: .35rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
// adjust for smaller font size on non-mobile
|
||||
top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
i-bs[name="search"],
|
||||
.badge {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.form-control::placeholder {
|
||||
color: rgba(255, 255, 255, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.input-group .btn {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: var(--pngx-primary-text-contrast);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
padding-left: 1.8rem;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transition: all .3s ease, padding-left 0s ease, background-color 0s ease; // Safari requires all
|
||||
> input {
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
color: var(--bs-light);
|
||||
flex-grow: 1;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
* {
|
||||
--pngx-focus-alpha: 0;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mh-75 {
|
||||
max-height: 75vh;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
&:has(button:focus) {
|
||||
background-color: var(--pngx-bg-darker);
|
||||
}
|
||||
|
||||
& button {
|
||||
transition: all 0.3s ease, color 0.15s ease;
|
||||
max-width: 2rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
& button span {
|
||||
opacity: 0;
|
||||
transition: inherit;
|
||||
}
|
||||
|
||||
&:hover button,
|
||||
&:has(button:focus) button {
|
||||
max-width: 10rem;
|
||||
}
|
||||
|
||||
&:hover button span,
|
||||
&:has(button:focus) span {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
@ -0,0 +1,453 @@
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
fakeAsync,
|
||||
tick,
|
||||
} from '@angular/core/testing'
|
||||
import { GlobalSearchComponent } from './global-search.component'
|
||||
import { of } from 'rxjs'
|
||||
import { SearchService } from 'src/app/services/rest/search.service'
|
||||
import { Router } from '@angular/router'
|
||||
import {
|
||||
NgbDropdownModule,
|
||||
NgbModal,
|
||||
NgbModalModule,
|
||||
NgbModalRef,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||
import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import {
|
||||
FILTER_FULLTEXT_QUERY,
|
||||
FILTER_HAS_CORRESPONDENT_ANY,
|
||||
FILTER_HAS_DOCUMENT_TYPE_ANY,
|
||||
FILTER_HAS_STORAGE_PATH_ANY,
|
||||
FILTER_HAS_TAGS_ANY,
|
||||
} from 'src/app/data/filter-rule-type'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component'
|
||||
import { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component'
|
||||
import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component'
|
||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
import { WorkflowEditDialogComponent } from '../../common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component'
|
||||
import { ElementRef } from '@angular/core'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { DataType } from 'src/app/data/datatype'
|
||||
|
||||
const searchResults = {
|
||||
total: 11,
|
||||
documents: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Test',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
document_type: { id: 1, name: 'Test' },
|
||||
storage_path: { id: 1, path: 'Test' },
|
||||
tags: [],
|
||||
correspondents: [],
|
||||
custom_fields: [],
|
||||
},
|
||||
],
|
||||
saved_views: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'TestSavedView',
|
||||
},
|
||||
],
|
||||
correspondents: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'TestCorrespondent',
|
||||
},
|
||||
],
|
||||
document_types: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'TestDocumentType',
|
||||
},
|
||||
],
|
||||
storage_paths: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'TestStoragePath',
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'TestTag',
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
id: 1,
|
||||
username: 'TestUser',
|
||||
},
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'TestGroup',
|
||||
},
|
||||
],
|
||||
mail_accounts: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'TestMailAccount',
|
||||
},
|
||||
],
|
||||
mail_rules: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'TestMailRule',
|
||||
},
|
||||
],
|
||||
custom_fields: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'TestCustomField',
|
||||
},
|
||||
],
|
||||
workflows: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'TestWorkflow',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
describe('GlobalSearchComponent', () => {
|
||||
let component: GlobalSearchComponent
|
||||
let fixture: ComponentFixture<GlobalSearchComponent>
|
||||
let searchService: SearchService
|
||||
let router: Router
|
||||
let modalService: NgbModal
|
||||
let documentService: DocumentService
|
||||
let documentListViewService: DocumentListViewService
|
||||
let toastService: ToastService
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [GlobalSearchComponent],
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
NgbModalModule,
|
||||
NgbDropdownModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
searchService = TestBed.inject(SearchService)
|
||||
router = TestBed.inject(Router)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
documentService = TestBed.inject(DocumentService)
|
||||
documentListViewService = TestBed.inject(DocumentListViewService)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
|
||||
fixture = TestBed.createComponent(GlobalSearchComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should handle keyboard nav', () => {
|
||||
const focusSpy = jest.spyOn(component.searchInput.nativeElement, 'focus')
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/' }))
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
|
||||
component.searchResults = searchResults as any
|
||||
component.resultsDropdown.open()
|
||||
fixture.detectChanges()
|
||||
|
||||
component['currentItemIndex'] = 0
|
||||
component['setCurrentItem']()
|
||||
const firstItemFocusSpy = jest.spyOn(
|
||||
component.primaryButtons.get(1).nativeElement,
|
||||
'focus'
|
||||
)
|
||||
component.dropdownKeyDown(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown' })
|
||||
)
|
||||
expect(component['currentItemIndex']).toBe(1)
|
||||
expect(firstItemFocusSpy).toHaveBeenCalled()
|
||||
|
||||
const secondaryItemFocusSpy = jest.spyOn(
|
||||
component.secondaryButtons.get(1).nativeElement,
|
||||
'focus'
|
||||
)
|
||||
component.dropdownKeyDown(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowRight' })
|
||||
)
|
||||
expect(secondaryItemFocusSpy).toHaveBeenCalled()
|
||||
|
||||
component.dropdownKeyDown(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowLeft' })
|
||||
)
|
||||
expect(firstItemFocusSpy).toHaveBeenCalled()
|
||||
|
||||
const zeroItemSpy = jest.spyOn(
|
||||
component.primaryButtons.get(0).nativeElement,
|
||||
'focus'
|
||||
)
|
||||
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
|
||||
expect(component['currentItemIndex']).toBe(0)
|
||||
expect(zeroItemSpy).toHaveBeenCalled()
|
||||
|
||||
const inputFocusSpy = jest.spyOn(
|
||||
component.searchInput.nativeElement,
|
||||
'focus'
|
||||
)
|
||||
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
|
||||
expect(component['currentItemIndex']).toBe(-1)
|
||||
expect(inputFocusSpy).toHaveBeenCalled()
|
||||
|
||||
component.dropdownKeyDown(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown' })
|
||||
)
|
||||
component['currentItemIndex'] = searchResults.total - 1
|
||||
component['setCurrentItem']()
|
||||
component.dropdownKeyDown(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown' })
|
||||
)
|
||||
expect(component['currentItemIndex']).toBe(-1)
|
||||
|
||||
// Search input
|
||||
|
||||
component.searchInputKeyDown(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowUp' })
|
||||
)
|
||||
expect(component['currentItemIndex']).toBe(searchResults.total - 1)
|
||||
|
||||
component.searchInputKeyDown(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown' })
|
||||
)
|
||||
expect(component['currentItemIndex']).toBe(0)
|
||||
|
||||
component.searchResults = { total: 1 } as any
|
||||
const primaryActionSpy = jest.spyOn(component, 'primaryAction')
|
||||
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
|
||||
expect(primaryActionSpy).toHaveBeenCalled()
|
||||
|
||||
component.query = 'test'
|
||||
const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
|
||||
component.searchInputKeyDown(
|
||||
new KeyboardEvent('keydown', { key: 'Escape' })
|
||||
)
|
||||
expect(resetSpy).toHaveBeenCalled()
|
||||
|
||||
component.query = ''
|
||||
const blurSpy = jest.spyOn(component.searchInput.nativeElement, 'blur')
|
||||
component.searchInputKeyDown(
|
||||
new KeyboardEvent('keydown', { key: 'Escape' })
|
||||
)
|
||||
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()
|
||||
})
|
||||
|
||||
it('should search on query debounce', fakeAsync(() => {
|
||||
const query = 'test'
|
||||
const searchSpy = jest.spyOn(searchService, 'globalSearch')
|
||||
searchSpy.mockReturnValue(of({} as any))
|
||||
const dropdownOpenSpy = jest.spyOn(component.resultsDropdown, 'open')
|
||||
component.queryDebounce.next(query)
|
||||
tick(401)
|
||||
expect(searchSpy).toHaveBeenCalledWith(query)
|
||||
expect(dropdownOpenSpy).toHaveBeenCalled()
|
||||
}))
|
||||
|
||||
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
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
|
||||
component.primaryAction(DataType.Document, object)
|
||||
expect(routerSpy).toHaveBeenCalledWith(['/documents', object.id])
|
||||
|
||||
component.primaryAction(DataType.SavedView, object)
|
||||
expect(routerSpy).toHaveBeenCalledWith(['/view', object.id])
|
||||
|
||||
component.primaryAction(DataType.Correspondent, object)
|
||||
expect(qfSpy).toHaveBeenCalledWith([
|
||||
{ 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() },
|
||||
])
|
||||
|
||||
component.primaryAction(DataType.StoragePath, object)
|
||||
expect(qfSpy).toHaveBeenCalledWith([
|
||||
{ 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() },
|
||||
])
|
||||
|
||||
component.primaryAction(DataType.User, object)
|
||||
expect(modalSpy).toHaveBeenCalledWith(UserEditDialogComponent, {
|
||||
size: 'lg',
|
||||
})
|
||||
|
||||
component.primaryAction(DataType.Group, object)
|
||||
expect(modalSpy).toHaveBeenCalledWith(GroupEditDialogComponent, {
|
||||
size: 'lg',
|
||||
})
|
||||
|
||||
component.primaryAction(DataType.MailAccount, object)
|
||||
expect(modalSpy).toHaveBeenCalledWith(MailAccountEditDialogComponent, {
|
||||
size: 'xl',
|
||||
})
|
||||
|
||||
component.primaryAction(DataType.MailRule, object)
|
||||
expect(modalSpy).toHaveBeenCalledWith(MailRuleEditDialogComponent, {
|
||||
size: 'xl',
|
||||
})
|
||||
|
||||
component.primaryAction(DataType.CustomField, object)
|
||||
expect(modalSpy).toHaveBeenCalledWith(CustomFieldEditDialogComponent, {
|
||||
size: 'md',
|
||||
})
|
||||
|
||||
component.primaryAction(DataType.Workflow, object)
|
||||
expect(modalSpy).toHaveBeenCalledWith(WorkflowEditDialogComponent, {
|
||||
size: 'xl',
|
||||
})
|
||||
|
||||
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
|
||||
// fail first
|
||||
editDialog.failed.emit({ error: 'error creating item' })
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
editDialog.succeeded.emit(true)
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support secondary action', () => {
|
||||
const doc = searchResults.documents[0]
|
||||
const openSpy = jest.spyOn(window, 'open')
|
||||
component.secondaryAction('document', doc)
|
||||
expect(openSpy).toHaveBeenCalledWith(documentService.getDownloadUrl(doc.id))
|
||||
|
||||
const correspondent = searchResults.correspondents[0]
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
|
||||
component.secondaryAction(DataType.Correspondent, correspondent)
|
||||
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
|
||||
size: 'md',
|
||||
})
|
||||
|
||||
component.secondaryAction(
|
||||
DataType.DocumentType,
|
||||
searchResults.document_types[0]
|
||||
)
|
||||
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
|
||||
size: 'md',
|
||||
})
|
||||
|
||||
component.secondaryAction(
|
||||
DataType.StoragePath,
|
||||
searchResults.storage_paths[0]
|
||||
)
|
||||
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
|
||||
size: 'md',
|
||||
})
|
||||
|
||||
component.secondaryAction(DataType.Tag, searchResults.tags[0])
|
||||
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
|
||||
size: 'md',
|
||||
})
|
||||
|
||||
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
|
||||
// fail first
|
||||
editDialog.failed.emit({ error: 'error creating item' })
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
editDialog.succeeded.emit(true)
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support reset', () => {
|
||||
const debounce = jest.spyOn(component.queryDebounce, 'next')
|
||||
const closeSpy = jest.spyOn(component.resultsDropdown, 'close')
|
||||
component['reset'](true)
|
||||
expect(debounce).toHaveBeenCalledWith(null)
|
||||
expect(component.searchResults).toBeNull()
|
||||
expect(component['currentItemIndex']).toBe(-1)
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support focus current item', () => {
|
||||
component.searchResults = searchResults as any
|
||||
fixture.detectChanges()
|
||||
const focusSpy = jest.spyOn(
|
||||
component.primaryButtons.get(0).nativeElement,
|
||||
'focus'
|
||||
)
|
||||
component['currentItemIndex'] = 0
|
||||
component['setCurrentItem']()
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reset on dropdown close', () => {
|
||||
const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
|
||||
component.onDropdownOpenChange(false)
|
||||
expect(resetSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should focus button on dropdown item hover', () => {
|
||||
component.searchResults = searchResults as any
|
||||
fixture.detectChanges()
|
||||
const item: ElementRef = component.resultItems.first
|
||||
const focusSpy = jest.spyOn(
|
||||
component.primaryButtons.first.nativeElement,
|
||||
'focus'
|
||||
)
|
||||
component.onItemHover({ currentTarget: item.nativeElement } as any)
|
||||
expect(component['currentItemIndex']).toBe(0)
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should focus on button hover', () => {
|
||||
const event = { currentTarget: { focus: jest.fn() } }
|
||||
const focusSpy = jest.spyOn(event.currentTarget, 'focus')
|
||||
component.onButtonHover(event as any)
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support explicit advanced search', () => {
|
||||
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
||||
component.query = 'test'
|
||||
component.runAdvanedSearch()
|
||||
expect(qfSpy).toHaveBeenCalledWith([
|
||||
{ rule_type: FILTER_FULLTEXT_QUERY, value: 'test' },
|
||||
])
|
||||
})
|
||||
})
|
@ -0,0 +1,362 @@
|
||||
import {
|
||||
Component,
|
||||
ViewChild,
|
||||
ElementRef,
|
||||
ViewChildren,
|
||||
QueryList,
|
||||
OnInit,
|
||||
} from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { NgbDropdown, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Subject, debounceTime, distinctUntilChanged, filter, map } from 'rxjs'
|
||||
import {
|
||||
FILTER_FULLTEXT_QUERY,
|
||||
FILTER_HAS_CORRESPONDENT_ANY,
|
||||
FILTER_HAS_DOCUMENT_TYPE_ANY,
|
||||
FILTER_HAS_STORAGE_PATH_ANY,
|
||||
FILTER_HAS_TAGS_ANY,
|
||||
} from 'src/app/data/filter-rule-type'
|
||||
import { DataType } from 'src/app/data/datatype'
|
||||
import { ObjectWithId } from 'src/app/data/object-with-id'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import {
|
||||
PermissionsService,
|
||||
PermissionAction,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import {
|
||||
GlobalSearchResult,
|
||||
SearchService,
|
||||
} from 'src/app/services/rest/search.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||
import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component'
|
||||
import { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component'
|
||||
import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component'
|
||||
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
|
||||
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||
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'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-global-search',
|
||||
templateUrl: './global-search.component.html',
|
||||
styleUrl: './global-search.component.scss',
|
||||
})
|
||||
export class GlobalSearchComponent implements OnInit {
|
||||
public DataType = DataType
|
||||
public query: string
|
||||
public queryDebounce: Subject<string>
|
||||
public searchResults: GlobalSearchResult
|
||||
private currentItemIndex: number = -1
|
||||
private domIndex: number = -1
|
||||
public loading: boolean = false
|
||||
|
||||
@ViewChild('searchInput') searchInput: ElementRef
|
||||
@ViewChild('resultsDropdown') resultsDropdown: NgbDropdown
|
||||
@ViewChildren('resultItem') resultItems: QueryList<ElementRef>
|
||||
@ViewChildren('primaryButton') primaryButtons: QueryList<ElementRef>
|
||||
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
|
||||
|
||||
constructor(
|
||||
public searchService: SearchService,
|
||||
private router: Router,
|
||||
private modalService: NgbModal,
|
||||
private documentService: DocumentService,
|
||||
private documentListViewService: DocumentListViewService,
|
||||
private permissionsService: PermissionsService,
|
||||
private toastService: ToastService,
|
||||
private hotkeyService: HotKeyService
|
||||
) {
|
||||
this.queryDebounce = new Subject<string>()
|
||||
|
||||
this.queryDebounce
|
||||
.pipe(
|
||||
debounceTime(400),
|
||||
map((query) => query?.trim()),
|
||||
filter((query) => !query?.length || query?.length > 2),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
.subscribe((text) => {
|
||||
this.query = text
|
||||
if (text) this.search(text)
|
||||
})
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.hotkeyService
|
||||
.addShortcut({ keys: '/', description: $localize`Global search` })
|
||||
.subscribe(() => {
|
||||
this.searchInput.nativeElement.focus()
|
||||
})
|
||||
}
|
||||
|
||||
private search(query: string) {
|
||||
this.loading = true
|
||||
this.searchService.globalSearch(query).subscribe((results) => {
|
||||
this.searchResults = results
|
||||
this.loading = false
|
||||
this.resultsDropdown.open()
|
||||
})
|
||||
}
|
||||
|
||||
public primaryAction(type: string, object: ObjectWithId) {
|
||||
this.reset(true)
|
||||
let filterRuleType: number
|
||||
let editDialogComponent: any
|
||||
let size: string = 'md'
|
||||
switch (type) {
|
||||
case DataType.Document:
|
||||
this.router.navigate(['/documents', object.id])
|
||||
return
|
||||
case DataType.SavedView:
|
||||
this.router.navigate(['/view', object.id])
|
||||
return
|
||||
case DataType.Correspondent:
|
||||
filterRuleType = FILTER_HAS_CORRESPONDENT_ANY
|
||||
break
|
||||
case DataType.DocumentType:
|
||||
filterRuleType = FILTER_HAS_DOCUMENT_TYPE_ANY
|
||||
break
|
||||
case DataType.StoragePath:
|
||||
filterRuleType = FILTER_HAS_STORAGE_PATH_ANY
|
||||
break
|
||||
case DataType.Tag:
|
||||
filterRuleType = FILTER_HAS_TAGS_ANY
|
||||
break
|
||||
case DataType.User:
|
||||
editDialogComponent = UserEditDialogComponent
|
||||
size = 'lg'
|
||||
break
|
||||
case DataType.Group:
|
||||
editDialogComponent = GroupEditDialogComponent
|
||||
size = 'lg'
|
||||
break
|
||||
case DataType.MailAccount:
|
||||
editDialogComponent = MailAccountEditDialogComponent
|
||||
size = 'xl'
|
||||
break
|
||||
case DataType.MailRule:
|
||||
editDialogComponent = MailRuleEditDialogComponent
|
||||
size = 'xl'
|
||||
break
|
||||
case DataType.CustomField:
|
||||
editDialogComponent = CustomFieldEditDialogComponent
|
||||
break
|
||||
case DataType.Workflow:
|
||||
editDialogComponent = WorkflowEditDialogComponent
|
||||
size = 'xl'
|
||||
break
|
||||
}
|
||||
|
||||
if (filterRuleType) {
|
||||
this.documentListViewService.quickFilter([
|
||||
{ rule_type: filterRuleType, value: object.id.toString() },
|
||||
])
|
||||
} else if (editDialogComponent) {
|
||||
const modalRef: NgbModalRef = this.modalService.open(
|
||||
editDialogComponent,
|
||||
{ size }
|
||||
)
|
||||
modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
|
||||
modalRef.componentInstance.object = object
|
||||
modalRef.componentInstance.succeeded.subscribe(() => {
|
||||
this.toastService.showInfo($localize`Successfully updated object.`)
|
||||
})
|
||||
modalRef.componentInstance.failed.subscribe((e) => {
|
||||
this.toastService.showError($localize`Error occurred saving object.`, e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public secondaryAction(type: string, object: ObjectWithId) {
|
||||
this.reset(true)
|
||||
let editDialogComponent: any
|
||||
let size: string = 'md'
|
||||
switch (type) {
|
||||
case DataType.Document:
|
||||
window.open(this.documentService.getDownloadUrl(object.id))
|
||||
break
|
||||
case DataType.Correspondent:
|
||||
editDialogComponent = CorrespondentEditDialogComponent
|
||||
break
|
||||
case DataType.DocumentType:
|
||||
editDialogComponent = DocumentTypeEditDialogComponent
|
||||
break
|
||||
case DataType.StoragePath:
|
||||
editDialogComponent = StoragePathEditDialogComponent
|
||||
break
|
||||
case DataType.Tag:
|
||||
editDialogComponent = TagEditDialogComponent
|
||||
break
|
||||
}
|
||||
|
||||
if (editDialogComponent) {
|
||||
const modalRef: NgbModalRef = this.modalService.open(
|
||||
editDialogComponent,
|
||||
{ size }
|
||||
)
|
||||
modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
|
||||
modalRef.componentInstance.object = object
|
||||
modalRef.componentInstance.succeeded.subscribe(() => {
|
||||
this.toastService.showInfo($localize`Successfully updated object.`)
|
||||
})
|
||||
modalRef.componentInstance.failed.subscribe((e) => {
|
||||
this.toastService.showError($localize`Error occurred saving object.`, e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private reset(close: boolean = false) {
|
||||
this.queryDebounce.next(null)
|
||||
this.searchResults = null
|
||||
this.currentItemIndex = -1
|
||||
if (close) {
|
||||
this.resultsDropdown.close()
|
||||
}
|
||||
}
|
||||
|
||||
private setCurrentItem() {
|
||||
// QueryLists do not always reflect the current DOM order, so we need to find the actual element
|
||||
// Yes, using some vanilla JS
|
||||
const result: HTMLElement = this.resultItems.first.nativeElement.parentNode
|
||||
.querySelectorAll('.dropdown-item')
|
||||
.item(this.currentItemIndex)
|
||||
this.domIndex = this.resultItems
|
||||
.toArray()
|
||||
.indexOf(this.resultItems.find((item) => item.nativeElement === result))
|
||||
const item: ElementRef = this.primaryButtons.get(this.domIndex)
|
||||
item.nativeElement.focus()
|
||||
}
|
||||
|
||||
onItemHover(event: MouseEvent) {
|
||||
const item: ElementRef = this.resultItems
|
||||
.toArray()
|
||||
.find((item) => item.nativeElement === event.currentTarget)
|
||||
this.currentItemIndex = this.resultItems.toArray().indexOf(item)
|
||||
this.setCurrentItem()
|
||||
}
|
||||
|
||||
onButtonHover(event: MouseEvent) {
|
||||
;(event.currentTarget as HTMLElement).focus()
|
||||
}
|
||||
|
||||
public searchInputKeyDown(event: KeyboardEvent) {
|
||||
if (
|
||||
event.key === 'ArrowDown' &&
|
||||
this.searchResults?.total &&
|
||||
this.resultsDropdown.isOpen()
|
||||
) {
|
||||
event.preventDefault()
|
||||
this.currentItemIndex = 0
|
||||
this.setCurrentItem()
|
||||
} else if (
|
||||
event.key === 'ArrowUp' &&
|
||||
this.searchResults?.total &&
|
||||
this.resultsDropdown.isOpen()
|
||||
) {
|
||||
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 === 'Escape' && !this.resultsDropdown.isOpen()) {
|
||||
if (this.query?.length) {
|
||||
this.reset(true)
|
||||
} else {
|
||||
this.searchInput.nativeElement.blur()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dropdownKeyDown(event: KeyboardEvent) {
|
||||
if (
|
||||
this.searchResults?.total &&
|
||||
this.resultsDropdown.isOpen() &&
|
||||
document.activeElement !== this.searchInput.nativeElement
|
||||
) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
if (this.currentItemIndex < this.searchResults.total - 1) {
|
||||
this.currentItemIndex++
|
||||
this.setCurrentItem()
|
||||
} else {
|
||||
this.searchInput.nativeElement.focus()
|
||||
this.currentItemIndex = -1
|
||||
}
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
if (this.currentItemIndex > 0) {
|
||||
this.currentItemIndex--
|
||||
this.setCurrentItem()
|
||||
} else {
|
||||
this.searchInput.nativeElement.focus()
|
||||
this.currentItemIndex = -1
|
||||
}
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
this.secondaryButtons.get(this.domIndex)?.nativeElement.focus()
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
this.primaryButtons.get(this.domIndex).nativeElement.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public onDropdownOpenChange(open: boolean) {
|
||||
if (!open) {
|
||||
this.reset()
|
||||
}
|
||||
}
|
||||
|
||||
public disablePrimaryButton(type: DataType, object: ObjectWithId): boolean {
|
||||
if (
|
||||
[
|
||||
DataType.Workflow,
|
||||
DataType.CustomField,
|
||||
DataType.Group,
|
||||
DataType.User,
|
||||
].includes(type)
|
||||
) {
|
||||
return !this.permissionsService.currentUserHasObjectPermissions(
|
||||
PermissionAction.Change,
|
||||
object
|
||||
)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
public disableSecondaryButton(type: DataType, object: ObjectWithId): boolean {
|
||||
if (DataType.Document === type) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !this.permissionsService.currentUserHasObjectPermissions(
|
||||
PermissionAction.Change,
|
||||
object
|
||||
)
|
||||
}
|
||||
|
||||
runAdvanedSearch() {
|
||||
this.documentListViewService.quickFilter([
|
||||
{ rule_type: FILTER_FULLTEXT_QUERY, value: this.query },
|
||||
])
|
||||
this.reset(true)
|
||||
}
|
||||
}
|
@ -26,6 +26,7 @@ import { TagComponent } from '../tag/tag.component'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { HotKeyService } from 'src/app/services/hot-key.service'
|
||||
|
||||
const items: Tag[] = [
|
||||
{
|
||||
@ -53,6 +54,7 @@ let selectionModel: FilterableDropdownSelectionModel
|
||||
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
|
||||
let component: FilterableDropdownComponent
|
||||
let fixture: ComponentFixture<FilterableDropdownComponent>
|
||||
let hotkeyService: HotKeyService
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
@ -72,6 +74,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
hotkeyService = TestBed.inject(HotKeyService)
|
||||
fixture = TestBed.createComponent(FilterableDropdownComponent)
|
||||
component = fixture.componentInstance
|
||||
selectionModel = new FilterableDropdownSelectionModel()
|
||||
@ -577,4 +580,14 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
expect(selectionModel.getSelectedItems()).toEqual([items[0]])
|
||||
expect(selectionModel.getExcludedItems()).toEqual([items[1]])
|
||||
})
|
||||
|
||||
it('should support shortcut keys', () => {
|
||||
component.items = items
|
||||
component.icon = 'tag-fill'
|
||||
component.shortcutKey = 't'
|
||||
fixture.detectChanges()
|
||||
const openSpy = jest.spyOn(component.dropdown, 'open')
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 't' }))
|
||||
expect(openSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
@ -5,14 +5,17 @@ import {
|
||||
Output,
|
||||
ElementRef,
|
||||
ViewChild,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
} from '@angular/core'
|
||||
import { FilterPipe } from 'src/app/pipes/filter.pipe'
|
||||
import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ToggleableItemState } from './toggleable-dropdown-button/toggleable-dropdown-button.component'
|
||||
import { MatchingModel } from 'src/app/data/matching-model'
|
||||
import { Subject } from 'rxjs'
|
||||
import { Subject, filter, take, takeUntil } from 'rxjs'
|
||||
import { SelectionDataItem } from 'src/app/services/rest/document.service'
|
||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||
import { HotKeyService } from 'src/app/services/hot-key.service'
|
||||
|
||||
export interface ChangedItems {
|
||||
itemsToAdd: MatchingModel[]
|
||||
@ -322,7 +325,7 @@ export class FilterableDropdownSelectionModel {
|
||||
templateUrl: './filterable-dropdown.component.html',
|
||||
styleUrls: ['./filterable-dropdown.component.scss'],
|
||||
})
|
||||
export class FilterableDropdownComponent {
|
||||
export class FilterableDropdownComponent implements OnDestroy, OnInit {
|
||||
@ViewChild('listFilterTextInput') listFilterTextInput: ElementRef
|
||||
@ViewChild('dropdown') dropdown: NgbDropdown
|
||||
@ViewChild('buttonItems') buttonItems: ElementRef
|
||||
@ -419,6 +422,9 @@ export class FilterableDropdownComponent {
|
||||
@Input()
|
||||
documentCounts: SelectionDataItem[]
|
||||
|
||||
@Input()
|
||||
shortcutKey: string
|
||||
|
||||
get name(): string {
|
||||
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
|
||||
}
|
||||
@ -427,12 +433,39 @@ export class FilterableDropdownComponent {
|
||||
|
||||
private keyboardIndex: number
|
||||
|
||||
constructor(private filterPipe: FilterPipe) {
|
||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||
|
||||
constructor(
|
||||
private filterPipe: FilterPipe,
|
||||
private hotkeyService: HotKeyService
|
||||
) {
|
||||
this.selectionModelChange.subscribe((updatedModel) => {
|
||||
this.modelIsDirty = updatedModel.isDirty()
|
||||
})
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.shortcutKey) {
|
||||
this.hotkeyService
|
||||
.addShortcut({
|
||||
keys: this.shortcutKey,
|
||||
description: $localize`Open ${this.title} filter`,
|
||||
})
|
||||
.pipe(
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
filter(() => !this.disabled)
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.dropdown.open()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribeNotifier.next(true)
|
||||
this.unsubscribeNotifier.complete()
|
||||
}
|
||||
|
||||
applyClicked() {
|
||||
if (this.selectionModel.isDirty()) {
|
||||
this.dropdown.close()
|
||||
|
@ -0,0 +1,23 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
@for (key of hotkeys.entries(); track key[0]) {
|
||||
<tr>
|
||||
<td>{{ key[1] }}</td>
|
||||
<td class="d-flex justify-content-end">
|
||||
<kbd [innerHTML]="formatKey(key[0])"></kbd>
|
||||
@if (key[0].includes('control')) {
|
||||
(macOS <kbd [innerHTML]="formatKey(key[0], true)"></kbd>)
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
</div>
|
@ -0,0 +1,35 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
|
||||
import { HotkeyDialogComponent } from './hotkey-dialog.component'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
|
||||
describe('HotkeyDialogComponent', () => {
|
||||
let component: HotkeyDialogComponent
|
||||
let fixture: ComponentFixture<HotkeyDialogComponent>
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [HotkeyDialogComponent],
|
||||
providers: [NgbActiveModal],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(HotkeyDialogComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should support close', () => {
|
||||
const closeSpy = jest.spyOn(component.activeModal, 'close')
|
||||
component.close()
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should format keys', () => {
|
||||
expect(component.formatKey('control.a')).toEqual('⌃ + a') // ⌃ + a
|
||||
expect(component.formatKey('control.a', true)).toEqual('⌘ + a') // ⌘ + a
|
||||
})
|
||||
})
|
@ -0,0 +1,38 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
|
||||
const SYMBOLS = {
|
||||
meta: '⌘', // ⌘
|
||||
control: '⌃', // ⌃
|
||||
shift: '⇧', // ⇧
|
||||
left: '←', // ←
|
||||
right: '→', // →
|
||||
up: '↑', // ↑
|
||||
down: '↓', // ↓
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-hotkey-dialog',
|
||||
templateUrl: './hotkey-dialog.component.html',
|
||||
styleUrl: './hotkey-dialog.component.scss',
|
||||
})
|
||||
export class HotkeyDialogComponent {
|
||||
public title: string = $localize`Keyboard shortcuts`
|
||||
public hotkeys: Map<string, string> = new Map()
|
||||
|
||||
constructor(public activeModal: NgbActiveModal) {}
|
||||
|
||||
public close(): void {
|
||||
this.activeModal.close()
|
||||
}
|
||||
|
||||
public formatKey(key: string, macOS: boolean = false): string {
|
||||
if (macOS) {
|
||||
key = key.replace('control', 'meta')
|
||||
}
|
||||
return key
|
||||
.split('.')
|
||||
.map((k) => SYMBOLS[k] || k)
|
||||
.join(' + ')
|
||||
}
|
||||
}
|
@ -12,8 +12,12 @@ import {
|
||||
} from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { Router, ActivatedRoute, convertToParamMap } from '@angular/router'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import {
|
||||
Router,
|
||||
ActivatedRoute,
|
||||
convertToParamMap,
|
||||
RouterModule,
|
||||
} from '@angular/router'
|
||||
import {
|
||||
NgbModal,
|
||||
NgbModule,
|
||||
@ -253,7 +257,7 @@ describe('DocumentDetailComponent', () => {
|
||||
DatePipe,
|
||||
],
|
||||
imports: [
|
||||
RouterTestingModule.withRoutes(routes),
|
||||
RouterModule.forRoot(routes),
|
||||
HttpClientTestingModule,
|
||||
NgbModule,
|
||||
NgSelectModule,
|
||||
@ -1126,6 +1130,35 @@ describe('DocumentDetailComponent', () => {
|
||||
req.flush(true)
|
||||
})
|
||||
|
||||
it('should support keyboard shortcuts', () => {
|
||||
initNormally()
|
||||
|
||||
jest.spyOn(component, 'hasNext').mockReturnValue(true)
|
||||
const nextSpy = jest.spyOn(component, 'nextDoc')
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'arrowright', ctrlKey: true })
|
||||
)
|
||||
expect(nextSpy).toHaveBeenCalled()
|
||||
|
||||
jest.spyOn(component, 'hasPrevious').mockReturnValue(true)
|
||||
const prevSpy = jest.spyOn(component, 'previousDoc')
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'arrowleft', ctrlKey: true })
|
||||
)
|
||||
expect(prevSpy).toHaveBeenCalled()
|
||||
|
||||
jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true)
|
||||
const saveSpy = jest.spyOn(component, 'save')
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 's', ctrlKey: true })
|
||||
)
|
||||
expect(saveSpy).toHaveBeenCalled()
|
||||
|
||||
const closeSpy = jest.spyOn(component, 'close')
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'escape' }))
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
function initNormally() {
|
||||
jest
|
||||
.spyOn(activatedRoute, 'paramMap', 'get')
|
||||
|
@ -69,6 +69,7 @@ import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service
|
||||
import { PDFDocumentProxy } from '../common/pdf-viewer/typings'
|
||||
import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
|
||||
import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||
import { HotKeyService } from 'src/app/services/hot-key.service'
|
||||
|
||||
enum DocumentDetailNavIDs {
|
||||
Details = 1,
|
||||
@ -201,7 +202,8 @@ export class DocumentDetailComponent
|
||||
private permissionsService: PermissionsService,
|
||||
private userService: UserService,
|
||||
private customFieldsService: CustomFieldsService,
|
||||
private http: HttpClient
|
||||
private http: HttpClient,
|
||||
private hotKeyService: HotKeyService
|
||||
) {
|
||||
super()
|
||||
}
|
||||
@ -455,6 +457,40 @@ export class DocumentDetailComponent
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
this.hotKeyService
|
||||
.addShortcut({
|
||||
keys: 'control.arrowright',
|
||||
description: $localize`Next document`,
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
if (this.hasNext()) this.nextDoc()
|
||||
})
|
||||
|
||||
this.hotKeyService
|
||||
.addShortcut({
|
||||
keys: 'control.arrowleft',
|
||||
description: $localize`Previous document`,
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
if (this.hasPrevious()) this.previousDoc()
|
||||
})
|
||||
|
||||
this.hotKeyService
|
||||
.addShortcut({ keys: 'escape', description: $localize`Close document` })
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
this.close()
|
||||
})
|
||||
|
||||
this.hotKeyService
|
||||
.addShortcut({ keys: 'control.s', description: $localize`Save document` })
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
if (this.openDocumentService.isDirty(this.document)) this.save()
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
|
@ -21,7 +21,7 @@
|
||||
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
|
||||
filterPlaceholder="Filter tags" i18n-filterPlaceholder
|
||||
[items]="tags"
|
||||
[disabled]="!userCanEditAll"
|
||||
[disabled]="!userCanEditAll || disabled"
|
||||
[editing]="true"
|
||||
[manyToOne]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
@ -29,49 +29,53 @@
|
||||
(opened)="openTagsDropdown()"
|
||||
[(selectionModel)]="tagSelectionModel"
|
||||
[documentCounts]="tagDocumentCounts"
|
||||
(apply)="setTags($event)">
|
||||
(apply)="setTags($event)"
|
||||
shortcutKey="t">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
|
||||
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
|
||||
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
|
||||
[items]="correspondents"
|
||||
[disabled]="!userCanEditAll"
|
||||
[disabled]="!userCanEditAll || disabled"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
[createRef]="createCorrespondent.bind(this)"
|
||||
(opened)="openCorrespondentDropdown()"
|
||||
[(selectionModel)]="correspondentSelectionModel"
|
||||
[documentCounts]="correspondentDocumentCounts"
|
||||
(apply)="setCorrespondents($event)">
|
||||
(apply)="setCorrespondents($event)"
|
||||
shortcutKey="y">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
|
||||
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
|
||||
filterPlaceholder="Filter document types" i18n-filterPlaceholder
|
||||
[items]="documentTypes"
|
||||
[disabled]="!userCanEditAll"
|
||||
[disabled]="!userCanEditAll || disabled"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
[createRef]="createDocumentType.bind(this)"
|
||||
(opened)="openDocumentTypeDropdown()"
|
||||
[(selectionModel)]="documentTypeSelectionModel"
|
||||
[documentCounts]="documentTypeDocumentCounts"
|
||||
(apply)="setDocumentTypes($event)">
|
||||
(apply)="setDocumentTypes($event)"
|
||||
shortcutKey="u">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
|
||||
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
|
||||
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
|
||||
[items]="storagePaths"
|
||||
[disabled]="!userCanEditAll"
|
||||
[disabled]="!userCanEditAll || disabled"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
[createRef]="createStoragePath.bind(this)"
|
||||
(opened)="openStoragePathDropdown()"
|
||||
[(selectionModel)]="storagePathsSelectionModel"
|
||||
[documentCounts]="storagePathDocumentCounts"
|
||||
(apply)="setStoragePaths($event)">
|
||||
(apply)="setStoragePaths($event)"
|
||||
shortcutKey="i">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core'
|
||||
import { Component, Input, OnDestroy, OnInit } from '@angular/core'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
@ -80,6 +80,9 @@ export class BulkEditorComponent
|
||||
downloadUseFormatting: new FormControl(false),
|
||||
})
|
||||
|
||||
@Input()
|
||||
public disabled: boolean = false
|
||||
|
||||
constructor(
|
||||
private documentTypeService: DocumentTypeService,
|
||||
private tagService: TagService,
|
||||
|
@ -96,8 +96,8 @@
|
||||
</pngx-page-header>
|
||||
|
||||
<div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body">
|
||||
<pngx-filter-editor [hidden]="isBulkEditing" [(filterRules)]="list.filterRules" [unmodifiedFilterRules]="unmodifiedFilterRules" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
|
||||
<pngx-bulk-editor [hidden]="!isBulkEditing"></pngx-bulk-editor>
|
||||
<pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [(filterRules)]="list.filterRules" [unmodifiedFilterRules]="unmodifiedFilterRules" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
|
||||
<pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor>
|
||||
</div>
|
||||
|
||||
|
||||
|
@ -19,6 +19,7 @@ import {
|
||||
NgbModalRef,
|
||||
NgbPopoverModule,
|
||||
NgbTooltipModule,
|
||||
NgbTypeaheadModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ClearableBadgeComponent } from '../common/clearable-badge/clearable-badge.component'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
@ -153,6 +154,7 @@ describe('DocumentListComponent', () => {
|
||||
NgbTooltipModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
NgSelectModule,
|
||||
NgbTypeaheadModule,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
@ -654,4 +656,42 @@ describe('DocumentListComponent', () => {
|
||||
'Custom Field 1'
|
||||
)
|
||||
})
|
||||
|
||||
it('should support hotkeys', () => {
|
||||
fixture.detectChanges()
|
||||
const resetSpy = jest.spyOn(component['filterEditor'], 'resetSelected')
|
||||
jest.spyOn(component, 'isFiltered', 'get').mockReturnValue(true)
|
||||
component.clickTag(1)
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'escape' }))
|
||||
expect(resetSpy).toHaveBeenCalled()
|
||||
|
||||
jest
|
||||
.spyOn(documentListService, 'selected', 'get')
|
||||
.mockReturnValue(new Set([1]))
|
||||
const clearSelectedSpy = jest.spyOn(documentListService, 'selectNone')
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'escape' }))
|
||||
expect(clearSelectedSpy).toHaveBeenCalled()
|
||||
|
||||
const selectAllSpy = jest.spyOn(documentListService, 'selectAll')
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }))
|
||||
expect(selectAllSpy).toHaveBeenCalled()
|
||||
|
||||
const selectPageSpy = jest.spyOn(documentListService, 'selectPage')
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'p' }))
|
||||
expect(selectPageSpy).toHaveBeenCalled()
|
||||
|
||||
jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs)
|
||||
fixture.detectChanges()
|
||||
const detailSpy = jest.spyOn(component, 'openDocumentDetail')
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'o' }))
|
||||
expect(detailSpy).toHaveBeenCalledWith(docs[0])
|
||||
|
||||
jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs)
|
||||
jest
|
||||
.spyOn(documentListService, 'selected', 'get')
|
||||
.mockReturnValue(new Set([docs[1].id]))
|
||||
fixture.detectChanges()
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'o' }))
|
||||
expect(detailSpy).toHaveBeenCalledWith(docs[1].id)
|
||||
})
|
||||
})
|
||||
|
@ -32,6 +32,7 @@ import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||
import { FilterEditorComponent } from './filter-editor/filter-editor.component'
|
||||
import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-view-config-dialog.component'
|
||||
import { HotKeyService } from 'src/app/services/hot-key.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-document-list',
|
||||
@ -55,6 +56,7 @@ export class DocumentListComponent
|
||||
private consumerStatusService: ConsumerStatusService,
|
||||
public openDocumentsService: OpenDocumentsService,
|
||||
public settingsService: SettingsService,
|
||||
private hotKeyService: HotKeyService,
|
||||
public permissionService: PermissionsService
|
||||
) {
|
||||
super()
|
||||
@ -215,6 +217,50 @@ export class DocumentListComponent
|
||||
this.unmodifiedFilterRules = []
|
||||
}
|
||||
})
|
||||
|
||||
this.hotKeyService
|
||||
.addShortcut({
|
||||
keys: 'escape',
|
||||
description: $localize`Reset filters / selection`,
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
if (this.list.selected.size > 0) {
|
||||
this.list.selectNone()
|
||||
} else if (this.isFiltered) {
|
||||
this.filterEditor.resetSelected()
|
||||
}
|
||||
})
|
||||
|
||||
this.hotKeyService
|
||||
.addShortcut({ keys: 'a', description: $localize`Select all` })
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
this.list.selectAll()
|
||||
})
|
||||
|
||||
this.hotKeyService
|
||||
.addShortcut({ keys: 'p', description: $localize`Select page` })
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
this.list.selectPage()
|
||||
})
|
||||
|
||||
this.hotKeyService
|
||||
.addShortcut({
|
||||
keys: 'o',
|
||||
description: $localize`Open first [selected] document`,
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
if (this.list.documents.length > 0) {
|
||||
if (this.list.selected.size > 0) {
|
||||
this.openDocumentDetail(Array.from(this.list.selected)[0])
|
||||
} else {
|
||||
this.openDocumentDetail(this.list.documents[0])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
@ -297,8 +343,11 @@ export class DocumentListComponent
|
||||
})
|
||||
}
|
||||
|
||||
openDocumentDetail(document: Document) {
|
||||
this.router.navigate(['documents', document.id])
|
||||
openDocumentDetail(document: Document | number) {
|
||||
this.router.navigate([
|
||||
'documents',
|
||||
typeof document === 'number' ? document : document.id,
|
||||
])
|
||||
}
|
||||
|
||||
toggleSelected(document: Document, event: MouseEvent): void {
|
||||
|
@ -22,7 +22,13 @@
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||
</button>
|
||||
}
|
||||
<input #textFilterInput class="form-control form-control-sm" type="text" [disabled]="textFilterModifierIsNull" [(ngModel)]="textFilter" (keyup)="textFilterKeyup($event)" [readonly]="textFilterTarget === 'fulltext-morelike'">
|
||||
<input #textFilterInput class="form-control form-control-sm" type="text"
|
||||
[disabled]="textFilterModifierIsNull"
|
||||
[(ngModel)]="textFilter"
|
||||
(keyup)="textFilterKeyup($event)"
|
||||
[ngbTypeahead]="searchAutoComplete"
|
||||
(selectItem)="itemSelected($event)"
|
||||
[readonly]="textFilterTarget === 'fulltext-morelike'">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -38,7 +44,9 @@
|
||||
(selectionModelChange)="updateRules()"
|
||||
(opened)="onTagsDropdownOpen()"
|
||||
[documentCounts]="tagDocumentCounts"
|
||||
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
||||
[allowSelectNone]="true"
|
||||
[disabled]="disabled"
|
||||
shortcutKey="t"></pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
|
||||
<pngx-filterable-dropdown class="flex-fill" title="Correspondent" icon="person-fill" i18n-title
|
||||
@ -48,7 +56,9 @@
|
||||
(selectionModelChange)="updateRules()"
|
||||
(opened)="onCorrespondentDropdownOpen()"
|
||||
[documentCounts]="correspondentDocumentCounts"
|
||||
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
||||
[allowSelectNone]="true"
|
||||
[disabled]="disabled"
|
||||
shortcutKey="y"></pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
|
||||
<pngx-filterable-dropdown class="flex-fill" title="Document type" icon="file-earmark-fill" i18n-title
|
||||
@ -58,7 +68,9 @@
|
||||
(selectionModelChange)="updateRules()"
|
||||
(opened)="onDocumentTypeDropdownOpen()"
|
||||
[documentCounts]="documentTypeDocumentCounts"
|
||||
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
||||
[allowSelectNone]="true"
|
||||
[disabled]="disabled"
|
||||
shortcutKey="u"></pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.StoragePath) && storagePaths.length > 0) {
|
||||
<pngx-filterable-dropdown class="flex-fill" title="Storage path" icon="folder-fill" i18n-title
|
||||
@ -68,7 +80,9 @@
|
||||
(selectionModelChange)="updateRules()"
|
||||
(opened)="onStoragePathDropdownOpen()"
|
||||
[documentCounts]="storagePathDocumentCounts"
|
||||
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
||||
[allowSelectNone]="true"
|
||||
[disabled]="disabled"
|
||||
shortcutKey="i"></pngx-filterable-dropdown>
|
||||
}
|
||||
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields.length > 0) {
|
||||
|
@ -11,14 +11,14 @@ import {
|
||||
} from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import {
|
||||
NgbDropdownModule,
|
||||
NgbDatepickerModule,
|
||||
NgbDropdownItem,
|
||||
NgbTypeaheadModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectComponent } from '@ng-select/ng-select'
|
||||
import { of } from 'rxjs'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import {
|
||||
FILTER_TITLE,
|
||||
FILTER_TITLE_CONTENT,
|
||||
@ -92,6 +92,8 @@ import {
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { SearchService } from 'src/app/services/rest/search.service'
|
||||
|
||||
const tags: Tag[] = [
|
||||
{
|
||||
@ -164,6 +166,7 @@ describe('FilterEditorComponent', () => {
|
||||
let settingsService: SettingsService
|
||||
let permissionsService: PermissionsService
|
||||
let httpTestingController: HttpTestingController
|
||||
let searchService: SearchService
|
||||
|
||||
beforeEach(fakeAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
@ -222,12 +225,13 @@ describe('FilterEditorComponent', () => {
|
||||
],
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
RouterTestingModule,
|
||||
RouterModule,
|
||||
NgbDropdownModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgbDatepickerModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
NgbTypeaheadModule,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
@ -235,6 +239,7 @@ describe('FilterEditorComponent', () => {
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
settingsService.currentUser = users[0]
|
||||
permissionsService = TestBed.inject(PermissionsService)
|
||||
searchService = TestBed.inject(SearchService)
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserCan')
|
||||
.mockImplementation((action, type) => {
|
||||
@ -2034,6 +2039,11 @@ describe('FilterEditorComponent', () => {
|
||||
new KeyboardEvent('keyup', { key: 'Escape' })
|
||||
)
|
||||
expect(component.textFilter).toEqual('')
|
||||
const blurSpy = jest.spyOn(component.textFilterInput.nativeElement, 'blur')
|
||||
component.textFilterInput.nativeElement.dispatchEvent(
|
||||
new KeyboardEvent('keyup', { key: 'Escape' })
|
||||
)
|
||||
expect(blurSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should adjust text filter targets if more like search', () => {
|
||||
@ -2044,4 +2054,40 @@ describe('FilterEditorComponent', () => {
|
||||
name: $localize`More like`,
|
||||
})
|
||||
})
|
||||
|
||||
it('should call autocomplete endpoint on input', fakeAsync(() => {
|
||||
component.textFilterTarget = 'fulltext-query' // TEXT_FILTER_TARGET_FULLTEXT_QUERY
|
||||
const autocompleteSpy = jest.spyOn(searchService, 'autocomplete')
|
||||
component.searchAutoComplete(of('hello')).subscribe()
|
||||
tick(250)
|
||||
expect(autocompleteSpy).toHaveBeenCalled()
|
||||
|
||||
component.searchAutoComplete(of('hello world 1')).subscribe()
|
||||
tick(250)
|
||||
expect(autocompleteSpy).toHaveBeenCalled()
|
||||
}))
|
||||
|
||||
it('should handle autocomplete backend failure gracefully', fakeAsync(() => {
|
||||
component.textFilterTarget = 'fulltext-query' // TEXT_FILTER_TARGET_FULLTEXT_QUERY
|
||||
const serviceAutocompleteSpy = jest.spyOn(searchService, 'autocomplete')
|
||||
serviceAutocompleteSpy.mockReturnValue(
|
||||
throwError(() => new Error('autcomplete failed'))
|
||||
)
|
||||
// serviceAutocompleteSpy.mockReturnValue(of([' world']))
|
||||
let result
|
||||
component.searchAutoComplete(of('hello')).subscribe((res) => {
|
||||
result = res
|
||||
})
|
||||
tick(250)
|
||||
expect(serviceAutocompleteSpy).toHaveBeenCalled()
|
||||
expect(result).toEqual([])
|
||||
}))
|
||||
|
||||
it('should support choosing a autocomplete item', () => {
|
||||
expect(component.textFilter).toBeNull()
|
||||
component.itemSelected({ item: 'hello', preventDefault: () => true })
|
||||
expect(component.textFilter).toEqual('hello ')
|
||||
component.itemSelected({ item: 'world', preventDefault: () => true })
|
||||
expect(component.textFilter).toEqual('hello world ')
|
||||
})
|
||||
})
|
||||
|
@ -7,12 +7,21 @@ import {
|
||||
OnDestroy,
|
||||
ViewChild,
|
||||
ElementRef,
|
||||
AfterViewInit,
|
||||
} from '@angular/core'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
import { Subject, Subscription } from 'rxjs'
|
||||
import { debounceTime, distinctUntilChanged, filter } from 'rxjs/operators'
|
||||
import { Observable, Subject, Subscription, from } from 'rxjs'
|
||||
import {
|
||||
catchError,
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
map,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
} from 'rxjs/operators'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
@ -82,6 +91,7 @@ import {
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import { SearchService } from 'src/app/services/rest/search.service'
|
||||
|
||||
const TEXT_FILTER_TARGET_TITLE = 'title'
|
||||
const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content'
|
||||
@ -169,7 +179,7 @@ const DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS = [
|
||||
})
|
||||
export class FilterEditorComponent
|
||||
extends ComponentWithPermissions
|
||||
implements OnInit, OnDestroy
|
||||
implements OnInit, OnDestroy, AfterViewInit
|
||||
{
|
||||
generateFilterName() {
|
||||
if (this.filterRules.length == 1) {
|
||||
@ -251,7 +261,8 @@ export class FilterEditorComponent
|
||||
private documentService: DocumentService,
|
||||
private storagePathService: StoragePathService,
|
||||
public permissionsService: PermissionsService,
|
||||
private customFieldService: CustomFieldsService
|
||||
private customFieldService: CustomFieldsService,
|
||||
private searchService: SearchService
|
||||
) {
|
||||
super()
|
||||
}
|
||||
@ -275,6 +286,8 @@ export class FilterEditorComponent
|
||||
_moreLikeId: number
|
||||
_moreLikeDoc: Document
|
||||
|
||||
unsubscribeNotifier: Subject<any> = new Subject()
|
||||
|
||||
get textFilterTargets() {
|
||||
if (this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_MORELIKE) {
|
||||
return DEFAULT_TEXT_FILTER_TARGET_OPTIONS.concat([
|
||||
@ -944,7 +957,9 @@ export class FilterEditorComponent
|
||||
}
|
||||
|
||||
textFilterDebounce: Subject<string>
|
||||
subscription: Subscription
|
||||
|
||||
@Input()
|
||||
public disabled: boolean = false
|
||||
|
||||
ngOnInit() {
|
||||
if (
|
||||
@ -1000,19 +1015,29 @@ export class FilterEditorComponent
|
||||
|
||||
this.textFilterDebounce = new Subject<string>()
|
||||
|
||||
this.subscription = this.textFilterDebounce
|
||||
this.textFilterDebounce
|
||||
.pipe(
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
debounceTime(400),
|
||||
distinctUntilChanged(),
|
||||
filter((query) => !query.length || query.length > 2)
|
||||
)
|
||||
.subscribe((text) => this.updateTextFilter(text))
|
||||
.subscribe((text) =>
|
||||
this.updateTextFilter(
|
||||
text,
|
||||
this.textFilterTarget !== TEXT_FILTER_TARGET_FULLTEXT_QUERY
|
||||
)
|
||||
)
|
||||
|
||||
if (this._textFilter) this.documentService.searchQuery = this._textFilter
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.textFilterInput.nativeElement.focus()
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.textFilterDebounce.complete()
|
||||
this.unsubscribeNotifier.next(true)
|
||||
}
|
||||
|
||||
resetSelected() {
|
||||
@ -1057,10 +1082,12 @@ export class FilterEditorComponent
|
||||
this.customFieldSelectionModel.apply()
|
||||
}
|
||||
|
||||
updateTextFilter(text) {
|
||||
updateTextFilter(text, updateRules = true) {
|
||||
this._textFilter = text
|
||||
this.documentService.searchQuery = text
|
||||
this.updateRules()
|
||||
if (updateRules) {
|
||||
this.documentService.searchQuery = text
|
||||
this.updateRules()
|
||||
}
|
||||
}
|
||||
|
||||
textFilterKeyup(event: KeyboardEvent) {
|
||||
@ -1071,8 +1098,12 @@ export class FilterEditorComponent
|
||||
if (filterString.length) {
|
||||
this.updateTextFilter(filterString)
|
||||
}
|
||||
} else if (event.key == 'Escape') {
|
||||
this.resetTextField()
|
||||
} else if (event.key === 'Escape') {
|
||||
if (this._textFilter?.length) {
|
||||
this.resetTextField()
|
||||
} else {
|
||||
this.textFilterInput.nativeElement.blur()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1105,4 +1136,40 @@ export class FilterEditorComponent
|
||||
this.updateRules()
|
||||
}
|
||||
}
|
||||
|
||||
searchAutoComplete = (text$: Observable<string>) =>
|
||||
text$.pipe(
|
||||
debounceTime(200),
|
||||
distinctUntilChanged(),
|
||||
filter(() => this.textFilterTarget === TEXT_FILTER_TARGET_FULLTEXT_QUERY),
|
||||
map((term) => {
|
||||
if (term.lastIndexOf(' ') != -1) {
|
||||
return term.substring(term.lastIndexOf(' ') + 1)
|
||||
} else {
|
||||
return term
|
||||
}
|
||||
}),
|
||||
switchMap((term) =>
|
||||
term.length < 2
|
||||
? from([[]])
|
||||
: this.searchService.autocomplete(term).pipe(
|
||||
catchError(() => {
|
||||
return from([[]])
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
itemSelected(event) {
|
||||
event.preventDefault()
|
||||
let currentSearch: string = this._textFilter ?? ''
|
||||
let lastSpaceIndex = currentSearch.lastIndexOf(' ')
|
||||
if (lastSpaceIndex != -1) {
|
||||
currentSearch = currentSearch.substring(0, lastSpaceIndex + 1)
|
||||
currentSearch += event.item + ' '
|
||||
} else {
|
||||
currentSearch = event.item + ' '
|
||||
}
|
||||
this.updateTextFilter(currentSearch)
|
||||
}
|
||||
}
|
||||
|
14
src-ui/src/app/data/datatype.ts
Normal file
14
src-ui/src/app/data/datatype.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export enum DataType {
|
||||
Document = 'document',
|
||||
SavedView = 'saved_view',
|
||||
Correspondent = 'correspondent',
|
||||
DocumentType = 'document_type',
|
||||
StoragePath = 'storage_path',
|
||||
Tag = 'tag',
|
||||
User = 'user',
|
||||
Group = 'group',
|
||||
MailAccount = 'mail_account',
|
||||
MailRule = 'mail_rule',
|
||||
CustomField = 'custom_field',
|
||||
Workflow = 'workflow',
|
||||
}
|
@ -1,3 +1,5 @@
|
||||
import { DataType } from './datatype'
|
||||
|
||||
// These correspond to src/documents/models.py and changes here require a DB migration (and vice versa)
|
||||
export const FILTER_TITLE = 0
|
||||
export const FILTER_CONTENT = 1
|
||||
@ -78,57 +80,57 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
|
||||
id: FILTER_CORRESPONDENT,
|
||||
filtervar: 'correspondent__id',
|
||||
isnull_filtervar: 'correspondent__isnull',
|
||||
datatype: 'correspondent',
|
||||
datatype: DataType.Correspondent,
|
||||
multi: false,
|
||||
},
|
||||
{
|
||||
id: FILTER_HAS_CORRESPONDENT_ANY,
|
||||
filtervar: 'correspondent__id__in',
|
||||
datatype: 'correspondent',
|
||||
datatype: DataType.Correspondent,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
id: FILTER_DOES_NOT_HAVE_CORRESPONDENT,
|
||||
filtervar: 'correspondent__id__none',
|
||||
datatype: 'correspondent',
|
||||
datatype: DataType.Correspondent,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
id: FILTER_STORAGE_PATH,
|
||||
filtervar: 'storage_path__id',
|
||||
isnull_filtervar: 'storage_path__isnull',
|
||||
datatype: 'storage_path',
|
||||
datatype: DataType.StoragePath,
|
||||
multi: false,
|
||||
},
|
||||
{
|
||||
id: FILTER_HAS_STORAGE_PATH_ANY,
|
||||
filtervar: 'storage_path__id__in',
|
||||
datatype: 'storage_path',
|
||||
datatype: DataType.StoragePath,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
id: FILTER_DOES_NOT_HAVE_STORAGE_PATH,
|
||||
filtervar: 'storage_path__id__none',
|
||||
datatype: 'storage_path',
|
||||
datatype: DataType.StoragePath,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
id: FILTER_DOCUMENT_TYPE,
|
||||
filtervar: 'document_type__id',
|
||||
isnull_filtervar: 'document_type__isnull',
|
||||
datatype: 'document_type',
|
||||
datatype: DataType.DocumentType,
|
||||
multi: false,
|
||||
},
|
||||
{
|
||||
id: FILTER_HAS_DOCUMENT_TYPE_ANY,
|
||||
filtervar: 'document_type__id__in',
|
||||
datatype: 'document_type',
|
||||
datatype: DataType.DocumentType,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
id: FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE,
|
||||
filtervar: 'document_type__id__none',
|
||||
datatype: 'document_type',
|
||||
datatype: DataType.DocumentType,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
@ -141,19 +143,19 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
|
||||
{
|
||||
id: FILTER_HAS_TAGS_ALL,
|
||||
filtervar: 'tags__id__all',
|
||||
datatype: 'tag',
|
||||
datatype: DataType.Tag,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
id: FILTER_HAS_TAGS_ANY,
|
||||
filtervar: 'tags__id__in',
|
||||
datatype: 'tag',
|
||||
datatype: DataType.Tag,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
id: FILTER_DOES_NOT_HAVE_TAG,
|
||||
filtervar: 'tags__id__none',
|
||||
datatype: 'tag',
|
||||
datatype: DataType.Tag,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
|
@ -56,6 +56,7 @@ export const SETTINGS_KEYS = {
|
||||
DEFAULT_PERMS_EDIT_GROUPS: 'general-settings:permissions:default-edit-groups',
|
||||
DOCUMENT_EDITING_REMOVE_INBOX_TAGS:
|
||||
'general-settings:document-editing:remove-inbox-tags',
|
||||
SEARCH_DB_ONLY: 'general-settings:search:db-only',
|
||||
}
|
||||
|
||||
export const SETTINGS: UiSetting[] = [
|
||||
@ -219,4 +220,9 @@ export const SETTINGS: UiSetting[] = [
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SEARCH_DB_ONLY,
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
]
|
||||
|
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)
|
||||
}
|
||||
}
|
||||
|
@ -87,7 +87,7 @@ table .btn-link {
|
||||
color: var(--pngx-primary-text-contrast) !important;
|
||||
}
|
||||
|
||||
.navbar .dropdown .btn {
|
||||
.navbar .dropdown > .btn {
|
||||
color: var(--pngx-primary-text-contrast) !important;
|
||||
}
|
||||
|
||||
@ -456,7 +456,7 @@ ul.pagination {
|
||||
color: var(--bs-body-color);
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: var(--pngx-bg-alt);
|
||||
background-color: var(--pngx-bg-darker);
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
|
@ -142,7 +142,7 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
|
||||
background-color: var(--pngx-body-color-accent);
|
||||
}
|
||||
|
||||
.search-form-container {
|
||||
.search-container {
|
||||
input, input:focus {
|
||||
color: var(--bs-body-color) !important;
|
||||
}
|
||||
@ -277,8 +277,9 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
|
||||
color: var(--bs-body-color)
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
.dropdown-item {
|
||||
--bs-dropdown-color: var(--bs-body-color);
|
||||
--pngx-bg-darker: var(--pngx-bg-alt);
|
||||
}
|
||||
|
||||
.list-group {
|
||||
@ -323,6 +324,10 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
|
||||
// navbar is og green in dark mode
|
||||
@include paperless-green;
|
||||
}
|
||||
|
||||
.navbar.bg-primary .dropdown-menu {
|
||||
@include paperless-green-dark-mode;
|
||||
}
|
||||
}
|
||||
|
||||
@include dark-mode;
|
||||
|
@ -796,6 +796,34 @@ class DocumentSerializer(
|
||||
)
|
||||
|
||||
|
||||
class SearchResultSerializer(DocumentSerializer):
|
||||
def to_representation(self, instance):
|
||||
doc = (
|
||||
Document.objects.select_related(
|
||||
"correspondent",
|
||||
"storage_path",
|
||||
"document_type",
|
||||
"owner",
|
||||
)
|
||||
.prefetch_related("tags", "custom_fields", "notes")
|
||||
.get(id=instance["id"])
|
||||
)
|
||||
notes = ",".join(
|
||||
[str(c.note) for c in doc.notes.all()],
|
||||
)
|
||||
r = super().to_representation(doc)
|
||||
r["__search_hit__"] = {
|
||||
"score": instance.score,
|
||||
"highlights": instance.highlights("content", text=doc.content),
|
||||
"note_highlights": (
|
||||
instance.highlights("notes", text=notes) if doc else None
|
||||
),
|
||||
"rank": instance.rank,
|
||||
}
|
||||
|
||||
return r
|
||||
|
||||
|
||||
class SavedViewFilterRuleSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = SavedViewFilterRule
|
||||
|
@ -4,6 +4,7 @@ from unittest import mock
|
||||
|
||||
import pytest
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import override_settings
|
||||
@ -20,9 +21,13 @@ from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import Note
|
||||
from documents.models import SavedView
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import Workflow
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from paperless_mail.models import MailAccount
|
||||
from paperless_mail.models import MailRule
|
||||
|
||||
|
||||
class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
@ -1153,3 +1158,110 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
search_query("&ordering=-owner"),
|
||||
[d3.id, d2.id, d1.id],
|
||||
)
|
||||
|
||||
def test_global_search(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Multiple documents and objects
|
||||
WHEN:
|
||||
- Global search query is made
|
||||
THEN:
|
||||
- Appropriately filtered results are returned
|
||||
"""
|
||||
d1 = Document.objects.create(
|
||||
title="invoice doc1",
|
||||
content="the thing i bought at a shop and paid with bank account",
|
||||
checksum="A",
|
||||
pk=1,
|
||||
)
|
||||
d2 = Document.objects.create(
|
||||
title="bank statement doc2",
|
||||
content="things i paid for in august",
|
||||
checksum="B",
|
||||
pk=2,
|
||||
)
|
||||
d3 = Document.objects.create(
|
||||
title="tax bill doc3",
|
||||
content="no b word",
|
||||
checksum="C",
|
||||
pk=3,
|
||||
)
|
||||
|
||||
with index.open_index_writer() as writer:
|
||||
index.update_document(writer, d1)
|
||||
index.update_document(writer, d2)
|
||||
index.update_document(writer, d3)
|
||||
|
||||
correspondent1 = Correspondent.objects.create(name="bank correspondent 1")
|
||||
Correspondent.objects.create(name="correspondent 2")
|
||||
document_type1 = DocumentType.objects.create(name="bank invoice")
|
||||
DocumentType.objects.create(name="invoice")
|
||||
storage_path1 = StoragePath.objects.create(name="bank path 1", path="path1")
|
||||
StoragePath.objects.create(name="path 2", path="path2")
|
||||
tag1 = Tag.objects.create(name="bank tag1")
|
||||
Tag.objects.create(name="tag2")
|
||||
user1 = User.objects.create_superuser("bank user1")
|
||||
User.objects.create_user("user2")
|
||||
group1 = Group.objects.create(name="bank group1")
|
||||
Group.objects.create(name="group2")
|
||||
SavedView.objects.create(
|
||||
name="bank view",
|
||||
show_on_dashboard=True,
|
||||
show_in_sidebar=True,
|
||||
sort_field="",
|
||||
owner=user1,
|
||||
)
|
||||
mail_account1 = MailAccount.objects.create(name="bank mail account 1")
|
||||
mail_account2 = MailAccount.objects.create(name="mail account 2")
|
||||
mail_rule1 = MailRule.objects.create(
|
||||
name="bank mail rule 1",
|
||||
account=mail_account1,
|
||||
action=MailRule.MailAction.MOVE,
|
||||
)
|
||||
MailRule.objects.create(
|
||||
name="mail rule 2",
|
||||
account=mail_account2,
|
||||
action=MailRule.MailAction.MOVE,
|
||||
)
|
||||
custom_field1 = CustomField.objects.create(
|
||||
name="bank custom field 1",
|
||||
data_type=CustomField.FieldDataType.STRING,
|
||||
)
|
||||
CustomField.objects.create(
|
||||
name="custom field 2",
|
||||
data_type=CustomField.FieldDataType.INT,
|
||||
)
|
||||
workflow1 = Workflow.objects.create(name="bank workflow 1")
|
||||
Workflow.objects.create(name="workflow 2")
|
||||
|
||||
self.client.force_authenticate(user1)
|
||||
|
||||
response = self.client.get("/api/search/?query=bank")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
results = response.data
|
||||
self.assertEqual(len(results["documents"]), 2)
|
||||
self.assertEqual(len(results["saved_views"]), 1)
|
||||
self.assertNotEqual(results["documents"][0]["id"], d3.id)
|
||||
self.assertNotEqual(results["documents"][1]["id"], d3.id)
|
||||
self.assertEqual(results["correspondents"][0]["id"], correspondent1.id)
|
||||
self.assertEqual(results["document_types"][0]["id"], document_type1.id)
|
||||
self.assertEqual(results["storage_paths"][0]["id"], storage_path1.id)
|
||||
self.assertEqual(results["tags"][0]["id"], tag1.id)
|
||||
self.assertEqual(results["users"][0]["id"], user1.id)
|
||||
self.assertEqual(results["groups"][0]["id"], group1.id)
|
||||
self.assertEqual(results["mail_accounts"][0]["id"], mail_account1.id)
|
||||
self.assertEqual(results["mail_rules"][0]["id"], mail_rule1.id)
|
||||
self.assertEqual(results["custom_fields"][0]["id"], custom_field1.id)
|
||||
self.assertEqual(results["workflows"][0]["id"], workflow1.id)
|
||||
|
||||
def test_global_search_bad_request(self):
|
||||
"""
|
||||
WHEN:
|
||||
- Global search query is made without or with query < 3 characters
|
||||
THEN:
|
||||
- Error is returned
|
||||
"""
|
||||
response = self.client.get("/api/search/")
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
response = self.client.get("/api/search/?query=no")
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
@ -17,6 +17,7 @@ from urllib.parse import urlparse
|
||||
import pathvalidate
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import connections
|
||||
@ -137,6 +138,7 @@ from documents.serialisers import DocumentSerializer
|
||||
from documents.serialisers import DocumentTypeSerializer
|
||||
from documents.serialisers import PostDocumentSerializer
|
||||
from documents.serialisers import SavedViewSerializer
|
||||
from documents.serialisers import SearchResultSerializer
|
||||
from documents.serialisers import ShareLinkSerializer
|
||||
from documents.serialisers import StoragePathSerializer
|
||||
from documents.serialisers import TagSerializer
|
||||
@ -152,7 +154,13 @@ from paperless import version
|
||||
from paperless.celery import app as celery_app
|
||||
from paperless.config import GeneralConfig
|
||||
from paperless.db import GnuPG
|
||||
from paperless.serialisers import GroupSerializer
|
||||
from paperless.serialisers import UserSerializer
|
||||
from paperless.views import StandardPagination
|
||||
from paperless_mail.models import MailAccount
|
||||
from paperless_mail.models import MailRule
|
||||
from paperless_mail.serialisers import MailAccountSerializer
|
||||
from paperless_mail.serialisers import MailRuleSerializer
|
||||
|
||||
if settings.AUDIT_LOG_ENABLED:
|
||||
from auditlog.models import LogEntry
|
||||
@ -813,34 +821,6 @@ class DocumentViewSet(
|
||||
return Response(sorted(entries, key=lambda x: x["timestamp"], reverse=True))
|
||||
|
||||
|
||||
class SearchResultSerializer(DocumentSerializer, PassUserMixin):
|
||||
def to_representation(self, instance):
|
||||
doc = (
|
||||
Document.objects.select_related(
|
||||
"correspondent",
|
||||
"storage_path",
|
||||
"document_type",
|
||||
"owner",
|
||||
)
|
||||
.prefetch_related("tags", "custom_fields", "notes")
|
||||
.get(id=instance["id"])
|
||||
)
|
||||
notes = ",".join(
|
||||
[str(c.note) for c in doc.notes.all()],
|
||||
)
|
||||
r = super().to_representation(doc)
|
||||
r["__search_hit__"] = {
|
||||
"score": instance.score,
|
||||
"highlights": instance.highlights("content", text=doc.content),
|
||||
"note_highlights": (
|
||||
instance.highlights("notes", text=notes) if doc else None
|
||||
),
|
||||
"rank": instance.rank,
|
||||
}
|
||||
|
||||
return r
|
||||
|
||||
|
||||
class UnifiedSearchViewSet(DocumentViewSet):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@ -1158,6 +1138,189 @@ class SearchAutoCompleteView(APIView):
|
||||
)
|
||||
|
||||
|
||||
class GlobalSearchView(PassUserMixin):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = SearchResultSerializer
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
query = request.query_params.get("query", None)
|
||||
if query is None:
|
||||
return HttpResponseBadRequest("Query required")
|
||||
elif len(query) < 3:
|
||||
return HttpResponseBadRequest("Query must be at least 3 characters")
|
||||
|
||||
db_only = request.query_params.get("db_only", False)
|
||||
|
||||
OBJECT_LIMIT = 3
|
||||
docs = []
|
||||
if request.user.has_perm("documents.view_document"):
|
||||
all_docs = get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_document",
|
||||
Document,
|
||||
)
|
||||
# First search by title
|
||||
docs = all_docs.filter(title__icontains=query)[:OBJECT_LIMIT]
|
||||
if not db_only and len(docs) < OBJECT_LIMIT:
|
||||
# If we don't have enough results, search by content
|
||||
from documents import index
|
||||
|
||||
with index.open_index_searcher() as s:
|
||||
q, _ = index.DelayedFullTextQuery(
|
||||
s,
|
||||
request.query_params,
|
||||
10,
|
||||
request.user,
|
||||
)._get_query()
|
||||
results = s.search(q, limit=OBJECT_LIMIT)
|
||||
docs = docs | all_docs.filter(id__in=[r["id"] for r in results])
|
||||
saved_views = (
|
||||
SavedView.objects.filter(owner=request.user, name__icontains=query)[
|
||||
:OBJECT_LIMIT
|
||||
]
|
||||
if request.user.has_perm("documents.view_savedview")
|
||||
else []
|
||||
)
|
||||
tags = (
|
||||
get_objects_for_user_owner_aware(request.user, "view_tag", Tag).filter(
|
||||
name__icontains=query,
|
||||
)[:OBJECT_LIMIT]
|
||||
if request.user.has_perm("documents.view_tag")
|
||||
else []
|
||||
)
|
||||
correspondents = (
|
||||
get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_correspondent",
|
||||
Correspondent,
|
||||
).filter(name__icontains=query)[:OBJECT_LIMIT]
|
||||
if request.user.has_perm("documents.view_correspondent")
|
||||
else []
|
||||
)
|
||||
document_types = (
|
||||
get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_documenttype",
|
||||
DocumentType,
|
||||
).filter(name__icontains=query)[:OBJECT_LIMIT]
|
||||
if request.user.has_perm("documents.view_documenttype")
|
||||
else []
|
||||
)
|
||||
storage_paths = (
|
||||
get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_storagepath",
|
||||
StoragePath,
|
||||
).filter(name__icontains=query)[:OBJECT_LIMIT]
|
||||
if request.user.has_perm("documents.view_storagepath")
|
||||
else []
|
||||
)
|
||||
users = (
|
||||
User.objects.filter(username__icontains=query)[:OBJECT_LIMIT]
|
||||
if request.user.has_perm("auth.view_user")
|
||||
else []
|
||||
)
|
||||
groups = (
|
||||
Group.objects.filter(name__icontains=query)[:OBJECT_LIMIT]
|
||||
if request.user.has_perm("auth.view_group")
|
||||
else []
|
||||
)
|
||||
mail_rules = (
|
||||
MailRule.objects.filter(name__icontains=query)[:OBJECT_LIMIT]
|
||||
if request.user.has_perm("paperless_mail.view_mailrule")
|
||||
else []
|
||||
)
|
||||
mail_accounts = (
|
||||
MailAccount.objects.filter(name__icontains=query)[:OBJECT_LIMIT]
|
||||
if request.user.has_perm("paperless_mail.view_mailaccount")
|
||||
else []
|
||||
)
|
||||
workflows = (
|
||||
Workflow.objects.filter(name__icontains=query)[:OBJECT_LIMIT]
|
||||
if request.user.has_perm("documents.view_workflow")
|
||||
else []
|
||||
)
|
||||
custom_fields = (
|
||||
CustomField.objects.filter(name__icontains=query)[:OBJECT_LIMIT]
|
||||
if request.user.has_perm("documents.view_customfield")
|
||||
else []
|
||||
)
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
}
|
||||
|
||||
docs_serializer = DocumentSerializer(docs, many=True, context=context)
|
||||
saved_views_serializer = SavedViewSerializer(
|
||||
saved_views,
|
||||
many=True,
|
||||
context=context,
|
||||
)
|
||||
tags_serializer = TagSerializer(tags, many=True, context=context)
|
||||
correspondents_serializer = CorrespondentSerializer(
|
||||
correspondents,
|
||||
many=True,
|
||||
context=context,
|
||||
)
|
||||
document_types_serializer = DocumentTypeSerializer(
|
||||
document_types,
|
||||
many=True,
|
||||
context=context,
|
||||
)
|
||||
storage_paths_serializer = StoragePathSerializer(
|
||||
storage_paths,
|
||||
many=True,
|
||||
context=context,
|
||||
)
|
||||
users_serializer = UserSerializer(users, many=True, context=context)
|
||||
groups_serializer = GroupSerializer(groups, many=True, context=context)
|
||||
mail_rules_serializer = MailRuleSerializer(
|
||||
mail_rules,
|
||||
many=True,
|
||||
context=context,
|
||||
)
|
||||
mail_accounts_serializer = MailAccountSerializer(
|
||||
mail_accounts,
|
||||
many=True,
|
||||
context=context,
|
||||
)
|
||||
workflows_serializer = WorkflowSerializer(workflows, many=True, context=context)
|
||||
custom_fields_serializer = CustomFieldSerializer(
|
||||
custom_fields,
|
||||
many=True,
|
||||
context=context,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"total": len(docs)
|
||||
+ len(saved_views)
|
||||
+ len(tags)
|
||||
+ len(correspondents)
|
||||
+ len(document_types)
|
||||
+ len(storage_paths)
|
||||
+ len(users)
|
||||
+ len(groups)
|
||||
+ len(mail_rules)
|
||||
+ len(mail_accounts)
|
||||
+ len(workflows)
|
||||
+ len(custom_fields),
|
||||
"documents": docs_serializer.data,
|
||||
"saved_views": saved_views_serializer.data,
|
||||
"tags": tags_serializer.data,
|
||||
"correspondents": correspondents_serializer.data,
|
||||
"document_types": document_types_serializer.data,
|
||||
"storage_paths": storage_paths_serializer.data,
|
||||
"users": users_serializer.data,
|
||||
"groups": groups_serializer.data,
|
||||
"mail_rules": mail_rules_serializer.data,
|
||||
"mail_accounts": mail_accounts_serializer.data,
|
||||
"workflows": workflows_serializer.data,
|
||||
"custom_fields": custom_fields_serializer.data,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class StatisticsView(APIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
|
@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-04-24 22:54-0700\n"
|
||||
"POT-Creation-Date: 2024-04-26 07:19-0700\n"
|
||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@ -917,12 +917,12 @@ msgstr ""
|
||||
msgid "Invalid color."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:1169
|
||||
#: documents/serialisers.py:1197
|
||||
#, python-format
|
||||
msgid "File type %(type)s not supported"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:1278
|
||||
#: documents/serialisers.py:1306
|
||||
msgid "Invalid variable detected."
|
||||
msgstr ""
|
||||
|
||||
@ -1418,7 +1418,7 @@ msgstr ""
|
||||
msgid "Chinese Simplified"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/urls.py:230
|
||||
#: paperless/urls.py:236
|
||||
msgid "Paperless-ngx administration"
|
||||
msgstr ""
|
||||
|
||||
|
@ -21,6 +21,7 @@ from documents.views import BulkEditView
|
||||
from documents.views import CorrespondentViewSet
|
||||
from documents.views import CustomFieldViewSet
|
||||
from documents.views import DocumentTypeViewSet
|
||||
from documents.views import GlobalSearchView
|
||||
from documents.views import IndexView
|
||||
from documents.views import LogViewSet
|
||||
from documents.views import PostDocumentView
|
||||
@ -91,6 +92,11 @@ urlpatterns = [
|
||||
SearchAutoCompleteView.as_view(),
|
||||
name="autocomplete",
|
||||
),
|
||||
re_path(
|
||||
"^search/",
|
||||
GlobalSearchView.as_view(),
|
||||
name="global_search",
|
||||
),
|
||||
re_path("^statistics/", StatisticsView.as_view(), name="statistics"),
|
||||
re_path(
|
||||
"^documents/post_document/",
|
||||
|
Loading…
x
Reference in New Issue
Block a user