diff --git a/app/src/assets/localization/en/error_recovery.json b/app/src/assets/localization/en/error_recovery.json index 605c2b55527..dd483a3daac 100644 --- a/app/src/assets/localization/en/error_recovery.json +++ b/app/src/assets/localization/en/error_recovery.json @@ -11,6 +11,7 @@ "change_location": "Change location", "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_the_robot_door": "Close the robot door, and then resume the recovery action.", "confirm": "Confirm", "continue": "Continue", @@ -24,6 +25,7 @@ "first_take_any_necessary_actions": "First, take any necessary actions to prepare the robot to retry the failed step.Then, close the robot door before proceeding.", "go_back": "Go back", "homing_pipette_dangerous": "Homing the {{mount}} pipette with liquid in the tips may damage it. You must remove all tips before using the pipette again.", + "if_issue_persists": " If the issue persists, cancel the run and make the necessary changes to the protocol", "if_tips_are_attached": "If tips are attached, you can choose to blow out any aspirated liquid and drop tips before the run is terminated.", "ignore_all_errors_of_this_type": "Ignore all errors of this type", "ignore_error_and_skip": "Ignore error and skip to next step", @@ -36,7 +38,6 @@ "next_try_another_action": "Next, you can try another recovery action or cancel the run.", "no_liquid_detected": "No liquid detected", "overpressure_is_usually_caused": "Overpressure is usually caused by a tip contacting labware, a clog, or moving viscous liquid too quickly", - "if_issue_persists": " If the issue persists, cancel the run and make the necessary changes to the protocol", "pick_up_tips": "Pick up tips", "pipette_overpressure": "Pipette overpressure", "preserve_aspirated_liquid": "First, do you need to preserve aspirated liquid?", diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx index d1809209764..b808844c62c 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx @@ -453,8 +453,8 @@ export function ProtocolRunHeader({ {/* Note: This banner is for before running a protocol */} {isDoorOpen && runStatus !== RUN_STATUS_BLOCKED_BY_OPEN_DOOR && - runStatus !== RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR && runStatus != null && + !RECOVERY_STATUSES.includes(runStatus) && CANCELLABLE_STATUSES.includes(runStatus) ? ( {t('shared:close_robot_door')} diff --git a/app/src/organisms/ErrorRecoveryFlows/RunPausedSplash.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx similarity index 62% rename from app/src/organisms/ErrorRecoveryFlows/RunPausedSplash.tsx rename to app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx index 30b916f7b11..041cc85cadb 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RunPausedSplash.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx @@ -3,24 +3,34 @@ import styled, { css } from 'styled-components' import { useTranslation } from 'react-i18next' import { - Flex, - Icon, - JUSTIFY_CENTER, ALIGN_CENTER, - SPACING, COLORS, DIRECTION_COLUMN, - POSITION_ABSOLUTE, - TYPOGRAPHY, - OVERFLOW_WRAP_BREAK_WORD, DISPLAY_FLEX, + Flex, + Icon, + JUSTIFY_CENTER, JUSTIFY_SPACE_BETWEEN, - TEXT_ALIGN_CENTER, - StyledText, + OVERFLOW_WRAP_BREAK_WORD, + POSITION_ABSOLUTE, PrimaryButton, SecondaryButton, + SPACING, + StyledText, + TEXT_ALIGN_CENTER, + TYPOGRAPHY, } from '@opentrons/components' +import { + RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, + RUN_STATUS_AWAITING_RECOVERY_PAUSED, +} from '@opentrons/api-client' +import type { + ERUtilsResults, + UseRecoveryAnalyticsResult, + UseRecoveryTakeoverResult, + useRetainedFailedCommandBySource, +} from './hooks' import { useErrorName } from './hooks' import { getErrorKind } from './utils' import { LargeButton } from '../../atoms/buttons' @@ -30,17 +40,13 @@ import { RECOVERY_MAP, } from './constants' import { RecoveryInterventionModal, StepInfo } from './shared' +import { useToaster } from '../ToasterOven' +import { WARNING_TOAST } from '../../atoms/Toast' import type { RobotType } from '@opentrons/shared-data' import type { ErrorRecoveryFlowsProps } from '.' -import type { - ERUtilsResults, - UseRecoveryAnalyticsResult, - UseRecoveryTakeoverResult, - useRetainedFailedCommandBySource, -} from './hooks' -export function useRunPausedSplash( +export function useRecoverySplash( isOnDevice: boolean, showERWizard: boolean ): boolean { @@ -53,18 +59,17 @@ export function useRunPausedSplash( } } -type RunPausedSplashProps = ERUtilsResults & { - isOnDevice: boolean - failedCommand: ReturnType - protocolAnalysis: ErrorRecoveryFlowsProps['protocolAnalysis'] - robotType: RobotType - robotName: string - toggleERWizAsActiveUser: UseRecoveryTakeoverResult['toggleERWizAsActiveUser'] - analytics: UseRecoveryAnalyticsResult -} -export function RunPausedSplash( - props: RunPausedSplashProps -): JSX.Element | null { +type RecoverySplashProps = ErrorRecoveryFlowsProps & + ERUtilsResults & { + isOnDevice: boolean + isWizardActive: boolean + failedCommand: ReturnType + robotType: RobotType + robotName: string + toggleERWizAsActiveUser: UseRecoveryTakeoverResult['toggleERWizAsActiveUser'] + analytics: UseRecoveryAnalyticsResult + } +export function RecoverySplash(props: RecoverySplashProps): JSX.Element | null { const { isOnDevice, toggleERWizAsActiveUser, @@ -72,10 +77,14 @@ export function RunPausedSplash( failedCommand, analytics, robotName, + runStatus, + recoveryActionMutationUtils, + isWizardActive, } = props const { t } = useTranslation('error_recovery') const errorKind = getErrorKind(failedCommand?.byRunRecord ?? null) const title = useErrorName(errorKind) + const { makeToast } = useToaster() const { proceedToRouteAndStep } = routeUpdateActions const { reportErrorEvent } = analytics @@ -88,18 +97,55 @@ export function RunPausedSplash( ) } + // Resume recovery when the run when the door is closed. + // The CTA/flow for handling a door open event within the ER wizard is different, and because this splash always renders + // behind the wizard, we want to ensure we only implicitly resume recovery when only viewing the splash. + React.useEffect(() => { + if (runStatus === RUN_STATUS_AWAITING_RECOVERY_PAUSED && !isWizardActive) { + recoveryActionMutationUtils.resumeRecovery() + } + }, [runStatus, isWizardActive]) + const buildDoorOpenAlert = (): void => { + makeToast(t('close_door_to_resume') as string, WARNING_TOAST) + } + + const handleConditionalClick = (onClick: () => void): void => { + switch (runStatus) { + case RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR: + buildDoorOpenAlert() + break + default: + onClick() + break + } + } // Do not launch error recovery, but do utilize the wizard's cancel route. - const onCancelClick = (): Promise => { - return toggleERWizAsActiveUser(true, false).then(() => { - reportErrorEvent(failedCommand?.byRunRecord ?? null, 'cancel-run') - void proceedToRouteAndStep(RECOVERY_MAP.CANCEL_RUN.ROUTE) - }) + const onCancelClick = (): void => { + const onClick = (): void => { + void toggleERWizAsActiveUser(true, false).then(() => { + reportErrorEvent(failedCommand?.byRunRecord ?? null, 'cancel-run') + void proceedToRouteAndStep(RECOVERY_MAP.CANCEL_RUN.ROUTE) + }) + } + handleConditionalClick(onClick) } - const onLaunchERClick = (): Promise => { - return toggleERWizAsActiveUser(true, true).then(() => { - reportErrorEvent(failedCommand?.byRunRecord ?? null, 'launch-recovery') - }) + const onLaunchERClick = (): void => { + const onClick = (): void => { + void toggleERWizAsActiveUser(true, true).then(() => { + reportErrorEvent(failedCommand?.byRunRecord ?? null, 'launch-recovery') + }) + } + handleConditionalClick(onClick) + } + + const isDisabled = (): boolean => { + switch (runStatus) { + case RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR: + return true + default: + return false + } } // TODO(jh 05-22-24): The hardcoded Z-indexing is non-ideal but must be done to keep the splash page above @@ -149,14 +195,18 @@ export function RunPausedSplash( @@ -194,12 +244,20 @@ export function RunPausedSplash( - + {t('cancel_run')} {t('launch_recovery_mode')} @@ -234,6 +292,30 @@ const SHARED_BUTTON_STYLE_ODD = css` width: 29rem; height: 13.5rem; ` +const BTN_STYLE_DISABLED_ODD = css` + ${SHARED_BUTTON_STYLE_ODD} + + background-color: ${COLORS.grey35}; + color: ${COLORS.grey50}; + border: none; + box-shadow: none; + + #btn-icon: { + color: ${COLORS.grey50}; + } + + &:active, + &:focus, + &:hover { + background-color: ${COLORS.grey35}; + color: ${COLORS.grey50}; + } + &:active, + &:focus, + &:hover #btn-icon { + color: ${COLORS.grey50}; + } +` const PRIMARY_BTN_STYLES_DESKTOP = css` background-color: ${COLORS.red50}; @@ -245,3 +327,17 @@ const PRIMARY_BTN_STYLES_DESKTOP = css` background-color: ${COLORS.red55}; } ` +const BTN_STYLES_DISABLED_DESKTOP = css` + background-color: ${COLORS.grey30}; + color: ${COLORS.grey40}; + border: none; + box-shadow: none; + + &:active, + &:focus, + &:hover { + background-color: ${COLORS.grey30}; + color: ${COLORS.grey40}; + } + cursor: default; +` diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx index 8ed839887ef..22a0e34109b 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx @@ -22,7 +22,7 @@ import { } from '../hooks' import { getIsOnDevice } from '../../../redux/config' import { useERWizard, ErrorRecoveryWizard } from '../ErrorRecoveryWizard' -import { useRunPausedSplash, RunPausedSplash } from '../RunPausedSplash' +import { useRecoverySplash, RecoverySplash } from '../RecoverySplash' import type { RunStatus } from '@opentrons/api-client' @@ -30,7 +30,7 @@ vi.mock('../ErrorRecoveryWizard') vi.mock('../hooks') vi.mock('../useRecoveryCommands') vi.mock('../../../redux/config') -vi.mock('../RunPausedSplash') +vi.mock('../RecoverySplash') vi.mock('@opentrons/react-api-client') vi.mock('react-redux', async () => { const actual = await vi.importActual('react-redux') @@ -143,15 +143,13 @@ describe('ErrorRecoveryFlows', () => { protocolAnalysis: {} as any, } vi.mocked(ErrorRecoveryWizard).mockReturnValue(
MOCK WIZARD
) - vi.mocked(RunPausedSplash).mockReturnValue( -
MOCK RUN PAUSED SPLASH
- ) + vi.mocked(RecoverySplash).mockReturnValue(
MOCK RUN PAUSED SPLASH
) vi.mocked(useERWizard).mockReturnValue({ hasLaunchedRecovery: true, toggleERWizard: () => Promise.resolve(), showERWizard: true, }) - vi.mocked(useRunPausedSplash).mockReturnValue(true) + vi.mocked(useRecoverySplash).mockReturnValue(true) vi.mocked(useERUtils).mockReturnValue({ routeUpdateActions: {} } as any) vi.mocked(useShowDoorInfo).mockReturnValue(false) vi.mocked(useRecoveryAnalytics).mockReturnValue({ @@ -202,7 +200,7 @@ describe('ErrorRecoveryFlows', () => { }) it('does not render the splash when showSplash is false', () => { - vi.mocked(useRunPausedSplash).mockReturnValue(false) + vi.mocked(useRecoverySplash).mockReturnValue(false) render(props) expect(screen.queryByText('MOCK RUN PAUSED SPLASH')).not.toBeInTheDocument() }) diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/RunPausedSplash.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoverySplash.test.tsx similarity index 74% rename from app/src/organisms/ErrorRecoveryFlows/__tests__/RunPausedSplash.test.tsx rename to app/src/organisms/ErrorRecoveryFlows/__tests__/RecoverySplash.test.tsx index 0764b6c865e..c09a35ff5fc 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__tests__/RunPausedSplash.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoverySplash.test.tsx @@ -3,20 +3,29 @@ import { MemoryRouter } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { fireEvent, screen, waitFor, renderHook } from '@testing-library/react' import { createStore } from 'redux' +import { QueryClient, QueryClientProvider } from 'react-query' +import { Provider } from 'react-redux' + +import { + RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, + RUN_STATUS_AWAITING_RECOVERY, + RUN_STATUS_AWAITING_RECOVERY_PAUSED, +} from '@opentrons/api-client' import { renderWithProviders } from '../../../__testing-utils__' import { i18n } from '../../../i18n' import { mockRecoveryContentProps } from '../__fixtures__' import { getIsOnDevice } from '../../../redux/config' -import { useRunPausedSplash, RunPausedSplash } from '../RunPausedSplash' +import { useRecoverySplash, RecoverySplash } from '../RecoverySplash' import { StepInfo } from '../shared' +import { useToaster } from '../../ToasterOven' +import { clickButtonLabeled } from './util' import type { Store } from 'redux' -import { QueryClient, QueryClientProvider } from 'react-query' -import { Provider } from 'react-redux' vi.mock('../../../redux/config') vi.mock('../shared') +vi.mock('../../ToasterOven') const store: Store = createStore(vi.fn(), {}) @@ -45,7 +54,7 @@ describe('useRunPausedSplash', () => { TEST_CASES.forEach(({ isOnDevice, showERWizard, expected }) => { it(`returns ${expected} when isOnDevice is ${isOnDevice} and showERWizard is ${showERWizard}`, () => { const { result } = renderHook( - () => useRunPausedSplash(isOnDevice, showERWizard), + () => useRecoverySplash(isOnDevice, showERWizard), { wrapper, } @@ -56,10 +65,10 @@ describe('useRunPausedSplash', () => { }) }) -const render = (props: React.ComponentProps) => { +const render = (props: React.ComponentProps) => { return renderWithProviders( - + , { i18nInstance: i18n, @@ -67,13 +76,15 @@ const render = (props: React.ComponentProps) => { ) } -describe('RunPausedSplash', () => { - let props: React.ComponentProps +describe('RecoverySplash', () => { + let props: React.ComponentProps const mockToggleERWiz = vi.fn(() => Promise.resolve()) const mockProceedToRouteAndStep = vi.fn() const mockRouteUpdateActions = { proceedToRouteAndStep: mockProceedToRouteAndStep, } as any + const mockMakeToast = vi.fn() + const mockResumeRecovery = vi.fn() beforeEach(() => { props = { @@ -81,9 +92,14 @@ describe('RunPausedSplash', () => { robotName: 'testRobot', toggleERWizAsActiveUser: mockToggleERWiz, routeUpdateActions: mockRouteUpdateActions, + recoveryActionMutationUtils: { + resumeRecovery: mockResumeRecovery, + } as any, + isWizardActive: false, } vi.mocked(StepInfo).mockReturnValue(
MOCK STEP INFO
) + vi.mocked(useToaster).mockReturnValue({ makeToast: mockMakeToast } as any) }) afterEach(() => { @@ -147,4 +163,25 @@ describe('RunPausedSplash', () => { expect(mockToggleERWiz).toHaveBeenCalledWith(true, true) }) }) + + it('should render a door open toast if the door is open', () => { + props = { + ...props, + runStatus: RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, + } + + render(props) + + clickButtonLabeled('Launch Recovery Mode') + + expect(mockMakeToast).toHaveBeenCalled() + }) + + it(`should transition the run status from ${RUN_STATUS_AWAITING_RECOVERY_PAUSED} to ${RUN_STATUS_AWAITING_RECOVERY}`, () => { + props = { ...props, runStatus: RUN_STATUS_AWAITING_RECOVERY_PAUSED } + + render(props) + + expect(mockResumeRecovery).toHaveBeenCalled() + }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/index.tsx b/app/src/organisms/ErrorRecoveryFlows/index.tsx index bb5dd9af584..b1b82fad236 100644 --- a/app/src/organisms/ErrorRecoveryFlows/index.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/index.tsx @@ -18,7 +18,7 @@ import { useHost } from '@opentrons/react-api-client' import { getIsOnDevice } from '../../redux/config' import { ErrorRecoveryWizard, useERWizard } from './ErrorRecoveryWizard' -import { RunPausedSplash, useRunPausedSplash } from './RunPausedSplash' +import { RecoverySplash, useRecoverySplash } from './RecoverySplash' import { RecoveryTakeover } from './RecoveryTakeover' import { useCurrentlyRecoveringFrom, @@ -136,7 +136,7 @@ export function ErrorRecoveryFlows( toggleERWizAsActiveUser, } = useRecoveryTakeover(toggleERWizard) const renderWizard = isActiveUser && (showERWizard || isDoorOpen) - const showSplash = useRunPausedSplash(isOnDevice, renderWizard) + const showSplash = useRecoverySplash(isOnDevice, renderWizard) const recoveryUtils = useERUtils({ ...props, @@ -168,7 +168,7 @@ export function ErrorRecoveryFlows( /> ) : null} {showSplash ? ( - ) : null}