From 8713caaa178ec5dd7d548b8c5dc3a5dfc4e12321 Mon Sep 17 00:00:00 2001 From: Nick Diehl <47604184+ncdiehl11@users.noreply.github.com> Date: Thu, 5 Sep 2024 14:01:51 -0400 Subject: [PATCH 01/38] fix(app): fix labware location on labware details modal (ODD protocol details) (#16198) On ODD Protocol Details > Deck tab, we render the protocol deck with clickable labware. Clicking a labware or stack should render the appropriate modal, whose header should be the location of that labware or stack. However, similar to how we handle this in Protocol Setup, we need to find the __initial__ locaiton of the labware at load time, rather than the final location of the labware. Here, I access that initial location to properly display the slot location icon for labware that is moved. Closes [RQA-3149](https://opentrons.atlassian.net/browse/RQA-3149) --- app/src/pages/ProtocolDetails/Deck.tsx | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/app/src/pages/ProtocolDetails/Deck.tsx b/app/src/pages/ProtocolDetails/Deck.tsx index d9c95508264..5960bcf6564 100644 --- a/app/src/pages/ProtocolDetails/Deck.tsx +++ b/app/src/pages/ProtocolDetails/Deck.tsx @@ -52,16 +52,23 @@ export const Deck = (props: { protocolId: string }): JSX.Element => { labware => labware.id === labwareId ) if (foundLabware != null) { + const location = onDeckItems.find( + item => item.labwareId === foundLabware.id + )?.initialLocation const nickName = onDeckItems.find( item => getLabwareDefURI(item.definition) === foundLabware.definitionUri )?.nickName - setSelectedLabware({ - ...labwareDef, - location: foundLabware.location, - nickName: nickName ?? null, - id: labwareId, - }) - setShowLabwareDetailsModal(true) + if (location != null) { + setSelectedLabware({ + ...labwareDef, + location: location, + nickName: nickName ?? null, + id: labwareId, + }) + setShowLabwareDetailsModal(true) + } else { + console.warn('no initial labware location found') + } } } From 2878ab37146267a9d92e1c11e77b12a115c7af4b Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Thu, 5 Sep 2024 15:06:14 -0400 Subject: [PATCH 02/38] fix(app): fix "rerun protocol now" redirection delay (#16199) Closes RQA-3152 --- .../HistoricalProtocolRunOverflowMenu.tsx | 18 ++++++++++++++++-- .../ProtocolUpload/hooks/useCloneRun.ts | 13 ++++++------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/app/src/organisms/Devices/HistoricalProtocolRunOverflowMenu.tsx b/app/src/organisms/Devices/HistoricalProtocolRunOverflowMenu.tsx index 5632df49161..ead35bf5466 100644 --- a/app/src/organisms/Devices/HistoricalProtocolRunOverflowMenu.tsx +++ b/app/src/organisms/Devices/HistoricalProtocolRunOverflowMenu.tsx @@ -134,7 +134,10 @@ function MenuDropdown(props: MenuDropdownProps): JSX.Element { } const trackEvent = useTrackEvent() const { trackProtocolRunEvent } = useTrackProtocolRunEvent(runId, robotName) - const { reset, isRunControlLoading } = useRunControls(runId, onResetSuccess) + const { reset, isResetRunLoading, isRunControlLoading } = useRunControls( + runId, + onResetSuccess + ) const { deleteRun } = useDeleteRunMutation() const robot = useRobot(robotName) const robotSerialNumber = @@ -189,7 +192,18 @@ function MenuDropdown(props: MenuDropdownProps): JSX.Element { } data-testid="RecentProtocolRun_OverflowMenu_rerunNow" > - {t('rerun_now')} + + {t('rerun_now')} + {isResetRunLoading ? ( + + ) : null} + {isRobotOnWrongVersionOfSoftware && ( diff --git a/app/src/organisms/ProtocolUpload/hooks/useCloneRun.ts b/app/src/organisms/ProtocolUpload/hooks/useCloneRun.ts index d15bee033ed..870491b032c 100644 --- a/app/src/organisms/ProtocolUpload/hooks/useCloneRun.ts +++ b/app/src/organisms/ProtocolUpload/hooks/useCloneRun.ts @@ -36,13 +36,12 @@ export function useCloneRun( 'protocols', protocolKey, ]) - Promise.all([invalidateRuns, invalidateProtocols]) - .then(() => { - onSuccessCallback?.(response) - }) - .catch((e: Error) => { - console.error(`error invalidating runs query: ${e.message}`) - }) + Promise.all([invalidateRuns, invalidateProtocols]).catch((e: Error) => { + console.error(`error invalidating runs query: ${e.message}`) + }) + // The onSuccess callback is not awaited until query invalidation, because currently, in every instance this + // onSuccessCallback is utilized, we only use it for navigating. We may need to revisit this. + onSuccessCallback?.(response) }, }) const { createProtocolAnalysis } = useCreateProtocolAnalysisMutation( From 6948d938ab021697f1f89e5569982ecc51e7598e Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Fri, 6 Sep 2024 13:00:24 -0400 Subject: [PATCH 03/38] fix(app): fix browser layer notification error handling (#16207) Closes RQA-3108 (for the third time) Fixes an issue in which browser layer error messages were dropped. The app shell sends MQTT error event messages on the ALL_TOPICS topic, but browser layer callbacks do not ever subscribe to the ALL_TOPICS topic, just their individual topic. This means when the app receives a disconnect event that is applicable to all topics, those topics do not default back to polling. --- app/src/redux/shell/remote.ts | 39 ++++++++++++++----------- app/src/resources/useNotifyDataReady.ts | 4 +-- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/app/src/redux/shell/remote.ts b/app/src/redux/shell/remote.ts index 4af9cc5a3ce..8a290018490 100644 --- a/app/src/redux/shell/remote.ts +++ b/app/src/redux/shell/remote.ts @@ -73,37 +73,42 @@ const callbackStore: CallbackStore = {} interface AppShellListener { hostname: string - topic: NotifyTopic + notifyTopic: NotifyTopic callback: (data: NotifyResponseData) => void isDismounting?: boolean } export function appShellListener({ hostname, - topic, + notifyTopic, callback, isDismounting = false, }: AppShellListener): CallbackStore { - if (isDismounting) { - const callbacks = callbackStore[hostname]?.[topic] - if (callbacks != null) { - callbackStore[hostname][topic] = callbacks.filter(cb => cb !== callback) - if (!callbackStore[hostname][topic].length) { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete callbackStore[hostname][topic] - if (!Object.keys(callbackStore[hostname]).length) { + // The shell emits general messages to ALL_TOPICS, typically errors, and all listeners must handle those messages. + const topics: NotifyTopic[] = [notifyTopic, 'ALL_TOPICS'] as const + + topics.forEach(topic => { + if (isDismounting) { + const callbacks = callbackStore[hostname]?.[topic] + if (callbacks != null) { + callbackStore[hostname][topic] = callbacks.filter(cb => cb !== callback) + if (!callbackStore[hostname][topic].length) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete callbackStore[hostname] + delete callbackStore[hostname][topic] + if (!Object.keys(callbackStore[hostname]).length) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete callbackStore[hostname] + } } } + } else { + callbackStore[hostname] = callbackStore[hostname] ?? {} + callbackStore[hostname][topic] ??= [] + callbackStore[hostname][topic].push(callback) } - } else { - callbackStore[hostname] = callbackStore[hostname] ?? {} - callbackStore[hostname][topic] ??= [] - callbackStore[hostname][topic].push(callback) - } + }) + return callbackStore } - // Instantiate the notify listener at runtime. remote.ipcRenderer.on( 'notify', diff --git a/app/src/resources/useNotifyDataReady.ts b/app/src/resources/useNotifyDataReady.ts index ae05fc6e6e5..520a38e659b 100644 --- a/app/src/resources/useNotifyDataReady.ts +++ b/app/src/resources/useNotifyDataReady.ts @@ -74,7 +74,7 @@ export function useNotifyDataReady({ setRefetch('once') appShellListener({ hostname, - topic, + notifyTopic: topic, callback: onDataEvent, }) dispatch(notifySubscribeAction(hostname, topic)) @@ -87,7 +87,7 @@ export function useNotifyDataReady({ if (seenHostname.current != null) { appShellListener({ hostname: seenHostname.current, - topic, + notifyTopic: topic, callback: onDataEvent, isDismounting: true, }) From 02ba9065c4b776238a46c342187b3600d0548bd2 Mon Sep 17 00:00:00 2001 From: Sarah Breen Date: Fri, 6 Sep 2024 13:21:32 -0400 Subject: [PATCH 04/38] feat(app): add mixpanel tracking for quick transfer (#16200) fix PLAT-218 --- app/src/organisms/Devices/hooks/index.ts | 1 + .../hooks/useTrackEventWithRobotSerial.ts | 33 +++++++++++++ .../QuickTransferAdvancedSettings/AirGap.tsx | 15 ++++++ .../QuickTransferAdvancedSettings/BlowOut.tsx | 15 ++++++ .../QuickTransferAdvancedSettings/Delay.tsx | 15 ++++++ .../FlowRate.tsx | 9 ++++ .../QuickTransferAdvancedSettings/Mix.tsx | 15 ++++++ .../PipettePath.tsx | 15 ++++++ .../TipPosition.tsx | 9 ++++ .../TouchTip.tsx | 15 ++++++ .../QuickTransferAdvancedSettings/index.tsx | 11 ++++- .../QuickTransferFlow/SelectDestWells.tsx | 46 +++++++++++-------- .../QuickTransferFlow/SelectSourceWells.tsx | 20 +++++--- .../QuickTransferFlow/SummaryAndSettings.tsx | 35 ++++++++++++-- .../TipManagement/ChangeTip.tsx | 9 ++++ .../TipManagement/TipDropLocation.tsx | 10 ++++ .../QuickTransferFlow/TipManagement/index.tsx | 10 ++++ .../__tests__/SummaryAndSettings.test.tsx | 17 ++++++- .../TipManagement/ChangeTip.test.tsx | 16 +++++++ .../TipManagement/TipDropLocation.test.tsx | 14 ++++++ .../TipManagement/TipManagement.test.tsx | 14 ++++++ app/src/organisms/QuickTransferFlow/index.tsx | 17 ++++++- .../pages/QuickTransferDashboard/index.tsx | 18 +++++++- .../__tests__/QuickTransferDetails.test.tsx | 37 +++++++++++++++ app/src/pages/QuickTransferDetails/index.tsx | 21 +++++++++ app/src/pages/RunSummary/index.tsx | 23 ++++++++-- app/src/redux/analytics/constants.ts | 25 ++++++++++ 27 files changed, 448 insertions(+), 37 deletions(-) create mode 100644 app/src/organisms/Devices/hooks/useTrackEventWithRobotSerial.ts diff --git a/app/src/organisms/Devices/hooks/index.ts b/app/src/organisms/Devices/hooks/index.ts index f8cdeaa68e8..a386b97bec1 100644 --- a/app/src/organisms/Devices/hooks/index.ts +++ b/app/src/organisms/Devices/hooks/index.ts @@ -33,6 +33,7 @@ export * from './useProtocolRunAnalyticsData' export * from './useRobotAnalyticsData' export * from './useTrackCreateProtocolRunEvent' export * from './useTrackProtocolRunEvent' +export * from './useTrackEventWithRobotSerial' export * from './useRunStatuses' export * from './useSyncRobotClock' export * from './useIsLegacySessionInProgress' diff --git a/app/src/organisms/Devices/hooks/useTrackEventWithRobotSerial.ts b/app/src/organisms/Devices/hooks/useTrackEventWithRobotSerial.ts new file mode 100644 index 00000000000..4baa24bbc0a --- /dev/null +++ b/app/src/organisms/Devices/hooks/useTrackEventWithRobotSerial.ts @@ -0,0 +1,33 @@ +import { useSelector } from 'react-redux' +import { useTrackEvent } from '../../../redux/analytics' +import { getLocalRobot, getRobotSerialNumber } from '../../../redux/discovery' + +interface AnalyticsEvent { + name: string + properties: { [key: string]: unknown } +} + +export type TrackEventWithRobotSerial = (event: AnalyticsEvent) => void + +export function useTrackEventWithRobotSerial(): { + trackEventWithRobotSerial: TrackEventWithRobotSerial +} { + const trackEvent = useTrackEvent() + const localRobot = useSelector(getLocalRobot) + const robotSerialNumber = + localRobot?.status != null ? getRobotSerialNumber(localRobot) : null + const trackEventWithRobotSerial: TrackEventWithRobotSerial = ({ + name, + properties, + }) => { + trackEvent({ + name, + properties: { + ...properties, + robotSerial: robotSerialNumber, + }, + }) + } + + return { trackEventWithRobotSerial } +} diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/AirGap.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/AirGap.tsx index d68a48633e7..3e0982ed659 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/AirGap.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/AirGap.tsx @@ -9,10 +9,12 @@ import { COLORS, ALIGN_CENTER, } from '@opentrons/components' +import { ANALYTICS_QUICK_TRANSFER_SETTING_SAVED } from '../../../redux/analytics' import { getTopPortalEl } from '../../../App/portal' import { RadioButton } from '../../../atoms/buttons' import { ChildNavigation } from '../../ChildNavigation' import { InputField } from '../../../atoms/InputField' +import { useTrackEventWithRobotSerial } from '../../Devices/hooks' import { ACTIONS } from '../constants' import type { @@ -33,6 +35,7 @@ interface AirGapProps { export function AirGap(props: AirGapProps): JSX.Element { const { kind, onBack, state, dispatch } = props const { t } = useTranslation('quick_transfer') + const { trackEventWithRobotSerial } = useTrackEventWithRobotSerial() const keyboardRef = React.useRef(null) const [airGapEnabled, setAirGapEnabled] = React.useState( @@ -79,10 +82,22 @@ export function AirGap(props: AirGapProps): JSX.Element { setCurrentStep(currentStep + 1) } else { dispatch({ type: action, volume: undefined }) + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_SETTING_SAVED, + properties: { + setting: `AirGap_${kind}`, + }, + }) onBack() } } else if (currentStep === 2) { dispatch({ type: action, volume: volume ?? undefined }) + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_SETTING_SAVED, + properties: { + setting: `AirGap_${kind}`, + }, + }) onBack() } } diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/BlowOut.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/BlowOut.tsx index 236fa86c462..f054999d85a 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/BlowOut.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/BlowOut.tsx @@ -14,9 +14,11 @@ import { FLEX_SINGLE_SLOT_BY_CUTOUT_ID, TRASH_BIN_ADAPTER_FIXTURE, } from '@opentrons/shared-data' +import { ANALYTICS_QUICK_TRANSFER_SETTING_SAVED } from '../../../redux/analytics' import { getTopPortalEl } from '../../../App/portal' import { RadioButton } from '../../../atoms/buttons' import { useNotifyDeckConfigurationQuery } from '../../../resources/deck_configuration' +import { useTrackEventWithRobotSerial } from '../../Devices/hooks' import { ChildNavigation } from '../../ChildNavigation' import { ACTIONS } from '../constants' @@ -91,6 +93,7 @@ export const useBlowOutLocationOptions = ( export function BlowOut(props: BlowOutProps): JSX.Element { const { onBack, state, dispatch } = props const { t } = useTranslation('quick_transfer') + const { trackEventWithRobotSerial } = useTrackEventWithRobotSerial() const deckConfig = useNotifyDeckConfigurationQuery().data ?? [] const [isBlowOutEnabled, setisBlowOutEnabled] = React.useState( @@ -134,6 +137,12 @@ export function BlowOut(props: BlowOutProps): JSX.Element { type: ACTIONS.SET_BLOW_OUT, location: undefined, }) + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_SETTING_SAVED, + properties: { + settting: `BlowOut`, + }, + }) onBack() } else { setCurrentStep(currentStep + 1) @@ -143,6 +152,12 @@ export function BlowOut(props: BlowOutProps): JSX.Element { type: ACTIONS.SET_BLOW_OUT, location: blowOutLocation, }) + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_SETTING_SAVED, + properties: { + settting: `BlowOut`, + }, + }) onBack() } } diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Delay.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Delay.tsx index f5c362830a0..d51129850be 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Delay.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Delay.tsx @@ -9,10 +9,12 @@ import { COLORS, ALIGN_CENTER, } from '@opentrons/components' +import { ANALYTICS_QUICK_TRANSFER_SETTING_SAVED } from '../../../redux/analytics' import { getTopPortalEl } from '../../../App/portal' import { RadioButton } from '../../../atoms/buttons' import { ChildNavigation } from '../../ChildNavigation' import { InputField } from '../../../atoms/InputField' +import { useTrackEventWithRobotSerial } from '../../Devices/hooks' import { ACTIONS } from '../constants' import type { @@ -33,6 +35,7 @@ interface DelayProps { export function Delay(props: DelayProps): JSX.Element { const { kind, onBack, state, dispatch } = props const { t } = useTranslation('quick_transfer') + const { trackEventWithRobotSerial } = useTrackEventWithRobotSerial() const keyboardRef = React.useRef(null) const [currentStep, setCurrentStep] = React.useState(1) @@ -85,6 +88,12 @@ export function Delay(props: DelayProps): JSX.Element { type: action, delaySettings: undefined, }) + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_SETTING_SAVED, + properties: { + settting: `Delay_${kind}`, + }, + }) onBack() } else { setCurrentStep(2) @@ -100,6 +109,12 @@ export function Delay(props: DelayProps): JSX.Element { positionFromBottom: position, }, }) + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_SETTING_SAVED, + properties: { + settting: `Delay_${kind}`, + }, + }) } onBack() } diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/FlowRate.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/FlowRate.tsx index d438b4266c9..f7d6dfcede0 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/FlowRate.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/FlowRate.tsx @@ -13,11 +13,13 @@ import { LOW_VOLUME_PIPETTES, getTipTypeFromTipRackDefinition, } from '@opentrons/shared-data' +import { ANALYTICS_QUICK_TRANSFER_SETTING_SAVED } from '../../../redux/analytics' import { getTopPortalEl } from '../../../App/portal' import { ChildNavigation } from '../../ChildNavigation' import { InputField } from '../../../atoms/InputField' import { NumericalKeyboard } from '../../../atoms/SoftwareKeyboard' +import { useTrackEventWithRobotSerial } from '../../Devices/hooks' import { ACTIONS } from '../constants' import type { SupportedTip } from '@opentrons/shared-data' @@ -37,6 +39,7 @@ interface FlowRateEntryProps { export function FlowRateEntry(props: FlowRateEntryProps): JSX.Element { const { onBack, state, dispatch, kind } = props const { i18n, t } = useTranslation(['quick_transfer', 'shared']) + const { trackEventWithRobotSerial } = useTrackEventWithRobotSerial() const keyboardRef = React.useRef(null) const [flowRate, setFlowRate] = React.useState( @@ -87,6 +90,12 @@ export function FlowRateEntry(props: FlowRateEntryProps): JSX.Element { type: flowRateAction, rate: flowRate, }) + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_SETTING_SAVED, + properties: { + setting: `FlowRate_${kind}`, + }, + }) } onBack() } diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Mix.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Mix.tsx index 8821d076d6e..ddc6b55ef59 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Mix.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Mix.tsx @@ -9,10 +9,12 @@ import { COLORS, ALIGN_CENTER, } from '@opentrons/components' +import { ANALYTICS_QUICK_TRANSFER_SETTING_SAVED } from '../../../redux/analytics' import { getTopPortalEl } from '../../../App/portal' import { RadioButton } from '../../../atoms/buttons' import { ChildNavigation } from '../../ChildNavigation' import { InputField } from '../../../atoms/InputField' +import { useTrackEventWithRobotSerial } from '../../Devices/hooks' import { ACTIONS } from '../constants' import type { @@ -33,6 +35,7 @@ interface MixProps { export function Mix(props: MixProps): JSX.Element { const { kind, onBack, state, dispatch } = props const { t } = useTranslation('quick_transfer') + const { trackEventWithRobotSerial } = useTrackEventWithRobotSerial() const keyboardRef = React.useRef(null) const [mixIsEnabled, setMixIsEnabled] = React.useState( @@ -85,6 +88,12 @@ export function Mix(props: MixProps): JSX.Element { type: mixAction, mixSettings: undefined, }) + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_SETTING_SAVED, + properties: { + setting: `Mix_${kind}`, + }, + }) onBack() } else { setCurrentStep(2) @@ -97,6 +106,12 @@ export function Mix(props: MixProps): JSX.Element { type: mixAction, mixSettings: { mixVolume, repititions: mixReps }, }) + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_SETTING_SAVED, + properties: { + setting: `Mix_${kind}`, + }, + }) } onBack() } diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/PipettePath.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/PipettePath.tsx index a192af4cb6b..3bb48859603 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/PipettePath.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/PipettePath.tsx @@ -10,10 +10,12 @@ import { COLORS, ALIGN_CENTER, } from '@opentrons/components' +import { ANALYTICS_QUICK_TRANSFER_SETTING_SAVED } from '../../../redux/analytics' import { useNotifyDeckConfigurationQuery } from '../../../resources/deck_configuration' import { getTopPortalEl } from '../../../App/portal' import { RadioButton } from '../../../atoms/buttons' import { ChildNavigation } from '../../ChildNavigation' +import { useTrackEventWithRobotSerial } from '../../Devices/hooks' import { useBlowOutLocationOptions } from './BlowOut' import type { @@ -36,6 +38,7 @@ interface PipettePathProps { export function PipettePath(props: PipettePathProps): JSX.Element { const { onBack, state, dispatch } = props const { t } = useTranslation('quick_transfer') + const { trackEventWithRobotSerial } = useTrackEventWithRobotSerial() const keyboardRef = React.useRef(null) const deckConfig = useNotifyDeckConfigurationQuery().data ?? [] @@ -95,6 +98,12 @@ export function PipettePath(props: PipettePathProps): JSX.Element { type: ACTIONS.SET_PIPETTE_PATH, path: selectedPath, }) + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_SETTING_SAVED, + properties: { + setting: `PipettePath`, + }, + }) onBack() } else { setCurrentStep(2) @@ -108,6 +117,12 @@ export function PipettePath(props: PipettePathProps): JSX.Element { disposalVolume, blowOutLocation, }) + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_SETTING_SAVED, + properties: { + setting: `PipettePath`, + }, + }) onBack() } } diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/TipPosition.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/TipPosition.tsx index 195abea1810..4e3762a7d8b 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/TipPosition.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/TipPosition.tsx @@ -8,10 +8,12 @@ import { POSITION_FIXED, COLORS, } from '@opentrons/components' +import { ANALYTICS_QUICK_TRANSFER_SETTING_SAVED } from '../../../redux/analytics' import { getTopPortalEl } from '../../../App/portal' import { ChildNavigation } from '../../ChildNavigation' import { InputField } from '../../../atoms/InputField' import { NumericalKeyboard } from '../../../atoms/SoftwareKeyboard' +import { useTrackEventWithRobotSerial } from '../../Devices/hooks' import type { QuickTransferSummaryState, @@ -32,6 +34,7 @@ interface TipPositionEntryProps { export function TipPositionEntry(props: TipPositionEntryProps): JSX.Element { const { onBack, state, dispatch, kind } = props const { i18n, t } = useTranslation(['quick_transfer', 'shared']) + const { trackEventWithRobotSerial } = useTrackEventWithRobotSerial() const keyboardRef = React.useRef(null) const [tipPosition, setTipPosition] = React.useState( @@ -73,6 +76,12 @@ export function TipPositionEntry(props: TipPositionEntryProps): JSX.Element { type: tipPositionAction, position: tipPosition, }) + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_SETTING_SAVED, + properties: { + setting: `TipPosition_${kind}`, + }, + }) } onBack() } diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/TouchTip.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/TouchTip.tsx index fd24005b144..0010667afa6 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/TouchTip.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/TouchTip.tsx @@ -9,10 +9,12 @@ import { COLORS, ALIGN_CENTER, } from '@opentrons/components' +import { ANALYTICS_QUICK_TRANSFER_SETTING_SAVED } from '../../../redux/analytics' import { getTopPortalEl } from '../../../App/portal' import { RadioButton } from '../../../atoms/buttons' import { ChildNavigation } from '../../ChildNavigation' import { InputField } from '../../../atoms/InputField' +import { useTrackEventWithRobotSerial } from '../../Devices/hooks' import { ACTIONS } from '../constants' import type { @@ -33,6 +35,7 @@ interface TouchTipProps { export function TouchTip(props: TouchTipProps): JSX.Element { const { kind, onBack, state, dispatch } = props const { t } = useTranslation('quick_transfer') + const { trackEventWithRobotSerial } = useTrackEventWithRobotSerial() const keyboardRef = React.useRef(null) const [touchTipIsEnabled, setTouchTipIsEnabled] = React.useState( @@ -77,12 +80,24 @@ export function TouchTip(props: TouchTipProps): JSX.Element { if (currentStep === 1) { if (!touchTipIsEnabled) { dispatch({ type: touchTipAction, position: undefined }) + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_SETTING_SAVED, + properties: { + setting: `TouchTip_${kind}`, + }, + }) onBack() } else { setCurrentStep(2) } } else if (currentStep === 2) { dispatch({ type: touchTipAction, position: position ?? undefined }) + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_SETTING_SAVED, + properties: { + setting: `TouchTip_${kind}`, + }, + }) onBack() } } diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/index.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/index.tsx index 34a36d2d4fb..21b43b20a21 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/index.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/index.tsx @@ -14,7 +14,8 @@ import { SIZE_2, TEXT_ALIGN_LEFT, } from '@opentrons/components' - +import { ANALYTICS_QUICK_TRANSFER_ADVANCED_SETTINGS_TAB } from '../../../redux/analytics' +import { useTrackEventWithRobotSerial } from '../../Devices/hooks' import type { QuickTransferSummaryAction, QuickTransferSummaryState, @@ -48,8 +49,16 @@ export function QuickTransferAdvancedSettings( const [selectedSetting, setSelectedSetting] = React.useState( null ) + const { trackEventWithRobotSerial } = useTrackEventWithRobotSerial() const { makeSnackbar } = useToaster() + React.useEffect(() => { + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_ADVANCED_SETTINGS_TAB, + properties: {}, + }) + }, []) + function getBlowoutValueCopy(): string | undefined { if (state.blowOut === 'dest_well') { return t('blow_out_into_destination_well') diff --git a/app/src/organisms/QuickTransferFlow/SelectDestWells.tsx b/app/src/organisms/QuickTransferFlow/SelectDestWells.tsx index 5c443e87375..7d44c5ad1ea 100644 --- a/app/src/organisms/QuickTransferFlow/SelectDestWells.tsx +++ b/app/src/organisms/QuickTransferFlow/SelectDestWells.tsx @@ -11,7 +11,9 @@ import { JUSTIFY_CENTER, } from '@opentrons/components' import { getAllDefinitions } from '@opentrons/shared-data' +import { ANALYTICS_QUICK_TRANSFER_WELL_SELECTION_DURATION } from '../../redux/analytics' +import { useTrackEventWithRobotSerial } from '../Devices/hooks' import { getTopPortalEl } from '../../App/portal' import { Modal } from '../../molecules/Modal' import { ChildNavigation } from '../../organisms/ChildNavigation' @@ -39,6 +41,7 @@ interface SelectDestWellsProps { export function SelectDestWells(props: SelectDestWellsProps): JSX.Element { const { onNext, onBack, state, dispatch } = props const { i18n, t } = useTranslation(['quick_transfer', 'shared']) + const { trackEventWithRobotSerial } = useTrackEventWithRobotSerial() const { makeToast } = useToaster() @@ -72,6 +75,21 @@ export function SelectDestWells(props: SelectDestWellsProps): JSX.Element { selectionUnits = t('grids') } + let labwareDefinition = + state.destination === 'source' ? state.source : state.destination + if (labwareDefinition?.parameters.format === '96Standard') { + const allDefinitions = getAllDefinitions() + if (Object.values(labwareDefinition.wells)[0].shape === 'circular') { + labwareDefinition = allDefinitions[CIRCULAR_WELL_96_PLATE_DEFINITION_URI] + } else { + labwareDefinition = + allDefinitions[RECTANGULAR_WELL_96_PLATE_DEFINITION_URI] + } + } + const is384WellPlate = labwareDefinition?.parameters.format === '384Standard' + + const [analyticsStartTime] = React.useState(new Date()) + const handleClickNext = (): void => { if ( selectedWellCount === 1 || @@ -82,6 +100,14 @@ export function SelectDestWells(props: SelectDestWellsProps): JSX.Element { type: 'SET_DEST_WELLS', wells: Object.keys(selectedWells), }) + const duration = new Date().getTime() - analyticsStartTime.getTime() + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_WELL_SELECTION_DURATION, + properties: { + is384WellPlate, + duration: `${duration / 1000} seconds`, + }, + }) onNext() } else { setIsNumberWellsSelectedError(true) @@ -113,17 +139,7 @@ export function SelectDestWells(props: SelectDestWellsProps): JSX.Element { e.currentTarget.blur?.() }, } - let labwareDefinition = - state.destination === 'source' ? state.source : state.destination - if (labwareDefinition?.parameters.format === '96Standard') { - const allDefinitions = getAllDefinitions() - if (Object.values(labwareDefinition.wells)[0].shape === 'circular') { - labwareDefinition = allDefinitions[CIRCULAR_WELL_96_PLATE_DEFINITION_URI] - } else { - labwareDefinition = - allDefinitions[RECTANGULAR_WELL_96_PLATE_DEFINITION_URI] - } - } + return ( <> {createPortal( @@ -159,13 +175,7 @@ export function SelectDestWells(props: SelectDestWellsProps): JSX.Element { width="100%" > {labwareDefinition != null ? ( - + { diff --git a/app/src/organisms/QuickTransferFlow/SelectSourceWells.tsx b/app/src/organisms/QuickTransferFlow/SelectSourceWells.tsx index 5f95482fe92..1fea995c4b2 100644 --- a/app/src/organisms/QuickTransferFlow/SelectSourceWells.tsx +++ b/app/src/organisms/QuickTransferFlow/SelectSourceWells.tsx @@ -8,7 +8,8 @@ import { SPACING, } from '@opentrons/components' import { getAllDefinitions } from '@opentrons/shared-data' - +import { ANALYTICS_QUICK_TRANSFER_WELL_SELECTION_DURATION } from '../../redux/analytics' +import { useTrackEventWithRobotSerial } from '../Devices/hooks' import { ChildNavigation } from '../../organisms/ChildNavigation' import { WellSelection } from '../../organisms/WellSelection' @@ -33,6 +34,7 @@ export const RECTANGULAR_WELL_96_PLATE_DEFINITION_URI = export function SelectSourceWells(props: SelectSourceWellsProps): JSX.Element { const { onNext, onBack, state, dispatch } = props const { i18n, t } = useTranslation(['quick_transfer', 'shared']) + const { trackEventWithRobotSerial } = useTrackEventWithRobotSerial() const sourceWells = state.sourceWells ?? [] const sourceWellGroup = sourceWells.reduce((acc, well) => { @@ -40,12 +42,22 @@ export function SelectSourceWells(props: SelectSourceWellsProps): JSX.Element { }, {}) const [selectedWells, setSelectedWells] = React.useState(sourceWellGroup) + const [startingTimeStamp] = React.useState(new Date()) + const is384WellPlate = state.source?.parameters.format === '384Standard' const handleClickNext = (): void => { dispatch({ type: 'SET_SOURCE_WELLS', wells: Object.keys(selectedWells), }) + const duration = new Date().getTime() - startingTimeStamp?.getTime() + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_WELL_SELECTION_DURATION, + properties: { + is384WellPlate, + duration: `${duration / 1000} seconds`, + }, + }) onNext() } @@ -91,11 +103,7 @@ export function SelectSourceWells(props: SelectSourceWellsProps): JSX.Element { width="100%" > {state.source != null && displayLabwareDefinition != null ? ( - + { diff --git a/app/src/organisms/QuickTransferFlow/SummaryAndSettings.tsx b/app/src/organisms/QuickTransferFlow/SummaryAndSettings.tsx index 44cfd13f5eb..8897a5eb4ad 100644 --- a/app/src/organisms/QuickTransferFlow/SummaryAndSettings.tsx +++ b/app/src/organisms/QuickTransferFlow/SummaryAndSettings.tsx @@ -17,6 +17,12 @@ import { useCreateRunMutation, useHost, } from '@opentrons/react-api-client' +import { + ANALYTICS_QUICK_TRANSFER_TIME_TO_CREATE, + ANALYTICS_QUICK_TRANSFER_SAVE_FOR_LATER, + ANALYTICS_QUICK_TRANSFER_RUN_NOW, +} from '../../redux/analytics' +import { useTrackEventWithRobotSerial } from '../Devices/hooks' import { useNotifyDeckConfigurationQuery } from '../../resources/deck_configuration' import { ChildNavigation } from '../ChildNavigation' @@ -33,13 +39,15 @@ import type { QuickTransferWizardState } from './types' interface SummaryAndSettingsProps { exitButtonProps: React.ComponentProps state: QuickTransferWizardState + analyticsStartTime: Date } export function SummaryAndSettings( props: SummaryAndSettingsProps ): JSX.Element | null { - const { exitButtonProps, state: wizardFlowState } = props + const { exitButtonProps, state: wizardFlowState, analyticsStartTime } = props const navigate = useNavigate() + const { trackEventWithRobotSerial } = useTrackEventWithRobotSerial() const queryClient = useQueryClient() const host = useHost() const { t } = useTranslation(['quick_transfer', 'shared']) @@ -82,6 +90,17 @@ export function SummaryAndSettings( host ) + const handleClickCreateTransfer = (): void => { + setShowSaveOrRunModal(true) + const duration = new Date().getTime() - analyticsStartTime.getTime() + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_TIME_TO_CREATE, + properties: { + duration: `${duration / 1000} seconds`, + }, + }) + } + const handleClickSave = (protocolName: string): void => { const protocolFile = createQuickTransferFile( state, @@ -94,6 +113,12 @@ export function SummaryAndSettings( }).then(() => { navigate('/quick-transfer') }) + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_SAVE_FOR_LATER, + properties: { + name: protocolName, + }, + }) } const handleClickRun = (): void => { @@ -106,6 +131,10 @@ export function SummaryAndSettings( protocolId: data.data.id, }) }) + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_RUN_NOW, + properties: {}, + }) } return showSaveOrRunModal ? ( @@ -115,9 +144,7 @@ export function SummaryAndSettings( { - setShowSaveOrRunModal(true) - }} + onClickButton={handleClickCreateTransfer} secondaryButtonProps={exitButtonProps} /> ( null ) + React.useEffect(() => { + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_TIP_MANAGEMENT_TAB, + properties: {}, + }) + }, []) + const displayItems = [ { option: t('change_tip'), diff --git a/app/src/organisms/QuickTransferFlow/__tests__/SummaryAndSettings.test.tsx b/app/src/organisms/QuickTransferFlow/__tests__/SummaryAndSettings.test.tsx index a6cfc429cb1..546b30fa7c6 100644 --- a/app/src/organisms/QuickTransferFlow/__tests__/SummaryAndSettings.test.tsx +++ b/app/src/organisms/QuickTransferFlow/__tests__/SummaryAndSettings.test.tsx @@ -5,10 +5,12 @@ import { useCreateProtocolMutation, useCreateRunMutation, } from '@opentrons/react-api-client' +import { ANALYTICS_QUICK_TRANSFER_RUN_NOW } from '../../../redux/analytics' import { useNotifyDeckConfigurationQuery } from '../../../resources/deck_configuration' import { createQuickTransferFile } from '../utils' import { renderWithProviders } from '../../../__testing-utils__' import { i18n } from '../../../i18n' +import { useTrackEventWithRobotSerial } from '../../Devices/hooks' import { SummaryAndSettings } from '../SummaryAndSettings' import { NameQuickTransfer } from '../NameQuickTransfer' import { Overview } from '../Overview' @@ -25,6 +27,7 @@ vi.mock('react-router-dom', async importOriginal => { }) vi.mock('../Overview') vi.mock('../NameQuickTransfer') +vi.mock('../../Devices/hooks') vi.mock('../utils', async () => { const actual = await vi.importActual('../utils') return { @@ -41,6 +44,7 @@ const render = (props: React.ComponentProps) => { i18nInstance: i18n, }) } +let mockTrackEventWithRobotSerial: any describe('SummaryAndSettings', () => { let props: React.ComponentProps @@ -65,13 +69,19 @@ describe('SummaryAndSettings', () => { transferType: 'transfer', volume: 25, }, + analyticsStartTime: new Date(), } + mockTrackEventWithRobotSerial = vi.fn( + () => new Promise(resolve => resolve({})) + ) vi.mocked(useNotifyDeckConfigurationQuery).mockReturnValue({ data: { data: [], }, } as any) - + vi.mocked(useTrackEventWithRobotSerial).mockReturnValue({ + trackEventWithRobotSerial: mockTrackEventWithRobotSerial, + }) vi.mocked(useCreateProtocolMutation).mockReturnValue({ mutateAsync: createProtocol, } as any) @@ -112,6 +122,7 @@ describe('SummaryAndSettings', () => { render(props) const continueBtn = screen.getByTestId('ChildNavigation_Primary_Button') fireEvent.click(continueBtn) + expect(mockTrackEventWithRobotSerial).toHaveBeenCalled() screen.getByText('Do you want to run your quick transfer now?') screen.getByText('Save your quick transfer to run it in the future.') }) @@ -129,6 +140,10 @@ describe('SummaryAndSettings', () => { fireEvent.click(continueBtn) const runBtn = screen.getByText('Run now') fireEvent.click(runBtn) + expect(mockTrackEventWithRobotSerial).toHaveBeenCalledWith({ + name: ANALYTICS_QUICK_TRANSFER_RUN_NOW, + properties: {}, + }) expect(vi.mocked(createQuickTransferFile)).toHaveBeenCalled() expect(vi.mocked(createProtocol)).toHaveBeenCalled() }) diff --git a/app/src/organisms/QuickTransferFlow/__tests__/TipManagement/ChangeTip.test.tsx b/app/src/organisms/QuickTransferFlow/__tests__/TipManagement/ChangeTip.test.tsx index a45a8381035..13c68b821dc 100644 --- a/app/src/organisms/QuickTransferFlow/__tests__/TipManagement/ChangeTip.test.tsx +++ b/app/src/organisms/QuickTransferFlow/__tests__/TipManagement/ChangeTip.test.tsx @@ -4,14 +4,20 @@ import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest' import { renderWithProviders } from '../../../../__testing-utils__' import { i18n } from '../../../../i18n' +import { ANALYTICS_QUICK_TRANSFER_SETTING_SAVED } from '../../../../redux/analytics' +import { useTrackEventWithRobotSerial } from '../../../Devices/hooks' import { ChangeTip } from '../../TipManagement/ChangeTip' +vi.mock('../../../Devices/hooks') + const render = (props: React.ComponentProps): any => { return renderWithProviders(, { i18nInstance: i18n, }) } +let mockTrackEventWithRobotSerial: any + describe('ChangeTip', () => { let props: React.ComponentProps @@ -28,6 +34,12 @@ describe('ChangeTip', () => { } as any, dispatch: vi.fn(), } + mockTrackEventWithRobotSerial = vi.fn( + () => new Promise(resolve => resolve({})) + ) + vi.mocked(useTrackEventWithRobotSerial).mockReturnValue({ + trackEventWithRobotSerial: mockTrackEventWithRobotSerial, + }) }) afterEach(() => { vi.resetAllMocks() @@ -49,6 +61,10 @@ describe('ChangeTip', () => { const saveBtn = screen.getByText('Save') fireEvent.click(saveBtn) expect(props.dispatch).toHaveBeenCalled() + expect(mockTrackEventWithRobotSerial).toHaveBeenCalledWith({ + name: ANALYTICS_QUICK_TRANSFER_SETTING_SAVED, + properties: { setting: 'ChangeTip' }, + }) }) it('renders correct change tip options when single transfer of less than 96 wells', () => { render(props) diff --git a/app/src/organisms/QuickTransferFlow/__tests__/TipManagement/TipDropLocation.test.tsx b/app/src/organisms/QuickTransferFlow/__tests__/TipManagement/TipDropLocation.test.tsx index faaf3c16ca7..340829eca21 100644 --- a/app/src/organisms/QuickTransferFlow/__tests__/TipManagement/TipDropLocation.test.tsx +++ b/app/src/organisms/QuickTransferFlow/__tests__/TipManagement/TipDropLocation.test.tsx @@ -5,15 +5,19 @@ import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest' import { renderWithProviders } from '../../../../__testing-utils__' import { i18n } from '../../../../i18n' import { useNotifyDeckConfigurationQuery } from '../../../../resources/deck_configuration' +import { ANALYTICS_QUICK_TRANSFER_SETTING_SAVED } from '../../../../redux/analytics' +import { useTrackEventWithRobotSerial } from '../../../Devices/hooks' import { TipDropLocation } from '../../TipManagement/TipDropLocation' vi.mock('../../../../resources/deck_configuration') +vi.mock('../../../Devices/hooks') const render = (props: React.ComponentProps): any => { return renderWithProviders(, { i18nInstance: i18n, }) } +let mockTrackEventWithRobotSerial: any describe('TipDropLocation', () => { let props: React.ComponentProps @@ -26,6 +30,12 @@ describe('TipDropLocation', () => { } as any, dispatch: vi.fn(), } + mockTrackEventWithRobotSerial = vi.fn( + () => new Promise(resolve => resolve({})) + ) + vi.mocked(useTrackEventWithRobotSerial).mockReturnValue({ + trackEventWithRobotSerial: mockTrackEventWithRobotSerial, + }) vi.mocked(useNotifyDeckConfigurationQuery).mockReturnValue({ data: [ { @@ -62,5 +72,9 @@ describe('TipDropLocation', () => { const saveBtn = screen.getByText('Save') fireEvent.click(saveBtn) expect(props.dispatch).toHaveBeenCalled() + expect(mockTrackEventWithRobotSerial).toHaveBeenCalledWith({ + name: ANALYTICS_QUICK_TRANSFER_SETTING_SAVED, + properties: { setting: 'TipDropLocation' }, + }) }) }) diff --git a/app/src/organisms/QuickTransferFlow/__tests__/TipManagement/TipManagement.test.tsx b/app/src/organisms/QuickTransferFlow/__tests__/TipManagement/TipManagement.test.tsx index 97e231a15ec..f52a78fe78a 100644 --- a/app/src/organisms/QuickTransferFlow/__tests__/TipManagement/TipManagement.test.tsx +++ b/app/src/organisms/QuickTransferFlow/__tests__/TipManagement/TipManagement.test.tsx @@ -4,18 +4,22 @@ import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest' import { renderWithProviders } from '../../../../__testing-utils__' import { i18n } from '../../../../i18n' +import { ANALYTICS_QUICK_TRANSFER_TIP_MANAGEMENT_TAB } from '../../../../redux/analytics' +import { useTrackEventWithRobotSerial } from '../../../Devices/hooks' import { ChangeTip } from '../../TipManagement/ChangeTip' import { TipDropLocation } from '../../TipManagement/TipDropLocation' import { TipManagement } from '../../TipManagement/' vi.mock('../../TipManagement/ChangeTip') vi.mock('../../TipManagement/TipDropLocation') +vi.mock('../../../Devices/hooks') const render = (props: React.ComponentProps): any => { return renderWithProviders(, { i18nInstance: i18n, }) } +let mockTrackEventWithRobotSerial: any describe('TipManagement', () => { let props: React.ComponentProps @@ -30,6 +34,12 @@ describe('TipManagement', () => { } as any, dispatch: vi.fn(), } + mockTrackEventWithRobotSerial = vi.fn( + () => new Promise(resolve => resolve({})) + ) + vi.mocked(useTrackEventWithRobotSerial).mockReturnValue({ + trackEventWithRobotSerial: mockTrackEventWithRobotSerial, + }) }) afterEach(() => { vi.resetAllMocks() @@ -41,6 +51,10 @@ describe('TipManagement', () => { screen.getByText('Once at the start of the transfer') screen.getByText('Tip drop location') screen.getByText('Trash bin') + expect(mockTrackEventWithRobotSerial).toHaveBeenCalledWith({ + name: ANALYTICS_QUICK_TRANSFER_TIP_MANAGEMENT_TAB, + properties: {}, + }) }) it('renders Change tip component when seleted', () => { render(props) diff --git a/app/src/organisms/QuickTransferFlow/index.tsx b/app/src/organisms/QuickTransferFlow/index.tsx index 6b589ca0b45..34809af3998 100644 --- a/app/src/organisms/QuickTransferFlow/index.tsx +++ b/app/src/organisms/QuickTransferFlow/index.tsx @@ -6,6 +6,8 @@ import { StepMeter, POSITION_STICKY, } from '@opentrons/components' +import { ANALYTICS_QUICK_TRANSFER_EXIT_EARLY } from '../../redux/analytics' +import { useTrackEventWithRobotSerial } from '../Devices/hooks' import { ConfirmExitModal } from './ConfirmExitModal' import { CreateNewTransfer } from './CreateNewTransfer' import { SelectPipette } from './SelectPipette' @@ -27,17 +29,26 @@ const initialQuickTransferState: QuickTransferWizardState = {} export const QuickTransferFlow = (): JSX.Element => { const navigate = useNavigate() const { i18n, t } = useTranslation(['quick_transfer', 'shared']) + const { trackEventWithRobotSerial } = useTrackEventWithRobotSerial() const [state, dispatch] = React.useReducer( quickTransferWizardReducer, initialQuickTransferState ) const [currentStep, setCurrentStep] = React.useState(0) + const [analyticsStartTime] = React.useState(new Date()) + const { confirm: confirmExit, showConfirmation: showConfirmExit, cancel: cancelExit, } = useConditionalConfirm(() => { + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_EXIT_EARLY, + properties: { + step: currentStep, + }, + }) navigate('/quick-transfer') }, true) @@ -73,7 +84,11 @@ export const QuickTransferFlow = (): JSX.Element => { , , , - , + , ] return ( diff --git a/app/src/pages/QuickTransferDashboard/index.tsx b/app/src/pages/QuickTransferDashboard/index.tsx index b47d14f5ed8..1ef8d3fa4c3 100644 --- a/app/src/pages/QuickTransferDashboard/index.tsx +++ b/app/src/pages/QuickTransferDashboard/index.tsx @@ -20,9 +20,13 @@ import { useAllProtocolsQuery, useInstrumentsQuery, } from '@opentrons/react-api-client' - +import { + ANALYTICS_QUICK_TRANSFER_TAB_SELECTED, + ANALYTICS_QUICK_TRANSFER_FLOW_STARTED, +} from '../../redux/analytics' import { SmallButton, FloatingActionButton } from '../../atoms/buttons' import { Navigation } from '../../organisms/Navigation' +import { useTrackEventWithRobotSerial } from '../../organisms/Devices/hooks' import { getPinnedQuickTransferIds, getQuickTransfersOnDeviceSortKey, @@ -69,6 +73,14 @@ export function QuickTransferDashboard(): JSX.Element { const [targetTransferId, setTargetTransferId] = React.useState('') const sortBy = useSelector(getQuickTransfersOnDeviceSortKey) ?? 'alphabetical' const hasDismissedIntro = useSelector(getHasDismissedQuickTransferIntro) + const { trackEventWithRobotSerial } = useTrackEventWithRobotSerial() + + React.useEffect(() => { + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_TAB_SELECTED, + properties: {}, + }) + }, []) const pipetteIsAttached = attachedInstruments?.data.some( (i): i is PipetteData => i.ok && i.instrumentType === 'pipette' @@ -151,6 +163,10 @@ export function QuickTransferDashboard(): JSX.Element { } else if (quickTransfersData.length >= 20) { setShowStorageLimitReachedModal(true) } else { + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_FLOW_STARTED, + properties: {}, + }) navigate('/quick-transfer/new') } } diff --git a/app/src/pages/QuickTransferDetails/__tests__/QuickTransferDetails.test.tsx b/app/src/pages/QuickTransferDetails/__tests__/QuickTransferDetails.test.tsx index 929f5d46f82..8e379d9fe67 100644 --- a/app/src/pages/QuickTransferDetails/__tests__/QuickTransferDetails.test.tsx +++ b/app/src/pages/QuickTransferDetails/__tests__/QuickTransferDetails.test.tsx @@ -12,6 +12,11 @@ import { useProtocolAnalysisAsDocumentQuery, } from '@opentrons/react-api-client' import { i18n } from '../../../i18n' +import { + ANALYTICS_QUICK_TRANSFER_DETAILS_PAGE, + ANALYTICS_QUICK_TRANSFER_RUN_FROM_DETAILS, +} from '../../../redux/analytics' +import { useTrackEventWithRobotSerial } from '../../../organisms/Devices/hooks' import { useHardwareStatusText } from '../../../organisms/OnDeviceDisplay/RobotDashboard/hooks' import { useOffsetCandidatesForAnalysis } from '../../../organisms/ApplyHistoricOffsets/hooks/useOffsetCandidatesForAnalysis' import { useMissingProtocolHardware } from '../../Protocols/hooks' @@ -40,6 +45,7 @@ vi.mock('../../../organisms/ProtocolSetupParameters') vi.mock('@opentrons/api-client') vi.mock('@opentrons/react-api-client') vi.mock('../../../organisms/OnDeviceDisplay/RobotDashboard/hooks') +vi.mock('../../../organisms/Devices/hooks') vi.mock( '../../../organisms/ApplyHistoricOffsets/hooks/useOffsetCandidatesForAnalysis' ) @@ -70,6 +76,7 @@ const MOCK_DATA = { key: '26ed5a82-502f-4074-8981-57cdda1d066d', }, } +let mockTrackEventWithRobotSerial: any const render = (path = '/quick-transfer/fakeTransferId') => { return renderWithProviders( @@ -89,6 +96,12 @@ const render = (path = '/quick-transfer/fakeTransferId') => { describe('ODDQuickTransferDetails', () => { beforeEach(() => { + mockTrackEventWithRobotSerial = vi.fn( + () => new Promise(resolve => resolve({})) + ) + vi.mocked(useTrackEventWithRobotSerial).mockReturnValue({ + trackEventWithRobotSerial: mockTrackEventWithRobotSerial, + }) vi.mocked(useCreateRunMutation).mockReturnValue({ createRun: mockCreateRun, } as any) @@ -128,11 +141,35 @@ describe('ODDQuickTransferDetails', () => { ) }) + it('calls analytics tracking event for showing a quick transfer details page', () => { + render() + expect(mockTrackEventWithRobotSerial).toHaveBeenCalledWith({ + name: ANALYTICS_QUICK_TRANSFER_DETAILS_PAGE, + properties: { + name: + 'Nextera XT DNA Library Prep Kit Protocol: Part 1/4 - Tagment Genomic DNA and Amplify Libraries', + }, + }) + }) + it('renders the start setup button', () => { render() screen.getByText('Start setup') }) + it('fires analytics event when start setup is pressed', () => { + render() + const setupBtn = screen.getByText('Start setup') + fireEvent.click(setupBtn) + expect(mockTrackEventWithRobotSerial).toHaveBeenCalledWith({ + name: ANALYTICS_QUICK_TRANSFER_RUN_FROM_DETAILS, + properties: { + name: + 'Nextera XT DNA Library Prep Kit Protocol: Part 1/4 - Tagment Genomic DNA and Amplify Libraries', + }, + }) + }) + it('renders the transfer description', () => { render() screen.getByText('A short mock quick transfer') diff --git a/app/src/pages/QuickTransferDetails/index.tsx b/app/src/pages/QuickTransferDetails/index.tsx index 9551f9b4371..12cc6b18246 100644 --- a/app/src/pages/QuickTransferDetails/index.tsx +++ b/app/src/pages/QuickTransferDetails/index.tsx @@ -45,6 +45,11 @@ import { getPinnedQuickTransferIds, updateConfigValue, } from '../../redux/config' +import { + ANALYTICS_QUICK_TRANSFER_DETAILS_PAGE, + ANALYTICS_QUICK_TRANSFER_RUN_FROM_DETAILS, +} from '../../redux/analytics' +import { useTrackEventWithRobotSerial } from '../../organisms/Devices/hooks' import { useOffsetCandidatesForAnalysis } from '../../organisms/ApplyHistoricOffsets/hooks/useOffsetCandidatesForAnalysis' import { useMissingProtocolHardware } from '../Protocols/hooks' import { DeleteTransferConfirmationModal } from '../QuickTransferDashboard/DeleteTransferConfirmationModal' @@ -73,6 +78,7 @@ const QuickTransferHeader = ({ isTransferFetching, }: QuickTransferHeaderProps): JSX.Element => { const navigate = useNavigate() + const { trackEventWithRobotSerial } = useTrackEventWithRobotSerial() const { t } = useTranslation('protocol_details') const [truncate, setTruncate] = React.useState(true) const [startSetup, setStartSetup] = React.useState(false) @@ -85,6 +91,15 @@ const QuickTransferHeader = ({ displayedTitle = truncateString(displayedTitle, 80, 60) } + React.useEffect(() => { + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_DETAILS_PAGE, + properties: { + name: title, + }, + }) + }, []) + return ( { setStartSetup(true) handleRunTransfer() + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_RUN_FROM_DETAILS, + properties: { + name: title, + }, + }) }} buttonText={t('start_setup')} disabled={isTransferFetching} diff --git a/app/src/pages/RunSummary/index.tsx b/app/src/pages/RunSummary/index.tsx index e88bf872c0c..7669ebc75cc 100644 --- a/app/src/pages/RunSummary/index.tsx +++ b/app/src/pages/RunSummary/index.tsx @@ -49,6 +49,7 @@ import { import { useRunCreatedAtTimestamp, useTrackProtocolRunEvent, + useTrackEventWithRobotSerial, useRobotAnalyticsData, } from '../../organisms/Devices/hooks' import { useCloseCurrentRun } from '../../organisms/ProtocolUpload/hooks' @@ -59,6 +60,7 @@ import { useTrackEvent, ANALYTICS_PROTOCOL_RUN_ACTION, ANALYTICS_PROTOCOL_PROCEED_TO_RUN, + ANALYTICS_QUICK_TRANSFER_RERUN, } from '../../redux/analytics' import { getLocalRobot } from '../../redux/discovery' import { RunFailedModal } from '../../organisms/OnDeviceDisplay/RunningProtocol' @@ -143,6 +145,8 @@ export function RunSummary(): JSX.Element { const { reset, isResetRunLoading } = useRunControls(runId, onCloneRunSuccess) const trackEvent = useTrackEvent() + const { trackEventWithRobotSerial } = useTrackEventWithRobotSerial() + const { closeCurrentRun, isClosingCurrentRun } = useCloseCurrentRun() // Close the current run only if it's active and then execute the onSuccess callback. Prefer this wrapper over // closeCurrentRun directly, since the callback is swallowed if currentRun is null. @@ -261,11 +265,20 @@ export function RunSummary(): JSX.Element { const runAgain = (): void => { setShowRunAgainSpinner(true) reset() - trackEvent({ - name: ANALYTICS_PROTOCOL_PROCEED_TO_RUN, - properties: { sourceLocation: 'RunSummary', robotSerialNumber }, - }) - trackProtocolRunEvent({ name: ANALYTICS_PROTOCOL_RUN_ACTION.AGAIN }) + if (isQuickTransfer) { + trackEventWithRobotSerial({ + name: ANALYTICS_QUICK_TRANSFER_RERUN, + properties: { + name: protocolName, + }, + }) + } else { + trackEvent({ + name: ANALYTICS_PROTOCOL_PROCEED_TO_RUN, + properties: { sourceLocation: 'RunSummary', robotSerialNumber }, + }) + trackProtocolRunEvent({ name: ANALYTICS_PROTOCOL_RUN_ACTION.AGAIN }) + } } // If no pipettes have tips attached, execute the routing callback. diff --git a/app/src/redux/analytics/constants.ts b/app/src/redux/analytics/constants.ts index 18e10cc8be8..cf99bfad9ea 100644 --- a/app/src/redux/analytics/constants.ts +++ b/app/src/redux/analytics/constants.ts @@ -72,3 +72,28 @@ export const ANALYTICS_RECOVERY_VIEW_ERROR_DETAILS = 'recoveryViewErrorDetails' export const ANALYTICS_RECOVERY_ACTION_RESULT = 'recoverySelectedRecoveryActionResult' export const ANALYTICS_RECOVERY_RUN_RESULT = 'recoveryRunResultAfterError' + +/** + * Quick Transfer Analytics + */ + +export const ANALYTICS_QUICK_TRANSFER_TAB_SELECTED = 'quickTransferTab' +export const ANALYTICS_QUICK_TRANSFER_FLOW_STARTED = 'quickTransferFlowStarted' +export const ANALYTICS_QUICK_TRANSFER_WELL_SELECTION_DURATION = + 'quickTransferWellSelectionDuration' +export const ANALYTICS_QUICK_TRANSFER_EXIT_EARLY = 'quickTransferExitEarly' +export const ANALYTICS_QUICK_TRANSFER_ADVANCED_SETTINGS_TAB = + 'quickTransferAdvancedSettingsTab' +export const ANALYTICS_QUICK_TRANSFER_TIP_MANAGEMENT_TAB = + 'quickTransferTipManagementTab' +export const ANALYTICS_QUICK_TRANSFER_SETTING_SAVED = + 'quickTransferSettingSaved' +export const ANALYTICS_QUICK_TRANSFER_TIME_TO_CREATE = + 'quickTransferTimeToCreate' +export const ANALYTICS_QUICK_TRANSFER_SAVE_FOR_LATER = + 'quickTransferSaveForLater' +export const ANALYTICS_QUICK_TRANSFER_RUN_NOW = 'quickTransferRunNow' +export const ANALYTICS_QUICK_TRANSFER_DETAILS_PAGE = 'quickTransferDetailsPage' +export const ANALYTICS_QUICK_TRANSFER_RUN_FROM_DETAILS = + 'quickTransferRunFromDetails' +export const ANALYTICS_QUICK_TRANSFER_RERUN = 'quickTransferReRunFromSummary' From b462aaf02d5aadbf7ef0dbd107673174001c0708 Mon Sep 17 00:00:00 2001 From: Brayan Almonte Date: Fri, 6 Sep 2024 14:40:36 -0400 Subject: [PATCH 05/38] fix(app): Don't initialize the keyboard with an invalid value when creating Quick Transfer protocols. (#16203) --- .../QuickTransferFlow/QuickTransferAdvancedSettings/AirGap.tsx | 2 +- .../QuickTransferFlow/QuickTransferAdvancedSettings/Delay.tsx | 3 ++- .../QuickTransferAdvancedSettings/FlowRate.tsx | 2 +- .../QuickTransferFlow/QuickTransferAdvancedSettings/Mix.tsx | 2 +- .../QuickTransferAdvancedSettings/PipettePath.tsx | 2 +- .../QuickTransferAdvancedSettings/TipPosition.tsx | 2 +- .../QuickTransferAdvancedSettings/TouchTip.tsx | 2 +- 7 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/AirGap.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/AirGap.tsx index 3e0982ed659..ee0bd2f895d 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/AirGap.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/AirGap.tsx @@ -220,7 +220,7 @@ export function AirGap(props: AirGapProps): JSX.Element { > { setVolume(Number(e)) }} diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Delay.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Delay.tsx index d51129850be..75276350458 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Delay.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Delay.tsx @@ -237,6 +237,7 @@ export function Delay(props: DelayProps): JSX.Element { > { setDelayDuration(Number(e)) }} @@ -277,7 +278,7 @@ export function Delay(props: DelayProps): JSX.Element { > { setPosition(Number(e)) }} diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/FlowRate.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/FlowRate.tsx index f7d6dfcede0..a5fb6c7eb78 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/FlowRate.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/FlowRate.tsx @@ -150,7 +150,7 @@ export function FlowRateEntry(props: FlowRateEntryProps): JSX.Element { > { setFlowRate(Number(e)) }} diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Mix.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Mix.tsx index ddc6b55ef59..f15323934ea 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Mix.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Mix.tsx @@ -217,7 +217,7 @@ export function Mix(props: MixProps): JSX.Element { > { setMixVolume(Number(e)) }} diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/PipettePath.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/PipettePath.tsx index 3bb48859603..0647a7806ba 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/PipettePath.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/PipettePath.tsx @@ -215,7 +215,7 @@ export function PipettePath(props: PipettePathProps): JSX.Element { > { setDisposalVolume(Number(e)) }} diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/TipPosition.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/TipPosition.tsx index 4e3762a7d8b..4603f295a5c 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/TipPosition.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/TipPosition.tsx @@ -141,7 +141,7 @@ export function TipPositionEntry(props: TipPositionEntryProps): JSX.Element { > { setTipPosition(Number(e)) }} diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/TouchTip.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/TouchTip.tsx index 0010667afa6..95de3167aeb 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/TouchTip.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/TouchTip.tsx @@ -209,7 +209,7 @@ export function TouchTip(props: TouchTipProps): JSX.Element { > { setPosition(Number(e)) }} From a1bab54862342eb424e28e11f4d88a7f96e7e302 Mon Sep 17 00:00:00 2001 From: koji Date: Sat, 7 Sep 2024 10:38:33 +0900 Subject: [PATCH 06/38] fix(app): scrolling issue in deck hardware screen (#16202) * fix(app): scrolling issue in deck hardware screen --- .../ProtocolSetupModulesAndDeck/index.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/app/src/organisms/ProtocolSetupModulesAndDeck/index.tsx b/app/src/organisms/ProtocolSetupModulesAndDeck/index.tsx index 60f8f4be6f9..f867358a5f1 100644 --- a/app/src/organisms/ProtocolSetupModulesAndDeck/index.tsx +++ b/app/src/organisms/ProtocolSetupModulesAndDeck/index.tsx @@ -6,8 +6,9 @@ import { COLORS, DIRECTION_COLUMN, Flex, - SPACING, + JUSTIFY_CENTER, LegacyStyledText, + SPACING, TYPOGRAPHY, } from '@opentrons/components' import { @@ -136,11 +137,13 @@ export function ProtocolSetupModulesAndDeck({ marginBottom={SPACING.spacing80} > {showMapView ? ( - + + + ) : ( <> {isModuleMismatch && !clearModuleMismatchBanner ? ( From 904b1ef51fc6f0259d4dbcace9a1d685c30d0542 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Mon, 9 Sep 2024 09:15:27 -0400 Subject: [PATCH 07/38] fix(app): liquid state needs analysis (#16212) We were defaulting the liquids-confirmed state to true if there weren't any liquids, but we can only know there aren't any liquids if we've actually received an analysis that says that - until then, we shouldn't show anything at all. Make the liquids-confirmed state always be false until we get an analysis, and only _then_ set it to true if we don't have any. Closes RQA-3157 --- app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx index eda53b261f0..5961bb67d96 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx @@ -184,8 +184,13 @@ export function ProtocolRunSetup({ setLabwareSetupComplete, ] = React.useState(false) const [liquidSetupComplete, setLiquidSetupComplete] = React.useState( - !hasLiquids + false ) + React.useEffect(() => { + if ((robotProtocolAnalysis || storedProtocolAnalysis) && !hasLiquids) { + setLiquidSetupComplete(true) + } + }, [robotProtocolAnalysis, storedProtocolAnalysis, hasLiquids]) if ( !hasLiquids && protocolAnalysis != null && From 877d5a56b662f6663c6cef90b28e6af5508dde1f Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Mon, 9 Sep 2024 10:29:21 -0400 Subject: [PATCH 08/38] fix(app): fix undismissable calibration modals after e-stop event (#16216) Closes RQA-3155 If you throw an estop event during pipette or gripper calibration flows, the "exit" button in the modal header does not properly fire the onClose event, making it impossible to exit the modal. Currently, onClose requires the home command to succeed in order to close the modal. Instead, let's just make the modal close regardless of whether the home command succeeds or fails. Note that module calibration flows already have this logic, it's just these two flows that don't. --- app/src/organisms/GripperWizardFlows/index.tsx | 18 ++++++++++-------- app/src/organisms/PipetteWizardFlows/index.tsx | 13 +++++++------ 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/app/src/organisms/GripperWizardFlows/index.tsx b/app/src/organisms/GripperWizardFlows/index.tsx index 219fd687771..95d7e6f3500 100644 --- a/app/src/organisms/GripperWizardFlows/index.tsx +++ b/app/src/organisms/GripperWizardFlows/index.tsx @@ -120,12 +120,13 @@ export function GripperWizardFlows( const [errorMessage, setErrorMessage] = React.useState(null) const handleClose = (): void => { - if (props?.onComplete != null) props.onComplete() + if (props?.onComplete != null) { + props.onComplete() + } if (maintenanceRunData != null) { deleteMaintenanceRun(maintenanceRunData?.data.id) - } else { - closeFlow() } + closeFlow() } const { @@ -141,20 +142,21 @@ export function GripperWizardFlows( }) const handleCleanUpAndClose = (): void => { - if (maintenanceRunData?.data.id == null) handleClose() - else { + if (maintenanceRunData?.data.id == null) { + handleClose() + } else { chainRunCommands( maintenanceRunData?.data.id, [{ commandType: 'home' as const, params: {} }], false ) - .then(() => { - handleClose() - }) .catch(error => { setIsExiting(true) setErrorMessage(error.message as string) }) + .finally(() => { + handleClose() + }) } } diff --git a/app/src/organisms/PipetteWizardFlows/index.tsx b/app/src/organisms/PipetteWizardFlows/index.tsx index ad483b6ce5b..05e49c77eb9 100644 --- a/app/src/organisms/PipetteWizardFlows/index.tsx +++ b/app/src/organisms/PipetteWizardFlows/index.tsx @@ -182,12 +182,13 @@ export const PipetteWizardFlows = ( } } const handleClose = (): void => { - if (onComplete != null) onComplete() + if (onComplete != null) { + onComplete() + } if (maintenanceRunData != null) { deleteMaintenanceRun(maintenanceRunData?.data.id) - } else { - closeFlow() } + closeFlow() } const { @@ -210,13 +211,13 @@ export const PipetteWizardFlows = ( [{ commandType: 'home' as const, params: {} }], false ) - .then(() => { - handleClose() - }) .catch(error => { setIsExiting(true) setShowErrorMessage(error.message as string) }) + .finally(() => { + handleClose() + }) } } const { From eeb0eebe5e430f22b5f9a45f2d1c04b2c5582a5d Mon Sep 17 00:00:00 2001 From: Sarah Breen Date: Mon, 9 Sep 2024 11:19:59 -0400 Subject: [PATCH 09/38] fix(app): Fix scrolling behind long press modals (#16206) fix RQA-3139 --- app/src/atoms/MenuList/index.tsx | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/app/src/atoms/MenuList/index.tsx b/app/src/atoms/MenuList/index.tsx index 1994e6b24d9..25571e87e01 100644 --- a/app/src/atoms/MenuList/index.tsx +++ b/app/src/atoms/MenuList/index.tsx @@ -8,6 +8,8 @@ import { BORDERS, JUSTIFY_CENTER, } from '@opentrons/components' +import { createPortal } from 'react-dom' +import { getTopPortalEl } from '../../App/portal' import { LegacyModalShell } from '../../molecules/LegacyModal' interface MenuListProps { @@ -19,19 +21,22 @@ interface MenuListProps { export const MenuList = (props: MenuListProps): JSX.Element | null => { const { children, isOnDevice = false, onClick = null } = props return isOnDevice && onClick != null ? ( - - - {children} - - + + {children} + + , + getTopPortalEl() + ) ) : ( Date: Mon, 9 Sep 2024 12:27:07 -0400 Subject: [PATCH 10/38] refactor(app): remove quick transfer file logging for final alpha (#16217) --- .../QuickTransferFlow/utils/createQuickTransferFile.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/src/organisms/QuickTransferFlow/utils/createQuickTransferFile.ts b/app/src/organisms/QuickTransferFlow/utils/createQuickTransferFile.ts index a4114c0a4d4..09fa2348f39 100644 --- a/app/src/organisms/QuickTransferFlow/utils/createQuickTransferFile.ts +++ b/app/src/organisms/QuickTransferFlow/utils/createQuickTransferFile.ts @@ -202,10 +202,9 @@ export function createQuickTransferFile( subcategory: null, tags: [], }, - // TODO: formalize designer application data type designerApplication: { - name: 'Quick Transfer', - version: '0.0', + name: 'opentrons/quick-transfer', + version: '1.0.0', data: quickTransferState, }, } @@ -250,8 +249,6 @@ export function createQuickTransferFile( ...commandAnnotionaV1Mixin, }) - // Leaving this in for debugging while work is still in flight - console.log('Here are the protocol contents:', protocolContents) return new File( [protocolContents], `${protocolBase.metadata.protocolName}.json` From 7002facc6899547d8d82bd07a1fed427f81b4752 Mon Sep 17 00:00:00 2001 From: Sarah Breen Date: Mon, 9 Sep 2024 12:46:17 -0400 Subject: [PATCH 11/38] fix(app): initialize disposal volume field with correct value (#16219) fix RQA-3158 --- app/src/assets/localization/en/quick_transfer.json | 2 +- .../QuickTransferAdvancedSettings/PipettePath.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/assets/localization/en/quick_transfer.json b/app/src/assets/localization/en/quick_transfer.json index 0cb31912964..32efac281bc 100644 --- a/app/src/assets/localization/en/quick_transfer.json +++ b/app/src/assets/localization/en/quick_transfer.json @@ -96,7 +96,7 @@ "pipette_path": "Pipette path", "pipette_path_multi_aspirate": "Multi-aspirate", "pipette_path_multi_dispense": "Multi-dispense", - "pipette_path_multi_dispense_volume_blowout": "Multi-dispense, {{volume}} disposal volume, blowout {{blowOutLocation}}", + "pipette_path_multi_dispense_volume_blowout": "Multi-dispense, {{volume}} µL disposal volume, blowout {{blowOutLocation}}", "pipette_path_single": "Single transfers", "pre_wet_tip": "Pre-wet tip", "quick_transfer": "Quick transfer", diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/PipettePath.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/PipettePath.tsx index 0647a7806ba..c36a63502ed 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/PipettePath.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/PipettePath.tsx @@ -48,9 +48,9 @@ export function PipettePath(props: PipettePathProps): JSX.Element { BlowOutLocation | undefined >(state.blowOut) - const [disposalVolume, setDisposalVolume] = React.useState( - state.volume - ) + const [disposalVolume, setDisposalVolume] = React.useState< + number | undefined + >(state?.disposalVolume) const maxPipetteVolume = Object.values(state.pipette.liquids)[0].maxVolume const tipVolume = Object.values(state.tipRack.wells)[0].totalLiquidVolume @@ -136,7 +136,7 @@ export function PipettePath(props: PipettePathProps): JSX.Element { const volumeRange = { min: 1, max: maxDisposalCapacity } const volumeError = - disposalVolume !== null && + disposalVolume != null && (disposalVolume < volumeRange.min || disposalVolume > volumeRange.max) ? t(`value_out_of_range`, { min: volumeRange.min, From b0b8221c0beb10a5e53f4aafffd9b20c7efc2333 Mon Sep 17 00:00:00 2001 From: Sanniti Pimpley Date: Mon, 9 Sep 2024 16:17:14 -0400 Subject: [PATCH 12/38] fix(robot-server): matching RTPs check during analysis of non-RTP protocols (#16171) Closes RQA-3118 # Overview The check for matching RTPs had a bug where if a protocol had no RTPs, it would deem that protocol to have 'no previously matching analyses' and hence would always re-analyze the protocol, even when the protocol has not changed. This PR fixes that check and adds tests so that we'll know if it happens again. Fixing this bug revealed another bug that the legacy context plugin task being created while setting up the protocol runner, was never being stopped/ cancelled, hence preventing the server from shutting down. To fix that, I am stopping the orchestrator when the analyzer's destructor is called. ## Test Plan and Hands on Testing Follow the instructions in the RQA ticket above and see that the erroneous behavior is not seen. ## Review requests - check that the tests cover all cases of analyzing RTP & non-RTP protocols with various combinations of new and previous parameters (if any) ## Risk assessment Medium. Bug fix, but need to make sure that all combinations of possible protocol analysis params are covered. Also affects server shutdown. --------- Co-authored-by: Josh McVey --- .../robot_server/protocols/analysis_store.py | 14 ++++-- .../protocols/protocol_analyzer.py | 19 ++++++++ .../protocols/test_analyses.tavern.yaml | 36 +++++++++++++++ ...lyses_with_run_time_parameters.tavern.yaml | 46 ++++++++++++++++++- .../tests/protocols/test_analysis_store.py | 43 +++++++++++++++++ 5 files changed, 152 insertions(+), 6 deletions(-) diff --git a/robot-server/robot_server/protocols/analysis_store.py b/robot-server/robot_server/protocols/analysis_store.py index 4458cecf025..71d170c6581 100644 --- a/robot-server/robot_server/protocols/analysis_store.py +++ b/robot-server/robot_server/protocols/analysis_store.py @@ -392,14 +392,18 @@ async def matching_rtp_values_in_analysis( last_analysis_summary.id ) ) - if len(primitive_rtps_in_last_analysis) == 0: - # Protocols migrated from v4 will not have any entries in RTP table, - # this is fine and we should just trigger a new analysis and have - # the new values be stored in the RTP table. - return False csv_rtps_in_last_analysis = self._completed_store.get_csv_rtps_by_analysis_id( last_analysis_summary.id ) + if ( + len(new_parameters) != 0 + and len(primitive_rtps_in_last_analysis) + len(csv_rtps_in_last_analysis) + == 0 + ): + # Protocols migrated from v4 will not have any entries in RTP table, + # this is fine, and we should just trigger a new analysis and have + # the new values be stored in the RTP table. + return False total_params_in_last_analysis = list( primitive_rtps_in_last_analysis.keys() ) + list(csv_rtps_in_last_analysis.keys()) diff --git a/robot-server/robot_server/protocols/protocol_analyzer.py b/robot-server/robot_server/protocols/protocol_analyzer.py index 65cb2adf7c8..89387c5cefc 100644 --- a/robot-server/robot_server/protocols/protocol_analyzer.py +++ b/robot-server/robot_server/protocols/protocol_analyzer.py @@ -1,5 +1,6 @@ """Protocol analysis module.""" import logging +import asyncio from typing import Optional, List from opentrons_shared_data.robot.types import RobotType @@ -137,6 +138,24 @@ async def update_to_failed_analysis( liquids=[], ) + def __del__(self) -> None: + """Stop the simulating run orchestrator. + + Once the analyzer is no longer in use- either because analysis completed + or was not required, stop the orchestrator so that all its background tasks + are stopped timely and do not block server shutdown. + """ + if self._orchestrator is not None: + if self._orchestrator.get_is_okay_to_clear(): + asyncio.run_coroutine_threadsafe( + self._orchestrator.stop(), asyncio.get_running_loop() + ) + else: + log.warning( + "Analyzer is no longer in use but orchestrator is busy. " + "Cannot stop the orchestrator currently." + ) + def create_protocol_analyzer( analysis_store: AnalysisStore, diff --git a/robot-server/tests/integration/http_api/protocols/test_analyses.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_analyses.tavern.yaml index 91e892c4f70..9aacacee3f6 100644 --- a/robot-server/tests/integration/http_api/protocols/test_analyses.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_analyses.tavern.yaml @@ -96,6 +96,9 @@ stages: data: forceReAnalyze: true response: + save: + json: + analysis_id2: data[1].id strict: - json:off status_code: 201 @@ -106,3 +109,36 @@ stages: - id: !anystr status: pending runTimeParameters: [] + + + - name: Retry until analysis is completed + max_retries: 5 + delay_after: 1 + request: + url: '{ot2_server_base_url}/protocols/{protocol_id}/analyses/{analysis_id2}' + response: + strict: + - json:off + json: + data: + id: '{analysis_id2}' + status: completed + + + - name: Check that a new analysis is NOT started for the same protocol + request: + url: '{ot2_server_base_url}/protocols/{protocol_id}/analyses' + method: POST + response: + strict: + - json:off + status_code: 200 + json: + meta: + cursor: 0 + totalLength: 2 + data: + - id: '{analysis_id}' + status: completed + - id: '{analysis_id2}' + status: completed diff --git a/robot-server/tests/integration/http_api/protocols/test_analyses_with_run_time_parameters.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_analyses_with_run_time_parameters.tavern.yaml index 333f7ebc827..441ec507006 100644 --- a/robot-server/tests/integration/http_api/protocols/test_analyses_with_run_time_parameters.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_analyses_with_run_time_parameters.tavern.yaml @@ -200,10 +200,17 @@ stages: runTimeParameterValues: sample_count: 2.0 response: + save: + json: + analysis_id3: data[2].id + run_time_parameters_data3: data[2].runTimeParameters strict: - json:off status_code: 201 json: + meta: + cursor: 0 + totalLength: 3 data: - id: '{analysis_id}' status: completed @@ -252,4 +259,41 @@ stages: description: What pipette to use during the protocol. - displayName: Liquid handling CSV file variableName: liq_handling_csv_file - description: A CSV file that contains wells to use for pipetting \ No newline at end of file + description: A CSV file that contains wells to use for pipetting + + - name: Retry until analysis is completed + max_retries: 5 + delay_after: 1 + request: + url: '{ot2_server_base_url}/protocols/{protocol_id}/analyses/{analysis_id3}' + response: + strict: + - json:off + json: + data: + id: '{analysis_id3}' + status: completed + + - name: Check that a new analysis is NOT started for the protocol when RTP values are same + request: + url: '{ot2_server_base_url}/protocols/{protocol_id}/analyses' + method: POST + json: + data: + runTimeParameterValues: + sample_count: 2.0 + response: + strict: + - json:off + status_code: 200 + json: + meta: + cursor: 0 + totalLength: 3 + data: + - id: '{analysis_id}' + status: completed + - id: '{analysis_id2}' + status: completed + - id: '{analysis_id3}' + status: completed \ No newline at end of file diff --git a/robot-server/tests/protocols/test_analysis_store.py b/robot-server/tests/protocols/test_analysis_store.py index 7a3d979be44..1200f5aff43 100644 --- a/robot-server/tests/protocols/test_analysis_store.py +++ b/robot-server/tests/protocols/test_analysis_store.py @@ -642,3 +642,46 @@ async def test_matching_default_rtp_values_in_analysis_with_pending_analysis( await subject.matching_rtp_values_in_analysis( AnalysisSummary(id="analysis-id", status=AnalysisStatus.PENDING), [] ) + + +async def test_matching_rtp_values_in_analysis_with_no_rtps( + decoy: Decoy, + sql_engine: SQLEngine, + subject: AnalysisStore, + protocol_store: ProtocolStore, +) -> None: + """It should handle the cases of no RTPs, either previously or newly, appropriately.""" + mock_completed_store = decoy.mock(cls=CompletedAnalysisStore) + subject = AnalysisStore(sql_engine=sql_engine, completed_store=mock_completed_store) + protocol_store.insert(make_dummy_protocol_resource(protocol_id="protocol-id")) + + decoy.when( + mock_completed_store.get_primitive_rtps_by_analysis_id("analysis-2") + ).then_return({}) + decoy.when( + mock_completed_store.get_csv_rtps_by_analysis_id("analysis-2") + ).then_return({}) + assert ( + await subject.matching_rtp_values_in_analysis( + last_analysis_summary=AnalysisSummary( + id="analysis-2", status=AnalysisStatus.COMPLETED + ), + new_parameters=[], + ) + is True + ) + decoy.when( + mock_completed_store.get_primitive_rtps_by_analysis_id("analysis-2") + ).then_return({}) + decoy.when( + mock_completed_store.get_csv_rtps_by_analysis_id("analysis-2") + ).then_return({}) + assert ( + await subject.matching_rtp_values_in_analysis( + last_analysis_summary=AnalysisSummary( + id="analysis-2", status=AnalysisStatus.COMPLETED + ), + new_parameters=[mock_number_param("cool_param", 2.0)], + ) + is False + ) From 9916a7c2bc2df5926415626288559ab81ece631f Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Tue, 10 Sep 2024 11:53:16 -0400 Subject: [PATCH 13/38] fix(app): fix gray run status when pressing "return to dash" on `RunSummary` (#16223) Closes RQA-3159 Fixes the run status changing to gray when clicking "return to dashboard" on the ODD RunSummary page. The run status graying on button click has always been in the code, but because we used to navigate immediately after clicking "return to dashboard", this wasn't a user-visible problem. --- app/src/pages/RunSummary/index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/pages/RunSummary/index.tsx b/app/src/pages/RunSummary/index.tsx index 7669ebc75cc..ec555f40078 100644 --- a/app/src/pages/RunSummary/index.tsx +++ b/app/src/pages/RunSummary/index.tsx @@ -147,7 +147,7 @@ export function RunSummary(): JSX.Element { const trackEvent = useTrackEvent() const { trackEventWithRobotSerial } = useTrackEventWithRobotSerial() - const { closeCurrentRun, isClosingCurrentRun } = useCloseCurrentRun() + const { closeCurrentRun } = useCloseCurrentRun() // Close the current run only if it's active and then execute the onSuccess callback. Prefer this wrapper over // closeCurrentRun directly, since the callback is swallowed if currentRun is null. const closeCurrentRunIfValid = (onSuccess?: () => void): void => { @@ -386,7 +386,6 @@ export function RunSummary(): JSX.Element { flexDirection={DIRECTION_COLUMN} position={POSITION_RELATIVE} overflow={OVERFLOW_HIDDEN} - disabled={isClosingCurrentRun} onClick={handleClickSplash} > {showSplash ? ( From 176e49499b132c2780ac1824ce1685969b6abe88 Mon Sep 17 00:00:00 2001 From: Brent Hagen Date: Tue, 10 Sep 2024 15:37:29 -0400 Subject: [PATCH 14/38] fix(app): display only one conflict with thermocycler in run setup (#16228) there are several combinations of labware, fixtures, and modules in A1 and B1 that generate two conflicts with a configured thermocycler. this PR filters out one of 2 conflicts from display when both conflicts are with single slot labware in a protocol. other scenarios should be covered by the modules or fixtures lists. closes RQA-3112 --- .../LocationConflictModal.tsx | 11 ++--- .../SetupModuleAndDeck/SetupFixtureList.tsx | 37 ++++++++++++++++- .../FixtureTable.tsx | 40 ++++++++++++++++++- 3 files changed, 79 insertions(+), 9 deletions(-) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/LocationConflictModal.tsx b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/LocationConflictModal.tsx index babcf5ba830..16cfa01c125 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/LocationConflictModal.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/LocationConflictModal.tsx @@ -191,9 +191,10 @@ export const LocationConflictModal = ( protocolSpecifiesDisplayName = getModuleDisplayName(requiredModule) } - const displaySlotName = isThermocyclerRequired - ? 'A1 + B1' - : getCutoutDisplayName(cutoutId) + const displaySlotName = + isThermocyclerRequired || isThermocyclerCurrentFixture + ? 'A1 + B1' + : getCutoutDisplayName(cutoutId) if (showModuleSelect && requiredModule != null) { return createPortal( @@ -232,7 +233,7 @@ export const LocationConflictModal = ( } values={{ currentFixture: currentFixtureDisplayName, - cutout: getCutoutDisplayName(cutoutId), + cutout: displaySlotName, }} components={{ block: , @@ -341,7 +342,7 @@ export const LocationConflictModal = ( } values={{ currentFixture: currentFixtureDisplayName, - cutout: getCutoutDisplayName(cutoutId), + cutout: displaySlotName, }} components={{ block: , diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/SetupFixtureList.tsx b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/SetupFixtureList.tsx index 82ec086b983..57764f08a34 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/SetupFixtureList.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/SetupFixtureList.tsx @@ -23,6 +23,9 @@ import { getCutoutDisplayName, getDeckDefFromRobotType, getFixtureDisplayName, + TC_MODULE_LOCATION_OT3, + THERMOCYCLER_V2_FRONT_FIXTURE, + THERMOCYCLER_V2_REAR_FIXTURE, } from '@opentrons/shared-data' import { StatusLabel } from '../../../../atoms/StatusLabel' import { TertiaryButton } from '../../../../atoms/buttons/TertiaryButton' @@ -47,9 +50,32 @@ interface SetupFixtureListProps { export const SetupFixtureList = (props: SetupFixtureListProps): JSX.Element => { const { deckConfigCompatibility, robotName } = props const deckDef = getDeckDefFromRobotType(FLEX_ROBOT_TYPE) + + const hasTwoLabwareThermocyclerConflicts = + deckConfigCompatibility.some( + ({ cutoutFixtureId, missingLabwareDisplayName }) => + cutoutFixtureId === THERMOCYCLER_V2_FRONT_FIXTURE && + missingLabwareDisplayName != null + ) && + deckConfigCompatibility.some( + ({ cutoutFixtureId, missingLabwareDisplayName }) => + cutoutFixtureId === THERMOCYCLER_V2_REAR_FIXTURE && + missingLabwareDisplayName != null + ) + + // if there are two labware conflicts with the thermocycler, don't show the conflict with the thermocycler rear fixture + const filteredDeckConfigCompatibility = deckConfigCompatibility.filter( + ({ cutoutFixtureId }) => { + return ( + !hasTwoLabwareThermocyclerConflicts || + !(cutoutFixtureId === THERMOCYCLER_V2_REAR_FIXTURE) + ) + } + ) + return ( <> - {deckConfigCompatibility.map(cutoutConfigAndCompatibility => { + {filteredDeckConfigCompatibility.map(cutoutConfigAndCompatibility => { // filter out all fixtures that only provide usb module addressable areas // (i.e. everything but MagBlockV1 and StagingAreaWithMagBlockV1) // as they're handled in the Modules Table @@ -89,6 +115,11 @@ export function FixtureListItem({ const isRequiredSingleSlotMissing = missingLabwareDisplayName != null const isConflictingFixtureConfigured = cutoutFixtureId != null && !SINGLE_SLOT_FIXTURES.includes(cutoutFixtureId) + + const isThermocyclerCurrentFixture = + cutoutFixtureId === THERMOCYCLER_V2_FRONT_FIXTURE || + cutoutFixtureId === THERMOCYCLER_V2_REAR_FIXTURE + let statusLabel if (!isCurrentFixtureCompatible) { statusLabel = ( @@ -215,7 +246,9 @@ export function FixtureListItem({ - {getCutoutDisplayName(cutoutId)} + {isThermocyclerCurrentFixture && isRequiredSingleSlotMissing + ? TC_MODULE_LOCATION_OT3 + : getCutoutDisplayName(cutoutId)} + cutoutFixtureId === THERMOCYCLER_V2_FRONT_FIXTURE && + missingLabwareDisplayName != null + ) && + requiredDeckConfigCompatibility.some( + ({ cutoutFixtureId, missingLabwareDisplayName }) => + cutoutFixtureId === THERMOCYCLER_V2_REAR_FIXTURE && + missingLabwareDisplayName != null + ) + + // if there are two labware conflicts with the thermocycler, don't show the conflict with the thermocycler rear fixture + const filteredDeckConfigCompatibility = requiredDeckConfigCompatibility.filter( + ({ cutoutFixtureId }) => { + return ( + !hasTwoLabwareThermocyclerConflicts || + !(cutoutFixtureId === THERMOCYCLER_V2_REAR_FIXTURE) + ) + } + ) + // list not configured/conflicted fixtures first - const sortedDeckConfigCompatibility = requiredDeckConfigCompatibility.sort( + const sortedDeckConfigCompatibility = filteredDeckConfigCompatibility.sort( a => a.cutoutFixtureId != null && a.compatibleCutoutFixtureIds.includes(a.cutoutFixtureId) @@ -139,6 +164,11 @@ function FixtureTableItem({ cutoutFixtureId != null && compatibleCutoutFixtureIds.includes(cutoutFixtureId) const isRequiredSingleSlotMissing = missingLabwareDisplayName != null + + const isThermocyclerCurrentFixture = + cutoutFixtureId === THERMOCYCLER_V2_FRONT_FIXTURE || + cutoutFixtureId === THERMOCYCLER_V2_REAR_FIXTURE + let chipLabel: JSX.Element if (!isCurrentFixtureCompatible) { const isConflictingFixtureConfigured = @@ -219,7 +249,13 @@ function FixtureTableItem({ - + Date: Tue, 10 Sep 2024 16:15:15 -0400 Subject: [PATCH 15/38] fix(api): Maintain existing nozzle manager for reloaded virtual pipettes (#16231) Covers RQA-3160 Ensures that we use any existing nozzle manager whenever a virtual pipette is loaded/built. --- .../protocol_engine/resources/pipette_data_provider.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/api/src/opentrons/protocol_engine/resources/pipette_data_provider.py b/api/src/opentrons/protocol_engine/resources/pipette_data_provider.py index 43b3be16f38..d3998c69bd1 100644 --- a/api/src/opentrons/protocol_engine/resources/pipette_data_provider.py +++ b/api/src/opentrons/protocol_engine/resources/pipette_data_provider.py @@ -193,9 +193,12 @@ def _get_virtual_pipette_static_config_by_model( # noqa: C901 pipette_model.pipette_channels, pipette_model.pipette_version, ) - nozzle_manager = NozzleConfigurationManager.build_from_config( - config, valid_nozzle_maps - ) + if pipette_id not in self._nozzle_manager_layout_by_id: + nozzle_manager = NozzleConfigurationManager.build_from_config( + config, valid_nozzle_maps + ) + else: + nozzle_manager = self._nozzle_manager_layout_by_id[pipette_id] tip_overlap_dict_for_tip_type = None for configuration in ( From 9d9c3603109cc3adbceabc3d0e59689b9bd83d30 Mon Sep 17 00:00:00 2001 From: Sarah Breen Date: Tue, 10 Sep 2024 16:54:41 -0400 Subject: [PATCH 16/38] fix(app): scroll to top of screen whenever route is changed (#16230) fix RQA-3153, RQA-3139 --- app/src/App/OnDeviceDisplayApp.tsx | 10 +++++++++- app/src/pages/ProtocolDashboard/index.tsx | 3 ++- app/src/pages/QuickTransferDashboard/index.tsx | 3 ++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/src/App/OnDeviceDisplayApp.tsx b/app/src/App/OnDeviceDisplayApp.tsx index 77aceabce20..7f96bb88b54 100644 --- a/app/src/App/OnDeviceDisplayApp.tsx +++ b/app/src/App/OnDeviceDisplayApp.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { useDispatch, useSelector } from 'react-redux' -import { Routes, Route, Navigate } from 'react-router-dom' +import { Routes, Route, Navigate, useLocation } from 'react-router-dom' import { css } from 'styled-components' import { ErrorBoundary } from 'react-error-boundary' @@ -225,6 +225,14 @@ export function OnDeviceDisplayAppRoutes(): JSX.Element { setCurrentNode(node) }, []) const isScrolling = useScrolling(currentNode) + const location = useLocation() + React.useEffect(() => { + currentNode?.scrollTo({ + top: 0, + left: 0, + behavior: 'auto', + }) + }, [location.pathname]) const { unfinishedUnboxingFlowRoute } = useSelector( getOnDeviceDisplaySettings diff --git a/app/src/pages/ProtocolDashboard/index.tsx b/app/src/pages/ProtocolDashboard/index.tsx index 79118a42860..c126610ef16 100644 --- a/app/src/pages/ProtocolDashboard/index.tsx +++ b/app/src/pages/ProtocolDashboard/index.tsx @@ -182,6 +182,7 @@ export function ProtocolDashboard(): JSX.Element { alignItems={ALIGN_CENTER} backgroundColor={COLORS.white} flexDirection={DIRECTION_ROW} + paddingTop={SPACING.spacing16} paddingBottom={SPACING.spacing16} position={ navMenuIsOpened || longPressModalIsOpened @@ -189,7 +190,7 @@ export function ProtocolDashboard(): JSX.Element { : POSITION_STICKY } top="7.75rem" - zIndex={navMenuIsOpened || longPressModalIsOpened ? 0 : 3} + zIndex={navMenuIsOpened || longPressModalIsOpened ? 0 : 2.5} width="100%" > diff --git a/app/src/pages/QuickTransferDashboard/index.tsx b/app/src/pages/QuickTransferDashboard/index.tsx index 1ef8d3fa4c3..1c395720d13 100644 --- a/app/src/pages/QuickTransferDashboard/index.tsx +++ b/app/src/pages/QuickTransferDashboard/index.tsx @@ -245,6 +245,7 @@ export function QuickTransferDashboard(): JSX.Element { justifyContent={JUSTIFY_SPACE_BETWEEN} backgroundColor={COLORS.white} flexDirection={DIRECTION_ROW} + paddingTop={SPACING.spacing16} paddingBottom={SPACING.spacing16} position={ navMenuIsOpened || longPressModalIsOpened @@ -252,7 +253,7 @@ export function QuickTransferDashboard(): JSX.Element { : POSITION_STICKY } top="7.75rem" - zIndex={navMenuIsOpened || longPressModalIsOpened ? 0 : 3} + zIndex={navMenuIsOpened || longPressModalIsOpened ? 0 : 2.5} width="100%" > From 8940797d5317097ec2b0ed48c70b0dd780abd0a9 Mon Sep 17 00:00:00 2001 From: CaseyBatten Date: Tue, 10 Sep 2024 17:20:30 -0400 Subject: [PATCH 17/38] fix(app): display correct pipette configuration names in run preview (#16191) Covers RQA-3128 Includes all configurations in the app run preview for any given layout --- .../assets/localization/en/protocol_command_text.json | 2 +- .../utils/getConfigureNozzleLayoutCommandText.ts | 11 ++++++++++- shared-data/command/types/setup.ts | 6 +++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/app/src/assets/localization/en/protocol_command_text.json b/app/src/assets/localization/en/protocol_command_text.json index b050ff7bc7a..40f0fe3044e 100644 --- a/app/src/assets/localization/en/protocol_command_text.json +++ b/app/src/assets/localization/en/protocol_command_text.json @@ -8,7 +8,7 @@ "closing_tc_lid": "Closing Thermocycler lid", "comment": "Comment", "configure_for_volume": "Configure {{pipette}} to aspirate {{volume}} µL", - "configure_nozzle_layout": "Configure {{pipette}} to use {{amount}} nozzles", + "configure_nozzle_layout": "Configure {{pipette}} to use {{layout}}", "confirm_and_resume": "Confirm and resume", "deactivate_hs_shake": "Deactivating shaker", "deactivate_temperature_module": "Deactivating Temperature Module", diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getConfigureNozzleLayoutCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getConfigureNozzleLayoutCommandText.ts index e6693a4b937..04d476fadd1 100644 --- a/app/src/molecules/Command/hooks/useCommandTextString/utils/getConfigureNozzleLayoutCommandText.ts +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getConfigureNozzleLayoutCommandText.ts @@ -13,8 +13,17 @@ export function getConfigureNozzleLayoutCommandText({ pip => pip.id === pipetteId )?.pipetteName + // TODO(cb, 2024-09-10): confirm these strings for copy consistency and add them to i18n + const ConfigAmount = { + SINGLE: 'single nozzle layout', + COLUMN: 'column layout', + ROW: 'row layout', + QUADRANT: 'partial layout', + ALL: 'all nozzles', + } + return t('configure_nozzle_layout', { - amount: configurationParams.style === 'COLUMN' ? '8' : 'all', + layout: ConfigAmount[configurationParams.style], pipette: pipetteName != null ? getPipetteSpecsV2(pipetteName)?.displayName : '', }) diff --git a/shared-data/command/types/setup.ts b/shared-data/command/types/setup.ts index 85613421b45..0be40e6de13 100644 --- a/shared-data/command/types/setup.ts +++ b/shared-data/command/types/setup.ts @@ -175,9 +175,9 @@ interface LoadLiquidResult { } export const COLUMN = 'COLUMN' -const SINGLE = 'SINGLE' -const ROW = 'ROW' -const QUADRANT = 'QUADRANT' +export const SINGLE = 'SINGLE' +export const ROW = 'ROW' +export const QUADRANT = 'QUADRANT' export const ALL = 'ALL' export type NozzleConfigurationStyle = From 9c83c6d45a9bc7e7b865ecffbbb05bb85b37bb17 Mon Sep 17 00:00:00 2001 From: Josh McVey Date: Tue, 10 Sep 2024 16:59:18 -0500 Subject: [PATCH 18/38] chore(cherrypick): 16208 to chore_release-8.0.0 (#16232) ## Cherrypick in #16208 Co-authored-by: Seth Foster --- .../ChooseRobotToRunProtocolSlideout.test.tsx | 19 ++---- .../index.tsx | 58 +++++++++++-------- 2 files changed, 40 insertions(+), 37 deletions(-) diff --git a/app/src/organisms/ChooseRobotToRunProtocolSlideout/__tests__/ChooseRobotToRunProtocolSlideout.test.tsx b/app/src/organisms/ChooseRobotToRunProtocolSlideout/__tests__/ChooseRobotToRunProtocolSlideout.test.tsx index 057533ce778..2f726984699 100644 --- a/app/src/organisms/ChooseRobotToRunProtocolSlideout/__tests__/ChooseRobotToRunProtocolSlideout.test.tsx +++ b/app/src/organisms/ChooseRobotToRunProtocolSlideout/__tests__/ChooseRobotToRunProtocolSlideout.test.tsx @@ -320,7 +320,7 @@ describe('ChooseRobotToRunProtocolSlideout', () => { runCreatedAt: '2022-05-11T13:33:51.012179+00:00', } when(vi.mocked(useOffsetCandidatesForAnalysis)) - .calledWith(storedProtocolDataFixture.mostRecentAnalysis, '127.0.0.1') + .calledWith(storedProtocolDataFixture.mostRecentAnalysis, null) .thenReturn([mockOffsetCandidate]) vi.mocked(getConnectableRobots).mockReturnValue([ mockConnectableRobot, @@ -333,7 +333,7 @@ describe('ChooseRobotToRunProtocolSlideout', () => { }) expect(vi.mocked(useCreateRunFromProtocol)).toHaveBeenCalledWith( expect.any(Object), - { hostname: '127.0.0.1' }, + null, [ { vector: mockOffsetCandidate.vector, @@ -369,11 +369,8 @@ describe('ChooseRobotToRunProtocolSlideout', () => { runCreatedAt: '2022-05-11T13:33:51.012179+00:00', } when(vi.mocked(useOffsetCandidatesForAnalysis)) - .calledWith(storedProtocolDataFixture.mostRecentAnalysis, '127.0.0.1') + .calledWith(storedProtocolDataFixture.mostRecentAnalysis, null) .thenReturn([mockOffsetCandidate]) - when(vi.mocked(useOffsetCandidatesForAnalysis)) - .calledWith(storedProtocolDataFixture.mostRecentAnalysis, 'otherIp') - .thenReturn([]) vi.mocked(getConnectableRobots).mockReturnValue([ mockConnectableRobot, { ...mockConnectableRobot, name: 'otherRobot', ip: 'otherIp' }, @@ -393,10 +390,9 @@ describe('ChooseRobotToRunProtocolSlideout', () => { }) fireEvent.click(proceedButton) fireEvent.click(screen.getByRole('button', { name: 'Confirm values' })) - expect(vi.mocked(useCreateRunFromProtocol)).nthCalledWith( - 3, + expect(vi.mocked(useCreateRunFromProtocol)).toHaveBeenLastCalledWith( expect.any(Object), - { hostname: '127.0.0.1' }, + null, [ { vector: mockOffsetCandidate.vector, @@ -405,11 +401,6 @@ describe('ChooseRobotToRunProtocolSlideout', () => { }, ] ) - expect(vi.mocked(useCreateRunFromProtocol)).toHaveBeenLastCalledWith( - expect.any(Object), - { hostname: 'otherIp' }, - [] - ) }) it('disables proceed button if no available robots', () => { diff --git a/app/src/organisms/ChooseRobotToRunProtocolSlideout/index.tsx b/app/src/organisms/ChooseRobotToRunProtocolSlideout/index.tsx index 0b145340852..38d39a1e031 100644 --- a/app/src/organisms/ChooseRobotToRunProtocolSlideout/index.tsx +++ b/app/src/organisms/ChooseRobotToRunProtocolSlideout/index.tsx @@ -16,7 +16,10 @@ import { SPACING, useHoverTooltip, } from '@opentrons/components' -import { useUploadCsvFileMutation } from '@opentrons/react-api-client' +import { + useUploadCsvFileMutation, + ApiHostProvider, +} from '@opentrons/react-api-client' import { Tooltip } from '../../atoms/Tooltip' import { getRobotUpdateDisplayInfo } from '../../redux/robot-update' @@ -46,11 +49,23 @@ interface ChooseRobotToRunProtocolSlideoutProps extends StyleProps { showSlideout: boolean } +interface ChooseRobotToRunProtocolSlideoutComponentProps + extends ChooseRobotToRunProtocolSlideoutProps { + selectedRobot: Robot | null + setSelectedRobot: (robot: Robot | null) => void +} + export function ChooseRobotToRunProtocolSlideoutComponent( - props: ChooseRobotToRunProtocolSlideoutProps + props: ChooseRobotToRunProtocolSlideoutComponentProps ): JSX.Element | null { const { t } = useTranslation(['protocol_details', 'shared', 'app_settings']) - const { storedProtocolData, showSlideout, onCloseClick } = props + const { + storedProtocolData, + showSlideout, + onCloseClick, + selectedRobot, + setSelectedRobot, + } = props const navigate = useNavigate() const [shouldApplyOffsets, setShouldApplyOffsets] = React.useState( true @@ -62,7 +77,6 @@ export function ChooseRobotToRunProtocolSlideoutComponent( mostRecentAnalysis, } = storedProtocolData const [currentPage, setCurrentPage] = React.useState(1) - const [selectedRobot, setSelectedRobot] = React.useState(null) const { trackCreateProtocolRunEvent } = useTrackCreateProtocolRunEvent( storedProtocolData, selectedRobot?.name ?? '' @@ -83,19 +97,10 @@ export function ChooseRobotToRunProtocolSlideoutComponent( const offsetCandidates = useOffsetCandidatesForAnalysis( mostRecentAnalysis, - selectedRobot?.ip ?? null + null ) - const { uploadCsvFile } = useUploadCsvFileMutation( - {}, - selectedRobot != null - ? { - hostname: selectedRobot.ip, - requestor: - selectedRobot?.ip === OPENTRONS_USB ? appShellRequestor : undefined, - } - : null - ) + const { uploadCsvFile } = useUploadCsvFileMutation() const { createRunFromProtocolSource, @@ -121,13 +126,7 @@ export function ChooseRobotToRunProtocolSlideoutComponent( }) }, }, - selectedRobot != null - ? { - hostname: selectedRobot.ip, - requestor: - selectedRobot?.ip === OPENTRONS_USB ? appShellRequestor : undefined, - } - : null, + null, shouldApplyOffsets ? offsetCandidates.map(({ vector, location, definitionUri }) => ({ vector, @@ -360,5 +359,18 @@ export function ChooseRobotToRunProtocolSlideoutComponent( export function ChooseRobotToRunProtocolSlideout( props: ChooseRobotToRunProtocolSlideoutProps ): JSX.Element | null { - return + const [selectedRobot, setSelectedRobot] = React.useState(null) + return ( + + + + ) } From 455d026c412b979ae69e8c022c62ff15dc474df2 Mon Sep 17 00:00:00 2001 From: Sarah Breen Date: Wed, 11 Sep 2024 16:48:34 -0400 Subject: [PATCH 19/38] fix(app): disable save button once clicked on name transfer screen (#16235) fix RQA-3165 --- .../organisms/QuickTransferFlow/NameQuickTransfer.tsx | 4 +++- app/src/organisms/QuickTransferFlow/SaveOrRunModal.tsx | 10 ++++++++-- .../__tests__/NameQuickTransfer.test.tsx | 1 + 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/src/organisms/QuickTransferFlow/NameQuickTransfer.tsx b/app/src/organisms/QuickTransferFlow/NameQuickTransfer.tsx index a68867e895f..2397c7ba1c3 100644 --- a/app/src/organisms/QuickTransferFlow/NameQuickTransfer.tsx +++ b/app/src/organisms/QuickTransferFlow/NameQuickTransfer.tsx @@ -25,6 +25,7 @@ export function NameQuickTransfer(props: NameQuickTransferProps): JSX.Element { const { t } = useTranslation('quick_transfer') const [name, setName] = React.useState('') const keyboardRef = React.useRef(null) + const [isSaving, setIsSaving] = React.useState(false) let error: string | null = null if (name.length > 60) { @@ -38,9 +39,10 @@ export function NameQuickTransfer(props: NameQuickTransferProps): JSX.Element { header={t('name_your_transfer')} buttonText={t('save')} onClickButton={() => { + setIsSaving(true) onSave(name) }} - buttonIsDisabled={name === '' || error != null} + buttonIsDisabled={name === '' || error != null || isSaving} /> { const { t } = useTranslation('quick_transfer') - const [showNameTransfer, setShowNameTransfer] = React.useState(false) + const [showNameTransfer, setShowNameTransfer] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(false) return showNameTransfer ? ( @@ -43,6 +44,7 @@ export const SaveOrRunModal = (props: SaveOrRunModalProps): JSX.Element => { { setShowNameTransfer(true) }} @@ -51,7 +53,11 @@ export const SaveOrRunModal = (props: SaveOrRunModalProps): JSX.Element => { { + setIsLoading(true) + props.onRun() + }} /> diff --git a/app/src/organisms/QuickTransferFlow/__tests__/NameQuickTransfer.test.tsx b/app/src/organisms/QuickTransferFlow/__tests__/NameQuickTransfer.test.tsx index 744849b89a8..2f4682bc060 100644 --- a/app/src/organisms/QuickTransferFlow/__tests__/NameQuickTransfer.test.tsx +++ b/app/src/organisms/QuickTransferFlow/__tests__/NameQuickTransfer.test.tsx @@ -52,6 +52,7 @@ describe('NameQuickTransfer', () => { expect(saveBtn).toBeEnabled() fireEvent.click(saveBtn) expect(props.onSave).toHaveBeenCalled() + expect(saveBtn).toBeDisabled() }) it('disables save if you enter more than 60 characters', () => { From 79cfedf4396fa5f10bf0c7d43e3fa1ce404ee1ab Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Thu, 12 Sep 2024 13:37:28 -0400 Subject: [PATCH 20/38] fix(app): Fix entering error recovery while door is open (#16245) Closes RABR-608 Currently, if you open the robot door while on the error recovery splash screen, the "launch recovery" button is not disabled, and the door CTA appears as soon as the user clicks "launch recovery". The pipette does not home (which isn't great), but for the most part, a user can complete recovery. However, the robot position is lost, which has downstream affects when the user either retries a failed command or resumes the protocol run, typically resulting in a MUST_HOME_ERROR. To solve for the pipettes not homing and this downstream error, we should just block the users from entering ER until the door is closed. After testing a few different approaches, showing a warning toast seems best. --- .../localization/en/error_recovery.json | 3 +- .../Devices/ProtocolRun/ProtocolRunHeader.tsx | 2 +- ...RunPausedSplash.tsx => RecoverySplash.tsx} | 178 ++++++++++++++---- .../__tests__/ErrorRecoveryFlows.test.tsx | 12 +- ...plash.test.tsx => RecoverySplash.test.tsx} | 53 +++++- .../organisms/ErrorRecoveryFlows/index.tsx | 7 +- 6 files changed, 194 insertions(+), 61 deletions(-) rename app/src/organisms/ErrorRecoveryFlows/{RunPausedSplash.tsx => RecoverySplash.tsx} (62%) rename app/src/organisms/ErrorRecoveryFlows/__tests__/{RunPausedSplash.test.tsx => RecoverySplash.test.tsx} (74%) diff --git a/app/src/assets/localization/en/error_recovery.json b/app/src/assets/localization/en/error_recovery.json index 605c2b55527..dd483a3daac 100644 --- a/app/src/assets/localization/en/error_recovery.json +++ b/app/src/assets/localization/en/error_recovery.json @@ -11,6 +11,7 @@ "change_location": "Change location", "change_tip_pickup_location": "Change tip pick-up location", "choose_a_recovery_action": "Choose a recovery action", + "close_door_to_resume": "Close robot door to resume", "close_the_robot_door": "Close the robot door, and then resume the recovery action.", "confirm": "Confirm", "continue": "Continue", @@ -24,6 +25,7 @@ "first_take_any_necessary_actions": "First, take any necessary actions to prepare the robot to retry the failed step.Then, close the robot door before proceeding.", "go_back": "Go back", "homing_pipette_dangerous": "Homing the {{mount}} pipette with liquid in the tips may damage it. You must remove all tips before using the pipette again.", + "if_issue_persists": " If the issue persists, cancel the run and make the necessary changes to the protocol", "if_tips_are_attached": "If tips are attached, you can choose to blow out any aspirated liquid and drop tips before the run is terminated.", "ignore_all_errors_of_this_type": "Ignore all errors of this type", "ignore_error_and_skip": "Ignore error and skip to next step", @@ -36,7 +38,6 @@ "next_try_another_action": "Next, you can try another recovery action or cancel the run.", "no_liquid_detected": "No liquid detected", "overpressure_is_usually_caused": "Overpressure is usually caused by a tip contacting labware, a clog, or moving viscous liquid too quickly", - "if_issue_persists": " If the issue persists, cancel the run and make the necessary changes to the protocol", "pick_up_tips": "Pick up tips", "pipette_overpressure": "Pipette overpressure", "preserve_aspirated_liquid": "First, do you need to preserve aspirated liquid?", diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx index d1809209764..b808844c62c 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx @@ -453,8 +453,8 @@ export function ProtocolRunHeader({ {/* Note: This banner is for before running a protocol */} {isDoorOpen && runStatus !== RUN_STATUS_BLOCKED_BY_OPEN_DOOR && - runStatus !== RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR && runStatus != null && + !RECOVERY_STATUSES.includes(runStatus) && CANCELLABLE_STATUSES.includes(runStatus) ? ( {t('shared:close_robot_door')} diff --git a/app/src/organisms/ErrorRecoveryFlows/RunPausedSplash.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx similarity index 62% rename from app/src/organisms/ErrorRecoveryFlows/RunPausedSplash.tsx rename to app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx index 30b916f7b11..041cc85cadb 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RunPausedSplash.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx @@ -3,24 +3,34 @@ import styled, { css } from 'styled-components' import { useTranslation } from 'react-i18next' import { - Flex, - Icon, - JUSTIFY_CENTER, ALIGN_CENTER, - SPACING, COLORS, DIRECTION_COLUMN, - POSITION_ABSOLUTE, - TYPOGRAPHY, - OVERFLOW_WRAP_BREAK_WORD, DISPLAY_FLEX, + Flex, + Icon, + JUSTIFY_CENTER, JUSTIFY_SPACE_BETWEEN, - TEXT_ALIGN_CENTER, - StyledText, + OVERFLOW_WRAP_BREAK_WORD, + POSITION_ABSOLUTE, PrimaryButton, SecondaryButton, + SPACING, + StyledText, + TEXT_ALIGN_CENTER, + TYPOGRAPHY, } from '@opentrons/components' +import { + RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, + RUN_STATUS_AWAITING_RECOVERY_PAUSED, +} from '@opentrons/api-client' +import type { + ERUtilsResults, + UseRecoveryAnalyticsResult, + UseRecoveryTakeoverResult, + useRetainedFailedCommandBySource, +} from './hooks' import { useErrorName } from './hooks' import { getErrorKind } from './utils' import { LargeButton } from '../../atoms/buttons' @@ -30,17 +40,13 @@ import { RECOVERY_MAP, } from './constants' import { RecoveryInterventionModal, StepInfo } from './shared' +import { useToaster } from '../ToasterOven' +import { WARNING_TOAST } from '../../atoms/Toast' import type { RobotType } from '@opentrons/shared-data' import type { ErrorRecoveryFlowsProps } from '.' -import type { - ERUtilsResults, - UseRecoveryAnalyticsResult, - UseRecoveryTakeoverResult, - useRetainedFailedCommandBySource, -} from './hooks' -export function useRunPausedSplash( +export function useRecoverySplash( isOnDevice: boolean, showERWizard: boolean ): boolean { @@ -53,18 +59,17 @@ export function useRunPausedSplash( } } -type RunPausedSplashProps = ERUtilsResults & { - isOnDevice: boolean - failedCommand: ReturnType - protocolAnalysis: ErrorRecoveryFlowsProps['protocolAnalysis'] - robotType: RobotType - robotName: string - toggleERWizAsActiveUser: UseRecoveryTakeoverResult['toggleERWizAsActiveUser'] - analytics: UseRecoveryAnalyticsResult -} -export function RunPausedSplash( - props: RunPausedSplashProps -): JSX.Element | null { +type RecoverySplashProps = ErrorRecoveryFlowsProps & + ERUtilsResults & { + isOnDevice: boolean + isWizardActive: boolean + failedCommand: ReturnType + robotType: RobotType + robotName: string + toggleERWizAsActiveUser: UseRecoveryTakeoverResult['toggleERWizAsActiveUser'] + analytics: UseRecoveryAnalyticsResult + } +export function RecoverySplash(props: RecoverySplashProps): JSX.Element | null { const { isOnDevice, toggleERWizAsActiveUser, @@ -72,10 +77,14 @@ export function RunPausedSplash( failedCommand, analytics, robotName, + runStatus, + recoveryActionMutationUtils, + isWizardActive, } = props const { t } = useTranslation('error_recovery') const errorKind = getErrorKind(failedCommand?.byRunRecord ?? null) const title = useErrorName(errorKind) + const { makeToast } = useToaster() const { proceedToRouteAndStep } = routeUpdateActions const { reportErrorEvent } = analytics @@ -88,18 +97,55 @@ export function RunPausedSplash( ) } + // Resume recovery when the run when the door is closed. + // The CTA/flow for handling a door open event within the ER wizard is different, and because this splash always renders + // behind the wizard, we want to ensure we only implicitly resume recovery when only viewing the splash. + React.useEffect(() => { + if (runStatus === RUN_STATUS_AWAITING_RECOVERY_PAUSED && !isWizardActive) { + recoveryActionMutationUtils.resumeRecovery() + } + }, [runStatus, isWizardActive]) + const buildDoorOpenAlert = (): void => { + makeToast(t('close_door_to_resume') as string, WARNING_TOAST) + } + + const handleConditionalClick = (onClick: () => void): void => { + switch (runStatus) { + case RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR: + buildDoorOpenAlert() + break + default: + onClick() + break + } + } // Do not launch error recovery, but do utilize the wizard's cancel route. - const onCancelClick = (): Promise => { - return toggleERWizAsActiveUser(true, false).then(() => { - reportErrorEvent(failedCommand?.byRunRecord ?? null, 'cancel-run') - void proceedToRouteAndStep(RECOVERY_MAP.CANCEL_RUN.ROUTE) - }) + const onCancelClick = (): void => { + const onClick = (): void => { + void toggleERWizAsActiveUser(true, false).then(() => { + reportErrorEvent(failedCommand?.byRunRecord ?? null, 'cancel-run') + void proceedToRouteAndStep(RECOVERY_MAP.CANCEL_RUN.ROUTE) + }) + } + handleConditionalClick(onClick) } - const onLaunchERClick = (): Promise => { - return toggleERWizAsActiveUser(true, true).then(() => { - reportErrorEvent(failedCommand?.byRunRecord ?? null, 'launch-recovery') - }) + const onLaunchERClick = (): void => { + const onClick = (): void => { + void toggleERWizAsActiveUser(true, true).then(() => { + reportErrorEvent(failedCommand?.byRunRecord ?? null, 'launch-recovery') + }) + } + handleConditionalClick(onClick) + } + + const isDisabled = (): boolean => { + switch (runStatus) { + case RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR: + return true + default: + return false + } } // TODO(jh 05-22-24): The hardcoded Z-indexing is non-ideal but must be done to keep the splash page above @@ -149,14 +195,18 @@ export function RunPausedSplash( @@ -194,12 +244,20 @@ export function RunPausedSplash( - + {t('cancel_run')} {t('launch_recovery_mode')} @@ -234,6 +292,30 @@ const SHARED_BUTTON_STYLE_ODD = css` width: 29rem; height: 13.5rem; ` +const BTN_STYLE_DISABLED_ODD = css` + ${SHARED_BUTTON_STYLE_ODD} + + background-color: ${COLORS.grey35}; + color: ${COLORS.grey50}; + border: none; + box-shadow: none; + + #btn-icon: { + color: ${COLORS.grey50}; + } + + &:active, + &:focus, + &:hover { + background-color: ${COLORS.grey35}; + color: ${COLORS.grey50}; + } + &:active, + &:focus, + &:hover #btn-icon { + color: ${COLORS.grey50}; + } +` const PRIMARY_BTN_STYLES_DESKTOP = css` background-color: ${COLORS.red50}; @@ -245,3 +327,17 @@ const PRIMARY_BTN_STYLES_DESKTOP = css` background-color: ${COLORS.red55}; } ` +const BTN_STYLES_DISABLED_DESKTOP = css` + background-color: ${COLORS.grey30}; + color: ${COLORS.grey40}; + border: none; + box-shadow: none; + + &:active, + &:focus, + &:hover { + background-color: ${COLORS.grey30}; + color: ${COLORS.grey40}; + } + cursor: default; +` diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx index 8ed839887ef..22a0e34109b 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx @@ -22,7 +22,7 @@ import { } from '../hooks' import { getIsOnDevice } from '../../../redux/config' import { useERWizard, ErrorRecoveryWizard } from '../ErrorRecoveryWizard' -import { useRunPausedSplash, RunPausedSplash } from '../RunPausedSplash' +import { useRecoverySplash, RecoverySplash } from '../RecoverySplash' import type { RunStatus } from '@opentrons/api-client' @@ -30,7 +30,7 @@ vi.mock('../ErrorRecoveryWizard') vi.mock('../hooks') vi.mock('../useRecoveryCommands') vi.mock('../../../redux/config') -vi.mock('../RunPausedSplash') +vi.mock('../RecoverySplash') vi.mock('@opentrons/react-api-client') vi.mock('react-redux', async () => { const actual = await vi.importActual('react-redux') @@ -143,15 +143,13 @@ describe('ErrorRecoveryFlows', () => { protocolAnalysis: {} as any, } vi.mocked(ErrorRecoveryWizard).mockReturnValue(
MOCK WIZARD
) - vi.mocked(RunPausedSplash).mockReturnValue( -
MOCK RUN PAUSED SPLASH
- ) + vi.mocked(RecoverySplash).mockReturnValue(
MOCK RUN PAUSED SPLASH
) vi.mocked(useERWizard).mockReturnValue({ hasLaunchedRecovery: true, toggleERWizard: () => Promise.resolve(), showERWizard: true, }) - vi.mocked(useRunPausedSplash).mockReturnValue(true) + vi.mocked(useRecoverySplash).mockReturnValue(true) vi.mocked(useERUtils).mockReturnValue({ routeUpdateActions: {} } as any) vi.mocked(useShowDoorInfo).mockReturnValue(false) vi.mocked(useRecoveryAnalytics).mockReturnValue({ @@ -202,7 +200,7 @@ describe('ErrorRecoveryFlows', () => { }) it('does not render the splash when showSplash is false', () => { - vi.mocked(useRunPausedSplash).mockReturnValue(false) + vi.mocked(useRecoverySplash).mockReturnValue(false) render(props) expect(screen.queryByText('MOCK RUN PAUSED SPLASH')).not.toBeInTheDocument() }) diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/RunPausedSplash.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoverySplash.test.tsx similarity index 74% rename from app/src/organisms/ErrorRecoveryFlows/__tests__/RunPausedSplash.test.tsx rename to app/src/organisms/ErrorRecoveryFlows/__tests__/RecoverySplash.test.tsx index 0764b6c865e..c09a35ff5fc 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__tests__/RunPausedSplash.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoverySplash.test.tsx @@ -3,20 +3,29 @@ import { MemoryRouter } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { fireEvent, screen, waitFor, renderHook } from '@testing-library/react' import { createStore } from 'redux' +import { QueryClient, QueryClientProvider } from 'react-query' +import { Provider } from 'react-redux' + +import { + RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, + RUN_STATUS_AWAITING_RECOVERY, + RUN_STATUS_AWAITING_RECOVERY_PAUSED, +} from '@opentrons/api-client' import { renderWithProviders } from '../../../__testing-utils__' import { i18n } from '../../../i18n' import { mockRecoveryContentProps } from '../__fixtures__' import { getIsOnDevice } from '../../../redux/config' -import { useRunPausedSplash, RunPausedSplash } from '../RunPausedSplash' +import { useRecoverySplash, RecoverySplash } from '../RecoverySplash' import { StepInfo } from '../shared' +import { useToaster } from '../../ToasterOven' +import { clickButtonLabeled } from './util' import type { Store } from 'redux' -import { QueryClient, QueryClientProvider } from 'react-query' -import { Provider } from 'react-redux' vi.mock('../../../redux/config') vi.mock('../shared') +vi.mock('../../ToasterOven') const store: Store = createStore(vi.fn(), {}) @@ -45,7 +54,7 @@ describe('useRunPausedSplash', () => { TEST_CASES.forEach(({ isOnDevice, showERWizard, expected }) => { it(`returns ${expected} when isOnDevice is ${isOnDevice} and showERWizard is ${showERWizard}`, () => { const { result } = renderHook( - () => useRunPausedSplash(isOnDevice, showERWizard), + () => useRecoverySplash(isOnDevice, showERWizard), { wrapper, } @@ -56,10 +65,10 @@ describe('useRunPausedSplash', () => { }) }) -const render = (props: React.ComponentProps) => { +const render = (props: React.ComponentProps) => { return renderWithProviders( - + , { i18nInstance: i18n, @@ -67,13 +76,15 @@ const render = (props: React.ComponentProps) => { ) } -describe('RunPausedSplash', () => { - let props: React.ComponentProps +describe('RecoverySplash', () => { + let props: React.ComponentProps const mockToggleERWiz = vi.fn(() => Promise.resolve()) const mockProceedToRouteAndStep = vi.fn() const mockRouteUpdateActions = { proceedToRouteAndStep: mockProceedToRouteAndStep, } as any + const mockMakeToast = vi.fn() + const mockResumeRecovery = vi.fn() beforeEach(() => { props = { @@ -81,9 +92,14 @@ describe('RunPausedSplash', () => { robotName: 'testRobot', toggleERWizAsActiveUser: mockToggleERWiz, routeUpdateActions: mockRouteUpdateActions, + recoveryActionMutationUtils: { + resumeRecovery: mockResumeRecovery, + } as any, + isWizardActive: false, } vi.mocked(StepInfo).mockReturnValue(
MOCK STEP INFO
) + vi.mocked(useToaster).mockReturnValue({ makeToast: mockMakeToast } as any) }) afterEach(() => { @@ -147,4 +163,25 @@ describe('RunPausedSplash', () => { expect(mockToggleERWiz).toHaveBeenCalledWith(true, true) }) }) + + it('should render a door open toast if the door is open', () => { + props = { + ...props, + runStatus: RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, + } + + render(props) + + clickButtonLabeled('Launch Recovery Mode') + + expect(mockMakeToast).toHaveBeenCalled() + }) + + it(`should transition the run status from ${RUN_STATUS_AWAITING_RECOVERY_PAUSED} to ${RUN_STATUS_AWAITING_RECOVERY}`, () => { + props = { ...props, runStatus: RUN_STATUS_AWAITING_RECOVERY_PAUSED } + + render(props) + + expect(mockResumeRecovery).toHaveBeenCalled() + }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/index.tsx b/app/src/organisms/ErrorRecoveryFlows/index.tsx index bb5dd9af584..b1b82fad236 100644 --- a/app/src/organisms/ErrorRecoveryFlows/index.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/index.tsx @@ -18,7 +18,7 @@ import { useHost } from '@opentrons/react-api-client' import { getIsOnDevice } from '../../redux/config' import { ErrorRecoveryWizard, useERWizard } from './ErrorRecoveryWizard' -import { RunPausedSplash, useRunPausedSplash } from './RunPausedSplash' +import { RecoverySplash, useRecoverySplash } from './RecoverySplash' import { RecoveryTakeover } from './RecoveryTakeover' import { useCurrentlyRecoveringFrom, @@ -136,7 +136,7 @@ export function ErrorRecoveryFlows( toggleERWizAsActiveUser, } = useRecoveryTakeover(toggleERWizard) const renderWizard = isActiveUser && (showERWizard || isDoorOpen) - const showSplash = useRunPausedSplash(isOnDevice, renderWizard) + const showSplash = useRecoverySplash(isOnDevice, renderWizard) const recoveryUtils = useERUtils({ ...props, @@ -168,7 +168,7 @@ export function ErrorRecoveryFlows( /> ) : null} {showSplash ? ( - ) : null} From 318b93e47cdbfd4d81360351e33551d62bc3bf52 Mon Sep 17 00:00:00 2001 From: Ryan Howard Date: Thu, 12 Sep 2024 15:30:47 -0400 Subject: [PATCH 21/38] fix(api): don't do lld when there is volume in the tip already (#16244) # Overview If you try to do a multi-aspirate or try to do an air-gap after aspirating it throws an error so just don't do the automatic lld if there's already liquid in the tip. Still will raise an error if you call the explicit lld methods. ## Test Plan and Hands on Testing ## Changelog ## Review requests ## Risk assessment --- api/src/opentrons/protocol_api/instrument_context.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py index 757e61cb88d..e30f920136a 100644 --- a/api/src/opentrons/protocol_api/instrument_context.py +++ b/api/src/opentrons/protocol_api/instrument_context.py @@ -266,6 +266,7 @@ def aspirate( and well is not None and self.liquid_presence_detection and self._96_tip_config_valid() + and self._core.get_current_volume() == 0 ): self.require_liquid_presence(well=well) From cf27f54e8105edc00694effd13aaff4169faa2b1 Mon Sep 17 00:00:00 2001 From: Ryan Howard Date: Thu, 12 Sep 2024 16:51:01 -0400 Subject: [PATCH 22/38] fix(api): default to both sensors on lld (#16247) # Overview In hardware testing we were explicitly calling liquid_probe with a defined probe, but the protocol engine wasn't doing this and it was always defaulting to just the A1 nozzle. The expected behavior is that both sensors should work in an OR configuration. This just adds a check that if the # of nozzles > 1 to turn both on instead of just the A1 sensor. ## Test Plan and Hands on Testing ## Changelog ## Review requests ## Risk assessment --- api/src/opentrons/hardware_control/ot3api.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index fc46f8ea9fc..78563c5c299 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -2683,6 +2683,15 @@ async def liquid_probe( self._pipette_handler.ready_for_tip_action( instrument, HardwareAction.LIQUID_PROBE, checked_mount ) + # default to using all available sensors + if probe: + checked_probe = probe + else: + checked_probe = ( + InstrumentProbeType.BOTH + if instrument.channels > 1 + else InstrumentProbeType.PRIMARY + ) if not probe_settings: probe_settings = deepcopy(self.config.liquid_sense) @@ -2756,7 +2765,7 @@ async def liquid_probe( height = await self._liquid_probe_pass( checked_mount, probe_settings, - probe if probe else InstrumentProbeType.PRIMARY, + checked_probe, p_pass_travel + p_impulse_mm, ) # if we made it here without an error we found the liquid From dff63475a6ccf53c664cc4e24cc9afecc657c0ae Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Mon, 16 Sep 2024 15:46:38 -0400 Subject: [PATCH 23/38] fix(app): fix auto transitioning run from recovery-paused to waiting recovery (#16259) Closes EXEC-702 In #16245, we made sure to block an open door while on the recovery splash view, but the logic for automatically transition a run from paused -> awaiting recovery is not quite right, which results in the app seemingly automatically issuing a POST play to transition a run from a recovery paused state to awaiting recovery whenever a user opens the door during error recovery flows (ie, anywhere but the splash screen). The tricky part is that when checking the network tab, there are no POST requests to initiate the transition. This problem occurs because another app issues the POST request. The logic for dispatching this POST in the useEffect condition within RecoverySplash is to check if the error recovery wizard is active, but this doesn't account for any other app that isn't doing the recovery. The solution: if the current app displays the takeover modal (which is true for any app except the sole app controlling the robot), then don't fire the POST request. --- .../ErrorRecoveryFlows/RecoverySplash.tsx | 14 +++++++++----- .../__tests__/RecoverySplash.test.tsx | 4 ++-- app/src/organisms/ErrorRecoveryFlows/index.tsx | 4 +++- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx index 041cc85cadb..c938fb9f4ad 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx @@ -62,10 +62,11 @@ export function useRecoverySplash( type RecoverySplashProps = ErrorRecoveryFlowsProps & ERUtilsResults & { isOnDevice: boolean - isWizardActive: boolean failedCommand: ReturnType robotType: RobotType robotName: string + /* Whether the app should resume any paused recovery state without user action. */ + resumePausedRecovery: boolean toggleERWizAsActiveUser: UseRecoveryTakeoverResult['toggleERWizAsActiveUser'] analytics: UseRecoveryAnalyticsResult } @@ -79,7 +80,7 @@ export function RecoverySplash(props: RecoverySplashProps): JSX.Element | null { robotName, runStatus, recoveryActionMutationUtils, - isWizardActive, + resumePausedRecovery, } = props const { t } = useTranslation('error_recovery') const errorKind = getErrorKind(failedCommand?.byRunRecord ?? null) @@ -99,12 +100,15 @@ export function RecoverySplash(props: RecoverySplashProps): JSX.Element | null { // Resume recovery when the run when the door is closed. // The CTA/flow for handling a door open event within the ER wizard is different, and because this splash always renders - // behind the wizard, we want to ensure we only implicitly resume recovery when only viewing the splash. + // behind the wizard, we want to ensure we only implicitly resume recovery when only viewing the splash from this app. React.useEffect(() => { - if (runStatus === RUN_STATUS_AWAITING_RECOVERY_PAUSED && !isWizardActive) { + if ( + runStatus === RUN_STATUS_AWAITING_RECOVERY_PAUSED && + resumePausedRecovery + ) { recoveryActionMutationUtils.resumeRecovery() } - }, [runStatus, isWizardActive]) + }, [runStatus, resumePausedRecovery]) const buildDoorOpenAlert = (): void => { makeToast(t('close_door_to_resume') as string, WARNING_TOAST) } diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoverySplash.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoverySplash.test.tsx index c09a35ff5fc..f0cf0099f41 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoverySplash.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoverySplash.test.tsx @@ -95,7 +95,7 @@ describe('RecoverySplash', () => { recoveryActionMutationUtils: { resumeRecovery: mockResumeRecovery, } as any, - isWizardActive: false, + resumePausedRecovery: true, } vi.mocked(StepInfo).mockReturnValue(
MOCK STEP INFO
) @@ -177,7 +177,7 @@ describe('RecoverySplash', () => { expect(mockMakeToast).toHaveBeenCalled() }) - it(`should transition the run status from ${RUN_STATUS_AWAITING_RECOVERY_PAUSED} to ${RUN_STATUS_AWAITING_RECOVERY}`, () => { + it(`should transition the run status from ${RUN_STATUS_AWAITING_RECOVERY_PAUSED} to ${RUN_STATUS_AWAITING_RECOVERY} when resumePausedRecovery is true`, () => { props = { ...props, runStatus: RUN_STATUS_AWAITING_RECOVERY_PAUSED } render(props) diff --git a/app/src/organisms/ErrorRecoveryFlows/index.tsx b/app/src/organisms/ErrorRecoveryFlows/index.tsx index b1b82fad236..3656fb7fa52 100644 --- a/app/src/organisms/ErrorRecoveryFlows/index.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/index.tsx @@ -147,6 +147,8 @@ export function ErrorRecoveryFlows( failedCommand: failedCommandBySource, }) + console.log('=>(index.tsx:180) showTakeover', showTakeover) + return ( <> {showTakeover ? ( @@ -176,7 +178,7 @@ export function ErrorRecoveryFlows( isOnDevice={isOnDevice} toggleERWizAsActiveUser={toggleERWizAsActiveUser} failedCommand={failedCommandBySource} - isWizardActive={renderWizard} + resumePausedRecovery={!renderWizard && !showTakeover} /> ) : null} From 5f868c7b9d52c5742d0c71e30602ee9c9fb5aaa4 Mon Sep 17 00:00:00 2001 From: CaseyBatten Date: Mon, 16 Sep 2024 16:00:13 -0400 Subject: [PATCH 24/38] fix(api, shared-data): Disable tip presence on single tip pickup with 96ch and update press distance (#16248) Covers RABR-617 Disables the tip presence sensor when the 96ch pipette is in single tip pickup configuration, change the press distance values down to 10.5mm --- .../protocol_engine/execution/tip_handler.py | 9 ++++++ .../general/ninety_six_channel/p1000/3_4.json | 32 +++++++++---------- .../general/ninety_six_channel/p1000/3_5.json | 32 +++++++++---------- .../general/ninety_six_channel/p1000/3_6.json | 32 +++++++++---------- 4 files changed, 57 insertions(+), 48 deletions(-) diff --git a/api/src/opentrons/protocol_engine/execution/tip_handler.py b/api/src/opentrons/protocol_engine/execution/tip_handler.py index 7acfae1e3ef..937e4abf9d8 100644 --- a/api/src/opentrons/protocol_engine/execution/tip_handler.py +++ b/api/src/opentrons/protocol_engine/execution/tip_handler.py @@ -20,6 +20,8 @@ ProtocolEngineError, ) +from opentrons.hardware_control.nozzle_manager import NozzleConfigurationType + PRIMARY_NOZZLE_TO_ENDING_NOZZLE_MAP = { "A1": {"COLUMN": "H1", "ROW": "A12"}, @@ -300,6 +302,13 @@ async def verify_tip_presence( This function will raise an exception if the specified tip presence status isn't matched. """ + if ( + self._state_view.pipettes.get_nozzle_layout_type(pipette_id) + == NozzleConfigurationType.SINGLE + and self._state_view.pipettes.get_channels(pipette_id) == 96 + ): + # Tip presence sensing is not supported for single tip pick up on the 96ch Flex Pipette + return try: ot3api = ensure_ot3_hardware(hardware_api=self._hardware_api) hw_mount = self._state_view.pipettes.get_mount(pipette_id).to_hw_mount() diff --git a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_4.json b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_4.json index 8245214c853..a47bbfc76da 100644 --- a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_4.json +++ b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_4.json @@ -119,7 +119,7 @@ "SingleA1": { "default": { "speed": 10.0, - "distance": 13.0, + "distance": 10.5, "current": 0.2, "tipOverlaps": { "v0": { @@ -144,7 +144,7 @@ }, "t1000": { "speed": 10.0, - "distance": 13.0, + "distance": 10.5, "current": 0.2, "tipOverlaps": { "v0": { @@ -161,7 +161,7 @@ }, "t200": { "speed": 10.0, - "distance": 13.0, + "distance": 10.5, "current": 0.2, "tipOverlaps": { "v0": { @@ -178,7 +178,7 @@ }, "t50": { "speed": 10.0, - "distance": 13.0, + "distance": 10.5, "current": 0.2, "tipOverlaps": { "v0": { @@ -197,7 +197,7 @@ "SingleH1": { "default": { "speed": 10.0, - "distance": 13.0, + "distance": 10.5, "current": 0.2, "tipOverlaps": { "v0": { @@ -222,7 +222,7 @@ }, "t1000": { "speed": 10.0, - "distance": 13.0, + "distance": 10.5, "current": 0.2, "tipOverlaps": { "v0": { @@ -239,7 +239,7 @@ }, "t200": { "speed": 10.0, - "distance": 13.0, + "distance": 10.5, "current": 0.2, "tipOverlaps": { "v0": { @@ -256,7 +256,7 @@ }, "t50": { "speed": 10.0, - "distance": 13.0, + "distance": 10.5, "current": 0.2, "tipOverlaps": { "v0": { @@ -275,7 +275,7 @@ "SingleA12": { "default": { "speed": 10.0, - "distance": 13.0, + "distance": 10.5, "current": 0.2, "tipOverlaps": { "v0": { @@ -300,7 +300,7 @@ }, "t1000": { "speed": 10.0, - "distance": 13.0, + "distance": 10.5, "current": 0.2, "tipOverlaps": { "v0": { @@ -317,7 +317,7 @@ }, "t200": { "speed": 10.0, - "distance": 13.0, + "distance": 10.5, "current": 0.2, "tipOverlaps": { "v0": { @@ -334,7 +334,7 @@ }, "t50": { "speed": 10.0, - "distance": 13.0, + "distance": 10.5, "current": 0.2, "tipOverlaps": { "v0": { @@ -353,7 +353,7 @@ "SingleH12": { "default": { "speed": 10.0, - "distance": 13.0, + "distance": 10.5, "current": 0.2, "tipOverlaps": { "v0": { @@ -378,7 +378,7 @@ }, "t1000": { "speed": 10.0, - "distance": 13.0, + "distance": 10.5, "current": 0.2, "tipOverlaps": { "v0": { @@ -395,7 +395,7 @@ }, "t200": { "speed": 10.0, - "distance": 13.0, + "distance": 10.5, "current": 0.2, "tipOverlaps": { "v0": { @@ -412,7 +412,7 @@ }, "t50": { "speed": 10.0, - "distance": 13.0, + "distance": 10.5, "current": 0.2, "tipOverlaps": { "v0": { diff --git a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_5.json b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_5.json index 9747bafc1d7..9c6df88b575 100644 --- a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_5.json +++ b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_5.json @@ -147,7 +147,7 @@ "SingleA1": { "default": { "speed": 10.0, - "distance": 12, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -172,7 +172,7 @@ }, "t1000": { "speed": 10.0, - "distance": 12, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -189,7 +189,7 @@ }, "t200": { "speed": 10.0, - "distance": 11, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -206,7 +206,7 @@ }, "t50": { "speed": 10.0, - "distance": 10.85, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -225,7 +225,7 @@ "SingleH1": { "default": { "speed": 10.0, - "distance": 12, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -250,7 +250,7 @@ }, "t1000": { "speed": 10.0, - "distance": 12, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -267,7 +267,7 @@ }, "t200": { "speed": 10.0, - "distance": 11, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -284,7 +284,7 @@ }, "t50": { "speed": 10.0, - "distance": 10.85, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -303,7 +303,7 @@ "SingleA12": { "default": { "speed": 10.0, - "distance": 12, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -328,7 +328,7 @@ }, "t1000": { "speed": 10.0, - "distance": 12, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -345,7 +345,7 @@ }, "t200": { "speed": 10.0, - "distance": 11, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -362,7 +362,7 @@ }, "t50": { "speed": 10.0, - "distance": 10.85, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -381,7 +381,7 @@ "SingleH12": { "default": { "speed": 10.0, - "distance": 12, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -406,7 +406,7 @@ }, "t1000": { "speed": 10.0, - "distance": 12, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -423,7 +423,7 @@ }, "t200": { "speed": 10.0, - "distance": 11, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -440,7 +440,7 @@ }, "t50": { "speed": 10.0, - "distance": 10.85, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { diff --git a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_6.json b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_6.json index 42a96a5d73b..7bcfb04e4f0 100644 --- a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_6.json +++ b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_6.json @@ -147,7 +147,7 @@ "SingleA1": { "default": { "speed": 10.0, - "distance": 12, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -172,7 +172,7 @@ }, "t1000": { "speed": 10.0, - "distance": 12, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -189,7 +189,7 @@ }, "t200": { "speed": 10.0, - "distance": 11, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -206,7 +206,7 @@ }, "t50": { "speed": 10.0, - "distance": 10.85, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -225,7 +225,7 @@ "SingleH1": { "default": { "speed": 10.0, - "distance": 12, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -250,7 +250,7 @@ }, "t1000": { "speed": 10.0, - "distance": 12, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -267,7 +267,7 @@ }, "t200": { "speed": 10.0, - "distance": 11, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -284,7 +284,7 @@ }, "t50": { "speed": 10.0, - "distance": 10.85, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -303,7 +303,7 @@ "SingleA12": { "default": { "speed": 10.0, - "distance": 12, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -328,7 +328,7 @@ }, "t1000": { "speed": 10.0, - "distance": 12, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -345,7 +345,7 @@ }, "t200": { "speed": 10.0, - "distance": 11, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -362,7 +362,7 @@ }, "t50": { "speed": 10.0, - "distance": 10.85, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -381,7 +381,7 @@ "SingleH12": { "default": { "speed": 10.0, - "distance": 12, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -406,7 +406,7 @@ }, "t1000": { "speed": 10.0, - "distance": 12, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -423,7 +423,7 @@ }, "t200": { "speed": 10.0, - "distance": 11, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { @@ -440,7 +440,7 @@ }, "t50": { "speed": 10.0, - "distance": 10.85, + "distance": 10.5, "current": 0.4, "tipOverlaps": { "v0": { From 81770b39885750dadb6369a5ad491e97efc2a3f7 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Tue, 17 Sep 2024 08:59:38 -0400 Subject: [PATCH 25/38] fix(app): fix ODD drop tip modal overflow not dismissing (#16263) --- .../InstrumentDetailOverflowMenu.tsx | 38 ++++++++----------- .../__tests__/InstrumentDetail.test.tsx | 20 +++++++++- .../InstrumentDetailOverflowMenu.test.tsx | 28 ++++++-------- app/src/pages/InstrumentDetail/index.tsx | 37 ++++++++++++++++-- 4 files changed, 80 insertions(+), 43 deletions(-) diff --git a/app/src/pages/InstrumentDetail/InstrumentDetailOverflowMenu.tsx b/app/src/pages/InstrumentDetail/InstrumentDetailOverflowMenu.tsx index 8d0541e041f..4f663d0405c 100644 --- a/app/src/pages/InstrumentDetail/InstrumentDetailOverflowMenu.tsx +++ b/app/src/pages/InstrumentDetail/InstrumentDetailOverflowMenu.tsx @@ -13,8 +13,6 @@ import { import { SINGLE_MOUNT_PIPETTES, NINETY_SIX_CHANNEL, - FLEX_ROBOT_TYPE, - getPipetteModelSpecs, } from '@opentrons/shared-data' import { ApiHostProvider } from '@opentrons/react-api-client' @@ -22,10 +20,6 @@ import { MenuList } from '../../atoms/MenuList' import { MenuItem } from '../../atoms/MenuList/MenuItem' import { PipetteWizardFlows } from '../../organisms/PipetteWizardFlows' import { GripperWizardFlows } from '../../organisms/GripperWizardFlows' -import { - DropTipWizardFlows, - useDropTipWizardFlows, -} from '../../organisms/DropTipWizardFlows' import { FLOWS } from '../../organisms/PipetteWizardFlows/constants' import { GRIPPER_FLOW_TYPES } from '../../organisms/GripperWizardFlows/constants' @@ -38,18 +32,24 @@ import type { interface InstrumentDetailsOverflowMenuProps { instrument: PipetteData | GripperData host: HostConfig | null + toggleDTWiz: () => void } export const handleInstrumentDetailOverflowMenu = ( instrument: InstrumentDetailsOverflowMenuProps['instrument'], - host: InstrumentDetailsOverflowMenuProps['host'] + host: InstrumentDetailsOverflowMenuProps['host'], + toggleDTWiz: () => void ): void => { - NiceModal.show(InstrumentDetailsOverflowMenu, { instrument, host }) + NiceModal.show(InstrumentDetailsOverflowMenu, { + instrument, + host, + toggleDTWiz, + }) } const InstrumentDetailsOverflowMenu = NiceModal.create( (props: InstrumentDetailsOverflowMenuProps): JSX.Element => { - const { instrument, host } = props + const { instrument, host, toggleDTWiz } = props const { t } = useTranslation('robot_controls') const modal = useModal() const [wizardProps, setWizardProps] = React.useState< @@ -66,9 +66,6 @@ const InstrumentDetailsOverflowMenu = NiceModal.create( modal.remove() }, } - const { showDTWiz, toggleDTWiz } = useDropTipWizardFlows() - const pipetteModelSpecs = - getPipetteModelSpecs((instrument as PipetteData).instrumentModel) ?? null const is96Channel = instrument?.ok && @@ -97,6 +94,11 @@ const InstrumentDetailsOverflowMenu = NiceModal.create( } } + const handleDropTip = (): void => { + toggleDTWiz() + modal.remove() + } + return ( @@ -120,7 +122,7 @@ const InstrumentDetailsOverflowMenu = NiceModal.create( ) : null} {instrument.mount !== 'extension' ? ( - + ) : null} - {showDTWiz && - instrument.mount !== 'extension' && - pipetteModelSpecs != null ? ( - - ) : null} ) } diff --git a/app/src/pages/InstrumentDetail/__tests__/InstrumentDetail.test.tsx b/app/src/pages/InstrumentDetail/__tests__/InstrumentDetail.test.tsx index 6b92a5ab9be..d92fd819043 100644 --- a/app/src/pages/InstrumentDetail/__tests__/InstrumentDetail.test.tsx +++ b/app/src/pages/InstrumentDetail/__tests__/InstrumentDetail.test.tsx @@ -13,9 +13,21 @@ import { usePipetteModelSpecs, } from '../../../resources/instruments/hooks' import { useIsOEMMode } from '../../../resources/robot-settings/hooks' +import { + DropTipWizardFlows, + useDropTipWizardFlows, +} from '../../../organisms/DropTipWizardFlows' import type { Instruments } from '@opentrons/api-client' - +import type * as SharedData from '@opentrons/shared-data' + +vi.mock('@opentrons/shared-data', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + getPipetteModelSpecs: vi.fn(), + } +}) vi.mock('@opentrons/react-api-client') vi.mock('react-router-dom', () => ({ useParams: vi.fn(), @@ -23,6 +35,7 @@ vi.mock('react-router-dom', () => ({ })) vi.mock('../../../resources/instruments/hooks') vi.mock('../../../resources/robot-settings/hooks') +vi.mock('../../../organisms/DropTipWizardFlows') const render = () => { return renderWithProviders(, { @@ -98,6 +111,11 @@ describe('InstrumentDetail', () => { vi.mocked(useGripperDisplayName).mockReturnValue('mockGripper') vi.mocked(useParams).mockReturnValue({ mount: 'left' }) vi.mocked(useIsOEMMode).mockReturnValue(false) + vi.mocked(useDropTipWizardFlows).mockReturnValue({ + toggleDTWiz: () => null, + showDTWiz: false, + }) + vi.mocked(DropTipWizardFlows).mockReturnValue(
MOCK_DROP_TIP_WIZ
) }) afterEach(() => { diff --git a/app/src/pages/InstrumentDetail/__tests__/InstrumentDetailOverflowMenu.test.tsx b/app/src/pages/InstrumentDetail/__tests__/InstrumentDetailOverflowMenu.test.tsx index 2c2c534ada3..555eb84ad82 100644 --- a/app/src/pages/InstrumentDetail/__tests__/InstrumentDetailOverflowMenu.test.tsx +++ b/app/src/pages/InstrumentDetail/__tests__/InstrumentDetailOverflowMenu.test.tsx @@ -11,9 +11,7 @@ import { handleInstrumentDetailOverflowMenu } from '../InstrumentDetailOverflowM import { useNotifyCurrentMaintenanceRun } from '../../../resources/maintenance_runs' import { PipetteWizardFlows } from '../../../organisms/PipetteWizardFlows' import { GripperWizardFlows } from '../../../organisms/GripperWizardFlows' -import { useDropTipWizardFlows } from '../../../organisms/DropTipWizardFlows' -import type { Mock } from 'vitest' import type { PipetteData, GripperData, @@ -31,7 +29,6 @@ vi.mock('@opentrons/shared-data', async importOriginal => { vi.mock('../../../resources/maintenance_runs') vi.mock('../../../organisms/PipetteWizardFlows') vi.mock('../../../organisms/GripperWizardFlows') -vi.mock('../../../organisms/DropTipWizardFlows') const MOCK_PIPETTE = { mount: 'left', @@ -103,13 +100,18 @@ const MOCK_GRIPPER = { } as GripperData const MOCK_HOST: HostConfig = { hostname: 'TEST_HOST' } +const mockToggleDTWiz = vi.fn() const render = (pipetteOrGripper: PipetteData | GripperData) => { return renderWithProviders(