From 4c3305ad7704fe9c513b58a9637134a80625a4ae Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Tue, 6 Aug 2024 19:21:42 -0400 Subject: [PATCH] feat(app): Display run setup task completion (#15889) In the run page on both app and ODD, you get an indication of completion when you get all your instruments and modules and deck stuff present and calibrated. But those are just two of the steps presented equally in the run setup page, and the rest of the steps don't get anything similar. It leads people to wonder whether they've set things up properly. This PR adds similar styling and completion semantics for the other tasks in the run setup screen to fix this issue. Specifically, LPC gets a "confirm offsets" button (which will confirm offsets even if you haven't run LPC - makes it more apparent that that's a separate option) and labware and liquids get generic confirm buttons. There's also a couple other visual fixes: - On desktop, the "back to top" button in run setup is now where figma thinks it is, outside the run-setup content area. This allows some refactoring of component props - On desktop, there was an issue with the react-router upgrade (I think - it's also in the latest IR alpha) that means that if you had an ongoing run, you couldn't view anything but run details without getting instantly navigated back to run details This implements this figma: https://www.figma.com/design/Rwdt9R0aERFC55oTLDTlqY/8.0-September-Release-File?node-id=39-35830&t=l6vwJjQsfyVeovfC-4 ## To come out of draft - [x] implement for ODD - [x] rebase onto release - [x] "are you sure" modal on desktop - [x] "are you sure" modal on ODD ## Review requests - This is some pretty complex UI - do you agree with how I've done this? - Some of this is pretty ugly, in large part because this is old code that I'm cleaning up. There's some duplicated logic in the run details and some pretty ugly typing. What I'd like to do is merge this since it implements some features nicely and then follow up with a refactor to get the size of some of these files down and enforce nicer separation between everything. ## Testing - [x] Desktop green checks on flex - [x] Desktop green checks on OT-2 (yes, this has to be different because the steps can be different here) Closes RSQ-7 --- .../localization/en/protocol_setup.json | 20 +- .../ProtocolRun/ConfirmMissingStepsModal.tsx | 65 +++++ .../Devices/ProtocolRun/ProtocolRunHeader.tsx | 35 ++- .../Devices/ProtocolRun/ProtocolRunSetup.tsx | 275 ++++++++++++------ .../__tests__/SetupLabware.test.tsx | 9 +- .../ProtocolRun/SetupLabware/index.tsx | 34 +-- .../SetupLabwarePositionCheck.test.tsx | 7 +- .../SetupLabwarePositionCheck/index.tsx | 44 ++- .../__tests__/SetupLiquids.test.tsx | 43 +-- .../ProtocolRun/SetupLiquids/index.tsx | 26 +- .../__tests__/ProtocolRunHeader.test.tsx | 10 +- .../__tests__/ProtocolRunSetup.test.tsx | 23 +- .../__tests__/ProtocolSetupLabware.test.tsx | 6 + .../organisms/ProtocolSetupLabware/index.tsx | 41 ++- .../__tests__/ProtocolSetupLiquids.test.tsx | 22 +- .../organisms/ProtocolSetupLiquids/index.tsx | 44 ++- .../organisms/ProtocolSetupOffsets/index.tsx | 122 ++++++++ .../Devices/ProtocolRunDetails/index.tsx | 104 +++++-- .../ConfirmSetupStepsCompleteModal.tsx | 68 +++++ .../__tests__/ProtocolSetup.test.tsx | 128 +++++++- app/src/pages/ProtocolSetup/index.tsx | 209 ++++++++++--- 21 files changed, 1054 insertions(+), 281 deletions(-) create mode 100644 app/src/organisms/Devices/ProtocolRun/ConfirmMissingStepsModal.tsx create mode 100644 app/src/organisms/ProtocolSetupOffsets/index.tsx create mode 100644 app/src/pages/ProtocolSetup/ConfirmSetupStepsCompleteModal.tsx diff --git a/app/src/assets/localization/en/protocol_setup.json b/app/src/assets/localization/en/protocol_setup.json index 5217a95dc4c..2eef6a6c15c 100644 --- a/app/src/assets/localization/en/protocol_setup.json +++ b/app/src/assets/localization/en/protocol_setup.json @@ -8,6 +8,8 @@ "add_to_slot": "Add to slot {{slotName}}", "additional_labware": "{{count}} additional labware", "additional_off_deck_labware": "Additional Off-Deck Labware", + "applied_labware_offsets": "applied labware offsets", + "are_you_sure_you_want_to_proceed": "Are you sure you want to proceed to run?", "attach_gripper_failure_reason": "Attach the required gripper to continue", "attach_gripper": "attach gripper", "attach_module": "Attach module before calibrating", @@ -47,6 +49,9 @@ "configured": "configured", "confirm_heater_shaker_module_modal_description": "Before the run begins, module should have both anchors fully extended for a firm attachment. The thermal adapter should be attached to the module. ", "confirm_heater_shaker_module_modal_title": "Confirm Heater-Shaker Module is attached", + "confirm_offsets": "Confirm offsets", + "confirm_liquids": "Confirm liquids", + "confirm_placements": "Confirm placements", "confirm_selection": "Confirm selection", "confirm_values": "Confirm values", "connect_all_hardware": "Connect and calibrate all hardware first", @@ -101,6 +106,7 @@ "labware_latch": "Labware Latch", "labware_location": "Labware Location", "labware_name": "Labware name", + "labware_placement": "labware placement", "labware_position_check_not_available_analyzing_on_robot": "Labware Position Check is not available while protocol is analyzing on robot", "labware_position_check_not_available_empty_protocol": "Labware Position Check requires that the protocol loads labware and pipettes", "labware_position_check_not_available": "Labware Position Check is not available after run has started", @@ -118,11 +124,13 @@ "learn_more": "Learn more", "liquid_information": "Liquid information", "liquid_name": "Liquid name", + "liquids": "liquids", "liquid_setup_step_description": "View liquid starting locations and volumes", "liquid_setup_step_title": "Liquids", "liquids_not_in_setup": "No liquids used in this protocol", "liquids_not_in_the_protocol": "no liquids are specified for this protocol.", - "liquids": "Liquids", + "liquids_ready": "Liquids ready", + "liquids_confirmed": "Liquids confirmed", "list_view": "List View", "loading_data": "Loading data...", "loading_labware_offsets": "Loading labware offsets", @@ -149,6 +157,7 @@ "module_name": "Module", "module_not_connected": "Not connected", "module_setup_step_title": "Deck hardware", + "module_setup_step_ready": "Calibration ready", "module_slot_location": "Slot {{slotName}}, {{moduleName}}", "module": "Module", "modules_connected_plural": "{{count}} modules attached", @@ -191,6 +200,7 @@ "offset_data": "Offset Data", "offsets_applied_plural": "{{count}} offsets applied", "offsets_applied": "{{count}} offset applied", + "offsets_ready": "Offsets ready", "on_adapter_in_mod": "on {{adapterName}} in {{moduleName}}", "on_adapter": "on {{adapterName}}", "on_deck": "On deck", @@ -206,6 +216,8 @@ "pipette_offset_cal_description": "This measures a pipette’s X, Y and Z values in relation to the pipette mount and the deck. Pipette Offset Calibration relies on Deck Calibration and Tip Length Calibration. ", "pipette_offset_cal": "Pipette Offset Calibration", "placement": "Placement", + "placements_ready": "Placements ready", + "placements_confirmed": "Placements confirmed", "plug_in_module_to_configure": "Plug in a {{module}} to add it to the slot", "plug_in_required_module_plural": "Plug in and power up the required modules to continue", "plug_in_required_module": "Plug in and power up the required module to continue", @@ -246,6 +258,7 @@ "robot_calibration_step_description_pipettes_only": "Review required instruments and calibrations for this protocol.", "robot_calibration_step_description": "Review required pipettes and tip length calibrations for this protocol.", "robot_calibration_step_title": "Instruments", + "robot_calibration_step_ready": "Calibration ready", "run_disabled_calibration_not_complete": "Make sure robot calibration is complete before proceeding to run", "run_disabled_modules_and_calibration_not_complete": "Make sure robot calibration is complete and all modules are connected before proceeding to run", "run_disabled_modules_not_connected": "Make sure all modules are connected before proceeding to run", @@ -260,6 +273,7 @@ "setup_is_view_only": "Setup is view-only once run has started", "slot_location": "Slot {{slotName}}", "slot_number": "Slot Number", + "start_run": "Start run", "status": "Status", "step": "STEP {{index}}", "there_are_no_unconfigured_modules": "No {{module}} is connected. Attach one and place it in {{slot}}.", @@ -271,6 +285,7 @@ "total_liquid_volume": "Total volume", "update_deck_config": "Update deck configuration", "update_deck": "Update deck", + "update_offsets": "Update offsets", "updated": "Updated", "usb_connected_no_port_info": "USB Port Connected", "usb_drive_notification": "Leave USB drive attached until run starts", @@ -286,5 +301,6 @@ "view_setup_instructions": "View setup instructions", "volume": "Volume", "what_labware_offset_is": "A Labware Offset is a type of positional adjustment that accounts for small, real-world variances in the overall position of the labware on a robot’s deck. Labware Offset data is unique to a specific combination of labware definition, deck slot, and robot.", - "with_the_chosen_value": "With the chosen values, the following error occurred:" + "with_the_chosen_value": "With the chosen values, the following error occurred:", + "you_havent_confirmed": "You haven't confirmed the {{missingSteps}} yet. Ensure these are correct before proceeding to run the protocol." } diff --git a/app/src/organisms/Devices/ProtocolRun/ConfirmMissingStepsModal.tsx b/app/src/organisms/Devices/ProtocolRun/ConfirmMissingStepsModal.tsx new file mode 100644 index 00000000000..549bc8f08b0 --- /dev/null +++ b/app/src/organisms/Devices/ProtocolRun/ConfirmMissingStepsModal.tsx @@ -0,0 +1,65 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { + ALIGN_CENTER, + DIRECTION_COLUMN, + DIRECTION_ROW, + Flex, + JUSTIFY_FLEX_END, + PrimaryButton, + SecondaryButton, + SPACING, + LegacyStyledText, + TYPOGRAPHY, +} from '@opentrons/components' +import { LegacyModal } from '../../../molecules/LegacyModal' + +interface ConfirmMissingStepsModalProps { + onCloseClick: () => void + onConfirmClick: () => void + missingSteps: string[] +} +export const ConfirmMissingStepsModal = ( + props: ConfirmMissingStepsModalProps +): JSX.Element | null => { + const { missingSteps, onCloseClick, onConfirmClick } = props + const { t, i18n } = useTranslation(['protocol_setup', 'shared']) + + const confirmAttached = (): void => { + onConfirmClick() + onCloseClick() + } + + return ( + + + + {t('you_havent_confirmed', { + missingSteps: new Intl.ListFormat('en', { + style: 'short', + type: 'conjunction', + }).format(missingSteps.map(step => t(step))), + })} + + + + + {i18n.format(t('shared:go_back'), 'capitalize')} + + + {t('start_run')} + + + + ) +} diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx index 6a704c96699..5d9821cc5a6 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx @@ -77,6 +77,7 @@ import { } from '../../../organisms/RunTimeControl/hooks' import { useIsHeaterShakerInProtocol } from '../../ModuleCard/hooks' import { ConfirmAttachmentModal } from '../../ModuleCard/ConfirmAttachmentModal' +import { ConfirmMissingStepsModal } from './ConfirmMissingStepsModal' import { useProtocolDetailsForRun, useProtocolAnalysisErrors, @@ -132,6 +133,7 @@ interface ProtocolRunHeaderProps { robotName: string runId: string makeHandleJumpToStep: (index: number) => () => void + missingSetupSteps: string[] } export function ProtocolRunHeader({ @@ -139,6 +141,7 @@ export function ProtocolRunHeader({ robotName, runId, makeHandleJumpToStep, + missingSetupSteps, }: ProtocolRunHeaderProps): JSX.Element | null { const { t } = useTranslation(['run_details', 'shared']) const navigate = useNavigate() @@ -447,6 +450,7 @@ export function ProtocolRunHeader({ isDoorOpen={isDoorOpen} isFixtureMismatch={isFixtureMismatch} isResetRunLoadingRef={isResetRunLoadingRef} + missingSetupSteps={missingSetupSteps} /> @@ -591,6 +595,7 @@ interface ActionButtonProps { isDoorOpen: boolean isFixtureMismatch: boolean isResetRunLoadingRef: React.MutableRefObject + missingSetupSteps: string[] } // TODO(jh, 04-22-2024): Refactor switch cases into separate factories to increase readability and testability. @@ -603,6 +608,7 @@ function ActionButton(props: ActionButtonProps): JSX.Element { isDoorOpen, isFixtureMismatch, isResetRunLoadingRef, + missingSetupSteps, } = props const navigate = useNavigate() const { t } = useTranslation(['run_details', 'shared']) @@ -682,12 +688,20 @@ function ActionButton(props: ActionButtonProps): JSX.Element { ) const { confirm: confirmAttachment, - showConfirmation: showConfirmationModal, - cancel: cancelExit, + showConfirmation: showHSConfirmationModal, + cancel: cancelExitHSConfirmation, } = useConditionalConfirm( handleProceedToRunClick, !configBypassHeaterShakerAttachmentConfirmation ) + const { + confirm: confirmMissingSteps, + showConfirmation: showMissingStepsConfirmationModal, + cancel: cancelExitMissingStepsConfirmation, + } = useConditionalConfirm( + handleProceedToRunClick, + missingSetupSteps.length !== 0 + ) const robotAnalyticsData = useRobotAnalyticsData(robotName) const isHeaterShakerInProtocol = useIsHeaterShakerInProtocol() @@ -745,6 +759,11 @@ function ActionButton(props: ActionButtonProps): JSX.Element { handleButtonClick = () => { if (isHeaterShakerShaking && isHeaterShakerInProtocol) { setShowIsShakingModal(true) + } else if ( + missingSetupSteps.length !== 0 && + (runStatus === RUN_STATUS_IDLE || runStatus === RUN_STATUS_STOPPED) + ) { + confirmMissingSteps() } else if ( isHeaterShakerInProtocol && !isHeaterShakerShaking && @@ -825,13 +844,21 @@ function ActionButton(props: ActionButtonProps): JSX.Element { startRun={play} /> )} - {showConfirmationModal && ( + {showHSConfirmationModal && ( )} + {showMissingStepsConfirmationModal && ( + + )} + {} ) } diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx index 19c29827c15..7ea1386768d 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx @@ -16,6 +16,7 @@ import { SPACING, LegacyStyledText, TYPOGRAPHY, + FLEX_MAX_CONTENT, } from '@opentrons/components' import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' @@ -48,8 +49,6 @@ import { SetupLiquids } from './SetupLiquids' import { EmptySetupStep } from './EmptySetupStep' import { HowLPCWorksModal } from './SetupLabwarePositionCheck/HowLPCWorksModal' -import type { ProtocolCalibrationStatus } from '../hooks' - const ROBOT_CALIBRATION_STEP_KEY = 'robot_calibration_step' as const const MODULE_SETUP_KEY = 'module_setup_step' as const const LPC_KEY = 'labware_position_check_step' as const @@ -63,16 +62,33 @@ export type StepKey = | typeof LABWARE_SETUP_KEY | typeof LIQUID_SETUP_KEY +export type MissingStep = + | 'applied_labware_offsets' + | 'labware_placement' + | 'liquids' + +export type MissingSteps = MissingStep[] + +export const initialMissingSteps = (): MissingSteps => [ + 'applied_labware_offsets', + 'labware_placement', + 'liquids', +] + interface ProtocolRunSetupProps { protocolRunHeaderRef: React.RefObject | null robotName: string runId: string + setMissingSteps: (missingSteps: MissingSteps) => void + missingSteps: MissingSteps } export function ProtocolRunSetup({ protocolRunHeaderRef, robotName, runId, + setMissingSteps, + missingSteps, }: ProtocolRunSetupProps): JSX.Element | null { const { t, i18n } = useTranslation('protocol_setup') const robotProtocolAnalysis = useMostRecentCompletedAnalysis(runId) @@ -147,6 +163,15 @@ export function ProtocolRunSetup({ return true }) + const [ + labwareSetupComplete, + setLabwareSetupComplete, + ] = React.useState(false) + const [liquidSetupComplete, setLiquidSetupComplete] = React.useState( + false + ) + const [lpcComplete, setLpcComplete] = React.useState(false) + if (robot == null) return null const liquids = protocolAnalysis?.liquids ?? [] @@ -171,7 +196,11 @@ export function ProtocolRunSetup({ const StepDetailMap: Record< StepKey, - { stepInternals: JSX.Element; description: string } + { + stepInternals: JSX.Element + description: string + rightElProps: StepRightElementProps + } > = { [ROBOT_CALIBRATION_STEP_KEY]: { stepInternals: ( @@ -193,6 +222,15 @@ export function ProtocolRunSetup({ description: isFlex ? t(`${ROBOT_CALIBRATION_STEP_KEY}_description_pipettes_only`) : t(`${ROBOT_CALIBRATION_STEP_KEY}_description`), + rightElProps: { + stepKey: ROBOT_CALIBRATION_STEP_KEY, + complete: calibrationStatusRobot.complete, + completeText: t('calibration_ready'), + missingHardware: isMissingPipette, + incompleteText: t('calibration_needed'), + missingHardwareText: t('action_needed'), + incompleteElement: null, + }, }, [MODULE_SETUP_KEY]: { stepInternals: ( @@ -209,47 +247,99 @@ export function ProtocolRunSetup({ description: isFlex ? flexDeckHardwareDescription : ot2DeckHardwareDescription, + rightElProps: { + stepKey: MODULE_SETUP_KEY, + complete: + calibrationStatusRobot.complete && calibrationStatusModules.complete, + completeText: isFlex ? t('calibration_ready') : '', + incompleteText: isFlex ? t('calibration_needed') : t('action_needed'), + missingHardware: isMissingModule || isFixtureMismatch, + missingHardwareText: t('action_needed'), + incompleteElement: null, + }, }, [LPC_KEY]: { stepInternals: ( { - setExpandedStepKey(LABWARE_SETUP_KEY) + setOffsetsConfirmed={confirmed => { + setLpcComplete(confirmed) + if (confirmed) { + setExpandedStepKey(LABWARE_SETUP_KEY) + setMissingSteps( + missingSteps.filter(step => step !== 'applied_labware_offsets') + ) + } }} + offsetsConfirmed={lpcComplete} /> ), description: t('labware_position_check_step_description'), + rightElProps: { + stepKey: LPC_KEY, + complete: lpcComplete, + completeText: t('offsets_ready'), + incompleteText: null, + incompleteElement: , + }, }, [LABWARE_SETUP_KEY]: { stepInternals: ( v === LABWARE_SETUP_KEY) === - targetStepKeyInOrder.length - 1 - ? null - : LIQUID_SETUP_KEY - } - expandStep={setExpandedStepKey} + labwareConfirmed={labwareSetupComplete} + setLabwareConfirmed={(confirmed: boolean) => { + setLabwareSetupComplete(confirmed) + if (confirmed) { + setMissingSteps( + missingSteps.filter(step => step !== 'labware_placement') + ) + const nextStep = + targetStepKeyInOrder.findIndex(v => v === LABWARE_SETUP_KEY) === + targetStepKeyInOrder.length - 1 + ? null + : LIQUID_SETUP_KEY + setExpandedStepKey(nextStep) + } + }} /> ), description: t(`${LABWARE_SETUP_KEY}_description`), + rightElProps: { + stepKey: LABWARE_SETUP_KEY, + complete: labwareSetupComplete, + completeText: t('placements_ready'), + incompleteText: null, + incompleteElement: null, + }, }, [LIQUID_SETUP_KEY]: { stepInternals: ( { + setLiquidSetupComplete(confirmed) + if (confirmed) { + setMissingSteps(missingSteps.filter(step => step !== 'liquids')) + setExpandedStepKey(null) + } + }} /> ), description: hasLiquids ? t(`${LIQUID_SETUP_KEY}_description`) : i18n.format(t('liquids_not_in_the_protocol'), 'capitalize'), + rightElProps: { + stepKey: LIQUID_SETUP_KEY, + complete: liquidSetupComplete, + completeText: t('liquids_ready'), + incompleteText: null, + incompleteElement: null, + }, }, } @@ -295,17 +385,7 @@ export function ProtocolRunSetup({ }} rightElement={ } > @@ -329,81 +409,110 @@ export function ProtocolRunSetup({ ) } -interface StepRightElementProps { - stepKey: StepKey - calibrationStatusRobot: ProtocolCalibrationStatus - calibrationStatusModules?: ProtocolCalibrationStatus - runHasStarted: boolean - isFlex: boolean - isMissingModule: boolean - isFixtureMismatch: boolean - isMissingPipette: boolean +interface NoHardwareRequiredStepCompletion { + stepKey: Exclude< + StepKey, + typeof ROBOT_CALIBRATION_STEP_KEY | typeof MODULE_SETUP_KEY + > + complete: boolean + incompleteText: string | null + incompleteElement: JSX.Element | null + completeText: string +} + +interface HardwareRequiredStepCompletion { + stepKey: typeof ROBOT_CALIBRATION_STEP_KEY | typeof MODULE_SETUP_KEY + complete: boolean + missingHardware: boolean + incompleteText: string | null + incompleteElement: JSX.Element | null + completeText: string + missingHardwareText: string } -function StepRightElement(props: StepRightElementProps): JSX.Element | null { - const { - stepKey, - runHasStarted, - calibrationStatusRobot, - calibrationStatusModules, - isFlex, - isMissingModule, - isFixtureMismatch, - isMissingPipette, - } = props - const { t } = useTranslation('protocol_setup') - const isActionNeeded = isMissingModule || isFixtureMismatch - if ( - !runHasStarted && - (stepKey === ROBOT_CALIBRATION_STEP_KEY || stepKey === MODULE_SETUP_KEY) - ) { - const moduleAndDeckStatus = isActionNeeded - ? { complete: false } - : calibrationStatusModules - const calibrationStatus = - stepKey === ROBOT_CALIBRATION_STEP_KEY - ? calibrationStatusRobot - : moduleAndDeckStatus +type StepRightElementProps = + | NoHardwareRequiredStepCompletion + | HardwareRequiredStepCompletion - let statusText = t('calibration_ready') - if ( - stepKey === ROBOT_CALIBRATION_STEP_KEY && - !calibrationStatusRobot.complete - ) { - statusText = isMissingPipette - ? t('action_needed') - : t('calibration_needed') - } else if (stepKey === MODULE_SETUP_KEY && !calibrationStatus?.complete) { - statusText = isActionNeeded ? t('action_needed') : t('calibration_needed') - } +const stepRequiresHW = ( + props: StepRightElementProps +): props is HardwareRequiredStepCompletion => + props.stepKey === ROBOT_CALIBRATION_STEP_KEY || + props.stepKey === MODULE_SETUP_KEY - // do not render calibration ready status icon for OT-2 module setup - return isFlex || - !( - stepKey === MODULE_SETUP_KEY && statusText === t('calibration_ready') - ) ? ( +function StepRightElement(props: StepRightElementProps): JSX.Element | null { + if (props.complete) { + return ( + + + + {props.completeText} + + + ) + } else if (stepRequiresHW(props) && props.missingHardware) { + return ( + + + + {props.missingHardwareText} + + + ) + } else if (props.incompleteText != null) { + return ( - {statusText} + {props.incompleteText} - ) : null - } else if (stepKey === LPC_KEY) { - return + ) + } else if (props.incompleteElement != null) { + return props.incompleteElement } else { return null } diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/SetupLabware.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/SetupLabware.test.tsx index d6a6ab4b05e..e92169bcb1d 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/SetupLabware.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/SetupLabware.test.tsx @@ -35,14 +35,17 @@ const ROBOT_NAME = 'otie' const RUN_ID = '1' const render = () => { + let labwareConfirmed = false + const confirmLabware = vi.fn(confirmed => { + labwareConfirmed = confirmed + }) return renderWithProviders( , { diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/index.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/index.tsx index 66b7bcdc1bc..526b944f425 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/index.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/index.tsx @@ -16,22 +16,18 @@ import { useModuleRenderInfoForProtocolById, useStoredProtocolAnalysis, } from '../../hooks' -import { BackToTopButton } from '../BackToTopButton' import { SetupLabwareMap } from './SetupLabwareMap' import { SetupLabwareList } from './SetupLabwareList' -import type { StepKey } from '../ProtocolRunSetup' - interface SetupLabwareProps { - protocolRunHeaderRef: React.RefObject | null robotName: string runId: string - nextStep: StepKey | null - expandStep: (step: StepKey) => void + labwareConfirmed: boolean + setLabwareConfirmed: (confirmed: boolean) => void } export function SetupLabware(props: SetupLabwareProps): JSX.Element { - const { robotName, runId, nextStep, expandStep, protocolRunHeaderRef } = props + const { robotName, runId, labwareConfirmed, setLabwareConfirmed } = props const { t } = useTranslation('protocol_setup') const robotProtocolAnalysis = useMostRecentCompletedAnalysis(runId) const storedProtocolAnalysis = useStoredProtocolAnalysis(runId) @@ -71,22 +67,14 @@ export function SetupLabware(props: SetupLabwareProps): JSX.Element { )} - {nextStep == null ? ( - - ) : ( - { - expandStep(nextStep) - }} - > - {t('proceed_to_liquid_setup_step')} - - )} + { + setLabwareConfirmed(true) + }} + disabled={labwareConfirmed} + > + {t('confirm_placements')} + ) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/__tests__/SetupLabwarePositionCheck.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/__tests__/SetupLabwarePositionCheck.test.tsx index 0bf4aaebbfc..0c0150937ad 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/__tests__/SetupLabwarePositionCheck.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/__tests__/SetupLabwarePositionCheck.test.tsx @@ -42,10 +42,15 @@ const ROBOT_NAME = 'otie' const RUN_ID = '1' const render = () => { + let areOffsetsConfirmed = false + const confirmOffsets = vi.fn((offsetsConfirmed: boolean) => { + areOffsetsConfirmed = offsetsConfirmed + }) return renderWithProviders( diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx index 66484717ef0..21862539e35 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx @@ -32,7 +32,8 @@ import { useNotifyRunQuery } from '../../../../resources/runs' import type { LabwareOffset } from '@opentrons/api-client' interface SetupLabwarePositionCheckProps { - expandLabwareStep: () => void + offsetsConfirmed: boolean + setOffsetsConfirmed: (confirmed: boolean) => void robotName: string runId: string } @@ -40,7 +41,7 @@ interface SetupLabwarePositionCheckProps { export function SetupLabwarePositionCheck( props: SetupLabwarePositionCheckProps ): JSX.Element { - const { robotName, runId, expandLabwareStep } = props + const { robotName, runId, setOffsetsConfirmed, offsetsConfirmed } = props const { t, i18n } = useTranslation('protocol_setup') const robotType = useRobotType(robotName) @@ -75,7 +76,13 @@ export function SetupLabwarePositionCheck( const robotProtocolAnalysis = useMostRecentCompletedAnalysis(runId) const storedProtocolAnalysis = useStoredProtocolAnalysis(runId) const protocolData = robotProtocolAnalysis ?? storedProtocolAnalysis - const [targetProps, tooltipProps] = useHoverTooltip({ + const [runLPCTargetProps, runLPCTooltipProps] = useHoverTooltip({ + placement: TOOLTIP_LEFT, + }) + const [ + confirmOffsetsTargetProps, + confirmOffsetsTooltipProps, + ] = useHoverTooltip({ placement: TOOLTIP_LEFT, }) @@ -114,6 +121,22 @@ export function SetupLabwarePositionCheck( )} { + setOffsetsConfirmed(true) + }} + id="LPC_setOffsetsConfirmed" + padding={`${SPACING.spacing8} ${SPACING.spacing16}`} + {...confirmOffsetsTargetProps} + disabled={offsetsConfirmed || lpcDisabledReason !== null} + > + {t('confirm_offsets')} + + {lpcDisabledReason !== null ? ( + + {lpcDisabledReason} + + ) : null} + { @@ -121,21 +144,16 @@ export function SetupLabwarePositionCheck( setIsShowingLPCSuccessToast(false) }} id="LabwareSetup_checkLabwarePositionsButton" - {...targetProps} + {...runLPCTargetProps} disabled={lpcDisabledReason !== null} > {t('run_labware_position_check')} - + {lpcDisabledReason !== null ? ( - {lpcDisabledReason} + + {lpcDisabledReason} + ) : null} - - {t('proceed_to_labware_setup_step')} - {LPCWizard} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/__tests__/SetupLiquids.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/__tests__/SetupLiquids.test.tsx index 1c3dc33181e..06e48c49738 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/__tests__/SetupLiquids.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/__tests__/SetupLiquids.test.tsx @@ -7,27 +7,35 @@ import { i18n } from '../../../../../i18n' import { SetupLiquids } from '../index' import { SetupLiquidsList } from '../SetupLiquidsList' import { SetupLiquidsMap } from '../SetupLiquidsMap' -import { BackToTopButton } from '../../BackToTopButton' vi.mock('../SetupLiquidsList') vi.mock('../SetupLiquidsMap') -vi.mock('../../BackToTopButton') -const render = (props: React.ComponentProps) => { - return renderWithProviders( - , - { - i18nInstance: i18n, +describe('SetupLiquids', () => { + const render = ( + props: React.ComponentProps & { + startConfirmed?: boolean } - ) -} + ) => { + let isConfirmed = + props?.startConfirmed == null ? false : props.startConfirmed + const confirmFn = vi.fn((confirmed: boolean) => { + isConfirmed = confirmed + }) + return renderWithProviders( + , + { + i18nInstance: i18n, + } + ) + } -describe('SetupLiquids', () => { let props: React.ComponentProps beforeEach(() => { vi.mocked(SetupLiquidsList).mockReturnValue( @@ -36,16 +44,13 @@ describe('SetupLiquids', () => { vi.mocked(SetupLiquidsMap).mockReturnValue(
Mock setup liquids map
) - vi.mocked(BackToTopButton).mockReturnValue( - - ) }) it('renders the list and map view buttons and proceed button', () => { render(props) screen.getByRole('button', { name: 'List View' }) screen.getByRole('button', { name: 'Map View' }) - screen.getByRole('button', { name: 'Mock BackToTopButton' }) + screen.getByRole('button', { name: 'Confirm placements' }) }) it('renders the map view when you press that toggle button', () => { render(props) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/index.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/index.tsx index daa2a7e114f..243bfeb3ed6 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/index.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/index.tsx @@ -6,10 +6,10 @@ import { SPACING, DIRECTION_COLUMN, ALIGN_CENTER, + PrimaryButton, } from '@opentrons/components' import { useToggleGroup } from '../../../../molecules/ToggleGroup/useToggleGroup' import { ANALYTICS_LIQUID_SETUP_VIEW_TOGGLE } from '../../../../redux/analytics' -import { BackToTopButton } from '../BackToTopButton' import { SetupLiquidsList } from './SetupLiquidsList' import { SetupLiquidsMap } from './SetupLiquidsMap' @@ -19,17 +19,19 @@ import type { } from '@opentrons/shared-data' interface SetupLiquidsProps { - protocolRunHeaderRef: React.RefObject | null - robotName: string runId: string protocolAnalysis: CompletedProtocolAnalysis | ProtocolAnalysisOutput | null + isLiquidSetupConfirmed: boolean + setLiquidSetupConfirmed: (confirmed: boolean) => void + robotName: string } export function SetupLiquids({ - protocolRunHeaderRef, - robotName, runId, protocolAnalysis, + isLiquidSetupConfirmed, + setLiquidSetupConfirmed, + robotName, }: SetupLiquidsProps): JSX.Element { const { t } = useTranslation('protocol_setup') const [selectedValue, toggleGroup] = useToggleGroup( @@ -51,12 +53,14 @@ export function SetupLiquids({ )} - + { + setLiquidSetupConfirmed(true) + }} + disabled={isLiquidSetupConfirmed} + > + {t('confirm_placements')} + ) diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx index 70b16c61b55..872dff5771f 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx @@ -97,7 +97,9 @@ import { ProtocolDropTipModal, useProtocolDropTipModal, } from '../ProtocolDropTipModal' +import { ConfirmMissingStepsModal } from '../ConfirmMissingStepsModal' +import type { MissingSteps } from '../ProtocolRunSetup' import type { UseQueryResult } from 'react-query' import type { NavigateFunction } from 'react-router-dom' import type { Mock } from 'vitest' @@ -153,6 +155,7 @@ vi.mock('../../../ProtocolUpload/hooks/useMostRecentRunId') vi.mock('../../../../resources/runs') vi.mock('../../../ErrorRecoveryFlows') vi.mock('../ProtocolDropTipModal') +vi.mock('../ConfirmMissingStepsModal') const ROBOT_NAME = 'otie' const RUN_ID = '95e67900-bc9f-4fbf-92c6-cc4d7226a51b' @@ -215,6 +218,7 @@ const mockDoorStatus = { doorRequiredClosedForProtocol: true, }, } +let mockMissingSteps: MissingSteps = [] const render = () => { return renderWithProviders( @@ -224,6 +228,7 @@ const render = () => { robotName={ROBOT_NAME} runId={RUN_ID} makeHandleJumpToStep={vi.fn(() => vi.fn())} + missingSetupSteps={mockMissingSteps} /> , { i18nInstance: i18n } @@ -240,7 +245,7 @@ describe('ProtocolRunHeader', () => { mockTrackProtocolRunEvent = vi.fn(() => new Promise(resolve => resolve({}))) mockCloseCurrentRun = vi.fn() mockDetermineTipStatus = vi.fn() - + mockMissingSteps = [] vi.mocked(useTrackEvent).mockReturnValue(mockTrackEvent) vi.mocked(ConfirmCancelModal).mockReturnValue(
Mock ConfirmCancelModal
@@ -267,6 +272,9 @@ describe('ProtocolRunHeader', () => { vi.mocked(ConfirmAttachmentModal).mockReturnValue(
mock confirm attachment modal
) + vi.mocked(ConfirmMissingStepsModal).mockReturnValue( +
mock missing steps modal
+ ) when(vi.mocked(useProtocolAnalysisErrors)).calledWith(RUN_ID).thenReturn({ analysisErrors: null, }) diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx index 89238cbaa01..e4fbc00e234 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx @@ -40,6 +40,7 @@ import { SetupLiquids } from '../SetupLiquids' import { SetupModuleAndDeck } from '../SetupModuleAndDeck' import { EmptySetupStep } from '../EmptySetupStep' import { ProtocolRunSetup } from '../ProtocolRunSetup' +import type { MissingSteps } from '../ProtocolRunSetup' import { useNotifyRunQuery } from '../../../../resources/runs' import type * as SharedData from '@opentrons/shared-data' @@ -68,12 +69,18 @@ vi.mock('@opentrons/shared-data', async importOriginal => { const ROBOT_NAME = 'otie' const RUN_ID = '1' const MOCK_PROTOCOL_LIQUID_KEY = { liquids: [] } +let mockMissingSteps: MissingSteps = [] +const mockSetMissingSteps = vi.fn((missingSteps: MissingSteps) => { + mockMissingSteps = missingSteps +}) const render = () => { return renderWithProviders( , { i18nInstance: i18n, @@ -83,6 +90,7 @@ const render = () => { describe('ProtocolRunSetup', () => { beforeEach(() => { + mockMissingSteps = [] when(vi.mocked(useIsFlex)).calledWith(ROBOT_NAME).thenReturn(false) when(vi.mocked(useMostRecentCompletedAnalysis)) .calledWith(RUN_ID) @@ -121,7 +129,6 @@ describe('ProtocolRunSetup', () => { when(vi.mocked(SetupLabware)) .calledWith( expect.objectContaining({ - protocolRunHeaderRef: null, robotName: ROBOT_NAME, runId: RUN_ID, }), @@ -146,6 +153,9 @@ describe('ProtocolRunSetup', () => { when(vi.mocked(useRunPipetteInfoByMount)) .calledWith(RUN_ID) .thenReturn({ left: null, right: null }) + when(vi.mocked(useModuleCalibrationStatus)) + .calledWith(ROBOT_NAME, RUN_ID) + .thenReturn({ complete: true }) }) afterEach(() => { vi.resetAllMocks() @@ -181,13 +191,6 @@ describe('ProtocolRunSetup', () => { screen.getByText('Calibration needed') }) - it('does not render calibration status when run has started', () => { - when(vi.mocked(useRunHasStarted)).calledWith(RUN_ID).thenReturn(true) - render() - expect(screen.queryByText('Calibration needed')).toBeNull() - expect(screen.queryByText('Calibration ready')).toBeNull() - }) - describe('when no modules are in the protocol', () => { it('renders robot calibration setup for OT-2', () => { render() @@ -426,10 +429,6 @@ describe('ProtocolRunSetup', () => { when(vi.mocked(useRunHasStarted)).calledWith(RUN_ID).thenReturn(true) render() - await new Promise(resolve => setTimeout(resolve, 1000)) - expect(screen.getByText('Mock SetupRobotCalibration')).not.toBeVisible() - expect(screen.getByText('Mock SetupModules')).not.toBeVisible() - expect(screen.getByText('Mock SetupLabware')).not.toBeVisible() screen.getByText('Setup is view-only once run has started') }) diff --git a/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx b/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx index 8182a8b73b3..0edc5a1ad1a 100644 --- a/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx +++ b/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx @@ -52,11 +52,17 @@ const mockRefetch = vi.fn() const mockCreateLiveCommand = vi.fn() const render = () => { + let confirmed = false + const setIsConfirmed = vi.fn((ready: boolean) => { + confirmed = ready + }) return renderWithProviders( , { diff --git a/app/src/organisms/ProtocolSetupLabware/index.tsx b/app/src/organisms/ProtocolSetupLabware/index.tsx index fa4d3926fdb..1210c1887df 100644 --- a/app/src/organisms/ProtocolSetupLabware/index.tsx +++ b/app/src/organisms/ProtocolSetupLabware/index.tsx @@ -22,6 +22,7 @@ import { SPACING, LegacyStyledText, TYPOGRAPHY, + Chip, } from '@opentrons/components' import { FLEX_ROBOT_TYPE, @@ -37,7 +38,7 @@ import { useModulesQuery, } from '@opentrons/react-api-client' -import { FloatingActionButton } from '../../atoms/buttons' +import { FloatingActionButton, SmallButton } from '../../atoms/buttons' import { ODDBackButton } from '../../molecules/ODDBackButton' import { getTopPortalEl } from '../../App/portal' import { Modal } from '../../molecules/Modal' @@ -77,11 +78,15 @@ const LabwareThumbnail = styled.svg` export interface ProtocolSetupLabwareProps { runId: string setSetupScreen: React.Dispatch> + isConfirmed: boolean + setIsConfirmed: (confirmed: boolean) => void } export function ProtocolSetupLabware({ runId, setSetupScreen, + isConfirmed, + setIsConfirmed, }: ProtocolSetupLabwareProps): JSX.Element { const { t } = useTranslation('protocol_setup') const [showMapView, setShowMapView] = React.useState(false) @@ -247,12 +252,34 @@ export function ProtocolSetupLabware({ , getTopPortalEl() )} - { - setSetupScreen('prepare to run') - }} - /> + + { + setSetupScreen('prepare to run') + }} + /> + {isConfirmed ? ( + + ) : ( + { + setIsConfirmed(true) + setSetupScreen('prepare to run') + }} + /> + )} + ) => { - return renderWithProviders(, { - i18nInstance: i18n, +describe('ProtocolSetupLiquids', () => { + let isConfirmed = false + const setIsConfirmed = vi.fn((confirmed: boolean) => { + isConfirmed = confirmed }) -} -describe('ProtocolSetupLiquids', () => { + const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) + } + let props: React.ComponentProps beforeEach(() => { - props = { runId: RUN_ID_1, setSetupScreen: vi.fn() } + props = { + runId: RUN_ID_1, + setSetupScreen: vi.fn(), + isConfirmed, + setIsConfirmed, + } vi.mocked(parseLiquidsInLoadOrder).mockReturnValue( MOCK_LIQUIDS_IN_LOAD_ORDER ) diff --git a/app/src/organisms/ProtocolSetupLiquids/index.tsx b/app/src/organisms/ProtocolSetupLiquids/index.tsx index 1fb10cdb79d..883054c6963 100644 --- a/app/src/organisms/ProtocolSetupLiquids/index.tsx +++ b/app/src/organisms/ProtocolSetupLiquids/index.tsx @@ -5,6 +5,7 @@ import { BORDERS, COLORS, DIRECTION_COLUMN, + DIRECTION_ROW, Flex, Icon, JUSTIFY_FLEX_END, @@ -12,6 +13,7 @@ import { StyledText, TYPOGRAPHY, JUSTIFY_SPACE_BETWEEN, + Chip, } from '@opentrons/components' import { parseLiquidsInLoadOrder, @@ -19,6 +21,8 @@ import { } from '@opentrons/api-client' import { MICRO_LITERS } from '@opentrons/shared-data' import { ODDBackButton } from '../../molecules/ODDBackButton' +import { SmallButton } from '../../atoms/buttons' + import { useMostRecentCompletedAnalysis } from '../LabwarePositionCheck/useMostRecentCompletedAnalysis' import { getTotalVolumePerLiquidId } from '../Devices/ProtocolRun/SetupLiquids/utils' import { LiquidDetails } from './LiquidDetails' @@ -29,13 +33,17 @@ import type { SetupScreens } from '../../pages/ProtocolSetup' export interface ProtocolSetupLiquidsProps { runId: string setSetupScreen: React.Dispatch> + isConfirmed: boolean + setIsConfirmed: (confirmed: boolean) => void } export function ProtocolSetupLiquids({ runId, setSetupScreen, + isConfirmed, + setIsConfirmed, }: ProtocolSetupLiquidsProps): JSX.Element { - const { t } = useTranslation('protocol_setup') + const { t, i18n } = useTranslation('protocol_setup') const protocolData = useMostRecentCompletedAnalysis(runId) const liquidsInLoadOrder = parseLiquidsInLoadOrder( protocolData?.liquids ?? [], @@ -43,12 +51,34 @@ export function ProtocolSetupLiquids({ ) return ( <> - { - setSetupScreen('prepare to run') - }} - /> + + { + setSetupScreen('prepare to run') + }} + /> + {isConfirmed ? ( + + ) : ( + { + setIsConfirmed(true) + setSetupScreen('prepare to run') + }} + /> + )} + > + lpcDisabledReason: string | null + launchLPC: () => void + LPCWizard: JSX.Element | null + isConfirmed: boolean + setIsConfirmed: (confirmed: boolean) => void +} + +export function ProtocolSetupOffsets({ + runId, + setSetupScreen, + isConfirmed, + setIsConfirmed, + launchLPC, + lpcDisabledReason, + LPCWizard, +}: ProtocolSetupOffsetsProps): JSX.Element { + const { t } = useTranslation('protocol_setup') + const { makeSnackbar } = useToaster() + const mostRecentAnalysis = useMostRecentCompletedAnalysis(runId) + const makeDisabledReasonSnackbar = (): void => { + if (lpcDisabledReason != null) { + makeSnackbar(lpcDisabledReason) + } + } + + const labwareDefinitions = getLabwareDefinitionsFromCommands( + mostRecentAnalysis?.commands ?? [] + ) + const { data: runRecord } = useNotifyRunQuery(runId, { staleTime: Infinity }) + const currentOffsets = runRecord?.data?.labwareOffsets ?? [] + const sortedOffsets: LabwareOffset[] = + currentOffsets.length > 0 + ? currentOffsets + .map(offset => ({ + ...offset, + // convert into date to sort + createdAt: new Date(offset.createdAt), + })) + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + .map(offset => ({ + ...offset, + // convert back into string + createdAt: offset.createdAt.toISOString(), + })) + : [] + const nonIdentityOffsets = getLatestCurrentOffsets(sortedOffsets) + return ( + <> + {LPCWizard} + {LPCWizard == null && ( + <> + + { + setSetupScreen('prepare to run') + }} + /> + {isConfirmed ? ( + + ) : ( + { + setIsConfirmed(true) + setSetupScreen('prepare to run') + }} + /> + )} + + + { + if (lpcDisabledReason != null) { + makeDisabledReasonSnackbar() + } else { + launchLPC() + } + }} + /> + + )} + + ) +} diff --git a/app/src/pages/Devices/ProtocolRunDetails/index.tsx b/app/src/pages/Devices/ProtocolRunDetails/index.tsx index 2935bc86100..62798b55b4f 100644 --- a/app/src/pages/Devices/ProtocolRunDetails/index.tsx +++ b/app/src/pages/Devices/ProtocolRunDetails/index.tsx @@ -10,6 +10,8 @@ import { Box, COLORS, DIRECTION_COLUMN, + DIRECTION_ROW, + JUSTIFY_SPACE_AROUND, Flex, LegacyStyledText, OVERFLOW_SCROLL, @@ -29,7 +31,11 @@ import { } from '../../../organisms/Devices/hooks' import { ProtocolRunHeader } from '../../../organisms/Devices/ProtocolRun/ProtocolRunHeader' import { RunPreview } from '../../../organisms/RunPreview' -import { ProtocolRunSetup } from '../../../organisms/Devices/ProtocolRun/ProtocolRunSetup' +import { + ProtocolRunSetup, + initialMissingSteps, +} from '../../../organisms/Devices/ProtocolRun/ProtocolRunSetup' +import { BackToTopButton } from '../../../organisms/Devices/ProtocolRun/BackToTopButton' import { ProtocolRunModuleControls } from '../../../organisms/Devices/ProtocolRun/ProtocolRunModuleControls' import { ProtocolRunRuntimeParameters } from '../../../organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters' import { useCurrentRunId } from '../../../resources/runs' @@ -134,7 +140,6 @@ export function ProtocolRunDetails(): JSX.Element | null { React.useEffect(() => { dispatch(fetchProtocols()) }, [dispatch]) - return robot != null ? ( + >(initialMissingSteps()) + const makeHandleScrollToStep = (i: number) => () => { listRef.current?.scrollToIndex(i, true, -1 * JUMP_OFFSET_FROM_TOP_PX) } @@ -193,37 +202,68 @@ function PageContents(props: PageContentsProps): JSX.Element { setJumpedIndex(i) } const protocolRunDetailsContentByTab: { - [K in ProtocolRunDetailsTab]: JSX.Element | null + [K in ProtocolRunDetailsTab]: { + content: JSX.Element | null + backToTop: JSX.Element | null + } } = { - setup: ( - - ), - 'runtime-parameters': , - 'module-controls': ( - - ), - 'run-preview': ( - - ), + setup: { + content: ( + + ), + backToTop: ( + + + + ), + }, + 'runtime-parameters': { + content: , + backToTop: null, + }, + 'module-controls': { + content: ( + + ), + backToTop: null, + }, + 'run-preview': { + content: ( + + ), + backToTop: null, + }, } - - const protocolRunDetailsContent = protocolRunDetailsContentByTab[ - protocolRunDetailsTab - ] ?? ( + const tabDetails = protocolRunDetailsContentByTab[protocolRunDetailsTab] ?? { // default to the setup tab if no tab or nonexistent tab is passed as a param - - - ) + content: ( + + ), + backToTop: null, + } + const { content, backToTop } = tabDetails return ( <> @@ -232,6 +272,7 @@ function PageContents(props: PageContentsProps): JSX.Element { robotName={robotName} runId={runId} makeHandleJumpToStep={makeHandleJumpToStep} + missingSetupSteps={missingSteps} /> - {protocolRunDetailsContent} + {content} + {backToTop} ) } diff --git a/app/src/pages/ProtocolSetup/ConfirmSetupStepsCompleteModal.tsx b/app/src/pages/ProtocolSetup/ConfirmSetupStepsCompleteModal.tsx new file mode 100644 index 00000000000..1757704e597 --- /dev/null +++ b/app/src/pages/ProtocolSetup/ConfirmSetupStepsCompleteModal.tsx @@ -0,0 +1,68 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' + +import { + DIRECTION_COLUMN, + Flex, + SPACING, + LegacyStyledText, +} from '@opentrons/components' + +import { SmallButton } from '../../atoms/buttons' +import { Modal } from '../../molecules/Modal' + +import type { ModalHeaderBaseProps } from '../../molecules/Modal/types' + +interface ConfirmSetupStepsCompleteModalProps { + onCloseClick: () => void + onConfirmClick: () => void + missingSteps: string[] +} + +export function ConfirmSetupStepsCompleteModal({ + onCloseClick, + missingSteps, + onConfirmClick, +}: ConfirmSetupStepsCompleteModalProps): JSX.Element { + const { i18n, t } = useTranslation(['protocol_setup', 'shared']) + const modalHeader: ModalHeaderBaseProps = { + title: t('are_you_sure_you_want_to_proceed'), + hasExitIcon: true, + } + + const handleStartRun = (): void => { + onConfirmClick() + onCloseClick() + } + + return ( + + + + {t('you_havent_confirmed', { + missingSteps: new Intl.ListFormat('en', { + style: 'short', + type: 'conjunction', + }).format(missingSteps), + })} + + + { + onCloseClick() + }} + /> + + + + + ) +} diff --git a/app/src/pages/ProtocolSetup/__tests__/ProtocolSetup.test.tsx b/app/src/pages/ProtocolSetup/__tests__/ProtocolSetup.test.tsx index 1be58ae82f8..5479f4693bd 100644 --- a/app/src/pages/ProtocolSetup/__tests__/ProtocolSetup.test.tsx +++ b/app/src/pages/ProtocolSetup/__tests__/ProtocolSetup.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { Route, MemoryRouter, Routes } from 'react-router-dom' import { fireEvent, screen } from '@testing-library/react' import { when } from 'vitest-when' -import { vi, it, describe, expect, beforeEach, afterEach } from 'vitest' +import { vi, it, describe, expect, beforeEach } from 'vitest' import { RUN_STATUS_IDLE, RUN_STATUS_STOPPED } from '@opentrons/api-client' import { @@ -39,10 +39,13 @@ import { ANALYTICS_PROTOCOL_RUN_ACTION } from '../../../redux/analytics' import { ProtocolSetupLiquids } from '../../../organisms/ProtocolSetupLiquids' import { getProtocolModulesInfo } from '../../../organisms/Devices/ProtocolRun/utils/getProtocolModulesInfo' import { ProtocolSetupModulesAndDeck } from '../../../organisms/ProtocolSetupModulesAndDeck' +import { ProtocolSetupLabware } from '../../../organisms/ProtocolSetupLabware' +import { ProtocolSetupOffsets } from '../../../organisms/ProtocolSetupOffsets' import { getUnmatchedModulesForProtocol } from '../../../organisms/ProtocolSetupModulesAndDeck/utils' import { useLaunchLPC } from '../../../organisms/LabwarePositionCheck/useLaunchLPC' import { ConfirmCancelRunModal } from '../../../organisms/OnDeviceDisplay/RunningProtocol' import { mockProtocolModuleInfo } from '../../../organisms/ProtocolSetupInstruments/__fixtures__' +import { getIncompleteInstrumentCount } from '../../../organisms/ProtocolSetupInstruments/utils' import { useProtocolHasRunTimeParameters, useRunControls, @@ -51,6 +54,7 @@ import { import { useIsHeaterShakerInProtocol } from '../../../organisms/ModuleCard/hooks' import { useDeckConfigurationCompatibility } from '../../../resources/deck_configuration/hooks' import { ConfirmAttachedModal } from '../../../pages/ProtocolSetup/ConfirmAttachedModal' +import { ConfirmSetupStepsCompleteModal } from '../../../pages/ProtocolSetup/ConfirmSetupStepsCompleteModal' import { ProtocolSetup } from '../../../pages/ProtocolSetup' import { useNotifyRunQuery } from '../../../resources/runs' import { ViewOnlyParameters } from '../../../organisms/ProtocolSetupParameters/ViewOnlyParameters' @@ -99,12 +103,15 @@ vi.mock('../../../organisms/ProtocolSetupParameters/ViewOnlyParameters') vi.mock( '../../../organisms/LabwarePositionCheck/useMostRecentCompletedAnalysis' ) +vi.mock('../../../organisms/ProtocolSetupInstruments/utils') vi.mock('../../../organisms/Devices/ProtocolRun/utils/getProtocolModulesInfo') vi.mock('../../../organisms/ProtocolSetupModulesAndDeck') vi.mock('../../../organisms/ProtocolSetupModulesAndDeck/utils') vi.mock('../../../organisms/OnDeviceDisplay/RunningProtocol') vi.mock('../../../organisms/RunTimeControl/hooks') vi.mock('../../../organisms/ProtocolSetupLiquids') +vi.mock('../../../organisms/ProtocolSetupLabware') +vi.mock('../../../organisms/ProtocolSetupOffsets') vi.mock('../../../organisms/ModuleCard/hooks') vi.mock('../../../redux/discovery/selectors') vi.mock('../ConfirmAttachedModal') @@ -112,6 +119,7 @@ vi.mock('../../../organisms/ToasterOven') vi.mock('../../../resources/deck_configuration/hooks') vi.mock('../../../resources/runs') vi.mock('../../../resources/deck_configuration') +vi.mock('../ConfirmSetupStepsCompleteModal') const render = (path = '/') => { return renderWithProviders( @@ -126,6 +134,12 @@ const render = (path = '/') => { ) } +const MockProtocolSetupLabware = vi.mocked(ProtocolSetupLabware) +const MockProtocolSetupLiquids = vi.mocked(ProtocolSetupLiquids) +const MockProtocolSetupOffsets = vi.mocked(ProtocolSetupOffsets) +const MockConfirmSetupStepsCompleteModal = vi.mocked( + ConfirmSetupStepsCompleteModal +) const ROBOT_NAME = 'fake-robot-name' const RUN_ID = 'my-run-id' const ROBOT_SERIAL_NUMBER = 'OT123' @@ -192,6 +206,30 @@ describe('ProtocolSetup', () => { beforeEach(() => { mockLaunchLPC = vi.fn() mockNavigate = vi.fn() + MockProtocolSetupLiquids.mockImplementation( + vi.fn(({ setIsConfirmed, setSetupScreen }) => { + setIsConfirmed(true) + setSetupScreen('prepare to run') + return
Mock ProtocolSetupLiquids
+ }) + ) + MockProtocolSetupLabware.mockImplementation( + vi.fn(({ setIsConfirmed, setSetupScreen }) => { + setIsConfirmed(true) + setSetupScreen('prepare to run') + return
Mock ProtocolSetupLabware
+ }) + ) + MockProtocolSetupOffsets.mockImplementation( + vi.fn(({ setIsConfirmed, setSetupScreen }) => { + setIsConfirmed(true) + setSetupScreen('prepare to run') + return
Mock ProtocolSetupOffsets
+ }) + ) + MockConfirmSetupStepsCompleteModal.mockReturnValue( +
Mock ConfirmSetupStepsCompleteModal
+ ) vi.mocked(useLPCDisabledReason).mockReturnValue(null) vi.mocked(useAttachedModules).mockReturnValue([]) vi.mocked(useModuleCalibrationStatus).mockReturnValue({ complete: true }) @@ -290,10 +328,6 @@ describe('ProtocolSetup', () => { .thenReturn({ trackProtocolRunEvent: mockTrackProtocolRunEvent }) }) - afterEach(() => { - vi.resetAllMocks() - }) - it('should render text, image, and buttons', () => { render(`/runs/${RUN_ID}/setup/`) screen.getByText('Prepare to run') @@ -305,9 +339,47 @@ describe('ProtocolSetup', () => { }) it('should play protocol when click play button', () => { + vi.mocked(useProtocolAnalysisAsDocumentQuery).mockReturnValue({ + data: { ...mockRobotSideAnalysis, liquids: mockLiquids }, + } as any) + when(vi.mocked(getProtocolModulesInfo)) + .calledWith( + { ...mockRobotSideAnalysis, liquids: mockLiquids }, + flexDeckDefV5 as any + ) + .thenReturn(mockProtocolModuleInfo) + when(vi.mocked(getUnmatchedModulesForProtocol)) + .calledWith([], mockProtocolModuleInfo) + .thenReturn({ missingModuleIds: [], remainingAttachedModules: [] }) + vi.mocked(getIncompleteInstrumentCount).mockReturnValue(0) + MockProtocolSetupLiquids.mockImplementation( + vi.fn(({ setIsConfirmed, setSetupScreen }) => { + setIsConfirmed(true) + setSetupScreen('prepare to run') + return
Mock ProtocolSetupLiquids
+ }) + ) + MockProtocolSetupLabware.mockImplementation( + vi.fn(({ setIsConfirmed, setSetupScreen }) => { + setIsConfirmed(true) + setSetupScreen('prepare to run') + return
Mock ProtocolSetupLabware
+ }) + ) + MockProtocolSetupOffsets.mockImplementation( + vi.fn(({ setIsConfirmed, setSetupScreen }) => { + setIsConfirmed(true) + setSetupScreen('prepare to run') + return
Mock ProtocolSetupOffsets
+ }) + ) render(`/runs/${RUN_ID}/setup/`) + fireEvent.click(screen.getByText('Labware Position Check')) + fireEvent.click(screen.getByText('Labware')) + fireEvent.click(screen.getByText('Liquids')) expect(mockPlay).toBeCalledTimes(0) fireEvent.click(screen.getByRole('button', { name: 'play' })) + expect(MockConfirmSetupStepsCompleteModal).toBeCalledTimes(0) expect(mockPlay).toBeCalledTimes(1) }) @@ -348,7 +420,25 @@ describe('ProtocolSetup', () => { render(`/runs/${RUN_ID}/setup/`) screen.getByText('1 initial liquid') fireEvent.click(screen.getByText('Liquids')) - expect(vi.mocked(ProtocolSetupLiquids)).toHaveBeenCalled() + expect(MockProtocolSetupLiquids).toHaveBeenCalled() + }) + + it('should launch protocol setup labware screen when click labware', () => { + vi.mocked(useProtocolAnalysisAsDocumentQuery).mockReturnValue({ + data: { ...mockRobotSideAnalysis, liquids: mockLiquids }, + } as any) + when(vi.mocked(getProtocolModulesInfo)) + .calledWith( + { ...mockRobotSideAnalysis, liquids: mockLiquids }, + flexDeckDefV5 as any + ) + .thenReturn(mockProtocolModuleInfo) + when(vi.mocked(getUnmatchedModulesForProtocol)) + .calledWith([], mockProtocolModuleInfo) + .thenReturn({ missingModuleIds: [], remainingAttachedModules: [] }) + render(`/runs/${RUN_ID}/setup`) + fireEvent.click(screen.getByTestId('SetupButton_Labware')) + expect(MockProtocolSetupLabware).toHaveBeenCalled() }) it('should launch view only parameters screen when click parameters', () => { @@ -376,14 +466,14 @@ describe('ProtocolSetup', () => { expect(vi.mocked(ViewOnlyParameters)).toHaveBeenCalled() }) - it('should launch LPC when clicked', () => { - vi.mocked(useLPCDisabledReason).mockReturnValue(null) + it('should launch offsets screen when click offsets', () => { + MockProtocolSetupOffsets.mockImplementation( + vi.fn(() =>
Mock ProtocolSetupOffsets
) + ) render(`/runs/${RUN_ID}/setup/`) - screen.getByText(/Recommended/) - screen.getByText(/1 offset applied/) fireEvent.click(screen.getByText('Labware Position Check')) - expect(mockLaunchLPC).toHaveBeenCalled() - screen.getByText('mock LPC Wizard') + expect(MockProtocolSetupOffsets).toHaveBeenCalled() + screen.getByText(/Mock ProtocolSetupOffsets/) }) it('should render a confirmation modal when heater-shaker is in a protocol and it is not shaking', () => { @@ -416,7 +506,21 @@ describe('ProtocolSetup', () => { }) it('calls trackProtocolRunEvent when tapping play button', () => { + vi.mocked(useProtocolAnalysisAsDocumentQuery).mockReturnValue({ + data: { ...mockRobotSideAnalysis, liquids: mockLiquids }, + } as any) + when(vi.mocked(getProtocolModulesInfo)) + .calledWith( + { ...mockRobotSideAnalysis, liquids: mockLiquids }, + flexDeckDefV5 as any + ) + .thenReturn(mockProtocolModuleInfo) + when(vi.mocked(getUnmatchedModulesForProtocol)) + .calledWith([], mockProtocolModuleInfo) + .thenReturn({ missingModuleIds: [], remainingAttachedModules: [] }) + vi.mocked(getIncompleteInstrumentCount).mockReturnValue(0) render(`/runs/${RUN_ID}/setup/`) + fireEvent.click(screen.getByRole('button', { name: 'play' })) expect(mockTrackProtocolRunEvent).toBeCalledTimes(1) expect(mockTrackProtocolRunEvent).toHaveBeenCalledWith({ diff --git a/app/src/pages/ProtocolSetup/index.tsx b/app/src/pages/ProtocolSetup/index.tsx index 36ce4220bcb..f152b0cc44a 100644 --- a/app/src/pages/ProtocolSetup/index.tsx +++ b/app/src/pages/ProtocolSetup/index.tsx @@ -60,6 +60,7 @@ import { getProtocolModulesInfo } from '../../organisms/Devices/ProtocolRun/util import { ProtocolSetupLabware } from '../../organisms/ProtocolSetupLabware' import { ProtocolSetupModulesAndDeck } from '../../organisms/ProtocolSetupModulesAndDeck' import { ProtocolSetupLiquids } from '../../organisms/ProtocolSetupLiquids' +import { ProtocolSetupOffsets } from '../../organisms/ProtocolSetupOffsets' import { ProtocolSetupInstruments } from '../../organisms/ProtocolSetupInstruments' import { ProtocolSetupDeckConfiguration } from '../../organisms/ProtocolSetupDeckConfiguration' import { useLaunchLPC } from '../../organisms/LabwarePositionCheck/useLaunchLPC' @@ -85,6 +86,7 @@ import { } from '../../redux/analytics' import { getIsHeaterShakerAttached } from '../../redux/config' import { ConfirmAttachedModal } from './ConfirmAttachedModal' +import { ConfirmSetupStepsCompleteModal } from './ConfirmSetupStepsCompleteModal' import { getLatestCurrentOffsets } from '../../organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/utils' import { CloseButton, PlayButton } from './Buttons' import { useDeckConfigurationCompatibility } from '../../resources/deck_configuration/hooks' @@ -118,6 +120,8 @@ interface ProtocolSetupStepProps { subDetail?: string | null // disallow click handler, disabled styling disabled?: boolean + // disallow click handler, don't show CTA icons, allow styling + interactionDisabled?: boolean // display the reason the setup step is disabled disabledReason?: string | null // optional description @@ -137,12 +141,14 @@ export function ProtocolSetupStep({ detail, subDetail, disabled = false, + interactionDisabled = false, disabledReason, description, hasRightIcon = true, hasLeftIcon = true, fontSize = 'p', }: ProtocolSetupStepProps): JSX.Element { + const isInteractionDisabled = interactionDisabled || disabled const backgroundColorByStepStatus = { ready: COLORS.green35, 'not ready': COLORS.yellow35, @@ -185,9 +191,12 @@ export function ProtocolSetupStep({ return ( { - !disabled ? onClickSetupStep() : makeDisabledReasonSnackbar() + !isInteractionDisabled + ? onClickSetupStep() + : makeDisabledReasonSnackbar() }} width="100%" + data-testid={`SetupButton_${title}`} > {detail} @@ -249,7 +257,7 @@ export function ProtocolSetupStep({ {subDetail} - {disabled || !hasRightIcon ? null : ( + {interactionDisabled || !hasRightIcon ? null : ( > confirmAttachment: () => void + confirmStepsComplete: () => void play: () => void robotName: string runRecord: Run | null + labwareConfirmed: boolean + liquidsConfirmed: boolean + offsetsConfirmed: boolean } function PrepareToRun({ @@ -280,6 +292,10 @@ function PrepareToRun({ play, robotName, runRecord, + labwareConfirmed, + liquidsConfirmed, + offsetsConfirmed, + confirmStepsComplete, }: PrepareToRunProps): JSX.Element { const { t, i18n } = useTranslation(['protocol_setup', 'shared']) const navigate = useNavigate() @@ -335,7 +351,6 @@ function PrepareToRun({ }, [mostRecentAnalysis?.status]) const robotType = useRobotType(robotName) - const { launchLPC, LPCWizard } = useLaunchLPC(runId, robotType, protocolName) const onConfirmCancelClose = (): void => { setShowConfirmCancelModal(false) @@ -381,12 +396,7 @@ function PrepareToRun({ : null const isMissingModules = missingModuleIds.length > 0 - const lpcDisabledReason = useLPCDisabledReason({ - runId, - hasMissingModulesForOdd: isMissingModules, - hasMissingCalForOdd: - incompleteInstrumentCount != null && incompleteInstrumentCount > 0, - }) + const moduleCalibrationStatus = useModuleCalibrationStatus(robotName, runId) const runTimeParameters = mostRecentAnalysis?.runTimeParameters ?? [] @@ -510,24 +520,25 @@ function PrepareToRun({ if (isDoorOpen) { makeSnackbar(t('shared:close_robot_door') as string) } else { - if ( - isHeaterShakerInProtocol && - isReadyToRun && - runStatus === RUN_STATUS_IDLE - ) { - confirmAttachment() - } else { - if (isReadyToRun) { + if (isReadyToRun) { + if (runStatus === RUN_STATUS_IDLE && isHeaterShakerInProtocol) { + confirmAttachment() + } else if ( + runStatus === RUN_STATUS_IDLE && + !(labwareConfirmed && offsetsConfirmed && liquidsConfirmed) + ) { + confirmStepsComplete() + } else { play() trackProtocolRunEvent({ name: ANALYTICS_PROTOCOL_RUN_ACTION.START, properties: robotAnalyticsData ?? {}, }) - } else { - makeSnackbar( - i18n.format(t('complete_setup_before_proceeding'), 'capitalize') - ) } + } else { + makeSnackbar( + i18n.format(t('complete_setup_before_proceeding'), 'capitalize') + ) } } } @@ -752,22 +763,16 @@ function PrepareToRun({ /> { - launchLPC() + setSetupScreen('offsets') }} title={t('labware_position_check')} - detail={t( - lpcDisabledReason != null - ? 'currently_unavailable' - : 'recommended' - )} + detail={t('recommended')} subDetail={ latestCurrentOffsets.length > 0 ? t('offsets_applied', { count: latestCurrentOffsets.length }) : null } - status="general" - disabled={lpcDisabledReason != null} - disabledReason={lpcDisabledReason} + status={offsetsConfirmed ? 'ready' : 'general'} /> { @@ -776,25 +781,25 @@ function PrepareToRun({ title={t('parameters')} detail={parametersDetail} subDetail={null} - status="general" - disabled={!hasRunTimeParameters} + status="ready" + interactionDisabled={!hasRunTimeParameters} /> { setSetupScreen('labware') }} - title={t('labware')} + title={i18n.format(t('labware'), 'capitalize')} detail={labwareDetail} subDetail={labwareSubDetail} - status="general" + status={labwareConfirmed ? 'ready' : 'general'} disabled={labwareDetail == null} /> { setSetupScreen('liquids') }} - title={t('liquids')} - status="general" + title={i18n.format(t('liquids'), 'capitalize')} + status={liquidsConfirmed ? 'ready' : 'general'} detail={ liquidsInProtocol.length > 0 ? t('initial_liquids_num', { @@ -809,7 +814,6 @@ function PrepareToRun({ )}
- {LPCWizard} {showConfirmCancelModal ? ( () as OnDeviceRouteParams const { data: runRecord } = useNotifyRunQuery(runId, { staleTime: Infinity }) const { analysisErrors } = useProtocolAnalysisErrors(runId) + const { t } = useTranslation(['protocol_setup']) const localRobot = useSelector(getLocalRobot) + const robotName = localRobot?.name != null ? localRobot.name : 'no name' const robotSerialNumber = localRobot?.status != null ? getRobotSerialNumber(localRobot) : null const trackEvent = useTrackEvent() @@ -849,7 +856,69 @@ export function ProtocolSetup(): JSX.Element { showAnalysisFailedModal, setShowAnalysisFailedModal, ] = React.useState(true) + const robotType = useRobotType(robotName) + const attachedModules = + useAttachedModules({ + refetchInterval: FETCH_DURATION_MS, + }) ?? [] + const protocolId = runRecord?.data?.protocolId ?? null + const { data: protocolRecord } = useProtocolQuery(protocolId, { + staleTime: Infinity, + }) + const mostRecentAnalysisSummary = last(protocolRecord?.data.analysisSummaries) + const [ + isPollingForCompletedAnalysis, + setIsPollingForCompletedAnalysis, + ] = React.useState(mostRecentAnalysisSummary?.status !== 'completed') + const { + data: mostRecentAnalysis = null, + } = useProtocolAnalysisAsDocumentQuery( + protocolId, + last(protocolRecord?.data.analysisSummaries)?.id ?? null, + { + enabled: protocolRecord != null && isPollingForCompletedAnalysis, + refetchInterval: ANALYSIS_POLL_MS, + } + ) + + React.useEffect(() => { + if (mostRecentAnalysis?.status === 'completed') { + setIsPollingForCompletedAnalysis(false) + } else { + setIsPollingForCompletedAnalysis(true) + } + }, [mostRecentAnalysis?.status]) + const deckDef = getDeckDefFromRobotType(robotType) + + const protocolModulesInfo = + mostRecentAnalysis != null + ? getProtocolModulesInfo(mostRecentAnalysis, deckDef) + : [] + + const { missingModuleIds } = getUnmatchedModulesForProtocol( + attachedModules, + protocolModulesInfo + ) + const isMissingModules = missingModuleIds.length > 0 + const { data: attachedInstruments } = useInstrumentsQuery() + + const incompleteInstrumentCount: number | null = + mostRecentAnalysis != null && attachedInstruments != null + ? getIncompleteInstrumentCount(mostRecentAnalysis, attachedInstruments) + : null + const lpcDisabledReason = useLPCDisabledReason({ + runId, + hasMissingModulesForOdd: isMissingModules, + hasMissingCalForOdd: + incompleteInstrumentCount != null && incompleteInstrumentCount > 0, + }) + const protocolName = + protocolRecord?.data.metadata.protocolName ?? + protocolRecord?.data.files[0].name ?? + '' + + const { launchLPC, LPCWizard } = useLaunchLPC(runId, robotType, protocolName) const handleProceedToRunClick = (): void => { trackEvent({ name: ANALYTICS_PROTOCOL_PROCEED_TO_RUN, @@ -862,8 +931,8 @@ export function ProtocolSetup(): JSX.Element { ) const { confirm: confirmAttachment, - showConfirmation: showConfirmationModal, - cancel: cancelExit, + showConfirmation: showHSConfirmationModal, + cancel: cancelExitHSConfirmation, } = useConditionalConfirm( handleProceedToRunClick, !configBypassHeaterShakerAttachmentConfirmation @@ -872,6 +941,22 @@ export function ProtocolSetup(): JSX.Element { const [providedFixtureOptions, setProvidedFixtureOptions] = React.useState< CutoutFixtureId[] >([]) + const [labwareConfirmed, setLabwareConfirmed] = React.useState(false) + const [liquidsConfirmed, setLiquidsConfirmed] = React.useState(false) + const [offsetsConfirmed, setOffsetsConfirmed] = React.useState(false) + const missingSteps = [ + !offsetsConfirmed ? t('applied_labware_offsets') : null, + !labwareConfirmed ? t('labware_placement') : null, + !liquidsConfirmed ? t('liquids') : null, + ].filter(s => s != null) + const { + confirm: confirmMissingSteps, + showConfirmation: showMissingStepsConfirmation, + cancel: cancelExitMissingStepsConfirmation, + } = useConditionalConfirm( + handleProceedToRunClick, + !(labwareConfirmed && liquidsConfirmed && offsetsConfirmed) + ) // orchestrate setup subpages/components const [setupScreen, setSetupScreen] = React.useState( @@ -883,9 +968,13 @@ export function ProtocolSetup(): JSX.Element { runId={runId} setSetupScreen={setSetupScreen} confirmAttachment={confirmAttachment} + confirmStepsComplete={confirmMissingSteps} play={play} - robotName={localRobot?.name != null ? localRobot.name : 'no name'} + robotName={robotName} runRecord={runRecord ?? null} + labwareConfirmed={labwareConfirmed} + liquidsConfirmed={liquidsConfirmed} + offsetsConfirmed={offsetsConfirmed} /> ), instruments: ( @@ -899,11 +988,32 @@ export function ProtocolSetup(): JSX.Element { setProvidedFixtureOptions={setProvidedFixtureOptions} /> ), + offsets: ( + + ), labware: ( - + ), liquids: ( - + ), 'deck configuration': ( error.detail)} /> ) : null} - {showConfirmationModal ? ( + {showMissingStepsConfirmation ? ( + + ) : null} + {showHSConfirmationModal ? (