From 2fd16e79877f7c0e544698cf6974fe11ca6d55b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Fri, 16 Jul 2021 19:19:55 +0100 Subject: [PATCH 01/11] Add test to show/hide preview panel and the empty prompt --- .../client_integration/field_editor.test.tsx | 16 ++- .../field_editor_flyout_content.test.ts | 97 ++++++++++++++++--- .../helpers/common_actions.ts | 43 +++++++- .../client_integration/helpers/index.ts | 6 +- .../helpers/setup_environment.tsx | 19 ++-- .../components/field_editor/field_editor.tsx | 2 +- .../field_editor_flyout_content.tsx | 7 +- .../components/flyout_panels/flyout_panel.tsx | 9 +- .../preview/field_preview_context.tsx | 6 +- .../preview/field_preview_empty_prompt.tsx | 2 +- 10 files changed, 164 insertions(+), 43 deletions(-) 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.test.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts index a4c03131bc3b6..adb38f2261659 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 @@ -8,7 +8,7 @@ import { act } from 'react-dom/test-utils'; import type { Props } from '../../public/components/field_editor_flyout_content'; -import { setupEnvironment } from './helpers'; +import { setupEnvironment, spySearchResult } from './helpers'; import { setup } from './field_editor_flyout_content.helpers'; describe('', () => { @@ -114,20 +114,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 +141,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'); @@ -164,4 +156,81 @@ describe('', () => { }); }); }); + + describe('preview panel', () => { + const mockDocuments = [ + { + _id: '123', + _source: { + title: 'foo', + }, + }, + { + _id: '456', + _source: { + title: 'bar', + }, + }, + ]; + + beforeEach(() => { + spySearchResult.mockResolvedValue({ + rawResponse: { + hits: { + total: mockDocuments.length, + hits: mockDocuments, + }, + }, + }); + }); + + test('should display the preview panel when either "set value" or "set format" is activated', async () => { + const { + exists, + actions: { toggleFormRow }, + } = await setup(); + + 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 display an empty prompt if no name and no script are defined', async () => { + const { + exists, + actions: { toggleFormRow, fields, waitForPreviewUpdate }, + } = await setup(); + + await toggleFormRow('value'); + expect(exists('previewPanel')).toBe(true); + expect(exists('previewPanel.emptyPrompt')).toBe(true); + + await fields.updateName('someName'); + await waitForPreviewUpdate(); + expect(exists('previewPanel.emptyPrompt')).toBe(false); + + await fields.updateName(' '); + await waitForPreviewUpdate(); + expect(exists('previewPanel.emptyPrompt')).toBe(true); + + // The name is empty and the empty prompt is there, let's add some script... + await fields.updateScript('echo("hello")'); + await waitForPreviewUpdate(); + expect(exists('previewPanel.emptyPrompt')).toBe(false); + + await fields.updateScript(' '); + await waitForPreviewUpdate(); + expect(exists('previewPanel.emptyPrompt')).toBe(true); + }); + }); }); 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..7674e593f913c 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,27 @@ 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); + }); + }; + + const updateScript = async (value: string) => { + await act(async () => { + testBed.form.setInputValue('scriptField', value); + }); }; - const changeFieldType = async (value: string, label?: string) => { + const updateType = async (value: string, label?: string) => { await act(async () => { testBed.find('typeField').simulate('change', [ { @@ -33,8 +53,23 @@ export const getCommonActions = (testBed: TestBed) => { testBed.component.update(); }; + // The preview updates on a debounce of 500ms whenever + // a parameter changes (script, type) + const waitForPreviewUpdate = async () => { + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + testBed.component.update(); + }; + return { toggleFormRow, - changeFieldType, + waitForPreviewUpdate, + fields: { + updateName, + updateType, + updateScript, + }, }; }; 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..435c32c5897d4 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,10 @@ export { findTestSubject, TestBed } from '@kbn/test/jest'; -export { setupEnvironment, WithFieldEditorDependencies } from './setup_environment'; +export { + setupEnvironment, + WithFieldEditorDependencies, + spySearchResult, +} from './setup_environment'; export { getCommonActions } from './common_actions'; 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..26278a3b43d55 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,7 +22,7 @@ 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(); @@ -59,10 +59,21 @@ export const indexPatternFields = [ }, ]; +export const fieldFormatsOptions = [{ id: 'upper', title: 'UpperCaseString' } as any]; + 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); + const dependencies: Context = { indexPattern: { title: 'testIndexPattern', @@ -84,11 +95,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/field_preview_context.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx index 88cffd9f3c361..1be992c4aa001 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 @@ -429,11 +429,9 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { * the 500ms of the debounce, we set the loading state in this effect */ useEffect(() => { - if (fieldTypeToProcess !== 'runtime' || !allParamsDefined()) { - return; + if (fieldTypeToProcess === 'runtime' && allParamsDefined()) { + setIsLoadingPreview(true); } - - setIsLoadingPreview(true); }, [fieldTypeToProcess, allParamsDefined]); /** 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 ( - + Date: Mon, 19 Jul 2021 17:10:53 +0100 Subject: [PATCH 02/11] Add test to filter fields, pin fields, field value and format --- .../field_editor_flyout_content.helpers.ts | 43 --- .../field_editor_flyout_content.helpers.tsx | 128 +++++++++ .../field_editor_flyout_content.test.ts | 272 +++++++++++++++++- .../helpers/common_actions.ts | 14 + .../client_integration/helpers/index.ts | 2 + .../client_integration/helpers/jest.mocks.tsx | 10 + .../helpers/setup_environment.tsx | 32 +-- .../preview/field_list/field_list.tsx | 6 +- .../preview/field_list/field_list_item.tsx | 15 +- .../components/preview/field_preview.tsx | 3 +- 10 files changed, 444 insertions(+), 81 deletions(-) delete mode 100644 src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts create mode 100644 src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.tsx 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 deleted file mode 100644 index b73c400b751d2..0000000000000 --- a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * 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 { registerTestBed, TestBed } from '@kbn/test/jest'; - -import { Context } from '../../public/components/field_editor_context'; -import { - FieldEditorFlyoutContent, - Props, -} from '../../public/components/field_editor_flyout_content'; -import { WithFieldEditorDependencies, getCommonActions } from './helpers'; - -const defaultProps: Props = { - onSave: () => {}, - onCancel: () => {}, - runtimeFieldValidator: () => Promise.resolve(null), - isSavingField: false, -}; - -export const setup = async (props?: Partial, deps?: Partial) => { - let testBed: TestBed; - - await act(async () => { - testBed = await registerTestBed(WithFieldEditorDependencies(FieldEditorFlyoutContent, deps), { - memoryRouter: { - wrapComponent: false, - }, - })({ ...defaultProps, ...props }); - }); - - testBed!.component.update(); - - const actions = { - ...getCommonActions(testBed!), - }; - - return { ...testBed!, actions }; -}; diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.tsx b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.tsx new file mode 100644 index 0000000000000..261e5c665a9d7 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.tsx @@ -0,0 +1,128 @@ +/* + * 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 { SinonFakeServer } from 'sinon'; +import { registerTestBed, TestBed } from '@kbn/test/jest'; + +import { Context } from '../../public/components/field_editor_context'; +import { + FieldEditorFlyoutContent, + Props, +} from '../../public/components/field_editor_flyout_content'; +import { + WithFieldEditorDependencies, + getCommonActions, + spyIndexPatternGetAllFields, +} from './helpers'; + +const defaultProps: Props = { + onSave: () => {}, + onCancel: () => {}, + runtimeFieldValidator: () => Promise.resolve(null), + isSavingField: false, +}; + +export const setIndexPatternFields = (fields: Array<{ name: string; displayName: string }>) => { + spyIndexPatternGetAllFields.mockReturnValue(fields); +}; + +const getActions = (testBed: TestBed) => { + // Add TS overload function types + function getRenderedIndexPatternFields(returnWrappers?: boolean): ReactWrapper; + function getRenderedIndexPatternFields( + returnWrappers?: boolean + ): Array<{ key: string; value: string }>; + + function getRenderedIndexPatternFields(returnWrappers = false) { + if (testBed.find('indexPatternFieldList').length === 0) { + return []; + } + + const allFields = testBed.find('indexPatternFieldList.listItem'); + + if (returnWrappers === true) { + return allFields; + } + + return allFields.map((field) => { + const key = testBed.find('key', field).text(); + const value = testBed.find('value', field).text(); + return { key, value }; + }); + } + + function 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(); + }; + + const getLatestPreviewHttpRequest = (server: SinonFakeServer) => { + let i = server.requests.length - 1; + + while (i >= 0) { + const request = server.requests[i]; + if ( + request.method === 'POST' && + request.url === '/api/index_pattern_field_editor/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.`); + }; + + return { + ...getCommonActions(testBed), + getRenderedIndexPatternFields, + getRenderedFieldsPreview, + setFilterFieldsValue, + getLatestPreviewHttpRequest, + }; +}; + +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_content.test.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts index adb38f2261659..b7ea1c5a463dd 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 @@ -8,13 +8,18 @@ import { act } from 'react-dom/test-utils'; import type { Props } from '../../public/components/field_editor_flyout_content'; -import { setupEnvironment, spySearchResult } from './helpers'; -import { setup } from './field_editor_flyout_content.helpers'; +import { setupEnvironment, spySearchResult, fieldFormatsOptions } from './helpers'; +import { + setup, + setIndexPatternFields, + FieldEditorFlyoutContentTestBed, +} from './field_editor_flyout_content.helpers'; describe('', () => { const { server, httpRequestsMockHelpers } = setupEnvironment(); beforeAll(() => { + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['foo'] }); jest.useFakeTimers(); }); @@ -23,10 +28,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); @@ -158,22 +159,62 @@ describe('', () => { }); describe('preview panel', () => { - const mockDocuments = [ + let testBed: FieldEditorFlyoutContentTestBed; + + interface TestDoc { + title: string; + subTitle: string; + description: string; + } + + const mockDocuments: Array<{ _id: string; _index: string; _source: TestDoc }> = [ { - _id: '123', + _id: '001', + _index: 'testIndex', _source: { - title: 'foo', + title: 'First doc - title', + subTitle: 'First doc - subTitle', + description: 'First doc - description', }, }, { - _id: '456', + _id: '002', + _index: 'testIndex', _source: { - title: 'bar', + 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', }, }, ]; - beforeEach(() => { + const indexPatternFields: Array<{ name: string; displayName: string }> = [ + { + name: 'title', + displayName: 'title', + }, + { + name: 'subTitle', + displayName: 'subTitle', + }, + { + name: 'description', + displayName: 'description', + }, + ]; + + beforeEach(async () => { + setIndexPatternFields(indexPatternFields); + spySearchResult.mockResolvedValue({ rawResponse: { hits: { @@ -182,13 +223,15 @@ describe('', () => { }, }, }); + + testBed = await setup(); }); test('should display the preview panel when either "set value" or "set format" is activated', async () => { const { exists, actions: { toggleFormRow }, - } = await setup(); + } = testBed; expect(exists('previewPanel')).toBe(false); @@ -209,7 +252,7 @@ describe('', () => { const { exists, actions: { toggleFormRow, fields, waitForPreviewUpdate }, - } = await setup(); + } = testBed; await toggleFormRow('value'); expect(exists('previewPanel')).toBe(true); @@ -223,7 +266,7 @@ describe('', () => { await waitForPreviewUpdate(); expect(exists('previewPanel.emptyPrompt')).toBe(true); - // The name is empty and the empty prompt is there, let's add some script... + // The name is empty and the empty prompt is displayed, let's now add a script... await fields.updateScript('echo("hello")'); await waitForPreviewUpdate(); expect(exists('previewPanel.emptyPrompt')).toBe(false); @@ -232,5 +275,204 @@ describe('', () => { await waitForPreviewUpdate(); expect(exists('previewPanel.emptyPrompt')).toBe(true); }); + + test('should list the list of fields of the index pattern', async () => { + const { + actions: { toggleFormRow, fields, getRenderedIndexPatternFields }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + + 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 }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + + // 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, getRenderedIndexPatternFields }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + + const fieldsRendered = getRenderedIndexPatternFields(true); + expect(fieldsRendered.length).toBe(3); + // 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('key & value', () => { + test('should set an empty value when no script is provided', async () => { + const { + actions: { toggleFormRow, fields, getRenderedFieldsPreview }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + + expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: '-' }]); + }); + + 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 }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('subTitle'); + + expect(getRenderedFieldsPreview()).toEqual([ + { key: 'subTitle', value: 'First doc - subTitle' }, + ]); + }); + + 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, + waitForPreviewUpdate, + getLatestPreviewHttpRequest, + getRenderedFieldsPreview, + }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await fields.updateScript('echo("hello")'); + await waitForPreviewUpdate(); + + 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 }, + ]); + }); + }); + + describe('format', () => { + test('should apply the format to the value', async () => { + /** + * Each of the formatter has already its own test. Here we are basically + * 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, waitForPreviewUpdate, getRenderedFieldsPreview }, + } = testBed; + + await fields.updateName('myRuntimeField'); + await toggleFormRow('value'); + await fields.updateScript('echo("hello")'); + await waitForPreviewUpdate(); + + // before + expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'hello' }]); + + // after + await toggleFormRow('format'); + await fields.updateFormat(fieldFormatsOptions[0].id); + await waitForPreviewUpdate(); + expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'HELLO' }]); + }); + }); }); }); 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 7674e593f913c..8967f54ba7e30 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 @@ -33,12 +33,16 @@ export const getCommonActions = (testBed: TestBed) => { await act(async () => { testBed.form.setInputValue('nameField.input', value); }); + + testBed.component.update(); }; const updateScript = async (value: string) => { await act(async () => { testBed.form.setInputValue('scriptField', value); }); + + testBed.component.update(); }; const updateType = async (value: string, label?: string) => { @@ -50,6 +54,15 @@ 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(); }; @@ -70,6 +83,7 @@ export const getCommonActions = (testBed: TestBed) => { 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 435c32c5897d4..b738eaf9c6c5d 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 @@ -12,6 +12,8 @@ export { setupEnvironment, WithFieldEditorDependencies, spySearchResult, + spyIndexPatternGetAllFields, + fieldFormatsOptions, } 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/setup_environment.tsx b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx index 26278a3b43d55..07c0d323c91ef 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 @@ -25,6 +25,7 @@ const dataStart = dataPluginMock.createStartContract(); const { search, fieldFormats } = dataStart; export const spySearchResult = jest.fn(); +export const spyIndexPatternGetAllFields = jest.fn().mockImplementation(() => []); search.search = () => ({ @@ -44,21 +45,6 @@ export const setupEnvironment = () => { }; }; -export const indexPatternFields = [ - { - name: 'field1', - displayName: 'field1', - }, - { - name: 'field2', - displayName: 'field2', - }, - { - name: 'field3', - displayName: 'field3', - }, -]; - export const fieldFormatsOptions = [{ id: 'upper', title: 'UpperCaseString' } as any]; export const WithFieldEditorDependencies = ( @@ -74,10 +60,24 @@ export const WithFieldEditorDependencies = ).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) { + return `${value.toUpperCase()}`; + }, + }, + }; + } + }); + const dependencies: Context = { indexPattern: { title: 'testIndexPattern', - fields: { getAll: () => indexPatternFields }, + fields: { getAll: spyIndexPatternGetAllFields }, } as any, uiSettings: uiSettingsServiceMock.createStartContract(), fieldTypeToProcess: 'runtime', 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 ( <> - + -
{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..48c4014c7c60a 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 @@ -57,7 +57,7 @@ export const FieldPreview = () => { return (
    -
  • +
@@ -92,6 +92,7 @@ export const FieldPreview = () => { } )} fullWidth + data-test-subj="filterFieldsInput" /> From ff8a58b59530c79bb70670bc97ef8d8e4b2a8b9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Tue, 20 Jul 2021 13:08:51 +0100 Subject: [PATCH 03/11] Add errorHandling, updating indicator tests --- .../field_editor_flyout_content.helpers.tsx | 8 +- .../field_editor_flyout_content.test.ts | 123 ++++++++++++++++-- .../client_integration/helpers/mocks.ts | 25 ++++ .../helpers/setup_environment.tsx | 4 +- .../preview/field_list/field_list_item.tsx | 2 +- .../preview/field_preview_context.tsx | 88 ++++++++++--- .../preview/field_preview_error.tsx | 8 +- .../preview/field_preview_header.tsx | 2 +- 8 files changed, 215 insertions(+), 45 deletions(-) create mode 100644 src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/mocks.ts diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.tsx b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.tsx index 261e5c665a9d7..b9e7ad871b169 100644 --- a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.tsx +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.tsx @@ -10,6 +10,7 @@ import { ReactWrapper } from 'enzyme'; import { SinonFakeServer } from 'sinon'; import { registerTestBed, TestBed } from '@kbn/test/jest'; +import { API_BASE_PATH } from '../../common/constants'; import { Context } from '../../public/components/field_editor_context'; import { FieldEditorFlyoutContent, @@ -84,10 +85,7 @@ const getActions = (testBed: TestBed) => { while (i >= 0) { const request = server.requests[i]; - if ( - request.method === 'POST' && - request.url === '/api/index_pattern_field_editor/field_preview' - ) { + if (request.method === 'POST' && request.url === `${API_BASE_PATH}/field_preview`) { return { ...request, requestBody: JSON.parse(JSON.parse(request.requestBody).body), @@ -96,7 +94,7 @@ const getActions = (testBed: TestBed) => { i--; } - throw new Error(`Can't access the latest preview http request as it hasn't been called.`); + throw new Error(`Can't access the latest preview HTTP request as it hasn't been called.`); }; return { 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 b7ea1c5a463dd..2c19b400966d7 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 @@ -14,6 +14,7 @@ import { setIndexPatternFields, FieldEditorFlyoutContentTestBed, } from './field_editor_flyout_content.helpers'; +import { createPreviewError } from './helpers/mocks'; describe('', () => { const { server, httpRequestsMockHelpers } = setupEnvironment(); @@ -388,17 +389,45 @@ describe('', () => { expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: '-' }]); }); - 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 }, - } = testBed; + 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 }, + } = testBed; - await toggleFormRow('value'); - await fields.updateName('subTitle'); + await toggleFormRow('value'); + await fields.updateName('subTitle'); - expect(getRenderedFieldsPreview()).toEqual([ - { key: 'subTitle', value: 'First doc - subTitle' }, - ]); + 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, waitForPreviewUpdate, getRenderedFieldsPreview }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('description'); // Field name is a field in _source + await fields.updateScript('echo("hello")'); + await waitForPreviewUpdate(); + + // 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' }, + ]); + }); }); test('should set the value returned by the painless _execute API', async () => { @@ -441,16 +470,54 @@ describe('', () => { { key: 'myRuntimeField', value: scriptEmitResponse }, ]); }); + + test('should display an updating indicator while fetching the preview', async () => { + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['ok'] }); + + const { + exists, + actions: { toggleFormRow, fields, waitForPreviewUpdate }, + } = testBed; + + await toggleFormRow('value'); + expect(exists('isUpdatingIndicator')).toBe(false); + + await fields.updateScript('echo("hello")'); + expect(exists('isUpdatingIndicator')).toBe(true); + + await waitForPreviewUpdate(); + 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, waitForPreviewUpdate }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await fields.updateScript('echo("hello")'); + expect(exists('isUpdatingIndicator')).toBe(true); + await waitForPreviewUpdate(); + 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('format', () => { test('should apply the format to the value', async () => { /** - * Each of the formatter has already its own test. Here we are basically + * 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. + * the fieldFormats.getInstance() handler. */ const scriptEmitResponse = 'hello'; httpRequestsMockHelpers.setFieldPreviewResponse({ values: [scriptEmitResponse] }); @@ -469,10 +536,42 @@ describe('', () => { // after await toggleFormRow('format'); - await fields.updateFormat(fieldFormatsOptions[0].id); + await fields.updateFormat(fieldFormatsOptions[0].id); // select 'upper' format await waitForPreviewUpdate(); 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, waitForPreviewUpdate, getRenderedFieldsPreview }, + } = testBed; + + await fields.updateName('myRuntimeField'); + await toggleFormRow('value'); + await fields.updateScript('bad()'); + await waitForPreviewUpdate(); + + 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 waitForPreviewUpdate(); + + expect(exists('fieldPreviewItem')).toBe(true); + expect(find('indexPatternFieldList.listItem').length).toBeGreaterThan(0); + expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'ok' }]); + }); + }); }); }); 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 07c0d323c91ef..611f88aebbad0 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 @@ -66,11 +66,11 @@ export const WithFieldEditorDependencies = ${value.toUpperCase()}`; }, }, - }; + } as any; } }); diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx index fa34cf476e33e..348c442a1cd37 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx @@ -63,7 +63,7 @@ export const PreviewListItem: React.FC = ({ return ( - {value} + {JSON.stringify(value)} ); }; 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 1be992c4aa001..fff678bf49169 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 @@ -50,7 +50,7 @@ interface Params { export interface FieldPreview { key: string; - value: string; + value: unknown; formattedValue?: string; } @@ -103,6 +103,16 @@ export const defaultValueFormatter = (value: unknown) => export const FieldPreviewProvider: FunctionComponent = ({ children }) => { const previewCount = useRef(0); + const lastExecutePainlessRequestParams = useRef<{ + type: Params['type']; + script: string | undefined; + documentId: string | undefined; + }>({ + type: null, + script: undefined, + documentId: undefined, + }); + const { indexPattern, fieldTypeToProcess, @@ -142,8 +152,9 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { ]); const currentDocIndex = currentDocument?._index; + const currentDocId = 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 })); @@ -158,6 +169,20 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { [params] ); + const hasSomePreviewRequestParamChanged = useCallback(() => { + return ( + lastExecutePainlessRequestParams.current.type !== type || + lastExecutePainlessRequestParams.current.script !== script?.source || + lastExecutePainlessRequestParams.current.documentId !== currentDocId + ); + }, [type, script, currentDocId]); + + const doExecuteScript = useCallback(() => { + return ( + fieldTypeToProcess === 'runtime' && allParamsDefined() && hasSomePreviewRequestParamChanged() + ); + }, [fieldTypeToProcess, allParamsDefined, hasSomePreviewRequestParamChanged]); + const valueFormatter = useCallback( (value: unknown) => { if (format?.id) { @@ -268,10 +293,16 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { ); const updatePreview = useCallback(async () => { - if (fieldTypeToProcess !== 'runtime' || !allParamsDefined()) { + if (!doExecuteScript()) { return; } + lastExecutePainlessRequestParams.current = { + type: params.type, + script: params.script?.source, + documentId: currentDocId, + }; + const currentApiCall = ++previewCount.current; const response = await getFieldPreview({ @@ -320,15 +351,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, + doExecuteScript, params, currentDocIndex, + currentDocId, getFieldPreview, notifications.toasts, valueFormatter, @@ -429,10 +460,10 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { * the 500ms of the debounce, we set the loading state in this effect */ useEffect(() => { - if (fieldTypeToProcess === 'runtime' && allParamsDefined()) { + if (doExecuteScript()) { setIsLoadingPreview(true); } - }, [fieldTypeToProcess, allParamsDefined]); + }, [doExecuteScript]); /** * When the component mounts, if we are creating/editing a runtime field @@ -458,26 +489,41 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { 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); + const willExecuteScript = doExecuteScript(); - setPreviewResponse({ - fields: [{ key: name, value: nextValue, formattedValue }], - error: null, - }); - } else { - // We immediately update the field preview whenever the name changes + if (willExecuteScript) { + // We'll update the field "value" after executing the Painless script + // Here we want to immediately update the field "key" whenever the name changes setPreviewResponse(({ fields: { 0: field } }) => ({ fields: [{ ...field, key: name ?? '' }], error: null, })); + + return; } + + // As we won't execute the Painless script we will immediately update + // the preview, reading the value from _source if needed and applying + // any formatter to the value. + setPreviewResponse((prev) => { + const { + fields: { 0: field }, + } = prev; + + const nextValue = + name && script === null + ? get(document, name) // When there is no script we read the value from _source + : field?.value; + + const formattedValue = valueFormatter(nextValue); + + return { + fields: [{ key: name ?? '', value: nextValue, formattedValue }], + error: null, + }; + }); } - }, [name, document, script, format, valueFormatter]); + }, [name, document, script, valueFormatter, doExecuteScript]); return {children}; }; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_error.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_error.tsx index c2a28a175ed7c..0a66e1aa96927 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_error.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_error.tsx @@ -25,10 +25,12 @@ export const FieldPreviewError = () => { })} 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..c0181cf1e489d 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 @@ -52,7 +52,7 @@ export const FieldPreviewHeader = () => {
{isUpdating && ( - + From d129782326040a62f804fbcc999e2ff0f89c2328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Wed, 21 Jul 2021 14:25:51 +0100 Subject: [PATCH 04/11] Add tests for documents navigation --- .../field_editor_flyout_content.helpers.tsx | 41 +++++--- .../field_editor_flyout_content.test.ts | 98 ++++++++++++++++++- .../preview/documents_nav_preview.tsx | 2 + 3 files changed, 126 insertions(+), 15 deletions(-) diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.tsx b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.tsx index b9e7ad871b169..50a585ea8fafb 100644 --- a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.tsx +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.tsx @@ -34,21 +34,18 @@ export const setIndexPatternFields = (fields: Array<{ name: string; displayName: }; const getActions = (testBed: TestBed) => { - // Add TS overload function types - function getRenderedIndexPatternFields(returnWrappers?: boolean): ReactWrapper; - function getRenderedIndexPatternFields( - returnWrappers?: boolean - ): Array<{ key: string; value: string }>; - - function getRenderedIndexPatternFields(returnWrappers = false) { + const getWrapperRenderedIndexPatternFields = (): ReactWrapper | null => { if (testBed.find('indexPatternFieldList').length === 0) { - return []; + return null; } + return testBed.find('indexPatternFieldList.listItem'); + }; - const allFields = testBed.find('indexPatternFieldList.listItem'); + const getRenderedIndexPatternFields = (): Array<{ key: string; value: string }> => { + const allFields = getWrapperRenderedIndexPatternFields(); - if (returnWrappers === true) { - return allFields; + if (allFields === null) { + return []; } return allFields.map((field) => { @@ -56,7 +53,7 @@ const getActions = (testBed: TestBed) => { const value = testBed.find('value', field).text(); return { key, value }; }); - } + }; function getRenderedFieldsPreview() { if (testBed.find('fieldPreviewItem').length === 0) { @@ -97,12 +94,32 @@ const getActions = (testBed: TestBed) => { 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, }; }; 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 2c19b400966d7..6371e14ab3f12 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 @@ -198,6 +198,8 @@ describe('', () => { }, ]; + const [doc1, doc2, doc3] = mockDocuments; + const indexPatternFields: Array<{ name: string; displayName: string }> = [ { name: 'title', @@ -354,14 +356,24 @@ describe('', () => { const { find, component, - actions: { toggleFormRow, fields, getRenderedIndexPatternFields }, + actions: { + toggleFormRow, + fields, + getWrapperRenderedIndexPatternFields, + getRenderedIndexPatternFields, + }, } = testBed; await toggleFormRow('value'); await fields.updateName('myRuntimeField'); - const fieldsRendered = getRenderedIndexPatternFields(true); - expect(fieldsRendered.length).toBe(3); + 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'); @@ -573,5 +585,85 @@ describe('', () => { expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'ok' }]); }); }); + + describe('Cluster document load and navigation', () => { + test('should update the field list when the document changes', async () => { + const { + actions: { + toggleFormRow, + fields, + getRenderedIndexPatternFields, + goToNextDocument, + goToPreviousDocument, + }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + + 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, + }); + + // We are 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, + waitForPreviewUpdate, + getRenderedFieldsPreview, + goToNextDocument, + }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await fields.updateScript('echo("hello world")'); + await waitForPreviewUpdate(); + + expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'valueDoc1' }]); + + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['valueDoc2'] }); + await goToNextDocument(); + await waitForPreviewUpdate(); + + expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'valueDoc2' }]); + }); + }); }); }); 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..a2017f4990284 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 @@ -125,6 +125,7 @@ export const DocumentsNavPreview = () => { size="m" onClick={prev} iconType="arrowLeft" + data-test-subj="goToPrevDocButton" aria-label={i18n.translate( 'indexPatternFieldEditor.fieldPreview.documentNav.previousArialabel', { @@ -139,6 +140,7 @@ export const DocumentsNavPreview = () => { size="m" onClick={next} iconType="arrowRight" + data-test-subj="goToNextDocButton" aria-label={i18n.translate( 'indexPatternFieldEditor.fieldPreview.documentNav.nextArialabel', { From 0ccca7bf6f1bca19b12f18bca64fdbd2214366db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Wed, 21 Jul 2021 14:47:14 +0100 Subject: [PATCH 05/11] Refactor logic for params change detection --- .../preview/field_preview_context.tsx | 40 +++++++------------ 1 file changed, 14 insertions(+), 26 deletions(-) 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 fff678bf49169..6e9251ef1e7b1 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 @@ -160,28 +160,18 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { 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 allParamsDefined = (['type', 'script', 'index', 'document'] as Array< + keyof Params + >).every((key) => Boolean(params[key])); - const hasSomePreviewRequestParamChanged = useCallback(() => { - return ( + const hasSomeParamsChanged = lastExecutePainlessRequestParams.current.type !== type || lastExecutePainlessRequestParams.current.script !== script?.source || - lastExecutePainlessRequestParams.current.documentId !== currentDocId - ); - }, [type, script, currentDocId]); + lastExecutePainlessRequestParams.current.documentId !== currentDocId; - const doExecuteScript = useCallback(() => { - return ( - fieldTypeToProcess === 'runtime' && allParamsDefined() && hasSomePreviewRequestParamChanged() - ); - }, [fieldTypeToProcess, allParamsDefined, hasSomePreviewRequestParamChanged]); + return allParamsDefined && hasSomeParamsChanged; + }, [type, script?.source, currentDocId, params]); const valueFormatter = useCallback( (value: unknown) => { @@ -293,7 +283,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { ); const updatePreview = useCallback(async () => { - if (!doExecuteScript()) { + if (!needToUpdatePreview) { return; } @@ -356,7 +346,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { }); } }, [ - doExecuteScript, + needToUpdatePreview, params, currentDocIndex, currentDocId, @@ -460,10 +450,10 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { * the 500ms of the debounce, we set the loading state in this effect */ useEffect(() => { - if (doExecuteScript()) { + if (needToUpdatePreview) { setIsLoadingPreview(true); } - }, [doExecuteScript]); + }, [needToUpdatePreview]); /** * When the component mounts, if we are creating/editing a runtime field @@ -489,9 +479,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { useEffect(() => { if (document) { - const willExecuteScript = doExecuteScript(); - - if (willExecuteScript) { + if (needToUpdatePreview) { // We'll update the field "value" after executing the Painless script // Here we want to immediately update the field "key" whenever the name changes setPreviewResponse(({ fields: { 0: field } }) => ({ @@ -523,7 +511,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { }; }); } - }, [name, document, script, valueFormatter, doExecuteScript]); + }, [name, document, script, valueFormatter, needToUpdatePreview]); return {children}; }; From 47bb9868fdedd442b00b66aa9ff8f5712353b0f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Wed, 21 Jul 2021 14:57:03 +0100 Subject: [PATCH 06/11] Rename field_editor_flyout_content.helpers.ts --- ...content.helpers.tsx => field_editor_flyout_content.helpers.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/plugins/index_pattern_field_editor/__jest__/client_integration/{field_editor_flyout_content.helpers.tsx => field_editor_flyout_content.helpers.ts} (100%) diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.tsx b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts similarity index 100% rename from src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.tsx rename to src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts From 227753a36ba95323e9765756f7ad1fa55d890fcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Wed, 21 Jul 2021 16:59:20 +0100 Subject: [PATCH 07/11] Move the preview panel test to its own file --- .../field_editor_flyout_content.helpers.ts | 99 +--- .../field_editor_flyout_content.test.ts | 517 +---------------- .../field_editor_flyout_preview.helpers.ts | 148 +++++ .../field_editor_flyout_preview.test.ts | 534 ++++++++++++++++++ 4 files changed, 685 insertions(+), 613 deletions(-) create mode 100644 src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.helpers.ts create mode 100644 src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.test.ts 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 50a585ea8fafb..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 @@ -6,21 +6,14 @@ * Side Public License, v 1. */ import { act } from 'react-dom/test-utils'; -import { ReactWrapper } from 'enzyme'; -import { SinonFakeServer } from 'sinon'; 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, -} from './helpers'; +import { WithFieldEditorDependencies, getCommonActions } from './helpers'; const defaultProps: Props = { onSave: () => {}, @@ -29,97 +22,9 @@ const defaultProps: Props = { isSavingField: false, }; -export const setIndexPatternFields = (fields: Array<{ name: string; displayName: string }>) => { - spyIndexPatternGetAllFields.mockReturnValue(fields); -}; - 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 }; - }); - }; - - function 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(); - }; - - const getLatestPreviewHttpRequest = (server: SinonFakeServer) => { - 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, }; }; @@ -139,5 +44,3 @@ export const setup = async (props?: Partial, deps?: Partial) => 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_content.test.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts index 6371e14ab3f12..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 @@ -8,13 +8,8 @@ import { act } from 'react-dom/test-utils'; import type { Props } from '../../public/components/field_editor_flyout_content'; -import { setupEnvironment, spySearchResult, fieldFormatsOptions } from './helpers'; -import { - setup, - setIndexPatternFields, - FieldEditorFlyoutContentTestBed, -} from './field_editor_flyout_content.helpers'; -import { createPreviewError } from './helpers/mocks'; +import { setupEnvironment } from './helpers'; +import { setup } from './field_editor_flyout_content.helpers'; describe('', () => { const { server, httpRequestsMockHelpers } = setupEnvironment(); @@ -158,512 +153,4 @@ describe('', () => { }); }); }); - - describe('preview panel', () => { - let testBed: FieldEditorFlyoutContentTestBed; - - interface TestDoc { - title: string; - subTitle: string; - description: string; - } - - const mockDocuments: Array<{ _id: string; _index: string; _source: TestDoc }> = [ - { - _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 () => { - setIndexPatternFields(indexPatternFields); - - spySearchResult.mockResolvedValue({ - rawResponse: { - hits: { - total: mockDocuments.length, - hits: 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 display an empty prompt if no name and no script are defined', async () => { - const { - exists, - actions: { toggleFormRow, fields, waitForPreviewUpdate }, - } = testBed; - - await toggleFormRow('value'); - expect(exists('previewPanel')).toBe(true); - expect(exists('previewPanel.emptyPrompt')).toBe(true); - - await fields.updateName('someName'); - await waitForPreviewUpdate(); - expect(exists('previewPanel.emptyPrompt')).toBe(false); - - await fields.updateName(' '); - await waitForPreviewUpdate(); - 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 waitForPreviewUpdate(); - expect(exists('previewPanel.emptyPrompt')).toBe(false); - - await fields.updateScript(' '); - await waitForPreviewUpdate(); - expect(exists('previewPanel.emptyPrompt')).toBe(true); - }); - - test('should list the list of fields of the index pattern', async () => { - const { - actions: { toggleFormRow, fields, getRenderedIndexPatternFields }, - } = testBed; - - await toggleFormRow('value'); - await fields.updateName('myRuntimeField'); - - 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 }, - } = testBed; - - await toggleFormRow('value'); - await fields.updateName('myRuntimeField'); - - // 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, - }, - } = testBed; - - await toggleFormRow('value'); - await fields.updateName('myRuntimeField'); - - 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('key & value', () => { - test('should set an empty value when no script is provided', async () => { - const { - actions: { toggleFormRow, fields, getRenderedFieldsPreview }, - } = testBed; - - await toggleFormRow('value'); - await fields.updateName('myRuntimeField'); - - expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: '-' }]); - }); - - 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 }, - } = testBed; - - await toggleFormRow('value'); - await fields.updateName('subTitle'); - - 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, waitForPreviewUpdate, getRenderedFieldsPreview }, - } = testBed; - - await toggleFormRow('value'); - await fields.updateName('description'); // Field name is a field in _source - await fields.updateScript('echo("hello")'); - await waitForPreviewUpdate(); - - // 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' }, - ]); - }); - }); - - 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, - waitForPreviewUpdate, - getLatestPreviewHttpRequest, - getRenderedFieldsPreview, - }, - } = testBed; - - await toggleFormRow('value'); - await fields.updateName('myRuntimeField'); - await fields.updateScript('echo("hello")'); - await waitForPreviewUpdate(); - - 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, waitForPreviewUpdate }, - } = testBed; - - await toggleFormRow('value'); - expect(exists('isUpdatingIndicator')).toBe(false); - - await fields.updateScript('echo("hello")'); - expect(exists('isUpdatingIndicator')).toBe(true); - - await waitForPreviewUpdate(); - 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, waitForPreviewUpdate }, - } = testBed; - - await toggleFormRow('value'); - await fields.updateName('myRuntimeField'); - await fields.updateScript('echo("hello")'); - expect(exists('isUpdatingIndicator')).toBe(true); - await waitForPreviewUpdate(); - 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('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, waitForPreviewUpdate, getRenderedFieldsPreview }, - } = testBed; - - await fields.updateName('myRuntimeField'); - await toggleFormRow('value'); - await fields.updateScript('echo("hello")'); - await waitForPreviewUpdate(); - - // before - expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'hello' }]); - - // after - await toggleFormRow('format'); - await fields.updateFormat(fieldFormatsOptions[0].id); // select 'upper' format - await waitForPreviewUpdate(); - 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, waitForPreviewUpdate, getRenderedFieldsPreview }, - } = testBed; - - await fields.updateName('myRuntimeField'); - await toggleFormRow('value'); - await fields.updateScript('bad()'); - await waitForPreviewUpdate(); - - 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 waitForPreviewUpdate(); - - expect(exists('fieldPreviewItem')).toBe(true); - expect(find('indexPatternFieldList.listItem').length).toBeGreaterThan(0); - expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'ok' }]); - }); - }); - - describe('Cluster document load and navigation', () => { - test('should update the field list when the document changes', async () => { - const { - actions: { - toggleFormRow, - fields, - getRenderedIndexPatternFields, - goToNextDocument, - goToPreviousDocument, - }, - } = testBed; - - await toggleFormRow('value'); - await fields.updateName('myRuntimeField'); - - 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, - }); - - // We are 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, - waitForPreviewUpdate, - getRenderedFieldsPreview, - goToNextDocument, - }, - } = testBed; - - await toggleFormRow('value'); - await fields.updateName('myRuntimeField'); - await fields.updateScript('echo("hello world")'); - await waitForPreviewUpdate(); - - expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'valueDoc1' }]); - - httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['valueDoc2'] }); - await goToNextDocument(); - await waitForPreviewUpdate(); - - expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'valueDoc2' }]); - }); - }); - }); }); 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..f6936a2374fae --- /dev/null +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.helpers.ts @@ -0,0 +1,148 @@ +/* + * 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, +} 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); +}; + +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..7b5a709560697 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.test.ts @@ -0,0 +1,534 @@ +/* + * 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 { setupEnvironment, spySearchResult, fieldFormatsOptions } from './helpers'; +import { + setup, + setIndexPatternFields, + FieldEditorFlyoutContentTestBed, +} from './field_editor_flyout_preview.helpers'; +import { createPreviewError } from './helpers/mocks'; + +describe('Field editor Preview panel', () => { + const { server, httpRequestsMockHelpers } = setupEnvironment(); + + beforeAll(() => { + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['foo'] }); + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + server.restore(); + }); + + let testBed: FieldEditorFlyoutContentTestBed; + + interface TestDoc { + title: string; + subTitle: string; + description: string; + } + + const mockDocuments: Array<{ _id: string; _index: string; _source: TestDoc }> = [ + { + _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 () => { + setIndexPatternFields(indexPatternFields); + + spySearchResult.mockResolvedValue({ + rawResponse: { + hits: { + total: mockDocuments.length, + hits: 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 display an empty prompt if no name and no script are defined', async () => { + const { + exists, + actions: { toggleFormRow, fields, waitForPreviewUpdate }, + } = testBed; + + await toggleFormRow('value'); + expect(exists('previewPanel')).toBe(true); + expect(exists('previewPanel.emptyPrompt')).toBe(true); + + await fields.updateName('someName'); + await waitForPreviewUpdate(); + expect(exists('previewPanel.emptyPrompt')).toBe(false); + + await fields.updateName(' '); + await waitForPreviewUpdate(); + 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 waitForPreviewUpdate(); + expect(exists('previewPanel.emptyPrompt')).toBe(false); + + await fields.updateScript(' '); + await waitForPreviewUpdate(); + expect(exists('previewPanel.emptyPrompt')).toBe(true); + }); + + test('should list the list of fields of the index pattern', async () => { + const { + actions: { toggleFormRow, fields, getRenderedIndexPatternFields }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + + 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 }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + + // 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, + }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + + 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('key & value', () => { + test('should set an empty value when no script is provided', async () => { + const { + actions: { toggleFormRow, fields, getRenderedFieldsPreview }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + + expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: '-' }]); + }); + + 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 }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('subTitle'); + + 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, waitForPreviewUpdate, getRenderedFieldsPreview }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('description'); // Field name is a field in _source + await fields.updateScript('echo("hello")'); + await waitForPreviewUpdate(); + + // 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' }, + ]); + }); + }); + + 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, + waitForPreviewUpdate, + getLatestPreviewHttpRequest, + getRenderedFieldsPreview, + }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await fields.updateScript('echo("hello")'); + await waitForPreviewUpdate(); + + 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, waitForPreviewUpdate }, + } = testBed; + + await toggleFormRow('value'); + expect(exists('isUpdatingIndicator')).toBe(false); + + await fields.updateScript('echo("hello")'); + expect(exists('isUpdatingIndicator')).toBe(true); + + await waitForPreviewUpdate(); + 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, waitForPreviewUpdate }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await fields.updateScript('echo("hello")'); + expect(exists('isUpdatingIndicator')).toBe(true); + await waitForPreviewUpdate(); + 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('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, waitForPreviewUpdate, getRenderedFieldsPreview }, + } = testBed; + + await fields.updateName('myRuntimeField'); + await toggleFormRow('value'); + await fields.updateScript('echo("hello")'); + await waitForPreviewUpdate(); + + // before + expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'hello' }]); + + // after + await toggleFormRow('format'); + await fields.updateFormat(fieldFormatsOptions[0].id); // select 'upper' format + await waitForPreviewUpdate(); + 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, waitForPreviewUpdate, getRenderedFieldsPreview }, + } = testBed; + + await fields.updateName('myRuntimeField'); + await toggleFormRow('value'); + await fields.updateScript('bad()'); + await waitForPreviewUpdate(); + + 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 waitForPreviewUpdate(); + + expect(exists('fieldPreviewItem')).toBe(true); + expect(find('indexPatternFieldList.listItem').length).toBeGreaterThan(0); + expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'ok' }]); + }); + }); + + describe('Cluster document load and navigation', () => { + test('should update the field list when the document changes', async () => { + const { + actions: { + toggleFormRow, + fields, + getRenderedIndexPatternFields, + goToNextDocument, + goToPreviousDocument, + }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + + 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, + }); + + // We are 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, + waitForPreviewUpdate, + getRenderedFieldsPreview, + goToNextDocument, + }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await fields.updateScript('echo("hello world")'); + await waitForPreviewUpdate(); + + expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'valueDoc1' }]); + + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['valueDoc2'] }); + await goToNextDocument(); + await waitForPreviewUpdate(); + + expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'valueDoc2' }]); + }); + }); +}); From fdd5cda0693ad9ab7a1e2d8d7463f042ce63b764 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Wed, 21 Jul 2021 17:36:10 +0100 Subject: [PATCH 08/11] Load cluster data even for concrete fields --- .../public/components/preview/field_preview_context.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6e9251ef1e7b1..ad5e543ff1569 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 @@ -461,7 +461,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { * field along with other document fields */ useEffect(() => { - if (isPanelVisible && fieldTypeToProcess === 'runtime') { + if (isPanelVisible) { fetchSampleDocuments(); } }, [isPanelVisible, fetchSampleDocuments, fieldTypeToProcess]); From 5bb746354cc647979111a69e7c56a1a90e3c8ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Mon, 26 Jul 2021 17:49:33 +0100 Subject: [PATCH 09/11] Add missing tests --- .../field_editor_flyout_preview.helpers.ts | 37 ++ .../field_editor_flyout_preview.test.ts | 535 +++++++++++++++--- .../helpers/common_actions.ts | 32 +- .../client_integration/helpers/index.ts | 4 +- .../helpers/setup_environment.tsx | 26 +- .../preview/documents_nav_preview.tsx | 59 +- .../components/preview/field_preview.tsx | 12 +- .../preview/field_preview_context.tsx | 364 +++++++----- .../preview/field_preview_header.tsx | 4 +- 9 files changed, 761 insertions(+), 312 deletions(-) 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 index f6936a2374fae..068ebce638aa1 100644 --- 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 @@ -19,6 +19,8 @@ import { WithFieldEditorDependencies, getCommonActions, spyIndexPatternGetAllFields, + spySearchQuery, + spySearchQueryResponse, } from './helpers'; const defaultProps: Props = { @@ -36,6 +38,41 @@ export const setIndexPatternFields = (fields: Array<{ name: string; displayName: 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) { 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 index 7b5a709560697..6b4882e4e1b69 100644 --- 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 @@ -5,19 +5,29 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { setupEnvironment, spySearchResult, fieldFormatsOptions } from './helpers'; +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(() => { - httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['foo'] }); jest.useFakeTimers(); }); @@ -28,13 +38,7 @@ describe('Field editor Preview panel', () => { let testBed: FieldEditorFlyoutContentTestBed; - interface TestDoc { - title: string; - subTitle: string; - description: string; - } - - const mockDocuments: Array<{ _id: string; _index: string; _source: TestDoc }> = [ + const mockDocuments: EsDoc[] = [ { _id: '001', _index: 'testIndex', @@ -82,16 +86,9 @@ describe('Field editor Preview panel', () => { ]; beforeEach(async () => { + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['mockedScriptValue'] }); setIndexPatternFields(indexPatternFields); - - spySearchResult.mockResolvedValue({ - rawResponse: { - hits: { - total: mockDocuments.length, - hits: mockDocuments, - }, - }, - }); + setSearchResponse(mockDocuments); testBed = await setup(); }); @@ -117,41 +114,28 @@ describe('Field editor Preview panel', () => { expect(exists('previewPanel')).toBe(false); }); - test('should display an empty prompt if no name and no script are defined', async () => { + test('should correctly set the title and subtitle of the panel', async () => { const { - exists, - actions: { toggleFormRow, fields, waitForPreviewUpdate }, + find, + actions: { toggleFormRow, fields, waitForUpdates }, } = testBed; await toggleFormRow('value'); - expect(exists('previewPanel')).toBe(true); - expect(exists('previewPanel.emptyPrompt')).toBe(true); - - await fields.updateName('someName'); - await waitForPreviewUpdate(); - expect(exists('previewPanel.emptyPrompt')).toBe(false); - - await fields.updateName(' '); - await waitForPreviewUpdate(); - 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 waitForPreviewUpdate(); - expect(exists('previewPanel.emptyPrompt')).toBe(false); + await fields.updateName('myRuntimeField'); + await waitForUpdates(); - await fields.updateScript(' '); - await waitForPreviewUpdate(); - expect(exists('previewPanel.emptyPrompt')).toBe(true); + 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 }, + actions: { toggleFormRow, fields, getRenderedIndexPatternFields, waitForUpdates }, } = testBed; await toggleFormRow('value'); await fields.updateName('myRuntimeField'); + await waitForUpdates(); expect(getRenderedIndexPatternFields()).toEqual([ { @@ -174,11 +158,18 @@ describe('Field editor Preview panel', () => { exists, find, component, - actions: { toggleFormRow, fields, setFilterFieldsValue, getRenderedIndexPatternFields }, + actions: { + toggleFormRow, + fields, + setFilterFieldsValue, + getRenderedIndexPatternFields, + waitForUpdates, + }, } = testBed; await toggleFormRow('value'); await fields.updateName('myRuntimeField'); + await waitForUpdates(); // Should find a single field await setFilterFieldsValue('descr'); @@ -227,11 +218,13 @@ describe('Field editor Preview panel', () => { fields, getWrapperRenderedIndexPatternFields, getRenderedIndexPatternFields, + waitForUpdates, }, } = testBed; await toggleFormRow('value'); await fields.updateName('myRuntimeField'); + await waitForUpdates(); const fieldsRendered = getWrapperRenderedIndexPatternFields(); @@ -255,57 +248,94 @@ describe('Field editor Preview panel', () => { ]); }); - describe('key & value', () => { - test('should set an empty value when no script is provided', async () => { + describe('empty prompt', () => { + test('should display an empty prompt if no name and no script are defined', async () => { const { - actions: { toggleFormRow, fields, getRenderedFieldsPreview }, + exists, + actions: { toggleFormRow, fields, waitForUpdates }, } = testBed; await toggleFormRow('value'); - await fields.updateName('myRuntimeField'); + expect(exists('previewPanel')).toBe(true); + expect(exists('previewPanel.emptyPrompt')).toBe(true); - expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: '-' }]); - }); + await fields.updateName('someName'); + await waitForUpdates(); + expect(exists('previewPanel.emptyPrompt')).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 }, - } = testBed; + await fields.updateName(' '); + await waitForUpdates(); + expect(exists('previewPanel.emptyPrompt')).toBe(true); - await toggleFormRow('value'); - await fields.updateName('subTitle'); + // 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); - expect(getRenderedFieldsPreview()).toEqual([ - { key: 'subTitle', value: 'First doc - subTitle' }, - ]); - }); + await fields.updateScript(' '); + await waitForUpdates(); + expect(exists('previewPanel.emptyPrompt')).toBe(true); + }); - 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'] }); + 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")', + }, + }; - const { - actions: { toggleFormRow, fields, waitForPreviewUpdate, getRenderedFieldsPreview }, - } = testBed; + // 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 }); + }); - await toggleFormRow('value'); - await fields.updateName('description'); // Field name is a field in _source - await fields.updateScript('echo("hello")'); - await waitForPreviewUpdate(); + const { exists, component } = testBed; + component.update(); - // We render the value from the _execute API - expect(getRenderedFieldsPreview()).toEqual([ - { key: 'description', value: 'valueFromExecuteAPI' }, - ]); + expect(exists('previewPanel')).toBe(true); + expect(exists('previewPanel.emptyPrompt')).toBe(false); + }); - await toggleFormRow('format', 'on'); - await toggleFormRow('value', 'off'); + test('should **not** display an empty prompt editing a document with format defined', async () => { + const field = { + name: 'foo', + type: 'ip', + format: { + id: 'upper', + params: {}, + }, + }; - // Fallback to _source value when "Set value" is turned off and we have a format - expect(getRenderedFieldsPreview()).toEqual([ - { key: 'description', value: 'First doc - description' }, - ]); + // 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 () => { @@ -316,7 +346,7 @@ describe('Field editor Preview panel', () => { actions: { toggleFormRow, fields, - waitForPreviewUpdate, + waitForUpdates, getLatestPreviewHttpRequest, getRenderedFieldsPreview, }, @@ -325,8 +355,8 @@ describe('Field editor Preview panel', () => { await toggleFormRow('value'); await fields.updateName('myRuntimeField'); await fields.updateScript('echo("hello")'); - await waitForPreviewUpdate(); - + await waitForUpdates(); // fetch documents + await waitForUpdates(); // fetch preview const request = getLatestPreviewHttpRequest(server); // Make sure the payload sent is correct @@ -354,16 +384,18 @@ describe('Field editor Preview panel', () => { const { exists, - actions: { toggleFormRow, fields, waitForPreviewUpdate }, + actions: { toggleFormRow, fields, waitForUpdates }, } = 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 waitForPreviewUpdate(); + await waitForUpdates(); // fetch documents + await waitForUpdates(); // fetch preview expect(exists('isUpdatingIndicator')).toBe(false); }); @@ -372,20 +404,66 @@ describe('Field editor Preview panel', () => { const { exists, - actions: { toggleFormRow, fields, waitForPreviewUpdate }, + actions: { toggleFormRow, fields, waitForUpdates }, } = 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 waitForPreviewUpdate(); + await waitForUpdates(); // fetch documents + await waitForUpdates(); // fetch preview 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, waitForUpdates }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateName('subTitle'); + await waitForUpdates(); + await waitForUpdates(); + + 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', () => { @@ -401,13 +479,14 @@ describe('Field editor Preview panel', () => { httpRequestsMockHelpers.setFieldPreviewResponse({ values: [scriptEmitResponse] }); const { - actions: { toggleFormRow, fields, waitForPreviewUpdate, getRenderedFieldsPreview }, + actions: { toggleFormRow, fields, waitForUpdates, getRenderedFieldsPreview }, } = testBed; await fields.updateName('myRuntimeField'); await toggleFormRow('value'); await fields.updateScript('echo("hello")'); - await waitForPreviewUpdate(); + await waitForUpdates(); + await waitForUpdates(); // before expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'hello' }]); @@ -415,7 +494,7 @@ describe('Field editor Preview panel', () => { // after await toggleFormRow('format'); await fields.updateFormat(fieldFormatsOptions[0].id); // select 'upper' format - await waitForPreviewUpdate(); + await waitForUpdates(); expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'HELLO' }]); }); }); @@ -428,13 +507,14 @@ describe('Field editor Preview panel', () => { const { exists, find, - actions: { toggleFormRow, fields, waitForPreviewUpdate, getRenderedFieldsPreview }, + actions: { toggleFormRow, fields, waitForUpdates, getRenderedFieldsPreview }, } = testBed; await fields.updateName('myRuntimeField'); await toggleFormRow('value'); await fields.updateScript('bad()'); - await waitForPreviewUpdate(); + await waitForUpdates(); + await waitForUpdates(); expect(exists('fieldPreviewItem')).toBe(false); expect(exists('indexPatternFieldList')).toBe(false); @@ -444,15 +524,51 @@ describe('Field editor Preview panel', () => { httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['ok'] }); await fields.updateScript('echo("ok")'); - await waitForPreviewUpdate(); + 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 }, + } = testBed; + + await fields.updateName('myRuntimeField'); + await toggleFormRow('value'); + await waitForUpdates(); + await waitForUpdates(); + + // 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: { @@ -461,11 +577,13 @@ describe('Field editor Preview panel', () => { getRenderedIndexPatternFields, goToNextDocument, goToPreviousDocument, + waitForUpdates, }, } = testBed; await toggleFormRow('value'); await fields.updateName('myRuntimeField'); + await waitForUpdates(); expect(getRenderedIndexPatternFields()[0]).toEqual({ key: 'title', @@ -484,7 +602,7 @@ describe('Field editor Preview panel', () => { value: doc3._source.title, }); - // We are back to the first document of the list + // Going next we circle back to the first document of the list await goToNextDocument(); expect(getRenderedIndexPatternFields()[0]).toEqual({ key: 'title', @@ -511,7 +629,7 @@ describe('Field editor Preview panel', () => { actions: { toggleFormRow, fields, - waitForPreviewUpdate, + waitForUpdates, getRenderedFieldsPreview, goToNextDocument, }, @@ -520,15 +638,244 @@ describe('Field editor Preview panel', () => { await toggleFormRow('value'); await fields.updateName('myRuntimeField'); await fields.updateScript('echo("hello world")'); - await waitForPreviewUpdate(); + await waitForUpdates(); + await waitForUpdates(); expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'valueDoc1' }]); httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['valueDoc2'] }); await goToNextDocument(); - await waitForPreviewUpdate(); + 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 8967f54ba7e30..b26d9e09cb23f 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 @@ -66,11 +66,32 @@ export const getCommonActions = (testBed: TestBed) => { testBed.component.update(); }; - // The preview updates on a debounce of 500ms whenever - // a parameter changes (script, type) - const waitForPreviewUpdate = async () => { + /** + * The prev preview update occurs after a debounce of 500ms and we simulate + * latency when searching ES documents (see setup_environment.tsx). + * This handler allows us to advance the jest timer and update the component + * @param ms time to move timer forward + */ + const waitForUpdates = async (ms = 3000) => { await act(async () => { - jest.advanceTimersByTime(1000); + jest.advanceTimersByTime(ms); + }); + + testBed.component.update(); + }; + + /** + * When often need to both wait for the documents to be fetched and + * then the preview to be fetched. We can't increase the `jest.advanceTimersByTime` time + * as those are 2 different operations. We will for that run all the timers to get to a stable state. + */ + const waitForDocumentsAndPreviewUpdate = async () => { + await act(async () => { + jest.runAllTimers(); + }); + + await act(async () => { + jest.runAllTimers(); }); testBed.component.update(); @@ -78,7 +99,8 @@ export const getCommonActions = (testBed: TestBed) => { return { toggleFormRow, - waitForPreviewUpdate, + waitForUpdates, + waitForDocumentsAndPreviewUpdate, fields: { updateName, updateType, 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 b738eaf9c6c5d..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 @@ -11,9 +11,11 @@ export { findTestSubject, TestBed } from '@kbn/test/jest'; export { setupEnvironment, WithFieldEditorDependencies, - spySearchResult, + 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/setup_environment.tsx b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx index 611f88aebbad0..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 @@ -24,13 +24,22 @@ const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); const dataStart = dataPluginMock.createStartContract(); 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; @@ -45,8 +54,11 @@ export const setupEnvironment = () => { }; }; +// 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 @@ -66,7 +78,7 @@ export const WithFieldEditorDependencies = ${value.toUpperCase()}`; }, }, @@ -76,7 +88,7 @@ export const WithFieldEditorDependencies = { 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 && ( - + { 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); 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 ad5e543ff1569..b2095dc8a7535 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'; @@ -57,8 +60,6 @@ export interface FieldPreview { 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,7 +108,7 @@ export const defaultValueFormatter = (value: unknown) => export const FieldPreviewProvider: FunctionComponent = ({ children }) => { const previewCount = useRef(0); - const lastExecutePainlessRequestParams = useRef<{ + const [lastExecutePainlessRequestParams, setLastExecutePainlessReqParams] = useState<{ type: Params['type']; script: string | undefined; documentId: string | undefined; @@ -142,17 +147,19 @@ 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 = currentDocument?._id; + const currentDocId: string = currentDocument?._id ?? ''; const totalDocs = documents.length; const { name, document, script, format, type } = params; @@ -165,13 +172,15 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { keyof Params >).every((key) => Boolean(params[key])); + const isCurrentDocIdDefined = currentDocId !== ''; + const hasSomeParamsChanged = - lastExecutePainlessRequestParams.current.type !== type || - lastExecutePainlessRequestParams.current.script !== script?.source || - lastExecutePainlessRequestParams.current.documentId !== currentDocId; + lastExecutePainlessRequestParams.type !== type || + lastExecutePainlessRequestParams.script !== script?.source || + lastExecutePainlessRequestParams.documentId !== currentDocId; - return allParamsDefined && hasSomeParamsChanged; - }, [type, script?.source, currentDocId, params]); + return allParamsDefined && isCurrentDocIdDefined && hasSomeParamsChanged; + }, [type, script?.source, currentDocId, params, lastExecutePainlessRequestParams]); const valueFormatter = useCallback( (value: unknown) => { @@ -187,111 +196,16 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { [format, fieldFormats] ); - const fetchSampleDocuments = useCallback( - async (limit = 50) => { - setIsFetchingDocument(true); - - const response = await search - .search({ - params: { - index: indexPattern.title, - body: { - size: limit, - }, - }, - }) - .toPromise(); - - setIsFetchingDocument(false); - - setPreviewResponse({ fields: [], error: null }); - setClusterData({ - documents: response ? response.rawResponse.hits.hits : [], - currentIdx: 0, - }); - }, - [indexPattern, search] - ); - - const loadDocument = useCallback( - async (id: string) => { - setIsFetchingDocument(true); - - const [response, error] = await search - .search({ - params: { - index: indexPattern.title, - body: { - size: 1, - query: { - ids: { - values: [id], - }, - }, - }, - }, - }) - .toPromise() - .then((res) => [res, null]) - .catch((err) => [null, err]); - - 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: [], - 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.', - } - ), - }, - }, - }); - } - } else if (error) { - // TODO: improve this error handling when there is a server - // error fetching a document - setPreviewResponse({ - fields: [], - error: { - code: 'ERR_FETCHING_DOC', - error: { - message: error.toString(), - }, - }, - }); - } - - setClusterData({ - documents: loadedDocuments, - currentIdx: 0, - }); - }, - [indexPattern, search] - ); - const updatePreview = useCallback(async () => { if (!needToUpdatePreview) { return; } - lastExecutePainlessRequestParams.current = { + setLastExecutePainlessReqParams({ type: params.type, script: params.script?.source, documentId: currentDocId, - }; + }); const currentApiCall = ++previewCount.current; @@ -355,6 +269,119 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { valueFormatter, ]); + const fetchSampleDocuments = useCallback( + async (limit: number = 50) => { + if (typeof limit !== 'number') { + // We guard ourself from passing an event handler accidentally + throw new Error('The "limit" option must be a number'); + } + + setIsFetchingDocument(true); + setClusterData({ + documents: [], + currentIdx: 0, + }); + setPreviewResponse({ fields: [], error: null }); + + const [response, error] = await search + .search({ + params: { + index: indexPattern.title, + body: { + size: limit, + }, + }, + }) + .toPromise() + .then((res) => [res, null]) + .catch((err) => [null, err]); + + setIsFetchingDocument(false); + setCustomDocIdToLoad(null); + + setClusterData({ + documents: response ? response.rawResponse.hits.hits : [], + currentIdx: 0, + }); + + setPreviewResponse((prev) => ({ ...prev, error: error ?? null })); + }, + [indexPattern, search] + ); + + const loadDocument = useCallback( + async (id: string) => { + if (!Boolean(id.trim())) { + return; + } + + setIsFetchingDocument(true); + + const [response, error] = await search + .search({ + params: { + index: indexPattern.title, + body: { + size: 1, + query: { + ids: { + values: [id], + }, + }, + }, + }, + }) + .toPromise() + .then((res) => [res, null]) + .catch((err) => [null, err]); + + setIsFetchingDocument(false); + + const isDocumentFound = response?.rawResponse.hits.total > 0; + const loadedDocuments: EsDocument[] = isDocumentFound ? response.rawResponse.hits.hits : []; + const errorToDisplay: Context['error'] = Boolean(error) + ? { + code: 'ERR_FETCHING_DOC', + error: { + message: error.toString(), + }, + } + : isDocumentFound === false + ? { + code: 'DOC_NOT_FOUND', + error: { + message: i18n.translate( + 'indexPatternFieldEditor.fieldPreview.error.documentNotFoundDescription', + { + defaultMessage: + 'Error previewing the field as the document provided was not found.', + } + ), + }, + } + : null; + + setPreviewResponse((prev) => ({ + ...prev, + error: errorToDisplay, + })); + + setClusterData({ + documents: loadedDocuments, + currentIdx: 0, + }); + + if (errorToDisplay !== null) { + // Make sure we disable the "Updating..." indicator as we have an error + // and we won't fetch the preview + setIsLoadingPreview(false); + } else { + updatePreview(); + } + }, + [indexPattern, search, updatePreview] + ); + const goToNextDoc = useCallback(() => { if (currentIdx >= totalDocs - 1) { setClusterData((prev) => ({ ...prev, currentIdx: 0 })); @@ -380,8 +407,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); }, []); @@ -391,16 +423,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, @@ -424,9 +459,10 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { isLoadingPreview, updateParams, currentDocument, - loadDocument, + currentDocId, fetchSampleDocuments, isFetchingDocument, + customDocIdToLoad, currentIdx, totalDocs, goToNextDoc, @@ -437,14 +473,6 @@ 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 @@ -453,7 +481,30 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { if (needToUpdatePreview) { setIsLoadingPreview(true); } - }, [needToUpdatePreview]); + }, [needToUpdatePreview, customDocIdToLoad]); + + 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 @@ -478,40 +529,45 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { }, [currentDocument, updateParams]); useEffect(() => { - if (document) { - if (needToUpdatePreview) { - // We'll update the field "value" after executing the Painless script - // Here we want to immediately update the field "key" whenever the name changes - setPreviewResponse(({ fields: { 0: field } }) => ({ - fields: [{ ...field, key: name ?? '' }], - error: null, - })); + // Whenever the name or the format changes we immediately update the preview + 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; } - // As we won't execute the Painless script we will immediately update - // the preview, reading the value from _source if needed and applying - // any formatter to the value. - setPreviewResponse((prev) => { - const { - fields: { 0: field }, - } = prev; - - const nextValue = - name && script === null - ? get(document, name) // When there is no script we read the value from _source - : field?.value; - - const formattedValue = valueFormatter(nextValue); - - return { - fields: [{ key: name ?? '', value: nextValue, formattedValue }], - error: null, - }; - }); - } - }, [name, document, script, valueFormatter, needToUpdatePreview]); + loadDocument(customDocIdToLoad); + }, + 500, + [customDocIdToLoad] + ); return {children}; }; 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 c0181cf1e489d..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,7 +47,7 @@ export const FieldPreviewHeader = () => { -

{i18nTexts.title}

+

{i18nTexts.title}

@@ -63,7 +63,7 @@ export const FieldPreviewHeader = () => { )}
- + {i18n.translate('indexPatternFieldEditor.fieldPreview.subTitle', { defaultMessage: 'From: {from}', values: { From 0d5d625999870998b5606aa9bf2a70930cd4d085 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Mon, 26 Jul 2021 17:55:30 +0100 Subject: [PATCH 10/11] Small refactor --- .../field_editor_flyout_preview.test.ts | 56 ++++--- .../preview/field_preview_context.tsx | 150 +++++++++--------- 2 files changed, 107 insertions(+), 99 deletions(-) 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 index 6b4882e4e1b69..1382d2c911200 100644 --- 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 @@ -346,7 +346,7 @@ describe('Field editor Preview panel', () => { actions: { toggleFormRow, fields, - waitForUpdates, + waitForDocumentsAndPreviewUpdate, getLatestPreviewHttpRequest, getRenderedFieldsPreview, }, @@ -355,8 +355,7 @@ describe('Field editor Preview panel', () => { await toggleFormRow('value'); await fields.updateName('myRuntimeField'); await fields.updateScript('echo("hello")'); - await waitForUpdates(); // fetch documents - await waitForUpdates(); // fetch preview + await waitForDocumentsAndPreviewUpdate(); const request = getLatestPreviewHttpRequest(server); // Make sure the payload sent is correct @@ -384,7 +383,7 @@ describe('Field editor Preview panel', () => { const { exists, - actions: { toggleFormRow, fields, waitForUpdates }, + actions: { toggleFormRow, fields, waitForUpdates, waitForDocumentsAndPreviewUpdate }, } = testBed; await toggleFormRow('value'); @@ -394,8 +393,7 @@ describe('Field editor Preview panel', () => { await fields.updateScript('echo("hello")'); expect(exists('isUpdatingIndicator')).toBe(true); - await waitForUpdates(); // fetch documents - await waitForUpdates(); // fetch preview + await waitForDocumentsAndPreviewUpdate(); expect(exists('isUpdatingIndicator')).toBe(false); }); @@ -404,7 +402,7 @@ describe('Field editor Preview panel', () => { const { exists, - actions: { toggleFormRow, fields, waitForUpdates }, + actions: { toggleFormRow, fields, waitForUpdates, waitForDocumentsAndPreviewUpdate }, } = testBed; await toggleFormRow('value'); @@ -412,8 +410,7 @@ describe('Field editor Preview panel', () => { await fields.updateName('myRuntimeField'); await fields.updateScript('echo("hello")'); expect(exists('isUpdatingIndicator')).toBe(true); - await waitForUpdates(); // fetch documents - await waitForUpdates(); // fetch preview + await waitForDocumentsAndPreviewUpdate(); expect(exists('isUpdatingIndicator')).toBe(false); await fields.updateName('nameChanged'); @@ -424,13 +421,17 @@ describe('Field editor Preview panel', () => { 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, waitForUpdates }, + actions: { + toggleFormRow, + fields, + getRenderedFieldsPreview, + waitForDocumentsAndPreviewUpdate, + }, } = testBed; await toggleFormRow('value'); await fields.updateName('subTitle'); - await waitForUpdates(); - await waitForUpdates(); + await waitForDocumentsAndPreviewUpdate(); expect(getRenderedFieldsPreview()).toEqual([ { key: 'subTitle', value: 'First doc - subTitle' }, @@ -479,14 +480,19 @@ describe('Field editor Preview panel', () => { httpRequestsMockHelpers.setFieldPreviewResponse({ values: [scriptEmitResponse] }); const { - actions: { toggleFormRow, fields, waitForUpdates, getRenderedFieldsPreview }, + actions: { + toggleFormRow, + fields, + waitForUpdates, + waitForDocumentsAndPreviewUpdate, + getRenderedFieldsPreview, + }, } = testBed; await fields.updateName('myRuntimeField'); await toggleFormRow('value'); await fields.updateScript('echo("hello")'); - await waitForUpdates(); - await waitForUpdates(); + await waitForDocumentsAndPreviewUpdate(); // before expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'hello' }]); @@ -507,14 +513,19 @@ describe('Field editor Preview panel', () => { const { exists, find, - actions: { toggleFormRow, fields, waitForUpdates, getRenderedFieldsPreview }, + actions: { + toggleFormRow, + fields, + waitForUpdates, + waitForDocumentsAndPreviewUpdate, + getRenderedFieldsPreview, + }, } = testBed; await fields.updateName('myRuntimeField'); await toggleFormRow('value'); await fields.updateScript('bad()'); - await waitForUpdates(); - await waitForUpdates(); + await waitForDocumentsAndPreviewUpdate(); expect(exists('fieldPreviewItem')).toBe(false); expect(exists('indexPatternFieldList')).toBe(false); @@ -536,13 +547,12 @@ describe('Field editor Preview panel', () => { exists, find, form, - actions: { toggleFormRow, fields, waitForUpdates }, + actions: { toggleFormRow, fields, waitForUpdates, waitForDocumentsAndPreviewUpdate }, } = testBed; await fields.updateName('myRuntimeField'); await toggleFormRow('value'); - await waitForUpdates(); - await waitForUpdates(); + await waitForDocumentsAndPreviewUpdate(); // We will return no document from the search setSearchResponse([]); @@ -630,6 +640,7 @@ describe('Field editor Preview panel', () => { toggleFormRow, fields, waitForUpdates, + waitForDocumentsAndPreviewUpdate, getRenderedFieldsPreview, goToNextDocument, }, @@ -638,8 +649,7 @@ describe('Field editor Preview panel', () => { await toggleFormRow('value'); await fields.updateName('myRuntimeField'); await fields.updateScript('echo("hello world")'); - await waitForUpdates(); - await waitForUpdates(); + await waitForDocumentsAndPreviewUpdate(); expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'valueDoc1' }]); 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 b2095dc8a7535..a9c6e71988be7 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 @@ -196,79 +196,6 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { [format, fieldFormats] ); - const updatePreview = useCallback(async () => { - if (!needToUpdatePreview) { - return; - } - - setLastExecutePainlessReqParams({ - type: params.type, - script: params.script?.source, - documentId: currentDocId, - }); - - const currentApiCall = ++previewCount.current; - - const response = await getFieldPreview({ - index: currentDocIndex, - document: params.document!, - context: `${params.type!}_field` as FieldPreviewContext, - script: params.script!, - }); - - if (currentApiCall !== previewCount.current) { - // Discard this response as there is another one inflight - // or we have called reset() and don't need the response anymore. - return; - } - - setIsLoadingPreview(false); - - const { error: serverError } = response; - - if (serverError) { - // Server error (not an ES error) - const title = i18n.translate('indexPatternFieldEditor.fieldPreview.errorTitle', { - defaultMessage: 'Failed to load field preview', - }); - notifications.toasts.addError(serverError, { title }); - - return; - } - - const data = response.data ?? { values: [], error: {} }; - const { values, error } = data; - - if (error) { - const fallBackError = { - message: i18n.translate('indexPatternFieldEditor.fieldPreview.defaultErrorTitle', { - defaultMessage: 'Error executing the script.', - }), - }; - - setPreviewResponse({ - fields: [], - error: { code: 'PAINLESS_SCRIPT_ERROR', error: parseEsError(error, true) ?? fallBackError }, - }); - } else { - const [value] = values; - const formattedValue = valueFormatter(value); - - setPreviewResponse({ - fields: [{ key: params.name!, value, formattedValue }], - error: null, - }); - } - }, [ - needToUpdatePreview, - params, - currentDocIndex, - currentDocId, - getFieldPreview, - notifications.toasts, - valueFormatter, - ]); - const fetchSampleDocuments = useCallback( async (limit: number = 50) => { if (typeof limit !== 'number') { @@ -375,13 +302,84 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { // Make sure we disable the "Updating..." indicator as we have an error // and we won't fetch the preview setIsLoadingPreview(false); - } else { - updatePreview(); } }, - [indexPattern, search, updatePreview] + [indexPattern, search] ); + const updatePreview = useCallback(async () => { + if (!needToUpdatePreview) { + return; + } + + setLastExecutePainlessReqParams({ + type: params.type, + script: params.script?.source, + documentId: currentDocId, + }); + + const currentApiCall = ++previewCount.current; + + const response = await getFieldPreview({ + index: currentDocIndex, + document: params.document!, + context: `${params.type!}_field` as FieldPreviewContext, + script: params.script!, + }); + + if (currentApiCall !== previewCount.current) { + // Discard this response as there is another one inflight + // or we have called reset() and don't need the response anymore. + return; + } + + setIsLoadingPreview(false); + + const { error: serverError } = response; + + if (serverError) { + // Server error (not an ES error) + const title = i18n.translate('indexPatternFieldEditor.fieldPreview.errorTitle', { + defaultMessage: 'Failed to load field preview', + }); + notifications.toasts.addError(serverError, { title }); + + return; + } + + const data = response.data ?? { values: [], error: {} }; + const { values, error } = data; + + if (error) { + const fallBackError = { + message: i18n.translate('indexPatternFieldEditor.fieldPreview.defaultErrorTitle', { + defaultMessage: 'Error executing the script.', + }), + }; + + setPreviewResponse({ + fields: [], + error: { code: 'PAINLESS_SCRIPT_ERROR', error: parseEsError(error, true) ?? fallBackError }, + }); + } else { + const [value] = values; + const formattedValue = valueFormatter(value); + + setPreviewResponse({ + fields: [{ key: params.name!, value, formattedValue }], + error: null, + }); + } + }, [ + needToUpdatePreview, + params, + currentDocIndex, + currentDocId, + getFieldPreview, + notifications.toasts, + valueFormatter, + ]); + const goToNextDoc = useCallback(() => { if (currentIdx >= totalDocs - 1) { setClusterData((prev) => ({ ...prev, currentIdx: 0 })); From 937f2fa84dcf29ade02bbaab82ccf684a3130a0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien=20Loix?= Date: Tue, 27 Jul 2021 10:33:24 +0100 Subject: [PATCH 11/11] Add comments and small refactors --- .../helpers/common_actions.ts | 16 +++---- .../preview/field_preview_context.tsx | 48 +++++++++++-------- 2 files changed, 36 insertions(+), 28 deletions(-) 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 b26d9e09cb23f..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 @@ -67,14 +67,12 @@ export const getCommonActions = (testBed: TestBed) => { }; /** - * The prev preview update occurs after a debounce of 500ms and we simulate - * latency when searching ES documents (see setup_environment.tsx). - * This handler allows us to advance the jest timer and update the component - * @param ms time to move timer forward + * 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 (ms = 3000) => { + const waitForUpdates = async () => { await act(async () => { - jest.advanceTimersByTime(ms); + jest.runAllTimers(); }); testBed.component.update(); @@ -82,14 +80,16 @@ export const getCommonActions = (testBed: TestBed) => { /** * When often need to both wait for the documents to be fetched and - * then the preview to be fetched. We can't increase the `jest.advanceTimersByTime` time - * as those are 2 different operations. We will for that run all the timers to get to a stable state. + * 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(); }); 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 a9c6e71988be7..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 @@ -168,18 +168,26 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { }, []); 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])); - const isCurrentDocIdDefined = currentDocId !== ''; + if (!allParamsDefined) { + return false; + } const hasSomeParamsChanged = lastExecutePainlessRequestParams.type !== type || lastExecutePainlessRequestParams.script !== script?.source || lastExecutePainlessRequestParams.documentId !== currentDocId; - return allParamsDefined && isCurrentDocIdDefined && hasSomeParamsChanged; + return hasSomeParamsChanged; }, [type, script?.source, currentDocId, params, lastExecutePainlessRequestParams]); const valueFormatter = useCallback( @@ -199,7 +207,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { const fetchSampleDocuments = useCallback( async (limit: number = 50) => { if (typeof limit !== 'number') { - // We guard ourself from passing an event handler accidentally + // We guard ourself from passing an event accidentally throw new Error('The "limit" option must be a number'); } @@ -231,7 +239,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { currentIdx: 0, }); - setPreviewResponse((prev) => ({ ...prev, error: error ?? null })); + setPreviewResponse((prev) => ({ ...prev, error })); }, [indexPattern, search] ); @@ -244,7 +252,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { setIsFetchingDocument(true); - const [response, error] = await search + const [response, searchError] = await search .search({ params: { index: indexPattern.title, @@ -266,11 +274,11 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { const isDocumentFound = response?.rawResponse.hits.total > 0; const loadedDocuments: EsDocument[] = isDocumentFound ? response.rawResponse.hits.hits : []; - const errorToDisplay: Context['error'] = Boolean(error) + const error: Context['error'] = Boolean(searchError) ? { code: 'ERR_FETCHING_DOC', error: { - message: error.toString(), + message: searchError.toString(), }, } : isDocumentFound === false @@ -288,17 +296,14 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { } : null; - setPreviewResponse((prev) => ({ - ...prev, - error: errorToDisplay, - })); + setPreviewResponse((prev) => ({ ...prev, error })); setClusterData({ documents: loadedDocuments, currentIdx: 0, }); - if (errorToDisplay !== null) { + if (error !== null) { // Make sure we disable the "Updating..." indicator as we have an error // and we won't fetch the preview setIsLoadingPreview(false); @@ -347,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 = { @@ -473,7 +477,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { /** * 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 (needToUpdatePreview) { @@ -481,6 +485,10 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { } }, [needToUpdatePreview, customDocIdToLoad]); + /** + * Whenever we enter manually a document ID to load we'll clear the + * documents and the preview value. + */ useEffect(() => { if (customDocIdToLoad !== null) { setIsFetchingDocument(true); @@ -505,9 +513,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { }, [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) { @@ -517,7 +523,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { /** * 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({ @@ -526,8 +532,10 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { }); }, [currentDocument, updateParams]); + /** + * Whenever the name or the format changes we immediately update the preview + */ useEffect(() => { - // Whenever the name or the format changes we immediately update the preview setPreviewResponse((prev) => { const { fields: { 0: field },