Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Runtime field editor] Expose handler from plugin to open editor #82464

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 64 additions & 4 deletions x-pack/plugins/runtime_fields/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,71 @@ Welcome to the home of the runtime field editor and everything related to runtim

## The runtime field editor

The runtime field editor is exported in 2 flavours:
### Integration

* As the content of a `<EuiFlyout />`
The recommended way to integrate the runtime fields editor is by adding a plugin dependency to the `"runtimeFields"` x-pack plugin. This way you will be able to lazy load the editor when it is required and it will not increment the bundle size of your plugin.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it is worth explaining here why we want to share the editor in this way. From what I can tell we are giving users a way to imperatively load a JSX element and I'm wondering what the benefits are of this approach vs a static component. Is it so that we can provide a way for plugins to lazy load the component? Happy if that is only reason, I just wanted to make sure I understood the intention correctly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So yes, the main reason would be the ability to lazy load. So if the editor depends on an external lib it should not increase the page load time.

The second reason is that it simplifies the API as it is not required anymore to provide core services to the editor. And any other plugin dependency is handled automatically instead of having to provide them all through props.


```js
// 1. Add the plugin as a dependency in your kibana.json
{
...
"requiredBundles": [
"runtimeFields",
...
]
}

// 2. Access it in your plugin setup()
export class MyPlugin {
setup(core, { runtimeFields }) {
// logic to provide it to your app, probably through context
}
}

// 3. Load the editor and open it anywhere in your app
const MyComponent = () => {
// Access the plugin through context
const { runtimeFields } = useAppPlugins();

// Ref for handler to close the editor
const closeRuntimeFieldEditor = useRef(() => {});

const saveRuntimeField = (field: RuntimeField) => {
// Do something with the field
};

const openRuntimeFieldsEditor = async() => {
// Lazy load the editor
const { openEditor } = await runtimeFields.loadEditor();

closeRuntimeFieldEditor.current = openEditor({
onSave: saveRuntimeField,
/* defaultValue: optional field to edit */
});
};

useEffect(() => {
return () => {
// Make sure to remove the editor when the component unmounts
closeRuntimeFieldEditor.current();
};
}, []);

return (
<button onClick={openRuntimeFieldsEditor}>Add field</button>
)
}
```

#### Alternative

The runtime field editor is also exported as static React component that you can import into your components. The editor is exported in 2 flavours:

* As the content of a `<EuiFlyout />` (it contains a flyout header and footer)
* As a standalone component that you can inline anywhere

**Note:** The runtime field editor uses the `<CodeEditor />` that has a dependency on the `Provider` from the `"kibana_react"` plugin. If your app is not already wrapped by this provider you will need to add it at least around the runtime field editor. You can see an example in the ["Using the core.overlays.openFlyout()"](#using-the-coreoverlaysopenflyout) example below.

### Content of a `<EuiFlyout />`

```js
Expand Down Expand Up @@ -43,9 +103,9 @@ const MyComponent = () => {
}
```

#### With the `core.overlays.openFlyout`
#### Using the `core.overlays.openFlyout()`

As an alternative you can open the flyout with the `core.overlays.openFlyout`. In this case you will need to wrap the editor with the `Provider` from the "kibana_react" plugin as it is a required dependency for the `<CodeEditor />` component.
As an alternative you can open the flyout with the `openFlyout()` helper from core.

```js
import React, { useRef } from 'react';
Expand Down
2 changes: 1 addition & 1 deletion x-pack/plugins/runtime_fields/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export {
RuntimeFieldFormState,
} from './components';
export { RUNTIME_FIELD_OPTIONS } from './constants';
export { RuntimeField, RuntimeType } from './types';
export { RuntimeField, RuntimeType, PluginSetup as RuntimeFieldsSetup } from './types';

export function plugin() {
return new RuntimeFieldsPlugin();
Expand Down
57 changes: 57 additions & 0 deletions x-pack/plugins/runtime_fields/public/load_editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { CoreSetup, OverlayRef } from 'src/core/public';

import { toMountPoint, createKibanaReactContext } from './shared_imports';
import { LoadEditorResponse, RuntimeField } from './types';

export interface OpenRuntimeFieldEditorProps {
onSave(field: RuntimeField): void;
defaultValue?: RuntimeField;
}

export const getRuntimeFieldEditorLoader = (coreSetup: CoreSetup) => async (): Promise<
LoadEditorResponse
> => {
const { RuntimeFieldEditorFlyoutContent } = await import('./components');
const [core] = await coreSetup.getStartServices();
const { uiSettings, overlays, docLinks } = core;
const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ uiSettings });

let overlayRef: OverlayRef | null = null;

const openEditor = ({ onSave, defaultValue }: OpenRuntimeFieldEditorProps) => {
const closeEditor = () => {
overlayRef?.close();
overlayRef = null;
};

const onSaveField = (field: RuntimeField) => {
closeEditor();
onSave(field);
};

overlayRef = overlays.openFlyout(
toMountPoint(
<KibanaReactContextProvider>
<RuntimeFieldEditorFlyoutContent
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is up to consumers to implement the global flyout logic, if they want that, right? I'm thinking it could be cool to mention something like that in the README if we haven't already.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So here there is no GlobalFlyout as it makes use of core.overlays.openFlyout. If we think it is good to use GlobalFlyout because it adds additional functionality (like drill down to other content) then we can update the README.md with some examples.

onSave={onSaveField}
onCancel={() => overlayRef?.close()}
docLinks={docLinks}
defaultValue={defaultValue}
/>
</KibanaReactContextProvider>
)
);

return closeEditor;
};

return {
openEditor,
};
};
82 changes: 82 additions & 0 deletions x-pack/plugins/runtime_fields/public/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { CoreSetup } from 'src/core/public';
import { coreMock } from 'src/core/public/mocks';

jest.mock('../../../../src/plugins/kibana_react/public', () => {
const original = jest.requireActual('../../../../src/plugins/kibana_react/public');

return {
...original,
toMountPoint: (node: React.ReactNode) => node,
};
});

import { StartPlugins, PluginStart } from './types';
import { RuntimeFieldEditorFlyoutContent } from './components';
import { RuntimeFieldsPlugin } from './plugin';

const noop = () => {};

describe('RuntimeFieldsPlugin', () => {
let coreSetup: CoreSetup<StartPlugins, PluginStart>;
let plugin: RuntimeFieldsPlugin;

beforeEach(() => {
plugin = new RuntimeFieldsPlugin();
coreSetup = coreMock.createSetup();
});

test('should return a handler to load the runtime field editor', async () => {
const setupApi = await plugin.setup(coreSetup, {});
expect(setupApi.loadEditor).toBeDefined();
});

test('once it is loaded it should expose a handler to open the editor', async () => {
const setupApi = await plugin.setup(coreSetup, {});
const response = await setupApi.loadEditor();
expect(response.openEditor).toBeDefined();
});

test('should call core.overlays.openFlyout when opening the editor', async () => {
const openFlyout = jest.fn();
const onSaveSpy = jest.fn();

const mockCore = {
overlays: {
openFlyout,
},
uiSettings: {},
};
coreSetup.getStartServices = async () => [mockCore] as any;
const setupApi = await plugin.setup(coreSetup, {});
const { openEditor } = await setupApi.loadEditor();

openEditor({ onSave: onSaveSpy });

expect(openFlyout).toHaveBeenCalled();

const [[arg]] = openFlyout.mock.calls;
expect(arg.props.children.type).toBe(RuntimeFieldEditorFlyoutContent);

// We force call the "onSave" prop from the <RuntimeFieldEditorFlyoutContent /> component
// and make sure that the the spy is being called.
// Note: we are testing implementation details, if we change or rename the "onSave" prop on
// the component, we will need to update this test accordingly.
expect(arg.props.children.props.onSave).toBeDefined();
arg.props.children.props.onSave();
expect(onSaveSpy).toHaveBeenCalled();
});

test('should return a handler to close the flyout', async () => {
const setupApi = await plugin.setup(coreSetup, {});
const { openEditor } = await setupApi.loadEditor();

const closeEditorHandler = openEditor({ onSave: noop });
expect(typeof closeEditorHandler).toBe('function');
});
});
5 changes: 4 additions & 1 deletion x-pack/plugins/runtime_fields/public/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@
import { Plugin, CoreSetup, CoreStart } from 'src/core/public';

import { PluginSetup, PluginStart, SetupPlugins, StartPlugins } from './types';
import { getRuntimeFieldEditorLoader } from './load_editor';

export class RuntimeFieldsPlugin
implements Plugin<PluginSetup, PluginStart, SetupPlugins, StartPlugins> {
public setup(core: CoreSetup<StartPlugins, PluginStart>, plugins: SetupPlugins): PluginSetup {
return {};
return {
loadEditor: getRuntimeFieldEditorLoader(core),
};
}

public start(core: CoreStart, plugins: StartPlugins) {
Expand Down
6 changes: 5 additions & 1 deletion x-pack/plugins/runtime_fields/public/shared_imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,8 @@ export { fieldValidators } from '../../../../src/plugins/es_ui_shared/static/for

export { TextField } from '../../../../src/plugins/es_ui_shared/static/forms/components';

export { CodeEditor } from '../../../../src/plugins/kibana_react/public';
export {
CodeEditor,
toMountPoint,
createKibanaReactContext,
} from '../../../../src/plugins/kibana_react/public';
10 changes: 8 additions & 2 deletions x-pack/plugins/runtime_fields/public/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@
import { DataPublicPluginStart } from 'src/plugins/data/public';

import { RUNTIME_FIELD_TYPES } from './constants';
import { OpenRuntimeFieldEditorProps } from './load_editor';

// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface PluginSetup {}
export interface LoadEditorResponse {
openEditor(props: OpenRuntimeFieldEditorProps): () => void;
}

export interface PluginSetup {
loadEditor(): Promise<LoadEditorResponse>;
}

// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface PluginStart {}
Expand Down