Initial frontend global search
This commit is contained in:
parent
5fdda05afb
commit
4c976bf070
@ -45,8 +45,8 @@ test('basic filtering', async ({ page }) => {
|
|||||||
test('text filtering', async ({ page }) => {
|
test('text filtering', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
||||||
await page.goto('/documents')
|
await page.goto('/documents')
|
||||||
await page.getByRole('textbox').click()
|
await page.getByRole('main').getByRole('textbox').click()
|
||||||
await page.getByRole('textbox').fill('test')
|
await page.getByRole('main').getByRole('textbox').fill('test')
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/32 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/32 documents/)
|
||||||
await expect(page).toHaveURL(/title_content=test/)
|
await expect(page).toHaveURL(/title_content=test/)
|
||||||
await page.getByRole('button', { name: 'Title & content' }).click()
|
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 expect(page.locator('pngx-document-list')).toHaveText(/26 documents/)
|
||||||
await page.getByRole('button', { name: 'Advanced search' }).click()
|
await page.getByRole('button', { name: 'Advanced search' }).click()
|
||||||
await page.getByRole('button', { name: 'ASN' }).click()
|
await page.getByRole('button', { name: 'ASN' }).click()
|
||||||
await page.getByRole('textbox').fill('1123')
|
await page.getByRole('main').getByRole('textbox').fill('1123')
|
||||||
await expect(page).toHaveURL(/archive_serial_number=1123/)
|
await expect(page).toHaveURL(/archive_serial_number=1123/)
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
|
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
|
||||||
await page.locator('select').selectOption('greater')
|
await page.locator('select').selectOption('greater')
|
||||||
await page.getByRole('textbox').click()
|
await page.getByRole('main').getByRole('textbox').click()
|
||||||
await page.getByRole('textbox').fill('1123')
|
await page.getByRole('main').getByRole('textbox').fill('1123')
|
||||||
await expect(page).toHaveURL(/archive_serial_number__gt=1123/)
|
await expect(page).toHaveURL(/archive_serial_number__gt=1123/)
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/5 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/5 documents/)
|
||||||
await page.locator('select').selectOption('less')
|
await page.locator('select').selectOption('less')
|
||||||
|
@ -120,6 +120,7 @@ import { RotateConfirmDialogComponent } from './components/common/confirm-dialog
|
|||||||
import { MergeConfirmDialogComponent } from './components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
import { MergeConfirmDialogComponent } from './components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||||
import { SplitConfirmDialogComponent } from './components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
|
import { SplitConfirmDialogComponent } from './components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
|
||||||
import { DocumentHistoryComponent } from './components/document-history/document-history.component'
|
import { DocumentHistoryComponent } from './components/document-history/document-history.component'
|
||||||
|
import { GlobalSearchComponent } from './components/app-frame/global-search/global-search.component'
|
||||||
import {
|
import {
|
||||||
airplane,
|
airplane,
|
||||||
archive,
|
archive,
|
||||||
@ -192,6 +193,7 @@ import {
|
|||||||
personFill,
|
personFill,
|
||||||
personFillLock,
|
personFillLock,
|
||||||
personLock,
|
personLock,
|
||||||
|
personSquare,
|
||||||
plus,
|
plus,
|
||||||
plusCircle,
|
plusCircle,
|
||||||
questionCircle,
|
questionCircle,
|
||||||
@ -202,6 +204,7 @@ import {
|
|||||||
sortAlphaDown,
|
sortAlphaDown,
|
||||||
sortAlphaUpAlt,
|
sortAlphaUpAlt,
|
||||||
tagFill,
|
tagFill,
|
||||||
|
tag,
|
||||||
tags,
|
tags,
|
||||||
textIndentLeft,
|
textIndentLeft,
|
||||||
textLeft,
|
textLeft,
|
||||||
@ -286,6 +289,7 @@ const icons = {
|
|||||||
personFill,
|
personFill,
|
||||||
personFillLock,
|
personFillLock,
|
||||||
personLock,
|
personLock,
|
||||||
|
personSquare,
|
||||||
plus,
|
plus,
|
||||||
plusCircle,
|
plusCircle,
|
||||||
questionCircle,
|
questionCircle,
|
||||||
@ -296,6 +300,7 @@ const icons = {
|
|||||||
sortAlphaDown,
|
sortAlphaDown,
|
||||||
sortAlphaUpAlt,
|
sortAlphaUpAlt,
|
||||||
tagFill,
|
tagFill,
|
||||||
|
tag,
|
||||||
tags,
|
tags,
|
||||||
textIndentLeft,
|
textIndentLeft,
|
||||||
textLeft,
|
textLeft,
|
||||||
@ -474,6 +479,7 @@ function initializeApp(settings: SettingsService) {
|
|||||||
MergeConfirmDialogComponent,
|
MergeConfirmDialogComponent,
|
||||||
SplitConfirmDialogComponent,
|
SplitConfirmDialogComponent,
|
||||||
DocumentHistoryComponent,
|
DocumentHistoryComponent,
|
||||||
|
GlobalSearchComponent,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
|
@ -24,19 +24,8 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</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"
|
<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">
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
<pngx-global-search></pngx-global-search>
|
||||||
<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>
|
</div>
|
||||||
<ul ngbNav class="order-sm-3">
|
<ul ngbNav class="order-sm-3">
|
||||||
<li ngbDropdown class="nav-item dropdown">
|
<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 {
|
.version-check {
|
||||||
animation: pulse 2s ease-in-out 0s 1;
|
animation: pulse 2s ease-in-out 0s 1;
|
||||||
}
|
}
|
||||||
|
@ -30,8 +30,6 @@ import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
|||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||||
import { SearchService } from 'src/app/services/rest/search.service'
|
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 { routes } from 'src/app/app-routing.module'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
import { CdkDragDrop, DragDropModule } from '@angular/cdk/drag-drop'
|
import { CdkDragDrop, DragDropModule } from '@angular/cdk/drag-drop'
|
||||||
@ -89,8 +87,6 @@ describe('AppFrameComponent', () => {
|
|||||||
let toastService: ToastService
|
let toastService: ToastService
|
||||||
let messagesService: DjangoMessagesService
|
let messagesService: DjangoMessagesService
|
||||||
let openDocumentsService: OpenDocumentsService
|
let openDocumentsService: OpenDocumentsService
|
||||||
let searchService: SearchService
|
|
||||||
let documentListViewService: DocumentListViewService
|
|
||||||
let router: Router
|
let router: Router
|
||||||
let savedViewSpy
|
let savedViewSpy
|
||||||
let modalService: NgbModal
|
let modalService: NgbModal
|
||||||
@ -159,8 +155,6 @@ describe('AppFrameComponent', () => {
|
|||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
messagesService = TestBed.inject(DjangoMessagesService)
|
messagesService = TestBed.inject(DjangoMessagesService)
|
||||||
openDocumentsService = TestBed.inject(OpenDocumentsService)
|
openDocumentsService = TestBed.inject(OpenDocumentsService)
|
||||||
searchService = TestBed.inject(SearchService)
|
|
||||||
documentListViewService = TestBed.inject(DocumentListViewService)
|
|
||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
router = TestBed.inject(Router)
|
router = TestBed.inject(Router)
|
||||||
|
|
||||||
@ -296,62 +290,6 @@ describe('AppFrameComponent', () => {
|
|||||||
expect(component.canDeactivate()).toBeFalsy()
|
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', () => {
|
it('should disable global dropzone on start drag + drop, re-enable after', () => {
|
||||||
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
|
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
|
||||||
component.onDragStart(null)
|
component.onDragStart(null)
|
||||||
|
@ -1,15 +1,7 @@
|
|||||||
import { Component, HostListener, OnInit } from '@angular/core'
|
import { Component, HostListener, OnInit } from '@angular/core'
|
||||||
import { FormControl } from '@angular/forms'
|
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
import { from, Observable } from 'rxjs'
|
import { Observable } from 'rxjs'
|
||||||
import {
|
import { first } from 'rxjs/operators'
|
||||||
debounceTime,
|
|
||||||
distinctUntilChanged,
|
|
||||||
map,
|
|
||||||
switchMap,
|
|
||||||
first,
|
|
||||||
catchError,
|
|
||||||
} from 'rxjs/operators'
|
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||||
import {
|
import {
|
||||||
@ -17,11 +9,8 @@ import {
|
|||||||
DjangoMessagesService,
|
DjangoMessagesService,
|
||||||
} from 'src/app/services/django-messages.service'
|
} from 'src/app/services/django-messages.service'
|
||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.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 { environment } from 'src/environments/environment'
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
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 {
|
import {
|
||||||
RemoteVersionService,
|
RemoteVersionService,
|
||||||
AppRemoteVersion,
|
AppRemoteVersion,
|
||||||
@ -63,16 +52,12 @@ export class AppFrameComponent
|
|||||||
|
|
||||||
slimSidebarAnimating: boolean = false
|
slimSidebarAnimating: boolean = false
|
||||||
|
|
||||||
searchField = new FormControl('')
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public router: Router,
|
public router: Router,
|
||||||
private activatedRoute: ActivatedRoute,
|
private activatedRoute: ActivatedRoute,
|
||||||
private openDocumentsService: OpenDocumentsService,
|
private openDocumentsService: OpenDocumentsService,
|
||||||
private searchService: SearchService,
|
|
||||||
public savedViewService: SavedViewService,
|
public savedViewService: SavedViewService,
|
||||||
private remoteVersionService: RemoteVersionService,
|
private remoteVersionService: RemoteVersionService,
|
||||||
private list: DocumentListViewService,
|
|
||||||
public settingsService: SettingsService,
|
public settingsService: SettingsService,
|
||||||
public tasksService: TasksService,
|
public tasksService: TasksService,
|
||||||
private readonly toastService: ToastService,
|
private readonly toastService: ToastService,
|
||||||
@ -164,65 +149,6 @@ export class AppFrameComponent
|
|||||||
return !this.openDocumentsService.hasDirty()
|
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) {
|
closeDocument(d: Document) {
|
||||||
this.openDocumentsService
|
this.openDocumentsService
|
||||||
.closeDocument(d)
|
.closeDocument(d)
|
||||||
|
@ -0,0 +1,104 @@
|
|||||||
|
|
||||||
|
<div ngbDropdown #resultsDropdown="ngbDropdown" (openChange)="onDropdownOpenChange">
|
||||||
|
<form class="form-inline col-6 position-relative">
|
||||||
|
<input #searchInput class="form-control form-control-sm" type="text" name="query"
|
||||||
|
autocomplete="off" placeholder="Search everything" aria-label="Search" i18n-placeholder
|
||||||
|
[(ngModel)]="query" (ngModelChange)="this.queryDebounce.next($event)" (keyup)="searchInputKeyDown($event)" ngbDropdownAnchor>
|
||||||
|
<span class="badge text-muted position-absolute top-50 start-100 translate-middle ms-n4 fw-normal">⌘K</span>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ng-template #resultItemTemplate let-item="item" let-nameProp="nameProp" let-type="type" let-icon="icon">
|
||||||
|
<div #resultItem ngbDropdownItem class="py-2 d-flex align-items-center focus-ring border-0" (click)="primaryAction(type, item)">
|
||||||
|
<i-bs width="1em" height="1em" name="{{icon}}" class="me-2"></i-bs>
|
||||||
|
<span>{{item[nameProp]}}</span>
|
||||||
|
<div class="btn-group ms-auto">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" (click)="primaryAction(type, item); $event.stopPropagation()">
|
||||||
|
@if (type === 'document' || type === 'workflow' || type === 'customField' || type === 'group' || type === 'user') {
|
||||||
|
<i-bs width="1em" height="1em" name="pencil"></i-bs>
|
||||||
|
} @else {
|
||||||
|
<i-bs width="1em" height="1em" name="filter"></i-bs>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
@if (type !== 'workflow' && type !== 'customField' && type !== 'group' && type !== 'user') {
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" (click)="secondaryAction(type, item); $event.stopPropagation()">
|
||||||
|
@if (type === 'document') {
|
||||||
|
<i-bs width="1em" height="1em" name="download"></i-bs>
|
||||||
|
} @else {
|
||||||
|
<i-bs width="1em" height="1em" name="pencil"></i-bs>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<div ngbDropdownMenu class="col-6">
|
||||||
|
@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: 'document', icon: 'file-text'}"></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: '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: '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: '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: '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: '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: '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: 'customField', icon: 'ui-radios'}"></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: 'workflow', icon: 'boxes'}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,33 @@
|
|||||||
|
form {
|
||||||
|
&:focus-within {
|
||||||
|
.form-control::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
background-color: rgba(0, 0, 0, 0.15);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
transition: all .3s ease, padding-left 0s ease, background-color 0s ease; // Safari requires all
|
||||||
|
|
||||||
|
&::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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
--pngx-focus-alpha: 0;
|
||||||
|
}
|
@ -0,0 +1,268 @@
|
|||||||
|
import {
|
||||||
|
ComponentFixture,
|
||||||
|
TestBed,
|
||||||
|
fakeAsync,
|
||||||
|
tick,
|
||||||
|
} from '@angular/core/testing'
|
||||||
|
import { GlobalSearchComponent } from './global-search.component'
|
||||||
|
import { Subject, of } from 'rxjs'
|
||||||
|
import { SearchService } from 'src/app/services/rest/search.service'
|
||||||
|
import { Router } from '@angular/router'
|
||||||
|
import {
|
||||||
|
NgbDropdownModule,
|
||||||
|
NgbModal,
|
||||||
|
NgbModalModule,
|
||||||
|
} 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 { HttpClient } from '@angular/common/http'
|
||||||
|
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
|
import { FILTER_HAS_CORRESPONDENT_ANY } from 'src/app/data/filter-rule-type'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
|
||||||
|
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: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
correspondents: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'TestCorrespondent',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
document_types: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'TestDocumentType',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
storage_paths: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
path: '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
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(GlobalSearchComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize properties', () => {
|
||||||
|
expect(component.query).toBeUndefined()
|
||||||
|
expect(component.queryDebounce).toBeInstanceOf(Subject)
|
||||||
|
expect(component.searchResults).toBeUndefined()
|
||||||
|
expect(component['currentItemIndex']).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle keyboard events', () => {
|
||||||
|
const focusSpy = jest.spyOn(component.searchInput.nativeElement, 'focus')
|
||||||
|
component.handleKeyboardEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'k', ctrlKey: true })
|
||||||
|
)
|
||||||
|
expect(focusSpy).toHaveBeenCalled()
|
||||||
|
// coverage
|
||||||
|
component.handleKeyboardEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'k', metaKey: true })
|
||||||
|
)
|
||||||
|
|
||||||
|
component.searchResults = searchResults as any
|
||||||
|
component.resultsDropdown.open()
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
component['currentItemIndex'] = 0
|
||||||
|
const firstItemFocusSpy = jest.spyOn(
|
||||||
|
component.resultItems.get(1).nativeElement,
|
||||||
|
'focus'
|
||||||
|
)
|
||||||
|
component.handleKeyboardEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowDown' })
|
||||||
|
)
|
||||||
|
expect(component['currentItemIndex']).toBe(1)
|
||||||
|
expect(firstItemFocusSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
const zeroItemSpy = jest.spyOn(
|
||||||
|
component.resultItems.get(0).nativeElement,
|
||||||
|
'focus'
|
||||||
|
)
|
||||||
|
component.handleKeyboardEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowUp' })
|
||||||
|
)
|
||||||
|
expect(component['currentItemIndex']).toBe(0)
|
||||||
|
expect(zeroItemSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
const actionSpy = jest.spyOn(component, 'primaryAction')
|
||||||
|
component.handleKeyboardEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter' })
|
||||||
|
)
|
||||||
|
expect(actionSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should search', 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 perform primary action', () => {
|
||||||
|
const object = { id: 1 }
|
||||||
|
const routerSpy = jest.spyOn(router, 'navigate')
|
||||||
|
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
|
||||||
|
component.primaryAction('document', object)
|
||||||
|
expect(routerSpy).toHaveBeenCalledWith(['/documents', object.id])
|
||||||
|
|
||||||
|
component.primaryAction('correspondent', object)
|
||||||
|
expect(qfSpy).toHaveBeenCalledWith([
|
||||||
|
{ rule_type: FILTER_HAS_CORRESPONDENT_ANY, value: object.id.toString() },
|
||||||
|
])
|
||||||
|
|
||||||
|
component.primaryAction('user', object)
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(UserEditDialogComponent, {
|
||||||
|
size: 'lg',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should perform secondary action', () => {
|
||||||
|
const doc = searchResults.documents[0]
|
||||||
|
const routerSpy = jest.spyOn(router, 'navigate')
|
||||||
|
component.secondaryAction('document', doc)
|
||||||
|
expect(routerSpy).toHaveBeenCalledWith(
|
||||||
|
[documentService.getDownloadUrl(doc.id)],
|
||||||
|
{ skipLocationChange: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const correspondent = searchResults.correspondents[0]
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
component.secondaryAction('correspondent', correspondent)
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
|
||||||
|
size: 'lg',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// it('should reset', () => {
|
||||||
|
// jest.spyOn(component.queryDebounce, 'next');
|
||||||
|
// jest.spyOn(component.resultsDropdown, 'close');
|
||||||
|
// component.reset();
|
||||||
|
// expect(component.queryDebounce.next).toHaveBeenCalledWith('');
|
||||||
|
// expect(component.searchResults).toBeNull();
|
||||||
|
// expect(component['currentItemIndex']).toBeUndefined();
|
||||||
|
// expect(component.resultsDropdown.close).toHaveBeenCalled();
|
||||||
|
// });
|
||||||
|
|
||||||
|
// it('should set current item', () => {
|
||||||
|
// jest.spyOn(component.resultItems.get(0).nativeElement, 'focus');
|
||||||
|
// component.currentItemIndex = 0;
|
||||||
|
// component.setCurrentItem();
|
||||||
|
// expect(component.resultItems.get(0).nativeElement.focus).toHaveBeenCalled();
|
||||||
|
// });
|
||||||
|
|
||||||
|
// it('should handle search input keydown', () => {
|
||||||
|
// jest.spyOn(component.resultItems.first.nativeElement, 'click');
|
||||||
|
// component.searchResults = { total: 1 };
|
||||||
|
// component.resultsDropdown = { isOpen: () => true };
|
||||||
|
// component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
|
||||||
|
// expect(component.currentItemIndex).toBe(0);
|
||||||
|
// expect(component.resultItems.first.nativeElement.focus).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }));
|
||||||
|
// expect(component.resultItems.first.nativeElement.click).toHaveBeenCalled();
|
||||||
|
// });
|
||||||
|
|
||||||
|
// it('should handle dropdown open change', () => {
|
||||||
|
// jest.spyOn(component, 'reset');
|
||||||
|
// component.onDropdownOpenChange(false);
|
||||||
|
// expect(component.reset).toHaveBeenCalled();
|
||||||
|
// });
|
||||||
|
})
|
@ -0,0 +1,228 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
HostListener,
|
||||||
|
QueryList,
|
||||||
|
ViewChild,
|
||||||
|
ViewChildren,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { Router } from '@angular/router'
|
||||||
|
import { NgbDropdown, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { Subject, debounceTime, distinctUntilChanged, filter } from 'rxjs'
|
||||||
|
import { ObjectWithId } from 'src/app/data/object-with-id'
|
||||||
|
import {
|
||||||
|
GlobalSearchResult,
|
||||||
|
SearchService,
|
||||||
|
} from 'src/app/services/rest/search.service'
|
||||||
|
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
|
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||||
|
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-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 { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-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 { 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 { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||||
|
import {
|
||||||
|
FILTER_HAS_ANY_TAG,
|
||||||
|
FILTER_HAS_CORRESPONDENT_ANY,
|
||||||
|
FILTER_HAS_DOCUMENT_TYPE_ANY,
|
||||||
|
FILTER_HAS_STORAGE_PATH_ANY,
|
||||||
|
} from 'src/app/data/filter-rule-type'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-global-search',
|
||||||
|
templateUrl: './global-search.component.html',
|
||||||
|
styleUrl: './global-search.component.scss',
|
||||||
|
})
|
||||||
|
export class GlobalSearchComponent {
|
||||||
|
public query: string
|
||||||
|
public queryDebounce: Subject<string>
|
||||||
|
public searchResults: GlobalSearchResult
|
||||||
|
private currentItemIndex: number
|
||||||
|
|
||||||
|
@ViewChild('searchInput') searchInput: ElementRef
|
||||||
|
@ViewChild('resultsDropdown') resultsDropdown: NgbDropdown
|
||||||
|
@ViewChildren('resultItem') resultItems: QueryList<ElementRef>
|
||||||
|
|
||||||
|
@HostListener('document:keydown', ['$event'])
|
||||||
|
handleKeyboardEvent(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'k' && (event.ctrlKey || event.metaKey)) {
|
||||||
|
this.searchInput.nativeElement.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.searchResults && this.resultsDropdown.isOpen()) {
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
if (this.currentItemIndex < this.resultItems.length - 1) {
|
||||||
|
this.currentItemIndex++
|
||||||
|
this.setCurrentItem()
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
if (this.currentItemIndex > 0) {
|
||||||
|
this.currentItemIndex--
|
||||||
|
this.setCurrentItem()
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
} else if (event.key === 'Enter') {
|
||||||
|
this.resultItems.get(this.currentItemIndex).nativeElement.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private searchService: SearchService,
|
||||||
|
private router: Router,
|
||||||
|
private modalService: NgbModal,
|
||||||
|
private documentService: DocumentService,
|
||||||
|
private documentListViewService: DocumentListViewService
|
||||||
|
) {
|
||||||
|
this.queryDebounce = new Subject<string>()
|
||||||
|
|
||||||
|
this.queryDebounce
|
||||||
|
.pipe(
|
||||||
|
debounceTime(400),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
filter((query) => !query.length || query.length > 2)
|
||||||
|
)
|
||||||
|
.subscribe((text) => {
|
||||||
|
this.query = text
|
||||||
|
this.search(text)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private search(query: string) {
|
||||||
|
this.searchService.globalSearch(query).subscribe((results) => {
|
||||||
|
this.searchResults = results
|
||||||
|
this.resultsDropdown.open()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public primaryAction(type: string, object: ObjectWithId) {
|
||||||
|
this.reset(true)
|
||||||
|
let filterRuleType: number
|
||||||
|
let editDialogComponent: any
|
||||||
|
switch (type) {
|
||||||
|
case 'document':
|
||||||
|
this.router.navigate(['/documents', object.id])
|
||||||
|
return
|
||||||
|
case 'correspondent':
|
||||||
|
filterRuleType = FILTER_HAS_CORRESPONDENT_ANY
|
||||||
|
break
|
||||||
|
case 'documentType':
|
||||||
|
filterRuleType = FILTER_HAS_DOCUMENT_TYPE_ANY
|
||||||
|
break
|
||||||
|
case 'storagePath':
|
||||||
|
filterRuleType = FILTER_HAS_STORAGE_PATH_ANY
|
||||||
|
break
|
||||||
|
case 'tag':
|
||||||
|
filterRuleType = FILTER_HAS_ANY_TAG
|
||||||
|
break
|
||||||
|
case 'user':
|
||||||
|
editDialogComponent = UserEditDialogComponent
|
||||||
|
break
|
||||||
|
case 'group':
|
||||||
|
editDialogComponent = GroupEditDialogComponent
|
||||||
|
break
|
||||||
|
case 'mailAccount':
|
||||||
|
editDialogComponent = MailAccountEditDialogComponent
|
||||||
|
break
|
||||||
|
case 'mailRule':
|
||||||
|
editDialogComponent = MailRuleEditDialogComponent
|
||||||
|
break
|
||||||
|
case 'customField':
|
||||||
|
editDialogComponent = CustomFieldEditDialogComponent
|
||||||
|
break
|
||||||
|
case 'workflow':
|
||||||
|
editDialogComponent = WorkflowEditDialogComponent
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterRuleType) {
|
||||||
|
this.documentListViewService.quickFilter([
|
||||||
|
{ rule_type: filterRuleType, value: object.id.toString() },
|
||||||
|
])
|
||||||
|
} else if (editDialogComponent) {
|
||||||
|
const modalRef: NgbModalRef = this.modalService.open(
|
||||||
|
editDialogComponent,
|
||||||
|
{ size: 'lg' }
|
||||||
|
)
|
||||||
|
modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
|
||||||
|
modalRef.componentInstance.object = object
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public secondaryAction(type: string, object: ObjectWithId) {
|
||||||
|
this.reset(true)
|
||||||
|
let editDialogComponent: any
|
||||||
|
switch (type) {
|
||||||
|
case 'document':
|
||||||
|
this.router.navigate([this.documentService.getDownloadUrl(object.id)], {
|
||||||
|
skipLocationChange: true,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'correspondent':
|
||||||
|
editDialogComponent = CorrespondentEditDialogComponent
|
||||||
|
break
|
||||||
|
case 'documentType':
|
||||||
|
editDialogComponent = DocumentTypeEditDialogComponent
|
||||||
|
break
|
||||||
|
case 'storagePath':
|
||||||
|
editDialogComponent = StoragePathEditDialogComponent
|
||||||
|
break
|
||||||
|
case 'tag':
|
||||||
|
editDialogComponent = TagEditDialogComponent
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editDialogComponent) {
|
||||||
|
const modalRef: NgbModalRef = this.modalService.open(
|
||||||
|
editDialogComponent,
|
||||||
|
{ size: 'lg' }
|
||||||
|
)
|
||||||
|
modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
|
||||||
|
modalRef.componentInstance.object = object
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private reset(close: boolean = false) {
|
||||||
|
this.queryDebounce.next('')
|
||||||
|
this.searchResults = null
|
||||||
|
this.currentItemIndex = undefined
|
||||||
|
if (close) {
|
||||||
|
this.resultsDropdown.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCurrentItem() {
|
||||||
|
const item: ElementRef = this.resultItems.get(this.currentItemIndex)
|
||||||
|
item.nativeElement.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
public searchInputKeyDown(event: KeyboardEvent) {
|
||||||
|
if (
|
||||||
|
event.key === 'ArrowDown' &&
|
||||||
|
this.searchResults &&
|
||||||
|
this.resultsDropdown.isOpen()
|
||||||
|
) {
|
||||||
|
this.currentItemIndex = 0
|
||||||
|
this.setCurrentItem()
|
||||||
|
} else if (
|
||||||
|
event.key === 'Enter' &&
|
||||||
|
this.searchResults?.total === 1 &&
|
||||||
|
this.resultsDropdown.isOpen()
|
||||||
|
) {
|
||||||
|
this.resultItems.first.nativeElement.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDropdownOpenChange(open: boolean) {
|
||||||
|
if (!open) {
|
||||||
|
this.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,33 @@
|
|||||||
import { HttpClient, HttpParams } from '@angular/common/http'
|
import { HttpClient, HttpParams } from '@angular/common/http'
|
||||||
import { Injectable } from '@angular/core'
|
import { Injectable } from '@angular/core'
|
||||||
import { Observable } from 'rxjs'
|
import { Observable } from 'rxjs'
|
||||||
import { map } from 'rxjs/operators'
|
|
||||||
import { environment } from 'src/environments/environment'
|
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'
|
||||||
|
|
||||||
|
export interface GlobalSearchResult {
|
||||||
|
total: number
|
||||||
|
documents: Document[]
|
||||||
|
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({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
@ -17,4 +41,11 @@ export class SearchService {
|
|||||||
{ params: new HttpParams().set('term', term) }
|
{ params: new HttpParams().set('term', term) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
globalSearch(query: string): Observable<GlobalSearchResult> {
|
||||||
|
return this.http.get<GlobalSearchResult>(
|
||||||
|
`${environment.apiBaseUrl}search/`,
|
||||||
|
{ params: new HttpParams().set('query', query) }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
background-color: var(--pngx-body-color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-form-container {
|
.search-container {
|
||||||
input, input:focus {
|
input, input:focus {
|
||||||
color: var(--bs-body-color) !important;
|
color: var(--bs-body-color) !important;
|
||||||
}
|
}
|
||||||
|
@ -1120,19 +1120,19 @@ class GlobalSearchView(PassUserMixin):
|
|||||||
10,
|
10,
|
||||||
request.user,
|
request.user,
|
||||||
)._get_query()
|
)._get_query()
|
||||||
results = s.search(q, limit=10)
|
results = s.search(q, limit=3)
|
||||||
docs = Document.objects.filter(id__in=[r["id"] for r in results])
|
docs = Document.objects.filter(id__in=[r["id"] for r in results])
|
||||||
|
|
||||||
tags = Tag.objects.filter(name__contains=query)
|
tags = Tag.objects.filter(name__contains=query)[:3]
|
||||||
correspondents = Correspondent.objects.filter(name__contains=query)
|
correspondents = Correspondent.objects.filter(name__contains=query)[:3]
|
||||||
document_types = DocumentType.objects.filter(name__contains=query)
|
document_types = DocumentType.objects.filter(name__contains=query)[:3]
|
||||||
storage_paths = StoragePath.objects.filter(name__contains=query)
|
storage_paths = StoragePath.objects.filter(name__contains=query)[:3]
|
||||||
users = User.objects.filter(username__contains=query)
|
users = User.objects.filter(username__contains=query)[:3]
|
||||||
groups = Group.objects.filter(name__contains=query)
|
groups = Group.objects.filter(name__contains=query)[:3]
|
||||||
mail_rules = MailRule.objects.filter(name__contains=query)
|
mail_rules = MailRule.objects.filter(name__contains=query)[:3]
|
||||||
mail_accounts = MailAccount.objects.filter(name__contains=query)
|
mail_accounts = MailAccount.objects.filter(name__contains=query)[:3]
|
||||||
workflows = Workflow.objects.filter(name__contains=query)
|
workflows = Workflow.objects.filter(name__contains=query)[:3]
|
||||||
custom_fields = CustomField.objects.filter(name__contains=query)
|
custom_fields = CustomField.objects.filter(name__contains=query)[:3]
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"request": request,
|
"request": request,
|
||||||
@ -1176,6 +1176,17 @@ class GlobalSearchView(PassUserMixin):
|
|||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
|
"total": len(docs)
|
||||||
|
+ 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,
|
"documents": docs_serializer.data,
|
||||||
"tags": tags_serializer.data,
|
"tags": tags_serializer.data,
|
||||||
"correspondents": correspondents_serializer.data,
|
"correspondents": correspondents_serializer.data,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user