From da889362743491b9e18887a9e777c0ca0fdc8d6c Mon Sep 17 00:00:00 2001 From: Mike Cousins Date: Mon, 9 Jul 2018 17:33:45 -0400 Subject: [PATCH] feat(app): Add GET /modules to API client (#1837) --- .../http-api-client/__tests__/modules.test.js | 92 ++++++++++++++ .../http-api-client/__tests__/reducer.test.js | 118 ++++++++++++++++++ app/src/http-api-client/actions.js | 6 + app/src/http-api-client/index.js | 9 +- app/src/http-api-client/modules.js | 57 +++++++++ app/src/http-api-client/reducer.js | 75 +++++++++++ 6 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 app/src/http-api-client/__tests__/modules.test.js create mode 100644 app/src/http-api-client/__tests__/reducer.test.js create mode 100644 app/src/http-api-client/modules.js create mode 100644 app/src/http-api-client/reducer.js diff --git a/app/src/http-api-client/__tests__/modules.test.js b/app/src/http-api-client/__tests__/modules.test.js new file mode 100644 index 00000000000..4c35e3e9664 --- /dev/null +++ b/app/src/http-api-client/__tests__/modules.test.js @@ -0,0 +1,92 @@ +// http api /modules tests +import configureMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' + +import client from '../client' +import {fetchModules, makeGetRobotModules} from '..' + +jest.mock('../client') + +const middlewares = [thunk] +const mockStore = configureMockStore(middlewares) + +const NAME = 'opentrons-dev' + +const modules = [ + { + model: 'model', + serial: 'serial', + fwVersion: 'fwVersion', + status: 'status', + displayName: 'displayName' + } +] + +describe('/modules', () => { + let robot + let state + let store + + beforeEach(() => { + client.__clearMock() + + robot = {name: NAME, ip: '1.2.3.4', port: '1234'} + state = {api: {api: {}}} + store = mockStore(state) + }) + + describe('fetchModules action creator', () => { + const path = 'modules' + const response = {modules} + + test('calls GET /modules', () => { + client.__setMockResponse(response) + + return store.dispatch(fetchModules(robot)) + .then(() => + expect(client).toHaveBeenCalledWith(robot, 'GET', path)) + }) + + test('dispatches api:REQUEST and api:SUCCESS', () => { + const request = null + const expectedActions = [ + {type: 'api:REQUEST', payload: {robot, request, path}}, + {type: 'api:SUCCESS', payload: {robot, response, path}} + ] + + client.__setMockResponse(response) + + return store.dispatch(fetchModules(robot)) + .then(() => expect(store.getActions()).toEqual(expectedActions)) + }) + + test('dispatches api:REQUEST and api:FAILURE', () => { + const request = null + const error = {name: 'ResponseError', status: 500, message: ''} + const expectedActions = [ + {type: 'api:REQUEST', payload: {robot, request, path}}, + {type: 'api:FAILURE', payload: {robot, error, path}} + ] + + client.__setMockError(error) + + return store.dispatch(fetchModules(robot)) + .then(() => expect(store.getActions()).toEqual(expectedActions)) + }) + }) + + describe('selectors', () => { + beforeEach(() => { + state.api.api[NAME] = { + modules: {inProgress: true} + } + }) + + test('makeGetRobotModules', () => { + const getModules = makeGetRobotModules() + + expect(getModules(state, robot)).toEqual(state.api.api[NAME].modules) + expect(getModules(state, {name: 'foo'})).toEqual({inProgress: false}) + }) + }) +}) diff --git a/app/src/http-api-client/__tests__/reducer.test.js b/app/src/http-api-client/__tests__/reducer.test.js new file mode 100644 index 00000000000..52c3290808e --- /dev/null +++ b/app/src/http-api-client/__tests__/reducer.test.js @@ -0,0 +1,118 @@ +// tests for generic api reducer +import apiReducer from '../reducer' + +describe('apiReducer', () => { + test('handles api:REQUEST', () => { + const emptyState = {} + const oldRequestState = { + name: { + otherPath: {inProgress: false}, + path: { + inProgress: false, + request: {}, + response: {baz: 'qux'}, + error: new Error('AH') + } + } + } + + const action = { + type: 'api:REQUEST', + payload: {robot: {name: 'name'}, path: 'path', request: {foo: 'bar'}} + } + + expect(apiReducer(emptyState, action)).toEqual({ + name: { + path: {inProgress: true, request: {foo: 'bar'}, error: null} + } + }) + + expect(apiReducer(oldRequestState, action)).toEqual({ + name: { + otherPath: {inProgress: false}, + path: { + inProgress: true, + request: {foo: 'bar'}, + response: {baz: 'qux'}, + error: null + } + } + }) + }) + + test('handles api:SUCCESS', () => { + const emptyState = {} + const oldRequestState = { + name: { + otherPath: {inProgress: false}, + path: { + inProgress: true, + request: {foo: 'bar'}, + response: {fizz: 'buzz'}, + error: new Error('AH') + } + } + } + + const action = { + type: 'api:SUCCESS', + payload: {robot: {name: 'name'}, path: 'path', response: {baz: 'qux'}} + } + + expect(apiReducer(emptyState, action)).toEqual({ + name: { + path: {inProgress: false, response: {baz: 'qux'}, error: null} + } + }) + + expect(apiReducer(oldRequestState, action)).toEqual({ + name: { + otherPath: {inProgress: false}, + path: { + inProgress: false, + request: {foo: 'bar'}, + response: {baz: 'qux'}, + error: null + } + } + }) + }) + + test('handles api:FAILURE', () => { + const emptyState = {} + const oldRequestState = { + name: { + otherPath: {inProgress: false}, + path: { + inProgress: true, + request: {foo: 'bar'}, + response: {baz: 'qux'}, + error: null + } + } + } + + const action = { + type: 'api:FAILURE', + payload: {robot: {name: 'name'}, path: 'path', error: new Error('AH')} + } + + expect(apiReducer(emptyState, action)).toEqual({ + name: { + path: {inProgress: false, error: new Error('AH')} + } + }) + + expect(apiReducer(oldRequestState, action)).toEqual({ + name: { + otherPath: {inProgress: false}, + path: { + inProgress: false, + request: {foo: 'bar'}, + response: {baz: 'qux'}, + error: new Error('AH') + } + } + }) + }) +}) diff --git a/app/src/http-api-client/actions.js b/app/src/http-api-client/actions.js index 469e2edb6a0..729c8be145d 100644 --- a/app/src/http-api-client/actions.js +++ b/app/src/http-api-client/actions.js @@ -42,6 +42,12 @@ export type ClearApiResponseAction = {| |} |} +export type ApiAction = + | ApiRequestAction + | ApiSuccessAction + | ApiFailureAction + | ClearApiResponseAction + export function apiRequest ( robot: BaseRobot, path: Path, diff --git a/app/src/http-api-client/index.js b/app/src/http-api-client/index.js index 6ade2bb74ed..6f5e28b46f8 100644 --- a/app/src/http-api-client/index.js +++ b/app/src/http-api-client/index.js @@ -1,8 +1,10 @@ // @flow // robot HTTP API client module import {combineReducers} from 'redux' +import apiReducer from './reducer' import {calibrationReducer, type CalibrationAction} from './calibration' import {healthReducer, type HealthAction} from './health' +import type {ModulesAction} from './modules' import {motorsReducer, type MotorsAction} from './motors' import {pipettesReducer, type PipettesAction} from './pipettes' import {robotReducer, type RobotAction} from './robot' @@ -18,7 +20,9 @@ export const reducer = combineReducers({ robot: robotReducer, server: serverReducer, settings: settingsReducer, - wifi: wifiReducer + wifi: wifiReducer, + // TODO(mc, 2018-07-09): api subreducer will become the sole reducer + api: apiReducer }) export * from './types' @@ -79,6 +83,7 @@ export type State = $Call export type Action = | CalibrationAction | HealthAction + | ModulesAction | MotorsAction | PipettesAction | RobotAction @@ -98,6 +103,8 @@ export { makeGetRobotHealth } from './health' +export * from './modules' + export { disengagePipetteMotors } from './motors' diff --git a/app/src/http-api-client/modules.js b/app/src/http-api-client/modules.js new file mode 100644 index 00000000000..d76da17b5ae --- /dev/null +++ b/app/src/http-api-client/modules.js @@ -0,0 +1,57 @@ +// @flow +// API client for modules (the robot kind) +import {createSelector, type Selector} from 'reselect' + +import type {State, ThunkPromiseAction} from '../types' +import type {BaseRobot, RobotService} from '../robot' +import type {ApiCall, ApiRequestError} from './types' +import type {ApiAction} from './actions' + +import {apiRequest, apiSuccess, apiFailure} from './actions' +import {getRobotApiState} from './reducer' +import client from './client' + +export type Module = { + model: string, + serial: string, + fwVersion: string, + status: string, + displayName: string, +} + +type FetchModulesResponse = { + modules: Array, +} + +type FetchModulesCall = ApiCall + +export type ModulesAction = + | ApiAction<'modules', null, FetchModulesResponse> + +export type ModulesState = { + modules?: FetchModulesCall +} + +const MODULES: 'modules' = 'modules' + +export function fetchModules (robot: RobotService): ThunkPromiseAction { + return (dispatch) => { + dispatch(apiRequest(robot, MODULES, null)) + + return client(robot, 'GET', MODULES) + .then( + (resp: FetchModulesResponse) => apiSuccess(robot, MODULES, resp), + (err: ApiRequestError) => apiFailure(robot, MODULES, err) + ) + .then(dispatch) + } +} + +export function makeGetRobotModules () { + const selector: Selector = createSelector( + getRobotApiState, + (state) => state[MODULES] || {inProgress: false} + ) + + return selector +} diff --git a/app/src/http-api-client/reducer.js b/app/src/http-api-client/reducer.js new file mode 100644 index 00000000000..caa40a43144 --- /dev/null +++ b/app/src/http-api-client/reducer.js @@ -0,0 +1,75 @@ +// @flow +// generic api reducer + +import type {State, Action} from '../types' +import type {BaseRobot} from '../robot' +import type {ModulesState} from './modules' + +type RobotApiState = + & ModulesState + +type ApiState = {[name: string]: ?RobotApiState} + +export default function apiReducer ( + state: ApiState = {}, + action: Action +): ApiState { + switch (action.type) { + case 'api:REQUEST': { + const {request} = action.payload + const {name, path, stateByName, stateByPath} = getUpdateInfo(state, action) + + return { + ...state, + [name]: { + ...stateByName, + [path]: {...stateByPath, request, inProgress: true, error: null} + } + } + } + + case 'api:SUCCESS': { + const {response} = action.payload + const {name, path, stateByName, stateByPath} = getUpdateInfo(state, action) + + return { + ...state, + [name]: { + ...stateByName, + [path]: {...stateByPath, response, inProgress: false, error: null} + } + } + } + + case 'api:FAILURE': { + const {error} = action.payload + const {name, path, stateByName, stateByPath} = getUpdateInfo(state, action) + + return { + ...state, + [name]: { + ...stateByName, + [path]: {...stateByPath, error, inProgress: false} + } + } + } + } + + return state +} + +export function getRobotApiState ( + state: State, + props: BaseRobot +): RobotApiState { + return state.api.api[props.name] || {} +} + +function getUpdateInfo (state: ApiState, action: *): * { + const {path, robot: {name}} = action.payload + const stateByName = state[name] || {} + // $FlowFixMe: type RobotApiState properly + const stateByPath = stateByName[path] || {} + + return {name, path, stateByName, stateByPath} +}