Skip to content

Commit

Permalink
feat(app): "Cancel run" during Error Recovery (#15240)
Browse files Browse the repository at this point in the history
Closes EXEC-462

Adds the ability to cancel a run during Error Recovery.
  • Loading branch information
mjhuff authored May 22, 2024
1 parent 2ede48c commit 3896d9d
Show file tree
Hide file tree
Showing 16 changed files with 292 additions and 25 deletions.
3 changes: 3 additions & 0 deletions app/src/assets/localization/en/error_recovery.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{
"are_you_sure_you_want_to_cancel": "Are you sure you want to cancel?",
"are_you_sure_you_want_to_resume": "Are you sure you want to resume?",
"before_you_begin": "Before you begin",
"cancel_run": "Cancel run",
"canceling_run": "Canceling run",
"confirm": "Confirm",
"continue": "Continue",
"general_error": "General error",
Expand All @@ -13,6 +15,7 @@
"resume": "Resume",
"run_paused": "Run paused",
"run_will_resume": "The run will resume from the point at which the error occurred. Take any necessary actions to correct the problem first. If the step is completed successfully, the protocol continues.",
"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.",
"stand_back": "Stand back, robot is in motion",
"stand_back_resuming": "Stand back, resuming current step",
"stand_back_retrying": "Stand back, retrying current command",
Expand Down
13 changes: 10 additions & 3 deletions app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import {
} from '@opentrons/components'

import { getIsOnDevice } from '../../redux/config'
import { getModalPortalEl } from '../../App/portal'
import { getTopPortalEl } from '../../App/portal'
import { BeforeBeginning } from './BeforeBeginning'
import { SelectRecoveryOption, ResumeRun } from './RecoveryOptions'
import { SelectRecoveryOption, ResumeRun, CancelRun } from './RecoveryOptions'
import { ErrorRecoveryHeader } from './ErrorRecoveryHeader'
import { RecoveryInProgress } from './RecoveryInProgress'
import { getErrorKind, useRouteUpdateActions } from './utils'
Expand Down Expand Up @@ -80,7 +80,7 @@ function ErrorRecoveryComponent(props: RecoveryContentProps): JSX.Element {
<ErrorRecoveryHeader errorKind={props.errorKind} />
<ErrorRecoveryContent {...props} />
</Flex>,
getModalPortalEl()
getTopPortalEl()
)
}

Expand All @@ -101,16 +101,23 @@ export function ErrorRecoveryContent(props: RecoveryContentProps): JSX.Element {
return <ResumeRun {...props} />
}

const buildCancelRun = (): JSX.Element => {
return <CancelRun {...props} />
}

switch (props.recoveryMap.route) {
case RECOVERY_MAP.BEFORE_BEGINNING.ROUTE:
return buildBeforeBeginning()
case RECOVERY_MAP.OPTION_SELECTION.ROUTE:
return buildSelectRecoveryOption()
case RECOVERY_MAP.RESUME.ROUTE:
return buildResumeRun()
case RECOVERY_MAP.CANCEL_RUN.ROUTE:
return buildCancelRun()
case RECOVERY_MAP.ROBOT_IN_MOTION.ROUTE:
case RECOVERY_MAP.ROBOT_RESUMING.ROUTE:
case RECOVERY_MAP.ROBOT_RETRYING_COMMAND.ROUTE:
case RECOVERY_MAP.ROBOT_CANCELING.ROUTE:
return buildRecoveryInProgress()
default:
return buildSelectRecoveryOption()
Expand Down
3 changes: 3 additions & 0 deletions app/src/organisms/ErrorRecoveryFlows/RecoveryInProgress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export function RecoveryInProgress({
recoveryMap,
}: RecoveryContentProps): JSX.Element {
const {
ROBOT_CANCELING,
ROBOT_IN_MOTION,
ROBOT_RESUMING,
ROBOT_RETRYING_COMMAND,
Expand All @@ -19,6 +20,8 @@ export function RecoveryInProgress({

const buildDescription = (): RobotMovingRoute => {
switch (route) {
case ROBOT_CANCELING.ROUTE:
return t('canceling_run')
case ROBOT_IN_MOTION.ROUTE:
return t('stand_back')
case ROBOT_RESUMING.ROUTE:
Expand Down
76 changes: 76 additions & 0 deletions app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/CancelRun.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'

import {
ALIGN_CENTER,
DIRECTION_COLUMN,
COLORS,
Flex,
Icon,
JUSTIFY_SPACE_BETWEEN,
SPACING,
StyledText,
} from '@opentrons/components'

import { RECOVERY_MAP } from '../constants'
import { RecoveryFooterButtons } from './shared'

import type { RecoveryContentProps } from '../types'

export function CancelRun({
isOnDevice,
routeUpdateActions,
recoveryCommands,
}: RecoveryContentProps): JSX.Element | null {
const { ROBOT_CANCELING } = RECOVERY_MAP
const { t } = useTranslation('error_recovery')

const { cancelRun } = recoveryCommands
const { goBackPrevStep, setRobotInMotion } = routeUpdateActions

const primaryBtnOnClick = (): Promise<void> => {
return setRobotInMotion(true, ROBOT_CANCELING.ROUTE).then(() => cancelRun())
}

if (isOnDevice) {
return (
<Flex
padding={SPACING.spacing32}
gridGap={SPACING.spacing24}
flexDirection={DIRECTION_COLUMN}
justifyContent={JUSTIFY_SPACE_BETWEEN}
alignItems={ALIGN_CENTER}
height="100%"
>
<Flex
flexDirection={DIRECTION_COLUMN}
alignItems={ALIGN_CENTER}
gridGap={SPACING.spacing24}
height="100%"
width="848px"
>
<Icon
name="ot-alert"
size="3.75rem"
marginTop={SPACING.spacing24}
color={COLORS.red50}
/>
<StyledText as="h3Bold">
{t('are_you_sure_you_want_to_cancel')}
</StyledText>
<StyledText as="h4" color={COLORS.grey60} textAlign={ALIGN_CENTER}>
{t('if_tips_are_attached')}
</StyledText>
</Flex>
<RecoveryFooterButtons
isOnDevice={isOnDevice}
primaryBtnOnClick={primaryBtnOnClick}
secondaryBtnOnClick={goBackPrevStep}
primaryBtnTextOverride={t('confirm')}
/>
</Flex>
)
} else {
return null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function ResumeRun({
const { goBackPrevStep, setRobotInMotion } = routeUpdateActions

const primaryBtnOnClick = (): Promise<void> => {
return setRobotInMotion(true, ROBOT_RETRYING_COMMAND.ROUTE) // Show the "retrying" motion screen while exiting ER.
return setRobotInMotion(true, ROBOT_RETRYING_COMMAND.ROUTE)
.then(() => retryFailedCommand())
.then(() => resumeRun())
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import * as React from 'react'
import { vi, describe, it, expect, beforeEach } from 'vitest'
import { screen, fireEvent, waitFor } from '@testing-library/react'

import { renderWithProviders } from '../../../../__testing-utils__'
import { i18n } from '../../../../i18n'
import { CancelRun } from '../CancelRun'
import { RECOVERY_MAP, ERROR_KINDS } from '../../constants'

import type { Mock } from 'vitest'

const render = (props: React.ComponentProps<typeof CancelRun>) => {
return renderWithProviders(<CancelRun {...props} />, {
i18nInstance: i18n,
})[0]
}

describe('RecoveryFooterButtons', () => {
const { CANCEL_RUN, ROBOT_CANCELING } = RECOVERY_MAP
let props: React.ComponentProps<typeof CancelRun>
let mockGoBackPrevStep: Mock

beforeEach(() => {
mockGoBackPrevStep = vi.fn()
const mockRouteUpdateActions = { goBackPrevStep: mockGoBackPrevStep } as any

props = {
isOnDevice: true,
recoveryCommands: {} as any,
failedCommand: {} as any,
errorKind: ERROR_KINDS.GENERAL_ERROR,
routeUpdateActions: mockRouteUpdateActions,
recoveryMap: {
route: CANCEL_RUN.ROUTE,
step: CANCEL_RUN.STEPS.CONFIRM_CANCEL,
},
}
})

it('renders appropriate copy and click behavior', async () => {
render(props)

screen.getByText('Are you sure you want to cancel?')
screen.queryByText(
'If tips are attached, you can choose to blowout any aspirated liquid and drop tips before the run is terminated.'
)

const secondaryBtn = screen.getByRole('button', { name: 'Go back' })

fireEvent.click(secondaryBtn)

expect(mockGoBackPrevStep).toHaveBeenCalled()
})

it('should call commands in the correct order for the primaryOnClick callback', async () => {
const setRobotInMotionMock = vi.fn(() => Promise.resolve())
const cancelRunMock = vi.fn(() => Promise.resolve())

const mockRecoveryCommands = {
cancelRun: cancelRunMock,
} as any

const mockRouteUpdateActions = {
setRobotInMotion: setRobotInMotionMock,
} as any

render({
...props,
recoveryCommands: mockRecoveryCommands,
routeUpdateActions: mockRouteUpdateActions,
})

const primaryBtn = screen.getByRole('button', { name: 'Confirm' })
fireEvent.click(primaryBtn)

await waitFor(() => {
expect(setRobotInMotionMock).toHaveBeenCalledTimes(1)
})
await waitFor(() => {
expect(setRobotInMotionMock).toHaveBeenCalledWith(
true,
ROBOT_CANCELING.ROUTE
)
})
await waitFor(() => {
expect(cancelRunMock).toHaveBeenCalledTimes(1)
})

expect(setRobotInMotionMock.mock.invocationCallOrder[0]).toBeLessThan(
cancelRunMock.mock.invocationCallOrder[0]
)
})
})
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { SelectRecoveryOption } from './SelectRecoveryOption'
export { ResumeRun } from './ResumeRun'
export { CancelRun } from './CancelRun'
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,11 @@ import { useTranslation } from 'react-i18next'
import {
ALIGN_CENTER,
Flex,
JUSTIFY_CENTER,
JUSTIFY_SPACE_BETWEEN,
SPACING,
} from '@opentrons/components'

import { SmallButton } from '../../../../atoms/buttons'
import {
NON_SANCTIONED_RECOVERY_COLOR_STYLE_PRIMARY,
NON_SANCTIONED_RECOVERY_COLOR_STYLE_SECONDARY,
} from '../../constants'

interface RecoveryOptionProps {
isOnDevice: boolean
Expand All @@ -39,20 +34,14 @@ export function RecoveryFooterButtons({
gridGap={SPACING.spacing8}
>
<SmallButton
buttonType="secondary"
flex="1"
css={NON_SANCTIONED_RECOVERY_COLOR_STYLE_SECONDARY}
buttonType="tertiaryLowLight"
buttonText={t('go_back')}
justifyContent={JUSTIFY_CENTER}
onClick={secondaryBtnOnClick}
marginTop="auto"
/>
<SmallButton
buttonType="primary"
flex="1"
css={NON_SANCTIONED_RECOVERY_COLOR_STYLE_PRIMARY}
buttonText={primaryBtnTextOverride ?? t('continue')}
justifyContent={JUSTIFY_CENTER}
onClick={primaryBtnOnClick}
marginTop="auto"
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { screen, renderHook, act } from '@testing-library/react'
import {
RUN_STATUS_AWAITING_RECOVERY,
RUN_STATUS_RUNNING,
RUN_STATUS_STOP_REQUESTED,
} from '@opentrons/api-client'

import { renderWithProviders } from '../../../__testing-utils__'
Expand Down Expand Up @@ -33,7 +34,7 @@ describe('useErrorRecovery', () => {
expect(result.current.isERActive).toBe(false)
})

it('should toggle the value of isEREnabled properly', () => {
it('should toggle the value of isEREnabled properly when the run status is valid', () => {
const { result } = renderHook(() =>
useErrorRecoveryFlows('MOCK_ID', RUN_STATUS_AWAITING_RECOVERY)
)
Expand All @@ -48,9 +49,19 @@ describe('useErrorRecovery', () => {
})

expect(result.current.isERActive).toBe(false)

const { result: resultStopRequested } = renderHook(() =>
useErrorRecoveryFlows('MOCK_ID', RUN_STATUS_STOP_REQUESTED)
)

act(() => {
resultStopRequested.current.toggleER()
})

expect(resultStopRequested.current.isERActive).toBe(true)
})

it('should disable error recovery when runStatus is not "awaiting-recovery"', () => {
it('should disable error recovery when runStatus is not a valid ER run status', () => {
const { result, rerender } = renderHook(
(runStatus: RunStatus) => useErrorRecoveryFlows('MOCK_ID', runStatus),
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe('ErrorRecoveryContent', () => {
OPTION_SELECTION,
BEFORE_BEGINNING,
RESUME,
ROBOT_CANCELING,
ROBOT_RESUMING,
ROBOT_IN_MOTION,
ROBOT_RETRYING_COMMAND,
Expand Down Expand Up @@ -92,6 +93,19 @@ describe('ErrorRecoveryContent', () => {
screen.getByText('MOCK_RESUME_RUN')
})

it(`returns RecoveryInProgressModal when the route is ${ROBOT_CANCELING.ROUTE}`, () => {
props = {
...props,
recoveryMap: {
...props.recoveryMap,
route: ROBOT_CANCELING.ROUTE,
},
}
render(props)

screen.getByText('MOCK_IN_PROGRESS')
})

it(`returns RecoveryInProgressModal when the route is ${ROBOT_IN_MOTION.ROUTE}`, () => {
props = {
...props,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const render = (props: React.ComponentProps<typeof RecoveryInProgress>) => {

describe('RecoveryInProgress', () => {
const {
ROBOT_CANCELING,
ROBOT_IN_MOTION,
ROBOT_RESUMING,
ROBOT_RETRYING_COMMAND,
Expand Down Expand Up @@ -66,4 +67,17 @@ describe('RecoveryInProgress', () => {

screen.getByText('Stand back, retrying current command')
})

it(`renders appropriate copy when the route is ${ROBOT_CANCELING.ROUTE}`, () => {
props = {
...props,
recoveryMap: {
route: ROBOT_CANCELING.ROUTE,
step: ROBOT_CANCELING.STEPS.CANCELING,
},
}
render(props)

screen.getByText('Canceling run')
})
})
Loading

0 comments on commit 3896d9d

Please sign in to comment.