Skip to content

Commit

Permalink
refactor(app, components): Update "run paused" splash (#15262)
Browse files Browse the repository at this point in the history
Closes EXEC-398 and EXEC-447

Refactors the Splash page, changing functionality and aligning it more with current Hi-Fi designs.
  • Loading branch information
mjhuff committed May 24, 2024
1 parent 162d12e commit df62b9b
Show file tree
Hide file tree
Showing 33 changed files with 888 additions and 517 deletions.
7 changes: 0 additions & 7 deletions api-client/src/runs/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,3 @@ export interface CreateCommandParams {
timeout?: number
failedCommandId?: string
}

export interface RunCommandError {
id: string
errorType: string
createdAt: string
detail: string
}
4 changes: 3 additions & 1 deletion app/src/assets/localization/en/error_recovery.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@
"general_error_message": "<Placeholder>",
"go_back": "Go back",
"how_do_you_want_to_proceed": "How do you want to proceed?",
"launch_recovery_mode": "Launch Recovery Mode",
"recovery_mode": "Recovery Mode",
"recovery_mode_explanation": "<block>Recovery Mode provides you with guided and manual controls for handling errors at runtime.</block><br/><block>You can make changes to ensure the step in progress when the error occurred can be completed or choose to cancel the protocol. When changes are made and no subsequent errors are detected, the method completes. Depending on the conditions that caused the error, you will only be provided with appropriate options.</block>",
"resume": "Resume",
"retry_step": "Retry step",
"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",
"tip_not_detected": "Tip not detected",
"view_recovery_options": "View recovery options"
}
5 changes: 4 additions & 1 deletion app/src/atoms/buttons/LargeButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ interface LargeButtonProps extends StyleProps {
buttonType?: LargeButtonTypes
buttonText: React.ReactNode
iconName?: IconName
iconColorOverride?: string
subtext?: string
disabled?: boolean
}
Expand All @@ -31,6 +32,7 @@ export function LargeButton(props: LargeButtonProps): JSX.Element {
buttonType = 'primary',
buttonText,
iconName,
iconColorOverride,
subtext,
disabled = false,
...buttonProps
Expand Down Expand Up @@ -130,7 +132,8 @@ export function LargeButton(props: LargeButtonProps): JSX.Element {
color={
disabled
? COLORS.grey50
: LARGE_BUTTON_PROPS_BY_TYPE[buttonType].iconColor
: iconColorOverride ??
LARGE_BUTTON_PROPS_BY_TYPE[buttonType].iconColor
}
size="5rem"
/>
Expand Down
11 changes: 11 additions & 0 deletions app/src/atoms/buttons/__tests__/LargeButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,15 @@ describe('LargeButton', () => {
render(props)
expect(screen.getByRole('button')).toBeDisabled()
})

it('renders the icon override color if specified', () => {
props = {
...props,
iconColorOverride: COLORS.red50,
}
render(props)
expect(screen.getByLabelText('play-round-corners icon')).toHaveStyle(
`color: ${COLORS.red50}`
)
})
})
2 changes: 1 addition & 1 deletion app/src/organisms/DropTipWizard/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { AlertPrimaryButton, SPACING } from '@opentrons/components'
import { DROP_TIP_SPECIAL_ERROR_TYPES } from './constants'
import { SmallButton } from '../../atoms/buttons'

import type { RunCommandError } from '@opentrons/api-client'
import type { RunCommandError } from '@opentrons/shared-data'
import type { useChainMaintenanceCommands } from '../../resources/runs'

export interface ErrorDetails {
Expand Down
112 changes: 70 additions & 42 deletions app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,54 +13,75 @@ import {
import { getIsOnDevice } from '../../redux/config'
import { getTopPortalEl } from '../../App/portal'
import { BeforeBeginning } from './BeforeBeginning'
import { SelectRecoveryOption, ResumeRun, CancelRun } from './RecoveryOptions'
import { SelectRecoveryOption, RetryStep, CancelRun } from './RecoveryOptions'
import { ErrorRecoveryHeader } from './ErrorRecoveryHeader'
import { RecoveryInProgress } from './RecoveryInProgress'
import { getErrorKind, useRouteUpdateActions } from './utils'
import { useRecoveryCommands } from './useRecoveryCommands'
import { getErrorKind } from './utils'
import { RECOVERY_MAP } from './constants'

import type { FailedCommand, IRecoveryMap, RecoveryContentProps } from './types'
import type {
useRouteUpdateActions,
UseRouteUpdateActionsResult,
} from './utils'
import type {
useRecoveryCommands,
UseRecoveryCommandsResult,
} from './useRecoveryCommands'

interface UseERWizardResult {
hasLaunchedRecovery: boolean
showERWizard: boolean
toggleERWizard: (hasLaunchedER: boolean) => Promise<void>
}

export interface ErrorRecoveryFlowsProps {
runId: string
failedCommand: FailedCommand | null
export function useERWizard(): UseERWizardResult {
const [showERWizard, setShowERWizard] = React.useState(false)
// Because RunPausedSplash has access to some ER Wiz routes but is not a part of the ER wizard, the splash screen
// is the "home" route as opposed to SelectRecoveryOption (accessed by pressing "go back" or "continue" enough times)
// when recovery mode has not been launched.
const [hasLaunchedRecovery, setHasLaunchedRecovery] = React.useState(false)

const toggleERWizard = (hasLaunchedER: boolean): Promise<void> => {
setHasLaunchedRecovery(hasLaunchedER)
setShowERWizard(!showERWizard)
return Promise.resolve()
}

return { showERWizard, toggleERWizard, hasLaunchedRecovery }
}

export function ErrorRecoveryWizard({
runId,
failedCommand,
}: ErrorRecoveryFlowsProps): JSX.Element {
/**
* Recovery Route: A logically-related collection of recovery steps or a single step if unrelated to any existing recovery route.
* Recovery Step: Analogous to a "step" in other wizard flows.
*/
const [recoveryMap, setRecoveryMap] = React.useState<IRecoveryMap>({
route: RECOVERY_MAP.OPTION_SELECTION.ROUTE,
step: RECOVERY_MAP.OPTION_SELECTION.STEPS.SELECT,
})
export interface ErrorRecoveryWizardProps {
failedCommand: FailedCommand | null
recoveryMap: IRecoveryMap
routeUpdateActions: UseRouteUpdateActionsResult
recoveryCommands: UseRecoveryCommandsResult
hasLaunchedRecovery: boolean
}

export function ErrorRecoveryWizard(
props: ErrorRecoveryWizardProps
): JSX.Element {
const {
hasLaunchedRecovery,
failedCommand,
recoveryCommands,
routeUpdateActions,
} = props
const errorKind = getErrorKind(failedCommand?.error?.errorType)
const isOnDevice = useSelector(getIsOnDevice)
const routeUpdateActions = useRouteUpdateActions({
recoveryMap,
setRecoveryMap,
})
const recoveryCommands = useRecoveryCommands({
runId,
failedCommand,
})

useInitialPipetteHome(recoveryCommands, routeUpdateActions)
useInitialPipetteHome({
hasLaunchedRecovery,
recoveryCommands,
routeUpdateActions,
})

return (
<ErrorRecoveryComponent
failedCommand={failedCommand}
errorKind={errorKind}
isOnDevice={isOnDevice}
recoveryMap={recoveryMap}
routeUpdateActions={routeUpdateActions}
recoveryCommands={recoveryCommands}
{...props}
/>
)
}
Expand Down Expand Up @@ -98,7 +119,7 @@ export function ErrorRecoveryContent(props: RecoveryContentProps): JSX.Element {
}

const buildResumeRun = (): JSX.Element => {
return <ResumeRun {...props} />
return <RetryStep {...props} />
}

const buildCancelRun = (): JSX.Element => {
Expand All @@ -110,7 +131,7 @@ export function ErrorRecoveryContent(props: RecoveryContentProps): JSX.Element {
return buildBeforeBeginning()
case RECOVERY_MAP.OPTION_SELECTION.ROUTE:
return buildSelectRecoveryOption()
case RECOVERY_MAP.RESUME.ROUTE:
case RECOVERY_MAP.RETRY_FAILED_COMMAND.ROUTE:
return buildResumeRun()
case RECOVERY_MAP.CANCEL_RUN.ROUTE:
return buildCancelRun()
Expand All @@ -123,19 +144,26 @@ export function ErrorRecoveryContent(props: RecoveryContentProps): JSX.Element {
return buildSelectRecoveryOption()
}
}

// Home the Z-axis of all attached pipettes on Error Recovery launch.
export function useInitialPipetteHome(
recoveryCommands: ReturnType<typeof useRecoveryCommands>,
interface UseInitialPipetteHomeParams {
hasLaunchedRecovery: boolean
recoveryCommands: ReturnType<typeof useRecoveryCommands>
routeUpdateActions: ReturnType<typeof useRouteUpdateActions>
): void {
}
// Home the Z-axis of all attached pipettes on Error Recovery launch.
export function useInitialPipetteHome({
hasLaunchedRecovery,
recoveryCommands,
routeUpdateActions,
}: UseInitialPipetteHomeParams): void {
const { homePipetteZAxes } = recoveryCommands
const { setRobotInMotion } = routeUpdateActions

// Synchronously set the recovery route to "robot in motion" before initial render to prevent screen flicker on ER launch.
React.useLayoutEffect(() => {
void setRobotInMotion(true)
.then(() => homePipetteZAxes())
.finally(() => setRobotInMotion(false))
}, [])
if (hasLaunchedRecovery) {
void setRobotInMotion(true)
.then(() => homePipetteZAxes())
.finally(() => setRobotInMotion(false))
}
}, [hasLaunchedRecovery])
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { RecoveryFooterButtons } from './shared'

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

export function ResumeRun({
export function RetryStep({
isOnDevice,
routeUpdateActions,
recoveryCommands,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,6 @@ export function SelectRecoveryOption({
primaryBtnOnClick={() =>
proceedToRoute(selectedRoute as RecoveryRoute)
}
secondaryBtnOnClick={() =>
proceedToRoute(RECOVERY_MAP.BEFORE_BEGINNING.ROUTE)
}
/>
</Flex>
)
Expand All @@ -83,8 +80,8 @@ export function RecoveryOptions({
return validRecoveryOptions.map((recoveryOption: RecoveryRoute) => {
const buildOptionName = (): string => {
switch (recoveryOption) {
case RECOVERY_MAP.RESUME.ROUTE:
return t('resume')
case RECOVERY_MAP.RETRY_FAILED_COMMAND.ROUTE:
return t('retry_step')
case RECOVERY_MAP.CANCEL_RUN.ROUTE:
return t('cancel_run')
default:
Expand Down Expand Up @@ -113,6 +110,6 @@ export function getRecoveryOptions(errorKind: ErrorKind): RecoveryRoute[] {
}

export const GENERAL_ERROR_OPTIONS: RecoveryRoute[] = [
RECOVERY_MAP.RESUME.ROUTE,
RECOVERY_MAP.RETRY_FAILED_COMMAND.ROUTE,
RECOVERY_MAP.CANCEL_RUN.ROUTE,
]
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { screen, fireEvent, waitFor } from '@testing-library/react'

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

import type { Mock } from 'vitest'

Expand All @@ -25,10 +26,7 @@ describe('RecoveryFooterButtons', () => {
const mockRouteUpdateActions = { goBackPrevStep: mockGoBackPrevStep } as any

props = {
isOnDevice: true,
recoveryCommands: {} as any,
failedCommand: {} as any,
errorKind: ERROR_KINDS.GENERAL_ERROR,
...mockRecoveryContentProps,
routeUpdateActions: mockRouteUpdateActions,
recoveryMap: {
route: CANCEL_RUN.ROUTE,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,33 @@ import { screen, fireEvent, waitFor } from '@testing-library/react'

import { renderWithProviders } from '../../../../__testing-utils__'
import { i18n } from '../../../../i18n'
import { ResumeRun } from '../ResumeRun'
import { RECOVERY_MAP, ERROR_KINDS } from '../../constants'
import { mockRecoveryContentProps } from '../../__fixtures__'
import { RetryStep } from '../RetryStep'
import { RECOVERY_MAP } from '../../constants'

import type { Mock } from 'vitest'

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

describe('RecoveryFooterButtons', () => {
const { RESUME, ROBOT_RETRYING_COMMAND } = RECOVERY_MAP
let props: React.ComponentProps<typeof ResumeRun>
const { RETRY_FAILED_COMMAND, ROBOT_RETRYING_COMMAND } = RECOVERY_MAP
let props: React.ComponentProps<typeof RetryStep>
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,
...mockRecoveryContentProps,
routeUpdateActions: mockRouteUpdateActions,
recoveryMap: {
route: RESUME.ROUTE,
step: RESUME.STEPS.CONFIRM_RESUME,
route: RETRY_FAILED_COMMAND.ROUTE,
step: RETRY_FAILED_COMMAND.STEPS.CONFIRM_RETRY,
},
}
})
Expand Down
Loading

0 comments on commit df62b9b

Please sign in to comment.