Enhancement: global search tweaks (#6674)

This commit is contained in:
shamoon 2024-05-13 09:12:02 -07:00 committed by GitHub
parent 7983487430
commit a1e4365ff2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 130 additions and 82 deletions

View File

@ -1013,7 +1013,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
<context context-type="linenumber">92</context>
<context context-type="linenumber">93</context>
</context-group>
</trans-unit>
<trans-unit id="1926290004382723170" datatype="html">
@ -2113,11 +2113,11 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
<context context-type="linenumber">51</context>
<context context-type="linenumber">55</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
<context context-type="linenumber">68</context>
<context context-type="linenumber">72</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
@ -2676,7 +2676,7 @@
<source>Advanced search</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
<context context-type="linenumber">19</context>
<context context-type="linenumber">23</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
@ -2687,25 +2687,25 @@
<source>Open</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
<context context-type="linenumber">45</context>
<context context-type="linenumber">49</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
<context context-type="linenumber">48</context>
<context context-type="linenumber">52</context>
</context-group>
</trans-unit>
<trans-unit id="6329940072345709724" datatype="html">
<source>Filter documents</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
<context context-type="linenumber">54</context>
<context context-type="linenumber">58</context>
</context-group>
</trans-unit>
<trans-unit id="3099741642167775297" datatype="html">
<source>Download</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
<context context-type="linenumber">65</context>
<context context-type="linenumber">69</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
@ -2732,113 +2732,113 @@
<source>No results</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
<context context-type="linenumber">79</context>
<context context-type="linenumber">83</context>
</context-group>
</trans-unit>
<trans-unit id="searchResults.documents" datatype="html">
<source>Documents</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
<context context-type="linenumber">82</context>
<context context-type="linenumber">86</context>
</context-group>
</trans-unit>
<trans-unit id="searchResults.saved_views" datatype="html">
<source>Saved Views</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
<context context-type="linenumber">88</context>
<context context-type="linenumber">92</context>
</context-group>
</trans-unit>
<trans-unit id="searchResults.tags" datatype="html">
<source>Tags</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
<context context-type="linenumber">95</context>
<context context-type="linenumber">99</context>
</context-group>
</trans-unit>
<trans-unit id="searchResults.correspondents" datatype="html">
<source>Correspondents</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
<context context-type="linenumber">102</context>
<context context-type="linenumber">106</context>
</context-group>
</trans-unit>
<trans-unit id="searchResults.documentTypes" datatype="html">
<source>Document types</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
<context context-type="linenumber">109</context>
<context context-type="linenumber">113</context>
</context-group>
</trans-unit>
<trans-unit id="searchResults.storagePaths" datatype="html">
<source>Storage paths</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
<context context-type="linenumber">116</context>
<context context-type="linenumber">120</context>
</context-group>
</trans-unit>
<trans-unit id="searchResults.users" datatype="html">
<source>Users</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
<context context-type="linenumber">123</context>
<context context-type="linenumber">127</context>
</context-group>
</trans-unit>
<trans-unit id="searchResults.groups" datatype="html">
<source>Groups</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
<context context-type="linenumber">130</context>
<context context-type="linenumber">134</context>
</context-group>
</trans-unit>
<trans-unit id="searchResults.customFields" datatype="html">
<source>Custom fields</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
<context context-type="linenumber">137</context>
<context context-type="linenumber">141</context>
</context-group>
</trans-unit>
<trans-unit id="searchResults.mailAccounts" datatype="html">
<source>Mail accounts</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
<context context-type="linenumber">144</context>
<context context-type="linenumber">148</context>
</context-group>
</trans-unit>
<trans-unit id="searchResults.mailRules" datatype="html">
<source>Mail rules</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
<context context-type="linenumber">151</context>
<context context-type="linenumber">155</context>
</context-group>
</trans-unit>
<trans-unit id="searchResults.workflows" datatype="html">
<source>Workflows</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
<context context-type="linenumber">158</context>
<context context-type="linenumber">162</context>
</context-group>
</trans-unit>
<trans-unit id="83507137894716798" datatype="html">
<source>Successfully updated object.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
<context context-type="linenumber">168</context>
<context context-type="linenumber">175</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
<context context-type="linenumber">206</context>
<context context-type="linenumber">213</context>
</context-group>
</trans-unit>
<trans-unit id="1801333259018423190" datatype="html">
<source>Error occurred saving object.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
<context context-type="linenumber">171</context>
<context context-type="linenumber">178</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.ts</context>
<context context-type="linenumber">209</context>
<context context-type="linenumber">216</context>
</context-group>
</trans-unit>
<trans-unit id="8700121026680200191" datatype="html">

View File

@ -6,15 +6,19 @@
<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)">
autocomplete="off"
spellcheck="false"
[(ngModel)]="query"
(ngModelChange)="this.queryDebounce.next($event)"
(keydown)="searchInputKeyDown($event)"
ngbDropdownAnchor>
<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)) {
@if (query) {
<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>
@ -25,7 +29,7 @@
<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)"
(click)="primaryAction(type, item, $event)"
(mouseenter)="onItemHover($event)">
<i-bs width="1.2em" height="1.2em" name="{{icon}}" class="me-2 text-muted"></i-bs>
<div class="text-truncate">
@ -36,7 +40,7 @@
</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.stopImmediatePropagation()"
(click)="primaryAction(type, item, $event); $event.stopImmediatePropagation()"
(keydown)="onButtonKeyDown($event)"
[disabled]="disablePrimaryButton(type, item)"
(mouseenter)="onButtonHover($event)">
@ -56,7 +60,7 @@
</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.stopImmediatePropagation()"
(click)="secondaryAction(type, item, $event); $event.stopImmediatePropagation()"
(keydown)="onButtonKeyDown($event)"
[disabled]="disableSecondaryButton(type, item)"
(mouseenter)="onButtonHover($event)">

View File

@ -36,6 +36,7 @@ import { WorkflowEditDialogComponent } from '../../common/edit-dialog/workflow-e
import { ElementRef } from '@angular/core'
import { ToastService } from 'src/app/services/toast.service'
import { DataType } from 'src/app/data/datatype'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
const searchResults = {
total: 11,
@ -248,10 +249,7 @@ describe('GlobalSearchComponent', () => {
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()
component.resultsDropdown.open()
component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowDown' })
@ -260,6 +258,13 @@ describe('GlobalSearchComponent', () => {
const closeSpy = jest.spyOn(component.resultsDropdown, 'close')
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'Escape' }))
expect(closeSpy).toHaveBeenCalled()
component.searchResults = searchResults as any
component.resultsDropdown.open()
component.query = 'test'
const advancedSearchSpy = jest.spyOn(component, 'runAdvanedSearch')
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(advancedSearchSpy).toHaveBeenCalled()
})
it('should search on query debounce', fakeAsync(() => {
@ -276,7 +281,6 @@ describe('GlobalSearchComponent', () => {
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
@ -289,23 +293,41 @@ describe('GlobalSearchComponent', () => {
expect(routerSpy).toHaveBeenCalledWith(['/view', object.id])
component.primaryAction(DataType.Correspondent, object)
expect(qfSpy).toHaveBeenCalledWith([
{ rule_type: FILTER_HAS_CORRESPONDENT_ANY, value: object.id.toString() },
expect(routerSpy).toHaveBeenCalledWith([
'/documents',
queryParamsFromFilterRules([
{
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() },
expect(routerSpy).toHaveBeenCalledWith([
'/documents',
queryParamsFromFilterRules([
{
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() },
expect(routerSpy).toHaveBeenCalledWith([
'/documents',
queryParamsFromFilterRules([
{ 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() },
expect(routerSpy).toHaveBeenCalledWith([
'/documents',
queryParamsFromFilterRules([
{ rule_type: FILTER_HAS_TAGS_ANY, value: object.id.toString() },
]),
])
component.primaryAction(DataType.User, object)
@ -450,13 +472,6 @@ describe('GlobalSearchComponent', () => {
expect(focusSpy).toHaveBeenCalled()
})
it('should prevent event propagation for keyboard events on buttons that are not arrows', () => {
const event = { stopImmediatePropagation: jest.fn(), key: 'Enter' }
const stopPropagationSpy = jest.spyOn(event, 'stopImmediatePropagation')
component.onButtonKeyDown(event as any)
expect(stopPropagationSpy).toHaveBeenCalled()
})
it('should support explicit advanced search', () => {
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.query = 'test'
@ -465,4 +480,25 @@ describe('GlobalSearchComponent', () => {
{ rule_type: FILTER_FULLTEXT_QUERY, value: 'test' },
])
})
it('should support open in new window', () => {
const openSpy = jest.spyOn(window, 'open')
const event = new Event('click')
event['ctrlKey'] = true
component.primaryAction(DataType.Document, { id: 2 }, event as any)
expect(openSpy).toHaveBeenCalledWith('/documents/2', '_blank')
component.searchResults = searchResults as any
component.resultsDropdown.open()
fixture.detectChanges()
const button = component.primaryButtons.get(0).nativeElement
const keyboardEvent = new KeyboardEvent('keydown', {
key: 'Enter',
ctrlKey: true,
})
const dispatchSpy = jest.spyOn(button, 'dispatchEvent')
button.dispatchEvent(keyboardEvent)
expect(dispatchSpy).toHaveBeenCalledTimes(2) // once for keydown, second for click
})
})

View File

@ -41,6 +41,7 @@ import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog
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'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
@Component({
selector: 'pngx-global-search',
@ -87,7 +88,7 @@ export class GlobalSearchComponent implements OnInit {
})
}
ngOnInit() {
public ngOnInit() {
this.hotkeyService
.addShortcut({ keys: '/', description: $localize`Global search` })
.subscribe(() => {
@ -104,17 +105,22 @@ export class GlobalSearchComponent implements OnInit {
})
}
public primaryAction(type: string, object: ObjectWithId) {
public primaryAction(
type: string,
object: ObjectWithId,
event: PointerEvent = null
) {
const newWindow = event?.metaKey || event?.ctrlKey
this.reset(true)
let filterRuleType: number
let editDialogComponent: any
let size: string = 'md'
switch (type) {
case DataType.Document:
this.router.navigate(['/documents', object.id])
this.navigateOrOpenInNewWindow(['/documents', object.id], newWindow)
return
case DataType.SavedView:
this.router.navigate(['/view', object.id])
this.navigateOrOpenInNewWindow(['/view', object.id], newWindow)
return
case DataType.Correspondent:
filterRuleType = FILTER_HAS_CORRESPONDENT_ANY
@ -154,9 +160,10 @@ export class GlobalSearchComponent implements OnInit {
}
if (filterRuleType) {
this.documentListViewService.quickFilter([
let params = queryParamsFromFilterRules([
{ rule_type: filterRuleType, value: object.id.toString() },
])
this.navigateOrOpenInNewWindow(['/documents', params], newWindow)
} else if (editDialogComponent) {
const modalRef: NgbModalRef = this.modalService.open(
editDialogComponent,
@ -213,6 +220,7 @@ export class GlobalSearchComponent implements OnInit {
private reset(close: boolean = false) {
this.queryDebounce.next(null)
this.query = null
this.searchResults = null
this.currentItemIndex = -1
if (close) {
@ -233,7 +241,7 @@ export class GlobalSearchComponent implements OnInit {
item.nativeElement.focus()
}
onItemHover(event: MouseEvent) {
public onItemHover(event: MouseEvent) {
const item: ElementRef = this.resultItems
.toArray()
.find((item) => item.nativeElement === event.currentTarget)
@ -241,7 +249,7 @@ export class GlobalSearchComponent implements OnInit {
this.setCurrentItem()
}
onButtonHover(event: MouseEvent) {
public onButtonHover(event: MouseEvent) {
;(event.currentTarget as HTMLElement).focus()
}
@ -262,19 +270,14 @@ export class GlobalSearchComponent implements OnInit {
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 === 'Enter') {
if (this.searchResults?.total === 1 && this.resultsDropdown.isOpen()) {
this.primaryButtons.first.nativeElement.click()
this.searchInput.nativeElement.blur()
} else if (this.query?.length) {
this.runAdvanedSearch()
this.reset(true)
}
} else if (event.key === 'Escape' && !this.resultsDropdown.isOpen()) {
if (this.query?.length) {
this.reset(true)
@ -284,7 +287,7 @@ export class GlobalSearchComponent implements OnInit {
}
}
dropdownKeyDown(event: KeyboardEvent) {
public dropdownKeyDown(event: KeyboardEvent) {
if (
this.searchResults?.total &&
this.resultsDropdown.isOpen() &&
@ -327,14 +330,9 @@ export class GlobalSearchComponent implements OnInit {
}
}
onButtonKeyDown(event: KeyboardEvent) {
// prevents ngBootstrap issue with keydown events
if (
!['ArrowDown', 'ArrowUp', 'ArrowRight', 'ArrowLeft', 'Escape'].includes(
event.key
)
) {
event.stopImmediatePropagation()
public onButtonKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.target.dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
}
}
@ -373,10 +371,19 @@ export class GlobalSearchComponent implements OnInit {
)
}
runAdvanedSearch() {
public runAdvanedSearch() {
this.documentListViewService.quickFilter([
{ rule_type: FILTER_FULLTEXT_QUERY, value: this.query },
])
this.reset(true)
}
private navigateOrOpenInNewWindow(commands: any, newWindow: boolean = false) {
if (newWindow) {
const url = this.router.serializeUrl(this.router.createUrlTree(commands))
window.open(url, '_blank')
} else {
this.router.navigate(commands)
}
}
}

View File

@ -332,7 +332,8 @@ textarea,
}
}
.input-group .form-control-sm {
.input-group .form-control-sm,
.input-group .btn-sm {
// accommodate larger font size on mobile
padding-top: .15rem;
padding-bottom: .15rem;