Skip to content

Commit

Permalink
feat(app): add ODD modal for incompatible modules
Browse files Browse the repository at this point in the history
This is a blocking modal takeover that pops when you have incompatible
modules (as determined by the machine) connected to your flex, and goes
away when you remove them.

This also adds a test id to the modal and app root elements (it's just
the id again) because testing-library/react doesn't offer a way to
search just by id, and it's nice to be able to test that your component
is hanging off the right root.

Closes RSQ-6
  • Loading branch information
sfoster1 committed May 9, 2024
1 parent 406f6c2 commit 5d148d8
Show file tree
Hide file tree
Showing 15 changed files with 827 additions and 4 deletions.
2 changes: 2 additions & 0 deletions app/src/App/OnDeviceDisplayApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { OnDeviceLocalizationProvider } from '../LocalizationProvider'
import { ToasterOven } from '../organisms/ToasterOven'
import { MaintenanceRunTakeover } from '../organisms/TakeoverModal'
import { FirmwareUpdateTakeover } from '../organisms/FirmwareUpdateModal/FirmwareUpdateTakeover'
import { IncompatibleModuleTakeover } from '../organisms/IncompatibleModuleModal/IncompatibleModuleTakeover'
import { EstopTakeover } from '../organisms/EmergencyStop'
import { ConnectViaEthernet } from '../pages/ConnectViaEthernet'
import { ConnectViaUSB } from '../pages/ConnectViaUSB'
Expand Down Expand Up @@ -179,6 +180,7 @@ export const OnDeviceDisplayApp = (): JSX.Element => {
) : (
<>
<EstopTakeover />
<IncompatibleModuleTakeover />
<MaintenanceRunTakeover>
<FirmwareUpdateTakeover />
<NiceModal.Provider>
Expand Down
8 changes: 4 additions & 4 deletions app/src/App/portal.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as React from 'react'
import { Box } from '@opentrons/components'

const TOP_PORTAL_ID = '__otAppTopPortalRoot'
const MODAL_PORTAL_ID = '__otAppModalPortalRoot'
export const TOP_PORTAL_ID = '__otAppTopPortalRoot'
export const MODAL_PORTAL_ID = '__otAppModalPortalRoot'
export function getTopPortalEl(): HTMLElement {
return global.document.getElementById(TOP_PORTAL_ID) ?? global.document.body
}
Expand All @@ -11,9 +11,9 @@ export function getModalPortalEl(): HTMLElement {
}

export function PortalRoot(): JSX.Element {
return <Box zIndex={1} id={MODAL_PORTAL_ID} />
return <Box zIndex={1} id={MODAL_PORTAL_ID} data-testid={MODAL_PORTAL_ID} />
}

export function TopPortalRoot(): JSX.Element {
return <Box zIndex={10} id={TOP_PORTAL_ID} />
return <Box zIndex={10} id={TOP_PORTAL_ID} data-testid={TOP_PORTAL_ID} />
}
4 changes: 4 additions & 0 deletions app/src/assets/localization/en/incompatible_modules.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"incompatible_modules_attached": "incompatible module detected",
"remove_before_running_protocol": "Remove the following hardware before running a protocol:"
}
2 changes: 2 additions & 0 deletions app/src/assets/localization/en/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import robot_controls from './robot_controls.json'
import run_details from './run_details.json'
import top_navigation from './top_navigation.json'
import error_recovery from './error_recovery.json'
import incompatible_modules from './incompatible_modules.json'

export const en = {
shared,
Expand Down Expand Up @@ -58,4 +59,5 @@ export const en = {
run_details,
top_navigation,
error_recovery,
incompatible_modules,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import * as React from 'react'
import { useTranslation, Trans } from 'react-i18next'
import capitalize from 'lodash/capitalize'
import {
DIRECTION_COLUMN,
Flex,
SPACING,
StyledText,
TYPOGRAPHY,
OVERFLOW_SCROLL,
} from '@opentrons/components'
import { getModuleDisplayName } from '@opentrons/shared-data'
import type { AttachedModule } from '@opentrons/api-client'
import { Modal } from '../../molecules/Modal'
import { ListItem } from '../../atoms/ListItem'
import type { ModalHeaderBaseProps } from '../../molecules/Modal/types'
export interface IncompatibleModuleModalBodyProps {
modules: AttachedModule[]
}

export function IncompatibleModuleModalBody(
props: IncompatibleModuleModalBodyProps
): JSX.Element {
const { t } = useTranslation('incompatible_modules')
const incompatibleModuleHeader: ModalHeaderBaseProps = {
title: capitalize(t('incompatible_modules_attached')),
}
const { modules } = props
return (
<Modal header={incompatibleModuleHeader}>
<Flex flexDirection={DIRECTION_COLUMN} width="100%">
<StyledText as="p" marginBottom={SPACING.spacing32}>
<Trans t={t} i18nKey="remove_before_running_protocol" />
</StyledText>
<Flex
overflowY={OVERFLOW_SCROLL}
flexDirection={DIRECTION_COLUMN}
gridGap={SPACING.spacing8}
maxHeight="196px"
>
{...modules.map(module => (
<ListItem key={module.id} type="noActive">
<StyledText
as="p"
key={module.id}
fontWeight={TYPOGRAPHY.fontWeightSemiBold}
>
{getModuleDisplayName(module.moduleModel)}
</StyledText>
</ListItem>
))}
</Flex>
</Flex>
</Modal>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as React from 'react'
import { createPortal } from 'react-dom'
import { IncompatibleModuleModalBody } from './IncompatibleModuleModalBody'
import { getTopPortalEl } from '../../App/portal'
import { useIncompatibleModulesAttached } from './hooks'

const POLL_INTERVAL_MS = 5000

export function IncompatibleModuleTakeover(): JSX.Element {
const incompatibleModules = useIncompatibleModulesAttached({
refetchInterval: POLL_INTERVAL_MS,
})
return (
<>
{incompatibleModules.length !== 0
? createPortal(
<IncompatibleModuleModalBody modules={incompatibleModules} />,
getTopPortalEl()
)
: null}
</>
)
}
154 changes: 154 additions & 0 deletions app/src/organisms/IncompatibleModuleModal/__fixtures__/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
export const oneIncompatibleModule = [
{
id: '3feb840a3fa2dac2409b977f1e330f54f50e6231',
serialNumber: 'dummySerialTC',
firmwareVersion: 'dummyVersionTC',
hardwareRevision: 'dummyModelTC',
hasAvailableUpdate: false,
moduleType: 'thermocyclerModuleType',
moduleModel: 'thermocyclerModuleV1',
compatibleWithRobot: false,
data: {
status: 'holding at target',
currentTemperature: 3.0,
targetTemperature: 3.0,
lidStatus: 'open',
lidTemperature: 4.0,
lidTargetTemperature: 4.0,
holdTime: 121.0,
},
usbPort: {
port: 0,
path: '',
hub: false,
portGroup: 'unknown',
},
},
]
export const manyIncompatibleModules = [
{
id: '3feb840a3fa2dac2409b977f1e330f54f50e6231',
serialNumber: 'dummySerialTC',
firmwareVersion: 'dummyVersionTC',
hardwareRevision: 'dummyModelTC',
hasAvailableUpdate: false,
moduleType: 'thermocyclerModuleType',
moduleModel: 'thermocyclerModuleV1',
compatibleWithRobot: false,
data: {
status: 'holding at target',
currentTemperature: 3.0,
targetTemperature: 3.0,
lidStatus: 'open',
lidTemperature: 4.0,
lidTargetTemperature: 4.0,
holdTime: 121.0,
},
usbPort: {
port: 0,
path: '',
hub: false,
portGroup: 'unknown',
},
},
{
id: 'aojfhkalshdaoahosifhoaisdada',
serialNumber: 'dummySerialTC',
firmwareVersion: 'dummyVersionTC',
hardwareRevision: 'dummyModelTC',
hasAvailableUpdate: false,
moduleType: 'thermocyclerModuleType',
moduleModel: 'thermocyclerModuleV1',
compatibleWithRobot: false,
data: {
status: 'holding at target',
currentTemperature: 3.0,
targetTemperature: 3.0,
lidStatus: 'open',
lidTemperature: 4.0,
lidTargetTemperature: 4.0,
holdTime: 121.0,
},
usbPort: {
port: 0,
path: '',
hub: false,
portGroup: 'unknown',
},
},
{
id: 'asojhfaohsoihfjaoisodaalallala',
serialNumber: 'dummySerialTC',
firmwareVersion: 'dummyVersionTC',
hardwareRevision: 'dummyModelTC',
hasAvailableUpdate: false,
moduleType: 'thermocyclerModuleType',
moduleModel: 'thermocyclerModuleV1',
compatibleWithRobot: false,
data: {
status: 'holding at target',
currentTemperature: 3.0,
targetTemperature: 3.0,
lidStatus: 'open',
lidTemperature: 4.0,
lidTargetTemperature: 4.0,
holdTime: 121.0,
},
usbPort: {
port: 0,
path: '',
hub: false,
portGroup: 'unknown',
},
},
{
id: 'sfaoisdfolasda09sd09aaaaaaaaaa',
serialNumber: 'dummySerialTC',
firmwareVersion: 'dummyVersionTC',
hardwareRevision: 'dummyModelTC',
hasAvailableUpdate: false,
moduleType: 'thermocyclerModuleType',
moduleModel: 'thermocyclerModuleV1',
compatibleWithRobot: false,
data: {
status: 'holding at target',
currentTemperature: 3.0,
targetTemperature: 3.0,
lidStatus: 'open',
lidTemperature: 4.0,
lidTargetTemperature: 4.0,
holdTime: 121.0,
},
usbPort: {
port: 0,
path: '',
hub: false,
portGroup: 'unknown',
},
},
{
id: 'oasihfa980109109dm011',
serialNumber: 'dummySerialTC',
firmwareVersion: 'dummyVersionTC',
hardwareRevision: 'dummyModelTC',
hasAvailableUpdate: false,
moduleType: 'thermocyclerModuleType',
moduleModel: 'thermocyclerModuleV1',
compatibleWithRobot: false,
data: {
status: 'holding at target',
currentTemperature: 3.0,
targetTemperature: 3.0,
lidStatus: 'open',
lidTemperature: 4.0,
lidTargetTemperature: 4.0,
holdTime: 121.0,
},
usbPort: {
port: 0,
path: '',
hub: false,
portGroup: 'unknown',
},
},
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React from 'react'
import { screen } from '@testing-library/react'
import { describe, it, beforeEach, expect } from 'vitest'
import '@testing-library/jest-dom/vitest'
import { renderWithProviders } from '../../../__testing-utils__'
import { i18n } from '../../../i18n'
import { IncompatibleModuleModalBody } from '../IncompatibleModuleModalBody'
import * as Fixtures from '../__fixtures__'

const render = (
props: React.ComponentProps<typeof IncompatibleModuleModalBody>
) => {
return renderWithProviders(<IncompatibleModuleModalBody {...props} />, {
i18nInstance: i18n,
})[0]
}

describe('IncompatibleModuleModalBody', () => {
let props: React.ComponentProps<typeof IncompatibleModuleModalBody>
beforeEach(() => {
props = {
modules: [],
}
})

it('should render i18nd header text', () => {
props = { ...props, modules: Fixtures.oneIncompatibleModule as any }
render(props)
screen.getByText('Incompatible module detected')
screen.getByText('Remove the following hardware before running a protocol:')
})

it('should render a module card', () => {
props = { ...props, modules: Fixtures.oneIncompatibleModule as any }
render(props)
screen.getByText('Thermocycler Module GEN1')
})

it('should overflow via scroll', () => {
props = { ...props, modules: Fixtures.manyIncompatibleModules as any }
render(props)
const labels = screen.getAllByText('Thermocycler Module GEN1')
expect(labels).toHaveLength(Fixtures.manyIncompatibleModules.length)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from 'react'
import { screen } from '@testing-library/react'
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'
import { when } from 'vitest-when'
import '@testing-library/jest-dom/vitest'
import { renderWithProviders } from '../../../__testing-utils__'
import { i18n } from '../../../i18n'
import { IncompatibleModuleTakeover } from '../IncompatibleModuleTakeover'
import { IncompatibleModuleModalBody } from '../IncompatibleModuleModalBody'
import { useIncompatibleModulesAttached } from '../hooks'
import type { AttachedModule } from '@opentrons/api-client'
import { PortalRoot, MODAL_PORTAL_ID } from '../../../App/portal'

vi.mock('../hooks')
vi.mock('../IncompatibleModuleModalBody')

import * as Fixtures from '../__fixtures__'

Check failure on line 17 in app/src/organisms/IncompatibleModuleModal/__tests__/IncompatibleModuleTakeover.test.tsx

View workflow job for this annotation

GitHub Actions / js checks

Import in body of module; reorder to top

const getRenderer = (incompatibleModules: AttachedModule[]) => {
when(useIncompatibleModulesAttached)
.calledWith(expect.anything())
.thenReturn(incompatibleModules)
vi.mocked(IncompatibleModuleModalBody).mockReturnValue(
<div>TEST ELEMENT</div>
)
return (props: React.ComponentProps<typeof IncompatibleModuleTakeover>) => {
return renderWithProviders(
<>
<PortalRoot />
<IncompatibleModuleTakeover {...(props as any)} />
</>,
{
i18nInstance: i18n,
}
)[0]
}
}

describe('IncompatibleModuleTakeover', () => {
let props: React.ComponentProps<typeof IncompatibleModuleTakeover>
beforeEach(() => {
props = {}
})

afterEach(() => {
vi.restoreAllMocks()
})

it('should render nothing when no incompatible modules are attached', () => {
getRenderer([])(props)
expect(screen.findByTestId(MODAL_PORTAL_ID)).resolves.toBeEmptyDOMElement()
})

it('should render the module body when incompatible modules are attached', async () => {
getRenderer(Fixtures.oneIncompatibleModule as any)(props)
const container = await screen.findByTestId(MODAL_PORTAL_ID)
await screen.findByText('TEST ELEMENT', {}, { container })
})
})
Loading

0 comments on commit 5d148d8

Please sign in to comment.