Skip to content

Commit

Permalink
feat(app): Add incompatible module notification modals (#15181)
Browse files Browse the repository at this point in the history
We meant to add this for the launch of the Flex, but it got cut for
scope; in the time since, people connecting modules like
first-generation temperature modules to Flexes that don't support them
has been a pretty consistent thorn in the side of support. This PR
implements the modals designed by design for telling the user when
there's incompatible modules.

This PR is pretty big and might be best viewed by commit, since it has
both the backend and the frontend, and both the ODD and the desktop -
and a couple ancillary support PRs that I'll rebase out of here as they
get merged. Specifically,

## Overview

90dc748 - Python, adds data to module configs to say what kinds of
deck they're compatible with, and adds code to the api and robot server
to expose a bool about whether the pipette's compatible or not in the
HTTP API
788e022 - JS, adds the above to the api clients
2e49f61 - JS, adds a blocking modal to the ODD that pops up whenever
modules are attached per
[figma](https://www.figma.com/file/0kuPeCi1t2Auu2GPMnqRb2/Primary%3A-Flex?type=design&node-id=19961-155919&mode=design&t=pkbLPA9150bwQQNw-4)
c5021ca - JS, adds a modal that blocks interaction with a robot but
_not_ the navbar or breadcrumbs whenever modules are attached per
[figma](https://www.figma.com/design/Nx7ORMnfyJNP4FQYxN4e5x/Primary%3A-Desktop-App?node-id=2032-304206&t=iat6IH7YQD4pMijc-4)


## Notes and Review requests

90dc748 - I'm pretty sure these are the module compatibility rules,
but we'll need to make sure. This will get exposed via HTTP as soon as
the module is plugged in. Review requests:
- [ ] bool should be correct for plugged-in modules basically as soon as
they appear in the app

The above, and 788e022 - This is set up to support polling. Let me
know if you think this should be notifications instead, will be another
couple commits though.

2e49f61 - ODD modal. Is this according to design, is there something
I'm missing? Is the testing approach OK? You can test by editing
`robot-server/simulator/test-flex.env` to specify a
`ThermocyclerModuleV1` instead of a `ThermocyclerModuleV2` and run with
`make -C robot-server dev-flex`, as well as actually plugging stuff in
to a real robot.

c5021ca - Desktop modal, basically same questions and same testing
approach. Note that this is the first ish time we've added a modal that
doesn't inhibit interaction with navbar or breadcrumbs.
  • Loading branch information
sfoster1 authored and ryanthecoder committed May 28, 2024
1 parent 4031cfc commit 37966dc
Show file tree
Hide file tree
Showing 36 changed files with 1,274 additions and 28 deletions.
4 changes: 4 additions & 0 deletions api-client/src/modules/__fixtures__/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const mockModulesResponse = [
hasAvailableUpdate: false,
moduleType: 'thermocyclerModuleType',
moduleModel: 'thermocyclerModuleV1',
compatibleWithRobot: true,
data: {
status: 'holding at target',
currentTemperature: 3.0,
Expand All @@ -31,6 +32,7 @@ export const mockModulesResponse = [
hasAvailableUpdate: false,
moduleType: 'heaterShakerModuleType',
moduleModel: 'heaterShakerModuleV1',
compatibleWithRobot: true,
data: {
status: 'idle',
labwareLatchStatus: 'idle_unknown',
Expand All @@ -55,6 +57,7 @@ export const mockModulesResponse = [
hasAvailableUpdate: false,
moduleType: 'temperatureModuleType',
moduleModel: 'temperatureModuleV1',
compatibleWithRobot: true,
data: {
status: 'holding at target',
currentTemperature: 3.0,
Expand All @@ -75,6 +78,7 @@ export const mockModulesResponse = [
hasAvailableUpdate: false,
moduleType: 'magneticModuleType',
moduleModel: 'magneticModuleV1',
compatibleWithRobot: true,
data: {
status: 'engaged',
engaged: true,
Expand Down
1 change: 1 addition & 0 deletions api-client/src/modules/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface ApiBaseModule {
firmwareVersion: string
hasAvailableUpdate: boolean
usbPort: PhysicalPort
compatibleWithRobot?: boolean
moduleOffset?: ModuleOffset
}

Expand Down
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} />
}
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/IncompatibleModule'
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 isOnDevice={true} />
<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} />
}
7 changes: 7 additions & 0 deletions app/src/assets/localization/en/incompatible_modules.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"incompatible_modules_attached": "incompatible module detected",
"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}}"
}
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,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>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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 IncompatibleModuleODDModalBodyProps {
modules: AttachedModule[]
}

export function IncompatibleModuleODDModalBody({
modules,
}: IncompatibleModuleODDModalBodyProps): JSX.Element {
const { t } = useTranslation('incompatible_modules')
const incompatibleModuleHeader: ModalHeaderBaseProps = {
title: capitalize(t('incompatible_modules_attached')),
}
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,39 @@
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 | null {
const incompatibleModules = useIncompatibleModulesAttached({
refetchInterval: POLL_INTERVAL_MS,
})
if (incompatibleModules.length === 0) {
return null
}
if (isOnDevice) {
return createPortal(
<IncompatibleModuleODDModalBody modules={incompatibleModules} />,
getTopPortalEl()
)
} else {
return createPortal(
<IncompatibleModuleDesktopModalBody
modules={incompatibleModules}
robotName={robotName ?? ''}
/>,
getModalPortalEl()
)
}
}
Loading

0 comments on commit 37966dc

Please sign in to comment.