Feature: global search, keyboard shortcuts / hotkey support (#6449)

This commit is contained in:
shamoon
2024-05-02 09:15:56 -07:00
committed by GitHub
parent 40289cd714
commit c6e7d06bb7
51 changed files with 2970 additions and 683 deletions

View File

@@ -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">

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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>&nbsp;<ng-container i18n>Open</ng-container></span>
} @else if (type === DataType.SavedView) {
<i-bs width="1em" height="1em" name="eye"></i-bs>
<span>&nbsp;<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>&nbsp;<ng-container i18n>Edit</ng-container></span>
} @else {
<i-bs width="1em" height="1em" name="filter"></i-bs>
<span>&nbsp;<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>&nbsp;<ng-container i18n>Download</ng-container></span>
} @else {
<i-bs width="1em" height="1em" name="pencil"></i-bs>
<span>&nbsp;<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>

View File

@@ -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;
}
}

View File

@@ -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' },
])
})
})

View File

@@ -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)
}
}