From 3402d2083132a81e1d5b481adc62d337112110bf Mon Sep 17 00:00:00 2001 From: koji Date: Tue, 15 Oct 2024 12:21:00 -0400 Subject: [PATCH 1/3] feat(protocol-designer): introduce react-lottie for animations in PD (#16472) * feat(protocol-designer): introduce react-lottie for animations in PD --- protocol-designer/package.json | 2 ++ yarn.lock | 22 +++++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/protocol-designer/package.json b/protocol-designer/package.json index b0a4cdc6fa6..045082d63be 100755 --- a/protocol-designer/package.json +++ b/protocol-designer/package.json @@ -27,6 +27,7 @@ "@opentrons/components": "link:../components", "@opentrons/step-generation": "link:../step-generation", "@opentrons/shared-data": "link:../shared-data", + "@types/react-lottie": "^1.2.10", "@types/redux-actions": "2.6.1", "@types/styled-components": "^5.1.26", "@types/ua-parser-js": "0.7.36", @@ -51,6 +52,7 @@ "react-dom": "18.2.0", "react-hook-form": "7.49.3", "react-i18next": "14.0.0", + "react-lottie": "^1.2.4", "react-redux": "8.1.2", "redux": "4.0.5", "redux-actions": "2.2.1", diff --git a/yarn.lock b/yarn.lock index 98d25011f2e..9d0bbe3f6c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5683,6 +5683,13 @@ dependencies: "@types/react" "*" +"@types/react-lottie@^1.2.10": + version "1.2.10" + resolved "https://registry.yarnpkg.com/@types/react-lottie/-/react-lottie-1.2.10.tgz#220f68a2dfa0d4b131ab4930e8bf166b9442c68c" + integrity sha512-rCd1p3US4ELKJlqwVnP0h5b24zt5p9OCvKUoNpYExLqwbFZMWEiJ6EGLMmH7nmq5V7KomBIbWO2X/XRFsL0vCA== + dependencies: + "@types/react" "*" + "@types/react-redux@7.1.32": version "7.1.32" resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.32.tgz#bf162289e0c69e44a649dfcadb30f7f7c4cb00e4" @@ -7159,7 +7166,7 @@ babel-plugin-unassert@^3.0.1: resolved "https://registry.yarnpkg.com/babel-plugin-unassert/-/babel-plugin-unassert-3.2.0.tgz#4ea8f65709905cc540627baf4ce4c837281a317d" integrity sha512-dNeuFtaJ1zNDr59r24NjjIm4SsXXm409iNOVMIERp6ePciII+rTrdwsWcHDqDFUKpOoBNT4ZS63nPEbrANW7DQ== -babel-runtime@6.x.x: +babel-runtime@6.x.x, babel-runtime@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" integrity sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g== @@ -14965,6 +14972,11 @@ lost@^8.3.1: object-assign "^4.1.1" postcss "7.0.14" +lottie-web@^5.1.3: + version "5.12.2" + resolved "https://registry.yarnpkg.com/lottie-web/-/lottie-web-5.12.2.tgz#579ca9fe6d3fd9e352571edd3c0be162492f68e5" + integrity sha512-uvhvYPC8kGPjXT3MyKMrL3JitEAmDMp30lVkuq/590Mw9ok6pWcFCwXJveo0t5uqYw1UREQHofD+jVpdjBv8wg== + loud-rejection@^1.0.0: version "1.6.0" resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f" @@ -18593,6 +18605,14 @@ react-is@^18.0.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +react-lottie@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/react-lottie/-/react-lottie-1.2.4.tgz#999ccabff8afc82074588bc50bd75be6f8945161" + integrity sha512-kBGxI+MIZGBf4wZhNCWwHkMcVP+kbpmrLWH/SkO0qCKc7D7eSPcxQbfpsmsCo8v2KCBYjuGSou+xTqK44D/jMg== + dependencies: + babel-runtime "^6.26.0" + lottie-web "^5.1.3" + react-markdown@9.0.1: version "9.0.1" resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-9.0.1.tgz#c05ddbff67fd3b3f839f8c648e6fb35d022397d1" From a3826db0f7a9ccff4bcd72d59b94bd5a8b4e11b0 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Tue, 15 Oct 2024 12:46:26 -0400 Subject: [PATCH 2/3] feat(app): Wire up door status affordances for gripper error flows (#16487) Closes EXEC-723 This commit closes out the gripper flows by adding the expected gripper behavior given the door status. These affordances include proper jaw release while the door is open, ensuring the door is closed before z-homing the gripper, implicitly z-homing the gripper if the door is already closed after releasing labware. There's some minor refactoring here as well. Copy for gripper flows was recently updated, so some views are refactored to handle different copy depending on the selectedRecoveryOption. The major challenge here is thinking through all the permutations of gripper/door state behavior. One of the trickier aspects is implicitly executing fixit commands, since most commands up to this point are directly tied to a CTA. We have a semi-pattern for this useInitialPipette. If we do more implicit fixit command behavior, it will probably be worth spending the time to think through how to elucidate this implicit behavior a bit better, since the control flow is dense. --- .../localization/en/error_recovery.json | 10 +- .../utils/getLabwareDisplayLocation.ts | 1 + .../getShowGenericRunHeaderBanners.ts | 4 +- .../ErrorRecoveryWizard.tsx | 13 +- .../ErrorRecoveryFlows/RecoveryInProgress.tsx | 77 ++++++--- .../RecoveryOptions/ManualMoveLwAndSkip.tsx | 3 + .../ManualReplaceLwAndRetry.tsx | 3 + .../__tests__/ManualMoveLwAndSkip.test.tsx | 8 + .../ManualReplaceLwAndRetry.test.tsx | 8 + .../ErrorRecoveryFlows/RecoverySplash.tsx | 1 + .../__tests__/ErrorRecoveryWizard.test.tsx | 25 ++- .../__tests__/RecoveryInProgress.test.tsx | 155 ++++++++++++------ .../organisms/ErrorRecoveryFlows/constants.ts | 35 +++- .../__tests__/useFailedLabwareUtils.test.ts | 70 ++++++++ .../__tests__/useHomeGripperZAxis.test.ts | 122 ++++++++++++++ .../__tests__/useRecoveryCommands.test.ts | 23 ++- .../hooks/__tests__/useShowDoorInfo.test.ts | 69 ++++++-- .../ErrorRecoveryFlows/hooks/index.ts | 1 + .../ErrorRecoveryFlows/hooks/useERUtils.ts | 7 +- .../hooks/useFailedLabwareUtils.ts | 87 +++++++++- .../hooks/useHomeGripperZAxis.ts | 44 +++++ .../hooks/useRecoveryCommands.ts | 28 +++- .../hooks/useRouteUpdateActions.ts | 12 +- .../hooks/useShowDoorInfo.ts | 12 +- .../shared/LeftColumnLabwareInfo.tsx | 25 +-- .../shared/RecoveryDoorOpenSpecial.tsx | 146 +++++++++++++++++ .../shared/TwoColLwInfoAndDeck.tsx | 18 +- .../__tests__/GripperReleaseLabware.test.tsx | 2 +- .../__tests__/LeftColumnLabwareInfo.test.tsx | 81 +++++---- .../RecoveryDoorOpenSpecial.test.tsx | 110 +++++++++++++ .../shared/__tests__/SelectTips.test.tsx | 2 + .../__tests__/TwoColLwInfoAndDeck.test.tsx | 134 +++++++++++++++ .../ErrorRecoveryFlows/shared/index.ts | 1 + shared-data/command/types/unsafe.ts | 13 ++ 34 files changed, 1179 insertions(+), 171 deletions(-) create mode 100644 app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripperZAxis.test.ts create mode 100644 app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripperZAxis.ts create mode 100644 app/src/organisms/ErrorRecoveryFlows/shared/RecoveryDoorOpenSpecial.tsx create mode 100644 app/src/organisms/ErrorRecoveryFlows/shared/__tests__/RecoveryDoorOpenSpecial.test.tsx create mode 100644 app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx diff --git a/app/src/assets/localization/en/error_recovery.json b/app/src/assets/localization/en/error_recovery.json index 63c259ce1f3..c5e5537ca83 100644 --- a/app/src/assets/localization/en/error_recovery.json +++ b/app/src/assets/localization/en/error_recovery.json @@ -12,12 +12,14 @@ "change_tip_pickup_location": "Change tip pick-up location", "choose_a_recovery_action": "Choose a recovery action", "close_door_to_resume": "Close robot door to resume", + "close_robot_door": "Close the robot door", "close_the_robot_door": "Close the robot door, and then resume the recovery action.", "confirm": "Confirm", "continue": "Continue", "continue_run_now": "Continue run now", "continue_to_drop_tip": "Continue to drop tip", - "ensure_lw_is_accurately_placed": "Ensure labware is accurately placed in the slot to prevent further errors", + "door_open_gripper_home": "The robot door must be closed for the gripper to home its Z-axis before you can continue manually moving labware.", + "ensure_lw_is_accurately_placed": "Ensure labware is accurately placed in the slot to prevent further errors.", "error": "Error", "error_details": "Error details", "error_on_robot": "Error on {{robot}}", @@ -38,7 +40,7 @@ "ignore_error_and_skip": "Ignore error and skip to next step", "ignore_only_this_error": "Ignore only this error", "ignore_similar_errors_later_in_run": "Ignore similar errors later in the run?", - "labware_released_from_current_height": "The labware will be released from its current height", + "labware_released_from_current_height": "The labware will be released from its current height.", "launch_recovery_mode": "Launch Recovery Mode", "manually_fill_liquid_in_well": "Manually fill liquid in well {{well}}", "manually_fill_well_and_skip": "Manually fill well and skip to next step", @@ -63,8 +65,8 @@ "remove_any_attached_tips": "Remove any attached tips", "replace_tips_and_select_loc_partial_tip": "Replace tips and select the last location used for partial tip pickup.", "replace_tips_and_select_location": "It's best to replace tips and select the last location used for tip pickup.", - "replace_used_tips_in_rack_location": "Replace used tips in rack location {{location}} in slot {{slot}}", - "replace_with_new_tip_rack": "Replace with new tip rack in slot {{slot}}", + "replace_used_tips_in_rack_location": "Replace used tips in rack location {{location}} in Slot {{slot}}", + "replace_with_new_tip_rack": "Replace with new tip rack in Slot {{slot}}", "resume": "Resume", "retry_now": "Retry now", "retry_step": "Retry step", diff --git a/app/src/molecules/Command/utils/getLabwareDisplayLocation.ts b/app/src/molecules/Command/utils/getLabwareDisplayLocation.ts index 724775fcc9e..60b03609c79 100644 --- a/app/src/molecules/Command/utils/getLabwareDisplayLocation.ts +++ b/app/src/molecules/Command/utils/getLabwareDisplayLocation.ts @@ -17,6 +17,7 @@ import type { } from '@opentrons/shared-data' import type { CommandTextData } from '../types' +// TODO(jh, 10-14-24): Refactor this util and related copy utils out of Command. export function getLabwareDisplayLocation( commandTextData: Omit, allRunDefs: LabwareDefinition2[], diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderBannerContainer/getShowGenericRunHeaderBanners.ts b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderBannerContainer/getShowGenericRunHeaderBanners.ts index e21853738d7..dde83ddb02d 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderBannerContainer/getShowGenericRunHeaderBanners.ts +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderBannerContainer/getShowGenericRunHeaderBanners.ts @@ -1,5 +1,6 @@ import { RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, + RUN_STATUS_AWAITING_RECOVERY_PAUSED, RUN_STATUS_BLOCKED_BY_OPEN_DOOR, RUN_STATUS_STOPPED, } from '@opentrons/api-client' @@ -32,7 +33,8 @@ export function getShowGenericRunHeaderBanners({ isDoorOpen && runStatus !== RUN_STATUS_BLOCKED_BY_OPEN_DOOR && runStatus !== RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR && - isCancellableStatus(runStatus) + runStatus !== RUN_STATUS_AWAITING_RECOVERY_PAUSED + isCancellableStatus(runStatus) const showDoorOpenDuringRunBanner = runStatus === RUN_STATUS_BLOCKED_BY_OPEN_DOOR diff --git a/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx b/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx index cfe211f7f3e..e763766ccb9 100644 --- a/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx @@ -24,16 +24,18 @@ import { useErrorDetailsModal, ErrorDetailsModal, RecoveryInterventionModal, + RecoveryDoorOpenSpecial, } from './shared' import { RecoveryInProgress } from './RecoveryInProgress' import { getErrorKind } from './utils' import { RECOVERY_MAP } from './constants' +import { useHomeGripperZAxis } from './hooks' import type { LabwareDefinition2, RobotType } from '@opentrons/shared-data' import type { RecoveryRoute, RouteStep, RecoveryContentProps } from './types' -import type { ERUtilsResults, useRetainedFailedCommandBySource } from './hooks' import type { ErrorRecoveryFlowsProps } from '.' import type { UseRecoveryAnalyticsResult } from '/app/redux-resources/analytics' +import type { ERUtilsResults, useRetainedFailedCommandBySource } from './hooks' export interface UseERWizardResult { hasLaunchedRecovery: boolean @@ -88,6 +90,8 @@ export function ErrorRecoveryWizard( routeUpdateActions, }) + useHomeGripperZAxis(props) + return } @@ -136,7 +140,6 @@ export function ErrorRecoveryComponent( ) - // TODO(jh, 07-29-24): Make RecoveryDoorOpen render logic equivalent to RecoveryTakeover. Do not nest it in RecoveryWizard. const buildInterventionContent = (): JSX.Element => { if (isProhibitedDoorOpen) { return @@ -233,6 +236,10 @@ export function ErrorRecoveryContent(props: RecoveryContentProps): JSX.Element { return } + const buildRecoveryDoorOpenSpecial = (): JSX.Element => { + return + } + switch (props.recoveryMap.route) { case RECOVERY_MAP.OPTION_SELECTION.ROUTE: return buildSelectRecoveryOption() @@ -260,6 +267,8 @@ export function ErrorRecoveryContent(props: RecoveryContentProps): JSX.Element { return buildManualMoveLwAndSkip() case RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE: return buildManualReplaceLwAndRetry() + case RECOVERY_MAP.ROBOT_DOOR_OPEN_SPECIAL.ROUTE: + return buildRecoveryDoorOpenSpecial() case RECOVERY_MAP.ROBOT_IN_MOTION.ROUTE: case RECOVERY_MAP.ROBOT_RESUMING.ROUTE: case RECOVERY_MAP.ROBOT_RETRYING_STEP.ROUTE: diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryInProgress.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryInProgress.tsx index dc06fd1979f..3a176942a74 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryInProgress.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryInProgress.tsx @@ -20,6 +20,8 @@ export function RecoveryInProgress({ recoveryMap, recoveryCommands, routeUpdateActions, + doorStatusUtils, + currentRecoveryOptionUtils, }: RecoveryContentProps): JSX.Element { const { ROBOT_CANCELING, @@ -37,6 +39,8 @@ export function RecoveryInProgress({ recoveryMap, recoveryCommands, routeUpdateActions, + doorStatusUtils, + currentRecoveryOptionUtils, }) const buildDescription = (): RobotMovingRoute => { @@ -76,47 +80,78 @@ export function RecoveryInProgress({ ) } -const GRIPPER_RELEASE_COUNTDOWN_S = 5 +export const GRIPPER_RELEASE_COUNTDOWN_S = 3 type UseGripperReleaseProps = Pick< RecoveryContentProps, - 'recoveryMap' | 'recoveryCommands' | 'routeUpdateActions' + | 'currentRecoveryOptionUtils' + | 'recoveryCommands' + | 'routeUpdateActions' + | 'doorStatusUtils' + | 'recoveryMap' > // Handles the gripper release copy and action, which operates on an interval. At T=0, release the labware then proceed -// to the next step in the active route. +// to the next step in the active route if the door is open (which should be a route to handle the door), or to the next +// CTA route if the door is closed. export function useGripperRelease({ - recoveryMap, + currentRecoveryOptionUtils, recoveryCommands, routeUpdateActions, + doorStatusUtils, + recoveryMap, }: UseGripperReleaseProps): number { const { releaseGripperJaws } = recoveryCommands + const { selectedRecoveryOption } = currentRecoveryOptionUtils const { proceedToRouteAndStep, proceedNextStep, handleMotionRouting, - stashedMap, } = routeUpdateActions + const { isDoorOpen } = doorStatusUtils const { MANUAL_MOVE_AND_SKIP, MANUAL_REPLACE_AND_RETRY } = RECOVERY_MAP const [countdown, setCountdown] = useState(GRIPPER_RELEASE_COUNTDOWN_S) const proceedToValidNextStep = (): void => { - switch (stashedMap?.route) { - case MANUAL_MOVE_AND_SKIP.ROUTE: - void proceedToRouteAndStep( - MANUAL_MOVE_AND_SKIP.ROUTE, - MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE - ) - break - case MANUAL_REPLACE_AND_RETRY.ROUTE: - void proceedToRouteAndStep( - MANUAL_REPLACE_AND_RETRY.ROUTE, - MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE - ) - break - default: - console.error('Unhandled post grip-release routing.') - void proceedNextStep() + if (isDoorOpen) { + switch (selectedRecoveryOption) { + case MANUAL_MOVE_AND_SKIP.ROUTE: + void proceedToRouteAndStep( + MANUAL_MOVE_AND_SKIP.ROUTE, + MANUAL_MOVE_AND_SKIP.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME + ) + break + case MANUAL_REPLACE_AND_RETRY.ROUTE: + void proceedToRouteAndStep( + MANUAL_REPLACE_AND_RETRY.ROUTE, + MANUAL_REPLACE_AND_RETRY.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME + ) + break + default: { + console.error( + 'Unhandled post grip-release routing when door is open.' + ) + void proceedToRouteAndStep(RECOVERY_MAP.OPTION_SELECTION.ROUTE) + } + } + } else { + switch (selectedRecoveryOption) { + case MANUAL_MOVE_AND_SKIP.ROUTE: + void proceedToRouteAndStep( + MANUAL_MOVE_AND_SKIP.ROUTE, + MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE + ) + break + case MANUAL_REPLACE_AND_RETRY.ROUTE: + void proceedToRouteAndStep( + MANUAL_REPLACE_AND_RETRY.ROUTE, + MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE + ) + break + default: + console.error('Unhandled post grip-release routing.') + void proceedNextStep() + } } } diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManualMoveLwAndSkip.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManualMoveLwAndSkip.tsx index 391674b54f1..123493480f7 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManualMoveLwAndSkip.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManualMoveLwAndSkip.tsx @@ -4,6 +4,7 @@ import { GripperReleaseLabware, SkipStepInfo, TwoColLwInfoAndDeck, + RecoveryDoorOpenSpecial, } from '../shared' import { SelectRecoveryOption } from './SelectRecoveryOption' @@ -20,6 +21,8 @@ export function ManualMoveLwAndSkip(props: RecoveryContentProps): JSX.Element { return case MANUAL_MOVE_AND_SKIP.STEPS.GRIPPER_RELEASE_LABWARE: return + case MANUAL_MOVE_AND_SKIP.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME: + return case MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE: return case MANUAL_MOVE_AND_SKIP.STEPS.SKIP: diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManualReplaceLwAndRetry.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManualReplaceLwAndRetry.tsx index 01d9f7fb282..11ffe783d42 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManualReplaceLwAndRetry.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManualReplaceLwAndRetry.tsx @@ -4,6 +4,7 @@ import { GripperReleaseLabware, TwoColLwInfoAndDeck, RetryStepInfo, + RecoveryDoorOpenSpecial, } from '../shared' import { SelectRecoveryOption } from './SelectRecoveryOption' @@ -22,6 +23,8 @@ export function ManualReplaceLwAndRetry( return case MANUAL_REPLACE_AND_RETRY.STEPS.GRIPPER_RELEASE_LABWARE: return + case MANUAL_REPLACE_AND_RETRY.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME: + return case MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE: return case MANUAL_REPLACE_AND_RETRY.STEPS.RETRY: diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManualMoveLwAndSkip.test.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManualMoveLwAndSkip.test.tsx index 863b406e1c5..48f8615cf81 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManualMoveLwAndSkip.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManualMoveLwAndSkip.test.tsx @@ -15,6 +15,7 @@ vi.mock('../../shared', () => ({ GripperReleaseLabware: vi.fn(() =>
MOCK_GRIPPER_RELEASE_LABWARE
), TwoColLwInfoAndDeck: vi.fn(() =>
MOCK_TWO_COL_LW_INFO_AND_DECK
), SkipStepInfo: vi.fn(() =>
MOCK_SKIP_STEP_INFO
), + RecoveryDoorOpenSpecial: vi.fn(() =>
MOCK_DOOR_OPEN_SPECIAL
), })) vi.mock('../SelectRecoveryOption', () => ({ @@ -51,6 +52,13 @@ describe('ManualMoveLwAndSkip', () => { screen.getByText('MOCK_GRIPPER_RELEASE_LABWARE') }) + it(`renders RecoveryDoorOpenSpecial for ${RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME} step`, () => { + props.recoveryMap.step = + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME + render(props) + screen.getByText('MOCK_DOOR_OPEN_SPECIAL') + }) + it(`renders TwoColLwInfoAndDeck for ${RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE} step`, () => { props.recoveryMap.step = RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE render(props) diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManualReplaceLwAndRetry.test.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManualReplaceLwAndRetry.test.tsx index 12fc8e5151c..fb47ccb5f2f 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManualReplaceLwAndRetry.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManualReplaceLwAndRetry.test.tsx @@ -15,6 +15,7 @@ vi.mock('../../shared', () => ({ GripperReleaseLabware: vi.fn(() =>
MOCK_GRIPPER_RELEASE_LABWARE
), TwoColLwInfoAndDeck: vi.fn(() =>
MOCK_TWO_COL_LW_INFO_AND_DECK
), RetryStepInfo: vi.fn(() =>
MOCK_RETRY_STEP_INFO
), + RecoveryDoorOpenSpecial: vi.fn(() =>
MOCK_DOOR_OPEN_SPECIAL
), })) vi.mock('../SelectRecoveryOption', () => ({ @@ -54,6 +55,13 @@ describe('ManualReplaceLwAndRetry', () => { screen.getByText('MOCK_GRIPPER_RELEASE_LABWARE') }) + it(`renders RecoveryDoorOpenSpecial for ${RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME} step`, () => { + props.recoveryMap.step = + RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME + render(props) + screen.getByText('MOCK_DOOR_OPEN_SPECIAL') + }) + it(`renders TwoColLwInfoAndDeck for ${RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE} step`, () => { props.recoveryMap.step = RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx index aad0f670cd0..c9006f5d552 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx @@ -148,6 +148,7 @@ export function RecoverySplash(props: RecoverySplashProps): JSX.Element | null { const isDisabled = (): boolean => { switch (runStatus) { case RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR: + case RUN_STATUS_AWAITING_RECOVERY_PAUSED: return true default: return false diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryWizard.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryWizard.test.tsx index ceaea85e58c..62fb2849753 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryWizard.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryWizard.test.tsx @@ -29,7 +29,11 @@ import { import { RecoveryInProgress } from '../RecoveryInProgress' import { RecoveryError } from '../RecoveryError' import { RecoveryDoorOpen } from '../RecoveryDoorOpen' -import { useErrorDetailsModal, ErrorDetailsModal } from '../shared' +import { + useErrorDetailsModal, + ErrorDetailsModal, + RecoveryDoorOpenSpecial, +} from '../shared' import type { Mock } from 'vitest' @@ -37,12 +41,14 @@ vi.mock('../RecoveryOptions') vi.mock('../RecoveryInProgress') vi.mock('../RecoveryError') vi.mock('../RecoveryDoorOpen') +vi.mock('../hooks') vi.mock('../shared', async importOriginal => { const actual = await importOriginal() return { ...actual, useErrorDetailsModal: vi.fn(), ErrorDetailsModal: vi.fn(), + RecoveryDoorOpenSpecial: vi.fn(), } }) describe('useERWizard', () => { @@ -181,6 +187,7 @@ describe('ErrorRecoveryContent', () => { DROP_TIP_FLOWS, ERROR_WHILE_RECOVERING, ROBOT_DOOR_OPEN, + ROBOT_DOOR_OPEN_SPECIAL, ROBOT_RELEASING_LABWARE, MANUAL_REPLACE_AND_RETRY, MANUAL_MOVE_AND_SKIP, @@ -218,6 +225,9 @@ describe('ErrorRecoveryContent', () => {
MOCK_IGNORE_ERROR_SKIP_STEP
) vi.mocked(RecoveryDoorOpen).mockReturnValue(
MOCK_DOOR_OPEN
) + vi.mocked(RecoveryDoorOpenSpecial).mockReturnValue( +
MOCK_DOOR_OPEN_SPECIAL
+ ) }) it(`returns SelectRecoveryOption when the route is ${OPTION_SELECTION.ROUTE}`, () => { @@ -485,6 +495,19 @@ describe('ErrorRecoveryContent', () => { screen.getByText('MOCK_DOOR_OPEN') }) + + it(`returns RecoveryDoorOpenSpecial when the route is ${ROBOT_DOOR_OPEN_SPECIAL.ROUTE}`, () => { + props = { + ...props, + recoveryMap: { + ...props.recoveryMap, + route: ROBOT_DOOR_OPEN_SPECIAL.ROUTE, + }, + } + renderRecoveryContent(props) + + screen.getByText('MOCK_DOOR_OPEN_SPECIAL') + }) }) describe('useInitialPipetteHome', () => { diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoveryInProgress.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoveryInProgress.test.tsx index 0eb61b8f5b0..c3005c10cda 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoveryInProgress.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoveryInProgress.test.tsx @@ -5,7 +5,11 @@ import { act, renderHook, screen } from '@testing-library/react' import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' import { mockRecoveryContentProps } from '../__fixtures__' -import { RecoveryInProgress, useGripperRelease } from '../RecoveryInProgress' +import { + RecoveryInProgress, + useGripperRelease, + GRIPPER_RELEASE_COUNTDOWN_S, +} from '../RecoveryInProgress' import { RECOVERY_MAP } from '../constants' const render = (props: React.ComponentProps) => { @@ -124,7 +128,7 @@ describe('RecoveryInProgress', () => { } render(props) - screen.getByText('Gripper will release labware in 5 seconds') + screen.getByText('Gripper will release labware in 3 seconds') }) it('updates countdown for gripper release', () => { @@ -138,16 +142,16 @@ describe('RecoveryInProgress', () => { } render(props) - screen.getByText('Gripper will release labware in 5 seconds') + screen.getByText('Gripper will release labware in 3 seconds') act(() => { vi.advanceTimersByTime(1000) }) - screen.getByText('Gripper will release labware in 4 seconds') + screen.getByText('Gripper will release labware in 2 seconds') act(() => { - vi.advanceTimersByTime(4000) + vi.advanceTimersByTime(GRIPPER_RELEASE_COUNTDOWN_S * 1000 - 1000) }) screen.getByText('Gripper releasing labware') @@ -171,6 +175,10 @@ describe('useGripperRelease', () => { route: RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, }, }, + currentRecoveryOptionUtils: { + selectedRecoveryOption: RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, + }, + doorStatusUtils: { isDoorOpen: false }, } as any beforeEach(() => { @@ -181,70 +189,111 @@ describe('useGripperRelease', () => { vi.useRealTimers() }) - it('counts down from 5 seconds', () => { + it('counts down from 3 seconds', () => { const { result } = renderHook(() => useGripperRelease(mockProps)) - expect(result.current).toBe(5) + expect(result.current).toBe(3) act(() => { vi.advanceTimersByTime(1000) }) - expect(result.current).toBe(4) + expect(result.current).toBe(2) act(() => { - vi.advanceTimersByTime(4000) + vi.advanceTimersByTime(GRIPPER_RELEASE_COUNTDOWN_S * 1000 - 1000) }) expect(result.current).toBe(0) }) - it('releases gripper jaws and proceeds to next step after countdown', async () => { - renderHook(() => useGripperRelease(mockProps)) - - act(() => { - vi.advanceTimersByTime(5000) + const IS_DOOR_OPEN = [false, true] + + IS_DOOR_OPEN.forEach(doorStatus => { + it(`releases gripper jaws and proceeds to next step after countdown for ${RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE} when the isDoorOpen is ${doorStatus}`, async () => { + renderHook(() => + useGripperRelease({ + ...mockProps, + doorStatusUtils: { isDoorOpen: doorStatus }, + }) + ) + + act(() => { + vi.advanceTimersByTime(GRIPPER_RELEASE_COUNTDOWN_S * 1000) + }) + + await vi.runAllTimersAsync() + + expect(mockProps.recoveryCommands.releaseGripperJaws).toHaveBeenCalled() + expect( + mockProps.routeUpdateActions.handleMotionRouting + ).toHaveBeenCalledWith(false) + if (!doorStatus) { + expect( + mockProps.routeUpdateActions.proceedToRouteAndStep + ).toHaveBeenCalledWith( + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE + ) + } else { + expect( + mockProps.routeUpdateActions.proceedToRouteAndStep + ).toHaveBeenCalledWith( + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME + ) + } }) - - await vi.runAllTimersAsync() - - expect(mockProps.recoveryCommands.releaseGripperJaws).toHaveBeenCalled() - expect( - mockProps.routeUpdateActions.handleMotionRouting - ).toHaveBeenCalledWith(false) - expect( - mockProps.routeUpdateActions.proceedToRouteAndStep - ).toHaveBeenCalledWith( - RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, - RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE - ) }) - it('handles MANUAL_REPLACE_AND_RETRY route', async () => { - const modifiedProps = { - ...mockProps, - routeUpdateActions: { - ...mockProps.routeUpdateActions, - stashedMap: { - route: RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE, + IS_DOOR_OPEN.forEach(doorStatus => { + it(`releases gripper jaws and proceeds to next step after countdown for ${RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE} when the isDoorOpen is ${doorStatus}`, async () => { + const modifiedProps = { + ...mockProps, + routeUpdateActions: { + ...mockProps.routeUpdateActions, + stashedMap: { + route: RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, + }, }, - }, - } - - renderHook(() => useGripperRelease(modifiedProps)) - - act(() => { - vi.advanceTimersByTime(5000) + currentRecoveryOptionUtils: { + selectedRecoveryOption: RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, + }, + } + + renderHook(() => + useGripperRelease({ + ...modifiedProps, + doorStatusUtils: { isDoorOpen: doorStatus }, + }) + ) + + act(() => { + vi.advanceTimersByTime(GRIPPER_RELEASE_COUNTDOWN_S * 1000) + }) + + await vi.runAllTimersAsync() + + expect(mockProps.recoveryCommands.releaseGripperJaws).toHaveBeenCalled() + expect( + mockProps.routeUpdateActions.handleMotionRouting + ).toHaveBeenCalledWith(false) + if (!doorStatus) { + expect( + mockProps.routeUpdateActions.proceedToRouteAndStep + ).toHaveBeenCalledWith( + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE + ) + } else { + expect( + mockProps.routeUpdateActions.proceedToRouteAndStep + ).toHaveBeenCalledWith( + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME + ) + } }) - - await vi.runAllTimersAsync() - - expect( - modifiedProps.routeUpdateActions.proceedToRouteAndStep - ).toHaveBeenCalledWith( - RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE, - RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE - ) }) it('calls proceedNextStep for unhandled routes', async () => { @@ -256,12 +305,16 @@ describe('useGripperRelease', () => { route: 'UNHANDLED_ROUTE', }, }, + currentRecoveryOptionUtils: { + selectedRecoveryOption: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE, + }, + doorStatusUtils: { isDoorOpen: false }, } renderHook(() => useGripperRelease(modifiedProps)) act(() => { - vi.advanceTimersByTime(5000) + vi.advanceTimersByTime(GRIPPER_RELEASE_COUNTDOWN_S * 1000) }) await vi.runAllTimersAsync() diff --git a/app/src/organisms/ErrorRecoveryFlows/constants.ts b/app/src/organisms/ErrorRecoveryFlows/constants.ts index b32416220b9..4923ceca53e 100644 --- a/app/src/organisms/ErrorRecoveryFlows/constants.ts +++ b/app/src/organisms/ErrorRecoveryFlows/constants.ts @@ -10,7 +10,7 @@ import { TEXT_ALIGN_CENTER, } from '@opentrons/components' -import type { RecoveryRouteStepMetadata, StepOrder } from './types' +import type { RecoveryRouteStepMetadata, RouteStep, StepOrder } from './types' // Server-defined error types. // (Values for the .error.errorType property of a run command.) @@ -101,6 +101,12 @@ export const RECOVERY_MAP = { DOOR_OPEN: 'door-open', }, }, + ROBOT_DOOR_OPEN_SPECIAL: { + ROUTE: 'door-special', + STEPS: { + DOOR_OPEN: 'door-open', + }, + }, // Recovery options below OPTION_SELECTION: { ROUTE: 'option-selection', @@ -126,6 +132,7 @@ export const RECOVERY_MAP = { STEPS: { GRIPPER_HOLDING_LABWARE: 'gripper-holding-labware', GRIPPER_RELEASE_LABWARE: 'gripper-release-labware', + CLOSE_DOOR_GRIPPER_Z_HOME: 'close-robot-door', MANUAL_MOVE: 'manual-move', SKIP: 'skip', }, @@ -135,6 +142,7 @@ export const RECOVERY_MAP = { STEPS: { GRIPPER_HOLDING_LABWARE: 'gripper-holding-labware', GRIPPER_RELEASE_LABWARE: 'gripper-release-labware', + CLOSE_DOOR_GRIPPER_Z_HOME: 'close-robot-door', MANUAL_REPLACE: 'manual-replace', RETRY: 'retry', }, @@ -187,6 +195,7 @@ const { ROBOT_RETRYING_STEP, ROBOT_SKIPPING_STEP, ROBOT_DOOR_OPEN, + ROBOT_DOOR_OPEN_SPECIAL, DROP_TIP_FLOWS, REFILL_AND_RESUME, IGNORE_AND_SKIP, @@ -229,6 +238,7 @@ export const STEP_ORDER: StepOrder = { [ROBOT_RETRYING_STEP.ROUTE]: [ROBOT_RETRYING_STEP.STEPS.RETRYING], [ROBOT_SKIPPING_STEP.ROUTE]: [ROBOT_SKIPPING_STEP.STEPS.SKIPPING], [ROBOT_DOOR_OPEN.ROUTE]: [ROBOT_DOOR_OPEN.STEPS.DOOR_OPEN], + [ROBOT_DOOR_OPEN_SPECIAL.ROUTE]: [ROBOT_DOOR_OPEN_SPECIAL.STEPS.DOOR_OPEN], [DROP_TIP_FLOWS.ROUTE]: [ DROP_TIP_FLOWS.STEPS.BEGIN_REMOVAL, DROP_TIP_FLOWS.STEPS.BEFORE_BEGINNING, @@ -245,12 +255,14 @@ export const STEP_ORDER: StepOrder = { [MANUAL_MOVE_AND_SKIP.ROUTE]: [ MANUAL_MOVE_AND_SKIP.STEPS.GRIPPER_HOLDING_LABWARE, MANUAL_MOVE_AND_SKIP.STEPS.GRIPPER_RELEASE_LABWARE, + MANUAL_MOVE_AND_SKIP.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME, MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE, MANUAL_MOVE_AND_SKIP.STEPS.SKIP, ], [MANUAL_REPLACE_AND_RETRY.ROUTE]: [ MANUAL_REPLACE_AND_RETRY.STEPS.GRIPPER_HOLDING_LABWARE, MANUAL_REPLACE_AND_RETRY.STEPS.GRIPPER_RELEASE_LABWARE, + MANUAL_MOVE_AND_SKIP.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME, MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE, MANUAL_REPLACE_AND_RETRY.STEPS.RETRY, ], @@ -316,6 +328,9 @@ export const RECOVERY_MAP_METADATA: RecoveryRouteStepMetadata = { [ROBOT_DOOR_OPEN.ROUTE]: { [ROBOT_DOOR_OPEN.STEPS.DOOR_OPEN]: { allowDoorOpen: false }, }, + [ROBOT_DOOR_OPEN_SPECIAL.ROUTE]: { + [ROBOT_DOOR_OPEN_SPECIAL.STEPS.DOOR_OPEN]: { allowDoorOpen: true }, + }, [OPTION_SELECTION.ROUTE]: { [OPTION_SELECTION.STEPS.SELECT]: { allowDoorOpen: false }, }, @@ -340,6 +355,9 @@ export const RECOVERY_MAP_METADATA: RecoveryRouteStepMetadata = { [MANUAL_MOVE_AND_SKIP.STEPS.GRIPPER_RELEASE_LABWARE]: { allowDoorOpen: true, }, + [MANUAL_MOVE_AND_SKIP.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME]: { + allowDoorOpen: true, + }, [MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE]: { allowDoorOpen: true }, [MANUAL_MOVE_AND_SKIP.STEPS.SKIP]: { allowDoorOpen: true }, }, @@ -350,6 +368,9 @@ export const RECOVERY_MAP_METADATA: RecoveryRouteStepMetadata = { [MANUAL_REPLACE_AND_RETRY.STEPS.GRIPPER_RELEASE_LABWARE]: { allowDoorOpen: true, }, + [MANUAL_REPLACE_AND_RETRY.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME]: { + allowDoorOpen: true, + }, [MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE]: { allowDoorOpen: true }, [MANUAL_REPLACE_AND_RETRY.STEPS.RETRY]: { allowDoorOpen: true }, }, @@ -387,6 +408,18 @@ export const RECOVERY_MAP_METADATA: RecoveryRouteStepMetadata = { }, } as const +/** + * Special step groupings + */ + +export const GRIPPER_MOVE_STEPS: RouteStep[] = [ + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.GRIPPER_RELEASE_LABWARE, + RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.STEPS.GRIPPER_RELEASE_LABWARE, + RECOVERY_MAP.ROBOT_RELEASING_LABWARE.STEPS.RELEASING_LABWARE, + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE, + RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE, +] + export const INVALID = 'INVALID' as const /** diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.ts index b20ab13a1cd..a98818b6efd 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect } from 'vitest' +import { renderHook } from '@testing-library/react' import { getRelevantWellName, getRelevantFailedLabwareCmdFrom, + useRelevantFailedLwLocations, } from '../useFailedLabwareUtils' import { DEFINED_ERROR_TYPES } from '../../constants' @@ -120,6 +122,22 @@ describe('getRelevantFailedLabwareCmdFrom', () => { expect(result).toBe(pickUpTipCommand) }) }) + + it('should return the failedCommand for GRIPPER_ERROR error kind', () => { + const failedGripperCommand = { + ...failedCommand, + commandType: 'moveLabware', + error: { + isDefined: true, + errorType: DEFINED_ERROR_TYPES.GRIPPER_MOVEMENT, + }, + } + const result = getRelevantFailedLabwareCmdFrom({ + failedCommandByRunRecord: failedGripperCommand, + }) + expect(result).toEqual(failedGripperCommand) + }) + it('should return null for GENERAL_ERROR error kind', () => { const result = getRelevantFailedLabwareCmdFrom({ failedCommandByRunRecord: { @@ -140,3 +158,55 @@ describe('getRelevantFailedLabwareCmdFrom', () => { expect(result).toBeNull() }) }) + +// TODO(jh 10-15-24): This testing will can more useful once translation is refactored out of this function. +describe('useRelevantFailedLwLocations', () => { + const mockProtocolAnalysis = {} as any + const mockAllRunDefs = [] as any + const mockFailedLabware = { + location: { slot: 'D1' }, + } as any + + it('should return current location for non-moveLabware commands', () => { + const mockFailedCommand = { + commandType: 'aspirate', + } as any + + const { result } = renderHook(() => + useRelevantFailedLwLocations({ + failedLabware: mockFailedLabware, + failedCommandByRunRecord: mockFailedCommand, + protocolAnalysis: mockProtocolAnalysis, + allRunDefs: mockAllRunDefs, + }) + ) + + expect(result.current).toEqual({ + currentLoc: '', + newLoc: null, + }) + }) + + it('should return current and new location for moveLabware commands', () => { + const mockFailedCommand = { + commandType: 'moveLabware', + params: { + newLocation: { slot: 'C2' }, + }, + } as any + + const { result } = renderHook(() => + useRelevantFailedLwLocations({ + failedLabware: mockFailedLabware, + failedCommandByRunRecord: mockFailedCommand, + protocolAnalysis: mockProtocolAnalysis, + allRunDefs: mockAllRunDefs, + }) + ) + + expect(result.current).toEqual({ + currentLoc: '', + newLoc: '', + }) + }) +}) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripperZAxis.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripperZAxis.test.ts new file mode 100644 index 00000000000..197dfbfd3e7 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripperZAxis.test.ts @@ -0,0 +1,122 @@ +import { renderHook, act } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { useHomeGripperZAxis } from '../useHomeGripperZAxis' +import { RECOVERY_MAP } from '/app/organisms/ErrorRecoveryFlows/constants' + +describe('useHomeGripperZAxis', () => { + const mockRecoveryCommands = { + homeGripperZAxis: vi.fn().mockResolvedValue(undefined), + } + + const mockRouteUpdateActions = { + handleMotionRouting: vi.fn().mockResolvedValue(undefined), + goBackPrevStep: vi.fn(), + } + + const mockRecoveryMap = { + step: RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE, + } + + const mockDoorStatusUtils = { + isDoorOpen: false, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should home gripper Z axis when in manual gripper step and door is closed', async () => { + renderHook(() => { + useHomeGripperZAxis({ + recoveryCommands: mockRecoveryCommands, + routeUpdateActions: mockRouteUpdateActions, + recoveryMap: mockRecoveryMap, + doorStatusUtils: mockDoorStatusUtils, + } as any) + }) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)) + }) + + expect(mockRouteUpdateActions.handleMotionRouting).toHaveBeenCalledWith( + true + ) + expect(mockRecoveryCommands.homeGripperZAxis).toHaveBeenCalled() + expect(mockRouteUpdateActions.handleMotionRouting).toHaveBeenCalledWith( + false + ) + }) + + it('should go back to previous step when door is open', () => { + renderHook(() => { + useHomeGripperZAxis({ + recoveryCommands: mockRecoveryCommands, + routeUpdateActions: mockRouteUpdateActions, + recoveryMap: mockRecoveryMap, + doorStatusUtils: { ...mockDoorStatusUtils, isDoorOpen: true }, + } as any) + }) + + expect(mockRouteUpdateActions.goBackPrevStep).toHaveBeenCalled() + expect(mockRecoveryCommands.homeGripperZAxis).not.toHaveBeenCalled() + }) + + it('should not home again if already homed once', async () => { + const { rerender } = renderHook(() => { + useHomeGripperZAxis({ + recoveryCommands: mockRecoveryCommands, + routeUpdateActions: mockRouteUpdateActions, + recoveryMap: mockRecoveryMap, + doorStatusUtils: mockDoorStatusUtils, + } as any) + }) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)) + }) + + expect(mockRecoveryCommands.homeGripperZAxis).toHaveBeenCalledTimes(1) + + rerender() + + expect(mockRecoveryCommands.homeGripperZAxis).toHaveBeenCalledTimes(1) + }) + + it('should reset hasHomedOnce when step changes to non-manual gripper step and back', async () => { + const { rerender } = renderHook( + ({ recoveryMap }) => { + useHomeGripperZAxis({ + recoveryCommands: mockRecoveryCommands, + routeUpdateActions: mockRouteUpdateActions, + recoveryMap, + doorStatusUtils: mockDoorStatusUtils, + } as any) + }, + { + initialProps: { recoveryMap: mockRecoveryMap }, + } + ) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)) + }) + + expect(mockRecoveryCommands.homeGripperZAxis).toHaveBeenCalledTimes(1) + + rerender({ recoveryMap: { step: 'SOME_OTHER_STEP' } as any }) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)) + }) + + rerender({ recoveryMap: mockRecoveryMap }) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)) + }) + + expect(mockRecoveryCommands.homeGripperZAxis).toHaveBeenCalledTimes(2) + }) +}) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts index 8df2c3ec86b..016e38be69d 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts @@ -11,8 +11,10 @@ import { useChainRunCommands } from '/app/resources/runs' import { useRecoveryCommands, HOME_PIPETTE_Z_AXES, + RELEASE_GRIPPER_JAW, buildPickUpTips, buildIgnorePolicyRules, + HOME_GRIPPER_Z_AXIS, } from '../useRecoveryCommands' import { RECOVERY_MAP } from '../../constants' @@ -252,14 +254,27 @@ describe('useRecoveryCommands', () => { it('should call releaseGripperJaws and resolve the promise', async () => { const { result } = renderHook(() => useRecoveryCommands(props)) - const consoleLogSpy = vi.spyOn(console, 'log') - await act(async () => { await result.current.releaseGripperJaws() }) - expect(consoleLogSpy).toHaveBeenCalledWith('PLACEHOLDER RELEASE THE JAWS') - consoleLogSpy.mockRestore() + expect(mockChainRunCommands).toHaveBeenCalledWith( + [RELEASE_GRIPPER_JAW], + false + ) + }) + + it('should call homeGripperZAxis and resolve the promise', async () => { + const { result } = renderHook(() => useRecoveryCommands(props)) + + await act(async () => { + await result.current.homeGripperZAxis() + }) + + expect(mockChainRunCommands).toHaveBeenCalledWith( + [HOME_GRIPPER_Z_AXIS], + false + ) }) it('should call skipFailedCommand and show success toast on success', async () => { diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useShowDoorInfo.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useShowDoorInfo.test.ts index 753823f2ec6..1ebb6e1d018 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useShowDoorInfo.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useShowDoorInfo.test.ts @@ -1,19 +1,24 @@ -import { describe, it, expect, beforeEach } from 'vitest' import { renderHook, act } from '@testing-library/react' -import { useShowDoorInfo } from '../useShowDoorInfo' +import { describe, it, expect, beforeEach } from 'vitest' + import { + RUN_STATUS_AWAITING_RECOVERY, RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, RUN_STATUS_AWAITING_RECOVERY_PAUSED, - RUN_STATUS_AWAITING_RECOVERY, } from '@opentrons/api-client' -import { RECOVERY_MAP } from '../../constants' +import { useShowDoorInfo } from '../useShowDoorInfo' +import { + RECOVERY_MAP, + GRIPPER_MOVE_STEPS, +} from '/app/organisms/ErrorRecoveryFlows/constants' -import type { IRecoveryMap } from '../../types' +import type { IRecoveryMap, RouteStep } from '../../types' describe('useShowDoorInfo', () => { let initialProps: Parameters[0] let mockRecoveryMap: IRecoveryMap + let initialStep: RouteStep beforeEach(() => { initialProps = RUN_STATUS_AWAITING_RECOVERY @@ -21,11 +26,12 @@ describe('useShowDoorInfo', () => { route: RECOVERY_MAP.OPTION_SELECTION.ROUTE, step: RECOVERY_MAP.OPTION_SELECTION.STEPS.SELECT, } as IRecoveryMap + initialStep = RECOVERY_MAP.OPTION_SELECTION.STEPS.SELECT }) it('should return false values initially', () => { const { result } = renderHook(() => - useShowDoorInfo(initialProps, mockRecoveryMap) + useShowDoorInfo(initialProps, mockRecoveryMap, initialStep) ) expect(result.current).toEqual({ isDoorOpen: false, @@ -36,7 +42,9 @@ describe('useShowDoorInfo', () => { it(`should return true values when runStatus is ${RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR}`, () => { const props = RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR - const { result } = renderHook(() => useShowDoorInfo(props, mockRecoveryMap)) + const { result } = renderHook(() => + useShowDoorInfo(props, mockRecoveryMap, initialStep) + ) expect(result.current).toEqual({ isDoorOpen: true, isProhibitedDoorOpen: true, @@ -46,7 +54,9 @@ describe('useShowDoorInfo', () => { it(`should return true values when runStatus is ${RUN_STATUS_AWAITING_RECOVERY_PAUSED}`, () => { const props = RUN_STATUS_AWAITING_RECOVERY_PAUSED - const { result } = renderHook(() => useShowDoorInfo(props, mockRecoveryMap)) + const { result } = renderHook(() => + useShowDoorInfo(props, mockRecoveryMap, initialStep) + ) expect(result.current).toEqual({ isDoorOpen: true, isProhibitedDoorOpen: true, @@ -55,9 +65,14 @@ describe('useShowDoorInfo', () => { it(`should keep returning true values when runStatus changes from ${RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR} to ${RUN_STATUS_AWAITING_RECOVERY_PAUSED}`, () => { const { result, rerender } = renderHook( - ({ runStatus, recoveryMap }) => useShowDoorInfo(runStatus, recoveryMap), + ({ runStatus, recoveryMap, currentStep }) => + useShowDoorInfo(runStatus, recoveryMap, currentStep), { - initialProps: { runStatus: initialProps, recoveryMap: mockRecoveryMap }, + initialProps: { + runStatus: initialProps, + recoveryMap: mockRecoveryMap, + currentStep: initialStep, + }, } ) @@ -65,6 +80,7 @@ describe('useShowDoorInfo', () => { rerender({ runStatus: RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, recoveryMap: mockRecoveryMap, + currentStep: initialStep, }) }) expect(result.current).toEqual({ @@ -76,6 +92,7 @@ describe('useShowDoorInfo', () => { rerender({ runStatus: RUN_STATUS_AWAITING_RECOVERY_PAUSED, recoveryMap: mockRecoveryMap, + currentStep: initialStep, }) }) expect(result.current).toEqual({ @@ -86,11 +103,13 @@ describe('useShowDoorInfo', () => { it('should return false values when runStatus changes to a non-door open status', () => { const { result, rerender } = renderHook( - ({ runStatus, recoveryMap }) => useShowDoorInfo(runStatus, recoveryMap), + ({ runStatus, recoveryMap, currentStep }) => + useShowDoorInfo(runStatus, recoveryMap, currentStep), { initialProps: { runStatus: RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, recoveryMap: mockRecoveryMap, + currentStep: initialStep, }, } ) @@ -104,6 +123,7 @@ describe('useShowDoorInfo', () => { rerender({ runStatus: RUN_STATUS_AWAITING_RECOVERY as any, recoveryMap: mockRecoveryMap, + currentStep: initialStep, }) }) expect(result.current).toEqual({ @@ -116,12 +136,31 @@ describe('useShowDoorInfo', () => { const props = RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR const { result } = renderHook(() => - useShowDoorInfo(props, { - route: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE, - step: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.STEPS.MANUAL_FILL, - }) + useShowDoorInfo( + props, + { + route: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE, + step: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.STEPS.MANUAL_FILL, + }, + RECOVERY_MAP.MANUAL_FILL_AND_SKIP.STEPS.MANUAL_FILL + ) ) expect(result.current.isProhibitedDoorOpen).toEqual(false) }) + + it('should return false for prohibited door if the current step is in GRIPPER_MOVE_STEPS', () => { + const props = RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR + + GRIPPER_MOVE_STEPS.forEach(step => { + const { result } = renderHook(() => + useShowDoorInfo(props, mockRecoveryMap, step) + ) + + expect(result.current).toEqual({ + isDoorOpen: true, + isProhibitedDoorOpen: false, + }) + }) + }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/index.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/index.ts index 2411c95c30e..da85e8b770e 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/index.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/index.ts @@ -6,6 +6,7 @@ export { useRouteUpdateActions } from './useRouteUpdateActions' export { useERUtils } from './useERUtils' export { useRecoveryTakeover } from './useRecoveryTakeover' export { useRetainedFailedCommandBySource } from './useRetainedFailedCommandBySource' +export { useHomeGripperZAxis } from './useHomeGripperZAxis' export type { ERUtilsProps } from './useERUtils' export type { UseRouteUpdateActionsResult } from './useRouteUpdateActions' diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts index 155c534ba6f..365bf01de36 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts @@ -107,7 +107,11 @@ export function useERUtils({ ...subMapUtils } = useRecoveryRouting() - const doorStatusUtils = useShowDoorInfo(runStatus, recoveryMap) + const doorStatusUtils = useShowDoorInfo( + runStatus, + recoveryMap, + recoveryMap.step + ) const recoveryToastUtils = useRecoveryToasts({ currentStepCount: stepCounts.currentStepNumber, @@ -147,6 +151,7 @@ export function useERUtils({ failedPipetteInfo, runRecord, runCommands, + allRunDefs, }) const recoveryCommands = useRecoveryCommands({ diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts index e1c15a9e264..ba86e77c553 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts @@ -1,7 +1,9 @@ import { useMemo, useState } from 'react' import without from 'lodash/without' +import { useTranslation } from 'react-i18next' import { + FLEX_ROBOT_TYPE, getAllLabwareDefs, getLabwareDisplayName, getLoadedLabwareDefinitionsByUri, @@ -10,7 +12,9 @@ import { import { ERROR_KINDS } from '../constants' import { getErrorKind } from '../utils' import { getLoadedLabware } from '/app/molecules/Command/utils/accessors' +import { getLabwareDisplayLocation } from '/app/molecules/Command' +import type { TFunction } from 'i18next' import type { WellGroup } from '@opentrons/components' import type { CommandsData, PipetteData, Run } from '@opentrons/api-client' import type { @@ -20,6 +24,8 @@ import type { AspirateRunTimeCommand, DispenseRunTimeCommand, LiquidProbeRunTimeCommand, + MoveLabwareRunTimeCommand, + LabwareLocation, } from '@opentrons/shared-data' import type { ErrorRecoveryFlowsProps } from '..' import type { ERUtilsProps } from './useERUtils' @@ -28,10 +34,16 @@ interface UseFailedLabwareUtilsProps { failedCommandByRunRecord: ERUtilsProps['failedCommandByRunRecord'] protocolAnalysis: ErrorRecoveryFlowsProps['protocolAnalysis'] failedPipetteInfo: PipetteData | null + allRunDefs: LabwareDefinition2[] runCommands?: CommandsData runRecord?: Run } +interface RelevantFailedLabwareLocations { + currentLoc: string + newLoc: string | null +} + export type UseFailedLabwareUtilsResult = UseTipSelectionUtilsResult & { /* The name of the labware relevant to the failed command, if any. */ failedLabwareName: string | null @@ -41,6 +53,7 @@ export type UseFailedLabwareUtilsResult = UseTipSelectionUtilsResult & { relevantWellName: string | null /* The user-content nickname of the failed labware, if any */ failedLabwareNickname: string | null + failedLabwareLocations: RelevantFailedLabwareLocations } /** Utils for labware relating to the failedCommand. @@ -55,6 +68,7 @@ export function useFailedLabwareUtils({ failedPipetteInfo, runCommands, runRecord, + allRunDefs, }: UseFailedLabwareUtilsProps): UseFailedLabwareUtilsResult { const recentRelevantFailedLabwareCmd = useMemo( () => @@ -87,12 +101,20 @@ export function useFailedLabwareUtils({ recentRelevantFailedLabwareCmd ) + const failedLabwareLocations = useRelevantFailedLwLocations({ + failedLabware, + failedCommandByRunRecord, + protocolAnalysis, + allRunDefs, + }) + return { ...tipSelectionUtils, failedLabwareName: failedLabwareDetails?.name ?? null, failedLabware, relevantWellName, failedLabwareNickname: failedLabwareDetails?.nickname ?? null, + failedLabwareLocations, } } @@ -101,6 +123,7 @@ type FailedCommandRelevantLabware = | Omit | Omit | Omit + | Omit | null interface RelevantFailedLabwareCmd { @@ -122,6 +145,8 @@ export function getRelevantFailedLabwareCmdFrom({ case ERROR_KINDS.OVERPRESSURE_WHILE_ASPIRATING: case ERROR_KINDS.OVERPRESSURE_WHILE_DISPENSING: return getRelevantPickUpTipCommand(failedCommandByRunRecord, runCommands) + case ERROR_KINDS.GRIPPER_ERROR: + return failedCommandByRunRecord as MoveLabwareRunTimeCommand case ERROR_KINDS.GENERAL_ERROR: return null default: @@ -177,11 +202,8 @@ interface UseTipSelectionUtilsResult { areTipsSelected: boolean } -// TODO(jh, 06-18-24): Enforce failure/warning when accessing tipSelectionUtils -// if used when the relevant labware -// is NOT relevant to tip pick up. - // Utils for initializing and interacting with the Tip Selector component. +// Note: if the relevant failed labware command is not associated with tips, these utils effectively return `null`. function useTipSelectionUtils( recentRelevantFailedLabwareCmd: FailedCommandRelevantLabware ): UseTipSelectionUtilsResult { @@ -293,7 +315,11 @@ export function getRelevantWellName( failedPipetteInfo: UseFailedLabwareUtilsProps['failedPipetteInfo'], recentRelevantPickUpTipCmd: FailedCommandRelevantLabware ): string { - if (failedPipetteInfo == null || recentRelevantPickUpTipCmd == null) { + if ( + failedPipetteInfo == null || + recentRelevantPickUpTipCmd == null || + recentRelevantPickUpTipCmd.commandType === 'moveLabware' + ) { return '' } @@ -309,3 +335,54 @@ export function getRelevantWellName( return wellName } } + +type GetRelevantLwLocationsParams = Pick< + UseFailedLabwareUtilsProps, + 'protocolAnalysis' | 'failedCommandByRunRecord' | 'allRunDefs' +> & { + failedLabware: UseFailedLabwareUtilsResult['failedLabware'] +} + +export function useRelevantFailedLwLocations({ + failedLabware, + failedCommandByRunRecord, + protocolAnalysis, + allRunDefs, +}: GetRelevantLwLocationsParams): RelevantFailedLabwareLocations { + const { t } = useTranslation('protocol_command_text') + const canGetDisplayLocation = + protocolAnalysis != null && failedLabware != null + + const buildLocationCopy = useMemo(() => { + return (location: LabwareLocation | undefined): string | null => { + return canGetDisplayLocation && location != null + ? getLabwareDisplayLocation( + protocolAnalysis, + allRunDefs, + location, + t as TFunction, + FLEX_ROBOT_TYPE, + false // Always return the "full" copy, which is the desktop copy. + ) + : null + } + }, [canGetDisplayLocation, allRunDefs]) + + const currentLocation = useMemo(() => { + return buildLocationCopy(failedLabware?.location) ?? '' + }, [canGetDisplayLocation]) + + const newLocation = useMemo(() => { + switch (failedCommandByRunRecord?.commandType) { + case 'moveLabware': + return buildLocationCopy(failedCommandByRunRecord.params.newLocation) + default: + return null + } + }, [canGetDisplayLocation, failedCommandByRunRecord?.key]) + + return { + currentLoc: currentLocation, + newLoc: newLocation, + } +} diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripperZAxis.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripperZAxis.ts new file mode 100644 index 00000000000..649fb801d44 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripperZAxis.ts @@ -0,0 +1,44 @@ +import { useLayoutEffect, useState } from 'react' +import { RECOVERY_MAP } from '/app/organisms/ErrorRecoveryFlows/constants' + +import type { ErrorRecoveryWizardProps } from '/app/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard' + +// Home the gripper z-axis implicitly. Because the z-home is not tied to a CTA, it must be handled here. +export function useHomeGripperZAxis({ + recoveryCommands, + routeUpdateActions, + recoveryMap, + doorStatusUtils, +}: ErrorRecoveryWizardProps): void { + const { step } = recoveryMap + const { isDoorOpen } = doorStatusUtils + const [hasHomedOnce, setHasHomedOnce] = useState(false) + + const isManualGripperStep = + step === RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE || + step === RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE + + useLayoutEffect(() => { + const { handleMotionRouting, goBackPrevStep } = routeUpdateActions + const { homeGripperZAxis } = recoveryCommands + + if (!hasHomedOnce) { + if (isManualGripperStep) { + if (isDoorOpen) { + void goBackPrevStep() + } else { + void handleMotionRouting(true) + .then(() => homeGripperZAxis()) + .then(() => { + setHasHomedOnce(true) + }) + .finally(() => handleMotionRouting(false)) + } + } + } else { + if (!isManualGripperStep) { + setHasHomedOnce(false) + } + } + }, [step, hasHomedOnce, isDoorOpen, isManualGripperStep]) +} diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts index f463d4dd107..fd78a62bcf6 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts @@ -58,7 +58,9 @@ export interface UseRecoveryCommandsResult { /* A non-terminal recovery command */ pickUpTips: () => Promise /* A non-terminal recovery command */ - releaseGripperJaws: () => Promise + releaseGripperJaws: () => Promise + /* A non-terminal recovery command */ + homeGripperZAxis: () => Promise } // TODO(jh, 07-24-24): Create tighter abstractions for terminal vs. non-terminal commands. @@ -215,10 +217,13 @@ export function useRecoveryCommands({ failedCommandByRunRecord?.commandType, ]) - const releaseGripperJaws = useCallback((): Promise => { - console.log('PLACEHOLDER RELEASE THE JAWS') - return Promise.resolve() - }, []) + const releaseGripperJaws = useCallback((): Promise => { + return chainRunRecoveryCommands([RELEASE_GRIPPER_JAW]) + }, [chainRunRecoveryCommands]) + + const homeGripperZAxis = useCallback((): Promise => { + return chainRunRecoveryCommands([HOME_GRIPPER_Z_AXIS]) + }, [chainRunRecoveryCommands]) return { resumeRun, @@ -227,6 +232,7 @@ export function useRecoveryCommands({ homePipetteZAxes, pickUpTips, releaseGripperJaws, + homeGripperZAxis, skipFailedCommand, ignoreErrorKindThisRun, } @@ -238,6 +244,18 @@ export const HOME_PIPETTE_Z_AXES: CreateCommand = { intent: 'fixit', } +export const RELEASE_GRIPPER_JAW: CreateCommand = { + commandType: 'unsafe/ungripLabware', + params: {}, + intent: 'fixit', +} + +export const HOME_GRIPPER_Z_AXIS: CreateCommand = { + commandType: 'home', + params: { axes: ['extensionZ'] }, + intent: 'fixit', +} + export const buildPickUpTips = ( tipGroup: WellGroup | null, failedCommand: FailedCommand | null, diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRouteUpdateActions.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRouteUpdateActions.ts index faf6ddf7a4a..09ef7b3dd47 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRouteUpdateActions.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRouteUpdateActions.ts @@ -4,7 +4,12 @@ import last from 'lodash/last' import head from 'lodash/head' -import { INVALID, RECOVERY_MAP, STEP_ORDER } from '../constants' +import { + INVALID, + RECOVERY_MAP, + STEP_ORDER, + GRIPPER_MOVE_STEPS, +} from '../constants' import type { IRecoveryMap, RecoveryRoute, @@ -14,11 +19,6 @@ import type { import type { UseRecoveryTakeoverResult } from './useRecoveryTakeover' import type { UseShowDoorInfoResult } from './useShowDoorInfo' -const GRIPPER_MOVE_STEPS: RouteStep[] = [ - RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.GRIPPER_RELEASE_LABWARE, - RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.STEPS.GRIPPER_RELEASE_LABWARE, -] - export interface GetRouteUpdateActionsParams { hasLaunchedRecovery: boolean toggleERWizAsActiveUser: UseRecoveryTakeoverResult['toggleERWizAsActiveUser'] diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useShowDoorInfo.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useShowDoorInfo.ts index 61b9131b15e..fc8569c02d8 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useShowDoorInfo.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useShowDoorInfo.ts @@ -3,11 +3,11 @@ import { RUN_STATUS_AWAITING_RECOVERY_PAUSED, } from '@opentrons/api-client' -import { RECOVERY_MAP_METADATA } from '../constants' +import { GRIPPER_MOVE_STEPS, RECOVERY_MAP_METADATA } from '../constants' import type { RunStatus } from '@opentrons/api-client' import type { ErrorRecoveryFlowsProps } from '../index' -import type { IRecoveryMap } from '../types' +import type { IRecoveryMap, RouteStep } from '../types' const DOOR_OPEN_STATUSES: RunStatus[] = [ RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, @@ -24,13 +24,17 @@ export interface UseShowDoorInfoResult { // Whether the door is open and not permitted to be open or the user has not yet resumed the run after a door open event. export function useShowDoorInfo( runStatus: ErrorRecoveryFlowsProps['runStatus'], - recoveryMap: IRecoveryMap + recoveryMap: IRecoveryMap, + currentStep: RouteStep ): UseShowDoorInfoResult { // TODO(jh, 07-16-24): "recovery paused" is only used for door status and therefore // a valid way to ensure all apps show the door open prompt, however this could be problematic in the future. // Consider restructuring this check once the takeover modals are added. const isDoorOpen = runStatus != null && DOOR_OPEN_STATUSES.includes(runStatus) - const isProhibitedDoorOpen = isDoorOpen && !isDoorPermittedOpen(recoveryMap) + const isProhibitedDoorOpen = + isDoorOpen && + !isDoorPermittedOpen(recoveryMap) && + !GRIPPER_MOVE_STEPS.includes(currentStep) return { isDoorOpen, isProhibitedDoorOpen } } diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/LeftColumnLabwareInfo.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/LeftColumnLabwareInfo.tsx index 80c0422a940..ad1e7b0bc4a 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/LeftColumnLabwareInfo.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/LeftColumnLabwareInfo.tsx @@ -1,7 +1,6 @@ -import type * as React from 'react' - import { InterventionContent } from '/app/molecules/InterventionModal/InterventionContent' +import type * as React from 'react' import type { RecoveryContentProps } from '../types' type LeftColumnLabwareInfoProps = RecoveryContentProps & { @@ -20,22 +19,15 @@ export function LeftColumnLabwareInfo({ }: LeftColumnLabwareInfoProps): JSX.Element | null { const { failedLabwareName, - failedLabware, failedLabwareNickname, + failedLabwareLocations, } = failedLabwareUtils + const { newLoc, currentLoc } = failedLabwareLocations - const buildLabwareLocationSlotName = (): string => { - const location = failedLabware?.location - if ( - location != null && - typeof location === 'object' && - 'slotName' in location - ) { - return location.slotName - } else { - return '' - } - } + const buildNewLocation = (): React.ComponentProps< + typeof InterventionContent + >['infoProps']['newLocationProps'] => + newLoc != null ? { deckLabel: newLoc.toUpperCase() } : undefined return ( { + setIsLoading(true) + void resumeRecovery() + } + + const buildSubtext = (): string => { + switch (selectedRecoveryOption) { + case RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE: + case RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE: + return t('door_open_gripper_home') + default: { + console.error( + `Unhandled special-cased door open subtext on route ${selectedRecoveryOption}.` + ) + return t('close_the_robot_door') + } + } + } + + if (!doorStatusUtils.isDoorOpen) { + const { proceedToRouteAndStep } = routeUpdateActions + switch (selectedRecoveryOption) { + case RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE: + void proceedToRouteAndStep( + RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE, + RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE + ) + break + case RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE: + void proceedToRouteAndStep( + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE + ) + break + default: { + console.error( + `Unhandled special-cased door open on route ${selectedRecoveryOption}.` + ) + void proceedToRouteAndStep(RECOVERY_MAP.OPTION_SELECTION.ROUTE) + } + } + } + + return ( + + + + + + {t('close_robot_door')} + + + {buildSubtext()} + + + + + + + + ) +} + +const TEXT_STYLE = css` + flex-direction: ${DIRECTION_COLUMN}; + grid-gap: ${SPACING.spacing8}; + align-items: ${ALIGN_CENTER}; + text-align: ${TEXT_ALIGN_CENTER}; + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + grid-gap: ${SPACING.spacing4}; + } +` + +const ICON_STYLE = css` + height: ${SPACING.spacing40}; + width: ${SPACING.spacing40}; + color: ${COLORS.yellow50}; + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + height: ${SPACING.spacing60}; + width: ${SPACING.spacing60}; + } +` diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/TwoColLwInfoAndDeck.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/TwoColLwInfoAndDeck.tsx index aab38a1aee0..b480c9614f2 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/TwoColLwInfoAndDeck.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/TwoColLwInfoAndDeck.tsx @@ -9,8 +9,8 @@ import { getSlotNameAndLwLocFrom } from '../hooks/useDeckMapUtils' import { RECOVERY_MAP } from '../constants' import type { RecoveryContentProps } from '../types' - -// TODO(jh, 10-09-24): Add testing for this component. +import type * as React from 'react' +import type { InterventionContent } from '/app/molecules/InterventionModal/InterventionContent' export function TwoColLwInfoAndDeck( props: RecoveryContentProps @@ -88,13 +88,25 @@ export function TwoColLwInfoAndDeck( } } + const buildType = (): React.ComponentProps< + typeof InterventionContent + >['infoProps']['type'] => { + switch (selectedRecoveryOption) { + case MANUAL_MOVE_AND_SKIP.ROUTE: + case MANUAL_REPLACE_AND_RETRY.ROUTE: + return 'location-arrow-location' + default: + return 'location' + } + } + return ( diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/GripperReleaseLabware.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/GripperReleaseLabware.test.tsx index 9a501e51459..9eff4a09ba4 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/GripperReleaseLabware.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/GripperReleaseLabware.test.tsx @@ -38,7 +38,7 @@ describe('GripperReleaseLabware', () => { screen.getByText( 'Take any necessary precautions before positioning yourself to stabilize or catch the labware. Once confirmed, a countdown will begin before the gripper releases.' ) - screen.getByText('The labware will be released from its current height') + screen.getByText('The labware will be released from its current height.') }) it('clicking the primary button has correct behavior', () => { diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/LeftColumnLabwareInfo.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/LeftColumnLabwareInfo.test.tsx index c714c0bc8a2..e2e6c268ef8 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/LeftColumnLabwareInfo.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/LeftColumnLabwareInfo.test.tsx @@ -6,11 +6,9 @@ import { renderWithProviders } from '/app/__testing-utils__' import { mockRecoveryContentProps } from '../../__fixtures__' import { i18n } from '/app/i18n' import { LeftColumnLabwareInfo } from '../LeftColumnLabwareInfo' -import { InterventionInfo } from '/app/molecules/InterventionModal/InterventionContent/InterventionInfo' -import { InlineNotification } from '/app/atoms/InlineNotification' +import { InterventionContent } from '/app/molecules/InterventionModal/InterventionContent' -vi.mock('/app/molecules/InterventionModal/InterventionContent/InterventionInfo') -vi.mock('/app/atoms/InlineNotification') +vi.mock('/app/molecules/InterventionModal/InterventionContent') const render = (props: React.ComponentProps) => { return renderWithProviders(, { @@ -27,60 +25,83 @@ describe('LeftColumnLabwareInfo', () => { title: 'MOCK_TITLE', failedLabwareUtils: { failedLabwareName: 'MOCK_LW_NAME', - failedLabware: { - location: { slotName: 'A1' }, + failedLabwareNickname: 'MOCK_LW_NICKNAME', + failedLabwareLocations: { + currentLoc: 'slot A1', + newLoc: 'slot B2', }, } as any, type: 'location', bannerText: 'MOCK_BANNER_TEXT', } - vi.mocked(InterventionInfo).mockReturnValue(
MOCK_MOVE
) - vi.mocked(InlineNotification).mockReturnValue( -
MOCK_INLINE_NOTIFICATION
+ vi.mocked(InterventionContent).mockReturnValue( +
MOCK_INTERVENTION_CONTENT
) }) - it('renders the title, InterventionInfo component, and InlineNotification when bannerText is provided', () => { + it('renders the InterventionContent component with correct props', () => { render(props) - screen.getByText('MOCK_TITLE') - screen.getByText('MOCK_MOVE') - expect(vi.mocked(InterventionInfo)).toHaveBeenCalledWith( + screen.getByText('MOCK_INTERVENTION_CONTENT') + expect(vi.mocked(InterventionContent)).toHaveBeenCalledWith( expect.objectContaining({ - type: 'location', - labwareName: 'MOCK_LW_NAME', - currentLocationProps: { deckLabel: 'A1' }, + headline: 'MOCK_TITLE', + infoProps: { + type: 'location', + labwareName: 'MOCK_LW_NAME', + labwareNickname: 'MOCK_LW_NICKNAME', + currentLocationProps: { deckLabel: 'SLOT A1' }, + newLocationProps: { deckLabel: 'SLOT B2' }, + }, + notificationProps: { + type: 'alert', + heading: 'MOCK_BANNER_TEXT', + }, }), {} ) - screen.getByText('MOCK_INLINE_NOTIFICATION') - expect(vi.mocked(InlineNotification)).toHaveBeenCalledWith( + }) + + it('does not include notificationProps when bannerText is not provided', () => { + props.bannerText = undefined + render(props) + + expect(vi.mocked(InterventionContent)).toHaveBeenCalledWith( expect.objectContaining({ - type: 'alert', - heading: 'MOCK_BANNER_TEXT', + notificationProps: undefined, }), {} ) }) - it('does not render the InlineNotification when bannerText is not provided', () => { - props.bannerText = undefined + it('does not include newLocationProps when newLoc is not provided', () => { + props.failedLabwareUtils.failedLabwareLocations.newLoc = null render(props) - screen.getByText('MOCK_TITLE') - screen.getByText('MOCK_MOVE') - expect(screen.queryByText('MOCK_INLINE_NOTIFICATION')).toBeNull() + expect(vi.mocked(InterventionContent)).toHaveBeenCalledWith( + expect.objectContaining({ + infoProps: expect.not.objectContaining({ + newLocationProps: expect.anything(), + }), + }), + {} + ) }) - it('returns an empty string for slotName when failedLabware location is not an object with slotName', () => { - // @ts-expect-error yeah this is ok - props.failedLabwareUtils.failedLabware.location = 'offDeck' + it('converts location labels to uppercase', () => { + props.failedLabwareUtils.failedLabwareLocations = { + currentLoc: 'slot A1', + newLoc: 'slot B2', + } render(props) - expect(vi.mocked(InterventionInfo)).toHaveBeenCalledWith( + expect(vi.mocked(InterventionContent)).toHaveBeenCalledWith( expect.objectContaining({ - currentLocationProps: { deckLabel: '' }, + infoProps: expect.objectContaining({ + currentLocationProps: { deckLabel: 'SLOT A1' }, + newLocationProps: { deckLabel: 'SLOT B2' }, + }), }), {} ) diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/RecoveryDoorOpenSpecial.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/RecoveryDoorOpenSpecial.test.tsx new file mode 100644 index 00000000000..423f75396c0 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/RecoveryDoorOpenSpecial.test.tsx @@ -0,0 +1,110 @@ +import { describe, it, vi, expect, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' + +import { + RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, + RUN_STATUS_AWAITING_RECOVERY, +} from '@opentrons/api-client' + +import { renderWithProviders } from '/app/__testing-utils__' +import { i18n } from '/app/i18n' +import { RecoveryDoorOpenSpecial } from '../RecoveryDoorOpenSpecial' +import { RECOVERY_MAP } from '../../constants' + +import type * as React from 'react' +import { clickButtonLabeled } from '/app/organisms/ErrorRecoveryFlows/__tests__/util' + +describe('RecoveryDoorOpenSpecial', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + currentRecoveryOptionUtils: { + selectedRecoveryOption: RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE, + }, + runStatus: RUN_STATUS_AWAITING_RECOVERY, + recoveryActionMutationUtils: { + resumeRecovery: vi.fn(), + }, + routeUpdateActions: { + proceedToRouteAndStep: vi.fn(), + }, + doorStatusUtils: { + isDoorOpen: true, + }, + } as any + }) + + const render = ( + props: React.ComponentProps + ) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] + } + + it('calls resumeRecovery when primary button is clicked', async () => { + render(props) + + clickButtonLabeled('Continue') + + expect(props.recoveryActionMutationUtils.resumeRecovery).toHaveBeenCalled() + }) + + it(`disables primary button when runStatus is ${RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR}`, () => { + props.runStatus = RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR + render(props) + + const btn = screen.getAllByRole('button', { name: 'Continue' })[0] + + expect(btn).toBeDisabled() + }) + + it(`renders correct copy for ${RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE}`, () => { + props.currentRecoveryOptionUtils.selectedRecoveryOption = + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE + render(props) + screen.getByText('Close the robot door') + screen.getByText( + 'The robot door must be closed for the gripper to home its Z-axis before you can continue manually moving labware.' + ) + }) + + it('renders default subtext for unhandled recovery option', () => { + props.currentRecoveryOptionUtils.selectedRecoveryOption = 'UNHANDLED_OPTION' as any + render(props) + screen.getByText('Close the robot door') + screen.getByText( + 'Close the robot door, and then resume the recovery action.' + ) + }) + + it(`calls proceedToRouteAndStep when door is closed for ${RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE}`, () => { + props.doorStatusUtils.isDoorOpen = false + render(props) + expect(props.routeUpdateActions.proceedToRouteAndStep).toHaveBeenCalledWith( + RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE, + RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE + ) + }) + + it(`calls proceedToRouteAndStep when door is closed for ${RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE}`, () => { + props.currentRecoveryOptionUtils.selectedRecoveryOption = + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE + props.doorStatusUtils.isDoorOpen = false + render(props) + expect(props.routeUpdateActions.proceedToRouteAndStep).toHaveBeenCalledWith( + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE + ) + }) + + it('calls proceedToRouteAndStep with OPTION_SELECTION for unhandled recovery option when door is closed', () => { + props.currentRecoveryOptionUtils.selectedRecoveryOption = 'UNHANDLED_OPTION' as any + props.doorStatusUtils.isDoorOpen = false + render(props) + expect(props.routeUpdateActions.proceedToRouteAndStep).toHaveBeenCalledWith( + RECOVERY_MAP.OPTION_SELECTION.ROUTE + ) + }) +}) diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SelectTips.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SelectTips.test.tsx index 8b8ab83d9f9..9a8fc10f5d6 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SelectTips.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SelectTips.test.tsx @@ -53,6 +53,7 @@ describe('SelectTips', () => { failedLabwareUtils: { selectedTipLocations: { A1: null }, areTipsSelected: true, + failedLabwareLocations: { newLoc: null, currentLoc: 'A1' }, } as any, } @@ -160,6 +161,7 @@ describe('SelectTips', () => { failedLabwareUtils: { selectedTipLocations: null, areTipsSelected: false, + failedLabwareLocations: { newLoc: null, currentLoc: '' }, } as any, } diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx new file mode 100644 index 00000000000..f2206c8f010 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx @@ -0,0 +1,134 @@ +import { describe, it, vi, expect, beforeEach } from 'vitest' + +import { renderWithProviders } from '/app/__testing-utils__' +import { i18n } from '/app/i18n' +import { clickButtonLabeled } from '/app/organisms/ErrorRecoveryFlows/__tests__/util' +import { TwoColLwInfoAndDeck } from '../TwoColLwInfoAndDeck' +import { RECOVERY_MAP } from '../../constants' +import { LeftColumnLabwareInfo } from '../LeftColumnLabwareInfo' +import { getSlotNameAndLwLocFrom } from '../../hooks/useDeckMapUtils' + +import type * as React from 'react' +import type { Mock } from 'vitest' + +vi.mock('../LeftColumnLabwareInfo') +vi.mock('../../hooks/useDeckMapUtils') + +let mockProceedNextStep: Mock + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('TwoColLwInfoAndDeck', () => { + let props: React.ComponentProps + + beforeEach(() => { + mockProceedNextStep = vi.fn() + + props = { + routeUpdateActions: { + proceedNextStep: mockProceedNextStep, + }, + failedPipetteUtils: { + failedPipetteInfo: { data: { channels: 8 } }, + isPartialTipConfigValid: false, + }, + failedLabwareUtils: { + relevantWellName: 'A1', + failedLabware: { location: 'C1' }, + }, + deckMapUtils: {}, + currentRecoveryOptionUtils: { + selectedRecoveryOption: RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, + }, + } as any + + vi.mocked(LeftColumnLabwareInfo).mockReturnValue( + vi.fn(() =>
) as any + ) + vi.mocked(getSlotNameAndLwLocFrom).mockReturnValue(['C1'] as any) + }) + + it('calls proceedNextStep when primary button is clicked', () => { + render(props) + clickButtonLabeled('Continue') + expect(mockProceedNextStep).toHaveBeenCalled() + }) + + it(`passes correct title to LeftColumnLabwareInfo for ${RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE}`, () => { + render(props) + expect(vi.mocked(LeftColumnLabwareInfo)).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Manually move labware on deck', + type: 'location-arrow-location', + bannerText: + 'Ensure labware is accurately placed in the slot to prevent further errors.', + }), + expect.anything() + ) + }) + + it(`passes correct title to LeftColumnLabwareInfo for ${RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE}`, () => { + props.currentRecoveryOptionUtils.selectedRecoveryOption = + RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE + render(props) + expect(vi.mocked(LeftColumnLabwareInfo)).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Manually replace labware on deck', + type: 'location-arrow-location', + bannerText: + 'Ensure labware is accurately placed in the slot to prevent further errors.', + }), + expect.anything() + ) + }) + + it(`passes correct title to LeftColumnLabwareInfo for ${RECOVERY_MAP.RETRY_NEW_TIPS.ROUTE}`, () => { + props.currentRecoveryOptionUtils.selectedRecoveryOption = + RECOVERY_MAP.RETRY_NEW_TIPS.ROUTE + render(props) + expect(vi.mocked(LeftColumnLabwareInfo)).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Replace used tips in rack location A1 in Slot C1', + type: 'location', + bannerText: + "It's best to replace tips and select the last location used for tip pickup.", + }), + expect.anything() + ) + }) + + it('passes correct title to LeftColumnLabwareInfo for 96-channel pipette', () => { + props.currentRecoveryOptionUtils.selectedRecoveryOption = + RECOVERY_MAP.RETRY_NEW_TIPS.ROUTE + // @ts-expect-error This is a test. It's always defined. + props.failedPipetteUtils.failedPipetteInfo.data.channels = 96 + render(props) + expect(vi.mocked(LeftColumnLabwareInfo)).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Replace with new tip rack in Slot C1', + type: 'location', + bannerText: + "It's best to replace tips and select the last location used for tip pickup.", + }), + expect.anything() + ) + }) + + it('passes correct title to LeftColumnLabwareInfo for partial tip config', () => { + props.currentRecoveryOptionUtils.selectedRecoveryOption = + RECOVERY_MAP.RETRY_NEW_TIPS.ROUTE + props.failedPipetteUtils.isPartialTipConfigValid = true + render(props) + expect(vi.mocked(LeftColumnLabwareInfo)).toHaveBeenCalledWith( + expect.objectContaining({ + bannerText: + 'Replace tips and select the last location used for partial tip pickup.', + }), + expect.anything() + ) + }) +}) diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/index.ts b/app/src/organisms/ErrorRecoveryFlows/shared/index.ts index 4e6b2708c12..0c9df1d9553 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/index.ts +++ b/app/src/organisms/ErrorRecoveryFlows/shared/index.ts @@ -19,5 +19,6 @@ export { GripperReleaseLabware } from './GripperReleaseLabware' export { RetryStepInfo } from './RetryStepInfo' export { SkipStepInfo } from './SkipStepInfo' export { GripperIsHoldingLabware } from './GripperIsHoldingLabware' +export { RecoveryDoorOpenSpecial } from './RecoveryDoorOpenSpecial' export type { RecoveryInterventionModalProps } from './RecoveryInterventionModal' diff --git a/shared-data/command/types/unsafe.ts b/shared-data/command/types/unsafe.ts index fd460f573b2..d24a6f8e054 100644 --- a/shared-data/command/types/unsafe.ts +++ b/shared-data/command/types/unsafe.ts @@ -6,12 +6,14 @@ export type UnsafeRunTimeCommand = | UnsafeDropTipInPlaceRunTimeCommand | UnsafeUpdatePositionEstimatorsRunTimeCommand | UnsafeEngageAxesRunTimeCommand + | UnsafeUngripLabwareRunTimeCommand export type UnsafeCreateCommand = | UnsafeBlowoutInPlaceCreateCommand | UnsafeDropTipInPlaceCreateCommand | UnsafeUpdatePositionEstimatorsCreateCommand | UnsafeEngageAxesCreateCommand + | UnsafeUngripLabwareCreateCommand export interface UnsafeBlowoutInPlaceParams { pipetteId: string @@ -72,3 +74,14 @@ export interface UnsafeEngageAxesRunTimeCommand UnsafeEngageAxesCreateCommand { result?: any } + +export interface UnsafeUngripLabwareCreateCommand + extends CommonCommandCreateInfo { + commandType: 'unsafe/ungripLabware' + params: {} +} +export interface UnsafeUngripLabwareRunTimeCommand + extends CommonCommandRunTimeInfo, + UnsafeUngripLabwareCreateCommand { + result?: any +} From 80176bafedc7886ef9bb01d6779f0774cd7a71f1 Mon Sep 17 00:00:00 2001 From: Jethary Alcid <66035149+jerader@users.noreply.github.com> Date: Tue, 15 Oct 2024 13:44:32 -0400 Subject: [PATCH 3/3] feat(protocol-designer): foundation for batch edit and multi-select (#16482) closes AUTH-819 --- .../localization/en/protocol_steps.json | 5 + .../BatchEditToolbox/BatchEditMixTools.tsx | 3 + .../BatchEditMoveLiquidTools.tsx | 3 + .../ProtocolSteps/BatchEditToolbox/index.tsx | 90 +++++++++++++ .../ProtocolSteps/BatchEditToolbox/utils.ts | 48 +++++++ .../Timeline/ConnectedStepInfo.tsx | 71 ++++++++++- .../Timeline/StepOverflowMenu.tsx | 119 ++++++++++++++---- .../Timeline/TerminalItemStep.tsx | 11 +- .../__tests__/StepOverflowMenu.test.tsx | 17 ++- .../Designer/ProtocolSteps/Timeline/utils.ts | 99 +++++++++++++++ .../pages/Designer/ProtocolSteps/index.tsx | 8 +- 11 files changed, 443 insertions(+), 31 deletions(-) create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/BatchEditMixTools.tsx create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/BatchEditMoveLiquidTools.tsx create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/index.tsx create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/utils.ts diff --git a/protocol-designer/src/assets/localization/en/protocol_steps.json b/protocol-designer/src/assets/localization/en/protocol_steps.json index 3b00d2f7d5c..24e42424355 100644 --- a/protocol-designer/src/assets/localization/en/protocol_steps.json +++ b/protocol-designer/src/assets/localization/en/protocol_steps.json @@ -1,10 +1,15 @@ { "add_details": "Add step details", "aspirated": "Aspirated", + "batch_edit_steps": "Batch edit steps", + "batch_edit": "Batch edit", + "batch_edits_saved": "Batch edits saved", "change_tips": "Change tips", "default_tip_option": "Default - get next tip", + "delete_steps": "Delete steps", "delete": "Delete step", "dispensed": "Dispensed", + "duplicate_steps": "Duplicate steps", "duplicate": "Duplicate step", "edit_step": "Edit step", "engage_height": "Engage height", diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/BatchEditMixTools.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/BatchEditMixTools.tsx new file mode 100644 index 00000000000..29a7080f76c --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/BatchEditMixTools.tsx @@ -0,0 +1,3 @@ +export function BatchEditMixTools(): JSX.Element { + return
Todo: wire this up
+} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/BatchEditMoveLiquidTools.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/BatchEditMoveLiquidTools.tsx new file mode 100644 index 00000000000..58f3e9d8c26 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/BatchEditMoveLiquidTools.tsx @@ -0,0 +1,3 @@ +export function BatchEditMoveLiquidTools(): JSX.Element { + return
Todo: wire this up
+} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/index.tsx new file mode 100644 index 00000000000..0f66e7d21f0 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/index.tsx @@ -0,0 +1,90 @@ +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' +import { Icon, PrimaryButton, StyledText, Toolbox } from '@opentrons/components' +import { + getBatchEditSelectedStepTypes, + getMultiSelectDisabledFields, + getMultiSelectFieldValues, + getMultiSelectItemIds, +} from '../../../../ui/steps/selectors' +import { useKitchen } from '../../../../organisms/Kitchen/hooks' +import { deselectAllSteps } from '../../../../ui/steps/actions/actions' +import { + // changeBatchEditField, + resetBatchEditFieldChanges, + saveStepFormsMulti, +} from '../../../../step-forms/actions' +import { BatchEditMoveLiquidTools } from './BatchEditMoveLiquidTools' +import { BatchEditMixTools } from './BatchEditMixTools' +// import { maskField } from '../../../../steplist/fieldLevel' + +// import type { StepFieldName } from '../../../../steplist/fieldLevel' +import type { ThunkDispatch } from 'redux-thunk' +import type { BaseState } from '../../../../types' + +export const BatchEditToolbox = (): JSX.Element | null => { + const { t } = useTranslation(['tooltip', 'protocol_steps', 'shared']) + const { makeSnackbar } = useKitchen() + const dispatch = useDispatch>() + const fieldValues = useSelector(getMultiSelectFieldValues) + const stepTypes = useSelector(getBatchEditSelectedStepTypes) + const disabledFields = useSelector(getMultiSelectDisabledFields) + const selectedStepIds = useSelector(getMultiSelectItemIds) + + // const handleChangeFormInput = (name: StepFieldName, value: unknown): void => { + // const maskedValue = maskField(name, value) + // dispatch(changeBatchEditField({ [name]: maskedValue })) + // } + + const handleSave = (): void => { + dispatch(saveStepFormsMulti(selectedStepIds)) + makeSnackbar(t('batch_edits_saved') as string) + dispatch(deselectAllSteps('EXIT_BATCH_EDIT_MODE_BUTTON_PRESS')) + } + + const handleCancel = (): void => { + dispatch(resetBatchEditFieldChanges()) + dispatch(deselectAllSteps('EXIT_BATCH_EDIT_MODE_BUTTON_PRESS')) + } + + const stepType = stepTypes.length === 1 ? stepTypes[0] : null + + if (stepType !== null && fieldValues !== null && disabledFields !== null) { + // const propsForFields = makeBatchEditFieldProps( + // fieldValues, + // disabledFields, + // handleChangeFormInput, + // t + // ) + if (stepType === 'moveLiquid' || stepType === 'mix') { + return ( + + {t('protocol_steps:batch_edit')} + + } + childrenPadding="0" + onCloseClick={handleCancel} + closeButton={} + confirmButton={ + + {t('shared:save')} + + } + > + {stepType === 'moveLiquid' ? ( + + ) : ( + + )} + + ) + } else { + return null + } + } else { + return null + } +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/utils.ts b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/utils.ts new file mode 100644 index 00000000000..4d8f581acea --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/utils.ts @@ -0,0 +1,48 @@ +import noop from 'lodash/noop' +import { + getFieldDefaultTooltip, + getFieldIndeterminateTooltip, +} from '../StepForm/utils' +import type { + DisabledFields, + MultiselectFieldValues, +} from '../../../../ui/steps/selectors' +import type { StepFieldName } from '../../../../form-types' +import type { FieldPropsByName } from '../StepForm/types' +export const makeBatchEditFieldProps = ( + fieldValues: MultiselectFieldValues, + disabledFields: DisabledFields, + handleChangeFormInput: (name: string, value: unknown) => void, + t: any +): FieldPropsByName => { + const fieldNames: StepFieldName[] = Object.keys(fieldValues) + return fieldNames.reduce((acc, name) => { + const defaultTooltip = getFieldDefaultTooltip(name, t) + const isIndeterminate = fieldValues[name].isIndeterminate + const indeterminateTooltip = getFieldIndeterminateTooltip(name, t) + let tooltipContent = defaultTooltip // Default to the default content (or blank) + + if (isIndeterminate && indeterminateTooltip) { + tooltipContent = indeterminateTooltip + } + + if (name in disabledFields) { + tooltipContent = disabledFields[name] // Use disabled content if field is disabled, override indeterminate tooltip if applicable + } + + acc[name] = { + disabled: name in disabledFields, + name, + updateValue: value => { + handleChangeFormInput(name, value) + }, + value: fieldValues[name].value, + errorToShow: null, + onFieldBlur: noop, + onFieldFocus: noop, + isIndeterminate: isIndeterminate, + tooltipContent: tooltipContent, + } + return acc + }, {}) +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/ConnectedStepInfo.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/ConnectedStepInfo.tsx index 318fa72e1ea..1e533b2bfd3 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/ConnectedStepInfo.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/ConnectedStepInfo.tsx @@ -10,6 +10,8 @@ import { getHoveredSubstep, getMultiSelectItemIds, getSelectedStepId, + getMultiSelectLastSelected, + getIsMultiSelectMode, } from '../../../../ui/steps' import { selectors as fileDataSelectors } from '../../../../file-data' import { @@ -24,9 +26,19 @@ import { } from '../../../../ui/steps/actions/actions' import { getOrderedStepIds } from '../../../../step-forms/selectors' import { StepContainer } from './StepContainer' +import { + getMetaSelectedSteps, + getMouseClickKeyInfo, + getShiftSelectedSteps, + nonePressed, +} from './utils' +import type * as React from 'react' import type { ThunkDispatch } from 'redux-thunk' -import type { HoverOnStepAction } from '../../../../ui/steps' +import type { + HoverOnStepAction, + SelectMultipleStepsAction, +} from '../../../../ui/steps' import type { StepIdType } from '../../../../form-types' import type { BaseState, ThunkAction } from '../../../../types' import type { DeleteModalType } from '../../../../components/modals/ConfirmDeleteModal' @@ -65,6 +77,9 @@ export function ConnectedStepInfo(props: ConnectedStepInfoProps): JSX.Element { const hoveredStep = useSelector(getHoveredStepId) const selectedStepId = useSelector(getSelectedStepId) const multiSelectItemIds = useSelector(getMultiSelectItemIds) + const orderedStepIds = useSelector(stepFormSelectors.getOrderedStepIds) + const lastMultiSelectedStepId = useSelector(getMultiSelectLastSelected) + const isMultiSelectMode = useSelector(getIsMultiSelectMode) const selected: boolean = multiSelectItemIds?.length ? multiSelectItemIds.includes(stepId) : selectedStepId === stepId @@ -74,6 +89,15 @@ export function ConnectedStepInfo(props: ConnectedStepInfoProps): JSX.Element { const singleEditFormHasUnsavedChanges = useSelector( stepFormSelectors.getCurrentFormHasUnsavedChanges ) + const batchEditFormHasUnsavedChanges = useSelector( + stepFormSelectors.getBatchEditFormHasUnsavedChanges + ) + const selectMultipleSteps = ( + steps: StepIdType[], + lastSelected: StepIdType + ): ThunkAction => + dispatch(stepsActions.selectMultipleSteps(steps, lastSelected)) + const selectStep = (): ThunkAction => dispatch(stepsActions.resetSelectStep(stepId)) const selectStepOnDoubleClick = (): ThunkAction => @@ -82,15 +106,51 @@ export function ConnectedStepInfo(props: ConnectedStepInfoProps): JSX.Element { dispatch(stepsActions.hoverOnStep(stepId)) const unhighlightStep = (): HoverOnStepAction => dispatch(stepsActions.hoverOnStep(null)) - const handleSelectStep = (): void => { - selectStep() + const handleSelectStep = (event: React.MouseEvent): void => { if (selectedStep !== stepId) { dispatch(toggleViewSubstep(null)) dispatch(hoverOnStep(null)) } + const { isShiftKeyPressed, isMetaKeyPressed } = getMouseClickKeyInfo(event) + let stepsToSelect: StepIdType[] = [] + + // if user clicked on the last multi-selected step, shift/meta keys don't matter + const toggledLastSelected = stepId === lastMultiSelectedStepId + const noModifierKeys = + nonePressed([isShiftKeyPressed, isMetaKeyPressed]) || toggledLastSelected + + if (noModifierKeys) { + selectStep() + } else if ( + (isMetaKeyPressed || isShiftKeyPressed) && + currentFormIsPresaved + ) { + // current form is presaved, enter batch edit mode with only the clicked + stepsToSelect = [stepId] + } else { + if (isShiftKeyPressed) { + stepsToSelect = getShiftSelectedSteps( + selectedStepId, + orderedStepIds, + stepId, + multiSelectItemIds, + lastMultiSelectedStepId + ) + } else if (isMetaKeyPressed) { + stepsToSelect = getMetaSelectedSteps( + multiSelectItemIds, + stepId, + selectedStepId + ) + } + } + if (stepsToSelect.length > 0) { + selectMultipleSteps(stepsToSelect, stepId) + } } const handleSelectDoubleStep = (): void => { selectStepOnDoubleClick() + if (selectedStep !== stepId) { dispatch(toggleViewSubstep(null)) dispatch(hoverOnStep(null)) @@ -105,9 +165,12 @@ export function ConnectedStepInfo(props: ConnectedStepInfoProps): JSX.Element { handleSelectDoubleStep, currentFormIsPresaved || singleEditFormHasUnsavedChanges ) + const { confirm, showConfirmation, cancel } = useConditionalConfirm( handleSelectStep, - currentFormIsPresaved || singleEditFormHasUnsavedChanges + isMultiSelectMode + ? batchEditFormHasUnsavedChanges + : currentFormIsPresaved || singleEditFormHasUnsavedChanges ) const getModalType = (): DeleteModalType => { diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/StepOverflowMenu.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/StepOverflowMenu.tsx index 0191ab9969d..5078ff4c0e5 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/StepOverflowMenu.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/StepOverflowMenu.tsx @@ -15,24 +15,33 @@ import { useConditionalConfirm, } from '@opentrons/components' import { actions as steplistActions } from '../../../../steplist' -import { actions as stepsActions } from '../../../../ui/steps' import { + getMultiSelectItemIds, + actions as stepsActions, +} from '../../../../ui/steps' +import { + CLOSE_BATCH_EDIT_FORM, CLOSE_STEP_FORM_WITH_CHANGES, CLOSE_UNSAVED_STEP_FORM, ConfirmDeleteModal, + DELETE_MULTIPLE_STEP_FORMS, DELETE_STEP_FORM, } from '../../../../components/modals/ConfirmDeleteModal' import { hoverOnStep, toggleViewSubstep, populateForm, + deselectAllSteps, } from '../../../../ui/steps/actions/actions' import { + getBatchEditFormHasUnsavedChanges, getCurrentFormHasUnsavedChanges, getCurrentFormIsPresaved, getSavedStepForms, getUnsavedForm, } from '../../../../step-forms/selectors' +import { deleteMultipleSteps } from '../../../../steplist/actions' +import { duplicateMultipleSteps } from '../../../../ui/steps/actions/thunks' import type * as React from 'react' import type { ThunkDispatch } from 'redux-thunk' import type { BaseState } from '../../../../types' @@ -49,6 +58,7 @@ interface StepOverflowMenuProps { export function StepOverflowMenu(props: StepOverflowMenuProps): JSX.Element { const { stepId, menuRootRef, top, setStepOverflowMenu } = props const { t } = useTranslation('protocol_steps') + const multiSelectItemIds = useSelector(getMultiSelectItemIds) const dispatch = useDispatch>() const deleteStep = (stepId: StepIdType): void => { dispatch(steplistActions.deleteStep(stepId)) @@ -59,6 +69,9 @@ export function StepOverflowMenu(props: StepOverflowMenuProps): JSX.Element { const singleEditFormHasUnsavedChanges = useSelector( getCurrentFormHasUnsavedChanges ) + const batchEditFormHasUnsavedChanges = useSelector( + getBatchEditFormHasUnsavedChanges + ) const duplicateStep = ( stepId: StepIdType ): ReturnType => @@ -77,11 +90,44 @@ export function StepOverflowMenu(props: StepOverflowMenuProps): JSX.Element { ) } } + const onDuplicateClickAction = (): void => { + if (multiSelectItemIds) { + dispatch(duplicateMultipleSteps(multiSelectItemIds)) + } else { + console.warn( + 'something went wrong, you cannot duplicate multiple steps if none are selected' + ) + } + } + const onDeleteClickAction = (): void => { + if (multiSelectItemIds) { + dispatch(deleteMultipleSteps(multiSelectItemIds)) + dispatch(deselectAllSteps('EXIT_BATCH_EDIT_MODE_BUTTON_PRESS')) + } else { + console.warn( + 'something went wrong, you cannot delete multiple steps if none are selected' + ) + } + } const { confirm, showConfirmation, cancel } = useConditionalConfirm( handleStepItemSelection, currentFormIsPresaved || singleEditFormHasUnsavedChanges ) + const { + confirm: confirmDuplicate, + showConfirmation: showDuplicateConfirmation, + cancel: cancelDuplicate, + } = useConditionalConfirm( + onDuplicateClickAction, + batchEditFormHasUnsavedChanges + ) + + const { + confirm: confirmMultiDelete, + showConfirmation: showMultiDeleteConfirmation, + cancel: cancelMultiDelete, + } = useConditionalConfirm(onDeleteClickAction, true) const { confirm: confirmDelete, @@ -112,6 +158,22 @@ export function StepOverflowMenu(props: StepOverflowMenuProps): JSX.Element { /> )} {/* TODO: update this modal */} + {showDuplicateConfirmation && ( + + )} + {/* TODO: update this modal */} + {showMultiDeleteConfirmation && ( + + )} + {/* TODO: update this modal */} {showDeleteConfirmation && ( - {formData != null ? null : ( - {t('edit_step')} + {multiSelectItemIds != null && multiSelectItemIds.length > 0 ? ( + <> + + {t('duplicate_steps')} + + + {t('delete_steps')} + + + ) : ( + <> + {formData != null ? null : ( + {t('edit_step')} + )} + {isPipetteStep || isThermocyclerStep ? ( + { + dispatch(hoverOnStep(stepId)) + dispatch(toggleViewSubstep(stepId)) + }} + > + {t('view_details')} + + ) : null} + { + duplicateStep(stepId) + }} + > + {t('duplicate')} + + + {t('delete')} + )} - {isPipetteStep || isThermocyclerStep ? ( - { - dispatch(hoverOnStep(stepId)) - dispatch(toggleViewSubstep(stepId)) - }} - > - {t('view_details')} - - ) : null} - { - duplicateStep(stepId) - }} - > - {t('duplicate')} - - - {t('delete')} ) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/TerminalItemStep.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/TerminalItemStep.tsx index 7104e43e5c1..f9c80080dad 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/TerminalItemStep.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/TerminalItemStep.tsx @@ -16,6 +16,7 @@ import { ConfirmDeleteModal, } from '../../../../components/modals/ConfirmDeleteModal' import { + deselectAllSteps, hoverOnStep, toggleViewSubstep, } from '../../../../ui/steps/actions/actions' @@ -26,6 +27,7 @@ import type { HoverOnTerminalItemAction, } from '../../../../ui/steps' import type { TerminalItemId } from '../../../../steplist' +import type { ThunkDispatch } from '../../../../types' export interface TerminalItemStepProps { id: TerminalItemId @@ -40,7 +42,7 @@ export function TerminalItemStep(props: TerminalItemStepProps): JSX.Element { const formHasChanges = useSelector(getCurrentFormHasUnsavedChanges) const isMultiSelectMode = useSelector(getIsMultiSelectMode) - const dispatch = useDispatch() + const dispatch = useDispatch>() const selectItem = (): SelectTerminalItemAction => dispatch(stepsActions.selectTerminalItem(id)) @@ -58,7 +60,12 @@ export function TerminalItemStep(props: TerminalItemStepProps): JSX.Element { currentFormIsPresaved || formHasChanges ) - const onClick = isMultiSelectMode ? () => null : confirm + const onClick = isMultiSelectMode + ? () => { + dispatch(deselectAllSteps('EXIT_BATCH_EDIT_MODE_BUTTON_PRESS')) + handleConfirm() + } + : confirm return ( <> diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/StepOverflowMenu.test.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/StepOverflowMenu.test.tsx index 3defbe61099..f37d2114c74 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/StepOverflowMenu.test.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/StepOverflowMenu.test.tsx @@ -3,7 +3,10 @@ import '@testing-library/jest-dom/vitest' import { fireEvent, screen } from '@testing-library/react' import { renderWithProviders } from '../../../../../__testing-utils__' import { i18n } from '../../../../../assets/localization' -import { duplicateStep } from '../../../../../ui/steps/actions/thunks' +import { + getMultiSelectItemIds, + actions as stepsActions, +} from '../../../../../ui/steps' import { StepOverflowMenu } from '../StepOverflowMenu' import { getCurrentFormHasUnsavedChanges, @@ -21,6 +24,7 @@ import type * as OpentronsComponents from '@opentrons/components' const mockConfirm = vi.fn() const mockCancel = vi.fn() +vi.mock('../../../../../ui/steps') vi.mock('../../../../../step-forms/selectors') vi.mock('../../../../../ui/steps/actions/actions') vi.mock('../../../../../ui/steps/actions/thunks') @@ -53,6 +57,7 @@ describe('StepOverflowMenu', () => { menuRootRef: { current: null }, setStepOverflowMenu: vi.fn(), } + vi.mocked(getMultiSelectItemIds).mockReturnValue(null) vi.mocked(getCurrentFormIsPresaved).mockReturnValue(false) vi.mocked(getCurrentFormHasUnsavedChanges).mockReturnValue(false) vi.mocked(getUnsavedForm).mockReturnValue(null) @@ -71,11 +76,19 @@ describe('StepOverflowMenu', () => { fireEvent.click(screen.getByText('delete step')) expect(mockConfirm).toHaveBeenCalled() fireEvent.click(screen.getByText('Duplicate step')) - expect(vi.mocked(duplicateStep)).toHaveBeenCalled() + expect(vi.mocked(stepsActions.duplicateStep)).toHaveBeenCalled() fireEvent.click(screen.getByText('Edit step')) expect(mockConfirm).toHaveBeenCalled() fireEvent.click(screen.getByText('View details')) expect(vi.mocked(hoverOnStep)).toHaveBeenCalled() expect(vi.mocked(toggleViewSubstep)).toHaveBeenCalled() }) + + it('renders the multi select overflow menu', () => { + vi.mocked(getMultiSelectItemIds).mockReturnValue(['1', '2']) + render(props) + screen.getByText('Duplicate steps') + screen.getByText('Delete steps') + screen.getByText('Delete multiple steps') + }) }) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/utils.ts b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/utils.ts index ef502c2a53f..c7f6f812dc2 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/utils.ts +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/utils.ts @@ -1,6 +1,9 @@ import round from 'lodash/round' import omitBy from 'lodash/omitBy' +import uniq from 'lodash/uniq' +import { UAParser } from 'ua-parser-js' import type { WellIngredientVolumeData } from '../../../../steplist' +import type { StepIdType } from '../../../../form-types' export const capitalizeFirstLetterAfterNumber = (title: string): string => title.replace( @@ -50,3 +53,99 @@ export const compactPreIngreds = ( return typeof ingred?.volume === 'number' && ingred.volume <= 0 }) } + +export const getMetaSelectedSteps = ( + multiSelectItemIds: StepIdType[] | null, + stepId: StepIdType, + selectedStepId: StepIdType | null +): StepIdType[] => { + let stepsToSelect: StepIdType[] + if (multiSelectItemIds?.length) { + // already have a selection, add/remove the meta-clicked item + stepsToSelect = multiSelectItemIds.includes(stepId) + ? multiSelectItemIds.filter(id => id !== stepId) + : [...multiSelectItemIds, stepId] + } else if (selectedStepId && selectedStepId === stepId) { + // meta-clicked on the selected single step + stepsToSelect = [selectedStepId] + } else if (selectedStepId) { + // meta-clicked on a different step, multi-select both + stepsToSelect = [selectedStepId, stepId] + } else { + // meta-clicked on a step when a terminal item was selected + stepsToSelect = [stepId] + } + return stepsToSelect +} + +export const getShiftSelectedSteps = ( + selectedStepId: StepIdType | null, + orderedStepIds: StepIdType[], + stepId: StepIdType, + multiSelectItemIds: StepIdType[] | null, + lastMultiSelectedStepId: StepIdType | null +): StepIdType[] => { + let stepsToSelect: StepIdType[] + if (selectedStepId) { + stepsToSelect = getOrderedStepsInRange( + selectedStepId, + stepId, + orderedStepIds + ) + } else if (multiSelectItemIds?.length && lastMultiSelectedStepId) { + const potentialStepsToSelect = getOrderedStepsInRange( + lastMultiSelectedStepId, + stepId, + orderedStepIds + ) + + const allSelected: boolean = potentialStepsToSelect + .slice(1) + .every(stepId => multiSelectItemIds.includes(stepId)) + + if (allSelected) { + // if they're all selected, deselect them all + if (multiSelectItemIds.length - potentialStepsToSelect.length > 0) { + stepsToSelect = multiSelectItemIds.filter( + (id: StepIdType) => !potentialStepsToSelect.includes(id) + ) + } else { + // unless deselecting them all results in none being selected + stepsToSelect = [potentialStepsToSelect[0]] + } + } else { + stepsToSelect = uniq([...multiSelectItemIds, ...potentialStepsToSelect]) + } + } else { + stepsToSelect = [stepId] + } + return stepsToSelect +} + +const getOrderedStepsInRange = ( + lastSelectedStepId: StepIdType, + stepId: StepIdType, + orderedStepIds: StepIdType[] +): StepIdType[] => { + const prevIndex: number = orderedStepIds.indexOf(lastSelectedStepId) + const currentIndex: number = orderedStepIds.indexOf(stepId) + + const [startIndex, endIndex] = [prevIndex, currentIndex].sort((a, b) => a - b) + const orderedSteps = orderedStepIds.slice(startIndex, endIndex + 1) + return orderedSteps +} + +export const nonePressed = (keysPressed: boolean[]): boolean => + keysPressed.every(keyPress => keyPress === false) + +export const getMouseClickKeyInfo = ( + event: React.MouseEvent +): { isShiftKeyPressed: boolean; isMetaKeyPressed: boolean } => { + const isMac: boolean = getUserOS() === 'Mac OS' + const isShiftKeyPressed: boolean = event.shiftKey + const isMetaKeyPressed: boolean = + (isMac && event.metaKey) || (!isMac && event.ctrlKey) + return { isShiftKeyPressed, isMetaKeyPressed } +} + +const getUserOS = (): string | undefined => new UAParser().getOS().name diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx index 7ff29ec1c30..800f1115633 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx @@ -14,16 +14,21 @@ import { ToggleGroup, } from '@opentrons/components' import { getUnsavedForm } from '../../../step-forms/selectors' -import { getSelectedSubstep } from '../../../ui/steps/selectors' import { getEnableHotKeysDisplay } from '../../../feature-flags/selectors' +import { + getIsMultiSelectMode, + getSelectedSubstep, +} from '../../../ui/steps/selectors' import { DeckSetupContainer } from '../DeckSetup' import { OffDeck } from '../Offdeck' import { TimelineToolbox, SubstepsToolbox } from './Timeline' import { StepForm } from './StepForm' +import { BatchEditToolbox } from './BatchEditToolbox' export function ProtocolSteps(): JSX.Element { const { t } = useTranslation('starting_deck_state') const formData = useSelector(getUnsavedForm) + const isMultiSelectMode = useSelector(getIsMultiSelectMode) const selectedSubstep = useSelector(getSelectedSubstep) const enableHoyKeyDisplay = useSelector(getEnableHotKeysDisplay) const leftString = t('onDeck') @@ -54,6 +59,7 @@ export function ProtocolSteps(): JSX.Element { justifyContent={JUSTIFY_CENTER} > + {isMultiSelectMode ? : null} {formData == null || formType === 'moveLabware' ? (