Skip to content

Commit

Permalink
feat(app): add desktop modal for incompatible modules
Browse files Browse the repository at this point in the history
feat(app): IncompatibleModules on desktop

Display a modal on the desktop app if incompatible modules are attached
to the robot you're currently viewing. This modal allows interaction
with the navbar but otherwise is displayed at any time that the app is
viewing a specific robot (i.e. prepare for run, device details) and that
robot has incompatible modules attached.
  • Loading branch information
sfoster1 committed May 17, 2024
1 parent b6a3e1f commit ab2df12
Show file tree
Hide file tree
Showing 6 changed files with 238 additions and 27 deletions.
29 changes: 24 additions & 5 deletions app/src/App/DesktopApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { Labware } from '../pages/Labware'
import { useSoftwareUpdatePoll } from './hooks'
import { Navbar } from './Navbar'
import { EstopTakeover, EmergencyStopContext } from '../organisms/EmergencyStop'
import { IncompatibleModuleTakeover } from '../organisms/IncompatibleModule'
import { OPENTRONS_USB } from '../redux/discovery'
import { appShellRequestor } from '../redux/shell/remote'
import { useRobot, useIsFlex } from '../organisms/Devices/hooks'
Expand Down Expand Up @@ -153,10 +154,6 @@ function RobotControlTakeover(): JSX.Element | null {
const params = deviceRouteMatch?.params as DesktopRouteParams
const robotName = params?.robotName
const robot = useRobot(robotName)
const isFlex = useIsFlex(robotName)

// E-stop is not supported on OT2
if (!isFlex) return null

if (deviceRouteMatch == null || robot == null || robotName == null)
return null
Expand All @@ -167,7 +164,29 @@ function RobotControlTakeover(): JSX.Element | null {
hostname={robot.ip ?? null}
requestor={robot?.ip === OPENTRONS_USB ? appShellRequestor : undefined}
>
<EstopTakeover robotName={robotName} />
<FlexOnlyRobotControlTakeover robotName={robotName} />
<AllRobotsRobotControlTakeover robotName={robotName} />
</ApiHostProvider>
)
}

interface TakeoverProps {
robotName: string
}

function AllRobotsRobotControlTakeover({
robotName,
}: TakeoverProps): JSX.Element | null {
return <IncompatibleModuleTakeover isOnDevice={false} robotName={robotName} />
}

function FlexOnlyRobotControlTakeover({
robotName,
}: TakeoverProps): JSX.Element | null {
// E-stop is not supported on OT2
const isFlex = useIsFlex(robotName)
if (!isFlex) {
return null
}
return <EstopTakeover robotName={robotName} />
}
5 changes: 4 additions & 1 deletion app/src/assets/localization/en/incompatible_modules.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
{
"incompatible_modules_attached": "incompatible module detected",
"remove_before_running_protocol": "Remove the following hardware before running a protocol:"
"remove_before_running_protocol": "Remove the following hardware before running a protocol:",
"needs_your_assistance": "{{robot_name}} needs your assistance",
"remove_before_using": "You must remove incompatible modules before using this robot.",
"is_not_compatible": "{{module_name}} is not compatible with the {{robot_type}}"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import * as React from 'react'
import { useTranslation, Trans } from 'react-i18next'
import {
DIRECTION_COLUMN,
DIRECTION_ROW,
ALIGN_CENTER,
JUSTIFY_FLEX_START,
Flex,
SPACING,
StyledText,
TYPOGRAPHY,
OVERFLOW_SCROLL,
Icon,
COLORS,
} from '@opentrons/components'
import { getModuleDisplayName } from '@opentrons/shared-data'
import type { AttachedModule } from '@opentrons/api-client'
import { useIsFlex } from '../Devices/hooks'
import { InterventionModal } from '../../molecules/InterventionModal'
export interface IncompatibleModuleDesktopModalBodyProps {
modules: AttachedModule[]
robotName: string
}

export function IncompatibleModuleDesktopModalBody({
modules,
robotName,
}: IncompatibleModuleDesktopModalBodyProps): JSX.Element {
const { t } = useTranslation('incompatible_modules')
const isFlex = useIsFlex(robotName)
const displayName = isFlex ? 'Flex' : 'OT-2'
return (
<InterventionModal
heading={
<Trans
as="h4"
fontSize={TYPOGRAPHY.fontSizeH4}
t={t}
i18nKey="needs_your_assistance"
values={{ robot_name: robotName }}
/>
}
type="error"
>
<Flex flexDirection={DIRECTION_COLUMN} padding={SPACING.spacing32}>
<Flex
overflowY={OVERFLOW_SCROLL}
flexDirection={DIRECTION_COLUMN}
gridGap={SPACING.spacing12}
maxHeight="196px"
as="ul"
>
{...modules.map(module => (
<li key={module.id}>
<Flex
flexDirection={DIRECTION_ROW}
width="100%"
justifyContent={JUSTIFY_FLEX_START}
alignItems={ALIGN_CENTER}
paddingBottom={SPACING.spacing12}
>
<Icon
name="alert-circle"
size={SPACING.spacing32}
color={COLORS.red50}
/>
<StyledText
as="p"
key={module.id}
fontWeight={TYPOGRAPHY.fontWeightSemiBold}
paddingLeft={SPACING.spacing12}
>
<Trans
i18nKey="is_not_compatible"
values={{
module_name: getModuleDisplayName(module.moduleModel),
robot_type: displayName,
}}
t={t}
/>
</StyledText>
</Flex>
</li>
))}
</Flex>
<StyledText as="p" paddingTop={SPACING.spacing12}>
<Trans t={t} i18nKey="remove_before_using" />
</StyledText>
</Flex>
</InterventionModal>
)
}
27 changes: 17 additions & 10 deletions app/src/organisms/IncompatibleModule/IncompatibleModuleTakeover.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,40 @@
import * as React from 'react'
import { createPortal } from 'react-dom'
import { IncompatibleModuleODDModalBody } from './IncompatibleModuleODDModalBody'
import { IncompatibleModuleDesktopModalBody } from './IncompatibleModuleDesktopModalBody'
import { getTopPortalEl, getModalPortalEl } from '../../App/portal'
import { useIncompatibleModulesAttached } from './hooks'

const POLL_INTERVAL_MS = 5000

export interface IncompatibleModuleTakeoverProps {
isOnDevice: boolean
robotName?: string
}

export function IncompatibleModuleTakeover({
isOnDevice,
robotName,
}: IncompatibleModuleTakeoverProps): JSX.Element {
const incompatibleModules = useIncompatibleModulesAttached({
refetchInterval: POLL_INTERVAL_MS,
})
return (
<>
{incompatibleModules.length !== 0 ? (
isOnDevice ? (
createPortal(
<IncompatibleModuleODDModalBody modules={incompatibleModules} />,
getTopPortalEl()
)
) : (
<></>
)
) : null}
{incompatibleModules.length !== 0
? isOnDevice
? createPortal(
<IncompatibleModuleODDModalBody modules={incompatibleModules} />,
getTopPortalEl()
)
: createPortal(
<IncompatibleModuleDesktopModalBody
modules={incompatibleModules}
robotName={robotName ?? ''}
/>,
getModalPortalEl()
)
: null}
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React from 'react'
import { screen } from '@testing-library/react'
import { describe, it, beforeEach, vi } from 'vitest'
import { when } from 'vitest-when'
import '@testing-library/jest-dom/vitest'
import { renderWithProviders } from '../../../__testing-utils__'
import { i18n } from '../../../i18n'
import { IncompatibleModuleDesktopModalBody } from '../IncompatibleModuleDesktopModalBody'
import { useIsFlex } from '../../Devices/hooks'
import * as Fixtures from '../__fixtures__'

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

const getRenderer = (isFlex: boolean) => {
when(useIsFlex).calledWith('otie').thenReturn(isFlex)
return (
props: React.ComponentProps<typeof IncompatibleModuleDesktopModalBody>
) => {
return renderWithProviders(
<IncompatibleModuleDesktopModalBody {...props} />,
{
i18nInstance: i18n,
}
)[0]
}
}

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

it('should render i18nd footer text', () => {
props = { ...props, modules: Fixtures.oneIncompatibleModule as any }
getRenderer(true)(props)
screen.getByText(
'You must remove incompatible modules before using this robot.'
)
screen.getByText('otie needs your assistance')
})
;['Flex', 'OT-2'].forEach(robotKind =>
it(`should render a module card that says ${robotKind}`, () => {
props = { ...props, modules: Fixtures.oneIncompatibleModule as any }
getRenderer(robotKind === 'Flex')(props)
screen.getByText(
`Thermocycler Module GEN1 is not compatible with the ${robotKind}`
)
})
)
})
Original file line number Diff line number Diff line change
@@ -1,37 +1,56 @@
import React from 'react'
import { screen } from '@testing-library/react'
import { screen, findByText } 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 { IncompatibleModuleODDModalBody } from '../IncompatibleModuleODDModalBody'
import { IncompatibleModuleDesktopModalBody } from '../IncompatibleModuleDesktopModalBody'
import { useIncompatibleModulesAttached } from '../hooks'
import type { AttachedModule } from '@opentrons/api-client'
import { PortalRoot, MODAL_PORTAL_ID } from '../../../App/portal'
import {
PortalRoot,
TopPortalRoot,
MODAL_PORTAL_ID,
TOP_PORTAL_ID,
} from '../../../App/portal'
import * as Fixtures from '../__fixtures__'

vi.mock('../hooks')
vi.mock('../IncompatibleModuleODDModalBody')
vi.mock('../IncompatibleModuleDesktopModalBody')

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

Expand All @@ -44,15 +63,32 @@ describe('IncompatibleModuleTakeover', () => {
afterEach(() => {
vi.restoreAllMocks()
})
;['desktop', 'odd'].forEach(target => {
it(`should render nothing on ${target} when no incompatible modules are attached`, () => {
getRenderer([])({ ...props, isOnDevice: target === 'odd' })
expect(screen.findByTestId(TOP_PORTAL_ID)).resolves.toBeEmptyDOMElement()
expect(
screen.findByTestId(MODAL_PORTAL_ID)
).resolves.toBeEmptyDOMElement()
expect(screen.queryByText(/TEST ELEMENT/)).toBeNull()
})
})

it('should render nothing when no incompatible modules are attached', () => {
getRenderer([])(props)
expect(screen.findByTestId(MODAL_PORTAL_ID)).resolves.toBeEmptyDOMElement()
it('should render the modal body on odd when incompatible modules are attached', async () => {
getRenderer(Fixtures.oneIncompatibleModule as any)({
...props,
isOnDevice: true,
})
const container = await screen.findByTestId(TOP_PORTAL_ID)
await findByText(container, 'TEST ELEMENT ODD')
})

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

0 comments on commit ab2df12

Please sign in to comment.