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. 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 294f10723ec..41029bc4380 100644 --- a/api-client/src/dataFiles/types.ts +++ b/api-client/src/dataFiles/types.ts @@ -18,7 +18,7 @@ export interface UploadedCsvFileResponse { } export interface UploadedCsvFilesResponse { - data: { - files: CsvFileData[] - } + data: CsvFileData[] } + +export type DownloadedCsvFileResponse = string 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..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[] @@ -146,3 +147,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/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/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/api/release-notes.md b/api/release-notes.md index dbfbfc5bad4..d073629a97c 100644 --- a/api/release-notes.md +++ b/api/release-notes.md @@ -6,15 +6,23 @@ log][]. For a list of currently known issues, please see the [Opentrons issue tr --- -## Opentrons Robot Software Changes in 7.4.0 +## Opentrons Robot Software Changes in 7.5.0 -Welcome to the v7.4.0 release of the Opentrons robot software! +Welcome to the v7.5.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). +### 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/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/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index df4f5e71b55..4f0cf262775 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -761,7 +761,7 @@ async def reset_tip_detectors( @ExecutionManagerProvider.wait_for_running async def _update_position_estimation( - self, axes: Optional[List[Axis]] = None + self, axes: Optional[Sequence[Axis]] = None ) -> None: """ Function to update motor estimation for a set of axes @@ -1141,6 +1141,12 @@ async def gantry_position( z=cur_pos[Axis.by_mount(realmount)], ) + async def update_axis_position_estimations(self, axes: Sequence[Axis]) -> None: + """Update specified axes position estimators from their encoders.""" + await self._update_position_estimation(axes) + await self._cache_current_position() + await self._cache_encoder_position() + async def move_to( self, mount: Union[top_types.Mount, OT3Mount], diff --git a/api/src/opentrons/hardware_control/protocols/__init__.py b/api/src/opentrons/hardware_control/protocols/__init__.py index 41de2b54506..cff17ff1d9a 100644 --- a/api/src/opentrons/hardware_control/protocols/__init__.py +++ b/api/src/opentrons/hardware_control/protocols/__init__.py @@ -1,8 +1,6 @@ """Typing protocols describing a hardware controller.""" from typing_extensions import Protocol, Type -from opentrons.hardware_control.types import Axis - from .module_provider import ModuleProvider from .hardware_manager import HardwareManager from .chassis_accessory_manager import ChassisAccessoryManager @@ -20,6 +18,7 @@ from .gripper_controller import GripperController from .flex_calibratable import FlexCalibratable from .flex_instrument_configurer import FlexInstrumentConfigurer +from .position_estimator import PositionEstimator from .types import ( CalibrationType, @@ -64,6 +63,7 @@ def cache_tip(self, mount: MountArgType, tip_length: float) -> None: class FlexHardwareControlInterface( + PositionEstimator, ModuleProvider, ExecutionControllable, LiquidHandler[CalibrationType, MountArgType, ConfigType], @@ -87,12 +87,6 @@ class FlexHardwareControlInterface( def get_robot_type(self) -> Type[FlexRobotType]: return FlexRobotType - def motor_status_ok(self, axis: Axis) -> bool: - ... - - def encoder_status_ok(self, axis: Axis) -> bool: - ... - def cache_tip(self, mount: MountArgType, tip_length: float) -> None: ... diff --git a/api/src/opentrons/hardware_control/protocols/position_estimator.py b/api/src/opentrons/hardware_control/protocols/position_estimator.py new file mode 100644 index 00000000000..04d551020c3 --- /dev/null +++ b/api/src/opentrons/hardware_control/protocols/position_estimator.py @@ -0,0 +1,43 @@ +from typing import Protocol, Sequence + +from ..types import Axis + + +class PositionEstimator(Protocol): + """Position-control extensions for harwdare with encoders.""" + + async def update_axis_position_estimations(self, axes: Sequence[Axis]) -> None: + """Update the specified axes' position estimators from their encoders. + + This will allow these axes to make a non-home move even if they do not currently have + a position estimation (unless there is no tracked poition from the encoders, as would be + true immediately after boot). + + Axis encoders have less precision than their position estimators. Calling this function will + cause absolute position drift. After this function is called, the axis should be homed before + it is relied upon for accurate motion. + + This function updates only the requested axes. If other axes have bad position estimation, + moves that require those axes or attempts to get the position of those axes will still fail. + """ + ... + + def motor_status_ok(self, axis: Axis) -> bool: + """Return whether an axis' position estimator is healthy. + + The position estimator is healthy if the axis has + 1) been homed + 2) not suffered a loss-of-positioning (from a cancel or stall, for instance) since being homed + + If this function returns false, getting the position of this axis or asking it to move will fail. + """ + ... + + def encoder_status_ok(self, axis: Axis) -> bool: + """Return whether an axis' position encoder tracking is healthy. + + The encoder status is healthy if the axis has been homed since booting up. + + If this function returns false, updating the estimator from the encoder will fail. + """ + ... 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/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_engine/commands/__init__.py b/api/src/opentrons/protocol_engine/commands/__init__.py index 75904ab00a3..d0550fce8c5 100644 --- a/api/src/opentrons/protocol_engine/commands/__init__.py +++ b/api/src/opentrons/protocol_engine/commands/__init__.py @@ -19,6 +19,7 @@ from . import temperature_module from . import thermocycler from . import calibration +from . import unsafe from .hash_command_params import hash_protocol_command_params from .generate_command_schema import generate_command_schema @@ -548,6 +549,8 @@ "thermocycler", # calibration command bundle "calibration", + # unsafe command bundle + "unsafe", # configure pipette volume command bundle "ConfigureForVolume", "ConfigureForVolumeCreate", diff --git a/api/src/opentrons/protocol_engine/commands/command_unions.py b/api/src/opentrons/protocol_engine/commands/command_unions.py index d20b64f363b..eeafb1770b6 100644 --- a/api/src/opentrons/protocol_engine/commands/command_unions.py +++ b/api/src/opentrons/protocol_engine/commands/command_unions.py @@ -22,6 +22,7 @@ from . import thermocycler from . import calibration +from . import unsafe from .set_rail_lights import ( SetRailLights, @@ -387,6 +388,9 @@ calibration.CalibratePipette, calibration.CalibrateModule, calibration.MoveToMaintenancePosition, + unsafe.UnsafeBlowOutInPlace, + unsafe.UnsafeDropTipInPlace, + unsafe.UpdatePositionEstimators, ], Field(discriminator="commandType"), ] @@ -456,6 +460,9 @@ calibration.CalibratePipetteParams, calibration.CalibrateModuleParams, calibration.MoveToMaintenancePositionParams, + unsafe.UnsafeBlowOutInPlaceParams, + unsafe.UnsafeDropTipInPlaceParams, + unsafe.UpdatePositionEstimatorsParams, ] CommandType = Union[ @@ -523,6 +530,9 @@ calibration.CalibratePipetteCommandType, calibration.CalibrateModuleCommandType, calibration.MoveToMaintenancePositionCommandType, + unsafe.UnsafeBlowOutInPlaceCommandType, + unsafe.UnsafeDropTipInPlaceCommandType, + unsafe.UpdatePositionEstimatorsCommandType, ] CommandCreate = Annotated[ @@ -591,6 +601,9 @@ calibration.CalibratePipetteCreate, calibration.CalibrateModuleCreate, calibration.MoveToMaintenancePositionCreate, + unsafe.UnsafeBlowOutInPlaceCreate, + unsafe.UnsafeDropTipInPlaceCreate, + unsafe.UpdatePositionEstimatorsCreate, ], Field(discriminator="commandType"), ] @@ -660,6 +673,9 @@ calibration.CalibratePipetteResult, calibration.CalibrateModuleResult, calibration.MoveToMaintenancePositionResult, + unsafe.UnsafeBlowOutInPlaceResult, + unsafe.UnsafeDropTipInPlaceResult, + unsafe.UpdatePositionEstimatorsResult, ] # todo(mm, 2024-06-12): Ideally, command return types would have specific diff --git a/api/src/opentrons/protocol_engine/commands/pipetting_common.py b/api/src/opentrons/protocol_engine/commands/pipetting_common.py index 898cb6f1850..2be1e6f2d54 100644 --- a/api/src/opentrons/protocol_engine/commands/pipetting_common.py +++ b/api/src/opentrons/protocol_engine/commands/pipetting_common.py @@ -132,9 +132,11 @@ class ErrorLocationInfo(TypedDict): class OverpressureError(ErrorOccurrence): """Returned when sensors detect an overpressure error while moving liquid. - The pipette plunger motion is stopped at the point of the error. The next thing to - move the plunger must be a `home` or `blowout` command; commands like `aspirate` - will return an error. + The pipette plunger motion is stopped at the point of the error. + + The next thing to move the plunger must account for the robot not having a valid + estimate of its position. It should be a `home`, `unsafe/updatePositionEstimators`, + `unsafe/dropTipInPlace`, or `unsafe/blowOutInPlace`. """ isDefined: bool = True 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.""" diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py b/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py new file mode 100644 index 00000000000..2875d38cb8e --- /dev/null +++ b/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py @@ -0,0 +1,45 @@ +"""Commands that will cause inaccuracy or incorrect behavior but are still necessary.""" + +from .unsafe_blow_out_in_place import ( + UnsafeBlowOutInPlaceCommandType, + UnsafeBlowOutInPlaceParams, + UnsafeBlowOutInPlaceResult, + UnsafeBlowOutInPlace, + UnsafeBlowOutInPlaceCreate, +) +from .unsafe_drop_tip_in_place import ( + UnsafeDropTipInPlaceCommandType, + UnsafeDropTipInPlaceParams, + UnsafeDropTipInPlaceResult, + UnsafeDropTipInPlace, + UnsafeDropTipInPlaceCreate, +) + +from .update_position_estimators import ( + UpdatePositionEstimatorsCommandType, + UpdatePositionEstimatorsParams, + UpdatePositionEstimatorsResult, + UpdatePositionEstimators, + UpdatePositionEstimatorsCreate, +) + +__all__ = [ + # Unsafe blow-out-in-place command models + "UnsafeBlowOutInPlaceCommandType", + "UnsafeBlowOutInPlaceParams", + "UnsafeBlowOutInPlaceResult", + "UnsafeBlowOutInPlace", + "UnsafeBlowOutInPlaceCreate", + # Unsafe drop-tip command models + "UnsafeDropTipInPlaceCommandType", + "UnsafeDropTipInPlaceParams", + "UnsafeDropTipInPlaceResult", + "UnsafeDropTipInPlace", + "UnsafeDropTipInPlaceCreate", + # Update position estimate command models + "UpdatePositionEstimatorsCommandType", + "UpdatePositionEstimatorsParams", + "UpdatePositionEstimatorsResult", + "UpdatePositionEstimators", + "UpdatePositionEstimatorsCreate", +] diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_blow_out_in_place.py b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_blow_out_in_place.py new file mode 100644 index 00000000000..cbf17ff1026 --- /dev/null +++ b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_blow_out_in_place.py @@ -0,0 +1,93 @@ +"""Command models to blow out in place while plunger positions are unknown.""" + +from __future__ import annotations +from typing import TYPE_CHECKING, Optional, Type +from typing_extensions import Literal + +from pydantic import BaseModel + +from ..command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData +from ..pipetting_common import PipetteIdMixin, FlowRateMixin +from ...resources import ensure_ot3_hardware +from ...errors.error_occurrence import ErrorOccurrence + +from opentrons.hardware_control import HardwareControlAPI +from opentrons.hardware_control.types import Axis + +if TYPE_CHECKING: + from ...execution import PipettingHandler + from ...state import StateView + + +UnsafeBlowOutInPlaceCommandType = Literal["unsafe/blowOutInPlace"] + + +class UnsafeBlowOutInPlaceParams(PipetteIdMixin, FlowRateMixin): + """Payload required to blow-out in place while position is unknown.""" + + pass + + +class UnsafeBlowOutInPlaceResult(BaseModel): + """Result data from an UnsafeBlowOutInPlace command.""" + + pass + + +class UnsafeBlowOutInPlaceImplementation( + AbstractCommandImpl[ + UnsafeBlowOutInPlaceParams, SuccessData[UnsafeBlowOutInPlaceResult, None] + ] +): + """UnsafeBlowOutInPlace command implementation.""" + + def __init__( + self, + pipetting: PipettingHandler, + state_view: StateView, + hardware_api: HardwareControlAPI, + **kwargs: object, + ) -> None: + self._pipetting = pipetting + self._state_view = state_view + self._hardware_api = hardware_api + + async def execute( + self, params: UnsafeBlowOutInPlaceParams + ) -> SuccessData[UnsafeBlowOutInPlaceResult, None]: + """Blow-out without moving the pipette even when position is unknown.""" + ot3_hardware_api = ensure_ot3_hardware(self._hardware_api) + pipette_location = self._state_view.motion.get_pipette_location( + params.pipetteId + ) + await ot3_hardware_api.update_axis_position_estimations( + [Axis.of_main_tool_actuator(pipette_location.mount.to_hw_mount())] + ) + await self._pipetting.blow_out_in_place( + pipette_id=params.pipetteId, flow_rate=params.flowRate + ) + + return SuccessData(public=UnsafeBlowOutInPlaceResult(), private=None) + + +class UnsafeBlowOutInPlace( + BaseCommand[UnsafeBlowOutInPlaceParams, UnsafeBlowOutInPlaceResult, ErrorOccurrence] +): + """UnsafeBlowOutInPlace command model.""" + + commandType: UnsafeBlowOutInPlaceCommandType = "unsafe/blowOutInPlace" + params: UnsafeBlowOutInPlaceParams + result: Optional[UnsafeBlowOutInPlaceResult] + + _ImplementationCls: Type[ + UnsafeBlowOutInPlaceImplementation + ] = UnsafeBlowOutInPlaceImplementation + + +class UnsafeBlowOutInPlaceCreate(BaseCommandCreate[UnsafeBlowOutInPlaceParams]): + """UnsafeBlowOutInPlace command request model.""" + + commandType: UnsafeBlowOutInPlaceCommandType = "unsafe/blowOutInPlace" + params: UnsafeBlowOutInPlaceParams + + _CommandCls: Type[UnsafeBlowOutInPlace] = UnsafeBlowOutInPlace diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py new file mode 100644 index 00000000000..2cb3fa78dd8 --- /dev/null +++ b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py @@ -0,0 +1,98 @@ +"""Command models to drop tip in place while plunger positions are unknown.""" +from __future__ import annotations +from pydantic import Field, BaseModel +from typing import TYPE_CHECKING, Optional, Type +from typing_extensions import Literal + +from opentrons.hardware_control import HardwareControlAPI +from opentrons.hardware_control.types import Axis + +from ..pipetting_common import PipetteIdMixin +from ..command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData +from ...errors.error_occurrence import ErrorOccurrence +from ...resources import ensure_ot3_hardware + +if TYPE_CHECKING: + from ...execution import TipHandler + from ...state import StateView + + +UnsafeDropTipInPlaceCommandType = Literal["unsafe/dropTipInPlace"] + + +class UnsafeDropTipInPlaceParams(PipetteIdMixin): + """Payload required to drop a tip in place even if the plunger position is not known.""" + + homeAfter: Optional[bool] = Field( + None, + description=( + "Whether to home this pipette's plunger after dropping the tip." + " You should normally leave this unspecified to let the robot choose" + " a safe default depending on its hardware." + ), + ) + + +class UnsafeDropTipInPlaceResult(BaseModel): + """Result data from the execution of an UnsafeDropTipInPlace command.""" + + pass + + +class UnsafeDropTipInPlaceImplementation( + AbstractCommandImpl[ + UnsafeDropTipInPlaceParams, SuccessData[UnsafeDropTipInPlaceResult, None] + ] +): + """Unsafe drop tip in place command implementation.""" + + def __init__( + self, + tip_handler: TipHandler, + state_view: StateView, + hardware_api: HardwareControlAPI, + **kwargs: object, + ) -> None: + self._state_view = state_view + self._tip_handler = tip_handler + self._hardware_api = hardware_api + + async def execute( + self, params: UnsafeDropTipInPlaceParams + ) -> SuccessData[UnsafeDropTipInPlaceResult, None]: + """Drop a tip using the requested pipette, even if the plunger position is not known.""" + ot3_hardware_api = ensure_ot3_hardware(self._hardware_api) + pipette_location = self._state_view.motion.get_pipette_location( + params.pipetteId + ) + await ot3_hardware_api.update_axis_position_estimations( + [Axis.of_main_tool_actuator(pipette_location.mount.to_hw_mount())] + ) + await self._tip_handler.drop_tip( + pipette_id=params.pipetteId, home_after=params.homeAfter + ) + + return SuccessData(public=UnsafeDropTipInPlaceResult(), private=None) + + +class UnsafeDropTipInPlace( + BaseCommand[UnsafeDropTipInPlaceParams, UnsafeDropTipInPlaceResult, ErrorOccurrence] +): + """Drop tip in place command model.""" + + commandType: UnsafeDropTipInPlaceCommandType = "unsafe/dropTipInPlace" + params: UnsafeDropTipInPlaceParams + result: Optional[UnsafeDropTipInPlaceResult] + + _ImplementationCls: Type[ + UnsafeDropTipInPlaceImplementation + ] = UnsafeDropTipInPlaceImplementation + + +class UnsafeDropTipInPlaceCreate(BaseCommandCreate[UnsafeDropTipInPlaceParams]): + """Drop tip in place command creation request model.""" + + commandType: UnsafeDropTipInPlaceCommandType = "unsafe/dropTipInPlace" + params: UnsafeDropTipInPlaceParams + + _CommandCls: Type[UnsafeDropTipInPlace] = UnsafeDropTipInPlace diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/update_position_estimators.py b/api/src/opentrons/protocol_engine/commands/unsafe/update_position_estimators.py new file mode 100644 index 00000000000..96be2eb8551 --- /dev/null +++ b/api/src/opentrons/protocol_engine/commands/unsafe/update_position_estimators.py @@ -0,0 +1,87 @@ +"""Update position estimators payload, result, and implementaiton.""" + +from __future__ import annotations +from pydantic import BaseModel, Field +from typing import TYPE_CHECKING, Optional, List, Type +from typing_extensions import Literal + +from ...types import MotorAxis +from ..command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData +from ...errors.error_occurrence import ErrorOccurrence +from ...resources import ensure_ot3_hardware + +from opentrons.hardware_control import HardwareControlAPI + +if TYPE_CHECKING: + from ...execution import GantryMover + + +UpdatePositionEstimatorsCommandType = Literal["unsafe/updatePositionEstimators"] + + +class UpdatePositionEstimatorsParams(BaseModel): + """Payload required for an UpdatePositionEstimators command.""" + + axes: List[MotorAxis] = Field( + ..., description="The axes for which to update the position estimators." + ) + + +class UpdatePositionEstimatorsResult(BaseModel): + """Result data from the execution of an UpdatePositionEstimators command.""" + + +class UpdatePositionEstimatorsImplementation( + AbstractCommandImpl[ + UpdatePositionEstimatorsParams, + SuccessData[UpdatePositionEstimatorsResult, None], + ] +): + """Update position estimators command implementation.""" + + def __init__( + self, + hardware_api: HardwareControlAPI, + gantry_mover: GantryMover, + **kwargs: object, + ) -> None: + self._hardware_api = hardware_api + self._gantry_mover = gantry_mover + + async def execute( + self, params: UpdatePositionEstimatorsParams + ) -> SuccessData[UpdatePositionEstimatorsResult, None]: + """Update axis position estimators from their encoders.""" + ot3_hardware_api = ensure_ot3_hardware(self._hardware_api) + await ot3_hardware_api.update_axis_position_estimations( + [ + self._gantry_mover.motor_axis_to_hardware_axis(axis) + for axis in params.axes + ] + ) + return SuccessData(public=UpdatePositionEstimatorsResult(), private=None) + + +class UpdatePositionEstimators( + BaseCommand[ + UpdatePositionEstimatorsParams, UpdatePositionEstimatorsResult, ErrorOccurrence + ] +): + """UpdatePositionEstimators command model.""" + + commandType: UpdatePositionEstimatorsCommandType = "unsafe/updatePositionEstimators" + params: UpdatePositionEstimatorsParams + result: Optional[UpdatePositionEstimatorsResult] + + _ImplementationCls: Type[ + UpdatePositionEstimatorsImplementation + ] = UpdatePositionEstimatorsImplementation + + +class UpdatePositionEstimatorsCreate(BaseCommandCreate[UpdatePositionEstimatorsParams]): + """UpdatePositionEstimators command request model.""" + + commandType: UpdatePositionEstimatorsCommandType = "unsafe/updatePositionEstimators" + params: UpdatePositionEstimatorsParams + + _CommandCls: Type[UpdatePositionEstimators] = UpdatePositionEstimators diff --git a/api/src/opentrons/protocol_engine/execution/gantry_mover.py b/api/src/opentrons/protocol_engine/execution/gantry_mover.py index 7e05c8db247..26ab20f69de 100644 --- a/api/src/opentrons/protocol_engine/execution/gantry_mover.py +++ b/api/src/opentrons/protocol_engine/execution/gantry_mover.py @@ -81,6 +81,10 @@ async def prepare_for_mount_movement(self, mount: Mount) -> None: """Retract the 'idle' mount if necessary.""" ... + def motor_axis_to_hardware_axis(self, motor_axis: MotorAxis) -> HardwareAxis: + """Transform an engine motor axis into a hardware axis.""" + ... + class HardwareGantryMover(GantryMover): """Hardware API based gantry movement handler.""" @@ -89,6 +93,10 @@ def __init__(self, hardware_api: HardwareControlAPI, state_view: StateView) -> N self._hardware_api = hardware_api self._state_view = state_view + def motor_axis_to_hardware_axis(self, motor_axis: MotorAxis) -> HardwareAxis: + """Transform an engine motor axis into a hardware axis.""" + return _MOTOR_AXIS_TO_HARDWARE_AXIS[motor_axis] + async def get_position( self, pipette_id: str, @@ -227,6 +235,10 @@ class VirtualGantryMover(GantryMover): def __init__(self, state_view: StateView) -> None: self._state_view = state_view + def motor_axis_to_hardware_axis(self, motor_axis: MotorAxis) -> HardwareAxis: + """Transform an engine motor axis into a hardware axis.""" + return _MOTOR_AXIS_TO_HARDWARE_AXIS[motor_axis] + async def get_position( self, pipette_id: str, 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/pipettes.py b/api/src/opentrons/protocol_engine/state/pipettes.py index e1dd00bbfb2..60720c917ec 100644 --- a/api/src/opentrons/protocol_engine/state/pipettes.py +++ b/api/src/opentrons/protocol_engine/state/pipettes.py @@ -53,6 +53,7 @@ RetractAxisResult, BlowOutResult, BlowOutInPlaceResult, + unsafe, TouchTipResult, thermocycler, heater_shaker, @@ -278,7 +279,10 @@ def _handle_command( # noqa: C901 default_dispense=tip_configuration.default_dispense_flowrate.values_by_api_level, ) - elif isinstance(command.result, (DropTipResult, DropTipInPlaceResult)): + elif isinstance( + command.result, + (DropTipResult, DropTipInPlaceResult, unsafe.UnsafeDropTipInPlaceResult), + ): pipette_id = command.params.pipetteId self._state.aspirated_volume_by_id[pipette_id] = None self._state.attached_tip_by_id[pipette_id] = None @@ -483,7 +487,8 @@ def _update_volumes( self._state.aspirated_volume_by_id[pipette_id] = next_volume elif isinstance(action, SucceedCommandAction) and isinstance( - action.command.result, (BlowOutResult, BlowOutInPlaceResult) + action.command.result, + (BlowOutResult, BlowOutInPlaceResult, unsafe.UnsafeBlowOutInPlaceResult), ): pipette_id = action.command.params.pipetteId self._state.aspirated_volume_by_id[pipette_id] = None 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/src/opentrons/protocol_engine/state/tips.py b/api/src/opentrons/protocol_engine/state/tips.py index 85d437888fb..9911b1f85b3 100644 --- a/api/src/opentrons/protocol_engine/state/tips.py +++ b/api/src/opentrons/protocol_engine/state/tips.py @@ -17,6 +17,7 @@ PickUpTipResult, DropTipResult, DropTipInPlaceResult, + unsafe, ) from ..commands.configuring_common import ( PipetteConfigUpdateResultMixin, @@ -126,7 +127,10 @@ def _handle_succeeded_command(self, command: Command) -> None: ) self._state.length_by_pipette_id[pipette_id] = length - elif isinstance(command.result, (DropTipResult, DropTipInPlaceResult)): + elif isinstance( + command.result, + (DropTipResult, DropTipInPlaceResult, unsafe.UnsafeDropTipInPlaceResult), + ): pipette_id = command.params.pipetteId self._state.length_by_pipette_id.pop(pipette_id, None) 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/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/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_blow_out_in_place.py b/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_blow_out_in_place.py new file mode 100644 index 00000000000..f25d8d06169 --- /dev/null +++ b/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_blow_out_in_place.py @@ -0,0 +1,49 @@ +"""Test blow-out-in-place commands.""" +from decoy import Decoy + +from opentrons.types import MountType +from opentrons.protocol_engine.state import StateView +from opentrons.protocol_engine.commands.unsafe.unsafe_blow_out_in_place import ( + UnsafeBlowOutInPlaceParams, + UnsafeBlowOutInPlaceResult, + UnsafeBlowOutInPlaceImplementation, +) +from opentrons.protocol_engine.commands.command import SuccessData +from opentrons.protocol_engine.execution import ( + PipettingHandler, +) +from opentrons.protocol_engine.state.motion import PipetteLocationData +from opentrons.hardware_control import OT3HardwareControlAPI +from opentrons.hardware_control.types import Axis + + +async def test_blow_out_in_place_implementation( + decoy: Decoy, + state_view: StateView, + ot3_hardware_api: OT3HardwareControlAPI, + pipetting: PipettingHandler, +) -> None: + """Test UnsafeBlowOut command execution.""" + subject = UnsafeBlowOutInPlaceImplementation( + state_view=state_view, + hardware_api=ot3_hardware_api, + pipetting=pipetting, + ) + + data = UnsafeBlowOutInPlaceParams( + pipetteId="pipette-id", + flowRate=1.234, + ) + + decoy.when( + state_view.motion.get_pipette_location(pipette_id="pipette-id") + ).then_return(PipetteLocationData(mount=MountType.LEFT, critical_point=None)) + + result = await subject.execute(data) + + assert result == SuccessData(public=UnsafeBlowOutInPlaceResult(), private=None) + + decoy.verify( + await ot3_hardware_api.update_axis_position_estimations([Axis.P_L]), + await pipetting.blow_out_in_place(pipette_id="pipette-id", flow_rate=1.234), + ) diff --git a/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_drop_tip_in_place.py b/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_drop_tip_in_place.py new file mode 100644 index 00000000000..3659dd2db31 --- /dev/null +++ b/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_drop_tip_in_place.py @@ -0,0 +1,53 @@ +"""Test unsafe drop tip in place commands.""" +import pytest +from decoy import Decoy + +from opentrons.types import MountType +from opentrons.protocol_engine.state import StateView + +from opentrons.protocol_engine.execution import TipHandler + +from opentrons.protocol_engine.commands.command import SuccessData +from opentrons.protocol_engine.commands.unsafe.unsafe_drop_tip_in_place import ( + UnsafeDropTipInPlaceParams, + UnsafeDropTipInPlaceResult, + UnsafeDropTipInPlaceImplementation, +) +from opentrons.protocol_engine.state.motion import PipetteLocationData +from opentrons.hardware_control import OT3HardwareControlAPI +from opentrons.hardware_control.types import Axis + + +@pytest.fixture +def mock_tip_handler(decoy: Decoy) -> TipHandler: + """Get a mock TipHandler.""" + return decoy.mock(cls=TipHandler) + + +async def test_drop_tip_implementation( + decoy: Decoy, + mock_tip_handler: TipHandler, + state_view: StateView, + ot3_hardware_api: OT3HardwareControlAPI, +) -> None: + """A DropTip command should have an execution implementation.""" + subject = UnsafeDropTipInPlaceImplementation( + tip_handler=mock_tip_handler, + state_view=state_view, + hardware_api=ot3_hardware_api, + ) + + params = UnsafeDropTipInPlaceParams(pipetteId="abc", homeAfter=False) + decoy.when(state_view.motion.get_pipette_location(pipette_id="abc")).then_return( + PipetteLocationData(mount=MountType.LEFT, critical_point=None) + ) + + result = await subject.execute(params) + + assert result == SuccessData(public=UnsafeDropTipInPlaceResult(), private=None) + + decoy.verify( + await ot3_hardware_api.update_axis_position_estimations([Axis.P_L]), + await mock_tip_handler.drop_tip(pipette_id="abc", home_after=False), + times=1, + ) diff --git a/api/tests/opentrons/protocol_engine/commands/unsafe/test_update_position_estimators.py b/api/tests/opentrons/protocol_engine/commands/unsafe/test_update_position_estimators.py new file mode 100644 index 00000000000..da7ffe75012 --- /dev/null +++ b/api/tests/opentrons/protocol_engine/commands/unsafe/test_update_position_estimators.py @@ -0,0 +1,54 @@ +"""Test update-position-estimator commands.""" +from decoy import Decoy + +from opentrons.protocol_engine.commands.unsafe.update_position_estimators import ( + UpdatePositionEstimatorsParams, + UpdatePositionEstimatorsResult, + UpdatePositionEstimatorsImplementation, +) +from opentrons.protocol_engine.commands.command import SuccessData +from opentrons.protocol_engine.execution import GantryMover +from opentrons.protocol_engine.types import MotorAxis +from opentrons.hardware_control import OT3HardwareControlAPI +from opentrons.hardware_control.types import Axis + + +async def test_update_position_estimators_implementation( + decoy: Decoy, ot3_hardware_api: OT3HardwareControlAPI, gantry_mover: GantryMover +) -> None: + """Test UnsafeBlowOut command execution.""" + subject = UpdatePositionEstimatorsImplementation( + hardware_api=ot3_hardware_api, gantry_mover=gantry_mover + ) + + data = UpdatePositionEstimatorsParams( + axes=[MotorAxis.LEFT_Z, MotorAxis.LEFT_PLUNGER, MotorAxis.X, MotorAxis.Y] + ) + + decoy.when(gantry_mover.motor_axis_to_hardware_axis(MotorAxis.LEFT_Z)).then_return( + Axis.Z_L + ) + decoy.when( + gantry_mover.motor_axis_to_hardware_axis(MotorAxis.LEFT_PLUNGER) + ).then_return(Axis.P_L) + decoy.when(gantry_mover.motor_axis_to_hardware_axis(MotorAxis.X)).then_return( + Axis.X + ) + decoy.when(gantry_mover.motor_axis_to_hardware_axis(MotorAxis.Y)).then_return( + Axis.Y + ) + decoy.when( + await ot3_hardware_api.update_axis_position_estimations( + [Axis.Z_L, Axis.P_L, Axis.X, Axis.Y] + ) + ).then_return(None) + + result = await subject.execute(data) + + assert result == SuccessData(public=UpdatePositionEstimatorsResult(), private=None) + + decoy.verify( + await ot3_hardware_api.update_axis_position_estimations( + [Axis.Z_L, Axis.P_L, Axis.X, Axis.Y] + ), + ) diff --git a/api/tests/opentrons/protocol_engine/state/command_fixtures.py b/api/tests/opentrons/protocol_engine/state/command_fixtures.py index 66c6a34fe9f..845b33f18d8 100644 --- a/api/tests/opentrons/protocol_engine/state/command_fixtures.py +++ b/api/tests/opentrons/protocol_engine/state/command_fixtures.py @@ -603,3 +603,41 @@ def create_reload_labware_command( params=params, result=result, ) + + +def create_unsafe_blow_out_in_place_command( + pipette_id: str, + flow_rate: float, +) -> cmd.unsafe.UnsafeBlowOutInPlace: + """Create a completed UnsafeBlowOutInPlace command.""" + params = cmd.unsafe.UnsafeBlowOutInPlaceParams( + pipetteId=pipette_id, flowRate=flow_rate + ) + result = cmd.unsafe.UnsafeBlowOutInPlaceResult() + + return cmd.unsafe.UnsafeBlowOutInPlace( + id="command-id", + key="command-key", + status=cmd.CommandStatus.SUCCEEDED, + createdAt=datetime.now(), + params=params, + result=result, + ) + + +def create_unsafe_drop_tip_in_place_command( + pipette_id: str, +) -> cmd.unsafe.UnsafeDropTipInPlace: + """Get a completed UnsafeDropTipInPlace command.""" + params = cmd.unsafe.UnsafeDropTipInPlaceParams(pipetteId=pipette_id) + + result = cmd.unsafe.UnsafeDropTipInPlaceResult() + + return cmd.unsafe.UnsafeDropTipInPlace( + id="command-id", + key="command-key", + status=cmd.CommandStatus.SUCCEEDED, + createdAt=datetime.now(), + params=params, + result=result, + ) 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/api/tests/opentrons/protocol_engine/state/test_pipette_store.py b/api/tests/opentrons/protocol_engine/state/test_pipette_store.py index 6e7428719ec..c8d60395b3b 100644 --- a/api/tests/opentrons/protocol_engine/state/test_pipette_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_pipette_store.py @@ -50,6 +50,7 @@ create_pick_up_tip_command, create_drop_tip_command, create_drop_tip_in_place_command, + create_unsafe_drop_tip_in_place_command, create_touch_tip_command, create_move_to_well_command, create_blow_out_command, @@ -58,6 +59,7 @@ create_move_to_coordinates_command, create_move_relative_command, create_prepare_to_aspirate_command, + create_unsafe_blow_out_in_place_command, ) from ..pipette_fixtures import get_default_nozzle_map @@ -176,6 +178,42 @@ def test_handles_drop_tip_in_place(subject: PipetteStore) -> None: assert subject.state.aspirated_volume_by_id["xyz"] is None +def test_handles_unsafe_drop_tip_in_place(subject: PipetteStore) -> None: + """It should clear tip and volume details after a drop tip in place.""" + load_pipette_command = create_load_pipette_command( + pipette_id="xyz", + pipette_name=PipetteNameType.P300_SINGLE, + mount=MountType.LEFT, + ) + + pick_up_tip_command = create_pick_up_tip_command( + pipette_id="xyz", tip_volume=42, tip_length=101, tip_diameter=8.0 + ) + + unsafe_drop_tip_in_place_command = create_unsafe_drop_tip_in_place_command( + pipette_id="xyz", + ) + + subject.handle_action( + SucceedCommandAction(private_result=None, command=load_pipette_command) + ) + subject.handle_action( + SucceedCommandAction(private_result=None, command=pick_up_tip_command) + ) + assert subject.state.attached_tip_by_id["xyz"] == TipGeometry( + volume=42, length=101, diameter=8.0 + ) + assert subject.state.aspirated_volume_by_id["xyz"] == 0 + + subject.handle_action( + SucceedCommandAction( + private_result=None, command=unsafe_drop_tip_in_place_command + ) + ) + assert subject.state.attached_tip_by_id["xyz"] is None + assert subject.state.aspirated_volume_by_id["xyz"] is None + + @pytest.mark.parametrize( "aspirate_command", [ @@ -261,6 +299,7 @@ def test_dispense_subtracts_volume( [ create_blow_out_command("pipette-id", 1.23), create_blow_out_in_place_command("pipette-id", 1.23), + create_unsafe_blow_out_in_place_command("pipette-id", 1.23), ], ) def test_blow_out_clears_volume( diff --git a/api/tests/opentrons/protocol_engine/state/test_tip_state.py b/api/tests/opentrons/protocol_engine/state/test_tip_state.py index b4b9968a82d..da570c940cd 100644 --- a/api/tests/opentrons/protocol_engine/state/test_tip_state.py +++ b/api/tests/opentrons/protocol_engine/state/test_tip_state.py @@ -109,6 +109,17 @@ def drop_tip_in_place_command() -> commands.DropTipInPlace: ) +@pytest.fixture +def unsafe_drop_tip_in_place_command() -> commands.unsafe.UnsafeDropTipInPlace: + """Get an unsafe drop-tip-in-place command.""" + return commands.unsafe.UnsafeDropTipInPlace.construct( # type: ignore[call-arg] + params=commands.unsafe.UnsafeDropTipInPlaceParams.construct( + pipetteId="pipette-id" + ), + result=commands.unsafe.UnsafeDropTipInPlaceResult.construct(), + ) + + @pytest.mark.parametrize( "labware_definition", [ @@ -903,6 +914,7 @@ def test_drop_tip( pick_up_tip_command: commands.PickUpTip, drop_tip_command: commands.DropTip, drop_tip_in_place_command: commands.DropTipInPlace, + unsafe_drop_tip_in_place_command: commands.unsafe.UnsafeDropTipInPlace, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, ) -> None: """It should be clear tip length when a tip is dropped.""" @@ -968,6 +980,20 @@ def test_drop_tip( result = TipView(subject.state).get_tip_length("pipette-id") assert result == 0 + subject.handle_action( + actions.SucceedCommandAction(private_result=None, command=pick_up_tip_command) + ) + result = TipView(subject.state).get_tip_length("pipette-id") + assert result == 1.23 + + subject.handle_action( + actions.SucceedCommandAction( + private_result=None, command=unsafe_drop_tip_in_place_command + ) + ) + result = TipView(subject.state).get_tip_length("pipette-id") + assert result == 0 + @pytest.mark.parametrize( argnames=["nozzle_map", "expected_channels"], 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) diff --git a/app-shell/Makefile b/app-shell/Makefile index 13e15e6e439..5daafd82f44 100644 --- a/app-shell/Makefile +++ b/app-shell/Makefile @@ -71,6 +71,15 @@ electron := yarn electron . \ --ui.url.path="localhost:$(PORT)" \ --python.pathToPythonOverride=$(shell cd ../api && pipenv --venv) +electron-dist := yarn electron . \ + --devtools \ + --log.level.console="debug" \ + --disable_ui.webPreferences.webSecurity \ + --ui.url.protocol="file:" \ + --ui.url.path="$(ui_dir)/dist/index.html" + --python.pathToPythonOverride=$(shell cd ../api && pipenv --venv) + + # standard targets ##################################################################### @@ -182,6 +191,13 @@ dev: dev-app-update-file vite build $(electron) +.PHONY: dev-dist +dev: export NODE_ENV := development +dev-dist: export OPENTRONS_PROJECT := $(OPENTRONS_PROJECT) +dev-dist: package-deps + vite build + $(electron-dist) + .PHONY: test test: $(MAKE) -C .. test-js-app-shell 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. 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. diff --git a/app-shell/build/release-notes.md b/app-shell/build/release-notes.md index 60adacf1a4a..ffdf4fad357 100644 --- a/app-shell/build/release-notes.md +++ b/app-shell/build/release-notes.md @@ -6,11 +6,11 @@ log][]. For a list of currently known issues, please see the [Opentrons issue tr --- -## Opentrons App Changes in 7.4.0 +## Opentrons App Changes in 7.5.0 -Welcome to the v7.4.0 release of the Opentrons App! +Welcome to the v7.5.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 [Opentrons Flex HEPA/UV Module](https://opentrons.com/products/opentrons-flex-hepa-uv-module) and the latest Flex Gripper model (serial numbers beginning `GRPV13`). --- 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/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}` + ) + }) + }, + }, ], } diff --git a/app/Makefile b/app/Makefile index 92d0343507a..51305c9b3b0 100644 --- a/app/Makefile +++ b/app/Makefile @@ -74,6 +74,11 @@ dev-server: export OPENTRONS_PROJECT := $(OPENTRONS_PROJECT) dev-server: vite serve +.PHONY: dev-dist +dev-dist: export NODE_ENV := development +dev-dist: + $(MAKE) -C $(shell_dir) dev-dist OPENTRONS_PROJECT=$(OPENTRONS_PROJECT) + .PHONY: dev-shell dev-shell: $(MAKE) -C $(shell_dir) dev OPENTRONS_PROJECT=$(OPENTRONS_PROJECT) diff --git a/app/src/App/DesktopApp.tsx b/app/src/App/DesktopApp.tsx index ab0b91f7c9c..c6eeadc278f 100644 --- a/app/src/App/DesktopApp.tsx +++ b/app/src/App/DesktopApp.tsx @@ -144,7 +144,7 @@ export const DesktopApp = (): JSX.Element => { /> ) })} - } /> + } /> 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/assets/images/change-pip/1_and_8_channel.png b/app/src/assets/images/change-pip/1_and_8_channel.png index 15c15bbacdc..d9091b899ad 100644 Binary files a/app/src/assets/images/change-pip/1_and_8_channel.png and b/app/src/assets/images/change-pip/1_and_8_channel.png differ 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/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/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/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/index.tsx b/app/src/index.tsx index b8fe832abdc..cf4fcbfc44c 100644 --- a/app/src/index.tsx +++ b/app/src/index.tsx @@ -2,7 +2,7 @@ import React from 'react' import ReactDom from 'react-dom/client' import { Provider } from 'react-redux' -import { BrowserRouter } from 'react-router-dom' +import { HashRouter } from 'react-router-dom' import { ApiClientProvider } from '@opentrons/react-api-client' @@ -32,10 +32,10 @@ if (container == null) throw new Error('Failed to find the root element') const root = ReactDom.createRoot(container) root.render( - + - + ) 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: '' } diff --git a/app/src/molecules/InProgressModal/InProgressModal.stories.tsx b/app/src/molecules/InProgressModal/InProgressModal.stories.tsx new file mode 100644 index 00000000000..9fc5f5b30c9 --- /dev/null +++ b/app/src/molecules/InProgressModal/InProgressModal.stories.tsx @@ -0,0 +1,40 @@ +import * as React from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { InProgressModal as InProgressModalComponent } from './' +import { SimpleWizardInProgressBody } from '../SimpleWizardBody' + +const meta: Meta = { + 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/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/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/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/ChooseProtocolSlideout/__tests__/ChooseProtocolSlideout.test.tsx b/app/src/organisms/ChooseProtocolSlideout/__tests__/ChooseProtocolSlideout.test.tsx index af2ce216663..152703e6afa 100644 --- a/app/src/organisms/ChooseProtocolSlideout/__tests__/ChooseProtocolSlideout.test.tsx +++ b/app/src/organisms/ChooseProtocolSlideout/__tests__/ChooseProtocolSlideout.test.tsx @@ -127,6 +127,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/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 ( { - 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 : ( <> - {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/ChooseRobotSlideout/index.tsx b/app/src/organisms/ChooseRobotSlideout/index.tsx index 092c90e0eb9..5e0ae213d79 100644 --- a/app/src/organisms/ChooseRobotSlideout/index.tsx +++ b/app/src/organisms/ChooseRobotSlideout/index.tsx @@ -32,7 +32,6 @@ import { OT2_ROBOT_TYPE, sortRuntimeParameters, } from '@opentrons/shared-data' -import { useFeatureFlag } from '../../redux/config' import { getConnectableRobots, getReachableRobots, @@ -153,7 +152,6 @@ export function ChooseRobotSlideout( resetRunTimeParameters, setHasMissingFileParam, } = props - const enableCsvFile = useFeatureFlag('enableCsvFile') const dispatch = useDispatch() 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 ( @@ -88,7 +88,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 +191,8 @@ describe('ChooseRobotToRunProtocolSlideout', () => { { ...mockConnectableRobot, name: 'otherRobot', ip: 'otherIp' }, mockConnectableRobot, ]) + + provideNullCurrentRunIdFor('otherIp') render({ storedProtocolData: storedProtocolDataFixture, onCloseClick: vi.fn(), @@ -213,6 +215,7 @@ describe('ChooseRobotToRunProtocolSlideout', () => { files: [expect.any(File)], protocolKey: storedProtocolDataFixture.protocolKey, runTimeParameterValues: expect.any(Object), + runTimeParameterFiles: expect.any(Object), }) ) expect(mockTrackCreateProtocolRunEvent).toHaveBeenCalled() @@ -262,6 +265,7 @@ describe('ChooseRobotToRunProtocolSlideout', () => { files: [expect.any(File)], protocolKey: storedProtocolDataFixture.protocolKey, runTimeParameterValues: expect.any(Object), + runTimeParameterFiles: expect.any(Object), }) ) expect(mockTrackCreateProtocolRunEvent).toHaveBeenCalled() @@ -295,6 +299,7 @@ describe('ChooseRobotToRunProtocolSlideout', () => { files: [expect.any(File)], protocolKey: storedProtocolDataFixture.protocolKey, runTimeParameterValues: expect.any(Object), + runTimeParameterFiles: expect.any(Object), }) ) expect(mockTrackCreateProtocolRunEvent).toHaveBeenCalled() @@ -348,6 +353,7 @@ describe('ChooseRobotToRunProtocolSlideout', () => { files: [expect.any(File)], protocolKey: storedProtocolDataFixture.protocolKey, runTimeParameterValues: expect.any(Object), + runTimeParameterFiles: expect.any(Object), }) }) }) @@ -372,6 +378,7 @@ describe('ChooseRobotToRunProtocolSlideout', () => { mockConnectableRobot, { ...mockConnectableRobot, name: 'otherRobot', ip: 'otherIp' }, ]) + provideNullCurrentRunIdFor('otherIp') render({ storedProtocolData: storedProtocolDataFixture, onCloseClick: vi.fn(), @@ -387,7 +394,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 +457,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/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/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, }) }, }, 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/HistoricalProtocolRun.tsx b/app/src/organisms/Devices/HistoricalProtocolRun.tsx index ef3a8434cfc..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' @@ -44,7 +43,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 @@ -55,7 +54,6 @@ export function HistoricalProtocolRun( duration = formatInterval(run.startedAt, new Date().toString()) } } - const enableCsvFile = useFeatureFlag('enableCsvFile') return ( <> @@ -89,17 +87,13 @@ export function HistoricalProtocolRun( > {protocolName} - {enableCsvFile && - allProtocolDataFiles != null && - allProtocolDataFiles.length > 0 ? ( - - {allProtocolDataFiles.length} - - ) : null} + + {allProtocolDataFiles.length} + 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 ( @@ -69,7 +67,6 @@ export function HistoricalProtocolRunDrawer( ? deckCalibrationData.lastModified : null const protocolDetails = useMostRecentCompletedAnalysis(run.id) - const enableCsvFile = useFeatureFlag('enableCsvFile') const isOutOfDate = typeof lastModifiedDeckCal === 'string' && @@ -97,7 +94,7 @@ export function HistoricalProtocolRunDrawer( ) : null const protocolFilesData = - allProtocolDataFiles.length === 0 ? ( + allProtocolDataFiles.length === 1 ? ( ) : ( @@ -137,7 +134,7 @@ export function HistoricalProtocolRunDrawer( {allProtocolDataFiles.map((fileData, index) => { - const { createdAt, name } = fileData + const { createdAt, name: fileName, id: fileId } = fileData return ( - {name} + {fileName} @@ -169,22 +166,7 @@ export function HistoricalProtocolRunDrawer( - {}} // TODO (nd: 06/18/2024) get file and download - > - - - {t('download')} - - - - + ) @@ -272,7 +254,7 @@ export function HistoricalProtocolRunDrawer( gridGap={SPACING.spacing4} alignItems={ALIGN_CENTER} > - + {offset.location.moduleModel != null ? getModuleDisplayName(offset.location.moduleModel) @@ -306,7 +288,7 @@ export function HistoricalProtocolRunDrawer( width="100%" padding={SPACING.spacing16} > - {enableCsvFile ? protocolFilesData : null} + {protocolFilesData} {labwareOffsets} ) 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/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..9f4bef400ee 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,12 +100,16 @@ 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, } 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' @@ -163,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 = @@ -213,6 +213,17 @@ export function ProtocolRunHeader({ host, isFlex, }) + const { + showDTModal, + onDTModalSkip, + onDTModalRemoval, + } = useProtocolDropTipModal({ + areTipsAttached, + toggleDTWiz, + isMostRecentRunCurrent: mostRecentRunId === runId, + }) + + const enteredER = runRecord?.data.hasEverEnteredErrorRecovery React.useEffect(() => { if (isFlex) { @@ -222,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() } @@ -235,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({ @@ -248,9 +265,6 @@ export function ProtocolRunHeader({ }) closeCurrentRun() } - if (runStatus === RUN_STATUS_AWAITING_RECOVERY) { - setEnteredER(true) - } }, [runStatus, isRunCurrent, runId, closeCurrentRun]) const startedAtTimestamp = @@ -403,6 +417,13 @@ export function ProtocolRunHeader({ }} /> ) : null} + {showDTModal ? ( + + ) : null} setTipStatusResolved().then(toggleDTWiz)} /> 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/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/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..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, @@ -96,6 +93,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 +152,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 +376,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,8 +1028,23 @@ 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 () => { diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunRuntimeParameters.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunRuntimeParameters.test.tsx index ec9eace43b7..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' @@ -93,7 +92,8 @@ const mockCsvRtp = { description: '', type: 'csv_file', file: { - file: { name: 'mock.csv' } as File, + id: 'mock_csv_id', + name: 'mock.csv', }, } @@ -121,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(() => { @@ -188,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 d52575b1cb9..06815a7b064 100644 --- a/app/src/organisms/Devices/RecentProtocolRuns.tsx +++ b/app/src/organisms/Devices/RecentProtocolRuns.tsx @@ -16,11 +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 { useFeatureFlag } from '../../redux/config' +import { useNotifyAllRunsQuery, useCurrentRunId } from '../../resources/runs' interface RecentProtocolRunsProps { robotName: string @@ -37,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')} + + {showRecoveryBanner ? ( + + ) : null}
+ {showRecoveryBanner ? ( + + ) : null} { @@ -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__/RobotCard.test.tsx b/app/src/organisms/Devices/__tests__/RobotCard.test.tsx index 5bb7d2cce4d..d4cf00a61d7 100644 --- a/app/src/organisms/Devices/__tests__/RobotCard.test.tsx +++ b/app/src/organisms/Devices/__tests__/RobotCard.test.tsx @@ -31,6 +31,10 @@ import { UpdateRobotBanner } from '../../UpdateRobotBanner' import { RobotOverflowMenu } from '../RobotOverflowMenu' import { RobotStatusHeader } from '../RobotStatusHeader' import { RobotCard } from '../RobotCard' +import { + ErrorRecoveryBanner, + useErrorRecoveryBanner, +} from '../../ErrorRecoveryBanner' import type { State } from '../../../redux/types' @@ -41,6 +45,7 @@ vi.mock('../../UpdateRobotBanner') vi.mock('../../../redux/config') vi.mock('../RobotOverflowMenu') vi.mock('../RobotStatusHeader') +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' @@ -127,6 +132,13 @@ describe('RobotCard', () => { 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__/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 6aaa236d49c..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' @@ -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' @@ -61,11 +65,12 @@ 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') 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/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/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/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/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( { 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/__tests__/TipsAttachedModal.test.tsx b/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx index 2d977285e3b..135ff4e0e6e 100644 --- a/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx +++ b/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx @@ -83,8 +83,10 @@ describe('TipsAttachedModal', () => { 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/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/hooks/useDropTipWithType/useDropTipCommands.ts b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/useDropTipCommands.ts index 12946613b58..c1360114e38 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/useDropTipCommands.ts +++ b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/useDropTipCommands.ts @@ -5,12 +5,14 @@ import { useDeleteMaintenanceRunMutation } from '@opentrons/react-api-client' import { MANAGED_PIPETTE_ID, POSITION_AND_BLOWOUT } from '../../constants' import { getAddressableAreaFromConfig } from '../../getAddressableAreaFromConfig' import { useNotifyDeckConfigurationQuery } from '../../../../resources/deck_configuration' - import type { CreateCommand, AddressableAreaName, PipetteModelSpecs, + DropTipInPlaceCreateCommand, + UnsafeDropTipInPlaceCreateCommand, } from '@opentrons/shared-data' +import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' import type { CommandData, PipetteData } from '@opentrons/api-client' import type { Axis, @@ -23,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 @@ -61,6 +64,7 @@ export function useDropTipCommands({ robotType, fixitCommandTypeUtils, }: UseDropTipSetupCommandsParams): UseDropTipCommandsResult { + const isFlex = robotType === FLEX_ROBOT_TYPE const [hasSeenClose, setHasSeenClose] = React.useState(false) const { deleteMaintenanceRun } = useDeleteMaintenanceRunMutation({ @@ -114,8 +118,12 @@ export function useDropTipCommands({ if (addressableAreaFromConfig != null) { const moveToAACommand = buildMoveToAACommand(addressableAreaFromConfig) - - return chainRunCommands([moveToAACommand], true) + return chainRunCommands( + isFlex + ? [UPDATE_ESTIMATORS_EXCEPT_PLUNGERS, moveToAACommand] + : [moveToAACommand], + true + ) .then((commandData: CommandData[]) => { const error = commandData[0].data.error if (error != null) { @@ -177,10 +185,10 @@ export function useDropTipCommands({ proceed: () => void ): Promise => { return new Promise((resolve, reject) => { - const blowoutCommand = buildBlowoutInPlaceCommand(instrumentModelSpecs) - chainRunCommands( - [currentStep === POSITION_AND_BLOWOUT ? blowoutCommand : DROP_TIP], + currentStep === POSITION_AND_BLOWOUT + ? buildBlowoutCommands(instrumentModelSpecs, isFlex) + : buildDropTipInPlaceCommand(isFlex), true ) .then((commandData: CommandData[]) => { @@ -260,22 +268,60 @@ const HOME_EXCEPT_PLUNGERS: CreateCommand = { params: { axes: ['leftZ', 'rightZ', 'x', 'y'] }, } -const DROP_TIP: CreateCommand = { - commandType: 'dropTipInPlace', - params: { pipetteId: MANAGED_PIPETTE_ID }, +const UPDATE_ESTIMATORS_EXCEPT_PLUNGERS: CreateCommand = { + commandType: 'unsafe/updatePositionEstimators' as const, + params: { axes: ['leftZ', 'rightZ', 'x', 'y'] }, } -const buildBlowoutInPlaceCommand = ( - specs: PipetteModelSpecs -): CreateCommand => { - return { - commandType: 'blowOutInPlace', - params: { - pipetteId: MANAGED_PIPETTE_ID, - flowRate: specs.defaultBlowOutFlowRate.value, - }, - } -} +const buildDropTipInPlaceCommand = ( + isFlex: boolean +): Array => + isFlex + ? [ + { + commandType: 'unsafe/dropTipInPlace', + params: { pipetteId: MANAGED_PIPETTE_ID }, + }, + ] + : [ + { + commandType: 'dropTipInPlace', + params: { pipetteId: MANAGED_PIPETTE_ID }, + }, + ] + +const buildBlowoutCommands = ( + specs: PipetteModelSpecs, + isFlex: boolean +): CreateCommand[] => + isFlex + ? [ + { + commandType: 'unsafe/blowOutInPlace', + params: { + pipetteId: MANAGED_PIPETTE_ID, + flowRate: Math.min( + specs.defaultBlowOutFlowRate.value, + MAXIMUM_BLOWOUT_FLOW_RATE_UL_PER_S + ), + }, + }, + { + commandType: 'prepareToAspirate', + params: { + pipetteId: MANAGED_PIPETTE_ID, + }, + }, + ] + : [ + { + commandType: 'blowOutInPlace', + params: { + pipetteId: MANAGED_PIPETTE_ID, + flowRate: specs.defaultBlowOutFlowRate.value, + }, + }, + ] const buildMoveToAACommand = ( addressableAreaFromConfig: AddressableAreaName 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/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')} + + + + + ) +} 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..e5886839326 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'} - /> - + - ) => { - setSelected(e.currentTarget.value as RemovalOptions) - }} - options={[ - { - value: 'begin-removal', - children: ( - - {t('begin_removal')} - - ), - }, - { - value: 'skip', - children: ( - - {t('skip_removal')} - - ), - }, - ]} + + + {t('remove_any_attached_tips')} + + + , + }} + /> + - + ) } @@ -243,7 +200,7 @@ export function useDropTipFlowUtils({ tipStatusUtils, failedCommand, currentRecoveryOptionUtils, - trackExternalMap, + subMapUtils, routeUpdateActions, recoveryMap, }: RecoveryContentProps): FixitCommandTypeUtils { @@ -258,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 => { @@ -331,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 } } } @@ -344,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 aa8a7063463..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( @@ -90,25 +91,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 +125,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, @@ -174,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, @@ -223,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', () => { @@ -294,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/__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/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/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/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/useFailedLabwareUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts index 5de95fe90da..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, 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/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/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..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]) + }, [failedCommand?.error?.detail]) const { hasLaunchedRecovery, toggleERWizard, showERWizard } = useERWizard() const isOnDevice = useSelector(getIsOnDevice) 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/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__/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/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() + }) + }) }) 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[] 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/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/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/LabwareDetails/Gallery.tsx b/app/src/organisms/LabwareDetails/Gallery.tsx index 3444bc149dc..6cc4e10c85e 100644 --- a/app/src/organisms/LabwareDetails/Gallery.tsx +++ b/app/src/organisms/LabwareDetails/Gallery.tsx @@ -8,8 +8,8 @@ import { JUSTIFY_SPACE_EVENLY, LabwareRender, RobotWorkSpace, - SPACING, SPACING_AUTO, + SPACING, } from '@opentrons/components' import { labwareImages } from './labware-images' @@ -51,10 +51,10 @@ export function Gallery(props: GalleryProps): JSX.Element { const images = staticImages != null ? [render, ...staticImages] : [render] return ( - + 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 ? ( - ) => { 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/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/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/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/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 ( + location = } else if ( selectedLabware != null && typeof selectedLabware.location === 'object' && 'addressableAreaName' in selectedLabware?.location ) { location = ( - + ) } else if ( selectedLabware != null && @@ -168,16 +171,7 @@ export function ProtocolSetupLabware({ module.moduleId === selectedLabware.location.moduleId ) if (matchedModule != null) { - location = ( - <> - - - - ) + location = } } else if ( selectedLabware != null && @@ -192,24 +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 = } } } @@ -486,7 +469,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,19 +493,20 @@ 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 = + 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) { @@ -533,25 +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 = } } } @@ -566,6 +542,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 ? ( diff --git a/app/src/organisms/ProtocolSetupLiquids/LiquidDetails.tsx b/app/src/organisms/ProtocolSetupLiquids/LiquidDetails.tsx index 4b9b87534bc..a0381ab0c6a 100644 --- a/app/src/organisms/ProtocolSetupLiquids/LiquidDetails.tsx +++ b/app/src/organisms/ProtocolSetupLiquids/LiquidDetails.tsx @@ -4,10 +4,10 @@ import { useTranslation } from 'react-i18next' import { BORDERS, COLORS, + DeckInfoLabel, DIRECTION_ROW, Flex, Icon, - LocationIcon, SPACING, LegacyStyledText, TYPOGRAPHY, @@ -105,7 +105,7 @@ export function LiquidDetails(props: LiquidDetailsProps): JSX.Element { > - + 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({ - + - { 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 = ( { 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/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/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/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 { - {enableCsvFile && isRequiredCSV ? ( + {isRequiredCSV ? ( ) if (hardware.hardwareType === 'module') { - location = + location = } else if (hardware.hardwareType === 'fixture') { location = ( - + ) } const isMagneticBlockFixture = diff --git a/app/src/pages/ProtocolDetails/__tests__/ProtocolDetails.test.tsx b/app/src/pages/ProtocolDetails/__tests__/ProtocolDetails.test.tsx index 443677f7ae6..9cef0e28bc0 100644 --- a/app/src/pages/ProtocolDetails/__tests__/ProtocolDetails.test.tsx +++ b/app/src/pages/ProtocolDetails/__tests__/ProtocolDetails.test.tsx @@ -20,7 +20,6 @@ import { useRunTimeParameters, } from '../../Protocols/hooks' import { ProtocolSetupParameters } from '../../../organisms/ProtocolSetupParameters' -import { useFeatureFlag } from '../../../redux/config' import { formatTimeWithUtcLabel } from '../../../resources/runs' import { ProtocolDetails } from '..' import { Deck } from '../Deck' @@ -94,9 +93,6 @@ const render = (path = '/protocols/fakeProtocolId') => { 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/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 (hardware.hardwareType === 'module') { - location = + location = } else if (hardware.hardwareType === 'fixture') { location = ( - + ) } const isMagneticBlockFixture = diff --git a/app/src/pages/RunSummary/index.tsx b/app/src/pages/RunSummary/index.tsx index 07fbba7b11c..b47c1838164 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, @@ -36,6 +37,7 @@ import { useHost, useProtocolQuery, useInstrumentsQuery, + useDeleteRunMutation, } from '@opentrons/react-api-client' import { LargeButton } from '../../atoms/buttons' @@ -63,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' @@ -78,6 +81,7 @@ export function RunSummary(): JSX.Element { const isRunCurrent = Boolean(runRecord?.data?.current) const mostRecentRunId = useMostRecentRunId() const { data: attachedInstruments } = useInstrumentsQuery() + const { deleteRun } = useDeleteRunMutation() const runStatus = runRecord?.data.status ?? null const didRunSucceed = runStatus === RUN_STATUS_SUCCEEDED const protocolId = runRecord?.data.protocolId ?? null @@ -87,6 +91,8 @@ export function RunSummary(): JSX.Element { const protocolName = protocolRecord?.data.metadata.protocolName ?? protocolRecord?.data.files[0].name + const isQuickTransfer = protocolRecord?.data.protocolKind === 'quick-transfer' + const { startedAt, stoppedAt, completedAt } = useRunTimestamps(runId) const createdAtTimestamp = useRunCreatedAtTimestamp(runId) const startedAtTimestamp = @@ -104,11 +110,30 @@ export function RunSummary(): JSX.Element { ) const localRobot = useSelector(getLocalRobot) const robotName = localRobot?.name ?? 'no name' - const { trackProtocolRunEvent } = useTrackProtocolRunEvent(runId, robotName) - const { reset, isResetRunLoading } = useRunControls(runId) + + const onCloneRunSuccess = (): void => { + if (isQuickTransfer) { + deleteRun(runId) + } + } + + 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 ) @@ -139,17 +164,34 @@ 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() 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. + 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 => { @@ -179,13 +221,15 @@ export function RunSummary(): JSX.Element { host, pipettesWithTip, }) + } else if (isQuickTransfer) { + returnToQuickTransfer() } else { returnToDash() } } const handleRunAgain = (pipettesWithTip: PipetteWithTip[]): void => { - if (isRunCurrent && pipettesWithTip.length > 0) { + if (mostRecentRunId === runId && pipettesWithTip.length > 0) { void handleTipsAttachedModal({ setTipStatusResolved: setTipStatusResolvedAndRoute(handleRunAgain), host, @@ -325,7 +369,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/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> 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/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/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 => { } > = { - 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/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, 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 + ) 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/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} 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/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 eee523918b9..d091efdae5c 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,124 @@ 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' +// | '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' +// | 'pickUpTip_location' +// | 'pickUpTip_wellNames' // | '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 +151,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 +171,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 +223,157 @@ 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 + dropTip_wellNames?: string[] | null + pickUpTip_location?: string | null + pickUpTip_wellNames?: string[] | 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 + dropTip_wellNames?: string[] | 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 + pickUpTip_location?: string | null + pickUpTip_wellNames?: string[] | 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 +406,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 +419,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/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/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') 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 915757e48cd..d4755cec0ca 100644 --- a/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts +++ b/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts @@ -13,90 +13,96 @@ 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, + dropTip_wellNames: undefined, + 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, + pickUpTip_location: undefined, + pickUpTip_wellNames: undefined, + 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, + dropTip_wellNames: undefined, 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', + pickUpTip_location: undefined, + pickUpTip_wellNames: undefined, + pipette: null, + preWetTip: false, + tipRack: null, + volume: null, } case 'comment': @@ -106,33 +112,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 +149,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: 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, + }, }) }) }) 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 +} 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/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/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 { 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'], () => 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, + } +} 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/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/persistence/tables/__init__.py b/robot-server/robot_server/persistence/tables/__init__.py index a74c840e984..3bf84e05fd6 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, run_csv_rtp_table, PrimitiveParamSQLEnum, @@ -26,6 +27,7 @@ "run_table", "run_command_table", "action_table", + "run_csv_rtp_table", "data_files_table", "run_csv_rtp_table", "PrimitiveParamSQLEnum", 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, ) 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 c9a8738ae55..1ed03b44cd7 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, @@ -371,7 +371,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( """ @@ -385,9 +385,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/robot_server/runs/run_data_manager.py b/robot-server/robot_server/runs/run_data_manager.py index 27452b9eae4..c5cacbb7571 100644 --- a/robot-server/robot_server/runs/run_data_manager.py +++ b/robot-server/robot_server/runs/run_data_manager.py @@ -47,6 +47,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, @@ -68,6 +69,7 @@ def _build_run( pipettes=[], modules=[], liquids=[], + hasEverEnteredErrorRecovery=False, ) errors.append(state_summary.dataError) else: @@ -110,6 +112,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 c5225e1518e..db068870915 100644 --- a/robot-server/robot_server/runs/run_models.py +++ b/robot-server/robot_server/runs/run_models.py @@ -111,6 +111,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.", @@ -184,6 +188,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/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/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 10ced5c50c8..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 @@ -44,6 +44,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_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_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/protocols/test_protocol_store.py b/robot-server/tests/protocols/test_protocol_store.py index 8295192cfc0..ff6d4ce7b49 100644 --- a/robot-server/tests/protocols/test_protocol_store.py +++ b/robot-server/tests/protocols/test_protocol_store.py @@ -69,9 +69,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/runs/router/test_base_router.py b/robot-server/tests/runs/router/test_base_router.py index 45fd85ea3dd..fd1cdd8b58a 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 @@ -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() @@ -160,6 +161,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() @@ -285,6 +287,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) @@ -331,6 +334,7 @@ async def test_get_run() -> None: labware=[], labwareOffsets=[], liquids=[], + hasEverEnteredErrorRecovery=False, ) result = await get_run(run_data=run_data) @@ -376,6 +380,7 @@ async def test_get_runs_not_empty( labware=[], labwareOffsets=[], liquids=[], + hasEverEnteredErrorRecovery=False, ) response_2 = Run( @@ -391,6 +396,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]) @@ -469,6 +475,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( @@ -503,6 +510,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( @@ -583,8 +591,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, @@ -600,14 +608,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, 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 5bb66120311..a369f7f47b0 100644 --- a/robot-server/tests/runs/test_run_data_manager.py +++ b/robot-server/tests/runs/test_run_data_manager.py @@ -87,6 +87,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] @@ -201,6 +202,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, @@ -284,6 +286,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, @@ -368,6 +371,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, @@ -409,6 +413,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, @@ -451,6 +456,7 @@ async def test_get_historical_run_no_data( actions=run_resource.actions, status=EngineStatus.STOPPED, errors=[], + hasEverEnteredErrorRecovery=False, labware=[], labwareOffsets=[], pipettes=[], @@ -470,6 +476,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] @@ -488,6 +495,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] @@ -547,6 +555,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, @@ -562,6 +571,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, @@ -651,6 +661,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, @@ -706,6 +717,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 26c1c8be4de..20218fa3661 100644 --- a/robot-server/tests/runs/test_run_store.py +++ b/robot-server/tests/runs/test_run_store.py @@ -119,6 +119,7 @@ def state_summary() -> StateSummary: labwareOffsets=[], status=EngineStatus.IDLE, liquids=liquids, + hasEverEnteredErrorRecovery=False, ) @@ -192,6 +193,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. 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 diff --git a/shared-data/command/schemas/9.json b/shared-data/command/schemas/9.json index 6466287e030..1cb30c99d69 100644 --- a/shared-data/command/schemas/9.json +++ b/shared-data/command/schemas/9.json @@ -68,7 +68,10 @@ "calibration/calibrateGripper": "#/definitions/CalibrateGripperCreate", "calibration/calibratePipette": "#/definitions/CalibratePipetteCreate", "calibration/calibrateModule": "#/definitions/CalibrateModuleCreate", - "calibration/moveToMaintenancePosition": "#/definitions/MoveToMaintenancePositionCreate" + "calibration/moveToMaintenancePosition": "#/definitions/MoveToMaintenancePositionCreate", + "unsafe/blowOutInPlace": "#/definitions/UnsafeBlowOutInPlaceCreate", + "unsafe/dropTipInPlace": "#/definitions/UnsafeDropTipInPlaceCreate", + "unsafe/updatePositionEstimators": "#/definitions/UpdatePositionEstimatorsCreate" } }, "oneOf": [ @@ -263,6 +266,15 @@ }, { "$ref": "#/definitions/MoveToMaintenancePositionCreate" + }, + { + "$ref": "#/definitions/UnsafeBlowOutInPlaceCreate" + }, + { + "$ref": "#/definitions/UnsafeDropTipInPlaceCreate" + }, + { + "$ref": "#/definitions/UpdatePositionEstimatorsCreate" } ], "definitions": { @@ -4220,6 +4232,148 @@ } }, "required": ["params"] + }, + "UnsafeBlowOutInPlaceParams": { + "title": "UnsafeBlowOutInPlaceParams", + "description": "Payload required to blow-out in place while position is unknown.", + "type": "object", + "properties": { + "flowRate": { + "title": "Flowrate", + "description": "Speed in \u00b5L/s configured for the pipette", + "exclusiveMinimum": 0, + "type": "number" + }, + "pipetteId": { + "title": "Pipetteid", + "description": "Identifier of pipette to use for liquid handling.", + "type": "string" + } + }, + "required": ["flowRate", "pipetteId"] + }, + "UnsafeBlowOutInPlaceCreate": { + "title": "UnsafeBlowOutInPlaceCreate", + "description": "UnsafeBlowOutInPlace command request model.", + "type": "object", + "properties": { + "commandType": { + "title": "Commandtype", + "default": "unsafe/blowOutInPlace", + "enum": ["unsafe/blowOutInPlace"], + "type": "string" + }, + "params": { + "$ref": "#/definitions/UnsafeBlowOutInPlaceParams" + }, + "intent": { + "description": "The reason the command was added. If not specified or `protocol`, the command will be treated as part of the protocol run itself, and added to the end of the existing command queue.\n\nIf `setup`, the command will be treated as part of run setup. A setup command may only be enqueued if the run has not started.\n\nUse setup commands for activities like pre-run calibration checks and module setup, like pre-heating.", + "allOf": [ + { + "$ref": "#/definitions/CommandIntent" + } + ] + }, + "key": { + "title": "Key", + "description": "A key value, unique in this run, that can be used to track the same logical command across multiple runs of the same protocol. If a value is not provided, one will be generated.", + "type": "string" + } + }, + "required": ["params"] + }, + "UnsafeDropTipInPlaceParams": { + "title": "UnsafeDropTipInPlaceParams", + "description": "Payload required to drop a tip in place even if the plunger position is not known.", + "type": "object", + "properties": { + "pipetteId": { + "title": "Pipetteid", + "description": "Identifier of pipette to use for liquid handling.", + "type": "string" + }, + "homeAfter": { + "title": "Homeafter", + "description": "Whether to home this pipette's plunger after dropping the tip. You should normally leave this unspecified to let the robot choose a safe default depending on its hardware.", + "type": "boolean" + } + }, + "required": ["pipetteId"] + }, + "UnsafeDropTipInPlaceCreate": { + "title": "UnsafeDropTipInPlaceCreate", + "description": "Drop tip in place command creation request model.", + "type": "object", + "properties": { + "commandType": { + "title": "Commandtype", + "default": "unsafe/dropTipInPlace", + "enum": ["unsafe/dropTipInPlace"], + "type": "string" + }, + "params": { + "$ref": "#/definitions/UnsafeDropTipInPlaceParams" + }, + "intent": { + "description": "The reason the command was added. If not specified or `protocol`, the command will be treated as part of the protocol run itself, and added to the end of the existing command queue.\n\nIf `setup`, the command will be treated as part of run setup. A setup command may only be enqueued if the run has not started.\n\nUse setup commands for activities like pre-run calibration checks and module setup, like pre-heating.", + "allOf": [ + { + "$ref": "#/definitions/CommandIntent" + } + ] + }, + "key": { + "title": "Key", + "description": "A key value, unique in this run, that can be used to track the same logical command across multiple runs of the same protocol. If a value is not provided, one will be generated.", + "type": "string" + } + }, + "required": ["params"] + }, + "UpdatePositionEstimatorsParams": { + "title": "UpdatePositionEstimatorsParams", + "description": "Payload required for an UpdatePositionEstimators command.", + "type": "object", + "properties": { + "axes": { + "description": "The axes for which to update the position estimators.", + "type": "array", + "items": { + "$ref": "#/definitions/MotorAxis" + } + } + }, + "required": ["axes"] + }, + "UpdatePositionEstimatorsCreate": { + "title": "UpdatePositionEstimatorsCreate", + "description": "UpdatePositionEstimators command request model.", + "type": "object", + "properties": { + "commandType": { + "title": "Commandtype", + "default": "unsafe/updatePositionEstimators", + "enum": ["unsafe/updatePositionEstimators"], + "type": "string" + }, + "params": { + "$ref": "#/definitions/UpdatePositionEstimatorsParams" + }, + "intent": { + "description": "The reason the command was added. If not specified or `protocol`, the command will be treated as part of the protocol run itself, and added to the end of the existing command queue.\n\nIf `setup`, the command will be treated as part of run setup. A setup command may only be enqueued if the run has not started.\n\nUse setup commands for activities like pre-run calibration checks and module setup, like pre-heating.", + "allOf": [ + { + "$ref": "#/definitions/CommandIntent" + } + ] + }, + "key": { + "title": "Key", + "description": "A key value, unique in this run, that can be used to track the same logical command across multiple runs of the same protocol. If a value is not provided, one will be generated.", + "type": "string" + } + }, + "required": ["params"] } }, "$id": "opentronsCommandSchemaV9", diff --git a/shared-data/command/types/index.ts b/shared-data/command/types/index.ts index 2970a8e5185..f668c602f35 100644 --- a/shared-data/command/types/index.ts +++ b/shared-data/command/types/index.ts @@ -19,6 +19,7 @@ import type { CalibrationRunTimeCommand, CalibrationCreateCommand, } from './calibration' +import type { UnsafeRunTimeCommand, UnsafeCreateCommand } from './unsafe' export * from './annotation' export * from './calibration' @@ -28,6 +29,7 @@ export * from './module' export * from './pipetting' export * from './setup' export * from './timing' +export * from './unsafe' // NOTE: these key/value pairs will only be present on commands at analysis/run time // they pertain only to the actual execution status of a command on hardware, as opposed to @@ -67,6 +69,7 @@ export type CreateCommand = | CalibrationCreateCommand // for automatic pipette calibration | AnnotationCreateCommand // annotating command execution | IncidentalCreateCommand // command with only incidental effects (status bar animations) + | UnsafeCreateCommand // command providing capabilities that are not safe for scientific uses // commands will be required to have a key, but will not be created with one export type RunTimeCommand = @@ -78,6 +81,7 @@ export type RunTimeCommand = | CalibrationRunTimeCommand // for automatic pipette calibration | AnnotationRunTimeCommand // annotating command execution | IncidentalRunTimeCommand // command with only incidental effects (status bar animations) + | UnsafeRunTimeCommand // command providing capabilities that are not safe for scientific uses export type RunCommandError = | RunCommandErrorUndefined diff --git a/shared-data/command/types/unsafe.ts b/shared-data/command/types/unsafe.ts new file mode 100644 index 00000000000..8ff4d7e74aa --- /dev/null +++ b/shared-data/command/types/unsafe.ts @@ -0,0 +1,58 @@ +import type { CommonCommandRunTimeInfo, CommonCommandCreateInfo } from '.' +import type { MotorAxes } from '../../js/types' + +export type UnsafeRunTimeCommand = + | UnsafeBlowoutInPlaceRunTimeCommand + | UnsafeDropTipInPlaceRunTimeCommand + | UnsafeUpdatePositionEstimatorsRunTimeCommand + +export type UnsafeCreateCommand = + | UnsafeBlowoutInPlaceCreateCommand + | UnsafeDropTipInPlaceCreateCommand + | UnsafeUpdatePositionEstimatorsCreateCommand + +export interface UnsafeBlowoutInPlaceParams { + pipetteId: string + flowRate: number // µL/s +} + +export interface UnsafeBlowoutInPlaceCreateCommand + extends CommonCommandCreateInfo { + commandType: 'unsafe/blowOutInPlace' + params: UnsafeBlowoutInPlaceParams +} +export interface UnsafeBlowoutInPlaceRunTimeCommand + extends CommonCommandRunTimeInfo, + UnsafeBlowoutInPlaceCreateCommand { + result?: {} +} + +export interface UnsafeDropTipInPlaceParams { + pipetteId: string +} + +export interface UnsafeDropTipInPlaceCreateCommand + extends CommonCommandCreateInfo { + commandType: 'unsafe/dropTipInPlace' + params: UnsafeDropTipInPlaceParams +} +export interface UnsafeDropTipInPlaceRunTimeCommand + extends CommonCommandRunTimeInfo, + UnsafeDropTipInPlaceCreateCommand { + result?: any +} + +export interface UnsafeUpdatePositionEstimatorsParams { + axes: MotorAxes +} + +export interface UnsafeUpdatePositionEstimatorsCreateCommand + extends CommonCommandCreateInfo { + commandType: 'unsafe/updatePositionEstimators' + params: UnsafeUpdatePositionEstimatorsParams +} +export interface UnsafeUpdatePositionEstimatorsRunTimeCommand + extends CommonCommandRunTimeInfo, + UnsafeUpdatePositionEstimatorsCreateCommand { + result?: any +} 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 { 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]}" 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"