mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 10:40:07 -04:00
[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:
parent
f46a6309bd
commit
047d11ea13
12 changed files with 807 additions and 41 deletions
|
@ -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.
|
||||
|
|
|
@ -94,6 +94,9 @@
|
|||
},
|
||||
{
|
||||
"id": "kibDevKeyConceptsNavigation"
|
||||
},
|
||||
{
|
||||
"id": "kibDevDocsEmbeddables"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>);
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
245
src/plugins/embeddable/public/store/create_store.test.ts
Normal file
245
src/plugins/embeddable/public/store/create_store.test.ts
Normal 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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
132
src/plugins/embeddable/public/store/create_store.ts
Normal file
132
src/plugins/embeddable/public/store/create_store.ts
Normal 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;
|
||||
}
|
17
src/plugins/embeddable/public/store/index.ts
Normal file
17
src/plugins/embeddable/public/store/index.ts
Normal 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,
|
||||
};
|
56
src/plugins/embeddable/public/store/input_slice.ts
Normal file
56
src/plugins/embeddable/public/store/input_slice.ts
Normal 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 };
|
||||
},
|
||||
},
|
||||
});
|
50
src/plugins/embeddable/public/store/output_slice.ts
Normal file
50
src/plugins/embeddable/public/store/output_slice.ts
Normal 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 };
|
||||
},
|
||||
},
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue