From 41ecca725200a088a4201f5a6923e59fa190de50 Mon Sep 17 00:00:00 2001 From: Sarah Breen Date: Mon, 24 Jul 2023 07:16:10 -0700 Subject: [PATCH] feat(app): oDD firmware update modal for pipettes and gripper (#13062) fix RLIQ-284 --- .../src/instruments/__fixtures__/index.ts | 2 + api-client/src/instruments/types.ts | 2 + app/src/App/OnDeviceDisplayApp.tsx | 3 + app/src/assets/images/flex_gripper.png | Bin 0 -> 5888 bytes app/src/assets/images/flex_gripper.svg | 65 ------- .../localization/en/device_details.json | 8 + .../localization/en/firmware_update.json | 10 ++ .../localization/en/gripper_wizard_flows.json | 2 +- app/src/assets/localization/en/index.ts | 2 + app/src/molecules/InstrumentCard/index.tsx | 39 +++-- .../equipmentImages.ts | 2 +- .../Devices/InstrumentsAndModules.tsx | 162 +++++++++++++++--- .../PipetteCard/AboutPipetteSlideout.tsx | 36 +++- .../__tests__/AboutPipetteSlideout.test.tsx | 31 ++++ .../organisms/Devices/PipetteCard/index.tsx | 1 + .../FirmwareUpdateTakeover.tsx | 39 +++++ .../UpdateInProgressModal.tsx | 59 +++++++ .../FirmwareUpdateModal/UpdateNeededModal.tsx | 99 +++++++++++ .../UpdateResultsModal.tsx | 111 ++++++++++++ .../__tests__/FirmwareUpdateModal.test.tsx | 8 +- .../__tests__/FirmwareUpdateTakeover.test.tsx | 73 ++++++++ .../__tests__/UpdateInProgressModal.test.tsx | 30 ++++ .../__tests__/UpdateNeededModal.test.tsx | 124 ++++++++++++++ .../__tests__/UpdateResultsModal.test.tsx | 49 ++++++ .../FirmwareUpdateModal/index.tsx | 20 ++- .../GripperCard/AboutGripperSlideout.tsx | 75 ++++++++ .../__tests__/AboutGripperSlideout.test.tsx | 40 +++++ .../__tests__/GripperCard.test.tsx | 131 ++++++++++++++ app/src/organisms/GripperCard/index.tsx | 27 ++- .../__tests__/MountGripper.test.tsx | 2 +- .../organisms/GripperWizardFlows/index.tsx | 2 +- .../__tests__/InstrumentInfo.test.tsx | 7 +- app/src/organisms/InstrumentInfo/index.tsx | 7 +- .../organisms/PipetteWizardFlows/index.tsx | 2 +- app/src/redux/pipettes/__fixtures__/index.ts | 1 + 35 files changed, 1142 insertions(+), 129 deletions(-) create mode 100644 app/src/assets/images/flex_gripper.png delete mode 100644 app/src/assets/images/flex_gripper.svg create mode 100644 app/src/assets/localization/en/firmware_update.json create mode 100644 app/src/organisms/FirmwareUpdateModal/FirmwareUpdateTakeover.tsx create mode 100644 app/src/organisms/FirmwareUpdateModal/UpdateInProgressModal.tsx create mode 100644 app/src/organisms/FirmwareUpdateModal/UpdateNeededModal.tsx create mode 100644 app/src/organisms/FirmwareUpdateModal/UpdateResultsModal.tsx rename app/src/{molecules => organisms}/FirmwareUpdateModal/__tests__/FirmwareUpdateModal.test.tsx (94%) create mode 100644 app/src/organisms/FirmwareUpdateModal/__tests__/FirmwareUpdateTakeover.test.tsx create mode 100644 app/src/organisms/FirmwareUpdateModal/__tests__/UpdateInProgressModal.test.tsx create mode 100644 app/src/organisms/FirmwareUpdateModal/__tests__/UpdateNeededModal.test.tsx create mode 100644 app/src/organisms/FirmwareUpdateModal/__tests__/UpdateResultsModal.test.tsx rename app/src/{molecules => organisms}/FirmwareUpdateModal/index.tsx (86%) create mode 100644 app/src/organisms/GripperCard/AboutGripperSlideout.tsx create mode 100644 app/src/organisms/GripperCard/__tests__/AboutGripperSlideout.test.tsx create mode 100644 app/src/organisms/GripperCard/__tests__/GripperCard.test.tsx diff --git a/api-client/src/instruments/__fixtures__/index.ts b/api-client/src/instruments/__fixtures__/index.ts index c4e514befd7..10e3753a885 100644 --- a/api-client/src/instruments/__fixtures__/index.ts +++ b/api-client/src/instruments/__fixtures__/index.ts @@ -22,6 +22,8 @@ export const instrumentsResponseFixture = { cursor: 0, totalLength: 1, }, + ok: true, + subsystem: 'gripper', }, ], } diff --git a/api-client/src/instruments/types.ts b/api-client/src/instruments/types.ts index c17500bd02f..0fb6d374ec9 100644 --- a/api-client/src/instruments/types.ts +++ b/api-client/src/instruments/types.ts @@ -8,6 +8,7 @@ export interface GripperData { last_modified?: string } } + firmwareVersion?: string instrumentModel: string instrumentType: 'gripper' mount: string @@ -26,6 +27,7 @@ export interface PipetteData { last_modified?: string } } + firmwareVersion?: string instrumentName: string instrumentModel: string instrumentType: 'pipette' diff --git a/app/src/App/OnDeviceDisplayApp.tsx b/app/src/App/OnDeviceDisplayApp.tsx index 4aa2264b570..dbe77eb3fc1 100644 --- a/app/src/App/OnDeviceDisplayApp.tsx +++ b/app/src/App/OnDeviceDisplayApp.tsx @@ -18,6 +18,7 @@ import { BackButton } from '../atoms/buttons' import { SleepScreen } from '../atoms/SleepScreen' import { ToasterOven } from '../organisms/ToasterOven' import { MaintenanceRunTakeover } from '../organisms/TakeoverModal' +import { FirmwareUpdateTakeover } from '../organisms/FirmwareUpdateModal/FirmwareUpdateTakeover' import { ConnectViaEthernet } from '../pages/OnDeviceDisplay/ConnectViaEthernet' import { ConnectViaUSB } from '../pages/OnDeviceDisplay/ConnectViaUSB' import { ConnectViaWifi } from '../pages/OnDeviceDisplay/ConnectViaWifi' @@ -253,6 +254,7 @@ export const OnDeviceDisplayApp = (): JSX.Element => { } }, [dispatch, isIdle, usersBrightness]) + // TODO (sb:6/12/23) Create a notification manager to set up preference and order of takeover modals return ( @@ -261,6 +263,7 @@ export const OnDeviceDisplayApp = (): JSX.Element => { ) : ( + diff --git a/app/src/assets/images/flex_gripper.png b/app/src/assets/images/flex_gripper.png new file mode 100644 index 0000000000000000000000000000000000000000..eaf67d85f38ab5b5c6d1b3cfd1eb0a1ec4a33e0d GIT binary patch literal 5888 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 MM?)V}sb&-MKaBzxga7~l literal 0 HcmV?d00001 diff --git a/app/src/assets/images/flex_gripper.svg b/app/src/assets/images/flex_gripper.svg deleted file mode 100644 index f4871edaa34..00000000000 --- a/app/src/assets/images/flex_gripper.svg +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/assets/localization/en/device_details.json b/app/src/assets/localization/en/device_details.json index d2482031711..0d93f5531c8 100644 --- a/app/src/assets/localization/en/device_details.json +++ b/app/src/assets/localization/en/device_details.json @@ -1,13 +1,17 @@ { + "about_flex_gripper": "About Flex Gripper", + "about_gripper": "About gripper", "about_module": "About {{name}}", "about_pipette_name": "About {{name}} Pipette", "about_pipette": "About pipette", "an_error_occurred_while_updating_module": "An error occurred while updating your {{moduleName}}. Please try again.", "an_error_occurred_while_updating_please_try_again": "An error occurred while updating your pipette's settings. Please try again.", "an_error_occurred_while_updating": "An error occurred while updating your pipette's settings.", + "attach_gripper": "Attach gripper", "attach_pipette": "Attach pipette", "both_mounts": "Both Mounts", "bundle_firmware_file_not_found": "Bundled fw file not found for module of type: {{module}}", + "calibrate_gripper": "Calibrate gripper", "calibrate_now": "Calibrate now", "calibrate_pipette_offset": "Calibrate pipette offset", "calibrate_pipette": "Calibrate pipette", @@ -24,6 +28,7 @@ "deck_cal_missing": "Pipette Offset calibration missing. Calibrate deck first.", "deck_slot": "deck slot {{slot}}", "delete_run": "Delete protocol run record", + "detach_gripper": "Detach gripper", "detach_pipette": "Detach pipette", "disengaged": "Disengaged", "download_run_log": "Download run log", @@ -31,6 +36,7 @@ "error_details": "error details", "failed": "failed", "firmware_update_available": "Firmware update available.", + "firmware_update_available_now": "Firmware update available. Update now", "firmware_update_failed": "Failed to update module firmware", "firmware_update_installation_successful": "Installation successful", "got_it": "Got it", @@ -41,6 +47,7 @@ "hot_to_the_touch": "Module is hot to the touch", "input_out_of_range": "Input out of range", "instruments_and_modules": "Instruments and Modules", + "instrument_attached": "Instrument attached", "labware_bottom": "Labware Bottom", "last_run_time": "last run {{number}}", "left_right": "Left+Right Mounts", @@ -89,6 +96,7 @@ "protocol": "Protocol", "ready_to_run": "ready to run", "ready": "Ready", + "recalibrate_gripper": "Recalibrate gripper", "recalibrate_now": "Recalibrate now", "recalibrate_pipette_offset": "Recalibrate pipette offset", "recalibrate_pipette": "Recalibrate pipette", diff --git a/app/src/assets/localization/en/firmware_update.json b/app/src/assets/localization/en/firmware_update.json new file mode 100644 index 00000000000..64c11d59190 --- /dev/null +++ b/app/src/assets/localization/en/firmware_update.json @@ -0,0 +1,10 @@ +{ + "download_logs": "Download the robot logs from the Opentrons App and send them to support@opentrons.com for assistance.", + "firmware_out_of_date": "The firmware for {{mount}} {{instrument}} is out of date. You need to update it before running protocols that use this instrument.", + "ready_to_use": "Your {{instrument}} is ready to use!", + "successful_update": "Successful update!", + "update_failed": "Update failed", + "update_firmware": "Update firmware", + "update_needed": "Instrument firmware update needed", + "updating_firmware": "Updating firmware..." +} diff --git a/app/src/assets/localization/en/gripper_wizard_flows.json b/app/src/assets/localization/en/gripper_wizard_flows.json index f0a258d3e9d..8a1ffe740e4 100644 --- a/app/src/assets/localization/en/gripper_wizard_flows.json +++ b/app/src/assets/localization/en/gripper_wizard_flows.json @@ -1,7 +1,7 @@ { "are_you_sure_exit": "Are you sure you want to exit before completing {{flow}}?", "attach_gripper": "Attach Gripper", - "attached_gripper_and_screw_in": "Attach the gripper to the robot by alinging the connector and ensuring a secure connection. Hold the gripper in place and use the hex screwdriver to tighten the gripper screws. Then test that the gripper is securely attached by gently pulling it side to side.", + "attached_gripper_and_screw_in": "Attach the gripper to the robot by aligning the connector and ensuring a secure connection. Hold the gripper in place and use the hex screwdriver to tighten the gripper screws. Then test that the gripper is securely attached by gently pulling it side to side.", "before_you_begin": "Before you begin", "begin_calibration": "Begin calibration", "calibrate_gripper": "Calibrate gripper", diff --git a/app/src/assets/localization/en/index.ts b/app/src/assets/localization/en/index.ts index 2cd942e36ab..f92edfc5510 100644 --- a/app/src/assets/localization/en/index.ts +++ b/app/src/assets/localization/en/index.ts @@ -5,6 +5,7 @@ import protocol_command_text from './protocol_command_text.json' import device_details from './device_details.json' import device_settings from './device_settings.json' import devices_landing from './devices_landing.json' +import firmware_update from './firmware_update.json' import gripper_wizard_flows from './gripper_wizard_flows.json' import heater_shaker from './heater_shaker.json' import instruments_dashboard from './instruments_dashboard.json' @@ -35,6 +36,7 @@ export const en = { device_details, device_settings, devices_landing, + firmware_update, gripper_wizard_flows, heater_shaker, instruments_dashboard, diff --git a/app/src/molecules/InstrumentCard/index.tsx b/app/src/molecules/InstrumentCard/index.tsx index f26334de3be..c00bb82c19b 100644 --- a/app/src/molecules/InstrumentCard/index.tsx +++ b/app/src/molecules/InstrumentCard/index.tsx @@ -14,7 +14,7 @@ import { SPACING, TYPOGRAPHY, } from '@opentrons/components' -import flexGripper from '../../assets/images/flex_gripper.svg' +import flexGripper from '../../assets/images/flex_gripper.png' import { useMenuHandleClickOutside } from '../../atoms/MenuList/hooks' import { OverflowBtn } from '../../atoms/MenuList/OverflowBtn' @@ -27,7 +27,7 @@ import type { MenuOverlayItemProps } from './MenuOverlay' interface InstrumentCardProps extends StyleProps { description: string label: string - menuOverlayItems: MenuOverlayItemProps[] + menuOverlayItems?: MenuOverlayItemProps[] hasDivider?: boolean instrumentDiagramProps?: InstrumentDiagramProps // special casing the gripper at least for now @@ -75,7 +75,7 @@ export function InstrumentCard(props: InstrumentCardProps): JSX.Element { width="3.75rem" height="3.75rem" > - + flex gripper ) : null} {instrumentDiagramProps?.pipetteSpecs != null ? ( @@ -106,21 +106,26 @@ export function InstrumentCard(props: InstrumentCardProps): JSX.Element { {description} - - - {menuOverlay} - {showOverflowMenu ? ( - + - ) : null} - + {menuOverlay} + {showOverflowMenu ? ( + + ) : null} + + )} ) } diff --git a/app/src/molecules/WizardRequiredEquipmentList/equipmentImages.ts b/app/src/molecules/WizardRequiredEquipmentList/equipmentImages.ts index 4e9063c4a35..12628a80222 100644 --- a/app/src/molecules/WizardRequiredEquipmentList/equipmentImages.ts +++ b/app/src/molecules/WizardRequiredEquipmentList/equipmentImages.ts @@ -8,5 +8,5 @@ export const equipmentImages = { flex_pipette: require('../../assets/images/change-pip/single_mount_pipettes.png'), pipette_96: require('../../assets/images/change-pip/ninety-six-channel.png'), mounting_plate_96_channel: require('../../assets/images/change-pip/mounting-plate-96-channel.png'), - flex_gripper: require('../../assets/images/flex_gripper.svg'), + flex_gripper: require('../../assets/images/flex_gripper.png'), } diff --git a/app/src/organisms/Devices/InstrumentsAndModules.tsx b/app/src/organisms/Devices/InstrumentsAndModules.tsx index fd2d05466ae..67ac5efd019 100644 --- a/app/src/organisms/Devices/InstrumentsAndModules.tsx +++ b/app/src/organisms/Devices/InstrumentsAndModules.tsx @@ -1,5 +1,6 @@ import * as React from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation, Trans } from 'react-i18next' +import { css } from 'styled-components' import { getPipetteModelSpecs, LEFT, RIGHT } from '@opentrons/shared-data' import { useAllPipetteOffsetCalibrationsQuery, @@ -10,6 +11,7 @@ import { import { Flex, + ModalShell, ALIGN_CENTER, ALIGN_FLEX_START, COLORS, @@ -22,8 +24,10 @@ import { import { StyledText } from '../../atoms/text' import { Banner } from '../../atoms/Banner' +import { InstrumentCard } from '../../molecules/InstrumentCard' import { useCurrentRunId } from '../ProtocolUpload/hooks' import { ModuleCard } from '../ModuleCard' +import { FirmwareUpdateModal } from '../FirmwareUpdateModal' import { useIsOT3, useIsRobotViewable, useRunStatuses } from './hooks' import { getIs96ChannelPipetteAttached, @@ -31,7 +35,13 @@ import { } from './utils' import { PipetteCard } from './PipetteCard' import { GripperCard } from '../GripperCard' -import type { GripperData, PipetteData } from '@opentrons/api-client' +import type { + BadGripper, + BadPipette, + GripperData, + PipetteData, + Subsystem, +} from '@opentrons/api-client' const EQUIPMENT_POLL_MS = 5000 const FETCH_PIPETTE_CAL_POLL = 30000 @@ -39,6 +49,12 @@ interface InstrumentsAndModulesProps { robotName: string } +const BANNER_LINK_CSS = css` + text-decoration: underline; + cursor: pointer; + margin-left: ${SPACING.spacing8}; +` + export function InstrumentsAndModules({ robotName, }: InstrumentsAndModulesProps): JSX.Element | null { @@ -53,6 +69,10 @@ export function InstrumentsAndModules({ const currentRunId = useCurrentRunId() const { isRunTerminal } = useRunStatuses() const isOT3 = useIsOT3(robotName) + const [ + subsystemToUpdate, + setSubsystemToUpdate, + ] = React.useState(null) const { data: attachedInstruments } = useInstrumentsQuery() // TODO(bc, 2023-03-20): reintroduce this poll, once it is safe to call cache_instruments during sensor reads on CAN bus @@ -61,16 +81,34 @@ export function InstrumentsAndModules({ (attachedInstruments?.data ?? []).find( (i): i is GripperData => i.instrumentType === 'gripper' && i.ok ) ?? null + const badGripper = + (attachedInstruments?.data ?? []).find( + (i): i is BadGripper => i.subsystem === 'gripper' && !i.ok + ) ?? null const attachedLeftPipette = attachedInstruments?.data?.find( (i): i is PipetteData => i.instrumentType === 'pipette' && i.ok && i.mount === 'left' ) ?? null + const badLeftPipette = + attachedInstruments?.data?.find( + (i): i is BadPipette => + i.instrumentType === 'pipette' && + !i.ok && + i.subsystem === 'pipette_left' + ) ?? null const attachedRightPipette = attachedInstruments?.data?.find( (i): i is PipetteData => i.instrumentType === 'pipette' && i.ok && i.mount === 'right' ) ?? null + const badRightPipette = + attachedInstruments?.data?.find( + (i): i is BadPipette => + i.instrumentType === 'pipette' && + !i.ok && + i.subsystem === 'pipette_right' + ) ?? null const is96ChannelAttached = getIs96ChannelPipetteAttached( attachedPipettes?.left ?? null ) @@ -112,6 +150,15 @@ export function InstrumentsAndModules({ flexDirection={DIRECTION_COLUMN} width="100%" > + {subsystemToUpdate != null && ( + + setSubsystemToUpdate(null)} + description={t('updating_firmware')} + /> + + )} - - {isOT3 ? ( + {badLeftPipette == null ? ( + + ) : ( + + + ), + }} + /> + + } + /> + )} + {isOT3 && badGripper == null && ( - ) : null} + )} + {isOT3 && badGripper != null && ( + + setSubsystemToUpdate('gripper')} + /> + ), + }} + /> + + } + /> + )} {leftColumnModules.map((module, index) => ( - {!Boolean(is96ChannelAttached) ? ( + {!Boolean(is96ChannelAttached) && badRightPipette == null && ( - ) : null} + )} + {badRightPipette != null && ( + + + ), + }} + /> + + } + /> + )} {rightColumnModules.map((module, index) => ( unknown isExpanded: boolean } @@ -24,8 +27,14 @@ interface AboutPipetteSlideoutProps { export const AboutPipetteSlideout = ( props: AboutPipetteSlideoutProps ): JSX.Element | null => { - const { pipetteId, pipetteName, isExpanded, onCloseClick } = props - const { t } = useTranslation(['device_details', 'shared']) + const { pipetteId, pipetteName, isExpanded, mount, onCloseClick } = props + const { i18n, t } = useTranslation(['device_details', 'shared']) + const { data: attachedInstruments } = useInstrumentsQuery() + const instrumentInfo = + attachedInstruments?.data?.find( + (i): i is PipetteData => + i.instrumentType === 'pipette' && i.ok && i.mount === mount + ) ?? null return ( - {t('shared:close')} + {i18n.format(t('shared:close'), 'capitalize')} } > + {instrumentInfo?.firmwareVersion != null && ( + <> + + {t('current_version')} + + + {instrumentInfo.firmwareVersion} + + + )} const render = (props: React.ComponentProps) => { return renderWithProviders(, { @@ -17,9 +25,13 @@ describe('AboutPipetteSlideout', () => { props = { pipetteId: '123', pipetteName: mockLeftSpecs.displayName, + mount: LEFT, isExpanded: true, onCloseClick: jest.fn(), } + mockUseInstrumentsQuery.mockReturnValue({ + data: { data: [] }, + } as any) }) afterEach(() => { jest.resetAllMocks() @@ -35,4 +47,23 @@ describe('AboutPipetteSlideout', () => { fireEvent.click(button) expect(props.onCloseClick).toHaveBeenCalled() }) + it('renders the firmware version if it exists', () => { + mockUseInstrumentsQuery.mockReturnValue({ + data: { + data: [ + { + instrumentType: 'pipette', + mount: LEFT, + ok: true, + firmwareVersion: 12, + } as any, + ], + }, + } as any) + + const { getByText } = render(props) + + getByText('Current Version') + getByText('12') + }) }) diff --git a/app/src/organisms/Devices/PipetteCard/index.tsx b/app/src/organisms/Devices/PipetteCard/index.tsx index 7303957b329..d6294b7ab7f 100644 --- a/app/src/organisms/Devices/PipetteCard/index.tsx +++ b/app/src/organisms/Devices/PipetteCard/index.tsx @@ -166,6 +166,7 @@ export const PipetteCard = (props: PipetteCardProps): JSX.Element => { setShowAboutSlideout(false)} isExpanded={true} /> diff --git a/app/src/organisms/FirmwareUpdateModal/FirmwareUpdateTakeover.tsx b/app/src/organisms/FirmwareUpdateModal/FirmwareUpdateTakeover.tsx new file mode 100644 index 00000000000..acb4ac5ce69 --- /dev/null +++ b/app/src/organisms/FirmwareUpdateModal/FirmwareUpdateTakeover.tsx @@ -0,0 +1,39 @@ +import * as React from 'react' +import { + useInstrumentsQuery, + useCurrentMaintenanceRun, +} from '@opentrons/react-api-client' +import { UpdateNeededModal } from './UpdateNeededModal' + +const INSTRUMENT_POLL_INTERVAL = 5000 + +export function FirmwareUpdateTakeover(): JSX.Element { + const [ + showUpdateNeededModal, + setShowUpdateNeededModal, + ] = React.useState(false) + const instrumentsData = useInstrumentsQuery({ + refetchInterval: INSTRUMENT_POLL_INTERVAL, + }).data?.data + const { data: maintenanceRunData } = useCurrentMaintenanceRun() + const subsystemUpdateInstrument = instrumentsData?.find( + instrument => instrument.ok === false + ) + + React.useEffect(() => { + if (subsystemUpdateInstrument != null && maintenanceRunData == null) { + setShowUpdateNeededModal(true) + } + }, [subsystemUpdateInstrument]) + + return ( + <> + {subsystemUpdateInstrument != null && showUpdateNeededModal ? ( + + ) : null} + + ) +} diff --git a/app/src/organisms/FirmwareUpdateModal/UpdateInProgressModal.tsx b/app/src/organisms/FirmwareUpdateModal/UpdateInProgressModal.tsx new file mode 100644 index 00000000000..9c66a775df1 --- /dev/null +++ b/app/src/organisms/FirmwareUpdateModal/UpdateInProgressModal.tsx @@ -0,0 +1,59 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { css } from 'styled-components' +import { + ALIGN_CENTER, + BORDERS, + COLORS, + DIRECTION_COLUMN, + Flex, + SPACING, + TYPOGRAPHY, +} from '@opentrons/components' +import { ProgressBar } from '../../atoms/ProgressBar' +import { StyledText } from '../../atoms/text' +import { Modal } from '../../molecules/Modal' + +interface UpdateInProgressModalProps { + percentComplete: number +} + +const OUTER_STYLES = css` + background: ${COLORS.medGreyEnabled}; + width: 100%; +` + +export function UpdateInProgressModal( + props: UpdateInProgressModalProps +): JSX.Element { + const { percentComplete } = props + const { i18n, t } = useTranslation('firmware_update') + + return ( + + + + {i18n.format(t('updating_firmware'), 'capitalize')} + + + + + ) +} diff --git a/app/src/organisms/FirmwareUpdateModal/UpdateNeededModal.tsx b/app/src/organisms/FirmwareUpdateModal/UpdateNeededModal.tsx new file mode 100644 index 00000000000..d1ff201287c --- /dev/null +++ b/app/src/organisms/FirmwareUpdateModal/UpdateNeededModal.tsx @@ -0,0 +1,99 @@ +import * as React from 'react' +import { useTranslation, Trans } from 'react-i18next' +import capitalize from 'lodash/capitalize' +import { COLORS, DIRECTION_COLUMN, Flex, SPACING } from '@opentrons/components' +import { + useInstrumentsQuery, + useSubsystemUpdateQuery, + useUpdateSubsystemMutation, +} from '@opentrons/react-api-client' +import { LEFT, RIGHT } from '@opentrons/shared-data' +import { Portal } from '../../App/portal' +import { SmallButton } from '../../atoms/buttons' +import { StyledText } from '../../atoms/text' +import { Modal } from '../../molecules/Modal' +import { UpdateInProgressModal } from './UpdateInProgressModal' +import { UpdateResultsModal } from './UpdateResultsModal' +import type { Subsystem } from '@opentrons/api-client' + +import type { ModalHeaderBaseProps } from '../../molecules/Modal/types' + +interface UpdateNeededModalProps { + setShowUpdateModal: React.Dispatch> + subsystem: Subsystem +} + +export function UpdateNeededModal(props: UpdateNeededModalProps): JSX.Element { + const { setShowUpdateModal, subsystem } = props + const { t } = useTranslation('firmware_update') + const [updateId, setUpdateId] = React.useState('') + const { + data: instrumentsData, + refetch: refetchInstruments, + } = useInstrumentsQuery() + const instrument = instrumentsData?.data.find( + instrument => instrument.subsystem === subsystem + ) + + const { updateSubsystem } = useUpdateSubsystemMutation({ + onSuccess: data => { + setUpdateId(data.data.id) + }, + }) + + const { data: updateData } = useSubsystemUpdateQuery(updateId) + const status = updateData?.data.updateStatus + const percentComplete = updateData?.data.updateProgress ?? 0 + const updateError = updateData?.data.updateError + const instrumentType = subsystem === 'gripper' ? 'gripper' : 'pipette' + let mount = '' + if (subsystem === 'pipette_left') mount = LEFT + else if (subsystem === 'pipette_right') mount = RIGHT + + const updateNeededHeader: ModalHeaderBaseProps = { + title: t('update_needed'), + iconName: 'ot-alert', + iconColor: COLORS.yellow2, + } + + let modalContent = ( + + + + , + }} + /> + + updateSubsystem(subsystem)} + buttonText={t('update_firmware')} + width="100%" + /> + + + ) + if (status === 'updating' || status === 'queued') { + modalContent = + } else if (status === 'done' || instrument?.ok) { + modalContent = ( + { + refetchInstruments().catch(error => console.error(error)) + setShowUpdateModal(false) + }} + /> + ) + } + + return {modalContent} +} diff --git a/app/src/organisms/FirmwareUpdateModal/UpdateResultsModal.tsx b/app/src/organisms/FirmwareUpdateModal/UpdateResultsModal.tsx new file mode 100644 index 00000000000..ec3666b46b0 --- /dev/null +++ b/app/src/organisms/FirmwareUpdateModal/UpdateResultsModal.tsx @@ -0,0 +1,111 @@ +import * as React from 'react' +import { useTranslation, Trans } from 'react-i18next' +import capitalize from 'lodash/capitalize' +import { + ALIGN_CENTER, + BORDERS, + COLORS, + DIRECTION_COLUMN, + Flex, + Icon, + SPACING, + TYPOGRAPHY, +} from '@opentrons/components' +import { SmallButton } from '../../atoms/buttons' +import { StyledText } from '../../atoms/text' +import { Modal } from '../../molecules/Modal' + +import type { InstrumentData } from '@opentrons/api-client' +import type { ModalHeaderBaseProps } from '../../molecules/Modal/types' + +interface UpdateResultsModalProps { + isSuccess: boolean + closeModal: () => void + instrument?: InstrumentData +} + +export function UpdateResultsModal( + props: UpdateResultsModalProps +): JSX.Element { + const { isSuccess, closeModal, instrument } = props + const { i18n, t } = useTranslation(['firmware_update', 'shared']) + + const updateFailedHeader: ModalHeaderBaseProps = { + title: t('update_failed'), + iconName: 'ot-alert', + iconColor: COLORS.red2, + } + + return ( + <> + {!isSuccess || instrument?.ok !== true ? ( + + + + {t('download_logs')} + + closeModal()} + buttonText={i18n.format(t('shared:close'), 'capitalize')} + width="100%" + /> + + + ) : ( + + + + + + {t('successful_update')} + + + , + }} + /> + + + closeModal()} + buttonText={i18n.format(t('shared:close'), 'capitalize')} + width="100%" + /> + + + )} + + ) +} diff --git a/app/src/molecules/FirmwareUpdateModal/__tests__/FirmwareUpdateModal.test.tsx b/app/src/organisms/FirmwareUpdateModal/__tests__/FirmwareUpdateModal.test.tsx similarity index 94% rename from app/src/molecules/FirmwareUpdateModal/__tests__/FirmwareUpdateModal.test.tsx rename to app/src/organisms/FirmwareUpdateModal/__tests__/FirmwareUpdateModal.test.tsx index baccdcf12d1..bac61418eb7 100644 --- a/app/src/molecules/FirmwareUpdateModal/__tests__/FirmwareUpdateModal.test.tsx +++ b/app/src/organisms/FirmwareUpdateModal/__tests__/FirmwareUpdateModal.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { waitFor } from '@testing-library/react' +import { act, waitFor } from '@testing-library/react' import { renderWithProviders } from '@opentrons/components' import { useInstrumentsQuery, @@ -7,7 +7,7 @@ import { useUpdateSubsystemMutation, } from '@opentrons/react-api-client' import { i18n } from '../../../i18n' -import { FirmwareUpdateModal } from '../' +import { FirmwareUpdateModal } from '..' import { BadPipette, PipetteData, @@ -95,9 +95,13 @@ describe('FirmwareUpdateModal', () => { expect(updateSubsystem).toHaveBeenCalled() }) it('calls refetch instruments and then proceed once update is complete', async () => { + jest.useFakeTimers() const { getByText } = render(props) getByText('A firmware update is required, instrument is updating') await waitFor(() => expect(refetch).toHaveBeenCalled()) + act(() => { + jest.advanceTimersByTime(10000) + }) await waitFor(() => expect(props.proceed).toHaveBeenCalled()) }) }) diff --git a/app/src/organisms/FirmwareUpdateModal/__tests__/FirmwareUpdateTakeover.test.tsx b/app/src/organisms/FirmwareUpdateModal/__tests__/FirmwareUpdateTakeover.test.tsx new file mode 100644 index 00000000000..39f52210fcd --- /dev/null +++ b/app/src/organisms/FirmwareUpdateModal/__tests__/FirmwareUpdateTakeover.test.tsx @@ -0,0 +1,73 @@ +import * as React from 'react' +import { renderWithProviders } from '@opentrons/components' +import { + useInstrumentsQuery, + useCurrentMaintenanceRun, +} from '@opentrons/react-api-client' +import { i18n } from '../../../i18n' +import { FirmwareUpdateTakeover } from '../FirmwareUpdateTakeover' +import { UpdateNeededModal } from '../UpdateNeededModal' +import type { BadPipette, PipetteData } from '@opentrons/api-client' + +jest.mock('@opentrons/react-api-client') +jest.mock('../UpdateNeededModal') + +const mockUseInstrumentQuery = useInstrumentsQuery as jest.MockedFunction< + typeof useInstrumentsQuery +> +const mockUseCurrentMaintenanceRun = useCurrentMaintenanceRun as jest.MockedFunction< + typeof useCurrentMaintenanceRun +> +const mockUpdateNeededModal = UpdateNeededModal as jest.MockedFunction< + typeof UpdateNeededModal +> + +const render = () => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('FirmwareUpdateTakeover', () => { + beforeEach(() => { + mockUseInstrumentQuery.mockReturnValue({ + data: { + data: [ + { + subsystem: 'pipette_left', + ok: false, + } as BadPipette, + ], + }, + } as any) + mockUpdateNeededModal.mockReturnValue(<>Mock Update Needed Modal) + mockUseCurrentMaintenanceRun.mockReturnValue({ data: undefined } as any) + }) + it('renders update needed modal when an instrument is not ok', () => { + const { getByText } = render() + getByText('Mock Update Needed Modal') + }) + it('does not render modal when no update is needed', () => { + mockUseInstrumentQuery.mockReturnValue({ + data: { + data: [ + { + subsystem: 'pipette_left', + ok: true, + } as PipetteData, + ], + }, + } as any) + const { queryByText } = render() + expect(queryByText('Mock Update In Progress Modal')).not.toBeInTheDocument() + }) + it('does not render modal when a maintenance run is active', () => { + mockUseCurrentMaintenanceRun.mockReturnValue({ + data: { + runId: 'mock run id', + }, + } as any) + const { queryByText } = render() + expect(queryByText('Mock Update In Progress Modal')).not.toBeInTheDocument() + }) +}) diff --git a/app/src/organisms/FirmwareUpdateModal/__tests__/UpdateInProgressModal.test.tsx b/app/src/organisms/FirmwareUpdateModal/__tests__/UpdateInProgressModal.test.tsx new file mode 100644 index 00000000000..3fc096e69bb --- /dev/null +++ b/app/src/organisms/FirmwareUpdateModal/__tests__/UpdateInProgressModal.test.tsx @@ -0,0 +1,30 @@ +import * as React from 'react' +import { renderWithProviders } from '@opentrons/components' +import { i18n } from '../../../i18n' +import { ProgressBar } from '../../../atoms/ProgressBar' +import { UpdateInProgressModal } from '../UpdateInProgressModal' + +jest.mock('../../../atoms/ProgressBar') + +const mockProgressBar = ProgressBar as jest.MockedFunction + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('UpdateInProgressModal', () => { + let props: React.ComponentProps + beforeEach(() => { + props = { + percentComplete: 12, + } + mockProgressBar.mockReturnValue('12' as any) + }) + it('renders test and progress bar', () => { + const { getByText } = render(props) + getByText('Updating firmware...') + getByText('12') + }) +}) diff --git a/app/src/organisms/FirmwareUpdateModal/__tests__/UpdateNeededModal.test.tsx b/app/src/organisms/FirmwareUpdateModal/__tests__/UpdateNeededModal.test.tsx new file mode 100644 index 00000000000..178d237e31b --- /dev/null +++ b/app/src/organisms/FirmwareUpdateModal/__tests__/UpdateNeededModal.test.tsx @@ -0,0 +1,124 @@ +import * as React from 'react' +import { nestedTextMatcher, renderWithProviders } from '@opentrons/components' +import { + useInstrumentsQuery, + useSubsystemUpdateQuery, + useUpdateSubsystemMutation, +} from '@opentrons/react-api-client' +import { i18n } from '../../../i18n' +import { UpdateNeededModal } from '../UpdateNeededModal' +import { UpdateInProgressModal } from '../UpdateInProgressModal' +import { UpdateResultsModal } from '../UpdateResultsModal' +import type { + BadPipette, + SubsystemUpdateProgressData, +} from '@opentrons/api-client' + +jest.mock('@opentrons/react-api-client') +jest.mock('../UpdateInProgressModal') +jest.mock('../UpdateResultsModal') + +const mockUseInstrumentQuery = useInstrumentsQuery as jest.MockedFunction< + typeof useInstrumentsQuery +> +const mockUseSubsystemUpdateQuery = useSubsystemUpdateQuery as jest.MockedFunction< + typeof useSubsystemUpdateQuery +> +const mockUseUpdateSubsystemMutation = useUpdateSubsystemMutation as jest.MockedFunction< + typeof useUpdateSubsystemMutation +> +const mockUpdateInProgressModal = UpdateInProgressModal as jest.MockedFunction< + typeof UpdateInProgressModal +> +const mockUpdateResultsModal = UpdateResultsModal as jest.MockedFunction< + typeof UpdateResultsModal +> + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('UpdateNeededModal', () => { + let props: React.ComponentProps + const refetch = jest.fn(() => Promise.resolve()) + const updateSubsystem = jest.fn(() => Promise.resolve()) + beforeEach(() => { + props = { + setShowUpdateModal: jest.fn(), + subsystem: 'pipette_left', + } + mockUseInstrumentQuery.mockReturnValue({ + data: { + data: [ + { + subsystem: 'pipette_left', + ok: false, + } as BadPipette, + ], + }, + refetch, + } as any) + mockUseSubsystemUpdateQuery.mockReturnValue({ + data: { + data: { + id: 'update id', + updateStatus: 'updating', + } as any, + } as SubsystemUpdateProgressData, + } as any) + mockUseUpdateSubsystemMutation.mockReturnValue({ + data: { + data: { + id: 'update id', + updateStatus: 'updating', + updateProgress: 20, + } as any, + } as SubsystemUpdateProgressData, + updateSubsystem, + } as any) + mockUpdateInProgressModal.mockReturnValue( + <>Mock Update In Progress Modal + ) + mockUpdateResultsModal.mockReturnValue(<>Mock Update Results Modal) + }) + it('renders update needed info and calles update firmware when button pressed', () => { + mockUseSubsystemUpdateQuery.mockReturnValue({} as any) + const { getByText } = render(props) + getByText('Instrument firmware update needed') + getByText( + nestedTextMatcher( + 'The firmware for Left Pipette is out of date. You need to update it before running protocols that use this instrument' + ) + ) + getByText('Update firmware').click() + expect(mockUseUpdateSubsystemMutation).toHaveBeenCalled() + }) + it('renders the update in progress modal when update is pending', () => { + const { getByText } = render(props) + getByText('Mock Update In Progress Modal') + }) + it('renders the update results modal when update is done', () => { + mockUseSubsystemUpdateQuery.mockReturnValue({ + data: { + data: { + id: 'update id', + updateStatus: 'done', + } as any, + } as SubsystemUpdateProgressData, + } as any) + mockUseUpdateSubsystemMutation.mockReturnValue({ + data: { + data: { + id: 'update id', + updateStatus: 'done', + updateProgress: 100, + } as any, + } as SubsystemUpdateProgressData, + updateSubsystem, + } as any) + const { getByText } = render(props) + getByText('Mock Update Results Modal') + }) +}) diff --git a/app/src/organisms/FirmwareUpdateModal/__tests__/UpdateResultsModal.test.tsx b/app/src/organisms/FirmwareUpdateModal/__tests__/UpdateResultsModal.test.tsx new file mode 100644 index 00000000000..b3cb2156a7d --- /dev/null +++ b/app/src/organisms/FirmwareUpdateModal/__tests__/UpdateResultsModal.test.tsx @@ -0,0 +1,49 @@ +import * as React from 'react' +import { renderWithProviders, nestedTextMatcher } from '@opentrons/components' +import { i18n } from '../../../i18n' +import { UpdateResultsModal } from '../UpdateResultsModal' + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('UpdateResultsModal', () => { + let props: React.ComponentProps + beforeEach(() => { + props = { + isSuccess: true, + closeModal: jest.fn(), + instrument: { + ok: true, + subsystem: 'gripper', + instrumentModel: 'gripper', + } as any, + } + }) + it('renders correct text for a successful instrument update', () => { + const { getByText } = render(props) + getByText('Successful update!') + getByText(nestedTextMatcher('Your Gripper is ready to use!')) + }) + it('calls close modal when the close button is pressed', () => { + const { getByText } = render(props) + getByText('Close').click() + expect(props.closeModal).toHaveBeenCalled() + }) + it('renders correct text for a failed instrument update', () => { + props = { + isSuccess: false, + closeModal: jest.fn(), + instrument: { + ok: false, + } as any, + } + const { getByText } = render(props) + getByText('Update failed') + getByText( + 'Download the robot logs from the Opentrons App and send them to support@opentrons.com for assistance.' + ) + }) +}) diff --git a/app/src/molecules/FirmwareUpdateModal/index.tsx b/app/src/organisms/FirmwareUpdateModal/index.tsx similarity index 86% rename from app/src/molecules/FirmwareUpdateModal/index.tsx rename to app/src/organisms/FirmwareUpdateModal/index.tsx index f575e180a49..c7fa737d399 100644 --- a/app/src/molecules/FirmwareUpdateModal/index.tsx +++ b/app/src/organisms/FirmwareUpdateModal/index.tsx @@ -66,7 +66,7 @@ export const FirmwareUpdateModal = ( const { data: attachedInstruments, refetch: refetchInstruments, - } = useInstrumentsQuery() + } = useInstrumentsQuery({ refetchInterval: 5000 }) const { updateSubsystem } = useUpdateSubsystemMutation({ onSuccess: data => { setUpdateId(data.data.id) @@ -88,16 +88,22 @@ export const FirmwareUpdateModal = ( const percentComplete = updateData?.data.updateProgress ?? 0 React.useEffect(() => { - if (status === 'done') { + if (status === 'done' && updateNeeded) { refetchInstruments() .then(() => { - proceed() - }) - .catch(() => { - proceed() + if (!updateNeeded) proceed() + else { + setTimeout(() => { + proceed() + }, 10000) + } }) + .catch(error => console.error(error.message)) + } else if (status === 'done' && !updateNeeded) { + proceed() } - }, [status, proceed, refetchInstruments]) + }, [status, proceed, refetchInstruments, updateNeeded]) + return ( {description} diff --git a/app/src/organisms/GripperCard/AboutGripperSlideout.tsx b/app/src/organisms/GripperCard/AboutGripperSlideout.tsx new file mode 100644 index 00000000000..aa325526003 --- /dev/null +++ b/app/src/organisms/GripperCard/AboutGripperSlideout.tsx @@ -0,0 +1,75 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { + Flex, + DIRECTION_COLUMN, + SPACING, + PrimaryButton, + TYPOGRAPHY, + COLORS, +} from '@opentrons/components' +import { StyledText } from '../../atoms/text' +import { Slideout } from '../../atoms/Slideout' + +interface AboutGripperSlideoutProps { + serialNumber: string + firmwareVersion?: string + onCloseClick: () => unknown + isExpanded: boolean +} + +export const AboutGripperSlideout = ( + props: AboutGripperSlideoutProps +): JSX.Element | null => { + const { serialNumber, firmwareVersion, isExpanded, onCloseClick } = props + const { i18n, t } = useTranslation(['device_details', 'shared']) + + return ( + + {i18n.format(t('shared:close'), 'capitalize')} + + } + > + + {firmwareVersion != null && ( + <> + + {t('current_version')} + + + {firmwareVersion} + + + )} + + {i18n.format(t('serial_number'), 'upperCase')} + + + {serialNumber} + + + + ) +} diff --git a/app/src/organisms/GripperCard/__tests__/AboutGripperSlideout.test.tsx b/app/src/organisms/GripperCard/__tests__/AboutGripperSlideout.test.tsx new file mode 100644 index 00000000000..8b457b50116 --- /dev/null +++ b/app/src/organisms/GripperCard/__tests__/AboutGripperSlideout.test.tsx @@ -0,0 +1,40 @@ +import * as React from 'react' +import { renderWithProviders } from '@opentrons/components' +import { fireEvent } from '@testing-library/react' +import { i18n } from '../../../i18n' +import { AboutGripperSlideout } from '../AboutGripperSlideout' + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('AboutGripperSlideout', () => { + let props: React.ComponentProps + beforeEach(() => { + props = { + serialNumber: '123', + isExpanded: true, + onCloseClick: jest.fn(), + } + }) + + it('renders correct info', () => { + const { getByText, getByRole } = render(props) + + getByText('About Flex Gripper') + getByText('123') + getByText('SERIAL NUMBER') + const button = getByRole('button', { name: /exit/i }) + fireEvent.click(button) + expect(props.onCloseClick).toHaveBeenCalled() + }) + it('renders the firmware version if it exists', () => { + props = { ...props, firmwareVersion: '12' } + const { getByText } = render(props) + + getByText('Current Version') + getByText('12') + }) +}) diff --git a/app/src/organisms/GripperCard/__tests__/GripperCard.test.tsx b/app/src/organisms/GripperCard/__tests__/GripperCard.test.tsx new file mode 100644 index 00000000000..1774bc0b68b --- /dev/null +++ b/app/src/organisms/GripperCard/__tests__/GripperCard.test.tsx @@ -0,0 +1,131 @@ +import * as React from 'react' +import { resetAllWhenMocks } from 'jest-when' +import { renderWithProviders } from '@opentrons/components' +import { fireEvent } from '@testing-library/react' +import { i18n } from '../../../i18n' +import { Banner } from '../../../atoms/Banner' +import { GripperWizardFlows } from '../../GripperWizardFlows' +import { AboutGripperSlideout } from '../AboutGripperSlideout' +import { GripperCard } from '../' +import type { GripperData } from '@opentrons/api-client' + +jest.mock('../../../atoms/Banner') +jest.mock('../../GripperWizardFlows') +jest.mock('../AboutGripperSlideout') + +const mockBanner = Banner as jest.MockedFunction +const mockGripperWizardFlows = GripperWizardFlows as jest.MockedFunction< + typeof GripperWizardFlows +> +const mockAboutGripperSlideout = AboutGripperSlideout as jest.MockedFunction< + typeof AboutGripperSlideout +> + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('GripperCard', () => { + let props: React.ComponentProps + beforeEach(() => { + props = { + attachedGripper: { + instrumentModel: 'gripperV1.1', + serialNumber: '123', + firmwareVersion: '12', + data: { + calibratedOffset: { + last_modified: '12/2/4', + }, + }, + } as GripperData, + isCalibrated: true, + } + mockBanner.mockReturnValue(<>calibration needed) + mockGripperWizardFlows.mockReturnValue(<>wizard flow launched) + mockAboutGripperSlideout.mockReturnValue(<>about gripper) + }) + afterEach(() => { + jest.resetAllMocks() + resetAllWhenMocks() + }) + + it('renders correct info when gripper is attached', () => { + const { getByText, getByRole } = render(props) + const image = getByRole('img', { name: 'flex gripper' }) + expect(image.getAttribute('src')).toEqual('flex_gripper.png') + getByText('extension mount') + getByText('Flex Gripper') + const overflowButton = getByRole('button', { + name: /overflow/i, + }) + fireEvent.click(overflowButton) + getByText('Recalibrate gripper') + getByText('Detach gripper') + getByText('About gripper') + }) + it('renders recalibrate banner when no calibration data is present', () => { + props = props = { + attachedGripper: { + instrumentModel: 'gripperV1.1', + serialNumber: '123', + firmwareVersion: '12', + data: { + calibratedOffset: { + last_modified: undefined, + }, + }, + } as GripperData, + isCalibrated: false, + } + + const { getByText } = render(props) + getByText('calibration needed') + }) + it('opens the about gripper slideout when button is pressed', () => { + const { getByText, getByRole } = render(props) + const overflowButton = getByRole('button', { + name: /overflow/i, + }) + overflowButton.click() + const aboutGripperButton = getByText('About gripper') + aboutGripperButton.click() + getByText('about gripper') + }) + it('renders wizard flow when recalibrate button is pressed', () => { + const { getByText, getByRole } = render(props) + const overflowButton = getByRole('button', { + name: /overflow/i, + }) + overflowButton.click() + const recalibrateGripperButton = getByText('Recalibrate gripper') + recalibrateGripperButton.click() + getByText('wizard flow launched') + }) + it('renders wizard flow when detach button is pressed', () => { + const { getByText, getByRole } = render(props) + const overflowButton = getByRole('button', { + name: /InstrumentCard_overflowMenu/i, + }) + overflowButton.click() + const detachGripperButton = getByText('Detach gripper') + detachGripperButton.click() + getByText('wizard flow launched') + }) + it('renders wizard flow when attach button is pressed', () => { + props = { + attachedGripper: null, + isCalibrated: false, + } + const { getByText, getByRole } = render(props) + const overflowButton = getByRole('button', { + name: /overflow/i, + }) + overflowButton.click() + const attachGripperButton = getByText('Attach gripper') + attachGripperButton.click() + getByText('wizard flow launched') + }) +}) diff --git a/app/src/organisms/GripperCard/index.tsx b/app/src/organisms/GripperCard/index.tsx index 2fe8469faa9..d0b1ca936cb 100644 --- a/app/src/organisms/GripperCard/index.tsx +++ b/app/src/organisms/GripperCard/index.tsx @@ -8,6 +8,7 @@ import { Banner } from '../../atoms/Banner' import { StyledText } from '../../atoms/text' import { InstrumentCard } from '../../molecules/InstrumentCard' import { GripperWizardFlows } from '../GripperWizardFlows' +import { AboutGripperSlideout } from './AboutGripperSlideout' import { GRIPPER_FLOW_TYPES } from '../GripperWizardFlows/constants' import type { GripperWizardFlowType } from '../GripperWizardFlows/types' @@ -25,6 +26,10 @@ export function GripperCard({ openWizardFlowType, setOpenWizardFlowType, ] = React.useState(null) + const [ + showAboutGripperSlideout, + setShowAboutGripperSlideout, + ] = React.useState(false) const handleAttach: React.MouseEventHandler = () => { setOpenWizardFlowType(GRIPPER_FLOW_TYPES.ATTACH) @@ -42,22 +47,30 @@ export function GripperCard({ attachedGripper == null ? [ { - label: 'Attach gripper', + label: t('attach_gripper'), disabled: attachedGripper != null, onClick: handleAttach, }, ] : [ { - label: 'Recalibrate gripper', + label: + attachedGripper.data.calibratedOffset?.last_modified != null + ? t('recalibrate_gripper') + : t('calibrate_gripper'), disabled: attachedGripper == null, onClick: handleCalibrate, }, { - label: 'Detach gripper', + label: t('detach_gripper'), disabled: attachedGripper == null, onClick: handleDetach, }, + { + label: t('about_gripper'), + disabled: attachedGripper == null, + onClick: () => setShowAboutGripperSlideout(true), + }, ] return ( <> @@ -103,6 +116,14 @@ export function GripperCard({ closeFlow={() => setOpenWizardFlowType(null)} /> ) : null} + {attachedGripper != null && showAboutGripperSlideout && ( + setShowAboutGripperSlideout(false)} + /> + )} ) } diff --git a/app/src/organisms/GripperWizardFlows/__tests__/MountGripper.test.tsx b/app/src/organisms/GripperWizardFlows/__tests__/MountGripper.test.tsx index acbb27cafc2..ded46f676fe 100644 --- a/app/src/organisms/GripperWizardFlows/__tests__/MountGripper.test.tsx +++ b/app/src/organisms/GripperWizardFlows/__tests__/MountGripper.test.tsx @@ -101,7 +101,7 @@ describe('MountGripper', () => { const { getByText } = render()[0] getByText('Connect and Screw In Gripper') getByText( - 'Attach the gripper to the robot by alinging the connector and ensuring a secure connection. Hold the gripper in place and use the hex screwdriver to tighten the gripper screws. Then test that the gripper is securely attached by gently pulling it side to side.' + 'Attach the gripper to the robot by aligning the connector and ensuring a secure connection. Hold the gripper in place and use the hex screwdriver to tighten the gripper screws. Then test that the gripper is securely attached by gently pulling it side to side.' ) }) }) diff --git a/app/src/organisms/GripperWizardFlows/index.tsx b/app/src/organisms/GripperWizardFlows/index.tsx index b8f406557c5..56e8c427571 100644 --- a/app/src/organisms/GripperWizardFlows/index.tsx +++ b/app/src/organisms/GripperWizardFlows/index.tsx @@ -17,7 +17,7 @@ import { import { LegacyModalShell } from '../../molecules/LegacyModal' import { Portal } from '../../App/portal' import { WizardHeader } from '../../molecules/WizardHeader' -import { FirmwareUpdateModal } from '../../molecules/FirmwareUpdateModal' +import { FirmwareUpdateModal } from '../FirmwareUpdateModal' import { getIsOnDevice } from '../../redux/config' import { useChainMaintenanceCommands } from '../../resources/runs/hooks' import { getGripperWizardSteps } from './getGripperWizardSteps' diff --git a/app/src/organisms/InstrumentInfo/__tests__/InstrumentInfo.test.tsx b/app/src/organisms/InstrumentInfo/__tests__/InstrumentInfo.test.tsx index 7fc42beeb3f..a0b5f62fe33 100644 --- a/app/src/organisms/InstrumentInfo/__tests__/InstrumentInfo.test.tsx +++ b/app/src/organisms/InstrumentInfo/__tests__/InstrumentInfo.test.tsx @@ -41,6 +41,7 @@ const mockGripperData: GripperData = { source: 'mockSource', }, }, + firmwareVersion: '12', instrumentModel: 'gripperModel_v1', instrumentType: 'gripper', mount: 'extension', @@ -58,6 +59,7 @@ const mockGripperDataWithCalData: GripperData = { last_modified: 'mockLastModified', }, }, + firmwareVersion: '12', instrumentModel: 'gripperModel_v1', instrumentType: 'gripper', mount: 'extension', @@ -80,6 +82,7 @@ describe('InstrumentInfo', () => { getByText('last calibrated') getByText('No calibration data') getByText('firmware version') + getByText('12') getByText('serial number') getByText('123') getByRole('button', { name: 'MediumButton_secondary' }).click() @@ -96,6 +99,7 @@ describe('InstrumentInfo', () => { getByText('last calibrated') getByText('mockLastModified') getByText('firmware version') + getByText('12') getByText('serial number') getByText('123') getByRole('button', { name: 'MediumButton_secondary' }).click() @@ -104,14 +108,13 @@ describe('InstrumentInfo', () => { getByText('mock GripperWizardFlows') }) - it('returns the correct information for a pipette with cal data', () => { + it('returns the correct information for a pipette with cal data and no firmware version', () => { props = { instrument: mockPipetteData1Channel, } const { getByText, getByRole } = render(props) getByText('last calibrated') getByText('08/25/2020 20:25:00') - getByText('firmware version') getByText('serial number') getByText('abc') getByRole('button', { name: 'MediumButton_secondary' }).click() diff --git a/app/src/organisms/InstrumentInfo/index.tsx b/app/src/organisms/InstrumentInfo/index.tsx index 74df7028312..ffbf24fae1a 100644 --- a/app/src/organisms/InstrumentInfo/index.tsx +++ b/app/src/organisms/InstrumentInfo/index.tsx @@ -133,7 +133,12 @@ export const InstrumentInfo = (props: InstrumentInfoProps): JSX.Element => { : i18n.format(t('no_cal_data'), 'capitalize') } /> - + {instrument.firmwareVersion != null && ( + + )}