diff --git a/api-client/src/robot/getDoorStatus.ts b/api-client/src/robot/getDoorStatus.ts new file mode 100644 index 00000000000..4c04beac5c5 --- /dev/null +++ b/api-client/src/robot/getDoorStatus.ts @@ -0,0 +1,9 @@ +import { GET, request } from '../request' + +import type { ResponsePromise } from '../request' +import type { HostConfig } from '../types' +import type { DoorStatus } from './types' + +export function getDoorStatus(config: HostConfig): ResponsePromise { + return request(GET, '/robot/door/status', null, config) +} diff --git a/api-client/src/robot/index.ts b/api-client/src/robot/index.ts index 7fa90642f8f..96ef28165b0 100644 --- a/api-client/src/robot/index.ts +++ b/api-client/src/robot/index.ts @@ -1,8 +1,10 @@ +export { getDoorStatus } from './getDoorStatus' export { getEstopStatus } from './getEstopStatus' export { acknowledgeEstopDisengage } from './acknowledgeEstopDisengage' export { getLights } from './getLights' export { setLights } from './setLights' export type { + DoorStatus, EstopPhysicalStatus, EstopState, EstopStatus, diff --git a/api-client/src/robot/types.ts b/api-client/src/robot/types.ts index 062d8592afd..00d887b9c4e 100644 --- a/api-client/src/robot/types.ts +++ b/api-client/src/robot/types.ts @@ -1,3 +1,9 @@ +export interface DoorStatus { + data: { + status: 'open' | 'closed' + doorRequiredClosedForProtocol: boolean + } +} export type EstopState = | 'physicallyEngaged' | 'logicallyEngaged' diff --git a/app/src/assets/localization/en/shared.json b/app/src/assets/localization/en/shared.json index 20883f99012..057d4aca41c 100644 --- a/app/src/assets/localization/en/shared.json +++ b/app/src/assets/localization/en/shared.json @@ -6,6 +6,7 @@ "browse": "browse", "cancel": "cancel", "clear_data": "clear data", + "close_robot_door": "Close the robot door before starting the run.", "close": "close", "computer_in_app_is_controlling_robot": "A computer with the Opentrons App is currently controlling this robot.", "confirm_placement": "Confirm placement", diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx index 014841be972..49ec312f1f1 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx @@ -16,7 +16,11 @@ import { RUN_STATUS_BLOCKED_BY_OPEN_DOOR, RunStatus, } from '@opentrons/api-client' -import { useRunQuery, useModulesQuery } from '@opentrons/react-api-client' +import { + useRunQuery, + useModulesQuery, + useDoorQuery, +} from '@opentrons/react-api-client' import { HEATERSHAKER_MODULE_TYPE } from '@opentrons/shared-data' import { Box, @@ -85,11 +89,11 @@ import { RunTimer } from './RunTimer' import { EMPTY_TIMESTAMP } from '../constants' import { getHighestPriorityError } from '../../OnDeviceDisplay/RunningProtocol' import { RunFailedModal } from './RunFailedModal' +import { RunProgressMeter } from '../../RunProgressMeter' import type { Run, RunError } from '@opentrons/api-client' import type { State } from '../../../redux/types' import type { HeaterShakerModule } from '../../../redux/modules/types' -import { RunProgressMeter } from '../../RunProgressMeter' const EQUIPMENT_POLL_MS = 5000 const CANCELLABLE_STATUSES = [ @@ -113,7 +117,7 @@ export function ProtocolRunHeader({ runId, makeHandleJumpToStep, }: ProtocolRunHeaderProps): JSX.Element | null { - const { t } = useTranslation('run_details') + const { t } = useTranslation(['run_details', 'shared']) const history = useHistory() const createdAtTimestamp = useRunCreatedAtTimestamp(runId) const { @@ -136,6 +140,12 @@ export function ProtocolRunHeader({ runRecord?.data.errors?.[0] != null ? getHighestPriorityError(runRecord?.data?.errors) : null + const { data: doorStatus } = useDoorQuery({ + refetchInterval: EQUIPMENT_POLL_MS, + }) + const isDoorOpen = + doorStatus?.data.status === 'open' && + doorStatus?.data.doorRequiredClosedForProtocol React.useEffect(() => { if (protocolData != null && !isRobotViewable) { @@ -258,6 +268,9 @@ export function ProtocolRunHeader({ {runStatus === RUN_STATUS_STOPPED ? ( {t('run_canceled')} ) : null} + {isDoorOpen ? ( + {t('shared:close_robot_door')} + ) : null} {isRunCurrent ? ( @@ -412,9 +426,10 @@ interface ActionButtonProps { robotName: string runStatus: RunStatus | null isProtocolAnalyzing: boolean + isDoorOpen: boolean } function ActionButton(props: ActionButtonProps): JSX.Element { - const { runId, robotName, runStatus, isProtocolAnalyzing } = props + const { runId, robotName, runStatus, isProtocolAnalyzing, isDoorOpen } = props const history = useHistory() const { t } = useTranslation(['run_details', 'shared']) const attachedModules = @@ -463,7 +478,8 @@ function ActionButton(props: ActionButtonProps): JSX.Element { isOtherRunCurrent || isProtocolAnalyzing || (runStatus != null && DISABLED_STATUSES.includes(runStatus)) || - isRobotOnWrongVersionOfSoftware + isRobotOnWrongVersionOfSoftware || + isDoorOpen const handleProceedToRunClick = (): void => { trackEvent({ name: ANALYTICS_PROTOCOL_PROCEED_TO_RUN, properties: {} }) play() diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx index e64ba577562..31fa9c930ad 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx @@ -21,6 +21,7 @@ import { usePipettesQuery, useDismissCurrentRunMutation, useEstopQuery, + useDoorQuery, } from '@opentrons/react-api-client' import _uncastedSimpleV6Protocol from '@opentrons/shared-data/protocol/fixtures/6/simpleV6.json' @@ -188,6 +189,9 @@ const mockUseEstopQuery = useEstopQuery as jest.MockedFunction< typeof useEstopQuery > const mockUseIsOT3 = useIsOT3 as jest.MockedFunction +const mockUseDoorQuery = useDoorQuery as jest.MockedFunction< + typeof useDoorQuery +> const ROBOT_NAME = 'otie' const RUN_ID = '95e67900-bc9f-4fbf-92c6-cc4d7226a51b' @@ -235,6 +239,12 @@ const mockEstopStatus = { rightEstopPhysicalStatus: NOT_PRESENT, }, } +const mockDoorStatus = { + data: { + status: 'closed', + doorRequiredClosedForProtocol: true, + }, +} const render = () => { return renderWithProviders( @@ -344,6 +354,7 @@ describe('ProtocolRunHeader', () => { mockUseIsOT3.mockReturnValue(true) mockRunFailedModal.mockReturnValue(
mock RunFailedModal
) mockUseEstopQuery.mockReturnValue({ data: mockEstopStatus } as any) + mockUseDoorQuery.mockReturnValue({ data: mockDoorStatus } as any) }) afterEach(() => { @@ -837,4 +848,13 @@ describe('ProtocolRunHeader', () => { getByText('Run completed.') getByLabelText('ot-spinner') }) + + it('renders banner when the robot door is open', () => { + const mockOpenDoorStatus = { + data: { status: 'open', doorRequiredClosedForProtocol: true }, + } + mockUseDoorQuery.mockReturnValue({ data: mockOpenDoorStatus } as any) + const [{ getByText }] = render() + getByText('Close the robot door before starting the run.') + }) }) diff --git a/app/src/organisms/ToasterOven/ToasterContext.ts b/app/src/organisms/ToasterOven/ToasterContext.ts index c6b911c674e..a2cbc7a3630 100644 --- a/app/src/organisms/ToasterOven/ToasterContext.ts +++ b/app/src/organisms/ToasterOven/ToasterContext.ts @@ -30,4 +30,8 @@ export const ToasterContext = React.createContext({ export type MakeSnackbarOptions = Omit -type MakeSnackbar = (message: string, options?: MakeSnackbarOptions) => void +type MakeSnackbar = ( + message: string, + duration?: number, + options?: MakeSnackbarOptions +) => void diff --git a/app/src/organisms/ToasterOven/ToasterOven.tsx b/app/src/organisms/ToasterOven/ToasterOven.tsx index bbf91734810..5bbc2d5f12b 100644 --- a/app/src/organisms/ToasterOven/ToasterOven.tsx +++ b/app/src/organisms/ToasterOven/ToasterOven.tsx @@ -74,8 +74,12 @@ export function ToasterOven({ children }: ToasterOvenProps): JSX.Element { return id } - function makeSnackbar(message: string, options?: MakeSnackbarOptions): void { - setSnackbar({ message, ...options }) + function makeSnackbar( + message: string, + duration?: number, + options?: MakeSnackbarOptions + ): void { + setSnackbar({ message, duration, ...options }) } // This function is needed to actually make the snackbar auto-close in the context of the diff --git a/app/src/pages/OnDeviceDisplay/ProtocolSetup/__tests__/ProtocolSetup.test.tsx b/app/src/pages/OnDeviceDisplay/ProtocolSetup/__tests__/ProtocolSetup.test.tsx index 58296638a14..78c33423c57 100644 --- a/app/src/pages/OnDeviceDisplay/ProtocolSetup/__tests__/ProtocolSetup.test.tsx +++ b/app/src/pages/OnDeviceDisplay/ProtocolSetup/__tests__/ProtocolSetup.test.tsx @@ -9,12 +9,14 @@ import { useInstrumentsQuery, useRunQuery, useProtocolQuery, + useDoorQuery, } from '@opentrons/react-api-client' import { renderWithProviders } from '@opentrons/components' import { getDeckDefFromRobotType } from '@opentrons/shared-data' import ot3StandardDeckDef from '@opentrons/shared-data/deck/definitions/3/ot3_standard.json' import { i18n } from '../../../../i18n' +import { useToaster } from '../../../../organisms/ToasterOven' import { mockRobotSideAnalysis } from '../../../../organisms/CommandText/__fixtures__' import { useAttachedModules, @@ -70,6 +72,7 @@ jest.mock('../../../../organisms/ProtocolSetupLiquids') jest.mock('../../../../organisms/ModuleCard/hooks') jest.mock('../../../../redux/config') jest.mock('../ConfirmAttachedModal') +jest.mock('../../../../organisms/ToasterOven') const mockGetDeckDefFromRobotType = getDeckDefFromRobotType as jest.MockedFunction< typeof getDeckDefFromRobotType @@ -126,6 +129,10 @@ const mockUseIsHeaterShakerInProtocol = useIsHeaterShakerInProtocol as jest.Mock const mockConfirmAttachedModal = ConfirmAttachedModal as jest.MockedFunction< typeof ConfirmAttachedModal > +const mockUseDoorQuery = useDoorQuery as jest.MockedFunction< + typeof useDoorQuery +> +const mockUseToaster = useToaster as jest.MockedFunction const render = (path = '/') => { return renderWithProviders( @@ -185,6 +192,14 @@ const mockOffset = { vector: { x: 1, y: 2, z: 3 }, } +const mockDoorStatus = { + data: { + status: 'closed', + doorRequiredClosedForProtocol: true, + }, +} +const MOCK_MAKE_SNACKBAR = jest.fn() + describe('ProtocolSetup', () => { let mockLaunchLPC: jest.Mock beforeEach(() => { @@ -263,6 +278,12 @@ describe('ProtocolSetup', () => { mockConfirmAttachedModal.mockReturnValue(
mock ConfirmAttachedModal
) + mockUseDoorQuery.mockReturnValue({ data: mockDoorStatus } as any) + when(mockUseToaster) + .calledWith() + .mockReturnValue(({ + makeSnackbar: MOCK_MAKE_SNACKBAR, + } as unknown) as any) }) afterEach(() => { @@ -353,4 +374,20 @@ describe('ProtocolSetup', () => { const [{ getAllByTestId }] = render(`/runs/${RUN_ID}/setup/`) expect(getAllByTestId('Skeleton').length).toBeGreaterThan(0) }) + + it('should render toast and make a button disabled when a robot door is open', () => { + const mockOpenDoorStatus = { + data: { + status: 'open', + doorRequiredClosedForProtocol: true, + }, + } + mockUseDoorQuery.mockReturnValue({ data: mockOpenDoorStatus } as any) + const [{ getByRole }] = render(`/runs/${RUN_ID}/setup/`) + expect(MOCK_MAKE_SNACKBAR).toBeCalledWith( + 'Close the robot door before starting the run.', + 7000 + ) + expect(getByRole('button', { name: 'play' })).toBeDisabled() + }) }) diff --git a/app/src/pages/OnDeviceDisplay/ProtocolSetup/index.tsx b/app/src/pages/OnDeviceDisplay/ProtocolSetup/index.tsx index ae0be8c4ae1..72662d9b1ff 100644 --- a/app/src/pages/OnDeviceDisplay/ProtocolSetup/index.tsx +++ b/app/src/pages/OnDeviceDisplay/ProtocolSetup/index.tsx @@ -29,6 +29,7 @@ import { useProtocolQuery, useRunQuery, useInstrumentsQuery, + useDoorQuery, } from '@opentrons/react-api-client' import { getDeckDefFromRobotType, @@ -76,6 +77,8 @@ import { ConfirmAttachedModal } from './ConfirmAttachedModal' import type { OnDeviceRouteParams } from '../../../App/types' import { getLatestCurrentOffsets } from '../../../organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/utils' +const FETCH_DOOR_STATUS_MS = 5000 +const SNACK_BAR_DURATION_MS = 7000 interface ProtocolSetupStepProps { onClickSetupStep: () => void status: 'ready' | 'not ready' | 'general' @@ -315,7 +318,7 @@ function PrepareToRun({ confirmAttachment, play, }: PrepareToRunProps): JSX.Element { - const { t, i18n } = useTranslation('protocol_setup') + const { t, i18n } = useTranslation(['protocol_setup', 'shared']) const history = useHistory() const { makeSnackbar } = useToaster() @@ -482,6 +485,20 @@ function PrepareToRun({ // Liquids information const liquidsInProtocol = mostRecentAnalysis?.liquids ?? [] + const { data: doorStatus } = useDoorQuery({ + refetchInterval: FETCH_DOOR_STATUS_MS, + }) + const isDoorOpen = + doorStatus?.data.status === 'open' && + doorStatus?.data.doorRequiredClosedForProtocol + React.useEffect(() => { + // Note show snackbar when instruments and modules are all green + // but the robot door is open + if (isReadyToRun && isDoorOpen) { + makeSnackbar(t('shared:close_robot_door'), SNACK_BAR_DURATION_MS) + } + }, [isDoorOpen]) + return ( <> {/* Empty box to detect scrolling */} @@ -495,7 +512,7 @@ function PrepareToRun({ position={POSITION_STICKY} top={0} backgroundColor={COLORS.white} - overflowY="hidden" + overflowY="auto" marginX={`-${SPACING.spacing32}`} > @@ -531,7 +548,7 @@ function PrepareToRun({ } /> diff --git a/react-api-client/src/robot/__tests__/useDoorQuery.test.tsx b/react-api-client/src/robot/__tests__/useDoorQuery.test.tsx new file mode 100644 index 00000000000..ad861daefb6 --- /dev/null +++ b/react-api-client/src/robot/__tests__/useDoorQuery.test.tsx @@ -0,0 +1,70 @@ +import * as React from 'react' +import { when } from 'jest-when' +import { QueryClient, QueryClientProvider } from 'react-query' +import { renderHook } from '@testing-library/react-hooks' + +import { getDoorStatus } from '@opentrons/api-client' +import { useHost } from '../../api' +import { useDoorQuery } from '..' + +import type { HostConfig, Response, DoorStatus } from '@opentrons/api-client' + +jest.mock('@opentrons/api-client') +jest.mock('../../api/useHost') + +const mockGetDoorStatus = getDoorStatus as jest.MockedFunction< + typeof getDoorStatus +> +const mockUseHost = useHost as jest.MockedFunction + +const HOST_CONFIG: HostConfig = { hostname: 'localhost' } +const DOOR_RESPONSE: DoorStatus = { + data: { status: 'open', doorRequiredClosedForProtocol: true }, +} as DoorStatus + +describe('useDoorQuery hook', () => { + let wrapper: React.FunctionComponent<{}> + + beforeEach(() => { + const queryClient = new QueryClient() + const clientProvider: React.FunctionComponent<{}> = ({ children }) => ( + {children} + ) + + wrapper = clientProvider + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + it('should return no data if no host', () => { + when(mockUseHost).calledWith().mockReturnValue(null) + + const { result } = renderHook(useDoorQuery, { wrapper }) + + expect(result.current?.data).toBeUndefined() + }) + + it('should return no data if lights request fails', () => { + when(mockUseHost).calledWith().mockReturnValue(HOST_CONFIG) + when(mockGetDoorStatus).calledWith(HOST_CONFIG).mockRejectedValue('oh no') + + const { result } = renderHook(useDoorQuery, { wrapper }) + + expect(result.current?.data).toBeUndefined() + }) + + it('should return lights response data', async () => { + when(mockUseHost).calledWith().mockReturnValue(HOST_CONFIG) + when(mockGetDoorStatus) + .calledWith(HOST_CONFIG) + .mockResolvedValue({ data: DOOR_RESPONSE } as Response) + + const { result, waitFor } = renderHook(useDoorQuery, { wrapper }) + + await waitFor(() => result.current?.data != null) + + expect(result.current?.data).toEqual(DOOR_RESPONSE) + }) +}) diff --git a/react-api-client/src/robot/__tests__/useEstopQuery.test.tsx b/react-api-client/src/robot/__tests__/useEstopQuery.test.tsx index 746c7502254..8cfade8e782 100644 --- a/react-api-client/src/robot/__tests__/useEstopQuery.test.tsx +++ b/react-api-client/src/robot/__tests__/useEstopQuery.test.tsx @@ -51,7 +51,7 @@ describe('useEstopQuery hook', () => { }) it('should return no data if estop request fails', () => { - when(useHost).calledWith().mockReturnValue(HOST_CONFIG) + when(mockUseHost).calledWith().mockReturnValue(HOST_CONFIG) when(mockGetEstopStatus).calledWith(HOST_CONFIG).mockRejectedValue('oh no') const { result } = renderHook(useEstopQuery, { wrapper }) @@ -60,7 +60,7 @@ describe('useEstopQuery hook', () => { }) it('should return estop state response data', async () => { - when(useHost).calledWith().mockReturnValue(HOST_CONFIG) + when(mockUseHost).calledWith().mockReturnValue(HOST_CONFIG) when(mockGetEstopStatus) .calledWith(HOST_CONFIG) .mockResolvedValue({ diff --git a/react-api-client/src/robot/index.ts b/react-api-client/src/robot/index.ts index 8fc08e44149..8a539abcea9 100644 --- a/react-api-client/src/robot/index.ts +++ b/react-api-client/src/robot/index.ts @@ -1,3 +1,4 @@ +export { useDoorQuery } from './useDoorQuery' export { useEstopQuery } from './useEstopQuery' export { useLightsQuery } from './useLightsQuery' export { useAcknowledgeEstopDisengageMutation } from './useAcknowledgeEstopDisengageMutation' diff --git a/react-api-client/src/robot/useDoorQuery.ts b/react-api-client/src/robot/useDoorQuery.ts new file mode 100644 index 00000000000..0bd3edfac41 --- /dev/null +++ b/react-api-client/src/robot/useDoorQuery.ts @@ -0,0 +1,19 @@ +import { useQuery } from 'react-query' +import { HostConfig, getDoorStatus } from '@opentrons/api-client' +import { useHost } from '../api' + +import type { UseQueryResult, UseQueryOptions } from 'react-query' +import type { DoorStatus } from '@opentrons/api-client' + +export function useDoorQuery( + options: UseQueryOptions = {} +): UseQueryResult { + const host = useHost() + const query = useQuery( + [host as HostConfig, '/robot/door/status'], + () => getDoorStatus(host as HostConfig).then(response => response.data), + { enabled: host !== null, ...options } + ) + + return query +}