From 7263f2028d6420858cd9753a6638199e29d51ef4 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Thu, 2 May 2024 14:28:05 -0400 Subject: [PATCH] feat(app): add ErrorRecoveryFlow orchestrator --- .../localization/en/error_recovery.json | 18 +- .../ErrorRecoveryFlows/BeforeBeginning.tsx | 60 +++++++ .../ErrorRecoveryHeader.tsx | 90 ++++++++++ .../ErrorRecoveryFlows/RecoveryInProgress.tsx | 30 ++++ .../RecoveryOptions/ResumeRun.tsx | 62 +++++++ .../RecoveryOptions/SelectRecoveryOption.tsx | 118 ++++++++++++ .../__tests__/ResumeRun.test.tsx | 58 ++++++ .../__tests__/SelectRecoveryOptions.test.tsx | 115 ++++++++++++ .../RecoveryOptions/index.ts | 2 + .../shared/RecoveryFooterButtons.tsx | 64 +++++++ .../__tests__/RecoveryFooterButtons.test.tsx | 51 ++++++ .../RecoveryOptions/shared/index.ts | 1 + .../__tests__/BeforeBeginning.test.tsx | 52 ++++++ .../__tests__/ErrorRecoveryFlows.test.tsx | 114 ++++++++++++ .../__tests__/ErrorRecoveryHeader.test.tsx | 36 ++++ .../__tests__/RecoveryInProgress.test.tsx | 52 ++++++ .../__tests__/utils.test.ts | 137 ++++++++++++++ .../organisms/ErrorRecoveryFlows/constants.ts | 114 ++++++++++++ .../organisms/ErrorRecoveryFlows/index.tsx | 109 ++++++++++++ app/src/organisms/ErrorRecoveryFlows/types.ts | 58 ++++++ app/src/organisms/ErrorRecoveryFlows/utils.ts | 168 ++++++++++++++++++ .../RunningProtocol/RunPausedSplash.tsx | 6 +- .../__tests__/RunPausedSplash.test.tsx | 2 +- app/src/pages/RunningProtocol/index.tsx | 12 +- 24 files changed, 1523 insertions(+), 6 deletions(-) create mode 100644 app/src/organisms/ErrorRecoveryFlows/BeforeBeginning.tsx create mode 100644 app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryHeader.tsx create mode 100644 app/src/organisms/ErrorRecoveryFlows/RecoveryInProgress.tsx create mode 100644 app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ResumeRun.tsx create mode 100644 app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx create mode 100644 app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ResumeRun.test.tsx create mode 100644 app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SelectRecoveryOptions.test.tsx create mode 100644 app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/index.ts create mode 100644 app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/shared/RecoveryFooterButtons.tsx create mode 100644 app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/shared/__tests__/RecoveryFooterButtons.test.tsx create mode 100644 app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/shared/index.ts create mode 100644 app/src/organisms/ErrorRecoveryFlows/__tests__/BeforeBeginning.test.tsx create mode 100644 app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx create mode 100644 app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryHeader.test.tsx create mode 100644 app/src/organisms/ErrorRecoveryFlows/__tests__/RecoveryInProgress.test.tsx create mode 100644 app/src/organisms/ErrorRecoveryFlows/__tests__/utils.test.ts create mode 100644 app/src/organisms/ErrorRecoveryFlows/constants.ts create mode 100644 app/src/organisms/ErrorRecoveryFlows/index.tsx create mode 100644 app/src/organisms/ErrorRecoveryFlows/types.ts create mode 100644 app/src/organisms/ErrorRecoveryFlows/utils.ts diff --git a/app/src/assets/localization/en/error_recovery.json b/app/src/assets/localization/en/error_recovery.json index 7531853df163..f606e5fdf33a 100644 --- a/app/src/assets/localization/en/error_recovery.json +++ b/app/src/assets/localization/en/error_recovery.json @@ -1,3 +1,19 @@ { - "run_paused": "Run paused" + "are_you_sure_you_want_to_resume": "Are you sure you want to resume?", + "before_you_begin": "Before you begin", + "cancel_run": "Cancel run", + "confirm": "Confirm", + "continue": "Continue", + "general_error": "General error", + "general_error_message": "", + "go_back": "Go back", + "how_do_you_want_to_proceed": "How do you want to proceed?", + "recovery_mode": "Recovery Mode", + "recovery_mode_explanation": "Recovery Mode provides you with guided and manual controls for handling errors at runtime.
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.", + "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.", + "stand_back": "Stand back, robot is in motion", + "stand_back_resuming": "Stand back, resuming current step", + "view_recovery_options": "View recovery options" } diff --git a/app/src/organisms/ErrorRecoveryFlows/BeforeBeginning.tsx b/app/src/organisms/ErrorRecoveryFlows/BeforeBeginning.tsx new file mode 100644 index 000000000000..02c8ecc4ba7d --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/BeforeBeginning.tsx @@ -0,0 +1,60 @@ +import * as React from 'react' +import { Trans, useTranslation } from 'react-i18next' + +import { + DIRECTION_COLUMN, + Flex, + JUSTIFY_SPACE_BETWEEN, + SPACING, + JUSTIFY_CENTER, + StyledText, +} from '@opentrons/components' + +import { SmallButton } from '../../atoms/buttons' +import { + NON_SANCTIONED_RECOVERY_COLOR_STYLE_PRIMARY, + BODY_TEXT_STYLE, + ODD_SECTION_TITLE_STYLE, +} from './constants' + +import type { RecoveryContentProps } from './types' + +export function BeforeBeginning({ + isOnDevice, + routeUpdateActions, +}: RecoveryContentProps): JSX.Element | null { + const { t } = useTranslation('error_recovery') + const { proceedNextStep } = routeUpdateActions + + if (isOnDevice) { + return ( + + + + {t('before_you_begin')} + + }} + /> + + + + ) + } else { + return null + } +} diff --git a/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryHeader.tsx b/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryHeader.tsx new file mode 100644 index 000000000000..0c501149b82e --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryHeader.tsx @@ -0,0 +1,90 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { css } from 'styled-components' + +import { + Box, + DIRECTION_ROW, + BORDERS, + ALIGN_CENTER, + Flex, + JUSTIFY_SPACE_BETWEEN, + TYPOGRAPHY, + COLORS, + SPACING, + RESPONSIVENESS, + StyledText, + Icon, +} from '@opentrons/components' + +import { useErrorName } from './utils' +import { NON_DESIGN_SANCTIONED_COLOR_1 } from './constants' + +import type { ErrorKind } from './types' + +interface ErrorRecoveryHeaderProps { + errorKind: ErrorKind +} +export function ErrorRecoveryHeader({ + errorKind, +}: ErrorRecoveryHeaderProps): JSX.Element { + const { t } = useTranslation('error_recovery') + const errorName = useErrorName(errorKind) + + return ( + + + + {t('recovery_mode')} + + + {errorName} + + + + + ) +} + +function AlertHeaderIcon(): JSX.Element { + return ( + + ) +} + +const BOX_STYLE = css` + background-color: ${NON_DESIGN_SANCTIONED_COLOR_1}; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + border-radius: ${BORDERS.borderRadius12} ${BORDERS.borderRadius12} 0 0; + } +` +const HEADER_CONTAINER_STYLE = css` + flex-direction: ${DIRECTION_ROW}; + justify-content: ${JUSTIFY_SPACE_BETWEEN}; + padding: ${SPACING.spacing16} ${SPACING.spacing32}; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + padding: 1.75rem ${SPACING.spacing32}; + } +` +const HEADER_TEXT_STYLE = css` + ${TYPOGRAPHY.pSemiBold} + color: ${COLORS.white}; + cursor: default; + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + font-size: ${TYPOGRAPHY.fontSize22}; + font-weight: ${TYPOGRAPHY.fontWeightBold}; + line-height: ${TYPOGRAPHY.lineHeight28}; + } +` diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryInProgress.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryInProgress.tsx new file mode 100644 index 000000000000..a30d8dd2f0aa --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryInProgress.tsx @@ -0,0 +1,30 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' + +import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal' +import { RECOVERY_MAP } from './constants' + +import type { RobotMovingRoute, RecoveryContentProps } from './types' + +export function RecoveryInProgress({ + recoveryMap, +}: RecoveryContentProps): JSX.Element { + const { ROBOT_IN_MOTION, ROBOT_RESUMING } = RECOVERY_MAP + const { t } = useTranslation('error_recovery') + const { route } = recoveryMap + + const buildDescription = (): RobotMovingRoute => { + switch (route) { + case ROBOT_IN_MOTION.ROUTE: + return t('stand_back') + case ROBOT_RESUMING.ROUTE: + return t('stand_back_resuming') + default: + return t('stand_back') + } + } + + const description = buildDescription() + + return +} diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ResumeRun.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ResumeRun.tsx new file mode 100644 index 000000000000..982e1a01129d --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ResumeRun.tsx @@ -0,0 +1,62 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' + +import { + ALIGN_CENTER, + DIRECTION_COLUMN, + Flex, + Icon, + JUSTIFY_SPACE_BETWEEN, + SPACING, + StyledText, +} from '@opentrons/components' + +import { RecoveryFooterButtons } from './shared' + +import type { RecoveryContentProps } from '../types' + +export function ResumeRun({ + isOnDevice, + onComplete, + routeUpdateActions, +}: RecoveryContentProps): JSX.Element | null { + const { t } = useTranslation('error_recovery') + const { goBackPrevStep } = routeUpdateActions + + if (isOnDevice) { + return ( + + + + + {t('are_you_sure_you_want_to_resume')} + + + {t('run_will_resume')} + + + + + ) + } else { + return null + } +} diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx new file mode 100644 index 000000000000..dee0761ab41d --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx @@ -0,0 +1,118 @@ +import * as React from 'react' +import head from 'lodash/head' +import { useTranslation } from 'react-i18next' + +import { + DIRECTION_COLUMN, + Flex, + JUSTIFY_SPACE_BETWEEN, + SPACING, + StyledText, +} from '@opentrons/components' + +import { + RECOVERY_MAP, + ERROR_KINDS, + ODD_SECTION_TITLE_STYLE, +} from '../constants' +import { RadioButton } from '../../../atoms/buttons' +import { RecoveryFooterButtons } from './shared' + +import type { ErrorKind, RecoveryContentProps, RecoveryRoute } from '../types' + +// The "home" screen within Error Recovery. When a user completes a non-terminal flow or presses "Go back" enough +// to escape the boundaries of a route, they will be redirected here. +export function SelectRecoveryOption({ + isOnDevice, + errorKind, + routeUpdateActions, +}: RecoveryContentProps): JSX.Element | null { + const { t } = useTranslation('error_recovery') + const { proceedToRoute } = routeUpdateActions + const validRecoveryOptions = getRecoveryOptions(errorKind) + const [selectedRoute, setSelectedRoute] = React.useState( + head(validRecoveryOptions) as RecoveryRoute + ) + // TOME: Test this behavior before committing. Resume should be selected off the bat! + if (isOnDevice) { + return ( + + + {t('how_do_you_want_to_proceed')} + + + + + + proceedToRoute(selectedRoute as RecoveryRoute) + } + secondaryBtnOnClick={() => + proceedToRoute(RECOVERY_MAP.BEFORE_BEGINNING.ROUTE) + } + /> + + ) + } else { + return null + } +} + +interface RecoveryOptionsProps { + validRecoveryOptions: RecoveryRoute[] + setSelectedRoute: (route: RecoveryRoute) => void + selectedRoute?: RecoveryRoute +} +export function RecoveryOptions({ + validRecoveryOptions, + selectedRoute, + setSelectedRoute, +}: RecoveryOptionsProps): JSX.Element[] { + const { t } = useTranslation('error_recovery') + + return validRecoveryOptions.map((recoveryOption: RecoveryRoute) => { + const buildOptionName = (): string => { + switch (recoveryOption) { + case RECOVERY_MAP.RESUME.ROUTE: + return t('resume') + case RECOVERY_MAP.CANCEL_RUN.ROUTE: + return t('cancel_run') + default: + return 'INVALID_OPTION' + } + } + const optionName = buildOptionName() + + return ( + setSelectedRoute(recoveryOption)} + isSelected={recoveryOption === selectedRoute} + /> + ) + }) +} + +export function getRecoveryOptions(errorKind: ErrorKind): RecoveryRoute[] { + switch (errorKind) { + case ERROR_KINDS.GENERAL_ERROR: + return GENERAL_ERROR_OPTIONS + } +} + +export const GENERAL_ERROR_OPTIONS: RecoveryRoute[] = [ + RECOVERY_MAP.RESUME.ROUTE, + RECOVERY_MAP.CANCEL_RUN.ROUTE, +] diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ResumeRun.test.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ResumeRun.test.tsx new file mode 100644 index 000000000000..ca7d83e297df --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ResumeRun.test.tsx @@ -0,0 +1,58 @@ +import * as React from 'react' +import { vi, describe, it, expect, beforeEach } from 'vitest' +import { screen, fireEvent } 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 type { Mock } from 'vitest' + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('RecoveryFooterButtons', () => { + const { RESUME } = RECOVERY_MAP + let props: React.ComponentProps + let mockOnComplete: Mock + let mockGoBackPrevStep: Mock + + beforeEach(() => { + mockOnComplete = vi.fn() + mockGoBackPrevStep = vi.fn() + const mockRouteUpdateActions = { goBackPrevStep: mockGoBackPrevStep } as any + + props = { + isOnDevice: true, + errorKind: ERROR_KINDS.GENERAL_ERROR, + onComplete: mockOnComplete, + routeUpdateActions: mockRouteUpdateActions, + recoveryMap: { + route: RESUME.ROUTE, + step: RESUME.STEPS.CONFIRM_RESUME, + }, + } + }) + + it('renders appropriate copy and click behavior', () => { + render(props) + + screen.getByText('Are you sure you want to resume?') + screen.queryByText( + 'The run will resume from the point at which the error occurred.' + ) + + const primaryBtn = screen.getByRole('button', { name: 'Confirm' }) + const secondaryBtn = screen.getByRole('button', { name: 'Go back' }) + + fireEvent.click(primaryBtn) + fireEvent.click(secondaryBtn) + + expect(mockOnComplete).toHaveBeenCalled() + expect(mockGoBackPrevStep).toHaveBeenCalled() + }) +}) diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SelectRecoveryOptions.test.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SelectRecoveryOptions.test.tsx new file mode 100644 index 000000000000..81e47d85dac2 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SelectRecoveryOptions.test.tsx @@ -0,0 +1,115 @@ +import * as React from 'react' +import { vi, describe, it, expect, beforeEach } from 'vitest' +import { screen, fireEvent } from '@testing-library/react' + +import { renderWithProviders } from '../../../../__testing-utils__' +import { i18n } from '../../../../i18n' +import { + SelectRecoveryOption, + RecoveryOptions, + getRecoveryOptions, + GENERAL_ERROR_OPTIONS, +} from '../SelectRecoveryOption' +import { RECOVERY_MAP, ERROR_KINDS } from '../../constants' + +import type { Mock } from 'vitest' + +const renderSelectRecoveryOption = ( + props: React.ComponentProps +) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +const renderRecoveryOptions = ( + props: React.ComponentProps +) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('SelectRecoveryOption', () => { + const { RESUME } = RECOVERY_MAP + let props: React.ComponentProps + let mockProceedToRoute: Mock + + beforeEach(() => { + mockProceedToRoute = vi.fn() + const mockRouteUpdateActions = { proceedToRoute: mockProceedToRoute } as any + + props = { + isOnDevice: true, + errorKind: ERROR_KINDS.GENERAL_ERROR, + onComplete: vi.fn(), + routeUpdateActions: mockRouteUpdateActions, + recoveryMap: { + route: RESUME.ROUTE, + step: RESUME.STEPS.CONFIRM_RESUME, + }, + } + }) + + it('renders appropriate general copy and click behavior', () => { + renderSelectRecoveryOption(props) + + screen.getByText('How do you want to proceed?') + + const resumeOptionRadioLabel = screen.getByRole('label', { name: 'Resume' }) + const primaryBtn = screen.getByRole('button', { name: 'Continue' }) + const secondaryBtn = screen.getByRole('button', { name: 'Go back' }) + + fireEvent.click(resumeOptionRadioLabel) + fireEvent.click(primaryBtn) + + expect(mockProceedToRoute).toHaveBeenCalledWith(RESUME.ROUTE) + + renderSelectRecoveryOption(props) + + fireEvent.click(secondaryBtn) + + expect(mockProceedToRoute).toHaveBeenCalledWith( + RECOVERY_MAP.BEFORE_BEGINNING.ROUTE + ) + }) +}) + +describe('RecoveryOptions', () => { + let props: React.ComponentProps + let mockSetSelectedRoute: Mock + + beforeEach(() => { + mockSetSelectedRoute = vi.fn() + const generalRecoveryOptions = getRecoveryOptions(ERROR_KINDS.GENERAL_ERROR) + + props = { + validRecoveryOptions: generalRecoveryOptions, + setSelectedRoute: mockSetSelectedRoute, + } + }) + + it('renders valid recovery options for a general error errorKind', () => { + renderRecoveryOptions(props) + + screen.getByRole('label', { name: 'Resume' }) + screen.getByRole('label', { name: 'Cancel run' }) + }) + + it('updates the selectedRoute when a new option is selected', () => { + renderRecoveryOptions(props) + + fireEvent.click(screen.getByRole('label', { name: 'Cancel run' })) + + expect(mockSetSelectedRoute).toHaveBeenCalledWith( + RECOVERY_MAP.CANCEL_RUN.ROUTE + ) + }) +}) + +describe('getRecoveryOptions', () => { + it(`returns general error options when the errorKind is ${ERROR_KINDS.GENERAL_ERROR}`, () => { + const generalErrorOptions = getRecoveryOptions(ERROR_KINDS.GENERAL_ERROR) + expect(generalErrorOptions).toBe(GENERAL_ERROR_OPTIONS) + }) +}) diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/index.ts b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/index.ts new file mode 100644 index 000000000000..c39d9e883c65 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/index.ts @@ -0,0 +1,2 @@ +export { SelectRecoveryOption } from './SelectRecoveryOption' +export { ResumeRun } from './ResumeRun' diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/shared/RecoveryFooterButtons.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/shared/RecoveryFooterButtons.tsx new file mode 100644 index 000000000000..64264630c2d3 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/shared/RecoveryFooterButtons.tsx @@ -0,0 +1,64 @@ +import * as React from 'react' +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 + secondaryBtnOnClick: () => void + primaryBtnOnClick: () => void + primaryBtnTextOverride?: string +} +export function RecoveryFooterButtons({ + isOnDevice, + secondaryBtnOnClick, + primaryBtnOnClick, + primaryBtnTextOverride, +}: RecoveryOptionProps): JSX.Element | null { + const { t } = useTranslation('error_recovery') + + if (isOnDevice) { + return ( + + + + + ) + } else { + return null + } +} diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/shared/__tests__/RecoveryFooterButtons.test.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/shared/__tests__/RecoveryFooterButtons.test.tsx new file mode 100644 index 000000000000..6933177e22c9 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/shared/__tests__/RecoveryFooterButtons.test.tsx @@ -0,0 +1,51 @@ +import * as React from 'react' +import { vi, describe, it, expect, beforeEach } from 'vitest' +import { screen, fireEvent } from '@testing-library/react' + +import { renderWithProviders } from '../../../../../__testing-utils__' +import { i18n } from '../../../../../i18n' +import { RecoveryFooterButtons } from '../RecoveryFooterButtons' + +import type { Mock } from 'vitest' + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('RecoveryFooterButtons', () => { + let props: React.ComponentProps + let mockPrimaryBtnOnClick: Mock + let mockSecondaryBtnOnClick: Mock + + beforeEach(() => { + mockPrimaryBtnOnClick = vi.fn() + mockSecondaryBtnOnClick = vi.fn() + props = { + isOnDevice: true, + primaryBtnOnClick: mockPrimaryBtnOnClick, + secondaryBtnOnClick: mockSecondaryBtnOnClick, + } + }) + + it('renders default button copy and click behavior', () => { + render(props) + + const primaryBtn = screen.getByRole('button', { name: 'Continue' }) + const secondaryBtn = screen.getByRole('button', { name: 'Go back' }) + + fireEvent.click(primaryBtn) + fireEvent.click(secondaryBtn) + + expect(mockPrimaryBtnOnClick).toHaveBeenCalled() + expect(mockSecondaryBtnOnClick).toHaveBeenCalled() + }) + + it('renders alternative button text when supplied', () => { + props = { ...props, primaryBtnTextOverride: 'MOCK_OVERRIDE_TEXT' } + render(props) + + screen.getByRole('button', { name: 'MOCK_OVERRIDE_TEXT' }) + }) +}) diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/shared/index.ts b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/shared/index.ts new file mode 100644 index 000000000000..82d6cdb71202 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/shared/index.ts @@ -0,0 +1 @@ +export { RecoveryFooterButtons } from './RecoveryFooterButtons' diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/BeforeBeginning.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/BeforeBeginning.test.tsx new file mode 100644 index 000000000000..5c290296d4e8 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/BeforeBeginning.test.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; +import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; +import { fireEvent, screen } from "@testing-library/react"; + +import { renderWithProviders } from "../../../__testing-utils__"; +import { i18n } from "../../../i18n"; +import { BeforeBeginning } from "../BeforeBeginning"; + +import { ERROR_KINDS, RECOVERY_MAP } from "../constants"; + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('BeforeBeginning', () => { + const { BEFORE_BEGINNING } = RECOVERY_MAP + let props: React.ComponentProps + let mockProceedNextStep: Mock + + beforeEach(() => { + mockProceedNextStep = vi.fn() + const mockRouteUpdateActions = { proceedNextStep: mockProceedNextStep } as any + + props = { + isOnDevice: true, + errorKind: ERROR_KINDS.GENERAL_ERROR, + onComplete: vi.fn(), + routeUpdateActions: mockRouteUpdateActions, + recoveryMap: { + route: BEFORE_BEGINNING.ROUTE, + step: BEFORE_BEGINNING.STEPS.RECOVERY_DESCRIPTION, + }, + } + }) + + it('renders appropriate copy and click behavior', () => { + render(props) + + screen.getByText('Before you begin') + screen.queryByText( + 'Recovery Mode provides you with guided and manual controls for handling errors at runtime.' + ) + + const primaryBtn = screen.getByRole('button', { name: 'View recovery options' }) + + fireEvent.click(primaryBtn) + + expect(mockProceedNextStep).toHaveBeenCalled() + }) +}) diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx new file mode 100644 index 000000000000..93ca813ba376 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx @@ -0,0 +1,114 @@ +import * as React from 'react' +import { vi, describe, it, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' + +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' +import { ErrorRecoveryContent } from '..' +import { ERROR_KINDS, RECOVERY_MAP } from '../constants' +import { BeforeBeginning } from '../BeforeBeginning' +import { SelectRecoveryOption, ResumeRun } from '../RecoveryOptions' +import { RecoveryInProgress } from '../RecoveryInProgress' + +import type { IRecoveryMap } from '../types' + +vi.mock('../BeforeBeginning') +vi.mock('../RecoveryOptions') +vi.mock('../RecoveryInProgress') + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('ErrorRecoveryContent', () => { + const { + OPTION_SELECTION, + BEFORE_BEGINNING, + RESUME, + ROBOT_RESUMING, + ROBOT_IN_MOTION, + } = RECOVERY_MAP + + let props: React.ComponentProps + const mockRecoveryMap: IRecoveryMap = { + route: OPTION_SELECTION.ROUTE, + step: OPTION_SELECTION.STEPS.SELECT, + } + + beforeEach(() => { + props = { + errorKind: ERROR_KINDS.GENERAL_ERROR, + routeUpdateActions: {} as any, + recoveryMap: mockRecoveryMap, + onComplete: vi.fn(), + isOnDevice: true, + } + + vi.mocked(SelectRecoveryOption).mockReturnValue( +
MOCK_SELECT_RECOVERY_OPTION
+ ) + vi.mocked(BeforeBeginning).mockReturnValue(
MOCK_BEFORE_BEGINNING
) + vi.mocked(ResumeRun).mockReturnValue(
MOCK_RESUME_RUN
) + vi.mocked(RecoveryInProgress).mockReturnValue(
MOCK_IN_PROGRESS
) + }) + + it(`returns SelectRecoveryOption when the route is ${OPTION_SELECTION.ROUTE}`, () => { + render(props) + + screen.getByText('MOCK_SELECT_RECOVERY_OPTION') + }) + + it(`returns BeforeBeginning when the route is ${BEFORE_BEGINNING.ROUTE}`, () => { + props = { + ...props, + recoveryMap: { + ...props.recoveryMap, + route: BEFORE_BEGINNING.ROUTE, + }, + } + render(props) + + screen.getByText('MOCK_BEFORE_BEGINNING') + }) + + it(`returns ResumeRun when the route is ${RESUME.ROUTE}`, () => { + props = { + ...props, + recoveryMap: { + ...props.recoveryMap, + route: RESUME.ROUTE, + }, + } + render(props) + + screen.getByText('MOCK_RESUME_RUN') + }) + + it(`returns RecoveryInProgressModal when the route is ${ROBOT_IN_MOTION.ROUTE}`, () => { + props = { + ...props, + recoveryMap: { + ...props.recoveryMap, + route: ROBOT_IN_MOTION.ROUTE, + }, + } + render(props) + + screen.getByText('MOCK_IN_PROGRESS') + }) + + it(`returns RecoveryInProgressModal when the route is ${ROBOT_RESUMING.ROUTE}`, () => { + props = { + ...props, + recoveryMap: { + ...props.recoveryMap, + route: ROBOT_IN_MOTION.ROUTE, + }, + } + render(props) + + screen.getByText('MOCK_IN_PROGRESS') + }) +}) diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryHeader.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryHeader.test.tsx new file mode 100644 index 000000000000..779c79e67d05 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryHeader.test.tsx @@ -0,0 +1,36 @@ +import * as React from 'react' +import { screen } from '@testing-library/react' + +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' +import { ErrorRecoveryHeader } from '../ErrorRecoveryHeader' +import { beforeEach, describe, it } from 'vitest' +import { ERROR_KINDS } from '../constants' + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('ErrorRecoveryHeader', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + errorKind: ERROR_KINDS.GENERAL_ERROR, + } + }) + + it('renders appropriate copy independent of errorKind', () => { + render(props) + + screen.getByText('Recovery Mode') + }) + + it('renders the appropriate header for a general error kind', () => { + render(props) + + screen.getByText('General error') + }) +}) diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoveryInProgress.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoveryInProgress.test.tsx new file mode 100644 index 000000000000..f039eb535037 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoveryInProgress.test.tsx @@ -0,0 +1,52 @@ +import * as React from 'react' +import { beforeEach, describe, it, vi } from 'vitest' +import { screen } from '@testing-library/react' + +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' +import { RecoveryInProgress } from '../RecoveryInProgress' + +import { ERROR_KINDS, RECOVERY_MAP } from '../constants' + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('RecoveryInProgress', () => { + const { ROBOT_IN_MOTION, ROBOT_RESUMING } = RECOVERY_MAP + let props: React.ComponentProps + + beforeEach(() => { + props = { + isOnDevice: true, + errorKind: ERROR_KINDS.GENERAL_ERROR, + onComplete: vi.fn(), + routeUpdateActions: vi.fn() as any, + recoveryMap: { + route: ROBOT_IN_MOTION.ROUTE, + step: ROBOT_IN_MOTION.STEPS.IN_MOTION, + }, + } + }) + + it(`renders appropriate copy when the route is ${ROBOT_IN_MOTION.ROUTE}`, () => { + render(props) + + screen.getByText('Stand back, robot is in motion') + }) + + it(`renders appropriate copy when the route is ${ROBOT_RESUMING.ROUTE}`, () => { + props = { + ...props, + recoveryMap: { + route: ROBOT_RESUMING.ROUTE, + step: ROBOT_RESUMING.STEPS.RESUMING, + }, + } + render(props) + + screen.getByText('Stand back, resuming current step') + }) +}) diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/utils.test.ts b/app/src/organisms/ErrorRecoveryFlows/__tests__/utils.test.ts new file mode 100644 index 000000000000..29da7e1a213c --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/utils.test.ts @@ -0,0 +1,137 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest' +import { renderHook } from '@testing-library/react' + +import { ERROR_KINDS, INVALID, RECOVERY_MAP } from '../constants' +import { + getErrorKind, + getRecoveryRouteNavigation, + useRouteUpdateActions, +} from '../utils' + +import type { Mock } from 'vitest' +import type { GetRouteUpdateActionsParams } from '../utils' + +describe('getErrorKind', () => { + it(`returns ${ERROR_KINDS.GENERAL_ERROR} if the errorType isn't handled explicitly`, () => { + const mockErrorType = 'NON_HANDLED_ERROR' + const result = getErrorKind(mockErrorType) + expect(result).toEqual(ERROR_KINDS.GENERAL_ERROR) + }) +}) + +describe('getRecoveryRouteNavigation', () => { + it(`getNextStep and getPrevStep return ${INVALID} if the recovery route does not contain multiple steps`, () => { + const { ROBOT_IN_MOTION } = RECOVERY_MAP + const { getNextStep, getPrevStep } = getRecoveryRouteNavigation( + ROBOT_IN_MOTION.ROUTE + ) + const nextStepResult = getNextStep(ROBOT_IN_MOTION.STEPS.IN_MOTION) + const prevStepResult = getPrevStep(ROBOT_IN_MOTION.STEPS.IN_MOTION) + + expect(nextStepResult).toEqual(INVALID) + expect(prevStepResult).toEqual(INVALID) + }) +}) + +describe('useRouteUpdateActions', () => { + const { OPTION_SELECTION } = RECOVERY_MAP + + let useRouteUpdateActionsParams: GetRouteUpdateActionsParams + let mockSetRecoveryMap: Mock + + beforeEach(() => { + mockSetRecoveryMap = vi.fn() + + useRouteUpdateActionsParams = { + recoveryMap: { + route: RECOVERY_MAP.RESUME.ROUTE, + step: RECOVERY_MAP.RESUME.STEPS.CONFIRM_RESUME, + }, + setRecoveryMap: mockSetRecoveryMap, + } + }) + + it(`routes to ${OPTION_SELECTION.ROUTE} ${OPTION_SELECTION.STEPS.SELECT} if proceedNextStep is called and the next step does not exist`, () => { + const { result } = renderHook(() => + useRouteUpdateActions(useRouteUpdateActionsParams) + ) + const { proceedNextStep } = result.current + + proceedNextStep() + expect(mockSetRecoveryMap).toHaveBeenCalledWith({ + route: OPTION_SELECTION.ROUTE, + step: OPTION_SELECTION.STEPS.SELECT, + }) + }) + + it(`routes to ${OPTION_SELECTION.ROUTE} ${OPTION_SELECTION.STEPS.SELECT} if proceedPrevStep is called and the previous step does not exist`, () => { + const { result } = renderHook(() => + useRouteUpdateActions(useRouteUpdateActionsParams) + ) + const { goBackPrevStep } = result.current + + goBackPrevStep() + expect(mockSetRecoveryMap).toHaveBeenCalledWith({ + route: OPTION_SELECTION.ROUTE, + step: OPTION_SELECTION.STEPS.SELECT, + }) + }) + + it('routes to the first step of the supplied route when proceedToRoute is called', () => { + const { result } = renderHook(() => + useRouteUpdateActions(useRouteUpdateActionsParams) + ) + const { proceedToRoute } = result.current + + proceedToRoute(RECOVERY_MAP.ROBOT_IN_MOTION.ROUTE) + expect(mockSetRecoveryMap).toHaveBeenCalledWith({ + route: RECOVERY_MAP.ROBOT_IN_MOTION.ROUTE, + step: RECOVERY_MAP.ROBOT_IN_MOTION.STEPS.IN_MOTION, + }) + }) + + it('routes to "robot in motion" when no other motion path is specified', () => { + const { result } = renderHook(() => + useRouteUpdateActions(useRouteUpdateActionsParams) + ) + const { setRobotInMotion } = result.current + + setRobotInMotion(true) + expect(mockSetRecoveryMap).toHaveBeenCalledWith({ + route: RECOVERY_MAP.ROBOT_IN_MOTION.ROUTE, + step: RECOVERY_MAP.ROBOT_IN_MOTION.STEPS.IN_MOTION, + }) + }) + + it('routes to alternative motion routes if specified', () => { + const { result } = renderHook(() => + useRouteUpdateActions(useRouteUpdateActionsParams) + ) + const { setRobotInMotion } = result.current + + setRobotInMotion(true, RECOVERY_MAP.ROBOT_RESUMING.ROUTE) + expect(mockSetRecoveryMap).toHaveBeenCalledWith({ + route: RECOVERY_MAP.ROBOT_RESUMING.ROUTE, + step: RECOVERY_MAP.ROBOT_RESUMING.STEPS.RESUMING, + }) + }) + + it('routes to the route prior to motion after the motion completes', () => { + const { result } = renderHook(() => + useRouteUpdateActions(useRouteUpdateActionsParams) + ) + const { setRobotInMotion } = result.current + + setRobotInMotion(true) + expect(mockSetRecoveryMap).toHaveBeenCalledWith({ + route: RECOVERY_MAP.ROBOT_IN_MOTION.ROUTE, + step: RECOVERY_MAP.ROBOT_IN_MOTION.STEPS.IN_MOTION, + }) + + setRobotInMotion(false) + expect(mockSetRecoveryMap).toHaveBeenCalledWith({ + route: RECOVERY_MAP.OPTION_SELECTION.ROUTE, + step: RECOVERY_MAP.OPTION_SELECTION.STEPS.SELECT, + }) + }) +}) diff --git a/app/src/organisms/ErrorRecoveryFlows/constants.ts b/app/src/organisms/ErrorRecoveryFlows/constants.ts new file mode 100644 index 000000000000..4a27ea62db88 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/constants.ts @@ -0,0 +1,114 @@ +import { css } from 'styled-components' + +import { SPACING, TYPOGRAPHY } from '@opentrons/components' + +import type { StepOrder } from './types' + +export const ERROR_KINDS = { + GENERAL_ERROR: 'GENERAL_ERROR', +} as const + +// Valid recovery routes and steps. +export const RECOVERY_MAP = { + BEFORE_BEGINNING: { + ROUTE: 'before-beginning', + STEPS: { + RECOVERY_DESCRIPTION: 'recovery-description', + }, + }, + CANCEL_RUN: { ROUTE: 'cancel-run', STEPS: {} }, + DROP_TIP: { ROUTE: 'drop-tip', STEPS: {} }, + IGNORE_AND_RESUME: { ROUTE: 'ignore-and-resume', STEPS: {} }, + REFILL_AND_RESUME: { ROUTE: 'refill-and-resume', STEPS: {} }, + RESUME: { + ROUTE: 'resume', + STEPS: { CONFIRM_RESUME: 'confirm-resume' }, + }, + ROBOT_IN_MOTION: { + ROUTE: 'robot-in-motion', + STEPS: { + IN_MOTION: 'in-motion', + }, + }, + ROBOT_RESUMING: { + ROUTE: 'robot-resuming', + STEPS: { + RESUMING: 'resuming', + }, + }, + OPTION_SELECTION: { + ROUTE: 'option-selection', + STEPS: { SELECT: 'select' }, + }, +} as const + +const { + BEFORE_BEGINNING, + OPTION_SELECTION, + RESUME, + ROBOT_RESUMING, + ROBOT_IN_MOTION, + DROP_TIP, + REFILL_AND_RESUME, + IGNORE_AND_RESUME, + CANCEL_RUN, +} = RECOVERY_MAP + +// The deterministic ordering of steps for a given route. +export const STEP_ORDER: StepOrder = { + [BEFORE_BEGINNING.ROUTE]: [BEFORE_BEGINNING.STEPS.RECOVERY_DESCRIPTION], + [OPTION_SELECTION.ROUTE]: [OPTION_SELECTION.STEPS.SELECT], + [RESUME.ROUTE]: [RESUME.STEPS.CONFIRM_RESUME], + [ROBOT_IN_MOTION.ROUTE]: [ROBOT_IN_MOTION.STEPS.IN_MOTION], + [ROBOT_RESUMING.ROUTE]: [ROBOT_RESUMING.STEPS.RESUMING], + [DROP_TIP.ROUTE]: [], + [REFILL_AND_RESUME.ROUTE]: [], + [IGNORE_AND_RESUME.ROUTE]: [], + [CANCEL_RUN.ROUTE]: [], +} + +export const INVALID = 'INVALID' as const + +/** + * Styling + */ + +// These colors are temp and will be removed as design does design things. +export const NON_DESIGN_SANCTIONED_COLOR_1 = '#56FF00' +export const NON_DESIGN_SANCTIONED_COLOR_2 = '#FF00EF' + +export const NON_SANCTIONED_RECOVERY_COLOR_STYLE_PRIMARY = css` + background-color: ${NON_DESIGN_SANCTIONED_COLOR_1}; + + &:active { + background-color: ${NON_DESIGN_SANCTIONED_COLOR_2}; + } + &:hover { + background-color: ${NON_DESIGN_SANCTIONED_COLOR_1}; + } + &:focus { + background-color: ${NON_DESIGN_SANCTIONED_COLOR_2}; + } +` + +export const NON_SANCTIONED_RECOVERY_COLOR_STYLE_SECONDARY = css` + background-color: ${NON_DESIGN_SANCTIONED_COLOR_2}; + + &:active { + background-color: ${NON_DESIGN_SANCTIONED_COLOR_2}; + } + &:hover { + background-color: ${NON_DESIGN_SANCTIONED_COLOR_1}; + } + &:focus { + background-color: ${NON_DESIGN_SANCTIONED_COLOR_2}; + } +` + +export const BODY_TEXT_STYLE = css` + ${TYPOGRAPHY.bodyTextRegular}; +` + +export const ODD_SECTION_TITLE_STYLE = css` + margin-bottom: ${SPACING.spacing16}; +` diff --git a/app/src/organisms/ErrorRecoveryFlows/index.tsx b/app/src/organisms/ErrorRecoveryFlows/index.tsx new file mode 100644 index 000000000000..2824b85fbe08 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/index.tsx @@ -0,0 +1,109 @@ +import * as React from 'react' +import { createPortal } from 'react-dom' +import { useSelector } from 'react-redux' + +import { + BORDERS, + COLORS, + DIRECTION_COLUMN, + Flex, + POSITION_ABSOLUTE, +} from '@opentrons/components' + +import { getIsOnDevice } from '../../redux/config' +import { getTopPortalEl } from '../../App/portal' +import { BeforeBeginning } from './BeforeBeginning' +import { SelectRecoveryOption, ResumeRun } from './RecoveryOptions' +import { ErrorRecoveryHeader } from './ErrorRecoveryHeader' +import { RecoveryInProgress } from './RecoveryInProgress' +import { getErrorKind, useRouteUpdateActions } from './utils' +import { RECOVERY_MAP } from './constants' + +import type { IRecoveryMap, RecoveryContentProps } from './types' + +interface ErrorRecoveryProps { + onComplete: () => void + errorType?: string +} +export function ErrorRecoveryFlows({ + onComplete, + errorType, +}: ErrorRecoveryProps): 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({ + route: RECOVERY_MAP.BEFORE_BEGINNING.ROUTE, + step: RECOVERY_MAP.BEFORE_BEGINNING.STEPS.RECOVERY_DESCRIPTION, + }) + + const errorKind = getErrorKind(errorType) + const isOnDevice = useSelector(getIsOnDevice) + + const routeUpdateActions = useRouteUpdateActions({ + recoveryMap, + setRecoveryMap, + }) + + return ( + + ) +} + +function ErrorRecoveryComponent(props: RecoveryContentProps): JSX.Element { + return createPortal( + + + + , + getTopPortalEl() + ) +} + +export function ErrorRecoveryContent(props: RecoveryContentProps): JSX.Element { + const buildBeforeBeginning = (): JSX.Element => { + return + } + + const buildSelectRecoveryOption = (): JSX.Element => { + return + } + + const buildRecoveryInProgress = (): JSX.Element => { + return + } + + const buildResumeRun = (): JSX.Element => { + return + } + + 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.ROBOT_IN_MOTION.ROUTE: + case RECOVERY_MAP.ROBOT_RESUMING.ROUTE: + return buildRecoveryInProgress() + default: + return buildSelectRecoveryOption() + } +} diff --git a/app/src/organisms/ErrorRecoveryFlows/types.ts b/app/src/organisms/ErrorRecoveryFlows/types.ts new file mode 100644 index 000000000000..51a3f4deb28c --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/types.ts @@ -0,0 +1,58 @@ +import type { ERROR_KINDS, RECOVERY_MAP, INVALID } from './constants' +import type { UseRouteUpdateActionsResult } from './utils' + +export type InvalidStep = typeof INVALID +export type RecoveryRoute = typeof RECOVERY_MAP[keyof typeof RECOVERY_MAP]['ROUTE'] +export type RobotMovingRoute = + | typeof RECOVERY_MAP['ROBOT_IN_MOTION']['ROUTE'] + | typeof RECOVERY_MAP['ROBOT_RESUMING']['ROUTE'] +export type ErrorKind = keyof typeof ERROR_KINDS + +interface RecoveryMapDetails { + ROUTE: string + STEPS: Record + STEP_ORDER: RouteStep +} + +export type RecoveryMap = Record +export type StepOrder = { + [K in RecoveryRoute]: RouteStep[] +} + +type RecoveryStep< + K extends keyof RecoveryMap +> = RecoveryMap[K]['STEPS'][keyof RecoveryMap[K]['STEPS']] + +type RobotInMotionStep = RecoveryStep<'ROBOT_IN_MOTION'> +type RobotResumingStep = RecoveryStep<'ROBOT_RESUMING'> +type BeforeBeginningStep = RecoveryStep<'BEFORE_BEGINNING'> +type CancelRunStep = RecoveryStep<'CANCEL_RUN'> +type DropTipStep = RecoveryStep<'DROP_TIP'> +type IgnoreAndResumeStep = RecoveryStep<'IGNORE_AND_RESUME'> +type RefillAndResumeStep = RecoveryStep<'REFILL_AND_RESUME'> +type ResumeStep = RecoveryStep<'RESUME'> +type OptionSelectionStep = RecoveryStep<'OPTION_SELECTION'> + +export type RouteStep = + | RobotInMotionStep + | RobotResumingStep + | BeforeBeginningStep + | CancelRunStep + | DropTipStep + | IgnoreAndResumeStep + | ResumeStep + | OptionSelectionStep + | RefillAndResumeStep + +export interface IRecoveryMap { + route: RecoveryRoute + step: RouteStep +} + +export interface RecoveryContentProps { + errorKind: ErrorKind + isOnDevice: boolean + recoveryMap: IRecoveryMap + routeUpdateActions: UseRouteUpdateActionsResult + onComplete: () => void +} diff --git a/app/src/organisms/ErrorRecoveryFlows/utils.ts b/app/src/organisms/ErrorRecoveryFlows/utils.ts new file mode 100644 index 000000000000..3a6c23e73dd3 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/utils.ts @@ -0,0 +1,168 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import last from 'lodash/last' +import head from 'lodash/head' + +import { RECOVERY_MAP, ERROR_KINDS, INVALID, STEP_ORDER } from './constants' + +import type { + RouteStep, + IRecoveryMap, + RecoveryRoute, + ErrorKind, + RobotMovingRoute, +} from './types' + +export function useErrorName(errorKind: ErrorKind): string { + const { t } = useTranslation('error_recovery') + + switch (errorKind) { + default: + return t('general_error') + } +} + +// The generalized error message shown to the user in select locations. +export function useErrorMessage(errorKind: ErrorKind): string { + const { t } = useTranslation('error_recovery') + + switch (errorKind) { + default: + return t('general_error_message') + } +} + +export function getErrorKind(errorType?: string): ErrorKind { + switch (errorType) { + default: + return ERROR_KINDS.GENERAL_ERROR + } +} + +export interface GetRouteUpdateActionsParams { + recoveryMap: IRecoveryMap + setRecoveryMap: (recoveryMap: IRecoveryMap) => void +} +export interface UseRouteUpdateActionsResult { + goBackPrevStep: () => void + proceedNextStep: () => void + proceedToRoute: (route: RecoveryRoute) => void + setRobotInMotion: (inMotion: boolean, movingRoute?: RobotMovingRoute) => void +} +// Utilities related to routing within the error recovery flows. +export function useRouteUpdateActions({ + recoveryMap, + setRecoveryMap, +}: GetRouteUpdateActionsParams): UseRouteUpdateActionsResult { + const { route: currentRoute, step: currentStep } = recoveryMap + const [stashedMap, setStashedMap] = React.useState(null) + const { OPTION_SELECTION, ROBOT_IN_MOTION } = RECOVERY_MAP + + // Redirect to the previous step for the current route if it exists, otherwise redirects to the option selection route. + const goBackPrevStep = React.useCallback((): void => { + const { getPrevStep } = getRecoveryRouteNavigation(currentRoute) + const updatedStep = getPrevStep(currentStep) + + if (updatedStep === INVALID) { + setRecoveryMap({ + route: OPTION_SELECTION.ROUTE, + step: OPTION_SELECTION.STEPS.SELECT, + }) + } else { + setRecoveryMap({ route: currentRoute, step: updatedStep }) + } + }, [currentStep, currentRoute]) + + // Redirect to the next step for the current route if it exists, otherwise redirects to the option selection route. + const proceedNextStep = React.useCallback((): void => { + const { getNextStep } = getRecoveryRouteNavigation(currentRoute) + const updatedStep = getNextStep(currentStep) + + if (updatedStep === INVALID) { + setRecoveryMap({ + route: OPTION_SELECTION.ROUTE, + step: OPTION_SELECTION.STEPS.SELECT, + }) + } else { + setRecoveryMap({ route: currentRoute, step: updatedStep }) + } + }, [currentStep, currentRoute]) + + // Redirect to a specific route. + const proceedToRoute = React.useCallback((route: RecoveryRoute): void => { + const newFlowSteps = STEP_ORDER[route] + + setRecoveryMap({ + route, + step: head(newFlowSteps) as RouteStep, + }) + }, []) + + // Stashes the current map then sets the current map to robot in motion. Restores the map after motion completes. + const setRobotInMotion = React.useCallback( + (inMotion: boolean, robotMovingRoute?: RobotMovingRoute): void => { + if (inMotion) { + if (stashedMap == null) { + setStashedMap({ route: currentRoute, step: currentStep }) + } + const route = robotMovingRoute ?? ROBOT_IN_MOTION.ROUTE + const step = + robotMovingRoute != null + ? (head(STEP_ORDER[robotMovingRoute]) as RouteStep) + : ROBOT_IN_MOTION.STEPS.IN_MOTION + + setRecoveryMap({ + route, + step, + }) + } else { + if (stashedMap != null) { + setRecoveryMap(stashedMap) + setStashedMap(null) + } else { + setRecoveryMap({ + route: OPTION_SELECTION.ROUTE, + step: OPTION_SELECTION.STEPS.SELECT, + }) + } + } + }, + [currentRoute, currentStep, stashedMap] + ) + + return { goBackPrevStep, proceedNextStep, proceedToRoute, setRobotInMotion } +} + +interface IRecoveryRouteNavigation { + getNextStep: (step: RouteStep) => RouteStep | typeof INVALID + getPrevStep: (step: RouteStep) => RouteStep | typeof INVALID +} +export function getRecoveryRouteNavigation( + route: RecoveryRoute +): IRecoveryRouteNavigation { + const getNextStep = (step: RouteStep): RouteStep => { + const routeSteps = STEP_ORDER[route] + const isStepFinalStep = step === last(routeSteps) + + if (isStepFinalStep) { + return INVALID + } else { + const stepIndex = routeSteps.indexOf(step) + return stepIndex !== -1 ? routeSteps[stepIndex + 1] : INVALID + } + } + + const getPrevStep = (step: RouteStep): RouteStep | typeof INVALID => { + const routeSteps = STEP_ORDER[route] + const isStepFirstStep = step === head(routeSteps) + + if (isStepFirstStep) { + return INVALID + } else { + const stepIndex = routeSteps.indexOf(step) + return stepIndex !== -1 ? routeSteps[stepIndex - 1] : INVALID + } + } + + return { getNextStep, getPrevStep } +} diff --git a/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunPausedSplash.tsx b/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunPausedSplash.tsx index 529e5b6653f1..57a0a3ad260b 100644 --- a/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunPausedSplash.tsx +++ b/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunPausedSplash.tsx @@ -18,13 +18,13 @@ import { } from '@opentrons/components' interface RunPausedSplashProps { - onClose: () => void + onClick: () => void errorType?: string protocolName?: string } export function RunPausedSplash({ - onClose, + onClick, errorType, protocolName, }: RunPausedSplashProps): JSX.Element { @@ -48,7 +48,7 @@ export function RunPausedSplash({ gridGap={SPACING.spacing40} padding={SPACING.spacing120} backgroundColor={COLORS.grey50} - onClick={onClose} + onClick={onClick} > diff --git a/app/src/organisms/OnDeviceDisplay/RunningProtocol/__tests__/RunPausedSplash.test.tsx b/app/src/organisms/OnDeviceDisplay/RunningProtocol/__tests__/RunPausedSplash.test.tsx index 6a7061346a4a..0f7455c154f4 100644 --- a/app/src/organisms/OnDeviceDisplay/RunningProtocol/__tests__/RunPausedSplash.test.tsx +++ b/app/src/organisms/OnDeviceDisplay/RunningProtocol/__tests__/RunPausedSplash.test.tsx @@ -28,7 +28,7 @@ describe('ConfirmCancelRunModal', () => { beforeEach(() => { props = { - onClose: mockOnClose, + onClick: mockOnClose, protocolName: MOCK_PROTOCOL_NAME, errorType: '', } diff --git a/app/src/pages/RunningProtocol/index.tsx b/app/src/pages/RunningProtocol/index.tsx index 3ebe3b1c0aba..d610d4f003f6 100644 --- a/app/src/pages/RunningProtocol/index.tsx +++ b/app/src/pages/RunningProtocol/index.tsx @@ -56,6 +56,7 @@ import { ConfirmCancelRunModal } from '../../organisms/OnDeviceDisplay/RunningPr import { RunPausedSplash } from '../../organisms/OnDeviceDisplay/RunningProtocol/RunPausedSplash' import { getLocalRobot } from '../../redux/discovery' import { OpenDoorAlertModal } from '../../organisms/OpenDoorAlertModal' +import { ErrorRecoveryFlows } from '../../organisms/ErrorRecoveryFlows' import type { OnDeviceRouteParams } from '../../App/types' @@ -106,6 +107,7 @@ export function RunningProtocol(): JSX.Element { refetchInterval: RUN_STATUS_REFETCH_INTERVAL, }) const [enableSplash, setEnableSplash] = React.useState(true) + const [showErrorRecovery, setShowErrorRecovery] = React.useState(false) const { startedAt, stoppedAt, completedAt } = useRunTimestamps(runId) const { data: runRecord } = useNotifyRunQuery(runId, { staleTime: Infinity }) const protocolId = runRecord?.data.protocolId ?? null @@ -166,13 +168,21 @@ export function RunningProtocol(): JSX.Element { } }, [lastRunCommand, interventionModalCommandKey]) + const handleCompleteRecovery = (): void => { + setShowErrorRecovery(false) + setEnableSplash(false) + } + return ( <> + {showErrorRecovery ? ( + + ) : null} {enableSplash && runStatus === RUN_STATUS_AWAITING_RECOVERY && enableRunNotes ? ( setEnableSplash(false)} + onClick={() => setShowErrorRecovery(true)} errorType={errorType} protocolName={protocolName} />