diff --git a/src-ui/e2e/document-list/document-list.spec.ts b/src-ui/e2e/document-list/document-list.spec.ts index 6d21d2835..449683110 100644 --- a/src-ui/e2e/document-list/document-list.spec.ts +++ b/src-ui/e2e/document-list/document-list.spec.ts @@ -45,8 +45,8 @@ test('basic filtering', async ({ page }) => { test('text filtering', async ({ page }) => { await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' }) await page.goto('/documents') - await page.getByRole('main').getByRole('textbox').click() - await page.getByRole('main').getByRole('textbox').fill('test') + await page.getByRole('main').getByRole('combobox').click() + await page.getByRole('main').getByRole('combobox').fill('test') await expect(page.locator('pngx-document-list')).toHaveText(/32 documents/) await expect(page).toHaveURL(/title_content=test/) await page.getByRole('button', { name: 'Title & content' }).click() @@ -59,12 +59,12 @@ test('text filtering', async ({ page }) => { await expect(page.locator('pngx-document-list')).toHaveText(/26 documents/) await page.getByRole('button', { name: 'Advanced search' }).click() await page.getByRole('button', { name: 'ASN' }).click() - await page.getByRole('main').getByRole('textbox').fill('1123') + await page.getByRole('main').getByRole('combobox').nth(1).fill('1123') await expect(page).toHaveURL(/archive_serial_number=1123/) await expect(page.locator('pngx-document-list')).toHaveText(/one document/i) await page.locator('select').selectOption('greater') - await page.getByRole('main').getByRole('textbox').click() - await page.getByRole('main').getByRole('textbox').fill('1123') + await page.getByRole('main').getByRole('combobox').nth(1).click() + await page.getByRole('main').getByRole('combobox').nth(1).fill('1123') await expect(page).toHaveURL(/archive_serial_number__gt=1123/) await expect(page.locator('pngx-document-list')).toHaveText(/5 documents/) await page.locator('select').selectOption('less') diff --git a/src-ui/src/app/components/document-list/document-list.component.spec.ts b/src-ui/src/app/components/document-list/document-list.component.spec.ts index 2815b6ae9..34c4f2a40 100644 --- a/src-ui/src/app/components/document-list/document-list.component.spec.ts +++ b/src-ui/src/app/components/document-list/document-list.component.spec.ts @@ -19,6 +19,7 @@ import { NgbModalRef, NgbPopoverModule, NgbTooltipModule, + NgbTypeaheadModule, } from '@ng-bootstrap/ng-bootstrap' import { ClearableBadgeComponent } from '../common/clearable-badge/clearable-badge.component' import { FormsModule, ReactiveFormsModule } from '@angular/forms' @@ -152,6 +153,7 @@ describe('DocumentListComponent', () => { NgbTooltipModule, NgxBootstrapIconsModule.pick(allIcons), NgSelectModule, + NgbTypeaheadModule, ], }).compileComponents() diff --git a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.html b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.html index 89900e087..c6c287ef5 100644 --- a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.html +++ b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.html @@ -22,7 +22,13 @@ } - + diff --git a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts index d9e92092e..5c393168f 100644 --- a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts +++ b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts @@ -11,14 +11,14 @@ import { } from '@angular/core/testing' import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { By } from '@angular/platform-browser' -import { RouterTestingModule } from '@angular/router/testing' import { NgbDropdownModule, NgbDatepickerModule, NgbDropdownItem, + NgbTypeaheadModule, } from '@ng-bootstrap/ng-bootstrap' import { NgSelectComponent } from '@ng-select/ng-select' -import { of } from 'rxjs' +import { of, throwError } from 'rxjs' import { FILTER_TITLE, FILTER_TITLE_CONTENT, @@ -86,6 +86,8 @@ import { PermissionsService, } from 'src/app/services/permissions.service' import { environment } from 'src/environments/environment' +import { RouterModule } from '@angular/router' +import { SearchService } from 'src/app/services/rest/search.service' const tags: Tag[] = [ { @@ -145,6 +147,7 @@ describe('FilterEditorComponent', () => { let settingsService: SettingsService let permissionsService: PermissionsService let httpTestingController: HttpTestingController + let searchService: SearchService beforeEach(fakeAsync(() => { TestBed.configureTestingModule({ @@ -197,12 +200,13 @@ describe('FilterEditorComponent', () => { ], imports: [ HttpClientTestingModule, - RouterTestingModule, + RouterModule, NgbDropdownModule, FormsModule, ReactiveFormsModule, NgbDatepickerModule, NgxBootstrapIconsModule.pick(allIcons), + NgbTypeaheadModule, ], }).compileComponents() @@ -210,6 +214,7 @@ describe('FilterEditorComponent', () => { settingsService = TestBed.inject(SettingsService) settingsService.currentUser = users[0] permissionsService = TestBed.inject(PermissionsService) + searchService = TestBed.inject(SearchService) jest .spyOn(permissionsService, 'currentUserCan') .mockImplementation((action, type) => { @@ -1829,4 +1834,40 @@ describe('FilterEditorComponent', () => { name: $localize`More like`, }) }) + + it('should call autocomplete endpoint on input', fakeAsync(() => { + component.textFilterTarget = 'fulltext-query' // TEXT_FILTER_TARGET_FULLTEXT_QUERY + const autocompleteSpy = jest.spyOn(searchService, 'autocomplete') + component.searchAutoComplete(of('hello')).subscribe() + tick(250) + expect(autocompleteSpy).toHaveBeenCalled() + + component.searchAutoComplete(of('hello world 1')).subscribe() + tick(250) + expect(autocompleteSpy).toHaveBeenCalled() + })) + + it('should handle autocomplete backend failure gracefully', fakeAsync(() => { + component.textFilterTarget = 'fulltext-query' // TEXT_FILTER_TARGET_FULLTEXT_QUERY + const serviceAutocompleteSpy = jest.spyOn(searchService, 'autocomplete') + serviceAutocompleteSpy.mockReturnValue( + throwError(() => new Error('autcomplete failed')) + ) + // serviceAutocompleteSpy.mockReturnValue(of([' world'])) + let result + component.searchAutoComplete(of('hello')).subscribe((res) => { + result = res + }) + tick(250) + expect(serviceAutocompleteSpy).toHaveBeenCalled() + expect(result).toEqual([]) + })) + + it('should support choosing a autocomplete item', () => { + expect(component.textFilter).toBeNull() + component.itemSelected({ item: 'hello', preventDefault: () => true }) + expect(component.textFilter).toEqual('hello ') + component.itemSelected({ item: 'world', preventDefault: () => true }) + expect(component.textFilter).toEqual('hello world ') + }) }) diff --git a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts index 5a84b16e2..b7be4b57c 100644 --- a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts +++ b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts @@ -12,11 +12,14 @@ import { import { Tag } from 'src/app/data/tag' import { Correspondent } from 'src/app/data/correspondent' import { DocumentType } from 'src/app/data/document-type' -import { Observable, Subject, Subscription } from 'rxjs' +import { Observable, Subject, Subscription, from } from 'rxjs' import { + catchError, debounceTime, distinctUntilChanged, filter, + map, + switchMap, takeUntil, } from 'rxjs/operators' import { DocumentTypeService } from 'src/app/services/rest/document-type.service' @@ -82,6 +85,7 @@ import { PermissionsService, } from 'src/app/services/permissions.service' import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' +import { SearchService } from 'src/app/services/rest/search.service' const TEXT_FILTER_TARGET_TITLE = 'title' const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content' @@ -240,7 +244,8 @@ export class FilterEditorComponent private correspondentService: CorrespondentService, private documentService: DocumentService, private storagePathService: StoragePathService, - public permissionsService: PermissionsService + public permissionsService: PermissionsService, + private searchService: SearchService ) { super() } @@ -922,7 +927,12 @@ export class FilterEditorComponent distinctUntilChanged(), filter((query) => !query.length || query.length > 2) ) - .subscribe((text) => this.updateTextFilter(text)) + .subscribe((text) => + this.updateTextFilter( + text, + this.textFilterTarget !== TEXT_FILTER_TARGET_FULLTEXT_QUERY + ) + ) if (this._textFilter) this.documentService.searchQuery = this._textFilter } @@ -973,10 +983,12 @@ export class FilterEditorComponent this.storagePathSelectionModel.apply() } - updateTextFilter(text) { + updateTextFilter(text, updateRules = true) { this._textFilter = text - this.documentService.searchQuery = text - this.updateRules() + if (updateRules) { + this.documentService.searchQuery = text + this.updateRules() + } } textFilterKeyup(event: KeyboardEvent) { @@ -1025,4 +1037,40 @@ export class FilterEditorComponent this.updateRules() } } + + searchAutoComplete = (text$: Observable) => + text$.pipe( + debounceTime(200), + distinctUntilChanged(), + filter(() => this.textFilterTarget === TEXT_FILTER_TARGET_FULLTEXT_QUERY), + map((term) => { + if (term.lastIndexOf(' ') != -1) { + return term.substring(term.lastIndexOf(' ') + 1) + } else { + return term + } + }), + switchMap((term) => + term.length < 2 + ? from([[]]) + : this.searchService.autocomplete(term).pipe( + catchError(() => { + return from([[]]) + }) + ) + ) + ) + + itemSelected(event) { + event.preventDefault() + let currentSearch: string = this._textFilter ?? '' + let lastSpaceIndex = currentSearch.lastIndexOf(' ') + if (lastSpaceIndex != -1) { + currentSearch = currentSearch.substring(0, lastSpaceIndex + 1) + currentSearch += event.item + ' ' + } else { + currentSearch = event.item + ' ' + } + this.updateTextFilter(currentSearch) + } }