[Runtime fields] Add support in index template (#84184)

Co-authored-by: Adam Locke <adam.locke@elastic.co>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Sébastien Loix 2020-12-03 15:59:20 +01:00 committed by GitHub
parent 43dd4876f2
commit e83bbfd289
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
78 changed files with 1850 additions and 557 deletions

View file

@ -35,7 +35,7 @@ const MyComponent = () => {
const saveRuntimeField = (field: RuntimeField) => {
// Do something with the field
console.log(field); // { name: 'myField', type: 'boolean', script: "return 'hello'" }
// See interface returned in @returns section below
};
const openRuntimeFieldsEditor = async() => {
@ -45,6 +45,7 @@ const MyComponent = () => {
closeRuntimeFieldEditor.current = openEditor({
onSave: saveRuntimeField,
/* defaultValue: optional field to edit */
/* ctx: Context -- see section below */
});
};
@ -61,7 +62,40 @@ const MyComponent = () => {
}
```
#### Alternative
#### `@returns`
You get back a `RuntimeField` object with the following interface
```ts
interface RuntimeField {
name: string;
type: RuntimeType; // 'long' | 'boolean' ...
script: {
source: string;
}
}
```
#### Context object
You can provide a context object to the runtime field editor. It has the following interface
```ts
interface Context {
/** An array of field name not allowed. You would probably provide an array of existing runtime fields
* to prevent the user creating a field with the same name.
*/
namesNotAllowed?: string[];
/**
* An array of existing concrete fields. If the user gives a name to the runtime
* field that matches one of the concrete fields, a callout will be displayed
* to indicate that this runtime field will shadow the concrete field.
*/
existingConcreteFields?: string[];
}
```
#### Other type of integration
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:
@ -96,6 +130,7 @@ const MyComponent = () => {
onCancel={() => setIsFlyoutVisible(false)}
docLinks={docLinksStart}
defaultValue={/*optional runtime field to edit*/}
ctx={/*optional context object -- see section above*/}
/>
</EuiFlyout>
)}
@ -138,6 +173,7 @@ const MyComponent = () => {
onCancel={() => flyoutEditor.current?.close()}
docLinks={docLinksStart}
defaultValue={defaultRuntimeField}
ctx={/*optional context object -- see section above*/}
/>
</KibanaReactContextProvider>
)
@ -182,6 +218,7 @@ const MyComponent = () => {
onChange={setRuntimeFieldFormState}
docLinks={docLinksStart}
defaultValue={/*optional runtime field to edit*/}
ctx={/*optional context object -- see section above*/}
/>
<EuiSpacer />

View file

@ -8,4 +8,7 @@ export { RuntimeFieldForm, FormState as RuntimeFieldFormState } from './runtime_
export { RuntimeFieldEditor } from './runtime_field_editor';
export { RuntimeFieldEditorFlyoutContent } from './runtime_field_editor_flyout_content';
export {
RuntimeFieldEditorFlyoutContent,
RuntimeFieldEditorFlyoutContentProps,
} from './runtime_field_editor_flyout_content';

View file

@ -31,6 +31,14 @@ describe('Runtime field editor', () => {
const lastOnChangeCall = (): FormState[] => onChange.mock.calls[onChange.mock.calls.length - 1];
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
beforeEach(() => {
onChange = jest.fn();
});
@ -46,7 +54,7 @@ describe('Runtime field editor', () => {
const defaultValue: RuntimeField = {
name: 'foo',
type: 'date',
script: 'test=123',
script: { source: 'test=123' },
};
testBed = setup({ onChange, defaultValue, docLinks });
@ -68,4 +76,75 @@ describe('Runtime field editor', () => {
expect(lastState.isValid).toBe(true);
expect(lastState.isSubmitted).toBe(true);
});
test('should accept a list of existing concrete fields and display a callout when shadowing one of the fields', async () => {
const existingConcreteFields = ['myConcreteField'];
testBed = setup({ onChange, docLinks, ctx: { existingConcreteFields } });
const { form, component, exists } = testBed;
expect(exists('shadowingFieldCallout')).toBe(false);
await act(async () => {
form.setInputValue('nameField.input', existingConcreteFields[0]);
});
component.update();
expect(exists('shadowingFieldCallout')).toBe(true);
});
describe('validation', () => {
test('should accept an optional list of existing runtime fields and prevent creating duplicates', async () => {
const existingRuntimeFieldNames = ['myRuntimeField'];
testBed = setup({ onChange, docLinks, ctx: { namesNotAllowed: existingRuntimeFieldNames } });
const { form, component } = testBed;
await act(async () => {
form.setInputValue('nameField.input', existingRuntimeFieldNames[0]);
form.setInputValue('scriptField', 'echo("hello")');
});
act(() => {
jest.advanceTimersByTime(1000); // Make sure our debounced error message is in the DOM
});
await act(async () => {
await lastOnChangeCall()[0].submit();
});
component.update();
expect(lastOnChangeCall()[0].isValid).toBe(false);
expect(form.getErrorsMessages()).toEqual(['There is already a field with this name.']);
});
test('should not count the default value as a duplicate', async () => {
const existingRuntimeFieldNames = ['myRuntimeField'];
const defaultValue: RuntimeField = {
name: 'myRuntimeField',
type: 'boolean',
script: { source: 'emit("hello"' },
};
testBed = setup({
defaultValue,
onChange,
docLinks,
ctx: { namesNotAllowed: existingRuntimeFieldNames },
});
const { form } = testBed;
await act(async () => {
await lastOnChangeCall()[0].submit();
});
expect(lastOnChangeCall()[0].isValid).toBe(true);
expect(form.getErrorsMessages()).toEqual([]);
});
});
});

View file

@ -15,10 +15,13 @@ export interface Props {
docLinks: DocLinksStart;
defaultValue?: RuntimeField;
onChange?: FormProps['onChange'];
ctx?: FormProps['ctx'];
}
export const RuntimeFieldEditor = ({ defaultValue, onChange, docLinks }: Props) => {
export const RuntimeFieldEditor = ({ defaultValue, onChange, docLinks, ctx }: Props) => {
const links = getLinks(docLinks);
return <RuntimeFieldForm links={links} defaultValue={defaultValue} onChange={onChange} />;
return (
<RuntimeFieldForm links={links} defaultValue={defaultValue} onChange={onChange} ctx={ctx} />
);
};

View file

@ -4,4 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { RuntimeFieldEditorFlyoutContent } from './runtime_field_editor_flyout_content';
export {
RuntimeFieldEditorFlyoutContent,
Props as RuntimeFieldEditorFlyoutContentProps,
} from './runtime_field_editor_flyout_content';

View file

@ -39,7 +39,7 @@ describe('Runtime field editor flyout', () => {
const field: RuntimeField = {
name: 'foo',
type: 'date',
script: 'test=123',
script: { source: 'test=123' },
};
const { find } = setup({ ...defaultProps, defaultValue: field });
@ -47,14 +47,14 @@ describe('Runtime field editor flyout', () => {
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);
expect(find('scriptField').props().value).toBe(field.script.source);
});
test('should accept an onSave prop', async () => {
const field: RuntimeField = {
name: 'foo',
type: 'date',
script: 'test=123',
script: { source: 'test=123' },
};
const onSave: jest.Mock<Props['onSave']> = jest.fn();
@ -93,10 +93,7 @@ describe('Runtime field editor flyout', () => {
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(form.getErrorsMessages()).toEqual(['Give a name to the field.']);
expect(exists('formError')).toBe(true);
expect(find('formError').text()).toBe('Fix errors in form before continuing.');
});
@ -120,7 +117,7 @@ describe('Runtime field editor flyout', () => {
expect(fieldReturned).toEqual({
name: 'someName',
type: 'keyword', // default to keyword
script: 'script=123',
script: { source: 'script=123' },
});
// Change the type and make sure it is forwarded
@ -139,7 +136,7 @@ describe('Runtime field editor flyout', () => {
expect(fieldReturned).toEqual({
name: 'someName',
type: 'other_type',
script: 'script=123',
script: { source: 'script=123' },
});
});
});

View file

@ -21,7 +21,10 @@ import { DocLinksStart } from 'src/core/public';
import { RuntimeField } from '../../types';
import { FormState } from '../runtime_field_form';
import { RuntimeFieldEditor } from '../runtime_field_editor';
import {
RuntimeFieldEditor,
Props as RuntimeFieldEditorProps,
} from '../runtime_field_editor/runtime_field_editor';
const geti18nTexts = (field?: RuntimeField) => {
return {
@ -64,6 +67,10 @@ export interface Props {
* An optional runtime field to edit
*/
defaultValue?: RuntimeField;
/**
* Optional context object
*/
ctx?: RuntimeFieldEditorProps['ctx'];
}
export const RuntimeFieldEditorFlyoutContent = ({
@ -71,6 +78,7 @@ export const RuntimeFieldEditorFlyoutContent = ({
onCancel,
docLinks,
defaultValue: field,
ctx,
}: Props) => {
const i18nTexts = geti18nTexts(field);
@ -95,12 +103,17 @@ export const RuntimeFieldEditorFlyoutContent = ({
<>
<EuiFlyoutHeader>
<EuiTitle size="m" data-test-subj="flyoutTitle">
<h2>{i18nTexts.flyoutTitle}</h2>
<h2 id="runtimeFieldEditorEditTitle">{i18nTexts.flyoutTitle}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<RuntimeFieldEditor docLinks={docLinks} defaultValue={field} onChange={setFormState} />
<RuntimeFieldEditor
docLinks={docLinks}
defaultValue={field}
onChange={setFormState}
ctx={ctx}
/>
</EuiFlyoutBody>
<EuiFlyoutFooter>

View file

@ -18,7 +18,7 @@ const setup = (props?: Props) =>
})(props) as TestBed;
const links = {
painlessSyntax: 'https://jestTest.elastic.co/to-be-defined.html',
runtimePainless: 'https://jestTest.elastic.co/to-be-defined.html',
};
describe('Runtime field form', () => {
@ -45,28 +45,28 @@ describe('Runtime field form', () => {
const { exists, find } = testBed;
expect(exists('painlessSyntaxLearnMoreLink')).toBe(true);
expect(find('painlessSyntaxLearnMoreLink').props().href).toBe(links.painlessSyntax);
expect(find('painlessSyntaxLearnMoreLink').props().href).toBe(links.runtimePainless);
});
test('should accept a "defaultValue" prop', () => {
const defaultValue: RuntimeField = {
name: 'foo',
type: 'date',
script: 'test=123',
script: { source: '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);
expect(find('scriptField').props().value).toBe(defaultValue.script.source);
});
test('should accept an "onChange" prop to forward the form state', async () => {
const defaultValue: RuntimeField = {
name: 'foo',
type: 'date',
script: 'test=123',
script: { source: 'test=123' },
};
testBed = setup({ onChange, defaultValue, links });

View file

@ -14,9 +14,20 @@ import {
EuiComboBox,
EuiComboBoxOptionOption,
EuiLink,
EuiCallOut,
} from '@elastic/eui';
import { useForm, Form, FormHook, UseField, TextField, CodeEditor } from '../../shared_imports';
import {
useForm,
useFormData,
Form,
FormHook,
UseField,
TextField,
CodeEditor,
ValidationFunc,
FieldConfig,
} from '../../shared_imports';
import { RuntimeField } from '../../types';
import { RUNTIME_FIELD_OPTIONS } from '../../constants';
import { schema } from './schema';
@ -29,15 +40,82 @@ export interface FormState {
export interface Props {
links: {
painlessSyntax: string;
runtimePainless: string;
};
defaultValue?: RuntimeField;
onChange?: (state: FormState) => void;
/**
* Optional context object
*/
ctx?: {
/** An array of field name not allowed */
namesNotAllowed?: string[];
/**
* An array of existing concrete fields. If the user gives a name to the runtime
* field that matches one of the concrete fields, a callout will be displayed
* to indicate that this runtime field will shadow the concrete field.
*/
existingConcreteFields?: string[];
};
}
const RuntimeFieldFormComp = ({ defaultValue, onChange, links }: Props) => {
const createNameNotAllowedValidator = (
namesNotAllowed: string[]
): ValidationFunc<{}, string, string> => ({ value }) => {
if (namesNotAllowed.includes(value)) {
return {
message: i18n.translate(
'xpack.runtimeFields.runtimeFieldsEditor.existRuntimeFieldNamesValidationErrorMessage',
{
defaultMessage: 'There is already a field with this name.',
}
),
};
}
};
/**
* Dynamically retrieve the config for the "name" field, adding
* a validator to avoid duplicated runtime fields to be created.
*
* @param namesNotAllowed Array of names not allowed for the field "name"
* @param defaultValue Initial value of the form
*/
const getNameFieldConfig = (
namesNotAllowed?: string[],
defaultValue?: Props['defaultValue']
): FieldConfig<string, RuntimeField> => {
const nameFieldConfig = schema.name as FieldConfig<string, RuntimeField>;
if (!namesNotAllowed) {
return nameFieldConfig;
}
// Add validation to not allow duplicates
return {
...nameFieldConfig!,
validations: [
...(nameFieldConfig.validations ?? []),
{
validator: createNameNotAllowedValidator(
namesNotAllowed.filter((name) => name !== defaultValue?.name)
),
},
],
};
};
const RuntimeFieldFormComp = ({
defaultValue,
onChange,
links,
ctx: { namesNotAllowed, existingConcreteFields = [] } = {},
}: Props) => {
const { form } = useForm<RuntimeField>({ defaultValue, schema });
const { submit, isValid: isFormValid, isSubmitted } = form;
const [{ name }] = useFormData<RuntimeField>({ form, watch: 'name' });
const nameFieldConfig = getNameFieldConfig(namesNotAllowed, defaultValue);
useEffect(() => {
if (onChange) {
@ -50,7 +128,19 @@ const RuntimeFieldFormComp = ({ defaultValue, onChange, links }: Props) => {
<EuiFlexGroup>
{/* Name */}
<EuiFlexItem>
<UseField path="name" component={TextField} data-test-subj="nameField" />
<UseField<string, RuntimeField>
path="name"
config={nameFieldConfig}
component={TextField}
data-test-subj="nameField"
componentProps={{
euiFieldProps: {
'aria-label': i18n.translate('xpack.runtimeFields.form.nameAriaLabel', {
defaultMessage: 'Name field',
}),
},
}}
/>
</EuiFlexItem>
{/* Return type */}
@ -82,6 +172,9 @@ const RuntimeFieldFormComp = ({ defaultValue, onChange, links }: Props) => {
}}
isClearable={false}
data-test-subj="typeField"
aria-label={i18n.translate('xpack.runtimeFields.form.typeSelectAriaLabel', {
defaultMessage: 'Type select',
})}
fullWidth
/>
</EuiFormRow>
@ -92,10 +185,32 @@ const RuntimeFieldFormComp = ({ defaultValue, onChange, links }: Props) => {
</EuiFlexItem>
</EuiFlexGroup>
{existingConcreteFields.includes(name) && (
<>
<EuiSpacer />
<EuiCallOut
title={i18n.translate('xpack.runtimeFields.form.fieldShadowingCalloutTitle', {
defaultMessage: 'Field shadowing',
})}
color="warning"
iconType="pin"
size="s"
data-test-subj="shadowingFieldCallout"
>
<div>
{i18n.translate('xpack.runtimeFields.form.fieldShadowingCalloutDescription', {
defaultMessage:
'This field shares the name of a mapped field. Values for this field will be returned in search results.',
})}
</div>
</EuiCallOut>
</>
)}
<EuiSpacer size="l" />
{/* Script */}
<UseField<string> path="script">
<UseField<string> path="script.source">
{({ value, setValue, label, isValid, getErrorsMessages }) => {
return (
<EuiFormRow
@ -106,7 +221,7 @@ const RuntimeFieldFormComp = ({ defaultValue, onChange, links }: Props) => {
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiLink
href={links.painlessSyntax}
href={links.runtimePainless}
target="_blank"
external
data-test-subj="painlessSyntaxLearnMoreLink"
@ -137,6 +252,9 @@ const RuntimeFieldFormComp = ({ defaultValue, onChange, links }: Props) => {
automaticLayout: true,
}}
data-test-subj="scriptField"
aria-label={i18n.translate('xpack.runtimeFields.form.scriptEditorAriaLabel', {
defaultMessage: 'Script editor',
})}
/>
</EuiFormRow>
);

View file

@ -42,17 +42,10 @@ export const schema: FormSchema<RuntimeField> = {
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.',
})
),
},
],
source: {
label: i18n.translate('xpack.runtimeFields.form.defineFieldLabel', {
defaultMessage: 'Define field (optional)',
}),
},
},
};

View file

@ -7,6 +7,7 @@ import { RuntimeFieldsPlugin } from './plugin';
export {
RuntimeFieldEditorFlyoutContent,
RuntimeFieldEditorFlyoutContentProps,
RuntimeFieldEditor,
RuntimeFieldFormState,
} from './components';

View file

@ -8,9 +8,11 @@ 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 esDocsBase = `${docsBase}/elasticsearch/reference/${DOC_LINK_VERSION}`;
const painlessDocsBase = `${docsBase}/elasticsearch/painless/${DOC_LINK_VERSION}`;
return {
runtimePainless: `${esDocsBase}/runtime.html#runtime-mapping-fields`,
painlessSyntax: `${painlessDocsBase}/painless-lang-spec.html`,
};
};

View file

@ -8,10 +8,12 @@ import { CoreSetup, OverlayRef } from 'src/core/public';
import { toMountPoint, createKibanaReactContext } from './shared_imports';
import { LoadEditorResponse, RuntimeField } from './types';
import { RuntimeFieldEditorFlyoutContentProps } from './components';
export interface OpenRuntimeFieldEditorProps {
onSave(field: RuntimeField): void;
defaultValue?: RuntimeField;
defaultValue?: RuntimeFieldEditorFlyoutContentProps['defaultValue'];
ctx?: RuntimeFieldEditorFlyoutContentProps['ctx'];
}
export const getRuntimeFieldEditorLoader = (
@ -24,10 +26,12 @@ export const getRuntimeFieldEditorLoader = (
let overlayRef: OverlayRef | null = null;
const openEditor = ({ onSave, defaultValue }: OpenRuntimeFieldEditorProps) => {
const openEditor = ({ onSave, defaultValue, ctx }: OpenRuntimeFieldEditorProps) => {
const closeEditor = () => {
overlayRef?.close();
overlayRef = null;
if (overlayRef) {
overlayRef.close();
overlayRef = null;
}
};
const onSaveField = (field: RuntimeField) => {
@ -43,6 +47,7 @@ export const getRuntimeFieldEditorLoader = (
onCancel={() => overlayRef?.close()}
docLinks={docLinks}
defaultValue={defaultValue}
ctx={ctx}
/>
</KibanaReactContextProvider>
)

View file

@ -6,10 +6,13 @@
export {
useForm,
useFormData,
Form,
FormSchema,
UseField,
FormHook,
ValidationFunc,
FieldConfig,
} from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib';
export { fieldValidators } from '../../../../src/plugins/es_ui_shared/static/forms/helpers';

View file

@ -31,7 +31,9 @@ export type RuntimeType = typeof RUNTIME_FIELD_TYPES[number];
export interface RuntimeField {
name: string;
type: RuntimeType;
script: string;
script: {
source: string;
};
}
export interface ComboBoxOption<T = unknown> {