diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.test.tsx b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.test.tsx index 729e12c035f43..c41a38cc69236 100644 --- a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.test.tsx +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.test.tsx @@ -136,14 +136,9 @@ describe('', () => { const { form, component, actions } = testBed; - await act(async () => { - actions.toggleFormRow('value'); - }); - - await act(async () => { - form.setInputValue('nameField.input', existingFields[0]); - form.setInputValue('scriptField', 'echo("hello")'); - }); + await actions.toggleFormRow('value'); + await actions.fields.updateName(existingFields[0]); + await actions.fields.updateScript('echo("hello")'); await act(async () => { jest.advanceTimersByTime(1000); // Make sure our debounced error message is in the DOM @@ -180,6 +175,7 @@ describe('', () => { const lastState = getLastStateUpdate(); await submitFormAndGetData(lastState); component.update(); + expect(getLastStateUpdate().isValid).toBe(true); expect(form.getErrorsMessages()).toEqual([]); }); @@ -237,7 +233,7 @@ describe('', () => { form, component, find, - actions: { changeFieldType }, + actions: { fields }, } = testBed; // We set some dummy painless error @@ -249,7 +245,7 @@ describe('', () => { expect(form.getErrorsMessages()).toEqual(['Awwww! Painless syntax error']); // We change the type and expect the form error to not be there anymore - await changeFieldType('keyword'); + await fields.updateType('keyword'); expect(form.getErrorsMessages()).toEqual([]); }); }); diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts index b73c400b751d2..5b916c1cd9960 100644 --- a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts @@ -22,9 +22,16 @@ const defaultProps: Props = { isSavingField: false, }; +const getActions = (testBed: TestBed) => { + return { + ...getCommonActions(testBed), + }; +}; + export const setup = async (props?: Partial, deps?: Partial) => { let testBed: TestBed; + // Setup testbed await act(async () => { testBed = await registerTestBed(WithFieldEditorDependencies(FieldEditorFlyoutContent, deps), { memoryRouter: { @@ -35,9 +42,5 @@ export const setup = async (props?: Partial, deps?: Partial) => testBed!.component.update(); - const actions = { - ...getCommonActions(testBed!), - }; - - return { ...testBed!, actions }; + return { ...testBed!, actions: getActions(testBed!) }; }; diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts index a4c03131bc3b6..9b00ff762fe8f 100644 --- a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts @@ -15,6 +15,7 @@ describe('', () => { const { server, httpRequestsMockHelpers } = setupEnvironment(); beforeAll(() => { + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['foo'] }); jest.useFakeTimers(); }); @@ -23,10 +24,6 @@ describe('', () => { server.restore(); }); - beforeEach(() => { - httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['Set by Jest test'] }); - }); - test('should have the correct title', async () => { const { exists, find } = await setup(); expect(exists('flyoutTitle')).toBe(true); @@ -114,20 +111,12 @@ describe('', () => { const { find, - component, - form, - actions: { toggleFormRow, changeFieldType }, + actions: { toggleFormRow, fields }, } = await setup({ onSave }); - act(() => { - form.setInputValue('nameField.input', 'someName'); - toggleFormRow('value'); - }); - component.update(); - - await act(async () => { - form.setInputValue('scriptField', 'echo("hello")'); - }); + await fields.updateName('someName'); + await toggleFormRow('value'); + await fields.updateScript('echo("hello")'); await act(async () => { // Let's make sure that validation has finished running @@ -149,7 +138,7 @@ describe('', () => { }); // Change the type and make sure it is forwarded - await changeFieldType('other_type', 'Other type'); + await fields.updateType('other_type', 'Other type'); await act(async () => { find('fieldSaveButton').simulate('click'); diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.helpers.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.helpers.ts new file mode 100644 index 0000000000000..068ebce638aa1 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.helpers.ts @@ -0,0 +1,185 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { act } from 'react-dom/test-utils'; +import { ReactWrapper } from 'enzyme'; +import { registerTestBed, TestBed } from '@kbn/test/jest'; + +import { API_BASE_PATH } from '../../common/constants'; +import { Context } from '../../public/components/field_editor_context'; +import { + FieldEditorFlyoutContent, + Props, +} from '../../public/components/field_editor_flyout_content'; +import { + WithFieldEditorDependencies, + getCommonActions, + spyIndexPatternGetAllFields, + spySearchQuery, + spySearchQueryResponse, +} from './helpers'; + +const defaultProps: Props = { + onSave: () => {}, + onCancel: () => {}, + runtimeFieldValidator: () => Promise.resolve(null), + isSavingField: false, +}; + +/** + * This handler lets us mock the fields present on the index pattern during our test + * @param fields The fields of the index pattern + */ +export const setIndexPatternFields = (fields: Array<{ name: string; displayName: string }>) => { + spyIndexPatternGetAllFields.mockReturnValue(fields); +}; + +export interface TestDoc { + title: string; + subTitle: string; + description: string; +} + +export const getSearchCallMeta = () => { + const totalCalls = spySearchQuery.mock.calls.length; + const lastCall = spySearchQuery.mock.calls[totalCalls - 1] ?? null; + let lastCallParams = null; + + if (lastCall) { + lastCallParams = lastCall[0]; + } + + return { + totalCalls, + lastCall, + lastCallParams, + }; +}; + +export const setSearchResponse = ( + documents: Array<{ _id: string; _index: string; _source: TestDoc }> +) => { + spySearchQueryResponse.mockResolvedValue({ + rawResponse: { + hits: { + total: documents.length, + hits: documents, + }, + }, + }); +}; + +const getActions = (testBed: TestBed) => { + const getWrapperRenderedIndexPatternFields = (): ReactWrapper | null => { + if (testBed.find('indexPatternFieldList').length === 0) { + return null; + } + return testBed.find('indexPatternFieldList.listItem'); + }; + + const getRenderedIndexPatternFields = (): Array<{ key: string; value: string }> => { + const allFields = getWrapperRenderedIndexPatternFields(); + + if (allFields === null) { + return []; + } + + return allFields.map((field) => { + const key = testBed.find('key', field).text(); + const value = testBed.find('value', field).text(); + return { key, value }; + }); + }; + + const getRenderedFieldsPreview = () => { + if (testBed.find('fieldPreviewItem').length === 0) { + return []; + } + + const previewFields = testBed.find('fieldPreviewItem.listItem'); + + return previewFields.map((field) => { + const key = testBed.find('key', field).text(); + const value = testBed.find('value', field).text(); + return { key, value }; + }); + }; + + const setFilterFieldsValue = async (value: string) => { + await act(async () => { + testBed.form.setInputValue('filterFieldsInput', value); + }); + + testBed.component.update(); + }; + + // Need to set "server: any" (instead of SinonFakeServer) to avoid a TS error :( + // Error: Exported variable 'setup' has or is using name 'Document' from external module "/dev/shm/workspace/parallel/14/kibana/node_modules/@types/sinon/ts3.1/index" + const getLatestPreviewHttpRequest = (server: any) => { + let i = server.requests.length - 1; + + while (i >= 0) { + const request = server.requests[i]; + if (request.method === 'POST' && request.url === `${API_BASE_PATH}/field_preview`) { + return { + ...request, + requestBody: JSON.parse(JSON.parse(request.requestBody).body), + }; + } + i--; + } + + throw new Error(`Can't access the latest preview HTTP request as it hasn't been called.`); + }; + + const goToNextDocument = async () => { + await act(async () => { + testBed.find('goToNextDocButton').simulate('click'); + }); + testBed.component.update(); + }; + + const goToPreviousDocument = async () => { + await act(async () => { + testBed.find('goToPrevDocButton').simulate('click'); + }); + testBed.component.update(); + }; + + const loadCustomDocument = (docId: string) => {}; + + return { + ...getCommonActions(testBed), + getWrapperRenderedIndexPatternFields, + getRenderedIndexPatternFields, + getRenderedFieldsPreview, + setFilterFieldsValue, + getLatestPreviewHttpRequest, + goToNextDocument, + goToPreviousDocument, + loadCustomDocument, + }; +}; + +export const setup = async (props?: Partial, deps?: Partial) => { + let testBed: TestBed; + + // Setup testbed + await act(async () => { + testBed = await registerTestBed(WithFieldEditorDependencies(FieldEditorFlyoutContent, deps), { + memoryRouter: { + wrapComponent: false, + }, + })({ ...defaultProps, ...props }); + }); + + testBed!.component.update(); + + return { ...testBed!, actions: getActions(testBed!) }; +}; + +export type FieldEditorFlyoutContentTestBed = TestBed & { actions: ReturnType }; diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.test.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.test.ts new file mode 100644 index 0000000000000..1382d2c911200 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.test.ts @@ -0,0 +1,891 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { act } from 'react-dom/test-utils'; + +import { setupEnvironment, fieldFormatsOptions, indexPatternNameForTest } from './helpers'; +import { + setup, + setIndexPatternFields, + getSearchCallMeta, + setSearchResponse, + FieldEditorFlyoutContentTestBed, + TestDoc, +} from './field_editor_flyout_preview.helpers'; +import { createPreviewError } from './helpers/mocks'; + +interface EsDoc { + _id: string; + _index: string; + _source: TestDoc; +} + +describe('Field editor Preview panel', () => { + const { server, httpRequestsMockHelpers } = setupEnvironment(); + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + server.restore(); + }); + + let testBed: FieldEditorFlyoutContentTestBed; + + const mockDocuments: EsDoc[] = [ + { + _id: '001', + _index: 'testIndex', + _source: { + title: 'First doc - title', + subTitle: 'First doc - subTitle', + description: 'First doc - description', + }, + }, + { + _id: '002', + _index: 'testIndex', + _source: { + title: 'Second doc - title', + subTitle: 'Second doc - subTitle', + description: 'Second doc - description', + }, + }, + { + _id: '003', + _index: 'testIndex', + _source: { + title: 'Third doc - title', + subTitle: 'Third doc - subTitle', + description: 'Third doc - description', + }, + }, + ]; + + const [doc1, doc2, doc3] = mockDocuments; + + const indexPatternFields: Array<{ name: string; displayName: string }> = [ + { + name: 'title', + displayName: 'title', + }, + { + name: 'subTitle', + displayName: 'subTitle', + }, + { + name: 'description', + displayName: 'description', + }, + ]; + + beforeEach(async () => { + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['mockedScriptValue'] }); + setIndexPatternFields(indexPatternFields); + setSearchResponse(mockDocuments); + + testBed = await setup(); + }); + + test('should display the preview panel when either "set value" or "set format" is activated', async () => { + const { + exists, + actions: { toggleFormRow }, + } = testBed; + + expect(exists('previewPanel')).toBe(false); + + await toggleFormRow('value'); + expect(exists('previewPanel')).toBe(true); + + await toggleFormRow('value', 'off'); + expect(exists('previewPanel')).toBe(false); + + await toggleFormRow('format'); + expect(exists('previewPanel')).toBe(true); + + await toggleFormRow('format', 'off'); + expect(exists('previewPanel')).toBe(false); + }); + + test('should correctly set the title and subtitle of the panel', async () => { + const { + find, + actions: { toggleFormRow, fields, waitForUpdates }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await waitForUpdates(); + + expect(find('previewPanel.title').text()).toBe('Preview'); + expect(find('previewPanel.subTitle').text()).toBe(`From: ${indexPatternNameForTest}`); + }); + + test('should list the list of fields of the index pattern', async () => { + const { + actions: { toggleFormRow, fields, getRenderedIndexPatternFields, waitForUpdates }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await waitForUpdates(); + + expect(getRenderedIndexPatternFields()).toEqual([ + { + key: 'title', + value: mockDocuments[0]._source.title, + }, + { + key: 'subTitle', + value: mockDocuments[0]._source.subTitle, + }, + { + key: 'description', + value: mockDocuments[0]._source.description, + }, + ]); + }); + + test('should filter down the field in the list', async () => { + const { + exists, + find, + component, + actions: { + toggleFormRow, + fields, + setFilterFieldsValue, + getRenderedIndexPatternFields, + waitForUpdates, + }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await waitForUpdates(); + + // Should find a single field + await setFilterFieldsValue('descr'); + expect(getRenderedIndexPatternFields()).toEqual([ + { key: 'description', value: 'First doc - description' }, + ]); + + // Should be case insensitive + await setFilterFieldsValue('title'); + expect(exists('emptySearchResult')).toBe(false); + expect(getRenderedIndexPatternFields()).toEqual([ + { key: 'title', value: 'First doc - title' }, + { key: 'subTitle', value: 'First doc - subTitle' }, + ]); + + // Should display an empty search result with a button to clear + await setFilterFieldsValue('doesNotExist'); + expect(exists('emptySearchResult')).toBe(true); + expect(getRenderedIndexPatternFields()).toEqual([]); + expect(exists('emptySearchResult.clearSearchButton')); + + find('emptySearchResult.clearSearchButton').simulate('click'); + component.update(); + expect(getRenderedIndexPatternFields()).toEqual([ + { + key: 'title', + value: mockDocuments[0]._source.title, + }, + { + key: 'subTitle', + value: mockDocuments[0]._source.subTitle, + }, + { + key: 'description', + value: mockDocuments[0]._source.description, + }, + ]); + }); + + test('should pin the field to the top of the list', async () => { + const { + find, + component, + actions: { + toggleFormRow, + fields, + getWrapperRenderedIndexPatternFields, + getRenderedIndexPatternFields, + waitForUpdates, + }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await waitForUpdates(); + + const fieldsRendered = getWrapperRenderedIndexPatternFields(); + + if (fieldsRendered === null) { + throw new Error('No index pattern field rendered.'); + } + + expect(fieldsRendered.length).toBe(Object.keys(doc1._source).length); + // make sure that the last one if the "description" field + expect(fieldsRendered.at(2).text()).toBe('descriptionFirst doc - description'); + + // Click the third field in the list ("description") + const descriptionField = fieldsRendered.at(2); + find('pinFieldButton', descriptionField).simulate('click'); + component.update(); + + expect(getRenderedIndexPatternFields()).toEqual([ + { key: 'description', value: 'First doc - description' }, // Pinned! + { key: 'title', value: 'First doc - title' }, + { key: 'subTitle', value: 'First doc - subTitle' }, + ]); + }); + + describe('empty prompt', () => { + test('should display an empty prompt if no name and no script are defined', async () => { + const { + exists, + actions: { toggleFormRow, fields, waitForUpdates }, + } = testBed; + + await toggleFormRow('value'); + expect(exists('previewPanel')).toBe(true); + expect(exists('previewPanel.emptyPrompt')).toBe(true); + + await fields.updateName('someName'); + await waitForUpdates(); + expect(exists('previewPanel.emptyPrompt')).toBe(false); + + await fields.updateName(' '); + await waitForUpdates(); + expect(exists('previewPanel.emptyPrompt')).toBe(true); + + // The name is empty and the empty prompt is displayed, let's now add a script... + await fields.updateScript('echo("hello")'); + await waitForUpdates(); + expect(exists('previewPanel.emptyPrompt')).toBe(false); + + await fields.updateScript(' '); + await waitForUpdates(); + expect(exists('previewPanel.emptyPrompt')).toBe(true); + }); + + test('should **not** display an empty prompt editing a document with a script', async () => { + const field = { + name: 'foo', + type: 'ip', + script: { + source: 'emit("hello world")', + }, + }; + + // We open the editor with a field to edit. The preview panel should be open + // and the empty prompt should not be there as we have a script and we'll load + // the preview. + await act(async () => { + testBed = await setup({ field }); + }); + + const { exists, component } = testBed; + component.update(); + + expect(exists('previewPanel')).toBe(true); + expect(exists('previewPanel.emptyPrompt')).toBe(false); + }); + + test('should **not** display an empty prompt editing a document with format defined', async () => { + const field = { + name: 'foo', + type: 'ip', + format: { + id: 'upper', + params: {}, + }, + }; + + // We open the editor with a field to edit. The preview panel should be open + // and the empty prompt should not be there as we have a script and we'll load + // the preview. + await act(async () => { + testBed = await setup({ field }); + }); + + const { exists, component } = testBed; + component.update(); + + expect(exists('previewPanel')).toBe(true); + expect(exists('previewPanel.emptyPrompt')).toBe(false); + }); + }); + + describe('key & value', () => { + test('should set an empty value when no script is provided', async () => { + const { + actions: { toggleFormRow, fields, getRenderedFieldsPreview, waitForUpdates }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await waitForUpdates(); + + expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: '-' }]); + }); + + test('should set the value returned by the painless _execute API', async () => { + const scriptEmitResponse = 'Field emit() response'; + httpRequestsMockHelpers.setFieldPreviewResponse({ values: [scriptEmitResponse] }); + + const { + actions: { + toggleFormRow, + fields, + waitForDocumentsAndPreviewUpdate, + getLatestPreviewHttpRequest, + getRenderedFieldsPreview, + }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await fields.updateScript('echo("hello")'); + await waitForDocumentsAndPreviewUpdate(); + const request = getLatestPreviewHttpRequest(server); + + // Make sure the payload sent is correct + expect(request.requestBody).toEqual({ + context: 'keyword_field', + document: { + description: 'First doc - description', + subTitle: 'First doc - subTitle', + title: 'First doc - title', + }, + index: 'testIndex', + script: { + source: 'echo("hello")', + }, + }); + + // And that we display the response + expect(getRenderedFieldsPreview()).toEqual([ + { key: 'myRuntimeField', value: scriptEmitResponse }, + ]); + }); + + test('should display an updating indicator while fetching the preview', async () => { + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['ok'] }); + + const { + exists, + actions: { toggleFormRow, fields, waitForUpdates, waitForDocumentsAndPreviewUpdate }, + } = testBed; + + await toggleFormRow('value'); + await waitForUpdates(); // wait for docs to be fetched + expect(exists('isUpdatingIndicator')).toBe(false); + + await fields.updateScript('echo("hello")'); + expect(exists('isUpdatingIndicator')).toBe(true); + + await waitForDocumentsAndPreviewUpdate(); + expect(exists('isUpdatingIndicator')).toBe(false); + }); + + test('should not display the updating indicator when neither the type nor the script has changed', async () => { + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['ok'] }); + + const { + exists, + actions: { toggleFormRow, fields, waitForUpdates, waitForDocumentsAndPreviewUpdate }, + } = testBed; + + await toggleFormRow('value'); + await waitForUpdates(); // wait for docs to be fetched + await fields.updateName('myRuntimeField'); + await fields.updateScript('echo("hello")'); + expect(exists('isUpdatingIndicator')).toBe(true); + await waitForDocumentsAndPreviewUpdate(); + expect(exists('isUpdatingIndicator')).toBe(false); + + await fields.updateName('nameChanged'); + // We haven't changed the type nor the script so there should not be any updating indicator + expect(exists('isUpdatingIndicator')).toBe(false); + }); + + describe('read from _source', () => { + test('should display the _source value when no script is provided and the name matched one of the fields in _source', async () => { + const { + actions: { + toggleFormRow, + fields, + getRenderedFieldsPreview, + waitForDocumentsAndPreviewUpdate, + }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('subTitle'); + await waitForDocumentsAndPreviewUpdate(); + + expect(getRenderedFieldsPreview()).toEqual([ + { key: 'subTitle', value: 'First doc - subTitle' }, + ]); + }); + + test('should display the value returned by the _execute API and fallback to _source if "Set value" is turned off', async () => { + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['valueFromExecuteAPI'] }); + + const { + actions: { toggleFormRow, fields, waitForUpdates, getRenderedFieldsPreview }, + } = testBed; + + await toggleFormRow('value'); + await waitForUpdates(); // fetch documents + await fields.updateName('description'); // Field name is a field in _source + await fields.updateScript('echo("hello")'); + await waitForUpdates(); // fetch preview + + // We render the value from the _execute API + expect(getRenderedFieldsPreview()).toEqual([ + { key: 'description', value: 'valueFromExecuteAPI' }, + ]); + + await toggleFormRow('format', 'on'); + await toggleFormRow('value', 'off'); + + // Fallback to _source value when "Set value" is turned off and we have a format + expect(getRenderedFieldsPreview()).toEqual([ + { key: 'description', value: 'First doc - description' }, + ]); + }); + }); + }); + + describe('format', () => { + test('should apply the format to the value', async () => { + /** + * Each of the formatter has already its own test. Here we are simply + * doing a smoke test to make sure that the preview panel applies the formatter + * to the runtime field value. + * We do that by mocking (in "setup_environment.tsx") the implementation of the + * the fieldFormats.getInstance() handler. + */ + const scriptEmitResponse = 'hello'; + httpRequestsMockHelpers.setFieldPreviewResponse({ values: [scriptEmitResponse] }); + + const { + actions: { + toggleFormRow, + fields, + waitForUpdates, + waitForDocumentsAndPreviewUpdate, + getRenderedFieldsPreview, + }, + } = testBed; + + await fields.updateName('myRuntimeField'); + await toggleFormRow('value'); + await fields.updateScript('echo("hello")'); + await waitForDocumentsAndPreviewUpdate(); + + // before + expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'hello' }]); + + // after + await toggleFormRow('format'); + await fields.updateFormat(fieldFormatsOptions[0].id); // select 'upper' format + await waitForUpdates(); + expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'HELLO' }]); + }); + }); + + describe('error handling', () => { + test('should display the error returned by the Painless _execute API', async () => { + const error = createPreviewError({ reason: 'Houston we got a problem' }); + httpRequestsMockHelpers.setFieldPreviewResponse({ values: [], error, status: 400 }); + + const { + exists, + find, + actions: { + toggleFormRow, + fields, + waitForUpdates, + waitForDocumentsAndPreviewUpdate, + getRenderedFieldsPreview, + }, + } = testBed; + + await fields.updateName('myRuntimeField'); + await toggleFormRow('value'); + await fields.updateScript('bad()'); + await waitForDocumentsAndPreviewUpdate(); + + expect(exists('fieldPreviewItem')).toBe(false); + expect(exists('indexPatternFieldList')).toBe(false); + expect(exists('previewError')).toBe(true); + expect(find('previewError.title').text()).toBe('Error compiling the painless script'); + expect(find('previewError.reason').text()).toBe(error.caused_by.reason); + + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['ok'] }); + await fields.updateScript('echo("ok")'); + await waitForUpdates(); + + expect(exists('fieldPreviewItem')).toBe(true); + expect(find('indexPatternFieldList.listItem').length).toBeGreaterThan(0); + expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'ok' }]); + }); + + test('should handle error when a document is not found', async () => { + const { + exists, + find, + form, + actions: { toggleFormRow, fields, waitForUpdates, waitForDocumentsAndPreviewUpdate }, + } = testBed; + + await fields.updateName('myRuntimeField'); + await toggleFormRow('value'); + await waitForDocumentsAndPreviewUpdate(); + + // We will return no document from the search + setSearchResponse([]); + + await act(async () => { + form.setInputValue('documentIdField', 'wrongID'); + }); + await waitForUpdates(); + + expect(exists('previewError')).toBe(true); + expect(find('previewError').text()).toContain('the document provided was not found'); + expect(exists('isUpdatingIndicator')).toBe(false); + }); + }); + + describe('Cluster document load and navigation', () => { + const customLoadedDoc: EsDoc = { + _id: '123456', + _index: 'otherIndex', + _source: { + title: 'loaded doc - title', + subTitle: 'loaded doc - subTitle', + description: 'loaded doc - description', + }, + }; + + test('should update the field list when the document changes', async () => { + const { + actions: { + toggleFormRow, + fields, + getRenderedIndexPatternFields, + goToNextDocument, + goToPreviousDocument, + waitForUpdates, + }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await waitForUpdates(); + + expect(getRenderedIndexPatternFields()[0]).toEqual({ + key: 'title', + value: doc1._source.title, + }); + + await goToNextDocument(); + expect(getRenderedIndexPatternFields()[0]).toEqual({ + key: 'title', + value: doc2._source.title, + }); + + await goToNextDocument(); + expect(getRenderedIndexPatternFields()[0]).toEqual({ + key: 'title', + value: doc3._source.title, + }); + + // Going next we circle back to the first document of the list + await goToNextDocument(); + expect(getRenderedIndexPatternFields()[0]).toEqual({ + key: 'title', + value: doc1._source.title, + }); + + // Let's go backward + await goToPreviousDocument(); + expect(getRenderedIndexPatternFields()[0]).toEqual({ + key: 'title', + value: doc3._source.title, + }); + + await goToPreviousDocument(); + expect(getRenderedIndexPatternFields()[0]).toEqual({ + key: 'title', + value: doc2._source.title, + }); + }); + + test('should update the field preview value when the document changes', async () => { + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['valueDoc1'] }); + const { + actions: { + toggleFormRow, + fields, + waitForUpdates, + waitForDocumentsAndPreviewUpdate, + getRenderedFieldsPreview, + goToNextDocument, + }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await fields.updateScript('echo("hello world")'); + await waitForDocumentsAndPreviewUpdate(); + + expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'valueDoc1' }]); + + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['valueDoc2'] }); + await goToNextDocument(); + await waitForUpdates(); + + expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'valueDoc2' }]); + }); + + test('should load a custom document when an ID is passed', async () => { + const { + component, + form, + exists, + actions: { + toggleFormRow, + fields, + getRenderedIndexPatternFields, + getRenderedFieldsPreview, + waitForUpdates, + waitForDocumentsAndPreviewUpdate, + }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await fields.updateScript('echo("hello world")'); + await waitForDocumentsAndPreviewUpdate(); + + // First make sure that we have the original cluster data is loaded + // and the preview value rendered. + expect(getRenderedIndexPatternFields()[0]).toEqual({ + key: 'title', + value: doc1._source.title, + }); + expect(getRenderedFieldsPreview()).toEqual([ + { key: 'myRuntimeField', value: 'mockedScriptValue' }, + ]); + + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['loadedDocPreview'] }); + setSearchResponse([customLoadedDoc]); + + await act(async () => { + form.setInputValue('documentIdField', '123456'); + }); + component.update(); + // We immediately remove the index pattern fields + expect(getRenderedIndexPatternFields()).toEqual([]); + + await waitForDocumentsAndPreviewUpdate(); + + expect(getRenderedIndexPatternFields()).toEqual([ + { + key: 'title', + value: 'loaded doc - title', + }, + { + key: 'subTitle', + value: 'loaded doc - subTitle', + }, + { + key: 'description', + value: 'loaded doc - description', + }, + ]); + + await waitForUpdates(); // Then wait for the preview HTTP request + + // The preview should have updated + expect(getRenderedFieldsPreview()).toEqual([ + { key: 'myRuntimeField', value: 'loadedDocPreview' }, + ]); + + // The nav should not be there when loading a single document + expect(exists('documentsNav')).toBe(false); + // There should be a link to load back the cluster data + expect(exists('loadDocsFromClusterButton')).toBe(true); + }); + + test('should load back the cluster data after providing a custom ID', async () => { + const { + form, + component, + find, + actions: { + toggleFormRow, + fields, + getRenderedFieldsPreview, + getRenderedIndexPatternFields, + waitForUpdates, + waitForDocumentsAndPreviewUpdate, + }, + } = testBed; + + await toggleFormRow('value'); + await waitForUpdates(); // fetch documents + await fields.updateName('myRuntimeField'); + await fields.updateScript('echo("hello world")'); + await waitForUpdates(); // fetch preview + + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['loadedDocPreview'] }); + setSearchResponse([customLoadedDoc]); + + // Load a custom document ID + await act(async () => { + form.setInputValue('documentIdField', '123456'); + }); + await waitForDocumentsAndPreviewUpdate(); + + // Load back the cluster data + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['clusterDataDocPreview'] }); + setSearchResponse(mockDocuments); + + await act(async () => { + find('loadDocsFromClusterButton').simulate('click'); + }); + component.update(); + // We immediately remove the index pattern fields + expect(getRenderedIndexPatternFields()).toEqual([]); + + await waitForDocumentsAndPreviewUpdate(); + + // The preview should be updated with the cluster data preview + expect(getRenderedFieldsPreview()).toEqual([ + { key: 'myRuntimeField', value: 'clusterDataDocPreview' }, + ]); + }); + + test('should not lose the state of single document vs cluster data after displaying the empty prompt', async () => { + const { + form, + component, + exists, + actions: { + toggleFormRow, + fields, + getRenderedIndexPatternFields, + waitForDocumentsAndPreviewUpdate, + }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await waitForDocumentsAndPreviewUpdate(); + + // Initial state where we have the cluster data loaded and the doc navigation + expect(exists('documentsNav')).toBe(true); + expect(exists('loadDocsFromClusterButton')).toBe(false); + + setSearchResponse([customLoadedDoc]); + + await act(async () => { + form.setInputValue('documentIdField', '123456'); + }); + component.update(); + await waitForDocumentsAndPreviewUpdate(); + + expect(exists('documentsNav')).toBe(false); + expect(exists('loadDocsFromClusterButton')).toBe(true); + + // Clearing the name will display the empty prompt as we don't have any script + await fields.updateName(''); + expect(exists('previewPanel.emptyPrompt')).toBe(true); + + // Give another name to hide the empty prompt and show the preview panel back + await fields.updateName('newName'); + expect(exists('previewPanel.emptyPrompt')).toBe(false); + + // We should still display the single document state + expect(exists('documentsNav')).toBe(false); + expect(exists('loadDocsFromClusterButton')).toBe(true); + expect(getRenderedIndexPatternFields()[0]).toEqual({ + key: 'title', + value: 'loaded doc - title', + }); + }); + + test('should send the correct params to the data plugin search() handler', async () => { + const { + form, + component, + find, + actions: { toggleFormRow, fields, waitForUpdates }, + } = testBed; + + const expectedParamsToFetchClusterData = { + params: { index: 'testIndexPattern', body: { size: 50 } }, + }; + + // Initial state + let searchMeta = getSearchCallMeta(); + const initialCount = searchMeta.totalCalls; + + // Open the preview panel. This will trigger document fetchint + await fields.updateName('myRuntimeField'); + await toggleFormRow('value'); + await waitForUpdates(); + + searchMeta = getSearchCallMeta(); + expect(searchMeta.totalCalls).toBe(initialCount + 1); + expect(searchMeta.lastCallParams).toEqual(expectedParamsToFetchClusterData); + + // Load single doc + setSearchResponse([customLoadedDoc]); + const nextId = '123456'; + await act(async () => { + form.setInputValue('documentIdField', nextId); + }); + component.update(); + await waitForUpdates(); + + searchMeta = getSearchCallMeta(); + expect(searchMeta.totalCalls).toBe(initialCount + 2); + expect(searchMeta.lastCallParams).toEqual({ + params: { + body: { + query: { + ids: { + values: [nextId], + }, + }, + size: 1, + }, + index: 'testIndexPattern', + }, + }); + + // Back to cluster data + setSearchResponse(mockDocuments); + await act(async () => { + find('loadDocsFromClusterButton').simulate('click'); + }); + searchMeta = getSearchCallMeta(); + expect(searchMeta.totalCalls).toBe(initialCount + 3); + expect(searchMeta.lastCallParams).toEqual(expectedParamsToFetchClusterData); + }); + }); +}); diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/common_actions.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/common_actions.ts index 92b00d30e6563..ca061968dae20 100644 --- a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/common_actions.ts +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/common_actions.ts @@ -9,7 +9,10 @@ import { act } from 'react-dom/test-utils'; import { TestBed } from '@kbn/test/jest'; export const getCommonActions = (testBed: TestBed) => { - const toggleFormRow = (row: 'customLabel' | 'value' | 'format', value: 'on' | 'off' = 'on') => { + const toggleFormRow = async ( + row: 'customLabel' | 'value' | 'format', + value: 'on' | 'off' = 'on' + ) => { const testSubj = `${row}Row.toggle`; const toggle = testBed.find(testSubj); const isOn = toggle.props()['aria-checked']; @@ -18,10 +21,31 @@ export const getCommonActions = (testBed: TestBed) => { return; } - testBed.form.toggleEuiSwitch(testSubj); + await act(async () => { + testBed.form.toggleEuiSwitch(testSubj); + }); + + testBed.component.update(); + }; + + // Fields + const updateName = async (value: string) => { + await act(async () => { + testBed.form.setInputValue('nameField.input', value); + }); + + testBed.component.update(); }; - const changeFieldType = async (value: string, label?: string) => { + const updateScript = async (value: string) => { + await act(async () => { + testBed.form.setInputValue('scriptField', value); + }); + + testBed.component.update(); + }; + + const updateType = async (value: string, label?: string) => { await act(async () => { testBed.find('typeField').simulate('change', [ { @@ -30,11 +54,58 @@ export const getCommonActions = (testBed: TestBed) => { }, ]); }); + + testBed.component.update(); + }; + + const updateFormat = async (value: string, label?: string) => { + await act(async () => { + testBed.find('editorSelectedFormatId').simulate('change', { target: { value } }); + }); + + testBed.component.update(); + }; + + /** + * Allows us to bypass the debounce time of 500ms before updating the preview. We also simulate + * a 2000ms latency when searching ES documents (see setup_environment.tsx). + */ + const waitForUpdates = async () => { + await act(async () => { + jest.runAllTimers(); + }); + + testBed.component.update(); + }; + + /** + * When often need to both wait for the documents to be fetched and + * the preview to be fetched. We can't increase the `jest.advanceTimersByTime` time + * as those are 2 different operations that occur in sequence. + */ + const waitForDocumentsAndPreviewUpdate = async () => { + // Wait for documents to be fetched + await act(async () => { + jest.runAllTimers(); + }); + + // Wait for preview to update + await act(async () => { + jest.runAllTimers(); + }); + testBed.component.update(); }; return { toggleFormRow, - changeFieldType, + waitForUpdates, + waitForDocumentsAndPreviewUpdate, + fields: { + updateName, + updateType, + updateScript, + updateFormat, + }, }; }; diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/index.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/index.ts index 9f6152abebcbc..6a1f1aa74036a 100644 --- a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/index.ts +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/index.ts @@ -8,6 +8,14 @@ export { findTestSubject, TestBed } from '@kbn/test/jest'; -export { setupEnvironment, WithFieldEditorDependencies } from './setup_environment'; +export { + setupEnvironment, + WithFieldEditorDependencies, + spySearchQuery, + spySearchQueryResponse, + spyIndexPatternGetAllFields, + fieldFormatsOptions, + indexPatternNameForTest, +} from './setup_environment'; export { getCommonActions } from './common_actions'; diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/jest.mocks.tsx b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/jest.mocks.tsx index fb98098e731dc..e291ec7b4ca08 100644 --- a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/jest.mocks.tsx +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/jest.mocks.tsx @@ -30,6 +30,16 @@ jest.mock('@elastic/eui', () => { }} /> ), + EuiResizeObserver: ({ + onResize, + children, + }: { + onResize(data: { height: number }): void; + children(): JSX.Element; + }) => { + onResize({ height: 1000 }); + return children(); + }, }; }); diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/mocks.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/mocks.ts new file mode 100644 index 0000000000000..8dfdd13e8338d --- /dev/null +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/mocks.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +interface PreviewErrorArgs { + reason: string; + scriptStack?: string[]; + position?: { offset: number; start: number; end: number } | null; +} + +export const createPreviewError = ({ + reason, + scriptStack = [], + position = null, +}: PreviewErrorArgs) => { + return { + caused_by: { reason }, + position, + script_stack: scriptStack, + }; +}; diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx index ff776140b688e..d87b49d35c68e 100644 --- a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx @@ -22,14 +22,24 @@ import { init as initHttpRequests } from './http_requests'; const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); const dataStart = dataPluginMock.createStartContract(); -const { search } = dataStart; +const { search, fieldFormats } = dataStart; -export const spySearchResult = jest.fn(); +export const spySearchQuery = jest.fn(); +export const spySearchQueryResponse = jest.fn(); +export const spyIndexPatternGetAllFields = jest.fn().mockImplementation(() => []); -search.search = () => - ({ - toPromise: spySearchResult, - } as any); +spySearchQuery.mockImplementation((params) => { + return { + toPromise: () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(undefined); + }, 2000); // simulate 2s latency for the HTTP request + }).then(() => spySearchQueryResponse()); + }, + }; +}); +search.search = spySearchQuery; let apiService: ApiService; @@ -44,29 +54,42 @@ export const setupEnvironment = () => { }; }; -export const indexPatternFields = [ - { - name: 'field1', - displayName: 'field1', - }, - { - name: 'field2', - displayName: 'field2', - }, - { - name: 'field3', - displayName: 'field3', - }, -]; +// The format options available in the dropdown select for our tests. +export const fieldFormatsOptions = [{ id: 'upper', title: 'UpperCaseString' } as any]; + +export const indexPatternNameForTest = 'testIndexPattern'; export const WithFieldEditorDependencies = ( Comp: FunctionComponent, overridingDependencies?: Partial ) => (props: T) => { + // Setup mocks + (fieldFormats.getByFieldType as jest.MockedFunction< + typeof fieldFormats['getByFieldType'] + >).mockReturnValue(fieldFormatsOptions); + + (fieldFormats.getDefaultType as jest.MockedFunction< + typeof fieldFormats['getDefaultType'] + >).mockReturnValue({ id: 'testDefaultFormat', title: 'TestDefaultFormat' } as any); + + (fieldFormats.getInstance as jest.MockedFunction< + typeof fieldFormats['getInstance'] + >).mockImplementation((id: string) => { + if (id === 'upper') { + return { + convertObject: { + html(value: string = '') { + return `${value.toUpperCase()}`; + }, + }, + } as any; + } + }); + const dependencies: Context = { indexPattern: { - title: 'testIndexPattern', - fields: { getAll: () => indexPatternFields }, + title: indexPatternNameForTest, + fields: { getAll: spyIndexPatternGetAllFields }, } as any, uiSettings: uiSettingsServiceMock.createStartContract(), fieldTypeToProcess: 'runtime', @@ -84,11 +107,7 @@ export const WithFieldEditorDependencies = [], getById: () => undefined, }, - fieldFormats: { - getDefaultInstance: () => ({ - convert: (val: any) => val, - }), - } as any, + fieldFormats, }; const mergedDependencies = merge({}, dependencies, overridingDependencies); diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx index 0f941687b2153..b46d587dc4146 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx @@ -239,7 +239,7 @@ const FieldEditorComponent = ({ field, onChange, onFormModifiedChange, syntaxErr
{/* Name */} diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx index f3756e0afac69..19015aa9d0d10 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx @@ -301,7 +301,12 @@ const FieldEditorFlyoutContentComponent = ({ {/* Preview panel */} {isPanelVisible && ( - + )} diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx index 580c5f55885f0..05f127c09c996 100644 --- a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx @@ -37,6 +37,7 @@ export interface Props { backgroundColor?: 'euiPageBackground' | 'euiEmptyShade'; /** Add a border to the panel */ border?: 'left' | 'right'; + 'data-test-subj'?: string; } export const Panel: React.FC> = ({ @@ -45,6 +46,7 @@ export const Panel: React.FC> = ({ className = '', backgroundColor, border, + 'data-test-subj': dataTestSubj, ...rest }) => { const [config, setConfig] = useState<{ hasFooter: boolean; hasContent: boolean }>({ @@ -116,7 +118,12 @@ export const Panel: React.FC> = ({ }, [width, addPanel]); return ( - +
{children} diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/documents_nav_preview.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/documents_nav_preview.tsx index c1c112b7ea197..60efdcd5927ac 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/documents_nav_preview.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/documents_nav_preview.tsx @@ -5,8 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React, { useCallback, useState, useEffect, useRef } from 'react'; -import useDebounce from 'react-use/lib/useDebounce'; +import React, { useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFlexGroup, @@ -21,15 +20,12 @@ import { useFieldPreviewContext } from './field_preview_context'; export const DocumentsNavPreview = () => { const { - currentDocument: { value: currentDocument, loadSingle, loadFromCluster, isLoading }, + currentDocument: { id: documentId, isCustomId }, + documents: { loadSingle, loadFromCluster }, navigation: { prev, next }, error, } = useFieldPreviewContext(); - const lastDocumentLoaded = useRef(null); - const [documentId, setDocumentId] = useState(''); - const [isCustomId, setIsCustomId] = useState(false); - const errorMessage = error !== null && error.code === 'DOC_NOT_FOUND' ? i18n.translate( @@ -45,40 +41,12 @@ export const DocumentsNavPreview = () => { // document ID as at that point there is no more reference to what's "next" const showNavButtons = isCustomId === false; - const onDocumentIdChange = useCallback((e: React.SyntheticEvent) => { - setIsCustomId(true); - const nextId = e.currentTarget.value; - setDocumentId(nextId); - }, []); - - const loadDocFromCluster = useCallback(() => { - lastDocumentLoaded.current = null; - setIsCustomId(false); - loadFromCluster(); - }, [loadFromCluster]); - - useEffect(() => { - if (currentDocument && !isCustomId) { - setDocumentId(currentDocument._id); - } - }, [currentDocument, isCustomId]); - - useDebounce( - () => { - if (!isCustomId || !Boolean(documentId.trim())) { - return; - } - - if (lastDocumentLoaded.current === documentId) { - return; - } - - lastDocumentLoaded.current = documentId; - - loadSingle(documentId); + const onDocumentIdChange = useCallback( + (e: React.SyntheticEvent) => { + const nextId = (e.target as HTMLInputElement).value; + loadSingle(nextId); }, - 500, - [documentId, isCustomId] + [loadSingle] ); return ( @@ -97,14 +65,19 @@ export const DocumentsNavPreview = () => { isInvalid={isInvalid} value={documentId} onChange={onDocumentIdChange} - isLoading={isLoading} fullWidth data-test-subj="documentIdField" /> {isCustomId && ( - + loadFromCluster()} + data-test-subj="loadDocsFromClusterButton" + > {i18n.translate( 'indexPatternFieldEditor.fieldPreview.documentIdField.loadDocumentsFromCluster', { @@ -117,7 +90,7 @@ export const DocumentsNavPreview = () => { {showNavButtons && ( - + { size="m" onClick={prev} iconType="arrowLeft" + data-test-subj="goToPrevDocButton" aria-label={i18n.translate( 'indexPatternFieldEditor.fieldPreview.documentNav.previousArialabel', { @@ -139,6 +113,7 @@ export const DocumentsNavPreview = () => { size="m" onClick={next} iconType="arrowRight" + data-test-subj="goToNextDocButton" aria-label={i18n.translate( 'indexPatternFieldEditor.fieldPreview.documentNav.nextArialabel', { diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx index 678e8d339df10..1cab591cb4a92 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx @@ -45,7 +45,7 @@ function escapeRegExp(text: string) { function fuzzyMatch(searchValue: string, text: string) { const pattern = `.*${searchValue.split('').map(escapeRegExp).join('.*')}.*`; - const regex = new RegExp(pattern); + const regex = new RegExp(pattern, 'i'); return regex.test(text); } @@ -164,7 +164,7 @@ export const PreviewFieldList: React.FC = ({ height, clearSearch, searchV } titleSize="xs" actions={ - + {i18n.translate( 'indexPatternFieldEditor.fieldPreview.searchResult.emptyPrompt.clearSearchButtonLabel', { @@ -214,7 +214,7 @@ export const PreviewFieldList: React.FC = ({ height, clearSearch, searchV const field = filteredFields[index]; return ( -
+
= ({ return ( - {value} + {JSON.stringify(value)} ); }; return ( <> - + -
{key}
+
+ {key} +
- + = ({ }} color="text" iconType="pinFilled" + data-test-subj="pinFieldButton" aria-label={i18n.translate( 'indexPatternFieldEditor.fieldPreview.pinFieldButtonLabel', { diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx index ee5926b9a040a..05258cfbf85ed 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx @@ -25,9 +25,8 @@ export const FieldPreview = () => { const { params: { - value: { name, script }, + value: { name, script, format }, }, - previewCount, fields, error, reset, @@ -36,13 +35,14 @@ export const FieldPreview = () => { // To show the preview we at least need a name to be defined, the script or the format // and an first response from the _execute API const isEmptyPromptVisible = - name === null && script === null + name === null && script === null && format === null ? true - : // We have a response from the preview + : // If we have some result from the _execute API call don't show the empty prompt error !== null || fields.length > 0 ? false - : // We leave it on until we have at least called once the _execute API - previewCount === 0; + : name === null && format === null + ? true + : false; const onFieldListResize = useCallback(({ height }: { height: number }) => { setFieldListHeight(height); @@ -57,7 +57,7 @@ export const FieldPreview = () => { return (
    -
  • +
@@ -92,6 +92,7 @@ export const FieldPreview = () => { } )} fullWidth + data-test-subj="filterFieldsInput" /> diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx index 88cffd9f3c361..a20f735f9c3c0 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx @@ -26,7 +26,10 @@ import { RuntimeType, RuntimeField } from '../../shared_imports'; import { useFieldEditorContext } from '../field_editor_context'; type From = 'cluster' | 'custom'; -type EsDocument = Record; +interface EsDocument { + _id: string; + [key: string]: any; +} interface PreviewError { code: 'DOC_NOT_FOUND' | 'PAINLESS_SCRIPT_ERROR' | 'ERR_FETCHING_DOC'; @@ -50,15 +53,13 @@ interface Params { export interface FieldPreview { key: string; - value: string; + value: unknown; formattedValue?: string; } interface Context { fields: FieldPreview[]; error: PreviewError | null; - // The preview count will help us decide when to display the empty prompt - previewCount: number; params: { value: Params; update: (updated: Partial) => void; @@ -66,9 +67,13 @@ interface Context { isLoadingPreview: boolean; currentDocument: { value?: EsDocument; - loadSingle: (id: string) => Promise; - loadFromCluster: () => Promise; + id: string; isLoading: boolean; + isCustomId: boolean; + }; + documents: { + loadSingle: (id: string) => void; + loadFromCluster: () => Promise; }; panel: { isVisible: boolean; @@ -103,6 +108,16 @@ export const defaultValueFormatter = (value: unknown) => export const FieldPreviewProvider: FunctionComponent = ({ children }) => { const previewCount = useRef(0); + const [lastExecutePainlessRequestParams, setLastExecutePainlessReqParams] = useState<{ + type: Params['type']; + script: string | undefined; + documentId: string | undefined; + }>({ + type: null, + script: undefined, + documentId: undefined, + }); + const { indexPattern, fieldTypeToProcess, @@ -132,31 +147,48 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { const [isFetchingDocument, setIsFetchingDocument] = useState(false); /** Flag to indicate if we are calling the _execute API */ const [isLoadingPreview, setIsLoadingPreview] = useState(false); + /** Flag to indicate if we are loading a single document by providing its ID */ + const [customDocIdToLoad, setCustomDocIdToLoad] = useState(null); /** Define if we provide the document to preview from the cluster or from a custom JSON */ const [from, setFrom] = useState('cluster'); const { documents, currentIdx } = clusterData; - const currentDocument: Record | undefined = useMemo(() => documents[currentIdx], [ + const currentDocument: EsDocument | undefined = useMemo(() => documents[currentIdx], [ documents, currentIdx, ]); const currentDocIndex = currentDocument?._index; + const currentDocId: string = currentDocument?._id ?? ''; const totalDocs = documents.length; - const { name, document, script, format } = params; + const { name, document, script, format, type } = params; const updateParams: Context['params']['update'] = useCallback((updated) => { setParams((prev) => ({ ...prev, ...updated })); }, []); - const allParamsDefined = useCallback( - () => - Object.entries(params) - // We don't need the "name" or "format" information for the _execute API - .filter(([key]) => key !== 'name' && key !== 'format') - .every(([_, value]) => Boolean(value)), - [params] - ); + const needToUpdatePreview = useMemo(() => { + const isCurrentDocIdDefined = currentDocId !== ''; + + if (!isCurrentDocIdDefined) { + return false; + } + + const allParamsDefined = (['type', 'script', 'index', 'document'] as Array< + keyof Params + >).every((key) => Boolean(params[key])); + + if (!allParamsDefined) { + return false; + } + + const hasSomeParamsChanged = + lastExecutePainlessRequestParams.type !== type || + lastExecutePainlessRequestParams.script !== script?.source || + lastExecutePainlessRequestParams.documentId !== currentDocId; + + return hasSomeParamsChanged; + }, [type, script?.source, currentDocId, params, lastExecutePainlessRequestParams]); const valueFormatter = useCallback( (value: unknown) => { @@ -173,10 +205,20 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { ); const fetchSampleDocuments = useCallback( - async (limit = 50) => { + async (limit: number = 50) => { + if (typeof limit !== 'number') { + // We guard ourself from passing an event accidentally + throw new Error('The "limit" option must be a number'); + } + setIsFetchingDocument(true); + setClusterData({ + documents: [], + currentIdx: 0, + }); + setPreviewResponse({ fields: [], error: null }); - const response = await search + const [response, error] = await search .search({ params: { index: indexPattern.title, @@ -185,24 +227,32 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { }, }, }) - .toPromise(); + .toPromise() + .then((res) => [res, null]) + .catch((err) => [null, err]); setIsFetchingDocument(false); + setCustomDocIdToLoad(null); - setPreviewResponse({ fields: [], error: null }); setClusterData({ documents: response ? response.rawResponse.hits.hits : [], currentIdx: 0, }); + + setPreviewResponse((prev) => ({ ...prev, error })); }, [indexPattern, search] ); const loadDocument = useCallback( async (id: string) => { + if (!Boolean(id.trim())) { + return; + } + setIsFetchingDocument(true); - const [response, error] = await search + const [response, searchError] = await search .search({ params: { index: indexPattern.title, @@ -222,56 +272,57 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { setIsFetchingDocument(false); - let loadedDocuments: EsDocument[] = []; - - if (response) { - if (response.rawResponse.hits.total > 0) { - setPreviewResponse({ fields: [], error: null }); - loadedDocuments = response.rawResponse.hits.hits; - } else { - setPreviewResponse({ - fields: [], + const isDocumentFound = response?.rawResponse.hits.total > 0; + const loadedDocuments: EsDocument[] = isDocumentFound ? response.rawResponse.hits.hits : []; + const error: Context['error'] = Boolean(searchError) + ? { + code: 'ERR_FETCHING_DOC', error: { - code: 'DOC_NOT_FOUND', - error: { - message: i18n.translate( - 'indexPatternFieldEditor.fieldPreview.error.documentNotFoundDescription', - { - defaultMessage: - 'Error previewing the field as the document provided was not found.', - } - ), - }, + message: searchError.toString(), }, - }); - } - } else if (error) { - // TODO: improve this error handling when there is a server - // error fetching a document - setPreviewResponse({ - fields: [], - error: { - code: 'ERR_FETCHING_DOC', + } + : isDocumentFound === false + ? { + code: 'DOC_NOT_FOUND', error: { - message: error.toString(), + message: i18n.translate( + 'indexPatternFieldEditor.fieldPreview.error.documentNotFoundDescription', + { + defaultMessage: + 'Error previewing the field as the document provided was not found.', + } + ), }, - }, - }); - } + } + : null; + + setPreviewResponse((prev) => ({ ...prev, error })); setClusterData({ documents: loadedDocuments, currentIdx: 0, }); + + if (error !== null) { + // Make sure we disable the "Updating..." indicator as we have an error + // and we won't fetch the preview + setIsLoadingPreview(false); + } }, [indexPattern, search] ); const updatePreview = useCallback(async () => { - if (fieldTypeToProcess !== 'runtime' || !allParamsDefined()) { + if (!needToUpdatePreview) { return; } + setLastExecutePainlessReqParams({ + type: params.type, + script: params.script?.source, + documentId: currentDocId, + }); + const currentApiCall = ++previewCount.current; const response = await getFieldPreview({ @@ -301,8 +352,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { return; } - const data = response.data ?? { values: [], error: {} }; - const { values, error } = data; + const { values, error } = response.data ?? { values: [], error: {} }; if (error) { const fallBackError = { @@ -320,15 +370,15 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { const formattedValue = valueFormatter(value); setPreviewResponse({ - fields: [{ key: params.name!, value: JSON.stringify(value), formattedValue }], + fields: [{ key: params.name!, value, formattedValue }], error: null, }); } }, [ - fieldTypeToProcess, - allParamsDefined, + needToUpdatePreview, params, currentDocIndex, + currentDocId, getFieldPreview, notifications.toasts, valueFormatter, @@ -359,8 +409,13 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { documents: [], currentIdx: 0, }); - setFrom('cluster'); setPreviewResponse({ fields: [], error: null }); + setLastExecutePainlessReqParams({ + type: null, + script: undefined, + documentId: undefined, + }); + setFrom('cluster'); setIsLoadingPreview(false); setIsFetchingDocument(false); }, []); @@ -370,16 +425,19 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { fields: previewResponse.fields, error: previewResponse.error, isLoadingPreview, - previewCount: previewCount.current, params: { value: params, update: updateParams, }, currentDocument: { value: currentDocument, - loadSingle: loadDocument, - loadFromCluster: fetchSampleDocuments, + id: customDocIdToLoad !== null ? customDocIdToLoad : currentDocId, isLoading: isFetchingDocument, + isCustomId: customDocIdToLoad !== null, + }, + documents: { + loadSingle: setCustomDocIdToLoad, + loadFromCluster: fetchSampleDocuments, }, navigation: { isFirstDoc: currentIdx === 0, @@ -403,9 +461,10 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { isLoadingPreview, updateParams, currentDocument, - loadDocument, + currentDocId, fetchSampleDocuments, isFetchingDocument, + customDocIdToLoad, currentIdx, totalDocs, goToNextDoc, @@ -416,40 +475,55 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { ] ); - useDebounce( - // Whenever updatePreview() changes (meaning whenever any of the params changes) - // we call it to update the preview response with the field(s) value or possible error. - updatePreview, - 500, - [updatePreview] - ); - /** * In order to immediately display the "Updating..." state indicator and not have to wait - * the 500ms of the debounce, we set the loading state in this effect + * the 500ms of the debounce, we set the isLoadingPreview state in this effect */ useEffect(() => { - if (fieldTypeToProcess !== 'runtime' || !allParamsDefined()) { - return; + if (needToUpdatePreview) { + setIsLoadingPreview(true); } + }, [needToUpdatePreview, customDocIdToLoad]); - setIsLoadingPreview(true); - }, [fieldTypeToProcess, allParamsDefined]); + /** + * Whenever we enter manually a document ID to load we'll clear the + * documents and the preview value. + */ + useEffect(() => { + if (customDocIdToLoad !== null) { + setIsFetchingDocument(true); + + setClusterData({ + documents: [], + currentIdx: 0, + }); + + setPreviewResponse((prev) => { + const { + fields: { 0: field }, + } = prev; + return { + ...prev, + fields: [ + { ...field, value: undefined, formattedValue: defaultValueFormatter(undefined) }, + ], + }; + }); + } + }, [customDocIdToLoad]); /** - * When the component mounts, if we are creating/editing a runtime field - * we fetch sample documents from the cluster to be able to preview the runtime - * field along with other document fields + * Whenever we show the preview panel we will update the documents from the cluster */ useEffect(() => { - if (isPanelVisible && fieldTypeToProcess === 'runtime') { + if (isPanelVisible) { fetchSampleDocuments(); } }, [isPanelVisible, fetchSampleDocuments, fieldTypeToProcess]); /** * Each time the current document changes we update the parameters - * for the Painless _execute API call. + * that will be sent in the _execute HTTP request. */ useEffect(() => { updateParams({ @@ -458,28 +532,48 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { }); }, [currentDocument, updateParams]); + /** + * Whenever the name or the format changes we immediately update the preview + */ useEffect(() => { - if (document) { - // We have a field name, a document loaded but no script (the set value toggle is - // either turned off or we have a blank script). If we have a format then we'll - // preview the field with the format by reading the value from _source - if (name && script === null) { - const nextValue = get(document, name); - const formattedValue = valueFormatter(nextValue); - - setPreviewResponse({ - fields: [{ key: name, value: nextValue, formattedValue }], - error: null, - }); - } else { - // We immediately update the field preview whenever the name changes - setPreviewResponse(({ fields: { 0: field } }) => ({ - fields: [{ ...field, key: name ?? '' }], - error: null, - })); + setPreviewResponse((prev) => { + const { + fields: { 0: field }, + } = prev; + + const nextValue = + script === null && Boolean(document) + ? get(document, name ?? '') // When there is no script we read the value from _source + : field?.value; + + const formattedValue = valueFormatter(nextValue); + + return { + ...prev, + fields: [{ ...field, key: name ?? '', value: nextValue, formattedValue }], + }; + }); + }, [name, script, document, valueFormatter]); + + useDebounce( + // Whenever updatePreview() changes (meaning whenever any of the params changes) + // we call it to update the preview response with the field(s) value or possible error. + updatePreview, + 500, + [updatePreview] + ); + + useDebounce( + () => { + if (customDocIdToLoad === null) { + return; } - } - }, [name, document, script, format, valueFormatter]); + + loadDocument(customDocIdToLoad); + }, + 500, + [customDocIdToLoad] + ); return {children}; }; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_empty_prompt.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_empty_prompt.tsx index 06eaa7f3efc85..c809142bf10f3 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_empty_prompt.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_empty_prompt.tsx @@ -12,7 +12,7 @@ import { EuiEmptyPrompt, EuiText, EuiTextColor, EuiFlexGroup, EuiFlexItem } from export const FieldPreviewEmptyPrompt = () => { return ( - + { })} color="danger" iconType="cross" - data-test-subj="formFormatError" + data-test-subj="previewError" > -

{error.error.message}

- {error.code === 'PAINLESS_SCRIPT_ERROR' &&

{error.error.reason}

} +

{error.error.message}

+ {error.code === 'PAINLESS_SCRIPT_ERROR' && ( +

{error.error.reason}

+ )} ); }; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_header.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_header.tsx index 40a9f475093f0..2d3d5c20ba7b3 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_header.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_header.tsx @@ -47,12 +47,12 @@ export const FieldPreviewHeader = () => { -

{i18nTexts.title}

+

{i18nTexts.title}

{isUpdating && ( - + @@ -63,7 +63,7 @@ export const FieldPreviewHeader = () => { )} - + {i18n.translate('indexPatternFieldEditor.fieldPreview.subTitle', { defaultMessage: 'From: {from}', values: {