Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Runtime field editor] Error handling #109233

Merged
merged 33 commits into from
Nov 9, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
01f73e3
[kbn-monaco] Expose Observable to notify validation changes
sebelga Oct 21, 2021
d0b48bf
[Form lib] Allow Promise provider instead of Observable for dynamic data
sebelga Oct 21, 2021
68b77fe
[Form lib] New `isAsync` option for validations.
sebelga Oct 21, 2021
272a78d
[Form lib] Add onChange handler to useFormData()
sebelga Oct 21, 2021
36bbad8
[Form lib] Fix stale state when getting form error messages
sebelga Oct 21, 2021
b22f509
Update <ScriptField /> to handle painless syntax and script validation
sebelga Oct 21, 2021
41f087e
Always Preview panel when opening the field editor
sebelga Oct 21, 2021
ea59de4
Display Painless error in Flyout footer
sebelga Oct 21, 2021
dbcf58f
Update Preview panel to handle error, loading and empty states
sebelga Oct 21, 2021
d0f1217
Update component integration tests
sebelga Oct 21, 2021
ead22f8
Fix functional and api integration tests
sebelga Oct 21, 2021
7f8734b
[osquery] Refactor to use updated form lib interface
sebelga Oct 21, 2021
ac4301b
Update i18n translations
sebelga Oct 21, 2021
4e4b3e1
Add comment for the use of the _search API instead of _execute
sebelga Oct 21, 2021
f2c4534
Merge branch 'master' into runtime-field-editor/error-handling-2
kibanamachine Oct 22, 2021
2221f76
Merge branch 'master' into runtime-field-editor/error-handling-2
kibanamachine Oct 24, 2021
19b7a97
Address CR changes
sebelga Oct 28, 2021
51d40fe
Use "firstValueFrom" instead of the deprecated "toPromise()"
sebelga Nov 2, 2021
dcbe6d6
[kbn-monaco] Avoid creating new observables when reading the validation$
sebelga Nov 2, 2021
d25bf92
Improve TS types and replace "any" with "unknown"
sebelga Nov 2, 2021
3edf2b3
Improve code readability for script validationDataProvider
sebelga Nov 2, 2021
4a259bc
Merge branch 'runtime-field-editor/error-handling-2' of github.com:se…
sebelga Nov 2, 2021
78c81d5
Merge remote-tracking branch 'upstream/main' into runtime-field-edito…
sebelga Nov 2, 2021
ff53785
Address UX review changes
sebelga Nov 2, 2021
95ba567
Merge remote-tracking branch 'upstream/main' into runtime-field-edito…
sebelga Nov 2, 2021
fba5e85
Merge branch 'main' into runtime-field-editor/error-handling-2
kibanamachine Nov 3, 2021
f2aeb4b
Fix i18n
sebelga Nov 3, 2021
82d2ddc
Merge branch 'runtime-field-editor/error-handling-2' of github.com:se…
sebelga Nov 3, 2021
93bc4bd
Fix jest tests
sebelga Nov 8, 2021
000e9e9
Merge remote-tracking branch 'upstream/main' into runtime-field-edito…
sebelga Nov 8, 2021
8e4c661
Fix lint issue
sebelga Nov 8, 2021
7cd865a
Merge branch 'main' into runtime-field-editor/error-handling-2
kibanamachine Nov 8, 2021
7b0843f
Merge remote-tracking branch 'upstream/main' into runtime-field-edito…
sebelga Nov 9, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/kbn-monaco/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down
68 changes: 68 additions & 0 deletions packages/kbn-monaco/__jest__/jest.mocks.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 19 additions & 0 deletions packages/kbn-monaco/__jest__/types.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 147 additions & 0 deletions packages/kbn-monaco/src/painless/diagnostics_adapter.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
53 changes: 45 additions & 8 deletions packages/kbn-monaco/src/painless/diagnostics_adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 => {
Expand All @@ -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 }) => {
Expand All @@ -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 = {
Expand All @@ -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() {
Expand Down
3 changes: 2 additions & 1 deletion packages/kbn-monaco/src/painless/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -17,6 +17,7 @@ export const PainlessLang: CompleteLangModuleType = {
lexerRules,
languageConfiguration,
getSyntaxErrors,
validation$,
};

export * from './types';
Loading