diff --git a/protocol-designer/src/components/StepCreationButton.js b/protocol-designer/src/components/StepCreationButton.js index cf97d50acc1..a6eebb2a1f4 100644 --- a/protocol-designer/src/components/StepCreationButton.js +++ b/protocol-designer/src/components/StepCreationButton.js @@ -132,7 +132,7 @@ const mapSTP = (state: BaseState): SP => { const mapDTP = (dispatch: ThunkDispatch<*>): DP => ({ makeAddStep: (stepType: StepType) => (e: SyntheticEvent<>) => - dispatch(stepsActions.addStep({ stepType })), + dispatch(stepsActions.addAndSelectStepWithHints({ stepType })), }) export const StepCreationButton = connect( diff --git a/protocol-designer/src/components/StepEditForm/ButtonRow/index.js b/protocol-designer/src/components/StepEditForm/ButtonRow/index.js index 1178cc0ab95..b7e7cc5e8c8 100644 --- a/protocol-designer/src/components/StepEditForm/ButtonRow/index.js +++ b/protocol-designer/src/components/StepEditForm/ButtonRow/index.js @@ -1,61 +1,99 @@ // @flow -import React from 'react' +import React, { useCallback, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import cx from 'classnames' import { OutlineButton, PrimaryButton } from '@opentrons/components' - +import { AutoAddPauseUntilTempStepModal } from '../../modals/AutoAddPauseUntilTempStepModal' import { actions as steplistActions } from '../../../steplist' -import { actions as stepFormActions } from '../../../step-forms' +import { actions as stepsActions } from '../../../ui/steps' -import { getCurrentFormCanBeSaved } from '../../../step-forms/selectors' +import { + getCurrentFormCanBeSaved, + getUnsavedForm, + getUnsavedFormIsPristineSetTempForm, +} from '../../../step-forms/selectors' import type { BaseState } from '../../../types' import modalStyles from '../../modals/modal.css' import styles from './styles.css' -const { cancelStepForm } = steplistActions -const { saveStepForm } = stepFormActions - type Props = {| onClickMoreOptions: (event: SyntheticEvent<>) => mixed, onDelete?: (event: SyntheticEvent<>) => mixed, |} export const ButtonRow = ({ onDelete, onClickMoreOptions }: Props) => { + const [ + showAddPauseUntilTempStepModal, + setShowAddPauseUntilTempStepModal, + ] = useState(false) + + const dispatch = useDispatch() const canSave = useSelector((state: BaseState): boolean => getCurrentFormCanBeSaved(state) ) - const dispatch = useDispatch() + const unsavedForm = useSelector(getUnsavedForm) + const shouldShowPauseUntilTempStepModal = useSelector( + getUnsavedFormIsPristineSetTempForm + ) + + const handleClickSave = useCallback(() => { + if (!canSave) { + return + } + if (shouldShowPauseUntilTempStepModal) { + setShowAddPauseUntilTempStepModal(true) + } else { + dispatch(stepsActions.saveStepForm()) + } + }, [canSave, shouldShowPauseUntilTempStepModal, dispatch]) return ( -
-
- - Delete - - - Notes - -
-
- dispatch(cancelStepForm())} - > - Close - - dispatch(saveStepForm()) : undefined} - > - Save - + <> + {showAddPauseUntilTempStepModal && ( + { + setShowAddPauseUntilTempStepModal(false) + // save normally + dispatch(stepsActions.saveStepForm()) + }} + handleContinueClick={() => { + setShowAddPauseUntilTempStepModal(false) + // save this form and add a subsequent pause + dispatch(stepsActions.saveSetTempFormWithAddedPauseUntilTemp()) + }} + /> + )} +
+
+ + Delete + + + Notes + +
+
+ dispatch(steplistActions.cancelStepForm())} + > + Close + + + Save + +
-
+ ) } diff --git a/protocol-designer/src/components/modals/AutoAddPauseUntilTempStepModal.css b/protocol-designer/src/components/modals/AutoAddPauseUntilTempStepModal.css new file mode 100644 index 00000000000..3630c28da94 --- /dev/null +++ b/protocol-designer/src/components/modals/AutoAddPauseUntilTempStepModal.css @@ -0,0 +1,20 @@ +@import '@opentrons/components'; + +.header { + @apply --font-header-dark; + + margin-bottom: 1rem; +} + +.body { + @apply --font-body-2-dark; +} + +.later_button { + width: 16rem; +} + +.now_button { + width: 15rem; + margin-left: 1rem; +} diff --git a/protocol-designer/src/components/modals/AutoAddPauseUntilTempStepModal.js b/protocol-designer/src/components/modals/AutoAddPauseUntilTempStepModal.js new file mode 100644 index 00000000000..985aa3ad618 --- /dev/null +++ b/protocol-designer/src/components/modals/AutoAddPauseUntilTempStepModal.js @@ -0,0 +1,44 @@ +// @flow +import React from 'react' +import { AlertModal, OutlineButton, PrimaryButton } from '@opentrons/components' +import { i18n } from '../../localization' +import modalStyles from './modal.css' +import styles from './AutoAddPauseUntilTempStepModal.css' + +type Props = {| + displayTemperature: string, + handleCancelClick: () => mixed, + handleContinueClick: () => mixed, +|} + +export const AutoAddPauseUntilTempStepModal = (props: Props) => ( + +
+ {i18n.t('modal.auto_add_pause_until_temp_step.title', { + temperature: props.displayTemperature, + })} +
+

+ {i18n.t('modal.auto_add_pause_until_temp_step.body', { + temperature: props.displayTemperature, + })} +

+
+ + {i18n.t('modal.auto_add_pause_until_temp_step.later_button')} + + + {i18n.t('modal.auto_add_pause_until_temp_step.now_button')} + +
+
+) diff --git a/protocol-designer/src/form-types.js b/protocol-designer/src/form-types.js index 151c6bc51c4..ac6eb9ac4f2 100644 --- a/protocol-designer/src/form-types.js +++ b/protocol-designer/src/form-types.js @@ -224,13 +224,15 @@ export type HydratedMixFormDataLegacy = { blowout_location: ?string, // labwareId or 'SOURCE_WELL' or 'DEST_WELL' } +export type MagnetAction = 'engage' | 'disengage' + export type HydratedMagnetFormData = {| ...AnnotationFields, id: string, stepType: 'magnet', stepDetails: string | null, moduleId: string | null, - magnetAction: 'engage' | 'disengage', + magnetAction: MagnetAction, engageHeight: string | null, |} diff --git a/protocol-designer/src/labware-ingred/selectors.js b/protocol-designer/src/labware-ingred/selectors.js index 53fb0bad3ae..21e7caae48d 100644 --- a/protocol-designer/src/labware-ingred/selectors.js +++ b/protocol-designer/src/labware-ingred/selectors.js @@ -20,7 +20,7 @@ import type { LiquidGroup, OrderedLiquids, } from './types' -import type { BaseState, MemoizedSelector } from './../types' +import type { BaseState, MemoizedSelector, Selector } from './../types' // TODO: Ian 2019-02-15 no RootSlice, use BaseState type RootSlice = { labwareIngred: RootState } @@ -143,7 +143,7 @@ const getLiquidGroupsOnDeck: MemoizedSelector> = createSelector( } ) -const getDeckHasLiquid: MemoizedSelector = createSelector( +const getDeckHasLiquid: Selector = createSelector( getLiquidGroupsOnDeck, liquidGroups => liquidGroups.length > 0 ) diff --git a/protocol-designer/src/localization/en/modal.json b/protocol-designer/src/localization/en/modal.json index a937881054a..a3df96a1134 100644 --- a/protocol-designer/src/localization/en/modal.json +++ b/protocol-designer/src/localization/en/modal.json @@ -100,5 +100,11 @@ "body1": "Warning: Any experimental features used in this protocol may cause inconsistent behavior in the Protocol Designer. For example, the Protocol Designer may no longer display all warnings or errors that exist when no experimental features have been used.", "body2": "We encourage you to report any bugs you find, however note that at this time we are unable to prioritize fixes as they are a result of experimental features." } + }, + "auto_add_pause_until_temp_step": { + "title": "Pause until module is {{temperature}}°C?", + "body": "Should your protocol pause until the module is {{temperature}}°C? Or should the protocol continue while the module changes temperature?", + "now_button": "Pause protocol now", + "later_button": "I will build a pause later" } } diff --git a/protocol-designer/src/step-forms/actions/index.js b/protocol-designer/src/step-forms/actions/index.js index 32e9bf1eed3..2d77d8c1c75 100644 --- a/protocol-designer/src/step-forms/actions/index.js +++ b/protocol-designer/src/step-forms/actions/index.js @@ -1,18 +1,3 @@ // @flow -import { getUnsavedForm } from '../selectors' -import type { Dispatch } from 'redux' -import type { StepIdType } from '../../form-types' -import type { GetState } from '../../types' - export * from './modules' export * from './pipettes' - -export const SAVE_STEP_FORM: 'SAVE_STEP_FORM' = 'SAVE_STEP_FORM' - -export type SaveStepFormAction = {| - type: typeof SAVE_STEP_FORM, - payload: { id: StepIdType }, -|} - -export const saveStepForm = () => (dispatch: Dispatch<*>, getState: GetState) => - dispatch({ type: SAVE_STEP_FORM, payload: getUnsavedForm(getState()) }) diff --git a/protocol-designer/src/step-forms/reducers/index.js b/protocol-designer/src/step-forms/reducers/index.js index 66ed938cdd0..667e4c3bc1d 100644 --- a/protocol-designer/src/step-forms/reducers/index.js +++ b/protocol-designer/src/step-forms/reducers/index.js @@ -58,7 +58,8 @@ import type { import type { DuplicateStepAction, ReorderSelectedStepAction, -} from '../../ui/steps' +} from '../../ui/steps/actions/types' +import type { SaveStepFormAction } from '../../ui/steps/actions/thunks' import type { StepItemData } from '../../steplist/types' import type { NormalizedPipetteById, @@ -73,7 +74,6 @@ import type { CreateModuleAction, EditModuleAction, DeleteModuleAction, - SaveStepFormAction, } from '../actions' type FormState = FormData | null diff --git a/protocol-designer/src/step-forms/selectors/index.js b/protocol-designer/src/step-forms/selectors/index.js index 9988eefa957..b91b7e007b9 100644 --- a/protocol-designer/src/step-forms/selectors/index.js +++ b/protocol-designer/src/step-forms/selectors/index.js @@ -523,6 +523,17 @@ export const getIsNewStepForm: Selector = createSelector( formData && formData.id != null ? !savedForms[formData.id] : true ) +export const getUnsavedFormIsPristineSetTempForm: Selector = createSelector( + getUnsavedForm, + getIsNewStepForm, + (unsavedForm, isNewStepForm) => { + const isSetTempForm = + unsavedForm?.stepType === 'temperature' && + unsavedForm?.setTemperature === 'true' + return isNewStepForm && isSetTempForm + } +) + export const getFormLevelWarningsForUnsavedForm: Selector< Array > = createSelector( diff --git a/protocol-designer/src/steplist/formLevel/getNextDefaultMagnetAction/index.js b/protocol-designer/src/steplist/formLevel/getNextDefaultMagnetAction/index.js index f9432a70b48..3ecd3f89a19 100644 --- a/protocol-designer/src/steplist/formLevel/getNextDefaultMagnetAction/index.js +++ b/protocol-designer/src/steplist/formLevel/getNextDefaultMagnetAction/index.js @@ -1,11 +1,11 @@ // @flow import last from 'lodash/last' -import type { StepIdType, FormData } from '../../../form-types' +import type { StepIdType, FormData, MagnetAction } from '../../../form-types' export function getNextDefaultMagnetAction( savedForms: { [StepIdType]: FormData }, orderedStepIds: Array -): ?string { +): MagnetAction { const prevMagnetSteps = orderedStepIds .map(stepId => savedForms[stepId]) .filter(form => form && form.magnetAction) @@ -14,7 +14,7 @@ export function getNextDefaultMagnetAction( // default the first magnet step to engage so that // recommended engage height can auto populate - let nextDefaultMagnetAction: ?string = 'engage' + let nextDefaultMagnetAction: MagnetAction = 'engage' if (lastMagnetStep && lastMagnetStep.magnetAction) { nextDefaultMagnetAction = diff --git a/protocol-designer/src/ui/steps/actions/__tests__/addAndSelectStepWithHints.test.js b/protocol-designer/src/ui/steps/actions/__tests__/addAndSelectStepWithHints.test.js new file mode 100644 index 00000000000..47906d04cdf --- /dev/null +++ b/protocol-designer/src/ui/steps/actions/__tests__/addAndSelectStepWithHints.test.js @@ -0,0 +1,131 @@ +// @flow +import { addAndSelectStepWithHints } from '../thunks' +import { addStep } from '../thunks/addStep' +import { addHint } from '../../../../tutorial/actions' +import * as uiModuleSelectors from '../../../../ui/modules/selectors' +import { selectors as labwareIngredSelectors } from '../../../../labware-ingred/selectors' +jest.mock('../thunks/addStep') +jest.mock('../../../../tutorial/actions') +jest.mock('../../../../ui/modules/selectors') +jest.mock('../../../../labware-ingred/selectors') + +const dispatch = jest.fn() +const getState = jest.fn() +const addStepMock: JestMockFn<[{ stepType: string }, string], *> = addStep +const addHintMock: JestMockFn<[any, string], *> = addHint +const mockGetDeckHasLiquid: JestMockFn<[Object], *> = + labwareIngredSelectors.getDeckHasLiquid +const mockGetMagnetModuleHasLabware: JestMockFn<[Object], *> = + uiModuleSelectors.getMagnetModuleHasLabware +const mockGetTemperatureModuleHasLabware: JestMockFn<[Object], *> = + uiModuleSelectors.getTemperatureModuleHasLabware +const mockGetThermocyclerModuleHasLabware: JestMockFn<[Object], *> = + uiModuleSelectors.getThermocyclerModuleHasLabware +const mockGetSingleTemperatureModuleId: JestMockFn<[Object], *> = + uiModuleSelectors.getSingleTemperatureModuleId +const mockGetSingleThermocyclerModuleId: JestMockFn<[Object], *> = + uiModuleSelectors.getSingleThermocyclerModuleId + +beforeEach(() => { + jest.clearAllMocks() + + addStepMock.mockReturnValue('addStepMockReturnValue') + addHintMock.mockReturnValue('addHintReturnValue') + + mockGetDeckHasLiquid.mockReturnValue(true) + mockGetMagnetModuleHasLabware.mockReturnValue(false) + mockGetTemperatureModuleHasLabware.mockReturnValue(false) + mockGetThermocyclerModuleHasLabware.mockReturnValue(false) + mockGetSingleTemperatureModuleId.mockReturnValue(null) + mockGetSingleThermocyclerModuleId.mockReturnValue(null) +}) + +describe('addAndSelectStepWithHints', () => { + it('should dispatch addStep thunk, and no hints when no hints are applicable (eg pause step)', () => { + const stepType = 'pause' + const payload = { stepType } + addAndSelectStepWithHints(payload)(dispatch, getState) + + expect(addStepMock.mock.calls).toEqual([[payload]]) + expect(dispatch.mock.calls).toEqual([['addStepMockReturnValue']]) + }) + + it('should dispatch addStep thunk, and also ADD_HINT "add_liquids_and_labware" if we\'re adding a step that uses liquid but have no liquids on the deck', () => { + const stepType = 'moveLiquid' + const payload = { stepType } + mockGetDeckHasLiquid.mockReturnValue(false) // no liquid! + addAndSelectStepWithHints(payload)(dispatch, getState) + + expect(addStepMock.mock.calls).toEqual([[payload]]) + expect(addHintMock.mock.calls).toEqual([['add_liquids_and_labware']]) + expect(dispatch.mock.calls).toEqual([ + ['addStepMockReturnValue'], + ['addHintReturnValue'], + ]) + }) + + describe('ADD_HINT "module_without_labware"', () => { + ;[ + { + testName: 'magnet step, when magnetic module has no labware', + stepType: 'magnet', + selectorValues: { + getMagnetModuleHasLabware: false, + getTemperatureModuleHasLabware: false, + getThermocyclerModuleHasLabware: false, + getSingleTemperatureModuleId: null, + getSingleThermocyclerModuleId: null, + }, + }, + { + testName: 'temperature step, when temperature module has no labware', + stepType: 'temperature', + selectorValues: { + getMagnetModuleHasLabware: false, + getTemperatureModuleHasLabware: false, + getThermocyclerModuleHasLabware: false, + getSingleTemperatureModuleId: 'something', + getSingleThermocyclerModuleId: null, + }, + }, + { + testName: 'temperature step, when thermocycler has no labware', + stepType: 'temperature', + selectorValues: { + getMagnetModuleHasLabware: false, + getTemperatureModuleHasLabware: false, + getThermocyclerModuleHasLabware: false, + getSingleTemperatureModuleId: null, + getSingleThermocyclerModuleId: 'something', + }, + }, + ].forEach(({ testName, stepType, selectorValues }) => { + it(`should be dispatched (after addStep thunk is dispatched) for ${testName}`, () => { + mockGetMagnetModuleHasLabware.mockReturnValue( + selectorValues.getMagnetModuleHasLabware + ) + mockGetTemperatureModuleHasLabware.mockReturnValue( + selectorValues.getTemperatureModuleHasLabware + ) + mockGetThermocyclerModuleHasLabware.mockReturnValue( + selectorValues.getThermocyclerModuleHasLabware + ) + mockGetSingleTemperatureModuleId.mockReturnValue( + selectorValues.getSingleTemperatureModuleId + ) + mockGetSingleThermocyclerModuleId.mockReturnValue( + selectorValues.getSingleThermocyclerModuleId + ) + const payload = { stepType } + addAndSelectStepWithHints(payload)(dispatch, getState) + + expect(addStepMock.mock.calls).toEqual([[payload]]) + expect(addHintMock.mock.calls).toEqual([['module_without_labware']]) + expect(dispatch.mock.calls).toEqual([ + ['addStepMockReturnValue'], + ['addHintReturnValue'], + ]) + }) + }) + }) +}) diff --git a/protocol-designer/src/ui/steps/actions/__tests__/addStep.test.js b/protocol-designer/src/ui/steps/actions/__tests__/addStep.test.js new file mode 100644 index 00000000000..6598cdce502 --- /dev/null +++ b/protocol-designer/src/ui/steps/actions/__tests__/addStep.test.js @@ -0,0 +1,38 @@ +// @flow +import { addStep } from '../thunks/addStep' +import { selectStep } from '../actions' +import { uuid } from '../../../../utils' +jest.mock('../actions') +jest.mock('../../../../utils') + +const dispatch = jest.fn() +const getState = jest.fn() +const uuidMock: JestMockFn<[], string> = uuid +const selectStepMock: JestMockFn<[string, string], *> = selectStep +const id = 'someUUID' + +beforeEach(() => { + jest.clearAllMocks() + + uuidMock.mockReturnValue(id) + selectStepMock.mockReturnValue('selectStepMockReturnValue') +}) + +describe('addStep', () => { + it('should dispatch an ADD_STEP action with given stepType and a UUID, then dispatch selectStep thunk', () => { + const stepType = 'transfer' + addStep({ stepType })(dispatch, getState) + + expect(dispatch.mock.calls).toEqual([ + [ + { + type: 'ADD_STEP', + payload: { stepType, id }, + }, + ], + ['selectStepMockReturnValue'], + ]) + expect(selectStepMock).toHaveBeenCalledTimes(1) + expect(selectStepMock).toHaveBeenCalledWith(id, stepType) + }) +}) diff --git a/protocol-designer/src/ui/steps/actions/__tests__/selectStep.test.js b/protocol-designer/src/ui/steps/actions/__tests__/selectStep.test.js new file mode 100644 index 00000000000..7ec5006fdaf --- /dev/null +++ b/protocol-designer/src/ui/steps/actions/__tests__/selectStep.test.js @@ -0,0 +1,243 @@ +// @flow +// TODO(IL, 2020-03-10): refactor the code covered here to make it more testable, probably by factoring out +// smaller fns from selectStep. +import configureMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' +import { selectStep } from '../actions' +import { PAUSE_UNTIL_RESUME } from '../../../../constants' +import { selectors as stepFormSelectors } from '../../../../step-forms' +import { selectors as uiModulesSelectors } from '../../../modules' +import { + getNextDefaultMagnetAction, + getNextDefaultTemperatureModuleId, + getNextDefaultPipetteId, + handleFormChange, +} from '../../../../steplist/formLevel' +import type { FormPatch } from '../../../../steplist/actions' +import type { FormData, MagnetAction } from '../../../../form-types' +jest.mock('../../../../step-forms') +jest.mock('../../../modules') +jest.mock('../../../../steplist/formLevel') + +const mock_getStepFormData: JestMockFn<[any, string, string], ?FormData> = + stepFormSelectors._getStepFormData +const mockGetSavedStepForms: JestMockFn<[any], any> = + stepFormSelectors.getSavedStepForms +const mockGetOrderedStepIds: JestMockFn<[any], any> = + stepFormSelectors.getOrderedStepIds +const mockGetInitialDeckSetup: JestMockFn<[any], any> = + stepFormSelectors.getInitialDeckSetup +const mockGetPipetteEntities: JestMockFn<[any], any> = + stepFormSelectors.getPipetteEntities +const mockGetLabwareEntities: JestMockFn<[any], any> = + stepFormSelectors.getLabwareEntities + +const mockGetNextDefaultPipetteId: JestMockFn< + [any, any, any], + string | null +> = getNextDefaultPipetteId +const mockGetNextDefaultTemperatureModuleId: JestMockFn< + [any, any, any], + string | null +> = getNextDefaultTemperatureModuleId +const mockGetSingleMagneticModuleId: JestMockFn<[any], string | null> = + uiModulesSelectors.getSingleMagneticModuleId +const mockGetMagnetLabwareEngageHeight: JestMockFn<[any], number | null> = + uiModulesSelectors.getMagnetLabwareEngageHeight +const mockGetNextDefaultMagnetAction: JestMockFn< + [any, any], + MagnetAction +> = getNextDefaultMagnetAction +const mockHandleFormChange: JestMockFn = handleFormChange + +beforeEach(() => { + jest.clearAllMocks() + + mock_getStepFormData.mockReturnValue(null) + // NOTE: selectStep doesn't use the results of these selectors directly, + // it just passes their results into steplist/formLevel fns that we also mock + mockGetSavedStepForms.mockReturnValue('getSaveStepFormsMockReturn') + mockGetOrderedStepIds.mockReturnValue('getOrderedStepIdsMockReturn') + mockGetInitialDeckSetup.mockReturnValue('getInitialDeckSetupMockReturn') + + mockGetPipetteEntities.mockReturnValue('pipetteEntityMockReturn') + mockGetLabwareEntities.mockReturnValue('labwareEntityMockReturn') + mockGetNextDefaultPipetteId.mockReturnValue(null) + mockGetSingleMagneticModuleId.mockReturnValue(null) + mockGetMagnetLabwareEngageHeight.mockReturnValue(null) + + mockHandleFormChange.mockReturnValue({ + handleFormChangePatch: 'mock', + }) +}) + +const middlewares = [thunk] +const mockStore = configureMockStore(middlewares) + +describe('selectStep', () => { + describe('new (never before saved) step', () => { + it('should select a pristine pause step & populate initial values', () => { + const mockedInitialFormData = { id: '123', stepType: 'pause' } + mock_getStepFormData.mockReturnValue(mockedInitialFormData) + const store = mockStore({}) + + // $FlowFixMe(IL, 2020-03-10): problem dispatching a thunk with mock dispatch + store.dispatch(selectStep('newStepId', 'pause')) + + expect(store.getActions()).toEqual([ + { type: 'SELECT_STEP', payload: 'newStepId' }, + { type: 'POPULATE_FORM', payload: mockedInitialFormData }, + ]) + }) + + it('should call handleFormChange with a default pipette for "moveLiquid" step', () => { + const mockedInitialFormData = { + id: 'newStepId', + stepType: 'moveLiquid', + pipette: null, + } + + mockHandleFormChange.mockReturnValue({ + pipette: 'somePipette', + }) + mockGetNextDefaultPipetteId.mockReturnValue('somePipette') + mock_getStepFormData.mockReturnValue(mockedInitialFormData) + const store = mockStore({}) + + // $FlowFixMe(IL, 2020-03-10): problem dispatching a thunk with mock dispatch + store.dispatch(selectStep('newStepId', 'moveLiquid')) + + expect(mockHandleFormChange.mock.calls).toEqual([ + [ + { pipette: 'somePipette' }, + mockedInitialFormData, + 'pipetteEntityMockReturn', + 'labwareEntityMockReturn', + ], + ]) + + expect(store.getActions()).toEqual([ + { type: 'SELECT_STEP', payload: 'newStepId' }, + { + type: 'POPULATE_FORM', + payload: { + ...mockedInitialFormData, + pipette: 'somePipette', + }, + }, + ]) + }) + + it('should set a default magnetic module for magnet step, and set engage height and magnetAction=engage, for magnet > engage', () => { + mockGetSingleMagneticModuleId.mockReturnValue('someMagneticModuleId') + mockGetNextDefaultMagnetAction.mockReturnValue('engage') + mockGetMagnetLabwareEngageHeight.mockReturnValue(12) + + const mockedInitialFormData = { + id: 'newStepId', + stepType: 'magnet', + moduleId: null, + } + mock_getStepFormData.mockReturnValue(mockedInitialFormData) + const store = mockStore({}) + + // $FlowFixMe(IL, 2020-03-10): problem dispatching a thunk with mock dispatch + store.dispatch(selectStep('newStepId', 'magnet')) + + expect(store.getActions()).toEqual([ + { type: 'SELECT_STEP', payload: 'newStepId' }, + { + type: 'POPULATE_FORM', + payload: { + ...mockedInitialFormData, + moduleId: 'someMagneticModuleId', + engageHeight: '12', + magnetAction: 'engage', + }, + }, + ]) + }) + + it('should set a default magnetic module for magnet step, and set magnetAction=disengage, for magnet > disengage', () => { + mockGetSingleMagneticModuleId.mockReturnValue('someMagneticModuleId') + mockGetNextDefaultMagnetAction.mockReturnValue('disengage') + + const mockedInitialFormData = { + id: 'newStepId', + stepType: 'magnet', + moduleId: null, + } + mock_getStepFormData.mockReturnValue(mockedInitialFormData) + const store = mockStore({}) + + // $FlowFixMe(IL, 2020-03-10): problem dispatching a thunk with mock dispatch + store.dispatch(selectStep('newStepId', 'magnet')) + + expect(store.getActions()).toEqual([ + { type: 'SELECT_STEP', payload: 'newStepId' }, + { + type: 'POPULATE_FORM', + payload: { + ...mockedInitialFormData, + moduleId: 'someMagneticModuleId', + engageHeight: null, + magnetAction: 'disengage', + }, + }, + ]) + }) + + it('should set a default temperature module for Temperature step', () => { + mockGetNextDefaultTemperatureModuleId.mockReturnValue( + 'someTemperatureModuleId' + ) + + const mockedInitialFormData = { + id: 'newStepId', + stepType: 'temperature', + moduleId: null, + } + mock_getStepFormData.mockReturnValue(mockedInitialFormData) + const store = mockStore({}) + + // $FlowFixMe(IL, 2020-03-10): problem dispatching a thunk with mock dispatch + store.dispatch(selectStep('newStepId', 'temperature')) + + expect(store.getActions()).toEqual([ + { type: 'SELECT_STEP', payload: 'newStepId' }, + { + type: 'POPULATE_FORM', + payload: { + ...mockedInitialFormData, + moduleId: 'someTemperatureModuleId', + }, + }, + ]) + }) + }) + + describe('selecting previously-saved step', () => { + it('should restore a saved step', () => { + const existingStep = { + stepType: 'pause', + id: 'existingStepId', + stepName: 'Example pause', + stepDetails: 'details', + pauseForAmountOfTime: PAUSE_UNTIL_RESUME, + } + mock_getStepFormData.mockReturnValue(existingStep) + const store = mockStore({}) + + // $FlowFixMe(IL, 2020-03-10): problem dispatching a thunk with mock dispatch + store.dispatch(selectStep('existingStepId')) + + expect(store.getActions()).toEqual([ + { type: 'SELECT_STEP', payload: 'existingStepId' }, + { + type: 'POPULATE_FORM', + payload: existingStep, + }, + ]) + }) + }) +}) diff --git a/protocol-designer/src/ui/steps/actions/thunks.js b/protocol-designer/src/ui/steps/actions/thunks.js deleted file mode 100644 index 8c03e7f7714..00000000000 --- a/protocol-designer/src/ui/steps/actions/thunks.js +++ /dev/null @@ -1,101 +0,0 @@ -// @flow -import { uuid } from '../../../utils' -import { selectors as labwareIngredsSelectors } from '../../../labware-ingred/selectors' -import { getSelectedStepId } from '../selectors' -import { selectStep } from './actions' -import { actions as tutorialActions } from '../../../tutorial' - -import * as uiModuleSelectors from '../../../ui/modules/selectors' -import type { DuplicateStepAction } from './types' - -import type { StepType, StepIdType } from '../../../form-types' -import type { GetState, ThunkDispatch } from '../../../types' - -// addStep thunk adds an incremental integer ID for Step reducers. -export const addStep = (payload: { stepType: StepType }) => ( - dispatch: ThunkDispatch<*>, - getState: GetState -) => { - const state = getState() - const stepId = uuid() - const { stepType } = payload - dispatch({ - type: 'ADD_STEP', - payload: { - ...payload, - id: stepId, - }, - }) - const deckHasLiquid = labwareIngredsSelectors.getDeckHasLiquid(state) - const magnetModuleHasLabware = uiModuleSelectors.getMagnetModuleHasLabware( - state - ) - const temperatureModuleHasLabware = uiModuleSelectors.getTemperatureModuleHasLabware( - state - ) - const thermocyclerModuleHasLabware = uiModuleSelectors.getThermocyclerModuleHasLabware( - state - ) - const temperatureModuleOnDeck = uiModuleSelectors.getSingleTemperatureModuleId( - state - ) - const thermocyclerModuleOnDeck = uiModuleSelectors.getSingleThermocyclerModuleId( - state - ) - - // TODO: Ian 2019-01-17 move out to centralized step info file - see #2926 - const stepNeedsLiquid = ['mix', 'moveLiquid'].includes(payload.stepType) - const stepMagnetNeedsLabware = ['magnet'].includes(payload.stepType) - const stepTemperatureNeedsLabware = ['temperature'].includes(payload.stepType) - const stepModuleMissingLabware = - (stepMagnetNeedsLabware && !magnetModuleHasLabware) || - (stepTemperatureNeedsLabware && - ((temperatureModuleOnDeck && !temperatureModuleHasLabware) || - (thermocyclerModuleOnDeck && !thermocyclerModuleHasLabware))) - if (stepNeedsLiquid && !deckHasLiquid) { - dispatch(tutorialActions.addHint('add_liquids_and_labware')) - } - if (stepModuleMissingLabware) { - dispatch(tutorialActions.addHint('module_without_labware')) - } - dispatch(selectStep(stepId, stepType)) -} - -export type ReorderSelectedStepAction = { - type: 'REORDER_SELECTED_STEP', - payload: { - delta: number, - stepId: StepIdType, - }, -} - -export const reorderSelectedStep = (delta: number) => ( - dispatch: ThunkDispatch, - getState: GetState -) => { - const stepId = getSelectedStepId(getState()) - - if (stepId != null) { - dispatch({ - type: 'REORDER_SELECTED_STEP', - payload: { - delta, - stepId, - }, - }) - } -} - -export const duplicateStep = (stepId: StepIdType) => ( - dispatch: ThunkDispatch, - getState: GetState -) => { - const duplicateStepId = uuid() - - if (stepId != null) { - dispatch({ - type: 'DUPLICATE_STEP', - payload: { stepId, duplicateStepId }, - }) - } -} diff --git a/protocol-designer/src/ui/steps/actions/thunks/addStep.js b/protocol-designer/src/ui/steps/actions/thunks/addStep.js new file mode 100644 index 00000000000..50f004a916d --- /dev/null +++ b/protocol-designer/src/ui/steps/actions/thunks/addStep.js @@ -0,0 +1,31 @@ +// @flow +import { uuid } from '../../../../utils' +import { selectStep } from '../actions' + +import type { StepType } from '../../../../form-types' +import type { GetState, ThunkDispatch } from '../../../../types' + +type AddStepPayload = { id: string, stepType: StepType } +type AddStepAction = {| type: 'ADD_STEP', payload: AddStepPayload |} +const _addStep = (payload: AddStepPayload): AddStepAction => ({ + type: 'ADD_STEP', + payload, +}) + +// addStep thunk adds an incremental integer ID for Step reducers. +// NOTE: if this is an "add step" directly performed by the user, +// addAndSelectStepWithHints is probably what you want +export const addStep = (payload: { stepType: StepType }) => ( + dispatch: ThunkDispatch<*>, + getState: GetState +) => { + const stepId = uuid() + const { stepType } = payload + dispatch( + _addStep({ + ...payload, + id: stepId, + }) + ) + dispatch(selectStep(stepId, stepType)) +} diff --git a/protocol-designer/src/ui/steps/actions/thunks/index.js b/protocol-designer/src/ui/steps/actions/thunks/index.js new file mode 100644 index 00000000000..ab26581f632 --- /dev/null +++ b/protocol-designer/src/ui/steps/actions/thunks/index.js @@ -0,0 +1,195 @@ +// @flow +import assert from 'assert' +import { + getUnsavedForm, + getUnsavedFormIsPristineSetTempForm, +} from '../../../../step-forms/selectors' +import { changeFormInput } from '../../../../steplist/actions/actions' + +import { uuid } from '../../../../utils' +import { selectors as labwareIngredsSelectors } from '../../../../labware-ingred/selectors' +import { getSelectedStepId } from '../../selectors' +import { addStep } from './addStep' +import { actions as tutorialActions } from '../../../../tutorial' + +import * as uiModuleSelectors from '../../../../ui/modules/selectors' +import type { DuplicateStepAction } from '../types' + +import type { StepType, StepIdType, FormData } from '../../../../form-types' +import type { GetState, ThunkDispatch } from '../../../../types' + +export const addAndSelectStepWithHints = (payload: { stepType: StepType }) => ( + dispatch: ThunkDispatch<*>, + getState: GetState +) => { + dispatch(addStep(payload)) + const state = getState() + const deckHasLiquid = labwareIngredsSelectors.getDeckHasLiquid(state) + const magnetModuleHasLabware = uiModuleSelectors.getMagnetModuleHasLabware( + state + ) + const temperatureModuleHasLabware = uiModuleSelectors.getTemperatureModuleHasLabware( + state + ) + const thermocyclerModuleHasLabware = uiModuleSelectors.getThermocyclerModuleHasLabware( + state + ) + const temperatureModuleOnDeck = uiModuleSelectors.getSingleTemperatureModuleId( + state + ) + const thermocyclerModuleOnDeck = uiModuleSelectors.getSingleThermocyclerModuleId( + state + ) + + // TODO: Ian 2019-01-17 move out to centralized step info file - see #2926 + const stepNeedsLiquid = ['mix', 'moveLiquid'].includes(payload.stepType) + const stepMagnetNeedsLabware = ['magnet'].includes(payload.stepType) + const stepTemperatureNeedsLabware = ['temperature'].includes(payload.stepType) + const stepModuleMissingLabware = + (stepMagnetNeedsLabware && !magnetModuleHasLabware) || + (stepTemperatureNeedsLabware && + ((temperatureModuleOnDeck && !temperatureModuleHasLabware) || + (thermocyclerModuleOnDeck && !thermocyclerModuleHasLabware))) + if (stepNeedsLiquid && !deckHasLiquid) { + dispatch(tutorialActions.addHint('add_liquids_and_labware')) + } + if (stepModuleMissingLabware) { + dispatch(tutorialActions.addHint('module_without_labware')) + } +} + +export type ReorderSelectedStepAction = { + type: 'REORDER_SELECTED_STEP', + payload: { + delta: number, + stepId: StepIdType, + }, +} + +export const reorderSelectedStep = (delta: number) => ( + dispatch: ThunkDispatch, + getState: GetState +) => { + const stepId = getSelectedStepId(getState()) + + if (stepId != null) { + dispatch({ + type: 'REORDER_SELECTED_STEP', + payload: { + delta, + stepId, + }, + }) + } +} + +export const duplicateStep = (stepId: StepIdType) => ( + dispatch: ThunkDispatch, + getState: GetState +) => { + const duplicateStepId = uuid() + + if (stepId != null) { + dispatch({ + type: 'DUPLICATE_STEP', + payload: { stepId, duplicateStepId }, + }) + } +} + +export const SAVE_STEP_FORM: 'SAVE_STEP_FORM' = 'SAVE_STEP_FORM' + +export type SaveStepFormAction = {| + type: typeof SAVE_STEP_FORM, + payload: FormData, +|} + +export const _saveStepForm = (form: FormData): SaveStepFormAction => ({ + type: SAVE_STEP_FORM, + payload: form, +}) + +/** take unsavedForm state and put it into the payload */ +export const saveStepForm = () => ( + dispatch: ThunkDispatch<*>, + getState: GetState +): void => { + const initialState = getState() + const unsavedForm = getUnsavedForm(initialState) + + // this check is only for Flow. At this point, unsavedForm should always be populated + if (!unsavedForm) { + assert( + false, + 'Tried to saveStepForm with falsey unsavedForm. This should never be able to happen.' + ) + return + } + + // save the form + dispatch(_saveStepForm(unsavedForm)) +} + +/** "power action", mimicking saving the never-saved "set temperature X" step, + ** then creating and saving a "pause until temp X" step */ +export const saveSetTempFormWithAddedPauseUntilTemp = () => ( + dispatch: ThunkDispatch<*>, + getState: GetState +): void => { + const initialState = getState() + const unsavedSetTemperatureForm = getUnsavedForm(initialState) + const isPristineSetTempForm = getUnsavedFormIsPristineSetTempForm( + initialState + ) + + // this check is only for Flow. At this point, unsavedForm should always be populated + if (!unsavedSetTemperatureForm) { + assert( + false, + 'Tried to saveSetTempFormWithAddedPauseUntilTemp with falsey unsavedForm. This should never be able to happen.' + ) + return + } + const { id } = unsavedSetTemperatureForm + + if (!isPristineSetTempForm) { + // this check should happen upstream (before dispatching saveSetTempFormWithAddedPauseUntilTemp in the first place) + assert( + false, + `tried to saveSetTempFormWithAddedPauseUntilTemp but form ${id} is not a pristine set temp form` + ) + return + } + + const temperature = unsavedSetTemperatureForm?.targetTemperature + assert( + temperature != null && temperature !== '', + `tried to auto-add a pause until temp, but targetTemperature is missing: ${temperature}` + ) + // save the set temperature step form that is currently open + dispatch(_saveStepForm(unsavedSetTemperatureForm)) + + // add a new pause step form + addStep({ stepType: 'pause' })(dispatch, getState) + + // NOTE: fields should be set one at a time b/c dependentFieldsUpdate fns can filter out inputs + // contingent on other inputs (eg changing the pauseForAmountOfTime radio button may clear the pauseTemperature). + dispatch( + changeFormInput({ + update: { + pauseForAmountOfTime: 'untilTemperature', + }, + }) + ) + dispatch(changeFormInput({ update: { pauseTemperature: temperature } })) + + // finally save the new pause form + const unsavedPauseForm = getUnsavedForm(getState()) + + // this conditional is for Flow, the unsaved form should always exist + if (unsavedPauseForm != null) { + dispatch(_saveStepForm(unsavedPauseForm)) + } else { + assert(false, 'could not auto-save pause form, getUnsavedForm returned') + } +}