mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[Index pattern field editor] Add preview for runtime fields (#100198)
This commit is contained in:
parent
d78d66d424
commit
b24d44d165
103 changed files with 5263 additions and 734 deletions
|
@ -0,0 +1,11 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayFlyoutOpenOptions](./kibana-plugin-core-public.overlayflyoutopenoptions.md) > ["aria-label"](./kibana-plugin-core-public.overlayflyoutopenoptions._aria-label_.md)
|
||||
|
||||
## OverlayFlyoutOpenOptions."aria-label" property
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
'aria-label'?: string;
|
||||
```
|
|
@ -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 | number | string</code> | |
|
||||
| [onClose](./kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md) | <code>(flyout: OverlayRef) => 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> | |
|
||||
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [OverlayFlyoutOpenOptions](./kibana-plugin-core-public.overlayflyoutopenoptions.md) > [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;
|
||||
```
|
|
@ -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>,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 };
|
||||
};
|
|
@ -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([]);
|
||||
});
|
||||
});
|
|
@ -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!) };
|
||||
};
|
|
@ -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');
|
|
@ -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> };
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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';
|
|
@ -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]: [],
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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';
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "indexPatternFieldEditor",
|
||||
"version": "kibana",
|
||||
"server": false,
|
||||
"server": true,
|
||||
"ui": true,
|
||||
"requiredPlugins": ["data"],
|
||||
"optionalPlugins": ["usageCollection"],
|
||||
|
|
|
@ -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';
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -34,4 +34,8 @@ export const RUNTIME_FIELD_OPTIONS: Array<EuiComboBoxOptionOption<RuntimeType>>
|
|||
label: 'Boolean',
|
||||
value: 'boolean',
|
||||
},
|
||||
{
|
||||
label: 'Geo point',
|
||||
value: 'geo_point',
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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={
|
||||
|
|
|
@ -54,6 +54,7 @@ export const TypeField = ({ isDisabled = false }: Props) => {
|
|||
defaultMessage: 'Type select',
|
||||
}
|
||||
)}
|
||||
aria-controls="runtimeFieldScript"
|
||||
fullWidth
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
|
|
@ -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[]
|
||||
|
|
|
@ -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;
|
|
@ -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()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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 = (
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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} />;
|
||||
};
|
|
@ -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} />;
|
||||
};
|
|
@ -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 />
|
||||
</>
|
||||
);
|
|
@ -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,
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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';
|
|
@ -7,3 +7,5 @@
|
|||
*/
|
||||
|
||||
export const pluginName = 'index_pattern_field_editor';
|
||||
|
||||
export const euiFlyoutClassname = 'indexPatternFieldEditorFlyout';
|
||||
|
|
|
@ -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';
|
||||
|
|
42
src/plugins/index_pattern_field_editor/public/lib/api.ts
Normal file
42
src/plugins/index_pattern_field_editor/public/lib/api.ts
Normal 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>;
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
|
||||
import { IndexPatternField, IndexPattern } from '../shared_imports';
|
||||
import { Field } from '../types';
|
||||
import type { Field } from '../types';
|
||||
|
||||
export const deserializeField = (
|
||||
indexPattern: IndexPattern,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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;
|
|
@ -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>;
|
||||
}
|
||||
|
|
13
src/plugins/index_pattern_field_editor/server/index.ts
Normal file
13
src/plugins/index_pattern_field_editor/server/index.ts
Normal 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);
|
||||
}
|
31
src/plugins/index_pattern_field_editor/server/plugin.ts
Normal file
31
src/plugins/index_pattern_field_editor/server/plugin.ts
Normal 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() {}
|
||||
}
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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';
|
12
src/plugins/index_pattern_field_editor/server/types.ts
Normal file
12
src/plugins/index_pattern_field_editor/server/types.ts
Normal 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;
|
||||
}
|
|
@ -7,7 +7,11 @@
|
|||
"declarationMap": true
|
||||
},
|
||||
"include": [
|
||||
"../../../typings/**/*",
|
||||
"__jest__/**/*",
|
||||
"common/**/*",
|
||||
"public/**/*",
|
||||
"server/**/*",
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../../core/tsconfig.json" },
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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'));
|
||||
|
|
|
@ -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';
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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}"] >
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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([
|
||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue