diff --git a/app/src/assets/localization/en/labware_position_check.json b/app/src/assets/localization/en/labware_position_check.json index 97704f67239..22d4eb29c0c 100644 --- a/app/src/assets/localization/en/labware_position_check.json +++ b/app/src/assets/localization/en/labware_position_check.json @@ -82,5 +82,6 @@ "labware_position_check_description": "Labware Position Check is a guided workflow that checks every labware on the deck for an added degree of precision in your protocol.Labware Position Check first checks tip racks, and then checks all other labware used in your protocol.", "all_modules_and_labware_from_protocol": "All modules and labware used in the protocol", "module_in_slot": "{{module}} in {{slot}}", - "run_labware_position_check": "run labware position check" + "run_labware_position_check": "run labware position check", + "get_labware_offset_data": "Get Labware Offset Data" } diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/CurrentOffsetsModal.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/CurrentOffsetsModal.tsx index 5ff26b856b0..c9bfc8c72de 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/CurrentOffsetsModal.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/CurrentOffsetsModal.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import styled from 'styled-components' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' + import { getLabwareDisplayName, getLoadedLabwareDefinitionsByUri, @@ -18,11 +19,18 @@ import { JUSTIFY_FLEX_END, ALIGN_CENTER, Link, + useHoverTooltip, + TOOLTIP_LEFT, } from '@opentrons/components' + import { getIsLabwareOffsetCodeSnippetsOn } from '../../../../redux/config' import { ModalHeader, ModalShell } from '../../../../molecules/Modal' import { PrimaryButton } from '../../../../atoms/buttons' +import { Tooltip } from '../../../../atoms/Tooltip' import { OffsetVector } from '../../../../molecules/OffsetVector' +import { useLPCDisabledReason } from '../../hooks' +import { getLatestCurrentOffsets } from './utils' + import type { LabwareOffset } from '@opentrons/api-client' const OffsetTable = styled('table')` @@ -51,19 +59,33 @@ const OffsetTableDatum = styled('td')` interface CurrentOffsetsModalProps { currentOffsets: LabwareOffset[] commands: RunTimeCommand[] + runId: string + robotName: string onCloseClick: () => void + handleRelaunchLPC: () => void } export function CurrentOffsetsModal( props: CurrentOffsetsModalProps ): JSX.Element { - const { currentOffsets, commands, onCloseClick } = props + const { + currentOffsets, + commands, + runId, + robotName, + onCloseClick, + handleRelaunchLPC, + } = props const { t } = useTranslation(['labware_position_check', 'shared']) const defsByURI = getLoadedLabwareDefinitionsByUri(commands) const [showCodeSnippet, setShowCodeSnippet] = React.useState(false) const isLabwareOffsetCodeSnippetsOn = useSelector( getIsLabwareOffsetCodeSnippetsOn ) - + const [targetProps, tooltipProps] = useHoverTooltip({ + placement: TOOLTIP_LEFT, + }) + const lpcDisabledReason = useLPCDisabledReason(robotName, runId) + const latestCurrentOffsets = getLatestCurrentOffsets(currentOffsets) return ( ) : null} - @@ -101,7 +122,7 @@ export function CurrentOffsetsModal( - {currentOffsets.map(offset => { + {latestCurrentOffsets.map(offset => { const labwareDisplayName = offset.definitionUri in defsByURI ? getLabwareDisplayName(defsByURI[offset.definitionUri]) @@ -111,8 +132,8 @@ export function CurrentOffsetsModal( {t('slot', { slotName: offset.location.slotName })} {offset.location.moduleModel != null - ? ` - ${String( - getModuleDisplayName(offset.location.moduleModel) + ? ` - ${getModuleDisplayName( + offset.location.moduleModel )}` : null} @@ -142,12 +163,15 @@ export function CurrentOffsetsModal( { - console.log('TODO, run LPC') - }} + onClick={handleRelaunchLPC} + disabled={lpcDisabledReason !== null} + {...targetProps} > {t('run_labware_position_check')} + {lpcDisabledReason !== null ? ( + {lpcDisabledReason} + ) : null} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/LaunchLabwarePositionCheck.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/LaunchLabwarePositionCheck.tsx index 3e8947bd21e..f1333784984 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/LaunchLabwarePositionCheck.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/LaunchLabwarePositionCheck.tsx @@ -1,7 +1,5 @@ import * as React from 'react' import { Trans, useTranslation } from 'react-i18next' -import isEmpty from 'lodash/isEmpty' -import some from 'lodash/some' import { Flex, SPACING, @@ -25,11 +23,9 @@ import { StyledText } from '../../../../atoms/text' import { LabwarePositionCheck } from '../../../LabwarePositionCheck' import { HowLPCWorksModal } from './HowLPCWorksModal' import { + useLPCDisabledReason, useProtocolDetailsForRun, - useRunCalibrationStatus, - useRunHasStarted, useStoredProtocolAnalysis, - useUnmatchedModulesForProtocol, } from '../../hooks' import { CurrentOffsetsModal } from './CurrentOffsetsModal' @@ -43,20 +39,15 @@ export function LaunchLabwarePositionCheck( ): JSX.Element { const { robotName, runId } = props const { t } = useTranslation('protocol_setup') + + const { data: runRecord } = useRunQuery(runId, { staleTime: Infinity }) + const currentOffsets = runRecord?.data?.labwareOffsets ?? [] + const lpcDisabledReason = useLPCDisabledReason(robotName, runId) const { protocolData: robotProtocolAnalysis } = useProtocolDetailsForRun( runId ) - const { data: runRecord } = useRunQuery(runId, { staleTime: Infinity }) - const currentOffsets = runRecord?.data?.labwareOffsets ?? [] - const storedProtocolAnalysis = useStoredProtocolAnalysis(runId) const protocolData = robotProtocolAnalysis ?? storedProtocolAnalysis - const runHasStarted = useRunHasStarted(runId) - const { complete: isCalibrationComplete } = useRunCalibrationStatus( - robotName, - runId - ) - const [showHelpModal, setShowHelpModal] = React.useState(false) const [ showLabwarePositionCheckModal, @@ -65,54 +56,11 @@ export function LaunchLabwarePositionCheck( const [showCurrentOffsetsModal, setShowCurrentOffsetsModal] = React.useState( false ) - const unmatchedModuleResults = useUnmatchedModulesForProtocol( - robotName, - runId - ) - const { missingModuleIds } = unmatchedModuleResults - const calibrationIncomplete = - missingModuleIds.length === 0 && !isCalibrationComplete - const moduleSetupIncomplete = - missingModuleIds.length > 0 && isCalibrationComplete - const moduleAndCalibrationIncomplete = - missingModuleIds.length > 0 && !isCalibrationComplete - const [targetProps, tooltipProps] = useHoverTooltip({ placement: TOOLTIP_LEFT, }) - const tipRackLoadedInProtocol: boolean = some( - protocolData?.labwareDefinitions, - def => def.parameters?.isTiprack - ) - const tipsArePickedUp: boolean = some( - protocolData?.commands, - command => command.commandType === 'pickUpTip' - ) - const { setIsShowingLPCSuccessToast } = useLPCSuccessToast() - let lpcDisabledReason: string | null = null - if (moduleAndCalibrationIncomplete) { - lpcDisabledReason = t('lpc_disabled_modules_and_calibration_not_complete') - } else if (calibrationIncomplete) { - lpcDisabledReason = t('lpc_disabled_calibration_not_complete') - } else if (moduleSetupIncomplete) { - lpcDisabledReason = t('lpc_disabled_modules_not_connected') - } else if (runHasStarted) { - lpcDisabledReason = t('labware_position_check_not_available') - } else if (robotProtocolAnalysis == null) { - lpcDisabledReason = t( - 'labware_position_check_not_available_analyzing_on_robot' - ) - } else if ( - isEmpty(protocolData?.pipettes) || - isEmpty(protocolData?.labware) - ) { - lpcDisabledReason = t('labware_position_check_not_available_empty_protocol') - } else if (!tipRackLoadedInProtocol) { - lpcDisabledReason = t('lpc_disabled_no_tipracks_loaded') - } else if (!tipsArePickedUp) { - lpcDisabledReason = t('lpc_disabled_no_tipracks_used') - } + const { setIsShowingLPCSuccessToast } = useLPCSuccessToast() const handleClickViewCurrentOffsets: React.MouseEventHandler = () => { setShowCurrentOffsetsModal(true) @@ -206,6 +154,12 @@ export function LaunchLabwarePositionCheck( currentOffsets={currentOffsets} commands={protocolData?.commands ?? []} onCloseClick={() => setShowCurrentOffsetsModal(false)} + handleRelaunchLPC={() => { + setShowCurrentOffsetsModal(false) + setShowLabwarePositionCheckModal(true) + }} + runId={runId} + robotName={robotName} /> )} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/CurrentOffsetModal.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/CurrentOffsetModal.test.tsx new file mode 100644 index 00000000000..09f2366cfe6 --- /dev/null +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/CurrentOffsetModal.test.tsx @@ -0,0 +1,139 @@ +import * as React from 'react' +import { renderWithProviders } from '@opentrons/components' +import _uncastedProtocolWithTC from '@opentrons/shared-data/protocol/fixtures/6/multipleTipracksWithTC.json' +import { getLoadedLabwareDefinitionsByUri } from '@opentrons/shared-data' +import { i18n } from '../../../../../i18n' +import { getIsLabwareOffsetCodeSnippetsOn } from '../../../../../redux/config' +import { LabwarePositionCheck } from '../../../../LabwarePositionCheck' +import { RUN_ID_1 } from '../../../../RunTimeControl/__fixtures__' +import { useLPCDisabledReason } from '../../../hooks' +import { CurrentOffsetsModal } from '../CurrentOffsetsModal' +import { getLatestCurrentOffsets } from '../utils' +import type { ProtocolAnalysisFile } from '@opentrons/shared-data' +import type { LabwareOffset } from '@opentrons/api-client' + +jest.mock('../../../hooks') +jest.mock('../../../../LabwarePositionCheck') +jest.mock('../../../../../redux/config') +jest.mock('../utils') +jest.mock('@opentrons/shared-data', () => { + const actualComponents = jest.requireActual('@opentrons/shared-data') + return { + ...actualComponents, + getLoadedLabwareDefinitionsByUri: jest.fn(), + } +}) +const mockGetLoadedLabwareDefinitionsByUri = getLoadedLabwareDefinitionsByUri as jest.MockedFunction< + typeof getLoadedLabwareDefinitionsByUri +> +const mockGetIsLabwareOffsetCodeSnippetsOn = getIsLabwareOffsetCodeSnippetsOn as jest.MockedFunction< + typeof getIsLabwareOffsetCodeSnippetsOn +> +const mockGetLatestCurrentOffsets = getLatestCurrentOffsets as jest.MockedFunction< + typeof getLatestCurrentOffsets +> +const mockLabwarePositionCheck = LabwarePositionCheck as jest.MockedFunction< + typeof LabwarePositionCheck +> +const mockUseLPCDisabledReason = useLPCDisabledReason as jest.MockedFunction< + typeof useLPCDisabledReason +> + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} +const protocolWithTC = (_uncastedProtocolWithTC as unknown) as ProtocolAnalysisFile +const mockCurrentOffsets: LabwareOffset[] = [ + { + createdAt: '2022-12-20T14:06:23.562082+00:00', + definitionUri: 'opentrons/opentrons_96_tiprack_10ul/1', + id: 'dceac542-bca4-4313-82ba-d54a19dab204', + location: { slotName: '2' }, + vector: { x: 0, y: -0.09999999999999432, z: 0 }, + }, + { + createdAt: '2022-12-20T14:06:23.562878+00:00', + definitionUri: + 'opentrons/opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat/1', + id: '70ae2e31-716b-4e1f-a90c-9b0dfd4d7feb', + location: { slotName: '1', moduleModel: 'heaterShakerModuleV1' }, + vector: { x: 0, y: 0, z: 0 }, + }, + { + createdAt: '2022-12-20T14:09:08.688756+00:00', + definitionUri: 'opentrons/opentrons_96_tiprack_10ul/1', + id: '494ec3d1-224f-4f9a-a83b-3dd3060ea332', + location: { slotName: '2' }, + vector: { x: 0, y: -0.09999999999999432, z: -0.09999999999999432 }, + }, + { + createdAt: '2022-12-20T14:09:08.689813+00:00', + definitionUri: + 'opentrons/opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat/1', + id: 'd39b972e-9b2d-436c-a597-3bc81aabc634', + location: { slotName: '1', moduleModel: 'heaterShakerModuleV1' }, + vector: { x: -0.10000000000000142, y: 0, z: 0 }, + }, +] + +describe('CurrentOffsetsModal', () => { + let props: React.ComponentProps + beforeEach(() => { + props = { + currentOffsets: mockCurrentOffsets, + commands: protocolWithTC.commands, + onCloseClick: jest.fn(), + runId: RUN_ID_1, + handleRelaunchLPC: jest.fn(), + robotName: 'otie', + } + mockUseLPCDisabledReason.mockReturnValue(null) + mockGetLoadedLabwareDefinitionsByUri.mockReturnValue( + protocolWithTC.labware as any + ) + mockLabwarePositionCheck.mockReturnValue( +
mock labware position check
+ ) + mockGetIsLabwareOffsetCodeSnippetsOn.mockReturnValue(false) + mockGetLatestCurrentOffsets.mockReturnValue([ + { + createdAt: '2022-12-20T14:06:23.562082+00:00', + definitionUri: 'opentrons/opentrons_96_tiprack_10ul/1', + id: 'dceac542-bca4-4313-82ba-d54a19dab204', + location: { slotName: '2' }, + vector: { x: 0, y: -0.09999999999999432, z: 0 }, + }, + ]) + }) + it('renders the correct text and buttons CTA work', () => { + const { getByText } = render(props) + getByText('Applied Labware Offset data') + getByText('location') + getByText('labware') + getByText('labware offset data') + getByText('cancel').click() + expect(props.onCloseClick).toHaveBeenCalled() + getByText('run labware position check').click() + expect(props.handleRelaunchLPC).toHaveBeenCalled() + }) + + it('renders 1 offset with the correct information', () => { + const { getByText } = render(props) + getByText('opentrons/opentrons_96_tiprack_10ul/1') + getByText('Slot 2') + }) + // TODO(jr, 12/20/22): finish this test when we add the jupyter snippet info + it('renders the Get labware offset data button, clicking on it renders the juypter snippet', () => { + mockGetIsLabwareOffsetCodeSnippetsOn.mockReturnValue(true) + const { getByText } = render(props) + getByText('Get Labware Offset Data').click() + getByText('TODO ADD JUPYTER/CLI SNIPPET SUPPORT') + }) + it('renders the LPC button as disabled when there is a disabled reason', () => { + mockUseLPCDisabledReason.mockReturnValue('mockDisabledReason') + const { getByText } = render(props) + expect(getByText('run labware position check')).toBeDisabled() + }) +}) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/SetupLabware.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/SetupLabware.test.tsx index 018a6d2906d..47cc07fbf55 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/SetupLabware.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/SetupLabware.test.tsx @@ -13,6 +13,7 @@ import { useFeatureFlag, } from '../../../../../redux/config' import { + useLPCDisabledReason, useProtocolDetailsForRun, useRunCalibrationStatus, useRunHasStarted, @@ -64,7 +65,10 @@ const mockSetupLabwareList = SetupLabwareList as jest.MockedFunction< const mockSetupLabwareMap = SetupLabwareMap as jest.MockedFunction< typeof SetupLabwareMap > - +const mockUseLPCDisabledReason = useLPCDisabledReason as jest.MockedFunction< + typeof useLPCDisabledReason +> +const DISABLED_REASON = 'MOCK_DISABLED_REASON' const ROBOT_NAME = 'otie' const RUN_ID = '1' const PICKUP_TIP_LABWARE_ID = 'PICKUP_TIP_LABWARE_ID' @@ -107,7 +111,7 @@ const render = () => { )[0] } -describe('SetupLabwareMap', () => { +describe('SetupLabware', () => { beforeEach(() => { when(mockGetModuleTypesThatRequireExtraAttention) .calledWith(expect.anything()) @@ -169,6 +173,7 @@ describe('SetupLabwareMap', () => { when(mockSetupLabwareList).mockReturnValue(
mock setup labware list
) + when(mockUseLPCDisabledReason).mockReturnValue(null) }) afterEach(() => { @@ -189,14 +194,14 @@ describe('SetupLabwareMap', () => { ) it('should render LPC button and clicking should launch modal', () => { const { getByRole, getByText } = render() - const button = getByRole('button', { + getByRole('button', { name: 'run labware position check', - }) - fireEvent.click(button) + }).click() getByText('mock Labware Position Check') }) it('should render a disabled LPC button when a run has started', () => { when(mockUseRunHasStarted).calledWith(RUN_ID).mockReturnValue(true) + when(mockUseLPCDisabledReason).mockReturnValue(DISABLED_REASON) const { getByRole, queryByText } = render() const button = getByRole('button', { name: 'run labware position check', @@ -224,6 +229,7 @@ describe('SetupLabwareMap', () => { .mockReturnValue({ protocolData: null, } as any) + when(mockUseLPCDisabledReason).mockReturnValue(DISABLED_REASON) const { getByRole } = render() const button = getByRole('button', { name: 'run labware position check', @@ -236,6 +242,7 @@ describe('SetupLabwareMap', () => { .mockReturnValue({ protocolData: { labware: {}, pipettes: {} }, } as any) + when(mockUseLPCDisabledReason).mockReturnValue(DISABLED_REASON) const { getByRole } = render() const button = getByRole('button', { name: 'run labware position check', @@ -248,6 +255,7 @@ describe('SetupLabwareMap', () => { .mockReturnValue({ complete: false, }) + when(mockUseLPCDisabledReason).mockReturnValue(DISABLED_REASON) const { getByRole } = render() const button = getByRole('button', { name: 'run labware position check', @@ -261,6 +269,7 @@ describe('SetupLabwareMap', () => { missingModuleIds: ['temperatureModuleV1'], remainingAttachedModules: [], }) + when(mockUseLPCDisabledReason).mockReturnValue(DISABLED_REASON) const { getByRole } = render() const button = getByRole('button', { name: 'run labware position check', @@ -279,6 +288,7 @@ describe('SetupLabwareMap', () => { missingModuleIds: ['temperatureModuleV1'], remainingAttachedModules: [], }) + when(mockUseLPCDisabledReason).mockReturnValue(DISABLED_REASON) const { getByRole } = render() const button = getByRole('button', { name: 'run labware position check', @@ -308,6 +318,7 @@ describe('SetupLabwareMap', () => { }, }, } as any) + when(mockUseLPCDisabledReason).mockReturnValue(DISABLED_REASON) const { getByRole } = render() const button = getByRole('button', { name: 'run labware position check', @@ -338,6 +349,7 @@ describe('SetupLabwareMap', () => { commands: [], }, } as any) + when(mockUseLPCDisabledReason).mockReturnValue(DISABLED_REASON) const { getByRole } = render() const button = getByRole('button', { name: 'run labware position check', diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/utils.test.ts b/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/utils.test.ts index c67b58db8ee..908e6aac528 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/utils.test.ts +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/utils.test.ts @@ -1,3 +1,183 @@ +import { getLatestCurrentOffsets } from '../utils' +import type { LabwareOffset } from '@opentrons/api-client' + describe('getLabwareSetupItemGroups', () => { it.todo('should thoroughly test this fn') }) + +describe('getLatestCurrentOffsets', () => { + it('should return the latest offsets when there are multiple offsets', () => { + const mockCurrentOffsets: LabwareOffset[] = [ + { + createdAt: '2022-12-20T14:06:23.562082+00:00', + definitionUri: 'opentrons/opentrons_96_tiprack_10ul/1', + id: 'dceac542-bca4-4313-82ba-d54a19dab204', + location: { slotName: '2' }, + vector: { x: 1, y: 2, z: 3 }, + }, + { + createdAt: '2022-12-20T14:06:23.562878+00:00', + definitionUri: + 'opentrons/opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat/1', + id: '70ae2e31-716b-4e1f-a90c-9b0dfd4d7feb', + location: { slotName: '1', moduleModel: 'heaterShakerModuleV1' }, + vector: { x: 4, y: 5, z: 6 }, + }, + { + createdAt: '2022-12-20T14:09:08.688756+00:00', + definitionUri: 'opentrons/opentrons_96_tiprack_10ul/1', + id: '494ec3d1-224f-4f9a-a83b-3dd3060ea332', + location: { slotName: '2' }, + vector: { x: 7, y: 8, z: 9 }, + }, + { + createdAt: '2022-12-20T14:09:08.689813+00:00', + definitionUri: + 'opentrons/opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat/1', + id: 'd39b972e-9b2d-436c-a597-3bc81aabc634', + location: { slotName: '1', moduleModel: 'heaterShakerModuleV1' }, + vector: { x: 10, y: 11, z: 12 }, + }, + ] + const mockLatestCurrentOffsets = [ + { + createdAt: '2022-12-20T14:09:08.688756+00:00', + definitionUri: 'opentrons/opentrons_96_tiprack_10ul/1', + id: '494ec3d1-224f-4f9a-a83b-3dd3060ea332', + location: { slotName: '2' }, + vector: { x: 7, y: 8, z: 9 }, + }, + { + createdAt: '2022-12-20T14:09:08.689813+00:00', + definitionUri: + 'opentrons/opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat/1', + id: 'd39b972e-9b2d-436c-a597-3bc81aabc634', + location: { slotName: '1', moduleModel: 'heaterShakerModuleV1' }, + vector: { x: 10, y: 11, z: 12 }, + }, + ] + + expect(getLatestCurrentOffsets(mockCurrentOffsets)).toStrictEqual( + mockLatestCurrentOffsets + ) + }) + it('should return empty array when the labware vector values are 0', () => { + const mockCurrentOffsets: LabwareOffset[] = [ + { + createdAt: '2022-12-20T14:06:23.562878+00:00', + definitionUri: + 'opentrons/opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat/1', + id: '70ae2e31-716b-4e1f-a90c-9b0dfd4d7feb', + location: { slotName: '1', moduleModel: 'heaterShakerModuleV1' }, + vector: { x: 0, y: 0, z: 0 }, + }, + ] + + expect(getLatestCurrentOffsets(mockCurrentOffsets)).toStrictEqual([]) + }) + it('should return the correct offsets when there are multiples in random order and modules in the same slot as labware', () => { + const mockCurrentOffsets: LabwareOffset[] = [ + { + createdAt: '2022-12-20T14:06:23.562082+00:00', + definitionUri: 'opentrons/opentrons_96_tiprack_10ul/1', + id: 'dceac542-bca4-4313-82ba-d54a19dab204', + location: { slotName: '4' }, + vector: { x: 1, y: 2, z: 3 }, + }, + { + createdAt: '2022-12-20T14:06:23.562878+00:00', + definitionUri: + 'opentrons/opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat/1', + id: '70ae2e31-716b-4e1f-a90c-9b0dfd4d7feb', + location: { slotName: '8', moduleModel: 'heaterShakerModuleV1' }, + vector: { x: 4, y: 5, z: 6 }, + }, + { + createdAt: '2022-12-20T18:09:08.688756+00:00', + definitionUri: 'opentrons/opentrons_96_tiprack_10ul/1', + id: '494ec3d1-224f-4f9a-a83b-3dd3060ea332', + location: { slotName: '1' }, + vector: { x: 7, y: 8, z: 9 }, + }, + { + createdAt: '2022-12-20T19:09:08.689813+00:00', + definitionUri: + 'opentrons/opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat/1', + id: 'd39b972e-9b2d-436c-a597-3bc81aabc634', + location: { slotName: '1', moduleModel: 'heaterShakerModuleV1' }, + vector: { x: 10, y: 11, z: 12 }, + }, + { + createdAt: '2022-12-20T20:09:08.688756+00:00', + definitionUri: 'opentrons/opentrons_96_tiprack_10ul/1', + id: '494ec3d1-224f-4f9a-a83b-3dd3060ea332', + location: { slotName: '1' }, + vector: { x: 13, y: 14, z: 15 }, + }, + { + createdAt: '2023-12-20T20:09:08.688756+00:00', + definitionUri: 'opentrons/opentrons_96_tiprack_10ul/1', + id: '494ec3d1-224f-4f9a-a83b-3dd3060ea332', + location: { slotName: '1' }, + vector: { x: 16, y: 17, z: 18 }, + }, + ] + const mockLatestCurrentOffsets = [ + { + createdAt: '2022-12-20T14:06:23.562082+00:00', + definitionUri: 'opentrons/opentrons_96_tiprack_10ul/1', + id: 'dceac542-bca4-4313-82ba-d54a19dab204', + location: { slotName: '4' }, + vector: { x: 1, y: 2, z: 3 }, + }, + { + createdAt: '2022-12-20T14:06:23.562878+00:00', + definitionUri: + 'opentrons/opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat/1', + id: '70ae2e31-716b-4e1f-a90c-9b0dfd4d7feb', + location: { slotName: '8', moduleModel: 'heaterShakerModuleV1' }, + vector: { x: 4, y: 5, z: 6 }, + }, + { + createdAt: '2022-12-20T19:09:08.689813+00:00', + definitionUri: + 'opentrons/opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat/1', + id: 'd39b972e-9b2d-436c-a597-3bc81aabc634', + location: { slotName: '1', moduleModel: 'heaterShakerModuleV1' }, + vector: { x: 10, y: 11, z: 12 }, + }, + { + createdAt: '2023-12-20T20:09:08.688756+00:00', + definitionUri: 'opentrons/opentrons_96_tiprack_10ul/1', + id: '494ec3d1-224f-4f9a-a83b-3dd3060ea332', + location: { slotName: '1' }, + vector: { x: 16, y: 17, z: 18 }, + }, + ] + expect(getLatestCurrentOffsets(mockCurrentOffsets)).toStrictEqual( + mockLatestCurrentOffsets + ) + }) + it('should return 2 offsets of the same slotname but 1 has a module and 1 does not', () => { + const mockCurrentOffsets: LabwareOffset[] = [ + { + createdAt: '2022-12-20T19:09:08.689813+00:00', + definitionUri: + 'opentrons/opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat/1', + id: 'd39b972e-9b2d-436c-a597-3bc81aabc634', + location: { slotName: '1', moduleModel: 'heaterShakerModuleV1' }, + vector: { x: 10, y: 11, z: 12 }, + }, + { + createdAt: '2022-12-20T20:09:08.688756+00:00', + definitionUri: 'opentrons/opentrons_96_tiprack_10ul/1', + id: '494ec3d1-224f-4f9a-a83b-3dd3060ea332', + location: { slotName: '1' }, + vector: { x: 13, y: 14, z: 15 }, + }, + ] + expect(getLatestCurrentOffsets(mockCurrentOffsets)).toStrictEqual( + mockCurrentOffsets + ) + }) +}) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/utils.ts b/app/src/organisms/Devices/ProtocolRun/SetupLabware/utils.ts index 2fb01cdd3ee..887419bdf37 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/utils.ts +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/utils.ts @@ -1,9 +1,11 @@ import partition from 'lodash/partition' -import { getLabwareDisplayName } from '@opentrons/shared-data' +import isEqual from 'lodash/isEqual' +import { getLabwareDisplayName, IDENTITY_VECTOR } from '@opentrons/shared-data' -import { GroupedLabwareSetupItems, LabwareSetupItem } from './types' -import { LoadModuleRunTimeCommand } from '@opentrons/shared-data/protocol/types/schemaV6/command/setup' +import type { LoadModuleRunTimeCommand } from '@opentrons/shared-data/protocol/types/schemaV6/command/setup' import type { RunTimeCommand } from '@opentrons/shared-data' +import type { LabwareOffset } from '@opentrons/api-client' +import type { GroupedLabwareSetupItems, LabwareSetupItem } from './types' const LABWARE_ACCESS_COMMAND_TYPES = [ 'moveToWell', @@ -85,3 +87,36 @@ export function getLabwareSetupItemGroups( ) return { onDeckItems, offDeckItems } } + +export function getLatestCurrentOffsets( + currentOffsets: LabwareOffset[] +): LabwareOffset[] { + const latestCurrentOffsets = currentOffsets.reduce( + (acc, offset) => { + const previousMatchIndex = acc.findIndex( + currentLabwareOffsets => + isEqual(offset.location, currentLabwareOffsets.location) && + isEqual(offset.definitionUri, currentLabwareOffsets.definitionUri) + ) + if ( + previousMatchIndex >= 0 && + new Date(acc[previousMatchIndex].createdAt) < new Date(offset.createdAt) + ) { + return [ + ...acc.slice(0, previousMatchIndex), + ...acc.slice(previousMatchIndex + 1), + offset, + ] + } else { + return [...acc, offset] + } + }, + [] + ) + + const nonIdentityOffsets = latestCurrentOffsets.filter( + currentOffset => !isEqual(currentOffset.vector, IDENTITY_VECTOR) + ) + + return nonIdentityOffsets +} diff --git a/app/src/organisms/Devices/hooks/__tests__/useLPCDisabledReason.test.tsx b/app/src/organisms/Devices/hooks/__tests__/useLPCDisabledReason.test.tsx new file mode 100644 index 00000000000..0001e8f06c8 --- /dev/null +++ b/app/src/organisms/Devices/hooks/__tests__/useLPCDisabledReason.test.tsx @@ -0,0 +1,155 @@ +import * as React from 'react' +import { renderHook } from '@testing-library/react-hooks' +import { Provider } from 'react-redux' +import { I18nextProvider } from 'react-i18next' +import { createStore } from 'redux' +import _uncastedSimpleV6Protocol from '@opentrons/shared-data/protocol/fixtures/6/simpleV6.json' +import { i18n } from '../../../../i18n' +import { RUN_ID_1 } from '../../../RunTimeControl/__fixtures__' +import { useLPCDisabledReason } from '../useLPCDisabledReason' +import { + useProtocolDetailsForRun, + useRunCalibrationStatus, + useRunHasStarted, + useStoredProtocolAnalysis, + useUnmatchedModulesForProtocol, +} from '..' +import type { Store } from 'redux' +import type { LegacySchemaAdapterOutput } from '@opentrons/shared-data' +import type { State } from '../../../../redux/types' +import type { StoredProtocolAnalysis } from '..' + +jest.mock('..') + +const mockUseProtocolDetailsForRun = useProtocolDetailsForRun as jest.MockedFunction< + typeof useProtocolDetailsForRun +> +const mockUseStoredProtocolAnalysis = useStoredProtocolAnalysis as jest.MockedFunction< + typeof useStoredProtocolAnalysis +> +const mockUseRunHasStarted = useRunHasStarted as jest.MockedFunction< + typeof useRunHasStarted +> +const mockUseRunCalibrationStatus = useRunCalibrationStatus as jest.MockedFunction< + typeof useRunCalibrationStatus +> +const mockUseUnmatchedModulesForProtocol = useUnmatchedModulesForProtocol as jest.MockedFunction< + typeof useUnmatchedModulesForProtocol +> +const simpleV6Protocol = (_uncastedSimpleV6Protocol as unknown) as LegacySchemaAdapterOutput + +describe('useLPCDisabledReason', () => { + const store: Store = createStore(jest.fn(), {}) + const wrapper: React.FunctionComponent<{}> = ({ children }) => ( + + {children} + + ) + beforeEach(() => { + store.dispatch = jest.fn() + mockUseProtocolDetailsForRun.mockReturnValue({ + protocolData: simpleV6Protocol, + } as any) + mockUseStoredProtocolAnalysis.mockReturnValue( + (simpleV6Protocol as unknown) as StoredProtocolAnalysis + ) + mockUseRunHasStarted.mockReturnValue(false) + mockUseRunCalibrationStatus.mockReturnValue({ complete: true }) + mockUseUnmatchedModulesForProtocol.mockReturnValue({ + missingModuleIds: [], + remainingAttachedModules: [], + }) + }) + afterEach(() => { + jest.resetAllMocks() + }) + it('renders no disabled reason', () => { + const { result } = renderHook( + () => useLPCDisabledReason('otie', RUN_ID_1), + { wrapper } + ) + expect(result.current).toBeNull() + }) + it('renders disabled reason for calibration incomponent', () => { + mockUseRunCalibrationStatus.mockReturnValue({ complete: false }) + const { result } = renderHook( + () => useLPCDisabledReason('otie', RUN_ID_1), + { wrapper } + ) + expect(result.current).toStrictEqual( + 'Make sure robot calibration is complete before running Labware Position Check' + ) + }) + it('renders disabled reason for missing modules', () => { + mockUseUnmatchedModulesForProtocol.mockReturnValue({ + missingModuleIds: ['mockId'], + remainingAttachedModules: [], + }) + const { result } = renderHook( + () => useLPCDisabledReason('otie', RUN_ID_1), + { wrapper } + ) + expect(result.current).toStrictEqual( + 'Make sure all modules are connected before running Labware Position Check' + ) + }) + it('renders disabled reason for run has started', () => { + mockUseRunHasStarted.mockReturnValue(true) + + const { result } = renderHook( + () => useLPCDisabledReason('otie', RUN_ID_1), + { wrapper } + ) + expect(result.current).toStrictEqual( + 'Labware Position Check is not available after run has started' + ) + }) + it('renders disabled reason if robot protocol anaylsis is null', () => { + mockUseProtocolDetailsForRun.mockReturnValue({ + protocolData: null, + } as any) + const { result } = renderHook( + () => useLPCDisabledReason('otie', RUN_ID_1), + { wrapper } + ) + expect(result.current).toStrictEqual( + 'Labware Position Check is not available while protocol is analyzing on robot' + ) + }) + it('renders disabled reason if no pipettes in protocol', () => { + mockUseProtocolDetailsForRun.mockReturnValue({ + protocolData: { ...simpleV6Protocol, pipettes: {} }, + } as any) + const { result } = renderHook( + () => useLPCDisabledReason('otie', RUN_ID_1), + { wrapper } + ) + expect(result.current).toStrictEqual( + 'Labware Position Check requires that the protocol loads labware and pipettes' + ) + }) + it('renders disabled reason if no tipracks in protocols', () => { + mockUseProtocolDetailsForRun.mockReturnValue({ + protocolData: { ...simpleV6Protocol, labwareDefinitions: {} }, + } as any) + const { result } = renderHook( + () => useLPCDisabledReason('otie', RUN_ID_1), + { wrapper } + ) + expect(result.current).toStrictEqual( + 'Labware Position Check requires that the protocol loads a tip rack' + ) + }) + it('renders disabled reason if no tips are being used in the protocols', () => { + mockUseProtocolDetailsForRun.mockReturnValue({ + protocolData: { ...simpleV6Protocol, commands: {} }, + } as any) + const { result } = renderHook( + () => useLPCDisabledReason('otie', RUN_ID_1), + { wrapper } + ) + expect(result.current).toStrictEqual( + 'Labware Position Check requires that the protocol has at least one pipette that picks up a tip' + ) + }) +}) diff --git a/app/src/organisms/Devices/hooks/index.ts b/app/src/organisms/Devices/hooks/index.ts index 4645fd29efd..8d03b6b820b 100644 --- a/app/src/organisms/Devices/hooks/index.ts +++ b/app/src/organisms/Devices/hooks/index.ts @@ -9,6 +9,7 @@ export * from './useIsRobotBusy' export * from './useIsRobotViewable' export * from './useLabwareRenderInfoForRunById' export * from './useLights' +export * from './useLPCDisabledReason' export * from './useLPCSuccessToast' export * from './useModuleRenderInfoForProtocolById' export * from './usePipetteOffsetCalibrations' diff --git a/app/src/organisms/Devices/hooks/useLPCDisabledReason.tsx b/app/src/organisms/Devices/hooks/useLPCDisabledReason.tsx new file mode 100644 index 00000000000..7d65bf7a6f7 --- /dev/null +++ b/app/src/organisms/Devices/hooks/useLPCDisabledReason.tsx @@ -0,0 +1,73 @@ +import isEmpty from 'lodash/isEmpty' +import some from 'lodash/some' +import { useTranslation } from 'react-i18next' +import { + useProtocolDetailsForRun, + useRunCalibrationStatus, + useRunHasStarted, + useStoredProtocolAnalysis, + useUnmatchedModulesForProtocol, +} from '.' + +export function useLPCDisabledReason( + robotName: string, + runId: string +): string | null { + const { t } = useTranslation('protocol_setup') + const runHasStarted = useRunHasStarted(runId) + const { complete: isCalibrationComplete } = useRunCalibrationStatus( + robotName, + runId + ) + const unmatchedModuleResults = useUnmatchedModulesForProtocol( + robotName, + runId + ) + const { protocolData: robotProtocolAnalysis } = useProtocolDetailsForRun( + runId + ) + const storedProtocolAnalysis = useStoredProtocolAnalysis(runId) + const protocolData = robotProtocolAnalysis ?? storedProtocolAnalysis + const { missingModuleIds } = unmatchedModuleResults + const calibrationIncomplete = + missingModuleIds.length === 0 && !isCalibrationComplete + const moduleSetupIncomplete = + missingModuleIds.length > 0 && isCalibrationComplete + const moduleAndCalibrationIncomplete = + missingModuleIds.length > 0 && !isCalibrationComplete + + const tipRackLoadedInProtocol: boolean = some( + protocolData?.labwareDefinitions, + def => def.parameters?.isTiprack + ) + const tipsArePickedUp: boolean = some( + protocolData?.commands, + command => command.commandType === 'pickUpTip' + ) + let lpcDisabledReason: string | null = null + + if (moduleAndCalibrationIncomplete) { + lpcDisabledReason = t('lpc_disabled_modules_and_calibration_not_complete') + } else if (calibrationIncomplete) { + lpcDisabledReason = t('lpc_disabled_calibration_not_complete') + } else if (moduleSetupIncomplete) { + lpcDisabledReason = t('lpc_disabled_modules_not_connected') + } else if (runHasStarted) { + lpcDisabledReason = t('labware_position_check_not_available') + } else if (robotProtocolAnalysis == null) { + lpcDisabledReason = t( + 'labware_position_check_not_available_analyzing_on_robot' + ) + } else if ( + isEmpty(protocolData?.pipettes) || + isEmpty(protocolData?.labware) + ) { + lpcDisabledReason = t('labware_position_check_not_available_empty_protocol') + } else if (!tipRackLoadedInProtocol) { + lpcDisabledReason = t('lpc_disabled_no_tipracks_loaded') + } else if (!tipsArePickedUp) { + lpcDisabledReason = t('lpc_disabled_no_tipracks_used') + } + + return lpcDisabledReason +}