mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Runtime field editor] Improve error handling (#109233)
This commit is contained in:
parent
903df6d80e
commit
4bedc1cd93
69 changed files with 2053 additions and 1404 deletions
|
@ -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",
|
||||
]
|
||||
|
|
68
packages/kbn-monaco/__jest__/jest.mocks.ts
Normal file
68
packages/kbn-monaco/__jest__/jest.mocks.ts
Normal file
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
19
packages/kbn-monaco/__jest__/types.ts
Normal file
19
packages/kbn-monaco/__jest__/types.ts
Normal file
|
@ -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;
|
||||
}
|
147
packages/kbn-monaco/src/painless/diagnostics_adapter.test.ts
Normal file
147
packages/kbn-monaco/src/painless/diagnostics_adapter.test.ts
Normal file
|
@ -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<string[] | undefined> => 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);
|
||||
});
|
||||
});
|
|
@ -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<LangValidation>({
|
||||
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<void> {
|
||||
private async validate(resource: monaco.Uri, idx: number): Promise<void> {
|
||||
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() {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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<LangValidation> = () =>
|
||||
diagnosticsAdapter?.validation$ ||
|
||||
of<LangValidation>({ isValid: true, isValidating: false, errors: [] });
|
||||
|
||||
monaco.languages.onLanguage(ID, async () => {
|
||||
workerProxyService.setup();
|
||||
|
||||
|
|
|
@ -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<LangValidation>;
|
||||
}
|
||||
|
||||
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[];
|
||||
}
|
||||
|
|
|
@ -14,5 +14,6 @@
|
|||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"__jest__/**/*",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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<T>, (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 <DocLink id="formLibCoreUseField" section="validationData$" text="the field `validationData$` prop" />.
|
||||
|
||||
See an example on how to use this hook in the <DocLink id="formLibExampleValidation" section="asynchronous-dynamic-data-in-the-validator" text="asynchronous dynamic data in the validator" /> 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);
|
||||
|
||||
...
|
||||
|
||||
<UseField path="indexName" validationData$={indices$} />
|
||||
|
||||
}
|
||||
```
|
|
@ -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<T>, (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 <DocLink id="formLibExampleValidation" section="asynchronous-dynamic-data-in-the-validator" text="asynchronous dynamic data in the validator" /> section.
|
||||
|
||||
## Options
|
||||
|
||||
### initialState
|
||||
|
||||
**Type:** `any`
|
||||
|
||||
The initial value of the BehaviorSubject.
|
||||
|
||||
```js
|
||||
const [indices$, nextIndices] = useBehaviorSubject([]);
|
||||
```
|
|
@ -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 <DocLink id="formLibExampleValidation" section="dynamic-data-inside-your-validation" text="dynamic data inside your validation" /> 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 <DocLink id="formLibExampleValidation" section="asynchronous-dynamic-data-in-the-validator" text="asynchronous dynamic data in the validator" /> section.
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 = () => {
|
|||
<>
|
||||
<Form form={form}>
|
||||
/* Pass the Observable to your field */
|
||||
<UseField path="indexName" validationData$={indices$} />
|
||||
<UseField path="indexName" validationDataProvider={indicesProvider} />
|
||||
</Form>
|
||||
|
||||
...
|
||||
|
|
|
@ -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('<UseField />', () => {
|
|||
|
||||
const TestComp = ({ validationData }: DynamicValidationDataProps) => {
|
||||
const { form } = useForm({ schema });
|
||||
const [stateValue, setStateValue] = useState('initialValue');
|
||||
const [validationData$, next] = useAsyncValidationData<string>(stateValue);
|
||||
const [validationData$, next] = useBehaviorSubject<string | undefined>(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('<UseField />', () => {
|
|||
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 (
|
||||
<Form form={form}>
|
||||
<>
|
||||
{/* Dynamic async validation data with an observable. The validation
|
||||
will complete **only after** the observable has emitted a value. */}
|
||||
<UseField<string> path="name" validationData$={validationData$}>
|
||||
<UseField<string> path="name" validationDataProvider={validationDataProvider}>
|
||||
{(field) => {
|
||||
onNameFieldHook(field);
|
||||
return (
|
||||
|
@ -479,15 +480,6 @@ describe('<UseField />', () => {
|
|||
<button data-test-subj="setInvalidValueBtn" onClick={setInvalidDynamicData}>
|
||||
Update dynamic data (invalid)
|
||||
</button>
|
||||
<button data-test-subj="setValidStateValueBtn" onClick={setStateValueWithValidValue}>
|
||||
Update state value (valid)
|
||||
</button>
|
||||
<button
|
||||
data-test-subj="setInvalidStateValueBtn"
|
||||
onClick={setStateValueWithInValidValue}
|
||||
>
|
||||
Update state value (invalid)
|
||||
</button>
|
||||
</>
|
||||
</Form>
|
||||
);
|
||||
|
@ -519,7 +511,8 @@ describe('<UseField />', () => {
|
|||
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('<UseField />', () => {
|
|||
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' });
|
||||
|
||||
|
|
|
@ -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<T, FormType = FormData, I = T> {
|
|||
/**
|
||||
* 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<T, FormType = FormData, I = T> {
|
|||
* }
|
||||
* ```
|
||||
*/
|
||||
validationData$?: Observable<unknown>;
|
||||
validationDataProvider?: () => Promise<unknown>;
|
||||
/**
|
||||
* 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<T = unknown, FormType = FormData, I = T>(props: Props<T, F
|
|||
onError,
|
||||
children,
|
||||
validationData: customValidationData,
|
||||
validationData$: customValidationData$,
|
||||
validationDataProvider: customValidationDataProvider,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
|
@ -92,8 +89,8 @@ function UseFieldComp<T = unknown, FormType = FormData, I = T>(props: Props<T, F
|
|||
}
|
||||
|
||||
const field = useField<T, FormType, I>(form, path, fieldConfig, onChange, onError, {
|
||||
customValidationData$,
|
||||
customValidationData,
|
||||
customValidationDataProvider,
|
||||
});
|
||||
|
||||
// Children prevails over anything else provided.
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 = <T = any>(state?: T) => {
|
||||
const validationData$ = useRef<Subject<T>>();
|
||||
|
||||
const getValidationData$ = useCallback(() => {
|
||||
if (validationData$.current === undefined) {
|
||||
validationData$.current = new Subject();
|
||||
}
|
||||
return validationData$.current;
|
||||
}, []);
|
||||
|
||||
const hook: [Observable<T>, (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;
|
||||
};
|
|
@ -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 = <T, FormType = FormData, I = T>(
|
|||
valueChangeListener?: (value: I) => void,
|
||||
errorChangeListener?: (errors: string[] | null) => void,
|
||||
{
|
||||
customValidationData$,
|
||||
customValidationData = null,
|
||||
}: { customValidationData$?: Observable<unknown>; customValidationData?: unknown } = {}
|
||||
customValidationDataProvider,
|
||||
}: {
|
||||
customValidationData?: unknown;
|
||||
customValidationDataProvider?: () => Promise<unknown>;
|
||||
} = {}
|
||||
) => {
|
||||
const {
|
||||
type = FIELD_TYPES.TEXT,
|
||||
|
@ -59,7 +60,7 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
__addField,
|
||||
__removeField,
|
||||
__updateFormDataAt,
|
||||
__validateFields,
|
||||
validateFields,
|
||||
__getFormData$,
|
||||
} = form;
|
||||
|
||||
|
@ -94,6 +95,14 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
errors: null,
|
||||
});
|
||||
|
||||
const hasAsyncValidation = useMemo(
|
||||
() =>
|
||||
validations === undefined
|
||||
? false
|
||||
: validations.some((validation) => validation.isAsync === true),
|
||||
[validations]
|
||||
);
|
||||
|
||||
// ----------------------------------
|
||||
// -- HELPERS
|
||||
// ----------------------------------
|
||||
|
@ -147,7 +156,7 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
__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 = <T, FormType = FormData, I = T>(
|
|||
/**
|
||||
* 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 = <T, FormType = FormData, I = T>(
|
|||
valueChangeDebounceTime,
|
||||
fieldsToValidateOnChange,
|
||||
__updateFormDataAt,
|
||||
__validateFields,
|
||||
validateFields,
|
||||
]);
|
||||
|
||||
// Cancel any inflight validation (e.g an HTTP Request)
|
||||
|
@ -238,18 +247,13 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
return false;
|
||||
};
|
||||
|
||||
let dataProvider: () => Promise<unknown> = () => Promise.resolve(null);
|
||||
|
||||
if (customValidationData$) {
|
||||
dataProvider = () => customValidationData$.pipe(first()).toPromise();
|
||||
}
|
||||
const dataProvider: () => Promise<any> =
|
||||
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 = <T, FormType = FormData, I = T>(
|
|||
|
||||
const validationResult = await inflightValidation.current;
|
||||
|
||||
inflightValidation.current = null;
|
||||
|
||||
if (!validationResult) {
|
||||
continue;
|
||||
}
|
||||
|
@ -345,17 +351,22 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
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 = <T, FormType = FormData, I = T>(
|
|||
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 = <T, FormType = FormData, I = T>(
|
|||
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);
|
||||
|
|
|
@ -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 (
|
||||
<Form form={form}>
|
||||
<UseField
|
||||
path="field1"
|
||||
config={{
|
||||
validations: [
|
||||
{
|
||||
validator: emptyField('Field1 can not be empty'),
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
||||
<UseField
|
||||
path="field2"
|
||||
data-test-subj="field2"
|
||||
config={{
|
||||
validations: [
|
||||
{
|
||||
validator: ({ value }) => {
|
||||
if (value === 'bad') {
|
||||
return {
|
||||
message: 'Field2 is invalid',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -66,6 +66,7 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
|
|||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [isSubmitting, setSubmitting] = useState(false);
|
||||
const [isValid, setIsValid] = useState<boolean | undefined>(undefined);
|
||||
const [errorMessages, setErrorMessages] = useState<{ [fieldName: string]: string }>({});
|
||||
|
||||
const fieldsRefs = useRef<FieldsMap>({});
|
||||
const fieldsRemovedRefs = useRef<FieldsMap>({});
|
||||
|
@ -73,6 +74,19 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
|
|||
const isMounted = useRef<boolean>(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<T extends FormData = FormData, I extends FormData = T>(
|
|||
[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<T extends FormData = FormData, I extends FormData = T>(
|
|||
});
|
||||
}, [fieldsToArray]);
|
||||
|
||||
const validateFields: FormHook<T, I>['__validateFields'] = useCallback(
|
||||
const validateFields: FormHook<T, I>['validateFields'] = useCallback(
|
||||
async (fieldNames, onlyBlocking = false) => {
|
||||
const fieldsToValidate = fieldNames
|
||||
.map((name) => fieldsRefs.current[name])
|
||||
|
@ -224,6 +266,7 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
|
|||
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<T extends FormData = FormData, I extends FormData = T>(
|
|||
setIsSubmitted(false);
|
||||
}
|
||||
},
|
||||
[updateFormDataAt]
|
||||
[updateFormDataAt, updateFieldErrorMessage]
|
||||
);
|
||||
|
||||
const removeField: FormHook<T, I>['__removeField'] = useCallback(
|
||||
|
@ -247,7 +290,7 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
|
|||
// 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<T extends FormData = FormData, I extends FormData = T>(
|
|||
return prev;
|
||||
});
|
||||
},
|
||||
[getFormData$, updateFormData$, fieldsToArray]
|
||||
[getFormData$, updateFormData$, fieldsToArray, updateFieldErrorMessage]
|
||||
);
|
||||
|
||||
const getFormDefaultValue: FormHook<T, I>['__getFormDefaultValue'] = useCallback(
|
||||
|
@ -306,15 +349,8 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
|
|||
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<T, I>['validate'] = useCallback(async (): Promise<boolean> => {
|
||||
// Maybe some field are being validated because of their async validation(s).
|
||||
|
@ -458,6 +494,7 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
|
|||
getFormData,
|
||||
getErrors,
|
||||
reset,
|
||||
validateFields,
|
||||
__options: formOptions,
|
||||
__getFormData$: getFormData$,
|
||||
__updateFormDataAt: updateFormDataAt,
|
||||
|
@ -467,7 +504,6 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
|
|||
__addField: addField,
|
||||
__removeField: removeField,
|
||||
__getFieldsRemoved: getFieldsRemoved,
|
||||
__validateFields: validateFields,
|
||||
};
|
||||
}, [
|
||||
isSubmitted,
|
||||
|
|
|
@ -15,7 +15,7 @@ import { useForm } from './use_form';
|
|||
import { useFormData, HookReturn } from './use_form_data';
|
||||
|
||||
interface Props<T extends object> {
|
||||
onChange(data: HookReturn<T>): void;
|
||||
onHookValueChange(data: HookReturn<T>): void;
|
||||
watch?: string | string[];
|
||||
}
|
||||
|
||||
|
@ -36,16 +36,16 @@ interface Form3 {
|
|||
}
|
||||
|
||||
describe('useFormData() hook', () => {
|
||||
const HookListenerComp = function <T extends object>({ onChange, watch }: Props<T>) {
|
||||
const HookListenerComp = function <T extends object>({ onHookValueChange, watch }: Props<T>) {
|
||||
const hookValue = useFormData<T>({ 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<Form1>) => {
|
||||
const TestComp = ({ onHookValueChange }: Props<Form1>) => {
|
||||
const { form } = useForm();
|
||||
const hookValue = useFormData<Form1>({ form });
|
||||
|
||||
useEffect(() => {
|
||||
onChange(hookValue);
|
||||
}, [hookValue, onChange]);
|
||||
onHookValueChange(hookValue);
|
||||
}, [hookValue, onHookValueChange]);
|
||||
|
||||
return (
|
||||
<Form form={form}>
|
||||
|
@ -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<Form1>({ form, onChange: onChangeSpy });
|
||||
|
||||
return (
|
||||
<Form form={form}>
|
||||
<UseField
|
||||
path="title"
|
||||
defaultValue="titleInitialValue"
|
||||
data-test-subj="titleField"
|
||||
config={{
|
||||
validations: [
|
||||
{
|
||||
validator: () => {
|
||||
// This spy should be called **after** the onChangeSpy
|
||||
validationSpy();
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<I> {
|
||||
watch?: string | string[];
|
||||
form?: FormHook<any>;
|
||||
/**
|
||||
* 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 extends object = FormData, T extends object = I> = [I, () => T, boolean];
|
||||
|
||||
export const useFormData = <I extends object = FormData, T extends object = I>(
|
||||
options: Options = {}
|
||||
options: Options<I> = {}
|
||||
): HookReturn<I, T> => {
|
||||
const { watch, form } = options;
|
||||
const { watch, form, onChange } = options;
|
||||
const ctx = useFormDataContext<T, I>();
|
||||
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 = <I extends object = FormData, T extends object = I>(
|
|||
// 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<I>(raw));
|
||||
const nextState = unflattenObject<I>(raw);
|
||||
|
||||
if (onChange) {
|
||||
onChange(nextState);
|
||||
}
|
||||
|
||||
setFormData(nextState);
|
||||
}
|
||||
} else {
|
||||
setFormData(unflattenObject<I>(raw));
|
||||
const nextState = unflattenObject<I>(raw);
|
||||
if (onChange) {
|
||||
onChange(nextState);
|
||||
}
|
||||
setFormData(nextState);
|
||||
}
|
||||
});
|
||||
|
||||
return subscription.unsubscribe;
|
||||
|
||||
// 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]);
|
||||
}, [stringifiedWatch, getFormData$, onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
|
|
|
@ -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';
|
|
@ -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 = <T = any>(initialState: T) => {
|
||||
const subjectRef = useRef<BehaviorSubject<T>>();
|
||||
|
||||
const getSubject$ = useCallback(() => {
|
||||
if (subjectRef.current === undefined) {
|
||||
subjectRef.current = new BehaviorSubject<T>(initialState);
|
||||
}
|
||||
return subjectRef.current;
|
||||
}, [initialState]);
|
||||
|
||||
const hook: [Observable<T>, (value: T) => void] = useMemo(() => {
|
||||
const subject = getSubject$();
|
||||
|
||||
const observable = subject.asObservable();
|
||||
const next = subject.next.bind(subject);
|
||||
|
||||
return [observable, next];
|
||||
}, [getSubject$]);
|
||||
|
||||
return hook;
|
||||
};
|
|
@ -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 <UseField /> 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';
|
||||
|
|
|
@ -50,15 +50,15 @@ export interface FormHook<T extends FormData = FormData, I extends FormData = T>
|
|||
* all the fields to their initial values.
|
||||
*/
|
||||
reset: (options?: { resetValues?: boolean; defaultValue?: Partial<T> }) => void;
|
||||
readonly __options: Required<FormOptions>;
|
||||
__getFormData$: () => Subject<FormData>;
|
||||
__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<FormOptions>;
|
||||
__getFormData$: () => Subject<FormData>;
|
||||
__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<I, V>
|
||||
) => ValidationError<E> | void | undefined | Promise<ValidationError<E> | void | undefined>;
|
||||
) => ValidationError<E> | void | undefined | ValidationCancelablePromise;
|
||||
|
||||
export type ValidationResponsePromise<E extends string = string> = Promise<
|
||||
ValidationError<E> | void | undefined
|
||||
>;
|
||||
|
||||
export type ValidationCancelablePromise<E extends string = string> =
|
||||
ValidationResponsePromise<E> & { 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;
|
||||
}
|
||||
|
|
|
@ -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<typeof getCommonActions> };
|
||||
|
|
|
@ -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('<FieldEditor />', () => {
|
||||
const { server, httpRequestsMockHelpers } = setupEnvironment();
|
||||
|
@ -42,18 +40,14 @@ describe('<FieldEditor />', () => {
|
|||
let promise: ReturnType<FieldEditorFormState['submit']>;
|
||||
|
||||
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('<FieldEditor />', () => {
|
|||
});
|
||||
});
|
||||
|
||||
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('<FieldEditor />', () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
onChange = jest.fn();
|
||||
setSearchResponse(mockDocuments);
|
||||
httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['mockedScriptValue'] });
|
||||
});
|
||||
|
||||
|
@ -88,7 +89,7 @@ describe('<FieldEditor />', () => {
|
|||
|
||||
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('<FieldEditor />', () => {
|
|||
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<RuntimeFieldPainlessError | null>(null);
|
||||
const clearError = useMemo(() => () => setError(null), []);
|
||||
const syntaxError = useMemo(() => ({ error, clear: clearError }), [error, clearError]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FieldEditor {...defaultProps} field={field} syntaxError={syntaxError} />
|
||||
|
||||
{/* Button to forward dummy syntax error */}
|
||||
<button onClick={() => setError(dummyError)} data-test-subj="setPainlessErrorButton">
|
||||
Set painless error
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
let testBedToCapturePainlessErrors: TestBed<string>;
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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('<FieldEditorFlyoutContent />', () => {
|
||||
const { server, httpRequestsMockHelpers } = setupEnvironment();
|
||||
|
||||
beforeAll(() => {
|
||||
httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['foo'] });
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
|
@ -24,6 +26,11 @@ describe('<FieldEditorFlyoutContent />', () => {
|
|||
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('<FieldEditorFlyoutContent />', () => {
|
|||
};
|
||||
const onSave: jest.Mock<Props['onSave']> = 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('<FieldEditorFlyoutContent />', () => {
|
|||
test('should validate the fields and prevent saving invalid form', async () => {
|
||||
const onSave: jest.Mock<Props['onSave']> = 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('<FieldEditorFlyoutContent />', () => {
|
|||
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('<FieldEditorFlyoutContent />', () => {
|
|||
|
||||
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('<FieldEditorFlyoutContent />', () => {
|
|||
});
|
||||
|
||||
// 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('<FieldEditorFlyoutContent />', () => {
|
|||
|
||||
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<Props['onSave']> = 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")' },
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
test('should display the preview panel along with the editor', async () => {
|
||||
const { exists } = testBed;
|
||||
|
||||
expect(exists('previewPanel')).toBe(false);
|
||||
|
||||
await toggleFormRow('value');
|
||||
expect(exists('previewPanel')).toBe(true);
|
||||
|
||||
await toggleFormRow('value', 'off');
|
||||
expect(exists('previewPanel')).toBe(false);
|
||||
|
||||
await toggleFormRow('format');
|
||||
expect(exists('previewPanel')).toBe(true);
|
||||
|
||||
await toggleFormRow('format', 'off');
|
||||
expect(exists('previewPanel')).toBe(false);
|
||||
});
|
||||
|
||||
test('should correctly set the title and subtitle of the panel', async () => {
|
||||
const {
|
||||
find,
|
||||
actions: { toggleFormRow, fields, waitForUpdates },
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
if (scriptError.length === 0) {
|
||||
return null;
|
||||
} else if (scriptError.length > 1) {
|
||||
return scriptError.at(0).text();
|
||||
}
|
||||
|
||||
/**
|
||||
* When often need to both wait for the documents to be fetched and
|
||||
* the preview to be fetched. We can't increase the `jest.advanceTimersByTime` time
|
||||
* as those are 2 different operations that occur in sequence.
|
||||
*/
|
||||
const waitForDocumentsAndPreviewUpdate = async () => {
|
||||
// Wait for documents to be fetched
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Wait for preview to update
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
testBed.component.update();
|
||||
return scriptError.text();
|
||||
};
|
||||
|
||||
return {
|
||||
toggleFormRow,
|
||||
waitForUpdates,
|
||||
waitForDocumentsAndPreviewUpdate,
|
||||
waitForUpdates: waitForUpdates.bind(null, testBed),
|
||||
waitForDocumentsAndPreviewUpdate: waitForDocumentsAndPreviewUpdate.bind(null, testBed),
|
||||
fields: {
|
||||
updateName,
|
||||
updateType,
|
||||
updateScript,
|
||||
updateFormat,
|
||||
getScriptError,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,7 +53,25 @@ 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);
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -60,15 +83,19 @@ jest.mock('../../../../kibana_react/public', () => {
|
|||
* with the uiSettings passed down. Let's use a simple <input /> 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 (
|
||||
<input
|
||||
|
|
|
@ -6,6 +6,18 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export interface EsDoc {
|
||||
_id: string;
|
||||
_index: string;
|
||||
_source: TestDoc;
|
||||
}
|
||||
|
||||
export interface TestDoc {
|
||||
title: string;
|
||||
subTitle: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface PreviewErrorArgs {
|
||||
reason: string;
|
||||
scriptStack?: string[];
|
||||
|
@ -23,3 +35,33 @@ export const createPreviewError = ({
|
|||
script_stack: scriptStack,
|
||||
};
|
||||
};
|
||||
|
||||
export 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',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
@ -25,17 +25,31 @@ const dataStart = dataPluginMock.createStartContract();
|
|||
const { search, fieldFormats } = dataStart;
|
||||
|
||||
export const spySearchQuery = jest.fn();
|
||||
export const spySearchQueryResponse = jest.fn();
|
||||
export const spySearchQueryResponse = jest.fn(() => 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();
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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<Field>['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<Field, FieldFormInternal>({
|
||||
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()}
|
||||
>
|
||||
<EuiFlexGroup>
|
||||
{/* Name */}
|
||||
|
@ -296,11 +280,7 @@ const FieldEditorComponent = ({ field, onChange, onFormModifiedChange, syntaxErr
|
|||
data-test-subj="valueRow"
|
||||
withDividerRule
|
||||
>
|
||||
<ScriptField
|
||||
existingConcreteFields={existingConcreteFields}
|
||||
links={links}
|
||||
syntaxError={syntaxError}
|
||||
/>
|
||||
<ScriptField existingConcreteFields={existingConcreteFields} links={links} />
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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<ReturnType<typeof setTimeout>>();
|
||||
const ScriptFieldComponent = ({ existingConcreteFields, links }: Props) => {
|
||||
const monacoEditor = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
const editorValidationSubscription = useRef<Subscription>();
|
||||
const fieldCurrentValue = useRef<string>('');
|
||||
|
||||
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<PainlessContext>(
|
||||
mapReturnTypeToPainlessContext(schema.type.defaultValue[0].value!)
|
||||
mapReturnTypeToPainlessContext(schema.type.defaultValue![0].value!)
|
||||
);
|
||||
|
||||
const [editorId, setEditorId] = useState<string | undefined>();
|
||||
const currentDocId = currentDocument?._id;
|
||||
|
||||
const suggestionProvider = PainlessLang.getSuggestionProvider(
|
||||
painlessContext,
|
||||
existingConcreteFields
|
||||
const suggestionProvider = useMemo(
|
||||
() => PainlessLang.getSuggestionProvider(painlessContext, existingConcreteFields),
|
||||
[painlessContext, existingConcreteFields]
|
||||
);
|
||||
|
||||
const [{ type, script: { source } = { source: '' } }] = useFormData<FieldFormInternal>({
|
||||
const { validateFields } = useFormContext();
|
||||
|
||||
// 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]
|
||||
);
|
||||
|
||||
useFormData<FieldFormInternal>({
|
||||
watch: ['type', 'script.source'],
|
||||
onChange: onFormDataChange,
|
||||
});
|
||||
|
||||
const { clear: clearSyntaxError } = syntaxError;
|
||||
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;
|
||||
}
|
||||
|
||||
const sourceFieldConfig: FieldConfig<string> = useMemo(() => {
|
||||
return {
|
||||
...schema.script.source,
|
||||
validations: [
|
||||
...schema.script.source.validations,
|
||||
{
|
||||
validator: () => {
|
||||
if (editorValidationTimeout.current) {
|
||||
clearTimeout(editorValidationTimeout.current);
|
||||
}
|
||||
// We are not interested in preview data meanwhile it
|
||||
// is still making HTTP request
|
||||
if (data.isFetchingDoc || data.isLoadingPreview) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
return true;
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
if (editorHasSyntaxErrors) {
|
||||
return resolve({
|
||||
message: i18n.translate(
|
||||
'indexPatternFieldEditor.editor.form.scriptEditorValidationMessage',
|
||||
{
|
||||
defaultMessage: 'Invalid Painless syntax.',
|
||||
}
|
||||
),
|
||||
});
|
||||
}
|
||||
return validationData!.error;
|
||||
}, [validationData$]);
|
||||
|
||||
resolve(undefined);
|
||||
}, 600);
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
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(() => {
|
||||
nextValidationData$({ isFetchingDoc, isLoadingPreview, error });
|
||||
}, [nextValidationData$, isFetchingDoc, isLoadingPreview, error]);
|
||||
|
||||
useEffect(() => {
|
||||
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();
|
||||
}
|
||||
};
|
||||
}, [editorId]);
|
||||
|
||||
useEffect(() => {
|
||||
setPainlessContext(mapReturnTypeToPainlessContext(type[0]!.value!));
|
||||
}, [type]);
|
||||
|
||||
useEffect(() => {
|
||||
// Whenever the source changes we clear potential syntax errors
|
||||
clearSyntaxError();
|
||||
}, [source, clearSyntaxError]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UseField<string> path="script.source" config={sourceFieldConfig}>
|
||||
<UseField<string> 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={
|
||||
<FormattedMessage
|
||||
id="indexPatternFieldEditor.editor.form.source.scriptFieldHelpText"
|
||||
|
@ -173,10 +252,10 @@ export const ScriptField = React.memo(({ existingConcreteFields, links, syntaxEr
|
|||
suggestionProvider={suggestionProvider}
|
||||
// 99% width allows the editor to resize horizontally. 100% prevents it from resizing.
|
||||
width="99%"
|
||||
height="300px"
|
||||
height="210px"
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
editorDidMount={(editor) => setEditorId(editor.getModel()?.id)}
|
||||
editorDidMount={onEditorDidMount}
|
||||
options={{
|
||||
fontSize: 12,
|
||||
minimap: {
|
||||
|
@ -199,33 +278,11 @@ export const ScriptField = React.memo(({ existingConcreteFields, links, syntaxEr
|
|||
)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
||||
{/* Help the user debug the error by showing where it failed in the script */}
|
||||
{syntaxError.error !== null && (
|
||||
<>
|
||||
<EuiSpacer />
|
||||
<EuiTitle size="xs">
|
||||
<h3>
|
||||
{i18n.translate(
|
||||
'indexPatternFieldEditor.editor.form.scriptEditor.debugErrorMessage',
|
||||
{
|
||||
defaultMessage: 'Syntax error detail',
|
||||
}
|
||||
)}
|
||||
</h3>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="xs" />
|
||||
<EuiCodeBlock
|
||||
// @ts-ignore
|
||||
whiteSpace="pre"
|
||||
>
|
||||
{syntaxError.error.scriptStack.join('\n')}
|
||||
</EuiCodeBlock>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</UseField>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const ScriptField = React.memo(ScriptFieldComponent);
|
||||
|
|
|
@ -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<Array<EuiComboBoxOptionOption<RuntimeType>>>,
|
||||
script: {
|
||||
source: {
|
||||
label: i18n.translate('indexPatternFieldEditor.editor.form.defineFieldLabel', {
|
||||
|
@ -64,6 +131,14 @@ export const schema = {
|
|||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
validator: painlessSyntaxValidator(),
|
||||
isAsync: true,
|
||||
},
|
||||
{
|
||||
validator: painlessScriptValidator,
|
||||
isAsync: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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<RuntimeFieldPainlessError | null>;
|
||||
/** 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<FieldEditorFormState>({
|
||||
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<RuntimeFieldPainlessError | null>(
|
||||
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 (!isMounted.current) {
|
||||
// User has closed the flyout meanwhile submitting the form
|
||||
return;
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
if (data.script) {
|
||||
setIsValidating(true);
|
||||
|
||||
const error = await runtimeFieldValidator({
|
||||
type: data.type,
|
||||
script: data.script,
|
||||
});
|
||||
|
||||
setIsValidating(false);
|
||||
setPainlessSyntaxError(error);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
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 (
|
||||
<>
|
||||
<FlyoutPanels.Group
|
||||
|
@ -253,23 +229,11 @@ const FieldEditorFlyoutContentComponent = ({
|
|||
field={field}
|
||||
onChange={setFormState}
|
||||
onFormModifiedChange={setIsFormModified}
|
||||
syntaxError={syntaxError}
|
||||
/>
|
||||
</FlyoutPanels.Content>
|
||||
|
||||
<FlyoutPanels.Footer>
|
||||
<>
|
||||
{isSubmitted && hasErrors && (
|
||||
<>
|
||||
<EuiCallOut
|
||||
title={i18nTexts.formErrorsCalloutTitle}
|
||||
color="danger"
|
||||
iconType="cross"
|
||||
data-test-subj="formError"
|
||||
/>
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
)}
|
||||
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
|
@ -289,7 +253,7 @@ const FieldEditorFlyoutContentComponent = ({
|
|||
data-test-subj="fieldSaveButton"
|
||||
fill
|
||||
disabled={hasErrors}
|
||||
isLoading={isSavingField || isValidating}
|
||||
isLoading={isSavingField || isSubmitting}
|
||||
>
|
||||
{i18nTexts.saveButtonLabel}
|
||||
</EuiButton>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</FieldPreviewProvider>
|
||||
|
|
|
@ -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
|
||||
>
|
||||
<EuiFieldText
|
||||
isInvalid={isInvalid}
|
||||
value={documentId}
|
||||
value={documentId ?? ''}
|
||||
onChange={onDocumentIdChange}
|
||||
fullWidth
|
||||
data-test-subj="documentIdField"
|
||||
|
|
|
@ -10,6 +10,10 @@ $previewShowMoreHeight: 40px; /* [2] */
|
|||
.indexPatternFieldEditor__previewFieldList {
|
||||
position: relative;
|
||||
|
||||
&--ligthWeight {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&__item {
|
||||
border-bottom: $euiBorderThin;
|
||||
height: $previewFieldItemHeight;
|
||||
|
@ -17,7 +21,7 @@ $previewShowMoreHeight: 40px; /* [2] */
|
|||
overflow: hidden;
|
||||
|
||||
&--highlighted {
|
||||
$backgroundColor: tintOrShade($euiColorWarning, 90%, 70%);
|
||||
$backgroundColor: tintOrShade($euiColorPrimary, 90%, 70%);
|
||||
background: $backgroundColor;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
|
@ -12,11 +12,8 @@ import { get } from 'lodash';
|
|||
import { EuiButtonEmpty, EuiButton, EuiSpacer, EuiEmptyPrompt, EuiTextColor } from '@elastic/eui';
|
||||
|
||||
import { useFieldEditorContext } from '../../field_editor_context';
|
||||
import {
|
||||
useFieldPreviewContext,
|
||||
defaultValueFormatter,
|
||||
FieldPreview,
|
||||
} from '../field_preview_context';
|
||||
import { useFieldPreviewContext, defaultValueFormatter } from '../field_preview_context';
|
||||
import type { FieldPreview } from '../types';
|
||||
import { PreviewListItem } from './field_list_item';
|
||||
|
||||
import './field_list.scss';
|
||||
|
|
|
@ -9,34 +9,103 @@
|
|||
import React, { useState } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiToolTip, EuiButtonIcon, EuiButtonEmpty } from '@elastic/eui';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiToolTip,
|
||||
EuiButtonIcon,
|
||||
EuiButtonEmpty,
|
||||
EuiBadge,
|
||||
EuiTextColor,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { useFieldPreviewContext } from '../field_preview_context';
|
||||
import { IsUpdatingIndicator } from '../is_updating_indicator';
|
||||
import { ImagePreviewModal } from '../image_preview_modal';
|
||||
import type { DocumentField } from './field_list';
|
||||
|
||||
interface Props {
|
||||
field: DocumentField;
|
||||
toggleIsPinned?: (name: string) => void;
|
||||
highlighted?: boolean;
|
||||
hasScriptError?: boolean;
|
||||
/** Indicates whether the field list item comes from the Painless script */
|
||||
isFromScript?: boolean;
|
||||
}
|
||||
|
||||
export const PreviewListItem: React.FC<Props> = ({
|
||||
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('<img');
|
||||
|
||||
const renderName = () => {
|
||||
if (isFromScript && !Boolean(key)) {
|
||||
return (
|
||||
<span className="indexPatternFieldEditor__previewFieldList--ligthWeight">
|
||||
<EuiTextColor color="subdued">
|
||||
{i18n.translate('indexPatternFieldEditor.fieldPreview.fieldNameNotSetLabel', {
|
||||
defaultMessage: 'Field name not set',
|
||||
})}
|
||||
</EuiTextColor>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return key;
|
||||
};
|
||||
|
||||
const withTooltip = (content: JSX.Element) => (
|
||||
<EuiToolTip position="top" content={typeof value !== 'string' ? JSON.stringify(value) : value}>
|
||||
{content}
|
||||
</EuiToolTip>
|
||||
);
|
||||
|
||||
const renderValue = () => {
|
||||
if (isFromScript && isLoadingPreview) {
|
||||
return (
|
||||
<span className="indexPatternFieldEditor__previewFieldList--ligthWeight">
|
||||
<IsUpdatingIndicator />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasScriptError) {
|
||||
return (
|
||||
<div>
|
||||
<EuiBadge iconType="alert" color="danger" data-test-subj="scriptErrorBadge">
|
||||
{i18n.translate('indexPatternFieldEditor.fieldPreview.scriptErrorBadgeLabel', {
|
||||
defaultMessage: 'Script error',
|
||||
})}
|
||||
</EuiBadge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isFromScript && value === undefined) {
|
||||
return (
|
||||
<span className="indexPatternFieldEditor__previewFieldList--ligthWeight">
|
||||
<EuiTextColor color="subdued">
|
||||
{i18n.translate('indexPatternFieldEditor.fieldPreview.valueNotSetLabel', {
|
||||
defaultMessage: 'Value not set',
|
||||
})}
|
||||
</EuiTextColor>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (doesContainImage) {
|
||||
return (
|
||||
<EuiButtonEmpty
|
||||
|
@ -52,7 +121,7 @@ export const PreviewListItem: React.FC<Props> = ({
|
|||
}
|
||||
|
||||
if (formattedValue !== undefined) {
|
||||
return (
|
||||
return withTooltip(
|
||||
<span
|
||||
className="indexPatternFieldEditor__previewFieldList__item__value__wrapper"
|
||||
// We can dangerously set HTML here because this content is guaranteed to have been run through a valid field formatter first.
|
||||
|
@ -61,7 +130,7 @@ export const PreviewListItem: React.FC<Props> = ({
|
|||
);
|
||||
}
|
||||
|
||||
return (
|
||||
return withTooltip(
|
||||
<span className="indexPatternFieldEditor__previewFieldList__item__value__wrapper">
|
||||
{JSON.stringify(value)}
|
||||
</span>
|
||||
|
@ -76,19 +145,14 @@ export const PreviewListItem: React.FC<Props> = ({
|
|||
className="indexPatternFieldEditor__previewFieldList__item__key__wrapper"
|
||||
data-test-subj="key"
|
||||
>
|
||||
{key}
|
||||
{renderName()}
|
||||
</div>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
className="indexPatternFieldEditor__previewFieldList__item__value"
|
||||
data-test-subj="value"
|
||||
>
|
||||
<EuiToolTip
|
||||
position="top"
|
||||
content={typeof value !== 'string' ? JSON.stringify(value) : value}
|
||||
>
|
||||
{renderValue()}
|
||||
</EuiToolTip>
|
||||
{renderValue()}
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiSpacer, EuiResizeObserver, EuiFieldSearch } from '@elastic/eui';
|
||||
import { EuiSpacer, EuiResizeObserver, EuiFieldSearch, EuiCallOut } from '@elastic/eui';
|
||||
|
||||
import { useFieldPreviewContext } from './field_preview_context';
|
||||
import { FieldPreviewHeader } from './field_preview_header';
|
||||
|
@ -29,7 +29,9 @@ export const FieldPreview = () => {
|
|||
},
|
||||
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 (
|
||||
<ul>
|
||||
<li data-test-subj="fieldPreviewItem">
|
||||
<PreviewListItem field={field} highlighted />
|
||||
<PreviewListItem field={field} isFromScript hasScriptError={Boolean(error)} />
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
|
@ -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 (
|
||||
<div
|
||||
className="indexPatternFieldEditor__previewPannel"
|
||||
|
@ -86,45 +88,76 @@ export const FieldPreview = () => {
|
|||
<FieldPreviewHeader />
|
||||
<EuiSpacer />
|
||||
|
||||
<DocumentsNavPreview />
|
||||
<EuiSpacer size="s" />
|
||||
|
||||
<EuiFieldSearch
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
placeholder={i18n.translate(
|
||||
'indexPatternFieldEditor.fieldPreview.filterFieldsPlaceholder',
|
||||
{
|
||||
defaultMessage: 'Filter fields',
|
||||
}
|
||||
)}
|
||||
fullWidth
|
||||
data-test-subj="filterFieldsInput"
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
|
||||
<FieldPreviewError />
|
||||
<EuiSpacer size="s" />
|
||||
|
||||
{doShowFieldList && (
|
||||
<>
|
||||
{/* The current field(s) the user is creating */}
|
||||
{renderFieldsToPreview()}
|
||||
|
||||
{/* List of other fields in the document */}
|
||||
<EuiResizeObserver onResize={onFieldListResize}>
|
||||
{(resizeRef) => (
|
||||
<div ref={resizeRef} style={{ flex: 1 }}>
|
||||
<PreviewFieldList
|
||||
height={fieldListHeight}
|
||||
clearSearch={() => setSearchValue('')}
|
||||
searchValue={searchValue}
|
||||
// We add a key to force rerender the virtual list whenever the window height changes
|
||||
key={fieldListHeight}
|
||||
/>
|
||||
</div>
|
||||
{showWarningPreviewNotAvailable ? (
|
||||
<EuiCallOut
|
||||
title={i18n.translate(
|
||||
'indexPatternFieldEditor.fieldPreview.notAvailableWarningCallout.title',
|
||||
{
|
||||
defaultMessage: 'Preview not available',
|
||||
}
|
||||
)}
|
||||
color="warning"
|
||||
iconType="alert"
|
||||
role="alert"
|
||||
data-test-subj="previewNotAvailableCallout"
|
||||
>
|
||||
<p>
|
||||
{i18n.translate(
|
||||
'indexPatternFieldEditor.fieldPreview.notAvailableWarningCallout.description',
|
||||
{
|
||||
defaultMessage:
|
||||
'Runtime field preview is disabled because no documents could be fetched from the cluster.',
|
||||
}
|
||||
)}
|
||||
</EuiResizeObserver>
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
) : (
|
||||
<>
|
||||
<DocumentsNavPreview />
|
||||
<EuiSpacer size="s" />
|
||||
|
||||
{doRenderListOfFields && (
|
||||
<>
|
||||
<EuiFieldSearch
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
placeholder={i18n.translate(
|
||||
'indexPatternFieldEditor.fieldPreview.filterFieldsPlaceholder',
|
||||
{
|
||||
defaultMessage: 'Filter fields',
|
||||
}
|
||||
)}
|
||||
fullWidth
|
||||
data-test-subj="filterFieldsInput"
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<FieldPreviewError />
|
||||
<EuiSpacer size="s" />
|
||||
|
||||
{doRenderListOfFields && (
|
||||
<>
|
||||
{/* The current field(s) the user is creating */}
|
||||
{renderFieldsToPreview()}
|
||||
|
||||
{/* List of other fields in the document */}
|
||||
<EuiResizeObserver onResize={onFieldListResize}>
|
||||
{(resizeRef) => (
|
||||
<div ref={resizeRef} style={{ flex: 1 }}>
|
||||
<PreviewFieldList
|
||||
height={fieldListHeight}
|
||||
clearSearch={() => setSearchValue('')}
|
||||
searchValue={searchValue}
|
||||
// We add a key to force rerender the virtual list whenever the window height changes
|
||||
key={fieldListHeight}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</EuiResizeObserver>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -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<string, any>;
|
||||
}
|
||||
|
||||
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<RuntimeField>['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<Params>) => void;
|
||||
};
|
||||
isLoadingPreview: boolean;
|
||||
currentDocument: {
|
||||
value?: EsDocument;
|
||||
id: string;
|
||||
isLoading: boolean;
|
||||
isCustomId: boolean;
|
||||
};
|
||||
documents: {
|
||||
loadSingle: (id: string) => void;
|
||||
loadFromCluster: () => Promise<void>;
|
||||
};
|
||||
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<React.SetStateAction<{ [key: string]: boolean }>>;
|
||||
};
|
||||
}
|
||||
import type {
|
||||
PainlessExecuteContext,
|
||||
Context,
|
||||
Params,
|
||||
ClusterData,
|
||||
From,
|
||||
EsDocument,
|
||||
ScriptErrorCodes,
|
||||
FetchDocError,
|
||||
} from './types';
|
||||
|
||||
const fieldPreviewContext = createContext<Context | undefined>(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<FetchDocError | null>(null);
|
||||
/** The parameters required for the Painless _execute API */
|
||||
const [params, setParams] = useState<Params>(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<From>('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;
|
||||
}
|
||||
return true;
|
||||
}, [currentDocIndex, script?.source, type]);
|
||||
|
||||
const allParamsDefined = (['type', 'script', 'index', 'document'] as Array<keyof Params>).every(
|
||||
(key) => Boolean(params[key])
|
||||
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 setPreviewError = useCallback((error: Context['error']) => {
|
||||
setPreviewResponse((prev) => ({
|
||||
...prev,
|
||||
error,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const hasSomeParamsChanged =
|
||||
lastExecutePainlessRequestParams.type !== type ||
|
||||
lastExecutePainlessRequestParams.script !== script?.source ||
|
||||
lastExecutePainlessRequestParams.documentId !== currentDocId;
|
||||
|
||||
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 (!needToUpdatePreview) {
|
||||
if (scriptEditorValidation.isValidating) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 (response.data) {
|
||||
const { values, error } = response.data;
|
||||
|
||||
if (error) {
|
||||
const fallBackError = {
|
||||
message: i18n.translate('indexPatternFieldEditor.fieldPreview.defaultErrorTitle', {
|
||||
defaultMessage: 'Unable to run the provided script',
|
||||
}),
|
||||
};
|
||||
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: [],
|
||||
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,
|
||||
});
|
||||
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) {
|
||||
|
|
|
@ -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 (
|
||||
<EuiCallOut
|
||||
title={i18n.translate('indexPatternFieldEditor.fieldPreview.errorCallout.title', {
|
||||
defaultMessage: 'Preview error',
|
||||
defaultMessage: 'Error fetching document',
|
||||
})}
|
||||
color="danger"
|
||||
iconType="cross"
|
||||
iconType="alert"
|
||||
role="alert"
|
||||
data-test-subj="previewError"
|
||||
data-test-subj="fetchDocError"
|
||||
>
|
||||
{error.code === 'PAINLESS_SCRIPT_ERROR' ? (
|
||||
<p data-test-subj="reason">{error.error.reason}</p>
|
||||
) : (
|
||||
<p data-test-subj="title">{error.error.message}</p>
|
||||
)}
|
||||
<p data-test-subj="title">{fetchDocError.error.message ?? fetchDocError.error.reason}</p>
|
||||
</EuiCallOut>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 (
|
||||
<div>
|
||||
<EuiFlexGroup alignItems="center">
|
||||
|
@ -50,15 +38,9 @@ export const FieldPreviewHeader = () => {
|
|||
<h2 data-test-subj="title">{i18nTexts.title}</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
|
||||
{isUpdating && (
|
||||
<EuiFlexItem data-test-subj="isUpdatingIndicator">
|
||||
<EuiFlexGroup gutterSize="xs">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiLoadingSpinner size="m" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>{i18nTexts.updatingLabel}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
{isFetchingDocument && (
|
||||
<EuiFlexItem data-test-subj="isFetchingDocumentIndicator">
|
||||
<IsUpdatingIndicator />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
|
|
|
@ -9,3 +9,5 @@
|
|||
export { useFieldPreviewContext, FieldPreviewProvider } from './field_preview_context';
|
||||
|
||||
export { FieldPreview } from './field_preview';
|
||||
|
||||
export type { PainlessExecuteContext, FieldPreviewResponse, Context } from './types';
|
||||
|
|
|
@ -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 (
|
||||
<div data-test-subj="isUpdatingIndicator">
|
||||
<EuiFlexGroup gutterSize="xs">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiLoadingSpinner size="m" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
{i18n.translate('indexPatternFieldEditor.fieldPreview.updatingPreviewLabel', {
|
||||
defaultMessage: 'Updating...',
|
||||
})}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -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<RuntimeField>['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<Params>) => void;
|
||||
};
|
||||
isPreviewAvailable: boolean;
|
||||
isLoadingPreview: boolean;
|
||||
currentDocument: {
|
||||
value?: EsDocument;
|
||||
id?: string;
|
||||
isLoading: boolean;
|
||||
isCustomId: boolean;
|
||||
};
|
||||
documents: {
|
||||
loadSingle: (id: string) => void;
|
||||
loadFromCluster: () => Promise<void>;
|
||||
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<React.SetStateAction<{ [key: string]: boolean }>>;
|
||||
};
|
||||
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;
|
||||
}
|
|
@ -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<string, any>;
|
||||
documentId: string;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<string, any>;
|
||||
|
||||
/**
|
||||
* We are only interested in "script_exception" error type
|
||||
*/
|
||||
const getScriptExceptionErrorOnShard = (error: Error): Error | null => {
|
||||
if (error.type === 'script_exception') {
|
||||
return error;
|
||||
export const getErrorCodeFromErrorReason = (reason: string = ''): PainlessErrorCode => {
|
||||
if (reason.startsWith('Cannot cast from')) {
|
||||
return 'CAST_ERROR';
|
||||
}
|
||||
|
||||
if (!error.caused_by) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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<string, any>;
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -628,13 +628,13 @@ export const ECSMappingEditorForm = forwardRef<ECSMappingEditorFormRef, ECSMappi
|
|||
}),
|
||||
});
|
||||
|
||||
const { submit, reset, validate, __validateFields } = form;
|
||||
const { submit, reset, validate, validateFields } = form;
|
||||
|
||||
const [formData] = useFormData({ form });
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
validate();
|
||||
__validateFields(['result.value']);
|
||||
validateFields(['result.value']);
|
||||
const { data, isValid } = await submit();
|
||||
|
||||
if (isValid) {
|
||||
|
@ -652,7 +652,7 @@ export const ECSMappingEditorForm = forwardRef<ECSMappingEditorFormRef, ECSMappi
|
|||
}
|
||||
reset();
|
||||
}
|
||||
}, [validate, __validateFields, submit, onAdd, onChange, reset]);
|
||||
}, [validate, validateFields, submit, onAdd, onChange, reset]);
|
||||
|
||||
const handleDeleteClick = useCallback(() => {
|
||||
if (defaultValue?.key && onDelete) {
|
||||
|
@ -701,7 +701,7 @@ export const ECSMappingEditorForm = forwardRef<ECSMappingEditorFormRef, ECSMappi
|
|||
return { data: {}, isValid: true };
|
||||
}
|
||||
|
||||
__validateFields(['result.value']);
|
||||
validateFields(['result.value']);
|
||||
const isValid = await validate();
|
||||
|
||||
return {
|
||||
|
@ -716,7 +716,7 @@ export const ECSMappingEditorForm = forwardRef<ECSMappingEditorFormRef, ECSMappi
|
|||
};
|
||||
},
|
||||
}),
|
||||
[__validateFields, editForm, formData, validate]
|
||||
[validateFields, editForm, formData, validate]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -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": "次のドキュメント",
|
||||
|
|
|
@ -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": "下一个文档",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue