From 234723ad7cd7d48ebe97e00a64a64162b0157a3a Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Wed, 1 May 2024 17:01:30 -0400 Subject: [PATCH] WIP --- app/src/App/OnDeviceDisplayApp.tsx | 3 +- .../localization/en/error_recovery.json | 17 +- .../ErrorRecoveryFlows/BeforeBeginning.tsx | 60 +++++++ .../ErrorRecoveryHeader.tsx | 88 ++++++++++ .../RecoveryOptions/ResumeRun.tsx | 62 +++++++ .../RecoveryOptions/SelectRecoveryOption.tsx | 114 +++++++++++++ .../__tests__/ResumeRun.test.tsx | 55 ++++++ .../__tests__/SelectRecoveryOptions.test.tsx | 118 +++++++++++++ .../RecoveryOptions/index.ts | 2 + .../shared/RecoveryFooterButtons.tsx | 64 +++++++ .../__tests__/RecoveryFooterButtons.test.tsx | 49 ++++++ .../RecoveryOptions/shared/index.ts | 1 + .../__tests__/ErrorRecoveryFlows.test.tsx | 0 .../organisms/ErrorRecoveryFlows/constants.ts | 101 +++++++++++ .../organisms/ErrorRecoveryFlows/index.tsx | 107 ++++++++++++ app/src/organisms/ErrorRecoveryFlows/types.ts | 23 +++ app/src/organisms/ErrorRecoveryFlows/utils.ts | 160 ++++++++++++++++++ .../RunningProtocol/RunPausedSplash.tsx | 6 +- .../__tests__/RunPausedSplash.test.tsx | 2 +- app/src/pages/RunningProtocol/index.tsx | 12 +- 20 files changed, 1037 insertions(+), 7 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/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__/ErrorRecoveryFlows.test.tsx 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/App/OnDeviceDisplayApp.tsx b/app/src/App/OnDeviceDisplayApp.tsx index 1459ff5071f8..e186cb57e886 100644 --- a/app/src/App/OnDeviceDisplayApp.tsx +++ b/app/src/App/OnDeviceDisplayApp.tsx @@ -85,12 +85,13 @@ export const ON_DEVICE_DISPLAY_PATHS = [ '/welcome', ] as const +// TOME: THIS IS FOR DEV PURPOSES ONLY - DO NOT COMMIT THIS! function getPathComponent( path: typeof ON_DEVICE_DISPLAY_PATHS[number] ): JSX.Element { switch (path) { case '/dashboard': - return + return case '/deck-configuration': return case '/emergency-stop': diff --git a/app/src/assets/localization/en/error_recovery.json b/app/src/assets/localization/en/error_recovery.json index 7531853df163..84cfc0ada3aa 100644 --- a/app/src/assets/localization/en/error_recovery.json +++ b/app/src/assets/localization/en/error_recovery.json @@ -1,3 +1,18 @@ { - "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", + "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..a5c9f3ccb551 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryHeader.tsx @@ -0,0 +1,88 @@ +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' + +interface ErrorRecoveryHeaderProps { + errorType?: string +} +export function ErrorRecoveryHeader({ + errorType, +}: ErrorRecoveryHeaderProps): JSX.Element { + const { t } = useTranslation('error_recovery') + const errorName = useErrorName(errorType) + + 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/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..a96fa2c9927e --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx @@ -0,0 +1,114 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' + +import { + DIRECTION_COLUMN, + Flex, + JUSTIFY_SPACE_BETWEEN, + SPACING, + StyledText, +} from '@opentrons/components' + +import { getErrorKind } from '../utils' +import { + ERROR_KINDS, + RECOVERY_ROUTES, + GENERAL_ERROR_OPTIONS, + ODD_SECTION_TITLE_STYLE, +} from '../constants' +import { RadioButton } from '../../../atoms/buttons' + +import type { RecoveryContentProps, RecoveryRoute } from '../types' +import { RecoveryFooterButtons } from './shared' + +// 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, + errorType, + routeUpdateActions, +}: RecoveryContentProps): JSX.Element | null { + const { t } = useTranslation('error_recovery') + const { proceedToRoute } = routeUpdateActions + const [selectedRoute, setSelectedRoute] = React.useState() + + if (isOnDevice) { + return ( + + + {t('how_do_you_want_to_proceed')} + + + + + + proceedToRoute(selectedRoute as RecoveryRoute) + } + secondaryBtnOnClick={() => + proceedToRoute(RECOVERY_ROUTES.BEFORE_BEGINNING) + } + /> + + ) + } else { + return null + } +} + +interface RecoveryOptionsProps { + setSelectedRoute: (route: RecoveryRoute) => void + selectedRoute?: RecoveryRoute + errorType?: string +} +export function RecoveryOptions({ + errorType, + selectedRoute, + setSelectedRoute, +}: RecoveryOptionsProps): JSX.Element[] { + const validRecoveryOptions = getRecoveryOptions(errorType) + const { t } = useTranslation('error_recovery') + + return validRecoveryOptions.map((recoveryOption: RecoveryRoute) => { + const buildOptionName = (): string => { + switch (recoveryOption) { + case RECOVERY_ROUTES.RESUME: + return t('resume') + case RECOVERY_ROUTES.CANCEL_RUN: + return t('cancel_run') + default: + return 'INVALID_OPTION' + } + } + const optionName = buildOptionName() + + return ( + setSelectedRoute(recoveryOption)} + isSelected={recoveryOption === selectedRoute} + /> + ) + }) +} + +export function getRecoveryOptions(errorType?: string): RecoveryRoute[] { + const errorKind = getErrorKind(errorType) + + switch (errorKind) { + case ERROR_KINDS.GENERAL_ERROR: + return GENERAL_ERROR_OPTIONS + } +} 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..4e5f64215e93 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ResumeRun.test.tsx @@ -0,0 +1,55 @@ +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_ROUTES, CONTENT } from '../../constants' + +import type { Mock } from 'vitest' + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('RecoveryFooterButtons', () => { + 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, + errorType: 'MOCK_GENERAL_ERROR', + onComplete: mockOnComplete, + routeUpdateActions: mockRouteUpdateActions, + recoveryMap: { + route: RECOVERY_ROUTES.RESUME, + content: CONTENT.RESUME.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..7e80aecfb4b4 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SelectRecoveryOptions.test.tsx @@ -0,0 +1,118 @@ +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, +} from '../SelectRecoveryOption' +import { RECOVERY_ROUTES, CONTENT } 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', () => { + let props: React.ComponentProps + let mockProceedToRoute: Mock + + beforeEach(() => { + mockProceedToRoute = vi.fn() + const mockRouteUpdateActions = { proceedToRoute: mockProceedToRoute } as any + + props = { + isOnDevice: true, + errorType: 'MOCK_GENERAL_ERROR', + onComplete: vi.fn(), + routeUpdateActions: mockRouteUpdateActions, + recoveryMap: { + route: RECOVERY_ROUTES.RESUME, + content: CONTENT.RESUME.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(RECOVERY_ROUTES.RESUME) + + renderSelectRecoveryOption(props) + + fireEvent.click(secondaryBtn) + expect(mockProceedToRoute).toHaveBeenCalledWith( + RECOVERY_ROUTES.BEFORE_BEGINNING + ) + }) +}) + +describe('RecoveryFooterButtons', () => { + 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, + errorType: 'MOCK_GENERAL_ERROR', + onComplete: mockOnComplete, + routeUpdateActions: mockRouteUpdateActions, + recoveryMap: { + route: RECOVERY_ROUTES.RESUME, + content: CONTENT.RESUME.CONFIRM_RESUME, + }, + } + }) +}) + +describe('RecoveryFooterButtons', () => { + 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, + errorType: 'MOCK_GENERAL_ERROR', + onComplete: mockOnComplete, + routeUpdateActions: mockRouteUpdateActions, + recoveryMap: { + route: RECOVERY_ROUTES.RESUME, + content: CONTENT.RESUME.CONFIRM_RESUME, + }, + } + }) +}) 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..ef29165cf28e --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/shared/__tests__/RecoveryFooterButtons.test.tsx @@ -0,0 +1,49 @@ +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__/ErrorRecoveryFlows.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/app/src/organisms/ErrorRecoveryFlows/constants.ts b/app/src/organisms/ErrorRecoveryFlows/constants.ts new file mode 100644 index 000000000000..62c845f4b3f2 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/constants.ts @@ -0,0 +1,101 @@ +import { css } from 'styled-components' + +import { SPACING, TYPOGRAPHY } from '@opentrons/components' + +import type { RecoveryRoute } from './types' + +/** + * Error Kinds + */ + +export const ERROR_KINDS = { + GENERAL_ERROR: 'GENERAL_ERROR', +} as const + +/** + * Recovery Routes and Steps + */ + +// A recovery route represents a recovery option, generates side effects, and contains a defined number of deterministic steps. +export const RECOVERY_ROUTES = { + BEFORE_BEGINNING: 'BEFORE_BEGINNING', + CANCEL_RUN: 'CANCEL_RUN', + DROP_TIP: 'DROP_TIP', + IGNORE_AND_RESUME: 'IGNORE_AND_RESUME', + REFILL_AND_RESUME: 'REFILL_AND_RESUME', + RESUME: 'RESUME', + ROBOT_IN_MOTION: 'ROBOT_IN_MOTION', + OPTION_SELECTION: 'OPTION_SELECTION', +} as const + +// Valid content for a given route. +export const CONTENT = { + OPTION_SELECTION: { + SELECT: 'SELECT', + }, + BEFORE_BEGINNING: { + RECOVERY_DESCRIPTION: 'RECOVERY_DESCRIPTION', + }, + RESUME: { + CONFIRM_RESUME: 'CONFIRM_RESUME', + }, + ROBOT_IN_MOTION: { + IN_MOTION: 'IN_MOTION', + RESUMING: 'RESUMING', + }, + INVALID: 'INVALID', +} as const + +/** + * Recovery Options + */ + +// Valid recovery options given an errorKind. +export const GENERAL_ERROR_OPTIONS: RecoveryRoute[] = [ + RECOVERY_ROUTES.RESUME, + RECOVERY_ROUTES.CANCEL_RUN, +] + +/** + * 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..7de69d3a992a --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/index.tsx @@ -0,0 +1,107 @@ +import * as React from 'react' +import { createPortal } from 'react-dom' +import { useSelector } from 'react-redux' +import { useTranslation } from 'react-i18next' + +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 { InProgressModal } from '../../molecules/InProgressModal/InProgressModal' +import { useRouteUpdateActions } from './utils' +import { RECOVERY_ROUTES, CONTENT } from './constants' + +import type { RecoveryMap, RecoveryContentProps } from './types' + +interface ErrorRecoveryProps { + onComplete: () => void + errorType?: string +} +export function ErrorRecoveryFlows(props: ErrorRecoveryProps): JSX.Element { + const isOnDevice = useSelector(getIsOnDevice) + + const [recoveryMap, setRecoveryMap] = React.useState({ + route: RECOVERY_ROUTES.BEFORE_BEGINNING, + content: CONTENT.BEFORE_BEGINNING.RECOVERY_DESCRIPTION, + }) + + const routeUpdateActions = useRouteUpdateActions({ + recoveryMap, + setRecoveryMap, + }) + + return ( + + ) +} + +function ErrorRecoveryComponent(props: RecoveryContentProps): JSX.Element { + const ModalContent = useBuildErrorRecoveryContent(props) + + return createPortal( + + + {ModalContent} + , + getTopPortalEl() + ) +} + +function useBuildErrorRecoveryContent( + props: RecoveryContentProps +): JSX.Element { + const { t } = useTranslation('error_recovery') + const { route: currentRoute } = props.recoveryMap + + const buildBeforeBeginning = (): JSX.Element => { + return + } + + const buildSelectRecoveryOption = (): JSX.Element => { + return + } + + const buildRobotInMotion = (): JSX.Element => { + return + } + + const buildResumeRun = (): JSX.Element => { + return + } + + switch (currentRoute) { + case RECOVERY_ROUTES.BEFORE_BEGINNING: + return buildBeforeBeginning() + case RECOVERY_ROUTES.OPTION_SELECTION: + return buildSelectRecoveryOption() + case RECOVERY_ROUTES.ROBOT_IN_MOTION: + return buildRobotInMotion() + case RECOVERY_ROUTES.RESUME: + return buildResumeRun() + 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..c7d29d5d0ff0 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/types.ts @@ -0,0 +1,23 @@ +import type { RECOVERY_ROUTES, CONTENT } from './constants' +import type { UseRouteUpdateActionsResult } from './utils' + +export type RecoveryRoute = keyof typeof RECOVERY_ROUTES + +type ValuesOfRoutes = T extends object ? T[keyof T] : never +type AllowedRouteSteps = ValuesOfRoutes +type RouteType = typeof CONTENT + +export type RouteStep = AllowedRouteSteps | typeof CONTENT['INVALID'] + +export interface RecoveryMap { + route: RecoveryRoute + content: RouteStep +} + +export interface RecoveryContentProps { + isOnDevice: boolean + recoveryMap: RecoveryMap + routeUpdateActions: UseRouteUpdateActionsResult + onComplete: () => void + errorType?: string +} diff --git a/app/src/organisms/ErrorRecoveryFlows/utils.ts b/app/src/organisms/ErrorRecoveryFlows/utils.ts new file mode 100644 index 000000000000..3bc36328bcaa --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/utils.ts @@ -0,0 +1,160 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import last from 'lodash/last' +import head from 'lodash/head' + +import { RECOVERY_ROUTES, CONTENT, ERROR_KINDS } from './constants' + +import type { RouteStep, RecoveryMap, RecoveryRoute } from './types' + +export function useErrorName(errorType?: string): string { + const { t } = useTranslation('error_recovery') + const errorKind = getErrorKind(errorType) + + switch (errorKind) { + default: + return t('general_error') + } +} + +// The generalized error message shown to the user in select locations. +export function useErrorMessage(errorType?: string): string { + const { t } = useTranslation('error_recovery') + const errorKind = getErrorKind(errorType) + + switch (errorKind) { + default: + return t('general_error_message') + } +} + +export function getErrorKind(errorType?: string): keyof typeof ERROR_KINDS { + switch (errorType) { + default: + return ERROR_KINDS.GENERAL_ERROR + } +} + +interface GetRouteUpdateActionsParams { + recoveryMap: RecoveryMap + setRecoveryMap: (recoveryMap: RecoveryMap) => void +} +export interface UseRouteUpdateActionsResult { + goBackPrevStep: () => void + proceedNextStep: () => void + proceedToRoute: (route: RecoveryRoute) => void + setRobotInMotion: (inMotion: boolean) => void +} +// Utilities related to routing within the error recovery flows. +export function useRouteUpdateActions({ + recoveryMap, + setRecoveryMap, +}: GetRouteUpdateActionsParams): UseRouteUpdateActionsResult { + const { route: currentRoute, content: currentStep } = recoveryMap + const [stashedMap, setStashedMap] = React.useState(null) + + // 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 === CONTENT.INVALID) { + setRecoveryMap({ + route: RECOVERY_ROUTES.OPTION_SELECTION, + content: CONTENT.OPTION_SELECTION.SELECT, + }) + } else { + setRecoveryMap({ route: currentRoute, content: 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 === CONTENT.INVALID) { + setRecoveryMap({ + route: RECOVERY_ROUTES.OPTION_SELECTION, + content: CONTENT.OPTION_SELECTION.SELECT, + }) + } else { + setRecoveryMap({ route: currentRoute, content: updatedStep }) + } + }, [currentStep, currentRoute]) + + // Redirect to a specific route. + const proceedToRoute = React.useCallback((route: RecoveryRoute): void => { + const newFlowSteps = getRouteSteps(route) + setRecoveryMap({ + route, + content: 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, + motionStep?: keyof typeof CONTENT.ROBOT_IN_MOTION + ): void => { + if (stashedMap != null) { + if (inMotion) { + setStashedMap({ route: currentRoute, content: currentStep }) + setRecoveryMap({ + route: RECOVERY_ROUTES.ROBOT_IN_MOTION, + content: motionStep ?? CONTENT.ROBOT_IN_MOTION.IN_MOTION, + }) + } else { + setRecoveryMap(stashedMap) + setStashedMap(null) + } + } + }, + [currentRoute, currentStep, stashedMap] + ) + + return { goBackPrevStep, proceedNextStep, proceedToRoute, setRobotInMotion } +} + +interface IRecoveryRouteNavigation { + getNextStep: (step: RouteStep) => RouteStep + getPrevStep: (step: RouteStep) => RouteStep +} +function getRecoveryRouteNavigation( + route: RecoveryRoute +): IRecoveryRouteNavigation { + const getNextStep = (step: RouteStep): RouteStep => { + const routeSteps = getRouteSteps(route) + const isStepFinalStep = step === last(routeSteps) + + if (isStepFinalStep || routeSteps.length <= 0) { + return CONTENT.INVALID + } else { + const stepIndex = routeSteps.indexOf(step) + return stepIndex !== -1 ? routeSteps[stepIndex + 1] : CONTENT.INVALID + } + } + + const getPrevStep = (step: RouteStep): RouteStep => { + const routeSteps = getRouteSteps(route) + const isStepFirstStep = step === head(routeSteps) + + if (isStepFirstStep || routeSteps.length <= 0) { + return CONTENT.INVALID + } else { + const stepIndex = routeSteps.indexOf(step) + return stepIndex !== -1 ? routeSteps[stepIndex - 1] : CONTENT.INVALID + } + } + + return { getNextStep, getPrevStep } +} + +// Returns the deterministic series of steps within a given route if applicable. +function getRouteSteps(route: RecoveryRoute): RouteStep[] { + switch (route) { + default: + return [] + } +} 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} />