diff --git a/app/src/assets/localization/en/protocol_setup.json b/app/src/assets/localization/en/protocol_setup.json index fc99548e57c..1aef1580d67 100644 --- a/app/src/assets/localization/en/protocol_setup.json +++ b/app/src/assets/localization/en/protocol_setup.json @@ -20,6 +20,7 @@ "calibrate_deck_to_proceed_to_tip_length_calibration": "Calibrate your deck in order to proceed to tip length calibration", "calibrate_gripper_failure_reason": "Calibrate the required gripper to continue", "calibrate_now": "Calibrate now", + "calibrate_module_failure_reason": "Calibrate the required modules(s) to continue", "calibrate_pipette_before_module_calibration": "Calibrate pipette before running module calibration", "calibrate_pipette_failure_reason": "Calibrate the required pipette(s) to continue", "calibrate_tiprack_failure_reason": "Calibrate the required tip lengths to continue", diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx index b0a6f1aaa1e..0d3057f26c5 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx @@ -85,6 +85,7 @@ import { useTrackProtocolRunEvent, useRobotAnalyticsData, useIsOT3, + useModuleCalibrationStatus, } from '../hooks' import { formatTimestamp } from '../utils' import { RunTimer } from './RunTimer' @@ -476,10 +477,17 @@ function ActionButton(props: ActionButtonProps): JSX.Element { robotName, runId ) + const { complete: isModuleCalibrationComplete } = useModuleCalibrationStatus( + robotName, + runId + ) const [showIsShakingModal, setShowIsShakingModal] = React.useState( false ) - const isSetupComplete = isCalibrationComplete && missingModuleIds.length === 0 + const isSetupComplete = + isCalibrationComplete && + isModuleCalibrationComplete && + missingModuleIds.length === 0 const isRobotOnWrongVersionOfSoftware = ['upgrade', 'downgrade'].includes( useSelector((state: State) => { return getRobotUpdateDisplayInfo(state, robotName) diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx index 286f0a827f2..d85fc15abca 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx @@ -25,6 +25,7 @@ import { useRunHasStarted, useProtocolAnalysisErrors, useStoredProtocolAnalysis, + useModuleCalibrationStatus, ProtocolCalibrationStatus, } from '../hooks' import { useMostRecentCompletedAnalysis } from '../../LabwarePositionCheck/useMostRecentCompletedAnalysis' @@ -67,8 +68,9 @@ export function ProtocolRunSetup({ const protocolData = robotProtocolAnalysis ?? storedProtocolAnalysis const modules = parseAllRequiredModuleModels(protocolData?.commands ?? []) const robot = useRobot(robotName) - const calibrationStatus = useRunCalibrationStatus(robotName, runId) - const isOT3 = useIsOT3(robotName) + const calibrationStatusRobot = useRunCalibrationStatus(robotName, runId) + const calibrationStatusModules = useModuleCalibrationStatus(robotName, runId) + const isFlex = useIsOT3(robotName) const runHasStarted = useRunHasStarted(runId) const { analysisErrors } = useProtocolAnalysisErrors(runId) const [expandedStepKey, setExpandedStepKey] = React.useState( @@ -129,11 +131,11 @@ export function ProtocolRunSetup({ ] } expandStep={setExpandedStepKey} - calibrationStatus={calibrationStatus} + calibrationStatus={calibrationStatusRobot} /> ), // change description for OT-3 - description: isOT3 + description: isFlex ? t(`${ROBOT_CALIBRATION_STEP_KEY}_description_pipettes_only`) : t(`${ROBOT_CALIBRATION_STEP_KEY}_description`), }, @@ -229,7 +231,13 @@ export function ProtocolRunSetup({ } rightElement={ } > @@ -254,25 +262,42 @@ export function ProtocolRunSetup({ interface StepRightElementProps { stepKey: StepKey - calibrationStatus: ProtocolCalibrationStatus + calibrationStatusRobot: ProtocolCalibrationStatus + calibrationStatusModules?: ProtocolCalibrationStatus runHasStarted: boolean + isFlex: boolean } function StepRightElement(props: StepRightElementProps): JSX.Element | null { - const { stepKey, calibrationStatus, runHasStarted } = props + const { + stepKey, + runHasStarted, + calibrationStatusRobot, + calibrationStatusModules, + isFlex, + } = props const { t } = useTranslation('protocol_setup') - if (stepKey === ROBOT_CALIBRATION_STEP_KEY && !runHasStarted) { + if ( + !runHasStarted && + (stepKey === ROBOT_CALIBRATION_STEP_KEY || + (stepKey === MODULE_SETUP_KEY && isFlex)) + ) { + const calibrationStatus = + stepKey === ROBOT_CALIBRATION_STEP_KEY + ? calibrationStatusRobot + : calibrationStatusModules + return ( - {calibrationStatus.complete + {calibrationStatus?.complete ? t('calibration_ready') : t('calibration_needed')} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModules/__tests__/SetupModules.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupModules/__tests__/SetupModules.test.tsx index 60e17d5153d..6fa3aed9330 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModules/__tests__/SetupModules.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupModules/__tests__/SetupModules.test.tsx @@ -9,6 +9,7 @@ import { SetupModulesMap } from '../SetupModulesMap' import { useRunHasStarted, useUnmatchedModulesForProtocol, + useModuleCalibrationStatus, } from '../../../hooks' import { mockTemperatureModule } from '../../../../../redux/modules/__fixtures__' @@ -22,6 +23,9 @@ const mockUseRunHasStarted = useRunHasStarted as jest.MockedFunction< const mockUseUnmatchedModulesForProtocol = useUnmatchedModulesForProtocol as jest.MockedFunction< typeof useUnmatchedModulesForProtocol > +const mockUseModuleCalibrationStatus = useModuleCalibrationStatus as jest.MockedFunction< + typeof useModuleCalibrationStatus +> const mockSetupModulesList = SetupModulesList as jest.MockedFunction< typeof SetupModulesList > @@ -55,6 +59,9 @@ describe('SetupModules', () => { missingModuleIds: [], remainingAttachedModules: [], }) + when(mockUseModuleCalibrationStatus) + .calledWith(MOCK_ROBOT_NAME, MOCK_RUN_ID) + .mockReturnValue({ complete: true }) }) it('renders the list and map view buttons', () => { @@ -85,6 +92,17 @@ describe('SetupModules', () => { expect(button).toBeDisabled() }) + it('should render a disabled Proceed to labware setup CTA if the protocol requests modules they are not all calibrated', () => { + when(mockUseModuleCalibrationStatus) + .calledWith(MOCK_ROBOT_NAME, MOCK_RUN_ID) + .mockReturnValue({ complete: false }) + const { getByRole } = render(props) + const button = getByRole('button', { + name: 'Proceed to labware position check', + }) + expect(button).toBeDisabled() + }) + it('should render the SetupModulesList component when clicking List View', () => { const { getByRole, getByText } = render(props) const button = getByRole('button', { name: 'List View' }) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModules/index.tsx b/app/src/organisms/Devices/ProtocolRun/SetupModules/index.tsx index 63500b87608..b26d3e0f43d 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModules/index.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupModules/index.tsx @@ -8,7 +8,11 @@ import { useHoverTooltip, PrimaryButton, } from '@opentrons/components' -import { useRunHasStarted, useUnmatchedModulesForProtocol } from '../../hooks' +import { + useRunHasStarted, + useUnmatchedModulesForProtocol, + useModuleCalibrationStatus, +} from '../../hooks' import { useToggleGroup } from '../../../../molecules/ToggleGroup/useToggleGroup' import { Tooltip } from '../../../../atoms/Tooltip' import { SetupModulesMap } from './SetupModulesMap' @@ -33,6 +37,9 @@ export const SetupModules = ({ const { missingModuleIds } = useUnmatchedModulesForProtocol(robotName, runId) const runHasStarted = useRunHasStarted(runId) const [targetProps, tooltipProps] = useHoverTooltip() + + const moduleCalibrationStatus = useModuleCalibrationStatus(robotName, runId) + return ( <> @@ -45,7 +52,11 @@ export const SetupModules = ({ 0 || runHasStarted} + disabled={ + missingModuleIds.length > 0 || + runHasStarted || + !moduleCalibrationStatus.complete + } onClick={expandLabwarePositionCheckStep} id="ModuleSetup_proceedToLabwarePositionCheck" padding={`${SPACING.spacing8} ${SPACING.spacing16}`} @@ -54,11 +65,15 @@ export const SetupModules = ({ {t('proceed_to_labware_position_check')} - {missingModuleIds.length > 0 || runHasStarted ? ( + {missingModuleIds.length > 0 || + runHasStarted || + !moduleCalibrationStatus.complete ? ( {runHasStarted ? t('protocol_run_started') - : t('plug_in_required_module', { count: missingModuleIds.length })} + : missingModuleIds.length > 0 + ? t('plug_in_required_module', { count: missingModuleIds.length }) + : t('calibrate_module_failure_reason')} ) : null} diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx index 0e52d62c811..4e3cc7afd7e 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx @@ -66,6 +66,7 @@ import { useTrackProtocolRunEvent, useRunCalibrationStatus, useRunCreatedAtTimestamp, + useModuleCalibrationStatus, useUnmatchedModulesForProtocol, useIsRobotViewable, useIsOT3, @@ -148,6 +149,9 @@ const mockUseUnmatchedModulesForProtocol = useUnmatchedModulesForProtocol as jes const mockUseRunCalibrationStatus = useRunCalibrationStatus as jest.MockedFunction< typeof useRunCalibrationStatus > +const mockUseModuleCalibrationStatus = useModuleCalibrationStatus as jest.MockedFunction< + typeof useModuleCalibrationStatus +> const mockUseRunCreatedAtTimestamp = useRunCreatedAtTimestamp as jest.MockedFunction< typeof useRunCreatedAtTimestamp > @@ -363,6 +367,9 @@ describe('ProtocolRunHeader', () => { when(mockUseRunCalibrationStatus) .calledWith(ROBOT_NAME, RUN_ID) .mockReturnValue({ complete: true }) + when(mockUseModuleCalibrationStatus) + .calledWith(ROBOT_NAME, RUN_ID) + .mockReturnValue({ complete: true }) when(mockUseIsOT3).calledWith(ROBOT_NAME).mockReturnValue(true) mockRunFailedModal.mockReturnValue(
mock RunFailedModal
) mockUseEstopQuery.mockReturnValue({ data: mockEstopStatus } as any) diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx index 2f5dd0b115c..ef9362236e2 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx @@ -20,6 +20,7 @@ import { useIsOT3, useRobot, useRunCalibrationStatus, + useModuleCalibrationStatus, useRunHasStarted, useProtocolAnalysisErrors, useStoredProtocolAnalysis, @@ -52,6 +53,9 @@ const mockUseRobot = useRobot as jest.MockedFunction const mockUseRunCalibrationStatus = useRunCalibrationStatus as jest.MockedFunction< typeof useRunCalibrationStatus > +const mockUseModuleCalibrationStatus = useModuleCalibrationStatus as jest.MockedFunction< + typeof useModuleCalibrationStatus +> const mockUseRunHasStarted = useRunHasStarted as jest.MockedFunction< typeof useRunHasStarted > @@ -266,11 +270,43 @@ describe('ProtocolRunSetup', () => { ...MOCK_ROTOCOL_LIQUID_KEY, } as any) when(mockUseRunHasStarted).calledWith(RUN_ID).mockReturnValue(false) + when(mockUseModuleCalibrationStatus) + .calledWith(ROBOT_NAME, RUN_ID) + .mockReturnValue({ complete: true }) }) afterEach(() => { resetAllWhenMocks() }) + it('renders calibration ready if robot is Flex and modules are calibrated', () => { + when(mockUseIsOT3).calledWith(ROBOT_NAME).mockReturnValue(true) + when(mockUseModuleCalibrationStatus) + .calledWith(ROBOT_NAME, RUN_ID) + .mockReturnValue({ complete: true }) + + const { getAllByText } = render() + expect(getAllByText('Calibration ready').length).toEqual(2) + }) + + it('renders calibration needed if robot is Flex and modules are not calibrated', () => { + when(mockUseIsOT3).calledWith(ROBOT_NAME).mockReturnValue(true) + when(mockUseModuleCalibrationStatus) + .calledWith(ROBOT_NAME, RUN_ID) + .mockReturnValue({ complete: false }) + + const { getByText } = render() + getByText('STEP 2') + getByText('Modules') + getByText('Calibration needed') + }) + + it('does not render calibration element if robot is OT-2', () => { + when(mockUseIsOT3).calledWith(ROBOT_NAME).mockReturnValue(false) + + const { getAllByText } = render() + expect(getAllByText('Calibration ready').length).toEqual(1) + }) + it('renders module setup and allows the user to proceed to labware setup', () => { const { getByText } = render() const moduleSetup = getByText('Modules') diff --git a/app/src/organisms/Devices/hooks/__tests__/useModuleCalibrationStatus.test.tsx b/app/src/organisms/Devices/hooks/__tests__/useModuleCalibrationStatus.test.tsx new file mode 100644 index 00000000000..3832af7c94e --- /dev/null +++ b/app/src/organisms/Devices/hooks/__tests__/useModuleCalibrationStatus.test.tsx @@ -0,0 +1,159 @@ +import * as React from 'react' +import { QueryClient, QueryClientProvider } from 'react-query' +import { renderHook } from '@testing-library/react-hooks' +import { when, resetAllWhenMocks } from 'jest-when' + +import { + useIsOT3, + useModuleCalibrationStatus, + useModuleRenderInfoForProtocolById, +} from '..' + +import { mockMagneticModuleGen2 } from '../../../../redux/modules/__fixtures__' + +import type { ModuleModel, ModuleType } from '@opentrons/shared-data' + +import { Provider } from 'react-redux' +import { createStore } from 'redux' + +jest.mock('../useIsOT3') +jest.mock('../useModuleRenderInfoForProtocolById') + +const mockUseIsOT3 = useIsOT3 as jest.MockedFunction +const mockUseModuleRenderInfoForProtocolById = useModuleRenderInfoForProtocolById as jest.MockedFunction< + typeof useModuleRenderInfoForProtocolById +> +let wrapper: React.FunctionComponent<{}> + +const mockMagneticModuleDefinition = { + moduleId: 'someMagneticModule', + model: 'magneticModuleV2' as ModuleModel, + type: 'magneticModuleType' as ModuleType, + labwareOffset: { x: 5, y: 5, z: 5 }, + cornerOffsetFromSlot: { x: 1, y: 1, z: 1 }, + dimensions: { + xDimension: 100, + yDimension: 100, + footprintXDimension: 50, + footprintYDimension: 50, + labwareInterfaceXDimension: 80, + labwareInterfaceYDimension: 120, + }, + twoDimensionalRendering: { children: [] }, +} + +const MAGNETIC_MODULE_INFO = { + moduleId: 'magneticModuleId', + x: 0, + y: 0, + z: 0, + moduleDef: mockMagneticModuleDefinition as any, + nestedLabwareDef: null, + nestedLabwareId: null, + nestedLabwareDisplayName: null, + protocolLoadOrder: 0, + slotName: '1', +} + +const mockOffsetData = { + offset: { + x: 0.2578125, + y: -0.3515625, + z: -0.7515000000000001, + }, + slot: 'D1', + last_modified: '2023-10-11T14:11:14.061780+00:00', +} + +describe('useModuleCalibrationStatus hook', () => { + beforeEach(() => { + const queryClient = new QueryClient() + const store = createStore(jest.fn(), {}) + store.dispatch = jest.fn() + store.getState = jest.fn(() => {}) + + wrapper = ({ children }) => ( + + {children} + + ) + }) + afterEach(() => { + resetAllWhenMocks() + jest.resetAllMocks() + }) + + it('should return calibration complete if OT-2', () => { + when(mockUseIsOT3).calledWith('otie').mockReturnValue(false) + when(mockUseModuleRenderInfoForProtocolById) + .calledWith('otie', '1') + .mockReturnValue({}) + + const { result } = renderHook( + () => useModuleCalibrationStatus('otie', '1'), + { wrapper } + ) + + expect(result.current).toEqual({ complete: true }) + }) + + it('should return calibration complete if no modules needed', () => { + when(mockUseIsOT3).calledWith('otie').mockReturnValue(true) + when(mockUseModuleRenderInfoForProtocolById) + .calledWith('otie', '1') + .mockReturnValue({}) + + const { result } = renderHook( + () => useModuleCalibrationStatus('otie', '1'), + { wrapper } + ) + + expect(result.current).toEqual({ complete: true }) + }) + + it('should return calibration complete if offset date exists', () => { + when(mockUseIsOT3).calledWith('otie').mockReturnValue(true) + when(mockUseModuleRenderInfoForProtocolById) + .calledWith('otie', '1') + .mockReturnValue({ + magneticModuleId: { + attachedModuleMatch: { + ...mockMagneticModuleGen2, + moduleOffset: mockOffsetData, + }, + ...MAGNETIC_MODULE_INFO, + }, + }) + + const { result } = renderHook( + () => useModuleCalibrationStatus('otie', '1'), + { wrapper } + ) + + expect(result.current).toEqual({ complete: true }) + }) + + it('should return calibration needed if offset date does not exist', () => { + when(mockUseIsOT3).calledWith('otie').mockReturnValue(true) + when(mockUseModuleRenderInfoForProtocolById) + .calledWith('otie', '1') + .mockReturnValue({ + magneticModuleId: { + attachedModuleMatch: { + ...mockMagneticModuleGen2, + }, + ...MAGNETIC_MODULE_INFO, + }, + }) + + const { result } = renderHook( + () => useModuleCalibrationStatus('otie', '1'), + { wrapper } + ) + + expect(result.current).toEqual({ + complete: false, + reason: 'calibrate_module_failure_reason', + }) + }) +}) diff --git a/app/src/organisms/Devices/hooks/index.ts b/app/src/organisms/Devices/hooks/index.ts index 6fd6b9bcc6d..b2195d15325 100644 --- a/app/src/organisms/Devices/hooks/index.ts +++ b/app/src/organisms/Devices/hooks/index.ts @@ -14,6 +14,7 @@ export * from './useLEDLights' export * from './useLPCDisabledReason' export * from './useLPCSuccessToast' export * from './useModuleRenderInfoForProtocolById' +export * from './useModuleCalibrationStatus' export * from './usePipetteOffsetCalibrations' export * from './usePipetteOffsetCalibration' export * from './useProtocolDetailsForRun' diff --git a/app/src/organisms/Devices/hooks/useModuleCalibrationStatus.ts b/app/src/organisms/Devices/hooks/useModuleCalibrationStatus.ts new file mode 100644 index 00000000000..68ea4420433 --- /dev/null +++ b/app/src/organisms/Devices/hooks/useModuleCalibrationStatus.ts @@ -0,0 +1,31 @@ +import { useIsOT3 } from './useIsOT3' +import { useModuleRenderInfoForProtocolById } from './useModuleRenderInfoForProtocolById' +import { ProtocolCalibrationStatus } from './useRunCalibrationStatus' + +export function useModuleCalibrationStatus( + robotName: string, + runId: string +): ProtocolCalibrationStatus { + const isFlex = useIsOT3(robotName) + const moduleRenderInfoForProtocolById = useModuleRenderInfoForProtocolById( + robotName, + runId + ) + // only check module calibration for Flex + if (!isFlex) { + return { complete: true } + } + + const moduleInfoKeys = Object.keys(moduleRenderInfoForProtocolById) + if (moduleInfoKeys.length === 0) { + return { complete: true } + } + const moduleData = moduleInfoKeys.map( + key => moduleRenderInfoForProtocolById[key] + ) + if (moduleData.some(m => m.attachedModuleMatch?.moduleOffset == null)) { + return { complete: false, reason: 'calibrate_module_failure_reason' } + } else { + return { complete: true } + } +} diff --git a/app/src/organisms/Devices/hooks/useRunCalibrationStatus.ts b/app/src/organisms/Devices/hooks/useRunCalibrationStatus.ts index dbb0ee19812..0ac05bce5e6 100644 --- a/app/src/organisms/Devices/hooks/useRunCalibrationStatus.ts +++ b/app/src/organisms/Devices/hooks/useRunCalibrationStatus.ts @@ -23,6 +23,7 @@ export interface ProtocolCalibrationStatus { | 'calibrate_tiprack_failure_reason' | 'calibrate_pipette_failure_reason' | 'calibrate_gripper_failure_reason' + | 'calibrate_module_failure_reason' | 'attach_pipette_failure_reason' | 'attach_gripper_failure_reason' } diff --git a/app/src/redux/calibration/types.ts b/app/src/redux/calibration/types.ts index 84cb1f5e11b..c8b54499bb9 100644 --- a/app/src/redux/calibration/types.ts +++ b/app/src/redux/calibration/types.ts @@ -69,6 +69,7 @@ export interface ProtocolCalibrationStatus { | 'calibrate_tiprack_failure_reason' | 'calibrate_pipette_failure_reason' | 'calibrate_gripper_failure_reason' + | 'calibrate_module_failure_reason' | 'attach_gripper_failure_reason' | 'attach_pipette_failure_reason' }