Skip to content

Commit

Permalink
[Endpoint] add Redux saga Middleware and app Store (#53906)
Browse files Browse the repository at this point in the history
* Added saga library
* Initialize endpoint app redux store
  • Loading branch information
paul-tavares committed Jan 16, 2020
1 parent 54c7d34 commit 93a1183
Show file tree
Hide file tree
Showing 16 changed files with 743 additions and 33 deletions.
79 changes: 46 additions & 33 deletions x-pack/plugins/endpoint/public/applications/endpoint/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,19 @@ import ReactDOM from 'react-dom';
import { CoreStart, AppMountParameters } from 'kibana/public';
import { I18nProvider, FormattedMessage } from '@kbn/i18n/react';
import { Route, BrowserRouter, Switch } from 'react-router-dom';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import { appStoreFactory } from './store';

/**
* This module will be loaded asynchronously to reduce the bundle size of your plugin's main bundle.
*/
export function renderApp(coreStart: CoreStart, { appBasePath, element }: AppMountParameters) {
coreStart.http.get('/api/endpoint/hello-world');

ReactDOM.render(<AppRoot basename={appBasePath} />, element);
const store = appStoreFactory(coreStart);

ReactDOM.render(<AppRoot basename={appBasePath} store={store} />, element);

return () => {
ReactDOM.unmountComponentAtNode(element);
Expand All @@ -25,38 +30,46 @@ export function renderApp(coreStart: CoreStart, { appBasePath, element }: AppMou

interface RouterProps {
basename: string;
store: Store;
}

const AppRoot: React.FunctionComponent<RouterProps> = React.memo(({ basename }) => (
<I18nProvider>
<BrowserRouter basename={basename}>
<Switch>
<Route
exact
path="/"
render={() => (
<h1 data-test-subj="welcomeTitle">
<FormattedMessage id="xpack.endpoint.welcomeTitle" defaultMessage="Hello World" />
</h1>
)}
/>
<Route
path="/management"
render={() => (
<h1 data-test-subj="endpointManagement">
<FormattedMessage
id="xpack.endpoint.endpointManagement"
defaultMessage="Manage Endpoints"
/>
</h1>
)}
/>
<Route
render={() => (
<FormattedMessage id="xpack.endpoint.notFound" defaultMessage="Page Not Found" />
)}
/>
</Switch>
</BrowserRouter>
</I18nProvider>
const AppRoot: React.FunctionComponent<RouterProps> = React.memo(({ basename, store }) => (
<Provider store={store}>
<I18nProvider>
<BrowserRouter basename={basename}>
<Switch>
<Route
exact
path="/"
render={() => (
<h1 data-test-subj="welcomeTitle">
<FormattedMessage id="xpack.endpoint.welcomeTitle" defaultMessage="Hello World" />
</h1>
)}
/>
<Route
path="/management"
render={() => {
// FIXME: This is temporary. Will be removed in next PR for endpoint list
store.dispatch({ type: 'userEnteredEndpointListPage' });

return (
<h1 data-test-subj="endpointManagement">
<FormattedMessage
id="xpack.endpoint.endpointManagement"
defaultMessage="Manage Endpoints"
/>
</h1>
);
}}
/>
<Route
render={() => (
<FormattedMessage id="xpack.endpoint.notFound" defaultMessage="Page Not Found" />
)}
/>
</Switch>
</BrowserRouter>
</I18nProvider>
</Provider>
));
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

export * from './saga';
101 changes: 101 additions & 0 deletions x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { createSagaMiddleware, SagaContext } from './index';
import { applyMiddleware, createStore, Reducer } from 'redux';

describe('saga', () => {
const INCREMENT_COUNTER = 'INCREMENT';
const DELAYED_INCREMENT_COUNTER = 'DELAYED INCREMENT COUNTER';
const STOP_SAGA_PROCESSING = 'BREAK ASYNC ITERATOR';

const sleep = (ms = 1000) => new Promise(resolve => setTimeout(resolve, ms));
let reducerA: Reducer;
let sideAffect: (a: unknown, s: unknown) => void;
let sagaExe: (sagaContext: SagaContext) => Promise<void>;

beforeEach(() => {
reducerA = jest.fn((prevState = { count: 0 }, { type }) => {
switch (type) {
case INCREMENT_COUNTER:
return { ...prevState, count: prevState.count + 1 };
default:
return prevState;
}
});

sideAffect = jest.fn();

sagaExe = jest.fn(async ({ actionsAndState, dispatch }: SagaContext) => {
for await (const { action, state } of actionsAndState()) {
expect(action).toBeDefined();
expect(state).toBeDefined();

if (action.type === STOP_SAGA_PROCESSING) {
break;
}

sideAffect(action, state);

if (action.type === DELAYED_INCREMENT_COUNTER) {
await sleep(1);
dispatch({
type: INCREMENT_COUNTER,
});
}
}
});
});

test('it returns Redux Middleware from createSagaMiddleware()', () => {
const sagaMiddleware = createSagaMiddleware(async () => {});
expect(sagaMiddleware).toBeInstanceOf(Function);
});
test('it does nothing if saga is not started', () => {
const store = createStore(reducerA, applyMiddleware(createSagaMiddleware(sagaExe)));
expect(store.getState().count).toEqual(0);
expect(reducerA).toHaveBeenCalled();
expect(sagaExe).toHaveBeenCalled();
expect(sideAffect).not.toHaveBeenCalled();
expect(store.getState()).toEqual({ count: 0 });
});
test('it updates store once running', async () => {
const sagaMiddleware = createSagaMiddleware(sagaExe);
const store = createStore(reducerA, applyMiddleware(sagaMiddleware));

expect(store.getState()).toEqual({ count: 0 });
expect(sagaExe).toHaveBeenCalled();

store.dispatch({ type: DELAYED_INCREMENT_COUNTER });
expect(store.getState()).toEqual({ count: 0 });

await sleep(100);

expect(sideAffect).toHaveBeenCalled();
expect(store.getState()).toEqual({ count: 1 });
});
test('it stops processing if break out of loop', async () => {
const sagaMiddleware = createSagaMiddleware(sagaExe);
const store = createStore(reducerA, applyMiddleware(sagaMiddleware));

store.dispatch({ type: DELAYED_INCREMENT_COUNTER });
await sleep(100);

expect(store.getState()).toEqual({ count: 1 });
expect(sideAffect).toHaveBeenCalledTimes(2);

store.dispatch({ type: STOP_SAGA_PROCESSING });
await sleep(100);

expect(store.getState()).toEqual({ count: 1 });
expect(sideAffect).toHaveBeenCalledTimes(2);

store.dispatch({ type: DELAYED_INCREMENT_COUNTER });
await sleep(100);

expect(store.getState()).toEqual({ count: 1 });
expect(sideAffect).toHaveBeenCalledTimes(2);
});
});
129 changes: 129 additions & 0 deletions x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { AnyAction, Dispatch, Middleware, MiddlewareAPI } from 'redux';
import { GlobalState } from '../store';

interface QueuedAction<TAction = AnyAction> {
/**
* The Redux action that was dispatched
*/
action: TAction;
/**
* The Global state at the time the action was dispatched
*/
state: GlobalState;
}

interface IteratorInstance {
queue: QueuedAction[];
nextResolve: null | ((inst: QueuedAction) => void);
}

type Saga = (storeContext: SagaContext) => Promise<void>;

type StoreActionsAndState<TAction = AnyAction> = AsyncIterableIterator<QueuedAction<TAction>>;

export interface SagaContext<TAction extends AnyAction = AnyAction> {
/**
* A generator function that will `yield` `Promise`s that resolve with a `QueuedAction`
*/
actionsAndState: () => StoreActionsAndState<TAction>;
dispatch: Dispatch<TAction>;
}

const noop = () => {};

/**
* Creates Saga Middleware for use with Redux.
*
* @param {Saga} saga The `saga` should initialize a long-running `for await...of` loop against
* the return value of the `actionsAndState()` method provided by the `SagaContext`.
*
* @return {Middleware}
*
* @example
*
* type TPossibleActions = { type: 'add', payload: any[] };
* //...
* const endpointsSaga = async ({ actionsAndState, dispatch }: SagaContext<TPossibleActions>) => {
* for await (const { action, state } of actionsAndState()) {
* if (action.type === "userRequestedResource") {
* const resourceData = await doApiFetch('of/some/resource');
* dispatch({
* type: 'add',
* payload: [ resourceData ]
* });
* }
* }
* }
* const endpointsSagaMiddleware = createSagaMiddleware(endpointsSaga);
* //....
* const store = createStore(reducers, [ endpointsSagaMiddleware ]);
*/
export function createSagaMiddleware(saga: Saga): Middleware {
const iteratorInstances = new Set<IteratorInstance>();
let runSaga: () => void = noop;

async function* getActionsAndStateIterator(): StoreActionsAndState {
const instance: IteratorInstance = { queue: [], nextResolve: null };
iteratorInstances.add(instance);
try {
while (true) {
yield await nextActionAndState();
}
} finally {
// If the consumer stops consuming this (e.g. `break` or `return` is called in the `for await`
// then this `finally` block will run and unregister this instance and reset `runSaga`
iteratorInstances.delete(instance);
runSaga = noop;
}

function nextActionAndState() {
if (instance.queue.length) {
return Promise.resolve(instance.queue.shift() as QueuedAction);
} else {
return new Promise<QueuedAction>(function(resolve) {
instance.nextResolve = resolve;
});
}
}
}

function enqueue(value: QueuedAction) {
for (const iteratorInstance of iteratorInstances) {
iteratorInstance.queue.push(value);
if (iteratorInstance.nextResolve !== null) {
iteratorInstance.nextResolve(iteratorInstance.queue.shift() as QueuedAction);
iteratorInstance.nextResolve = null;
}
}
}

function middleware({ getState, dispatch }: MiddlewareAPI) {
if (runSaga === noop) {
runSaga = saga.bind<null, SagaContext, any[], Promise<void>>(null, {
actionsAndState: getActionsAndStateIterator,
dispatch,
});
runSaga();
}
return (next: Dispatch<AnyAction>) => (action: AnyAction) => {
// Call the next dispatch method in the middleware chain.
const returnValue = next(action);

enqueue({
action,
state: getState(),
});

// This will likely be the action itself, unless a middleware further in chain changed it.
return returnValue;
};
}

return middleware;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { EndpointListAction } from './endpoint_list';

export type AppAction = EndpointListAction;
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { EndpointListData } from './types';

interface ServerReturnedEndpointList {
type: 'serverReturnedEndpointList';
payload: EndpointListData;
}

interface UserEnteredEndpointListPage {
type: 'userEnteredEndpointListPage';
}

interface UserExitedEndpointListPage {
type: 'userExitedEndpointListPage';
}

export type EndpointListAction =
| ServerReturnedEndpointList
| UserEnteredEndpointListPage
| UserExitedEndpointListPage;
Loading

0 comments on commit 93a1183

Please sign in to comment.