= (props) =>
createElement(type, { ...props, services: useServices() });
@@ -40,7 +39,6 @@ export const LegacyServicesProvider: FC<{
const specifiedProviders: CanvasServiceProviders = { ...services, ...providers };
const value = {
search: specifiedProviders.search.getService(),
- labs: specifiedProviders.labs.getService(),
};
return {children};
};
diff --git a/x-pack/plugins/canvas/public/services/legacy/index.ts b/x-pack/plugins/canvas/public/services/legacy/index.ts
index 61ebae07c38c8..fdc4e30cabe51 100644
--- a/x-pack/plugins/canvas/public/services/legacy/index.ts
+++ b/x-pack/plugins/canvas/public/services/legacy/index.ts
@@ -9,7 +9,6 @@ import { BehaviorSubject } from 'rxjs';
import { CoreSetup, CoreStart, AppUpdater } from '../../../../../../src/core/public';
import { CanvasSetupDeps, CanvasStartDeps } from '../../plugin';
import { searchServiceFactory } from './search';
-import { labsServiceFactory } from './labs';
export { SearchService } from './search';
export { ExpressionsService } from '../../../../../../src/plugins/expressions/common';
@@ -68,14 +67,12 @@ export type ServiceFromProvider = P extends CanvasServiceProvider ?
export const services = {
search: new CanvasServiceProvider(searchServiceFactory),
- labs: new CanvasServiceProvider(labsServiceFactory),
};
export type CanvasServiceProviders = typeof services;
export interface CanvasServices {
search: ServiceFromProvider;
- labs: ServiceFromProvider;
}
export const startLegacyServices = async (
diff --git a/x-pack/plugins/canvas/public/services/legacy/stubs/index.ts b/x-pack/plugins/canvas/public/services/legacy/stubs/index.ts
index b78763324b5dd..af43e9ed75c98 100644
--- a/x-pack/plugins/canvas/public/services/legacy/stubs/index.ts
+++ b/x-pack/plugins/canvas/public/services/legacy/stubs/index.ts
@@ -6,12 +6,10 @@
*/
import { CanvasServices, services } from '../';
-import { labsService } from './labs';
import { searchService } from './search';
export const stubs: CanvasServices = {
search: searchService,
- labs: labsService,
};
export const startServices = async (providedServices: Partial = {}) => {
diff --git a/x-pack/plugins/canvas/public/services/stubs/index.ts b/x-pack/plugins/canvas/public/services/stubs/index.ts
index df1370e83c00d..06a5ff49e9c04 100644
--- a/x-pack/plugins/canvas/public/services/stubs/index.ts
+++ b/x-pack/plugins/canvas/public/services/stubs/index.ts
@@ -17,6 +17,7 @@ import { CanvasPluginServices } from '..';
import { customElementServiceFactory } from './custom_element';
import { embeddablesServiceFactory } from './embeddables';
import { expressionsServiceFactory } from './expressions';
+import { labsServiceFactory } from './labs';
import { navLinkServiceFactory } from './nav_link';
import { notifyServiceFactory } from './notify';
import { platformServiceFactory } from './platform';
@@ -25,6 +26,7 @@ import { workpadServiceFactory } from './workpad';
export { customElementServiceFactory } from './custom_element';
export { expressionsServiceFactory } from './expressions';
+export { labsServiceFactory } from './labs';
export { navLinkServiceFactory } from './nav_link';
export { notifyServiceFactory } from './notify';
export { platformServiceFactory } from './platform';
@@ -35,6 +37,7 @@ export const pluginServiceProviders: PluginServiceProviders;
+
const noop = (..._args: any[]): any => {};
-export const labsService: CanvasLabsService = {
+export const labsServiceFactory: CanvasLabsServiceFactory = () => ({
getProject: noop,
getProjects: noop,
getProjectIDs: () => projectIDs,
@@ -19,4 +24,4 @@ export const labsService: CanvasLabsService = {
projectIDs,
reset: noop,
setProjectStatus: noop,
-};
+});
From b24d44d16570a70834f26d4d1639196690d5eb7e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=A9bastien=20Loix?=
Date: Fri, 13 Aug 2021 23:27:23 +0100
Subject: [PATCH 25/56] [Index pattern field editor] Add preview for runtime
fields (#100198)
---
...c.overlayflyoutopenoptions._aria-label_.md | 11 +
...in-core-public.overlayflyoutopenoptions.md | 2 +
...public.overlayflyoutopenoptions.onclose.md | 13 +
.../public/overlays/flyout/flyout_service.tsx | 16 +-
src/core/public/public.api.md | 3 +
.../data/common/index_patterns/constants.ts | 10 +-
.../forms/docs/core/use_form_is_modified.mdx | 51 +
.../components/use_field.test.tsx | 141 ++-
.../forms/hook_form_lib/form_context.tsx | 10 +-
.../static/forms/hook_form_lib/hooks/index.ts | 1 +
.../forms/hook_form_lib/hooks/use_field.ts | 65 +-
.../forms/hook_form_lib/hooks/use_form.ts | 20 +
.../hooks/use_form_is_modified.test.tsx | 125 +++
.../hooks/use_form_is_modified.ts | 97 ++
.../static/forms/hook_form_lib/index.ts | 6 +-
.../static/forms/hook_form_lib/types.ts | 4 +
.../field_editor.helpers.ts | 42 +
.../client_integration}/field_editor.test.tsx | 164 ++--
.../field_editor_flyout_content.helpers.ts | 46 +
.../field_editor_flyout_content.test.ts | 78 +-
.../field_editor_flyout_preview.helpers.ts | 185 ++++
.../field_editor_flyout_preview.test.ts | 890 ++++++++++++++++++
.../helpers/common_actions.ts | 111 +++
.../helpers/http_requests.ts | 47 +
.../client_integration/helpers/index.ts | 21 +
.../helpers/jest.mocks.tsx} | 82 +-
.../client_integration/helpers/mocks.ts | 25 +
.../helpers/setup_environment.tsx | 122 +++
.../index.ts => common/constants.ts} | 6 +-
.../index_pattern_field_editor/kibana.json | 2 +-
.../delete_field_modal.tsx | 0
.../public/components/confirm_modals/index.ts | 13 +
.../confirm_modals/modified_field_modal.tsx | 46 +
.../save_field_type_or_name_changed_modal.tsx | 86 ++
.../components/field_editor/constants.ts | 4 +
.../components/field_editor/field_editor.tsx | 129 +--
.../field_editor/form_fields/format_field.tsx | 15 +-
.../field_editor/form_fields/script_field.tsx | 1 +
.../field_editor/form_fields/type_field.tsx | 1 +
.../public/components/field_editor/lib.ts | 2 +-
.../components/field_editor_context.tsx | 96 ++
.../field_editor_flyout_content.tsx | 432 ++++-----
.../field_editor_flyout_content_container.tsx | 152 +--
.../public/components/field_editor_loader.tsx | 38 +
.../editors/default/default.tsx | 4 +-
.../components/flyout_panels/flyout_panel.tsx | 144 +++
.../flyout_panels/flyout_panels.scss | 48 +
.../flyout_panels/flyout_panels.tsx | 145 +++
.../flyout_panels/flyout_panels_content.tsx | 21 +
.../flyout_panels/flyout_panels_footer.tsx | 23 +
.../flyout_panels/flyout_panels_header.tsx | 19 +
.../public/components/flyout_panels/index.ts | 23 +
.../public/components/index.ts | 11 -
.../preview/documents_nav_preview.tsx | 132 +++
.../preview/field_list/field_list.scss | 63 ++
.../preview/field_list/field_list.tsx | 235 +++++
.../preview/field_list/field_list_item.tsx | 125 +++
.../components/preview/field_preview.scss | 19 +
.../components/preview/field_preview.tsx | 134 +++
.../preview/field_preview_context.tsx | 599 ++++++++++++
.../preview/field_preview_empty_prompt.tsx | 43 +
.../preview/field_preview_error.tsx | 38 +
.../preview/field_preview_header.tsx | 77 ++
.../preview/image_preview_modal.tsx | 43 +
.../public/components/preview/index.ts | 11 +
.../public/constants.ts | 2 +
.../public/index.ts | 3 +-
.../public/lib/api.ts | 42 +
.../public/lib/index.ts | 8 +-
.../public/lib/runtime_field_validation.ts | 9 +-
.../public/lib/serialization.ts | 2 +-
.../public/open_delete_modal.tsx | 2 +-
.../public/open_editor.tsx | 60 +-
.../public/plugin.test.tsx | 9 +-
.../public/plugin.ts | 7 +-
.../public/shared_imports.ts | 3 +
.../public/test_utils/helpers.ts | 41 -
.../public/test_utils/mocks.ts | 24 -
.../public/types.ts | 14 +
.../server/index.ts | 13 +
.../server/plugin.ts | 31 +
.../server/routes/field_preview.ts | 73 ++
.../server/routes/index.ts | 16 +
.../shared_imports.ts} | 4 +-
.../server/types.ts | 12 +
.../index_pattern_field_editor/tsconfig.json | 4 +
test/accessibility/apps/management.ts | 15 +-
test/api_integration/apis/index.ts | 1 +
.../index_pattern_field_editor/constants.ts | 9 +
.../field_preview.ts | 130 +++
.../apis/index_pattern_field_editor/index.ts | 15 +
.../apps/management/_field_formatter.ts | 1 -
.../apps/management/_runtime_fields.js | 16 +-
test/functional/page_objects/index.ts | 2 +
.../indexpattern_field_editor_page.ts | 19 +
test/functional/page_objects/settings_page.ts | 23 +-
.../public/common/mock/test_providers.tsx | 2 +
.../public/common/mock/test_providers.tsx | 2 +
.../transform/common/api_schemas/common.ts | 1 +
.../transform/common/shared_imports.ts | 2 +
.../components/step_define/common/types.ts | 4 +-
.../translations/translations/ja-JP.json | 1 -
.../translations/translations/zh-CN.json | 1 -
103 files changed, 5260 insertions(+), 731 deletions(-)
create mode 100644 docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions._aria-label_.md
create mode 100644 docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md
create mode 100644 src/plugins/es_ui_shared/static/forms/docs/core/use_form_is_modified.mdx
create mode 100644 src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.test.tsx
create mode 100644 src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.ts
create mode 100644 src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.helpers.ts
rename src/plugins/index_pattern_field_editor/{public/components/field_editor => __jest__/client_integration}/field_editor.test.tsx (72%)
create mode 100644 src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts
rename src/plugins/index_pattern_field_editor/{public/components => __jest__/client_integration}/field_editor_flyout_content.test.ts (66%)
create mode 100644 src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.helpers.ts
create mode 100644 src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.test.ts
create mode 100644 src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/common_actions.ts
create mode 100644 src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/http_requests.ts
create mode 100644 src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/index.ts
rename src/plugins/index_pattern_field_editor/{public/test_utils/setup_environment.tsx => __jest__/client_integration/helpers/jest.mocks.tsx} (78%)
create mode 100644 src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/mocks.ts
create mode 100644 src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx
rename src/plugins/index_pattern_field_editor/{public/test_utils/index.ts => common/constants.ts} (80%)
rename src/plugins/index_pattern_field_editor/public/components/{ => confirm_modals}/delete_field_modal.tsx (100%)
create mode 100644 src/plugins/index_pattern_field_editor/public/components/confirm_modals/index.ts
create mode 100644 src/plugins/index_pattern_field_editor/public/components/confirm_modals/modified_field_modal.tsx
create mode 100644 src/plugins/index_pattern_field_editor/public/components/confirm_modals/save_field_type_or_name_changed_modal.tsx
create mode 100644 src/plugins/index_pattern_field_editor/public/components/field_editor_context.tsx
create mode 100644 src/plugins/index_pattern_field_editor/public/components/field_editor_loader.tsx
create mode 100644 src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx
create mode 100644 src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.scss
create mode 100644 src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.tsx
create mode 100644 src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels_content.tsx
create mode 100644 src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels_footer.tsx
create mode 100644 src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels_header.tsx
create mode 100644 src/plugins/index_pattern_field_editor/public/components/flyout_panels/index.ts
create mode 100644 src/plugins/index_pattern_field_editor/public/components/preview/documents_nav_preview.tsx
create mode 100644 src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss
create mode 100644 src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx
create mode 100644 src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx
create mode 100644 src/plugins/index_pattern_field_editor/public/components/preview/field_preview.scss
create mode 100644 src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx
create mode 100644 src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx
create mode 100644 src/plugins/index_pattern_field_editor/public/components/preview/field_preview_empty_prompt.tsx
create mode 100644 src/plugins/index_pattern_field_editor/public/components/preview/field_preview_error.tsx
create mode 100644 src/plugins/index_pattern_field_editor/public/components/preview/field_preview_header.tsx
create mode 100644 src/plugins/index_pattern_field_editor/public/components/preview/image_preview_modal.tsx
create mode 100644 src/plugins/index_pattern_field_editor/public/components/preview/index.ts
create mode 100644 src/plugins/index_pattern_field_editor/public/lib/api.ts
delete mode 100644 src/plugins/index_pattern_field_editor/public/test_utils/helpers.ts
delete mode 100644 src/plugins/index_pattern_field_editor/public/test_utils/mocks.ts
create mode 100644 src/plugins/index_pattern_field_editor/server/index.ts
create mode 100644 src/plugins/index_pattern_field_editor/server/plugin.ts
create mode 100644 src/plugins/index_pattern_field_editor/server/routes/field_preview.ts
create mode 100644 src/plugins/index_pattern_field_editor/server/routes/index.ts
rename src/plugins/index_pattern_field_editor/{public/test_utils/test_utils.ts => server/shared_imports.ts} (76%)
create mode 100644 src/plugins/index_pattern_field_editor/server/types.ts
create mode 100644 test/api_integration/apis/index_pattern_field_editor/constants.ts
create mode 100644 test/api_integration/apis/index_pattern_field_editor/field_preview.ts
create mode 100644 test/api_integration/apis/index_pattern_field_editor/index.ts
create mode 100644 test/functional/page_objects/management/indexpattern_field_editor_page.ts
diff --git a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions._aria-label_.md b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions._aria-label_.md
new file mode 100644
index 0000000000000..f135fa9618958
--- /dev/null
+++ b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions._aria-label_.md
@@ -0,0 +1,11 @@
+
+
+[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
+
+Signature:
+
+```typescript
+'aria-label'?: string;
+```
diff --git a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md
index fc4959b87a987..dcecdeb840869 100644
--- a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md
+++ b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.md
@@ -15,11 +15,13 @@ export interface OverlayFlyoutOpenOptions
| Property | Type | Description |
| --- | --- | --- |
+| ["aria-label"](./kibana-plugin-core-public.overlayflyoutopenoptions._aria-label_.md) | string
| |
| ["data-test-subj"](./kibana-plugin-core-public.overlayflyoutopenoptions._data-test-subj_.md) | string
| |
| [className](./kibana-plugin-core-public.overlayflyoutopenoptions.classname.md) | string
| |
| [closeButtonAriaLabel](./kibana-plugin-core-public.overlayflyoutopenoptions.closebuttonarialabel.md) | string
| |
| [hideCloseButton](./kibana-plugin-core-public.overlayflyoutopenoptions.hideclosebutton.md) | boolean
| |
| [maxWidth](./kibana-plugin-core-public.overlayflyoutopenoptions.maxwidth.md) | boolean | number | string
| |
+| [onClose](./kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md) | (flyout: OverlayRef) => void
| 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) | boolean
| |
| [size](./kibana-plugin-core-public.overlayflyoutopenoptions.size.md) | EuiFlyoutSize
| |
diff --git a/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md
new file mode 100644
index 0000000000000..5cfbba4c84a36
--- /dev/null
+++ b/docs/development/core/public/kibana-plugin-core-public.overlayflyoutopenoptions.onclose.md
@@ -0,0 +1,13 @@
+
+
+[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;
+
+Signature:
+
+```typescript
+onClose?: (flyout: OverlayRef) => void;
+```
diff --git a/src/core/public/overlays/flyout/flyout_service.tsx b/src/core/public/overlays/flyout/flyout_service.tsx
index b41b85e5f429f..6e986cc8ecb48 100644
--- a/src/core/public/overlays/flyout/flyout_service.tsx
+++ b/src/core/public/overlays/flyout/flyout_service.tsx
@@ -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(
- flyout.close()}>
+
,
diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md
index 5ce12a1889c26..d3f9ce71379b7 100644
--- a/src/core/public/public.api.md
+++ b/src/core/public/public.api.md
@@ -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)
diff --git a/src/plugins/data/common/index_patterns/constants.ts b/src/plugins/data/common/index_patterns/constants.ts
index 88309447a8a29..d508a62422fc7 100644
--- a/src/plugins/data/common/index_patterns/constants.ts
+++ b/src/plugins/data/common/index_patterns/constants.ts
@@ -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;
diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/use_form_is_modified.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/use_form_is_modified.mdx
new file mode 100644
index 0000000000000..17f94b2921e63
--- /dev/null
+++ b/src/plugins/es_ui_shared/static/forms/docs/core/use_form_is_modified.mdx
@@ -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 (
+
+ );
+};
+
+const ChildComponent = () => {
+ const isModified = useFormIsModified(); // no need to provide the form object
+ return (
+ ...
+ );
+};
+```
+
+### 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.
diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx
index 72990808e61a9..2106bd50dad03 100644
--- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx
+++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx
@@ -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('', () => {
});
});
+ 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) => {
+ // Make sure to set the field value to an **object**
+ setValue(JSON.parse(e.target.value));
+ };
+
+ return ;
+ };
+
+ 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 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 ? (
+
+ ) : (
+
+ );
+ };
+
+ return childrenFunc;
+ };
+
+ interface Props {
+ fieldProps: Record;
+ }
+
+ const TestComp = ({ fieldProps }: Props) => {
+ const { form } = useForm();
+ return (
+
+ );
+ };
+
+ const onStateChangeSpy = jest.fn();
+ 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;
diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_context.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_context.tsx
index 79affc8c31a72..b733c2285fa89 100644
--- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_context.tsx
+++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_context.tsx
@@ -21,9 +21,15 @@ export const FormProvider = ({ children, form }: Props) => (
{children}
);
-export const useFormContext = function () {
+interface Options {
+ throwIfNotFound?: boolean;
+}
+
+export const useFormContext = function ({
+ throwIfNotFound = true,
+}: Options = {}) {
const context = useContext(FormContext) as FormHook;
- if (context === undefined) {
+ if (throwIfNotFound && context === undefined) {
throw new Error('useFormContext must be used within a ');
}
return context;
diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/index.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/index.ts
index 3e1e72d4ed5f0..3afb5bf6a20c2 100644
--- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/index.ts
+++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/index.ts
@@ -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';
diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts
index 77bb17d7b9e60..806c60a66aa1d 100644
--- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts
+++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts
@@ -34,7 +34,7 @@ export const useField = (
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 = (
const [value, setStateValue] = useState(deserializeValue);
const [errors, setStateErrors] = useState([]);
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 = (
[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['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 = (
value,
errors,
isPristine,
+ isDirty: !isPristine,
+ isModified,
isValid,
isValidating,
isValidated,
@@ -565,6 +536,7 @@ export const useField = (
helpText,
value,
isPristine,
+ isModified,
errors,
isValid,
isValidating,
@@ -617,6 +589,15 @@ export const useField = (
};
}, [onValueChange]);
+ useEffect(() => {
+ setIsModified(() => {
+ if (typeof value === 'object') {
+ return JSON.stringify(value) !== JSON.stringify(initialValue);
+ }
+ return value !== initialValue;
+ });
+ }, [value, initialValue]);
+
useEffect(() => {
if (!isMounted.current) {
return;
diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts
index dcf2cb37d6542..b42b3211871ba 100644
--- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts
+++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts
@@ -61,6 +61,7 @@ export function useForm(
const [isValid, setIsValid] = useState(undefined);
const fieldsRefs = useRef({});
+ const fieldsRemovedRefs = useRef({});
const formUpdateSubscribers = useRef([]);
const isMounted = useRef(false);
const defaultValueDeserialized = useRef(defaultValueMemoized);
@@ -213,6 +214,7 @@ export function useForm(
(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(
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(
[getFormData$, updateFormData$, fieldsToArray]
);
+ const getFormDefaultValue: FormHook['__getFormDefaultValue'] = useCallback(
+ () => defaultValueDeserialized.current,
+ []
+ );
+
const readFieldConfigFromSchema: FormHook['__readFieldConfigFromSchema'] = useCallback(
(fieldName) => {
const config = (get(schema ?? {}, fieldName) as FieldConfig) || {};
@@ -266,6 +277,11 @@ export function useForm(
[schema]
);
+ const getFieldsRemoved: FormHook['getFields'] = useCallback(
+ () => fieldsRemovedRefs.current,
+ []
+ );
+
// ----------------------------------
// -- Public API
// ----------------------------------
@@ -440,8 +456,10 @@ export function useForm(
__updateFormDataAt: updateFormDataAt,
__updateDefaultValueAt: updateDefaultValueAt,
__readFieldConfigFromSchema: readFieldConfigFromSchema,
+ __getFormDefaultValue: getFormDefaultValue,
__addField: addField,
__removeField: removeField,
+ __getFieldsRemoved: getFieldsRemoved,
__validateFields: validateFields,
};
}, [
@@ -454,8 +472,10 @@ export function useForm(
setFieldValue,
setFieldErrors,
getFields,
+ getFieldsRemoved,
getFormData,
getErrors,
+ getFormDefaultValue,
getFieldDefaultValue,
reset,
formOptions,
diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.test.tsx
new file mode 100644
index 0000000000000..dc89cfe4f1fb6
--- /dev/null
+++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.test.tsx
@@ -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 (
+
+ );
+ };
+
+ 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);
+ });
+});
diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.ts
new file mode 100644
index 0000000000000..d87c44e614c04
--- /dev/null
+++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.ts
@@ -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;
+ /** 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;
+};
diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/index.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/index.ts
index 72dbea3b14cce..19121bb6753a0 100644
--- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/index.ts
+++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/index.ts
@@ -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 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 component to create a field
+export { useForm, useFormData, useFormIsModified } from './hooks';
export { getFieldValidityAndErrorMessage } from './helpers';
export * from './form_context';
diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts
index 4e9ff29f0cdd3..151adea30c4f1 100644
--- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts
+++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts
@@ -62,6 +62,8 @@ export interface FormHook
__updateFormDataAt: (field: string, value: unknown) => void;
__updateDefaultValueAt: (field: string, value: unknown) => void;
__readFieldConfigFromSchema: (field: string) => FieldConfig;
+ __getFormDefaultValue: () => FormData;
+ __getFieldsRemoved: () => FieldsMap;
}
export type FormSchema = {
@@ -109,6 +111,8 @@ export interface FieldHook {
readonly errors: ValidationError[];
readonly isValid: boolean;
readonly isPristine: boolean;
+ readonly isDirty: boolean;
+ readonly isModified: boolean;
readonly isValidating: boolean;
readonly isValidated: boolean;
readonly isChangingValue: boolean;
diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.helpers.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.helpers.ts
new file mode 100644
index 0000000000000..0d58b2ce89358
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.helpers.ts
@@ -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 };
+
+export const setup = async (props?: Partial, deps?: Partial) => {
+ let testBed: TestBed;
+
+ await act(async () => {
+ testBed = await registerTestBed(WithFieldEditorDependencies(FieldEditor, deps), {
+ memoryRouter: {
+ wrapComponent: false,
+ },
+ })({ ...defaultProps, ...props });
+ });
+ testBed!.component.update();
+
+ const actions = {
+ ...getCommonActions(testBed!),
+ };
+
+ return { ...testBed!, actions };
+};
diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.test.tsx b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.test.tsx
similarity index 72%
rename from src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.test.tsx
rename to src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.test.tsx
index dfea1a94de7fa..4a4c42f69fc8e 100644
--- a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.test.tsx
+++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.test.tsx
@@ -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 '../../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) => {
- const testBed = registerTestBed(FieldEditor, {
- memoryRouter: {
- wrapComponent: false,
- },
- })({ ...defaultProps, ...props }) as TestBed;
-
- const actions = {
- ...getCommonActions(testBed),
- };
-
- return {
- ...testBed,
- actions,
- };
-};
+import { registerTestBed, TestBed } from '@kbn/test/jest';
+
+// 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('', () => {
- beforeAll(() => {
- jest.useFakeTimers();
- });
-
- afterAll(() => {
- jest.useRealTimers();
- });
+ const { server, httpRequestsMockHelpers } = setupEnvironment();
- let testBed: TestBed & { actions: ReturnType };
+ let testBed: FieldEditorTestBed;
let onChange: jest.Mock = jest.fn();
const lastOnChangeCall = (): FieldEditorFormState[] =>
@@ -104,12 +64,22 @@ describe('', () => {
return formState!;
};
- beforeEach(() => {
+ beforeAll(() => {
+ jest.useFakeTimers();
+ });
+
+ 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', () => {
- testBed = setup();
+ 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('', () => {
script: { source: 'emit("hello")' },
};
- testBed = setup({ onChange, field });
+ testBed = await setup({ onChange, field });
expect(onChange).toHaveBeenCalled();
@@ -153,25 +123,22 @@ describe('', () => {
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('', () => {
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('', () => {
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(null);
const clearError = useMemo(() => () => setError(null), []);
const syntaxError = useMemo(() => ({ error, clear: clearError }), [error, clearError]);
@@ -240,22 +211,29 @@ describe('', () => {
);
};
- const customTestbed = registerTestBed(TestComponent, {
- memoryRouter: {
- wrapComponent: false,
- },
- })() as TestBed;
+ let testBedToCapturePainlessErrors: TestBed;
+
+ 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('', () => {
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([]);
});
});
diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts
new file mode 100644
index 0000000000000..5b916c1cd9960
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts
@@ -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, deps?: Partial) => {
+ 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!) };
+};
diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.test.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts
similarity index 66%
rename from src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.test.ts
rename to src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts
index ed71e40fc80a9..9b00ff762fe8f 100644
--- a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.test.ts
+++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts
@@ -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('', () => {
+ 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('', () => {
},
};
- 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('', () => {
};
const onSave: jest.Mock = 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('', () => {
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('', () => {
test('should validate the fields and prevent saving invalid form', async () => {
const onSave: jest.Mock = 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('', () => {
const {
find,
- component,
- form,
- actions: { toggleFormRow, changeFieldType },
- } = setup({ ...defaultProps, onSave });
-
- act(() => {
- form.setInputValue('nameField.input', 'someName');
- toggleFormRow('value');
- });
- component.update();
+ actions: { toggleFormRow, fields },
+ } = await setup({ onSave });
- 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('', () => {
});
// 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');
diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.helpers.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.helpers.ts
new file mode 100644
index 0000000000000..068ebce638aa1
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.helpers.ts
@@ -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, deps?: Partial) => {
+ 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 };
diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.test.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.test.ts
new file mode 100644
index 0000000000000..65089bc24317b
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.test.ts
@@ -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);
+ });
+ });
+});
diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/common_actions.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/common_actions.ts
new file mode 100644
index 0000000000000..ca061968dae20
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/common_actions.ts
@@ -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,
+ },
+ };
+};
diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/http_requests.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/http_requests.ts
new file mode 100644
index 0000000000000..4b03db247bad1
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/http_requests.ts
@@ -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 | 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,
+ };
+};
diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/index.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/index.ts
new file mode 100644
index 0000000000000..6a1f1aa74036a
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/index.ts
@@ -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';
diff --git a/src/plugins/index_pattern_field_editor/public/test_utils/setup_environment.tsx b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/jest.mocks.tsx
similarity index 78%
rename from src/plugins/index_pattern_field_editor/public/test_utils/setup_environment.tsx
rename to src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/jest.mocks.tsx
index 885bcc87f89df..e291ec7b4ca08 100644
--- a/src/plugins/index_pattern_field_editor/public/test_utils/setup_environment.tsx
+++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/jest.mocks.tsx
@@ -5,44 +5,13 @@
* 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');
-
- /**
- * We mock the CodeEditor because it requires the
- * with the uiSettings passed down. Let's use a simple in our tests.
- */
- const CodeEditorMock = (props: any) => {
- // Forward our deterministic ID to the consumer
- // We need below for the PainlessLang.getSyntaxErrors mock
- props.editorDidMount({
- getModel() {
- return {
- id: EDITOR_ID,
- };
- },
- });
-
- return (
- ) => {
- props.onChange(e.target.value);
- }}
- />
- );
- };
-
+jest.mock('@elastic/eui/lib/services/accessibility', () => {
return {
- ...original,
- CodeEditor: CodeEditorMock,
+ htmlIdGenerator: () => () => `generated-id`,
};
});
@@ -61,6 +30,16 @@ jest.mock('@elastic/eui', () => {
}}
/>
),
+ EuiResizeObserver: ({
+ onResize,
+ children,
+ }: {
+ onResize(data: { height: number }): void;
+ children(): JSX.Element;
+ }) => {
+ onResize({ height: 1000 });
+ return children();
+ },
};
});
@@ -78,3 +57,40 @@ jest.mock('@kbn/monaco', () => {
},
};
});
+
+jest.mock('../../../../kibana_react/public', () => {
+ const original = jest.requireActual('../../../../kibana_react/public');
+
+ /**
+ * We mock the CodeEditor because it requires the
+ * with the uiSettings passed down. Let's use a simple in our tests.
+ */
+ const CodeEditorMock = (props: any) => {
+ // Forward our deterministic ID to the consumer
+ // We need below for the PainlessLang.getSyntaxErrors mock
+ props.editorDidMount({
+ getModel() {
+ return {
+ id: EDITOR_ID,
+ };
+ },
+ });
+
+ return (
+ ) => {
+ props.onChange(e.target.value);
+ }}
+ />
+ );
+ };
+
+ return {
+ ...original,
+ toMountPoint: (node: React.ReactNode) => node,
+ CodeEditor: CodeEditorMock,
+ };
+});
diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/mocks.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/mocks.ts
new file mode 100644
index 0000000000000..8dfdd13e8338d
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/mocks.ts
@@ -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,
+ };
+};
diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx
new file mode 100644
index 0000000000000..d87b49d35c68e
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx
@@ -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 = (
+ Comp: FunctionComponent,
+ overridingDependencies?: Partial
+) => (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 `${value.toUpperCase()}`;
+ },
+ },
+ } 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 (
+
+
+
+
+
+ );
+};
diff --git a/src/plugins/index_pattern_field_editor/public/test_utils/index.ts b/src/plugins/index_pattern_field_editor/common/constants.ts
similarity index 80%
rename from src/plugins/index_pattern_field_editor/public/test_utils/index.ts
rename to src/plugins/index_pattern_field_editor/common/constants.ts
index b5d943281cd79..ecd6b1ddd408b 100644
--- a/src/plugins/index_pattern_field_editor/public/test_utils/index.ts
+++ b/src/plugins/index_pattern_field_editor/common/constants.ts
@@ -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';
diff --git a/src/plugins/index_pattern_field_editor/kibana.json b/src/plugins/index_pattern_field_editor/kibana.json
index 02308b349d4ca..898e7c564e57f 100644
--- a/src/plugins/index_pattern_field_editor/kibana.json
+++ b/src/plugins/index_pattern_field_editor/kibana.json
@@ -1,7 +1,7 @@
{
"id": "indexPatternFieldEditor",
"version": "kibana",
- "server": false,
+ "server": true,
"ui": true,
"requiredPlugins": ["data"],
"optionalPlugins": ["usageCollection"],
diff --git a/src/plugins/index_pattern_field_editor/public/components/delete_field_modal.tsx b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/delete_field_modal.tsx
similarity index 100%
rename from src/plugins/index_pattern_field_editor/public/components/delete_field_modal.tsx
rename to src/plugins/index_pattern_field_editor/public/components/confirm_modals/delete_field_modal.tsx
diff --git a/src/plugins/index_pattern_field_editor/public/components/confirm_modals/index.ts b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/index.ts
new file mode 100644
index 0000000000000..2283070f6f727
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/index.ts
@@ -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';
diff --git a/src/plugins/index_pattern_field_editor/public/components/confirm_modals/modified_field_modal.tsx b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/modified_field_modal.tsx
new file mode 100644
index 0000000000000..c9fabbaa73561
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/modified_field_modal.tsx
@@ -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 = ({ onCancel, onConfirm }) => {
+ return (
+
+ {i18nTexts.description}
+
+ );
+};
diff --git a/src/plugins/index_pattern_field_editor/public/components/confirm_modals/save_field_type_or_name_changed_modal.tsx b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/save_field_type_or_name_changed_modal.tsx
new file mode 100644
index 0000000000000..51af86868c632
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/public/components/confirm_modals/save_field_type_or_name_changed_modal.tsx
@@ -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 = ({
+ fieldName,
+ onCancel,
+ onConfirm,
+}) => {
+ const i18nTexts = geti18nTexts(fieldName);
+ const [confirmContent, setConfirmContent] = useState('');
+
+ return (
+
+
+
+
+ setConfirmContent(e.target.value)}
+ data-test-subj="saveModalConfirmText"
+ />
+
+
+ );
+};
diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/constants.ts b/src/plugins/index_pattern_field_editor/public/components/field_editor/constants.ts
index 82711f707fa19..e262d3ecbfe45 100644
--- a/src/plugins/index_pattern_field_editor/public/components/field_editor/constants.ts
+++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/constants.ts
@@ -34,4 +34,8 @@ export const RUNTIME_FIELD_OPTIONS: Array>
label: 'Boolean',
value: 'boolean',
},
+ {
+ label: 'Geo point',
+ value: 'geo_point',
+ },
];
diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx
index 77ef0903bc6fc..b46d587dc4146 100644
--- a/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx
+++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/field_editor.tsx
@@ -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
}
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({
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({ form });
-
const nameFieldConfig = getNameFieldConfig(namesNotAllowed, field);
const i18nTexts = geti18nTexts();
+ const [formData] = useFormData({ 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 (
,
- }}
+
+ {/* Editor panel */}
+
+
+
+
+
+ {field ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {indexPattern.title},
+ }}
+ />
+
+
+
+
+
-
-
-
-
-
- {FieldEditor && (
-
- )}
-
-
-
- {FieldEditor && (
- <>
- {isSubmitted && isSaveButtonDisabled && (
- <>
-
-
- >
- )}
-
-
-
- {i18nTexts.closeButtonLabel}
-
-
-
-
-
- {i18nTexts.saveButtonLabel}
-
-
-
- >
+
+
+
+ <>
+ {isSubmitted && hasErrors && (
+ <>
+
+
+ >
+ )}
+
+
+
+ {i18nTexts.cancelButtonLabel}
+
+
+
+
+
+ {i18nTexts.saveButtonLabel}
+
+
+
+ >
+
+
+
+ {/* Preview panel */}
+ {isPanelVisible && (
+
+
+
)}
-
- {modal}
+
+
+ {renderModal()}
>
);
};
diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content_container.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content_container.tsx
index e01b3f9bb422c..cf2b29bbc97e8 100644
--- a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content_container.tsx
+++ b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content_container.tsx
@@ -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 | 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 (
-
+ services={services}
+ fieldFormatEditors={fieldFormatEditors}
+ fieldFormats={fieldFormats}
+ namesNotAllowed={namesNotAllowed}
+ existingConcreteFields={existingConcreteFields}
+ >
+
+
+
+
);
};
diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor_loader.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor_loader.tsx
new file mode 100644
index 0000000000000..f77db7e407caa
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/public/components/field_editor_loader.tsx
@@ -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) => {
+ const [Editor, setEditor] = useState | 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 ? (
+
+ ) : (
+ <>
+
+
+
+ >
+ );
+};
diff --git a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/default/default.tsx b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/default/default.tsx
index 129049e1b0565..fcf73f397b3fe 100644
--- a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/default/default.tsx
+++ b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/default/default.tsx
@@ -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 = (
diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx
new file mode 100644
index 0000000000000..05f127c09c996
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panel.tsx
@@ -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({
+ 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> = ({
+ 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({});
+
+ /* 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 when you add a '
+ );
+ }
+ 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 (
+
+
+
+ {children}
+
+
+
+ );
+};
+
+export const useFlyoutPanelContext = (): Context => {
+ const ctx = useContext(flyoutPanelContext);
+
+ if (ctx === undefined) {
+ throw new Error('useFlyoutPanel() must be used within a ');
+ }
+
+ return ctx;
+};
diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.scss b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.scss
new file mode 100644
index 0000000000000..29a62a16db213
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.scss
@@ -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;
+ }
+}
diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.tsx b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.tsx
new file mode 100644
index 0000000000000..95fb44b293e00
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels.tsx
@@ -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({
+ 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 = ({
+ 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 (
+
+
+
+ );
+};
+
+export const useFlyoutPanelsContext = (): Context => {
+ const ctx = useContext(flyoutPanelsContext);
+
+ if (ctx === undefined) {
+ throw new Error(' must be used within a wrapper');
+ }
+
+ return ctx;
+};
diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels_content.tsx b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels_content.tsx
new file mode 100644
index 0000000000000..ef2f7498a4c22
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels_content.tsx
@@ -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 ;
+};
diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels_footer.tsx b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels_footer.tsx
new file mode 100644
index 0000000000000..8a987420dd84b
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels_footer.tsx
@@ -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
+> = (props) => {
+ const { registerFooter } = useFlyoutPanelContext();
+
+ useEffect(() => {
+ registerFooter();
+ }, [registerFooter]);
+
+ return ;
+};
diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels_header.tsx b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels_header.tsx
new file mode 100644
index 0000000000000..00edf1c637fc1
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/flyout_panels_header.tsx
@@ -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
+> = (props) => (
+ <>
+
+
+ >
+);
diff --git a/src/plugins/index_pattern_field_editor/public/components/flyout_panels/index.ts b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/index.ts
new file mode 100644
index 0000000000000..0380a0bfefe72
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/public/components/flyout_panels/index.ts
@@ -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,
+};
diff --git a/src/plugins/index_pattern_field_editor/public/components/index.ts b/src/plugins/index_pattern_field_editor/public/components/index.ts
index 9f7f40fcadec7..927e28a8e3adf 100644
--- a/src/plugins/index_pattern_field_editor/public/components/index.ts
+++ b/src/plugins/index_pattern_field_editor/public/components/index.ts
@@ -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';
diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/documents_nav_preview.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/documents_nav_preview.tsx
new file mode 100644
index 0000000000000..fa4097725cde1
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/public/components/preview/documents_nav_preview.tsx
@@ -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) => {
+ const nextId = (e.target as HTMLInputElement).value;
+ loadSingle(nextId);
+ },
+ [loadSingle]
+ );
+
+ return (
+
+
+
+
+
+
+ {isCustomId && (
+
+ loadFromCluster()}
+ data-test-subj="loadDocsFromClusterButton"
+ >
+ {i18n.translate(
+ 'indexPatternFieldEditor.fieldPreview.documentIdField.loadDocumentsFromCluster',
+ {
+ defaultMessage: 'Load documents from cluster',
+ }
+ )}
+
+
+ )}
+
+
+ {showNavButtons && (
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ );
+};
diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss
new file mode 100644
index 0000000000000..d1bb8cb5731c9
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.scss
@@ -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 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;
+ }
+}
diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx
new file mode 100644
index 0000000000000..aae0f0c74a5f1
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list.tsx
@@ -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 = ({ 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 (
+ <>
+
+
+
+ {i18n.translate(
+ 'indexPatternFieldEditor.fieldPreview.searchResult.emptyPromptTitle',
+ {
+ defaultMessage: 'No matching fields in this index pattern',
+ }
+ )}
+
+
+ }
+ titleSize="xs"
+ actions={
+
+ {i18n.translate(
+ 'indexPatternFieldEditor.fieldPreview.searchResult.emptyPrompt.clearSearchButtonLabel',
+ {
+ defaultMessage: 'Clear search',
+ }
+ )}
+
+ }
+ data-test-subj="emptySearchResult"
+ />
+ >
+ );
+ };
+
+ const renderToggleFieldsButton = () =>
+ totalFields <= INITIAL_MAX_NUMBER_OF_FIELDS ? null : (
+
+
+ {showAllFields
+ ? i18n.translate('indexPatternFieldEditor.fieldPreview.showLessFieldsButtonLabel', {
+ defaultMessage: 'Show less',
+ })
+ : i18n.translate('indexPatternFieldEditor.fieldPreview.showMoreFieldsButtonLabel', {
+ defaultMessage: 'Show more',
+ })}
+
+
+ );
+
+ if (currentDocument === undefined || height === -1) {
+ return null;
+ }
+
+ return (
+
+ {isEmptySearchResultVisible ? (
+ renderEmptyResult()
+ ) : (
+
{
+ const field = filteredFields[index];
+
+ return (
+
+ );
+ }}
+ />
+ )}
+
+ {renderToggleFieldsButton()}
+
+ );
+};
diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx
new file mode 100644
index 0000000000000..348c442a1cd37
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_list/field_list_item.tsx
@@ -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 = ({
+ 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(' {
+ if (doesContainImage) {
+ return (
+ setIsPreviewImageModalVisible(true)}
+ iconType="image"
+ >
+ {i18n.translate('indexPatternFieldEditor.fieldPreview.viewImageButtonLabel', {
+ defaultMessage: 'View image',
+ })}
+
+ );
+ }
+
+ if (formattedValue !== undefined) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {JSON.stringify(value)}
+
+ );
+ };
+
+ return (
+ <>
+
+
+
+ {key}
+
+
+
+
+ {renderValue()}
+
+
+
+
+ {toggleIsPinned && (
+ {
+ 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"
+ />
+ )}
+
+
+ {isPreviewImageModalVisible && (
+ setIsPreviewImageModalVisible(false)}
+ />
+ )}
+ >
+ );
+};
diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.scss b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.scss
new file mode 100644
index 0000000000000..2d51cd19bf925
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.scss
@@ -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;
+ }
+}
diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx
new file mode 100644
index 0000000000000..09bacf2a46096
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx
@@ -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 (
+
+ );
+ };
+
+ 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 (
+
+ {isEmptyPromptVisible ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+
setSearchValue(e.target.value)}
+ placeholder={i18n.translate(
+ 'indexPatternFieldEditor.fieldPreview.filterFieldsPlaceholder',
+ {
+ defaultMessage: 'Filter fields',
+ }
+ )}
+ fullWidth
+ data-test-subj="filterFieldsInput"
+ />
+
+
+
+
+
+ {doShowFieldList && (
+ <>
+ {/* The current field(s) the user is creating */}
+ {renderFieldsToPreview()}
+
+ {/* List of other fields in the document */}
+
+ {(resizeRef) => (
+
+
setSearchValue('')}
+ searchValue={searchValue}
+ // We add a key to force rerender the virtual list whenever the window height changes
+ key={fieldListHeight}
+ />
+
+ )}
+
+ >
+ )}
+ >
+ )}
+
+ );
+};
diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx
new file mode 100644
index 0000000000000..e1fc4b05883f4
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx
@@ -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;
+}
+
+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['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) => void;
+ };
+ isLoadingPreview: boolean;
+ currentDocument: {
+ value?: EsDocument;
+ id: string;
+ isLoading: boolean;
+ isCustomId: boolean;
+ };
+ documents: {
+ loadSingle: (id: string) => void;
+ loadFromCluster: () => Promise;
+ };
+ 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>;
+ };
+}
+
+const fieldPreviewContext = createContext(undefined);
+
+const defaultParams: Params = {
+ name: null,
+ index: null,
+ script: null,
+ document: null,
+ type: null,
+ format: null,
+};
+
+export const defaultValueFormatter = (value: unknown) =>
+ `${typeof value === 'object' ? JSON.stringify(value) : value ?? '-'}`;
+
+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(defaultParams);
+ /** The sample documents fetched from the cluster */
+ const [clusterData, setClusterData] = useState({
+ 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(null);
+ /** Define if we provide the document to preview from the cluster or from a custom JSON */
+ const [from, setFrom] = useState('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 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(
+ () => ({
+ 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 {children};
+};
+
+export const useFieldPreviewContext = (): Context => {
+ const ctx = useContext(fieldPreviewContext);
+
+ if (ctx === undefined) {
+ throw new Error('useFieldPreviewContext must be used within a ');
+ }
+
+ return ctx;
+};
diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_empty_prompt.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_empty_prompt.tsx
new file mode 100644
index 0000000000000..6e4c4626d9dae
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_empty_prompt.tsx
@@ -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 (
+
+
+
+ {i18n.translate('indexPatternFieldEditor.fieldPreview.emptyPromptTitle', {
+ defaultMessage: 'Preview',
+ })}
+
+ }
+ titleSize="s"
+ body={
+
+
+
+ {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.',
+ })}
+
+
+
+ }
+ />
+
+
+ );
+};
diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_error.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_error.tsx
new file mode 100644
index 0000000000000..7994e649e1ebb
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_error.tsx
@@ -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 (
+
+ {error.code === 'PAINLESS_SCRIPT_ERROR' ? (
+ {error.error.reason}
+ ) : (
+ {error.error.message}
+ )}
+
+ );
+};
diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_header.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_header.tsx
new file mode 100644
index 0000000000000..2d3d5c20ba7b3
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_header.tsx
@@ -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 (
+
+
+
+
+ {i18nTexts.title}
+
+
+
+ {isUpdating && (
+
+
+
+
+
+ {i18nTexts.updatingLabel}
+
+
+ )}
+
+
+
+ {i18n.translate('indexPatternFieldEditor.fieldPreview.subTitle', {
+ defaultMessage: 'From: {from}',
+ values: {
+ from: from.value === 'cluster' ? indexPattern.title : i18nTexts.customData,
+ },
+ })}
+
+
+
+ );
+};
diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/image_preview_modal.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/image_preview_modal.tsx
new file mode 100644
index 0000000000000..69be3d144bfda
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/public/components/preview/image_preview_modal.tsx
@@ -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 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 (
+
+
+
+
+
+ );
+};
diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/index.ts b/src/plugins/index_pattern_field_editor/public/components/preview/index.ts
new file mode 100644
index 0000000000000..5d3b4bb41fc5f
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/public/components/preview/index.ts
@@ -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';
diff --git a/src/plugins/index_pattern_field_editor/public/constants.ts b/src/plugins/index_pattern_field_editor/public/constants.ts
index 69d231f375848..5a16e805a3fc9 100644
--- a/src/plugins/index_pattern_field_editor/public/constants.ts
+++ b/src/plugins/index_pattern_field_editor/public/constants.ts
@@ -7,3 +7,5 @@
*/
export const pluginName = 'index_pattern_field_editor';
+
+export const euiFlyoutClassname = 'indexPatternFieldEditorFlyout';
diff --git a/src/plugins/index_pattern_field_editor/public/index.ts b/src/plugins/index_pattern_field_editor/public/index.ts
index a63c9ada52e3d..80ead500c3d9d 100644
--- a/src/plugins/index_pattern_field_editor/public/index.ts
+++ b/src/plugins/index_pattern_field_editor/public/index.ts
@@ -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';
diff --git a/src/plugins/index_pattern_field_editor/public/lib/api.ts b/src/plugins/index_pattern_field_editor/public/lib/api.ts
new file mode 100644
index 0000000000000..9325b5c2faf47
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/public/lib/api.ts
@@ -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;
+ }) => {
+ return sendRequest(httpClient, {
+ path: `${API_BASE_PATH}/field_preview`,
+ method: 'post',
+ body: {
+ index,
+ context,
+ script,
+ document,
+ },
+ });
+ };
+
+ return {
+ getFieldPreview,
+ };
+};
+
+export type ApiService = ReturnType;
diff --git a/src/plugins/index_pattern_field_editor/public/lib/index.ts b/src/plugins/index_pattern_field_editor/public/lib/index.ts
index 5d5b3d881e976..336de9574c460 100644
--- a/src/plugins/index_pattern_field_editor/public/lib/index.ts
+++ b/src/plugins/index_pattern_field_editor/public/lib/index.ts
@@ -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';
diff --git a/src/plugins/index_pattern_field_editor/public/lib/runtime_field_validation.ts b/src/plugins/index_pattern_field_editor/public/lib/runtime_field_validation.ts
index f1a6fd7f9e8aa..789c4f7fa71fc 100644
--- a/src/plugins/index_pattern_field_editor/public/lib/runtime_field_validation.ts
+++ b/src/plugins/index_pattern_field_editor/public/lib/runtime_field_validation.ts
@@ -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;
diff --git a/src/plugins/index_pattern_field_editor/public/lib/serialization.ts b/src/plugins/index_pattern_field_editor/public/lib/serialization.ts
index 9000a34b23cbe..8a0a47e07c9c9 100644
--- a/src/plugins/index_pattern_field_editor/public/lib/serialization.ts
+++ b/src/plugins/index_pattern_field_editor/public/lib/serialization.ts
@@ -7,7 +7,7 @@
*/
import { IndexPatternField, IndexPattern } from '../shared_imports';
-import { Field } from '../types';
+import type { Field } from '../types';
export const deserializeField = (
indexPattern: IndexPattern,
diff --git a/src/plugins/index_pattern_field_editor/public/open_delete_modal.tsx b/src/plugins/index_pattern_field_editor/public/open_delete_modal.tsx
index 27aa1d0313a7f..72dbb76863353 100644
--- a/src/plugins/index_pattern_field_editor/public/open_delete_modal.tsx
+++ b/src/plugins/index_pattern_field_editor/public/open_delete_modal.tsx
@@ -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 {
diff --git a/src/plugins/index_pattern_field_editor/public/open_editor.tsx b/src/plugins/index_pattern_field_editor/public/open_editor.tsx
index e70ba48cca0a5..946e666bf8205 100644
--- a/src/plugins/index_pattern_field_editor/public/open_editor.tsx
+++ b/src/plugins/index_pattern_field_editor/public/open_editor.tsx
@@ -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 onMounted = (args: { canCloseValidator: () => boolean }) => {
+ canCloseValidator.current = args.canCloseValidator;
+ };
- const openEditor = ({ onSave, fieldName, ctx }: OpenFieldEditorOptions): CloseEditor => {
+ 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(
-
- )
+ ),
+ {
+ 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;
diff --git a/src/plugins/index_pattern_field_editor/public/plugin.test.tsx b/src/plugins/index_pattern_field_editor/public/plugin.test.tsx
index 2212264427d1a..75bb1322d305e 100644
--- a/src/plugins/index_pattern_field_editor/public/plugin.test.tsx
+++ b/src/plugins/index_pattern_field_editor/public/plugin.test.tsx
@@ -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 component
// and make sure that the the spy is being called.
diff --git a/src/plugins/index_pattern_field_editor/public/plugin.ts b/src/plugins/index_pattern_field_editor/public/plugin.ts
index a46bef92cbbb1..4bf8dd5c1c4e8 100644
--- a/src/plugins/index_pattern_field_editor/public/plugin.ts
+++ b/src/plugins/index_pattern_field_editor/public/plugin.ts
@@ -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 {
@@ -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,
diff --git a/src/plugins/index_pattern_field_editor/public/shared_imports.ts b/src/plugins/index_pattern_field_editor/public/shared_imports.ts
index cfc36543780c1..2827928d1c060 100644
--- a/src/plugins/index_pattern_field_editor/public/shared_imports.ts
+++ b/src/plugins/index_pattern_field_editor/public/shared_imports.ts
@@ -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';
diff --git a/src/plugins/index_pattern_field_editor/public/test_utils/helpers.ts b/src/plugins/index_pattern_field_editor/public/test_utils/helpers.ts
deleted file mode 100644
index b55a59df34545..0000000000000
--- a/src/plugins/index_pattern_field_editor/public/test_utils/helpers.ts
+++ /dev/null
@@ -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,
- };
-};
diff --git a/src/plugins/index_pattern_field_editor/public/test_utils/mocks.ts b/src/plugins/index_pattern_field_editor/public/test_utils/mocks.ts
deleted file mode 100644
index c6bc24f176858..0000000000000
--- a/src/plugins/index_pattern_field_editor/public/test_utils/mocks.ts
+++ /dev/null
@@ -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;
diff --git a/src/plugins/index_pattern_field_editor/public/types.ts b/src/plugins/index_pattern_field_editor/public/types.ts
index e78c0805c51b5..f7efc9d82fc48 100644
--- a/src/plugins/index_pattern_field_editor/public/types.ts
+++ b/src/plugins/index_pattern_field_editor/public/types.ts
@@ -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;
+}
diff --git a/src/plugins/index_pattern_field_editor/server/index.ts b/src/plugins/index_pattern_field_editor/server/index.ts
new file mode 100644
index 0000000000000..dc6f734a7e503
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/server/index.ts
@@ -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);
+}
diff --git a/src/plugins/index_pattern_field_editor/server/plugin.ts b/src/plugins/index_pattern_field_editor/server/plugin.ts
new file mode 100644
index 0000000000000..18601aad85307
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/server/plugin.ts
@@ -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 {
+ 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() {}
+}
diff --git a/src/plugins/index_pattern_field_editor/server/routes/field_preview.ts b/src/plugins/index_pattern_field_editor/server/routes/field_preview.ts
new file mode 100644
index 0000000000000..238701904e22c
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/server/routes/field_preview.ts
@@ -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 });
+ }
+ }
+ );
+};
diff --git a/src/plugins/index_pattern_field_editor/server/routes/index.ts b/src/plugins/index_pattern_field_editor/server/routes/index.ts
new file mode 100644
index 0000000000000..c04c5bb3feec4
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/server/routes/index.ts
@@ -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);
+ }
+}
diff --git a/src/plugins/index_pattern_field_editor/public/test_utils/test_utils.ts b/src/plugins/index_pattern_field_editor/server/shared_imports.ts
similarity index 76%
rename from src/plugins/index_pattern_field_editor/public/test_utils/test_utils.ts
rename to src/plugins/index_pattern_field_editor/server/shared_imports.ts
index c8e4aedc26471..d818f11ceefda 100644
--- a/src/plugins/index_pattern_field_editor/public/test_utils/test_utils.ts
+++ b/src/plugins/index_pattern_field_editor/server/shared_imports.ts
@@ -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';
diff --git a/src/plugins/index_pattern_field_editor/server/types.ts b/src/plugins/index_pattern_field_editor/server/types.ts
new file mode 100644
index 0000000000000..c86708c12a71e
--- /dev/null
+++ b/src/plugins/index_pattern_field_editor/server/types.ts
@@ -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;
+}
diff --git a/src/plugins/index_pattern_field_editor/tsconfig.json b/src/plugins/index_pattern_field_editor/tsconfig.json
index e5caf463835d0..11a16ace1f2f5 100644
--- a/src/plugins/index_pattern_field_editor/tsconfig.json
+++ b/src/plugins/index_pattern_field_editor/tsconfig.json
@@ -7,7 +7,11 @@
"declarationMap": true
},
"include": [
+ "../../../typings/**/*",
+ "__jest__/**/*",
+ "common/**/*",
"public/**/*",
+ "server/**/*",
],
"references": [
{ "path": "../../core/tsconfig.json" },
diff --git a/test/accessibility/apps/management.ts b/test/accessibility/apps/management.ts
index 538755b482fbf..2fb3de63a81a7 100644
--- a/test/accessibility/apps/management.ts
+++ b/test/accessibility/apps/management.ts
@@ -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 () => {
diff --git a/test/api_integration/apis/index.ts b/test/api_integration/apis/index.ts
index 0d87569cb8b97..998c0b834d224 100644
--- a/test/api_integration/apis/index.ts
+++ b/test/api_integration/apis/index.ts
@@ -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'));
diff --git a/test/api_integration/apis/index_pattern_field_editor/constants.ts b/test/api_integration/apis/index_pattern_field_editor/constants.ts
new file mode 100644
index 0000000000000..ecd6b1ddd408b
--- /dev/null
+++ b/test/api_integration/apis/index_pattern_field_editor/constants.ts
@@ -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';
diff --git a/test/api_integration/apis/index_pattern_field_editor/field_preview.ts b/test/api_integration/apis/index_pattern_field_editor/field_preview.ts
new file mode 100644
index 0000000000000..a84accc8e5f03
--- /dev/null
+++ b/test/api_integration/apis/index_pattern_field_editor/field_preview.ts
@@ -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);
+ });
+ });
+ });
+}
diff --git a/test/api_integration/apis/index_pattern_field_editor/index.ts b/test/api_integration/apis/index_pattern_field_editor/index.ts
new file mode 100644
index 0000000000000..51e4dfaa6ccee
--- /dev/null
+++ b/test/api_integration/apis/index_pattern_field_editor/index.ts
@@ -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'));
+ });
+}
diff --git a/test/functional/apps/management/_field_formatter.ts b/test/functional/apps/management/_field_formatter.ts
index 60c1bbe7b3d1d..9231da8209326 100644
--- a/test/functional/apps/management/_field_formatter.ts
+++ b/test/functional/apps/management/_field_formatter.ts
@@ -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();
});
});
diff --git a/test/functional/apps/management/_runtime_fields.js b/test/functional/apps/management/_runtime_fields.js
index 105e1a394fecb..9a051bbdef6eb 100644
--- a/test/functional/apps/management/_runtime_fields.js
+++ b/test/functional/apps/management/_runtime_fields.js
@@ -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);
});
diff --git a/test/functional/page_objects/index.ts b/test/functional/page_objects/index.ts
index 7c06344c1a1ad..4c9cb150eca03 100644
--- a/test/functional/page_objects/index.ts
+++ b/test/functional/page_objects/index.ts
@@ -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,
};
diff --git a/test/functional/page_objects/management/indexpattern_field_editor_page.ts b/test/functional/page_objects/management/indexpattern_field_editor_page.ts
new file mode 100644
index 0000000000000..6e122b1da5da2
--- /dev/null
+++ b/test/functional/page_objects/management/indexpattern_field_editor_page.ts
@@ -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 });
+ }
+}
diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts
index 5c51a8e76dcad..2645148467d58 100644
--- a/test/functional/page_objects/settings_page.ts
+++ b/test/functional/page_objects/settings_page.ts
@@ -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}"] >
diff --git a/x-pack/plugins/cases/public/common/mock/test_providers.tsx b/x-pack/plugins/cases/public/common/mock/test_providers.tsx
index 15a4f6d992da4..98304808224c7 100644
--- a/x-pack/plugins/cases/public/common/mock/test_providers.tsx
+++ b/x-pack/plugins/cases/public/common/mock/test_providers.tsx
@@ -45,6 +45,8 @@ export const useFormFieldMock = (options?: Partial>): FieldHook
type: 'type',
value: ('mockedValue' as unknown) as T,
isPristine: false,
+ isDirty: false,
+ isModified: false,
isValidating: false,
isValidated: false,
isChangingValue: false,
diff --git a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx
index d0755d05bdb5f..337234bb752f5 100644
--- a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx
+++ b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx
@@ -91,6 +91,8 @@ export const useFormFieldMock = (options?: Partial>): FieldHook
type: 'type',
value: ('mockedValue' as unknown) as T,
isPristine: false,
+ isDirty: false,
+ isModified: false,
isValidating: false,
isValidated: false,
isChangingValue: false,
diff --git a/x-pack/plugins/transform/common/api_schemas/common.ts b/x-pack/plugins/transform/common/api_schemas/common.ts
index b84dcd2a4b749..5354598dc2475 100644
--- a/x-pack/plugins/transform/common/api_schemas/common.ts
+++ b/x-pack/plugins/transform/common/api_schemas/common.ts
@@ -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([
diff --git a/x-pack/plugins/transform/common/shared_imports.ts b/x-pack/plugins/transform/common/shared_imports.ts
index 38cfb6bc457f1..42e77938d9cec 100644
--- a/x-pack/plugins/transform/common/shared_imports.ts
+++ b/x-pack/plugins/transform/common/shared_imports.ts
@@ -12,3 +12,5 @@ export {
patternValidator,
ChartData,
} from '../../ml/common';
+
+export { RUNTIME_FIELD_TYPES } from '../../../../src/plugins/data/common';
diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/types.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/types.ts
index 8b3b33fdde3ef..6e5a34fa6ef2b 100644
--- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/types.ts
+++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/types.ts
@@ -24,7 +24,7 @@ import {
} from '../../../../../../../common/types/transform';
import { LatestFunctionConfig } from '../../../../../../../common/api_schemas/transforms';
-import { isPopulatedObject } from '../../../../../../../common/shared_imports';
+import { isPopulatedObject, RUNTIME_FIELD_TYPES } from '../../../../../../../common/shared_imports';
export interface ErrorMessage {
query: string;
@@ -36,8 +36,6 @@ export interface Field {
type: KBN_FIELD_TYPES;
}
-// Replace this with import once #88995 is merged
-const RUNTIME_FIELD_TYPES = ['keyword', 'long', 'double', 'date', 'ip', 'boolean'] as const;
type RuntimeType = typeof RUNTIME_FIELD_TYPES[number];
export interface RuntimeField {
diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json
index 9d880276312f7..7bf0607e27bd5 100644
--- a/x-pack/plugins/translations/translations/ja-JP.json
+++ b/x-pack/plugins/translations/translations/ja-JP.json
@@ -2810,7 +2810,6 @@
"indexPatternFieldEditor.duration.showSuffixLabel": "接尾辞を表示",
"indexPatternFieldEditor.duration.showSuffixLabel.short": "短縮サフィックスを使用",
"indexPatternFieldEditor.durationErrorMessage": "小数部分の桁数は0から20までの間で指定する必要があります",
- "indexPatternFieldEditor.editor.flyoutCloseButtonLabel": "閉じる",
"indexPatternFieldEditor.editor.flyoutDefaultTitle": "フィールドを作成",
"indexPatternFieldEditor.editor.flyoutEditFieldSubtitle": "インデックスパターン:{patternName}",
"indexPatternFieldEditor.editor.flyoutEditFieldTitle": "「{fieldName}」フィールドの編集",
diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json
index addbe289ee990..fb662890571a5 100644
--- a/x-pack/plugins/translations/translations/zh-CN.json
+++ b/x-pack/plugins/translations/translations/zh-CN.json
@@ -2822,7 +2822,6 @@
"indexPatternFieldEditor.duration.showSuffixLabel": "显示后缀",
"indexPatternFieldEditor.duration.showSuffixLabel.short": "使用短后缀",
"indexPatternFieldEditor.durationErrorMessage": "小数位数必须介于 0 和 20 之间",
- "indexPatternFieldEditor.editor.flyoutCloseButtonLabel": "关闭",
"indexPatternFieldEditor.editor.flyoutDefaultTitle": "创建字段",
"indexPatternFieldEditor.editor.flyoutEditFieldSubtitle": "索引模式:{patternName}",
"indexPatternFieldEditor.editor.flyoutEditFieldTitle": "编辑字段“{fieldName}”",
From 1311fe38ae7c5631b55cc1aacac7d2d274b734b3 Mon Sep 17 00:00:00 2001
From: Ross Bell
Date: Fri, 13 Aug 2021 17:41:17 -0500
Subject: [PATCH 26/56] Add Workplace Search sync controls UI (#108558)
* Wip
* Things are more broken, but closer to the end goal
* Get patch request working
* Update event type
* Other two toggles
* Force sync button
* Remove force sync button for now
* Disable the checkbox when globally disabled and introduce click to save
* Wip tests
* One test down
* Test for skipping name alert
* Linter
* Fix undefined check
* Prettier
* Apply suggestions from code review
Co-authored-by: Scotty Bollinger
* Refactor some structures into interfaces
* UI tweaks
Co-authored-by: Scotty Bollinger
---
.../__mocks__/content_sources.mock.ts | 26 +++++-
.../applications/workplace_search/types.ts | 15 +++
.../components/source_settings.test.tsx | 78 ++++++++++++++++
.../components/source_settings.tsx | 93 ++++++++++++++++++-
.../views/content_sources/constants.ts | 42 +++++++++
.../content_sources/source_logic.test.ts | 15 +++
.../views/content_sources/source_logic.ts | 24 ++++-
.../server/routes/workplace_search/sources.ts | 37 ++++++--
8 files changed, 314 insertions(+), 16 deletions(-)
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts
index c599a13cc3119..5f515fc99769c 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts
@@ -28,7 +28,7 @@ export const contentSources = [
},
{
id: '124',
- serviceType: 'jira',
+ serviceType: 'jira_cloud',
searchable: true,
supportedByLicense: true,
status: 'synced',
@@ -43,6 +43,24 @@ export const contentSources = [
},
];
+const defaultIndexing = {
+ enabled: true,
+ defaultAction: 'include',
+ rules: [],
+ schedule: {
+ intervals: [],
+ blocked: [],
+ },
+ features: {
+ contentExtraction: {
+ enabled: true,
+ },
+ thumbnails: {
+ enabled: true,
+ },
+ },
+};
+
export const fullContentSources = [
{
...contentSources[0],
@@ -66,8 +84,11 @@ export const fullContentSources = [
type: 'summary',
},
],
+ indexing: defaultIndexing,
groups,
custom: false,
+ isIndexedSource: true,
+ areThumbnailsConfigEnabled: true,
accessToken: '123token',
urlField: 'myLink',
titleField: 'heading',
@@ -85,7 +106,10 @@ export const fullContentSources = [
details: [],
summary: [],
groups: [],
+ indexing: defaultIndexing,
custom: true,
+ isIndexedSource: true,
+ areThumbnailsConfigEnabled: true,
accessToken: '123token',
urlField: 'url',
titleField: 'title',
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts
index e50b12f781947..8f9528d52195e 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts
@@ -129,12 +129,27 @@ interface SourceActivity {
status: string;
}
+interface IndexingConfig {
+ enabled: boolean;
+ features: {
+ contentExtraction: {
+ enabled: boolean;
+ };
+ thumbnails: {
+ enabled: boolean;
+ };
+ };
+}
+
export interface ContentSourceFullData extends ContentSourceDetails {
activities: SourceActivity[];
details: DescriptionList[];
summary: DocumentSummaryItem[];
groups: Group[];
+ indexing: IndexingConfig;
custom: boolean;
+ isIndexedSource: boolean;
+ areThumbnailsConfigEnabled: boolean;
accessToken: string;
urlField: string;
titleField: string;
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.test.tsx
index da4346d54727c..0276e75e4d219 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.test.tsx
@@ -105,6 +105,84 @@ describe('SourceSettings', () => {
);
});
+ it('handles disabling synchronization', () => {
+ const wrapper = shallow();
+
+ const synchronizeSwitch = wrapper.find('[data-test-subj="SynchronizeToggle"]').first();
+ const event = { target: { checked: false } };
+ synchronizeSwitch.prop('onChange')?.(event as any);
+
+ wrapper.find('[data-test-subj="SaveSyncControlsButton"]').simulate('click');
+
+ expect(updateContentSource).toHaveBeenCalledWith(fullContentSources[0].id, {
+ indexing: {
+ enabled: false,
+ features: {
+ content_extraction: { enabled: true },
+ thumbnails: { enabled: true },
+ },
+ },
+ });
+ });
+
+ it('handles disabling thumbnails', () => {
+ const wrapper = shallow();
+
+ const thumbnailsSwitch = wrapper.find('[data-test-subj="ThumbnailsToggle"]').first();
+ const event = { target: { checked: false } };
+ thumbnailsSwitch.prop('onChange')?.(event as any);
+
+ wrapper.find('[data-test-subj="SaveSyncControlsButton"]').simulate('click');
+
+ expect(updateContentSource).toHaveBeenCalledWith(fullContentSources[0].id, {
+ indexing: {
+ enabled: true,
+ features: {
+ content_extraction: { enabled: true },
+ thumbnails: { enabled: false },
+ },
+ },
+ });
+ });
+
+ it('handles disabling content extraction', () => {
+ const wrapper = shallow();
+
+ const contentExtractionSwitch = wrapper
+ .find('[data-test-subj="ContentExtractionToggle"]')
+ .first();
+ const event = { target: { checked: false } };
+ contentExtractionSwitch.prop('onChange')?.(event as any);
+
+ wrapper.find('[data-test-subj="SaveSyncControlsButton"]').simulate('click');
+
+ expect(updateContentSource).toHaveBeenCalledWith(fullContentSources[0].id, {
+ indexing: {
+ enabled: true,
+ features: {
+ content_extraction: { enabled: false },
+ thumbnails: { enabled: true },
+ },
+ },
+ });
+ });
+
+ it('disables the thumbnails switch when globally disabled', () => {
+ setMockValues({
+ ...mockValues,
+ contentSource: {
+ ...fullContentSources[0],
+ areThumbnailsConfigEnabled: false,
+ },
+ });
+
+ const wrapper = shallow();
+
+ const synchronizeSwitch = wrapper.find('[data-test-subj="ThumbnailsToggle"]');
+
+ expect(synchronizeSwitch.prop('disabled')).toEqual(true);
+ });
+
describe('DownloadDiagnosticsButton', () => {
it('renders for org with correct href', () => {
const wrapper = shallow();
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx
index e4f52d94ad9e7..d6f16db4d5129 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx
@@ -17,6 +17,8 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
+ EuiSpacer,
+ EuiSwitch,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
@@ -50,6 +52,12 @@ import {
SYNC_DIAGNOSTICS_TITLE,
SYNC_DIAGNOSTICS_DESCRIPTION,
SYNC_DIAGNOSTICS_BUTTON,
+ SYNC_MANAGEMENT_TITLE,
+ SYNC_MANAGEMENT_DESCRIPTION,
+ SYNC_MANAGEMENT_SYNCHRONIZE_LABEL,
+ SYNC_MANAGEMENT_THUMBNAILS_LABEL,
+ SYNC_MANAGEMENT_THUMBNAILS_GLOBAL_CONFIG_LABEL,
+ SYNC_MANAGEMENT_CONTENT_EXTRACTION_LABEL,
} from '../constants';
import { staticSourceData } from '../source_data';
import { SourceLogic } from '../source_logic';
@@ -63,7 +71,21 @@ export const SourceSettings: React.FC = () => {
const { getSourceConfigData } = useActions(AddSourceLogic);
const {
- contentSource: { name, id, serviceType },
+ contentSource: {
+ name,
+ id,
+ serviceType,
+ custom: isCustom,
+ isIndexedSource,
+ areThumbnailsConfigEnabled,
+ indexing: {
+ enabled,
+ features: {
+ contentExtraction: { enabled: contentExtractionEnabled },
+ thumbnails: { enabled: thumbnailsEnabled },
+ },
+ },
+ },
buttonLoading,
} = useValues(SourceLogic);
@@ -88,6 +110,11 @@ export const SourceSettings: React.FC = () => {
const hideConfirm = () => setModalVisibility(false);
const showConfig = isOrganization && !isEmpty(configuredFields);
+ const showSyncControls = isOrganization && isIndexedSource && !isCustom;
+
+ const [synchronizeChecked, setSynchronize] = useState(enabled);
+ const [thumbnailsChecked, setThumbnails] = useState(thumbnailsEnabled);
+ const [contentExtractionChecked, setContentExtraction] = useState(contentExtractionEnabled);
const { clientId, clientSecret, publicKey, consumerKey, baseUrl } = configuredFields || {};
@@ -102,6 +129,18 @@ export const SourceSettings: React.FC = () => {
updateContentSource(id, { name: inputValue });
};
+ const submitSyncControls = () => {
+ updateContentSource(id, {
+ indexing: {
+ enabled: synchronizeChecked,
+ features: {
+ content_extraction: { enabled: contentExtractionChecked },
+ thumbnails: { enabled: thumbnailsChecked },
+ },
+ },
+ });
+ };
+
const handleSourceRemoval = () => {
/**
* The modal was just hanging while the UI waited for the server to respond.
@@ -180,6 +219,58 @@ export const SourceSettings: React.FC = () => {
)}
+ {showSyncControls && (
+
+
+
+ setSynchronize(e.target.checked)}
+ label={SYNC_MANAGEMENT_SYNCHRONIZE_LABEL}
+ data-test-subj="SynchronizeToggle"
+ />
+
+
+
+
+
+ setThumbnails(e.target.checked)}
+ label={
+ areThumbnailsConfigEnabled
+ ? SYNC_MANAGEMENT_THUMBNAILS_LABEL
+ : SYNC_MANAGEMENT_THUMBNAILS_GLOBAL_CONFIG_LABEL
+ }
+ disabled={!areThumbnailsConfigEnabled}
+ data-test-subj="ThumbnailsToggle"
+ />
+
+
+
+
+ setContentExtraction(e.target.checked)}
+ label={SYNC_MANAGEMENT_CONTENT_EXTRACTION_LABEL}
+ data-test-subj="ContentExtractionToggle"
+ />
+
+
+
+
+
+
+ {SAVE_CHANGES_BUTTON}
+
+
+
+
+ )}
{
expect(onUpdateSourceNameSpy).toHaveBeenCalledWith(contentSource.name);
});
+ it('does not call onUpdateSourceName when the name is not supplied', async () => {
+ AppLogic.values.isOrganization = true;
+
+ const onUpdateSourceNameSpy = jest.spyOn(SourceLogic.actions, 'onUpdateSourceName');
+ const promise = Promise.resolve(contentSource);
+ http.patch.mockReturnValue(promise);
+ SourceLogic.actions.updateContentSource(contentSource.id, { indexing: { enabled: true } });
+
+ expect(http.patch).toHaveBeenCalledWith('/api/workplace_search/org/sources/123/settings', {
+ body: JSON.stringify({ content_source: { indexing: { enabled: true } } }),
+ });
+ await promise;
+ expect(onUpdateSourceNameSpy).not.toHaveBeenCalledWith(contentSource.name);
+ });
+
it('calls API and sets values (account)', async () => {
AppLogic.values.isOrganization = false;
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts
index 0fd44e01ae495..4d145bf798160 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts
@@ -34,8 +34,11 @@ export interface SourceActions {
searchContentSourceDocuments(sourceId: string): { sourceId: string };
updateContentSource(
sourceId: string,
- source: { name: string }
- ): { sourceId: string; source: { name: string } };
+ source: SourceUpdatePayload
+ ): {
+ sourceId: string;
+ source: ContentSourceFullData;
+ };
resetSourceState(): void;
removeContentSource(sourceId: string): { sourceId: string };
initializeSource(sourceId: string): { sourceId: string };
@@ -57,6 +60,17 @@ interface SearchResultsResponse {
meta: Meta;
}
+interface SourceUpdatePayload {
+ name?: string;
+ indexing?: {
+ enabled?: boolean;
+ features?: {
+ thumbnails?: { enabled: boolean };
+ content_extraction?: { enabled: boolean };
+ };
+ };
+}
+
export const SourceLogic = kea>({
path: ['enterprise_search', 'workplace_search', 'source_logic'],
actions: {
@@ -69,7 +83,7 @@ export const SourceLogic = kea>({
initializeSource: (sourceId: string) => ({ sourceId }),
initializeFederatedSummary: (sourceId: string) => ({ sourceId }),
searchContentSourceDocuments: (sourceId: string) => ({ sourceId }),
- updateContentSource: (sourceId: string, source: { name: string }) => ({ sourceId, source }),
+ updateContentSource: (sourceId: string, source: SourceUpdatePayload) => ({ sourceId, source }),
removeContentSource: (sourceId: string) => ({
sourceId,
}),
@@ -209,7 +223,9 @@ export const SourceLogic = kea>({
const response = await HttpLogic.values.http.patch(route, {
body: JSON.stringify({ content_source: source }),
});
- actions.onUpdateSourceName(response.name);
+ if (source.name) {
+ actions.onUpdateSourceName(response.name);
+ }
} catch (e) {
flashAPIErrors(e);
}
diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts
index 835ad84ef6853..cb56f54a1df6a 100644
--- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts
+++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts
@@ -57,6 +57,31 @@ const displaySettingsSchema = schema.object({
detailFields: schema.oneOf([schema.arrayOf(displayFieldSchema), displayFieldSchema]),
});
+const sourceSettingsSchema = schema.object({
+ content_source: schema.object({
+ name: schema.maybe(schema.string()),
+ indexing: schema.maybe(
+ schema.object({
+ enabled: schema.maybe(schema.boolean()),
+ features: schema.maybe(
+ schema.object({
+ thumbnails: schema.maybe(
+ schema.object({
+ enabled: schema.boolean(),
+ })
+ ),
+ content_extraction: schema.maybe(
+ schema.object({
+ enabled: schema.boolean(),
+ })
+ ),
+ })
+ ),
+ })
+ ),
+ }),
+});
+
// Account routes
export function registerAccountSourcesRoute({
router,
@@ -217,11 +242,7 @@ export function registerAccountSourceSettingsRoute({
{
path: '/api/workplace_search/account/sources/{id}/settings',
validate: {
- body: schema.object({
- content_source: schema.object({
- name: schema.string(),
- }),
- }),
+ body: sourceSettingsSchema,
params: schema.object({
id: schema.string(),
}),
@@ -565,11 +586,7 @@ export function registerOrgSourceSettingsRoute({
{
path: '/api/workplace_search/org/sources/{id}/settings',
validate: {
- body: schema.object({
- content_source: schema.object({
- name: schema.string(),
- }),
- }),
+ body: sourceSettingsSchema,
params: schema.object({
id: schema.string(),
}),
From 5ef1f95711377434d14688508d47b4f005b47348 Mon Sep 17 00:00:00 2001
From: Marshall Main <55718608+marshallmain@users.noreply.github.com>
Date: Fri, 13 Aug 2021 15:51:23 -0700
Subject: [PATCH 27/56] Add signal.original_event.reason to signal_extra_fields
for insertion into old indices (#108594)
---
.../routes/index/signal_extra_fields.json | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signal_extra_fields.json b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signal_extra_fields.json
index 7bc20fd540b9b..32c084f927d7b 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signal_extra_fields.json
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signal_extra_fields.json
@@ -43,6 +43,14 @@
}
}
},
+ "original_event": {
+ "type": "object",
+ "properties": {
+ "reason": {
+ "type": "keyword"
+ }
+ }
+ },
"reason": {
"type": "keyword"
},
From bfea4a1c2bab0df1ff56df2f48ddfeda4b15e408 Mon Sep 17 00:00:00 2001
From: CJ Cenizal
Date: Fri, 13 Aug 2021 16:49:55 -0700
Subject: [PATCH 28/56] Add EuiCodeEditor to ES UI Shared. (#108318)
* Export EuiCodeEditor from es_ui_shared and consume it in Grok Debugger. Remove warning from EuiCodeEditor.
* Lazy-load code editor so it doesn't bloat the EsUiShared plugin bundle.
* Refactor mocks into a shared jest_mock.tsx file.
---
package.json | 2 +-
.../__snapshots__/code_editor.test.tsx.snap | 627 ++++++++++++++++++
.../components/code_editor/_code_editor.scss | 38 ++
.../public/components/code_editor/_index.scss | 1 +
.../code_editor/code_editor.test.tsx | 117 ++++
.../components/code_editor/code_editor.tsx | 308 +++++++++
.../public/components/code_editor/index.tsx | 32 +
.../components/code_editor/jest_mock.tsx | 32 +
.../components/json_editor/json_editor.tsx | 3 +-
src/plugins/es_ui_shared/public/index.ts | 1 +
x-pack/plugins/grokdebugger/kibana.json | 3 +-
.../custom_patterns_input.js | 13 +-
.../components/event_input/event_input.js | 6 +-
.../components/event_output/event_output.js | 4 +-
.../components/pattern_input/pattern_input.js | 6 +-
.../grokdebugger/public/shared_imports.ts | 8 +
.../component_template_create.test.tsx | 5 +-
.../helpers/mappings_editor.helpers.tsx | 2 +-
.../helpers/setup_environment.tsx | 12 +-
.../load_mappings_provider.test.tsx | 18 +-
.../__jest__/test_pipeline.helpers.tsx | 22 +-
.../load_from_json/modal_provider.test.tsx | 18 +-
yarn.lock | 14 +-
23 files changed, 1199 insertions(+), 93 deletions(-)
create mode 100644 src/plugins/es_ui_shared/public/components/code_editor/__snapshots__/code_editor.test.tsx.snap
create mode 100644 src/plugins/es_ui_shared/public/components/code_editor/_code_editor.scss
create mode 100644 src/plugins/es_ui_shared/public/components/code_editor/_index.scss
create mode 100644 src/plugins/es_ui_shared/public/components/code_editor/code_editor.test.tsx
create mode 100644 src/plugins/es_ui_shared/public/components/code_editor/code_editor.tsx
create mode 100644 src/plugins/es_ui_shared/public/components/code_editor/index.tsx
create mode 100644 src/plugins/es_ui_shared/public/components/code_editor/jest_mock.tsx
create mode 100644 x-pack/plugins/grokdebugger/public/shared_imports.ts
diff --git a/package.json b/package.json
index 17beadebca91c..db8f9fac9238f 100644
--- a/package.json
+++ b/package.json
@@ -336,7 +336,7 @@
"re-resizable": "^6.1.1",
"re2": "^1.15.4",
"react": "^16.12.0",
- "react-ace": "^5.9.0",
+ "react-ace": "^7.0.5",
"react-beautiful-dnd": "^13.0.0",
"react-color": "^2.13.8",
"react-dom": "^16.12.0",
diff --git a/src/plugins/es_ui_shared/public/components/code_editor/__snapshots__/code_editor.test.tsx.snap b/src/plugins/es_ui_shared/public/components/code_editor/__snapshots__/code_editor.test.tsx.snap
new file mode 100644
index 0000000000000..aeab9a66c7694
--- /dev/null
+++ b/src/plugins/es_ui_shared/public/components/code_editor/__snapshots__/code_editor.test.tsx.snap
@@ -0,0 +1,627 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`EuiCodeEditor behavior hint element should be disabled when the ui ace box gains focus 1`] = `
+
+`;
+
+exports[`EuiCodeEditor behavior hint element should be enabled when the ui ace box loses focus 1`] = `
+
+`;
+
+exports[`EuiCodeEditor behavior hint element should be tabable 1`] = `
+
+`;
+
+exports[`EuiCodeEditor is rendered 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+ XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
+
+
+
+
+`;
+
+exports[`EuiCodeEditor props aria attributes allows setting aria-describedby on textbox 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+ XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
+
+
+
+
+`;
+
+exports[`EuiCodeEditor props aria attributes allows setting aria-labelledby on textbox 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+ XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
+
+
+
+
+`;
+
+exports[`EuiCodeEditor props isReadOnly renders alternate hint text 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+ XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
+
+
+
+
+`;
+
+exports[`EuiCodeEditor props theme renders terminal theme 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+ XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
+
+
+
+
+`;
diff --git a/src/plugins/es_ui_shared/public/components/code_editor/_code_editor.scss b/src/plugins/es_ui_shared/public/components/code_editor/_code_editor.scss
new file mode 100644
index 0000000000000..a3acf6b46e1de
--- /dev/null
+++ b/src/plugins/es_ui_shared/public/components/code_editor/_code_editor.scss
@@ -0,0 +1,38 @@
+.euiCodeEditorWrapper {
+ position: relative;
+
+ .ace_hidden-cursors {
+ opacity: 0;
+ }
+
+ &.euiCodeEditorWrapper-isEditing {
+ .ace_hidden-cursors {
+ opacity: 1;
+ }
+ }
+}
+
+.euiCodeEditorKeyboardHint {
+ position: absolute;
+ top: 0;
+ left: 0;
+ background: transparentize($euiColorGhost, .3);
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ opacity: 0;
+ cursor: pointer;
+ height: 100%;
+ width: 100%;
+
+ &:focus {
+ opacity: 1;
+ border: 2px solid $euiColorPrimary;
+ z-index: $euiZLevel1;
+ }
+
+ &.euiCodeEditorKeyboardHint-isInactive {
+ display: none;
+ }
+}
diff --git a/src/plugins/es_ui_shared/public/components/code_editor/_index.scss b/src/plugins/es_ui_shared/public/components/code_editor/_index.scss
new file mode 100644
index 0000000000000..e68320413af86
--- /dev/null
+++ b/src/plugins/es_ui_shared/public/components/code_editor/_index.scss
@@ -0,0 +1 @@
+@import 'code_editor';
diff --git a/src/plugins/es_ui_shared/public/components/code_editor/code_editor.test.tsx b/src/plugins/es_ui_shared/public/components/code_editor/code_editor.test.tsx
new file mode 100644
index 0000000000000..b5e23bfc3f95b
--- /dev/null
+++ b/src/plugins/es_ui_shared/public/components/code_editor/code_editor.test.tsx
@@ -0,0 +1,117 @@
+/*
+ * 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 { mount, ReactWrapper } from 'enzyme';
+import EuiCodeEditor from './code_editor';
+// @ts-ignore
+import { keys } from '@elastic/eui/lib/services';
+import { findTestSubject, requiredProps, takeMountedSnapshot } from '@elastic/eui/lib/test';
+
+describe('EuiCodeEditor', () => {
+ test('is rendered', () => {
+ const component = mount();
+ expect(takeMountedSnapshot(component)).toMatchSnapshot();
+ });
+
+ describe('props', () => {
+ describe('isReadOnly', () => {
+ test('renders alternate hint text', () => {
+ const component = mount();
+ expect(takeMountedSnapshot(component)).toMatchSnapshot();
+ });
+ });
+
+ describe('theme', () => {
+ test('renders terminal theme', () => {
+ const component = mount();
+ expect(takeMountedSnapshot(component)).toMatchSnapshot();
+ });
+ });
+
+ describe('aria attributes', () => {
+ test('allows setting aria-labelledby on textbox', () => {
+ const component = mount();
+ expect(takeMountedSnapshot(component)).toMatchSnapshot();
+ });
+
+ test('allows setting aria-describedby on textbox', () => {
+ const component = mount();
+ expect(takeMountedSnapshot(component)).toMatchSnapshot();
+ });
+ });
+ });
+
+ describe('behavior', () => {
+ let component: ReactWrapper;
+
+ beforeEach(() => {
+ // Addresses problems with attaching to document.body.
+ // https://meganesulli.com/blog/managing-focus-with-react-and-jest/
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ // We need to manually attach the element to document.body to assert against
+ // document.activeElement in our focus behavior tests, below.
+ component = mount(, { attachTo: container });
+ });
+
+ afterEach(() => {
+ // We need to clean up after ourselves per https://github.com/enzymejs/enzyme/issues/2337.
+ if (component) {
+ component.unmount();
+ }
+ });
+
+ describe('hint element', () => {
+ test('should be tabable', () => {
+ const hint = findTestSubject(component, 'codeEditorHint').getDOMNode();
+ expect(hint).toMatchSnapshot();
+ });
+
+ test('should be disabled when the ui ace box gains focus', () => {
+ const hint = findTestSubject(component, 'codeEditorHint');
+ hint.simulate('keyup', { key: keys.ENTER });
+ expect(findTestSubject(component, 'codeEditorHint').getDOMNode()).toMatchSnapshot();
+ });
+
+ test('should be enabled when the ui ace box loses focus', () => {
+ const hint = findTestSubject(component, 'codeEditorHint');
+ hint.simulate('keyup', { key: keys.ENTER });
+ // @ts-ignore onBlurAce is known to exist and its params are only passed through to the onBlur callback
+ component.instance().onBlurAce();
+ expect(findTestSubject(component, 'codeEditorHint').getDOMNode()).toMatchSnapshot();
+ });
+ });
+
+ describe('interaction', () => {
+ test('bluring the ace textbox should call a passed onBlur prop', () => {
+ const blurSpy = jest.fn().mockName('blurSpy');
+ const el = mount();
+ // @ts-ignore onBlurAce is known to exist and its params are only passed through to the onBlur callback
+ el.instance().onBlurAce();
+ expect(blurSpy).toHaveBeenCalled();
+ });
+
+ test('pressing escape in ace textbox will enable overlay', () => {
+ // We cannot simulate the `commands` path, but this interaction still
+ // serves as a fallback in cases where `commands` is unavailable.
+ // @ts-ignore onFocusAce is known to exist
+ component.instance().onFocusAce();
+ // @ts-ignore onKeydownAce is known to exist and its params' values are unimportant
+ component.instance().onKeydownAce({
+ preventDefault: () => {},
+ stopPropagation: () => {},
+ key: keys.ESCAPE,
+ });
+ const hint = findTestSubject(component, 'codeEditorHint').getDOMNode();
+ expect(hint).toBe(document.activeElement);
+ });
+ });
+ });
+});
diff --git a/src/plugins/es_ui_shared/public/components/code_editor/code_editor.tsx b/src/plugins/es_ui_shared/public/components/code_editor/code_editor.tsx
new file mode 100644
index 0000000000000..cae3210857543
--- /dev/null
+++ b/src/plugins/es_ui_shared/public/components/code_editor/code_editor.tsx
@@ -0,0 +1,308 @@
+/*
+ * 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, { Component, AriaAttributes } from 'react';
+import classNames from 'classnames';
+import AceEditor, { IAceEditorProps } from 'react-ace';
+import { EuiI18n } from '@elastic/eui';
+// @ts-ignore
+import { htmlIdGenerator, keys } from '@elastic/eui/lib/services';
+
+import './_index.scss';
+
+/**
+ * Wraps Object.keys with proper typescript definition of the resulting array
+ */
+function keysOf(obj: T): K[] {
+ return Object.keys(obj) as K[];
+}
+
+const DEFAULT_MODE = 'text';
+const DEFAULT_THEME = 'textmate';
+
+function setOrRemoveAttribute(
+ element: HTMLTextAreaElement,
+ attributeName: SupportedAriaAttribute,
+ value: SupportedAriaAttributes[SupportedAriaAttribute]
+) {
+ if (value === null || value === undefined) {
+ element.removeAttribute(attributeName);
+ } else {
+ element.setAttribute(attributeName, value);
+ }
+}
+
+type SupportedAriaAttribute = 'aria-label' | 'aria-labelledby' | 'aria-describedby';
+type SupportedAriaAttributes = Pick;
+
+export interface EuiCodeEditorProps extends SupportedAriaAttributes, Omit {
+ width?: string;
+ height?: string;
+ onBlur?: IAceEditorProps['onBlur'];
+ onFocus?: IAceEditorProps['onFocus'];
+ isReadOnly?: boolean;
+ setOptions: IAceEditorProps['setOptions'];
+ cursorStart?: number;
+ 'data-test-subj'?: string;
+ /**
+ * Select the `brace` theme
+ * The matching theme file must also be imported from `brace` (e.g., `import 'brace/theme/github';`)
+ */
+ theme?: IAceEditorProps['theme'];
+
+ /**
+ * Use string for a built-in mode or object for a custom mode
+ */
+ mode?: IAceEditorProps['mode'] | object;
+ id?: string;
+}
+
+export interface EuiCodeEditorState {
+ isHintActive: boolean;
+ isEditing: boolean;
+ name: string;
+}
+
+class EuiCodeEditor extends Component {
+ static defaultProps = {
+ setOptions: {},
+ };
+
+ state: EuiCodeEditorState = {
+ isHintActive: true,
+ isEditing: false,
+ name: htmlIdGenerator()(),
+ };
+
+ constructor(props: EuiCodeEditorProps) {
+ super(props);
+ }
+
+ idGenerator = htmlIdGenerator();
+ aceEditor: AceEditor | null = null;
+ editorHint: HTMLButtonElement | null = null;
+
+ aceEditorRef = (aceEditor: AceEditor | null) => {
+ if (aceEditor) {
+ this.aceEditor = aceEditor;
+ const textbox = aceEditor.editor.textInput.getElement() as HTMLTextAreaElement;
+ textbox.tabIndex = -1;
+ textbox.addEventListener('keydown', this.onKeydownAce);
+ setOrRemoveAttribute(textbox, 'aria-label', this.props['aria-label']);
+ setOrRemoveAttribute(textbox, 'aria-labelledby', this.props['aria-labelledby']);
+ setOrRemoveAttribute(textbox, 'aria-describedby', this.props['aria-describedby']);
+ }
+ };
+
+ onEscToExit = () => {
+ this.stopEditing();
+ if (this.editorHint) {
+ this.editorHint.focus();
+ }
+ };
+
+ onKeydownAce = (event: KeyboardEvent) => {
+ if (event.key === keys.ESCAPE) {
+ event.preventDefault();
+ event.stopPropagation();
+ // Handles exiting edit mode when `isReadOnly` is set.
+ // Other 'esc' cases handled by `stopEditingOnEsc` command.
+ // Would run after `stopEditingOnEsc`.
+ if (this.aceEditor !== null && !this.aceEditor.editor.completer && this.state.isEditing) {
+ this.onEscToExit();
+ }
+ }
+ };
+
+ onFocusAce: IAceEditorProps['onFocus'] = (event, editor) => {
+ this.setState({
+ isEditing: true,
+ });
+ if (this.props.onFocus) {
+ this.props.onFocus(event, editor);
+ }
+ };
+
+ onBlurAce: IAceEditorProps['onBlur'] = (event, editor) => {
+ this.stopEditing();
+ if (this.props.onBlur) {
+ this.props.onBlur(event, editor);
+ }
+ };
+
+ startEditing = () => {
+ this.setState({
+ isHintActive: false,
+ });
+ if (this.aceEditor !== null) {
+ this.aceEditor.editor.textInput.focus();
+ }
+ };
+
+ stopEditing() {
+ this.setState({
+ isHintActive: true,
+ isEditing: false,
+ });
+ }
+
+ isCustomMode() {
+ return typeof this.props.mode === 'object';
+ }
+
+ setCustomMode() {
+ if (this.aceEditor !== null) {
+ this.aceEditor.editor.getSession().setMode(this.props.mode);
+ }
+ }
+
+ componentDidMount() {
+ if (this.isCustomMode()) {
+ this.setCustomMode();
+ }
+ const { isReadOnly, id } = this.props;
+
+ const textareaProps: {
+ id?: string;
+ readOnly?: boolean;
+ } = { id, readOnly: isReadOnly };
+
+ const el = document.getElementById(this.state.name);
+ if (el) {
+ const textarea = el.querySelector('textarea');
+ if (textarea)
+ keysOf(textareaProps).forEach((key) => {
+ if (textareaProps[key]) textarea.setAttribute(`${key}`, textareaProps[key]!.toString());
+ });
+ }
+ }
+
+ componentDidUpdate(prevProps: EuiCodeEditorProps) {
+ if (this.props.mode !== prevProps.mode && this.isCustomMode()) {
+ this.setCustomMode();
+ }
+ }
+
+ render() {
+ const {
+ width,
+ height,
+ onBlur,
+ isReadOnly,
+ setOptions,
+ cursorStart,
+ mode = DEFAULT_MODE,
+ 'data-test-subj': dataTestSubj = 'codeEditorContainer',
+ theme = DEFAULT_THEME,
+ commands = [],
+ ...rest
+ } = this.props;
+
+ const classes = classNames('euiCodeEditorWrapper', {
+ 'euiCodeEditorWrapper-isEditing': this.state.isEditing,
+ });
+
+ const promptClasses = classNames('euiCodeEditorKeyboardHint', {
+ 'euiCodeEditorKeyboardHint-isInactive': !this.state.isHintActive,
+ });
+
+ let filteredCursorStart;
+
+ const options: IAceEditorProps['setOptions'] = { ...setOptions };
+
+ if (isReadOnly) {
+ // Put the cursor at the beginning of the editor, so that it doesn't look like
+ // a prompt to begin typing.
+ filteredCursorStart = -1;
+
+ Object.assign(options, {
+ readOnly: true,
+ highlightActiveLine: false,
+ highlightGutterLine: false,
+ });
+ } else {
+ filteredCursorStart = cursorStart;
+ }
+
+ const prompt = (
+
+ );
+
+ return (
+
+ );
+ }
+}
+
+// Needed for React.lazy
+// eslint-disable-next-line import/no-default-export
+export default EuiCodeEditor;
diff --git a/src/plugins/es_ui_shared/public/components/code_editor/index.tsx b/src/plugins/es_ui_shared/public/components/code_editor/index.tsx
new file mode 100644
index 0000000000000..3424f89d8ee82
--- /dev/null
+++ b/src/plugins/es_ui_shared/public/components/code_editor/index.tsx
@@ -0,0 +1,32 @@
+/*
+ * 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 { EuiLoadingContentProps, EuiLoadingContent } from '@elastic/eui';
+import type { EuiCodeEditorProps } from './code_editor';
+
+const Placeholder = ({ height }: { height?: string }) => {
+ const numericalHeight = height ? parseInt(height, 10) : 0;
+ // The height of one EuiLoadingContent line is 24px.
+ const lineHeight = 24;
+ const calculatedLineCount =
+ numericalHeight < lineHeight ? 1 : Math.floor(numericalHeight / lineHeight);
+ const lines = Math.min(10, calculatedLineCount);
+
+ return ;
+};
+
+const LazyEuiCodeEditor = React.lazy(() => import('./code_editor'));
+
+export const EuiCodeEditor = (props: EuiCodeEditorProps) => (
+ }>
+
+
+);
+
+export type { EuiCodeEditorProps } from './code_editor';
diff --git a/src/plugins/es_ui_shared/public/components/code_editor/jest_mock.tsx b/src/plugins/es_ui_shared/public/components/code_editor/jest_mock.tsx
new file mode 100644
index 0000000000000..95a0ee3237a48
--- /dev/null
+++ b/src/plugins/es_ui_shared/public/components/code_editor/jest_mock.tsx
@@ -0,0 +1,32 @@
+/*
+ * 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';
+
+// NOTE: Import this file for its side-effects. You must import it before the code that it mocks
+// is imported. Typically this means just importing above your other imports.
+// See https://jestjs.io/docs/manual-mocks for more info.
+
+// This mocks any direct imports of EuiCodeEditor, e.g. by JsonEditor.
+jest.mock('.', () => {
+ const original = jest.requireActual('.');
+
+ return {
+ ...original,
+ // Mock EuiCodeEditor, which uses React Ace under the hood.
+ EuiCodeEditor: (props: any) => (
+ {
+ props.onChange(syntheticEvent.jsonString);
+ }}
+ />
+ ),
+ };
+});
diff --git a/src/plugins/es_ui_shared/public/components/json_editor/json_editor.tsx b/src/plugins/es_ui_shared/public/components/json_editor/json_editor.tsx
index 2e4fe3619d314..9eb7695bbae00 100644
--- a/src/plugins/es_ui_shared/public/components/json_editor/json_editor.tsx
+++ b/src/plugins/es_ui_shared/public/components/json_editor/json_editor.tsx
@@ -7,9 +7,10 @@
*/
import React, { useCallback, useMemo } from 'react';
-import { EuiFormRow, EuiCodeEditor } from '@elastic/eui';
+import { EuiFormRow } from '@elastic/eui';
import { debounce } from 'lodash';
+import { EuiCodeEditor } from '../code_editor';
import { useJson, OnJsonEditorUpdateHandler } from './use_json';
interface Props {
diff --git a/src/plugins/es_ui_shared/public/index.ts b/src/plugins/es_ui_shared/public/index.ts
index ef2e2daa25468..9db00bc4be8df 100644
--- a/src/plugins/es_ui_shared/public/index.ts
+++ b/src/plugins/es_ui_shared/public/index.ts
@@ -20,6 +20,7 @@ export { JsonEditor, OnJsonEditorUpdateHandler, JsonEditorState } from './compon
export { PageLoading } from './components/page_loading';
export { SectionLoading } from './components/section_loading';
+export { EuiCodeEditor, EuiCodeEditorProps } from './components/code_editor';
export { Frequency, CronEditor } from './components/cron_editor';
export {
diff --git a/x-pack/plugins/grokdebugger/kibana.json b/x-pack/plugins/grokdebugger/kibana.json
index 8466c191ed9b6..5f288e0cf3bdb 100644
--- a/x-pack/plugins/grokdebugger/kibana.json
+++ b/x-pack/plugins/grokdebugger/kibana.json
@@ -11,6 +11,7 @@
"ui": true,
"configPath": ["xpack", "grokdebugger"],
"requiredBundles": [
- "kibanaReact"
+ "kibanaReact",
+ "esUiShared"
]
}
diff --git a/x-pack/plugins/grokdebugger/public/components/custom_patterns_input/custom_patterns_input.js b/x-pack/plugins/grokdebugger/public/components/custom_patterns_input/custom_patterns_input.js
index 87f3019fd3864..b1f6c2dff50e7 100644
--- a/x-pack/plugins/grokdebugger/public/components/custom_patterns_input/custom_patterns_input.js
+++ b/x-pack/plugins/grokdebugger/public/components/custom_patterns_input/custom_patterns_input.js
@@ -6,17 +6,12 @@
*/
import React from 'react';
-import {
- EuiAccordion,
- EuiCallOut,
- EuiCodeBlock,
- EuiFormRow,
- EuiCodeEditor,
- EuiSpacer,
-} from '@elastic/eui';
-import { EDITOR } from '../../../common/constants';
+import { EuiAccordion, EuiCallOut, EuiCodeBlock, EuiFormRow, EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
+import { EDITOR } from '../../../common/constants';
+import { EuiCodeEditor } from '../../shared_imports';
+
export function CustomPatternsInput({ value, onChange }) {
const sampleCustomPatterns = `POSTFIX_QUEUEID [0-9A-F]{10,11}
MSG message-id=<%{GREEDYDATA}>`;
diff --git a/x-pack/plugins/grokdebugger/public/components/event_input/event_input.js b/x-pack/plugins/grokdebugger/public/components/event_input/event_input.js
index 9989d3972f07d..fe31f40fd8f94 100644
--- a/x-pack/plugins/grokdebugger/public/components/event_input/event_input.js
+++ b/x-pack/plugins/grokdebugger/public/components/event_input/event_input.js
@@ -6,10 +6,12 @@
*/
import React from 'react';
-import { EuiFormRow, EuiCodeEditor } from '@elastic/eui';
-import { EDITOR } from '../../../common/constants';
+import { EuiFormRow } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
+import { EDITOR } from '../../../common/constants';
+import { EuiCodeEditor } from '../../shared_imports';
+
export function EventInput({ value, onChange }) {
return (
', () => {
// Meta editor should be hidden by default
// Since the editor itself is mocked, we checked for the mocked element
- expect(exists('mockCodeEditor')).toBe(false);
+ expect(exists('metaEditor')).toBe(false);
await act(async () => {
actions.toggleMetaSwitch();
@@ -82,7 +83,7 @@ describe('', () => {
component.update();
- expect(exists('mockCodeEditor')).toBe(true);
+ expect(exists('metaEditor')).toBe(true);
});
describe('Validation', () => {
diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx
index c2d74a48263d7..3110b3ce24041 100644
--- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx
+++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx
@@ -276,7 +276,7 @@ const createActions = (testBed: TestBed) => {
};
const updateJsonEditor = (testSubject: TestSubjects, value: object) => {
- find(testSubject).simulate('change', { jsonContent: JSON.stringify(value) });
+ find(testSubject).simulate('change', { jsonString: JSON.stringify(value) });
};
const getJsonEditorValue = (testSubject: TestSubjects) => {
diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/setup_environment.tsx
index b2647b175b324..efd1ec7062423 100644
--- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/setup_environment.tsx
+++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/setup_environment.tsx
@@ -6,6 +6,8 @@
*/
import React from 'react';
+/* eslint-disable-next-line @kbn/eslint/no-restricted-paths */
+import '../../../../../../../../../../src/plugins/es_ui_shared/public/components/code_editor/jest_mock';
import { GlobalFlyout } from '../../../../../../../../../../src/plugins/es_ui_shared/public';
import {
docLinksServiceMock,
@@ -30,16 +32,6 @@ jest.mock('@elastic/eui', () => {
}}
/>
),
- // Mocking EuiCodeEditor, which uses React Ace under the hood
- EuiCodeEditor: (props: any) => (
- {
- props.onChange(e.jsonContent);
- }}
- />
- ),
// Mocking EuiSuperSelect to be able to easily change its value
// with a `myWrapper.simulate('change', { target: { value: 'someValue' } })`
EuiSuperSelect: (props: any) => (
diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/load_mappings/load_mappings_provider.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/load_mappings/load_mappings_provider.test.tsx
index 8f70406962e4c..7f6aea8878a77 100644
--- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/load_mappings/load_mappings_provider.test.tsx
+++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/load_mappings/load_mappings_provider.test.tsx
@@ -7,23 +7,7 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
-
-jest.mock('@elastic/eui', () => {
- const original = jest.requireActual('@elastic/eui');
-
- return {
- ...original,
- // Mocking EuiCodeEditor, which uses React Ace under the hood
- EuiCodeEditor: (props: any) => (
- {
- props.onChange(syntheticEvent.jsonString);
- }}
- />
- ),
- };
-});
+import '../../../../../../../../../src/plugins/es_ui_shared/public/components/code_editor/jest_mock';
jest.mock('lodash', () => {
const original = jest.requireActual('lodash');
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/test_pipeline.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/test_pipeline.helpers.tsx
index 88e5c62a5b1d3..263a40a605d2d 100644
--- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/test_pipeline.helpers.tsx
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/test_pipeline.helpers.tsx
@@ -10,11 +10,14 @@ import React from 'react';
import axios from 'axios';
import axiosXhrAdapter from 'axios/lib/adapters/xhr';
-/* eslint-disable @kbn/eslint/no-restricted-paths */
+/* eslint-disable-next-line @kbn/eslint/no-restricted-paths */
import { usageCollectionPluginMock } from 'src/plugins/usage_collection/public/mocks';
import { registerTestBed, TestBed } from '@kbn/test/jest';
import { stubWebWorker } from '@kbn/test/jest';
+
+/* eslint-disable-next-line @kbn/eslint/no-restricted-paths */
+import '../../../../../../../../src/plugins/es_ui_shared/public/components/code_editor/jest_mock';
import { uiMetricService, apiService } from '../../../services';
import { Props } from '../';
import { initHttpRequests } from './http_requests.helpers';
@@ -24,6 +27,7 @@ stubWebWorker();
jest.mock('../../../../../../../../src/plugins/kibana_react/public', () => {
const original = jest.requireActual('../../../../../../../../src/plugins/kibana_react/public');
+
return {
...original,
// Mocking CodeEditor, which uses React Monaco under the hood
@@ -39,22 +43,6 @@ jest.mock('../../../../../../../../src/plugins/kibana_react/public', () => {
};
});
-jest.mock('@elastic/eui', () => {
- const original = jest.requireActual('@elastic/eui');
- return {
- ...original,
- // Mocking EuiCodeEditor, which uses React Ace under the hood
- EuiCodeEditor: (props: any) => (
- {
- props.onChange(syntheticEvent.jsonString);
- }}
- />
- ),
- };
-});
-
jest.mock('react-virtualized', () => {
const original = jest.requireActual('react-virtualized');
diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/modal_provider.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/modal_provider.test.tsx
index ccefac896fe7c..74b759fc80375 100644
--- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/modal_provider.test.tsx
+++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/modal_provider.test.tsx
@@ -6,25 +6,9 @@
*/
import React from 'react';
+import '../../../../../../../../../src/plugins/es_ui_shared/public/components/code_editor/jest_mock';
import { ModalProvider, OnDoneLoadJsonHandler } from './modal_provider';
-jest.mock('@elastic/eui', () => {
- const original = jest.requireActual('@elastic/eui');
-
- return {
- ...original,
- // Mocking EuiCodeEditor, which uses React Ace under the hood
- EuiCodeEditor: (props: any) => (
- {
- props.onChange(syntheticEvent.jsonString);
- }}
- />
- ),
- };
-});
-
jest.mock('lodash', () => {
const original = jest.requireActual('lodash');
diff --git a/yarn.lock b/yarn.lock
index 39e70709bbf0f..5582c40b94749 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8500,7 +8500,7 @@ brace-expansion@^1.1.7:
balanced-match "^1.0.0"
concat-map "0.0.1"
-brace@0.11.1, brace@^0.11.0, brace@^0.11.1:
+brace@0.11.1, brace@^0.11.1:
version "0.11.1"
resolved "https://registry.yarnpkg.com/brace/-/brace-0.11.1.tgz#4896fcc9d544eef45f4bb7660db320d3b379fe58"
integrity sha1-SJb8ydVE7vRfS7dmDbMg07N5/lg=
@@ -18684,7 +18684,7 @@ lodash.isempty@^4.4.0:
resolved "https://registry.yarnpkg.com/lodash.isempty/-/lodash.isempty-4.4.0.tgz#6f86cbedd8be4ec987be9aaf33c9684db1b31e7e"
integrity sha1-b4bL7di+TsmHvpqvM8loTbGzHn4=
-lodash.isequal@^4.0.0, lodash.isequal@^4.1.1, lodash.isequal@^4.5.0:
+lodash.isequal@^4.0.0, lodash.isequal@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA=
@@ -22865,16 +22865,6 @@ re2@^1.15.4:
nan "^2.14.1"
node-gyp "^7.0.0"
-react-ace@^5.9.0:
- version "5.9.0"
- resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-5.9.0.tgz#427a1cc4869b960a6f9748aa7eb169a9269fc336"
- integrity sha512-r6Tuce6seG05g9kT2Tio6DWohy06knG7e5u9OfhvMquZL+Cyu4eqPf60K1Vi2RXlS3+FWrdG8Rinwu4+oQjjgw==
- dependencies:
- brace "^0.11.0"
- lodash.get "^4.4.2"
- lodash.isequal "^4.1.1"
- prop-types "^15.5.8"
-
react-ace@^7.0.5:
version "7.0.5"
resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-7.0.5.tgz#798299fd52ddf3a3dcc92afc5865538463544f01"
From e33cdc29c698fe2d591c124133476ffb16242b85 Mon Sep 17 00:00:00 2001
From: Caroline Horn <549577+cchaos@users.noreply.github.com>
Date: Fri, 13 Aug 2021 20:29:10 -0400
Subject: [PATCH 29/56] [Enterprise Search] Updated `product_selector` to match
new No Data screens (#108592)
And updated product selector images to match new Kibana UI
---
.../enterprise_search/assets/app_search.png | Bin 25568 -> 147300 bytes
.../assets/bg_enterprise_search.png | Bin 24757 -> 0 bytes
.../assets/workplace_search.png | Bin 30483 -> 79761 bytes
.../license_callout/license_callout.tsx | 2 +-
.../components/product_card/product_card.scss | 50 +-------
.../product_selector.test.tsx | 3 -
.../product_selector/product_selector.tsx | 107 +++++++++---------
.../setup_guide/setup_guide_cta.scss | 13 ---
.../setup_guide/setup_guide_cta.test.tsx | 2 +-
.../setup_guide/setup_guide_cta.tsx | 8 +-
.../applications/enterprise_search/index.scss | 41 -------
.../applications/enterprise_search/index.tsx | 2 -
.../public/applications/index.test.tsx | 2 +-
13 files changed, 62 insertions(+), 168 deletions(-)
delete mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search/assets/bg_enterprise_search.png
delete mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.scss
diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/assets/app_search.png b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/assets/app_search.png
index 6cf0639167e2fe45a58adfebe78c4864ac336e3a..fc22f35d04037ef6eaeadea485a3351a02613062 100644
GIT binary patch
literal 147300
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