[Runtime fields] Editor phase 1 (#81472)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: spalger <spalger@users.noreply.github.com>
This commit is contained in:
Sébastien Loix 2020-11-18 09:10:00 +01:00 committed by GitHub
parent 982639fc2a
commit e3c2dccf00
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 1320 additions and 47 deletions

View file

@ -479,6 +479,10 @@ Elastic.
|Welcome to the Kibana rollup plugin! This plugin provides Kibana support for Elasticsearch's rollup feature. Please refer to the Elasticsearch documentation to understand rollup indices and how to create rollup jobs.
|{kib-repo}blob/{branch}/x-pack/plugins/runtime_fields/README.md[runtimeFields]
|Welcome to the home of the runtime field editor and everything related to runtime fields!
|{kib-repo}blob/{branch}/x-pack/plugins/saved_objects_tagging/README.md[savedObjectsTagging]
|Add tagging capability to saved objects

View file

@ -102,4 +102,5 @@ pageLoadAssetSize:
visualizations: 295025
visualize: 57431
watcher: 43598
runtimeFields: 41752
stackAlerts: 29684

View file

@ -31,7 +31,7 @@ export interface Props<T, FormType = FormData, I = T> {
componentProps?: Record<string, any>;
readDefaultValueOnForm?: boolean;
onChange?: (value: I) => void;
children?: (field: FieldHook<T, I>) => JSX.Element;
children?: (field: FieldHook<T, I>) => JSX.Element | null;
[key: string]: any;
}

View file

@ -38,10 +38,11 @@
"xpack.maps": ["plugins/maps"],
"xpack.ml": ["plugins/ml"],
"xpack.monitoring": ["plugins/monitoring"],
"xpack.remoteClusters": "plugins/remote_clusters",
"xpack.painlessLab": "plugins/painless_lab",
"xpack.remoteClusters": "plugins/remote_clusters",
"xpack.reporting": ["plugins/reporting"],
"xpack.rollupJobs": ["plugins/rollup"],
"xpack.runtimeFields": "plugins/runtime_fields",
"xpack.searchProfiler": "plugins/searchprofiler",
"xpack.security": "plugins/security",
"xpack.server": "legacy/server",

View file

@ -18,6 +18,7 @@
"configPath": ["xpack", "index_management"],
"requiredBundles": [
"kibanaReact",
"esUiShared"
"esUiShared",
"runtimeFields"
]
}

View file

@ -6,6 +6,7 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { PainlessLang } from '@kbn/monaco';
import { EuiFormRow, EuiDescribedFormGroup } from '@elastic/eui';
import { CodeEditor, UseField } from '../../../shared_imports';
@ -18,7 +19,7 @@ interface Props {
export const PainlessScriptParameter = ({ stack }: Props) => {
return (
<UseField path="script.source" config={getFieldConfig('script')}>
<UseField<string> path="script.source" config={getFieldConfig('script')}>
{(scriptField) => {
const error = scriptField.getErrorsMessages();
const isInvalid = error ? Boolean(error.length) : false;
@ -26,11 +27,10 @@ export const PainlessScriptParameter = ({ stack }: Props) => {
const field = (
<EuiFormRow label={scriptField.label} error={error} isInvalid={isInvalid} fullWidth>
<CodeEditor
languageId="painless"
// 99% width allows the editor to resize horizontally. 100% prevents it from resizing.
width="99%"
languageId={PainlessLang.ID}
width="100%"
height="400px"
value={scriptField.value as string}
value={scriptField.value}
onChange={scriptField.setValue}
options={{
fontSize: 12,

View file

@ -14,10 +14,10 @@ import {
EuiSpacer,
} from '@elastic/eui';
import { UseField } from '../../../shared_imports';
import { UseField, RUNTIME_FIELD_OPTIONS } from '../../../shared_imports';
import { DataType } from '../../../types';
import { getFieldConfig } from '../../../lib';
import { RUNTIME_FIELD_OPTIONS, TYPE_DEFINITION } from '../../../constants';
import { TYPE_DEFINITION } from '../../../constants';
import { EditFieldFormRow, FieldDescriptionSection } from '../fields/edit_field';
interface Props {
@ -26,7 +26,10 @@ interface Props {
export const RuntimeTypeParameter = ({ stack }: Props) => {
return (
<UseField path="runtime_type" config={getFieldConfig('runtime_type')}>
<UseField<EuiComboBoxOptionOption[]>
path="runtime_type"
config={getFieldConfig('runtime_type')}
>
{(runtimeTypeField) => {
const { label, value, setValue } = runtimeTypeField;
const typeDefinition =
@ -44,8 +47,14 @@ export const RuntimeTypeParameter = ({ stack }: Props) => {
)}
singleSelection={{ asPlainText: true }}
options={RUNTIME_FIELD_OPTIONS}
selectedOptions={value as EuiComboBoxOptionOption[]}
onChange={setValue}
selectedOptions={value}
onChange={(newValue) => {
if (newValue.length === 0) {
// Don't allow clearing the type. One must always be selected
return;
}
setValue(newValue);
}}
isClearable={false}
fullWidth
/>

View file

@ -28,35 +28,6 @@ export const FIELD_TYPES_OPTIONS = Object.entries(MAIN_DATA_TYPE_DEFINITION).map
})
) as ComboBoxOption[];
export const RUNTIME_FIELD_OPTIONS = [
{
label: 'Keyword',
value: 'keyword',
},
{
label: 'Long',
value: 'long',
},
{
label: 'Double',
value: 'double',
},
{
label: 'Date',
value: 'date',
},
{
label: 'IP',
value: 'ip',
},
{
label: 'Boolean',
value: 'boolean',
},
] as ComboBoxOption[];
export const RUNTIME_FIELD_TYPES = ['keyword', 'long', 'double', 'date', 'ip', 'boolean'] as const;
interface SuperSelectOptionConfig {
inputDisplay: string;
dropdownDisplay: JSX.Element;

View file

@ -16,11 +16,12 @@ import {
ValidationFuncArg,
fieldFormatters,
FieldConfig,
RUNTIME_FIELD_OPTIONS,
RuntimeType,
} from '../shared_imports';
import {
AliasOption,
DataType,
RuntimeType,
ComboBoxOption,
ParameterName,
ParameterDefinition,
@ -28,7 +29,6 @@ import {
import { documentationService } from '../../../services/documentation';
import { INDEX_DEFAULT } from './default_values';
import { TYPE_DEFINITION } from './data_types_definition';
import { RUNTIME_FIELD_OPTIONS } from './field_options';
const { toInt } = fieldFormatters;
const { emptyField, containsCharsField, numberGreaterThanField, isJsonField } = fieldValidators;

View file

@ -53,3 +53,5 @@ export {
} from '../../../../../../../src/plugins/es_ui_shared/public';
export { CodeEditor } from '../../../../../../../src/plugins/kibana_react/public';
export { RUNTIME_FIELD_OPTIONS, RuntimeType } from '../../../../../runtime_fields/public';

View file

@ -8,7 +8,7 @@ import { ReactNode } from 'react';
import { GenericObject } from './mappings_editor';
import { FieldConfig } from '../shared_imports';
import { PARAMETERS_DEFINITION, RUNTIME_FIELD_TYPES } from '../constants';
import { PARAMETERS_DEFINITION } from '../constants';
export interface DataTypeDefinition {
label: string;
@ -76,8 +76,6 @@ export type SubType = NumericType | RangeType;
export type DataType = MainType | SubType;
export type RuntimeType = typeof RUNTIME_FIELD_TYPES[number];
export type NumericType =
| 'long'
| 'integer'

View file

@ -0,0 +1,197 @@
# Runtime fields
Welcome to the home of the runtime field editor and everything related to runtime fields!
## The runtime field editor
### Integration
The recommended way to integrate the runtime fields editor is by adding a plugin dependency to the `"runtimeFields"` x-pack plugin. This way you will be able to lazy load the editor when it is required and it will not increment the bundle size of your plugin.
```js
// 1. Add the plugin as a dependency in your kibana.json
{
...
"requiredBundles": [
"runtimeFields",
...
]
}
// 2. Access it in your plugin setup()
export class MyPlugin {
setup(core, { runtimeFields }) {
// logic to provide it to your app, probably through context
}
}
// 3. Load the editor and open it anywhere in your app
const MyComponent = () => {
// Access the plugin through context
const { runtimeFields } = useAppPlugins();
// Ref of the handler to close the editor
const closeRuntimeFieldEditor = useRef(() => {});
const saveRuntimeField = (field: RuntimeField) => {
// Do something with the field
console.log(field); // { name: 'myField', type: 'boolean', script: "return 'hello'" }
};
const openRuntimeFieldsEditor = async() => {
// Lazy load the editor
const { openEditor } = await runtimeFields.loadEditor();
closeRuntimeFieldEditor.current = openEditor({
onSave: saveRuntimeField,
/* defaultValue: optional field to edit */
});
};
useEffect(() => {
return () => {
// Make sure to remove the editor when the component unmounts
closeRuntimeFieldEditor.current();
};
}, []);
return (
<button onClick={openRuntimeFieldsEditor}>Add field</button>
)
}
```
#### Alternative
The runtime field editor is also exported as static React component that you can import into your components. The editor is exported in 2 flavours:
* As the content of a `<EuiFlyout />` (it contains a flyout header and footer)
* As a standalone component that you can inline anywhere
**Note:** The runtime field editor uses the `<CodeEditor />` that has a dependency on the `Provider` from the `"kibana_react"` plugin. If your app is not already wrapped by this provider you will need to add it at least around the runtime field editor. You can see an example in the ["Using the core.overlays.openFlyout()"](#using-the-coreoverlaysopenflyout) example below.
### Content of a `<EuiFlyout />`
```js
import React, { useState } from 'react';
import { EuiFlyoutBody, EuiButton } from '@elastic/eui';
import { RuntimeFieldEditorFlyoutContent, RuntimeField } from '../runtime_fields/public';
const MyComponent = () => {
const { docLinksStart } = useCoreContext(); // access the core start service
const [isFlyoutVisilbe, setIsFlyoutVisible] = useState(false);
const saveRuntimeField = useCallback((field: RuntimeField) => {
// Do something with the field
}, []);
return (
<>
<EuiButton onClick={() => setIsFlyoutVisible(true)}>Create field</EuiButton>
{isFlyoutVisible && (
<EuiFlyout onClose={() => setIsFlyoutVisible(false)}>
<RuntimeFieldEditorFlyoutContent
onSave={saveRuntimeField}
onCancel={() => setIsFlyoutVisible(false)}
docLinks={docLinksStart}
defaultValue={/*optional runtime field to edit*/}
/>
</EuiFlyout>
)}
</>
)
}
```
#### Using the `core.overlays.openFlyout()`
As an alternative you can open the flyout with the `openFlyout()` helper from core.
```js
import React, { useRef } from 'react';
import { EuiButton } from '@elastic/eui';
import { OverlayRef } from 'src/core/public';
import { createKibanaReactContext, toMountPoint } from '../../src/plugins/kibana_react/public';
import { RuntimeFieldEditorFlyoutContent, RuntimeField } from '../runtime_fields/public';
const MyComponent = () => {
// Access the core start service
const { docLinksStart, overlays, uiSettings } = useCoreContext();
const flyoutEditor = useRef<OverlayRef | null>(null);
const { openFlyout } = overlays;
const saveRuntimeField = useCallback((field: RuntimeField) => {
// Do something with the field
}, []);
const openRuntimeFieldEditor = useCallback(() => {
const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ uiSettings });
flyoutEditor.current = openFlyout(
toMountPoint(
<KibanaReactContextProvider>
<RuntimeFieldEditorFlyoutContent
onSave={saveRuntimeField}
onCancel={() => flyoutEditor.current?.close()}
docLinks={docLinksStart}
defaultValue={defaultRuntimeField}
/>
</KibanaReactContextProvider>
)
);
}, [openFlyout, saveRuntimeField, uiSettings]);
return (
<>
<EuiButton onClick={openRuntimeFieldEditor}>Create field</EuiButton>
</>
)
}
```
### Standalone component
```js
import React, { useState } from 'react';
import { EuiButton, EuiSpacer } from '@elastic/eui';
import { RuntimeFieldEditor, RuntimeField, RuntimeFieldFormState } from '../runtime_fields/public';
const MyComponent = () => {
const { docLinksStart } = useCoreContext(); // access the core start service
const [runtimeFieldFormState, setRuntimeFieldFormState] = useState<RuntimeFieldFormState>({
isSubmitted: false,
isValid: undefined,
submit: async() => Promise.resolve({ isValid: false, data: {} as RuntimeField })
});
const { submit, isValid: isFormValid, isSubmitted } = runtimeFieldFormState;
const saveRuntimeField = useCallback(async () => {
const { isValid, data } = await submit();
if (isValid) {
// Do something with the field (data)
}
}, [submit]);
return (
<>
<RuntimeFieldEditor
onChange={setRuntimeFieldFormState}
docLinks={docLinksStart}
defaultValue={/*optional runtime field to edit*/}
/>
<EuiSpacer />
<EuiButton
onClick={saveRuntimeField}
disabled={isSubmitted && !isFormValid}>
Save field
</EuiButton>
</>
)
}
```

View file

@ -0,0 +1,15 @@
{
"id": "runtimeFields",
"version": "kibana",
"server": false,
"ui": true,
"requiredPlugins": [
],
"optionalPlugins": [
],
"configPath": ["xpack", "runtime_fields"],
"requiredBundles": [
"kibanaReact",
"esUiShared"
]
}

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
jest.mock('../../../../../src/plugins/kibana_react/public', () => {
const original = jest.requireActual('../../../../../src/plugins/kibana_react/public');
const CodeEditorMock = (props: any) => (
<input
data-test-subj={props['data-test-subj'] || 'mockCodeEditor'}
data-value={props.value}
value={props.value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
props.onChange(e.target.value);
}}
/>
);
return {
...original,
CodeEditor: CodeEditorMock,
};
});
jest.mock('@elastic/eui', () => {
const original = jest.requireActual('@elastic/eui');
return {
...original,
EuiComboBox: (props: any) => (
<input
data-test-subj={props['data-test-subj'] || 'mockComboBox'}
data-currentvalue={props.selectedOptions}
value={props.selectedOptions[0]?.value}
onChange={async (syntheticEvent: any) => {
props.onChange([syntheticEvent['0']]);
}}
/>
),
};
});

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { RuntimeFieldForm, FormState as RuntimeFieldFormState } from './runtime_field_form';
export { RuntimeFieldEditor } from './runtime_field_editor';
export { RuntimeFieldEditorFlyoutContent } from './runtime_field_editor_flyout_content';

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { RuntimeFieldEditor } from './runtime_field_editor';

View file

@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { act } from 'react-dom/test-utils';
import { DocLinksStart } from 'src/core/public';
import '../../__jest__/setup_environment';
import { registerTestBed, TestBed } from '../../test_utils';
import { RuntimeField } from '../../types';
import { RuntimeFieldForm, FormState } from '../runtime_field_form/runtime_field_form';
import { RuntimeFieldEditor, Props } from './runtime_field_editor';
const setup = (props?: Props) =>
registerTestBed(RuntimeFieldEditor, {
memoryRouter: {
wrapComponent: false,
},
})(props) as TestBed;
const docLinks: DocLinksStart = {
ELASTIC_WEBSITE_URL: 'https://jestTest.elastic.co',
DOC_LINK_VERSION: 'jest',
links: {} as any,
};
describe('Runtime field editor', () => {
let testBed: TestBed;
let onChange: jest.Mock<Props['onChange']> = jest.fn();
const lastOnChangeCall = (): FormState[] => onChange.mock.calls[onChange.mock.calls.length - 1];
beforeEach(() => {
onChange = jest.fn();
});
test('should render the <RuntimeFieldForm />', () => {
testBed = setup({ docLinks });
const { component } = testBed;
expect(component.find(RuntimeFieldForm).length).toBe(1);
});
test('should accept a defaultValue and onChange prop to forward the form state', async () => {
const defaultValue: RuntimeField = {
name: 'foo',
type: 'date',
script: 'test=123',
};
testBed = setup({ onChange, defaultValue, docLinks });
expect(onChange).toHaveBeenCalled();
let lastState = lastOnChangeCall()[0];
expect(lastState.isValid).toBe(undefined);
expect(lastState.isSubmitted).toBe(false);
expect(lastState.submit).toBeDefined();
let data;
await act(async () => {
({ data } = await lastState.submit());
});
expect(data).toEqual(defaultValue);
// Make sure that both isValid and isSubmitted state are now "true"
lastState = lastOnChangeCall()[0];
expect(lastState.isValid).toBe(true);
expect(lastState.isSubmitted).toBe(true);
});
});

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { DocLinksStart } from 'src/core/public';
import { RuntimeField } from '../../types';
import { getLinks } from '../../lib';
import { RuntimeFieldForm, Props as FormProps } from '../runtime_field_form/runtime_field_form';
export interface Props {
docLinks: DocLinksStart;
defaultValue?: RuntimeField;
onChange?: FormProps['onChange'];
}
export const RuntimeFieldEditor = ({ defaultValue, onChange, docLinks }: Props) => {
const links = getLinks(docLinks);
return <RuntimeFieldForm links={links} defaultValue={defaultValue} onChange={onChange} />;
};

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { RuntimeFieldEditorFlyoutContent } from './runtime_field_editor_flyout_content';

View file

@ -0,0 +1,146 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { act } from 'react-dom/test-utils';
import { DocLinksStart } from 'src/core/public';
import '../../__jest__/setup_environment';
import { registerTestBed, TestBed } from '../../test_utils';
import { RuntimeField } from '../../types';
import { RuntimeFieldEditorFlyoutContent, Props } from './runtime_field_editor_flyout_content';
const setup = (props?: Props) =>
registerTestBed(RuntimeFieldEditorFlyoutContent, {
memoryRouter: {
wrapComponent: false,
},
})(props) as TestBed;
const docLinks: DocLinksStart = {
ELASTIC_WEBSITE_URL: 'htts://jestTest.elastic.co',
DOC_LINK_VERSION: 'jest',
links: {} as any,
};
const noop = () => {};
const defaultProps = { onSave: noop, onCancel: noop, docLinks };
describe('Runtime field editor flyout', () => {
test('should have a flyout title', () => {
const { exists, find } = setup(defaultProps);
expect(exists('flyoutTitle')).toBe(true);
expect(find('flyoutTitle').text()).toBe('Create new field');
});
test('should allow a runtime field to be provided', () => {
const field: RuntimeField = {
name: 'foo',
type: 'date',
script: 'test=123',
};
const { find } = setup({ ...defaultProps, defaultValue: field });
expect(find('flyoutTitle').text()).toBe(`Edit ${field.name} field`);
expect(find('nameField.input').props().value).toBe(field.name);
expect(find('typeField').props().value).toBe(field.type);
expect(find('scriptField').props().value).toBe(field.script);
});
test('should accept an onSave prop', async () => {
const field: RuntimeField = {
name: 'foo',
type: 'date',
script: 'test=123',
};
const onSave: jest.Mock<Props['onSave']> = jest.fn();
const { find } = setup({ ...defaultProps, onSave, defaultValue: field });
await act(async () => {
find('saveFieldButton').simulate('click');
});
expect(onSave).toHaveBeenCalled();
const fieldReturned: RuntimeField = onSave.mock.calls[onSave.mock.calls.length - 1][0];
expect(fieldReturned).toEqual(field);
});
test('should accept an onCancel prop', () => {
const onCancel = jest.fn();
const { find } = setup({ ...defaultProps, onCancel });
find('closeFlyoutButton').simulate('click');
expect(onCancel).toHaveBeenCalled();
});
describe('validation', () => {
test('should validate the fields and prevent saving invalid form', async () => {
const onSave: jest.Mock<Props['onSave']> = jest.fn();
const { find, exists, form, component } = setup({ ...defaultProps, onSave });
expect(find('saveFieldButton').props().disabled).toBe(false);
await act(async () => {
find('saveFieldButton').simulate('click');
});
component.update();
expect(onSave).toHaveBeenCalledTimes(0);
expect(find('saveFieldButton').props().disabled).toBe(true);
expect(form.getErrorsMessages()).toEqual([
'Give a name to the field.',
'Script must emit() a value.',
]);
expect(exists('formError')).toBe(true);
expect(find('formError').text()).toBe('Fix errors in form before continuing.');
});
test('should forward values from the form', async () => {
const onSave: jest.Mock<Props['onSave']> = jest.fn();
const { find, form } = setup({ ...defaultProps, onSave });
act(() => {
form.setInputValue('nameField.input', 'someName');
form.setInputValue('scriptField', 'script=123');
});
await act(async () => {
find('saveFieldButton').simulate('click');
});
expect(onSave).toHaveBeenCalled();
let fieldReturned: RuntimeField = onSave.mock.calls[onSave.mock.calls.length - 1][0];
expect(fieldReturned).toEqual({
name: 'someName',
type: 'keyword', // default to keyword
script: 'script=123',
});
// Change the type and make sure it is forwarded
act(() => {
find('typeField').simulate('change', [
{
label: 'Other type',
value: 'other_type',
},
]);
});
await act(async () => {
find('saveFieldButton').simulate('click');
});
fieldReturned = onSave.mock.calls[onSave.mock.calls.length - 1][0];
expect(fieldReturned).toEqual({
name: 'someName',
type: 'other_type',
script: 'script=123',
});
});
});
});

View file

@ -0,0 +1,146 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback, useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiFlyoutHeader,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiTitle,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiButton,
EuiSpacer,
EuiCallOut,
} from '@elastic/eui';
import { DocLinksStart } from 'src/core/public';
import { RuntimeField } from '../../types';
import { FormState } from '../runtime_field_form';
import { RuntimeFieldEditor } from '../runtime_field_editor';
const geti18nTexts = (field?: RuntimeField) => {
return {
flyoutTitle: field
? i18n.translate('xpack.runtimeFields.editor.flyoutEditFieldTitle', {
defaultMessage: 'Edit {fieldName} field',
values: {
fieldName: field.name,
},
})
: i18n.translate('xpack.runtimeFields.editor.flyoutDefaultTitle', {
defaultMessage: 'Create new field',
}),
closeButtonLabel: i18n.translate('xpack.runtimeFields.editor.flyoutCloseButtonLabel', {
defaultMessage: 'Close',
}),
saveButtonLabel: i18n.translate('xpack.runtimeFields.editor.flyoutSaveButtonLabel', {
defaultMessage: 'Save',
}),
formErrorsCalloutTitle: i18n.translate('xpack.runtimeFields.editor.validationErrorTitle', {
defaultMessage: 'Fix errors in form before continuing.',
}),
};
};
export interface Props {
/**
* Handler for the "save" footer button
*/
onSave: (field: RuntimeField) => void;
/**
* Handler for the "cancel" footer button
*/
onCancel: () => void;
/**
* The docLinks start service from core
*/
docLinks: DocLinksStart;
/**
* An optional runtime field to edit
*/
defaultValue?: RuntimeField;
}
export const RuntimeFieldEditorFlyoutContent = ({
onSave,
onCancel,
docLinks,
defaultValue: field,
}: Props) => {
const i18nTexts = geti18nTexts(field);
const [formState, setFormState] = useState<FormState>({
isSubmitted: false,
isValid: field ? true : undefined,
submit: field
? async () => ({ isValid: true, data: field })
: async () => ({ isValid: false, data: {} as RuntimeField }),
});
const { submit, isValid: isFormValid, isSubmitted } = formState;
const onSaveField = useCallback(async () => {
const { isValid, data } = await submit();
if (isValid) {
onSave(data);
}
}, [submit, onSave]);
return (
<>
<EuiFlyoutHeader>
<EuiTitle size="m" data-test-subj="flyoutTitle">
<h2>{i18nTexts.flyoutTitle}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<RuntimeFieldEditor docLinks={docLinks} defaultValue={field} onChange={setFormState} />
</EuiFlyoutBody>
<EuiFlyoutFooter>
{isSubmitted && !isFormValid && (
<>
<EuiCallOut
title={i18nTexts.formErrorsCalloutTitle}
color="danger"
iconType="cross"
data-test-subj="formError"
/>
<EuiSpacer size="m" />
</>
)}
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
flush="left"
onClick={() => onCancel()}
data-test-subj="closeFlyoutButton"
>
{i18nTexts.closeButtonLabel}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
color="primary"
onClick={() => onSaveField()}
data-test-subj="saveFieldButton"
disabled={isSubmitted && !isFormValid}
fill
>
{i18nTexts.saveButtonLabel}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</>
);
};

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { RuntimeFieldForm, FormState } from './runtime_field_form';

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { act } from 'react-dom/test-utils';
import '../../__jest__/setup_environment';
import { registerTestBed, TestBed } from '../../test_utils';
import { RuntimeField } from '../../types';
import { RuntimeFieldForm, Props, FormState } from './runtime_field_form';
const setup = (props?: Props) =>
registerTestBed(RuntimeFieldForm, {
memoryRouter: {
wrapComponent: false,
},
})(props) as TestBed;
const links = {
painlessSyntax: 'https://jestTest.elastic.co/to-be-defined.html',
};
describe('Runtime field form', () => {
let testBed: TestBed;
let onChange: jest.Mock<Props['onChange']> = jest.fn();
const lastOnChangeCall = (): FormState[] => onChange.mock.calls[onChange.mock.calls.length - 1];
beforeEach(() => {
onChange = jest.fn();
});
test('should render expected 3 fields (name, returnType, script)', () => {
testBed = setup({ links });
const { exists } = testBed;
expect(exists('nameField')).toBe(true);
expect(exists('typeField')).toBe(true);
expect(exists('scriptField')).toBe(true);
});
test('should have a link to learn more about painless syntax', () => {
testBed = setup({ links });
const { exists, find } = testBed;
expect(exists('painlessSyntaxLearnMoreLink')).toBe(true);
expect(find('painlessSyntaxLearnMoreLink').props().href).toBe(links.painlessSyntax);
});
test('should accept a "defaultValue" prop', () => {
const defaultValue: RuntimeField = {
name: 'foo',
type: 'date',
script: 'test=123',
};
testBed = setup({ defaultValue, links });
const { find } = testBed;
expect(find('nameField.input').props().value).toBe(defaultValue.name);
expect(find('typeField').props().value).toBe(defaultValue.type);
expect(find('scriptField').props().value).toBe(defaultValue.script);
});
test('should accept an "onChange" prop to forward the form state', async () => {
const defaultValue: RuntimeField = {
name: 'foo',
type: 'date',
script: 'test=123',
};
testBed = setup({ onChange, defaultValue, links });
expect(onChange).toHaveBeenCalled();
let lastState = lastOnChangeCall()[0];
expect(lastState.isValid).toBe(undefined);
expect(lastState.isSubmitted).toBe(false);
expect(lastState.submit).toBeDefined();
let data;
await act(async () => {
({ data } = await lastState.submit());
});
expect(data).toEqual(defaultValue);
// Make sure that both isValid and isSubmitted state are now "true"
lastState = lastOnChangeCall()[0];
expect(lastState.isValid).toBe(true);
expect(lastState.isSubmitted).toBe(true);
});
});

View file

@ -0,0 +1,149 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { PainlessLang } from '@kbn/monaco';
import {
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiFormRow,
EuiComboBox,
EuiComboBoxOptionOption,
EuiLink,
} from '@elastic/eui';
import { useForm, Form, FormHook, UseField, TextField, CodeEditor } from '../../shared_imports';
import { RuntimeField } from '../../types';
import { RUNTIME_FIELD_OPTIONS } from '../../constants';
import { schema } from './schema';
export interface FormState {
isValid: boolean | undefined;
isSubmitted: boolean;
submit: FormHook<RuntimeField>['submit'];
}
export interface Props {
links: {
painlessSyntax: string;
};
defaultValue?: RuntimeField;
onChange?: (state: FormState) => void;
}
const RuntimeFieldFormComp = ({ defaultValue, onChange, links }: Props) => {
const { form } = useForm<RuntimeField>({ defaultValue, schema });
const { submit, isValid: isFormValid, isSubmitted } = form;
useEffect(() => {
if (onChange) {
onChange({ isValid: isFormValid, isSubmitted, submit });
}
}, [onChange, isFormValid, isSubmitted, submit]);
return (
<Form form={form} className="runtimeFieldEditor_form">
<EuiFlexGroup>
{/* Name */}
<EuiFlexItem>
<UseField path="name" component={TextField} data-test-subj="nameField" />
</EuiFlexItem>
{/* Return type */}
<EuiFlexItem>
<UseField<EuiComboBoxOptionOption[]> path="type">
{({ label, value, setValue }) => {
if (value === undefined) {
return null;
}
return (
<>
<EuiFormRow label={label} fullWidth>
<EuiComboBox
placeholder={i18n.translate(
'xpack.runtimeFields.form.runtimeType.placeholderLabel',
{
defaultMessage: 'Select a type',
}
)}
singleSelection={{ asPlainText: true }}
options={RUNTIME_FIELD_OPTIONS}
selectedOptions={value}
onChange={(newValue) => {
if (newValue.length === 0) {
// Don't allow clearing the type. One must always be selected
return;
}
setValue(newValue);
}}
isClearable={false}
data-test-subj="typeField"
fullWidth
/>
</EuiFormRow>
</>
);
}}
</UseField>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
{/* Script */}
<UseField<string> path="script">
{({ value, setValue, label, isValid, getErrorsMessages }) => {
return (
<EuiFormRow
label={label}
error={getErrorsMessages()}
isInvalid={!isValid}
helpText={
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiLink
href={links.painlessSyntax}
target="_blank"
external
data-test-subj="painlessSyntaxLearnMoreLink"
>
{i18n.translate('xpack.runtimeFields.form.script.learnMoreLinkText', {
defaultMessage: 'Learn more about syntax.',
})}
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
}
fullWidth
>
<CodeEditor
languageId={PainlessLang.ID}
width="100%"
height="300px"
value={value}
onChange={setValue}
options={{
fontSize: 12,
minimap: {
enabled: false,
},
scrollBeyondLastLine: false,
wordWrap: 'on',
wrappingIndent: 'indent',
automaticLayout: true,
}}
data-test-subj="scriptField"
/>
</EuiFormRow>
);
}}
</UseField>
</Form>
);
};
export const RuntimeFieldForm = React.memo(RuntimeFieldFormComp);

View file

@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { FormSchema, fieldValidators } from '../../shared_imports';
import { RUNTIME_FIELD_OPTIONS } from '../../constants';
import { RuntimeField, RuntimeType, ComboBoxOption } from '../../types';
const { emptyField } = fieldValidators;
export const schema: FormSchema<RuntimeField> = {
name: {
label: i18n.translate('xpack.runtimeFields.form.nameLabel', {
defaultMessage: 'Name',
}),
validations: [
{
validator: emptyField(
i18n.translate('xpack.runtimeFields.form.validations.nameIsRequiredErrorMessage', {
defaultMessage: 'Give a name to the field.',
})
),
},
],
},
type: {
label: i18n.translate('xpack.runtimeFields.form.runtimeTypeLabel', {
defaultMessage: 'Type',
}),
defaultValue: 'keyword',
deserializer: (fieldType?: RuntimeType) => {
if (!fieldType) {
return [];
}
const label = RUNTIME_FIELD_OPTIONS.find(({ value }) => value === fieldType)?.label;
return [{ label: label ?? fieldType, value: fieldType }];
},
serializer: (value: Array<ComboBoxOption<RuntimeType>>) => value[0].value!,
},
script: {
label: i18n.translate('xpack.runtimeFields.form.defineFieldLabel', {
defaultMessage: 'Define field',
}),
validations: [
{
validator: emptyField(
i18n.translate('xpack.runtimeFields.form.validations.scriptIsRequiredErrorMessage', {
defaultMessage: 'Script must emit() a value.',
})
),
},
],
},
};

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ComboBoxOption } from './types';
export const RUNTIME_FIELD_TYPES = ['keyword', 'long', 'double', 'date', 'ip', 'boolean'] as const;
type RuntimeType = typeof RUNTIME_FIELD_TYPES[number];
export const RUNTIME_FIELD_OPTIONS: Array<ComboBoxOption<RuntimeType>> = [
{
label: 'Keyword',
value: 'keyword',
},
{
label: 'Long',
value: 'long',
},
{
label: 'Double',
value: 'double',
},
{
label: 'Date',
value: 'date',
},
{
label: 'IP',
value: 'ip',
},
{
label: 'Boolean',
value: 'boolean',
},
];

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { RuntimeFieldsPlugin } from './plugin';
export {
RuntimeFieldEditorFlyoutContent,
RuntimeFieldEditor,
RuntimeFieldFormState,
} from './components';
export { RUNTIME_FIELD_OPTIONS } from './constants';
export { RuntimeField, RuntimeType, PluginSetup as RuntimeFieldsSetup } from './types';
export function plugin() {
return new RuntimeFieldsPlugin();
}

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { DocLinksStart } from 'src/core/public';
export const getLinks = (docLinks: DocLinksStart) => {
const { DOC_LINK_VERSION, ELASTIC_WEBSITE_URL } = docLinks;
const docsBase = `${ELASTIC_WEBSITE_URL}guide/en`;
const painlessDocsBase = `${docsBase}/elasticsearch/painless/${DOC_LINK_VERSION}`;
return {
painlessSyntax: `${painlessDocsBase}/painless-lang-spec.html`,
};
};

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { getLinks } from './documentation';

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { CoreSetup, OverlayRef } from 'src/core/public';
import { toMountPoint, createKibanaReactContext } from './shared_imports';
import { LoadEditorResponse, RuntimeField } from './types';
export interface OpenRuntimeFieldEditorProps {
onSave(field: RuntimeField): void;
defaultValue?: RuntimeField;
}
export const getRuntimeFieldEditorLoader = (coreSetup: CoreSetup) => async (): Promise<
LoadEditorResponse
> => {
const { RuntimeFieldEditorFlyoutContent } = await import('./components');
const [core] = await coreSetup.getStartServices();
const { uiSettings, overlays, docLinks } = core;
const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ uiSettings });
let overlayRef: OverlayRef | null = null;
const openEditor = ({ onSave, defaultValue }: OpenRuntimeFieldEditorProps) => {
const closeEditor = () => {
overlayRef?.close();
overlayRef = null;
};
const onSaveField = (field: RuntimeField) => {
closeEditor();
onSave(field);
};
overlayRef = overlays.openFlyout(
toMountPoint(
<KibanaReactContextProvider>
<RuntimeFieldEditorFlyoutContent
onSave={onSaveField}
onCancel={() => overlayRef?.close()}
docLinks={docLinks}
defaultValue={defaultValue}
/>
</KibanaReactContextProvider>
)
);
return closeEditor;
};
return {
openEditor,
};
};

View file

@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { CoreSetup } from 'src/core/public';
import { coreMock } from 'src/core/public/mocks';
jest.mock('../../../../src/plugins/kibana_react/public', () => {
const original = jest.requireActual('../../../../src/plugins/kibana_react/public');
return {
...original,
toMountPoint: (node: React.ReactNode) => node,
};
});
import { StartPlugins, PluginStart } from './types';
import { RuntimeFieldEditorFlyoutContent } from './components';
import { RuntimeFieldsPlugin } from './plugin';
const noop = () => {};
describe('RuntimeFieldsPlugin', () => {
let coreSetup: CoreSetup<StartPlugins, PluginStart>;
let plugin: RuntimeFieldsPlugin;
beforeEach(() => {
plugin = new RuntimeFieldsPlugin();
coreSetup = coreMock.createSetup();
});
test('should return a handler to load the runtime field editor', async () => {
const setupApi = await plugin.setup(coreSetup, {});
expect(setupApi.loadEditor).toBeDefined();
});
test('once it is loaded it should expose a handler to open the editor', async () => {
const setupApi = await plugin.setup(coreSetup, {});
const response = await setupApi.loadEditor();
expect(response.openEditor).toBeDefined();
});
test('should call core.overlays.openFlyout when opening the editor', async () => {
const openFlyout = jest.fn();
const onSaveSpy = jest.fn();
const mockCore = {
overlays: {
openFlyout,
},
uiSettings: {},
};
coreSetup.getStartServices = async () => [mockCore] as any;
const setupApi = await plugin.setup(coreSetup, {});
const { openEditor } = await setupApi.loadEditor();
openEditor({ onSave: onSaveSpy });
expect(openFlyout).toHaveBeenCalled();
const [[arg]] = openFlyout.mock.calls;
expect(arg.props.children.type).toBe(RuntimeFieldEditorFlyoutContent);
// We force call the "onSave" prop from the <RuntimeFieldEditorFlyoutContent /> component
// and make sure that the the spy is being called.
// Note: we are testing implementation details, if we change or rename the "onSave" prop on
// the component, we will need to update this test accordingly.
expect(arg.props.children.props.onSave).toBeDefined();
arg.props.children.props.onSave();
expect(onSaveSpy).toHaveBeenCalled();
});
test('should return a handler to close the flyout', async () => {
const setupApi = await plugin.setup(coreSetup, {});
const { openEditor } = await setupApi.loadEditor();
const closeEditorHandler = openEditor({ onSave: noop });
expect(typeof closeEditorHandler).toBe('function');
});
});

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Plugin, CoreSetup, CoreStart } from 'src/core/public';
import { PluginSetup, PluginStart, SetupPlugins, StartPlugins } from './types';
import { getRuntimeFieldEditorLoader } from './load_editor';
export class RuntimeFieldsPlugin
implements Plugin<PluginSetup, PluginStart, SetupPlugins, StartPlugins> {
public setup(core: CoreSetup<StartPlugins, PluginStart>, plugins: SetupPlugins): PluginSetup {
return {
loadEditor: getRuntimeFieldEditorLoader(core),
};
}
public start(core: CoreStart, plugins: StartPlugins) {
return {};
}
public stop() {
return {};
}
}

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export {
useForm,
Form,
FormSchema,
UseField,
FormHook,
} from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib';
export { fieldValidators } from '../../../../src/plugins/es_ui_shared/static/forms/helpers';
export { TextField } from '../../../../src/plugins/es_ui_shared/static/forms/components';
export {
CodeEditor,
toMountPoint,
createKibanaReactContext,
} from '../../../../src/plugins/kibana_react/public';

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { registerTestBed, TestBed } from '@kbn/test/jest';

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { DataPublicPluginStart } from 'src/plugins/data/public';
import { RUNTIME_FIELD_TYPES } from './constants';
import { OpenRuntimeFieldEditorProps } from './load_editor';
export interface LoadEditorResponse {
openEditor(props: OpenRuntimeFieldEditorProps): () => void;
}
export interface PluginSetup {
loadEditor(): Promise<LoadEditorResponse>;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface PluginStart {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface SetupPlugins {}
export interface StartPlugins {
data: DataPublicPluginStart;
}
export type RuntimeType = typeof RUNTIME_FIELD_TYPES[number];
export interface RuntimeField {
name: string;
type: RuntimeType;
script: string;
}
export interface ComboBoxOption<T = unknown> {
label: string;
value?: T;
}