diff --git a/packages/kbn-monaco/BUILD.bazel b/packages/kbn-monaco/BUILD.bazel index d2d9bf3f9a00c..caf5f7c25b569 100644 --- a/packages/kbn-monaco/BUILD.bazel +++ b/packages/kbn-monaco/BUILD.bazel @@ -36,12 +36,14 @@ RUNTIME_DEPS = [ "@npm//monaco-editor", "@npm//raw-loader", "@npm//regenerator-runtime", + "@npm//rxjs", ] TYPES_DEPS = [ "//packages/kbn-i18n", "@npm//antlr4ts", "@npm//monaco-editor", + "@npm//rxjs", "@npm//@types/jest", "@npm//@types/node", ] diff --git a/packages/kbn-monaco/__jest__/jest.mocks.ts b/packages/kbn-monaco/__jest__/jest.mocks.ts new file mode 100644 index 0000000000000..a210d7e171aa8 --- /dev/null +++ b/packages/kbn-monaco/__jest__/jest.mocks.ts @@ -0,0 +1,68 @@ +/* + * 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 { MockIModel } from './types'; + +const createMockModel = (ID: string) => { + const model: MockIModel = { + uri: '', + id: 'mockModel', + value: '', + getModeId: () => ID, + changeContentListeners: [], + setValue(newValue) { + this.value = newValue; + this.changeContentListeners.forEach((listener) => listener()); + }, + getValue() { + return this.value; + }, + onDidChangeContent(handler) { + this.changeContentListeners.push(handler); + }, + onDidChangeLanguage: (handler) => { + handler({ newLanguage: ID }); + }, + }; + + return model; +}; + +jest.mock('../src/monaco_imports', () => { + const original = jest.requireActual('../src/monaco_imports'); + const originalMonaco = original.monaco; + const originalEditor = original.monaco.editor; + + return { + ...original, + monaco: { + ...originalMonaco, + editor: { + ...originalEditor, + model: null, + createModel(ID: string) { + this.model = createMockModel(ID); + return this.model; + }, + onDidCreateModel(handler: (model: MockIModel) => void) { + if (!this.model) { + throw new Error( + `Model needs to be created by calling monaco.editor.createModel(ID) first.` + ); + } + handler(this.model); + }, + getModel() { + return this.model; + }, + getModels: () => [], + setModelMarkers: () => undefined, + }, + }, + }; +}); diff --git a/packages/kbn-monaco/__jest__/types.ts b/packages/kbn-monaco/__jest__/types.ts new file mode 100644 index 0000000000000..929964c5300fc --- /dev/null +++ b/packages/kbn-monaco/__jest__/types.ts @@ -0,0 +1,19 @@ +/* + * 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. + */ + +export interface MockIModel { + uri: string; + id: string; + value: string; + changeContentListeners: Array<() => void>; + getModeId: () => string; + setValue: (value: string) => void; + getValue: () => string; + onDidChangeContent: (handler: () => void) => void; + onDidChangeLanguage: (handler: (options: { newLanguage: string }) => void) => void; +} diff --git a/packages/kbn-monaco/src/painless/diagnostics_adapter.test.ts b/packages/kbn-monaco/src/painless/diagnostics_adapter.test.ts new file mode 100644 index 0000000000000..7c3a9b66a82e1 --- /dev/null +++ b/packages/kbn-monaco/src/painless/diagnostics_adapter.test.ts @@ -0,0 +1,147 @@ +/* + * 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 '../../__jest__/jest.mocks'; // Make sure this is the first import + +import { Subscription } from 'rxjs'; + +import { MockIModel } from '../../__jest__/types'; +import { LangValidation } from '../types'; +import { monaco } from '../monaco_imports'; +import { ID } from './constants'; + +import { DiagnosticsAdapter } from './diagnostics_adapter'; + +const getSyntaxErrors = jest.fn(async (): Promise => undefined); + +const getMockWorker = async () => { + return { + getSyntaxErrors, + } as any; +}; + +function flushPromises() { + return new Promise((resolve) => setImmediate(resolve)); +} + +describe('Painless DiagnosticAdapter', () => { + let diagnosticAdapter: DiagnosticsAdapter; + let subscription: Subscription; + let model: MockIModel; + let validation: LangValidation; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(async () => { + model = monaco.editor.createModel(ID) as unknown as MockIModel; + diagnosticAdapter = new DiagnosticsAdapter(getMockWorker); + + // validate() has a promise we need to wait for + // --> await worker.getSyntaxErrors() + await flushPromises(); + + subscription = diagnosticAdapter.validation$.subscribe((newValidation) => { + validation = newValidation; + }); + }); + + afterEach(() => { + if (subscription) { + subscription.unsubscribe(); + } + }); + + test('should validate when the content changes', async () => { + expect(validation!.isValidating).toBe(false); + + model.setValue('new content'); + await flushPromises(); + expect(validation!.isValidating).toBe(true); + + jest.advanceTimersByTime(500); // there is a 500ms debounce for the validate() to trigger + await flushPromises(); + + expect(validation!.isValidating).toBe(false); + + model.setValue('changed'); + // Flushing promise here is not actually required but adding it to make sure the test + // works as expected even when doing so. + await flushPromises(); + expect(validation!.isValidating).toBe(true); + + // when we clear the content we immediately set the + // "isValidating" to false and mark the content as valid. + // No need to wait for the setTimeout + model.setValue(''); + await flushPromises(); + expect(validation!.isValidating).toBe(false); + expect(validation!.isValid).toBe(true); + }); + + test('should prevent race condition of multiple content change and validation triggered', async () => { + const errors = ['Syntax error returned']; + + getSyntaxErrors.mockResolvedValueOnce(errors); + + expect(validation!.isValidating).toBe(false); + + model.setValue('foo'); + jest.advanceTimersByTime(300); // only 300ms out of the 500ms + + model.setValue('bar'); // This will cancel the first setTimeout + + jest.advanceTimersByTime(300); // Again, only 300ms out of the 500ms. + await flushPromises(); + + expect(validation!.isValidating).toBe(true); // we are still validating + + jest.advanceTimersByTime(200); // rest of the 500ms + await flushPromises(); + + expect(validation!.isValidating).toBe(false); + expect(validation!.isValid).toBe(false); + expect(validation!.errors).toBe(errors); + }); + + test('should prevent race condition (2) of multiple content change and validation triggered', async () => { + const errors1 = ['First error returned']; + const errors2 = ['Second error returned']; + + getSyntaxErrors + .mockResolvedValueOnce(errors1) // first call + .mockResolvedValueOnce(errors2); // second call + + model.setValue('foo'); + // By now we are waiting on the worker to await getSyntaxErrors() + // we won't flush the promise to not pass this point in time just yet + jest.advanceTimersByTime(700); + + // We change the value at the same moment + model.setValue('bar'); + // now we pass the await getSyntaxErrors() point but its result (errors1) should be stale and discarted + await flushPromises(); + + jest.advanceTimersByTime(300); + await flushPromises(); + + expect(validation!.isValidating).toBe(true); // we are still validating value "bar" + + jest.advanceTimersByTime(200); // rest of the 500ms + await flushPromises(); + + expect(validation!.isValidating).toBe(false); + expect(validation!.isValid).toBe(false); + // We have the second error response, the first one has been discarted + expect(validation!.errors).toBe(errors2); + }); +}); diff --git a/packages/kbn-monaco/src/painless/diagnostics_adapter.ts b/packages/kbn-monaco/src/painless/diagnostics_adapter.ts index 3d13d76743dbc..a113adb74f22d 100644 --- a/packages/kbn-monaco/src/painless/diagnostics_adapter.ts +++ b/packages/kbn-monaco/src/painless/diagnostics_adapter.ts @@ -6,7 +6,10 @@ * Side Public License, v 1. */ +import { BehaviorSubject } from 'rxjs'; + import { monaco } from '../monaco_imports'; +import { SyntaxErrors, LangValidation } from '../types'; import { ID } from './constants'; import { WorkerAccessor } from './language'; import { PainlessError } from './worker'; @@ -18,11 +21,17 @@ const toDiagnostics = (error: PainlessError): monaco.editor.IMarkerData => { }; }; -export interface SyntaxErrors { - [modelId: string]: PainlessError[]; -} export class DiagnosticsAdapter { private errors: SyntaxErrors = {}; + private validation = new BehaviorSubject({ + isValid: true, + isValidating: false, + errors: [], + }); + // To avoid stale validation data we keep track of the latest call to validate(). + private validateIdx = 0; + + public validation$ = this.validation.asObservable(); constructor(private worker: WorkerAccessor) { const onModelAdd = (model: monaco.editor.IModel): void => { @@ -35,14 +44,27 @@ export class DiagnosticsAdapter { return; } + const idx = ++this.validateIdx; // Disable any possible inflight validation + clearTimeout(handle); + // Reset the model markers if an empty string is provided on change if (model.getValue().trim() === '') { + this.validation.next({ + isValid: true, + isValidating: false, + errors: [], + }); return monaco.editor.setModelMarkers(model, ID, []); } + this.validation.next({ + ...this.validation.value, + isValidating: true, + }); // Every time a new change is made, wait 500ms before validating - clearTimeout(handle); - handle = setTimeout(() => this.validate(model.uri), 500); + handle = setTimeout(() => { + this.validate(model.uri, idx); + }, 500); }); model.onDidChangeLanguage(({ newLanguage }) => { @@ -51,21 +73,33 @@ export class DiagnosticsAdapter { if (newLanguage !== ID) { return monaco.editor.setModelMarkers(model, ID, []); } else { - this.validate(model.uri); + this.validate(model.uri, ++this.validateIdx); } }); - this.validate(model.uri); + this.validation.next({ + ...this.validation.value, + isValidating: true, + }); + this.validate(model.uri, ++this.validateIdx); } }; monaco.editor.onDidCreateModel(onModelAdd); monaco.editor.getModels().forEach(onModelAdd); } - private async validate(resource: monaco.Uri): Promise { + private async validate(resource: monaco.Uri, idx: number): Promise { + if (idx !== this.validateIdx) { + return; + } + const worker = await this.worker(resource); const errorMarkers = await worker.getSyntaxErrors(resource.toString()); + if (idx !== this.validateIdx) { + return; + } + if (errorMarkers) { const model = monaco.editor.getModel(resource); this.errors = { @@ -75,6 +109,9 @@ export class DiagnosticsAdapter { // Set the error markers and underline them with "Error" severity monaco.editor.setModelMarkers(model!, ID, errorMarkers.map(toDiagnostics)); } + + const isValid = errorMarkers === undefined || errorMarkers.length === 0; + this.validation.next({ isValidating: false, isValid, errors: errorMarkers ?? [] }); } public getSyntaxErrors() { diff --git a/packages/kbn-monaco/src/painless/index.ts b/packages/kbn-monaco/src/painless/index.ts index 3bba7643e28b6..793dc5142a41e 100644 --- a/packages/kbn-monaco/src/painless/index.ts +++ b/packages/kbn-monaco/src/painless/index.ts @@ -8,7 +8,7 @@ import { ID } from './constants'; import { lexerRules, languageConfiguration } from './lexer_rules'; -import { getSuggestionProvider, getSyntaxErrors } from './language'; +import { getSuggestionProvider, getSyntaxErrors, validation$ } from './language'; import { CompleteLangModuleType } from '../types'; export const PainlessLang: CompleteLangModuleType = { @@ -17,6 +17,7 @@ export const PainlessLang: CompleteLangModuleType = { lexerRules, languageConfiguration, getSyntaxErrors, + validation$, }; export * from './types'; diff --git a/packages/kbn-monaco/src/painless/language.ts b/packages/kbn-monaco/src/painless/language.ts index 3cb26d970fc7d..abeee8d501f31 100644 --- a/packages/kbn-monaco/src/painless/language.ts +++ b/packages/kbn-monaco/src/painless/language.ts @@ -5,15 +5,16 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import { Observable, of } from 'rxjs'; import { monaco } from '../monaco_imports'; import { WorkerProxyService, EditorStateService } from './lib'; +import { LangValidation, SyntaxErrors } from '../types'; import { ID } from './constants'; import { PainlessContext, PainlessAutocompleteField } from './types'; import { PainlessWorker } from './worker'; import { PainlessCompletionAdapter } from './completion_adapter'; -import { DiagnosticsAdapter, SyntaxErrors } from './diagnostics_adapter'; +import { DiagnosticsAdapter } from './diagnostics_adapter'; const workerProxyService = new WorkerProxyService(); const editorStateService = new EditorStateService(); @@ -37,9 +38,13 @@ let diagnosticsAdapter: DiagnosticsAdapter; // Returns syntax errors for all models by model id export const getSyntaxErrors = (): SyntaxErrors => { - return diagnosticsAdapter.getSyntaxErrors(); + return diagnosticsAdapter?.getSyntaxErrors() ?? {}; }; +export const validation$: () => Observable = () => + diagnosticsAdapter?.validation$ || + of({ isValid: true, isValidating: false, errors: [] }); + monaco.languages.onLanguage(ID, async () => { workerProxyService.setup(); diff --git a/packages/kbn-monaco/src/types.ts b/packages/kbn-monaco/src/types.ts index 0e20021bf69eb..8512ef1ac58c0 100644 --- a/packages/kbn-monaco/src/types.ts +++ b/packages/kbn-monaco/src/types.ts @@ -5,6 +5,8 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +import type { Observable } from 'rxjs'; + import { monaco } from './monaco_imports'; export interface LangModuleType { @@ -19,4 +21,23 @@ export interface CompleteLangModuleType extends LangModuleType { languageConfiguration: monaco.languages.LanguageConfiguration; getSuggestionProvider: Function; getSyntaxErrors: Function; + validation$: () => Observable; +} + +export interface EditorError { + startLineNumber: number; + startColumn: number; + endLineNumber: number; + endColumn: number; + message: string; +} + +export interface LangValidation { + isValidating: boolean; + isValid: boolean; + errors: EditorError[]; +} + +export interface SyntaxErrors { + [modelId: string]: EditorError[]; } diff --git a/packages/kbn-monaco/tsconfig.json b/packages/kbn-monaco/tsconfig.json index 959051b17b782..4a373843c555a 100644 --- a/packages/kbn-monaco/tsconfig.json +++ b/packages/kbn-monaco/tsconfig.json @@ -14,5 +14,6 @@ }, "include": [ "src/**/*", + "__jest__/**/*", ] } diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/use_async_validation_data.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/use_async_validation_data.mdx deleted file mode 100644 index 8020a54596b46..0000000000000 --- a/src/plugins/es_ui_shared/static/forms/docs/core/use_async_validation_data.mdx +++ /dev/null @@ -1,36 +0,0 @@ ---- -id: formLibCoreUseAsyncValidationData -slug: /form-lib/core/use-async-validation-data -title: useAsyncValidationData() -summary: Provide dynamic data to your validators... asynchronously -tags: ['forms', 'kibana', 'dev'] -date: 2021-08-20 ---- - -**Returns:** `[Observable, (nextValue: T|undefined) => void]` - -This hook creates for you an observable and a handler to update its value. You can then pass the observable directly to . - -See an example on how to use this hook in the section. - -## Options - -### state (optional) - -**Type:** `any` - -If you provide a state when calling the hook, the observable value will keep in sync with the state. - -```js -const MyForm = () => { - ... - const [indices, setIndices] = useState([]); - // Whenever the "indices" state changes, the "indices$" Observable will be updated - const [indices$] = useAsyncValidationData(indices); - - ... - - - -} -``` \ No newline at end of file diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/use_behavior_subject.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/use_behavior_subject.mdx new file mode 100644 index 0000000000000..f7eca9c360ac4 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/core/use_behavior_subject.mdx @@ -0,0 +1,26 @@ +--- +id: formLibCoreUseBehaviorSubject +slug: /form-lib/utils/use-behavior-subject +title: useBehaviorSubject() +summary: Util to create a rxjs BehaviorSubject with a handler to change its value +tags: ['forms', 'kibana', 'dev'] +date: 2021-08-20 +--- + +**Returns:** `[Observable, (nextValue: T|undefined) => void]` + +This hook creates for you a rxjs BehaviorSubject and a handler to update its value. + +See an example on how to use this hook in the section. + +## Options + +### initialState + +**Type:** `any` + +The initial value of the BehaviorSubject. + +```js +const [indices$, nextIndices] = useBehaviorSubject([]); +``` \ No newline at end of file diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/use_field.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/use_field.mdx index fd5f3b26cdf0d..dd073e0b38d1f 100644 --- a/src/plugins/es_ui_shared/static/forms/docs/core/use_field.mdx +++ b/src/plugins/es_ui_shared/static/forms/docs/core/use_field.mdx @@ -207,6 +207,14 @@ For example: when we add an item to the ComboBox array, we don't want to block t By default, when any of the validation fails, the following validation are not executed. If you still want to execute the following validation(s), set the `exitOnFail` to `false`. +##### isAsync + +**Type:** `boolean` +**Default:** `false` + +Flag to indicate if the validation is asynchronous. If not specified the lib will first try to run all the validations synchronously and if it detects a Promise it will run the validations a second time asynchronously. This means that HTTP request will be called twice which is not ideal. +**It is thus recommended** to set the `isAsync` flag to `true` for all asynchronous validations. + #### deserializer **Type:** `SerializerFunc` @@ -342,9 +350,9 @@ Use this prop to pass down dynamic data to your field validator. The data is the See an example on how to use this prop in the section. -### validationData$ +### validationDataProvider -Use this prop to pass down an Observable into which you can send, asynchronously, dynamic data required inside your validation. +Use this prop to pass down a Promise to provide dynamic data asynchronously in your validation. See an example on how to use this prop in the section. diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/use_form_data.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/use_form_data.mdx index 17276f41b3dac..0deb449591871 100644 --- a/src/plugins/es_ui_shared/static/forms/docs/core/use_form_data.mdx +++ b/src/plugins/es_ui_shared/static/forms/docs/core/use_form_data.mdx @@ -56,6 +56,31 @@ const [{ type }] = useFormData({ watch: 'type' }); const [{ type, subType }] = useFormData({ watch: ['type', 'subType'] }); ``` +### onChange + +**Type:** `(data: T) => void` + +This handler lets you listen to form fields value change _before_ any validation is executed. + +```js +// With "onChange": listen to changes before any validation is triggered +const onFieldChange = useCallback(({ myField, otherField }) => { + // React to changes before any validation is executed +}, []); + +useFormData({ + watch: ['myField', 'otherField'], + onChange: onFieldChange +}); + +// Without "onChange": the way to go most of the time +const [{ myField, otherField }] = useFormData({ watch['myField', 'otherField'] }); + +useEffect(() => { + // React to changes after validation have been triggered +}, [myField, otherField]); +``` + ## Return As you have noticed, you get back an array from the hook. The first element of the array is form data and the second argument is a handler to get the **serialized** form data if needed. diff --git a/src/plugins/es_ui_shared/static/forms/docs/examples/validation.mdx b/src/plugins/es_ui_shared/static/forms/docs/examples/validation.mdx index 8526a8912ba08..43ec8da11c5cc 100644 --- a/src/plugins/es_ui_shared/static/forms/docs/examples/validation.mdx +++ b/src/plugins/es_ui_shared/static/forms/docs/examples/validation.mdx @@ -334,7 +334,7 @@ const MyForm = () => { Great. Now let's imagine that you want to add a validation to the `indexName` field and mark it as invalid if it does not match at least one index in the cluster. For that you need to provide dynamic data (the list of indices fetched) which is not immediately accesible when the field value changes (and the validation kicks in). We need to ask the validation to **wait** until we have fetched the indices and then have access to the dynamic data. -For that we will use the `validationData$` Observable that you can pass to the field. Whenever a value is sent to the observable (**after** the field value has changed, important!), it will be available in the validator through the `customData.provider()` handler. +For that we will use the `validationDataProvider` prop that you can pass to the field. This data provider will be available in the validator through the `customData.provider()` handler. ```js // form.schema.ts @@ -357,15 +357,28 @@ const schema = { } // myform.tsx +import { firstValueFrom } from '@kbn/std'; + const MyForm = () => { ... const [indices, setIndices] = useState([]); - const [indices$, nextIndices] = useAsyncValidationData(); // Use the provided hook to create the Observable + const [indices$, nextIndices] = useBehaviorSubject(null); // Use the provided util hook to create an observable + + const indicesProvider = useCallback(() => { + // We wait until we have fetched the indices. + // The result will then be sent to the validator (await provider() call); + return await firstValueFrom(indices$.pipe(first((data) => data !== null))); + }, [indices$, nextIndices]); const fetchIndices = useCallback(async () => { + // Reset the subject to not send stale data to the validator + nextIndices(null); + const result = await httpClient.get(`/api/search/${indexName}`); setIndices(result); - nextIndices(result); // Send the indices to your validator "provider()" + + // Send the indices to the BehaviorSubject to resolve the validator "provider()" + nextIndices(result); }, [indexName]); // Whenever the indexName changes we fetch the indices @@ -377,7 +390,7 @@ const MyForm = () => { <>
/* Pass the Observable to your field */ - + ... diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx index 0950f2dabb1b7..cbf0d9d619636 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx @@ -6,13 +6,14 @@ * Side Public License, v 1. */ -import React, { useEffect, FunctionComponent, useState } from 'react'; +import React, { useEffect, FunctionComponent, useState, useCallback } from 'react'; import { act } from 'react-dom/test-utils'; +import { first } from 'rxjs/operators'; import { registerTestBed, TestBed } from '../shared_imports'; import { FormHook, OnUpdateHandler, FieldConfig, FieldHook } from '../types'; import { useForm } from '../hooks/use_form'; -import { useAsyncValidationData } from '../hooks/use_async_validation_data'; +import { useBehaviorSubject } from '../hooks/utils/use_behavior_subject'; import { Form } from './form'; import { UseField } from './use_field'; @@ -420,8 +421,18 @@ describe('', () => { const TestComp = ({ validationData }: DynamicValidationDataProps) => { const { form } = useForm({ schema }); - const [stateValue, setStateValue] = useState('initialValue'); - const [validationData$, next] = useAsyncValidationData(stateValue); + const [validationData$, next] = useBehaviorSubject(undefined); + + const validationDataProvider = useCallback(async () => { + const data = await validationData$ + .pipe(first((value) => value !== undefined)) + .toPromise(); + + // Clear the Observable so we are forced to send a new value to + // resolve the provider + next(undefined); + return data; + }, [validationData$, next]); const setInvalidDynamicData = () => { next('bad'); @@ -431,22 +442,12 @@ describe('', () => { next('good'); }; - // Updating the state should emit a new value in the observable - // which in turn should be available in the validation and allow it to complete. - const setStateValueWithValidValue = () => { - setStateValue('good'); - }; - - const setStateValueWithInValidValue = () => { - setStateValue('bad'); - }; - return (
<> {/* Dynamic async validation data with an observable. The validation will complete **only after** the observable has emitted a value. */} - path="name" validationData$={validationData$}> + path="name" validationDataProvider={validationDataProvider}> {(field) => { onNameFieldHook(field); return ( @@ -479,15 +480,6 @@ describe('', () => { - - ); @@ -519,7 +511,8 @@ describe('', () => { await act(async () => { jest.advanceTimersByTime(10000); }); - // The field is still validating as no value has been sent to the observable + // The field is still validating as the validationDataProvider has not resolved yet + // (no value has been sent to the observable) expect(nameFieldHook?.isValidating).toBe(true); // We now send a valid value to the observable @@ -545,38 +538,6 @@ describe('', () => { expect(nameFieldHook?.getErrorsMessages()).toBe('Invalid dynamic data'); }); - test('it should access dynamic data coming after the field value changed, **in sync** with a state change', async () => { - const { form, find } = setupDynamicData(); - - await act(async () => { - form.setInputValue('nameField', 'newValue'); - }); - expect(nameFieldHook?.isValidating).toBe(true); - - // We now update the state with a valid value - // this should update the observable - await act(async () => { - find('setValidStateValueBtn').simulate('click'); - }); - - expect(nameFieldHook?.isValidating).toBe(false); - expect(nameFieldHook?.isValid).toBe(true); - - // Let's change the input value to trigger the validation once more - await act(async () => { - form.setInputValue('nameField', 'anotherValue'); - }); - expect(nameFieldHook?.isValidating).toBe(true); - - // And change the state with an invalid value - await act(async () => { - find('setInvalidStateValueBtn').simulate('click'); - }); - - expect(nameFieldHook?.isValidating).toBe(false); - expect(nameFieldHook?.isValid).toBe(false); - }); - test('it should access dynamic data provided through props', async () => { let { form } = setupDynamicData({ validationData: 'good' }); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.tsx index a73eee1bd8bd3..49ee21667752a 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.tsx @@ -7,7 +7,6 @@ */ import React, { FunctionComponent } from 'react'; -import { Observable } from 'rxjs'; import { FieldHook, FieldConfig, FormData } from '../types'; import { useField } from '../hooks'; @@ -23,8 +22,6 @@ export interface Props { /** * Use this prop to pass down dynamic data **asynchronously** to your validators. * Your validator accesses the dynamic data by resolving the provider() Promise. - * The Promise will resolve **when a new value is sent** to the validationData$ Observable. - * * ```typescript * validator: ({ customData }) => { * // Wait until a value is sent to the "validationData$" Observable @@ -32,7 +29,7 @@ export interface Props { * } * ``` */ - validationData$?: Observable; + validationDataProvider?: () => Promise; /** * Use this prop to pass down dynamic data to your validators. The validation data * is then accessible in your validator inside the `customData.value` property. @@ -63,7 +60,7 @@ function UseFieldComp(props: Props(props: Props(form, path, fieldConfig, onChange, onError, { - customValidationData$, customValidationData, + customValidationDataProvider, }); // Children prevails over anything else provided. diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/index.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/index.ts index 6f2dc768508ec..f4911bfaadfa4 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/index.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/index.ts @@ -11,4 +11,4 @@ export { useField } from './use_field'; export { useForm } from './use_form'; export { useFormData } from './use_form_data'; export { useFormIsModified } from './use_form_is_modified'; -export { useAsyncValidationData } from './use_async_validation_data'; +export { useBehaviorSubject } from './utils'; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_async_validation_data.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_async_validation_data.ts deleted file mode 100644 index 21d5e101536ae..0000000000000 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_async_validation_data.ts +++ /dev/null @@ -1,36 +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 { useCallback, useRef, useMemo, useEffect } from 'react'; -import { Subject, Observable } from 'rxjs'; - -export const useAsyncValidationData = (state?: T) => { - const validationData$ = useRef>(); - - const getValidationData$ = useCallback(() => { - if (validationData$.current === undefined) { - validationData$.current = new Subject(); - } - return validationData$.current; - }, []); - - const hook: [Observable, (value?: T) => void] = useMemo(() => { - const subject = getValidationData$(); - - const observable = subject.asObservable(); - const next = subject.next.bind(subject); - - return [observable, next]; - }, [getValidationData$]); - - // Whenever the state changes we update the observable - useEffect(() => { - getValidationData$().next(state); - }, [state, getValidationData$]); - - return hook; -}; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts index c01295f6ee42c..5079a8b69ba80 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts @@ -7,8 +7,6 @@ */ import { useMemo, useState, useEffect, useRef, useCallback } from 'react'; -import { Observable } from 'rxjs'; -import { first } from 'rxjs/operators'; import { FormHook, @@ -33,9 +31,12 @@ export const useField = ( valueChangeListener?: (value: I) => void, errorChangeListener?: (errors: string[] | null) => void, { - customValidationData$, customValidationData = null, - }: { customValidationData$?: Observable; customValidationData?: unknown } = {} + customValidationDataProvider, + }: { + customValidationData?: unknown; + customValidationDataProvider?: () => Promise; + } = {} ) => { const { type = FIELD_TYPES.TEXT, @@ -59,7 +60,7 @@ export const useField = ( __addField, __removeField, __updateFormDataAt, - __validateFields, + validateFields, __getFormData$, } = form; @@ -94,6 +95,14 @@ export const useField = ( errors: null, }); + const hasAsyncValidation = useMemo( + () => + validations === undefined + ? false + : validations.some((validation) => validation.isAsync === true), + [validations] + ); + // ---------------------------------- // -- HELPERS // ---------------------------------- @@ -147,7 +156,7 @@ export const useField = ( __updateFormDataAt(path, value); // Validate field(s) (this will update the form.isValid state) - await __validateFields(fieldsToValidateOnChange ?? [path]); + await validateFields(fieldsToValidateOnChange ?? [path]); if (isMounted.current === false) { return; @@ -156,7 +165,7 @@ export const useField = ( /** * If we have set a delay to display the error message after the field value has changed, * we first check that this is the last "change iteration" (=== the last keystroke from the user) - * and then, we verify how long we've already waited for as form.__validateFields() is asynchronous + * and then, we verify how long we've already waited for as form.validateFields() is asynchronous * and might already have taken more than the specified delay) */ if (changeIteration === changeCounter.current) { @@ -181,7 +190,7 @@ export const useField = ( valueChangeDebounceTime, fieldsToValidateOnChange, __updateFormDataAt, - __validateFields, + validateFields, ]); // Cancel any inflight validation (e.g an HTTP Request) @@ -238,18 +247,13 @@ export const useField = ( return false; }; - let dataProvider: () => Promise = () => Promise.resolve(null); - - if (customValidationData$) { - dataProvider = () => customValidationData$.pipe(first()).toPromise(); - } + const dataProvider: () => Promise = + customValidationDataProvider ?? (() => Promise.resolve(undefined)); const runAsync = async () => { const validationErrors: ValidationError[] = []; for (const validation of validations) { - inflightValidation.current = null; - const { validator, exitOnFail = true, @@ -271,6 +275,8 @@ export const useField = ( const validationResult = await inflightValidation.current; + inflightValidation.current = null; + if (!validationResult) { continue; } @@ -345,17 +351,22 @@ export const useField = ( return validationErrors; }; + if (hasAsyncValidation) { + return runAsync(); + } + // We first try to run the validations synchronously return runSync(); }, [ cancelInflightValidation, validations, + hasAsyncValidation, getFormData, getFields, path, customValidationData, - customValidationData$, + customValidationDataProvider, ] ); @@ -388,7 +399,6 @@ export const useField = ( onlyBlocking = false, } = validationData; - setIsValidated(true); setValidating(true); // By the time our validate function has reached completion, it’s possible @@ -401,6 +411,7 @@ export const useField = ( if (validateIteration === validateCounter.current && isMounted.current) { // This is the most recent invocation setValidating(false); + setIsValidated(true); // Update the errors array setStateErrors((prev) => { const filteredErrors = filterErrors(prev, validationType); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx index 92a9876f1cd30..e3e818729340e 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx @@ -572,4 +572,63 @@ describe('useForm() hook', () => { expect(isValid).toBe(false); }); }); + + describe('form.getErrors()', () => { + test('should return the errors in the form', async () => { + const TestComp = () => { + const { form } = useForm(); + formHook = form; + + return ( +
+ + + { + if (value === 'bad') { + return { + message: 'Field2 is invalid', + }; + } + }, + }, + ], + }} + /> + + ); + }; + + const { + form: { setInputValue }, + } = registerTestBed(TestComp)() as TestBed; + + let errors: string[] = formHook!.getErrors(); + expect(errors).toEqual([]); + + await act(async () => { + await formHook!.submit(); + }); + errors = formHook!.getErrors(); + expect(errors).toEqual(['Field1 can not be empty']); + + await setInputValue('field2', 'bad'); + errors = formHook!.getErrors(); + expect(errors).toEqual(['Field1 can not be empty', 'Field2 is invalid']); + }); + }); }); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts index 23827c0d1aa3b..f8a773597a823 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts @@ -66,6 +66,7 @@ export function useForm( const [isSubmitted, setIsSubmitted] = useState(false); const [isSubmitting, setSubmitting] = useState(false); const [isValid, setIsValid] = useState(undefined); + const [errorMessages, setErrorMessages] = useState<{ [fieldName: string]: string }>({}); const fieldsRefs = useRef({}); const fieldsRemovedRefs = useRef({}); @@ -73,6 +74,19 @@ export function useForm( const isMounted = useRef(false); const defaultValueDeserialized = useRef(defaultValueMemoized); + /** + * We have both a state and a ref for the error messages so the consumer can, in the same callback, + * validate the form **and** have the errors returned immediately. + * + * ``` + * const myHandler = useCallback(async () => { + * const isFormValid = await validate(); + * const errors = getErrors(); // errors from the validate() call are there + * }, [validate, getErrors]); + * ``` + */ + const errorMessagesRef = useRef<{ [fieldName: string]: string }>({}); + // formData$ is an observable we can subscribe to in order to receive live // update of the raw form data. As an observable it does not trigger any React // render(). @@ -97,6 +111,34 @@ export function useForm( [getFormData$] ); + const updateFieldErrorMessage = useCallback((path: string, errorMessage: string | null) => { + setErrorMessages((prev) => { + const previousMessageValue = prev[path]; + + if ( + errorMessage === previousMessageValue || + (previousMessageValue === undefined && errorMessage === null) + ) { + // Don't update the state, the error message has not changed. + return prev; + } + + if (errorMessage === null) { + // We strip out previous error message + const { [path]: discard, ...next } = prev; + errorMessagesRef.current = next; + return next; + } + + const next = { + ...prev, + [path]: errorMessage, + }; + errorMessagesRef.current = next; + return next; + }); + }, []); + const fieldsToArray = useCallback<() => FieldHook[]>(() => Object.values(fieldsRefs.current), []); const getFieldsForOutput = useCallback( @@ -158,7 +200,7 @@ export function useForm( }); }, [fieldsToArray]); - const validateFields: FormHook['__validateFields'] = useCallback( + const validateFields: FormHook['validateFields'] = useCallback( async (fieldNames, onlyBlocking = false) => { const fieldsToValidate = fieldNames .map((name) => fieldsRefs.current[name]) @@ -224,6 +266,7 @@ export function useForm( delete fieldsRemovedRefs.current[field.path]; updateFormDataAt(field.path, field.value); + updateFieldErrorMessage(field.path, field.getErrorsMessages()); if (!fieldExists && !field.isValidated) { setIsValid(undefined); @@ -235,7 +278,7 @@ export function useForm( setIsSubmitted(false); } }, - [updateFormDataAt] + [updateFormDataAt, updateFieldErrorMessage] ); const removeField: FormHook['__removeField'] = useCallback( @@ -247,7 +290,7 @@ export function useForm( // Keep a track of the fields that have been removed from the form // This will allow us to know if the form has been modified fieldsRemovedRefs.current[name] = fieldsRefs.current[name]; - + updateFieldErrorMessage(name, null); delete fieldsRefs.current[name]; delete currentFormData[name]; }); @@ -267,7 +310,7 @@ export function useForm( return prev; }); }, - [getFormData$, updateFormData$, fieldsToArray] + [getFormData$, updateFormData$, fieldsToArray, updateFieldErrorMessage] ); const getFormDefaultValue: FormHook['__getFormDefaultValue'] = useCallback( @@ -306,15 +349,8 @@ export function useForm( if (isValid === true) { return []; } - - return fieldsToArray().reduce((acc, field) => { - const fieldError = field.getErrorsMessages(); - if (fieldError === null) { - return acc; - } - return [...acc, fieldError]; - }, [] as string[]); - }, [isValid, fieldsToArray]); + return Object.values({ ...errorMessages, ...errorMessagesRef.current }); + }, [isValid, errorMessages]); const validate: FormHook['validate'] = useCallback(async (): Promise => { // Maybe some field are being validated because of their async validation(s). @@ -458,6 +494,7 @@ export function useForm( getFormData, getErrors, reset, + validateFields, __options: formOptions, __getFormData$: getFormData$, __updateFormDataAt: updateFormDataAt, @@ -467,7 +504,6 @@ export function useForm( __addField: addField, __removeField: removeField, __getFieldsRemoved: getFieldsRemoved, - __validateFields: validateFields, }; }, [ isSubmitted, diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.test.tsx index c6f920ef88c69..614d4a5f3fd1d 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.test.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.test.tsx @@ -15,7 +15,7 @@ import { useForm } from './use_form'; import { useFormData, HookReturn } from './use_form_data'; interface Props { - onChange(data: HookReturn): void; + onHookValueChange(data: HookReturn): void; watch?: string | string[]; } @@ -36,16 +36,16 @@ interface Form3 { } describe('useFormData() hook', () => { - const HookListenerComp = function ({ onChange, watch }: Props) { + const HookListenerComp = function ({ onHookValueChange, watch }: Props) { const hookValue = useFormData({ watch }); const isMounted = useRef(false); useEffect(() => { if (isMounted.current) { - onChange(hookValue); + onHookValueChange(hookValue); } isMounted.current = true; - }, [hookValue, onChange]); + }, [hookValue, onHookValueChange]); return null; }; @@ -77,7 +77,7 @@ describe('useFormData() hook', () => { beforeEach(() => { onChangeSpy = jest.fn(); - testBed = setup({ onChange: onChangeSpy }) as TestBed; + testBed = setup({ onHookValueChange: onChangeSpy }) as TestBed; }); test('should return the form data', () => { @@ -126,7 +126,7 @@ describe('useFormData() hook', () => { beforeEach(() => { onChangeSpy = jest.fn(); - setup({ onChange: onChangeSpy }); + setup({ onHookValueChange: onChangeSpy }); }); test('should expose a handler to build the form data', () => { @@ -171,7 +171,7 @@ describe('useFormData() hook', () => { beforeEach(() => { onChangeSpy = jest.fn(); - testBed = setup({ watch: 'title', onChange: onChangeSpy }) as TestBed; + testBed = setup({ watch: 'title', onHookValueChange: onChangeSpy }) as TestBed; }); test('should not listen to changes on fields we are not interested in', async () => { @@ -199,13 +199,13 @@ describe('useFormData() hook', () => { return onChangeSpy.mock.calls[onChangeSpy.mock.calls.length - 1][0] as HookReturn; }; - const TestComp = ({ onChange }: Props) => { + const TestComp = ({ onHookValueChange }: Props) => { const { form } = useForm(); const hookValue = useFormData({ form }); useEffect(() => { - onChange(hookValue); - }, [hookValue, onChange]); + onHookValueChange(hookValue); + }, [hookValue, onHookValueChange]); return (
@@ -220,7 +220,7 @@ describe('useFormData() hook', () => { beforeEach(() => { onChangeSpy = jest.fn(); - testBed = setup({ onChange: onChangeSpy }) as TestBed; + testBed = setup({ onHookValueChange: onChangeSpy }) as TestBed; }); test('should allow a form to be provided when the hook is called outside of the FormDataContext', async () => { @@ -239,5 +239,71 @@ describe('useFormData() hook', () => { expect(updatedData).toEqual({ title: 'titleChanged' }); }); }); + + describe('onChange', () => { + let testBed: TestBed; + let onChangeSpy: jest.Mock; + let validationSpy: jest.Mock; + + const TestComp = () => { + const { form } = useForm(); + useFormData({ form, onChange: onChangeSpy }); + + return ( + + { + // This spy should be called **after** the onChangeSpy + validationSpy(); + }, + }, + ], + }} + /> + + ); + }; + + const setup = registerTestBed(TestComp, { + memoryRouter: { wrapComponent: false }, + }); + + beforeEach(() => { + onChangeSpy = jest.fn(); + validationSpy = jest.fn(); + testBed = setup({ watch: 'title' }) as TestBed; + }); + + test('should call onChange handler _before_ running the validations', async () => { + const { + form: { setInputValue }, + } = testBed; + + onChangeSpy.mockReset(); // Reset our counters + validationSpy.mockReset(); + + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(validationSpy).not.toHaveBeenCalled(); + + await act(async () => { + setInputValue('titleField', 'titleChanged'); + }); + + expect(onChangeSpy).toHaveBeenCalled(); + expect(validationSpy).toHaveBeenCalled(); + + const onChangeCallOrder = onChangeSpy.mock.invocationCallOrder[0]; + const validationCallOrder = validationSpy.mock.invocationCallOrder[0]; + + // onChange called before validation + expect(onChangeCallOrder).toBeLessThan(validationCallOrder); + }); + }); }); }); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.ts index 7ad98bc2483bb..7185421553bbf 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.ts @@ -6,23 +6,28 @@ * Side Public License, v 1. */ -import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { FormData, FormHook } from '../types'; import { unflattenObject } from '../lib'; import { useFormDataContext, Context } from '../form_data_context'; -interface Options { +interface Options { watch?: string | string[]; form?: FormHook; + /** + * Use this handler if you want to listen to field value change + * before the validations are ran. + */ + onChange?: (formData: I) => void; } export type HookReturn = [I, () => T, boolean]; export const useFormData = ( - options: Options = {} + options: Options = {} ): HookReturn => { - const { watch, form } = options; + const { watch, form, onChange } = options; const ctx = useFormDataContext(); const watchToArray: string[] = watch === undefined ? [] : Array.isArray(watch) ? watch : [watch]; // We will use "stringifiedWatch" to compare if the array has changed in the useMemo() below @@ -57,29 +62,38 @@ export const useFormData = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, [getFormData, formData]); - const subscription = useMemo(() => { - return getFormData$().subscribe((raw) => { + useEffect(() => { + const subscription = getFormData$().subscribe((raw) => { if (!isMounted.current && Object.keys(raw).length === 0) { return; } if (watchToArray.length > 0) { + // Only update the state if one of the field we watch has changed. if (watchToArray.some((path) => previousRawData.current[path] !== raw[path])) { previousRawData.current = raw; - // Only update the state if one of the field we watch has changed. - setFormData(unflattenObject(raw)); + const nextState = unflattenObject(raw); + + if (onChange) { + onChange(nextState); + } + + setFormData(nextState); } } else { - setFormData(unflattenObject(raw)); + const nextState = unflattenObject(raw); + if (onChange) { + onChange(nextState); + } + setFormData(nextState); } }); - // To compare we use the stringified version of the "watchToArray" array - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [stringifiedWatch, getFormData$]); - useEffect(() => { return subscription.unsubscribe; - }, [subscription]); + + // To compare we use the stringified version of the "watchToArray" array + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stringifiedWatch, getFormData$, onChange]); useEffect(() => { isMounted.current = true; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/utils/index.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/utils/index.ts new file mode 100644 index 0000000000000..f7d3bd563ea3b --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/utils/index.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export { useBehaviorSubject } from './use_behavior_subject'; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/utils/use_behavior_subject.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/utils/use_behavior_subject.ts new file mode 100644 index 0000000000000..3bf4a6b225c8b --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/utils/use_behavior_subject.ts @@ -0,0 +1,31 @@ +/* + * 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 { useCallback, useRef, useMemo } from 'react'; +import { BehaviorSubject, Observable } from 'rxjs'; + +export const useBehaviorSubject = (initialState: T) => { + const subjectRef = useRef>(); + + const getSubject$ = useCallback(() => { + if (subjectRef.current === undefined) { + subjectRef.current = new BehaviorSubject(initialState); + } + return subjectRef.current; + }, [initialState]); + + const hook: [Observable, (value: T) => void] = useMemo(() => { + const subject = getSubject$(); + + const observable = subject.asObservable(); + const next = subject.next.bind(subject); + + return [observable, next]; + }, [getSubject$]); + + return hook; +}; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/index.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/index.ts index b5c7f5b4214e0..258b15e96e442 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/index.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/index.ts @@ -8,7 +8,7 @@ // We don't export the "useField" hook as it is for internal use. // The consumer of the library must use the component to create a field -export { useForm, useFormData, useFormIsModified, useAsyncValidationData } from './hooks'; +export { useForm, useFormData, useFormIsModified, useBehaviorSubject } from './hooks'; export { getFieldValidityAndErrorMessage } from './helpers'; export * from './form_context'; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts index cfb211b702ed6..2e1863adaa467 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts @@ -50,15 +50,15 @@ export interface FormHook * all the fields to their initial values. */ reset: (options?: { resetValues?: boolean; defaultValue?: Partial }) => void; - readonly __options: Required; - __getFormData$: () => Subject; - __addField: (field: FieldHook) => void; - __removeField: (fieldNames: string | string[]) => void; - __validateFields: ( + validateFields: ( fieldNames: string[], /** Run only blocking validations */ onlyBlocking?: boolean ) => Promise<{ areFieldsValid: boolean; isFormValid: boolean | undefined }>; + readonly __options: Required; + __getFormData$: () => Subject; + __addField: (field: FieldHook) => void; + __removeField: (fieldNames: string | string[]) => void; __updateFormDataAt: (field: string, value: unknown) => void; __updateDefaultValueAt: (field: string, value: unknown) => void; __readFieldConfigFromSchema: (field: string) => FieldConfig; @@ -206,7 +206,14 @@ export type ValidationFunc< V = unknown > = ( data: ValidationFuncArg -) => ValidationError | void | undefined | Promise | void | undefined>; +) => ValidationError | void | undefined | ValidationCancelablePromise; + +export type ValidationResponsePromise = Promise< + ValidationError | void | undefined +>; + +export type ValidationCancelablePromise = + ValidationResponsePromise & { cancel?(): void }; export interface FieldValidateResponse { isValid: boolean; @@ -239,4 +246,12 @@ export interface ValidationConfig< */ isBlocking?: boolean; exitOnFail?: boolean; + /** + * Flag to indicate if the validation is asynchronous. If not specified the lib will + * first try to run all the validations synchronously and if it detects a Promise it + * will run the validations a second time asynchronously. + * This means that HTTP request will be called twice which is not ideal. It is then + * recommended to set the "isAsync" flag to `true` to all asynchronous validations. + */ + isAsync?: boolean; } diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.helpers.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.helpers.ts index 0d58b2ce89358..1fd280a937a03 100644 --- a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.helpers.ts +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.helpers.ts @@ -12,12 +12,10 @@ import { Context } from '../../public/components/field_editor_context'; import { FieldEditor, Props } from '../../public/components/field_editor/field_editor'; import { WithFieldEditorDependencies, getCommonActions } from './helpers'; +export { waitForUpdates, waitForDocumentsAndPreviewUpdate } from './helpers'; + export const defaultProps: Props = { onChange: jest.fn(), - syntaxError: { - error: null, - clear: () => {}, - }, }; export type FieldEditorTestBed = TestBed & { actions: ReturnType }; 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 4a4c42f69fc8e..55b9876ac54ad 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 @@ -5,20 +5,18 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React, { useState, useMemo } from 'react'; import { act } from 'react-dom/test-utils'; -import { registerTestBed, TestBed } from '@kbn/test/jest'; // This import needs to come first as it contains the jest.mocks -import { setupEnvironment, getCommonActions, WithFieldEditorDependencies } from './helpers'; -import { - FieldEditor, - FieldEditorFormState, - Props, -} from '../../public/components/field_editor/field_editor'; +import { setupEnvironment, mockDocuments } from './helpers'; +import { FieldEditorFormState, Props } from '../../public/components/field_editor/field_editor'; import type { Field } from '../../public/types'; -import type { RuntimeFieldPainlessError } from '../../public/lib'; -import { setup, FieldEditorTestBed, defaultProps } from './field_editor.helpers'; +import { setSearchResponse } from './field_editor_flyout_preview.helpers'; +import { + setup, + FieldEditorTestBed, + waitForDocumentsAndPreviewUpdate, +} from './field_editor.helpers'; describe('', () => { const { server, httpRequestsMockHelpers } = setupEnvironment(); @@ -42,18 +40,14 @@ describe('', () => { let promise: ReturnType; await act(async () => { - // We can't await for the promise here as the validation for the - // "script" field has a setTimeout which is mocked by jest. If we await - // we don't have the chance to call jest.advanceTimersByTime and thus the - // test times out. + // We can't await for the promise here ("await state.submit()") as the validation for the + // "script" field has different setTimeout mocked by jest. + // If we await here (await state.submit()) we don't have the chance to call jest.advanceTimersByTime() + // below and the test times out. promise = state.submit(); }); - await act(async () => { - // The painless syntax validation has a timeout set to 600ms - // we give it a bit more time just to be on the safe side - jest.advanceTimersByTime(1000); - }); + await waitForDocumentsAndPreviewUpdate(); await act(async () => { promise.then((response) => { @@ -61,7 +55,13 @@ describe('', () => { }); }); - return formState!; + if (formState === undefined) { + throw new Error( + `The form state is not defined, this probably means that the promise did not resolve due to an unresolved validation.` + ); + } + + return formState; }; beforeAll(() => { @@ -75,6 +75,7 @@ describe('', () => { beforeEach(async () => { onChange = jest.fn(); + setSearchResponse(mockDocuments); httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['mockedScriptValue'] }); }); @@ -88,7 +89,7 @@ describe('', () => { try { expect(isOn).toBe(false); - } catch (e) { + } catch (e: any) { e.message = `"${row}" row toggle expected to be 'off' but was 'on'. \n${e.message}`; throw e; } @@ -179,74 +180,5 @@ describe('', () => { expect(getLastStateUpdate().isValid).toBe(true); expect(form.getErrorsMessages()).toEqual([]); }); - - test('should clear the painless syntax error whenever the field type changes', async () => { - const field: Field = { - name: 'myRuntimeField', - type: 'keyword', - script: { source: 'emit(6)' }, - }; - - const dummyError = { - reason: 'Awwww! Painless syntax error', - message: '', - position: { offset: 0, start: 0, end: 0 }, - scriptStack: [''], - }; - - const ComponentToProvidePainlessSyntaxErrors = () => { - const [error, setError] = useState(null); - const clearError = useMemo(() => () => setError(null), []); - const syntaxError = useMemo(() => ({ error, clear: clearError }), [error, clearError]); - - return ( - <> - - - {/* Button to forward dummy syntax error */} - - - ); - }; - - let testBedToCapturePainlessErrors: TestBed; - - await act(async () => { - testBedToCapturePainlessErrors = await registerTestBed( - WithFieldEditorDependencies(ComponentToProvidePainlessSyntaxErrors), - { - memoryRouter: { - wrapComponent: false, - }, - } - )(); - }); - - testBed = { - ...testBedToCapturePainlessErrors!, - actions: getCommonActions(testBedToCapturePainlessErrors!), - }; - - const { - form, - component, - find, - actions: { fields }, - } = testBed; - - // We set some dummy painless error - act(() => { - find('setPainlessErrorButton').simulate('click'); - }); - component.update(); - - expect(form.getErrorsMessages()).toEqual(['Awwww! Painless syntax error']); - - // We change the type and expect the form error to not be there anymore - await fields.updateType('keyword'); - expect(form.getErrorsMessages()).toEqual([]); - }); }); }); diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts index 5b916c1cd9960..0e87756819bf2 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 @@ -15,10 +15,11 @@ import { } from '../../public/components/field_editor_flyout_content'; import { WithFieldEditorDependencies, getCommonActions } from './helpers'; +export { waitForUpdates, waitForDocumentsAndPreviewUpdate } from './helpers'; + const defaultProps: Props = { onSave: () => {}, onCancel: () => {}, - runtimeFieldValidator: () => Promise.resolve(null), isSavingField: false, }; 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 9b00ff762fe8f..1730593dbda20 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 @@ -7,15 +7,17 @@ */ import { act } from 'react-dom/test-utils'; -import type { Props } from '../../public/components/field_editor_flyout_content'; +// This import needs to come first as it contains the jest.mocks import { setupEnvironment } from './helpers'; +import type { Props } from '../../public/components/field_editor_flyout_content'; +import { setSearchResponse } from './field_editor_flyout_preview.helpers'; import { setup } from './field_editor_flyout_content.helpers'; +import { mockDocuments, createPreviewError } from './helpers/mocks'; describe('', () => { const { server, httpRequestsMockHelpers } = setupEnvironment(); beforeAll(() => { - httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['foo'] }); jest.useFakeTimers(); }); @@ -24,6 +26,11 @@ describe('', () => { server.restore(); }); + beforeEach(async () => { + setSearchResponse(mockDocuments); + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['mockedScriptValue'] }); + }); + test('should have the correct title', async () => { const { exists, find } = await setup(); expect(exists('flyoutTitle')).toBe(true); @@ -55,17 +62,13 @@ describe('', () => { }; const onSave: jest.Mock = jest.fn(); - const { find } = await setup({ onSave, field }); + const { find, actions } = await setup({ onSave, field }); await act(async () => { find('fieldSaveButton').simulate('click'); }); - await act(async () => { - // The painless syntax validation has a timeout set to 600ms - // we give it a bit more time just to be on the safe side - jest.advanceTimersByTime(1000); - }); + await actions.waitForUpdates(); // Run the validations expect(onSave).toHaveBeenCalled(); const fieldReturned = onSave.mock.calls[onSave.mock.calls.length - 1][0]; @@ -85,7 +88,11 @@ describe('', () => { test('should validate the fields and prevent saving invalid form', async () => { const onSave: jest.Mock = jest.fn(); - const { find, exists, form, component } = await setup({ onSave }); + const { + find, + form, + actions: { waitForUpdates }, + } = await setup({ onSave }); expect(find('fieldSaveButton').props().disabled).toBe(false); @@ -93,17 +100,11 @@ describe('', () => { find('fieldSaveButton').simulate('click'); }); - await act(async () => { - jest.advanceTimersByTime(1000); - }); - - component.update(); + await waitForUpdates(); expect(onSave).toHaveBeenCalledTimes(0); expect(find('fieldSaveButton').props().disabled).toBe(true); expect(form.getErrorsMessages()).toEqual(['A name is required.']); - expect(exists('formError')).toBe(true); - expect(find('formError').text()).toBe('Fix errors in form before continuing.'); }); test('should forward values from the form', async () => { @@ -111,17 +112,14 @@ describe('', () => { const { find, - actions: { toggleFormRow, fields }, + actions: { toggleFormRow, fields, waitForUpdates }, } = await setup({ onSave }); await fields.updateName('someName'); await toggleFormRow('value'); await fields.updateScript('echo("hello")'); - await act(async () => { - // Let's make sure that validation has finished running - jest.advanceTimersByTime(1000); - }); + await waitForUpdates(); await act(async () => { find('fieldSaveButton').simulate('click'); @@ -138,7 +136,8 @@ describe('', () => { }); // Change the type and make sure it is forwarded - await fields.updateType('other_type', 'Other type'); + await fields.updateType('date'); + await waitForUpdates(); await act(async () => { find('fieldSaveButton').simulate('click'); @@ -148,7 +147,44 @@ describe('', () => { expect(fieldReturned).toEqual({ name: 'someName', - type: 'other_type', + type: 'date', + script: { source: 'echo("hello")' }, + }); + }); + + test('should not block validation if no documents could be fetched from server', async () => { + // If no documents can be fetched from the cluster (either because there are none or because + // the request failed), we still need to be able to resolve the painless script validation. + // In this test we will make sure that the validation for the script does not block saving the + // field even when no documentes where returned from the search query. + // successfully even though the script is invalid. + const error = createPreviewError({ reason: 'Houston we got a problem' }); + httpRequestsMockHelpers.setFieldPreviewResponse({ values: [], error, status: 400 }); + setSearchResponse([]); + + const onSave: jest.Mock = jest.fn(); + + const { + find, + actions: { toggleFormRow, fields, waitForUpdates }, + } = await setup({ onSave }); + + await fields.updateName('someName'); + await toggleFormRow('value'); + await fields.updateScript('echo("hello")'); + + await waitForUpdates(); // Wait for validation... it should not block and wait for preview response + + await act(async () => { + find('fieldSaveButton').simulate('click'); + }); + + expect(onSave).toBeCalled(); + const fieldReturned = onSave.mock.calls[onSave.mock.calls.length - 1][0]; + + expect(fieldReturned).toEqual({ + name: 'someName', + type: 'keyword', script: { source: 'echo("hello")' }, }); }); 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 068ebce638aa1..305cf84d59622 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 @@ -21,12 +21,12 @@ import { spyIndexPatternGetAllFields, spySearchQuery, spySearchQueryResponse, + TestDoc, } from './helpers'; const defaultProps: Props = { onSave: () => {}, onCancel: () => {}, - runtimeFieldValidator: () => Promise.resolve(null), isSavingField: false, }; @@ -38,12 +38,6 @@ 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; 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 67309aab44a76..2403ae8c12e51 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 @@ -7,22 +7,21 @@ */ import { act } from 'react-dom/test-utils'; -import { setupEnvironment, fieldFormatsOptions, indexPatternNameForTest } from './helpers'; +import { + setupEnvironment, + fieldFormatsOptions, + indexPatternNameForTest, + EsDoc, + setSearchResponseLatency, +} 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; -} +import { mockDocuments, createPreviewError } from './helpers/mocks'; describe('Field editor Preview panel', () => { const { server, httpRequestsMockHelpers } = setupEnvironment(); @@ -38,36 +37,6 @@ describe('Field editor Preview panel', () => { let testBed: FieldEditorFlyoutContentTestBed; - const mockDocuments: EsDoc[] = [ - { - _id: '001', - _index: 'testIndex', - _source: { - title: 'First doc - title', - subTitle: 'First doc - subTitle', - description: 'First doc - description', - }, - }, - { - _id: '002', - _index: 'testIndex', - _source: { - title: 'Second doc - title', - subTitle: 'Second doc - subTitle', - description: 'Second doc - description', - }, - }, - { - _id: '003', - _index: 'testIndex', - _source: { - title: 'Third doc - title', - subTitle: 'Third doc - subTitle', - description: 'Third doc - description', - }, - }, - ]; - const [doc1, doc2, doc3] = mockDocuments; const indexPatternFields: Array<{ name: string; displayName: string }> = [ @@ -86,43 +55,31 @@ describe('Field editor Preview panel', () => { ]; beforeEach(async () => { + server.respondImmediately = true; + server.autoRespond = true; + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['mockedScriptValue'] }); setIndexPatternFields(indexPatternFields); setSearchResponse(mockDocuments); + setSearchResponseLatency(0); 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); + test('should display the preview panel along with the editor', async () => { + const { exists } = testBed; - await toggleFormRow('value'); expect(exists('previewPanel')).toBe(true); - - await toggleFormRow('value', 'off'); - expect(exists('previewPanel')).toBe(false); - - await toggleFormRow('format'); - expect(exists('previewPanel')).toBe(true); - - await toggleFormRow('format', 'off'); - expect(exists('previewPanel')).toBe(false); }); test('should correctly set the title and subtitle of the panel', async () => { const { find, - actions: { toggleFormRow, fields, waitForUpdates }, + actions: { toggleFormRow, fields }, } = testBed; await toggleFormRow('value'); await fields.updateName('myRuntimeField'); - await waitForUpdates(); expect(find('previewPanel.title').text()).toBe('Preview'); expect(find('previewPanel.subTitle').text()).toBe(`From: ${indexPatternNameForTest}`); @@ -130,12 +87,11 @@ describe('Field editor Preview panel', () => { test('should list the list of fields of the index pattern', async () => { const { - actions: { toggleFormRow, fields, getRenderedIndexPatternFields, waitForUpdates }, + actions: { toggleFormRow, fields, getRenderedIndexPatternFields }, } = testBed; await toggleFormRow('value'); await fields.updateName('myRuntimeField'); - await waitForUpdates(); expect(getRenderedIndexPatternFields()).toEqual([ { @@ -158,18 +114,11 @@ describe('Field editor Preview panel', () => { exists, find, component, - actions: { - toggleFormRow, - fields, - setFilterFieldsValue, - getRenderedIndexPatternFields, - waitForUpdates, - }, + actions: { toggleFormRow, fields, setFilterFieldsValue, getRenderedIndexPatternFields }, } = testBed; await toggleFormRow('value'); await fields.updateName('myRuntimeField'); - await waitForUpdates(); // Should find a single field await setFilterFieldsValue('descr'); @@ -218,26 +167,21 @@ describe('Field editor Preview panel', () => { fields, getWrapperRenderedIndexPatternFields, getRenderedIndexPatternFields, - waitForUpdates, }, } = testBed; await toggleFormRow('value'); await fields.updateName('myRuntimeField'); - await waitForUpdates(); const fieldsRendered = getWrapperRenderedIndexPatternFields(); - if (fieldsRendered === null) { - throw new Error('No index pattern field rendered.'); - } - - expect(fieldsRendered.length).toBe(Object.keys(doc1._source).length); + expect(fieldsRendered).not.toBe(null); + 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'); + expect(fieldsRendered!.at(2).text()).toBe('descriptionFirst doc - description'); // Click the third field in the list ("description") - const descriptionField = fieldsRendered.at(2); + const descriptionField = fieldsRendered!.at(2); find('pinFieldButton', descriptionField).simulate('click'); component.update(); @@ -252,7 +196,7 @@ describe('Field editor Preview panel', () => { test('should display an empty prompt if no name and no script are defined', async () => { const { exists, - actions: { toggleFormRow, fields, waitForUpdates }, + actions: { toggleFormRow, fields }, } = testBed; await toggleFormRow('value'); @@ -260,20 +204,16 @@ describe('Field editor Preview panel', () => { expect(exists('previewPanel.emptyPrompt')).toBe(true); await fields.updateName('someName'); - await waitForUpdates(); expect(exists('previewPanel.emptyPrompt')).toBe(false); await fields.updateName(' '); - await waitForUpdates(); expect(exists('previewPanel.emptyPrompt')).toBe(true); // The name is empty and the empty prompt is displayed, let's now add a script... await fields.updateScript('echo("hello")'); - await waitForUpdates(); expect(exists('previewPanel.emptyPrompt')).toBe(false); await fields.updateScript(' '); - await waitForUpdates(); expect(exists('previewPanel.emptyPrompt')).toBe(true); }); @@ -286,9 +226,8 @@ describe('Field editor Preview panel', () => { }, }; - // 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. + // We open the editor with a field to edit 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 }); }); @@ -296,7 +235,6 @@ describe('Field editor Preview panel', () => { const { exists, component } = testBed; component.update(); - expect(exists('previewPanel')).toBe(true); expect(exists('previewPanel.emptyPrompt')).toBe(false); }); @@ -310,9 +248,6 @@ describe('Field editor Preview panel', () => { }, }; - // 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 }); }); @@ -320,7 +255,6 @@ describe('Field editor Preview panel', () => { const { exists, component } = testBed; component.update(); - expect(exists('previewPanel')).toBe(true); expect(exists('previewPanel.emptyPrompt')).toBe(false); }); }); @@ -328,14 +262,15 @@ describe('Field editor Preview panel', () => { describe('key & value', () => { test('should set an empty value when no script is provided', async () => { const { - actions: { toggleFormRow, fields, getRenderedFieldsPreview, waitForUpdates }, + actions: { toggleFormRow, fields, getRenderedFieldsPreview }, } = testBed; await toggleFormRow('value'); await fields.updateName('myRuntimeField'); - await waitForUpdates(); - expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: '-' }]); + expect(getRenderedFieldsPreview()).toEqual([ + { key: 'myRuntimeField', value: 'Value not set' }, + ]); }); test('should set the value returned by the painless _execute API', async () => { @@ -346,7 +281,7 @@ describe('Field editor Preview panel', () => { actions: { toggleFormRow, fields, - waitForDocumentsAndPreviewUpdate, + waitForUpdates, getLatestPreviewHttpRequest, getRenderedFieldsPreview, }, @@ -355,7 +290,7 @@ describe('Field editor Preview panel', () => { await toggleFormRow('value'); await fields.updateName('myRuntimeField'); await fields.updateScript('echo("hello")'); - await waitForDocumentsAndPreviewUpdate(); + await waitForUpdates(); // Run validations const request = getLatestPreviewHttpRequest(server); // Make sure the payload sent is correct @@ -379,46 +314,6 @@ describe('Field editor Preview panel', () => { ]); }); - test('should display an updating indicator while fetching the preview', async () => { - httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['ok'] }); - - const { - exists, - actions: { toggleFormRow, fields, waitForUpdates, waitForDocumentsAndPreviewUpdate }, - } = testBed; - - await toggleFormRow('value'); - await waitForUpdates(); // wait for docs to be fetched - expect(exists('isUpdatingIndicator')).toBe(false); - - await fields.updateScript('echo("hello")'); - expect(exists('isUpdatingIndicator')).toBe(true); - - await waitForDocumentsAndPreviewUpdate(); - expect(exists('isUpdatingIndicator')).toBe(false); - }); - - test('should not display the updating indicator when neither the type nor the script has changed', async () => { - httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['ok'] }); - - const { - exists, - actions: { toggleFormRow, fields, waitForUpdates, waitForDocumentsAndPreviewUpdate }, - } = testBed; - - await toggleFormRow('value'); - await waitForUpdates(); // wait for docs to be fetched - await fields.updateName('myRuntimeField'); - await fields.updateScript('echo("hello")'); - expect(exists('isUpdatingIndicator')).toBe(true); - await waitForDocumentsAndPreviewUpdate(); - expect(exists('isUpdatingIndicator')).toBe(false); - - await fields.updateName('nameChanged'); - // We haven't changed the type nor the script so there should not be any updating indicator - expect(exists('isUpdatingIndicator')).toBe(false); - }); - describe('read from _source', () => { test('should display the _source value when no script is provided and the name matched one of the fields in _source', async () => { const { @@ -445,12 +340,12 @@ describe('Field editor Preview panel', () => { const { actions: { toggleFormRow, fields, waitForUpdates, getRenderedFieldsPreview }, } = testBed; + await waitForUpdates(); // fetch documents 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 + await waitForUpdates(); // Run validations // We render the value from the _execute API expect(getRenderedFieldsPreview()).toEqual([ @@ -468,6 +363,71 @@ describe('Field editor Preview panel', () => { }); }); + describe('updating indicator', () => { + beforeEach(async () => { + // Add some latency to be able to test the "updatingIndicator" state + setSearchResponseLatency(2000); + testBed = await setup(); + }); + + test('should display an updating indicator while fetching the docs and the preview', async () => { + // We want to test if the loading indicator is in the DOM, for that we don't want the server to + // respond immediately. We'll manualy send the response. + server.respondImmediately = false; + server.autoRespond = false; + + httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['ok'] }); + + const { + exists, + actions: { toggleFormRow, fields, waitForUpdates }, + } = testBed; + await fields.updateName('myRuntimeField'); // Give a name to remove the empty prompt + expect(exists('isUpdatingIndicator')).toBe(true); // indicator while fetching the docs + + await waitForUpdates(); // wait for docs to be fetched + expect(exists('isUpdatingIndicator')).toBe(false); + + await toggleFormRow('value'); + expect(exists('isUpdatingIndicator')).toBe(false); + + await fields.updateScript('echo("hello")'); + expect(exists('isUpdatingIndicator')).toBe(true); // indicator while getting preview + + server.respond(); + await waitForUpdates(); + 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'] }); + // We want to test if the loading indicator is in the DOM, for that we need to manually + // send the response from the server + server.respondImmediately = false; + server.autoRespond = false; + + const { + exists, + actions: { toggleFormRow, fields, waitForUpdates, waitForDocumentsAndPreviewUpdate }, + } = testBed; + await waitForUpdates(); // wait for docs to be fetched + + await toggleFormRow('value'); + await fields.updateName('myRuntimeField'); + await fields.updateScript('echo("hello")'); + expect(exists('isUpdatingIndicator')).toBe(true); + + server.respond(); + await waitForDocumentsAndPreviewUpdate(); + + expect(exists('isUpdatingIndicator')).toBe(false); + + await fields.updateName('nameChanged'); + // We haven't changed the type nor the script so there should not be any updating indicator + expect(exists('isUpdatingIndicator')).toBe(false); + }); + }); + describe('format', () => { test('should apply the format to the value', async () => { /** @@ -513,32 +473,25 @@ describe('Field editor Preview panel', () => { const { exists, - find, - actions: { - toggleFormRow, - fields, - waitForUpdates, - waitForDocumentsAndPreviewUpdate, - getRenderedFieldsPreview, - }, + actions: { toggleFormRow, fields, waitForUpdates, getRenderedFieldsPreview }, } = testBed; + expect(exists('scriptErrorBadge')).toBe(false); + await fields.updateName('myRuntimeField'); await toggleFormRow('value'); await fields.updateScript('bad()'); - await waitForDocumentsAndPreviewUpdate(); + await waitForUpdates(); // Run validations - expect(exists('fieldPreviewItem')).toBe(false); - expect(exists('indexPatternFieldList')).toBe(false); - expect(exists('previewError')).toBe(true); - expect(find('previewError.reason').text()).toBe(error.caused_by.reason); + expect(exists('scriptErrorBadge')).toBe(true); + expect(fields.getScriptError()).toBe(error.caused_by.reason); httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['ok'] }); await fields.updateScript('echo("ok")'); await waitForUpdates(); - expect(exists('fieldPreviewItem')).toBe(true); - expect(find('indexPatternFieldList.listItem').length).toBeGreaterThan(0); + expect(exists('scriptErrorBadge')).toBe(false); + expect(fields.getScriptError()).toBe(null); expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'ok' }]); }); @@ -547,12 +500,12 @@ describe('Field editor Preview panel', () => { exists, find, form, - actions: { toggleFormRow, fields, waitForUpdates, waitForDocumentsAndPreviewUpdate }, + component, + actions: { toggleFormRow, fields }, } = testBed; await fields.updateName('myRuntimeField'); await toggleFormRow('value'); - await waitForDocumentsAndPreviewUpdate(); // We will return no document from the search setSearchResponse([]); @@ -560,12 +513,34 @@ describe('Field editor Preview panel', () => { await act(async () => { form.setInputValue('documentIdField', 'wrongID'); }); - await waitForUpdates(); + component.update(); - expect(exists('previewError')).toBe(true); - expect(find('previewError').text()).toContain('Document ID not found'); + expect(exists('fetchDocError')).toBe(true); + expect(find('fetchDocError').text()).toContain('Document ID not found'); expect(exists('isUpdatingIndicator')).toBe(false); }); + + test('should clear the error when disabling "Set value"', async () => { + const error = createPreviewError({ reason: 'Houston we got a problem' }); + httpRequestsMockHelpers.setFieldPreviewResponse({ values: [], error, status: 400 }); + + const { + exists, + actions: { toggleFormRow, fields, waitForUpdates }, + } = testBed; + + await toggleFormRow('value'); + await fields.updateScript('bad()'); + await waitForUpdates(); // Run validations + + expect(exists('scriptErrorBadge')).toBe(true); + expect(fields.getScriptError()).toBe(error.caused_by.reason); + + await toggleFormRow('value', 'off'); + + expect(exists('scriptErrorBadge')).toBe(false); + expect(fields.getScriptError()).toBe(null); + }); }); describe('Cluster document load and navigation', () => { @@ -581,19 +556,10 @@ describe('Field editor Preview panel', () => { test('should update the field list when the document changes', async () => { const { - actions: { - toggleFormRow, - fields, - getRenderedIndexPatternFields, - goToNextDocument, - goToPreviousDocument, - waitForUpdates, - }, + actions: { fields, getRenderedIndexPatternFields, goToNextDocument, goToPreviousDocument }, } = testBed; - await toggleFormRow('value'); - await fields.updateName('myRuntimeField'); - await waitForUpdates(); + await fields.updateName('myRuntimeField'); // Give a name to remove empty prompt expect(getRenderedIndexPatternFields()[0]).toEqual({ key: 'title', @@ -636,26 +602,17 @@ describe('Field editor Preview panel', () => { test('should update the field preview value when the document changes', async () => { httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['valueDoc1'] }); const { - actions: { - toggleFormRow, - fields, - waitForUpdates, - waitForDocumentsAndPreviewUpdate, - getRenderedFieldsPreview, - goToNextDocument, - }, + actions: { toggleFormRow, fields, getRenderedFieldsPreview, goToNextDocument }, } = testBed; await toggleFormRow('value'); await fields.updateName('myRuntimeField'); await fields.updateScript('echo("hello world")'); - await waitForDocumentsAndPreviewUpdate(); expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'valueDoc1' }]); httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['valueDoc2'] }); await goToNextDocument(); - await waitForUpdates(); expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'valueDoc2' }]); }); @@ -665,20 +622,12 @@ describe('Field editor Preview panel', () => { component, form, exists, - actions: { - toggleFormRow, - fields, - getRenderedIndexPatternFields, - getRenderedFieldsPreview, - waitForUpdates, - waitForDocumentsAndPreviewUpdate, - }, + actions: { toggleFormRow, fields, getRenderedIndexPatternFields, getRenderedFieldsPreview }, } = 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. @@ -697,10 +646,6 @@ describe('Field editor Preview panel', () => { form.setInputValue('documentIdField', '123456'); }); component.update(); - // We immediately remove the index pattern fields - expect(getRenderedIndexPatternFields()).toEqual([]); - - await waitForDocumentsAndPreviewUpdate(); expect(getRenderedIndexPatternFields()).toEqual([ { @@ -717,8 +662,6 @@ describe('Field editor Preview panel', () => { }, ]); - await waitForUpdates(); // Then wait for the preview HTTP request - // The preview should have updated expect(getRenderedFieldsPreview()).toEqual([ { key: 'myRuntimeField', value: 'loadedDocPreview' }, @@ -735,18 +678,10 @@ describe('Field editor Preview panel', () => { form, component, find, - actions: { - toggleFormRow, - fields, - getRenderedFieldsPreview, - getRenderedIndexPatternFields, - waitForUpdates, - waitForDocumentsAndPreviewUpdate, - }, + actions: { toggleFormRow, fields, getRenderedFieldsPreview, waitForUpdates }, } = testBed; await toggleFormRow('value'); - await waitForUpdates(); // fetch documents await fields.updateName('myRuntimeField'); await fields.updateScript('echo("hello world")'); await waitForUpdates(); // fetch preview @@ -758,7 +693,7 @@ describe('Field editor Preview panel', () => { await act(async () => { form.setInputValue('documentIdField', '123456'); }); - await waitForDocumentsAndPreviewUpdate(); + component.update(); // Load back the cluster data httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['clusterDataDocPreview'] }); @@ -768,10 +703,6 @@ describe('Field editor Preview panel', () => { 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([ @@ -779,22 +710,16 @@ describe('Field editor Preview panel', () => { ]); }); - test('should not lose the state of single document vs cluster data after displaying the empty prompt', async () => { + test('should not lose the state of single document vs cluster data after toggling on/off the empty prompt', async () => { const { form, component, exists, - actions: { - toggleFormRow, - fields, - getRenderedIndexPatternFields, - waitForDocumentsAndPreviewUpdate, - }, + actions: { toggleFormRow, fields, getRenderedIndexPatternFields }, } = 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); @@ -806,7 +731,6 @@ describe('Field editor Preview panel', () => { form.setInputValue('documentIdField', '123456'); }); component.update(); - await waitForDocumentsAndPreviewUpdate(); expect(exists('documentsNav')).toBe(false); expect(exists('loadDocsFromClusterButton')).toBe(true); @@ -833,24 +757,20 @@ describe('Field editor Preview panel', () => { form, component, find, - actions: { toggleFormRow, fields, waitForUpdates }, + actions: { fields }, } = testBed; const expectedParamsToFetchClusterData = { - params: { index: 'testIndexPattern', body: { size: 50 } }, + params: { index: indexPatternNameForTest, 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(); + await fields.updateName('myRuntimeField'); // hide the empty prompt searchMeta = getSearchCallMeta(); - expect(searchMeta.totalCalls).toBe(initialCount + 1); + const initialCount = searchMeta.totalCalls; expect(searchMeta.lastCallParams).toEqual(expectedParamsToFetchClusterData); // Load single doc @@ -860,10 +780,9 @@ describe('Field editor Preview panel', () => { form.setInputValue('documentIdField', nextId); }); component.update(); - await waitForUpdates(); searchMeta = getSearchCallMeta(); - expect(searchMeta.totalCalls).toBe(initialCount + 2); + expect(searchMeta.totalCalls).toBe(initialCount + 1); expect(searchMeta.lastCallParams).toEqual({ params: { body: { @@ -874,7 +793,7 @@ describe('Field editor Preview panel', () => { }, size: 1, }, - index: 'testIndexPattern', + index: indexPatternNameForTest, }, }); @@ -884,8 +803,30 @@ describe('Field editor Preview panel', () => { find('loadDocsFromClusterButton').simulate('click'); }); searchMeta = getSearchCallMeta(); - expect(searchMeta.totalCalls).toBe(initialCount + 3); + expect(searchMeta.totalCalls).toBe(initialCount + 2); expect(searchMeta.lastCallParams).toEqual(expectedParamsToFetchClusterData); }); }); + + describe('When no documents could be fetched from cluster', () => { + beforeEach(() => { + setSearchResponse([]); + }); + + test('should not display the updating indicator and have a callout to indicate that preview is not available', async () => { + setSearchResponseLatency(2000); + testBed = await setup(); + + const { + exists, + actions: { fields, waitForUpdates }, + } = testBed; + await fields.updateName('myRuntimeField'); // Give a name to remove the empty prompt + expect(exists('isUpdatingIndicator')).toBe(true); // indicator while fetching the docs + + await waitForUpdates(); // wait for docs to be fetched + expect(exists('isUpdatingIndicator')).toBe(false); + expect(exists('previewNotAvailableCallout')).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 ca061968dae20..9f8b52af5878e 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 @@ -8,6 +8,36 @@ import { act } from 'react-dom/test-utils'; import { TestBed } from '@kbn/test/jest'; +/** + * We often need to wait for both the documents & the preview to be fetched. + * We can't increase the `jest.advanceTimersByTime()` time + * as those are 2 different operations that occur in sequence. + */ +export const waitForDocumentsAndPreviewUpdate = async (testBed?: TestBed) => { + // Wait for documents to be fetched + await act(async () => { + jest.advanceTimersByTime(5000); + }); + + // Wait for the syntax validation debounced + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + testBed?.component.update(); +}; + +/** + * Handler to bypass the debounce time in our tests + */ +export const waitForUpdates = async (testBed?: TestBed) => { + await act(async () => { + jest.advanceTimersByTime(5000); + }); + + testBed?.component.update(); +}; + export const getCommonActions = (testBed: TestBed) => { const toggleFormRow = async ( row: 'customLabel' | 'value' | 'format', @@ -66,46 +96,28 @@ export const getCommonActions = (testBed: TestBed) => { testBed.component.update(); }; - /** - * Allows us to bypass the debounce time of 500ms before updating the preview. We also simulate - * a 2000ms latency when searching ES documents (see setup_environment.tsx). - */ - const waitForUpdates = async () => { - await act(async () => { - jest.runAllTimers(); - }); + const getScriptError = () => { + const scriptError = testBed.component.find('#runtimeFieldScript-error-0'); - testBed.component.update(); - }; - - /** - * When often need to both wait for the documents to be fetched and - * the preview to be fetched. We can't increase the `jest.advanceTimersByTime` time - * as those are 2 different operations that occur in sequence. - */ - const waitForDocumentsAndPreviewUpdate = async () => { - // Wait for documents to be fetched - await act(async () => { - jest.runAllTimers(); - }); - - // Wait for preview to update - await act(async () => { - jest.runAllTimers(); - }); + if (scriptError.length === 0) { + return null; + } else if (scriptError.length > 1) { + return scriptError.at(0).text(); + } - testBed.component.update(); + return scriptError.text(); }; return { toggleFormRow, - waitForUpdates, - waitForDocumentsAndPreviewUpdate, + waitForUpdates: waitForUpdates.bind(null, testBed), + waitForDocumentsAndPreviewUpdate: waitForDocumentsAndPreviewUpdate.bind(null, testBed), fields: { updateName, updateType, updateScript, updateFormat, + getScriptError, }, }; }; 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 e8ff7eb7538f2..2fc870bd42d66 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 @@ -17,6 +17,14 @@ export { spyIndexPatternGetAllFields, fieldFormatsOptions, indexPatternNameForTest, + setSearchResponseLatency, } from './setup_environment'; -export { getCommonActions } from './common_actions'; +export { + getCommonActions, + waitForUpdates, + waitForDocumentsAndPreviewUpdate, +} from './common_actions'; + +export type { EsDoc, TestDoc } from './mocks'; +export { mockDocuments } from './mocks'; 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 d33a0d2a87fb5..7161776c21fb1 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 @@ -5,7 +5,11 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React from 'react'; +import React, { useEffect } from 'react'; +import { of } from 'rxjs'; + +const mockUseEffect = useEffect; +const mockOf = of; const EDITOR_ID = 'testEditor'; @@ -39,6 +43,7 @@ jest.mock('@elastic/eui', () => { jest.mock('@kbn/monaco', () => { const original = jest.requireActual('@kbn/monaco'); + const originalMonaco = original.monaco; return { ...original, @@ -48,10 +53,28 @@ jest.mock('@kbn/monaco', () => { getSyntaxErrors: () => ({ [EDITOR_ID]: [], }), + validation$() { + return mockOf({ isValid: true, isValidating: false, errors: [] }); + }, + }, + monaco: { + ...originalMonaco, + editor: { + ...originalMonaco.editor, + setModelMarkers() {}, + }, }, }; }); +jest.mock('react-use/lib/useDebounce', () => { + return (cb: () => void, ms: number, deps: any[]) => { + mockUseEffect(() => { + cb(); + }, deps); + }; +}); + jest.mock('../../../../kibana_react/public', () => { const original = jest.requireActual('../../../../kibana_react/public'); @@ -60,15 +83,19 @@ jest.mock('../../../../kibana_react/public', () => { * with the uiSettings passed down. Let's use a simple in our tests. */ const CodeEditorMock = (props: any) => { - // Forward our deterministic ID to the consumer - // We need below for the PainlessLang.getSyntaxErrors mock - props.editorDidMount({ - getModel() { - return { - id: EDITOR_ID, - }; - }, - }); + const { editorDidMount } = props; + + mockUseEffect(() => { + // Forward our deterministic ID to the consumer + // We need below for the PainlessLang.getSyntaxErrors mock + editorDidMount({ + getModel() { + return { + id: EDITOR_ID, + }; + }, + }); + }, [editorDidMount]); return ( Promise.resolve({})); export const spyIndexPatternGetAllFields = jest.fn().mockImplementation(() => []); -spySearchQuery.mockImplementation((params) => { +let searchResponseDelay = 0; + +// Add latency to the search request +export const setSearchResponseLatency = (ms: number) => { + searchResponseDelay = ms; +}; + +spySearchQuery.mockImplementation(() => { return { toPromise: () => { + if (searchResponseDelay === 0) { + // no delay, it is synchronous + return spySearchQueryResponse(); + } + return new Promise((resolve) => { setTimeout(() => { resolve(undefined); - }, 2000); // simulate 2s latency for the HTTP request - }).then(() => spySearchQueryResponse()); + }, searchResponseDelay); + }).then(() => { + return spySearchQueryResponse(); + }); }, }; }); 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 11183d575e955..ddc3aa72c7610 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 @@ -42,7 +42,6 @@ import { ScriptField, FormatField, PopularityField, - ScriptSyntaxError, } from './form_fields'; import { FormRow } from './form_row'; import { AdvancedParametersSection } from './advanced_parameters_section'; @@ -50,6 +49,7 @@ import { AdvancedParametersSection } from './advanced_parameters_section'; export interface FieldEditorFormState { isValid: boolean | undefined; isSubmitted: boolean; + isSubmitting: boolean; submit: FormHook['submit']; } @@ -70,7 +70,6 @@ export interface Props { onChange?: (state: FieldEditorFormState) => void; /** Handler to receive update on the form "isModified" state */ onFormModifiedChange?: (isModified: boolean) => void; - syntaxError: ScriptSyntaxError; } const geti18nTexts = (): { @@ -150,12 +149,11 @@ const formSerializer = (field: FieldFormInternal): Field => { }; }; -const FieldEditorComponent = ({ field, onChange, onFormModifiedChange, syntaxError }: Props) => { +const FieldEditorComponent = ({ field, onChange, onFormModifiedChange }: Props) => { const { links, namesNotAllowed, existingConcreteFields, fieldTypeToProcess } = useFieldEditorContext(); const { params: { update: updatePreviewParams }, - panel: { setIsVisible: setIsPanelVisible }, } = useFieldPreviewContext(); const { form } = useForm({ defaultValue: field, @@ -163,8 +161,7 @@ const FieldEditorComponent = ({ field, onChange, onFormModifiedChange, syntaxErr deserializer: formDeserializer, serializer: formSerializer, }); - const { submit, isValid: isFormValid, isSubmitted, getFields } = form; - const { clear: clearSyntaxError } = syntaxError; + const { submit, isValid: isFormValid, isSubmitted, getFields, isSubmitting } = form; const nameFieldConfig = getNameFieldConfig(namesNotAllowed, field); const i18nTexts = geti18nTexts(); @@ -191,19 +188,12 @@ const FieldEditorComponent = ({ field, onChange, onFormModifiedChange, syntaxErr const typeHasChanged = (Boolean(field?.type) && typeField?.isModified) ?? false; const isValueVisible = get(formData, '__meta__.isValueVisible'); - const isFormatVisible = get(formData, '__meta__.isFormatVisible'); useEffect(() => { if (onChange) { - onChange({ isValid: isFormValid, isSubmitted, submit }); + onChange({ isValid: isFormValid, isSubmitted, isSubmitting, submit }); } - }, [onChange, isFormValid, isSubmitted, submit]); - - useEffect(() => { - // Whenever the field "type" changes we clear any possible painless syntax - // error as it is possibly stale. - clearSyntaxError(); - }, [updatedType, clearSyntaxError]); + }, [onChange, isFormValid, isSubmitted, isSubmitting, submit]); useEffect(() => { updatePreviewParams({ @@ -217,14 +207,6 @@ const FieldEditorComponent = ({ field, onChange, onFormModifiedChange, syntaxErr }); }, [updatedName, updatedType, updatedScript, isValueVisible, updatedFormat, updatePreviewParams]); - useEffect(() => { - if (isValueVisible || isFormatVisible) { - setIsPanelVisible(true); - } else { - setIsPanelVisible(false); - } - }, [isValueVisible, isFormatVisible, setIsPanelVisible]); - useEffect(() => { if (onFormModifiedChange) { onFormModifiedChange(isFormModified); @@ -236,6 +218,8 @@ const FieldEditorComponent = ({ field, onChange, onFormModifiedChange, syntaxErr form={form} className="indexPatternFieldEditor__form" data-test-subj="indexPatternFieldEditorForm" + isInvalid={isSubmitted && isFormValid === false} + error={form.getErrors()} > {/* Name */} @@ -296,11 +280,7 @@ const FieldEditorComponent = ({ field, onChange, onFormModifiedChange, syntaxErr data-test-subj="valueRow" withDividerRule > - + )} diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/form_fields/index.ts b/src/plugins/index_pattern_field_editor/public/components/field_editor/form_fields/index.ts index 693709729ed92..cfa09db3cdc83 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/form_fields/index.ts +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/form_fields/index.ts @@ -12,7 +12,6 @@ export { CustomLabelField } from './custom_label_field'; export { PopularityField } from './popularity_field'; -export type { ScriptSyntaxError } from './script_field'; export { ScriptField } from './script_field'; export { FormatField } from './format_field'; diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/form_fields/script_field.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor/form_fields/script_field.tsx index d73e8046e5db7..b1dcddd459c8a 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/form_fields/script_field.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/form_fields/script_field.tsx @@ -6,32 +6,32 @@ * Side Public License, v 1. */ -import React, { useState, useEffect, useMemo, useRef } from 'react'; +import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import { first } from 'rxjs/operators'; +import type { Subscription } from 'rxjs'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiFormRow, EuiLink, EuiCode, EuiCodeBlock, EuiSpacer, EuiTitle } from '@elastic/eui'; -import { PainlessLang, PainlessContext } from '@kbn/monaco'; +import { EuiFormRow, EuiLink, EuiCode } from '@elastic/eui'; +import { PainlessLang, PainlessContext, monaco } from '@kbn/monaco'; +import { firstValueFrom } from '@kbn/std'; import { UseField, useFormData, + useBehaviorSubject, RuntimeType, - FieldConfig, CodeEditor, + useFormContext, } from '../../../shared_imports'; -import { RuntimeFieldPainlessError } from '../../../lib'; +import type { RuntimeFieldPainlessError } from '../../../types'; +import { painlessErrorToMonacoMarker } from '../../../lib'; +import { useFieldPreviewContext, Context } from '../../preview'; import { schema } from '../form_schema'; import type { FieldFormInternal } from '../field_editor'; interface Props { links: { runtimePainless: string }; existingConcreteFields?: Array<{ name: string; type: string }>; - syntaxError: ScriptSyntaxError; -} - -export interface ScriptSyntaxError { - error: RuntimeFieldPainlessError | null; - clear: () => void; } const mapReturnTypeToPainlessContext = (runtimeType: RuntimeType): PainlessContext => { @@ -53,87 +53,166 @@ const mapReturnTypeToPainlessContext = (runtimeType: RuntimeType): PainlessConte } }; -export const ScriptField = React.memo(({ existingConcreteFields, links, syntaxError }: Props) => { - const editorValidationTimeout = useRef>(); +const ScriptFieldComponent = ({ existingConcreteFields, links }: Props) => { + const monacoEditor = useRef(null); + const editorValidationSubscription = useRef(); + const fieldCurrentValue = useRef(''); + + const { + error, + isLoadingPreview, + isPreviewAvailable, + currentDocument: { isLoading: isFetchingDoc, value: currentDocument }, + validation: { setScriptEditorValidation }, + } = useFieldPreviewContext(); + const [validationData$, nextValidationData$] = useBehaviorSubject< + | { + isFetchingDoc: boolean; + isLoadingPreview: boolean; + error: Context['error']; + } + | undefined + >(undefined); const [painlessContext, setPainlessContext] = useState( - mapReturnTypeToPainlessContext(schema.type.defaultValue[0].value!) + mapReturnTypeToPainlessContext(schema.type.defaultValue![0].value!) + ); + + const currentDocId = currentDocument?._id; + + const suggestionProvider = useMemo( + () => PainlessLang.getSuggestionProvider(painlessContext, existingConcreteFields), + [painlessContext, existingConcreteFields] ); - const [editorId, setEditorId] = useState(); + const { validateFields } = useFormContext(); - const suggestionProvider = PainlessLang.getSuggestionProvider( - painlessContext, - existingConcreteFields + // Listen to formData changes **before** validations are executed + const onFormDataChange = useCallback( + ({ type }: FieldFormInternal) => { + if (type !== undefined) { + setPainlessContext(mapReturnTypeToPainlessContext(type[0]!.value!)); + } + + if (isPreviewAvailable) { + // To avoid a race condition where the validation would run before + // the context state are updated, we clear the old value of the observable. + // This way the validationDataProvider() will await until new values come in before resolving + nextValidationData$(undefined); + } + }, + [nextValidationData$, isPreviewAvailable] ); - const [{ type, script: { source } = { source: '' } }] = useFormData({ + useFormData({ watch: ['type', 'script.source'], + onChange: onFormDataChange, }); - const { clear: clearSyntaxError } = syntaxError; - - const sourceFieldConfig: FieldConfig = useMemo(() => { - return { - ...schema.script.source, - validations: [ - ...schema.script.source.validations, - { - validator: () => { - if (editorValidationTimeout.current) { - clearTimeout(editorValidationTimeout.current); - } - - return new Promise((resolve) => { - // monaco waits 500ms before validating, so we also add a delay - // before checking if there are any syntax errors - editorValidationTimeout.current = setTimeout(() => { - const painlessSyntaxErrors = PainlessLang.getSyntaxErrors(); - // It is possible for there to be more than one editor in a view, - // so we need to get the syntax errors based on the editor (aka model) ID - const editorHasSyntaxErrors = - editorId && - painlessSyntaxErrors[editorId] && - painlessSyntaxErrors[editorId].length > 0; - - if (editorHasSyntaxErrors) { - return resolve({ - message: i18n.translate( - 'indexPatternFieldEditor.editor.form.scriptEditorValidationMessage', - { - defaultMessage: 'Invalid Painless syntax.', - } - ), - }); - } - - resolve(undefined); - }, 600); - }); - }, - }, - ], - }; - }, [editorId]); + const validationDataProvider = useCallback(async () => { + const validationData = await firstValueFrom( + validationData$.pipe( + first((data) => { + // We first wait to get field preview data + if (data === undefined) { + return false; + } + + // We are not interested in preview data meanwhile it + // is still making HTTP request + if (data.isFetchingDoc || data.isLoadingPreview) { + return false; + } + + return true; + }) + ) + ); + + return validationData!.error; + }, [validationData$]); + + const onEditorDidMount = useCallback( + (editor: monaco.editor.IStandaloneCodeEditor) => { + monacoEditor.current = editor; + + if (editorValidationSubscription.current) { + editorValidationSubscription.current.unsubscribe(); + } + + editorValidationSubscription.current = PainlessLang.validation$().subscribe( + ({ isValid, isValidating, errors }) => { + setScriptEditorValidation({ + isValid, + isValidating, + message: errors[0]?.message ?? null, + }); + } + ); + }, + [setScriptEditorValidation] + ); + + const updateMonacoMarkers = useCallback((markers: monaco.editor.IMarkerData[]) => { + const model = monacoEditor.current?.getModel(); + if (model) { + monaco.editor.setModelMarkers(model, PainlessLang.ID, markers); + } + }, []); + + const displayPainlessScriptErrorInMonaco = useCallback( + (painlessError: RuntimeFieldPainlessError) => { + const model = monacoEditor.current?.getModel(); + + if (painlessError.position !== null && Boolean(model)) { + const { offset } = painlessError.position; + // Get the monaco Position (lineNumber and colNumber) from the ES Painless error position + const errorStartPosition = model!.getPositionAt(offset); + const markerData = painlessErrorToMonacoMarker(painlessError, errorStartPosition); + const errorMarkers = markerData ? [markerData] : []; + updateMonacoMarkers(errorMarkers); + } + }, + [updateMonacoMarkers] + ); + + // Whenever we navigate to a different doc we validate the script + // field as it could be invalid against the new document. + useEffect(() => { + if (fieldCurrentValue.current.trim() !== '' && currentDocId !== undefined) { + validateFields(['script.source']); + } + }, [currentDocId, validateFields]); useEffect(() => { - setPainlessContext(mapReturnTypeToPainlessContext(type[0]!.value!)); - }, [type]); + nextValidationData$({ isFetchingDoc, isLoadingPreview, error }); + }, [nextValidationData$, isFetchingDoc, isLoadingPreview, error]); useEffect(() => { - // Whenever the source changes we clear potential syntax errors - clearSyntaxError(); - }, [source, clearSyntaxError]); + if (error?.code === 'PAINLESS_SCRIPT_ERROR') { + displayPainlessScriptErrorInMonaco(error!.error as RuntimeFieldPainlessError); + } else if (error === null) { + updateMonacoMarkers([]); + } + }, [error, displayPainlessScriptErrorInMonaco, updateMonacoMarkers]); + + useEffect(() => { + return () => { + if (editorValidationSubscription.current) { + editorValidationSubscription.current.unsubscribe(); + } + }; + }, []); return ( - path="script.source" config={sourceFieldConfig}> + path="script.source" validationDataProvider={validationDataProvider}> {({ value, setValue, label, isValid, getErrorsMessages }) => { - let errorMessage: string | null = ''; - if (syntaxError.error !== null) { - errorMessage = syntaxError.error.reason ?? syntaxError.error.message; - } else { - errorMessage = getErrorsMessages(); + let errorMessage = getErrorsMessages(); + + if (error) { + errorMessage = error.error.reason!; } + fieldCurrentValue.current = value; return ( <> @@ -141,7 +220,7 @@ export const ScriptField = React.memo(({ existingConcreteFields, links, syntaxEr label={label} id="runtimeFieldScript" error={errorMessage} - isInvalid={syntaxError.error !== null || !isValid} + isInvalid={!isValid} helpText={ setEditorId(editor.getModel()?.id)} + editorDidMount={onEditorDidMount} options={{ fontSize: 12, minimap: { @@ -199,33 +278,11 @@ export const ScriptField = React.memo(({ existingConcreteFields, links, syntaxEr )} /> - - {/* Help the user debug the error by showing where it failed in the script */} - {syntaxError.error !== null && ( - <> - - -

- {i18n.translate( - 'indexPatternFieldEditor.editor.form.scriptEditor.debugErrorMessage', - { - defaultMessage: 'Syntax error detail', - } - )} -

-
- - - {syntaxError.error.scriptStack.join('\n')} - - - )} ); }}
); -}); +}; + +export const ScriptField = React.memo(ScriptFieldComponent); diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/form_schema.ts b/src/plugins/index_pattern_field_editor/public/components/field_editor/form_schema.ts index 979a1fdb1adc1..7a15dce3af019 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/form_schema.ts +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/form_schema.ts @@ -7,11 +7,77 @@ */ import { i18n } from '@kbn/i18n'; -import { fieldValidators } from '../../shared_imports'; +import { EuiComboBoxOptionOption } from '@elastic/eui'; +import type { Subscription } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { PainlessLang } from '@kbn/monaco'; +import { + fieldValidators, + FieldConfig, + RuntimeType, + ValidationFunc, + ValidationCancelablePromise, +} from '../../shared_imports'; +import type { Context } from '../preview'; import { RUNTIME_FIELD_OPTIONS } from './constants'; const { containsCharsField, emptyField, numberGreaterThanField } = fieldValidators; +const i18nTexts = { + invalidScriptErrorMessage: i18n.translate( + 'indexPatternFieldEditor.editor.form.scriptEditorPainlessValidationMessage', + { + defaultMessage: 'Invalid Painless script.', + } + ), +}; + +// Validate the painless **syntax** (no need to make an HTTP request) +const painlessSyntaxValidator = () => { + let isValidatingSub: Subscription; + + return (() => { + const promise: ValidationCancelablePromise<'ERR_PAINLESS_SYNTAX'> = new Promise((resolve) => { + isValidatingSub = PainlessLang.validation$() + .pipe( + first(({ isValidating }) => { + return isValidating === false; + }) + ) + .subscribe(({ errors }) => { + const editorHasSyntaxErrors = errors.length > 0; + + if (editorHasSyntaxErrors) { + return resolve({ + message: i18nTexts.invalidScriptErrorMessage, + code: 'ERR_PAINLESS_SYNTAX', + }); + } + + resolve(undefined); + }); + }); + + promise.cancel = () => { + if (isValidatingSub) { + isValidatingSub.unsubscribe(); + } + }; + + return promise; + }) as ValidationFunc; +}; + +// Validate the painless **script** +const painlessScriptValidator: ValidationFunc = async ({ customData: { provider } }) => { + const previewError = (await provider()) as Context['error']; + + if (previewError && previewError.code === 'PAINLESS_SCRIPT_ERROR') { + return { + message: i18nTexts.invalidScriptErrorMessage, + }; + } +}; export const schema = { name: { @@ -47,7 +113,8 @@ export const schema = { defaultMessage: 'Type', }), defaultValue: [RUNTIME_FIELD_OPTIONS[0]], - }, + fieldsToValidateOnChange: ['script.source'], + } as FieldConfig>>, script: { source: { label: i18n.translate('indexPatternFieldEditor.editor.form.defineFieldLabel', { @@ -64,6 +131,14 @@ export const schema = { ) ), }, + { + validator: painlessSyntaxValidator(), + isAsync: true, + }, + { + validator: painlessScriptValidator, + isAsync: true, + }, ], }, }, 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 f13b30f13327c..d1dbb50ebf2e4 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 @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useState, useCallback, useMemo, useEffect } from 'react'; +import React, { useState, useCallback, useEffect, useRef } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -15,13 +15,10 @@ import { EuiFlexItem, EuiButtonEmpty, EuiButton, - EuiCallOut, - EuiSpacer, EuiText, } from '@elastic/eui'; -import type { Field, EsRuntimeField } from '../types'; -import { RuntimeFieldPainlessError } from '../lib'; +import type { Field } from '../types'; import { euiFlyoutClassname } from '../constants'; import { FlyoutPanels } from './flyout_panels'; import { useFieldEditorContext } from './field_editor_context'; @@ -36,9 +33,6 @@ const i18nTexts = { saveButtonLabel: i18n.translate('indexPatternFieldEditor.editor.flyoutSaveButtonLabel', { defaultMessage: 'Save', }), - formErrorsCalloutTitle: i18n.translate('indexPatternFieldEditor.editor.validationErrorTitle', { - defaultMessage: 'Fix errors in form before continuing.', - }), }; const defaultModalVisibility = { @@ -55,8 +49,6 @@ export interface Props { * Handler for the "cancel" footer button */ onCancel: () => void; - /** Handler to validate the script */ - runtimeFieldValidator: (field: EsRuntimeField) => Promise; /** Optional field to process */ field?: Field; isSavingField: boolean; @@ -70,10 +62,10 @@ const FieldEditorFlyoutContentComponent = ({ field, onSave, onCancel, - runtimeFieldValidator, isSavingField, onMounted, }: Props) => { + const isMounted = useRef(false); const isEditingExistingField = !!field; const { indexPattern } = useFieldEditorContext(); const { @@ -82,32 +74,18 @@ const FieldEditorFlyoutContentComponent = ({ const [formState, setFormState] = useState({ isSubmitted: false, + isSubmitting: false, isValid: field ? true : undefined, submit: field ? async () => ({ isValid: true, data: field }) : async () => ({ isValid: false, data: {} as Field }), }); - const [painlessSyntaxError, setPainlessSyntaxError] = useState( - null - ); - - const [isValidating, setIsValidating] = useState(false); const [modalVisibility, setModalVisibility] = useState(defaultModalVisibility); const [isFormModified, setIsFormModified] = useState(false); - const { submit, isValid: isFormValid, isSubmitted } = formState; - const hasErrors = isFormValid === false || painlessSyntaxError !== null; - - const clearSyntaxError = useCallback(() => setPainlessSyntaxError(null), []); - - const syntaxError = useMemo( - () => ({ - error: painlessSyntaxError, - clear: clearSyntaxError, - }), - [painlessSyntaxError, clearSyntaxError] - ); + const { submit, isValid: isFormValid, isSubmitting } = formState; + const hasErrors = isFormValid === false; const canCloseValidator = useCallback(() => { if (isFormModified) { @@ -121,25 +99,15 @@ const FieldEditorFlyoutContentComponent = ({ const onClickSave = useCallback(async () => { const { isValid, data } = await submit(); - const nameChange = field?.name !== data.name; - const typeChange = field?.type !== data.type; - - if (isValid) { - if (data.script) { - setIsValidating(true); - - const error = await runtimeFieldValidator({ - type: data.type, - script: data.script, - }); - setIsValidating(false); - setPainlessSyntaxError(error); + if (!isMounted.current) { + // User has closed the flyout meanwhile submitting the form + return; + } - if (error) { - return; - } - } + if (isValid) { + const nameChange = field?.name !== data.name; + const typeChange = field?.type !== data.type; if (isEditingExistingField && (nameChange || typeChange)) { setModalVisibility({ @@ -150,7 +118,7 @@ const FieldEditorFlyoutContentComponent = ({ onSave(data); } } - }, [onSave, submit, runtimeFieldValidator, field, isEditingExistingField]); + }, [onSave, submit, field, isEditingExistingField]); const onClickCancel = useCallback(() => { const canClose = canCloseValidator(); @@ -206,6 +174,14 @@ const FieldEditorFlyoutContentComponent = ({ } }, [onMounted, canCloseValidator]); + useEffect(() => { + isMounted.current = true; + + return () => { + isMounted.current = false; + }; + }, []); + return ( <> <> - {isSubmitted && hasErrors && ( - <> - - - - )} {i18nTexts.saveButtonLabel} diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content_container.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content_container.tsx index ea981662c1ff7..1738c55ba1f55 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content_container.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content_container.tsx @@ -20,7 +20,7 @@ import { } from '../shared_imports'; import type { Field, PluginStart, InternalFieldType } from '../types'; import { pluginName } from '../constants'; -import { deserializeField, getRuntimeFieldValidator, getLinks, ApiService } from '../lib'; +import { deserializeField, getLinks, ApiService } from '../lib'; import { FieldEditorFlyoutContent, Props as FieldEditorFlyoutContentProps, @@ -103,11 +103,6 @@ export const FieldEditorFlyoutContentContainer = ({ return existing; }, [fields, field]); - const validateRuntimeField = useMemo( - () => getRuntimeFieldValidator(indexPattern.title, search), - [search, indexPattern] - ); - const services = useMemo( () => ({ api: apiService, @@ -207,7 +202,6 @@ export const FieldEditorFlyoutContentContainer = ({ onCancel={onCancel} onMounted={onMounted} field={fieldToEdit} - runtimeFieldValidator={validateRuntimeField} isSavingField={isSaving} /> 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 fa4097725cde1..04f5e2e542f40 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 @@ -21,22 +21,11 @@ import { useFieldPreviewContext } from './field_preview_context'; export const DocumentsNavPreview = () => { const { currentDocument: { id: documentId, isCustomId }, - documents: { loadSingle, loadFromCluster }, + documents: { loadSingle, loadFromCluster, fetchDocError }, navigation: { prev, next }, - error, } = useFieldPreviewContext(); - const errorMessage = - error !== null && error.code === 'DOC_NOT_FOUND' - ? i18n.translate( - 'indexPatternFieldEditor.fieldPreview.documentIdField.documentNotFoundError', - { - defaultMessage: 'Document not found', - } - ) - : null; - - const isInvalid = error !== null && error.code === 'DOC_NOT_FOUND'; + const isInvalid = fetchDocError?.code === 'DOC_NOT_FOUND'; // We don't display the nav button when the user has entered a custom // document ID as at that point there is no more reference to what's "next" @@ -58,13 +47,12 @@ export const DocumentsNavPreview = () => { label={i18n.translate('indexPatternFieldEditor.fieldPreview.documentIdField.label', { defaultMessage: 'Document ID', })} - error={errorMessage} isInvalid={isInvalid} fullWidth > void; - highlighted?: boolean; + hasScriptError?: boolean; + /** Indicates whether the field list item comes from the Painless script */ + isFromScript?: boolean; } export const PreviewListItem: React.FC = ({ field: { key, value, formattedValue, isPinned = false }, - highlighted, toggleIsPinned, + hasScriptError, + isFromScript = false, }) => { + const { isLoadingPreview } = useFieldPreviewContext(); + const [isPreviewImageModalVisible, setIsPreviewImageModalVisible] = useState(false); /* eslint-disable @typescript-eslint/naming-convention */ const classes = classnames('indexPatternFieldEditor__previewFieldList__item', { - 'indexPatternFieldEditor__previewFieldList__item--highlighted': highlighted, + 'indexPatternFieldEditor__previewFieldList__item--highlighted': isFromScript, 'indexPatternFieldEditor__previewFieldList__item--pinned': isPinned, }); /* eslint-enable @typescript-eslint/naming-convention */ const doesContainImage = formattedValue?.includes(' { + if (isFromScript && !Boolean(key)) { + return ( + + + {i18n.translate('indexPatternFieldEditor.fieldPreview.fieldNameNotSetLabel', { + defaultMessage: 'Field name not set', + })} + + + ); + } + + return key; + }; + + const withTooltip = (content: JSX.Element) => ( + + {content} + + ); + const renderValue = () => { + if (isFromScript && isLoadingPreview) { + return ( + + + + ); + } + + if (hasScriptError) { + return ( +
+ + {i18n.translate('indexPatternFieldEditor.fieldPreview.scriptErrorBadgeLabel', { + defaultMessage: 'Script error', + })} + +
+ ); + } + + if (isFromScript && value === undefined) { + return ( + + + {i18n.translate('indexPatternFieldEditor.fieldPreview.valueNotSetLabel', { + defaultMessage: 'Value not set', + })} + + + ); + } + if (doesContainImage) { return ( = ({ } if (formattedValue !== undefined) { - return ( + return withTooltip( = ({ ); } - return ( + return withTooltip( {JSON.stringify(value)} @@ -76,19 +145,14 @@ export const PreviewListItem: React.FC = ({ className="indexPatternFieldEditor__previewFieldList__item__key__wrapper" data-test-subj="key" > - {key} + {renderName()}
- - {renderValue()} - + {renderValue()} { }, fields, error, + documents: { fetchDocError }, reset, + isPreviewAvailable, } = useFieldPreviewContext(); // To show the preview we at least need a name to be defined, the script or the format @@ -38,12 +40,15 @@ export const FieldPreview = () => { name === null && script === null && format === null ? true : // If we have some result from the _execute API call don't show the empty prompt - error !== null || fields.length > 0 + Boolean(error) || fields.length > 0 ? false : name === null && format === null ? true : false; + const doRenderListOfFields = fetchDocError === null; + const showWarningPreviewNotAvailable = isPreviewAvailable === false && fetchDocError === null; + const onFieldListResize = useCallback(({ height }: { height: number }) => { setFieldListHeight(height); }, []); @@ -58,7 +63,7 @@ export const FieldPreview = () => { return (
  • - +
); @@ -70,9 +75,6 @@ export const FieldPreview = () => { return reset; }, [reset]); - const doShowFieldList = - error === null || (error.code !== 'DOC_NOT_FOUND' && error.code !== 'ERR_FETCHING_DOC'); - return (
{ - - - - setSearchValue(e.target.value)} - placeholder={i18n.translate( - 'indexPatternFieldEditor.fieldPreview.filterFieldsPlaceholder', - { - defaultMessage: 'Filter fields', - } - )} - fullWidth - data-test-subj="filterFieldsInput" - /> - - - - - - {doShowFieldList && ( - <> - {/* The current field(s) the user is creating */} - {renderFieldsToPreview()} - - {/* List of other fields in the document */} - - {(resizeRef) => ( -
- setSearchValue('')} - searchValue={searchValue} - // We add a key to force rerender the virtual list whenever the window height changes - key={fieldListHeight} - /> -
+ {showWarningPreviewNotAvailable ? ( + +

+ {i18n.translate( + 'indexPatternFieldEditor.fieldPreview.notAvailableWarningCallout.description', + { + defaultMessage: + 'Runtime field preview is disabled because no documents could be fetched from the cluster.', + } )} - +

+
+ ) : ( + <> + + + + {doRenderListOfFields && ( + <> + setSearchValue(e.target.value)} + placeholder={i18n.translate( + 'indexPatternFieldEditor.fieldPreview.filterFieldsPlaceholder', + { + defaultMessage: 'Filter fields', + } + )} + fullWidth + data-test-subj="filterFieldsInput" + /> + + + )} + + + + + {doRenderListOfFields && ( + <> + {/* The current field(s) the user is creating */} + {renderFieldsToPreview()} + + {/* List of other fields in the document */} + + {(resizeRef) => ( +
+ setSearchValue('')} + searchValue={searchValue} + // We add a key to force rerender the virtual list whenever the window height changes + key={fieldListHeight} + /> +
+ )} +
+ + )} )} 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 21ab055c9b05e..74f77f91e2f13 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 @@ -20,81 +20,18 @@ import useDebounce from 'react-use/lib/useDebounce'; import { i18n } from '@kbn/i18n'; import { get } from 'lodash'; -import type { FieldPreviewContext, FieldFormatConfig } from '../../types'; import { parseEsError } from '../../lib/runtime_field_validation'; -import { RuntimeType, RuntimeField } from '../../shared_imports'; import { useFieldEditorContext } from '../field_editor_context'; - -type From = 'cluster' | 'custom'; -interface EsDocument { - _id: string; - [key: string]: any; -} - -interface PreviewError { - code: 'DOC_NOT_FOUND' | 'PAINLESS_SCRIPT_ERROR' | 'ERR_FETCHING_DOC'; - error: Record; -} - -interface ClusterData { - documents: EsDocument[]; - currentIdx: number; -} - -// The parameters required to preview the field -interface Params { - name: string | null; - index: string | null; - type: RuntimeType | null; - script: Required['script'] | null; - format: FieldFormatConfig | null; - document: EsDocument | null; -} - -export interface FieldPreview { - key: string; - value: unknown; - formattedValue?: string; -} - -interface Context { - fields: FieldPreview[]; - error: PreviewError | null; - params: { - value: Params; - update: (updated: Partial) => void; - }; - isLoadingPreview: boolean; - currentDocument: { - value?: EsDocument; - id: string; - isLoading: boolean; - isCustomId: boolean; - }; - documents: { - loadSingle: (id: string) => void; - loadFromCluster: () => Promise; - }; - panel: { - isVisible: boolean; - setIsVisible: (isVisible: boolean) => void; - }; - from: { - value: From; - set: (value: From) => void; - }; - navigation: { - isFirstDoc: boolean; - isLastDoc: boolean; - next: () => void; - prev: () => void; - }; - reset: () => void; - pinnedFields: { - value: { [key: string]: boolean }; - set: React.Dispatch>; - }; -} +import type { + PainlessExecuteContext, + Context, + Params, + ClusterData, + From, + EsDocument, + ScriptErrorCodes, + FetchDocError, +} from './types'; const fieldPreviewContext = createContext(undefined); @@ -112,7 +49,10 @@ export const defaultValueFormatter = (value: unknown) => export const FieldPreviewProvider: FunctionComponent = ({ children }) => { const previewCount = useRef(0); - const [lastExecutePainlessRequestParams, setLastExecutePainlessReqParams] = useState<{ + + // We keep in cache the latest params sent to the _execute API so we don't make unecessary requests + // when changing parameters that don't affect the preview result (e.g. changing the "name" field). + const lastExecutePainlessRequestParams = useRef<{ type: Params['type']; script: string | undefined; documentId: string | undefined; @@ -138,6 +78,8 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { fields: Context['fields']; error: Context['error']; }>({ fields: [], error: null }); + /** Possible error while fetching sample documents */ + const [fetchDocError, setFetchDocError] = useState(null); /** The parameters required for the Painless _execute API */ const [params, setParams] = useState(defaultParams); /** The sample documents fetched from the cluster */ @@ -146,7 +88,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { currentIdx: 0, }); /** Flag to show/hide the preview panel */ - const [isPanelVisible, setIsPanelVisible] = useState(false); + const [isPanelVisible, setIsPanelVisible] = useState(true); /** Flag to indicate if we are loading document from cluster */ const [isFetchingDocument, setIsFetchingDocument] = useState(false); /** Flag to indicate if we are calling the _execute API */ @@ -157,44 +99,66 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { const [from, setFrom] = useState('cluster'); /** Map of fields pinned to the top of the list */ const [pinnedFields, setPinnedFields] = useState<{ [key: string]: boolean }>({}); + /** Keep track if the script painless syntax is being validated and if it is valid */ + const [scriptEditorValidation, setScriptEditorValidation] = useState<{ + isValidating: boolean; + isValid: boolean; + message: string | null; + }>({ isValidating: false, isValid: true, message: null }); const { documents, currentIdx } = clusterData; - const currentDocument: EsDocument | undefined = useMemo( - () => documents[currentIdx], - [documents, currentIdx] - ); - - const currentDocIndex = currentDocument?._index; - const currentDocId: string = currentDocument?._id ?? ''; + const currentDocument: EsDocument | undefined = documents[currentIdx]; + const currentDocIndex: string | undefined = currentDocument?._index; + const currentDocId: string | undefined = currentDocument?._id; const totalDocs = documents.length; + const isCustomDocId = customDocIdToLoad !== null; + let isPreviewAvailable = true; + + // If no documents could be fetched from the cluster (and we are not trying to load + // a custom doc ID) then we disable preview as the script field validation expect the result + // of the preview to before resolving. If there are no documents we can't have a preview + // (the _execute API expects one) and thus the validation should not expect any value. + if (!isFetchingDocument && !isCustomDocId && documents.length === 0) { + isPreviewAvailable = false; + } + const { name, document, script, format, type } = params; const updateParams: Context['params']['update'] = useCallback((updated) => { setParams((prev) => ({ ...prev, ...updated })); }, []); - const needToUpdatePreview = useMemo(() => { - const isCurrentDocIdDefined = currentDocId !== ''; - - if (!isCurrentDocIdDefined) { + const allParamsDefined = useMemo(() => { + if (!currentDocIndex || !script?.source || !type) { return false; } - - const allParamsDefined = (['type', 'script', 'index', 'document'] as Array).every( - (key) => Boolean(params[key]) + return true; + }, [currentDocIndex, script?.source, type]); + + const hasSomeParamsChanged = useMemo(() => { + return ( + lastExecutePainlessRequestParams.current.type !== type || + lastExecutePainlessRequestParams.current.script !== script?.source || + lastExecutePainlessRequestParams.current.documentId !== currentDocId ); + }, [type, script, currentDocId]); - if (!allParamsDefined) { - return false; - } - - const hasSomeParamsChanged = - lastExecutePainlessRequestParams.type !== type || - lastExecutePainlessRequestParams.script !== script?.source || - lastExecutePainlessRequestParams.documentId !== currentDocId; + const setPreviewError = useCallback((error: Context['error']) => { + setPreviewResponse((prev) => ({ + ...prev, + error, + })); + }, []); - return hasSomeParamsChanged; - }, [type, script?.source, currentDocId, params, lastExecutePainlessRequestParams]); + const clearPreviewError = useCallback((errorCode: ScriptErrorCodes) => { + setPreviewResponse((prev) => { + const error = prev.error === null || prev.error?.code === errorCode ? null : prev.error; + return { + ...prev, + error, + }; + }); + }, []); const valueFormatter = useCallback( (value: unknown) => { @@ -217,14 +181,11 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { throw new Error('The "limit" option must be a number'); } + lastExecutePainlessRequestParams.current.documentId = undefined; setIsFetchingDocument(true); - setClusterData({ - documents: [], - currentIdx: 0, - }); setPreviewResponse({ fields: [], error: null }); - const [response, error] = await search + const [response, searchError] = await search .search({ params: { index: indexPattern.title, @@ -240,12 +201,29 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { setIsFetchingDocument(false); setCustomDocIdToLoad(null); - setClusterData({ - documents: response ? response.rawResponse.hits.hits : [], - currentIdx: 0, - }); + const error: FetchDocError | null = Boolean(searchError) + ? { + code: 'ERR_FETCHING_DOC', + error: { + message: searchError.toString(), + reason: i18n.translate( + 'indexPatternFieldEditor.fieldPreview.error.errorLoadingSampleDocumentsDescription', + { + defaultMessage: 'Error loading sample documents.', + } + ), + }, + } + : null; - setPreviewResponse((prev) => ({ ...prev, error })); + setFetchDocError(error); + + if (error === null) { + setClusterData({ + documents: response ? response.rawResponse.hits.hits : [], + currentIdx: 0, + }); + } }, [indexPattern, search] ); @@ -256,6 +234,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { return; } + lastExecutePainlessRequestParams.current.documentId = undefined; setIsFetchingDocument(true); const [response, searchError] = await search @@ -280,11 +259,17 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { const isDocumentFound = response?.rawResponse.hits.total > 0; const loadedDocuments: EsDocument[] = isDocumentFound ? response.rawResponse.hits.hits : []; - const error: Context['error'] = Boolean(searchError) + const error: FetchDocError | null = Boolean(searchError) ? { code: 'ERR_FETCHING_DOC', error: { message: searchError.toString(), + reason: i18n.translate( + 'indexPatternFieldEditor.fieldPreview.error.errorLoadingDocumentDescription', + { + defaultMessage: 'Error loading document.', + } + ), }, } : isDocumentFound === false @@ -301,14 +286,14 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { } : null; - setPreviewResponse((prev) => ({ ...prev, error })); + setFetchDocError(error); - setClusterData({ - documents: loadedDocuments, - currentIdx: 0, - }); - - if (error !== null) { + if (error === null) { + setClusterData({ + documents: loadedDocuments, + currentIdx: 0, + }); + } else { // Make sure we disable the "Updating..." indicator as we have an error // and we won't fetch the preview setIsLoadingPreview(false); @@ -318,23 +303,28 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { ); const updatePreview = useCallback(async () => { - setLastExecutePainlessReqParams({ - type: params.type, - script: params.script?.source, - documentId: currentDocId, - }); + if (scriptEditorValidation.isValidating) { + return; + } - if (!needToUpdatePreview) { + if (!allParamsDefined || !hasSomeParamsChanged || scriptEditorValidation.isValid === false) { + setIsLoadingPreview(false); return; } + lastExecutePainlessRequestParams.current = { + type, + script: 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!, + index: currentDocIndex!, + document: document!, + context: `${type!}_field` as PainlessExecuteContext, + script: script!, documentId: currentDocId, }); @@ -344,8 +334,6 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { return; } - setIsLoadingPreview(false); - const { error: serverError } = response; if (serverError) { @@ -355,39 +343,43 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { }); notifications.toasts.addError(serverError, { title }); + setIsLoadingPreview(false); return; } - const { values, error } = response.data ?? { values: [], error: {} }; - - if (error) { - const fallBackError = { - message: i18n.translate('indexPatternFieldEditor.fieldPreview.defaultErrorTitle', { - defaultMessage: 'Unable to run the provided 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, - }); + if (response.data) { + const { values, error } = response.data; + + if (error) { + setPreviewResponse({ + fields: [{ key: name ?? '', value: '', formattedValue: defaultValueFormatter('') }], + error: { code: 'PAINLESS_SCRIPT_ERROR', error: parseEsError(error) }, + }); + } else { + const [value] = values; + const formattedValue = valueFormatter(value); + + setPreviewResponse({ + fields: [{ key: name!, value, formattedValue }], + error: null, + }); + } } + + setIsLoadingPreview(false); }, [ - needToUpdatePreview, - params, + name, + type, + script, + document, currentDocIndex, currentDocId, getFieldPreview, notifications.toasts, valueFormatter, + allParamsDefined, + scriptEditorValidation, + hasSomeParamsChanged, ]); const goToNextDoc = useCallback(() => { @@ -416,11 +408,6 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { currentIdx: 0, }); setPreviewResponse({ fields: [], error: null }); - setLastExecutePainlessReqParams({ - type: null, - script: undefined, - documentId: undefined, - }); setFrom('cluster'); setIsLoadingPreview(false); setIsFetchingDocument(false); @@ -430,6 +417,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { () => ({ fields: previewResponse.fields, error: previewResponse.error, + isPreviewAvailable, isLoadingPreview, params: { value: params, @@ -437,13 +425,14 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { }, currentDocument: { value: currentDocument, - id: customDocIdToLoad !== null ? customDocIdToLoad : currentDocId, + id: isCustomDocId ? customDocIdToLoad! : currentDocId, isLoading: isFetchingDocument, - isCustomId: customDocIdToLoad !== null, + isCustomId: isCustomDocId, }, documents: { loadSingle: setCustomDocIdToLoad, loadFromCluster: fetchSampleDocuments, + fetchDocError, }, navigation: { isFirstDoc: currentIdx === 0, @@ -464,14 +453,20 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { value: pinnedFields, set: setPinnedFields, }, + validation: { + setScriptEditorValidation, + }, }), [ previewResponse, + fetchDocError, params, + isPreviewAvailable, isLoadingPreview, updateParams, currentDocument, currentDocId, + isCustomDocId, fetchSampleDocuments, isFetchingDocument, customDocIdToLoad, @@ -488,38 +483,23 @@ 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 isLoadingPreview state in this effect + * the 500ms of the debounce, we set the isLoadingPreview state in this effect whenever + * one of the _execute API param changes */ useEffect(() => { - if (needToUpdatePreview) { + if (allParamsDefined && hasSomeParamsChanged) { setIsLoadingPreview(true); } - }, [needToUpdatePreview, customDocIdToLoad]); + }, [allParamsDefined, hasSomeParamsChanged, script?.source, type, currentDocId]); /** - * Whenever we enter manually a document ID to load we'll clear the - * documents and the preview value. + * In order to immediately display the "Updating..." state indicator and not have to wait + * the 500ms of the debounce, we set the isFetchingDocument state in this effect whenever + * "customDocIdToLoad" changes */ useEffect(() => { - if (customDocIdToLoad !== null) { + if (customDocIdToLoad !== null && Boolean(customDocIdToLoad.trim())) { setIsFetchingDocument(true); - - setClusterData({ - documents: [], - currentIdx: 0, - }); - - setPreviewResponse((prev) => { - const { - fields: { 0: field }, - } = prev; - return { - ...prev, - fields: [ - { ...field, value: undefined, formattedValue: defaultValueFormatter(undefined) }, - ], - }; - }); } }, [customDocIdToLoad]); @@ -566,14 +546,60 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { }); }, [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] - ); + useEffect(() => { + if (script?.source === undefined) { + // Whenever the source is not defined ("Set value" is toggled off or the + // script is empty) we clear the error and update the params cache. + lastExecutePainlessRequestParams.current.script = undefined; + setPreviewError(null); + } + }, [script?.source, setPreviewError]); + + // Handle the validation state coming from the Painless DiagnosticAdapter + // (see @kbn-monaco/src/painless/diagnostics_adapter.ts) + useEffect(() => { + if (scriptEditorValidation.isValidating) { + return; + } + if (scriptEditorValidation.isValid === false) { + // Make sure to remove the "Updating..." spinner + setIsLoadingPreview(false); + + // Set preview response error so it is displayed in the flyout footer + const error = + script?.source === undefined + ? null + : { + code: 'PAINLESS_SYNTAX_ERROR' as const, + error: { + reason: + scriptEditorValidation.message ?? + i18n.translate('indexPatternFieldEditor.fieldPreview.error.painlessSyntax', { + defaultMessage: 'Invalid Painless syntax', + }), + }, + }; + setPreviewError(error); + + // Make sure to update the lastExecutePainlessRequestParams cache so when the user updates + // the script and fixes the syntax the "updatePreview()" will run + lastExecutePainlessRequestParams.current.script = script?.source; + } else { + // Clear possible previous syntax error + clearPreviewError('PAINLESS_SYNTAX_ERROR'); + } + }, [scriptEditorValidation, script?.source, setPreviewError, clearPreviewError]); + + /** + * 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. + */ + useDebounce(updatePreview, 500, [updatePreview]); + + /** + * Whenever the doc ID to load changes we load the document (after a 500ms debounce) + */ useDebounce( () => { if (customDocIdToLoad === null) { 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 7994e649e1ebb..6ca38d4d186fb 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 @@ -12,27 +12,25 @@ import { i18n } from '@kbn/i18n'; import { useFieldPreviewContext } from './field_preview_context'; export const FieldPreviewError = () => { - const { error } = useFieldPreviewContext(); + const { + documents: { fetchDocError }, + } = useFieldPreviewContext(); - if (error === null) { + if (fetchDocError === null) { return null; } return ( - {error.code === 'PAINLESS_SCRIPT_ERROR' ? ( -

{error.error.reason}

- ) : ( -

{error.error.message}

- )} +

{fetchDocError.error.message ?? fetchDocError.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 2d3d5c20ba7b3..28b75a43b7d11 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 @@ -7,18 +7,12 @@ */ import React from 'react'; -import { - EuiTitle, - EuiText, - EuiTextColor, - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, -} from '@elastic/eui'; +import { EuiTitle, EuiText, EuiTextColor, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useFieldEditorContext } from '../field_editor_context'; import { useFieldPreviewContext } from './field_preview_context'; +import { IsUpdatingIndicator } from './is_updating_indicator'; const i18nTexts = { title: i18n.translate('indexPatternFieldEditor.fieldPreview.title', { @@ -27,21 +21,15 @@ const i18nTexts = { customData: i18n.translate('indexPatternFieldEditor.fieldPreview.subTitle.customData', { defaultMessage: 'Custom data', }), - updatingLabel: i18n.translate('indexPatternFieldEditor.fieldPreview.updatingPreviewLabel', { - defaultMessage: 'Updating...', - }), }; export const FieldPreviewHeader = () => { const { indexPattern } = useFieldEditorContext(); const { from, - isLoadingPreview, - currentDocument: { isLoading }, + currentDocument: { isLoading: isFetchingDocument }, } = useFieldPreviewContext(); - const isUpdating = isLoadingPreview || isLoading; - return (
@@ -50,15 +38,9 @@ export const FieldPreviewHeader = () => {

{i18nTexts.title}

- - {isUpdating && ( - - - - - - {i18nTexts.updatingLabel} - + {isFetchingDocument && ( + + )}
diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/index.ts b/src/plugins/index_pattern_field_editor/public/components/preview/index.ts index 5d3b4bb41fc5f..2f93616ef72eb 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/index.ts +++ b/src/plugins/index_pattern_field_editor/public/components/preview/index.ts @@ -9,3 +9,5 @@ export { useFieldPreviewContext, FieldPreviewProvider } from './field_preview_context'; export { FieldPreview } from './field_preview'; + +export type { PainlessExecuteContext, FieldPreviewResponse, Context } from './types'; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/is_updating_indicator.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/is_updating_indicator.tsx new file mode 100644 index 0000000000000..0c030d498c617 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/preview/is_updating_indicator.tsx @@ -0,0 +1,27 @@ +/* + * 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 React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; + +export const IsUpdatingIndicator = () => { + return ( +
+ + + + + + {i18n.translate('indexPatternFieldEditor.fieldPreview.updatingPreviewLabel', { + defaultMessage: 'Updating...', + })} + + +
+ ); +}; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/types.ts b/src/plugins/index_pattern_field_editor/public/components/preview/types.ts new file mode 100644 index 0000000000000..d7c0a5867efd6 --- /dev/null +++ b/src/plugins/index_pattern_field_editor/public/components/preview/types.ts @@ -0,0 +1,139 @@ +/* + * 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 React from 'react'; + +import type { RuntimeType, RuntimeField } from '../../shared_imports'; +import type { FieldFormatConfig, RuntimeFieldPainlessError } from '../../types'; + +export type From = 'cluster' | 'custom'; + +export interface EsDocument { + _id: string; + _index: string; + _source: { + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export type ScriptErrorCodes = 'PAINLESS_SCRIPT_ERROR' | 'PAINLESS_SYNTAX_ERROR'; +export type FetchDocErrorCodes = 'DOC_NOT_FOUND' | 'ERR_FETCHING_DOC'; + +interface PreviewError { + code: ScriptErrorCodes; + error: + | RuntimeFieldPainlessError + | { + reason?: string; + [key: string]: unknown; + }; +} + +export interface FetchDocError { + code: FetchDocErrorCodes; + error: { + message?: string; + reason?: string; + [key: string]: unknown; + }; +} + +export interface ClusterData { + documents: EsDocument[]; + currentIdx: number; +} + +// The parameters required to preview the field +export interface Params { + name: string | null; + index: string | null; + type: RuntimeType | null; + script: Required['script'] | null; + format: FieldFormatConfig | null; + document: { [key: string]: unknown } | null; +} + +export interface FieldPreview { + key: string; + value: unknown; + formattedValue?: string; +} + +export interface Context { + fields: FieldPreview[]; + error: PreviewError | null; + params: { + value: Params; + update: (updated: Partial) => void; + }; + isPreviewAvailable: boolean; + isLoadingPreview: boolean; + currentDocument: { + value?: EsDocument; + id?: string; + isLoading: boolean; + isCustomId: boolean; + }; + documents: { + loadSingle: (id: string) => void; + loadFromCluster: () => Promise; + fetchDocError: FetchDocError | null; + }; + panel: { + isVisible: boolean; + setIsVisible: (isVisible: boolean) => void; + }; + from: { + value: From; + set: (value: From) => void; + }; + navigation: { + isFirstDoc: boolean; + isLastDoc: boolean; + next: () => void; + prev: () => void; + }; + reset: () => void; + pinnedFields: { + value: { [key: string]: boolean }; + set: React.Dispatch>; + }; + validation: { + setScriptEditorValidation: React.Dispatch< + React.SetStateAction<{ isValid: boolean; isValidating: boolean; message: string | null }> + >; + }; +} + +export type PainlessExecuteContext = + | 'boolean_field' + | 'date_field' + | 'double_field' + | 'geo_point_field' + | 'ip_field' + | 'keyword_field' + | 'long_field'; + +export interface FieldPreviewResponse { + values: unknown[]; + error?: ScriptError; +} + +export interface ScriptError { + caused_by: { + reason: string; + [key: string]: unknown; + }; + position?: { + offset: number; + start: number; + end: number; + }; + script_stack?: string[]; + [key: string]: unknown; +} diff --git a/src/plugins/index_pattern_field_editor/public/lib/api.ts b/src/plugins/index_pattern_field_editor/public/lib/api.ts index 9641619640a52..594cd07ecb70e 100644 --- a/src/plugins/index_pattern_field_editor/public/lib/api.ts +++ b/src/plugins/index_pattern_field_editor/public/lib/api.ts @@ -8,7 +8,7 @@ import { HttpSetup } from 'src/core/public'; import { API_BASE_PATH } from '../../common/constants'; import { sendRequest } from '../shared_imports'; -import { FieldPreviewContext, FieldPreviewResponse } from '../types'; +import { PainlessExecuteContext, FieldPreviewResponse } from '../components/preview'; export const initApi = (httpClient: HttpSetup) => { const getFieldPreview = ({ @@ -19,7 +19,7 @@ export const initApi = (httpClient: HttpSetup) => { documentId, }: { index: string; - context: FieldPreviewContext; + context: PainlessExecuteContext; script: { source: string } | null; document: Record; documentId: string; diff --git a/src/plugins/index_pattern_field_editor/public/lib/index.ts b/src/plugins/index_pattern_field_editor/public/lib/index.ts index d9aaab77ff66a..c7627a63da9ff 100644 --- a/src/plugins/index_pattern_field_editor/public/lib/index.ts +++ b/src/plugins/index_pattern_field_editor/public/lib/index.ts @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -export { deserializeField } from './serialization'; +export { deserializeField, painlessErrorToMonacoMarker } from './serialization'; export { getLinks } from './documentation'; -export type { RuntimeFieldPainlessError } from './runtime_field_validation'; -export { getRuntimeFieldValidator, parseEsError } from './runtime_field_validation'; +export { parseEsError } from './runtime_field_validation'; export type { ApiService } from './api'; + export { initApi } from './api'; diff --git a/src/plugins/index_pattern_field_editor/public/lib/runtime_field_validation.test.ts b/src/plugins/index_pattern_field_editor/public/lib/runtime_field_validation.test.ts deleted file mode 100644 index b25d47b3d0d15..0000000000000 --- a/src/plugins/index_pattern_field_editor/public/lib/runtime_field_validation.test.ts +++ /dev/null @@ -1,165 +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 { dataPluginMock } from '../../../data/public/mocks'; -import { getRuntimeFieldValidator } from './runtime_field_validation'; - -const dataStart = dataPluginMock.createStartContract(); -const { search } = dataStart; - -const runtimeField = { - type: 'keyword', - script: { - source: 'emit("hello")', - }, -}; - -const spy = jest.fn(); - -search.search = () => - ({ - toPromise: spy, - } as any); - -const validator = getRuntimeFieldValidator('myIndex', search); - -describe('Runtime field validation', () => { - const expectedError = { - message: 'Error compiling the painless script', - position: { offset: 4, start: 0, end: 18 }, - reason: 'cannot resolve symbol [emit]', - scriptStack: ["emit.some('value')", ' ^---- HERE'], - }; - - [ - { - title: 'should return null when there are no errors', - response: {}, - status: 200, - expected: null, - }, - { - title: 'should return the error in the first failed shard', - response: { - attributes: { - type: 'status_exception', - reason: 'error while executing search', - caused_by: { - failed_shards: [ - { - shard: 0, - index: 'kibana_sample_data_logs', - node: 'gVwk20UWSdO6VyuNOc_6UA', - reason: { - type: 'script_exception', - script_stack: ["emit.some('value')", ' ^---- HERE'], - position: { offset: 4, start: 0, end: 18 }, - caused_by: { - type: 'illegal_argument_exception', - reason: 'cannot resolve symbol [emit]', - }, - }, - }, - ], - }, - }, - }, - status: 400, - expected: expectedError, - }, - { - title: 'should return the error in the third failed shard', - response: { - attributes: { - type: 'status_exception', - reason: 'error while executing search', - caused_by: { - failed_shards: [ - { - shard: 0, - index: 'kibana_sample_data_logs', - node: 'gVwk20UWSdO6VyuNOc_6UA', - reason: { - type: 'foo', - }, - }, - { - shard: 1, - index: 'kibana_sample_data_logs', - node: 'gVwk20UWSdO6VyuNOc_6UA', - reason: { - type: 'bar', - }, - }, - { - shard: 2, - index: 'kibana_sample_data_logs', - node: 'gVwk20UWSdO6VyuNOc_6UA', - reason: { - type: 'script_exception', - script_stack: ["emit.some('value')", ' ^---- HERE'], - position: { offset: 4, start: 0, end: 18 }, - caused_by: { - type: 'illegal_argument_exception', - reason: 'cannot resolve symbol [emit]', - }, - }, - }, - ], - }, - }, - }, - status: 400, - expected: expectedError, - }, - { - title: 'should have default values if an error prop is not found', - response: { - attributes: { - type: 'status_exception', - reason: 'error while executing search', - caused_by: { - failed_shards: [ - { - shard: 0, - index: 'kibana_sample_data_logs', - node: 'gVwk20UWSdO6VyuNOc_6UA', - reason: { - // script_stack, position and caused_by are missing - type: 'script_exception', - caused_by: { - type: 'illegal_argument_exception', - }, - }, - }, - ], - }, - }, - }, - status: 400, - expected: { - message: 'Error compiling the painless script', - position: null, - reason: null, - scriptStack: [], - }, - }, - ].map(({ title, response, status, expected }) => { - test(title, async () => { - if (status !== 200) { - spy.mockRejectedValueOnce(response); - } else { - spy.mockResolvedValueOnce(response); - } - - const result = await validator(runtimeField); - - expect(result).toEqual(expected); - }); - }); -}); diff --git a/src/plugins/index_pattern_field_editor/public/lib/runtime_field_validation.ts b/src/plugins/index_pattern_field_editor/public/lib/runtime_field_validation.ts index 5f80b7823b6a0..770fb548f1251 100644 --- a/src/plugins/index_pattern_field_editor/public/lib/runtime_field_validation.ts +++ b/src/plugins/index_pattern_field_editor/public/lib/runtime_field_validation.ts @@ -6,72 +6,28 @@ * Side Public License, v 1. */ import { i18n } from '@kbn/i18n'; +import { ScriptError } from '../components/preview/types'; +import { RuntimeFieldPainlessError, PainlessErrorCode } from '../types'; -import { DataPublicPluginStart } from '../shared_imports'; -import type { EsRuntimeField } from '../types'; - -export interface RuntimeFieldPainlessError { - message: string; - reason: string; - position: { - offset: number; - start: number; - end: number; - } | null; - scriptStack: string[]; -} - -type Error = Record; - -/** - * We are only interested in "script_exception" error type - */ -const getScriptExceptionErrorOnShard = (error: Error): Error | null => { - if (error.type === 'script_exception') { - return error; - } - - if (!error.caused_by) { - return null; +export const getErrorCodeFromErrorReason = (reason: string = ''): PainlessErrorCode => { + if (reason.startsWith('Cannot cast from')) { + return 'CAST_ERROR'; } - - // Recursively try to get a script exception error - return getScriptExceptionErrorOnShard(error.caused_by); + return 'UNKNOWN'; }; -/** - * We get the first script exception error on any failing shard. - * The UI can only display one error at the time so there is no need - * to look any further. - */ -const getScriptExceptionError = (error: Error): Error | null => { - if (error === undefined || !Array.isArray(error.failed_shards)) { - return null; - } +export const parseEsError = (scriptError: ScriptError): RuntimeFieldPainlessError => { + let reason = scriptError.caused_by?.reason; + const errorCode = getErrorCodeFromErrorReason(reason); - let scriptExceptionError = null; - for (const err of error.failed_shards) { - scriptExceptionError = getScriptExceptionErrorOnShard(err.reason); - - if (scriptExceptionError !== null) { - break; - } - } - return scriptExceptionError; -}; - -export const parseEsError = ( - error?: Error, - isScriptError = false -): RuntimeFieldPainlessError | null => { - if (error === undefined) { - return null; - } - - const scriptError = isScriptError ? error : getScriptExceptionError(error.caused_by); - - if (scriptError === null) { - return null; + if (errorCode === 'CAST_ERROR') { + // Help the user as he might have forgot to change the runtime type + reason = `${reason} ${i18n.translate( + 'indexPatternFieldEditor.editor.form.scriptEditor.castErrorMessage', + { + defaultMessage: 'Verify that you have correctly set the runtime field type.', + } + )}`; } return { @@ -83,36 +39,7 @@ export const parseEsError = ( ), position: scriptError.position ?? null, scriptStack: scriptError.script_stack ?? [], - reason: scriptError.caused_by?.reason ?? null, + reason: reason ?? null, + code: errorCode, }; }; - -/** - * Handler to validate the painless script for syntax and semantic errors. - * This is a temporary solution. In a future work we will have a dedicate - * ES API to debug the script. - */ -export const getRuntimeFieldValidator = - (index: string, searchService: DataPublicPluginStart['search']) => - async (runtimeField: EsRuntimeField) => { - return await searchService - .search({ - params: { - index, - body: { - runtime_mappings: { - temp: runtimeField, - }, - size: 0, - query: { - match_none: {}, - }, - }, - }, - }) - .toPromise() - .then(() => null) - .catch((e) => { - return parseEsError(e.attributes); - }); - }; diff --git a/src/plugins/index_pattern_field_editor/public/lib/serialization.ts b/src/plugins/index_pattern_field_editor/public/lib/serialization.ts index 8a0a47e07c9c9..0f042cdac114f 100644 --- a/src/plugins/index_pattern_field_editor/public/lib/serialization.ts +++ b/src/plugins/index_pattern_field_editor/public/lib/serialization.ts @@ -5,9 +5,9 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import { monaco } from '@kbn/monaco'; import { IndexPatternField, IndexPattern } from '../shared_imports'; -import type { Field } from '../types'; +import type { Field, RuntimeFieldPainlessError } from '../types'; export const deserializeField = ( indexPattern: IndexPattern, @@ -26,3 +26,20 @@ export const deserializeField = ( format: indexPattern.getFormatterForFieldNoDefault(field.name)?.toJSON(), }; }; + +export const painlessErrorToMonacoMarker = ( + { reason }: RuntimeFieldPainlessError, + startPosition: monaco.Position +): monaco.editor.IMarkerData | undefined => { + return { + startLineNumber: startPosition.lineNumber, + startColumn: startPosition.column, + endLineNumber: startPosition.lineNumber, + // Ideally we'd want the endColumn to be the end of the error but + // ES does not return that info. There is an issue to track the enhancement: + // https://github.com/elastic/elasticsearch/issues/78072 + endColumn: startPosition.column + 1, + message: reason, + severity: monaco.MarkerSeverity.Error, + }; +}; diff --git a/src/plugins/index_pattern_field_editor/public/shared_imports.ts b/src/plugins/index_pattern_field_editor/public/shared_imports.ts index e2154800908cb..5b377bdd1d2b5 100644 --- a/src/plugins/index_pattern_field_editor/public/shared_imports.ts +++ b/src/plugins/index_pattern_field_editor/public/shared_imports.ts @@ -23,7 +23,9 @@ export type { FormHook, ValidationFunc, FieldConfig, + ValidationCancelablePromise, } from '../../es_ui_shared/static/forms/hook_form_lib'; + export { useForm, useFormData, @@ -31,6 +33,7 @@ export { useFormIsModified, Form, UseField, + useBehaviorSubject, } from '../../es_ui_shared/static/forms/hook_form_lib'; export { fieldValidators } from '../../es_ui_shared/static/forms/helpers'; diff --git a/src/plugins/index_pattern_field_editor/public/types.ts b/src/plugins/index_pattern_field_editor/public/types.ts index f7efc9d82fc48..9d62a5568584c 100644 --- a/src/plugins/index_pattern_field_editor/public/types.ts +++ b/src/plugins/index_pattern_field_editor/public/types.ts @@ -66,16 +66,24 @@ export interface EsRuntimeField { export type CloseEditor = () => void; -export type FieldPreviewContext = - | 'boolean_field' - | 'date_field' - | 'double_field' - | 'geo_point_field' - | 'ip_field' - | 'keyword_field' - | 'long_field'; +export type PainlessErrorCode = 'CAST_ERROR' | 'UNKNOWN'; -export interface FieldPreviewResponse { - values: unknown[]; - error?: Record; +export interface RuntimeFieldPainlessError { + message: string; + reason: string; + position: { + offset: number; + start: number; + end: number; + } | null; + scriptStack: string[]; + code: PainlessErrorCode; +} + +export interface MonacoEditorErrorMarker { + startLineNumber: number; + startColumn: number; + endLineNumber: number; + endColumn: number; + message: string; } diff --git a/src/plugins/index_pattern_field_editor/server/routes/field_preview.ts b/src/plugins/index_pattern_field_editor/server/routes/field_preview.ts index 9ffa5c88df8e8..e95c12469ffb9 100644 --- a/src/plugins/index_pattern_field_editor/server/routes/field_preview.ts +++ b/src/plugins/index_pattern_field_editor/server/routes/field_preview.ts @@ -58,6 +58,13 @@ export const registerFieldPreviewRoute = ({ router }: RouteDependencies): void = }; try { + // Ideally we want to use the Painless _execute API to get the runtime field preview. + // There is a current ES limitation that requires a user to have too many privileges + // to execute the script. (issue: https://github.com/elastic/elasticsearch/issues/48856) + // Until we find a way to execute a script without advanced privileges we are going to + // use the Search API to get the field value (and possible errors). + // Note: here is the PR were we changed from using Painless _execute to _search and should be + // reverted when the ES issue is fixed: https://github.com/elastic/kibana/pull/115070 const response = await client.asCurrentUser.search({ index: req.body.index, body, diff --git a/test/api_integration/apis/index_pattern_field_editor/field_preview.ts b/test/api_integration/apis/index_pattern_field_editor/field_preview.ts index 7123be1deb18a..c687f3094b6fd 100644 --- a/test/api_integration/apis/index_pattern_field_editor/field_preview.ts +++ b/test/api_integration/apis/index_pattern_field_editor/field_preview.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; +import { getErrorCodeFromErrorReason } from '../../../../src/plugins/index_pattern_field_editor/public/lib/runtime_field_validation'; import { FtrProviderContext } from '../../ftr_provider_context'; import { API_BASE_PATH } from './constants'; @@ -140,5 +141,26 @@ export default function ({ getService }: FtrProviderContext) { .expect(400); }); }); + + describe('Error messages', () => { + // As ES does not return error codes we will add a test to make sure its error message string + // does not change overtime as we rely on it to extract our own error code. + // If this test fail we'll need to update the "getErrorCodeFromErrorReason()" handler + it('should detect a script casting error', async () => { + const { body: response } = await supertest + .post(`${API_BASE_PATH}/field_preview`) + .send({ + script: { source: 'emit(123)' }, // We send a long but the type is "keyword" + context: 'keyword_field', + index: INDEX_NAME, + documentId: DOC_ID, + }) + .set('kbn-xsrf', 'xxx'); + + const errorCode = getErrorCodeFromErrorReason(response.error?.caused_by?.reason); + + expect(errorCode).be('CAST_ERROR'); + }); + }); }); } diff --git a/test/functional/apps/management/_index_pattern_popularity.js b/test/functional/apps/management/_index_pattern_popularity.js index 0618dd79e272e..1a71e4c5fbc68 100644 --- a/test/functional/apps/management/_index_pattern_popularity.js +++ b/test/functional/apps/management/_index_pattern_popularity.js @@ -33,7 +33,7 @@ export default function ({ getService, getPageObjects }) { }); afterEach(async () => { - await testSubjects.click('closeFlyoutButton'); + await PageObjects.settings.closeIndexPatternFieldEditor(); await PageObjects.settings.removeIndexPattern(); // Cancel saving the popularity change (we didn't make a change in this case, just checking the value) }); @@ -46,7 +46,7 @@ export default function ({ getService, getPageObjects }) { it('should be reset on cancel', async function () { // Cancel saving the popularity change - await testSubjects.click('closeFlyoutButton'); + await PageObjects.settings.closeIndexPatternFieldEditor(); await PageObjects.settings.openControlsByName(fieldName); // check that it is 0 (previous increase was cancelled const popularity = await PageObjects.settings.getPopularity(); diff --git a/test/functional/services/field_editor.ts b/test/functional/services/field_editor.ts index 27cb8cf010d92..d6d2f2606e29d 100644 --- a/test/functional/services/field_editor.ts +++ b/test/functional/services/field_editor.ts @@ -37,7 +37,13 @@ export class FieldEditorService extends FtrService { const textarea = await editor.findByClassName('monaco-mouse-cursor-text'); await textarea.click(); - await this.browser.pressKeys(script); + + // To avoid issue with the timing needed for Selenium to write the script and the monaco editor + // syntax validation kicking in, we loop through all the chars of the script and enter + // them one by one (instead of calling "await this.browser.pressKeys(script);"). + for (const letter of script.split('')) { + await this.browser.pressKeys(letter); + } } public async save() { await this.testSubjects.click('fieldSaveButton'); diff --git a/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx b/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx index 5a40be95dd824..76805c452bf98 100644 --- a/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx +++ b/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx @@ -628,13 +628,13 @@ export const ECSMappingEditorForm = forwardRef { validate(); - __validateFields(['result.value']); + validateFields(['result.value']); const { data, isValid } = await submit(); if (isValid) { @@ -652,7 +652,7 @@ export const ECSMappingEditorForm = forwardRef { if (defaultValue?.key && onDelete) { @@ -701,7 +701,7 @@ export const ECSMappingEditorForm = forwardRef { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 95bfc395e78f7..7a3952957a5f8 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -3799,9 +3799,7 @@ "indexPatternFieldEditor.editor.form.runtimeTypeLabel": "型", "indexPatternFieldEditor.editor.form.script.learnMoreLinkText": "スクリプト構文の詳細を参照してください。", "indexPatternFieldEditor.editor.form.scriptEditor.compileErrorMessage": "Painlessスクリプトのコンパイルエラー", - "indexPatternFieldEditor.editor.form.scriptEditor.debugErrorMessage": "構文エラー詳細", "indexPatternFieldEditor.editor.form.scriptEditorAriaLabel": "スクリプトエディター", - "indexPatternFieldEditor.editor.form.scriptEditorValidationMessage": "無効なPainless構文です。", "indexPatternFieldEditor.editor.form.source.scriptFieldHelpText": "スクリプトがないランタイムフィールドは、{source}から値を取得します。フィールドが_sourceに存在しない場合は、検索リクエストは値を返しません。{learnMoreLink}", "indexPatternFieldEditor.editor.form.typeSelectAriaLabel": "タイプ選択", "indexPatternFieldEditor.editor.form.validations.customLabelIsRequiredErrorMessage": "フィールドにラベルを付けます。", @@ -3812,9 +3810,6 @@ "indexPatternFieldEditor.editor.form.valueDescription": "{source}の同じ名前のフィールドから取得するのではなく、フィールドの値を設定します。", "indexPatternFieldEditor.editor.form.valueTitle": "値を設定", "indexPatternFieldEditor.editor.runtimeFieldsEditor.existRuntimeFieldNamesValidationErrorMessage": "この名前のフィールドはすでに存在します。", - "indexPatternFieldEditor.editor.validationErrorTitle": "続行する前にフォームのエラーを修正してください。", - "indexPatternFieldEditor.fieldPreview.defaultErrorTitle": "指定したスクリプトを実行できません", - "indexPatternFieldEditor.fieldPreview.documentIdField.documentNotFoundError": "ドキュメントが見つかりません", "indexPatternFieldEditor.fieldPreview.documentIdField.label": "ドキュメントID", "indexPatternFieldEditor.fieldPreview.documentIdField.loadDocumentsFromCluster": "クラスターからドキュメントを読み込む", "indexPatternFieldEditor.fieldPreview.documentNav.nextArialabel": "次のドキュメント", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 921bd74939afa..f46a091be5161 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -3835,9 +3835,7 @@ "indexPatternFieldEditor.editor.form.runtimeTypeLabel": "类型", "indexPatternFieldEditor.editor.form.script.learnMoreLinkText": "了解脚本语法。", "indexPatternFieldEditor.editor.form.scriptEditor.compileErrorMessage": "编译 Painless 脚本时出错", - "indexPatternFieldEditor.editor.form.scriptEditor.debugErrorMessage": "语法错误详细信息", "indexPatternFieldEditor.editor.form.scriptEditorAriaLabel": "脚本编辑器", - "indexPatternFieldEditor.editor.form.scriptEditorValidationMessage": "Painless 语法无效。", "indexPatternFieldEditor.editor.form.source.scriptFieldHelpText": "没有脚本的运行时字段从 {source} 中检索值。如果字段在 _source 中不存在,搜索请求将不返回值。{learnMoreLink}", "indexPatternFieldEditor.editor.form.typeSelectAriaLabel": "类型选择", "indexPatternFieldEditor.editor.form.validations.customLabelIsRequiredErrorMessage": "为字段提供标签。", @@ -3848,9 +3846,6 @@ "indexPatternFieldEditor.editor.form.valueDescription": "为字段设置值,而非从在 {source} 中同名的字段检索值。", "indexPatternFieldEditor.editor.form.valueTitle": "设置值", "indexPatternFieldEditor.editor.runtimeFieldsEditor.existRuntimeFieldNamesValidationErrorMessage": "已存在具有此名称的字段。", - "indexPatternFieldEditor.editor.validationErrorTitle": "继续前请解决表单中的错误。", - "indexPatternFieldEditor.fieldPreview.defaultErrorTitle": "无法运行提供的脚本", - "indexPatternFieldEditor.fieldPreview.documentIdField.documentNotFoundError": "未找到文档", "indexPatternFieldEditor.fieldPreview.documentIdField.label": "文档 ID", "indexPatternFieldEditor.fieldPreview.documentIdField.loadDocumentsFromCluster": "从集群加载文档", "indexPatternFieldEditor.fieldPreview.documentNav.nextArialabel": "下一个文档",