diff --git a/dev_docs/key_concepts/embeddables.mdx b/dev_docs/key_concepts/embeddables.mdx index f1a2bea5b9b1..e42233a7f3f2 100644 --- a/dev_docs/key_concepts/embeddables.mdx +++ b/dev_docs/key_concepts/embeddables.mdx @@ -14,6 +14,7 @@ If you are planning to integrate with the plugin, please get in touch with the A ## Capabilities - Framework-agnostic API. - Out-of-the-box React support. +- Integration with Redux. - 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. diff --git a/nav-kibana-dev.docnav.json b/nav-kibana-dev.docnav.json index 75d0acdc8a9d..c1ea6f702483 100644 --- a/nav-kibana-dev.docnav.json +++ b/nav-kibana-dev.docnav.json @@ -94,6 +94,9 @@ }, { "id": "kibDevKeyConceptsNavigation" + }, + { + "id": "kibDevDocsEmbeddables" } ] }, diff --git a/src/plugins/embeddable/README.md b/src/plugins/embeddable/README.md index fc6632252bb0..14fab2f8412f 100644 --- a/src/plugins/embeddable/README.md +++ b/src/plugins/embeddable/README.md @@ -4,6 +4,7 @@ The Embeddables Plugin provides an opportunity to expose reusable interactive wi ## Capabilities - Framework-agnostic API. - Out-of-the-box React support. +- Integration with Redux. - 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. @@ -354,6 +355,251 @@ The plugin provides a set of ready-to-use React components that abstract renderi 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. +### Redux +The plugin provides an adapter for Redux over the embeddable state. +It uses the Redux Toolkit library underneath and works as a decorator on top of the [`configureStore`](https://redux-toolkit.js.org/api/configureStore) function. +In other words, it provides a way to use the full power of the library together with the embeddable plugin features. + +The adapter implements a bi-directional sync mechanism between the embeddable instance and the Redux state. +To perform state mutations, the plugin also exposes a pre-defined state of the actions that can be extended by an additional reducer. + +Here is an example of initializing a Redux store: +```tsx +import React from 'react'; +import { render } from 'react-dom'; +import { connect, Provider } from 'react-redux'; +import { Embeddable, IEmbeddable } from '@kbn/embeddable-plugin/public'; +import { createStore, State } from '@kbn/embeddable-plugin/public/store'; +import { HelloWorldComponent } from './hello_world_component'; + +export const HELLO_WORLD = 'HELLO_WORLD'; + +export class HelloWorld extends Embeddable { + readonly type = HELLO_WORLD; + + readonly store = createStore(this); + + reload() {} + + render(node: HTMLElement) { + const Component = connect((state: State) => ({ title: state.input.title }))( + HelloWorldComponent + ); + + render( + + + , + node + ); + } +} +``` + +Then inside the embedded component, it is possible to use the [`useSelector`](https://react-redux.js.org/api/hooks#useselector) and [`useDispatch`](https://react-redux.js.org/api/hooks#usedispatch) hooks. +```tsx +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { actions, State } from '@kbn/embeddable-plugin/public/store'; +import { ViewMode } from '@kbn/embeddable-plugin/public'; +import type { HelloWorld } from './hello_world'; + +interface HelloWorldComponentProps { + title?: string; +} + +export function HelloWorldComponent({ title }: HelloWorldComponentProps) { + const viewMode = useSelector>(({ input }) => input.viewMode); + const dispatch = useDispatch(); + + return ( +
+

{title}

+ {viewMode === ViewMode.EDIT && ( + dispatch(actions.input.setTitle(target.value))} + /> + )} +
+ ); +} +``` + +#### Custom Properties +The `createStore` function provides an option to pass a custom reducer in the second argument. +That reducer will be merged with the one the embeddable plugin provides. +That means there is no need to reimplement already existing actions. + +```tsx +import React from 'react'; +import { render } from 'react-dom'; +import { createSlice } from '@reduxjs/toolkit'; +import { + Embeddable, + EmbeddableInput, + EmbeddableOutput, + IEmbeddable +} from '@kbn/embeddable-plugin/public'; +import { createStore, State } from '@kbn/embeddable-plugin/public/store'; + +interface HelloWorldInput extends EmbeddableInput { + greeting?: string; +} + +interface HelloWorldOutput extends EmbeddableOutput { + message?: string; +} + +const input = createSlice({ + name: 'hello-world-input', + initialState: {} as HelloWorldInput, + reducers: { + setGreeting(state, action: PayloadAction) { + state.greeting = action.payload; + }, + }, +}); + +const output = createSlice({ + name: 'hello-world-input', + initialState: {} as HelloWorldOutput, + reducers: { + setMessage(state, action: PayloadAction) { + state.message = action.payload; + }, + }, +}); + +export const actions = { + ...input.actions, + ...output.actions, +}; + +export class HelloWorld extends Embeddable { + readonly store = createStore(this, { + reducer: { + input: input.reducer, + output: output.reducer, + } + }); + + // ... +} +``` + +There is a way to provide a custom reducer that will manipulate the root state: +```typescript +// ... + +import { createAction, createRducer } from '@reduxjs/toolkit'; + +// ... + +const setGreeting = createAction('greeting'); +const setMessage = createAction('message'); +const reducer = createReducer({} as State, (builder) => + builder + .addCase(setGreeting, (state, action) => ({ ...state, input: { ...state.input, greeting: action.payload } })) + .addCase(setMessage, (state, action) => ({ ...state, output: { ...state.output, message: action.payload } })) +); + +export const actions = { + setGreeting, + setMessage, +}; + +export class HelloWorld extends Embeddable { + readonly store = createStore(this, { reducer }); + + // ... +} +``` + +#### Custom State +Sometimes, there is a need to store a custom state next to the embeddable state. +This can be achieved by passing a custom reducer. + +```tsx +import React from 'react'; +import { render } from 'react-dom'; +import { createSlice } from '@reduxjs/toolkit'; +import { Embeddable, IEmbeddable } from '@kbn/embeddable-plugin/public'; +import { createStore, State } from '@kbn/embeddable-plugin/public/store'; + +interface ComponentState { + foo?: string; + bar?: string; +} + +export interface HelloWorldState extends State { + component: ComponentState; +} + +const component = createSlice({ + name: 'hello-world-component', + initialState: {} as ComponentState, + reducers: { + setFoo(state, action: PayloadAction) { + state.foo = action.payload; + }, + setBar(state, action: PayloadAction) { + state.bar = action.payload; + }, + }, +}); + +export const { actions } = component; + +export class HelloWorld extends Embeddable { + readonly store = createStore(this, { + preloadedState: { + component: { + foo: 'bar', + bar: 'foo', + } + }, + reducer: { component: component.reducer } + }); + + // ... +} +``` + +#### Typings +When using the `useSelector` hook, it is convenient to have a `State` type to guarantee type safety and determine types implicitly. + +For the state containing input and output substates only, it is enough to use a utility type `State`: +```typescript +import { useSelector } from 'react-redux'; +import type { State } from '@kbn/embeddable-plugin/public/store'; +import type { Embeddable } from './some_embeddable'; + +// ... +const title = useSelector>((state) => state.input.title); +``` + +For the more complex case, the best way would be to define a state type separately: +```typescript +import { useSelector } from 'react-redux'; +import type { State } from '@kbn/embeddable-plugin/public/store'; +import type { Embeddable } from './some_embeddable'; + +interface EmbeddableState extends State { + foo?: string; + bar?: Bar; +} + +// ... +const foo = useSelector((state) => state.foo); +``` + +#### Advanced Usage +In case when there is a need to enhance the produced store in some way (e.g., perform custom serialization or debugging), it is possible to use [parameters](https://redux-toolkit.js.org/api/configureStore#parameters) supported by the `configureStore` function. + +In case when custom serialization is needed, that should be done using middleware. The embeddable plugin's `createStore` function does not apply any middleware, so all the synchronization job is done outside the store. + ## API Please use automatically generated API reference or generated TypeDoc comments to find the complete documentation. diff --git a/src/plugins/embeddable/public/__stories__/embeddable_panel.stories.tsx b/src/plugins/embeddable/public/__stories__/embeddable_panel.stories.tsx index 1f15b942fb58..97e8b83b9f7b 100644 --- a/src/plugins/embeddable/public/__stories__/embeddable_panel.stories.tsx +++ b/src/plugins/embeddable/public/__stories__/embeddable_panel.stories.tsx @@ -25,6 +25,7 @@ import { CoreTheme } from '@kbn/core-theme-browser'; import type { Action } from '@kbn/ui-actions-plugin/public'; import { CONTEXT_MENU_TRIGGER, EmbeddablePanel, PANEL_BADGE_TRIGGER, ViewMode } from '..'; +import { actions } from '../store'; import { HelloWorldEmbeddable } from './hello_world_embeddable'; const layout: DecoratorFn = (story) => { @@ -93,20 +94,16 @@ const HelloWorldEmbeddablePanel = forwardRef< const theme = useContext(ThemeContext) as CoreTheme; useEffect(() => theme$.next(theme), [theme$, theme]); + useEffect(() => { + embeddable.store.dispatch(actions.input.setTitle(title)); + }, [embeddable.store, title]); + useEffect(() => { + embeddable.store.dispatch( + actions.input.setViewMode(viewMode ? ViewMode.VIEW : ViewMode.EDIT) + ); + }, [embeddable.store, viewMode]); useEffect( - () => - embeddable.updateInput({ - title, - viewMode: viewMode ? ViewMode.VIEW : ViewMode.EDIT, - lastReloadRequestTime: new Date().getMilliseconds(), - }), - [embeddable, title, viewMode] - ); - useEffect( - () => - embeddable.updateOutput({ - loading, - }), + () => void embeddable.store.dispatch(actions.output.setLoading(loading)), [embeddable, loading] ); useImperativeHandle(ref, () => ({ embeddable })); @@ -162,7 +159,9 @@ export function DefaultWithBadges({ badges, ...props }: DefaultWithBadgesProps) useEffect( () => - ref.current?.embeddable.updateInput({ lastReloadRequestTime: new Date().getMilliseconds() }), + void ref.current?.embeddable.store.dispatch( + actions.input.setLastReloadRequestTime(new Date().getMilliseconds()) + ), [getActions] ); @@ -207,7 +206,9 @@ export function DefaultWithContextMenu({ items, ...props }: DefaultWithContextMe useEffect( () => - ref.current?.embeddable.updateInput({ lastReloadRequestTime: new Date().getMilliseconds() }), + void ref.current?.embeddable.store.dispatch( + actions.input.setLastReloadRequestTime(new Date().getMilliseconds()) + ), [getActions] ); @@ -230,7 +231,10 @@ interface DefaultWithErrorProps extends HelloWorldEmbeddablePanelProps { export function DefaultWithError({ message, ...props }: DefaultWithErrorProps) { const ref = useRef>(null); - useEffect(() => ref.current?.embeddable.updateOutput({ error: new Error(message) }), [message]); + useEffect( + () => void ref.current?.embeddable.store.dispatch(actions.output.setError(new Error(message))), + [message] + ); return ; } @@ -256,7 +260,10 @@ export function DefaultWithCustomError({ message, ...props }: DefaultWithErrorPr }), [] ); - useEffect(() => ref.current?.embeddable.updateOutput({ error: new Error(message) }), [message]); + useEffect( + () => void ref.current?.embeddable.store.dispatch(actions.output.setError(new Error(message))), + [message] + ); return ; } diff --git a/src/plugins/embeddable/public/__stories__/hello_world_embeddable.tsx b/src/plugins/embeddable/public/__stories__/hello_world_embeddable.tsx index 2ea923704be7..5cf2c5fdc46e 100644 --- a/src/plugins/embeddable/public/__stories__/hello_world_embeddable.tsx +++ b/src/plugins/embeddable/public/__stories__/hello_world_embeddable.tsx @@ -8,10 +8,15 @@ import React from 'react'; import { render } from 'react-dom'; +import { connect, Provider } from 'react-redux'; import { EuiEmptyPrompt } from '@elastic/eui'; import { Embeddable, IEmbeddable } from '..'; +import { createStore, State } from '../store'; export class HelloWorldEmbeddable extends Embeddable { + // eslint-disable-next-line @kbn/eslint/no_this_in_property_initializers + readonly store = createStore(this); + readonly type = 'hello-world'; renderError: IEmbeddable['renderError']; @@ -19,16 +24,17 @@ export class HelloWorldEmbeddable extends Embeddable { reload() {} render(node: HTMLElement) { - render(, node); + const App = connect((state: State) => ({ body: state.input.title }))(EuiEmptyPrompt); - this.reload = this.render.bind(this, node); + render( + + + , + node + ); } setErrorRenderer(renderer: IEmbeddable['renderError']) { this.renderError = renderer; } - - updateOutput(...args: Parameters): void { - return super.updateOutput(...args); - } } diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx index 001cb98afa6c..94025320ec86 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx @@ -41,8 +41,10 @@ export abstract class Embeddable< protected output: TEmbeddableOutput; protected input: TEmbeddableInput; - private readonly input$: Rx.BehaviorSubject; - private readonly output$: Rx.BehaviorSubject; + private readonly inputSubject = new Rx.ReplaySubject(1); + private readonly outputSubject = new Rx.ReplaySubject(1); + private readonly input$ = this.inputSubject.asObservable(); + private readonly output$ = this.outputSubject.asObservable(); protected renderComplete = new RenderCompleteDispatcher(); @@ -71,8 +73,8 @@ export abstract class Embeddable< }; this.parent = parent; - this.input$ = new Rx.BehaviorSubject(this.input); - this.output$ = new Rx.BehaviorSubject(this.output); + this.inputSubject.next(this.input); + this.outputSubject.next(this.output); if (parent) { this.parentSubscription = Rx.merge(parent.getInput$(), parent.getOutput$()).subscribe(() => { @@ -89,12 +91,7 @@ export abstract class Embeddable< map(({ title }) => title || ''), distinctUntilChanged() ) - .subscribe( - (title) => { - this.renderComplete.setTitle(title); - }, - () => {} - ); + .subscribe((title) => this.renderComplete.setTitle(title)); } public reportsEmbeddableLoad() { @@ -142,11 +139,11 @@ export abstract class Embeddable< } public getInput$(): Readonly> { - return this.input$.asObservable(); + return this.input$; } public getOutput$(): Readonly> { - return this.output$.asObservable(); + return this.output$; } public getOutput(): Readonly { @@ -238,8 +235,8 @@ export abstract class Embeddable< public destroy(): void { this.destroyed = true; - this.input$.complete(); - this.output$.complete(); + this.inputSubject.complete(); + this.outputSubject.complete(); if (this.parentSubscription) { this.parentSubscription.unsubscribe(); @@ -257,20 +254,20 @@ export abstract class Embeddable< } } - protected updateOutput(outputChanges: Partial): void { + public updateOutput(outputChanges: Partial): void { const newOutput = { ...this.output, ...outputChanges, }; if (!fastIsEqual(this.output, newOutput)) { this.output = newOutput; - this.output$.next(this.output); + this.outputSubject.next(this.output); } } protected onFatalError(e: Error) { this.fatalError = e; - this.output$.error(e); + this.outputSubject.error(e); // if the container is waiting for this embeddable to complete loading, // a fatal error counts as complete. if (this.deferEmbeddableLoad && this.parent?.isContainer) { @@ -282,7 +279,7 @@ export abstract class Embeddable< if (!fastIsEqual(this.input, newInput)) { const oldLastReloadRequestTime = this.input.lastReloadRequestTime; this.input = newInput; - this.input$.next(newInput); + this.inputSubject.next(newInput); this.updateOutput({ title: getPanelTitle(this.input, this.output), } as Partial); diff --git a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts index 9037de1a1007..1c9bdebcefc9 100644 --- a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts @@ -142,6 +142,12 @@ export interface IEmbeddable< */ updateInput(changes: Partial): void; + /** + * Updates output state with the given changes. + * @param changes + */ + updateOutput(changes: Partial): void; + /** * Returns an observable which will be notified when input state changes. */ diff --git a/src/plugins/embeddable/public/store/create_store.test.ts b/src/plugins/embeddable/public/store/create_store.test.ts new file mode 100644 index 000000000000..52ac1eb32c8d --- /dev/null +++ b/src/plugins/embeddable/public/store/create_store.test.ts @@ -0,0 +1,245 @@ +/* + * 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. + */ + +// eslint-disable-next-line max-classes-per-file +import { createAction, createReducer, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import type { Store } from 'redux'; +import { + defaultEmbeddableFactoryProvider, + Container, + ContainerInput, + Embeddable, + EmbeddableInput, + EmbeddableOutput, +} from '../lib'; +import { createStore, State } from './create_store'; +import { input } from './input_slice'; +import { output } from './output_slice'; + +interface TestEmbeddableInput extends EmbeddableInput { + custom?: string; +} + +interface TestEmbeddableOutput extends EmbeddableOutput { + custom?: string; +} + +interface TestContainerInput extends ContainerInput { + custom?: string; +} + +class TestEmbeddable extends Embeddable { + type = 'test'; + reload = jest.fn(); + render = jest.fn(); +} + +class TestContainer extends Container, TestContainerInput> { + type = 'test'; + + getInheritedInput() { + return { + custom: this.input.custom, + }; + } +} + +describe('createStore', () => { + let embeddable: TestEmbeddable; + let store: Store>; + + beforeEach(() => { + embeddable = new TestEmbeddable({ id: '12345' }, { title: 'Test' }); + store = createStore(embeddable); + }); + + it('should populate the state with the embeddable input', () => { + expect(store.getState()).toHaveProperty('input', expect.objectContaining({ id: '12345' })); + }); + + it('should populate the state with the embeddable output', () => { + expect(store.getState()).toHaveProperty('output', expect.objectContaining({ title: 'Test' })); + }); + + it('should update the embeddable input on action dispatch', () => { + store.dispatch(input.actions.setTitle('Something')); + + expect(store.getState()).toHaveProperty('input.title', 'Something'); + }); + + it('should update the embeddable output on action dispatch', () => { + store.dispatch(output.actions.setTitle('Something')); + + expect(store.getState()).toHaveProperty('output.title', 'Something'); + }); + + it('should group input updates on multiple dispatch calls', async () => { + jest.spyOn(embeddable, 'updateInput'); + store.dispatch(input.actions.setTitle('Something')); + store.dispatch(input.actions.setHidePanelTitles(true)); + await new Promise((resolve) => setTimeout(resolve)); + + expect(embeddable.updateInput).toHaveBeenCalledTimes(1); + expect(embeddable.updateInput).nthCalledWith( + 1, + expect.objectContaining({ title: 'Something', hidePanelTitles: true }) + ); + }); + + it('should group output updates on multiple dispatch calls', async () => { + jest.spyOn(embeddable, 'updateOutput'); + store.dispatch(output.actions.setTitle('Something')); + store.dispatch(output.actions.setLoading(true)); + await new Promise((resolve) => setTimeout(resolve)); + + expect(embeddable.updateOutput).toHaveBeenCalledTimes(1); + expect(embeddable.updateOutput).nthCalledWith( + 1, + expect.objectContaining({ title: 'Something', loading: true }) + ); + }); + + it('should not update input on output changes', async () => { + jest.spyOn(embeddable, 'updateInput'); + store.dispatch(output.actions.setTitle('Something')); + await new Promise((resolve) => setTimeout(resolve)); + + expect(embeddable.updateInput).not.toHaveBeenCalled(); + }); + + it('should sync input changes', () => { + jest.spyOn(embeddable, 'updateInput'); + embeddable.updateInput({ title: 'Something' }); + + expect(embeddable.updateInput).toHaveBeenCalledTimes(1); + expect(store.getState()).toHaveProperty('input.title', 'Something'); + }); + + it('should sync output changes', () => { + jest.spyOn(embeddable, 'updateOutput'); + embeddable.updateOutput({ title: 'Something' }); + + expect(embeddable.updateOutput).toHaveBeenCalledTimes(1); + expect(store.getState()).toHaveProperty('output.title', 'Something'); + }); + + it('should provide a way to use a custom reducer', async () => { + const setCustom = createAction('custom'); + const customStore = createStore(embeddable, { + reducer: { + input: createReducer({} as TestEmbeddableInput, (builder) => + builder.addCase(setCustom, (state, action) => ({ ...state, custom: action.payload })) + ), + }, + }); + + jest.spyOn(embeddable, 'updateInput'); + customStore.dispatch(input.actions.setTitle('Something')); + customStore.dispatch(setCustom('Something else')); + await new Promise((resolve) => setTimeout(resolve)); + + expect(embeddable.updateInput).toHaveBeenCalledWith( + expect.objectContaining({ custom: 'Something else', title: 'Something' }) + ); + }); + + it('should provide a way to use a custom slice', async () => { + const slice = createSlice({ + name: 'test', + initialState: {} as State, + reducers: { + setCustom(state, action: PayloadAction) { + state.input.custom = action.payload; + state.output.custom = action.payload; + }, + }, + }); + const customStore = createStore(embeddable, { reducer: slice.reducer }); + + jest.spyOn(embeddable, 'updateInput'); + jest.spyOn(embeddable, 'updateOutput'); + customStore.dispatch(input.actions.setTitle('Something')); + customStore.dispatch(slice.actions.setCustom('Something else')); + await new Promise((resolve) => setTimeout(resolve)); + + expect(embeddable.updateInput).toHaveBeenCalledWith( + expect.objectContaining({ custom: 'Something else', title: 'Something' }) + ); + expect(embeddable.updateOutput).toHaveBeenCalledWith( + expect.objectContaining({ custom: 'Something else' }) + ); + }); + + describe('of a nested embeddable', () => { + const factory = defaultEmbeddableFactoryProvider< + TestEmbeddableInput, + TestEmbeddableOutput, + TestEmbeddable + >({ + type: 'test', + getDisplayName: () => 'Test', + isEditable: async () => true, + create: async (data, parent) => new TestEmbeddable(data, {}, parent), + }); + const getFactory = jest.fn().mockReturnValue(factory); + + let container: TestContainer; + + beforeEach(async () => { + container = new TestContainer( + { custom: 'something', id: 'id', panels: {} }, + { embeddableLoaded: {} }, + getFactory + ); + embeddable = (await container.addNewEmbeddable('test', { id: '12345' })) as TestEmbeddable; + store = createStore(embeddable); + }); + + it('should populate inherited input', () => { + expect(store.getState()).toHaveProperty('input.custom', 'something'); + }); + + it('should override inherited input on dispatch', async () => { + store.dispatch( + input.actions.update({ custom: 'something else' } as Partial) + ); + await new Promise((resolve) => setTimeout(resolve)); + + expect(store.getState()).toHaveProperty('input.custom', 'something else'); + expect(container.getInput()).toHaveProperty( + 'input.custom', + expect.not.stringMatching('something else') + ); + }); + + it('should restore value from the inherited input', async () => { + store.dispatch( + input.actions.update({ custom: 'something else' } as Partial) + ); + await new Promise((resolve) => setTimeout(resolve)); + store.dispatch(input.actions.update({ custom: undefined } as Partial)); + await new Promise((resolve) => setTimeout(resolve)); + + expect(store.getState()).toHaveProperty('input.custom', 'something'); + }); + + it('should not override inherited input on dispatch', async () => { + store.dispatch(input.actions.setTitle('Something')); + await new Promise((resolve) => setTimeout(resolve)); + container.updateInput({ custom: 'something else' }); + + expect(store.getState()).toHaveProperty( + 'input', + expect.objectContaining({ + title: 'Something', + custom: 'something else', + }) + ); + }); + }); +}); diff --git a/src/plugins/embeddable/public/store/create_store.ts b/src/plugins/embeddable/public/store/create_store.ts new file mode 100644 index 000000000000..135f793c079d --- /dev/null +++ b/src/plugins/embeddable/public/store/create_store.ts @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { chain, isEmpty, keys } from 'lodash'; +import { combineReducers, Reducer, Store, ReducersMapObject } from 'redux'; +import { configureStore, ConfigureStoreOptions } from '@reduxjs/toolkit'; +import { + debounceTime, + distinctUntilChanged, + filter, + last, + map, + pluck, + share, + takeUntil, + Observable, +} from 'rxjs'; +import reduceReducers from 'reduce-reducers'; +import type { Optional } from 'utility-types'; +import type { IEmbeddable } from '../lib'; +import { input } from './input_slice'; +import { output } from './output_slice'; + +export interface State { + input: E extends IEmbeddable ? I : never; + output: E extends IEmbeddable ? O : never; +} + +export interface CreateStoreOptions + extends Omit, 'reducer'> { + reducer?: Reducer | Optional, keyof State>; +} + +function createReducer( + reducer?: CreateStoreOptions['reducer'] +): Reducer | ReducersMapObject { + if (reducer instanceof Function) { + const generic = combineReducers>({ + input: input.reducer, + output: output.reducer, + }) as Reducer; + + return reduceReducers(generic, reducer) as Reducer; + } + + return { + ...(reducer ?? {}), + input: reducer?.input ? reduceReducers(input.reducer, reducer.input) : input.reducer, + output: reducer?.output ? reduceReducers(output.reducer, reducer.output) : output.reducer, + } as ReducersMapObject; +} + +function diff>(previous: T, current: T) { + return chain(current) + .keys() + .concat(keys(previous)) + .uniq() + .filter((key) => previous[key] !== current[key]) + .map((key) => [key, current[key]]) + .fromPairs() + .value() as Partial; +} + +/** + * Creates a Redux store for the given embeddable. + * @param embeddable The embeddable instance. + * @param options The custom options to pass to the `configureStore` call. + * @returns The Redux store. + */ +export function createStore = State>( + embeddable: E, + { preloadedState, reducer, ...options }: CreateStoreOptions = {} +): Store { + const store = configureStore({ + ...options, + preloadedState: { + input: embeddable.getInput(), + output: embeddable.getOutput(), + ...(preloadedState ?? {}), + } as NonNullable, + reducer: createReducer(reducer), + }); + + const state$ = new Observable((subscriber) => { + subscriber.add(store.subscribe(() => subscriber.next(store.getState()))); + }).pipe(share()); + const input$ = embeddable.getInput$(); + const output$ = embeddable.getOutput$(); + + state$ + .pipe( + takeUntil(input$.pipe(last())), + pluck('input'), + distinctUntilChanged(), + map((value) => diff(embeddable.getInput(), value)), + filter((patch) => !isEmpty(patch)), + debounceTime(0) + ) + .subscribe((patch) => embeddable.updateInput(patch)); + + state$ + .pipe( + takeUntil(output$.pipe(last())), + pluck('output'), + distinctUntilChanged(), + map((value) => diff(embeddable.getOutput(), value)), + filter((patch) => !isEmpty(patch)), + debounceTime(0) + ) + .subscribe((patch) => embeddable.updateOutput(patch)); + + input$ + .pipe( + map((value) => diff(store.getState().input, value)), + filter((patch) => !isEmpty(patch)) + ) + .subscribe((patch) => store.dispatch(input.actions.update(patch))); + + output$ + .pipe( + map((value) => diff(store.getState().output, value)), + filter((patch) => !isEmpty(patch)) + ) + .subscribe((patch) => store.dispatch(output.actions.update(patch))); + + return store; +} diff --git a/src/plugins/embeddable/public/store/index.ts b/src/plugins/embeddable/public/store/index.ts new file mode 100644 index 000000000000..05567e021558 --- /dev/null +++ b/src/plugins/embeddable/public/store/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { input } from './input_slice'; +import { output } from './output_slice'; + +export type { CreateStoreOptions, State } from './create_store'; +export { createStore } from './create_store'; +export const actions = { + input: input.actions, + output: output.actions, +}; diff --git a/src/plugins/embeddable/public/store/input_slice.ts b/src/plugins/embeddable/public/store/input_slice.ts new file mode 100644 index 000000000000..da4bc6618ae1 --- /dev/null +++ b/src/plugins/embeddable/public/store/input_slice.ts @@ -0,0 +1,56 @@ +/* + * 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 { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import type { EmbeddableInput } from '../lib'; + +export const input = createSlice({ + name: 'input', + initialState: {} as EmbeddableInput, + reducers: { + setDisabledActions(state, action: PayloadAction) { + state.disabledActions = action.payload; + }, + setDisableTriggers(state, action: PayloadAction) { + state.disableTriggers = action.payload; + }, + setEnhancements(state, action: PayloadAction) { + state.enhancements = action.payload; + }, + setExecutionContext(state, action: PayloadAction) { + state.executionContext = action.payload; + }, + setHidePanelTitles(state, action: PayloadAction) { + state.hidePanelTitles = action.payload; + }, + setLastReloadRequestTime( + state, + action: PayloadAction + ) { + state.lastReloadRequestTime = action.payload; + }, + setSearchSessionId(state, action: PayloadAction) { + state.searchSessionId = action.payload; + }, + setSyncColors(state, action: PayloadAction) { + state.syncColors = action.payload; + }, + setSyncTooltips(state, action: PayloadAction) { + state.syncTooltips = action.payload; + }, + setTitle(state, action: PayloadAction) { + state.title = action.payload; + }, + setViewMode(state, action: PayloadAction) { + state.viewMode = action.payload; + }, + update(state, action: PayloadAction>) { + return { ...state, ...action.payload }; + }, + }, +}); diff --git a/src/plugins/embeddable/public/store/output_slice.ts b/src/plugins/embeddable/public/store/output_slice.ts new file mode 100644 index 000000000000..c3cbaf687589 --- /dev/null +++ b/src/plugins/embeddable/public/store/output_slice.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 { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import type { EmbeddableOutput } from '../lib'; + +export const output = createSlice({ + name: 'output', + initialState: {} as EmbeddableOutput, + reducers: { + setLoading(state, action: PayloadAction) { + state.loading = action.payload; + }, + setRendered(state, action: PayloadAction) { + state.rendered = action.payload; + }, + setError(state, action: PayloadAction) { + state.error = action.payload; + }, + setEditUrl(state, action: PayloadAction) { + state.editUrl = action.payload; + }, + setEditApp(state, action: PayloadAction) { + state.editApp = action.payload; + }, + setEditPath(state, action: PayloadAction) { + state.editPath = action.payload; + }, + setDefaultTitle(state, action: PayloadAction) { + state.defaultTitle = action.payload; + }, + setTitle(state, action: PayloadAction) { + state.title = action.payload; + }, + setEditable(state, action: PayloadAction) { + state.editable = action.payload; + }, + setSavedObjectId(state, action: PayloadAction) { + state.savedObjectId = action.payload; + }, + update(state, action: PayloadAction>) { + return { ...state, ...action.payload }; + }, + }, +});