mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
982639fc2a
commit
e3c2dccf00
35 changed files with 1320 additions and 47 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -102,4 +102,5 @@ pageLoadAssetSize:
|
|||
visualizations: 295025
|
||||
visualize: 57431
|
||||
watcher: 43598
|
||||
runtimeFields: 41752
|
||||
stackAlerts: 29684
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
"configPath": ["xpack", "index_management"],
|
||||
"requiredBundles": [
|
||||
"kibanaReact",
|
||||
"esUiShared"
|
||||
"esUiShared",
|
||||
"runtimeFields"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
/>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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'
|
||||
|
|
197
x-pack/plugins/runtime_fields/README.md
Normal file
197
x-pack/plugins/runtime_fields/README.md
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
15
x-pack/plugins/runtime_fields/kibana.json
Normal file
15
x-pack/plugins/runtime_fields/kibana.json
Normal file
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"id": "runtimeFields",
|
||||
"version": "kibana",
|
||||
"server": false,
|
||||
"ui": true,
|
||||
"requiredPlugins": [
|
||||
],
|
||||
"optionalPlugins": [
|
||||
],
|
||||
"configPath": ["xpack", "runtime_fields"],
|
||||
"requiredBundles": [
|
||||
"kibanaReact",
|
||||
"esUiShared"
|
||||
]
|
||||
}
|
|
@ -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']]);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
});
|
11
x-pack/plugins/runtime_fields/public/components/index.ts
Normal file
11
x-pack/plugins/runtime_fields/public/components/index.ts
Normal 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';
|
|
@ -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';
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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} />;
|
||||
};
|
|
@ -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';
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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';
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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.',
|
||||
})
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
37
x-pack/plugins/runtime_fields/public/constants.ts
Normal file
37
x-pack/plugins/runtime_fields/public/constants.ts
Normal 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',
|
||||
},
|
||||
];
|
18
x-pack/plugins/runtime_fields/public/index.ts
Normal file
18
x-pack/plugins/runtime_fields/public/index.ts
Normal 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();
|
||||
}
|
16
x-pack/plugins/runtime_fields/public/lib/documentation.ts
Normal file
16
x-pack/plugins/runtime_fields/public/lib/documentation.ts
Normal 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`,
|
||||
};
|
||||
};
|
7
x-pack/plugins/runtime_fields/public/lib/index.ts
Normal file
7
x-pack/plugins/runtime_fields/public/lib/index.ts
Normal 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';
|
57
x-pack/plugins/runtime_fields/public/load_editor.tsx
Normal file
57
x-pack/plugins/runtime_fields/public/load_editor.tsx
Normal 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,
|
||||
};
|
||||
};
|
82
x-pack/plugins/runtime_fields/public/plugin.test.ts
Normal file
82
x-pack/plugins/runtime_fields/public/plugin.test.ts
Normal 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');
|
||||
});
|
||||
});
|
26
x-pack/plugins/runtime_fields/public/plugin.ts
Normal file
26
x-pack/plugins/runtime_fields/public/plugin.ts
Normal 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 {};
|
||||
}
|
||||
}
|
23
x-pack/plugins/runtime_fields/public/shared_imports.ts
Normal file
23
x-pack/plugins/runtime_fields/public/shared_imports.ts
Normal 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';
|
7
x-pack/plugins/runtime_fields/public/test_utils.ts
Normal file
7
x-pack/plugins/runtime_fields/public/test_utils.ts
Normal 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';
|
40
x-pack/plugins/runtime_fields/public/types.ts
Normal file
40
x-pack/plugins/runtime_fields/public/types.ts
Normal 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;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue