From cb341b4a1c29d3bbc7075eb5af7de7eeed422316 Mon Sep 17 00:00:00 2001 From: Michael Dokolin Date: Fri, 29 Jul 2022 14:33:24 +0200 Subject: [PATCH 01/13] [Embeddables] Update documentation (#137302) * Add a developer documentation page to cover embeddables concept * Update generic embeddable documentation --- dev_docs/key_concepts/building_blocks.mdx | 2 +- dev_docs/key_concepts/embeddables.mdx | 51 +++ docs/developer/plugin-list.asciidoc | 5 +- src/plugins/embeddable/README.asciidoc | 43 -- src/plugins/embeddable/README.md | 374 ++++++++++++++++++ src/plugins/embeddable/docs/README.md | 6 - .../docs/containers_and_inherited_state.md | 33 -- .../embeddable/docs/input_and_output_state.md | 282 ------------- 8 files changed, 428 insertions(+), 368 deletions(-) create mode 100644 dev_docs/key_concepts/embeddables.mdx delete mode 100644 src/plugins/embeddable/README.asciidoc create mode 100644 src/plugins/embeddable/README.md delete mode 100644 src/plugins/embeddable/docs/README.md delete mode 100644 src/plugins/embeddable/docs/containers_and_inherited_state.md delete mode 100644 src/plugins/embeddable/docs/input_and_output_state.md diff --git a/dev_docs/key_concepts/building_blocks.mdx b/dev_docs/key_concepts/building_blocks.mdx index 787fc59c3bba3..aeb2d2be7d6c9 100644 --- a/dev_docs/key_concepts/building_blocks.mdx +++ b/dev_docs/key_concepts/building_blocks.mdx @@ -142,7 +142,7 @@ application could register a UI Action called "View in Maps" to appear any time ## Embeddables -Embeddables help you integrate your application with the Dashboard application. Register your custom UI Widget as an Embeddable and users will + help you integrate your application with the Dashboard application. Register your custom UI Widget as an Embeddable and users will be able to add it as a panel on a Dashboard. With a little extra work, it can also be exposed in Canvas workpads. **Github labels**: `Team:AppServices`, `Feature:Embeddables` diff --git a/dev_docs/key_concepts/embeddables.mdx b/dev_docs/key_concepts/embeddables.mdx new file mode 100644 index 0000000000000..2d5e14b7d4669 --- /dev/null +++ b/dev_docs/key_concepts/embeddables.mdx @@ -0,0 +1,51 @@ +--- +id: kibDevDocsEmbeddables +slug: /kibana-dev-docs/key-concepts/embeddables +title: Embeddables +summary: Embeddables provide a way to expose a reusable widget. +date: 2022-07-27 +tags: ['kibana', 'dev', 'contributor', 'api docs'] +--- + +The Embeddables Plugin provides an opportunity to expose reusable interactive widgets that can be embedded outside the original plugin. + +If you are planning to integrate with the plugin, please get in touch with the App Services team to get to know all the limitations. + +## Capabilities +- Framework-agnostic API. +- Out-of-the-box React support. +- Integration with the [UI Actions](https://github.com/elastic/kibana/tree/HEAD/src/plugins/ui_actions) plugin. +- Hierarchical structure to enclose multiple widgets. +- Error handling. + +## Key Concepts +### Embeddable +Embeddable is a re-usable widget that can be rendered on a dashboard as well as in other applications. +Developers are free to embed them directly in their plugins. +End users can dynamically select an embeddable to add to a _container_. +Dashboard container powers the grid of panels on the Dashboard app. + +### Container +Container is a special type of embeddable that can hold other embeddable items. +Embeddables can be added dynamically to the containers, but that should be implemented on the end plugin side. +Currently, the dashboard plugin provides such functionality. + +### Input +Every embeddable has an input which is a serializable set of data. +This data can be used to update the state of the embeddable widget. +The input can be updated later so that the embeddable should be capable of reacting to those changes. + +### Output +Every embeddable may expose some data to the external interface. +Usually, it is diverged from the input and not necessarily serializable. +Output data can also be updated, but that should always be done inside the embeddable. + +## Documentation + + Check the plugin documentation [here](https://github.com/elastic/kibana/tree/HEAD/src/plugins/embeddable) to get to know all the capabilities better. + + +## API + + Check the complete API reference . + diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index d9b2c970b6302..67626e0580390 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -86,8 +86,8 @@ This API doesn't support angular, for registering angular dev tools, bootstrap a |Contains the Discover application and the saved search embeddable. -|<> -|Embeddables are re-usable widgets that can be rendered on dashboard, but also in other apps. Developers can embed them directly in their plugin. End users can dynamically add them to embeddable _containers_. +|{kib-repo}blob/{branch}/src/plugins/embeddable/README.md[embeddable] +|The Embeddables Plugin provides an opportunity to expose reusable interactive widgets that can be embedded outside the original plugin. |{kib-repo}blob/{branch}/src/plugins/es_ui_shared/README.md[esUiShared] @@ -680,7 +680,6 @@ As a developer you can reuse and extend built-in alerts and actions UI functiona |=== include::{kibana-root}/src/plugins/dashboard/README.asciidoc[leveloffset=+1] -include::{kibana-root}/src/plugins/embeddable/README.asciidoc[leveloffset=+1] include::{kibana-root}/src/plugins/expressions/README.asciidoc[leveloffset=+1] include::{kibana-root}/src/plugins/ui_actions/README.asciidoc[leveloffset=+1] include::{kibana-root}/x-pack/plugins/dashboard_enhanced/README.asciidoc[leveloffset=+1] diff --git a/src/plugins/embeddable/README.asciidoc b/src/plugins/embeddable/README.asciidoc deleted file mode 100644 index a5b54fabc7af1..0000000000000 --- a/src/plugins/embeddable/README.asciidoc +++ /dev/null @@ -1,43 +0,0 @@ -[[embeddable-plugin]] -== Embeddables plugin - -Embeddables are re-usable widgets that can be rendered on dashboard, but also in other apps. Developers can embed them directly in their plugin. End users can dynamically add them to embeddable _containers_. - -=== Embeddable containers - -Containers are a special type of embeddable that can contain nested embeddables. Embeddables can be dynamically added to embeddable _containers_. Currently only dashboard uses this interface. - -=== Examples - -Multiple embeddable examples are implemented and registered https://github.com/elastic/kibana/tree/main/examples/embeddable_examples[here]. They can be played around with and explored https://github.com/elastic/kibana/tree/main/examples/embeddable_explorer[in the Embeddable Explorer example plugin]. Just run kibana with - -[source,sh] --- -yarn start --run-examples --- - -and navigate to the Embeddable explorer app. - -There is also an example of rendering dashboard container outside of dashboard app https://github.com/elastic/kibana/tree/main/examples/dashboard_embeddable_examples[here]. - -=== Docs - -link:https://github.com/elastic/kibana/blob/main/src/plugins/embeddable/docs/README.md[Embeddable docs, guides & caveats] - -=== API docs - -==== Browser API -https://github.com/elastic/kibana/blob/main/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablesetup.md[Browser Setup contract] -https://github.com/elastic/kibana/blob/main/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestart.md[Browser Start contract] - -==== Server API -https://github.com/elastic/kibana/blob/main/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.md[Server Setup contract] - -=== Testing - -Run unit tests - -[source,sh] --- -yarn test:jest src/plugins/embeddable --- diff --git a/src/plugins/embeddable/README.md b/src/plugins/embeddable/README.md new file mode 100644 index 0000000000000..fc6632252bb01 --- /dev/null +++ b/src/plugins/embeddable/README.md @@ -0,0 +1,374 @@ +# Embeddables Plugin +The Embeddables Plugin provides an opportunity to expose reusable interactive widgets that can be embedded outside the original plugin. + +## Capabilities +- Framework-agnostic API. +- Out-of-the-box React support. +- Integration with the [UI Actions](https://github.com/elastic/kibana/tree/HEAD/src/plugins/ui_actions) plugin. +- Hierarchical structure to enclose multiple widgets. +- Error handling. + +## Key Concepts +### Embeddable +Embeddable is a re-usable widget that can be rendered on a dashboard as well as in other applications. +Developers are free to embed them directly in their plugins. +End users can dynamically select an embeddable to add to a _container_. +Dashboard container powers the grid of panels on the Dashboard app. + +### Container +Container is a special type of embeddable that can hold other embeddable items. +Embeddables can be added dynamically to the containers, but that should be implemented on the end plugin side. +Currently, the dashboard plugin provides such functionality. + +### Input +Every embeddable has an input which is a serializable set of data. +This data can be used to update the state of the embeddable widget. +The input can be updated later so that the embeddable should be capable of reacting to those changes. + +### Output +Every embeddable may expose some data to the external interface. +Usually, it is diverged from the input and not necessarily serializable. +Output data can also be updated, but that should always be done inside the embeddable. + +## Usage +### Getting Started +After listing the `embeddable` plugin in your dependencies, the plugin will be intitalized on the setup stage. + +The setup contract exposes a handle to register an embeddable factory. +At this point, we can provide all the dependencies needed for the widget via the factory. +```typescript +import { EmbeddableSetup } from '@kbn/embeddable-plugin/public'; +import { HELLO_WORLD } from './hello_world'; +import { HelloWorldFactory } from './hello_world_factory'; + +interface SetupDeps { + embeddable: EmbeddableSetup; +} + +class ExamplePlugin implements Plugin { + setup({}: CoreSetup, { embeddable }: SetupDeps) { + embeddable.registerEmbeddableFactory(HELLO_WORLD, new HelloWorldFactory()); + } + + start() {} +} + +export function plugin() { + return new ExamplePlugin(); +} +``` + +The factory should implement the `EmbeddableFactoryDefinition` interface. +At this stage, we can inject all the dependencies into the embeddable instance. +```typescript +import { + IContainer, + EmbeddableInput, + EmbeddableFactoryDefinition, +} from '@kbn/embeddable-plugin/public'; +import { HelloWorld, HELLO_WORLD } from './hello_world'; + +export class HelloWorldEmbeddableFactoryDefinition implements EmbeddableFactoryDefinition { + readonly type = HELLO_WORLD; + + async isEditable() { + return true; + } + + async create(input: EmbeddableInput, parent?: IContainer) { + return new HelloWorld(input, {}, parent); + } + + getDisplayName() { + return 'Hello World'; + } +} +``` + + +The embeddable should implement the `IEmbeddable` interface, and usually, that just extends the base class `Embeddable`. +```tsx +import React from 'react'; +import { render } from 'react-dom'; +import { Embeddable } from '@kbn/embeddable-plugin/public'; + +export const HELLO_WORLD = 'HELLO_WORLD'; + +export class HelloWorld extends Embeddable { + readonly type = HELLO_WORLD; + + render(node: HTMLElement) { + render(
{this.getTitle()}
, node); + } + + reload() {} +} +``` + +### Life-Cycle Hooks +Every embeddable can implement a specific behavior for the following life-cycle stages. + +#### `render` +This is a mandatory method to implement. +It is used for the initial render of the embeddable. +```tsx +import React from 'react'; +import { render } from 'react-dom'; +import { Embeddable } from '@kbn/embeddable-plugin/public'; + +export class HelloWorld extends Embeddable { + // ... + + render(node: HTMLElement) { + render(
{this.getTitle()}
, node); + } +} +``` + +#### `reload` +This hook is called after every input update to perform some UI changes. +```typescript +import { Embeddable } from '@kbn/embeddable-plugin/public'; + +export class HelloWorld extends Embeddable { + // ... + + private node?: HTMLElement; + + render(node: HTMLElement) { + this.node = node; + + // ... + } + + reload() { + if (this.node) { + this.render(this.node); + } + } +} +``` + +#### `renderError` +This is an optional error handler to provide a custom UI for the error state. + +The embeddable may change its state in the future so that the error should be able to disappear. +In that case, the method should return a callback performing cleanup actions for the error UI. + +If there is no implementation provided for the `renderError` hook, the embeddable will render a fallback error UI. + +In case of an error, the embeddable UI will not be destroyed or unmounted. +The default behavior is to hide that visually and show the error message on top of that. + +```typescript +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { Embeddable } from '@kbn/embeddable-plugin/public'; + +export class HelloWorld extends Embeddable { + // ... + + renderError(node: HTMLElement, error: Error) { + render(
Something went wrong: {error.message}
, node); + + return () => unmountComponentAtNode(node); + } +} +``` + +#### `destroy` +This hook is invoked when the embeddable is destroyed and should perform cleanup actions. +```typescript +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { Embeddable } from '@kbn/embeddable-plugin/public'; + +export class HelloWorld extends Embeddable { + // ... + + render(node: HTMLElement) { + this.node = node; + + // ... + } + + destroy() { + if (this.node) { + unmountComponentAtNode(this.node); + } + } +} +``` + +### Input State +The input state can be updated throughout the lifecycle of an embeddable. That can be done via `updateInput` method call. +```typescript +import { Embeddable } from '@kbn/embeddable-plugin/public'; + +export class HelloWorld extends Embeddable { + setTitle(title: string) { + this.updateInput({ title }); + } +} +``` + +The input should always be updated partially. Otherwise, it may break the inheritance of the container's input as all the values passed in the changes object have precedence over the values set on the parent container. + +For example, the time range on a dashboard is _inherited_ by all children _unless_ they explicitly override their time range. +This is the way the per panel time range works. In that case, there is a call `item.updateInput({ timeRange })` that detaches the time range from the container. + +### Containers +The plugin provides a way to organize a collection of embeddable widgets inside containers. +In this case, the container holds the state of all the children and manages all the input state updates. +```typescript +import { Container } from '@kbn/embeddable-plugin/public'; + +export class HelloWorldContainer extends Container { + protected getInhertedInput() { + return { + timeRange: this.input.timeRange, + viewMode: this.input.viewMode, + }; + } +} +``` + +_Note 1:_ The `getInhertedInput` may also return values not from the input state, but it is an uncommon case. + +_Note 2:_ Keep in mind that this input state will be passed down to all the children, which can be redundant in most cases. +It is better to return only necessary generic information that all children will likely consume. + +### Inheritance +In the example above, all the container children will share the `timeRange` and `viewMode` properties. +If the container has other properties in the input state, they will not be shared with the children. +From the embeddable point, that works transparently, and there is no difference whether the embeddable is placed inside a container or not. + +Let's take, for example, a container with the following input: +```typescript +{ + gridData: { /* ... */ }, + timeRange: 'now-15m to now', + + // Every embeddable container has a panels mapping. + // It's how the base container class manages common changes like children being added, removed or edited. + panels: { + '1': { + // The `type` is used to grab the right embeddable factory. + // Every PanelState must specify one. + type: 'clock', + + // The `explicitInput` is combined with the `inheritedInput`. + explicitInput: { + + // The `explicitInput` requires to have an `id`. + // This is needed for the embeddable to know where it stays in the panels array if it's inside a container. + // This is not a saved object id even though it can be the same sometimes. + id: '1', + } + } + } +} +``` + +That could result in the following input being passed to a child embeddable: +```typescript +{ + timeRange: 'now-15m to now', + id: '1', +} +``` + +#### Input Overriding +There is a way of _overriding_ this inherited state. +For example, the _inherited_ `timeRange` input can be overridden by the _explicit_ `timeRange` input. + +Let's take this example dashboard container input: +```javascript +{ + gridData: { /* ... */ }, + timeRange: 'now-15m to now', + panels: { + ['1']: { + type: 'clock', + explicitInput: { + timeRange: 'now-30m to now', + id: '1', + } + }, + ['2']: { + type: 'clock', + explicitInput: { + id: '2', + } + }, +} +``` + +The first child embeddable will get the following state: +```javascript +{ + timeRange: 'now-30m to now', + id: '1', +} +``` + +This override wouldn't affect other children, so the second child would get: +```javascript +{ + timeRange: 'now-15m to now', + id: '2', +} +``` + +#### Embeddable Id +The `id` parameter in the input is marked as required even though it is only used when the embeddable is inside a container. +That is done to guarantee consistency. + +This has nothing to do with a saved object id, even though in the dashboard app, the saved object happens to be the same. + +#### Accessing Container +The parent container can be retrieved via either `embeddabble.parent` or `embeddable.getRoot()`. +The `getRoot` variety will walk up to find the root parent. + +We can use those to get an explicit input from the child embeddable: +```typescript + return parent.getInput().panels[embeddable.id].explicitInput; +``` + +#### Encapsulated Explicit Input +It is possible for a container to store an explicit input state on the embeddable side. It would be encapsulated from a container in this case. + +This can ne achieved in two ways by implementing one of the following: +- `EmbeddableFactory.getExplicitInput` was intended as a way for an embeddable to retrieve input state it needs that is not provided by a container. +- `EmbeddableFactory.getDefaultInput` will provide default values, only if the container did not supply them through inheritance. +Explicit input will always provide these values, and will always be stored in a containers `panel[id].explicitInput`, even if the container _did_ provide it. + +### React +The plugin provides a set of ready-to-use React components that abstract rendering of an embeddable behind a React component: + +- `EmbeddablePanel` provides a way to render an embeddable inside a rectangular panel. This also provides error handling and a basic user interface over some of the embeddable properties. +- `EmbeddableChildPanel` is a higher-order component for the `EmbeddablePanel` that provides a way to render that inside a container. +- `EmbeddableRoot` is the most straightforward wrapper performing rendering of an embeddable. +- `EmbeddableRenderer` is a helper component to render an embeddable or an embeddable factory. + +Apart from the React components, there is also a way to construct an embeddable object using `useEmbeddableFactory` hook. +This React hook takes care of producing an embeddable and updating its input state if passed state changes. + +## API +Please use automatically generated API reference or generated TypeDoc comments to find the complete documentation. + +## Examples +- Multiple embeddable examples are implemented and registered [here](https://github.com/elastic/kibana/tree/HEAD/examples/embeddable_examples). +- They can be played around with and explored in the [Embeddable Explorer](https://github.com/elastic/kibana/tree/HEAD/examples/embeddable_explorer) example plugin. +- There is an [example](https://github.com/elastic/kibana/tree/HEAD/examples/dashboard_embeddable_examples) of rendering a dashboard container outside the dashboard app. +- There are storybook [stories](https://github.com/elastic/kibana/tree/HEAD/src/plugins/embeddable/public/__stories__) that demonstrate usage of the embeddable components. + +To run the examples plugin use the following command: +```bash +yarn start --run-examples +``` + +To run the storybook: +```bash +yarn storybook embeddable +``` diff --git a/src/plugins/embeddable/docs/README.md b/src/plugins/embeddable/docs/README.md deleted file mode 100644 index ce5e76d54a046..0000000000000 --- a/src/plugins/embeddable/docs/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# Embeddable Docs, Guides & Caveats - -## Reference - -- [Input and output state](./input_and_output_state.md) -- [Common mistakes with embeddable containers and inherited input state](./containers_and_inherited_state.md) diff --git a/src/plugins/embeddable/docs/containers_and_inherited_state.md b/src/plugins/embeddable/docs/containers_and_inherited_state.md deleted file mode 100644 index 35e399f89c131..0000000000000 --- a/src/plugins/embeddable/docs/containers_and_inherited_state.md +++ /dev/null @@ -1,33 +0,0 @@ -## Common mistakes with embeddable containers and inherited input state - -`updateInput` is typed as `updateInput(input: Partial)`. Notice it's _partial_. This is to support the use case of inherited state when an embeddable is inside a container. - -If you are simply rendering an embeddable, it's no problem to do something like: - -```ts -// Notice this isn't a partial so it'll be the entire state. -const input: EmbeddableInput = this.state.input -embeddable.updateInput(input); -``` - -However when you are dealing with _containers_, you want to be sure to **only pass into `updateInput` the actual state that changed**. This is because calling `child.updateInput({ foo })` will make `foo` _explicit_ state. It cannot be inherited from it's parent. - -For example, on a dashboard, the time range is _inherited_ by all children, _unless_ they had their time range set explicitly. This is how "per panel time range" works. That action calls `embeddable.updateInput({ timeRange })`, and the time range will no longer be inherited from the container. - -### Why is this important? - -A common mistake is always passing in the full state. If you do this, all of a sudden you will lose the inheritance of the container state. - -**Don't do** - -```ts -// Doing this will make it so this embeddable inherits _nothing_ from its container. No more time range updates -// when the user updates the dashboard time range! -embeddable.updateInput({ ...embeddable.getInput(), foo: 'bar' }); -``` - -**Do** - -```ts -embeddable.updateInput({ foo: 'bar' }); -``` diff --git a/src/plugins/embeddable/docs/input_and_output_state.md b/src/plugins/embeddable/docs/input_and_output_state.md deleted file mode 100644 index 810dc72664f96..0000000000000 --- a/src/plugins/embeddable/docs/input_and_output_state.md +++ /dev/null @@ -1,282 +0,0 @@ -## Input and output state - -### What's the difference? - -Input vs Output State - -| Input | Output | -| ----------- | ----------- | -| Public, on the IEmbeddable interface. `embeddable.updateInput(changedInput)` | Protected inside the Embeddable class. `this.updateOutput(changedOutput)` | -| Serializable representation of the embeddable | Does not need to be serializable | -| Can be updated throughout the lifecycle of an Embeddable | Often derived from input state | - -Non-real examples to showcase the difference: - -| Input | Output | -| ----------- | ----------- | -| savedObjectId | savedObjectAttributes | -| esQueryRequest | esQueryResponse | -| props | renderComplete | - -### Types of input state - -#### Inherited input state - -The only reason we have different types of input state is to support embeddable containers, and children embeddables _inheriting_ state from the container. -For example, when the dashboard time range changes, so does -the time range of all children embeddables. Dashboard passes down time range as _inherited_ input state. From the viewpoint of the child Embeddable, -time range is just input state. It doesn't care where it gets this data from. - - -For example, imagine a container with this input: - -```js -{ - gridData: {...}, - timeRange: 'now-15m to now', - - // Every embeddable container has a panels mapping. It's how the base container class manages common changes like children being - // added, removed or edited. - panels: { - ['1']: { - // `type` is used to grab the right embeddable factory. Every PanelState must specify one. - type: 'clock', - - // `explicitInput` is combined with `inheritedInput` to create `childInput`, and is use like: - // `embeddableFactories.get(type).create(childInput)`. - explicitInput: { - - // All explicitInput is required to have an id. This is used as a way for the - // embeddable to know where it exists in the panels array if it's living in a container. - // Note, this is NOT THE SAVED OBJECT ID! Even though it's sometimes used to store the saved object id. - id: '1', - } - } - } -} -``` - -That could result in the following input being passed to a child: - -```js -{ - timeRange: 'now-15m to now', - id: '1', -} -``` - -Notice that `gridData` is not passed down, but `timeRange` is. What ends up as _inherited_ state, that is passed down to a child, is up to the specific -implementation of a container and -determined by the abstract function `Container.getInheritedInput()` - -#### Overridding inherited input - -We wanted to support _overriding_ this inherited state, to support the "Per panel time range" feature. The _inherited_ `timeRange` input can be -overridden by the _explicit_ `timeRange` input. - -Take this example dashboard container input: - -```js -{ - gridData: {...}, - timeRange: 'now-15m to now', - panels: { - ['1']: { - type: 'clock', - explicitInput: { - timeRange: 'now-30m to now', - id: '1', - } - }, - ['2']: { - type: 'clock', - explicitInput: { - id: '2', - } - }, -} -``` - -The first child embeddable will get passed input state: - -```js -{ - timeRange: 'now-30m to now', - id: '1', -} -``` - -This override wouldn't affect other children, so the second child would receive: - -```js -{ - timeRange: 'now-15m to now', - id: '2', -} -``` - -#### EmbeddableInput.id and some technical debt - -Above I said: - -> From the viewpoint of the child Embeddable, -> time range is just input state. It doesn't care where it gets this data from. - -and this is mostly true, however, the primary reason EmbeddableInput.id exists is to support the -case where the custom time range badge action needs to look up a child's explicit input on the -parent. It does this to determine whether or not to show the badge. The logic is something like: - -```ts - // If there is no explicit input defined on the parent then this embeddable inherits the - // time range from whatever the time range of the parent is. - return parent.getInput().panels[embeddable.id].explicitInput.timeRange === undefined; -``` - -It doesn't just compare the timeRange input on the parent (`embeddable.parent?.getInput().timeRange` )because even if they happen to match, -we still want the badge showing to indicate the time range is "locked" on this particular panel. - -Note that `parent` can be retrieved from either `embeddabble.parent` or `embeddable.getRoot()`. The -`getRoot` variety will walk up to find the root parent, even though we have no tested or used -nested containers, it is theoretically possible. - -This EmbeddableInput.id parameter is marked as required on the `EmbeddableInput` interface, even though it's only used -when an embeddable is inside a parent. There is also no -typescript safety to ensure the id matches the panel id in the parents json: - -```js - ['2']: { - type: 'clock', - explicitInput: { - id: '3', // No! Should be 2! - } - }, -``` - -It should probably be something that the parent passes down to the child specifically, based on the panel mapping key, -and renamed to something like `panelKeyInParent`. - -Note that this has nothing to do with a saved object id, even though in dashboard app, the saved object happens to be -used as the dashboard container id. Another reason this should probably not be required for embeddables not -inside containers. - -#### A container can pass down any information to the children - -It doesn't have to be part of it's own input. It's possible for a container input like: - - -```js -{ - timeRange: 'now-15m to now', - panels: { - ['1']: { - type: 'clock', - explicitInput: { - timeRange: 'now-30m to now', - id: '1', - } - } -} -``` - -to pass down this input: - -```js -{ - timeRange: 'now-30m to now', - id: '1', - zed: 'bar', // <-- Where did this come from?? -} -``` - -I don't have a realistic use case for this, just noting it's possible in any containers implementation of `getInheritedInput`. Note this is still considered -inherited input because it's coming from the container. - -#### Explicit input stored on behalf of the container - -It's possible for a container to store explicit input state on behalf of an embeddable, without knowing what that state is. For example, a container could -have input state like: - -```js -{ - timeRange: 'now-15m to now', - panels: { - ['1']: { - type: 'clock', - explicitInput: { - display: 'analog', - id: '1', - } - } -} -``` - -And what gets passed to the child is: - -```js -{ - timeRange: 'now-15m to now', - id: '1', - display: 'analog' -} -``` - -even if a container has no idea about this `clock` embeddable implementation, nor this `explicitInput.display` field. - -There are two ways for this kind of state to end up in `panels[id].explicitInput`. - -1. `ClockEmbeddableFactory.getExplicitInput` returns it. -2. `ClockEmbeddableFactory.getDefaultInput` returns it. (This function is largely unused. We may be able to get rid of it.) -3. Someone called `embeddable.updateInput({ display: 'analog' })`, when the embeddable is a child in a container. - -#### Containers can pass down too much information - -Lets say our container state is: - -```js -{ - timeRange: 'now-15m to now', - panels: { - ['1']: { - type: 'helloWorld', - explicitInput: { - id: '1', - } - } -} -``` - -What gets passed to the child is: - -```js -{ - timeRange: 'now-15m to now', - id: '1', -} -``` - -It doesn't matter if the embeddable does not require, nor use, `timeRange`. The container passes down inherited input state to every child. -This could present problems with trying to figure out which embeddables support -different types of actions. For example, it'd be great if "Customize time range" action only showed up on embeddables that actually did something -with the `timeRange`. You can't check at runtime whether `input.timeRange === undefined` to do so though, because it will be passed in by the container -regardless. - - -#### Tech debt warnings - -`EmbeddableFactory.getExplicitInput` was intended as a way for an embeddable to retrieve input state it needs, that will not -be provided by a container. However, an embeddable won't know where it will be rendered, so how will the factory know which -required data to ask from the user and which will be inherited from the container? I believe `getDefaultInput` was meant to solve this. -`getDefaultInput` would provide default values, only if the container didn't supply them through inheritance. Explicit input would -always provide these values, and would always be stored in a containers `panel[id].explicitInput`, even if the container _did_ provide -them. - -There are no real life examples showcasing this, it may not even be really needed by current use cases. Containers were built as an abstraction, with -the thinking being that it would support any type of rendering of child embeddables - whether in a "snap to grid" style like dashboard, -or in a free form layout like canvas. - -The only real implementation of a container in production code at the time this is written is Dashboard however, with no plans to migrate -Canvas over to use it (this was the original impetus for an abstraction). The container code is quite complicated with child management, -so it makes creating a new container very easy, as you can see in the developer examples of containers. But, it's possible this layer was - an over abstraction without a real prod use case (I can say that because I wrote it, I'm only insulting myself!) :). - -Be sure to read [Common mistakes with embeddable containers and inherited input state](./containers_and_inherited_state.md) next! \ No newline at end of file From ec15a9ce53e2f386be51fdc4b58d974f7f6880f2 Mon Sep 17 00:00:00 2001 From: Mark Hopkin Date: Fri, 29 Jul 2022 13:59:49 +0100 Subject: [PATCH 02/13] [fleet] Do not create agent policy twice when there is a verification error (#137572) * Do not create agent policy twice when there is a verification error * re-add setPolicyValidation call * neaten diff --- .../single_page_layout/index.tsx | 74 +++++++++---------- 1 file changed, 36 insertions(+), 38 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx index 7bea61da2125c..d57f246ecbb4d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx @@ -228,16 +228,15 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ [setNewAgentPolicy, newAgentPolicy, selectedPolicyTab] ); - const updateSelectedPolicy = useCallback( - (policy) => { - setSelectedPolicyTab(policy); - setPolicyValidation(policy, newAgentPolicy); + const updateSelectedPolicyTab = useCallback( + (selectedTab) => { + setSelectedPolicyTab(selectedTab); + setPolicyValidation(selectedTab, newAgentPolicy); }, [setSelectedPolicyTab, newAgentPolicy] ); const hasErrors = validationResults ? validationHasErrors(validationResults) : false; - // Update package policy validation const updatePackagePolicyValidation = useCallback( (newPackagePolicy?: NewPackagePolicy) => { @@ -313,32 +312,31 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ [agentCount] ); - const createAgentPolicy = useCallback( - async ({ force }: { force?: boolean } = {}): Promise => { - let policyId; - setFormState('LOADING'); - // do not create agent policy with system integration if package policy already is for system package - const packagePolicyIsSystem = packagePolicy?.package?.name === FLEET_SYSTEM_PACKAGE; - const resp = await sendCreateAgentPolicy(newAgentPolicy, { - withSysMonitoring: withSysMonitoring && !packagePolicyIsSystem, - }); - if (resp.error) { - setFormState('VALID'); - throw resp.error; - } - if (resp.data) { - policyId = resp.data.item.id; - setAgentPolicy(resp.data.item); - setSelectedPolicyTab(SelectedPolicyTab.EXISTING); - updatePackagePolicy({ policy_id: policyId }); - } - return policyId; - }, - [newAgentPolicy, updatePackagePolicy, withSysMonitoring, packagePolicy] - ); + const createAgentPolicy = useCallback(async (): Promise => { + let createdAgentPolicy; + setFormState('LOADING'); + // do not create agent policy with system integration if package policy already is for system package + const packagePolicyIsSystem = packagePolicy?.package?.name === FLEET_SYSTEM_PACKAGE; + const resp = await sendCreateAgentPolicy(newAgentPolicy, { + withSysMonitoring: withSysMonitoring && !packagePolicyIsSystem, + }); + if (resp.error) { + setFormState('VALID'); + throw resp.error; + } + if (resp.data) { + createdAgentPolicy = resp.data.item; + setAgentPolicy(createdAgentPolicy); + updatePackagePolicy({ policy_id: createdAgentPolicy.id }); + } + return createdAgentPolicy; + }, [packagePolicy?.package?.name, newAgentPolicy, withSysMonitoring, updatePackagePolicy]); const onSubmit = useCallback( - async ({ force }: { force?: boolean } = {}) => { + async ({ + force, + overrideCreatedAgentPolicy, + }: { overrideCreatedAgentPolicy?: AgentPolicy; force?: boolean } = {}) => { if (formState === 'VALID' && hasErrors) { setFormState('INVALID'); return; @@ -347,10 +345,10 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ setFormState('CONFIRM'); return; } - let policyId; - if (selectedPolicyTab === SelectedPolicyTab.NEW) { + let createdPolicy = overrideCreatedAgentPolicy; + if (selectedPolicyTab === SelectedPolicyTab.NEW && !overrideCreatedAgentPolicy) { try { - policyId = await createAgentPolicy({ force }); + createdPolicy = await createAgentPolicy(); } catch (e) { notifications.toasts.addError(e, { title: i18n.translate('xpack.fleet.createAgentPolicy.errorNotificationTitle', { @@ -365,7 +363,7 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ // passing pkgPolicy with policy_id here as setPackagePolicy doesn't propagate immediately const { error, data } = await savePackagePolicy({ ...packagePolicy, - policy_id: policyId ?? packagePolicy.policy_id, + policy_id: createdPolicy?.id ?? packagePolicy.policy_id, force, }); if (!error) { @@ -397,12 +395,12 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ }); } else { if (isVerificationError(error)) { + setFormState('VALID'); // don't show the add agent modal const forceInstall = await confirmForceInstall(packagePolicy.package!); if (forceInstall) { - onSubmit({ force: true }); - } else { - setFormState('VALID'); + // skip creating the agent policy because it will have already been successfully created + onSubmit({ overrideCreatedAgentPolicy: createdPolicy, force: true }); } return; } @@ -461,7 +459,7 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ validation={validation} packageInfo={packageInfo} setHasAgentPolicyError={setHasAgentPolicyError} - updateSelectedTab={updateSelectedPolicy} + updateSelectedTab={updateSelectedPolicyTab} selectedAgentPolicyId={queryParamsPolicyId} /> ), @@ -473,7 +471,7 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ updateNewAgentPolicy, validation, withSysMonitoring, - updateSelectedPolicy, + updateSelectedPolicyTab, queryParamsPolicyId, ] ); From 8bd2c273f81d64bedfffa55ed40ec5925aab1cca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20G=C3=BCrkan=20YALAMAN?= Date: Fri, 29 Jul 2022 15:30:10 +0200 Subject: [PATCH 03/13] [Enterprise Search] Fix cloudId decoding on index detail (#137478) * Fix cloudId decoding on index detail * Add filepaths Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../search_index/generate_api_key_panel.tsx | 18 ++- .../utils/decode_cloud_id.test.ts | 104 ++++++++++++++++++ .../utils/decode_cloud_id.ts | 66 +++++++++++ 3 files changed, 177 insertions(+), 11 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/utils/decode_cloud_id.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/utils/decode_cloud_id.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/generate_api_key_panel.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/generate_api_key_panel.tsx index be81b8a12131e..3be5632fbbefe 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/generate_api_key_panel.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/generate_api_key_panel.tsx @@ -21,6 +21,8 @@ import { import { i18n } from '@kbn/i18n'; import { useCloudDetails } from '../../../shared/cloud_details/cloud_details'; +import { decodeCloudId } from '../../utils/decode_cloud_id'; + import { DOCUMENTS_API_JSON_EXAMPLE } from '../new_index/constants'; import { ClientLibrariesPopover } from './components/client_libraries_popover/popover'; @@ -29,21 +31,15 @@ import { ManageKeysPopover } from './components/manage_api_keys_popover/popover' import { OverviewLogic } from './overview.logic'; -const getDeploymentUrls = (cloudId: string) => { - const [host, kibanaHost, elasticHost] = window.atob(cloudId); - return { - elasticUrl: `https://${elasticHost}.${host}`, - kibanaUrl: `https://${kibanaHost}.${host}`, - }; -}; export const GenerateApiKeyPanel: React.FC = () => { const { apiKey, isGenerateModalOpen, indexData } = useValues(OverviewLogic); const { closeGenerateModal } = useActions(OverviewLogic); const cloudContext = useCloudDetails(); - const searchIndexApiUrl = cloudContext.cloudId - ? getDeploymentUrls(cloudContext.cloudId).elasticUrl - : ':/'; + + const DEFAULT_URL = ':'; + const searchIndexApiUrl = + (cloudContext.cloudId && decodeCloudId(cloudContext.cloudId)?.elasticsearchUrl) || DEFAULT_URL; const apiKeyExample = apiKey || ''; @@ -88,7 +84,7 @@ export const GenerateApiKeyPanel: React.FC = () => { {`\ -curl -X POST '${searchIndexApiUrl}${indexData?.name}/_doc' \\ +curl -X POST '${searchIndexApiUrl}/${indexData?.name}/_doc' \\ -H 'Content-Type: application/json' \\ -H 'Authorization: ApiKey ${apiKeyExample}' \\ -d '${JSON.stringify(DOCUMENTS_API_JSON_EXAMPLE, null, 2)}' diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/utils/decode_cloud_id.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/utils/decode_cloud_id.test.ts new file mode 100644 index 0000000000000..2886d5f5b9141 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/utils/decode_cloud_id.test.ts @@ -0,0 +1,104 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { decodeCloudId } from './decode_cloud_id'; + +// Copied from Fleet's solution +// x-pack/fleet/common/services/decode_cloud_id.test.ts +describe('Enterprise Search - decodeCloudId', () => { + it('parses various CloudID formats', () => { + const tests = [ + { + cloudID: + 'staging:dXMtZWFzdC0xLmF3cy5mb3VuZC5pbyRjZWM2ZjI2MWE3NGJmMjRjZTMzYmI4ODExYjg0Mjk0ZiRjNmMyY2E2ZDA0MjI0OWFmMGNjN2Q3YTllOTYyNTc0Mw==', + expectedEsURL: 'https://cec6f261a74bf24ce33bb8811b84294f.us-east-1.aws.found.io:443', + expectedKibanaURL: 'https://c6c2ca6d042249af0cc7d7a9e9625743.us-east-1.aws.found.io:443', + }, + { + cloudID: + 'dXMtZWFzdC0xLmF3cy5mb3VuZC5pbyRjZWM2ZjI2MWE3NGJmMjRjZTMzYmI4ODExYjg0Mjk0ZiRjNmMyY2E2ZDA0MjI0OWFmMGNjN2Q3YTllOTYyNTc0Mw==', + expectedEsURL: 'https://cec6f261a74bf24ce33bb8811b84294f.us-east-1.aws.found.io:443', + expectedKibanaURL: 'https://c6c2ca6d042249af0cc7d7a9e9625743.us-east-1.aws.found.io:443', + }, + { + cloudID: + ':dXMtZWFzdC0xLmF3cy5mb3VuZC5pbyRjZWM2ZjI2MWE3NGJmMjRjZTMzYmI4ODExYjg0Mjk0ZiRjNmMyY2E2ZDA0MjI0OWFmMGNjN2Q3YTllOTYyNTc0Mw==', + expectedEsURL: 'https://cec6f261a74bf24ce33bb8811b84294f.us-east-1.aws.found.io:443', + expectedKibanaURL: 'https://c6c2ca6d042249af0cc7d7a9e9625743.us-east-1.aws.found.io:443', + }, + { + cloudID: + 'gcp-cluster:dXMtY2VudHJhbDEuZ2NwLmNsb3VkLmVzLmlvJDhhMDI4M2FmMDQxZjE5NWY3NzI5YmMwNGM2NmEwZmNlJDBjZDVjZDU2OGVlYmU1M2M4OWViN2NhZTViYWM4YjM3', + expectedEsURL: 'https://8a0283af041f195f7729bc04c66a0fce.us-central1.gcp.cloud.es.io:443', + expectedKibanaURL: + 'https://0cd5cd568eebe53c89eb7cae5bac8b37.us-central1.gcp.cloud.es.io:443', + }, + { + cloudID: + 'custom-port:dXMtY2VudHJhbDEuZ2NwLmNsb3VkLmVzLmlvOjkyNDMkYWMzMWViYjkwMjQxNzczMTU3MDQzYzM0ZmQyNmZkNDYkYTRjMDYyMzBlNDhjOGZjZTdiZTg4YTA3NGEzYmIzZTA=', + expectedEsURL: 'https://ac31ebb90241773157043c34fd26fd46.us-central1.gcp.cloud.es.io:9243', + expectedKibanaURL: + 'https://a4c06230e48c8fce7be88a074a3bb3e0.us-central1.gcp.cloud.es.io:9243', + }, + { + cloudID: + 'different-es-kb-port:dXMtY2VudHJhbDEuZ2NwLmNsb3VkLmVzLmlvJGFjMzFlYmI5MDI0MTc3MzE1NzA0M2MzNGZkMjZmZDQ2OjkyNDMkYTRjMDYyMzBlNDhjOGZjZTdiZTg4YTA3NGEzYmIzZTA6OTI0NA==', + expectedEsURL: 'https://ac31ebb90241773157043c34fd26fd46.us-central1.gcp.cloud.es.io:9243', + expectedKibanaURL: + 'https://a4c06230e48c8fce7be88a074a3bb3e0.us-central1.gcp.cloud.es.io:9244', + }, + { + cloudID: + 'only-kb-set:dXMtY2VudHJhbDEuZ2NwLmNsb3VkLmVzLmlvJGFjMzFlYmI5MDI0MTc3MzE1NzA0M2MzNGZkMjZmZDQ2JGE0YzA2MjMwZTQ4YzhmY2U3YmU4OGEwNzRhM2JiM2UwOjkyNDQ=', + expectedEsURL: 'https://ac31ebb90241773157043c34fd26fd46.us-central1.gcp.cloud.es.io:443', + expectedKibanaURL: + 'https://a4c06230e48c8fce7be88a074a3bb3e0.us-central1.gcp.cloud.es.io:9244', + }, + { + cloudID: + 'host-and-kb-set:dXMtY2VudHJhbDEuZ2NwLmNsb3VkLmVzLmlvOjkyNDMkYWMzMWViYjkwMjQxNzczMTU3MDQzYzM0ZmQyNmZkNDYkYTRjMDYyMzBlNDhjOGZjZTdiZTg4YTA3NGEzYmIzZTA6OTI0NA==', + expectedEsURL: 'https://ac31ebb90241773157043c34fd26fd46.us-central1.gcp.cloud.es.io:9243', + expectedKibanaURL: + 'https://a4c06230e48c8fce7be88a074a3bb3e0.us-central1.gcp.cloud.es.io:9244', + }, + { + cloudID: + 'extra-items:dXMtY2VudHJhbDEuZ2NwLmNsb3VkLmVzLmlvJGFjMzFlYmI5MDI0MTc3MzE1NzA0M2MzNGZkMjZmZDQ2JGE0YzA2MjMwZTQ4YzhmY2U3YmU4OGEwNzRhM2JiM2UwJGFub3RoZXJpZCRhbmRhbm90aGVy', + expectedEsURL: 'https://ac31ebb90241773157043c34fd26fd46.us-central1.gcp.cloud.es.io:443', + expectedKibanaURL: + 'https://a4c06230e48c8fce7be88a074a3bb3e0.us-central1.gcp.cloud.es.io:443', + }, + ]; + + for (const test of tests) { + const decoded = decodeCloudId(test.cloudID); + expect(decoded).toBeTruthy(); + expect(decoded?.elasticsearchUrl === test.expectedEsURL).toBe(true); + expect(decoded?.kibanaUrl === test.expectedKibanaURL).toBe(true); + } + }); + + it('returns undefined for invalid formats', () => { + const tests = [ + { + cloudID: + 'staging:garbagedXMtZWFzdC0xLmF3cy5mb3VuZC5pbyRjZWM2ZjI2MWE3NGJmMjRjZTMzYmI4ODExYjg0Mjk0ZiRjNmMyY2E2ZDA0MjI0OWFmMGNjN2Q3YTllOTYyNTc0Mw==', + errorMsg: 'base64 decoding failed', + }, + { + cloudID: 'dXMtY2VudHJhbDEuZ2NwLmNsb3VkLmVzLmlvJDhhMDI4M2FmMDQxZjE5NWY3NzI5YmMwNGM2NmEwZg==', + errorMsg: 'Expected at least 3 parts', + }, + ]; + + for (const test of tests) { + const decoded = decodeCloudId(test.cloudID); + expect(decoded).toBe(undefined); + // decodeCloudId currently only logs; not throws errors + } + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/utils/decode_cloud_id.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/utils/decode_cloud_id.ts new file mode 100644 index 0000000000000..f0059495ef17e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/utils/decode_cloud_id.ts @@ -0,0 +1,66 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// copied this solution from fleet due to time constraints +// x-pack/fleet/common/services/decode_cloud_id.ts +// decodeCloudId decodes the c.id into c.esURL and c.kibURL +export function decodeCloudId(cid: string): + | { + defaultPort: string; + elasticsearchUrl: string; + host: string; + kibanaUrl: string; + } + | undefined { + // 1. Ignore anything before `:`. + const id = cid.split(':').pop(); + if (!id) { + // throw new Error(`Unable to decode ${id}`); + // eslint-disable-next-line no-console + console.debug(`Unable to decode ${id}`); + return; + } + + // 2. base64 decode + let decoded: string | undefined; + try { + decoded = Buffer.from(id, 'base64').toString('utf8'); + } catch { + // throw new Error(`base64 decoding failed on ${id}`); + // eslint-disable-next-line no-console + console.debug(`base64 decoding failed on ${id}`); + return; + } + + // 3. separate based on `$` + const words = decoded.split('$'); + if (words.length < 3) { + // throw new Error(`Expected at least 3 parts in ${decoded}`); + // eslint-disable-next-line no-console + console.debug(`Expected at least 3 parts in ${decoded}`); + return; + } + // 4. extract port from the ES and Kibana host + const [host, defaultPort] = extractPortFromName(words[0]); + const [esId, esPort] = extractPortFromName(words[1], defaultPort); + const [kbId, kbPort] = extractPortFromName(words[2], defaultPort); + // 5. form the URLs + const esUrl = `https://${esId}.${host}:${esPort}`; + const kbUrl = `https://${kbId}.${host}:${kbPort}`; + return { + defaultPort, + elasticsearchUrl: esUrl, + host, + kibanaUrl: kbUrl, + }; +} +// extractPortFromName takes a string in the form `id:port` and returns the +// Id and the port. If there's no `:`, the default port is returned +function extractPortFromName(word: string, defaultPort = '443') { + const [host, port = defaultPort] = word.split(':'); + return [host, port]; +} From 7d66145352b59f3e12897b1b590d7390228671f7 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Fri, 29 Jul 2022 14:40:55 +0100 Subject: [PATCH 04/13] skip flaky suite (#136990) --- .../migrations/incompatible_cluster_routing_allocation.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/server/integration_tests/saved_objects/migrations/incompatible_cluster_routing_allocation.test.ts b/src/core/server/integration_tests/saved_objects/migrations/incompatible_cluster_routing_allocation.test.ts index 95f6445812d81..cf00a15c795f2 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/incompatible_cluster_routing_allocation.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/incompatible_cluster_routing_allocation.test.ts @@ -91,7 +91,8 @@ async function updateRoutingAllocations( }); } -describe('incompatible_cluster_routing_allocation', () => { +// FLAKY: https://github.com/elastic/kibana/issues/136990 +describe.skip('incompatible_cluster_routing_allocation', () => { let client: ElasticsearchClient; let root: Root; From b97701cd8a56adc9b71339463b26bfc39e188b02 Mon Sep 17 00:00:00 2001 From: Dmitry Tomashevich <39378793+dimaanj@users.noreply.github.com> Date: Fri, 29 Jul 2022 17:05:15 +0300 Subject: [PATCH 05/13] [Discover] Fix column width handling (#137445) * [Discover] fix column width handling * [Discover] add unit test --- .../layout/discover_documents.test.tsx | 26 +++++++++++++++-- .../components/layout/discover_documents.tsx | 29 ++++++++++++------- .../main/utils/get_state_defaults.test.ts | 2 ++ .../main/utils/get_state_defaults.ts | 1 + 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/plugins/discover/public/application/main/components/layout/discover_documents.test.tsx b/src/plugins/discover/public/application/main/components/layout/discover_documents.test.tsx index cc4bc4b285a86..dca425977b359 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_documents.test.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_documents.test.tsx @@ -12,11 +12,11 @@ import { mountWithIntl } from '@kbn/test-jest-helpers'; import { setHeaderActionMenuMounter } from '../../../../kibana_services'; import { esHits } from '../../../../__mocks__/es_hits'; import { savedSearchMock } from '../../../../__mocks__/saved_search'; -import { GetStateReturn } from '../../services/discover_state'; +import { AppState, GetStateReturn } from '../../services/discover_state'; import { DataDocuments$ } from '../../hooks/use_saved_search'; import { discoverServiceMock } from '../../../../__mocks__/services'; import { FetchStatus } from '../../../types'; -import { DiscoverDocuments } from './discover_documents'; +import { DiscoverDocuments, onResize } from './discover_documents'; import { dataViewMock } from '../../../../__mocks__/data_view'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { buildDataTableRecord } from '../../../../utils/build_data_record'; @@ -74,4 +74,26 @@ describe('Discover documents layout', () => { expect(component.find('.dscDocuments__loading').exists()).toBeFalsy(); expect(component.find('.dscTable').exists()).toBeTruthy(); }); + + test('should set rounded width to state on resize column', () => { + let state = { + grid: { columns: { timestamp: { width: 173 }, someField: { width: 197 } } }, + } as AppState; + const stateContainer = { + setAppState: (newState: Partial) => { + state = { ...state, ...newState }; + }, + } as unknown as GetStateReturn; + + onResize( + { + columnId: 'someField', + width: 205.5435345534, + }, + stateContainer, + state + ); + + expect(state.grid?.columns?.someField.width).toEqual(206); + }); }); diff --git a/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx b/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx index bc5acbc0b6a60..2a824ddf361fb 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx @@ -42,6 +42,21 @@ import { getRawRecordType } from '../../utils/get_raw_record_type'; const DocTableInfiniteMemoized = React.memo(DocTableInfinite); const DataGridMemoized = React.memo(DiscoverGrid); +// export needs for testing +export const onResize = ( + colSettings: { columnId: string; width: number }, + stateContainer: GetStateReturn, + state: AppState +) => { + const grid = { ...(state.grid || {}) }; + const newColumns = { ...(grid.columns || {}) }; + newColumns[colSettings.columnId] = { + width: Math.round(colSettings.width), + }; + const newGrid = { ...grid, columns: newColumns }; + stateContainer.setAppState({ grid: newGrid }); +}; + function DiscoverDocumentsComponent({ documents$, expandedDoc, @@ -88,16 +103,8 @@ function DiscoverDocumentsComponent({ useNewFieldsApi, }); - const onResize = useCallback( - (colSettings: { columnId: string; width: number }) => { - const grid = { ...(state.grid || {}) }; - const newColumns = { ...(grid.columns || {}) }; - newColumns[colSettings.columnId] = { - width: colSettings.width, - }; - const newGrid = { ...grid, columns: newColumns }; - stateContainer.setAppState({ grid: newGrid }); - }, + const onResizeDataGrid = useCallback( + (colSettings) => onResize(colSettings, stateContainer, state), [stateContainer, state] ); @@ -200,7 +207,7 @@ function DiscoverDocumentsComponent({ onRemoveColumn={onRemoveColumn} onSetColumns={onSetColumns} onSort={!isPlainRecord ? onSort : undefined} - onResize={onResize} + onResize={onResizeDataGrid} useNewFieldsApi={useNewFieldsApi} rowHeightState={state.rowHeight} onUpdateRowHeight={onUpdateRowHeight} diff --git a/src/plugins/discover/public/application/main/utils/get_state_defaults.test.ts b/src/plugins/discover/public/application/main/utils/get_state_defaults.test.ts index d68913e5d5c35..17cb547055a87 100644 --- a/src/plugins/discover/public/application/main/utils/get_state_defaults.test.ts +++ b/src/plugins/discover/public/application/main/utils/get_state_defaults.test.ts @@ -31,6 +31,7 @@ describe('getStateDefaults', () => { "default_column", ], "filters": undefined, + "grid": undefined, "hideAggregatedPreview": undefined, "hideChart": undefined, "index": "index-pattern-with-timefield-id", @@ -65,6 +66,7 @@ describe('getStateDefaults', () => { "default_column", ], "filters": undefined, + "grid": undefined, "hideAggregatedPreview": undefined, "hideChart": undefined, "index": "the-data-view-id", diff --git a/src/plugins/discover/public/application/main/utils/get_state_defaults.ts b/src/plugins/discover/public/application/main/utils/get_state_defaults.ts index 34f19af08bacb..56996e999d6c9 100644 --- a/src/plugins/discover/public/application/main/utils/get_state_defaults.ts +++ b/src/plugins/discover/public/application/main/utils/get_state_defaults.ts @@ -70,6 +70,7 @@ export function getStateDefaults({ savedQuery: undefined, rowHeight: undefined, rowsPerPage: undefined, + grid: undefined, }; if (savedSearch.grid) { defaultState.grid = savedSearch.grid; From 4a95b2cde3f481cac04e376e3386596320ab8642 Mon Sep 17 00:00:00 2001 From: suchcodemuchwow Date: Fri, 29 Jul 2022 16:14:34 +0200 Subject: [PATCH 06/13] skip flaky suite (#137590) --- .../components/artifact_delete_modal.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_delete_modal.test.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_delete_modal.test.ts index 77d59c918d832..a0baac5b7163c 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_delete_modal.test.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_delete_modal.test.ts @@ -13,7 +13,8 @@ import { act, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { getDeferred } from '../../mocks'; -describe('When displaying the Delete artfifact modal in the Artifact List Page', () => { +// FLAKY: https://github.com/elastic/kibana/issues/137590 +describe.skip('When displaying the Delete artfifact modal in the Artifact List Page', () => { let renderResult: ReturnType; let history: AppContextTestRender['history']; let coreStart: AppContextTestRender['coreStart']; From 01fc584a4a7381454798dfc784036e5ad4074518 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Fri, 29 Jul 2022 10:38:58 -0400 Subject: [PATCH 07/13] [Portable Dashboards] Prep Redux Tools (#136572) * Created new Redux system for embeddables in Presentation Util. Migrated all Controls to use it. --- .../control_types/options_list/types.ts | 5 +- .../component/control_frame_component.tsx | 8 +- .../component/control_group_component.tsx | 14 +- .../component/control_group_sortable_item.tsx | 11 +- .../control_group/editor/edit_control.tsx | 6 +- .../embeddable/control_group_container.tsx | 67 +++-- .../control_group_container_factory.ts | 4 +- .../state/control_group_reducers.ts | 26 +- .../controls/public/control_group/types.ts | 8 +- .../options_list/options_list_component.tsx | 77 ++---- .../options_list/options_list_embeddable.tsx | 237 ++++++++++------- .../options_list_embeddable_factory.tsx | 7 +- .../options_list_popover_component.tsx | 38 ++- .../options_list/options_list_reducers.ts | 92 +++++-- .../control_types/options_list/types.ts | 25 ++ .../range_slider/range_slider.component.tsx | 62 +---- .../range_slider/range_slider_embeddable.tsx | 181 ++++++------- .../range_slider_embeddable_factory.tsx | 7 +- .../range_slider/range_slider_popover.tsx | 90 ++++--- .../range_slider/range_slider_reducers.ts | 47 +++- .../control_types/range_slider/types.ts | 22 +- .../control_types/time_slider/time_slider.tsx | 25 +- .../time_slider_embeddable.test.ts | 23 +- .../time_slider/time_slider_embeddable.tsx | 42 ++- .../time_slider_embeddable_factory.tsx | 7 +- .../time_slider/time_slider_reducers.ts | 7 +- .../public/control_types/time_slider/types.ts | 30 +++ .../public/hooks/use_state_observable.ts | 23 -- .../public/services/kibana/options_list.ts | 2 +- src/plugins/controls/public/types.ts | 4 +- .../hooks/use_dashboard_app_state.test.tsx | 3 + .../hooks/use_dashboard_app_state.ts | 12 +- .../lib/dashboard_control_group.test.ts | 6 +- .../lib/sync_dashboard_data_views.ts | 57 ++-- .../public/components/index.tsx | 5 - .../generic_embeddable_store.ts | 42 --- .../components/redux_embeddables/index.ts | 17 -- .../redux_embeddable_wrapper.tsx | 243 ------------------ .../components/redux_embeddables/types.ts | 72 ------ src/plugins/presentation_util/public/index.ts | 18 +- .../clean_redux_embeddable_state.ts | 50 ++++ .../create_redux_embeddable_tools.tsx | 125 +++++++++ .../public/redux_embeddables/index.ts | 23 ++ .../sync_redux_embeddable.ts | 105 ++++++++ .../public/redux_embeddables/types.ts | 123 +++++++++ .../use_redux_embeddable_context.ts} | 44 ++-- 46 files changed, 1192 insertions(+), 950 deletions(-) create mode 100644 src/plugins/controls/public/control_types/time_slider/types.ts delete mode 100644 src/plugins/controls/public/hooks/use_state_observable.ts delete mode 100644 src/plugins/presentation_util/public/components/redux_embeddables/generic_embeddable_store.ts delete mode 100644 src/plugins/presentation_util/public/components/redux_embeddables/index.ts delete mode 100644 src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx delete mode 100644 src/plugins/presentation_util/public/components/redux_embeddables/types.ts create mode 100644 src/plugins/presentation_util/public/redux_embeddables/clean_redux_embeddable_state.ts create mode 100644 src/plugins/presentation_util/public/redux_embeddables/create_redux_embeddable_tools.tsx create mode 100644 src/plugins/presentation_util/public/redux_embeddables/index.ts create mode 100644 src/plugins/presentation_util/public/redux_embeddables/sync_redux_embeddable.ts create mode 100644 src/plugins/presentation_util/public/redux_embeddables/types.ts rename src/plugins/presentation_util/public/{components/redux_embeddables/redux_embeddable_context.ts => redux_embeddables/use_redux_embeddable_context.ts} (51%) diff --git a/src/plugins/controls/common/control_types/options_list/types.ts b/src/plugins/controls/common/control_types/options_list/types.ts index 4b805210f37d3..46e4aab854d0d 100644 --- a/src/plugins/controls/common/control_types/options_list/types.ts +++ b/src/plugins/controls/common/control_types/options_list/types.ts @@ -7,7 +7,7 @@ */ import type { Filter, Query, BoolQuery, TimeRange } from '@kbn/es-query'; -import { FieldSpec, DataView, DataViewField } from '@kbn/data-views-plugin/common'; +import { FieldSpec, DataView } from '@kbn/data-views-plugin/common'; import { DataControlInput } from '../../types'; @@ -17,10 +17,9 @@ export interface OptionsListEmbeddableInput extends DataControlInput { selectedOptions?: string[]; runPastTimeout?: boolean; singleSelect?: boolean; - loading?: boolean; } -export type OptionsListField = DataViewField & { +export type OptionsListField = FieldSpec & { textFieldName?: string; parentFieldName?: string; childFieldName?: string; diff --git a/src/plugins/controls/public/control_group/component/control_frame_component.tsx b/src/plugins/controls/public/control_group/component/control_frame_component.tsx index 4a4550f54d519..2dd3bafa87da7 100644 --- a/src/plugins/controls/public/control_group/component/control_frame_component.tsx +++ b/src/plugins/controls/public/control_group/component/control_frame_component.tsx @@ -19,7 +19,7 @@ import { import { useReduxContainerContext } from '@kbn/presentation-util-plugin/public'; import { ErrorEmbeddable } from '@kbn/embeddable-plugin/public'; -import { ControlGroupInput } from '../types'; +import { ControlGroupReduxState } from '../types'; import { pluginServices } from '../../services'; import { EditControlButton } from '../editor/edit_control'; import { ControlGroupStrings } from '../control_group_strings'; @@ -42,11 +42,11 @@ export const ControlFrame = ({ const [hasFatalError, setHasFatalError] = useState(false); const { - useEmbeddableSelector, + useEmbeddableSelector: select, containerActions: { untilEmbeddableLoaded, removeEmbeddable }, - } = useReduxContainerContext(); + } = useReduxContainerContext(); - const { controlStyle } = useEmbeddableSelector((state) => state); + const controlStyle = select((state) => state.explicitInput.controlStyle); // Controls Services Context const { overlays } = pluginServices.getHooks(); diff --git a/src/plugins/controls/public/control_group/component/control_group_component.tsx b/src/plugins/controls/public/control_group/component/control_group_component.tsx index 1c977e0162cbd..18b22aa9007fb 100644 --- a/src/plugins/controls/public/control_group/component/control_group_component.tsx +++ b/src/plugins/controls/public/control_group/component/control_group_component.tsx @@ -28,27 +28,31 @@ import { useSensors, LayoutMeasuringStrategy, } from '@dnd-kit/core'; + import { ViewMode } from '@kbn/embeddable-plugin/public'; import { useReduxContainerContext } from '@kbn/presentation-util-plugin/public'; -import { ControlGroupInput } from '../types'; + +import { ControlGroupReduxState } from '../types'; import { controlGroupReducers } from '../state/control_group_reducers'; import { ControlClone, SortableControl } from './control_group_sortable_item'; export const ControlGroup = () => { // Redux embeddable container Context const reduxContainerContext = useReduxContainerContext< - ControlGroupInput, + ControlGroupReduxState, typeof controlGroupReducers >(); const { - useEmbeddableSelector, - useEmbeddableDispatch, actions: { setControlOrders }, + useEmbeddableSelector: select, + useEmbeddableDispatch, } = reduxContainerContext; const dispatch = useEmbeddableDispatch(); // current state - const { panels, viewMode, controlStyle } = useEmbeddableSelector((state) => state); + const panels = select((state) => state.explicitInput.panels); + const viewMode = select((state) => state.explicitInput.viewMode); + const controlStyle = select((state) => state.explicitInput.controlStyle); const isEditable = viewMode === ViewMode.EDIT; diff --git a/src/plugins/controls/public/control_group/component/control_group_sortable_item.tsx b/src/plugins/controls/public/control_group/component/control_group_sortable_item.tsx index e523cecb70896..43907b95a893a 100644 --- a/src/plugins/controls/public/control_group/component/control_group_sortable_item.tsx +++ b/src/plugins/controls/public/control_group/component/control_group_sortable_item.tsx @@ -13,8 +13,8 @@ import { CSS } from '@dnd-kit/utilities'; import classNames from 'classnames'; import { useReduxContainerContext } from '@kbn/presentation-util-plugin/public'; -import { ControlGroupInput } from '../types'; import { ControlFrame, ControlFrameProps } from './control_frame_component'; +import { ControlGroupReduxState } from '../types'; import { ControlGroupStrings } from '../control_group_strings'; interface DragInfo { @@ -67,8 +67,8 @@ const SortableControlInner = forwardRef< dragHandleRef ) => { const { isOver, isDragging, draggingIndex, index } = dragInfo; - const { useEmbeddableSelector } = useReduxContainerContext(); - const { panels } = useEmbeddableSelector((state) => state); + const { useEmbeddableSelector } = useReduxContainerContext(); + const panels = useEmbeddableSelector((state) => state.explicitInput.panels); const grow = panels[embeddableId].grow; const width = panels[embeddableId].width; @@ -119,8 +119,9 @@ const SortableControlInner = forwardRef< * can be quite cumbersome. */ export const ControlClone = ({ draggingId }: { draggingId: string }) => { - const { useEmbeddableSelector } = useReduxContainerContext(); - const { panels, controlStyle } = useEmbeddableSelector((state) => state); + const { useEmbeddableSelector: select } = useReduxContainerContext(); + const panels = select((state) => state.explicitInput.panels); + const controlStyle = select((state) => state.explicitInput.controlStyle); const width = panels[draggingId].width; const title = panels[draggingId].explicitInput.title; diff --git a/src/plugins/controls/public/control_group/editor/edit_control.tsx b/src/plugins/controls/public/control_group/editor/edit_control.tsx index 12ceab4f69645..28ece063fcce3 100644 --- a/src/plugins/controls/public/control_group/editor/edit_control.tsx +++ b/src/plugins/controls/public/control_group/editor/edit_control.tsx @@ -14,7 +14,7 @@ import { OverlayRef } from '@kbn/core/public'; import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { EmbeddableFactoryNotFoundError } from '@kbn/embeddable-plugin/public'; import { useReduxContainerContext } from '@kbn/presentation-util-plugin/public'; -import { ControlGroupInput } from '../types'; +import { ControlGroupReduxState } from '../types'; import { ControlEditor } from './control_editor'; import { pluginServices } from '../../services'; import { ControlGroupStrings } from '../control_group_strings'; @@ -41,7 +41,7 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => // Redux embeddable container Context const reduxContainerContext = useReduxContainerContext< - ControlGroupInput, + ControlGroupReduxState, typeof controlGroupReducers >(); const { @@ -53,7 +53,7 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => const dispatch = useEmbeddableDispatch(); // current state - const { panels } = useEmbeddableSelector((state) => state); + const panels = useEmbeddableSelector((state) => state.explicitInput.panels); // keep up to date ref of latest panel state for comparison when closing editor. const latestPanelState = useRef(panels[embeddableId]); diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx index f5a6d244b8ff6..9632402bce6c2 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx +++ b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx @@ -6,37 +6,34 @@ * Side Public License, v 1. */ +import { + map, + skip, + switchMap, + catchError, + debounceTime, + distinctUntilChanged, +} from 'rxjs/operators'; import React from 'react'; -import { uniqBy } from 'lodash'; import ReactDOM from 'react-dom'; import deepEqual from 'fast-deep-equal'; import { Filter, uniqFilters } from '@kbn/es-query'; import { EMPTY, merge, pipe, Subject, Subscription } from 'rxjs'; import { EuiContextMenuPanel } from '@elastic/eui'; -import { - distinctUntilChanged, - debounceTime, - catchError, - switchMap, - map, - skip, - mapTo, -} from 'rxjs/operators'; import { - withSuspense, - LazyReduxEmbeddableWrapper, - ReduxEmbeddableWrapperPropsWithChildren, + ReduxEmbeddablePackage, + ReduxEmbeddableTools, SolutionToolbarPopover, } from '@kbn/presentation-util-plugin/public'; import { OverlayRef } from '@kbn/core/public'; -import { DataView } from '@kbn/data-views-plugin/public'; +import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import { Container, EmbeddableFactory } from '@kbn/embeddable-plugin/public'; -import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import { ControlGroupInput, ControlGroupOutput, + ControlGroupReduxState, ControlPanelState, ControlsPanels, CONTROL_GROUP_TYPE, @@ -54,10 +51,6 @@ import { controlGroupReducers } from '../state/control_group_reducers'; import { ControlEmbeddable, ControlInput, ControlOutput } from '../../types'; import { CreateControlButton, CreateControlButtonTypes } from '../editor/create_control'; -const ControlGroupReduxWrapper = withSuspense< - ReduxEmbeddableWrapperPropsWithChildren ->(LazyReduxEmbeddableWrapper); - let flyoutRef: OverlayRef | undefined; export const setFlyoutRef = (newRef: OverlayRef | undefined) => { flyoutRef = newRef; @@ -77,6 +70,11 @@ export class ControlGroupContainer extends Container< private relevantDataViewId?: string; private lastUsedDataViewId?: string; + private reduxEmbeddableTools: ReduxEmbeddableTools< + ControlGroupReduxState, + typeof controlGroupReducers + >; + public setLastUsedDataViewId = (lastUsedDataViewId: string) => { this.lastUsedDataViewId = lastUsedDataViewId; }; @@ -162,10 +160,14 @@ export class ControlGroupContainer extends Container< ); }; - constructor(initialInput: ControlGroupInput, parent?: Container) { + constructor( + reduxEmbeddablePackage: ReduxEmbeddablePackage, + initialInput: ControlGroupInput, + parent?: Container + ) { super( initialInput, - { embeddableLoaded: {} }, + { dataViewIds: [], embeddableLoaded: {}, filters: [] }, pluginServices.getServices().controls.getControlFactory, parent, ControlGroupChainingSystems[initialInput.chainingSystem]?.getContainerSettings(initialInput) @@ -173,6 +175,15 @@ export class ControlGroupContainer extends Container< this.recalculateFilters$ = new Subject(); + // build redux embeddable tools + this.reduxEmbeddableTools = reduxEmbeddablePackage.createTools< + ControlGroupReduxState, + typeof controlGroupReducers + >({ + embeddable: this, + reducers: controlGroupReducers, + }); + // when all children are ready setup subscriptions this.untilReady().then(() => { this.recalculateDataViews(); @@ -215,7 +226,7 @@ export class ControlGroupContainer extends Container< .pipe( // Embeddables often throw errors into their output streams. catchError(() => EMPTY), - mapTo(childId) + map(() => childId) ) ) ) @@ -261,12 +272,12 @@ export class ControlGroupContainer extends Container< }; private recalculateDataViews = () => { - const allDataViews: DataView[] = []; + const allDataViewIds: Set = new Set(); Object.values(this.children).map((child) => { - const childOutput = child.getOutput() as ControlOutput; - allDataViews.push(...(childOutput.dataViews ?? [])); + const dataViewId = (child.getOutput() as ControlOutput).dataViewId; + if (dataViewId) allDataViewIds.add(dataViewId); }); - this.updateOutput({ dataViews: uniqBy(allDataViews, 'id') }); + this.updateOutput({ dataViewIds: Array.from(allDataViewIds) }); }; protected createNewPanelState( @@ -354,10 +365,11 @@ export class ControlGroupContainer extends Container< } this.domNode = dom; const ControlsServicesProvider = pluginServices.getContextProvider(); + const { Wrapper: ControlGroupReduxWrapper } = this.reduxEmbeddableTools; ReactDOM.render( - + @@ -370,6 +382,7 @@ export class ControlGroupContainer extends Container< super.destroy(); this.closeAllFlyouts(); this.subscriptions.unsubscribe(); + this.reduxEmbeddableTools.cleanup(); if (this.domNode) ReactDOM.unmountComponentAtNode(this.domNode); } } diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts b/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts index f55c49101dc40..bcbd31955f36e 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts +++ b/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts @@ -15,6 +15,7 @@ */ import { Container, EmbeddableFactoryDefinition } from '@kbn/embeddable-plugin/public'; +import { lazyLoadReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public'; import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common'; import { ControlGroupInput, CONTROL_GROUP_TYPE } from '../types'; @@ -47,7 +48,8 @@ export class ControlGroupContainerFactory implements EmbeddableFactoryDefinition } public create = async (initialInput: ControlGroupInput, parent?: Container) => { + const reduxEmbeddablePackage = await lazyLoadReduxEmbeddablePackage(); const { ControlGroupContainer } = await import('./control_group_container'); - return new ControlGroupContainer(initialInput, parent); + return new ControlGroupContainer(reduxEmbeddablePackage, initialInput, parent); }; } diff --git a/src/plugins/controls/public/control_group/state/control_group_reducers.ts b/src/plugins/controls/public/control_group/state/control_group_reducers.ts index 25167ac9247dd..ff1b2e501dabe 100644 --- a/src/plugins/controls/public/control_group/state/control_group_reducers.ts +++ b/src/plugins/controls/public/control_group/state/control_group_reducers.ts @@ -10,45 +10,45 @@ import { PayloadAction } from '@reduxjs/toolkit'; import { WritableDraft } from 'immer/dist/types/types-external'; import { ControlWidth } from '../../types'; -import { ControlGroupInput } from '../types'; +import { ControlGroupInput, ControlGroupReduxState } from '../types'; export const controlGroupReducers = { setControlStyle: ( - state: WritableDraft, + state: WritableDraft, action: PayloadAction ) => { - state.controlStyle = action.payload; + state.explicitInput.controlStyle = action.payload; }, setDefaultControlWidth: ( - state: WritableDraft, + state: WritableDraft, action: PayloadAction ) => { - state.defaultControlWidth = action.payload; + state.explicitInput.defaultControlWidth = action.payload; }, setDefaultControlGrow: ( - state: WritableDraft, + state: WritableDraft, action: PayloadAction ) => { - state.defaultControlGrow = action.payload; + state.explicitInput.defaultControlGrow = action.payload; }, setControlWidth: ( - state: WritableDraft, + state: WritableDraft, action: PayloadAction<{ width: ControlWidth; embeddableId: string }> ) => { - state.panels[action.payload.embeddableId].width = action.payload.width; + state.explicitInput.panels[action.payload.embeddableId].width = action.payload.width; }, setControlGrow: ( - state: WritableDraft, + state: WritableDraft, action: PayloadAction<{ grow: boolean; embeddableId: string }> ) => { - state.panels[action.payload.embeddableId].grow = action.payload.grow; + state.explicitInput.panels[action.payload.embeddableId].grow = action.payload.grow; }, setControlOrders: ( - state: WritableDraft, + state: WritableDraft, action: PayloadAction<{ ids: string[] }> ) => { action.payload.ids.forEach((id, index) => { - state.panels[id].order = index; + state.explicitInput.panels[id].order = index; }); }, }; diff --git a/src/plugins/controls/public/control_group/types.ts b/src/plugins/controls/public/control_group/types.ts index 05422fd0c9729..8304c9dd0d095 100644 --- a/src/plugins/controls/public/control_group/types.ts +++ b/src/plugins/controls/public/control_group/types.ts @@ -7,9 +7,15 @@ */ import { ContainerOutput } from '@kbn/embeddable-plugin/public'; +import { ReduxEmbeddableState } from '@kbn/presentation-util-plugin/public'; +import { ControlGroupInput } from '../../common/control_group/types'; import { CommonControlOutput } from '../types'; -export type ControlGroupOutput = ContainerOutput & CommonControlOutput; +export type ControlGroupOutput = ContainerOutput & + Omit & { dataViewIds: string[] }; + +// public only - redux embeddable state type +export type ControlGroupReduxState = ReduxEmbeddableState; export { type ControlsPanels, diff --git a/src/plugins/controls/public/control_types/options_list/options_list_component.tsx b/src/plugins/controls/public/control_types/options_list/options_list_component.tsx index b7483cd9165c9..8d911c4072bbc 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_component.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_component.tsx @@ -8,60 +8,46 @@ import { EuiFilterButton, EuiFilterGroup, EuiPopover, useResizeObserver } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; -import { BehaviorSubject, Subject } from 'rxjs'; +import { Subject } from 'rxjs'; import classNames from 'classnames'; import { debounce, isEmpty } from 'lodash'; import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'; -import { DataViewField } from '@kbn/data-views-plugin/public'; import { OptionsListStrings } from './options_list_strings'; import { optionsListReducers } from './options_list_reducers'; import { OptionsListPopover } from './options_list_popover_component'; import './options_list.scss'; -import { useStateObservable } from '../../hooks/use_state_observable'; -import { OptionsListEmbeddableInput } from './types'; - -// OptionsListComponentState is controlled by the embeddable, but is not considered embeddable input. -export interface OptionsListComponentState { - loading: boolean; - field?: DataViewField; - totalCardinality?: number; - availableOptions?: string[]; - invalidSelections?: string[]; - validSelections?: string[]; -} +import { OptionsListReduxState } from './types'; export const OptionsListComponent = ({ typeaheadSubject, - componentStateSubject, }: { typeaheadSubject: Subject; - componentStateSubject: BehaviorSubject; }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const [searchString, setSearchString] = useState(''); const resizeRef = useRef(null); const dimensions = useResizeObserver(resizeRef.current); - // Redux embeddable Context to get state from Embeddable input + // Redux embeddable Context const { useEmbeddableDispatch, - useEmbeddableSelector, - actions: { replaceSelection }, - } = useReduxEmbeddableContext(); + actions: { replaceSelection, setSearchString }, + useEmbeddableSelector: select, + } = useReduxEmbeddableContext(); const dispatch = useEmbeddableDispatch(); - const { controlStyle, selectedOptions, singleSelect, id } = useEmbeddableSelector( - (state) => state - ); - // useStateObservable to get component state from Embeddable - const { availableOptions, loading, invalidSelections, validSelections, totalCardinality, field } = - useStateObservable( - componentStateSubject, - componentStateSubject.getValue() - ); + // Select current state from Redux using multiple selectors to avoid rerenders. + const invalidSelections = select((state) => state.componentState.invalidSelections); + const validSelections = select((state) => state.componentState.validSelections); + + const selectedOptions = select((state) => state.explicitInput.selectedOptions); + const controlStyle = select((state) => state.explicitInput.controlStyle); + const singleSelect = select((state) => state.explicitInput.singleSelect); + const id = select((state) => state.explicitInput.id); + + const loading = select((state) => state.output.loading); // debounce loading state so loading doesn't flash when user types const [buttonLoading, setButtonLoading] = useState(true); @@ -69,7 +55,7 @@ export const OptionsListComponent = ({ () => debounce((latestLoading: boolean) => setButtonLoading(latestLoading), 100), [] ); - useEffect(() => debounceSetButtonLoading(loading), [loading, debounceSetButtonLoading]); + useEffect(() => debounceSetButtonLoading(loading ?? false), [loading, debounceSetButtonLoading]); // remove all other selections if this control is single select useEffect(() => { @@ -81,15 +67,11 @@ export const OptionsListComponent = ({ const updateSearchString = useCallback( (newSearchString: string) => { typeaheadSubject.next(newSearchString); - setSearchString(newSearchString); + dispatch(setSearchString(newSearchString)); }, - [typeaheadSubject] + [typeaheadSubject, dispatch, setSearchString] ); - useEffect(() => { - updateSearchString(''); - }, [field?.spec?.name, updateSearchString]); - const { hasSelections, selectionDisplayNode, validSelectionsCount } = useMemo(() => { return { hasSelections: !isEmpty(validSelections) || !isEmpty(invalidSelections), @@ -136,26 +118,17 @@ export const OptionsListComponent = ({ })} > setIsPopoverOpen(false)} panelPaddingSize="none" anchorPosition="downCenter" - ownFocus - repositionOnScroll + className="optionsList__popoverOverride" + closePopover={() => setIsPopoverOpen(false)} + anchorClassName="optionsList__anchorOverride" > - + ); diff --git a/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx index da6de055d4823..3b586cfb4716a 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx @@ -6,6 +6,14 @@ * Side Public License, v 1. */ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { batch } from 'react-redux'; +import deepEqual from 'fast-deep-equal'; +import { isEmpty, isEqual } from 'lodash'; +import { merge, Subject, Subscription } from 'rxjs'; +import { debounceTime, map, distinctUntilChanged, skip } from 'rxjs/operators'; + import { Filter, compareFilters, @@ -13,24 +21,18 @@ import { buildPhrasesFilter, COMPARE_ALL_OPTIONS, } from '@kbn/es-query'; -import React from 'react'; -import ReactDOM from 'react-dom'; -import { isEmpty, isEqual } from 'lodash'; -import deepEqual from 'fast-deep-equal'; -import { merge, Subject, Subscription, BehaviorSubject } from 'rxjs'; -import { tap, debounceTime, map, distinctUntilChanged, skip } from 'rxjs/operators'; - -import { - withSuspense, - LazyReduxEmbeddableWrapper, - ReduxEmbeddableWrapperPropsWithChildren, -} from '@kbn/presentation-util-plugin/public'; +import { ReduxEmbeddableTools, ReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public'; import { DataView } from '@kbn/data-views-plugin/public'; import { Embeddable, IContainer } from '@kbn/embeddable-plugin/public'; import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; -import { OptionsListEmbeddableInput, OptionsListField, OPTIONS_LIST_CONTROL } from './types'; -import { OptionsListComponent, OptionsListComponentState } from './options_list_component'; +import { + OptionsListEmbeddableInput, + OptionsListField, + OptionsListReduxState, + OPTIONS_LIST_CONTROL, +} from './types'; +import { OptionsListComponent } from './options_list_component'; import { ControlsOptionsListService } from '../../services/options_list'; import { ControlsDataViewsService } from '../../services/data_views'; import { optionsListReducers } from './options_list_reducers'; @@ -38,10 +40,6 @@ import { OptionsListStrings } from './options_list_strings'; import { ControlInput, ControlOutput } from '../..'; import { pluginServices } from '../../services'; -const OptionsListReduxWrapper = withSuspense< - ReduxEmbeddableWrapperPropsWithChildren ->(LazyReduxEmbeddableWrapper); - const diffDataFetchProps = ( last?: OptionsListDataFetchProps, current?: OptionsListDataFetchProps @@ -79,27 +77,35 @@ export class OptionsListEmbeddable extends Embeddable({ - invalidSelections: [], - validSelections: [], - loading: true, - }); + private reduxEmbeddableTools: ReduxEmbeddableTools< + OptionsListReduxState, + typeof optionsListReducers + >; - constructor(input: OptionsListEmbeddableInput, output: ControlOutput, parent?: IContainer) { - super(input, output, parent); // get filters for initial output... + constructor( + reduxEmbeddablePackage: ReduxEmbeddablePackage, + input: OptionsListEmbeddableInput, + output: ControlOutput, + parent?: IContainer + ) { + super(input, output, parent); // Destructure controls services ({ dataViews: this.dataViewsService, optionsList: this.optionsListService } = pluginServices.getServices()); - this.componentState = { loading: true }; - this.updateComponentState(this.componentState); this.typeaheadSubject = new Subject(); + // build redux embeddable tools + this.reduxEmbeddableTools = reduxEmbeddablePackage.createTools< + OptionsListReduxState, + typeof optionsListReducers + >({ + embeddable: this, + reducers: optionsListReducers, + }); + this.initialize(); } @@ -129,11 +135,8 @@ export class OptionsListEmbeddable extends Embeddable (this.searchString = newSearchString)) - ); + // debounce typeahead pipe to slow down search string related queries + const typeaheadPipe = this.typeaheadSubject.pipe(debounceTime(100)); // fetch available options when input changes or when search string has changed this.subscriptions.add( @@ -149,13 +152,19 @@ export class OptionsListEmbeddable extends Embeddable isEqual(a.selectedOptions, b.selectedOptions))) .subscribe(async ({ selectedOptions: newSelectedOptions }) => { + const { + actions: { + clearValidAndInvalidSelections, + setValidAndInvalidSelections, + publishFilters, + }, + dispatch, + } = this.reduxEmbeddableTools; + if (!newSelectedOptions || isEmpty(newSelectedOptions)) { - this.updateComponentState({ - validSelections: [], - invalidSelections: [], - }); + dispatch(clearValidAndInvalidSelections({})); } else { - const { invalidSelections } = this.componentStateSubject$.getValue(); + const { invalidSelections } = this.reduxEmbeddableTools.getState().componentState ?? {}; const newValidSelections: string[] = []; const newInvalidSelections: string[] = []; for (const selectedOption of newSelectedOptions) { @@ -165,13 +174,15 @@ export class OptionsListEmbeddable extends Embeddable => { - const { dataViewId, fieldName, parentFieldName, childFieldName } = this.getInput(); + const { + dispatch, + getState, + actions: { setField, setDataViewId }, + } = this.reduxEmbeddableTools; + + const { + explicitInput: { dataViewId, fieldName, parentFieldName, childFieldName }, + } = getState(); if (!this.dataView || this.dataView.id !== dataViewId) { try { @@ -190,49 +209,65 @@ export class OptionsListEmbeddable extends Embeddable) { - this.componentState = { - ...this.componentState, - ...changes, - }; - this.componentStateSubject$.next(this.componentState); - } - private runOptionsListQuery = async () => { + const { + dispatch, + getState, + actions: { setLoading, updateQueryResults, publishFilters, setSearchString }, + } = this.reduxEmbeddableTools; + + const previousFieldName = this.field?.name; const { dataView, field } = await this.getCurrentDataViewAndField(); if (!dataView || !field) return; - this.updateComponentState({ loading: true }); - this.updateOutput({ loading: true, dataViews: [dataView] }); - const { ignoreParentSettings, filters, query, selectedOptions, timeRange, runPastTimeout } = - this.getInput(); + if (previousFieldName && field.name !== previousFieldName) { + dispatch(setSearchString('')); + } + + const { + componentState: { searchString }, + explicitInput: { selectedOptions, runPastTimeout }, + } = getState(); + + dispatch(setLoading(true)); + + // need to get filters, query, ignoreParentSettings, and timeRange from input for inheritance + const { ignoreParentSettings, filters, query, timeRange } = this.getInput(); if (this.abortController) this.abortController.abort(); this.abortController = new AbortController(); @@ -244,20 +279,21 @@ export class OptionsListEmbeddable extends Embeddable { + dispatch(setLoading(false)); + dispatch(publishFilters(newFilters)); + }); }; private buildFilter = async () => { - const { validSelections } = this.componentState; + const { getState } = this.reduxEmbeddableTools; + const { validSelections } = getState().componentState ?? {}; + if (!validSelections || isEmpty(validSelections)) { return []; } @@ -307,20 +349,19 @@ export class OptionsListEmbeddable extends Embeddable { if (this.node) { ReactDOM.unmountComponentAtNode(this.node); } + const { Wrapper: OptionsListReduxWrapper } = this.reduxEmbeddableTools; this.node = node; ReactDOM.render( - - + + , node diff --git a/src/plugins/controls/public/control_types/options_list/options_list_embeddable_factory.tsx b/src/plugins/controls/public/control_types/options_list/options_list_embeddable_factory.tsx index c95159a0c9dce..1b49350181906 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_embeddable_factory.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_embeddable_factory.tsx @@ -8,7 +8,9 @@ import deepEqual from 'fast-deep-equal'; +import { lazyLoadReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public'; import { EmbeddableFactoryDefinition, IContainer } from '@kbn/embeddable-plugin/public'; + import { OptionsListEditorOptions } from './options_list_editor_options'; import { ControlEmbeddable, DataControlField, IEditableControlFactory } from '../../types'; import { OptionsListEmbeddableInput, OPTIONS_LIST_CONTROL } from './types'; @@ -27,8 +29,11 @@ export class OptionsListEmbeddableFactory constructor() {} public async create(initialInput: OptionsListEmbeddableInput, parent?: IContainer) { + const reduxEmbeddablePackage = await lazyLoadReduxEmbeddablePackage(); const { OptionsListEmbeddable } = await import('./options_list_embeddable'); - return Promise.resolve(new OptionsListEmbeddable(initialInput, {}, parent)); + return Promise.resolve( + new OptionsListEmbeddable(reduxEmbeddablePackage, initialInput, {}, parent) + ); } public presaveTransformFunction = ( diff --git a/src/plugins/controls/public/control_types/options_list/options_list_popover_component.tsx b/src/plugins/controls/public/control_types/options_list/options_list_popover_component.tsx index aa6b14c73f6e1..9023d40818be6 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_popover_component.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_popover_component.tsx @@ -23,41 +23,39 @@ import { } from '@elastic/eui'; import { isEmpty } from 'lodash'; -import { DataViewField } from '@kbn/data-views-plugin/public'; import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'; -import { OptionsListEmbeddableInput } from './types'; -import { OptionsListStrings } from './options_list_strings'; import { optionsListReducers } from './options_list_reducers'; -import { OptionsListComponentState } from './options_list_component'; +import { OptionsListStrings } from './options_list_strings'; +import { OptionsListReduxState } from './types'; export const OptionsListPopover = ({ - field, - loading, - searchString, - availableOptions, - totalCardinality, - invalidSelections, - updateSearchString, width, + updateSearchString, }: { - field?: DataViewField; - searchString: string; - totalCardinality?: number; width: number; - loading: OptionsListComponentState['loading']; - invalidSelections?: string[]; updateSearchString: (newSearchString: string) => void; - availableOptions: OptionsListComponentState['availableOptions']; }) => { // Redux embeddable container Context const { - useEmbeddableSelector, useEmbeddableDispatch, + useEmbeddableSelector: select, actions: { selectOption, deselectOption, clearSelections, replaceSelection }, - } = useReduxEmbeddableContext(); + } = useReduxEmbeddableContext(); const dispatch = useEmbeddableDispatch(); - const { selectedOptions, singleSelect, title } = useEmbeddableSelector((state) => state); + + // Select current state from Redux using multiple selectors to avoid rerenders. + const invalidSelections = select((state) => state.componentState.invalidSelections); + const totalCardinality = select((state) => state.componentState.totalCardinality); + const availableOptions = select((state) => state.componentState.availableOptions); + const searchString = select((state) => state.componentState.searchString); + const field = select((state) => state.componentState.field); + + const selectedOptions = select((state) => state.explicitInput.selectedOptions); + const singleSelect = select((state) => state.explicitInput.singleSelect); + const title = select((state) => state.explicitInput.title); + + const loading = select((state) => state.output.loading); // track selectedOptions and invalidSelections in sets for more efficient lookup const selectedOptionsSet = useMemo(() => new Set(selectedOptions), [selectedOptions]); diff --git a/src/plugins/controls/public/control_types/options_list/options_list_reducers.ts b/src/plugins/controls/public/control_types/options_list/options_list_reducers.ts index 15f41380e0d72..aa62ad91da53e 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_reducers.ts +++ b/src/plugins/controls/public/control_types/options_list/options_list_reducers.ts @@ -5,53 +5,95 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - import { PayloadAction } from '@reduxjs/toolkit'; import { WritableDraft } from 'immer/dist/types/types-external'; -import { OptionsListEmbeddableInput } from './types'; +import { Filter } from '@kbn/es-query'; + +import { OptionsListField, OptionsListReduxState, OptionsListComponentState } from './types'; export const optionsListReducers = { - deselectOption: ( - state: WritableDraft, - action: PayloadAction - ) => { - if (!state.selectedOptions) return; - const itemIndex = state.selectedOptions.indexOf(action.payload); + deselectOption: (state: WritableDraft, action: PayloadAction) => { + if (!state.explicitInput.selectedOptions) return; + const itemIndex = state.explicitInput.selectedOptions.indexOf(action.payload); if (itemIndex !== -1) { - const newSelections = [...state.selectedOptions]; + const newSelections = [...state.explicitInput.selectedOptions]; newSelections.splice(itemIndex, 1); - state.selectedOptions = newSelections; + state.explicitInput.selectedOptions = newSelections; } }, deselectOptions: ( - state: WritableDraft, + state: WritableDraft, action: PayloadAction ) => { for (const optionToDeselect of action.payload) { - if (!state.selectedOptions) return; - const itemIndex = state.selectedOptions.indexOf(optionToDeselect); + if (!state.explicitInput.selectedOptions) return; + const itemIndex = state.explicitInput.selectedOptions.indexOf(optionToDeselect); if (itemIndex !== -1) { - const newSelections = [...state.selectedOptions]; + const newSelections = [...state.explicitInput.selectedOptions]; newSelections.splice(itemIndex, 1); - state.selectedOptions = newSelections; + state.explicitInput.selectedOptions = newSelections; } } }, - selectOption: ( - state: WritableDraft, - action: PayloadAction - ) => { - if (!state.selectedOptions) state.selectedOptions = []; - state.selectedOptions?.push(action.payload); + setSearchString: (state: WritableDraft, action: PayloadAction) => { + state.componentState.searchString = action.payload; + }, + selectOption: (state: WritableDraft, action: PayloadAction) => { + if (!state.explicitInput.selectedOptions) state.explicitInput.selectedOptions = []; + state.explicitInput.selectedOptions?.push(action.payload); }, replaceSelection: ( - state: WritableDraft, + state: WritableDraft, action: PayloadAction ) => { - state.selectedOptions = [action.payload]; + state.explicitInput.selectedOptions = [action.payload]; + }, + clearSelections: (state: WritableDraft) => { + if (state.explicitInput.selectedOptions) state.explicitInput.selectedOptions = []; }, - clearSelections: (state: WritableDraft) => { - if (state.selectedOptions) state.selectedOptions = []; + clearValidAndInvalidSelections: (state: WritableDraft) => { + state.componentState.invalidSelections = []; + state.componentState.validSelections = []; + }, + setValidAndInvalidSelections: ( + state: WritableDraft, + action: PayloadAction<{ validSelections: string[]; invalidSelections: string[] }> + ) => { + const { invalidSelections, validSelections } = action.payload; + state.componentState.invalidSelections = invalidSelections; + state.componentState.validSelections = validSelections; + }, + setLoading: (state: WritableDraft, action: PayloadAction) => { + state.output.loading = action.payload; + }, + setField: ( + state: WritableDraft, + action: PayloadAction + ) => { + state.componentState.field = action.payload; + }, + updateQueryResults: ( + state: WritableDraft, + action: PayloadAction< + Pick< + OptionsListComponentState, + 'availableOptions' | 'invalidSelections' | 'validSelections' | 'totalCardinality' + > + > + ) => { + state.componentState = { ...(state.componentState ?? {}), ...action.payload }; + }, + publishFilters: ( + state: WritableDraft, + action: PayloadAction + ) => { + state.output.filters = action.payload; + }, + setDataViewId: ( + state: WritableDraft, + action: PayloadAction + ) => { + state.output.dataViewId = action.payload; }, }; diff --git a/src/plugins/controls/public/control_types/options_list/types.ts b/src/plugins/controls/public/control_types/options_list/types.ts index f537cccf3d690..513baa0f1b0cb 100644 --- a/src/plugins/controls/public/control_types/options_list/types.ts +++ b/src/plugins/controls/public/control_types/options_list/types.ts @@ -6,4 +6,29 @@ * Side Public License, v 1. */ +import { ReduxEmbeddableState } from '@kbn/presentation-util-plugin/public'; + +import { ControlOutput } from '../../types'; +import { + OptionsListEmbeddableInput, + OptionsListField, +} from '../../../common/control_types/options_list/types'; + export * from '../../../common/control_types/options_list/types'; + +// Component state is only used by public components. +export interface OptionsListComponentState { + field?: OptionsListField; + totalCardinality?: number; + availableOptions?: string[]; + invalidSelections?: string[]; + validSelections?: string[]; + searchString: string; +} + +// public only - redux embeddable state type +export type OptionsListReduxState = ReduxEmbeddableState< + OptionsListEmbeddableInput, + ControlOutput, + OptionsListComponentState +>; diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider.component.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider.component.tsx index 54b53f25da89f..ca9c1e4b2dd0c 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider.component.tsx +++ b/src/plugins/controls/public/control_types/range_slider/range_slider.component.tsx @@ -6,68 +6,12 @@ * Side Public License, v 1. */ -import React, { FC, useCallback } from 'react'; -import { BehaviorSubject } from 'rxjs'; +import React from 'react'; -import { DataViewField } from '@kbn/data-views-plugin/public'; -import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'; -import { useStateObservable } from '../../hooks/use_state_observable'; import { RangeSliderPopover } from './range_slider_popover'; -import { rangeSliderReducers } from './range_slider_reducers'; -import { RangeSliderEmbeddableInput, RangeValue } from './types'; import './range_slider.scss'; -interface Props { - componentStateSubject: BehaviorSubject; - ignoreValidation: boolean; -} -// Availableoptions and loading state is controled by the embeddable, but is not considered embeddable input. -export interface RangeSliderComponentState { - field?: DataViewField; - fieldFormatter: (value: string) => string; - min: string; - max: string; - loading: boolean; - isInvalid?: boolean; -} - -export const RangeSliderComponent: FC = ({ componentStateSubject, ignoreValidation }) => { - // Redux embeddable Context to get state from Embeddable input - const { - useEmbeddableDispatch, - useEmbeddableSelector, - actions: { selectRange }, - } = useReduxEmbeddableContext(); - const dispatch = useEmbeddableDispatch(); - - // useStateObservable to get component state from Embeddable - const { loading, min, max, fieldFormatter, isInvalid } = - useStateObservable( - componentStateSubject, - componentStateSubject.getValue() - ); - - const { value, id, title } = useEmbeddableSelector((state) => state); - - const onChangeComplete = useCallback( - (range: RangeValue) => { - dispatch(selectRange(range)); - }, - [selectRange, dispatch] - ); - - return ( - - ); +export const RangeSliderComponent = () => { + return ; }; diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable.tsx index 86231c4b05b20..699c0c42ceae7 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable.tsx +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable.tsx @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import { isEmpty } from 'lodash'; import { compareFilters, buildRangeFilter, @@ -17,33 +16,27 @@ import { } from '@kbn/es-query'; import React from 'react'; import ReactDOM from 'react-dom'; +import { isEmpty } from 'lodash'; +import { batch } from 'react-redux'; import { get, isEqual } from 'lodash'; import deepEqual from 'fast-deep-equal'; -import { Subscription, BehaviorSubject, lastValueFrom } from 'rxjs'; +import { Subscription, lastValueFrom } from 'rxjs'; import { debounceTime, distinctUntilChanged, skip, map } from 'rxjs/operators'; -import { - withSuspense, - LazyReduxEmbeddableWrapper, - ReduxEmbeddableWrapperPropsWithChildren, -} from '@kbn/presentation-util-plugin/public'; +import { ReduxEmbeddableTools, ReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public'; import { Embeddable, IContainer } from '@kbn/embeddable-plugin/public'; import { DataView, DataViewField } from '@kbn/data-views-plugin/public'; import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; -import { ControlsDataViewsService } from '../../services/data_views'; -import { ControlsDataService } from '../../services/data'; -import { ControlInput, ControlOutput } from '../..'; import { pluginServices } from '../../services'; +import { ControlInput, ControlOutput } from '../..'; +import { ControlsDataService } from '../../services/data'; +import { ControlsDataViewsService } from '../../services/data_views'; -import { RangeSliderComponent, RangeSliderComponentState } from './range_slider.component'; -import { rangeSliderReducers } from './range_slider_reducers'; import { RangeSliderStrings } from './range_slider_strings'; -import { RangeSliderEmbeddableInput, RANGE_SLIDER_CONTROL } from './types'; - -const RangeSliderReduxWrapper = withSuspense< - ReduxEmbeddableWrapperPropsWithChildren ->(LazyReduxEmbeddableWrapper); +import { RangeSliderComponent } from './range_slider.component'; +import { getDefaultComponentState, rangeSliderReducers } from './range_slider_reducers'; +import { RangeSliderEmbeddableInput, RangeSliderReduxState, RANGE_SLIDER_CONTROL } from './types'; const diffDataFetchProps = ( current?: RangeSliderDataFetchProps, @@ -83,29 +76,30 @@ export class RangeSliderEmbeddable extends Embeddable({ - min: '', - max: '', - loading: true, - fieldFormatter: (value: string) => value, - }); - - constructor(input: RangeSliderEmbeddableInput, output: ControlOutput, parent?: IContainer) { + private reduxEmbeddableTools: ReduxEmbeddableTools< + RangeSliderReduxState, + typeof rangeSliderReducers + >; + + constructor( + reduxEmbeddablePackage: ReduxEmbeddablePackage, + input: RangeSliderEmbeddableInput, + output: ControlOutput, + parent?: IContainer + ) { super(input, output, parent); // get filters for initial output... // Destructure controls services ({ data: this.dataService, dataViews: this.dataViewsService } = pluginServices.getServices()); - this.componentState = { - min: '', - max: '', - loading: true, - fieldFormatter: (value: string) => value, - isInvalid: false, - }; - this.updateComponentState(this.componentState); + this.reduxEmbeddableTools = reduxEmbeddablePackage.createTools< + RangeSliderReduxState, + typeof rangeSliderReducers + >({ + embeddable: this, + reducers: rangeSliderReducers, + initialComponentState: getDefaultComponentState(), + }); this.initialize(); } @@ -158,13 +152,21 @@ export class RangeSliderEmbeddable extends Embeddable => { - const { dataViewId, fieldName } = this.getInput(); + const { + getState, + dispatch, + actions: { setField, setDataViewId }, + } = this.reduxEmbeddableTools; + const { + explicitInput: { dataViewId, fieldName }, + } = getState(); if (!this.dataView || this.dataView.id !== dataViewId) { try { this.dataView = await this.dataViewsService.get(dataViewId); if (!this.dataView) throw new Error(RangeSliderStrings.errors.getDataViewNotFoundError(dataViewId)); + dispatch(setDataViewId(this.dataView.id)); } catch (e) { this.onFatalError(e); } @@ -176,28 +178,19 @@ export class RangeSliderEmbeddable extends Embeddable value, - }); + dispatch(setField(this.field?.toSpec())); } return { dataView: this.dataView, field: this.field! }; }; - private updateComponentState(changes: Partial) { - this.componentState = { - ...this.componentState, - ...changes, - }; - this.componentStateSubject$.next(this.componentState); - } - private runRangeSliderQuery = async () => { - this.updateComponentState({ loading: true }); - this.updateOutput({ loading: true }); + const { + dispatch, + actions: { setLoading, publishFilters, setMinMax }, + } = this.reduxEmbeddableTools; + + dispatch(setLoading(true)); const { dataView, field } = await this.getCurrentDataViewAndField(); if (!dataView || !field) return; @@ -206,8 +199,10 @@ export class RangeSliderEmbeddable extends Embeddable { + dispatch(setLoading(false)); + dispatch(publishFilters([])); + }); throw fieldMissingError(fieldName); } @@ -229,10 +224,12 @@ export class RangeSliderEmbeddable extends Embeddable { const { - value: [selectedMin, selectedMax] = ['', ''], - query, - timeRange, - filters = [], - ignoreParentSettings, - } = this.getInput(); - - const availableMin = this.componentState.min; - const availableMax = this.componentState.max; + dispatch, + getState, + actions: { setLoading, setIsInvalid, setDataViewId, publishFilters }, + } = this.reduxEmbeddableTools; + const { + componentState: { min: availableMin, max: availableMax }, + explicitInput: { + query, + timeRange, + filters = [], + ignoreParentSettings, + value: [selectedMin, selectedMax] = ['', ''], + }, + } = getState(); const hasData = !isEmpty(availableMin) && !isEmpty(availableMax); const hasLowerSelection = !isEmpty(selectedMin); @@ -312,11 +314,12 @@ export class RangeSliderEmbeddable extends Embeddable { + dispatch(setLoading(false)); + dispatch(setIsInvalid(!ignoreParentSettings?.ignoreValidations && hasEitherSelection)); + dispatch(setDataViewId(dataView.id)); + dispatch(publishFilters([])); }); - this.updateOutput({ filters: [], dataViews: dataView && [dataView], loading: false }); return; } @@ -366,18 +369,22 @@ export class RangeSliderEmbeddable extends Embeddable { + dispatch(setLoading(false)); + dispatch(setIsInvalid(true)); + dispatch(setDataViewId(dataView.id)); + dispatch(publishFilters([])); }); return; } } - this.updateComponentState({ loading: false, isInvalid: false }); - this.updateOutput({ filters: [rangeFilter], dataViews: [dataView], loading: false }); + batch(() => { + dispatch(setLoading(false)); + dispatch(setIsInvalid(false)); + dispatch(setDataViewId(dataView.id)); + dispatch(publishFilters([rangeFilter])); + }); }; public reload = () => { @@ -387,25 +394,23 @@ export class RangeSliderEmbeddable extends Embeddable { super.destroy(); this.subscriptions.unsubscribe(); + this.reduxEmbeddableTools.cleanup(); }; public render = (node: HTMLElement) => { if (this.node) { ReactDOM.unmountComponentAtNode(this.node); } + const { Wrapper: RangeSliderReduxWrapper } = this.reduxEmbeddableTools; this.node = node; + const ControlsServicesProvider = pluginServices.getContextProvider(); ReactDOM.render( - - - + + + + + , node ); diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable_factory.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable_factory.tsx index 962937a8dc500..8610624a45daf 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable_factory.tsx +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable_factory.tsx @@ -9,6 +9,8 @@ import deepEqual from 'fast-deep-equal'; import { EmbeddableFactoryDefinition, IContainer } from '@kbn/embeddable-plugin/public'; +import { lazyLoadReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public'; + import { ControlEmbeddable, DataControlField, IEditableControlFactory } from '../../types'; import { RangeSliderEmbeddableInput, RANGE_SLIDER_CONTROL } from './types'; import { @@ -26,8 +28,11 @@ export class RangeSliderEmbeddableFactory constructor() {} public async create(initialInput: RangeSliderEmbeddableInput, parent?: IContainer) { + const reduxEmbeddablePackage = await lazyLoadReduxEmbeddablePackage(); const { RangeSliderEmbeddable } = await import('./range_slider_embeddable'); - return Promise.resolve(new RangeSliderEmbeddable(initialInput, {}, parent)); + return Promise.resolve( + new RangeSliderEmbeddable(reduxEmbeddablePackage, initialInput, {}, parent) + ); } public presaveTransformFunction = ( diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_popover.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_popover.tsx index fce3dbdfe7009..4e674e752edaa 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_popover.tsx +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_popover.tsx @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import React, { FC, useState, useRef } from 'react'; import { EuiFieldNumber, EuiPopoverTitle, @@ -19,39 +18,62 @@ import { EuiFlexItem, EuiDualRange, } from '@elastic/eui'; +import React, { useState, useRef, useEffect } from 'react'; +import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'; import { RangeSliderStrings } from './range_slider_strings'; -import { RangeValue } from './types'; +import { RangeSliderReduxState, RangeValue } from './types'; +import { rangeSliderReducers } from './range_slider_reducers'; +import { pluginServices } from '../../services'; const INVALID_CLASS = 'rangeSliderAnchor__fieldNumber--invalid'; -export interface Props { - id: string; - isInvalid?: boolean; - isLoading?: boolean; - min: string; - max: string; - title?: string; - value: RangeValue; - onChange: (value: RangeValue) => void; - fieldFormatter: (value: string) => string; -} - -export const RangeSliderPopover: FC = ({ - id, - isInvalid, - isLoading, - min, - max, - title, - value, - onChange, - fieldFormatter, -}) => { +export const RangeSliderPopover = () => { + // Controls Services Context + const { dataViews } = pluginServices.getHooks(); + const { get: getDataViewById } = dataViews.useService(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [rangeSliderMin, setRangeSliderMin] = useState(-Infinity); const [rangeSliderMax, setRangeSliderMax] = useState(Infinity); + const [fieldFormatter, setFieldFormatter] = useState(() => (toFormat: string) => toFormat); + const rangeRef = useRef(null); + + const { + useEmbeddableDispatch, + useEmbeddableSelector: select, + actions: { setSelectedRange }, + } = useReduxEmbeddableContext(); + const dispatch = useEmbeddableDispatch(); + + // Select current state from Redux using multiple selectors to avoid rerenders. + const min = select((state) => state.componentState.min); + const max = select((state) => state.componentState.max); + const isInvalid = select((state) => state.componentState.isInvalid); + const fieldSpec = select((state) => state.componentState.field); + + const id = select((state) => state.explicitInput.id); + const value = select((state) => state.explicitInput.value) ?? ['', '']; + const title = select((state) => state.explicitInput.title); + + const isLoading = select((state) => state.output.loading); + const dataViewId = select((state) => state.output.dataViewId); + + // derive field formatter from fieldSpec and dataViewId + useEffect(() => { + (async () => { + if (!dataViewId || !fieldSpec) return; + // dataViews are cached, and should always be available without having to hit ES. + const dataView = await getDataViewById(dataViewId); + setFieldFormatter( + () => + dataView?.getFormatterForField(fieldSpec).getConverterFor('text') ?? + ((toFormat: string) => toFormat) + ); + })(); + }, [fieldSpec, dataViewId, getDataViewById]); + let errorMessage = ''; let helpText = ''; @@ -129,7 +151,12 @@ export const RangeSliderPopover: FC = ({ }`} value={hasLowerBoundSelection ? lowerBoundValue : ''} onChange={(event) => { - onChange([event.target.value, isNaN(upperBoundValue) ? '' : String(upperBoundValue)]); + dispatch( + setSelectedRange([ + event.target.value, + isNaN(upperBoundValue) ? '' : String(upperBoundValue), + ]) + ); }} disabled={isLoading} placeholder={`${hasAvailableRange ? roundedMin : ''}`} @@ -151,7 +178,12 @@ export const RangeSliderPopover: FC = ({ }`} value={hasUpperBoundSelection ? upperBoundValue : ''} onChange={(event) => { - onChange([isNaN(lowerBoundValue) ? '' : String(lowerBoundValue), event.target.value]); + dispatch( + setSelectedRange([ + isNaN(lowerBoundValue) ? '' : String(lowerBoundValue), + event.target.value, + ]) + ); }} disabled={isLoading} placeholder={`${hasAvailableRange ? roundedMax : ''}`} @@ -209,7 +241,7 @@ export const RangeSliderPopover: FC = ({ const updatedUpperBound = typeof newUpperBound === 'number' ? String(newUpperBound) : value[1]; - onChange([updatedLowerBound, updatedUpperBound]); + dispatch(setSelectedRange([updatedLowerBound, updatedUpperBound])); }} value={displayedValue} ticks={hasAvailableRange ? ticks : undefined} @@ -233,7 +265,7 @@ export const RangeSliderPopover: FC = ({ onChange(['', ''])} + onClick={() => dispatch(setSelectedRange(['', '']))} aria-label={RangeSliderStrings.popover.getClearRangeButtonTitle()} data-test-subj="rangeSlider__clearRangeButton" /> diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_reducers.ts b/src/plugins/controls/public/control_types/range_slider/range_slider_reducers.ts index ce7e5ced101a6..5bcf55770998f 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_reducers.ts +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_reducers.ts @@ -6,16 +6,55 @@ * Side Public License, v 1. */ +import { Filter } from '@kbn/es-query'; import { PayloadAction } from '@reduxjs/toolkit'; import { WritableDraft } from 'immer/dist/types/types-external'; +import { FieldSpec } from '@kbn/data-views-plugin/common'; -import { RangeSliderEmbeddableInput, RangeValue } from './types'; +import { RangeSliderReduxState, RangeValue } from './types'; + +export const getDefaultComponentState = (): RangeSliderReduxState['componentState'] => ({ + min: '', + max: '', + isInvalid: false, +}); export const rangeSliderReducers = { - selectRange: ( - state: WritableDraft, + setSelectedRange: ( + state: WritableDraft, action: PayloadAction ) => { - state.value = action.payload; + state.explicitInput.value = action.payload; + }, + setField: ( + state: WritableDraft, + action: PayloadAction + ) => { + state.componentState.field = action.payload; + }, + setDataViewId: ( + state: WritableDraft, + action: PayloadAction + ) => { + state.output.dataViewId = action.payload; + }, + setLoading: (state: WritableDraft, action: PayloadAction) => { + state.output.loading = action.payload; + }, + setMinMax: ( + state: WritableDraft, + action: PayloadAction<{ min: string; max: string }> + ) => { + state.componentState.min = action.payload.min; + state.componentState.max = action.payload.max; + }, + publishFilters: ( + state: WritableDraft, + action: PayloadAction + ) => { + state.output.filters = action.payload; + }, + setIsInvalid: (state: WritableDraft, action: PayloadAction) => { + state.componentState.isInvalid = action.payload; }, }; diff --git a/src/plugins/controls/public/control_types/range_slider/types.ts b/src/plugins/controls/public/control_types/range_slider/types.ts index e9ebe61bf6267..390d91de08b88 100644 --- a/src/plugins/controls/public/control_types/range_slider/types.ts +++ b/src/plugins/controls/public/control_types/range_slider/types.ts @@ -6,5 +6,25 @@ * Side Public License, v 1. */ -export * from '../../../common/control_types/options_list/types'; +import { FieldSpec } from '@kbn/data-views-plugin/common'; +import type { ReduxEmbeddableState } from '@kbn/presentation-util-plugin/public'; + +import { RangeSliderEmbeddableInput } from '../../../common/control_types/range_slider/types'; +import { ControlOutput } from '../../types'; + +// Component state is only used by public components. +export interface RangeSliderComponentState { + field?: FieldSpec; + min: string; + max: string; + isInvalid?: boolean; +} + +// public only - redux embeddable state type +export type RangeSliderReduxState = ReduxEmbeddableState< + RangeSliderEmbeddableInput, + ControlOutput, + RangeSliderComponentState +>; + export * from '../../../common/control_types/range_slider/types'; diff --git a/src/plugins/controls/public/control_types/time_slider/time_slider.tsx b/src/plugins/controls/public/control_types/time_slider/time_slider.tsx index a530a86c5a2d0..0b519406ccf8d 100644 --- a/src/plugins/controls/public/control_types/time_slider/time_slider.tsx +++ b/src/plugins/controls/public/control_types/time_slider/time_slider.tsx @@ -10,18 +10,10 @@ import React, { FC, useCallback, useMemo } from 'react'; import { BehaviorSubject } from 'rxjs'; import { debounce } from 'lodash'; import { useReduxEmbeddableContext } from '@kbn/presentation-util-plugin/public'; -import { useStateObservable } from '../../hooks/use_state_observable'; -import { TimeSliderControlEmbeddableInput } from '../../../common/control_types/time_slider/types'; + import { timeSliderReducers } from './time_slider_reducers'; import { TimeSlider as Component } from './time_slider.component'; - -export interface TimeSliderSubjectState { - range?: { - min?: number; - max?: number; - }; - loading: boolean; -} +import { TimeSliderReduxState, TimeSliderSubjectState } from './types'; interface TimeSliderProps { componentStateSubject: BehaviorSubject; @@ -40,15 +32,14 @@ export const TimeSlider: FC = ({ }) => { const { useEmbeddableDispatch, - useEmbeddableSelector, + useEmbeddableSelector: select, actions: { selectRange }, - } = useReduxEmbeddableContext(); + } = useReduxEmbeddableContext(); const dispatch = useEmbeddableDispatch(); - const { range: availableRange } = useStateObservable( - componentStateSubject, - componentStateSubject.getValue() - ); + const availableRange = select((state) => state.componentState.range); + const value = select((state) => state.explicitInput.value); + const id = select((state) => state.explicitInput.id); const { min, max } = availableRange ? availableRange @@ -57,8 +48,6 @@ export const TimeSlider: FC = ({ max?: number; }); - const { value, id } = useEmbeddableSelector((state) => state); - const dispatchChange = useCallback( (range: [number | null, number | null]) => { dispatch(selectRange(range)); diff --git a/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable.test.ts b/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable.test.ts index b4c1f3ce5c7c0..4db5277a0e2f9 100644 --- a/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable.test.ts +++ b/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable.test.ts @@ -14,6 +14,7 @@ import { stubLogstashDataView } from '@kbn/data-views-plugin/common/data_view.st import { pluginServices } from '../../services'; import { TestScheduler } from 'rxjs/testing'; import { buildRangeFilter } from '@kbn/es-query'; +import { ReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public'; const buildFilter = (range: [number | null, number | null]) => { const filterPieces: Record = {}; @@ -62,6 +63,10 @@ const baseInput: TimeSliderControlEmbeddableInput = { dataViewId: stubLogstashDataView.id!, }; +const mockReduxEmbeddablePackage = { + createTools: () => {}, +} as unknown as ReduxEmbeddablePackage; + describe('Time Slider Control Embeddable', () => { const services = pluginServices.getServices(); const fetchRange = jest.spyOn(services.data, 'fetchFieldRange'); @@ -99,7 +104,7 @@ describe('Time Slider Control Embeddable', () => { b: expectedFilterAfterRangeFetch ? [expectedFilterAfterRangeFetch] : undefined, }; - const embeddable = new TimeSliderControlEmbeddable(input, {}); + const embeddable = new TimeSliderControlEmbeddable(mockReduxEmbeddablePackage, input, {}); const source$ = embeddable.getOutput$().pipe(map((o) => o.filters)); expectObservable(source$).toBe(expectedMarbles, expectedValues); @@ -196,7 +201,11 @@ describe('Time Slider Control Embeddable', () => { b: mockRange, }; - const embeddable = new TimeSliderControlEmbeddable(baseInput, {}); + const embeddable = new TimeSliderControlEmbeddable( + mockReduxEmbeddablePackage, + baseInput, + {} + ); const source$ = embeddable.getComponentState$().pipe(map((state) => state.range)); const { fieldName, ...inputForFetch } = baseInput; @@ -221,7 +230,11 @@ describe('Time Slider Control Embeddable', () => { const mockRange = { min: 1, max: 2 }; fetchRange$.mockReturnValue(cold('a', { a: mockRange })); - const embeddable = new TimeSliderControlEmbeddable(baseInput, {}); + const embeddable = new TimeSliderControlEmbeddable( + mockReduxEmbeddablePackage, + baseInput, + {} + ); const updatedInput = { ...baseInput, fieldName: '@timestamp' }; embeddable.updateInput(updatedInput); @@ -247,7 +260,7 @@ describe('Time Slider Control Embeddable', () => { timeRange: {} as any, }; - new TimeSliderControlEmbeddable(input, {}); + new TimeSliderControlEmbeddable(mockReduxEmbeddablePackage, input, {}); expect(fetchRange$).toBeCalledTimes(1); const args = fetchRange$.mock.calls[0][2]; @@ -274,7 +287,7 @@ describe('Time Slider Control Embeddable', () => { ignoreParentSettings: { ignoreFilters: true, ignoreQuery: true, ignoreTimerange: true }, }; - new TimeSliderControlEmbeddable(input, {}); + new TimeSliderControlEmbeddable(mockReduxEmbeddablePackage, input, {}); expect(fetchRange$).toBeCalledTimes(1); const args = fetchRange$.mock.calls[0][2]; diff --git a/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable.tsx b/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable.tsx index 7900cc193ac24..a4098a72dfe1a 100644 --- a/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable.tsx +++ b/src/plugins/controls/public/control_types/time_slider/time_slider_embeddable.tsx @@ -14,27 +14,20 @@ import deepEqual from 'fast-deep-equal'; import { merge, Subscription, BehaviorSubject, Observable } from 'rxjs'; import { map, distinctUntilChanged, skip, take, mergeMap } from 'rxjs/operators'; -import { - withSuspense, - LazyReduxEmbeddableWrapper, - ReduxEmbeddableWrapperPropsWithChildren, -} from '@kbn/presentation-util-plugin/public'; import { Embeddable, IContainer } from '@kbn/embeddable-plugin/public'; +import { ReduxEmbeddableTools, ReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public'; import { DataView } from '@kbn/data-views-plugin/public'; -import { TimeSliderControlEmbeddableInput } from '../../../common/control_types/time_slider/types'; +import { TimeSliderControlEmbeddableInput } from '../../../common/control_types/time_slider/types'; import { TIME_SLIDER_CONTROL } from '../..'; import { ControlsSettingsService } from '../../services/settings'; import { ControlsDataService } from '../../services/data'; import { ControlOutput } from '../..'; import { pluginServices } from '../../services'; -import { TimeSlider as TimeSliderComponent, TimeSliderSubjectState } from './time_slider'; +import { TimeSlider as TimeSliderComponent } from './time_slider'; import { timeSliderReducers } from './time_slider_reducers'; - -const TimeSliderControlReduxWrapper = withSuspense< - ReduxEmbeddableWrapperPropsWithChildren ->(LazyReduxEmbeddableWrapper); +import { TimeSliderReduxState, TimeSliderSubjectState } from './types'; const diffDataFetchProps = (current?: any, last?: any) => { if (!current || !last) return false; @@ -77,7 +70,17 @@ export class TimeSliderControlEmbeddable extends Embeddable< private getDateFormat: ControlsSettingsService['getDateFormat']; private getTimezone: ControlsSettingsService['getTimezone']; - constructor(input: TimeSliderControlEmbeddableInput, output: ControlOutput, parent?: IContainer) { + private reduxEmbeddableTools: ReduxEmbeddableTools< + TimeSliderReduxState, + typeof timeSliderReducers + >; + + constructor( + reduxEmbeddablePackage: ReduxEmbeddablePackage, + input: TimeSliderControlEmbeddableInput, + output: ControlOutput, + parent?: IContainer + ) { super(input, output, parent); // get filters for initial output... const { @@ -94,6 +97,15 @@ export class TimeSliderControlEmbeddable extends Embeddable< this.internalOutput = {}; + // build redux embeddable tools + this.reduxEmbeddableTools = reduxEmbeddablePackage.createTools< + TimeSliderReduxState, + typeof timeSliderReducers + >({ + embeddable: this, + reducers: timeSliderReducers, + }); + this.initialize(); } @@ -204,7 +216,7 @@ export class TimeSliderControlEmbeddable extends Embeddable< this.updateInternalOutput({ filters: [rangeFilter] }, true); this.updateComponentState({ loading: false }); } else { - this.updateInternalOutput({ filters: undefined, dataViews: [dataView] }, true); + this.updateInternalOutput({ filters: undefined, dataViewId: dataView.id }, true); this.updateComponentState({ loading: false }); } }); @@ -300,8 +312,10 @@ export class TimeSliderControlEmbeddable extends Embeddable< } this.node = node; + const { Wrapper: TimeSliderControlReduxWrapper } = this.reduxEmbeddableTools; + ReactDOM.render( - + , + state: WritableDraft, action: PayloadAction<[number | null, number | null]> ) => { - state.value = action.payload; + state.explicitInput.value = action.payload; }, }; diff --git a/src/plugins/controls/public/control_types/time_slider/types.ts b/src/plugins/controls/public/control_types/time_slider/types.ts new file mode 100644 index 0000000000000..fc147dc3ba959 --- /dev/null +++ b/src/plugins/controls/public/control_types/time_slider/types.ts @@ -0,0 +1,30 @@ +/* + * 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 { ReduxEmbeddableState } from '@kbn/presentation-util-plugin/public'; + +import { ControlOutput } from '../../types'; +import { TimeSliderControlEmbeddableInput } from '../../../common/control_types/time_slider/types'; + +export * from '../../../common/control_types/time_slider/types'; + +// Component state is only used by public components. +export interface TimeSliderSubjectState { + range?: { + min?: number; + max?: number; + }; + loading: boolean; +} + +// public only - redux embeddable state type +export type TimeSliderReduxState = ReduxEmbeddableState< + TimeSliderControlEmbeddableInput, + ControlOutput, + TimeSliderSubjectState +>; diff --git a/src/plugins/controls/public/hooks/use_state_observable.ts b/src/plugins/controls/public/hooks/use_state_observable.ts deleted file mode 100644 index 79decd14ba358..0000000000000 --- a/src/plugins/controls/public/hooks/use_state_observable.ts +++ /dev/null @@ -1,23 +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 { useEffect, useState } from 'react'; -import { Observable } from 'rxjs'; - -export const useStateObservable = ( - stateObservable: Observable, - initialState: T -) => { - const [innerState, setInnerState] = useState(initialState); - useEffect(() => { - const subscription = stateObservable.subscribe((newState) => setInnerState(newState)); - return () => subscription.unsubscribe(); - }, [stateObservable]); - - return innerState; -}; diff --git a/src/plugins/controls/public/services/kibana/options_list.ts b/src/plugins/controls/public/services/kibana/options_list.ts index aa3f28e24ab16..4ff603f1af23f 100644 --- a/src/plugins/controls/public/services/kibana/options_list.ts +++ b/src/plugins/controls/public/services/kibana/options_list.ts @@ -86,7 +86,7 @@ class OptionsListService implements ControlsOptionsListService { ...passThroughProps, filters: esFilters, fieldName: field.name, - fieldSpec: field.toSpec?.(), + fieldSpec: field, textFieldName: (field as OptionsListField).textFieldName, }; }; diff --git a/src/plugins/controls/public/types.ts b/src/plugins/controls/public/types.ts index 71436fa9926e0..79db062136fd0 100644 --- a/src/plugins/controls/public/types.ts +++ b/src/plugins/controls/public/types.ts @@ -16,14 +16,14 @@ import { IEmbeddable, } from '@kbn/embeddable-plugin/public'; import { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import { DataView, DataViewField, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import { DataViewField, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import { ControlInput } from '../common/types'; import { ControlsService } from './services/controls'; export interface CommonControlOutput { filters?: Filter[]; - dataViews?: DataView[]; + dataViewId?: string; } export type ControlOutput = EmbeddableOutput & CommonControlOutput; diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.test.tsx b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.test.tsx index 2f906127b9229..e30b26532b518 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.test.tsx +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.test.tsx @@ -63,6 +63,9 @@ const createDashboardAppStateServices = () => { defaults.dataViews.getDefaultDataView = jest .fn() .mockImplementation(() => Promise.resolve(defaultDataView)); + defaults.dataViews.getDefaultId = jest + .fn() + .mockImplementation(() => Promise.resolve(defaultDataView.id)); defaults.dataViews.getDefault = jest .fn() diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts index 621a2179ca538..5497ae15a8714 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts @@ -14,7 +14,6 @@ import { BehaviorSubject, combineLatest, Observable, Subject } from 'rxjs'; import { DashboardConstants } from '../..'; import { ViewMode } from '../../services/embeddable'; import { useKibana } from '../../services/kibana_react'; -import { DataView } from '../../services/data_views'; import { getNewDashboardTitle } from '../../dashboard_strings'; import { IKbnUrlStateStorage } from '../../services/kibana_utils'; import { setDashboardState, useDashboardDispatch, useDashboardSelector } from '../state'; @@ -255,11 +254,14 @@ export const useDashboardAppState = ({ const dataViewsSubscription = syncDashboardDataViews({ dashboardContainer, dataViews: dashboardBuildContext.dataViews, - onUpdateDataViews: (newDataViews: DataView[]) => { - if (newDataViews.length > 0 && newDataViews[0].id) { - dashboardContainer.controlGroup?.setRelevantDataViewId(newDataViews[0].id); + onUpdateDataViews: async (newDataViewIds: string[]) => { + if (newDataViewIds?.[0]) { + dashboardContainer.controlGroup?.setRelevantDataViewId(newDataViewIds[0]); } - setDashboardAppState((s) => ({ ...s, dataViews: newDataViews })); + + // fetch all data views. These should be cached locally at this time so we will not need to query ES. + const allDataViews = await Promise.all(newDataViewIds.map((id) => dataViews.get(id))); + setDashboardAppState((s) => ({ ...s, dataViews: allDataViews })); }, }); diff --git a/src/plugins/dashboard/public/application/lib/dashboard_control_group.test.ts b/src/plugins/dashboard/public/application/lib/dashboard_control_group.test.ts index e6edf50bbecd1..19cdf4ba2eff2 100644 --- a/src/plugins/dashboard/public/application/lib/dashboard_control_group.test.ts +++ b/src/plugins/dashboard/public/application/lib/dashboard_control_group.test.ts @@ -9,6 +9,7 @@ import { mockControlGroupInput } from '@kbn/controls-plugin/common/mocks'; import { ControlGroupContainer } from '@kbn/controls-plugin/public/control_group/embeddable/control_group_container'; import { Filter } from '@kbn/es-query'; +import { ReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public'; import { combineDashboardFiltersWithControlGroupFilters } from './dashboard_control_group'; jest.mock('@kbn/controls-plugin/public/control_group/embeddable/control_group_container'); @@ -50,7 +51,10 @@ const testFilter3: Filter = { }, }; -const mockControlGroupContainer = new ControlGroupContainer(mockControlGroupInput()); +const mockControlGroupContainer = new ControlGroupContainer( + { getTools: () => {} } as unknown as ReduxEmbeddablePackage, + mockControlGroupInput() +); describe('Test dashboard control group', () => { describe('Combine dashboard filters with control group filters test', () => { diff --git a/src/plugins/dashboard/public/application/lib/sync_dashboard_data_views.ts b/src/plugins/dashboard/public/application/lib/sync_dashboard_data_views.ts index 63cecaa76fb2f..0d55ee26ab6ed 100644 --- a/src/plugins/dashboard/public/application/lib/sync_dashboard_data_views.ts +++ b/src/plugins/dashboard/public/application/lib/sync_dashboard_data_views.ts @@ -6,10 +6,9 @@ * Side Public License, v 1. */ -import { uniqBy } from 'lodash'; import deepEqual from 'fast-deep-equal'; import { Observable, pipe, combineLatest } from 'rxjs'; -import { distinctUntilChanged, switchMap, filter, mapTo, map } from 'rxjs/operators'; +import { distinctUntilChanged, switchMap, filter, map } from 'rxjs/operators'; import { DashboardContainer } from '..'; import { isErrorEmbeddable } from '../../services/embeddable'; @@ -19,7 +18,7 @@ import { DataView } from '../../services/data_views'; interface SyncDashboardDataViewsProps { dashboardContainer: DashboardContainer; dataViews: DataViewsContract; - onUpdateDataViews: (newDataViews: DataView[]) => void; + onUpdateDataViews: (newDataViewIds: string[]) => void; } export const syncDashboardDataViews = ({ @@ -29,54 +28,59 @@ export const syncDashboardDataViews = ({ }: SyncDashboardDataViewsProps) => { const updateDataViewsOperator = pipe( filter((container: DashboardContainer) => !!container && !isErrorEmbeddable(container)), - map((container: DashboardContainer): DataView[] | undefined => { - let panelDataViews: DataView[] = []; + map((container: DashboardContainer): string[] | undefined => { + const panelDataViewIds: Set = new Set(); Object.values(container.getChildIds()).forEach((id) => { const embeddableInstance = container.getChild(id); if (isErrorEmbeddable(embeddableInstance)) return; - const embeddableDataViews = ( + + /** + * TODO - this assumes that all embeddables which communicate data views do so via an `indexPatterns` key on their output. + * This should be replaced with a more generic, interface based method where an embeddable can communicate a data view ID. + */ + const childPanelDataViews = ( embeddableInstance.getOutput() as { indexPatterns: DataView[] } ).indexPatterns; - if (!embeddableDataViews) return; - panelDataViews.push(...embeddableDataViews); + if (!childPanelDataViews) return; + childPanelDataViews.forEach((dataView) => { + if (dataView.id) panelDataViewIds.add(dataView.id); + }); }); if (container.controlGroup) { - panelDataViews.push(...(container.controlGroup.getOutput().dataViews ?? [])); + const controlGroupDataViewIds = container.controlGroup.getOutput().dataViewIds; + controlGroupDataViewIds?.forEach((dataViewId) => panelDataViewIds.add(dataViewId)); } - panelDataViews = uniqBy(panelDataViews, 'id'); /** * If no index patterns have been returned yet, and there is at least one embeddable which * hasn't yet loaded, defer the loading of the default index pattern by returning undefined. */ if ( - panelDataViews.length === 0 && + panelDataViewIds.size === 0 && Object.keys(container.getOutput().embeddableLoaded).length > 0 && Object.values(container.getOutput().embeddableLoaded).some((value) => value === false) ) { return; } - return panelDataViews; + return Array.from(panelDataViewIds); }), - distinctUntilChanged((a, b) => - deepEqual( - a?.map((ip) => ip && ip.id), - b?.map((ip) => ip && ip.id) - ) - ), + distinctUntilChanged((a, b) => deepEqual(a, b)), + // using switchMap for previous task cancellation - switchMap((panelDataViews?: DataView[]) => { + switchMap((allDataViewIds?: string[]) => { return new Observable((observer) => { - if (!panelDataViews) return; - if (panelDataViews.length > 0) { + if (!allDataViewIds) return; + if (allDataViewIds.length > 0) { if (observer.closed) return; - onUpdateDataViews(panelDataViews); + onUpdateDataViews(allDataViewIds); observer.complete(); } else { - dataViews.getDefault().then((defaultDataView) => { + dataViews.getDefaultId().then((defaultDataViewId) => { if (observer.closed) return; - onUpdateDataViews([defaultDataView as DataView]); + if (defaultDataViewId) { + onUpdateDataViews([defaultDataViewId]); + } observer.complete(); }); } @@ -89,6 +93,9 @@ export const syncDashboardDataViews = ({ dataViewSources.push(dashboardContainer.controlGroup.getOutput$()); return combineLatest(dataViewSources) - .pipe(mapTo(dashboardContainer), updateDataViewsOperator) + .pipe( + map(() => dashboardContainer), + updateDataViewsOperator + ) .subscribe(); }; diff --git a/src/plugins/presentation_util/public/components/index.tsx b/src/plugins/presentation_util/public/components/index.tsx index 5a254877399ed..fc5e97ad31736 100644 --- a/src/plugins/presentation_util/public/components/index.tsx +++ b/src/plugins/presentation_util/public/components/index.tsx @@ -8,7 +8,6 @@ import React, { Suspense, ComponentType, ReactElement, Ref } from 'react'; import { EuiLoadingSpinner, EuiErrorBoundary } from '@elastic/eui'; -import { ReduxEmbeddableWrapperType } from './redux_embeddables/redux_embeddable_wrapper'; /** * A HOC which supplies React.Suspense with a fallback component, and a `EuiErrorBoundary` to contain errors. @@ -39,10 +38,6 @@ export const LazySavedObjectSaveModalDashboard = React.lazy( () => import('./saved_object_save_modal_dashboard') ); -export const LazyReduxEmbeddableWrapper = React.lazy( - () => import('./redux_embeddables/redux_embeddable_wrapper') -) as ReduxEmbeddableWrapperType; // Lazy component needs to be casted due to generic type props - export const LazyDataViewPicker = React.lazy(() => import('./data_view_picker/data_view_picker')); export const LazyFieldPicker = React.lazy(() => import('./field_picker/field_picker')); diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/generic_embeddable_store.ts b/src/plugins/presentation_util/public/components/redux_embeddables/generic_embeddable_store.ts deleted file mode 100644 index fe5a647e7e327..0000000000000 --- a/src/plugins/presentation_util/public/components/redux_embeddables/generic_embeddable_store.ts +++ /dev/null @@ -1,42 +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 { configureStore, EnhancedStore } from '@reduxjs/toolkit'; -import { combineReducers, Reducer } from 'redux'; - -export interface InjectReducerProps { - key: string; - asyncReducer: Reducer; -} - -type ManagedEmbeddableReduxStore = EnhancedStore & { - asyncReducers: { [key: string]: Reducer }; - injectReducer: (props: InjectReducerProps) => void; -}; -const embeddablesStore = configureStore({ reducer: (state) => state }); // store with blank reducers - -const managedEmbeddablesStore = embeddablesStore as ManagedEmbeddableReduxStore; -managedEmbeddablesStore.asyncReducers = {}; - -managedEmbeddablesStore.injectReducer = ({ - key, - asyncReducer, -}: InjectReducerProps) => { - if (!managedEmbeddablesStore.asyncReducers[key]) { - managedEmbeddablesStore.asyncReducers[key] = asyncReducer as Reducer; - managedEmbeddablesStore.replaceReducer( - combineReducers({ ...managedEmbeddablesStore.asyncReducers }) - ); - } -}; - -/** - * A managed Redux store which can be used with multiple embeddables at once. When a new embeddable is created at runtime, - * all passed in reducers will be made into a slice, then combined into the store using combineReducers. - */ -export const getManagedEmbeddablesStore = () => managedEmbeddablesStore; diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/index.ts b/src/plugins/presentation_util/public/components/redux_embeddables/index.ts deleted file mode 100644 index 55fb913635e81..0000000000000 --- a/src/plugins/presentation_util/public/components/redux_embeddables/index.ts +++ /dev/null @@ -1,17 +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. - */ - -export { - ReduxEmbeddableContext, - useReduxContainerContext, - useReduxEmbeddableContext, -} from './redux_embeddable_context'; -export type { - ReduxContainerContextServices, - ReduxEmbeddableWrapperPropsWithChildren, -} from './types'; diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx b/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx deleted file mode 100644 index dc98b098f8428..0000000000000 --- a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx +++ /dev/null @@ -1,243 +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 { Provider, TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; -import { SliceCaseReducers, PayloadAction, createSlice } from '@reduxjs/toolkit'; -import React, { PropsWithChildren, useEffect, useMemo, useRef } from 'react'; -import { Draft } from 'immer/dist/types/types-external'; -import { debounceTime, finalize } from 'rxjs/operators'; -import { Filter } from '@kbn/es-query'; -import { isEqual } from 'lodash'; - -import { - IContainer, - IEmbeddable, - EmbeddableInput, - EmbeddableOutput, - isErrorEmbeddable, -} from '@kbn/embeddable-plugin/public'; -import { - ReduxEmbeddableWrapperProps, - ReduxContainerContextServices, - ReduxEmbeddableContextServices, - ReduxEmbeddableWrapperPropsWithChildren, -} from './types'; -import { getManagedEmbeddablesStore } from './generic_embeddable_store'; -import { ReduxEmbeddableContext, useReduxEmbeddableContext } from './redux_embeddable_context'; - -type InputWithFilters = Partial & { filters: Filter[] }; -export const stateContainsFilters = ( - state: Partial -): state is InputWithFilters => { - if ((state as InputWithFilters).filters) return true; - return false; -}; - -export const cleanFiltersForSerialize = (filters: Filter[]): Filter[] => { - return filters.map((filter) => { - if (filter.meta.value) delete filter.meta.value; - return filter; - }); -}; - -const getDefaultProps = (): Required< - Pick, 'diffInput'> -> => ({ - diffInput: (a, b) => { - const differences: Partial = {}; - const allKeys = [...Object.keys(a), ...Object.keys(b)] as Array; - allKeys.forEach((key) => { - if (!isEqual(a[key], b[key])) differences[key] = a[key]; - }); - return differences; - }, -}); - -const embeddableIsContainer = ( - embeddable: IEmbeddable -): embeddable is IContainer => embeddable.isContainer; - -export const getExplicitInput = ( - embeddable: IEmbeddable -): InputType => { - const root = embeddable.getRoot(); - if (!embeddableIsContainer(embeddable) && embeddableIsContainer(root)) { - return (root.getInput().panels[embeddable.id]?.explicitInput ?? - embeddable.getInput()) as InputType; - } - return embeddable.getInput() as InputType; -}; - -/** - * Place this wrapper around the react component when rendering an embeddable to automatically set up - * redux for use with the embeddable via the supplied reducers. Any child components can then use ReduxEmbeddableContext - * or ReduxContainerContext to interface with the state of the embeddable. - */ -export const ReduxEmbeddableWrapper = ( - props: ReduxEmbeddableWrapperPropsWithChildren -) => { - const { embeddable, reducers, diffInput } = useMemo( - () => ({ ...getDefaultProps(), ...props }), - [props] - ); - - const containerActions: ReduxContainerContextServices['containerActions'] | undefined = - useMemo(() => { - if (embeddableIsContainer(embeddable)) { - return { - untilEmbeddableLoaded: embeddable.untilEmbeddableLoaded.bind(embeddable), - updateInputForChild: embeddable.updateInputForChild.bind(embeddable), - removeEmbeddable: embeddable.removeEmbeddable.bind(embeddable), - addNewEmbeddable: embeddable.addNewEmbeddable.bind(embeddable), - replaceEmbeddable: embeddable.replaceEmbeddable.bind(embeddable), - }; - } - return; - }, [embeddable]); - - const ReduxEmbeddableStoreProvider = useMemo( - () => - ({ children }: PropsWithChildren<{}>) => - {children}, - [] - ); - - const reduxEmbeddableContext: ReduxEmbeddableContextServices | ReduxContainerContextServices = - useMemo(() => { - const key = `${embeddable.type}_${embeddable.id}`; - const store = getManagedEmbeddablesStore(); - - const initialState = getExplicitInput(embeddable); - if (stateContainsFilters(initialState)) { - initialState.filters = cleanFiltersForSerialize(initialState.filters); - } - - // A generic reducer used to update redux state when the embeddable input changes - const updateEmbeddableReduxState = ( - state: Draft, - action: PayloadAction> - ) => { - return { ...state, ...action.payload }; - }; - - // A generic reducer used to clear redux state when the embeddable is destroyed - const clearEmbeddableReduxState = () => { - return undefined; - }; - - const slice = createSlice>({ - initialState, - name: key, - reducers: { ...reducers, updateEmbeddableReduxState, clearEmbeddableReduxState }, - }); - - if (store.asyncReducers[key]) { - // if the store already has reducers set up for this embeddable type & id, update the existing state. - const updateExistingState = (slice.actions as ReduxEmbeddableContextServices['actions']) - .updateEmbeddableReduxState; - store.dispatch(updateExistingState(initialState)); - } else { - store.injectReducer({ - key, - asyncReducer: slice.reducer, - }); - } - - const useEmbeddableSelector: TypedUseSelectorHook = () => - useSelector((state: ReturnType) => state[key]); - - return { - useEmbeddableDispatch: () => useDispatch(), - useEmbeddableSelector, - ReduxEmbeddableStoreProvider, - actions: slice.actions as ReduxEmbeddableContextServices['actions'], - containerActions, - }; - }, [reducers, embeddable, containerActions, ReduxEmbeddableStoreProvider]); - - return ( - - - - {props.children} - - - - ); -}; - -interface ReduxEmbeddableSyncProps { - diffInput: (a: InputType, b: InputType) => Partial; - embeddable: IEmbeddable; -} - -/** - * This component uses the context from the embeddable wrapper to set up a generic two-way binding between the embeddable input and - * the redux store. a custom diffInput function can be provided, this function should always prioritize input A over input B. - */ -const ReduxEmbeddableSync = ({ - embeddable, - diffInput, - children, -}: PropsWithChildren>) => { - const { - useEmbeddableSelector, - useEmbeddableDispatch, - actions: { updateEmbeddableReduxState, clearEmbeddableReduxState }, - } = useReduxEmbeddableContext(); - - const dispatch = useEmbeddableDispatch(); - const currentState = useEmbeddableSelector((state) => state); - const stateRef = useRef(currentState); - const destroyedRef = useRef(false); - - useEffect(() => { - // When Embeddable Input changes, push differences to redux. - const inputSubscription = embeddable - .getInput$() - .pipe( - finalize(() => { - // empty redux store, when embeddable is destroyed. - destroyedRef.current = true; - dispatch(clearEmbeddableReduxState(undefined)); - }), - debounceTime(0) - ) // debounce input changes to ensure that when many updates are made in one render the latest wins out - .subscribe(() => { - const differences = diffInput(getExplicitInput(embeddable), stateRef.current); - if (differences && Object.keys(differences).length > 0) { - if (stateContainsFilters(differences)) { - differences.filters = cleanFiltersForSerialize(differences.filters); - } - dispatch(updateEmbeddableReduxState(differences)); - } - }); - return () => inputSubscription.unsubscribe(); - }, [diffInput, dispatch, embeddable, updateEmbeddableReduxState, clearEmbeddableReduxState]); - - useEffect(() => { - if (isErrorEmbeddable(embeddable) || destroyedRef.current) return; - // When redux state changes, push differences to Embeddable Input. - stateRef.current = currentState; - const differences = diffInput(currentState, getExplicitInput(embeddable)); - if (differences && Object.keys(differences).length > 0) { - if (stateContainsFilters(differences)) { - differences.filters = cleanFiltersForSerialize(differences.filters); - } - embeddable.updateInput(differences); - } - }, [currentState, diffInput, embeddable]); - - return <>{children}; -}; - -// required for dynamic import using React.lazy() -// eslint-disable-next-line import/no-default-export -export default ReduxEmbeddableWrapper; - -export type ReduxEmbeddableWrapperType = typeof ReduxEmbeddableWrapper; diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/types.ts b/src/plugins/presentation_util/public/components/redux_embeddables/types.ts deleted file mode 100644 index 818676da881b4..0000000000000 --- a/src/plugins/presentation_util/public/components/redux_embeddables/types.ts +++ /dev/null @@ -1,72 +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 { - ActionCreatorWithPayload, - AnyAction, - CaseReducer, - Dispatch, - PayloadAction, -} from '@reduxjs/toolkit'; -import { PropsWithChildren } from 'react'; -import { TypedUseSelectorHook } from 'react-redux'; -import { - EmbeddableInput, - EmbeddableOutput, - IContainer, - IEmbeddable, -} from '@kbn/embeddable-plugin/public'; - -export interface GenericEmbeddableReducers { - /** - * PayloadAction of type any is strategic here because we want to allow payloads of any shape in generic reducers. - * This type will be overridden to remove any and be type safe when returned by ReduxEmbeddableContextServices. - */ - [key: string]: CaseReducer>; -} - -export interface ReduxEmbeddableWrapperProps { - embeddable: IEmbeddable; - reducers: GenericEmbeddableReducers; - diffInput?: (a: InputType, b: InputType) => Partial; -} - -export type ReduxEmbeddableWrapperPropsWithChildren< - InputType extends EmbeddableInput = EmbeddableInput -> = PropsWithChildren>; - -/** - * This context allows components underneath the redux embeddable wrapper to get access to the actions, selector, dispatch, and containerActions. - */ -export interface ReduxEmbeddableContextServices< - InputType extends EmbeddableInput = EmbeddableInput, - ReducerType extends GenericEmbeddableReducers = GenericEmbeddableReducers -> { - actions: { - [Property in keyof ReducerType]: ActionCreatorWithPayload< - Parameters[1]['payload'] - >; - } & { updateEmbeddableReduxState: ActionCreatorWithPayload> }; - ReduxEmbeddableStoreProvider: React.FC>; - useEmbeddableSelector: TypedUseSelectorHook; - useEmbeddableDispatch: () => Dispatch; -} - -export type ReduxContainerContextServices< - InputType extends EmbeddableInput = EmbeddableInput, - ReducerType extends GenericEmbeddableReducers = GenericEmbeddableReducers -> = ReduxEmbeddableContextServices & { - containerActions: Pick< - IContainer, - | 'untilEmbeddableLoaded' - | 'removeEmbeddable' - | 'addNewEmbeddable' - | 'updateInputForChild' - | 'replaceEmbeddable' - >; -}; diff --git a/src/plugins/presentation_util/public/index.ts b/src/plugins/presentation_util/public/index.ts index 707924de6b7f6..afc5c45438e6b 100644 --- a/src/plugins/presentation_util/public/index.ts +++ b/src/plugins/presentation_util/public/index.ts @@ -43,9 +43,17 @@ export { withSuspense, LazyDataViewPicker, LazyFieldPicker, - LazyReduxEmbeddableWrapper, } from './components'; +export { + useReduxContainerContext, + useReduxEmbeddableContext, + lazyLoadReduxEmbeddablePackage, + type ReduxEmbeddableState, + type ReduxEmbeddableTools, + type ReduxEmbeddablePackage, +} from './redux_embeddables'; + export * from './components/types'; export type { QuickButtonProps } from './components/solution_toolbar'; @@ -61,14 +69,6 @@ export { SolutionToolbarPopover, } from './components/solution_toolbar'; -export { - ReduxEmbeddableContext, - useReduxContainerContext, - useReduxEmbeddableContext, - type ReduxContainerContextServices, - type ReduxEmbeddableWrapperPropsWithChildren, -} from './components/redux_embeddables'; - /** * Register a set of Expression Functions with the Presentation Utility ExpressionInput. This allows * the Monaco Editor to understand the functions and their arguments. diff --git a/src/plugins/presentation_util/public/redux_embeddables/clean_redux_embeddable_state.ts b/src/plugins/presentation_util/public/redux_embeddables/clean_redux_embeddable_state.ts new file mode 100644 index 0000000000000..ebb282285f173 --- /dev/null +++ b/src/plugins/presentation_util/public/redux_embeddables/clean_redux_embeddable_state.ts @@ -0,0 +1,50 @@ +/* + * 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 { Filter } from '@kbn/es-query'; +import { EmbeddableInput } from '@kbn/embeddable-plugin/public'; + +import { ReduxEmbeddableState } from './types'; + +// TODO: Make filters serializable so we don't need special treatment for them. +type InputWithFilters = Partial & { filters: Filter[] }; +export const stateContainsFilters = ( + state: Partial +): state is InputWithFilters => { + if ((state as InputWithFilters).filters && (state as InputWithFilters).filters.length > 0) + return true; + return false; +}; + +export const cleanFiltersForSerialize = (filters: Filter[]): Filter[] => { + return filters.map((filter) => { + if (filter.meta.value) delete filter.meta.value; + return filter; + }); +}; + +export const cleanInputForRedux = < + ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState +>( + explicitInput: ReduxEmbeddableStateType['explicitInput'] +) => { + if (stateContainsFilters(explicitInput)) { + explicitInput.filters = cleanFiltersForSerialize(explicitInput.filters); + } + return explicitInput; +}; + +export const cleanStateForRedux = < + ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState +>( + state: ReduxEmbeddableStateType +) => { + // clean explicit input + state.explicitInput = cleanInputForRedux(state.explicitInput); + return state; +}; diff --git a/src/plugins/presentation_util/public/redux_embeddables/create_redux_embeddable_tools.tsx b/src/plugins/presentation_util/public/redux_embeddables/create_redux_embeddable_tools.tsx new file mode 100644 index 0000000000000..f6400d1424ffe --- /dev/null +++ b/src/plugins/presentation_util/public/redux_embeddables/create_redux_embeddable_tools.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 { + configureStore, + createSlice, + Draft, + PayloadAction, + SliceCaseReducers, +} from '@reduxjs/toolkit'; +import React, { ReactNode, PropsWithChildren } from 'react'; +import { Provider, TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; + +import { IEmbeddable } from '@kbn/embeddable-plugin/public'; + +import { + EmbeddableReducers, + ReduxEmbeddableTools, + ReduxEmbeddableContext, + ReduxEmbeddableState, + ReduxEmbeddableSyncSettings, +} from './types'; +import { syncReduxEmbeddable } from './sync_redux_embeddable'; +import { EmbeddableReduxContext } from './use_redux_embeddable_context'; +import { cleanStateForRedux } from './clean_redux_embeddable_state'; + +export const createReduxEmbeddableTools = < + ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState, + ReducerType extends EmbeddableReducers = EmbeddableReducers +>({ + reducers, + embeddable, + syncSettings, + initialComponentState, +}: { + embeddable: IEmbeddable< + ReduxEmbeddableStateType['explicitInput'], + ReduxEmbeddableStateType['output'] + >; + initialComponentState?: ReduxEmbeddableStateType['componentState']; + syncSettings?: ReduxEmbeddableSyncSettings; + reducers: ReducerType; +}): ReduxEmbeddableTools => { + // Additional generic reducers to aid in embeddable syncing + const genericReducers = { + updateEmbeddableReduxInput: ( + state: Draft, + action: PayloadAction> + ) => { + state.explicitInput = { ...state.explicitInput, ...action.payload }; + }, + updateEmbeddableReduxOutput: ( + state: Draft, + action: PayloadAction> + ) => { + state.output = { ...state.output, ...action.payload }; + }, + }; + + // create initial state from Embeddable + let initialState: ReduxEmbeddableStateType = { + output: embeddable.getOutput(), + componentState: initialComponentState ?? {}, + explicitInput: embeddable.getExplicitInput(), + } as ReduxEmbeddableStateType; + + initialState = cleanStateForRedux(initialState); + + // create slice out of reducers and embeddable initial state. + const slice = createSlice>({ + initialState, + name: `${embeddable.type}_${embeddable.id}`, + reducers: { ...reducers, ...genericReducers }, + }); + + const store = configureStore({ reducer: slice.reducer }); + + // create the context which will wrap this embeddable's react components to allow access to update and read from the store. + const context = { + actions: slice.actions as ReduxEmbeddableContext< + ReduxEmbeddableStateType, + typeof reducers + >['actions'], + useEmbeddableDispatch: () => useDispatch(), + useEmbeddableSelector: useSelector as TypedUseSelectorHook, + + // populate container actions for embeddables which are Containers + containerActions: embeddable.getIsContainer() + ? { + untilEmbeddableLoaded: embeddable.untilEmbeddableLoaded.bind(embeddable), + updateInputForChild: embeddable.updateInputForChild.bind(embeddable), + removeEmbeddable: embeddable.removeEmbeddable.bind(embeddable), + addNewEmbeddable: embeddable.addNewEmbeddable.bind(embeddable), + replaceEmbeddable: embeddable.replaceEmbeddable.bind(embeddable), + } + : undefined, + }; + + const Wrapper: React.FC> = ({ children }: { children?: ReactNode }) => ( + + {children} + + ); + + const stopReduxEmbeddableSync = syncReduxEmbeddable({ + actions: context.actions, + settings: syncSettings, + embeddable, + store, + }); + + // return redux tools for the embeddable class to use. + return { + Wrapper, + actions: context.actions, + dispatch: store.dispatch, + getState: store.getState, + cleanup: () => stopReduxEmbeddableSync?.(), + }; +}; diff --git a/src/plugins/presentation_util/public/redux_embeddables/index.ts b/src/plugins/presentation_util/public/redux_embeddables/index.ts new file mode 100644 index 0000000000000..f73322a7ca260 --- /dev/null +++ b/src/plugins/presentation_util/public/redux_embeddables/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 { ReduxEmbeddablePackage } from './types'; + +export { + useReduxContainerContext, + useReduxEmbeddableContext, +} from './use_redux_embeddable_context'; + +export type { ReduxEmbeddableState, ReduxEmbeddableTools, ReduxEmbeddablePackage } from './types'; + +export const lazyLoadReduxEmbeddablePackage = async (): Promise => { + const { createReduxEmbeddableTools } = await import('./create_redux_embeddable_tools'); + return { + createTools: createReduxEmbeddableTools, + }; +}; diff --git a/src/plugins/presentation_util/public/redux_embeddables/sync_redux_embeddable.ts b/src/plugins/presentation_util/public/redux_embeddables/sync_redux_embeddable.ts new file mode 100644 index 0000000000000..5f4dd20818ba2 --- /dev/null +++ b/src/plugins/presentation_util/public/redux_embeddables/sync_redux_embeddable.ts @@ -0,0 +1,105 @@ +/* + * 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 deepEqual from 'fast-deep-equal'; + +import { IEmbeddable } from '@kbn/embeddable-plugin/public'; +import { EnhancedStore } from '@reduxjs/toolkit'; +import { ReduxEmbeddableContext, ReduxEmbeddableState, ReduxEmbeddableSyncSettings } from './types'; +import { cleanInputForRedux } from './clean_redux_embeddable_state'; + +type Writeable = { -readonly [P in keyof T]: T[P] }; + +export const syncReduxEmbeddable = < + ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState +>({ + store, + actions, + settings, + embeddable, +}: { + settings?: ReduxEmbeddableSyncSettings; + store: EnhancedStore; + embeddable: IEmbeddable< + ReduxEmbeddableStateType['explicitInput'], + ReduxEmbeddableStateType['output'] + >; + actions: ReduxEmbeddableContext['actions']; +}) => { + if (settings?.disableSync) { + return; + } + + let embeddableToReduxInProgress = false; + let reduxToEmbeddableInProgress = false; + + const { isInputEqual: inputEqualityCheck, isOutputEqual: outputEqualityCheck } = settings ?? {}; + const inputEqual = ( + inputA: Partial, + inputB: Partial + ) => (inputEqualityCheck ? inputEqualityCheck(inputA, inputB) : deepEqual(inputA, inputB)); + const outputEqual = ( + outputA: ReduxEmbeddableStateType['output'], + outputB: ReduxEmbeddableStateType['output'] + ) => (outputEqualityCheck ? outputEqualityCheck(outputA, outputB) : deepEqual(outputA, outputB)); + + // when the redux store changes, diff, and push updates to the embeddable input or to the output. + const unsubscribeFromStore = store.subscribe(() => { + if (embeddableToReduxInProgress) return; + reduxToEmbeddableInProgress = true; + const reduxState = store.getState(); + if (!inputEqual(reduxState.explicitInput, embeddable.getExplicitInput())) { + embeddable.updateInput(reduxState.explicitInput); + } + if (!outputEqual(reduxState.output, embeddable.getOutput())) { + // updating output is usually not accessible from outside of the embeddable. + // This redux sync utility is meant to be used from inside the embeddable, so we need to workaround the typescript error via casting. + ( + embeddable as unknown as { + updateOutput: (newOutput: ReduxEmbeddableStateType['output']) => void; + } + ).updateOutput(reduxState.output); + } + reduxToEmbeddableInProgress = false; + }); + + // when the embeddable input changes, diff and dispatch to the redux store + const inputSubscription = embeddable.getInput$().subscribe(() => { + if (reduxToEmbeddableInProgress) return; + embeddableToReduxInProgress = true; + const { explicitInput: reduxExplicitInput } = store.getState(); + + // store only explicit input in the store + const embeddableExplictInput = embeddable.getExplicitInput() as Writeable< + ReduxEmbeddableStateType['explicitInput'] + >; + + if (!inputEqual(reduxExplicitInput, embeddableExplictInput)) { + store.dispatch( + actions.updateEmbeddableReduxInput(cleanInputForRedux(embeddableExplictInput)) + ); + } + embeddableToReduxInProgress = false; + }); + + // when the embeddable output changes, diff and dispatch to the redux store + const outputSubscription = embeddable.getOutput$().subscribe((embeddableOutput) => { + if (reduxToEmbeddableInProgress) return; + embeddableToReduxInProgress = true; + const reduxState = store.getState(); + if (!outputEqual(reduxState.output, embeddableOutput)) { + store.dispatch(actions.updateEmbeddableReduxOutput(embeddableOutput)); + } + embeddableToReduxInProgress = false; + }); + return () => { + unsubscribeFromStore(); + inputSubscription.unsubscribe(); + outputSubscription.unsubscribe(); + }; +}; diff --git a/src/plugins/presentation_util/public/redux_embeddables/types.ts b/src/plugins/presentation_util/public/redux_embeddables/types.ts new file mode 100644 index 0000000000000..eaa53bba24548 --- /dev/null +++ b/src/plugins/presentation_util/public/redux_embeddables/types.ts @@ -0,0 +1,123 @@ +/* + * 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 { + Dispatch, + AnyAction, + CaseReducer, + PayloadAction, + ActionCreatorWithPayload, + EnhancedStore, +} from '@reduxjs/toolkit'; +import { TypedUseSelectorHook } from 'react-redux'; +import { EmbeddableInput, EmbeddableOutput, IContainer } from '@kbn/embeddable-plugin/public'; +import { PropsWithChildren } from 'react'; + +export interface ReduxEmbeddableSyncSettings< + ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState +> { + disableSync: boolean; + isInputEqual?: ( + a: Partial, + b: Partial + ) => boolean; + isOutputEqual?: ( + a: Partial, + b: Partial + ) => boolean; +} + +/** + * The package type is lazily exported from presentation_util and should contain all methods needed to use the redux embeddable tools. + */ +export interface ReduxEmbeddablePackage { + createTools: typeof import('./create_redux_embeddable_tools')['createReduxEmbeddableTools']; +} + +/** + * The return type from setupReduxEmbeddable. Contains a wrapper which comes with the store provider and provides the context to react components, + * but also returns the context object to allow the embeddable class to interact with the redux store. + */ +export interface ReduxEmbeddableTools< + ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState, + ReducerType extends EmbeddableReducers = EmbeddableReducers +> { + cleanup: () => void; + Wrapper: React.FC>; + dispatch: EnhancedStore['dispatch']; + getState: EnhancedStore['getState']; + actions: ReduxEmbeddableContext['actions']; +} + +/** + * The Embeddable Redux store should contain Input, Output and State. Input is serialized and used to create the embeddable, + * Output is used as a communication layer for state that the Embeddable creates, and State is used to store ephemeral state which needs + * to be communicated between an embeddable and its inner React components. + */ +export interface ReduxEmbeddableState< + InputType extends EmbeddableInput = EmbeddableInput, + OutputType extends EmbeddableOutput = EmbeddableOutput, + StateType extends unknown = unknown +> { + explicitInput: InputType; + output: OutputType; + componentState: StateType; +} + +/** + * The Embeddable Reducers are the shape of the Raw reducers which will be passed into createSlice. These will be used to populate the actions + * object which will be returned in the ReduxEmbeddableContext. + */ +export interface EmbeddableReducers< + ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState +> { + /** + * PayloadAction of type any is strategic here because we want to allow payloads of any shape in generic reducers. + * This type will be overridden to remove any and be type safe when returned by setupReduxEmbeddable. + */ + [key: string]: CaseReducer>; +} + +/** + * This context type contains the actions, selector, and dispatch that embeddables need to interact with their state. This + * should be passed down from the embeddable class, to its react components by wrapping the embeddable's render output in ReduxEmbeddableContext. + */ +export interface ReduxEmbeddableContext< + ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState, + ReducerType extends EmbeddableReducers = EmbeddableReducers +> { + actions: { + [Property in keyof ReducerType]: ActionCreatorWithPayload< + Parameters[1]['payload'] + >; + } & { + // Generic reducers to interact with embeddable Input and Output. + updateEmbeddableReduxInput: ActionCreatorWithPayload< + Partial + >; + updateEmbeddableReduxOutput: ActionCreatorWithPayload< + Partial + >; + }; + useEmbeddableSelector: TypedUseSelectorHook; + useEmbeddableDispatch: () => Dispatch; +} + +export type ReduxContainerContext< + ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState, + ReducerType extends EmbeddableReducers = EmbeddableReducers +> = ReduxEmbeddableContext & { + containerActions: Pick< + IContainer, + | 'untilEmbeddableLoaded' + | 'removeEmbeddable' + | 'addNewEmbeddable' + | 'updateInputForChild' + | 'replaceEmbeddable' + >; +}; diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_context.ts b/src/plugins/presentation_util/public/redux_embeddables/use_redux_embeddable_context.ts similarity index 51% rename from src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_context.ts rename to src/plugins/presentation_util/public/redux_embeddables/use_redux_embeddable_context.ts index 4e0a7886d8795..4454d090a9566 100644 --- a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_context.ts +++ b/src/plugins/presentation_util/public/redux_embeddables/use_redux_embeddable_context.ts @@ -7,21 +7,19 @@ */ import { createContext, useContext } from 'react'; -import type { ContainerInput, EmbeddableInput } from '@kbn/embeddable-plugin/public'; import type { - GenericEmbeddableReducers, - ReduxContainerContextServices, - ReduxEmbeddableContextServices, + ReduxEmbeddableState, + ReduxContainerContext, + ReduxEmbeddableContext, + EmbeddableReducers, } from './types'; /** * When creating the context, a generic EmbeddableInput as placeholder is used. This will later be cast to - * the generic type passed in by the useReduxEmbeddableContext or useReduxContainerContext hooks + * the type passed in by the useReduxEmbeddableContext or useReduxContainerContext hooks **/ -export const ReduxEmbeddableContext = createContext< - | ReduxEmbeddableContextServices - | ReduxContainerContextServices - | null +export const EmbeddableReduxContext = createContext< + ReduxEmbeddableContext | ReduxContainerContext | null >(null); /** @@ -31,17 +29,17 @@ export const ReduxEmbeddableContext = createContext< * types of your reducers. use `typeof MyReducers` here to retain them. */ export const useReduxEmbeddableContext = < - InputType extends EmbeddableInput = EmbeddableInput, - ReducerType extends GenericEmbeddableReducers = GenericEmbeddableReducers ->(): ReduxEmbeddableContextServices => { - const context = useContext>( - ReduxEmbeddableContext as unknown as React.Context< - ReduxEmbeddableContextServices + ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState, + ReducerType extends EmbeddableReducers = EmbeddableReducers +>(): ReduxEmbeddableContext => { + const context = useContext>( + EmbeddableReduxContext as unknown as React.Context< + ReduxEmbeddableContext > ); if (context == null) { throw new Error( - 'useReduxEmbeddableContext must be used inside the useReduxEmbeddableContextProvider.' + 'useReduxEmbeddableContext must be used inside the ReduxEmbeddableWrapper from build_redux_embeddable_context.' ); } @@ -56,17 +54,17 @@ export const useReduxEmbeddableContext = < * key which contains most of the commonly used container operations */ export const useReduxContainerContext = < - InputType extends ContainerInput = ContainerInput, - ReducerType extends GenericEmbeddableReducers = GenericEmbeddableReducers ->(): ReduxContainerContextServices => { - const context = useContext>( - ReduxEmbeddableContext as unknown as React.Context< - ReduxContainerContextServices + ReduxEmbeddableStateType extends ReduxEmbeddableState = ReduxEmbeddableState, + ReducerType extends EmbeddableReducers = EmbeddableReducers +>(): ReduxContainerContext => { + const context = useContext>( + EmbeddableReduxContext as unknown as React.Context< + ReduxContainerContext > ); if (context == null) { throw new Error( - 'useReduxEmbeddableContext must be used inside the useReduxEmbeddableContextProvider.' + 'useReduxEmbeddableContext must be used inside the ReduxEmbeddableWrapper from build_redux_embeddable_context.' ); } return context!; From fdd4020353bcdc7e213f612b837859724ec6bfba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Fri, 29 Jul 2022 16:49:19 +0200 Subject: [PATCH 08/13] =?UTF-8?q?[Guided=20onboarding]=C2=A0Added=20the=20?= =?UTF-8?q?team=20to=20the=20codeowners=20file=20(#137591)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Guided onboarding] Added the team handle to the codeowners file * [Guided onboarding] Also the landing page in the home plugin --- .github/CODEOWNERS | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d1f72aadb1882..bf5c575a9fc5c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -154,6 +154,10 @@ /x-pack/plugins/apm/public/components/app/rum_dashboard @elastic/uptime /x-pack/test/apm_api_integration/tests/csm/ @elastic/uptime +# Observability onboarding tour +/x-pack/plugins/observability/public/components/shared/tour @elastic/platform-onboarding +/x-pack/test/functional/apps/infra/tour.ts @elastic/platform-onboarding + ### END Observability Plugins # Presentation @@ -606,6 +610,10 @@ x-pack/test/threat_intelligence_cypress @elastic/protections-experience /x-pack/plugins/cloud_security_posture/ @elastic/kibana-cloud-security-posture /x-pack/plugins/security_solution/public/cloud_security_posture @elastic/kibana-cloud-security-posture +# Security Solution onboarding tour +/x-pack/plugins/security_solution/public/common/components/guided_onboarding @elastic/platform-onboarding +/x-pack/plugins/security_solution/cypress/integration/guided_onboarding @elastic/platform-onboarding + # Design (at the bottom for specificity of SASS files) **/*.scss @elastic/kibana-design #CC# /packages/kbn-ui-framework/ @elastic/kibana-design @@ -666,6 +674,9 @@ x-pack/test/threat_intelligence_cypress @elastic/protections-experience /src/plugins/home/server/*.ts @elastic/shared-ux /src/plugins/home/server/services/ @elastic/shared-ux +# Landing page for guided onboarding in Home plugin +/src/plugins/home/public/application/components/guided_onboarding @elastic/platform-onboarding + ### Overview Plugin and Packages /src/plugins/kibana_overview/ @elastic/shared-ux From 57ab388060342f3f614a01705ab3c794cb2da0f8 Mon Sep 17 00:00:00 2001 From: Sergi Massaneda Date: Fri, 29 Jul 2022 17:15:30 +0200 Subject: [PATCH 09/13] add navigation test coverage (#137564) --- .../integration/header/navigation.spec.ts | 80 +++++++++++++++++-- .../cypress/screens/kibana_navigation.ts | 3 + .../cypress/screens/security_header.ts | 28 ++++++- .../cypress/urls/navigation.ts | 72 +++++++++-------- 4 files changed, 141 insertions(+), 42 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/header/navigation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/header/navigation.spec.ts index 4926a2ea1d85b..8019242ce6bdf 100644 --- a/x-pack/plugins/security_solution/cypress/integration/header/navigation.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/header/navigation.spec.ts @@ -19,6 +19,16 @@ import { EXCEPTIONS, USERS, DETECTION_RESPONSE, + DASHBOARDS, + CSP_DASHBOARD, + KUBERNETES, + THREAT_INTELLIGENCE, + BLOCKLIST, + CSP_BENCHMARKS, + CSP_FINDINGS, + POLICIES, + EXPLORE, + MANAGE, } from '../../screens/security_header'; import { login, visit } from '../../tasks/login'; @@ -42,6 +52,13 @@ import { DETECTION_RESPONSE_URL, EXPLORE_URL, MANAGE_URL, + CSP_DASHBOARD_URL, + KUBERNETES_URL, + THREAT_INTELLIGENCE_URL, + BLOCKLIST_URL, + CSP_BENCHMARKS_URL, + CSP_FINDINGS_URL, + POLICIES_URL, } from '../../urls/navigation'; import { openKibanaNavigation, @@ -54,6 +71,7 @@ import { MANAGE_PAGE, DASHBOARDS_PAGE, TIMELINES_PAGE, + FINDINGS_PAGE, } from '../../screens/kibana_navigation'; before(() => { @@ -65,6 +83,11 @@ describe('top-level navigation common to all pages in the Security app', () => { visit(TIMELINES_URL); }); + it('navigates to the Dashboards landing page', () => { + navigateFromHeaderTo(DASHBOARDS); + cy.url().should('include', DASHBOARDS_URL); + }); + it('navigates to the Overview page', () => { navigateFromHeaderTo(OVERVIEW); cy.url().should('include', OVERVIEW_URL); @@ -75,11 +98,36 @@ describe('top-level navigation common to all pages in the Security app', () => { cy.url().should('include', DETECTION_RESPONSE_URL); }); + it('navigates to the Kubernetes page', () => { + navigateFromHeaderTo(KUBERNETES); + cy.url().should('include', KUBERNETES_URL); + }); + + it('navigates to the CSP dashboard page', () => { + navigateFromHeaderTo(CSP_DASHBOARD); + cy.url().should('include', CSP_DASHBOARD_URL); + }); + it('navigates to the Alerts page', () => { navigateFromHeaderTo(ALERTS); cy.url().should('include', ALERTS_URL); }); + it('navigates to the Findings page', () => { + navigateFromHeaderTo(CSP_FINDINGS); + cy.url().should('include', CSP_FINDINGS_URL); + }); + + it('navigates to the Timelines page', () => { + navigateFromHeaderTo(TIMELINES); + cy.url().should('include', TIMELINES_URL); + }); + + it('navigates to the Explore landing page', () => { + navigateFromHeaderTo(EXPLORE); + cy.url().should('include', EXPLORE_URL); + }); + it('navigates to the Hosts page', () => { navigateFromHeaderTo(HOSTS); cy.url().should('include', HOSTS_URL); @@ -95,6 +143,11 @@ describe('top-level navigation common to all pages in the Security app', () => { cy.url().should('include', USERS_URL); }); + it('navigates to the Threat Intelligence page', () => { + navigateFromHeaderTo(THREAT_INTELLIGENCE); + cy.url().should('include', THREAT_INTELLIGENCE_URL); + }); + it('navigates to the Rules page', () => { navigateFromHeaderTo(RULES); cy.url().should('include', DETECTIONS_RULE_MANAGEMENT_URL); @@ -105,20 +158,24 @@ describe('top-level navigation common to all pages in the Security app', () => { cy.url().should('include', EXCEPTIONS_URL); }); - it('navigates to the Timelines page', () => { - navigateFromHeaderTo(TIMELINES); - cy.url().should('include', TIMELINES_URL); - }); - it('navigates to the Cases page', () => { navigateFromHeaderTo(CASES); cy.url().should('include', CASES_URL); }); + it('navigates to the Manage landing page', () => { + navigateFromHeaderTo(MANAGE); + cy.url().should('include', MANAGE_URL); + }); + it('navigates to the Endpoints page', () => { navigateFromHeaderTo(ENDPOINTS); cy.url().should('include', ENDPOINTS_URL); }); + it('navigates to the Policies page', () => { + navigateFromHeaderTo(POLICIES); + cy.url().should('include', POLICIES_URL); + }); it('navigates to the Trusted Apps page', () => { navigateFromHeaderTo(TRUSTED_APPS); cy.url().should('include', TRUSTED_APPS_URL); @@ -127,6 +184,14 @@ describe('top-level navigation common to all pages in the Security app', () => { navigateFromHeaderTo(EVENT_FILTERS); cy.url().should('include', EVENT_FILTERS_URL); }); + it('navigates to the Blocklist page', () => { + navigateFromHeaderTo(BLOCKLIST); + cy.url().should('include', BLOCKLIST_URL); + }); + it('navigates to the CSP Benchmarks page', () => { + navigateFromHeaderTo(CSP_BENCHMARKS); + cy.url().should('include', CSP_BENCHMARKS_URL); + }); }); describe('Kibana navigation to all pages in the Security app ', () => { @@ -146,6 +211,11 @@ describe('Kibana navigation to all pages in the Security app ', () => { cy.url().should('include', ALERTS_URL); }); + it('navigates to the Findings page', () => { + navigateFromKibanaCollapsibleTo(FINDINGS_PAGE); + cy.url().should('include', CSP_FINDINGS_URL); + }); + it('navigates to the Timelines page', () => { navigateFromKibanaCollapsibleTo(TIMELINES_PAGE); cy.url().should('include', TIMELINES_URL); diff --git a/x-pack/plugins/security_solution/cypress/screens/kibana_navigation.ts b/x-pack/plugins/security_solution/cypress/screens/kibana_navigation.ts index 0947706b09798..9a862e914b7a5 100644 --- a/x-pack/plugins/security_solution/cypress/screens/kibana_navigation.ts +++ b/x-pack/plugins/security_solution/cypress/screens/kibana_navigation.ts @@ -11,6 +11,9 @@ export const DASHBOARDS_PAGE = export const ALERTS_PAGE = '[data-test-subj="collapsibleNavGroup-securitySolution"] [title="Alerts"]'; +export const FINDINGS_PAGE = + '[data-test-subj="collapsibleNavGroup-securitySolution"] [title="Findings"]'; + export const TIMELINES_PAGE = '[data-test-subj="collapsibleNavGroup-securitySolution"] [title="Timelines"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/security_header.ts b/x-pack/plugins/security_solution/cypress/screens/security_header.ts index 55c1b37923572..e967023a63647 100644 --- a/x-pack/plugins/security_solution/cypress/screens/security_header.ts +++ b/x-pack/plugins/security_solution/cypress/screens/security_header.ts @@ -10,6 +10,8 @@ export const DASHBOARDS = '[data-test-subj="groupedNavItemLink-dashboards"]'; export const ALERTS = '[data-test-subj="groupedNavItemLink-alerts"]'; +export const CSP_FINDINGS = '[data-test-subj="groupedNavItemLink-cloud_security_posture-findings"]'; + export const CASES = '[data-test-subj="groupedNavItemLink-cases"]'; export const TIMELINES = '[data-test-subj="groupedNavItemLink-timelines"]'; @@ -23,18 +25,32 @@ export const OVERVIEW = '[data-test-subj="groupedNavPanelLink-overview"]'; export const DETECTION_RESPONSE = '[data-test-subj="groupedNavPanelLink-detection_response"]'; +export const KUBERNETES = '[data-test-subj="groupedNavPanelLink-kubernetes"]'; + +export const CSP_DASHBOARD = + '[data-test-subj="groupedNavPanelLink-cloud_security_posture-dashboard"]'; + export const HOSTS = '[data-test-subj="groupedNavPanelLink-hosts"]'; export const ENDPOINTS = '[data-test-subj="groupedNavPanelLink-endpoints"]'; +export const POLICIES = '[data-test-subj="groupedNavPanelLink-policy"]'; + export const TRUSTED_APPS = '[data-test-subj="groupedNavPanelLink-trusted_apps"]'; export const EVENT_FILTERS = '[data-test-subj="groupedNavPanelLink-event_filters"]'; +export const BLOCKLIST = '[data-test-subj="groupedNavPanelLink-blocklist"]'; + +export const CSP_BENCHMARKS = + '[data-test-subj="groupedNavPanelLink-cloud_security_posture-benchmarks"]'; + export const NETWORK = '[data-test-subj="groupedNavPanelLink-network"]'; export const USERS = '[data-test-subj="groupedNavPanelLink-users"]'; +export const THREAT_INTELLIGENCE = '[data-test-subj="groupedNavPanelLink-threat-intelligence"]'; + export const RULES = '[data-test-subj="groupedNavPanelLink-rules"]'; export const EXCEPTIONS = '[data-test-subj="groupedNavPanelLink-exceptions"]'; @@ -53,13 +69,16 @@ export const openNavigationPanelFor = (page: string) => { let panel; switch (page) { case OVERVIEW: - case DETECTION_RESPONSE: { + case DETECTION_RESPONSE: + case KUBERNETES: + case CSP_DASHBOARD: { panel = DASHBOARDS; break; } case HOSTS: case NETWORK: - case USERS: { + case USERS: + case THREAT_INTELLIGENCE: { panel = EXPLORE; break; } @@ -67,7 +86,10 @@ export const openNavigationPanelFor = (page: string) => { case TRUSTED_APPS: case EVENT_FILTERS: case RULES: - case EXCEPTIONS: { + case POLICIES: + case EXCEPTIONS: + case BLOCKLIST: + case CSP_BENCHMARKS: { panel = MANAGE; break; } diff --git a/x-pack/plugins/security_solution/cypress/urls/navigation.ts b/x-pack/plugins/security_solution/cypress/urls/navigation.ts index 5a655f5306488..430bd9716dc55 100644 --- a/x-pack/plugins/security_solution/cypress/urls/navigation.ts +++ b/x-pack/plugins/security_solution/cypress/urls/navigation.ts @@ -5,57 +5,61 @@ * 2.0. */ -export const ALERTS_URL = 'app/security/alerts'; -export const DETECTIONS_RULE_MANAGEMENT_URL = 'app/security/rules'; -export const ruleDetailsUrl = (ruleId: string) => `app/security/rules/id/${ruleId}`; -export const detectionsRuleDetailsUrl = (ruleId: string) => - `app/security/detections/rules/id/${ruleId}`; - -export const ruleEditUrl = (ruleId: string) => `${ruleDetailsUrl(ruleId)}/edit`; -export const detectionRuleEditUrl = (ruleId: string) => `${detectionsRuleDetailsUrl(ruleId)}/edit`; - -export const CASES_URL = '/app/security/cases'; -export const DETECTIONS = '/app/siem#/detections'; -export const SECURITY_DETECTIONS_URL = '/app/security/detections'; -export const SECURITY_DETECTIONS_RULES_URL = '/app/security/detections/rules'; -export const SECURITY_DETECTIONS_RULES_CREATION_URL = '/app/security/detections/rules/create'; - -export const EXCEPTIONS_URL = 'app/security/exceptions'; - -export const HOSTS_URL = '/app/security/hosts/allHosts'; - -export const hostDetailsUrl = (hostName: string) => - `/app/security/hosts/${hostName}/authentications`; - -export const USERS_URL = '/app/security/users/allUsers'; - -export const DETECTIONS_RESPONSE_URL = '/app/security/detection_response'; - -export const userDetailsUrl = (userName: string) => `/app/security/users/${userName}/allUsers`; - -export const HOSTS_PAGE_TAB_URLS = { - allHosts: '/app/security/hosts/allHosts', - anomalies: '/app/security/hosts/anomalies', - events: '/app/security/hosts/events', - uncommonProcesses: '/app/security/hosts/uncommonProcesses', -}; export const KIBANA_HOME = '/app/home#/'; export const KIBANA_SAVED_OBJECTS = '/app/management/kibana/objects'; + +export const ALERTS_URL = 'app/security/alerts'; export const ENDPOINTS_URL = '/app/security/administration/endpoints'; +export const POLICIES_URL = '/app/security/administration/policy'; +export const USERS_URL = '/app/security/users/allUsers'; +export const DETECTIONS_RESPONSE_URL = '/app/security/detection_response'; export const TRUSTED_APPS_URL = '/app/security/administration/trusted_apps'; export const EVENT_FILTERS_URL = '/app/security/administration/event_filters'; +export const BLOCKLIST_URL = '/app/security/administration/blocklist'; +export const CSP_BENCHMARKS_URL = '/app/security/cloud_security_posture/benchmarks'; export const NETWORK_URL = '/app/security/network'; export const OVERVIEW_URL = '/app/security/overview'; export const DASHBOARDS_URL = '/app/security/dashboards'; export const DETECTION_RESPONSE_URL = '/app/security/detection_response'; +export const KUBERNETES_URL = '/app/security/kubernetes'; +export const CSP_DASHBOARD_URL = '/app/security/cloud_security_posture/dashboard'; +export const THREAT_INTELLIGENCE_URL = '/app/security/threat_intelligence'; export const EXPLORE_URL = '/app/security/explore'; export const MANAGE_URL = '/app/security/manage'; export const RULE_CREATION = 'app/security/rules/create'; export const TIMELINES_URL = '/app/security/timelines'; export const TIMELINE_TEMPLATES_URL = '/app/security/timelines/template'; +export const CASES_URL = '/app/security/cases'; +export const EXCEPTIONS_URL = 'app/security/exceptions'; +export const HOSTS_URL = '/app/security/hosts/allHosts'; +export const CSP_FINDINGS_URL = 'app/security/cloud_security_posture/findings'; +export const DETECTIONS_RULE_MANAGEMENT_URL = 'app/security/rules'; +export const DETECTIONS = '/app/siem#/detections'; +export const SECURITY_DETECTIONS_URL = '/app/security/detections'; +export const SECURITY_DETECTIONS_RULES_URL = '/app/security/detections/rules'; +export const SECURITY_DETECTIONS_RULES_CREATION_URL = '/app/security/detections/rules/create'; + +export const HOSTS_PAGE_TAB_URLS = { + allHosts: '/app/security/hosts/allHosts', + anomalies: '/app/security/hosts/anomalies', + events: '/app/security/hosts/events', + uncommonProcesses: '/app/security/hosts/uncommonProcesses', +}; export const LOGOUT_URL = '/logout'; export const DISCOVER_WITH_FILTER_URL = "/app/discover#/?_g=(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:now%2Fd,to:now%2Fd))&_a=(columns:!(),filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:security-solution-default,key:host.name,negate:!f,params:(query:test-host),type:phrase),query:(match_phrase:(host.name:test-host)))),index:security-solution-default,interval:auto,query:(language:kuery,query:''),sort:!(!('@timestamp',desc)))"; export const DISCOVER_WITH_PINNED_FILTER_URL = "/app/discover#/?_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!f,index:security-solution-default,key:host.name,negate:!f,params:(query:test-host),type:phrase),query:(match_phrase:(host.name:test-host)))),refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))&_a=(columns:!(),filters:!(),index:security-solution-default,interval:auto,query:(language:kuery,query:''),sort:!(!('@timestamp',desc)))"; + +export const ruleDetailsUrl = (ruleId: string) => `app/security/rules/id/${ruleId}`; +export const detectionsRuleDetailsUrl = (ruleId: string) => + `app/security/detections/rules/id/${ruleId}`; + +export const ruleEditUrl = (ruleId: string) => `${ruleDetailsUrl(ruleId)}/edit`; +export const detectionRuleEditUrl = (ruleId: string) => `${detectionsRuleDetailsUrl(ruleId)}/edit`; + +export const hostDetailsUrl = (hostName: string) => + `/app/security/hosts/${hostName}/authentications`; + +export const userDetailsUrl = (userName: string) => `/app/security/users/${userName}/allUsers`; From 4ef97446e9bf5d20080eebd218a25d421b4ffb2a Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Fri, 29 Jul 2022 11:16:11 -0400 Subject: [PATCH 10/13] [Fleet] Fix agent reassign bulk selection and package icon alignment (#137586) --- .../agent_reassign_policy_modal/index.tsx | 30 ++++++++++++++----- .../components/agent_policy_package_badge.tsx | 7 ----- .../fleet/public/components/package_icon.tsx | 12 +++++++- 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_reassign_policy_modal/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_reassign_policy_modal/index.tsx index 6136bd413e6e9..aee476723f1a9 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_reassign_policy_modal/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_reassign_policy_modal/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState } from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiConfirmModal, @@ -44,14 +44,25 @@ export const AgentReassignAgentPolicyModal: React.FunctionComponent = ({ perPage: SO_SEARCH_LIMIT, }); - const agentPolicies = agentPoliciesRequest.data - ? agentPoliciesRequest.data.items.filter((policy) => policy && !policy.is_managed) - : []; + const agentPolicies = useMemo( + () => + agentPoliciesRequest.data + ? agentPoliciesRequest.data.items.filter((policy) => policy && !policy.is_managed) + : [], + [agentPoliciesRequest.data] + ); const [selectedAgentPolicyId, setSelectedAgentPolicyId] = useState( - isSingleAgent ? (agents[0] as Agent).policy_id : agentPolicies[0]?.id ?? undefined + isSingleAgent ? (agents[0] as Agent).policy_id : undefined ); + // Select the first policy if not policy is selected + useEffect(() => { + if (!selectedAgentPolicyId && agentPolicies.length) { + setSelectedAgentPolicyId(agentPolicies[0]?.id); + } + }, [selectedAgentPolicyId, agentPolicies]); + const [isSubmitting, setIsSubmitting] = useState(false); async function onSubmit() { try { @@ -146,8 +157,13 @@ export const AgentReassignAgentPolicyModal: React.FunctionComponent = ({ - - {selectedAgentPolicyId && } + + + {selectedAgentPolicyId && ( + + )} + + ); }; diff --git a/x-pack/plugins/fleet/public/components/agent_policy_package_badge.tsx b/x-pack/plugins/fleet/public/components/agent_policy_package_badge.tsx index 09e7a30fa5f83..8a378855d1eb2 100644 --- a/x-pack/plugins/fleet/public/components/agent_policy_package_badge.tsx +++ b/x-pack/plugins/fleet/public/components/agent_policy_package_badge.tsx @@ -33,13 +33,6 @@ export const AgentPolicyPackageBadge: React.FunctionComponent = ({ packageName={pkgName} version={pkgVersion || ''} tryApi={pkgVersion !== undefined} - style={ - // when a custom SVG is used the logo is rendered with - // this collides with some EuiText (+img) CSS from the EuiIcon component - // which makes the button large, wide, and poorly layed out - // override those styles until the bug is fixed or we find a better approach - { margin: 'unset', width: '16px' } - } /> {pkgTitle} diff --git a/x-pack/plugins/fleet/public/components/package_icon.tsx b/x-pack/plugins/fleet/public/components/package_icon.tsx index 7d106852324fe..242c97da0896c 100644 --- a/x-pack/plugins/fleet/public/components/package_icon.tsx +++ b/x-pack/plugins/fleet/public/components/package_icon.tsx @@ -6,18 +6,28 @@ */ import React from 'react'; +import styled from 'styled-components'; import type { EuiIconProps } from '@elastic/eui'; import { EuiIcon } from '@elastic/eui'; import type { UsePackageIconType } from '../hooks'; import { usePackageIconType } from '../hooks'; +// when a custom SVG is used the logo is rendered with +// this collides with some EuiText (+img) CSS from the EuiIcon component +// which makes the button large, wide, and poorly layed out +// override those styles until the bug is fixed or we find a better approach +const Icon = styled(EuiIcon)` + width: '16px'; + margin: unset !important; +`; + export const PackageIcon: React.FunctionComponent< UsePackageIconType & Omit > = ({ packageName, integrationName, version, icons, tryApi, ...euiIconProps }) => { const iconType = usePackageIconType({ packageName, integrationName, version, icons, tryApi }); // @ts-expect-error loading="lazy" is not supported by EuiIcon - return ; + return ; }; export const CardIcon: React.FunctionComponent> = ( From 4912f3b8dc3c705c2c5c6469b826875f8abf1b08 Mon Sep 17 00:00:00 2001 From: Jatin Kathuria Date: Fri, 29 Jul 2022 17:43:02 +0200 Subject: [PATCH 11/13] fix add field popover visibility issue (#137588) --- .../add_data_provider_popover.test.tsx | 55 +++++++++++++++++++ .../add_data_provider_popover.tsx | 12 ++-- 2 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__tests__/add_data_provider_popover.test.tsx diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__tests__/add_data_provider_popover.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__tests__/add_data_provider_popover.test.tsx new file mode 100644 index 0000000000000..b08ac9109f3f4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__tests__/add_data_provider_popover.test.tsx @@ -0,0 +1,55 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { AddDataProviderPopover } from '../add_data_provider_popover'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { TestProvidersComponent } from '../../../../../common/mock/test_providers'; +import { mockBrowserFields } from '../../../../../common/containers/source/mock'; + +const TEST_ID = { + ADD_FIELD: 'addField', +}; + +const clickOnAddField = () => { + const addFieldButton = screen.getByTestId(TEST_ID.ADD_FIELD); + fireEvent.click(addFieldButton); +}; + +describe('Testing AddDataProviderPopover', () => { + it('Test Popover is visible', async () => { + render( + + + + ); + + clickOnAddField(); + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeVisible(); + }); + }); + + it('Test Popover goes away after clicking again on add field', async () => { + render( + + + + ); + + clickOnAddField(); + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeVisible(); + }); + + clickOnAddField(); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx index b2a1b091ded83..f6482217fb3bc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx @@ -44,9 +44,9 @@ const AddDataProviderPopoverComponent: React.FC = ( pick(['dataProviders', 'timelineType'], getTimeline(state, timelineId)) ); - const handleOpenPopover = useCallback( - () => setIsAddFilterPopoverOpen(true), - [setIsAddFilterPopoverOpen] + const togglePopoverState = useCallback( + () => setIsAddFilterPopoverOpen(!isAddFilterPopoverOpen), + [setIsAddFilterPopoverOpen, isAddFilterPopoverOpen] ); const handleClosePopover = useCallback( @@ -152,7 +152,7 @@ const AddDataProviderPopoverComponent: React.FC = ( return ( = ( return ( {`+ ${ADD_FIELD_LABEL}`} ); - }, [handleOpenPopover, timelineType]); + }, [togglePopoverState, timelineType]); const content = useMemo(() => { if (timelineType === TimelineType.template) { From bbe4a89e75274165ddae72765419638331483d21 Mon Sep 17 00:00:00 2001 From: Dominique Clarke Date: Fri, 29 Jul 2022 12:41:35 -0400 Subject: [PATCH 12/13] [Synthetics] adjust private locations form (#137526) * adjust locations form * fix tests --- .../plugins/synthetics/e2e/journeys/index.ts | 2 +- .../private_locations/manage_locations.ts | 2 +- .../public/hooks/use_form_wrapped.tsx | 3 +- .../manage_locations/add_location_flyout.tsx | 94 +++++++++++-------- .../manage_locations/location_form.tsx | 40 +------- .../manage_locations/locations_table.tsx | 2 +- .../manage_locations_flyout.tsx | 8 +- 7 files changed, 70 insertions(+), 81 deletions(-) diff --git a/x-pack/plugins/synthetics/e2e/journeys/index.ts b/x-pack/plugins/synthetics/e2e/journeys/index.ts index a84301b8fbc2c..a73ec34ef1d29 100644 --- a/x-pack/plugins/synthetics/e2e/journeys/index.ts +++ b/x-pack/plugins/synthetics/e2e/journeys/index.ts @@ -16,4 +16,4 @@ export * from './monitor_management.journey'; export * from './monitor_management_enablement.journey'; export * from './monitor_details'; export * from './locations'; -// export * from './private_locations'; +export * from './private_locations'; diff --git a/x-pack/plugins/synthetics/e2e/journeys/private_locations/manage_locations.ts b/x-pack/plugins/synthetics/e2e/journeys/private_locations/manage_locations.ts index 80b429ccbc1c2..6d318d2adfc90 100644 --- a/x-pack/plugins/synthetics/e2e/journeys/private_locations/manage_locations.ts +++ b/x-pack/plugins/synthetics/e2e/journeys/private_locations/manage_locations.ts @@ -33,7 +33,7 @@ journey('ManagePrivateLocation', async ({ page, params: { kibanaUrl } }) => { await page.click('button:has-text("Private locations")'); }); - step('Click text=Add two agent policies', async () => { + step('Add two agent policies', async () => { await page.click('text=Create agent policy'); await addAgentPolicy('Fleet test policy'); diff --git a/x-pack/plugins/synthetics/public/hooks/use_form_wrapped.tsx b/x-pack/plugins/synthetics/public/hooks/use_form_wrapped.tsx index 5e80eaee2c031..d02b835a29d44 100644 --- a/x-pack/plugins/synthetics/public/hooks/use_form_wrapped.tsx +++ b/x-pack/plugins/synthetics/public/hooks/use_form_wrapped.tsx @@ -11,7 +11,7 @@ import { FieldValues, useForm, UseFormProps } from 'react-hook-form'; export function useFormWrapped( props?: UseFormProps ) { - const { register, ...restOfForm } = useForm(props); + const { register, ...restOfForm } = useForm(props); const euiRegister = useCallback( (name, ...registerArgs) => { @@ -19,6 +19,7 @@ export function useFormWrapped void; privateLocations: PrivateLocation[]; }) => { - const [formData, setFormData] = useState>(); + const form = useFormWrapped({ + mode: 'onSubmit', + reValidateMode: 'onChange', + shouldFocusError: true, + defaultValues: { + label: '', + agentPolicyId: '', + id: '', + geo: { + lat: 0, + lon: 0, + }, + concurrentMonitors: 1, + }, + }); + + const { handleSubmit } = form; const { canReadAgentPolicies } = usePrivateLocationPermissions(); @@ -46,45 +64,39 @@ export const AddLocationFlyout = ({ }; return ( - - - -

{ADD_PRIVATE_LOCATION}

-
-
- - {!canReadAgentPolicies && ( - -

{NEED_FLEET_READ_AGENT_POLICIES_PERMISSION}

-
- )} + + + + +

{ADD_PRIVATE_LOCATION}

+
+
+ + {!canReadAgentPolicies && ( + +

{NEED_FLEET_READ_AGENT_POLICIES_PERMISSION}

+
+ )} - - -
- - - - - {CANCEL_LABEL} - - - - { - if (formData) { - onSubmit(formData as PrivateLocation); - closeFlyout(); - } - }} - > - {SAVE_LABEL} - - - - -
+ + +
+ + + + + {CANCEL_LABEL} + + + + + {SAVE_LABEL} + + + + +
+ ); }; @@ -100,5 +112,5 @@ const CANCEL_LABEL = i18n.translate('xpack.synthetics.monitorManagement.cancelLa }); const SAVE_LABEL = i18n.translate('xpack.synthetics.monitorManagement.saveLabel', { - defaultMessage: 'save', + defaultMessage: 'Save', }); diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/manage_locations/location_form.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/manage_locations/location_form.tsx index 4acd67da8bb83..8985a5e0a09ee 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/manage_locations/location_form.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/manage_locations/location_form.tsx @@ -4,55 +4,25 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useEffect } from 'react'; +import React from 'react'; import { EuiFieldText, EuiForm, EuiFormRow, EuiSpacer } from '@elastic/eui'; import { useSelector } from 'react-redux'; import { i18n } from '@kbn/i18n'; +import { useFormContext, useFormState } from 'react-hook-form'; import { AgentPolicyNeeded } from './agent_policy_needed'; -import { useFormWrapped } from '../../../../hooks/use_form_wrapped'; import { PrivateLocation } from '../../../../../common/runtime_types'; import { PolicyHostsField } from './policy_hosts'; import { selectAgentPolicies } from '../../../state/private_locations'; export const LocationForm = ({ - setFormData, privateLocations, }: { - setFormData: (val: Partial) => void; onDiscard?: () => void; privateLocations: PrivateLocation[]; }) => { const { data } = useSelector(selectAgentPolicies); - - const { - getValues, - control, - register, - formState: { errors }, - } = useFormWrapped({ - mode: 'onTouched', - reValidateMode: 'onChange', - shouldFocusError: true, - defaultValues: { - label: '', - agentPolicyId: '', - id: '', - geo: { - lat: 0, - lon: 0, - }, - concurrentMonitors: 1, - }, - }); - - const label = getValues('label'); - const agentPolicyId = getValues('agentPolicyId'); - - useEffect(() => { - if (label && agentPolicyId) { - setFormData({ label, agentPolicyId }); - } - }, [label, agentPolicyId, setFormData]); + const { control, register } = useFormContext(); + const { errors } = useFormState(); return ( <> @@ -67,7 +37,7 @@ export const LocationForm = ({ { const dispatch = useDispatch(); @@ -69,6 +70,11 @@ export const ManageLocationsFlyout = () => { dispatch(getServiceLocations()); }; + const handleSubmit = (formData: PrivateLocation) => { + onSubmit(formData); + setIsAddingNew(false); + }; + const flyout = ( @@ -127,7 +133,7 @@ export const ManageLocationsFlyout = () => { {isAddingNew ? ( ) : null} From 405c9041e7bf8f26ad4f776517fe1b3415056235 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Fri, 29 Jul 2022 10:00:32 -0700 Subject: [PATCH 13/13] Clean up error state of Ingest Pipelines drag and drop list items. (#137244) --- .../__jest__/processors/grok.test.ts | 8 +-- .../__jest__/processors/processor.helpers.tsx | 1 - .../drag_and_drop_text_list.scss | 16 +++--- .../drag_and_drop_text_list.tsx | 52 ++++++------------- 4 files changed, 29 insertions(+), 48 deletions(-) diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/grok.test.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/grok.test.ts index b488e69ed84da..01b4cedf82536 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/grok.test.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/grok.test.ts @@ -62,16 +62,16 @@ describe('Processor: Grok', () => { const { actions: { saveNewProcessor }, form, - exists, } = testBed; // Click submit button with only the type defined await saveNewProcessor(); // Expect form error as "field" is a required parameter - expect(form.getErrorsMessages()).toEqual(['A field value is required.']); - // Patterns field is also required; it uses EuiDraggable and only shows an error icon when invalid - expect(exists('droppableList.errorIcon')).toBe(true); + expect(form.getErrorsMessages()).toEqual([ + 'A field value is required.', // "Field" input + 'A value is required.', // First input in "Patterns" list + ]); }); test('saves with default parameter values', async () => { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx index ade7077e3fd10..c5996073fd3ec 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx @@ -182,7 +182,6 @@ type TestSubject = | 'copyFromInput' | 'trimSwitch.input' | 'droppableList.addButton' - | 'droppableList.errorIcon' | 'droppableList.input-0' | 'droppableList.input-1' | 'droppableList.input-2'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/field_components/drag_and_drop_text_list.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/field_components/drag_and_drop_text_list.scss index 2f563d86a6d4a..bee4f053b4af5 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/field_components/drag_and_drop_text_list.scss +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/field_components/drag_and_drop_text_list.scss @@ -4,18 +4,10 @@ padding: $euiSizeM; } - &__grabIcon { - margin-right: $euiSizeS; - } - &__removeButton { margin-left: $euiSizeS; } - &__errorIcon { - margin-left: -$euiSizeXL; - } - &__item { background-color: $euiColorLightestShade; padding-top: $euiSizeS; @@ -26,3 +18,11 @@ margin-bottom: $euiSizeXS; } } + +.pipelineProcessorsEditor__form__dragAndDropList__grabIcon { + height: $euiSizeXL; + width: $euiSizeXL; + display: flex; + align-items: center; + justify-content: center; +} \ No newline at end of file diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/field_components/drag_and_drop_text_list.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/field_components/drag_and_drop_text_list.tsx index d574479b03e7b..a27251978ab6f 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/field_components/drag_and_drop_text_list.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/field_components/drag_and_drop_text_list.tsx @@ -18,7 +18,6 @@ import { EuiFlexItem, EuiIcon, EuiFieldText, - EuiIconTip, EuiFormRow, EuiText, } from '@elastic/eui'; @@ -133,15 +132,14 @@ function DragAndDropTextListComponent({ -
- +
+
@@ -160,34 +158,17 @@ function DragAndDropTextListComponent({ const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); return ( - - - - - {typeof errorMessage === 'string' && ( - -
- -
-
- )} -
+ + + ); }} @@ -200,6 +181,7 @@ function DragAndDropTextListComponent({ iconType="minusInCircle" color="danger" onClick={() => onRemove(item.id)} + size="s" /> ) : ( // Render a no-op placeholder button