[Embeddable] Provide a Redux store adapter for the embeddable input (#136319)

* Add embeddable store factory
* Update embeddable storybook to use store instead of input updates
* Fix embeddable implementation to initialize observables before the constructor
* Add Redux store documentation
* Add missing navigation link to the embeddables documentation
This commit is contained in:
Michael Dokolin 2022-08-18 16:27:22 +02:00 committed by GitHub
parent f46a6309bd
commit 047d11ea13
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 807 additions and 41 deletions

View file

@ -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.

View file

@ -94,6 +94,9 @@
},
{
"id": "kibDevKeyConceptsNavigation"
},
{
"id": "kibDevDocsEmbeddables"
}
]
},

View file

@ -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<HelloWorld>) => ({ title: state.input.title }))(
HelloWorldComponent
);
render(
<Provider store={this.store}>
<Component />
</Provider>,
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<State<HelloWorld>>(({ input }) => input.viewMode);
const dispatch = useDispatch();
return (
<div>
<h1>{title}</h1>
{viewMode === ViewMode.EDIT && (
<input
type="text"
value={title}
onChange={({ target }) => dispatch(actions.input.setTitle(target.value))}
/>
)}
</div>
);
}
```
#### 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<HelloWorldInput['greeting']>) {
state.greeting = action.payload;
},
},
});
const output = createSlice({
name: 'hello-world-input',
initialState: {} as HelloWorldOutput,
reducers: {
setMessage(state, action: PayloadAction<HelloWorldOutput['message']>) {
state.message = action.payload;
},
},
});
export const actions = {
...input.actions,
...output.actions,
};
export class HelloWorld extends Embeddable<HelloWorldInput, HelloWorldOutput> {
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<HelloWorldInput['greeting']>('greeting');
const setMessage = createAction<HelloWorldOutput['message']>('message');
const reducer = createReducer({} as State<HelloWorld>, (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<HelloWorldInput, HelloWorldOutput> {
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<HelloWorld> {
component: ComponentState;
}
const component = createSlice({
name: 'hello-world-component',
initialState: {} as ComponentState,
reducers: {
setFoo(state, action: PayloadAction<ComponentState['foo']>) {
state.foo = action.payload;
},
setBar(state, action: PayloadAction<ComponentState['bar']>) {
state.bar = action.payload;
},
},
});
export const { actions } = component;
export class HelloWorld extends Embeddable {
readonly store = createStore<HelloWorld, HelloWorldState>(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<Embeddable>>((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<Embeddable> {
foo?: string;
bar?: Bar;
}
// ...
const foo = useSelector<EmbeddableState>((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.

View file

@ -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<React.ComponentRef<typeof HelloWorldEmbeddablePanel>>(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 <HelloWorldEmbeddablePanel ref={ref} {...props} />;
}
@ -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 <HelloWorldEmbeddablePanel ref={ref} {...props} />;
}

View file

@ -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(<EuiEmptyPrompt body={this.getTitle()} />, node);
const App = connect((state: State) => ({ body: state.input.title }))(EuiEmptyPrompt);
this.reload = this.render.bind(this, node);
render(
<Provider store={this.store}>
<App />
</Provider>,
node
);
}
setErrorRenderer(renderer: IEmbeddable['renderError']) {
this.renderError = renderer;
}
updateOutput(...args: Parameters<Embeddable['updateOutput']>): void {
return super.updateOutput(...args);
}
}

View file

@ -41,8 +41,10 @@ export abstract class Embeddable<
protected output: TEmbeddableOutput;
protected input: TEmbeddableInput;
private readonly input$: Rx.BehaviorSubject<TEmbeddableInput>;
private readonly output$: Rx.BehaviorSubject<TEmbeddableOutput>;
private readonly inputSubject = new Rx.ReplaySubject<TEmbeddableInput>(1);
private readonly outputSubject = new Rx.ReplaySubject<TEmbeddableOutput>(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<TEmbeddableInput>(this.input);
this.output$ = new Rx.BehaviorSubject<TEmbeddableOutput>(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<Rx.Observable<TEmbeddableInput>> {
return this.input$.asObservable();
return this.input$;
}
public getOutput$(): Readonly<Rx.Observable<TEmbeddableOutput>> {
return this.output$.asObservable();
return this.output$;
}
public getOutput(): Readonly<TEmbeddableOutput> {
@ -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<TEmbeddableOutput>): void {
public updateOutput(outputChanges: Partial<TEmbeddableOutput>): 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<TEmbeddableOutput>);

View file

@ -142,6 +142,12 @@ export interface IEmbeddable<
*/
updateInput(changes: Partial<I>): void;
/**
* Updates output state with the given changes.
* @param changes
*/
updateOutput(changes: Partial<O>): void;
/**
* Returns an observable which will be notified when input state changes.
*/

View file

@ -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<TestEmbeddableInput, TestEmbeddableOutput> {
type = 'test';
reload = jest.fn();
render = jest.fn();
}
class TestContainer extends Container<Partial<TestEmbeddableInput>, TestContainerInput> {
type = 'test';
getInheritedInput() {
return {
custom: this.input.custom,
};
}
}
describe('createStore', () => {
let embeddable: TestEmbeddable;
let store: Store<State<TestEmbeddable>>;
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<string>('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<TestEmbeddable>,
reducers: {
setCustom(state, action: PayloadAction<TestEmbeddableInput['custom']>) {
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<TestEmbeddableInput>)
);
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<TestEmbeddableInput>)
);
await new Promise((resolve) => setTimeout(resolve));
store.dispatch(input.actions.update({ custom: undefined } as Partial<TestEmbeddableInput>));
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',
})
);
});
});
});

View file

@ -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<E extends IEmbeddable = IEmbeddable> {
input: E extends IEmbeddable<infer I, infer O> ? I : never;
output: E extends IEmbeddable<infer I, infer O> ? O : never;
}
export interface CreateStoreOptions<S extends State>
extends Omit<ConfigureStoreOptions<S>, 'reducer'> {
reducer?: Reducer<S> | Optional<ReducersMapObject<S>, keyof State>;
}
function createReducer<S extends State>(
reducer?: CreateStoreOptions<S>['reducer']
): Reducer<S> | ReducersMapObject<S> {
if (reducer instanceof Function) {
const generic = combineReducers<Pick<S, keyof State>>({
input: input.reducer,
output: output.reducer,
}) as Reducer<S>;
return reduceReducers(generic, reducer) as Reducer<S>;
}
return {
...(reducer ?? {}),
input: reducer?.input ? reduceReducers(input.reducer, reducer.input) : input.reducer,
output: reducer?.output ? reduceReducers(output.reducer, reducer.output) : output.reducer,
} as ReducersMapObject<S>;
}
function diff<T extends Record<keyof any, any>>(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<T>;
}
/**
* 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<E extends IEmbeddable = IEmbeddable, S extends State<E> = State<E>>(
embeddable: E,
{ preloadedState, reducer, ...options }: CreateStoreOptions<S> = {}
): Store<S> {
const store = configureStore({
...options,
preloadedState: {
input: embeddable.getInput(),
output: embeddable.getOutput(),
...(preloadedState ?? {}),
} as NonNullable<typeof preloadedState>,
reducer: createReducer(reducer),
});
const state$ = new Observable<S>((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;
}

View file

@ -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,
};

View file

@ -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<EmbeddableInput['disabledActions']>) {
state.disabledActions = action.payload;
},
setDisableTriggers(state, action: PayloadAction<EmbeddableInput['disableTriggers']>) {
state.disableTriggers = action.payload;
},
setEnhancements(state, action: PayloadAction<EmbeddableInput['enhancements']>) {
state.enhancements = action.payload;
},
setExecutionContext(state, action: PayloadAction<EmbeddableInput['executionContext']>) {
state.executionContext = action.payload;
},
setHidePanelTitles(state, action: PayloadAction<EmbeddableInput['hidePanelTitles']>) {
state.hidePanelTitles = action.payload;
},
setLastReloadRequestTime(
state,
action: PayloadAction<EmbeddableInput['lastReloadRequestTime']>
) {
state.lastReloadRequestTime = action.payload;
},
setSearchSessionId(state, action: PayloadAction<EmbeddableInput['searchSessionId']>) {
state.searchSessionId = action.payload;
},
setSyncColors(state, action: PayloadAction<EmbeddableInput['syncColors']>) {
state.syncColors = action.payload;
},
setSyncTooltips(state, action: PayloadAction<EmbeddableInput['syncTooltips']>) {
state.syncTooltips = action.payload;
},
setTitle(state, action: PayloadAction<EmbeddableInput['title']>) {
state.title = action.payload;
},
setViewMode(state, action: PayloadAction<EmbeddableInput['viewMode']>) {
state.viewMode = action.payload;
},
update(state, action: PayloadAction<Partial<EmbeddableInput>>) {
return { ...state, ...action.payload };
},
},
});

View file

@ -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<EmbeddableOutput['loading']>) {
state.loading = action.payload;
},
setRendered(state, action: PayloadAction<EmbeddableOutput['rendered']>) {
state.rendered = action.payload;
},
setError(state, action: PayloadAction<EmbeddableOutput['error']>) {
state.error = action.payload;
},
setEditUrl(state, action: PayloadAction<EmbeddableOutput['editUrl']>) {
state.editUrl = action.payload;
},
setEditApp(state, action: PayloadAction<EmbeddableOutput['editApp']>) {
state.editApp = action.payload;
},
setEditPath(state, action: PayloadAction<EmbeddableOutput['editPath']>) {
state.editPath = action.payload;
},
setDefaultTitle(state, action: PayloadAction<EmbeddableOutput['defaultTitle']>) {
state.defaultTitle = action.payload;
},
setTitle(state, action: PayloadAction<EmbeddableOutput['title']>) {
state.title = action.payload;
},
setEditable(state, action: PayloadAction<EmbeddableOutput['editable']>) {
state.editable = action.payload;
},
setSavedObjectId(state, action: PayloadAction<EmbeddableOutput['savedObjectId']>) {
state.savedObjectId = action.payload;
},
update(state, action: PayloadAction<Partial<EmbeddableOutput>>) {
return { ...state, ...action.payload };
},
},
});