From 28254f8396634b7095d532845be3c1491f31a288 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Wed, 24 Jul 2024 12:22:03 -0400 Subject: [PATCH] feat(app): add ErrorRecovery ErrorDetails desktop support --- .../localization/en/error_recovery.json | 5 +- app/src/molecules/InterventionModal/index.tsx | 2 + .../ErrorRecoveryWizard.tsx | 18 +- .../RecoveryOptions/ManageTips.tsx | 1 - .../ErrorRecoveryFlows/RunPausedSplash.tsx | 1 - .../organisms/ErrorRecoveryFlows/constants.ts | 2 +- .../shared/ErrorDetailsModal.tsx | 159 +++++++++++++++--- .../shared/RecoveryInterventionModal.tsx | 3 +- .../__tests__/ErrorDetailsModal.test.tsx | 59 +++---- .../shared/__tests__/StepInfo.test.tsx | 2 +- app/src/organisms/ErrorRecoveryFlows/types.ts | 2 + 11 files changed, 189 insertions(+), 65 deletions(-) diff --git a/app/src/assets/localization/en/error_recovery.json b/app/src/assets/localization/en/error_recovery.json index c139f21acd2..035c51fbb01 100644 --- a/app/src/assets/localization/en/error_recovery.json +++ b/app/src/assets/localization/en/error_recovery.json @@ -16,6 +16,7 @@ "continue_run_now": "Continue run now", "continue_to_drop_tip": "Continue to drop tip", "error": "Error", + "error_details": "Error details", "error_on_robot": "Error on {{robot}}", "failed_dispense_step_not_completed": "The failed dispense step will not be completed. The run will continue from the next step.Close the robot door before proceeding.", "failed_step": "Failed step", @@ -40,6 +41,7 @@ "recovery_action_failed": "{{action}} failed", "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.", + "remove_tips_from_pipette": "Remove tips from {{mount}} pipette before canceling the run?", "replace_tips_and_select_location": "It's best to replace tips and select the last location used for tip pickup.", "replace_used_tips_in_rack_location": "Replace used tips in rack location {{location}} in slot {{slot}}", "replace_with_new_tip_rack": "Replace with new tip rack in slot {{slot}}", @@ -72,6 +74,5 @@ "tip_not_detected": "Tip not detected", "view_error_details": "View error details", "view_recovery_options": "View recovery options", - "you_can_still_drop_tips": "You can still drop the attached tips before proceeding to tip selection.", - "remove_tips_from_pipette": "Remove tips from {{mount}} pipette before canceling the run?" + "you_can_still_drop_tips": "You can still drop the attached tips before proceeding to tip selection." } diff --git a/app/src/molecules/InterventionModal/index.tsx b/app/src/molecules/InterventionModal/index.tsx index 3faa3b34f2c..aec8c9fea22 100644 --- a/app/src/molecules/InterventionModal/index.tsx +++ b/app/src/molecules/InterventionModal/index.tsx @@ -179,6 +179,8 @@ const ICON_STYLE = css` width: ${SPACING.spacing16}; height: ${SPACING.spacing16}; margin: ${SPACING.spacing4}; + cursor: pointer; + @media (${RESPONSIVENESS.touchscreenMediaQuerySpecs}) { width: ${SPACING.spacing32}; height: ${SPACING.spacing32}; diff --git a/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx b/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx index 73b7a1a5ea1..7f02436c2db 100644 --- a/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' +import { css } from 'styled-components' import { StyledText } from '@opentrons/components' @@ -101,7 +102,13 @@ export function ErrorRecoveryComponent( } const buildIconHeading = (): JSX.Element => ( - + {t('view_error_details')} ) @@ -119,6 +126,7 @@ export function ErrorRecoveryComponent( !isDoorOpen && route === RECOVERY_MAP.DROP_TIP_FLOWS.ROUTE && step !== RECOVERY_MAP.DROP_TIP_FLOWS.STEPS.BEGIN_REMOVAL + const desktopType = isLargeDesktopStyle ? 'desktop-large' : 'desktop-small' return ( {showModal ? ( - + ) : null} {buildInterventionContent()} diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx index 9c52ea28217..dfddd49aeba 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx @@ -11,7 +11,6 @@ import { StyledText, RESPONSIVENESS, } from '@opentrons/components' -import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { RadioButton } from '../../../atoms/buttons' import { diff --git a/app/src/organisms/ErrorRecoveryFlows/RunPausedSplash.tsx b/app/src/organisms/ErrorRecoveryFlows/RunPausedSplash.tsx index 69b58141c92..814491d702b 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RunPausedSplash.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RunPausedSplash.tsx @@ -20,7 +20,6 @@ import { StyledText, JUSTIFY_END, PrimaryButton, - ALIGN_FLEX_END, SecondaryButton, } from '@opentrons/components' diff --git a/app/src/organisms/ErrorRecoveryFlows/constants.ts b/app/src/organisms/ErrorRecoveryFlows/constants.ts index ddcb1c28086..d61805d1777 100644 --- a/app/src/organisms/ErrorRecoveryFlows/constants.ts +++ b/app/src/organisms/ErrorRecoveryFlows/constants.ts @@ -1,6 +1,6 @@ import { css } from 'styled-components' -import { SPACING, TYPOGRAPHY, RESPONSIVENESS } from '@opentrons/components' +import { SPACING, RESPONSIVENESS } from '@opentrons/components' import type { StepOrder } from './types' diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx index de4829d937f..f1921b83d02 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx @@ -1,9 +1,11 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import { createPortal } from 'react-dom' +import { css } from 'styled-components' import { Flex, + StyledText, SPACING, COLORS, BORDERS, @@ -12,16 +14,22 @@ import { import { useErrorName } from '../hooks' import { Modal } from '../../../molecules/Modal' -import { getTopPortalEl } from '../../../App/portal' +import { getModalPortalEl, getTopPortalEl } from '../../../App/portal' import { ERROR_KINDS } from '../constants' import { InlineNotification } from '../../../atoms/InlineNotification' import { StepInfo } from './StepInfo' import { getErrorKind } from '../utils' +import { + LegacyModalShell, + LegacyModalHeader, +} from '../../../molecules/LegacyModal' import type { RobotType } from '@opentrons/shared-data' +import type { IconProps } from '@opentrons/components' import type { ModalHeaderBaseProps } from '../../../molecules/Modal/types' import type { ERUtilsResults } from '../hooks' import type { ErrorRecoveryFlowsProps } from '..' +import type { DesktopSizeType } from '../types' export function useErrorDetailsModal(): { showModal: boolean @@ -41,6 +49,7 @@ type ErrorDetailsModalProps = ErrorRecoveryFlowsProps & toggleModal: () => void isOnDevice: boolean robotType: RobotType + desktopType: DesktopSizeType } export function ErrorDetailsModal(props: ErrorDetailsModalProps): JSX.Element { @@ -64,7 +73,101 @@ export function ErrorDetailsModal(props: ErrorDetailsModalProps): JSX.Element { hasExitIcon: true, } - return createPortal( + const buildModal = (): JSX.Element => { + if (isOnDevice) { + return createPortal( + + {getIsOverpressureErrorKind() ? : null} + , + getTopPortalEl() + ) + } else { + return createPortal( + + {getIsOverpressureErrorKind() ? : null} + , + getModalPortalEl() + ) + } + } + + return buildModal() +} + +type ErrorDetailsModalType = ErrorDetailsModalProps & { + children: React.ReactNode + modalHeader: ModalHeaderBaseProps + toggleModal: () => void + desktopType: DesktopSizeType +} + +export function ErrorDetailsModalDesktop( + props: ErrorDetailsModalType +): JSX.Element { + const { children, modalHeader, toggleModal, desktopType } = props + const { t } = useTranslation('error_recovery') + + const buildIcon = (): IconProps => { + return { + name: 'information', + color: COLORS.grey60, + size: SPACING.spacing20, + marginRight: SPACING.spacing8, + } + } + + const buildHeader = (): JSX.Element => { + return ( + + ) + } + + return ( + + + + {modalHeader.title} + + {children} + + + + + + ) +} + +export function ErrorDetailsModalODD( + props: ErrorDetailsModalType +): JSX.Element { + const { children, modalHeader, toggleModal } = props + + return ( - {getIsOverpressureErrorKind() ? ( - - ) : null} + {children} - + - , - getTopPortalEl() + ) } -export function OverpressureBanner(props: { - isOnDevice: boolean -}): JSX.Element | null { +export function OverpressureBanner(): JSX.Element | null { const { t } = useTranslation('error_recovery') - if (props.isOnDevice) { - return ( - - ) - } else { - return null - } + return ( + + ) } + +// TODO(jh, 07-24-24): Using shared height/width constants for intervention modal sizing and the ErrorDetailsModal sizing +// would be ideal. +const DESKTOP_STEP_INFO_STYLE = css` + background-color: ${COLORS.grey30}; + grid-gap: ${SPACING.spacing10}; + border-radius: ${BORDERS.borderRadius4}; + padding: ${SPACING.spacing6} ${SPACING.spacing24} ${SPACING.spacing6} + ${SPACING.spacing12}; +` + +const DESKTOP_MODAL_STYLE_BASE = css` + width: 47rem; +` + +const DESKTOP_MODAL_STYLE_SMALL = css` + ${DESKTOP_MODAL_STYLE_BASE} + height: 26rem; +` +const DESKTOP_MODAL_STYLE_LARGE = css` + ${DESKTOP_MODAL_STYLE_BASE} + height: 31rem; +` diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryInterventionModal.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryInterventionModal.tsx index 00a853ee99a..e044d46054f 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryInterventionModal.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryInterventionModal.tsx @@ -8,13 +8,14 @@ import { InterventionModal } from '../../../molecules/InterventionModal' import { getModalPortalEl, getTopPortalEl } from '../../../App/portal' import type { ModalType } from '../../../molecules/InterventionModal' +import type { DesktopSizeType } from '../types' export type RecoveryInterventionModalProps = Omit< React.ComponentProps, 'type' > & { /* If on desktop, specifies the hard-coded dimensions height of the modal. */ - desktopType: 'desktop-small' | 'desktop-large' + desktopType: DesktopSizeType isOnDevice: boolean } diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/ErrorDetailsModal.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/ErrorDetailsModal.test.tsx index 3eb590f1a35..b63464b4382 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/ErrorDetailsModal.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/ErrorDetailsModal.test.tsx @@ -44,27 +44,6 @@ describe('useErrorDetailsModal', () => { }) }) -describe('ErrorDetailsModal', () => { - let props: React.ComponentProps - - beforeEach(() => { - props = { - ...mockRecoveryContentProps, - toggleModal: vi.fn(), - robotType: 'OT-3 Standard', - } - - vi.mocked(StepInfo).mockReturnValue(
MOCK_STEP_INFO
) - }) - - it('renders ErrorDetailsModal', () => { - renderWithProviders(, { - i18nInstance: i18n, - }) - expect(screen.getByText('MOCK_STEP_INFO')).toBeInTheDocument() - }) -}) - const render = (props: React.ComponentProps) => { return renderWithProviders(, { i18nInstance: i18n, @@ -79,6 +58,7 @@ describe('ErrorDetailsModal', () => { ...mockRecoveryContentProps, toggleModal: vi.fn(), robotType: 'OT-3 Standard', + desktopType: 'desktop-small', } vi.mocked(StepInfo).mockReturnValue(
MOCK_STEP_INFO
) @@ -87,7 +67,9 @@ describe('ErrorDetailsModal', () => { ) }) - it('renders the modal with the correct content', () => { + const IS_ODD = [true, false] + + it('renders the ODD modal with the correct content', () => { render(props) expect(vi.mocked(Modal)).toHaveBeenCalledWith( expect.objectContaining({ @@ -102,21 +84,30 @@ describe('ErrorDetailsModal', () => { expect(screen.getByText('MOCK_STEP_INFO')).toBeInTheDocument() }) - it('renders the OverpressureBanner when the error kind is an overpressure error', () => { - props.failedCommand = { - ...props.failedCommand, - commandType: 'aspirate', - error: { isDefined: true, errorType: 'overpressure' }, - } as any - render(props) + it('renders the desktop modal with the correct content', () => { + render({ ...props, isOnDevice: false }) - screen.getByText('MOCK_INLINE_NOTIFICATION') + screen.getByText('MOCK_STEP_INFO') + screen.getByText('Error details') }) - it('does not render the OverpressureBanner when the error kind is not an overpressure error', () => { - render(props) + IS_ODD.forEach(isOnDevice => { + it('renders the OverpressureBanner when the error kind is an overpressure error', () => { + props.failedCommand = { + ...props.failedCommand, + commandType: 'aspirate', + error: { isDefined: true, errorType: 'overpressure' }, + } as any + render({ ...props, isOnDevice }) + + screen.getByText('MOCK_INLINE_NOTIFICATION') + }) + + it('does not render the OverpressureBanner when the error kind is not an overpressure error', () => { + render({ ...props, isOnDevice }) - expect(screen.queryByText('MOCK_INLINE_NOTIFICATION')).toBeNull() + expect(screen.queryByText('MOCK_INLINE_NOTIFICATION')).toBeNull() + }) }) }) @@ -128,7 +119,7 @@ describe('OverpressureBanner', () => { }) it('renders the InlineNotification', () => { - renderWithProviders(, { + renderWithProviders(, { i18nInstance: i18n, }) expect(vi.mocked(InlineNotification)).toHaveBeenCalledWith( diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/StepInfo.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/StepInfo.test.tsx index 9c62c75b913..9396fcf8f7d 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/StepInfo.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/StepInfo.test.tsx @@ -25,7 +25,7 @@ describe('StepInfo', () => { ...mockRecoveryContentProps, protocolAnalysis: { commands: [mockFailedCommand] } as any, }, - desktopStyle: 'h4', + desktopStyle: 'bodyDefaultRegular', stepCounts: { currentStepNumber: 5, totalStepCount: 10, diff --git a/app/src/organisms/ErrorRecoveryFlows/types.ts b/app/src/organisms/ErrorRecoveryFlows/types.ts index c1f0ea49329..747000f2dbb 100644 --- a/app/src/organisms/ErrorRecoveryFlows/types.ts +++ b/app/src/organisms/ErrorRecoveryFlows/types.ts @@ -63,3 +63,5 @@ export type RecoveryContentProps = ErrorRecoveryWizardProps & { errorKind: ErrorKind isOnDevice: boolean } + +export type DesktopSizeType = 'desktop-small' | 'desktop-large'