</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">
</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>
<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>
<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>
<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">
<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>
<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">
</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)">
</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)">
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,
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' })
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(() => {
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
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)
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'
{ 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
+ })
})
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',
})
}
- ngOnInit() {
+ public ngOnInit() {
this.hotkeyService
.addShortcut({ keys: '/', description: $localize`Global search` })
.subscribe(() => {
})
}
- 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
}
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,
private reset(close: boolean = false) {
this.queryDebounce.next(null)
+ this.query = null
this.searchResults = null
this.currentItemIndex = -1
if (close) {
item.nativeElement.focus()
}
- onItemHover(event: MouseEvent) {
+ public onItemHover(event: MouseEvent) {
const item: ElementRef = this.resultItems
.toArray()
.find((item) => item.nativeElement === event.currentTarget)
this.setCurrentItem()
}
- onButtonHover(event: MouseEvent) {
+ public onButtonHover(event: MouseEvent) {
;(event.currentTarget as HTMLElement).focus()
}
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)
}
}
- dropdownKeyDown(event: KeyboardEvent) {
+ public dropdownKeyDown(event: KeyboardEvent) {
if (
this.searchResults?.total &&
this.resultsDropdown.isOpen() &&
}
}
- 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 }))
}
}
)
}
- 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)
+ }
+ }
}
}
}
-.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;