From 81ce7bb3e3777557e243e2c98c15a709c69a5ada Mon Sep 17 00:00:00 2001 From: Jethary Alcid <66035149+jerader@users.noreply.github.com> Date: Wed, 23 Oct 2024 13:44:39 -0400 Subject: [PATCH 01/18] feat(protocol-designer, components): analytics opt-in modal and footer (#16561) closes AUTH-967, AUTH-888, AUTH-792 --- components/src/modals/Modal.tsx | 8 +- components/src/modals/ModalShell.tsx | 27 ++++-- .../__tests__/EndUserAgreementFooter.test.tsx | 21 ++++ .../EndUserAgreementFooter/index.tsx | 49 ++++++++++ components/src/organisms/index.ts | 1 + protocol-designer/src/ProtocolEditor.tsx | 2 +- protocol-designer/src/ProtocolRoutes.tsx | 4 + .../src/assets/localization/en/shared.json | 6 ++ .../src/components/modals/GateModal/index.tsx | 49 ---------- .../src/organisms/GateModal/index.tsx | 97 +++++++++++++++++++ protocol-designer/src/organisms/index.ts | 1 + .../pages/Landing/__tests__/Landing.test.tsx | 3 + protocol-designer/src/pages/Landing/index.tsx | 11 ++- .../src/pages/ProtocolOverview/index.tsx | 2 + 14 files changed, 220 insertions(+), 61 deletions(-) create mode 100644 components/src/organisms/EndUserAgreementFooter/__tests__/EndUserAgreementFooter.test.tsx create mode 100644 components/src/organisms/EndUserAgreementFooter/index.tsx delete mode 100644 protocol-designer/src/components/modals/GateModal/index.tsx create mode 100644 protocol-designer/src/organisms/GateModal/index.tsx diff --git a/components/src/modals/Modal.tsx b/components/src/modals/Modal.tsx index fc823243b5d..7be1ca06340 100644 --- a/components/src/modals/Modal.tsx +++ b/components/src/modals/Modal.tsx @@ -6,6 +6,7 @@ import { ModalHeader } from './ModalHeader' import { ModalShell } from './ModalShell' import type { IconProps } from '../icons' import type { StyleProps } from '../primitives' +import type { Position } from './ModalShell' type ModalType = 'info' | 'warning' | 'error' @@ -21,6 +22,8 @@ export interface ModalProps extends StyleProps { children?: React.ReactNode footer?: React.ReactNode zIndexOverlay?: number + showOverlay?: boolean + position?: Position } /** @@ -38,6 +41,8 @@ export const Modal = (props: ModalProps): JSX.Element => { titleElement1, titleElement2, zIndexOverlay, + position, + showOverlay, ...styleProps } = props @@ -72,9 +77,10 @@ export const Modal = (props: ModalProps): JSX.Element => { backgroundColor={COLORS.white} /> ) - return ( { @@ -61,7 +71,7 @@ export function ModalShell(props: ModalShellProps): JSX.Element { if (onOutsideClick != null) onOutsideClick(e) }} > - + ) } -const Overlay = styled.div<{ zIndex: string | number }>` +const Overlay = styled.div<{ zIndex: string | number; showOverlay: boolean }>` position: ${POSITION_ABSOLUTE}; left: 0; right: 0; top: 0; bottom: 0; z-index: ${({ zIndex }) => zIndex}; - background-color: ${COLORS.black90}${COLORS.opacity40HexCode}; + background-color: ${({ showOverlay }) => + showOverlay + ? `${COLORS.black90}${COLORS.opacity40HexCode}` + : COLORS.transparent}; cursor: ${CURSOR_DEFAULT}; ` -const ContentArea = styled.div<{ zIndex: string | number }>` +const ContentArea = styled.div<{ zIndex: string | number; position: Position }>` display: flex; position: ${POSITION_ABSOLUTE}; - align-items: ${ALIGN_CENTER}; - justify-content: ${JUSTIFY_CENTER}; + align-items: ${({ position }) => + position === 'center' ? ALIGN_CENTER : ALIGN_END}; + justify-content: ${({ position }) => + position === 'center' ? JUSTIFY_CENTER : JUSTIFY_END}; top: 0; right: 0; bottom: 0; diff --git a/components/src/organisms/EndUserAgreementFooter/__tests__/EndUserAgreementFooter.test.tsx b/components/src/organisms/EndUserAgreementFooter/__tests__/EndUserAgreementFooter.test.tsx new file mode 100644 index 00000000000..a13aacfe27f --- /dev/null +++ b/components/src/organisms/EndUserAgreementFooter/__tests__/EndUserAgreementFooter.test.tsx @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest' +import { screen } from '@testing-library/react' +import { renderWithProviders } from '../../../testing/utils' +import { EndUserAgreementFooter } from '../index' + +const render = () => { + return renderWithProviders() +} + +describe('EndUserAgreementFooter', () => { + it('should render text and links', () => { + render() + screen.getByText('Copyright © 2024 Opentrons') + expect( + screen.getByRole('link', { name: 'privacy policy' }) + ).toHaveAttribute('href', 'https://opentrons.com/privacy-policy') + expect( + screen.getByRole('link', { name: 'end user license agreement' }) + ).toHaveAttribute('href', 'https://opentrons.com/eula') + }) +}) diff --git a/components/src/organisms/EndUserAgreementFooter/index.tsx b/components/src/organisms/EndUserAgreementFooter/index.tsx new file mode 100644 index 00000000000..5e40b205665 --- /dev/null +++ b/components/src/organisms/EndUserAgreementFooter/index.tsx @@ -0,0 +1,49 @@ +import { StyledText } from '../../atoms' +import { COLORS } from '../../helix-design-system' +import { Flex, Link } from '../../primitives' +import { + ALIGN_CENTER, + DIRECTION_COLUMN, + TEXT_DECORATION_UNDERLINE, +} from '../../styles' +import { SPACING } from '../../ui-style-constants' + +const PRIVACY_POLICY_URL = 'https://opentrons.com/privacy-policy' +const EULA_URL = 'https://opentrons.com/eula' + +export function EndUserAgreementFooter(): JSX.Element { + return ( + + + By continuing, you agree to the Opentrons{' '} + + privacy policy + {' '} + and{' '} + + end user license agreement + + + + Copyright © 2024 Opentrons + + + ) +} diff --git a/components/src/organisms/index.ts b/components/src/organisms/index.ts index 2aee78e806c..8775f49abc3 100644 --- a/components/src/organisms/index.ts +++ b/components/src/organisms/index.ts @@ -1,2 +1,3 @@ export * from './DeckLabelSet' +export * from './EndUserAgreementFooter' export * from './Toolbox' diff --git a/protocol-designer/src/ProtocolEditor.tsx b/protocol-designer/src/ProtocolEditor.tsx index 570b27da6b6..8c9fb9fe1a0 100644 --- a/protocol-designer/src/ProtocolEditor.tsx +++ b/protocol-designer/src/ProtocolEditor.tsx @@ -16,7 +16,7 @@ import { PrereleaseModeIndicator } from './components/PrereleaseModeIndicator' import { PortalRoot as TopPortalRoot } from './components/portals/TopPortal' import { FileUploadMessageModal } from './components/modals/FileUploadMessageModal/FileUploadMessageModal' import { LabwareUploadMessageModal } from './components/modals/LabwareUploadMessageModal/LabwareUploadMessageModal' -import { GateModal } from './components/modals/GateModal' +import { GateModal } from './organisms/GateModal' import { CreateFileWizard } from './components/modals/CreateFileWizard' import { AnnouncementModal } from './organisms' import { ProtocolRoutes } from './ProtocolRoutes' diff --git a/protocol-designer/src/ProtocolRoutes.tsx b/protocol-designer/src/ProtocolRoutes.tsx index 74f79c437b9..908f46539be 100644 --- a/protocol-designer/src/ProtocolRoutes.tsx +++ b/protocol-designer/src/ProtocolRoutes.tsx @@ -11,6 +11,7 @@ import { Kitchen, FileUploadMessagesModal, LabwareUploadModal, + GateModal, } from './organisms' import type { RouteProps } from './types' @@ -56,12 +57,15 @@ export function ProtocolRoutes(): JSX.Element { path: '/', } const allRoutes: RouteProps[] = [...pdRoutes, landingPage] + const showGateModal = + process.env.NODE_ENV === 'production' || process.env.OT_PD_SHOW_GATE return ( <> + {showGateModal ? : null} diff --git a/protocol-designer/src/assets/localization/en/shared.json b/protocol-designer/src/assets/localization/en/shared.json index 499bec09040..8151c1e3270 100644 --- a/protocol-designer/src/assets/localization/en/shared.json +++ b/protocol-designer/src/assets/localization/en/shared.json @@ -1,5 +1,7 @@ { "add": "add", + "agree": "Agree", + "analytics_tracking": "I consent to analytics tracking:", "amount": "Amount:", "app_settings": "App settings", "ask_for_labware_overwrite": "Duplicate labware name", @@ -102,8 +104,10 @@ "protocol_designer": "Protocol Designer", "re_export": "To use this definition, use Labware Creator to give it a unique load name and display name.", "remove": "remove", + "reject": "Reject", "reset_hints_and_tips": "Reset all hints and tips notifications", "reset_hints": "Reset hints", + "review_our_privacy_policy": "You can adjust this setting at any time by clicking on the settings icon. Find detailed information in our privacy policy.", "right": "Right", "save": "Save", "settings": "Settings", @@ -116,6 +120,7 @@ "stagingArea": "Staging area", "step_count": "Step {{current}}", "step": "Step {{current}} / {{max}}", + "consent_to_eula": "By using Protocol Designer, you consent to the Opentrons EULA.", "temperaturemoduletype": "Temperature Module", "thermocyclermoduletype": "Thermocycler Module", "trashBin": "Trash Bin", @@ -129,5 +134,6 @@ "wasteChuteAndStagingArea": "Waste chute and staging area slot", "we_are_improving": "In order to improve our products, Opentrons would like to collect data related to your use of Protocol Designer. With your consent, Opentrons will collect and store analytics and session data, including through the use of cookies and similar technologies, solely for the purpose enhancing our products. Find detailed information in our privacy policy. By using Protocol Designer, you consent to the Opentrons EULA.", "welcome": "Welcome to Protocol Designer!", + "opentrons_collects_data": "In order to improve our products, Opentrons would like to collect data related to your use of Protocol Designer. With your consent, Opentrons will collect and store analytics and session data, including through the use of cookies and similar technologies, solely for the purpose enhancing our products.", "yes": "Yes" } diff --git a/protocol-designer/src/components/modals/GateModal/index.tsx b/protocol-designer/src/components/modals/GateModal/index.tsx deleted file mode 100644 index b3b97e4f366..00000000000 --- a/protocol-designer/src/components/modals/GateModal/index.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { useSelector, useDispatch } from 'react-redux' -import cx from 'classnames' -import { AlertModal } from '@opentrons/components' -import { - actions as analyticsActions, - selectors as analyticsSelectors, -} from '../../../analytics' -import settingsStyles from '../../SettingsPage/SettingsPage.module.css' -import modalStyles from '../modal.module.css' - -export function GateModal(): JSX.Element | null { - const { t } = useTranslation(['card', 'button']) - const hasOptedIn = useSelector(analyticsSelectors.getHasOptedIn) - const dispatch = useDispatch() - - if (hasOptedIn == null) { - return ( - dispatch(analyticsActions.optOut()), - children: t('button:no'), - }, - { - onClick: () => dispatch(analyticsActions.optIn()), - children: t('button:yes'), - }, - ]} - > -

{t('toggle.share_session')}

-
-

- {t('body.reason_for_collecting_data')} -

-
    -
  • {t('body.data_collected_is_internal')}
  • -
  • {t('body.data_only_from_pd')}
  • -
  • {t('body.opt_out_of_data_collection')}
  • -
-
-
- ) - } else { - return null - } -} diff --git a/protocol-designer/src/organisms/GateModal/index.tsx b/protocol-designer/src/organisms/GateModal/index.tsx new file mode 100644 index 00000000000..cfe35b1b24a --- /dev/null +++ b/protocol-designer/src/organisms/GateModal/index.tsx @@ -0,0 +1,97 @@ +import { Trans, useTranslation } from 'react-i18next' +import { useSelector, useDispatch } from 'react-redux' +import { + COLORS, + DIRECTION_COLUMN, + Flex, + JUSTIFY_END, + Link as LinkComponent, + Modal, + PrimaryButton, + SPACING, + SecondaryButton, + StyledText, +} from '@opentrons/components' +import { + actions as analyticsActions, + selectors as analyticsSelectors, +} from '../../analytics' + +const PRIVACY_POLICY_URL = 'https://opentrons.com/privacy-policy' +const EULA_URL = 'https://opentrons.com/eula' + +export function GateModal(): JSX.Element | null { + const { t } = useTranslation('shared') + const hasOptedIn = useSelector(analyticsSelectors.getHasOptedIn) + const dispatch = useDispatch() + + if (hasOptedIn == null) { + return ( + + dispatch(analyticsActions.optOut())} + > + + {t('reject')} + + + dispatch(analyticsActions.optIn())}> + + {t('agree')} + + + + } + > + + + {t('opentrons_collects_data')} + + + + ), + }} + /> + + + + ), + }} + /> + + + {t('analytics_tracking')} + + + + ) + } else { + return null + } +} diff --git a/protocol-designer/src/organisms/index.ts b/protocol-designer/src/organisms/index.ts index dc07179d0da..cda71137c57 100644 --- a/protocol-designer/src/organisms/index.ts +++ b/protocol-designer/src/organisms/index.ts @@ -6,6 +6,7 @@ export * from './EditInstrumentsModal' export * from './EditNickNameModal' export * from './EditProtocolMetadataModal' export * from './FileUploadMessagesModal/' +export * from './GateModal' export * from './IncompatibleTipsModal' export * from './Kitchen' export * from './LabwareUploadModal' diff --git a/protocol-designer/src/pages/Landing/__tests__/Landing.test.tsx b/protocol-designer/src/pages/Landing/__tests__/Landing.test.tsx index 315496d755e..4ca8430796f 100644 --- a/protocol-designer/src/pages/Landing/__tests__/Landing.test.tsx +++ b/protocol-designer/src/pages/Landing/__tests__/Landing.test.tsx @@ -8,6 +8,7 @@ import { getFileMetadata } from '../../../file-data/selectors' import { toggleNewProtocolModal } from '../../../navigation/actions' import { useKitchen } from '../../../organisms/Kitchen/hooks' import { useAnnouncements } from '../../../organisms/AnnouncementModal/announcements' +import { getHasOptedIn } from '../../../analytics/selectors' import { Landing } from '../index' vi.mock('../../../load-file/actions') @@ -15,6 +16,7 @@ vi.mock('../../../file-data/selectors') vi.mock('../../../navigation/actions') vi.mock('../../../organisms/AnnouncementModal/announcements') vi.mock('../../../organisms/Kitchen/hooks') +vi.mock('../../../analytics/selectors') const mockMakeSnackbar = vi.fn() const mockEatToast = vi.fn() @@ -33,6 +35,7 @@ const render = () => { describe('Landing', () => { beforeEach(() => { + vi.mocked(getHasOptedIn).mockReturnValue(false) vi.mocked(getFileMetadata).mockReturnValue({}) vi.mocked(loadProtocolFile).mockReturnValue(vi.fn()) vi.mocked(useAnnouncements).mockReturnValue({} as any) diff --git a/protocol-designer/src/pages/Landing/index.tsx b/protocol-designer/src/pages/Landing/index.tsx index a4e0be187f5..315787cd9ea 100644 --- a/protocol-designer/src/pages/Landing/index.tsx +++ b/protocol-designer/src/pages/Landing/index.tsx @@ -8,6 +8,7 @@ import { COLORS, CURSOR_POINTER, DIRECTION_COLUMN, + EndUserAgreementFooter, Flex, INFO_TOAST, JUSTIFY_CENTER, @@ -22,6 +23,7 @@ import { actions as loadFileActions } from '../../load-file' import { getFileMetadata } from '../../file-data/selectors' import { toggleNewProtocolModal } from '../../navigation/actions' import { useKitchen } from '../../organisms/Kitchen/hooks' +import { getHasOptedIn } from '../../analytics/selectors' import { useAnnouncements } from '../../organisms/AnnouncementModal/announcements' import { getLocalStorageItem, localStorageAnnouncementKey } from '../../persist' import welcomeImage from '../../assets/images/welcome_page.png' @@ -36,17 +38,17 @@ export function Landing(): JSX.Element { const [showAnnouncementModal, setShowAnnouncementModal] = useState( false ) + const hasOptedIn = useSelector(getHasOptedIn) const { bakeToast, eatToast } = useKitchen() const announcements = useAnnouncements() const lastAnnouncement = announcements[announcements.length - 1] const announcementKey = lastAnnouncement ? lastAnnouncement.announcementKey : null - const showGateModal = - process.env.NODE_ENV === 'production' || process.env.OT_PD_SHOW_GATE + const userHasNotSeenAnnouncement = getLocalStorageItem(localStorageAnnouncementKey) !== announcementKey && - !showGateModal + hasOptedIn != null useEffect(() => { if (userHasNotSeenAnnouncement) { @@ -96,7 +98,7 @@ export function Landing(): JSX.Element { flexDirection={DIRECTION_COLUMN} alignItems={ALIGN_CENTER} justifyContent={JUSTIFY_CENTER} - height="calc(100vh - 3.5rem)" + height="calc(100vh - 9rem)" width="100%" gridGap={SPACING.spacing32} > @@ -142,6 +144,7 @@ export function Landing(): JSX.Element { + ) } diff --git a/protocol-designer/src/pages/ProtocolOverview/index.tsx b/protocol-designer/src/pages/ProtocolOverview/index.tsx index 29e72aa171f..4d68ba7e0d3 100644 --- a/protocol-designer/src/pages/ProtocolOverview/index.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/index.tsx @@ -10,6 +10,7 @@ import { ALIGN_CENTER, Btn, DIRECTION_COLUMN, + EndUserAgreementFooter, Flex, JUSTIFY_END, JUSTIFY_FLEX_END, @@ -414,6 +415,7 @@ export function ProtocolOverview(): JSX.Element { + ) } From 9f28fab1ecc3e29bfec39c078511bc9d696af72c Mon Sep 17 00:00:00 2001 From: Ryan Howard Date: Wed, 23 Oct 2024 14:02:47 -0400 Subject: [PATCH 02/18] fix(api): use the message only formatter for the sensor logs (#16577) # Overview ## Test Plan and Hands on Testing ## Changelog ## Review requests ## Risk assessment --- api/src/opentrons/util/logging_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/opentrons/util/logging_config.py b/api/src/opentrons/util/logging_config.py index 944f4d3d5ed..42a32501576 100644 --- a/api/src/opentrons/util/logging_config.py +++ b/api/src/opentrons/util/logging_config.py @@ -125,7 +125,7 @@ def _buildroot_config(level_value: int) -> Dict[str, Any]: }, "sensor": { "class": "logging.handlers.RotatingFileHandler", - "formatter": "basic", + "formatter": "message_only", "filename": sensor_log_filename, "maxBytes": 1000000, "level": logging.DEBUG, From 7e3453da7a00b28ce5a9f177c5267a51d7ea95bc Mon Sep 17 00:00:00 2001 From: Sanniti Pimpley Date: Wed, 23 Oct 2024 14:15:59 -0400 Subject: [PATCH 03/18] chore(share-data): add format and lint targets for js (#16529) Adds `format-js` and `lint-js` targets to shared-data's makefile --- shared-data/Makefile | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/shared-data/Makefile b/shared-data/Makefile index a06c7979d9e..2e972110c19 100644 --- a/shared-data/Makefile +++ b/shared-data/Makefile @@ -9,20 +9,31 @@ tests ?= cov_opts ?= --coverage=true test_opts ?= +# warning suppression variables for tests and linting +quiet ?= false + +FORMAT_FILE_GLOB = "**/*.@(ts|tsx|js|json|md|yml)" + # Top level targets .PHONY: all all: clean dist .PHONY: setup -setup: setup-py setup-js +setup: setup-py .PHONY: dist -dist: dist-js dist-py +dist: dist-py .PHONY: clean clean: clean-py +.PHONY: format +format: format-js format-py + +.PHONY: lint +lint: lint-js lint-py + # JavaScript targets .PHONY: lib-js @@ -34,6 +45,22 @@ lib-js: build-ts: yarn tsc --build --emitDeclarationOnly +.PHONY: format-js +format-js: + yarn prettier --ignore-path ../.eslintignore --write $(FORMAT_FILE_GLOB) + +.PHONY: lint-js +lint-js: lint-js-eslint lint-js-prettier + +.PHONY: lint-js-eslint +lint-js-eslint: + yarn eslint --ignore-path ../.eslintignore --quiet=$(quiet) "**/*.@(js|ts|tsx)" + +.PHONY: lint-js-prettier +lint-js-prettier: + yarn prettier --ignore-path ../.eslintignore --check $(FORMAT_FILE_GLOB) + + # Python targets .PHONY: setup-py From 1deeb88ee4128233943ec3b1839da62e6595d808 Mon Sep 17 00:00:00 2001 From: fbelginetw <167361860+fbelginetw@users.noreply.github.com> Date: Wed, 23 Oct 2024 16:03:18 -0400 Subject: [PATCH 04/18] feat: Opentrons ai client landing page (#16552) # Overview This PR refactors Opentrons AI Client to match the new design. It also adds the mixpanel files for tracking analytics. ![image](https://github.com/user-attachments/assets/cebc0744-c791-4426-9dda-f7b1121a4e19) ## Test Plan and Hands on Testing Still wip, but tested manually the landing page and its buttons, and if mixpanel is tracking the events. ## Changelog - New Opentron AI client flow and Landing page. - Add mixpanel analytics. ## Review requests - Landing page. - Verify if the approach for Mixpanel implementation is ok. ## Risk assessment It breaks the current Opentrons AI client, as it's being remade from the ground up. --- opentrons-ai-client/src/App.test.tsx | 52 +-------- opentrons-ai-client/src/App.tsx | 81 +------------ opentrons-ai-client/src/OpentronsAI.test.tsx | 82 ++++++++++++++ opentrons-ai-client/src/OpentronsAI.tsx | 90 +++++++++++++++ opentrons-ai-client/src/OpentronsAIRoutes.tsx | 39 +++++++ opentrons-ai-client/src/analytics/mixpanel.ts | 67 +++++++++++ .../src/analytics/selectors.ts | 2 + .../src/assets/images/welcome_dashboard.png | Bin 0 -> 10982 bytes .../localization/en/protocol_generator.json | 6 + .../src/molecules/Footer/index.tsx | 2 +- .../Header/__tests__/Header.test.tsx | 38 ++++++- .../src/molecules/Header/index.tsx | 13 ++- .../pages/Landing/__tests__/Landing.test.tsx | 107 ++++++++++++++++++ .../src/pages/Landing/index.tsx | 89 +++++++++++++++ opentrons-ai-client/src/resources/atoms.ts | 6 +- .../src/resources/constants.ts | 2 + .../hooks/__tests__/useIsMobile.test.ts | 18 +++ .../hooks/__tests__/useTrackEvent.test.tsx | 60 ++++++++++ .../src/resources/hooks/useIsMobile.ts | 22 ++++ .../src/resources/hooks/useTrackEvent.ts | 16 +++ opentrons-ai-client/src/resources/types.ts | 26 +++++ .../src/resources/utils/testUtils.tsx | 29 +++++ 22 files changed, 717 insertions(+), 130 deletions(-) create mode 100644 opentrons-ai-client/src/OpentronsAI.test.tsx create mode 100644 opentrons-ai-client/src/OpentronsAI.tsx create mode 100644 opentrons-ai-client/src/OpentronsAIRoutes.tsx create mode 100644 opentrons-ai-client/src/analytics/mixpanel.ts create mode 100644 opentrons-ai-client/src/analytics/selectors.ts create mode 100644 opentrons-ai-client/src/assets/images/welcome_dashboard.png create mode 100644 opentrons-ai-client/src/pages/Landing/__tests__/Landing.test.tsx create mode 100644 opentrons-ai-client/src/pages/Landing/index.tsx create mode 100644 opentrons-ai-client/src/resources/hooks/__tests__/useIsMobile.test.ts create mode 100644 opentrons-ai-client/src/resources/hooks/__tests__/useTrackEvent.test.tsx create mode 100644 opentrons-ai-client/src/resources/hooks/useIsMobile.ts create mode 100644 opentrons-ai-client/src/resources/hooks/useTrackEvent.ts create mode 100644 opentrons-ai-client/src/resources/utils/testUtils.tsx diff --git a/opentrons-ai-client/src/App.test.tsx b/opentrons-ai-client/src/App.test.tsx index 859bb488f0e..ec61b02472c 100644 --- a/opentrons-ai-client/src/App.test.tsx +++ b/opentrons-ai-client/src/App.test.tsx @@ -1,22 +1,13 @@ -import { fireEvent, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import { describe, it, vi, beforeEach, expect } from 'vitest' -import * as auth0 from '@auth0/auth0-react' import { renderWithProviders } from './__testing-utils__' import { i18n } from './i18n' -import { SidePanel } from './molecules/SidePanel' -import { MainContentContainer } from './organisms/MainContentContainer' -import { Loading } from './molecules/Loading' import { App } from './App' +import { OpentronsAI } from './OpentronsAI' -vi.mock('@auth0/auth0-react') - -const mockLogout = vi.fn() - -vi.mock('./molecules/SidePanel') -vi.mock('./organisms/MainContentContainer') -vi.mock('./molecules/Loading') +vi.mock('./OpentronsAI') const render = (): ReturnType => { return renderWithProviders(, { @@ -26,42 +17,11 @@ const render = (): ReturnType => { describe('App', () => { beforeEach(() => { - vi.mocked(SidePanel).mockReturnValue(
mock SidePanel
) - vi.mocked(MainContentContainer).mockReturnValue( -
mock MainContentContainer
- ) - vi.mocked(Loading).mockReturnValue(
mock Loading
) - }) - - it('should render loading screen when isLoading is true', () => { - ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ - isAuthenticated: false, - isLoading: true, - }) - render() - screen.getByText('mock Loading') - }) - - it('should render text', () => { - ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ - isAuthenticated: true, - isLoading: false, - }) - render() - screen.getByText('mock SidePanel') - screen.getByText('mock MainContentContainer') - screen.getByText('Logout') + vi.mocked(OpentronsAI).mockReturnValue(
mock OpentronsAI
) }) - it('should call a mock function when clicking logout button', () => { - ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ - isAuthenticated: true, - isLoading: false, - logout: mockLogout, - }) + it('should render OpentronsAI', () => { render() - const logoutButton = screen.getByText('Logout') - fireEvent.click(logoutButton) - expect(mockLogout).toHaveBeenCalled() + expect(screen.getByText('mock OpentronsAI')).toBeInTheDocument() }) }) diff --git a/opentrons-ai-client/src/App.tsx b/opentrons-ai-client/src/App.tsx index 263ea02c844..104977150fc 100644 --- a/opentrons-ai-client/src/App.tsx +++ b/opentrons-ai-client/src/App.tsx @@ -1,82 +1,5 @@ -import { useEffect } from 'react' -import { useAuth0 } from '@auth0/auth0-react' -import { useTranslation } from 'react-i18next' -import { useForm, FormProvider } from 'react-hook-form' -import { useAtom } from 'jotai' -import { - COLORS, - Flex, - Link as LinkButton, - POSITION_ABSOLUTE, - POSITION_RELATIVE, - TYPOGRAPHY, -} from '@opentrons/components' - -import { tokenAtom } from './resources/atoms' -import { useGetAccessToken } from './resources/hooks' -import { SidePanel } from './molecules/SidePanel' -import { Loading } from './molecules/Loading' -import { MainContentContainer } from './organisms/MainContentContainer' - -export interface InputType { - userPrompt: string -} +import { OpentronsAI } from './OpentronsAI' export function App(): JSX.Element | null { - const { t } = useTranslation('protocol_generator') - const { isAuthenticated, logout, isLoading, loginWithRedirect } = useAuth0() - const [, setToken] = useAtom(tokenAtom) - const { getAccessToken } = useGetAccessToken() - - const fetchAccessToken = async (): Promise => { - try { - const accessToken = await getAccessToken() - setToken(accessToken) - } catch (error) { - console.error('Error fetching access token:', error) - } - } - const methods = useForm({ - defaultValues: { - userPrompt: '', - }, - }) - - useEffect(() => { - if (!isAuthenticated && !isLoading) { - void loginWithRedirect() - } - if (isAuthenticated) { - void fetchAccessToken() - } - }, [isAuthenticated, isLoading, loginWithRedirect]) - - if (isLoading) { - return - } - - if (!isAuthenticated) { - return null - } - - return ( - - - logout()} - textDecoration={TYPOGRAPHY.textDecorationUnderline} - > - {t('logout')} - - - - - - - - ) + return } diff --git a/opentrons-ai-client/src/OpentronsAI.test.tsx b/opentrons-ai-client/src/OpentronsAI.test.tsx new file mode 100644 index 00000000000..68d604edf07 --- /dev/null +++ b/opentrons-ai-client/src/OpentronsAI.test.tsx @@ -0,0 +1,82 @@ +import { screen } from '@testing-library/react' +import { describe, it, vi, beforeEach } from 'vitest' +import * as auth0 from '@auth0/auth0-react' + +import { renderWithProviders } from './__testing-utils__' +import { i18n } from './i18n' +import { Loading } from './molecules/Loading' + +import { OpentronsAI } from './OpentronsAI' +import { Landing } from './pages/Landing' +import { useGetAccessToken } from './resources/hooks' +import { Header } from './molecules/Header' +import { Footer } from './molecules/Footer' + +vi.mock('@auth0/auth0-react') + +vi.mock('./pages/Landing') +vi.mock('./molecules/Header') +vi.mock('./molecules/Footer') +vi.mock('./molecules/Loading') +vi.mock('./resources/hooks/useGetAccessToken') +vi.mock('./analytics/mixpanel') + +const mockUseTrackEvent = vi.fn() + +vi.mock('./resources/hooks/useTrackEvent', () => ({ + useTrackEvent: () => mockUseTrackEvent, +})) + +const render = (): ReturnType => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('OpentronsAI', () => { + beforeEach(() => { + vi.mocked(useGetAccessToken).mockReturnValue({ + getAccessToken: vi.fn().mockResolvedValue('mock access token'), + }) + vi.mocked(Landing).mockReturnValue(
mock Landing page
) + vi.mocked(Loading).mockReturnValue(
mock Loading
) + vi.mocked(Header).mockReturnValue(
mock Header component
) + vi.mocked(Footer).mockReturnValue(
mock Footer component
) + }) + + it('should render loading screen when isLoading is true', () => { + ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ + isAuthenticated: false, + isLoading: true, + }) + render() + screen.getByText('mock Loading') + }) + + it('should render text', () => { + ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ + isAuthenticated: true, + isLoading: false, + }) + render() + screen.getByText('mock Landing page') + }) + + it('should render Header component', () => { + ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ + isAuthenticated: true, + isLoading: false, + }) + render() + screen.getByText('mock Header component') + }) + + it('should render Footer component', () => { + ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ + isAuthenticated: true, + isLoading: false, + }) + render() + screen.getByText('mock Footer component') + }) +}) diff --git a/opentrons-ai-client/src/OpentronsAI.tsx b/opentrons-ai-client/src/OpentronsAI.tsx new file mode 100644 index 00000000000..621c2453e50 --- /dev/null +++ b/opentrons-ai-client/src/OpentronsAI.tsx @@ -0,0 +1,90 @@ +import { HashRouter } from 'react-router-dom' +import { + DIRECTION_COLUMN, + Flex, + OVERFLOW_AUTO, + COLORS, + ALIGN_CENTER, +} from '@opentrons/components' +import { OpentronsAIRoutes } from './OpentronsAIRoutes' +import { useAuth0 } from '@auth0/auth0-react' +import { useAtom } from 'jotai' +import { useEffect } from 'react' +import { Loading } from './molecules/Loading' +import { mixpanelAtom, tokenAtom } from './resources/atoms' +import { useGetAccessToken } from './resources/hooks' +import { initializeMixpanel } from './analytics/mixpanel' +import { useTrackEvent } from './resources/hooks/useTrackEvent' +import { Header } from './molecules/Header' +import { CLIENT_MAX_WIDTH } from './resources/constants' +import { Footer } from './molecules/Footer' + +export function OpentronsAI(): JSX.Element | null { + const { isAuthenticated, isLoading, loginWithRedirect } = useAuth0() + const [, setToken] = useAtom(tokenAtom) + const [mixpanel] = useAtom(mixpanelAtom) + const { getAccessToken } = useGetAccessToken() + const trackEvent = useTrackEvent() + + initializeMixpanel(mixpanel) + + const fetchAccessToken = async (): Promise => { + try { + const accessToken = await getAccessToken() + setToken(accessToken) + } catch (error) { + console.error('Error fetching access token:', error) + } + } + + useEffect(() => { + if (!isAuthenticated && !isLoading) { + void loginWithRedirect() + } + if (isAuthenticated) { + void fetchAccessToken() + } + }, [isAuthenticated, isLoading, loginWithRedirect]) + + useEffect(() => { + if (isAuthenticated) { + trackEvent({ name: 'user-login', properties: {} }) + } + }, [isAuthenticated]) + + if (isLoading) { + return + } + + if (!isAuthenticated) { + return null + } + + return ( +
+ +
+ + + + + + + +
+ +
+ ) +} diff --git a/opentrons-ai-client/src/OpentronsAIRoutes.tsx b/opentrons-ai-client/src/OpentronsAIRoutes.tsx new file mode 100644 index 00000000000..630429c2aa1 --- /dev/null +++ b/opentrons-ai-client/src/OpentronsAIRoutes.tsx @@ -0,0 +1,39 @@ +import { Route, Navigate, Routes } from 'react-router-dom' +import { Landing } from './pages/Landing' + +import type { RouteProps } from './resources/types' + +const opentronsAIRoutes: RouteProps[] = [ + // replace Landing with the correct component + { + Component: Landing, + name: 'Create A New Protocol', + navLinkTo: '/new-protocol', + path: '/new-protocol', + }, + { + Component: Landing, + name: 'Update An Existing Protocol', + navLinkTo: '/update-protocol', + path: '/update-protocol', + }, +] + +export function OpentronsAIRoutes(): JSX.Element { + const landingPage: RouteProps = { + Component: Landing, + name: 'Landing', + navLinkTo: '/', + path: '/', + } + const allRoutes: RouteProps[] = [...opentronsAIRoutes, landingPage] + + return ( + + {allRoutes.map(({ Component, path }: RouteProps) => ( + } /> + ))} + } /> + + ) +} diff --git a/opentrons-ai-client/src/analytics/mixpanel.ts b/opentrons-ai-client/src/analytics/mixpanel.ts new file mode 100644 index 00000000000..eb81b72e6e3 --- /dev/null +++ b/opentrons-ai-client/src/analytics/mixpanel.ts @@ -0,0 +1,67 @@ +import mixpanel from 'mixpanel-browser' +import { getHasOptedIn } from './selectors' + +export const getIsProduction = (): boolean => + global.location.host === 'designer.opentrons.com' // UPDATE THIS TO CORRECT URL + +export type AnalyticsEvent = + | { + name: string + properties: Record + superProperties?: Record + } + | { superProperties: Record } + +// pulled in from environment at build time +const MIXPANEL_ID = process.env.OT_AI_CLIENT_MIXPANEL_ID + +const MIXPANEL_OPTS = { + // opt out by default + opt_out_tracking_by_default: true, +} + +export function initializeMixpanel(state: any): void { + const optedIn = getHasOptedIn(state) ?? false + if (MIXPANEL_ID != null) { + console.debug('Initializing Mixpanel', { optedIn }) + + mixpanel.init(MIXPANEL_ID, MIXPANEL_OPTS) + setMixpanelTracking(optedIn) + trackEvent({ name: 'appOpen', properties: {} }, optedIn) // TODO IMMEDIATELY: do we want this? + } else { + console.warn('MIXPANEL_ID not found; this is a bug if build is production') + } +} + +export function trackEvent(event: AnalyticsEvent, optedIn: boolean): void { + console.debug('Trackable event', { event, optedIn }) + if (MIXPANEL_ID != null && optedIn) { + if ('superProperties' in event && event.superProperties != null) { + mixpanel.register(event.superProperties) + } + if ('name' in event && event.name != null) { + mixpanel.track(event.name, event.properties) + } + } +} + +export function setMixpanelTracking(optedIn: boolean): void { + if (MIXPANEL_ID != null) { + if (optedIn) { + console.debug('User has opted into analytics; tracking with Mixpanel') + mixpanel.opt_in_tracking() + // Register "super properties" which are included with all events + mixpanel.register({ + appVersion: 'test', // TODO update this? + // NOTE(IL, 2020): Since PD may be in the same Mixpanel project as other OT web apps, this 'appName' property is intended to distinguish it + appName: 'opentronsAIClient', + }) + } else { + console.debug( + 'User has opted out of analytics; stopping Mixpanel tracking' + ) + mixpanel.opt_out_tracking() + mixpanel.reset() + } + } +} diff --git a/opentrons-ai-client/src/analytics/selectors.ts b/opentrons-ai-client/src/analytics/selectors.ts new file mode 100644 index 00000000000..b55165f3049 --- /dev/null +++ b/opentrons-ai-client/src/analytics/selectors.ts @@ -0,0 +1,2 @@ +export const getHasOptedIn = (state: any): boolean | null => + state.analytics.hasOptedIn diff --git a/opentrons-ai-client/src/assets/images/welcome_dashboard.png b/opentrons-ai-client/src/assets/images/welcome_dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..c6f84b4429b4454540ba9ce8789ed2f49133cabe GIT binary patch literal 10982 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𝔙=i9J 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 Labware Library.", + "landing_page_body": "Get started building a prompt that will generate a Python protocol that you can use on your Opentrons robot. OpentronsAI lets you create and optimize your protocol by responding in natural language.", + "landing_page_body_mobile": "Use a desktop browser to use OpentronsAI.", + "landing_page_button_new_protocol": "Create a new protocol", + "landing_page_button_update_protocol": "Update an existing protocol", + "landing_page_heading": "Welcome to OpentronsAI", + "landing_page_image_alt": "welcome image", "liquid_locations": "Liquid locations: Describe where liquids should go in the labware.", "loading": "Loading...", "login": "Login", diff --git a/opentrons-ai-client/src/molecules/Footer/index.tsx b/opentrons-ai-client/src/molecules/Footer/index.tsx index 5ef44bc733f..c8bbc4054fd 100644 --- a/opentrons-ai-client/src/molecules/Footer/index.tsx +++ b/opentrons-ai-client/src/molecules/Footer/index.tsx @@ -44,7 +44,7 @@ export function Footer(): JSX.Element { > ({ + useTrackEvent: () => mockUseTrackEvent, +})) const render = (): ReturnType => { return renderWithProviders(
, { @@ -11,6 +20,14 @@ const render = (): ReturnType => { } describe('Header', () => { + beforeEach(() => { + ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ + isAuthenticated: true, + isLoading: false, + logout: mockLogout, + }) + }) + it('should render Header component', () => { render() screen.getByText('Opentrons') @@ -20,4 +37,21 @@ describe('Header', () => { render() screen.getByText('Logout') }) + + it('should logout when log out button is clicked', () => { + render() + const logoutButton = screen.getByText('Logout') + fireEvent.click(logoutButton) + expect(mockLogout).toHaveBeenCalled() + }) + + it('should track logout event when log out button is clicked', () => { + render() + const logoutButton = screen.getByText('Logout') + fireEvent.click(logoutButton) + expect(mockUseTrackEvent).toHaveBeenCalledWith({ + name: 'user-logout', + properties: {}, + }) + }) }) diff --git a/opentrons-ai-client/src/molecules/Header/index.tsx b/opentrons-ai-client/src/molecules/Header/index.tsx index e909aeaf691..8221aa03e81 100644 --- a/opentrons-ai-client/src/molecules/Header/index.tsx +++ b/opentrons-ai-client/src/molecules/Header/index.tsx @@ -10,15 +10,19 @@ import { COLORS, POSITION_RELATIVE, ALIGN_CENTER, + JUSTIFY_CENTER, JUSTIFY_SPACE_BETWEEN, } from '@opentrons/components' import { useAuth0 } from '@auth0/auth0-react' +import { CLIENT_MAX_WIDTH } from '../../resources/constants' +import { useTrackEvent } from '../../resources/hooks/useTrackEvent' const HeaderBar = styled(Flex)` position: ${POSITION_RELATIVE}; background-color: ${COLORS.white}; width: 100%; align-items: ${ALIGN_CENTER}; + justify-content: ${JUSTIFY_CENTER}; height: 60px; ` @@ -27,6 +31,7 @@ const HeaderBarContent = styled(Flex)` padding: 18px 32px; justify-content: ${JUSTIFY_SPACE_BETWEEN}; width: 100%; + max-width: ${CLIENT_MAX_WIDTH}; ` const HeaderGradientTitle = styled(StyledText)` @@ -48,6 +53,12 @@ const LogoutButton = styled(LinkButton)` export function Header(): JSX.Element { const { t } = useTranslation('protocol_generator') const { logout } = useAuth0() + const trackEvent = useTrackEvent() + + function handleLogout(): void { + logout() + trackEvent({ name: 'user-logout', properties: {} }) + } return ( @@ -56,7 +67,7 @@ export function Header(): JSX.Element { {t('opentrons')} {t('ai')} - logout()}>{t('logout')} + {t('logout')} ) diff --git a/opentrons-ai-client/src/pages/Landing/__tests__/Landing.test.tsx b/opentrons-ai-client/src/pages/Landing/__tests__/Landing.test.tsx new file mode 100644 index 00000000000..a90807878eb --- /dev/null +++ b/opentrons-ai-client/src/pages/Landing/__tests__/Landing.test.tsx @@ -0,0 +1,107 @@ +import { screen } from '@testing-library/react' +import { describe, it, vi, beforeEach, expect } from 'vitest' +import { renderWithProviders } from '../../../__testing-utils__' +import type { NavigateFunction } from 'react-router-dom' + +import { Landing } from '../index' +import { i18n } from '../../../i18n' + +const mockNavigate = vi.fn() +const mockUseTrackEvent = vi.fn() + +vi.mock('../../../resources/hooks/useTrackEvent', () => ({ + useTrackEvent: () => mockUseTrackEvent, +})) + +vi.mock('react-router-dom', async importOriginal => { + const reactRouterDom = await importOriginal() + return { + ...reactRouterDom, + useNavigate: () => mockNavigate, + } +}) + +vi.mock('../../../hooks/useTrackEvent', () => ({ + useTrackEvent: () => mockUseTrackEvent, +})) + +const render = () => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('Landing', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render', () => { + render() + expect(screen.getByText('Welcome to OpentronsAI')).toBeInTheDocument() + }) + + it('should render the image, heading and body text', () => { + render() + expect(screen.getByAltText('welcome image')).toBeInTheDocument() + expect(screen.getByText('Welcome to OpentronsAI')).toBeInTheDocument() + expect( + screen.getByText( + 'Get started building a prompt that will generate a Python protocol that you can use on your Opentrons robot. OpentronsAI lets you create and optimize your protocol by responding in natural language.' + ) + ).toBeInTheDocument() + }) + + it('should render create and update protocol buttons', () => { + render() + expect(screen.getByText('Create a new protocol')).toBeInTheDocument() + expect(screen.getByText('Update an existing protocol')).toBeInTheDocument() + }) + + it('should render the mobile body text if the screen width is less than 768px', () => { + vi.stubGlobal('innerWidth', 767) + window.dispatchEvent(new Event('resize')) + render() + expect( + screen.getByText('Use a desktop browser to use OpentronsAI.') + ).toBeInTheDocument() + + vi.unstubAllGlobals() + }) + + it('should redirect to the new protocol page when the create a new protocol button is clicked', () => { + render() + const createProtocolButton = screen.getByText('Create a new protocol') + createProtocolButton.click() + expect(mockNavigate).toHaveBeenCalledWith('/new-protocol') + }) + + it('should redirect to the update protocol page when the update an existing protocol button is clicked', () => { + render() + const updateProtocolButton = screen.getByText('Update an existing protocol') + updateProtocolButton.click() + expect(mockNavigate).toHaveBeenCalledWith('/update-protocol') + }) + + it('should track new protocol event when new protocol button is clicked', () => { + render() + const createProtocolButton = screen.getByText('Create a new protocol') + createProtocolButton.click() + + expect(mockUseTrackEvent).toHaveBeenCalledWith({ + name: 'create-new-protocol', + properties: {}, + }) + }) + + it('should track logout event when log out button is clicked', () => { + render() + const updateProtocolButton = screen.getByText('Update an existing protocol') + updateProtocolButton.click() + + expect(mockUseTrackEvent).toHaveBeenCalledWith({ + name: 'update-protocol', + properties: {}, + }) + }) +}) diff --git a/opentrons-ai-client/src/pages/Landing/index.tsx b/opentrons-ai-client/src/pages/Landing/index.tsx new file mode 100644 index 00000000000..b464ad5ff29 --- /dev/null +++ b/opentrons-ai-client/src/pages/Landing/index.tsx @@ -0,0 +1,89 @@ +import { + ALIGN_CENTER, + BORDERS, + COLORS, + DIRECTION_COLUMN, + Flex, + JUSTIFY_CENTER, + LargeButton, + POSITION_RELATIVE, + SPACING, + StyledText, + TEXT_ALIGN_CENTER, +} from '@opentrons/components' +import welcomeImage from '../../assets/images/welcome_dashboard.png' +import { useTranslation } from 'react-i18next' +import { useIsMobile } from '../../resources/hooks/useIsMobile' +import { useNavigate } from 'react-router-dom' +import { useTrackEvent } from '../../resources/hooks/useTrackEvent' + +export interface InputType { + userPrompt: string +} + +export function Landing(): JSX.Element | null { + const navigate = useNavigate() + const { t } = useTranslation('protocol_generator') + const isMobile = useIsMobile() + const trackEvent = useTrackEvent() + + function handleCreateNewProtocol(): void { + trackEvent({ name: 'create-new-protocol', properties: {} }) + navigate('/new-protocol') + } + + function handleUpdateProtocol(): void { + trackEvent({ name: 'update-protocol', properties: {} }) + navigate('/update-protocol') + } + + return ( + + + {t('landing_page_image_alt')} + + + {t('landing_page_heading')} + + + {!isMobile ? t('landing_page_body') : t('landing_page_body_mobile')} + + + {!isMobile && ( + <> + + + + )} + + + ) +} diff --git a/opentrons-ai-client/src/resources/atoms.ts b/opentrons-ai-client/src/resources/atoms.ts index 2065f7e89e2..73d45fb165b 100644 --- a/opentrons-ai-client/src/resources/atoms.ts +++ b/opentrons-ai-client/src/resources/atoms.ts @@ -1,6 +1,6 @@ // jotai's atoms import { atom } from 'jotai' -import type { Chat, ChatData } from './types' +import type { Chat, ChatData, Mixpanel } from './types' /** ChatDataAtom is for chat data (user prompt and response from OpenAI API) */ export const chatDataAtom = atom([]) @@ -8,3 +8,7 @@ export const chatDataAtom = atom([]) export const chatHistoryAtom = atom([]) export const tokenAtom = atom(null) + +export const mixpanelAtom = atom({ + analytics: { hasOptedIn: true }, // TODO: set to false +}) diff --git a/opentrons-ai-client/src/resources/constants.ts b/opentrons-ai-client/src/resources/constants.ts index 834e58cb1db..c5e2f8826c6 100644 --- a/opentrons-ai-client/src/resources/constants.ts +++ b/opentrons-ai-client/src/resources/constants.ts @@ -19,3 +19,5 @@ export const LOCAL_AUTH0_CLIENT_ID = 'PcuD1wEutfijyglNeRBi41oxsKJ1HtKw' export const LOCAL_AUTH0_AUDIENCE = 'sandbox-ai-api' export const LOCAL_AUTH0_DOMAIN = 'identity.auth-dev.opentrons.com' export const LOCAL_END_POINT = 'http://localhost:8000/api/chat/completion' + +export const CLIENT_MAX_WIDTH = '1440px' diff --git a/opentrons-ai-client/src/resources/hooks/__tests__/useIsMobile.test.ts b/opentrons-ai-client/src/resources/hooks/__tests__/useIsMobile.test.ts new file mode 100644 index 00000000000..bd1374e64b1 --- /dev/null +++ b/opentrons-ai-client/src/resources/hooks/__tests__/useIsMobile.test.ts @@ -0,0 +1,18 @@ +import { describe, it, vi, expect } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useIsMobile } from '../useIsMobile' + +describe('useIsMobile', () => { + it('should return true if the window width is less than 768px', () => { + vi.stubGlobal('innerWidth', 767) + const { result } = renderHook(() => useIsMobile()) + expect(result.current).toBe(true) + }) + + it('should return false if the window width is greater than 768px', () => { + vi.stubGlobal('innerWidth', 769) + window.dispatchEvent(new Event('resize')) + const { result } = renderHook(() => useIsMobile()) + expect(result.current).toBe(false) + }) +}) diff --git a/opentrons-ai-client/src/resources/hooks/__tests__/useTrackEvent.test.tsx b/opentrons-ai-client/src/resources/hooks/__tests__/useTrackEvent.test.tsx new file mode 100644 index 00000000000..fab96155156 --- /dev/null +++ b/opentrons-ai-client/src/resources/hooks/__tests__/useTrackEvent.test.tsx @@ -0,0 +1,60 @@ +import { describe, it, vi, expect, afterEach } from 'vitest' +import { trackEvent } from '../../../analytics/mixpanel' +import { useTrackEvent } from '../useTrackEvent' +import { renderHook } from '@testing-library/react' +import { mixpanelAtom } from '../../atoms' +import type { AnalyticsEvent } from '../../../analytics/mixpanel' +import type { Mixpanel } from '../../types' +import { TestProvider } from '../../utils/testUtils' + +vi.mock('../../../analytics/mixpanel', () => ({ + trackEvent: vi.fn(), +})) + +describe('useTrackEvent', () => { + afterEach(() => { + vi.resetAllMocks() + }) + + it('should call trackEvent with the correct arguments when hasOptedIn is true', () => { + const mockMixpanelAtom: Mixpanel = { + analytics: { + hasOptedIn: true, + }, + } + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ) + + const { result } = renderHook(() => useTrackEvent(), { wrapper }) + + const event: AnalyticsEvent = { name: 'test_event', properties: {} } + result.current(event) + + expect(trackEvent).toHaveBeenCalledWith(event, true) + }) + + it('should call trackEvent with the correct arguments when hasOptedIn is false', () => { + const mockMixpanelAtomFalse: Mixpanel = { + analytics: { + hasOptedIn: false, + }, + } + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ) + + const { result } = renderHook(() => useTrackEvent(), { wrapper }) + + const event: AnalyticsEvent = { name: 'test_event', properties: {} } + result.current(event) + + expect(trackEvent).toHaveBeenCalledWith(event, false) + }) +}) diff --git a/opentrons-ai-client/src/resources/hooks/useIsMobile.ts b/opentrons-ai-client/src/resources/hooks/useIsMobile.ts new file mode 100644 index 00000000000..5c0f4933b75 --- /dev/null +++ b/opentrons-ai-client/src/resources/hooks/useIsMobile.ts @@ -0,0 +1,22 @@ +import { useState, useEffect } from 'react' + +const MOBILE_BREAKPOINT = 768 + +export const useIsMobile = (): boolean => { + const [isMobile, setIsMobile] = useState( + window.innerWidth < MOBILE_BREAKPOINT + ) + + useEffect(() => { + const handleResize = (): void => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + + window.addEventListener('resize', handleResize) + return () => { + window.removeEventListener('resize', handleResize) + } + }, []) + + return isMobile +} diff --git a/opentrons-ai-client/src/resources/hooks/useTrackEvent.ts b/opentrons-ai-client/src/resources/hooks/useTrackEvent.ts new file mode 100644 index 00000000000..bdd9eb1c470 --- /dev/null +++ b/opentrons-ai-client/src/resources/hooks/useTrackEvent.ts @@ -0,0 +1,16 @@ +import { useAtom } from 'jotai' +import { trackEvent } from '../../analytics/mixpanel' +import { mixpanelAtom } from '../atoms' +import type { AnalyticsEvent } from '../types' + +/** + * React hook to send an analytics tracking event directly from a component + * + * @returns {AnalyticsEvent => void} track event function + */ +export function useTrackEvent(): (e: AnalyticsEvent) => void { + const [mixpanel] = useAtom(mixpanelAtom) + return event => { + trackEvent(event, mixpanel?.analytics?.hasOptedIn ?? false) + } +} diff --git a/opentrons-ai-client/src/resources/types.ts b/opentrons-ai-client/src/resources/types.ts index d2758c966ae..067c1ef9764 100644 --- a/opentrons-ai-client/src/resources/types.ts +++ b/opentrons-ai-client/src/resources/types.ts @@ -16,3 +16,29 @@ export interface Chat { /** content ChatGPT API return or user prompt */ content: string } + +export interface RouteProps { + /** the component rendered by a route match + * drop developed components into slots held by placeholder div components + * */ + Component: React.FC + /** a route/page name to render in the nav bar + */ + name: string + /** the path for navigation linking, for example to push to a default tab + */ + path: string + navLinkTo: string +} + +export interface Mixpanel { + analytics: { + hasOptedIn: boolean + } +} + +export interface AnalyticsEvent { + name: string + properties: Record + superProperties?: Record +} diff --git a/opentrons-ai-client/src/resources/utils/testUtils.tsx b/opentrons-ai-client/src/resources/utils/testUtils.tsx new file mode 100644 index 00000000000..954307bd391 --- /dev/null +++ b/opentrons-ai-client/src/resources/utils/testUtils.tsx @@ -0,0 +1,29 @@ +import { Provider } from 'jotai' +import { useHydrateAtoms } from 'jotai/utils' + +interface HydrateAtomsProps { + initialValues: Array<[any, any]> + children: React.ReactNode +} + +interface TestProviderProps { + initialValues: Array<[any, any]> + children: React.ReactNode +} + +export const HydrateAtoms = ({ + initialValues, + children, +}: HydrateAtomsProps): React.ReactNode => { + useHydrateAtoms(initialValues) + return children +} + +export const TestProvider = ({ + initialValues, + children, +}: TestProviderProps): React.ReactNode => ( + + {children} + +) From 3e0cd93522a57391dcb2e3226e31d542a8eb345f Mon Sep 17 00:00:00 2001 From: Elyor Kodirov Date: Thu, 24 Oct 2024 02:01:02 +0500 Subject: [PATCH 05/18] docs(api): fixes to protocol examples (#16545) Fixed minor typos in api/docs/v2/new_examples.rst # Overview ## Test Plan and Hands on Testing No need testing Closes AUTH-963 ## Changelog ## Review requests Look at the changes please ## Risk assessment Low --- api/docs/v2/new_examples.rst | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/api/docs/v2/new_examples.rst b/api/docs/v2/new_examples.rst index 28490e03135..1aae3b633d0 100644 --- a/api/docs/v2/new_examples.rst +++ b/api/docs/v2/new_examples.rst @@ -383,7 +383,7 @@ Opentrons electronic pipettes can do some things that a human cannot do with a p location=3) p300 = protocol.load_instrument( instrument_name="p300_single", - mount="right", + mount="left", tip_racks=[tiprack_1]) p300.pick_up_tip() @@ -442,13 +442,13 @@ This protocol dispenses diluent to all wells of a Corning 96-well plate. Next, i source = reservoir.wells()[i] row = plate.rows()[i] - # transfer 30 µL of source to first well in column - pipette.transfer(30, source, row[0], mix_after=(3, 25)) + # transfer 30 µL of source to first well in column + pipette.transfer(30, source, row[0], mix_after=(3, 25)) - # dilute the sample down the column - pipette.transfer( - 30, row[:11], row[1:], - mix_after=(3, 25)) + # dilute the sample down the column + pipette.transfer( + 30, row[:11], row[1:], + mix_after=(3, 25)) .. tab:: OT-2 @@ -474,7 +474,7 @@ This protocol dispenses diluent to all wells of a Corning 96-well plate. Next, i location=4) p300 = protocol.load_instrument( instrument_name="p300_single", - mount="right", + mount="left", tip_racks=[tiprack_1, tiprack_2]) # Dispense diluent p300.distribute(50, reservoir["A12"], plate.wells()) @@ -483,16 +483,15 @@ This protocol dispenses diluent to all wells of a Corning 96-well plate. Next, i for i in range(8): # save the source well and destination column to variables source = reservoir.wells()[i] - source = reservoir.wells()[i] row = plate.rows()[i] - # transfer 30 µL of source to first well in column - p300.transfer(30, source, row[0], mix_after=(3, 25)) + # transfer 30 µL of source to first well in column + p300.transfer(30, source, row[0], mix_after=(3, 25)) - # dilute the sample down the column - p300.transfer( - 30, row[:11], row[1:], - mix_after=(3, 25)) + # dilute the sample down the column + p300.transfer( + 30, row[:11], row[1:], + mix_after=(3, 25)) Notice here how the code sample loops through the rows and uses slicing to distribute the diluent. For information about these features, see the Loops and Air Gaps examples above. See also, the :ref:`tutorial-commands` section of the Tutorial. From 4887bd54c5c2695ff594cc27d960d2a42f088bb0 Mon Sep 17 00:00:00 2001 From: Nick Diehl <47604184+ncdiehl11@users.noreply.github.com> Date: Wed, 23 Oct 2024 17:20:44 -0400 Subject: [PATCH 06/18] feat(protocol-designer): update heater shaker form fields (#16580) This PR overhauls the new PD heater shaker form. I consolidate shake duration form field (minutes and seconds) into a single time field. I also fix the function of the HS form toggle input fields and update errors, maskers, and default values for the form. I also add form-level HS form errors to catch required fields when they are toggled but no input is given. Closes AUTH-864, Closes AUTH-966 --- .../assets/localization/en/application.json | 3 +- .../src/assets/localization/en/form.json | 16 ++++--- .../localization/en/protocol_steps.json | 1 + .../src/atoms/ToggleButton/index.tsx | 8 ++-- .../src/components/StepEditForm/index.tsx | 2 +- protocol-designer/src/form-types.ts | 1 + .../ToggleExpandStepFormField/index.tsx | 29 ++++++------ .../molecules/ToggleStepFormField/index.tsx | 18 ++++---- .../StepTools/HeaterShakerTools/index.tsx | 28 ++++------- .../StepForm/StepTools/MagnetTools/index.tsx | 1 - .../StepForm/StepTools/PauseTools/index.tsx | 5 +- .../Designer/ProtocolSteps/StepForm/index.tsx | 2 +- .../Designer/ProtocolSteps/StepSummary.tsx | 12 ++--- .../src/steplist/fieldLevel/errors.ts | 4 +- .../src/steplist/fieldLevel/index.ts | 6 +++ .../src/steplist/formLevel/errors.ts | 46 ++++++++++++++++++- .../formLevel/getDefaultsForStepType.ts | 3 ++ .../src/steplist/formLevel/index.ts | 10 ++++ .../stepFormToArgs/heaterShakerFormToArgs.ts | 19 ++++---- .../stepFormToArgs/pauseFormToArgs.ts | 12 +++-- .../test/getDefaultsForStepType.test.ts | 1 + ...imeFromPauseForm.ts => getTimeFromForm.ts} | 23 ++++++---- 22 files changed, 162 insertions(+), 88 deletions(-) rename protocol-designer/src/steplist/utils/{getTimeFromPauseForm.ts => getTimeFromForm.ts} (62%) diff --git a/protocol-designer/src/assets/localization/en/application.json b/protocol-designer/src/assets/localization/en/application.json index 78394f35ab7..b4d1ebf8157 100644 --- a/protocol-designer/src/assets/localization/en/application.json +++ b/protocol-designer/src/assets/localization/en/application.json @@ -43,7 +43,7 @@ "thermocycler": "thermocycler" }, "temperature": "Temperature (˚C)", - "time": "Time (hh:mm:ss)", + "time": "Time", "units": { "cycles": "cycles", "degrees": "°C", @@ -55,6 +55,7 @@ "rpm": "rpm", "seconds": "s", "time": "mm:ss", + "time_hms": "hh:mm:ss", "times": "x" }, "update": "UPDATE", diff --git a/protocol-designer/src/assets/localization/en/form.json b/protocol-designer/src/assets/localization/en/form.json index 80bef5b5a20..995c6ae9d0e 100644 --- a/protocol-designer/src/assets/localization/en/form.json +++ b/protocol-designer/src/assets/localization/en/form.json @@ -72,23 +72,24 @@ "range": "between {{min}} and {{max}}" }, "heaterShaker": { + "duration": "Duration", "latch": { - "setLatch": "Labware Latch", - "toggleOff": "Closed & Locked", + "setLatch": "Labware latch", + "toggleOff": "Close", "toggleOn": "Open" }, "shaker": { - "setShake": "Set shake speed", - "toggleOff": "Deactivated", + "setShake": "Shake speed", + "toggleOff": "Deactivate", "toggleOn": "Active" }, "temperature": { - "setTemperature": "Set temperature", - "toggleOff": "Deactivated", + "setTemperature": "Temperature", + "toggleOff": "Deactivate", "toggleOn": "Active" }, "timer": { - "heaterShakerSetTimer": "Set timer" + "heaterShakerSetTimer": "Timer" } }, "location": { @@ -144,6 +145,7 @@ } }, "pauseAction": { + "duration": "Delay duration", "options": { "untilResume": "Pause until told to resume", "untilTemperature": "Pause until temperature reached", diff --git a/protocol-designer/src/assets/localization/en/protocol_steps.json b/protocol-designer/src/assets/localization/en/protocol_steps.json index b2f00290059..4f7e3a00ed6 100644 --- a/protocol-designer/src/assets/localization/en/protocol_steps.json +++ b/protocol-designer/src/assets/localization/en/protocol_steps.json @@ -93,6 +93,7 @@ "select_volume": "Select a volume", "shake": "Shake", "single": "Single path", + "speed": "Speed", "starting_deck_state": "Starting deck state", "step_substeps": "{{stepType}} details", "temperature": "Temperature", diff --git a/protocol-designer/src/atoms/ToggleButton/index.tsx b/protocol-designer/src/atoms/ToggleButton/index.tsx index 9bb4c45a330..0dfb605ec7b 100644 --- a/protocol-designer/src/atoms/ToggleButton/index.tsx +++ b/protocol-designer/src/atoms/ToggleButton/index.tsx @@ -1,7 +1,7 @@ import type * as React from 'react' import { css } from 'styled-components' -import { Btn, Icon, COLORS } from '@opentrons/components' +import { Btn, Icon, COLORS, Flex } from '@opentrons/components' import type { StyleProps } from '@opentrons/components' @@ -38,8 +38,8 @@ const TOGGLE_ENABLED_STYLES = css` ` interface ToggleButtonProps extends StyleProps { - label: string toggledOn: boolean + label?: string | null disabled?: boolean | null id?: string onClick?: (e: React.MouseEvent) => void @@ -59,7 +59,9 @@ export function ToggleButton(props: ToggleButtonProps): JSX.Element { css={props.toggledOn ? TOGGLE_ENABLED_STYLES : TOGGLE_DISABLED_STYLES} {...buttonProps} > - + + + ) } diff --git a/protocol-designer/src/components/StepEditForm/index.tsx b/protocol-designer/src/components/StepEditForm/index.tsx index 8b54c56c891..747651e7268 100644 --- a/protocol-designer/src/components/StepEditForm/index.tsx +++ b/protocol-designer/src/components/StepEditForm/index.tsx @@ -187,7 +187,7 @@ const StepEditFormManager = ( : '' } handleCancelClick={saveStepForm} - handleContinueClick={confirmAddPauseUntilTempStep} + handleContinueClick={handleSave} // TODO (nd: 10/21/2024) can remove this prop once redesign FF removed moduleType={ showAddPauseUntilTempStepModal diff --git a/protocol-designer/src/form-types.ts b/protocol-designer/src/form-types.ts index 0dc4497cdae..58da6b5676b 100644 --- a/protocol-designer/src/form-types.ts +++ b/protocol-designer/src/form-types.ts @@ -366,6 +366,7 @@ export interface HydratedHeaterShakerFormData { heaterShakerSetTimer: 'true' | 'false' | null heaterShakerTimerMinutes: string | null heaterShakerTimerSeconds: string | null + heaterShakerTimer?: string | null id: string latchOpen: boolean moduleId: string diff --git a/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx b/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx index ed57de37f3b..b27b72b7821 100644 --- a/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx +++ b/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx @@ -18,12 +18,11 @@ interface ToggleExpandStepFormFieldProps extends FieldProps { fieldTitle: string isSelected: boolean units: string - onLabel: string - offLabel: string toggleUpdateValue: (value: unknown) => void toggleValue: unknown + onLabel?: string + offLabel?: string caption?: string - islabel?: boolean } export function ToggleExpandStepFormField( props: ToggleExpandStepFormFieldProps @@ -38,16 +37,24 @@ export function ToggleExpandStepFormField( toggleUpdateValue, toggleValue, caption, - islabel, ...restProps } = props + const resetFieldValue = (): void => { + restProps.updateValue('null') + } + const onToggleUpdateValue = (): void => { if (typeof toggleValue === 'boolean') { toggleUpdateValue(!toggleValue) + if (toggleValue) { + resetFieldValue() + } } else if (toggleValue === 'engage' || toggleValue === 'disengage') { const newToggleValue = toggleValue === 'engage' ? 'disengage' : 'engage' toggleUpdateValue(newToggleValue) + } else if (toggleValue == null) { + toggleUpdateValue(true) } } @@ -60,16 +67,10 @@ export function ToggleExpandStepFormField( > {title} - - {islabel ? ( - - {isSelected ? onLabel : offLabel} - - ) : null} - + + + {isSelected ? onLabel : offLabel ?? null} + { onToggleUpdateValue() diff --git a/protocol-designer/src/molecules/ToggleStepFormField/index.tsx b/protocol-designer/src/molecules/ToggleStepFormField/index.tsx index 51bc2a1ef25..9ce244c7d57 100644 --- a/protocol-designer/src/molecules/ToggleStepFormField/index.tsx +++ b/protocol-designer/src/molecules/ToggleStepFormField/index.tsx @@ -62,14 +62,16 @@ export function ToggleStepFormField( > {isSelected ? onLabel : offLabel} - { - toggleUpdateValue(!toggleValue) - }} - label={isSelected ? onLabel : offLabel} - toggledOn={isSelected} - /> + {isDisabled ? null : ( + { + toggleUpdateValue(!toggleValue) + }} + label={isSelected ? onLabel : offLabel} + toggledOn={isSelected} + /> + )} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/HeaterShakerTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/HeaterShakerTools/index.tsx index eefc9d36717..e82230adeb0 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/HeaterShakerTools/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/HeaterShakerTools/index.tsx @@ -12,9 +12,7 @@ import { } from '@opentrons/components' import { getHeaterShakerLabwareOptions } from '../../../../../../ui/modules/selectors' import { - CheckboxExpandStepFormField, DropdownStepFormField, - InputStepFormField, ToggleExpandStepFormField, ToggleStepFormField, } from '../../../../../../molecules' @@ -90,7 +88,7 @@ export function HeaterShakerTools(props: StepFormProps): JSX.Element { toggleValue={propsForFields.setShake.value} toggleUpdateValue={propsForFields.setShake.updateValue} title={t('form:step_edit_form.field.heaterShaker.shaker.setShake')} - fieldTitle={t('protocol_steps:shake')} + fieldTitle={t('protocol_steps:speed')} isSelected={formData.setShake === true} units={t('units.rpm')} onLabel={t('form:step_edit_form.field.heaterShaker.shaker.toggleOn')} @@ -112,25 +110,17 @@ export function HeaterShakerTools(props: StepFormProps): JSX.Element { : null } /> - - {/* TODO: wire up the new timer with the combined field */} - {formData.heaterShakerSetTimer === true ? ( - - ) : null} - + fieldTitle={t('form:step_edit_form.field.heaterShaker.duration')} + isSelected={formData.heaterShakerSetTimer === true} + units={t('application:units.time')} + /> ) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx index e98727dd045..42768144177 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx @@ -123,7 +123,6 @@ export function MagnetTools(props: StepFormProps): JSX.Element { 'form:step_edit_form.field.magnetAction.options.disengage' )} caption={engageHeightCaption} - islabel={true} /> diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/PauseTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/PauseTools/index.tsx index e76153a102e..1ab9d90ce44 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/PauseTools/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/PauseTools/index.tsx @@ -139,10 +139,11 @@ export function PauseTools(props: StepFormProps): JSX.Element { > @@ -193,7 +194,7 @@ export function PauseTools(props: StepFormProps): JSX.Element { gridGap={SPACING.spacing4} paddingX={SPACING.spacing16} > - + {i18n.format( t('form:step_edit_form.field.pauseMessage.label'), 'capitalize' diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/index.tsx index 0272a35e618..0b7049bd546 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/index.tsx @@ -180,7 +180,7 @@ function StepFormManager(props: StepFormManagerProps): JSX.Element | null { : '' } handleCancelClick={saveStepForm} - handleContinueClick={confirmAddPauseUntilTempStep} + handleContinueClick={handleSave} moduleType={ showAddPauseUntilTempStepModal ? TEMPERATURE_MODULE_TYPE diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepSummary.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepSummary.tsx index 0d78ea5dc08..18938c3975b 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepSummary.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepSummary.tsx @@ -327,8 +327,7 @@ export function StepSummary(props: StepSummaryProps): JSX.Element | null { case 'heaterShaker': const { latchOpen, - heaterShakerTimerMinutes, - heaterShakerTimerSeconds, + heaterShakerTimer, moduleId: heaterShakerModuleId, targetHeaterShakerTemperature, targetSpeed, @@ -343,14 +342,14 @@ export function StepSummary(props: StepSummaryProps): JSX.Element | null { i18nKey="protocol_steps:heater_shaker.active.temperature" values={{ module: moduleDisplayName }} tagText={ - targetHeaterShakerTemperature != null + targetHeaterShakerTemperature ? `${targetHeaterShakerTemperature}${t( 'application:units.degrees' )}` : t('protocol_steps:heater_shaker.active.ambient') } /> - {targetSpeed != null ? ( + {targetSpeed ? ( - {heaterShakerTimerMinutes != null && - heaterShakerTimerSeconds != null ? ( + {heaterShakerTimer ? ( ) : null} !value ? FIELD_ERRORS.REQUIRED : null export const isTimeFormat: ErrorChecker = (value: unknown): string | null => { const timeRegex = new RegExp(/^\d{1,2}:\d{1,2}:\d{1,2}$/g) - return (typeof value === 'string' && timeRegex.test(value)) || value == null + return (typeof value === 'string' && timeRegex.test(value)) || !value ? null : FIELD_ERRORS.BAD_TIME_HMS } @@ -43,7 +43,7 @@ export const isTimeFormatMinutesSeconds: ErrorChecker = ( value: unknown ): string | null => { const timeRegex = new RegExp(/^\d{1,2}:\d{1,2}$/g) - return (typeof value === 'string' && timeRegex.test(value)) || value == null + return (typeof value === 'string' && timeRegex.test(value)) || !value ? null : FIELD_ERRORS.BAD_TIME_MS } diff --git a/protocol-designer/src/steplist/fieldLevel/index.ts b/protocol-designer/src/steplist/fieldLevel/index.ts index b9367adaa60..96e04bfed07 100644 --- a/protocol-designer/src/steplist/fieldLevel/index.ts +++ b/protocol-designer/src/steplist/fieldLevel/index.ts @@ -8,6 +8,7 @@ import { temperatureRangeFieldValue, realNumber, isTimeFormat, + isTimeFormatMinutesSeconds, } from './errors' import { maskToInteger, @@ -345,6 +346,11 @@ const stepFieldHelperMap: Record = { maskValue: composeMaskers(maskToInteger, onlyPositiveNumbers), castValue: Number, }, + heaterShakerTimer: { + maskValue: composeMaskers(maskToTime), + getErrors: composeErrors(isTimeFormatMinutesSeconds), + castValue: String, + }, pauseAction: { getErrors: composeErrors(requiredField), }, diff --git a/protocol-designer/src/steplist/formLevel/errors.ts b/protocol-designer/src/steplist/formLevel/errors.ts index ada2cef64fc..e5203cdc84e 100644 --- a/protocol-designer/src/steplist/formLevel/errors.ts +++ b/protocol-designer/src/steplist/formLevel/errors.ts @@ -15,7 +15,7 @@ import { import { getPipetteCapacity } from '../../pipettes/pipetteData' import { canPipetteUseLabware } from '../../utils' import { getWellRatio } from '../utils' -import { getTimeFromPauseForm } from '../utils/getTimeFromPauseForm' +import { getTimeFromForm } from '../utils/getTimeFromForm' import type { LabwareDefinition2, PipetteV2Specs } from '@opentrons/shared-data' import type { LabwareEntities, PipetteEntity } from '@opentrons/step-generation' @@ -137,6 +137,22 @@ const LID_TEMPERATURE_HOLD_REQUIRED: FormError = { title: 'Temperature is required', dependentFields: ['lidIsActiveHold', 'lidTargetTempHold'], } +const SHAKE_SPEED_REQUIRED: FormError = { + title: 'Shake speed required', + dependentFields: ['setShake', 'targetSpeed'], +} +const SHAKE_TIME_REQUIRED: FormError = { + title: 'Shake duration required', + dependentFields: ['heaterShakerSetTimer', 'heaterShakerTimer'], +} +const HS_TEMPERATURE_REQUIRED: FormError = { + title: 'Temperature required', + dependentFields: [ + 'targetHeaterShakerTemperature', + 'setHeaterShakerTemperature', + ], +} + export interface HydratedFormData { [key: string]: any } @@ -198,7 +214,13 @@ export const pauseForTimeOrUntilTold = ( const { pauseAction, moduleId, pauseTemperature } = fields if (pauseAction === PAUSE_UNTIL_TIME) { - const { hours, minutes, seconds } = getTimeFromPauseForm(fields) + const { hours, minutes, seconds } = getTimeFromForm( + fields, + 'pauseTime', + 'pauseSeconds', + 'pauseMinutes', + 'pauseSeconds' + ) // user selected pause for amount of time const totalSeconds = hours * 3600 + minutes * 60 + seconds return totalSeconds <= 0 ? TIME_PARAM_REQUIRED : null @@ -342,6 +364,26 @@ export const lidTemperatureHoldRequired = ( ? LID_TEMPERATURE_HOLD_REQUIRED : null } +export const shakeSpeedRequired = ( + fields: HydratedFormData +): FormError | null => { + const { targetSpeed, setShake } = fields + return setShake && !targetSpeed ? SHAKE_SPEED_REQUIRED : null +} +export const shakeTimeRequired = ( + fields: HydratedFormData +): FormError | null => { + const { heaterShakerTimer, heaterShakerSetTimer } = fields + return heaterShakerSetTimer && !heaterShakerTimer ? SHAKE_TIME_REQUIRED : null +} +export const temperatureRequired = ( + fields: HydratedFormData +): FormError | null => { + const { setHeaterShakerTemperature, targetHeaterShakerTemperature } = fields + return setHeaterShakerTemperature && !targetHeaterShakerTemperature + ? HS_TEMPERATURE_REQUIRED + : null +} export const engageHeightRangeExceeded = ( fields: HydratedFormData ): FormError | null => { diff --git a/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts b/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts index b669b865e4e..40b35b3ccad 100644 --- a/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts +++ b/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts @@ -120,6 +120,7 @@ export function getDefaultsForStepType( return { moduleId: null, pauseAction: null, + // TODO: (nd: 10/23/2024) remove individual time unit fields pauseHour: null, pauseMessage: '', pauseMinute: null, @@ -151,8 +152,10 @@ export function getDefaultsForStepType( case 'heaterShaker': return { heaterShakerSetTimer: null, + // TODO: (nd: 10/23/2024) remove individual time unit fields heaterShakerTimerMinutes: null, heaterShakerTimerSeconds: null, + heaterShakerTimer: null, latchOpen: false, moduleId: null, setHeaterShakerTemperature: null, diff --git a/protocol-designer/src/steplist/formLevel/index.ts b/protocol-designer/src/steplist/formLevel/index.ts index efa9334315e..1d67206c82b 100644 --- a/protocol-designer/src/steplist/formLevel/index.ts +++ b/protocol-designer/src/steplist/formLevel/index.ts @@ -17,6 +17,9 @@ import { blockTemperatureHoldRequired, lidTemperatureHoldRequired, volumeTooHigh, + shakeSpeedRequired, + temperatureRequired, + shakeTimeRequired, } from './errors' import { @@ -51,6 +54,13 @@ interface FormHelpers { getWarnings?: (arg: unknown) => FormWarning[] } const stepFormHelperMap: Partial> = { + heaterShaker: { + getErrors: composeErrors( + shakeSpeedRequired, + shakeTimeRequired, + temperatureRequired + ), + }, mix: { getErrors: composeErrors(incompatibleLabware, volumeTooHigh), getWarnings: composeWarnings( diff --git a/protocol-designer/src/steplist/formLevel/stepFormToArgs/heaterShakerFormToArgs.ts b/protocol-designer/src/steplist/formLevel/stepFormToArgs/heaterShakerFormToArgs.ts index 8366864f190..eda1e073fb2 100644 --- a/protocol-designer/src/steplist/formLevel/stepFormToArgs/heaterShakerFormToArgs.ts +++ b/protocol-designer/src/steplist/formLevel/stepFormToArgs/heaterShakerFormToArgs.ts @@ -1,3 +1,4 @@ +import { getTimeFromForm } from '../../utils/getTimeFromForm' import type { HeaterShakerArgs } from '@opentrons/step-generation' import type { HydratedHeaterShakerFormData } from '../../../form-types' @@ -22,6 +23,14 @@ export const heaterShakerFormToArgs = ( setShake ? !Number.isNaN(targetSpeed) : true, 'heaterShakerFormToArgs expected targeShake to be a number when setShake is true' ) + const { minutes, seconds } = getTimeFromForm( + formData, + 'heaterShakerTimer', + 'heaterShakerTimerSeconds', + 'heaterShakerTimerMinutes' + ) + + const isNullTime = minutes === 0 && seconds === 0 const targetTemperature = setHeaterShakerTemperature && targetHeaterShakerTemperature != null @@ -36,13 +45,7 @@ export const heaterShakerFormToArgs = ( targetTemperature: targetTemperature, rpm: targetShake, latchOpen: latchOpen, - timerMinutes: - formData.heaterShakerTimerMinutes != null - ? parseInt(formData.heaterShakerTimerMinutes) - : null, - timerSeconds: - formData.heaterShakerTimerSeconds != null - ? parseInt(formData.heaterShakerTimerSeconds) - : null, + timerMinutes: isNullTime ? null : minutes, + timerSeconds: isNullTime ? null : seconds, } } diff --git a/protocol-designer/src/steplist/formLevel/stepFormToArgs/pauseFormToArgs.ts b/protocol-designer/src/steplist/formLevel/stepFormToArgs/pauseFormToArgs.ts index cc7a32a13e3..88de52bacec 100644 --- a/protocol-designer/src/steplist/formLevel/stepFormToArgs/pauseFormToArgs.ts +++ b/protocol-designer/src/steplist/formLevel/stepFormToArgs/pauseFormToArgs.ts @@ -1,4 +1,4 @@ -import { getTimeFromPauseForm } from '../../utils/getTimeFromPauseForm' +import { getTimeFromForm } from '../../utils/getTimeFromForm' import { PAUSE_UNTIL_TIME, PAUSE_UNTIL_TEMP, @@ -13,8 +13,14 @@ import type { export const pauseFormToArgs = ( formData: FormData ): PauseArgs | WaitForTemperatureArgs | null => { - const { hours, minutes, seconds } = getTimeFromPauseForm(formData) - const totalSeconds = hours * 3600 + minutes * 60 + seconds + const { hours, minutes, seconds } = getTimeFromForm( + formData, + 'pauseTime', + 'pauseSecond', + 'pauseMinute', + 'pauseHour' + ) + const totalSeconds = (hours ?? 0) * 3600 + minutes * 60 + seconds const temperature = parseFloat(formData.pauseTemperature as string) const message = formData.pauseMessage ?? '' diff --git a/protocol-designer/src/steplist/formLevel/test/getDefaultsForStepType.test.ts b/protocol-designer/src/steplist/formLevel/test/getDefaultsForStepType.test.ts index 5af511e500d..ba0897607ac 100644 --- a/protocol-designer/src/steplist/formLevel/test/getDefaultsForStepType.test.ts +++ b/protocol-designer/src/steplist/formLevel/test/getDefaultsForStepType.test.ts @@ -164,6 +164,7 @@ describe('getDefaultsForStepType', () => { heaterShakerSetTimer: null, heaterShakerTimerMinutes: null, heaterShakerTimerSeconds: null, + heaterShakerTimer: null, }) }) }) diff --git a/protocol-designer/src/steplist/utils/getTimeFromPauseForm.ts b/protocol-designer/src/steplist/utils/getTimeFromForm.ts similarity index 62% rename from protocol-designer/src/steplist/utils/getTimeFromPauseForm.ts rename to protocol-designer/src/steplist/utils/getTimeFromForm.ts index ed1b2243b49..ff35bdaeb09 100644 --- a/protocol-designer/src/steplist/utils/getTimeFromPauseForm.ts +++ b/protocol-designer/src/steplist/utils/getTimeFromForm.ts @@ -4,28 +4,33 @@ import type { HydratedFormData } from '../formLevel/errors' const TIME_DELIMITER = ':' interface TimeData { - hours: number minutes: number seconds: number + hours: number } -export const getTimeFromPauseForm = ( - formData: FormData | HydratedFormData +export const getTimeFromForm = ( + formData: FormData | HydratedFormData, + timeField: string, + secondsField: string, + minutesField: string, + hoursField?: string ): TimeData => { let hoursFromForm let minutesFromForm let secondsFromForm // importing results in stringified "null" value - if (formData.pauseTime != null && formData.pauseTime !== 'null') { - const timeSplit = formData.pauseTime.split(TIME_DELIMITER) - ;[hoursFromForm, minutesFromForm, secondsFromForm] = timeSplit + if (formData[timeField] != null && formData[timeField] !== 'null') { + const timeSplit = formData[timeField].split(TIME_DELIMITER) + ;[hoursFromForm, minutesFromForm, secondsFromForm] = + timeSplit.length === 3 ? timeSplit : [0, ...timeSplit] } else { // TODO (nd 09/23/2024): remove individual time units after redesign FF is removed ;[hoursFromForm, minutesFromForm, secondsFromForm] = [ - formData.pauseHour, - formData.pauseMinute, - formData.pauseSecond, + hoursField != null ? formData[hoursField] : null, + formData[minutesField], + formData[secondsField], ] } const hours = isNaN(parseFloat(hoursFromForm as string)) From e520e419bb0f08a754a9bf108c92fbe285260b91 Mon Sep 17 00:00:00 2001 From: Jethary Alcid <66035149+jerader@users.noreply.github.com> Date: Wed, 23 Oct 2024 17:24:36 -0400 Subject: [PATCH 07/18] fix(step-generation): add wait for temperature timeline warning (#16581) closes RESC-329 AUTH-891 --- .../src/assets/localization/en/alert.json | 4 +++ .../src/__tests__/moveLabware.test.ts | 2 +- .../src/__tests__/waitForTemperature.test.ts | 8 +++++- .../atomic/waitForTemperature.ts | 26 ++++++++++++++++--- step-generation/src/types.ts | 1 + step-generation/src/warningCreators.ts | 14 +++++++--- 6 files changed, 46 insertions(+), 9 deletions(-) diff --git a/protocol-designer/src/assets/localization/en/alert.json b/protocol-designer/src/assets/localization/en/alert.json index 66a0a406a47..248d70a0aec 100644 --- a/protocol-designer/src/assets/localization/en/alert.json +++ b/protocol-designer/src/assets/localization/en/alert.json @@ -219,6 +219,10 @@ "TIPRACK_IN_WASTE_CHUTE_HAS_TIPS": { "title": "Disposing unused tips", "body": "This step moves a tip rack that contains unused tips to the waste chute. There is no way to retrieve the tips after disposal." + }, + "TEMPERATURE_IS_POTENTIALLY_UNREACHABLE": { + "title": "The pause temperature is potentially unreachable", + "body": "This step tries to set the module temperature but it can possibly not be reached, resulting in your protocol running forever." } } }, diff --git a/step-generation/src/__tests__/moveLabware.test.ts b/step-generation/src/__tests__/moveLabware.test.ts index ddc8e6008de..1f1a973a520 100644 --- a/step-generation/src/__tests__/moveLabware.test.ts +++ b/step-generation/src/__tests__/moveLabware.test.ts @@ -372,7 +372,7 @@ describe('moveLabware', () => { ) expect(result.warnings).toEqual([ { - message: 'Disposing of a tiprack with tips', + message: 'Disposing unused tips', type: 'TIPRACK_IN_WASTE_CHUTE_HAS_TIPS', }, ]) diff --git a/step-generation/src/__tests__/waitForTemperature.test.ts b/step-generation/src/__tests__/waitForTemperature.test.ts index 7cdd64333d6..5d730bb03ee 100644 --- a/step-generation/src/__tests__/waitForTemperature.test.ts +++ b/step-generation/src/__tests__/waitForTemperature.test.ts @@ -46,7 +46,7 @@ describe('waitForTemperature', () => { invariantContext = stateAndContext.invariantContext robotState = stateAndContext.robotState }) - it('temperature module id exists and temp status is approaching temp', () => { + it('temperature module id exists and temp status is approaching temp with a warning that the temp might not be hit', () => { const temperature = 20 const args: WaitForTemperatureArgs = { module: temperatureModuleId, @@ -70,6 +70,12 @@ describe('waitForTemperature', () => { }, }, ], + warnings: [ + { + type: 'TEMPERATURE_IS_POTENTIALLY_UNREACHABLE', + message: expect.any(String), + }, + ], } const result = waitForTemperature( args, diff --git a/step-generation/src/commandCreators/atomic/waitForTemperature.ts b/step-generation/src/commandCreators/atomic/waitForTemperature.ts index e01fbdf6d5f..e12ed70c0ec 100644 --- a/step-generation/src/commandCreators/atomic/waitForTemperature.ts +++ b/step-generation/src/commandCreators/atomic/waitForTemperature.ts @@ -3,10 +3,19 @@ import { TEMPERATURE_MODULE_TYPE, } from '@opentrons/shared-data' import { uuid } from '../../utils' -import { TEMPERATURE_AT_TARGET, TEMPERATURE_DEACTIVATED } from '../../constants' -import * as errorCreators from '../../errorCreators' -import type { CommandCreator, WaitForTemperatureArgs } from '../../types' +import { + TEMPERATURE_APPROACHING_TARGET, + TEMPERATURE_AT_TARGET, + TEMPERATURE_DEACTIVATED, +} from '../../constants' import { getModuleState } from '../../robotStateSelectors' +import * as errorCreators from '../../errorCreators' +import * as warningCreators from '../../warningCreators' +import type { + CommandCreator, + CommandCreatorWarning, + WaitForTemperatureArgs, +} from '../../types' /** Set temperature target for specified module. */ export const waitForTemperature: CommandCreator = ( @@ -16,6 +25,7 @@ export const waitForTemperature: CommandCreator = ( ) => { const { module, temperature } = args const moduleState = module ? getModuleState(prevRobotState, module) : null + const warnings: CommandCreatorWarning[] = [] if (module === null || !moduleState) { return { @@ -42,6 +52,10 @@ export const waitForTemperature: CommandCreator = ( 'status' in moduleState && moduleState.status === TEMPERATURE_AT_TARGET && moduleState.targetTemperature !== temperature + const potentiallyUnreachableTemp = + 'status' in moduleState && + moduleState.status === TEMPERATURE_APPROACHING_TARGET && + moduleState.targetTemperature !== temperature if ( unreachableTemp || @@ -52,6 +66,10 @@ export const waitForTemperature: CommandCreator = ( } } + if (potentiallyUnreachableTemp) { + warnings.push(warningCreators.potentiallyUnreachableTemp()) + } + const moduleType = invariantContext.moduleEntities[module]?.type switch (moduleType) { @@ -67,6 +85,7 @@ export const waitForTemperature: CommandCreator = ( }, }, ], + warnings: warnings.length > 0 ? warnings : undefined, } case HEATERSHAKER_MODULE_TYPE: @@ -81,6 +100,7 @@ export const waitForTemperature: CommandCreator = ( }, }, ], + warnings: warnings.length > 0 ? warnings : undefined, } default: diff --git a/step-generation/src/types.ts b/step-generation/src/types.ts index 149d7060316..021b6cfa515 100644 --- a/step-generation/src/types.ts +++ b/step-generation/src/types.ts @@ -575,6 +575,7 @@ export type WarningType = | 'ASPIRATE_FROM_PRISTINE_WELL' | 'LABWARE_IN_WASTE_CHUTE_HAS_LIQUID' | 'TIPRACK_IN_WASTE_CHUTE_HAS_TIPS' + | 'TEMPERATURE_IS_POTENTIALLY_UNREACHABLE' export interface CommandCreatorWarning { message: string diff --git a/step-generation/src/warningCreators.ts b/step-generation/src/warningCreators.ts index 69959fcbe15..8936b2a9f7a 100644 --- a/step-generation/src/warningCreators.ts +++ b/step-generation/src/warningCreators.ts @@ -2,14 +2,13 @@ import type { CommandCreatorWarning } from './types' export function aspirateMoreThanWellContents(): CommandCreatorWarning { return { type: 'ASPIRATE_MORE_THAN_WELL_CONTENTS', - message: 'Not enough liquid in well(s)', + message: 'Not enough liquid', } } export function aspirateFromPristineWell(): CommandCreatorWarning { return { type: 'ASPIRATE_FROM_PRISTINE_WELL', - message: - 'Aspirating from a pristine well. No liquids were ever added to this well', + message: 'This step tries to aspirate from an empty well.', } } export function labwareInWasteChuteHasLiquid(): CommandCreatorWarning { @@ -21,6 +20,13 @@ export function labwareInWasteChuteHasLiquid(): CommandCreatorWarning { export function tiprackInWasteChuteHasTips(): CommandCreatorWarning { return { type: 'TIPRACK_IN_WASTE_CHUTE_HAS_TIPS', - message: 'Disposing of a tiprack with tips', + message: 'Disposing unused tips', + } +} + +export function potentiallyUnreachableTemp(): CommandCreatorWarning { + return { + type: 'TEMPERATURE_IS_POTENTIALLY_UNREACHABLE', + message: 'The module set temperature is potentially unreachable.', } } From 5965706ec2093e0d6a290daca62f0e77d4865e44 Mon Sep 17 00:00:00 2001 From: koji Date: Wed, 23 Oct 2024 17:34:25 -0400 Subject: [PATCH 08/18] fix(protocol-designer): change DEFAULT_SLOT_MAP_OT2 TC slot to be 7 instead of span7_8_10_11 (#16585) * fix(protocol-designer): change DEFAULT_SLOT_MAP_OT2 TC slot to be 7 instead of span7_8_10_11 --- .../src/pages/CreateNewProtocolWizard/constants.ts | 3 +-- .../src/pages/ProtocolOverview/DeckThumbnailDetails.tsx | 7 +------ 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/constants.ts b/protocol-designer/src/pages/CreateNewProtocolWizard/constants.ts index ae220daf450..6e762e48f0a 100644 --- a/protocol-designer/src/pages/CreateNewProtocolWizard/constants.ts +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/constants.ts @@ -8,7 +8,6 @@ import { MAGNETIC_MODULE_V1, MAGNETIC_MODULE_V2, OT2_ROBOT_TYPE, - SPAN7_8_10_11_SLOT, TEMPERATURE_MODULE_TYPE, TEMPERATURE_MODULE_V1, TEMPERATURE_MODULE_V2, @@ -132,7 +131,7 @@ export const DEFAULT_SLOT_MAP_FLEX: { } export const DEFAULT_SLOT_MAP_OT2: { [moduleType in ModuleType]?: string } = { - [THERMOCYCLER_MODULE_TYPE]: SPAN7_8_10_11_SLOT, + [THERMOCYCLER_MODULE_TYPE]: '7', [HEATERSHAKER_MODULE_TYPE]: '1', [MAGNETIC_MODULE_TYPE]: '1', [TEMPERATURE_MODULE_TYPE]: '3', diff --git a/protocol-designer/src/pages/ProtocolOverview/DeckThumbnailDetails.tsx b/protocol-designer/src/pages/ProtocolOverview/DeckThumbnailDetails.tsx index 36caf29c4ad..5dde2a5f4d6 100644 --- a/protocol-designer/src/pages/ProtocolOverview/DeckThumbnailDetails.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/DeckThumbnailDetails.tsx @@ -10,7 +10,6 @@ import { inferModuleOrientationFromXCoordinate, isAddressableAreaStandardSlot, THERMOCYCLER_MODULE_TYPE, - SPAN7_8_10_11_SLOT, } from '@opentrons/shared-data' import { LabwareOnDeck } from '../../components/DeckSetup/LabwareOnDeck' import { getStagingAreaAddressableAreas } from '../../utils' @@ -67,11 +66,7 @@ export const DeckThumbnailDetails = ( <> {/* all modules */} {allModules.map(({ id, slot, model, type, moduleState }) => { - const slotId = - slot === SPAN7_8_10_11_SLOT && type === THERMOCYCLER_MODULE_TYPE - ? '7' - : slot - + const slotId = slot const slotPosition = getPositionFromSlotId(slotId, deckDef) if (slotPosition == null) { console.warn(`no slot ${slotId} for module ${id}`) From 1d7b1f7770ca5c1438ab67c17cff08d853045574 Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Wed, 23 Oct 2024 17:36:29 -0400 Subject: [PATCH 09/18] fix(app): Fix "carraige" typo to "carriage" (#16582) --- app/src/assets/localization/en/pipette_wizard_flows.json | 2 +- .../PipetteWizardFlows/__tests__/UnskippableModal.test.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/assets/localization/en/pipette_wizard_flows.json b/app/src/assets/localization/en/pipette_wizard_flows.json index 53ae23d07e2..78dc2b852a6 100644 --- a/app/src/assets/localization/en/pipette_wizard_flows.json +++ b/app/src/assets/localization/en/pipette_wizard_flows.json @@ -49,7 +49,7 @@ "install_probe": "Take the calibration probe from its storage location. Ensure its collar is unlocked. Push the pipette ejector up and press the probe firmly onto the {{location}} pipette nozzle. Twist the collar to lock the probe. Test that the probe is secure by gently pulling it back and forth.", "loose_detach": "Loosen screws and detach ", "move_gantry_to_front": "Move gantry to front", - "must_detach_mounting_plate": "You must detach the mounting plate and reattach the z-axis carraige before using other pipettes. We do not recommend exiting this process before completion.", + "must_detach_mounting_plate": "You must detach the mounting plate and reattach the z-axis carriage before using other pipettes. We do not recommend exiting this process before completion.", "name_and_volume_detected": "{{name}} Pipette Detected", "next": "next", "ninety_six_channel": "{{ninetySix}} pipette", diff --git a/app/src/organisms/PipetteWizardFlows/__tests__/UnskippableModal.test.tsx b/app/src/organisms/PipetteWizardFlows/__tests__/UnskippableModal.test.tsx index a290e689809..bc738d0caf3 100644 --- a/app/src/organisms/PipetteWizardFlows/__tests__/UnskippableModal.test.tsx +++ b/app/src/organisms/PipetteWizardFlows/__tests__/UnskippableModal.test.tsx @@ -24,7 +24,7 @@ describe('UnskippableModal', () => { render(props) screen.getByText('This is a critical step that should not be skipped') screen.getByText( - 'You must detach the mounting plate and reattach the z-axis carraige before using other pipettes. We do not recommend exiting this process before completion.' + 'You must detach the mounting plate and reattach the z-axis carriage before using other pipettes. We do not recommend exiting this process before completion.' ) fireEvent.click(screen.getByRole('button', { name: 'Go back' })) expect(props.goBack).toHaveBeenCalled() @@ -39,7 +39,7 @@ describe('UnskippableModal', () => { render(props) screen.getByText('This is a critical step that should not be skipped') screen.getByText( - 'You must detach the mounting plate and reattach the z-axis carraige before using other pipettes. We do not recommend exiting this process before completion.' + 'You must detach the mounting plate and reattach the z-axis carriage before using other pipettes. We do not recommend exiting this process before completion.' ) screen.getByText('Exit') screen.getByText('Go back') From 5c04eabfea08f405387562084fc53e97fdaaa0e1 Mon Sep 17 00:00:00 2001 From: koji Date: Thu, 24 Oct 2024 08:41:06 -0400 Subject: [PATCH 10/18] fix(protocol-designer): fix ListItemDescriptor content and description alignment issue (#16588) fix ListItemDescriptor content and description alignment issue --- .../ListItemChildren/ListItemDescriptor.tsx | 13 ++- .../organisms/MaterialsListModal/index.tsx | 80 +++++++++++------- .../src/organisms/SlotInformation/index.tsx | 26 +++--- .../StepForm/StepTools/MagnetTools/index.tsx | 1 - .../ProtocolOverview/InstrumentsInfo.tsx | 83 +++++++++++++++---- .../ProtocolOverview/LiquidDefinitions.tsx | 17 +++- .../ProtocolOverview/ProtocolMetadata.tsx | 49 ++++++++--- .../src/pages/ProtocolOverview/StepsInfo.tsx | 14 ++-- 8 files changed, 194 insertions(+), 89 deletions(-) diff --git a/components/src/atoms/ListItem/ListItemChildren/ListItemDescriptor.tsx b/components/src/atoms/ListItem/ListItemChildren/ListItemDescriptor.tsx index 12b494ea894..dcedecaa9f8 100644 --- a/components/src/atoms/ListItem/ListItemChildren/ListItemDescriptor.tsx +++ b/components/src/atoms/ListItem/ListItemChildren/ListItemDescriptor.tsx @@ -8,15 +8,14 @@ import { SPACING } from '../../../ui-style-constants' interface ListItemDescriptorProps { type: 'default' | 'large' - description: JSX.Element | string - content: JSX.Element | string - isInSlideout?: boolean + description: JSX.Element + content: JSX.Element } export const ListItemDescriptor = ( props: ListItemDescriptorProps ): JSX.Element => { - const { description, content, type, isInSlideout = false } = props + const { description, content, type } = props return ( - - {description} - - {content} + {description} + {content} ) } diff --git a/protocol-designer/src/organisms/MaterialsListModal/index.tsx b/protocol-designer/src/organisms/MaterialsListModal/index.tsx index 6fa69307eef..14324594969 100644 --- a/protocol-designer/src/organisms/MaterialsListModal/index.tsx +++ b/protocol-designer/src/organisms/MaterialsListModal/index.tsx @@ -11,7 +11,6 @@ import { DIRECTION_ROW, Flex, InfoScreen, - JUSTIFY_SPACE_BETWEEN, LiquidIcon, ListItem, ListItemDescriptor, @@ -32,13 +31,14 @@ import { getInitialDeckSetup } from '../../step-forms/selectors' import { getTopPortalEl } from '../../components/portals/TopPortal' import { selectors as labwareIngredSelectors } from '../../labware-ingred/selectors' import { HandleEnter } from '../../atoms/HandleEnter' +import { LINE_CLAMP_TEXT_STYLE } from '../../atoms' import type { AdditionalEquipmentName } from '@opentrons/step-generation' import type { LabwareOnDeck, ModuleOnDeck } from '../../step-forms' import type { OrderedLiquids } from '../../labware-ingred/types' // ToDo (kk:09/04/2024) this should be removed when break-point is set up -const MODAL_MIN_WIDTH = '36.1875rem' +const MODAL_MIN_WIDTH = '37.125rem' export interface FixtureInList { name: AdditionalEquipmentName @@ -82,6 +82,7 @@ export function MaterialsListModal({ title={t('materials_list')} marginLeft="0rem" minWidth={MODAL_MIN_WIDTH} + childrenPadding={SPACING.spacing24} > @@ -95,13 +96,18 @@ export function MaterialsListModal({ - ) : ( - '' - ) + + {fixture.location != null ? ( + + ) : ( + '' + )} + } content={ + + + } content={ + + + + } + content={ + + {lw.def.metadata.displayName} + } - content={lw.def.metadata.displayName} /> ) @@ -246,29 +262,31 @@ export function MaterialsListModal({ } else { return ( - - - - - {liquid.name ?? t('n/a')} - - - - + + + + {liquid.name ?? t('n/a')} + + + } + content={ - - + } + /> ) } diff --git a/protocol-designer/src/organisms/SlotInformation/index.tsx b/protocol-designer/src/organisms/SlotInformation/index.tsx index cd3550ed7d5..a945ebffcd7 100644 --- a/protocol-designer/src/organisms/SlotInformation/index.tsx +++ b/protocol-designer/src/organisms/SlotInformation/index.tsx @@ -1,4 +1,3 @@ -import type * as React from 'react' import { useTranslation } from 'react-i18next' import { useLocation } from 'react-router-dom' import { @@ -12,6 +11,7 @@ import { StyledText, TYPOGRAPHY, } from '@opentrons/components' +import type { FC } from 'react' import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' import type { RobotType } from '@opentrons/shared-data' @@ -25,7 +25,7 @@ interface SlotInformationProps { fixtures?: string[] } -export const SlotInformation: React.FC = ({ +export const SlotInformation: FC = ({ location, robotType, liquids = [], @@ -50,10 +50,10 @@ export const SlotInformation: React.FC = ({ {liquids.length > 1 ? ( - + {liquids.join(', ')}} description={t('liquid')} /> @@ -115,18 +115,14 @@ function StackInfo({ title, stackInformation }: StackInfoProps): JSX.Element { - {stackInformation} - - ) : ( - t('none') - ) + + {stackInformation ?? t('none')} + } - description={title} + description={{title}} /> ) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx index 42768144177..2468923d9c2 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx @@ -83,7 +83,6 @@ export function MagnetTools(props: StepFormProps): JSX.Element { + {' '} + + {t('robotType')} + + + } content={ - robotType === FLEX_ROBOT_TYPE - ? t('shared:opentrons_flex') - : t('shared:ot2') + + {robotType === FLEX_ROBOT_TYPE + ? t('shared:opentrons_flex') + : t('shared:ot2')} + } /> + {' '} + + {t('left_pip')} + + + } + content={ + + {pipetteInfo(leftPipette)} + + } /> + {' '} + + {t('right_pip')} + + + } + content={ + + {pipetteInfo(rightPipette)} + + } /> {robotType === FLEX_ROBOT_TYPE ? ( + {' '} + + {t('extension')} + + + } + content={ + + {isGripperAttached ? t('gripper') : t('na')} + + } /> ) : null} diff --git a/protocol-designer/src/pages/ProtocolOverview/LiquidDefinitions.tsx b/protocol-designer/src/pages/ProtocolOverview/LiquidDefinitions.tsx index fc767242929..dc753bee12f 100644 --- a/protocol-designer/src/pages/ProtocolOverview/LiquidDefinitions.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/LiquidDefinitions.tsx @@ -37,11 +37,15 @@ export function LiquidDefinitions({ + @@ -49,7 +53,14 @@ export function LiquidDefinitions({ } - content={liquid.description ?? t('na')} + content={ + + {liquid.description ?? t('na')} + + } /> )) diff --git a/protocol-designer/src/pages/ProtocolOverview/ProtocolMetadata.tsx b/protocol-designer/src/pages/ProtocolOverview/ProtocolMetadata.tsx index 69d8697765b..e24f016e07c 100644 --- a/protocol-designer/src/pages/ProtocolOverview/ProtocolMetadata.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/ProtocolMetadata.tsx @@ -1,14 +1,15 @@ import { useTranslation } from 'react-i18next' import { - Flex, + Btn, + COLORS, DIRECTION_COLUMN, - SPACING, + Flex, JUSTIFY_SPACE_BETWEEN, - TYPOGRAPHY, - StyledText, ListItem, ListItemDescriptor, - Btn, + SPACING, + StyledText, + TYPOGRAPHY, } from '@opentrons/components' import { BUTTON_LINK_STYLE } from '../../atoms' @@ -62,8 +63,21 @@ export function ProtocolMetadata({ + + {t(`${title}`)} + + + } + content={ + + {value ?? t('na')} + + } /> ) @@ -71,10 +85,23 @@ export function ProtocolMetadata({ + + {t('required_app_version')} + + + } + content={ + + {t('app_version', { + version: REQUIRED_APP_VERSION, + })} + + } /> diff --git a/protocol-designer/src/pages/ProtocolOverview/StepsInfo.tsx b/protocol-designer/src/pages/ProtocolOverview/StepsInfo.tsx index 3eacf7beeba..090db7e78d4 100644 --- a/protocol-designer/src/pages/ProtocolOverview/StepsInfo.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/StepsInfo.tsx @@ -35,12 +35,14 @@ export function StepsInfo({ savedStepForms }: StepsInfoProps): JSX.Element { - {t('number_of_steps')} - + + + {t('number_of_steps')} + + } content={ From 2b18f3f218cbe7411865f7bb2c57f2b5536af787 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Thu, 24 Oct 2024 09:14:06 -0400 Subject: [PATCH 11/18] fix(app): avoid reodering in robot dashboard (#16583) Here are some interesting facts: - Array.sort() mutates - Array.reverse() mutates - react query caches data These facts combined to mean that in the ODD robot dashboard, protocols view, and run history, we would almost always rerender multiple times with rapidly-reordering data and move stuff out from under someone trying to interact with us. I'm actually pretty surprised that these would usually end up in the right order instead of exactly reversed. The fix for this is to quit using those methods, and the way to do that is to mark the data read only. We should do this for all data returned by a react-query hook IMO. ## testing - [x] on the odd, you can navigate away from and back to the dashboard (particularly via the protocols tab) and when you come back to the dashboard all the cards will render in order once and not jump around Closes RQA-3422 --- api-client/src/runs/types.ts | 2 +- .../ApplyHistoricOffsets/hooks/useHistoricRunDetails.ts | 2 +- .../Desktop/Devices/__tests__/RecentProtocolRuns.test.tsx | 8 ++++---- app/src/pages/ODD/ProtocolDashboard/index.tsx | 2 +- app/src/pages/ODD/RobotDashboard/index.tsx | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/api-client/src/runs/types.ts b/api-client/src/runs/types.ts index c53c589b231..2c467b4de4f 100644 --- a/api-client/src/runs/types.ts +++ b/api-client/src/runs/types.ts @@ -112,7 +112,7 @@ export interface GetRunsParams { } export interface Runs { - data: RunData[] + data: readonly RunData[] links: RunsLinks } diff --git a/app/src/organisms/ApplyHistoricOffsets/hooks/useHistoricRunDetails.ts b/app/src/organisms/ApplyHistoricOffsets/hooks/useHistoricRunDetails.ts index 597f2129e42..cadfc5cf618 100644 --- a/app/src/organisms/ApplyHistoricOffsets/hooks/useHistoricRunDetails.ts +++ b/app/src/organisms/ApplyHistoricOffsets/hooks/useHistoricRunDetails.ts @@ -9,7 +9,7 @@ export function useHistoricRunDetails( return allHistoricRuns == null ? [] - : allHistoricRuns.data.sort( + : allHistoricRuns.data.toSorted( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ) diff --git a/app/src/organisms/Desktop/Devices/__tests__/RecentProtocolRuns.test.tsx b/app/src/organisms/Desktop/Devices/__tests__/RecentProtocolRuns.test.tsx index 3b258d2c199..daa1fc10251 100644 --- a/app/src/organisms/Desktop/Devices/__tests__/RecentProtocolRuns.test.tsx +++ b/app/src/organisms/Desktop/Devices/__tests__/RecentProtocolRuns.test.tsx @@ -52,9 +52,9 @@ describe('RecentProtocolRuns', () => { }) it('renders table headers if there are runs', () => { vi.mocked(useIsRobotViewable).mockReturnValue(true) - vi.mocked(useNotifyAllRunsQuery).mockReturnValue({ + vi.mocked(useNotifyAllRunsQuery).mockReturnValue(({ data: { - data: [ + data: ([ { createdAt: '2022-05-04T18:24:40.833862+00:00', current: false, @@ -62,9 +62,9 @@ describe('RecentProtocolRuns', () => { protocolId: 'test_protocol_id', status: 'succeeded', }, - ], + ] as any) as Runs, }, - } as UseQueryResult) + } as any) as UseQueryResult) render() screen.getByText('Recent Protocol Runs') screen.getByText('Run') diff --git a/app/src/pages/ODD/ProtocolDashboard/index.tsx b/app/src/pages/ODD/ProtocolDashboard/index.tsx index ba2efa23949..de775795ded 100644 --- a/app/src/pages/ODD/ProtocolDashboard/index.tsx +++ b/app/src/pages/ODD/ProtocolDashboard/index.tsx @@ -98,7 +98,7 @@ export function ProtocolDashboard(): JSX.Element { } const runData = runs.data?.data != null ? runs.data?.data : [] - const allRunsNewestFirst = runData.sort( + const allRunsNewestFirst = runData.toSorted( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ) const sortedProtocols = sortProtocols( diff --git a/app/src/pages/ODD/RobotDashboard/index.tsx b/app/src/pages/ODD/RobotDashboard/index.tsx index b699f6ab569..80ae745b055 100644 --- a/app/src/pages/ODD/RobotDashboard/index.tsx +++ b/app/src/pages/ODD/RobotDashboard/index.tsx @@ -41,7 +41,7 @@ export function RobotDashboard(): JSX.Element { ) const recentRunsOfUniqueProtocols = (allRunsQueryData?.data ?? []) - .reverse() // newest runs first + .toReversed() // newest runs first .reduce((acc, run) => { if ( acc.some(collectedRun => collectedRun.protocolId === run.protocolId) From 80189200081610abd1d507f2b357da54747769df Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Thu, 24 Oct 2024 10:41:05 -0400 Subject: [PATCH 12/18] refactor(api): Allow cache_tip() to overwrite the current tip (#16587) --- api/src/opentrons/hardware_control/api.py | 5 --- .../instruments/ot2/pipette_handler.py | 8 +++++ .../instruments/ot3/pipette_handler.py | 8 +++++ api/src/opentrons/hardware_control/ot3api.py | 14 +++----- .../protocols/instrument_configurer.py | 11 ++++-- .../execution/hardware_stopper.py | 8 +++-- .../protocol_engine/execution/tip_handler.py | 34 +++++++------------ .../execution/test_hardware_stopper.py | 8 ++--- .../execution/test_tip_handler.py | 4 +-- 9 files changed, 55 insertions(+), 45 deletions(-) diff --git a/api/src/opentrons/hardware_control/api.py b/api/src/opentrons/hardware_control/api.py index 909a50a3d8c..ec019ef2f1d 100644 --- a/api/src/opentrons/hardware_control/api.py +++ b/api/src/opentrons/hardware_control/api.py @@ -1189,11 +1189,6 @@ async def tip_pickup_moves( await self.retract(mount, spec.retract_target) - def cache_tip(self, mount: top_types.Mount, tip_length: float) -> None: - instrument = self.get_pipette(mount) - instrument.add_tip(tip_length=tip_length) - instrument.set_current_volume(0) - async def pick_up_tip( self, mount: top_types.Mount, diff --git a/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py b/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py index 907788d6dda..c1389ea6a5b 100644 --- a/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py +++ b/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py @@ -430,6 +430,14 @@ def add_tip(self, mount: MountType, tip_length: float) -> None: f"attach tip called while tip already attached to {instr}" ) + def cache_tip(self, mount: MountType, tip_length: float) -> None: + instrument = self.get_pipette(mount) + if instrument.has_tip: + # instrument.add_tip() would raise an AssertionError if we tried to overwrite an existing tip. + instrument.remove_tip() + instrument.add_tip(tip_length=tip_length) + instrument.set_current_volume(0) + def remove_tip(self, mount: MountType) -> None: instr = self._attached_instruments[mount] attached = self.attached_instruments diff --git a/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py b/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py index 9f44f7b0ab8..f64078fcbff 100644 --- a/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py +++ b/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py @@ -440,6 +440,14 @@ def add_tip(self, mount: OT3Mount, tip_length: float) -> None: "attach tip called while tip already attached to {instr}" ) + def cache_tip(self, mount: OT3Mount, tip_length: float) -> None: + instrument = self.get_pipette(mount) + if instrument.has_tip: + # instrument.add_tip() would raise an AssertionError if we tried to overwrite an existing tip. + instrument.remove_tip() + instrument.add_tip(tip_length=tip_length) + instrument.set_current_volume(0) + def remove_tip(self, mount: OT3Mount) -> None: instr = self._attached_instruments[mount] attached = self.attached_instruments diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index 856b755565c..f90a0a539dc 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -2236,15 +2236,6 @@ async def _tip_motor_action( ) await self.home_gear_motors() - def cache_tip( - self, mount: Union[top_types.Mount, OT3Mount], tip_length: float - ) -> None: - realmount = OT3Mount.from_mount(mount) - instrument = self._pipette_handler.get_pipette(realmount) - - instrument.add_tip(tip_length=tip_length) - instrument.set_current_volume(0) - async def pick_up_tip( self, mount: Union[top_types.Mount, OT3Mount], @@ -2613,6 +2604,11 @@ def add_tip( ) -> None: self._pipette_handler.add_tip(OT3Mount.from_mount(mount), tip_length) + def cache_tip( + self, mount: Union[top_types.Mount, OT3Mount], tip_length: float + ) -> None: + self._pipette_handler.cache_tip(OT3Mount.from_mount(mount), tip_length) + def remove_tip(self, mount: Union[top_types.Mount, OT3Mount]) -> None: self._pipette_handler.remove_tip(OT3Mount.from_mount(mount)) diff --git a/api/src/opentrons/hardware_control/protocols/instrument_configurer.py b/api/src/opentrons/hardware_control/protocols/instrument_configurer.py index c1292620b74..5cd85716e36 100644 --- a/api/src/opentrons/hardware_control/protocols/instrument_configurer.py +++ b/api/src/opentrons/hardware_control/protocols/instrument_configurer.py @@ -142,17 +142,24 @@ def get_instrument_max_height( """ ... - # todo(mm, 2024-10-17): Consider deleting this in favor of cache_tip(), which is - # the same except for `assert`s, if we can do so without breaking anything. + # todo(mm, 2024-10-17): Consider deleting this in favor of cache_tip() + # if we can do so without breaking anything. def add_tip(self, mount: MountArgType, tip_length: float) -> None: """Inform the hardware that a tip is now attached to a pipette. + If a tip is already attached, this no-ops. + This changes the critical point of the pipette to make sure that the end of the tip is what moves around, and allows liquid handling. """ ... def cache_tip(self, mount: MountArgType, tip_length: float) -> None: + """Inform the hardware that a tip is now attached to a pipette. + + This is like `add_tip()`, except that if a tip is already attached, + this replaces it instead of no-opping. + """ ... def remove_tip(self, mount: MountArgType) -> None: diff --git a/api/src/opentrons/protocol_engine/execution/hardware_stopper.py b/api/src/opentrons/protocol_engine/execution/hardware_stopper.py index 28c310acd70..24055f6b03b 100644 --- a/api/src/opentrons/protocol_engine/execution/hardware_stopper.py +++ b/api/src/opentrons/protocol_engine/execution/hardware_stopper.py @@ -78,7 +78,9 @@ async def _drop_tip(self) -> None: try: if self._state_store.labware.get_fixed_trash_id() == FIXED_TRASH_ID: # OT-2 and Flex 2.15 protocols will default to the Fixed Trash Labware - await self._tip_handler.add_tip(pipette_id=pipette_id, tip=tip) + await self._tip_handler.cache_tip( + pipette_id=pipette_id, tip=tip + ) await self._movement_handler.move_to_well( pipette_id=pipette_id, labware_id=FIXED_TRASH_ID, @@ -90,7 +92,9 @@ async def _drop_tip(self) -> None: ) elif self._state_store.config.robot_type == "OT-2 Standard": # API 2.16 and above OT2 protocols use addressable areas - await self._tip_handler.add_tip(pipette_id=pipette_id, tip=tip) + await self._tip_handler.cache_tip( + pipette_id=pipette_id, tip=tip + ) await self._movement_handler.move_to_addressable_area( pipette_id=pipette_id, addressable_area_name="fixedTrash", diff --git a/api/src/opentrons/protocol_engine/execution/tip_handler.py b/api/src/opentrons/protocol_engine/execution/tip_handler.py index 0fe2462ee5e..a963dd9abac 100644 --- a/api/src/opentrons/protocol_engine/execution/tip_handler.py +++ b/api/src/opentrons/protocol_engine/execution/tip_handler.py @@ -83,7 +83,7 @@ async def drop_tip(self, pipette_id: str, home_after: Optional[bool]) -> None: TipAttachedError """ - async def add_tip(self, pipette_id: str, tip: TipGeometry) -> None: + async def cache_tip(self, pipette_id: str, tip: TipGeometry) -> None: """Tell the Hardware API that a tip is attached.""" async def get_tip_presence(self, pipette_id: str) -> TipPresenceStatus: @@ -234,6 +234,11 @@ async def pick_up_tip( labware_definition=self._state_view.labware.get_definition(labware_id), nominal_fallback=nominal_tip_geometry.length, ) + tip_geometry = TipGeometry( + length=actual_tip_length, + diameter=nominal_tip_geometry.diameter, + volume=nominal_tip_geometry.volume, + ) await self._hardware_api.tip_pickup_moves( mount=hw_mount, presses=None, increment=None @@ -241,24 +246,11 @@ async def pick_up_tip( # Allow TipNotAttachedError to propagate. await self.verify_tip_presence(pipette_id, TipPresenceStatus.PRESENT) - self._hardware_api.cache_tip(hw_mount, actual_tip_length) - await self._hardware_api.prepare_for_aspirate(hw_mount) - - self._hardware_api.set_current_tiprack_diameter( - mount=hw_mount, - tiprack_diameter=nominal_tip_geometry.diameter, - ) + await self.cache_tip(pipette_id, tip_geometry) - self._hardware_api.set_working_volume( - mount=hw_mount, - tip_volume=nominal_tip_geometry.volume, - ) + await self._hardware_api.prepare_for_aspirate(hw_mount) - return TipGeometry( - length=actual_tip_length, - diameter=nominal_tip_geometry.diameter, - volume=nominal_tip_geometry.volume, - ) + return tip_geometry async def drop_tip(self, pipette_id: str, home_after: Optional[bool]) -> None: """See documentation on abstract base class.""" @@ -279,11 +271,11 @@ async def drop_tip(self, pipette_id: str, home_after: Optional[bool]) -> None: self._hardware_api.remove_tip(hw_mount) self._hardware_api.set_current_tiprack_diameter(hw_mount, 0) - async def add_tip(self, pipette_id: str, tip: TipGeometry) -> None: + async def cache_tip(self, pipette_id: str, tip: TipGeometry) -> None: """See documentation on abstract base class.""" hw_mount = self._state_view.pipettes.get_mount(pipette_id).to_hw_mount() - self._hardware_api.add_tip(mount=hw_mount, tip_length=tip.length) + self._hardware_api.cache_tip(mount=hw_mount, tip_length=tip.length) self._hardware_api.set_current_tiprack_diameter( mount=hw_mount, @@ -422,12 +414,12 @@ async def drop_tip( expected_has_tip=True, ) - async def add_tip(self, pipette_id: str, tip: TipGeometry) -> None: + async def cache_tip(self, pipette_id: str, tip: TipGeometry) -> None: """Add a tip using a virtual pipette. This should not be called when using virtual pipettes. """ - assert False, "TipHandler.add_tip should not be used with virtual pipettes" + assert False, "TipHandler.cache_tip should not be used with virtual pipettes" async def verify_tip_presence( self, diff --git a/api/tests/opentrons/protocol_engine/execution/test_hardware_stopper.py b/api/tests/opentrons/protocol_engine/execution/test_hardware_stopper.py index 4c3e629d2ed..d6c69d0b170 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_hardware_stopper.py +++ b/api/tests/opentrons/protocol_engine/execution/test_hardware_stopper.py @@ -158,7 +158,7 @@ async def test_hardware_stopping_sequence_no_tip_drop( decoy.verify(await hardware_api.stop(home_after=False), times=1) decoy.verify( - await mock_tip_handler.add_tip( + await mock_tip_handler.cache_tip( pipette_id="pipette-id", tip=TipGeometry(length=1.0, volume=2.0, diameter=3.0), ), @@ -181,7 +181,7 @@ async def test_hardware_stopping_sequence_no_pipette( ) decoy.when( - await mock_tip_handler.add_tip( + await mock_tip_handler.cache_tip( pipette_id="pipette-id", tip=TipGeometry(length=1.0, volume=2.0, diameter=3.0), ), @@ -271,7 +271,7 @@ async def test_hardware_stopping_sequence_with_fixed_trash( await movement.home( axes=[MotorAxis.X, MotorAxis.Y, MotorAxis.LEFT_Z, MotorAxis.RIGHT_Z] ), - await mock_tip_handler.add_tip( + await mock_tip_handler.cache_tip( pipette_id="pipette-id", tip=TipGeometry(length=1.0, volume=2.0, diameter=3.0), ), @@ -320,7 +320,7 @@ async def test_hardware_stopping_sequence_with_OT2_addressable_area( await movement.home( axes=[MotorAxis.X, MotorAxis.Y, MotorAxis.LEFT_Z, MotorAxis.RIGHT_Z] ), - await mock_tip_handler.add_tip( + await mock_tip_handler.cache_tip( pipette_id="pipette-id", tip=TipGeometry(length=1.0, volume=2.0, diameter=3.0), ), diff --git a/api/tests/opentrons/protocol_engine/execution/test_tip_handler.py b/api/tests/opentrons/protocol_engine/execution/test_tip_handler.py index af5c49faf6a..8ddb8840597 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_tip_handler.py +++ b/api/tests/opentrons/protocol_engine/execution/test_tip_handler.py @@ -289,10 +289,10 @@ async def test_add_tip( MountType.LEFT ) - await subject.add_tip(pipette_id="pipette-id", tip=tip) + await subject.cache_tip(pipette_id="pipette-id", tip=tip) decoy.verify( - mock_hardware_api.add_tip(mount=Mount.LEFT, tip_length=50), + mock_hardware_api.cache_tip(mount=Mount.LEFT, tip_length=50), mock_hardware_api.set_current_tiprack_diameter( mount=Mount.LEFT, tiprack_diameter=5, From eb710c036b9576fabee093765f72c40681719976 Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Thu, 24 Oct 2024 10:45:17 -0400 Subject: [PATCH 13/18] feat(robot-server): HTTP API for "Ignore error and skip to next step" (#16564) --- api-client/src/runs/types.ts | 9 +++- .../robot_server/runs/action_models.py | 54 ++++++++++++++----- .../runs/error_recovery_mapping.py | 4 ++ .../runs/error_recovery_models.py | 36 ++++++++++--- .../robot_server/runs/router/base_router.py | 1 - .../robot_server/runs/run_controller.py | 12 +++++ 6 files changed, 94 insertions(+), 22 deletions(-) diff --git a/api-client/src/runs/types.ts b/api-client/src/runs/types.ts index 2c467b4de4f..9a34c2b4978 100644 --- a/api-client/src/runs/types.ts +++ b/api-client/src/runs/types.ts @@ -125,12 +125,15 @@ export const RUN_ACTION_TYPE_PAUSE: 'pause' = 'pause' export const RUN_ACTION_TYPE_STOP: 'stop' = 'stop' export const RUN_ACTION_TYPE_RESUME_FROM_RECOVERY: 'resume-from-recovery' = 'resume-from-recovery' +export const RUN_ACTION_TYPE_RESUME_FROM_RECOVERY_ASSUMING_FALSE_POSITIVE: 'resume-from-recovery-assuming-false-positive' = + 'resume-from-recovery-assuming-false-positive' export type RunActionType = | typeof RUN_ACTION_TYPE_PLAY | typeof RUN_ACTION_TYPE_PAUSE | typeof RUN_ACTION_TYPE_STOP | typeof RUN_ACTION_TYPE_RESUME_FROM_RECOVERY + | typeof RUN_ACTION_TYPE_RESUME_FROM_RECOVERY_ASSUMING_FALSE_POSITIVE export interface RunAction { id: string @@ -172,7 +175,11 @@ export type RunError = RunCommandError * Error Policy */ -export type IfMatchType = 'ignoreAndContinue' | 'failRun' | 'waitForRecovery' +export type IfMatchType = + | 'assumeFalsePositiveAndContinue' + | 'ignoreAndContinue' + | 'failRun' + | 'waitForRecovery' export interface ErrorRecoveryPolicy { policyRules: Array<{ diff --git a/robot-server/robot_server/runs/action_models.py b/robot-server/robot_server/runs/action_models.py index ede27d823c6..c640fb33cae 100644 --- a/robot-server/robot_server/runs/action_models.py +++ b/robot-server/robot_server/runs/action_models.py @@ -7,20 +7,53 @@ class RunActionType(str, Enum): - """The type of the run control action. - - * `"play"`: Start or resume a run. - * `"pause"`: Pause a run. - * `"stop"`: Stop (cancel) a run. - * `"resume-from-recovery"`: Resume normal protocol execution after a command failed, - the run was placed in `awaiting-recovery` mode, and manual recovery steps - were taken. + """The type of the run control action, which determines behavior. + + * `"play"`: Start the run, or resume it after it's been paused. + + * `"pause"`: Pause the run. + + * `"stop"`: Stop (cancel) the run. + + * `"resume-from-recovery"`: Resume normal protocol execution after the run was in + error recovery mode. Continue from however the last command left the robot. + + * `"resume-from-recovery-assuming-false-positive"`: Resume normal protocol execution + after the run was in error recovery mode. Act as if the underlying error was a + false positive. + + To see the difference between `"resume-from-recovery"` and + `"resume-from-recovery-assuming-false-positive"`, suppose we've just entered error + recovery mode after a `commandType: "pickUpTip"` command failed with an + `errorType: "tipPhysicallyMissing"` error. That normally leaves the robot thinking + it has no tip attached. If you use `"resume-from-recovery"`, the robot will run + the next protocol command from that state, acting as if there's no tip attached. + (This may cause another error, if the next command needs a tip.) + Whereas if you use `"resume-from-recovery-assuming-false-positive"`, + the robot will try to nullify the error, thereby acting as if it *does* have a tip + attached. + + Generally: + + * If you've tried to recover from the error by sending your own `intent: "fixit"` + commands to `POST /runs/{id}/commands`, use `"resume-from-recovery"`. It's your + responsibility to ensure your `POST`ed commands leave the robot in a good-enough + state to continue with the protocol. + + * Otherwise, use `"resume-from-recovery-assuming-false-positive"`. + + Do not combine `intent: "fixit"` commands with + `"resume-from-recovery-assuming-false-positive"`—the robot's built-in + false-positive recovery may compete with your own. """ PLAY = "play" PAUSE = "pause" STOP = "stop" RESUME_FROM_RECOVERY = "resume-from-recovery" + RESUME_FROM_RECOVERY_ASSUMING_FALSE_POSITIVE = ( + "resume-from-recovery-assuming-false-positive" + ) class RunActionCreate(BaseModel): @@ -41,7 +74,4 @@ class RunAction(ResourceModel): id: str = Field(..., description="A unique identifier to reference the command.") createdAt: datetime = Field(..., description="When the command was created.") - actionType: RunActionType = Field( - ..., - description="Specific type of action, which determines behavior.", - ) + actionType: RunActionType diff --git a/robot-server/robot_server/runs/error_recovery_mapping.py b/robot-server/robot_server/runs/error_recovery_mapping.py index d29ebf4b054..b548394cd8a 100644 --- a/robot-server/robot_server/runs/error_recovery_mapping.py +++ b/robot-server/robot_server/runs/error_recovery_mapping.py @@ -102,6 +102,10 @@ def _map_error_recovery_type(reaction_if_match: ReactionIfMatch) -> ErrorRecover match reaction_if_match: case ReactionIfMatch.IGNORE_AND_CONTINUE: return ErrorRecoveryType.IGNORE_AND_CONTINUE + case ReactionIfMatch.ASSUME_FALSE_POSITIVE_AND_CONTINUE: + # todo(mm, 2024-10-23): Connect to work in + # https://github.com/Opentrons/opentrons/pull/16556. + return ErrorRecoveryType.IGNORE_AND_CONTINUE case ReactionIfMatch.FAIL_RUN: return ErrorRecoveryType.FAIL_RUN case ReactionIfMatch.WAIT_FOR_RECOVERY: diff --git a/robot-server/robot_server/runs/error_recovery_models.py b/robot-server/robot_server/runs/error_recovery_models.py index a2990a007cb..1e2d4ac45aa 100644 --- a/robot-server/robot_server/runs/error_recovery_models.py +++ b/robot-server/robot_server/runs/error_recovery_models.py @@ -24,17 +24,40 @@ class ReactionIfMatch(Enum): - """How to handle a given error. + """How to handle a matching error. - * `"ignoreAndContinue"`: Ignore this error and continue with the next command. * `"failRun"`: Fail the run. - * `"waitForRecovery"`: Enter interactive error recovery mode. + * `"waitForRecovery"`: Enter interactive error recovery mode. You can then + perform error recovery with `POST /runs/{id}/commands` and exit error + recovery mode with `POST /runs/{id}/actions`. + + * `"assumeFalsePositiveAndContinue"`: Continue the run without interruption, acting + as if the error was a false positive. + + This is equivalent to doing `"waitForRecovery"` + and then sending `actionType: "resume-from-recovery-assuming-false-positive"` + to `POST /runs/{id}/actions`, except this requires no ongoing intervention from + the client. + + * `"ignoreAndContinue"`: Continue the run without interruption, accepting whatever + state the error left the robot in. + + This is equivalent to doing `"waitForRecovery"` + and then sending `actionType: "resume-from-recovery"` to `POST /runs/{id}/actions`, + except this requires no ongoing intervention from the client. + + This is probably not useful very often because it's likely to cause downstream + errors—imagine trying an `aspirate` command after a failed `pickUpTip` command. + This is provided for symmetry. """ - IGNORE_AND_CONTINUE = "ignoreAndContinue" FAIL_RUN = "failRun" WAIT_FOR_RECOVERY = "waitForRecovery" + ASSUME_FALSE_POSITIVE_AND_CONTINUE = "assumeFalsePositiveAndContinue" + # todo(mm, 2024-10-22): "ignoreAndContinue" may be a misnomer now: is + # "assumeFalsePositiveAndContinue" not also a way to "ignore"? Consider renaming. + IGNORE_AND_CONTINUE = "ignoreAndContinue" class ErrorMatcher(BaseModel): @@ -69,10 +92,7 @@ class ErrorRecoveryRule(BaseModel): ..., description="The criteria that must be met for this rule to be applied.", ) - ifMatch: ReactionIfMatch = Field( - ..., - description="How to handle errors matched by this rule.", - ) + ifMatch: ReactionIfMatch class ErrorRecoveryPolicy(BaseModel): diff --git a/robot-server/robot_server/runs/router/base_router.py b/robot-server/robot_server/runs/router/base_router.py index 788ca44aa1c..c108fa60a74 100644 --- a/robot-server/robot_server/runs/router/base_router.py +++ b/robot-server/robot_server/runs/router/base_router.py @@ -442,7 +442,6 @@ async def update_run( See `PATCH /errorRecovery/settings`. """ ), - status_code=status.HTTP_201_CREATED, responses={ status.HTTP_200_OK: {"model": SimpleEmptyBody}, status.HTTP_409_CONFLICT: {"model": ErrorBody[RunStopped]}, diff --git a/robot-server/robot_server/runs/run_controller.py b/robot-server/robot_server/runs/run_controller.py index 903bf22f252..1619cd20a08 100644 --- a/robot-server/robot_server/runs/run_controller.py +++ b/robot-server/robot_server/runs/run_controller.py @@ -2,6 +2,7 @@ import logging from datetime import datetime from typing import Optional +from typing_extensions import assert_never from opentrons.protocol_engine import ProtocolEngineError from opentrons_shared_data.errors.exceptions import RoboticsInteractionError @@ -96,6 +97,17 @@ def create_action( elif action_type == RunActionType.RESUME_FROM_RECOVERY: self._run_orchestrator_store.resume_from_recovery() + elif ( + action_type + == RunActionType.RESUME_FROM_RECOVERY_ASSUMING_FALSE_POSITIVE + ): + # todo(mm, 2024-10-23): Connect to work in + # https://github.com/Opentrons/opentrons/pull/16556. + self._run_orchestrator_store.resume_from_recovery() + + else: + assert_never(action_type) + except ProtocolEngineError as e: raise RunActionNotAllowedError(message=e.message, wrapping=[e]) from e From e32c0f361c9919dfe5dd86dbc50ccdd078c45103 Mon Sep 17 00:00:00 2001 From: Shlok Amin Date: Thu, 24 Oct 2024 11:38:33 -0400 Subject: [PATCH 14/18] fix(scripts): make aws deploy scripts work with new sdk (#16586) closes AUTH-979 --- scripts/deploy/lib/copyObject.js | 35 ++++++++++++++++++++---------- scripts/deploy/lib/removeObject.js | 27 ++++++++++++++++------- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/scripts/deploy/lib/copyObject.js b/scripts/deploy/lib/copyObject.js index 1735cb4a55e..63418067b40 100644 --- a/scripts/deploy/lib/copyObject.js +++ b/scripts/deploy/lib/copyObject.js @@ -1,6 +1,7 @@ 'use strict' const mime = require('mime') +const { CopyObjectCommand } = require('@aws-sdk/client-s3') // TODO(mc, 2019-07-16): optimize cache values const getCopyParams = obj => ({ @@ -12,7 +13,7 @@ const getCopyParams = obj => ({ /** * Copy an object to an S3 bucket * - * @param {S3} s3 - AWS.S3 instance + * @param {S3Client} s3 - AWS SDK v3 S3Client instance * @param {S3Object} sourceObj - Object to copy * @param {string} destBucket - Destination bucket * @param {string} [destPath] - Destination bucket folder (root if unspecified) @@ -21,10 +22,10 @@ const getCopyParams = obj => ({ * * @typedef S3Object * @property {string} Bucket - Object bucket - * @property {String} Prefix - Deploy folder in bucket + * @property {string} Prefix - Deploy folder in bucket * @property {string} Key - Full key to object */ -module.exports = function copyObject( +module.exports = async function copyObject( s3, sourceObj, destBucket, @@ -37,18 +38,28 @@ module.exports = function copyObject( const copyParams = getCopyParams(sourceObj) console.log( - `${dryrun ? 'DRYRUN: ' : ''}Copy - Source: ${copySource} - Dest: /${destBucket}/${destKey} - Params: ${JSON.stringify(copyParams)}\n` + `${ + dryrun ? 'DRYRUN: ' : '' + }Copy\nSource: ${copySource}\nDest: /${destBucket}/${destKey}\nParams: ${JSON.stringify( + copyParams + )}\n` ) if (dryrun) return Promise.resolve() - const copyObjectParams = Object.assign( - { Bucket: destBucket, Key: destKey, CopySource: copySource }, - copyParams - ) + const copyObjectParams = { + Bucket: destBucket, + Key: destKey, + CopySource: copySource, + ...copyParams, + } - return s3.copyObject(copyObjectParams).promise() + try { + const command = new CopyObjectCommand(copyObjectParams) + await s3.send(command) + console.log(`Successfully copied to /${destBucket}/${destKey}`) + } catch (err) { + console.error(`Error copying object: ${err.message}`) + throw err + } } diff --git a/scripts/deploy/lib/removeObject.js b/scripts/deploy/lib/removeObject.js index 56bd309a6eb..728d8927496 100644 --- a/scripts/deploy/lib/removeObject.js +++ b/scripts/deploy/lib/removeObject.js @@ -1,9 +1,11 @@ 'use strict' +const { DeleteObjectCommand } = require('@aws-sdk/client-s3'); + /** * Remove an object from S3 * - * @param {AWS.S3} s3 - AWS.S3 instance + * @param {S3Client} s3 - S3Client instance * @param {S3Object} obj - Object to remove * @param {boolean} [dryrun] - Don't actually remove anything * @returns {Promise} Promise that resolves when the removal is complete @@ -13,13 +15,22 @@ * @property {String} Prefix - Deploy folder in bucket * @property {string} Key - Full key to object */ -module.exports = function removeObject(s3, obj, dryrun) { +module.exports = async function removeObject(s3, obj, dryrun) { console.log( - `${dryrun ? 'DRYRUN: ' : ''}Remove - Source: /${obj.Bucket}/${obj.Key}\n` - ) + `${dryrun ? 'DRYRUN: ' : ''}Remove\nSource: /${obj.Bucket}/${obj.Key}\n` + ); + + if (dryrun) return Promise.resolve(); - if (dryrun) return Promise.resolve() + // Construct the deleteObject command with the bucket and key + const deleteParams = { Bucket: obj.Bucket, Key: obj.Key }; - return s3.deleteObject({ Bucket: obj.Bucket, Key: obj.Key }).promise() -} + try { + // Use the send method with DeleteObjectCommand + const result = await s3.send(new DeleteObjectCommand(deleteParams)); + return result; + } catch (error) { + console.error('Error removing object:', error); + throw error; + } +}; From 9d57048fda674858b9684ec3501cb7a7a796d03c Mon Sep 17 00:00:00 2001 From: Ryan Howard Date: Thu, 24 Oct 2024 11:46:32 -0400 Subject: [PATCH 15/18] fix(api): don't use sensor log on ot2 or simulators (#16590) # Overview This makes is so the logging setup only tries to import the sensor log name if it's running on a Flex. ## Test Plan and Hands on Testing ## Changelog ## Review requests ## Risk assessment --- api/src/opentrons/util/logging_config.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/src/opentrons/util/logging_config.py b/api/src/opentrons/util/logging_config.py index 42a32501576..0a36468f3bc 100644 --- a/api/src/opentrons/util/logging_config.py +++ b/api/src/opentrons/util/logging_config.py @@ -5,7 +5,11 @@ from opentrons.config import CONFIG, ARCHITECTURE, SystemArchitecture -from opentrons_hardware.sensors import SENSOR_LOG_NAME +if ARCHITECTURE is SystemArchitecture.BUILDROOT: + from opentrons_hardware.sensors import SENSOR_LOG_NAME +else: + # we don't use the sensor log on ot2 or host + SENSOR_LOG_NAME = "unused" def _host_config(level_value: int) -> Dict[str, Any]: From 02236b39b38a048d2231e620289538aae465ac10 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Thu, 24 Oct 2024 11:53:29 -0400 Subject: [PATCH 16/18] chore: fix github workflow and failing tests (#16593) Messed up the app test workflow so it wasn't running and therefore we didn't notice some tests were failing. Fix the workflow, fix the tests. Only changes of note are that vitest doesn't support baseline 2023 copying array reorder functions for some reason so replace them with equivalents. --- .github/workflows/app-test-build-deploy.yaml | 2 +- .../hooks/useHistoricRunDetails.ts | 12 +++++++----- app/src/pages/ODD/RobotDashboard/index.tsx | 3 +-- app/src/redux/config/__tests__/config.test.ts | 2 ++ 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/app-test-build-deploy.yaml b/.github/workflows/app-test-build-deploy.yaml index d3c03e1f500..f0bfe7d8946 100644 --- a/.github/workflows/app-test-build-deploy.yaml +++ b/.github/workflows/app-test-build-deploy.yaml @@ -148,7 +148,7 @@ jobs: yarn config set cache-folder ${{ github.workspace }}/.yarn-cache make setup-js - name: 'test native(er) packages' - run: make test-js-internal tests="${{}matrix.shell}/src" cov_opts="--coverage=true" + run: make test-js-internal tests="${{matrix.shell}}/src" cov_opts="--coverage=true" - name: 'Upload coverage report' uses: 'codecov/codecov-action@v3' with: diff --git a/app/src/organisms/ApplyHistoricOffsets/hooks/useHistoricRunDetails.ts b/app/src/organisms/ApplyHistoricOffsets/hooks/useHistoricRunDetails.ts index cadfc5cf618..39f6d5e59b8 100644 --- a/app/src/organisms/ApplyHistoricOffsets/hooks/useHistoricRunDetails.ts +++ b/app/src/organisms/ApplyHistoricOffsets/hooks/useHistoricRunDetails.ts @@ -6,11 +6,13 @@ export function useHistoricRunDetails( hostOverride?: HostConfig | null ): RunData[] { const { data: allHistoricRuns } = useNotifyAllRunsQuery({}, {}, hostOverride) - return allHistoricRuns == null ? [] - : allHistoricRuns.data.toSorted( - (a, b) => - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ) + : // TODO(sf): figure out why .toSorted() doesn't work in vitest + allHistoricRuns.data + .map(t => t) + .sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ) } diff --git a/app/src/pages/ODD/RobotDashboard/index.tsx b/app/src/pages/ODD/RobotDashboard/index.tsx index 80ae745b055..aa255717388 100644 --- a/app/src/pages/ODD/RobotDashboard/index.tsx +++ b/app/src/pages/ODD/RobotDashboard/index.tsx @@ -41,8 +41,7 @@ export function RobotDashboard(): JSX.Element { ) const recentRunsOfUniqueProtocols = (allRunsQueryData?.data ?? []) - .toReversed() // newest runs first - .reduce((acc, run) => { + .reduceRight((acc, run) => { if ( acc.some(collectedRun => collectedRun.protocolId === run.protocolId) ) { diff --git a/app/src/redux/config/__tests__/config.test.ts b/app/src/redux/config/__tests__/config.test.ts index d99eb95c36e..bf5b4e98004 100644 --- a/app/src/redux/config/__tests__/config.test.ts +++ b/app/src/redux/config/__tests__/config.test.ts @@ -28,6 +28,7 @@ describe('config', () => { expect(Cfg.configInitialized(state.config as any)).toEqual({ type: 'config:INITIALIZED', payload: { config: state.config }, + meta: { shell: true }, }) }) @@ -35,6 +36,7 @@ describe('config', () => { expect(Cfg.configValueUpdated('foo.bar', false)).toEqual({ type: 'config:VALUE_UPDATED', payload: { path: 'foo.bar', value: false }, + meta: { shell: true }, }) }) From 545d0f7dc839c33de3b4210ac829f64926f1dc8e Mon Sep 17 00:00:00 2001 From: Nick Diehl <47604184+ncdiehl11@users.noreply.github.com> Date: Thu, 24 Oct 2024 12:26:35 -0400 Subject: [PATCH 17/18] feat(protocol-designer): update error style and render logic (#16589) This PR fixes some styling bugs for both form-level and timeline errors. Also, I update the logic of the step form 'save' button to always be enabled, and conditionally render form errors and warnings should any exist. To consolidate components that are providing essentially the same function in `ToggleExpandStepFormField` and `CheckboxExpandStepFormField`, I refactor `ToggleExpandStepFormField` to render either a toggle button or a checkbox. Lastly, I update copy for thermocycler step form toggle input field menus. --- .../src/assets/localization/en/form.json | 12 +++--- .../ToggleExpandStepFormField/index.tsx | 34 +++++++++++----- .../src/organisms/Alerts/FormAlerts.tsx | 23 +++++++---- .../src/organisms/Alerts/TimelineAlerts.tsx | 12 +++++- .../StepForm/StepFormToolbox.tsx | 39 +++++++++++-------- .../StepTools/HeaterShakerTools/index.tsx | 1 + .../ThermocyclerTools/ThermocyclerState.tsx | 6 +-- .../pages/Designer/ProtocolSteps/index.tsx | 23 +++++------ .../src/steplist/fieldLevel/errors.ts | 6 +-- 9 files changed, 93 insertions(+), 63 deletions(-) diff --git a/protocol-designer/src/assets/localization/en/form.json b/protocol-designer/src/assets/localization/en/form.json index 995c6ae9d0e..1f3f4bc9e34 100644 --- a/protocol-designer/src/assets/localization/en/form.json +++ b/protocol-designer/src/assets/localization/en/form.json @@ -209,20 +209,20 @@ }, "thermocyclerState": { "block": { - "engage": "Engage block temperature", + "engage": "Block temperature", "label": "Thermocycler block", "temperature": "Block temperature", - "toggleOff": "Deactivated", - "toggleOn": "Active", + "toggleOff": "Deactivate", + "toggleOn": "Activate", "valid_range": "Valid range between 4 and 96ºC" }, "ending_hold": "Ending hold", "lid": { - "engage": "Engage lid temperature", + "engage": "Lid temperature", "label": "Lid", "temperature": "Lid temperature", - "toggleOff": "Deactivated", - "toggleOn": "Active", + "toggleOff": "Deactivate", + "toggleOn": "Activate", "valid_range": "Valid range between 37 and 110ºC" }, "lidPosition": { diff --git a/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx b/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx index b27b72b7821..05e3883e575 100644 --- a/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx +++ b/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx @@ -1,6 +1,8 @@ import { ALIGN_CENTER, + Btn, COLORS, + Check, DIRECTION_COLUMN, Flex, JUSTIFY_SPACE_BETWEEN, @@ -23,6 +25,7 @@ interface ToggleExpandStepFormFieldProps extends FieldProps { onLabel?: string offLabel?: string caption?: string + toggleElement?: 'toggle' | 'checkbox' } export function ToggleExpandStepFormField( props: ToggleExpandStepFormFieldProps @@ -37,6 +40,7 @@ export function ToggleExpandStepFormField( toggleUpdateValue, toggleValue, caption, + toggleElement = 'toggle', ...restProps } = props @@ -58,6 +62,7 @@ export function ToggleExpandStepFormField( } } + const label = isSelected ? onLabel : offLabel ?? null return ( {title} - - {isSelected ? onLabel : offLabel ?? null} - - { - onToggleUpdateValue() - }} - label={isSelected ? onLabel : offLabel} - toggledOn={isSelected} - /> + {label != null ? ( + + {isSelected ? onLabel : offLabel ?? null} + + ) : null} + {toggleElement === 'toggle' ? ( + + ) : ( + + + + )} diff --git a/protocol-designer/src/organisms/Alerts/FormAlerts.tsx b/protocol-designer/src/organisms/Alerts/FormAlerts.tsx index 5aa2833ee60..290295305af 100644 --- a/protocol-designer/src/organisms/Alerts/FormAlerts.tsx +++ b/protocol-designer/src/organisms/Alerts/FormAlerts.tsx @@ -32,7 +32,7 @@ interface FormAlertsProps { dirtyFields?: StepFieldName[] } -function FormAlertsComponent(props: FormAlertsProps): JSX.Element { +function FormAlertsComponent(props: FormAlertsProps): JSX.Element | null { const { focusedField, dirtyFields } = props const { t } = useTranslation('alert') const dispatch = useDispatch() @@ -95,7 +95,7 @@ function FormAlertsComponent(props: FormAlertsProps): JSX.Element { } const makeAlert: MakeAlert = (alertType, data, key) => ( - + {data.title} - - {data.description} - + {data.description != null ? ( + + {data.description} + + ) : null} @@ -151,15 +154,19 @@ function FormAlertsComponent(props: FormAlertsProps): JSX.Element { ) } } - return ( - + return [...formErrors, ...formWarnings, ...timelineWarnings].length > 0 ? ( + {formErrors.map((error, key) => makeAlert('error', error, key))} {formWarnings.map((warning, key) => makeAlert('warning', warning, key))} {timelineWarnings.map((warning, key) => makeAlert('warning', warning, key) )} - ) + ) : null } export const FormAlerts = memo(FormAlertsComponent) diff --git a/protocol-designer/src/organisms/Alerts/TimelineAlerts.tsx b/protocol-designer/src/organisms/Alerts/TimelineAlerts.tsx index 4df442b44f7..fbc36ddac76 100644 --- a/protocol-designer/src/organisms/Alerts/TimelineAlerts.tsx +++ b/protocol-designer/src/organisms/Alerts/TimelineAlerts.tsx @@ -11,10 +11,12 @@ import { } from '@opentrons/components' import { getRobotStateTimeline } from '../../file-data/selectors' import { ErrorContents } from './ErrorContents' + +import type { StyleProps } from '@opentrons/components' import type { CommandCreatorError } from '@opentrons/step-generation' import type { MakeAlert } from './types' -function TimelineAlertsComponent(): JSX.Element { +function TimelineAlertsComponent(props: StyleProps): JSX.Element | null { const { t } = useTranslation('alert') const timeline = useSelector(getRobotStateTimeline) @@ -26,6 +28,10 @@ function TimelineAlertsComponent(): JSX.Element { }) ) + if (timelineErrors.length === 0) { + return null + } + const makeAlert: MakeAlert = (alertType, data, key) => ( {timelineErrors.map((error, key) => makeAlert('error', error, key))} + + {timelineErrors.map((error, key) => makeAlert('error', error, key))} + ) } diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx index f101237cb45..9c60605163c 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx @@ -99,6 +99,10 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { ? 1 : 0 ) + const [ + showFormErrorsAndWarnings, + setShowFormErrorsAndWarnings, + ] = useState(false) const [isRename, setIsRename] = useState(false) const icon = stepIconsByType[formData.stepType] @@ -126,18 +130,22 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { const numErrors = timeline.errors?.length ?? 0 const handleSaveClick = (): void => { - handleSave() - makeSnackbar( - getSaveStepSnackbarText({ - numWarnings, - numErrors, - stepTypeDisplayName: i18n.format( - t(`stepType.${formData.stepType}`), - 'capitalize' - ), - t, - }) as string - ) + if (canSave) { + handleSave() + makeSnackbar( + getSaveStepSnackbarText({ + numWarnings, + numErrors, + stepTypeDisplayName: i18n.format( + t(`stepType.${formData.stepType}`), + 'capitalize' + ), + t, + }) as string + ) + } else { + setShowFormErrorsAndWarnings(true) + } } return ( @@ -195,9 +203,6 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { } : handleSaveClick } - disabled={ - isMultiStepToolbox && toolboxStep === 0 ? false : !canSave - } width="100%" > {isMultiStepToolbox && toolboxStep === 0 @@ -215,7 +220,9 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { } > - + {showFormErrorsAndWarnings ? ( + + ) : null} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ThermocyclerState.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ThermocyclerState.tsx index 4872610a284..a5f1676f2c8 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ThermocyclerState.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ThermocyclerState.tsx @@ -77,11 +77,9 @@ export function ThermocyclerState(props: ThermocyclerStateProps): JSX.Element { fieldTitle={i18n.format(t('stepType.temperature'), 'capitalize')} units={t('units.degrees')} isSelected={formData[lidFieldActive] === true} - onLabel={t( - 'form:step_edit_form.field.thermocyclerState.lidPosition.toggleOn' - )} + onLabel={t('form:step_edit_form.field.thermocyclerState.lid.toggleOn')} offLabel={t( - 'form:step_edit_form.field.thermocyclerState.lidPosition.toggleOff' + 'form:step_edit_form.field.thermocyclerState.lid.toggleOff' )} /> - + {tab === 'protocolSteps' ? ( - - - + ) : null} - + {deckView === leftString ? ( ) : ( diff --git a/protocol-designer/src/steplist/fieldLevel/errors.ts b/protocol-designer/src/steplist/fieldLevel/errors.ts index 6ddc0d3dfd2..7405f097643 100644 --- a/protocol-designer/src/steplist/fieldLevel/errors.ts +++ b/protocol-designer/src/steplist/fieldLevel/errors.ts @@ -58,20 +58,20 @@ export const minimumWellCount = (minimum: number): ErrorChecker => ( export const minFieldValue = (minimum: number): ErrorChecker => ( value: unknown ): string | null => - value === null || Number(value) >= minimum + !value || Number(value) >= minimum ? null : `${FIELD_ERRORS.UNDER_RANGE_MINIMUM} ${minimum}` export const maxFieldValue = (maximum: number): ErrorChecker => ( value: unknown ): string | null => - value === null || Number(value) <= maximum + !value || Number(value) <= maximum ? null : `${FIELD_ERRORS.OVER_RANGE_MAXIMUM} ${maximum}` export const temperatureRangeFieldValue = ( minimum: number, maximum: number ): ErrorChecker => (value: unknown): string | null => - value === null || (Number(value) <= maximum && Number(value) >= minimum) + !value || (Number(value) <= maximum && Number(value) >= minimum) ? null : `${FIELD_ERRORS.OUTSIDE_OF_RANGE} ${minimum} and ${maximum} °C` export const realNumber: ErrorChecker = (value: unknown) => From a0086ccc095470b094348561de6044890a6908c4 Mon Sep 17 00:00:00 2001 From: Jethary Alcid <66035149+jerader@users.noreply.github.com> Date: Thu, 24 Oct 2024 12:48:46 -0400 Subject: [PATCH 18/18] feat(protocol-designer): magnetic module change hint wire up (#16498) closes AUTH-797 --- .../localization/en/starting_deck_state.json | 8 +- .../__tests__/BlockingHintModal.test.tsx | 38 ++ .../src/organisms/BlockingHintModal/index.tsx | 86 ++++ .../BlockingHintModal/useBlockingHint.tsx | 40 ++ protocol-designer/src/organisms/index.ts | 1 + .../Designer/DeckSetup/DeckSetupTools.tsx | 411 ++++++++++-------- .../DeckSetup/MagnetModuleChangeContent.tsx | 40 ++ .../__tests__/DeckSetupTools.test.tsx | 3 + .../MagnetModuleChangeContent.test.tsx | 32 ++ 9 files changed, 481 insertions(+), 178 deletions(-) create mode 100644 protocol-designer/src/organisms/BlockingHintModal/__tests__/BlockingHintModal.test.tsx create mode 100644 protocol-designer/src/organisms/BlockingHintModal/index.tsx create mode 100644 protocol-designer/src/organisms/BlockingHintModal/useBlockingHint.tsx create mode 100644 protocol-designer/src/pages/Designer/DeckSetup/MagnetModuleChangeContent.tsx create mode 100644 protocol-designer/src/pages/Designer/DeckSetup/__tests__/MagnetModuleChangeContent.test.tsx diff --git a/protocol-designer/src/assets/localization/en/starting_deck_state.json b/protocol-designer/src/assets/localization/en/starting_deck_state.json index 1e284990c62..5fc398de084 100644 --- a/protocol-designer/src/assets/localization/en/starting_deck_state.json +++ b/protocol-designer/src/assets/localization/en/starting_deck_state.json @@ -8,10 +8,14 @@ "add_liquid": "Add liquid", "add_module": "Add a module", "add_rest": "Add labware and liquids to complete deck setup", + "alter_pause": "You may also need to alter the time you pause while your magnet is engaged.", "aluminumBlock": "Aluminum block", "clear_labware": "Clear labware", "clear_slot": "Clear slot", "clear": "Clear", + "command_click_to_multi_select": "Command + Click for multi-select", + "convert_gen1_to_gen2": "To convert engage heights from GEN1 to GEN2, divide your engage height by 2.", + "convert_gen2_to_gen1": "To convert engage heights from GEN2 to GEN1, multiply your engage height by 2.", "custom": "Custom labware definitions", "customize_slot": "Customize slot", "deck_hardware": "Deck hardware", @@ -25,9 +29,11 @@ "edit_protocol": "Edit protocol", "edit_slot": "Edit slot", "edit": "Edit", + "gen1_gen2_different_units": "Switching between GEN1 and GEN2 Magnetic Modules will clear all non-default engage heights from existing magnet steps in your protocol. GEN1 and GEN2 Magnetic Modules do not use the same units.", "heater_shaker_adjacent_to": "A module is adjacent to this slot. The Heater-Shaker cannot be placed next to a module", "heater_shaker_adjacent": "A Heater-Shaker is adjacent to this slot. Modules cannot be placed next to a Heater-Shaker", "heater_shaker_trash": "The heater-shaker cannot be next to the trash bin", + "here": "here.", "labware": "Labware", "liquids": "Liquids", "no_offdeck_labware": "No off-deck labware added", @@ -37,8 +43,8 @@ "onDeck": "On deck", "one_item": "No more than 1 {{hardware}} allowed on the deck at one time", "only_display_rec": "Only display recommended labware", - "command_click_to_multi_select": "Command + Click for multi-select", "protocol_starting_deck": "Protocol starting deck", + "read_more_gen1_gen2": "Read more about the differences between GEN1 and GEN2 Magnetic Modules", "rename_lab": "Rename labware", "reservoir": "Reservoir", "shift_click_to_select_all": "Shift + Click to select all", diff --git a/protocol-designer/src/organisms/BlockingHintModal/__tests__/BlockingHintModal.test.tsx b/protocol-designer/src/organisms/BlockingHintModal/__tests__/BlockingHintModal.test.tsx new file mode 100644 index 00000000000..b92295ff060 --- /dev/null +++ b/protocol-designer/src/organisms/BlockingHintModal/__tests__/BlockingHintModal.test.tsx @@ -0,0 +1,38 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { fireEvent, screen } from '@testing-library/react' +import { i18n } from '../../../assets/localization' +import { renderWithProviders } from '../../../__testing-utils__' +import { removeHint } from '../../../tutorial/actions' +import { BlockingHintModal } from '..' +import type { ComponentProps } from 'react' + +vi.mock('../../../tutorial/actions') + +const render = (props: ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('BlockingHintModal', () => { + let props: ComponentProps + + beforeEach(() => { + props = { + content:
mock content
, + handleCancel: vi.fn(), + handleContinue: vi.fn(), + hintKey: 'change_magnet_module_model', + } + }) + it('renders the hint with buttons and checkbox', () => { + render(props) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(props.handleCancel).toHaveBeenCalled() + expect(vi.mocked(removeHint)).toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(props.handleContinue).toHaveBeenCalled() + expect(vi.mocked(removeHint)).toHaveBeenCalled() + screen.getByText('mock content') + }) +}) diff --git a/protocol-designer/src/organisms/BlockingHintModal/index.tsx b/protocol-designer/src/organisms/BlockingHintModal/index.tsx new file mode 100644 index 00000000000..be33b06742f --- /dev/null +++ b/protocol-designer/src/organisms/BlockingHintModal/index.tsx @@ -0,0 +1,86 @@ +import { useCallback, useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { + ALIGN_CENTER, + COLORS, + Check, + Flex, + JUSTIFY_SPACE_BETWEEN, + Modal, + PrimaryButton, + SPACING, + SecondaryButton, + StyledText, +} from '@opentrons/components' +import { actions } from '../../tutorial' +import { getMainPagePortalEl } from '../../components/portals/MainPageModalPortal' +import type { ReactNode } from 'react' +import type { HintKey } from '../../tutorial' + +export interface HintProps { + hintKey: HintKey + handleCancel: () => void + handleContinue: () => void + content: ReactNode +} + +export function BlockingHintModal(props: HintProps): JSX.Element { + const { content, hintKey, handleCancel, handleContinue } = props + const { t, i18n } = useTranslation(['alert', 'shared']) + const dispatch = useDispatch() + + const [rememberDismissal, setRememberDismissal] = useState(false) + + const toggleRememberDismissal = useCallback(() => { + setRememberDismissal(prevDismissal => !prevDismissal) + }, []) + + const onCancelClick = (): void => { + dispatch(actions.removeHint(hintKey, rememberDismissal)) + handleCancel() + } + + const onContinueClick = (): void => { + dispatch(actions.removeHint(hintKey, rememberDismissal)) + handleContinue() + } + + return createPortal( + + + + + {t('hint.dont_show_again')} + + + + + {t('shared:cancel')} + + + {i18n.format(t('shared:continue'), 'capitalize')} + + +
+ } + > + {content} + , + getMainPagePortalEl() + ) +} diff --git a/protocol-designer/src/organisms/BlockingHintModal/useBlockingHint.tsx b/protocol-designer/src/organisms/BlockingHintModal/useBlockingHint.tsx new file mode 100644 index 00000000000..19926e2d51b --- /dev/null +++ b/protocol-designer/src/organisms/BlockingHintModal/useBlockingHint.tsx @@ -0,0 +1,40 @@ +import { useSelector } from 'react-redux' +import { getDismissedHints } from '../../tutorial/selectors' +import { BlockingHintModal } from './index' +import type { HintKey } from '../../tutorial' + +export interface HintProps { + /** `enabled` should be a condition that the parent uses to toggle whether the hint should be active or not. + * If the hint is enabled but has been dismissed, it will automatically call `handleContinue` when enabled. + * useBlockingHint expects the parent to disable the hint on cancel/continue */ + enabled: boolean + hintKey: HintKey + content: React.ReactNode + handleCancel: () => void + handleContinue: () => void +} + +export const useBlockingHint = (args: HintProps): JSX.Element | null => { + const { enabled, hintKey, handleCancel, handleContinue, content } = args + const isDismissed = useSelector(getDismissedHints).includes(hintKey) + + if (isDismissed) { + if (enabled) { + handleContinue() + } + return null + } + + if (!enabled) { + return null + } + + return ( + + ) +} diff --git a/protocol-designer/src/organisms/index.ts b/protocol-designer/src/organisms/index.ts index cda71137c57..0fd6e481e3e 100644 --- a/protocol-designer/src/organisms/index.ts +++ b/protocol-designer/src/organisms/index.ts @@ -1,6 +1,7 @@ export * from './Alerts' export * from './AnnouncementModal' export * from './AssignLiquidsModal' +export * from './BlockingHintModal' export * from './DefineLiquidsModal' export * from './EditInstrumentsModal' export * from './EditNickNameModal' diff --git a/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx b/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx index e062fa4784d..6c000ad0428 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx @@ -17,6 +17,9 @@ import { FLEX_ROBOT_TYPE, getModuleDisplayName, getModuleType, + MAGNETIC_MODULE_TYPE, + MAGNETIC_MODULE_V1, + MAGNETIC_MODULE_V2, OT2_ROBOT_TYPE, } from '@opentrons/shared-data' @@ -38,12 +41,15 @@ import { selectZoomedIntoSlot, } from '../../../labware-ingred/actions' import { getEnableAbsorbanceReader } from '../../../feature-flags/selectors' +import { useBlockingHint } from '../../../organisms/BlockingHintModal/useBlockingHint' import { selectors } from '../../../labware-ingred/selectors' import { useKitchen } from '../../../organisms/Kitchen/hooks' +import { getDismissedHints } from '../../../tutorial/selectors' import { createContainerAboveModule } from '../../../step-forms/actions/thunks' import { FIXTURES, MOAM_MODELS } from './constants' import { getSlotInformation } from '../utils' import { getModuleModelsBySlot, getDeckErrors } from './utils' +import { MagnetModuleChangeContent } from './MagnetModuleChangeContent' import { LabwareTools } from './LabwareTools' import type { ModuleModel } from '@opentrons/shared-data' @@ -65,6 +71,9 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { const { makeSnackbar } = useKitchen() const selectedSlotInfo = useSelector(selectors.getZoomedInSlotInfo) const robotType = useSelector(getRobotType) + const isDismissedModuleHint = useSelector(getDismissedHints).includes( + 'change_magnet_module_model' + ) const dispatch = useDispatch>() const enableAbsorbanceReader = useSelector(getEnableAbsorbanceReader) const deckSetup = useSelector(getDeckSetupForActiveItem) @@ -76,6 +85,9 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { selectedNestedLabwareDefUri, } = selectedSlotInfo const { slot, cutout } = selectedSlot + const [changeModuleWarningInfo, displayModuleWarning] = useState( + false + ) const [selectedHardware, setSelectedHardware] = useState< ModuleModel | Fixture | null >(null) @@ -95,6 +107,34 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { const [tab, setTab] = useState<'hardware' | 'labware'>( moduleModels?.length === 0 || slot === 'offDeck' ? 'labware' : 'hardware' ) + const hasMagneticModule = Object.values(deckSetup.modules).some( + module => module.type === MAGNETIC_MODULE_TYPE + ) + const moduleOnSlotIsMagneticModuleV1 = + Object.values(deckSetup.modules).find(module => module.slot === slot) + ?.model === MAGNETIC_MODULE_V1 + + const changeModuleWarning = useBlockingHint({ + hintKey: 'change_magnet_module_model', + handleCancel: () => { + displayModuleWarning(false) + }, + handleContinue: () => { + setSelectedHardware( + moduleOnSlotIsMagneticModuleV1 ? MAGNETIC_MODULE_V2 : MAGNETIC_MODULE_V1 + ) + dispatch( + selectModule({ + moduleModel: moduleOnSlotIsMagneticModuleV1 + ? MAGNETIC_MODULE_V2 + : MAGNETIC_MODULE_V1, + }) + ) + displayModuleWarning(false) + }, + content: , + enabled: changeModuleWarningInfo, + }) if (slot == null || (onDeckProps == null && slot !== 'offDeck')) { return null @@ -236,194 +276,211 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { dispatch(selectZoomedIntoSlot({ slot: null, cutout: null })) onCloseClick() } - return ( - - - - {t('customize_slot')} + <> + {changeModuleWarning} + + + + {t('customize_slot')} + +
+ } + closeButton={ + + {t('clear')} -
- } - closeButton={ - {t('clear')} - } - onCloseClick={() => { - handleClear() - handleResetToolbox() - }} - onConfirmClick={() => { - handleConfirm() - }} - confirmButtonText={t('done')} - > - - {slot !== 'offDeck' ? : null} - {tab === 'hardware' ? ( - - - - {t('add_module')} - - - {moduleModels?.map(model => { - const modelSomewhereOnDeck = Object.values( - deckSetupModules - ).filter( - module => module.model === model && module.slot !== slot - ) - const typeSomewhereOnDeck = Object.values( - deckSetupModules - ).filter( - module => - module.type === getModuleType(model) && - module.slot !== slot - ) - const moamModels = MOAM_MODELS - - const collisionError = getDeckErrors({ - modules: deckSetupModules, - selectedSlot: slot, - selectedModel: model, - labware: deckSetupLabware, - robotType, - }) - - return ( - { - if (onDeckProps?.setHoveredModule != null) { - onDeckProps.setHoveredModule(null) - } - }} - setHovered={() => { - if (onDeckProps?.setHoveredModule != null) { - onDeckProps.setHoveredFixture(null) - onDeckProps.setHoveredModule(model) - } - }} - largeDesktopBorderRadius - buttonLabel={ - - - - {getModuleDisplayName(model)} - - - } - key={`${model}_${slot}`} - buttonValue={model} - onChange={() => { - if ( - modelSomewhereOnDeck.length === 1 && - !moamModels.includes(model) && - robotType === FLEX_ROBOT_TYPE - ) { - makeSnackbar( - t('one_item', { - hardware: getModuleDisplayName(model), - }) as string - ) - } else if ( - typeSomewhereOnDeck.length > 0 && - robotType === OT2_ROBOT_TYPE - ) { - makeSnackbar( - t('one_item', { - hardware: t( - `shared:${getModuleType(model).toLowerCase()}` - ), - }) as string - ) - } else if (collisionError != null) { - makeSnackbar(t(`${collisionError}`) as string) - } else { - setSelectedHardware(model) - dispatch(selectFixture({ fixture: null })) - dispatch(selectModule({ moduleModel: model })) - dispatch(selectLabware({ labwareDefUri: null })) - dispatch( - selectNestedLabware({ nestedLabwareDefUri: null }) - ) - } - }} - isSelected={model === selectedHardware} - /> - ) - })} - - - {robotType === OT2_ROBOT_TYPE || fixtures.length === 0 ? null : ( + } + onCloseClick={() => { + handleClear() + handleResetToolbox() + }} + onConfirmClick={() => { + handleConfirm() + }} + confirmButtonText={t('done')} + > + + {slot !== 'offDeck' ? ( + + ) : null} + {tab === 'hardware' ? ( + - {t('add_fixture')} + {t('add_module')} - {fixtures.map(fixture => ( - { - if (onDeckProps?.setHoveredFixture != null) { - onDeckProps.setHoveredFixture(null) - } - }} - setHovered={() => { - if (onDeckProps?.setHoveredFixture != null) { - onDeckProps.setHoveredModule(null) - onDeckProps.setHoveredFixture(fixture) - } - }} - largeDesktopBorderRadius - buttonLabel={t(`shared:${fixture}`)} - key={`${fixture}_${slot}`} - buttonValue={fixture} - onChange={() => { - // delete this when multiple trash bins are supported - if (fixture === 'trashBin' && hasTrash) { - makeSnackbar( - t('one_item', { - hardware: t('shared:trashBin'), - }) as string - ) - } else { - setSelectedHardware(fixture) - dispatch(selectModule({ moduleModel: null })) - dispatch(selectFixture({ fixture })) - dispatch(selectLabware({ labwareDefUri: null })) - dispatch( - selectNestedLabware({ nestedLabwareDefUri: null }) - ) + {moduleModels?.map(model => { + const modelSomewhereOnDeck = Object.values( + deckSetupModules + ).filter( + module => module.model === model && module.slot !== slot + ) + const typeSomewhereOnDeck = Object.values( + deckSetupModules + ).filter( + module => + module.type === getModuleType(model) && + module.slot !== slot + ) + const moamModels = MOAM_MODELS + + const collisionError = getDeckErrors({ + modules: deckSetupModules, + selectedSlot: slot, + selectedModel: model, + labware: deckSetupLabware, + robotType, + }) + + return ( + { + if (onDeckProps?.setHoveredModule != null) { + onDeckProps.setHoveredModule(null) + } + }} + setHovered={() => { + if (onDeckProps?.setHoveredModule != null) { + onDeckProps.setHoveredModule(model) + } + }} + largeDesktopBorderRadius + buttonLabel={ + + + + {getModuleDisplayName(model)} + + } - }} - isSelected={fixture === selectedHardware} - /> - ))} + key={`${model}_${slot}`} + buttonValue={model} + onChange={() => { + if ( + modelSomewhereOnDeck.length === 1 && + !moamModels.includes(model) && + robotType === FLEX_ROBOT_TYPE + ) { + makeSnackbar( + t('one_item', { + hardware: getModuleDisplayName(model), + }) as string + ) + } else if ( + typeSomewhereOnDeck.length > 0 && + robotType === OT2_ROBOT_TYPE + ) { + makeSnackbar( + t('one_item', { + hardware: t( + `shared:${getModuleType(model).toLowerCase()}` + ), + }) as string + ) + } else if (collisionError != null) { + makeSnackbar(t(`${collisionError}`) as string) + } else if ( + hasMagneticModule && + (model === 'magneticModuleV1' || + model === 'magneticModuleV2') && + !isDismissedModuleHint + ) { + displayModuleWarning(true) + } else { + setSelectedHardware(model) + dispatch(selectFixture({ fixture: null })) + dispatch(selectModule({ moduleModel: model })) + dispatch(selectLabware({ labwareDefUri: null })) + dispatch( + selectNestedLabware({ nestedLabwareDefUri: null }) + ) + } + }} + isSelected={model === selectedHardware} + /> + ) + })} - )} - - ) : ( - - )} - - + {robotType === OT2_ROBOT_TYPE || fixtures.length === 0 ? null : ( + + + {t('add_fixture')} + + + {fixtures.map(fixture => ( + { + if (onDeckProps?.setHoveredFixture != null) { + onDeckProps.setHoveredFixture(null) + } + }} + setHovered={() => { + if (onDeckProps?.setHoveredFixture != null) { + onDeckProps.setHoveredFixture(fixture) + } + }} + largeDesktopBorderRadius + buttonLabel={t(`shared:${fixture}`)} + key={`${fixture}_${slot}`} + buttonValue={fixture} + onChange={() => { + // delete this when multiple trash bins are supported + if (fixture === 'trashBin' && hasTrash) { + makeSnackbar( + t('one_item', { + hardware: t('shared:trashBin'), + }) as string + ) + } else { + setSelectedHardware(fixture) + dispatch(selectModule({ moduleModel: null })) + dispatch(selectFixture({ fixture })) + dispatch(selectLabware({ labwareDefUri: null })) + dispatch( + selectNestedLabware({ nestedLabwareDefUri: null }) + ) + } + }} + isSelected={fixture === selectedHardware} + /> + ))} + + + )} + + ) : ( + + )} + + + ) } diff --git a/protocol-designer/src/pages/Designer/DeckSetup/MagnetModuleChangeContent.tsx b/protocol-designer/src/pages/Designer/DeckSetup/MagnetModuleChangeContent.tsx new file mode 100644 index 00000000000..0a5e9c18471 --- /dev/null +++ b/protocol-designer/src/pages/Designer/DeckSetup/MagnetModuleChangeContent.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from 'react-i18next' +import { + DIRECTION_COLUMN, + Flex, + Link, + SPACING, + StyledText, +} from '@opentrons/components' + +export function MagnetModuleChangeContent(): JSX.Element { + const { t } = useTranslation('starting_deck_state') + + return ( + + + {t('gen1_gen2_different_units')} + + + {t('convert_gen1_to_gen2')} + + + {t('convert_gen2_to_gen1')} + + + {t('alter_pause')} + + + + {t('read_more_gen1_gen2')}{' '} + + {t('here')} + + + + + ) +} diff --git a/protocol-designer/src/pages/Designer/DeckSetup/__tests__/DeckSetupTools.test.tsx b/protocol-designer/src/pages/Designer/DeckSetup/__tests__/DeckSetupTools.test.tsx index cb85cf12693..ffb4c968acd 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/__tests__/DeckSetupTools.test.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/__tests__/DeckSetupTools.test.tsx @@ -18,6 +18,7 @@ import { deleteDeckFixture, } from '../../../../step-forms/actions/additionalItems' import { selectors } from '../../../../labware-ingred/selectors' +import { getDismissedHints } from '../../../../tutorial/selectors' import { getDeckSetupForActiveItem } from '../../../../top-selectors/labware-locations' import { DeckSetupTools } from '../DeckSetupTools' import { LabwareTools } from '../LabwareTools' @@ -32,6 +33,7 @@ vi.mock('../../../../labware-ingred/actions') vi.mock('../../../../step-forms/actions') vi.mock('../../../../step-forms/actions/additionalItems') vi.mock('../../../../labware-ingred/selectors') +vi.mock('../../../../tutorial/selectors') const render = (props: React.ComponentProps) => { return renderWithProviders(, { i18nInstance: i18n, @@ -66,6 +68,7 @@ describe('DeckSetupTools', () => { additionalEquipmentOnDeck: {}, pipettes: {}, }) + vi.mocked(getDismissedHints).mockReturnValue([]) }) it('should render the relevant modules and fixtures for slot D3 on Flex with tabs', () => { render(props) diff --git a/protocol-designer/src/pages/Designer/DeckSetup/__tests__/MagnetModuleChangeContent.test.tsx b/protocol-designer/src/pages/Designer/DeckSetup/__tests__/MagnetModuleChangeContent.test.tsx new file mode 100644 index 00000000000..1767d0cea18 --- /dev/null +++ b/protocol-designer/src/pages/Designer/DeckSetup/__tests__/MagnetModuleChangeContent.test.tsx @@ -0,0 +1,32 @@ +import { describe, it } from 'vitest' +import { screen } from '@testing-library/react' +import { i18n } from '../../../../assets/localization' +import { renderWithProviders } from '../../../../__testing-utils__' +import { MagnetModuleChangeContent } from '../MagnetModuleChangeContent' + +const render = () => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('MagnetModuleChangeContent', () => { + it('renders the text for the modal content', () => { + render() + screen.getByText( + 'Switching between GEN1 and GEN2 Magnetic Modules will clear all non-default engage heights from existing magnet steps in your protocol. GEN1 and GEN2 Magnetic Modules do not use the same units.' + ) + screen.getByText( + 'To convert engage heights from GEN1 to GEN2, divide your engage height by 2.' + ) + screen.getByText( + 'To convert engage heights from GEN2 to GEN1, multiply your engage height by 2.' + ) + screen.getByText( + 'You may also need to alter the time you pause while your magnet is engaged.' + ) + screen.getByText( + 'Read more about the differences between GEN1 and GEN2 Magnetic Modules' + ) + }) +})