From 93a11838ad7c0a48d4de9038c7aef80d9e0ad2cc Mon Sep 17 00:00:00 2001
From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com>
Date: Thu, 16 Jan 2020 11:34:32 -0500
Subject: [PATCH] [Endpoint] add Redux saga Middleware and app Store (#53906)
* Added saga library
* Initialize endpoint app redux store
---
.../public/applications/endpoint/index.tsx | 79 ++++++-----
.../public/applications/endpoint/lib/index.ts | 7 +
.../applications/endpoint/lib/saga.test.ts | 101 ++++++++++++++
.../public/applications/endpoint/lib/saga.ts | 129 ++++++++++++++++++
.../applications/endpoint/store/actions.ts | 9 ++
.../endpoint/store/endpoint_list/action.ts | 25 ++++
.../store/endpoint_list/index.test.ts | 126 +++++++++++++++++
.../endpoint/store/endpoint_list/index.ts | 10 ++
.../endpoint/store/endpoint_list/reducer.ts | 32 +++++
.../endpoint/store/endpoint_list/saga.test.ts | 111 +++++++++++++++
.../endpoint/store/endpoint_list/saga.ts | 26 ++++
.../endpoint/store/endpoint_list/selectors.ts | 9 ++
.../endpoint/store/endpoint_list/types.ts | 54 ++++++++
.../applications/endpoint/store/index.ts | 24 ++++
.../applications/endpoint/store/reducer.ts | 16 +++
.../applications/endpoint/store/saga.ts | 18 +++
16 files changed, 743 insertions(+), 33 deletions(-)
create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/lib/index.ts
create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.test.ts
create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.ts
create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/store/actions.ts
create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/action.ts
create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/index.test.ts
create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/index.ts
create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/reducer.ts
create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/saga.test.ts
create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/saga.ts
create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/selectors.ts
create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/types.ts
create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/store/index.ts
create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/store/reducer.ts
create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/store/saga.ts
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/index.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/index.tsx
index 82c95b37ee7b..d69e068bdea3 100644
--- a/x-pack/plugins/endpoint/public/applications/endpoint/index.tsx
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/index.tsx
@@ -9,6 +9,9 @@ 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.
@@ -16,7 +19,9 @@ import { Route, BrowserRouter, Switch } from 'react-router-dom';
export function renderApp(coreStart: CoreStart, { appBasePath, element }: AppMountParameters) {
coreStart.http.get('/api/endpoint/hello-world');
- ReactDOM.render(, element);
+ const store = appStoreFactory(coreStart);
+
+ ReactDOM.render(, element);
return () => {
ReactDOM.unmountComponentAtNode(element);
@@ -25,38 +30,46 @@ export function renderApp(coreStart: CoreStart, { appBasePath, element }: AppMou
interface RouterProps {
basename: string;
+ store: Store;
}
-const AppRoot: React.FunctionComponent = React.memo(({ basename }) => (
-
-
-
- (
-
-
-
- )}
- />
- (
-
-
-
- )}
- />
- (
-
- )}
- />
-
-
-
+const AppRoot: React.FunctionComponent = React.memo(({ basename, store }) => (
+
+
+
+
+ (
+
+
+
+ )}
+ />
+ {
+ // FIXME: This is temporary. Will be removed in next PR for endpoint list
+ store.dispatch({ type: 'userEnteredEndpointListPage' });
+
+ return (
+
+
+
+ );
+ }}
+ />
+ (
+
+ )}
+ />
+
+
+
+
));
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/lib/index.ts b/x-pack/plugins/endpoint/public/applications/endpoint/lib/index.ts
new file mode 100644
index 000000000000..ba2e1ce8f9fe
--- /dev/null
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/lib/index.ts
@@ -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';
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.test.ts
new file mode 100644
index 000000000000..0387eac0e7c7
--- /dev/null
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.test.ts
@@ -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;
+
+ 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);
+ });
+});
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.ts b/x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.ts
new file mode 100644
index 000000000000..b93360ec6b5a
--- /dev/null
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.ts
@@ -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 {
+ /**
+ * 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;
+
+type StoreActionsAndState = AsyncIterableIterator>;
+
+export interface SagaContext {
+ /**
+ * A generator function that will `yield` `Promise`s that resolve with a `QueuedAction`
+ */
+ actionsAndState: () => StoreActionsAndState;
+ dispatch: Dispatch;
+}
+
+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) => {
+ * 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();
+ 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(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, {
+ actionsAndState: getActionsAndStateIterator,
+ dispatch,
+ });
+ runSaga();
+ }
+ return (next: Dispatch) => (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;
+}
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/actions.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/actions.ts
new file mode 100644
index 000000000000..796dabce1d76
--- /dev/null
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/actions.ts
@@ -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;
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/action.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/action.ts
new file mode 100644
index 000000000000..02ec0f9d0903
--- /dev/null
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/action.ts
@@ -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;
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/index.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/index.test.ts
new file mode 100644
index 000000000000..a46653f82ee4
--- /dev/null
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/index.test.ts
@@ -0,0 +1,126 @@
+/*
+ * 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 { createStore, Dispatch, Store } from 'redux';
+import { EndpointListAction, EndpointData, endpointListReducer, EndpointListState } from './index';
+import { endpointListData } from './selectors';
+
+describe('endpoint_list store concerns', () => {
+ let store: Store;
+ let dispatch: Dispatch;
+ const createTestStore = () => {
+ store = createStore(endpointListReducer);
+ dispatch = store.dispatch;
+ };
+ const generateEndpoint = (): EndpointData => {
+ return {
+ machine_id: Math.random()
+ .toString(16)
+ .substr(2),
+ created_at: new Date(),
+ host: {
+ name: '',
+ hostname: '',
+ ip: '',
+ mac_address: '',
+ os: {
+ name: '',
+ full: '',
+ },
+ },
+ endpoint: {
+ domain: '',
+ is_base_image: true,
+ active_directory_distinguished_name: '',
+ active_directory_hostname: '',
+ upgrade: {
+ status: '',
+ updated_at: new Date(),
+ },
+ isolation: {
+ status: false,
+ request_status: true,
+ updated_at: new Date(),
+ },
+ policy: {
+ name: '',
+ id: '',
+ },
+ sensor: {
+ persistence: true,
+ status: {},
+ },
+ },
+ };
+ };
+ const loadDataToStore = () => {
+ dispatch({
+ type: 'serverReturnedEndpointList',
+ payload: {
+ endpoints: [generateEndpoint()],
+ request_page_size: 1,
+ request_index: 1,
+ total: 10,
+ },
+ });
+ };
+
+ describe('# Reducers', () => {
+ beforeEach(() => {
+ createTestStore();
+ });
+
+ test('it creates default state', () => {
+ expect(store.getState()).toEqual({
+ endpoints: [],
+ request_page_size: 10,
+ request_index: 0,
+ total: 0,
+ });
+ });
+
+ test('it handles `serverReturnedEndpointList', () => {
+ const payload = {
+ endpoints: [generateEndpoint()],
+ request_page_size: 1,
+ request_index: 1,
+ total: 10,
+ };
+ dispatch({
+ type: 'serverReturnedEndpointList',
+ payload,
+ });
+
+ const currentState = store.getState();
+ expect(currentState.endpoints).toEqual(payload.endpoints);
+ expect(currentState.request_page_size).toEqual(payload.request_page_size);
+ expect(currentState.request_index).toEqual(payload.request_index);
+ expect(currentState.total).toEqual(payload.total);
+ });
+
+ test('it handles `userExitedEndpointListPage`', () => {
+ loadDataToStore();
+
+ expect(store.getState().total).toEqual(10);
+
+ dispatch({ type: 'userExitedEndpointListPage' });
+ expect(store.getState().endpoints.length).toEqual(0);
+ expect(store.getState().request_index).toEqual(0);
+ });
+ });
+
+ describe('# Selectors', () => {
+ beforeEach(() => {
+ createTestStore();
+ loadDataToStore();
+ });
+
+ test('it selects `endpointListData`', () => {
+ const currentState = store.getState();
+ expect(endpointListData(currentState)).toEqual(currentState.endpoints);
+ });
+ });
+});
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/index.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/index.ts
new file mode 100644
index 000000000000..bdf0708457bb
--- /dev/null
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/index.ts
@@ -0,0 +1,10 @@
+/*
+ * 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 { endpointListReducer } from './reducer';
+export { EndpointListAction } from './action';
+export { endpointListSaga } from './saga';
+export * from './types';
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/reducer.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/reducer.ts
new file mode 100644
index 000000000000..9813777c988e
--- /dev/null
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/reducer.ts
@@ -0,0 +1,32 @@
+/*
+ * 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 { EndpointListState } from './types';
+import { EndpointListAction } from './action';
+
+const initialState = (): EndpointListState => {
+ return {
+ endpoints: [],
+ request_page_size: 10,
+ request_index: 0,
+ total: 0,
+ };
+};
+
+export const endpointListReducer = (state = initialState(), action: EndpointListAction) => {
+ if (action.type === 'serverReturnedEndpointList') {
+ return {
+ ...state,
+ ...action.payload,
+ };
+ }
+
+ if (action.type === 'userExitedEndpointListPage') {
+ return initialState();
+ }
+
+ return state;
+};
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/saga.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/saga.test.ts
new file mode 100644
index 000000000000..92bf3b7fd92d
--- /dev/null
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/saga.test.ts
@@ -0,0 +1,111 @@
+/*
+ * 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 { CoreStart, HttpSetup } from 'kibana/public';
+import { applyMiddleware, createStore, Dispatch, Store } from 'redux';
+import { createSagaMiddleware, SagaContext } from '../../lib';
+import { endpointListSaga } from './saga';
+import { coreMock } from '../../../../../../../../src/core/public/mocks';
+import {
+ EndpointData,
+ EndpointListAction,
+ EndpointListData,
+ endpointListReducer,
+ EndpointListState,
+} from './index';
+import { endpointListData } from './selectors';
+
+describe('endpoint list saga', () => {
+ const sleep = (ms = 100) => new Promise(wakeup => setTimeout(wakeup, ms));
+ let fakeCoreStart: jest.Mocked;
+ let fakeHttpServices: jest.Mocked;
+ let store: Store;
+ let dispatch: Dispatch;
+
+ // TODO: consolidate the below ++ helpers in `index.test.ts` into a `test_helpers.ts`??
+ const generateEndpoint = (): EndpointData => {
+ return {
+ machine_id: Math.random()
+ .toString(16)
+ .substr(2),
+ created_at: new Date(),
+ host: {
+ name: '',
+ hostname: '',
+ ip: '',
+ mac_address: '',
+ os: {
+ name: '',
+ full: '',
+ },
+ },
+ endpoint: {
+ domain: '',
+ is_base_image: true,
+ active_directory_distinguished_name: '',
+ active_directory_hostname: '',
+ upgrade: {
+ status: '',
+ updated_at: new Date(),
+ },
+ isolation: {
+ status: false,
+ request_status: true,
+ updated_at: new Date(),
+ },
+ policy: {
+ name: '',
+ id: '',
+ },
+ sensor: {
+ persistence: true,
+ status: {},
+ },
+ },
+ };
+ };
+ const getEndpointListApiResponse = (): EndpointListData => {
+ return {
+ endpoints: [generateEndpoint()],
+ request_page_size: 1,
+ request_index: 1,
+ total: 10,
+ };
+ };
+
+ const endpointListSagaFactory = () => {
+ return async (sagaContext: SagaContext) => {
+ await endpointListSaga(sagaContext, fakeCoreStart).catch((e: Error) => {
+ // eslint-disable-next-line no-console
+ console.error(e);
+ return Promise.reject(e);
+ });
+ };
+ };
+
+ beforeEach(() => {
+ fakeCoreStart = coreMock.createStart({ basePath: '/mock' });
+ fakeHttpServices = fakeCoreStart.http as jest.Mocked;
+ store = createStore(
+ endpointListReducer,
+ applyMiddleware(createSagaMiddleware(endpointListSagaFactory()))
+ );
+ dispatch = store.dispatch;
+ });
+
+ test('it handles `userEnteredEndpointListPage`', async () => {
+ const apiResponse = getEndpointListApiResponse();
+
+ fakeHttpServices.post.mockResolvedValue(apiResponse);
+ expect(fakeHttpServices.post).not.toHaveBeenCalled();
+
+ dispatch({ type: 'userEnteredEndpointListPage' });
+ await sleep();
+
+ expect(fakeHttpServices.post).toHaveBeenCalledWith('/api/endpoint/endpoints');
+ expect(endpointListData(store.getState())).toEqual(apiResponse.endpoints);
+ });
+});
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/saga.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/saga.ts
new file mode 100644
index 000000000000..cc156cfa8800
--- /dev/null
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/saga.ts
@@ -0,0 +1,26 @@
+/*
+ * 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 { CoreStart } from 'kibana/public';
+import { SagaContext } from '../../lib';
+import { EndpointListAction } from './action';
+
+export const endpointListSaga = async (
+ { actionsAndState, dispatch }: SagaContext,
+ coreStart: CoreStart
+) => {
+ const { post: httpPost } = coreStart.http;
+
+ for await (const { action } of actionsAndState()) {
+ if (action.type === 'userEnteredEndpointListPage') {
+ const response = await httpPost('/api/endpoint/endpoints');
+ dispatch({
+ type: 'serverReturnedEndpointList',
+ payload: response,
+ });
+ }
+ }
+};
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/selectors.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/selectors.ts
new file mode 100644
index 000000000000..6ffcebc3f41a
--- /dev/null
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/selectors.ts
@@ -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 { EndpointListState } from './types';
+
+export const endpointListData = (state: EndpointListState) => state.endpoints;
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/types.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/types.ts
new file mode 100644
index 000000000000..f2810dd89f85
--- /dev/null
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/endpoint_list/types.ts
@@ -0,0 +1,54 @@
+/*
+ * 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.
+ */
+
+// FIXME: temporary until server defined `interface` is moved
+export interface EndpointData {
+ machine_id: string;
+ created_at: Date;
+ host: {
+ name: string;
+ hostname: string;
+ ip: string;
+ mac_address: string;
+ os: {
+ name: string;
+ full: string;
+ };
+ };
+ endpoint: {
+ domain: string;
+ is_base_image: boolean;
+ active_directory_distinguished_name: string;
+ active_directory_hostname: string;
+ upgrade: {
+ status?: string;
+ updated_at?: Date;
+ };
+ isolation: {
+ status: boolean;
+ request_status?: string | boolean;
+ updated_at?: Date;
+ };
+ policy: {
+ name: string;
+ id: string;
+ };
+ sensor: {
+ persistence: boolean;
+ status: object;
+ };
+ };
+}
+
+// FIXME: temporary until server defined `interface` is moved to a module we can reference
+export interface EndpointListData {
+ endpoints: EndpointData[];
+ request_page_size: number;
+ request_index: number;
+ total: number;
+}
+
+export type EndpointListState = EndpointListData;
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/index.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/index.ts
new file mode 100644
index 000000000000..d0dc002031ce
--- /dev/null
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/index.ts
@@ -0,0 +1,24 @@
+/*
+ * 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 { createStore, compose, applyMiddleware } from 'redux';
+import { CoreStart } from 'kibana/public';
+import { appSagaFactory } from './saga';
+import { appReducer } from './reducer';
+
+export { GlobalState } from './reducer';
+
+const composeWithReduxDevTools = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
+ ? (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ name: 'EndpointApp' })
+ : compose;
+
+export const appStoreFactory = (coreStart: CoreStart) => {
+ const store = createStore(
+ appReducer,
+ composeWithReduxDevTools(applyMiddleware(appSagaFactory(coreStart)))
+ );
+ return store;
+};
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/reducer.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/reducer.ts
new file mode 100644
index 000000000000..59ca4de91ac8
--- /dev/null
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/reducer.ts
@@ -0,0 +1,16 @@
+/*
+ * 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 { combineReducers, Reducer } from 'redux';
+import { endpointListReducer, EndpointListState } from './endpoint_list';
+import { AppAction } from './actions';
+
+export interface GlobalState {
+ endpointList: EndpointListState;
+}
+
+export const appReducer: Reducer = combineReducers({
+ endpointList: endpointListReducer,
+});
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/saga.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/saga.ts
new file mode 100644
index 000000000000..3b7de79d5443
--- /dev/null
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/saga.ts
@@ -0,0 +1,18 @@
+/*
+ * 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 { CoreStart } from 'kibana/public';
+import { createSagaMiddleware, SagaContext } from '../lib';
+import { endpointListSaga } from './endpoint_list';
+
+export const appSagaFactory = (coreStart: CoreStart) => {
+ return createSagaMiddleware(async (sagaContext: SagaContext) => {
+ await Promise.all([
+ // Concerns specific sagas here
+ endpointListSaga(sagaContext, coreStart),
+ ]);
+ });
+};