diff --git a/app/src/components/RobotSettings/ControlsCard.js b/app/src/components/RobotSettings/ControlsCard.js index b4c3de22ca7..898430add95 100644 --- a/app/src/components/RobotSettings/ControlsCard.js +++ b/app/src/components/RobotSettings/ControlsCard.js @@ -1,92 +1,56 @@ // @flow // "Robot Controls" card import * as React from 'react' -import { connect } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { push } from 'connected-react-router' -import { - home, - fetchRobotLights, - setRobotLights, - makeGetRobotLights, - startDeckCalibration, -} from '../../http-api-client' - +import { home, startDeckCalibration } from '../../http-api-client' +import { fetchLights, updateLights, getLightsOn } from '../../robot-controls' import { restartRobot } from '../../robot-admin' import { selectors as robotSelectors } from '../../robot' import { CONNECTABLE } from '../../discovery' -import { - RefreshCard, - LabeledToggle, - LabeledButton, -} from '@opentrons/components' +import { Card, LabeledToggle, LabeledButton } from '@opentrons/components' import type { State, Dispatch } from '../../types' import type { ViewableRobot } from '../../discovery/types' -type OP = {| +type Props = {| robot: ViewableRobot, calibrateDeckUrl: string, |} -type SP = {| - lightsOn: boolean, - homeEnabled: boolean, - restartEnabled: boolean, -|} - -type DP = {| - dispatch: Dispatch, -|} - -type Props = { - ...OP, - ...SP, - homeAll: () => mixed, - restartRobot: () => mixed, - fetchLights: () => mixed, - toggleLights: () => mixed, - start: () => mixed, -} - const TITLE = 'Robot Controls' -export default connect( - makeMakeStateToProps, - null, - mergeProps -)(ControlsCard) - const CALIBRATE_DECK_DESCRIPTION = "Calibrate the position of the robot's deck. Recommended for all new robots and after moving robots." -function ControlsCard(props: Props) { - const { - lightsOn, - fetchLights, - toggleLights, - homeAll, - homeEnabled, - restartRobot, - restartEnabled, - start, - } = props - const { name, status } = props.robot - const disabled = status !== CONNECTABLE +export function ControlsCard(props: Props) { + const dispatch = useDispatch() + const { robot, calibrateDeckUrl } = props + const { name: robotName, status } = robot + const lightsOn = useSelector((state: State) => getLightsOn(state, robotName)) + const isRunning = useSelector(robotSelectors.getIsRunning) + const notConnectable = status !== CONNECTABLE + const toggleLights = () => dispatch(updateLights(robotName, !lightsOn)) + const canControl = robot.connected && !isRunning + + const startCalibration = () => + dispatch(startDeckCalibration(robot)).then(() => + dispatch(push(calibrateDeckUrl)) + ) + + React.useEffect(() => { + dispatch(fetchLights(robotName)) + }, [dispatch, robotName]) return ( - + @@ -95,8 +59,8 @@ function ControlsCard(props: Props) { dispatch(home(robot)), + disabled: notConnectable || !canControl, children: 'Home', }} > @@ -105,51 +69,20 @@ function ControlsCard(props: Props) { dispatch(restartRobot(robotName)), + disabled: notConnectable || !canControl, children: 'Restart', }} >

Restart robot.

- +

Control lights on deck.

-
+ ) } - -function makeMakeStateToProps(): (state: State, ownProps: OP) => SP { - const getRobotLights = makeGetRobotLights() - - return (state, ownProps) => { - const { robot } = ownProps - const lights = getRobotLights(state, robot) - const isRunning = robotSelectors.getIsRunning(state) - - return { - lightsOn: !!(lights && lights.response && lights.response.on), - homeEnabled: robot.connected === true && !isRunning, - restartEnabled: robot.connected === true && !isRunning, - } - } -} - -function mergeProps(stateProps: SP, dispatchProps: DP, ownProps: OP): Props { - const { robot, calibrateDeckUrl } = ownProps - const { lightsOn } = stateProps - const { dispatch } = dispatchProps - - return { - ...ownProps, - ...stateProps, - homeAll: () => dispatch(home(robot)), - restartRobot: () => dispatch(restartRobot(robot.name)), - fetchLights: () => dispatch(fetchRobotLights(robot)), - toggleLights: () => dispatch(setRobotLights(robot, !lightsOn)), - start: () => - dispatch(startDeckCalibration(robot)).then(() => - dispatch(push(calibrateDeckUrl)) - ), - } -} diff --git a/app/src/components/RobotSettings/__tests__/ControlsCard.test.js b/app/src/components/RobotSettings/__tests__/ControlsCard.test.js new file mode 100644 index 00000000000..dd4b928171e --- /dev/null +++ b/app/src/components/RobotSettings/__tests__/ControlsCard.test.js @@ -0,0 +1,195 @@ +// @flow +import * as React from 'react' +import { Provider } from 'react-redux' +import { mount } from 'enzyme' + +import * as RobotControls from '../../../robot-controls' +import * as RobotAdmin from '../../../robot-admin' +import * as RobotSelectors from '../../../robot/selectors' +import { ControlsCard } from '../ControlsCard' +import { LabeledToggle, LabeledButton } from '@opentrons/components' +import { CONNECTABLE, REACHABLE } from '../../../discovery' + +import type { State } from '../../../types' +import type { ViewableRobot } from '../../../discovery/types' + +jest.mock('../../../robot-controls/selectors') +jest.mock('../../../robot/selectors') + +const mockRobot: ViewableRobot = ({ + name: 'robot-name', + connected: true, + status: CONNECTABLE, +}: any) + +const mockUnconnectableRobot: ViewableRobot = ({ + name: 'robot-name', + connected: true, + status: REACHABLE, +}: any) + +const mockGetLightsOn: JestMockFn< + [State, string], + $Call +> = RobotControls.getLightsOn + +const mockGetIsRunning: JestMockFn< + [State], + $Call +> = RobotSelectors.getIsRunning + +describe('ControlsCard', () => { + let mockStore + + const getDeckCalButton = wrapper => + wrapper + .find({ label: 'Calibrate deck' }) + .find(LabeledButton) + .find('button') + + const getHomeButton = wrapper => + wrapper + .find({ label: 'Home all axes' }) + .find(LabeledButton) + .find('button') + + const getRestartButton = wrapper => + wrapper + .find({ label: 'Restart robot' }) + .find(LabeledButton) + .find('button') + + const getLightsButton = wrapper => + wrapper.find({ label: 'Lights' }).find(LabeledToggle) + + beforeEach(() => { + mockStore = { + subscribe: () => {}, + getState: () => ({ mockState: true }), + dispatch: jest.fn(), + } + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + test('calls fetchLights on mount', () => { + mount( + + + + ) + + expect(mockStore.dispatch).toHaveBeenCalledWith( + RobotControls.fetchLights(mockRobot.name) + ) + }) + + test('calls updateLights with toggle on button click', () => { + mockGetLightsOn.mockReturnValue(true) + + const wrapper = mount( + + + + ) + + getLightsButton(wrapper).invoke('onClick')() + + expect(mockStore.dispatch).toHaveBeenCalledWith( + RobotControls.updateLights(mockRobot.name, false) + ) + }) + + test('calls restartRobot on button click', () => { + const wrapper = mount( + + + + ) + + getRestartButton(wrapper).invoke('onClick')() + + expect(mockStore.dispatch).toHaveBeenCalledWith( + RobotAdmin.restartRobot(mockRobot.name) + ) + }) + + // TODO(mc, 2019-12-17): enable test when POST /robot/home is epic-based + test.skip('calls home on button click', () => { + const wrapper = mount( + + + + ) + + getHomeButton(wrapper).invoke('onClick')() + + // expect(mockStore.dispatch).toHaveBeenCalledWith(RobotControls.home(mockRobot.name)) + }) + + test('DC, home, and restart buttons enabled if connected and not running', () => { + mockGetIsRunning.mockReturnValue(false) + + const wrapper = mount( + + + + ) + + expect(getDeckCalButton(wrapper).prop('disabled')).toBe(true) + expect(getHomeButton(wrapper).prop('disabled')).toBe(true) + expect(getRestartButton(wrapper).prop('disabled')).toBe(true) + }) + + test('DC, home, and restart buttons disabled if not connectable', () => { + const wrapper = mount( + + + + ) + + expect(getDeckCalButton(wrapper).prop('disabled')).toBe(true) + expect(getHomeButton(wrapper).prop('disabled')).toBe(true) + expect(getRestartButton(wrapper).prop('disabled')).toBe(true) + }) + + test('DC, home, and restart buttons disabled if not connected', () => { + const mockRobot: ViewableRobot = ({ + name: 'robot-name', + connected: false, + status: CONNECTABLE, + }: any) + + const wrapper = mount( + + + + ) + + expect(getDeckCalButton(wrapper).prop('disabled')).toBe(true) + expect(getHomeButton(wrapper).prop('disabled')).toBe(true) + expect(getRestartButton(wrapper).prop('disabled')).toBe(true) + }) + + test('DC, home, and restart buttons disabled if protocol running', () => { + mockGetIsRunning.mockReturnValue(true) + + const wrapper = mount( + + + + ) + + expect(getDeckCalButton(wrapper).prop('disabled')).toBe(true) + expect(getHomeButton(wrapper).prop('disabled')).toBe(true) + expect(getRestartButton(wrapper).prop('disabled')).toBe(true) + }) +}) diff --git a/app/src/components/RobotSettings/index.js b/app/src/components/RobotSettings/index.js index 849c4281283..5f16df23468 100644 --- a/app/src/components/RobotSettings/index.js +++ b/app/src/components/RobotSettings/index.js @@ -5,7 +5,7 @@ import * as React from 'react' import { CardContainer, CardRow } from '../layout' import StatusCard from './StatusCard' import InformationCard from './InformationCard' -import ControlsCard from './ControlsCard' +import { ControlsCard } from './ControlsCard' import ConnectionCard from './ConnectionCard' import AdvancedSettingsCard from './AdvancedSettingsCard' import ConnectAlertModal from './ConnectAlertModal' diff --git a/app/src/epic.js b/app/src/epic.js index 0e365363e68..625a14f4e3c 100644 --- a/app/src/epic.js +++ b/app/src/epic.js @@ -5,6 +5,7 @@ import { combineEpics } from 'redux-observable' import { analyticsEpic } from './analytics' import { discoveryEpic } from './discovery/epic' import { robotAdminEpic } from './robot-admin/epic' +import { robotControlsEpic } from './robot-controls/epic' import { robotSettingsEpic } from './robot-settings/epic' import { pipettesEpic } from './pipettes/epic' import { modulesEpic } from './modules/epic' @@ -14,6 +15,7 @@ export default combineEpics( analyticsEpic, discoveryEpic, robotAdminEpic, + robotControlsEpic, robotSettingsEpic, pipettesEpic, modulesEpic, diff --git a/app/src/http-api-client/__tests__/robot.test.js b/app/src/http-api-client/__tests__/robot.test.js index f7e04e54300..524451cbe21 100644 --- a/app/src/http-api-client/__tests__/robot.test.js +++ b/app/src/http-api-client/__tests__/robot.test.js @@ -6,14 +6,11 @@ import client from '../client' import { moveRobotTo, home, - fetchRobotLights, - setRobotLights, clearHomeResponse, clearMoveResponse, superDeprecatedRobotApiReducer as reducer, makeGetRobotMove, makeGetRobotHome, - makeGetRobotLights, } from '..' jest.mock('../client') @@ -181,100 +178,6 @@ describe('robot/*', () => { }) }) - describe('fetchRobotLights action creator', () => { - const path = 'robot/lights' - const request = null - const response = { on: true } - - test('calls GET /robot/lights', () => { - client.__setMockResponse(response) - - return store - .dispatch(fetchRobotLights(robot)) - .then(() => - expect(client).toHaveBeenCalledWith(robot, 'GET', 'robot/lights') - ) - }) - - test('dispatches api:REQUEST and api:SUCCESS', () => { - const expectedActions = [ - { type: 'api:REQUEST', payload: { robot, request, path } }, - { type: 'api:SUCCESS', payload: { robot, response, path } }, - ] - - client.__setMockResponse(response) - - return store - .dispatch(fetchRobotLights(robot)) - .then(() => expect(store.getActions()).toEqual(expectedActions)) - }) - - test('dispatches api:REQUEST and api:FAILURE', () => { - const error = { name: 'ResponseError', status: '400' } - const expectedActions = [ - { type: 'api:REQUEST', payload: { robot, request, path } }, - { type: 'api:FAILURE', payload: { robot, error, path } }, - ] - - client.__setMockError(error) - - return store - .dispatch(fetchRobotLights(robot)) - .then(() => expect(store.getActions()).toEqual(expectedActions)) - }) - }) - - describe('setRobotLights action creator', () => { - const path = 'robot/lights' - const response = { on: false } - - test('calls POST /robot/home to home robot', () => { - const expectedBody = { on: false } - - client.__setMockResponse(response) - - return store - .dispatch(setRobotLights(robot, false)) - .then(() => - expect(client).toHaveBeenCalledWith( - robot, - 'POST', - 'robot/lights', - expectedBody - ) - ) - }) - - test('dispatches api:REQUEST and api:SUCCESS', () => { - const request = { on: true } - const expectedActions = [ - { type: 'api:REQUEST', payload: { robot, request, path } }, - { type: 'api:SUCCESS', payload: { robot, response, path } }, - ] - - client.__setMockResponse(response) - - return store - .dispatch(setRobotLights(robot, true)) - .then(() => expect(store.getActions()).toEqual(expectedActions)) - }) - - test('dispatches api:REQUEST and api:FAILURE', () => { - const request = { on: false } - const error = { name: 'ResponseError', status: '400' } - const expectedActions = [ - { type: 'api:REQUEST', payload: { robot, request, path } }, - { type: 'api:FAILURE', payload: { robot, error, path } }, - ] - - client.__setMockError(error) - - return store - .dispatch(setRobotLights(robot, false)) - .then(() => expect(store.getActions()).toEqual(expectedActions)) - }) - }) - const REDUCER_REQUEST_RESPONSE_TESTS = [ { path: 'robot/move', @@ -286,11 +189,6 @@ describe('robot/*', () => { request: { target: 'pipette', mount: 'left' }, response: { message: 'we did it!' }, }, - { - path: 'robot/lights', - request: null, - response: { on: true }, - }, ] REDUCER_REQUEST_RESPONSE_TESTS.forEach(spec => { @@ -435,14 +333,5 @@ describe('robot/*', () => { ) expect(getHome(state, { name: 'foo' })).toEqual({ inProgress: false }) }) - - test('makeGetRobotLights', () => { - const getLights = makeGetRobotLights() - - expect(getLights(state, robot)).toEqual( - state.superDeprecatedRobotApi.robot[NAME]['robot/lights'] - ) - expect(getLights(state, { name: 'foo' })).toEqual({ inProgress: false }) - }) }) }) diff --git a/app/src/http-api-client/index.js b/app/src/http-api-client/index.js index 495c042e25d..b4241f594bd 100644 --- a/app/src/http-api-client/index.js +++ b/app/src/http-api-client/index.js @@ -1,4 +1,6 @@ // @flow +// DEPRECATED - do not add to nor import from this module if you can help it +// TODO(mc, 2019-12-17): remove when able // robot HTTP API client module import { combineReducers } from 'redux' import apiReducer from './reducer' @@ -33,7 +35,7 @@ export type { DeckCalPoint, } from './calibration' -export type { RobotMove, RobotHome, RobotLights } from './robot' +export type { RobotMove, RobotHome } from './robot' export type State = $Call @@ -62,9 +64,6 @@ export { clearHomeResponse, moveRobotTo, clearMoveResponse, - fetchRobotLights, - setRobotLights, makeGetRobotMove, makeGetRobotHome, - makeGetRobotLights, } from './robot' diff --git a/app/src/http-api-client/robot.js b/app/src/http-api-client/robot.js index 636c71fe8c9..88b1bc142fb 100644 --- a/app/src/http-api-client/robot.js +++ b/app/src/http-api-client/robot.js @@ -58,19 +58,11 @@ type RobotHomeResponse = { message: string, } -type RobotLightsRequest = ?{ - on: boolean, -} - -type RobotLightsResponse = { - on: boolean, -} +type RobotPath = 'robot/move' | 'robot/home' -type RobotPath = 'robot/move' | 'robot/home' | 'robot/lights' +type RobotRequest = RobotMoveRequest | RobotHomeRequest -type RobotRequest = RobotMoveRequest | RobotHomeRequest | RobotLightsRequest - -type RobotResponse = RobotMoveResponse | RobotHomeResponse | RobotLightsResponse +type RobotResponse = RobotMoveResponse | RobotHomeResponse export type RobotAction = | ApiRequestAction @@ -82,12 +74,9 @@ export type RobotMove = ApiCall export type RobotHome = ApiCall -export type RobotLights = ApiCall - type RobotByNameState = { 'robot/move'?: RobotMove, 'robot/home'?: RobotHome, - 'robot/lights'?: RobotLights, } type RobotState = { @@ -98,13 +87,12 @@ type RobotState = { const POSITIONS = 'robot/positions' const MOVE: 'robot/move' = 'robot/move' const HOME: 'robot/home' = 'robot/home' -const LIGHTS: 'robot/lights' = 'robot/lights' // TODO(mc, 2018-07-03): flow helper until we have one reducer, since // p === 'constant' checks but p === CONSTANT does not, even if // CONSTANT is defined as `const CONSTANT: 'constant' = 'constant'` function getRobotPath(p: string): ?RobotPath { - if (p === 'robot/move' || p === 'robot/home' || p === 'robot/lights') { + if (p === 'robot/move' || p === 'robot/home') { return p } @@ -165,23 +153,6 @@ export function home(robot: RobotService, mount?: Mount): ThunkPromiseAction { } } -export function fetchRobotLights(robot: RobotService): ThunkPromiseAction { - return dispatch => { - // $FlowFixMe: (mc, 2019-04-17): http-api-client types need to be redone - dispatch(apiRequest(robot, LIGHTS, null)) - - return ( - client(robot, 'GET', LIGHTS) - .then( - (resp: RobotLightsResponse) => apiSuccess(robot, LIGHTS, resp), - (err: ApiRequestError) => apiFailure(robot, LIGHTS, err) - ) - // $FlowFixMe: (mc, 2019-04-17): http-api-client types need to be redone - .then(dispatch) - ) - } -} - export function clearHomeResponse( robot: BaseRobot ): ClearApiResponseAction { @@ -194,28 +165,6 @@ export function clearMoveResponse( return clearApiResponse(robot, MOVE) } -export function setRobotLights( - robot: RobotService, - on: boolean -): ThunkPromiseAction { - const request: RobotLightsRequest = { on } - - return dispatch => { - // $FlowFixMe: (mc, 2019-04-17): http-api-client types need to be redone - dispatch(apiRequest(robot, LIGHTS, request)) - - return ( - client(robot, 'POST', LIGHTS, request) - .then( - (resp: RobotLightsResponse) => apiSuccess(robot, LIGHTS, resp), - (err: ApiRequestError) => apiFailure(robot, LIGHTS, err) - ) - // $FlowFixMe: (mc, 2019-04-17): http-api-client types need to be redone - .then(dispatch) - ) - } -} - // TODO(mc, 2018-07-03): remove in favor of single HTTP API reducer export function robotReducer(state: ?RobotState, action: Action): RobotState { if (!state) return {} @@ -325,19 +274,6 @@ export const makeGetRobotHome = () => { return selector } -export const makeGetRobotLights = () => { - const selector: OutputSelector< - State, - BaseRobot, - RobotLights - > = createSelector( - selectRobotState, - state => state[LIGHTS] || { inProgress: false } - ) - - return selector -} - function selectRobotState(state: State, props: BaseRobot): RobotByNameState { return state.superDeprecatedRobotApi.robot[props.name] || {} } diff --git a/app/src/reducer.js b/app/src/reducer.js index 756e858d7fc..0e9f10bf839 100644 --- a/app/src/reducer.js +++ b/app/src/reducer.js @@ -17,6 +17,9 @@ import { robotApiReducer } from './robot-api/reducer' // robot administration state import { robotAdminReducer } from './robot-admin/reducer' +// robot controls state +import { robotControlsReducer } from './robot-controls/reducer' + // robot settings state import { robotSettingsReducer } from './robot-settings/reducer' @@ -51,6 +54,7 @@ const rootReducer: Reducer = combineReducers<_, Action>({ superDeprecatedRobotApi: superDeprecatedRobotApiReducer, robotApi: robotApiReducer, robotAdmin: robotAdminReducer, + robotControls: robotControlsReducer, robotSettings: robotSettingsReducer, pipettes: pipettesReducer, modules: modulesReducer, diff --git a/app/src/robot-api/__fixtures__/index.js b/app/src/robot-api/__fixtures__/index.js new file mode 100644 index 00000000000..c01934ca8fe --- /dev/null +++ b/app/src/robot-api/__fixtures__/index.js @@ -0,0 +1,4 @@ +// @flow +// generic, robot HTTP API fixtures + +export const mockRobot = { name: 'robot', ip: '127.0.0.1', port: 31950 } diff --git a/app/src/robot-controls/__fixtures__/index.js b/app/src/robot-controls/__fixtures__/index.js new file mode 100644 index 00000000000..1cbc4d336e4 --- /dev/null +++ b/app/src/robot-controls/__fixtures__/index.js @@ -0,0 +1,3 @@ +// @flow + +export * from './lights' diff --git a/app/src/robot-controls/__fixtures__/lights.js b/app/src/robot-controls/__fixtures__/lights.js new file mode 100644 index 00000000000..9ccbdbd7e4d --- /dev/null +++ b/app/src/robot-controls/__fixtures__/lights.js @@ -0,0 +1,60 @@ +// @flow +// mock HTTP responses for /robot/lights endpoints + +import { mockRobot } from '../../robot-api/__fixtures__' + +// GET /robot/lights + +export const mockFetchLightsSuccessMeta = { + method: 'GET', + path: '/robot/lights', + ok: true, + status: 200, +} + +export const mockFetchLightsSuccess = { + ...mockFetchLightsSuccessMeta, + host: mockRobot, + body: { on: false }, +} + +export const mockFetchLightsFailureMeta = { + method: 'GET', + path: '/robot/lights', + ok: false, + status: 500, +} + +export const mockFetchLightsFailure = { + ...mockFetchLightsFailureMeta, + host: mockRobot, + body: { message: 'AH' }, +} + +// POST /robot/lights + +export const mockUpdateLightsSuccessMeta = { + method: 'POST', + path: '/robot/lights', + ok: true, + status: 200, +} + +export const mockUpdateLightsSuccess = { + ...mockUpdateLightsSuccessMeta, + host: mockRobot, + body: { on: true }, +} + +export const mockUpdateLightsFailureMeta = { + method: 'POST', + path: '/robot/lights', + ok: false, + status: 500, +} + +export const mockUpdateLightsFailure = { + ...mockUpdateLightsFailureMeta, + host: mockRobot, + body: { message: 'AH' }, +} diff --git a/app/src/robot-controls/__tests__/actions.test.js b/app/src/robot-controls/__tests__/actions.test.js new file mode 100644 index 00000000000..a442b628a9c --- /dev/null +++ b/app/src/robot-controls/__tests__/actions.test.js @@ -0,0 +1,94 @@ +// @flow + +import * as Actions from '../actions' + +import type { RobotControlsAction } from '../types' + +type ActionSpec = {| + name: string, + creator: (...Array) => mixed, + args: Array, + expected: RobotControlsAction, +|} + +const SPECS: Array = [ + { + name: 'robotControls:FETCH_LIGHTS', + creator: Actions.fetchLights, + args: ['robot-name'], + expected: { + type: 'robotControls:FETCH_LIGHTS', + payload: { robotName: 'robot-name' }, + meta: {}, + }, + }, + { + name: 'robotControls:FETCH_LIGHTS_SUCCESS', + creator: Actions.fetchLightsSuccess, + args: ['robot-name', true, { requestId: 'abc' }], + expected: { + type: 'robotControls:FETCH_LIGHTS_SUCCESS', + payload: { + robotName: 'robot-name', + lightsOn: true, + }, + meta: { requestId: 'abc' }, + }, + }, + { + name: 'robotControls:FETCH_LIGHTS_FAILURE', + creator: Actions.fetchLightsFailure, + args: ['robot-name', { message: 'AH' }, { requestId: 'abc' }], + expected: { + type: 'robotControls:FETCH_LIGHTS_FAILURE', + payload: { + robotName: 'robot-name', + error: { message: 'AH' }, + }, + meta: { requestId: 'abc' }, + }, + }, + { + name: 'robotControls:UPDATE_LIGHTS', + creator: Actions.updateLights, + args: ['robot-name', true], + expected: { + type: 'robotControls:UPDATE_LIGHTS', + payload: { robotName: 'robot-name', lightsOn: true }, + meta: {}, + }, + }, + { + name: 'robotControls:UPDATE_LIGHTS_SUCCESS', + creator: Actions.updateLightsSuccess, + args: ['robot-name', true, { requestId: 'abc' }], + expected: { + type: 'robotControls:UPDATE_LIGHTS_SUCCESS', + payload: { + robotName: 'robot-name', + lightsOn: true, + }, + meta: { requestId: 'abc' }, + }, + }, + { + name: 'robotControls:UPDATE_LIGHTS_FAILURE', + creator: Actions.updateLightsFailure, + args: ['robot-name', { message: 'AH' }, { requestId: 'abc' }], + expected: { + type: 'robotControls:UPDATE_LIGHTS_FAILURE', + payload: { + robotName: 'robot-name', + error: { message: 'AH' }, + }, + meta: { requestId: 'abc' }, + }, + }, +] + +describe('robot controls actions', () => { + SPECS.forEach(spec => { + const { name, creator, args, expected } = spec + test(name, () => expect(creator(...args)).toEqual(expected)) + }) +}) diff --git a/app/src/robot-controls/__tests__/reducer.test.js b/app/src/robot-controls/__tests__/reducer.test.js new file mode 100644 index 00000000000..dd916c3aa40 --- /dev/null +++ b/app/src/robot-controls/__tests__/reducer.test.js @@ -0,0 +1,50 @@ +// @flow +import { robotControlsReducer } from '../reducer' + +import type { Action } from '../../types' +import type { RobotControlsState } from '../types' + +type ReducerSpec = {| + name: string, + state: RobotControlsState, + action: Action, + expected: RobotControlsState, +|} + +const SPECS: Array = [ + { + name: 'handles robotControls:FETCH_LIGHTS_SUCCESS', + action: { + type: 'robotControls:FETCH_LIGHTS_SUCCESS', + payload: { + robotName: 'robotName', + lightsOn: true, + }, + meta: {}, + }, + state: { robotName: { lightsOn: false } }, + expected: { robotName: { lightsOn: true } }, + }, + { + name: 'handles robotControls:UPDATE_LIGHTS_SUCCESS', + action: { + type: 'robotControls:UPDATE_LIGHTS_SUCCESS', + payload: { + robotName: 'robotName', + lightsOn: false, + }, + meta: {}, + }, + state: { robotName: { lightsOn: true } }, + expected: { robotName: { lightsOn: false } }, + }, +] + +describe('robotControlsReducer', () => { + SPECS.forEach(spec => { + const { name, state, action, expected } = spec + test(name, () => + expect(robotControlsReducer(state, action)).toEqual(expected) + ) + }) +}) diff --git a/app/src/robot-controls/__tests__/selectors.test.js b/app/src/robot-controls/__tests__/selectors.test.js new file mode 100644 index 00000000000..52890922458 --- /dev/null +++ b/app/src/robot-controls/__tests__/selectors.test.js @@ -0,0 +1,36 @@ +// @flow + +import * as Selectors from '../selectors' +import type { State } from '../../types' + +type SelectorSpec = {| + name: string, + selector: (State, ...Array) => mixed, + state: $Shape, + args?: Array, + expected: mixed, +|} + +const SPECS: Array = [ + { + name: 'getLightsOn returns null by default', + selector: Selectors.getLightsOn, + state: { robotControls: {} }, + args: ['robotName'], + expected: null, + }, + { + name: 'getLightsOn returns value if present', + selector: Selectors.getLightsOn, + state: { robotControls: { robotName: { lightsOn: false } } }, + args: ['robotName'], + expected: false, + }, +] + +describe('robot controls selectors', () => { + SPECS.forEach(spec => { + const { name, selector, state, args = [], expected } = spec + test(name, () => expect(selector(state, ...args)).toEqual(expected)) + }) +}) diff --git a/app/src/robot-controls/actions.js b/app/src/robot-controls/actions.js new file mode 100644 index 00000000000..eff1fb487aa --- /dev/null +++ b/app/src/robot-controls/actions.js @@ -0,0 +1,61 @@ +// @flow + +import * as Constants from './constants' +import * as Types from './types' + +import type { RobotApiRequestMeta } from '../robot-api/types' + +export const fetchLights = (robotName: string): Types.FetchLightsAction => ({ + type: Constants.FETCH_LIGHTS, + payload: { robotName }, + meta: {}, +}) + +export const fetchLightsSuccess = ( + robotName: string, + lightsOn: boolean, + meta: RobotApiRequestMeta +): Types.FetchLightsSuccessAction => ({ + type: Constants.FETCH_LIGHTS_SUCCESS, + payload: { robotName, lightsOn }, + meta, +}) + +export const fetchLightsFailure = ( + robotName: string, + error: {}, + meta: RobotApiRequestMeta +): Types.FetchLightsFailureAction => ({ + type: Constants.FETCH_LIGHTS_FAILURE, + payload: { robotName, error }, + meta, +}) + +export const updateLights = ( + robotName: string, + lightsOn: boolean +): Types.UpdateLightsAction => ({ + type: Constants.UPDATE_LIGHTS, + payload: { robotName, lightsOn }, + meta: {}, +}) + +export const updateLightsSuccess = ( + robotName: string, + lightsOn: boolean, + meta: RobotApiRequestMeta +): Types.UpdateLightsSuccessAction => ({ + type: Constants.UPDATE_LIGHTS_SUCCESS, + payload: { robotName, lightsOn }, + meta, +}) + +export const updateLightsFailure = ( + robotName: string, + error: {}, + meta: RobotApiRequestMeta +): Types.UpdateLightsFailureAction => ({ + type: Constants.UPDATE_LIGHTS_FAILURE, + payload: { robotName, error }, + meta, +}) diff --git a/app/src/robot-controls/constants.js b/app/src/robot-controls/constants.js new file mode 100644 index 00000000000..90b32336bc1 --- /dev/null +++ b/app/src/robot-controls/constants.js @@ -0,0 +1,25 @@ +// @flow + +// http paths + +export const LIGHTS_PATH: '/robot/lights' = '/robot/lights' + +// action type strings + +export const FETCH_LIGHTS: 'robotControls:FETCH_LIGHTS' = + 'robotControls:FETCH_LIGHTS' + +export const FETCH_LIGHTS_SUCCESS: 'robotControls:FETCH_LIGHTS_SUCCESS' = + 'robotControls:FETCH_LIGHTS_SUCCESS' + +export const FETCH_LIGHTS_FAILURE: 'robotControls:FETCH_LIGHTS_FAILURE' = + 'robotControls:FETCH_LIGHTS_FAILURE' + +export const UPDATE_LIGHTS: 'robotControls:UPDATE_LIGHTS' = + 'robotControls:UPDATE_LIGHTS' + +export const UPDATE_LIGHTS_SUCCESS: 'robotControls:UPDATE_LIGHTS_SUCCESS' = + 'robotControls:UPDATE_LIGHTS_SUCCESS' + +export const UPDATE_LIGHTS_FAILURE: 'robotControls:UPDATE_LIGHTS_FAILURE' = + 'robotControls:UPDATE_LIGHTS_FAILURE' diff --git a/app/src/robot-controls/epic/__tests__/fetchLightsEpic.test.js b/app/src/robot-controls/epic/__tests__/fetchLightsEpic.test.js new file mode 100644 index 00000000000..c62d312f1ff --- /dev/null +++ b/app/src/robot-controls/epic/__tests__/fetchLightsEpic.test.js @@ -0,0 +1,113 @@ +// @flow +import { TestScheduler } from 'rxjs/testing' + +import { mockRobot } from '../../../robot-api/__fixtures__' +import * as RobotApiHttp from '../../../robot-api/http' +import * as DiscoverySelectors from '../../../discovery/selectors' +import * as Fixtures from '../../__fixtures__' +import * as Actions from '../../actions' +import * as Types from '../../types' +import { robotControlsEpic } from '..' + +import type { Observable } from 'rxjs' +import type { + RobotHost, + RobotApiRequestOptions, + RobotApiResponse, +} from '../../../robot-api/types' + +jest.mock('../../../robot-api/http') +jest.mock('../../../discovery/selectors') + +const mockState = { state: true } + +const mockFetchRobotApi: JestMockFn< + [RobotHost, RobotApiRequestOptions], + Observable +> = RobotApiHttp.fetchRobotApi + +const mockGetRobotByName: JestMockFn<[any, string], mixed> = + DiscoverySelectors.getRobotByName + +describe('fetchLightsEpic', () => { + let testScheduler + + beforeEach(() => { + mockGetRobotByName.mockReturnValue(mockRobot) + + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected) + }) + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + const meta = { requestId: '1234' } + const action: Types.FetchLightsAction = { + ...Actions.fetchLights(mockRobot.name), + meta, + } + + test('calls GET /robot/lights', () => { + testScheduler.run(({ hot, cold, expectObservable, flush }) => { + mockFetchRobotApi.mockReturnValue( + cold('r', { r: Fixtures.mockFetchLightsSuccess }) + ) + + const action$ = hot('--a', { a: action }) + const state$ = hot('a-a', { a: mockState }) + const output$ = robotControlsEpic(action$, state$) + + expectObservable(output$) + flush() + + expect(mockGetRobotByName).toHaveBeenCalledWith(mockState, mockRobot.name) + expect(mockFetchRobotApi).toHaveBeenCalledWith(mockRobot, { + method: 'GET', + path: '/robot/lights', + }) + }) + }) + + test('maps successful response to FETCH_LIGHTS_SUCCESS', () => { + testScheduler.run(({ hot, cold, expectObservable, flush }) => { + mockFetchRobotApi.mockReturnValue( + cold('r', { r: Fixtures.mockFetchLightsSuccess }) + ) + + const action$ = hot('--a', { a: action }) + const state$ = hot('a-a', { a: {} }) + const output$ = robotControlsEpic(action$, state$) + + expectObservable(output$).toBe('--a', { + a: Actions.fetchLightsSuccess( + mockRobot.name, + Fixtures.mockFetchLightsSuccess.body.on, + { ...meta, response: Fixtures.mockFetchLightsSuccessMeta } + ), + }) + }) + }) + + test('maps failed response to FETCH_LIGHTS_FAILURE', () => { + testScheduler.run(({ hot, cold, expectObservable, flush }) => { + mockFetchRobotApi.mockReturnValue( + cold('r', { r: Fixtures.mockFetchLightsFailure }) + ) + + const action$ = hot('--a', { a: action }) + const state$ = hot('a-a', { a: {} }) + const output$ = robotControlsEpic(action$, state$) + + expectObservable(output$).toBe('--a', { + a: Actions.fetchLightsFailure( + mockRobot.name, + { message: 'AH' }, + { ...meta, response: Fixtures.mockFetchLightsFailureMeta } + ), + }) + }) + }) +}) diff --git a/app/src/robot-controls/epic/__tests__/updateLightsEpic.test.js b/app/src/robot-controls/epic/__tests__/updateLightsEpic.test.js new file mode 100644 index 00000000000..d429c687871 --- /dev/null +++ b/app/src/robot-controls/epic/__tests__/updateLightsEpic.test.js @@ -0,0 +1,114 @@ +// @flow +import { TestScheduler } from 'rxjs/testing' + +import { mockRobot } from '../../../robot-api/__fixtures__' +import * as RobotApiHttp from '../../../robot-api/http' +import * as DiscoverySelectors from '../../../discovery/selectors' +import * as Fixtures from '../../__fixtures__' +import * as Actions from '../../actions' +import * as Types from '../../types' +import { robotControlsEpic } from '..' + +import type { Observable } from 'rxjs' +import type { + RobotHost, + RobotApiRequestOptions, + RobotApiResponse, +} from '../../../robot-api/types' + +jest.mock('../../../robot-api/http') +jest.mock('../../../discovery/selectors') + +const mockState = { state: true } + +const mockFetchRobotApi: JestMockFn< + [RobotHost, RobotApiRequestOptions], + Observable +> = RobotApiHttp.fetchRobotApi + +const mockGetRobotByName: JestMockFn<[any, string], mixed> = + DiscoverySelectors.getRobotByName + +describe('updateLightsEpic', () => { + let testScheduler + + beforeEach(() => { + mockGetRobotByName.mockReturnValue(mockRobot) + + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected) + }) + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + const meta = { requestId: '1234' } + const action: Types.UpdateLightsAction = { + ...Actions.updateLights(mockRobot.name, true), + meta, + } + + test('calls POST /robot/lights', () => { + testScheduler.run(({ hot, cold, expectObservable, flush }) => { + mockFetchRobotApi.mockReturnValue( + cold('r', { r: Fixtures.mockUpdateLightsSuccess }) + ) + + const action$ = hot('--a', { a: action }) + const state$ = hot('a-a', { a: mockState }) + const output$ = robotControlsEpic(action$, state$) + + expectObservable(output$) + flush() + + expect(mockGetRobotByName).toHaveBeenCalledWith(mockState, mockRobot.name) + expect(mockFetchRobotApi).toHaveBeenCalledWith(mockRobot, { + method: 'POST', + path: '/robot/lights', + body: { on: true }, + }) + }) + }) + + test('maps successful response to UPDATE_LIGHTS_SUCCESS', () => { + testScheduler.run(({ hot, cold, expectObservable, flush }) => { + mockFetchRobotApi.mockReturnValue( + cold('r', { r: Fixtures.mockUpdateLightsSuccess }) + ) + + const action$ = hot('--a', { a: action }) + const state$ = hot('a-a', { a: {} }) + const output$ = robotControlsEpic(action$, state$) + + expectObservable(output$).toBe('--a', { + a: Actions.updateLightsSuccess( + mockRobot.name, + Fixtures.mockUpdateLightsSuccess.body.on, + { ...meta, response: Fixtures.mockUpdateLightsSuccessMeta } + ), + }) + }) + }) + + test('maps failed response to UPDATE_LIGHTS_FAILURE', () => { + testScheduler.run(({ hot, cold, expectObservable, flush }) => { + mockFetchRobotApi.mockReturnValue( + cold('r', { r: Fixtures.mockUpdateLightsFailure }) + ) + + const action$ = hot('--a', { a: action }) + const state$ = hot('a-a', { a: {} }) + const output$ = robotControlsEpic(action$, state$) + + expectObservable(output$).toBe('--a', { + a: Actions.updateLightsFailure( + mockRobot.name, + { message: 'AH' }, + { ...meta, response: Fixtures.mockUpdateLightsFailureMeta } + ), + }) + }) + }) +}) diff --git a/app/src/robot-controls/epic/fetchLightsEpic.js b/app/src/robot-controls/epic/fetchLightsEpic.js new file mode 100644 index 00000000000..f34b3ba54bd --- /dev/null +++ b/app/src/robot-controls/epic/fetchLightsEpic.js @@ -0,0 +1,49 @@ +// @flow +import { ofType } from 'redux-observable' + +import { GET } from '../../robot-api/constants' +import { mapToRobotApiRequest } from '../../robot-api/operators' + +import * as Actions from '../actions' +import * as Constants from '../constants' + +import type { StrictEpic } from '../../types' + +import type { + ActionToRequestMapper, + ResponseToActionMapper, +} from '../../robot-api/operators' + +import type { FetchLightsAction, FetchLightsDoneAction } from '../types' + +const mapActionToRequest: ActionToRequestMapper = () => ({ + method: GET, + path: Constants.LIGHTS_PATH, +}) + +const mapResponseToAction: ResponseToActionMapper< + FetchLightsAction, + FetchLightsDoneAction +> = (response, originalAction) => { + const { host, body, ...responseMeta } = response + const meta = { ...originalAction.meta, response: responseMeta } + + return response.ok + ? Actions.fetchLightsSuccess(host.name, body.on, meta) + : Actions.fetchLightsFailure(host.name, body, meta) +} + +export const fetchLightsEpic: StrictEpic = ( + action$, + state$ +) => { + return action$.pipe( + ofType(Constants.FETCH_LIGHTS), + mapToRobotApiRequest( + state$, + a => a.payload.robotName, + mapActionToRequest, + mapResponseToAction + ) + ) +} diff --git a/app/src/robot-controls/epic/index.js b/app/src/robot-controls/epic/index.js new file mode 100644 index 00000000000..e4a61f232b2 --- /dev/null +++ b/app/src/robot-controls/epic/index.js @@ -0,0 +1,12 @@ +// @flow +import { combineEpics } from 'redux-observable' + +import { fetchLightsEpic } from './fetchLightsEpic' +import { updateLightsEpic } from './updateLightsEpic' + +import type { Epic } from '../../types' + +export const robotControlsEpic: Epic = combineEpics( + fetchLightsEpic, + updateLightsEpic +) diff --git a/app/src/robot-controls/epic/updateLightsEpic.js b/app/src/robot-controls/epic/updateLightsEpic.js new file mode 100644 index 00000000000..1d8f0b8d1d7 --- /dev/null +++ b/app/src/robot-controls/epic/updateLightsEpic.js @@ -0,0 +1,50 @@ +// @flow +import { ofType } from 'redux-observable' + +import { POST } from '../../robot-api/constants' +import { mapToRobotApiRequest } from '../../robot-api/operators' + +import * as Actions from '../actions' +import * as Constants from '../constants' + +import type { StrictEpic } from '../../types' + +import type { + ActionToRequestMapper, + ResponseToActionMapper, +} from '../../robot-api/operators' + +import type { UpdateLightsAction, UpdateLightsDoneAction } from '../types' + +const mapActionToRequest: ActionToRequestMapper = action => ({ + method: POST, + path: Constants.LIGHTS_PATH, + body: { on: action.payload.lightsOn }, +}) + +const mapResponseToAction: ResponseToActionMapper< + UpdateLightsAction, + UpdateLightsDoneAction +> = (response, originalAction) => { + const { host, body, ...responseMeta } = response + const meta = { ...originalAction.meta, response: responseMeta } + + return response.ok + ? Actions.updateLightsSuccess(host.name, body.on, meta) + : Actions.updateLightsFailure(host.name, body, meta) +} + +export const updateLightsEpic: StrictEpic = ( + action$, + state$ +) => { + return action$.pipe( + ofType(Constants.UPDATE_LIGHTS), + mapToRobotApiRequest( + state$, + a => a.payload.robotName, + mapActionToRequest, + mapResponseToAction + ) + ) +} diff --git a/app/src/robot-controls/index.js b/app/src/robot-controls/index.js new file mode 100644 index 00000000000..375ed43fe48 --- /dev/null +++ b/app/src/robot-controls/index.js @@ -0,0 +1,5 @@ +// @flow + +export * from './actions' +export * from './constants' +export * from './selectors' diff --git a/app/src/robot-controls/reducer.js b/app/src/robot-controls/reducer.js new file mode 100644 index 00000000000..6c3e855a3de --- /dev/null +++ b/app/src/robot-controls/reducer.js @@ -0,0 +1,32 @@ +// @flow + +import * as Constants from './constants' + +import type { Action } from '../types' +import type { RobotControlsState, PerRobotControlsState } from './types' + +const INITIAL_STATE: RobotControlsState = {} + +const INITIAL_CONTROLS_STATE: PerRobotControlsState = { + lightsOn: null, +} + +export function robotControlsReducer( + state: RobotControlsState = INITIAL_STATE, + action: Action +): RobotControlsState { + switch (action.type) { + case Constants.FETCH_LIGHTS_SUCCESS: + case Constants.UPDATE_LIGHTS_SUCCESS: { + const { robotName, lightsOn } = action.payload + const robotState = state[robotName] || INITIAL_CONTROLS_STATE + + return { + ...state, + [robotName]: { ...robotState, lightsOn }, + } + } + } + + return state +} diff --git a/app/src/robot-controls/selectors.js b/app/src/robot-controls/selectors.js new file mode 100644 index 00000000000..7522aa22200 --- /dev/null +++ b/app/src/robot-controls/selectors.js @@ -0,0 +1,10 @@ +// @flow +import type { State } from '../types' + +export const getLightsOn = ( + state: State, + robotName: string +): boolean | null => { + const lightsOn = state.robotControls[robotName]?.lightsOn + return lightsOn != null ? lightsOn : null +} diff --git a/app/src/robot-controls/types.js b/app/src/robot-controls/types.js new file mode 100644 index 00000000000..d69aa1c71e8 --- /dev/null +++ b/app/src/robot-controls/types.js @@ -0,0 +1,75 @@ +// @flow + +import type { RobotApiRequestMeta } from '../robot-api/types' + +// action types + +// fetch lights + +export type FetchLightsAction = {| + type: 'robotControls:FETCH_LIGHTS', + payload: {| robotName: string |}, + meta: RobotApiRequestMeta, +|} + +export type FetchLightsSuccessAction = {| + type: 'robotControls:FETCH_LIGHTS_SUCCESS', + payload: {| robotName: string, lightsOn: boolean |}, + meta: RobotApiRequestMeta, +|} + +export type FetchLightsFailureAction = {| + type: 'robotControls:FETCH_LIGHTS_FAILURE', + payload: {| robotName: string, error: {} |}, + meta: RobotApiRequestMeta, +|} + +export type FetchLightsDoneAction = + | FetchLightsSuccessAction + | FetchLightsFailureAction + +// update lights + +export type UpdateLightsAction = {| + type: 'robotControls:UPDATE_LIGHTS', + payload: {| robotName: string, lightsOn: boolean |}, + meta: RobotApiRequestMeta, +|} + +export type UpdateLightsSuccessAction = {| + type: 'robotControls:UPDATE_LIGHTS_SUCCESS', + payload: {| robotName: string, lightsOn: boolean |}, + meta: RobotApiRequestMeta, +|} + +export type UpdateLightsFailureAction = {| + type: 'robotControls:UPDATE_LIGHTS_FAILURE', + payload: {| robotName: string, error: {} |}, + meta: RobotApiRequestMeta, +|} + +export type UpdateLightsDoneAction = + | UpdateLightsSuccessAction + | UpdateLightsFailureAction + +// action union + +export type RobotControlsAction = + | FetchLightsAction + | FetchLightsSuccessAction + | FetchLightsFailureAction + | UpdateLightsAction + | UpdateLightsSuccessAction + | UpdateLightsFailureAction + +// state types + +export type PerRobotControlsState = $ReadOnly<{| + lightsOn: boolean | null, +|}> + +export type RobotControlsState = $Shape< + $ReadOnly<{| + [robotName: string]: void | PerRobotControlsState, + |}> +> diff --git a/app/src/types.js b/app/src/types.js index 4585220e68b..3d2d4d51231 100644 --- a/app/src/types.js +++ b/app/src/types.js @@ -7,6 +7,10 @@ import type { Observable } from 'rxjs' import type { RobotApiState } from './robot-api/types' import type { RobotAdminState, RobotAdminAction } from './robot-admin/types' +import type { + RobotControlsState, + RobotControlsAction, +} from './robot-controls/types' import type { PipettesState, PipettesAction } from './pipettes/types' import type { ModulesState, ModulesAction } from './modules/types' import type { @@ -33,6 +37,7 @@ export type State = $ReadOnly<{| superDeprecatedRobotApi: SuperDeprecatedRobotApiState, robotApi: RobotApiState, robotAdmin: RobotAdminState, + robotControls: RobotControlsState, robotSettings: RobotSettingsState, pipettes: PipettesState, modules: ModulesState, @@ -48,6 +53,7 @@ export type Action = | RobotAction | SuperDeprecatedRobotApiAction | RobotAdminAction + | RobotControlsAction | RobotSettingsAction | PipettesAction | ModulesAction