diff --git a/app/src/organisms/Devices/ProtocolRun/ConfirmMissingStepsModal.tsx b/app/src/organisms/Devices/ProtocolRun/ConfirmMissingStepsModal.tsx new file mode 100644 index 00000000000..af480cdbc65 --- /dev/null +++ b/app/src/organisms/Devices/ProtocolRun/ConfirmMissingStepsModal.tsx @@ -0,0 +1,67 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { + ALIGN_CENTER, + DIRECTION_COLUMN, + DIRECTION_ROW, + Flex, + JUSTIFY_FLEX_END, + Link, + PrimaryButton, + SecondaryButton, + SPACING, + LegacyStyledText, + TEXT_ALIGN_CENTER, + TYPOGRAPHY, +} from '@opentrons/components' +import { LegacyModal } from '../../../molecules/LegacyModal' + +interface ConfirmMissingStepsModalProps { + onCloseClick: () => void + onConfirmClick: () => void + missingSteps: string[] +} +export const ConfirmMissingStepsModal = ( + props: ConfirmMissingStepsModalProps +): JSX.Element | null => { + const { missingSteps, onCloseClick, onConfirmClick } = props + const { t, i18n } = useTranslation(['protocol_setup', 'shared']) + + const confirmAttached = (): void => { + onConfirmClick() + onCloseClick() + } + + return ( + + + + {t('you_havent_confirmed', { + missingSteps: new Intl.ListFormat('en', { + style: 'short', + type: 'conjunction', + }).format(missingSteps.map(step => t(step))), + })} + + + + + {i18n.format(t('shared:go_back'), 'capitalize')} + + + {t('start_run')} + + + + ) +} diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx index 6a704c96699..5d9821cc5a6 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx @@ -77,6 +77,7 @@ import { } from '../../../organisms/RunTimeControl/hooks' import { useIsHeaterShakerInProtocol } from '../../ModuleCard/hooks' import { ConfirmAttachmentModal } from '../../ModuleCard/ConfirmAttachmentModal' +import { ConfirmMissingStepsModal } from './ConfirmMissingStepsModal' import { useProtocolDetailsForRun, useProtocolAnalysisErrors, @@ -132,6 +133,7 @@ interface ProtocolRunHeaderProps { robotName: string runId: string makeHandleJumpToStep: (index: number) => () => void + missingSetupSteps: string[] } export function ProtocolRunHeader({ @@ -139,6 +141,7 @@ export function ProtocolRunHeader({ robotName, runId, makeHandleJumpToStep, + missingSetupSteps, }: ProtocolRunHeaderProps): JSX.Element | null { const { t } = useTranslation(['run_details', 'shared']) const navigate = useNavigate() @@ -447,6 +450,7 @@ export function ProtocolRunHeader({ isDoorOpen={isDoorOpen} isFixtureMismatch={isFixtureMismatch} isResetRunLoadingRef={isResetRunLoadingRef} + missingSetupSteps={missingSetupSteps} /> @@ -591,6 +595,7 @@ interface ActionButtonProps { isDoorOpen: boolean isFixtureMismatch: boolean isResetRunLoadingRef: React.MutableRefObject + missingSetupSteps: string[] } // TODO(jh, 04-22-2024): Refactor switch cases into separate factories to increase readability and testability. @@ -603,6 +608,7 @@ function ActionButton(props: ActionButtonProps): JSX.Element { isDoorOpen, isFixtureMismatch, isResetRunLoadingRef, + missingSetupSteps, } = props const navigate = useNavigate() const { t } = useTranslation(['run_details', 'shared']) @@ -682,12 +688,20 @@ function ActionButton(props: ActionButtonProps): JSX.Element { ) const { confirm: confirmAttachment, - showConfirmation: showConfirmationModal, - cancel: cancelExit, + showConfirmation: showHSConfirmationModal, + cancel: cancelExitHSConfirmation, } = useConditionalConfirm( handleProceedToRunClick, !configBypassHeaterShakerAttachmentConfirmation ) + const { + confirm: confirmMissingSteps, + showConfirmation: showMissingStepsConfirmationModal, + cancel: cancelExitMissingStepsConfirmation, + } = useConditionalConfirm( + handleProceedToRunClick, + missingSetupSteps.length !== 0 + ) const robotAnalyticsData = useRobotAnalyticsData(robotName) const isHeaterShakerInProtocol = useIsHeaterShakerInProtocol() @@ -745,6 +759,11 @@ function ActionButton(props: ActionButtonProps): JSX.Element { handleButtonClick = () => { if (isHeaterShakerShaking && isHeaterShakerInProtocol) { setShowIsShakingModal(true) + } else if ( + missingSetupSteps.length !== 0 && + (runStatus === RUN_STATUS_IDLE || runStatus === RUN_STATUS_STOPPED) + ) { + confirmMissingSteps() } else if ( isHeaterShakerInProtocol && !isHeaterShakerShaking && @@ -825,13 +844,21 @@ function ActionButton(props: ActionButtonProps): JSX.Element { startRun={play} /> )} - {showConfirmationModal && ( + {showHSConfirmationModal && ( )} + {showMissingStepsConfirmationModal && ( + + )} + {} ) } diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx index 78eae1454cc..d5c9e56585b 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx @@ -62,16 +62,33 @@ export type StepKey = | typeof LABWARE_SETUP_KEY | typeof LIQUID_SETUP_KEY +export type MissingStep = + | 'applied_labware_offsets' + | 'labware_placement' + | 'liquids' + +export type MissingSteps = MissingStep[] + +export const initialMissingSteps = (): MissingSteps => [ + 'applied_labware_offsets', + 'labware_placement', + 'liquids', +] + interface ProtocolRunSetupProps { protocolRunHeaderRef: React.RefObject | null robotName: string runId: string + setMissingSteps: (missingSteps: MissingSteps) => void + missingSteps: MissingSteps } export function ProtocolRunSetup({ protocolRunHeaderRef, robotName, runId, + setMissingSteps, + missingSteps, }: ProtocolRunSetupProps): JSX.Element | null { const { t, i18n } = useTranslation('protocol_setup') const robotProtocolAnalysis = useMostRecentCompletedAnalysis(runId) @@ -246,8 +263,13 @@ export function ProtocolRunSetup({ { - confirmed && setExpandedStepKey(LABWARE_SETUP_KEY) setLpcComplete(confirmed) + if (confirmed) { + setExpandedStepKey(LABWARE_SETUP_KEY) + setMissingSteps( + missingSteps.filter(step => step !== 'applied_labware_offsets') + ) + } }} offsetsConfirmed={lpcComplete} /> @@ -270,6 +292,9 @@ export function ProtocolRunSetup({ setLabwareConfirmed={(confirmed: boolean) => { setLabwareSetupComplete(confirmed) if (confirmed) { + setMissingSteps( + missingSteps.filter(step => step !== 'labware_placement') + ) const nextStep = targetStepKeyInOrder.findIndex(v => v === LABWARE_SETUP_KEY) === targetStepKeyInOrder.length - 1 @@ -298,6 +323,7 @@ export function ProtocolRunSetup({ setLiquidSetupConfirmed={(confirmed: boolean) => { setLiquidSetupComplete(confirmed) if (confirmed) { + setMissingSteps(missingSteps.filter(step => step != 'liquids')) setExpandedStepKey(null) } }} diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx index 70b16c61b55..872dff5771f 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx @@ -97,7 +97,9 @@ import { ProtocolDropTipModal, useProtocolDropTipModal, } from '../ProtocolDropTipModal' +import { ConfirmMissingStepsModal } from '../ConfirmMissingStepsModal' +import type { MissingSteps } from '../ProtocolRunSetup' import type { UseQueryResult } from 'react-query' import type { NavigateFunction } from 'react-router-dom' import type { Mock } from 'vitest' @@ -153,6 +155,7 @@ vi.mock('../../../ProtocolUpload/hooks/useMostRecentRunId') vi.mock('../../../../resources/runs') vi.mock('../../../ErrorRecoveryFlows') vi.mock('../ProtocolDropTipModal') +vi.mock('../ConfirmMissingStepsModal') const ROBOT_NAME = 'otie' const RUN_ID = '95e67900-bc9f-4fbf-92c6-cc4d7226a51b' @@ -215,6 +218,7 @@ const mockDoorStatus = { doorRequiredClosedForProtocol: true, }, } +let mockMissingSteps: MissingSteps = [] const render = () => { return renderWithProviders( @@ -224,6 +228,7 @@ const render = () => { robotName={ROBOT_NAME} runId={RUN_ID} makeHandleJumpToStep={vi.fn(() => vi.fn())} + missingSetupSteps={mockMissingSteps} /> , { i18nInstance: i18n } @@ -240,7 +245,7 @@ describe('ProtocolRunHeader', () => { mockTrackProtocolRunEvent = vi.fn(() => new Promise(resolve => resolve({}))) mockCloseCurrentRun = vi.fn() mockDetermineTipStatus = vi.fn() - + mockMissingSteps = [] vi.mocked(useTrackEvent).mockReturnValue(mockTrackEvent) vi.mocked(ConfirmCancelModal).mockReturnValue(
Mock ConfirmCancelModal
@@ -267,6 +272,9 @@ describe('ProtocolRunHeader', () => { vi.mocked(ConfirmAttachmentModal).mockReturnValue(
mock confirm attachment modal
) + vi.mocked(ConfirmMissingStepsModal).mockReturnValue( +
mock missing steps modal
+ ) when(vi.mocked(useProtocolAnalysisErrors)).calledWith(RUN_ID).thenReturn({ analysisErrors: null, }) diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx index c64d00aa534..e4fbc00e234 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx @@ -40,6 +40,7 @@ import { SetupLiquids } from '../SetupLiquids' import { SetupModuleAndDeck } from '../SetupModuleAndDeck' import { EmptySetupStep } from '../EmptySetupStep' import { ProtocolRunSetup } from '../ProtocolRunSetup' +import type { MissingSteps } from '../ProtocolRunSetup' import { useNotifyRunQuery } from '../../../../resources/runs' import type * as SharedData from '@opentrons/shared-data' @@ -68,12 +69,18 @@ vi.mock('@opentrons/shared-data', async importOriginal => { const ROBOT_NAME = 'otie' const RUN_ID = '1' const MOCK_PROTOCOL_LIQUID_KEY = { liquids: [] } +let mockMissingSteps: MissingSteps = [] +const mockSetMissingSteps = vi.fn((missingSteps: MissingSteps) => { + mockMissingSteps = missingSteps +}) const render = () => { return renderWithProviders( , { i18nInstance: i18n, @@ -83,6 +90,7 @@ const render = () => { describe('ProtocolRunSetup', () => { beforeEach(() => { + mockMissingSteps = [] when(vi.mocked(useIsFlex)).calledWith(ROBOT_NAME).thenReturn(false) when(vi.mocked(useMostRecentCompletedAnalysis)) .calledWith(RUN_ID) diff --git a/app/src/pages/Devices/ProtocolRunDetails/index.tsx b/app/src/pages/Devices/ProtocolRunDetails/index.tsx index 6e5501e6abd..82865237d8e 100644 --- a/app/src/pages/Devices/ProtocolRunDetails/index.tsx +++ b/app/src/pages/Devices/ProtocolRunDetails/index.tsx @@ -31,7 +31,10 @@ import { } from '../../../organisms/Devices/hooks' import { ProtocolRunHeader } from '../../../organisms/Devices/ProtocolRun/ProtocolRunHeader' import { RunPreview } from '../../../organisms/RunPreview' -import { ProtocolRunSetup } from '../../../organisms/Devices/ProtocolRun/ProtocolRunSetup' +import { + ProtocolRunSetup, + initialMissingSteps, +} from '../../../organisms/Devices/ProtocolRun/ProtocolRunSetup' import { BackToTopButton } from '../../../organisms/Devices/ProtocolRun/BackToTopButton' import { ProtocolRunModuleControls } from '../../../organisms/Devices/ProtocolRun/ProtocolRunModuleControls' import { ProtocolRunRuntimeParameters } from '../../../organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters' @@ -137,7 +140,6 @@ export function ProtocolRunDetails(): JSX.Element | null { React.useEffect(() => { dispatch(fetchProtocols()) }, [dispatch]) - return robot != null ? ( + >(initialMissingSteps()) + const makeHandleScrollToStep = (i: number) => () => { listRef.current?.scrollToIndex(i, true, -1 * JUMP_OFFSET_FROM_TOP_PX) } @@ -207,6 +213,8 @@ function PageContents(props: PageContentsProps): JSX.Element { protocolRunHeaderRef={protocolRunHeaderRef} robotName={robotName} runId={runId} + setMissingSteps={setMissingSteps} + missingSteps={missingSteps} /> ), backToTop: ( @@ -248,12 +256,16 @@ function PageContents(props: PageContentsProps): JSX.Element { backToTop: null, }, } - - const tabDetails = protocolRunDetailsContentByTab[protocolRunDetailsTab] ?? ( - // default to the setup tab if no tab or nonexistent tab is passed as a param - - - ) + console.log(`run details tab is ${protocolRunDetailsTab}`) + const tabDetails = protocolRunDetailsContentByTab[ + protocolRunDetailsTab + ] ?? // default to the setup tab if no tab or nonexistent tab is passed as a param + { + content: ( + + ), + backToTop: null, + } const { content, backToTop } = tabDetails return ( @@ -263,6 +275,7 @@ function PageContents(props: PageContentsProps): JSX.Element { robotName={robotName} runId={runId} makeHandleJumpToStep={makeHandleJumpToStep} + missingSetupSteps={missingSteps} />