From c06a003775cc2027a8b109ad9d1035588331d178 Mon Sep 17 00:00:00 2001 From: Ed Cormany Date: Tue, 23 Jul 2024 20:02:09 -0400 Subject: [PATCH 01/49] chore: 7.5.0 alpha release notes (#15777) # Overview Notes for 7.5.0 alpha and hopefully production. # Test Plan Check notes when updating to alpha builds. # Changelog The usual two notes files. # Review requests Accurate description of this version's sole feature addition? # Risk assessment nil. --- api/release-notes.md | 8 ++++++++ app-shell/build/release-notes.md | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/api/release-notes.md b/api/release-notes.md index dbfbfc5bad4..653dd1224ff 100644 --- a/api/release-notes.md +++ b/api/release-notes.md @@ -6,6 +6,14 @@ log][]. For a list of currently known issues, please see the [Opentrons issue tr --- +## Opentrons Robot Software Changes in 7.5.0 + +Welcome to the v7.5.0 release of the Opentrons robot software! + +This release adds support for the latest Flex Gripper hardware. + +--- + ## Opentrons Robot Software Changes in 7.4.0 Welcome to the v7.4.0 release of the Opentrons robot software! diff --git a/app-shell/build/release-notes.md b/app-shell/build/release-notes.md index 60adacf1a4a..c9ccae951b9 100644 --- a/app-shell/build/release-notes.md +++ b/app-shell/build/release-notes.md @@ -6,6 +6,14 @@ log][]. For a list of currently known issues, please see the [Opentrons issue tr --- +## Opentrons App Changes in 7.5.0 + +Welcome to the v7.5.0 release of the Opentrons App! + +There are no changes to the Opentrons App in v7.5.0, but it is required for updating the robot software to improve some features. + +--- + ## Opentrons App Changes in 7.4.0 Welcome to the v7.4.0 release of the Opentrons App! From c3a1bad3dcd3630baea0be9504f86124fbe5b7ff Mon Sep 17 00:00:00 2001 From: Josh McVey Date: Wed, 24 Jul 2024 09:14:27 -0500 Subject: [PATCH 02/49] feat(shared-data): bump gripper model version (#15413) (#15775) ## git cherry-pick 4f90e28b7ebf2a07a5a4445c43cf819a1833ae8f - #15413 was the edge PR - This is the only commit needed in the 7.5.0 release to allow for the new gripper model. - There are no changes to buildroot, oe-core, or ot3-firmware Co-authored-by: Alise Au <20424172+ahiuchingau@users.noreply.github.com> --- api/src/opentrons/config/gripper_config.py | 7 ++++- .../gripper/definitions/1/gripperV1.3.json | 29 +++++++++++++++++++ shared-data/js/constants.ts | 8 ++++- shared-data/js/gripper.ts | 10 ++++++- shared-data/js/types.ts | 2 ++ .../gripper/gripper_definition.py | 2 ++ 6 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 shared-data/gripper/definitions/1/gripperV1.3.json diff --git a/api/src/opentrons/config/gripper_config.py b/api/src/opentrons/config/gripper_config.py index 0c364bc749c..1de9be1de0b 100644 --- a/api/src/opentrons/config/gripper_config.py +++ b/api/src/opentrons/config/gripper_config.py @@ -24,7 +24,12 @@ def info_num_to_model(num: str) -> GripperModel: # PVT will now be 1.2 model_map = { "0": {"0": GripperModel.v1, "1": GripperModel.v1}, - "1": {"0": GripperModel.v1, "1": GripperModel.v1_1, "2": GripperModel.v1_2}, + "1": { + "0": GripperModel.v1, + "1": GripperModel.v1_1, + "2": GripperModel.v1_2, + "3": GripperModel.v1_3, + }, } return model_map[major_model][minor_model] diff --git a/shared-data/gripper/definitions/1/gripperV1.3.json b/shared-data/gripper/definitions/1/gripperV1.3.json new file mode 100644 index 00000000000..ef26cc062ba --- /dev/null +++ b/shared-data/gripper/definitions/1/gripperV1.3.json @@ -0,0 +1,29 @@ +{ + "$otSharedSchema": "gripper/schemas/1", + "model": "gripperV1.3", + "schemaVersion": 1, + "displayName": "Flex Gripper", + "gripForceProfile": { + "polynomial": [ + [0, 3.759869], + [1, 0.81005], + [2, 0.04597701] + ], + "defaultGripForce": 15.0, + "defaultIdleForce": 10.0, + "defaultHomeForce": 12.0, + "min": 2.0, + "max": 30.0 + }, + "geometry": { + "baseOffsetFromMount": [19.5, -74.325, -94.825], + "jawCenterOffsetFromBase": [0.0, 0.0, -86.475], + "pinOneOffsetFromBase": [6.0, -54.0, -98.475], + "pinTwoOffsetFromBase": [6.0, 54.0, -98.475], + "jawWidth": { + "min": 60.0, + "max": 92.0 + }, + "maxAllowedGripError": 3.0 + } +} diff --git a/shared-data/js/constants.ts b/shared-data/js/constants.ts index 71b4813c07e..0ba54d36fad 100644 --- a/shared-data/js/constants.ts +++ b/shared-data/js/constants.ts @@ -47,7 +47,13 @@ export const MAGNETIC_BLOCK_V1: 'magneticBlockV1' = 'magneticBlockV1' export const GRIPPER_V1: 'gripperV1' = 'gripperV1' export const GRIPPER_V1_1: 'gripperV1.1' = 'gripperV1.1' export const GRIPPER_V1_2: 'gripperV1.2' = 'gripperV1.2' -export const GRIPPER_MODELS = [GRIPPER_V1, GRIPPER_V1_1, GRIPPER_V1_2] +export const GRIPPER_V1_3: 'gripperV1.3' = 'gripperV1.3' +export const GRIPPER_MODELS = [ + GRIPPER_V1, + GRIPPER_V1_1, + GRIPPER_V1_2, + GRIPPER_V1_3, +] // robot display name export const OT2_DISPLAY_NAME: 'Opentrons OT-2' = 'Opentrons OT-2' diff --git a/shared-data/js/gripper.ts b/shared-data/js/gripper.ts index 15c1d3f7f7b..9bc8282421e 100644 --- a/shared-data/js/gripper.ts +++ b/shared-data/js/gripper.ts @@ -1,8 +1,14 @@ import gripperV1 from '../gripper/definitions/1/gripperV1.json' import gripperV1_1 from '../gripper/definitions/1/gripperV1.1.json' import gripperV1_2 from '../gripper/definitions/1/gripperV1.2.json' +import gripperV1_3 from '../gripper/definitions/1/gripperV1.3.json' -import { GRIPPER_V1, GRIPPER_V1_1, GRIPPER_V1_2 } from './constants' +import { + GRIPPER_V1, + GRIPPER_V1_1, + GRIPPER_V1_2, + GRIPPER_V1_3, +} from './constants' import type { GripperModel, GripperDefinition } from './types' @@ -16,6 +22,8 @@ export const getGripperDef = ( return gripperV1_1 as GripperDefinition case GRIPPER_V1_2: return gripperV1_2 as GripperDefinition + case GRIPPER_V1_3: + return gripperV1_3 as GripperDefinition default: console.warn( `Could not find a gripper with model ${gripperModel}, falling back to most recent definition: ${GRIPPER_V1_2}` diff --git a/shared-data/js/types.ts b/shared-data/js/types.ts index 1309159875a..681b0cb4828 100644 --- a/shared-data/js/types.ts +++ b/shared-data/js/types.ts @@ -22,6 +22,7 @@ import { GRIPPER_V1, GRIPPER_V1_1, GRIPPER_V1_2, + GRIPPER_V1_3, EXTENSION, MAGNETIC_BLOCK_V1, } from './constants' @@ -230,6 +231,7 @@ export type GripperModel = | typeof GRIPPER_V1 | typeof GRIPPER_V1_1 | typeof GRIPPER_V1_2 + | typeof GRIPPER_V1_3 export type ModuleModelWithLegacy = | ModuleModel diff --git a/shared-data/python/opentrons_shared_data/gripper/gripper_definition.py b/shared-data/python/opentrons_shared_data/gripper/gripper_definition.py index 4c4c30c623b..707d960a9ba 100644 --- a/shared-data/python/opentrons_shared_data/gripper/gripper_definition.py +++ b/shared-data/python/opentrons_shared_data/gripper/gripper_definition.py @@ -24,6 +24,7 @@ class GripperModel(str, Enum): v1 = "gripperV1" v1_1 = "gripperV1.1" v1_2 = "gripperV1.2" + v1_3 = "gripperV1.3" def __str__(self) -> str: """Model name.""" @@ -31,6 +32,7 @@ def __str__(self) -> str: self.__class__.v1: "gripperV1", self.__class__.v1_1: "gripperV1.1", self.__class__.v1_2: "gripperV1.2", + self.__class__.v1_3: "gripperV1.3", } return enum_to_str[self] From 3a93b5c757d6a6bb4f606fad5dfba1c6ab2a68bf Mon Sep 17 00:00:00 2001 From: Josh McVey Date: Thu, 25 Jul 2024 15:18:46 -0500 Subject: [PATCH 03/49] chore(release): update release notes post 7.4.0 revocation (#15798) ## Overview Update the release notes now that 7.4.0 is revoked and 7.5.0 will handle the new gripper hardware and the support for the HEPA/UV. Should we have a comment stating something about 7.4.0 being revoked? --- api/release-notes.md | 11 ++--------- app-shell/build/release-notes.md | 10 +--------- 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/api/release-notes.md b/api/release-notes.md index 653dd1224ff..a86a9fccf2e 100644 --- a/api/release-notes.md +++ b/api/release-notes.md @@ -10,19 +10,12 @@ log][]. For a list of currently known issues, please see the [Opentrons issue tr Welcome to the v7.5.0 release of the Opentrons robot software! -This release adds support for the latest Flex Gripper hardware. - ---- - -## Opentrons Robot Software Changes in 7.4.0 - -Welcome to the v7.4.0 release of the Opentrons robot software! - -This release adds support for the [Opentrons Flex HEPA/UV Module](https://opentrons.com/products/opentrons-flex-hepa-uv-module). +This release adds support for the latest Flex Gripper hardware and the [Opentrons Flex HEPA/UV Module](https://opentrons.com/products/opentrons-flex-hepa-uv-module). ### Bug Fixes - Fixed certain string runtime parameter values being misinterpreted as an incorrect type. + --- ## Opentrons Robot Software Changes in 7.3.1 diff --git a/app-shell/build/release-notes.md b/app-shell/build/release-notes.md index c9ccae951b9..68a9ff23c45 100644 --- a/app-shell/build/release-notes.md +++ b/app-shell/build/release-notes.md @@ -10,15 +10,7 @@ log][]. For a list of currently known issues, please see the [Opentrons issue tr Welcome to the v7.5.0 release of the Opentrons App! -There are no changes to the Opentrons App in v7.5.0, but it is required for updating the robot software to improve some features. - ---- - -## Opentrons App Changes in 7.4.0 - -Welcome to the v7.4.0 release of the Opentrons App! - -This release adds support for the [Opentrons Flex HEPA/UV Module](https://opentrons.com/products/opentrons-flex-hepa-uv-module). +There are no changes to the Opentrons App in v7.5.0, but it is required for updating the robot software to support the latest Flex Gripper hardware and the [Opentrons Flex HEPA/UV Module](https://opentrons.com/products/opentrons-flex-hepa-uv-module). --- From c83fec9242e43987cbeeb396a72afc87698e67d6 Mon Sep 17 00:00:00 2001 From: Josh McVey Date: Mon, 29 Jul 2024 13:01:13 -0500 Subject: [PATCH 04/49] chore(release): release note formatting and gripper model info (#15804) ## Address release notes feedback > A slight change requested. - do backticks work in this markdown? --------- Co-authored-by: Ed Cormany --- api/release-notes.md | 9 ++++++++- app-shell/build/release-notes.md | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/api/release-notes.md b/api/release-notes.md index a86a9fccf2e..d073629a97c 100644 --- a/api/release-notes.md +++ b/api/release-notes.md @@ -10,12 +10,19 @@ log][]. For a list of currently known issues, please see the [Opentrons issue tr Welcome to the v7.5.0 release of the Opentrons robot software! -This release adds support for the latest Flex Gripper hardware and the [Opentrons Flex HEPA/UV Module](https://opentrons.com/products/opentrons-flex-hepa-uv-module). +### Hardware Support + +- [Opentrons Flex HEPA/UV Module](https://opentrons.com/products/opentrons-flex-hepa-uv-module) +- Latest Flex Gripper model (serial numbers beginning `GRPV13`) ### Bug Fixes - Fixed certain string runtime parameter values being misinterpreted as an incorrect type. +### Known Issue + +- The HEPA/UV Module's buttons may not respond properly after its safety shutoff is activated. This happens when the module is removed from the top of Flex while its lights are on. Power cycle the module to restore normal behavior. The module is safe to use even if you do not power cycle it. + --- ## Opentrons Robot Software Changes in 7.3.1 diff --git a/app-shell/build/release-notes.md b/app-shell/build/release-notes.md index 68a9ff23c45..ffdf4fad357 100644 --- a/app-shell/build/release-notes.md +++ b/app-shell/build/release-notes.md @@ -10,7 +10,7 @@ log][]. For a list of currently known issues, please see the [Opentrons issue tr Welcome to the v7.5.0 release of the Opentrons App! -There are no changes to the Opentrons App in v7.5.0, but it is required for updating the robot software to support the latest Flex Gripper hardware and the [Opentrons Flex HEPA/UV Module](https://opentrons.com/products/opentrons-flex-hepa-uv-module). +There are no changes to the Opentrons App in v7.5.0, but it is required for updating the robot software to support the [Opentrons Flex HEPA/UV Module](https://opentrons.com/products/opentrons-flex-hepa-uv-module) and the latest Flex Gripper model (serial numbers beginning `GRPV13`). --- From bec238c45a7df82643d780e6993b162957fed218 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Tue, 30 Jul 2024 13:44:12 -0400 Subject: [PATCH 05/49] refactor(app): Strongly suggest dropping tips (#15834) Closes EXEC-631 Updates existing drop tip copy to strongly suggest users drop tips. Also adds a new modal on the desktop app to help call attention to dropping tips. When users close out of this modal, the drop tip banner still displays if tips are attached. If a user completes tip removal on the ODD/another app, this modal will dismiss if no more tips are detected. --- .../localization/en/drop_tip_wizard.json | 9 +- .../localization/en/error_recovery.json | 5 +- .../ProtocolRun/ProtocolDropTipBanner.tsx | 6 +- .../ProtocolRun/ProtocolDropTipModal.tsx | 132 +++++++++++++++ .../Devices/ProtocolRun/ProtocolRunHeader.tsx | 22 ++- .../__tests__/ProtocolDropTipBanner.test.tsx | 6 +- .../__tests__/ProtocolDropTipModal.test.tsx | 107 ++++++++++++ .../__tests__/ProtocolRunHeader.test.tsx | 30 +++- .../DropTipWizardFlows/TipsAttachedModal.tsx | 6 +- .../__tests__/TipsAttachedModal.test.tsx | 6 +- .../RecoveryOptions/CancelRun.tsx | 28 +-- .../RecoveryOptions/ManageTips.tsx | 159 +++++++----------- .../__tests__/ManageTips.test.tsx | 16 +- .../organisms/ErrorRecoveryFlows/constants.ts | 16 +- .../shared/RecoveryFooterButtons.tsx | 37 +++- .../__tests__/RecoveryFooterButtons.test.tsx | 55 ++++++ 16 files changed, 484 insertions(+), 156 deletions(-) create mode 100644 app/src/organisms/Devices/ProtocolRun/ProtocolDropTipModal.tsx create mode 100644 app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolDropTipModal.test.tsx diff --git a/app/src/assets/localization/en/drop_tip_wizard.json b/app/src/assets/localization/en/drop_tip_wizard.json index 16a9fab24b6..4bedd4bc8e6 100644 --- a/app/src/assets/localization/en/drop_tip_wizard.json +++ b/app/src/assets/localization/en/drop_tip_wizard.json @@ -17,13 +17,15 @@ "getting_ready": "Getting ready…", "go_back": "go back", "jog_too_far": "Jog too far?", - "start_over": "Start over", + "liquid_damages_pipette": "Homing the pipette with liquid in the tips may damage it. You must remove all tips before using the pipette again.", + "liquid_damages_this_pipette": "Homing the {{mount}} pipette with liquid in the tips may damage it. You must remove all tips before using the pipette again.", "move_to_slot": "move to slot", "no_proceed_to_drop_tip": "No, proceed to tip removal", "position_and_blowout": "Ensure that the pipette tip is centered above and level with where you want the liquid to be blown out. If it isn't, use the controls below or your keyboard to jog the pipette until it is properly aligned.", "position_and_drop_tip": "Ensure that the pipette tip is centered above and level with where you want to drop the tips. If it isn't, use the controls below or your keyboard to jog the pipette until it is properly aligned.", "position_the_pipette": "position the pipette", - "remove_the_tips": "You may want to remove the tips from the {{mount}} Pipette before using it again in a protocol.", + "remove_any_attached_tips": "Remove any attached tips", + "remove_attached_tips": "Remove any attached tips", "remove_the_tips_from_pipette": "You may want to remove the tips from the pipette before using it again in a protocol.", "remove_the_tips_manually": "Remove the tips manually. Then home the gantry. Homing with tips attached could pull liquid into the pipette and damage it.", "remove_tips": "Remove tips", @@ -35,7 +37,6 @@ "stand_back_blowing_out": "Stand back, robot is blowing out liquid", "stand_back_dropping_tips": "Stand back, robot is dropping tips", "stand_back_robot_in_motion": "Stand back, robot is in motion", - "tips_are_attached": "Tips are attached", - "tips_may_be_attached": "Tips may be attached.", + "start_over": "Start over", "yes_blow_out_liquid": "Yes, blow out liquid in labware" } diff --git a/app/src/assets/localization/en/error_recovery.json b/app/src/assets/localization/en/error_recovery.json index 71f31ccc44d..f7eb5c5a565 100644 --- a/app/src/assets/localization/en/error_recovery.json +++ b/app/src/assets/localization/en/error_recovery.json @@ -23,6 +23,7 @@ "failed_step": "Failed step", "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_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", @@ -42,7 +43,7 @@ "recovery_action_failed": "{{action}} failed", "recovery_mode": "Recovery Mode", "recovery_mode_explanation": "Recovery Mode provides you with guided and manual controls for handling errors at runtime.
You can make changes to ensure the step in progress when the error occurred can be completed or choose to cancel the protocol. When changes are made and no subsequent errors are detected, the method completes. Depending on the conditions that caused the error, you will only be provided with appropriate options.", - "remove_tips_from_pipette": "Remove tips from {{mount}} pipette before canceling the run?", + "remove_any_attached_tips": "Remove any attached tips", "replace_tips_and_select_location": "It's best to replace tips and select the last location used for tip pickup.", "replace_used_tips_in_rack_location": "Replace used tips in rack location {{location}} in slot {{slot}}", "replace_with_new_tip_rack": "Replace with new tip rack in slot {{slot}}", @@ -63,7 +64,7 @@ "robot_will_retry_with_tips": "The robot will retry the failed step with new tips.", "run_paused": "Run paused", "select_tip_pickup_location": "Select tip pick-up location", - "skip_removal": "Skip removal", + "skip": "Skip", "skip_to_next_step": "Skip to next step", "skip_to_next_step_new_tips": "Skip to next step with new tips", "skip_to_next_step_same_tips": "Skip to next step with same tips", diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolDropTipBanner.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolDropTipBanner.tsx index 7344d160510..65458700d95 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolDropTipBanner.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolDropTipBanner.tsx @@ -23,19 +23,19 @@ export function ProtocolDropTipBanner(props: { return ( - {t('tips_may_be_attached')} + {t('remove_attached_tips')} - {t('remove_the_tips_from_pipette')} + {t('liquid_damages_pipette')} void + /* True if the most recent run is the current run */ + isMostRecentRunCurrent: boolean +} + +interface UseProtocolDropTipModalResult { + showDTModal: boolean + onDTModalSkip: () => void + onDTModalRemoval: () => void +} + +// Wraps functionality required for rendering the related modal. +export function useProtocolDropTipModal({ + areTipsAttached, + toggleDTWiz, + isMostRecentRunCurrent, +}: UseProtocolDropTipModalProps): UseProtocolDropTipModalResult { + const [showDTModal, setShowDTModal] = React.useState(areTipsAttached) + + React.useEffect(() => { + if (isMostRecentRunCurrent) { + setShowDTModal(areTipsAttached) + } else { + setShowDTModal(false) + } + }, [areTipsAttached, isMostRecentRunCurrent]) + + const onDTModalSkip = (): void => { + setShowDTModal(false) + } + + const onDTModalRemoval = (): void => { + toggleDTWiz() + } + + return { showDTModal, onDTModalSkip, onDTModalRemoval } +} + +interface ProtocolDropTipModalProps { + onSkip: UseProtocolDropTipModalResult['onDTModalSkip'] + onBeginRemoval: UseProtocolDropTipModalResult['onDTModalRemoval'] + mount?: PipetteData['mount'] +} + +export function ProtocolDropTipModal({ + onSkip, + onBeginRemoval, + mount, +}: ProtocolDropTipModalProps): JSX.Element { + const { t } = useTranslation('drop_tip_wizard') + + const buildIcon = (): IconProps => { + return { + name: 'information', + color: COLORS.red50, + size: SPACING.spacing20, + marginRight: SPACING.spacing8, + } + } + + const buildHeader = (): JSX.Element => { + return ( + + ) + } + + return ( + + + + , + }} + /> + + + + + {t('begin_removal')} + + + + + ) +} + +const MODAL_STYLE = css` + width: 500px; +` diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx index 11e184c2931..bba204b5da5 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx @@ -109,6 +109,10 @@ import { ErrorRecoveryFlows, } from '../../ErrorRecoveryFlows' import { useRecoveryAnalytics } from '../../ErrorRecoveryFlows/hooks' +import { + useProtocolDropTipModal, + ProtocolDropTipModal, +} from './ProtocolDropTipModal' import type { Run, RunError, RunStatus } from '@opentrons/api-client' import type { IconName } from '@opentrons/components' @@ -213,6 +217,15 @@ export function ProtocolRunHeader({ host, isFlex, }) + const { + showDTModal, + onDTModalSkip, + onDTModalRemoval, + } = useProtocolDropTipModal({ + areTipsAttached, + toggleDTWiz, + isMostRecentRunCurrent: mostRecentRunId === runId, + }) React.useEffect(() => { if (isFlex) { @@ -403,6 +416,13 @@ export function ProtocolRunHeader({ }} /> ) : null} + {showDTModal ? ( + + ) : null} setTipStatusResolved().then(toggleDTWiz)} /> diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolDropTipBanner.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolDropTipBanner.test.tsx index 0f8b391aa57..295a1bea3f6 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolDropTipBanner.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolDropTipBanner.test.tsx @@ -24,8 +24,10 @@ describe('Module Update Banner', () => { it('displays appropriate banner text', () => { render(props) - screen.getByText('Tips may be attached.') - screen.queryByText('You may want to remove tips') + screen.getByText('Remove any attached tips') + screen.queryByText( + /Homing the .* pipette with liquid in the tips may damage it\. You must remove all tips before using the pipette again\./ + ) screen.getByText('Remove tips') }) diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolDropTipModal.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolDropTipModal.test.tsx new file mode 100644 index 00000000000..0e9ce19fc5f --- /dev/null +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolDropTipModal.test.tsx @@ -0,0 +1,107 @@ +import * as React from 'react' +import { describe, it, vi, expect, beforeEach } from 'vitest' +import { renderHook, act, screen, fireEvent } from '@testing-library/react' + +import { + useProtocolDropTipModal, + ProtocolDropTipModal, +} from '../ProtocolDropTipModal' +import { renderWithProviders } from '../../../../__testing-utils__' +import { i18n } from '../../../../i18n' + +describe('useProtocolDropTipModal', () => { + let props: Parameters[0] + + beforeEach(() => { + props = { + areTipsAttached: true, + toggleDTWiz: vi.fn(), + isMostRecentRunCurrent: true, + } + }) + + it('should return initial values', () => { + const { result } = renderHook(() => useProtocolDropTipModal(props)) + + expect(result.current).toEqual({ + showDTModal: true, + onDTModalSkip: expect.any(Function), + onDTModalRemoval: expect.any(Function), + }) + }) + + it('should update showDTModal when areTipsAttached changes', () => { + const { result, rerender } = renderHook(() => + useProtocolDropTipModal(props) + ) + + expect(result.current.showDTModal).toBe(true) + + props.areTipsAttached = false + rerender() + + expect(result.current.showDTModal).toBe(false) + }) + + it('should not show modal when isMostRecentRunCurrent is false', () => { + props.isMostRecentRunCurrent = false + const { result } = renderHook(() => useProtocolDropTipModal(props)) + + expect(result.current.showDTModal).toBe(false) + }) + + it('should call toggleDTWiz when onDTModalRemoval is called', () => { + const { result } = renderHook(() => useProtocolDropTipModal(props)) + + act(() => { + result.current.onDTModalRemoval() + }) + + expect(props.toggleDTWiz).toHaveBeenCalled() + }) +}) + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('ProtocolDropTipModal', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + onSkip: vi.fn(), + onBeginRemoval: vi.fn(), + mount: 'left', + } + }) + + it('renders the modal with correct content', () => { + render(props) + + screen.getByText('Remove any attached tips') + screen.queryByText( + /Homing the .* pipette with liquid in the tips may damage it\. You must remove all tips before using the pipette again\./ + ) + screen.getByText('Begin removal') + screen.getByText('Skip') + }) + + it('calls onSkip when skip button is clicked', () => { + render(props) + + fireEvent.click(screen.getByText('Skip')) + + expect(props.onSkip).toHaveBeenCalled() + }) + + it('calls onBeginRemoval when begin removal button is clicked', () => { + render(props) + + fireEvent.click(screen.getByText('Begin removal')) + + expect(props.onBeginRemoval).toHaveBeenCalled() + }) +}) diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx index b090b284a34..0c9b559aa22 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx @@ -96,6 +96,10 @@ import { useErrorRecoveryFlows, ErrorRecoveryFlows, } from '../../../ErrorRecoveryFlows' +import { + ProtocolDropTipModal, + useProtocolDropTipModal, +} from '../ProtocolDropTipModal' import type { UseQueryResult } from 'react-query' import type { NavigateFunction } from 'react-router-dom' @@ -151,6 +155,7 @@ vi.mock('../../../LabwarePositionCheck/useMostRecentCompletedAnalysis') vi.mock('../../../ProtocolUpload/hooks/useMostRecentRunId') vi.mock('../../../../resources/runs') vi.mock('../../../ErrorRecoveryFlows') +vi.mock('../ProtocolDropTipModal') const ROBOT_NAME = 'otie' const RUN_ID = '95e67900-bc9f-4fbf-92c6-cc4d7226a51b' @@ -374,6 +379,14 @@ describe('ProtocolRunHeader', () => { vi.mocked(ErrorRecoveryFlows).mockReturnValue(
MOCK_ERROR_RECOVERY
) + vi.mocked(useProtocolDropTipModal).mockReturnValue({ + onDTModalRemoval: vi.fn(), + onDTModalSkip: vi.fn(), + showDTModal: false, + } as any) + vi.mocked(ProtocolDropTipModal).mockReturnValue( +
MOCK_DROP_TIP_MODAL
+ ) }) afterEach(() => { @@ -1018,10 +1031,25 @@ describe('ProtocolRunHeader', () => { render() await waitFor(() => { - screen.getByText('Tips may be attached.') + screen.getByText('Remove any attached tips') + screen.getByText( + 'Homing the pipette with liquid in the tips may damage it. You must remove all tips before using the pipette again.' + ) }) }) + it('renders the drop tip modal initially when the run ends if tips are attached', () => { + vi.mocked(useProtocolDropTipModal).mockReturnValue({ + onDTModalRemoval: vi.fn(), + onDTModalSkip: vi.fn(), + showDTModal: true, + }) + + render() + + screen.getByText('MOCK_DROP_TIP_MODAL') + }) + it('does not render the drop tip banner when the run is not over', async () => { when(vi.mocked(useNotifyRunQuery)) .calledWith(RUN_ID) diff --git a/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx b/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx index c71b537cde3..0cb1872b196 100644 --- a/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx +++ b/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx @@ -46,9 +46,9 @@ const TipsAttachedModal = NiceModal.create( const { showDTWiz, toggleDTWiz } = useDropTipWizardFlows() const tipsAttachedHeader: ModalHeaderBaseProps = { - title: t('tips_are_attached'), + title: t('remove_any_attached_tips'), iconName: 'ot-alert', - iconColor: COLORS.yellow50, + iconColor: COLORS.red50, } const cleanUpAndClose = (): void => { @@ -66,7 +66,7 @@ const TipsAttachedModal = NiceModal.create( { const btn = screen.getByTestId('testButton') fireEvent.click(btn) - screen.getByText('Tips are attached') - screen.queryByText(`${LEFT} Pipette`) + screen.getByText('Remove any attached tips') + screen.queryByText( + /Homing the .* pipette with liquid in the tips may damage it\. You must remove all tips before using the pipette again\./ + ) }) it('clicking the skip button properly closes the modal', () => { render(MOCK_PIPETTES_WITH_TIP) diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/CancelRun.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/CancelRun.tsx index 96de863c965..8f49634a3ad 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/CancelRun.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/CancelRun.tsx @@ -1,6 +1,5 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' -import { css } from 'styled-components' import { ALIGN_CENTER, @@ -10,10 +9,13 @@ import { Icon, SPACING, StyledText, - RESPONSIVENESS, } from '@opentrons/components' -import { RECOVERY_MAP } from '../constants' +import { + FLEX_WIDTH_ALERT_INFO_STYLE, + ICON_SIZE_ALERT_INFO_STYLE, + RECOVERY_MAP, +} from '../constants' import { RecoveryFooterButtons, RecoverySingleColumnContentWrapper, @@ -71,11 +73,11 @@ function CancelRunConfirmation({ gridGap={SPACING.spacing16} padding={`${SPACING.spacing32} ${SPACING.spacing16}`} height="100%" - css={FLEX_WIDTH} + css={FLEX_WIDTH_ALERT_INFO_STYLE} > @@ -147,19 +149,3 @@ export function useOnCancelRun({ return { showBtnLoadingState, handleCancelRunClick } } - -const FLEX_WIDTH = css` - width: 41.625rem; - @media (${RESPONSIVENESS.touchscreenMediaQuerySpecs}) { - width: 53rem; - } -` - -const ICON_SIZE = css` - width: ${SPACING.spacing40}; - height: ${SPACING.spacing40}; - @media (${RESPONSIVENESS.touchscreenMediaQuerySpecs}) { - width: ${SPACING.spacing60}; - height: ${SPACING.spacing60}; - } -` diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx index 9fe5f22c413..6dbc6924559 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx @@ -1,7 +1,6 @@ import * as React from 'react' -import { useTranslation } from 'react-i18next' +import { Trans, useTranslation } from 'react-i18next' import head from 'lodash/head' -import { css } from 'styled-components' import { DIRECTION_COLUMN, @@ -9,20 +8,18 @@ import { SPACING, Flex, StyledText, - RESPONSIVENESS, + ALIGN_CENTER, + Icon, } from '@opentrons/components' -import { RadioButton } from '../../../atoms/buttons' import { - ODD_SECTION_TITLE_STYLE, RECOVERY_MAP, - ODD_ONLY, - DESKTOP_ONLY, + FLEX_WIDTH_ALERT_INFO_STYLE, + ICON_SIZE_ALERT_INFO_STYLE, } from '../constants' import { RecoveryFooterButtons, RecoverySingleColumnContentWrapper, - RecoveryRadioGroup, } from '../shared' import { DropTipWizardFlows } from '../../DropTipWizardFlows' import { DT_ROUTES } from '../../DropTipWizardFlows/constants' @@ -56,8 +53,6 @@ export function ManageTips(props: RecoveryContentProps): JSX.Element { return buildContent() } -type RemovalOptions = 'begin-removal' | 'skip' - export function BeginRemoval({ tipStatusUtils, routeUpdateActions, @@ -76,108 +71,70 @@ export function BeginRemoval({ const { ROBOT_CANCELING, RETRY_NEW_TIPS } = RECOVERY_MAP const mount = head(pipettesWithTip)?.mount - const [selected, setSelected] = React.useState( - 'begin-removal' - ) - const primaryOnClick = (): void => { - if (selected === 'begin-removal') { - void proceedNextStep() - } else { - if (selectedRecoveryOption === RETRY_NEW_TIPS.ROUTE) { - void proceedToRouteAndStep( - RETRY_NEW_TIPS.ROUTE, - RETRY_NEW_TIPS.STEPS.REPLACE_TIPS - ) - } else { - void setRobotInMotion(true, ROBOT_CANCELING.ROUTE).then(() => { - cancelRun() - }) - } - } + void proceedNextStep() } - const DESKTOP_ONLY_GRID_GAP = css` - @media not (${RESPONSIVENESS.touchscreenMediaQuerySpecs}) { - gap: 0rem; - } - ` - - const RADIO_GROUP_STYLE = css` - @media not (${RESPONSIVENESS.touchscreenMediaQuerySpecs}) { - color: ${COLORS.black90}; - margin-left: 0.5rem; + const secondaryOnClick = (): void => { + if (selectedRecoveryOption === RETRY_NEW_TIPS.ROUTE) { + void proceedToRouteAndStep( + RETRY_NEW_TIPS.ROUTE, + RETRY_NEW_TIPS.STEPS.REPLACE_TIPS + ) + } else { + void setRobotInMotion(true, ROBOT_CANCELING.ROUTE).then(() => { + cancelRun() + }) } - ` + } return ( - - - {t('remove_tips_from_pipette', { mount })} - + - { - setSelected('begin-removal') - }} - isSelected={selected === 'begin-removal'} - /> - { - setSelected('skip') - }} - isSelected={selected === 'skip'} + + + {t('remove_any_attached_tips')} + + + , + }} + /> + - - ) => { - setSelected(e.currentTarget.value as RemovalOptions) - }} - options={[ - { - value: 'begin-removal', - children: ( - - {t('begin_removal')} - - ), - }, - { - value: 'skip', - children: ( - - {t('skip_removal')} - - ), - }, - ]} - /> - - + ) } diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManageTips.test.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManageTips.test.tsx index aa8a7063463..362d30e2860 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManageTips.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManageTips.test.tsx @@ -90,25 +90,27 @@ describe('ManageTips', () => { it(`renders BeginRemoval with correct copy when the step is ${DROP_TIP_FLOWS.STEPS.BEGIN_REMOVAL}`, () => { render(props) - screen.getByText('Remove tips from left pipette before canceling the run?') + screen.getByText('Remove any attached tips') + screen.queryByText( + /Homing the .* pipette with liquid in the tips may damage it\. You must remove all tips before using the pipette again\./ + ) screen.queryAllByText('Begin removal') screen.queryAllByText('Skip') - expect(screen.getAllByText('Continue').length).toBe(2) }) it('routes correctly when continuing on BeginRemoval', () => { render(props) const beginRemovalBtn = screen.queryAllByText('Begin removal')[0] - const skipBtn = screen.queryAllByText('Skip removal')[0] + const skipBtn = screen.queryAllByText('Skip')[0] fireEvent.click(beginRemovalBtn) - clickButtonLabeled('Continue') + clickButtonLabeled('Begin removal') expect(mockProceedNextStep).toHaveBeenCalled() fireEvent.click(skipBtn) - clickButtonLabeled('Continue') + clickButtonLabeled('Skip') expect(mockSetRobotInMotion).toHaveBeenCalled() }) @@ -122,10 +124,10 @@ describe('ManageTips', () => { } render(props) - const skipBtn = screen.queryAllByText('Skip removal')[0] + const skipBtn = screen.queryAllByText('Skip')[0] fireEvent.click(skipBtn) - clickButtonLabeled('Continue') + clickButtonLabeled('Skip') expect(mockProceedToRouteAndStep).toHaveBeenCalledWith( RETRY_NEW_TIPS.ROUTE, diff --git a/app/src/organisms/ErrorRecoveryFlows/constants.ts b/app/src/organisms/ErrorRecoveryFlows/constants.ts index d61805d1777..2af09a3bf97 100644 --- a/app/src/organisms/ErrorRecoveryFlows/constants.ts +++ b/app/src/organisms/ErrorRecoveryFlows/constants.ts @@ -1,6 +1,6 @@ import { css } from 'styled-components' -import { SPACING, RESPONSIVENESS } from '@opentrons/components' +import { RESPONSIVENESS, SPACING } from '@opentrons/components' import type { StepOrder } from './types' @@ -218,3 +218,17 @@ export const DESKTOP_ONLY = css` display: none; } ` +export const FLEX_WIDTH_ALERT_INFO_STYLE = css` + width: 41.625rem; + @media (${RESPONSIVENESS.touchscreenMediaQuerySpecs}) { + width: 53rem; + } +` +export const ICON_SIZE_ALERT_INFO_STYLE = css` + width: ${SPACING.spacing40}; + height: ${SPACING.spacing40}; + @media (${RESPONSIVENESS.touchscreenMediaQuerySpecs}) { + width: ${SPACING.spacing60}; + height: ${SPACING.spacing60}; + } +` diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryFooterButtons.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryFooterButtons.tsx index ea78376da4e..c9042cfc55f 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryFooterButtons.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryFooterButtons.tsx @@ -20,16 +20,19 @@ import { SmallButton, TextOnlyButton } from '../../../atoms/buttons' interface RecoveryFooterButtonProps { primaryBtnOnClick: () => void - /* The "Go back" button */ - secondaryBtnOnClick?: () => void primaryBtnTextOverride?: string primaryBtnDisabled?: boolean /* If true, render pressed state and a spinner icon for the primary button. */ isLoadingPrimaryBtnAction?: boolean + /* Typically the "Go back" button */ + secondaryBtnOnClick?: () => void + secondaryBtnTextOverride?: string /* To the left of the primary button. */ tertiaryBtnOnClick?: () => void tertiaryBtnText?: string tertiaryBtnDisabled?: boolean + /* Use the style of the secondary button in the position typically used by the tertiary button. */ + secondaryAsTertiary?: boolean } export function RecoveryFooterButtons( props: RecoveryFooterButtonProps @@ -42,20 +45,24 @@ export function RecoveryFooterButtons( alignItems={ALIGN_FLEX_END} gridGap={SPACING.spacing8} > - + {!props.secondaryAsTertiary && }
) } function RecoveryGoBackButton({ + secondaryBtnTextOverride, secondaryBtnOnClick, }: RecoveryFooterButtonProps): JSX.Element | null { const showGoBackBtn = secondaryBtnOnClick != null const { t } = useTranslation('error_recovery') return showGoBackBtn ? ( - + ) : ( @@ -63,10 +70,17 @@ function RecoveryGoBackButton({ } function PrimaryButtonGroup(props: RecoveryFooterButtonProps): JSX.Element { - const { tertiaryBtnOnClick, tertiaryBtnText } = props + const { + tertiaryBtnOnClick, + tertiaryBtnText, + secondaryAsTertiary, + secondaryBtnOnClick, + } = props const renderTertiaryBtn = - tertiaryBtnOnClick != null || tertiaryBtnText != null + tertiaryBtnOnClick != null || + tertiaryBtnText != null || + (secondaryBtnOnClick != null && secondaryAsTertiary) if (!renderTertiaryBtn) { return ( @@ -76,8 +90,15 @@ function PrimaryButtonGroup(props: RecoveryFooterButtonProps): JSX.Element { ) } else { return ( - - + + {secondaryAsTertiary ? ( + + ) : ( + + )} ) diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/RecoveryFooterButtons.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/RecoveryFooterButtons.test.tsx index b4e2b260715..3e4e9045c1a 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/RecoveryFooterButtons.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/RecoveryFooterButtons.test.tsx @@ -139,4 +139,59 @@ describe('RecoveryFooterButtons', () => { expect(btn).toBeDisabled() }) }) + + it('renders the secondary button as tertiary when secondaryAsTertiary is true', () => { + props = { + ...props, + secondaryAsTertiary: true, + secondaryBtnOnClick: mockSecondaryBtnOnClick, + } + render(props) + + const secondaryBtn = screen.getAllByRole('button', { name: 'Go back' }) + expect(secondaryBtn.length).toBe(1) + + secondaryBtn.forEach(btn => { + mockSecondaryBtnOnClick.mockReset() + fireEvent.click(btn) + expect(mockSecondaryBtnOnClick).toHaveBeenCalled() + }) + }) + + it('renders secondary button with custom text when secondaryBtnTextOverride is provided', () => { + props = { + ...props, + secondaryBtnTextOverride: 'Custom Back', + } + render(props) + + const secondaryBtns = screen.getAllByRole('button', { name: 'Custom Back' }) + expect(secondaryBtns.length).toBe(1) + + secondaryBtns.forEach(btn => { + mockSecondaryBtnOnClick.mockReset() + fireEvent.click(btn) + expect(mockSecondaryBtnOnClick).toHaveBeenCalled() + }) + }) + + it('renders secondary button as tertiary with custom text', () => { + props = { + ...props, + secondaryAsTertiary: true, + secondaryBtnTextOverride: 'Custom Tertiary', + } + render(props) + + const secondaryBtns = screen.getAllByRole('button', { + name: 'Custom Tertiary', + }) + expect(secondaryBtns.length).toBe(1) + + secondaryBtns.forEach(btn => { + mockSecondaryBtnOnClick.mockReset() + fireEvent.click(btn) + expect(mockSecondaryBtnOnClick).toHaveBeenCalled() + }) + }) }) From 8d9e55de2235e8c5c79954ad1face835e543ccfb Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Tue, 30 Jul 2024 13:57:49 -0400 Subject: [PATCH 06/49] refactor(robot-server): Rename `.../errorRecoveryPolicies` to `.../errorRecoveryPolicy` (#15832) --- .../robot_server/runs/error_recovery_models.py | 2 +- robot-server/robot_server/runs/router/base_router.py | 8 ++++---- robot-server/tests/runs/router/test_base_router.py | 12 ++++++------ 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/robot-server/robot_server/runs/error_recovery_models.py b/robot-server/robot_server/runs/error_recovery_models.py index e36cc576b01..5558c65a8ac 100644 --- a/robot-server/robot_server/runs/error_recovery_models.py +++ b/robot-server/robot_server/runs/error_recovery_models.py @@ -71,7 +71,7 @@ class ErrorRecoveryRule(BaseModel): ) -class ErrorRecoveryPolicies(BaseModel): +class ErrorRecoveryPolicy(BaseModel): """Request/Response model for new error recovery policy rules creation.""" policyRules: List[ErrorRecoveryRule] = Field( diff --git a/robot-server/robot_server/runs/router/base_router.py b/robot-server/robot_server/runs/router/base_router.py index b4bc4baf3fe..14c5b822fda 100644 --- a/robot-server/robot_server/runs/router/base_router.py +++ b/robot-server/robot_server/runs/router/base_router.py @@ -45,7 +45,7 @@ get_run_auto_deleter, get_quick_transfer_run_auto_deleter, ) -from ..error_recovery_models import ErrorRecoveryPolicies +from ..error_recovery_models import ErrorRecoveryPolicy from robot_server.deck_configuration.fastapi_dependencies import ( get_deck_configuration_store, @@ -367,7 +367,7 @@ async def update_run( @PydanticResponse.wrap_route( base_router.put, - path="/runs/{runId}/errorRecoveryPolicies", + path="/runs/{runId}/errorRecoveryPolicy", summary="Set run policies", description=dedent( """ @@ -381,9 +381,9 @@ async def update_run( status.HTTP_409_CONFLICT: {"model": ErrorBody[RunStopped]}, }, ) -async def set_run_policies( +async def put_error_recovery_policy( runId: str, - request_body: RequestModel[ErrorRecoveryPolicies], + request_body: RequestModel[ErrorRecoveryPolicy], run_data_manager: RunDataManager = Depends(get_run_data_manager), ) -> PydanticResponse[SimpleEmptyBody]: """Create run polices. diff --git a/robot-server/tests/runs/router/test_base_router.py b/robot-server/tests/runs/router/test_base_router.py index 5f770de5ae9..8ee37d64b9d 100644 --- a/robot-server/tests/runs/router/test_base_router.py +++ b/robot-server/tests/runs/router/test_base_router.py @@ -9,7 +9,7 @@ from opentrons.protocol_reader import ProtocolSource, JsonProtocolConfig from robot_server.errors.error_responses import ApiError -from robot_server.runs.error_recovery_models import ErrorRecoveryPolicies +from robot_server.runs.error_recovery_models import ErrorRecoveryPolicy from robot_server.service.json_api import ( RequestModel, SimpleBody, @@ -39,7 +39,7 @@ get_runs, remove_run, update_run, - set_run_policies, + put_error_recovery_policy, ) from robot_server.deck_configuration.store import DeckConfigurationStore @@ -578,8 +578,8 @@ async def test_create_policies( decoy: Decoy, mock_run_data_manager: RunDataManager ) -> None: """It should call RunDataManager create run policies.""" - policies = decoy.mock(cls=ErrorRecoveryPolicies) - await set_run_policies( + policies = decoy.mock(cls=ErrorRecoveryPolicy) + await put_error_recovery_policy( runId="rud-id", request_body=RequestModel(data=policies), run_data_manager=mock_run_data_manager, @@ -595,14 +595,14 @@ async def test_create_policies_raises_not_active_run( decoy: Decoy, mock_run_data_manager: RunDataManager ) -> None: """It should raise that the run is not current.""" - policies = decoy.mock(cls=ErrorRecoveryPolicies) + policies = decoy.mock(cls=ErrorRecoveryPolicy) decoy.when( mock_run_data_manager.set_policies( run_id="rud-id", policies=policies.policyRules ) ).then_raise(RunNotCurrentError()) with pytest.raises(ApiError) as exc_info: - await set_run_policies( + await put_error_recovery_policy( runId="rud-id", request_body=RequestModel(data=policies), run_data_manager=mock_run_data_manager, From a4197cb3887c37d371ffbe50c11e25e4052ca82c Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Tue, 30 Jul 2024 16:35:47 -0400 Subject: [PATCH 07/49] feat(app): Add Error Recovery takeover banners (#15841) Closes EXEC-633 and EXEC-420 --- app/src/assets/localization/en/shared.json | 21 +++--- app/src/organisms/Devices/RobotCard.tsx | 12 +++ app/src/organisms/Devices/RobotOverview.tsx | 12 +++ .../Devices/__tests__/RobotCard.test.tsx | 23 ++++++ .../Devices/__tests__/RobotOverview.test.tsx | 23 ++++++ .../__tests__/ErrorRecoveryBanner.test.tsx | 48 ++++++++++++ .../__tests__/useErrorRecoveryBanner.test.ts | 54 +++++++++++++ .../organisms/ErrorRecoveryBanner/index.tsx | 75 +++++++++++++++++++ 8 files changed, 258 insertions(+), 10 deletions(-) create mode 100644 app/src/organisms/ErrorRecoveryBanner/__tests__/ErrorRecoveryBanner.test.tsx create mode 100644 app/src/organisms/ErrorRecoveryBanner/__tests__/useErrorRecoveryBanner.test.ts create mode 100644 app/src/organisms/ErrorRecoveryBanner/index.tsx diff --git a/app/src/assets/localization/en/shared.json b/app/src/assets/localization/en/shared.json index 996ed8326d2..0b580a612e8 100644 --- a/app/src/assets/localization/en/shared.json +++ b/app/src/assets/localization/en/shared.json @@ -2,6 +2,7 @@ "a_software_update_is_available": "A software update is available for this robot. Update to run protocols.", "add": "add", "alphabetical": "Alphabetical", + "another_app_controlling_robot": "The robot's touchscreen or another app may be controlling this robot.", "back": "Back", "before_you_begin": "Before you begin", "browse": "browse", @@ -9,15 +10,15 @@ "change_protocol": "Change protocol", "change_robot": "Change robot", "clear_data": "clear data", - "close_robot_door": "Close the robot door before starting the run.", "close": "close", + "close_robot_door": "Close the robot door before starting the run.", + "confirm": "Confirm", "confirm_placement": "Confirm placement", "confirm_position": "Confirm position", "confirm_values": "Confirm values", - "confirm": "Confirm", + "continue": "continue", "continue_activity": "Continue activity", "continue_to_param": "Continue to parameters", - "continue": "continue", "delete": "Delete", "did_pipette_pick_up_tip": "Did pipette pick up tip successfully?", "disabled_cannot_connect": "Cannot connect to robot", @@ -28,8 +29,8 @@ "drag_and_drop": "Drag and drop or browse your files", "empty": "empty", "ending": "ending", - "error_encountered": "Error encountered", "error": "error", + "error_encountered": "Error encountered", "exit": "exit", "extension_mount": "extension mount", "flow_complete": "{{flowName}} complete!", @@ -39,8 +40,8 @@ "instruments": "instruments", "loading": "Loading...", "next": "Next", - "no_data": "no data", "no": "no", + "no_data": "no data", "none": "None", "not_used": "Not Used", "off": "Off", @@ -50,18 +51,18 @@ "proceed_to_setup": "Proceed to setup", "protocol_run_general_error_msg": "Protocol run could not be created on the robot.", "reanalyze": "Reanalyze", - "refresh_list": "Refresh list", "refresh": "refresh", + "refresh_list": "Refresh list", "remember_my_selection_and_do_not_ask_again": "Remember my selection and don't ask again", - "reset_all": "Reset all", "reset": "Reset", + "reset_all": "Reset all", "restart": "restart", "resume": "resume", "return": "return", "reverse": "Reverse alphabetical", "robot_is_analyzing": "Robot is analyzing", - "robot_is_busy_no_protocol_run_allowed": "This robot is busy and can’t run this protocol right now. Go to Robot", "robot_is_busy": "Robot is busy", + "robot_is_busy_no_protocol_run_allowed": "This robot is busy and can’t run this protocol right now. Go to Robot", "robot_is_reachable_but_not_responding": "This robot's API server is not responding correctly to requests at IP address {{hostname}}", "robot_was_seen_but_is_unreachable": "This robot has been seen recently, but is currently not reachable at IP address {{hostname}}", "save": "save", @@ -72,11 +73,11 @@ "starting": "starting", "step": "Step {{current}} / {{max}}", "stop": "stop", - "terminate_activity": "Terminate activity", "terminate": "Terminate remote activity", + "terminate_activity": "Terminate activity", "try_again": "try again", - "unknown_error": "An unknown error occurred", "unknown": "unknown", + "unknown_error": "An unknown error occurred", "update": "Update", "view_latest_release_notes": "View latest release notes on", "yes": "yes", diff --git a/app/src/organisms/Devices/RobotCard.tsx b/app/src/organisms/Devices/RobotCard.tsx index d92ea790435..246cfea101f 100644 --- a/app/src/organisms/Devices/RobotCard.tsx +++ b/app/src/organisms/Devices/RobotCard.tsx @@ -41,6 +41,10 @@ import { useIsFlex } from './hooks' import { ReachableBanner } from './ReachableBanner' import { RobotOverflowMenu } from './RobotOverflowMenu' import { RobotStatusHeader } from './RobotStatusHeader' +import { + ErrorRecoveryBanner, + useErrorRecoveryBanner, +} from '../ErrorRecoveryBanner' import type { GripperData } from '@opentrons/api-client' import type { GripperModel } from '@opentrons/shared-data' @@ -59,6 +63,8 @@ export function RobotCard(props: RobotCardProps): JSX.Element | null { getRobotModelByName(state, robotName) ) + const { showRecoveryBanner, recoveryIntent } = useErrorRecoveryBanner() + return robot != null ? ( + {showRecoveryBanner ? ( + + ) : null} + {showRecoveryBanner ? ( + + ) : null} { when(getRobotModelByName) .calledWith(MOCK_STATE, mockConnectableRobot.name) .thenReturn('OT-2') + vi.mocked(ErrorRecoveryBanner).mockReturnValue( +
MOCK_RECOVERY_BANNER
+ ) + vi.mocked(useErrorRecoveryBanner).mockReturnValue({ + showRecoveryBanner: false, + recoveryIntent: 'recovering', + }) }) it('renders an OT-2 image when robot model is OT-2', () => { @@ -161,4 +173,15 @@ describe('RobotCard', () => { render(props) screen.getByText('Mock RobotStatusHeader') }) + + it('renders the error recovery banner when another user is performing error recovery', () => { + vi.mocked(useErrorRecoveryBanner).mockReturnValue({ + showRecoveryBanner: true, + recoveryIntent: 'recovering', + }) + + render(props) + + screen.getByText('MOCK_RECOVERY_BANNER') + }) }) diff --git a/app/src/organisms/Devices/__tests__/RobotOverview.test.tsx b/app/src/organisms/Devices/__tests__/RobotOverview.test.tsx index 6aaa236d49c..c55757684d5 100644 --- a/app/src/organisms/Devices/__tests__/RobotOverview.test.tsx +++ b/app/src/organisms/Devices/__tests__/RobotOverview.test.tsx @@ -44,6 +44,10 @@ import { UpdateRobotBanner } from '../../UpdateRobotBanner' import { RobotStatusHeader } from '../RobotStatusHeader' import { RobotOverview } from '../RobotOverview' import { RobotOverviewOverflowMenu } from '../RobotOverviewOverflowMenu' +import { + ErrorRecoveryBanner, + useErrorRecoveryBanner, +} from '../../ErrorRecoveryBanner' import type { Config } from '../../../redux/config/types' import type { DiscoveryClientRobotAddress } from '../../../redux/discovery/types' @@ -66,6 +70,7 @@ vi.mock('../hooks') vi.mock('../RobotStatusHeader') vi.mock('../../UpdateRobotBanner') vi.mock('../RobotOverviewOverflowMenu') +vi.mock('../../ErrorRecoveryBanner') const OT2_PNG_FILE_NAME = '/app/src/assets/images/OT2-R_HERO.png' const FLEX_PNG_FILE_NAME = '/app/src/assets/images/FLEX.png' @@ -164,6 +169,13 @@ describe('RobotOverview', () => { registrationToken: { token: 'my.registration.jwt' }, }) vi.mocked(useIsRobotViewable).mockReturnValue(true) + vi.mocked(ErrorRecoveryBanner).mockReturnValue( +
MOCK_RECOVERY_BANNER
+ ) + vi.mocked(useErrorRecoveryBanner).mockReturnValue({ + showRecoveryBanner: false, + recoveryIntent: 'recovering', + }) }) it('renders an OT-2 image', () => { @@ -367,4 +379,15 @@ describe('RobotOverview', () => { agentId: 'opentrons-robot-user', }) }) + + it('renders the error recovery banner when another user is performing error recovery', () => { + vi.mocked(useErrorRecoveryBanner).mockReturnValue({ + showRecoveryBanner: true, + recoveryIntent: 'recovering', + }) + + render(props) + + screen.getByText('MOCK_RECOVERY_BANNER') + }) }) diff --git a/app/src/organisms/ErrorRecoveryBanner/__tests__/ErrorRecoveryBanner.test.tsx b/app/src/organisms/ErrorRecoveryBanner/__tests__/ErrorRecoveryBanner.test.tsx new file mode 100644 index 00000000000..78c5da162c3 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryBanner/__tests__/ErrorRecoveryBanner.test.tsx @@ -0,0 +1,48 @@ +import * as React from 'react' +import { describe, it, vi, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' + +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' +import { useErrorRecoveryBanner, ErrorRecoveryBanner } from '..' + +vi.mock('..', async importOriginal => { + const actualReact = await importOriginal() + return { + ...actualReact, + useErrorRecoveryBanner: vi.fn(), + } +}) + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('ErrorRecoveryBanner', () => { + beforeEach(() => { + vi.mocked(useErrorRecoveryBanner).mockReturnValue({ + showRecoveryBanner: true, + recoveryIntent: 'recovering', + }) + }) + + it('renders banner with correct content for recovering intent', () => { + render({ recoveryIntent: 'recovering' }) + + screen.getByText('Robot is in recovery mode') + screen.getByText( + 'The robot’s touchscreen or another computer with the app is currently controlling this robot.' + ) + }) + + it('renders banner with correct content for canceling intent', () => { + render({ recoveryIntent: 'canceling' }) + + screen.getByText('Robot is canceling the run') + screen.getByText( + 'The robot’s touchscreen or another computer with the app is currently controlling this robot.' + ) + }) +}) diff --git a/app/src/organisms/ErrorRecoveryBanner/__tests__/useErrorRecoveryBanner.test.ts b/app/src/organisms/ErrorRecoveryBanner/__tests__/useErrorRecoveryBanner.test.ts new file mode 100644 index 00000000000..6eedd264499 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryBanner/__tests__/useErrorRecoveryBanner.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useSelector } from 'react-redux' +import { getUserId } from '../../../redux/config' +import { useClientDataRecovery } from '../../../resources/client_data' +import { renderHook } from '@testing-library/react' +import { useErrorRecoveryBanner } from '../index' + +vi.mock('react-redux', () => ({ + useSelector: vi.fn(), +})) +vi.mock('../../../redux/config') +vi.mock('../../../resources/client_data') + +describe('useErrorRecoveryBanner', () => { + beforeEach(() => { + vi.mocked(useSelector).mockReturnValue('thisUserId') + vi.mocked(getUserId).mockReturnValue('thisUserId') + vi.mocked(useClientDataRecovery).mockReturnValue({ + userId: null, + intent: null, + }) + }) + + it('should return initial values', () => { + const { result } = renderHook(() => useErrorRecoveryBanner()) + + expect(result.current).toEqual({ + showRecoveryBanner: false, + recoveryIntent: 'recovering', + }) + }) + + it('should show banner when userId is different', () => { + vi.mocked(useClientDataRecovery).mockReturnValue({ + userId: 'otherUserId', + intent: null, + }) + + const { result } = renderHook(() => useErrorRecoveryBanner()) + + expect(result.current.showRecoveryBanner).toBe(true) + }) + + it('should return correct intent when provided', () => { + vi.mocked(useClientDataRecovery).mockReturnValue({ + userId: 'otherUserId', + intent: 'canceling', + }) + + const { result } = renderHook(() => useErrorRecoveryBanner()) + + expect(result.current.recoveryIntent).toBe('canceling') + }) +}) diff --git a/app/src/organisms/ErrorRecoveryBanner/index.tsx b/app/src/organisms/ErrorRecoveryBanner/index.tsx new file mode 100644 index 00000000000..504cf2fc979 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryBanner/index.tsx @@ -0,0 +1,75 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' + +import { + Flex, + DIRECTION_COLUMN, + SPACING, + StyledText, +} from '@opentrons/components' + +import { getUserId } from '../../redux/config' +import { useClientDataRecovery } from '../../resources/client_data' +import { Banner } from '../../atoms/Banner' + +import type { RecoveryIntent } from '../../resources/client_data' +import type { StyleProps } from '@opentrons/components' + +const CLIENT_DATA_INTERVAL_MS = 5000 + +export interface UseErrorRecoveryBannerResult { + showRecoveryBanner: boolean + recoveryIntent: RecoveryIntent +} + +export function useErrorRecoveryBanner(): UseErrorRecoveryBannerResult { + const { userId, intent } = useClientDataRecovery({ + refetchInterval: CLIENT_DATA_INTERVAL_MS, + }) + const thisUserId = useSelector(getUserId) + + return { + showRecoveryBanner: userId !== null && thisUserId !== userId, + recoveryIntent: intent ?? 'recovering', + } +} + +export interface ErrorRecoveryBannerProps extends StyleProps { + recoveryIntent: RecoveryIntent +} + +export function ErrorRecoveryBanner({ + recoveryIntent, + ...styleProps +}: ErrorRecoveryBannerProps): JSX.Element { + const { t } = useTranslation(['error_recovery', 'shared']) + + const buildTitleText = (): string => { + switch (recoveryIntent) { + case 'canceling': + return t('robot_is_canceling_run') + case 'recovering': + default: + return t('robot_is_in_recovery_mode') + } + } + + return ( + + + + {buildTitleText()} + + + + {t('another_app_controlling_robot')} + + + + + ) +} From e8eda33c534c13bc5c4422f2fa2d64488a983222 Mon Sep 17 00:00:00 2001 From: Ed Cormany Date: Tue, 30 Jul 2024 18:41:45 -0400 Subject: [PATCH 08/49] fix(app-shell): straighten quotes in EULA text (#15829) # Overview Uniformly use straight instead of curly quotes in the EULA text, for maximum compatibility. Partially addresses RQA-2876. ## Test Plan and Hands on Testing Check display in next alpha. ## Changelog Quotation marks only. No other non-ASCII "gremlins" present. ## Risk assessment v low --- app-shell/build/license_en.txt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app-shell/build/license_en.txt b/app-shell/build/license_en.txt index cf847badf81..c764c7b3e8a 100644 --- a/app-shell/build/license_en.txt +++ b/app-shell/build/license_en.txt @@ -2,9 +2,9 @@ Opentrons End-User License Agreement Last updated: July 10, 2024 -THIS END-USER LICENSE AGREEMENT (“EULA”) is a legal agreement between you (“User”), either as an individual or on behalf of an entity, and Opentrons Labworks Inc. (“Opentrons”) regarding your use of Opentrons robots, modules, software, and associated documentation (“Opentrons Products”) including, but not limited to, the Opentrons OT-2 robot and associated modules, the Opentrons Flex robot and associated modules, the Opentrons App, the Opentrons API, the Opentrons Protocol Designer and Protocol Library, the Opentrons Labware Library, and the Opentrons Website. By installing or using the Opentrons Products, you agree to be bound by the terms and conditions of this EULA. If you do not agree to the terms of this EULA, you must immediately cease use of the Opentrons Products. +THIS END-USER LICENSE AGREEMENT ("EULA") is a legal agreement between you ("User"), either as an individual or on behalf of an entity, and Opentrons Labworks Inc. ("Opentrons") regarding your use of Opentrons robots, modules, software, and associated documentation ("Opentrons Products") including, but not limited to, the Opentrons OT-2 robot and associated modules, the Opentrons Flex robot and associated modules, the Opentrons App, the Opentrons API, the Opentrons Protocol Designer and Protocol Library, the Opentrons Labware Library, and the Opentrons Website. By installing or using the Opentrons Products, you agree to be bound by the terms and conditions of this EULA. If you do not agree to the terms of this EULA, you must immediately cease use of the Opentrons Products. -License Grant. Opentrons grants User a revocable, non-exclusive, non-transferable, limited license to access and use the Opentrons Products strictly in accordance with the terms and conditions of this EULA, the Opentrons Terms and Conditions of Sale, the Opentrons Privacy Policy, and any other agreements between User and Opentrons (collectively “Related Agreements”). +License Grant. Opentrons grants User a revocable, non-exclusive, non-transferable, limited license to access and use the Opentrons Products strictly in accordance with the terms and conditions of this EULA, the Opentrons Terms and Conditions of Sale, the Opentrons Privacy Policy, and any other agreements between User and Opentrons (collectively "Related Agreements"). Use of Opentrons Products. Permitted Use. User shall use the Opentrons Products strictly in accordance with the terms of the EULA and Related Agreements. User shall use Opentrons Product software only in conjunction with Opentrons Product hardware. Restrictions on Use. Unless otherwise specified in a separate agreement entered into between Opentrons and User, User may not, and may not permit others to: @@ -25,15 +25,15 @@ Intellectual Property Rights. Opentrons retains all rights, title, and interest Ownership. All worldwide patents, copyrights, trade secrets, and other intellectual property rights related to the Opentrons Products are the exclusive property of Opentrons. Feedback. Any feedback or suggestions provided by User regarding the Opentrons Products may be used by Opentrons without any obligation to User, and User hereby provides Opentrons a perpetual, global, fully paid-up, royalty free license to use such feedback or suggestions. Privacy Notices. The Opentrons Products may automatically communicate with Opentrons servers and transmit data to Opentrons for various purposes including, but not limited to: 1. updating Opentrons Product software; 2. sending error reports to Opentrons; and 3. sending Opentrons Product usage data to Opentrons. The collection and use of such data is governed by the Opentrons Privacy Policy. By agreeing to this EULA you also acknowledge and agree to the Opentrons Privacy Policy. If User does not agree to the Opentrons Privacy or to the collection of Opentrons Product data, User must immediately cease all use of Opentrons Products and destroy all copies of Opentrons Product software. -Disclaimer of Warranties. THE OPENTRONS PRODUCTS ARE PROVIDED ON AN “AS IS” BASIS AND NO WARRANTY, EITHER EXPRESS OR IMPLIED, IS GIVEN. OPENTRONS DISCLAIMS ALL REPRESENTATIONS, WARRANTIES AND CONDITIONS, EXPRESS, IMPLIED OR COLLATERAL, INCLUDING AS TO OWNERSHIP AND NON-INFRINGEMENT, THE IMPLIED WARRANTIES OR CONDITIONS OF MERCHANTABILITY, MERCHANTABLE QUALITY, FITNESS FOR A PARTICULAR PURPOSE, AND THOSE ARISING BY STATUTE OR OTHERWISE IN LAW, OR FROM THE COURSE OF DEALING OR USAGE OF TRADE. WITHOUT LIMITING THE FOREGOING, OPENTRONS DOES NOT REPRESENT OR WARRANT THAT THE OPENTRONS PRODUCTS WILL MEET ANY OR ALL OF YOUR PARTICULAR REQUIREMENTS, THAT THE OPERATION OF THE OPENTRONS PRODUCTS WILL BE ERROR FREE OR UNINTERRUPTED OR THAT ALL PROGRAMMING ERRORS IN THE OPENTRONS PRODUCTS CAN BE FOUND IN ORDER TO BE CORRECTED. -Limitation of Liability. To the maximum extent permitted by applicable law, in no event shall Opentrons, its affiliates, shareholders, directors, officers, employees and agents be liable for any special, incidental, indirect, exemplary, or consequential damages whatsoever (including, without limitation, damages for loss of business profits, business interruption, loss of business information, or any other pecuniary loss) arising out of your use or inability to use the Opentrons Products, whether or not the damages were foreseeable and whether or not Opentrons was advised of the possibility of such damages. In any case, without limiting the foregoing, Opentrons’ entire liability arising from or under any provision of this EULA or from the use of the Opentrons Products shall be limited to fifty dollars ($50.00). The foregoing limitations will apply even if the above stated remedy fails in its essential purpose. +Disclaimer of Warranties. THE OPENTRONS PRODUCTS ARE PROVIDED ON AN "AS IS" BASIS AND NO WARRANTY, EITHER EXPRESS OR IMPLIED, IS GIVEN. OPENTRONS DISCLAIMS ALL REPRESENTATIONS, WARRANTIES AND CONDITIONS, EXPRESS, IMPLIED OR COLLATERAL, INCLUDING AS TO OWNERSHIP AND NON-INFRINGEMENT, THE IMPLIED WARRANTIES OR CONDITIONS OF MERCHANTABILITY, MERCHANTABLE QUALITY, FITNESS FOR A PARTICULAR PURPOSE, AND THOSE ARISING BY STATUTE OR OTHERWISE IN LAW, OR FROM THE COURSE OF DEALING OR USAGE OF TRADE. WITHOUT LIMITING THE FOREGOING, OPENTRONS DOES NOT REPRESENT OR WARRANT THAT THE OPENTRONS PRODUCTS WILL MEET ANY OR ALL OF YOUR PARTICULAR REQUIREMENTS, THAT THE OPERATION OF THE OPENTRONS PRODUCTS WILL BE ERROR FREE OR UNINTERRUPTED OR THAT ALL PROGRAMMING ERRORS IN THE OPENTRONS PRODUCTS CAN BE FOUND IN ORDER TO BE CORRECTED. +Limitation of Liability. To the maximum extent permitted by applicable law, in no event shall Opentrons, its affiliates, shareholders, directors, officers, employees and agents be liable for any special, incidental, indirect, exemplary, or consequential damages whatsoever (including, without limitation, damages for loss of business profits, business interruption, loss of business information, or any other pecuniary loss) arising out of your use or inability to use the Opentrons Products, whether or not the damages were foreseeable and whether or not Opentrons was advised of the possibility of such damages. In any case, without limiting the foregoing, Opentrons' entire liability arising from or under any provision of this EULA or from the use of the Opentrons Products shall be limited to fifty dollars ($50.00). The foregoing limitations will apply even if the above stated remedy fails in its essential purpose. General Provisions. User Representations. User represents and warrants that User is not located in a country that is subject to a United States government embargo, or that has been designated by the United States government as a "terrorist supporting" country. User represents and warrants that User is not listed on any United States government list of prohibited or restricted parties. User represents and warrants that they will comply with all United States export laws and regulations applicable to their possession and use of the Opentrons Products. Amendment; Waiver. This EULA shall not be modified or amended except by a written document executed by the parties. No waiver by Opentrons or any failure by Opentrons to keep or perform any provision, covenant or condition of this EULA shall be deemed to be a waiver of any preceding or succeeding breach of the same or of any other provision, covenant, or condition. Any waiver to be granted by Opentrons shall not be effective unless it is set forth in a written instrument signed by Opentrons. Assignment; Successors and Assigns. User may not assign this EULA or any rights, interests, claims or obligations under this EULA without the prior written consent of Opentrons. This EULA shall be binding upon and shall inure to the benefit of the parties and their respective successors, representatives and permitted assigns. Governing Law; Venue. This EULA shall be construed and governed by the laws of the State of New York without regard to any conflicts of law provisions or rules that would operate to cause the application of the laws of any other jurisdiction. The exclusive jurisdiction and venue for all actions under this EULA will be in the state or federal courts of competent jurisdiction in New York County, NY. Survival. All provisions of this EULA reasonably expected to survive the termination or expiration of this EULA shall do so. -Severability. Whenever possible, each provision of this EULA shall be interpreted in such manner as to be effective and valid under applicable law, but if any provision of this EULA is held to be invalid, illegal or unenforceable in any respect under any applicable law or rule in any jurisdiction, such invalidity, illegality or unenforceability shall not affect any other provision of this EULA or the parties’ rights and obligations under this EULA in any other jurisdiction. Instead, this EULA shall be reformed, construed and enforced in such jurisdiction to include an amended or modified version of the provision held to be invalid, illegal, or unenforceable or, if amendment or modification is impossible, as if such invalid, illegal or unenforceable provision had never been contained herein. +Severability. Whenever possible, each provision of this EULA shall be interpreted in such manner as to be effective and valid under applicable law, but if any provision of this EULA is held to be invalid, illegal or unenforceable in any respect under any applicable law or rule in any jurisdiction, such invalidity, illegality or unenforceability shall not affect any other provision of this EULA or the parties' rights and obligations under this EULA in any other jurisdiction. Instead, this EULA shall be reformed, construed and enforced in such jurisdiction to include an amended or modified version of the provision held to be invalid, illegal, or unenforceable or, if amendment or modification is impossible, as if such invalid, illegal or unenforceable provision had never been contained herein. Captions. The captions or section headings used in this EULA are for convenience only and shall not affect the construction, interpretation or meaning of any term or provision of this EULA. Amendments. Opentrons reserves the right to amend this EULA at any time by providing notice to the User. Continued use of the Opentrons Products following such notice constitutes acceptance of the amended EULA. From 5c79dde787fb9d8ffe5044ba262b9774382ea2d6 Mon Sep 17 00:00:00 2001 From: Jethary Rader <66035149+jerader@users.noreply.github.com> Date: Wed, 31 Jul 2024 09:07:33 -0400 Subject: [PATCH 09/49] refactor(protocol-designer): clean up form-types file and organize fields (#15840) partially addresses AUTH-615 --- protocol-designer/src/form-types.ts | 243 +++++++++++------- .../formLevel/getDefaultsForStepType.ts | 142 +++++----- 2 files changed, 215 insertions(+), 170 deletions(-) diff --git a/protocol-designer/src/form-types.ts b/protocol-designer/src/form-types.ts index eee523918b9..cb1ce12fe1b 100644 --- a/protocol-designer/src/form-types.ts +++ b/protocol-designer/src/form-types.ts @@ -1,7 +1,7 @@ import type { PAUSE_UNTIL_RESUME, - PAUSE_UNTIL_TIME, PAUSE_UNTIL_TEMP, + PAUSE_UNTIL_TIME, } from './constants' import type { IconName } from '@opentrons/components' import type { @@ -16,6 +16,8 @@ import type { } from '@opentrons/step-generation' export type StepIdType = string export type StepFieldName = string + +/* PIPETTING AND GRIPPER FIELDS */ // | 'aspirate_airGap_checkbox' // | 'aspirate_airGap_volume' // | 'aspirate_changeTip' @@ -24,78 +26,121 @@ export type StepFieldName = string // | 'aspirate_mix_checkbox' // | 'aspirate_mix_times' // | 'aspirate_mix_volume' +// | 'aspirate_mmFromBottom' // | 'aspirate_touchTip_checkbox' // | 'aspirate_touchTip_mmFromBottom' -// | 'aspirate_mmFromBottom' // | 'aspirate_wellOrder_first' // | 'aspirate_wellOrder_second' -// | 'aspirate_wells' // | 'aspirate_wells_grouped' +// | 'aspirate_wells' +// | 'aspirate_x_position +// | 'aspirate_y_position // | 'blowout_checkbox' +// | 'blowout_flowRate' // | 'blowout_location' +// | 'blowout_z_offset' // | 'changeTip' // | 'dispense_flowRate' // | 'dispense_labware' -// | 'dispense_touchTip_checkbox' // | 'dispense_mix_checkbox' // | 'dispense_mix_times' // | 'dispense_mix_volume' -// | 'dispense_touchTip_mmFromBottom' // | 'dispense_mmFromBottom' +// | 'dispense_touchTip_checkbox' +// | 'dispense_touchTip_mmFromBottom' // | 'dispense_wellOrder_first' // | 'dispense_wellOrder_second' // | 'dispense_wells' +// | 'dispense_x_position +// | 'dispense_y_position // | 'disposalVolume_checkbox', // | 'disposalVolume_volume', +// | 'dropTip_location' // | 'labware' // | 'labwareLocationUpdate' +// | 'message' // | 'mix_mmFromBottom' // | 'mix_touchTip_mmFromBottom' +// | 'mix_x_position +// | 'mix_y_position // | 'newLocation' +// | 'nozzles' // | 'path' // | 'pauseAction' // | 'pauseHour' // | 'pauseMessage' // | 'pauseMinute' // | 'pauseSecond' -// | 'preWetTip' // | 'pipette' +// | 'preWetTip' // | 'stepDetails' // | 'stepName' // | 'times' +// | 'tipRack' // | 'touchTip' // | 'useGripper' // | 'volume' // | 'wells' -// // deck setup form fields + +/* MODULE FIELDS */ +// | 'blockIsActive' +// | 'blockIsActiveHold' +// | 'blockTargetTempHold' +// | 'engageHeight' +// | 'heaterShakerSetTimer' +// | 'heaterShakerTimerMinutes' +// | 'heaterShakerTimerSeconds' +// | 'latchOpen' +// | 'lidIsActive' +// | 'lidIsActiveHold' +// | 'lidOpen' +// | 'lidOpenHold' +// | 'lidTargetTemp' +// | 'lidTargetTempHold' +// | 'magnetAction' +// | 'moduleId' +// | 'orderedProfileItems' +// | 'profileItemsById' +// | 'profileTargetLidTemp' +// | 'profileVolume' +// | 'setHeaterShakerTemperature' +// | 'setShake' +// | 'setTemperature' +// | 'targetHeaterShakerTemperature' +// | 'targetSpeed' +// | 'targetTemperature' +// | 'thermocyclerFormType' + +/* COMMENT FIELD */ +// | message + +/* DECK SETUP FIELDS */ // | 'labwareLocationUpdate' +// | 'moduleLocationUpdate' // | 'pipetteLocationUpdate' + // // TODO: Ian 2019-01-17 below are DEPRECATED remove in #2916 (make sure to account for this in migration #2917) +// | 'aspirate_disposalVol_checkbox' +// | 'aspirate_disposalVol_volume' // | 'aspirate_preWetTip' // | 'aspirate_touchTip' // | 'dispense_blowout_checkbox' // | 'dispense_blowout_location' // | 'dispense_touchTip' -// | 'aspirate_disposalVol_checkbox' -// | 'aspirate_disposalVol_volume' -// | 'aspirate_x_position -// | 'aspirate_y_position -// | 'dispense_x_position -// | 'dispense_y_position -// | 'mix_x_position -// | 'mix_y_position + // TODO Ian 2019-01-16 factor out to some constants.js ? See #2926 export type StepType = | 'comment' + | 'heaterShaker' + | 'magnet' + | 'manualIntervention' + | 'mix' | 'moveLabware' | 'moveLiquid' - | 'mix' | 'pause' - | 'manualIntervention' - | 'magnet' | 'temperature' | 'thermocycler' - | 'heaterShaker' + export const stepIconsByType: Record = { comment: 'comment', moveLabware: 'move-xy', @@ -103,7 +148,6 @@ export const stepIconsByType: Record = { mix: 'ot-mix', pause: 'pause', manualIntervention: 'pause', - // TODO Ian 2018-12-13 pause icon for this is a placeholder magnet: 'ot-magnet-v2', temperature: 'ot-temperature-v2', thermocycler: 'ot-thermocycler', @@ -124,14 +168,14 @@ export interface ChangeTipFields { export type MixForm = AnnotationFields & BlowoutFields & ChangeTipFields & { - stepType: 'mix' id: StepIdType + stepType: 'mix' labware?: string pipette?: string times?: string + touchTip?: boolean volume?: string wells?: string[] - touchTip?: boolean } export type PauseForm = AnnotationFields & { stepType: 'pause' @@ -176,153 +220,151 @@ export type BlankForm = AnnotationFields & { stepType: StepType id: StepIdType } -// TODO: Ian 2019-01-15 these types are a placeholder. Should be used in form hydration. -// TODO: this is the type we are aiming for + export interface HydratedMoveLiquidFormData { id: string stepType: 'moveLiquid' stepName: string - description: string | null | undefined fields: { - tipRack: string - pipette: PipetteEntity - volume: number - path: PathOption - changeTip: ChangeTipOptions - aspirate_wells_grouped: boolean | null | undefined - preWetTip: boolean | null | undefined + aspirate_airGap_checkbox: boolean + aspirate_delay_checkbox: boolean aspirate_labware: LabwareEntity - aspirate_wells: string[] + aspirate_mix_checkbox: boolean + aspirate_touchTip_checkbox: boolean aspirate_wellOrder_first: WellOrderOption aspirate_wellOrder_second: WellOrderOption - aspirate_flowRate: number | null | undefined - aspirate_mmFromBottom: number | null | undefined - aspirate_touchTip_checkbox: boolean - aspirate_touchTip_mmFromBottom: number | null | undefined - aspirate_mix_checkbox: boolean - aspirate_mix_volume: number | null | undefined - aspirate_mix_times: number | null | undefined - aspirate_airGap_checkbox: boolean - aspirate_airGap_volume: number | null | undefined - aspirate_delay_checkbox: boolean - aspirate_delay_seconds: number | null | undefined - aspirate_delay_mmFromBottom: number | null | undefined - // TODO(IL, 2020-09-30): when FF is removed, change to `dispense_airGap_checkbox: boolean` (no longer Maybe-typed) + aspirate_wells: string[] + blowout_checkbox: boolean + changeTip: ChangeTipOptions dispense_airGap_checkbox: boolean - dispense_airGap_volume: number | null | undefined dispense_delay_checkbox: boolean - dispense_delay_seconds: number | null | undefined - dispense_delay_mmFromBottom: number | null | undefined dispense_labware: LabwareEntity | AdditionalEquipmentEntity - dispense_wells: string[] + dispense_mix_checkbox: boolean + dispense_touchTip_checkbox: boolean dispense_wellOrder_first: WellOrderOption dispense_wellOrder_second: WellOrderOption - dispense_flowRate: number | null | undefined - dispense_mmFromBottom: number | null | undefined - dispense_touchTip_checkbox: boolean - dispense_touchTip_mmFromBottom: number | null | undefined - dispense_mix_checkbox: boolean - dispense_mix_volume: number | null | undefined - dispense_mix_times: number | null | undefined + dispense_wells: string[] disposalVolume_checkbox: boolean - disposalVolume_volume: number | null | undefined - blowout_checkbox: boolean - blowout_location: string | null | undefined // labwareId or 'SOURCE_WELL' or 'DEST_WELL' dropTip_location: string nozzles: NozzleConfigurationStyle | null + path: PathOption + pipette: PipetteEntity + tipRack: string + volume: number + aspirate_airGap_volume?: number | null + aspirate_delay_mmFromBottom?: number | null + aspirate_delay_seconds?: number | null + aspirate_flowRate?: number | null + aspirate_mix_times?: number | null + aspirate_mix_volume?: number | null + aspirate_mmFromBottom?: number | null + aspirate_touchTip_mmFromBottom?: number | null + aspirate_wells_grouped?: boolean | null aspirate_x_position?: number | null aspirate_y_position?: number | null + blowout_flowRate?: number | null + blowout_location?: string | null + blowout_z_offset?: number | null + dispense_airGap_volume?: number | null + dispense_delay_mmFromBottom?: number | null + dispense_delay_seconds?: number | null + dispense_flowRate?: number | null + dispense_mix_times?: number | null + dispense_mix_volume?: number | null + dispense_mmFromBottom?: number | null + dispense_touchTip_mmFromBottom?: number | null dispense_x_position?: number | null dispense_y_position?: number | null - blowout_z_offset?: number | null - blowout_flowRate?: number | null + disposalVolume_volume?: number | null + preWetTip?: boolean | null } + description?: string | null } export interface HydratedMoveLabwareFormData { id: string stepType: 'moveLabware' stepName: string - description: string | null | undefined fields: { labware: LabwareEntity newLocation: LabwareLocation useGripper: boolean } + description?: string | null } export interface HydratedCommentFormData { id: string stepType: 'comment' stepName: string - stepDetails?: string | null fields: { message: string } + stepDetails?: string | null } export interface HydratedMixFormDataLegacy { - id: string - stepType: 'mix' - stepName: string - tipRack: string - stepDetails: string | null | undefined - pipette: PipetteEntity - volume: number + aspirate_delay_checkbox: boolean + blowout_checkbox: boolean changeTip: ChangeTipOptions + dispense_delay_checkbox: boolean + dropTip_location: string + id: string labware: LabwareEntity - wells: string[] + mix_touchTip_checkbox: boolean mix_wellOrder_first: WellOrderOption mix_wellOrder_second: WellOrderOption - aspirate_flowRate: number | null | undefined - mix_mmFromBottom: number | null | undefined - mix_touchTip_checkbox: boolean - mix_touchTip_mmFromBottom: number | null | undefined - times: number | null | undefined - dispense_flowRate: number | null | undefined - blowout_checkbox: boolean - blowout_location: string | null | undefined // labwareId or 'SOURCE_WELL' or 'DEST_WELL' - aspirate_delay_checkbox: boolean - aspirate_delay_seconds: number | null | undefined - dispense_delay_checkbox: boolean - dispense_delay_seconds: number | null | undefined - dropTip_location: string nozzles: NozzleConfigurationStyle | null + pipette: PipetteEntity + stepName: string + stepType: 'mix' + tipRack: string + volume: number + wells: string[] + aspirate_delay_seconds?: number | null + aspirate_flowRate?: number | null + blowout_flowRate?: number | null + blowout_location?: string | null + blowout_z_offset?: number | null + dispense_delay_seconds?: number | null + dispense_flowRate?: number | null + mix_mmFromBottom?: number | null + mix_touchTip_mmFromBottom?: number | null mix_x_position?: number | null mix_y_position?: number | null - blowout_z_offset?: number | null - blowout_flowRate?: number | null + stepDetails?: string | null + times?: number | null } export type MagnetAction = 'engage' | 'disengage' export type HydratedMagnetFormData = AnnotationFields & { + engageHeight: string | null id: string - stepType: 'magnet' - stepDetails: string | null - moduleId: string | null magnetAction: MagnetAction - engageHeight: string | null + moduleId: string | null + stepDetails: string | null + stepType: 'magnet' } export interface HydratedTemperatureFormData { id: string - stepType: 'temperature' - stepDetails: string | null moduleId: string | null setTemperature: 'true' | 'false' + stepDetails: string | null + stepType: 'temperature' targetTemperature: string | null } export interface HydratedHeaterShakerFormData { + heaterShakerSetTimer: 'true' | 'false' | null + heaterShakerTimerMinutes: string | null + heaterShakerTimerSeconds: string | null id: string - stepType: 'heaterShaker' - stepDetails: string | null + latchOpen: boolean moduleId: string - heaterShakerSetTimer: 'true' | 'false' | null setHeaterShakerTemperature: boolean setShake: boolean - latchOpen: boolean + stepDetails: string | null + stepType: 'heaterShaker' targetHeaterShakerTemperature: string | null targetSpeed: string | null - heaterShakerTimerMinutes: string | null - heaterShakerTimerSeconds: string | null } // TODO: Ian 2019-01-17 Moving away from this and towards nesting all form fields // inside `fields` key, but deprecating transfer/consolidate/distribute is a pre-req @@ -355,9 +397,11 @@ export type TipXOffsetFields = export type DelayCheckboxFields = | 'aspirate_delay_checkbox' | 'dispense_delay_checkbox' + export type DelaySecondFields = | 'aspirate_delay_seconds' | 'dispense_delay_seconds' + export function getIsTouchTipField(fieldName: StepFieldName): boolean { const touchTipFields = [ 'aspirate_touchTip_mmFromBottom', @@ -366,6 +410,7 @@ export function getIsTouchTipField(fieldName: StepFieldName): boolean { ] return touchTipFields.includes(fieldName) } + export function getIsDelayPositionField(fieldName: string): boolean { const delayPositionFields = [ 'aspirate_delay_mmFromBottom', diff --git a/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts b/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts index 915757e48cd..e175cea4fc7 100644 --- a/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts +++ b/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts @@ -13,90 +13,90 @@ export function getDefaultsForStepType( switch (stepType) { case 'mix': return { - times: null, - changeTip: DEFAULT_CHANGE_TIP_OPTION, - labware: null, - mix_wellOrder_first: DEFAULT_WELL_ORDER_FIRST_OPTION, - mix_wellOrder_second: DEFAULT_WELL_ORDER_SECOND_OPTION, - blowout_checkbox: false, - blowout_location: null, - // NOTE(IL, 2021-03-12): mix uses dispense for both asp + disp, unless its falsey. // For now, unlike the other mmFromBottom fields, it's initializing to a constant instead of + // NOTE(IL, 2021-03-12): mix uses dispense for both asp + disp, unless its falsey. // using null to represent default (because null becomes 1mm asp, 0.5mm dispense -- see #7470.) - mix_mmFromBottom: DEFAULT_MM_FROM_BOTTOM_DISPENSE, - pipette: null, - volume: undefined, - wells: [], - aspirate_flowRate: null, - dispense_flowRate: null, aspirate_delay_checkbox: false, aspirate_delay_seconds: `${DEFAULT_DELAY_SECONDS}`, + aspirate_flowRate: null, + blowout_checkbox: false, + blowout_flowRate: null, + blowout_location: null, + blowout_z_offset: DEFAULT_MM_BLOWOUT_OFFSET_FROM_TOP, + changeTip: DEFAULT_CHANGE_TIP_OPTION, dispense_delay_checkbox: false, dispense_delay_seconds: `${DEFAULT_DELAY_SECONDS}`, + dispense_flowRate: null, + dropTip_location: null, + labware: null, + mix_mmFromBottom: DEFAULT_MM_FROM_BOTTOM_DISPENSE, mix_touchTip_checkbox: false, mix_touchTip_mmFromBottom: null, - dropTip_location: null, - nozzles: null, - tipRack: null, + mix_wellOrder_first: DEFAULT_WELL_ORDER_FIRST_OPTION, + mix_wellOrder_second: DEFAULT_WELL_ORDER_SECOND_OPTION, mix_x_position: 0, mix_y_position: 0, - blowout_z_offset: DEFAULT_MM_BLOWOUT_OFFSET_FROM_TOP, - blowout_flowRate: null, + nozzles: null, + pipette: null, + times: null, + tipRack: null, + volume: undefined, + wells: [], } case 'moveLiquid': return { - pipette: null, - volume: null, - tipRack: null, - changeTip: DEFAULT_CHANGE_TIP_OPTION, - path: 'single', - aspirate_wells_grouped: false, + aspirate_airGap_checkbox: false, + aspirate_airGap_volume: null, + aspirate_delay_checkbox: false, + aspirate_delay_mmFromBottom: null, + aspirate_delay_seconds: `${DEFAULT_DELAY_SECONDS}`, aspirate_flowRate: null, aspirate_labware: null, - aspirate_wells: [], - aspirate_wellOrder_first: DEFAULT_WELL_ORDER_FIRST_OPTION, - aspirate_wellOrder_second: DEFAULT_WELL_ORDER_SECOND_OPTION, aspirate_mix_checkbox: false, aspirate_mix_times: null, aspirate_mix_volume: null, aspirate_mmFromBottom: null, aspirate_touchTip_checkbox: false, aspirate_touchTip_mmFromBottom: null, + aspirate_wellOrder_first: DEFAULT_WELL_ORDER_FIRST_OPTION, + aspirate_wellOrder_second: DEFAULT_WELL_ORDER_SECOND_OPTION, + aspirate_wells_grouped: false, + aspirate_wells: [], + aspirate_x_position: 0, + aspirate_y_position: 0, + blowout_checkbox: false, + blowout_flowRate: null, + blowout_location: null, + blowout_z_offset: DEFAULT_MM_BLOWOUT_OFFSET_FROM_TOP, + changeTip: DEFAULT_CHANGE_TIP_OPTION, + dispense_airGap_checkbox: false, + dispense_airGap_volume: null, + dispense_delay_checkbox: false, + dispense_delay_mmFromBottom: null, + dispense_delay_seconds: `${DEFAULT_DELAY_SECONDS}`, dispense_flowRate: null, dispense_labware: null, - dispense_wells: [], - dispense_wellOrder_first: DEFAULT_WELL_ORDER_FIRST_OPTION, - dispense_wellOrder_second: DEFAULT_WELL_ORDER_SECOND_OPTION, dispense_mix_checkbox: false, dispense_mix_times: null, dispense_mix_volume: null, dispense_mmFromBottom: null, dispense_touchTip_checkbox: false, dispense_touchTip_mmFromBottom: null, + dispense_wellOrder_first: DEFAULT_WELL_ORDER_FIRST_OPTION, + dispense_wellOrder_second: DEFAULT_WELL_ORDER_SECOND_OPTION, + dispense_wells: [], + dispense_x_position: 0, + dispense_y_position: 0, disposalVolume_checkbox: false, disposalVolume_volume: null, - blowout_checkbox: false, - blowout_location: null, - preWetTip: false, - aspirate_airGap_checkbox: false, - aspirate_airGap_volume: null, - aspirate_delay_checkbox: false, - aspirate_delay_mmFromBottom: null, - aspirate_delay_seconds: `${DEFAULT_DELAY_SECONDS}`, - dispense_airGap_checkbox: false, - dispense_airGap_volume: null, - dispense_delay_checkbox: false, - dispense_delay_seconds: `${DEFAULT_DELAY_SECONDS}`, - dispense_delay_mmFromBottom: null, dropTip_location: null, nozzles: null, - dispense_x_position: 0, - dispense_y_position: 0, - aspirate_x_position: 0, - aspirate_y_position: 0, - blowout_z_offset: DEFAULT_MM_BLOWOUT_OFFSET_FROM_TOP, - blowout_flowRate: null, + path: 'single', + pipette: null, + preWetTip: false, + tipRack: null, + volume: null, } case 'comment': @@ -106,33 +106,33 @@ export function getDefaultsForStepType( case 'moveLabware': return { labware: null, - useGripper: false, newLocation: null, + useGripper: false, } case 'pause': return { + moduleId: null, pauseAction: null, pauseHour: null, + pauseMessage: '', pauseMinute: null, pauseSecond: null, - pauseMessage: '', - moduleId: null, pauseTemperature: null, } case 'manualIntervention': return { labwareLocationUpdate: {}, - pipetteLocationUpdate: {}, moduleLocationUpdate: {}, + pipetteLocationUpdate: {}, } case 'magnet': return { - moduleId: null, - magnetAction: null, engageHeight: null, + magnetAction: null, + moduleId: null, } case 'temperature': @@ -143,34 +143,34 @@ export function getDefaultsForStepType( } case 'heaterShaker': return { + heaterShakerSetTimer: null, + heaterShakerTimerMinutes: null, + heaterShakerTimerSeconds: null, + latchOpen: false, moduleId: null, setHeaterShakerTemperature: null, + setShake: null, targetHeaterShakerTemperature: null, targetSpeed: null, - setShake: null, - latchOpen: false, - heaterShakerSetTimer: null, - heaterShakerTimerMinutes: null, - heaterShakerTimerSeconds: null, } case 'thermocycler': return { - thermocyclerFormType: null, - moduleId: null, blockIsActive: false, + blockIsActiveHold: false, blockTargetTemp: null, + blockTargetTempHold: null, lidIsActive: false, - lidTargetTemp: null, + lidIsActiveHold: false, lidOpen: false, - profileVolume: null, - profileTargetLidTemp: null, + lidOpenHold: null, + lidTargetTemp: null, + lidTargetTempHold: null, + moduleId: null, orderedProfileItems: [], profileItemsById: {}, - blockIsActiveHold: false, - blockTargetTempHold: null, - lidIsActiveHold: false, - lidTargetTempHold: null, - lidOpenHold: null, + profileTargetLidTemp: null, + profileVolume: null, + thermocyclerFormType: null, } default: From 2d38eb9b96df8fe3ce2fa46d7374a42078a28288 Mon Sep 17 00:00:00 2001 From: Shlok Amin Date: Wed, 31 Jul 2024 10:02:27 -0400 Subject: [PATCH 10/49] feat(app-shell): add EULA to desktop app help menu (#15683) closes AUTH-536 --- app-shell/src/menu.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app-shell/src/menu.ts b/app-shell/src/menu.ts index 52f04978934..7b3abc186a1 100644 --- a/app-shell/src/menu.ts +++ b/app-shell/src/menu.ts @@ -8,6 +8,8 @@ import { LOG_DIR } from './log' const PRODUCT_NAME: string = _PKG_PRODUCT_NAME_ const BUGS_URL: string = _PKG_BUGS_URL_ +const EULA_URL = 'https://opentrons.com/eula' as const + // file or application menu const firstMenu: MenuItemConstructorOptions = { role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu', @@ -42,6 +44,16 @@ const helpMenu: MenuItemConstructorOptions = { shell.openExternal(BUGS_URL) }, }, + { + label: 'View Privacy Policy', + click: () => { + shell.openExternal(EULA_URL).catch((e: Error) => { + console.error( + `could not open end user license agreement: ${e.message}` + ) + }) + }, + }, ], } From 7316d723e77e9c1ef9f4c8cd365415643c67325b Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Wed, 31 Jul 2024 10:17:30 -0400 Subject: [PATCH 11/49] fix(app): Allow starting protocols on robots with completed runs (#15839) When you click the button to start protocol setup, we try to filter to only robots that are currently capable of starting a protocol. We implemented that as - You can connect to the robot - The robot doesn't have a current run But, robots don't actually get rid of their current run until a user either clicks the little X button in the run screen on the desktop or goes back to the main screen of the ODD. That doesn't necessarily align with a user's view of "this protocol is done", and so people would think that the robot just disappeared from that little slideout. To fix this, we should also consider robots that have a completed protocol to be done. This is a small conceptual change and unfortunately a huge pain to implement because data about whether a run is done has to come from getting the state data for that run. ## Testing - [x] The setup protocol slideout should show robots that have completed, failed, or cancelled active runs - [x] If you start setup, it should work - [x] The setup protocol slideout should show robots that do not have an active run - [x] The setup protocol slideout should not show robots that have an active run that is not completed, failed, or canceled Closes EXEC-519 --- .../__tests__/CalibrationTaskList.test.tsx | 2 +- .../organisms/CalibrationTaskList/index.tsx | 2 +- .../AvailableRobotOption.tsx | 38 +++++++++++++++---- .../ChooseRobotToRunProtocolSlideout.test.tsx | 31 ++++++++++++--- .../Devices/InstrumentsAndModules.tsx | 2 +- .../Devices/ProtocolRun/ProtocolRunHeader.tsx | 7 +--- .../__tests__/ProtocolRunHeader.test.tsx | 7 +--- .../organisms/Devices/RecentProtocolRuns.tsx | 3 +- .../organisms/Devices/RobotOverflowMenu.tsx | 2 +- .../Devices/RobotOverviewOverflowMenu.tsx | 3 +- .../organisms/Devices/RobotStatusHeader.tsx | 3 +- .../__tests__/InstrumentsAndModules.test.tsx | 4 +- .../__tests__/RobotOverflowMenu.test.tsx | 4 +- .../Devices/__tests__/RobotOverview.test.tsx | 4 +- .../RobotOverviewOverflowMenu.test.tsx | 4 +- .../__tests__/RobotStatusHeader.test.tsx | 4 +- ...nStartedOrLegacySessionInProgress.test.tsx | 4 +- .../hooks/__tests__/useRunStatuses.test.tsx | 4 +- .../useRunStartedOrLegacySessionInProgress.ts | 2 +- .../organisms/Devices/hooks/useRunStatuses.ts | 2 +- .../ModuleCard/ModuleOverflowMenu.tsx | 2 +- .../__tests__/ModuleOverflowMenu.test.tsx | 4 +- .../ModuleCard/__tests__/hooks.test.tsx | 4 +- app/src/organisms/ModuleCard/hooks.tsx | 2 +- .../organisms/ProtocolUpload/hooks/index.ts | 1 - .../hooks/useCloseCurrentRun.ts | 2 +- .../ProtocolUpload/hooks/useCurrentRun.ts | 3 +- .../hooks/useCurrentRunCommands.ts | 2 +- .../RunTimeControl/__tests__/hooks.test.tsx | 8 +--- app/src/organisms/RunTimeControl/hooks.ts | 8 +--- .../__tests__/ProtocolRunDetails.test.tsx | 4 +- .../Devices/ProtocolRunDetails/index.tsx | 2 +- .../runs}/__tests__/useCurrentRunId.test.tsx | 4 +- app/src/resources/runs/index.ts | 1 + .../runs}/useCurrentRunId.ts | 14 +++++-- app/src/resources/runs/useNotifyRunQuery.ts | 20 ++++++---- react-api-client/src/runs/useRunQuery.ts | 7 +++- 37 files changed, 128 insertions(+), 92 deletions(-) rename app/src/{organisms/ProtocolUpload/hooks => resources/runs}/__tests__/useCurrentRunId.test.tsx (92%) rename app/src/{organisms/ProtocolUpload/hooks => resources/runs}/useCurrentRunId.ts (60%) diff --git a/app/src/organisms/CalibrationTaskList/__tests__/CalibrationTaskList.test.tsx b/app/src/organisms/CalibrationTaskList/__tests__/CalibrationTaskList.test.tsx index 2c3e27b43df..8c35b7e5c04 100644 --- a/app/src/organisms/CalibrationTaskList/__tests__/CalibrationTaskList.test.tsx +++ b/app/src/organisms/CalibrationTaskList/__tests__/CalibrationTaskList.test.tsx @@ -26,7 +26,7 @@ import { import { mockLeftProtoPipette } from '../../../redux/pipettes/__fixtures__' vi.mock('../../Devices/hooks') -vi.mock('../../ProtocolUpload/hooks') +vi.mock('../../../resources/runs') const render = (robotName: string = 'otie') => { return renderWithProviders( diff --git a/app/src/organisms/CalibrationTaskList/index.tsx b/app/src/organisms/CalibrationTaskList/index.tsx index 301a6d1e2b8..d72a5ced341 100644 --- a/app/src/organisms/CalibrationTaskList/index.tsx +++ b/app/src/organisms/CalibrationTaskList/index.tsx @@ -25,7 +25,7 @@ import { useCalibrationTaskList, useRunHasStarted, } from '../Devices/hooks' -import { useCurrentRunId } from '../ProtocolUpload/hooks' +import { useCurrentRunId } from '../../resources/runs' import type { DashboardCalOffsetInvoker } from '../../pages/Devices/CalibrationDashboard/hooks/useDashboardCalibratePipOffset' import type { DashboardCalTipLengthInvoker } from '../../pages/Devices/CalibrationDashboard/hooks/useDashboardCalibrateTipLength' diff --git a/app/src/organisms/ChooseRobotSlideout/AvailableRobotOption.tsx b/app/src/organisms/ChooseRobotSlideout/AvailableRobotOption.tsx index abcdc5b903d..7953a0cd353 100644 --- a/app/src/organisms/ChooseRobotSlideout/AvailableRobotOption.tsx +++ b/app/src/organisms/ChooseRobotSlideout/AvailableRobotOption.tsx @@ -22,7 +22,7 @@ import { getNetworkInterfaces, fetchStatus } from '../../redux/networking' import { appShellRequestor } from '../../redux/shell/remote' import OT2_PNG from '../../assets/images/OT2-R_HERO.png' import FLEX_PNG from '../../assets/images/FLEX.png' -import { useNotifyAllRunsQuery } from '../../resources/runs' +import { useCurrentRunId, useNotifyRunQuery } from '../../resources/runs' import type { IconName } from '@opentrons/components' import type { Runs } from '@opentrons/api-client' @@ -59,14 +59,15 @@ export function AvailableRobotOption( getRobotModelByName(state, robotName) ) - const { data: runsData } = useNotifyAllRunsQuery( - { pageLength: 0 }, + const [isBusy, setIsBusy] = React.useState(true) + + const currentRunId = useCurrentRunId( { onSuccess: data => { - if ((data as Runs)?.links?.current != null) - registerRobotBusyStatus({ type: 'robotIsBusy', robotName }) - else { + const definitelyIdle = (data as Runs)?.links?.current == null + if (definitelyIdle) { registerRobotBusyStatus({ type: 'robotIsIdle', robotName }) + setIsBusy(false) } }, }, @@ -75,7 +76,28 @@ export function AvailableRobotOption( requestor: ip === OPENTRONS_USB ? appShellRequestor : undefined, } ) - const robotHasCurrentRun = runsData?.links?.current != null + + useNotifyRunQuery( + currentRunId, + { + onSuccess: data => { + const busy = data?.data != null && data.data.completedAt == null + registerRobotBusyStatus({ + type: busy ? 'robotIsBusy' : 'robotIsIdle', + robotName, + }) + setIsBusy(busy) + }, + onError: () => { + registerRobotBusyStatus({ type: 'robotIsIdle', robotName }) + setIsBusy(false) + }, + }, + { + hostname: ip, + requestor: ip === OPENTRONS_USB ? appShellRequestor : undefined, + } + ) const { ethernet, wifi } = useSelector((state: State) => getNetworkInterfaces(state, robotName) @@ -95,7 +117,7 @@ export function AvailableRobotOption( // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - return showIdleOnly && robotHasCurrentRun ? null : ( + return showIdleOnly && isBusy ? null : ( <> @@ -88,7 +87,7 @@ describe('ChooseRobotToRunProtocolSlideout', () => { isClosingCurrentRun: false, closeCurrentRun: mockCloseCurrentRun, }) - vi.mocked(useCurrentRunId).mockReturnValue(null) + provideNullCurrentRunIdFor(mockConnectableRobot.ip) vi.mocked(useCurrentRunStatus).mockReturnValue(null) when(vi.mocked(useCreateRunFromProtocol)) .calledWith( @@ -191,6 +190,7 @@ describe('ChooseRobotToRunProtocolSlideout', () => { { ...mockConnectableRobot, name: 'otherRobot', ip: 'otherIp' }, mockConnectableRobot, ]) + provideNullCurrentRunIdFor('otherIp') render({ storedProtocolData: storedProtocolDataFixture, onCloseClick: vi.fn(), @@ -372,6 +372,7 @@ describe('ChooseRobotToRunProtocolSlideout', () => { mockConnectableRobot, { ...mockConnectableRobot, name: 'otherRobot', ip: 'otherIp' }, ]) + provideNullCurrentRunIdFor('otherIp') render({ storedProtocolData: storedProtocolDataFixture, onCloseClick: vi.fn(), @@ -387,7 +388,7 @@ describe('ChooseRobotToRunProtocolSlideout', () => { fireEvent.click(proceedButton) fireEvent.click(screen.getByRole('button', { name: 'Confirm values' })) expect(vi.mocked(useCreateRunFromProtocol)).nthCalledWith( - 2, + 3, expect.any(Object), { hostname: '127.0.0.1' }, [ @@ -450,3 +451,21 @@ describe('ChooseRobotToRunProtocolSlideout', () => { ) }) }) + +const provideNullCurrentRunIdFor = (hostname: string): void => { + let once = true + when(vi.mocked(useCurrentRunId)) + .calledWith(expect.any(Object), { + hostname, + requestor: undefined, + }) + .thenDo(options => { + void (options?.onSuccess != null && once + ? options.onSuccess({ + links: { current: null }, + } as any) + : {}) + once = false + return null + }) +} diff --git a/app/src/organisms/Devices/InstrumentsAndModules.tsx b/app/src/organisms/Devices/InstrumentsAndModules.tsx index d9216144750..fd81eeb0267 100644 --- a/app/src/organisms/Devices/InstrumentsAndModules.tsx +++ b/app/src/organisms/Devices/InstrumentsAndModules.tsx @@ -22,7 +22,7 @@ import { import { Banner } from '../../atoms/Banner' import { PipetteRecalibrationWarning } from './PipetteCard/PipetteRecalibrationWarning' -import { useCurrentRunId } from '../ProtocolUpload/hooks' +import { useCurrentRunId } from '../../resources/runs' import { ModuleCard } from '../ModuleCard' import { useIsFlex, useIsRobotViewable, useRunStatuses } from './hooks' import { getShowPipetteCalibrationWarning } from './utils' diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx index bba204b5da5..a35276c7e8c 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx @@ -67,10 +67,7 @@ import { } from '../../../redux/analytics' import { getIsHeaterShakerAttached } from '../../../redux/config' import { Tooltip } from '../../../atoms/Tooltip' -import { - useCloseCurrentRun, - useCurrentRunId, -} from '../../../organisms/ProtocolUpload/hooks' +import { useCloseCurrentRun } from '../../../organisms/ProtocolUpload/hooks' import { ConfirmCancelModal } from '../../../organisms/RunDetails/ConfirmCancelModal' import { HeaterShakerIsRunningModal } from '../HeaterShakerIsRunningModal' import { @@ -103,7 +100,7 @@ import { getIsFixtureMismatch } from '../../../resources/deck_configuration/util import { useDeckConfigurationCompatibility } from '../../../resources/deck_configuration/hooks' import { useMostRecentCompletedAnalysis } from '../../LabwarePositionCheck/useMostRecentCompletedAnalysis' import { useMostRecentRunId } from '../../ProtocolUpload/hooks/useMostRecentRunId' -import { useNotifyRunQuery } from '../../../resources/runs' +import { useNotifyRunQuery, useCurrentRunId } from '../../../resources/runs' import { useErrorRecoveryFlows, ErrorRecoveryFlows, diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx index 0c9b559aa22..157538c9ff8 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx @@ -34,10 +34,7 @@ import { import { renderWithProviders } from '../../../../__testing-utils__' import { i18n } from '../../../../i18n' -import { - useCloseCurrentRun, - useCurrentRunId, -} from '../../../../organisms/ProtocolUpload/hooks' +import { useCloseCurrentRun } from '../../../../organisms/ProtocolUpload/hooks' import { ConfirmCancelModal } from '../../../../organisms/RunDetails/ConfirmCancelModal' import { useRunTimestamps, @@ -87,7 +84,7 @@ import { getIsFixtureMismatch } from '../../../../resources/deck_configuration/u import { useDeckConfigurationCompatibility } from '../../../../resources/deck_configuration/hooks' import { useMostRecentCompletedAnalysis } from '../../../LabwarePositionCheck/useMostRecentCompletedAnalysis' import { useMostRecentRunId } from '../../../ProtocolUpload/hooks/useMostRecentRunId' -import { useNotifyRunQuery } from '../../../../resources/runs' +import { useNotifyRunQuery, useCurrentRunId } from '../../../../resources/runs' import { useDropTipWizardFlows, useTipAttachmentStatus, diff --git a/app/src/organisms/Devices/RecentProtocolRuns.tsx b/app/src/organisms/Devices/RecentProtocolRuns.tsx index d52575b1cb9..af685895ffa 100644 --- a/app/src/organisms/Devices/RecentProtocolRuns.tsx +++ b/app/src/organisms/Devices/RecentProtocolRuns.tsx @@ -16,10 +16,9 @@ import { TYPOGRAPHY, } from '@opentrons/components' -import { useCurrentRunId } from '../ProtocolUpload/hooks' import { HistoricalProtocolRun } from './HistoricalProtocolRun' import { useIsRobotViewable, useRunStatuses } from './hooks' -import { useNotifyAllRunsQuery } from '../../resources/runs' +import { useNotifyAllRunsQuery, useCurrentRunId } from '../../resources/runs' import { useFeatureFlag } from '../../redux/config' interface RecentProtocolRunsProps { diff --git a/app/src/organisms/Devices/RobotOverflowMenu.tsx b/app/src/organisms/Devices/RobotOverflowMenu.tsx index dd624e2dcd5..dd9f0e3ad3e 100644 --- a/app/src/organisms/Devices/RobotOverflowMenu.tsx +++ b/app/src/organisms/Devices/RobotOverflowMenu.tsx @@ -24,7 +24,7 @@ import { Divider } from '../../atoms/structure' import { MenuItem } from '../../atoms/MenuList/MenuItem' import { getTopPortalEl } from '../../App/portal' import { ChooseProtocolSlideout } from '../ChooseProtocolSlideout' -import { useCurrentRunId } from '../ProtocolUpload/hooks' +import { useCurrentRunId } from '../../resources/runs' import { ConnectionTroubleshootingModal } from './ConnectionTroubleshootingModal' import { useMenuHandleClickOutside } from '../../atoms/MenuList/hooks' import { useIsRobotBusy } from './hooks' diff --git a/app/src/organisms/Devices/RobotOverviewOverflowMenu.tsx b/app/src/organisms/Devices/RobotOverviewOverflowMenu.tsx index 4b7057d2cad..cb4346d0f75 100644 --- a/app/src/organisms/Devices/RobotOverviewOverflowMenu.tsx +++ b/app/src/organisms/Devices/RobotOverviewOverflowMenu.tsx @@ -25,7 +25,6 @@ import { Tooltip } from '../../atoms/Tooltip' import { ChooseProtocolSlideout } from '../../organisms/ChooseProtocolSlideout' import { DisconnectModal } from '../../organisms/Devices/RobotSettings/ConnectNetwork/DisconnectModal' import { handleUpdateBuildroot } from '../../organisms/Devices/RobotSettings/UpdateBuildroot' -import { useCurrentRunId } from '../../organisms/ProtocolUpload/hooks' import { getRobotUpdateDisplayInfo } from '../../redux/robot-update' import { UNREACHABLE, CONNECTABLE, REACHABLE } from '../../redux/discovery' import { checkShellUpdate } from '../../redux/shell' @@ -34,7 +33,7 @@ import { home, ROBOT } from '../../redux/robot-controls' import { useIsRobotBusy } from './hooks' import { useCanDisconnect } from '../../resources/networking/hooks' import { useIsEstopNotDisengaged } from '../../resources/devices/hooks/useIsEstopNotDisengaged' - +import { useCurrentRunId } from '../../resources/runs' import type { DiscoveredRobot } from '../../redux/discovery/types' import type { Dispatch, State } from '../../redux/types' diff --git a/app/src/organisms/Devices/RobotStatusHeader.tsx b/app/src/organisms/Devices/RobotStatusHeader.tsx index 6516c7910c7..3abbf676fc7 100644 --- a/app/src/organisms/Devices/RobotStatusHeader.tsx +++ b/app/src/organisms/Devices/RobotStatusHeader.tsx @@ -26,7 +26,6 @@ import { import { QuaternaryButton } from '../../atoms/buttons' import { Tooltip } from '../../atoms/Tooltip' import { useIsFlex } from '../../organisms/Devices/hooks' -import { useCurrentRunId } from '../../organisms/ProtocolUpload/hooks' import { useCurrentRunStatus } from '../../organisms/RunTimeControl/hooks' import { getRobotAddressesByName, @@ -34,7 +33,7 @@ import { OPENTRONS_USB, } from '../../redux/discovery' import { getNetworkInterfaces, fetchStatus } from '../../redux/networking' -import { useNotifyRunQuery } from '../../resources/runs' +import { useNotifyRunQuery, useCurrentRunId } from '../../resources/runs' import type { IconName, StyleProps } from '@opentrons/components' import type { DiscoveredRobot } from '../../redux/discovery/types' diff --git a/app/src/organisms/Devices/__tests__/InstrumentsAndModules.test.tsx b/app/src/organisms/Devices/__tests__/InstrumentsAndModules.test.tsx index 5b3e058b8fc..ea24f586fad 100644 --- a/app/src/organisms/Devices/__tests__/InstrumentsAndModules.test.tsx +++ b/app/src/organisms/Devices/__tests__/InstrumentsAndModules.test.tsx @@ -13,7 +13,6 @@ import { import { i18n } from '../../../i18n' import { Banner } from '../../../atoms/Banner' import { mockMagneticModule } from '../../../redux/modules/__fixtures__' -import { useCurrentRunId } from '../../ProtocolUpload/hooks' import { useIsFlex, useIsRobotViewable, useRunStatuses } from '../hooks' import { ModuleCard } from '../../ModuleCard' import { InstrumentsAndModules } from '../InstrumentsAndModules' @@ -23,6 +22,7 @@ import { FlexPipetteCard } from '../PipetteCard/FlexPipetteCard' import { PipetteRecalibrationWarning } from '../PipetteCard/PipetteRecalibrationWarning' import { getShowPipetteCalibrationWarning } from '../utils' import { useIsEstopNotDisengaged } from '../../../resources/devices/hooks/useIsEstopNotDisengaged' +import { useCurrentRunId } from '../../../resources/runs' import type * as Components from '@opentrons/components' vi.mock('@opentrons/components', async importOriginal => { @@ -39,7 +39,7 @@ vi.mock('../../ModuleCard') vi.mock('../PipetteCard') vi.mock('../PipetteCard/FlexPipetteCard') vi.mock('../PipetteCard/PipetteRecalibrationWarning') -vi.mock('../../ProtocolUpload/hooks') +vi.mock('../../../resources/runs') vi.mock('../../../atoms/Banner') vi.mock('../utils') vi.mock('../../RunTimeControl/hooks') diff --git a/app/src/organisms/Devices/__tests__/RobotOverflowMenu.test.tsx b/app/src/organisms/Devices/__tests__/RobotOverflowMenu.test.tsx index 6227cbd5675..868e14cf171 100644 --- a/app/src/organisms/Devices/__tests__/RobotOverflowMenu.test.tsx +++ b/app/src/organisms/Devices/__tests__/RobotOverflowMenu.test.tsx @@ -5,7 +5,7 @@ import { describe, it, vi, beforeEach, expect } from 'vitest' import '@testing-library/jest-dom/vitest' import { renderWithProviders } from '../../../__testing-utils__' import { i18n } from '../../../i18n' -import { useCurrentRunId } from '../../ProtocolUpload/hooks' +import { useCurrentRunId } from '../../../resources/runs' import { ChooseProtocolSlideout } from '../../ChooseProtocolSlideout' import { RobotOverflowMenu } from '../RobotOverflowMenu' import { getRobotUpdateDisplayInfo } from '../../../redux/robot-update' @@ -17,7 +17,7 @@ import { } from '../../../redux/discovery/__fixtures__' vi.mock('../../../redux/robot-update/selectors') -vi.mock('../../ProtocolUpload/hooks') +vi.mock('../../../resources/runs') vi.mock('../../ChooseProtocolSlideout') vi.mock('../hooks') vi.mock('../../../resources/devices/hooks/useIsEstopNotDisengaged') diff --git a/app/src/organisms/Devices/__tests__/RobotOverview.test.tsx b/app/src/organisms/Devices/__tests__/RobotOverview.test.tsx index c55757684d5..e800f9741bf 100644 --- a/app/src/organisms/Devices/__tests__/RobotOverview.test.tsx +++ b/app/src/organisms/Devices/__tests__/RobotOverview.test.tsx @@ -9,7 +9,7 @@ import * as DiscoveryClientFixtures from '../../../../../discovery-client/src/fi import { useAuthorization } from '@opentrons/react-api-client' import { i18n } from '../../../i18n' -import { useCurrentRunId } from '../../ProtocolUpload/hooks' +import { useCurrentRunId } from '../../../resources/runs' import { mockConnectableRobot } from '../../../redux/discovery/__fixtures__' import { getRobotUpdateDisplayInfo } from '../../../redux/robot-update' import { getConfig, useFeatureFlag } from '../../../redux/config' @@ -65,7 +65,7 @@ vi.mock('../../../redux/robot-controls') vi.mock('../../../redux/robot-update/selectors') vi.mock('../../../redux/config') vi.mock('../../../redux/discovery/selectors') -vi.mock('../../ProtocolUpload/hooks') +vi.mock('../../../resources/runs') vi.mock('../hooks') vi.mock('../RobotStatusHeader') vi.mock('../../UpdateRobotBanner') diff --git a/app/src/organisms/Devices/__tests__/RobotOverviewOverflowMenu.test.tsx b/app/src/organisms/Devices/__tests__/RobotOverviewOverflowMenu.test.tsx index d39aa5d6f61..2cbcab8b99c 100644 --- a/app/src/organisms/Devices/__tests__/RobotOverviewOverflowMenu.test.tsx +++ b/app/src/organisms/Devices/__tests__/RobotOverviewOverflowMenu.test.tsx @@ -18,7 +18,7 @@ import { import { useCanDisconnect } from '../../../resources/networking/hooks' import { DisconnectModal } from '../../../organisms/Devices/RobotSettings/ConnectNetwork/DisconnectModal' import { ChooseProtocolSlideout } from '../../ChooseProtocolSlideout' -import { useCurrentRunId } from '../../ProtocolUpload/hooks' +import { useCurrentRunId } from '../../../resources/runs' import { useIsRobotBusy } from '../hooks' import { handleUpdateBuildroot } from '../RobotSettings/UpdateBuildroot' import { useIsEstopNotDisengaged } from '../../../resources/devices/hooks/useIsEstopNotDisengaged' @@ -35,7 +35,7 @@ vi.mock( '../../../organisms/Devices/RobotSettings/ConnectNetwork/DisconnectModal' ) vi.mock('../../ChooseProtocolSlideout') -vi.mock('../../ProtocolUpload/hooks') +vi.mock('../../../resources/runs') vi.mock('../RobotSettings/UpdateBuildroot') vi.mock('../../../resources/devices/hooks/useIsEstopNotDisengaged') diff --git a/app/src/organisms/Devices/__tests__/RobotStatusHeader.test.tsx b/app/src/organisms/Devices/__tests__/RobotStatusHeader.test.tsx index c93299ebf85..b7fee94c37f 100644 --- a/app/src/organisms/Devices/__tests__/RobotStatusHeader.test.tsx +++ b/app/src/organisms/Devices/__tests__/RobotStatusHeader.test.tsx @@ -9,7 +9,6 @@ import { renderWithProviders } from '../../../__testing-utils__' import { useProtocolQuery } from '@opentrons/react-api-client' import { i18n } from '../../../i18n' -import { useCurrentRunId } from '../../../organisms/ProtocolUpload/hooks' import { useCurrentRunStatus } from '../../../organisms/RunTimeControl/hooks' import { getRobotAddressesByName, @@ -19,14 +18,13 @@ import { import { getNetworkInterfaces } from '../../../redux/networking' import { useIsFlex } from '../hooks' import { RobotStatusHeader } from '../RobotStatusHeader' -import { useNotifyRunQuery } from '../../../resources/runs' +import { useNotifyRunQuery, useCurrentRunId } from '../../../resources/runs' import type { DiscoveryClientRobotAddress } from '../../../redux/discovery/types' import type { SimpleInterfaceStatus } from '../../../redux/networking/types' import type { State } from '../../../redux/types' vi.mock('@opentrons/react-api-client') -vi.mock('../../../organisms/ProtocolUpload/hooks') vi.mock('../../../organisms/RunTimeControl/hooks') vi.mock('../../../redux/discovery') vi.mock('../../../redux/networking') diff --git a/app/src/organisms/Devices/hooks/__tests__/useRunStartedOrLegacySessionInProgress.test.tsx b/app/src/organisms/Devices/hooks/__tests__/useRunStartedOrLegacySessionInProgress.test.tsx index 2354c31c6a8..e659e24930a 100644 --- a/app/src/organisms/Devices/hooks/__tests__/useRunStartedOrLegacySessionInProgress.test.tsx +++ b/app/src/organisms/Devices/hooks/__tests__/useRunStartedOrLegacySessionInProgress.test.tsx @@ -2,7 +2,7 @@ import { useAllSessionsQuery } from '@opentrons/react-api-client' import { RUN_STATUS_IDLE, RUN_STATUS_RUNNING } from '@opentrons/api-client' import { vi, it, expect, describe, beforeEach, afterEach } from 'vitest' -import { useCurrentRunId } from '../../../ProtocolUpload/hooks' +import { useCurrentRunId } from '../../../../resources/runs' import { useRunStatus } from '../../../RunTimeControl/hooks' import { useRunStartedOrLegacySessionInProgress } from '..' @@ -10,7 +10,7 @@ import type { UseQueryResult } from 'react-query' import type { Sessions } from '@opentrons/api-client' vi.mock('@opentrons/react-api-client') -vi.mock('../../../ProtocolUpload/hooks') +vi.mock('../../../../resources/runs') vi.mock('../../../RunTimeControl/hooks') describe('useRunStartedOrLegacySessionInProgress', () => { diff --git a/app/src/organisms/Devices/hooks/__tests__/useRunStatuses.test.tsx b/app/src/organisms/Devices/hooks/__tests__/useRunStatuses.test.tsx index 6c805c7ca39..9277ddafd10 100644 --- a/app/src/organisms/Devices/hooks/__tests__/useRunStatuses.test.tsx +++ b/app/src/organisms/Devices/hooks/__tests__/useRunStatuses.test.tsx @@ -8,11 +8,11 @@ import { } from '@opentrons/api-client' import { vi, it, expect, describe, beforeEach, afterEach } from 'vitest' -import { useCurrentRunId } from '../../../ProtocolUpload/hooks' +import { useCurrentRunId } from '../../../../resources/runs' import { useRunStatus } from '../../../RunTimeControl/hooks' import { useRunStatuses } from '..' -vi.mock('../../../ProtocolUpload/hooks') +vi.mock('../../../../resources/runs') vi.mock('../../../RunTimeControl/hooks') describe(' useRunStatuses ', () => { diff --git a/app/src/organisms/Devices/hooks/useRunStartedOrLegacySessionInProgress.ts b/app/src/organisms/Devices/hooks/useRunStartedOrLegacySessionInProgress.ts index f272c322bc2..e8678518847 100644 --- a/app/src/organisms/Devices/hooks/useRunStartedOrLegacySessionInProgress.ts +++ b/app/src/organisms/Devices/hooks/useRunStartedOrLegacySessionInProgress.ts @@ -1,6 +1,6 @@ import { useAllSessionsQuery } from '@opentrons/react-api-client' import { RUN_STATUS_IDLE } from '@opentrons/api-client' -import { useCurrentRunId } from '../../ProtocolUpload/hooks' +import { useCurrentRunId } from '../../../resources/runs' import { useRunStatus } from '../../RunTimeControl/hooks' export function useRunStartedOrLegacySessionInProgress(): boolean { diff --git a/app/src/organisms/Devices/hooks/useRunStatuses.ts b/app/src/organisms/Devices/hooks/useRunStatuses.ts index c93b1fc070f..bf1c550efa0 100644 --- a/app/src/organisms/Devices/hooks/useRunStatuses.ts +++ b/app/src/organisms/Devices/hooks/useRunStatuses.ts @@ -6,7 +6,7 @@ import { RUN_STATUS_PAUSED, RUN_STATUS_RUNNING, } from '@opentrons/api-client' -import { useCurrentRunId } from '../../ProtocolUpload/hooks' +import { useCurrentRunId } from '../../../resources/runs' import { useRunStatus } from '../../RunTimeControl/hooks' import type { RunStatus } from '@opentrons/api-client' diff --git a/app/src/organisms/ModuleCard/ModuleOverflowMenu.tsx b/app/src/organisms/ModuleCard/ModuleOverflowMenu.tsx index 28744a218cf..872927bf170 100644 --- a/app/src/organisms/ModuleCard/ModuleOverflowMenu.tsx +++ b/app/src/organisms/ModuleCard/ModuleOverflowMenu.tsx @@ -12,7 +12,7 @@ import { import { MenuList } from '../../atoms/MenuList' import { Tooltip } from '../../atoms/Tooltip' import { MenuItem } from '../../atoms/MenuList/MenuItem' -import { useCurrentRunId } from '../ProtocolUpload/hooks' +import { useCurrentRunId } from '../../resources/runs' import { useIsFlex, useRunStatuses, diff --git a/app/src/organisms/ModuleCard/__tests__/ModuleOverflowMenu.test.tsx b/app/src/organisms/ModuleCard/__tests__/ModuleOverflowMenu.test.tsx index 57c51f4d2f9..e78b0a2d0e7 100644 --- a/app/src/organisms/ModuleCard/__tests__/ModuleOverflowMenu.test.tsx +++ b/app/src/organisms/ModuleCard/__tests__/ModuleOverflowMenu.test.tsx @@ -16,14 +16,14 @@ import { useIsLegacySessionInProgress, useIsFlex, } from '../../Devices/hooks' -import { useCurrentRunId } from '../../ProtocolUpload/hooks' +import { useCurrentRunId } from '../../../resources/runs' import { ModuleOverflowMenu } from '../ModuleOverflowMenu' import type { TemperatureStatus } from '@opentrons/api-client' vi.mock('../../Devices/hooks') vi.mock('../../RunTimeControl/hooks') -vi.mock('../../ProtocolUpload/hooks') +vi.mock('../../../resources/runs') const render = (props: React.ComponentProps) => { return renderWithProviders(, { diff --git a/app/src/organisms/ModuleCard/__tests__/hooks.test.tsx b/app/src/organisms/ModuleCard/__tests__/hooks.test.tsx index 854b41b24a5..ffeb8eb0e84 100644 --- a/app/src/organisms/ModuleCard/__tests__/hooks.test.tsx +++ b/app/src/organisms/ModuleCard/__tests__/hooks.test.tsx @@ -19,7 +19,7 @@ import { } from '../../../redux/modules/__fixtures__' import { useIsRobotBusy, useRunStatuses } from '../../Devices/hooks' import { useMostRecentCompletedAnalysis } from '../../LabwarePositionCheck/useMostRecentCompletedAnalysis' -import { useCurrentRunId } from '../../ProtocolUpload/hooks' +import { useCurrentRunId } from '../../../resources/runs' import { useLatchControls, useModuleOverflowMenu, @@ -31,7 +31,7 @@ import type { State } from '../../../redux/types' vi.mock('@opentrons/react-api-client') vi.mock('../../LabwarePositionCheck/useMostRecentCompletedAnalysis') -vi.mock('../../ProtocolUpload/hooks') +vi.mock('../../../resources/runs') vi.mock('../../Devices/hooks') const mockCloseLatchHeaterShaker = { diff --git a/app/src/organisms/ModuleCard/hooks.tsx b/app/src/organisms/ModuleCard/hooks.tsx index 5f515b75a8d..88f5ae69a02 100644 --- a/app/src/organisms/ModuleCard/hooks.tsx +++ b/app/src/organisms/ModuleCard/hooks.tsx @@ -11,7 +11,7 @@ import { import { useMostRecentCompletedAnalysis } from '../LabwarePositionCheck/useMostRecentCompletedAnalysis' import { MenuItem } from '../../atoms/MenuList/MenuItem' import { Tooltip } from '../../atoms/Tooltip' -import { useCurrentRunId } from '../ProtocolUpload/hooks' +import { useCurrentRunId } from '../../resources/runs' import type { HeaterShakerCloseLatchCreateCommand, diff --git a/app/src/organisms/ProtocolUpload/hooks/index.ts b/app/src/organisms/ProtocolUpload/hooks/index.ts index 52b34474d94..c53b3d97ce0 100644 --- a/app/src/organisms/ProtocolUpload/hooks/index.ts +++ b/app/src/organisms/ProtocolUpload/hooks/index.ts @@ -2,7 +2,6 @@ export * from './useCloseCurrentRun' export * from './useCurrentProtocol' export * from './useCurrentRun' export * from './useCurrentRunCommands' -export * from './useCurrentRunId' export * from './useCloneRun' export * from './useRestartRun' export * from './useRunCommands' diff --git a/app/src/organisms/ProtocolUpload/hooks/useCloseCurrentRun.ts b/app/src/organisms/ProtocolUpload/hooks/useCloseCurrentRun.ts index 811ac95e223..58512ba1e9d 100644 --- a/app/src/organisms/ProtocolUpload/hooks/useCloseCurrentRun.ts +++ b/app/src/organisms/ProtocolUpload/hooks/useCloseCurrentRun.ts @@ -1,7 +1,7 @@ import * as React from 'react' import { useDismissCurrentRunMutation } from '@opentrons/react-api-client' -import { useCurrentRunId } from './useCurrentRunId' +import { useCurrentRunId } from '../../../resources/runs' import type { UseDismissCurrentRunMutationOptions } from '@opentrons/react-api-client/src/runs/useDismissCurrentRunMutation' diff --git a/app/src/organisms/ProtocolUpload/hooks/useCurrentRun.ts b/app/src/organisms/ProtocolUpload/hooks/useCurrentRun.ts index 6510f7e672e..0c19e86c097 100644 --- a/app/src/organisms/ProtocolUpload/hooks/useCurrentRun.ts +++ b/app/src/organisms/ProtocolUpload/hooks/useCurrentRun.ts @@ -1,5 +1,4 @@ -import { useCurrentRunId } from './useCurrentRunId' -import { useNotifyRunQuery } from '../../../resources/runs' +import { useNotifyRunQuery, useCurrentRunId } from '../../../resources/runs' import type { Run } from '@opentrons/api-client' diff --git a/app/src/organisms/ProtocolUpload/hooks/useCurrentRunCommands.ts b/app/src/organisms/ProtocolUpload/hooks/useCurrentRunCommands.ts index 59ca41a4b23..b6cc00709f9 100644 --- a/app/src/organisms/ProtocolUpload/hooks/useCurrentRunCommands.ts +++ b/app/src/organisms/ProtocolUpload/hooks/useCurrentRunCommands.ts @@ -1,4 +1,4 @@ -import { useCurrentRunId } from './useCurrentRunId' +import { useCurrentRunId } from '../../../resources/runs' import { useRunCommands } from './useRunCommands' import type { UseQueryOptions } from 'react-query' import type { diff --git a/app/src/organisms/RunTimeControl/__tests__/hooks.test.tsx b/app/src/organisms/RunTimeControl/__tests__/hooks.test.tsx index 0fe4351caa7..8107a236383 100644 --- a/app/src/organisms/RunTimeControl/__tests__/hooks.test.tsx +++ b/app/src/organisms/RunTimeControl/__tests__/hooks.test.tsx @@ -4,11 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import '@testing-library/jest-dom/vitest' import { useRunActionMutations } from '@opentrons/react-api-client' -import { - useCloneRun, - useCurrentRunId, - useRunCommands, -} from '../../ProtocolUpload/hooks' +import { useCloneRun, useRunCommands } from '../../ProtocolUpload/hooks' import { useRunControls, useRunStatus, @@ -16,7 +12,7 @@ import { useRunTimestamps, useRunErrors, } from '../hooks' -import { useNotifyRunQuery } from '../../../resources/runs' +import { useNotifyRunQuery, useCurrentRunId } from '../../../resources/runs' import { RUN_ID_2, diff --git a/app/src/organisms/RunTimeControl/hooks.ts b/app/src/organisms/RunTimeControl/hooks.ts index 4339a3a3eee..606e5852f36 100644 --- a/app/src/organisms/RunTimeControl/hooks.ts +++ b/app/src/organisms/RunTimeControl/hooks.ts @@ -16,12 +16,8 @@ import { } from '@opentrons/api-client' import { useRunActionMutations } from '@opentrons/react-api-client' -import { - useCloneRun, - useCurrentRunId, - useRunCommands, -} from '../ProtocolUpload/hooks' -import { useNotifyRunQuery } from '../../resources/runs' +import { useCloneRun, useRunCommands } from '../ProtocolUpload/hooks' +import { useNotifyRunQuery, useCurrentRunId } from '../../resources/runs' import { useMostRecentCompletedAnalysis } from '../LabwarePositionCheck/useMostRecentCompletedAnalysis' import type { UseQueryOptions } from 'react-query' diff --git a/app/src/pages/Devices/ProtocolRunDetails/__tests__/ProtocolRunDetails.test.tsx b/app/src/pages/Devices/ProtocolRunDetails/__tests__/ProtocolRunDetails.test.tsx index a5a1df6d218..c5543f06d8c 100644 --- a/app/src/pages/Devices/ProtocolRunDetails/__tests__/ProtocolRunDetails.test.tsx +++ b/app/src/pages/Devices/ProtocolRunDetails/__tests__/ProtocolRunDetails.test.tsx @@ -18,7 +18,7 @@ import { ProtocolRunModuleControls } from '../../../../organisms/Devices/Protoco import { ProtocolRunSetup } from '../../../../organisms/Devices/ProtocolRun/ProtocolRunSetup' import { RunPreviewComponent } from '../../../../organisms/RunPreview' import { ProtocolRunRuntimeParameters } from '../../../../organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters' -import { useCurrentRunId } from '../../../../organisms/ProtocolUpload/hooks' +import { useCurrentRunId } from '../../../../resources/runs' import { mockRobotSideAnalysis } from '../../../../molecules/Command/__fixtures__' import { useFeatureFlag } from '../../../../redux/config' import { ProtocolRunDetails } from '..' @@ -33,7 +33,7 @@ vi.mock('../../../../organisms/Devices/ProtocolRun/ProtocolRunHeader') vi.mock('../../../../organisms/Devices/ProtocolRun/ProtocolRunSetup') vi.mock('../../../../organisms/RunPreview') vi.mock('../../../../organisms/Devices/ProtocolRun/ProtocolRunModuleControls') -vi.mock('../../../../organisms/ProtocolUpload/hooks') +vi.mock('../../../../resources/runs') vi.mock( '../../../../organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters' ) diff --git a/app/src/pages/Devices/ProtocolRunDetails/index.tsx b/app/src/pages/Devices/ProtocolRunDetails/index.tsx index e77841ec980..8d21ae21203 100644 --- a/app/src/pages/Devices/ProtocolRunDetails/index.tsx +++ b/app/src/pages/Devices/ProtocolRunDetails/index.tsx @@ -33,7 +33,7 @@ import { RunPreview } from '../../../organisms/RunPreview' import { ProtocolRunSetup } from '../../../organisms/Devices/ProtocolRun/ProtocolRunSetup' import { ProtocolRunModuleControls } from '../../../organisms/Devices/ProtocolRun/ProtocolRunModuleControls' import { ProtocolRunRuntimeParameters } from '../../../organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters' -import { useCurrentRunId } from '../../../organisms/ProtocolUpload/hooks' +import { useCurrentRunId } from '../../../resources/runs' import { OPENTRONS_USB } from '../../../redux/discovery' import { fetchProtocols } from '../../../redux/protocol-storage' import { appShellRequestor } from '../../../redux/shell/remote' diff --git a/app/src/organisms/ProtocolUpload/hooks/__tests__/useCurrentRunId.test.tsx b/app/src/resources/runs/__tests__/useCurrentRunId.test.tsx similarity index 92% rename from app/src/organisms/ProtocolUpload/hooks/__tests__/useCurrentRunId.test.tsx rename to app/src/resources/runs/__tests__/useCurrentRunId.test.tsx index af4c9edf012..b10695789f1 100644 --- a/app/src/organisms/ProtocolUpload/hooks/__tests__/useCurrentRunId.test.tsx +++ b/app/src/resources/runs/__tests__/useCurrentRunId.test.tsx @@ -3,9 +3,9 @@ import { renderHook } from '@testing-library/react' import { describe, it, afterEach, expect, vi } from 'vitest' import { useCurrentRunId } from '../useCurrentRunId' -import { useNotifyAllRunsQuery } from '../../../../resources/runs' +import { useNotifyAllRunsQuery } from '../useNotifyAllRunsQuery' -vi.mock('../../../../resources/runs') +vi.mock('../useNotifyAllRunsQuery') describe('useCurrentRunId hook', () => { afterEach(() => { diff --git a/app/src/resources/runs/index.ts b/app/src/resources/runs/index.ts index a69aba067aa..b9023f3f702 100644 --- a/app/src/resources/runs/index.ts +++ b/app/src/resources/runs/index.ts @@ -4,3 +4,4 @@ export * from './useNotifyAllRunsQuery' export * from './useNotifyRunQuery' export * from './useNotifyAllCommandsQuery' export * from './useNotifyAllCommandsAsPreSerializedList' +export * from './useCurrentRunId' diff --git a/app/src/organisms/ProtocolUpload/hooks/useCurrentRunId.ts b/app/src/resources/runs/useCurrentRunId.ts similarity index 60% rename from app/src/organisms/ProtocolUpload/hooks/useCurrentRunId.ts rename to app/src/resources/runs/useCurrentRunId.ts index 6ae83907681..88efba892fc 100644 --- a/app/src/organisms/ProtocolUpload/hooks/useCurrentRunId.ts +++ b/app/src/resources/runs/useCurrentRunId.ts @@ -1,13 +1,19 @@ -import { useNotifyAllRunsQuery } from '../../../resources/runs' +import { useNotifyAllRunsQuery } from './useNotifyAllRunsQuery' import type { AxiosError } from 'axios' import type { UseAllRunsQueryOptions } from '@opentrons/react-api-client/src/runs/useAllRunsQuery' -import type { QueryOptionsWithPolling } from '../../../resources/useNotifyDataReady' +import type { QueryOptionsWithPolling } from '../useNotifyDataReady' +import type { HostConfig } from '@opentrons/api-client' export function useCurrentRunId( - options: QueryOptionsWithPolling = {} + options: QueryOptionsWithPolling = {}, + hostOverride?: HostConfig | null ): string | null { - const { data: allRuns } = useNotifyAllRunsQuery({ pageLength: 0 }, options) + const { data: allRuns } = useNotifyAllRunsQuery( + { pageLength: 0 }, + options, + hostOverride + ) const currentRunLink = allRuns?.links?.current ?? null return currentRunLink != null && typeof currentRunLink !== 'string' && diff --git a/app/src/resources/runs/useNotifyRunQuery.ts b/app/src/resources/runs/useNotifyRunQuery.ts index 1b0a99e5f41..003cfeabf94 100644 --- a/app/src/resources/runs/useNotifyRunQuery.ts +++ b/app/src/resources/runs/useNotifyRunQuery.ts @@ -3,26 +3,32 @@ import { useRunQuery } from '@opentrons/react-api-client' import { useNotifyDataReady } from '../useNotifyDataReady' import type { UseQueryResult } from 'react-query' -import type { Run } from '@opentrons/api-client' +import type { Run, HostConfig } from '@opentrons/api-client' import type { QueryOptionsWithPolling } from '../useNotifyDataReady' import type { NotifyTopic } from '../../redux/shell/types' export function useNotifyRunQuery( runId: string | null, - options: QueryOptionsWithPolling = {} + options: QueryOptionsWithPolling = {}, + hostOverride?: HostConfig | null ): UseQueryResult { const isEnabled = options.enabled !== false && runId != null const { notifyOnSettled, shouldRefetch } = useNotifyDataReady({ topic: `robot-server/runs/${runId}` as NotifyTopic, options: { ...options, enabled: options.enabled != null && runId != null }, + hostOverride, }) - const httpResponse = useRunQuery(runId, { - ...options, - enabled: isEnabled && shouldRefetch, - onSettled: notifyOnSettled, - }) + const httpResponse = useRunQuery( + runId, + { + ...options, + enabled: isEnabled && shouldRefetch, + onSettled: notifyOnSettled, + }, + hostOverride + ) return httpResponse } diff --git a/react-api-client/src/runs/useRunQuery.ts b/react-api-client/src/runs/useRunQuery.ts index 9cf74cb2429..4cb231eb5df 100644 --- a/react-api-client/src/runs/useRunQuery.ts +++ b/react-api-client/src/runs/useRunQuery.ts @@ -7,9 +7,12 @@ import type { HostConfig, Run } from '@opentrons/api-client' export function useRunQuery( runId: string | null, - options: UseQueryOptions = {} + options: UseQueryOptions = {}, + hostOverride?: HostConfig | null ): UseQueryResult { - const host = useHost() + const contextHost = useHost() + const host = + hostOverride != null ? { ...contextHost, ...hostOverride } : contextHost const query = useQuery( [host, 'runs', runId, 'details'], () => From 36a7501171847a28b6de7efcfe37c7d300812f28 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Wed, 31 Jul 2024 10:20:52 -0400 Subject: [PATCH 12/49] fix(app): fix error recovery crashing the app (#15837) Closes RQA-2887 --- .../ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx | 2 +- .../organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts | 2 +- .../organisms/ErrorRecoveryFlows/hooks/useRecoveryTakeover.ts | 2 +- app/src/organisms/ErrorRecoveryFlows/index.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx index fab322f992c..54462e62f22 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx @@ -218,7 +218,7 @@ describe('ErrorRecoveryFlows', () => { const newProps = { ...props, - failedCommand: { ...mockFailedCommand, id: 'NEW_ID' }, + failedCommand: null, } rerender() expect(mockReportErrorEvent).toHaveBeenCalledWith(newProps.failedCommand) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts index 5de95fe90da..a5cdcc8ca19 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts @@ -57,7 +57,7 @@ export function useFailedLabwareUtils({ }: UseFailedLabwareUtilsProps): UseFailedLabwareUtilsResult { const recentRelevantFailedLabwareCmd = React.useMemo( () => getRelevantFailedLabwareCmdFrom({ failedCommand, runCommands }), - [failedCommand, runCommands] + [failedCommand?.key, runCommands] ) const tipSelectionUtils = useTipSelectionUtils(recentRelevantFailedLabwareCmd) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryTakeover.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryTakeover.ts index e724780708c..3008a9066df 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryTakeover.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryTakeover.ts @@ -62,7 +62,7 @@ export function useRecoveryTakeover( clearClientData() } } - }, [clearClientData, isActiveUser]) + }, [isActiveUser]) const showTakeover = !(activeId == null || thisUserId === activeId) diff --git a/app/src/organisms/ErrorRecoveryFlows/index.tsx b/app/src/organisms/ErrorRecoveryFlows/index.tsx index 13d0a7ec751..9ccea140663 100644 --- a/app/src/organisms/ErrorRecoveryFlows/index.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/index.tsx @@ -121,7 +121,7 @@ export function ErrorRecoveryFlows( const analytics = useRecoveryAnalytics() React.useEffect(() => { analytics.reportErrorEvent(failedCommand) - }, [failedCommand]) + }, [failedCommand?.key]) const { hasLaunchedRecovery, toggleERWizard, showERWizard } = useERWizard() const isOnDevice = useSelector(getIsOnDevice) From ec7298a7a7409734917dfea49b033174f3c93b59 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Wed, 31 Jul 2024 11:35:01 -0400 Subject: [PATCH 13/49] fix(shared-data): handle bad files in pipette defs (#15847) We are iterating through pipette definitions on disk but not really handlign the possibility that you might have random files or dirs in there. Now we do, and you can drop .DS_Store to your heart's content. Closes RQA-2726 --- .../pipette/load_data.py | 39 ++++++++++++------- .../tests/pipette/test_validate_schema.py | 1 + 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/shared-data/python/opentrons_shared_data/pipette/load_data.py b/shared-data/python/opentrons_shared_data/pipette/load_data.py index 7e0a13de3b7..f8c361cca0c 100644 --- a/shared-data/python/opentrons_shared_data/pipette/load_data.py +++ b/shared-data/python/opentrons_shared_data/pipette/load_data.py @@ -1,7 +1,8 @@ import json -import os +from pathlib import Path +from logging import getLogger -from typing import Dict, Any, Union, Optional, List +from typing import Dict, Any, Union, Optional, List, Iterator from typing_extensions import Literal from functools import lru_cache @@ -26,6 +27,8 @@ LoadedConfiguration = Dict[str, Union[str, Dict[str, Any]]] +LOG = getLogger(__name__) + def _get_configuration_dictionary( config_type: Literal["general", "geometry", "liquid"], @@ -96,6 +99,12 @@ def _physical( return _get_configuration_dictionary("general", channels, model, version) +def _dirs_in(path: Path) -> Iterator[Path]: + for child in path.iterdir(): + if child.is_dir(): + yield child + + @lru_cache(maxsize=None) def load_serial_lookup_table() -> Dict[str, str]: """Load a serial abbreviation lookup table mapped to model name.""" @@ -112,23 +121,27 @@ def load_serial_lookup_table() -> Dict[str, str]: "eight_channel": "multi", } _model_shorthand = {"p1000": "p1k", "p300": "p3h"} - for channel_dir in os.listdir(config_path): - for model_dir in os.listdir(config_path / channel_dir): - for version_file in os.listdir(config_path / channel_dir / model_dir): - version_list = version_file.split(".json")[0].split("_") - built_model = f"{model_dir}_{_channel_model_str[channel_dir]}_v{version_list[0]}.{version_list[1]}" - - model_shorthand = _model_shorthand.get(model_dir, model_dir) - + for channel_dir in _dirs_in(config_path): + for model_dir in _dirs_in(channel_dir): + for version_file in model_dir.iterdir(): + if version_file.suffix != ".json": + continue + try: + version_list = version_file.stem.split("_") + built_model = f"{model_dir.stem}_{_channel_model_str[channel_dir.stem]}_v{version_list[0]}.{version_list[1]}" + except IndexError: + LOG.warning(f"Pipette def with bad name {version_file} ignored") + continue + model_shorthand = _model_shorthand.get(model_dir.stem, model_dir.stem) if ( - model_dir == "p300" + model_dir.stem == "p300" and int(version_list[0]) == 1 and int(version_list[1]) == 0 ): # Well apparently, we decided to switch the shorthand of the p300 depending # on whether it's a "V1" model or not...so...here is the lovely workaround. - model_shorthand = model_dir - serial_shorthand = f"{model_shorthand.upper()}{_channel_shorthand[channel_dir]}V{version_list[0]}{version_list[1]}" + model_shorthand = model_dir.stem + serial_shorthand = f"{model_shorthand.upper()}{_channel_shorthand[channel_dir.stem]}V{version_list[0]}{version_list[1]}" _lookup_table[serial_shorthand] = built_model return _lookup_table diff --git a/shared-data/python/tests/pipette/test_validate_schema.py b/shared-data/python/tests/pipette/test_validate_schema.py index 494541c0d0b..0b703504957 100644 --- a/shared-data/python/tests/pipette/test_validate_schema.py +++ b/shared-data/python/tests/pipette/test_validate_schema.py @@ -68,6 +68,7 @@ def test_pick_up_configs_configuration_by_nozzle_map_keys() -> None: for channel_dir in os.listdir(paths_to_validate): for model_dir in os.listdir(paths_to_validate / channel_dir): for version_file in os.listdir(paths_to_validate / channel_dir / model_dir): + print(version_file) version_list = version_file.split(".json")[0].split("_") built_model: PipetteModel = PipetteModel( f"{model_dir}_{_channel_model_str[channel_dir]}_v{version_list[0]}.{version_list[1]}" From 567dd5e670d7eb489d700e487da08a6c5f522d06 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Wed, 31 Jul 2024 12:52:35 -0400 Subject: [PATCH 14/49] feat(api-client, react-api-client): Add client support for `/runs/:runId/errorRecoveryPolicy` (#15851) Works towards EXEC-560 --- api-client/src/runs/index.ts | 2 + api-client/src/runs/types.ts | 26 ++++++++ .../src/runs/updateErrorRecoveryPolicy.ts | 48 ++++++++++++++ react-api-client/src/runs/index.ts | 1 + .../src/runs/useUpdateErrorRecoveryPolicy.ts | 62 +++++++++++++++++++ 5 files changed, 139 insertions(+) create mode 100644 api-client/src/runs/updateErrorRecoveryPolicy.ts create mode 100644 react-api-client/src/runs/useUpdateErrorRecoveryPolicy.ts diff --git a/api-client/src/runs/index.ts b/api-client/src/runs/index.ts index 01653713c81..02bf0c0e036 100644 --- a/api-client/src/runs/index.ts +++ b/api-client/src/runs/index.ts @@ -12,5 +12,7 @@ export { createRunAction } from './createRunAction' export * from './createLabwareOffset' export * from './createLabwareDefinition' export * from './constants' +export * from './updateErrorRecoveryPolicy' + export * from './types' export type { CreateRunData } from './createRun' diff --git a/api-client/src/runs/types.ts b/api-client/src/runs/types.ts index 45e40f2f8b9..1986a34a0b8 100644 --- a/api-client/src/runs/types.ts +++ b/api-client/src/runs/types.ts @@ -146,3 +146,29 @@ export interface CommandData { // Although run errors are semantically different from command errors, // the server currently happens to use the exact same model for both. export type RunError = RunCommandError + +/** + * Error Policy + */ + +export type IfMatchType = 'ignoreAndContinue' | 'failRun' | 'waitForRecovery' + +export interface ErrorRecoveryPolicy { + policyRules: Array<{ + matchCriteria: { + command: { + commandType: RunTimeCommand['commandType'] + error: { + errorType: RunCommandError['errorType'] + } + } + } + ifMatch: IfMatchType + }> +} + +export interface UpdateErrorRecoveryPolicyRequest { + data: ErrorRecoveryPolicy +} + +export type UpdateErrorRecoveryPolicyResponse = Record diff --git a/api-client/src/runs/updateErrorRecoveryPolicy.ts b/api-client/src/runs/updateErrorRecoveryPolicy.ts new file mode 100644 index 00000000000..2efdd974775 --- /dev/null +++ b/api-client/src/runs/updateErrorRecoveryPolicy.ts @@ -0,0 +1,48 @@ +import { PUT, request } from '../request' + +import type { HostConfig } from '../types' +import type { ResponsePromise } from '../request' +import type { + ErrorRecoveryPolicy, + IfMatchType, + UpdateErrorRecoveryPolicyRequest, + UpdateErrorRecoveryPolicyResponse, +} from './types' +import type { RunCommandError, RunTimeCommand } from '@opentrons/shared-data' + +export type RecoveryPolicyRulesParams = Array<{ + commandType: RunTimeCommand['commandType'] + errorType: RunCommandError['errorType'] + ifMatch: IfMatchType +}> + +export function updateErrorRecoveryPolicy( + config: HostConfig, + runId: string, + policyRules: RecoveryPolicyRulesParams +): ResponsePromise { + const policy = buildErrorRecoveryPolicyBody(policyRules) + + return request< + UpdateErrorRecoveryPolicyResponse, + UpdateErrorRecoveryPolicyRequest + >(PUT, `/runs/${runId}/errorRecoveryPolicy`, { data: policy }, config) +} + +function buildErrorRecoveryPolicyBody( + policyRules: RecoveryPolicyRulesParams +): ErrorRecoveryPolicy { + return { + policyRules: policyRules.map(rule => ({ + matchCriteria: { + command: { + commandType: rule.commandType, + error: { + errorType: rule.errorType, + }, + }, + }, + ifMatch: rule.ifMatch, + })), + } +} diff --git a/react-api-client/src/runs/index.ts b/react-api-client/src/runs/index.ts index 207950738e1..72a087d1529 100644 --- a/react-api-client/src/runs/index.ts +++ b/react-api-client/src/runs/index.ts @@ -15,6 +15,7 @@ export { useAllCommandsAsPreSerializedList } from './useAllCommandsAsPreSerializ export { useCommandQuery } from './useCommandQuery' export * from './useCreateLabwareOffsetMutation' export * from './useCreateLabwareDefinitionMutation' +export * from './useUpdateErrorRecoveryPolicy' export type { UsePlayRunMutationResult } from './usePlayRunMutation' export type { UsePauseRunMutationResult } from './usePauseRunMutation' diff --git a/react-api-client/src/runs/useUpdateErrorRecoveryPolicy.ts b/react-api-client/src/runs/useUpdateErrorRecoveryPolicy.ts new file mode 100644 index 00000000000..1fa379b1bc5 --- /dev/null +++ b/react-api-client/src/runs/useUpdateErrorRecoveryPolicy.ts @@ -0,0 +1,62 @@ +import { useMutation } from 'react-query' + +import { updateErrorRecoveryPolicy } from '@opentrons/api-client' + +import { useHost } from '../api' + +import type { + UseMutationOptions, + UseMutationResult, + UseMutateFunction, +} from 'react-query' +import type { AxiosError } from 'axios' +import type { + RecoveryPolicyRulesParams, + UpdateErrorRecoveryPolicyResponse, + HostConfig, +} from '@opentrons/api-client' + +export type UseUpdateErrorRecoveryPolicyResponse = UseMutationResult< + UpdateErrorRecoveryPolicyResponse, + AxiosError, + RecoveryPolicyRulesParams +> & { + updateErrorRecoveryPolicy: UseMutateFunction< + UpdateErrorRecoveryPolicyResponse, + AxiosError, + RecoveryPolicyRulesParams + > +} + +export type UseUpdateErrorRecoveryPolicyOptions = UseMutationOptions< + UpdateErrorRecoveryPolicyResponse, + AxiosError, + RecoveryPolicyRulesParams +> + +export function useUpdateErrorRecoveryPolicy( + runId: string, + options: UseUpdateErrorRecoveryPolicyOptions = {} +): UseUpdateErrorRecoveryPolicyResponse { + const host = useHost() + + const mutation = useMutation< + UpdateErrorRecoveryPolicyResponse, + AxiosError, + RecoveryPolicyRulesParams + >( + [host, 'runs', runId, 'errorRecoveryPolicy'], + (policyRules: RecoveryPolicyRulesParams) => + updateErrorRecoveryPolicy(host as HostConfig, runId, policyRules) + .then(response => response.data) + .catch(e => { + throw e + }), + options + ) + + return { + ...mutation, + updateErrorRecoveryPolicy: mutation.mutate, + } +} From 1c059f99a45b8fa66a4b8a6bf48b96b06f09c8a7 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Wed, 31 Jul 2024 14:23:22 -0400 Subject: [PATCH 15/49] fix(app): fix CommandText console warnings (#15856) Explicitly handle the null command case to prevent warnings. The intention of useCommandTextString after #15708 is to support null commands, so this change genuinely implements the desired behavior. --- app/src/molecules/Command/hooks/useCommandTextString/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/molecules/Command/hooks/useCommandTextString/index.tsx b/app/src/molecules/Command/hooks/useCommandTextString/index.tsx index 34df2f33c7f..093593dc911 100644 --- a/app/src/molecules/Command/hooks/useCommandTextString/index.tsx +++ b/app/src/molecules/Command/hooks/useCommandTextString/index.tsx @@ -216,6 +216,7 @@ export function useCommandTextString( commandText: utils.getCustomCommandText({ ...fullParams, command }), } + case undefined: case null: return { commandText: '' } From 0869f432ca5fe321c418eb1ff34290cac2053104 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Wed, 31 Jul 2024 14:24:28 -0400 Subject: [PATCH 16/49] feat(app): Ignore error this run (#15854) Closes EXEC-560 and EXEC-446 Adds support for "ignoring all errors of this type this run". --- .../__tests__/useRecoveryCommands.test.ts | 46 ++++++++++++++++--- .../hooks/useFailedLabwareUtils.ts | 2 +- .../hooks/useRecoveryCommands.ts | 37 +++++++++++++-- .../organisms/ErrorRecoveryFlows/index.tsx | 2 +- 4 files changed, 75 insertions(+), 12 deletions(-) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts index df6ccebaa87..a55f3ef43f2 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts @@ -4,6 +4,7 @@ import { renderHook, act } from '@testing-library/react' import { useResumeRunFromRecoveryMutation, useStopRunMutation, + useUpdateErrorRecoveryPolicy, } from '@opentrons/react-api-client' import { useChainRunCommands } from '../../../../resources/runs' @@ -11,6 +12,7 @@ import { useRecoveryCommands, HOME_PIPETTE_Z_AXES, buildPickUpTips, + buildIgnorePolicyRules, } from '../useRecoveryCommands' import { RECOVERY_MAP } from '../../constants' @@ -40,6 +42,7 @@ describe('useRecoveryCommands', () => { const mockChainRunCommands = vi.fn().mockResolvedValue([]) const mockReportActionSelectedResult = vi.fn() const mockReportRecoveredRunResult = vi.fn() + const mockUpdateErrorRecoveryPolicy = vi.fn() const props = { runId: mockRunId, @@ -64,6 +67,9 @@ describe('useRecoveryCommands', () => { vi.mocked(useChainRunCommands).mockReturnValue({ chainRunCommands: mockChainRunCommands, } as any) + vi.mocked(useUpdateErrorRecoveryPolicy).mockReturnValue({ + updateErrorRecoveryPolicy: mockUpdateErrorRecoveryPolicy, + } as any) }) it('should call chainRunRecoveryCommands with continuePastCommandFailure set to false', async () => { @@ -254,18 +260,46 @@ describe('useRecoveryCommands', () => { expect(mockMakeSuccessToast).toHaveBeenCalled() }) - it('should call ignoreErrorKindThisRun and resolve immediately', async () => { - const { result } = renderHook(() => useRecoveryCommands(props)) + it('should call updateErrorRecoveryPolicy with correct policy rules when failedCommand has an error', async () => { + const mockFailedCommandWithError = { + ...mockFailedCommand, + commandType: 'aspirateInPlace', + error: { + errorType: 'mockErrorType', + }, + } - const consoleSpy = vi.spyOn(console, 'log') + const testProps = { + ...props, + failedCommand: mockFailedCommandWithError, + } + + const { result } = renderHook(() => useRecoveryCommands(testProps)) await act(async () => { await result.current.ignoreErrorKindThisRun() }) - expect(consoleSpy).toHaveBeenCalledWith( - 'IGNORING ALL ERRORS OF THIS KIND THIS RUN' + const expectedPolicyRules = buildIgnorePolicyRules( + 'aspirateInPlace', + 'mockErrorType' + ) + + expect(mockUpdateErrorRecoveryPolicy).toHaveBeenCalledWith( + expectedPolicyRules + ) + }) + + it('should reject with an error when failedCommand or error is null', async () => { + const testProps = { + ...props, + failedCommand: null, + } + + const { result } = renderHook(() => useRecoveryCommands(testProps)) + + await expect(result.current.ignoreErrorKindThisRun()).rejects.toThrow( + 'Could not execute command. No failed command.' ) - expect(result.current.ignoreErrorKindThisRun()).resolves.toBeUndefined() }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts index a5cdcc8ca19..927b867752b 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts @@ -57,7 +57,7 @@ export function useFailedLabwareUtils({ }: UseFailedLabwareUtilsProps): UseFailedLabwareUtilsResult { const recentRelevantFailedLabwareCmd = React.useMemo( () => getRelevantFailedLabwareCmdFrom({ failedCommand, runCommands }), - [failedCommand?.key, runCommands] + [failedCommand?.error?.errorType, runCommands] ) const tipSelectionUtils = useTipSelectionUtils(recentRelevantFailedLabwareCmd) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts index c33bce43416..803bdf18f6a 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts @@ -4,6 +4,7 @@ import head from 'lodash/head' import { useResumeRunFromRecoveryMutation, useStopRunMutation, + useUpdateErrorRecoveryPolicy, } from '@opentrons/react-api-client' import { useChainRunCommands } from '../../../resources/runs' @@ -19,7 +20,10 @@ import type { DropTipInPlaceRunTimeCommand, PrepareToAspirateRunTimeCommand, } from '@opentrons/shared-data' -import type { CommandData } from '@opentrons/api-client' +import type { + CommandData, + RecoveryPolicyRulesParams, +} from '@opentrons/api-client' import type { WellGroup } from '@opentrons/components' import type { FailedCommand } from '../types' import type { UseFailedLabwareUtilsResult } from './useFailedLabwareUtils' @@ -71,6 +75,7 @@ export function useRecoveryCommands({ mutateAsync: resumeRunFromRecovery, } = useResumeRunFromRecoveryMutation() const { stopRun } = useStopRunMutation() + const { updateErrorRecoveryPolicy } = useUpdateErrorRecoveryPolicy(runId) const { makeSuccessToast } = recoveryToastUtils const buildRetryPrepMove = (): MoveToCoordinatesCreateCommand | null => { @@ -184,9 +189,20 @@ export function useRecoveryCommands({ }, [runId, resumeRunFromRecovery, makeSuccessToast]) const ignoreErrorKindThisRun = React.useCallback((): Promise => { - console.log('IGNORING ALL ERRORS OF THIS KIND THIS RUN') - return Promise.resolve() - }, []) + if (failedCommand?.error != null) { + const ignorePolicyRules = buildIgnorePolicyRules( + failedCommand.commandType, + failedCommand.error.errorType + ) + + updateErrorRecoveryPolicy(ignorePolicyRules) + return Promise.resolve() + } else { + return Promise.reject( + new Error('Could not execute command. No failed command.') + ) + } + }, [failedCommand?.error?.errorType, failedCommand?.commandType]) return { resumeRun, @@ -230,3 +246,16 @@ export const buildPickUpTips = ( } } } + +export const buildIgnorePolicyRules = ( + commandType: FailedCommand['commandType'], + errorType: string +): RecoveryPolicyRulesParams => { + return [ + { + commandType, + errorType, + ifMatch: 'ignoreAndContinue', + }, + ] +} diff --git a/app/src/organisms/ErrorRecoveryFlows/index.tsx b/app/src/organisms/ErrorRecoveryFlows/index.tsx index 9ccea140663..76b5d6b07bc 100644 --- a/app/src/organisms/ErrorRecoveryFlows/index.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/index.tsx @@ -121,7 +121,7 @@ export function ErrorRecoveryFlows( const analytics = useRecoveryAnalytics() React.useEffect(() => { analytics.reportErrorEvent(failedCommand) - }, [failedCommand?.key]) + }, [failedCommand?.error?.detail]) const { hasLaunchedRecovery, toggleERWizard, showERWizard } = useERWizard() const isOnDevice = useSelector(getIsOnDevice) From 39297bbc475b661f2c110f5f2ca9e10c541011d6 Mon Sep 17 00:00:00 2001 From: Nicholas Shiland Date: Wed, 31 Jul 2024 15:19:40 -0400 Subject: [PATCH 17/49] feat(abr-testing): Link relevant JIRA tickets (#15849) # Overview The create jira script will now link relevant tickets to a newly created ticket. ## Test Plan and Hands on Testing It was run multiple times on multiple accounts. ## Changelog Fixed issues_on_board to grab recent issues, created match issues that matches relevant issues, and created link tickets that links tickets. Called all three functions in the Create Jira script. ## Review requests ## Risk assessment --- .../abr_testing/automation/jira_tool.py | 60 ++++++++++++++++++- .../data_collection/abr_robot_error.py | 9 ++- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/abr-testing/abr_testing/automation/jira_tool.py b/abr-testing/abr_testing/automation/jira_tool.py index df31c5231e8..4bc8ba4686b 100644 --- a/abr-testing/abr_testing/automation/jira_tool.py +++ b/abr-testing/abr_testing/automation/jira_tool.py @@ -23,25 +23,79 @@ def __init__(self, url: str, api_token: str, email: str) -> None: "Content-Type": "application/json", } - def issues_on_board(self, board_id: str) -> List[str]: + def issues_on_board(self, project_key: str) -> List[List[Any]]: """Print Issues on board.""" + params = {"jql": f"project = {project_key}"} response = requests.get( - f"{self.url}/rest/agile/1.0/board/{board_id}/issue", + f"{self.url}/rest/api/3/search", headers=self.headers, + params=params, auth=self.auth, ) + response.raise_for_status() try: board_data = response.json() all_issues = board_data["issues"] except json.JSONDecodeError as e: print("Error decoding json: ", e) + # convert issue id's into array and have one key as + # the issue key and one be summary, return entire array issue_ids = [] for i in all_issues: issue_id = i.get("id") - issue_ids.append(issue_id) + issue_summary = i["fields"].get("summary") + issue_ids.append([issue_id, issue_summary]) return issue_ids + def match_issues(self, issue_ids: List[List[str]], ticket_summary: str) -> List: + """Matches related ticket ID's.""" + to_link = [] + error = ticket_summary.split("_")[3] + robot = ticket_summary.split("_")[0] + # for every issue see if both match, if yes then grab issue ID and add it to a list + for issue in issue_ids: + summary = issue[1] + try: + issue_error = summary.split("_")[3] + issue_robot = summary.split("_")[0] + except IndexError: + continue + issue_id = issue[0] + if robot == issue_robot and error == issue_error: + to_link.append(issue_id) + return to_link + + def link_issues(self, to_link: list, ticket_key: str) -> None: + """Links relevant issues in Jira.""" + for issue in to_link: + link_data = json.dumps( + { + "inwardIssue": {"key": ticket_key}, + "outwardIssue": {"id": issue}, + "type": {"name": "Relates"}, + } + ) + try: + response = requests.post( + f"{self.url}/rest/api/3/issueLink", + headers=self.headers, + auth=self.auth, + data=link_data, + ) + response.raise_for_status() + except requests.exceptions.HTTPError: + print( + f"HTTP error occurred. Ticket ID {issue} was not linked. \ + Check user permissions and authentication credentials" + ) + except requests.exceptions.ConnectionError: + print(f"Connection error occurred. Ticket ID {issue} was not linked.") + except json.JSONDecodeError: + print( + f"JSON decoding error occurred. Ticket ID {issue} was not linked." + ) + def open_issue(self, issue_key: str) -> str: """Open issue on web browser.""" url = f"{self.url}/browse/{issue_key}" diff --git a/abr-testing/abr_testing/data_collection/abr_robot_error.py b/abr-testing/abr_testing/data_collection/abr_robot_error.py index 98af232304d..a35a93f54ae 100644 --- a/abr-testing/abr_testing/data_collection/abr_robot_error.py +++ b/abr-testing/abr_testing/data_collection/abr_robot_error.py @@ -481,7 +481,6 @@ def get_run_error_info_from_robot( reporter_id = args.reporter_id[0] file_paths = read_robot_logs.get_logs(storage_directory, ip) ticket = jira_tool.JiraTicket(url, api_token, email) - ticket.issues_on_board(board_id) users_file_path = ticket.get_jira_users(storage_directory) assignee_id = get_user_id(users_file_path, assignee) run_log_file_path = "" @@ -519,6 +518,9 @@ def get_run_error_info_from_robot( print(robot) parent_key = project_key + "-" + robot.split("ABR")[1] + # Grab all previous issues + all_issues = ticket.issues_on_board(project_key) + # TODO: read board to see if ticket for run id already exists. # CREATE TICKET issue_key, raw_issue_url = ticket.create_ticket( @@ -533,6 +535,11 @@ def get_run_error_info_from_robot( affects_version, parent_key, ) + + # Link Tickets + to_link = ticket.match_issues(all_issues, summary) + ticket.link_issues(to_link, issue_key) + # OPEN TICKET issue_url = ticket.open_issue(issue_key) # MOVE FILES TO ERROR FOLDER. From a477f16a90b22a175a990fd26537b6d97fbcb04b Mon Sep 17 00:00:00 2001 From: Derek Maggio Date: Wed, 31 Jul 2024 13:37:18 -0700 Subject: [PATCH 18/49] fix: fix using wrong index in from_csv_row (#15802) # Overview I accidentally specified the same index multiple times when hardcoding from_csv_row. Since hardcoding in this case is a no-no fix it by using built-in dataclass methods to look up headers and converting to/from csv. # Test Plan - Added unit tests that fail before fix was implemented due to hard coding - Added unit tests that ensure ordering of converted data. Although this tests the dataclass package more than it is testing our software, it is still helpful to have this because the docs aren't super clear about if the order is maintained # Changelog - Create a CSVStorageBase dataclass that contains shared methods - Have RawActivityData and ProcessResourceUsageSnapshot inherit from CSVStorageBase # Review requests Is it weird having a blank dataclass as a base? I was having trouble when trying to use an ABC. # Risk assessment The lowest of lows --- api/src/opentrons/util/performance_helpers.py | 2 +- performance-metrics/README.md | 55 +++++++----- .../src/performance_metrics/_data_shapes.py | 87 +++++-------------- .../src/performance_metrics/_metrics_store.py | 5 +- .../src/performance_metrics/_types.py | 18 ---- .../performance_metrics/test_data_shapes.py | 65 ++++++++++++++ 6 files changed, 124 insertions(+), 108 deletions(-) create mode 100644 performance-metrics/tests/performance_metrics/test_data_shapes.py diff --git a/api/src/opentrons/util/performance_helpers.py b/api/src/opentrons/util/performance_helpers.py index 416e6766d02..e14ad20ff51 100644 --- a/api/src/opentrons/util/performance_helpers.py +++ b/api/src/opentrons/util/performance_helpers.py @@ -103,7 +103,7 @@ def _track_a_function( state_name: "RobotActivityState", func: _UnderlyingFunction[_UnderlyingFunctionParameters, _UnderlyingFunctionReturn], ) -> typing.Callable[_UnderlyingFunctionParameters, _UnderlyingFunctionReturn]: - """Track a function. + """Wrap a passed function with RobotActivityTracker.track. This function is a decorator that will track the given state for the decorated function. diff --git a/performance-metrics/README.md b/performance-metrics/README.md index 008ff0f6db2..637bcea0b8e 100644 --- a/performance-metrics/README.md +++ b/performance-metrics/README.md @@ -13,29 +13,7 @@ It is assumed that you already have the other projects in the monorepo setup cor make -C performance-metrics setup ``` -### Testing against OT-2 Dev Server - -```bash -make -C robot-server dev-ot2 -``` - -### Testing against real OT-2 - -To push development packages to OT-2 run the following commands from the root directory of this repo: - -```bash -make -C performance-metrics push-no-restart host= -make -C api push-no-restart host= -make -C robot-server push host= -``` - -### Testing against Flex Dev Server - -```bash -make -C robot-server dev-flex -``` - -### Testing against real Flex +### Pushing performance-metrics package to Flex ```bash make -C performance-metrics push-no-restart-ot3 host= @@ -69,3 +47,34 @@ To disable it run: ```bash make unset-performance-metrics-ff host= ``` + +## Available features + +### Robot activity tracking + +#### Description + +Developers are able to track when the robot is in a block of code they choose to monitor. Looking at +`api/src/opentrons/util/performance_helpers.py` you will see a class called `TrackingFunctions`. This class +defines static methods which are decorators that can be used wrap arbitrary functions. + +As of 2024-07-31, the following tracking functions are available: + +- `track_analysis` +- `track_getting_cached_protocol_analysis` + +Looking at `TrackingFunctions.track_analysis` we see that the underlying call to \_track_a_function specifies a string `"ANALYZING_PROTOCOL"`. Whenever a function that is wrapped with `TrackingFunctions.track_analysis` executes, the tracking function will label the underlying function as `"ANALYZING_PROTOCOL"`. + +To see where tracking function is used look at `robot_server/robot-server/protocols/protocol_analyzer.py`. You will see that the `ProtocolAnalyzer.analyze` function is wrapped with `TrackingFunctions.track_analysis`. Whenever `ProtocolAnalyzer.analyze` is called, the tracking function will start a timer. When the `ProtocolAnalyzer.analyze` function completes, the tracking function will stop the timer. It will then store the function start time and duration to the csv file, /data/performance_metrics_data/robot_activity_data + +#### Adding new tracking decorator + +To add a new tracking decorator, go to `performance-metrics/src/performance_metrics/_types.py`, and look at RobotActivityState literal and add a new state. +Go to `api/src/opentrons/util/performance_helpers.py` and add a static method to the `TrackingFunctions` class that uses the new state. + +You can now wrap your functions with your new tracking decorator. + +### System resource tracking + +performance-metrics also exposes a tracking application called `SystemResourceTracker`. The application is implemented as a systemd service on the robot and records system resource usage by process. See the `oe-core` repo for more details. +You can configure the system resource tracker by modifying the environment variables set for the service. The service file lives at `/lib/systemd/system/system-resource-tracker.service`. You can change the defined environment variables or remove them and define them in the robot's environment variables. See `performance-metrics/src/performance_metrics/system_resource_tracker/_config.py` to see what environment variables are available. diff --git a/performance-metrics/src/performance_metrics/_data_shapes.py b/performance-metrics/src/performance_metrics/_data_shapes.py index 0bcde3584e3..237a2e1b066 100644 --- a/performance-metrics/src/performance_metrics/_data_shapes.py +++ b/performance-metrics/src/performance_metrics/_data_shapes.py @@ -4,52 +4,48 @@ import typing from pathlib import Path -from ._types import SupportsCSVStorage, StorableData, RobotActivityState +from ._types import StorableData, RobotActivityState from ._util import get_timing_function _timing_function = get_timing_function() @dataclasses.dataclass(frozen=True) -class RawActivityData(SupportsCSVStorage): +class CSVStorageBase: + """Base class for all data classes.""" + + @classmethod + def headers(cls) -> typing.Tuple[str, ...]: + """Returns the headers for the BaseData class.""" + return tuple([field.name for field in dataclasses.fields(cls)]) + + def csv_row(self) -> typing.Tuple[StorableData, ...]: + """Returns the object as a CSV row.""" + return dataclasses.astuple(self) + + @classmethod + def from_csv_row(cls, row: typing.Sequence[StorableData]) -> "CSVStorageBase": + """Returns an object from a CSV row.""" + return cls(*row) + + +@dataclasses.dataclass(frozen=True) +class RawActivityData(CSVStorageBase): """Represents raw duration data with activity state information. Attributes: - - function_start_time (int): The start time of the function. - - duration_measurement_start_time (int): The start time for duration measurement. - - duration_measurement_end_time (int): The end time for duration measurement. - state (RobotActivityStates): The current state of the activity. + - func_start (int): The start time of the function. + - duration (int): The start time for duration measurement. """ state: RobotActivityState func_start: int duration: int - @classmethod - def headers(self) -> typing.Tuple[str, str, str]: - """Returns the headers for the raw activity data.""" - return ("state_name", "function_start_time", "duration") - - def csv_row(self) -> typing.Tuple[str, int, int]: - """Returns the raw activity data as a string.""" - return ( - self.state, - self.func_start, - self.duration, - ) - - @classmethod - def from_csv_row(cls, row: typing.Sequence[StorableData]) -> SupportsCSVStorage: - """Returns a RawActivityData object from a CSV row.""" - return cls( - state=typing.cast(RobotActivityState, row[0]), - func_start=int(row[1]), - duration=int(row[2]), - ) - @dataclasses.dataclass(frozen=True) -class ProcessResourceUsageSnapshot(SupportsCSVStorage): +class ProcessResourceUsageSnapshot(CSVStorageBase): """Represents process resource usage data. Attributes: @@ -68,41 +64,6 @@ class ProcessResourceUsageSnapshot(SupportsCSVStorage): system_cpu_time: float # seconds memory_percent: float - @classmethod - def headers(self) -> typing.Tuple[str, str, str, str, str, str]: - """Returns the headers for the process resource usage data.""" - return ( - "query_time", - "command", - "running_since", - "user_cpu_time", - "system_cpu_time", - "memory_percent", - ) - - def csv_row(self) -> typing.Tuple[int, str, float, float, float, float]: - """Returns the process resource usage data as a string.""" - return ( - self.query_time, - self.command, - self.running_since, - self.user_cpu_time, - self.system_cpu_time, - self.memory_percent, - ) - - @classmethod - def from_csv_row(cls, row: typing.Sequence[StorableData]) -> SupportsCSVStorage: - """Returns a ProcessResourceUsageData object from a CSV row.""" - return cls( - query_time=int(row[0]), - command=str(row[1]), - running_since=float(row[2]), - user_cpu_time=float(row[3]), - system_cpu_time=float(row[4]), - memory_percent=float(row[4]), - ) - @dataclasses.dataclass(frozen=True) class MetricsMetadata: diff --git a/performance-metrics/src/performance_metrics/_metrics_store.py b/performance-metrics/src/performance_metrics/_metrics_store.py index e09fb917a81..8d790c67a07 100644 --- a/performance-metrics/src/performance_metrics/_metrics_store.py +++ b/performance-metrics/src/performance_metrics/_metrics_store.py @@ -3,13 +3,12 @@ import csv import typing import logging -from ._data_shapes import MetricsMetadata -from ._types import SupportsCSVStorage +from ._data_shapes import MetricsMetadata, CSVStorageBase from ._logging_config import LOGGER_NAME logger = logging.getLogger(LOGGER_NAME) -T = typing.TypeVar("T", bound=SupportsCSVStorage) +T = typing.TypeVar("T", bound=CSVStorageBase) class MetricsStore(typing.Generic[T]): diff --git a/performance-metrics/src/performance_metrics/_types.py b/performance-metrics/src/performance_metrics/_types.py index dbc8ab002a1..353917d8feb 100644 --- a/performance-metrics/src/performance_metrics/_types.py +++ b/performance-metrics/src/performance_metrics/_types.py @@ -43,21 +43,3 @@ def store(self) -> None: StorableData = typing.Union[int, float, str] - - -class SupportsCSVStorage(typing.Protocol): - """A protocol for classes that support CSV storage.""" - - @classmethod - def headers(self) -> typing.Tuple[str, ...]: - """Returns the headers for the CSV data.""" - ... - - def csv_row(self) -> typing.Tuple[StorableData, ...]: - """Returns the object as a CSV row.""" - ... - - @classmethod - def from_csv_row(cls, row: typing.Tuple[StorableData, ...]) -> "SupportsCSVStorage": - """Returns an object from a CSV row.""" - ... diff --git a/performance-metrics/tests/performance_metrics/test_data_shapes.py b/performance-metrics/tests/performance_metrics/test_data_shapes.py new file mode 100644 index 00000000000..c417b5ba6a3 --- /dev/null +++ b/performance-metrics/tests/performance_metrics/test_data_shapes.py @@ -0,0 +1,65 @@ +"""Tests for the data shapes.""" + +from performance_metrics._data_shapes import ProcessResourceUsageSnapshot + + +def test_headers_ordering() -> None: + """Tests that the headers are in the correct order.""" + assert ProcessResourceUsageSnapshot.headers() == ( + "query_time", + "command", + "running_since", + "user_cpu_time", + "system_cpu_time", + "memory_percent", + ) + + +def test_csv_row_method_ordering() -> None: + """Tests that the CSV row method returns the correct order.""" + expected = ( + 1, + "test", + 2, + 3, + 4, + 5, + ) + + assert ( + ProcessResourceUsageSnapshot( + query_time=1, + command="test", + running_since=2, + user_cpu_time=3, + system_cpu_time=4, + memory_percent=5, + ).csv_row() + == expected + ) + + assert ( + ProcessResourceUsageSnapshot( + command="test", + query_time=1, + user_cpu_time=3, + system_cpu_time=4, + running_since=2, + memory_percent=5, + ).csv_row() + == expected + ) + + assert ( + ProcessResourceUsageSnapshot.from_csv_row( + ( + 1, + "test", + 2, + 3, + 4, + 5, + ) + ).csv_row() + == expected + ) From 3dff0068268cf4e518408df380181deb47ac6eec Mon Sep 17 00:00:00 2001 From: Nick Diehl <47604184+ncdiehl11@users.noreply.github.com> Date: Wed, 31 Jul 2024 16:57:07 -0400 Subject: [PATCH 19/49] fix(app, api-client): fix response interface for /protocols/{id}/dataFiles (#15848) There was an error in the client's expected response interface from `/protocols/{id}/dataFiles`, causing the app to error when expanding `HistoricalProtocolRunDrawer`. This PR fixes that response interface and the implicated endpoint's implementation in `HistoricalProtocolRun` and `HistoricalProtocolRunDrawer`. --- api-client/src/dataFiles/types.ts | 4 +--- app/src/organisms/Devices/HistoricalProtocolRun.tsx | 6 ++---- app/src/organisms/Devices/HistoricalProtocolRunDrawer.tsx | 2 +- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/api-client/src/dataFiles/types.ts b/api-client/src/dataFiles/types.ts index 294f10723ec..12a129ec176 100644 --- a/api-client/src/dataFiles/types.ts +++ b/api-client/src/dataFiles/types.ts @@ -18,7 +18,5 @@ export interface UploadedCsvFileResponse { } export interface UploadedCsvFilesResponse { - data: { - files: CsvFileData[] - } + data: CsvFileData[] } diff --git a/app/src/organisms/Devices/HistoricalProtocolRun.tsx b/app/src/organisms/Devices/HistoricalProtocolRun.tsx index ef3a8434cfc..5f8d8a9547e 100644 --- a/app/src/organisms/Devices/HistoricalProtocolRun.tsx +++ b/app/src/organisms/Devices/HistoricalProtocolRun.tsx @@ -44,7 +44,7 @@ export function HistoricalProtocolRun( const [drawerOpen, setDrawerOpen] = React.useState(false) const { data: protocolFileData } = useAllCsvFilesQuery(run.protocolId ?? '') const allProtocolDataFiles = - protocolFileData != null ? protocolFileData.data.files : [] + protocolFileData != null ? protocolFileData.data : [] const runStatus = run.status const runDisplayName = formatTimestamp(run.createdAt) let duration = EMPTY_TIMESTAMP @@ -89,9 +89,7 @@ export function HistoricalProtocolRun( > {protocolName} - {enableCsvFile && - allProtocolDataFiles != null && - allProtocolDataFiles.length > 0 ? ( + {enableCsvFile ? ( new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ) const { data } = useAllCsvFilesQuery(run.protocolId ?? '') - const allProtocolDataFiles = data != null ? data.data.files : [] + const allProtocolDataFiles = data != null ? data.data : [] const uniqueLabwareOffsets = allLabwareOffsets?.filter( (offset, index, array) => { return ( From 5d39889e02e6517eb1c841dc52875f674dba8a60 Mon Sep 17 00:00:00 2001 From: Sarah Breen Date: Wed, 31 Jul 2024 17:10:23 -0400 Subject: [PATCH 20/49] feat(app): design updates to protocol setup labware list view on ODD (#15858) fix PLAT-363, PLAT-364, PLAT-389 --- .../organisms/ProtocolSetupLabware/index.tsx | 121 ++++++++++-------- 1 file changed, 67 insertions(+), 54 deletions(-) diff --git a/app/src/organisms/ProtocolSetupLabware/index.tsx b/app/src/organisms/ProtocolSetupLabware/index.tsx index 01004aac6c0..b3380696be3 100644 --- a/app/src/organisms/ProtocolSetupLabware/index.tsx +++ b/app/src/organisms/ProtocolSetupLabware/index.tsx @@ -27,6 +27,7 @@ import { getDeckDefFromRobotType, getLabwareDefURI, getLabwareDisplayName, + getModuleDisplayName, HEATERSHAKER_MODULE_TYPE, } from '@opentrons/shared-data' import { parseInitialLoadedLabwareByAdapter } from '@opentrons/api-client' @@ -168,16 +169,7 @@ export function ProtocolSetupLabware({ module.moduleId === selectedLabware.location.moduleId ) if (matchedModule != null) { - location = ( - <> - - - - ) + location = } } else if ( selectedLabware != null && @@ -198,18 +190,7 @@ export function ProtocolSetupLabware({ module => module.moduleId === adapterLocation.moduleId ) if (moduleUnderAdapter != null) { - location = ( - <> - - - - ) + location = } } } @@ -486,7 +467,10 @@ function RowLabware({ commands, }: RowLabwareProps): JSX.Element | null { const { definition, initialLocation, nickName } = labware - const { t } = useTranslation('protocol_command_text') + const { t, i18n } = useTranslation([ + 'protocol_command_text', + 'protocol_setup', + ]) const matchedModule = initialLocation !== 'offDeck' && @@ -507,7 +491,9 @@ function RowLabware({ let slotName: string = '' let location: JSX.Element | string | null = null if (initialLocation === 'offDeck') { - location = t('off_deck') + location = ( + + ) } else if ('slotName' in initialLocation) { slotName = initialLocation.slotName location = @@ -519,7 +505,6 @@ function RowLabware({ location = ( <> - ) } else if ('labwareId' in initialLocation) { @@ -540,18 +525,7 @@ function RowLabware({ ) if (moduleUnderAdapter != null) { slotName = moduleUnderAdapter.slotName - location = ( - <> - - - - ) + location = } } } @@ -566,6 +540,9 @@ function RowLabware({ > {location} + {nestedLabwareInfo != null || matchedModule != null ? ( + + ) : null} - {nestedLabwareInfo != null ? ( - - ) : null} {nestedLabwareInfo != null && nestedLabwareInfo?.sharedSlotId === slotName ? ( - - + + + + {nestedLabwareInfo.nestedLabwareDisplayName} + + + {nestedLabwareInfo.nestedLabwareNickName} + + + + ) : null} + {matchedModule != null ? ( + <> + + - {nestedLabwareInfo.nestedLabwareDisplayName} - - - {nestedLabwareInfo.nestedLabwareNickName} - - + + + + {getModuleDisplayName(matchedModule.moduleDef.model)} + + {matchingHeaterShaker != null ? ( + + {t('protocol_setup:labware_latch_instructions')} + + ) : null} + +
+ ) : null}
{matchingHeaterShaker != null ? ( From 8739d72fa2a492331b582326de97aa8c85cc8094 Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Wed, 31 Jul 2024 17:12:40 -0400 Subject: [PATCH 21/49] fix(api): Actually ignore errors that are marked as ignorable (#15850) --- api/src/opentrons/protocol_engine/clients/transports.py | 8 +++++--- api/src/opentrons/protocol_runner/protocol_runner.py | 8 +++++--- .../opentrons/protocol_runner/test_protocol_runner.py | 7 +++++++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/api/src/opentrons/protocol_engine/clients/transports.py b/api/src/opentrons/protocol_engine/clients/transports.py index 434f461d524..348bbc286c2 100644 --- a/api/src/opentrons/protocol_engine/clients/transports.py +++ b/api/src/opentrons/protocol_engine/clients/transports.py @@ -125,11 +125,13 @@ async def run_in_pe_thread() -> Command: ) if command.error is not None: - error_was_recovered_from = ( + error_recovery_type = ( self._engine.state_view.commands.get_error_recovery_type(command.id) - == ErrorRecoveryType.WAIT_FOR_RECOVERY ) - if not error_was_recovered_from: + error_should_fail_run = ( + error_recovery_type == ErrorRecoveryType.FAIL_RUN + ) + if error_should_fail_run: error = command.error # TODO: this needs to have an actual code raise ProtocolCommandFailedError( diff --git a/api/src/opentrons/protocol_runner/protocol_runner.py b/api/src/opentrons/protocol_runner/protocol_runner.py index 1fd86d420f8..bfe959ca0eb 100644 --- a/api/src/opentrons/protocol_runner/protocol_runner.py +++ b/api/src/opentrons/protocol_runner/protocol_runner.py @@ -391,13 +391,15 @@ async def _add_and_execute_commands(self) -> None: ) ) if executed_command.error is not None: - error_was_recovered_from = ( + error_recovery_type = ( self._protocol_engine.state_view.commands.get_error_recovery_type( executed_command.id ) - == ErrorRecoveryType.WAIT_FOR_RECOVERY ) - if not error_was_recovered_from: + error_should_fail_run = ( + error_recovery_type == ErrorRecoveryType.FAIL_RUN + ) + if error_should_fail_run: raise ProtocolCommandFailedError( original_error=executed_command.error, message=f"{executed_command.error.errorType}: {executed_command.error.detail}", diff --git a/api/tests/opentrons/protocol_runner/test_protocol_runner.py b/api/tests/opentrons/protocol_runner/test_protocol_runner.py index 2127c594f2b..e975e90fa73 100644 --- a/api/tests/opentrons/protocol_runner/test_protocol_runner.py +++ b/api/tests/opentrons/protocol_runner/test_protocol_runner.py @@ -19,6 +19,7 @@ from opentrons.hardware_control import API as HardwareAPI from opentrons.legacy_broker import LegacyBroker from opentrons.protocol_api import ProtocolContext +from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryType from opentrons.protocol_engine.types import PostRunHardwareState from opentrons.protocols.api_support.types import APIVersion from opentrons.protocols.parse import PythonParseMode @@ -409,8 +410,14 @@ async def test_run_json_runner_stop_requested_stops_enqueuing( createdAt=datetime(year=2021, month=1, day=1), error=pe_errors.ProtocolEngineError(), ), + status=pe_commands.CommandStatus.FAILED, ) ) + decoy.when( + protocol_engine.state_view.commands.get_error_recovery_type( + "protocol-command-id" + ) + ).then_return(ErrorRecoveryType.FAIL_RUN) await json_runner_subject.load(json_protocol_source) From a25a964fece8be4d3cc5c2aa944b6f9143e5de49 Mon Sep 17 00:00:00 2001 From: Sarah Breen Date: Wed, 31 Jul 2024 17:47:08 -0400 Subject: [PATCH 22/49] feat(app): Delete quick transfer run on ODD (#15831) fix PLAT-258, PLAT-259 --- .../assets/localization/en/run_details.json | 1 + .../RunningProtocol/ConfirmCancelRunModal.tsx | 23 ++++++++++-- .../__tests__/ConfirmCancelRunModal.test.tsx | 18 ++++++++++ .../QuickTransferAdvancedSettings/AirGap.tsx | 4 +-- .../QuickTransferAdvancedSettings/index.tsx | 8 +++-- .../TipManagement/ChangeTip.tsx | 8 +++-- app/src/pages/ProtocolSetup/index.tsx | 3 ++ app/src/pages/RunSummary/index.tsx | 35 +++++++++++++++++-- app/src/pages/RunningProtocol/index.tsx | 2 ++ .../src/runs/useDismissCurrentRunMutation.ts | 7 ++-- 10 files changed, 96 insertions(+), 13 deletions(-) diff --git a/app/src/assets/localization/en/run_details.json b/app/src/assets/localization/en/run_details.json index 9209e9e5fc2..ab9a8114f5d 100644 --- a/app/src/assets/localization/en/run_details.json +++ b/app/src/assets/localization/en/run_details.json @@ -87,6 +87,7 @@ "protocol_title": "Protocol - {{protocol_name}}", "resume_run": "Resume run", "return_to_dashboard": "Return to dashboard", + "return_to_quick_transfer": "Return to quick transfer", "right": "Right", "robot_has_previous_offsets": "This robot has stored Labware Offset data from previous protocol runs. Do you want to apply that data to this protocol run? You can still adjust any offsets with Labware Position Check.", "robot_was_recalibrated": "This robot was recalibrated after this Labware Offset data was stored.", diff --git a/app/src/organisms/OnDeviceDisplay/RunningProtocol/ConfirmCancelRunModal.tsx b/app/src/organisms/OnDeviceDisplay/RunningProtocol/ConfirmCancelRunModal.tsx index 76efff81b8b..6a674b6835b 100644 --- a/app/src/organisms/OnDeviceDisplay/RunningProtocol/ConfirmCancelRunModal.tsx +++ b/app/src/organisms/OnDeviceDisplay/RunningProtocol/ConfirmCancelRunModal.tsx @@ -14,6 +14,7 @@ import { } from '@opentrons/components' import { useStopRunMutation, + useDeleteRunMutation, useDismissCurrentRunMutation, } from '@opentrons/react-api-client' @@ -31,6 +32,7 @@ interface ConfirmCancelRunModalProps { runId: string setShowConfirmCancelRunModal: (showConfirmCancelRunModal: boolean) => void isActiveRun: boolean + isQuickTransfer: boolean protocolId?: string | null } @@ -38,14 +40,27 @@ export function ConfirmCancelRunModal({ runId, setShowConfirmCancelRunModal, isActiveRun, + isQuickTransfer, protocolId, }: ConfirmCancelRunModalProps): JSX.Element { const { t } = useTranslation(['run_details', 'shared']) const { stopRun } = useStopRunMutation() + const { deleteRun } = useDeleteRunMutation({ + onError: error => { + setIsCanceling(false) + console.error('Error deleting quick transfer run', error) + }, + }) const { dismissCurrentRun, isLoading: isDismissing, - } = useDismissCurrentRunMutation() + } = useDismissCurrentRunMutation({ + onSuccess: () => { + if (isQuickTransfer && !isActiveRun) { + deleteRun(runId) + } + }, + }) const runStatus = useRunStatus(runId) const localRobot = useSelector(getLocalRobot) const robotName = localRobot?.name ?? '' @@ -74,7 +89,11 @@ export function ConfirmCancelRunModal({ trackProtocolRunEvent({ name: ANALYTICS_PROTOCOL_RUN_ACTION.CANCEL }) dismissCurrentRun(runId) if (!isActiveRun) { - if (protocolId != null) { + if (isQuickTransfer && protocolId != null) { + navigate(`/quick-transfer/${protocolId}`) + } else if (isQuickTransfer) { + navigate('/quick-transfer') + } else if (protocolId != null) { navigate(`/protocols/${protocolId}`) } else { navigate('/protocols') diff --git a/app/src/organisms/OnDeviceDisplay/RunningProtocol/__tests__/ConfirmCancelRunModal.test.tsx b/app/src/organisms/OnDeviceDisplay/RunningProtocol/__tests__/ConfirmCancelRunModal.test.tsx index 9ae25e466f4..358436283aa 100644 --- a/app/src/organisms/OnDeviceDisplay/RunningProtocol/__tests__/ConfirmCancelRunModal.test.tsx +++ b/app/src/organisms/OnDeviceDisplay/RunningProtocol/__tests__/ConfirmCancelRunModal.test.tsx @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { RUN_STATUS_IDLE, RUN_STATUS_STOPPED } from '@opentrons/api-client' import { useStopRunMutation, + useDeleteRunMutation, useDismissCurrentRunMutation, } from '@opentrons/react-api-client' @@ -31,6 +32,7 @@ vi.mock('../CancelingRunModal') vi.mock('../../../../redux/discovery') const mockNavigate = vi.fn() const mockStopRun = vi.fn() +const mockDeleteRun = vi.fn() const mockDismissCurrentRun = vi.fn() const mockTrackEvent = vi.fn() const mockTrackProtocolRunEvent = vi.fn( @@ -69,11 +71,15 @@ describe('ConfirmCancelRunModal', () => { isActiveRun: true, runId: RUN_ID, setShowConfirmCancelRunModal: mockFn, + isQuickTransfer: false, } vi.mocked(useStopRunMutation).mockReturnValue({ stopRun: mockStopRun, } as any) + vi.mocked(useDeleteRunMutation).mockReturnValue({ + deleteRun: mockDeleteRun, + } as any) vi.mocked(useDismissCurrentRunMutation).mockReturnValue({ dismissCurrentRun: mockDismissCurrentRun, isLoading: false, @@ -152,4 +158,16 @@ describe('ConfirmCancelRunModal', () => { expect(mockTrackProtocolRunEvent).toHaveBeenCalled() expect(mockNavigate).toHaveBeenCalledWith('/protocols') }) + it('when quick transfer run is stopped, the run is dismissed and you return to quick transfer', () => { + props = { + ...props, + isActiveRun: false, + isQuickTransfer: true, + } + when(useRunStatus).calledWith(RUN_ID).thenReturn(RUN_STATUS_STOPPED) + render(props) + + expect(mockDismissCurrentRun).toHaveBeenCalled() + expect(mockNavigate).toHaveBeenCalledWith('/quick-transfer') + }) }) diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/AirGap.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/AirGap.tsx index 1101cf3b9e3..16732019986 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/AirGap.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/AirGap.tsx @@ -109,13 +109,13 @@ export function AirGap(props: AirGapProps): JSX.Element { // after each aspirate action, so we need to halve the available capacity for single path // to get the amount available, assuming a min of 2 aspirates per dispense maxAvailableCapacity = - (Math.min(maxPipetteVolume, tipVolume) - state.volume) / 2 + (Math.min(maxPipetteVolume, tipVolume) - 2 * state.volume) / 2 } else { // aspirate air gap for multi dispense occurs once per asprirate and // available volume is max capacity - volume*3 assuming a min of 2 dispenses // per aspirate plus 1x the volume for disposal maxAvailableCapacity = - Math.min(maxPipetteVolume, tipVolume) - state.volume / 3 + Math.min(maxPipetteVolume, tipVolume) - state.volume * 3 } } diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/index.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/index.tsx index 5d79f2a85e5..7ec21c7c857 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/index.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/index.tsx @@ -156,7 +156,9 @@ export function QuickTransferAdvancedSettings( reps: state.mixOnAspirate?.repititions, }) : '', - enabled: state.transferType === 'transfer', + enabled: + state.transferType === 'transfer' || + state.transferType === 'distribute', onClick: () => { if (state.transferType === 'transfer') { setSelectedSetting('aspirate_mix') @@ -234,7 +236,9 @@ export function QuickTransferAdvancedSettings( reps: state.mixOnDispense?.repititions, }) : '', - enabled: state.transferType === 'transfer', + enabled: + state.transferType === 'transfer' || + state.transferType === 'consolidate', onClick: () => { if (state.transferType === 'transfer') { setSelectedSetting('dispense_mix') diff --git a/app/src/organisms/QuickTransferFlow/TipManagement/ChangeTip.tsx b/app/src/organisms/QuickTransferFlow/TipManagement/ChangeTip.tsx index 32a2ea9111c..af10ba03431 100644 --- a/app/src/organisms/QuickTransferFlow/TipManagement/ChangeTip.tsx +++ b/app/src/organisms/QuickTransferFlow/TipManagement/ChangeTip.tsx @@ -35,9 +35,13 @@ export function ChangeTip(props: ChangeTipProps): JSX.Element { ) { allowedChangeTipOptions.push('always') } - if (state.path === 'single' && state.transferType === 'distribute') { + if ( + state.path === 'single' && + state.transferType === 'distribute' && + state.destinationWells.length <= 96 + ) { allowedChangeTipOptions.push('perDest') - } else if (state.path === 'single') { + } else if (state.path === 'single' && state.sourceWells.length <= 96) { allowedChangeTipOptions.push('perSource') } diff --git a/app/src/pages/ProtocolSetup/index.tsx b/app/src/pages/ProtocolSetup/index.tsx index 8954e7d0b01..36ce4220bcb 100644 --- a/app/src/pages/ProtocolSetup/index.tsx +++ b/app/src/pages/ProtocolSetup/index.tsx @@ -813,6 +813,9 @@ function PrepareToRun({ {showConfirmCancelModal ? ( { + if (isQuickTransfer) { + deleteRun(runId) + } + } + + const { reset, isResetRunLoading } = useRunControls(runId, onCloneRunSuccess) const trackEvent = useTrackEvent() const { closeCurrentRun, isClosingCurrentRun } = useCloseCurrentRun() const robotAnalyticsData = useRobotAnalyticsData(robotName) @@ -148,9 +159,21 @@ export function RunSummary(): JSX.Element { closeCurrentRun() navigate('/') } - // TODO(jh, 07-24-24): After EXEC-504, add reportRecoveredRunResult here. + const returnToQuickTransfer = (): void => { + if (!isRunCurrent) { + deleteRun(runId) + } else { + closeCurrentRun({ + onSuccess: () => { + deleteRun(runId) + }, + }) + } + navigate('/quick-transfer') + } + // TODO(jh, 05-30-24): EXEC-487. Refactor reset() so we can redirect to the setup page, showing the shimmer skeleton instead. const runAgain = (): void => { setShowRunAgainSpinner(true) @@ -179,6 +202,8 @@ export function RunSummary(): JSX.Element { host, pipettesWithTip, }) + } else if (isQuickTransfer) { + returnToQuickTransfer() } else { returnToDash() } @@ -325,7 +350,11 @@ export function RunSummary(): JSX.Element { onClick={() => { handleReturnToDash(pipettesWithTip) }} - buttonText={t('return_to_dashboard')} + buttonText={ + isQuickTransfer + ? t('return_to_quick_transfer') + : t('return_to_dashboard') + } height="17rem" /> diff --git a/react-api-client/src/runs/useDismissCurrentRunMutation.ts b/react-api-client/src/runs/useDismissCurrentRunMutation.ts index 1212317d563..5fdcc56fd3d 100644 --- a/react-api-client/src/runs/useDismissCurrentRunMutation.ts +++ b/react-api-client/src/runs/useDismissCurrentRunMutation.ts @@ -22,7 +22,9 @@ export type UseDismissCurrentRunMutationOptions = UseMutationOptions< string > -export function useDismissCurrentRunMutation(): UseDismissCurrentRunMutationResult { +export function useDismissCurrentRunMutation( + options: UseDismissCurrentRunMutationOptions = {} +): UseDismissCurrentRunMutationResult { const host = useHost() const queryClient = useQueryClient() @@ -34,7 +36,8 @@ export function useDismissCurrentRunMutation(): UseDismissCurrentRunMutationResu console.error(`error invalidating runs query: ${e.message}`) }) return response.data - }) + }), + options ) return { From 449730020af54b2c37edcd145645bddda87e5da6 Mon Sep 17 00:00:00 2001 From: Nick Diehl <47604184+ncdiehl11@users.noreply.github.com> Date: Wed, 31 Jul 2024 19:00:01 -0400 Subject: [PATCH 23/49] feat(api-client, app): implement download CSV file (#15861) Here, I create api-client functions and types for downloading CSV file content from the robot server's `dataFiles/{fileId}/download` endpoint. I also add a react-api-client wrapper hook and implement in the `HistoricalProtocolRunDrawer` component. --- api-client/src/dataFiles/getCsvFileRaw.ts | 17 +++++ api-client/src/dataFiles/index.ts | 1 + api-client/src/dataFiles/types.ts | 2 + .../organisms/Devices/DownloadCsvFileLink.tsx | 47 +++++++++++++ .../Devices/HistoricalProtocolRunDrawer.tsx | 26 ++----- app/src/organisms/Devices/utils.ts | 5 +- .../__tests__/useCsvFileRawQuery.test.tsx | 70 +++++++++++++++++++ react-api-client/src/dataFiles/index.ts | 1 + .../src/dataFiles/useCsvFileRawQuery.ts | 28 ++++++++ 9 files changed, 174 insertions(+), 23 deletions(-) create mode 100644 api-client/src/dataFiles/getCsvFileRaw.ts create mode 100644 app/src/organisms/Devices/DownloadCsvFileLink.tsx create mode 100644 react-api-client/src/dataFiles/__tests__/useCsvFileRawQuery.test.tsx create mode 100644 react-api-client/src/dataFiles/useCsvFileRawQuery.ts diff --git a/api-client/src/dataFiles/getCsvFileRaw.ts b/api-client/src/dataFiles/getCsvFileRaw.ts new file mode 100644 index 00000000000..a8cdd67f915 --- /dev/null +++ b/api-client/src/dataFiles/getCsvFileRaw.ts @@ -0,0 +1,17 @@ +import { GET, request } from '../request' + +import type { DownloadedCsvFileResponse } from './types' +import type { ResponsePromise } from '../request' +import type { HostConfig } from '../types' + +export function getCsvFileRaw( + config: HostConfig, + fileId: string +): ResponsePromise { + return request( + GET, + `/dataFiles/${fileId}/download`, + null, + config + ) +} diff --git a/api-client/src/dataFiles/index.ts b/api-client/src/dataFiles/index.ts index 03cba1330b9..3496c8acaa0 100644 --- a/api-client/src/dataFiles/index.ts +++ b/api-client/src/dataFiles/index.ts @@ -1,3 +1,4 @@ +export { getCsvFileRaw } from './getCsvFileRaw' export { uploadCsvFile } from './uploadCsvFile' export * from './types' diff --git a/api-client/src/dataFiles/types.ts b/api-client/src/dataFiles/types.ts index 12a129ec176..41029bc4380 100644 --- a/api-client/src/dataFiles/types.ts +++ b/api-client/src/dataFiles/types.ts @@ -20,3 +20,5 @@ export interface UploadedCsvFileResponse { export interface UploadedCsvFilesResponse { data: CsvFileData[] } + +export type DownloadedCsvFileResponse = string diff --git a/app/src/organisms/Devices/DownloadCsvFileLink.tsx b/app/src/organisms/Devices/DownloadCsvFileLink.tsx new file mode 100644 index 00000000000..4975db0ce11 --- /dev/null +++ b/app/src/organisms/Devices/DownloadCsvFileLink.tsx @@ -0,0 +1,47 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' + +import { + ALIGN_CENTER, + Flex, + Icon, + LegacyStyledText, + Link, + SPACING, + TYPOGRAPHY, +} from '@opentrons/components' +import { useCsvFileRawQuery } from '@opentrons/react-api-client' +import { downloadFile } from './utils' + +interface DownloadCsvFileLinkProps { + fileId: string + fileName: string +} +export function DownloadCsvFileLink( + props: DownloadCsvFileLinkProps +): JSX.Element { + const { fileId, fileName } = props + const { t } = useTranslation('run_details') + const { data: csvFileRaw } = useCsvFileRawQuery(fileId) + + return ( + { + if (csvFileRaw != null) { + downloadFile(csvFileRaw, fileName) + } + }} + > + + {t('download')} + + + + ) +} diff --git a/app/src/organisms/Devices/HistoricalProtocolRunDrawer.tsx b/app/src/organisms/Devices/HistoricalProtocolRunDrawer.tsx index 201b29c8a54..4021a44388d 100644 --- a/app/src/organisms/Devices/HistoricalProtocolRunDrawer.tsx +++ b/app/src/organisms/Devices/HistoricalProtocolRunDrawer.tsx @@ -11,11 +11,9 @@ import { COLORS, DIRECTION_COLUMN, Flex, - Icon, InfoScreen, JUSTIFY_FLEX_START, LegacyStyledText, - Link, LocationIcon, OVERFLOW_HIDDEN, SPACING, @@ -28,6 +26,7 @@ import { getModuleDisplayName, } from '@opentrons/shared-data' import { useAllCsvFilesQuery } from '@opentrons/react-api-client' +import { DownloadCsvFileLink } from './DownloadCsvFileLink' import { useFeatureFlag } from '../../redux/config' import { Banner } from '../../atoms/Banner' import { useMostRecentCompletedAnalysis } from '../LabwarePositionCheck/useMostRecentCompletedAnalysis' @@ -97,7 +96,7 @@ export function HistoricalProtocolRunDrawer( ) : null const protocolFilesData = - allProtocolDataFiles.length === 0 ? ( + allProtocolDataFiles.length === 1 ? ( ) : ( @@ -137,7 +136,7 @@ export function HistoricalProtocolRunDrawer( {allProtocolDataFiles.map((fileData, index) => { - const { createdAt, name } = fileData + const { createdAt, name: fileName, id: fileId } = fileData return ( - {name} + {fileName} @@ -169,22 +168,7 @@ export function HistoricalProtocolRunDrawer( - {}} // TODO (nd: 06/18/2024) get file and download - > - - - {t('download')} - - - - + ) diff --git a/app/src/organisms/Devices/utils.ts b/app/src/organisms/Devices/utils.ts index c5302e62208..718bf976c63 100644 --- a/app/src/organisms/Devices/utils.ts +++ b/app/src/organisms/Devices/utils.ts @@ -33,9 +33,10 @@ export function onDeviceDisplayFormatTimestamp(timestamp: string): string { : timestamp } -export function downloadFile(data: object, fileName: string): void { +export function downloadFile(data: object | string, fileName: string): void { // Create a blob with the data we want to download as a file - const blob = new Blob([JSON.stringify(data)], { type: 'text/json' }) + const blobContent = typeof data === 'string' ? data : JSON.stringify(data) + const blob = new Blob([blobContent], { type: 'text/json' }) // Create an anchor element and dispatch a click event on it // to trigger a download const a = document.createElement('a') diff --git a/react-api-client/src/dataFiles/__tests__/useCsvFileRawQuery.test.tsx b/react-api-client/src/dataFiles/__tests__/useCsvFileRawQuery.test.tsx new file mode 100644 index 00000000000..e24ff3d6a82 --- /dev/null +++ b/react-api-client/src/dataFiles/__tests__/useCsvFileRawQuery.test.tsx @@ -0,0 +1,70 @@ +import * as React from 'react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { QueryClient, QueryClientProvider } from 'react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { getCsvFileRaw } from '@opentrons/api-client' +import { useHost } from '../../api' +import { useCsvFileRawQuery } from '..' + +import type { + HostConfig, + Response, + DownloadedCsvFileResponse, +} from '@opentrons/api-client' + +vi.mock('@opentrons/api-client') +vi.mock('../../api/useHost') + +const HOST_CONFIG: HostConfig = { hostname: 'localhost' } +const FILE_ID = 'file123' +const FILE_CONTENT_RESPONSE = 'content,of,my,csv\nfile,' as DownloadedCsvFileResponse + +describe('useCsvFileRawQuery hook', () => { + let wrapper: React.FunctionComponent<{ children: React.ReactNode }> + + beforeEach(() => { + const queryClient = new QueryClient() + const clientProvider: React.FunctionComponent<{ + children: React.ReactNode + }> = ({ children }) => ( + {children} + ) + + wrapper = clientProvider + }) + + it('should return no data if no host', () => { + vi.mocked(useHost).mockReturnValue(null) + + const { result } = renderHook(() => useCsvFileRawQuery(FILE_ID), { + wrapper, + }) + + expect(result.current.data).toBeUndefined() + }) + + it('should return no data if the get file request fails', () => { + vi.mocked(useHost).mockReturnValue(HOST_CONFIG) + vi.mocked(getCsvFileRaw).mockRejectedValue('oh no') + + const { result } = renderHook(() => useCsvFileRawQuery(FILE_ID), { + wrapper, + }) + expect(result.current.data).toBeUndefined() + }) + + it('should return file data if successful request', async () => { + vi.mocked(useHost).mockReturnValue(HOST_CONFIG) + vi.mocked(getCsvFileRaw).mockResolvedValue({ + data: FILE_CONTENT_RESPONSE, + } as Response) + + const { result } = renderHook(() => useCsvFileRawQuery(FILE_ID), { + wrapper, + }) + + await waitFor(() => { + expect(result.current.data).toEqual(FILE_CONTENT_RESPONSE) + }) + }) +}) diff --git a/react-api-client/src/dataFiles/index.ts b/react-api-client/src/dataFiles/index.ts index 3ff92db8497..cd6fe47daf0 100644 --- a/react-api-client/src/dataFiles/index.ts +++ b/react-api-client/src/dataFiles/index.ts @@ -1 +1,2 @@ +export { useCsvFileRawQuery } from './useCsvFileRawQuery' export { useUploadCsvFileMutation } from './useUploadCsvFileMutation' diff --git a/react-api-client/src/dataFiles/useCsvFileRawQuery.ts b/react-api-client/src/dataFiles/useCsvFileRawQuery.ts new file mode 100644 index 00000000000..22cae3ad920 --- /dev/null +++ b/react-api-client/src/dataFiles/useCsvFileRawQuery.ts @@ -0,0 +1,28 @@ +import { useQuery } from 'react-query' +import { getCsvFileRaw } from '@opentrons/api-client' +import { useHost } from '../api' + +import type { UseQueryOptions, UseQueryResult } from 'react-query' +import type { + HostConfig, + DownloadedCsvFileResponse, +} from '@opentrons/api-client' + +export function useCsvFileRawQuery( + fileId: string, + options?: UseQueryOptions +): UseQueryResult { + const host = useHost() + const allOptions: UseQueryOptions = { + ...options, + enabled: host !== null && fileId !== null, + } + + const query = useQuery( + [host, `/dataFiles/${fileId}/download`], + () => + getCsvFileRaw(host as HostConfig, fileId).then(response => response.data), + allOptions + ) + return query +} From c976794cefe8a3c5ef0d488cf6b2dee6d89068a1 Mon Sep 17 00:00:00 2001 From: Jethary Alcid <66035149+jerader@users.noreply.github.com> Date: Thu, 1 Aug 2024 08:51:56 -0400 Subject: [PATCH 24/49] feat(protocol-designer): return tip UI foundation (#15823) closes AUTH-580 AUTH-581 AUTH-582 --- .../__tests__/DropTipField.test.tsx | 68 +++++++++++++++ .../fields/DropTipField/index.tsx | 33 ++++++-- .../_tests__/PickUpTipField.test.tsx | 46 ++++++++++ .../fields/PickUpTipField/index.tsx | 48 +++++++++++ .../fields/TipPositionField/index.tsx | 1 - .../__tests__/TipWellSelectionField.test.tsx | 56 +++++++++++++ .../fields/TipWellSelectionField/index.tsx | 83 +++++++++++++++++++ .../WellSelectionField/WellSelectionField.tsx | 15 +--- .../components/StepEditForm/fields/index.ts | 5 ++ .../components/StepEditForm/forms/MixForm.tsx | 57 +++++++++++-- .../forms/MoveLiquidForm/index.tsx | 56 ++++++++++--- .../src/components/StepEditForm/utils.ts | 19 +++++ .../src/feature-flags/reducers.ts | 1 + .../src/feature-flags/selectors.ts | 4 + protocol-designer/src/feature-flags/types.ts | 2 + protocol-designer/src/form-types.ts | 9 ++ .../src/localization/en/feature_flags.json | 4 + .../src/localization/en/form.json | 7 +- .../test/createPresavedStepForm.test.ts | 6 ++ .../formLevel/getDefaultsForStepType.ts | 6 ++ .../getDisabledFieldsMixForm.ts | 5 ++ .../getDisabledFieldsMoveLiquidForm.ts | 12 ++- .../formLevel/stepFormToArgs/mixFormToArgs.ts | 1 + .../stepFormToArgs/moveLiquidFormToArgs.ts | 1 + .../test/getDefaultsForStepType.test.ts | 7 +- protocol-designer/src/ui/labware/selectors.ts | 29 +++++++ .../src/ui/steps/test/selectors.test.ts | 45 ++++++++++ 27 files changed, 583 insertions(+), 43 deletions(-) create mode 100644 protocol-designer/src/components/StepEditForm/fields/DropTipField/__tests__/DropTipField.test.tsx create mode 100644 protocol-designer/src/components/StepEditForm/fields/PickUpTipField/_tests__/PickUpTipField.test.tsx create mode 100644 protocol-designer/src/components/StepEditForm/fields/PickUpTipField/index.tsx create mode 100644 protocol-designer/src/components/StepEditForm/fields/TipWellSelectionField/__tests__/TipWellSelectionField.test.tsx create mode 100644 protocol-designer/src/components/StepEditForm/fields/TipWellSelectionField/index.tsx diff --git a/protocol-designer/src/components/StepEditForm/fields/DropTipField/__tests__/DropTipField.test.tsx b/protocol-designer/src/components/StepEditForm/fields/DropTipField/__tests__/DropTipField.test.tsx new file mode 100644 index 00000000000..ea9569d865e --- /dev/null +++ b/protocol-designer/src/components/StepEditForm/fields/DropTipField/__tests__/DropTipField.test.tsx @@ -0,0 +1,68 @@ +import * as React from 'react' +import { screen } from '@testing-library/react' +import { describe, it, vi, beforeEach, expect } from 'vitest' +import { fixtureTiprack1000ul } from '@opentrons/shared-data' + +import { renderWithProviders } from '../../../../../__testing-utils__' +import { i18n } from '../../../../../localization' +import { getAllTiprackOptions } from '../../../../../ui/labware/selectors' +import { getEnableReturnTip } from '../../../../../feature-flags/selectors' +import { + getAdditionalEquipmentEntities, + getLabwareEntities, +} from '../../../../../step-forms/selectors' +import { DropTipField } from '../index' +import type { LabwareDefinition2 } from '@opentrons/shared-data' + +vi.mock('../../../../../step-forms/selectors') +vi.mock('../../../../../ui/labware/selectors') +vi.mock('../../../../../feature-flags/selectors') +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} +const mockDropTip = 'dropTip_location' +const mockTrashBin = 'trashBinId' +const mockLabwareId = 'mockId' +describe('DropTipField', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + name: mockDropTip, + value: mockTrashBin, + updateValue: vi.fn(), + onFieldBlur: vi.fn(), + onFieldFocus: vi.fn(), + disabled: false, + } + + vi.mocked(getAdditionalEquipmentEntities).mockReturnValue({ + [mockTrashBin]: { name: 'trashBin', location: 'A3', id: mockTrashBin }, + }) + vi.mocked(getEnableReturnTip).mockReturnValue(true) + vi.mocked(getAllTiprackOptions).mockReturnValue([ + { name: 'mock tip', value: mockLabwareId }, + ]) + vi.mocked(getLabwareEntities).mockReturnValue({ + [mockLabwareId]: { + id: mockLabwareId, + labwareDefURI: 'mock uri', + def: fixtureTiprack1000ul as LabwareDefinition2, + }, + }) + }) + it('renders the label and dropdown field with trash bin selected as default', () => { + render(props) + screen.getByText('drop tip') + screen.getByRole('combobox', { name: '' }) + screen.getByRole('option', { name: 'Trash Bin' }) + screen.getByRole('option', { name: 'mock tip' }) + }) + it('renders dropdown as disabled', () => { + props.disabled = true + render(props) + expect(screen.getByRole('combobox', { name: '' })).toBeDisabled() + }) +}) diff --git a/protocol-designer/src/components/StepEditForm/fields/DropTipField/index.tsx b/protocol-designer/src/components/StepEditForm/fields/DropTipField/index.tsx index f4468cd28bc..a17e1804576 100644 --- a/protocol-designer/src/components/StepEditForm/fields/DropTipField/index.tsx +++ b/protocol-designer/src/components/StepEditForm/fields/DropTipField/index.tsx @@ -2,13 +2,19 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' import { DropdownField, FormGroup } from '@opentrons/components' -import { getAdditionalEquipmentEntities } from '../../../../step-forms/selectors' -import styles from '../../StepEditForm.module.css' +import { + getAdditionalEquipmentEntities, + getLabwareEntities, +} from '../../../../step-forms/selectors' +import { getAllTiprackOptions } from '../../../../ui/labware/selectors' +import { getEnableReturnTip } from '../../../../feature-flags/selectors' import type { DropdownOption } from '@opentrons/components' import type { StepFormDropdown } from '../StepFormDropdownField' +import styles from '../../StepEditForm.module.css' + export function DropTipField( - props: Omit, 'options'> + props: Omit, 'options'> & {} ): JSX.Element { const { value: dropdownItem, @@ -16,9 +22,14 @@ export function DropTipField( onFieldBlur, onFieldFocus, updateValue, + disabled, } = props const { t } = useTranslation('form') const additionalEquipment = useSelector(getAdditionalEquipmentEntities) + const labwareEntities = useSelector(getLabwareEntities) + const tiprackOptions = useSelector(getAllTiprackOptions) + const enableReturnTip = useSelector(getEnableReturnTip) + const wasteChute = Object.values(additionalEquipment).find( aE => aE.name === 'wasteChute' ) @@ -39,17 +50,27 @@ export function DropTipField( if (trashBin != null) options.push(trashOption) React.useEffect(() => { - if (additionalEquipment[String(dropdownItem)] == null) { + if ( + additionalEquipment[String(dropdownItem)] == null && + labwareEntities[String(dropdownItem)] == null + ) { updateValue(null) } }, [dropdownItem]) + return ( ) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} +const mockPickUpTip = 'pickUpTip_location' +const mockLabwareId = 'mockId' +describe('PickUpTipField', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + name: mockPickUpTip, + value: '', + updateValue: vi.fn(), + onFieldBlur: vi.fn(), + onFieldFocus: vi.fn(), + disabled: false, + } + vi.mocked(getAllTiprackOptions).mockReturnValue([ + { name: 'mock tip', value: mockLabwareId }, + ]) + }) + it('renders the label and dropdown field with default pick up tip selected as default', () => { + render(props) + screen.getByText('pick up tip') + screen.getByRole('combobox', { name: '' }) + screen.getByRole('option', { name: 'Default - get next tip' }) + screen.getByRole('option', { name: 'mock tip' }) + }) + it('renders dropdown as disabled', () => { + props.disabled = true + render(props) + expect(screen.getByRole('combobox', { name: '' })).toBeDisabled() + }) +}) diff --git a/protocol-designer/src/components/StepEditForm/fields/PickUpTipField/index.tsx b/protocol-designer/src/components/StepEditForm/fields/PickUpTipField/index.tsx new file mode 100644 index 00000000000..d30eeb2fe0e --- /dev/null +++ b/protocol-designer/src/components/StepEditForm/fields/PickUpTipField/index.tsx @@ -0,0 +1,48 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' +import { DropdownField, FormGroup } from '@opentrons/components' +import { getAllTiprackOptions } from '../../../../ui/labware/selectors' +import type { DropdownOption } from '@opentrons/components' +import type { StepFormDropdown } from '../StepFormDropdownField' + +import styles from '../../StepEditForm.module.css' + +export function PickUpTipField( + props: Omit, 'options'> & {} +): JSX.Element { + const { + value: dropdownItem, + name, + onFieldBlur, + onFieldFocus, + updateValue, + disabled, + } = props + const { t } = useTranslation('form') + const tiprackOptions = useSelector(getAllTiprackOptions) + const defaultOption: DropdownOption = { + name: 'Default - get next tip', + value: '', + } + + return ( + + ) => { + updateValue(e.currentTarget.value) + }} + /> + + ) +} diff --git a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/index.tsx b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/index.tsx index 2adc9cf1951..8d8a7a866f0 100644 --- a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/index.tsx +++ b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/index.tsx @@ -103,7 +103,6 @@ export function TipPositionField(props: TipPositionFieldProps): JSX.Element { zValue = mmFromBottom ?? getDefaultMmFromBottom({ name: zName, wellDepthMm }) } - let modal = ( ) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +const mockPipId = 'mockId' + +describe('TipWellSelectionField', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + name: 'well', + value: [], + updateValue: vi.fn(), + onFieldBlur: vi.fn(), + onFieldFocus: vi.fn(), + disabled: false, + pipetteId: mockPipId, + labwareId: 'mockLabwareId', + nozzles: null, + } + vi.mocked(getPipetteEntities).mockReturnValue({ + [mockPipId]: { + name: 'p50_single_flex', + spec: {} as any, + id: mockPipId, + tiprackLabwareDef: [], + tiprackDefURI: ['mockDefURI1', 'mockDefURI2'], + }, + }) + vi.mocked(WellSelectionModal).mockReturnValue( +
mock WellSelectionModal
+ ) + }) + it('renders the readOnly input field and clicking on it renders the modal', () => { + render(props) + screen.getByText('wells') + fireEvent.click(screen.getByRole('textbox', { name: '' })) + screen.getByText('mock WellSelectionModal') + }) +}) diff --git a/protocol-designer/src/components/StepEditForm/fields/TipWellSelectionField/index.tsx b/protocol-designer/src/components/StepEditForm/fields/TipWellSelectionField/index.tsx new file mode 100644 index 00000000000..67507b8834f --- /dev/null +++ b/protocol-designer/src/components/StepEditForm/fields/TipWellSelectionField/index.tsx @@ -0,0 +1,83 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' +import { createPortal } from 'react-dom' +import { FormGroup, InputField } from '@opentrons/components' +import { getPipetteEntities } from '../../../../step-forms/selectors' +import { getNozzleType } from '../../utils' +import { getMainPagePortalEl } from '../../../portals/MainPageModalPortal' +import { WellSelectionModal } from '../WellSelectionField/WellSelectionModal' +import type { StepFormDropdown } from '../StepFormDropdownField' + +import styles from '../../StepEditForm.module.css' + +type TipWellSelectionFieldProps = Omit< + React.ComponentProps, + 'options' +> & { + pipetteId: unknown + labwareId: unknown + nozzles: string | null +} + +export function TipWellSelectionField( + props: TipWellSelectionFieldProps +): JSX.Element { + const { + value: selectedWells, + errorToShow, + name, + updateValue, + disabled, + pipetteId, + labwareId, + nozzles, + } = props + const { t } = useTranslation('form') + const pipetteEntities = useSelector(getPipetteEntities) + const primaryWellCount = + Array.isArray(selectedWells) && selectedWells.length > 0 + ? selectedWells.length.toString() + : null + const [openModal, setOpenModal] = React.useState(false) + const pipette = pipetteId != null ? pipetteEntities[String(pipetteId)] : null + const nozzleType = getNozzleType(pipette, nozzles) + + return ( + <> + {createPortal( + { + setOpenModal(false) + }} + pipetteId={String(pipetteId)} + updateValue={updateValue} + value={selectedWells} + nozzleType={nozzleType} + />, + + getMainPagePortalEl() + )} + + + { + setOpenModal(true) + }} + /> + + + ) +} diff --git a/protocol-designer/src/components/StepEditForm/fields/WellSelectionField/WellSelectionField.tsx b/protocol-designer/src/components/StepEditForm/fields/WellSelectionField/WellSelectionField.tsx index b2d670d0260..a97ef894d07 100644 --- a/protocol-designer/src/components/StepEditForm/fields/WellSelectionField/WellSelectionField.tsx +++ b/protocol-designer/src/components/StepEditForm/fields/WellSelectionField/WellSelectionField.tsx @@ -3,7 +3,7 @@ import { createPortal } from 'react-dom' import { useDispatch, useSelector } from 'react-redux' import { useTranslation } from 'react-i18next' import { FormGroup, InputField } from '@opentrons/components' -import { ALL, COLUMN } from '@opentrons/shared-data' +import { COLUMN } from '@opentrons/shared-data' import { actions as stepsActions, getSelectedStepId, @@ -11,10 +11,10 @@ import { } from '../../../../ui/steps' import { selectors as stepFormSelectors } from '../../../../step-forms' import { getMainPagePortalEl } from '../../../portals/MainPageModalPortal' +import { getNozzleType } from '../../utils' import { WellSelectionModal } from './WellSelectionModal' import styles from '../../StepEditForm.module.css' -import type { NozzleType } from '../../../../types' import type { FieldProps } from '../../types' export type Props = FieldProps & { @@ -46,16 +46,7 @@ export const WellSelectionField = (props: Props): JSX.Element => { ? selectedWells.length.toString() : undefined const pipette = pipetteId != null ? pipetteEntities[pipetteId] : null - const is8Channel = pipette != null ? pipette.spec.channels === 8 : false - - let nozzleType: NozzleType | null = null - if (pipette !== null && is8Channel) { - nozzleType = '8-channel' - } else if (nozzles === COLUMN) { - nozzleType = COLUMN - } else if (nozzles === ALL) { - nozzleType = ALL - } + const nozzleType = getNozzleType(pipette, nozzles) const getModalKey = (): string => { return `${String(stepId)}${name}${pipetteId || 'noPipette'}${ diff --git a/protocol-designer/src/components/StepEditForm/fields/index.ts b/protocol-designer/src/components/StepEditForm/fields/index.ts index 70d10ffa616..d89f07e4d5e 100644 --- a/protocol-designer/src/components/StepEditForm/fields/index.ts +++ b/protocol-designer/src/components/StepEditForm/fields/index.ts @@ -9,17 +9,22 @@ export { TextField } from './TextField' export { BlowoutLocationField } from './BlowoutLocationField' export { BlowoutZOffsetField } from './BlowoutZOffsetField' export { ChangeTipField } from './ChangeTipField' +export { Configure96ChannelField } from './Configure96ChannelField' export { DelayFields } from './DelayFields' export { DisposalVolumeField } from './DisposalVolumeField' +export { DropTipField } from './DropTipField' export { FlowRateField } from './FlowRateField' export { LabwareField } from './LabwareField' export { LabwareLocationField } from './LabwareLocationField' export { MoveLabwareField } from './MoveLabwareField' export { PathField } from './PathField/PathField' +export { PickUpTipField } from './PickUpTipField' export { PipetteField } from './PipetteField' export { ProfileItemRows } from './ProfileItemRows' export { StepFormDropdown } from './StepFormDropdownField' export { TipPositionField } from './TipPositionField' +export { TiprackField } from './TiprackField' +export { TipWellSelectionField } from './TipWellSelectionField' export { ToggleRowField } from './ToggleRowField' export { VolumeField } from './VolumeField' export { WellOrderField } from './WellOrderField' diff --git a/protocol-designer/src/components/StepEditForm/forms/MixForm.tsx b/protocol-designer/src/components/StepEditForm/forms/MixForm.tsx index bd7fda80407..598e35e213a 100644 --- a/protocol-designer/src/components/StepEditForm/forms/MixForm.tsx +++ b/protocol-designer/src/components/StepEditForm/forms/MixForm.tsx @@ -3,29 +3,35 @@ import cx from 'classnames' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' import { FormGroup } from '@opentrons/components' -import { getPipetteEntities } from '../../../step-forms/selectors' +import { + getLabwareEntities, + getPipetteEntities, +} from '../../../step-forms/selectors' +import { getEnableReturnTip } from '../../../feature-flags/selectors' import { BlowoutLocationField, + BlowoutZOffsetField, ChangeTipField, CheckboxRowField, + Configure96ChannelField, DelayFields, + DropTipField, FlowRateField, LabwareField, + PickUpTipField, PipetteField, TextField, TipPositionField, + TiprackField, + TipWellSelectionField, VolumeField, WellOrderField, WellSelectionField, - BlowoutZOffsetField, } from '../fields' -import { TiprackField } from '../fields/TiprackField' import { getBlowoutLocationOptionsForForm, getLabwareFieldForPositioningField, } from '../utils' -import { Configure96ChannelField } from '../fields/Configure96ChannelField' -import { DropTipField } from '../fields/DropTipField' import { AspDispSection } from './AspDispSection' import type { StepFormProps } from '../types' @@ -35,17 +41,22 @@ import styles from '../StepEditForm.module.css' export const MixForm = (props: StepFormProps): JSX.Element => { const [collapsed, setCollapsed] = React.useState(true) const pipettes = useSelector(getPipetteEntities) + const enableReturnTip = useSelector(getEnableReturnTip) + const labwares = useSelector(getLabwareEntities) const { t } = useTranslation(['application', 'form']) const { propsForFields, formData } = props const is96Channel = propsForFields.pipette.value != null && pipettes[String(propsForFields.pipette.value)].name === 'p1000_96' + const userSelectedPickUpTipLocation = + labwares[String(propsForFields.pickUpTip_location.value)] != null + const userSelectedDropTipLocation = + labwares[String(propsForFields.dropTip_location.value)] != null const toggleCollapsed = (): void => { setCollapsed(prevCollapsed => !prevCollapsed) } - return (
@@ -250,9 +261,37 @@ export const MixForm = (props: StepFormProps): JSX.Element => { stepType={formData.stepType} />
-
- -
+
+
+ + {enableReturnTip + ? t('form:step_edit_form.section.pickUpAndDrop') + : t('form:step_edit_form.section.dropTip')} + +
+
+ {enableReturnTip ? ( + <> + + {userSelectedPickUpTipLocation ? ( + + ) : null} + + ) : null} + + {userSelectedDropTipLocation ? ( + + ) : null}
) diff --git a/protocol-designer/src/components/StepEditForm/forms/MoveLiquidForm/index.tsx b/protocol-designer/src/components/StepEditForm/forms/MoveLiquidForm/index.tsx index 8e5f426d569..94e83805a0d 100644 --- a/protocol-designer/src/components/StepEditForm/forms/MoveLiquidForm/index.tsx +++ b/protocol-designer/src/components/StepEditForm/forms/MoveLiquidForm/index.tsx @@ -2,35 +2,48 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import cx from 'classnames' import { useSelector } from 'react-redux' -import { getPipetteEntities } from '../../../../step-forms/selectors' +import { + getLabwareEntities, + getPipetteEntities, +} from '../../../../step-forms/selectors' +import { getEnableReturnTip } from '../../../../feature-flags/selectors' import { VolumeField, PipetteField, ChangeTipField, DisposalVolumeField, PathField, + TiprackField, + DropTipField, + PickUpTipField, + TipWellSelectionField, + Configure96ChannelField, } from '../../fields' -import { TiprackField } from '../../fields/TiprackField' -import { Configure96ChannelField } from '../../fields/Configure96ChannelField' -import { DropTipField } from '../../fields/DropTipField' -import styles from '../../StepEditForm.module.css' import { SourceDestFields } from './SourceDestFields' import { SourceDestHeaders } from './SourceDestHeaders' import type { StepFormProps } from '../../types' +import styles from '../../StepEditForm.module.css' + // TODO: BC 2019-01-25 instead of passing path from here, put it in connect fields where needed // or question if it even needs path export const MoveLiquidForm = (props: StepFormProps): JSX.Element => { + const { propsForFields, formData } = props + const { stepType, path } = formData + const { t } = useTranslation(['application', 'form']) const [collapsed, _setCollapsed] = React.useState(true) + const enableReturnTip = useSelector(getEnableReturnTip) + const labwares = useSelector(getLabwareEntities) const pipettes = useSelector(getPipetteEntities) - const { t } = useTranslation(['application', 'form']) const toggleCollapsed = (): void => { _setCollapsed(!collapsed) } + const userSelectedPickUpTipLocation = + labwares[String(propsForFields.pickUpTip_location.value)] != null + const userSelectedDropTipLocation = + labwares[String(propsForFields.dropTip_location.value)] != null - const { propsForFields, formData } = props - const { stepType, path } = formData const is96Channel = propsForFields.pipette.value != null && pipettes[String(propsForFields.pipette.value)].name === 'p1000_96' @@ -139,11 +152,34 @@ export const MoveLiquidForm = (props: StepFormProps): JSX.Element => {
- {t('form:step_edit_form.section.dropTip')} + {enableReturnTip + ? t('form:step_edit_form.section.pickUpAndDrop') + : t('form:step_edit_form.section.dropTip')}
-
+
+ {enableReturnTip ? ( + <> + + {userSelectedPickUpTipLocation ? ( + + ) : null} + + ) : null} + {userSelectedDropTipLocation && enableReturnTip ? ( + + ) : null}
) diff --git a/protocol-designer/src/components/StepEditForm/utils.ts b/protocol-designer/src/components/StepEditForm/utils.ts index 79afc0c80d6..6fa220c9f21 100644 --- a/protocol-designer/src/components/StepEditForm/utils.ts +++ b/protocol-designer/src/components/StepEditForm/utils.ts @@ -5,8 +5,10 @@ import { SOURCE_WELL_BLOWOUT_DESTINATION, DEST_WELL_BLOWOUT_DESTINATION, } from '@opentrons/step-generation' +import { ALL, COLUMN } from '@opentrons/shared-data' import { PROFILE_CYCLE } from '../../form-types' import { getDefaultsForStepType } from '../../steplist/formLevel/getDefaultsForStepType' +import type { PipetteEntity } from '@opentrons/step-generation' import type { Options } from '@opentrons/components' import type { ProfileFormError } from '../../steplist/formLevel/profileErrors' import type { FormWarning } from '../../steplist/formLevel/warnings' @@ -18,6 +20,7 @@ import type { StepType, PathOption, } from '../../form-types' +import type { NozzleType } from '../../types' export function getBlowoutLocationOptionsForForm(args: { stepType: StepType @@ -198,3 +201,19 @@ export function getLabwareFieldForPositioningField( } return fieldMap[name] } + +export const getNozzleType = ( + pipette: PipetteEntity | null, + nozzles: string | null +): NozzleType | null => { + const is8Channel = pipette != null && pipette.spec.channels === 8 + if (is8Channel) { + return '8-channel' + } else if (nozzles === COLUMN) { + return COLUMN + } else if (nozzles === ALL) { + return ALL + } else { + return null + } +} diff --git a/protocol-designer/src/feature-flags/reducers.ts b/protocol-designer/src/feature-flags/reducers.ts index f08b2fa1081..b8d0a695867 100644 --- a/protocol-designer/src/feature-flags/reducers.ts +++ b/protocol-designer/src/feature-flags/reducers.ts @@ -28,6 +28,7 @@ const initialFlags: Flags = { OT_PD_ENABLE_REDESIGN: process.env.OT_PD_ENABLE_REDESIGN === '1' || false, OT_PD_ENABLE_MOAM: process.env.OT_PD_ENABLE_MOAM === '1' || false, OT_PD_ENABLE_COMMENT: process.env.OT_PD_ENABLE_COMMENT === '1' || false, + OT_PD_ENABLE_RETURN_TIP: process.env.OT_PD_ENABLE_RETURN_TIP === '1' || false, } // @ts-expect-error(sa, 2021-6-10): cannot use string literals as action type // TODO IMMEDIATELY: refactor this to the old fashioned way if we cannot have type safety: https://github.com/redux-utilities/redux-actions/issues/282#issuecomment-595163081 diff --git a/protocol-designer/src/feature-flags/selectors.ts b/protocol-designer/src/feature-flags/selectors.ts index c70fcff00ef..7c5e57cf985 100644 --- a/protocol-designer/src/feature-flags/selectors.ts +++ b/protocol-designer/src/feature-flags/selectors.ts @@ -41,3 +41,7 @@ export const getEnableComment: Selector = createSelector( getFeatureFlagData, flags => flags.OT_PD_ENABLE_COMMENT ?? false ) +export const getEnableReturnTip: Selector = createSelector( + getFeatureFlagData, + flags => flags.OT_PD_ENABLE_RETURN_TIP ?? false +) diff --git a/protocol-designer/src/feature-flags/types.ts b/protocol-designer/src/feature-flags/types.ts index 5a1dfb810e3..eda8f7182fe 100644 --- a/protocol-designer/src/feature-flags/types.ts +++ b/protocol-designer/src/feature-flags/types.ts @@ -33,6 +33,7 @@ export type FlagTypes = | 'OT_PD_ENABLE_REDESIGN' | 'OT_PD_ENABLE_MOAM' | 'OT_PD_ENABLE_COMMENT' + | 'OT_PD_ENABLE_RETURN_TIP' // flags that are not in this list only show in prerelease mode export const userFacingFlags: FlagTypes[] = [ 'OT_PD_DISABLE_MODULE_RESTRICTIONS', @@ -45,5 +46,6 @@ export const allFlags: FlagTypes[] = [ 'OT_PD_ENABLE_REDESIGN', 'OT_PD_ENABLE_MOAM', 'OT_PD_ENABLE_COMMENT', + 'OT_PD_ENABLE_RETURN_TIP', ] export type Flags = Partial> diff --git a/protocol-designer/src/form-types.ts b/protocol-designer/src/form-types.ts index cb1ce12fe1b..d091efdae5c 100644 --- a/protocol-designer/src/form-types.ts +++ b/protocol-designer/src/form-types.ts @@ -56,6 +56,7 @@ export type StepFieldName = string // | 'disposalVolume_checkbox', // | 'disposalVolume_volume', // | 'dropTip_location' +// | 'dropTip_location' // | 'labware' // | 'labwareLocationUpdate' // | 'message' @@ -71,6 +72,8 @@ export type StepFieldName = string // | 'pauseMessage' // | 'pauseMinute' // | 'pauseSecond' +// | 'pickUpTip_location' +// | 'pickUpTip_wellNames' // | 'pipette' // | 'preWetTip' // | 'stepDetails' @@ -276,6 +279,9 @@ export interface HydratedMoveLiquidFormData { dispense_x_position?: number | null dispense_y_position?: number | null disposalVolume_volume?: number | null + dropTip_wellNames?: string[] | null + pickUpTip_location?: string | null + pickUpTip_wellNames?: string[] | null preWetTip?: boolean | null } description?: string | null @@ -328,10 +334,13 @@ export interface HydratedMixFormDataLegacy { blowout_z_offset?: number | null dispense_delay_seconds?: number | null dispense_flowRate?: number | null + dropTip_wellNames?: string[] | null mix_mmFromBottom?: number | null mix_touchTip_mmFromBottom?: number | null mix_x_position?: number | null mix_y_position?: number | null + pickUpTip_location?: string | null + pickUpTip_wellNames?: string[] | null stepDetails?: string | null times?: number | null } diff --git a/protocol-designer/src/localization/en/feature_flags.json b/protocol-designer/src/localization/en/feature_flags.json index 233a6632c69..9fe53d8f802 100644 --- a/protocol-designer/src/localization/en/feature_flags.json +++ b/protocol-designer/src/localization/en/feature_flags.json @@ -27,5 +27,9 @@ "OT_PD_ENABLE_COMMENT": { "title": "Enable comment step", "description": "You can add comments anywhere between timeline steps." + }, + "OT_PD_ENABLE_RETURN_TIP": { + "title": "Enable return tip", + "description": "You can choose which tip to pick up and where to drop tip." } } diff --git a/protocol-designer/src/localization/en/form.json b/protocol-designer/src/localization/en/form.json index 4c91e48028a..7729e960edf 100644 --- a/protocol-designer/src/localization/en/form.json +++ b/protocol-designer/src/localization/en/form.json @@ -30,7 +30,8 @@ "section": { "sterility": "sterility", "sterility&motion": "sterility & motion", - "dropTip": "drop tip" + "dropTip": "drop tip", + "pickUpAndDrop": "pick up & drop tip" }, "labwareLabel": { "aspirate": "source", @@ -60,7 +61,9 @@ "airGap": { "label": "air gap" }, "blowout": { "label": "blowout" }, "location": { - "label": "location" + "label": "location", + "pickUp": "pick up tip", + "dropTip": "drop tip" }, "change_tip": { "label": "change tip", diff --git a/protocol-designer/src/step-forms/test/createPresavedStepForm.test.ts b/protocol-designer/src/step-forms/test/createPresavedStepForm.test.ts index f7e37d2e467..daafb41c74e 100644 --- a/protocol-designer/src/step-forms/test/createPresavedStepForm.test.ts +++ b/protocol-designer/src/step-forms/test/createPresavedStepForm.test.ts @@ -143,7 +143,10 @@ describe('createPresavedStepForm', () => { stepType: 'moveLiquid', tipRack: null, // default fields + dropTip_wellNames: undefined, dropTip_location: 'mockTrash', + pickUpTip_location: undefined, + pickUpTip_wellNames: undefined, aspirate_airGap_checkbox: false, aspirate_airGap_volume: '1', aspirate_delay_checkbox: false, @@ -205,7 +208,10 @@ describe('createPresavedStepForm', () => { // default fields labware: null, nozzles: null, + dropTip_wellNames: undefined, dropTip_location: 'mockTrash', + pickUpTip_location: undefined, + pickUpTip_wellNames: undefined, wells: [], aspirate_delay_checkbox: false, aspirate_delay_seconds: `${DEFAULT_DELAY_SECONDS}`, diff --git a/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts b/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts index e175cea4fc7..d4755cec0ca 100644 --- a/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts +++ b/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts @@ -28,6 +28,7 @@ export function getDefaultsForStepType( dispense_delay_seconds: `${DEFAULT_DELAY_SECONDS}`, dispense_flowRate: null, dropTip_location: null, + dropTip_wellNames: undefined, labware: null, mix_mmFromBottom: DEFAULT_MM_FROM_BOTTOM_DISPENSE, mix_touchTip_checkbox: false, @@ -37,6 +38,8 @@ export function getDefaultsForStepType( mix_x_position: 0, mix_y_position: 0, nozzles: null, + pickUpTip_location: undefined, + pickUpTip_wellNames: undefined, pipette: null, times: null, tipRack: null, @@ -91,8 +94,11 @@ export function getDefaultsForStepType( disposalVolume_checkbox: false, disposalVolume_volume: null, dropTip_location: null, + dropTip_wellNames: undefined, nozzles: null, path: 'single', + pickUpTip_location: undefined, + pickUpTip_wellNames: undefined, pipette: null, preWetTip: false, tipRack: null, diff --git a/protocol-designer/src/steplist/formLevel/getDisabledFields/getDisabledFieldsMixForm.ts b/protocol-designer/src/steplist/formLevel/getDisabledFields/getDisabledFieldsMixForm.ts index d480b455666..6e3249a8523 100644 --- a/protocol-designer/src/steplist/formLevel/getDisabledFields/getDisabledFieldsMixForm.ts +++ b/protocol-designer/src/steplist/formLevel/getDisabledFields/getDisabledFieldsMixForm.ts @@ -7,6 +7,11 @@ export function getDisabledFieldsMixForm( ): Set { const disabled: Set = new Set() + if (hydratedForm.wells.length === 0 || hydratedForm.pipette == null) { + disabled.add('pickUpTip_location') + disabled.add('dropTip_location') + } + if (!hydratedForm.pipette || !hydratedForm.labware) { disabled.add('mix_touchTip_checkbox') disabled.add('mix_mmFromBottom') diff --git a/protocol-designer/src/steplist/formLevel/getDisabledFields/getDisabledFieldsMoveLiquidForm.ts b/protocol-designer/src/steplist/formLevel/getDisabledFields/getDisabledFieldsMoveLiquidForm.ts index 5ca7db1395f..cdadae6453f 100644 --- a/protocol-designer/src/steplist/formLevel/getDisabledFields/getDisabledFieldsMoveLiquidForm.ts +++ b/protocol-designer/src/steplist/formLevel/getDisabledFields/getDisabledFieldsMoveLiquidForm.ts @@ -10,11 +10,19 @@ export function getDisabledFieldsMoveLiquidForm( ): Set { const disabled: Set = new Set() const prefixes = ['aspirate', 'dispense'] - - if ( + const isDispensingIntoTrash = hydratedForm.dispense_labware?.name === 'wasteChute' || hydratedForm.dispense_labware?.name === 'trashBin' + + if ( + (hydratedForm.dispense_wells.length === 0 && !isDispensingIntoTrash) || + hydratedForm.aspirate_wells.length === 0 || + hydratedForm.pipette == null ) { + disabled.add('pickUpTip_location') + disabled.add('dropTip_location') + } + if (isDispensingIntoTrash) { disabled.add('dispense_mix_checkbox') disabled.add('dispense_touchTip_checkbox') disabled.add('dispense_mmFromBottom') diff --git a/protocol-designer/src/steplist/formLevel/stepFormToArgs/mixFormToArgs.ts b/protocol-designer/src/steplist/formLevel/stepFormToArgs/mixFormToArgs.ts index 17b1125c763..999e189f4b6 100644 --- a/protocol-designer/src/steplist/formLevel/stepFormToArgs/mixFormToArgs.ts +++ b/protocol-designer/src/steplist/formLevel/stepFormToArgs/mixFormToArgs.ts @@ -111,6 +111,7 @@ export const mixFormToArgs = ( aspirateDelaySeconds, tipRack: hydratedFormData.tipRack, dispenseDelaySeconds, + // TODO(jr, 7/26/24): wire up wellNames dropTipLocation: dropTip_location, nozzles, aspirateXOffset: mix_x_position ?? 0, diff --git a/protocol-designer/src/steplist/formLevel/stepFormToArgs/moveLiquidFormToArgs.ts b/protocol-designer/src/steplist/formLevel/stepFormToArgs/moveLiquidFormToArgs.ts index c9d83e49b02..b77cd8a8f2e 100644 --- a/protocol-designer/src/steplist/formLevel/stepFormToArgs/moveLiquidFormToArgs.ts +++ b/protocol-designer/src/steplist/formLevel/stepFormToArgs/moveLiquidFormToArgs.ts @@ -218,6 +218,7 @@ export const moveLiquidFormToArgs = ( touchTipAfterDispenseOffsetMmFromBottom, description: hydratedFormData.description, name: hydratedFormData.stepName, + // TODO(jr, 7/26/24): wire up wellNames dropTipLocation, nozzles, aspirateXOffset: aspirate_x_position ?? 0, diff --git a/protocol-designer/src/steplist/formLevel/test/getDefaultsForStepType.test.ts b/protocol-designer/src/steplist/formLevel/test/getDefaultsForStepType.test.ts index 2d2d1fa5e25..0d499185d8b 100644 --- a/protocol-designer/src/steplist/formLevel/test/getDefaultsForStepType.test.ts +++ b/protocol-designer/src/steplist/formLevel/test/getDefaultsForStepType.test.ts @@ -20,9 +20,11 @@ describe('getDefaultsForStepType', () => { volume: null, changeTip: DEFAULT_CHANGE_TIP_OPTION, path: 'single', + dropTip_wellNames: undefined, dropTip_location: null, + pickUpTip_location: undefined, + pickUpTip_wellNames: undefined, aspirate_wells_grouped: false, - aspirate_flowRate: null, aspirate_labware: null, aspirate_wells: [], @@ -79,7 +81,10 @@ describe('getDefaultsForStepType', () => { expect(getDefaultsForStepType('mix')).toEqual({ changeTip: DEFAULT_CHANGE_TIP_OPTION, labware: null, + dropTip_wellNames: undefined, dropTip_location: null, + pickUpTip_location: undefined, + pickUpTip_wellNames: undefined, aspirate_delay_checkbox: false, aspirate_delay_seconds: `${DEFAULT_DELAY_SECONDS}`, dispense_delay_checkbox: false, diff --git a/protocol-designer/src/ui/labware/selectors.ts b/protocol-designer/src/ui/labware/selectors.ts index 1f321526c76..77369002aeb 100644 --- a/protocol-designer/src/ui/labware/selectors.ts +++ b/protocol-designer/src/ui/labware/selectors.ts @@ -289,3 +289,32 @@ export const getTiprackOptions: Selector = createSelector( return options } ) + +export const getAllTiprackOptions: Selector = createSelector( + stepFormSelectors.getLabwareEntities, + getLabwareNicknamesById, + (labwareEntities, nicknamesById) => { + const options = reduce( + labwareEntities, + ( + acc: DropdownOption[], + labwareEntity: LabwareEntity, + labwareId: string + ): DropdownOption[] => { + if (!getIsTiprack(labwareEntity.def)) { + return acc + } else { + return [ + ...acc, + { + name: nicknamesById[labwareId], + value: labwareEntity.id, + }, + ] + } + }, + [] + ) + return options + } +) diff --git a/protocol-designer/src/ui/steps/test/selectors.test.ts b/protocol-designer/src/ui/steps/test/selectors.test.ts index 64c96f2d9a3..e33e36d872f 100644 --- a/protocol-designer/src/ui/steps/test/selectors.test.ts +++ b/protocol-designer/src/ui/steps/test/selectors.test.ts @@ -612,6 +612,18 @@ describe('_getSavedMultiSelectFieldValues', () => { value: 'fixedTrash', isIndeterminate: false, }, + dropTip_wellNames: { + value: undefined, + isIndeterminate: false, + }, + pickUpTip_location: { + value: undefined, + isIndeterminate: false, + }, + pickUpTip_wellNames: { + value: undefined, + isIndeterminate: false, + }, }) }) }) @@ -842,6 +854,18 @@ describe('_getSavedMultiSelectFieldValues', () => { value: 'fixedTrash', isIndeterminate: false, }, + dropTip_wellNames: { + value: undefined, + isIndeterminate: false, + }, + pickUpTip_location: { + value: undefined, + isIndeterminate: false, + }, + pickUpTip_wellNames: { + value: undefined, + isIndeterminate: false, + }, }) }) }) @@ -904,6 +928,18 @@ describe('_getSavedMultiSelectFieldValues', () => { value: 'fixedTrash', isIndeterminate: false, }, + dropTip_wellNames: { + value: undefined, + isIndeterminate: false, + }, + pickUpTip_location: { + value: undefined, + isIndeterminate: false, + }, + pickUpTip_wellNames: { + value: undefined, + isIndeterminate: false, + }, }) }) }) @@ -986,6 +1022,15 @@ describe('_getSavedMultiSelectFieldValues', () => { value: 'fixedTrash', isIndeterminate: false, }, + dropTip_wellNames: { + isIndeterminate: false, + }, + pickUpTip_location: { + isIndeterminate: false, + }, + pickUpTip_wellNames: { + isIndeterminate: false, + }, }) }) }) From 161d7bfc15bd1a189a65add3acbdda868d0db9c3 Mon Sep 17 00:00:00 2001 From: Brayan Almonte Date: Thu, 1 Aug 2024 10:03:28 -0400 Subject: [PATCH 25/49] fix(robot-server): Set the max quick transfer runs to 2 to account for cloning an active run. (#15864) --- robot-server/robot_server/runs/dependencies.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/robot-server/robot_server/runs/dependencies.py b/robot-server/robot_server/runs/dependencies.py index 3fbef3a7e30..c3a990d38c8 100644 --- a/robot-server/robot_server/runs/dependencies.py +++ b/robot-server/robot_server/runs/dependencies.py @@ -181,7 +181,8 @@ async def get_quick_transfer_run_auto_deleter( return RunAutoDeleter( run_store=run_store, protocol_store=protocol_store, - # We dont store quick transfer runs - deletion_planner=RunDeletionPlanner(maximum_runs=1), + # NOTE: We dont store quick transfer runs, however we need an additional + # run slot so we can clone an active run. + deletion_planner=RunDeletionPlanner(maximum_runs=2), protocol_kind=ProtocolKind.QUICK_TRANSFER, ) From f4d61195d20604bd4295515d356b74c2a4d0f1fd Mon Sep 17 00:00:00 2001 From: Laura Cox <31892318+Laura-Danielle@users.noreply.github.com> Date: Thu, 1 Aug 2024 17:07:33 +0300 Subject: [PATCH 26/49] feat(api): Add robot context API skeleton (#15745) --- api/docs/v2/conf.py | 1 + api/src/opentrons/protocol_api/__init__.py | 2 + .../protocol_api/protocol_context.py | 21 ++--- .../opentrons/protocol_api/robot_context.py | 89 +++++++++++++++++++ .../commands/robot/__init__.py | 1 + 5 files changed, 102 insertions(+), 12 deletions(-) create mode 100644 api/src/opentrons/protocol_api/robot_context.py create mode 100644 api/src/opentrons/protocol_engine/commands/robot/__init__.py diff --git a/api/docs/v2/conf.py b/api/docs/v2/conf.py index 9e9f2fd365b..1913abbfe1a 100644 --- a/api/docs/v2/conf.py +++ b/api/docs/v2/conf.py @@ -446,5 +446,6 @@ ("py:class", r".*opentrons_shared_data.*"), ("py:class", r".*protocol_api._parameters.Parameters.*"), ("py:class", r".*AbsorbanceReaderContext"), # shh it's a secret (for now) + ("py:class", r".*RobotContext"), # shh it's a secret (for now) ("py:class", r'.*AbstractLabware|APIVersion|LabwareLike|LoadedCoreMap|ModuleTypes|NoneType|OffDeckType|ProtocolCore|WellCore'), # laundry list of not fully qualified things ] diff --git a/api/src/opentrons/protocol_api/__init__.py b/api/src/opentrons/protocol_api/__init__.py index 3bf263d6b76..975f2996c98 100644 --- a/api/src/opentrons/protocol_api/__init__.py +++ b/api/src/opentrons/protocol_api/__init__.py @@ -15,6 +15,7 @@ from .protocol_context import ProtocolContext from .deck import Deck +from .robot_context import RobotContext from .instrument_context import InstrumentContext from .labware import Labware, Well from .module_contexts import ( @@ -51,6 +52,7 @@ "ProtocolContext", "Deck", "ModuleContext", + "RobotContext", "InstrumentContext", "TemperatureModuleContext", "MagneticModuleContext", diff --git a/api/src/opentrons/protocol_api/protocol_context.py b/api/src/opentrons/protocol_api/protocol_context.py index ad1f326b40e..59b7d1d8aee 100644 --- a/api/src/opentrons/protocol_api/protocol_context.py +++ b/api/src/opentrons/protocol_api/protocol_context.py @@ -5,7 +5,6 @@ Callable, Dict, List, - NamedTuple, Optional, Type, Union, @@ -18,7 +17,6 @@ from opentrons.types import Mount, Location, DeckLocation, DeckSlotName, StagingSlotName from opentrons.legacy_broker import LegacyBroker -from opentrons.hardware_control import SyncHardwareAPI from opentrons.hardware_control.modules.types import MagneticBlockModel from opentrons.legacy_commands import protocol_commands as cmds, types as cmd_types from opentrons.legacy_commands.helpers import stringify_labware_movement_command @@ -54,6 +52,7 @@ AbstractMagneticBlockCore, AbstractAbsorbanceReaderCore, ) +from .robot_context import RobotContext, HardwareManager from .core.engine import ENGINE_CORE_API_VERSION from .core.legacy.legacy_protocol_core import LegacyProtocolCore @@ -88,15 +87,6 @@ ] -class HardwareManager(NamedTuple): - """Back. compat. wrapper for a removed class called `HardwareManager`. - - This interface will not be present in PAPIv3. - """ - - hardware: SyncHardwareAPI - - class ProtocolContext(CommandPublisher): """A context for the state of a protocol. @@ -179,6 +169,7 @@ def __init__( self._commands: List[str] = [] self._params: Parameters = Parameters() self._unsubscribe_commands: Optional[Callable[[], None]] = None + self._robot = RobotContext(self._core) self.clear_commands() @property @@ -203,15 +194,21 @@ def api_version(self) -> APIVersion: """ return self._api_version + @property + @requires_version(2, 20) + def robot(self) -> RobotContext: + return self._robot + @property def _hw_manager(self) -> HardwareManager: # TODO (lc 01-05-2021) remove this once we have a more # user facing hardware control http api. + # HardwareManager(hardware=self._core.get_hardware()) logger.warning( "This function will be deprecated in later versions." "Please use with caution." ) - return HardwareManager(hardware=self._core.get_hardware()) + return self._robot.hardware @property @requires_version(2, 0) diff --git a/api/src/opentrons/protocol_api/robot_context.py b/api/src/opentrons/protocol_api/robot_context.py new file mode 100644 index 00000000000..01a443cd743 --- /dev/null +++ b/api/src/opentrons/protocol_api/robot_context.py @@ -0,0 +1,89 @@ +from typing import NamedTuple, Union, Dict, Optional + +from opentrons.types import Mount, DeckLocation, Point +from opentrons.legacy_commands import publisher +from opentrons.hardware_control import SyncHardwareAPI, types as hw_types + +from ._types import OffDeckType +from .core.common import ProtocolCore + + +class HardwareManager(NamedTuple): + """Back. compat. wrapper for a removed class called `HardwareManager`. + + This interface will not be present in PAPIv3. + """ + + hardware: SyncHardwareAPI + + +class RobotContext(publisher.CommandPublisher): + """ + A context for the movement system of the robot. + + The RobotContext class provides the objects, attributes, and methods that allow + you to control robot motor axes individually. + + Its methods can command the robot to perform an action, like moving to an absolute position, + controlling the gripper jaw, or moving individual pipette motors. + + Objects in this class should not be instantiated directly. Instead, instances are + returned by :py:meth:`ProtocolContext.robot`. + + .. versionadded:: 2.20 + + """ + + def __init__(self, core: ProtocolCore) -> None: + self._hardware = HardwareManager(hardware=core.get_hardware()) + + @property + def hardware(self) -> HardwareManager: + return self._hardware + + def move_to( + self, + mount: Union[Mount, str], + destination: Point, + velocity: float, + ) -> None: + raise NotImplementedError() + + def move_axes_to( + self, + abs_axis_map: Dict[hw_types.Axis, hw_types.AxisMapValue], + velocity: float, + critical_point: Optional[hw_types.CriticalPoint], + ) -> None: + raise NotImplementedError() + + def move_axes_relative( + self, rel_axis_map: Dict[hw_types.Axis, hw_types.AxisMapValue], velocity: float + ) -> None: + raise NotImplementedError() + + def close_gripper_jaw(self, force: float) -> None: + raise NotImplementedError() + + def open_gripper_jaw(self) -> None: + raise NotImplementedError() + + def axis_coordinates_for( + self, mount: Union[Mount, str], location: Union[DeckLocation, OffDeckType] + ) -> None: + raise NotImplementedError() + + def plunger_coordinates_for_volume( + self, mount: Union[Mount, str], volume: float + ) -> None: + raise NotImplementedError() + + def plunger_coordinates_for_named_position( + self, mount: Union[Mount, str], position_name: str + ) -> None: + raise NotImplementedError() + + def build_axis_map( + self, axis_map: Dict[hw_types.Axis, hw_types.AxisMapValue] + ) -> None: + raise NotImplementedError() diff --git a/api/src/opentrons/protocol_engine/commands/robot/__init__.py b/api/src/opentrons/protocol_engine/commands/robot/__init__.py new file mode 100644 index 00000000000..ee78c1d4044 --- /dev/null +++ b/api/src/opentrons/protocol_engine/commands/robot/__init__.py @@ -0,0 +1 @@ +"""Robot movement commands.""" From b574712463c2a26ce1535db388ebb7255c09fa31 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Thu, 1 Aug 2024 10:25:59 -0400 Subject: [PATCH 27/49] fix(app): Fix spinner sizing on ODD and desktop (#15862) There's a lot of places in the ODD that return either an InProgressModal or a SimpleWizardBody. This used to be fine because the InProgressMOdal had sizing, but now it doesn't so that it can be used inside other components, so those places all have an in-progress modal collapsed on top of the spinner icon. To fix this, let's make a component that is explicitly "InProgressModal designed to work alongside SimpleWizardBody", defined in SimpleWizardBody. We can make sure it's the same by splittin SWB into a container component and a contents component, and reusing the container. The container is also where styleprops go. Finally, take all the component that return (cond ? : ... = { + title: 'App/Molecules/InProgressModal', + component: InProgressModalComponent, + argTypes: { + description: { + control: { + type: 'text', + }, + }, + body: { + control: { + type: 'text', + }, + }, + }, +} + +export default meta + +export type Story = StoryObj + +export const InProgressModal: Story = { + args: { + description: 'here is a description', + body: 'Here is the body of the whole thing', + }, +} + +export const InProgressModalSimpleWizard: Story = { + args: { + description: 'here is a description', + body: 'Here is the body of the whole thing', + }, + render: args => , +} diff --git a/app/src/molecules/InProgressModal/InProgressModal.tsx b/app/src/molecules/InProgressModal/InProgressModal.tsx index c916f574472..70d6c3eadc4 100644 --- a/app/src/molecules/InProgressModal/InProgressModal.tsx +++ b/app/src/molecules/InProgressModal/InProgressModal.tsx @@ -1,6 +1,5 @@ import * as React from 'react' import { css } from 'styled-components' -import { useSelector } from 'react-redux' import { ALIGN_CENTER, COLORS, @@ -13,7 +12,6 @@ import { LegacyStyledText, TYPOGRAPHY, } from '@opentrons/components' -import { getIsOnDevice } from '../../redux/config' interface Props { // optional override of the spinner @@ -61,33 +59,35 @@ const MODAL_STYLE = css` ` const SPINNER_STYLE = css` color: ${COLORS.grey60}; - opacity: 100%; + width: 5.125rem; + height: 5.125rem; @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { - color: ${COLORS.black90}; - opacity: 70%; + width: 6.25rem; + height: 6.25rem; + } +` + +const DESCRIPTION_CONTAINER_STYLE = css` + padding-x: 6.5625rem; + gap: ${SPACING.spacing8}; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + padding-x: ${SPACING.spacing40}; + gap: ${SPACING.spacing4}; } ` export function InProgressModal(props: Props): JSX.Element { const { alternativeSpinner, children, description, body } = props - const isOnDevice = useSelector(getIsOnDevice) return ( {alternativeSpinner ?? ( - + )} {description != null && ( diff --git a/app/src/molecules/InProgressModal/index.tsx b/app/src/molecules/InProgressModal/index.tsx new file mode 100644 index 00000000000..b6ab378260c --- /dev/null +++ b/app/src/molecules/InProgressModal/index.tsx @@ -0,0 +1 @@ +export { InProgressModal } from './InProgressModal' diff --git a/app/src/molecules/SimpleWizardBody/SimpleWizardBodyContainer.tsx b/app/src/molecules/SimpleWizardBody/SimpleWizardBodyContainer.tsx new file mode 100644 index 00000000000..11644dba212 --- /dev/null +++ b/app/src/molecules/SimpleWizardBody/SimpleWizardBodyContainer.tsx @@ -0,0 +1,35 @@ +import * as React from 'react' +import { css } from 'styled-components' + +import { + Flex, + DIRECTION_COLUMN, + JUSTIFY_SPACE_BETWEEN, + RESPONSIVENESS, +} from '@opentrons/components' +import type { StyleProps } from '@opentrons/components' + +const WIZARD_CONTAINER_STYLE = css` + min-height: 394px; + flex-direction: ${DIRECTION_COLUMN}; + justify-content: ${JUSTIFY_SPACE_BETWEEN}; + height: 'auto'; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + height: 472px; + } +` + +export interface SimpleWizardBodyContainerProps extends StyleProps { + children?: JSX.Element | JSX.Element[] | null +} + +export function SimpleWizardBodyContainer({ + children, + ...styleProps +}: SimpleWizardBodyContainerProps): JSX.Element { + return ( + + {children} + + ) +} diff --git a/app/src/molecules/SimpleWizardBody/SimpleWizardBodyContent.tsx b/app/src/molecules/SimpleWizardBody/SimpleWizardBodyContent.tsx new file mode 100644 index 00000000000..61e6b6de67a --- /dev/null +++ b/app/src/molecules/SimpleWizardBody/SimpleWizardBodyContent.tsx @@ -0,0 +1,178 @@ +import * as React from 'react' +import { useSelector } from 'react-redux' +import { css } from 'styled-components' +import { + ALIGN_CENTER, + DIRECTION_COLUMN, + Flex, + Icon, + JUSTIFY_CENTER, + JUSTIFY_FLEX_END, + JUSTIFY_FLEX_START, + JUSTIFY_SPACE_BETWEEN, + POSITION_ABSOLUTE, + RESPONSIVENESS, + SPACING, + LegacyStyledText, + TYPOGRAPHY, +} from '@opentrons/components' +import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' +import SuccessIcon from '../../assets/images/icon_success.png' +import { getIsOnDevice } from '../../redux/config' + +import { Skeleton } from '../../atoms/Skeleton' +import type { RobotType } from '@opentrons/shared-data' + +interface Props { + iconColor: string + header: string + isSuccess: boolean + children?: React.ReactNode + subHeader?: string | JSX.Element + isPending?: boolean + robotType?: RobotType + /** + * this prop is to change justifyContent of OnDeviceDisplay buttons + * TODO(jr, 8/9/23): this SHOULD be refactored so the + * buttons' justifyContent is specified at the parent level + */ + justifyContentForOddButton?: string +} + +const BACKGROUND_SIZE = '47rem' + +const HEADER_STYLE = css` + ${TYPOGRAPHY.h1Default}; + margin-top: ${SPACING.spacing24}; + margin-bottom: ${SPACING.spacing8}; + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + font-size: 2rem; + font-weight: 700; + line-height: ${SPACING.spacing40}; + } +` +const SUBHEADER_STYLE = css` + ${TYPOGRAPHY.pRegular}; + margin-left: 6.25rem; + margin-right: 6.25rem; + margin-bottom: ${SPACING.spacing32}; + text-align: ${TYPOGRAPHY.textAlignCenter}; + height: 1.75rem; + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + font-size: ${TYPOGRAPHY.fontSize28}; + line-height: ${TYPOGRAPHY.lineHeight36}; + margin-left: 4.5rem; + margin-right: 4.5rem; + } +` + +const FLEX_SPACING_STYLE = css` + height: 1.75rem; + margin-bottom: ${SPACING.spacing32}; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + height: 0rem; + } +` + +export function SimpleWizardBodyContent(props: Props): JSX.Element { + const { + iconColor, + children, + header, + subHeader, + isSuccess, + isPending, + robotType = FLEX_ROBOT_TYPE, + } = props + const isOnDevice = useSelector(getIsOnDevice) + + const BUTTON_STYLE = css` + width: 100%; + justify-content: ${JUSTIFY_FLEX_END}; + padding-right: ${SPACING.spacing32}; + padding-bottom: ${SPACING.spacing32}; + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + justify-content: ${props.justifyContentForOddButton ?? + JUSTIFY_SPACE_BETWEEN}; + padding-bottom: ${SPACING.spacing32}; + padding-left: ${SPACING.spacing32}; + } + ` + + const ICON_POSITION_STYLE = css` + justify-content: ${JUSTIFY_CENTER}; + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + justify-content: ${JUSTIFY_FLEX_START}; + margin-top: ${isSuccess ? SPACING.spacing32 : '8.1875rem'}; + } + ` + + return ( + <> + + {isPending ? ( + + + + + ) : ( + <> + {isSuccess ? ( + Success Icon + ) : ( + + )} + {header} + {subHeader != null ? ( + + {subHeader} + + ) : ( + + )} + + )} + + + {children} + + + ) +} diff --git a/app/src/molecules/SimpleWizardBody/SimpleWizardInProgressBody.tsx b/app/src/molecules/SimpleWizardBody/SimpleWizardInProgressBody.tsx new file mode 100644 index 00000000000..55bd83b534b --- /dev/null +++ b/app/src/molecules/SimpleWizardBody/SimpleWizardInProgressBody.tsx @@ -0,0 +1,29 @@ +import * as React from 'react' +import type { StyleProps } from '@opentrons/components' +import { InProgressModal } from '../InProgressModal/InProgressModal' +import { SimpleWizardBodyContainer } from './SimpleWizardBodyContainer' + +export type SimpleWizardInProgressBodyProps = React.ComponentProps< + typeof InProgressModal +> & + StyleProps + +export function SimpleWizardInProgressBody({ + alternativeSpinner, + description, + body, + children, + ...styleProps +}: SimpleWizardInProgressBodyProps): JSX.Element { + return ( + + + {children} + + + ) +} diff --git a/app/src/molecules/SimpleWizardBody/index.tsx b/app/src/molecules/SimpleWizardBody/index.tsx index 4c941d73ba4..c0408417030 100644 --- a/app/src/molecules/SimpleWizardBody/index.tsx +++ b/app/src/molecules/SimpleWizardBody/index.tsx @@ -1,187 +1,22 @@ import * as React from 'react' -import { useSelector } from 'react-redux' -import { css } from 'styled-components' -import { - ALIGN_CENTER, - DIRECTION_COLUMN, - Flex, - Icon, - JUSTIFY_CENTER, - JUSTIFY_FLEX_END, - JUSTIFY_FLEX_START, - JUSTIFY_SPACE_BETWEEN, - POSITION_ABSOLUTE, - RESPONSIVENESS, - SPACING, - LegacyStyledText, - TYPOGRAPHY, -} from '@opentrons/components' -import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' -import SuccessIcon from '../../assets/images/icon_success.png' -import { getIsOnDevice } from '../../redux/config' -import { Skeleton } from '../../atoms/Skeleton' -import type { RobotType } from '@opentrons/shared-data' -import type { StyleProps } from '@opentrons/components' -interface Props extends StyleProps { - iconColor: string - header: string - isSuccess: boolean - children?: React.ReactNode - subHeader?: string | JSX.Element - isPending?: boolean - robotType?: RobotType - /** - * this prop is to change justifyContent of OnDeviceDisplay buttons - * TODO(jr, 8/9/23): this SHOULD be refactored so the - * buttons' justifyContent is specified at the parent level - */ - justifyContentForOddButton?: string +import { SimpleWizardBodyContainer } from './SimpleWizardBodyContainer' +import { SimpleWizardBodyContent } from './SimpleWizardBodyContent' +import { SimpleWizardInProgressBody } from './SimpleWizardInProgressBody' +export { + SimpleWizardBodyContainer, + SimpleWizardBodyContent, + SimpleWizardInProgressBody, } -const BACKGROUND_SIZE = '47rem' - -const HEADER_STYLE = css` - ${TYPOGRAPHY.h1Default}; - margin-top: ${SPACING.spacing24}; - margin-bottom: ${SPACING.spacing8}; - - @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { - font-size: 2rem; - font-weight: 700; - line-height: ${SPACING.spacing40}; - } -` -const SUBHEADER_STYLE = css` - ${TYPOGRAPHY.pRegular}; - margin-left: 6.25rem; - margin-right: 6.25rem; - margin-bottom: ${SPACING.spacing32}; - text-align: ${TYPOGRAPHY.textAlignCenter}; - height: 1.75rem; - - @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { - font-size: ${TYPOGRAPHY.fontSize28}; - line-height: ${TYPOGRAPHY.lineHeight36}; - margin-left: 4.5rem; - margin-right: 4.5rem; - } -` -const WIZARD_CONTAINER_STYLE = css` - min-height: 394px; - flex-direction: ${DIRECTION_COLUMN}; - justify-content: ${JUSTIFY_SPACE_BETWEEN}; - height: 'auto'; - @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { - height: 472px; - } -` -const FLEX_SPACING_STYLE = css` - height: 1.75rem; - margin-bottom: ${SPACING.spacing32}; - @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { - height: 0rem; - } -` - -export function SimpleWizardBody(props: Props): JSX.Element { - const { - iconColor, - children, - header, - subHeader, - isSuccess, - isPending, - robotType = FLEX_ROBOT_TYPE, - ...styleProps - } = props - const isOnDevice = useSelector(getIsOnDevice) - - const BUTTON_STYLE = css` - width: 100%; - justify-content: ${JUSTIFY_FLEX_END}; - padding-right: ${SPACING.spacing32}; - padding-bottom: ${SPACING.spacing32}; - - @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { - justify-content: ${props.justifyContentForOddButton ?? - JUSTIFY_SPACE_BETWEEN}; - padding-bottom: ${SPACING.spacing32}; - padding-left: ${SPACING.spacing32}; - } - ` - - const ICON_POSITION_STYLE = css` - justify-content: ${JUSTIFY_CENTER}; - - @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { - justify-content: ${JUSTIFY_FLEX_START}; - margin-top: ${isSuccess ? SPACING.spacing32 : '8.1875rem'}; - } - ` - +export function SimpleWizardBody( + props: React.ComponentProps & + React.ComponentProps +): JSX.Element { + const { children, ...rest } = props return ( - - - {isPending ? ( - - - - - ) : ( - <> - {isSuccess ? ( - Success Icon - ) : ( - - )} - {header} - {subHeader != null ? ( - - {subHeader} - - ) : ( - - )} - - )} - - - {children} - - + + {children} + ) } diff --git a/app/src/organisms/GripperWizardFlows/BeforeBeginning.tsx b/app/src/organisms/GripperWizardFlows/BeforeBeginning.tsx index 8c3264bf134..249f7ca8ddf 100644 --- a/app/src/organisms/GripperWizardFlows/BeforeBeginning.tsx +++ b/app/src/organisms/GripperWizardFlows/BeforeBeginning.tsx @@ -3,8 +3,10 @@ import { Trans, useTranslation } from 'react-i18next' import { COLORS, LegacyStyledText } from '@opentrons/components' import { EXTENSION } from '@opentrons/shared-data' import { GenericWizardTile } from '../../molecules/GenericWizardTile' -import { SimpleWizardBody } from '../../molecules/SimpleWizardBody' -import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal' +import { + SimpleWizardBody, + SimpleWizardInProgressBody, +} from '../../molecules/SimpleWizardBody' import { WizardRequiredEquipmentList } from '../../molecules/WizardRequiredEquipmentList' import { GRIPPER_FLOW_TYPES, @@ -119,7 +121,7 @@ export const BeforeBeginning = ( if (isRobotMoving) return ( - ) diff --git a/app/src/organisms/GripperWizardFlows/ExitConfirmation.tsx b/app/src/organisms/GripperWizardFlows/ExitConfirmation.tsx index 9439612e7fc..6633a7ba9fd 100644 --- a/app/src/organisms/GripperWizardFlows/ExitConfirmation.tsx +++ b/app/src/organisms/GripperWizardFlows/ExitConfirmation.tsx @@ -10,8 +10,10 @@ import { JUSTIFY_FLEX_END, } from '@opentrons/components' import { getIsOnDevice } from '../../redux/config' -import { SimpleWizardBody } from '../../molecules/SimpleWizardBody' -import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal' +import { + SimpleWizardBody, + SimpleWizardInProgressBody, +} from '../../molecules/SimpleWizardBody' import { SmallButton } from '../../atoms/buttons' import { GRIPPER_FLOW_TYPES } from './constants' import type { GripperWizardFlowType } from './types' @@ -37,7 +39,7 @@ export function ExitConfirmation(props: ExitConfirmationProps): JSX.Element { if (isRobotMoving) return ( - ) diff --git a/app/src/organisms/GripperWizardFlows/MountGripper.tsx b/app/src/organisms/GripperWizardFlows/MountGripper.tsx index da7d9a6c14e..7f69bf389ac 100644 --- a/app/src/organisms/GripperWizardFlows/MountGripper.tsx +++ b/app/src/organisms/GripperWizardFlows/MountGripper.tsx @@ -19,8 +19,10 @@ import { useTranslation } from 'react-i18next' import { getIsOnDevice } from '../../redux/config' import { SmallButton } from '../../atoms/buttons' import { GenericWizardTile } from '../../molecules/GenericWizardTile' -import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal' -import { SimpleWizardBody } from '../../molecules/SimpleWizardBody' +import { + SimpleWizardBody, + SimpleWizardInProgressBody, +} from '../../molecules/SimpleWizardBody' import mountGripper from '../../assets/videos/gripper-wizards/MOUNT_GRIPPER.webm' import type { GripperWizardStepProps } from './types' @@ -83,7 +85,7 @@ export const MountGripper = ( if (isRobotMoving) return ( - ) diff --git a/app/src/organisms/GripperWizardFlows/MovePin.tsx b/app/src/organisms/GripperWizardFlows/MovePin.tsx index 1a0b42ebc7b..2a791a1faeb 100644 --- a/app/src/organisms/GripperWizardFlows/MovePin.tsx +++ b/app/src/organisms/GripperWizardFlows/MovePin.tsx @@ -9,9 +9,11 @@ import { LegacyStyledText, } from '@opentrons/components' import { css } from 'styled-components' -import { SimpleWizardBody } from '../../molecules/SimpleWizardBody' +import { + SimpleWizardBody, + SimpleWizardInProgressBody, +} from '../../molecules/SimpleWizardBody' import { GenericWizardTile } from '../../molecules/GenericWizardTile' -import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal' import { MOVE_PIN_FROM_FRONT_JAW_TO_REAR_JAW, MOVE_PIN_TO_FRONT_JAW, @@ -245,7 +247,7 @@ export const MovePin = (props: MovePinProps): JSX.Element | null => { } = infoByMovement[movement] if (isRobotMoving) return ( - ) diff --git a/app/src/organisms/GripperWizardFlows/UnmountGripper.tsx b/app/src/organisms/GripperWizardFlows/UnmountGripper.tsx index c91514b9fe9..46b62bdf40d 100644 --- a/app/src/organisms/GripperWizardFlows/UnmountGripper.tsx +++ b/app/src/organisms/GripperWizardFlows/UnmountGripper.tsx @@ -19,8 +19,10 @@ import { css } from 'styled-components' import { getIsOnDevice } from '../../redux/config' import { SmallButton } from '../../atoms/buttons' import { GenericWizardTile } from '../../molecules/GenericWizardTile' -import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal' -import { SimpleWizardBody } from '../../molecules/SimpleWizardBody' +import { + SimpleWizardBody, + SimpleWizardInProgressBody, +} from '../../molecules/SimpleWizardBody' import unmountGripper from '../../assets/videos/gripper-wizards/UNMOUNT_GRIPPER.webm' import type { GripperWizardStepProps } from './types' @@ -93,7 +95,7 @@ export const UnmountGripper = ( if (isRobotMoving) return ( - ) diff --git a/app/src/organisms/ModuleWizardFlows/AttachProbe.tsx b/app/src/organisms/ModuleWizardFlows/AttachProbe.tsx index b509abfc6dd..94af0fcdd40 100644 --- a/app/src/organisms/ModuleWizardFlows/AttachProbe.tsx +++ b/app/src/organisms/ModuleWizardFlows/AttachProbe.tsx @@ -12,7 +12,7 @@ import { LEFT, WASTE_CHUTE_FIXTURES } from '@opentrons/shared-data' import attachProbe1 from '../../assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_1.webm' import attachProbe8 from '../../assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_8.webm' import attachProbe96 from '../../assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_96.webm' -import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal' +import { SimpleWizardInProgressBody } from '../../molecules/SimpleWizardBody' import type { CreateCommand, @@ -161,7 +161,7 @@ export const AttachProbe = (props: AttachProbeProps): JSX.Element | null => { if (isRobotMoving) return ( - { if (isRobotMoving) return ( - ) diff --git a/app/src/organisms/ModuleWizardFlows/index.tsx b/app/src/organisms/ModuleWizardFlows/index.tsx index 01bb358101b..f0b542b4069 100644 --- a/app/src/organisms/ModuleWizardFlows/index.tsx +++ b/app/src/organisms/ModuleWizardFlows/index.tsx @@ -16,7 +16,6 @@ import { } from '@opentrons/shared-data' import { LegacyModalShell } from '../../molecules/LegacyModal' import { getTopPortalEl } from '../../App/portal' -import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal' import { WizardHeader } from '../../molecules/WizardHeader' import { useAttachedPipettesFromInstrumentsQuery } from '../../organisms/Devices/hooks' import { @@ -24,7 +23,10 @@ import { useCreateTargetedMaintenanceRunMutation, } from '../../resources/runs' import { getIsOnDevice } from '../../redux/config' -import { SimpleWizardBody } from '../../molecules/SimpleWizardBody' +import { + SimpleWizardBody, + SimpleWizardInProgressBody, +} from '../../molecules/SimpleWizardBody' import { getModuleCalibrationSteps } from './getModuleCalibrationSteps' import { FLEX_SLOT_NAMES_BY_MOD_TYPE, SECTIONS } from './constants' import { BeforeBeginning } from './BeforeBeginning' @@ -263,7 +265,7 @@ export const ModuleWizardFlows = ( let modalContent: JSX.Element =
UNASSIGNED STEP
if (isPrepCommandLoading) { modalContent = ( - ) } else if (isExiting) { - modalContent = + modalContent = ( + + ) } else if (currentStep.section === SECTIONS.BEFORE_BEGINNING) { modalContent = } else if (currentStep.section === SECTIONS.SELECT_LOCATION) { diff --git a/app/src/organisms/PipetteWizardFlows/AttachProbe.tsx b/app/src/organisms/PipetteWizardFlows/AttachProbe.tsx index a03067f45a7..86652e4f558 100644 --- a/app/src/organisms/PipetteWizardFlows/AttachProbe.tsx +++ b/app/src/organisms/PipetteWizardFlows/AttachProbe.tsx @@ -12,8 +12,10 @@ import { import { LEFT, WASTE_CHUTE_CUTOUT } from '@opentrons/shared-data' import { Banner } from '../../atoms/Banner' import { GenericWizardTile } from '../../molecules/GenericWizardTile' -import { SimpleWizardBody } from '../../molecules/SimpleWizardBody' -import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal' +import { + SimpleWizardBody, + SimpleWizardInProgressBody, +} from '../../molecules/SimpleWizardBody' import pipetteProbe1 from '../../assets/videos/pipette-wizard-flows/Pipette_Probing_1.webm' import pipetteProbe8 from '../../assets/videos/pipette-wizard-flows/Pipette_Probing_8.webm' import probing96 from '../../assets/videos/pipette-wizard-flows/Pipette_Probing_96.webm' @@ -156,7 +158,7 @@ export const AttachProbe = (props: AttachProbeProps): JSX.Element | null => { if (isRobotMoving) return ( - {
)} -
+ ) else if (showUnableToDetect) return ( diff --git a/app/src/organisms/PipetteWizardFlows/BeforeBeginning.tsx b/app/src/organisms/PipetteWizardFlows/BeforeBeginning.tsx index 586dc07ce04..2e9fbd28e3f 100644 --- a/app/src/organisms/PipetteWizardFlows/BeforeBeginning.tsx +++ b/app/src/organisms/PipetteWizardFlows/BeforeBeginning.tsx @@ -15,9 +15,11 @@ import { WASTE_CHUTE_CUTOUT, } from '@opentrons/shared-data' import { Banner } from '../../atoms/Banner' -import { SimpleWizardBody } from '../../molecules/SimpleWizardBody' +import { + SimpleWizardBody, + SimpleWizardInProgressBody, +} from '../../molecules/SimpleWizardBody' import { GenericWizardTile } from '../../molecules/GenericWizardTile' -import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal' import { WizardRequiredEquipmentList } from '../../molecules/WizardRequiredEquipmentList' import { usePipetteNameSpecs } from '../../resources/instruments/hooks' import { @@ -231,7 +233,8 @@ export const BeforeBeginning = ( }) } - if (isRobotMoving) return + if (isRobotMoving) + return return errorMessage != null ? ( { ) } - if (isRobotMoving) return + if (isRobotMoving) + return if (showPipetteStillAttached) { return ( { const pipetteWizardStep = { mount, flowType, section: SECTIONS.DETACH_PROBE } const channel = attachedPipettes[mount]?.data.channels - if (isRobotMoving) return + if (isRobotMoving) + return return ( + return return ( + if (isRobotMoving) + return return errorMessage != null ? ( { ) } - if (isRobotMoving) return + if (isRobotMoving) + return if (errorMessage != null) { return ( Date: Thu, 1 Aug 2024 10:19:23 -0500 Subject: [PATCH 28/49] chore(release): internal release notes ot3@2.0.0-alpha.4 (#15865) # ot3@2.0.0-alpha.4 --- api/release-notes-internal.md | 10 ++++++++++ app-shell/build/release-notes-internal.md | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/api/release-notes-internal.md b/api/release-notes-internal.md index 273056c4670..6ddd85c885b 100644 --- a/api/release-notes-internal.md +++ b/api/release-notes-internal.md @@ -2,6 +2,16 @@ For more details about this release, please see the full [technical change log][ [technical change log]: https://github.com/Opentrons/opentrons/releases +## Internal Release 2.0.0-alpha.4 + +This internal release, pulled from the `edge` branch, contains features being developed for 8.0.0. It's for internal testing only. There are no changes to `buildroot`, `ot3-firmware`, or `oe-core` since the last internal release. + +- [Opentrons changes since the latest stable release](https://github.com/Opentrons/opentrons/compare/v7.5.0...ot3@2.0.0-alpha.4) +- [Opentrons changes since the last internal release](https://github.com/Opentrons/opentrons/compare/ot3@2.0.0-alpha.3...ot3@2.0.0-alpha.4) +- [Flex changes since last stable release](https://github.com/Opentrons/oe-core/compare/v0.6.4...internal@2.0.0-alpha.3) +- [Flex firmware changes since last stable release](https://github.com/Opentrons/ot3-firmware/compare/v52...internal@v10) +- [OT2 changes since last stable release](https://github.com/Opentrons/buildroot/compare/v1.17.7...internal@2.0.0-alpha.0) + ## Internal Release 2.0.0-alpha.3 This internal release, pulled from the `edge` branch, contains features being developed for 8.0.0. It's for internal testing only. diff --git a/app-shell/build/release-notes-internal.md b/app-shell/build/release-notes-internal.md index 6967672777e..ce6e62da888 100644 --- a/app-shell/build/release-notes-internal.md +++ b/app-shell/build/release-notes-internal.md @@ -1,6 +1,16 @@ For more details about this release, please see the full [technical changelog][]. [technical change log]: https://github.com/Opentrons/opentrons/releases +## Internal Release 2.0.0-alpha.4 + +This internal release, pulled from the `edge` branch, contains features being developed for 8.0.0. It's for internal testing only. There are no changes to `buildroot`, `ot3-firmware`, or `oe-core` since the last internal release. + +- [Opentrons changes since the latest stable release](https://github.com/Opentrons/opentrons/compare/v7.5.0...ot3@2.0.0-alpha.4) +- [Opentrons changes since the last internal release](https://github.com/Opentrons/opentrons/compare/ot3@2.0.0-alpha.3...ot3@2.0.0-alpha.4) +- [Flex changes since last stable release](https://github.com/Opentrons/oe-core/compare/v0.6.4...internal@2.0.0-alpha.3) +- [Flex firmware changes since last stable release](https://github.com/Opentrons/ot3-firmware/compare/v52...internal@v10) +- [OT2 changes since last stable release](https://github.com/Opentrons/buildroot/compare/v1.17.7...internal@2.0.0-alpha.0) + ## Internal Release 2.0.0-alpha.3 This internal release, pulled from the `edge` branch, contains features being developed for 8.0.0. It's for internal testing only. From 851a23a26c69421e1bd392a1fc436bd3ce995439 Mon Sep 17 00:00:00 2001 From: Sarah Breen Date: Thu, 1 Aug 2024 12:47:12 -0400 Subject: [PATCH 29/49] fix(components): invert cornerOffsetFromSlot values if adapter orientation is flipped (#15866) --- components/src/hardware-sim/Labware/LabwareRender.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/components/src/hardware-sim/Labware/LabwareRender.tsx b/components/src/hardware-sim/Labware/LabwareRender.tsx index 9b2365b668c..d6d1f2a0c79 100644 --- a/components/src/hardware-sim/Labware/LabwareRender.tsx +++ b/components/src/hardware-sim/Labware/LabwareRender.tsx @@ -78,7 +78,11 @@ export const LabwareRender = (props: LabwareRenderProps): JSX.Element => { } > Date: Thu, 1 Aug 2024 15:20:21 -0400 Subject: [PATCH 30/49] feature(robot-server, api): add whether the run has entered recovery mode in GET run by id (#15843) # Overview part of https://opentrons.atlassian.net/browse/EXEC-504. add full command error list to commands state. add whether the run has entered recovery mode and return it in GET /run/{runId} ## Test Plan and Hands on Testing Tested on dev server, Should test on a robot as well. - manually raise `TipNotAttachedError` from pickup tip command. - posted a protocol with a pickup tip command. - GET `/run/{runId}` should return the full error list in the response. `{ "data": { "id": "2a3375f6-0773-445f-aefd-2108bc7d376e", "ok": true, "createdAt": "2024-07-31T13:36:25.543796+00:00", "status": "stopped", "current": true, "actions": [ { "id": "3a3204f0-a8f0-4fbf-a5c3-19261f7a465f", "createdAt": "2024-07-31T13:36:39.326489+00:00", "actionType": "play" }, { "id": "6d66da93-fddb-4a9d-a4e1-5966158c1945", "createdAt": "2024-07-31T13:37:18.836018+00:00", "actionType": "resume-from-recovery" }, { "id": "8a54bc88-e5f3-48bc-ba80-411f2870515d", "createdAt": "2024-07-31T13:37:54.278171+00:00", "actionType": "stop" } ], "errors": [], "hasEverEnteredErrorRecovery": true, "pipettes": [ { "id": "2a652542-3b81-4c03-8a60-d3f7c33cb5b1", "pipetteName": "p1000_single_flex", "mount": "left" } ], "modules": [], "labware": [ { "id": "562293ac-6120-4bdb-9a0f-3a2736c4dae3", "loadName": "opentrons_flex_96_tiprack_50ul", "definitionUri": "opentrons/opentrons_flex_96_tiprack_50ul/1", "location": { "slotName": "C2" } }, { "id": "5e7c2ff5-46dd-443c-abff-f4e2c2e82473", "loadName": "opentrons_96_wellplate_200ul_pcr_full_skirt", "definitionUri": "opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", "location": { "slotName": "C3" } } ], "liquids": [], "labwareOffsets": [], "runTimeParameters": [], "protocolId": "b93d9f73-bb43-4c57-ba2c-16aedc36d06f", "completedAt": "2024-07-31T13:37:54.295804+00:00", "startedAt": "2024-07-31T13:36:39.330993+00:00" } }` ## Changelog - every `FailCommandAction` will now append the `ErrorOccurenece` to the full error list. - set state has_entered_error_recovery to True when issuing a `ResumeFromRecoveryAction`. - `StateSummery` will get hasEverEnteredErrorRecovery. - `hasEverEnteredErrorRecovery` added the `BadRun` and `Run` result. - `hasEverEnteredErrorRecovery` added to ts LegacyRun ## Review requests changes make sense? ## Risk assessment low. should not effect existing code. --------- Co-authored-by: Max Marrone Co-authored-by: Seth Foster --- api-client/src/runs/types.ts | 1 + api/src/opentrons/cli/analyze.py | 1 + .../protocol_engine/state/commands.py | 18 +++++++ .../opentrons/protocol_engine/state/state.py | 1 + .../protocol_engine/state/state_summary.py | 1 + .../state/test_command_state.py | 54 +++++++++++++++++-- .../state/test_command_store_old.py | 22 ++++++++ .../state/test_command_view_old.py | 4 ++ .../InterventionModal/__fixtures__/index.ts | 1 + .../RecentRunProtocolCarousel.test.tsx | 1 + .../RunTimeControl/__fixtures__/index.ts | 8 +++ .../src/runs/__fixtures__/runs.ts | 2 + .../maintenance_run_data_manager.py | 2 + .../maintenance_run_models.py | 4 ++ .../robot_server/runs/run_data_manager.py | 3 ++ robot-server/robot_server/runs/run_models.py | 8 +++ .../test_json_v6_protocol_run.tavern.yaml | 1 + .../test_json_v7_protocol_run.tavern.yaml | 1 + .../runs/test_protocol_run.tavern.yaml | 2 + ...t_run_queued_protocol_commands.tavern.yaml | 1 + ...t_run_with_run_time_parameters.tavern.yaml | 1 + .../router/test_base_router.py | 4 ++ .../router/test_labware_router.py | 1 + .../maintenance_runs/test_run_data_manager.py | 4 ++ .../tests/protocols/test_protocol_analyzer.py | 1 + .../tests/runs/router/test_base_router.py | 8 +++ .../tests/runs/router/test_labware_router.py | 1 + .../tests/runs/test_run_controller.py | 1 + .../tests/runs/test_run_data_manager.py | 12 +++++ robot-server/tests/runs/test_run_store.py | 2 + 30 files changed, 167 insertions(+), 4 deletions(-) diff --git a/api-client/src/runs/types.ts b/api-client/src/runs/types.ts index 1986a34a0b8..19127b70bba 100644 --- a/api-client/src/runs/types.ts +++ b/api-client/src/runs/types.ts @@ -47,6 +47,7 @@ export interface LegacyGoodRunData { status: RunStatus actions: RunAction[] errors: RunError[] + hasEverEnteredErrorRecovery: boolean pipettes: LoadedPipette[] labware: LoadedLabware[] liquids: Liquid[] diff --git a/api/src/opentrons/cli/analyze.py b/api/src/opentrons/cli/analyze.py index 0daacd711b3..7270e517644 100644 --- a/api/src/opentrons/cli/analyze.py +++ b/api/src/opentrons/cli/analyze.py @@ -275,6 +275,7 @@ async def _do_analyze(protocol_source: ProtocolSource) -> RunResult: modules=[], labwareOffsets=[], liquids=[], + hasEverEnteredErrorRecovery=False, ), parameters=[], ) diff --git a/api/src/opentrons/protocol_engine/state/commands.py b/api/src/opentrons/protocol_engine/state/commands.py index 9989f9aec01..1ad17867450 100644 --- a/api/src/opentrons/protocol_engine/state/commands.py +++ b/api/src/opentrons/protocol_engine/state/commands.py @@ -203,6 +203,12 @@ class CommandState: This value can be used to generate future hashes. """ + failed_command_errors: List[ErrorOccurrence] + """List of command errors that occurred during run execution.""" + + has_entered_error_recovery: bool + """Whether the run has entered error recovery.""" + stopped_by_estop: bool """If this is set to True, the engine was stopped by an estop event.""" @@ -238,7 +244,9 @@ def __init__( run_started_at=None, latest_protocol_command_hash=None, stopped_by_estop=False, + failed_command_errors=[], error_recovery_policy=error_recovery_policy, + has_entered_error_recovery=False, ) def handle_action(self, action: Action) -> None: @@ -330,6 +338,7 @@ def _handle_fail_command_action(self, action: FailCommandAction) -> None: notes=action.notes, ) self._state.failed_command = self._state.command_history.get(action.command_id) + self._state.failed_command_errors.append(public_error_occurrence) other_command_ids_to_fail: List[str] if prev_entry.command.intent == CommandIntent.SETUP: @@ -373,6 +382,7 @@ def _handle_fail_command_action(self, action: FailCommandAction) -> None: ): self._state.queue_status = QueueStatus.AWAITING_RECOVERY self._state.recovery_target_command_id = action.command_id + self._state.has_entered_error_recovery = True def _handle_play_action(self, action: PlayAction) -> None: if not self._state.run_result: @@ -635,6 +645,14 @@ def get_error(self) -> Optional[ErrorOccurrence]: else: return run_error or finish_error + def get_all_errors(self) -> List[ErrorOccurrence]: + """Get the run's full error list, if there was none, returns an empty list.""" + return self._state.failed_command_errors + + def get_has_entered_recovery_mode(self) -> bool: + """Get whether the run has entered recovery mode.""" + return self._state.has_entered_error_recovery + def get_running_command_id(self) -> Optional[str]: """Return the ID of the command that's currently running, if there is one.""" running_command = self._state.command_history.get_running_command() diff --git a/api/src/opentrons/protocol_engine/state/state.py b/api/src/opentrons/protocol_engine/state/state.py index 430ca1e5738..a05d529f50a 100644 --- a/api/src/opentrons/protocol_engine/state/state.py +++ b/api/src/opentrons/protocol_engine/state/state.py @@ -129,6 +129,7 @@ def get_summary(self) -> StateSummary: completedAt=self._state.commands.run_completed_at, startedAt=self._state.commands.run_started_at, liquids=self._liquid.get_all(), + hasEverEnteredErrorRecovery=self._commands.get_has_entered_recovery_mode(), ) diff --git a/api/src/opentrons/protocol_engine/state/state_summary.py b/api/src/opentrons/protocol_engine/state/state_summary.py index c7185cc2c0d..7e6e003aaa8 100644 --- a/api/src/opentrons/protocol_engine/state/state_summary.py +++ b/api/src/opentrons/protocol_engine/state/state_summary.py @@ -21,6 +21,7 @@ class StateSummary(BaseModel): # errors is a list for historical reasons. (This model needs to stay compatible with # robot-server's database.) It shouldn't have more than 1 element. errors: List[ErrorOccurrence] + hasEverEnteredErrorRecovery: bool = Field(default=False) labware: List[LoadedLabware] pipettes: List[LoadedPipette] modules: List[LoadedModule] diff --git a/api/tests/opentrons/protocol_engine/state/test_command_state.py b/api/tests/opentrons/protocol_engine/state/test_command_state.py index 9ebb338d85c..afafcc3cabe 100644 --- a/api/tests/opentrons/protocol_engine/state/test_command_state.py +++ b/api/tests/opentrons/protocol_engine/state/test_command_state.py @@ -192,6 +192,7 @@ def test_command_failure(error_recovery_type: ErrorRecoveryType) -> None: ) assert subject_view.get("command-id") == expected_failed_command + assert subject.state.failed_command_errors == [expected_error_occurrence] def test_command_failure_clears_queues() -> None: @@ -227,12 +228,20 @@ def test_command_failure_clears_queues() -> None: started_at=datetime(year=2022, month=2, day=2), ) subject.handle_action(run_1) + expected_error = errors.ProtocolEngineError(message="oh no") + expected_error_occurance = errors.ErrorOccurrence( + id="error-id", + errorType="ProtocolEngineError", + createdAt=datetime(year=2023, month=3, day=3), + detail="oh no", + errorCode=ErrorCodes.GENERAL_ERROR.value.code, + ) fail_1 = actions.FailCommandAction( command_id="command-id-1", running_command=subject_view.get("command-id-1"), error_id="error-id", failed_at=datetime(year=2023, month=3, day=3), - error=errors.ProtocolEngineError(message="oh no"), + error=expected_error, notes=[], type=ErrorRecoveryType.FAIL_RUN, ) @@ -245,6 +254,7 @@ def test_command_failure_clears_queues() -> None: assert subject_view.get_running_command_id() is None assert subject_view.get_queue_ids() == OrderedSet() assert subject_view.get_next_to_execute() is None + assert subject.state.failed_command_errors == [expected_error_occurance] def test_setup_command_failure_only_clears_setup_command_queue() -> None: @@ -489,12 +499,20 @@ def test_door_during_error_recovery() -> None: started_at=datetime(year=2022, month=2, day=2), ) subject.handle_action(run_1) + expected_error = errors.ProtocolEngineError(message="oh no") + expected_error_occurance = errors.ErrorOccurrence( + id="error-id", + errorType="ProtocolEngineError", + createdAt=datetime(year=2023, month=3, day=3), + detail="oh no", + errorCode=ErrorCodes.GENERAL_ERROR.value.code, + ) fail_1 = actions.FailCommandAction( command_id="command-id-1", running_command=subject_view.get("command-id-1"), error_id="error-id", failed_at=datetime(year=2023, month=3, day=3), - error=errors.ProtocolEngineError(message="oh no"), + error=expected_error, notes=[], type=ErrorRecoveryType.WAIT_FOR_RECOVERY, ) @@ -536,6 +554,7 @@ def test_door_during_error_recovery() -> None: subject.handle_action(play) assert subject_view.get_status() == EngineStatus.AWAITING_RECOVERY assert subject_view.get_next_to_execute() == "command-id-2" + assert subject.state.failed_command_errors == [expected_error_occurance] @pytest.mark.parametrize( @@ -605,7 +624,7 @@ def test_error_recovery_type_tracking() -> None: command_id="c1", running_command=running_command_1, error_id="c1-error", - failed_at=datetime.now(), + failed_at=datetime(year=2023, month=3, day=3), error=PythonException(RuntimeError("new sheriff in town")), notes=[], type=ErrorRecoveryType.WAIT_FOR_RECOVERY, @@ -620,7 +639,7 @@ def test_error_recovery_type_tracking() -> None: command_id="c2", running_command=running_command_2, error_id="c2-error", - failed_at=datetime.now(), + failed_at=datetime(year=2023, month=3, day=3), error=PythonException(RuntimeError("new sheriff in town")), notes=[], type=ErrorRecoveryType.FAIL_RUN, @@ -631,6 +650,19 @@ def test_error_recovery_type_tracking() -> None: assert view.get_error_recovery_type("c1") == ErrorRecoveryType.WAIT_FOR_RECOVERY assert view.get_error_recovery_type("c2") == ErrorRecoveryType.FAIL_RUN + exception = PythonException(RuntimeError("new sheriff in town")) + error_occurrence_1 = ErrorOccurrence.from_failed( + id="c1-error", createdAt=datetime(year=2023, month=3, day=3), error=exception + ) + error_occurrence_2 = ErrorOccurrence.from_failed( + id="c2-error", createdAt=datetime(year=2023, month=3, day=3), error=exception + ) + + assert subject.state.failed_command_errors == [ + error_occurrence_1, + error_occurrence_2, + ] + def test_recovery_target_tracking() -> None: """It should keep track of the command currently undergoing error recovery.""" @@ -729,6 +761,8 @@ def test_recovery_target_tracking() -> None: assert subject_view.get_recovery_target() is None assert not subject_view.get_recovery_in_progress_for_command("c3") + assert subject_view.get_has_entered_recovery_mode() is True + def test_final_state_after_estop() -> None: """Test the final state of the run after it's E-stopped.""" @@ -761,6 +795,7 @@ def test_final_state_after_estop() -> None: assert subject_view.get_status() == EngineStatus.FAILED assert subject_view.get_error() == expected_error_occurrence + assert subject_view.get_all_errors() == [] def test_final_state_after_stop() -> None: @@ -833,6 +868,13 @@ def test_final_state_after_error_recovery_stop() -> None: notes=[], type=ErrorRecoveryType.WAIT_FOR_RECOVERY, ) + expected_error_occurrence_1 = ErrorOccurrence( + id="error-id", + createdAt=datetime(year=2023, month=3, day=3), + errorCode=ErrorCodes.GENERAL_ERROR.value.code, + errorType="ProtocolEngineError", + detail="oh no", + ) subject.handle_action(fail_1) assert subject_view.get_status() == EngineStatus.AWAITING_RECOVERY @@ -856,9 +898,13 @@ def test_final_state_after_error_recovery_stop() -> None: finish_error_details=None, ) ) + assert subject_view.get_status() == EngineStatus.STOPPED assert subject_view.get_recovery_target() is None assert subject_view.get_error() is None + assert subject_view.get_all_errors() == [ + expected_error_occurrence_1, + ] def test_set_and_get_error_recovery_policy() -> None: diff --git a/api/tests/opentrons/protocol_engine/state/test_command_store_old.py b/api/tests/opentrons/protocol_engine/state/test_command_store_old.py index 92fba9b4851..018634db435 100644 --- a/api/tests/opentrons/protocol_engine/state/test_command_store_old.py +++ b/api/tests/opentrons/protocol_engine/state/test_command_store_old.py @@ -337,7 +337,9 @@ def test_command_store_handles_pause_action(pause_source: PauseSource) -> None: recovery_target_command_id=None, latest_protocol_command_hash=None, stopped_by_estop=False, + failed_command_errors=[], error_recovery_policy=matchers.Anything(), + has_entered_error_recovery=False, ) @@ -365,7 +367,9 @@ def test_command_store_handles_play_action(pause_source: PauseSource) -> None: run_started_at=datetime(year=2021, month=1, day=1), latest_protocol_command_hash=None, stopped_by_estop=False, + failed_command_errors=[], error_recovery_policy=matchers.Anything(), + has_entered_error_recovery=False, ) assert subject.state.command_history.get_running_command() is None assert subject.state.command_history.get_all_ids() == [] @@ -398,7 +402,9 @@ def test_command_store_handles_finish_action() -> None: run_started_at=datetime(year=2021, month=1, day=1), latest_protocol_command_hash=None, stopped_by_estop=False, + failed_command_errors=[], error_recovery_policy=matchers.Anything(), + has_entered_error_recovery=False, ) assert subject.state.command_history.get_running_command() is None assert subject.state.command_history.get_all_ids() == [] @@ -451,7 +457,9 @@ def test_command_store_handles_stop_action( run_started_at=datetime(year=2021, month=1, day=1), latest_protocol_command_hash=None, stopped_by_estop=from_estop, + failed_command_errors=[], error_recovery_policy=matchers.Anything(), + has_entered_error_recovery=False, ) assert subject.state.command_history.get_running_command() is None assert subject.state.command_history.get_all_ids() == [] @@ -487,7 +495,9 @@ def test_command_store_handles_stop_action_when_awaiting_recovery() -> None: run_started_at=datetime(year=2021, month=1, day=1), latest_protocol_command_hash=None, stopped_by_estop=False, + failed_command_errors=[], error_recovery_policy=matchers.Anything(), + has_entered_error_recovery=False, ) assert subject.state.command_history.get_running_command() is None assert subject.state.command_history.get_all_ids() == [] @@ -519,7 +529,9 @@ def test_command_store_cannot_restart_after_should_stop() -> None: run_started_at=None, latest_protocol_command_hash=None, stopped_by_estop=False, + failed_command_errors=[], error_recovery_policy=matchers.Anything(), + has_entered_error_recovery=False, ) assert subject.state.command_history.get_running_command() is None assert subject.state.command_history.get_all_ids() == [] @@ -664,7 +676,9 @@ def test_command_store_wraps_unknown_errors() -> None: recovery_target_command_id=None, latest_protocol_command_hash=None, stopped_by_estop=False, + failed_command_errors=[], error_recovery_policy=matchers.Anything(), + has_entered_error_recovery=False, ) assert subject.state.command_history.get_running_command() is None assert subject.state.command_history.get_all_ids() == [] @@ -732,7 +746,9 @@ def __init__(self, message: str) -> None: run_started_at=None, latest_protocol_command_hash=None, stopped_by_estop=False, + failed_command_errors=[], error_recovery_policy=matchers.Anything(), + has_entered_error_recovery=False, ) assert subject.state.command_history.get_running_command() is None assert subject.state.command_history.get_all_ids() == [] @@ -766,7 +782,9 @@ def test_command_store_ignores_stop_after_graceful_finish() -> None: run_started_at=datetime(year=2021, month=1, day=1), latest_protocol_command_hash=None, stopped_by_estop=False, + failed_command_errors=[], error_recovery_policy=matchers.Anything(), + has_entered_error_recovery=False, ) assert subject.state.command_history.get_running_command() is None assert subject.state.command_history.get_all_ids() == [] @@ -800,7 +818,9 @@ def test_command_store_ignores_finish_after_non_graceful_stop() -> None: run_started_at=datetime(year=2021, month=1, day=1), latest_protocol_command_hash=None, stopped_by_estop=False, + failed_command_errors=[], error_recovery_policy=matchers.Anything(), + has_entered_error_recovery=False, ) assert subject.state.command_history.get_running_command() is None assert subject.state.command_history.get_all_ids() == [] @@ -834,7 +854,9 @@ def test_handles_hardware_stopped() -> None: run_started_at=None, latest_protocol_command_hash=None, stopped_by_estop=False, + failed_command_errors=[], error_recovery_policy=matchers.Anything(), + has_entered_error_recovery=False, ) assert subject.state.command_history.get_running_command() is None assert subject.state.command_history.get_all_ids() == [] diff --git a/api/tests/opentrons/protocol_engine/state/test_command_view_old.py b/api/tests/opentrons/protocol_engine/state/test_command_view_old.py index f64f4a09d2d..2b86fe9259f 100644 --- a/api/tests/opentrons/protocol_engine/state/test_command_view_old.py +++ b/api/tests/opentrons/protocol_engine/state/test_command_view_old.py @@ -72,6 +72,8 @@ def get_command_view( # noqa: C901 finish_error: Optional[errors.ErrorOccurrence] = None, commands: Sequence[cmd.Command] = (), latest_command_hash: Optional[str] = None, + failed_command_errors: Optional[List[ErrorOccurrence]] = None, + has_entered_error_recovery: bool = False, ) -> CommandView: """Get a command view test subject.""" command_history = CommandHistory() @@ -108,6 +110,8 @@ def get_command_view( # noqa: C901 run_started_at=run_started_at, latest_protocol_command_hash=latest_command_hash, stopped_by_estop=False, + failed_command_errors=failed_command_errors or [], + has_entered_error_recovery=has_entered_error_recovery, error_recovery_policy=_placeholder_error_recovery_policy, ) diff --git a/app/src/organisms/InterventionModal/__fixtures__/index.ts b/app/src/organisms/InterventionModal/__fixtures__/index.ts index 7e9f57090e4..2594aace49e 100644 --- a/app/src/organisms/InterventionModal/__fixtures__/index.ts +++ b/app/src/organisms/InterventionModal/__fixtures__/index.ts @@ -192,6 +192,7 @@ export const mockRunData: RunData = { status: 'running', actions: [], errors: [], + hasEverEnteredErrorRecovery: false, pipettes: [], labware: [mockLabwareOnModule, mockLabwareOnSlot, mockLabwareOffDeck], modules: [mockModule], diff --git a/app/src/organisms/OnDeviceDisplay/RobotDashboard/__tests__/RecentRunProtocolCarousel.test.tsx b/app/src/organisms/OnDeviceDisplay/RobotDashboard/__tests__/RecentRunProtocolCarousel.test.tsx index 8bc3a481843..f029d739806 100644 --- a/app/src/organisms/OnDeviceDisplay/RobotDashboard/__tests__/RecentRunProtocolCarousel.test.tsx +++ b/app/src/organisms/OnDeviceDisplay/RobotDashboard/__tests__/RecentRunProtocolCarousel.test.tsx @@ -18,6 +18,7 @@ const mockRun = { createdAt: '2023-04-12T15:13:52.110602+00:00', current: false, errors: [], + hasEverEnteredErrorRecovery: false, id: '853a3fae-8043-47de-8f03-5d28b3ee3d35', labware: [], labwareOffsets: [], diff --git a/app/src/organisms/RunTimeControl/__fixtures__/index.ts b/app/src/organisms/RunTimeControl/__fixtures__/index.ts index 235c49fbcde..49dadfe85ea 100644 --- a/app/src/organisms/RunTimeControl/__fixtures__/index.ts +++ b/app/src/organisms/RunTimeControl/__fixtures__/index.ts @@ -37,6 +37,7 @@ export const mockPausedRun: RunData = { }, ], errors: [], + hasEverEnteredErrorRecovery: false, pipettes: [], labware: [], modules: [], @@ -68,6 +69,7 @@ export const mockRunningRun: RunData = { }, ], errors: [], + hasEverEnteredErrorRecovery: false, pipettes: [], labware: [], modules: [], @@ -110,6 +112,7 @@ export const mockFailedRun: RunData = { errorCode: '4000', }, ], + hasEverEnteredErrorRecovery: false, pipettes: [], labware: [], modules: [], @@ -146,6 +149,7 @@ export const mockStopRequestedRun: RunData = { }, ], errors: [], + hasEverEnteredErrorRecovery: false, pipettes: [], labware: [], modules: [], @@ -182,6 +186,7 @@ export const mockStoppedRun: RunData = { }, ], errors: [], + hasEverEnteredErrorRecovery: false, pipettes: [], labware: [], modules: [], @@ -213,6 +218,7 @@ export const mockSucceededRun: RunData = { }, ], errors: [], + hasEverEnteredErrorRecovery: false, pipettes: [], labware: [], modules: [], @@ -228,6 +234,7 @@ export const mockIdleUnstartedRun: RunData = { protocolId: PROTOCOL_ID, actions: [], errors: [], + hasEverEnteredErrorRecovery: false, pipettes: [], labware: [], modules: [], @@ -259,6 +266,7 @@ export const mockIdleStartedRun: RunData = { }, ], errors: [], + hasEverEnteredErrorRecovery: false, pipettes: [], labware: [], modules: [], diff --git a/react-api-client/src/runs/__fixtures__/runs.ts b/react-api-client/src/runs/__fixtures__/runs.ts index 33ae7cb4b4d..2222e6562d7 100644 --- a/react-api-client/src/runs/__fixtures__/runs.ts +++ b/react-api-client/src/runs/__fixtures__/runs.ts @@ -27,6 +27,7 @@ export const mockPausedRun: RunData = { }, ], errors: [], + hasEverEnteredErrorRecovery: false, pipettes: [], labware: [], modules: [], @@ -58,6 +59,7 @@ export const mockRunningRun: RunData = { }, ], errors: [], + hasEverEnteredErrorRecovery: false, pipettes: [], labware: [], modules: [], diff --git a/robot-server/robot_server/maintenance_runs/maintenance_run_data_manager.py b/robot-server/robot_server/maintenance_runs/maintenance_run_data_manager.py index 7b255a6f79d..6f2cddd1835 100644 --- a/robot-server/robot_server/maintenance_runs/maintenance_run_data_manager.py +++ b/robot-server/robot_server/maintenance_runs/maintenance_run_data_manager.py @@ -32,6 +32,7 @@ def _build_run( pipettes=[], modules=[], liquids=[], + hasEverEnteredErrorRecovery=False, ) return MaintenanceRun.construct( id=run_id, @@ -47,6 +48,7 @@ def _build_run( completedAt=state_summary.completedAt, startedAt=state_summary.startedAt, liquids=state_summary.liquids, + hasEverEnteredErrorRecovery=state_summary.hasEverEnteredErrorRecovery, ) diff --git a/robot-server/robot_server/maintenance_runs/maintenance_run_models.py b/robot-server/robot_server/maintenance_runs/maintenance_run_models.py index 766c717e7bf..e4c5971f5d1 100644 --- a/robot-server/robot_server/maintenance_runs/maintenance_run_models.py +++ b/robot-server/robot_server/maintenance_runs/maintenance_run_models.py @@ -47,6 +47,10 @@ class MaintenanceRun(ResourceModel): " but it won't have more than one element." ), ) + hasEverEnteredErrorRecovery: bool = Field( + ..., + description=("Whether the run has entered error recovery."), + ) pipettes: List[LoadedPipette] = Field( ..., description="Pipettes that have been loaded into the run.", diff --git a/robot-server/robot_server/runs/run_data_manager.py b/robot-server/robot_server/runs/run_data_manager.py index 5ae7581d952..de5eea82e45 100644 --- a/robot-server/robot_server/runs/run_data_manager.py +++ b/robot-server/robot_server/runs/run_data_manager.py @@ -44,6 +44,7 @@ def _build_run( actions=run_resource.actions, status=state_summary.status, errors=state_summary.errors, + hasEverEnteredErrorRecovery=state_summary.hasEverEnteredErrorRecovery, labware=state_summary.labware, labwareOffsets=state_summary.labwareOffsets, pipettes=state_summary.pipettes, @@ -65,6 +66,7 @@ def _build_run( pipettes=[], modules=[], liquids=[], + hasEverEnteredErrorRecovery=False, ) errors.append(state_summary.dataError) else: @@ -107,6 +109,7 @@ def _build_run( startedAt=state.startedAt, liquids=state.liquids, runTimeParameters=run_time_parameters, + hasEverEnteredErrorRecovery=state.hasEverEnteredErrorRecovery, ) diff --git a/robot-server/robot_server/runs/run_models.py b/robot-server/robot_server/runs/run_models.py index 5d2fb0a41ca..45ad22e3167 100644 --- a/robot-server/robot_server/runs/run_models.py +++ b/robot-server/robot_server/runs/run_models.py @@ -110,6 +110,10 @@ class Run(ResourceModel): " but it won't have more than one element." ), ) + hasEverEnteredErrorRecovery: bool = Field( + ..., + description=("Whether the run has entered error recovery."), + ) pipettes: List[LoadedPipette] = Field( ..., description="Pipettes that have been loaded into the run.", @@ -183,6 +187,10 @@ class BadRun(ResourceModel): " but it won't have more than one element." ), ) + hasEverEnteredErrorRecovery: bool = Field( + ..., + description=("Whether the run has entered error recovery."), + ) pipettes: List[LoadedPipette] = Field( ..., description="Pipettes that have been loaded into the run.", diff --git a/robot-server/tests/integration/http_api/runs/test_json_v6_protocol_run.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_json_v6_protocol_run.tavern.yaml index a22c3c3d74a..28d39bcfa77 100644 --- a/robot-server/tests/integration/http_api/runs/test_json_v6_protocol_run.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_json_v6_protocol_run.tavern.yaml @@ -37,6 +37,7 @@ stages: current: True actions: [] errors: [] + hasEverEnteredErrorRecovery: False pipettes: [] modules: [] labware: diff --git a/robot-server/tests/integration/http_api/runs/test_json_v7_protocol_run.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_json_v7_protocol_run.tavern.yaml index 7aaec1dd822..70737a7f6c3 100644 --- a/robot-server/tests/integration/http_api/runs/test_json_v7_protocol_run.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_json_v7_protocol_run.tavern.yaml @@ -37,6 +37,7 @@ stages: current: True actions: [] errors: [] + hasEverEnteredErrorRecovery: False pipettes: [] modules: [] labware: diff --git a/robot-server/tests/integration/http_api/runs/test_protocol_run.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_protocol_run.tavern.yaml index 159b1238986..2bfa2ccd552 100644 --- a/robot-server/tests/integration/http_api/runs/test_protocol_run.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_protocol_run.tavern.yaml @@ -34,6 +34,7 @@ stages: current: True actions: [] errors: [] + hasEverEnteredErrorRecovery: False pipettes: [] modules: [] labware: @@ -241,6 +242,7 @@ stages: runTimeParameters: [] completedAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" errors: [] + hasEverEnteredErrorRecovery: False pipettes: [] modules: [] protocolId: '{protocol_id}' diff --git a/robot-server/tests/integration/http_api/runs/test_run_queued_protocol_commands.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_run_queued_protocol_commands.tavern.yaml index 074f68b5456..edec26c4e03 100644 --- a/robot-server/tests/integration/http_api/runs/test_run_queued_protocol_commands.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_run_queued_protocol_commands.tavern.yaml @@ -90,6 +90,7 @@ stages: createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" current: True errors: [] + hasEverEnteredErrorRecovery: False id: '{run_id}' labware: [] labwareOffsets: [] diff --git a/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml index 2a71179b37d..9d91abea32f 100644 --- a/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml @@ -43,6 +43,7 @@ stages: current: True actions: [] errors: [] + hasEverEnteredErrorRecovery: False pipettes: [] modules: [] labware: [] diff --git a/robot-server/tests/maintenance_runs/router/test_base_router.py b/robot-server/tests/maintenance_runs/router/test_base_router.py index 87881f982c2..b363cd1e6ac 100644 --- a/robot-server/tests/maintenance_runs/router/test_base_router.py +++ b/robot-server/tests/maintenance_runs/router/test_base_router.py @@ -75,6 +75,7 @@ async def test_create_run( labwareOffsets=[], status=pe_types.EngineStatus.IDLE, liquids=[], + hasEverEnteredErrorRecovery=False, ) decoy.when( @@ -146,6 +147,7 @@ async def test_get_run_data_from_url( labware=[], labwareOffsets=[], liquids=[], + hasEverEnteredErrorRecovery=False, ) decoy.when(mock_maintenance_run_data_manager.get("run-id")).then_return( @@ -195,6 +197,7 @@ async def test_get_run() -> None: labware=[], labwareOffsets=[], liquids=[], + hasEverEnteredErrorRecovery=False, ) result = await get_run(run_data=run_data) @@ -220,6 +223,7 @@ async def test_get_current_run( labware=[], labwareOffsets=[], liquids=[], + hasEverEnteredErrorRecovery=False, ) decoy.when(mock_maintenance_run_data_manager.current_run_id).then_return( "current-run-id" diff --git a/robot-server/tests/maintenance_runs/router/test_labware_router.py b/robot-server/tests/maintenance_runs/router/test_labware_router.py index cb88688731e..d8a8fdab603 100644 --- a/robot-server/tests/maintenance_runs/router/test_labware_router.py +++ b/robot-server/tests/maintenance_runs/router/test_labware_router.py @@ -38,6 +38,7 @@ def run() -> MaintenanceRun: modules=[], labwareOffsets=[], liquids=[], + hasEverEnteredErrorRecovery=False, ) diff --git a/robot-server/tests/maintenance_runs/test_run_data_manager.py b/robot-server/tests/maintenance_runs/test_run_data_manager.py index f1127f287fb..7baffe86a29 100644 --- a/robot-server/tests/maintenance_runs/test_run_data_manager.py +++ b/robot-server/tests/maintenance_runs/test_run_data_manager.py @@ -63,6 +63,7 @@ def engine_state_summary() -> StateSummary: return StateSummary( status=EngineStatus.IDLE, errors=[ErrorOccurrence.construct(id="some-error-id")], # type: ignore[call-arg] + hasEverEnteredErrorRecovery=False, labware=[LoadedLabware.construct(id="some-labware-id")], # type: ignore[call-arg] labwareOffsets=[LabwareOffset.construct(id="some-labware-offset-id")], # type: ignore[call-arg] pipettes=[LoadedPipette.construct(id="some-pipette-id")], # type: ignore[call-arg] @@ -132,6 +133,7 @@ async def test_create( status=engine_state_summary.status, actions=[], errors=engine_state_summary.errors, + hasEverEnteredErrorRecovery=engine_state_summary.hasEverEnteredErrorRecovery, labware=engine_state_summary.labware, labwareOffsets=engine_state_summary.labwareOffsets, pipettes=engine_state_summary.pipettes, @@ -184,6 +186,7 @@ async def test_create_with_options( status=engine_state_summary.status, actions=[], errors=engine_state_summary.errors, + hasEverEnteredErrorRecovery=engine_state_summary.hasEverEnteredErrorRecovery, labware=engine_state_summary.labware, labwareOffsets=engine_state_summary.labwareOffsets, pipettes=engine_state_summary.pipettes, @@ -252,6 +255,7 @@ async def test_get_current_run( status=engine_state_summary.status, actions=[], errors=engine_state_summary.errors, + hasEverEnteredErrorRecovery=engine_state_summary.hasEverEnteredErrorRecovery, labware=engine_state_summary.labware, labwareOffsets=engine_state_summary.labwareOffsets, pipettes=engine_state_summary.pipettes, diff --git a/robot-server/tests/protocols/test_protocol_analyzer.py b/robot-server/tests/protocols/test_protocol_analyzer.py index 1e9c004999a..a5eb40b95bc 100644 --- a/robot-server/tests/protocols/test_protocol_analyzer.py +++ b/robot-server/tests/protocols/test_protocol_analyzer.py @@ -189,6 +189,7 @@ async def test_analyze( modules=[], labwareOffsets=[], liquids=[], + hasEverEnteredErrorRecovery=False, ), parameters=[bool_parameter], ) diff --git a/robot-server/tests/runs/router/test_base_router.py b/robot-server/tests/runs/router/test_base_router.py index 8ee37d64b9d..979d3a92371 100644 --- a/robot-server/tests/runs/router/test_base_router.py +++ b/robot-server/tests/runs/router/test_base_router.py @@ -84,6 +84,7 @@ async def test_create_run( labwareOffsets=[], status=pe_types.EngineStatus.IDLE, liquids=[], + hasEverEnteredErrorRecovery=False, ) decoy.when( await mock_deck_configuration_store.get_deck_configuration() @@ -159,6 +160,7 @@ async def test_create_protocol_run( labwareOffsets=[], status=pe_types.EngineStatus.IDLE, liquids=[], + hasEverEnteredErrorRecovery=False, ) decoy.when( await mock_deck_configuration_store.get_deck_configuration() @@ -280,6 +282,7 @@ async def test_get_run_data_from_url( labware=[], labwareOffsets=[], liquids=[], + hasEverEnteredErrorRecovery=False, ) decoy.when(mock_run_data_manager.get("run-id")).then_return(expected_response) @@ -326,6 +329,7 @@ async def test_get_run() -> None: labware=[], labwareOffsets=[], liquids=[], + hasEverEnteredErrorRecovery=False, ) result = await get_run(run_data=run_data) @@ -371,6 +375,7 @@ async def test_get_runs_not_empty( labware=[], labwareOffsets=[], liquids=[], + hasEverEnteredErrorRecovery=False, ) response_2 = Run( @@ -386,6 +391,7 @@ async def test_get_runs_not_empty( labware=[], labwareOffsets=[], liquids=[], + hasEverEnteredErrorRecovery=False, ) decoy.when(mock_run_data_manager.get_all(20)).then_return([response_1, response_2]) @@ -464,6 +470,7 @@ async def test_update_run_to_not_current( labware=[], labwareOffsets=[], liquids=[], + hasEverEnteredErrorRecovery=False, ) decoy.when(await mock_run_data_manager.update("run-id", current=False)).then_return( @@ -498,6 +505,7 @@ async def test_update_current_none_noop( labware=[], labwareOffsets=[], liquids=[], + hasEverEnteredErrorRecovery=False, ) decoy.when(await mock_run_data_manager.update("run-id", current=None)).then_return( diff --git a/robot-server/tests/runs/router/test_labware_router.py b/robot-server/tests/runs/router/test_labware_router.py index 09811f20a38..9a38ce6cd0f 100644 --- a/robot-server/tests/runs/router/test_labware_router.py +++ b/robot-server/tests/runs/router/test_labware_router.py @@ -40,6 +40,7 @@ def run() -> Run: labwareOffsets=[], protocolId=None, liquids=[], + hasEverEnteredErrorRecovery=False, ) diff --git a/robot-server/tests/runs/test_run_controller.py b/robot-server/tests/runs/test_run_controller.py index c6d58b229dc..a901c988168 100644 --- a/robot-server/tests/runs/test_run_controller.py +++ b/robot-server/tests/runs/test_run_controller.py @@ -65,6 +65,7 @@ def engine_state_summary() -> StateSummary: pipettes=[], modules=[], liquids=[], + hasEverEnteredErrorRecovery=False, ) diff --git a/robot-server/tests/runs/test_run_data_manager.py b/robot-server/tests/runs/test_run_data_manager.py index 63716a8ebd5..ba0b457f9f6 100644 --- a/robot-server/tests/runs/test_run_data_manager.py +++ b/robot-server/tests/runs/test_run_data_manager.py @@ -86,6 +86,7 @@ def engine_state_summary() -> StateSummary: return StateSummary( status=EngineStatus.IDLE, errors=[ErrorOccurrence.construct(id="some-error-id")], # type: ignore[call-arg] + hasEverEnteredErrorRecovery=False, labware=[LoadedLabware.construct(id="some-labware-id")], # type: ignore[call-arg] labwareOffsets=[LabwareOffset.construct(id="some-labware-offset-id")], # type: ignore[call-arg] pipettes=[LoadedPipette.construct(id="some-pipette-id")], # type: ignore[call-arg] @@ -195,6 +196,7 @@ async def test_create( actions=run_resource.actions, status=engine_state_summary.status, errors=engine_state_summary.errors, + hasEverEnteredErrorRecovery=engine_state_summary.hasEverEnteredErrorRecovery, labware=engine_state_summary.labware, labwareOffsets=engine_state_summary.labwareOffsets, pipettes=engine_state_summary.pipettes, @@ -266,6 +268,7 @@ async def test_create_with_options( actions=run_resource.actions, status=engine_state_summary.status, errors=engine_state_summary.errors, + hasEverEnteredErrorRecovery=engine_state_summary.hasEverEnteredErrorRecovery, labware=engine_state_summary.labware, labwareOffsets=engine_state_summary.labwareOffsets, pipettes=engine_state_summary.pipettes, @@ -347,6 +350,7 @@ async def test_get_current_run( actions=run_resource.actions, status=engine_state_summary.status, errors=engine_state_summary.errors, + hasEverEnteredErrorRecovery=engine_state_summary.hasEverEnteredErrorRecovery, labware=engine_state_summary.labware, labwareOffsets=engine_state_summary.labwareOffsets, pipettes=engine_state_summary.pipettes, @@ -388,6 +392,7 @@ async def test_get_historical_run( actions=run_resource.actions, status=engine_state_summary.status, errors=engine_state_summary.errors, + hasEverEnteredErrorRecovery=engine_state_summary.hasEverEnteredErrorRecovery, labware=engine_state_summary.labware, labwareOffsets=engine_state_summary.labwareOffsets, pipettes=engine_state_summary.pipettes, @@ -430,6 +435,7 @@ async def test_get_historical_run_no_data( actions=run_resource.actions, status=EngineStatus.STOPPED, errors=[], + hasEverEnteredErrorRecovery=False, labware=[], labwareOffsets=[], pipettes=[], @@ -449,6 +455,7 @@ async def test_get_all_runs( current_run_data = StateSummary( status=EngineStatus.IDLE, errors=[ErrorOccurrence.construct(id="current-error-id")], # type: ignore[call-arg] + hasEverEnteredErrorRecovery=False, labware=[LoadedLabware.construct(id="current-labware-id")], # type: ignore[call-arg] labwareOffsets=[LabwareOffset.construct(id="current-labware-offset-id")], # type: ignore[call-arg] pipettes=[LoadedPipette.construct(id="current-pipette-id")], # type: ignore[call-arg] @@ -467,6 +474,7 @@ async def test_get_all_runs( historical_run_data = StateSummary( status=EngineStatus.STOPPED, errors=[ErrorOccurrence.construct(id="old-error-id")], # type: ignore[call-arg] + hasEverEnteredErrorRecovery=False, labware=[LoadedLabware.construct(id="old-labware-id")], # type: ignore[call-arg] labwareOffsets=[LabwareOffset.construct(id="old-labware-offset-id")], # type: ignore[call-arg] pipettes=[LoadedPipette.construct(id="old-pipette-id")], # type: ignore[call-arg] @@ -526,6 +534,7 @@ async def test_get_all_runs( actions=historical_run_resource.actions, status=historical_run_data.status, errors=historical_run_data.errors, + hasEverEnteredErrorRecovery=historical_run_data.hasEverEnteredErrorRecovery, labware=historical_run_data.labware, labwareOffsets=historical_run_data.labwareOffsets, pipettes=historical_run_data.pipettes, @@ -541,6 +550,7 @@ async def test_get_all_runs( actions=current_run_resource.actions, status=current_run_data.status, errors=current_run_data.errors, + hasEverEnteredErrorRecovery=current_run_data.hasEverEnteredErrorRecovery, labware=current_run_data.labware, labwareOffsets=current_run_data.labwareOffsets, pipettes=current_run_data.pipettes, @@ -630,6 +640,7 @@ async def test_update_current( actions=run_resource.actions, status=engine_state_summary.status, errors=engine_state_summary.errors, + hasEverEnteredErrorRecovery=engine_state_summary.hasEverEnteredErrorRecovery, labware=engine_state_summary.labware, labwareOffsets=engine_state_summary.labwareOffsets, pipettes=engine_state_summary.pipettes, @@ -685,6 +696,7 @@ async def test_update_current_noop( actions=run_resource.actions, status=engine_state_summary.status, errors=engine_state_summary.errors, + hasEverEnteredErrorRecovery=engine_state_summary.hasEverEnteredErrorRecovery, labware=engine_state_summary.labware, labwareOffsets=engine_state_summary.labwareOffsets, pipettes=engine_state_summary.pipettes, diff --git a/robot-server/tests/runs/test_run_store.py b/robot-server/tests/runs/test_run_store.py index 94899c5c20e..7e4155ef1b5 100644 --- a/robot-server/tests/runs/test_run_store.py +++ b/robot-server/tests/runs/test_run_store.py @@ -117,6 +117,7 @@ def state_summary() -> StateSummary: labwareOffsets=[], status=EngineStatus.IDLE, liquids=liquids, + hasEverEnteredErrorRecovery=False, ) @@ -189,6 +190,7 @@ def invalid_state_summary() -> StateSummary: return StateSummary( errors=[analysis_error], + hasEverEnteredErrorRecovery=False, labware=[analysis_labware], pipettes=[analysis_pipette], # TODO(mc, 2022-02-14): evaluate usage of modules in the analysis resp. From a5204fbfce6cc3914e22e2f669312ebdcc1588e5 Mon Sep 17 00:00:00 2001 From: Nick Diehl <47604184+ncdiehl11@users.noreply.github.com> Date: Thu, 1 Aug 2024 15:34:28 -0400 Subject: [PATCH 31/49] Fix app rtpfiles post (#15868) From both `ChooseRobotToRunProtocolSlideout` and `ChooseProtocolSlideout` components, we render a hook `useCreateRunFromProtocol`, which synchronously handles posting to /protocols and /runs. This PR fixes a bug where `runTimeParameterFiles` overrides were being passed to /protocols, but not to /runs in this hook. --- .../__tests__/ChooseProtocolSlideout.test.tsx | 4 ++++ .../__tests__/ChooseRobotToRunProtocolSlideout.test.tsx | 5 +++++ .../useCreateRunFromProtocol.ts | 3 ++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/src/organisms/ChooseProtocolSlideout/__tests__/ChooseProtocolSlideout.test.tsx b/app/src/organisms/ChooseProtocolSlideout/__tests__/ChooseProtocolSlideout.test.tsx index af2ce216663..fe0c4dd748b 100644 --- a/app/src/organisms/ChooseProtocolSlideout/__tests__/ChooseProtocolSlideout.test.tsx +++ b/app/src/organisms/ChooseProtocolSlideout/__tests__/ChooseProtocolSlideout.test.tsx @@ -7,6 +7,7 @@ import { simpleAnalysisFileFixture } from '@opentrons/api-client' import { OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { renderWithProviders } from '../../../__testing-utils__' import { i18n } from '../../../i18n' +import { useFeatureFlag } from '../../../redux/config' import { getStoredProtocols } from '../../../redux/protocol-storage' import { mockConnectableRobot } from '../../../redux/discovery/__fixtures__' import { @@ -18,6 +19,7 @@ import { useCreateRunFromProtocol } from '../../ChooseRobotToRunProtocolSlideout import { ChooseProtocolSlideout } from '../' import { useNotifyDataReady } from '../../../resources/useNotifyDataReady' import type { ProtocolAnalysisOutput } from '@opentrons/shared-data' +import { when } from 'vitest-when' vi.mock('../../ChooseRobotToRunProtocolSlideout/useCreateRunFromProtocol') vi.mock('../../../redux/protocol-storage') @@ -67,6 +69,7 @@ describe('ChooseProtocolSlideout', () => { trackCreateProtocolRunEvent: mockTrackCreateProtocolRunEvent, }) vi.mocked(useNotifyDataReady).mockReturnValue({} as any) + when(vi.mocked(useFeatureFlag)).calledWith('enableCsvFile').thenReturn(true) }) it('renders slideout if showSlideout true', () => { @@ -127,6 +130,7 @@ describe('ChooseProtocolSlideout', () => { files: [expect.any(File)], protocolKey: storedProtocolDataFixture.protocolKey, runTimeParameterValues: expect.any(Object), + runTimeParameterFiles: expect.any(Object), }) ) expect(mockTrackCreateProtocolRunEvent).toHaveBeenCalled() diff --git a/app/src/organisms/ChooseRobotToRunProtocolSlideout/__tests__/ChooseRobotToRunProtocolSlideout.test.tsx b/app/src/organisms/ChooseRobotToRunProtocolSlideout/__tests__/ChooseRobotToRunProtocolSlideout.test.tsx index 9dc260f1b85..d7ead4bd4dc 100644 --- a/app/src/organisms/ChooseRobotToRunProtocolSlideout/__tests__/ChooseRobotToRunProtocolSlideout.test.tsx +++ b/app/src/organisms/ChooseRobotToRunProtocolSlideout/__tests__/ChooseRobotToRunProtocolSlideout.test.tsx @@ -9,6 +9,7 @@ import { i18n } from '../../../i18n' import { useTrackCreateProtocolRunEvent } from '../../../organisms/Devices/hooks' import { useCloseCurrentRun } from '../../../organisms/ProtocolUpload/hooks' import { useCurrentRunStatus } from '../../../organisms/RunTimeControl/hooks' +import { useFeatureFlag } from '../../../redux/config' import { getConnectableRobots, getReachableRobots, @@ -38,6 +39,7 @@ import type { State } from '../../../redux/types' vi.mock('../../../organisms/Devices/hooks') vi.mock('../../../organisms/ProtocolUpload/hooks') vi.mock('../../../organisms/RunTimeControl/hooks') +vi.mock('../../../redux/config') vi.mock('../../../redux/discovery') vi.mock('../../../redux/robot-update') vi.mock('../../../redux/networking') @@ -190,6 +192,8 @@ describe('ChooseRobotToRunProtocolSlideout', () => { { ...mockConnectableRobot, name: 'otherRobot', ip: 'otherIp' }, mockConnectableRobot, ]) + vi.mocked(useFeatureFlag).mockReturnValue(true) + provideNullCurrentRunIdFor('otherIp') render({ storedProtocolData: storedProtocolDataFixture, @@ -213,6 +217,7 @@ describe('ChooseRobotToRunProtocolSlideout', () => { files: [expect.any(File)], protocolKey: storedProtocolDataFixture.protocolKey, runTimeParameterValues: expect.any(Object), + runTimeParameterFiles: expect.any(Object), }) ) expect(mockTrackCreateProtocolRunEvent).toHaveBeenCalled() diff --git a/app/src/organisms/ChooseRobotToRunProtocolSlideout/useCreateRunFromProtocol.ts b/app/src/organisms/ChooseRobotToRunProtocolSlideout/useCreateRunFromProtocol.ts index 24d4d5d0eba..1dcddfe12ce 100644 --- a/app/src/organisms/ChooseRobotToRunProtocolSlideout/useCreateRunFromProtocol.ts +++ b/app/src/organisms/ChooseRobotToRunProtocolSlideout/useCreateRunFromProtocol.ts @@ -71,11 +71,12 @@ export function useCreateRunFromProtocol( reset: resetProtocolMutation, } = useCreateProtocolMutation( { - onSuccess: (data, { runTimeParameterValues }) => { + onSuccess: (data, { runTimeParameterValues, runTimeParameterFiles }) => { createRun({ protocolId: data.data.id, labwareOffsets, runTimeParameterValues, + runTimeParameterFiles, }) }, }, From 60dbb657750c8472301b9cca29f0ae4803fa37bd Mon Sep 17 00:00:00 2001 From: koji Date: Fri, 2 Aug 2024 09:13:44 -0400 Subject: [PATCH 32/49] refactor(protocol_designer): move Slideout from atoms to components (#15874) * refactor(protocol_designer): move Slideout from atoms to components --- protocol-designer/src/atoms/index.ts | 1 + protocol-designer/src/components/OffDeckLabwareSlideout.tsx | 2 +- .../src/{atoms/Slideout.tsx => components/Slideout/index.tsx} | 0 protocol-designer/src/molecules/index.ts | 1 + protocol-designer/src/organisms/index.ts | 1 + 5 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 protocol-designer/src/atoms/index.ts rename protocol-designer/src/{atoms/Slideout.tsx => components/Slideout/index.tsx} (100%) create mode 100644 protocol-designer/src/molecules/index.ts create mode 100644 protocol-designer/src/organisms/index.ts diff --git a/protocol-designer/src/atoms/index.ts b/protocol-designer/src/atoms/index.ts new file mode 100644 index 00000000000..f8f44e7744b --- /dev/null +++ b/protocol-designer/src/atoms/index.ts @@ -0,0 +1 @@ +console.log('atoms for new components') diff --git a/protocol-designer/src/components/OffDeckLabwareSlideout.tsx b/protocol-designer/src/components/OffDeckLabwareSlideout.tsx index 0fa281ef49c..d21ed78f3e9 100644 --- a/protocol-designer/src/components/OffDeckLabwareSlideout.tsx +++ b/protocol-designer/src/components/OffDeckLabwareSlideout.tsx @@ -32,7 +32,7 @@ import { getRobotStateAtActiveItem } from '../top-selectors/labware-locations' import { getLabwareNicknamesById } from '../ui/labware/selectors' import { EditLabwareOffDeck } from './DeckSetup/LabwareOverlays/EditLabwareOffDeck' import { BrowseLabware } from './DeckSetup/LabwareOverlays/BrowseLabware' -import { Slideout } from '../atoms/Slideout' +import { Slideout } from './Slideout' import { wellFillFromWellContents } from './labware' interface OffDeckLabwareSlideoutProps { diff --git a/protocol-designer/src/atoms/Slideout.tsx b/protocol-designer/src/components/Slideout/index.tsx similarity index 100% rename from protocol-designer/src/atoms/Slideout.tsx rename to protocol-designer/src/components/Slideout/index.tsx diff --git a/protocol-designer/src/molecules/index.ts b/protocol-designer/src/molecules/index.ts new file mode 100644 index 00000000000..a865b38e935 --- /dev/null +++ b/protocol-designer/src/molecules/index.ts @@ -0,0 +1 @@ +console.log('molecules for new components') diff --git a/protocol-designer/src/organisms/index.ts b/protocol-designer/src/organisms/index.ts new file mode 100644 index 00000000000..c15c8fdc690 --- /dev/null +++ b/protocol-designer/src/organisms/index.ts @@ -0,0 +1 @@ +console.log('organisms for new components') From 4a8f098415b6cdc203b4a1d771872ddfc2172ffd Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Fri, 2 Aug 2024 09:26:17 -0400 Subject: [PATCH 33/49] fix(app): use a slow blowout in DTWiz (#15869) This is a recommendation from hardware that will make sure that liquid will properly blow out even if it's significantly viscous. - [x] do a DTWiz and make sure we use the slow blowout and prepare to aspirate Closes EXEC-181 --- .../useDropTipWithType/useDropTipCommands.ts | 74 +++++++++++-------- 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/useDropTipCommands.ts b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/useDropTipCommands.ts index 4bb8f6c96ae..c1360114e38 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/useDropTipCommands.ts +++ b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/useDropTipCommands.ts @@ -9,8 +9,6 @@ import type { CreateCommand, AddressableAreaName, PipetteModelSpecs, - BlowoutInPlaceCreateCommand, - UnsafeBlowoutInPlaceCreateCommand, DropTipInPlaceCreateCommand, UnsafeDropTipInPlaceCreateCommand, } from '@opentrons/shared-data' @@ -27,6 +25,7 @@ import type { UseDTWithTypeParams } from '..' import type { RunCommandByCommandTypeParams } from './useDropTipCreateCommands' const JOG_COMMAND_TIMEOUT_MS = 10000 +const MAXIMUM_BLOWOUT_FLOW_RATE_UL_PER_S = 50 type UseDropTipSetupCommandsParams = UseDTWithTypeParams & { activeMaintenanceRunId: string | null @@ -187,11 +186,9 @@ export function useDropTipCommands({ ): Promise => { return new Promise((resolve, reject) => { chainRunCommands( - [ - currentStep === POSITION_AND_BLOWOUT - ? buildBlowoutInPlaceCommand(instrumentModelSpecs, isFlex) - : buildDropTipInPlaceCommand(isFlex), - ], + currentStep === POSITION_AND_BLOWOUT + ? buildBlowoutCommands(instrumentModelSpecs, isFlex) + : buildDropTipInPlaceCommand(isFlex), true ) .then((commandData: CommandData[]) => { @@ -278,36 +275,53 @@ const UPDATE_ESTIMATORS_EXCEPT_PLUNGERS: CreateCommand = { const buildDropTipInPlaceCommand = ( isFlex: boolean -): DropTipInPlaceCreateCommand | UnsafeDropTipInPlaceCreateCommand => +): Array => isFlex - ? { - commandType: 'unsafe/dropTipInPlace', - params: { pipetteId: MANAGED_PIPETTE_ID }, - } - : { - commandType: 'dropTipInPlace', - params: { pipetteId: MANAGED_PIPETTE_ID }, - } + ? [ + { + commandType: 'unsafe/dropTipInPlace', + params: { pipetteId: MANAGED_PIPETTE_ID }, + }, + ] + : [ + { + commandType: 'dropTipInPlace', + params: { pipetteId: MANAGED_PIPETTE_ID }, + }, + ] -const buildBlowoutInPlaceCommand = ( +const buildBlowoutCommands = ( specs: PipetteModelSpecs, isFlex: boolean -): BlowoutInPlaceCreateCommand | UnsafeBlowoutInPlaceCreateCommand => +): CreateCommand[] => isFlex - ? { - commandType: 'unsafe/blowOutInPlace', - params: { - pipetteId: MANAGED_PIPETTE_ID, - flowRate: specs.defaultBlowOutFlowRate.value, + ? [ + { + commandType: 'unsafe/blowOutInPlace', + params: { + pipetteId: MANAGED_PIPETTE_ID, + flowRate: Math.min( + specs.defaultBlowOutFlowRate.value, + MAXIMUM_BLOWOUT_FLOW_RATE_UL_PER_S + ), + }, }, - } - : { - commandType: 'blowOutInPlace', - params: { - pipetteId: MANAGED_PIPETTE_ID, - flowRate: specs.defaultBlowOutFlowRate.value, + { + commandType: 'prepareToAspirate', + params: { + pipetteId: MANAGED_PIPETTE_ID, + }, }, - } + ] + : [ + { + commandType: 'blowOutInPlace', + params: { + pipetteId: MANAGED_PIPETTE_ID, + flowRate: specs.defaultBlowOutFlowRate.value, + }, + }, + ] const buildMoveToAACommand = ( addressableAreaFromConfig: AddressableAreaName From 589d65df82f974df6d1bf3728efd14834c07a093 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Fri, 2 Aug 2024 11:56:31 -0400 Subject: [PATCH 34/49] fix(app): fix "return to dashboard" not returning to dashboard (#15875) Closes RQA-2886 After clicking "return to dashboard" on the ODD, the run context is closed, and then a navigate to dashboard is issued. However, the top level redirects logic keeps the ODD on the runs summary page as long as there is an active run record, which is always the case when the network request hasn't yet completed. By the time the user clicks "return to dashboard" a second time, the run has (most likely) closed, and the user can actually navigate away. Unfortunately, throwing this navigate behind an onSuccess callback is insufficient, because the top level redirects hook correctly uses a different endpoint to determine whether it should redirect. To solve this, let's eagerly clear the query cache on the ODD for the run record. Any future refetches of that run record simply repopulate the cache. It's worth revisiting our approach to top level redirects in the future, but this solution is adequate for now. --- app/src/App/hooks.ts | 17 ++--------------- app/src/pages/RunSummary/index.tsx | 5 +++++ 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/app/src/App/hooks.ts b/app/src/App/hooks.ts index 7e5054c8ae8..3d80704af1d 100644 --- a/app/src/App/hooks.ts +++ b/app/src/App/hooks.ts @@ -22,7 +22,7 @@ import { import { checkShellUpdate } from '../redux/shell' import { useToaster } from '../organisms/ToasterOven' -import { useNotifyAllRunsQuery, useNotifyRunQuery } from '../resources/runs' +import { useCurrentRunId, useNotifyRunQuery } from '../resources/runs' import type { SetStatusBarCreateCommand } from '@opentrons/shared-data' import type { Dispatch } from '../redux/types' @@ -125,20 +125,7 @@ export function useProtocolReceiptToast(): void { } export function useCurrentRunRoute(): string | null { - const { data: allRuns } = useNotifyAllRunsQuery( - { pageLength: 1 }, - { refetchInterval: CURRENT_RUN_POLL } - ) - const currentRunLink = allRuns?.links?.current ?? null - const currentRun = - currentRunLink != null && - typeof currentRunLink !== 'string' && - 'href' in currentRunLink - ? allRuns?.data.find( - run => run.id === currentRunLink.href.replace('/runs/', '') - ) // trim link path down to only runId - : null - const currentRunId = currentRun?.id ?? null + const currentRunId = useCurrentRunId({ refetchInterval: CURRENT_RUN_POLL }) const { data: runRecord } = useNotifyRunQuery(currentRunId, { staleTime: Infinity, enabled: currentRunId != null, diff --git a/app/src/pages/RunSummary/index.tsx b/app/src/pages/RunSummary/index.tsx index 28789061c18..ab08e986544 100644 --- a/app/src/pages/RunSummary/index.tsx +++ b/app/src/pages/RunSummary/index.tsx @@ -3,6 +3,7 @@ import { useSelector } from 'react-redux' import { useParams, useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' +import { useQueryClient } from 'react-query' import { ALIGN_CENTER, @@ -155,8 +156,12 @@ export function RunSummary(): JSX.Element { determineTipStatus() }, []) + // TODO(jh, 08-02-24): Revisit useCurrentRunRoute and top level redirects. + const queryClient = useQueryClient() const returnToDash = (): void => { closeCurrentRun() + // Eagerly clear the query cache to prevent top level redirecting back to this page. + queryClient.setQueryData([host, 'runs', runId, 'details'], () => undefined) navigate('/') } // TODO(jh, 07-24-24): After EXEC-504, add reportRecoveredRunResult here. From c40a009690db605f1a517147d91f07c6ea3261b8 Mon Sep 17 00:00:00 2001 From: koji Date: Fri, 2 Aug 2024 12:10:16 -0400 Subject: [PATCH 35/49] refactor(app): display file name from robot-server data (#15873) refactor(app): display file name from robot-server data --- app/src/organisms/ChooseRobotSlideout/FileCard.tsx | 2 +- .../ChooseRobotSlideout/__tests__/FileCard.test.tsx | 9 ++++++--- .../Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx | 3 +-- .../__tests__/ProtocolRunRuntimeParameters.test.tsx | 3 ++- .../__tests__/formatRunTimeParameterValue.test.ts | 2 +- shared-data/js/helpers/formatRunTimeParameterValue.ts | 2 +- shared-data/js/types.ts | 1 + 7 files changed, 13 insertions(+), 9 deletions(-) diff --git a/app/src/organisms/ChooseRobotSlideout/FileCard.tsx b/app/src/organisms/ChooseRobotSlideout/FileCard.tsx index 0ed23c4aa2f..99d66bf54fe 100644 --- a/app/src/organisms/ChooseRobotSlideout/FileCard.tsx +++ b/app/src/organisms/ChooseRobotSlideout/FileCard.tsx @@ -51,7 +51,7 @@ export function FileCard(props: FileCardProps): JSX.Element { white-space: nowrap; `} > - {truncateString(fileRunTimeParameter?.file?.file?.name ?? '', 35, 18)} + {truncateString(fileRunTimeParameter?.file?.name ?? '', 35, 18)} ) => { return renderWithProviders( @@ -29,7 +30,7 @@ const mockSetRunTimeParametersOverrides = vi.fn() const mockCsvRunTimeParameterSuccess: CsvFileParameter = { displayName: 'My sample file', - file: { file: { name: 'my_file.csv' } as File }, + file: { id: 'my_file_id', name: 'my_file.csv' }, variableName: 'my_sample_csv', description: 'This is a mock CSV runtime parameter', type: 'csv_file', @@ -37,7 +38,7 @@ const mockCsvRunTimeParameterSuccess: CsvFileParameter = { const mockCsvRunTimeParameterError = { ...mockCsvRunTimeParameterSuccess, - file: { file: { name: 'my_bad_file.pdf' } as File }, + file: { id: 'my_bad_file_id', name: 'my_bad_file.pdf' }, } const mockRunTimeParametersOverrides = [mockCsvRunTimeParameterSuccess] @@ -52,6 +53,7 @@ describe('FileCard', () => { }) screen.getByText('my_file.csv') }) + it('displays error message if the file type is incorrect', () => { render({ error: 'CSV file type required', @@ -61,6 +63,7 @@ describe('FileCard', () => { }) screen.getByText('CSV file type required') }) + it('sets runtime parameters overrides file parameter to null on close', () => { render({ error: 'CSV file type required', diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx index ef3740625fc..ab436e5973f 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx @@ -209,8 +209,7 @@ const StyledTableRowComponent = ( {parameter.type === 'csv_file' - ? // TODO (nd, 07/17/2024): retrieve filename from parameter once backend is wired up - parameter.file?.file?.name ?? '' + ? parameter.file?.name ?? '' : formatRunTimeParameterValue(parameter, t)} {parameter.type === 'csv_file' || diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunRuntimeParameters.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunRuntimeParameters.test.tsx index ec9eace43b7..bf6bfce0d79 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunRuntimeParameters.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunRuntimeParameters.test.tsx @@ -93,7 +93,8 @@ const mockCsvRtp = { description: '', type: 'csv_file', file: { - file: { name: 'mock.csv' } as File, + id: 'mock_csv_id', + name: 'mock.csv', }, } diff --git a/shared-data/js/helpers/__tests__/formatRunTimeParameterValue.test.ts b/shared-data/js/helpers/__tests__/formatRunTimeParameterValue.test.ts index a722fc84b65..3eb2fe645e0 100644 --- a/shared-data/js/helpers/__tests__/formatRunTimeParameterValue.test.ts +++ b/shared-data/js/helpers/__tests__/formatRunTimeParameterValue.test.ts @@ -144,7 +144,7 @@ describe('utils-formatRunTimeParameterDefaultValue', () => { it('should return value when type is csv', () => { const mockData = { - file: { id: 'test', file: { name: 'mock.csv' } as File }, + file: { id: 'test', name: 'mock.csv' }, displayName: 'My CSV File', variableName: 'CSVFILE', description: 'CSV File for a protocol', diff --git a/shared-data/js/helpers/formatRunTimeParameterValue.ts b/shared-data/js/helpers/formatRunTimeParameterValue.ts index 92ff97b99e0..9ab799d67a9 100644 --- a/shared-data/js/helpers/formatRunTimeParameterValue.ts +++ b/shared-data/js/helpers/formatRunTimeParameterValue.ts @@ -17,7 +17,7 @@ export const formatRunTimeParameterValue = ( const { type } = runTimeParameter const value = runTimeParameter.type === 'csv_file' - ? runTimeParameter.file?.file?.name ?? '' + ? runTimeParameter.file?.name ?? '' : runTimeParameter.value const suffix = 'suffix' in runTimeParameter && runTimeParameter.suffix != null diff --git a/shared-data/js/types.ts b/shared-data/js/types.ts index c347042f4e4..6dbac40e170 100644 --- a/shared-data/js/types.ts +++ b/shared-data/js/types.ts @@ -679,6 +679,7 @@ export interface CsvFileParameterFileData { file?: File | null filePath?: string fileName?: string + name?: string } export interface CsvFileParameter extends BaseRunTimeParameter { From 8cdf2b79141f8ec77f64e98cb86cb1ae7b4228bf Mon Sep 17 00:00:00 2001 From: Nick Diehl <47604184+ncdiehl11@users.noreply.github.com> Date: Fri, 2 Aug 2024 12:15:54 -0400 Subject: [PATCH 36/49] chore(app): remove enableCsvFile feature flag (#15863) remove enableCsvFile feature flag from app --- .../assets/localization/en/app_settings.json | 1 - .../__tests__/ChooseProtocolSlideout.test.tsx | 3 - .../ChooseProtocolSlideout/index.tsx | 24 +--- .../organisms/ChooseRobotSlideout/index.tsx | 5 +- .../ChooseRobotToRunProtocolSlideout.test.tsx | 5 +- .../index.tsx | 66 ++++----- .../Devices/HistoricalProtocolRun.tsx | 18 +-- .../Devices/HistoricalProtocolRunDrawer.tsx | 4 +- .../ProtocolRunRuntimeParameters.test.tsx | 5 - .../organisms/Devices/RecentProtocolRuns.tsx | 18 +-- .../ProtocolSetupParameters.test.tsx | 32 ++--- .../ProtocolSetupParameters/index.tsx | 129 ++++++++---------- .../ProtocolDashboard/PinnedProtocol.tsx | 12 +- .../pages/ProtocolDashboard/ProtocolCard.tsx | 4 - .../__tests__/ProtocolDetails.test.tsx | 5 - app/src/pages/ProtocolDetails/index.tsx | 4 +- app/src/redux/config/constants.ts | 1 - app/src/redux/config/schema-types.ts | 1 - 18 files changed, 124 insertions(+), 213 deletions(-) diff --git a/app/src/assets/localization/en/app_settings.json b/app/src/assets/localization/en/app_settings.json index ddd63bf7487..41a6923112c 100644 --- a/app/src/assets/localization/en/app_settings.json +++ b/app/src/assets/localization/en/app_settings.json @@ -4,7 +4,6 @@ "__dev_internal__protocolTimeline": "Protocol Timeline", "__dev_internal__enableRunNotes": "Display Notes During a Protocol Run", "__dev_internal__enableQuickTransfer": "Enable Quick Transfer", - "__dev_internal__enableCsvFile": "Enable CSV File", "__dev_internal__enableLabwareCreator": "Enable App Labware Creator", "add_folder_button": "Add labware source folder", "add_ip_button": "Add", diff --git a/app/src/organisms/ChooseProtocolSlideout/__tests__/ChooseProtocolSlideout.test.tsx b/app/src/organisms/ChooseProtocolSlideout/__tests__/ChooseProtocolSlideout.test.tsx index fe0c4dd748b..152703e6afa 100644 --- a/app/src/organisms/ChooseProtocolSlideout/__tests__/ChooseProtocolSlideout.test.tsx +++ b/app/src/organisms/ChooseProtocolSlideout/__tests__/ChooseProtocolSlideout.test.tsx @@ -7,7 +7,6 @@ import { simpleAnalysisFileFixture } from '@opentrons/api-client' import { OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { renderWithProviders } from '../../../__testing-utils__' import { i18n } from '../../../i18n' -import { useFeatureFlag } from '../../../redux/config' import { getStoredProtocols } from '../../../redux/protocol-storage' import { mockConnectableRobot } from '../../../redux/discovery/__fixtures__' import { @@ -19,7 +18,6 @@ import { useCreateRunFromProtocol } from '../../ChooseRobotToRunProtocolSlideout import { ChooseProtocolSlideout } from '../' import { useNotifyDataReady } from '../../../resources/useNotifyDataReady' import type { ProtocolAnalysisOutput } from '@opentrons/shared-data' -import { when } from 'vitest-when' vi.mock('../../ChooseRobotToRunProtocolSlideout/useCreateRunFromProtocol') vi.mock('../../../redux/protocol-storage') @@ -69,7 +67,6 @@ describe('ChooseProtocolSlideout', () => { trackCreateProtocolRunEvent: mockTrackCreateProtocolRunEvent, }) vi.mocked(useNotifyDataReady).mockReturnValue({} as any) - when(vi.mocked(useFeatureFlag)).calledWith('enableCsvFile').thenReturn(true) }) it('renders slideout if showSlideout true', () => { diff --git a/app/src/organisms/ChooseProtocolSlideout/index.tsx b/app/src/organisms/ChooseProtocolSlideout/index.tsx index a5d5a293256..6697e260fd8 100644 --- a/app/src/organisms/ChooseProtocolSlideout/index.tsx +++ b/app/src/organisms/ChooseProtocolSlideout/index.tsx @@ -36,7 +36,6 @@ import { import { sortRuntimeParameters } from '@opentrons/shared-data' import { useLogger } from '../../logger' -import { useFeatureFlag } from '../../redux/config' import { OPENTRONS_USB } from '../../redux/discovery' import { getStoredProtocols } from '../../redux/protocol-storage' import { appShellRequestor } from '../../redux/shell/remote' @@ -117,7 +116,6 @@ export function ChooseProtocolSlideoutComponent( ) ?? false ) const [isInputFocused, setIsInputFocused] = React.useState(false) - const enableCsvFile = useFeatureFlag('enableCsvFile') React.useEffect(() => { setRunTimeParametersOverrides( @@ -239,20 +237,12 @@ export function ChooseProtocolSlideoutComponent( runTimeParametersOverrides, mappedResolvedCsvVariableToFileId ) - if (enableCsvFile) { - createRunFromProtocolSource({ - files: srcFileObjects, - protocolKey: selectedProtocol.protocolKey, - runTimeParameterValues, - runTimeParameterFiles, - }) - } else { - createRunFromProtocolSource({ - files: srcFileObjects, - protocolKey: selectedProtocol.protocolKey, - runTimeParameterValues, - }) - } + createRunFromProtocolSource({ + files: srcFileObjects, + protocolKey: selectedProtocol.protocolKey, + runTimeParameterValues, + runTimeParameterFiles, + }) }) } else { logger.warn('failed to create protocol, no protocol selected') @@ -441,7 +431,7 @@ export function ChooseProtocolSlideoutComponent( if (error != null) { errors.push(error as string) } - return !enableCsvFile ? null : ( + return ( () const isScanning = useSelector((state: State) => getScanning(state)) @@ -530,8 +528,9 @@ export function ChooseRobotSlideout( if (error != null) { errors.push(error as string) } - return !enableCsvFile ? null : ( + return ( { { ...mockConnectableRobot, name: 'otherRobot', ip: 'otherIp' }, mockConnectableRobot, ]) - vi.mocked(useFeatureFlag).mockReturnValue(true) provideNullCurrentRunIdFor('otherIp') render({ @@ -267,6 +265,7 @@ describe('ChooseRobotToRunProtocolSlideout', () => { files: [expect.any(File)], protocolKey: storedProtocolDataFixture.protocolKey, runTimeParameterValues: expect.any(Object), + runTimeParameterFiles: expect.any(Object), }) ) expect(mockTrackCreateProtocolRunEvent).toHaveBeenCalled() @@ -300,6 +299,7 @@ describe('ChooseRobotToRunProtocolSlideout', () => { files: [expect.any(File)], protocolKey: storedProtocolDataFixture.protocolKey, runTimeParameterValues: expect.any(Object), + runTimeParameterFiles: expect.any(Object), }) ) expect(mockTrackCreateProtocolRunEvent).toHaveBeenCalled() @@ -353,6 +353,7 @@ describe('ChooseRobotToRunProtocolSlideout', () => { files: [expect.any(File)], protocolKey: storedProtocolDataFixture.protocolKey, runTimeParameterValues: expect.any(Object), + runTimeParameterFiles: expect.any(Object), }) }) }) diff --git a/app/src/organisms/ChooseRobotToRunProtocolSlideout/index.tsx b/app/src/organisms/ChooseRobotToRunProtocolSlideout/index.tsx index 49cbb7d8217..a75739ed78c 100644 --- a/app/src/organisms/ChooseRobotToRunProtocolSlideout/index.tsx +++ b/app/src/organisms/ChooseRobotToRunProtocolSlideout/index.tsx @@ -18,7 +18,6 @@ import { useUploadCsvFileMutation } from '@opentrons/react-api-client' import { Tooltip } from '../../atoms/Tooltip' import { getRobotUpdateDisplayInfo } from '../../redux/robot-update' -import { useFeatureFlag } from '../../redux/config' import { OPENTRONS_USB } from '../../redux/discovery' import { appShellRequestor } from '../../redux/shell/remote' import { useTrackCreateProtocolRunEvent } from '../Devices/hooks' @@ -96,8 +95,6 @@ export function ChooseRobotToRunProtocolSlideoutComponent( : null ) - const enableCsvFile = useFeatureFlag('enableCsvFile') - const { createRunFromProtocolSource, runCreationError, @@ -139,52 +136,41 @@ export function ChooseRobotToRunProtocolSlideoutComponent( ) const handleProceed: React.MouseEventHandler = () => { trackCreateProtocolRunEvent({ name: 'createProtocolRecordRequest' }) - if (enableCsvFile) { - const dataFilesForProtocolMap = runTimeParametersOverrides.reduce< - Record - >( - (acc, parameter) => - parameter.type === 'csv_file' && parameter.file?.file != null - ? { ...acc, [parameter.variableName]: parameter.file.file } - : acc, - {} - ) - Promise.all( - Object.entries(dataFilesForProtocolMap).map(([key, file]) => { - const fileResponse = uploadCsvFile(file) - const varName = Promise.resolve(key) - return Promise.all([fileResponse, varName]) - }) - ).then(responseTuples => { - const mappedResolvedCsvVariableToFileId = responseTuples.reduce< - Record - >((acc, [uploadedFileResponse, variableName]) => { - return { ...acc, [variableName]: uploadedFileResponse.data.id } - }, {}) - const runTimeParameterValues = getRunTimeParameterValuesForRun( - runTimeParametersOverrides - ) - const runTimeParameterFiles = getRunTimeParameterFilesForRun( - runTimeParametersOverrides, - mappedResolvedCsvVariableToFileId - ) - createRunFromProtocolSource({ - files: srcFileObjects, - protocolKey, - runTimeParameterValues, - runTimeParameterFiles, - }) + const dataFilesForProtocolMap = runTimeParametersOverrides.reduce< + Record + >( + (acc, parameter) => + parameter.type === 'csv_file' && parameter.file?.file != null + ? { ...acc, [parameter.variableName]: parameter.file.file } + : acc, + {} + ) + Promise.all( + Object.entries(dataFilesForProtocolMap).map(([key, file]) => { + const fileResponse = uploadCsvFile(file) + const varName = Promise.resolve(key) + return Promise.all([fileResponse, varName]) }) - } else { + ).then(responseTuples => { + const mappedResolvedCsvVariableToFileId = responseTuples.reduce< + Record + >((acc, [uploadedFileResponse, variableName]) => { + return { ...acc, [variableName]: uploadedFileResponse.data.id } + }, {}) const runTimeParameterValues = getRunTimeParameterValuesForRun( runTimeParametersOverrides ) + const runTimeParameterFiles = getRunTimeParameterFilesForRun( + runTimeParametersOverrides, + mappedResolvedCsvVariableToFileId + ) createRunFromProtocolSource({ files: srcFileObjects, protocolKey, runTimeParameterValues, + runTimeParameterFiles, }) - } + }) } const { autoUpdateAction } = useSelector((state: State) => diff --git a/app/src/organisms/Devices/HistoricalProtocolRun.tsx b/app/src/organisms/Devices/HistoricalProtocolRun.tsx index 5f8d8a9547e..d9fe2e823a4 100644 --- a/app/src/organisms/Devices/HistoricalProtocolRun.tsx +++ b/app/src/organisms/Devices/HistoricalProtocolRun.tsx @@ -14,7 +14,6 @@ import { LegacyStyledText, } from '@opentrons/components' import { useAllCsvFilesQuery } from '@opentrons/react-api-client' -import { useFeatureFlag } from '../../redux/config' import { formatInterval } from '../RunTimeControl/utils' import { formatTimestamp } from './utils' import { EMPTY_TIMESTAMP } from './constants' @@ -55,7 +54,6 @@ export function HistoricalProtocolRun( duration = formatInterval(run.startedAt, new Date().toString()) } } - const enableCsvFile = useFeatureFlag('enableCsvFile') return ( <> @@ -89,15 +87,13 @@ export function HistoricalProtocolRun( > {protocolName} - {enableCsvFile ? ( - - {allProtocolDataFiles.length} - - ) : null} + + {allProtocolDataFiles.length} + - {enableCsvFile ? protocolFilesData : null} + {protocolFilesData} {labwareOffsets} ) diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunRuntimeParameters.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunRuntimeParameters.test.tsx index bf6bfce0d79..777c263078d 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunRuntimeParameters.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunRuntimeParameters.test.tsx @@ -8,7 +8,6 @@ import { i18n } from '../../../../i18n' import { useMostRecentCompletedAnalysis } from '../../../LabwarePositionCheck/useMostRecentCompletedAnalysis' import { useRunStatus } from '../../../RunTimeControl/hooks' import { useNotifyRunQuery } from '../../../../resources/runs' -import { useFeatureFlag } from '../../../../redux/config' import { mockSucceededRun } from '../../../RunTimeControl/__fixtures__' import { ProtocolRunRuntimeParameters } from '../ProtocolRunRunTimeParameters' @@ -122,9 +121,6 @@ describe('ProtocolRunRuntimeParameters', () => { vi.mocked(useNotifyRunQuery).mockReturnValue(({ data: { data: mockSucceededRun }, } as unknown) as UseQueryResult) - when(vi.mocked(useFeatureFlag)) - .calledWith('enableCsvFile') - .thenReturn(false) }) afterEach(() => { @@ -189,7 +185,6 @@ describe('ProtocolRunRuntimeParameters', () => { }) it('should render csv row if a protocol requires a csv', () => { - when(vi.mocked(useFeatureFlag)).calledWith('enableCsvFile').thenReturn(true) vi.mocked(useMostRecentCompletedAnalysis).mockReturnValue({ runTimeParameters: [...mockRunTimeParameterData, mockCsvRtp], } as CompletedProtocolAnalysis) diff --git a/app/src/organisms/Devices/RecentProtocolRuns.tsx b/app/src/organisms/Devices/RecentProtocolRuns.tsx index af685895ffa..06815a7b064 100644 --- a/app/src/organisms/Devices/RecentProtocolRuns.tsx +++ b/app/src/organisms/Devices/RecentProtocolRuns.tsx @@ -19,7 +19,6 @@ import { import { HistoricalProtocolRun } from './HistoricalProtocolRun' import { useIsRobotViewable, useRunStatuses } from './hooks' import { useNotifyAllRunsQuery, useCurrentRunId } from '../../resources/runs' -import { useFeatureFlag } from '../../redux/config' interface RecentProtocolRunsProps { robotName: string @@ -36,7 +35,6 @@ export function RecentProtocolRuns({ const currentRunId = useCurrentRunId() const { isRunTerminal } = useRunStatuses() const robotIsBusy = currentRunId != null ? !isRunTerminal : false - const enableCsvFile = useFeatureFlag('enableCsvFile') return ( {t('protocol')} - {enableCsvFile ? ( - - {t('files')} - - ) : null} + + {t('files')} + { vi.mocked(ChooseEnum).mockReturnValue(
mock ChooseEnum
) vi.mocked(ChooseNumber).mockReturnValue(
mock ChooseNumber
) vi.mocked(ChooseCsvFile).mockReturnValue(
mock ChooseCsvFile
) - vi.mocked(useFeatureFlag).mockReturnValue(false) vi.mocked(useHost).mockReturnValue(MOCK_HOST_CONFIG) when(vi.mocked(useCreateProtocolAnalysisMutation)) .calledWith(expect.anything(), expect.anything()) @@ -84,9 +82,6 @@ describe('ProtocolSetupParameters', () => { when(vi.mocked(useUploadCsvFileMutation)) .calledWith(expect.anything(), expect.anything()) .thenReturn({ uploadCsvFile: mockUploadCsvFile } as any) - when(vi.mocked(useFeatureFlag)) - .calledWith('enableCsvFile') - .thenReturn(false) vi.mocked(useToaster).mockReturnValue({ makeSnackbar: mockMakeSnackbar, makeToast: vi.fn(), @@ -116,7 +111,6 @@ describe('ProtocolSetupParameters', () => { }) it('renders the ChooseCsvFile component when a str param is selected', () => { - vi.mocked(useFeatureFlag).mockReturnValue(true) render(props) fireEvent.click(screen.getByText('CSV File')) screen.getByText('mock ChooseCsvFile') @@ -142,7 +136,6 @@ describe('ProtocolSetupParameters', () => { }) it('renders the other setting when csv param', () => { - vi.mocked(useFeatureFlag).mockReturnValue(true) render(props) screen.getByText('CSV File') }) @@ -153,18 +146,19 @@ describe('ProtocolSetupParameters', () => { expect(mockNavigate).toHaveBeenCalled() }) - it('renders the confirm values button and clicking on it creates a run', () => { - render(props) - fireEvent.click(screen.getByRole('button', { name: 'Confirm values' })) - expect(mockCreateRun).toHaveBeenCalled() - }) + // TODO(nd: 08/1/2024) We intentionally set file field for `csv_file` type parameter to null on mount + // it('renders the confirm values button and clicking on it creates a run', () => { + // render(props) + // fireEvent.click(screen.getByRole('button', { name: 'Confirm values' })) + // expect(mockCreateRun).toHaveBeenCalled() + // }) - it('should restore default values button is disabled when tapping confirm values button', async () => { - render(props) - const resetButton = screen.getByTestId('ChildNavigation_Secondary_Button') - fireEvent.click(screen.getByText('Confirm values')) - expect(resetButton).toBeDisabled() - }) + // it('should restore default values button is disabled when tapping confirm values button', async () => { + // render(props) + // const resetButton = screen.getByTestId('ChildNavigation_Secondary_Button') + // fireEvent.click(screen.getByText('Confirm values')) + // expect(resetButton).toBeDisabled() + // }) it('renders the reset values modal', () => { render(props) @@ -180,7 +174,6 @@ describe('ProtocolSetupParameters', () => { }) it('render csv file when a protocol requires a csv file and confirm values button has the disabled style', () => { - when(vi.mocked(useFeatureFlag)).calledWith('enableCsvFile').thenReturn(true) const mockMostRecentAnalysisForCsv = ({ commands: [], labware: [], @@ -199,7 +192,6 @@ describe('ProtocolSetupParameters', () => { }) it('when tapping aria-disabled button, snack bar will show up', () => { - when(vi.mocked(useFeatureFlag)).calledWith('enableCsvFile').thenReturn(true) const mockMostRecentAnalysisForCsv = ({ commands: [], labware: [], diff --git a/app/src/organisms/ProtocolSetupParameters/index.tsx b/app/src/organisms/ProtocolSetupParameters/index.tsx index 66cad283b6c..1c22f0d371b 100644 --- a/app/src/organisms/ProtocolSetupParameters/index.tsx +++ b/app/src/organisms/ProtocolSetupParameters/index.tsx @@ -28,7 +28,6 @@ import { ResetValuesModal } from './ResetValuesModal' import { ChooseEnum } from './ChooseEnum' import { ChooseNumber } from './ChooseNumber' import { ChooseCsvFile } from './ChooseCsvFile' -import { useFeatureFlag } from '../../redux/config' import { useToaster } from '../ToasterOven' import { ProtocolSetupStep } from '../../pages/ProtocolSetup' import type { @@ -57,7 +56,6 @@ export function ProtocolSetupParameters({ mostRecentAnalysis, }: ProtocolSetupParametersProps): JSX.Element { const { t } = useTranslation('protocol_setup') - const enableCsvFile = useFeatureFlag('enableCsvFile') const navigate = useNavigate() const host = useHost() const queryClient = useQueryClient() @@ -154,9 +152,6 @@ export function ProtocolSetupParameters({ } } - const runTimeParameterValues = getRunTimeParameterValuesForRun( - runTimeParametersOverrides - ) const { createProtocolAnalysis } = useCreateProtocolAnalysisMutation( protocolId, host @@ -172,73 +167,59 @@ export function ProtocolSetupParameters({ }, }) const handleConfirmValues = (): void => { - if (enableCsvFile) { - if (hasMissingFileParam) { - makeSnackbar(t('protocol_requires_csv') as string) - } else { - const dataFilesForProtocolMap = runTimeParametersOverrides.reduce< - Record - >((acc, parameter) => { - // create {variableName: FileData} map for sending to /dataFiles endpoint - if ( - parameter.type === 'csv_file' && - parameter.file?.id == null && - parameter.file?.file != null - ) { - return { [parameter.variableName]: parameter.file.file } - } else if ( - parameter.type === 'csv_file' && - parameter.file?.id == null && - parameter.file?.filePath != null - ) { - return { [parameter.variableName]: parameter.file.filePath } - } - return acc - }, {}) - void Promise.all( - Object.entries(dataFilesForProtocolMap).map(([key, fileData]) => { - const fileResponse = uploadCsvFile(fileData) - const varName = Promise.resolve(key) - return Promise.all([fileResponse, varName]) - }) - ).then(responseTuples => { - const mappedResolvedCsvVariableToFileId = responseTuples.reduce< - Record - >((acc, [uploadedFileResponse, variableName]) => { - return { ...acc, [variableName]: uploadedFileResponse.data.id } - }, {}) - const runTimeParameterValues = getRunTimeParameterValuesForRun( - runTimeParametersOverrides - ) - const runTimeParameterFiles = getRunTimeParameterFilesForRun( - runTimeParametersOverrides, - mappedResolvedCsvVariableToFileId - ) - createProtocolAnalysis({ - protocolKey: protocolId, - runTimeParameterValues, - runTimeParameterFiles, - }) - createRun({ - protocolId, - labwareOffsets, - runTimeParameterValues, - runTimeParameterFiles, - }) - }) - } + if (hasMissingFileParam) { + makeSnackbar(t('protocol_requires_csv') as string) } else { - setStartSetup(true) - createProtocolAnalysis({ - protocolKey: protocolId, - runTimeParameterValues: runTimeParameterValues, - }) - createRun({ - protocolId, - labwareOffsets, - runTimeParameterValues: getRunTimeParameterValuesForRun( + const dataFilesForProtocolMap = runTimeParametersOverrides.reduce< + Record + >((acc, parameter) => { + // create {variableName: FileData} map for sending to /dataFiles endpoint + if ( + parameter.type === 'csv_file' && + parameter.file?.id == null && + parameter.file?.file != null + ) { + return { [parameter.variableName]: parameter.file.file } + } else if ( + parameter.type === 'csv_file' && + parameter.file?.id == null && + parameter.file?.filePath != null + ) { + return { [parameter.variableName]: parameter.file.filePath } + } + return acc + }, {}) + void Promise.all( + Object.entries(dataFilesForProtocolMap).map(([key, fileData]) => { + const fileResponse = uploadCsvFile(fileData) + const varName = Promise.resolve(key) + return Promise.all([fileResponse, varName]) + }) + ).then(responseTuples => { + const mappedResolvedCsvVariableToFileId = responseTuples.reduce< + Record + >((acc, [uploadedFileResponse, variableName]) => { + return { ...acc, [variableName]: uploadedFileResponse.data.id } + }, {}) + const runTimeParameterValues = getRunTimeParameterValuesForRun( runTimeParametersOverrides - ), + ) + const runTimeParameterFiles = getRunTimeParameterFilesForRun( + runTimeParametersOverrides, + mappedResolvedCsvVariableToFileId + ) + setStartSetup(true) + createProtocolAnalysis({ + protocolKey: protocolId, + runTimeParameterValues, + runTimeParameterFiles, + }) + createRun({ + protocolId, + labwareOffsets, + runTimeParameterValues, + runTimeParameterFiles, + }) }) } } @@ -267,8 +248,8 @@ export function ProtocolSetupParameters({ }} onClickButton={handleConfirmValues} buttonText={t('confirm_values')} - ariaDisabled={enableCsvFile && hasMissingFileParam} - buttonIsDisabled={enableCsvFile && hasMissingFileParam} + ariaDisabled={hasMissingFileParam} + buttonIsDisabled={hasMissingFileParam} iconName={isLoading || startSetup ? 'ot-spinner' : undefined} iconPlacement="startIcon" secondaryButtonProps={{ @@ -292,7 +273,7 @@ export function ProtocolSetupParameters({ (parameter, index) => { let detail: string = '' let setupStatus: ProtocolSetupStepStatus - if (enableCsvFile && parameter.type === 'csv_file') { + if (parameter.type === 'csv_file') { if (parameter.file?.fileName == null) { detail = t('required') setupStatus = 'not ready' @@ -334,7 +315,7 @@ export function ProtocolSetupParameters({ ) // ToDo (kk:06/18/2024) ff will be removed when we freeze the code - if (enableCsvFile && chooseCsvFileScreen != null) { + if (chooseCsvFileScreen != null) { children = ( - {enableCsvFile && isRequiredCSV ? ( + {isRequiredCSV ? ( { describe('ODDProtocolDetails', () => { beforeEach(() => { when(useRunTimeParameters).calledWith('fakeProtocolId').thenReturn([]) - when(vi.mocked(useFeatureFlag)) - .calledWith('enableCsvFile') - .thenReturn(false) vi.mocked(useCreateRunMutation).mockReturnValue({ createRun: mockCreateRun, } as any) @@ -248,7 +244,6 @@ describe('ODDProtocolDetails', () => { }) it('render requires csv text when a csv file is required', () => { - when(vi.mocked(useFeatureFlag)).calledWith('enableCsvFile').thenReturn(true) vi.mocked(useProtocolAnalysisAsDocumentQuery).mockReturnValue({ data: { id: 'mockAnalysisId', diff --git a/app/src/pages/ProtocolDetails/index.tsx b/app/src/pages/ProtocolDetails/index.tsx index cb7d979ea6f..d474328840f 100644 --- a/app/src/pages/ProtocolDetails/index.tsx +++ b/app/src/pages/ProtocolDetails/index.tsx @@ -45,7 +45,6 @@ import { getApplyHistoricOffsets, getPinnedProtocolIds, updateConfigValue, - useFeatureFlag, } from '../../redux/config' import { useOffsetCandidatesForAnalysis } from '../../organisms/ApplyHistoricOffsets/hooks/useOffsetCandidatesForAnalysis' import { @@ -308,7 +307,6 @@ export function ProtocolDetails(): JSX.Element | null { 'protocol_info', 'shared', ]) - const enableCsvFile = useFeatureFlag('enableCsvFile') const { protocolId } = useParams< keyof OnDeviceRouteParams >() as OnDeviceRouteParams @@ -381,7 +379,7 @@ export function ProtocolDetails(): JSX.Element | null { const isRequiredCsv = mostRecentAnalysis?.result === 'parameter-value-required' - if (enableCsvFile && isRequiredCsv) { + if (isRequiredCsv) { if (chipText === 'Ready to run') { chipText = i18n.format(t('requires_csv'), 'capitalize') } else { diff --git a/app/src/redux/config/constants.ts b/app/src/redux/config/constants.ts index 9c71360fa26..b197e2b3420 100644 --- a/app/src/redux/config/constants.ts +++ b/app/src/redux/config/constants.ts @@ -6,7 +6,6 @@ export const DEV_INTERNAL_FLAGS: DevInternalFlag[] = [ 'enableRunNotes', 'enableQuickTransfer', 'protocolTimeline', - 'enableCsvFile', 'enableLabwareCreator', ] diff --git a/app/src/redux/config/schema-types.ts b/app/src/redux/config/schema-types.ts index ae83dbabe7e..a8cd37da84a 100644 --- a/app/src/redux/config/schema-types.ts +++ b/app/src/redux/config/schema-types.ts @@ -13,7 +13,6 @@ export type DevInternalFlag = | 'enableRunNotes' | 'enableQuickTransfer' | 'protocolTimeline' - | 'enableCsvFile' | 'enableLabwareCreator' export type FeatureFlags = Partial> From 9e6eb1273e3b3091f04582059c541bf2ba344bcf Mon Sep 17 00:00:00 2001 From: Caila Marashaj <98041399+caila-marashaj@users.noreply.github.com> Date: Fri, 2 Aug 2024 13:21:11 -0400 Subject: [PATCH 37/49] feat(hardware): move pipette up out of liquid before requesting data buffer (#15852) --- .../hardware_control/tool_sensors.py | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/hardware/opentrons_hardware/hardware_control/tool_sensors.py b/hardware/opentrons_hardware/hardware_control/tool_sensors.py index c1b2f1650f5..6b6443d8828 100644 --- a/hardware/opentrons_hardware/hardware_control/tool_sensors.py +++ b/hardware/opentrons_hardware/hardware_control/tool_sensors.py @@ -54,7 +54,10 @@ MoveGroupStep, ) from opentrons_hardware.hardware_control.move_group_runner import MoveGroupRunner -from opentrons_hardware.hardware_control.types import MotorPositionStatus +from opentrons_hardware.hardware_control.types import ( + MotorPositionStatus, + MoveCompleteAck, +) LOG = getLogger(__name__) PipetteProbeTarget = Literal[NodeId.pipette_left, NodeId.pipette_right] @@ -179,6 +182,7 @@ async def run_sync_buffer_to_csv( tool: InstrumentProbeTarget, sensor_type: SensorType, output_file_heading: list[str], + raise_z: Optional[MoveGroupRunner] = None, ) -> Dict[NodeId, MotorPositionStatus]: """Runs the sensor pass move group and creates a csv file with the results.""" sensor_metadata = [0, 0, mount_speed, plunger_speed, threshold] @@ -197,6 +201,10 @@ async def run_sync_buffer_to_csv( ), expected_nodes=[tool], ) + if raise_z is not None and False: + # if probing is finished, move the head node back up before requesting the data buffer + if positions[head_node].move_ack == MoveCompleteAck.stopped_by_condition: + await raise_z.run(can_messenger=messenger) for sensor_id in log_files.keys(): sensor_capturer = LogListener( mount=head_node, @@ -472,14 +480,24 @@ async def liquid_probe( pressure_output_file_heading, ) elif sync_buffer_output: + raise_z = create_step( + distance={head_node: float64(max_z_distance)}, + velocity={head_node: float64(-1 * mount_speed)}, + acceleration={}, + duration=float64(max_z_distance / mount_speed), + present_nodes=[head_node], + ) + raise_z_runner = MoveGroupRunner(move_groups=[[raise_z]]) + return await run_sync_buffer_to_csv( - messenger, - mount_speed, - plunger_speed, - threshold_pascals, - head_node, - sensor_runner, - log_files, + messenger=messenger, + mount_speed=mount_speed, + plunger_speed=plunger_speed, + threshold=threshold_pascals, + head_node=head_node, + move_group=sensor_runner, + raise_z=raise_z_runner, + log_files=log_files, tool=tool, sensor_type=SensorType.pressure, output_file_heading=pressure_output_file_heading, From 446cb2ecb4d1e57b63f29c0df359dd4fdd19f6d3 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Fri, 2 Aug 2024 13:36:46 -0400 Subject: [PATCH 38/49] feat(app): Implement `hasEverEnteredErrorRecovery` (#15876) Closes EXEC-636 and EXEC-637 and RQA-2900 Because ER always requires the users to go through drop tip flows, it's redundant and mildly annoying for users to see a second set of now irrelevant CTAs. Now, we only show the CTAs at the end of the run if the run did not enter error recovery (and the conditions for detecting potential tip attachment occur). --- .../Devices/ProtocolRun/ProtocolRunHeader.tsx | 16 +++++++---- app/src/pages/RunSummary/index.tsx | 28 ++++++++++++++----- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx index a35276c7e8c..9f4bef400ee 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx @@ -164,7 +164,6 @@ export function ProtocolRunHeader({ const { startedAt, stoppedAt, completedAt } = useRunTimestamps(runId) const [showRunFailedModal, setShowRunFailedModal] = React.useState(false) const [showDropTipBanner, setShowDropTipBanner] = React.useState(true) - const [enteredER, setEnteredER] = React.useState(false) const isResetRunLoadingRef = React.useRef(false) const { data: runRecord } = useNotifyRunQuery(runId, { staleTime: Infinity }) const highestPriorityError = @@ -224,6 +223,8 @@ export function ProtocolRunHeader({ isMostRecentRunCurrent: mostRecentRunId === runId, }) + const enteredER = runRecord?.data.hasEverEnteredErrorRecovery + React.useEffect(() => { if (isFlex) { if (runStatus === RUN_STATUS_IDLE) { @@ -232,7 +233,8 @@ export function ProtocolRunHeader({ } else if ( runStatus != null && // @ts-expect-error runStatus expected to possibly not be terminal - RUN_STATUSES_TERMINAL.includes(runStatus) + RUN_STATUSES_TERMINAL.includes(runStatus) && + enteredER === false ) { void determineTipStatus() } @@ -245,9 +247,14 @@ export function ProtocolRunHeader({ } }, [protocolData, isRobotViewable, navigate]) + React.useEffect(() => { + if (isRunCurrent && typeof enteredER === 'boolean') { + reportRecoveredRunResult(runStatus, enteredER) + } + }, [isRunCurrent, enteredER]) + // Side effects dependent on the current run state. React.useEffect(() => { - reportRecoveredRunResult(runStatus, enteredER) // After a user-initiated stopped run, close the run current run automatically. if (runStatus === RUN_STATUS_STOPPED && isRunCurrent && runId != null) { trackProtocolRunEvent({ @@ -258,9 +265,6 @@ export function ProtocolRunHeader({ }) closeCurrentRun() } - if (runStatus === RUN_STATUS_AWAITING_RECOVERY) { - setEnteredER(true) - } }, [runStatus, isRunCurrent, runId, closeCurrentRun]) const startedAtTimestamp = diff --git a/app/src/pages/RunSummary/index.tsx b/app/src/pages/RunSummary/index.tsx index ab08e986544..b47c1838164 100644 --- a/app/src/pages/RunSummary/index.tsx +++ b/app/src/pages/RunSummary/index.tsx @@ -65,6 +65,7 @@ import { formatTimeWithUtcLabel, useNotifyRunQuery } from '../../resources/runs' import { handleTipsAttachedModal } from '../../organisms/DropTipWizardFlows/TipsAttachedModal' import { useMostRecentRunId } from '../../organisms/ProtocolUpload/hooks/useMostRecentRunId' import { useTipAttachmentStatus } from '../../organisms/DropTipWizardFlows' +import { useRecoveryAnalytics } from '../../organisms/ErrorRecoveryFlows/hooks' import type { OnDeviceRouteParams } from '../../App/types' import type { PipetteWithTip } from '../../organisms/DropTipWizardFlows' @@ -109,7 +110,6 @@ export function RunSummary(): JSX.Element { ) const localRobot = useSelector(getLocalRobot) const robotName = localRobot?.name ?? 'no name' - const { trackProtocolRunEvent } = useTrackProtocolRunEvent(runId, robotName) const onCloneRunSuccess = (): void => { if (isQuickTransfer) { @@ -117,10 +117,23 @@ export function RunSummary(): JSX.Element { } } + const { trackProtocolRunEvent } = useTrackProtocolRunEvent( + runId, + robotName as string + ) + const robotAnalyticsData = useRobotAnalyticsData(robotName as string) + const { reportRecoveredRunResult } = useRecoveryAnalytics() + + const enteredER = runRecord?.data.hasEverEnteredErrorRecovery + React.useEffect(() => { + if (isRunCurrent && typeof enteredER === 'boolean') { + reportRecoveredRunResult(runStatus, enteredER) + } + }, [isRunCurrent, enteredER]) + const { reset, isResetRunLoading } = useRunControls(runId, onCloneRunSuccess) const trackEvent = useTrackEvent() const { closeCurrentRun, isClosingCurrentRun } = useCloseCurrentRun() - const robotAnalyticsData = useRobotAnalyticsData(robotName) const [showRunFailedModal, setShowRunFailedModal] = React.useState( false ) @@ -151,10 +164,12 @@ export function RunSummary(): JSX.Element { isFlex: true, }) - // Determine tip status on initial render only. + // Determine tip status on initial render only. Error Recovery always handles tip status, so don't show it twice. React.useEffect(() => { - determineTipStatus() - }, []) + if (isRunCurrent && enteredER === false) { + void determineTipStatus() + } + }, [isRunCurrent, enteredER]) // TODO(jh, 08-02-24): Revisit useCurrentRunRoute and top level redirects. const queryClient = useQueryClient() @@ -164,7 +179,6 @@ export function RunSummary(): JSX.Element { queryClient.setQueryData([host, 'runs', runId, 'details'], () => undefined) navigate('/') } - // TODO(jh, 07-24-24): After EXEC-504, add reportRecoveredRunResult here. const returnToQuickTransfer = (): void => { if (!isRunCurrent) { @@ -215,7 +229,7 @@ export function RunSummary(): JSX.Element { } const handleRunAgain = (pipettesWithTip: PipetteWithTip[]): void => { - if (isRunCurrent && pipettesWithTip.length > 0) { + if (mostRecentRunId === runId && pipettesWithTip.length > 0) { void handleTipsAttachedModal({ setTipStatusResolved: setTipStatusResolvedAndRoute(handleRunAgain), host, From e60bbcd884cfc383049d177d15bc493b6a504e61 Mon Sep 17 00:00:00 2001 From: koji Date: Fri, 2 Aug 2024 14:01:55 -0400 Subject: [PATCH 39/49] chore: update electron-debug (#15877) * chore: update electron-debug --- app-shell/package.json | 2 +- yarn.lock | 39 +++++++-------------------------------- 2 files changed, 8 insertions(+), 33 deletions(-) diff --git a/app-shell/package.json b/app-shell/package.json index 457dc15eb55..e93babb3342 100644 --- a/app-shell/package.json +++ b/app-shell/package.json @@ -47,7 +47,7 @@ "axios": "^0.21.1", "dateformat": "3.0.3", "electron-context-menu": "3.6.1", - "electron-debug": "3.0.1", + "electron-debug": "3.2.0", "electron-is-dev": "1.2.0", "electron-localshortcut": "3.2.1", "electron-devtools-installer": "3.2.0", diff --git a/yarn.lock b/yarn.lock index 05383e56198..bcf51c052b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10124,10 +10124,10 @@ electron-context-menu@3.6.1: electron-dl "^3.2.1" electron-is-dev "^2.0.0" -electron-debug@3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/electron-debug/-/electron-debug-3.0.1.tgz#95b43b968ec7dbe96300034143e58b803a1e82dc" - integrity sha512-fo3mtDM4Bxxm3DW1I+XcJKfQlUlns4QGWyWGs8OrXK1bBZ2X9HeqYMntYBx78MYRcGY5S/ualuG4GhCnPlaZEA== +electron-debug@3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/electron-debug/-/electron-debug-3.2.0.tgz#46a15b555c3b11872218c65ea01d058aa0814920" + integrity sha512-7xZh+LfUvJ52M9rn6N+tPuDw6oRAjxUj9SoxAZfJ0hVCXhZCsdkrSt7TgXOiWiEOBgEV8qwUIO/ScxllsPS7ow== dependencies: electron-is-dev "^1.1.0" electron-localshortcut "^3.1.0" @@ -20381,16 +20381,7 @@ strict-uri-encode@^2.0.0: resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" integrity sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -20520,7 +20511,7 @@ stringify-object@^3.2.1: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -20541,13 +20532,6 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -22763,7 +22747,7 @@ worker-plugin@^5.0.0: dependencies: loader-utils "^1.1.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -22790,15 +22774,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From 3ef9ad614364673f899293cb7d45b64ab42a985c Mon Sep 17 00:00:00 2001 From: Brent Hagen Date: Fri, 2 Aug 2024 14:36:23 -0400 Subject: [PATCH 40/49] feat(components): migrate LocationIcon to DeckInfoLabel (#15846) updates and renames the location icon component to reflect helix/ODD styles closes PLAT-370, PLAT-408, RQA-2827 --- .../InterventionContent/InterventionInfo.tsx | 18 ++-- .../Devices/HistoricalProtocolRunDrawer.tsx | 4 +- .../SetupLabware/LabwareListItem.tsx | 8 +- .../__tests__/LabwareListItem.test.tsx | 4 +- .../shared/LeftColumnLabwareInfo.tsx | 2 +- .../__tests__/LeftColumnLabwareInfo.test.tsx | 4 +- .../MoveLabwareInterventionContent.tsx | 8 +- .../LabwarePositionCheck/ResultsSummary.tsx | 6 +- .../organisms/ProtocolSetupLabware/index.tsx | 30 +++--- .../ProtocolSetupLiquids/LiquidDetails.tsx | 4 +- .../FixtureTable.tsx | 4 +- .../ModuleTable.tsx | 6 +- app/src/pages/ProtocolDetails/Hardware.tsx | 8 +- .../pages/QuickTransferDetails/Hardware.tsx | 8 +- .../src/atoms/StyledText/StyledText.tsx | 19 +++- .../src/hardware-sim/Deck/SlotLabels.tsx | 38 ++------ .../helix-design-system/product/typography.ts | 7 ++ components/src/icons/ModuleIcon.tsx | 13 ++- .../DeckInfoLabel.stories.tsx} | 52 +++++----- .../__tests__/DeckInfoLabel.test.tsx | 49 ++++++++++ .../src/molecules/DeckInfoLabel/index.tsx | 94 +++++++++++++++++++ .../__tests__/LocationIcon.test.tsx | 52 ---------- .../src/molecules/LocationIcon/index.tsx | 89 ------------------ components/src/molecules/index.ts | 2 +- .../src/ui-style-constants/typography.ts | 1 - .../src/components/DeckSetup/SlotLabels.tsx | 18 ++-- 26 files changed, 276 insertions(+), 272 deletions(-) rename components/src/molecules/{LocationIcon/LocationIcon.stories.tsx => DeckInfoLabel/DeckInfoLabel.stories.tsx} (57%) create mode 100644 components/src/molecules/DeckInfoLabel/__tests__/DeckInfoLabel.test.tsx create mode 100644 components/src/molecules/DeckInfoLabel/index.tsx delete mode 100644 components/src/molecules/LocationIcon/__tests__/LocationIcon.test.tsx delete mode 100644 components/src/molecules/LocationIcon/index.tsx diff --git a/app/src/molecules/InterventionModal/InterventionContent/InterventionInfo.tsx b/app/src/molecules/InterventionModal/InterventionContent/InterventionInfo.tsx index aa6ad81c97e..1031519602a 100644 --- a/app/src/molecules/InterventionModal/InterventionContent/InterventionInfo.tsx +++ b/app/src/molecules/InterventionModal/InterventionContent/InterventionInfo.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { css } from 'styled-components' import { - LocationIcon, + DeckInfoLabel, Flex, Icon, COLORS, @@ -15,14 +15,14 @@ import { } from '@opentrons/components' import { Divider } from '../../../atoms/structure/Divider' -import type { LocationIconProps } from '@opentrons/components' +import type { DeckInfoLabelProps } from '@opentrons/components' export interface InterventionInfoProps { type: 'location-arrow-location' | 'location-colon-location' | 'location' labwareName: string labwareNickname?: string - currentLocationProps: LocationIconProps - newLocationProps?: LocationIconProps + currentLocationProps: DeckInfoLabelProps + newLocationProps?: DeckInfoLabelProps } export function InterventionInfo(props: InterventionInfoProps): JSX.Element { @@ -96,9 +96,9 @@ const buildLocArrowLoc = (props: InterventionInfoProps): JSX.Element => { } `} > - + - + ) } else { @@ -111,7 +111,7 @@ const buildLoc = ({ }: InterventionInfoProps): JSX.Element => { return ( - + ) } @@ -130,9 +130,9 @@ const buildLocColonLoc = (props: InterventionInfoProps): JSX.Element => { } `} > - + - +
) } else { diff --git a/app/src/organisms/Devices/HistoricalProtocolRunDrawer.tsx b/app/src/organisms/Devices/HistoricalProtocolRunDrawer.tsx index a2d897ca283..6171e7d6e01 100644 --- a/app/src/organisms/Devices/HistoricalProtocolRunDrawer.tsx +++ b/app/src/organisms/Devices/HistoricalProtocolRunDrawer.tsx @@ -9,12 +9,12 @@ import { BORDERS, Box, COLORS, + DeckInfoLabel, DIRECTION_COLUMN, Flex, InfoScreen, JUSTIFY_FLEX_START, LegacyStyledText, - LocationIcon, OVERFLOW_HIDDEN, SPACING, TYPOGRAPHY, @@ -254,7 +254,7 @@ export function HistoricalProtocolRunDrawer( gridGap={SPACING.spacing4} alignItems={ALIGN_CENTER} > - + {offset.location.moduleModel != null ? getModuleDisplayName(offset.location.moduleModel) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareListItem.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareListItem.tsx index 32c3d434c35..651657d4597 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareListItem.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareListItem.tsx @@ -6,8 +6,8 @@ import { ALIGN_CENTER, BORDERS, Btn, - LocationIcon, COLORS, + DeckInfoLabel, DIRECTION_COLUMN, DIRECTION_ROW, DISPLAY_FLEX, @@ -270,7 +270,7 @@ export function LabwareListItem( {slotInfo != null && isFlex ? ( - + ) : ( )} {nestedLabwareInfo != null || moduleDisplayName != null ? ( - + ) : null} {moduleType != null ? ( - ) : null} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/LabwareListItem.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/LabwareListItem.test.tsx index 108439c1262..0a83231dee5 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/LabwareListItem.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/LabwareListItem.test.tsx @@ -172,7 +172,7 @@ describe('LabwareListItem', () => { }) screen.getByText('Mock Labware Definition') screen.getByTestId('slot_info_7') - screen.getByTestId('LocationIcon_stacked') + screen.getByTestId('DeckInfoLabel_stacked') screen.getByText('Magnetic Module GEN1') const button = screen.getByText('Secure labware instructions') fireEvent.click(button) @@ -207,7 +207,7 @@ describe('LabwareListItem', () => { }) screen.getByText('Mock Labware Definition') screen.getByTestId('slot_info_7') - screen.getByTestId('LocationIcon_stacked') + screen.getByTestId('DeckInfoLabel_stacked') screen.getByText('Temperature Module GEN1') screen.getByText('nickName') }) diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/LeftColumnLabwareInfo.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/LeftColumnLabwareInfo.tsx index 2c38ec645c6..9363bdf7e50 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/LeftColumnLabwareInfo.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/LeftColumnLabwareInfo.tsx @@ -44,7 +44,7 @@ export function LeftColumnLabwareInfo({ type, labwareName: failedLabwareName ?? '', labwareNickname: failedLabwareNickname ?? '', - currentLocationProps: { slotName: buildLabwareLocationSlotName() }, + currentLocationProps: { deckLabel: buildLabwareLocationSlotName() }, }} notificationProps={ bannerText ? { type: 'alert', heading: bannerText } : undefined diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/LeftColumnLabwareInfo.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/LeftColumnLabwareInfo.test.tsx index 0edf9b95236..30aae62a9ca 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/LeftColumnLabwareInfo.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/LeftColumnLabwareInfo.test.tsx @@ -52,7 +52,7 @@ describe('LeftColumnLabwareInfo', () => { expect.objectContaining({ type: 'location', labwareName: 'MOCK_LW_NAME', - currentLocationProps: { slotName: 'A1' }, + currentLocationProps: { deckLabel: 'A1' }, }), {} ) @@ -82,7 +82,7 @@ describe('LeftColumnLabwareInfo', () => { expect(vi.mocked(InterventionInfo)).toHaveBeenCalledWith( expect.objectContaining({ - currentLocationProps: { slotName: '' }, + currentLocationProps: { deckLabel: '' }, }), {} ) diff --git a/app/src/organisms/InterventionModal/MoveLabwareInterventionContent.tsx b/app/src/organisms/InterventionModal/MoveLabwareInterventionContent.tsx index 469e5e9f2d7..f8692794081 100644 --- a/app/src/organisms/InterventionModal/MoveLabwareInterventionContent.tsx +++ b/app/src/organisms/InterventionModal/MoveLabwareInterventionContent.tsx @@ -7,12 +7,12 @@ import { BORDERS, Box, COLORS, + DeckInfoLabel, DIRECTION_COLUMN, DISPLAY_NONE, Flex, Icon, LabwareRender, - LocationIcon, Module, MoveLabwareOnDeck, RESPONSIVENESS, @@ -256,11 +256,11 @@ function LabwareDisplayLocation( let displayLocation: React.ReactNode = '' if (location === 'offDeck') { // TODO(BC, 08/28/23): remove this string cast after update i18next to >23 (see https://www.i18next.com/overview/typescript#argument-of-type-defaulttfuncreturn-is-not-assignable-to-parameter-of-type-xyz) - displayLocation = + displayLocation = } else if ('slotName' in location) { - displayLocation = + displayLocation = } else if ('addressableAreaName' in location) { - displayLocation = + displayLocation = } else if ('moduleId' in location) { const moduleModel = getModuleModelFromRunData( protocolData, diff --git a/app/src/organisms/LabwarePositionCheck/ResultsSummary.tsx b/app/src/organisms/LabwarePositionCheck/ResultsSummary.tsx index 65320d3cc36..3459b095f61 100644 --- a/app/src/organisms/LabwarePositionCheck/ResultsSummary.tsx +++ b/app/src/organisms/LabwarePositionCheck/ResultsSummary.tsx @@ -17,11 +17,11 @@ import { ALIGN_FLEX_END, BORDERS, COLORS, + DeckInfoLabel, DIRECTION_COLUMN, Flex, Icon, JUSTIFY_SPACE_BETWEEN, - LocationIcon, MODULE_ICON_NAME_BY_TYPE, OVERFLOW_AUTO, PrimaryButton, @@ -373,9 +373,9 @@ export const TerseOffsetTable = (props: OffsetTableProps): JSX.Element => { return ( - + {location.moduleModel != null ? ( - + location = } else if ( selectedLabware != null && typeof selectedLabware.location === 'object' && 'addressableAreaName' in selectedLabware?.location ) { location = ( - + ) } else if ( selectedLabware != null && @@ -169,7 +171,7 @@ export function ProtocolSetupLabware({ module.moduleId === selectedLabware.location.moduleId ) if (matchedModule != null) { - location = + location = } } else if ( selectedLabware != null && @@ -184,13 +186,13 @@ export function ProtocolSetupLabware({ )?.params.location if (adapterLocation != null && adapterLocation !== 'offDeck') { if ('slotName' in adapterLocation) { - location = + location = } else if ('moduleId' in adapterLocation) { const moduleUnderAdapter = attachedProtocolModuleMatches.find( module => module.moduleId === adapterLocation.moduleId ) if (moduleUnderAdapter != null) { - location = + location = } } } @@ -492,19 +494,19 @@ function RowLabware({ let location: JSX.Element | string | null = null if (initialLocation === 'offDeck') { location = ( - + ) } else if ('slotName' in initialLocation) { slotName = initialLocation.slotName - location = + location = } else if ('addressableAreaName' in initialLocation) { slotName = initialLocation.addressableAreaName - location = + location = } else if (matchedModuleType != null && matchedModule?.slotName != null) { slotName = matchedModule.slotName location = ( <> - + ) } else if ('labwareId' in initialLocation) { @@ -518,14 +520,14 @@ function RowLabware({ if (adapterLocation != null && adapterLocation !== 'offDeck') { if ('slotName' in adapterLocation) { slotName = adapterLocation.slotName - location = + location = } else if ('moduleId' in adapterLocation) { const moduleUnderAdapter = attachedProtocolModules.find( module => module.moduleId === adapterLocation.moduleId ) if (moduleUnderAdapter != null) { slotName = moduleUnderAdapter.slotName - location = + location = } } } @@ -541,7 +543,7 @@ function RowLabware({ {location} {nestedLabwareInfo != null || matchedModule != null ? ( - + ) : null} - - + diff --git a/app/src/organisms/ProtocolSetupModulesAndDeck/FixtureTable.tsx b/app/src/organisms/ProtocolSetupModulesAndDeck/FixtureTable.tsx index 5576c7d049c..b8885dd60a1 100644 --- a/app/src/organisms/ProtocolSetupModulesAndDeck/FixtureTable.tsx +++ b/app/src/organisms/ProtocolSetupModulesAndDeck/FixtureTable.tsx @@ -5,10 +5,10 @@ import { BORDERS, COLORS, Chip, + DeckInfoLabel, DIRECTION_ROW, Flex, JUSTIFY_SPACE_BETWEEN, - LocationIcon, SPACING, LegacyStyledText, TYPOGRAPHY, @@ -219,7 +219,7 @@ function FixtureTableItem({
- + - ) if (hardware.hardwareType === 'module') { - location = + location = } else if (hardware.hardwareType === 'fixture') { location = ( - + ) } const isMagneticBlockFixture = diff --git a/app/src/pages/QuickTransferDetails/Hardware.tsx b/app/src/pages/QuickTransferDetails/Hardware.tsx index 11e6e3319c8..399f9764f97 100644 --- a/app/src/pages/QuickTransferDetails/Hardware.tsx +++ b/app/src/pages/QuickTransferDetails/Hardware.tsx @@ -5,8 +5,8 @@ import { ALIGN_CENTER, BORDERS, COLORS, + DeckInfoLabel, Flex, - LocationIcon, ModuleIcon, SPACING, LegacyStyledText, @@ -114,10 +114,12 @@ function HardwareItem({ ) if (hardware.hardwareType === 'module') { - location = + location = } else if (hardware.hardwareType === 'fixture') { location = ( - + ) } const isMagneticBlockFixture = diff --git a/components/src/atoms/StyledText/StyledText.tsx b/components/src/atoms/StyledText/StyledText.tsx index 3bb124a3def..fc33536da9a 100644 --- a/components/src/atoms/StyledText/StyledText.tsx +++ b/components/src/atoms/StyledText/StyledText.tsx @@ -111,6 +111,14 @@ const helixProductStyleMap = { } `, }, + captionBold: { + as: 'label', + style: css` + @media not (${RESPONSIVENESS.touchscreenMediaQuerySpecs}) { + font: ${HELIX_TYPOGRAPHY.fontStyleCaptionBold}; + } + `, + }, captionRegular: { as: 'label', style: css` @@ -300,12 +308,13 @@ function styleForODDName(name?: ODDStyles): FlattenSimpleInterpolation { return name ? ODDStyleMap[name].style : css`` } -// this is some artifact of the way styled-text forwards arguments. -/* eslint-disable @typescript-eslint/no-unsafe-argument */ -const DesktopStyledText: (props: Props) => JSX.Element = styled(Text)` - ${props => styleForDesktopName(props.desktopStyle)} +const DesktopStyledText: (props: Props) => JSX.Element = styled( + Text +).withConfig({ + shouldForwardProp: p => p !== 'oddStyle' && p !== 'desktopStyle', +})` + ${(props: Props) => styleForDesktopName(props.desktopStyle)} ` -/* eslint-enable @typescript-eslint/no-unsafe-argument */ export const StyledText: (props: Props) => JSX.Element = styled( DesktopStyledText diff --git a/components/src/hardware-sim/Deck/SlotLabels.tsx b/components/src/hardware-sim/Deck/SlotLabels.tsx index 31648cda9c0..ffa69db790d 100644 --- a/components/src/hardware-sim/Deck/SlotLabels.tsx +++ b/components/src/hardware-sim/Deck/SlotLabels.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' -import { LocationIcon } from '../../molecules' +import { DeckInfoLabel } from '../../molecules' import { Flex } from '../../primitives' import { ALIGN_CENTER, DIRECTION_COLUMN, JUSTIFY_CENTER } from '../../styles' import { RobotCoordsForeignObject } from './RobotCoordsForeignObject' @@ -41,36 +41,16 @@ export const SlotLabels = ({ width="2.5rem" > - + - + - + - + @@ -99,14 +79,14 @@ export const SlotLabels = ({ justifyContent={JUSTIFY_CENTER} width={`${widthLargeRem}rem`} > - +
- + - + {show4thColumn ? ( - + ) : null}
diff --git a/components/src/helix-design-system/product/typography.ts b/components/src/helix-design-system/product/typography.ts index 2d80ffc99f1..f2f83996a15 100644 --- a/components/src/helix-design-system/product/typography.ts +++ b/components/src/helix-design-system/product/typography.ts @@ -117,6 +117,13 @@ const fontSizeCaption = '0.8125rem' // 13px const lineHeightCaption = '1rem' // 16px const fontFamilyCaption = fontFamily +// Caption-Bold +export const fontSizeCaptionBold = fontSizeCaption +export const lineHeightCaptionBold = lineHeightCaption +export const fontFamilyCaptionBold = fontFamilyCaption +export const fontWeightCaptionBold = fontWeightBold +export const fontStyleCaptionBold = `${fontWeightCaptionBold} ${fontSizeCaptionBold}/${lineHeightCaptionBold} ${fontFamilyCaptionBold}` + // Caption-Medium export const fontSizeCaptionSemiBold = fontSizeCaption export const lineHeightCaptionSemiBold = lineHeightCaption diff --git a/components/src/icons/ModuleIcon.tsx b/components/src/icons/ModuleIcon.tsx index 616c6c1408d..d5ba6b78b70 100644 --- a/components/src/icons/ModuleIcon.tsx +++ b/components/src/icons/ModuleIcon.tsx @@ -11,9 +11,18 @@ import { import type { ModuleType } from '@opentrons/shared-data' import type { StyleProps } from '../primitives/types' -import type { IconName } from './Icon' -export const MODULE_ICON_NAME_BY_TYPE: { [type in ModuleType]: IconName } = { +export type ModuleIconName = + | 'ot-magnet-v2' + | 'ot-heater-shaker' + | 'ot-temperature-v2' + | 'ot-magnet-v2' + | 'ot-thermocycler' + | 'ot-absorbance' + +export const MODULE_ICON_NAME_BY_TYPE: { + [type in ModuleType]: ModuleIconName +} = { [MAGNETIC_BLOCK_TYPE]: 'ot-magnet-v2', [HEATERSHAKER_MODULE_TYPE]: 'ot-heater-shaker', [TEMPERATURE_MODULE_TYPE]: 'ot-temperature-v2', diff --git a/components/src/molecules/LocationIcon/LocationIcon.stories.tsx b/components/src/molecules/DeckInfoLabel/DeckInfoLabel.stories.tsx similarity index 57% rename from components/src/molecules/LocationIcon/LocationIcon.stories.tsx rename to components/src/molecules/DeckInfoLabel/DeckInfoLabel.stories.tsx index 64f150640c2..ee604d56453 100644 --- a/components/src/molecules/LocationIcon/LocationIcon.stories.tsx +++ b/components/src/molecules/DeckInfoLabel/DeckInfoLabel.stories.tsx @@ -2,48 +2,37 @@ import * as React from 'react' import { customViewports } from '../../../../.storybook/preview' import { Flex } from '../../primitives' import { SPACING } from '../../ui-style-constants' -import { ICON_DATA_BY_NAME } from '../../icons' -import { LocationIcon } from '.' +import { DeckInfoLabel } from '.' import type { Meta, StoryObj } from '@storybook/react' -const slots = [ - 'A1', - 'A2', - 'A3', - 'A4', - 'B1', - 'B2', - 'B3', - 'B4', - 'C1', - 'C2', - 'C3', - 'C4', - 'D1', - 'D2', - 'D3', - 'D4', -] - -const meta: Meta = { - title: 'Library/Molecules/LocationIcon', +const meta: Meta = { + title: 'Library/Molecules/DeckInfoLabel', argTypes: { iconName: { control: { type: 'select', }, - options: Object.keys(ICON_DATA_BY_NAME), + options: [ + 'ot-magnet-v2', + 'ot-heater-shaker', + 'ot-temperature-v2', + 'ot-magnet-v2', + 'ot-thermocycler', + 'ot-absorbance', + 'stacked', + ], }, - slotName: { + deckLabel: { control: { - type: 'select', + type: 'text', }, - options: slots, + defaultValue: 'A1', }, }, - component: LocationIcon, + component: DeckInfoLabel, parameters: { + controls: { include: ['highlight', 'iconName', 'deckLabel'] }, viewport: { viewports: customViewports, defaultViewport: 'onDeviceDisplay', @@ -58,17 +47,20 @@ const meta: Meta = { ], } export default meta -type Story = StoryObj +type Story = StoryObj export const DisplaySlot: Story = { args: { - slotName: 'A1', + deckLabel: 'A1', iconName: undefined, + highlight: false, }, } export const DisplayIcon: Story = { args: { + deckLabel: undefined, iconName: 'ot-temperature-v2', + highlight: false, }, } diff --git a/components/src/molecules/DeckInfoLabel/__tests__/DeckInfoLabel.test.tsx b/components/src/molecules/DeckInfoLabel/__tests__/DeckInfoLabel.test.tsx new file mode 100644 index 00000000000..0784374d363 --- /dev/null +++ b/components/src/molecules/DeckInfoLabel/__tests__/DeckInfoLabel.test.tsx @@ -0,0 +1,49 @@ +import * as React from 'react' +import { describe, it, beforeEach, expect } from 'vitest' +import { renderWithProviders } from '../../../testing/utils' +import { screen } from '@testing-library/react' +import { SPACING } from '../../../ui-style-constants' +import { BORDERS, COLORS } from '../../../helix-design-system' + +import { DeckInfoLabel } from '..' + +const render = (props: React.ComponentProps) => { + return renderWithProviders() +} + +describe('DeckInfoLabel', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + deckLabel: 'A1', + } + }) + + it('should render the proper styles - web style', () => { + render(props) + const deckInfoLabel = screen.getByTestId('DeckInfoLabel_A1') + expect(deckInfoLabel).toHaveStyle( + `padding: ${SPACING.spacing2} ${SPACING.spacing4}` + ) + expect(deckInfoLabel).toHaveStyle(`height: ${SPACING.spacing20}`) + expect(deckInfoLabel).toHaveStyle('width: max-content') + expect(deckInfoLabel).toHaveStyle(`border: 2px solid ${COLORS.black90}`) + expect(deckInfoLabel).toHaveStyle(`border-radius: ${BORDERS.borderRadius8}`) + }) + + it.todo('should render the proper styles - odd style') + + it('should render deck label', () => { + render(props) + screen.getByText('A1') + }) + + it('should render an icon', () => { + props = { + iconName: 'ot-temperature-v2', + } + render(props) + screen.getByLabelText(props.iconName) + }) +}) diff --git a/components/src/molecules/DeckInfoLabel/index.tsx b/components/src/molecules/DeckInfoLabel/index.tsx new file mode 100644 index 00000000000..86666c3263f --- /dev/null +++ b/components/src/molecules/DeckInfoLabel/index.tsx @@ -0,0 +1,94 @@ +import * as React from 'react' +import styled from 'styled-components' + +import { StyledText } from '../../atoms' +import { BORDERS, COLORS } from '../../helix-design-system' +import { Icon } from '../../icons' +import { Flex } from '../../primitives' +import { ALIGN_CENTER, JUSTIFY_CENTER } from '../../styles' +import { RESPONSIVENESS, SPACING } from '../../ui-style-constants' + +import type { ModuleIconName } from '../../icons' +import type { StyleProps } from '../../primitives' + +interface DeckLabelProps extends StyleProps { + /** deck label to display */ + deckLabel: string + iconName?: undefined +} + +interface HardwareIconProps extends StyleProps { + /** hardware icon name */ + iconName: ModuleIconName | 'stacked' + deckLabel?: undefined +} + +// type union requires one of deckLabel or iconName, but not both +export type DeckInfoLabelProps = (DeckLabelProps | HardwareIconProps) & { + highlight?: boolean +} + +export const DeckInfoLabel = styled(DeckInfoLabelComponent)` + align-items: ${ALIGN_CENTER}; + background-color: ${props => + props.highlight ?? false ? COLORS.blue50 : 'inherit'}; + border: 2px solid + ${props => (props.highlight ?? false ? 'transparent' : COLORS.black90)}; + width: ${props => props.width ?? 'max-content'}; + padding: ${SPACING.spacing2} ${SPACING.spacing4}; + border-radius: ${BORDERS.borderRadius8}; + justify-content: ${JUSTIFY_CENTER}; + height: ${props => + props.height ?? SPACING.spacing20}; // prevents the icon from being squished + + > svg { + height: 0.875rem; + width: 0.875rem; + } + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + border-radius: ${BORDERS.borderRadius12}; + height: ${props => props.height ?? SPACING.spacing32}; + padding: ${SPACING.spacing4} + ${props => + props.deckLabel != null ? SPACING.spacing8 : SPACING.spacing6}; + > svg { + height: 1.25rem; + width: 1.25rem; + } + } +` + +function DeckInfoLabelComponent({ + deckLabel, + iconName, + highlight = false, + ...styleProps +}: DeckInfoLabelProps): JSX.Element { + return ( + + {iconName != null ? ( + + ) : ( + + {deckLabel} + + )} + + ) +} diff --git a/components/src/molecules/LocationIcon/__tests__/LocationIcon.test.tsx b/components/src/molecules/LocationIcon/__tests__/LocationIcon.test.tsx deleted file mode 100644 index 1750d594d1d..00000000000 --- a/components/src/molecules/LocationIcon/__tests__/LocationIcon.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import * as React from 'react' -import { describe, it, beforeEach, expect } from 'vitest' -import { renderWithProviders } from '../../../testing/utils' -import { screen } from '@testing-library/react' -import { SPACING, TYPOGRAPHY } from '../../../ui-style-constants' -import { BORDERS, COLORS } from '../../../helix-design-system' - -import { LocationIcon } from '..' - -const render = (props: React.ComponentProps) => { - return renderWithProviders() -} - -describe('LocationIcon', () => { - let props: React.ComponentProps - - beforeEach(() => { - props = { - slotName: 'A1', - } - }) - - it('should render the proper styles - web style', () => { - render(props) - const locationIcon = screen.getByTestId('LocationIcon_A1') - expect(locationIcon).toHaveStyle( - `padding: ${SPACING.spacing2} ${SPACING.spacing4}` - ) - expect(locationIcon).toHaveStyle('height: max-content') - expect(locationIcon).toHaveStyle('width: max-content') - expect(locationIcon).toHaveStyle(`border: 1px solid ${COLORS.black90}`) - expect(locationIcon).toHaveStyle(`border-radius: ${BORDERS.borderRadius4}`) - }) - - it.todo('should render the proper styles - odd style') - - it('should render slot name', () => { - render(props) - const text = screen.getByText('A1') - expect(text).toHaveStyle(`font-size: ${TYPOGRAPHY.fontSizeCaption}`) - expect(text).toHaveStyle('line-height: normal') - expect(text).toHaveStyle(` font-weight: ${TYPOGRAPHY.fontWeightBold}`) - }) - - it('should render an icon', () => { - props = { - iconName: 'ot-temperature-v2', - } - render(props) - screen.getByLabelText(props.iconName as string) - }) -}) diff --git a/components/src/molecules/LocationIcon/index.tsx b/components/src/molecules/LocationIcon/index.tsx deleted file mode 100644 index 6a922f155c0..00000000000 --- a/components/src/molecules/LocationIcon/index.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import * as React from 'react' -import { css } from 'styled-components' - -import { Icon } from '../../icons' -import { Flex, Text } from '../../primitives' -import { ALIGN_CENTER, JUSTIFY_CENTER } from '../../styles' -import { RESPONSIVENESS, SPACING, TYPOGRAPHY } from '../../ui-style-constants' -import { BORDERS, COLORS } from '../../helix-design-system' - -import type { IconName } from '../../icons' -import type { StyleProps } from '../../primitives' - -interface SlotLocationProps extends StyleProps { - /** name constant of the slot to display */ - slotName: string - iconName?: undefined -} - -interface HardwareIconProps extends StyleProps { - /** hardware icon name */ - iconName: IconName - slotName?: undefined -} - -// type union requires one of slotName or iconName, but not both -export type LocationIconProps = SlotLocationProps | HardwareIconProps - -const LOCATION_ICON_STYLE = css<{ - slotName?: string - color?: string - height?: string - width?: string -}>` - align-items: ${ALIGN_CENTER}; - border: 1px solid ${props => props.color ?? COLORS.black90}; - width: ${props => props.width ?? 'max-content'}; - padding: ${SPACING.spacing2} ${SPACING.spacing4}; - border-radius: ${BORDERS.borderRadius4}; - justify-content: ${JUSTIFY_CENTER}; - height: max-content; // prevents the icon from being squished - - @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { - border: 2px solid ${props => props.color ?? COLORS.black90}; - border-radius: ${BORDERS.borderRadius12}; - height: ${props => props.height ?? SPACING.spacing32}; - padding: ${SPACING.spacing4} - ${props => (props.slotName != null ? SPACING.spacing8 : SPACING.spacing6)}; - } -` - -const SLOT_NAME_TEXT_STYLE = css` - font-size: ${TYPOGRAPHY.fontSizeCaption}; - line-height: normal; - font-weight: ${TYPOGRAPHY.fontWeightBold}; - - @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { - ${TYPOGRAPHY.smallBodyTextBold} - } -` - -export function LocationIcon({ - slotName, - iconName, - color, - ...styleProps -}: LocationIconProps): JSX.Element { - return ( - - {iconName != null ? ( - - ) : ( - {slotName} - )} - - ) -} diff --git a/components/src/molecules/index.ts b/components/src/molecules/index.ts index e188f8070d3..e4b70216f31 100644 --- a/components/src/molecules/index.ts +++ b/components/src/molecules/index.ts @@ -1,5 +1,5 @@ +export * from './DeckInfoLabel' export * from './LiquidIcon' -export * from './LocationIcon' export * from './Tabs' export * from './ParametersTable' export * from './ParametersTable/InfoScreen' diff --git a/components/src/ui-style-constants/typography.ts b/components/src/ui-style-constants/typography.ts index 83134e5cedf..b596d7376f5 100644 --- a/components/src/ui-style-constants/typography.ts +++ b/components/src/ui-style-constants/typography.ts @@ -15,7 +15,6 @@ export const fontSizeH4 = '0.813rem' // 13px export const fontSizeH6 = '0.563rem' // 9px export const fontSizeP = '0.8125rem' // 13px export const fontSizeLabel = '0.6875rem' // 11px -// this is redundant but we need this for captions and it makes more sense to call it caption rather than re-using fsh6 export const fontSizeCaption = '0.625rem' // 10px // Font Weights diff --git a/protocol-designer/src/components/DeckSetup/SlotLabels.tsx b/protocol-designer/src/components/DeckSetup/SlotLabels.tsx index 5b736cf760e..ffbbb73fde0 100644 --- a/protocol-designer/src/components/DeckSetup/SlotLabels.tsx +++ b/protocol-designer/src/components/DeckSetup/SlotLabels.tsx @@ -2,9 +2,9 @@ import * as React from 'react' import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' import { + DeckInfoLabel, Flex, JUSTIFY_CENTER, - LocationIcon, RobotCoordsForeignObject, ALIGN_CENTER, DIRECTION_COLUMN, @@ -44,16 +44,16 @@ export const SlotLabels = ({ width="2.5rem" > - + - + - + - + @@ -74,21 +74,21 @@ export const SlotLabels = ({ justifyContent={JUSTIFY_CENTER} flex="1" > - + - + - + {hasStagingAreas ? ( - + ) : null} From a80502f6a954c4026fcd9cdca45bb7588bd40140 Mon Sep 17 00:00:00 2001 From: Brent Hagen Date: Fri, 2 Aug 2024 14:45:37 -0400 Subject: [PATCH 41/49] fix(app): disallow 8-channel 384 well selection if no starting well selected (#15659) disables the plus button for 8-channel 384 well selection when no starting well selected. also fixes a 384 well edge case when navigating back through the quick transfer flow - deselects all wells on 384 well selection mount to keep the the last selected index pointer in sync with the selected wells. closes RQA-2854 --- .../WellSelection/Selection384Wells.tsx | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/app/src/organisms/WellSelection/Selection384Wells.tsx b/app/src/organisms/WellSelection/Selection384Wells.tsx index fe99e0ecdd1..b857c9040ee 100644 --- a/app/src/organisms/WellSelection/Selection384Wells.tsx +++ b/app/src/organisms/WellSelection/Selection384Wells.tsx @@ -98,8 +98,7 @@ export function Selection384Wells({ if (selectBy === 'columns') { if (channels === 8) { - // for 8-channel, select first and second member of column (all rows) unless only one starting well option is selected - if (startingWellState.A1 === startingWellState.B1) { + if (startingWellState.A1 && startingWellState.B1) { selectWells({ [columns[nextIndex][0]]: null, [columns[nextIndex][1]]: null, @@ -152,19 +151,27 @@ export function Selection384Wells({ ) : ( )} @@ -225,20 +232,20 @@ type StartingWellOption = 'A1' | 'B1' | 'A2' | 'B2' function StartingWell({ channels, - columns, deselectWells, selectWells, startingWellState, setStartingWellState, + wells, }: { channels: PipetteChannels - columns: string[][] deselectWells: (wells: string[]) => void selectWells: (wellGroup: WellGroup) => void startingWellState: Record setStartingWellState: React.Dispatch< React.SetStateAction> > + wells: string[] }): JSX.Element { const { t, i18n } = useTranslation('quick_transfer') @@ -247,6 +254,9 @@ function StartingWell({ // on mount, select A1 well group for 96-channel React.useEffect(() => { + // deselect all wells on mount; clears well selection when navigating back within quick transfer flow + // otherwise, selected wells and lastSelectedIndex pointer will be out of sync + deselectWells(wells) if (channels === 96) { selectWells({ A1: null }) } @@ -288,8 +298,8 @@ interface ButtonControlsProps { channels: PipetteChannels handleMinus: () => void handlePlus: () => void - lastSelectedIndex: number | null - selectBy: 'columns' | 'wells' + minusDisabled: boolean + plusDisabled: boolean } function ButtonControls(props: ButtonControlsProps): JSX.Element { @@ -297,8 +307,8 @@ function ButtonControls(props: ButtonControlsProps): JSX.Element { channels, handleMinus, handlePlus, - lastSelectedIndex, - selectBy, + minusDisabled, + plusDisabled, } = props const { t, i18n } = useTranslation('quick_transfer') @@ -313,18 +323,14 @@ function ButtonControls(props: ButtonControlsProps): JSX.Element { Date: Fri, 2 Aug 2024 15:11:14 -0400 Subject: [PATCH 42/49] feat(robot-server): implement data files auto-deletion (#15879) Closes AUTH-467 # Overview Adds auto-deletion of old data files. If the data files stored on disk exceed 50 files, then we start auto-deleting files as needed. The oldest file which is not being referenced by any analysis or run in the database is deleted from disk and removed from the database ## Test Plan and Hands on Testing - Add 50 data files to the robot, use some of them in a run - Check that 50 files still exist - Add another file - Check that the oldest unused file is deleted (pro-tip: you can change the file limit in robot settings for testing) ## Changelog - added `DataFileAutoDeleter` and `DataFileDeletionPlanner` - updated data file router to use auto-deletion before adding a new file - added new `remove()` method to `DataFilesStore` ## Review requests - Usual code review ## Risk assessment Medium. Doesn't change existing infrastructure, except that if implemented incorrectly, could delete data files unexpectedly. --- .../data_files/data_files_store.py | 103 ++++++++- .../robot_server/data_files/dependencies.py | 20 +- .../data_files/file_auto_deleter.py | 40 ++++ .../robot_server/data_files/models.py | 10 + .../robot_server/data_files/router.py | 10 +- robot-server/robot_server/deletion_planner.py | 58 ++++- .../persistence/tables/__init__.py | 2 + robot-server/robot_server/settings.py | 8 + .../tests/data_files/test_data_files_store.py | 204 +++++++++++++++++- .../data_files/test_file_auto_deleter.py | 40 ++++ robot-server/tests/data_files/test_router.py | 12 ++ .../test_completed_analysis_store.py | 6 +- .../tests/protocols/test_protocol_store.py | 6 +- robot-server/tests/test_deletion_planner.py | 56 +++++ 14 files changed, 558 insertions(+), 17 deletions(-) create mode 100644 robot-server/robot_server/data_files/file_auto_deleter.py create mode 100644 robot-server/tests/data_files/test_file_auto_deleter.py diff --git a/robot-server/robot_server/data_files/data_files_store.py b/robot-server/robot_server/data_files/data_files_store.py index 4427f8efb91..785046d80a1 100644 --- a/robot-server/robot_server/data_files/data_files_store.py +++ b/robot-server/robot_server/data_files/data_files_store.py @@ -3,14 +3,20 @@ from dataclasses import dataclass from datetime import datetime -from typing import Optional, List +from pathlib import Path +from typing import Optional, List, Set import sqlalchemy.engine +from robot_server.deletion_planner import FileUsageInfo from robot_server.persistence.database import sqlite_rowid -from robot_server.persistence.tables import data_files_table +from robot_server.persistence.tables import ( + data_files_table, + analysis_csv_rtp_table, + run_csv_rtp_table, +) -from .models import FileIdNotFoundError +from .models import FileIdNotFoundError, FileInUseError @dataclass(frozen=True) @@ -29,9 +35,11 @@ class DataFilesStore: def __init__( self, sql_engine: sqlalchemy.engine.Engine, + data_files_directory: Path, ) -> None: """Create a new DataFilesStore.""" self._sql_engine = sql_engine + self._data_files_directory = data_files_directory def get_file_info_by_hash(self, file_hash: str) -> Optional[DataFileInfo]: """Get the ID of data file having the provided hash.""" @@ -72,6 +80,95 @@ def sql_get_all_from_engine(self) -> List[DataFileInfo]: all_rows = transaction.execute(statement).all() return [_convert_row_data_file_info(sql_row) for sql_row in all_rows] + def get_usage_info(self) -> List[FileUsageInfo]: + """Return information about usage of all the existing data files in runs & analyses. + + Results are ordered with the oldest-added data file first. + """ + select_all_data_file_ids = sqlalchemy.select(data_files_table.c.id).order_by( + sqlite_rowid + ) + select_ids_used_in_analyses = sqlalchemy.select( + analysis_csv_rtp_table.c.file_id + ).where(analysis_csv_rtp_table.c.file_id.is_not(None)) + select_ids_used_in_runs = sqlalchemy.select(run_csv_rtp_table.c.file_id).where( + run_csv_rtp_table.c.file_id.is_not(None) + ) + + with self._sql_engine.begin() as transaction: + all_file_ids: List[str] = ( + transaction.execute(select_all_data_file_ids).scalars().all() + ) + files_used_in_analyses: Set[str] = set( + transaction.execute(select_ids_used_in_analyses).scalars().all() + ) + files_used_in_runs: Set[str] = set( + transaction.execute(select_ids_used_in_runs).scalars().all() + ) + + usage_info = [ + FileUsageInfo( + file_id=file_id, + used_by_run_or_analysis=( + file_id in files_used_in_runs or file_id in files_used_in_analyses + ), + ) + for file_id in all_file_ids + ] + return usage_info + + def remove(self, file_id: str) -> None: + """Remove the specified files from database and persistence directory. + + This should only be called when the specified file has no references + in the database. + + Raises: + FileIdNotFoundError: the given file ID was not found in the store. + FileInUseError: the given file is referenced by an analysis or run + and cannot be deleted. + """ + select_ids_used_in_analyses = sqlalchemy.select( + analysis_csv_rtp_table.c.analysis_id + ).where(analysis_csv_rtp_table.c.file_id == file_id) + select_ids_used_in_runs = sqlalchemy.select(run_csv_rtp_table.c.run_id).where( + run_csv_rtp_table.c.file_id == file_id + ) + delete_statement = sqlalchemy.delete(data_files_table).where( + data_files_table.c.id == file_id + ) + with self._sql_engine.begin() as transaction: + files_used_in_analyses: Set[str] = set( + transaction.execute(select_ids_used_in_analyses).scalars().all() + ) + files_used_in_runs: Set[str] = set( + transaction.execute(select_ids_used_in_runs).scalars().all() + ) + if len(files_used_in_analyses) + len(files_used_in_runs) > 0: + analysis_usage_text = ( + f" analyses: {files_used_in_analyses}" + if len(files_used_in_analyses) > 0 + else None + ) + runs_usage_text = ( + f" runs: {files_used_in_runs}" + if len(files_used_in_runs) > 0 + else None + ) + conjunction = " and " if analysis_usage_text and runs_usage_text else "" + raise FileInUseError( + data_file_id=file_id, + message=f"Cannot remove file {file_id} as it is being used in" + f" existing{analysis_usage_text or ''}{conjunction}{runs_usage_text or ''}.", + ) + transaction.execute(delete_statement) + + file_dir = self._data_files_directory.joinpath(file_id) + if file_dir: + for file in file_dir.glob("*"): + file.unlink() + file_dir.rmdir() + def _convert_row_data_file_info(row: sqlalchemy.engine.Row) -> DataFileInfo: return DataFileInfo( diff --git a/robot-server/robot_server/data_files/dependencies.py b/robot-server/robot_server/data_files/dependencies.py index 7d5b459de2b..77aab325b6a 100644 --- a/robot-server/robot_server/data_files/dependencies.py +++ b/robot-server/robot_server/data_files/dependencies.py @@ -12,12 +12,15 @@ get_app_state, AppStateAccessor, ) +from robot_server.settings import get_settings from robot_server.persistence.fastapi_dependencies import ( get_active_persistence_directory, get_sql_engine, ) - +from robot_server.deletion_planner import DataFileDeletionPlanner from .data_files_store import DataFilesStore +from .file_auto_deleter import DataFileAutoDeleter + _DATA_FILES_SUBDIRECTORY: Final = "data_files" @@ -46,11 +49,24 @@ async def get_data_files_directory( async def get_data_files_store( app_state: AppState = Depends(get_app_state), sql_engine: SQLEngine = Depends(get_sql_engine), + data_files_directory: Path = Depends(get_data_files_directory), ) -> DataFilesStore: """Get a singleton DataFilesStore to keep track of uploaded data files.""" async with _data_files_store_init_lock: data_files_store = _data_files_store_accessor.get_from(app_state) if data_files_store is None: - data_files_store = DataFilesStore(sql_engine) + data_files_store = DataFilesStore(sql_engine, data_files_directory) _data_files_store_accessor.set_on(app_state, data_files_store) return data_files_store + + +def get_data_file_auto_deleter( + data_files_store: DataFilesStore = Depends(get_data_files_store), +) -> DataFileAutoDeleter: + """Get a `DataFileAutoDeleter` to delete old data files.""" + return DataFileAutoDeleter( + data_files_store=data_files_store, + deletion_planner=DataFileDeletionPlanner( + maximum_files=get_settings().maximum_data_files + ), + ) diff --git a/robot-server/robot_server/data_files/file_auto_deleter.py b/robot-server/robot_server/data_files/file_auto_deleter.py new file mode 100644 index 00000000000..46c26eb866a --- /dev/null +++ b/robot-server/robot_server/data_files/file_auto_deleter.py @@ -0,0 +1,40 @@ +"""Auto-delete old data files to make room for new ones.""" +from logging import getLogger + +from robot_server.data_files.data_files_store import DataFilesStore +from robot_server.deletion_planner import DataFileDeletionPlanner + +_log = getLogger(__name__) + + +class DataFileAutoDeleter: + """Auto deleter for data files.""" + + def __init__( + self, + data_files_store: DataFilesStore, + deletion_planner: DataFileDeletionPlanner, + ) -> None: + self._data_files_store = data_files_store + self._deletion_planner = deletion_planner + + async def make_room_for_new_file(self) -> None: + """Delete old data files to make room for a new one.""" + # It feels wasteful to collect usage info of upto 50 files + # even when there's no need for deletion + data_file_usage_info = [ + usage_info for usage_info in self._data_files_store.get_usage_info() + ] + + if len(data_file_usage_info) < self._deletion_planner.maximum_allowed_files: + return + file_ids_to_delete = self._deletion_planner.plan_for_new_file( + existing_files=data_file_usage_info + ) + + if file_ids_to_delete: + _log.info( + f"Auto-deleting these files to make room for a new one: {file_ids_to_delete}" + ) + for file_id in file_ids_to_delete: + self._data_files_store.remove(file_id) diff --git a/robot-server/robot_server/data_files/models.py b/robot-server/robot_server/data_files/models.py index 7396743076a..f5a9800452b 100644 --- a/robot-server/robot_server/data_files/models.py +++ b/robot-server/robot_server/data_files/models.py @@ -25,3 +25,13 @@ def __init__(self, data_file_id: str) -> None: message=f"Data file {data_file_id} was not found.", detail={"dataFileId": data_file_id}, ) + + +class FileInUseError(GeneralError): + """Error raised when a file being removed is in use.""" + + def __init__(self, data_file_id: str, message: str) -> None: + super().__init__( + message=message, + detail={"dataFileId": data_file_id}, + ) diff --git a/robot-server/robot_server/data_files/router.py b/robot-server/robot_server/data_files/router.py index afd63487b16..35d23fb5d51 100644 --- a/robot-server/robot_server/data_files/router.py +++ b/robot-server/robot_server/data_files/router.py @@ -14,8 +14,13 @@ MultiBodyMeta, ) from robot_server.errors.error_responses import ErrorDetails, ErrorBody -from .dependencies import get_data_files_directory, get_data_files_store +from .dependencies import ( + get_data_files_directory, + get_data_files_store, + get_data_file_auto_deleter, +) from .data_files_store import DataFilesStore, DataFileInfo +from .file_auto_deleter import DataFileAutoDeleter from .models import DataFile, FileIdNotFoundError from ..protocols.dependencies import get_file_hasher, get_file_reader_writer from ..service.dependencies import get_current_time, get_unique_id @@ -92,6 +97,7 @@ async def upload_data_file( ), data_files_directory: Path = Depends(get_data_files_directory), data_files_store: DataFilesStore = Depends(get_data_files_store), + data_file_auto_deleter: DataFileAutoDeleter = Depends(get_data_file_auto_deleter), file_reader_writer: FileReaderWriter = Depends(get_file_reader_writer), file_hasher: FileHasher = Depends(get_file_hasher), file_id: str = Depends(get_unique_id, use_cache=False), @@ -129,7 +135,7 @@ async def upload_data_file( status_code=status.HTTP_200_OK, ) - # TODO (spp, 2024-06-18): auto delete data files if max exceeded + await data_file_auto_deleter.make_room_for_new_file() await file_reader_writer.write( directory=data_files_directory / file_id, files=[buffered_file] ) diff --git a/robot-server/robot_server/deletion_planner.py b/robot-server/robot_server/deletion_planner.py index d11e1a4f11b..961c0bab9fd 100644 --- a/robot-server/robot_server/deletion_planner.py +++ b/robot-server/robot_server/deletion_planner.py @@ -29,12 +29,19 @@ This module only handles the abstract planning of what to delete. Actual storage access is handled elsewhere. """ - - +from dataclasses import dataclass from typing import Sequence, Set from typing_extensions import Protocol as InterfaceShape +@dataclass(frozen=True) +class FileUsageInfo: + """Information about whether a particular data file is being used by any runs or analyses.""" + + file_id: str + used_by_run_or_analysis: bool + + class ProtocolSpec(InterfaceShape): """Minimal info about a protocol in the SQL database. @@ -132,3 +139,50 @@ def plan_for_new_run( return set(runs_to_delete) return set() + + +class DataFileDeletionPlanner: + """Deletion planner for data files.""" + + def __init__(self, maximum_files: int) -> None: + """Return a configured data file deletion planner. + + Args: + maximum_files: The maximum number of data files to allow. + Must be at least 1. + """ + self._maximum_files = maximum_files + + @property + def maximum_allowed_files(self) -> int: + """Return the max allowed files.""" + return self._maximum_files + + def plan_for_new_file( + self, + existing_files: Sequence[FileUsageInfo], + ) -> Set[str]: + """Choose which files to delete in order to make room for a new one. + + Args: + existing_files: The IDs and usage info of all data files that currently exist. + Must be in order from oldest first! + + Returns: + The IDs of files to delete. + + After deleting these files, there will be at least one slot free + to add a new file without going over the configured limit. + """ + unused_files = [ + file for file in existing_files if not file.used_by_run_or_analysis + ] + + files_after_new_addition = len(existing_files) + 1 + if files_after_new_addition > self._maximum_files: + num_deletions_required = files_after_new_addition - self._maximum_files + else: + num_deletions_required = 0 + + files_to_delete = unused_files[:num_deletions_required] + return set(file.file_id for file in files_to_delete) diff --git a/robot-server/robot_server/persistence/tables/__init__.py b/robot-server/robot_server/persistence/tables/__init__.py index a4bcf487e2a..3dd235278bf 100644 --- a/robot-server/robot_server/persistence/tables/__init__.py +++ b/robot-server/robot_server/persistence/tables/__init__.py @@ -10,6 +10,7 @@ run_table, run_command_table, action_table, + run_csv_rtp_table, data_files_table, PrimitiveParamSQLEnum, ProtocolKindSQLEnum, @@ -25,6 +26,7 @@ "run_table", "run_command_table", "action_table", + "run_csv_rtp_table", "data_files_table", "PrimitiveParamSQLEnum", "ProtocolKindSQLEnum", diff --git a/robot-server/robot_server/settings.py b/robot-server/robot_server/settings.py index c359dd13e83..d4508406510 100644 --- a/robot-server/robot_server/settings.py +++ b/robot-server/robot_server/settings.py @@ -103,5 +103,13 @@ class RobotServerSettings(BaseSettings): ), ) + maximum_data_files: int = Field( + default=50, + gt=0, + description=( + "The maximum number of data files to allow before auto-deleting old ones." + ), + ) + class Config: env_prefix = "OT_ROBOT_SERVER_" diff --git a/robot-server/tests/data_files/test_data_files_store.py b/robot-server/tests/data_files/test_data_files_store.py index 18910c1de83..33cb31e2621 100644 --- a/robot-server/tests/data_files/test_data_files_store.py +++ b/robot-server/tests/data_files/test_data_files_store.py @@ -1,16 +1,102 @@ """Tests for the DataFilesStore interface.""" +from pathlib import Path + import pytest from datetime import datetime, timezone + +from decoy import Decoy +from opentrons.protocol_reader import ProtocolSource, JsonProtocolConfig from sqlalchemy.engine import Engine as SQLEngine -from robot_server.data_files.data_files_store import DataFilesStore, DataFileInfo -from robot_server.data_files.models import FileIdNotFoundError +from robot_server.data_files.data_files_store import ( + DataFilesStore, + DataFileInfo, +) +from robot_server.deletion_planner import FileUsageInfo +from robot_server.data_files.models import FileIdNotFoundError, FileInUseError +from robot_server.protocols.analysis_memcache import MemoryCache +from robot_server.protocols.analysis_models import ( + CompletedAnalysis, + AnalysisStatus, + AnalysisResult, +) +from robot_server.protocols.completed_analysis_store import ( + CompletedAnalysisStore, + CompletedAnalysisResource, +) +from robot_server.protocols.protocol_models import ProtocolKind +from robot_server.protocols.protocol_store import ProtocolResource, ProtocolStore +from robot_server.protocols.rtp_resources import CSVParameterResource + + +@pytest.fixture +def data_files_directory(tmp_path: Path) -> Path: + """Return a directory for storing data files.""" + subdirectory = tmp_path / "data_files" + subdirectory.mkdir() + return subdirectory @pytest.fixture -def subject(sql_engine: SQLEngine) -> DataFilesStore: +def subject(sql_engine: SQLEngine, data_files_directory: Path) -> DataFilesStore: """Get a DataFilesStore test subject.""" - return DataFilesStore(sql_engine=sql_engine) + return DataFilesStore( + sql_engine=sql_engine, data_files_directory=data_files_directory + ) + + +@pytest.fixture +def completed_analysis_store( + decoy: Decoy, + sql_engine: SQLEngine, +) -> CompletedAnalysisStore: + """Get a `CompletedAnalysisStore` linked to the same database as the subject under test.""" + return CompletedAnalysisStore(sql_engine, decoy.mock(cls=MemoryCache), "2") + + +@pytest.fixture +def protocol_store(sql_engine: SQLEngine) -> ProtocolStore: + """Return a `ProtocolStore` linked to the same database as the subject under test.""" + return ProtocolStore.create_empty(sql_engine=sql_engine) + + +def _get_sample_protocol_resource(protocol_id: str) -> ProtocolResource: + return ProtocolResource( + protocol_id=protocol_id, + created_at=datetime(year=2024, month=1, day=1, tzinfo=timezone.utc), + source=ProtocolSource( + directory=None, + main_file=Path("/dev/null"), + config=JsonProtocolConfig(schema_version=123), + files=[], + metadata={}, + robot_type="OT-2 Standard", + content_hash="abc1", + ), + protocol_key=None, + protocol_kind=ProtocolKind.STANDARD, + ) + + +def _get_sample_analysis_resource( + protocol_id: str, analysis_id: str +) -> CompletedAnalysisResource: + return CompletedAnalysisResource( + analysis_id, + protocol_id, + "2", + CompletedAnalysis( + id="analysis-id", + status=AnalysisStatus.COMPLETED, + result=AnalysisResult.OK, + pipettes=[], + labware=[], + modules=[], + commands=[], + errors=[], + liquids=[], + ), + ) async def test_insert_data_file_info_and_fetch_by_hash( @@ -69,3 +155,113 @@ def test_get_by_id_raises( """It should raise if the requested data file id does not exist.""" with pytest.raises(FileIdNotFoundError): assert subject.get("file-id") + + +async def test_get_usage_info( + subject: DataFilesStore, + protocol_store: ProtocolStore, + completed_analysis_store: CompletedAnalysisStore, +) -> None: + """It should return the usage info of all the data files in store.""" + protocol_resource = _get_sample_protocol_resource("protocol-id") + analysis_resource1 = _get_sample_analysis_resource("protocol-id", "analysis-id") + csv_param_resource = [ + CSVParameterResource( + analysis_id="analysis-id", + parameter_variable_name="baz", + file_id="file-id-1", + ) + ] + data_file_1 = DataFileInfo( + id="file-id-1", + name="file-name", + file_hash="abc", + created_at=datetime(year=2024, month=7, day=15, tzinfo=timezone.utc), + ) + data_file_2 = DataFileInfo( + id="file-id-2", + name="file-name", + file_hash="xyz", + created_at=datetime(year=2024, month=7, day=15, tzinfo=timezone.utc), + ) + await subject.insert(data_file_1) + await subject.insert(data_file_2) + protocol_store.insert(protocol_resource) + await completed_analysis_store.make_room_and_add( + completed_analysis_resource=analysis_resource1, + primitive_rtp_resources=[], + csv_rtp_resources=csv_param_resource, + ) + assert subject.get_usage_info() == [ + FileUsageInfo("file-id-1", used_by_run_or_analysis=True), + FileUsageInfo("file-id-2", used_by_run_or_analysis=False), + ] + + +async def test_remove( + subject: DataFilesStore, + data_files_directory: Path, +) -> None: + """It should remove the specified data file from database and store.""" + file_dir = data_files_directory.joinpath("file-id") + file_dir.mkdir() + data_file = file_dir / "abc.csv" + data_file.touch() + + data_file_info = DataFileInfo( + id="file-id", + name="file-name", + file_hash="abc123", + created_at=datetime(year=2024, month=6, day=20, tzinfo=timezone.utc), + ) + await subject.insert(data_file_info) + subject.remove(file_id="file-id") + + assert data_files_directory.exists() is True + assert file_dir.exists() is False + assert data_file.exists() is False + + with pytest.raises(FileIdNotFoundError): + subject.get("file-id") + + +async def test_remove_raises_in_file_in_use( + subject: DataFilesStore, + data_files_directory: Path, + protocol_store: ProtocolStore, + completed_analysis_store: CompletedAnalysisStore, +) -> None: + """It should raise `FileInUseError` when trying to remove a file that's in use.""" + file_dir = data_files_directory.joinpath("file-id") + file_dir.mkdir() + data_file = file_dir / "abc.csv" + data_file.touch() + + data_file_info = DataFileInfo( + id="file-id", + name="file-name", + file_hash="abc123", + created_at=datetime(year=2024, month=6, day=20, tzinfo=timezone.utc), + ) + + protocol_resource = _get_sample_protocol_resource("protocol-id") + analysis_resource = _get_sample_analysis_resource("protocol-id", "analysis-id") + csv_param_resource = [ + CSVParameterResource( + analysis_id="analysis-id", + parameter_variable_name="foo", + file_id="file-id", + ) + ] + + await subject.insert(data_file_info) + protocol_store.insert(protocol_resource) + await completed_analysis_store.make_room_and_add( + completed_analysis_resource=analysis_resource, + primitive_rtp_resources=[], + csv_rtp_resources=csv_param_resource, + ) + + expected_error_message = "Cannot remove file file-id as it is being used in existing analyses: {'analysis-id'}." + with pytest.raises(FileInUseError, match=expected_error_message): + subject.remove(file_id="file-id") diff --git a/robot-server/tests/data_files/test_file_auto_deleter.py b/robot-server/tests/data_files/test_file_auto_deleter.py new file mode 100644 index 00000000000..422af0891cb --- /dev/null +++ b/robot-server/tests/data_files/test_file_auto_deleter.py @@ -0,0 +1,40 @@ +"""Tests for DataFileAutoDeleter.""" +import logging + +import pytest +from decoy import Decoy + +from robot_server.data_files.data_files_store import DataFilesStore +from robot_server.data_files.file_auto_deleter import DataFileAutoDeleter +from robot_server.deletion_planner import DataFileDeletionPlanner, FileUsageInfo + + +async def test_make_room_for_new_file( + decoy: Decoy, + caplog: pytest.LogCaptureFixture, +) -> None: + """It should get a deletion plan and enact it on the data files store.""" + mock_data_files_store = decoy.mock(cls=DataFilesStore) + mock_deletion_planner = decoy.mock(cls=DataFileDeletionPlanner) + + files_usage = [ + FileUsageInfo(file_id="file-1", used_by_run_or_analysis=False), + FileUsageInfo(file_id="file-2", used_by_run_or_analysis=True), + FileUsageInfo(file_id="file-2", used_by_run_or_analysis=True), + ] + decoy.when(mock_deletion_planner.maximum_allowed_files).then_return(1) + decoy.when(mock_data_files_store.get_usage_info()).then_return(files_usage) + decoy.when(mock_deletion_planner.plan_for_new_file(files_usage)).then_return( + {"id-to-be-deleted-1", "id-to-be-deleted-2"} + ) + subject = DataFileAutoDeleter( + data_files_store=mock_data_files_store, + deletion_planner=mock_deletion_planner, + ) + with caplog.at_level(logging.INFO): + await subject.make_room_for_new_file() + + decoy.verify(mock_data_files_store.remove("id-to-be-deleted-1")) + decoy.verify(mock_data_files_store.remove("id-to-be-deleted-2")) + assert "id-to-be-deleted-1" in caplog.text + assert "id-to-be-deleted-2" in caplog.text diff --git a/robot-server/tests/data_files/test_router.py b/robot-server/tests/data_files/test_router.py index 0bb9f4c7bee..7437af48c33 100644 --- a/robot-server/tests/data_files/test_router.py +++ b/robot-server/tests/data_files/test_router.py @@ -18,6 +18,7 @@ get_data_file, get_all_data_files, ) +from robot_server.data_files.file_auto_deleter import DataFileAutoDeleter from robot_server.errors.error_responses import ApiError @@ -39,10 +40,17 @@ def file_reader_writer(decoy: Decoy) -> FileReaderWriter: return decoy.mock(cls=FileReaderWriter) +@pytest.fixture +def file_auto_deleter(decoy: Decoy) -> DataFileAutoDeleter: + """Get a mocked out DataFileAutoDeleter.""" + return decoy.mock(cls=DataFileAutoDeleter) + + async def test_upload_new_data_file( decoy: Decoy, data_files_store: DataFilesStore, file_reader_writer: FileReaderWriter, + file_auto_deleter: DataFileAutoDeleter, file_hasher: FileHasher, ) -> None: """It should store an uploaded data file to persistent storage & update the database.""" @@ -65,6 +73,7 @@ async def test_upload_new_data_file( data_files_directory=data_files_directory, data_files_store=data_files_store, file_reader_writer=file_reader_writer, + data_file_auto_deleter=file_auto_deleter, file_hasher=file_hasher, file_id="data-file-id", created_at=datetime(year=2024, month=6, day=18), @@ -77,6 +86,7 @@ async def test_upload_new_data_file( ) assert result.status_code == 201 decoy.verify( + await file_auto_deleter.make_room_for_new_file(), await file_reader_writer.write( directory=data_files_directory / "data-file-id", files=[buffered_file] ), @@ -141,6 +151,7 @@ async def test_upload_new_data_file_path( data_files_store: DataFilesStore, file_reader_writer: FileReaderWriter, file_hasher: FileHasher, + file_auto_deleter: DataFileAutoDeleter, ) -> None: """It should store the data file from path to persistent storage & update the database.""" data_files_directory = Path("/dev/null") @@ -159,6 +170,7 @@ async def test_upload_new_data_file_path( data_files_directory=data_files_directory, data_files_store=data_files_store, file_reader_writer=file_reader_writer, + data_file_auto_deleter=file_auto_deleter, file_hasher=file_hasher, file_id="data-file-id", created_at=datetime(year=2024, month=6, day=18), diff --git a/robot-server/tests/protocols/test_completed_analysis_store.py b/robot-server/tests/protocols/test_completed_analysis_store.py index 3f1e5302bdf..4426ad062c7 100644 --- a/robot-server/tests/protocols/test_completed_analysis_store.py +++ b/robot-server/tests/protocols/test_completed_analysis_store.py @@ -67,14 +67,16 @@ def protocol_store(sql_engine: Engine) -> ProtocolStore: @pytest.fixture -def data_files_store(sql_engine: Engine) -> DataFilesStore: +def data_files_store(sql_engine: Engine, tmp_path: Path) -> DataFilesStore: """Return a `DataFilesStore` linked to the same database as the subject under test. `DataFilesStore` is tested elsewhere. We only need it here to prepare the database for the analysis store tests. The CSV parameters table always needs a data file to link to. """ - return DataFilesStore(sql_engine=sql_engine) + data_files_dir = tmp_path / "data_files" + data_files_dir.mkdir() + return DataFilesStore(sql_engine=sql_engine, data_files_directory=data_files_dir) def make_dummy_protocol_resource(protocol_id: str) -> ProtocolResource: diff --git a/robot-server/tests/protocols/test_protocol_store.py b/robot-server/tests/protocols/test_protocol_store.py index 6550db63371..952bcb9c0fd 100644 --- a/robot-server/tests/protocols/test_protocol_store.py +++ b/robot-server/tests/protocols/test_protocol_store.py @@ -68,9 +68,11 @@ def run_store(sql_engine: SQLEngine, mock_runs_publisher: RunsPublisher) -> RunS @pytest.fixture -def data_files_store(sql_engine: SQLEngine) -> DataFilesStore: +def data_files_store(sql_engine: SQLEngine, tmp_path: Path) -> DataFilesStore: """Get a mocked out DataFilesStore.""" - return DataFilesStore(sql_engine=sql_engine) + data_files_dir = tmp_path / "data_files" + data_files_dir.mkdir() + return DataFilesStore(sql_engine=sql_engine, data_files_directory=data_files_dir) @pytest.fixture diff --git a/robot-server/tests/test_deletion_planner.py b/robot-server/tests/test_deletion_planner.py index f94884bd6dd..cc93c621bd2 100644 --- a/robot-server/tests/test_deletion_planner.py +++ b/robot-server/tests/test_deletion_planner.py @@ -8,6 +8,8 @@ from robot_server.deletion_planner import ( ProtocolDeletionPlanner, RunDeletionPlanner, + DataFileDeletionPlanner, + FileUsageInfo, ) @@ -129,3 +131,57 @@ def test_plan_for_new_run( subject = RunDeletionPlanner(maximum_runs=maximum_runs) result = subject.plan_for_new_run(existing_runs=existing_runs) assert result == expected_deletion_plan + + +class _FileDeletionTestSpec(NamedTuple): + """Input and expected output for a single file deletion.""" + + maximum_files: int + existing_files: List[FileUsageInfo] + expected_deletion_plan: Set[str] + + +_file_deletion_test_specs = [ + _FileDeletionTestSpec( + maximum_files=3, + existing_files=[ + FileUsageInfo(file_id="f-1-unused", used_by_run_or_analysis=False), + FileUsageInfo(file_id="f-2-used", used_by_run_or_analysis=True), + FileUsageInfo(file_id="f-3-unused", used_by_run_or_analysis=False), + FileUsageInfo(file_id="f-4-used", used_by_run_or_analysis=True), + FileUsageInfo(file_id="f-5-unused", used_by_run_or_analysis=False), + ], + expected_deletion_plan={"f-1-unused", "f-3-unused", "f-5-unused"}, + ), + _FileDeletionTestSpec( + maximum_files=3, + existing_files=[ + FileUsageInfo(file_id="f-1-used", used_by_run_or_analysis=True), + FileUsageInfo(file_id="f-2-used", used_by_run_or_analysis=True), + FileUsageInfo(file_id="f-3-used", used_by_run_or_analysis=True), + FileUsageInfo(file_id="f-4-used", used_by_run_or_analysis=True), + ], + expected_deletion_plan=set(), + ), + _FileDeletionTestSpec( + maximum_files=3, + existing_files=[ + FileUsageInfo(file_id="f-1-used", used_by_run_or_analysis=True), + FileUsageInfo(file_id="f-2-unused", used_by_run_or_analysis=False), + FileUsageInfo(file_id="f-3-used", used_by_run_or_analysis=True), + ], + expected_deletion_plan={"f-2-unused"}, + ), +] + + +@pytest.mark.parametrize(_FileDeletionTestSpec._fields, _file_deletion_test_specs) +def test_plan_for_new_data_file( + maximum_files: int, + existing_files: List[FileUsageInfo], + expected_deletion_plan: Set[str], +) -> None: + """It should return a plan that leaves at least one slot open for a new data file.""" + subject = DataFileDeletionPlanner(maximum_files=maximum_files) + result = subject.plan_for_new_file(existing_files=existing_files) + assert result == expected_deletion_plan From c66e43ebe7f34f6c816032516e68a638b7679a9a Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Fri, 2 Aug 2024 15:12:37 -0400 Subject: [PATCH 43/49] fix(app): resume at appropriate drop tip route after door open event (#15871) Closes EXEC-605 This PR adds something between a feature and a bug fix. Earlier, we added rough support for a "submap", which is basically a way to get more granular routing when necessary. So for example, if you have an error recovery route and step, you could have a sub route and sub step. The idea is this would probably be necessary at some point when we had multiple apps looking at the same exact thing at the same time, but it wouldn't be needed for this release. Turns out we needed it this release in exactly one case. When we do drop tip wizard within error recovery, we effectively go to the "drop tip route" and the "before beginning" or "drop tip" or "blowout" step. When the door opens, we change the route and step to the door route and step, but when we navigate back to where we were after resuming the door open event, we effectively end up back at the start of the flows, because error recovery can't exactly pinpoint where we are in the drop tip flow. What we really need is something roughly equivalent to (for example) "recovery-drop-tip/recovery-blowout/drop-tip-flow-blowout/drop-tip-flow-jog-to-position" in order to retain the routing correctly. This is the perfect spot for using a submap! --- .../DropTipWizardFlows/DropTipWizard.tsx | 11 +++- .../__tests__/DropTipWizardHeader.test.tsx | 2 +- .../organisms/DropTipWizardFlows/constants.ts | 10 ++-- .../__tests__/useDropTipRouting.test.tsx | 28 +++++----- .../hooks/useDropTipRouting.tsx | 20 +++---- app/src/organisms/DropTipWizardFlows/types.ts | 13 +++-- .../RecoveryOptions/ManageTips.tsx | 15 +++-- .../__tests__/ManageTips.test.tsx | 56 +++++++++++++------ .../ErrorRecoveryFlows/__fixtures__/index.ts | 2 +- .../ErrorRecoveryFlows/hooks/useERUtils.ts | 11 ++-- .../hooks/useRecoveryRouting.ts | 35 +++++++----- app/src/organisms/ErrorRecoveryFlows/types.ts | 11 ++++ 12 files changed, 137 insertions(+), 77 deletions(-) diff --git a/app/src/organisms/DropTipWizardFlows/DropTipWizard.tsx b/app/src/organisms/DropTipWizardFlows/DropTipWizard.tsx index 329ec38d199..0586db9966c 100644 --- a/app/src/organisms/DropTipWizardFlows/DropTipWizard.tsx +++ b/app/src/organisms/DropTipWizardFlows/DropTipWizard.tsx @@ -324,10 +324,15 @@ export const DropTipWizardContent = ( const handleProceed = (): void => { if (currentStep === BLOWOUT_SUCCESS) { void proceedToRoute(DT_ROUTES.DROP_TIP) - } else if (tipDropComplete != null) { - tipDropComplete() } else { - proceedWithConditionalClose() + // Clear the error recovery submap upon completion of drop tip wizard. + fixitCommandTypeUtils?.reportMap(null) + + if (tipDropComplete != null) { + tipDropComplete() + } else { + proceedWithConditionalClose() + } } } diff --git a/app/src/organisms/DropTipWizardFlows/__tests__/DropTipWizardHeader.test.tsx b/app/src/organisms/DropTipWizardFlows/__tests__/DropTipWizardHeader.test.tsx index 4a3cc939eff..5dbb85ecca2 100644 --- a/app/src/organisms/DropTipWizardFlows/__tests__/DropTipWizardHeader.test.tsx +++ b/app/src/organisms/DropTipWizardFlows/__tests__/DropTipWizardHeader.test.tsx @@ -39,7 +39,7 @@ describe('useSeenBlowoutSuccess', () => { it('should not render step counter when currentRoute is BEFORE_BEGINNING', () => { const { result } = renderHook(() => useSeenBlowoutSuccess({ - currentStep: 'SOME_STEP', + currentStep: 'SOME_STEP' as any, currentRoute: DT_ROUTES.BEFORE_BEGINNING, currentStepIdx: 0, }) diff --git a/app/src/organisms/DropTipWizardFlows/constants.ts b/app/src/organisms/DropTipWizardFlows/constants.ts index 1a6e9c24e04..39d75318824 100644 --- a/app/src/organisms/DropTipWizardFlows/constants.ts +++ b/app/src/organisms/DropTipWizardFlows/constants.ts @@ -9,17 +9,17 @@ export const POSITION_AND_DROP_TIP = 'POSITION_AND_DROP_TIP' as const export const DROP_TIP_SUCCESS = 'DROP_TIP_SUCCESS' as const export const INVALID = 'INVALID' as const -const BEFORE_BEGINNING_STEPS = [BEFORE_BEGINNING] -const BLOWOUT_STEPS = [ +export const BEFORE_BEGINNING_STEPS = [BEFORE_BEGINNING] as const +export const BLOWOUT_STEPS = [ CHOOSE_BLOWOUT_LOCATION, POSITION_AND_BLOWOUT, BLOWOUT_SUCCESS, -] -const DROP_TIP_STEPS = [ +] as const +export const DROP_TIP_STEPS = [ CHOOSE_DROP_TIP_LOCATION, POSITION_AND_DROP_TIP, DROP_TIP_SUCCESS, -] +] as const export const DT_ROUTES = { BEFORE_BEGINNING: BEFORE_BEGINNING_STEPS, diff --git a/app/src/organisms/DropTipWizardFlows/hooks/__tests__/useDropTipRouting.test.tsx b/app/src/organisms/DropTipWizardFlows/hooks/__tests__/useDropTipRouting.test.tsx index 350c7bc7a4f..651b3959b5d 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/__tests__/useDropTipRouting.test.tsx +++ b/app/src/organisms/DropTipWizardFlows/hooks/__tests__/useDropTipRouting.test.tsx @@ -87,12 +87,12 @@ describe('useDropTipRouting', () => { }) }) -describe('useExternalMapUpdates', () => { - it('should call trackCurrentMap when the drop tip flow map updates', async () => { - const mockTrackCurrentMap = vi.fn() +describe('useReportMap', () => { + it('should call reportMap when the drop tip flow map updates', async () => { + const mockReportMap = vi.fn() const mockFixitUtils = { - trackCurrentMap: mockTrackCurrentMap, + reportMap: mockReportMap, } as any const { result } = renderHook(() => useDropTipRouting(mockFixitUtils)) @@ -101,18 +101,18 @@ describe('useExternalMapUpdates', () => { await result.current.proceedToRoute(DT_ROUTES.BLOWOUT) }) - expect(mockTrackCurrentMap).toHaveBeenCalledWith({ - currentRoute: DT_ROUTES.BLOWOUT, - currentStep: expect.any(String), + expect(mockReportMap).toHaveBeenCalledWith({ + route: DT_ROUTES.BLOWOUT, + step: expect.any(String), }) await act(async () => { await result.current.proceed() }) - expect(mockTrackCurrentMap).toHaveBeenCalledWith({ - currentRoute: DT_ROUTES.BLOWOUT, - currentStep: expect.any(String), + expect(mockReportMap).toHaveBeenCalledWith({ + route: DT_ROUTES.BLOWOUT, + step: expect.any(String), }) }) }) @@ -126,9 +126,7 @@ describe('getInitialRouteAndStep', () => { }) it('should return the default initial route and step when fixitUtils.routeOverride is not provided', () => { - const fixitUtils = { - routeOverride: undefined, - } as any + const fixitUtils = undefined const [initialRoute, initialStep] = getInitialRouteAndStep(fixitUtils) @@ -138,12 +136,12 @@ describe('getInitialRouteAndStep', () => { it('should return the overridden route and step when fixitUtils.routeOverride is provided', () => { const fixitUtils = { - routeOverride: DT_ROUTES.DROP_TIP, + routeOverride: { route: DT_ROUTES.DROP_TIP, step: DT_ROUTES.DROP_TIP[2] }, } as any const [initialRoute, initialStep] = getInitialRouteAndStep(fixitUtils) expect(initialRoute).toBe(DT_ROUTES.DROP_TIP) - expect(initialStep).toBe(DT_ROUTES.DROP_TIP[0]) + expect(initialStep).toBe(DT_ROUTES.DROP_TIP[2]) }) }) diff --git a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipRouting.tsx b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipRouting.tsx index 78e3e63977e..50a72417c8b 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipRouting.tsx +++ b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipRouting.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import head from 'lodash/head' import last from 'lodash/last' -import { DT_ROUTES, INVALID } from '../constants' +import { BEFORE_BEGINNING_STEPS, DT_ROUTES, INVALID } from '../constants' import type { DropTipFlowsRoute, @@ -46,7 +46,7 @@ export function useDropTipRouting( ): UseDropTipRoutingResult { const [initialRoute, initialStep] = React.useMemo( () => getInitialRouteAndStep(fixitUtils), - [fixitUtils] + [] ) const [dropTipFlowsMap, setDropTipFlowsMap] = React.useState( @@ -57,7 +57,7 @@ export function useDropTipRouting( } ) - useExternalMapUpdates(dropTipFlowsMap, fixitUtils) + useReportMap(dropTipFlowsMap, fixitUtils) const { currentStep, currentRoute } = dropTipFlowsMap @@ -126,7 +126,7 @@ interface DropTipRouteNavigationResult { // Returns functions that calculate the next and previous steps of a route given a step. function getDropTipRouteNavigation( - route: DropTipFlowsStep[] + route: readonly DropTipFlowsStep[] ): DropTipRouteNavigationResult { const getNextStep = (step: DropTipFlowsStep): StepNavigationResult => { const isStepFinalStep = step === last(route) @@ -180,7 +180,7 @@ function determineValidRoute( } // If an external flow is keeping track of the Drop tip flow map, update it when the drop tip flow map updates. -export function useExternalMapUpdates( +export function useReportMap( map: DropTipFlowsMap, fixitUtils?: FixitCommandTypeUtils ): void { @@ -188,9 +188,9 @@ export function useExternalMapUpdates( React.useEffect(() => { if (fixitUtils != null) { - fixitUtils.trackCurrentMap({ currentRoute, currentStep }) + fixitUtils.reportMap({ route: currentRoute, step: currentStep }) } - }, [currentStep, currentRoute, fixitUtils]) + }, [currentStep, currentRoute]) } // If present, return fixit route overrides for setting the initial Drop Tip Wizard route. @@ -198,8 +198,8 @@ export function getInitialRouteAndStep( fixitUtils?: FixitCommandTypeUtils ): [DropTipFlowsRoute, DropTipFlowsStep] { const routeOverride = fixitUtils?.routeOverride - const initialRoute = routeOverride ?? DT_ROUTES.BEFORE_BEGINNING - const initialStep = head(routeOverride) ?? head(DT_ROUTES.BEFORE_BEGINNING) + const initialRoute = routeOverride?.route ?? DT_ROUTES.BEFORE_BEGINNING + const initialStep = routeOverride?.step ?? BEFORE_BEGINNING_STEPS[0] - return [initialRoute as DropTipFlowsRoute, initialStep as DropTipFlowsStep] + return [initialRoute, initialStep] } diff --git a/app/src/organisms/DropTipWizardFlows/types.ts b/app/src/organisms/DropTipWizardFlows/types.ts index f4aa36266ae..15a9e25cc9e 100644 --- a/app/src/organisms/DropTipWizardFlows/types.ts +++ b/app/src/organisms/DropTipWizardFlows/types.ts @@ -1,11 +1,9 @@ import type { DT_ROUTES } from './constants' import type { DropTipErrorComponents } from './hooks' import type { DropTipWizardProps } from './DropTipWizard' -import type { ERUtilsResults } from '../ErrorRecoveryFlows/hooks' export type DropTipFlowsRoute = typeof DT_ROUTES[keyof typeof DT_ROUTES] export type DropTipFlowsStep = DropTipFlowsRoute[number] - export interface ErrorDetails { message: string header?: string @@ -30,14 +28,21 @@ interface ButtonOverrides { tipDropComplete: (() => void) | null } +export interface DropTipWizardRouteOverride { + route: DropTipFlowsRoute + step: DropTipFlowsStep | null +} + export interface FixitCommandTypeUtils { runId: string failedCommandId: string - trackCurrentMap: ERUtilsResults['trackExternalMap'] copyOverrides: CopyOverrides errorOverrides: ErrorOverrides buttonOverrides: ButtonOverrides - routeOverride?: typeof DT_ROUTES[keyof typeof DT_ROUTES] + /* Report to an external flow (ex, Error Recovery) the current step of drop tip wizard. */ + reportMap: (dropTipMap: DropTipWizardRouteOverride | null) => void + /* If supplied, begin drop tip flows on the specified route & step. If no step is supplied, begin at the start of the route. */ + routeOverride?: DropTipWizardRouteOverride } export type DropTipWizardContainerProps = DropTipWizardProps & { diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx index 6dbc6924559..e5886839326 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx @@ -200,7 +200,7 @@ export function useDropTipFlowUtils({ tipStatusUtils, failedCommand, currentRecoveryOptionUtils, - trackExternalMap, + subMapUtils, routeUpdateActions, recoveryMap, }: RecoveryContentProps): FixitCommandTypeUtils { @@ -215,6 +215,7 @@ export function useDropTipFlowUtils({ const { step } = recoveryMap const { selectedRecoveryOption } = currentRecoveryOptionUtils const { proceedToRouteAndStep } = routeUpdateActions + const { updateSubMap, subMap } = subMapUtils const failedCommandId = failedCommand?.id ?? '' // We should have a failed command here unless the run is not in AWAITING_RECOVERY. const buildTipDropCompleteBtn = (): string => { @@ -288,12 +289,18 @@ export function useDropTipFlowUtils({ } // If a specific step within the DROP_TIP_FLOWS route is selected, begin the Drop Tip Flows at its related route. + // + // NOTE: The substep is cleared by drop tip wizard after the completion of the wizard flow. const buildRouteOverride = (): FixitCommandTypeUtils['routeOverride'] => { + if (subMap?.route != null) { + return { route: subMap.route, step: subMap.step } + } + switch (step) { case DROP_TIP_FLOWS.STEPS.CHOOSE_TIP_DROP: - return DT_ROUTES.DROP_TIP + return { route: DT_ROUTES.DROP_TIP, step: subMap?.step ?? null } case DROP_TIP_FLOWS.STEPS.CHOOSE_BLOWOUT: - return DT_ROUTES.BLOWOUT + return { route: DT_ROUTES.BLOWOUT, step: subMap?.step ?? null } } } @@ -301,9 +308,9 @@ export function useDropTipFlowUtils({ runId, failedCommandId, copyOverrides: buildCopyOverrides(), - trackCurrentMap: trackExternalMap, errorOverrides: buildErrorOverrides(), buttonOverrides: buildButtonOverrides(), routeOverride: buildRouteOverride(), + reportMap: updateSubMap, } } diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManageTips.test.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManageTips.test.tsx index 362d30e2860..bd743bc60e7 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManageTips.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManageTips.test.tsx @@ -69,6 +69,7 @@ describe('ManageTips', () => { currentRecoveryOptionUtils: { selectedRecoveryOption: null, } as any, + subMapUtils: { subMap: null, updateSubMap: vi.fn() }, } vi.mocked(DropTipWizardFlows).mockReturnValue( @@ -176,13 +177,16 @@ describe('useDropTipFlowUtils', () => { const mockRunId = 'MOCK_RUN_ID' const mockTipStatusUtils = { runId: mockRunId } const mockProceedToRouteAndStep = vi.fn() + const mockUpdateSubMap = vi.fn() const { ERROR_WHILE_RECOVERING, DROP_TIP_FLOWS } = RECOVERY_MAP const mockProps = { tipStatusUtils: mockTipStatusUtils, failedCommand: null, - previousRoute: null, - trackExternalMap: vi.fn(), + subMapUtils: { + updateSubMap: mockUpdateSubMap, + subMap: null, + }, currentRecoveryOptionUtils: { selectedRecoveryOption: null, } as any, @@ -225,19 +229,13 @@ describe('useDropTipFlowUtils', () => { screen.getByText('Proceed to cancel') }) - it('should call trackExternalMap with the current map', () => { - const mockTrackExternalMap = vi.fn() - const { result } = renderHook(() => - useDropTipFlowUtils({ - ...mockProps, - trackExternalMap: mockTrackExternalMap, - }) - ) + it('should call updateSubMap with the current map', () => { + const { result } = renderHook(() => useDropTipFlowUtils(mockProps)) - const currentMap = { route: 'route', step: 'step' } - result.current.trackCurrentMap(currentMap) + const currentMap = { route: 'route', step: 'step' } as any + result.current.reportMap(currentMap) - expect(mockTrackExternalMap).toHaveBeenCalledWith(currentMap) + expect(mockUpdateSubMap).toHaveBeenCalledWith(currentMap) }) it('should return the correct error overrides', () => { @@ -296,19 +294,43 @@ describe('useDropTipFlowUtils', () => { ) }) - it(`should return correct route overrides when the route is ${DROP_TIP_FLOWS.STEPS.CHOOSE_TIP_DROP}`, () => { + it(`should return correct route override when the step is ${DROP_TIP_FLOWS.STEPS.CHOOSE_TIP_DROP}`, () => { const { result } = renderHook(() => useDropTipFlowUtils(mockProps)) - expect(result.current.routeOverride).toEqual(DT_ROUTES.DROP_TIP) + expect(result.current.routeOverride).toEqual({ + route: DT_ROUTES.DROP_TIP, + step: null, + }) }) - it(`should return correct route overrides when the route is ${DROP_TIP_FLOWS.STEPS.CHOOSE_BLOWOUT}`, () => { + it(`should return correct route override when the step is ${DROP_TIP_FLOWS.STEPS.CHOOSE_BLOWOUT}`, () => { const mockPropsBlowout = { ...mockProps, recoveryMap: { step: DROP_TIP_FLOWS.STEPS.CHOOSE_BLOWOUT }, } const { result } = renderHook(() => useDropTipFlowUtils(mockPropsBlowout)) - expect(result.current.routeOverride).toEqual(DT_ROUTES.BLOWOUT) + expect(result.current.routeOverride).toEqual({ + route: DT_ROUTES.BLOWOUT, + step: null, + }) + }) + + it('should use subMap.step in routeOverride if available', () => { + const mockPropsWithSubMap = { + ...mockProps, + subMapUtils: { + ...mockProps.subMapUtils, + subMap: { route: DT_ROUTES.DROP_TIP, step: 'SOME_STEP' }, + }, + } + const { result } = renderHook(() => + useDropTipFlowUtils(mockPropsWithSubMap) + ) + + expect(result.current.routeOverride).toEqual({ + route: DT_ROUTES.DROP_TIP, + step: 'SOME_STEP', + }) }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts b/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts index e7d3a85c484..919f45d9c42 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts +++ b/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts @@ -73,7 +73,7 @@ export const mockRecoveryContentProps: RecoveryContentProps = { deckMapUtils: { setSelectedLocation: () => {} } as any, stepCounts: {} as any, protocolAnalysis: mockRobotSideAnalysis, - trackExternalMap: () => null, + subMapUtils: { subMap: null, updateSubMap: () => null } as any, hasLaunchedRecovery: true, getRecoveryOptionCopy: () => 'MOCK_COPY', commandsAfterFailedCommand: [ diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts index 965abf761bc..10860cbacc5 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts @@ -26,7 +26,10 @@ import type { UseRecoveryCommandsResult } from './useRecoveryCommands' import type { RecoveryTipStatusUtils } from './useRecoveryTipStatus' import type { UseFailedLabwareUtilsResult } from './useFailedLabwareUtils' import type { UseDeckMapUtilsResult } from './useDeckMapUtils' -import type { CurrentRecoveryOptionUtils } from './useRecoveryRouting' +import type { + CurrentRecoveryOptionUtils, + SubMapUtils, +} from './useRecoveryRouting' import type { RecoveryActionMutationResult } from './useRecoveryActionMutation' import type { StepCounts } from '../../../resources/protocols/hooks' import type { UseRecoveryAnalyticsResult } from './useRecoveryAnalytics' @@ -52,9 +55,9 @@ export interface ERUtilsResults { recoveryActionMutationUtils: RecoveryActionMutationResult failedPipetteInfo: PipetteData | null hasLaunchedRecovery: boolean - trackExternalMap: (map: Record) => void stepCounts: StepCounts commandsAfterFailedCommand: ReturnType + subMapUtils: SubMapUtils } const SUBSEQUENT_COMMAND_DEPTH = 2 @@ -86,8 +89,8 @@ export function useERUtils({ const { recoveryMap, setRM, - trackExternalMap, currentRecoveryOptionUtils, + ...subMapUtils } = useRecoveryRouting() const recoveryToastUtils = useRecoveryToasts({ @@ -155,7 +158,7 @@ export function useERUtils({ ) return { recoveryMap, - trackExternalMap, + subMapUtils, currentRecoveryOptionUtils, recoveryActionMutationUtils, routeUpdateActions, diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryRouting.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryRouting.ts index db3daed9976..b97a1206739 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryRouting.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryRouting.ts @@ -2,30 +2,38 @@ import * as React from 'react' import { RECOVERY_MAP } from '../constants' -import type { IRecoveryMap, RecoveryRoute } from '../types' -import type { ERUtilsResults } from './useERUtils' +import type { IRecoveryMap, RecoveryRoute, ValidSubMap } from '../types' + +// Utils for getting/setting the current submap. See useRecoveryRouting. +export interface SubMapUtils { + /* See useRecoveryRouting. */ + updateSubMap: (subMap: ValidSubMap | null) => void + /* See useRecoveryRouting. */ + subMap: ValidSubMap | null +} + +export interface UseRecoveryRoutingResult { + recoveryMap: IRecoveryMap + currentRecoveryOptionUtils: CurrentRecoveryOptionUtils + setRM: (map: IRecoveryMap) => void + updateSubMap: SubMapUtils['updateSubMap'] + subMap: SubMapUtils['subMap'] +} /** * ER Wizard routing. Also provides access to the routing of any other flow launched from ER. * Recovery Route: A logically-related collection of recovery steps or a single step if unrelated to any existing recovery route. * Recovery Step: Analogous to a "step" in other wizard flows. + * SubMap: Used for more granular routing, when required. * - * @params {trackExternalStep} Used to keep track of the current step in other flows launched from Error Recovery, ex. Drop Tip flows. */ - -export function useRecoveryRouting(): { - recoveryMap: IRecoveryMap - currentRecoveryOptionUtils: CurrentRecoveryOptionUtils - setRM: (map: IRecoveryMap) => void - trackExternalMap: ERUtilsResults['trackExternalMap'] -} { +export function useRecoveryRouting(): UseRecoveryRoutingResult { const [recoveryMap, setRecoveryMap] = React.useState({ route: RECOVERY_MAP.OPTION_SELECTION.ROUTE, step: RECOVERY_MAP.OPTION_SELECTION.STEPS.SELECT, }) - // If we do multi-app routing, concat the sub-step to the error recovery routing. - const [, setSubMap] = React.useState | null>(null) + const [subMap, setSubMap] = React.useState(null) const currentRecoveryOptionUtils = useSelectedRecoveryOption() @@ -33,7 +41,8 @@ export function useRecoveryRouting(): { recoveryMap, currentRecoveryOptionUtils, setRM: setRecoveryMap, - trackExternalMap: setSubMap, + updateSubMap: setSubMap, + subMap, } } diff --git a/app/src/organisms/ErrorRecoveryFlows/types.ts b/app/src/organisms/ErrorRecoveryFlows/types.ts index 747000f2dbb..f3df4a86c50 100644 --- a/app/src/organisms/ErrorRecoveryFlows/types.ts +++ b/app/src/organisms/ErrorRecoveryFlows/types.ts @@ -1,6 +1,10 @@ import type { RunCommandSummary } from '@opentrons/api-client' import type { ERROR_KINDS, RECOVERY_MAP, INVALID } from './constants' import type { ErrorRecoveryWizardProps } from './ErrorRecoveryWizard' +import type { + DropTipFlowsRoute, + DropTipFlowsStep, +} from '../DropTipWizardFlows/types' export type FailedCommand = RunCommandSummary export type InvalidStep = typeof INVALID @@ -20,6 +24,13 @@ interface RecoveryMapDetails { STEP_ORDER: RouteStep } +export type ValidSubRoutes = DropTipFlowsRoute +export type ValidSubSteps = DropTipFlowsStep +export interface ValidSubMap { + route: ValidSubRoutes + step: ValidSubSteps | null +} + export type RecoveryMap = Record export type StepOrder = { [K in RecoveryRoute]: RouteStep[] From a2b5c44000fd09e91e64bdf143e69640bb751b3c Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Fri, 2 Aug 2024 15:23:58 -0400 Subject: [PATCH 44/49] fix(protocol-engine): Update location after `liquidProbe` and `tryLiquidProbe` (#15867) --- .../protocol_engine/state/pipettes.py | 154 +++++++++--------- .../state/test_pipette_store.py | 91 +++++++++++ 2 files changed, 169 insertions(+), 76 deletions(-) diff --git a/api/src/opentrons/protocol_engine/state/pipettes.py b/api/src/opentrons/protocol_engine/state/pipettes.py index 60720c917ec..58a798e90bd 100644 --- a/api/src/opentrons/protocol_engine/state/pipettes.py +++ b/api/src/opentrons/protocol_engine/state/pipettes.py @@ -12,17 +12,15 @@ NozzleMap, ) from opentrons.protocol_engine.actions.actions import FailCommandAction -from opentrons.protocol_engine.commands.aspirate import Aspirate -from opentrons.protocol_engine.commands.dispense import Dispense -from opentrons.protocol_engine.commands.aspirate_in_place import AspirateInPlace -from opentrons.protocol_engine.commands.dispense_in_place import DispenseInPlace from opentrons.protocol_engine.commands.command import DefinedErrorData from opentrons.protocol_engine.commands.pipetting_common import ( + LiquidNotFoundError, OverpressureError, OverpressureErrorInternalData, ) from opentrons.types import MountType, Mount as HwMount, Point +from .. import commands from .. import errors from ..types import ( LoadedPipette, @@ -34,31 +32,6 @@ CurrentPipetteLocation, TipGeometry, ) -from ..commands import ( - LoadPipetteResult, - AspirateResult, - AspirateInPlaceResult, - DispenseResult, - DispenseInPlaceResult, - MoveLabwareResult, - MoveToCoordinatesResult, - MoveToWellResult, - MoveRelativeResult, - MoveToAddressableAreaResult, - MoveToAddressableAreaForDropTipResult, - PickUpTipResult, - DropTipResult, - DropTipInPlaceResult, - HomeResult, - RetractAxisResult, - BlowOutResult, - BlowOutInPlaceResult, - unsafe, - TouchTipResult, - thermocycler, - heater_shaker, - PrepareToAspirateResult, -) from ..commands.configuring_common import ( PipetteConfigUpdateResultMixin, PipetteNozzleLayoutResultMixin, @@ -227,7 +200,7 @@ def _handle_command( # noqa: C901 private_result.pipette_id ] = private_result.nozzle_map - if isinstance(command.result, LoadPipetteResult): + if isinstance(command.result, commands.LoadPipetteResult): pipette_id = command.result.pipetteId self._state.pipettes_by_id[pipette_id] = LoadedPipette( @@ -247,7 +220,7 @@ def _handle_command( # noqa: C901 pipette_id ] = static_config.default_nozzle_map - elif isinstance(command.result, PickUpTipResult): + elif isinstance(command.result, commands.PickUpTipResult): pipette_id = command.params.pipetteId attached_tip = TipGeometry( length=command.result.tipLength, @@ -281,7 +254,11 @@ def _handle_command( # noqa: C901 elif isinstance( command.result, - (DropTipResult, DropTipInPlaceResult, unsafe.UnsafeDropTipInPlaceResult), + ( + commands.DropTipResult, + commands.DropTipInPlaceResult, + commands.unsafe.UnsafeDropTipInPlaceResult, + ), ): pipette_id = command.params.pipetteId self._state.aspirated_volume_by_id[pipette_id] = None @@ -307,13 +284,15 @@ def _update_current_location( # noqa: C901 if isinstance(action, SucceedCommandAction) and isinstance( action.command.result, ( - MoveToWellResult, - PickUpTipResult, - DropTipResult, - AspirateResult, - DispenseResult, - BlowOutResult, - TouchTipResult, + commands.MoveToWellResult, + commands.PickUpTipResult, + commands.DropTipResult, + commands.AspirateResult, + commands.DispenseResult, + commands.BlowOutResult, + commands.TouchTipResult, + commands.LiquidProbeResult, + commands.TryLiquidProbeResult, ), ): self._state.current_location = CurrentWell( @@ -321,11 +300,20 @@ def _update_current_location( # noqa: C901 labware_id=action.command.params.labwareId, well_name=action.command.params.wellName, ) - elif ( - isinstance(action, FailCommandAction) - and isinstance(action.running_command, (Aspirate, Dispense)) - and isinstance(action.error, DefinedErrorData) - and isinstance(action.error.public, OverpressureError) + elif isinstance(action, FailCommandAction) and ( + isinstance(action.error, DefinedErrorData) + and ( + ( + isinstance( + action.running_command, (commands.Aspirate, commands.Dispense) + ) + and isinstance(action.error.public, OverpressureError) + ) + or ( + isinstance(action.running_command, commands.LiquidProbe) + and isinstance(action.error.public, LiquidNotFoundError) + ) + ) ): self._state.current_location = CurrentWell( pipette_id=action.running_command.params.pipetteId, @@ -334,7 +322,10 @@ def _update_current_location( # noqa: C901 ) elif isinstance(action, SucceedCommandAction) and isinstance( action.command.result, - (MoveToAddressableAreaResult, MoveToAddressableAreaForDropTipResult), + ( + commands.MoveToAddressableAreaResult, + commands.MoveToAddressableAreaForDropTipResult, + ), ): self._state.current_location = CurrentAddressableArea( pipette_id=action.command.params.pipetteId, @@ -349,11 +340,11 @@ def _update_current_location( # noqa: C901 elif isinstance(action, SucceedCommandAction) and isinstance( action.command.result, ( - HomeResult, - RetractAxisResult, - MoveToCoordinatesResult, - thermocycler.OpenLidResult, - thermocycler.CloseLidResult, + commands.HomeResult, + commands.RetractAxisResult, + commands.MoveToCoordinatesResult, + commands.thermocycler.OpenLidResult, + commands.thermocycler.CloseLidResult, ), ): self._state.current_location = None @@ -363,8 +354,8 @@ def _update_current_location( # noqa: C901 elif isinstance(action, SucceedCommandAction) and isinstance( action.command.result, ( - heater_shaker.SetAndWaitForShakeSpeedResult, - heater_shaker.OpenLabwareLatchResult, + commands.heater_shaker.SetAndWaitForShakeSpeedResult, + commands.heater_shaker.OpenLabwareLatchResult, ), ): if action.command.result.pipetteRetracted: @@ -377,7 +368,7 @@ def _update_current_location( # noqa: C901 # This is necessary for safe motion planning in case the next movement # goes to the same labware (now in a new place). elif isinstance(action, SucceedCommandAction) and isinstance( - action.command.result, MoveLabwareResult + action.command.result, commands.MoveLabwareResult ): moved_labware_id = action.command.params.labwareId if action.command.params.strategy == "usingGripper": @@ -398,17 +389,17 @@ def _update_deck_point( if isinstance(action, SucceedCommandAction) and isinstance( action.command.result, ( - MoveToWellResult, - MoveToCoordinatesResult, - MoveRelativeResult, - MoveToAddressableAreaResult, - MoveToAddressableAreaForDropTipResult, - PickUpTipResult, - DropTipResult, - AspirateResult, - DispenseResult, - BlowOutResult, - TouchTipResult, + commands.MoveToWellResult, + commands.MoveToCoordinatesResult, + commands.MoveRelativeResult, + commands.MoveToAddressableAreaResult, + commands.MoveToAddressableAreaForDropTipResult, + commands.PickUpTipResult, + commands.DropTipResult, + commands.AspirateResult, + commands.DispenseResult, + commands.BlowOutResult, + commands.TouchTipResult, ), ): pipette_id = action.command.params.pipetteId @@ -421,7 +412,12 @@ def _update_deck_point( isinstance(action, FailCommandAction) and isinstance( action.running_command, - (Aspirate, Dispense, AspirateInPlace, DispenseInPlace), + ( + commands.Aspirate, + commands.Dispense, + commands.AspirateInPlace, + commands.DispenseInPlace, + ), ) and isinstance(action.error, DefinedErrorData) and isinstance(action.error.public, OverpressureError) @@ -437,10 +433,10 @@ def _update_deck_point( elif isinstance(action, SucceedCommandAction) and isinstance( action.command.result, ( - HomeResult, - RetractAxisResult, - thermocycler.OpenLidResult, - thermocycler.CloseLidResult, + commands.HomeResult, + commands.RetractAxisResult, + commands.thermocycler.OpenLidResult, + commands.thermocycler.CloseLidResult, ), ): self._clear_deck_point() @@ -448,15 +444,15 @@ def _update_deck_point( elif isinstance(action, SucceedCommandAction) and isinstance( action.command.result, ( - heater_shaker.SetAndWaitForShakeSpeedResult, - heater_shaker.OpenLabwareLatchResult, + commands.heater_shaker.SetAndWaitForShakeSpeedResult, + commands.heater_shaker.OpenLabwareLatchResult, ), ): if action.command.result.pipetteRetracted: self._clear_deck_point() elif isinstance(action, SucceedCommandAction) and isinstance( - action.command.result, MoveLabwareResult + action.command.result, commands.MoveLabwareResult ): if action.command.params.strategy == "usingGripper": # All mounts will have been retracted. @@ -466,7 +462,8 @@ def _update_volumes( self, action: Union[SucceedCommandAction, FailCommandAction] ) -> None: if isinstance(action, SucceedCommandAction) and isinstance( - action.command.result, (AspirateResult, AspirateInPlaceResult) + action.command.result, + (commands.AspirateResult, commands.AspirateInPlaceResult), ): pipette_id = action.command.params.pipetteId previous_volume = self._state.aspirated_volume_by_id[pipette_id] or 0 @@ -477,7 +474,8 @@ def _update_volumes( self._state.aspirated_volume_by_id[pipette_id] = next_volume elif isinstance(action, SucceedCommandAction) and isinstance( - action.command.result, (DispenseResult, DispenseInPlaceResult) + action.command.result, + (commands.DispenseResult, commands.DispenseInPlaceResult), ): pipette_id = action.command.params.pipetteId previous_volume = self._state.aspirated_volume_by_id[pipette_id] or 0 @@ -488,13 +486,17 @@ def _update_volumes( elif isinstance(action, SucceedCommandAction) and isinstance( action.command.result, - (BlowOutResult, BlowOutInPlaceResult, unsafe.UnsafeBlowOutInPlaceResult), + ( + commands.BlowOutResult, + commands.BlowOutInPlaceResult, + commands.unsafe.UnsafeBlowOutInPlaceResult, + ), ): pipette_id = action.command.params.pipetteId self._state.aspirated_volume_by_id[pipette_id] = None elif isinstance(action, SucceedCommandAction) and isinstance( - action.command.result, PrepareToAspirateResult + action.command.result, commands.PrepareToAspirateResult ): pipette_id = action.command.params.pipetteId self._state.aspirated_volume_by_id[pipette_id] = 0 diff --git a/api/tests/opentrons/protocol_engine/state/test_pipette_store.py b/api/tests/opentrons/protocol_engine/state/test_pipette_store.py index c8d60395b3b..a49c9255605 100644 --- a/api/tests/opentrons/protocol_engine/state/test_pipette_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_pipette_store.py @@ -10,6 +10,8 @@ from opentrons.protocol_engine import commands as cmd from opentrons.protocol_engine.commands.command import DefinedErrorData from opentrons.protocol_engine.commands.pipetting_common import ( + LiquidNotFoundError, + LiquidNotFoundErrorInternalData, OverpressureError, OverpressureErrorInternalData, ) @@ -502,6 +504,95 @@ def test_blow_out_clears_volume( well_name="dispense-well-name", ), ), + # liquidProbe and tryLiquidProbe succeeding and with overpressure error + ( + SucceedCommandAction( + command=cmd.LiquidProbe( + id="command-id", + createdAt=datetime.now(), + startedAt=datetime.now(), + completedAt=datetime.now(), + key="command-key", + status=cmd.CommandStatus.SUCCEEDED, + params=cmd.LiquidProbeParams( + labwareId="liquid-probe-labware-id", + wellName="liquid-probe-well-name", + pipetteId="pipette-id", + ), + result=cmd.LiquidProbeResult( + position=DeckPoint(x=0, y=0, z=0), z_position=0 + ), + ), + private_result=None, + ), + CurrentWell( + pipette_id="pipette-id", + labware_id="liquid-probe-labware-id", + well_name="liquid-probe-well-name", + ), + ), + ( + FailCommandAction( + running_command=cmd.LiquidProbe( + id="command-id", + createdAt=datetime.now(), + startedAt=datetime.now(), + key="command-key", + status=cmd.CommandStatus.RUNNING, + params=cmd.LiquidProbeParams( + labwareId="liquid-probe-labware-id", + wellName="liquid-probe-well-name", + pipetteId="pipette-id", + ), + ), + error=DefinedErrorData( + public=LiquidNotFoundError( + id="error-id", + createdAt=datetime.now(), + ), + private=LiquidNotFoundErrorInternalData( + position=DeckPoint(x=0, y=0, z=0) + ), + ), + command_id="command-id", + error_id="error-id", + failed_at=datetime.now(), + notes=[], + type=ErrorRecoveryType.WAIT_FOR_RECOVERY, + ), + CurrentWell( + pipette_id="pipette-id", + labware_id="liquid-probe-labware-id", + well_name="liquid-probe-well-name", + ), + ), + ( + SucceedCommandAction( + command=cmd.TryLiquidProbe( + id="command-id", + createdAt=datetime.now(), + startedAt=datetime.now(), + completedAt=datetime.now(), + key="command-key", + status=cmd.CommandStatus.SUCCEEDED, + params=cmd.TryLiquidProbeParams( + labwareId="try-liquid-probe-labware-id", + wellName="try-liquid-probe-well-name", + pipetteId="pipette-id", + ), + result=cmd.TryLiquidProbeResult( + position=DeckPoint(x=0, y=0, z=0), + z_position=0, + ), + ), + private_result=None, + ), + CurrentWell( + pipette_id="pipette-id", + labware_id="try-liquid-probe-labware-id", + well_name="try-liquid-probe-well-name", + ), + ), ), ) def test_movement_commands_update_current_well( From 47f15dc7306438853510fff3e8183a25b8895c08 Mon Sep 17 00:00:00 2001 From: syao1226 <146495172+syao1226@users.noreply.github.com> Date: Fri, 2 Aug 2024 17:15:02 -0400 Subject: [PATCH 45/49] feat(robot_server): send and save csv rtp files for runs (#15857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit re AUTH-591 # Overview Send and save the CSV RTPs used in protocol runs to the new table created in the database, and hook it up to the GET endpoint that fetches all the data files used in analysis and run. ## Test Plan and Hands on Testing - run test files to ensure that csv rtps get insert to the new run_csv_rtp_table and able to get them from the table ## Changelog - Added `run_csv_rtp_table` to schema_6.py file - Added runTimeParameterFiles field to the run creation request model - Passed rtp files input onto the Run Orchestrator’s load process - Added `insert_csv_rtp()` and `get_all_csv_rtp()` function - Updated `get_reference_data_files()` to add file referenced in runs - Updated tests --------- Co-authored-by: shiyaochen Co-authored-by: Sanniti --- .../robot_server/protocols/protocol_store.py | 31 ++++- .../robot_server/runs/router/base_router.py | 4 + .../robot_server/runs/run_data_manager.py | 10 +- robot-server/robot_server/runs/run_models.py | 5 + .../runs/run_orchestrator_store.py | 11 +- robot-server/robot_server/runs/run_store.py | 59 ++++++++ ...t_run_with_run_time_parameters.tavern.yaml | 131 ++++++------------ .../tests/protocols/test_protocol_store.py | 62 +++++++-- .../tests/runs/router/test_base_router.py | 7 +- .../tests/runs/test_run_data_manager.py | 23 +++ robot-server/tests/runs/test_run_store.py | 52 ++++++- 11 files changed, 285 insertions(+), 110 deletions(-) diff --git a/robot-server/robot_server/protocols/protocol_store.py b/robot-server/robot_server/protocols/protocol_store.py index 0488a958a12..13676a798eb 100644 --- a/robot-server/robot_server/protocols/protocol_store.py +++ b/robot-server/robot_server/protocols/protocol_store.py @@ -24,6 +24,7 @@ analysis_primitive_type_rtp_table, analysis_csv_rtp_table, data_files_table, + run_csv_rtp_table, ProtocolKindSQLEnum, ) from robot_server.protocols.protocol_models import ProtocolKind @@ -310,20 +311,38 @@ def get_usage_info(self) -> List[ProtocolUsageInfo]: # TODO (spp, 2024-07-22): get files referenced in runs as well async def get_referenced_data_files(self, protocol_id: str) -> List[DataFile]: """Get a list of data files referenced in specified protocol's analyses and runs.""" - # Get analyses of protocol_id + # Get analyses and runs of protocol_id select_referencing_analysis_ids = sqlalchemy.select(analysis_table.c.id).where( analysis_table.c.protocol_id == protocol_id ) + select_referencing_run_ids = sqlalchemy.select(run_table.c.id).where( + run_table.c.protocol_id == protocol_id + ) # Get all entries in csv table that match the analyses - csv_file_ids = sqlalchemy.select(analysis_csv_rtp_table.c.file_id).where( + analysis_csv_file_ids = sqlalchemy.select( + analysis_csv_rtp_table.c.file_id + ).where( analysis_csv_rtp_table.c.analysis_id.in_(select_referencing_analysis_ids) ) + run_csv_file_ids = sqlalchemy.select(run_csv_rtp_table.c.file_id).where( + run_csv_rtp_table.c.run_id.in_(select_referencing_run_ids) + ) # Get list of data file IDs from the entries - select_data_file_rows_statement = data_files_table.select().where( - data_files_table.c.id.in_(csv_file_ids) + select_analysis_data_file_rows_statement = data_files_table.select().where( + data_files_table.c.id.in_(analysis_csv_file_ids) + ) + select_run_data_file_rows_statement = data_files_table.select().where( + data_files_table.c.id.in_(run_csv_file_ids) ) with self._sql_engine.begin() as transaction: - data_files_rows = transaction.execute(select_data_file_rows_statement).all() + analysis_data_files_rows = transaction.execute( + select_analysis_data_file_rows_statement + ).all() + run_data_files_rows = transaction.execute( + select_run_data_file_rows_statement + ).all() + + combine_data_file_rows = set(analysis_data_files_rows + run_data_files_rows) return [ DataFile( @@ -331,7 +350,7 @@ async def get_referenced_data_files(self, protocol_id: str) -> List[DataFile]: name=sql_row.name, createdAt=sql_row.created_at, ) - for sql_row in data_files_rows + for sql_row in combine_data_file_rows ] def get_referencing_run_ids(self, protocol_id: str) -> List[str]: diff --git a/robot-server/robot_server/runs/router/base_router.py b/robot-server/robot_server/runs/router/base_router.py index 14c5b822fda..1ed03b44cd7 100644 --- a/robot-server/robot_server/runs/router/base_router.py +++ b/robot-server/robot_server/runs/router/base_router.py @@ -176,6 +176,9 @@ async def create_run( rtp_values = ( request_body.data.runTimeParameterValues if request_body is not None else None ) + rtp_files = ( + request_body.data.runTimeParameterFiles if request_body is not None else None + ) protocol_resource = None deck_configuration = await deck_configuration_store.get_deck_configuration() @@ -206,6 +209,7 @@ async def create_run( labware_offsets=offsets, deck_configuration=deck_configuration, run_time_param_values=rtp_values, + run_time_param_files=rtp_files, protocol=protocol_resource, notify_publishers=notify_publishers, ) diff --git a/robot-server/robot_server/runs/run_data_manager.py b/robot-server/robot_server/runs/run_data_manager.py index de5eea82e45..c5cacbb7571 100644 --- a/robot-server/robot_server/runs/run_data_manager.py +++ b/robot-server/robot_server/runs/run_data_manager.py @@ -12,7 +12,10 @@ CommandPointer, Command, ) -from opentrons.protocol_engine.types import PrimitiveRunTimeParamValuesType +from opentrons.protocol_engine.types import ( + CSVRunTimeParamFilesType, + PrimitiveRunTimeParamValuesType, +) from robot_server.protocols.protocol_store import ProtocolResource from robot_server.service.task_runner import TaskRunner @@ -156,6 +159,7 @@ async def create( labware_offsets: List[LabwareOffsetCreate], deck_configuration: DeckConfigurationType, run_time_param_values: Optional[PrimitiveRunTimeParamValuesType], + run_time_param_files: Optional[CSVRunTimeParamFilesType], notify_publishers: Callable[[], None], protocol: Optional[ProtocolResource], ) -> Union[Run, BadRun]: @@ -168,6 +172,7 @@ async def create( deck_configuration: A mapping of fixtures to cutout fixtures the deck will be loaded with. notify_publishers: Utilized by the engine to notify publishers of state changes. run_time_param_values: Any runtime parameter values to set. + run_time_param_files: Any runtime parameter values to set. protocol: The protocol to load the runner with, if any. Returns: @@ -192,6 +197,7 @@ async def create( deck_configuration=deck_configuration, protocol=protocol, run_time_param_values=run_time_param_values, + run_time_param_files=run_time_param_files, notify_publishers=notify_publishers, ) run_resource = self._run_store.insert( @@ -210,7 +216,7 @@ async def create( run_resource=run_resource, state_summary=state_summary, current=True, - run_time_parameters=[], + run_time_parameters=self._run_orchestrator_store.get_run_time_parameters(), ) def get(self, run_id: str) -> Union[Run, BadRun]: diff --git a/robot-server/robot_server/runs/run_models.py b/robot-server/robot_server/runs/run_models.py index 45ad22e3167..db068870915 100644 --- a/robot-server/robot_server/runs/run_models.py +++ b/robot-server/robot_server/runs/run_models.py @@ -19,6 +19,7 @@ CommandNote, ) from opentrons.protocol_engine.types import ( + CSVRunTimeParamFilesType, RunTimeParameter, PrimitiveRunTimeParamValuesType, ) @@ -252,6 +253,10 @@ class RunCreate(BaseModel): None, description="Key-value pairs of run-time parameters defined in a protocol.", ) + runTimeParameterFiles: Optional[CSVRunTimeParamFilesType] = Field( + None, + description="Key-fileId pairs of CSV run-time parameters defined in a protocol.", + ) class RunUpdate(BaseModel): diff --git a/robot-server/robot_server/runs/run_orchestrator_store.py b/robot-server/robot_server/runs/run_orchestrator_store.py index 11448a81d0c..953c9758cb1 100644 --- a/robot-server/robot_server/runs/run_orchestrator_store.py +++ b/robot-server/robot_server/runs/run_orchestrator_store.py @@ -4,7 +4,11 @@ from typing import List, Optional, Callable from opentrons.protocol_engine.errors.exceptions import EStopActivatedError -from opentrons.protocol_engine.types import PostRunHardwareState, RunTimeParameter +from opentrons.protocol_engine.types import ( + CSVRunTimeParamFilesType, + PostRunHardwareState, + RunTimeParameter, +) from opentrons_shared_data.labware.labware_definition import LabwareDefinition from opentrons_shared_data.robot.types import RobotType @@ -188,6 +192,7 @@ async def create( notify_publishers: Callable[[], None], protocol: Optional[ProtocolResource], run_time_param_values: Optional[PrimitiveRunTimeParamValuesType] = None, + run_time_param_files: Optional[CSVRunTimeParamFilesType] = None, ) -> StateSummary: """Create and store a ProtocolRunner and ProtocolEngine for a given Run. @@ -198,6 +203,7 @@ async def create( notify_publishers: Utilized by the engine to notify publishers of state changes. protocol: The protocol to load the runner with, if any. run_time_param_values: Any runtime parameter values to set. + run_time_param_files: Any runtime parameter files to set. Returns: The initial equipment and status summary of the engine. @@ -243,8 +249,7 @@ async def create( await self.run_orchestrator.load( protocol.source, run_time_param_values=run_time_param_values, - # TODO (spp, 2024-07-16): update this once runs accept csv params - run_time_param_files={}, + run_time_param_files=run_time_param_files, parse_mode=ParseMode.ALLOW_LEGACY_METADATA_AND_REQUIREMENTS, ) else: diff --git a/robot-server/robot_server/runs/run_store.py b/robot-server/robot_server/runs/run_store.py index 6cf86d14af1..bbd50b1f713 100644 --- a/robot-server/robot_server/runs/run_store.py +++ b/robot-server/robot_server/runs/run_store.py @@ -25,6 +25,7 @@ run_table, run_command_table, action_table, + run_csv_rtp_table, ) from robot_server.persistence.pydantic import ( json_to_pydantic, @@ -85,6 +86,15 @@ class BadStateSummary: dataError: EnumeratedError +@dataclass +class CSVParameterRunResource: + """A CSV runtime parameter from a completed run, storable in a SQL database.""" + + run_id: str + parameter_variable_name: str + file_id: Optional[str] + + class CommandNotFoundError(ValueError): """Error raised when a given command ID is not found in the store.""" @@ -198,6 +208,39 @@ def insert_action(self, run_id: str, action: RunAction) -> None: self._clear_caches() + def get_all_csv_rtp(self) -> List[CSVParameterRunResource]: + """Get all of the csv rtp from the run_csv_rtp_table.""" + select_all_csv_rtp = sqlalchemy.select(run_csv_rtp_table).order_by( + sqlite_rowid.asc() + ) + + with self._sql_engine.begin() as transaction: + csv_rtps = transaction.execute(select_all_csv_rtp).all() + + return [_covert_row_to_csv_rtp(row) for row in csv_rtps] + + def insert_csv_rtp( + self, run_id: str, run_time_parameters: List[RunTimeParameter] + ) -> None: + """Save csv rtp to the run_csv_rtp_table.""" + insert_csv_rtp = sqlalchemy.insert(run_csv_rtp_table) + + with self._sql_engine.begin() as transaction: + if not self._run_exists(run_id, transaction): + raise RunNotFoundError(run_id=run_id) + for run_time_param in run_time_parameters: + if run_time_param.type == "csv_file": + transaction.execute( + insert_csv_rtp, + { + "run_id": run_id, + "parameter_variable_name": run_time_param.variableName, + "file_id": run_time_param.file.id + if run_time_param.file + else None, + }, + ) + def insert( self, run_id: str, @@ -531,6 +574,22 @@ def _clear_caches(self) -> None: _run_columns = [run_table.c.id, run_table.c.protocol_id, run_table.c.created_at] +def _covert_row_to_csv_rtp( + row: sqlalchemy.engine.Row, +) -> CSVParameterRunResource: + run_id = row.run_id + parameter_variable_name = row.parameter_variable_name + file_id = row.file_id + + assert isinstance(run_id, str) + assert isinstance(parameter_variable_name, str) + assert isinstance(file_id, str) or file_id is None + + return CSVParameterRunResource( + run_id=run_id, parameter_variable_name=parameter_variable_name, file_id=file_id + ) + + def _convert_row_to_run( row: sqlalchemy.engine.Row, action_rows: List[sqlalchemy.engine.Row], diff --git a/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml index 9d91abea32f..a616a50cc66 100644 --- a/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml @@ -34,6 +34,7 @@ stages: save: json: run_id: data.id + run_time_parameters_data: data.runTimeParameters json: data: id: !anystr @@ -48,7 +49,49 @@ stages: modules: [] labware: [] labwareOffsets: [] - runTimeParameters: [] + runTimeParameters: + - displayName: Sample count + variableName: sample_count + type: int + default: 6.0 + min: 1.0 + max: 12.0 + value: 4.0 + description: How many samples to process. + - displayName: Pipette volume + variableName: volume + type: float + default: 20.1 + choices: + - displayName: Low Volume + value: 10.23 + - displayName: Medium Volume + value: 20.1 + - displayName: High Volume + value: 50.5 + value: 10.23 + description: How many microliters to pipette of each sample. + - displayName: Dry Run + variableName: dry_run + type: bool + default: false + value: true + description: Skip aspirate and dispense steps. + - displayName: Pipette Name + variableName: pipette + type: str + choices: + - displayName: Single channel 50µL + value: flex_1channel_50 + - displayName: Eight Channel 50µL + value: flex_8channel_50 + default: flex_1channel_50 + value: flex_8channel_50 + 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 + type: csv_file liquids: [] protocolId: '{protocol_id}' @@ -96,48 +139,7 @@ stages: createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" status: succeeded current: True - runTimeParameters: - - displayName: Sample count - variableName: sample_count - type: int - default: 6.0 - min: 1.0 - max: 12.0 - value: 4.0 - description: How many samples to process. - - displayName: Pipette volume - variableName: volume - type: float - default: 20.1 - choices: - - displayName: Low Volume - value: 10.23 - - displayName: Medium Volume - value: 20.1 - - displayName: High Volume - value: 50.5 - value: 10.23 - description: How many microliters to pipette of each sample. - - displayName: Dry Run - variableName: dry_run - type: bool - default: false - value: true - description: Skip aspirate and dispense steps. - - displayName: Pipette Name - variableName: pipette - type: str - choices: - - displayName: Single channel 50µL - value: flex_1channel_50 - - displayName: Eight Channel 50µL - value: flex_8channel_50 - default: flex_1channel_50 - value: flex_8channel_50 - 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 + runTimeParameters: !force_original_structure '{run_time_parameters_data}' protocolId: '{protocol_id}' - name: Mark the run as not-current @@ -165,46 +167,5 @@ stages: createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" status: succeeded current: False - runTimeParameters: - - displayName: Sample count - variableName: sample_count - type: int - default: 6.0 - min: 1.0 - max: 12.0 - value: 4.0 - description: How many samples to process. - - displayName: Pipette volume - variableName: volume - type: float - default: 20.1 - choices: - - displayName: Low Volume - value: 10.23 - - displayName: Medium Volume - value: 20.1 - - displayName: High Volume - value: 50.5 - value: 10.23 - description: How many microliters to pipette of each sample. - - displayName: Dry Run - variableName: dry_run - type: bool - default: false - value: true - description: Skip aspirate and dispense steps. - - displayName: Pipette Name - variableName: pipette - type: str - choices: - - displayName: Single channel 50µL - value: flex_1channel_50 - - displayName: Eight Channel 50µL - value: flex_8channel_50 - default: flex_1channel_50 - value: flex_8channel_50 - 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 + runTimeParameters: !force_original_structure '{run_time_parameters_data}' protocolId: '{protocol_id}' diff --git a/robot-server/tests/protocols/test_protocol_store.py b/robot-server/tests/protocols/test_protocol_store.py index 952bcb9c0fd..ff6d4ce7b49 100644 --- a/robot-server/tests/protocols/test_protocol_store.py +++ b/robot-server/tests/protocols/test_protocol_store.py @@ -1,4 +1,5 @@ """Tests for the ProtocolStore interface.""" +from opentrons.protocol_engine.types import CSVParameter, FileInfo import pytest from decoy import Decoy from datetime import datetime, timezone @@ -530,6 +531,7 @@ async def test_get_referenced_data_files( subject: ProtocolStore, data_files_store: DataFilesStore, completed_analysis_store: CompletedAnalysisStore, + run_store: RunStore, ) -> None: """It should fetch a list of data files referenced in protocol's analyses and runs.""" protocol_resource_1 = ProtocolResource( @@ -579,6 +581,7 @@ async def test_get_referenced_data_files( liquids=[], ), ) + subject.insert(protocol_resource_1) await data_files_store.insert( DataFileInfo( @@ -596,6 +599,32 @@ async def test_get_referenced_data_files( created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), ) ) + await data_files_store.insert( + DataFileInfo( + id="data-file-id-3", + name="file-name", + file_hash="abc123", + created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ) + ) + + run_store.insert( + run_id="run-id-1", + protocol_id="protocol-id", + created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ) + + run_store.insert_csv_rtp( + run_id="run-id-1", + run_time_parameters=[ + CSVParameter( + variableName="csvFile", + displayName="csv param", + file=FileInfo(id="data-file-id-3", name="file-name"), + ) + ], + ) + await completed_analysis_store.make_room_and_add( completed_analysis_resource=analysis_resource1, primitive_rtp_resources=[], @@ -618,15 +647,24 @@ async def test_get_referenced_data_files( csv_rtp_resources=[], ) result = await subject.get_referenced_data_files("protocol-id") - assert result == [ - DataFile( - id="data-file-id", - name="file-name", - createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), - ), - DataFile( - id="data-file-id-2", - name="file-name", - createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), - ), - ] + + for data_file in result: + assert data_file in [ + DataFile( + id="data-file-id", + name="file-name", + createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ), + DataFile( + id="data-file-id-2", + name="file-name", + createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ), + DataFile( + id="data-file-id-3", + name="file-name", + createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ), + ] + + assert len(result) == 3 diff --git a/robot-server/tests/runs/router/test_base_router.py b/robot-server/tests/runs/router/test_base_router.py index 979d3a92371..fd1cdd8b58a 100644 --- a/robot-server/tests/runs/router/test_base_router.py +++ b/robot-server/tests/runs/router/test_base_router.py @@ -97,6 +97,7 @@ async def test_create_run( deck_configuration=[], protocol=None, run_time_param_values=None, + run_time_param_files=None, notify_publishers=mock_notify_publishers, ) ).then_return(expected_response) @@ -177,6 +178,7 @@ async def test_create_protocol_run( deck_configuration=[], protocol=protocol_resource, run_time_param_values={"foo": "bar"}, + run_time_param_files={"my_file": "file-id"}, notify_publishers=mock_notify_publishers, ) ).then_return(expected_response) @@ -184,7 +186,9 @@ async def test_create_protocol_run( result = await create_run( request_body=RequestModel( data=RunCreate( - protocolId="protocol-id", runTimeParameterValues={"foo": "bar"} + protocolId="protocol-id", + runTimeParameterValues={"foo": "bar"}, + runTimeParameterFiles={"my_file": "file-id"}, ) ), protocol_store=mock_protocol_store, @@ -245,6 +249,7 @@ async def test_create_run_conflict( deck_configuration=[], protocol=None, run_time_param_values=None, + run_time_param_files=None, notify_publishers=mock_notify_publishers, ) ).then_raise(RunConflictError("oh no")) diff --git a/robot-server/tests/runs/test_run_data_manager.py b/robot-server/tests/runs/test_run_data_manager.py index ba0b457f9f6..a369f7f47b0 100644 --- a/robot-server/tests/runs/test_run_data_manager.py +++ b/robot-server/tests/runs/test_run_data_manager.py @@ -19,6 +19,7 @@ ) from opentrons.protocol_engine import Liquid from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryPolicy +from opentrons.protocol_engine.types import BooleanParameter, CSVParameter from opentrons.protocol_runner import RunResult from opentrons.types import DeckSlotName @@ -167,9 +168,13 @@ async def test_create( protocol=None, deck_configuration=[], run_time_param_values=None, + run_time_param_files=None, notify_publishers=mock_notify_publishers, ) ).then_return(engine_state_summary) + + decoy.when(mock_run_orchestrator_store.get_run_time_parameters()).then_return([]) + decoy.when( mock_run_store.insert( run_id=run_id, @@ -185,6 +190,7 @@ async def test_create( protocol=None, deck_configuration=[], run_time_param_values=None, + run_time_param_files=None, notify_publishers=mock_notify_publishers, ) @@ -238,6 +244,7 @@ async def test_create_with_options( protocol=protocol, deck_configuration=[], run_time_param_values={"foo": "bar"}, + run_time_param_files={"my_file": "file-id"}, notify_publishers=mock_notify_publishers, ) ).then_return(engine_state_summary) @@ -250,6 +257,16 @@ async def test_create_with_options( ) ).then_return(run_resource) + bool_parameter = BooleanParameter( + displayName="foo", variableName="bar", default=True, value=False + ) + + file_parameter = CSVParameter(displayName="my_file", variableName="file-id") + + decoy.when(mock_run_orchestrator_store.get_run_time_parameters()).then_return( + [bool_parameter, file_parameter] + ) + result = await subject.create( run_id=run_id, created_at=created_at, @@ -257,6 +274,7 @@ async def test_create_with_options( protocol=protocol, deck_configuration=[], run_time_param_values={"foo": "bar"}, + run_time_param_files={"my_file": "file-id"}, notify_publishers=mock_notify_publishers, ) @@ -274,6 +292,7 @@ async def test_create_with_options( pipettes=engine_state_summary.pipettes, modules=engine_state_summary.modules, liquids=engine_state_summary.liquids, + runTimeParameters=[bool_parameter, file_parameter], ) @@ -294,6 +313,7 @@ async def test_create_engine_error( protocol=None, deck_configuration=[], run_time_param_values=None, + run_time_param_files=None, notify_publishers=mock_notify_publishers, ) ).then_raise(RunConflictError("oh no")) @@ -306,6 +326,7 @@ async def test_create_engine_error( protocol=None, deck_configuration=[], run_time_param_values=None, + run_time_param_files=None, notify_publishers=mock_notify_publishers, ) @@ -753,6 +774,7 @@ async def test_create_archives_existing( protocol=None, deck_configuration=[], run_time_param_values=None, + run_time_param_files=None, notify_publishers=mock_notify_publishers, ) ).then_return(engine_state_summary) @@ -772,6 +794,7 @@ async def test_create_archives_existing( protocol=None, deck_configuration=[], run_time_param_values=None, + run_time_param_files=None, notify_publishers=mock_notify_publishers, ) diff --git a/robot-server/tests/runs/test_run_store.py b/robot-server/tests/runs/test_run_store.py index 7e4155ef1b5..f4b2b8e154f 100644 --- a/robot-server/tests/runs/test_run_store.py +++ b/robot-server/tests/runs/test_run_store.py @@ -1,9 +1,11 @@ """Tests for robot_server.runs.run_store.""" from datetime import datetime, timezone +from pathlib import Path from typing import List, Optional, Type import pytest from decoy import Decoy +from robot_server.data_files.data_files_store import DataFileInfo, DataFilesStore from sqlalchemy.engine import Engine from unittest import mock @@ -12,6 +14,7 @@ from robot_server.protocols.protocol_store import ProtocolNotFoundError from robot_server.runs.run_store import ( + CSVParameterRunResource, RunStore, RunResource, CommandNotFoundError, @@ -157,6 +160,7 @@ def run_time_parameters() -> List[pe_types.RunTimeParameter]: displayName="Display Name 4", variableName="variable_name_4", description="a csv parameter without file id", + file=pe_types.FileInfo(id="file-id", name="csvFile"), ), ] @@ -202,7 +206,20 @@ def invalid_state_summary() -> StateSummary: ) -def test_update_run_state( +@pytest.fixture +def data_files_store(sql_engine: Engine, tmp_path: Path) -> DataFilesStore: + """Return a `DataFilesStore` linked to the same database as the subject under test. + + `DataFilesStore` is tested elsewhere. + We only need it here to prepare the database for the analysis store tests. + The CSV parameters table always needs a data file to link to. + """ + data_files_dir = tmp_path / "data_files" + data_files_dir.mkdir() + return DataFilesStore(sql_engine=sql_engine, data_files_directory=data_files_dir) + + +async def test_update_run_state( subject: RunStore, state_summary: StateSummary, protocol_commands: List[pe_commands.Command], @@ -252,6 +269,39 @@ def test_update_run_state( ) +async def test_insert_and_get_csv_rtp( + subject: RunStore, + data_files_store: DataFilesStore, + run_time_parameters: List[pe_types.RunTimeParameter], +) -> None: + """It should be able to insert and get csv rtp from the db.""" + await data_files_store.insert( + DataFileInfo( + id="file-id", + name="my_csv_file.csv", + file_hash="file-hash", + created_at=datetime(year=2024, month=1, day=1, tzinfo=timezone.utc), + ) + ) + + subject.insert( + run_id="run-id", + protocol_id=None, + created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ) + + subject.insert_csv_rtp(run_id="run-id", run_time_parameters=run_time_parameters) + csv_rtp_result = subject.get_all_csv_rtp() + + assert csv_rtp_result == [ + CSVParameterRunResource( + run_id="run-id", + parameter_variable_name="variable_name_4", + file_id="file-id", + ) + ] + + def test_update_state_run_not_found( subject: RunStore, state_summary: StateSummary, From f2f3c74b6a69774349286033fb85a71644411a1d Mon Sep 17 00:00:00 2001 From: Jeremy Leon Date: Fri, 2 Aug 2024 18:20:58 -0400 Subject: [PATCH 46/49] feat(api, robot-server): use runtime parameter files set in protocols and runs to set in-protocol values (#15855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Overview Closes AUTH-419. This PR hooks up the setting of runtime parameter files in the POST `/runs`, `/protocols` and `/protocols/{protocolId}/analyses` to the actual `CSVParameter` object used in the protocol context. This file is sent via a dictionary of parameter name and file-id, so we resolve the file-id into a `Path` by using the `data_files_directory` and `data_files_store`. This is then passed to the `ParameterContext` and used to open up a temporary file that contains the contents of the actual file stored on the robot. ## Test Plan and Hands on Testing Tested end to end via the following protocol and two CSV files ``` metadata = { 'protocolName': 'CSV End to End Test', } requirements = { "robotType": "Flex", "apiLevel": "2.18" } def add_parameters(parameters): parameters.add_str( display_name="Pipette Name", variable_name="pipette", choices=[ {"display_name": "Single channel 50µL", "value": "flex_1channel_50"}, {"display_name": "Single channel 1000µL", "value": "flex_1channel_1000"}, {"display_name": "Eight Channel 50µL", "value": "flex_8channel_50"}, {"display_name": "Eight Channel 1000µL", "value": "flex_8channel_1000"}, ], default="flex_1channel_50", description="What pipette to use during the protocol.", ) parameters.add_csv_file( display_name="CSV Data", variable_name="csv_data", description="CSV file containing labware and volume information." ) def run(context): PIPETTE_NAME = context.params.pipette trash_bin = context.load_trash_bin('A3') tip_rack = context.load_labware('opentrons_flex_96_tiprack_50ul', 'D2') pipette = context.load_instrument(PIPETTE_NAME, mount="left", tip_racks=[tip_rack]) csv_file_data = context.params.csv_data.parse_as_csv() labware_name = csv_file_data[1][0].strip() location = csv_file_data[1][1].strip() volume = float(csv_file_data[1][2]) labware = context.load_labware(labware_name, location) pipette.pick_up_tip() pipette.aspirate(volume, labware.wells()[0].top()) pipette.dispense(volume, labware.wells()[1].top()) pipette.drop_tip() ``` ``` Labware Name, Location, Volume opentrons_96_wellplate_200ul_pcr_full_skirt, C1, 20 ``` ``` Labware Name, Location, Volume nest_96_wellplate_100ul_pcr_full_skirt, C2, 30 ``` ## Changelog - Resolve `runTimeParameterFiles` into a dictionary of `Paths` - pass the `run_time_param_paths` all the way to the `ParameterContext` where they can be opened as temporary file handlers - close the file handlers upon protocol end - Allow importing of `CSVParameter` from the `protocol_api` namespace. ## Review requests ## Risk assessment Medium. --------- Co-authored-by: Sanniti --- api/src/opentrons/cli/analyze.py | 2 +- api/src/opentrons/protocol_api/__init__.py | 2 + .../protocol_api/_parameter_context.py | 46 ++++++++++-- api/src/opentrons/protocol_engine/types.py | 2 + .../protocol_runner/protocol_runner.py | 11 +-- .../python_protocol_wrappers.py | 11 ++- .../protocol_runner/run_orchestrator.py | 6 +- .../opentrons/protocols/execution/execute.py | 29 +++++--- .../protocols/execution/execute_python.py | 6 +- .../opentrons/protocols/parameters/types.py | 1 + .../protocol_runner/test_protocol_runner.py | 8 +- .../protocol_runner/test_run_orchestrator.py | 8 +- .../protocols/analyses_manager.py | 6 +- .../protocols/protocol_analyzer.py | 6 +- robot-server/robot_server/protocols/router.py | 36 +++++++-- .../robot_server/runs/router/base_router.py | 22 +++++- .../robot_server/runs/run_data_manager.py | 8 +- robot-server/robot_server/runs/run_models.py | 4 +- .../runs/run_orchestrator_store.py | 9 ++- robot-server/settings_schema.json | 40 ++++++++-- .../integration/data_files/sample_plates.csv | 41 ++++++++++ ...lyses_with_csv_file_parameters.tavern.yaml | 2 +- ...t_run_with_run_time_parameters.tavern.yaml | 22 ++++++ .../tests/protocols/test_analyses_manager.py | 8 +- .../tests/protocols/test_protocol_analyzer.py | 8 +- .../tests/protocols/test_protocols_router.py | 74 +++++++++++++++---- .../tests/runs/router/test_base_router.py | 30 +++++++- .../tests/runs/test_run_data_manager.py | 22 +++--- 28 files changed, 363 insertions(+), 107 deletions(-) create mode 100644 robot-server/tests/integration/data_files/sample_plates.csv diff --git a/api/src/opentrons/cli/analyze.py b/api/src/opentrons/cli/analyze.py index 7270e517644..8e90e08190b 100644 --- a/api/src/opentrons/cli/analyze.py +++ b/api/src/opentrons/cli/analyze.py @@ -248,7 +248,7 @@ async def _do_analyze(protocol_source: ProtocolSource) -> RunResult: protocol_source=protocol_source, parse_mode=ParseMode.NORMAL, run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, ) except Exception as error: err_id = "analysis-setup-error" diff --git a/api/src/opentrons/protocol_api/__init__.py b/api/src/opentrons/protocol_api/__init__.py index 975f2996c98..ed95efca22d 100644 --- a/api/src/opentrons/protocol_api/__init__.py +++ b/api/src/opentrons/protocol_api/__init__.py @@ -12,6 +12,7 @@ from opentrons.protocols.parameters.exceptions import ( RuntimeParameterRequired as RuntimeParameterRequiredError, ) +from opentrons.protocols.parameters.types import CSVParameter from .protocol_context import ProtocolContext from .deck import Deck @@ -74,6 +75,7 @@ "ALL", "OFF_DECK", "RuntimeParameterRequiredError", + "CSVParameter", # For internal Opentrons use only: "create_protocol_context", "ProtocolEngineCoreRequiredError", diff --git a/api/src/opentrons/protocol_api/_parameter_context.py b/api/src/opentrons/protocol_api/_parameter_context.py index 32528dab8d6..f334c2ef1d2 100644 --- a/api/src/opentrons/protocol_api/_parameter_context.py +++ b/api/src/opentrons/protocol_api/_parameter_context.py @@ -1,5 +1,5 @@ """Parameter context for python protocols.""" - +import tempfile from typing import List, Optional, Union, Dict from opentrons.protocols.api_support.types import APIVersion @@ -19,7 +19,7 @@ from opentrons.protocol_engine.types import ( RunTimeParameter, PrimitiveRunTimeParamValuesType, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, FileInfo, ) @@ -218,7 +218,7 @@ def set_parameters( parameter.value = validated_value def initialize_csv_files( - self, run_time_param_file_overrides: CSVRunTimeParamFilesType + self, run_time_param_file_overrides: CSVRuntimeParamPaths ) -> None: """Initializes the files for CSV parameters. @@ -226,7 +226,7 @@ def initialize_csv_files( This is intended for Opentrons internal use only and is not a guaranteed API. """ - for variable_name, file_id in run_time_param_file_overrides.items(): + for variable_name, file_path in run_time_param_file_overrides.items(): try: parameter = self._parameters[variable_name] except KeyError: @@ -240,11 +240,41 @@ def initialize_csv_files( f"File Id was provided for the parameter '{variable_name}'," f" but '{variable_name}' is not a CSV parameter." ) + # TODO(jbl 2024-08-02) This file opening should be moved elsewhere to provide more flexibility with files + # that may be opened as non-text or non-UTF-8 + # The parent folder in the path will be the file ID, so we can use that to resolve that here + file_id = file_path.parent.name + file_name = file_path.name + + # Read the contents of the actual file + with file_path.open() as csv_file: + contents = csv_file.read() + + # Open a temporary file with write permissions and write contents to that + temporary_file = tempfile.NamedTemporaryFile("r+") + temporary_file.write(contents) + temporary_file.flush() + + # Open a new file handler for the temporary file with read-only permissions and close the other + parameter_file = open(temporary_file.name, "r") + temporary_file.close() + + parameter.file_info = FileInfo(id=file_id, name=file_name) + parameter.value = parameter_file + + def close_csv_files(self) -> None: + """Close all file handlers for CSV parameters. - parameter.file_info = FileInfo(id=file_id, name="") - # TODO (spp, 2024-07-16): set the file name and assign the file as parameter.value. - # Most likely, we will be creating a temporary file copy of the original - # to pass onto the protocol context + :meta private: + + This is intended for Opentrons internal use only and is not a guaranteed API. + """ + for parameter in self._parameters.values(): + if ( + isinstance(parameter, csv_parameter_definition.CSVParameterDefinition) + and parameter.value is not None + ): + parameter.value.close() def export_parameters_for_analysis(self) -> List[RunTimeParameter]: """Exports all parameters into a protocol engine models for reporting in analysis. diff --git a/api/src/opentrons/protocol_engine/types.py b/api/src/opentrons/protocol_engine/types.py index 9da73149043..6c19324870a 100644 --- a/api/src/opentrons/protocol_engine/types.py +++ b/api/src/opentrons/protocol_engine/types.py @@ -4,6 +4,7 @@ from datetime import datetime from enum import Enum from dataclasses import dataclass +from pathlib import Path from pydantic import ( BaseModel, Field, @@ -1069,3 +1070,4 @@ class CSVParameter(RTPBase): ] # update value types as more RTP types are added CSVRunTimeParamFilesType = Mapping[StrictStr, StrictStr] +CSVRuntimeParamPaths = Dict[str, Path] diff --git a/api/src/opentrons/protocol_runner/protocol_runner.py b/api/src/opentrons/protocol_runner/protocol_runner.py index bfe959ca0eb..b0aabad34a4 100644 --- a/api/src/opentrons/protocol_runner/protocol_runner.py +++ b/api/src/opentrons/protocol_runner/protocol_runner.py @@ -44,7 +44,7 @@ DeckConfigurationType, RunTimeParameter, PrimitiveRunTimeParamValuesType, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, ) from ..protocols.types import PythonProtocol @@ -186,7 +186,7 @@ async def load( protocol_source: ProtocolSource, python_parse_mode: PythonParseMode, run_time_param_values: Optional[PrimitiveRunTimeParamValuesType], - run_time_param_files: Optional[CSVRunTimeParamFilesType], + run_time_param_paths: Optional[CSVRuntimeParamPaths], ) -> None: """Load a Python or JSONv5(& older) ProtocolSource into managed ProtocolEngine.""" labware_definitions = await protocol_reader.extract_labware_definitions( @@ -209,7 +209,7 @@ async def load( protocol=protocol, parameter_context=self._parameter_context, run_time_param_overrides=run_time_param_values, - run_time_param_file_overrides=run_time_param_files, + run_time_param_file_overrides=run_time_param_paths, ) ) else: @@ -244,6 +244,7 @@ async def run_func() -> None: await self._protocol_executor.execute( protocol=protocol, context=context, + parameter_context=self._parameter_context, run_time_parameters_with_overrides=run_time_parameters_with_overrides, ) @@ -254,7 +255,7 @@ async def run( # noqa: D102 deck_configuration: DeckConfigurationType, protocol_source: Optional[ProtocolSource] = None, run_time_param_values: Optional[PrimitiveRunTimeParamValuesType] = None, - run_time_param_files: Optional[CSVRunTimeParamFilesType] = None, + run_time_param_paths: Optional[CSVRuntimeParamPaths] = None, python_parse_mode: PythonParseMode = PythonParseMode.NORMAL, ) -> RunResult: # TODO(mc, 2022-01-11): move load to runner creation, remove from `run` @@ -264,7 +265,7 @@ async def run( # noqa: D102 protocol_source=protocol_source, python_parse_mode=python_parse_mode, run_time_param_values=run_time_param_values, - run_time_param_files=run_time_param_files, + run_time_param_paths=run_time_param_paths, ) self.play(deck_configuration=deck_configuration) diff --git a/api/src/opentrons/protocol_runner/python_protocol_wrappers.py b/api/src/opentrons/protocol_runner/python_protocol_wrappers.py index e1090d98fa4..17f82b88846 100644 --- a/api/src/opentrons/protocol_runner/python_protocol_wrappers.py +++ b/api/src/opentrons/protocol_runner/python_protocol_wrappers.py @@ -15,7 +15,7 @@ from opentrons.protocol_engine import ProtocolEngine from opentrons.protocol_engine.types import ( PrimitiveRunTimeParamValuesType, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, ) from opentrons.protocol_reader import ProtocolSource, ProtocolFileRole from opentrons.util.broker import Broker @@ -153,11 +153,16 @@ class PythonProtocolExecutor: async def execute( protocol: Protocol, context: ProtocolContext, + parameter_context: Optional[ParameterContext], run_time_parameters_with_overrides: Optional[Parameters], ) -> None: """Execute a PAPIv2 protocol with a given ProtocolContext in a child thread.""" await to_thread.run_sync( - run_protocol, protocol, context, run_time_parameters_with_overrides + run_protocol, + protocol, + context, + parameter_context, + run_time_parameters_with_overrides, ) @staticmethod @@ -165,7 +170,7 @@ def extract_run_parameters( protocol: PythonProtocol, parameter_context: ParameterContext, run_time_param_overrides: Optional[PrimitiveRunTimeParamValuesType], - run_time_param_file_overrides: Optional[CSVRunTimeParamFilesType], + run_time_param_file_overrides: Optional[CSVRuntimeParamPaths], ) -> Optional[Parameters]: """Extract the parameters defined in the protocol, overridden with values for the run.""" return exec_add_parameters( diff --git a/api/src/opentrons/protocol_runner/run_orchestrator.py b/api/src/opentrons/protocol_runner/run_orchestrator.py index fe5cf4483f6..7c748598f21 100644 --- a/api/src/opentrons/protocol_runner/run_orchestrator.py +++ b/api/src/opentrons/protocol_runner/run_orchestrator.py @@ -32,7 +32,7 @@ DeckConfigurationType, RunTimeParameter, PrimitiveRunTimeParamValuesType, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, ) from ..protocol_engine.error_recovery_policy import ErrorRecoveryPolicy @@ -340,7 +340,7 @@ async def load( self, protocol_source: ProtocolSource, run_time_param_values: Optional[PrimitiveRunTimeParamValuesType], - run_time_param_files: Optional[CSVRunTimeParamFilesType], + run_time_param_paths: Optional[CSVRuntimeParamPaths], parse_mode: ParseMode, ) -> None: """Load a json/python protocol.""" @@ -356,7 +356,7 @@ async def load( # doesn't conform to the new rules. python_parse_mode=python_parse_mode, run_time_param_values=run_time_param_values, - run_time_param_files=run_time_param_files, + run_time_param_paths=run_time_param_paths, ) def get_is_okay_to_clear(self) -> bool: diff --git a/api/src/opentrons/protocols/execution/execute.py b/api/src/opentrons/protocols/execution/execute.py index 68bd22470c3..46f429839f5 100644 --- a/api/src/opentrons/protocols/execution/execute.py +++ b/api/src/opentrons/protocols/execution/execute.py @@ -1,7 +1,7 @@ import logging from typing import Optional -from opentrons.protocol_api import ProtocolContext +from opentrons.protocol_api import ProtocolContext, ParameterContext from opentrons.protocol_api._parameters import Parameters from opentrons.protocols.execution.execute_python import exec_run from opentrons.protocols.execution.json_dispatchers import ( @@ -21,25 +21,36 @@ def run_protocol( protocol: Protocol, context: ProtocolContext, + parameter_context: Optional[ParameterContext] = None, run_time_parameters_with_overrides: Optional[Parameters] = None, ) -> None: """Run a protocol. :param protocol: The :py:class:`.protocols.types.Protocol` to execute :param context: The protocol context to use. + :param parameter_context: The parameter context to use if running with runtime parameters. :param run_time_parameters_with_overrides: Run time parameters defined in the protocol, updated with the run's RTP override values. When we are running either simulate or execute, this will be None (until RTP is supported in cli commands) """ if isinstance(protocol, PythonProtocol): - if protocol.api_level >= APIVersion(2, 0): - exec_run( - proto=protocol, - context=context, - run_time_parameters_with_overrides=run_time_parameters_with_overrides, - ) - else: - raise RuntimeError(f"Unsupported python API version: {protocol.api_level}") + try: + if protocol.api_level >= APIVersion(2, 0): + exec_run( + proto=protocol, + context=context, + run_time_parameters_with_overrides=run_time_parameters_with_overrides, + ) + else: + raise RuntimeError( + f"Unsupported python API version: {protocol.api_level}" + ) + except Exception: + raise + finally: + # TODO(jbl 2024-08-02) this should be more tightly bound to the opening of the csv files + if parameter_context is not None: + parameter_context.close_csv_files() else: if protocol.contents["schemaVersion"] == 3: ins = execute_json_v3.load_pipettes_from_json(context, protocol.contents) diff --git a/api/src/opentrons/protocols/execution/execute_python.py b/api/src/opentrons/protocols/execution/execute_python.py index 59c9db943df..5d7793144d3 100644 --- a/api/src/opentrons/protocols/execution/execute_python.py +++ b/api/src/opentrons/protocols/execution/execute_python.py @@ -12,7 +12,7 @@ from opentrons.protocols.types import PythonProtocol, MalformedPythonProtocolError from opentrons.protocol_engine.types import ( PrimitiveRunTimeParamValuesType, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, ) @@ -71,7 +71,7 @@ def _raise_pretty_protocol_error(exception: Exception, filename: str) -> None: def _parse_and_set_parameters( parameter_context: ParameterContext, run_time_param_overrides: Optional[PrimitiveRunTimeParamValuesType], - run_time_param_file_overrides: Optional[CSVRunTimeParamFilesType], + run_time_param_file_overrides: Optional[CSVRuntimeParamPaths], new_globs: Dict[Any, Any], filename: str, ) -> Parameters: @@ -111,7 +111,7 @@ def exec_add_parameters( protocol: PythonProtocol, parameter_context: ParameterContext, run_time_param_overrides: Optional[PrimitiveRunTimeParamValuesType], - run_time_param_file_overrides: Optional[CSVRunTimeParamFilesType], + run_time_param_file_overrides: Optional[CSVRuntimeParamPaths], ) -> Optional[Parameters]: """Exec the add_parameters function and get the final run time parameters with overrides.""" new_globs: Dict[Any, Any] = {} diff --git a/api/src/opentrons/protocols/parameters/types.py b/api/src/opentrons/protocols/parameters/types.py index 46b47a04282..a4b4e30e5c6 100644 --- a/api/src/opentrons/protocols/parameters/types.py +++ b/api/src/opentrons/protocols/parameters/types.py @@ -4,6 +4,7 @@ from .exceptions import RuntimeParameterRequired, ParameterValueError +# TODO(jbl 2024-08-02) This is a public facing class and as such should be moved to the protocol_api folder class CSVParameter: def __init__(self, csv_file: Optional[TextIO]) -> None: self._file = csv_file diff --git a/api/tests/opentrons/protocol_runner/test_protocol_runner.py b/api/tests/opentrons/protocol_runner/test_protocol_runner.py index e975e90fa73..14307411d53 100644 --- a/api/tests/opentrons/protocol_runner/test_protocol_runner.py +++ b/api/tests/opentrons/protocol_runner/test_protocol_runner.py @@ -645,7 +645,7 @@ async def test_load_legacy_python( legacy_protocol_source, python_parse_mode=PythonParseMode.ALLOW_LEGACY_METADATA_AND_REQUIREMENTS, run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, ) run_func_captor = matchers.Captor() @@ -668,6 +668,7 @@ async def test_load_legacy_python( await python_protocol_executor.execute( protocol=legacy_protocol, context=protocol_context, + parameter_context=python_runner_subject._parameter_context, run_time_parameters_with_overrides=None, ), ) @@ -727,7 +728,7 @@ async def test_load_python_with_pe_papi_core( protocol_source, python_parse_mode=PythonParseMode.ALLOW_LEGACY_METADATA_AND_REQUIREMENTS, run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, ) decoy.verify(protocol_engine.add_plugin(matchers.IsA(LegacyContextPlugin)), times=0) @@ -790,7 +791,7 @@ async def test_load_legacy_json( legacy_protocol_source, python_parse_mode=PythonParseMode.ALLOW_LEGACY_METADATA_AND_REQUIREMENTS, run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, ) run_func_captor = matchers.Captor() @@ -811,6 +812,7 @@ async def test_load_legacy_json( await python_protocol_executor.execute( protocol=legacy_protocol, context=protocol_context, + parameter_context=None, run_time_parameters_with_overrides=None, ), ) diff --git a/api/tests/opentrons/protocol_runner/test_run_orchestrator.py b/api/tests/opentrons/protocol_runner/test_run_orchestrator.py index 2c5e243c3ec..6e1c04949f8 100644 --- a/api/tests/opentrons/protocol_runner/test_run_orchestrator.py +++ b/api/tests/opentrons/protocol_runner/test_run_orchestrator.py @@ -337,7 +337,7 @@ async def test_load_json( await json_protocol_subject.load( protocol_source=protocol_source, run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, parse_mode=ParseMode.NORMAL, ) @@ -364,7 +364,7 @@ async def test_load_python( protocol_source=protocol_source, parse_mode=ParseMode.NORMAL, run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, ) decoy.verify( @@ -372,7 +372,7 @@ async def test_load_python( protocol_source=protocol_source, python_parse_mode=PythonParseMode.NORMAL, run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, ) ) @@ -396,7 +396,7 @@ async def test_load_json_raises_no_protocol( await live_protocol_subject.load( protocol_source=protocol_source, run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, parse_mode=ParseMode.NORMAL, ) diff --git a/robot-server/robot_server/protocols/analyses_manager.py b/robot-server/robot_server/protocols/analyses_manager.py index 4485dce3b60..3a41568371f 100644 --- a/robot-server/robot_server/protocols/analyses_manager.py +++ b/robot-server/robot_server/protocols/analyses_manager.py @@ -5,7 +5,7 @@ from opentrons.protocol_engine.types import ( PrimitiveRunTimeParamValuesType, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, ) from opentrons.protocol_engine.errors import ErrorOccurrence @@ -40,7 +40,7 @@ async def initialize_analyzer( analysis_id: str, protocol_resource: ProtocolResource, run_time_param_values: Optional[PrimitiveRunTimeParamValuesType], - run_time_param_files: Optional[CSVRunTimeParamFilesType], + run_time_param_paths: Optional[CSVRuntimeParamPaths], ) -> protocol_analyzer.ProtocolAnalyzer: """Initialize the protocol analyzer with protocol resource and run time parameter values & fileIds. @@ -61,7 +61,7 @@ async def initialize_analyzer( try: await analyzer.load_orchestrator( run_time_param_values=run_time_param_values, - run_time_param_files=run_time_param_files, + run_time_param_paths=run_time_param_paths, ) except Exception as error: internal_error = em.map_unexpected_error(error) diff --git a/robot-server/robot_server/protocols/protocol_analyzer.py b/robot-server/robot_server/protocols/protocol_analyzer.py index 5ab628e42b0..65cb2adf7c8 100644 --- a/robot-server/robot_server/protocols/protocol_analyzer.py +++ b/robot-server/robot_server/protocols/protocol_analyzer.py @@ -10,7 +10,7 @@ from opentrons.protocol_engine.types import ( PrimitiveRunTimeParamValuesType, RunTimeParameter, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, ) import opentrons.util.helpers as datetime_helper from opentrons.protocol_runner import ( @@ -53,7 +53,7 @@ def get_verified_run_time_parameters(self) -> List[RunTimeParameter]: async def load_orchestrator( self, run_time_param_values: Optional[PrimitiveRunTimeParamValuesType], - run_time_param_files: Optional[CSVRunTimeParamFilesType], + run_time_param_paths: Optional[CSVRuntimeParamPaths], ) -> None: """Load runner with the protocol and run time parameter values. @@ -67,7 +67,7 @@ async def load_orchestrator( protocol_source=self._protocol_resource.source, parse_mode=ParseMode.NORMAL, run_time_param_values=run_time_param_values, - run_time_param_files=run_time_param_files, + run_time_param_paths=run_time_param_paths, ) @TrackingFunctions.track_analysis diff --git a/robot-server/robot_server/protocols/router.py b/robot-server/robot_server/protocols/router.py index 1e50b6a13a9..5380464bae4 100644 --- a/robot-server/robot_server/protocols/router.py +++ b/robot-server/robot_server/protocols/router.py @@ -9,7 +9,7 @@ from opentrons.protocol_engine.types import ( PrimitiveRunTimeParamValuesType, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, ) from opentrons_shared_data.robot import user_facing_robot_type from opentrons.util.performance_helpers import TrackingFunctions @@ -48,6 +48,11 @@ PydanticResponse, RequestModel, ) +from robot_server.data_files.dependencies import ( + get_data_files_directory, + get_data_files_store, +) +from robot_server.data_files.data_files_store import DataFilesStore from robot_server.data_files.models import DataFile from .analyses_manager import AnalysesManager, FailedToInitializeAnalyzer @@ -245,6 +250,8 @@ async def create_protocol( # noqa: C901 quick_transfer_protocol_auto_deleter: ProtocolAutoDeleter = Depends( get_quick_transfer_protocol_auto_deleter ), + data_files_directory: Path = Depends(get_data_files_directory), + data_files_store: DataFilesStore = Depends(get_data_files_store), robot_type: RobotType = Depends(get_robot_type), protocol_id: str = Depends(get_unique_id, use_cache=False), analysis_id: str = Depends(get_unique_id, use_cache=False), @@ -272,6 +279,8 @@ async def create_protocol( # noqa: C901 the new protocol. quick_transfer_protocol_auto_deleter: An interface to delete old quick transfer resources to make room for the new protocol. + data_files_directory: Persistence directory for data files. + data_files_store: Database of data file resources. robot_type: The type of this robot. Protocols meant for other robot types are rejected. protocol_id: Unique identifier to attach to the protocol resource. @@ -314,6 +323,11 @@ async def create_protocol( # noqa: C901 assert file.filename is not None buffered_files = await file_reader_writer.read(files=files) # type: ignore[arg-type] + rtp_paths = { + name: data_files_directory / file_id / data_files_store.get(file_id).name + for name, file_id in parsed_rtp_files.items() + } + content_hash = await file_hasher.hash(buffered_files) cached_protocol_id = protocol_store.get_id_by_hash(content_hash) @@ -330,7 +344,7 @@ async def _get_cached_protocol_analysis() -> PydanticResponse[ analysis_id=analysis_id, force_analyze=False, rtp_values=parsed_rtp_values, - rtp_files=parsed_rtp_files, + rtp_files=rtp_paths, protocol_resource=protocol_store.get( protocol_id=cached_protocol_id ), @@ -409,7 +423,7 @@ async def _get_cached_protocol_analysis() -> PydanticResponse[ analysis_id=analysis_id, force_analyze=True, rtp_values=parsed_rtp_values, - rtp_files=parsed_rtp_files, + rtp_files=rtp_paths, protocol_resource=protocol_resource, analysis_store=analysis_store, analyses_manager=analyses_manager, @@ -440,7 +454,7 @@ async def _start_new_analysis_if_necessary( analysis_id: str, force_analyze: bool, rtp_values: PrimitiveRunTimeParamValuesType, - rtp_files: CSVRunTimeParamFilesType, + rtp_files: CSVRuntimeParamPaths, protocol_resource: ProtocolResource, analysis_store: AnalysisStore, analyses_manager: AnalysesManager, @@ -458,7 +472,7 @@ async def _start_new_analysis_if_necessary( analysis_id=analysis_id, protocol_resource=protocol_resource, run_time_param_values=rtp_values, - run_time_param_files=rtp_files, + run_time_param_paths=rtp_files, ) except FailedToInitializeAnalyzer: analyses.append( @@ -695,6 +709,8 @@ async def create_protocol_analysis( analysis_store: AnalysisStore = Depends(get_analysis_store), analyses_manager: AnalysesManager = Depends(get_analyses_manager), analysis_id: str = Depends(get_unique_id, use_cache=False), + data_files_directory: Path = Depends(get_data_files_directory), + data_files_store: DataFilesStore = Depends(get_data_files_store), ) -> PydanticResponse[SimpleMultiBody[AnalysisSummary]]: """Start a new analysis for the given existing protocol. @@ -713,6 +729,14 @@ async def create_protocol_analysis( raise ProtocolNotFound(detail=f"Protocol {protocolId} not found").as_error( status.HTTP_404_NOT_FOUND ) + + rtp_files = request_body.data.runTimeParameterFiles if request_body else {} + + rtp_paths = { + name: data_files_directory / file_id / data_files_store.get(file_id).name + for name, file_id in rtp_files.items() + } + try: ( analysis_summaries, @@ -722,7 +746,7 @@ async def create_protocol_analysis( analysis_id=analysis_id, force_analyze=request_body.data.forceReAnalyze if request_body else False, rtp_values=request_body.data.runTimeParameterValues if request_body else {}, - rtp_files=request_body.data.runTimeParameterFiles if request_body else {}, + rtp_files=rtp_paths, protocol_resource=protocol_store.get(protocol_id=protocolId), analysis_store=analysis_store, analyses_manager=analyses_manager, diff --git a/robot-server/robot_server/runs/router/base_router.py b/robot-server/robot_server/runs/router/base_router.py index 1ed03b44cd7..9fea6851f33 100644 --- a/robot-server/robot_server/runs/router/base_router.py +++ b/robot-server/robot_server/runs/router/base_router.py @@ -4,6 +4,7 @@ """ import logging from datetime import datetime +from pathlib import Path from textwrap import dedent from typing import Optional, Union, Callable from typing_extensions import Literal @@ -12,7 +13,13 @@ from pydantic import BaseModel, Field from opentrons_shared_data.errors import ErrorCodes +from opentrons.protocol_engine.types import CSVRuntimeParamPaths +from robot_server.data_files.dependencies import ( + get_data_files_directory, + get_data_files_store, +) +from robot_server.data_files.data_files_store import DataFilesStore from robot_server.errors.error_responses import ErrorDetails, ErrorBody from robot_server.protocols.protocol_models import ProtocolKind from robot_server.service.dependencies import get_current_time, get_unique_id @@ -149,6 +156,8 @@ async def create_run( quick_transfer_run_auto_deleter: RunAutoDeleter = Depends( get_quick_transfer_run_auto_deleter ), + data_files_directory: Path = Depends(get_data_files_directory), + data_files_store: DataFilesStore = Depends(get_data_files_store), check_estop: bool = Depends(require_estop_in_good_state), deck_configuration_store: DeckConfigurationStore = Depends( get_deck_configuration_store @@ -166,6 +175,8 @@ async def create_run( run_auto_deleter: An interface to delete old resources to make room for the new run. quick_transfer_run_auto_deleter: An interface to delete old quick-transfer + data_files_directory: Persistence directory for data files. + data_files_store: Database of data file resources. resources to make room for the new run. check_estop: Dependency to verify the estop is in a valid state. deck_configuration_store: Dependency to fetch the deck configuration. @@ -179,6 +190,15 @@ async def create_run( rtp_files = ( request_body.data.runTimeParameterFiles if request_body is not None else None ) + + rtp_paths: Optional[CSVRuntimeParamPaths] = None + # TODO(jbl 2024-08-02) raise the proper error if file ids don't exist + if rtp_files: + rtp_paths = { + name: data_files_directory / file_id / data_files_store.get(file_id).name + for name, file_id in rtp_files.items() + } + protocol_resource = None deck_configuration = await deck_configuration_store.get_deck_configuration() @@ -209,7 +229,7 @@ async def create_run( labware_offsets=offsets, deck_configuration=deck_configuration, run_time_param_values=rtp_values, - run_time_param_files=rtp_files, + run_time_param_paths=rtp_paths, protocol=protocol_resource, notify_publishers=notify_publishers, ) diff --git a/robot-server/robot_server/runs/run_data_manager.py b/robot-server/robot_server/runs/run_data_manager.py index c5cacbb7571..0c9b24228a3 100644 --- a/robot-server/robot_server/runs/run_data_manager.py +++ b/robot-server/robot_server/runs/run_data_manager.py @@ -13,8 +13,8 @@ Command, ) from opentrons.protocol_engine.types import ( - CSVRunTimeParamFilesType, PrimitiveRunTimeParamValuesType, + CSVRuntimeParamPaths, ) from robot_server.protocols.protocol_store import ProtocolResource @@ -159,7 +159,7 @@ async def create( labware_offsets: List[LabwareOffsetCreate], deck_configuration: DeckConfigurationType, run_time_param_values: Optional[PrimitiveRunTimeParamValuesType], - run_time_param_files: Optional[CSVRunTimeParamFilesType], + run_time_param_paths: Optional[CSVRuntimeParamPaths], notify_publishers: Callable[[], None], protocol: Optional[ProtocolResource], ) -> Union[Run, BadRun]: @@ -172,7 +172,7 @@ async def create( deck_configuration: A mapping of fixtures to cutout fixtures the deck will be loaded with. notify_publishers: Utilized by the engine to notify publishers of state changes. run_time_param_values: Any runtime parameter values to set. - run_time_param_files: Any runtime parameter values to set. + run_time_param_paths: Any runtime filepath to set. protocol: The protocol to load the runner with, if any. Returns: @@ -197,7 +197,7 @@ async def create( deck_configuration=deck_configuration, protocol=protocol, run_time_param_values=run_time_param_values, - run_time_param_files=run_time_param_files, + run_time_param_paths=run_time_param_paths, notify_publishers=notify_publishers, ) run_resource = self._run_store.insert( diff --git a/robot-server/robot_server/runs/run_models.py b/robot-server/robot_server/runs/run_models.py index db068870915..1860e86d3e8 100644 --- a/robot-server/robot_server/runs/run_models.py +++ b/robot-server/robot_server/runs/run_models.py @@ -19,9 +19,9 @@ CommandNote, ) from opentrons.protocol_engine.types import ( - CSVRunTimeParamFilesType, RunTimeParameter, PrimitiveRunTimeParamValuesType, + CSVRunTimeParamFilesType, ) from opentrons_shared_data.errors import GeneralError from robot_server.service.json_api import ResourceModel @@ -255,7 +255,7 @@ class RunCreate(BaseModel): ) runTimeParameterFiles: Optional[CSVRunTimeParamFilesType] = Field( None, - description="Key-fileId pairs of CSV run-time parameters defined in a protocol.", + description="Key-fileId pairs of CSV run-time parameters defined in a run.", ) diff --git a/robot-server/robot_server/runs/run_orchestrator_store.py b/robot-server/robot_server/runs/run_orchestrator_store.py index 953c9758cb1..13049d3b780 100644 --- a/robot-server/robot_server/runs/run_orchestrator_store.py +++ b/robot-server/robot_server/runs/run_orchestrator_store.py @@ -5,9 +5,9 @@ from opentrons.protocol_engine.errors.exceptions import EStopActivatedError from opentrons.protocol_engine.types import ( - CSVRunTimeParamFilesType, PostRunHardwareState, RunTimeParameter, + CSVRuntimeParamPaths, ) from opentrons_shared_data.labware.labware_definition import LabwareDefinition @@ -192,7 +192,8 @@ async def create( notify_publishers: Callable[[], None], protocol: Optional[ProtocolResource], run_time_param_values: Optional[PrimitiveRunTimeParamValuesType] = None, - run_time_param_files: Optional[CSVRunTimeParamFilesType] = None, + # TODO(jbl 2024-08-02) combine this with run_time_param_values now that theres no ambiguity with Paths + run_time_param_paths: Optional[CSVRuntimeParamPaths] = None, ) -> StateSummary: """Create and store a ProtocolRunner and ProtocolEngine for a given Run. @@ -203,7 +204,7 @@ async def create( notify_publishers: Utilized by the engine to notify publishers of state changes. protocol: The protocol to load the runner with, if any. run_time_param_values: Any runtime parameter values to set. - run_time_param_files: Any runtime parameter files to set. + run_time_param_paths: Any runtime filepath to set. Returns: The initial equipment and status summary of the engine. @@ -249,7 +250,7 @@ async def create( await self.run_orchestrator.load( protocol.source, run_time_param_values=run_time_param_values, - run_time_param_files=run_time_param_files, + run_time_param_paths=run_time_param_paths, parse_mode=ParseMode.ALLOW_LEGACY_METADATA_AND_REQUIREMENTS, ) else: diff --git a/robot-server/settings_schema.json b/robot-server/settings_schema.json index 43878e4833a..a3f12670125 100644 --- a/robot-server/settings_schema.json +++ b/robot-server/settings_schema.json @@ -6,24 +6,32 @@ "simulator_configuration_file_path": { "title": "Simulator Configuration File Path", "description": "Path to a json file that describes the hardware simulator.", - "env_names": ["ot_robot_server_simulator_configuration_file_path"], + "env_names": [ + "ot_robot_server_simulator_configuration_file_path" + ], "type": "string" }, "notification_server_subscriber_address": { "title": "Notification Server Subscriber Address", "description": "The endpoint to subscribe to notification server topics.", "default": "tcp://localhost:5555", - "env_names": ["ot_robot_server_notification_server_subscriber_address"], + "env_names": [ + "ot_robot_server_notification_server_subscriber_address" + ], "type": "string" }, "persistence_directory": { "title": "Persistence Directory", "description": "A directory for the server to store things persistently across boots. If this directory doesn't already exist, the server will create it. If this is the string `automatically_make_temporary`, the server will use a fresh temporary directory (effectively not persisting anything).\n\nNote that the `opentrons` library is also responsible for persisting certain things, and it has its own configuration.", "default": "automatically_make_temporary", - "env_names": ["ot_robot_server_persistence_directory"], + "env_names": [ + "ot_robot_server_persistence_directory" + ], "anyOf": [ { - "enum": ["automatically_make_temporary"], + "enum": [ + "automatically_make_temporary" + ], "type": "string" }, { @@ -37,7 +45,9 @@ "description": "The maximum number of runs to allow HTTP clients to create before auto-deleting old ones.", "default": 20, "exclusiveMinimum": 0, - "env_names": ["ot_robot_server_maximum_runs"], + "env_names": [ + "ot_robot_server_maximum_runs" + ], "type": "integer" }, "maximum_unused_protocols": { @@ -45,7 +55,9 @@ "description": "The maximum number of \"unused protocols\" to allow before auto-deleting old ones. A protocol is \"unused\" if it isn't used by any run that currently exists.", "default": 5, "exclusiveMinimum": 0, - "env_names": ["ot_robot_server_maximum_unused_protocols"], + "env_names": [ + "ot_robot_server_maximum_unused_protocols" + ], "type": "integer" }, "maximum_quick_transfer_protocols": { @@ -53,9 +65,21 @@ "description": "The maximum number of \"quick transfer protocols\" to allow before auto-deleting old ones.", "default": 20, "exclusiveMinimum": 0, - "env_names": ["ot_robot_server_maximum_quick_transfer_protocols"], + "env_names": [ + "ot_robot_server_maximum_quick_transfer_protocols" + ], + "type": "integer" + }, + "maximum_data_files": { + "title": "Maximum Data Files", + "description": "The maximum number of data files to allow before auto-deleting old ones.", + "default": 50, + "exclusiveMinimum": 0, + "env_names": [ + "ot_robot_server_maximum_data_files" + ], "type": "integer" } }, "additionalProperties": false -} +} \ No newline at end of file diff --git a/robot-server/tests/integration/data_files/sample_plates.csv b/robot-server/tests/integration/data_files/sample_plates.csv new file mode 100644 index 00000000000..09ddcf31c80 --- /dev/null +++ b/robot-server/tests/integration/data_files/sample_plates.csv @@ -0,0 +1,41 @@ +Sample_Plate, Sample_well,InitialVol,InitialConc,TargetConc +sample_plate,A1,5,35.6,1 +sample_plate,B1,5,31.5,1 +sample_plate,C1,5,33.7,1 +sample_plate,D1,5,28.9,1 +sample_plate,E1,5,28.9,1 +sample_plate,F1,5,26.5,1 +sample_plate,G1,5,26.2,1 +sample_plate,H1,5,18.9,1 +sample_plate,A2,5,12.5,1 +sample_plate,B2,5,18.4,1 +sample_plate,C2,5,13,1 +sample_plate,D2,5,14.8,1 +sample_plate,E2,5,13.3,1 +sample_plate,F2,5,12.8,1 +sample_plate,G2,5,15.2,1 +sample_plate,H2,5,8.89,1 +sample_plate,A3,5,14,1 +sample_plate,B3,5,19.5,1 +sample_plate,C3,5,18.9,1 +sample_plate,D3,5,21,1 +sample_plate,E3,5,23.8,1 +sample_plate,F3,5,12.9,1 +sample_plate,G3,5,16.7,1 +sample_plate,H3,5,20,1 +sample_plate,A4,10,2.88,1 +sample_plate,B4,10,2.36,1 +sample_plate,C4,10,2.04,1 +sample_plate,D4,10,2.57,1 +sample_plate,E4,10,2.47,1 +sample_plate,F4,10,2.09,1 +sample_plate,G4,10,2.47,1 +sample_plate,H4,10,3.18,1 +sample_plate,A5,10,3.2,1 +sample_plate,B5,10,4.12,1 +sample_plate,C5,10,3.18,1 +sample_plate,D5,10,2.6,1 +sample_plate,E5,10,4.47,1 +sample_plate,F5,10,2.99,1 +sample_plate,G5,10,2.97,1 +sample_plate,H5,10,2.93,1 \ No newline at end of file diff --git a/robot-server/tests/integration/http_api/protocols/test_analyses_with_csv_file_parameters.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_analyses_with_csv_file_parameters.tavern.yaml index d915d599ba5..399fc6e445c 100644 --- a/robot-server/tests/integration/http_api/protocols/test_analyses_with_csv_file_parameters.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_analyses_with_csv_file_parameters.tavern.yaml @@ -98,7 +98,7 @@ stages: description: A CSV file that contains wells to use for pipetting file: id: '{csv_file_id}' - name: '' + name: 'sample_record.csv' - name: Wait until analysis is completed max_retries: 5 diff --git a/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml index a616a50cc66..f029e945e20 100644 --- a/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml @@ -17,6 +17,23 @@ stages: json: protocol_id: data.id + - name: Upload sample_plates.csv file + request: + url: '{ot2_server_base_url}/dataFiles' + method: POST + files: + file: 'tests/integration/data_files/sample_plates.csv' + response: + save: + json: + data_file_id: data.id + status_code: 201 + json: + data: + id: !anystr + name: "sample_plates.csv" + createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" + - name: Create run from protocol request: url: '{ot2_server_base_url}/runs' @@ -29,6 +46,8 @@ stages: volume: 10.23 dry_run: True pipette: flex_8channel_50 + runTimeParameterFiles: + liq_handling_csv_file: '{data_file_id}' response: status_code: 201 save: @@ -92,6 +111,9 @@ stages: variableName: liq_handling_csv_file description: A CSV file that contains wells to use for pipetting type: csv_file + file: + id: '{data_file_id}' + name: sample_plates.csv liquids: [] protocolId: '{protocol_id}' diff --git a/robot-server/tests/protocols/test_analyses_manager.py b/robot-server/tests/protocols/test_analyses_manager.py index 46c46a5243f..26514a86983 100644 --- a/robot-server/tests/protocols/test_analyses_manager.py +++ b/robot-server/tests/protocols/test_analyses_manager.py @@ -97,12 +97,12 @@ async def test_initialize_analyzer( analysis_id="analysis-id", protocol_resource=protocol_resource, run_time_param_values={"sample_count": 123}, - run_time_param_files={"my_file": "file-id"}, + run_time_param_paths={"my_file": Path("file-path")}, ) decoy.verify( await analyzer.load_orchestrator( run_time_param_values={"sample_count": 123}, - run_time_param_files={"my_file": "file-id"}, + run_time_param_paths={"my_file": Path("file-path")}, ) ) @@ -145,7 +145,7 @@ async def test_raises_error_and_saves_result_if_initialization_errors( decoy.when( await analyzer.load_orchestrator( run_time_param_values={"sample_count": 123}, - run_time_param_files={}, + run_time_param_paths={}, ) ).then_raise(raised_exception) decoy.when(analyzer.get_verified_run_time_parameters()).then_return([]) @@ -157,7 +157,7 @@ async def test_raises_error_and_saves_result_if_initialization_errors( analysis_id="analysis-id", protocol_resource=protocol_resource, run_time_param_values={"sample_count": 123}, - run_time_param_files={}, + run_time_param_paths={}, ) decoy.verify( await analysis_store.save_initialization_failed_analysis( diff --git a/robot-server/tests/protocols/test_protocol_analyzer.py b/robot-server/tests/protocols/test_protocol_analyzer.py index a5eb40b95bc..87108ff75f8 100644 --- a/robot-server/tests/protocols/test_protocol_analyzer.py +++ b/robot-server/tests/protocols/test_protocol_analyzer.py @@ -102,7 +102,7 @@ async def test_load_orchestrator( ).then_return(run_orchestrator) await subject.load_orchestrator( run_time_param_values={"rtp_var": 123}, - run_time_param_files={"csv_param": "file-id"}, + run_time_param_paths={"csv_param": Path("file-path")}, ) decoy.verify( @@ -110,7 +110,7 @@ async def test_load_orchestrator( protocol_source=protocol_source, parse_mode=ParseMode.NORMAL, run_time_param_values={"rtp_var": 123}, - run_time_param_files={"csv_param": "file-id"}, + run_time_param_paths={"csv_param": Path("file-path")}, ), times=1, ) @@ -176,7 +176,7 @@ async def test_analyze( analysis_store=analysis_store, protocol_resource=protocol_resource ) await subject.load_orchestrator( - run_time_param_values={"rtp_var": 123}, run_time_param_files={} + run_time_param_values={"rtp_var": 123}, run_time_param_paths={} ) decoy.when(await orchestrator.run(deck_configuration=[],)).then_return( protocol_runner.RunResult( @@ -275,7 +275,7 @@ async def test_analyze_updates_pending_on_error( datetime(year=2023, month=3, day=3) ) await subject.load_orchestrator( - run_time_param_values={"rtp_var": 123}, run_time_param_files={} + run_time_param_values={"rtp_var": 123}, run_time_param_paths={} ) await subject.analyze( analysis_id="analysis-id", diff --git a/robot-server/tests/protocols/test_protocols_router.py b/robot-server/tests/protocols/test_protocols_router.py index e1e9968b232..89c41341941 100644 --- a/robot-server/tests/protocols/test_protocols_router.py +++ b/robot-server/tests/protocols/test_protocols_router.py @@ -12,7 +12,7 @@ PrimitiveRunTimeParamValuesType, NumberParameter, CSVParameter, - CSVRunTimeParamFilesType, + CSVRuntimeParamPaths, FileInfo, ) from opentrons.protocols.api_support.types import APIVersion @@ -30,6 +30,7 @@ BufferedFile, ) +from robot_server.data_files.data_files_store import DataFilesStore, DataFileInfo from robot_server.data_files.models import DataFile from robot_server.errors.error_responses import ApiError from robot_server.protocols.analyses_manager import AnalysesManager @@ -115,6 +116,12 @@ def analyses_manager(decoy: Decoy) -> AnalysesManager: return decoy.mock(cls=AnalysesManager) +@pytest.fixture +def data_files_store(decoy: Decoy) -> DataFilesStore: + """Get a mocked out DataFilesStore.""" + return decoy.mock(cls=DataFilesStore) + + @pytest.fixture def protocol_auto_deleter(decoy: Decoy) -> ProtocolAutoDeleter: """Get a mocked out AutoDeleter.""" @@ -456,7 +463,7 @@ async def test_create_existing_protocol( analysis_id="analysis-id", protocol_resource=stored_protocol_resource, run_time_param_values={}, - run_time_param_files={}, + run_time_param_paths={}, ) ).then_return(analyzer) decoy.when(analyzer.get_verified_run_time_parameters()).then_return([]) @@ -572,7 +579,7 @@ async def test_create_protocol( analysis_id="analysis-id", protocol_resource=protocol_resource, run_time_param_values={}, - run_time_param_files={}, + run_time_param_paths={}, ) ).then_return(analyzer) decoy.when(analyzer.get_verified_run_time_parameters()).then_return([]) @@ -625,6 +632,7 @@ async def test_create_new_protocol_with_run_time_params( decoy: Decoy, protocol_store: ProtocolStore, analysis_store: AnalysisStore, + data_files_store: DataFilesStore, protocol_reader: ProtocolReader, file_reader_writer: FileReaderWriter, file_hasher: FileHasher, @@ -684,6 +692,15 @@ async def test_create_new_protocol_with_run_time_params( ) ).then_return([buffered_file]) + decoy.when(data_files_store.get("file-id")).then_return( + DataFileInfo( + id="123", + name="file.abc", + file_hash="xyz", + created_at=datetime(year=2022, month=2, day=2), + ) + ) + decoy.when(await file_hasher.hash(files=[buffered_file])).then_return("abc123") decoy.when( @@ -702,7 +719,7 @@ async def test_create_new_protocol_with_run_time_params( analysis_id="analysis-id", protocol_resource=protocol_resource, run_time_param_values={"vol": 123, "dry_run": True, "mount": "left"}, - run_time_param_files={"my_csv_file": "file-id"}, + run_time_param_paths={"my_csv_file": Path("/dev/null/file-id/file.abc")}, ) ).then_return(analyzer) decoy.when( @@ -721,6 +738,8 @@ async def test_create_new_protocol_with_run_time_params( protocol_directory=protocol_directory, protocol_store=protocol_store, analysis_store=analysis_store, + data_files_store=data_files_store, + data_files_directory=Path("/dev/null"), file_reader_writer=file_reader_writer, protocol_reader=protocol_reader, file_hasher=file_hasher, @@ -817,7 +836,7 @@ async def test_create_existing_protocol_with_no_previous_analysis( analysis_id="analysis-id", protocol_resource=stored_protocol_resource, run_time_param_values={"vol": 123, "dry_run": True, "mount": "left"}, - run_time_param_files={}, + run_time_param_paths={}, ) ).then_return(analyzer) @@ -865,6 +884,7 @@ async def test_create_existing_protocol_with_different_run_time_params( decoy: Decoy, protocol_store: ProtocolStore, analysis_store: AnalysisStore, + data_files_store: DataFilesStore, protocol_reader: ProtocolReader, file_reader_writer: FileReaderWriter, file_hasher: FileHasher, @@ -929,6 +949,14 @@ async def test_create_existing_protocol_with_different_run_time_params( files=[protocol_file] # type: ignore[list-item] ) ).then_return([buffered_file]) + decoy.when(data_files_store.get("csv-file-id")).then_return( + DataFileInfo( + id="123", + name="file.abc", + file_hash="xyz", + created_at=datetime(year=2022, month=2, day=2), + ) + ) decoy.when(await file_hasher.hash(files=[buffered_file])).then_return("a_b_c") decoy.when(protocol_store.get_all()).then_return([]) decoy.when(protocol_store.get_id_by_hash("a_b_c")).then_return("the-og-proto-id") @@ -944,7 +972,9 @@ async def test_create_existing_protocol_with_different_run_time_params( analysis_id="analysis-id", protocol_resource=stored_protocol_resource, run_time_param_values={"vol": 123, "dry_run": True, "mount": "left"}, - run_time_param_files={"my_csv_file": "csv-file-id"}, + run_time_param_paths={ + "my_csv_file": Path("/dev/null/csv-file-id/file.abc") + }, ) ).then_return(analyzer) decoy.when(analyzer.get_verified_run_time_parameters()).then_return( @@ -970,6 +1000,8 @@ async def test_create_existing_protocol_with_different_run_time_params( protocol_directory=protocol_directory, protocol_store=protocol_store, analysis_store=analysis_store, + data_files_store=data_files_store, + data_files_directory=Path("/dev/null"), file_reader_writer=file_reader_writer, protocol_reader=protocol_reader, file_hasher=file_hasher, @@ -1075,7 +1107,7 @@ async def test_create_existing_protocol_with_same_run_time_params( analysis_id="analysis-id", protocol_resource=stored_protocol_resource, run_time_param_values={"vol": 123, "dry_run": True, "mount": "left"}, - run_time_param_files={}, + run_time_param_paths={}, ) ).then_return(analyzer) decoy.when(analyzer.get_verified_run_time_parameters()).then_return( @@ -1200,7 +1232,7 @@ async def test_create_existing_protocol_with_pending_analysis_raises( analysis_id="analysis-id", protocol_resource=stored_protocol_resource, run_time_param_values={"vol": 123, "dry_run": True, "mount": "left"}, - run_time_param_files={}, + run_time_param_paths={}, ) ).then_return(analyzer) decoy.when(analyzer.get_verified_run_time_parameters()).then_return( @@ -1618,7 +1650,7 @@ async def test_create_protocol_analyses_with_same_rtp_values( analysis_id="analysis-id-2", protocol_resource=stored_protocol_resource, run_time_param_values=rtp_values, - run_time_param_files={}, + run_time_param_paths={}, ) ).then_return(analyzer) decoy.when(analyzer.get_verified_run_time_parameters()).then_return( @@ -1648,6 +1680,7 @@ async def test_update_protocol_analyses_with_new_rtp_values( decoy: Decoy, protocol_store: ProtocolStore, analysis_store: AnalysisStore, + data_files_store: DataFilesStore, analyses_manager: AnalysesManager, ) -> None: """It should start a new analysis for the new rtp values.""" @@ -1656,8 +1689,8 @@ async def test_update_protocol_analyses_with_new_rtp_values( "dry_run": True, "mount": "left", } - rtp_files: CSVRunTimeParamFilesType = { - "csv_param": "file-id", + rtp_files: CSVRuntimeParamPaths = { + "csv_param": Path("/dev/null/file-id/foo.csv"), } protocol_source = ProtocolSource( directory=Path("/dev/null"), @@ -1701,6 +1734,14 @@ async def test_update_protocol_analyses_with_new_rtp_values( variableName="csv_param", file=FileInfo(id="file-id", name=""), ) + decoy.when(data_files_store.get("file-id")).then_return( + DataFileInfo( + id="123", + name="foo.csv", + file_hash="xyz", + created_at=datetime(year=2022, month=2, day=2), + ) + ) decoy.when(protocol_store.has(protocol_id="protocol-id")).then_return(True) decoy.when(protocol_store.get(protocol_id="protocol-id")).then_return( stored_protocol_resource @@ -1714,7 +1755,7 @@ async def test_update_protocol_analyses_with_new_rtp_values( analysis_id="analysis-id-2", protocol_resource=stored_protocol_resource, run_time_param_values=rtp_values, - run_time_param_files=rtp_files, + run_time_param_paths=rtp_files, ) ).then_return(analyzer) decoy.when(analyzer.get_verified_run_time_parameters()).then_return( @@ -1742,12 +1783,15 @@ async def test_update_protocol_analyses_with_new_rtp_values( protocolId="protocol-id", request_body=RequestModel( data=AnalysisRequest( - runTimeParameterValues=rtp_values, runTimeParameterFiles=rtp_files + runTimeParameterValues=rtp_values, + runTimeParameterFiles={"csv_param": "file-id"}, ) ), protocol_store=protocol_store, analysis_store=analysis_store, analyses_manager=analyses_manager, + data_files_store=data_files_store, + data_files_directory=Path("/dev/null"), analysis_id="analysis-id-2", ) assert result.content.data == [ @@ -1809,7 +1853,7 @@ async def test_update_protocol_analyses_with_forced_reanalysis( analysis_id="analysis-id-2", protocol_resource=stored_protocol_resource, run_time_param_values={}, - run_time_param_files={}, + run_time_param_paths={}, ) ).then_return(analyzer) decoy.when( @@ -1915,7 +1959,7 @@ async def test_create_protocol_kind_quick_transfer( analysis_id="analysis-id", protocol_resource=protocol_resource, run_time_param_values={}, - run_time_param_files={}, + run_time_param_paths={}, ) ).then_return(analyzer) decoy.when( diff --git a/robot-server/tests/runs/router/test_base_router.py b/robot-server/tests/runs/router/test_base_router.py index fd1cdd8b58a..2e9b532ad93 100644 --- a/robot-server/tests/runs/router/test_base_router.py +++ b/robot-server/tests/runs/router/test_base_router.py @@ -8,6 +8,8 @@ from opentrons.protocol_engine import LabwareOffsetCreate, types as pe_types from opentrons.protocol_reader import ProtocolSource, JsonProtocolConfig +from robot_server.data_files.data_files_store import DataFilesStore, DataFileInfo + from robot_server.errors.error_responses import ApiError from robot_server.runs.error_recovery_models import ErrorRecoveryPolicy from robot_server.service.json_api import ( @@ -50,6 +52,12 @@ def mock_notify_publishers() -> None: return None +@pytest.fixture +def mock_data_files_store(decoy: Decoy) -> DataFilesStore: + """Get a mock DataFilesStore.""" + return decoy.mock(cls=DataFilesStore) + + @pytest.fixture def labware_offset_create() -> LabwareOffsetCreate: """Get a labware offset create request value object.""" @@ -66,6 +74,7 @@ async def test_create_run( mock_run_auto_deleter: RunAutoDeleter, labware_offset_create: pe_types.LabwareOffsetCreate, mock_deck_configuration_store: DeckConfigurationStore, + mock_data_files_store: DataFilesStore, ) -> None: """It should be able to create a basic run.""" run_id = "run-id" @@ -97,7 +106,7 @@ async def test_create_run( deck_configuration=[], protocol=None, run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, notify_publishers=mock_notify_publishers, ) ).then_return(expected_response) @@ -107,6 +116,8 @@ async def test_create_run( data=RunCreate(labwareOffsets=[labware_offset_create]) ), run_data_manager=mock_run_data_manager, + data_files_store=mock_data_files_store, + data_files_directory=Path("/dev/null"), run_id=run_id, created_at=run_created_at, run_auto_deleter=mock_run_auto_deleter, @@ -126,6 +137,7 @@ async def test_create_protocol_run( mock_run_data_manager: RunDataManager, mock_run_auto_deleter: RunAutoDeleter, mock_deck_configuration_store: DeckConfigurationStore, + mock_data_files_store: DataFilesStore, ) -> None: """It should be able to create a protocol run.""" run_id = "run-id" @@ -163,6 +175,14 @@ async def test_create_protocol_run( liquids=[], hasEverEnteredErrorRecovery=False, ) + decoy.when(mock_data_files_store.get("file-id")).then_return( + DataFileInfo( + id="123", + name="abc.xyz", + file_hash="987", + created_at=datetime(month=1, day=2, year=2024), + ) + ) decoy.when( await mock_deck_configuration_store.get_deck_configuration() ).then_return([]) @@ -178,7 +198,7 @@ async def test_create_protocol_run( deck_configuration=[], protocol=protocol_resource, run_time_param_values={"foo": "bar"}, - run_time_param_files={"my_file": "file-id"}, + run_time_param_paths={"my-csv-param": Path("/dev/null/file-id/abc.xyz")}, notify_publishers=mock_notify_publishers, ) ).then_return(expected_response) @@ -188,11 +208,13 @@ async def test_create_protocol_run( data=RunCreate( protocolId="protocol-id", runTimeParameterValues={"foo": "bar"}, - runTimeParameterFiles={"my_file": "file-id"}, + runTimeParameterFiles={"my-csv-param": "file-id"}, ) ), protocol_store=mock_protocol_store, run_data_manager=mock_run_data_manager, + data_files_store=mock_data_files_store, + data_files_directory=Path("/dev/null"), run_id=run_id, created_at=run_created_at, run_auto_deleter=mock_run_auto_deleter, @@ -249,7 +271,7 @@ async def test_create_run_conflict( deck_configuration=[], protocol=None, run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, notify_publishers=mock_notify_publishers, ) ).then_raise(RunConflictError("oh no")) diff --git a/robot-server/tests/runs/test_run_data_manager.py b/robot-server/tests/runs/test_run_data_manager.py index a369f7f47b0..309c82747a2 100644 --- a/robot-server/tests/runs/test_run_data_manager.py +++ b/robot-server/tests/runs/test_run_data_manager.py @@ -4,6 +4,8 @@ import pytest from decoy import Decoy, matchers +from pathlib import Path + from opentrons.protocol_engine import ( EngineStatus, StateSummary, @@ -16,8 +18,8 @@ LoadedPipette, LoadedModule, LabwareOffset, + Liquid, ) -from opentrons.protocol_engine import Liquid from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryPolicy from opentrons.protocol_engine.types import BooleanParameter, CSVParameter from opentrons.protocol_runner import RunResult @@ -168,7 +170,7 @@ async def test_create( protocol=None, deck_configuration=[], run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, notify_publishers=mock_notify_publishers, ) ).then_return(engine_state_summary) @@ -183,6 +185,8 @@ async def test_create( ) ).then_return(run_resource) + decoy.when(mock_run_orchestrator_store.get_run_time_parameters()).then_return([]) + result = await subject.create( run_id=run_id, created_at=created_at, @@ -190,7 +194,7 @@ async def test_create( protocol=None, deck_configuration=[], run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, notify_publishers=mock_notify_publishers, ) @@ -244,7 +248,7 @@ async def test_create_with_options( protocol=protocol, deck_configuration=[], run_time_param_values={"foo": "bar"}, - run_time_param_files={"my_file": "file-id"}, + run_time_param_paths={"xyzzy": Path("zork")}, notify_publishers=mock_notify_publishers, ) ).then_return(engine_state_summary) @@ -274,7 +278,7 @@ async def test_create_with_options( protocol=protocol, deck_configuration=[], run_time_param_values={"foo": "bar"}, - run_time_param_files={"my_file": "file-id"}, + run_time_param_paths={"xyzzy": Path("zork")}, notify_publishers=mock_notify_publishers, ) @@ -313,7 +317,7 @@ async def test_create_engine_error( protocol=None, deck_configuration=[], run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, notify_publishers=mock_notify_publishers, ) ).then_raise(RunConflictError("oh no")) @@ -326,7 +330,7 @@ async def test_create_engine_error( protocol=None, deck_configuration=[], run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, notify_publishers=mock_notify_publishers, ) @@ -774,7 +778,7 @@ async def test_create_archives_existing( protocol=None, deck_configuration=[], run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, notify_publishers=mock_notify_publishers, ) ).then_return(engine_state_summary) @@ -794,7 +798,7 @@ async def test_create_archives_existing( protocol=None, deck_configuration=[], run_time_param_values=None, - run_time_param_files=None, + run_time_param_paths=None, notify_publishers=mock_notify_publishers, ) From 1edec95da265ca42750bea42adfc5656b4ad5a50 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Mon, 5 Aug 2024 09:44:59 -0400 Subject: [PATCH 47/49] fix(app): fix module wizard missing copy (#15881) Closes RQA-2895 --- app/src/organisms/ModuleWizardFlows/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/organisms/ModuleWizardFlows/index.tsx b/app/src/organisms/ModuleWizardFlows/index.tsx index f0b542b4069..9cc33d05688 100644 --- a/app/src/organisms/ModuleWizardFlows/index.tsx +++ b/app/src/organisms/ModuleWizardFlows/index.tsx @@ -299,7 +299,9 @@ export const ModuleWizardFlows = ( ) } else if (isExiting) { modalContent = ( - + ) } else if (currentStep.section === SECTIONS.BEFORE_BEGINNING) { modalContent = From d84b3499056ddec2875e6ed5d46abde004c6326e Mon Sep 17 00:00:00 2001 From: koji Date: Mon, 5 Aug 2024 10:11:30 -0400 Subject: [PATCH 48/49] fix(app)switch navlink to useNavigate for tabas in run setup (#15885) * fix(app)switch navlink to useNavigate for tabas in run setup --- .../ProtocolRunRunTimeParameters.tsx | 11 +- app/src/organisms/RunPreview/index.tsx | 5 +- .../Devices/ProtocolRunDetails/index.tsx | 118 ++++++++++-------- 3 files changed, 76 insertions(+), 58 deletions(-) diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx index ab436e5973f..dcec582de1d 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' + import { RUN_ACTION_TYPE_PLAY, RUN_STATUS_STOPPED, @@ -21,8 +22,9 @@ import { Flex, Icon, InfoScreen, - SPACING, LegacyStyledText, + OVERFLOW_AUTO, + SPACING, TYPOGRAPHY, useHoverTooltip, } from '@opentrons/components' @@ -132,7 +134,12 @@ export function ProtocolRunRuntimeParameters({ ) : ( <> - + {t('name')} diff --git a/app/src/organisms/RunPreview/index.tsx b/app/src/organisms/RunPreview/index.tsx index 9ffee161e5f..420d820790e 100644 --- a/app/src/organisms/RunPreview/index.tsx +++ b/app/src/organisms/RunPreview/index.tsx @@ -13,10 +13,11 @@ import { DISPLAY_NONE, Flex, InfoScreen, + LegacyStyledText, + OVERFLOW_SCROLL, POSITION_FIXED, PrimaryButton, SPACING, - LegacyStyledText, TYPOGRAPHY, } from '@opentrons/components' @@ -122,7 +123,7 @@ export const RunPreviewComponent = ( flexDirection={DIRECTION_COLUMN} height="28rem" width="100%" - overflowY="scroll" + overflowY={OVERFLOW_SCROLL} gridGap={SPACING.spacing8} padding={SPACING.spacing16} > diff --git a/app/src/pages/Devices/ProtocolRunDetails/index.tsx b/app/src/pages/Devices/ProtocolRunDetails/index.tsx index 8d21ae21203..2935bc86100 100644 --- a/app/src/pages/Devices/ProtocolRunDetails/index.tsx +++ b/app/src/pages/Devices/ProtocolRunDetails/index.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import isEmpty from 'lodash/isEmpty' import { useTranslation } from 'react-i18next' import { useDispatch } from 'react-redux' -import { NavLink, Navigate, useParams } from 'react-router-dom' +import { NavLink, Navigate, useParams, useNavigate } from 'react-router-dom' import styled, { css } from 'styled-components' import { @@ -11,11 +11,10 @@ import { COLORS, DIRECTION_COLUMN, Flex, + LegacyStyledText, OVERFLOW_SCROLL, POSITION_RELATIVE, - SIZE_6, SPACING, - LegacyStyledText, TYPOGRAPHY, useHoverTooltip, } from '@opentrons/components' @@ -144,7 +143,7 @@ export function ProtocolRunDetails(): JSX.Element | null { robotName={robot.name} > - - - + + + { - const { robotName, runId } = props + const { robotName, runId, protocolRunDetailsTab } = props const { t } = useTranslation('run_details') const currentRunId = useCurrentRunId() + const navigate = useNavigate() const disabled = currentRunId !== runId const tabDisabledReason = `${t('setup')} ${t( 'not_available_for_a_completed_run' )}` + React.useEffect(() => { + if (disabled && protocolRunDetailsTab === 'setup') + navigate(`/devices/${robotName}/protocol-runs/${runId}/run-preview`) + }, [disabled, navigate, robotName, runId]) + return ( - <> - - {currentRunId !== runId ? ( - // redirect to run preview if not current run - - ) : null} - + ) } interface ParametersTabProps { robotName: string runId: string + protocolRunDetailsTab: ProtocolRunDetailsTab } const ParametersTab = (props: ParametersTabProps): JSX.Element | null => { - const { robotName, runId } = props + const { robotName, runId, protocolRunDetailsTab } = props const { t } = useTranslation('run_details') - const disabled = false - const tabDisabledReason = '' + const mostRecentAnalysis = useMostRecentCompletedAnalysis(runId) + const navigate = useNavigate() + const disabled = mostRecentAnalysis == null + + React.useEffect(() => { + if (disabled && protocolRunDetailsTab === 'runtime-parameters') { + navigate(`/devices/${robotName}/protocol-runs/${runId}/run-preview`, { + replace: true, + }) + } + }, [disabled, navigate, robotName, runId]) return ( - <> - - {disabled ? ( - - ) : null} - + ) } interface ModuleControlsTabProps { robotName: string runId: string + protocolRunDetailsTab: ProtocolRunDetailsTab } const ModuleControlsTab = ( props: ModuleControlsTabProps ): JSX.Element | null => { - const { robotName, runId } = props + const { robotName, runId, protocolRunDetailsTab } = props const { t } = useTranslation('run_details') const currentRunId = useCurrentRunId() const moduleRenderInfoForProtocolById = useModuleRenderInfoForProtocolById( runId ) const { isRunStill } = useRunStatuses() + const navigate = useNavigate() const disabled = currentRunId !== runId || !isRunStill const tabDisabledReason = `${t('module_controls')} ${t( @@ -335,22 +349,18 @@ const ModuleControlsTab = ( : 'not_available_for_a_run_in_progress' )}` - return isEmpty(moduleRenderInfoForProtocolById) ? null : ( - <> - - {disabled ? ( - // redirect to run preview if not current run + React.useEffect(() => { + if (disabled && protocolRunDetailsTab === 'module-controls') + navigate(`/devices/${robotName}/protocol-runs/${runId}/run-preview`) + }, [disabled, navigate, robotName, runId]) - - ) : null} - + return isEmpty(moduleRenderInfoForProtocolById) ? null : ( + ) } From f93ac416187b5f14ce1769be6c44afabf9ce5a84 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Mon, 5 Aug 2024 11:21:35 -0400 Subject: [PATCH 49/49] fix(app): fix desktop post-run drop tip wiz crash after tip removal (#15887) Closes RQA-2902 Refactor useTipAttachementStatus to provide only one pipette with tip at a time. --- .../Devices/ProtocolRun/ProtocolRunHeader.tsx | 10 ++-- .../__tests__/ProtocolRunHeader.test.tsx | 20 ++++++-- .../DropTipWizardFlows/TipsAttachedModal.tsx | 11 +++-- .../__tests__/DropTipWizardFlows.test.tsx | 46 +++++++++---------- .../__tests__/TipsAttachedModal.test.tsx | 22 ++++----- .../organisms/DropTipWizardFlows/index.tsx | 20 ++++---- .../RecoveryOptions/ManageTips.tsx | 9 ++-- .../__tests__/ManageTips.test.tsx | 2 +- app/src/pages/RunSummary/index.tsx | 20 ++++---- 9 files changed, 83 insertions(+), 77 deletions(-) diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx index 9f4bef400ee..6a704c96699 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx @@ -205,7 +205,7 @@ export function ProtocolRunHeader({ determineTipStatus, resetTipStatus, setTipStatusResolved, - pipettesWithTip, + aPipetteWithTip, } = useTipAttachmentStatus({ runId, runRecord, @@ -421,7 +421,7 @@ export function ProtocolRunHeader({ ) : null} @@ -496,11 +496,11 @@ export function ProtocolRunHeader({ robotName={robotName} /> ) : null} - {showDTWiz && mostRecentRunId === runId ? ( + {showDTWiz && aPipetteWithTip != null ? ( setTipStatusResolved().then(toggleDTWiz)} /> ) : null} diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx index 157538c9ff8..70b16c61b55 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx @@ -14,7 +14,6 @@ import { RUN_STATUS_SUCCEEDED, RUN_STATUS_BLOCKED_BY_OPEN_DOOR, instrumentsResponseLeftPipetteFixture, - instrumentsResponseRightPipetteFixture, } from '@opentrons/api-client' import { useHost, @@ -88,6 +87,7 @@ import { useNotifyRunQuery, useCurrentRunId } from '../../../../resources/runs' import { useDropTipWizardFlows, useTipAttachmentStatus, + DropTipWizardFlows, } from '../../../DropTipWizardFlows' import { useErrorRecoveryFlows, @@ -340,10 +340,7 @@ describe('ProtocolRunHeader', () => { vi.mocked(useInstrumentsQuery).mockReturnValue({ data: {} } as any) vi.mocked(useHost).mockReturnValue({} as any) vi.mocked(useTipAttachmentStatus).mockReturnValue({ - pipettesWithTip: [ - instrumentsResponseLeftPipetteFixture, - instrumentsResponseRightPipetteFixture, - ], + aPipetteWithTip: instrumentsResponseLeftPipetteFixture, areTipsAttached: true, determineTipStatus: mockDetermineTipStatus, resetTipStatus: vi.fn(), @@ -384,6 +381,9 @@ describe('ProtocolRunHeader', () => { vi.mocked(ProtocolDropTipModal).mockReturnValue(
MOCK_DROP_TIP_MODAL
) + vi.mocked(DropTipWizardFlows).mockReturnValue( +
MOCK_DROP_TIP_WIZARD_FLOWS
+ ) }) afterEach(() => { @@ -1076,4 +1076,14 @@ describe('ProtocolRunHeader', () => { render() screen.getByText('MOCK_ERROR_RECOVERY') }) + + it('renders DropTipWizardFlows when conditions are met', () => { + vi.mocked(useDropTipWizardFlows).mockReturnValue({ + showDTWiz: true, + toggleDTWiz: vi.fn(), + }) + + render() + screen.getByText('MOCK_DROP_TIP_WIZARD_FLOWS') + }) }) diff --git a/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx b/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx index 0cb1872b196..71de14567fa 100644 --- a/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx +++ b/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx @@ -1,6 +1,5 @@ import * as React from 'react' import capitalize from 'lodash/capitalize' -import head from 'lodash/head' import NiceModal, { useModal } from '@ebay/nice-modal-react' import { Trans, useTranslation } from 'react-i18next' @@ -23,7 +22,7 @@ import type { ModalHeaderBaseProps } from '../../molecules/Modal/types' import type { PipetteWithTip } from '.' interface TipsAttachedModalProps { - pipettesWithTip: PipetteWithTip[] + aPipetteWithTip: PipetteWithTip host: HostConfig | null setTipStatusResolved: (onEmpty?: () => void) => Promise } @@ -38,11 +37,11 @@ export const handleTipsAttachedModal = ( const TipsAttachedModal = NiceModal.create( (props: TipsAttachedModalProps): JSX.Element => { - const { pipettesWithTip, host, setTipStatusResolved } = props + const { aPipetteWithTip, host, setTipStatusResolved } = props const { t } = useTranslation(['drop_tip_wizard']) const modal = useModal() - const { mount, specs } = head(pipettesWithTip) as PipetteWithTip + const { mount, specs } = aPipetteWithTip const { showDTWiz, toggleDTWiz } = useDropTipWizardFlows() const tipsAttachedHeader: ModalHeaderBaseProps = { @@ -57,7 +56,9 @@ const TipsAttachedModal = NiceModal.create( } const is96Channel = specs.channels === 96 - const displayMountText = is96Channel ? '96-Channel' : capitalize(mount) + const displayMountText = is96Channel + ? '96-Channel' + : capitalize(mount as string) return ( diff --git a/app/src/organisms/DropTipWizardFlows/__tests__/DropTipWizardFlows.test.tsx b/app/src/organisms/DropTipWizardFlows/__tests__/DropTipWizardFlows.test.tsx index d0763e3e307..bd1cc918ea5 100644 --- a/app/src/organisms/DropTipWizardFlows/__tests__/DropTipWizardFlows.test.tsx +++ b/app/src/organisms/DropTipWizardFlows/__tests__/DropTipWizardFlows.test.tsx @@ -37,6 +37,21 @@ const MOCK_ACTUAL_PIPETTE = { }, } as PipetteModelSpecs +const mockPipetteWithTip: PipetteWithTip = { + mount: 'left', + specs: MOCK_ACTUAL_PIPETTE, +} + +const mockSecondPipetteWithTip: PipetteWithTip = { + mount: 'right', + specs: MOCK_ACTUAL_PIPETTE, +} + +const mockPipettesWithTip: PipetteWithTip[] = [ + mockPipetteWithTip, + mockSecondPipetteWithTip, +] + describe('useTipAttachmentStatus', () => { let mockGetPipettesWithTipAttached: Mock @@ -44,6 +59,7 @@ describe('useTipAttachmentStatus', () => { mockGetPipettesWithTipAttached = vi.mocked(getPipettesWithTipAttached) vi.mocked(getPipetteModelSpecs).mockReturnValue(MOCK_ACTUAL_PIPETTE) vi.mocked(DropTipWizard).mockReturnValue(
MOCK DROP TIP WIZ
) + mockGetPipettesWithTipAttached.mockResolvedValue(mockPipettesWithTip) }) afterEach(() => { @@ -54,16 +70,10 @@ describe('useTipAttachmentStatus', () => { const { result } = renderHook(() => useTipAttachmentStatus({} as any)) expect(result.current.areTipsAttached).toBe(false) - expect(result.current.pipettesWithTip).toEqual([]) + expect(result.current.aPipetteWithTip).toEqual(null) }) it('should determine tip status and update state accordingly', async () => { - const mockPipettesWithTip: PipetteWithTip[] = [ - { mount: 'left', specs: MOCK_ACTUAL_PIPETTE }, - { mount: 'right', specs: MOCK_ACTUAL_PIPETTE }, - ] - mockGetPipettesWithTipAttached.mockResolvedValueOnce(mockPipettesWithTip) - const { result } = renderHook(() => useTipAttachmentStatus({} as any)) await act(async () => { @@ -71,15 +81,10 @@ describe('useTipAttachmentStatus', () => { }) expect(result.current.areTipsAttached).toBe(true) - expect(result.current.pipettesWithTip).toEqual(mockPipettesWithTip) + expect(result.current.aPipetteWithTip).toEqual(mockPipetteWithTip) }) it('should reset tip status', async () => { - const mockPipettesWithTip: PipetteWithTip[] = [ - { mount: 'left', specs: MOCK_ACTUAL_PIPETTE }, - ] - mockGetPipettesWithTipAttached.mockResolvedValueOnce(mockPipettesWithTip) - const { result } = renderHook(() => useTipAttachmentStatus({} as any)) await act(async () => { @@ -88,16 +93,10 @@ describe('useTipAttachmentStatus', () => { }) expect(result.current.areTipsAttached).toBe(false) - expect(result.current.pipettesWithTip).toEqual([]) + expect(result.current.aPipetteWithTip).toEqual(null) }) it('should set tip status resolved and update state', async () => { - const mockPipettesWithTip: PipetteWithTip[] = [ - { mount: 'left', specs: MOCK_ACTUAL_PIPETTE }, - { mount: 'right', specs: MOCK_ACTUAL_PIPETTE }, - ] - mockGetPipettesWithTipAttached.mockResolvedValueOnce(mockPipettesWithTip) - const { result } = renderHook(() => useTipAttachmentStatus({} as any)) await act(async () => { @@ -105,14 +104,11 @@ describe('useTipAttachmentStatus', () => { result.current.setTipStatusResolved() }) - expect(result.current.pipettesWithTip).toEqual([mockPipettesWithTip[1]]) + expect(result.current.aPipetteWithTip).toEqual(mockSecondPipetteWithTip) }) it('should call onEmptyCache callback when cache becomes empty', async () => { - const mockPipettesWithTip: PipetteWithTip[] = [ - { mount: 'left', specs: MOCK_ACTUAL_PIPETTE }, - ] - mockGetPipettesWithTipAttached.mockResolvedValueOnce(mockPipettesWithTip) + mockGetPipettesWithTipAttached.mockResolvedValueOnce([mockPipetteWithTip]) const onEmptyCacheMock = vi.fn() const { result } = renderHook(() => useTipAttachmentStatus({} as any)) diff --git a/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx b/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx index 135ff4e0e6e..edd24d50e10 100644 --- a/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx +++ b/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx @@ -33,24 +33,24 @@ const ninetySixSpecs = { channels: 96, } as PipetteModelSpecs -const MOCK_PIPETTES_WITH_TIP: PipetteWithTip[] = [ - { mount: LEFT, specs: MOCK_ACTUAL_PIPETTE }, -] -const MOCK_96_WITH_TIP: PipetteWithTip[] = [ - { mount: LEFT, specs: ninetySixSpecs }, -] +const MOCK_A_PIPETTE_WITH_TIP: PipetteWithTip = { + mount: LEFT, + specs: MOCK_ACTUAL_PIPETTE, +} + +const MOCK_96_WITH_TIP: PipetteWithTip = { mount: LEFT, specs: ninetySixSpecs } const mockSetTipStatusResolved = vi.fn() const MOCK_HOST: HostConfig = { hostname: 'MOCK_HOST' } -const render = (pipettesWithTips: PipetteWithTip[]) => { +const render = (aPipetteWithTip: PipetteWithTip) => { return renderWithProviders(