diff --git a/app/src/assets/localization/en/device_settings.json b/app/src/assets/localization/en/device_settings.json index 8ecaf1a094f..9066277f49f 100644 --- a/app/src/assets/localization/en/device_settings.json +++ b/app/src/assets/localization/en/device_settings.json @@ -17,6 +17,7 @@ "calibrate_deck_description": "For pre-2019 robots that do not have crosses etched on the deck.", "calibrate_deck_to_dots": "Calibrate deck to dots", "calibrate_deck": "Calibrate deck", + "calibrate_gripper": "Calibrate gripper", "calibrate_now": "Calibrate now", "calibrate_pipette": "Calibrate Pipette Offset", "calibration_health_check_description": "Check the accuracy of key calibration points without recalibrating the robot.", @@ -121,8 +122,9 @@ "firmware_version": "Firmware Version", "fully_calibrate_before_checking_health": "Fully calibrate your robot before checking calibration health", "go_to_advanced_settings": "Go to Advanced App Settings", - "gripper_calibration_description": "Placeholder for gripper calibration section", + "gripper_calibration_description": "Gripper calibration uses a metal pin to determine the gripper's exact position relative to precision-cut squares on deck slots.", "gripper_calibration_title": "Gripper Calibration", + "gripper_serial": "Gripper Serial", "health_check": "Check health", "hide": "Hide", "historic_offsets_description": "Use stored data when setting up a protocol.", @@ -187,6 +189,7 @@ "proceed_without_updating": "Proceed without updating", "protocol_run_history": "Protocol run History", "recalibrate_deck": "Recalibrate deck", + "recalibrate_gripper": "Recalibrate gripper", "recalibrate_now": "Recalibrate now", "recalibrate_pipette": "Recalibrate Pipette Offset", "recalibrate_tip_and_pipette": "Recalibrate Tip Length and Pipette Offset", diff --git a/app/src/molecules/InstrumentCard/index.tsx b/app/src/molecules/InstrumentCard/index.tsx index c00bb82c19b..f197d47059f 100644 --- a/app/src/molecules/InstrumentCard/index.tsx +++ b/app/src/molecules/InstrumentCard/index.tsx @@ -69,12 +69,7 @@ export function InstrumentCard(props: InstrumentCardProps): JSX.Element { {...styleProps} > {isGripperAttached ? ( - + flex gripper ) : null} diff --git a/app/src/organisms/Devices/RobotCard.tsx b/app/src/organisms/Devices/RobotCard.tsx index 477ef04ef1a..6efe08579fb 100644 --- a/app/src/organisms/Devices/RobotCard.tsx +++ b/app/src/organisms/Devices/RobotCard.tsx @@ -20,6 +20,7 @@ import { WRAP, } from '@opentrons/components' import { + GripperModel, getGripperDisplayName, getModuleDisplayName, getPipetteModelSpecs, @@ -169,8 +170,8 @@ function AttachedInstruments(props: { robotName: string }): JSX.Element { const leftPipetteModel = pipettesData?.left?.model ?? null const rightPipetteModel = pipettesData?.right?.model ?? null const gripperDisplayName = - attachedGripper != null && attachedGripper.instrumentModel === 'gripperV1' - ? getGripperDisplayName(attachedGripper.instrumentModel) + attachedGripper != null + ? getGripperDisplayName(attachedGripper.instrumentModel as GripperModel) : null // TODO(bh, 2022-11-1): insert actual 96-channel data diff --git a/app/src/organisms/RobotSettingsCalibration/RobotSettingsGripperCalibration.tsx b/app/src/organisms/RobotSettingsCalibration/RobotSettingsGripperCalibration.tsx index 5f29a3d3508..33b4ba2a1cc 100644 --- a/app/src/organisms/RobotSettingsCalibration/RobotSettingsGripperCalibration.tsx +++ b/app/src/organisms/RobotSettingsCalibration/RobotSettingsGripperCalibration.tsx @@ -1,32 +1,148 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' - +import styled, { css } from 'styled-components' import { Box, Flex, ALIGN_CENTER, - JUSTIFY_SPACE_BETWEEN, + COLORS, + BORDERS, SPACING, TYPOGRAPHY, + DIRECTION_COLUMN, + POSITION_RELATIVE, + ALIGN_FLEX_END, + POSITION_ABSOLUTE, + useOnClickOutside, } from '@opentrons/components' - +import { useMenuHandleClickOutside } from '../../atoms/MenuList/hooks' +import { OverflowBtn } from '../../atoms/MenuList/OverflowBtn' +import { MenuItem } from '../../atoms/MenuList/MenuItem' import { StyledText } from '../../atoms/text' +import { GripperWizardFlows } from '../../organisms/GripperWizardFlows' +import { formatLastCalibrated } from './CalibrationDetails/utils' +import type { GripperData } from '@opentrons/api-client' -export function RobotSettingsGripperCalibration(): JSX.Element { - const { t } = useTranslation('device_settings') +const StyledTable = styled.table` + width: 100%; + border-collapse: collapse; + text-align: ${TYPOGRAPHY.textAlignLeft}; +` +const StyledTableHeader = styled.th` + ${TYPOGRAPHY.labelSemiBold} + padding: ${SPACING.spacing8}; +` +const StyledTableRow = styled.tr` + padding: ${SPACING.spacing8}; + border-bottom: ${BORDERS.lineBorder}; +` +const StyledTableCell = styled.td` + padding: ${SPACING.spacing8}; + text-overflow: wrap; +` + +const BODY_STYLE = css` + box-shadow: 0 0 0 1px ${COLORS.medGreyEnabled}; + border-radius: 3px; +` +export interface RobotSettingsGripperCalibrationProps { + gripper: GripperData +} + +export function RobotSettingsGripperCalibration( + props: RobotSettingsGripperCalibrationProps +): JSX.Element { + const { t } = useTranslation('device_settings') + const { gripper } = props + const { + menuOverlay, + handleOverflowClick, + showOverflowMenu, + setShowOverflowMenu, + } = useMenuHandleClickOutside() + const calsOverflowWrapperRef = useOnClickOutside({ + onClickOutside: () => setShowOverflowMenu(false), + }) + const [showWizardFlow, setShowWizardFlow] = React.useState(false) + const gripperCalibrationLastModified = + gripper.data.calibratedOffset?.last_modified return ( - - - - {t('gripper_calibration_title')} - - - {t('gripper_calibration_description')} - - - + + {t('gripper_calibration_title')} + + + {t('gripper_calibration_description')} + + + + + {t('gripper_serial')} + {t('last_calibrated_label')} + + + + + + {gripper.serialNumber} + + + + {gripperCalibrationLastModified != null ? ( + + {formatLastCalibrated(gripperCalibrationLastModified)} + + ) : ( + {t('not_calibrated_short')} + )} + + + + + + {showWizardFlow ? ( + setShowWizardFlow(false)} + /> + ) : null} + {showOverflowMenu ? ( + + setShowWizardFlow(true)}> + {t( + gripperCalibrationLastModified == null + ? 'calibrate_gripper' + : 'recalibrate_gripper' + )} + + + ) : null} + {menuOverlay} + + + + + ) } diff --git a/app/src/organisms/RobotSettingsCalibration/__tests__/RobotSettingsCalibration.test.tsx b/app/src/organisms/RobotSettingsCalibration/__tests__/RobotSettingsCalibration.test.tsx index 1923d2f44ff..09d296a5958 100644 --- a/app/src/organisms/RobotSettingsCalibration/__tests__/RobotSettingsCalibration.test.tsx +++ b/app/src/organisms/RobotSettingsCalibration/__tests__/RobotSettingsCalibration.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { when, resetAllWhenMocks } from 'jest-when' import { renderWithProviders } from '@opentrons/components' - +import { useInstrumentsQuery } from '@opentrons/react-api-client' import { i18n } from '../../../i18n' import { CalibrationStatusCard } from '../../../organisms/CalibrationStatusCard' import { useFeatureFlag } from '../../../redux/config' @@ -36,6 +36,7 @@ import { RobotSettingsCalibration } from '..' import type { AttachedPipettesByMount } from '../../../redux/pipettes/types' +jest.mock('@opentrons/react-api-client/src/instruments/useInstrumentsQuery') jest.mock('../../../organisms/CalibrationStatusCard') jest.mock('../../../redux/config') jest.mock('../../../redux/sessions/selectors') @@ -55,6 +56,9 @@ const mockAttachedPipettes: AttachedPipettesByMount = { const mockUsePipetteOffsetCalibrations = usePipetteOffsetCalibrations as jest.MockedFunction< typeof usePipetteOffsetCalibrations > +const mockUseInstrumentsQuery = useInstrumentsQuery as jest.MockedFunction< + typeof useInstrumentsQuery +> const mockUseRobot = useRobot as jest.MockedFunction const mockUseAttachedPipettes = useAttachedPipettes as jest.MockedFunction< typeof useAttachedPipettes @@ -121,6 +125,16 @@ describe('RobotSettingsCalibration', () => { left: null, right: null, }) + mockUseInstrumentsQuery.mockReturnValue({ + data: { + data: [ + { + ok: true, + instrumentType: 'gripper', + } as any, + ], + }, + } as any) mockUsePipetteOffsetCalibrations.mockReturnValue([ mockPipetteOffsetCalibration1, mockPipetteOffsetCalibration2, diff --git a/app/src/organisms/RobotSettingsCalibration/__tests__/RobotSettingsGripperCalibration.test.tsx b/app/src/organisms/RobotSettingsCalibration/__tests__/RobotSettingsGripperCalibration.test.tsx index f110b113383..2ff8e23f95c 100644 --- a/app/src/organisms/RobotSettingsCalibration/__tests__/RobotSettingsGripperCalibration.test.tsx +++ b/app/src/organisms/RobotSettingsCalibration/__tests__/RobotSettingsGripperCalibration.test.tsx @@ -3,17 +3,91 @@ import * as React from 'react' import { renderWithProviders } from '@opentrons/components' import { i18n } from '../../../i18n' +import { GripperWizardFlows } from '../../../organisms/GripperWizardFlows' +import { formatLastCalibrated } from '../CalibrationDetails/utils' import { RobotSettingsGripperCalibration } from '../RobotSettingsGripperCalibration' +import type { GripperData } from '@opentrons/api-client' +import type { RobotSettingsGripperCalibrationProps } from '../RobotSettingsGripperCalibration' +jest.mock('../../../organisms/GripperWizardFlows') +jest.mock('../CalibrationDetails/utils') -const render = () => { - return renderWithProviders(, { +const mockGripperWizardFlows = GripperWizardFlows as jest.MockedFunction< + typeof GripperWizardFlows +> +const mockFormatLastCalibrated = formatLastCalibrated as jest.MockedFunction< + typeof formatLastCalibrated +> + +let props = { + gripper: { + serialNumber: 'mockSerial123', + data: { + calibratedOffset: { + last_modified: '12345', + }, + }, + } as GripperData, +} + +const render = (props: RobotSettingsGripperCalibrationProps) => { + return renderWithProviders(, { i18nInstance: i18n, }) } describe('RobotSettingsGripperCalibration', () => { + beforeEach(() => { + mockFormatLastCalibrated.mockReturnValue('last calibrated 1/2/3') + mockGripperWizardFlows.mockReturnValue(<>Mock Wizard Flow) + }) it('renders a title and description - Gripper Calibration section', () => { - const [{ getByText }] = render() + const [{ getByText }] = render(props) getByText('Gripper Calibration') + getByText( + `Gripper calibration uses a metal pin to determine the gripper's exact position relative to precision-cut squares on deck slots.` + ) + getByText('Gripper Serial') + getByText('Last Calibrated') + }) + it('renders last calibrated date and recalibrate button if calibration data exists', () => { + const [{ getByText, getByRole }] = render(props) + getByText('mockSerial123') + getByText('last calibrated 1/2/3') + const overflowButton = getByRole('button', { + name: 'CalibrationOverflowMenu_button_gripperCalibration', + }) + overflowButton.click() + getByText('Recalibrate gripper') + }) + it('renders not calibrated and calibrate button if calibration data does not exist', () => { + props = { + gripper: { + serialNumber: 'mockSerial123', + data: { + calibratedOffset: { + last_modified: undefined, + }, + }, + } as GripperData, + } + + const [{ getByText, getByRole }] = render(props) + getByText('mockSerial123') + getByText('Not calibrated') + const overflowButton = getByRole('button', { + name: 'CalibrationOverflowMenu_button_gripperCalibration', + }) + overflowButton.click() + getByText('Calibrate gripper') + }) + it('renders gripper wizard flows when calibrate is pressed', () => { + const [{ getByText, getByRole }] = render(props) + const overflowButton = getByRole('button', { + name: 'CalibrationOverflowMenu_button_gripperCalibration', + }) + overflowButton.click() + const calibrateButton = getByText('Calibrate gripper') + calibrateButton.click() + getByText('Mock Wizard Flow') }) }) diff --git a/app/src/organisms/RobotSettingsCalibration/index.tsx b/app/src/organisms/RobotSettingsCalibration/index.tsx index 086ad913fdd..1cfdc0c0fd4 100644 --- a/app/src/organisms/RobotSettingsCalibration/index.tsx +++ b/app/src/organisms/RobotSettingsCalibration/index.tsx @@ -6,6 +6,7 @@ import { useAllPipetteOffsetCalibrationsQuery, useAllTipLengthCalibrationsQuery, useCalibrationStatusQuery, + useInstrumentsQuery, } from '@opentrons/react-api-client' import { Portal } from '../../App/portal' @@ -16,7 +17,6 @@ import { CalibrationStatusCard } from '../../organisms/CalibrationStatusCard' import { CheckCalibration } from '../../organisms/CheckCalibration' import { useRobot, - useAttachedPipettes, useRunStatuses, useIsOT3, useAttachedPipettesFromInstrumentsQuery, @@ -33,6 +33,7 @@ import { RobotSettingsGripperCalibration } from './RobotSettingsGripperCalibrati import { RobotSettingsPipetteOffsetCalibration } from './RobotSettingsPipetteOffsetCalibration' import { RobotSettingsTipLengthCalibration } from './RobotSettingsTipLengthCalibration' +import type { GripperData } from '@opentrons/api-client' import type { Mount } from '@opentrons/components' import type { RequestState } from '../../redux/robot-api/types' import type { @@ -118,17 +119,23 @@ export function RobotSettingsCalibration({ } ) - const pipetteOffsetCalibrations = useAllPipetteOffsetCalibrationsQuery().data - ?.data - const attachedPipettesFromInstrumentQuery = useAttachedPipettesFromInstrumentsQuery() - const attachedPipettes = useAttachedPipettes() + // Note: following fetch need to reflect the latest state of calibrations + // when a user does calibration or rename a robot. + useCalibrationStatusQuery({ refetchInterval: CALS_FETCH_MS }) + useAllTipLengthCalibrationsQuery({ refetchInterval: CALS_FETCH_MS }) + const pipetteOffsetCalibrations = + useAllPipetteOffsetCalibrationsQuery({ refetchInterval: CALS_FETCH_MS }) + .data?.data ?? [] + const attachedInstruments = + useInstrumentsQuery({ refetchInterval: CALS_FETCH_MS }).data?.data ?? [] + const attachedGripper = + (attachedInstruments ?? []).find( + (i): i is GripperData => i.instrumentType === 'gripper' && i.ok + ) ?? null + const attachedPipettes = useAttachedPipettesFromInstrumentsQuery() const { isRunRunning: isRunning } = useRunStatuses() - const pipettePresentOt2 = + const pipettePresent = !(attachedPipettes.left == null) || !(attachedPipettes.right == null) - const pipettePresentOt3 = - !(attachedPipettesFromInstrumentQuery.left == null) || - !(attachedPipettesFromInstrumentQuery.right == null) - const pipettePresent = isOT3 ? pipettePresentOt3 : pipettePresentOt2 const isPending = useSelector(state => @@ -186,49 +193,47 @@ export function RobotSettingsCalibration({ if (!isOT3 && attachedPipettes != null) { formattedPipetteOffsetCalibrations.push({ - modelName: attachedPipettes.left?.modelSpecs?.displayName, - serialNumber: attachedPipettes.left?.id, + modelName: attachedPipettes.left?.displayName, + serialNumber: attachedPipettes.left?.serialNumber, mount: 'left' as Mount, tiprack: pipetteOffsetCalibrations?.find( - p => p.pipette === attachedPipettes.left?.id + p => p.pipette === attachedPipettes.left?.serialNumber )?.tiprackUri, lastCalibrated: pipetteOffsetCalibrations?.find( - p => p.pipette === attachedPipettes.left?.id + p => p.pipette === attachedPipettes.left?.serialNumber )?.lastModified, markedBad: pipetteOffsetCalibrations?.find( - p => p.pipette === attachedPipettes.left?.id + p => p.pipette === attachedPipettes.left?.serialNumber )?.status.markedBad, }) formattedPipetteOffsetCalibrations.push({ - modelName: attachedPipettes.right?.modelSpecs?.displayName, - serialNumber: attachedPipettes.right?.id, + modelName: attachedPipettes.right?.displayName, + serialNumber: attachedPipettes.right?.serialNumber, mount: 'right' as Mount, tiprack: pipetteOffsetCalibrations?.find( - p => p.pipette === attachedPipettes.right?.id + p => p.pipette === attachedPipettes.right?.serialNumber )?.tiprackUri, lastCalibrated: pipetteOffsetCalibrations?.find( - p => p.pipette === attachedPipettes.right?.id + p => p.pipette === attachedPipettes.right?.serialNumber )?.lastModified, markedBad: pipetteOffsetCalibrations?.find( - p => p.pipette === attachedPipettes.right?.id + p => p.pipette === attachedPipettes.right?.serialNumber )?.status.markedBad, }) } else { formattedPipetteOffsetCalibrations.push({ - modelName: attachedPipettesFromInstrumentQuery.left?.displayName, - serialNumber: attachedPipettesFromInstrumentQuery.left?.serialNumber, + modelName: attachedPipettes.left?.displayName, + serialNumber: attachedPipettes.left?.serialNumber, mount: 'left' as Mount, lastCalibrated: - attachedPipettesFromInstrumentQuery.left?.data.calibratedOffset - ?.last_modified, + attachedPipettes.left?.data.calibratedOffset?.last_modified, }) formattedPipetteOffsetCalibrations.push({ - modelName: attachedPipettesFromInstrumentQuery.right?.displayName, - serialNumber: attachedPipettesFromInstrumentQuery.right?.serialNumber, + modelName: attachedPipettes.right?.displayName, + serialNumber: attachedPipettes.right?.serialNumber, mount: 'right' as Mount, lastCalibrated: - attachedPipettesFromInstrumentQuery.right?.data.calibratedOffset - ?.last_modified, + attachedPipettes.right?.data.calibratedOffset?.last_modified, }) } @@ -238,12 +243,6 @@ export function RobotSettingsCalibration({ } }, [createStatus]) - // Note: following fetch need to reflect the latest state of calibrations - // when a user does calibration or rename a robot. - useCalibrationStatusQuery({ refetchInterval: CALS_FETCH_MS }) - useAllPipetteOffsetCalibrationsQuery({ refetchInterval: CALS_FETCH_MS }) - useAllTipLengthCalibrationsQuery({ refetchInterval: CALS_FETCH_MS }) - return ( <> @@ -317,7 +316,9 @@ export function RobotSettingsCalibration({ updateRobotStatus={updateRobotStatus} /> - + {attachedGripper != null && ( + + )} ) : ( <> diff --git a/shared-data/js/constants.ts b/shared-data/js/constants.ts index 66b7294914f..0f06a391b6b 100644 --- a/shared-data/js/constants.ts +++ b/shared-data/js/constants.ts @@ -45,7 +45,8 @@ export const MAGNETIC_BLOCK_V1: 'magneticBlockV1' = 'magneticBlockV1' export const GRIPPER_V1: 'gripperV1' = 'gripperV1' export const GRIPPER_V1_1: 'gripperV1.1' = 'gripperV1.1' -export const GRIPPER_MODELS = [GRIPPER_V1, GRIPPER_V1_1] +export const GRIPPER_V1_2: 'gripperV1.2' = 'gripperV1.2' +export const GRIPPER_MODELS = [GRIPPER_V1, GRIPPER_V1_1, GRIPPER_V1_2] // pipette display categories export const FLEX: 'FLEX' = 'FLEX' diff --git a/shared-data/js/gripper.ts b/shared-data/js/gripper.ts index 9fde415cf7a..3a2714e30c1 100644 --- a/shared-data/js/gripper.ts +++ b/shared-data/js/gripper.ts @@ -1,7 +1,8 @@ import gripperV1 from '../gripper/definitions/1/gripperV1.json' import gripperV1_1 from '../gripper/definitions/1/gripperV1.1.json' +import gripperV1_2 from '../gripper/definitions/1/gripperV1.2.json' -import { GRIPPER_V1, GRIPPER_V1_1 } from './constants' +import { GRIPPER_V1, GRIPPER_V1_1, GRIPPER_V1_2 } from './constants' import type { GripperModel, GripperDefinition } from './types' @@ -13,6 +14,8 @@ export const getGripperDef = ( return gripperV1 as GripperDefinition case GRIPPER_V1_1: return gripperV1_1 as GripperDefinition + case GRIPPER_V1_2: + return gripperV1_2 as GripperDefinition default: console.warn( `Could not find a gripper with model ${gripperModel}, falling back to most recent definition: ${GRIPPER_V1_1}` diff --git a/shared-data/js/types.ts b/shared-data/js/types.ts index 7a9ceede368..710ba41cc85 100644 --- a/shared-data/js/types.ts +++ b/shared-data/js/types.ts @@ -21,6 +21,7 @@ import { RIGHT, GRIPPER_V1, GRIPPER_V1_1, + GRIPPER_V1_2, EXTENSION, MAGNETIC_BLOCK_V1, } from './constants' @@ -212,7 +213,10 @@ export type ModuleModel = | HeaterShakerModuleModel | MagneticBlockModel -export type GripperModel = typeof GRIPPER_V1 | typeof GRIPPER_V1_1 +export type GripperModel = + | typeof GRIPPER_V1 + | typeof GRIPPER_V1_1 + | typeof GRIPPER_V1_2 export type ModuleModelWithLegacy = | ModuleModel