[Index pattern field editor] Add preview for runtime fields (#100198)

This commit is contained in:
Sébastien Loix 2021-08-13 23:27:23 +01:00 committed by GitHub
parent d78d66d424
commit b24d44d165
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
103 changed files with 5263 additions and 734 deletions

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayFlyoutOpenOptions](./kibana-plugin-core-public.overlayflyoutopenoptions.md) &gt; ["aria-label"](./kibana-plugin-core-public.overlayflyoutopenoptions._aria-label_.md)
## OverlayFlyoutOpenOptions."aria-label" property
<b>Signature:</b>
```typescript
'aria-label'?: string;
```

View file

@ -15,11 +15,13 @@ export interface OverlayFlyoutOpenOptions
| Property | Type | Description |
| --- | --- | --- |
| ["aria-label"](./kibana-plugin-core-public.overlayflyoutopenoptions._aria-label_.md) | <code>string</code> | |
| ["data-test-subj"](./kibana-plugin-core-public.overlayflyoutopenoptions._data-test-subj_.md) | <code>string</code> | |
| [className](./kibana-plugin-core-public.overlayflyoutopenoptions.classname.md) | <code>string</code> | |
| [closeButtonAriaLabel](./kibana-plugin-core-public.overlayflyoutopenoptions.closebuttonarialabel.md) | <code>string</code> | |
| [hideCloseButton](./kibana-plugin-core-public.overlayflyoutopenoptions.hideclosebutton.md) | <code>boolean</code> | |
| [maxWidth](./kibana-plugin-core-public.overlayflyoutopenoptions.maxwidth.md) | <code>boolean &#124; number &#124; string</code> | |
| [onClose](./kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md) | <code>(flyout: OverlayRef) =&gt; void</code> | EuiFlyout onClose handler. If provided the consumer is responsible for calling flyout.close() to close the flyout; |
| [ownFocus](./kibana-plugin-core-public.overlayflyoutopenoptions.ownfocus.md) | <code>boolean</code> | |
| [size](./kibana-plugin-core-public.overlayflyoutopenoptions.size.md) | <code>EuiFlyoutSize</code> | |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [OverlayFlyoutOpenOptions](./kibana-plugin-core-public.overlayflyoutopenoptions.md) &gt; [onClose](./kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md)
## OverlayFlyoutOpenOptions.onClose property
EuiFlyout onClose handler. If provided the consumer is responsible for calling flyout.close() to close the flyout;
<b>Signature:</b>
```typescript
onClose?: (flyout: OverlayRef) => void;
```

View file

@ -82,9 +82,15 @@ export interface OverlayFlyoutOpenOptions {
closeButtonAriaLabel?: string;
ownFocus?: boolean;
'data-test-subj'?: string;
'aria-label'?: string;
size?: EuiFlyoutSize;
maxWidth?: boolean | number | string;
hideCloseButton?: boolean;
/**
* EuiFlyout onClose handler.
* If provided the consumer is responsible for calling flyout.close() to close the flyout;
*/
onClose?: (flyout: OverlayRef) => void;
}
interface StartDeps {
@ -119,9 +125,17 @@ export class FlyoutService {
this.activeFlyout = flyout;
const onCloseFlyout = () => {
if (options.onClose) {
options.onClose(flyout);
} else {
flyout.close();
}
};
render(
<i18n.Context>
<EuiFlyout {...options} onClose={() => flyout.close()}>
<EuiFlyout {...options} onClose={onCloseFlyout}>
<MountWrapper mount={mount} className="kbnOverlayMountWrapper" />
</EuiFlyout>
</i18n.Context>,

View file

@ -1006,6 +1006,8 @@ export interface OverlayBannersStart {
// @public (undocumented)
export interface OverlayFlyoutOpenOptions {
// (undocumented)
'aria-label'?: string;
// (undocumented)
'data-test-subj'?: string;
// (undocumented)
@ -1016,6 +1018,7 @@ export interface OverlayFlyoutOpenOptions {
hideCloseButton?: boolean;
// (undocumented)
maxWidth?: boolean | number | string;
onClose?: (flyout: OverlayRef) => void;
// (undocumented)
ownFocus?: boolean;
// (undocumented)

View file

@ -6,4 +6,12 @@
* Side Public License, v 1.
*/
export const RUNTIME_FIELD_TYPES = ['keyword', 'long', 'double', 'date', 'ip', 'boolean'] as const;
export const RUNTIME_FIELD_TYPES = [
'keyword',
'long',
'double',
'date',
'ip',
'boolean',
'geo_point',
] as const;

View file

@ -0,0 +1,51 @@
---
id: formLibCoreUseFormIsModified
slug: /form-lib/core/use-form-is-modified
title: useFormIsModified()
summary: Know when your form has been modified by the user
tags: ['forms', 'kibana', 'dev']
date: 2021-06-15
---
**Returns:** `boolean`
There might be cases where you need to know if the form has been modified by the user. For example: the user is about to leave the form after making some changes, you might want to show a modal indicating that the changes will be lost.
For that you can use the `useFormIsModified` hook which will update each time any of the field value changes. If the user makes a change and then undoes the change and puts the initial value back, the form **won't be marked** as modified.
**Important:** If you form dynamically adds and removes fields, the `isModified` state will be set to `true` when a field is removed from the DOM **only** if it was declared in the form initial `defaultValue` object.
## Options
### form
**Type:** `FormHook`
The form hook object. It is only required to provide the form hook object in your **root form component**.
```js
const RootFormComponent = () => {
// root form component, where the form object is declared
const { form } = useForm();
const isModified = useFormIsModified({ form });
return (
<Form form={form}>
<ChildComponent />
</Form>
);
};
const ChildComponent = () => {
const isModified = useFormIsModified(); // no need to provide the form object
return (
<div>...</div>
);
};
```
### discard
**Type:** `string[]`
If there are certain fields that you want to discard when checking if the form has been modified you can provide an array of field paths to the `discard` option.

View file

@ -10,7 +10,7 @@ import React, { useEffect, FunctionComponent } from 'react';
import { act } from 'react-dom/test-utils';
import { registerTestBed, TestBed } from '../shared_imports';
import { FormHook, OnUpdateHandler, FieldConfig } from '../types';
import { FormHook, OnUpdateHandler, FieldConfig, FieldHook } from '../types';
import { useForm } from '../hooks/use_form';
import { Form } from './form';
import { UseField } from './use_field';
@ -54,6 +54,145 @@ describe('<UseField />', () => {
});
});
describe('state', () => {
describe('isPristine, isDirty, isModified', () => {
// Dummy component to handle object type data
const ObjectField: React.FC<{ field: FieldHook }> = ({ field: { setValue } }) => {
const onFieldChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Make sure to set the field value to an **object**
setValue(JSON.parse(e.target.value));
};
return <input onChange={onFieldChange} data-test-subj="testField" />;
};
interface FieldState {
isModified: boolean;
isDirty: boolean;
isPristine: boolean;
value: unknown;
}
const getChildrenFunc = (
onStateChange: (state: FieldState) => void,
Component?: React.ComponentType<{ field: FieldHook }>
) => {
// This is the children passed down to the <UseField path="name" /> of our form
const childrenFunc = (field: FieldHook) => {
const { onChange, isModified, isPristine, isDirty, value } = field;
// Forward the field state to our jest.fn() spy
onStateChange({ isModified, isPristine, isDirty, value });
// Render the child component if any (useful to test the Object field type)
return Component ? (
<Component field={field} />
) : (
<input onChange={onChange} data-test-subj="testField" />
);
};
return childrenFunc;
};
interface Props {
fieldProps: Record<string, any>;
}
const TestComp = ({ fieldProps }: Props) => {
const { form } = useForm();
return (
<Form form={form}>
<UseField path="name" {...fieldProps} />
</Form>
);
};
const onStateChangeSpy = jest.fn<void, [FieldState]>();
const lastFieldState = (): FieldState =>
onStateChangeSpy.mock.calls[onStateChangeSpy.mock.calls.length - 1][0];
const toString = (value: unknown): string =>
typeof value === 'string' ? value : JSON.stringify(value);
const setup = registerTestBed(TestComp, {
defaultProps: { onStateChangeSpy },
memoryRouter: { wrapComponent: false },
});
[
{
description: 'should update the state for field without default values',
initialValue: '',
changedValue: 'changed',
fieldProps: { children: getChildrenFunc(onStateChangeSpy) },
},
{
description: 'should update the state for field with default value in their config',
initialValue: 'initialValue',
changedValue: 'changed',
fieldProps: {
children: getChildrenFunc(onStateChangeSpy),
config: { defaultValue: 'initialValue' },
},
},
{
description: 'should update the state for field with default value passed through props',
initialValue: 'initialValue',
changedValue: 'changed',
fieldProps: {
children: getChildrenFunc(onStateChangeSpy),
defaultValue: 'initialValue',
},
},
// "Object" field type must be JSON.serialized to compare old and new value
// this test makes sure this is done and "isModified" is indeed "false" when
// putting back the original object
{
description: 'should update the state for field with object field type',
initialValue: { initial: 'value' },
changedValue: { foo: 'bar' },
fieldProps: {
children: getChildrenFunc(onStateChangeSpy, ObjectField),
defaultValue: { initial: 'value' },
},
},
].forEach(({ description, fieldProps, initialValue, changedValue }) => {
test(description, async () => {
const { form } = await setup({ fieldProps });
expect(lastFieldState()).toEqual({
isPristine: true,
isDirty: false,
isModified: false,
value: initialValue,
});
await act(async () => {
form.setInputValue('testField', toString(changedValue));
});
expect(lastFieldState()).toEqual({
isPristine: false,
isDirty: true,
isModified: true,
value: changedValue,
});
// Put back to the initial value --> isModified should be false
await act(async () => {
form.setInputValue('testField', toString(initialValue));
});
expect(lastFieldState()).toEqual({
isPristine: false,
isDirty: true,
isModified: false,
value: initialValue,
});
});
});
});
});
describe('validation', () => {
let formHook: FormHook | null = null;

View file

@ -21,9 +21,15 @@ export const FormProvider = ({ children, form }: Props) => (
<FormContext.Provider value={form}>{children}</FormContext.Provider>
);
export const useFormContext = function <T extends FormData = FormData>() {
interface Options {
throwIfNotFound?: boolean;
}
export const useFormContext = function <T extends FormData = FormData>({
throwIfNotFound = true,
}: Options = {}) {
const context = useContext(FormContext) as FormHook<T>;
if (context === undefined) {
if (throwIfNotFound && context === undefined) {
throw new Error('useFormContext must be used within a <FormProvider />');
}
return context;

View file

@ -9,3 +9,4 @@
export { useField, InternalFieldConfig } from './use_field';
export { useForm } from './use_form';
export { useFormData } from './use_form_data';
export { useFormIsModified } from './use_form_is_modified';

View file

@ -34,7 +34,7 @@ export const useField = <T, FormType = FormData, I = T>(
const {
type = FIELD_TYPES.TEXT,
defaultValue = '', // The value to use a fallback mecanism when no initial value is passed
initialValue = config.defaultValue ?? '', // The value explicitly passed
initialValue = config.defaultValue ?? (('' as unknown) as I), // The value explicitly passed
isIncludedInOutput = true,
label = '',
labelAppend = '',
@ -70,6 +70,7 @@ export const useField = <T, FormType = FormData, I = T>(
const [value, setStateValue] = useState<I>(deserializeValue);
const [errors, setStateErrors] = useState<ValidationError[]>([]);
const [isPristine, setPristine] = useState(true);
const [isModified, setIsModified] = useState(false);
const [isValidating, setValidating] = useState(false);
const [isChangingValue, setIsChangingValue] = useState(false);
const [isValidated, setIsValidated] = useState(false);
@ -476,58 +477,26 @@ export const useField = <T, FormType = FormData, I = T>(
[errors]
);
/**
* Handler to update the state and make sure the component is still mounted.
* When resetting the form, some field might get unmounted (e.g. a toggle on "true" becomes "false" and now certain fields should not be in the DOM).
* In that scenario there is a race condition in the "reset" method below, because the useState() hook is not synchronous.
*
* A better approach would be to have the state in a reducer and being able to update all values in a single dispatch action.
*/
const updateStateIfMounted = useCallback(
(
state: 'isPristine' | 'isValidating' | 'isChangingValue' | 'isValidated' | 'errors' | 'value',
nextValue: any
) => {
if (isMounted.current === false) {
return;
}
switch (state) {
case 'value':
return setValue(nextValue);
case 'errors':
return setStateErrors(nextValue);
case 'isChangingValue':
return setIsChangingValue(nextValue);
case 'isPristine':
return setPristine(nextValue);
case 'isValidated':
return setIsValidated(nextValue);
case 'isValidating':
return setValidating(nextValue);
}
},
[setValue]
);
const reset: FieldHook<T, I>['reset'] = useCallback(
(resetOptions = { resetValue: true }) => {
const { resetValue = true, defaultValue: updatedDefaultValue } = resetOptions;
updateStateIfMounted('isPristine', true);
updateStateIfMounted('isValidating', false);
updateStateIfMounted('isChangingValue', false);
updateStateIfMounted('isValidated', false);
updateStateIfMounted('errors', []);
setPristine(true);
setIsModified(false);
setValidating(false);
setIsChangingValue(false);
setIsValidated(false);
setStateErrors([]);
if (resetValue) {
hasBeenReset.current = true;
const newValue = deserializeValue(updatedDefaultValue ?? defaultValue);
updateStateIfMounted('value', newValue);
// updateStateIfMounted('value', newValue);
setValue(newValue);
return newValue;
}
},
[updateStateIfMounted, deserializeValue, defaultValue]
[deserializeValue, defaultValue, setValue, setStateErrors]
);
// Don't take into account non blocker validation. Some are just warning (like trying to add a wrong ComboBox item)
@ -543,6 +512,8 @@ export const useField = <T, FormType = FormData, I = T>(
value,
errors,
isPristine,
isDirty: !isPristine,
isModified,
isValid,
isValidating,
isValidated,
@ -565,6 +536,7 @@ export const useField = <T, FormType = FormData, I = T>(
helpText,
value,
isPristine,
isModified,
errors,
isValid,
isValidating,
@ -617,6 +589,15 @@ export const useField = <T, FormType = FormData, I = T>(
};
}, [onValueChange]);
useEffect(() => {
setIsModified(() => {
if (typeof value === 'object') {
return JSON.stringify(value) !== JSON.stringify(initialValue);
}
return value !== initialValue;
});
}, [value, initialValue]);
useEffect(() => {
if (!isMounted.current) {
return;

View file

@ -61,6 +61,7 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
const [isValid, setIsValid] = useState<boolean | undefined>(undefined);
const fieldsRefs = useRef<FieldsMap>({});
const fieldsRemovedRefs = useRef<FieldsMap>({});
const formUpdateSubscribers = useRef<Subscription[]>([]);
const isMounted = useRef<boolean>(false);
const defaultValueDeserialized = useRef(defaultValueMemoized);
@ -213,6 +214,7 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
(field) => {
const fieldExists = fieldsRefs.current[field.path] !== undefined;
fieldsRefs.current[field.path] = field;
delete fieldsRemovedRefs.current[field.path];
updateFormDataAt(field.path, field.value);
@ -235,6 +237,10 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
const currentFormData = { ...getFormData$().value };
fieldNames.forEach((name) => {
// Keep a track of the fields that have been removed from the form
// This will allow us to know if the form has been modified
fieldsRemovedRefs.current[name] = fieldsRefs.current[name];
delete fieldsRefs.current[name];
delete currentFormData[name];
});
@ -257,6 +263,11 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
[getFormData$, updateFormData$, fieldsToArray]
);
const getFormDefaultValue: FormHook<T, I>['__getFormDefaultValue'] = useCallback(
() => defaultValueDeserialized.current,
[]
);
const readFieldConfigFromSchema: FormHook<T, I>['__readFieldConfigFromSchema'] = useCallback(
(fieldName) => {
const config = (get(schema ?? {}, fieldName) as FieldConfig) || {};
@ -266,6 +277,11 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
[schema]
);
const getFieldsRemoved: FormHook<T, I>['getFields'] = useCallback(
() => fieldsRemovedRefs.current,
[]
);
// ----------------------------------
// -- Public API
// ----------------------------------
@ -440,8 +456,10 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
__updateFormDataAt: updateFormDataAt,
__updateDefaultValueAt: updateDefaultValueAt,
__readFieldConfigFromSchema: readFieldConfigFromSchema,
__getFormDefaultValue: getFormDefaultValue,
__addField: addField,
__removeField: removeField,
__getFieldsRemoved: getFieldsRemoved,
__validateFields: validateFields,
};
}, [
@ -454,8 +472,10 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
setFieldValue,
setFieldErrors,
getFields,
getFieldsRemoved,
getFormData,
getErrors,
getFormDefaultValue,
getFieldDefaultValue,
reset,
formOptions,

View file

@ -0,0 +1,125 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useState } from 'react';
import { act } from 'react-dom/test-utils';
import { registerTestBed } from '../shared_imports';
import { useForm } from './use_form';
import { useFormIsModified } from './use_form_is_modified';
import { Form } from '../components/form';
import { UseField } from '../components/use_field';
describe('useFormIsModified()', () => {
interface Props {
onIsModifiedChange: (isModified: boolean) => void;
discard?: string[];
}
// We don't add the "lastName" field on purpose to test that we don't set the
// form "isModified" to true for fields that are not declared in the
// and that we remove from the DOM
const formDefaultValue = {
user: {
name: 'initialValue',
},
toDiscard: 'initialValue',
};
const TestComp = ({ onIsModifiedChange, discard = [] }: Props) => {
const { form } = useForm({ defaultValue: formDefaultValue });
const isModified = useFormIsModified({ form, discard });
const [isNameVisible, setIsNameVisible] = useState(true);
const [isLastNameVisible, setIsLastNameVisible] = useState(true);
// Call our jest.spy() with the latest hook value
onIsModifiedChange(isModified);
return (
<Form form={form}>
{isNameVisible && <UseField path="user.name" data-test-subj="nameField" />}
{isLastNameVisible && <UseField path="user.lastName" data-test-subj="lastNameField" />}
<UseField path="toDiscard" data-test-subj="toDiscardField" />
<button data-test-subj="hideNameButton" onClick={() => setIsNameVisible((prev) => !prev)}>
Toggle show/hide name
</button>
<button
data-test-subj="hideLastNameButton"
onClick={() => setIsLastNameVisible((prev) => !prev)}
>
Toggle show/hide lastname
</button>
</Form>
);
};
const onIsModifiedChange = jest.fn();
const isFormModified = () =>
onIsModifiedChange.mock.calls[onIsModifiedChange.mock.calls.length - 1][0];
const setup = registerTestBed(TestComp, {
defaultProps: { onIsModifiedChange },
memoryRouter: { wrapComponent: false },
});
test('should return true **only** when the field value differs from its initial value', async () => {
const { form } = await setup();
expect(isFormModified()).toBe(false);
await act(async () => {
form.setInputValue('nameField', 'changed');
});
expect(isFormModified()).toBe(true);
// Put back to the initial value --> isModified should be false
await act(async () => {
form.setInputValue('nameField', 'initialValue');
});
expect(isFormModified()).toBe(false);
});
test('should accepts a list of field to discard', async () => {
const { form } = await setup({ discard: ['toDiscard'] });
expect(isFormModified()).toBe(false);
await act(async () => {
form.setInputValue('toDiscardField', 'changed');
});
// It should still not be modififed
expect(isFormModified()).toBe(false);
});
test('should take into account if a field is removed from the DOM **and** it existed on the form "defaultValue"', async () => {
const { find } = await setup();
expect(isFormModified()).toBe(false);
await act(async () => {
find('hideNameButton').simulate('click');
});
expect(isFormModified()).toBe(true);
// Put back the name
await act(async () => {
find('hideNameButton').simulate('click');
});
expect(isFormModified()).toBe(false);
// Hide the lastname which is **not** in the form defaultValue
// this it won't set the form isModified to true
await act(async () => {
find('hideLastNameButton').simulate('click');
});
expect(isFormModified()).toBe(false);
});
});

View file

@ -0,0 +1,97 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { useMemo } from 'react';
import { get } from 'lodash';
import { FieldHook, FormHook } from '../types';
import { useFormContext } from '../form_context';
import { useFormData } from './use_form_data';
interface Options {
form?: FormHook<any>;
/** List of field paths to discard when checking if a field has been modified */
discard?: string[];
}
/**
* Hook to detect if any of the form fields have been modified by the user.
* If a field is modified and then the value is changed back to the initial value
* the form **won't be marked as modified**.
* This is useful to detect if a form has changed and we need to display a confirm modal
* to the user before he navigates away and loses his changes.
*
* @param options - Optional options object
* @returns flag to indicate if the form has been modified
*/
export const useFormIsModified = ({
form: formFromOptions,
discard = [],
}: Options = {}): boolean => {
// As hook calls can not be conditional we first try to access the form through context
let form = useFormContext({ throwIfNotFound: false });
if (formFromOptions) {
form = formFromOptions;
}
if (!form) {
throw new Error(
`useFormIsModified() used outside the form context and no form was provided in the options.`
);
}
const { getFields, __getFieldsRemoved, __getFormDefaultValue } = form;
const discardToString = JSON.stringify(discard);
// Create a map of the fields to discard to optimize look up
const fieldsToDiscard = useMemo(() => {
if (discard.length === 0) {
return;
}
return discard.reduce((acc, path) => ({ ...acc, [path]: {} }), {} as { [key: string]: {} });
// discardToString === discard, we don't want to add it to the deps so we
// the coansumer does not need to memoize the array he provides.
}, [discardToString]); // eslint-disable-line react-hooks/exhaustive-deps
// We listen to all the form data change to trigger a re-render
// and update our derived "isModified" state
useFormData({ form });
let predicate: (arg: [string, FieldHook]) => boolean = () => true;
if (fieldsToDiscard) {
predicate = ([path]) => fieldsToDiscard[path] === undefined;
}
let isModified = Object.entries(getFields())
.filter(predicate)
.some(([_, field]) => field.isModified);
if (isModified) {
return isModified;
}
// Check if any field has been removed.
// If somme field has been removed **and** they were originaly present on the
// form "defaultValue" then the form has been modified.
const formDefaultValue = __getFormDefaultValue();
const fieldOnFormDefaultValue = (path: string) => Boolean(get(formDefaultValue, path));
const fieldsRemovedFromDOM: string[] = fieldsToDiscard
? Object.keys(__getFieldsRemoved())
.filter((path) => fieldsToDiscard[path] === undefined)
.filter(fieldOnFormDefaultValue)
: Object.keys(__getFieldsRemoved()).filter(fieldOnFormDefaultValue);
isModified = fieldsRemovedFromDOM.length > 0;
return isModified;
};

View file

@ -6,9 +6,9 @@
* Side Public License, v 1.
*/
// Only export the useForm hook. The "useField" hook is for internal use
// as the consumer of the library must use the <UseField /> component
export { useForm, useFormData } from './hooks';
// We don't export the "useField" hook as it is for internal use.
// The consumer of the library must use the <UseField /> component to create a field
export { useForm, useFormData, useFormIsModified } from './hooks';
export { getFieldValidityAndErrorMessage } from './helpers';
export * from './form_context';

View file

@ -62,6 +62,8 @@ export interface FormHook<T extends FormData = FormData, I extends FormData = T>
__updateFormDataAt: (field: string, value: unknown) => void;
__updateDefaultValueAt: (field: string, value: unknown) => void;
__readFieldConfigFromSchema: (field: string) => FieldConfig;
__getFormDefaultValue: () => FormData;
__getFieldsRemoved: () => FieldsMap;
}
export type FormSchema<T extends FormData = FormData> = {
@ -109,6 +111,8 @@ export interface FieldHook<T = unknown, I = T> {
readonly errors: ValidationError[];
readonly isValid: boolean;
readonly isPristine: boolean;
readonly isDirty: boolean;
readonly isModified: boolean;
readonly isValidating: boolean;
readonly isValidated: boolean;
readonly isChangingValue: boolean;

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { act } from 'react-dom/test-utils';
import { registerTestBed, TestBed } from '@kbn/test/jest';
import { Context } from '../../public/components/field_editor_context';
import { FieldEditor, Props } from '../../public/components/field_editor/field_editor';
import { WithFieldEditorDependencies, getCommonActions } from './helpers';
export const defaultProps: Props = {
onChange: jest.fn(),
syntaxError: {
error: null,
clear: () => {},
},
};
export type FieldEditorTestBed = TestBed & { actions: ReturnType<typeof getCommonActions> };
export const setup = async (props?: Partial<Props>, deps?: Partial<Context>) => {
let testBed: TestBed<string>;
await act(async () => {
testBed = await registerTestBed(WithFieldEditorDependencies(FieldEditor, deps), {
memoryRouter: {
wrapComponent: false,
},
})({ ...defaultProps, ...props });
});
testBed!.component.update();
const actions = {
...getCommonActions(testBed!),
};
return { ...testBed!, actions };
};

View file

@ -5,65 +5,25 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useState, useMemo } from 'react';
import { act } from 'react-dom/test-utils';
import { registerTestBed, TestBed } from '@kbn/test/jest';
import '../../test_utils/setup_environment';
import { registerTestBed, TestBed, getCommonActions } from '../../test_utils';
import { RuntimeFieldPainlessError } from '../../lib';
import { Field } from '../../types';
import { FieldEditor, Props, FieldEditorFormState } from './field_editor';
import { docLinksServiceMock } from '../../../../../core/public/mocks';
const defaultProps: Props = {
onChange: jest.fn(),
links: docLinksServiceMock.createStartContract() as any,
ctx: {
existingConcreteFields: [],
namesNotAllowed: [],
fieldTypeToProcess: 'runtime',
},
indexPattern: { fields: [] } as any,
fieldFormatEditors: {
getAll: () => [],
getById: () => undefined,
},
fieldFormats: {} as any,
uiSettings: {} as any,
syntaxError: {
error: null,
clear: () => {},
},
};
const setup = (props?: Partial<Props>) => {
const testBed = registerTestBed(FieldEditor, {
memoryRouter: {
wrapComponent: false,
},
})({ ...defaultProps, ...props }) as TestBed;
const actions = {
...getCommonActions(testBed),
};
return {
...testBed,
actions,
};
};
// This import needs to come first as it contains the jest.mocks
import { setupEnvironment, getCommonActions, WithFieldEditorDependencies } from './helpers';
import {
FieldEditor,
FieldEditorFormState,
Props,
} from '../../public/components/field_editor/field_editor';
import type { Field } from '../../public/types';
import type { RuntimeFieldPainlessError } from '../../public/lib';
import { setup, FieldEditorTestBed, defaultProps } from './field_editor.helpers';
describe('<FieldEditor />', () => {
beforeAll(() => {
jest.useFakeTimers();
});
const { server, httpRequestsMockHelpers } = setupEnvironment();
afterAll(() => {
jest.useRealTimers();
});
let testBed: TestBed & { actions: ReturnType<typeof getCommonActions> };
let testBed: FieldEditorTestBed;
let onChange: jest.Mock<Props['onChange']> = jest.fn();
const lastOnChangeCall = (): FieldEditorFormState[] =>
@ -104,12 +64,22 @@ describe('<FieldEditor />', () => {
return formState!;
};
beforeEach(() => {
onChange = jest.fn();
beforeAll(() => {
jest.useFakeTimers();
});
test('initial state should have "set custom label", "set value" and "set format" turned off', () => {
testBed = setup();
afterAll(() => {
jest.useRealTimers();
server.restore();
});
beforeEach(async () => {
onChange = jest.fn();
httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['mockedScriptValue'] });
});
test('initial state should have "set custom label", "set value" and "set format" turned off', async () => {
testBed = await setup();
['customLabel', 'value', 'format'].forEach((row) => {
const testSubj = `${row}Row.toggle`;
@ -132,7 +102,7 @@ describe('<FieldEditor />', () => {
script: { source: 'emit("hello")' },
};
testBed = setup({ onChange, field });
testBed = await setup({ onChange, field });
expect(onChange).toHaveBeenCalled();
@ -153,25 +123,22 @@ describe('<FieldEditor />', () => {
describe('validation', () => {
test('should accept an optional list of existing fields and prevent creating duplicates', async () => {
const existingFields = ['myRuntimeField'];
testBed = setup({
onChange,
ctx: {
testBed = await setup(
{
onChange,
},
{
namesNotAllowed: existingFields,
existingConcreteFields: [],
fieldTypeToProcess: 'runtime',
},
});
}
);
const { form, component, actions } = testBed;
await act(async () => {
actions.toggleFormRow('value');
});
await act(async () => {
form.setInputValue('nameField.input', existingFields[0]);
form.setInputValue('scriptField', 'echo("hello")');
});
await actions.toggleFormRow('value');
await actions.fields.updateName(existingFields[0]);
await actions.fields.updateScript('echo("hello")');
await act(async () => {
jest.advanceTimersByTime(1000); // Make sure our debounced error message is in the DOM
@ -192,20 +159,23 @@ describe('<FieldEditor />', () => {
script: { source: 'emit("hello"' },
};
testBed = setup({
field,
onChange,
ctx: {
testBed = await setup(
{
field,
onChange,
},
{
namesNotAllowed: existingRuntimeFieldNames,
existingConcreteFields: [],
fieldTypeToProcess: 'runtime',
},
});
}
);
const { form, component } = testBed;
const lastState = getLastStateUpdate();
await submitFormAndGetData(lastState);
component.update();
expect(getLastStateUpdate().isValid).toBe(true);
expect(form.getErrorsMessages()).toEqual([]);
});
@ -217,13 +187,14 @@ describe('<FieldEditor />', () => {
script: { source: 'emit(6)' },
};
const TestComponent = () => {
const dummyError = {
reason: 'Awwww! Painless syntax error',
message: '',
position: { offset: 0, start: 0, end: 0 },
scriptStack: [''],
};
const dummyError = {
reason: 'Awwww! Painless syntax error',
message: '',
position: { offset: 0, start: 0, end: 0 },
scriptStack: [''],
};
const ComponentToProvidePainlessSyntaxErrors = () => {
const [error, setError] = useState<RuntimeFieldPainlessError | null>(null);
const clearError = useMemo(() => () => setError(null), []);
const syntaxError = useMemo(() => ({ error, clear: clearError }), [error, clearError]);
@ -240,22 +211,29 @@ describe('<FieldEditor />', () => {
);
};
const customTestbed = registerTestBed(TestComponent, {
memoryRouter: {
wrapComponent: false,
},
})() as TestBed<string>;
let testBedToCapturePainlessErrors: TestBed<string>;
await act(async () => {
testBedToCapturePainlessErrors = await registerTestBed(
WithFieldEditorDependencies(ComponentToProvidePainlessSyntaxErrors),
{
memoryRouter: {
wrapComponent: false,
},
}
)();
});
testBed = {
...customTestbed,
actions: getCommonActions(customTestbed),
...testBedToCapturePainlessErrors!,
actions: getCommonActions(testBedToCapturePainlessErrors!),
};
const {
form,
component,
find,
actions: { changeFieldType },
actions: { fields },
} = testBed;
// We set some dummy painless error
@ -267,7 +245,7 @@ describe('<FieldEditor />', () => {
expect(form.getErrorsMessages()).toEqual(['Awwww! Painless syntax error']);
// We change the type and expect the form error to not be there anymore
await changeFieldType('keyword');
await fields.updateType('keyword');
expect(form.getErrorsMessages()).toEqual([]);
});
});

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { act } from 'react-dom/test-utils';
import { registerTestBed, TestBed } from '@kbn/test/jest';
import { Context } from '../../public/components/field_editor_context';
import {
FieldEditorFlyoutContent,
Props,
} from '../../public/components/field_editor_flyout_content';
import { WithFieldEditorDependencies, getCommonActions } from './helpers';
const defaultProps: Props = {
onSave: () => {},
onCancel: () => {},
runtimeFieldValidator: () => Promise.resolve(null),
isSavingField: false,
};
const getActions = (testBed: TestBed) => {
return {
...getCommonActions(testBed),
};
};
export const setup = async (props?: Partial<Props>, deps?: Partial<Context>) => {
let testBed: TestBed;
// Setup testbed
await act(async () => {
testBed = await registerTestBed(WithFieldEditorDependencies(FieldEditorFlyoutContent, deps), {
memoryRouter: {
wrapComponent: false,
},
})({ ...defaultProps, ...props });
});
testBed!.component.update();
return { ...testBed!, actions: getActions(testBed!) };
};

View file

@ -7,58 +7,30 @@
*/
import { act } from 'react-dom/test-utils';
import '../test_utils/setup_environment';
import { registerTestBed, TestBed, noop, getCommonActions } from '../test_utils';
import { FieldEditor } from './field_editor';
import { FieldEditorFlyoutContent, Props } from './field_editor_flyout_content';
import { docLinksServiceMock } from '../../../../core/public/mocks';
const defaultProps: Props = {
onSave: noop,
onCancel: noop,
docLinks: docLinksServiceMock.createStartContract() as any,
FieldEditor,
indexPattern: { fields: [] } as any,
uiSettings: {} as any,
fieldFormats: {} as any,
fieldFormatEditors: {} as any,
fieldTypeToProcess: 'runtime',
runtimeFieldValidator: () => Promise.resolve(null),
isSavingField: false,
};
const setup = (props: Props = defaultProps) => {
const testBed = registerTestBed(FieldEditorFlyoutContent, {
memoryRouter: { wrapComponent: false },
})(props) as TestBed;
const actions = {
...getCommonActions(testBed),
};
return {
...testBed,
actions,
};
};
import type { Props } from '../../public/components/field_editor_flyout_content';
import { setupEnvironment } from './helpers';
import { setup } from './field_editor_flyout_content.helpers';
describe('<FieldEditorFlyoutContent />', () => {
const { server, httpRequestsMockHelpers } = setupEnvironment();
beforeAll(() => {
httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['foo'] });
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
server.restore();
});
test('should have the correct title', () => {
const { exists, find } = setup();
test('should have the correct title', async () => {
const { exists, find } = await setup();
expect(exists('flyoutTitle')).toBe(true);
expect(find('flyoutTitle').text()).toBe('Create field');
});
test('should allow a field to be provided', () => {
test('should allow a field to be provided', async () => {
const field = {
name: 'foo',
type: 'ip',
@ -67,7 +39,7 @@ describe('<FieldEditorFlyoutContent />', () => {
},
};
const { find } = setup({ ...defaultProps, field });
const { find } = await setup({ field });
expect(find('flyoutTitle').text()).toBe(`Edit field 'foo'`);
expect(find('nameField.input').props().value).toBe(field.name);
@ -83,7 +55,7 @@ describe('<FieldEditorFlyoutContent />', () => {
};
const onSave: jest.Mock<Props['onSave']> = jest.fn();
const { find } = setup({ ...defaultProps, onSave, field });
const { find } = await setup({ onSave, field });
await act(async () => {
find('fieldSaveButton').simulate('click');
@ -100,9 +72,9 @@ describe('<FieldEditorFlyoutContent />', () => {
expect(fieldReturned).toEqual(field);
});
test('should accept an onCancel prop', () => {
test('should accept an onCancel prop', async () => {
const onCancel = jest.fn();
const { find } = setup({ ...defaultProps, onCancel });
const { find } = await setup({ onCancel });
find('closeFlyoutButton').simulate('click');
@ -113,7 +85,7 @@ describe('<FieldEditorFlyoutContent />', () => {
test('should validate the fields and prevent saving invalid form', async () => {
const onSave: jest.Mock<Props['onSave']> = jest.fn();
const { find, exists, form, component } = setup({ ...defaultProps, onSave });
const { find, exists, form, component } = await setup({ onSave });
expect(find('fieldSaveButton').props().disabled).toBe(false);
@ -139,20 +111,12 @@ describe('<FieldEditorFlyoutContent />', () => {
const {
find,
component,
form,
actions: { toggleFormRow, changeFieldType },
} = setup({ ...defaultProps, onSave });
actions: { toggleFormRow, fields },
} = await setup({ onSave });
act(() => {
form.setInputValue('nameField.input', 'someName');
toggleFormRow('value');
});
component.update();
await act(async () => {
form.setInputValue('scriptField', 'echo("hello")');
});
await fields.updateName('someName');
await toggleFormRow('value');
await fields.updateScript('echo("hello")');
await act(async () => {
// Let's make sure that validation has finished running
@ -174,7 +138,7 @@ describe('<FieldEditorFlyoutContent />', () => {
});
// Change the type and make sure it is forwarded
await changeFieldType('other_type', 'Other type');
await fields.updateType('other_type', 'Other type');
await act(async () => {
find('fieldSaveButton').simulate('click');

View file

@ -0,0 +1,185 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { act } from 'react-dom/test-utils';
import { ReactWrapper } from 'enzyme';
import { registerTestBed, TestBed } from '@kbn/test/jest';
import { API_BASE_PATH } from '../../common/constants';
import { Context } from '../../public/components/field_editor_context';
import {
FieldEditorFlyoutContent,
Props,
} from '../../public/components/field_editor_flyout_content';
import {
WithFieldEditorDependencies,
getCommonActions,
spyIndexPatternGetAllFields,
spySearchQuery,
spySearchQueryResponse,
} from './helpers';
const defaultProps: Props = {
onSave: () => {},
onCancel: () => {},
runtimeFieldValidator: () => Promise.resolve(null),
isSavingField: false,
};
/**
* This handler lets us mock the fields present on the index pattern during our test
* @param fields The fields of the index pattern
*/
export const setIndexPatternFields = (fields: Array<{ name: string; displayName: string }>) => {
spyIndexPatternGetAllFields.mockReturnValue(fields);
};
export interface TestDoc {
title: string;
subTitle: string;
description: string;
}
export const getSearchCallMeta = () => {
const totalCalls = spySearchQuery.mock.calls.length;
const lastCall = spySearchQuery.mock.calls[totalCalls - 1] ?? null;
let lastCallParams = null;
if (lastCall) {
lastCallParams = lastCall[0];
}
return {
totalCalls,
lastCall,
lastCallParams,
};
};
export const setSearchResponse = (
documents: Array<{ _id: string; _index: string; _source: TestDoc }>
) => {
spySearchQueryResponse.mockResolvedValue({
rawResponse: {
hits: {
total: documents.length,
hits: documents,
},
},
});
};
const getActions = (testBed: TestBed) => {
const getWrapperRenderedIndexPatternFields = (): ReactWrapper | null => {
if (testBed.find('indexPatternFieldList').length === 0) {
return null;
}
return testBed.find('indexPatternFieldList.listItem');
};
const getRenderedIndexPatternFields = (): Array<{ key: string; value: string }> => {
const allFields = getWrapperRenderedIndexPatternFields();
if (allFields === null) {
return [];
}
return allFields.map((field) => {
const key = testBed.find('key', field).text();
const value = testBed.find('value', field).text();
return { key, value };
});
};
const getRenderedFieldsPreview = () => {
if (testBed.find('fieldPreviewItem').length === 0) {
return [];
}
const previewFields = testBed.find('fieldPreviewItem.listItem');
return previewFields.map((field) => {
const key = testBed.find('key', field).text();
const value = testBed.find('value', field).text();
return { key, value };
});
};
const setFilterFieldsValue = async (value: string) => {
await act(async () => {
testBed.form.setInputValue('filterFieldsInput', value);
});
testBed.component.update();
};
// Need to set "server: any" (instead of SinonFakeServer) to avoid a TS error :(
// Error: Exported variable 'setup' has or is using name 'Document' from external module "/dev/shm/workspace/parallel/14/kibana/node_modules/@types/sinon/ts3.1/index"
const getLatestPreviewHttpRequest = (server: any) => {
let i = server.requests.length - 1;
while (i >= 0) {
const request = server.requests[i];
if (request.method === 'POST' && request.url === `${API_BASE_PATH}/field_preview`) {
return {
...request,
requestBody: JSON.parse(JSON.parse(request.requestBody).body),
};
}
i--;
}
throw new Error(`Can't access the latest preview HTTP request as it hasn't been called.`);
};
const goToNextDocument = async () => {
await act(async () => {
testBed.find('goToNextDocButton').simulate('click');
});
testBed.component.update();
};
const goToPreviousDocument = async () => {
await act(async () => {
testBed.find('goToPrevDocButton').simulate('click');
});
testBed.component.update();
};
const loadCustomDocument = (docId: string) => {};
return {
...getCommonActions(testBed),
getWrapperRenderedIndexPatternFields,
getRenderedIndexPatternFields,
getRenderedFieldsPreview,
setFilterFieldsValue,
getLatestPreviewHttpRequest,
goToNextDocument,
goToPreviousDocument,
loadCustomDocument,
};
};
export const setup = async (props?: Partial<Props>, deps?: Partial<Context>) => {
let testBed: TestBed;
// Setup testbed
await act(async () => {
testBed = await registerTestBed(WithFieldEditorDependencies(FieldEditorFlyoutContent, deps), {
memoryRouter: {
wrapComponent: false,
},
})({ ...defaultProps, ...props });
});
testBed!.component.update();
return { ...testBed!, actions: getActions(testBed!) };
};
export type FieldEditorFlyoutContentTestBed = TestBed & { actions: ReturnType<typeof getActions> };

View file

@ -0,0 +1,890 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { act } from 'react-dom/test-utils';
import { setupEnvironment, fieldFormatsOptions, indexPatternNameForTest } from './helpers';
import {
setup,
setIndexPatternFields,
getSearchCallMeta,
setSearchResponse,
FieldEditorFlyoutContentTestBed,
TestDoc,
} from './field_editor_flyout_preview.helpers';
import { createPreviewError } from './helpers/mocks';
interface EsDoc {
_id: string;
_index: string;
_source: TestDoc;
}
describe('Field editor Preview panel', () => {
const { server, httpRequestsMockHelpers } = setupEnvironment();
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
server.restore();
});
let testBed: FieldEditorFlyoutContentTestBed;
const mockDocuments: EsDoc[] = [
{
_id: '001',
_index: 'testIndex',
_source: {
title: 'First doc - title',
subTitle: 'First doc - subTitle',
description: 'First doc - description',
},
},
{
_id: '002',
_index: 'testIndex',
_source: {
title: 'Second doc - title',
subTitle: 'Second doc - subTitle',
description: 'Second doc - description',
},
},
{
_id: '003',
_index: 'testIndex',
_source: {
title: 'Third doc - title',
subTitle: 'Third doc - subTitle',
description: 'Third doc - description',
},
},
];
const [doc1, doc2, doc3] = mockDocuments;
const indexPatternFields: Array<{ name: string; displayName: string }> = [
{
name: 'title',
displayName: 'title',
},
{
name: 'subTitle',
displayName: 'subTitle',
},
{
name: 'description',
displayName: 'description',
},
];
beforeEach(async () => {
httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['mockedScriptValue'] });
setIndexPatternFields(indexPatternFields);
setSearchResponse(mockDocuments);
testBed = await setup();
});
test('should display the preview panel when either "set value" or "set format" is activated', async () => {
const {
exists,
actions: { toggleFormRow },
} = testBed;
expect(exists('previewPanel')).toBe(false);
await toggleFormRow('value');
expect(exists('previewPanel')).toBe(true);
await toggleFormRow('value', 'off');
expect(exists('previewPanel')).toBe(false);
await toggleFormRow('format');
expect(exists('previewPanel')).toBe(true);
await toggleFormRow('format', 'off');
expect(exists('previewPanel')).toBe(false);
});
test('should correctly set the title and subtitle of the panel', async () => {
const {
find,
actions: { toggleFormRow, fields, waitForUpdates },
} = testBed;
await toggleFormRow('value');
await fields.updateName('myRuntimeField');
await waitForUpdates();
expect(find('previewPanel.title').text()).toBe('Preview');
expect(find('previewPanel.subTitle').text()).toBe(`From: ${indexPatternNameForTest}`);
});
test('should list the list of fields of the index pattern', async () => {
const {
actions: { toggleFormRow, fields, getRenderedIndexPatternFields, waitForUpdates },
} = testBed;
await toggleFormRow('value');
await fields.updateName('myRuntimeField');
await waitForUpdates();
expect(getRenderedIndexPatternFields()).toEqual([
{
key: 'title',
value: mockDocuments[0]._source.title,
},
{
key: 'subTitle',
value: mockDocuments[0]._source.subTitle,
},
{
key: 'description',
value: mockDocuments[0]._source.description,
},
]);
});
test('should filter down the field in the list', async () => {
const {
exists,
find,
component,
actions: {
toggleFormRow,
fields,
setFilterFieldsValue,
getRenderedIndexPatternFields,
waitForUpdates,
},
} = testBed;
await toggleFormRow('value');
await fields.updateName('myRuntimeField');
await waitForUpdates();
// Should find a single field
await setFilterFieldsValue('descr');
expect(getRenderedIndexPatternFields()).toEqual([
{ key: 'description', value: 'First doc - description' },
]);
// Should be case insensitive
await setFilterFieldsValue('title');
expect(exists('emptySearchResult')).toBe(false);
expect(getRenderedIndexPatternFields()).toEqual([
{ key: 'title', value: 'First doc - title' },
{ key: 'subTitle', value: 'First doc - subTitle' },
]);
// Should display an empty search result with a button to clear
await setFilterFieldsValue('doesNotExist');
expect(exists('emptySearchResult')).toBe(true);
expect(getRenderedIndexPatternFields()).toEqual([]);
expect(exists('emptySearchResult.clearSearchButton'));
find('emptySearchResult.clearSearchButton').simulate('click');
component.update();
expect(getRenderedIndexPatternFields()).toEqual([
{
key: 'title',
value: mockDocuments[0]._source.title,
},
{
key: 'subTitle',
value: mockDocuments[0]._source.subTitle,
},
{
key: 'description',
value: mockDocuments[0]._source.description,
},
]);
});
test('should pin the field to the top of the list', async () => {
const {
find,
component,
actions: {
toggleFormRow,
fields,
getWrapperRenderedIndexPatternFields,
getRenderedIndexPatternFields,
waitForUpdates,
},
} = testBed;
await toggleFormRow('value');
await fields.updateName('myRuntimeField');
await waitForUpdates();
const fieldsRendered = getWrapperRenderedIndexPatternFields();
if (fieldsRendered === null) {
throw new Error('No index pattern field rendered.');
}
expect(fieldsRendered.length).toBe(Object.keys(doc1._source).length);
// make sure that the last one if the "description" field
expect(fieldsRendered.at(2).text()).toBe('descriptionFirst doc - description');
// Click the third field in the list ("description")
const descriptionField = fieldsRendered.at(2);
find('pinFieldButton', descriptionField).simulate('click');
component.update();
expect(getRenderedIndexPatternFields()).toEqual([
{ key: 'description', value: 'First doc - description' }, // Pinned!
{ key: 'title', value: 'First doc - title' },
{ key: 'subTitle', value: 'First doc - subTitle' },
]);
});
describe('empty prompt', () => {
test('should display an empty prompt if no name and no script are defined', async () => {
const {
exists,
actions: { toggleFormRow, fields, waitForUpdates },
} = testBed;
await toggleFormRow('value');
expect(exists('previewPanel')).toBe(true);
expect(exists('previewPanel.emptyPrompt')).toBe(true);
await fields.updateName('someName');
await waitForUpdates();
expect(exists('previewPanel.emptyPrompt')).toBe(false);
await fields.updateName(' ');
await waitForUpdates();
expect(exists('previewPanel.emptyPrompt')).toBe(true);
// The name is empty and the empty prompt is displayed, let's now add a script...
await fields.updateScript('echo("hello")');
await waitForUpdates();
expect(exists('previewPanel.emptyPrompt')).toBe(false);
await fields.updateScript(' ');
await waitForUpdates();
expect(exists('previewPanel.emptyPrompt')).toBe(true);
});
test('should **not** display an empty prompt editing a document with a script', async () => {
const field = {
name: 'foo',
type: 'ip',
script: {
source: 'emit("hello world")',
},
};
// We open the editor with a field to edit. The preview panel should be open
// and the empty prompt should not be there as we have a script and we'll load
// the preview.
await act(async () => {
testBed = await setup({ field });
});
const { exists, component } = testBed;
component.update();
expect(exists('previewPanel')).toBe(true);
expect(exists('previewPanel.emptyPrompt')).toBe(false);
});
test('should **not** display an empty prompt editing a document with format defined', async () => {
const field = {
name: 'foo',
type: 'ip',
format: {
id: 'upper',
params: {},
},
};
// We open the editor with a field to edit. The preview panel should be open
// and the empty prompt should not be there as we have a script and we'll load
// the preview.
await act(async () => {
testBed = await setup({ field });
});
const { exists, component } = testBed;
component.update();
expect(exists('previewPanel')).toBe(true);
expect(exists('previewPanel.emptyPrompt')).toBe(false);
});
});
describe('key & value', () => {
test('should set an empty value when no script is provided', async () => {
const {
actions: { toggleFormRow, fields, getRenderedFieldsPreview, waitForUpdates },
} = testBed;
await toggleFormRow('value');
await fields.updateName('myRuntimeField');
await waitForUpdates();
expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: '-' }]);
});
test('should set the value returned by the painless _execute API', async () => {
const scriptEmitResponse = 'Field emit() response';
httpRequestsMockHelpers.setFieldPreviewResponse({ values: [scriptEmitResponse] });
const {
actions: {
toggleFormRow,
fields,
waitForDocumentsAndPreviewUpdate,
getLatestPreviewHttpRequest,
getRenderedFieldsPreview,
},
} = testBed;
await toggleFormRow('value');
await fields.updateName('myRuntimeField');
await fields.updateScript('echo("hello")');
await waitForDocumentsAndPreviewUpdate();
const request = getLatestPreviewHttpRequest(server);
// Make sure the payload sent is correct
expect(request.requestBody).toEqual({
context: 'keyword_field',
document: {
description: 'First doc - description',
subTitle: 'First doc - subTitle',
title: 'First doc - title',
},
index: 'testIndex',
script: {
source: 'echo("hello")',
},
});
// And that we display the response
expect(getRenderedFieldsPreview()).toEqual([
{ key: 'myRuntimeField', value: scriptEmitResponse },
]);
});
test('should display an updating indicator while fetching the preview', async () => {
httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['ok'] });
const {
exists,
actions: { toggleFormRow, fields, waitForUpdates, waitForDocumentsAndPreviewUpdate },
} = testBed;
await toggleFormRow('value');
await waitForUpdates(); // wait for docs to be fetched
expect(exists('isUpdatingIndicator')).toBe(false);
await fields.updateScript('echo("hello")');
expect(exists('isUpdatingIndicator')).toBe(true);
await waitForDocumentsAndPreviewUpdate();
expect(exists('isUpdatingIndicator')).toBe(false);
});
test('should not display the updating indicator when neither the type nor the script has changed', async () => {
httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['ok'] });
const {
exists,
actions: { toggleFormRow, fields, waitForUpdates, waitForDocumentsAndPreviewUpdate },
} = testBed;
await toggleFormRow('value');
await waitForUpdates(); // wait for docs to be fetched
await fields.updateName('myRuntimeField');
await fields.updateScript('echo("hello")');
expect(exists('isUpdatingIndicator')).toBe(true);
await waitForDocumentsAndPreviewUpdate();
expect(exists('isUpdatingIndicator')).toBe(false);
await fields.updateName('nameChanged');
// We haven't changed the type nor the script so there should not be any updating indicator
expect(exists('isUpdatingIndicator')).toBe(false);
});
describe('read from _source', () => {
test('should display the _source value when no script is provided and the name matched one of the fields in _source', async () => {
const {
actions: {
toggleFormRow,
fields,
getRenderedFieldsPreview,
waitForDocumentsAndPreviewUpdate,
},
} = testBed;
await toggleFormRow('value');
await fields.updateName('subTitle');
await waitForDocumentsAndPreviewUpdate();
expect(getRenderedFieldsPreview()).toEqual([
{ key: 'subTitle', value: 'First doc - subTitle' },
]);
});
test('should display the value returned by the _execute API and fallback to _source if "Set value" is turned off', async () => {
httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['valueFromExecuteAPI'] });
const {
actions: { toggleFormRow, fields, waitForUpdates, getRenderedFieldsPreview },
} = testBed;
await toggleFormRow('value');
await waitForUpdates(); // fetch documents
await fields.updateName('description'); // Field name is a field in _source
await fields.updateScript('echo("hello")');
await waitForUpdates(); // fetch preview
// We render the value from the _execute API
expect(getRenderedFieldsPreview()).toEqual([
{ key: 'description', value: 'valueFromExecuteAPI' },
]);
await toggleFormRow('format', 'on');
await toggleFormRow('value', 'off');
// Fallback to _source value when "Set value" is turned off and we have a format
expect(getRenderedFieldsPreview()).toEqual([
{ key: 'description', value: 'First doc - description' },
]);
});
});
});
describe('format', () => {
test('should apply the format to the value', async () => {
/**
* Each of the formatter has already its own test. Here we are simply
* doing a smoke test to make sure that the preview panel applies the formatter
* to the runtime field value.
* We do that by mocking (in "setup_environment.tsx") the implementation of the
* the fieldFormats.getInstance() handler.
*/
const scriptEmitResponse = 'hello';
httpRequestsMockHelpers.setFieldPreviewResponse({ values: [scriptEmitResponse] });
const {
actions: {
toggleFormRow,
fields,
waitForUpdates,
waitForDocumentsAndPreviewUpdate,
getRenderedFieldsPreview,
},
} = testBed;
await fields.updateName('myRuntimeField');
await toggleFormRow('value');
await fields.updateScript('echo("hello")');
await waitForDocumentsAndPreviewUpdate();
// before
expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'hello' }]);
// after
await toggleFormRow('format');
await fields.updateFormat(fieldFormatsOptions[0].id); // select 'upper' format
await waitForUpdates();
expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'HELLO' }]);
});
});
describe('error handling', () => {
test('should display the error returned by the Painless _execute API', async () => {
const error = createPreviewError({ reason: 'Houston we got a problem' });
httpRequestsMockHelpers.setFieldPreviewResponse({ values: [], error, status: 400 });
const {
exists,
find,
actions: {
toggleFormRow,
fields,
waitForUpdates,
waitForDocumentsAndPreviewUpdate,
getRenderedFieldsPreview,
},
} = testBed;
await fields.updateName('myRuntimeField');
await toggleFormRow('value');
await fields.updateScript('bad()');
await waitForDocumentsAndPreviewUpdate();
expect(exists('fieldPreviewItem')).toBe(false);
expect(exists('indexPatternFieldList')).toBe(false);
expect(exists('previewError')).toBe(true);
expect(find('previewError.reason').text()).toBe(error.caused_by.reason);
httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['ok'] });
await fields.updateScript('echo("ok")');
await waitForUpdates();
expect(exists('fieldPreviewItem')).toBe(true);
expect(find('indexPatternFieldList.listItem').length).toBeGreaterThan(0);
expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'ok' }]);
});
test('should handle error when a document is not found', async () => {
const {
exists,
find,
form,
actions: { toggleFormRow, fields, waitForUpdates, waitForDocumentsAndPreviewUpdate },
} = testBed;
await fields.updateName('myRuntimeField');
await toggleFormRow('value');
await waitForDocumentsAndPreviewUpdate();
// We will return no document from the search
setSearchResponse([]);
await act(async () => {
form.setInputValue('documentIdField', 'wrongID');
});
await waitForUpdates();
expect(exists('previewError')).toBe(true);
expect(find('previewError').text()).toContain('Document ID not found');
expect(exists('isUpdatingIndicator')).toBe(false);
});
});
describe('Cluster document load and navigation', () => {
const customLoadedDoc: EsDoc = {
_id: '123456',
_index: 'otherIndex',
_source: {
title: 'loaded doc - title',
subTitle: 'loaded doc - subTitle',
description: 'loaded doc - description',
},
};
test('should update the field list when the document changes', async () => {
const {
actions: {
toggleFormRow,
fields,
getRenderedIndexPatternFields,
goToNextDocument,
goToPreviousDocument,
waitForUpdates,
},
} = testBed;
await toggleFormRow('value');
await fields.updateName('myRuntimeField');
await waitForUpdates();
expect(getRenderedIndexPatternFields()[0]).toEqual({
key: 'title',
value: doc1._source.title,
});
await goToNextDocument();
expect(getRenderedIndexPatternFields()[0]).toEqual({
key: 'title',
value: doc2._source.title,
});
await goToNextDocument();
expect(getRenderedIndexPatternFields()[0]).toEqual({
key: 'title',
value: doc3._source.title,
});
// Going next we circle back to the first document of the list
await goToNextDocument();
expect(getRenderedIndexPatternFields()[0]).toEqual({
key: 'title',
value: doc1._source.title,
});
// Let's go backward
await goToPreviousDocument();
expect(getRenderedIndexPatternFields()[0]).toEqual({
key: 'title',
value: doc3._source.title,
});
await goToPreviousDocument();
expect(getRenderedIndexPatternFields()[0]).toEqual({
key: 'title',
value: doc2._source.title,
});
});
test('should update the field preview value when the document changes', async () => {
httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['valueDoc1'] });
const {
actions: {
toggleFormRow,
fields,
waitForUpdates,
waitForDocumentsAndPreviewUpdate,
getRenderedFieldsPreview,
goToNextDocument,
},
} = testBed;
await toggleFormRow('value');
await fields.updateName('myRuntimeField');
await fields.updateScript('echo("hello world")');
await waitForDocumentsAndPreviewUpdate();
expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'valueDoc1' }]);
httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['valueDoc2'] });
await goToNextDocument();
await waitForUpdates();
expect(getRenderedFieldsPreview()).toEqual([{ key: 'myRuntimeField', value: 'valueDoc2' }]);
});
test('should load a custom document when an ID is passed', async () => {
const {
component,
form,
exists,
actions: {
toggleFormRow,
fields,
getRenderedIndexPatternFields,
getRenderedFieldsPreview,
waitForUpdates,
waitForDocumentsAndPreviewUpdate,
},
} = testBed;
await toggleFormRow('value');
await fields.updateName('myRuntimeField');
await fields.updateScript('echo("hello world")');
await waitForDocumentsAndPreviewUpdate();
// First make sure that we have the original cluster data is loaded
// and the preview value rendered.
expect(getRenderedIndexPatternFields()[0]).toEqual({
key: 'title',
value: doc1._source.title,
});
expect(getRenderedFieldsPreview()).toEqual([
{ key: 'myRuntimeField', value: 'mockedScriptValue' },
]);
httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['loadedDocPreview'] });
setSearchResponse([customLoadedDoc]);
await act(async () => {
form.setInputValue('documentIdField', '123456');
});
component.update();
// We immediately remove the index pattern fields
expect(getRenderedIndexPatternFields()).toEqual([]);
await waitForDocumentsAndPreviewUpdate();
expect(getRenderedIndexPatternFields()).toEqual([
{
key: 'title',
value: 'loaded doc - title',
},
{
key: 'subTitle',
value: 'loaded doc - subTitle',
},
{
key: 'description',
value: 'loaded doc - description',
},
]);
await waitForUpdates(); // Then wait for the preview HTTP request
// The preview should have updated
expect(getRenderedFieldsPreview()).toEqual([
{ key: 'myRuntimeField', value: 'loadedDocPreview' },
]);
// The nav should not be there when loading a single document
expect(exists('documentsNav')).toBe(false);
// There should be a link to load back the cluster data
expect(exists('loadDocsFromClusterButton')).toBe(true);
});
test('should load back the cluster data after providing a custom ID', async () => {
const {
form,
component,
find,
actions: {
toggleFormRow,
fields,
getRenderedFieldsPreview,
getRenderedIndexPatternFields,
waitForUpdates,
waitForDocumentsAndPreviewUpdate,
},
} = testBed;
await toggleFormRow('value');
await waitForUpdates(); // fetch documents
await fields.updateName('myRuntimeField');
await fields.updateScript('echo("hello world")');
await waitForUpdates(); // fetch preview
httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['loadedDocPreview'] });
setSearchResponse([customLoadedDoc]);
// Load a custom document ID
await act(async () => {
form.setInputValue('documentIdField', '123456');
});
await waitForDocumentsAndPreviewUpdate();
// Load back the cluster data
httpRequestsMockHelpers.setFieldPreviewResponse({ values: ['clusterDataDocPreview'] });
setSearchResponse(mockDocuments);
await act(async () => {
find('loadDocsFromClusterButton').simulate('click');
});
component.update();
// We immediately remove the index pattern fields
expect(getRenderedIndexPatternFields()).toEqual([]);
await waitForDocumentsAndPreviewUpdate();
// The preview should be updated with the cluster data preview
expect(getRenderedFieldsPreview()).toEqual([
{ key: 'myRuntimeField', value: 'clusterDataDocPreview' },
]);
});
test('should not lose the state of single document vs cluster data after displaying the empty prompt', async () => {
const {
form,
component,
exists,
actions: {
toggleFormRow,
fields,
getRenderedIndexPatternFields,
waitForDocumentsAndPreviewUpdate,
},
} = testBed;
await toggleFormRow('value');
await fields.updateName('myRuntimeField');
await waitForDocumentsAndPreviewUpdate();
// Initial state where we have the cluster data loaded and the doc navigation
expect(exists('documentsNav')).toBe(true);
expect(exists('loadDocsFromClusterButton')).toBe(false);
setSearchResponse([customLoadedDoc]);
await act(async () => {
form.setInputValue('documentIdField', '123456');
});
component.update();
await waitForDocumentsAndPreviewUpdate();
expect(exists('documentsNav')).toBe(false);
expect(exists('loadDocsFromClusterButton')).toBe(true);
// Clearing the name will display the empty prompt as we don't have any script
await fields.updateName('');
expect(exists('previewPanel.emptyPrompt')).toBe(true);
// Give another name to hide the empty prompt and show the preview panel back
await fields.updateName('newName');
expect(exists('previewPanel.emptyPrompt')).toBe(false);
// We should still display the single document state
expect(exists('documentsNav')).toBe(false);
expect(exists('loadDocsFromClusterButton')).toBe(true);
expect(getRenderedIndexPatternFields()[0]).toEqual({
key: 'title',
value: 'loaded doc - title',
});
});
test('should send the correct params to the data plugin search() handler', async () => {
const {
form,
component,
find,
actions: { toggleFormRow, fields, waitForUpdates },
} = testBed;
const expectedParamsToFetchClusterData = {
params: { index: 'testIndexPattern', body: { size: 50 } },
};
// Initial state
let searchMeta = getSearchCallMeta();
const initialCount = searchMeta.totalCalls;
// Open the preview panel. This will trigger document fetchint
await fields.updateName('myRuntimeField');
await toggleFormRow('value');
await waitForUpdates();
searchMeta = getSearchCallMeta();
expect(searchMeta.totalCalls).toBe(initialCount + 1);
expect(searchMeta.lastCallParams).toEqual(expectedParamsToFetchClusterData);
// Load single doc
setSearchResponse([customLoadedDoc]);
const nextId = '123456';
await act(async () => {
form.setInputValue('documentIdField', nextId);
});
component.update();
await waitForUpdates();
searchMeta = getSearchCallMeta();
expect(searchMeta.totalCalls).toBe(initialCount + 2);
expect(searchMeta.lastCallParams).toEqual({
params: {
body: {
query: {
ids: {
values: [nextId],
},
},
size: 1,
},
index: 'testIndexPattern',
},
});
// Back to cluster data
setSearchResponse(mockDocuments);
await act(async () => {
find('loadDocsFromClusterButton').simulate('click');
});
searchMeta = getSearchCallMeta();
expect(searchMeta.totalCalls).toBe(initialCount + 3);
expect(searchMeta.lastCallParams).toEqual(expectedParamsToFetchClusterData);
});
});
});

View file

@ -0,0 +1,111 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { act } from 'react-dom/test-utils';
import { TestBed } from '@kbn/test/jest';
export const getCommonActions = (testBed: TestBed) => {
const toggleFormRow = async (
row: 'customLabel' | 'value' | 'format',
value: 'on' | 'off' = 'on'
) => {
const testSubj = `${row}Row.toggle`;
const toggle = testBed.find(testSubj);
const isOn = toggle.props()['aria-checked'];
if ((value === 'on' && isOn) || (value === 'off' && isOn === false)) {
return;
}
await act(async () => {
testBed.form.toggleEuiSwitch(testSubj);
});
testBed.component.update();
};
// Fields
const updateName = async (value: string) => {
await act(async () => {
testBed.form.setInputValue('nameField.input', value);
});
testBed.component.update();
};
const updateScript = async (value: string) => {
await act(async () => {
testBed.form.setInputValue('scriptField', value);
});
testBed.component.update();
};
const updateType = async (value: string, label?: string) => {
await act(async () => {
testBed.find('typeField').simulate('change', [
{
value,
label: label ?? value,
},
]);
});
testBed.component.update();
};
const updateFormat = async (value: string, label?: string) => {
await act(async () => {
testBed.find('editorSelectedFormatId').simulate('change', { target: { value } });
});
testBed.component.update();
};
/**
* Allows us to bypass the debounce time of 500ms before updating the preview. We also simulate
* a 2000ms latency when searching ES documents (see setup_environment.tsx).
*/
const waitForUpdates = async () => {
await act(async () => {
jest.runAllTimers();
});
testBed.component.update();
};
/**
* When often need to both wait for the documents to be fetched and
* the preview to be fetched. We can't increase the `jest.advanceTimersByTime` time
* as those are 2 different operations that occur in sequence.
*/
const waitForDocumentsAndPreviewUpdate = async () => {
// Wait for documents to be fetched
await act(async () => {
jest.runAllTimers();
});
// Wait for preview to update
await act(async () => {
jest.runAllTimers();
});
testBed.component.update();
};
return {
toggleFormRow,
waitForUpdates,
waitForDocumentsAndPreviewUpdate,
fields: {
updateName,
updateType,
updateScript,
updateFormat,
},
};
};

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import sinon, { SinonFakeServer } from 'sinon';
import { API_BASE_PATH } from '../../../common/constants';
type HttpResponse = Record<string, any> | any[];
// Register helpers to mock HTTP Requests
const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
const setFieldPreviewResponse = (response?: HttpResponse, error?: any) => {
const status = error ? error.body.status || 400 : 200;
const body = error ? JSON.stringify(error.body) : JSON.stringify(response);
server.respondWith('POST', `${API_BASE_PATH}/field_preview`, [
status,
{ 'Content-Type': 'application/json' },
body,
]);
};
return {
setFieldPreviewResponse,
};
};
export const init = () => {
const server = sinon.fakeServer.create();
server.respondImmediately = true;
// Define default response for unhandled requests.
// We make requests to APIs which don't impact the component under test, e.g. UI metric telemetry,
// and we can mock them all with a 200 instead of mocking each one individually.
server.respondWith([200, {}, 'DefaultSinonMockServerResponse']);
const httpRequestsMockHelpers = registerHttpRequestMockHelpers(server);
return {
server,
httpRequestsMockHelpers,
};
};

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { findTestSubject, TestBed } from '@kbn/test/jest';
export {
setupEnvironment,
WithFieldEditorDependencies,
spySearchQuery,
spySearchQueryResponse,
spyIndexPatternGetAllFields,
fieldFormatsOptions,
indexPatternNameForTest,
} from './setup_environment';
export { getCommonActions } from './common_actions';

View file

@ -5,13 +5,61 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
const EDITOR_ID = 'testEditor';
jest.mock('../../../kibana_react/public', () => {
const original = jest.requireActual('../../../kibana_react/public');
jest.mock('@elastic/eui/lib/services/accessibility', () => {
return {
htmlIdGenerator: () => () => `generated-id`,
};
});
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']]);
}}
/>
),
EuiResizeObserver: ({
onResize,
children,
}: {
onResize(data: { height: number }): void;
children(): JSX.Element;
}) => {
onResize({ height: 1000 });
return children();
},
};
});
jest.mock('@kbn/monaco', () => {
const original = jest.requireActual('@kbn/monaco');
return {
...original,
PainlessLang: {
ID: 'painless',
getSuggestionProvider: () => undefined,
getSyntaxErrors: () => ({
[EDITOR_ID]: [],
}),
},
};
});
jest.mock('../../../../kibana_react/public', () => {
const original = jest.requireActual('../../../../kibana_react/public');
/**
* We mock the CodeEditor because it requires the <KibanaReactContextProvider>
@ -42,39 +90,7 @@ jest.mock('../../../kibana_react/public', () => {
return {
...original,
toMountPoint: (node: React.ReactNode) => node,
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']]);
}}
/>
),
};
});
jest.mock('@kbn/monaco', () => {
const original = jest.requireActual('@kbn/monaco');
return {
...original,
PainlessLang: {
ID: 'painless',
getSuggestionProvider: () => undefined,
getSyntaxErrors: () => ({
[EDITOR_ID]: [],
}),
},
};
});

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
interface PreviewErrorArgs {
reason: string;
scriptStack?: string[];
position?: { offset: number; start: number; end: number } | null;
}
export const createPreviewError = ({
reason,
scriptStack = [],
position = null,
}: PreviewErrorArgs) => {
return {
caused_by: { reason },
position,
script_stack: scriptStack,
};
};

View file

@ -0,0 +1,122 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import './jest.mocks';
import React, { FunctionComponent } from 'react';
import axios from 'axios';
import axiosXhrAdapter from 'axios/lib/adapters/xhr';
import { merge } from 'lodash';
import { notificationServiceMock, uiSettingsServiceMock } from '../../../../../core/public/mocks';
import { dataPluginMock } from '../../../../data/public/mocks';
import { FieldEditorProvider, Context } from '../../../public/components/field_editor_context';
import { FieldPreviewProvider } from '../../../public/components/preview';
import { initApi, ApiService } from '../../../public/lib';
import { init as initHttpRequests } from './http_requests';
const mockHttpClient = axios.create({ adapter: axiosXhrAdapter });
const dataStart = dataPluginMock.createStartContract();
const { search, fieldFormats } = dataStart;
export const spySearchQuery = jest.fn();
export const spySearchQueryResponse = jest.fn();
export const spyIndexPatternGetAllFields = jest.fn().mockImplementation(() => []);
spySearchQuery.mockImplementation((params) => {
return {
toPromise: () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(undefined);
}, 2000); // simulate 2s latency for the HTTP request
}).then(() => spySearchQueryResponse());
},
};
});
search.search = spySearchQuery;
let apiService: ApiService;
export const setupEnvironment = () => {
// @ts-expect-error Axios does not fullfill HttpSetupn from core but enough for our tests
apiService = initApi(mockHttpClient);
const { server, httpRequestsMockHelpers } = initHttpRequests();
return {
server,
httpRequestsMockHelpers,
};
};
// The format options available in the dropdown select for our tests.
export const fieldFormatsOptions = [{ id: 'upper', title: 'UpperCaseString' } as any];
export const indexPatternNameForTest = 'testIndexPattern';
export const WithFieldEditorDependencies = <T extends object = { [key: string]: unknown }>(
Comp: FunctionComponent<T>,
overridingDependencies?: Partial<Context>
) => (props: T) => {
// Setup mocks
(fieldFormats.getByFieldType as jest.MockedFunction<
typeof fieldFormats['getByFieldType']
>).mockReturnValue(fieldFormatsOptions);
(fieldFormats.getDefaultType as jest.MockedFunction<
typeof fieldFormats['getDefaultType']
>).mockReturnValue({ id: 'testDefaultFormat', title: 'TestDefaultFormat' } as any);
(fieldFormats.getInstance as jest.MockedFunction<
typeof fieldFormats['getInstance']
>).mockImplementation((id: string) => {
if (id === 'upper') {
return {
convertObject: {
html(value: string = '') {
return `<span>${value.toUpperCase()}</span>`;
},
},
} as any;
}
});
const dependencies: Context = {
indexPattern: {
title: indexPatternNameForTest,
fields: { getAll: spyIndexPatternGetAllFields },
} as any,
uiSettings: uiSettingsServiceMock.createStartContract(),
fieldTypeToProcess: 'runtime',
existingConcreteFields: [],
namesNotAllowed: [],
links: {
runtimePainless: 'https://elastic.co',
},
services: {
notifications: notificationServiceMock.createStartContract(),
search,
api: apiService,
},
fieldFormatEditors: {
getAll: () => [],
getById: () => undefined,
},
fieldFormats,
};
const mergedDependencies = merge({}, dependencies, overridingDependencies);
return (
<FieldEditorProvider {...mergedDependencies}>
<FieldPreviewProvider>
<Comp {...props} />
</FieldPreviewProvider>
</FieldEditorProvider>
);
};

View file

@ -6,8 +6,4 @@
* Side Public License, v 1.
*/
export * from './test_utils';
export * from './mocks';
export * from './helpers';
export const API_BASE_PATH = '/api/index_pattern_field_editor';

View file

@ -1,7 +1,7 @@
{
"id": "indexPatternFieldEditor",
"version": "kibana",
"server": false,
"server": true,
"ui": true,
"requiredPlugins": ["data"],
"optionalPlugins": ["usageCollection"],

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { DeleteFieldModal } from './delete_field_modal';
export { ModifiedFieldModal } from './modified_field_modal';
export { SaveFieldTypeOrNameChangedModal } from './save_field_type_or_name_changed_modal';

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiConfirmModal } from '@elastic/eui';
const i18nTexts = {
title: i18n.translate('indexPatternFieldEditor.cancelField.confirmationModal.title', {
defaultMessage: 'Discard changes',
}),
description: i18n.translate('indexPatternFieldEditor.cancelField.confirmationModal.description', {
defaultMessage: `Changes that you've made to your field will be discarded, are you sure you want to continue?`,
}),
cancelButton: i18n.translate(
'indexPatternFieldEditor.cancelField.confirmationModal.cancelButtonLabel',
{
defaultMessage: 'Cancel',
}
),
};
interface Props {
onConfirm: () => void;
onCancel: () => void;
}
export const ModifiedFieldModal: React.FC<Props> = ({ onCancel, onConfirm }) => {
return (
<EuiConfirmModal
title={i18nTexts.title}
data-test-subj="runtimeFieldModifiedFieldConfirmModal"
cancelButtonText={i18nTexts.cancelButton}
confirmButtonText={i18nTexts.title}
onCancel={onCancel}
onConfirm={onConfirm}
maxWidth={600}
>
<p>{i18nTexts.description}</p>
</EuiConfirmModal>
);
};

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useState } from 'react';
import { EuiCallOut, EuiSpacer, EuiConfirmModal, EuiFieldText, EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
const geti18nTexts = (fieldName: string) => ({
cancelButtonText: i18n.translate(
'indexPatternFieldEditor.saveRuntimeField.confirmationModal.cancelButtonLabel',
{
defaultMessage: 'Cancel',
}
),
confirmButtonText: i18n.translate(
'indexPatternFieldEditor.deleteRuntimeField.confirmationModal.saveButtonLabel',
{
defaultMessage: 'Save changes',
}
),
warningChangingFields: i18n.translate(
'indexPatternFieldEditor.deleteRuntimeField.confirmModal.warningChangingFields',
{
defaultMessage:
'Changing name or type can break searches and visualizations that rely on this field.',
}
),
typeConfirm: i18n.translate('indexPatternFieldEditor.saveRuntimeField.confirmModal.typeConfirm', {
defaultMessage: 'Enter CHANGE to continue',
}),
titleConfirmChanges: i18n.translate(
'indexPatternFieldEditor.saveRuntimeField.confirmModal.title',
{
defaultMessage: `Save changes to '{name}'`,
values: {
name: fieldName,
},
}
),
});
interface Props {
fieldName: string;
onConfirm: () => void;
onCancel: () => void;
}
export const SaveFieldTypeOrNameChangedModal: React.FC<Props> = ({
fieldName,
onCancel,
onConfirm,
}) => {
const i18nTexts = geti18nTexts(fieldName);
const [confirmContent, setConfirmContent] = useState<string>('');
return (
<EuiConfirmModal
title={i18nTexts.titleConfirmChanges}
data-test-subj="runtimeFieldSaveConfirmModal"
cancelButtonText={i18nTexts.cancelButtonText}
confirmButtonText={i18nTexts.confirmButtonText}
confirmButtonDisabled={confirmContent?.toUpperCase() !== 'CHANGE'}
onCancel={onCancel}
onConfirm={onConfirm}
>
<EuiCallOut
color="warning"
title={i18nTexts.warningChangingFields}
iconType="alert"
size="s"
/>
<EuiSpacer />
<EuiFormRow label={i18nTexts.typeConfirm}>
<EuiFieldText
value={confirmContent}
onChange={(e) => setConfirmContent(e.target.value)}
data-test-subj="saveModalConfirmText"
/>
</EuiFormRow>
</EuiConfirmModal>
);
};

View file

@ -34,4 +34,8 @@ export const RUNTIME_FIELD_OPTIONS: Array<EuiComboBoxOptionOption<RuntimeType>>
label: 'Boolean',
value: 'boolean',
},
{
label: 'Geo point',
value: 'geo_point',
},
];

View file

@ -9,6 +9,7 @@
import React, { useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { get } from 'lodash';
import {
EuiFlexGroup,
EuiFlexItem,
@ -17,20 +18,20 @@ import {
EuiCode,
EuiCallOut,
} from '@elastic/eui';
import type { CoreStart } from 'src/core/public';
import {
Form,
useForm,
useFormData,
useFormIsModified,
FormHook,
UseField,
TextField,
RuntimeType,
IndexPattern,
DataPublicPluginStart,
} from '../../shared_imports';
import { Field, InternalFieldType, PluginStart } from '../../types';
import { Field } from '../../types';
import { useFieldEditorContext } from '../field_editor_context';
import { useFieldPreviewContext } from '../preview';
import { RUNTIME_FIELD_OPTIONS } from './constants';
import { schema } from './form_schema';
@ -63,36 +64,12 @@ export interface FieldFormInternal extends Omit<Field, 'type' | 'internalType'>
}
export interface Props {
/** Link URLs to our doc site */
links: {
runtimePainless: string;
};
/** Optional field to edit */
field?: Field;
/** Handler to receive state changes updates */
onChange?: (state: FieldEditorFormState) => void;
indexPattern: IndexPattern;
fieldFormatEditors: PluginStart['fieldFormatEditors'];
fieldFormats: DataPublicPluginStart['fieldFormats'];
uiSettings: CoreStart['uiSettings'];
/** Context object */
ctx: {
/** The internal field type we are dealing with (concrete|runtime)*/
fieldTypeToProcess: InternalFieldType;
/**
* An array of field names not allowed.
* e.g we probably don't want a user to give a name of an existing
* runtime field (for that the user should edit the existing runtime field).
*/
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.
* It is also used to provide the list of field autocomplete suggestions to the code editor.
*/
existingConcreteFields: Array<{ name: string; type: string }>;
};
/** Handler to receive update on the form "isModified" state */
onFormModifiedChange?: (isModified: boolean) => void;
syntaxError: ScriptSyntaxError;
}
@ -173,31 +150,53 @@ const formSerializer = (field: FieldFormInternal): Field => {
};
};
const FieldEditorComponent = ({
field,
onChange,
links,
indexPattern,
fieldFormatEditors,
fieldFormats,
uiSettings,
syntaxError,
ctx: { fieldTypeToProcess, namesNotAllowed, existingConcreteFields },
}: Props) => {
const FieldEditorComponent = ({ field, onChange, onFormModifiedChange, syntaxError }: Props) => {
const {
links,
namesNotAllowed,
existingConcreteFields,
fieldTypeToProcess,
} = useFieldEditorContext();
const {
params: { update: updatePreviewParams },
panel: { setIsVisible: setIsPanelVisible },
} = useFieldPreviewContext();
const { form } = useForm<Field, FieldFormInternal>({
defaultValue: field,
schema,
deserializer: formDeserializer,
serializer: formSerializer,
});
const { submit, isValid: isFormValid, isSubmitted } = form;
const { submit, isValid: isFormValid, isSubmitted, getFields } = form;
const { clear: clearSyntaxError } = syntaxError;
const [{ type }] = useFormData<FieldFormInternal>({ form });
const nameFieldConfig = getNameFieldConfig(namesNotAllowed, field);
const i18nTexts = geti18nTexts();
const [formData] = useFormData<FieldFormInternal>({ form });
const isFormModified = useFormIsModified({
form,
discard: [
'__meta__.isCustomLabelVisible',
'__meta__.isValueVisible',
'__meta__.isFormatVisible',
'__meta__.isPopularityVisible',
],
});
const {
name: updatedName,
type: updatedType,
script: updatedScript,
format: updatedFormat,
} = formData;
const { name: nameField, type: typeField } = getFields();
const nameHasChanged = (Boolean(field?.name) && nameField?.isModified) ?? false;
const typeHasChanged = (Boolean(field?.type) && typeField?.isModified) ?? false;
const isValueVisible = get(formData, '__meta__.isValueVisible');
const isFormatVisible = get(formData, '__meta__.isFormatVisible');
useEffect(() => {
if (onChange) {
onChange({ isValid: isFormValid, isSubmitted, submit });
@ -208,18 +207,39 @@ const FieldEditorComponent = ({
// Whenever the field "type" changes we clear any possible painless syntax
// error as it is possibly stale.
clearSyntaxError();
}, [type, clearSyntaxError]);
}, [updatedType, clearSyntaxError]);
const [{ name: updatedName, type: updatedType }] = useFormData({ form });
const nameHasChanged = Boolean(field?.name) && field?.name !== updatedName;
const typeHasChanged =
Boolean(field?.type) && field?.type !== (updatedType && updatedType[0].value);
useEffect(() => {
updatePreviewParams({
name: Boolean(updatedName?.trim()) ? updatedName : null,
type: updatedType?.[0].value,
script:
isValueVisible === false || Boolean(updatedScript?.source.trim()) === false
? null
: updatedScript,
format: updatedFormat?.id !== undefined ? updatedFormat : null,
});
}, [updatedName, updatedType, updatedScript, isValueVisible, updatedFormat, updatePreviewParams]);
useEffect(() => {
if (isValueVisible || isFormatVisible) {
setIsPanelVisible(true);
} else {
setIsPanelVisible(false);
}
}, [isValueVisible, isFormatVisible, setIsPanelVisible]);
useEffect(() => {
if (onFormModifiedChange) {
onFormModifiedChange(isFormModified);
}
}, [isFormModified, onFormModifiedChange]);
return (
<Form
form={form}
className="indexPatternFieldEditor__form"
data-test-subj={'indexPatternFieldEditorForm'}
data-test-subj="indexPatternFieldEditorForm"
>
<EuiFlexGroup>
{/* Name */}
@ -296,12 +316,7 @@ const FieldEditorComponent = ({
data-test-subj="formatRow"
withDividerRule
>
<FormatField
indexPattern={indexPattern}
fieldFormatEditors={fieldFormatEditors}
fieldFormats={fieldFormats}
uiSettings={uiSettings}
/>
<FormatField />
</FormRow>
{/* Advanced settings */}
@ -320,4 +335,4 @@ const FieldEditorComponent = ({
);
};
export const FieldEditor = React.memo(FieldEditorComponent);
export const FieldEditor = React.memo(FieldEditorComponent) as typeof FieldEditorComponent;

View file

@ -9,16 +9,13 @@ import React, { useState, useEffect, useRef } from 'react';
import { EuiCallOut, EuiSpacer } from '@elastic/eui';
import { UseField, useFormData, ES_FIELD_TYPES, useFormContext } from '../../../shared_imports';
import { FormatSelectEditor, FormatSelectEditorProps } from '../../field_format_editor';
import { FieldFormInternal } from '../field_editor';
import { FieldFormatConfig } from '../../../types';
import { useFieldEditorContext } from '../../field_editor_context';
import { FormatSelectEditor } from '../../field_format_editor';
import type { FieldFormInternal } from '../field_editor';
import type { FieldFormatConfig } from '../../../types';
export const FormatField = ({
indexPattern,
fieldFormatEditors,
fieldFormats,
uiSettings,
}: Omit<FormatSelectEditorProps, 'onChange' | 'onError' | 'esTypes'>) => {
export const FormatField = () => {
const { indexPattern, uiSettings, fieldFormats, fieldFormatEditors } = useFieldEditorContext();
const isMounted = useRef(false);
const [{ type }] = useFormData<FieldFormInternal>({ watch: ['name', 'type'] });
const { getFields, isSubmitted } = useFormContext();

View file

@ -139,6 +139,7 @@ export const ScriptField = React.memo(({ existingConcreteFields, links, syntaxEr
<>
<EuiFormRow
label={label}
id="runtimeFieldScript"
error={errorMessage}
isInvalid={syntaxError.error !== null || !isValid}
helpText={

View file

@ -54,6 +54,7 @@ export const TypeField = ({ isDisabled = false }: Props) => {
defaultMessage: 'Type select',
}
)}
aria-controls="runtimeFieldScript"
fullWidth
/>
</EuiFormRow>

View file

@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n';
import { ValidationFunc, FieldConfig } from '../../shared_imports';
import { Field } from '../../types';
import { schema } from './form_schema';
import { Props } from './field_editor';
import type { Props } from './field_editor';
const createNameNotAllowedValidator = (
namesNotAllowed: string[]

View file

@ -0,0 +1,96 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { createContext, useContext, FunctionComponent, useMemo } from 'react';
import { NotificationsStart, CoreStart } from 'src/core/public';
import type { IndexPattern, DataPublicPluginStart } from '../shared_imports';
import { ApiService } from '../lib/api';
import type { InternalFieldType, PluginStart } from '../types';
export interface Context {
indexPattern: IndexPattern;
fieldTypeToProcess: InternalFieldType;
uiSettings: CoreStart['uiSettings'];
links: {
runtimePainless: string;
};
services: {
search: DataPublicPluginStart['search'];
api: ApiService;
notifications: NotificationsStart;
};
fieldFormatEditors: PluginStart['fieldFormatEditors'];
fieldFormats: DataPublicPluginStart['fieldFormats'];
/**
* An array of field names not allowed.
* e.g we probably don't want a user to give a name of an existing
* runtime field (for that the user should edit the existing runtime field).
*/
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.
* It is also used to provide the list of field autocomplete suggestions to the code editor.
*/
existingConcreteFields: Array<{ name: string; type: string }>;
}
const fieldEditorContext = createContext<Context | undefined>(undefined);
export const FieldEditorProvider: FunctionComponent<Context> = ({
services,
indexPattern,
links,
uiSettings,
fieldTypeToProcess,
fieldFormats,
fieldFormatEditors,
namesNotAllowed,
existingConcreteFields,
children,
}) => {
const ctx = useMemo<Context>(
() => ({
indexPattern,
fieldTypeToProcess,
links,
uiSettings,
services,
fieldFormats,
fieldFormatEditors,
namesNotAllowed,
existingConcreteFields,
}),
[
indexPattern,
fieldTypeToProcess,
services,
links,
uiSettings,
fieldFormats,
fieldFormatEditors,
namesNotAllowed,
existingConcreteFields,
]
);
return <fieldEditorContext.Provider value={ctx}>{children}</fieldEditorContext.Provider>;
};
export const useFieldEditorContext = (): Context => {
const ctx = useContext(fieldEditorContext);
if (ctx === undefined) {
throw new Error('useFieldEditorContext must be used within a <FieldEditorContext />');
}
return ctx;
};
export const useFieldEditorServices = () => useFieldEditorContext().services;

View file

@ -6,13 +6,10 @@
* Side Public License, v 1.
*/
import React, { useState, useCallback, useMemo } from 'react';
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiFlyoutHeader,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiTitle,
EuiFlexGroup,
EuiFlexItem,
@ -21,64 +18,32 @@ import {
EuiCallOut,
EuiSpacer,
EuiText,
EuiConfirmModal,
EuiFieldText,
EuiFormRow,
} from '@elastic/eui';
import { DocLinksStart, CoreStart } from 'src/core/public';
import type { Field, EsRuntimeField } from '../types';
import { RuntimeFieldPainlessError } from '../lib';
import { euiFlyoutClassname } from '../constants';
import { FlyoutPanels } from './flyout_panels';
import { useFieldEditorContext } from './field_editor_context';
import { FieldEditor, FieldEditorFormState } from './field_editor/field_editor';
import { FieldPreview, useFieldPreviewContext } from './preview';
import { ModifiedFieldModal, SaveFieldTypeOrNameChangedModal } from './confirm_modals';
import { Field, InternalFieldType, PluginStart, EsRuntimeField } from '../types';
import { getLinks, RuntimeFieldPainlessError } from '../lib';
import type { IndexPattern, DataPublicPluginStart } from '../shared_imports';
import type { Props as FieldEditorProps, FieldEditorFormState } from './field_editor/field_editor';
const i18nTexts = {
cancelButtonLabel: i18n.translate('indexPatternFieldEditor.editor.flyoutCancelButtonLabel', {
defaultMessage: 'Cancel',
}),
saveButtonLabel: i18n.translate('indexPatternFieldEditor.editor.flyoutSaveButtonLabel', {
defaultMessage: 'Save',
}),
formErrorsCalloutTitle: i18n.translate('indexPatternFieldEditor.editor.validationErrorTitle', {
defaultMessage: 'Fix errors in form before continuing.',
}),
};
const geti18nTexts = (field?: Field) => {
return {
closeButtonLabel: i18n.translate('indexPatternFieldEditor.editor.flyoutCloseButtonLabel', {
defaultMessage: 'Close',
}),
saveButtonLabel: i18n.translate('indexPatternFieldEditor.editor.flyoutSaveButtonLabel', {
defaultMessage: 'Save',
}),
formErrorsCalloutTitle: i18n.translate('indexPatternFieldEditor.editor.validationErrorTitle', {
defaultMessage: 'Fix errors in form before continuing.',
}),
cancelButtonText: i18n.translate(
'indexPatternFieldEditor.saveRuntimeField.confirmationModal.cancelButtonLabel',
{
defaultMessage: 'Cancel',
}
),
confirmButtonText: i18n.translate(
'indexPatternFieldEditor.deleteRuntimeField.confirmationModal.saveButtonLabel',
{
defaultMessage: 'Save changes',
}
),
warningChangingFields: i18n.translate(
'indexPatternFieldEditor.deleteRuntimeField.confirmModal.warningChangingFields',
{
defaultMessage:
'Changing name or type can break searches and visualizations that rely on this field.',
}
),
typeConfirm: i18n.translate(
'indexPatternFieldEditor.saveRuntimeField.confirmModal.typeConfirm',
{
defaultMessage: 'Enter CHANGE to continue',
}
),
titleConfirmChanges: i18n.translate(
'indexPatternFieldEditor.saveRuntimeField.confirmModal.title',
{
defaultMessage: `Save changes to '{name}'`,
values: {
name: field?.name,
},
}
),
};
const defaultModalVisibility = {
confirmChangeNameOrType: false,
confirmUnsavedChanges: false,
};
export interface Props {
@ -90,44 +55,30 @@ export interface Props {
* Handler for the "cancel" footer button
*/
onCancel: () => void;
/**
* The docLinks start service from core
*/
docLinks: DocLinksStart;
/**
* The Field editor component that contains the form to create or edit a field
*/
FieldEditor: React.ComponentType<FieldEditorProps> | null;
/** The internal field type we are dealing with (concrete|runtime)*/
fieldTypeToProcess: InternalFieldType;
/** Handler to validate the script */
runtimeFieldValidator: (field: EsRuntimeField) => Promise<RuntimeFieldPainlessError | null>;
/** Optional field to process */
field?: Field;
indexPattern: IndexPattern;
fieldFormatEditors: PluginStart['fieldFormatEditors'];
fieldFormats: DataPublicPluginStart['fieldFormats'];
uiSettings: CoreStart['uiSettings'];
isSavingField: boolean;
/** Handler to call when the component mounts.
* We will pass "up" data that the parent component might need
*/
onMounted?: (args: { canCloseValidator: () => boolean }) => void;
}
const FieldEditorFlyoutContentComponent = ({
field,
onSave,
onCancel,
FieldEditor,
docLinks,
indexPattern,
fieldFormatEditors,
fieldFormats,
uiSettings,
fieldTypeToProcess,
runtimeFieldValidator,
isSavingField,
onMounted,
}: Props) => {
const isEditingExistingField = !!field;
const i18nTexts = geti18nTexts(field);
const { indexPattern } = useFieldEditorContext();
const {
panel: { isVisible: isPanelVisible },
} = useFieldPreviewContext();
const [formState, setFormState] = useState<FieldEditorFormState>({
isSubmitted: false,
@ -142,12 +93,11 @@ const FieldEditorFlyoutContentComponent = ({
);
const [isValidating, setIsValidating] = useState(false);
const [isModalVisible, setIsModalVisible] = useState(false);
const [confirmContent, setConfirmContent] = useState<string>('');
const [modalVisibility, setModalVisibility] = useState(defaultModalVisibility);
const [isFormModified, setIsFormModified] = useState(false);
const { submit, isValid: isFormValid, isSubmitted } = formState;
const { fields } = indexPattern;
const isSaveButtonDisabled = isFormValid === false || painlessSyntaxError !== null;
const hasErrors = isFormValid === false || painlessSyntaxError !== null;
const clearSyntaxError = useCallback(() => setPainlessSyntaxError(null), []);
@ -159,6 +109,16 @@ const FieldEditorFlyoutContentComponent = ({
[painlessSyntaxError, clearSyntaxError]
);
const canCloseValidator = useCallback(() => {
if (isFormModified) {
setModalVisibility({
...defaultModalVisibility,
confirmUnsavedChanges: true,
});
}
return !isFormModified;
}, [isFormModified]);
const onClickSave = useCallback(async () => {
const { isValid, data } = await submit();
const nameChange = field?.name !== data.name;
@ -182,167 +142,177 @@ const FieldEditorFlyoutContentComponent = ({
}
if (isEditingExistingField && (nameChange || typeChange)) {
setIsModalVisible(true);
setModalVisibility({
...defaultModalVisibility,
confirmChangeNameOrType: true,
});
} else {
onSave(data);
}
}
}, [onSave, submit, runtimeFieldValidator, field, isEditingExistingField]);
const namesNotAllowed = useMemo(() => fields.map((fld) => fld.name), [fields]);
const onClickCancel = useCallback(() => {
const canClose = canCloseValidator();
const existingConcreteFields = useMemo(() => {
const existing: Array<{ name: string; type: string }> = [];
if (canClose) {
onCancel();
}
}, [onCancel, canCloseValidator]);
fields
.filter((fld) => {
const isFieldBeingEdited = field?.name === fld.name;
return !isFieldBeingEdited && fld.isMapped;
})
.forEach((fld) => {
existing.push({
name: fld.name,
type: (fld.esTypes && fld.esTypes[0]) || '',
});
});
return existing;
}, [fields, field]);
const ctx = useMemo(
() => ({
fieldTypeToProcess,
namesNotAllowed,
existingConcreteFields,
}),
[fieldTypeToProcess, namesNotAllowed, existingConcreteFields]
);
const modal = isModalVisible ? (
<EuiConfirmModal
title={i18nTexts.titleConfirmChanges}
data-test-subj="runtimeFieldSaveConfirmModal"
cancelButtonText={i18nTexts.cancelButtonText}
confirmButtonText={i18nTexts.confirmButtonText}
confirmButtonDisabled={confirmContent?.toUpperCase() !== 'CHANGE'}
onCancel={() => {
setIsModalVisible(false);
setConfirmContent('');
}}
onConfirm={async () => {
const { data } = await submit();
onSave(data);
}}
>
<EuiCallOut
color="warning"
title={i18nTexts.warningChangingFields}
iconType="alert"
size="s"
/>
<EuiSpacer />
<EuiFormRow label={i18nTexts.typeConfirm}>
<EuiFieldText
value={confirmContent}
onChange={(e) => setConfirmContent(e.target.value)}
data-test-subj="saveModalConfirmText"
const renderModal = () => {
if (modalVisibility.confirmChangeNameOrType) {
return (
<SaveFieldTypeOrNameChangedModal
fieldName={field?.name!}
onConfirm={async () => {
const { data } = await submit();
onSave(data);
}}
onCancel={() => {
setModalVisibility(defaultModalVisibility);
}}
/>
</EuiFormRow>
</EuiConfirmModal>
) : null;
);
}
if (modalVisibility.confirmUnsavedChanges) {
return (
<ModifiedFieldModal
onConfirm={() => {
setModalVisibility(defaultModalVisibility);
onCancel();
}}
onCancel={() => {
setModalVisibility(defaultModalVisibility);
}}
/>
);
}
return null;
};
useEffect(() => {
if (onMounted) {
// When the flyout mounts we send to the parent the validator to check
// if we can close the flyout or not (and display a confirm modal if needed).
// This is required to display the confirm modal when clicking outside the flyout.
onMounted({ canCloseValidator });
return () => {
onMounted({ canCloseValidator: () => true });
};
}
}, [onMounted, canCloseValidator]);
return (
<>
<EuiFlyoutHeader>
<EuiTitle data-test-subj="flyoutTitle">
<h2>
{field ? (
<FormattedMessage
id="indexPatternFieldEditor.editor.flyoutEditFieldTitle"
defaultMessage="Edit field '{fieldName}'"
values={{
fieldName: field.name,
}}
/>
) : (
<FormattedMessage
id="indexPatternFieldEditor.editor.flyoutDefaultTitle"
defaultMessage="Create field"
/>
)}
</h2>
</EuiTitle>
<EuiText color="subdued">
<p>
<FormattedMessage
id="indexPatternFieldEditor.editor.flyoutEditFieldSubtitle"
defaultMessage="Index pattern: {patternName}"
values={{
patternName: <i>{indexPattern.title}</i>,
}}
<FlyoutPanels.Group
flyoutClassName={euiFlyoutClassname}
maxWidth={1180}
data-test-subj="fieldEditor"
fixedPanelWidths
>
{/* Editor panel */}
<FlyoutPanels.Item width={600}>
<FlyoutPanels.Content>
<FlyoutPanels.Header>
<EuiTitle data-test-subj="flyoutTitle">
<h2>
{field ? (
<FormattedMessage
id="indexPatternFieldEditor.editor.flyoutEditFieldTitle"
defaultMessage="Edit field '{fieldName}'"
values={{
fieldName: field.name,
}}
/>
) : (
<FormattedMessage
id="indexPatternFieldEditor.editor.flyoutDefaultTitle"
defaultMessage="Create field"
/>
)}
</h2>
</EuiTitle>
<EuiText color="subdued">
<p>
<FormattedMessage
id="indexPatternFieldEditor.editor.flyoutEditFieldSubtitle"
defaultMessage="Index pattern: {patternName}"
values={{
patternName: <i>{indexPattern.title}</i>,
}}
/>
</p>
</EuiText>
</FlyoutPanels.Header>
<FieldEditor
field={field}
onChange={setFormState}
onFormModifiedChange={setIsFormModified}
syntaxError={syntaxError}
/>
</p>
</EuiText>
</EuiFlyoutHeader>
</FlyoutPanels.Content>
<EuiFlyoutBody>
{FieldEditor && (
<FieldEditor
indexPattern={indexPattern}
fieldFormatEditors={fieldFormatEditors}
fieldFormats={fieldFormats}
uiSettings={uiSettings}
links={getLinks(docLinks)}
field={field}
onChange={setFormState}
ctx={ctx}
syntaxError={syntaxError}
/>
<FlyoutPanels.Footer>
<>
{isSubmitted && hasErrors && (
<>
<EuiCallOut
title={i18nTexts.formErrorsCalloutTitle}
color="danger"
iconType="cross"
data-test-subj="formError"
/>
<EuiSpacer size="m" />
</>
)}
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
flush="left"
onClick={onClickCancel}
data-test-subj="closeFlyoutButton"
>
{i18nTexts.cancelButtonLabel}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
color="primary"
onClick={onClickSave}
data-test-subj="fieldSaveButton"
fill
disabled={hasErrors}
isLoading={isSavingField || isValidating}
>
{i18nTexts.saveButtonLabel}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
</FlyoutPanels.Footer>
</FlyoutPanels.Item>
{/* Preview panel */}
{isPanelVisible && (
<FlyoutPanels.Item
width={440}
backgroundColor="euiPageBackground"
border="left"
data-test-subj="previewPanel"
>
<FieldPreview />
</FlyoutPanels.Item>
)}
</EuiFlyoutBody>
</FlyoutPanels.Group>
<EuiFlyoutFooter>
{FieldEditor && (
<>
{isSubmitted && isSaveButtonDisabled && (
<>
<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={onClickSave}
data-test-subj="fieldSaveButton"
fill
disabled={isSaveButtonDisabled}
isLoading={isSavingField || isValidating}
>
{i18nTexts.saveButtonLabel}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
)}
</EuiFlyoutFooter>
{modal}
{renderModal()}
</>
);
};

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import React, { useCallback, useEffect, useState, useMemo } from 'react';
import React, { useCallback, useState, useMemo } from 'react';
import { DocLinksStart, NotificationsStart, CoreStart } from 'src/core/public';
import { i18n } from '@kbn/i18n';
import { METRIC_TYPE } from '@kbn/analytics';
@ -18,53 +18,40 @@ import {
RuntimeType,
UsageCollectionStart,
} from '../shared_imports';
import { Field, PluginStart, InternalFieldType } from '../types';
import type { Field, PluginStart, InternalFieldType } from '../types';
import { pluginName } from '../constants';
import { deserializeField, getRuntimeFieldValidator } from '../lib';
import { Props as FieldEditorProps } from './field_editor/field_editor';
import { FieldEditorFlyoutContent } from './field_editor_flyout_content';
export interface FieldEditorContext {
indexPattern: IndexPattern;
/**
* The Kibana field type of the field to create or edit
* Default: "runtime"
*/
fieldTypeToProcess: InternalFieldType;
/** The search service from the data plugin */
search: DataPublicPluginStart['search'];
}
import { deserializeField, getRuntimeFieldValidator, getLinks, ApiService } from '../lib';
import {
FieldEditorFlyoutContent,
Props as FieldEditorFlyoutContentProps,
} from './field_editor_flyout_content';
import { FieldEditorProvider } from './field_editor_context';
import { FieldPreviewProvider } from './preview';
export interface Props {
/**
* Handler for the "save" footer button
*/
/** Handler for the "save" footer button */
onSave: (field: IndexPatternField) => void;
/**
* Handler for the "cancel" footer button
*/
/** Handler for the "cancel" footer button */
onCancel: () => void;
/**
* The docLinks start service from core
*/
onMounted?: FieldEditorFlyoutContentProps['onMounted'];
/** The docLinks start service from core */
docLinks: DocLinksStart;
/**
* The context object specific to where the editor is currently being consumed
*/
ctx: FieldEditorContext;
/**
* Optional field to edit
*/
/** The index pattern where the field will be added */
indexPattern: IndexPattern;
/** The Kibana field type of the field to create or edit (default: "runtime") */
fieldTypeToProcess: InternalFieldType;
/** Optional field to edit */
field?: IndexPatternField;
/**
* Services
*/
/** Services */
indexPatternService: DataPublicPluginStart['indexPatterns'];
notifications: NotificationsStart;
search: DataPublicPluginStart['search'];
usageCollection: UsageCollectionStart;
apiService: ApiService;
/** Field format */
fieldFormatEditors: PluginStart['fieldFormatEditors'];
fieldFormats: DataPublicPluginStart['fieldFormats'];
uiSettings: CoreStart['uiSettings'];
usageCollection: UsageCollectionStart;
}
/**
@ -78,19 +65,58 @@ export const FieldEditorFlyoutContentContainer = ({
field,
onSave,
onCancel,
onMounted,
docLinks,
fieldTypeToProcess,
indexPattern,
indexPatternService,
ctx: { indexPattern, fieldTypeToProcess, search },
search,
notifications,
usageCollection,
apiService,
fieldFormatEditors,
fieldFormats,
uiSettings,
usageCollection,
}: Props) => {
const fieldToEdit = deserializeField(indexPattern, field);
const [Editor, setEditor] = useState<React.ComponentType<FieldEditorProps> | null>(null);
const [isSaving, setIsSaving] = useState(false);
const { fields } = indexPattern;
const namesNotAllowed = useMemo(() => fields.map((fld) => fld.name), [fields]);
const existingConcreteFields = useMemo(() => {
const existing: Array<{ name: string; type: string }> = [];
fields
.filter((fld) => {
const isFieldBeingEdited = field?.name === fld.name;
return !isFieldBeingEdited && fld.isMapped;
})
.forEach((fld) => {
existing.push({
name: fld.name,
type: (fld.esTypes && fld.esTypes[0]) || '',
});
});
return existing;
}, [fields, field]);
const validateRuntimeField = useMemo(() => getRuntimeFieldValidator(indexPattern.title, search), [
search,
indexPattern,
]);
const services = useMemo(
() => ({
api: apiService,
search,
notifications,
}),
[apiService, search, notifications]
);
const saveField = useCallback(
async (updatedField: Field) => {
setIsSaving(true);
@ -163,36 +189,28 @@ export const FieldEditorFlyoutContentContainer = ({
]
);
const validateRuntimeField = useMemo(() => getRuntimeFieldValidator(indexPattern.title, search), [
search,
indexPattern,
]);
const loadEditor = useCallback(async () => {
const { FieldEditor } = await import('./field_editor');
setEditor(() => FieldEditor);
}, []);
useEffect(() => {
// On mount: load the editor asynchronously
loadEditor();
}, [loadEditor]);
return (
<FieldEditorFlyoutContent
onSave={saveField}
onCancel={onCancel}
docLinks={docLinks}
field={fieldToEdit}
FieldEditor={Editor}
<FieldEditorProvider
indexPattern={indexPattern}
uiSettings={uiSettings}
links={getLinks(docLinks)}
fieldTypeToProcess={fieldTypeToProcess}
services={services}
fieldFormatEditors={fieldFormatEditors}
fieldFormats={fieldFormats}
uiSettings={uiSettings}
indexPattern={indexPattern}
fieldTypeToProcess={fieldTypeToProcess}
runtimeFieldValidator={validateRuntimeField}
isSavingField={isSaving}
/>
namesNotAllowed={namesNotAllowed}
existingConcreteFields={existingConcreteFields}
>
<FieldPreviewProvider>
<FieldEditorFlyoutContent
onSave={saveField}
onCancel={onCancel}
onMounted={onMounted}
field={fieldToEdit}
runtimeFieldValidator={validateRuntimeField}
isSavingField={isSaving}
/>
</FieldPreviewProvider>
</FieldEditorProvider>
);
};

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useState, useCallback, useEffect } from 'react';
import { EuiFlyoutHeader, EuiFlyoutBody, EuiFlyoutFooter } from '@elastic/eui';
import type { Props } from './field_editor_flyout_content_container';
export const FieldEditorLoader: React.FC<Props> = (props) => {
const [Editor, setEditor] = useState<React.ComponentType<Props> | null>(null);
const loadEditor = useCallback(async () => {
const { FieldEditorFlyoutContentContainer } = await import(
'./field_editor_flyout_content_container'
);
setEditor(() => FieldEditorFlyoutContentContainer);
}, []);
useEffect(() => {
// On mount: load the editor asynchronously
loadEditor();
}, [loadEditor]);
return Editor ? (
<Editor {...props} />
) : (
<>
<EuiFlyoutHeader />
<EuiFlyoutBody />
<EuiFlyoutFooter />
</>
);
};

View file

@ -10,8 +10,8 @@ import React, { PureComponent, ReactText } from 'react';
import { i18n } from '@kbn/i18n';
import type { FieldFormatsContentType } from 'src/plugins/field_formats/common';
import { Sample, SampleInput } from '../../types';
import { FormatEditorProps } from '../types';
import type { Sample, SampleInput } from '../../types';
import type { FormatEditorProps } from '../types';
import { formatId } from './constants';
export const convertSampleInput = (

View file

@ -0,0 +1,144 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, {
CSSProperties,
useState,
useLayoutEffect,
useCallback,
createContext,
useContext,
useMemo,
} from 'react';
import classnames from 'classnames';
import { EuiFlexItem } from '@elastic/eui';
import { useFlyoutPanelsContext } from './flyout_panels';
interface Context {
registerFooter: () => void;
registerContent: () => void;
}
const flyoutPanelContext = createContext<Context>({
registerFooter: () => {},
registerContent: () => {},
});
export interface Props {
/** Width of the panel (in percent % or in px if the "fixedPanelWidths" prop is set to true on the panels group) */
width?: number;
/** EUI sass background */
backgroundColor?: 'euiPageBackground' | 'euiEmptyShade';
/** Add a border to the panel */
border?: 'left' | 'right';
'data-test-subj'?: string;
}
export const Panel: React.FC<Props & React.HTMLProps<HTMLDivElement>> = ({
children,
width,
className = '',
backgroundColor,
border,
'data-test-subj': dataTestSubj,
...rest
}) => {
const [config, setConfig] = useState<{ hasFooter: boolean; hasContent: boolean }>({
hasContent: false,
hasFooter: false,
});
const [styles, setStyles] = useState<CSSProperties>({});
/* eslint-disable @typescript-eslint/naming-convention */
const classes = classnames('fieldEditor__flyoutPanel', className, {
'fieldEditor__flyoutPanel--pageBackground': backgroundColor === 'euiPageBackground',
'fieldEditor__flyoutPanel--emptyShade': backgroundColor === 'euiEmptyShade',
'fieldEditor__flyoutPanel--leftBorder': border === 'left',
'fieldEditor__flyoutPanel--rightBorder': border === 'right',
'fieldEditor__flyoutPanel--withContent': config.hasContent,
});
/* eslint-enable @typescript-eslint/naming-convention */
const { addPanel } = useFlyoutPanelsContext();
const registerContent = useCallback(() => {
setConfig((prev) => {
return {
...prev,
hasContent: true,
};
});
}, []);
const registerFooter = useCallback(() => {
setConfig((prev) => {
if (!prev.hasContent) {
throw new Error(
'You need to add a <FlyoutPanels.Content /> when you add a <FlyoutPanels.Footer />'
);
}
return {
...prev,
hasFooter: true,
};
});
}, []);
const ctx = useMemo(() => ({ registerContent, registerFooter }), [
registerFooter,
registerContent,
]);
useLayoutEffect(() => {
const { removePanel, isFixedWidth } = addPanel({ width });
if (width) {
setStyles((prev) => {
if (isFixedWidth) {
return {
...prev,
width: `${width}px`,
};
}
return {
...prev,
minWidth: `${width}%`,
};
});
}
return removePanel;
}, [width, addPanel]);
return (
<EuiFlexItem
className="fieldEditor__flyoutPanels__column"
style={styles}
grow={false}
data-test-subj={dataTestSubj}
>
<flyoutPanelContext.Provider value={ctx}>
<div className={classes} {...rest}>
{children}
</div>
</flyoutPanelContext.Provider>
</EuiFlexItem>
);
};
export const useFlyoutPanelContext = (): Context => {
const ctx = useContext(flyoutPanelContext);
if (ctx === undefined) {
throw new Error('useFlyoutPanel() must be used within a <flyoutPanelContext.Provider />');
}
return ctx;
};

View file

@ -0,0 +1,48 @@
.fieldEditor__flyoutPanels {
height: 100%;
&__column {
height: 100%;
overflow: hidden;
}
}
.fieldEditor__flyoutPanel {
height: 100%;
overflow-y: auto;
padding: $euiSizeL;
&--pageBackground {
background-color: $euiPageBackgroundColor;
}
&--emptyShade {
background-color: $euiColorEmptyShade;
}
&--leftBorder {
border-left: $euiBorderThin;
}
&--rightBorder {
border-right: $euiBorderThin;
}
&--withContent {
padding: 0;
overflow-y: hidden;
display: flex;
flex-direction: column;
}
&__header {
padding: 0 !important;
}
&__content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: $euiSizeL;
}
&__footer {
flex: 0;
}
}

View file

@ -0,0 +1,145 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, {
useState,
createContext,
useContext,
useCallback,
useMemo,
useLayoutEffect,
} from 'react';
import { EuiFlexGroup, EuiFlexGroupProps } from '@elastic/eui';
import './flyout_panels.scss';
interface Panel {
width?: number;
}
interface Context {
addPanel: (panel: Panel) => { removePanel: () => void; isFixedWidth: boolean };
}
let idx = 0;
const panelId = () => idx++;
const flyoutPanelsContext = createContext<Context>({
addPanel() {
return {
removePanel: () => {},
isFixedWidth: false,
};
},
});
const limitWidthToWindow = (width: number, { innerWidth }: Window): number =>
Math.min(width, innerWidth * 0.8);
export interface Props {
/**
* The total max width with all the panels in the DOM
* Corresponds to the "maxWidth" prop passed to the EuiFlyout
*/
maxWidth: number;
/** The className selector of the flyout */
flyoutClassName: string;
/** The size between the panels. Corresponds to EuiFlexGroup gutterSize */
gutterSize?: EuiFlexGroupProps['gutterSize'];
/** Flag to indicate if the panels width are declared as fixed pixel width instead of percent */
fixedPanelWidths?: boolean;
}
export const Panels: React.FC<Props> = ({
maxWidth,
flyoutClassName,
fixedPanelWidths = false,
...props
}) => {
const flyoutDOMelement = useMemo(() => {
const el = document.getElementsByClassName(flyoutClassName);
if (el.length === 0) {
return null;
}
return el.item(0) as HTMLDivElement;
}, [flyoutClassName]);
const [panels, setPanels] = useState<{ [id: number]: Panel }>({});
const removePanel = useCallback((id: number) => {
setPanels((prev) => {
const { [id]: panelToRemove, ...rest } = prev;
return rest;
});
}, []);
const addPanel = useCallback(
(panel: Panel) => {
const nextId = panelId();
setPanels((prev) => {
return { ...prev, [nextId]: panel };
});
return {
removePanel: removePanel.bind(null, nextId),
isFixedWidth: fixedPanelWidths,
};
},
[removePanel, fixedPanelWidths]
);
const ctx: Context = useMemo(
() => ({
addPanel,
}),
[addPanel]
);
useLayoutEffect(() => {
if (!flyoutDOMelement) {
return;
}
let currentWidth: number;
if (fixedPanelWidths) {
const totalWidth = Object.values(panels).reduce((acc, { width = 0 }) => acc + width, 0);
currentWidth = Math.min(maxWidth, totalWidth);
// As EUI declares both min-width and max-width on the .euiFlyout CSS class
// we need to override both values
flyoutDOMelement.style.minWidth = `${limitWidthToWindow(currentWidth, window)}px`;
flyoutDOMelement.style.maxWidth = `${limitWidthToWindow(currentWidth, window)}px`;
} else {
const totalPercentWidth = Math.min(
100,
Object.values(panels).reduce((acc, { width = 0 }) => acc + width, 0)
);
currentWidth = (maxWidth * totalPercentWidth) / 100;
flyoutDOMelement.style.maxWidth = `${limitWidthToWindow(currentWidth, window)}px`;
}
}, [panels, maxWidth, fixedPanelWidths, flyoutClassName, flyoutDOMelement]);
return (
<flyoutPanelsContext.Provider value={ctx}>
<EuiFlexGroup className="fieldEditor__flyoutPanels" gutterSize="none" {...props} />
</flyoutPanelsContext.Provider>
);
};
export const useFlyoutPanelsContext = (): Context => {
const ctx = useContext(flyoutPanelsContext);
if (ctx === undefined) {
throw new Error('<Panel /> must be used within a <Panels /> wrapper');
}
return ctx;
};

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useEffect } from 'react';
import { useFlyoutPanelContext } from './flyout_panel';
export const PanelContent: React.FC = (props) => {
const { registerContent } = useFlyoutPanelContext();
useEffect(() => {
registerContent();
}, [registerContent]);
// Adding a tabIndex prop to the div as it is the body of the flyout which is scrollable.
return <div className="fieldEditor__flyoutPanel__content" tabIndex={0} {...props} />;
};

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
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useEffect } from 'react';
import { EuiFlyoutFooter, EuiFlyoutFooterProps } from '@elastic/eui';
import { useFlyoutPanelContext } from './flyout_panel';
export const PanelFooter: React.FC<
{ children: React.ReactNode } & Omit<EuiFlyoutFooterProps, 'children'>
> = (props) => {
const { registerFooter } = useFlyoutPanelContext();
useEffect(() => {
registerFooter();
}, [registerFooter]);
return <EuiFlyoutFooter className="fieldEditor__flyoutPanel__footer" {...props} />;
};

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiSpacer, EuiFlyoutHeader, EuiFlyoutHeaderProps } from '@elastic/eui';
export const PanelHeader: React.FunctionComponent<
{ children: React.ReactNode } & Omit<EuiFlyoutHeaderProps, 'children'>
> = (props) => (
<>
<EuiFlyoutHeader className="fieldEditor__flyoutPanel__header" {...props} />
<EuiSpacer />
</>
);

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
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { PanelFooter } from './flyout_panels_footer';
import { PanelHeader } from './flyout_panels_header';
import { PanelContent } from './flyout_panels_content';
import { Panel } from './flyout_panel';
import { Panels } from './flyout_panels';
export { useFlyoutPanelContext } from './flyout_panel';
export const FlyoutPanels = {
Group: Panels,
Item: Panel,
Content: PanelContent,
Header: PanelHeader,
Footer: PanelFooter,
};

View file

@ -6,17 +6,6 @@
* Side Public License, v 1.
*/
export {
FieldEditorFlyoutContent,
Props as FieldEditorFlyoutContentProps,
} from './field_editor_flyout_content';
export {
FieldEditorFlyoutContentContainer,
Props as FieldEditorFlyoutContentContainerProps,
FieldEditorContext,
} from './field_editor_flyout_content_container';
export { getDeleteFieldProvider, Props as DeleteFieldProviderProps } from './delete_field_provider';
export * from './field_format_editor';

View file

@ -0,0 +1,132 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiFieldText,
EuiButtonIcon,
EuiButtonEmpty,
} from '@elastic/eui';
import { useFieldPreviewContext } from './field_preview_context';
export const DocumentsNavPreview = () => {
const {
currentDocument: { id: documentId, isCustomId },
documents: { loadSingle, loadFromCluster },
navigation: { prev, next },
error,
} = useFieldPreviewContext();
const errorMessage =
error !== null && error.code === 'DOC_NOT_FOUND'
? i18n.translate(
'indexPatternFieldEditor.fieldPreview.documentIdField.documentNotFoundError',
{
defaultMessage: 'Document not found',
}
)
: null;
const isInvalid = error !== null && error.code === 'DOC_NOT_FOUND';
// We don't display the nav button when the user has entered a custom
// document ID as at that point there is no more reference to what's "next"
const showNavButtons = isCustomId === false;
const onDocumentIdChange = useCallback(
(e: React.SyntheticEvent<HTMLInputElement>) => {
const nextId = (e.target as HTMLInputElement).value;
loadSingle(nextId);
},
[loadSingle]
);
return (
<div>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<EuiFormRow
label={i18n.translate('indexPatternFieldEditor.fieldPreview.documentIdField.label', {
defaultMessage: 'Document ID',
})}
error={errorMessage}
isInvalid={isInvalid}
fullWidth
>
<EuiFieldText
isInvalid={isInvalid}
value={documentId}
onChange={onDocumentIdChange}
fullWidth
data-test-subj="documentIdField"
/>
</EuiFormRow>
{isCustomId && (
<span>
<EuiButtonEmpty
color="primary"
size="xs"
flush="left"
onClick={() => loadFromCluster()}
data-test-subj="loadDocsFromClusterButton"
>
{i18n.translate(
'indexPatternFieldEditor.fieldPreview.documentIdField.loadDocumentsFromCluster',
{
defaultMessage: 'Load documents from cluster',
}
)}
</EuiButtonEmpty>
</span>
)}
</EuiFlexItem>
{showNavButtons && (
<EuiFlexItem grow={false} data-test-subj="documentsNav">
<EuiFlexGroup gutterSize="s" alignItems="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonIcon
display="base"
size="m"
onClick={prev}
iconType="arrowLeft"
data-test-subj="goToPrevDocButton"
aria-label={i18n.translate(
'indexPatternFieldEditor.fieldPreview.documentNav.previousArialabel',
{
defaultMessage: 'Previous document',
}
)}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
display="base"
size="m"
onClick={next}
iconType="arrowRight"
data-test-subj="goToNextDocButton"
aria-label={i18n.translate(
'indexPatternFieldEditor.fieldPreview.documentNav.nextArialabel',
{
defaultMessage: 'Next document',
}
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
)}
</EuiFlexGroup>
</div>
);
};

View file

@ -0,0 +1,63 @@
/**
[1] This corresponds to the ITEM_HEIGHT declared in "field_list.tsx"
[2] This corresponds to the SHOW_MORE_HEIGHT declared in "field_list.tsx"
[3] We need the tooltip <span /> to be 100% to display the text ellipsis of the field value
*/
$previewFieldItemHeight: 40px; /* [1] */
$previewShowMoreHeight: 40px; /* [2] */
.indexPatternFieldEditor__previewFieldList {
position: relative;
&__item {
border-bottom: $euiBorderThin;
height: $previewFieldItemHeight;
align-items: center;
overflow: hidden;
&--highlighted {
$backgroundColor: tintOrShade($euiColorWarning, 90%, 70%);
background: $backgroundColor;
font-weight: 600;
}
&__key, &__value {
overflow: hidden;
}
&__actions {
flex-basis: 24px !important;
}
&__actionsBtn {
display: none;
}
&--pinned .indexPatternFieldEditor__previewFieldList__item__actionsBtn,
&:hover .indexPatternFieldEditor__previewFieldList__item__actionsBtn {
display: block;
}
&__value .euiToolTipAnchor {
width: 100%; /* [3] */
}
&__key__wrapper, &__value__wrapper {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
width: 100%;
}
}
&__showMore {
position: absolute;
width: 100%;
height: $previewShowMoreHeight;
bottom: $previewShowMoreHeight * -1;
display: flex;
align-items: flex-end;
}
}

View file

@ -0,0 +1,235 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useState, useMemo, useCallback } from 'react';
import VirtualList from 'react-tiny-virtual-list';
import { i18n } from '@kbn/i18n';
import { get } from 'lodash';
import { EuiButtonEmpty, EuiButton, EuiSpacer, EuiEmptyPrompt, EuiTextColor } from '@elastic/eui';
import { useFieldEditorContext } from '../../field_editor_context';
import {
useFieldPreviewContext,
defaultValueFormatter,
FieldPreview,
} from '../field_preview_context';
import { PreviewListItem } from './field_list_item';
import './field_list.scss';
const ITEM_HEIGHT = 40;
const SHOW_MORE_HEIGHT = 40;
const INITIAL_MAX_NUMBER_OF_FIELDS = 7;
export type DocumentField = FieldPreview & {
isPinned?: boolean;
};
interface Props {
height: number;
clearSearch: () => void;
searchValue?: string;
}
/**
* Escape regex special characters (e.g /, ^, $...) with a "\"
* Copied from https://stackoverflow.com/a/9310752
*/
function escapeRegExp(text: string) {
return text.replace(/[-\[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
}
function fuzzyMatch(searchValue: string, text: string) {
const pattern = `.*${searchValue.split('').map(escapeRegExp).join('.*')}.*`;
const regex = new RegExp(pattern, 'i');
return regex.test(text);
}
export const PreviewFieldList: React.FC<Props> = ({ height, clearSearch, searchValue = '' }) => {
const { indexPattern } = useFieldEditorContext();
const {
currentDocument: { value: currentDocument },
pinnedFields: { value: pinnedFields, set: setPinnedFields },
} = useFieldPreviewContext();
const [showAllFields, setShowAllFields] = useState(false);
const {
fields: { getAll: getAllFields },
} = indexPattern;
const indexPatternFields = useMemo(() => {
return getAllFields();
}, [getAllFields]);
const fieldList: DocumentField[] = useMemo(
() =>
indexPatternFields
.map(({ name, displayName }) => {
const value = get(currentDocument?._source, name);
const formattedValue = defaultValueFormatter(value);
return {
key: displayName,
value,
formattedValue,
isPinned: false,
};
})
.filter(({ value }) => value !== undefined),
[indexPatternFields, currentDocument?._source]
);
const fieldListWithPinnedFields: DocumentField[] = useMemo(() => {
const pinned: DocumentField[] = [];
const notPinned: DocumentField[] = [];
fieldList.forEach((field) => {
if (pinnedFields[field.key]) {
pinned.push({ ...field, isPinned: true });
} else {
notPinned.push({ ...field, isPinned: false });
}
});
return [...pinned, ...notPinned];
}, [fieldList, pinnedFields]);
const { filteredFields, totalFields } = useMemo(() => {
const list =
searchValue.trim() === ''
? fieldListWithPinnedFields
: fieldListWithPinnedFields.filter(({ key }) => fuzzyMatch(searchValue, key));
const total = list.length;
if (showAllFields) {
return {
filteredFields: list,
totalFields: total,
};
}
return {
filteredFields: list.filter((_, i) => i < INITIAL_MAX_NUMBER_OF_FIELDS),
totalFields: total,
};
}, [fieldListWithPinnedFields, showAllFields, searchValue]);
const hasSearchValue = searchValue.trim() !== '';
const isEmptySearchResultVisible = hasSearchValue && totalFields === 0;
// "height" corresponds to the total height of the flex item that occupies the remaining
// vertical space up to the bottom of the flyout panel. We don't want to give that height
// to the virtual list because it would mean that the "Show more" button would be pinned to the
// bottom of the panel all the time. Which is not what we want when we render initially a few
// fields.
const listHeight = Math.min(filteredFields.length * ITEM_HEIGHT, height - SHOW_MORE_HEIGHT);
const toggleShowAllFields = useCallback(() => {
setShowAllFields((prev) => !prev);
}, []);
const toggleIsPinnedField = useCallback(
(name) => {
setPinnedFields((prev) => {
const isPinned = !prev[name];
return {
...prev,
[name]: isPinned,
};
});
},
[setPinnedFields]
);
const renderEmptyResult = () => {
return (
<>
<EuiSpacer />
<EuiEmptyPrompt
iconType="search"
title={
<EuiTextColor color="subdued">
<h3 className="indexPatternFieldEditor__previewEmptySearchResult__title">
{i18n.translate(
'indexPatternFieldEditor.fieldPreview.searchResult.emptyPromptTitle',
{
defaultMessage: 'No matching fields in this index pattern',
}
)}
</h3>
</EuiTextColor>
}
titleSize="xs"
actions={
<EuiButton onClick={clearSearch} data-test-subj="clearSearchButton">
{i18n.translate(
'indexPatternFieldEditor.fieldPreview.searchResult.emptyPrompt.clearSearchButtonLabel',
{
defaultMessage: 'Clear search',
}
)}
</EuiButton>
}
data-test-subj="emptySearchResult"
/>
</>
);
};
const renderToggleFieldsButton = () =>
totalFields <= INITIAL_MAX_NUMBER_OF_FIELDS ? null : (
<div className="indexPatternFieldEditor__previewFieldList__showMore">
<EuiButtonEmpty onClick={toggleShowAllFields} flush="left">
{showAllFields
? i18n.translate('indexPatternFieldEditor.fieldPreview.showLessFieldsButtonLabel', {
defaultMessage: 'Show less',
})
: i18n.translate('indexPatternFieldEditor.fieldPreview.showMoreFieldsButtonLabel', {
defaultMessage: 'Show more',
})}
</EuiButtonEmpty>
</div>
);
if (currentDocument === undefined || height === -1) {
return null;
}
return (
<div className="indexPatternFieldEditor__previewFieldList">
{isEmptySearchResultVisible ? (
renderEmptyResult()
) : (
<VirtualList
style={{ overflowX: 'hidden' }}
width="100%"
height={listHeight}
itemCount={filteredFields.length}
itemSize={ITEM_HEIGHT}
overscanCount={4}
renderItem={({ index, style }) => {
const field = filteredFields[index];
return (
<div key={field.key} style={style} data-test-subj="indexPatternFieldList">
<PreviewListItem
key={field.key}
field={field}
toggleIsPinned={toggleIsPinnedField}
/>
</div>
);
}}
/>
)}
{renderToggleFieldsButton()}
</div>
);
};

View file

@ -0,0 +1,125 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useState } from 'react';
import classnames from 'classnames';
import { i18n } from '@kbn/i18n';
import { EuiFlexGroup, EuiFlexItem, EuiToolTip, EuiButtonIcon, EuiButtonEmpty } from '@elastic/eui';
import { ImagePreviewModal } from '../image_preview_modal';
import type { DocumentField } from './field_list';
interface Props {
field: DocumentField;
toggleIsPinned?: (name: string) => void;
highlighted?: boolean;
}
export const PreviewListItem: React.FC<Props> = ({
field: { key, value, formattedValue, isPinned = false },
highlighted,
toggleIsPinned,
}) => {
const [isPreviewImageModalVisible, setIsPreviewImageModalVisible] = useState(false);
/* eslint-disable @typescript-eslint/naming-convention */
const classes = classnames('indexPatternFieldEditor__previewFieldList__item', {
'indexPatternFieldEditor__previewFieldList__item--highlighted': highlighted,
'indexPatternFieldEditor__previewFieldList__item--pinned': isPinned,
});
/* eslint-enable @typescript-eslint/naming-convention */
const doesContainImage = formattedValue?.includes('<img');
const renderValue = () => {
if (doesContainImage) {
return (
<EuiButtonEmpty
color="text"
onClick={() => setIsPreviewImageModalVisible(true)}
iconType="image"
>
{i18n.translate('indexPatternFieldEditor.fieldPreview.viewImageButtonLabel', {
defaultMessage: 'View image',
})}
</EuiButtonEmpty>
);
}
if (formattedValue !== undefined) {
return (
<span
className="indexPatternFieldEditor__previewFieldList__item__value__wrapper"
// We can dangerously set HTML here because this content is guaranteed to have been run through a valid field formatter first.
dangerouslySetInnerHTML={{ __html: formattedValue! }} // eslint-disable-line react/no-danger
/>
);
}
return (
<span className="indexPatternFieldEditor__previewFieldList__item__value__wrapper">
{JSON.stringify(value)}
</span>
);
};
return (
<>
<EuiFlexGroup className={classes} gutterSize="none" data-test-subj="listItem">
<EuiFlexItem className="indexPatternFieldEditor__previewFieldList__item__key">
<div
className="indexPatternFieldEditor__previewFieldList__item__key__wrapper"
data-test-subj="key"
>
{key}
</div>
</EuiFlexItem>
<EuiFlexItem
className="indexPatternFieldEditor__previewFieldList__item__value"
data-test-subj="value"
>
<EuiToolTip
position="top"
content={typeof value !== 'string' ? JSON.stringify(value) : value}
>
{renderValue()}
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem
className="indexPatternFieldEditor__previewFieldList__item__actions"
grow={false}
>
{toggleIsPinned && (
<EuiButtonIcon
onClick={() => {
toggleIsPinned(key);
}}
color="text"
iconType="pinFilled"
data-test-subj="pinFieldButton"
aria-label={i18n.translate(
'indexPatternFieldEditor.fieldPreview.pinFieldButtonLabel',
{
defaultMessage: 'Pin field',
}
)}
className="indexPatternFieldEditor__previewFieldList__item__actionsBtn"
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
{isPreviewImageModalVisible && (
<ImagePreviewModal
imgHTML={formattedValue!}
closeModal={() => setIsPreviewImageModalVisible(false)}
/>
)}
</>
);
};

View file

@ -0,0 +1,19 @@
.indexPatternFieldEditor {
&__previewPannel {
display: flex;
flex-direction: column;
height: 100%;
}
&__previewImageModal__wrapper {
padding: $euiSize;
img {
max-width: 100%;
}
}
&__previewEmptySearchResult__title {
font-weight: 400;
}
}

View file

@ -0,0 +1,134 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useState, useCallback, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiSpacer, EuiResizeObserver, EuiFieldSearch } from '@elastic/eui';
import { useFieldPreviewContext } from './field_preview_context';
import { FieldPreviewHeader } from './field_preview_header';
import { FieldPreviewEmptyPrompt } from './field_preview_empty_prompt';
import { DocumentsNavPreview } from './documents_nav_preview';
import { FieldPreviewError } from './field_preview_error';
import { PreviewListItem } from './field_list/field_list_item';
import { PreviewFieldList } from './field_list/field_list';
import './field_preview.scss';
export const FieldPreview = () => {
const [fieldListHeight, setFieldListHeight] = useState(-1);
const [searchValue, setSearchValue] = useState('');
const {
params: {
value: { name, script, format },
},
fields,
error,
reset,
} = useFieldPreviewContext();
// To show the preview we at least need a name to be defined, the script or the format
// and an first response from the _execute API
const isEmptyPromptVisible =
name === null && script === null && format === null
? true
: // If we have some result from the _execute API call don't show the empty prompt
error !== null || fields.length > 0
? false
: name === null && format === null
? true
: false;
const onFieldListResize = useCallback(({ height }: { height: number }) => {
setFieldListHeight(height);
}, []);
const renderFieldsToPreview = () => {
if (fields.length === 0) {
return null;
}
const [field] = fields;
return (
<ul>
<li data-test-subj="fieldPreviewItem">
<PreviewListItem field={field} highlighted />
</li>
</ul>
);
};
useEffect(() => {
// When unmounting the preview pannel we make sure to reset
// the state of the preview panel.
return reset;
}, [reset]);
const doShowFieldList =
error === null || (error.code !== 'DOC_NOT_FOUND' && error.code !== 'ERR_FETCHING_DOC');
return (
<div
className="indexPatternFieldEditor__previewPannel"
// This tabIndex is for the scrollable area of the flyout panel.
tabIndex={0}
>
{isEmptyPromptVisible ? (
<FieldPreviewEmptyPrompt />
) : (
<>
<FieldPreviewHeader />
<EuiSpacer />
<DocumentsNavPreview />
<EuiSpacer size="s" />
<EuiFieldSearch
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
placeholder={i18n.translate(
'indexPatternFieldEditor.fieldPreview.filterFieldsPlaceholder',
{
defaultMessage: 'Filter fields',
}
)}
fullWidth
data-test-subj="filterFieldsInput"
/>
<EuiSpacer size="s" />
<FieldPreviewError />
<EuiSpacer size="s" />
{doShowFieldList && (
<>
{/* The current field(s) the user is creating */}
{renderFieldsToPreview()}
{/* List of other fields in the document */}
<EuiResizeObserver onResize={onFieldListResize}>
{(resizeRef) => (
<div ref={resizeRef} style={{ flex: 1 }}>
<PreviewFieldList
height={fieldListHeight}
clearSearch={() => setSearchValue('')}
searchValue={searchValue}
// We add a key to force rerender the virtual list whenever the window height changes
key={fieldListHeight}
/>
</div>
)}
</EuiResizeObserver>
</>
)}
</>
)}
</div>
);
};

View file

@ -0,0 +1,599 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, {
createContext,
useState,
useContext,
useMemo,
useCallback,
useEffect,
useRef,
FunctionComponent,
} from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import { i18n } from '@kbn/i18n';
import { get } from 'lodash';
import type { FieldPreviewContext, FieldFormatConfig } from '../../types';
import { parseEsError } from '../../lib/runtime_field_validation';
import { RuntimeType, RuntimeField } from '../../shared_imports';
import { useFieldEditorContext } from '../field_editor_context';
type From = 'cluster' | 'custom';
interface EsDocument {
_id: string;
[key: string]: any;
}
interface PreviewError {
code: 'DOC_NOT_FOUND' | 'PAINLESS_SCRIPT_ERROR' | 'ERR_FETCHING_DOC';
error: Record<string, any>;
}
interface ClusterData {
documents: EsDocument[];
currentIdx: number;
}
// The parameters required to preview the field
interface Params {
name: string | null;
index: string | null;
type: RuntimeType | null;
script: Required<RuntimeField>['script'] | null;
format: FieldFormatConfig | null;
document: EsDocument | null;
}
export interface FieldPreview {
key: string;
value: unknown;
formattedValue?: string;
}
interface Context {
fields: FieldPreview[];
error: PreviewError | null;
params: {
value: Params;
update: (updated: Partial<Params>) => void;
};
isLoadingPreview: boolean;
currentDocument: {
value?: EsDocument;
id: string;
isLoading: boolean;
isCustomId: boolean;
};
documents: {
loadSingle: (id: string) => void;
loadFromCluster: () => Promise<void>;
};
panel: {
isVisible: boolean;
setIsVisible: (isVisible: boolean) => void;
};
from: {
value: From;
set: (value: From) => void;
};
navigation: {
isFirstDoc: boolean;
isLastDoc: boolean;
next: () => void;
prev: () => void;
};
reset: () => void;
pinnedFields: {
value: { [key: string]: boolean };
set: React.Dispatch<React.SetStateAction<{ [key: string]: boolean }>>;
};
}
const fieldPreviewContext = createContext<Context | undefined>(undefined);
const defaultParams: Params = {
name: null,
index: null,
script: null,
document: null,
type: null,
format: null,
};
export const defaultValueFormatter = (value: unknown) =>
`<span>${typeof value === 'object' ? JSON.stringify(value) : value ?? '-'}</span>`;
export const FieldPreviewProvider: FunctionComponent = ({ children }) => {
const previewCount = useRef(0);
const [lastExecutePainlessRequestParams, setLastExecutePainlessReqParams] = useState<{
type: Params['type'];
script: string | undefined;
documentId: string | undefined;
}>({
type: null,
script: undefined,
documentId: undefined,
});
const {
indexPattern,
fieldTypeToProcess,
services: {
search,
notifications,
api: { getFieldPreview },
},
fieldFormats,
} = useFieldEditorContext();
/** Response from the Painless _execute API */
const [previewResponse, setPreviewResponse] = useState<{
fields: Context['fields'];
error: Context['error'];
}>({ fields: [], error: null });
/** The parameters required for the Painless _execute API */
const [params, setParams] = useState<Params>(defaultParams);
/** The sample documents fetched from the cluster */
const [clusterData, setClusterData] = useState<ClusterData>({
documents: [],
currentIdx: 0,
});
/** Flag to show/hide the preview panel */
const [isPanelVisible, setIsPanelVisible] = useState(false);
/** Flag to indicate if we are loading document from cluster */
const [isFetchingDocument, setIsFetchingDocument] = useState(false);
/** Flag to indicate if we are calling the _execute API */
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
/** Flag to indicate if we are loading a single document by providing its ID */
const [customDocIdToLoad, setCustomDocIdToLoad] = useState<string | null>(null);
/** Define if we provide the document to preview from the cluster or from a custom JSON */
const [from, setFrom] = useState<From>('cluster');
/** Map of fields pinned to the top of the list */
const [pinnedFields, setPinnedFields] = useState<{ [key: string]: boolean }>({});
const { documents, currentIdx } = clusterData;
const currentDocument: EsDocument | undefined = useMemo(() => documents[currentIdx], [
documents,
currentIdx,
]);
const currentDocIndex = currentDocument?._index;
const currentDocId: string = currentDocument?._id ?? '';
const totalDocs = documents.length;
const { name, document, script, format, type } = params;
const updateParams: Context['params']['update'] = useCallback((updated) => {
setParams((prev) => ({ ...prev, ...updated }));
}, []);
const needToUpdatePreview = useMemo(() => {
const isCurrentDocIdDefined = currentDocId !== '';
if (!isCurrentDocIdDefined) {
return false;
}
const allParamsDefined = (['type', 'script', 'index', 'document'] as Array<
keyof Params
>).every((key) => Boolean(params[key]));
if (!allParamsDefined) {
return false;
}
const hasSomeParamsChanged =
lastExecutePainlessRequestParams.type !== type ||
lastExecutePainlessRequestParams.script !== script?.source ||
lastExecutePainlessRequestParams.documentId !== currentDocId;
return hasSomeParamsChanged;
}, [type, script?.source, currentDocId, params, lastExecutePainlessRequestParams]);
const valueFormatter = useCallback(
(value: unknown) => {
if (format?.id) {
const formatter = fieldFormats.getInstance(format.id, format.params);
if (formatter) {
return formatter.convertObject?.html(value) ?? JSON.stringify(value);
}
}
return defaultValueFormatter(value);
},
[format, fieldFormats]
);
const fetchSampleDocuments = useCallback(
async (limit: number = 50) => {
if (typeof limit !== 'number') {
// We guard ourself from passing an <input /> event accidentally
throw new Error('The "limit" option must be a number');
}
setIsFetchingDocument(true);
setClusterData({
documents: [],
currentIdx: 0,
});
setPreviewResponse({ fields: [], error: null });
const [response, error] = await search
.search({
params: {
index: indexPattern.title,
body: {
size: limit,
},
},
})
.toPromise()
.then((res) => [res, null])
.catch((err) => [null, err]);
setIsFetchingDocument(false);
setCustomDocIdToLoad(null);
setClusterData({
documents: response ? response.rawResponse.hits.hits : [],
currentIdx: 0,
});
setPreviewResponse((prev) => ({ ...prev, error }));
},
[indexPattern, search]
);
const loadDocument = useCallback(
async (id: string) => {
if (!Boolean(id.trim())) {
return;
}
setIsFetchingDocument(true);
const [response, searchError] = await search
.search({
params: {
index: indexPattern.title,
body: {
size: 1,
query: {
ids: {
values: [id],
},
},
},
},
})
.toPromise()
.then((res) => [res, null])
.catch((err) => [null, err]);
setIsFetchingDocument(false);
const isDocumentFound = response?.rawResponse.hits.total > 0;
const loadedDocuments: EsDocument[] = isDocumentFound ? response.rawResponse.hits.hits : [];
const error: Context['error'] = Boolean(searchError)
? {
code: 'ERR_FETCHING_DOC',
error: {
message: searchError.toString(),
},
}
: isDocumentFound === false
? {
code: 'DOC_NOT_FOUND',
error: {
message: i18n.translate(
'indexPatternFieldEditor.fieldPreview.error.documentNotFoundDescription',
{
defaultMessage: 'Document ID not found',
}
),
},
}
: null;
setPreviewResponse((prev) => ({ ...prev, error }));
setClusterData({
documents: loadedDocuments,
currentIdx: 0,
});
if (error !== null) {
// Make sure we disable the "Updating..." indicator as we have an error
// and we won't fetch the preview
setIsLoadingPreview(false);
}
},
[indexPattern, search]
);
const updatePreview = useCallback(async () => {
setLastExecutePainlessReqParams({
type: params.type,
script: params.script?.source,
documentId: currentDocId,
});
if (!needToUpdatePreview) {
return;
}
const currentApiCall = ++previewCount.current;
const response = await getFieldPreview({
index: currentDocIndex,
document: params.document!,
context: `${params.type!}_field` as FieldPreviewContext,
script: params.script!,
});
if (currentApiCall !== previewCount.current) {
// Discard this response as there is another one inflight
// or we have called reset() and don't need the response anymore.
return;
}
setIsLoadingPreview(false);
const { error: serverError } = response;
if (serverError) {
// Server error (not an ES error)
const title = i18n.translate('indexPatternFieldEditor.fieldPreview.errorTitle', {
defaultMessage: 'Failed to load field preview',
});
notifications.toasts.addError(serverError, { title });
return;
}
const { values, error } = response.data ?? { values: [], error: {} };
if (error) {
const fallBackError = {
message: i18n.translate('indexPatternFieldEditor.fieldPreview.defaultErrorTitle', {
defaultMessage: 'Unable to run the provided script',
}),
};
setPreviewResponse({
fields: [],
error: { code: 'PAINLESS_SCRIPT_ERROR', error: parseEsError(error, true) ?? fallBackError },
});
} else {
const [value] = values;
const formattedValue = valueFormatter(value);
setPreviewResponse({
fields: [{ key: params.name!, value, formattedValue }],
error: null,
});
}
}, [
needToUpdatePreview,
params,
currentDocIndex,
currentDocId,
getFieldPreview,
notifications.toasts,
valueFormatter,
]);
const goToNextDoc = useCallback(() => {
if (currentIdx >= totalDocs - 1) {
setClusterData((prev) => ({ ...prev, currentIdx: 0 }));
} else {
setClusterData((prev) => ({ ...prev, currentIdx: prev.currentIdx + 1 }));
}
}, [currentIdx, totalDocs]);
const goToPrevDoc = useCallback(() => {
if (currentIdx === 0) {
setClusterData((prev) => ({ ...prev, currentIdx: totalDocs - 1 }));
} else {
setClusterData((prev) => ({ ...prev, currentIdx: prev.currentIdx - 1 }));
}
}, [currentIdx, totalDocs]);
const reset = useCallback(() => {
// By resetting the previewCount we will discard any inflight
// API call response coming in after calling reset() was called
previewCount.current = 0;
setClusterData({
documents: [],
currentIdx: 0,
});
setPreviewResponse({ fields: [], error: null });
setLastExecutePainlessReqParams({
type: null,
script: undefined,
documentId: undefined,
});
setFrom('cluster');
setIsLoadingPreview(false);
setIsFetchingDocument(false);
}, []);
const ctx = useMemo<Context>(
() => ({
fields: previewResponse.fields,
error: previewResponse.error,
isLoadingPreview,
params: {
value: params,
update: updateParams,
},
currentDocument: {
value: currentDocument,
id: customDocIdToLoad !== null ? customDocIdToLoad : currentDocId,
isLoading: isFetchingDocument,
isCustomId: customDocIdToLoad !== null,
},
documents: {
loadSingle: setCustomDocIdToLoad,
loadFromCluster: fetchSampleDocuments,
},
navigation: {
isFirstDoc: currentIdx === 0,
isLastDoc: currentIdx >= totalDocs - 1,
next: goToNextDoc,
prev: goToPrevDoc,
},
panel: {
isVisible: isPanelVisible,
setIsVisible: setIsPanelVisible,
},
from: {
value: from,
set: setFrom,
},
reset,
pinnedFields: {
value: pinnedFields,
set: setPinnedFields,
},
}),
[
previewResponse,
params,
isLoadingPreview,
updateParams,
currentDocument,
currentDocId,
fetchSampleDocuments,
isFetchingDocument,
customDocIdToLoad,
currentIdx,
totalDocs,
goToNextDoc,
goToPrevDoc,
isPanelVisible,
from,
reset,
pinnedFields,
]
);
/**
* In order to immediately display the "Updating..." state indicator and not have to wait
* the 500ms of the debounce, we set the isLoadingPreview state in this effect
*/
useEffect(() => {
if (needToUpdatePreview) {
setIsLoadingPreview(true);
}
}, [needToUpdatePreview, customDocIdToLoad]);
/**
* Whenever we enter manually a document ID to load we'll clear the
* documents and the preview value.
*/
useEffect(() => {
if (customDocIdToLoad !== null) {
setIsFetchingDocument(true);
setClusterData({
documents: [],
currentIdx: 0,
});
setPreviewResponse((prev) => {
const {
fields: { 0: field },
} = prev;
return {
...prev,
fields: [
{ ...field, value: undefined, formattedValue: defaultValueFormatter(undefined) },
],
};
});
}
}, [customDocIdToLoad]);
/**
* Whenever we show the preview panel we will update the documents from the cluster
*/
useEffect(() => {
if (isPanelVisible) {
fetchSampleDocuments();
}
}, [isPanelVisible, fetchSampleDocuments, fieldTypeToProcess]);
/**
* Each time the current document changes we update the parameters
* that will be sent in the _execute HTTP request.
*/
useEffect(() => {
updateParams({
document: currentDocument?._source,
index: currentDocument?._index,
});
}, [currentDocument, updateParams]);
/**
* Whenever the name or the format changes we immediately update the preview
*/
useEffect(() => {
setPreviewResponse((prev) => {
const {
fields: { 0: field },
} = prev;
const nextValue =
script === null && Boolean(document)
? get(document, name ?? '') // When there is no script we read the value from _source
: field?.value;
const formattedValue = valueFormatter(nextValue);
return {
...prev,
fields: [{ ...field, key: name ?? '', value: nextValue, formattedValue }],
};
});
}, [name, script, document, valueFormatter]);
useDebounce(
// Whenever updatePreview() changes (meaning whenever any of the params changes)
// we call it to update the preview response with the field(s) value or possible error.
updatePreview,
500,
[updatePreview]
);
useDebounce(
() => {
if (customDocIdToLoad === null) {
return;
}
loadDocument(customDocIdToLoad);
},
500,
[customDocIdToLoad]
);
return <fieldPreviewContext.Provider value={ctx}>{children}</fieldPreviewContext.Provider>;
};
export const useFieldPreviewContext = (): Context => {
const ctx = useContext(fieldPreviewContext);
if (ctx === undefined) {
throw new Error('useFieldPreviewContext must be used within a <FieldPreviewProvider />');
}
return ctx;
};

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiEmptyPrompt, EuiText, EuiTextColor, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
export const FieldPreviewEmptyPrompt = () => {
return (
<EuiFlexGroup style={{ height: '100%' }} data-test-subj="emptyPrompt">
<EuiFlexItem>
<EuiEmptyPrompt
iconType="inspect"
title={
<h2>
{i18n.translate('indexPatternFieldEditor.fieldPreview.emptyPromptTitle', {
defaultMessage: 'Preview',
})}
</h2>
}
titleSize="s"
body={
<EuiText size="s">
<EuiTextColor color="subdued">
<p>
{i18n.translate('indexPatternFieldEditor.fieldPreview.emptyPromptDescription', {
defaultMessage:
'Enter the name of an existing field or define a script to view a preview of the calculated output.',
})}
</p>
</EuiTextColor>
</EuiText>
}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiCallOut } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useFieldPreviewContext } from './field_preview_context';
export const FieldPreviewError = () => {
const { error } = useFieldPreviewContext();
if (error === null) {
return null;
}
return (
<EuiCallOut
title={i18n.translate('indexPatternFieldEditor.fieldPreview.errorCallout.title', {
defaultMessage: 'Preview error',
})}
color="danger"
iconType="cross"
role="alert"
data-test-subj="previewError"
>
{error.code === 'PAINLESS_SCRIPT_ERROR' ? (
<p data-test-subj="reason">{error.error.reason}</p>
) : (
<p data-test-subj="title">{error.error.message}</p>
)}
</EuiCallOut>
);
};

View file

@ -0,0 +1,77 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import {
EuiTitle,
EuiText,
EuiTextColor,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useFieldEditorContext } from '../field_editor_context';
import { useFieldPreviewContext } from './field_preview_context';
const i18nTexts = {
title: i18n.translate('indexPatternFieldEditor.fieldPreview.title', {
defaultMessage: 'Preview',
}),
customData: i18n.translate('indexPatternFieldEditor.fieldPreview.subTitle.customData', {
defaultMessage: 'Custom data',
}),
updatingLabel: i18n.translate('indexPatternFieldEditor.fieldPreview.updatingPreviewLabel', {
defaultMessage: 'Updating...',
}),
};
export const FieldPreviewHeader = () => {
const { indexPattern } = useFieldEditorContext();
const {
from,
isLoadingPreview,
currentDocument: { isLoading },
} = useFieldPreviewContext();
const isUpdating = isLoadingPreview || isLoading;
return (
<div>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size="s">
<h2 data-test-subj="title">{i18nTexts.title}</h2>
</EuiTitle>
</EuiFlexItem>
{isUpdating && (
<EuiFlexItem data-test-subj="isUpdatingIndicator">
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="m" />
</EuiFlexItem>
<EuiFlexItem grow={false}>{i18nTexts.updatingLabel}</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiText>
<EuiTextColor color="subdued" data-test-subj="subTitle">
{i18n.translate('indexPatternFieldEditor.fieldPreview.subTitle', {
defaultMessage: 'From: {from}',
values: {
from: from.value === 'cluster' ? indexPattern.title : i18nTexts.customData,
},
})}
</EuiTextColor>
</EuiText>
</div>
);
};

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiModal, EuiModalBody } from '@elastic/eui';
/**
* By default the image formatter sets the max-width to "none" on the <img /> tag
* To render nicely the image in the modal we want max_width: 100%
*/
const setMaxWidthImage = (imgHTML: string): string => {
const regex = new RegExp('max-width:[^;]+;', 'gm');
if (regex.test(imgHTML)) {
return imgHTML.replace(regex, 'max-width: 100%;');
}
return imgHTML;
};
interface Props {
imgHTML: string;
closeModal: () => void;
}
export const ImagePreviewModal = ({ imgHTML, closeModal }: Props) => {
return (
<EuiModal onClose={closeModal}>
<EuiModalBody>
<div
className="indexPatternFieldEditor__previewImageModal__wrapper"
// We can dangerously set HTML here because this content is guaranteed to have been run through a valid field formatter first.
dangerouslySetInnerHTML={{ __html: setMaxWidthImage(imgHTML) }} // eslint-disable-line react/no-danger
/>
</EuiModalBody>
</EuiModal>
);
};

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
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { useFieldPreviewContext, FieldPreviewProvider } from './field_preview_context';
export { FieldPreview } from './field_preview';

View file

@ -7,3 +7,5 @@
*/
export const pluginName = 'index_pattern_field_editor';
export const euiFlyoutClassname = 'indexPatternFieldEditorFlyout';

View file

@ -21,7 +21,7 @@
import { IndexPatternFieldEditorPlugin } from './plugin';
export { PluginStart as IndexPatternFieldEditorStart } from './types';
export { DefaultFormatEditor } from './components';
export { DefaultFormatEditor } from './components/field_format_editor/editors/default/default';
export { FieldFormatEditorFactory, FieldFormatEditor, FormatEditorProps } from './components';
export function plugin() {
@ -31,4 +31,3 @@ export function plugin() {
// Expose types
export type { OpenFieldEditorOptions } from './open_editor';
export type { OpenFieldDeleteModalOptions } from './open_delete_modal';
export type { FieldEditorContext } from './components/field_editor_flyout_content_container';

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { HttpSetup } from 'src/core/public';
import { API_BASE_PATH } from '../../common/constants';
import { sendRequest } from '../shared_imports';
import { FieldPreviewContext, FieldPreviewResponse } from '../types';
export const initApi = (httpClient: HttpSetup) => {
const getFieldPreview = ({
index,
context,
script,
document,
}: {
index: string;
context: FieldPreviewContext;
script: { source: string } | null;
document: Record<string, any>;
}) => {
return sendRequest<FieldPreviewResponse>(httpClient, {
path: `${API_BASE_PATH}/field_preview`,
method: 'post',
body: {
index,
context,
script,
document,
},
});
};
return {
getFieldPreview,
};
};
export type ApiService = ReturnType<typeof initApi>;

View file

@ -10,4 +10,10 @@ export { deserializeField } from './serialization';
export { getLinks } from './documentation';
export { getRuntimeFieldValidator, RuntimeFieldPainlessError } from './runtime_field_validation';
export {
getRuntimeFieldValidator,
RuntimeFieldPainlessError,
parseEsError,
} from './runtime_field_validation';
export { initApi, ApiService } from './api';

View file

@ -8,7 +8,7 @@
import { i18n } from '@kbn/i18n';
import { DataPublicPluginStart } from '../shared_imports';
import { EsRuntimeField } from '../types';
import type { EsRuntimeField } from '../types';
export interface RuntimeFieldPainlessError {
message: string;
@ -60,12 +60,15 @@ const getScriptExceptionError = (error: Error): Error | null => {
return scriptExceptionError;
};
const parseEsError = (error?: Error): RuntimeFieldPainlessError | null => {
export const parseEsError = (
error?: Error,
isScriptError = false
): RuntimeFieldPainlessError | null => {
if (error === undefined) {
return null;
}
const scriptError = getScriptExceptionError(error.caused_by);
const scriptError = isScriptError ? error : getScriptExceptionError(error.caused_by);
if (scriptError === null) {
return null;

View file

@ -7,7 +7,7 @@
*/
import { IndexPatternField, IndexPattern } from '../shared_imports';
import { Field } from '../types';
import type { Field } from '../types';
export const deserializeField = (
indexPattern: IndexPattern,

View file

@ -18,7 +18,7 @@ import {
import { CloseEditor } from './types';
import { DeleteFieldModal } from './components/delete_field_modal';
import { DeleteFieldModal } from './components/confirm_modals/delete_field_modal';
import { removeFields } from './lib/remove_fields';
export interface OpenFieldDeleteModalOptions {

View file

@ -19,10 +19,10 @@ import {
UsageCollectionStart,
} from './shared_imports';
import { InternalFieldType, CloseEditor } from './types';
import { FieldEditorFlyoutContentContainer } from './components/field_editor_flyout_content_container';
import { PluginStart } from './types';
import type { PluginStart, InternalFieldType, CloseEditor } from './types';
import type { ApiService } from './lib/api';
import { euiFlyoutClassname } from './constants';
import { FieldEditorLoader } from './components/field_editor_loader';
export interface OpenFieldEditorOptions {
ctx: {
@ -37,6 +37,7 @@ interface Dependencies {
/** The search service from the data plugin */
search: DataPublicPluginStart['search'];
indexPatternService: DataPublicPluginStart['indexPatterns'];
apiService: ApiService;
fieldFormats: DataPublicPluginStart['fieldFormats'];
fieldFormatEditors: PluginStart['fieldFormatEditors'];
usageCollection: UsageCollectionStart;
@ -49,6 +50,7 @@ export const getFieldEditorOpener = ({
fieldFormatEditors,
search,
usageCollection,
apiService,
}: Dependencies) => (options: OpenFieldEditorOptions): CloseEditor => {
const { uiSettings, overlays, docLinks, notifications } = core;
const { Provider: KibanaReactContextProvider } = createKibanaReactContext({
@ -58,8 +60,19 @@ export const getFieldEditorOpener = ({
});
let overlayRef: OverlayRef | null = null;
const canCloseValidator = {
current: () => true,
};
const openEditor = ({ onSave, fieldName, ctx }: OpenFieldEditorOptions): CloseEditor => {
const onMounted = (args: { canCloseValidator: () => boolean }) => {
canCloseValidator.current = args.canCloseValidator;
};
const openEditor = ({
onSave,
fieldName,
ctx: { indexPattern },
}: OpenFieldEditorOptions): CloseEditor => {
const closeEditor = () => {
if (overlayRef) {
overlayRef.close();
@ -75,7 +88,7 @@ export const getFieldEditorOpener = ({
}
};
const field = fieldName ? ctx.indexPattern.getFieldByName(fieldName) : undefined;
const field = fieldName ? indexPattern.getFieldByName(fieldName) : undefined;
if (fieldName && !field) {
const err = i18n.translate('indexPatternFieldEditor.noSuchFieldName', {
@ -94,21 +107,48 @@ export const getFieldEditorOpener = ({
overlayRef = overlays.openFlyout(
toMountPoint(
<KibanaReactContextProvider>
<FieldEditorFlyoutContentContainer
<FieldEditorLoader
onSave={onSaveField}
onCancel={closeEditor}
onMounted={onMounted}
docLinks={docLinks}
field={field}
ctx={{ ...ctx, fieldTypeToProcess, search }}
fieldTypeToProcess={fieldTypeToProcess}
indexPattern={indexPattern}
search={search}
indexPatternService={indexPatternService}
notifications={notifications}
usageCollection={usageCollection}
apiService={apiService}
fieldFormatEditors={fieldFormatEditors}
fieldFormats={fieldFormats}
uiSettings={uiSettings}
usageCollection={usageCollection}
/>
</KibanaReactContextProvider>
)
),
{
className: euiFlyoutClassname,
maxWidth: 708,
size: 'l',
ownFocus: true,
hideCloseButton: true,
'aria-label': isNewRuntimeField
? i18n.translate('indexPatternFieldEditor.createField.flyoutAriaLabel', {
defaultMessage: 'Create field',
})
: i18n.translate('indexPatternFieldEditor.editField.flyoutAriaLabel', {
defaultMessage: 'Edit {fieldName} field',
values: {
fieldName,
},
}),
onClose: (flyout) => {
const canClose = canCloseValidator.current();
if (canClose) {
flyout.close();
}
},
}
);
return closeEditor;

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import React from 'react';
import { registerTestBed } from '@kbn/test/jest';
jest.mock('../../kibana_react/public', () => {
const original = jest.requireActual('../../kibana_react/public');
@ -21,11 +22,9 @@ import { coreMock } from 'src/core/public/mocks';
import { dataPluginMock } from '../../data/public/mocks';
import { usageCollectionPluginMock } from '../../usage_collection/public/mocks';
import { registerTestBed } from './test_utils';
import { FieldEditorFlyoutContentContainer } from './components/field_editor_flyout_content_container';
import { FieldEditorLoader } from './components/field_editor_loader';
import { IndexPatternFieldEditorPlugin } from './plugin';
import { DeleteFieldModal } from './components/delete_field_modal';
import { DeleteFieldModal } from './components/confirm_modals/delete_field_modal';
import { IndexPattern } from './shared_imports';
const noop = () => {};
@ -66,7 +65,7 @@ describe('IndexPatternFieldEditorPlugin', () => {
expect(openFlyout).toHaveBeenCalled();
const [[arg]] = openFlyout.mock.calls;
expect(arg.props.children.type).toBe(FieldEditorFlyoutContentContainer);
expect(arg.props.children.type).toBe(FieldEditorLoader);
// We force call the "onSave" prop from the <RuntimeFieldEditorFlyoutContent /> component
// and make sure that the the spy is being called.

View file

@ -8,11 +8,12 @@
import { Plugin, CoreSetup, CoreStart } from 'src/core/public';
import { PluginSetup, PluginStart, SetupPlugins, StartPlugins } from './types';
import type { PluginSetup, PluginStart, SetupPlugins, StartPlugins } from './types';
import { getFieldEditorOpener } from './open_editor';
import { FormatEditorService } from './service';
import { FormatEditorService } from './service/format_editor_service';
import { getDeleteFieldProvider } from './components/delete_field_provider';
import { getFieldDeleteModalOpener } from './open_delete_modal';
import { initApi } from './lib/api';
export class IndexPatternFieldEditorPlugin
implements Plugin<PluginSetup, PluginStart, SetupPlugins, StartPlugins> {
@ -30,6 +31,7 @@ export class IndexPatternFieldEditorPlugin
const { fieldFormatEditors } = this.formatEditorService.start();
const {
application: { capabilities },
http,
} = core;
const { data, usageCollection } = plugins;
const openDeleteModal = getFieldDeleteModalOpener({
@ -42,6 +44,7 @@ export class IndexPatternFieldEditorPlugin
openEditor: getFieldEditorOpener({
core,
indexPatternService: data.indexPatterns,
apiService: initApi(http),
fieldFormats: data.fieldFormats,
fieldFormatEditors,
search: data.search,

View file

@ -20,6 +20,7 @@ export {
useForm,
useFormData,
useFormContext,
useFormIsModified,
Form,
FormSchema,
UseField,
@ -31,3 +32,5 @@ export {
export { fieldValidators } from '../../es_ui_shared/static/forms/helpers';
export { TextField, ToggleField, NumericField } from '../../es_ui_shared/static/forms/components';
export { sendRequest } from '../../es_ui_shared/public';

View file

@ -1,41 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { act } from 'react-dom/test-utils';
import { TestBed } from './test_utils';
export const getCommonActions = (testBed: TestBed) => {
const toggleFormRow = (row: 'customLabel' | 'value' | 'format', value: 'on' | 'off' = 'on') => {
const testSubj = `${row}Row.toggle`;
const toggle = testBed.find(testSubj);
const isOn = toggle.props()['aria-checked'];
if ((value === 'on' && isOn) || (value === 'off' && isOn === false)) {
return;
}
testBed.form.toggleEuiSwitch(testSubj);
};
const changeFieldType = async (value: string, label?: string) => {
await act(async () => {
testBed.find('typeField').simulate('change', [
{
value,
label: label ?? value,
},
]);
});
testBed.component.update();
};
return {
toggleFormRow,
changeFieldType,
};
};

View file

@ -1,24 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { DocLinksStart } from 'src/core/public';
export const noop = () => {};
export const docLinks: DocLinksStart = {
ELASTIC_WEBSITE_URL: 'htts://jestTest.elastic.co',
DOC_LINK_VERSION: 'jest',
links: {} as any,
};
// TODO check how we can better stub an index pattern format
export const fieldFormats = {
getDefaultInstance: () => ({
convert: (val: any) => val,
}),
} as any;

View file

@ -65,3 +65,17 @@ export interface EsRuntimeField {
}
export type CloseEditor = () => void;
export type FieldPreviewContext =
| 'boolean_field'
| 'date_field'
| 'double_field'
| 'geo_point_field'
| 'ip_field'
| 'keyword_field'
| 'long_field';
export interface FieldPreviewResponse {
values: unknown[];
error?: Record<string, any>;
}

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { PluginInitializerContext } from '../../../../src/core/server';
import { IndexPatternPlugin } from './plugin';
export function plugin(initializerContext: PluginInitializerContext) {
return new IndexPatternPlugin(initializerContext);
}

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { PluginInitializerContext, CoreSetup, Plugin, Logger } from 'kibana/server';
import { ApiRoutes } from './routes';
export class IndexPatternPlugin implements Plugin<void, void, any, any> {
private readonly logger: Logger;
private readonly apiRoutes: ApiRoutes;
constructor({ logger }: PluginInitializerContext) {
this.logger = logger.get();
this.apiRoutes = new ApiRoutes();
}
public setup({ http }: CoreSetup) {
this.logger.debug('index_pattern_field_editor: setup');
const router = http.createRouter();
this.apiRoutes.setup({ router });
}
public start() {}
public stop() {}
}

View file

@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { schema } from '@kbn/config-schema';
import { HttpResponsePayload } from 'kibana/server';
import { API_BASE_PATH } from '../../common/constants';
import { RouteDependencies } from '../types';
import { handleEsError } from '../shared_imports';
const bodySchema = schema.object({
index: schema.string(),
script: schema.object({ source: schema.string() }),
context: schema.oneOf([
schema.literal('boolean_field'),
schema.literal('date_field'),
schema.literal('double_field'),
schema.literal('geo_point_field'),
schema.literal('ip_field'),
schema.literal('keyword_field'),
schema.literal('long_field'),
]),
document: schema.object({}, { unknowns: 'allow' }),
});
export const registerFieldPreviewRoute = ({ router }: RouteDependencies): void => {
router.post(
{
path: `${API_BASE_PATH}/field_preview`,
validate: {
body: bodySchema,
},
},
async (ctx, req, res) => {
const { client } = ctx.core.elasticsearch;
const body = JSON.stringify({
script: req.body.script,
context: req.body.context,
context_setup: {
document: req.body.document,
index: req.body.index,
} as any,
});
try {
const response = await client.asCurrentUser.scriptsPainlessExecute({
// @ts-expect-error `ExecutePainlessScriptRequest.body` does not allow `string`
body,
});
const fieldValue = (response.body.result as any[]) as HttpResponsePayload;
return res.ok({ body: { values: fieldValue } });
} catch (error) {
// Assume invalid painless script was submitted
// Return 200 with error object
const handleCustomError = () => {
return res.ok({
body: { values: [], ...error.body },
});
};
return handleEsError({ error, response: res, handleCustomError });
}
}
);
};

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
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { RouteDependencies } from '../types';
import { registerFieldPreviewRoute } from './field_preview';
export class ApiRoutes {
setup(dependencies: RouteDependencies) {
registerFieldPreviewRoute(dependencies);
}
}

View file

@ -6,6 +6,4 @@
* Side Public License, v 1.
*/
export { getRandomString } from '@kbn/test/jest';
export { registerTestBed, TestBed } from '@kbn/test/jest';
export { handleEsError } from '../../es_ui_shared/server';

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { IRouter } from 'src/core/server';
export interface RouteDependencies {
router: IRouter;
}

View file

@ -7,7 +7,11 @@
"declarationMap": true
},
"include": [
"../../../typings/**/*",
"__jest__/**/*",
"common/**/*",
"public/**/*",
"server/**/*",
],
"references": [
{ "path": "../../core/tsconfig.json" },

View file

@ -5,11 +5,16 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const PageObjects = getPageObjects(['common', 'settings', 'header']);
const PageObjects = getPageObjects([
'common',
'settings',
'header',
'indexPatternFieldEditorObjects',
]);
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
const a11y = getService('a11y');
@ -58,10 +63,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.settings.toggleRow('customLabelRow');
await PageObjects.settings.setCustomLabel('custom label');
await testSubjects.click('toggleAdvancedSetting');
// Let's make sure the field preview is visible before testing the snapshot
const isFieldPreviewVisible = await PageObjects.indexPatternFieldEditorObjects.isFieldPreviewVisible();
expect(isFieldPreviewVisible).to.be(true);
await a11y.testAppSnapshot();
await testSubjects.click('euiFlyoutCloseButton');
await PageObjects.settings.closeIndexPatternFieldEditor();
});
@ -83,7 +90,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('Edit field type', async () => {
await PageObjects.settings.clickEditFieldFormat();
await a11y.testAppSnapshot();
await PageObjects.settings.clickCloseEditFieldFormatFlyout();
await PageObjects.settings.closeIndexPatternFieldEditor();
});
it('Advanced settings', async () => {

View file

@ -14,6 +14,7 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./core'));
loadTestFile(require.resolve('./general'));
loadTestFile(require.resolve('./home'));
loadTestFile(require.resolve('./index_pattern_field_editor'));
loadTestFile(require.resolve('./index_patterns'));
loadTestFile(require.resolve('./kql_telemetry'));
loadTestFile(require.resolve('./saved_objects_management'));

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export const API_BASE_PATH = '/api/index_pattern_field_editor';

View file

@ -0,0 +1,130 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
import { API_BASE_PATH } from './constants';
const INDEX_NAME = 'api-integration-test-field-preview';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const es = getService('es');
const createIndex = async () => {
await es.indices.create({
index: INDEX_NAME,
body: {
mappings: {
properties: {
foo: {
type: 'integer',
},
bar: {
type: 'keyword',
},
},
},
},
});
};
const deleteIndex = async () => {
await es.indices.delete({
index: INDEX_NAME,
});
};
describe('Field preview', function () {
before(async () => await createIndex());
after(async () => await deleteIndex());
describe('should return the script value', () => {
const document = { foo: 1, bar: 'hello' };
const tests = [
{
context: 'keyword_field',
script: {
source: 'emit("test")',
},
expected: 'test',
},
{
context: 'long_field',
script: {
source: 'emit(doc["foo"].value + 1)',
},
expected: 2,
},
{
context: 'keyword_field',
script: {
source: 'emit(doc["bar"].value + " world")',
},
expected: 'hello world',
},
];
tests.forEach((test) => {
it(`> ${test.context}`, async () => {
const payload = {
script: test.script,
document,
context: test.context,
index: INDEX_NAME,
};
const { body: response } = await supertest
.post(`${API_BASE_PATH}/field_preview`)
.send(payload)
.set('kbn-xsrf', 'xxx')
.expect(200);
expect(response.values).eql([test.expected]);
});
});
});
describe('payload validation', () => {
it('should require a script', async () => {
await supertest
.post(`${API_BASE_PATH}/field_preview`)
.send({
context: 'keyword_field',
index: INDEX_NAME,
})
.set('kbn-xsrf', 'xxx')
.expect(400);
});
it('should require a context', async () => {
await supertest
.post(`${API_BASE_PATH}/field_preview`)
.send({
script: { source: 'emit("hello")' },
index: INDEX_NAME,
})
.set('kbn-xsrf', 'xxx')
.expect(400);
});
it('should require an index', async () => {
await supertest
.post(`${API_BASE_PATH}/field_preview`)
.send({
script: { source: 'emit("hello")' },
context: 'keyword_field',
})
.set('kbn-xsrf', 'xxx')
.expect(400);
});
});
});
}

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('index pattern field editor', () => {
loadTestFile(require.resolve('./field_preview'));
});
}

View file

@ -53,7 +53,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.settings.setFieldFormat('duration');
await PageObjects.settings.setFieldFormat('bytes');
await PageObjects.settings.setFieldFormat('duration');
await testSubjects.click('euiFlyoutCloseButton');
await PageObjects.settings.closeIndexPatternFieldEditor();
});
});

View file

@ -17,8 +17,7 @@ export default function ({ getService, getPageObjects }) {
const PageObjects = getPageObjects(['settings']);
const testSubjects = getService('testSubjects');
// Failing: See https://github.com/elastic/kibana/issues/95376
describe.skip('runtime fields', function () {
describe('runtime fields', function () {
this.tags(['skipFirefox']);
before(async function () {
@ -44,7 +43,18 @@ export default function ({ getService, getPageObjects }) {
await PageObjects.settings.clickIndexPatternLogstash();
const startingCount = parseInt(await PageObjects.settings.getFieldsTabCount());
await log.debug('add runtime field');
await PageObjects.settings.addRuntimeField(fieldName, 'Keyword', "emit('hello world')");
await PageObjects.settings.addRuntimeField(
fieldName,
'Keyword',
"emit('hello world')",
false
);
await log.debug('check that field preview is rendered');
expect(await testSubjects.exists('fieldPreviewItem', { timeout: 1500 })).to.be(true);
await PageObjects.settings.clickSaveField();
await retry.try(async function () {
expect(parseInt(await PageObjects.settings.getFieldsTabCount())).to.be(startingCount + 1);
});

View file

@ -30,6 +30,7 @@ import { TagCloudPageObject } from './tag_cloud_page';
import { VegaChartPageObject } from './vega_chart_page';
import { SavedObjectsPageObject } from './management/saved_objects_page';
import { LegacyDataTableVisPageObject } from './legacy/data_table_vis';
import { IndexPatternFieldEditorPageObject } from './management/indexpattern_field_editor_page';
export const pageObjects = {
common: CommonPageObject,
@ -56,4 +57,5 @@ export const pageObjects = {
tagCloud: TagCloudPageObject,
vegaChart: VegaChartPageObject,
savedObjects: SavedObjectsPageObject,
indexPatternFieldEditorObjects: IndexPatternFieldEditorPageObject,
};

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { FtrService } from '../../ftr_provider_context';
export class IndexPatternFieldEditorPageObject extends FtrService {
private readonly log = this.ctx.getService('log');
private readonly testSubjects = this.ctx.getService('testSubjects');
public async isFieldPreviewVisible() {
this.log.debug('isFieldPreviewVisible');
return await this.testSubjects.exists('fieldPreviewItem', { timeout: 1500 });
}
}

View file

@ -217,7 +217,9 @@ export class SettingsPageObject extends FtrService {
async getFieldsTabCount() {
return this.retry.try(async () => {
// We extract the text from the tab (something like "Fields (86)")
const text = await this.testSubjects.getVisibleText('tab-indexedFields');
// And we return the number inside the parenthesis "86"
return text.split(' ')[1].replace(/\((.*)\)/, '$1');
});
}
@ -543,15 +545,16 @@ export class SettingsPageObject extends FtrService {
await this.clickSaveScriptedField();
}
async addRuntimeField(name: string, type: string, script: string) {
async addRuntimeField(name: string, type: string, script: string, doSaveField = true) {
await this.clickAddField();
await this.setFieldName(name);
await this.setFieldType(type);
if (script) {
await this.setFieldScript(script);
}
await this.clickSaveField();
await this.closeIndexPatternFieldEditor();
if (doSaveField) {
await this.clickSaveField();
}
}
public async confirmSave() {
@ -565,8 +568,16 @@ export class SettingsPageObject extends FtrService {
}
async closeIndexPatternFieldEditor() {
await this.testSubjects.click('closeFlyoutButton');
// We might have unsaved changes and we need to confirm inside the modal
if (await this.testSubjects.exists('runtimeFieldModifiedFieldConfirmModal')) {
this.log.debug('Unsaved changes for the field: need to confirm');
await this.testSubjects.click('confirmModalConfirmButton');
}
await this.retry.waitFor('field editor flyout to close', async () => {
return !(await this.testSubjects.exists('euiFlyoutCloseButton'));
return !(await this.testSubjects.exists('fieldEditor'));
});
}
@ -768,10 +779,6 @@ export class SettingsPageObject extends FtrService {
await this.testSubjects.click('editFieldFormat');
}
async clickCloseEditFieldFormatFlyout() {
await this.testSubjects.click('euiFlyoutCloseButton');
}
async associateIndexPattern(oldIndexPatternId: string, newIndexPatternTitle: string) {
await this.find.clickByCssSelector(
`select[data-test-subj="managementChangeIndexSelection-${oldIndexPatternId}"] >

View file

@ -45,6 +45,8 @@ export const useFormFieldMock = <T,>(options?: Partial<FieldHook<T>>): FieldHook
type: 'type',
value: ('mockedValue' as unknown) as T,
isPristine: false,
isDirty: false,
isModified: false,
isValidating: false,
isValidated: false,
isChangingValue: false,

View file

@ -91,6 +91,8 @@ export const useFormFieldMock = <T,>(options?: Partial<FieldHook<T>>): FieldHook
type: 'type',
value: ('mockedValue' as unknown) as T,
isPristine: false,
isDirty: false,
isModified: false,
isValidating: false,
isValidated: false,
isChangingValue: false,

View file

@ -67,6 +67,7 @@ export const runtimeMappingsSchema = schema.maybe(
schema.literal('date'),
schema.literal('ip'),
schema.literal('boolean'),
schema.literal('geo_point'),
]),
script: schema.maybe(
schema.oneOf([

View file

@ -12,3 +12,5 @@ export {
patternValidator,
ChartData,
} from '../../ml/common';
export { RUNTIME_FIELD_TYPES } from '../../../../src/plugins/data/common';

Some files were not shown because too many files have changed in this diff Show more