Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(app): Fix entering error recovery while door is open #16245

Merged
merged 3 commits into from
Sep 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/src/assets/localization/en/error_recovery.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -24,6 +25,7 @@
"first_take_any_necessary_actions": "<block>First, take any necessary actions to prepare the robot to retry the failed step.</block><block>Then, close the robot door before proceeding.</block>",
"go_back": "Go back",
"homing_pipette_dangerous": "Homing the <bold>{{mount}} pipette</bold> 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",
Expand All @@ -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?",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) ? (
<Banner type="warning" iconMarginLeft={SPACING.spacing4}>
{t('shared:close_robot_door')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 {
Expand All @@ -53,29 +59,32 @@ export function useRunPausedSplash(
}
}

type RunPausedSplashProps = ERUtilsResults & {
isOnDevice: boolean
failedCommand: ReturnType<typeof useRetainedFailedCommandBySource>
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<typeof useRetainedFailedCommandBySource>
robotType: RobotType
robotName: string
toggleERWizAsActiveUser: UseRecoveryTakeoverResult['toggleERWizAsActiveUser']
analytics: UseRecoveryAnalyticsResult
}
export function RecoverySplash(props: RecoverySplashProps): JSX.Element | null {
const {
isOnDevice,
toggleERWizAsActiveUser,
routeUpdateActions,
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
Expand All @@ -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<void> => {
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<void> => {
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
Expand Down Expand Up @@ -149,14 +195,18 @@ export function RunPausedSplash(
<LargeButton
onClick={onCancelClick}
buttonText={t('cancel_run')}
css={SHARED_BUTTON_STYLE_ODD}
css={
isDisabled() ? BTN_STYLE_DISABLED_ODD : SHARED_BUTTON_STYLE_ODD
}
iconName={'remove'}
buttonType="alertAlt"
/>
<LargeButton
onClick={onLaunchERClick}
buttonText={t('launch_recovery_mode')}
css={SHARED_BUTTON_STYLE_ODD}
css={
isDisabled() ? BTN_STYLE_DISABLED_ODD : SHARED_BUTTON_STYLE_ODD
}
iconName={'recovery'}
buttonType="alertStroke"
/>
Expand Down Expand Up @@ -194,12 +244,20 @@ export function RunPausedSplash(
</Flex>
</Flex>
<Flex gridGap={SPACING.spacing8} marginLeft="auto">
<SecondaryButton isDangerous onClick={onCancelClick}>
<SecondaryButton
isDangerous
onClick={onCancelClick}
css={isDisabled() ? BTN_STYLES_DISABLED_DESKTOP : undefined}
>
{t('cancel_run')}
</SecondaryButton>
<PrimaryButton
onClick={onLaunchERClick}
css={PRIMARY_BTN_STYLES_DESKTOP}
css={
isDisabled()
? BTN_STYLES_DISABLED_DESKTOP
: PRIMARY_BTN_STYLES_DESKTOP
}
>
<StyledText desktopStyle="bodyDefaultSemiBold">
{t('launch_recovery_mode')}
Expand Down Expand Up @@ -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};
Expand All @@ -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;
`
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ 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'

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')
Expand Down Expand Up @@ -143,15 +143,13 @@ describe('ErrorRecoveryFlows', () => {
protocolAnalysis: {} as any,
}
vi.mocked(ErrorRecoveryWizard).mockReturnValue(<div>MOCK WIZARD</div>)
vi.mocked(RunPausedSplash).mockReturnValue(
<div>MOCK RUN PAUSED SPLASH</div>
)
vi.mocked(RecoverySplash).mockReturnValue(<div>MOCK RUN PAUSED SPLASH</div>)
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({
Expand Down Expand Up @@ -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()
})
Expand Down
Loading
Loading