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/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/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 +} 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"