From 87a89f11fefd1cade50834bcd0fbe783d6fdc89b Mon Sep 17 00:00:00 2001 From: Evgeny Stepanovych Date: Fri, 22 Dec 2023 14:21:42 +0100 Subject: [PATCH] NAS-125370 / 24.04 / Integration tests for autocomplete (#9354) --- .../advanced-search.component.html | 2 +- .../advanced-search.component.spec.ts | 140 ---------- .../advanced-search.component.ts | 56 ++-- .../advanced-search.harness.ts | 27 +- .../codemirror-autocomplete.harness.ts | 30 +++ .../tests/autocomplete.spec.ts | 243 ++++++++++++++++++ .../advanced-search/tests/editing.spec.ts | 112 ++++++++ .../options-suggestions.component.html | 0 .../options-suggestions.component.scss | 0 .../options-suggestions.component.ts | 18 -- .../search-input/search-input.module.ts | 4 - .../advanced-search-autocomplete.service.ts | 4 +- .../query-parser/query-parser.service.ts | 34 +-- .../types/search-property.interface.ts | 5 - src/setup-jest.ts | 15 ++ 15 files changed, 464 insertions(+), 226 deletions(-) delete mode 100644 src/app/modules/search-input/components/advanced-search/advanced-search.component.spec.ts create mode 100644 src/app/modules/search-input/components/advanced-search/codemirror-autocomplete.harness.ts create mode 100644 src/app/modules/search-input/components/advanced-search/tests/autocomplete.spec.ts create mode 100644 src/app/modules/search-input/components/advanced-search/tests/editing.spec.ts delete mode 100644 src/app/modules/search-input/components/options-suggestions/options-suggestions.component.html delete mode 100644 src/app/modules/search-input/components/options-suggestions/options-suggestions.component.scss delete mode 100644 src/app/modules/search-input/components/options-suggestions/options-suggestions.component.ts diff --git a/src/app/modules/search-input/components/advanced-search/advanced-search.component.html b/src/app/modules/search-input/components/advanced-search/advanced-search.component.html index 7669bde58e4..9211fe201f9 100644 --- a/src/app/modules/search-input/components/advanced-search/advanced-search.component.html +++ b/src/app/modules/search-input/components/advanced-search/advanced-search.component.html @@ -4,7 +4,7 @@ -
+
diff --git a/src/app/modules/search-input/components/advanced-search/advanced-search.component.spec.ts b/src/app/modules/search-input/components/advanced-search/advanced-search.component.spec.ts deleted file mode 100644 index c86be508529..00000000000 --- a/src/app/modules/search-input/components/advanced-search/advanced-search.component.spec.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; -import { auditEventLabels, AuditService } from 'app/enums/audit.enum'; -import { QueryFilters } from 'app/interfaces/query-api.interface'; -import { User } from 'app/interfaces/user.interface'; -import { AdvancedSearchComponent } from 'app/modules/search-input/components/advanced-search/advanced-search.component'; -import { AdvancedSearchHarness } from 'app/modules/search-input/components/advanced-search/advanced-search.harness'; -import { AdvancedSearchAutocompleteService } from 'app/modules/search-input/services/advanced-search-autocomplete.service'; -import { QueryParserService } from 'app/modules/search-input/services/query-parser/query-parser.service'; -import { QueryToApiService } from 'app/modules/search-input/services/query-to-api/query-to-api.service'; -import { PropertyType, SearchProperty } from 'app/modules/search-input/types/search-property.interface'; - -describe('AdvancedSearchComponent', () => { - let spectator: Spectator>; - let searchHarness: AdvancedSearchHarness; - const createComponent = createComponentFactory({ - component: AdvancedSearchComponent, - providers: [ - QueryToApiService, - QueryParserService, - AdvancedSearchAutocompleteService, - ], - }); - - describe('no default input provided', () => { - beforeEach(async () => { - spectator = createComponent(); - jest.spyOn(spectator.component.switchToBasic, 'emit'); - searchHarness = await TestbedHarnessEnvironment.harnessForFixture(spectator.fixture, AdvancedSearchHarness); - searchHarness.setEditor(spectator.component.editorView); - }); - - it('resets text area when reset icon is pressed', async () => { - await searchHarness.setValue('test'); - await (await searchHarness.getResetIcon()).click(); - const placeholderText = await searchHarness.getPlaceholder(); - - expect(await searchHarness.getValue()).toBe(placeholderText); - }); - - it('emits (switchToBasic) when Switch To Basic is pressed', async () => { - expect(await (await searchHarness.getSwitchLink()).text()).toBe('Switch To Basic'); - await searchHarness.clickSwitchToBasic(); - - expect(spectator.component.switchToBasic.emit).toHaveBeenCalled(); - }); - }); - - describe('has default input provided', () => { - beforeEach(async () => { - spectator = createComponent({ - props: { - query: [ - [ - 'OR', - [ - [ - [ - 'event', - 'in', - [ - 'AUTHENTICATION', - 'CLOSE', - ], - ], - [ - 'username', - '=', - 'admin', - ], - [ - 'service', - '=', - 'MIDDLEWARE', - ], - ], - [ - [ - 'event', - '=', - 'AUTHENTICATION', - ], - [ - 'service', - '=', - 'SMB', - ], - ], - ], - ], - ] as QueryFilters, - properties: [ - { - label: 'Username', - property: 'username', - propertyType: PropertyType.Text, - }, - { - label: 'Сервіс', - property: 'service', - propertyType: PropertyType.Text, - enumMap: new Map([ - [AuditService.Middleware, 'Проміжне програмне забезпечення'], - [AuditService.Smb, 'Ес-ем-бе'], - ]), - }, - { - label: 'Event', - property: 'event', - enumMap: auditEventLabels, - propertyType: PropertyType.Text, - }, - ] as SearchProperty[], - }, - }); - searchHarness = await TestbedHarnessEnvironment.harnessForFixture(spectator.fixture, AdvancedSearchHarness); - }); - - it('correctly sets predefined input value', async () => { - expect(await searchHarness.getValue()).toBe( - '(("Event" IN ("Authentication", "Close") AND "Username" = "admin" AND "Сервіс" = "Проміжне програмне забезпечення") OR ("Event" = "Authentication" AND "Сервіс" = "Ес-ем-бе"))', - ); - }); - }); - - describe('handles errors', () => { - beforeEach(async () => { - spectator = createComponent(); - searchHarness = await TestbedHarnessEnvironment.harnessForFixture(spectator.fixture, AdvancedSearchHarness); - searchHarness.setEditor(spectator.component.editorView); - }); - - it('shows error icon and stores error', async () => { - await searchHarness.setValue('Username = "root'); - - expect(spectator.query('ix-icon')).toHaveAttribute('data-mat-icon-name', 'warning'); - expect(spectator.component.errorMessages[0].message).toBe('Syntax error at 11-16'); - }); - }); -}); diff --git a/src/app/modules/search-input/components/advanced-search/advanced-search.component.ts b/src/app/modules/search-input/components/advanced-search/advanced-search.component.ts index a402419e75a..cd524a01546 100644 --- a/src/app/modules/search-input/components/advanced-search/advanced-search.component.ts +++ b/src/app/modules/search-input/components/advanced-search/advanced-search.component.ts @@ -9,7 +9,9 @@ import { Output, ViewChild, } from '@angular/core'; -import { autocompletion, closeBrackets, startCompletion } from '@codemirror/autocomplete'; +import { + autocompletion, closeBrackets, CompletionContext, startCompletion, +} from '@codemirror/autocomplete'; import { linter } from '@codemirror/lint'; import { EditorState, StateEffect, StateField } from '@codemirror/state'; import { @@ -42,19 +44,19 @@ export class AdvancedSearchComponent implements OnInit { @ViewChild('inputArea', { static: true }) inputArea: ElementRef; - hasQueryErrors = false; - queryInputValue: string; + protected hasQueryErrors = false; + protected queryInputValue: string; errorMessages: QueryParsingError[] | null = null; - editorView: EditorView; + protected editorView: EditorView; - showDatePicker$ = this.advancedSearchAutocomplete.showDatePicker$; + protected showDatePicker$ = this.advancedSearchAutocomplete.showDatePicker$; get editorHasValue(): boolean { - return (this.editorView.state.doc as unknown as { text: string[] })?.text?.[0] !== ''; + return this.editorView.state.doc?.length > 0; } constructor( - private queryParser: QueryParserService, + private queryParser: QueryParserService, private queryToApi: QueryToApiService, private advancedSearchAutocomplete: AdvancedSearchAutocompleteService, private cdr: ChangeDetectorRef, @@ -67,11 +69,8 @@ export class AdvancedSearchComponent implements OnInit { this.advancedSearchAutocomplete.setEditorView(this.editorView); if (this.query) { - this.setEditorContents( - this.queryParser.formatFiltersToQuery( - this.query as QueryFilters, - this.properties as SearchProperty[], - ), + this.replaceEditorContents( + this.queryParser.formatFiltersToQuery(this.query, this.properties), ); } } @@ -106,7 +105,7 @@ export class AdvancedSearchComponent implements OnInit { const advancedSearchLinter = linter((view) => view.state.field(diagnosticField)); const autocompleteExtension = autocompletion({ - override: [this.advancedSearchAutocomplete.setCompletionSource.bind(this.advancedSearchAutocomplete)], + override: [(context: CompletionContext) => this.advancedSearchAutocomplete.getCompletions(context)], icons: false, }); @@ -125,6 +124,7 @@ export class AdvancedSearchComponent implements OnInit { EditorView.lineWrapping, updateListener, closeBrackets(), + // TODO: Extract placeholder into a property with a default value (or auto-build one based on the properties). placeholder(this.translate.instant('Service = "SMB" AND Event = "CLOSE"')), advancedSearchLinter, diagnosticField, @@ -142,13 +142,13 @@ export class AdvancedSearchComponent implements OnInit { } dateSelected(value: string): void { - this.setEditorContents(`"${format(new Date(value), 'yyyy-MM-dd')}" `, this.editorView.state.doc.length); + this.appendEditorContents(`"${format(new Date(value), 'yyyy-MM-dd')}" `); this.focusInput(); this.hideDatePicker(); } protected onResetInput(): void { - this.setEditorContents('', 0, this.editorView.state.doc.length); + this.replaceEditorContents(''); this.focusInput(); this.hideDatePicker(); this.paramsChange.emit([]); @@ -165,6 +165,7 @@ export class AdvancedSearchComponent implements OnInit { this.hasQueryErrors = Boolean(this.queryInputValue.length && parsedQuery.hasErrors); this.cdr.markForCheck(); + this.cdr.detectChanges(); if (parsedQuery.hasErrors && this.queryInputValue?.length) { this.errorMessages = parsedQuery.errors; @@ -173,22 +174,29 @@ export class AdvancedSearchComponent implements OnInit { parsedQuery.errors.filter((error) => error.from !== error.to), ), }); - } else { - this.editorView.dispatch({ - effects: setDiagnostics.of([]), - }); - this.errorMessages = null; + return; } - const filters = this.queryToApi.buildFilters(parsedQuery, this.properties); + this.editorView.dispatch({ + effects: setDiagnostics.of([]), + }); + this.errorMessages = null; + const filters = this.queryToApi.buildFilters(parsedQuery, this.properties); this.paramsChange.emit(filters); } - private setEditorContents(contents: string, from = 0, to?: number): void { + private replaceEditorContents(contents: string): void { + this.editorView.dispatch({ + changes: { from: 0, to: this.editorView.state.doc.length, insert: contents }, + selection: { anchor: contents.length }, + }); + } + + private appendEditorContents(contents: string): void { this.editorView.dispatch({ - changes: { from, to, insert: contents }, - selection: { anchor: from + contents.length }, + changes: { from: this.editorView.state.doc.length, insert: contents }, + selection: { anchor: this.editorView.state.doc.length + contents.length }, }); } } diff --git a/src/app/modules/search-input/components/advanced-search/advanced-search.harness.ts b/src/app/modules/search-input/components/advanced-search/advanced-search.harness.ts index 40f50830436..2f2d8312ec0 100644 --- a/src/app/modules/search-input/components/advanced-search/advanced-search.harness.ts +++ b/src/app/modules/search-input/components/advanced-search/advanced-search.harness.ts @@ -1,5 +1,7 @@ import { ComponentHarness } from '@angular/cdk/testing'; -import { EditorView } from '@codemirror/view'; +import { + CodemirrorAutocompleteHarness, +} from 'app/modules/search-input/components/advanced-search/codemirror-autocomplete.harness'; export class AdvancedSearchHarness extends ComponentHarness { static hostSelector = 'ix-advanced-search'; @@ -8,12 +10,7 @@ export class AdvancedSearchHarness extends ComponentHarness { getInputArea = this.locatorFor('.cm-content'); getInputPlaceholder = this.locatorFor('.cm-placeholder'); getSwitchLink = this.locatorFor('.switch-link'); - - editor: EditorView; - - setEditor(editor: EditorView): void { - this.editor = editor; - } + getAutocomplete = this.documentRootLocatorFactory().locatorFor(CodemirrorAutocompleteHarness); async getValue(): Promise { return (await (this.getInputArea())).text(); @@ -25,16 +22,16 @@ export class AdvancedSearchHarness extends ComponentHarness { async setValue(value: string): Promise { const inputArea = await this.getInputArea(); + await inputArea.setContenteditableValue(value); + + await inputArea.dispatchEvent('input'); - if (this.editor) { - this.editor.dispatch({ - changes: { from: 0, to: 0, insert: value }, - }); - } else { - await inputArea.setContenteditableValue(value); - } + // Using fakeAsync doesn't work for some reason. + await new Promise((resolve) => { + setTimeout(resolve); + }); - return inputArea.dispatchEvent('input'); + await this.forceStabilize(); } async clickSwitchToBasic(): Promise { diff --git a/src/app/modules/search-input/components/advanced-search/codemirror-autocomplete.harness.ts b/src/app/modules/search-input/components/advanced-search/codemirror-autocomplete.harness.ts new file mode 100644 index 00000000000..91c9e2e3e11 --- /dev/null +++ b/src/app/modules/search-input/components/advanced-search/codemirror-autocomplete.harness.ts @@ -0,0 +1,30 @@ +import { ComponentHarness, parallel } from '@angular/cdk/testing'; + +export class CodemirrorAutocompleteHarness extends ComponentHarness { + static hostSelector = '.cm-tooltip-autocomplete'; + + private getOptionElements = this.locatorForAll('li'); + + async getOptions(): Promise { + const items = await this.getOptionElements(); + return parallel(() => items.map((item) => item.text())); + } + + async select(text: string): Promise { + const items = await this.getOptionElements(); + let selectedItem = null; + + for (const item of items) { + if ((await item.text()) === text) { + selectedItem = item; + break; + } + } + + if (!selectedItem) { + throw new Error(`Cannot find item with text "${text}"`); + } + + return selectedItem.click(); + } +} diff --git a/src/app/modules/search-input/components/advanced-search/tests/autocomplete.spec.ts b/src/app/modules/search-input/components/advanced-search/tests/autocomplete.spec.ts new file mode 100644 index 00000000000..e47127e6121 --- /dev/null +++ b/src/app/modules/search-input/components/advanced-search/tests/autocomplete.spec.ts @@ -0,0 +1,243 @@ +import { HarnessLoader, TestKey } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { MatCalendarHarness } from '@angular/material/datepicker/testing'; +import { CompletionContext } from '@codemirror/autocomplete'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; +import { auditEventLabels, AuditService } from 'app/enums/audit.enum'; +import { AuditEntry } from 'app/interfaces/audit/audit.interface'; +import { AdvancedSearchComponent } from 'app/modules/search-input/components/advanced-search/advanced-search.component'; +import { AdvancedSearchHarness } from 'app/modules/search-input/components/advanced-search/advanced-search.harness'; +import { + AdvancedSearchAutocompleteService, +} from 'app/modules/search-input/services/advanced-search-autocomplete.service'; +import { QueryParserService } from 'app/modules/search-input/services/query-parser/query-parser.service'; +import { QueryToApiService } from 'app/modules/search-input/services/query-to-api/query-to-api.service'; +import { dateProperty, searchProperties, textProperty } from 'app/modules/search-input/utils/search-properties.utils'; + +describe('AdvancedSearchComponent – autocomplete', () => { + let spectator: Spectator>; + let searchHarness: AdvancedSearchHarness; + let loader: HarnessLoader; + const createComponent = createComponentFactory({ + component: AdvancedSearchComponent, + providers: [ + QueryToApiService, + QueryParserService, + AdvancedSearchAutocompleteService, + ], + }); + + beforeEach(async () => { + spectator = createComponent({ + props: { + properties: searchProperties([ + textProperty( + 'username', + 'Username', + of([ + { label: 'Bob', value: '"bob"' }, + { label: 'John', value: '"john"' }, + ]), + ), + textProperty( + 'service', + 'Сервіс', + of([]), + new Map([ + [AuditService.Middleware, 'Проміжне програмне забезпечення'], + [AuditService.Smb, 'Ес-ем-бе'], + ]), + ), + textProperty( + 'event', + 'Event', + of([]), + auditEventLabels, + ), + dateProperty( + 'message_timestamp', + 'Timestamp', + ), + ]), + }, + }); + searchHarness = await TestbedHarnessEnvironment.harnessForFixture(spectator.fixture, AdvancedSearchHarness); + loader = TestbedHarnessEnvironment.loader(spectator.fixture); + + // Positioning does not work correctly because of JSDOM limitations, so we fudge it here. + const autocompleteService = spectator.inject(AdvancedSearchAutocompleteService); + const original = autocompleteService.getCompletions; + jest.spyOn(autocompleteService, 'getCompletions').mockImplementation((context: CompletionContext) => { + return original.call(autocompleteService, { + ...context, + pos: context.state.doc.toString().length, + }); + }); + }); + + describe('seeing suggestions', () => { + it('shows autocomplete for column names when user clicks in the empty editor', async () => { + await (await searchHarness.getInputArea()).click(); + const autocomplete = await searchHarness.getAutocomplete(); + expect(await autocomplete.getOptions()).toEqual([ + 'Event', + 'Timestamp', + 'Username', + 'Сервіс', + ]); + }); + + it('shows filtered column options as user types', async () => { + await searchHarness.setValue('User'); + const autocomplete = await searchHarness.getAutocomplete(); + + expect(await autocomplete.getOptions()).toEqual([ + 'Username', + ]); + }); + + it('shows autocomplete for comparators after user types column name and space', async () => { + await searchHarness.setValue('Username '); + + const autocomplete = await searchHarness.getAutocomplete(); + expect(await autocomplete.getOptions()).toEqual([ + '!^ (Not Starts With)', + '!= (Not Equals)', + '!$ (Not Ends With)', + '^ (Starts With)', + '< (Less Than)', + '<= (Less Than or Equal To)', + '= (Equals)', + '> (Greater Than)', + '>= (Greater Than or Equal To)', + '~ (Contains)', + '$ (Ends With)', + 'IN (In)', + 'NIN (Not In)', + ]); + }); + + it('shows autocomplete for values matching column name', async () => { + await searchHarness.setValue('Username = '); + + const autocomplete = await searchHarness.getAutocomplete(); + expect(await autocomplete.getOptions()).toEqual([ + 'Bob', + 'John', + ]); + }); + + it('shows autocomplete for values matching column name after quote was typed', async () => { + await searchHarness.setValue('Username = "'); + + const autocomplete = await searchHarness.getAutocomplete(); + expect(await autocomplete.getOptions()).toEqual([ + 'Bob', + 'John', + ]); + }); + + it('shows autocomplete for connectors between statements', async () => { + await searchHarness.setValue('Username = "Bob" '); + + const autocomplete = await searchHarness.getAutocomplete(); + expect(await autocomplete.getOptions()).toEqual([ + 'AND', + 'OR', + ]); + }); + + it('provides suggestions for IN statements', async () => { + await searchHarness.setValue('Username IN ('); + + const autocomplete = await searchHarness.getAutocomplete(); + expect(await autocomplete.getOptions()).toEqual([ + 'Bob', + 'John', + ]); + }); + + it('provides suggestions for new column after a connector', async () => { + await searchHarness.setValue('Username = "Bob" OR '); + + const autocomplete = await searchHarness.getAutocomplete(); + expect(await autocomplete.getOptions()).toEqual([ + 'Event', + 'Timestamp', + 'Username', + 'Сервіс', + ]); + }); + + it('provides suggestions for connector after a group in quotes', async () => { + await searchHarness.setValue('(Username = "Bob") '); + + const autocomplete = await searchHarness.getAutocomplete(); + expect(await autocomplete.getOptions()).toEqual([ + 'AND', + 'OR', + ]); + }); + }); + + describe('selecting suggestions', () => { + it('inserts column name when it is selected', async () => { + await searchHarness.setValue('User'); + const autocomplete = await searchHarness.getAutocomplete(); + + await autocomplete.select('Username'); + + expect(await searchHarness.getValue()).toBe('Username'); + }); + + it('inserts a comparator when it is selected', async () => { + await searchHarness.setValue('Username '); + const autocomplete = await searchHarness.getAutocomplete(); + + await autocomplete.select('= (Equals)'); + + expect(await searchHarness.getValue()).toBe('Username ='); + }); + + it('inserts a quoted value when it is selected', async () => { + await searchHarness.setValue('Username = '); + const autocomplete = await searchHarness.getAutocomplete(); + + await autocomplete.select('Bob'); + + expect(await searchHarness.getValue()).toBe('Username = "bob"'); + }); + + it('inserts a logical operator when it is selected', async () => { + await searchHarness.setValue('Username = "Bob" '); + const autocomplete = await searchHarness.getAutocomplete(); + + await autocomplete.select('AND'); + + expect(await searchHarness.getValue()).toBe('Username = "Bob" AND'); + }); + + it('inserts a suggestion when Enter is pressed', async () => { + await searchHarness.setValue('User'); + + await (await searchHarness.getInputArea()).sendKeys(TestKey.ENTER); + + expect(await searchHarness.getValue()).toBe('Username'); + }); + }); + + describe('date suggestions', () => { + it('shows and inserts a date for date properties', async () => { + await searchHarness.setValue('Timestamp > '); + + const calendar = await loader.getHarness(MatCalendarHarness); + await calendar.changeView(); + await calendar.selectCell({ text: '2023' }); + await calendar.selectCell({ text: 'DEC' }); + await calendar.selectCell({ text: '21' }); + + expect(await searchHarness.getValue()).toBe('Timestamp > "2023-12-21"'); + }); + }); +}); diff --git a/src/app/modules/search-input/components/advanced-search/tests/editing.spec.ts b/src/app/modules/search-input/components/advanced-search/tests/editing.spec.ts new file mode 100644 index 00000000000..846ae423c48 --- /dev/null +++ b/src/app/modules/search-input/components/advanced-search/tests/editing.spec.ts @@ -0,0 +1,112 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; +import { auditEventLabels, AuditService } from 'app/enums/audit.enum'; +import { AuditEntry } from 'app/interfaces/audit/audit.interface'; +import { QueryFilters } from 'app/interfaces/query-api.interface'; +import { IxIconHarness } from 'app/modules/ix-icon/ix-icon.harness'; +import { AdvancedSearchComponent } from 'app/modules/search-input/components/advanced-search/advanced-search.component'; +import { AdvancedSearchHarness } from 'app/modules/search-input/components/advanced-search/advanced-search.harness'; +import { AdvancedSearchAutocompleteService } from 'app/modules/search-input/services/advanced-search-autocomplete.service'; +import { QueryParserService } from 'app/modules/search-input/services/query-parser/query-parser.service'; +import { QueryToApiService } from 'app/modules/search-input/services/query-to-api/query-to-api.service'; +import { searchProperties, textProperty } from 'app/modules/search-input/utils/search-properties.utils'; + +describe('AdvancedSearchComponent – editing', () => { + let spectator: Spectator>; + let loader: HarnessLoader; + let searchHarness: AdvancedSearchHarness; + const createComponent = createComponentFactory({ + component: AdvancedSearchComponent, + providers: [ + QueryToApiService, + QueryParserService, + AdvancedSearchAutocompleteService, + ], + }); + + beforeEach(async () => { + spectator = createComponent({ + props: { + query: [ + [ + 'OR', + [ + [ + ['event', 'in', ['AUTHENTICATION', 'CLOSE']], + ['username', '=', 'admin'], + ['service', '=', 'MIDDLEWARE'], + ], + [ + ['event', '=', 'AUTHENTICATION'], + ['service', '=', 'SMB'], + ], + ], + ], + ] as QueryFilters, + properties: searchProperties([ + textProperty( + 'username', + 'Username', + of([]), + ), + textProperty( + 'service', + 'Сервіс', + of([]), + new Map([ + [AuditService.Middleware, 'Проміжне програмне забезпечення'], + [AuditService.Smb, 'Ес-ем-бе'], + ]), + ), + textProperty( + 'event', + 'Event', + of([]), + auditEventLabels, + ), + ]), + }, + }); + searchHarness = await TestbedHarnessEnvironment.harnessForFixture(spectator.fixture, AdvancedSearchHarness); + loader = TestbedHarnessEnvironment.loader(spectator.fixture); + jest.spyOn(spectator.component.switchToBasic, 'emit'); + }); + + describe('no default input provided', () => { + it('resets text area when reset icon is pressed', async () => { + await searchHarness.setValue('test'); + await (await searchHarness.getResetIcon()).click(); + const placeholderText = await searchHarness.getPlaceholder(); + + expect(await searchHarness.getValue()).toBe(placeholderText); + }); + + it('emits (switchToBasic) when Switch To Basic is pressed', async () => { + expect(await (await searchHarness.getSwitchLink()).text()).toBe('Switch To Basic'); + await searchHarness.clickSwitchToBasic(); + + expect(spectator.component.switchToBasic.emit).toHaveBeenCalled(); + }); + }); + + describe('has default input provided', () => { + it('correctly sets predefined input value', async () => { + expect(await searchHarness.getValue()).toBe( + '(("Event" IN ("Authentication", "Close") AND "Username" = "admin" AND "Сервіс" = "Проміжне програмне забезпечення") OR ("Event" = "Authentication" AND "Сервіс" = "Ес-ем-бе"))', + ); + }); + }); + + describe('handles errors', () => { + it('shows error icon and stores error', async () => { + await searchHarness.setValue('Username = "root'); + + const icon = await loader.getHarness(IxIconHarness.with({ ancestor: '.prefix-icon' })); + expect(await icon.getName()).toBe('warning'); + // TODO: Refactor not to rely on protected property. + expect(spectator.component.errorMessages[0].message).toBe('Syntax error at 11-16'); + }); + }); +}); diff --git a/src/app/modules/search-input/components/options-suggestions/options-suggestions.component.html b/src/app/modules/search-input/components/options-suggestions/options-suggestions.component.html deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/app/modules/search-input/components/options-suggestions/options-suggestions.component.scss b/src/app/modules/search-input/components/options-suggestions/options-suggestions.component.scss deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/app/modules/search-input/components/options-suggestions/options-suggestions.component.ts b/src/app/modules/search-input/components/options-suggestions/options-suggestions.component.ts deleted file mode 100644 index f3f1b462d2a..00000000000 --- a/src/app/modules/search-input/components/options-suggestions/options-suggestions.component.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { - ChangeDetectionStrategy, Component, EventEmitter, Input, Output, -} from '@angular/core'; -import { Observable } from 'rxjs'; -import { Option } from 'app/interfaces/option.interface'; -import { SearchSuggestionsComponent } from 'app/modules/search-input/types/search-property.interface'; - -@Component({ - selector: 'ix-options-suggestions', - templateUrl: './options-suggestions.component.html', - styleUrls: ['./options-suggestions.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class OptionsSuggestionsComponent implements SearchSuggestionsComponent { - @Input() options$: Observable; - - @Output() suggestionSelected = new EventEmitter(); -} diff --git a/src/app/modules/search-input/search-input.module.ts b/src/app/modules/search-input/search-input.module.ts index ada6b8b9184..c12ba931f16 100644 --- a/src/app/modules/search-input/search-input.module.ts +++ b/src/app/modules/search-input/search-input.module.ts @@ -11,9 +11,6 @@ import { CastModule } from 'app/modules/cast/cast.module'; import { IxIconModule } from 'app/modules/ix-icon/ix-icon.module'; import { AdvancedSearchComponent } from 'app/modules/search-input/components/advanced-search/advanced-search.component'; import { BasicSearchComponent } from 'app/modules/search-input/components/basic-search/basic-search.component'; -import { - OptionsSuggestionsComponent, -} from 'app/modules/search-input/components/options-suggestions/options-suggestions.component'; import { AdvancedSearchAutocompleteService } from 'app/modules/search-input/services/advanced-search-autocomplete.service'; import { QueryParserService } from 'app/modules/search-input/services/query-parser/query-parser.service'; import { QueryToApiService } from 'app/modules/search-input/services/query-to-api/query-to-api.service'; @@ -42,7 +39,6 @@ import { SearchInputComponent } from './components/search-input/search-input.com declarations: [ SearchInputComponent, AdvancedSearchComponent, - OptionsSuggestionsComponent, BasicSearchComponent, ], providers: [ diff --git a/src/app/modules/search-input/services/advanced-search-autocomplete.service.ts b/src/app/modules/search-input/services/advanced-search-autocomplete.service.ts index 526b15cbc03..5be61278363 100644 --- a/src/app/modules/search-input/services/advanced-search-autocomplete.service.ts +++ b/src/app/modules/search-input/services/advanced-search-autocomplete.service.ts @@ -60,7 +60,7 @@ export class AdvancedSearchAutocompleteService { showDatePicker$ = new BehaviorSubject(false); constructor( - private queryParser: QueryParserService, + private queryParser: QueryParserService, private translate: TranslateService, ) {} @@ -72,7 +72,7 @@ export class AdvancedSearchAutocompleteService { this.editorView = editorView; } - setCompletionSource(context: CompletionContext): Promise { + getCompletions(context: CompletionContext): Promise { const currentQuery = context.state.doc.toString(); this.queryContext = this.getQueryContext(currentQuery, context.pos); const suggestions$ = this.generateSuggestionsBasedOnContext(this.queryContext); diff --git a/src/app/modules/search-input/services/query-parser/query-parser.service.ts b/src/app/modules/search-input/services/query-parser/query-parser.service.ts index 4944322c48b..36e1b298319 100644 --- a/src/app/modules/search-input/services/query-parser/query-parser.service.ts +++ b/src/app/modules/search-input/services/query-parser/query-parser.service.ts @@ -15,7 +15,7 @@ import { import { PropertyType, SearchProperty } from 'app/modules/search-input/types/search-property.interface'; @Injectable() -export class QueryParserService { +export class QueryParserService { private input: string; constructor(private translate: TranslateService) {} @@ -73,7 +73,7 @@ export class QueryParserService { } } - formatFiltersToQuery(structure: QueryFilters, properties: SearchProperty[]): string { + formatFiltersToQuery(structure: QueryFilters, properties: SearchProperty[]): string { return structure.map((element) => this.parseElementFromQueryFilter(element, properties)).join(' AND '); } @@ -196,7 +196,7 @@ export class QueryParserService { } private mapValueByPropertyType( - property: SearchProperty, + property: SearchProperty, value: LiteralValue | LiteralValue[], ): LiteralValue | LiteralValue[] { if (property?.propertyType === PropertyType.Date) { @@ -227,7 +227,7 @@ export class QueryParserService { } private formatMemoryValue( - property: SearchProperty, + property: SearchProperty, value: LiteralValue | LiteralValue[], ): string | string[] { const formatValue = (memoryValue: LiteralValue): string => { @@ -242,7 +242,7 @@ export class QueryParserService { } private formatTextValue( - property: SearchProperty, + property: SearchProperty, value: LiteralValue | LiteralValue[], ): string | string[] { const parseValue = (textValue: LiteralValue): string => { @@ -259,9 +259,9 @@ export class QueryParserService { } private parseArrayFromQueryFilter( - array: QueryFilter[], + array: QueryFilter[], operator: string, - properties: SearchProperty[], + properties: SearchProperty[], ): string { const parsedConditions = array.map((element) => this.parseElementFromQueryFilter(element, properties)); const innerTemplate = parsedConditions.join(` ${operator} `); @@ -269,8 +269,8 @@ export class QueryParserService { } private conditionToStringFromQueryFilter( - condition: QueryFilter, - properties: SearchProperty[], + condition: QueryFilter, + properties: SearchProperty[], ): string { const [property, comparator, value] = condition; @@ -290,22 +290,22 @@ export class QueryParserService { } private parseElementFromQueryFilter( - element: QueryFilters | QueryFilter | OrQueryFilter, - properties: SearchProperty[], + element: QueryFilters | QueryFilter | OrQueryFilter, + properties: SearchProperty[], ): string { if (Array.isArray(element)) { - if (typeof element[0] === 'string' && ['OR', 'AND'].includes(element[0].toUpperCase())) { - const operator = element[0].toUpperCase(); - return this.parseArrayFromQueryFilter(element[1] as QueryFilter[], operator, properties); + if (typeof element[0] === 'string' && ['OR', 'AND'].includes((element[0] as string).toUpperCase())) { + const operator = (element[0] as string).toUpperCase(); + return this.parseArrayFromQueryFilter(element[1] as QueryFilter[], operator, properties); } if (element.length === 3 && typeof element[1] === 'string') { - return this.conditionToStringFromQueryFilter(element as QueryFilter, properties); + return this.conditionToStringFromQueryFilter(element as QueryFilter, properties); } - return this.parseArrayFromQueryFilter(element as QueryFilter[], 'AND', properties); + return this.parseArrayFromQueryFilter(element as QueryFilter[], 'AND', properties); } - return this.conditionToStringFromQueryFilter(element as QueryFilter, properties); + return this.conditionToStringFromQueryFilter(element as QueryFilter, properties); } } diff --git a/src/app/modules/search-input/types/search-property.interface.ts b/src/app/modules/search-input/types/search-property.interface.ts index 33491f825d4..aa55f7ad002 100644 --- a/src/app/modules/search-input/types/search-property.interface.ts +++ b/src/app/modules/search-input/types/search-property.interface.ts @@ -1,4 +1,3 @@ -import { EventEmitter } from '@angular/core'; import { Observable } from 'rxjs'; import { Option } from 'app/interfaces/option.interface'; @@ -25,10 +24,6 @@ export interface SearchProperty { parseValue?: (value: string) => unknown; } -export interface SearchSuggestionsComponent { - suggestionSelected: EventEmitter; -} - export enum PropertyType { Text = 'text', Date = 'date', diff --git a/src/setup-jest.ts b/src/setup-jest.ts index b24c03b0ea7..68fcf4d758a 100644 --- a/src/setup-jest.ts +++ b/src/setup-jest.ts @@ -141,3 +141,18 @@ beforeEach(() => { })), }); }); + +// https://github.com/jsdom/jsdom/issues/3002 +Range.prototype.getBoundingClientRect = () => ({ + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, +} as DOMRect); +Range.prototype.getClientRects = () => ({ + item: () => null, + length: 0, + [Symbol.iterator]: jest.fn(), +});