diff --git a/app/src/analytics/selectors.js b/app/src/analytics/selectors.js index f1ac64ddc78..b554875808d 100644 --- a/app/src/analytics/selectors.js +++ b/app/src/analytics/selectors.js @@ -24,7 +24,8 @@ import { getRobotSystemType, } from '../shell' -import { getRobotSettingsState, getPipettesState } from '../robot-api' +import { getRobotSettings } from '../robot-settings' +import { getPipettesState } from '../robot-api' import hash from './hash' @@ -77,7 +78,7 @@ export function getRobotAnalyticsData(state: State): RobotAnalyticsData | null { if (robot) { const pipettes = getPipettesState(state, robot.name) - const settings = getRobotSettingsState(state, robot.name) + const settings = getRobotSettings(state, robot.name) return settings.reduce( (result, setting) => ({ diff --git a/app/src/components/RobotSettings/AdvancedSettingsCard.js b/app/src/components/RobotSettings/AdvancedSettingsCard.js index 02726f637a0..f6b2d935d9b 100644 --- a/app/src/components/RobotSettings/AdvancedSettingsCard.js +++ b/app/src/components/RobotSettings/AdvancedSettingsCard.js @@ -6,9 +6,9 @@ import { Link } from 'react-router-dom' import { fetchSettings, - setSettings, - getRobotSettingsState, -} from '../../robot-api' + updateSetting, + getRobotSettings, +} from '../../robot-settings' import { CONNECTABLE } from '../../discovery' import { downloadLogs } from '../../shell/robot-logs/actions' @@ -23,8 +23,9 @@ import { } from '@opentrons/components' import type { State, Dispatch } from '../../types' -import type { ViewableRobot } from '../../discovery' -import type { RobotSettings } from '../../robot-api' +import type { ViewableRobot } from '../../discovery/types' +import type { RobotSettings } from '../../robot-settings/types' + import UploadRobotUpdate from './UploadRobotUpdate' type Props = {| @@ -53,7 +54,7 @@ export default function AdvancedSettingsCard(props: Props) { const { robot, resetUrl } = props const { name, health, status } = robot const settings = useSelector(state => - getRobotSettingsState(state, name) + getRobotSettings(state, name) ) const robotLogsDownloading = useSelector(getRobotLogsDownloading) const dispatch = useDispatch() @@ -64,7 +65,7 @@ export default function AdvancedSettingsCard(props: Props) { s => s.id === ROBOT_LOGS_OPTOUT_ID && s.value === null ) const setLogOptout = (value: boolean) => - dispatch(setSettings(robot, { id: ROBOT_LOGS_OPTOUT_ID, value })) + dispatch(updateSetting(robot, ROBOT_LOGS_OPTOUT_ID, value)) React.useEffect(() => { dispatch(fetchSettings(robot)) @@ -102,7 +103,7 @@ export default function AdvancedSettingsCard(props: Props) { key={id} label={title} toggledOn={value === true} - onClick={() => dispatch(setSettings(robot, { id, value: !value }))} + onClick={() => dispatch(updateSetting(robot, id, !value))} >

{description}

diff --git a/app/src/components/RobotSettings/RestartRequiredBanner.js b/app/src/components/RobotSettings/RestartRequiredBanner.js new file mode 100644 index 00000000000..ad84f4f2e25 --- /dev/null +++ b/app/src/components/RobotSettings/RestartRequiredBanner.js @@ -0,0 +1,47 @@ +// @flow +import * as React from 'react' +import { useDispatch } from 'react-redux' +import { AlertItem, OutlineButton } from '@opentrons/components' + +import { restartRobot } from '../../robot-admin' +import styles from './styles.css' + +import type { Dispatch } from '../../types' +import type { RobotHost } from '../../robot-api/types' + +export type RestartRequiredBannerProps = {| + robot: RobotHost, +|} + +// TODO(mc, 2019-10-24): i18n +const TITLE = 'Robot restart required' +const MESSAGE = + 'You must restart your robot for your settings changes to take effect' +const RESTART_NOW = 'Restart Now' + +function RestartRequiredBanner(props: RestartRequiredBannerProps) { + const { robot } = props + const [dismissed, setDismissed] = React.useState(false) + const dispatch = useDispatch() + const restart = React.useCallback(() => dispatch(restartRobot(robot)), [ + dispatch, + robot, + ]) + + if (dismissed) return null + + return ( + setDismissed(true)} + title={TITLE} + > +
+

{MESSAGE}

+ {RESTART_NOW} +
+
+ ) +} + +export default RestartRequiredBanner diff --git a/app/src/components/RobotSettings/styles.css b/app/src/components/RobotSettings/styles.css index 86d659c5fb1..25b477807a6 100644 --- a/app/src/components/RobotSettings/styles.css +++ b/app/src/components/RobotSettings/styles.css @@ -85,3 +85,12 @@ position: fixed; clip: rect(1px 1px 1px 1px); } + +.restart_banner_message { + display: flex; + + & > p { + max-width: 24rem; + margin-right: auto; + } +} diff --git a/app/src/epic.js b/app/src/epic.js index c11d3141fc0..1887b4a0cd2 100644 --- a/app/src/epic.js +++ b/app/src/epic.js @@ -6,6 +6,7 @@ import { analyticsEpic } from './analytics' import { discoveryEpic } from './discovery/epic' import { robotApiEpic } from './robot-api' import { robotAdminEpic } from './robot-admin/epic' +import { robotSettingsEpic } from './robot-settings/epic' import { shellEpic } from './shell' export default combineEpics( @@ -13,5 +14,6 @@ export default combineEpics( discoveryEpic, robotApiEpic, robotAdminEpic, + robotSettingsEpic, shellEpic ) diff --git a/app/src/pages/Calibrate/Labware.js b/app/src/pages/Calibrate/Labware.js index 258c4a771ca..62af0827ffd 100644 --- a/app/src/pages/Calibrate/Labware.js +++ b/app/src/pages/Calibrate/Labware.js @@ -7,7 +7,7 @@ import { push } from 'connected-react-router' import { selectors as robotSelectors } from '../../robot' import { getConnectedRobot } from '../../discovery' -import { getRobotSettingsState, type Module } from '../../robot-api' +import { getRobotSettings } from '../../robot-settings' import { getUnpreparedModules } from '../../robot-api/resources/modules' import Page from '../../components/Page' @@ -22,6 +22,7 @@ import type { ContextRouter } from 'react-router-dom' import type { State, Dispatch } from '../../types' import type { Labware } from '../../robot' import type { Robot } from '../../discovery' +import type { Module } from '../../robot-api' type OP = ContextRouter @@ -106,7 +107,7 @@ function mapStateToProps(state: State, ownProps: OP): SP { const hasModulesLeftToReview = modules.length > 0 && !robotSelectors.getModulesReviewed(state) const robot = getConnectedRobot(state) - const settings = robot && getRobotSettingsState(state, robot.name) + const settings = robot && getRobotSettings(state, robot.name) // TODO(mc, 2018-07-23): make diagram component a container const calToBottomFlag = diff --git a/app/src/pages/Robots/RobotSettings.js b/app/src/pages/Robots/RobotSettings.js index a6f1b950806..e43baf78f7d 100644 --- a/app/src/pages/Robots/RobotSettings.js +++ b/app/src/pages/Robots/RobotSettings.js @@ -18,6 +18,7 @@ import { import { makeGetRobotHome, clearHomeResponse } from '../../http-api-client' import { getRobotRestarting } from '../../robot-admin' +import { getRobotRestartRequired } from '../../robot-settings' import { SpinnerModalPage } from '@opentrons/components' import { ErrorModal } from '../../components/modals' @@ -29,6 +30,7 @@ import UpdateBuildroot from '../../components/RobotSettings/UpdateBuildroot' import CalibrateDeck from '../../components/CalibrateDeck' import ConnectBanner from '../../components/RobotSettings/ConnectBanner' import ReachableRobotBanner from '../../components/RobotSettings/ReachableRobotBanner' +import RestartRequiredBanner from '../../components/RobotSettings/RestartRequiredBanner' import ResetRobotModal from '../../components/RobotSettings/ResetRobotModal' import type { ContextRouter } from 'react-router-dom' @@ -48,6 +50,7 @@ type SP = {| homeInProgress: ?boolean, homeError: ?Error, updateInProgress: boolean, + restartRequired: boolean, restarting: boolean, |} @@ -82,6 +85,7 @@ function RobotSettingsPage(props: Props) { closeConnectAlert, showUpdateModal, updateInProgress, + restartRequired, restarting, match: { path, url }, } = props @@ -103,6 +107,9 @@ function RobotSettingsPage(props: Props) { {robot.status === CONNECTABLE && ( )} + {restartRequired && !restarting && ( + + )} SP { const buildrootUpdateType = getBuildrootUpdateAvailable(state, robot) const updateInProgress = getBuildrootUpdateInProgress(state, robot) const currentBrRobot = getBuildrootRobot(state) - const restarting = getRobotRestarting(state, robot.name) const showUpdateModal = updateInProgress || @@ -205,7 +211,8 @@ function makeMapStateToProps(): (state: State, ownProps: OP) => SP { return { updateInProgress, - restarting, + restarting: getRobotRestarting(state, robot.name), + restartRequired: getRobotRestartRequired(state, robot.name), showUpdateModal: !!showUpdateModal, homeInProgress: homeRequest && homeRequest.inProgress, homeError: homeRequest && homeRequest.error, diff --git a/app/src/reducer.js b/app/src/reducer.js index 8d5685a5b63..7e3a3e537d9 100644 --- a/app/src/reducer.js +++ b/app/src/reducer.js @@ -17,6 +17,9 @@ import { robotApiReducer } from './robot-api' // robot administration state import { robotAdminReducer } from './robot-admin/reducer' +// robot settings state +import { robotSettingsReducer } from './robot-settings/reducer' + // app shell state import { shellReducer } from './shell' @@ -42,6 +45,7 @@ const rootReducer: Reducer = combineReducers<_, Action>({ api: apiReducer, robotApi: robotApiReducer, robotAdmin: robotAdminReducer, + robotSettings: robotSettingsReducer, config: configReducer, discovery: discoveryReducer, labware: customLabwareReducer, diff --git a/app/src/robot-admin/__tests__/epic.test.js b/app/src/robot-admin/__tests__/epic.test.js index b4a414a743c..01d403cd061 100644 --- a/app/src/robot-admin/__tests__/epic.test.js +++ b/app/src/robot-admin/__tests__/epic.test.js @@ -2,6 +2,7 @@ import { TestScheduler } from 'rxjs/testing' import * as ApiUtils from '../../robot-api/utils' +import * as SettingsSelectors from '../../robot-settings/selectors' import * as DiscoveryActions from '../../discovery/actions' import * as Actions from '../actions' import { robotAdminEpic } from '../epic' @@ -14,6 +15,7 @@ import type { } from '../../robot-api/types' jest.mock('../../robot-api/utils') +jest.mock('../../robot-settings/selectors') const mockMakeApiRequest: JestMockFn<[RobotApiRequest, RequestMeta], mixed> = ApiUtils.makeRobotApiRequest @@ -23,7 +25,11 @@ const mockPassRobotApiResponseAction: JestMockFn< RobotApiResponseAction | null > = ApiUtils.passRobotApiResponseAction +const mockGetRestartPath: JestMockFn, string | null> = + SettingsSelectors.getRobotRestartPath + const mockRobot = { name: 'robot', ip: '127.0.0.1', port: 31950 } +const mockState = { mock: true } const setupMockMakeApiRequest = cold => { mockMakeApiRequest.mockImplementation((req, meta) => @@ -49,9 +55,10 @@ describe('robotAdminEpic', () => { testScheduler.run(({ hot, cold, expectObservable }) => { setupMockMakeApiRequest(cold) + mockGetRestartPath.mockReturnValue(null) const action$ = hot('-a', { a: action }) - const state$: any = null + const state$ = hot('a-', { a: mockState }) const output$ = robotAdminEpic(action$, state$) expectObservable(output$).toBe('--a', { @@ -63,6 +70,26 @@ describe('robotAdminEpic', () => { }) }) + test('makes a POST to the settings restart path on RESTART is applicable', () => { + const action = Actions.restartRobot(mockRobot) + + testScheduler.run(({ hot, cold, expectObservable }) => { + setupMockMakeApiRequest(cold) + mockGetRestartPath.mockReturnValue('/restart') + + const action$ = hot('-a', { a: action }) + const state$ = hot('a-', { a: mockState }) + const output$ = robotAdminEpic(action$, state$) + + expectObservable(output$).toBe('--a', { + a: { + req: { host: mockRobot, method: 'POST', path: '/restart' }, + meta: {}, + }, + }) + }) + }) + test('starts discovery on restart request success', () => { testScheduler.run(({ hot, expectObservable }) => { const serverSuccessAction = { @@ -81,7 +108,7 @@ describe('robotAdminEpic', () => { mockPassRobotApiResponseAction.mockReturnValue(serverSuccessAction) const action$ = hot('-a', { a: serverSuccessAction }) - const state$: any = null + const state$ = hot('a-', { a: mockState }) const output$ = robotAdminEpic(action$, state$) expectObservable(output$).toBe('-a', { diff --git a/app/src/robot-admin/actions.js b/app/src/robot-admin/actions.js index 250878eee0a..f02a53fa580 100644 --- a/app/src/robot-admin/actions.js +++ b/app/src/robot-admin/actions.js @@ -7,6 +7,6 @@ import type { RobotAdminAction } from './types' export const restartRobot = (host: RobotHost): RobotAdminAction => ({ type: RESTART, - payload: { host, method: POST, path: RESTART_PATH }, + payload: { host, path: RESTART_PATH, method: POST }, meta: { robot: true }, }) diff --git a/app/src/robot-admin/epic.js b/app/src/robot-admin/epic.js index ae3679b0d1a..ea72c83802f 100644 --- a/app/src/robot-admin/epic.js +++ b/app/src/robot-admin/epic.js @@ -1,27 +1,35 @@ // @flow import { of } from 'rxjs' import { ofType, combineEpics } from 'redux-observable' -import { filter, switchMap } from 'rxjs/operators' +import { filter, switchMap, withLatestFrom } from 'rxjs/operators' import { makeRobotApiRequest, passRobotApiResponseAction, } from '../robot-api/utils' -import { startDiscovery } from '../discovery/actions' +import { startDiscovery } from '../discovery' +import { getRobotRestartPath } from '../robot-settings' import { RESTART, RESTART_PATH } from './constants' -import type { Epic, LooseEpic } from '../types' -import type { RequestMeta, RobotApiResponseAction } from '../robot-api/types' +import type { State, Epic, LooseEpic } from '../types' +import type { RobotApiResponseAction } from '../robot-api/types' import type { RobotAdminAction } from './types' export const RESTART_DISCOVERY_TIMEOUT_MS = 60000 -const robotAdminApiEpic: Epic = action$ => { +const robotAdminApiEpic: Epic = (action$, state$) => { return action$.pipe( ofType(RESTART), - switchMap(a => { - const meta: RequestMeta = {} - return makeRobotApiRequest(a.payload, meta) + withLatestFrom(state$), + switchMap<[RobotAdminAction, State], _, _>(([action, state]) => { + const { name: robotName } = action.payload.host + const restartPath = getRobotRestartPath(state, robotName) + const payload = + restartPath !== null + ? { ...action.payload, path: restartPath } + : action.payload + + return makeRobotApiRequest(payload, {}) }) ) } diff --git a/app/src/robot-api/resources/settings.js b/app/src/robot-api/resources/settings.js index 93660a0ec89..7e5eabf6ceb 100644 --- a/app/src/robot-api/resources/settings.js +++ b/app/src/robot-api/resources/settings.js @@ -10,7 +10,6 @@ import { createBaseRobotApiEpic, passRobotApiResponseAction, GET, - POST, PATCH, } from '../utils' @@ -20,47 +19,26 @@ import type { State as AppState, Action, ActionLike, Epic } from '../../types' import type { RobotHost, RobotApiAction, RobotApiRequestState } from '../types' import type { SettingsState as State, - RobotSettings, PipetteSettings, - RobotSettingsFieldUpdate, PipetteSettingsUpdate, } from './types' -const INITIAL_STATE: State = { robot: [], pipettesById: {} } +const INITIAL_STATE: State = { pipettesById: {} } -export const SETTINGS_PATH = '/settings' export const PIPETTE_SETTINGS_PATH = '/settings/pipettes' const makePipetteSettingsPath = (id: string) => `${PIPETTE_SETTINGS_PATH}/${id}` -export const FETCH_SETTINGS: 'robotApi:FETCH_SETTINGS' = - 'robotApi:FETCH_SETTINGS' - -export const SET_SETTINGS: 'robotApi:SET_SETTINGS' = 'robotApi:SET_SETTINGS' - export const FETCH_PIPETTE_SETTINGS: 'robotApi:FETCH_PIPETTE_SETTINGS' = 'robotApi:FETCH_PIPETTE_SETTINGS' export const SET_PIPETTE_SETTINGS: 'robotApi:SET_PIPETTE_SETTINGS' = 'robotApi:SET_PIPETTE_SETTINGS' -export const fetchSettings = (host: RobotHost): RobotApiAction => ({ - type: FETCH_SETTINGS, - payload: { host, method: GET, path: SETTINGS_PATH }, -}) - export const fetchPipetteSettings = (host: RobotHost): RobotApiAction => ({ type: FETCH_PIPETTE_SETTINGS, payload: { host, method: GET, path: PIPETTE_SETTINGS_PATH }, }) -export const setSettings = ( - host: RobotHost, - body: RobotSettingsFieldUpdate -): RobotApiAction => ({ - type: SET_SETTINGS, - payload: { host, body, method: POST, path: SETTINGS_PATH }, -}) - export const setPipetteSettings = ( host: RobotHost, id: string, @@ -72,8 +50,6 @@ export const setPipetteSettings = ( meta: { id }, }) -const fetchSettingsEpic = createBaseRobotApiEpic(FETCH_SETTINGS) -const setSettingsEpic = createBaseRobotApiEpic(SET_SETTINGS) const fetchPipetteSettingsEpic = createBaseRobotApiEpic(FETCH_PIPETTE_SETTINGS) const setPipetteSettingsEpic = createBaseRobotApiEpic(SET_PIPETTE_SETTINGS) @@ -88,8 +64,6 @@ const fetchPipettesForSettingsEpic: Epic = action$ => ) export const settingsEpic = combineEpics( - fetchSettingsEpic, - setSettingsEpic, fetchPipetteSettingsEpic, setPipetteSettingsEpic, fetchPipettesForSettingsEpic @@ -105,12 +79,6 @@ export function settingsReducer( const { payload, meta } = resAction const { method, path, body } = payload - // grabs responses from GET /settings and POST /settings - // settings in body check is a guard against an old version of GET /settings - if (path === SETTINGS_PATH && 'settings' in body) { - return { ...state, robot: body.settings } - } - // grabs responses from GET /settings/pipettes if (path === PIPETTE_SETTINGS_PATH) { return { ...state, pipettesById: body } @@ -135,15 +103,6 @@ export function settingsReducer( return state } -export function getRobotSettingsState( - state: AppState, - robotName: string -): RobotSettings { - const robotState = getRobotApiState(state, robotName) - - return robotState?.resources.settings.robot || [] -} - export function getPipetteSettingsState( state: AppState, robotName: string, diff --git a/app/src/robot-api/resources/types.js b/app/src/robot-api/resources/types.js index d7d6aa91b47..1399c4b9313 100644 --- a/app/src/robot-api/resources/types.js +++ b/app/src/robot-api/resources/types.js @@ -97,29 +97,14 @@ export type MotorAxis = 'a' | 'b' | 'c' | 'x' | 'y' | 'z' // settings export type SettingsState = {| - robot: RobotSettings, pipettesById: {| [id: string]: PipetteSettings |}, |} -export type RobotSettings = Array - export type PipetteSettings = {| info: {| name: ?string, model: ?string |}, fields: PipetteSettingsFieldsMap, |} -export type RobotSettingsField = {| - id: string, - title: string, - description: string, - value: boolean | null, -|} - -export type RobotSettingsFieldUpdate = {| - id: $PropertyType, - value: $PropertyType, -|} - export type PipetteSettingsFieldsMap = {| [fieldId: string]: PipetteSettingsField, quirks?: PipetteQuirksField, diff --git a/app/src/robot-api/types.js b/app/src/robot-api/types.js index d8594c9bbae..26f4927578e 100644 --- a/app/src/robot-api/types.js +++ b/app/src/robot-api/types.js @@ -10,7 +10,7 @@ export * from './resources/types' export type Method = 'GET' | 'POST' | 'PATCH' | 'DELETE' -export type RequestMeta = { [string]: mixed } +export type RequestMeta = $Shape<{| [string]: mixed |}> // api call + response types @@ -53,9 +53,7 @@ export type RobotApiAction = meta: {| id: string |}, |} | {| type: 'robotApi:FETCH_PIPETTES', payload: RobotApiRequest |} - | {| type: 'robotApi:FETCH_SETTINGS', payload: RobotApiRequest |} | {| type: 'robotApi:FETCH_PIPETTE_SETTINGS', payload: RobotApiRequest |} - | {| type: 'robotApi:SET_SETTINGS', payload: RobotApiRequest |} | {| type: 'robotApi:SET_PIPETTE_SETTINGS', payload: RobotApiRequest, diff --git a/app/src/robot-settings/__tests__/actions.test.js b/app/src/robot-settings/__tests__/actions.test.js new file mode 100644 index 00000000000..3e3b58b8c46 --- /dev/null +++ b/app/src/robot-settings/__tests__/actions.test.js @@ -0,0 +1,47 @@ +// @flow + +import * as Actions from '../actions' + +import type { RobotSettingsAction } from '../types' + +type ActionSpec = {| + name: string, + creator: (...Array) => mixed, + args: Array, + expected: RobotSettingsAction, +|} + +const mockRobot = { name: 'robotName', ip: 'localhost', port: 31950 } + +describe('robot settings actions', () => { + const SPECS: Array = [ + { + name: 'robotSettings:FETCH_SETTINGS', + creator: Actions.fetchSettings, + args: [mockRobot], + expected: { + type: 'robotSettings:FETCH_SETTINGS', + payload: { host: mockRobot, method: 'GET', path: '/settings' }, + }, + }, + { + name: 'robotSettings:UPDATE_SETTING', + creator: Actions.updateSetting, + args: [mockRobot, 'foo', true], + expected: { + type: 'robotSettings:UPDATE_SETTING', + payload: { + host: mockRobot, + method: 'POST', + path: '/settings', + body: { id: 'foo', value: true }, + }, + }, + }, + ] + + SPECS.forEach(spec => { + const { name, creator, args, expected } = spec + test(name, () => expect(creator(...args)).toEqual(expected)) + }) +}) diff --git a/app/src/robot-settings/__tests__/epics.test.js b/app/src/robot-settings/__tests__/epics.test.js new file mode 100644 index 00000000000..05e4cd3bce9 --- /dev/null +++ b/app/src/robot-settings/__tests__/epics.test.js @@ -0,0 +1,78 @@ +// @flow +import { TestScheduler } from 'rxjs/testing' + +import * as ApiUtils from '../../robot-api/utils' +import * as Actions from '../actions' +import { robotSettingsEpic } from '../epic' + +import type { RobotApiRequest, RequestMeta } from '../../robot-api/types' + +jest.mock('../../robot-api/utils') + +const mockMakeApiRequest: JestMockFn<[RobotApiRequest, RequestMeta], mixed> = + ApiUtils.makeRobotApiRequest + +const mockRobot = { name: 'robot', ip: '127.0.0.1', port: 31950 } + +const setupMockMakeApiRequest = cold => { + mockMakeApiRequest.mockImplementation((req, meta) => + cold('-a', { a: { req, meta } }) + ) +} + +describe('robotSettingsEpic', () => { + let testScheduler + + beforeEach(() => { + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected) + }) + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + test('makes GET /settings request on FETCH_SETTINGS', () => { + const action = Actions.fetchSettings(mockRobot) + + testScheduler.run(({ hot, cold, expectObservable }) => { + setupMockMakeApiRequest(cold) + + const action$ = hot('-a', { a: action }) + const state$: any = null + const output$ = robotSettingsEpic(action$, state$) + + expectObservable(output$).toBe('--a', { + a: { + req: { host: mockRobot, method: 'GET', path: '/settings' }, + meta: {}, + }, + }) + }) + }) + + test('makes POST /settings request on UPDATE_SETTING', () => { + const action = Actions.updateSetting(mockRobot, 'settingId', true) + + testScheduler.run(({ hot, cold, expectObservable }) => { + setupMockMakeApiRequest(cold) + + const action$ = hot('-a', { a: action }) + const state$: any = null + const output$ = robotSettingsEpic(action$, state$) + + expectObservable(output$).toBe('--a', { + a: { + req: { + host: mockRobot, + method: 'POST', + path: '/settings', + body: { id: 'settingId', value: true }, + }, + meta: {}, + }, + }) + }) + }) +}) diff --git a/app/src/robot-settings/__tests__/reducer.test.js b/app/src/robot-settings/__tests__/reducer.test.js new file mode 100644 index 00000000000..e8874ab9e14 --- /dev/null +++ b/app/src/robot-settings/__tests__/reducer.test.js @@ -0,0 +1,175 @@ +// @flow + +import { robotSettingsReducer } from '../reducer' + +import type { Action, ActionLike } from '../../types' +import type { RobotSettingsState } from '../types' + +type ReducerSpec = {| + name: string, + action: Action | ActionLike, + state: RobotSettingsState, + expected: RobotSettingsState, +|} + +describe('robotSettingsReducer', () => { + const SPECS: Array = [ + { + name: 'handles initial robotApi:RESPONSE for GET /settings', + action: { + type: 'robotApi:RESPONSE__GET__/settings', + meta: {}, + payload: { + host: { name: 'robotName' }, + method: 'GET', + path: '/settings', + body: { + settings: [ + { id: 'foo', title: 'Foo', description: 'foobar', value: true }, + { id: 'bar', title: 'Bar', description: 'bazqux', value: false }, + ], + }, + }, + }, + state: {}, + expected: { + robotName: { + settings: [ + { id: 'foo', title: 'Foo', description: 'foobar', value: true }, + { id: 'bar', title: 'Bar', description: 'bazqux', value: false }, + ], + restartPath: null, + }, + }, + }, + { + name: 'handles robotApi:RESPONSE for GET /settings with restart required', + action: { + type: 'robotApi:RESPONSE__GET__/settings', + meta: {}, + payload: { + host: { name: 'robotName' }, + method: 'GET', + path: '/settings', + body: { + settings: [ + { id: 'foo', title: 'Foo', description: 'foobar', value: true }, + ], + links: { restart: '/server/restart' }, + }, + }, + }, + state: { + robotName: { + settings: [ + { id: 'foo', title: 'Foo', description: 'foobar', value: true }, + { id: 'bar', title: 'Bar', description: 'bazqux', value: false }, + ], + restartPath: null, + }, + }, + expected: { + robotName: { + settings: [ + { id: 'foo', title: 'Foo', description: 'foobar', value: true }, + ], + restartPath: '/server/restart', + }, + }, + }, + { + name: 'handles robotApi:RESPONSE for POST /settings', + action: { + type: 'robotApi:RESPONSE__POST__/settings', + meta: {}, + payload: { + host: { name: 'robotName' }, + method: 'POST', + path: '/settings', + body: { + settings: [ + { id: 'foo', title: 'Foo', description: 'foobar', value: true }, + { id: 'bar', title: 'Bar', description: 'bazqux', value: false }, + ], + }, + }, + }, + state: { + robotName: { + settings: [], + restartPath: null, + }, + }, + expected: { + robotName: { + settings: [ + { id: 'foo', title: 'Foo', description: 'foobar', value: true }, + { id: 'bar', title: 'Bar', description: 'bazqux', value: false }, + ], + restartPath: null, + }, + }, + }, + { + name: + 'handles robotApi:RESPONSE for POST /settings where restart is required', + action: { + type: 'robotApi:RESPONSE__POST__/settings', + meta: { settingId: 'baz' }, + payload: { + host: { name: 'robotName' }, + method: 'POST', + path: '/settings', + body: { + settings: [ + { + id: 'baz', + title: 'Baz', + description: 'bazqux', + value: true, + restart_required: true, + }, + ], + links: { restart: '/server/restart' }, + }, + }, + }, + state: { + robotName: { + settings: [ + { + id: 'baz', + title: 'Baz', + description: 'bazqux', + value: false, + restart_required: true, + }, + ], + restartPath: null, + }, + }, + expected: { + robotName: { + settings: [ + { + id: 'baz', + title: 'Baz', + description: 'bazqux', + value: true, + restart_required: true, + }, + ], + restartPath: '/server/restart', + }, + }, + }, + ] + + SPECS.forEach(spec => { + const { name, action, state, expected } = spec + + test(name, () => { + expect(robotSettingsReducer(state, action)).toEqual(expected) + }) + }) +}) diff --git a/app/src/robot-settings/__tests__/selectors.test.js b/app/src/robot-settings/__tests__/selectors.test.js new file mode 100644 index 00000000000..a48bf76bc87 --- /dev/null +++ b/app/src/robot-settings/__tests__/selectors.test.js @@ -0,0 +1,68 @@ +// @flow +import * as Selectors from '../selectors' +import type { State } from '../../types' + +type SelectorSpec = {| + name: string, + selector: ($Shape, ...Array) => mixed, + state: $Shape, + args?: Array, + expected: mixed, +|} + +describe('robot settings selectors', () => { + const SPECS: Array = [ + { + name: 'getRobotSettings', + selector: Selectors.getRobotSettings, + state: { + robotSettings: { + robotName: { + restartPath: null, + settings: [ + { id: 'foo', title: 'Foo', description: 'Foo', value: true }, + ], + }, + }, + }, + args: ['robotName'], + expected: [{ id: 'foo', title: 'Foo', description: 'Foo', value: true }], + }, + { + name: 'getRobotRestartPath', + selector: Selectors.getRobotRestartPath, + state: { + robotSettings: { robotName: { restartPath: '/restart', settings: [] } }, + }, + args: ['robotName'], + expected: '/restart', + }, + { + name: 'getRobotRestartRequired when required', + selector: Selectors.getRobotRestartRequired, + state: { + robotSettings: { robotName: { restartPath: '/restart', settings: [] } }, + }, + args: ['robotName'], + expected: true, + }, + { + name: 'getRobotRestartRequired when not required', + selector: Selectors.getRobotRestartRequired, + state: { + robotSettings: { robotName: { restartPath: null, settings: [] } }, + }, + args: ['robotName'], + expected: false, + }, + ] + + SPECS.forEach(spec => { + const { name, selector, state, args = [], expected } = spec + + test(name, () => { + const result = selector(state, ...args) + expect(result).toEqual(expected) + }) + }) +}) diff --git a/app/src/robot-settings/actions.js b/app/src/robot-settings/actions.js new file mode 100644 index 00000000000..07e37311f6f --- /dev/null +++ b/app/src/robot-settings/actions.js @@ -0,0 +1,26 @@ +// @flow +import { GET, POST } from '../robot-api/utils' + +import { FETCH_SETTINGS, UPDATE_SETTING, SETTINGS_PATH } from './constants' + +import type { RobotHost } from '../robot-api/types' +import type { RobotSettingsAction } from './types' + +export const fetchSettings = (host: RobotHost): RobotSettingsAction => ({ + type: FETCH_SETTINGS, + payload: { host, method: GET, path: SETTINGS_PATH }, +}) + +export const updateSetting = ( + host: RobotHost, + settingId: string, + value: boolean | null +): RobotSettingsAction => ({ + type: UPDATE_SETTING, + payload: { + host, + method: POST, + path: SETTINGS_PATH, + body: { id: settingId, value }, + }, +}) diff --git a/app/src/robot-settings/constants.js b/app/src/robot-settings/constants.js new file mode 100644 index 00000000000..aa396b0b6c7 --- /dev/null +++ b/app/src/robot-settings/constants.js @@ -0,0 +1,12 @@ +// @flow + +export const SETTINGS_PATH: '/settings' = '/settings' + +export const FETCH_SETTINGS: 'robotSettings:FETCH_SETTINGS' = + 'robotSettings:FETCH_SETTINGS' + +export const UPDATE_SETTING: 'robotSettings:UPDATE_SETTING' = + 'robotSettings:UPDATE_SETTING' + +export const APPLY_WITH_RESTART: 'robotSettings:APPLY_WITH_RESTART' = + 'robotSettings:APPLY_WITH_RESTART' diff --git a/app/src/robot-settings/epic.js b/app/src/robot-settings/epic.js new file mode 100644 index 00000000000..eb0995ac23b --- /dev/null +++ b/app/src/robot-settings/epic.js @@ -0,0 +1,17 @@ +// @flow +import { ofType } from 'redux-observable' +import { switchMap } from 'rxjs/operators' +import { makeRobotApiRequest } from '../robot-api/utils' +import { FETCH_SETTINGS, UPDATE_SETTING } from './constants' + +import type { Epic } from '../types' +import type { RobotSettingsAction } from './types' + +export const robotSettingsEpic: Epic = action$ => { + return action$.pipe( + ofType(FETCH_SETTINGS, UPDATE_SETTING), + switchMap(a => { + return makeRobotApiRequest(a.payload, {}) + }) + ) +} diff --git a/app/src/robot-settings/index.js b/app/src/robot-settings/index.js new file mode 100644 index 00000000000..552091d705d --- /dev/null +++ b/app/src/robot-settings/index.js @@ -0,0 +1,5 @@ +// @flow +// robot settings actions, constants, selectors re-exports for convenience +export * from './actions' +export * from './constants' +export * from './selectors' diff --git a/app/src/robot-settings/reducer.js b/app/src/robot-settings/reducer.js new file mode 100644 index 00000000000..a78b04fa678 --- /dev/null +++ b/app/src/robot-settings/reducer.js @@ -0,0 +1,33 @@ +// @flow +import { passRobotApiResponseAction } from '../robot-api/utils' +import { SETTINGS_PATH } from './constants' + +import type { Action, ActionLike } from '../types' +import type { RobotSettingsResponse, RobotSettingsState } from './types' + +export const INITIAL_STATE: RobotSettingsState = {} + +export function robotSettingsReducer( + state: RobotSettingsState = INITIAL_STATE, + action: Action | ActionLike +): RobotSettingsState { + const resAction = passRobotApiResponseAction(action) + + if (resAction) { + const { payload } = resAction + const { host, path, body } = payload + const { name: robotName } = host + + // grabs responses from GET /settings and POST /settings + // settings in body check is a guard against an old version of GET /settings + if (path === SETTINGS_PATH && 'settings' in body) { + const { settings, links } = (body: RobotSettingsResponse) + // restart is required if `links` comes back with a `restart` field + const restartPath = links?.restart || null + + return { ...state, [robotName]: { settings, restartPath } } + } + } + + return state +} diff --git a/app/src/robot-settings/selectors.js b/app/src/robot-settings/selectors.js new file mode 100644 index 00000000000..8ada86f754a --- /dev/null +++ b/app/src/robot-settings/selectors.js @@ -0,0 +1,26 @@ +// @flow +import type { State } from '../types' +import type { RobotSettings } from './types' + +const robotState = (state: State, name: string) => state.robotSettings[name] + +export function getRobotSettings( + state: State, + robotName: string +): RobotSettings { + return robotState(state, robotName)?.settings || [] +} + +export function getRobotRestartPath( + state: State, + robotName: string +): string | null { + return robotState(state, robotName)?.restartPath || null +} + +export function getRobotRestartRequired( + state: State, + robotName: string +): boolean { + return getRobotRestartPath(state, robotName) !== null +} diff --git a/app/src/robot-settings/types.js b/app/src/robot-settings/types.js new file mode 100644 index 00000000000..fae771a737b --- /dev/null +++ b/app/src/robot-settings/types.js @@ -0,0 +1,36 @@ +// @flow + +import type { RobotApiRequest } from '../robot-api/types' + +export type RobotSettingsField = {| + id: string, + title: string, + description: string, + value: boolean | null, + restart_required?: boolean, +|} + +export type RobotSettings = Array + +export type RobotSettingsResponse = {| + settings: RobotSettings, + links?: {| restart?: string |}, +|} + +export type PerRobotRobotSettingsState = {| + settings: RobotSettings, + restartPath: string | null, +|} + +export type RobotSettingsState = $Shape<{| + [robotName: string]: void | PerRobotRobotSettingsState, +|}> + +export type RobotSettingsFieldUpdate = {| + id: $PropertyType, + value: $PropertyType, +|} + +export type RobotSettingsAction = + | {| type: 'robotSettings:FETCH_SETTINGS', payload: RobotApiRequest |} + | {| type: 'robotSettings:UPDATE_SETTING', payload: RobotApiRequest |} diff --git a/app/src/types.js b/app/src/types.js index eca42b2682e..0e9ad61acae 100644 --- a/app/src/types.js +++ b/app/src/types.js @@ -18,11 +18,17 @@ import type { CustomLabwareAction, } from './custom-labware/types' +import type { + RobotSettingsState, + RobotSettingsAction, +} from './robot-settings/types' + export type State = $ReadOnly<{| robot: RobotState, api: HttpApiState, robotApi: RobotApiState, robotAdmin: RobotAdminState, + robotSettings: RobotSettingsState, config: Config, discovery: DiscoveryState, labware: CustomLabwareState, @@ -36,6 +42,7 @@ export type Action = | HttpApiAction | RobotApiAction | RobotAdminAction + | RobotSettingsAction | ShellAction | ConfigAction | RouterAction