diff --git a/api-client/src/maintenance_runs/createMaintenanceRunLabwareDefinition.ts b/api-client/src/maintenance_runs/createMaintenanceRunLabwareDefinition.ts index 85615b01849..0ee55b9e86c 100644 --- a/api-client/src/maintenance_runs/createMaintenanceRunLabwareDefinition.ts +++ b/api-client/src/maintenance_runs/createMaintenanceRunLabwareDefinition.ts @@ -3,14 +3,20 @@ import { POST, request } from '../request' import type { ResponsePromise } from '../request' import type { HostConfig } from '../types' import type { LabwareDefinitionSummary } from './types' -import type { LabwareDefinition2 } from '@opentrons/shared-data' +import type { + LabwareDefinition2, + LabwareDefinition3, +} from '@opentrons/shared-data' export function createMaintenanceRunLabwareDefinition( config: HostConfig, maintenanceRunId: string, - data: LabwareDefinition2 + data: LabwareDefinition2 | LabwareDefinition3 ): ResponsePromise { - return request( + return request< + LabwareDefinitionSummary, + { data: LabwareDefinition2 | LabwareDefinition3 } + >( POST, `/maintenance_runs/${maintenanceRunId}/labware_definitions`, { data }, diff --git a/app/src/atoms/buttons/SubmitPrimaryButton.tsx b/app/src/atoms/buttons/SubmitPrimaryButton.tsx index cc53717bab0..f83c5935e21 100644 --- a/app/src/atoms/buttons/SubmitPrimaryButton.tsx +++ b/app/src/atoms/buttons/SubmitPrimaryButton.tsx @@ -1,4 +1,4 @@ -import { css } from 'styled-components' +import styled from 'styled-components' import { SPACING, COLORS, @@ -15,37 +15,39 @@ interface SubmitPrimaryButtonProps { onClick?: (event: MouseEvent) => unknown disabled?: boolean } + +const StyledSubmitInput = styled.input` + background-color: ${COLORS.blue50}; + border-radius: ${BORDERS.borderRadius8}; + padding: ${SPACING.spacing8} ${SPACING.spacing16}; + color: ${COLORS.white}; + ${TYPOGRAPHY.pSemiBold} + width: 100%; + border: none; + + ${styleProps} + + &:focus-visible { + box-shadow: 0 0 0 3px ${COLORS.yellow50}; + } + + &:hover { + background-color: ${COLORS.blue55}; + box-shadow: 0 0 0; + } + + &:active { + background-color: ${COLORS.blue60}; + } + + &:disabled { + background-color: ${COLORS.grey30}; + color: ${COLORS.grey40}; + } +` + export const SubmitPrimaryButton = ( props: SubmitPrimaryButtonProps ): JSX.Element => { - const SUBMIT_INPUT_STYLE = css` - background-color: ${COLORS.blue50}; - border-radius: ${BORDERS.borderRadius8}; - padding: ${SPACING.spacing8} ${SPACING.spacing16}; - color: ${COLORS.white}; - ${TYPOGRAPHY.pSemiBold} - width: 100%; - border: none; - - ${styleProps} - - &:focus-visible { - box-shadow: 0 0 0 3px ${COLORS.yellow50}; - } - - &:hover { - background-color: ${COLORS.blue55}; - box-shadow: 0 0 0; - } - - &:active { - background-color: ${COLORS.blue60}; - } - - &:disabled { - background-color: ${COLORS.grey30}; - color: ${COLORS.grey40}; - } - ` - return + return } diff --git a/app/src/molecules/InProgressModal/InProgressModal.tsx b/app/src/molecules/InProgressModal/InProgressModal.tsx index 82693b34429..5fd03a539f0 100644 --- a/app/src/molecules/InProgressModal/InProgressModal.tsx +++ b/app/src/molecules/InProgressModal/InProgressModal.tsx @@ -1,4 +1,4 @@ -import { css } from 'styled-components' +import styled from 'styled-components' import { ALIGN_CENTER, COLORS, @@ -22,7 +22,7 @@ interface Props { children?: JSX.Element } -const DESCRIPTION_STYLE = css` +const StyledDescription = styled(LegacyStyledText)` ${TYPOGRAPHY.h1Default} margin-top: ${SPACING.spacing24}; margin-bottom: ${SPACING.spacing8}; @@ -38,7 +38,8 @@ const DESCRIPTION_STYLE = css` line-height: ${TYPOGRAPHY.lineHeight42}; } ` -const BODY_STYLE = css` + +const StyledBody = styled(LegacyStyledText)` ${TYPOGRAPHY.pRegular} text-align: ${TYPOGRAPHY.textAlignCenter}; @@ -47,7 +48,8 @@ const BODY_STYLE = css` color: ${COLORS.grey60} } ` -const MODAL_STYLE = css` + +const StyledModal = styled(Flex)` align-items: ${ALIGN_CENTER}; flex-direction: ${DIRECTION_COLUMN}; justify-content: ${JUSTIFY_CENTER}; @@ -59,7 +61,8 @@ const MODAL_STYLE = css` height: 100%; } ` -const SPINNER_STYLE = css` + +const StyledSpinner = styled(Icon)` color: ${COLORS.grey60}; width: 5.125rem; height: 5.125rem; @@ -69,11 +72,13 @@ const SPINNER_STYLE = css` } ` -const DESCRIPTION_CONTAINER_STYLE = css` - padding-x: 6.5625rem; +const DescriptionContainer = styled(Flex)` + padding-left: 6.5625rem; + padding-right: 6.5625rem; gap: ${SPACING.spacing8}; @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { - padding-x: ${SPACING.spacing40}; + padding-left: ${SPACING.spacing40}; + padding-right: ${SPACING.spacing40}; gap: ${SPACING.spacing4}; } ` @@ -82,25 +87,20 @@ export function InProgressModal(props: Props): JSX.Element { const { alternativeSpinner, children, description, body } = props return ( - + {alternativeSpinner ?? ( - + )} - {description != null && ( - - {description} - - )} - {body != null && ( - {body} + {description} )} - + {body != null && {body}} + {children} - + ) } diff --git a/app/src/molecules/JogControls/DirectionControl.tsx b/app/src/molecules/JogControls/DirectionControl.tsx index d51615fd97e..382b1943f0d 100644 --- a/app/src/molecules/JogControls/DirectionControl.tsx +++ b/app/src/molecules/JogControls/DirectionControl.tsx @@ -397,7 +397,8 @@ const ARROW_BUTTON_STYLES = css` } } ` -const ARROW_ICON_STYLES = css` + +const StyledIcon = styled(Icon)` height: 1.125rem; width: 1.125rem; @@ -427,21 +428,19 @@ export const ArrowKeys = (props: ArrowKeysProps): JSX.Element => { return ( - {controls.map( - ({ bearing, iconName, axis, sign, gridColumn, keyName, disabled }) => ( - jog(axis, sign, stepSize)} - css={ARROW_BUTTON_STYLES} - title={bearing} - gridArea={keyName} - alignSelf={BUTTON_ALIGN_BY_KEY_NAME[keyName] ?? 'center'} - disabled={disabled} - > - - - ) - )} + {controls.map(({ bearing, iconName, axis, sign, keyName, disabled }) => ( + jog(axis, sign, stepSize)} + css={ARROW_BUTTON_STYLES} + title={bearing} + gridArea={keyName} + alignSelf={BUTTON_ALIGN_BY_KEY_NAME[keyName] ?? 'center'} + disabled={disabled} + > + + + ))} ) } diff --git a/app/src/molecules/JogControls/StepSizeControl.tsx b/app/src/molecules/JogControls/StepSizeControl.tsx index e8fd2d98860..6e207fe29e5 100644 --- a/app/src/molecules/JogControls/StepSizeControl.tsx +++ b/app/src/molecules/JogControls/StepSizeControl.tsx @@ -25,7 +25,8 @@ import type { MouseEvent } from 'react' import type { StepSize } from './types' const JUMP_SIZE_SUBTITLE = '- / +' -const JUMP_SIZE_ICON_STYLE = css` + +const StyledIcon = styled(Icon)` flex-shrink: 0; ` @@ -77,6 +78,7 @@ const DEFAULT_BUTTON_STYLE = css` color: ${COLORS.grey40}; } ` + const ACTIVE_BUTTON_STYLE = css` ${DEFAULT_BUTTON_STYLE} color: ${COLORS.blue50}; @@ -88,11 +90,13 @@ const ACTIVE_BUTTON_STYLE = css` outline: 0; } ` + interface StepSizeControlProps { stepSizes: StepSize[] currentStepSize: StepSize setCurrentStepSize: (stepSize: StepSize) => void } + export function StepSizeControl(props: StepSizeControlProps): JSX.Element { const { stepSizes, currentStepSize, setCurrentStepSize } = props const { t } = useTranslation(['robot_calibration']) @@ -125,7 +129,7 @@ export function StepSizeControl(props: StepSizeControlProps): JSX.Element { > - + {items.map((item, index) => ( -
  • + {item} -
  • + ))} ) diff --git a/app/src/molecules/WizardRequiredEquipmentList/index.tsx b/app/src/molecules/WizardRequiredEquipmentList/index.tsx index 3a39d904639..e9327a2a9ac 100644 --- a/app/src/molecules/WizardRequiredEquipmentList/index.tsx +++ b/app/src/molecules/WizardRequiredEquipmentList/index.tsx @@ -1,6 +1,6 @@ import { useSelector } from 'react-redux' import { useTranslation } from 'react-i18next' -import { css } from 'styled-components' +import styled from 'styled-components' import { ALIGN_CENTER, BORDERS, @@ -24,10 +24,19 @@ import { equipmentImages } from './equipmentImages' import type { ComponentProps } from 'react' import type { StyleProps } from '@opentrons/components' + interface WizardRequiredEquipmentListProps extends StyleProps { equipmentList: Array> footer?: string } + +const StyledEquipmentImage = styled.img<{ isEquipmentImage: boolean }>` + max-width: 100%; + max-height: 100%; + flex: ${props => (props.isEquipmentImage ? '0' : '0 1 5rem')}; + display: block; +` + export function WizardRequiredEquipmentList( props: WizardRequiredEquipmentListProps ): JSX.Element { @@ -142,13 +151,8 @@ function RequiredEquipmentCard(props: RequiredEquipmentCardProps): JSX.Element { alignItems={ALIGN_CENTER} marginRight={SPACING.spacing16} > - {displayName} diff --git a/app/src/organisms/Desktop/ChooseProtocolSlideout/index.tsx b/app/src/organisms/Desktop/ChooseProtocolSlideout/index.tsx index 9368fb1b697..829a311dc87 100644 --- a/app/src/organisms/Desktop/ChooseProtocolSlideout/index.tsx +++ b/app/src/organisms/Desktop/ChooseProtocolSlideout/index.tsx @@ -107,7 +107,7 @@ export function ChooseProtocolSlideoutComponent( const { robot, showSlideout, onCloseClick } = props const { name } = robot - const isNewLpc = useFeatureFlag('lpcRedesign') + const isNewLPC = useFeatureFlag('lpcRedesign') const [ selectedProtocol, @@ -655,7 +655,7 @@ export function ChooseProtocolSlideoutComponent( } > {currentPage === 1 - ? !isNewLpc && ( + ? !isNewLPC && ( (true) const { protocolKey, @@ -221,7 +221,7 @@ export function ChooseRobotToRunProtocolSlideoutComponent( ) - const offsetsComponent = isNewLpc ? null : ( + const offsetsComponent = isNewLPC ? null : ( ), description: t('labware_position_check_step_description'), diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabwarePositionCheck/__tests__/SetupLabwarePositionCheck.test.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabwarePositionCheck/__tests__/SetupLabwarePositionCheck.test.tsx index 67d33d6c982..e3f396c5ed5 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabwarePositionCheck/__tests__/SetupLabwarePositionCheck.test.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabwarePositionCheck/__tests__/SetupLabwarePositionCheck.test.tsx @@ -24,6 +24,7 @@ import { useUnmatchedModulesForProtocol, } from '/app/resources/runs' import { useRobotType } from '/app/redux-resources/robots' +import { useLPCFlows } from '/app/organisms/LabwarePositionCheck' import type { Mock } from 'vitest' @@ -34,6 +35,7 @@ vi.mock('/app/redux/config') vi.mock('../../../hooks/useLPCSuccessToast') vi.mock('@opentrons/react-api-client') vi.mock('/app/resources/runs') +vi.mock('/app/organisms/LabwarePositionCheck') const DISABLED_REASON = 'MOCK_DISABLED_REASON' const ROBOT_NAME = 'otie' @@ -51,7 +53,7 @@ const render = () => { setOffsetsConfirmed={confirmOffsets} robotName={ROBOT_NAME} runId={RUN_ID} - isNewLpc={false} + isNewLPC={false} /> , { @@ -106,6 +108,7 @@ describe('SetupLabwarePositionCheck', () => { vi.mocked(useProtocolAnalysisAsDocumentQuery).mockReturnValue({ data: null, } as any) + vi.mocked(useLPCFlows).mockReturnValue({ launchLPC: mockLaunchLPC } as any) }) afterEach(() => { diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx index a0322a4110d..02648343551 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx @@ -29,6 +29,8 @@ import { useLPCDisabledReason, } from '/app/resources/runs' import { useRobotType } from '/app/redux-resources/robots' +import { useLPCFlows, LPCFlows } from '/app/organisms/LabwarePositionCheck' + import type { LabwareOffset } from '@opentrons/api-client' interface SetupLabwarePositionCheckProps { @@ -36,7 +38,7 @@ interface SetupLabwarePositionCheckProps { setOffsetsConfirmed: (confirmed: boolean) => void robotName: string runId: string - isNewLpc: boolean + isNewLPC: boolean } export function SetupLabwarePositionCheck( @@ -47,7 +49,7 @@ export function SetupLabwarePositionCheck( runId, setOffsetsConfirmed, offsetsConfirmed, - isNewLpc, + isNewLPC, } = props const { t, i18n } = useTranslation('protocol_setup') @@ -100,6 +102,11 @@ export function SetupLabwarePositionCheck( robotType, protocolName ) + const { launchLPC, lpcProps, showLPC } = useLPCFlows({ + runId, + robotType, + protocolName, + }) const nonIdentityOffsets = getLatestCurrentOffsets(sortedOffsets) @@ -155,7 +162,7 @@ export function SetupLabwarePositionCheck( { - isNewLpc ? (() => null)() : launchLegacyLPC() + isNewLPC ? launchLPC() : launchLegacyLPC() setIsShowingLPCSuccessToast(false) }} id="LabwareSetup_checkLabwarePositionsButton" @@ -170,7 +177,8 @@ export function SetupLabwarePositionCheck( ) : null}
    - {isNewLpc ? null : LegacyLPCWizard} + {isNewLPC ? null : LegacyLPCWizard} + {showLPC && }
    ) } diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts index 533b9877f72..7c0ea974149 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts @@ -110,7 +110,6 @@ export function useRecoveryFullCommandText( ): string | null { const { commandTextData, stepNumber } = props - // TODO TOME: I think you are looking one command to far, for some reason. const relevantCmdIdx = stepNumber ?? -1 const relevantCmd = commandTextData?.commands[relevantCmdIdx - 1] ?? null diff --git a/app/src/organisms/LabwarePositionCheck/ExitConfirmation.tsx b/app/src/organisms/LabwarePositionCheck/ExitConfirmation.tsx new file mode 100644 index 00000000000..d5dbd072d04 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/ExitConfirmation.tsx @@ -0,0 +1,150 @@ +import styled, { css } from 'styled-components' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' + +import { + AlertPrimaryButton, + ALIGN_CENTER, + COLORS, + DIRECTION_COLUMN, + Flex, + Icon, + JUSTIFY_CENTER, + JUSTIFY_FLEX_END, + RESPONSIVENESS, + SecondaryButton, + SIZE_3, + SPACING, + LegacyStyledText, + TEXT_ALIGN_CENTER, + TYPOGRAPHY, +} from '@opentrons/components' + +import { SmallButton } from '/app/atoms/buttons' +import { getIsOnDevice } from '/app/redux/config' + +import type { LPCWizardContentProps } from '/app/organisms/LabwarePositionCheck/types' + +export function ExitConfirmation({ + commandUtils, +}: LPCWizardContentProps): JSX.Element { + const { i18n, t } = useTranslation(['labware_position_check', 'shared']) + const { confirmExitLPC, cancelExitLPC, toggleRobotMoving } = commandUtils + const isOnDevice = useSelector(getIsOnDevice) + + const handleConfirmExit = (): void => { + toggleRobotMoving(true).then(() => { + confirmExitLPC() + }) + } + + return ( + + + + {isOnDevice ? ( + <> + + {t('remove_probe_before_exit')} + + + + {t('exit_screen_subtitle')} + + + + ) : ( + <> + + {t('remove_probe_before_exit')} + + + {t('exit_screen_subtitle')} + + + )} + + {isOnDevice ? ( + + + + + ) : ( + + + + {t('shared:go_back')} + + + {t('remove_calibration_probe')} + + + + )} + + ) +} + +const CONTAINER_STYLE = css` + flex-direction: ${DIRECTION_COLUMN}; + padding: ${SPACING.spacing32}; + min-height: 29.5rem; +` + +const CONTENT_CONTAINER_STYLE = css` + flex: 1; + flex-direction: ${DIRECTION_COLUMN}; + justify-content: ${JUSTIFY_CENTER}; + align-items: ${ALIGN_CENTER}; + padding-left: ${SPACING.spacing32}; + padding-right: ${SPACING.spacing32}; +` + +const BUTTON_CONTAINER_STYLE = css` + width: 100%; + margin-top: ${SPACING.spacing32}; + justify-content: ${JUSTIFY_FLEX_END}; + align-items: ${ALIGN_CENTER}; +` + +const BUTTON_CONTAINER_STYLE_ODD = css` + width: 100%; + justify-content: ${JUSTIFY_FLEX_END}; + align-items: ${ALIGN_CENTER}; + grid-gap: ${SPACING.spacing8}; +` + +const ConfirmationHeader = styled.h1` + margin-top: ${SPACING.spacing24}; + ${TYPOGRAPHY.h1Default} + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + ${TYPOGRAPHY.level4HeaderSemiBold} + } +` + +const ConfirmationHeaderODD = styled.h1` + margin-top: ${SPACING.spacing24}; + ${TYPOGRAPHY.level3HeaderBold} + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + ${TYPOGRAPHY.level4HeaderSemiBold} + } +` + +const ConfirmationBodyODD = styled.h1` + ${TYPOGRAPHY.level4HeaderRegular} + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + ${TYPOGRAPHY.level4HeaderRegular} + } + color: ${COLORS.grey60}; +` diff --git a/app/src/organisms/LabwarePositionCheck/LPCErrorModal.tsx b/app/src/organisms/LabwarePositionCheck/LPCErrorModal.tsx new file mode 100644 index 00000000000..88a3e2b01de --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCErrorModal.tsx @@ -0,0 +1,126 @@ +import styled from 'styled-components' +import { useTranslation } from 'react-i18next' + +import { + ALIGN_CENTER, + ALIGN_FLEX_END, + BORDERS, + COLORS, + DIRECTION_COLUMN, + Flex, + Icon, + JUSTIFY_SPACE_BETWEEN, + PrimaryButton, + RESPONSIVENESS, + SPACING, + LegacyStyledText, + TEXT_ALIGN_CENTER, + TEXT_TRANSFORM_CAPITALIZE, + TYPOGRAPHY, +} from '@opentrons/components' + +import { i18n } from '/app/i18n' + +import type { LPCWizardContentProps } from '/app/organisms/LabwarePositionCheck/types' + +const SUPPORT_EMAIL = 'support@opentrons.com' + +export function LPCErrorModal({ + commandUtils, + onCloseClick, +}: LPCWizardContentProps): JSX.Element { + const { t } = useTranslation(['labware_position_check', 'shared', 'branded']) + const { errorMessage, toggleRobotMoving } = commandUtils + + const handleClose = (): void => { + void toggleRobotMoving(true).then(() => { + onCloseClick() + }) + } + + return ( + + + + {i18n.format(t('shared:something_went_wrong'), 'sentenceCase')} + + + + {t('remove_probe_before_exit')} + + + {t('branded:help_us_improve_send_error_report', { + support_email: SUPPORT_EMAIL, + })} + + + + + {t('shared:exit')} + + + ) +} + +const ModalContainer = styled(Flex)` + width: 100%; + box-sizing: border-box; +` + +const ContentWrapper = styled.div` + width: 100%; + padding: 0 ${SPACING.spacing16}; + box-sizing: border-box; + + p { + margin: ${SPACING.spacing8} 0; + line-height: 1.5; + } +` + +const ErrorHeader = styled.h1` + text-align: ${TEXT_ALIGN_CENTER}; + ${TYPOGRAPHY.h1Default} + width: 100%; + padding: 0 ${SPACING.spacing16}; + box-sizing: border-box; + word-wrap: break-word; + overflow-wrap: break-word; + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + ${TYPOGRAPHY.level4HeaderSemiBold} + } +` + +const ErrorTextArea = styled.textarea` + min-height: 6rem; + width: 100%; + background-color: #f8f8f8; + border: ${BORDERS.lineBorder}; + border-radius: ${BORDERS.borderRadius4}; + padding: ${SPACING.spacing8}; + margin: ${SPACING.spacing16} 0; + font-size: ${TYPOGRAPHY.fontSizeCaption}; + font-family: monospace; + resize: none; + box-sizing: border-box; +` diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/LPCFlows.tsx b/app/src/organisms/LabwarePositionCheck/LPCFlows/LPCFlows.tsx new file mode 100644 index 00000000000..5c7ab9bdf3c --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/LPCFlows.tsx @@ -0,0 +1,23 @@ +import { LPCWizardContainer } from '/app/organisms/LabwarePositionCheck/LPCWizardContainer' + +import type { + RobotType, + CompletedProtocolAnalysis, + DeckConfiguration, +} from '@opentrons/shared-data' +import type { LabwareOffset } from '@opentrons/api-client' + +export interface LPCFlowsProps { + onCloseClick: () => void + runId: string + robotType: RobotType + deckConfig: DeckConfiguration + existingOffsets: LabwareOffset[] + mostRecentAnalysis: CompletedProtocolAnalysis + protocolName: string + maintenanceRunId: string +} + +export function LPCFlows(props: LPCFlowsProps): JSX.Element { + return +} diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/index.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/index.ts new file mode 100644 index 00000000000..5f8b4c9bb88 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/index.ts @@ -0,0 +1,4 @@ +export { useLPCFlows } from './useLPCFlows' +export * from './LPCFlows' + +export type { UseLPCFlowsProps, UseLPCFlowsResult } from './useLPCFlows' diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/useLPCFlows.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/useLPCFlows.ts new file mode 100644 index 00000000000..1b31d79de0a --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/useLPCFlows.ts @@ -0,0 +1,186 @@ +import { useEffect, useState } from 'react' + +import { + useCreateMaintenanceRunLabwareDefinitionMutation, + useDeleteMaintenanceRunMutation, + useRunLoadedLabwareDefinitions, +} from '@opentrons/react-api-client' + +import { + useCreateTargetedMaintenanceRunMutation, + useNotifyRunQuery, + useMostRecentCompletedAnalysis, +} from '/app/resources/runs' +import { useNotifyCurrentMaintenanceRun } from '/app/resources/maintenance_runs' + +import type { RobotType } from '@opentrons/shared-data' +import type { LPCFlowsProps } from '/app/organisms/LabwarePositionCheck/LPCFlows/LPCFlows' +import { useNotifyDeckConfigurationQuery } from '/app/resources/deck_configuration' + +interface UseLPCFlowsBase { + showLPC: boolean + lpcProps: LPCFlowsProps | null + isLaunchingLPC: boolean + launchLPC: () => Promise +} +interface UseLPCFlowsIdle extends UseLPCFlowsBase { + showLPC: false + lpcProps: null +} +interface UseLPCFlowsLaunched extends UseLPCFlowsBase { + showLPC: true + lpcProps: LPCFlowsProps + isLaunchingLPC: false +} +export type UseLPCFlowsResult = UseLPCFlowsIdle | UseLPCFlowsLaunched + +export interface UseLPCFlowsProps { + runId: string + robotType: RobotType + protocolName: string | undefined +} + +export function useLPCFlows({ + runId, + robotType, + protocolName, +}: UseLPCFlowsProps): UseLPCFlowsResult { + const [maintenanceRunId, setMaintenanceRunId] = useState(null) + const [isLaunching, setIsLaunching] = useState(false) + const [hasCreatedLPCRun, setHasCreatedLPCRun] = useState(false) + + const { data: runRecord } = useNotifyRunQuery(runId, { staleTime: Infinity }) + const deckConfig = useNotifyDeckConfigurationQuery().data + const currentOffsets = runRecord?.data?.labwareOffsets ?? [] + const mostRecentAnalysis = useMostRecentCompletedAnalysis(runId) + + useMonitorMaintenanceRunForDeletion({ maintenanceRunId, setMaintenanceRunId }) + + const { + createTargetedMaintenanceRun, + } = useCreateTargetedMaintenanceRunMutation() + const { + createLabwareDefinition, + } = useCreateMaintenanceRunLabwareDefinitionMutation() + const { deleteMaintenanceRun } = useDeleteMaintenanceRunMutation() + // TODO(jh, 01-14-25): There's no external error handing if LPC fails this series of POST requests. + // If the server doesn't absorb this functionality for the redesign, add error handling. + useRunLoadedLabwareDefinitions(runId, { + onSuccess: res => { + void Promise.all( + res.data.map(def => { + if ('schemaVersion' in def) { + return createLabwareDefinition({ + maintenanceRunId: maintenanceRunId as string, + labwareDef: def, + }) + } + }) + ).then(() => { + setHasCreatedLPCRun(true) + }) + }, + onSettled: () => { + setIsLaunching(false) + }, + enabled: maintenanceRunId != null, + }) + + const launchLPC = (): Promise => { + setIsLaunching(true) + + return createTargetedMaintenanceRun({ + labwareOffsets: currentOffsets.map( + ({ vector, location, definitionUri }) => ({ + vector, + location, + definitionUri, + }) + ), + }).then(maintenanceRun => { + setMaintenanceRunId(maintenanceRun.data.id) + }) + } + + const handleCloseLPC = (): void => { + if (maintenanceRunId != null) { + deleteMaintenanceRun(maintenanceRunId, { + onSuccess: () => { + setMaintenanceRunId(null) + setHasCreatedLPCRun(false) + }, + }) + } + } + + const showLPC = + hasCreatedLPCRun && + maintenanceRunId != null && + protocolName != null && + mostRecentAnalysis != null && + deckConfig != null + + return showLPC + ? { + launchLPC, + isLaunchingLPC: false, + showLPC, + lpcProps: { + onCloseClick: handleCloseLPC, + runId, + robotType, + deckConfig, + existingOffsets: currentOffsets, + mostRecentAnalysis, + protocolName, + maintenanceRunId, + }, + } + : { launchLPC, isLaunchingLPC: isLaunching, lpcProps: null, showLPC } +} + +const RUN_REFETCH_INTERVAL = 5000 + +// TODO(jh, 01-02-25): Monitor for deletion behavior exists in several other flows. We should consolidate it. + +// Closes the modal in case the run was deleted by the terminate activity modal on the ODD +function useMonitorMaintenanceRunForDeletion({ + maintenanceRunId, + setMaintenanceRunId, +}: { + maintenanceRunId: string | null + setMaintenanceRunId: (id: string | null) => void +}): void { + const [ + monitorMaintenanceRunForDeletion, + setMonitorMaintenanceRunForDeletion, + ] = useState(false) + + // We should start checking for run deletion only after the maintenance run is created + // and the useCurrentRun poll has returned that created id + const { data: maintenanceRunData } = useNotifyCurrentMaintenanceRun({ + refetchInterval: RUN_REFETCH_INTERVAL, + enabled: maintenanceRunId != null, + }) + + useEffect(() => { + if (maintenanceRunId === null) { + setMonitorMaintenanceRunForDeletion(false) + } else if ( + maintenanceRunId !== null && + maintenanceRunData?.data.id === maintenanceRunId + ) { + setMonitorMaintenanceRunForDeletion(true) + } else if ( + maintenanceRunData?.data.id !== maintenanceRunId && + monitorMaintenanceRunForDeletion + ) { + setMaintenanceRunId(null) + } + }, [ + maintenanceRunData?.data.id, + maintenanceRunId, + monitorMaintenanceRunForDeletion, + setMaintenanceRunId, + ]) +} diff --git a/app/src/organisms/LabwarePositionCheck/LPCWizardContainer.tsx b/app/src/organisms/LabwarePositionCheck/LPCWizardContainer.tsx new file mode 100644 index 00000000000..2215cd14bc6 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCWizardContainer.tsx @@ -0,0 +1,19 @@ +import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' + +import { LPCWizardFlex } from './LPCWizardFlex' +import { LegacyLabwarePositionCheck } from '/app/organisms/LegacyLabwarePositionCheck' + +import type { LPCFlowsProps } from '/app/organisms/LabwarePositionCheck/LPCFlows' + +export function LPCWizardContainer(props: LPCFlowsProps): JSX.Element { + switch (props.robotType) { + case FLEX_ROBOT_TYPE: + return + case OT2_ROBOT_TYPE: + return + default: { + console.error('Unhandled robot type in LPC.') + return <> + } + } +} diff --git a/app/src/organisms/LabwarePositionCheck/LPCWizardFlex.tsx b/app/src/organisms/LabwarePositionCheck/LPCWizardFlex.tsx new file mode 100644 index 00000000000..4706c2eaaf8 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCWizardFlex.tsx @@ -0,0 +1,169 @@ +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' + +import { ModalShell } from '@opentrons/components' + +import { getTopPortalEl } from '/app/App/portal' +import { + BeforeBeginning, + CheckItem, + AttachProbe, + DetachProbe, + ResultsSummary, +} from '/app/organisms/LabwarePositionCheck/steps' +import { ExitConfirmation } from './ExitConfirmation' +import { RobotMotionLoader } from './RobotMotionLoader' +import { WizardHeader } from '/app/molecules/WizardHeader' +import { LPCErrorModal } from './LPCErrorModal' +import { + useLPCCommands, + useLPCInitialState, +} from '/app/organisms/LabwarePositionCheck/hooks' +import { NAV_STEPS } from '/app/organisms/LabwarePositionCheck/constants' +import { closeLPC, proceedStep } from '/app/redux/protocol-runs' +import { getIsOnDevice } from '/app/redux/config' + +import type { LPCFlowsProps } from '/app/organisms/LabwarePositionCheck/LPCFlows' +import type { LPCWizardContentProps } from '/app/organisms/LabwarePositionCheck/types' +import type { State } from '/app/redux/types' +import { useEffect } from 'react' + +export interface LPCWizardFlexProps extends Omit {} + +export function LPCWizardFlex(props: LPCWizardFlexProps): JSX.Element { + const { onCloseClick, ...rest } = props + + // TODO(jh, 01-14-25): Also inject goBack functionality once designs are finalized. + const proceed = (): void => { + dispatch(proceedStep(props.runId)) + } + const onCloseClickDispatch = (): void => { + onCloseClick() + } + const dispatch = useDispatch() + const LPCHandlerUtils = useLPCCommands({ + ...props, + onCloseClick: onCloseClickDispatch, + }) + + useLPCInitialState({ ...rest }) + + // Clean up state on LPC close. + useEffect(() => { + return () => { + dispatch(closeLPC(props.runId)) + } + }, []) + + return ( + + ) +} + +function LPCWizardFlexComponent(props: LPCWizardContentProps): JSX.Element { + const isOnDevice = useSelector(getIsOnDevice) + + return createPortal( + isOnDevice ? ( + + + + + ) : ( + }> + + + ), + getTopPortalEl() + ) +} + +function LPCWizardHeader({ + runId, + commandUtils, +}: LPCWizardContentProps): JSX.Element { + const { t } = useTranslation('labware_position_check') + const { currentStepIndex, totalStepCount } = useSelector((state: State) => ({ + currentStepIndex: + state.protocolRuns[runId]?.lpc?.steps.currentStepIndex ?? 0, + totalStepCount: state.protocolRuns[runId]?.lpc?.steps.totalStepCount ?? 0, + })) + const { + errorMessage, + showExitConfirmation, + isExiting, + confirmExitLPC, + } = commandUtils + + // TODO(jh 01-15-24): Revisit the onExit conditions. Can we simplify? + return ( + + ) +} + +function LPCWizardContent(props: LPCWizardContentProps): JSX.Element { + const { t } = useTranslation('shared') + const currentStep = useSelector( + (state: State) => + state.protocolRuns[props.runId]?.lpc?.steps.current ?? null + ) + const { + isRobotMoving, + errorMessage, + showExitConfirmation, + } = props.commandUtils + + // TODO(jh, 01-14-25): Handle open door behavior. + + // Handle special cases that are shared by multiple steps first. + if (isRobotMoving) { + return + } + if (errorMessage != null) { + return + } + if (showExitConfirmation) { + return + } + if (currentStep == null) { + console.error('LPC store not properly initialized.') + return <> + } + + // Handle step-based routing. + switch (currentStep.section) { + case NAV_STEPS.BEFORE_BEGINNING: + return + + case NAV_STEPS.CHECK_POSITIONS: + return + + case NAV_STEPS.ATTACH_PROBE: + return + + case NAV_STEPS.DETACH_PROBE: + return + + case NAV_STEPS.RESULTS_SUMMARY: + return + + default: + console.error('Unhandled LPC step.') + return + } +} diff --git a/app/src/organisms/LabwarePositionCheck/RobotMotionLoader.tsx b/app/src/organisms/LabwarePositionCheck/RobotMotionLoader.tsx new file mode 100644 index 00000000000..5b83f147b8b --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/RobotMotionLoader.tsx @@ -0,0 +1,54 @@ +import styled, { css } from 'styled-components' +import { + ALIGN_CENTER, + COLORS, + DIRECTION_COLUMN, + Flex, + Icon, + JUSTIFY_CENTER, + RESPONSIVENESS, + SIZE_4, + SPACING, + LegacyStyledText, + TYPOGRAPHY, +} from '@opentrons/components' + +interface RobotMotionLoaderProps { + header?: string + body?: string +} + +export function RobotMotionLoader(props: RobotMotionLoaderProps): JSX.Element { + const { header, body } = props + return ( + + + {header != null ? {header} : null} + {body != null ? {body} : null} + + ) +} + +const LoadingText = styled.h1` + ${TYPOGRAPHY.h1Default} + + p { + text-transform: lowercase; + } + + p::first-letter { + text-transform: uppercase; + } + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + ${TYPOGRAPHY.level4HeaderSemiBold} + } +` + +const CONTAINER_STYLE = css` + flex-direction: ${DIRECTION_COLUMN}; + justify-content: ${JUSTIFY_CENTER}; + align-items: ${ALIGN_CENTER}; + min-height: 29.5rem; + grid-gap: ${SPACING.spacing24}; +` diff --git a/app/src/organisms/LabwarePositionCheck/__fixtures__/index.ts b/app/src/organisms/LabwarePositionCheck/__fixtures__/index.ts new file mode 100644 index 00000000000..493230e2035 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/__fixtures__/index.ts @@ -0,0 +1,4 @@ +export * from './mockWorkingOffsets' +export * from './mockExistingOffsets' +export * from './mockTipRackDef' +export * from './mockCompletedAnalysis' diff --git a/app/src/organisms/LabwarePositionCheck/__fixtures__/mockCompletedAnalysis.ts b/app/src/organisms/LabwarePositionCheck/__fixtures__/mockCompletedAnalysis.ts new file mode 100644 index 00000000000..9743796b945 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/__fixtures__/mockCompletedAnalysis.ts @@ -0,0 +1,79 @@ +import { getLabwareDefURI } from '@opentrons/shared-data' +import { mockTipRackDef } from './mockTipRackDef' +import { mockLabwareDef } from './mockLabwareDef' + +import type { CompletedProtocolAnalysis } from '@opentrons/shared-data' + +export const mockCompletedAnalysis: CompletedProtocolAnalysis = { + id: 'fakeAnalysisId', + status: 'completed', + result: 'ok', + errors: [], + labware: [ + { + id: 'labwareId1', + loadName: 'fakeLoadName', + definitionUri: getLabwareDefURI(mockTipRackDef), + location: { slotName: '1' }, + }, + { + id: 'labwareId2', + loadName: 'fakeSecondLoadName', + definitionUri: getLabwareDefURI(mockLabwareDef), + location: { slotName: '2' }, + }, + ], + pipettes: [ + { + id: 'pipetteId1', + pipetteName: 'p10_single', + mount: 'left', + }, + ], + modules: [], + liquids: [], + commands: [ + { + commandType: 'loadLabware', + id: 'fakeCommandId', + status: 'succeeded', + createdAt: 'fakeCreatedAtTimestamp', + startedAt: 'fakeStartedAtTimestamp', + completedAt: 'fakecompletedAtTimestamp', + error: null, + params: { + labwareId: 'labwareId1', + location: { slotName: '1' }, + version: 1, + loadName: 'mockLoadname', + namespace: 'mockNamespace', + }, + result: { + labwareId: 'labwareId1', + definition: mockTipRackDef, + offset: { x: 0, y: 0, z: 0 }, + }, + }, + { + commandType: 'loadLabware', + id: 'fakeSecondCommandId', + status: 'succeeded', + createdAt: 'fakeCreatedAtTimestamp', + startedAt: 'fakeStartedAtTimestamp', + completedAt: 'fakecompletedAtTimestamp', + error: null, + params: { + labwareId: 'labwareId2', + location: { slotName: '2' }, + version: 1, + loadName: 'mockLoadname', + namespace: 'mockNamespace', + }, + result: { + labwareId: 'labwareId2', + definition: mockLabwareDef, + offset: { x: 0, y: 0, z: 0 }, + }, + }, + ], +} diff --git a/app/src/organisms/LabwarePositionCheck/__fixtures__/mockExistingOffsets.ts b/app/src/organisms/LabwarePositionCheck/__fixtures__/mockExistingOffsets.ts new file mode 100644 index 00000000000..63296abfab4 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/__fixtures__/mockExistingOffsets.ts @@ -0,0 +1,18 @@ +import { getLabwareDefURI } from '@opentrons/shared-data' +import { mockTipRackDef } from './mockTipRackDef' + +export const mockExistingOffset = { + id: 'offset1', + createdAt: 'fake_timestamp', + definitionUri: getLabwareDefURI(mockTipRackDef), + location: { slotName: '2' }, + vector: { x: 1, y: 2, z: 3 }, +} +export const mockOtherExistingOffset = { + id: 'offset2', + createdAt: 'fake_timestamp', + definitionUri: getLabwareDefURI(mockTipRackDef), + location: { slotName: '4' }, + vector: { x: 4, y: 5, z: 6 }, +} +export const mockExistingOffsets = [mockExistingOffset, mockOtherExistingOffset] diff --git a/app/src/organisms/LabwarePositionCheck/__fixtures__/mockLabwareDef.ts b/app/src/organisms/LabwarePositionCheck/__fixtures__/mockLabwareDef.ts new file mode 100644 index 00000000000..450d7754a98 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/__fixtures__/mockLabwareDef.ts @@ -0,0 +1,11 @@ +import { fixture96Plate } from '@opentrons/shared-data' +import type { LabwareDefinition2 } from '@opentrons/shared-data' + +export const mockLabwareDef: LabwareDefinition2 = { + ...(fixture96Plate as LabwareDefinition2), + metadata: { + displayName: 'Mock Labware Definition', + displayCategory: 'wellPlate', + displayVolumeUnits: 'mL', + }, +} diff --git a/app/src/organisms/LabwarePositionCheck/__fixtures__/mockTipRackDef.ts b/app/src/organisms/LabwarePositionCheck/__fixtures__/mockTipRackDef.ts new file mode 100644 index 00000000000..0c7288b338a --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/__fixtures__/mockTipRackDef.ts @@ -0,0 +1,11 @@ +import { fixtureTiprack10ul } from '@opentrons/shared-data' +import type { LabwareDefinition2 } from '@opentrons/shared-data' + +export const mockTipRackDef: LabwareDefinition2 = { + ...(fixtureTiprack10ul as LabwareDefinition2), + metadata: { + displayName: 'Mock TipRack Definition', + displayCategory: 'tipRack', + displayVolumeUnits: 'mL', + }, +} diff --git a/app/src/organisms/LabwarePositionCheck/__fixtures__/mockWorkingOffsets.ts b/app/src/organisms/LabwarePositionCheck/__fixtures__/mockWorkingOffsets.ts new file mode 100644 index 00000000000..e4cde4850c7 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/__fixtures__/mockWorkingOffsets.ts @@ -0,0 +1,13 @@ +export const mockWorkingOffset = { + labwareId: 'labwareId1', + location: { slotName: '1' }, + initialPosition: { x: 1, y: 2, z: 3 }, + finalPosition: { x: 2, y: 3, z: 4 }, +} +export const mockOtherWorkingOffset = { + labwareId: 'labwareId1', + location: { slotName: '3' }, + initialPosition: { x: 3, y: 4, z: 5 }, + finalPosition: { x: 6, y: 7, z: 8 }, +} +export const mockWorkingOffsets = [mockWorkingOffset, mockOtherWorkingOffset] diff --git a/app/src/organisms/LabwarePositionCheck/constants.ts b/app/src/organisms/LabwarePositionCheck/constants.ts new file mode 100644 index 00000000000..9ccd9b81eef --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/constants.ts @@ -0,0 +1,7 @@ +export const NAV_STEPS = { + BEFORE_BEGINNING: 'BEFORE_BEGINNING', + ATTACH_PROBE: 'ATTACH_PROBE', + CHECK_POSITIONS: 'CHECK_POSITIONS', + DETACH_PROBE: 'DETACH_PROBE', + RESULTS_SUMMARY: 'RESULTS_SUMMARY', +} as const diff --git a/app/src/organisms/LabwarePositionCheck/hooks/index.ts b/app/src/organisms/LabwarePositionCheck/hooks/index.ts new file mode 100644 index 00000000000..aca4c22cc83 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useLPCInitialState' +export * from './useLPCCommands' diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/gantry.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/gantry.ts new file mode 100644 index 00000000000..8183153008f --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/gantry.ts @@ -0,0 +1,5 @@ +import type { CreateCommand } from '@opentrons/shared-data' + +export const fullHomeCommands = (): CreateCommand[] => [ + { commandType: 'home' as const, params: {} }, +] diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/index.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/index.ts new file mode 100644 index 00000000000..0fa392d6074 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/index.ts @@ -0,0 +1,4 @@ +export * from './labware' +export * from './modules' +export * from './pipettes' +export * from './gantry' diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/labware.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/labware.ts new file mode 100644 index 00000000000..95cc6db6ccc --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/labware.ts @@ -0,0 +1,42 @@ +import type { CreateCommand } from '@opentrons/shared-data' +import type { CheckPositionsStep } from '/app/organisms/LabwarePositionCheck/types' + +export interface BuildMoveLabwareOffDeckParams { + step: CheckPositionsStep +} + +export function moveLabwareOffDeckCommands({ + step, +}: BuildMoveLabwareOffDeckParams): CreateCommand[] { + const { adapterId, labwareId } = step + + return adapterId != null + ? [ + { + commandType: 'moveLabware' as const, + params: { + labwareId, + newLocation: 'offDeck', + strategy: 'manualMoveWithoutPause', + }, + }, + { + commandType: 'moveLabware' as const, + params: { + labwareId: adapterId, + newLocation: 'offDeck', + strategy: 'manualMoveWithoutPause', + }, + }, + ] + : [ + { + commandType: 'moveLabware' as const, + params: { + labwareId, + newLocation: 'offDeck', + strategy: 'manualMoveWithoutPause', + }, + }, + ] +} diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/modules.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/modules.ts new file mode 100644 index 00000000000..6fd4f57c1d3 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/modules.ts @@ -0,0 +1,150 @@ +import { + ABSORBANCE_READER_TYPE, + getModuleType, + HEATERSHAKER_MODULE_TYPE, + THERMOCYCLER_MODULE_TYPE, +} from '@opentrons/shared-data' + +import type { CheckPositionsStep } from '/app/organisms/LabwarePositionCheck/types' +import type { + CompletedProtocolAnalysis, + CreateCommand, +} from '@opentrons/shared-data' +import type { LabwareOffsetLocation } from '@opentrons/api-client' + +export interface BuildModulePrepCommandsParams { + step: CheckPositionsStep +} + +export function modulePrepCommands({ + step, +}: BuildModulePrepCommandsParams): CreateCommand[] { + const { moduleId, location } = step + + const moduleType = + (moduleId != null && + 'moduleModel' in location && + location.moduleModel != null && + getModuleType(location.moduleModel)) ?? + null + + if (moduleId == null || moduleType == null) { + return [] + } else { + switch (moduleType) { + case THERMOCYCLER_MODULE_TYPE: + return [ + { + commandType: 'thermocycler/openLid', + params: { moduleId }, + }, + ] + case HEATERSHAKER_MODULE_TYPE: + return [ + { + commandType: 'heaterShaker/closeLabwareLatch', + params: { moduleId }, + }, + { + commandType: 'heaterShaker/deactivateShaker', + params: { moduleId }, + }, + { + commandType: 'heaterShaker/openLabwareLatch', + params: { moduleId }, + }, + ] + default: + return [] + } + } +} + +// The module initialization that must happen before the start of any LPC. This should +// include commands that open lids and place modules in a known state that makes +// each individual LPC straightforward (ex, close the latches on the HS now, so +// we can simply open the latches when prepping for an LPC involving the HS). +export const moduleInitBeforeAnyLPCCommands = ( + analysis: CompletedProtocolAnalysis +): CreateCommand[] => [ + ...thermocyclerInitCommands(analysis), + ...absorbanceReaderInitCommands(analysis), + ...heaterShakerInitCommands(analysis), +] + +// Not all modules require initialization before each labware LPC. +export const moduleInitDuringLPCCommands = ( + analysis: CompletedProtocolAnalysis +): CreateCommand[] => [...heaterShakerInitCommands(analysis)] + +// Not all modules require cleanup after each labware LPC. +export const moduleCleanupDuringLPCCommands = ( + step: CheckPositionsStep +): CreateCommand[] => { + const { moduleId, location } = step + + return [...heaterShakerCleanupCommands(moduleId, location)] +} + +const heaterShakerInitCommands = ( + analysis: CompletedProtocolAnalysis +): CreateCommand[] => { + return analysis.modules + .filter(mod => getModuleType(mod.model) === HEATERSHAKER_MODULE_TYPE) + .map(mod => ({ + commandType: 'heaterShaker/closeLabwareLatch', + params: { moduleId: mod.id }, + })) +} + +const absorbanceReaderInitCommands = ( + analysis: CompletedProtocolAnalysis +): CreateCommand[] => { + // @ts-expect-error Home command does not need params. + return analysis.modules + .filter(mod => getModuleType(mod.model) === ABSORBANCE_READER_TYPE) + .flatMap(mod => [ + { + commandType: 'home', + params: {}, + }, + { + commandType: 'absorbanceReader/openLid', + params: { moduleId: mod.id }, + }, + ]) +} + +const thermocyclerInitCommands = ( + analysis: CompletedProtocolAnalysis +): CreateCommand[] => { + return analysis.modules + .filter(mod => getModuleType(mod.model) === THERMOCYCLER_MODULE_TYPE) + .map(mod => ({ + commandType: 'thermocycler/openLid', + params: { moduleId: mod.id }, + })) +} + +const heaterShakerCleanupCommands = ( + moduleId: string | undefined, + location: LabwareOffsetLocation +): CreateCommand[] => { + const moduleType = + (moduleId != null && + 'moduleModel' in location && + location.moduleModel != null && + getModuleType(location.moduleModel)) ?? + null + + return moduleId != null && + moduleType != null && + moduleType === HEATERSHAKER_MODULE_TYPE + ? [ + { + commandType: 'heaterShaker/openLabwareLatch', + params: { moduleId }, + }, + ] + : [] +} diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/pipettes.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/pipettes.ts new file mode 100644 index 00000000000..fba1f7b025f --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/pipettes.ts @@ -0,0 +1,140 @@ +import { fullHomeCommands } from './gantry' + +import type { + CreateCommand, + LoadedPipette, + MotorAxes, +} from '@opentrons/shared-data' +import type { CheckPositionsStep } from '/app/organisms/LabwarePositionCheck/types' +import type { Axis, Sign, StepSize } from '/app/molecules/JogControls/types' + +const PROBE_LENGTH_MM = 44.5 + +export const savePositionCommands = (pipetteId: string): CreateCommand[] => [ + { commandType: 'savePosition', params: { pipetteId } }, +] + +export const moveToWellCommands = ( + step: CheckPositionsStep +): CreateCommand[] => { + const { pipetteId, labwareId } = step + + return [ + { + commandType: 'moveToWell' as const, + params: { + pipetteId, + labwareId, + wellName: 'A1', + wellLocation: { + origin: 'top' as const, + offset: { x: 0, y: 0, z: PROBE_LENGTH_MM }, + }, + }, + }, + ] +} + +export const retractSafelyAndHomeCommands = (): CreateCommand[] => [ + { + commandType: 'retractAxis' as const, + params: { + axis: 'leftZ', + }, + }, + { + commandType: 'retractAxis' as const, + params: { + axis: 'rightZ', + }, + }, + { + commandType: 'retractAxis' as const, + params: { axis: 'x' }, + }, + { + commandType: 'retractAxis' as const, + params: { axis: 'y' }, + }, + ...fullHomeCommands(), +] + +export const retractPipetteAxesSequentiallyCommands = ( + pipette: LoadedPipette | null +): CreateCommand[] => { + const pipetteZMotorAxis = pipette?.mount === 'left' ? 'leftZ' : 'rightZ' + + return [ + { + commandType: 'retractAxis' as const, + params: { + axis: pipetteZMotorAxis, + }, + }, + { + commandType: 'retractAxis' as const, + params: { axis: 'x' }, + }, + { + commandType: 'retractAxis' as const, + params: { axis: 'y' }, + }, + ] +} + +export interface MoveRelativeCommandParams { + pipetteId: string + axis: Axis + dir: Sign + step: StepSize +} + +export const moveRelativeCommand = ({ + pipetteId, + axis, + dir, + step, +}: MoveRelativeCommandParams): CreateCommand => ({ + commandType: 'moveRelative', + params: { pipetteId, distance: step * dir, axis }, +}) + +export const moveToMaintenancePosition = ( + pipette: LoadedPipette | null +): CreateCommand[] => { + const pipetteMount = pipette?.mount + + return [ + { + commandType: 'calibration/moveToMaintenancePosition' as const, + params: { + mount: pipetteMount ?? 'left', + }, + }, + ] +} + +export const verifyProbeAttachmentAndHomeCommands = ( + pipetteId: string, + pipette: LoadedPipette | null +): CreateCommand[] => { + const pipetteMount = pipette?.mount + const pipetteZMotorAxis = pipetteMount === 'left' ? 'leftZ' : 'rightZ' + + return [ + { + commandType: 'verifyTipPresence', + params: { + pipetteId, + expectedState: 'present', + followSingularSensor: 'primary', + }, + }, + homeSelectAxesSequentiallyCommand([pipetteZMotorAxis, 'x', 'y']), + ] +} + +const homeSelectAxesSequentiallyCommand = (axes: MotorAxes): CreateCommand => ({ + commandType: 'home', + params: { axes }, +}) diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/index.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/index.ts new file mode 100644 index 00000000000..606d25a7e45 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/index.ts @@ -0,0 +1,124 @@ +import { useState } from 'react' + +import { useApplyLPCOffsets } from './useApplyLPCOffsets' +import { useHandleJog } from './useHandleJog' +import { useHandleConditionalCleanup } from './useHandleConditionalCleanup' +import { useChainMaintenanceCommands } from '/app/resources/maintenance_runs' +import { useHandleProbeCommands } from './useHandleProbeCommands' +import { useHandleStartLPC } from './useHandleStartLPC' +import { useHandlePrepModules } from './useHandlePrepModules' +import { useHandleConfirmLwModulePlacement } from './useHandleConfirmLwModulePlacement' +import { useHandleConfirmLwFinalPosition } from './useHandleConfirmLwFinalPosition' +import { useHandleResetLwModulesOnDeck } from './useHandleResetLwModulesOnDeck' +import { useBuildOffsetsToApply } from './useBuildOffsetsToApply' +import { useHandleValidMoveToMaintenancePosition } from './useHandleValidMoveToMaintenancePosition' + +import type { CreateCommand } from '@opentrons/shared-data' +import type { CommandData } from '@opentrons/api-client' +import type { UseProbeCommandsResult } from './useHandleProbeCommands' +import type { UseHandleConditionalCleanupResult } from './useHandleConditionalCleanup' +import type { UseHandleJogResult } from './useHandleJog' +import type { UseApplyLPCOffsetsResult } from './useApplyLPCOffsets' +import type { UseHandleStartLPCResult } from './useHandleStartLPC' +import type { UseHandlePrepModulesResult } from './useHandlePrepModules' +import type { UseHandleConfirmPlacementResult } from './useHandleConfirmLwModulePlacement' +import type { UseHandleConfirmPositionResult } from './useHandleConfirmLwFinalPosition' +import type { UseHandleResetLwModulesOnDeckResult } from './useHandleResetLwModulesOnDeck' +import type { LPCWizardFlexProps } from '/app/organisms/LabwarePositionCheck/LPCWizardFlex' +import type { UseBuildOffsetsToApplyResult } from './useBuildOffsetsToApply' +import type { UseHandleValidMoveToMaintenancePositionResult } from './useHandleValidMoveToMaintenancePosition' + +export interface UseLPCCommandsProps extends LPCWizardFlexProps {} + +export type UseLPCCommandsResult = UseApplyLPCOffsetsResult & + UseHandleJogResult & + UseHandleConditionalCleanupResult & + UseProbeCommandsResult & + UseHandleStartLPCResult & + UseHandlePrepModulesResult & + UseHandleConfirmPlacementResult & + UseHandleConfirmPositionResult & + UseBuildOffsetsToApplyResult & + UseHandleResetLwModulesOnDeckResult & + UseHandleValidMoveToMaintenancePositionResult & { + errorMessage: string | null + isRobotMoving: boolean + toggleRobotMoving: (isMoving: boolean) => Promise + } + +// Consolidates all command handlers and handler state for injection into LPC. +export function useLPCCommands( + props: UseLPCCommandsProps +): UseLPCCommandsResult { + const [errorMessage, setErrorMessage] = useState(null) + const [isRobotMoving, setIsRobotMoving] = useState(false) + + const { chainRunCommands } = useChainMaintenanceCommands() + + const chainLPCCommands = ( + commands: CreateCommand[], + continuePastCommandFailure: boolean, + shouldPropogateError?: boolean // Let a higher level handler manage the error. + ): Promise => + chainRunCommands( + props.maintenanceRunId, + commands, + continuePastCommandFailure + ).catch((e: Error) => { + if (!shouldPropogateError) { + setErrorMessage(`Error during LPC command: ${e.message}`) + return Promise.resolve([]) + } else { + return Promise.reject(e) + } + }) + + const applyLPCOffsetsUtils = useApplyLPCOffsets({ ...props, setErrorMessage }) + const buildLPCOffsets = useBuildOffsetsToApply({ ...props, setErrorMessage }) + const handleJogUtils = useHandleJog({ ...props, setErrorMessage }) + const handleConditionalCleanupUtils = useHandleConditionalCleanup(props) + const handleProbeCommands = useHandleProbeCommands({ + ...props, + chainLPCCommands, + }) + const handleStartLPC = useHandleStartLPC({ ...props, chainLPCCommands }) + const handlePrepModules = useHandlePrepModules({ ...props, chainLPCCommands }) + const handleConfirmLwModulePlacement = useHandleConfirmLwModulePlacement({ + ...props, + chainLPCCommands, + setErrorMessage, + }) + const handleConfirmLwFinalPosition = useHandleConfirmLwFinalPosition({ + ...props, + chainLPCCommands, + setErrorMessage, + }) + const handleResetLwModulesOnDeck = useHandleResetLwModulesOnDeck({ + ...props, + chainLPCCommands, + }) + const handleValidMoveToMaintenancePosition = useHandleValidMoveToMaintenancePosition( + { ...props, chainLPCCommands } + ) + + return { + errorMessage, + isRobotMoving, + toggleRobotMoving: (isMoving: boolean) => + new Promise(resolve => { + setIsRobotMoving(isMoving) + resolve() + }), + ...applyLPCOffsetsUtils, + ...buildLPCOffsets, + ...handleJogUtils, + ...handleConditionalCleanupUtils, + ...handleProbeCommands, + ...handleStartLPC, + ...handlePrepModules, + ...handleConfirmLwModulePlacement, + ...handleConfirmLwFinalPosition, + ...handleResetLwModulesOnDeck, + ...handleValidMoveToMaintenancePosition, + } +} diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/types.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/types.ts new file mode 100644 index 00000000000..58590bfcbca --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/types.ts @@ -0,0 +1,14 @@ +import type { CreateCommand } from '@opentrons/shared-data' +import type { CommandData } from '@opentrons/api-client' +import type { UseLPCCommandsProps } from '.' + +export interface UseLPCCommandChildProps extends UseLPCCommandsProps {} + +export interface UseLPCCommandWithChainRunChildProps + extends UseLPCCommandChildProps { + chainLPCCommands: ( + commands: CreateCommand[], + continuePastCommandFailure: boolean, + shouldPropogateError?: boolean + ) => Promise +} diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useApplyLPCOffsets.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useApplyLPCOffsets.ts new file mode 100644 index 00000000000..64c505d9fbd --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useApplyLPCOffsets.ts @@ -0,0 +1,46 @@ +import { useState } from 'react' + +import { useCreateLabwareOffsetMutation } from '@opentrons/react-api-client' + +import type { LabwareOffsetCreateData } from '@opentrons/api-client' +import type { UseLPCCommandChildProps } from './types' + +export interface UseApplyLPCOffsetsProps extends UseLPCCommandChildProps { + setErrorMessage: (msg: string | null) => void +} + +export interface UseApplyLPCOffsetsResult { + handleApplyOffsetsAndClose: ( + offsets: LabwareOffsetCreateData[] + ) => Promise + isApplyingOffsets: boolean +} + +export function useApplyLPCOffsets({ + onCloseClick, + runId, + setErrorMessage, +}: UseApplyLPCOffsetsProps): UseApplyLPCOffsetsResult { + const [isApplyingOffsets, setIsApplyingOffsets] = useState(false) + + const { createLabwareOffset } = useCreateLabwareOffsetMutation() + + const handleApplyOffsetsAndClose = ( + offsets: LabwareOffsetCreateData[] + ): Promise => { + setIsApplyingOffsets(true) + return Promise.all( + offsets.map(data => createLabwareOffset({ runId, data })) + ) + .then(() => { + onCloseClick() + setIsApplyingOffsets(false) + }) + .catch((e: Error) => { + setErrorMessage(`Error applying labware offsets: ${e.message}`) + return Promise.reject(new Error('Could not apply offsets.')) + }) + } + + return { isApplyingOffsets, handleApplyOffsetsAndClose } +} diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useBuildOffsetsToApply.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useBuildOffsetsToApply.ts new file mode 100644 index 00000000000..6b4b01d6632 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useBuildOffsetsToApply.ts @@ -0,0 +1,41 @@ +import { useStore } from 'react-redux' + +import { selectOffsetsToApply } from '/app/redux/protocol-runs' + +import type { State } from '/app/redux/types' +import type { LabwareOffsetCreateData } from '@opentrons/api-client' +import type { UseLPCCommandChildProps } from '/app/organisms/LabwarePositionCheck/hooks/useLPCCommands/types' + +export interface UseBuildOffsetsToApplyResult { + buildOffsetsToApply: () => LabwareOffsetCreateData[] +} + +export interface UseApplyLPCOffsetsProps extends UseLPCCommandChildProps { + setErrorMessage: (msg: string | null) => void +} + +export function useBuildOffsetsToApply({ + runId, + setErrorMessage, +}: UseApplyLPCOffsetsProps): UseBuildOffsetsToApplyResult { + // Utilizing useStore instead of useSelector enables error handling within the selector + // but only invoke the selector when it's actually needed. + const store = useStore() + + return { + buildOffsetsToApply: () => { + try { + const selectOffsets = selectOffsetsToApply(runId) + const offsetsToApply = selectOffsets(store.getState()) + return offsetsToApply + } catch (e) { + if (e instanceof Error) { + setErrorMessage(e.message) + } else { + setErrorMessage('Failed to create finalized labware offsets.') + } + return [] + } + }, + } +} diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleConditionalCleanup.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleConditionalCleanup.ts new file mode 100644 index 00000000000..63af35de66e --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleConditionalCleanup.ts @@ -0,0 +1,45 @@ +import { useState } from 'react' + +import { useConditionalConfirm } from '@opentrons/components' + +import { useChainMaintenanceCommands } from '/app/resources/maintenance_runs' +import { retractSafelyAndHomeCommands } from './commands' + +import type { UseLPCCommandChildProps } from './types' +import type { CreateCommand } from '@opentrons/shared-data' + +export interface UseHandleConditionalCleanupResult { + isExiting: boolean + showExitConfirmation: boolean + confirmExitLPC: () => void + cancelExitLPC: () => void +} + +export function useHandleConditionalCleanup({ + onCloseClick, + maintenanceRunId, +}: UseLPCCommandChildProps): UseHandleConditionalCleanupResult { + const [isExiting, setIsExiting] = useState(false) + + const { chainRunCommands } = useChainMaintenanceCommands() + + const handleCleanUpAndClose = (): void => { + setIsExiting(true) + + const cleanupCommands: CreateCommand[] = [...retractSafelyAndHomeCommands()] + + void chainRunCommands(maintenanceRunId, cleanupCommands, true).finally( + () => { + onCloseClick() + } + ) + } + + const { + confirm: confirmExitLPC, + showConfirmation: showExitConfirmation, + cancel: cancelExitLPC, + } = useConditionalConfirm(handleCleanUpAndClose, true) + + return { isExiting, confirmExitLPC, cancelExitLPC, showExitConfirmation } +} diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleConfirmLwFinalPosition.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleConfirmLwFinalPosition.ts new file mode 100644 index 00000000000..9c159b4c518 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleConfirmLwFinalPosition.ts @@ -0,0 +1,69 @@ +import { + moduleCleanupDuringLPCCommands, + moveLabwareOffDeckCommands, + retractPipetteAxesSequentiallyCommands, + savePositionCommands, +} from './commands' + +import type { + LoadedPipette, + Coordinates, + CreateCommand, +} from '@opentrons/shared-data' +import type { UseLPCCommandWithChainRunChildProps } from './types' +import type { BuildMoveLabwareOffDeckParams } from './commands' + +interface UseHandleConfirmPositionProps + extends UseLPCCommandWithChainRunChildProps { + setErrorMessage: (msg: string | null) => void +} + +export interface UseHandleConfirmPositionResult { + /* Initiate commands to return specific modules to a post-run condition before + * non-plunger homing the utilized pipette and saving the LPC position. */ + handleConfirmLwFinalPosition: ( + params: BuildMoveLabwareOffDeckParams & { + onSuccess: () => void + pipette: LoadedPipette | null + } + ) => Promise +} + +export function useHandleConfirmLwFinalPosition({ + setErrorMessage, + chainLPCCommands, +}: UseHandleConfirmPositionProps): UseHandleConfirmPositionResult { + const handleConfirmLwFinalPosition = ( + params: BuildMoveLabwareOffDeckParams & { + onSuccess: () => void + pipette: LoadedPipette | null + } + ): Promise => { + const { onSuccess, pipette, step } = params + const { pipetteId } = step + + const confirmCommands: CreateCommand[] = [ + ...savePositionCommands(pipetteId), + ...retractPipetteAxesSequentiallyCommands(pipette), + ...moduleCleanupDuringLPCCommands(step), + ...moveLabwareOffDeckCommands(params), + ] + + return chainLPCCommands(confirmCommands, false).then(responses => { + const firstResponse = responses[0] + if (firstResponse.data.commandType === 'savePosition') { + const { position } = firstResponse.data?.result ?? { position: null } + onSuccess() + + return Promise.resolve(position) + } else { + setErrorMessage('CheckItem failed to save final position with message') + return Promise.reject( + new Error('CheckItem failed to save final position with message') + ) + } + }) + } + + return { handleConfirmLwFinalPosition } +} diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleConfirmLwModulePlacement.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleConfirmLwModulePlacement.ts new file mode 100644 index 00000000000..eb58121b6bf --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleConfirmLwModulePlacement.ts @@ -0,0 +1,111 @@ +import { + moduleInitDuringLPCCommands, + moveToWellCommands, + savePositionCommands, +} from './commands' + +import type { + MoveLabwareCreateCommand, + Coordinates, + CreateCommand, +} from '@opentrons/shared-data' +import type { UseLPCCommandWithChainRunChildProps } from './types' +import type { CheckPositionsStep } from '/app/organisms/LabwarePositionCheck/types' + +export interface UseHandleConfirmPlacementProps + extends UseLPCCommandWithChainRunChildProps { + setErrorMessage: (msg: string | null) => void +} + +export interface UseHandleConfirmPlacementResult { + /* Initiate commands to finalize pre-protocol run conditions for specific modules + before moving the pipette to the initial LPC position. */ + handleConfirmLwModulePlacement: ( + params: BuildMoveLabwareCommandParams + ) => Promise +} + +export function useHandleConfirmLwModulePlacement({ + chainLPCCommands, + mostRecentAnalysis, + setErrorMessage, +}: UseHandleConfirmPlacementProps): UseHandleConfirmPlacementResult { + const handleConfirmLwModulePlacement = ( + params: BuildMoveLabwareCommandParams + ): Promise => { + const { pipetteId } = params.step + + const confirmCommands: CreateCommand[] = [ + ...buildMoveLabwareCommand(params), + ...moduleInitDuringLPCCommands(mostRecentAnalysis), + ...moveToWellCommands(params.step), + ...savePositionCommands(pipetteId), + ] + + return chainLPCCommands(confirmCommands, false).then(responses => { + const finalResponse = responses[responses.length - 1] + if (finalResponse.data.commandType === 'savePosition') { + const { position } = finalResponse.data.result ?? { position: null } + + return Promise.resolve(position) + } else { + setErrorMessage( + 'CheckItem failed to save position for initial placement.' + ) + return Promise.reject( + new Error('CheckItem failed to save position for initial placement.') + ) + } + }) + } + + return { handleConfirmLwModulePlacement } +} + +interface BuildMoveLabwareCommandParams { + step: CheckPositionsStep +} + +function buildMoveLabwareCommand({ + step, +}: BuildMoveLabwareCommandParams): MoveLabwareCreateCommand[] { + const { labwareId, moduleId, adapterId, location } = step + + const newLocation = + moduleId != null ? { moduleId } : { slotName: location.slotName } + + if (adapterId != null) { + return [ + { + commandType: 'moveLabware' as const, + params: { + labwareId: adapterId, + newLocation, + strategy: 'manualMoveWithoutPause', + }, + }, + { + commandType: 'moveLabware' as const, + params: { + labwareId, + newLocation: + adapterId != null + ? { labwareId: adapterId } + : { slotName: location.slotName }, + strategy: 'manualMoveWithoutPause', + }, + }, + ] + } else { + return [ + { + commandType: 'moveLabware' as const, + params: { + labwareId, + newLocation, + strategy: 'manualMoveWithoutPause', + }, + }, + ] + } +} diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleJog.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleJog.ts new file mode 100644 index 00000000000..9d49cd7835e --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleJog.ts @@ -0,0 +1,120 @@ +import { useCallback, useEffect, useState } from 'react' +import { useSelector } from 'react-redux' + +import { useCreateMaintenanceCommandMutation } from '@opentrons/react-api-client' + +import { moveRelativeCommand } from './commands' + +import type { Coordinates } from '@opentrons/shared-data' +import type { + Axis, + Jog, + Sign, + StepSize, +} from '/app/molecules/JogControls/types' +import type { State } from '/app/redux/types' +import type { UseLPCCommandChildProps } from './types' + +const JOG_COMMAND_TIMEOUT_MS = 10000 +const MAX_QUEUED_JOGS = 3 + +interface UseHandleJogProps extends UseLPCCommandChildProps { + setErrorMessage: (msg: string | null) => void +} + +export interface UseHandleJogResult { + handleJog: Jog +} + +// TODO(jh, 01-21-25): Extract the throttling logic into its own hook that lives elsewhere and is used by other Jog flows. + +export function useHandleJog({ + runId, + maintenanceRunId, + setErrorMessage, +}: UseHandleJogProps): UseHandleJogResult { + const { current: currentStep } = + useSelector((state: State) => state.protocolRuns[runId]?.lpc?.steps) ?? {} + + const [isJogging, setIsJogging] = useState(false) + const [jogQueue, setJogQueue] = useState Promise>>([]) + const { + createMaintenanceCommand: createSilentCommand, + } = useCreateMaintenanceCommandMutation() + + const executeJog = useCallback( + ( + axis: Axis, + dir: Sign, + step: StepSize, + onSuccess?: (position: Coordinates | null) => void + ): Promise => { + return new Promise((resolve, reject) => { + const pipetteId = + currentStep != null && 'pipetteId' in currentStep + ? currentStep.pipetteId + : null + + if (pipetteId != null) { + createSilentCommand({ + maintenanceRunId, + command: moveRelativeCommand({ pipetteId, axis, dir, step }), + waitUntilComplete: true, + timeout: JOG_COMMAND_TIMEOUT_MS, + }) + .then(data => { + onSuccess?.( + (data?.data?.result?.position ?? null) as Coordinates | null + ) + resolve() + }) + .catch((e: Error) => { + setErrorMessage(`Error issuing jog command: ${e.message}`) + reject(e) + }) + } else { + const error = new Error( + `Could not find pipette to jog with id: ${pipetteId ?? ''}` + ) + setErrorMessage(error.message) + reject(error) + } + }) + }, + [currentStep, maintenanceRunId, createSilentCommand, setErrorMessage] + ) + + const processJogQueue = useCallback((): void => { + if (jogQueue.length > 0 && !isJogging) { + setIsJogging(true) + const nextJog = jogQueue[0] + setJogQueue(prevQueue => prevQueue.slice(1)) + void nextJog().finally(() => { + setIsJogging(false) + }) + } + }, [jogQueue, isJogging]) + + useEffect(() => { + processJogQueue() + }, [processJogQueue, jogQueue.length, isJogging]) + + const handleJog = useCallback( + ( + axis: Axis, + dir: Sign, + step: StepSize, + onSuccess?: (position: Coordinates | null) => void + ): void => { + setJogQueue(prevQueue => { + if (prevQueue.length < MAX_QUEUED_JOGS) { + return [...prevQueue, () => executeJog(axis, dir, step, onSuccess)] + } + return prevQueue + }) + }, + [executeJog] + ) + + return { handleJog } +} diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandlePrepModules.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandlePrepModules.ts new file mode 100644 index 00000000000..0f944bf74f0 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandlePrepModules.ts @@ -0,0 +1,57 @@ +import { useSelector } from 'react-redux' + +import { modulePrepCommands } from './commands' +import { NAV_STEPS } from '/app/organisms/LabwarePositionCheck/constants' +import { selectActiveLwInitialPosition } from '/app/redux/protocol-runs' + +import type { CreateCommand } from '@opentrons/shared-data' +import type { UseLPCCommandWithChainRunChildProps } from './types' +import type { LabwarePositionCheckStep } from '/app/organisms/LabwarePositionCheck/types' +import type { CommandData } from '@opentrons/api-client' +import type { State } from '/app/redux/types' + +export interface UseHandlePrepModulesResult { + handleCheckItemsPrepModules: ( + step: LabwarePositionCheckStep | null + ) => Promise +} + +// Prep module(s) before LPCing a specific labware involving module(s). +export function useHandlePrepModules({ + runId, + chainLPCCommands, +}: UseLPCCommandWithChainRunChildProps): UseHandlePrepModulesResult { + const selectInitialPositionFrom = useSelector( + (state: State) => (step: LabwarePositionCheckStep | null) => + selectActiveLwInitialPosition(step, runId, state) + ) + + const handleCheckItemsPrepModules = ( + step: LabwarePositionCheckStep | null + ): Promise => { + const initialPosition = selectInitialPositionFrom(step) + + if (step?.section === NAV_STEPS.CHECK_POSITIONS) { + const prepCommands: CreateCommand[] = modulePrepCommands({ + step, + }) + + if ( + initialPosition == null && + // Only run these commands during the appropriate step. + step.section === NAV_STEPS.CHECK_POSITIONS && + prepCommands.length > 0 + ) { + return chainLPCCommands(prepCommands, false) + } else { + return Promise.resolve([]) + } + } + + return Promise.reject( + new Error(`Cannot prep modules during unsupported step: ${step?.section}`) + ) + } + + return { handleCheckItemsPrepModules } +} diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleProbeCommands.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleProbeCommands.ts new file mode 100644 index 00000000000..8fe77d5c60a --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleProbeCommands.ts @@ -0,0 +1,71 @@ +import { useState } from 'react' + +import { + retractPipetteAxesSequentiallyCommands, + verifyProbeAttachmentAndHomeCommands, +} from './commands' + +import type { CreateCommand, LoadedPipette } from '@opentrons/shared-data' +import type { UseLPCCommandWithChainRunChildProps } from './types' + +export interface UseProbeCommandsResult { + createProbeAttachmentHandler: ( + pipetteId: string, + pipette: LoadedPipette | null, + onSuccess: () => void + ) => () => Promise + createProbeDetachmentHandler: ( + pipette: LoadedPipette | null, + onSuccess: () => void + ) => () => Promise + unableToDetect: boolean + setShowUnableToDetect: (canDetect: boolean) => void +} + +export function useHandleProbeCommands({ + chainLPCCommands, +}: UseLPCCommandWithChainRunChildProps): UseProbeCommandsResult { + const [showUnableToDetect, setShowUnableToDetect] = useState(false) + + const createProbeAttachmentHandler = ( + pipetteId: string, + pipette: LoadedPipette | null, + onSuccess: () => void + ): (() => Promise) => { + const attachmentCommands: CreateCommand[] = [ + ...verifyProbeAttachmentAndHomeCommands(pipetteId, pipette), + ] + + return () => + chainLPCCommands(attachmentCommands, false, true) + .catch(() => { + setShowUnableToDetect(true) + return Promise.reject(new Error('Unable to detect probe.')) + }) + .then(() => { + setShowUnableToDetect(false) + onSuccess() + }) + } + + const createProbeDetachmentHandler = ( + pipette: LoadedPipette | null, + onSuccess: () => void + ): (() => Promise) => { + const detatchmentCommands: CreateCommand[] = [ + ...retractPipetteAxesSequentiallyCommands(pipette), + ] + + return () => + chainLPCCommands(detatchmentCommands, false).then(() => { + onSuccess() + }) + } + + return { + createProbeAttachmentHandler, + unableToDetect: showUnableToDetect, + setShowUnableToDetect, + createProbeDetachmentHandler, + } +} diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleResetLwModulesOnDeck.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleResetLwModulesOnDeck.ts new file mode 100644 index 00000000000..b64965e0cc9 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleResetLwModulesOnDeck.ts @@ -0,0 +1,36 @@ +import { + fullHomeCommands, + modulePrepCommands, + moveLabwareOffDeckCommands, +} from './commands' + +import type { CreateCommand } from '@opentrons/shared-data' +import type { UseLPCCommandWithChainRunChildProps } from './types' +import type { + BuildMoveLabwareOffDeckParams, + BuildModulePrepCommandsParams, +} from './commands' + +export interface UseHandleResetLwModulesOnDeckResult { + handleResetLwModulesOnDeck: ( + params: BuildModulePrepCommandsParams & BuildMoveLabwareOffDeckParams + ) => Promise +} + +export function useHandleResetLwModulesOnDeck({ + chainLPCCommands, +}: UseLPCCommandWithChainRunChildProps): UseHandleResetLwModulesOnDeckResult { + const handleResetLwModulesOnDeck = ( + params: BuildModulePrepCommandsParams & BuildMoveLabwareOffDeckParams + ): Promise => { + const resetCommands: CreateCommand[] = [ + ...modulePrepCommands(params), + ...fullHomeCommands(), + ...moveLabwareOffDeckCommands(params as BuildMoveLabwareOffDeckParams), + ] + + return chainLPCCommands(resetCommands, false).then(() => Promise.resolve()) + } + + return { handleResetLwModulesOnDeck } +} diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleStartLPC.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleStartLPC.ts new file mode 100644 index 00000000000..df3270555f6 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleStartLPC.ts @@ -0,0 +1,118 @@ +import { + fullHomeCommands, + moduleInitBeforeAnyLPCCommands, + moveToMaintenancePosition, +} from './commands' + +import type { + CompletedProtocolAnalysis, + CreateCommand, + RunTimeCommand, + SetupRunTimeCommand, + LoadedPipette, +} from '@opentrons/shared-data' +import type { UseLPCCommandWithChainRunChildProps } from './types' + +export interface UseHandleStartLPCResult { + createStartLPCHandler: ( + pipette: LoadedPipette | null, + onSuccess: () => void + ) => () => Promise +} + +export function useHandleStartLPC({ + chainLPCCommands, + mostRecentAnalysis, +}: UseLPCCommandWithChainRunChildProps): UseHandleStartLPCResult { + const createStartLPCHandler = ( + pipette: LoadedPipette | null, + onSuccess: () => void + ): (() => Promise) => { + const startCommands: CreateCommand[] = [ + ...buildInstrumentLabwarePrepCommands(mostRecentAnalysis), + ...moduleInitBeforeAnyLPCCommands(mostRecentAnalysis), + ...fullHomeCommands(), + ...moveToMaintenancePosition(pipette), + ] + + return () => + chainLPCCommands(startCommands, false).then(() => { + onSuccess() + }) + } + + return { createStartLPCHandler } +} + +// Load all pipettes and labware into the maintenance run by utilizing the protocol resource. +// Labware is loaded off-deck so that LPC can move them on individually later. +// Next, emit module-specific setup commands to prepare for LPC. +function buildInstrumentLabwarePrepCommands( + protocolData: CompletedProtocolAnalysis +): SetupRunTimeCommand[] { + return ( + protocolData.commands + .filter(isLoadCommand) + .reduce((acc, command) => { + if ( + command.commandType === 'loadPipette' && + command.result?.pipetteId != null + ) { + const { pipetteId } = command.result + const loadWithPipetteId = { + ...command, + params: { + ...command.params, + pipetteId, + }, + } + return [...acc, loadWithPipetteId] + } else if ( + command.commandType === 'loadLabware' && + command.result?.labwareId != null + ) { + return [ + ...acc, + { + ...command, + params: { + ...command.params, + location: 'offDeck', + // python protocols won't have labwareId in the params, we want to + // use the same labwareIds that came back as the result of analysis + labwareId: command.result.labwareId, + }, + }, + ] + } else if ( + command.commandType === 'loadModule' && + command.result?.moduleId != null + ) { + return [ + ...acc, + { + ...command, + params: { + ...command.params, + // python protocols won't have moduleId in the params, we want to + // use the same moduleIds that came back as the result of analysis + moduleId: command.result.moduleId, + }, + }, + ] + } + return [...acc, command] + }, []) ?? [] + ) +} + +function isLoadCommand( + command: RunTimeCommand +): command is SetupRunTimeCommand { + const loadCommands: Array = [ + 'loadLabware', + 'loadModule', + 'loadPipette', + ] + return loadCommands.includes(command.commandType) +} diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleValidMoveToMaintenancePosition.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleValidMoveToMaintenancePosition.ts new file mode 100644 index 00000000000..180cf73f892 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleValidMoveToMaintenancePosition.ts @@ -0,0 +1,39 @@ +import { moveToMaintenancePosition } from '/app/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands' +import { NAV_STEPS } from '/app/organisms/LabwarePositionCheck/constants' + +import type { CommandData } from '@opentrons/api-client' +import type { LoadedPipette } from '@opentrons/shared-data' +import type { LabwarePositionCheckStep } from '/app/organisms/LabwarePositionCheck/types' +import type { UseLPCCommandWithChainRunChildProps } from '/app/organisms/LabwarePositionCheck/hooks/useLPCCommands/types' + +export interface UseHandleValidMoveToMaintenancePositionResult { + /* Only move to maintenance position during probe steps. */ + handleValidMoveToMaintenancePosition: ( + pipette: LoadedPipette | null, + step: LabwarePositionCheckStep | null + ) => Promise +} + +export function useHandleValidMoveToMaintenancePosition({ + chainLPCCommands, +}: UseLPCCommandWithChainRunChildProps): UseHandleValidMoveToMaintenancePositionResult { + return { + handleValidMoveToMaintenancePosition: ( + pipette: LoadedPipette | null, + step: LabwarePositionCheckStep | null + ): Promise => { + if ( + step?.section === NAV_STEPS.ATTACH_PROBE || + step?.section === NAV_STEPS.DETACH_PROBE + ) { + return chainLPCCommands(moveToMaintenancePosition(pipette), false) + } else { + return Promise.reject( + new Error( + `Does not move to maintenance position if step is not a probe step. Step: ${step?.section}` + ) + ) + } + }, + } +} diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCInitialState/index.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCInitialState/index.ts new file mode 100644 index 00000000000..c39cc30d305 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCInitialState/index.ts @@ -0,0 +1,47 @@ +import { useEffect } from 'react' +import { useDispatch } from 'react-redux' + +import { getLabwareDefinitionsFromCommands } from '@opentrons/components' + +import { startLPC } from '/app/redux/protocol-runs' +import { getLPCSteps } from './utils' + +import type { RunTimeCommand } from '@opentrons/shared-data' +import type { LPCWizardState } from '/app/redux/protocol-runs' +import type { LPCWizardFlexProps } from '/app/organisms/LabwarePositionCheck/LPCWizardFlex' + +export interface UseLPCInitialStateProps + extends Omit {} + +export function useLPCInitialState({ + mostRecentAnalysis, + runId, + ...rest +}: UseLPCInitialStateProps): void { + const dispatch = useDispatch() + + useEffect(() => { + const protocolCommands: RunTimeCommand[] = mostRecentAnalysis.commands + const labwareDefs = getLabwareDefinitionsFromCommands(protocolCommands) + const LPCSteps = getLPCSteps({ + protocolData: mostRecentAnalysis, + labwareDefs, + }) + + const initialState: LPCWizardState = { + ...rest, + protocolData: mostRecentAnalysis, + labwareDefs, + workingOffsets: [], + steps: { + currentStepIndex: 0, + totalStepCount: LPCSteps.length, + current: LPCSteps[0], + all: LPCSteps, + next: LPCSteps[1], + }, + } + + dispatch(startLPC(runId, initialState)) + }, []) +} diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCInitialState/utils/getLPCSteps/getProbeBasedLPCSteps.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCInitialState/utils/getLPCSteps/getProbeBasedLPCSteps.ts new file mode 100644 index 00000000000..b6184663762 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCInitialState/utils/getLPCSteps/getProbeBasedLPCSteps.ts @@ -0,0 +1,95 @@ +import { isEqual } from 'lodash' + +import { getLabwareDefURI, getPipetteNameSpecs } from '@opentrons/shared-data' + +import { NAV_STEPS } from '../../../../constants' +import { getLabwareLocationCombos } from '/app/organisms/LegacyApplyHistoricOffsets/hooks/getLabwareLocationCombos' + +import type { LoadedPipette } from '@opentrons/shared-data' +import type { + LabwarePositionCheckStep, + CheckPositionsStep, +} from '/app/organisms/LabwarePositionCheck/types' +import type { LabwareLocationCombo } from '/app/organisms/LegacyApplyHistoricOffsets/hooks/getLabwareLocationCombos' +import type { GetLPCStepsParams } from '.' + +export function getProbeBasedLPCSteps( + params: GetLPCStepsParams +): LabwarePositionCheckStep[] { + const { protocolData } = params + + return [ + { section: NAV_STEPS.BEFORE_BEGINNING }, + { + section: NAV_STEPS.ATTACH_PROBE, + pipetteId: getPrimaryPipetteId(protocolData.pipettes), + }, + ...getAllCheckSectionSteps(params), + { + section: NAV_STEPS.DETACH_PROBE, + pipetteId: getPrimaryPipetteId(protocolData.pipettes), + }, + { section: NAV_STEPS.RESULTS_SUMMARY }, + ] +} + +function getPrimaryPipetteId(pipettes: LoadedPipette[]): string { + if (pipettes.length < 1) { + throw new Error( + 'no pipettes in protocol, cannot determine primary pipette for LPC' + ) + } + + return pipettes.reduce((acc, pip) => { + return (getPipetteNameSpecs(acc.pipetteName)?.channels ?? 0) > + (getPipetteNameSpecs(pip.pipetteName)?.channels ?? 0) + ? pip + : acc + }, pipettes[0]).id +} + +function getAllCheckSectionSteps({ + labwareDefs, + protocolData, +}: GetLPCStepsParams): CheckPositionsStep[] { + const { pipettes, commands, labware, modules = [] } = protocolData + const labwareLocationCombos = getLabwareLocationCombos( + commands, + labware, + modules + ) + const labwareLocations = labwareLocationCombos.reduce( + (acc, labwareLocationCombo) => { + const labwareDef = labwareDefs.find( + def => getLabwareDefURI(def) === labwareLocationCombo.definitionUri + ) + if ( + (labwareDef?.allowedRoles ?? []).includes('adapter') || + (labwareDef?.allowedRoles ?? []).includes('lid') + ) { + return acc + } + // remove duplicate definitionUri in same location + const comboAlreadyExists = acc.some( + accLocationCombo => + labwareLocationCombo.definitionUri === + accLocationCombo.definitionUri && + isEqual(labwareLocationCombo.location, accLocationCombo.location) + ) + return comboAlreadyExists ? acc : [...acc, labwareLocationCombo] + }, + [] + ) + + return labwareLocations.map( + ({ location, labwareId, moduleId, adapterId, definitionUri }) => ({ + section: NAV_STEPS.CHECK_POSITIONS, + labwareId: labwareId, + pipetteId: getPrimaryPipetteId(pipettes), + location, + moduleId, + adapterId, + definitionUri, + }) + ) +} diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCInitialState/utils/getLPCSteps/index.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCInitialState/utils/getLPCSteps/index.ts new file mode 100644 index 00000000000..7121d2cdf98 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCInitialState/utils/getLPCSteps/index.ts @@ -0,0 +1,30 @@ +import { getProbeBasedLPCSteps } from './getProbeBasedLPCSteps' + +import type { + CompletedProtocolAnalysis, + LabwareDefinition2, +} from '@opentrons/shared-data' +import type { LabwarePositionCheckStep } from '/app/organisms/LabwarePositionCheck/types' + +export interface GetLPCStepsParams { + protocolData: CompletedProtocolAnalysis + labwareDefs: LabwareDefinition2[] +} + +// Prepare all LPC steps for injection. +export function getLPCSteps( + params: GetLPCStepsParams +): LabwarePositionCheckStep[] { + if ('pipettes' in params.protocolData) { + if (params.protocolData.pipettes.length === 0) { + throw new Error( + 'no pipettes loaded within protocol, labware position check cannot be performed' + ) + } else { + return getProbeBasedLPCSteps(params) + } + } else { + console.error('expected pipettes to be in protocol data') + return [] + } +} diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCInitialState/utils/index.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCInitialState/utils/index.ts new file mode 100644 index 00000000000..3fd9dba02b5 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCInitialState/utils/index.ts @@ -0,0 +1 @@ +export * from './getLPCSteps' diff --git a/app/src/organisms/LabwarePositionCheck/index.ts b/app/src/organisms/LabwarePositionCheck/index.ts new file mode 100644 index 00000000000..d595197b5f1 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/index.ts @@ -0,0 +1 @@ +export * from './LPCFlows' diff --git a/app/src/organisms/LabwarePositionCheck/redux/index.ts b/app/src/organisms/LabwarePositionCheck/redux/index.ts new file mode 100644 index 00000000000..51a3b4100a9 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/redux/index.ts @@ -0,0 +1 @@ +export * from '../types' diff --git a/app/src/organisms/LabwarePositionCheck/redux/types.ts b/app/src/organisms/LabwarePositionCheck/redux/types.ts new file mode 100644 index 00000000000..d40d18d91d7 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/redux/types.ts @@ -0,0 +1,11 @@ +import type { LabwarePositionCheckStep } from '/app/organisms/LabwarePositionCheck/types' + +// TODO(jh, 01-16-25): Remove this once `steps` are refactored out of Redux. + +export interface StepsInfo { + currentStepIndex: number + totalStepCount: number + current: LabwarePositionCheckStep + next: LabwarePositionCheckStep | null + all: LabwarePositionCheckStep[] +} diff --git a/app/src/organisms/LabwarePositionCheck/steps/AttachProbe.tsx b/app/src/organisms/LabwarePositionCheck/steps/AttachProbe.tsx new file mode 100644 index 00000000000..5ff197f899d --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/steps/AttachProbe.tsx @@ -0,0 +1,139 @@ +import { Trans, useTranslation } from 'react-i18next' +import styled from 'styled-components' +import { useSelector } from 'react-redux' + +import { + RESPONSIVENESS, + SPACING, + LegacyStyledText, + TYPOGRAPHY, +} from '@opentrons/components' + +import { ProbeNotAttached } from '/app/organisms/PipetteWizardFlows/ProbeNotAttached' +import { GenericWizardTile } from '/app/molecules/GenericWizardTile' +import { + selectActivePipette, + selectActivePipetteChannelCount, +} from '/app/redux/protocol-runs' +import { getIsOnDevice } from '/app/redux/config' + +import attachProbe1 from '/app/assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_1.webm' +import attachProbe8 from '/app/assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_8.webm' +import attachProbe96 from '/app/assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_96.webm' + +import type { AttachProbeStep, LPCStepProps } from '../types' +import type { State } from '/app/redux/types' +import type { LPCWizardState } from '/app/redux/protocol-runs' + +const StyledVideo = styled.video` + padding-top: ${SPACING.spacing4}; + width: 100%; + min-height: 18rem; +` + +const StyledBody = styled(LegacyStyledText)` + ${TYPOGRAPHY.pRegular}; + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + font-size: 1.275rem; + line-height: 1.75rem; + } +` + +export function AttachProbe({ + runId, + proceed, + commandUtils, + step, +}: LPCStepProps): JSX.Element { + const { t, i18n } = useTranslation(['labware_position_check', 'shared']) + const isOnDevice = useSelector(getIsOnDevice) + const { steps } = useSelector( + (state: State) => state.protocolRuns[runId]?.lpc as LPCWizardState + ) + const { pipetteId } = step + const { + createProbeAttachmentHandler, + handleCheckItemsPrepModules, + toggleRobotMoving, + setShowUnableToDetect, + unableToDetect, + } = commandUtils + const pipette = useSelector((state: State) => + selectActivePipette(step, runId, state) + ) + const channels = useSelector((state: State) => + selectActivePipetteChannelCount(step, runId, state) + ) + + const handleProbeAttached = createProbeAttachmentHandler( + pipetteId, + pipette, + proceed + ) + + const { probeLocation, probeVideoSrc } = ((): { + probeLocation: string + probeVideoSrc: string + } => { + switch (channels) { + case 1: + return { probeLocation: '', probeVideoSrc: attachProbe1 } + case 8: + return { probeLocation: t('backmost'), probeVideoSrc: attachProbe8 } + case 96: + return { + probeLocation: t('ninety_six_probe_location'), + probeVideoSrc: attachProbe96, + } + } + })() + + const handleProbeCheck = (): void => { + void toggleRobotMoving(true) + .then(() => handleProbeAttached()) + .finally(() => toggleRobotMoving(false)) + } + + const handleProceed = (): void => { + void toggleRobotMoving(true) + .then(() => handleProbeAttached()) + .then(() => handleCheckItemsPrepModules(steps.next)) + .finally(() => toggleRobotMoving(false)) + } + + if (unableToDetect) { + return ( + + ) + } else { + return ( + + + + } + bodyText={ + + , + }} + /> + + } + proceedButtonText={i18n.format(t('shared:continue'), 'capitalize')} + proceed={handleProceed} + /> + ) + } +} diff --git a/app/src/organisms/LabwarePositionCheck/steps/BeforeBeginning/TwoUpTileLayout.tsx b/app/src/organisms/LabwarePositionCheck/steps/BeforeBeginning/TwoUpTileLayout.tsx new file mode 100644 index 00000000000..ea9a38356db --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/steps/BeforeBeginning/TwoUpTileLayout.tsx @@ -0,0 +1,69 @@ +import styled, { css } from 'styled-components' + +import { + DIRECTION_COLUMN, + Flex, + SPACING, + JUSTIFY_SPACE_BETWEEN, + DIRECTION_ROW, + TYPOGRAPHY, + JUSTIFY_CENTER, + RESPONSIVENESS, + DISPLAY_INLINE_BLOCK, +} from '@opentrons/components' + +import type { ReactNode } from 'react' + +export interface TwoUpTileLayoutProps { + /** main header text on left half */ + title: string + /** paragraph text below title on left half */ + body: ReactNode + /** entire contents of the right half */ + rightElement: ReactNode + /** footer underneath both halves of content */ + footer: ReactNode +} + +export function TwoUpTileLayout(props: TwoUpTileLayoutProps): JSX.Element { + const { title, body, rightElement, footer } = props + return ( + + + + {title} + {body} + + + {rightElement} + + + {footer} + + ) +} + +const Title = styled.h1` + ${TYPOGRAPHY.h1Default}; + margin-bottom: ${SPACING.spacing8}; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + ${TYPOGRAPHY.level4HeaderSemiBold}; + margin-bottom: 0; + height: ${SPACING.spacing40}; + display: ${DISPLAY_INLINE_BLOCK}; + } +` + +const TILE_CONTAINER_STYLE = css` + flex-direction: ${DIRECTION_COLUMN}; + justify-content: ${JUSTIFY_SPACE_BETWEEN}; + padding: ${SPACING.spacing32}; + height: 24.625rem; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + height: 29.5rem; + } +` diff --git a/app/src/organisms/LabwarePositionCheck/steps/BeforeBeginning/ViewOffsets.tsx b/app/src/organisms/LabwarePositionCheck/steps/BeforeBeginning/ViewOffsets.tsx new file mode 100644 index 00000000000..c0747e01b24 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/steps/BeforeBeginning/ViewOffsets.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { css } from 'styled-components' + +import { + ALIGN_CENTER, + Box, + Btn, + COLORS, + DIRECTION_COLUMN, + Flex, + Icon, + JUSTIFY_SPACE_BETWEEN, + ModalShell, + SPACING, + LegacyStyledText, + TYPOGRAPHY, +} from '@opentrons/components' + +import { getLatestCurrentOffsets } from '/app/transformations/runs' +import { getTopPortalEl } from '/app/App/portal' +import { SmallButton } from '/app/atoms/buttons' +import { TerseOffsetTable } from '/app/organisms/TerseOffsetTable' + +import type { LabwareOffset } from '@opentrons/api-client' +import type { LabwareDefinition2 } from '@opentrons/shared-data' + +interface ViewOffsetsProps { + existingOffsets: LabwareOffset[] + labwareDefinitions: LabwareDefinition2[] +} +export function ViewOffsets(props: ViewOffsetsProps): JSX.Element { + const { existingOffsets, labwareDefinitions } = props + const { t, i18n } = useTranslation('labware_position_check') + + const [showOffsetsTable, setShowOffsetsModal] = useState(false) + + const latestCurrentOffsets = getLatestCurrentOffsets(existingOffsets) + + return existingOffsets.length > 0 ? ( + <> + { + setShowOffsetsModal(true) + }} + css={VIEW_OFFSETS_BUTTON_STYLE} + aria-label="show labware offsets" + > + + + {i18n.format(t('view_current_offsets'), 'capitalize')} + + + {showOffsetsTable + ? createPortal( + + {i18n.format(t('labware_offset_data'), 'capitalize')} + + } + footer={ + { + setShowOffsetsModal(false) + }} + /> + } + > + + + + , + getTopPortalEl() + ) + : null} + + ) : ( + + ) +} + +const VIEW_OFFSETS_BUTTON_STYLE = css` + ${TYPOGRAPHY.pSemiBold}; + color: ${COLORS.black90}; + font-size: ${TYPOGRAPHY.fontSize22}; + &:hover { + opacity: 100%; + } + &:active { + opacity: 70%; + } +` diff --git a/app/src/organisms/LabwarePositionCheck/steps/BeforeBeginning/index.tsx b/app/src/organisms/LabwarePositionCheck/steps/BeforeBeginning/index.tsx new file mode 100644 index 00000000000..c63568c4c60 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/steps/BeforeBeginning/index.tsx @@ -0,0 +1,103 @@ +import { Trans, useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' + +import { + Flex, + JUSTIFY_SPACE_BETWEEN, + PrimaryButton, + LegacyStyledText, +} from '@opentrons/components' + +import { WizardRequiredEquipmentList } from '/app/molecules/WizardRequiredEquipmentList' +import { NeedHelpLink } from '/app/molecules/OT2CalibrationNeedHelpLink' +import { TwoUpTileLayout } from './TwoUpTileLayout' +import { ViewOffsets } from './ViewOffsets' +import { SmallButton } from '/app/atoms/buttons' +import { getIsOnDevice } from '/app/redux/config' +import { selectActivePipette } from '/app/redux/protocol-runs' + +import type { + LPCStepProps, + BeforeBeginningStep, + LabwarePositionCheckStep, +} from '/app/organisms/LabwarePositionCheck/types' +import type { State } from '/app/redux/types' +import type { LPCWizardState } from '/app/redux/protocol-runs' + +// TODO(BC, 09/01/23): replace updated support article link for LPC on OT-2/Flex +const SUPPORT_PAGE_URL = 'https://support.opentrons.com/s/ot2-calibration' + +export function BeforeBeginning({ + runId, + proceed, + commandUtils, +}: LPCStepProps): JSX.Element { + const { t, i18n } = useTranslation(['labware_position_check', 'shared']) + const isOnDevice = useSelector(getIsOnDevice) + const activePipette = useSelector((state: State) => { + const step = state.protocolRuns[runId]?.lpc?.steps + .current as LabwarePositionCheckStep + return selectActivePipette(step, runId, state) ?? null + }) + const { protocolName, labwareDefs, existingOffsets } = useSelector( + (state: State) => state.protocolRuns[runId]?.lpc as LPCWizardState + ) + const { createStartLPCHandler, toggleRobotMoving } = commandUtils + + const handleStartLPC = createStartLPCHandler(activePipette, proceed) + + const requiredEquipmentList = [ + { + loadName: t('all_modules_and_labware_from_protocol', { + protocol_name: protocolName, + }), + displayName: t('all_modules_and_labware_from_protocol', { + protocol_name: protocolName, + }), + }, + ] + + const handleProceed = (): void => { + void toggleRobotMoving(true) + .then(() => handleStartLPC()) + .finally(() => toggleRobotMoving(false)) + } + + return ( + }} + /> + } + rightElement={ + + } + footer={ + + {isOnDevice ? ( + + ) : ( + + )} + {isOnDevice ? ( + + ) : ( + + {i18n.format(t('shared:get_started'), 'capitalize')} + + )} + + } + /> + ) +} diff --git a/app/src/organisms/LabwarePositionCheck/steps/CheckItem/JogToWell/LiveOffsetValue.tsx b/app/src/organisms/LabwarePositionCheck/steps/CheckItem/JogToWell/LiveOffsetValue.tsx new file mode 100644 index 00000000000..18ead803548 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/steps/CheckItem/JogToWell/LiveOffsetValue.tsx @@ -0,0 +1,85 @@ +import { Fragment } from 'react' +import { useTranslation } from 'react-i18next' +import { css } from 'styled-components' +import { useSelector } from 'react-redux' + +import { + ALIGN_CENTER, + BORDERS, + COLORS, + DIRECTION_COLUMN, + Flex, + Icon, + SIZE_1, + SPACING, + LegacyStyledText, + TYPOGRAPHY, +} from '@opentrons/components' + +import { getIsOnDevice } from '/app/redux/config' + +import type { StyleProps } from '@opentrons/components' +import type { + CheckPositionsStep, + LPCStepProps, +} from '/app/organisms/LabwarePositionCheck/types' + +interface OffsetVectorProps extends StyleProps { + x: number + y: number + z: number +} + +export function LiveOffsetValue( + props: OffsetVectorProps & LPCStepProps +): JSX.Element { + const { x, y, z, ...styleProps } = props + const { i18n, t } = useTranslation('labware_position_check') + const isOnDevice = useSelector(getIsOnDevice) + + return ( + + + {i18n.format(t('labware_offset_data'), 'capitalize')} + + + + {[x, y, z].map((axis, index) => ( + + + {['X', 'Y', 'Z'][index]} + + {axis.toFixed(1)} + + ))} + + + ) +} + +const FLEX_CONTAINER_STYLE = css` + flex-direction: ${DIRECTION_COLUMN}; + margin-top: ${SPACING.spacing8}; + margin-bottom: ${SPACING.spacing8}; + grid-gap: ${SPACING.spacing4}; +` + +const OFFSET_CONTAINER_STYLE = css` + align-items: ${ALIGN_CENTER}; + border: ${BORDERS.styleSolid} 1px ${COLORS.grey30}; + border-radius: ${BORDERS.borderRadius4}; + padding: ${SPACING.spacing8}; +` + +const OFFSET_LABEL_STYLE = css` + margin-left: ${SPACING.spacing8}; + margin-right: ${SPACING.spacing4}; + font-weight: ${TYPOGRAPHY.fontWeightSemiBold}; +` diff --git a/app/src/organisms/LabwarePositionCheck/steps/CheckItem/JogToWell/index.tsx b/app/src/organisms/LabwarePositionCheck/steps/CheckItem/JogToWell/index.tsx new file mode 100644 index 00000000000..26a0dd599e6 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/steps/CheckItem/JogToWell/index.tsx @@ -0,0 +1,299 @@ +import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' +import { useSelector } from 'react-redux' + +import { + ALIGN_CENTER, + ALIGN_FLEX_START, + COLORS, + DIRECTION_COLUMN, + Flex, + JUSTIFY_SPACE_BETWEEN, + LabwareRender, + LegacyStyledText, + ModalShell, + PipetteRender, + PrimaryButton, + RESPONSIVENESS, + RobotWorkSpace, + SecondaryButton, + SPACING, + TYPOGRAPHY, + WELL_LABEL_OPTIONS, +} from '@opentrons/components' +import { + getVectorDifference, + getVectorSum, + IDENTITY_VECTOR, +} from '@opentrons/shared-data' + +import { getTopPortalEl } from '/app/App/portal' +import { SmallButton } from '/app/atoms/buttons' +import { NeedHelpLink } from '/app/molecules/OT2CalibrationNeedHelpLink' +import { JogControls } from '/app/molecules/JogControls' +import { LiveOffsetValue } from './LiveOffsetValue' +import { + selectActiveLwExistingOffset, + selectActiveLwInitialPosition, + selectActivePipette, + selectIsActiveLwTipRack, + selectItemLabwareDef, +} from '/app/redux/protocol-runs' +import { getIsOnDevice } from '/app/redux/config' + +import type { ReactNode } from 'react' +import type { LabwareDefinition2 } from '@opentrons/shared-data' +import type { VectorOffset } from '@opentrons/api-client' +import type { Jog } from '/app/molecules/JogControls' +import type { + CheckPositionsStep, + LPCStepProps, +} from '/app/organisms/LabwarePositionCheck/types' +import type { LPCWizardState } from '/app/redux/protocol-runs' + +import levelProbeWithTip from '/app/assets/images/lpc_level_probe_with_tip.svg' +import levelProbeWithLabware from '/app/assets/images/lpc_level_probe_with_labware.svg' +import type { State } from '/app/redux/types' + +const DECK_MAP_VIEWBOX = '-10 -10 150 105' +const LPC_HELP_LINK_URL = + 'https://support.opentrons.com/s/article/How-Labware-Offsets-work-on-the-OT-2' + +interface JogToWellProps extends LPCStepProps { + header: ReactNode + body: ReactNode + handleConfirmPosition: () => void + handleGoBack: () => void + handleJog: Jog +} + +export function JogToWell(props: JogToWellProps): JSX.Element { + const { + runId, + header, + body, + handleConfirmPosition, + handleGoBack, + handleJog, + } = props + const { t } = useTranslation(['labware_position_check', 'shared']) + const { steps } = useSelector( + (state: State) => state.protocolRuns[runId]?.lpc as LPCWizardState + ) + const { current: currentStep } = steps + + const isOnDevice = useSelector(getIsOnDevice) + const initialPosition = useSelector( + (state: State) => + selectActiveLwInitialPosition(currentStep, runId, state) ?? + IDENTITY_VECTOR + ) + const pipetteName = useSelector( + (state: State) => + selectActivePipette(currentStep, runId, state)?.pipetteName ?? + 'p1000_single' + ) + const itemLwDef = useSelector( + selectItemLabwareDef(runId) + ) as LabwareDefinition2 // Safe if component only used with CheckItem step. + const isTipRack = useSelector((state: State) => + selectIsActiveLwTipRack(runId, state) + ) + const activeLwExistingOffset = useSelector((state: State) => + selectActiveLwExistingOffset(runId, state) + ) + + const [joggedPosition, setJoggedPosition] = useState( + initialPosition + ) + const [showFullJogControls, setShowFullJogControls] = useState(false) + + const levelSrc = isTipRack ? levelProbeWithTip : levelProbeWithLabware + const liveOffset = getVectorSum( + activeLwExistingOffset, + getVectorDifference(joggedPosition, initialPosition) + ) + + useEffect(() => { + // NOTE: this will perform a "null" jog when the jog controls mount so + // if a user reaches the "confirm exit" modal (unmounting this component) + // and clicks "go back" we are able so initialize the live offset to whatever + // distance they had already jogged before clicking exit. + // the `mounted` variable prevents a possible memory leak (see https://legacy.reactjs.org/docs/hooks-effect.html#example-using-hooks-1) + let mounted = true + if (mounted) { + handleJog('x', 1, 0, setJoggedPosition) + } + return () => { + mounted = false + } + }, []) + + return ( + + + +
    {header}
    + {body} + +
    + + + {() => ( + <> + + + + )} + + {`level + +
    + {isOnDevice ? ( + + + + { + setShowFullJogControls(true) + }} + /> + + + {showFullJogControls + ? createPortal( + + {t('move_to_a1_position')} + + } + footer={ + { + setShowFullJogControls(false) + }} + /> + } + > + + handleJog(axis, direction, step, setJoggedPosition) + } + isOnDevice={true} + /> + , + getTopPortalEl() + ) + : null} + + ) : ( + <> + + handleJog(axis, direction, step, setJoggedPosition) + } + /> + + + + + {t('shared:go_back')} + + + {t('shared:confirm_position')} + + + + + )} +
    + ) +} + +const CONTAINER_STYLE = css` + flex-direction: ${DIRECTION_COLUMN}; + justify-content: ${JUSTIFY_SPACE_BETWEEN}; + padding: ${SPACING.spacing32}; + min-height: 29.5rem; +` + +const CONTENT_GRID_STYLE = css` + grid-gap: ${SPACING.spacing24}; +` + +const INFO_CONTAINER_STYLE = css` + flex: 1; + flex-direction: ${DIRECTION_COLUMN}; + grid-gap: ${SPACING.spacing8}; + align-items: ${ALIGN_FLEX_START}; +` + +const RENDER_CONTAINER_STYLE = css` + flex: 1; + align-items: ${ALIGN_CENTER}; + grid-gap: ${SPACING.spacing20}; +` + +const FOOTER_CONTAINER_STYLE = css` + width: 100%; + margin-top: ${SPACING.spacing32}; + justify-content: ${JUSTIFY_SPACE_BETWEEN}; + align-items: ${ALIGN_CENTER}; +` + +const BUTTON_GROUP_STYLE = css` + grid-gap: ${SPACING.spacing8}; + align-items: ${ALIGN_CENTER}; +` + +const Header = styled.h1` + ${TYPOGRAPHY.h1Default} + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + ${TYPOGRAPHY.level4HeaderSemiBold} + } +` diff --git a/app/src/organisms/LabwarePositionCheck/steps/CheckItem/PlaceItemInstruction.tsx b/app/src/organisms/LabwarePositionCheck/steps/CheckItem/PlaceItemInstruction.tsx new file mode 100644 index 00000000000..7fc4487b278 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/steps/CheckItem/PlaceItemInstruction.tsx @@ -0,0 +1,99 @@ +import { Trans, useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' + +import { TYPOGRAPHY, LegacyStyledText } from '@opentrons/components' + +import { + selectActiveAdapterDisplayName, + selectLwDisplayName, +} from '/app/redux/protocol-runs' + +import type { State } from '/app/redux/types' +import type { + CheckPositionsStep, + LPCStepProps, +} from '/app/organisms/LabwarePositionCheck/types' + +interface PlaceItemInstructionProps extends LPCStepProps { + isLwTiprack: boolean + slotOnlyDisplayLocation: string + fullDisplayLocation: string +} + +export function PlaceItemInstruction({ + runId, + step, + isLwTiprack, + slotOnlyDisplayLocation, + fullDisplayLocation, +}: PlaceItemInstructionProps): JSX.Element { + const { t } = useTranslation('labware_position_check') + const { adapterId } = step + + const labwareDisplayName = useSelector((state: State) => + selectLwDisplayName(runId, state) + ) + const adapterDisplayName = useSelector((state: State) => + selectActiveAdapterDisplayName(runId, state) + ) + + if (isLwTiprack) { + return ( + + ), + }} + /> + ) + } else if (adapterId != null) { + return ( + + ), + }} + /> + ) + } else { + return ( + + ), + }} + /> + ) + } +} diff --git a/app/src/organisms/LabwarePositionCheck/steps/CheckItem/PrepareSpace.tsx b/app/src/organisms/LabwarePositionCheck/steps/CheckItem/PrepareSpace.tsx new file mode 100644 index 00000000000..d308d986a11 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/steps/CheckItem/PrepareSpace.tsx @@ -0,0 +1,157 @@ +import styled, { css } from 'styled-components' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' + +import { + DIRECTION_COLUMN, + JUSTIFY_SPACE_BETWEEN, + RESPONSIVENESS, + SPACING, + Flex, + DIRECTION_ROW, + JUSTIFY_CENTER, + TYPOGRAPHY, + JUSTIFY_FLEX_END, + PrimaryButton, + BaseDeck, + ALIGN_FLEX_START, +} from '@opentrons/components' +import { + THERMOCYCLER_MODULE_TYPE, + getModuleType, + FLEX_ROBOT_TYPE, +} from '@opentrons/shared-data' + +import { SmallButton } from '/app/atoms/buttons' +import { NeedHelpLink } from '/app/molecules/OT2CalibrationNeedHelpLink' +import { selectItemLabwareDef } from '/app/redux/protocol-runs' +import { getIsOnDevice } from '/app/redux/config' + +import type { ReactNode } from 'react' +import type { LabwareDefinition2 } from '@opentrons/shared-data' +import type { + CheckPositionsStep, + LPCStepProps, +} from '/app/organisms/LabwarePositionCheck/types' +import type { State } from '/app/redux/types' +import type { LPCWizardState } from '/app/redux/protocol-runs' + +const LPC_HELP_LINK_URL = + 'https://support.opentrons.com/s/article/How-Labware-Offsets-work-on-the-OT-2' + +interface PrepareSpaceProps extends LPCStepProps { + header: ReactNode + body: ReactNode + confirmPlacement: () => void +} + +export function PrepareSpace({ + runId, + header, + body, + confirmPlacement, +}: PrepareSpaceProps): JSX.Element { + const { i18n, t } = useTranslation(['labware_position_check', 'shared']) + const { protocolData, deckConfig, steps } = useSelector( + (state: State) => state.protocolRuns[runId]?.lpc as LPCWizardState + ) + const isOnDevice = useSelector(getIsOnDevice) + const labwareDef = useSelector( + selectItemLabwareDef(runId) + ) as LabwareDefinition2 // CheckItem always has lwId on step. + const { location } = steps.current as CheckPositionsStep // safely enforced by iface + + return ( + + + + {header} + {body} + + + ({ + moduleModel: mod.model, + moduleLocation: mod.location, + nestedLabwareDef: + 'moduleModel' in location && location.moduleModel != null + ? labwareDef + : null, + innerProps: + 'moduleModel' in location && + location.moduleModel != null && + getModuleType(location.moduleModel) === THERMOCYCLER_MODULE_TYPE + ? { lidMotorState: 'open' } + : {}, + }))} + labwareOnDeck={[ + { + labwareLocation: location, + definition: labwareDef, + }, + ].filter( + () => !('moduleModel' in location && location.moduleModel != null) + )} + deckConfig={deckConfig} + /> + + + {isOnDevice ? ( + + + + ) : ( + + + + {i18n.format(t('shared:confirm_placement'), 'capitalize')} + + + )} + + ) +} + +const PARENT_CONTAINER_STYLE = css` + flex-direction: ${DIRECTION_COLUMN}; + justify-content: ${JUSTIFY_SPACE_BETWEEN}; + padding: ${SPACING.spacing32}; + height: 24.625rem; + flex: 1; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + height: 29.5rem; + } +` + +const TITLE_CONTAINER_STYLE = css` + flex: 2; + flex-direction: ${DIRECTION_COLUMN}; + grid-gap: ${SPACING.spacing16}; +` + +const CONTENT_CONTAINER_STYLE = css` + flex: 1; + flex-direction: ${DIRECTION_ROW}; + grid-gap: ${SPACING.spacing40}; +` + +const DECK_CONTAINER_STYLE = css` + flex: 3; + justify-content: ${JUSTIFY_CENTER}; + align-items: ${ALIGN_FLEX_START}; +` + +const Title = styled.h1` + ${TYPOGRAPHY.h1Default}; + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + ${TYPOGRAPHY.level4HeaderSemiBold}; + } +` diff --git a/app/src/organisms/LabwarePositionCheck/steps/CheckItem/index.tsx b/app/src/organisms/LabwarePositionCheck/steps/CheckItem/index.tsx new file mode 100644 index 00000000000..fa7366af6fc --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/steps/CheckItem/index.tsx @@ -0,0 +1,209 @@ +import { Trans, useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' + +import { + DIRECTION_COLUMN, + Flex, + LegacyStyledText, + getLabwareDisplayLocation, +} from '@opentrons/components' + +import { NAV_STEPS } from '/app/organisms/LabwarePositionCheck/constants' +import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' +import { UnorderedList } from '/app/molecules/UnorderedList' +import { + setFinalPosition, + setInitialPosition, +} from '/app/redux/protocol-runs/actions' +import { JogToWell } from './JogToWell' +import { PrepareSpace } from './PrepareSpace' +import { PlaceItemInstruction } from './PlaceItemInstruction' +import { + selectActiveLwInitialPosition, + selectActivePipette, + selectIsActiveLwTipRack, +} from '/app/redux/protocol-runs' +import { getIsOnDevice } from '/app/redux/config' + +import type { DisplayLocationParams } from '@opentrons/components' +import type { + CheckPositionsStep, + LPCStepProps, +} from '/app/organisms/LabwarePositionCheck/types' +import type { State } from '/app/redux/types' +import type { LPCWizardState } from '/app/redux/protocol-runs' + +export function CheckItem( + props: LPCStepProps +): JSX.Element { + const { runId, proceed, commandUtils, step } = props + const { labwareId, location } = step + const { + handleJog, + handleCheckItemsPrepModules, + handleConfirmLwModulePlacement, + handleConfirmLwFinalPosition, + handleResetLwModulesOnDeck, + handleValidMoveToMaintenancePosition, + toggleRobotMoving, + } = commandUtils + const dispatch = useDispatch() + + const isOnDevice = useSelector(getIsOnDevice) + const { protocolData, labwareDefs, steps } = useSelector( + (state: State) => state.protocolRuns[runId]?.lpc as LPCWizardState + ) + const { t } = useTranslation(['labware_position_check', 'shared']) + const { t: commandTextT } = useTranslation('protocol_command_text') + + const pipette = useSelector( + (state: State) => selectActivePipette(step, runId, state) ?? null + ) + const initialPosition = useSelector((state: State) => + selectActiveLwInitialPosition(step, runId, state) + ) + const isLwTiprack = useSelector((state: State) => + selectIsActiveLwTipRack(runId, state) + ) + + const buildDisplayParams = (): Omit< + DisplayLocationParams, + 'detailLevel' + > => ({ + t: commandTextT, + loadedModules: protocolData.modules, + loadedLabwares: protocolData.labware, + robotType: FLEX_ROBOT_TYPE, + location, + }) + + const slotOnlyDisplayLocation = getLabwareDisplayLocation({ + detailLevel: 'slot-only', + ...buildDisplayParams(), + }) + const fullDisplayLocation = getLabwareDisplayLocation({ + detailLevel: 'full', + allRunDefs: labwareDefs, + ...buildDisplayParams(), + }) + + const handlePrepareProceed = (): void => { + void toggleRobotMoving(true) + .then(() => handleConfirmLwModulePlacement({ step })) + .then(position => { + dispatch( + setInitialPosition(runId, { + labwareId, + location, + position, + }) + ) + }) + .finally(() => toggleRobotMoving(false)) + } + + // TODO(jh, 01-14-25): Revisit next step injection after refactoring the store (after designs settle). + const handleJogProceed = (): void => { + void toggleRobotMoving(true) + .then(() => + handleConfirmLwFinalPosition({ + step, + onSuccess: proceed, + pipette, + }) + ) + .then(position => { + dispatch( + setFinalPosition(runId, { + labwareId, + location, + position, + }) + ) + }) + .then(() => { + if (steps.next?.section === NAV_STEPS.CHECK_POSITIONS) { + return handleCheckItemsPrepModules(steps.next) + } else { + return handleValidMoveToMaintenancePosition(pipette, steps.next) + } + }) + .finally(() => toggleRobotMoving(false)) + } + + const handleGoBack = (): void => { + void toggleRobotMoving(true) + .then(() => handleResetLwModulesOnDeck({ step })) + .then(() => { + dispatch( + setInitialPosition(runId, { + labwareId, + location, + position: null, + }) + ) + }) + .finally(() => toggleRobotMoving(false)) + } + + // TODO(jh 01-15-24): These should be separate steps, but let's wait for designs to settle. + return ( + + {initialPosition != null ? ( + , + bold: , + }} + /> + } + handleConfirmPosition={handleJogProceed} + handleGoBack={handleGoBack} + handleJog={handleJog} + {...props} + /> + ) : ( + , + ]} + /> + } + confirmPlacement={handlePrepareProceed} + {...props} + /> + )} + + ) +} diff --git a/app/src/organisms/LabwarePositionCheck/steps/DetachProbe.tsx b/app/src/organisms/LabwarePositionCheck/steps/DetachProbe.tsx new file mode 100644 index 00000000000..1659fe375db --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/steps/DetachProbe.tsx @@ -0,0 +1,93 @@ +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' +import { useSelector } from 'react-redux' + +import { + LegacyStyledText, + RESPONSIVENESS, + SPACING, + TYPOGRAPHY, +} from '@opentrons/components' + +import { GenericWizardTile } from '/app/molecules/GenericWizardTile' +import { + selectActivePipette, + selectActivePipetteChannelCount, +} from '/app/redux/protocol-runs' + +import detachProbe1 from '/app/assets/videos/pipette-wizard-flows/Pipette_Detach_Probe_1.webm' +import detachProbe8 from '/app/assets/videos/pipette-wizard-flows/Pipette_Detach_Probe_8.webm' +import detachProbe96 from '/app/assets/videos/pipette-wizard-flows/Pipette_Detach_Probe_96.webm' + +import type { DetachProbeStep, LPCStepProps } from '../types' +import type { State } from '/app/redux/types' +import type { StepsInfo } from '/app/organisms/LabwarePositionCheck/redux/types' + +const StyledVideo = styled.video` + padding-top: ${SPACING.spacing4}; + width: 100%; + min-height: 18rem; +` + +const StyledBody = styled(LegacyStyledText)` + ${TYPOGRAPHY.pRegular}; + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + font-size: 1.275rem; + line-height: 1.75rem; + } +` + +export const DetachProbe = ({ + runId, + proceed, + commandUtils, +}: LPCStepProps): JSX.Element => { + const { t, i18n } = useTranslation(['labware_position_check', 'shared']) + const { current: currentStep } = useSelector( + (state: State) => state.protocolRuns[runId]?.lpc?.steps as StepsInfo + ) + const { createProbeDetachmentHandler, toggleRobotMoving } = commandUtils + const pipette = useSelector((state: State) => + selectActivePipette(currentStep, runId, state) + ) + const channels = useSelector((state: State) => + selectActivePipetteChannelCount(currentStep, runId, state) + ) + + const probeVideoSrc = ((): string => { + switch (channels) { + case 1: + return detachProbe1 + case 8: + return detachProbe8 + case 96: + return detachProbe96 + } + })() + + const handleProbeDetached = createProbeDetachmentHandler(pipette, proceed) + + const handleProceed = (): void => { + void toggleRobotMoving(true) + .then(() => handleProbeDetached()) + .finally(() => toggleRobotMoving(false)) + } + + return ( + + + + } + bodyText={ + {i18n.format(t('remove_probe'), 'capitalize')} + } + proceedButtonText={t('confirm_detached')} + proceed={handleProceed} + /> + ) +} diff --git a/app/src/organisms/LabwarePositionCheck/steps/ResultsSummary/OffsetTable.tsx b/app/src/organisms/LabwarePositionCheck/steps/ResultsSummary/OffsetTable.tsx new file mode 100644 index 00000000000..d35c3d2f885 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/steps/ResultsSummary/OffsetTable.tsx @@ -0,0 +1,147 @@ +import { Fragment } from 'react' +import styled from 'styled-components' +import isEqual from 'lodash/isEqual' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' + +import { FLEX_ROBOT_TYPE, IDENTITY_VECTOR } from '@opentrons/shared-data' +import { + BORDERS, + COLORS, + Flex, + SPACING, + LegacyStyledText, + TYPOGRAPHY, + getLabwareDisplayLocation, +} from '@opentrons/components' + +import { selectLwDisplayName } from '/app/redux/protocol-runs' + +import type { LabwareDefinition2 } from '@opentrons/shared-data' +import type { LabwareOffsetCreateData } from '@opentrons/api-client' +import type { + LPCStepProps, + ResultsSummaryStep, +} from '/app/organisms/LabwarePositionCheck/types' +import type { LPCWizardState } from '/app/redux/protocol-runs' +import type { State } from '/app/redux/types' + +interface OffsetTableProps extends LPCStepProps { + offsets: LabwareOffsetCreateData[] + labwareDefinitions: LabwareDefinition2[] +} + +export function OffsetTable({ + offsets, + runId, + labwareDefinitions, +}: OffsetTableProps): JSX.Element { + const { protocolData } = useSelector( + (state: State) => state.protocolRuns[runId]?.lpc as LPCWizardState + ) + const lwDisplayName = useSelector((state: State) => + selectLwDisplayName(runId, state) + ) + + const { t } = useTranslation('labware_position_check') + + return ( + + + + {t('location')} + {t('labware')} + {t('labware_offset_data')} + + + + + {offsets.map(({ location, vector }, index) => { + const displayLocation = getLabwareDisplayLocation({ + location, + allRunDefs: labwareDefinitions, + detailLevel: 'full', + t, + loadedModules: protocolData.modules, + loadedLabwares: protocolData.labware, + robotType: FLEX_ROBOT_TYPE, + }) + + return ( + + + + {displayLocation} + + + + {lwDisplayName} + + + {isEqual(vector, IDENTITY_VECTOR) ? ( + {t('no_labware_offsets')} + ) : ( + + {[vector.x, vector.y, vector.z].map((axis, index) => ( + + 0 ? SPACING.spacing8 : 0} + marginRight={SPACING.spacing4} + fontWeight={TYPOGRAPHY.fontWeightSemiBold} + > + {['X', 'Y', 'Z'][index]} + + + {axis.toFixed(1)} + + + ))} + + )} + + + ) + })} + +
    + ) +} + +const Table = styled('table')` + ${TYPOGRAPHY.labelRegular} + table-layout: auto; + width: 100%; + border-spacing: 0 ${SPACING.spacing4}; + margin: ${SPACING.spacing16} 0; + text-align: left; +` + +const TableHeader = styled('th')` + text-transform: ${TYPOGRAPHY.textTransformUppercase}; + color: ${COLORS.black90}; + font-weight: ${TYPOGRAPHY.fontWeightRegular}; + font-size: ${TYPOGRAPHY.fontSizeCaption}; + padding: ${SPACING.spacing4}; +` + +const TableRow = styled('tr')` + background-color: ${COLORS.grey20}; +` + +const TableDatum = styled('td')` + padding: ${SPACING.spacing4}; + white-space: break-spaces; + text-overflow: wrap; +` + +const LeftRoundedTableDatum = styled(TableDatum)` + border-radius: ${BORDERS.borderRadius4} 0 0 ${BORDERS.borderRadius4}; +` + +const RightRoundedTableDatum = styled(TableDatum)` + border-radius: 0 ${BORDERS.borderRadius4} ${BORDERS.borderRadius4} 0; +` diff --git a/app/src/organisms/LabwarePositionCheck/steps/ResultsSummary/TableComponent.tsx b/app/src/organisms/LabwarePositionCheck/steps/ResultsSummary/TableComponent.tsx new file mode 100644 index 00000000000..90ddb8edcf1 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/steps/ResultsSummary/TableComponent.tsx @@ -0,0 +1,38 @@ +import { useSelector } from 'react-redux' + +import { TerseOffsetTable } from '/app/organisms/TerseOffsetTable' +import { OffsetTable } from './OffsetTable' +import { getIsOnDevice } from '/app/redux/config' + +import type { LabwareOffsetCreateData } from '@opentrons/api-client' +import type { + LPCStepProps, + ResultsSummaryStep, +} from '/app/organisms/LabwarePositionCheck/types' +import type { State } from '/app/redux/types' +import type { LPCWizardState } from '/app/redux/protocol-runs' + +interface TableComponentProps extends LPCStepProps { + offsetsToApply: LabwareOffsetCreateData[] +} + +export function TableComponent(props: TableComponentProps): JSX.Element { + const { offsetsToApply, runId } = props + const isOnDevice = useSelector(getIsOnDevice) + const { labwareDefs } = useSelector( + (state: State) => state.protocolRuns[runId]?.lpc as LPCWizardState + ) + + return isOnDevice ? ( + + ) : ( + + ) +} diff --git a/app/src/organisms/LabwarePositionCheck/steps/ResultsSummary/index.tsx b/app/src/organisms/LabwarePositionCheck/steps/ResultsSummary/index.tsx new file mode 100644 index 00000000000..f63d5f8518d --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/steps/ResultsSummary/index.tsx @@ -0,0 +1,169 @@ +import styled, { css } from 'styled-components' +import { useSelector } from 'react-redux' +import { useTranslation } from 'react-i18next' + +import { + ALIGN_CENTER, + ALIGN_FLEX_END, + COLORS, + DIRECTION_COLUMN, + Flex, + Icon, + JUSTIFY_SPACE_BETWEEN, + OVERFLOW_AUTO, + PrimaryButton, + RESPONSIVENESS, + SPACING, + LegacyStyledText, + TYPOGRAPHY, +} from '@opentrons/components' + +import { NeedHelpLink } from '/app/molecules/OT2CalibrationNeedHelpLink' +import { PythonLabwareOffsetSnippet } from '/app/molecules/PythonLabwareOffsetSnippet' +import { + getIsLabwareOffsetCodeSnippetsOn, + getIsOnDevice, +} from '/app/redux/config' +import { SmallButton } from '/app/atoms/buttons' +import { LabwareOffsetTabs } from '/app/organisms/LabwareOffsetTabs' +import { TableComponent } from './TableComponent' + +import type { + LPCStepProps, + ResultsSummaryStep, +} from '/app/organisms/LabwarePositionCheck/types' +import type { State } from '/app/redux/types' +import type { LPCWizardState } from '/app/redux/protocol-runs' + +// TODO(jh, 01-08-25): This support link will likely need updating as a part of RPRD-173, too. +const LPC_HELP_LINK_URL = + 'https://support.opentrons.com/s/article/How-Labware-Offsets-work-on-the-OT-2' + +export function ResultsSummary( + props: LPCStepProps +): JSX.Element { + const { commandUtils, runId } = props + const isOnDevice = useSelector(getIsOnDevice) + const { protocolData } = useSelector( + (state: State) => state.protocolRuns[runId]?.lpc as LPCWizardState + ) + const { + isApplyingOffsets, + handleApplyOffsetsAndClose, + buildOffsetsToApply, + toggleRobotMoving, + } = commandUtils + const { i18n, t } = useTranslation('labware_position_check') + const offsetsToApply = buildOffsetsToApply() + const isLabwareOffsetCodeSnippetsOn = useSelector( + getIsLabwareOffsetCodeSnippetsOn + ) + + const handleProceed = (): void => { + void toggleRobotMoving(true).then(() => + handleApplyOffsetsAndClose(offsetsToApply) + ) + } + + return ( + + +
    {t('new_labware_offset_data')}
    + {isLabwareOffsetCodeSnippetsOn ? ( + + } + JupyterComponent={ + + } + CommandLineComponent={ + + } + marginTop={SPACING.spacing16} + /> + ) : ( + + )} +
    + {isOnDevice ? ( + + ) : ( + + + + + {isApplyingOffsets ? ( + + ) : null} + + {i18n.format(t('apply_offsets'), 'capitalize')} + + + + + )} +
    + ) +} + +const PARENT_CONTAINER_STYLE = css` + flex-direction: ${DIRECTION_COLUMN}; + justify-content: ${JUSTIFY_SPACE_BETWEEN}; + padding: ${SPACING.spacing32}; + min-height: 29.5rem; +` + +const SHARED_CONTAINER_STYLE = css` + flex-direction: ${DIRECTION_COLUMN}; + max-height: 20rem; + overflow-y: ${OVERFLOW_AUTO}; + + &::-webkit-scrollbar { + width: 0.75rem; + background-color: transparent; + } + &::-webkit-scrollbar-thumb { + background: ${COLORS.grey50}; + border-radius: 11px; + } +` + +const DESKTOP_BUTTON_STYLE = css` + width: 100%; + margin-top: ${SPACING.spacing32}; + justify-content: ${JUSTIFY_SPACE_BETWEEN}; + align-items: ${ALIGN_CENTER}; +` + +const Header = styled.h1` + ${TYPOGRAPHY.h1Default} + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + ${TYPOGRAPHY.level4HeaderSemiBold} + } +` diff --git a/app/src/organisms/LabwarePositionCheck/steps/index.ts b/app/src/organisms/LabwarePositionCheck/steps/index.ts new file mode 100644 index 00000000000..9bf7efbad46 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/steps/index.ts @@ -0,0 +1,5 @@ +export { BeforeBeginning } from './BeforeBeginning' +export { AttachProbe } from './AttachProbe' +export { CheckItem } from './CheckItem' +export { DetachProbe } from './DetachProbe' +export { ResultsSummary } from './ResultsSummary' diff --git a/app/src/organisms/LabwarePositionCheck/types/content.ts b/app/src/organisms/LabwarePositionCheck/types/content.ts new file mode 100644 index 00000000000..d32b02191bb --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/types/content.ts @@ -0,0 +1,10 @@ +import type { UseLPCCommandsResult } from '/app/organisms/LabwarePositionCheck/hooks' +import type { LPCWizardFlexProps } from '/app/organisms/LabwarePositionCheck/LPCWizardFlex' + +export type LPCWizardContentProps = Pick< + LPCWizardFlexProps, + 'onCloseClick' | 'runId' +> & { + proceed: () => void + commandUtils: UseLPCCommandsResult +} diff --git a/app/src/organisms/LabwarePositionCheck/types/index.ts b/app/src/organisms/LabwarePositionCheck/types/index.ts new file mode 100644 index 00000000000..4da2755de80 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/types/index.ts @@ -0,0 +1,2 @@ +export * from './steps' +export * from './content' diff --git a/app/src/organisms/LabwarePositionCheck/types/steps.ts b/app/src/organisms/LabwarePositionCheck/types/steps.ts new file mode 100644 index 00000000000..3cc781aebff --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/types/steps.ts @@ -0,0 +1,48 @@ +import type { LabwareOffsetLocation } from '@opentrons/api-client' +import type { NAV_STEPS } from '../constants' +import type { LPCWizardContentProps } from './content' + +export type LabwarePositionCheckStep = + | BeforeBeginningStep + | AttachProbeStep + | CheckPositionsStep + | DetachProbeStep + | ResultsSummaryStep + +export type LPCStepProps = Omit< + LPCWizardContentProps, + 'step' +> & { + step: Extract +} + +export interface PerformLPCStep { + pipetteId: string + labwareId: string + location: LabwareOffsetLocation + definitionUri: string + adapterId?: string + moduleId?: string +} + +export interface BeforeBeginningStep { + section: typeof NAV_STEPS.BEFORE_BEGINNING +} + +export interface AttachProbeStep { + section: typeof NAV_STEPS.ATTACH_PROBE + pipetteId: string +} + +export interface CheckPositionsStep extends PerformLPCStep { + section: typeof NAV_STEPS.CHECK_POSITIONS +} + +export interface DetachProbeStep { + section: typeof NAV_STEPS.DETACH_PROBE + pipetteId: string +} + +export interface ResultsSummaryStep { + section: typeof NAV_STEPS.RESULTS_SUMMARY +} diff --git a/app/src/organisms/LegacyLabwarePositionCheck/AttachProbe.tsx b/app/src/organisms/LegacyLabwarePositionCheck/AttachProbe.tsx index afd9efba19f..9dab5c9d8cb 100644 --- a/app/src/organisms/LegacyLabwarePositionCheck/AttachProbe.tsx +++ b/app/src/organisms/LegacyLabwarePositionCheck/AttachProbe.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' +import styled from 'styled-components' import { RESPONSIVENESS, SPACING, @@ -7,7 +8,6 @@ import { TYPOGRAPHY, } from '@opentrons/components' import { getPipetteNameSpecs } from '@opentrons/shared-data' -import { css } from 'styled-components' import { ProbeNotAttached } from '/app/organisms/PipetteWizardFlows/ProbeNotAttached' import { RobotMotionLoader } from './RobotMotionLoader' import attachProbe1 from '/app/assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_1.webm' @@ -29,6 +29,21 @@ import type { WorkingOffset, } from './types' +const StyledVideo = styled.video` + padding-top: ${SPACING.spacing4}; + width: 100%; + min-height: 18rem; +` + +const StyledBody = styled(LegacyStyledText)` + ${TYPOGRAPHY.pRegular}; + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + font-size: 1.275rem; + line-height: 1.75rem; + } +` + interface AttachProbeProps extends AttachProbeStep { protocolData: CompletedProtocolAnalysis proceed: () => void @@ -155,21 +170,12 @@ export const AttachProbe = (props: AttachProbeProps): JSX.Element | null => { + - + } bodyText={ - + { bold: , }} /> - + } proceedButtonText={i18n.format(t('shared:continue'), 'capitalize')} proceed={handleProbeAttached} /> ) } - -export const BODY_STYLE = css` - ${TYPOGRAPHY.pRegular}; - - @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { - font-size: 1.275rem; - line-height: 1.75rem; - } -` diff --git a/app/src/organisms/LegacyLabwarePositionCheck/DetachProbe.tsx b/app/src/organisms/LegacyLabwarePositionCheck/DetachProbe.tsx index dd040654a23..a8bfb29bdbf 100644 --- a/app/src/organisms/LegacyLabwarePositionCheck/DetachProbe.tsx +++ b/app/src/organisms/LegacyLabwarePositionCheck/DetachProbe.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { css } from 'styled-components' +import styled from 'styled-components' import { LegacyStyledText, RESPONSIVENESS, @@ -25,6 +25,21 @@ import type { } from './types' import type { LabwareOffset } from '@opentrons/api-client' +const StyledVideo = styled.video` + padding-top: ${SPACING.spacing4}; + width: 100%; + min-height: 18rem; +` + +const StyledBody = styled(LegacyStyledText)` + ${TYPOGRAPHY.pRegular}; + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + font-size: 1.275rem; + line-height: 1.75rem; + } +` + interface DetachProbeProps extends DetachProbeStep { protocolData: CompletedProtocolAnalysis proceed: () => void @@ -36,6 +51,7 @@ interface DetachProbeProps extends DetachProbeStep { handleJog: Jog isRobotMoving: boolean } + export const DetachProbe = (props: DetachProbeProps): JSX.Element | null => { const { t, i18n } = useTranslation(['labware_position_check', 'shared']) const { @@ -121,35 +137,15 @@ export const DetachProbe = (props: DetachProbeProps): JSX.Element | null => { header={i18n.format(t('detach_probe'), 'capitalize')} // todo(jr, 5/30/23): update animations! these are not final for 1, 8 and 96 rightHandBody={ - + } bodyText={ - - {i18n.format(t('remove_probe'), 'capitalize')} - + {i18n.format(t('remove_probe'), 'capitalize')} } proceedButtonText={t('confirm_detached')} proceed={handleProbeDetached} /> ) } - -export const BODY_STYLE = css` - ${TYPOGRAPHY.pRegular}; - - @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { - font-size: 1.275rem; - line-height: 1.75rem; - } -` diff --git a/app/src/organisms/LegacyLabwarePositionCheck/LabwarePositionCheckComponent.tsx b/app/src/organisms/LegacyLabwarePositionCheck/LabwarePositionCheckComponent.tsx index 6f0953093a6..6ff45018b1a 100644 --- a/app/src/organisms/LegacyLabwarePositionCheck/LabwarePositionCheckComponent.tsx +++ b/app/src/organisms/LegacyLabwarePositionCheck/LabwarePositionCheckComponent.tsx @@ -56,8 +56,8 @@ interface LabwarePositionCheckModalProps { existingOffsets: LabwareOffset[] onCloseClick: () => unknown protocolName: string - setMaintenanceRunId: (id: string | null) => void - isDeletingMaintenanceRun: boolean + setMaintenanceRunId?: (id: string | null) => void + isDeletingMaintenanceRun?: boolean caughtError?: Error } @@ -105,7 +105,7 @@ export const LabwarePositionCheckComponent = ( maintenanceRunData?.data.id !== maintenanceRunId && monitorMaintenanceRunForDeletion ) { - setMaintenanceRunId(null) + setMaintenanceRunId?.(null) } }, [ maintenanceRunData?.data.id, diff --git a/app/src/organisms/LegacyLabwarePositionCheck/ResultsSummary.tsx b/app/src/organisms/LegacyLabwarePositionCheck/ResultsSummary.tsx index aad9dac0983..22934c0f8a0 100644 --- a/app/src/organisms/LegacyLabwarePositionCheck/ResultsSummary.tsx +++ b/app/src/organisms/LegacyLabwarePositionCheck/ResultsSummary.tsx @@ -1,5 +1,5 @@ import { useMemo, Fragment } from 'react' -import styled, { css } from 'styled-components' +import styled from 'styled-components' import { useSelector } from 'react-redux' import isEqual from 'lodash/isEqual' import { useTranslation } from 'react-i18next' @@ -62,7 +62,7 @@ interface ResultsSummaryProps extends ResultsSummaryStep { existingOffsets: LabwareOffset[] handleApplyOffsets: (offsets: LabwareOffsetCreateData[]) => void isApplyingOffsets: boolean - isDeletingMaintenanceRun: boolean + isDeletingMaintenanceRun?: boolean } export const ResultsSummary = ( props: ResultsSummaryProps @@ -157,21 +157,7 @@ export const ResultsSummary = ( padding={SPACING.spacing32} minHeight="29.5rem" > - +
    {t('new_labware_offset_data')}
    {isLabwareOffsetCodeSnippetsOn ? ( +
    {isOnDevice ? ( { return ( - + { i18n )} - + {labwareDisplayName} - + {isEqual(vector, IDENTITY_VECTOR) ? ( {t('no_labware_offsets')} ) : ( @@ -338,7 +334,7 @@ const OffsetTable = (props: OffsetTableProps): JSX.Element => { ))}
    )} - + ) })} diff --git a/app/src/organisms/LegacyLabwarePositionCheck/index.tsx b/app/src/organisms/LegacyLabwarePositionCheck/index.tsx index f09721a9ac2..52a87f9b687 100644 --- a/app/src/organisms/LegacyLabwarePositionCheck/index.tsx +++ b/app/src/organisms/LegacyLabwarePositionCheck/index.tsx @@ -22,15 +22,17 @@ interface LabwarePositionCheckModalProps { existingOffsets: LabwareOffset[] mostRecentAnalysis: CompletedProtocolAnalysis | null protocolName: string + setMaintenanceRunId?: (id: string | null) => void + isDeletingMaintenanceRun?: boolean caughtError?: Error - setMaintenanceRunId: (id: string | null) => void - isDeletingMaintenanceRun: boolean } // We explicitly wrap LabwarePositionCheckComponent in an ErrorBoundary because an error might occur while pulling in // the component's dependencies (like useLabwarePositionCheck). If we wrapped the contents of LabwarePositionCheckComponent // in an ErrorBoundary as part of its return value (render), an error could occur before this point, meaning the error boundary // would never get invoked + +// LegacyFlows are utilized by the OT-2, and should never actually be utilized by the Flex despite offering Flex support. export const LegacyLabwarePositionCheck = ( props: LabwarePositionCheckModalProps ): JSX.Element => { diff --git a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupOffsets/index.tsx b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupOffsets/index.tsx index 0f7b7855d67..bbb51edb9a8 100644 --- a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupOffsets/index.tsx +++ b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupOffsets/index.tsx @@ -16,7 +16,7 @@ import { useToaster } from '/app/organisms/ToasterOven' import { ODDBackButton } from '/app/molecules/ODDBackButton' import { FloatingActionButton, SmallButton } from '/app/atoms/buttons' import type { SetupScreens } from '../types' -import { TerseOffsetTable } from '/app/organisms/LegacyLabwarePositionCheck/ResultsSummary' +import { TerseOffsetTable } from '/app/organisms/TerseOffsetTable' import { useNotifyRunQuery, useMostRecentCompletedAnalysis, @@ -33,7 +33,6 @@ export interface ProtocolSetupOffsetsProps { LPCWizard: JSX.Element | null isConfirmed: boolean setIsConfirmed: (confirmed: boolean) => void - isNewLpc: boolean } export function ProtocolSetupOffsets({ @@ -44,7 +43,6 @@ export function ProtocolSetupOffsets({ launchLPC, lpcDisabledReason, LPCWizard, - isNewLpc, }: ProtocolSetupOffsetsProps): JSX.Element { const { t } = useTranslation('protocol_setup') const { makeSnackbar } = useToaster() @@ -78,8 +76,7 @@ export function ProtocolSetupOffsets({ const nonIdentityOffsets = getLatestCurrentOffsets(sortedOffsets) return ( <> - {isNewLpc ? null : LPCWizard} - {LPCWizard == null && ( + {LPCWizard ?? ( <> null)() : launchLPC() + launchLPC() } }} /> diff --git a/app/src/organisms/PipetteWizardFlows/ProbeNotAttached.tsx b/app/src/organisms/PipetteWizardFlows/ProbeNotAttached.tsx index d98fde280f7..376984673d9 100644 --- a/app/src/organisms/PipetteWizardFlows/ProbeNotAttached.tsx +++ b/app/src/organisms/PipetteWizardFlows/ProbeNotAttached.tsx @@ -23,6 +23,8 @@ interface ProbeNotAttachedProps { isOnDevice: boolean } +// TODO(jh 01-07-25): This component is utilized by other flows. Let's hoist it out of PipetteWizardFlows. + export const ProbeNotAttached = ( props: ProbeNotAttachedProps ): JSX.Element | null => { diff --git a/app/src/organisms/TerseOffsetTable/TerseOffsetTable.stories.tsx b/app/src/organisms/TerseOffsetTable/TerseOffsetTable.stories.tsx new file mode 100644 index 00000000000..0a24182e887 --- /dev/null +++ b/app/src/organisms/TerseOffsetTable/TerseOffsetTable.stories.tsx @@ -0,0 +1,109 @@ +import type * as React from 'react' +import { + ALIGN_FLEX_END, + DIRECTION_COLUMN, + Flex, + JUSTIFY_SPACE_BETWEEN, + SPACING, + VIEWPORT, +} from '@opentrons/components' +import { + fixture12Trough, + fixtureTiprack10ul, + getLabwareDefURI, +} from '@opentrons/shared-data' + +import { SmallButton } from '../../../atoms/buttons' +import { TerseOffsetTable } from '.' + +import type { Story, Meta } from '@storybook/react' +import type { LabwareDefinition2 } from '@opentrons/shared-data' + +export default { + title: 'ODD/Organisms/TerseOffsetTable', + component: TerseOffsetTable, + parameters: VIEWPORT.touchScreenViewport, +} as Meta + +// Note: 59rem(944px) is the size of ODD +const Template: Story> = ({ + ...args +}) => ( + + + +

    new labware offset data

    + +
    + { + console.log('FAKE BUTTON') + }} + buttonText="Apply offsets" + /> +
    +
    +) + +export const Basic = Template.bind({}) +Basic.args = { + offsets: [ + { + definitionUri: getLabwareDefURI(fixture12Trough as LabwareDefinition2), + location: { slotName: 'A1' }, + vector: { x: 1, y: 2, z: 3 }, + }, + { + definitionUri: getLabwareDefURI(fixture12Trough as LabwareDefinition2), + location: { slotName: 'A2' }, + vector: { x: 1, y: 2, z: 3 }, + }, + { + definitionUri: getLabwareDefURI(fixture12Trough as LabwareDefinition2), + location: { slotName: 'A3' }, + vector: { x: 1, y: 2, z: 3 }, + }, + { + definitionUri: getLabwareDefURI(fixture12Trough as LabwareDefinition2), + location: { slotName: 'B1' }, + vector: { x: 1, y: 2, z: 3 }, + }, + { + definitionUri: getLabwareDefURI(fixture12Trough as LabwareDefinition2), + location: { slotName: 'B2' }, + vector: { x: 1, y: 2, z: 3 }, + }, + { + definitionUri: getLabwareDefURI(fixture12Trough as LabwareDefinition2), + location: { slotName: 'B3' }, + vector: { x: 1, y: 2, z: 3 }, + }, + { + definitionUri: getLabwareDefURI(fixture12Trough as LabwareDefinition2), + location: { slotName: 'C1' }, + vector: { x: 1, y: 2, z: 3 }, + }, + { + definitionUri: getLabwareDefURI(fixture12Trough as LabwareDefinition2), + location: { slotName: 'C2' }, + vector: { x: 1, y: 2, z: 3 }, + }, + { + definitionUri: getLabwareDefURI(fixture12Trough as LabwareDefinition2), + location: { slotName: 'C3' }, + vector: { x: 1, y: 2, z: 3 }, + }, + ], + labwareDefinitions: [fixture12Trough, fixtureTiprack10ul], +} diff --git a/app/src/organisms/TerseOffsetTable/index.tsx b/app/src/organisms/TerseOffsetTable/index.tsx new file mode 100644 index 00000000000..5fdbaf162a2 --- /dev/null +++ b/app/src/organisms/TerseOffsetTable/index.tsx @@ -0,0 +1,147 @@ +import { Fragment } from 'react' +import styled from 'styled-components' +import isEqual from 'lodash/isEqual' +import { useTranslation } from 'react-i18next' + +import { + getLabwareDefURI, + getLabwareDisplayName, + getModuleType, + IDENTITY_VECTOR, +} from '@opentrons/shared-data' +import { + BORDERS, + COLORS, + DeckInfoLabel, + Flex, + MODULE_ICON_NAME_BY_TYPE, + SPACING, + LegacyStyledText, + TYPOGRAPHY, + DIRECTION_ROW, +} from '@opentrons/components' + +import type { LabwareOffsetCreateData } from '@opentrons/api-client' +import type { LabwareDefinition2 } from '@opentrons/shared-data' + +export interface TerseOffsetTableProps { + offsets: LabwareOffsetCreateData[] + labwareDefinitions: LabwareDefinition2[] +} + +// Very similar to the OffsetTable, but abbreviates certain things to be optimized +// for smaller screens. +export function TerseOffsetTable({ + offsets, + labwareDefinitions, +}: TerseOffsetTableProps): JSX.Element { + const { i18n, t } = useTranslation('labware_position_check') + return ( + + + + + {i18n.format(t('slot_location'), 'capitalize')} + + {i18n.format(t('labware'), 'capitalize')} + {i18n.format(t('offsets'), 'capitalize')} + + + + + {offsets.map(({ location, definitionUri, vector }, index) => { + const labwareDef = labwareDefinitions.find( + def => getLabwareDefURI(def) === definitionUri + ) + const labwareDisplayName = + labwareDef != null ? getLabwareDisplayName(labwareDef) : '' + return ( + + + + + {location.moduleModel != null ? ( + + ) : null} + + + + + {labwareDisplayName} + + + + {isEqual(vector, IDENTITY_VECTOR) ? ( + {t('no_labware_offsets')} + ) : ( + + {[vector.x, vector.y, vector.z].map((axis, index) => ( + + 0 ? SPACING.spacing8 : 0} + marginRight={SPACING.spacing4} + fontWeight={TYPOGRAPHY.fontWeightSemiBold} + > + {['X', 'Y', 'Z'][index]} + + + {axis.toFixed(1)} + + + ))} + + )} + + + ) + })} + + + ) +} + +const TerseTable = styled('table')` + table-layout: auto; + width: 100%; + border-spacing: 0 ${SPACING.spacing4}; + margin: ${SPACING.spacing16} 0; + text-align: left; + tr td:first-child { + border-top-left-radius: ${BORDERS.borderRadius8}; + border-bottom-left-radius: ${BORDERS.borderRadius8}; + padding-left: ${SPACING.spacing12}; + } + tr td:last-child { + border-top-right-radius: ${BORDERS.borderRadius8}; + border-bottom-right-radius: ${BORDERS.borderRadius8}; + padding-right: ${SPACING.spacing12}; + } +` +const TerseHeader = styled('th')` + font-size: ${TYPOGRAPHY.fontSize20}; + line-height: ${TYPOGRAPHY.lineHeight24}; + font-weight: ${TYPOGRAPHY.fontWeightSemiBold}; +` +const TerseTableRow = styled('tr')` + background-color: ${COLORS.grey35}; +` + +const TerseTableDatum = styled('td')` + padding: ${SPACING.spacing12} 0; + white-space: break-spaces; + text-overflow: wrap; +` diff --git a/app/src/pages/ODD/ProtocolSetup/__tests__/ProtocolSetup.test.tsx b/app/src/pages/ODD/ProtocolSetup/__tests__/ProtocolSetup.test.tsx index 5863d70ba93..d87bad08d52 100644 --- a/app/src/pages/ODD/ProtocolSetup/__tests__/ProtocolSetup.test.tsx +++ b/app/src/pages/ODD/ProtocolSetup/__tests__/ProtocolSetup.test.tsx @@ -65,6 +65,7 @@ import { import { mockConnectableRobot } from '/app/redux/discovery/__fixtures__' import { mockRunTimeParameterData } from '/app/organisms/ODD/ProtocolSetup/__fixtures__' import { useScrollPosition } from '/app/local-resources/dom-utils' +import { useLPCFlows } from '/app/organisms/LabwarePositionCheck' import type { UseQueryResult } from 'react-query' import type * as SharedData from '@opentrons/shared-data' @@ -115,6 +116,7 @@ vi.mock('/app/redux-resources/analytics') vi.mock('/app/redux-resources/robots') vi.mock('/app/resources/modules') vi.mock('/app/local-resources/dom-utils') +vi.mock('/app/organisms/LabwarePositionCheck') const render = (path = '/') => { return renderWithProviders( @@ -328,6 +330,7 @@ describe('ProtocolSetup', () => { isScrolled: false, scrollRef: {} as any, }) + vi.mocked(useLPCFlows).mockReturnValue({ launchLPC: mockLaunchLPC } as any) }) it('should render text, image, and buttons', () => { diff --git a/app/src/pages/ODD/ProtocolSetup/index.tsx b/app/src/pages/ODD/ProtocolSetup/index.tsx index 1df659c633b..05dad45ae09 100644 --- a/app/src/pages/ODD/ProtocolSetup/index.tsx +++ b/app/src/pages/ODD/ProtocolSetup/index.tsx @@ -88,6 +88,7 @@ import { useRequiredProtocolHardwareFromAnalysis, useMissingProtocolHardwareFromAnalysis, } from '/app/transformations/commands' +import { useLPCFlows, LPCFlows } from '/app/organisms/LabwarePositionCheck' import type { Dispatch, SetStateAction } from 'react' import type { Run } from '@opentrons/api-client' @@ -658,7 +659,7 @@ export function ProtocolSetup(): JSX.Element { const { runId } = useParams< keyof OnDeviceRouteParams >() as OnDeviceRouteParams - const isNewLpc = useFeatureFlag('lpcRedesign') + const isNewLPC = useFeatureFlag('lpcRedesign') const { data: runRecord } = useNotifyRunQuery(runId, { staleTime: Infinity }) const { analysisErrors } = useProtocolAnalysisErrors(runId) const { t } = useTranslation(['protocol_setup']) @@ -741,6 +742,11 @@ export function ProtocolSetup(): JSX.Element { robotType, protocolName ) + const { launchLPC, showLPC, lpcProps } = useLPCFlows({ + runId, + robotType, + protocolName, + }) const { trackProtocolRunEvent } = useTrackProtocolRunEvent(runId, robotName) const robotAnalyticsData = useRobotAnalyticsData(robotName) @@ -824,11 +830,18 @@ export function ProtocolSetup(): JSX.Element { runId={runId} setSetupScreen={setSetupScreen} lpcDisabledReason={lpcDisabledReason} - launchLPC={launchLegacyLPC} - LPCWizard={LegacyLPCWizard} + launchLPC={isNewLPC ? launchLPC : launchLegacyLPC} + LPCWizard={ + isNewLPC ? ( + showLPC ? ( + + ) : null + ) : ( + LegacyLPCWizard + ) + } isConfirmed={offsetsConfirmed} setIsConfirmed={setOffsetsConfirmed} - isNewLpc={isNewLpc} /> ), labware: ( diff --git a/app/src/pages/ODD/RobotSettingsDashboard/RobotSettingsList.tsx b/app/src/pages/ODD/RobotSettingsDashboard/RobotSettingsList.tsx index 6dd08c20823..2a878cf7e16 100644 --- a/app/src/pages/ODD/RobotSettingsDashboard/RobotSettingsList.tsx +++ b/app/src/pages/ODD/RobotSettingsDashboard/RobotSettingsList.tsx @@ -61,7 +61,7 @@ export function RobotSettingsList(props: RobotSettingsListProps): JSX.Element { 'app_settings', 'branded', ]) - const isNewLpc = useFeatureFlag('lpcRedesign') + const isNewLPC = useFeatureFlag('lpcRedesign') const dispatch = useDispatch() const localRobot = useSelector(getLocalRobot) const robotName = localRobot?.name != null ? localRobot.name : 'no name' @@ -186,7 +186,7 @@ export function RobotSettingsList(props: RobotSettingsListProps): JSX.Element { }} iconName="privacy" /> - {!isNewLpc && ( + {!isNewLPC && ( ({ + type: PROCEED_STEP, + payload: { runId }, +}) +export const setInitialPosition = ( + runId: string, + params: PositionParams +): InitialPositionAction => ({ + type: SET_INITIAL_POSITION, + payload: { ...params, runId }, +}) + +export const setFinalPosition = ( + runId: string, + params: PositionParams +): FinalPositionAction => ({ + type: SET_FINAL_POSITION, + payload: { ...params, runId }, +}) + +export const startLPC = ( + runId: string, + state: LPCWizardState +): StartLPCAction => ({ + type: START_LPC, + payload: { runId, state }, +}) + +export const closeLPC = (runId: string): FinishLPCAction => ({ + type: FINISH_LPC, + payload: { runId }, +}) diff --git a/app/src/redux/protocol-runs/actions.ts b/app/src/redux/protocol-runs/actions/setup.ts similarity index 87% rename from app/src/redux/protocol-runs/actions.ts rename to app/src/redux/protocol-runs/actions/setup.ts index 378ee297ed2..86a8397736b 100644 --- a/app/src/redux/protocol-runs/actions.ts +++ b/app/src/redux/protocol-runs/actions/setup.ts @@ -1,5 +1,5 @@ -import * as Constants from './constants' -import type * as Types from './types' +import * as Constants from '../constants' +import type * as Types from '../types' export const updateRunSetupStepsComplete = ( runId: string, diff --git a/app/src/redux/protocol-runs/constants/index.ts b/app/src/redux/protocol-runs/constants/index.ts new file mode 100644 index 00000000000..9571602c772 --- /dev/null +++ b/app/src/redux/protocol-runs/constants/index.ts @@ -0,0 +1,2 @@ +export * from './setup' +export * from './lpc' diff --git a/app/src/redux/protocol-runs/constants/lpc.ts b/app/src/redux/protocol-runs/constants/lpc.ts new file mode 100644 index 00000000000..669c8ec503a --- /dev/null +++ b/app/src/redux/protocol-runs/constants/lpc.ts @@ -0,0 +1,5 @@ +export const START_LPC = 'START_LPC' +export const FINISH_LPC = 'FINISH_LPC' +export const PROCEED_STEP = 'PROCEED_STEP' +export const SET_INITIAL_POSITION = 'SET_INITIAL_POSITION' +export const SET_FINAL_POSITION = 'SET_FINAL_POSITION' diff --git a/app/src/redux/protocol-runs/constants.ts b/app/src/redux/protocol-runs/constants/setup.ts similarity index 100% rename from app/src/redux/protocol-runs/constants.ts rename to app/src/redux/protocol-runs/constants/setup.ts diff --git a/app/src/redux/protocol-runs/reducer.ts b/app/src/redux/protocol-runs/reducer.ts deleted file mode 100644 index 0b2d8378a67..00000000000 --- a/app/src/redux/protocol-runs/reducer.ts +++ /dev/null @@ -1,63 +0,0 @@ -import * as Constants from './constants' - -import type { Reducer } from 'redux' -import type { Action } from '../types' - -import type { ProtocolRunState, RunSetupStatus } from './types' - -const INITIAL_STATE: ProtocolRunState = {} - -const INITIAL_SETUP_STEP_STATE = { complete: false, required: true } - -const INITIAL_RUN_SETUP_STATE: RunSetupStatus = { - [Constants.ROBOT_CALIBRATION_STEP_KEY]: INITIAL_SETUP_STEP_STATE, - [Constants.MODULE_SETUP_STEP_KEY]: INITIAL_SETUP_STEP_STATE, - [Constants.LPC_STEP_KEY]: INITIAL_SETUP_STEP_STATE, - [Constants.LABWARE_SETUP_STEP_KEY]: INITIAL_SETUP_STEP_STATE, - [Constants.LIQUID_SETUP_STEP_KEY]: INITIAL_SETUP_STEP_STATE, -} - -export const protocolRunReducer: Reducer = ( - state = INITIAL_STATE, - action -) => { - switch (action.type) { - case Constants.UPDATE_RUN_SETUP_STEPS_COMPLETE: { - return { - ...state, - [action.payload.runId]: { - setup: Constants.SETUP_STEP_KEYS.reduce( - (currentState, step) => ({ - ...currentState, - [step]: { - complete: - action.payload.complete[step] ?? currentState[step].complete, - required: currentState[step].required, - }, - }), - state[action.payload.runId]?.setup ?? INITIAL_RUN_SETUP_STATE - ), - }, - } - } - case Constants.UPDATE_RUN_SETUP_STEPS_REQUIRED: { - return { - ...state, - [action.payload.runId]: { - setup: Constants.SETUP_STEP_KEYS.reduce( - (currentState, step) => ({ - ...currentState, - [step]: { - required: - action.payload.required[step] ?? currentState[step].required, - complete: currentState[step].complete, - }, - }), - state[action.payload.runId]?.setup ?? INITIAL_RUN_SETUP_STATE - ), - }, - } - } - } - return state -} diff --git a/app/src/redux/protocol-runs/reducer/index.ts b/app/src/redux/protocol-runs/reducer/index.ts new file mode 100644 index 00000000000..48facfbe8d1 --- /dev/null +++ b/app/src/redux/protocol-runs/reducer/index.ts @@ -0,0 +1,53 @@ +import * as Constants from '../constants' +import { LPCReducer } from './lpc' + +import type { Reducer } from 'redux' + +import type { Action } from '../../types' +import type { ProtocolRunState } from '../types' + +import { setupReducer } from './setup' + +const INITIAL_STATE: ProtocolRunState = {} + +export const protocolRunReducer: Reducer = ( + state = INITIAL_STATE, + action +) => { + switch (action.type) { + case Constants.UPDATE_RUN_SETUP_STEPS_COMPLETE: + case Constants.UPDATE_RUN_SETUP_STEPS_REQUIRED: { + const runId = action.payload.runId + const currentRunState = state[runId] + + return { + ...state, + [runId]: { + ...currentRunState, + setup: setupReducer(currentRunState?.setup, action), + }, + } + } + + case Constants.START_LPC: + case Constants.FINISH_LPC: + case Constants.PROCEED_STEP: + case Constants.SET_INITIAL_POSITION: + case Constants.SET_FINAL_POSITION: { + const runId = action.payload.runId + const currentRunState = state[runId] || { lpc: undefined } + const nextLpcState = LPCReducer(currentRunState.lpc, action) + + return { + ...state, + [runId]: { + ...currentRunState, + lpc: nextLpcState, + }, + } + } + + default: + return state + } +} diff --git a/app/src/redux/protocol-runs/reducer/lpc.ts b/app/src/redux/protocol-runs/reducer/lpc.ts new file mode 100644 index 00000000000..ca27fac6273 --- /dev/null +++ b/app/src/redux/protocol-runs/reducer/lpc.ts @@ -0,0 +1,60 @@ +import { + PROCEED_STEP, + SET_INITIAL_POSITION, + SET_FINAL_POSITION, + FINISH_LPC, + START_LPC, +} from '../constants' +import { updateWorkingOffset } from './transforms' + +import type { LPCWizardAction, LPCWizardState } from '../types' + +// TODO(jh, 01-17-25): A lot of this state should live above the LPC slice, in the general protocolRuns slice instead. +export function LPCReducer( + state: LPCWizardState | undefined, + action: LPCWizardAction +): LPCWizardState | undefined { + if (action.type === START_LPC) { + return action.payload.state + } else if (state == null) { + return undefined + } else { + switch (action.type) { + case PROCEED_STEP: { + const { currentStepIndex, totalStepCount } = state.steps + const newStepIdx = + currentStepIndex + 1 < totalStepCount + ? currentStepIndex + 1 + : currentStepIndex + + const nextStepIdx = + newStepIdx + 1 < totalStepCount ? newStepIdx + 1 : null + const nextStep = + nextStepIdx != null ? state.steps.all[nextStepIdx] : null + + return { + ...state, + steps: { + ...state.steps, + currentStepIndex: newStepIdx, + current: state.steps.all[newStepIdx], + next: nextStep, + }, + } + } + + case SET_INITIAL_POSITION: + case SET_FINAL_POSITION: + return { + ...state, + workingOffsets: updateWorkingOffset(state.workingOffsets, action), + } + + case FINISH_LPC: + return undefined + + default: + return state + } + } +} diff --git a/app/src/redux/protocol-runs/reducer/setup.ts b/app/src/redux/protocol-runs/reducer/setup.ts new file mode 100644 index 00000000000..6cac26dc22f --- /dev/null +++ b/app/src/redux/protocol-runs/reducer/setup.ts @@ -0,0 +1,48 @@ +import * as Constants from '../constants' +import type { RunSetupStatus, ProtocolRunAction } from '../types' + +const INITIAL_SETUP_STEP_STATE = { complete: false, required: true } + +export const INITIAL_RUN_SETUP_STATE: RunSetupStatus = { + [Constants.ROBOT_CALIBRATION_STEP_KEY]: INITIAL_SETUP_STEP_STATE, + [Constants.MODULE_SETUP_STEP_KEY]: INITIAL_SETUP_STEP_STATE, + [Constants.LPC_STEP_KEY]: INITIAL_SETUP_STEP_STATE, + [Constants.LABWARE_SETUP_STEP_KEY]: INITIAL_SETUP_STEP_STATE, + [Constants.LIQUID_SETUP_STEP_KEY]: INITIAL_SETUP_STEP_STATE, +} + +export function setupReducer( + state: RunSetupStatus = INITIAL_RUN_SETUP_STATE, + action: ProtocolRunAction +): RunSetupStatus { + switch (action.type) { + case Constants.UPDATE_RUN_SETUP_STEPS_COMPLETE: + return Constants.SETUP_STEP_KEYS.reduce( + (currentState, step) => ({ + ...currentState, + [step]: { + complete: + action.payload.complete[step] ?? currentState[step].complete, + required: currentState[step].required, + }, + }), + state + ) + + case Constants.UPDATE_RUN_SETUP_STEPS_REQUIRED: + return Constants.SETUP_STEP_KEYS.reduce( + (currentState, step) => ({ + ...currentState, + [step]: { + required: + action.payload.required[step] ?? currentState[step].required, + complete: currentState[step].complete, + }, + }), + state + ) + + default: + return state + } +} diff --git a/app/src/redux/protocol-runs/reducer/transforms/index.ts b/app/src/redux/protocol-runs/reducer/transforms/index.ts new file mode 100644 index 00000000000..5c5cf75aeae --- /dev/null +++ b/app/src/redux/protocol-runs/reducer/transforms/index.ts @@ -0,0 +1 @@ +export * from './lpc' diff --git a/app/src/redux/protocol-runs/reducer/transforms/lpc.ts b/app/src/redux/protocol-runs/reducer/transforms/lpc.ts new file mode 100644 index 00000000000..3d08fadee62 --- /dev/null +++ b/app/src/redux/protocol-runs/reducer/transforms/lpc.ts @@ -0,0 +1,47 @@ +import isEqual from 'lodash/isEqual' + +import type { LPCWizardAction, WorkingOffset } from '../../types' + +export function updateWorkingOffset( + workingOffsets: WorkingOffset[], + action: Extract< + LPCWizardAction, + { type: 'SET_INITIAL_POSITION' | 'SET_FINAL_POSITION' } + > +): WorkingOffset[] { + const { type, payload } = action + const { labwareId, location, position } = payload + const existingRecordIndex = workingOffsets.findIndex( + record => + record.labwareId === labwareId && isEqual(record.location, location) + ) + + if (existingRecordIndex < 0) { + return [ + ...workingOffsets, + { + labwareId, + location, + initialPosition: type === 'SET_INITIAL_POSITION' ? position : null, + finalPosition: type === 'SET_FINAL_POSITION' ? position : null, + }, + ] + } else { + const updatedOffset = { + ...workingOffsets[existingRecordIndex], + ...(type === 'SET_INITIAL_POSITION' && { + initialPosition: position, + finalPosition: null, + }), + ...(type === 'SET_FINAL_POSITION' && { + finalPosition: position, + }), + } + + return [ + ...workingOffsets.slice(0, existingRecordIndex), + updatedOffset, + ...workingOffsets.slice(existingRecordIndex + 1), + ] + } +} diff --git a/app/src/redux/protocol-runs/selectors/index.ts b/app/src/redux/protocol-runs/selectors/index.ts new file mode 100644 index 00000000000..9571602c772 --- /dev/null +++ b/app/src/redux/protocol-runs/selectors/index.ts @@ -0,0 +1,2 @@ +export * from './setup' +export * from './lpc' diff --git a/app/src/redux/protocol-runs/selectors/lpc/index.ts b/app/src/redux/protocol-runs/selectors/lpc/index.ts new file mode 100644 index 00000000000..5bd4a518ac2 --- /dev/null +++ b/app/src/redux/protocol-runs/selectors/lpc/index.ts @@ -0,0 +1,2 @@ +export * from './labware' +export * from './pipettes' diff --git a/app/src/redux/protocol-runs/selectors/lpc/labware.ts b/app/src/redux/protocol-runs/selectors/lpc/labware.ts new file mode 100644 index 00000000000..afce4ae46e1 --- /dev/null +++ b/app/src/redux/protocol-runs/selectors/lpc/labware.ts @@ -0,0 +1,251 @@ +import { createSelector } from 'reselect' +import isEqual from 'lodash/isEqual' + +import { + getIsTiprack, + getLabwareDisplayName, + getLabwareDefURI, + getVectorSum, + getVectorDifference, + IDENTITY_VECTOR, +} from '@opentrons/shared-data' + +import { getCurrentOffsetForLabwareInLocation } from '/app/transformations/analysis' +import { getItemLabwareDef } from './transforms' + +import type { Selector } from 'reselect' +import type { VectorOffset, LabwareOffsetLocation } from '@opentrons/api-client' +import type { LabwareDefinition2, Coordinates } from '@opentrons/shared-data' +import type { State } from '../../../types' + +// TODO(jh, 01-16-25): Revisit once LPC `step` refactors are completed. +// eslint-disable-next-line opentrons/no-imports-across-applications +import type { LabwarePositionCheckStep } from '/app/organisms/LabwarePositionCheck/types' + +// TODO(jh, 01-13-25): Remove the explicit type casting after restructuring "step". +// TODO(jh, 01-17-25): As LPC selectors become finalized, wrap them in createSelector. + +export const selectActiveLwInitialPosition = ( + step: LabwarePositionCheckStep | null, + runId: string, + state: State +): VectorOffset | null => { + const { workingOffsets } = state.protocolRuns[runId]?.lpc ?? {} + + if (step != null && workingOffsets != null) { + const labwareId = 'labwareId' in step ? step.labwareId : '' + const location = 'location' in step ? step.location : '' + + return ( + workingOffsets.find( + o => + o.labwareId === labwareId && + isEqual(o.location, location) && + o.initialPosition != null + )?.initialPosition ?? null + ) + } else { + if (workingOffsets == null) { + console.warn('LPC state not initalized before selector use.') + } + + return null + } +} + +export const selectActiveLwExistingOffset = ( + runId: string, + state: State +): VectorOffset => { + const { existingOffsets, steps } = state.protocolRuns[runId]?.lpc ?? {} + + if (existingOffsets == null || steps == null) { + console.warn('LPC state not initalized before selector use.') + return IDENTITY_VECTOR + } else if ( + !('labwareId' in steps.current) || + !('location' in steps.current) || + !('slotName' in steps.current.location) + ) { + console.warn( + `No labwareId or location in current step: ${steps.current.section}` + ) + return IDENTITY_VECTOR + } else { + const lwUri = getLabwareDefURI( + getItemLabwareDefFrom(runId, state) as LabwareDefinition2 + ) + + return ( + getCurrentOffsetForLabwareInLocation( + existingOffsets, + lwUri, + steps.current.location + )?.vector ?? IDENTITY_VECTOR + ) + } +} + +export interface SelectOffsetsToApplyResult { + definitionUri: string + location: LabwareOffsetLocation + vector: Coordinates +} + +export const selectOffsetsToApply = ( + runId: string +): Selector => + createSelector( + (state: State) => state.protocolRuns[runId]?.lpc?.workingOffsets, + (state: State) => state.protocolRuns[runId]?.lpc?.protocolData, + (state: State) => state.protocolRuns[runId]?.lpc?.existingOffsets, + (workingOffsets, protocolData, existingOffsets) => { + if ( + workingOffsets == null || + protocolData == null || + existingOffsets == null + ) { + console.warn('LPC state not initalized before selector use.') + return [] + } + + return workingOffsets.map( + ({ initialPosition, finalPosition, labwareId, location }) => { + const definitionUri = + protocolData.labware.find(l => l.id === labwareId)?.definitionUri ?? + null + + if ( + finalPosition == null || + initialPosition == null || + definitionUri == null + ) { + throw new Error( + `cannot create offset for labware with id ${labwareId}, in location ${JSON.stringify( + location + )}, with initial position ${String( + initialPosition + )}, and final position ${String(finalPosition)}` + ) + } else { + const existingOffset = + getCurrentOffsetForLabwareInLocation( + existingOffsets, + definitionUri, + location + )?.vector ?? IDENTITY_VECTOR + const vector = getVectorSum( + existingOffset, + getVectorDifference(finalPosition, initialPosition) + ) + return { definitionUri, location, vector } + } + } + ) + } + ) + +export const selectIsActiveLwTipRack = ( + runId: string, + state: State +): boolean => { + const { current } = state.protocolRuns[runId]?.lpc?.steps ?? {} + + if (current != null && 'labwareId' in current) { + return getIsTiprack( + getItemLabwareDefFrom(runId, state) as LabwareDefinition2 + ) + } else { + console.warn( + 'No labwareId in step or LPC state not initalized before selector use.' + ) + return false + } +} + +export const selectLwDisplayName = (runId: string, state: State): string => { + const { current } = state.protocolRuns[runId]?.lpc?.steps ?? {} + + if (current != null && 'labwareId' in current) { + return getLabwareDisplayName( + getItemLabwareDefFrom(runId, state) as LabwareDefinition2 + ) + } else { + console.warn( + 'No labwareId in step or LPC state not initalized before selector use.' + ) + return '' + } +} + +export const selectActiveAdapterDisplayName = ( + runId: string, + state: State +): string => { + const { protocolData, labwareDefs, steps } = + state.protocolRuns[runId]?.lpc ?? {} + + if (protocolData == null || labwareDefs == null || steps == null) { + console.warn('LPC state not initialized before selector use.') + return '' + } + + return 'adapterId' in steps.current && steps.current.adapterId != null + ? getItemLabwareDef({ + labwareId: steps.current.adapterId, + loadedLabware: protocolData.labware, + labwareDefs, + })?.metadata.displayName ?? '' + : '' +} + +export const selectItemLabwareDef = ( + runId: string +): Selector => + createSelector( + (state: State) => state.protocolRuns[runId]?.lpc?.steps.current, + (state: State) => state.protocolRuns[runId]?.lpc?.labwareDefs, + (state: State) => state.protocolRuns[runId]?.lpc?.protocolData.labware, + (current, labwareDefs, loadedLabware) => { + const labwareId = + current != null && 'labwareId' in current ? current.labwareId : '' + + if (labwareId === '' || labwareDefs == null || loadedLabware == null) { + console.warn( + `No labwareId associated with step: ${current?.section} or LPC state not initialized before selector use.` + ) + return null + } + + return getItemLabwareDef({ + labwareId, + labwareDefs, + loadedLabware, + }) + } + ) + +const getItemLabwareDefFrom = ( + runId: string, + state: State +): LabwareDefinition2 | null => { + const current = state.protocolRuns[runId]?.lpc?.steps.current + const labwareDefs = state.protocolRuns[runId]?.lpc?.labwareDefs + const loadedLabware = state.protocolRuns[runId]?.lpc?.protocolData.labware + + const labwareId = + current != null && 'labwareId' in current ? current.labwareId : '' + + if (labwareId === '' || labwareDefs == null || loadedLabware == null) { + console.warn( + `No labwareId associated with step: ${current?.section} or LPC state not initialized before selector use.` + ) + return null + } + + return getItemLabwareDef({ + labwareId, + labwareDefs, + loadedLabware, + }) +} diff --git a/app/src/redux/protocol-runs/selectors/lpc/pipettes.ts b/app/src/redux/protocol-runs/selectors/lpc/pipettes.ts new file mode 100644 index 00000000000..1070e80946e --- /dev/null +++ b/app/src/redux/protocol-runs/selectors/lpc/pipettes.ts @@ -0,0 +1,39 @@ +import { getPipetteNameSpecs } from '@opentrons/shared-data' + +import type { LoadedPipette, PipetteChannels } from '@opentrons/shared-data' + +// TODO(jh, 01-16-25): Revisit once LPC `step` refactors are completed. +// eslint-disable-next-line opentrons/no-imports-across-applications +import type { LabwarePositionCheckStep } from '/app/organisms/LabwarePositionCheck/types' +import type { State } from '../../../types' + +export const selectActivePipette = ( + step: LabwarePositionCheckStep, + runId: string, + state: State +): LoadedPipette | null => { + const { protocolData } = state.protocolRuns[runId]?.lpc ?? {} + const pipetteId = 'pipetteId' in step ? step.pipetteId : '' + + if (pipetteId === '') { + console.warn(`No matching pipette found for pipetteId ${pipetteId}`) + } else if (protocolData == null) { + console.warn('LPC state not initalized before selector use.') + } + + return ( + protocolData?.pipettes.find(pipette => pipette.id === pipetteId) ?? null + ) +} + +export const selectActivePipetteChannelCount = ( + step: LabwarePositionCheckStep, + runId: string, + state: State +): PipetteChannels => { + const pipetteName = selectActivePipette(step, runId, state)?.pipetteName + + return pipetteName != null + ? getPipetteNameSpecs(pipetteName)?.channels ?? 1 + : 1 +} diff --git a/app/src/redux/protocol-runs/selectors/lpc/transforms.ts b/app/src/redux/protocol-runs/selectors/lpc/transforms.ts new file mode 100644 index 00000000000..780e5336133 --- /dev/null +++ b/app/src/redux/protocol-runs/selectors/lpc/transforms.ts @@ -0,0 +1,29 @@ +import { getLabwareDefURI } from '@opentrons/shared-data' + +import type { + CompletedProtocolAnalysis, + LabwareDefinition2, +} from '@opentrons/shared-data' + +interface GetLabwareDefsForLPCParams { + labwareId: string + loadedLabware: CompletedProtocolAnalysis['labware'] + labwareDefs: LabwareDefinition2[] +} + +export function getItemLabwareDef({ + labwareId, + loadedLabware, + labwareDefs, +}: GetLabwareDefsForLPCParams): LabwareDefinition2 | null { + const labwareDefUri = + loadedLabware.find(l => l.id === labwareId)?.definitionUri ?? null + + if (labwareDefUri == null) { + console.warn(`Null labware def found for labwareId: ${labwareId}`) + } + + return ( + labwareDefs.find(def => getLabwareDefURI(def) === labwareDefUri) ?? null + ) +} diff --git a/app/src/redux/protocol-runs/selectors.ts b/app/src/redux/protocol-runs/selectors/setup.ts similarity index 96% rename from app/src/redux/protocol-runs/selectors.ts rename to app/src/redux/protocol-runs/selectors/setup.ts index 14149b603bb..0be0168e048 100644 --- a/app/src/redux/protocol-runs/selectors.ts +++ b/app/src/redux/protocol-runs/selectors/setup.ts @@ -1,5 +1,5 @@ -import type { State } from '../types' -import type * as Types from './types' +import type { State } from '../../types' +import type * as Types from '../types' export const getSetupStepComplete: ( state: State, diff --git a/app/src/redux/protocol-runs/types/index.ts b/app/src/redux/protocol-runs/types/index.ts new file mode 100644 index 00000000000..93b2aab9451 --- /dev/null +++ b/app/src/redux/protocol-runs/types/index.ts @@ -0,0 +1,16 @@ +import type { RunSetupStatus, RunSetupStepsAction } from './setup' +import type { LPCWizardAction, LPCWizardState } from './lpc' + +export * from './setup' +export * from './lpc' + +export interface PerRunUIState { + setup: RunSetupStatus + lpc?: LPCWizardState +} + +export interface ProtocolRunState { + readonly [runId: string]: PerRunUIState +} + +export type ProtocolRunAction = RunSetupStepsAction | LPCWizardAction diff --git a/app/src/redux/protocol-runs/types/lpc.ts b/app/src/redux/protocol-runs/types/lpc.ts new file mode 100644 index 00000000000..aec42e6eb98 --- /dev/null +++ b/app/src/redux/protocol-runs/types/lpc.ts @@ -0,0 +1,70 @@ +import type { + DeckConfiguration, + LabwareDefinition2, + CompletedProtocolAnalysis, +} from '@opentrons/shared-data' +import type { + LabwareOffsetLocation, + VectorOffset, + LabwareOffset, +} from '@opentrons/api-client' + +// TODO(jh, 01-16-25): Make sure there's no cross importing after `steps` is refactored. +// eslint-disable-next-line opentrons/no-imports-across-applications +import type { StepsInfo } from '/app/organisms/LabwarePositionCheck/redux/types' + +export interface PositionParams { + labwareId: string + location: LabwareOffsetLocation + position: VectorOffset | null +} + +export interface WorkingOffset { + labwareId: string + location: LabwareOffsetLocation + initialPosition: VectorOffset | null + finalPosition: VectorOffset | null +} + +export interface LPCWizardState { + workingOffsets: WorkingOffset[] + protocolData: CompletedProtocolAnalysis + labwareDefs: LabwareDefinition2[] + deckConfig: DeckConfiguration + steps: StepsInfo + existingOffsets: LabwareOffset[] + protocolName: string + maintenanceRunId: string +} + +export interface StartLPCAction { + type: 'START_LPC' + payload: { runId: string; state: LPCWizardState } +} + +export interface FinishLPCAction { + type: 'FINISH_LPC' + payload: { runId: string } +} + +export interface ProceedStepAction { + type: 'PROCEED_STEP' + payload: { runId: string } +} + +export interface InitialPositionAction { + type: 'SET_INITIAL_POSITION' + payload: PositionParams & { runId: string } +} + +export interface FinalPositionAction { + type: 'SET_FINAL_POSITION' + payload: PositionParams & { runId: string } +} + +export type LPCWizardAction = + | StartLPCAction + | FinishLPCAction + | InitialPositionAction + | FinalPositionAction + | ProceedStepAction diff --git a/app/src/redux/protocol-runs/types.ts b/app/src/redux/protocol-runs/types/setup.ts similarity index 86% rename from app/src/redux/protocol-runs/types.ts rename to app/src/redux/protocol-runs/types/setup.ts index c14d556d495..72185a9a096 100644 --- a/app/src/redux/protocol-runs/types.ts +++ b/app/src/redux/protocol-runs/types/setup.ts @@ -6,7 +6,7 @@ import type { LIQUID_SETUP_STEP_KEY, UPDATE_RUN_SETUP_STEPS_COMPLETE, UPDATE_RUN_SETUP_STEPS_REQUIRED, -} from './constants' +} from '../constants' export type RobotCalibrationStepKey = typeof ROBOT_CALIBRATION_STEP_KEY export type ModuleSetupStepKey = typeof MODULE_SETUP_STEP_KEY @@ -32,14 +32,6 @@ export type RunSetupStatus = { [Step in StepKey]: StepState } -export interface PerRunUIState { - setup: RunSetupStatus -} - -export type ProtocolRunState = Partial<{ - readonly [runId: string]: PerRunUIState -}> - export interface UpdateRunSetupStepsCompleteAction { type: typeof UPDATE_RUN_SETUP_STEPS_COMPLETE payload: { @@ -56,6 +48,6 @@ export interface UpdateRunSetupStepsRequiredAction { } } -export type ProtocolRunAction = +export type RunSetupStepsAction = | UpdateRunSetupStepsCompleteAction | UpdateRunSetupStepsRequiredAction diff --git a/react-api-client/src/maintenance_runs/useCreateMaintenanceRunLabwareDefinitionMutation.ts b/react-api-client/src/maintenance_runs/useCreateMaintenanceRunLabwareDefinitionMutation.ts index 8b36eb3d100..ac69446d593 100644 --- a/react-api-client/src/maintenance_runs/useCreateMaintenanceRunLabwareDefinitionMutation.ts +++ b/react-api-client/src/maintenance_runs/useCreateMaintenanceRunLabwareDefinitionMutation.ts @@ -10,11 +10,14 @@ import type { LabwareDefinitionSummary, HostConfig, } from '@opentrons/api-client' -import type { LabwareDefinition2 } from '@opentrons/shared-data' +import type { + LabwareDefinition2, + LabwareDefinition3, +} from '@opentrons/shared-data' interface CreateMaintenanceRunLabwareDefinitionMutateParams { maintenanceRunId: string - labwareDef: LabwareDefinition2 + labwareDef: LabwareDefinition2 | LabwareDefinition3 } export type UseCreateLabwareDefinitionMutationResult = UseMutationResult<