From f45e6df388c8131501db691b770b73862eb79007 Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Tue, 6 Aug 2024 16:53:02 -0400 Subject: [PATCH 01/14] fix(api): Fix liquid probes always causing `opentrons_simulate` to raise `MustHomeError` (#15900) --- .../create_simulating_orchestrator.py | 6 ++- api/src/opentrons/simulate.py | 13 +++++++ api/tests/opentrons/test_simulate.py | 39 +++++++++++++++++++ .../hardware_testing/gravimetric/helpers.py | 5 +++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/api/src/opentrons/protocol_runner/create_simulating_orchestrator.py b/api/src/opentrons/protocol_runner/create_simulating_orchestrator.py index 0aa5114b5a5..dd826eeade3 100644 --- a/api/src/opentrons/protocol_runner/create_simulating_orchestrator.py +++ b/api/src/opentrons/protocol_runner/create_simulating_orchestrator.py @@ -47,7 +47,11 @@ async def create_simulating_orchestrator( robot_type=robot_type ) - # TODO(mc, 2021-08-25): move initial home to protocol engine + # TODO(mm, 2024-08-06): This home has theoretically been replaced by Protocol Engine + # `home` commands within the `RunOrchestrator` or `ProtocolRunner`. However, it turns + # out that this `HardwareControlAPI`-level home is accidentally load-bearing, + # working around Protocol Engine bugs where *both* layers need to be homed for + # certain commands to work. https://opentrons.atlassian.net/browse/EXEC-646 await simulating_hardware_api.home() protocol_engine = await create_protocol_engine( diff --git a/api/src/opentrons/simulate.py b/api/src/opentrons/simulate.py index 01a1484c6b5..665231efca9 100644 --- a/api/src/opentrons/simulate.py +++ b/api/src/opentrons/simulate.py @@ -327,6 +327,12 @@ def get_protocol_api( # Intentional difference from execute.get_protocol_api(): # For the caller's convenience, we home the virtual hardware so they don't get MustHomeErrors. # Since this hardware is virtual, there's no harm in commanding this "movement" implicitly. + # + # Calling `checked_hardware_sync.home()` is a hack. It ought to be redundant with + # `context.home()`. We need it here to work around a Protocol Engine simulation bug + # where both the `HardwareControlAPI` level and the `ProtocolEngine` level need to + # be homed for certain commands to work. https://opentrons.atlassian.net/browse/EXEC-646 + checked_hardware.sync.home() context.home() return context @@ -936,6 +942,13 @@ async def run(protocol_source: ProtocolSource) -> _SimulateResult: ), ) + # TODO(mm, 2024-08-06): This home is theoretically redundant with Protocol + # Engine `home` commands within the `RunOrchestrator`. However, we need this to + # work around Protocol Engine bugs where both the `HardwareControlAPI` level + # and the `ProtocolEngine` level need to be homed for certain commands to work. + # https://opentrons.atlassian.net/browse/EXEC-646 + await hardware_api_wrapped.home() + scraper = _CommandScraper(stack_logger, log_level, protocol_runner.broker) with scraper.scrape(): result = await orchestrator.run( diff --git a/api/tests/opentrons/test_simulate.py b/api/tests/opentrons/test_simulate.py index 6750bf850b0..6d5c96fc49c 100644 --- a/api/tests/opentrons/test_simulate.py +++ b/api/tests/opentrons/test_simulate.py @@ -296,6 +296,45 @@ def test_get_protocol_api_usable_without_homing(api_version: APIVersion) -> None pipette.pick_up_tip(tip_rack["A1"]) # Should not raise. +def test_liquid_probe_get_protocol_api() -> None: + """Covers `simulate.get_protocol_api()`-specific issues with liquid probes. + + See https://opentrons.atlassian.net/browse/EXEC-646. + """ + protocol = simulate.get_protocol_api(version="2.20", robot_type="Flex") + pipette = protocol.load_instrument("flex_1channel_1000", mount="left") + tip_rack = protocol.load_labware("opentrons_flex_96_tiprack_1000ul", "A1") + well_plate = protocol.load_labware( + "opentrons_96_wellplate_200ul_pcr_full_skirt", "A2" + ) + pipette.pick_up_tip(tip_rack["A1"]) + pipette.require_liquid_presence(well_plate["A1"]) # Should not raise MustHomeError. + + +def test_liquid_probe_simulate_file() -> None: + """Covers `opentrons_simulate`-specific issues with liquid probes. + + See https://opentrons.atlassian.net/browse/EXEC-646. + """ + protocol_contents = textwrap.dedent( + """\ + requirements = {"robotType": "Flex", "apiLevel": "2.20"} + def run(protocol): + pipette = protocol.load_instrument("flex_1channel_1000", mount="left") + tip_rack = protocol.load_labware("opentrons_flex_96_tiprack_1000ul", "A1") + well_plate = protocol.load_labware( + "opentrons_96_wellplate_200ul_pcr_full_skirt", "A2" + ) + pipette.pick_up_tip(tip_rack["A1"]) + pipette.require_liquid_presence(well_plate["A1"]) + """ + ) + protocol_contents_stream = io.StringIO(protocol_contents) + simulate.simulate( + protocol_file=protocol_contents_stream + ) # Should not raise MustHomeError. + + class TestGetProtocolAPILabware: """Tests for making sure get_protocol_api() handles extra labware correctly.""" diff --git a/hardware-testing/hardware_testing/gravimetric/helpers.py b/hardware-testing/hardware_testing/gravimetric/helpers.py index 5c89712ac81..eaadca2c6a9 100644 --- a/hardware-testing/hardware_testing/gravimetric/helpers.py +++ b/hardware-testing/hardware_testing/gravimetric/helpers.py @@ -109,6 +109,11 @@ async def _thread_manager_build_hw_api( extra_labware=extra_labware, hardware_simulator=ThreadManager(_thread_manager_build_hw_api), robot_type="Flex", + # use_virtual_hardware=False makes this simulation work unlike + # opentrons_simulate, app-side analysis, and server-side analysis. + # We need to do this because some of our hardware testing scripts still + # interact directly with the OT3API and there is no way to tell Protocol + # Engine's hardware virtualization about those updates. use_virtual_hardware=False, ) else: From e2d48dc65aa7119b1f34145c27102bca76814a85 Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Tue, 6 Aug 2024 16:55:10 -0400 Subject: [PATCH 02/14] style(api): Revise execute/simulate reference docstrings (#15901) Co-authored-by: Ed Cormany --- api/src/opentrons/execute.py | 74 +++++++++---------- api/src/opentrons/simulate.py | 129 ++++++++++++++++++---------------- 2 files changed, 104 insertions(+), 99 deletions(-) diff --git a/api/src/opentrons/execute.py b/api/src/opentrons/execute.py index e4109d5d390..2e6a5870f7d 100644 --- a/api/src/opentrons/execute.py +++ b/api/src/opentrons/execute.py @@ -142,23 +142,23 @@ def get_protocol_api( When this function is called, modules and instruments will be recached. :param version: The API version to use. This must be lower than - ``opentrons.protocol_api.MAX_SUPPORTED_VERSION``. - It may be specified either as a string (``'2.0'``) or - as a ``protocols.types.APIVersion`` - (``APIVersion(2, 0)``). + ``opentrons.protocol_api.MAX_SUPPORTED_VERSION``. + It may be specified either as a string (``'2.0'``) or + as a ``protocols.types.APIVersion`` + (``APIVersion(2, 0)``). :param bundled_labware: If specified, a mapping from labware names to - labware definitions for labware to consider in the - protocol. Note that if you specify this, _only_ - labware in this argument will be allowed in the - protocol. This is preparation for a beta feature - and is best not used. + labware definitions for labware to consider in the + protocol. Note that if you specify this, *only* + labware in this argument will be allowed in the + protocol. This is preparation for a beta feature + and is best not used. :param bundled_data: If specified, a mapping from filenames to contents - for data to be available in the protocol from - :py:obj:`opentrons.protocol_api.ProtocolContext.bundled_data`. + for data to be available in the protocol from + :py:obj:`opentrons.protocol_api.ProtocolContext.bundled_data`. :param extra_labware: A mapping from labware load names to custom labware definitions. - If this is ``None`` (the default), and this function is called on a robot, - it will look for labware in the ``labware`` subdirectory of the Jupyter - data directory. + If this is ``None`` (the default), and this function is called on a robot, + it will look for labware in the ``labware`` subdirectory of the Jupyter + data directory. :return: The protocol context. """ if isinstance(version, str): @@ -313,18 +313,18 @@ def execute( :param protocol_file: The protocol file to execute :param protocol_name: The name of the protocol file. This is required - internally, but it may not be a thing we can get - from the protocol_file argument. + internally, but it may not be a thing we can get + from the ``protocol_file`` argument. :param propagate_logs: Whether this function should allow logs from the - Opentrons stack to propagate up to the root handler. - This can be useful if you're integrating this - function in a larger application, but most logs that - occur during protocol simulation are best associated - with the actions in the protocol that cause them. - Default: ``False`` + Opentrons stack to propagate up to the root handler. + This can be useful if you're integrating this + function in a larger application, but most logs that + occur during protocol simulation are best associated + with the actions in the protocol that cause them. + Default: ``False`` :param log_level: The level of logs to emit on the command line: - ``"debug"``, ``"info"``, ``"warning"``, or ``"error"``. - Defaults to ``"warning"``. + ``"debug"``, ``"info"``, ``"warning"``, or ``"error"``. + Defaults to ``"warning"``. :param emit_runlog: A callback for printing the run log. If specified, this will be called whenever a command adds an entry to the run log, which can be used for display and progress @@ -353,17 +353,17 @@ def execute( ``KeyError``. :param custom_labware_paths: A list of directories to search for custom labware. - Loads valid labware from these paths and makes them available - to the protocol context. If this is ``None`` (the default), and - this function is called on a robot, it will look in the ``labware`` - subdirectory of the Jupyter data directory. + Loads valid labware from these paths and makes them available + to the protocol context. If this is ``None`` (the default), and + this function is called on a robot, it will look in the ``labware`` + subdirectory of the Jupyter data directory. :param custom_data_paths: A list of directories or files to load custom - data files from. Ignored if the apiv2 feature - flag if not set. Entries may be either files or - directories. Specified files and the - non-recursive contents of specified directories - are presented by the protocol context in - ``ProtocolContext.bundled_data``. + data files from. Ignored if the apiv2 feature + flag if not set. Entries may be either files or + directories. Specified files and the + non-recursive contents of specified directories + are presented by the protocol context in + ``ProtocolContext.bundled_data``. """ stack_logger = logging.getLogger("opentrons") stack_logger.propagate = propagate_logs @@ -457,10 +457,10 @@ def main() -> int: """Handler for command line invocation to run a protocol. :param argv: The arguments the program was invoked with; this is usually - :py:obj:`sys.argv` but if you want to override that you can. + :py:obj:`sys.argv` but if you want to override that you can. :returns int: A success or failure value suitable for use as a shell - return code passed to :py:obj:`sys.exit` (0 means success, - anything else is a kind of failure). + return code passed to :py:obj:`sys.exit` (0 means success, + anything else is a kind of failure). """ parser = argparse.ArgumentParser( prog="opentrons_execute", description="Run an OT-2 protocol" diff --git a/api/src/opentrons/simulate.py b/api/src/opentrons/simulate.py index 665231efca9..1c6a4c3a745 100644 --- a/api/src/opentrons/simulate.py +++ b/api/src/opentrons/simulate.py @@ -228,10 +228,9 @@ def get_protocol_api( use_virtual_hardware: bool = True, ) -> protocol_api.ProtocolContext: """ - Build and return a ``protocol_api.ProtocolContext`` - connected to Virtual Smoothie. + Build and return a ``protocol_api.ProtocolContext`` that simulates robot control. - This can be used to run protocols from interactive Python sessions + This can be used to simulate protocols from interactive Python sessions such as Jupyter or an interpreter on the command line: .. code-block:: python @@ -242,28 +241,31 @@ def get_protocol_api( >>> instr.home() :param version: The API version to use. This must be lower than - ``opentrons.protocol_api.MAX_SUPPORTED_VERSION``. - It may be specified either as a string (``'2.0'``) or - as a ``protocols.types.APIVersion`` - (``APIVersion(2, 0)``). + ``opentrons.protocol_api.MAX_SUPPORTED_VERSION``. + It may be specified either as a string (``'2.0'``) or + as a ``protocols.types.APIVersion`` + (``APIVersion(2, 0)``). :param bundled_labware: If specified, a mapping from labware names to - labware definitions for labware to consider in the - protocol. Note that if you specify this, _only_ - labware in this argument will be allowed in the - protocol. This is preparation for a beta feature - and is best not used. + labware definitions for labware to consider in the + protocol. Note that if you specify this, *only* + labware in this argument will be allowed in the + protocol. This is preparation for a beta feature + and is best not used. :param bundled_data: If specified, a mapping from filenames to contents - for data to be available in the protocol from - :py:obj:`opentrons.protocol_api.ProtocolContext.bundled_data`. + for data to be available in the protocol from + :py:obj:`opentrons.protocol_api.ProtocolContext.bundled_data`. :param extra_labware: A mapping from labware load names to custom labware definitions. - If this is ``None`` (the default), and this function is called on a robot, - it will look for labware in the ``labware`` subdirectory of the Jupyter - data directory. - :param hardware_simulator: If specified, a hardware simulator instance. + If this is ``None`` (the default), and this function is called on a robot, + it will look for labware in the ``labware`` subdirectory of the Jupyter + data directory. + :param hardware_simulator: This is only for internal use by Opentrons. If specified, + it's a hardware simulator instance to reuse instead of creating a fresh one. :param robot_type: The type of robot to simulate: either ``"Flex"`` or ``"OT-2"``. - If you're running this function on a robot, the default is the type of that - robot. Otherwise, the default is ``"OT-2"``, for backwards compatibility. - :param use_virtual_hardware: If true, use the protocol engines virtual hardware, if false use the lower level hardware simulator. + If you're running this function on a robot, the default is the type of that + robot. Otherwise, the default is ``"OT-2"``, for backwards compatibility. + :param use_virtual_hardware: This is only for internal use by Opentrons. + If ``True``, use the Protocol Engine's virtual hardware. If ``False``, use the + lower level hardware simulator. :return: The protocol context. """ if isinstance(version, str): @@ -321,7 +323,7 @@ def get_protocol_api( hardware_api=checked_hardware, bundled_data=bundled_data, extra_labware=extra_labware, - use_virtual_hardware=use_virtual_hardware, + use_pe_virtual_hardware=use_virtual_hardware, ) # Intentional difference from execute.get_protocol_api(): @@ -441,15 +443,15 @@ def simulate( """ Simulate the protocol itself. - This is a one-stop function to simulate a protocol, whether python or json, - no matter the api version, from external (i.e. not bound up in other + This is a one-stop function to simulate a protocol, whether Python or JSON, + no matter the API version, from external (i.e. not bound up in other internal server infrastructure) sources. - To simulate an opentrons protocol from other places, pass in a file like - object as protocol_file; this function either returns (if the simulation + To simulate an opentrons protocol from other places, pass in a file-like + object as ``protocol_file``; this function either returns (if the simulation has no problems) or raises an exception. - To call from the command line use either the autogenerated entrypoint + To call from the command line, use either the autogenerated entrypoint ``opentrons_simulate`` (``opentrons_simulate.exe``, on windows) or ``python -m opentrons.simulate``. @@ -480,36 +482,37 @@ def simulate( :param protocol_file: The protocol file to simulate. :param file_name: The name of the file :param custom_labware_paths: A list of directories to search for custom labware. - Loads valid labware from these paths and makes them available - to the protocol context. If this is ``None`` (the default), and - this function is called on a robot, it will look in the ``labware`` - subdirectory of the Jupyter data directory. + Loads valid labware from these paths and makes them available + to the protocol context. If this is ``None`` (the default), and + this function is called on a robot, it will look in the ``labware`` + subdirectory of the Jupyter data directory. :param custom_data_paths: A list of directories or files to load custom - data files from. Ignored if the apiv2 feature - flag if not set. Entries may be either files or - directories. Specified files and the - non-recursive contents of specified directories - are presented by the protocol context in - ``protocol_api.ProtocolContext.bundled_data``. - :param hardware_simulator_file_path: A path to a JSON file defining a - hardware simulator. + data files from. Ignored if the apiv2 feature + flag if not set. Entries may be either files or + directories. Specified files and the + non-recursive contents of specified directories + are presented by the protocol context in + ``protocol_api.ProtocolContext.bundled_data``. + :param hardware_simulator_file_path: A path to a JSON file defining the simulated + hardware. This is mainly for internal use by Opentrons, and is not necessary + to simulate protocols. :param duration_estimator: For internal use only. - Optional duration estimator object. + Optional duration estimator object. :param propagate_logs: Whether this function should allow logs from the - Opentrons stack to propagate up to the root handler. - This can be useful if you're integrating this - function in a larger application, but most logs that - occur during protocol simulation are best associated - with the actions in the protocol that cause them. - Default: ``False`` + Opentrons stack to propagate up to the root handler. + This can be useful if you're integrating this + function in a larger application, but most logs that + occur during protocol simulation are best associated + with the actions in the protocol that cause them. + Default: ``False`` :param log_level: The level of logs to capture in the run log: - ``"debug"``, ``"info"``, ``"warning"``, or ``"error"``. - Defaults to ``"warning"``. + ``"debug"``, ``"info"``, ``"warning"``, or ``"error"``. + Defaults to ``"warning"``. :returns: A tuple of a run log for user output, and possibly the required - data to write to a bundle to bundle this protocol. The bundle is - only emitted if bundling is allowed - and this is an unbundled Protocol API - v2 python protocol. In other cases it is None. + data to write to a bundle to bundle this protocol. The bundle is + only emitted if bundling is allowed + and this is an unbundled Protocol API + v2 python protocol. In other cases it is None. """ stack_logger = logging.getLogger("opentrons") stack_logger.propagate = propagate_logs @@ -642,8 +645,7 @@ def get_arguments(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: Useful if you want to use this module as a component of another CLI program and want to add its arguments. - :param parser: A parser to add arguments to. If not specified, one will be - created. + :param parser: A parser to add arguments to. If not specified, one will be created. :returns argparse.ArgumentParser: The parser with arguments added. """ parser.add_argument( @@ -800,7 +802,7 @@ def _create_live_context_pe( deck_type: str, extra_labware: Dict[str, "LabwareDefinitionDict"], bundled_data: Optional[Dict[str, bytes]], - use_virtual_hardware: bool = True, + use_pe_virtual_hardware: bool = True, ) -> ProtocolContext: """Return a live ProtocolContext that controls the robot through ProtocolEngine.""" assert api_version >= ENGINE_CORE_API_VERSION @@ -810,7 +812,7 @@ def _create_live_context_pe( create_protocol_engine_in_thread( hardware_api=hardware_api_wrapped, config=_get_protocol_engine_config( - robot_type, virtual=use_virtual_hardware + robot_type, use_pe_virtual_hardware=use_pe_virtual_hardware ), error_recovery_policy=error_recovery_policy.never_recover, drop_tips_after_run=False, @@ -916,7 +918,9 @@ async def run(protocol_source: ProtocolSource) -> _SimulateResult: hardware_api_wrapped = hardware_api.wrapped() protocol_engine = await create_protocol_engine( hardware_api=hardware_api_wrapped, - config=_get_protocol_engine_config(robot_type, virtual=True), + config=_get_protocol_engine_config( + robot_type, use_pe_virtual_hardware=True + ), error_recovery_policy=error_recovery_policy.never_recover, load_fixed_trash=should_load_fixed_trash(protocol_source.config), ) @@ -974,15 +978,17 @@ async def run(protocol_source: ProtocolSource) -> _SimulateResult: return asyncio.run(run(protocol_source)) -def _get_protocol_engine_config(robot_type: RobotType, virtual: bool) -> Config: +def _get_protocol_engine_config( + robot_type: RobotType, use_pe_virtual_hardware: bool +) -> Config: """Return a Protocol Engine config to execute protocols on this device.""" return Config( robot_type=robot_type, deck_type=DeckType(deck_type_for_simulation(robot_type)), ignore_pause=True, - use_virtual_pipettes=virtual, - use_virtual_modules=virtual, - use_virtual_gripper=virtual, + use_virtual_pipettes=use_pe_virtual_hardware, + use_virtual_modules=use_pe_virtual_hardware, + use_virtual_gripper=use_pe_virtual_hardware, use_simulated_deck_config=True, ) @@ -1005,7 +1011,6 @@ def main() -> int: parser = get_arguments(parser) args = parser.parse_args() - # Try to migrate api v1 containers if needed # TODO(mm, 2022-12-01): Configure the DurationEstimator with the correct deck type. duration_estimator = DurationEstimator() if args.estimate_duration else None # type: ignore[no-untyped-call] From d3eac9055d7d4e7b8edfad08546634e0072cd446 Mon Sep 17 00:00:00 2001 From: Sarah Breen Date: Tue, 6 Aug 2024 17:29:14 -0400 Subject: [PATCH 03/14] fix(app): Various quick transfer fixes (#15904) Fix RQA-2908, RQA-2848, RQA-2906 --- .../assets/localization/en/app_settings.json | 1 - .../localization/en/quick_transfer.json | 4 ++- .../Navigation/__tests__/Navigation.test.tsx | 15 +++-------- app/src/organisms/Navigation/index.tsx | 13 ++-------- .../QuickTransferAdvancedSettings/Mix.tsx | 1 + .../QuickTransferAdvancedSettings/index.tsx | 17 ++++++++++--- .../QuickTransferFlow/SelectDestWells.tsx | 25 +++++++++++++------ .../QuickTransferFlow/SelectSourceWells.tsx | 21 ++++++++++++++-- app/src/redux/config/constants.ts | 1 - app/src/redux/config/schema-types.ts | 1 - 10 files changed, 58 insertions(+), 41 deletions(-) diff --git a/app/src/assets/localization/en/app_settings.json b/app/src/assets/localization/en/app_settings.json index 41a6923112c..adbc00d3181 100644 --- a/app/src/assets/localization/en/app_settings.json +++ b/app/src/assets/localization/en/app_settings.json @@ -3,7 +3,6 @@ "__dev_internal__protocolStats": "Protocol Stats", "__dev_internal__protocolTimeline": "Protocol Timeline", "__dev_internal__enableRunNotes": "Display Notes During a Protocol Run", - "__dev_internal__enableQuickTransfer": "Enable Quick Transfer", "__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/quick_transfer.json b/app/src/assets/localization/en/quick_transfer.json index f40298d6ae1..b754376c81a 100644 --- a/app/src/assets/localization/en/quick_transfer.json +++ b/app/src/assets/localization/en/quick_transfer.json @@ -47,6 +47,7 @@ "deleted_transfer": "Deleted quick transfer", "destination": "Destination", "destination_labware": "Destination labware", + "disabled": "Disabled", "dispense_flow_rate": "Dispense flow rate", "dispense_flow_rate_µL": "Dispense flow rate (µL/s)", "dispense_settings": "Dispense Settings", @@ -90,7 +91,8 @@ "pipette_currently_attached": "Quick transfer options depend on the pipettes currently attached to your robot.", "pipette_path": "Pipette path", "pipette_path_multi_aspirate": "Multi-aspirate", - "pipette_path_multi_dispense": "Multi-dispense, {{volume}} disposal volume, blowout into {{blowOutLocation}}", + "pipette_path_multi_dispense": "Multi-dispense", + "pipette_path_multi_dispense_volume_blowout": "Multi-dispense, {{volume}} disposal volume, blowout into {{blowOutLocation}}", "pipette_path_single": "Single transfers", "pre_wet_tip": "Pre-wet tip", "quick_transfer": "Quick transfer", diff --git a/app/src/organisms/Navigation/__tests__/Navigation.test.tsx b/app/src/organisms/Navigation/__tests__/Navigation.test.tsx index dc132b39749..d1056531976 100644 --- a/app/src/organisms/Navigation/__tests__/Navigation.test.tsx +++ b/app/src/organisms/Navigation/__tests__/Navigation.test.tsx @@ -2,20 +2,17 @@ import * as React from 'react' import { MemoryRouter } from 'react-router-dom' import { fireEvent, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { when } from 'vitest-when' import { renderWithProviders } from '../../../__testing-utils__' import { i18n } from '../../../i18n' import { getLocalRobot } from '../../../redux/discovery' import { mockConnectedRobot } from '../../../redux/discovery/__fixtures__' -import { useFeatureFlag } from '../../../redux/config' import { useNetworkConnection } from '../../../resources/networking/hooks/useNetworkConnection' import { NavigationMenu } from '../NavigationMenu' import { Navigation } from '..' vi.mock('../../../resources/networking/hooks/useNetworkConnection') vi.mock('../../../redux/discovery') -vi.mock('../../../redux/config') vi.mock('../NavigationMenu') mockConnectedRobot.name = '12345678901234567' @@ -66,6 +63,9 @@ describe('Navigation', () => { const allProtocols = screen.getByRole('link', { name: 'Protocols' }) expect(allProtocols).toHaveAttribute('href', '/protocols') + const quickTransfer = screen.getByRole('link', { name: 'Quick Transfer' }) + expect(quickTransfer).toHaveAttribute('href', '/quick-transfer') + const instruments = screen.getByRole('link', { name: 'Instruments' }) expect(instruments).toHaveAttribute('href', '/instruments') @@ -75,15 +75,6 @@ describe('Navigation', () => { expect(screen.queryByText('Get started')).not.toBeInTheDocument() expect(screen.queryByLabelText('network icon')).not.toBeInTheDocument() }) - it('should render quick transfer tab if feature flag is on', () => { - when(vi.mocked(useFeatureFlag)) - .calledWith('enableQuickTransfer') - .thenReturn(true) - render(props) - screen.getByRole('link', { name: '123456789012...' }) // because of the truncate function - const quickTransfer = screen.getByRole('link', { name: 'Quick Transfer' }) - expect(quickTransfer).toHaveAttribute('href', '/quick-transfer') - }) it('should render a network icon', () => { vi.mocked(useNetworkConnection).mockReturnValue({ isEthernetConnected: false, diff --git a/app/src/organisms/Navigation/index.tsx b/app/src/organisms/Navigation/index.tsx index 2d920726f03..a9a55f53e63 100644 --- a/app/src/organisms/Navigation/index.tsx +++ b/app/src/organisms/Navigation/index.tsx @@ -29,12 +29,12 @@ import { ODD_FOCUS_VISIBLE } from '../../atoms/buttons/constants' import { useNetworkConnection } from '../../resources/networking/hooks/useNetworkConnection' import { getLocalRobot } from '../../redux/discovery' -import { useFeatureFlag } from '../../redux/config' import { NavigationMenu } from './NavigationMenu' import type { ON_DEVICE_DISPLAY_PATHS } from '../../App/OnDeviceDisplayApp' -let NAV_LINKS: Array = [ +const NAV_LINKS: Array = [ '/protocols', + '/quick-transfer', '/instruments', '/robot-settings', ] @@ -68,15 +68,6 @@ export function Navigation(props: NavigationProps): JSX.Element { const localRobot = useSelector(getLocalRobot) const [showNavMenu, setShowNavMenu] = React.useState(false) const robotName = localRobot?.name != null ? localRobot.name : 'no name' - const enableQuickTransferFF = useFeatureFlag('enableQuickTransfer') - if (enableQuickTransferFF) { - NAV_LINKS = [ - '/protocols', - '/quick-transfer', - '/instruments', - '/robot-settings', - ] - } // We need to display an icon for what type of network connection (if any) // is active next to the robot's name. The designs call for it to change color diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Mix.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Mix.tsx index c0df132b52a..46727cb1228 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Mix.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/Mix.tsx @@ -85,6 +85,7 @@ export function Mix(props: MixProps): JSX.Element { type: mixAction, mixSettings: undefined, }) + onBack() } else { setCurrentStep(2) } diff --git a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/index.tsx b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/index.tsx index 7ec21c7c857..34a36d2d4fb 100644 --- a/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/index.tsx +++ b/app/src/organisms/QuickTransferFlow/QuickTransferAdvancedSettings/index.tsx @@ -74,7 +74,7 @@ export function QuickTransferAdvancedSettings( } else if (state.path === 'multiAspirate') { pipettePathValue = t('pipette_path_multi_aspirate') } else if (state.path === 'multiDispense') { - pipettePathValue = t('pipette_path_multi_dispense', { + pipettePathValue = t('pipette_path_multi_dispense_volume_blowout', { volume: state.disposalVolume, blowOutLocation: getBlowoutValueCopy(), }) @@ -160,7 +160,10 @@ export function QuickTransferAdvancedSettings( state.transferType === 'transfer' || state.transferType === 'distribute', onClick: () => { - if (state.transferType === 'transfer') { + if ( + state.transferType === 'transfer' || + state.transferType === 'distribute' + ) { setSelectedSetting('aspirate_mix') } else { makeSnackbar(t('advanced_setting_disabled') as string) @@ -240,7 +243,10 @@ export function QuickTransferAdvancedSettings( state.transferType === 'transfer' || state.transferType === 'consolidate', onClick: () => { - if (state.transferType === 'transfer') { + if ( + state.transferType === 'transfer' || + state.transferType === 'consolidate' + ) { setSelectedSetting('dispense_mix') } else { makeSnackbar(t('advanced_setting_disabled') as string) @@ -293,7 +299,10 @@ export function QuickTransferAdvancedSettings( { option: 'dispense_blow_out', copy: t('blow_out'), - value: i18n.format(getBlowoutValueCopy(), 'capitalize'), + value: + state.transferType === 'distribute' + ? t('disabled') + : i18n.format(getBlowoutValueCopy(), 'capitalize'), enabled: state.transferType !== 'distribute', onClick: () => { if (state.transferType === 'distribute') { diff --git a/app/src/organisms/QuickTransferFlow/SelectDestWells.tsx b/app/src/organisms/QuickTransferFlow/SelectDestWells.tsx index 71378fb6eb2..6860815ac08 100644 --- a/app/src/organisms/QuickTransferFlow/SelectDestWells.tsx +++ b/app/src/organisms/QuickTransferFlow/SelectDestWells.tsx @@ -10,12 +10,17 @@ import { LegacyStyledText, JUSTIFY_CENTER, } from '@opentrons/components' +import { getAllDefinitions } from '@opentrons/shared-data' import { getTopPortalEl } from '../../App/portal' import { Modal } from '../../molecules/Modal' import { ChildNavigation } from '../../organisms/ChildNavigation' import { useToaster } from '../../organisms/ToasterOven' import { WellSelection } from '../../organisms/WellSelection' +import { + CIRCULAR_WELL_96_PLATE_DEFINITION_URI, + RECTANGULAR_WELL_96_PLATE_DEFINITION_URI, +} from './SelectSourceWells' import type { SmallButton } from '../../atoms/buttons' import type { ModalHeaderBaseProps } from '../../molecules/Modal/types' @@ -107,9 +112,17 @@ export function SelectDestWells(props: SelectDestWellsProps): JSX.Element { setSelectedWells({}) }, } - const labwareDefinition = + let labwareDefinition = state.destination === 'source' ? state.source : state.destination - + if (labwareDefinition?.parameters.format === '96Standard') { + const allDefinitions = getAllDefinitions() + if (Object.values(labwareDefinition.wells)[0].shape === 'circular') { + labwareDefinition = allDefinitions[CIRCULAR_WELL_96_PLATE_DEFINITION_URI] + } else { + labwareDefinition = + allDefinitions[RECTANGULAR_WELL_96_PLATE_DEFINITION_URI] + } + } return ( <> {createPortal( @@ -143,7 +156,7 @@ export function SelectDestWells(props: SelectDestWellsProps): JSX.Element { left="0" width="100%" > - {state.destination != null && state.source != null ? ( + {labwareDefinition != null ? ( { setSelectedWells(prevWells => without(Object.keys(prevWells), ...wells).reduce( diff --git a/app/src/organisms/QuickTransferFlow/SelectSourceWells.tsx b/app/src/organisms/QuickTransferFlow/SelectSourceWells.tsx index d7628bbcefe..342bb157193 100644 --- a/app/src/organisms/QuickTransferFlow/SelectSourceWells.tsx +++ b/app/src/organisms/QuickTransferFlow/SelectSourceWells.tsx @@ -7,6 +7,7 @@ import { POSITION_FIXED, SPACING, } from '@opentrons/components' +import { getAllDefinitions } from '@opentrons/shared-data' import { ChildNavigation } from '../../organisms/ChildNavigation' import { WellSelection } from '../../organisms/WellSelection' @@ -24,6 +25,11 @@ interface SelectSourceWellsProps { dispatch: React.Dispatch } +export const CIRCULAR_WELL_96_PLATE_DEFINITION_URI = + 'opentrons/thermoscientificnunc_96_wellplate_2000ul/1' +export const RECTANGULAR_WELL_96_PLATE_DEFINITION_URI = + 'opentrons/usascientific_96_wellplate_2.4ml_deep/1' + export function SelectSourceWells(props: SelectSourceWellsProps): JSX.Element { const { onNext, onBack, state, dispatch } = props const { i18n, t } = useTranslation(['quick_transfer', 'shared']) @@ -50,6 +56,17 @@ export function SelectSourceWells(props: SelectSourceWellsProps): JSX.Element { setSelectedWells({}) }, } + let displayLabwareDefinition = state.source + if (state.source?.parameters.format === '96Standard') { + const allDefinitions = getAllDefinitions() + if (Object.values(state.source.wells)[0].shape === 'circular') { + displayLabwareDefinition = + allDefinitions[CIRCULAR_WELL_96_PLATE_DEFINITION_URI] + } else { + displayLabwareDefinition = + allDefinitions[RECTANGULAR_WELL_96_PLATE_DEFINITION_URI] + } + } return ( <> @@ -71,14 +88,14 @@ export function SelectSourceWells(props: SelectSourceWellsProps): JSX.Element { left="0" width="100%" > - {state.source != null ? ( + {state.source != null && displayLabwareDefinition != null ? ( { setSelectedWells(prevWells => without(Object.keys(prevWells), ...wells).reduce( diff --git a/app/src/redux/config/constants.ts b/app/src/redux/config/constants.ts index b197e2b3420..f77241acaf3 100644 --- a/app/src/redux/config/constants.ts +++ b/app/src/redux/config/constants.ts @@ -4,7 +4,6 @@ export const DEV_INTERNAL_FLAGS: DevInternalFlag[] = [ 'forceHttpPolling', 'protocolStats', 'enableRunNotes', - 'enableQuickTransfer', 'protocolTimeline', 'enableLabwareCreator', ] diff --git a/app/src/redux/config/schema-types.ts b/app/src/redux/config/schema-types.ts index a8cd37da84a..1d277c4e62a 100644 --- a/app/src/redux/config/schema-types.ts +++ b/app/src/redux/config/schema-types.ts @@ -11,7 +11,6 @@ export type DevInternalFlag = | 'forceHttpPolling' | 'protocolStats' | 'enableRunNotes' - | 'enableQuickTransfer' | 'protocolTimeline' | 'enableLabwareCreator' From 4c3305ad7704fe9c513b58a9637134a80625a4ae Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Tue, 6 Aug 2024 19:21:42 -0400 Subject: [PATCH 04/14] feat(app): Display run setup task completion (#15889) In the run page on both app and ODD, you get an indication of completion when you get all your instruments and modules and deck stuff present and calibrated. But those are just two of the steps presented equally in the run setup page, and the rest of the steps don't get anything similar. It leads people to wonder whether they've set things up properly. This PR adds similar styling and completion semantics for the other tasks in the run setup screen to fix this issue. Specifically, LPC gets a "confirm offsets" button (which will confirm offsets even if you haven't run LPC - makes it more apparent that that's a separate option) and labware and liquids get generic confirm buttons. There's also a couple other visual fixes: - On desktop, the "back to top" button in run setup is now where figma thinks it is, outside the run-setup content area. This allows some refactoring of component props - On desktop, there was an issue with the react-router upgrade (I think - it's also in the latest IR alpha) that means that if you had an ongoing run, you couldn't view anything but run details without getting instantly navigated back to run details This implements this figma: https://www.figma.com/design/Rwdt9R0aERFC55oTLDTlqY/8.0-September-Release-File?node-id=39-35830&t=l6vwJjQsfyVeovfC-4 ## To come out of draft - [x] implement for ODD - [x] rebase onto release - [x] "are you sure" modal on desktop - [x] "are you sure" modal on ODD ## Review requests - This is some pretty complex UI - do you agree with how I've done this? - Some of this is pretty ugly, in large part because this is old code that I'm cleaning up. There's some duplicated logic in the run details and some pretty ugly typing. What I'd like to do is merge this since it implements some features nicely and then follow up with a refactor to get the size of some of these files down and enforce nicer separation between everything. ## Testing - [x] Desktop green checks on flex - [x] Desktop green checks on OT-2 (yes, this has to be different because the steps can be different here) Closes RSQ-7 --- .../localization/en/protocol_setup.json | 20 +- .../ProtocolRun/ConfirmMissingStepsModal.tsx | 65 +++++ .../Devices/ProtocolRun/ProtocolRunHeader.tsx | 35 ++- .../Devices/ProtocolRun/ProtocolRunSetup.tsx | 275 ++++++++++++------ .../__tests__/SetupLabware.test.tsx | 9 +- .../ProtocolRun/SetupLabware/index.tsx | 34 +-- .../SetupLabwarePositionCheck.test.tsx | 7 +- .../SetupLabwarePositionCheck/index.tsx | 44 ++- .../__tests__/SetupLiquids.test.tsx | 43 +-- .../ProtocolRun/SetupLiquids/index.tsx | 26 +- .../__tests__/ProtocolRunHeader.test.tsx | 10 +- .../__tests__/ProtocolRunSetup.test.tsx | 23 +- .../__tests__/ProtocolSetupLabware.test.tsx | 6 + .../organisms/ProtocolSetupLabware/index.tsx | 41 ++- .../__tests__/ProtocolSetupLiquids.test.tsx | 22 +- .../organisms/ProtocolSetupLiquids/index.tsx | 44 ++- .../organisms/ProtocolSetupOffsets/index.tsx | 122 ++++++++ .../Devices/ProtocolRunDetails/index.tsx | 104 +++++-- .../ConfirmSetupStepsCompleteModal.tsx | 68 +++++ .../__tests__/ProtocolSetup.test.tsx | 128 +++++++- app/src/pages/ProtocolSetup/index.tsx | 209 ++++++++++--- 21 files changed, 1054 insertions(+), 281 deletions(-) create mode 100644 app/src/organisms/Devices/ProtocolRun/ConfirmMissingStepsModal.tsx create mode 100644 app/src/organisms/ProtocolSetupOffsets/index.tsx create mode 100644 app/src/pages/ProtocolSetup/ConfirmSetupStepsCompleteModal.tsx diff --git a/app/src/assets/localization/en/protocol_setup.json b/app/src/assets/localization/en/protocol_setup.json index 5217a95dc4c..2eef6a6c15c 100644 --- a/app/src/assets/localization/en/protocol_setup.json +++ b/app/src/assets/localization/en/protocol_setup.json @@ -8,6 +8,8 @@ "add_to_slot": "Add to slot {{slotName}}", "additional_labware": "{{count}} additional labware", "additional_off_deck_labware": "Additional Off-Deck Labware", + "applied_labware_offsets": "applied labware offsets", + "are_you_sure_you_want_to_proceed": "Are you sure you want to proceed to run?", "attach_gripper_failure_reason": "Attach the required gripper to continue", "attach_gripper": "attach gripper", "attach_module": "Attach module before calibrating", @@ -47,6 +49,9 @@ "configured": "configured", "confirm_heater_shaker_module_modal_description": "Before the run begins, module should have both anchors fully extended for a firm attachment. The thermal adapter should be attached to the module. ", "confirm_heater_shaker_module_modal_title": "Confirm Heater-Shaker Module is attached", + "confirm_offsets": "Confirm offsets", + "confirm_liquids": "Confirm liquids", + "confirm_placements": "Confirm placements", "confirm_selection": "Confirm selection", "confirm_values": "Confirm values", "connect_all_hardware": "Connect and calibrate all hardware first", @@ -101,6 +106,7 @@ "labware_latch": "Labware Latch", "labware_location": "Labware Location", "labware_name": "Labware name", + "labware_placement": "labware placement", "labware_position_check_not_available_analyzing_on_robot": "Labware Position Check is not available while protocol is analyzing on robot", "labware_position_check_not_available_empty_protocol": "Labware Position Check requires that the protocol loads labware and pipettes", "labware_position_check_not_available": "Labware Position Check is not available after run has started", @@ -118,11 +124,13 @@ "learn_more": "Learn more", "liquid_information": "Liquid information", "liquid_name": "Liquid name", + "liquids": "liquids", "liquid_setup_step_description": "View liquid starting locations and volumes", "liquid_setup_step_title": "Liquids", "liquids_not_in_setup": "No liquids used in this protocol", "liquids_not_in_the_protocol": "no liquids are specified for this protocol.", - "liquids": "Liquids", + "liquids_ready": "Liquids ready", + "liquids_confirmed": "Liquids confirmed", "list_view": "List View", "loading_data": "Loading data...", "loading_labware_offsets": "Loading labware offsets", @@ -149,6 +157,7 @@ "module_name": "Module", "module_not_connected": "Not connected", "module_setup_step_title": "Deck hardware", + "module_setup_step_ready": "Calibration ready", "module_slot_location": "Slot {{slotName}}, {{moduleName}}", "module": "Module", "modules_connected_plural": "{{count}} modules attached", @@ -191,6 +200,7 @@ "offset_data": "Offset Data", "offsets_applied_plural": "{{count}} offsets applied", "offsets_applied": "{{count}} offset applied", + "offsets_ready": "Offsets ready", "on_adapter_in_mod": "on {{adapterName}} in {{moduleName}}", "on_adapter": "on {{adapterName}}", "on_deck": "On deck", @@ -206,6 +216,8 @@ "pipette_offset_cal_description": "This measures a pipette’s X, Y and Z values in relation to the pipette mount and the deck. Pipette Offset Calibration relies on Deck Calibration and Tip Length Calibration. ", "pipette_offset_cal": "Pipette Offset Calibration", "placement": "Placement", + "placements_ready": "Placements ready", + "placements_confirmed": "Placements confirmed", "plug_in_module_to_configure": "Plug in a {{module}} to add it to the slot", "plug_in_required_module_plural": "Plug in and power up the required modules to continue", "plug_in_required_module": "Plug in and power up the required module to continue", @@ -246,6 +258,7 @@ "robot_calibration_step_description_pipettes_only": "Review required instruments and calibrations for this protocol.", "robot_calibration_step_description": "Review required pipettes and tip length calibrations for this protocol.", "robot_calibration_step_title": "Instruments", + "robot_calibration_step_ready": "Calibration ready", "run_disabled_calibration_not_complete": "Make sure robot calibration is complete before proceeding to run", "run_disabled_modules_and_calibration_not_complete": "Make sure robot calibration is complete and all modules are connected before proceeding to run", "run_disabled_modules_not_connected": "Make sure all modules are connected before proceeding to run", @@ -260,6 +273,7 @@ "setup_is_view_only": "Setup is view-only once run has started", "slot_location": "Slot {{slotName}}", "slot_number": "Slot Number", + "start_run": "Start run", "status": "Status", "step": "STEP {{index}}", "there_are_no_unconfigured_modules": "No {{module}} is connected. Attach one and place it in {{slot}}.", @@ -271,6 +285,7 @@ "total_liquid_volume": "Total volume", "update_deck_config": "Update deck configuration", "update_deck": "Update deck", + "update_offsets": "Update offsets", "updated": "Updated", "usb_connected_no_port_info": "USB Port Connected", "usb_drive_notification": "Leave USB drive attached until run starts", @@ -286,5 +301,6 @@ "view_setup_instructions": "View setup instructions", "volume": "Volume", "what_labware_offset_is": "A Labware Offset is a type of positional adjustment that accounts for small, real-world variances in the overall position of the labware on a robot’s deck. Labware Offset data is unique to a specific combination of labware definition, deck slot, and robot.", - "with_the_chosen_value": "With the chosen values, the following error occurred:" + "with_the_chosen_value": "With the chosen values, the following error occurred:", + "you_havent_confirmed": "You haven't confirmed the {{missingSteps}} yet. Ensure these are correct before proceeding to run the protocol." } diff --git a/app/src/organisms/Devices/ProtocolRun/ConfirmMissingStepsModal.tsx b/app/src/organisms/Devices/ProtocolRun/ConfirmMissingStepsModal.tsx new file mode 100644 index 00000000000..549bc8f08b0 --- /dev/null +++ b/app/src/organisms/Devices/ProtocolRun/ConfirmMissingStepsModal.tsx @@ -0,0 +1,65 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { + ALIGN_CENTER, + DIRECTION_COLUMN, + DIRECTION_ROW, + Flex, + JUSTIFY_FLEX_END, + PrimaryButton, + SecondaryButton, + SPACING, + LegacyStyledText, + TYPOGRAPHY, +} from '@opentrons/components' +import { LegacyModal } from '../../../molecules/LegacyModal' + +interface ConfirmMissingStepsModalProps { + onCloseClick: () => void + onConfirmClick: () => void + missingSteps: string[] +} +export const ConfirmMissingStepsModal = ( + props: ConfirmMissingStepsModalProps +): JSX.Element | null => { + const { missingSteps, onCloseClick, onConfirmClick } = props + const { t, i18n } = useTranslation(['protocol_setup', 'shared']) + + const confirmAttached = (): void => { + onConfirmClick() + onCloseClick() + } + + return ( + + + + {t('you_havent_confirmed', { + missingSteps: new Intl.ListFormat('en', { + style: 'short', + type: 'conjunction', + }).format(missingSteps.map(step => t(step))), + })} + + + + + {i18n.format(t('shared:go_back'), 'capitalize')} + + + {t('start_run')} + + + + ) +} diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx index 6a704c96699..5d9821cc5a6 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx @@ -77,6 +77,7 @@ import { } from '../../../organisms/RunTimeControl/hooks' import { useIsHeaterShakerInProtocol } from '../../ModuleCard/hooks' import { ConfirmAttachmentModal } from '../../ModuleCard/ConfirmAttachmentModal' +import { ConfirmMissingStepsModal } from './ConfirmMissingStepsModal' import { useProtocolDetailsForRun, useProtocolAnalysisErrors, @@ -132,6 +133,7 @@ interface ProtocolRunHeaderProps { robotName: string runId: string makeHandleJumpToStep: (index: number) => () => void + missingSetupSteps: string[] } export function ProtocolRunHeader({ @@ -139,6 +141,7 @@ export function ProtocolRunHeader({ robotName, runId, makeHandleJumpToStep, + missingSetupSteps, }: ProtocolRunHeaderProps): JSX.Element | null { const { t } = useTranslation(['run_details', 'shared']) const navigate = useNavigate() @@ -447,6 +450,7 @@ export function ProtocolRunHeader({ isDoorOpen={isDoorOpen} isFixtureMismatch={isFixtureMismatch} isResetRunLoadingRef={isResetRunLoadingRef} + missingSetupSteps={missingSetupSteps} /> @@ -591,6 +595,7 @@ interface ActionButtonProps { isDoorOpen: boolean isFixtureMismatch: boolean isResetRunLoadingRef: React.MutableRefObject + missingSetupSteps: string[] } // TODO(jh, 04-22-2024): Refactor switch cases into separate factories to increase readability and testability. @@ -603,6 +608,7 @@ function ActionButton(props: ActionButtonProps): JSX.Element { isDoorOpen, isFixtureMismatch, isResetRunLoadingRef, + missingSetupSteps, } = props const navigate = useNavigate() const { t } = useTranslation(['run_details', 'shared']) @@ -682,12 +688,20 @@ function ActionButton(props: ActionButtonProps): JSX.Element { ) const { confirm: confirmAttachment, - showConfirmation: showConfirmationModal, - cancel: cancelExit, + showConfirmation: showHSConfirmationModal, + cancel: cancelExitHSConfirmation, } = useConditionalConfirm( handleProceedToRunClick, !configBypassHeaterShakerAttachmentConfirmation ) + const { + confirm: confirmMissingSteps, + showConfirmation: showMissingStepsConfirmationModal, + cancel: cancelExitMissingStepsConfirmation, + } = useConditionalConfirm( + handleProceedToRunClick, + missingSetupSteps.length !== 0 + ) const robotAnalyticsData = useRobotAnalyticsData(robotName) const isHeaterShakerInProtocol = useIsHeaterShakerInProtocol() @@ -745,6 +759,11 @@ function ActionButton(props: ActionButtonProps): JSX.Element { handleButtonClick = () => { if (isHeaterShakerShaking && isHeaterShakerInProtocol) { setShowIsShakingModal(true) + } else if ( + missingSetupSteps.length !== 0 && + (runStatus === RUN_STATUS_IDLE || runStatus === RUN_STATUS_STOPPED) + ) { + confirmMissingSteps() } else if ( isHeaterShakerInProtocol && !isHeaterShakerShaking && @@ -825,13 +844,21 @@ function ActionButton(props: ActionButtonProps): JSX.Element { startRun={play} /> )} - {showConfirmationModal && ( + {showHSConfirmationModal && ( )} + {showMissingStepsConfirmationModal && ( + + )} + {} ) } diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx index 19c29827c15..7ea1386768d 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx @@ -16,6 +16,7 @@ import { SPACING, LegacyStyledText, TYPOGRAPHY, + FLEX_MAX_CONTENT, } from '@opentrons/components' import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' @@ -48,8 +49,6 @@ import { SetupLiquids } from './SetupLiquids' import { EmptySetupStep } from './EmptySetupStep' import { HowLPCWorksModal } from './SetupLabwarePositionCheck/HowLPCWorksModal' -import type { ProtocolCalibrationStatus } from '../hooks' - const ROBOT_CALIBRATION_STEP_KEY = 'robot_calibration_step' as const const MODULE_SETUP_KEY = 'module_setup_step' as const const LPC_KEY = 'labware_position_check_step' as const @@ -63,16 +62,33 @@ export type StepKey = | typeof LABWARE_SETUP_KEY | typeof LIQUID_SETUP_KEY +export type MissingStep = + | 'applied_labware_offsets' + | 'labware_placement' + | 'liquids' + +export type MissingSteps = MissingStep[] + +export const initialMissingSteps = (): MissingSteps => [ + 'applied_labware_offsets', + 'labware_placement', + 'liquids', +] + interface ProtocolRunSetupProps { protocolRunHeaderRef: React.RefObject | null robotName: string runId: string + setMissingSteps: (missingSteps: MissingSteps) => void + missingSteps: MissingSteps } export function ProtocolRunSetup({ protocolRunHeaderRef, robotName, runId, + setMissingSteps, + missingSteps, }: ProtocolRunSetupProps): JSX.Element | null { const { t, i18n } = useTranslation('protocol_setup') const robotProtocolAnalysis = useMostRecentCompletedAnalysis(runId) @@ -147,6 +163,15 @@ export function ProtocolRunSetup({ return true }) + const [ + labwareSetupComplete, + setLabwareSetupComplete, + ] = React.useState(false) + const [liquidSetupComplete, setLiquidSetupComplete] = React.useState( + false + ) + const [lpcComplete, setLpcComplete] = React.useState(false) + if (robot == null) return null const liquids = protocolAnalysis?.liquids ?? [] @@ -171,7 +196,11 @@ export function ProtocolRunSetup({ const StepDetailMap: Record< StepKey, - { stepInternals: JSX.Element; description: string } + { + stepInternals: JSX.Element + description: string + rightElProps: StepRightElementProps + } > = { [ROBOT_CALIBRATION_STEP_KEY]: { stepInternals: ( @@ -193,6 +222,15 @@ export function ProtocolRunSetup({ description: isFlex ? t(`${ROBOT_CALIBRATION_STEP_KEY}_description_pipettes_only`) : t(`${ROBOT_CALIBRATION_STEP_KEY}_description`), + rightElProps: { + stepKey: ROBOT_CALIBRATION_STEP_KEY, + complete: calibrationStatusRobot.complete, + completeText: t('calibration_ready'), + missingHardware: isMissingPipette, + incompleteText: t('calibration_needed'), + missingHardwareText: t('action_needed'), + incompleteElement: null, + }, }, [MODULE_SETUP_KEY]: { stepInternals: ( @@ -209,47 +247,99 @@ export function ProtocolRunSetup({ description: isFlex ? flexDeckHardwareDescription : ot2DeckHardwareDescription, + rightElProps: { + stepKey: MODULE_SETUP_KEY, + complete: + calibrationStatusRobot.complete && calibrationStatusModules.complete, + completeText: isFlex ? t('calibration_ready') : '', + incompleteText: isFlex ? t('calibration_needed') : t('action_needed'), + missingHardware: isMissingModule || isFixtureMismatch, + missingHardwareText: t('action_needed'), + incompleteElement: null, + }, }, [LPC_KEY]: { stepInternals: ( { - setExpandedStepKey(LABWARE_SETUP_KEY) + setOffsetsConfirmed={confirmed => { + setLpcComplete(confirmed) + if (confirmed) { + setExpandedStepKey(LABWARE_SETUP_KEY) + setMissingSteps( + missingSteps.filter(step => step !== 'applied_labware_offsets') + ) + } }} + offsetsConfirmed={lpcComplete} /> ), description: t('labware_position_check_step_description'), + rightElProps: { + stepKey: LPC_KEY, + complete: lpcComplete, + completeText: t('offsets_ready'), + incompleteText: null, + incompleteElement: , + }, }, [LABWARE_SETUP_KEY]: { stepInternals: ( v === LABWARE_SETUP_KEY) === - targetStepKeyInOrder.length - 1 - ? null - : LIQUID_SETUP_KEY - } - expandStep={setExpandedStepKey} + labwareConfirmed={labwareSetupComplete} + setLabwareConfirmed={(confirmed: boolean) => { + setLabwareSetupComplete(confirmed) + if (confirmed) { + setMissingSteps( + missingSteps.filter(step => step !== 'labware_placement') + ) + const nextStep = + targetStepKeyInOrder.findIndex(v => v === LABWARE_SETUP_KEY) === + targetStepKeyInOrder.length - 1 + ? null + : LIQUID_SETUP_KEY + setExpandedStepKey(nextStep) + } + }} /> ), description: t(`${LABWARE_SETUP_KEY}_description`), + rightElProps: { + stepKey: LABWARE_SETUP_KEY, + complete: labwareSetupComplete, + completeText: t('placements_ready'), + incompleteText: null, + incompleteElement: null, + }, }, [LIQUID_SETUP_KEY]: { stepInternals: ( { + setLiquidSetupComplete(confirmed) + if (confirmed) { + setMissingSteps(missingSteps.filter(step => step !== 'liquids')) + setExpandedStepKey(null) + } + }} /> ), description: hasLiquids ? t(`${LIQUID_SETUP_KEY}_description`) : i18n.format(t('liquids_not_in_the_protocol'), 'capitalize'), + rightElProps: { + stepKey: LIQUID_SETUP_KEY, + complete: liquidSetupComplete, + completeText: t('liquids_ready'), + incompleteText: null, + incompleteElement: null, + }, }, } @@ -295,17 +385,7 @@ export function ProtocolRunSetup({ }} rightElement={ } > @@ -329,81 +409,110 @@ export function ProtocolRunSetup({ ) } -interface StepRightElementProps { - stepKey: StepKey - calibrationStatusRobot: ProtocolCalibrationStatus - calibrationStatusModules?: ProtocolCalibrationStatus - runHasStarted: boolean - isFlex: boolean - isMissingModule: boolean - isFixtureMismatch: boolean - isMissingPipette: boolean +interface NoHardwareRequiredStepCompletion { + stepKey: Exclude< + StepKey, + typeof ROBOT_CALIBRATION_STEP_KEY | typeof MODULE_SETUP_KEY + > + complete: boolean + incompleteText: string | null + incompleteElement: JSX.Element | null + completeText: string +} + +interface HardwareRequiredStepCompletion { + stepKey: typeof ROBOT_CALIBRATION_STEP_KEY | typeof MODULE_SETUP_KEY + complete: boolean + missingHardware: boolean + incompleteText: string | null + incompleteElement: JSX.Element | null + completeText: string + missingHardwareText: string } -function StepRightElement(props: StepRightElementProps): JSX.Element | null { - const { - stepKey, - runHasStarted, - calibrationStatusRobot, - calibrationStatusModules, - isFlex, - isMissingModule, - isFixtureMismatch, - isMissingPipette, - } = props - const { t } = useTranslation('protocol_setup') - const isActionNeeded = isMissingModule || isFixtureMismatch - if ( - !runHasStarted && - (stepKey === ROBOT_CALIBRATION_STEP_KEY || stepKey === MODULE_SETUP_KEY) - ) { - const moduleAndDeckStatus = isActionNeeded - ? { complete: false } - : calibrationStatusModules - const calibrationStatus = - stepKey === ROBOT_CALIBRATION_STEP_KEY - ? calibrationStatusRobot - : moduleAndDeckStatus +type StepRightElementProps = + | NoHardwareRequiredStepCompletion + | HardwareRequiredStepCompletion - let statusText = t('calibration_ready') - if ( - stepKey === ROBOT_CALIBRATION_STEP_KEY && - !calibrationStatusRobot.complete - ) { - statusText = isMissingPipette - ? t('action_needed') - : t('calibration_needed') - } else if (stepKey === MODULE_SETUP_KEY && !calibrationStatus?.complete) { - statusText = isActionNeeded ? t('action_needed') : t('calibration_needed') - } +const stepRequiresHW = ( + props: StepRightElementProps +): props is HardwareRequiredStepCompletion => + props.stepKey === ROBOT_CALIBRATION_STEP_KEY || + props.stepKey === MODULE_SETUP_KEY - // do not render calibration ready status icon for OT-2 module setup - return isFlex || - !( - stepKey === MODULE_SETUP_KEY && statusText === t('calibration_ready') - ) ? ( +function StepRightElement(props: StepRightElementProps): JSX.Element | null { + if (props.complete) { + return ( + + + + {props.completeText} + + + ) + } else if (stepRequiresHW(props) && props.missingHardware) { + return ( + + + + {props.missingHardwareText} + + + ) + } else if (props.incompleteText != null) { + return ( - {statusText} + {props.incompleteText} - ) : null - } else if (stepKey === LPC_KEY) { - return + ) + } else if (props.incompleteElement != null) { + return props.incompleteElement } else { return null } diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/SetupLabware.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/SetupLabware.test.tsx index d6a6ab4b05e..e92169bcb1d 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/SetupLabware.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/SetupLabware.test.tsx @@ -35,14 +35,17 @@ const ROBOT_NAME = 'otie' const RUN_ID = '1' const render = () => { + let labwareConfirmed = false + const confirmLabware = vi.fn(confirmed => { + labwareConfirmed = confirmed + }) return renderWithProviders( , { diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/index.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/index.tsx index 66b7bcdc1bc..526b944f425 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/index.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/index.tsx @@ -16,22 +16,18 @@ import { useModuleRenderInfoForProtocolById, useStoredProtocolAnalysis, } from '../../hooks' -import { BackToTopButton } from '../BackToTopButton' import { SetupLabwareMap } from './SetupLabwareMap' import { SetupLabwareList } from './SetupLabwareList' -import type { StepKey } from '../ProtocolRunSetup' - interface SetupLabwareProps { - protocolRunHeaderRef: React.RefObject | null robotName: string runId: string - nextStep: StepKey | null - expandStep: (step: StepKey) => void + labwareConfirmed: boolean + setLabwareConfirmed: (confirmed: boolean) => void } export function SetupLabware(props: SetupLabwareProps): JSX.Element { - const { robotName, runId, nextStep, expandStep, protocolRunHeaderRef } = props + const { robotName, runId, labwareConfirmed, setLabwareConfirmed } = props const { t } = useTranslation('protocol_setup') const robotProtocolAnalysis = useMostRecentCompletedAnalysis(runId) const storedProtocolAnalysis = useStoredProtocolAnalysis(runId) @@ -71,22 +67,14 @@ export function SetupLabware(props: SetupLabwareProps): JSX.Element { )} - {nextStep == null ? ( - - ) : ( - { - expandStep(nextStep) - }} - > - {t('proceed_to_liquid_setup_step')} - - )} + { + setLabwareConfirmed(true) + }} + disabled={labwareConfirmed} + > + {t('confirm_placements')} + ) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/__tests__/SetupLabwarePositionCheck.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/__tests__/SetupLabwarePositionCheck.test.tsx index 0bf4aaebbfc..0c0150937ad 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/__tests__/SetupLabwarePositionCheck.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/__tests__/SetupLabwarePositionCheck.test.tsx @@ -42,10 +42,15 @@ const ROBOT_NAME = 'otie' const RUN_ID = '1' const render = () => { + let areOffsetsConfirmed = false + const confirmOffsets = vi.fn((offsetsConfirmed: boolean) => { + areOffsetsConfirmed = offsetsConfirmed + }) return renderWithProviders( diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx index 66484717ef0..21862539e35 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx @@ -32,7 +32,8 @@ import { useNotifyRunQuery } from '../../../../resources/runs' import type { LabwareOffset } from '@opentrons/api-client' interface SetupLabwarePositionCheckProps { - expandLabwareStep: () => void + offsetsConfirmed: boolean + setOffsetsConfirmed: (confirmed: boolean) => void robotName: string runId: string } @@ -40,7 +41,7 @@ interface SetupLabwarePositionCheckProps { export function SetupLabwarePositionCheck( props: SetupLabwarePositionCheckProps ): JSX.Element { - const { robotName, runId, expandLabwareStep } = props + const { robotName, runId, setOffsetsConfirmed, offsetsConfirmed } = props const { t, i18n } = useTranslation('protocol_setup') const robotType = useRobotType(robotName) @@ -75,7 +76,13 @@ export function SetupLabwarePositionCheck( const robotProtocolAnalysis = useMostRecentCompletedAnalysis(runId) const storedProtocolAnalysis = useStoredProtocolAnalysis(runId) const protocolData = robotProtocolAnalysis ?? storedProtocolAnalysis - const [targetProps, tooltipProps] = useHoverTooltip({ + const [runLPCTargetProps, runLPCTooltipProps] = useHoverTooltip({ + placement: TOOLTIP_LEFT, + }) + const [ + confirmOffsetsTargetProps, + confirmOffsetsTooltipProps, + ] = useHoverTooltip({ placement: TOOLTIP_LEFT, }) @@ -114,6 +121,22 @@ export function SetupLabwarePositionCheck( )} { + setOffsetsConfirmed(true) + }} + id="LPC_setOffsetsConfirmed" + padding={`${SPACING.spacing8} ${SPACING.spacing16}`} + {...confirmOffsetsTargetProps} + disabled={offsetsConfirmed || lpcDisabledReason !== null} + > + {t('confirm_offsets')} + + {lpcDisabledReason !== null ? ( + + {lpcDisabledReason} + + ) : null} + { @@ -121,21 +144,16 @@ export function SetupLabwarePositionCheck( setIsShowingLPCSuccessToast(false) }} id="LabwareSetup_checkLabwarePositionsButton" - {...targetProps} + {...runLPCTargetProps} disabled={lpcDisabledReason !== null} > {t('run_labware_position_check')} - + {lpcDisabledReason !== null ? ( - {lpcDisabledReason} + + {lpcDisabledReason} + ) : null} - - {t('proceed_to_labware_setup_step')} - {LPCWizard} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/__tests__/SetupLiquids.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/__tests__/SetupLiquids.test.tsx index 1c3dc33181e..06e48c49738 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/__tests__/SetupLiquids.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/__tests__/SetupLiquids.test.tsx @@ -7,27 +7,35 @@ import { i18n } from '../../../../../i18n' import { SetupLiquids } from '../index' import { SetupLiquidsList } from '../SetupLiquidsList' import { SetupLiquidsMap } from '../SetupLiquidsMap' -import { BackToTopButton } from '../../BackToTopButton' vi.mock('../SetupLiquidsList') vi.mock('../SetupLiquidsMap') -vi.mock('../../BackToTopButton') -const render = (props: React.ComponentProps) => { - return renderWithProviders( - , - { - i18nInstance: i18n, +describe('SetupLiquids', () => { + const render = ( + props: React.ComponentProps & { + startConfirmed?: boolean } - ) -} + ) => { + let isConfirmed = + props?.startConfirmed == null ? false : props.startConfirmed + const confirmFn = vi.fn((confirmed: boolean) => { + isConfirmed = confirmed + }) + return renderWithProviders( + , + { + i18nInstance: i18n, + } + ) + } -describe('SetupLiquids', () => { let props: React.ComponentProps beforeEach(() => { vi.mocked(SetupLiquidsList).mockReturnValue( @@ -36,16 +44,13 @@ describe('SetupLiquids', () => { vi.mocked(SetupLiquidsMap).mockReturnValue(
Mock setup liquids map
) - vi.mocked(BackToTopButton).mockReturnValue( - - ) }) it('renders the list and map view buttons and proceed button', () => { render(props) screen.getByRole('button', { name: 'List View' }) screen.getByRole('button', { name: 'Map View' }) - screen.getByRole('button', { name: 'Mock BackToTopButton' }) + screen.getByRole('button', { name: 'Confirm placements' }) }) it('renders the map view when you press that toggle button', () => { render(props) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/index.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/index.tsx index daa2a7e114f..243bfeb3ed6 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/index.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/index.tsx @@ -6,10 +6,10 @@ import { SPACING, DIRECTION_COLUMN, ALIGN_CENTER, + PrimaryButton, } from '@opentrons/components' import { useToggleGroup } from '../../../../molecules/ToggleGroup/useToggleGroup' import { ANALYTICS_LIQUID_SETUP_VIEW_TOGGLE } from '../../../../redux/analytics' -import { BackToTopButton } from '../BackToTopButton' import { SetupLiquidsList } from './SetupLiquidsList' import { SetupLiquidsMap } from './SetupLiquidsMap' @@ -19,17 +19,19 @@ import type { } from '@opentrons/shared-data' interface SetupLiquidsProps { - protocolRunHeaderRef: React.RefObject | null - robotName: string runId: string protocolAnalysis: CompletedProtocolAnalysis | ProtocolAnalysisOutput | null + isLiquidSetupConfirmed: boolean + setLiquidSetupConfirmed: (confirmed: boolean) => void + robotName: string } export function SetupLiquids({ - protocolRunHeaderRef, - robotName, runId, protocolAnalysis, + isLiquidSetupConfirmed, + setLiquidSetupConfirmed, + robotName, }: SetupLiquidsProps): JSX.Element { const { t } = useTranslation('protocol_setup') const [selectedValue, toggleGroup] = useToggleGroup( @@ -51,12 +53,14 @@ export function SetupLiquids({ )} - + { + setLiquidSetupConfirmed(true) + }} + disabled={isLiquidSetupConfirmed} + > + {t('confirm_placements')} + ) diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx index 70b16c61b55..872dff5771f 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx @@ -97,7 +97,9 @@ import { ProtocolDropTipModal, useProtocolDropTipModal, } from '../ProtocolDropTipModal' +import { ConfirmMissingStepsModal } from '../ConfirmMissingStepsModal' +import type { MissingSteps } from '../ProtocolRunSetup' import type { UseQueryResult } from 'react-query' import type { NavigateFunction } from 'react-router-dom' import type { Mock } from 'vitest' @@ -153,6 +155,7 @@ vi.mock('../../../ProtocolUpload/hooks/useMostRecentRunId') vi.mock('../../../../resources/runs') vi.mock('../../../ErrorRecoveryFlows') vi.mock('../ProtocolDropTipModal') +vi.mock('../ConfirmMissingStepsModal') const ROBOT_NAME = 'otie' const RUN_ID = '95e67900-bc9f-4fbf-92c6-cc4d7226a51b' @@ -215,6 +218,7 @@ const mockDoorStatus = { doorRequiredClosedForProtocol: true, }, } +let mockMissingSteps: MissingSteps = [] const render = () => { return renderWithProviders( @@ -224,6 +228,7 @@ const render = () => { robotName={ROBOT_NAME} runId={RUN_ID} makeHandleJumpToStep={vi.fn(() => vi.fn())} + missingSetupSteps={mockMissingSteps} /> , { i18nInstance: i18n } @@ -240,7 +245,7 @@ describe('ProtocolRunHeader', () => { mockTrackProtocolRunEvent = vi.fn(() => new Promise(resolve => resolve({}))) mockCloseCurrentRun = vi.fn() mockDetermineTipStatus = vi.fn() - + mockMissingSteps = [] vi.mocked(useTrackEvent).mockReturnValue(mockTrackEvent) vi.mocked(ConfirmCancelModal).mockReturnValue(
Mock ConfirmCancelModal
@@ -267,6 +272,9 @@ describe('ProtocolRunHeader', () => { vi.mocked(ConfirmAttachmentModal).mockReturnValue(
mock confirm attachment modal
) + vi.mocked(ConfirmMissingStepsModal).mockReturnValue( +
mock missing steps modal
+ ) when(vi.mocked(useProtocolAnalysisErrors)).calledWith(RUN_ID).thenReturn({ analysisErrors: null, }) diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx index 89238cbaa01..e4fbc00e234 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx @@ -40,6 +40,7 @@ import { SetupLiquids } from '../SetupLiquids' import { SetupModuleAndDeck } from '../SetupModuleAndDeck' import { EmptySetupStep } from '../EmptySetupStep' import { ProtocolRunSetup } from '../ProtocolRunSetup' +import type { MissingSteps } from '../ProtocolRunSetup' import { useNotifyRunQuery } from '../../../../resources/runs' import type * as SharedData from '@opentrons/shared-data' @@ -68,12 +69,18 @@ vi.mock('@opentrons/shared-data', async importOriginal => { const ROBOT_NAME = 'otie' const RUN_ID = '1' const MOCK_PROTOCOL_LIQUID_KEY = { liquids: [] } +let mockMissingSteps: MissingSteps = [] +const mockSetMissingSteps = vi.fn((missingSteps: MissingSteps) => { + mockMissingSteps = missingSteps +}) const render = () => { return renderWithProviders( , { i18nInstance: i18n, @@ -83,6 +90,7 @@ const render = () => { describe('ProtocolRunSetup', () => { beforeEach(() => { + mockMissingSteps = [] when(vi.mocked(useIsFlex)).calledWith(ROBOT_NAME).thenReturn(false) when(vi.mocked(useMostRecentCompletedAnalysis)) .calledWith(RUN_ID) @@ -121,7 +129,6 @@ describe('ProtocolRunSetup', () => { when(vi.mocked(SetupLabware)) .calledWith( expect.objectContaining({ - protocolRunHeaderRef: null, robotName: ROBOT_NAME, runId: RUN_ID, }), @@ -146,6 +153,9 @@ describe('ProtocolRunSetup', () => { when(vi.mocked(useRunPipetteInfoByMount)) .calledWith(RUN_ID) .thenReturn({ left: null, right: null }) + when(vi.mocked(useModuleCalibrationStatus)) + .calledWith(ROBOT_NAME, RUN_ID) + .thenReturn({ complete: true }) }) afterEach(() => { vi.resetAllMocks() @@ -181,13 +191,6 @@ describe('ProtocolRunSetup', () => { screen.getByText('Calibration needed') }) - it('does not render calibration status when run has started', () => { - when(vi.mocked(useRunHasStarted)).calledWith(RUN_ID).thenReturn(true) - render() - expect(screen.queryByText('Calibration needed')).toBeNull() - expect(screen.queryByText('Calibration ready')).toBeNull() - }) - describe('when no modules are in the protocol', () => { it('renders robot calibration setup for OT-2', () => { render() @@ -426,10 +429,6 @@ describe('ProtocolRunSetup', () => { when(vi.mocked(useRunHasStarted)).calledWith(RUN_ID).thenReturn(true) render() - await new Promise(resolve => setTimeout(resolve, 1000)) - expect(screen.getByText('Mock SetupRobotCalibration')).not.toBeVisible() - expect(screen.getByText('Mock SetupModules')).not.toBeVisible() - expect(screen.getByText('Mock SetupLabware')).not.toBeVisible() screen.getByText('Setup is view-only once run has started') }) diff --git a/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx b/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx index 8182a8b73b3..0edc5a1ad1a 100644 --- a/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx +++ b/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx @@ -52,11 +52,17 @@ const mockRefetch = vi.fn() const mockCreateLiveCommand = vi.fn() const render = () => { + let confirmed = false + const setIsConfirmed = vi.fn((ready: boolean) => { + confirmed = ready + }) return renderWithProviders( , { diff --git a/app/src/organisms/ProtocolSetupLabware/index.tsx b/app/src/organisms/ProtocolSetupLabware/index.tsx index fa4d3926fdb..1210c1887df 100644 --- a/app/src/organisms/ProtocolSetupLabware/index.tsx +++ b/app/src/organisms/ProtocolSetupLabware/index.tsx @@ -22,6 +22,7 @@ import { SPACING, LegacyStyledText, TYPOGRAPHY, + Chip, } from '@opentrons/components' import { FLEX_ROBOT_TYPE, @@ -37,7 +38,7 @@ import { useModulesQuery, } from '@opentrons/react-api-client' -import { FloatingActionButton } from '../../atoms/buttons' +import { FloatingActionButton, SmallButton } from '../../atoms/buttons' import { ODDBackButton } from '../../molecules/ODDBackButton' import { getTopPortalEl } from '../../App/portal' import { Modal } from '../../molecules/Modal' @@ -77,11 +78,15 @@ const LabwareThumbnail = styled.svg` export interface ProtocolSetupLabwareProps { runId: string setSetupScreen: React.Dispatch> + isConfirmed: boolean + setIsConfirmed: (confirmed: boolean) => void } export function ProtocolSetupLabware({ runId, setSetupScreen, + isConfirmed, + setIsConfirmed, }: ProtocolSetupLabwareProps): JSX.Element { const { t } = useTranslation('protocol_setup') const [showMapView, setShowMapView] = React.useState(false) @@ -247,12 +252,34 @@ export function ProtocolSetupLabware({ , getTopPortalEl() )} - { - setSetupScreen('prepare to run') - }} - /> + + { + setSetupScreen('prepare to run') + }} + /> + {isConfirmed ? ( + + ) : ( + { + setIsConfirmed(true) + setSetupScreen('prepare to run') + }} + /> + )} + ) => { - return renderWithProviders(, { - i18nInstance: i18n, +describe('ProtocolSetupLiquids', () => { + let isConfirmed = false + const setIsConfirmed = vi.fn((confirmed: boolean) => { + isConfirmed = confirmed }) -} -describe('ProtocolSetupLiquids', () => { + const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) + } + let props: React.ComponentProps beforeEach(() => { - props = { runId: RUN_ID_1, setSetupScreen: vi.fn() } + props = { + runId: RUN_ID_1, + setSetupScreen: vi.fn(), + isConfirmed, + setIsConfirmed, + } vi.mocked(parseLiquidsInLoadOrder).mockReturnValue( MOCK_LIQUIDS_IN_LOAD_ORDER ) diff --git a/app/src/organisms/ProtocolSetupLiquids/index.tsx b/app/src/organisms/ProtocolSetupLiquids/index.tsx index 1fb10cdb79d..883054c6963 100644 --- a/app/src/organisms/ProtocolSetupLiquids/index.tsx +++ b/app/src/organisms/ProtocolSetupLiquids/index.tsx @@ -5,6 +5,7 @@ import { BORDERS, COLORS, DIRECTION_COLUMN, + DIRECTION_ROW, Flex, Icon, JUSTIFY_FLEX_END, @@ -12,6 +13,7 @@ import { StyledText, TYPOGRAPHY, JUSTIFY_SPACE_BETWEEN, + Chip, } from '@opentrons/components' import { parseLiquidsInLoadOrder, @@ -19,6 +21,8 @@ import { } from '@opentrons/api-client' import { MICRO_LITERS } from '@opentrons/shared-data' import { ODDBackButton } from '../../molecules/ODDBackButton' +import { SmallButton } from '../../atoms/buttons' + import { useMostRecentCompletedAnalysis } from '../LabwarePositionCheck/useMostRecentCompletedAnalysis' import { getTotalVolumePerLiquidId } from '../Devices/ProtocolRun/SetupLiquids/utils' import { LiquidDetails } from './LiquidDetails' @@ -29,13 +33,17 @@ import type { SetupScreens } from '../../pages/ProtocolSetup' export interface ProtocolSetupLiquidsProps { runId: string setSetupScreen: React.Dispatch> + isConfirmed: boolean + setIsConfirmed: (confirmed: boolean) => void } export function ProtocolSetupLiquids({ runId, setSetupScreen, + isConfirmed, + setIsConfirmed, }: ProtocolSetupLiquidsProps): JSX.Element { - const { t } = useTranslation('protocol_setup') + const { t, i18n } = useTranslation('protocol_setup') const protocolData = useMostRecentCompletedAnalysis(runId) const liquidsInLoadOrder = parseLiquidsInLoadOrder( protocolData?.liquids ?? [], @@ -43,12 +51,34 @@ export function ProtocolSetupLiquids({ ) return ( <> - { - setSetupScreen('prepare to run') - }} - /> + + { + setSetupScreen('prepare to run') + }} + /> + {isConfirmed ? ( + + ) : ( + { + setIsConfirmed(true) + setSetupScreen('prepare to run') + }} + /> + )} + > + lpcDisabledReason: string | null + launchLPC: () => void + LPCWizard: JSX.Element | null + isConfirmed: boolean + setIsConfirmed: (confirmed: boolean) => void +} + +export function ProtocolSetupOffsets({ + runId, + setSetupScreen, + isConfirmed, + setIsConfirmed, + launchLPC, + lpcDisabledReason, + LPCWizard, +}: ProtocolSetupOffsetsProps): JSX.Element { + const { t } = useTranslation('protocol_setup') + const { makeSnackbar } = useToaster() + const mostRecentAnalysis = useMostRecentCompletedAnalysis(runId) + const makeDisabledReasonSnackbar = (): void => { + if (lpcDisabledReason != null) { + makeSnackbar(lpcDisabledReason) + } + } + + const labwareDefinitions = getLabwareDefinitionsFromCommands( + mostRecentAnalysis?.commands ?? [] + ) + const { data: runRecord } = useNotifyRunQuery(runId, { staleTime: Infinity }) + const currentOffsets = runRecord?.data?.labwareOffsets ?? [] + const sortedOffsets: LabwareOffset[] = + currentOffsets.length > 0 + ? currentOffsets + .map(offset => ({ + ...offset, + // convert into date to sort + createdAt: new Date(offset.createdAt), + })) + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + .map(offset => ({ + ...offset, + // convert back into string + createdAt: offset.createdAt.toISOString(), + })) + : [] + const nonIdentityOffsets = getLatestCurrentOffsets(sortedOffsets) + return ( + <> + {LPCWizard} + {LPCWizard == null && ( + <> + + { + setSetupScreen('prepare to run') + }} + /> + {isConfirmed ? ( + + ) : ( + { + setIsConfirmed(true) + setSetupScreen('prepare to run') + }} + /> + )} + + + { + if (lpcDisabledReason != null) { + makeDisabledReasonSnackbar() + } else { + launchLPC() + } + }} + /> + + )} + + ) +} diff --git a/app/src/pages/Devices/ProtocolRunDetails/index.tsx b/app/src/pages/Devices/ProtocolRunDetails/index.tsx index 2935bc86100..62798b55b4f 100644 --- a/app/src/pages/Devices/ProtocolRunDetails/index.tsx +++ b/app/src/pages/Devices/ProtocolRunDetails/index.tsx @@ -10,6 +10,8 @@ import { Box, COLORS, DIRECTION_COLUMN, + DIRECTION_ROW, + JUSTIFY_SPACE_AROUND, Flex, LegacyStyledText, OVERFLOW_SCROLL, @@ -29,7 +31,11 @@ import { } from '../../../organisms/Devices/hooks' import { ProtocolRunHeader } from '../../../organisms/Devices/ProtocolRun/ProtocolRunHeader' import { RunPreview } from '../../../organisms/RunPreview' -import { ProtocolRunSetup } from '../../../organisms/Devices/ProtocolRun/ProtocolRunSetup' +import { + ProtocolRunSetup, + initialMissingSteps, +} from '../../../organisms/Devices/ProtocolRun/ProtocolRunSetup' +import { BackToTopButton } from '../../../organisms/Devices/ProtocolRun/BackToTopButton' import { ProtocolRunModuleControls } from '../../../organisms/Devices/ProtocolRun/ProtocolRunModuleControls' import { ProtocolRunRuntimeParameters } from '../../../organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters' import { useCurrentRunId } from '../../../resources/runs' @@ -134,7 +140,6 @@ export function ProtocolRunDetails(): JSX.Element | null { React.useEffect(() => { dispatch(fetchProtocols()) }, [dispatch]) - return robot != null ? ( + >(initialMissingSteps()) + const makeHandleScrollToStep = (i: number) => () => { listRef.current?.scrollToIndex(i, true, -1 * JUMP_OFFSET_FROM_TOP_PX) } @@ -193,37 +202,68 @@ function PageContents(props: PageContentsProps): JSX.Element { setJumpedIndex(i) } const protocolRunDetailsContentByTab: { - [K in ProtocolRunDetailsTab]: JSX.Element | null + [K in ProtocolRunDetailsTab]: { + content: JSX.Element | null + backToTop: JSX.Element | null + } } = { - setup: ( - - ), - 'runtime-parameters': , - 'module-controls': ( - - ), - 'run-preview': ( - - ), + setup: { + content: ( + + ), + backToTop: ( + + + + ), + }, + 'runtime-parameters': { + content: , + backToTop: null, + }, + 'module-controls': { + content: ( + + ), + backToTop: null, + }, + 'run-preview': { + content: ( + + ), + backToTop: null, + }, } - - const protocolRunDetailsContent = protocolRunDetailsContentByTab[ - protocolRunDetailsTab - ] ?? ( + const tabDetails = protocolRunDetailsContentByTab[protocolRunDetailsTab] ?? { // default to the setup tab if no tab or nonexistent tab is passed as a param - - - ) + content: ( + + ), + backToTop: null, + } + const { content, backToTop } = tabDetails return ( <> @@ -232,6 +272,7 @@ function PageContents(props: PageContentsProps): JSX.Element { robotName={robotName} runId={runId} makeHandleJumpToStep={makeHandleJumpToStep} + missingSetupSteps={missingSteps} /> - {protocolRunDetailsContent} + {content} + {backToTop} ) } diff --git a/app/src/pages/ProtocolSetup/ConfirmSetupStepsCompleteModal.tsx b/app/src/pages/ProtocolSetup/ConfirmSetupStepsCompleteModal.tsx new file mode 100644 index 00000000000..1757704e597 --- /dev/null +++ b/app/src/pages/ProtocolSetup/ConfirmSetupStepsCompleteModal.tsx @@ -0,0 +1,68 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' + +import { + DIRECTION_COLUMN, + Flex, + SPACING, + LegacyStyledText, +} from '@opentrons/components' + +import { SmallButton } from '../../atoms/buttons' +import { Modal } from '../../molecules/Modal' + +import type { ModalHeaderBaseProps } from '../../molecules/Modal/types' + +interface ConfirmSetupStepsCompleteModalProps { + onCloseClick: () => void + onConfirmClick: () => void + missingSteps: string[] +} + +export function ConfirmSetupStepsCompleteModal({ + onCloseClick, + missingSteps, + onConfirmClick, +}: ConfirmSetupStepsCompleteModalProps): JSX.Element { + const { i18n, t } = useTranslation(['protocol_setup', 'shared']) + const modalHeader: ModalHeaderBaseProps = { + title: t('are_you_sure_you_want_to_proceed'), + hasExitIcon: true, + } + + const handleStartRun = (): void => { + onConfirmClick() + onCloseClick() + } + + return ( + + + + {t('you_havent_confirmed', { + missingSteps: new Intl.ListFormat('en', { + style: 'short', + type: 'conjunction', + }).format(missingSteps), + })} + + + { + onCloseClick() + }} + /> + + + + + ) +} diff --git a/app/src/pages/ProtocolSetup/__tests__/ProtocolSetup.test.tsx b/app/src/pages/ProtocolSetup/__tests__/ProtocolSetup.test.tsx index 1be58ae82f8..5479f4693bd 100644 --- a/app/src/pages/ProtocolSetup/__tests__/ProtocolSetup.test.tsx +++ b/app/src/pages/ProtocolSetup/__tests__/ProtocolSetup.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { Route, MemoryRouter, Routes } from 'react-router-dom' import { fireEvent, screen } from '@testing-library/react' import { when } from 'vitest-when' -import { vi, it, describe, expect, beforeEach, afterEach } from 'vitest' +import { vi, it, describe, expect, beforeEach } from 'vitest' import { RUN_STATUS_IDLE, RUN_STATUS_STOPPED } from '@opentrons/api-client' import { @@ -39,10 +39,13 @@ import { ANALYTICS_PROTOCOL_RUN_ACTION } from '../../../redux/analytics' import { ProtocolSetupLiquids } from '../../../organisms/ProtocolSetupLiquids' import { getProtocolModulesInfo } from '../../../organisms/Devices/ProtocolRun/utils/getProtocolModulesInfo' import { ProtocolSetupModulesAndDeck } from '../../../organisms/ProtocolSetupModulesAndDeck' +import { ProtocolSetupLabware } from '../../../organisms/ProtocolSetupLabware' +import { ProtocolSetupOffsets } from '../../../organisms/ProtocolSetupOffsets' import { getUnmatchedModulesForProtocol } from '../../../organisms/ProtocolSetupModulesAndDeck/utils' import { useLaunchLPC } from '../../../organisms/LabwarePositionCheck/useLaunchLPC' import { ConfirmCancelRunModal } from '../../../organisms/OnDeviceDisplay/RunningProtocol' import { mockProtocolModuleInfo } from '../../../organisms/ProtocolSetupInstruments/__fixtures__' +import { getIncompleteInstrumentCount } from '../../../organisms/ProtocolSetupInstruments/utils' import { useProtocolHasRunTimeParameters, useRunControls, @@ -51,6 +54,7 @@ import { import { useIsHeaterShakerInProtocol } from '../../../organisms/ModuleCard/hooks' import { useDeckConfigurationCompatibility } from '../../../resources/deck_configuration/hooks' import { ConfirmAttachedModal } from '../../../pages/ProtocolSetup/ConfirmAttachedModal' +import { ConfirmSetupStepsCompleteModal } from '../../../pages/ProtocolSetup/ConfirmSetupStepsCompleteModal' import { ProtocolSetup } from '../../../pages/ProtocolSetup' import { useNotifyRunQuery } from '../../../resources/runs' import { ViewOnlyParameters } from '../../../organisms/ProtocolSetupParameters/ViewOnlyParameters' @@ -99,12 +103,15 @@ vi.mock('../../../organisms/ProtocolSetupParameters/ViewOnlyParameters') vi.mock( '../../../organisms/LabwarePositionCheck/useMostRecentCompletedAnalysis' ) +vi.mock('../../../organisms/ProtocolSetupInstruments/utils') vi.mock('../../../organisms/Devices/ProtocolRun/utils/getProtocolModulesInfo') vi.mock('../../../organisms/ProtocolSetupModulesAndDeck') vi.mock('../../../organisms/ProtocolSetupModulesAndDeck/utils') vi.mock('../../../organisms/OnDeviceDisplay/RunningProtocol') vi.mock('../../../organisms/RunTimeControl/hooks') vi.mock('../../../organisms/ProtocolSetupLiquids') +vi.mock('../../../organisms/ProtocolSetupLabware') +vi.mock('../../../organisms/ProtocolSetupOffsets') vi.mock('../../../organisms/ModuleCard/hooks') vi.mock('../../../redux/discovery/selectors') vi.mock('../ConfirmAttachedModal') @@ -112,6 +119,7 @@ vi.mock('../../../organisms/ToasterOven') vi.mock('../../../resources/deck_configuration/hooks') vi.mock('../../../resources/runs') vi.mock('../../../resources/deck_configuration') +vi.mock('../ConfirmSetupStepsCompleteModal') const render = (path = '/') => { return renderWithProviders( @@ -126,6 +134,12 @@ const render = (path = '/') => { ) } +const MockProtocolSetupLabware = vi.mocked(ProtocolSetupLabware) +const MockProtocolSetupLiquids = vi.mocked(ProtocolSetupLiquids) +const MockProtocolSetupOffsets = vi.mocked(ProtocolSetupOffsets) +const MockConfirmSetupStepsCompleteModal = vi.mocked( + ConfirmSetupStepsCompleteModal +) const ROBOT_NAME = 'fake-robot-name' const RUN_ID = 'my-run-id' const ROBOT_SERIAL_NUMBER = 'OT123' @@ -192,6 +206,30 @@ describe('ProtocolSetup', () => { beforeEach(() => { mockLaunchLPC = vi.fn() mockNavigate = vi.fn() + MockProtocolSetupLiquids.mockImplementation( + vi.fn(({ setIsConfirmed, setSetupScreen }) => { + setIsConfirmed(true) + setSetupScreen('prepare to run') + return
Mock ProtocolSetupLiquids
+ }) + ) + MockProtocolSetupLabware.mockImplementation( + vi.fn(({ setIsConfirmed, setSetupScreen }) => { + setIsConfirmed(true) + setSetupScreen('prepare to run') + return
Mock ProtocolSetupLabware
+ }) + ) + MockProtocolSetupOffsets.mockImplementation( + vi.fn(({ setIsConfirmed, setSetupScreen }) => { + setIsConfirmed(true) + setSetupScreen('prepare to run') + return
Mock ProtocolSetupOffsets
+ }) + ) + MockConfirmSetupStepsCompleteModal.mockReturnValue( +
Mock ConfirmSetupStepsCompleteModal
+ ) vi.mocked(useLPCDisabledReason).mockReturnValue(null) vi.mocked(useAttachedModules).mockReturnValue([]) vi.mocked(useModuleCalibrationStatus).mockReturnValue({ complete: true }) @@ -290,10 +328,6 @@ describe('ProtocolSetup', () => { .thenReturn({ trackProtocolRunEvent: mockTrackProtocolRunEvent }) }) - afterEach(() => { - vi.resetAllMocks() - }) - it('should render text, image, and buttons', () => { render(`/runs/${RUN_ID}/setup/`) screen.getByText('Prepare to run') @@ -305,9 +339,47 @@ describe('ProtocolSetup', () => { }) it('should play protocol when click play button', () => { + vi.mocked(useProtocolAnalysisAsDocumentQuery).mockReturnValue({ + data: { ...mockRobotSideAnalysis, liquids: mockLiquids }, + } as any) + when(vi.mocked(getProtocolModulesInfo)) + .calledWith( + { ...mockRobotSideAnalysis, liquids: mockLiquids }, + flexDeckDefV5 as any + ) + .thenReturn(mockProtocolModuleInfo) + when(vi.mocked(getUnmatchedModulesForProtocol)) + .calledWith([], mockProtocolModuleInfo) + .thenReturn({ missingModuleIds: [], remainingAttachedModules: [] }) + vi.mocked(getIncompleteInstrumentCount).mockReturnValue(0) + MockProtocolSetupLiquids.mockImplementation( + vi.fn(({ setIsConfirmed, setSetupScreen }) => { + setIsConfirmed(true) + setSetupScreen('prepare to run') + return
Mock ProtocolSetupLiquids
+ }) + ) + MockProtocolSetupLabware.mockImplementation( + vi.fn(({ setIsConfirmed, setSetupScreen }) => { + setIsConfirmed(true) + setSetupScreen('prepare to run') + return
Mock ProtocolSetupLabware
+ }) + ) + MockProtocolSetupOffsets.mockImplementation( + vi.fn(({ setIsConfirmed, setSetupScreen }) => { + setIsConfirmed(true) + setSetupScreen('prepare to run') + return
Mock ProtocolSetupOffsets
+ }) + ) render(`/runs/${RUN_ID}/setup/`) + fireEvent.click(screen.getByText('Labware Position Check')) + fireEvent.click(screen.getByText('Labware')) + fireEvent.click(screen.getByText('Liquids')) expect(mockPlay).toBeCalledTimes(0) fireEvent.click(screen.getByRole('button', { name: 'play' })) + expect(MockConfirmSetupStepsCompleteModal).toBeCalledTimes(0) expect(mockPlay).toBeCalledTimes(1) }) @@ -348,7 +420,25 @@ describe('ProtocolSetup', () => { render(`/runs/${RUN_ID}/setup/`) screen.getByText('1 initial liquid') fireEvent.click(screen.getByText('Liquids')) - expect(vi.mocked(ProtocolSetupLiquids)).toHaveBeenCalled() + expect(MockProtocolSetupLiquids).toHaveBeenCalled() + }) + + it('should launch protocol setup labware screen when click labware', () => { + vi.mocked(useProtocolAnalysisAsDocumentQuery).mockReturnValue({ + data: { ...mockRobotSideAnalysis, liquids: mockLiquids }, + } as any) + when(vi.mocked(getProtocolModulesInfo)) + .calledWith( + { ...mockRobotSideAnalysis, liquids: mockLiquids }, + flexDeckDefV5 as any + ) + .thenReturn(mockProtocolModuleInfo) + when(vi.mocked(getUnmatchedModulesForProtocol)) + .calledWith([], mockProtocolModuleInfo) + .thenReturn({ missingModuleIds: [], remainingAttachedModules: [] }) + render(`/runs/${RUN_ID}/setup`) + fireEvent.click(screen.getByTestId('SetupButton_Labware')) + expect(MockProtocolSetupLabware).toHaveBeenCalled() }) it('should launch view only parameters screen when click parameters', () => { @@ -376,14 +466,14 @@ describe('ProtocolSetup', () => { expect(vi.mocked(ViewOnlyParameters)).toHaveBeenCalled() }) - it('should launch LPC when clicked', () => { - vi.mocked(useLPCDisabledReason).mockReturnValue(null) + it('should launch offsets screen when click offsets', () => { + MockProtocolSetupOffsets.mockImplementation( + vi.fn(() =>
Mock ProtocolSetupOffsets
) + ) render(`/runs/${RUN_ID}/setup/`) - screen.getByText(/Recommended/) - screen.getByText(/1 offset applied/) fireEvent.click(screen.getByText('Labware Position Check')) - expect(mockLaunchLPC).toHaveBeenCalled() - screen.getByText('mock LPC Wizard') + expect(MockProtocolSetupOffsets).toHaveBeenCalled() + screen.getByText(/Mock ProtocolSetupOffsets/) }) it('should render a confirmation modal when heater-shaker is in a protocol and it is not shaking', () => { @@ -416,7 +506,21 @@ describe('ProtocolSetup', () => { }) it('calls trackProtocolRunEvent when tapping play button', () => { + vi.mocked(useProtocolAnalysisAsDocumentQuery).mockReturnValue({ + data: { ...mockRobotSideAnalysis, liquids: mockLiquids }, + } as any) + when(vi.mocked(getProtocolModulesInfo)) + .calledWith( + { ...mockRobotSideAnalysis, liquids: mockLiquids }, + flexDeckDefV5 as any + ) + .thenReturn(mockProtocolModuleInfo) + when(vi.mocked(getUnmatchedModulesForProtocol)) + .calledWith([], mockProtocolModuleInfo) + .thenReturn({ missingModuleIds: [], remainingAttachedModules: [] }) + vi.mocked(getIncompleteInstrumentCount).mockReturnValue(0) render(`/runs/${RUN_ID}/setup/`) + fireEvent.click(screen.getByRole('button', { name: 'play' })) expect(mockTrackProtocolRunEvent).toBeCalledTimes(1) expect(mockTrackProtocolRunEvent).toHaveBeenCalledWith({ diff --git a/app/src/pages/ProtocolSetup/index.tsx b/app/src/pages/ProtocolSetup/index.tsx index 36ce4220bcb..f152b0cc44a 100644 --- a/app/src/pages/ProtocolSetup/index.tsx +++ b/app/src/pages/ProtocolSetup/index.tsx @@ -60,6 +60,7 @@ import { getProtocolModulesInfo } from '../../organisms/Devices/ProtocolRun/util import { ProtocolSetupLabware } from '../../organisms/ProtocolSetupLabware' import { ProtocolSetupModulesAndDeck } from '../../organisms/ProtocolSetupModulesAndDeck' import { ProtocolSetupLiquids } from '../../organisms/ProtocolSetupLiquids' +import { ProtocolSetupOffsets } from '../../organisms/ProtocolSetupOffsets' import { ProtocolSetupInstruments } from '../../organisms/ProtocolSetupInstruments' import { ProtocolSetupDeckConfiguration } from '../../organisms/ProtocolSetupDeckConfiguration' import { useLaunchLPC } from '../../organisms/LabwarePositionCheck/useLaunchLPC' @@ -85,6 +86,7 @@ import { } from '../../redux/analytics' import { getIsHeaterShakerAttached } from '../../redux/config' import { ConfirmAttachedModal } from './ConfirmAttachedModal' +import { ConfirmSetupStepsCompleteModal } from './ConfirmSetupStepsCompleteModal' import { getLatestCurrentOffsets } from '../../organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/utils' import { CloseButton, PlayButton } from './Buttons' import { useDeckConfigurationCompatibility } from '../../resources/deck_configuration/hooks' @@ -118,6 +120,8 @@ interface ProtocolSetupStepProps { subDetail?: string | null // disallow click handler, disabled styling disabled?: boolean + // disallow click handler, don't show CTA icons, allow styling + interactionDisabled?: boolean // display the reason the setup step is disabled disabledReason?: string | null // optional description @@ -137,12 +141,14 @@ export function ProtocolSetupStep({ detail, subDetail, disabled = false, + interactionDisabled = false, disabledReason, description, hasRightIcon = true, hasLeftIcon = true, fontSize = 'p', }: ProtocolSetupStepProps): JSX.Element { + const isInteractionDisabled = interactionDisabled || disabled const backgroundColorByStepStatus = { ready: COLORS.green35, 'not ready': COLORS.yellow35, @@ -185,9 +191,12 @@ export function ProtocolSetupStep({ return ( { - !disabled ? onClickSetupStep() : makeDisabledReasonSnackbar() + !isInteractionDisabled + ? onClickSetupStep() + : makeDisabledReasonSnackbar() }} width="100%" + data-testid={`SetupButton_${title}`} > {detail} @@ -249,7 +257,7 @@ export function ProtocolSetupStep({ {subDetail} - {disabled || !hasRightIcon ? null : ( + {interactionDisabled || !hasRightIcon ? null : ( > confirmAttachment: () => void + confirmStepsComplete: () => void play: () => void robotName: string runRecord: Run | null + labwareConfirmed: boolean + liquidsConfirmed: boolean + offsetsConfirmed: boolean } function PrepareToRun({ @@ -280,6 +292,10 @@ function PrepareToRun({ play, robotName, runRecord, + labwareConfirmed, + liquidsConfirmed, + offsetsConfirmed, + confirmStepsComplete, }: PrepareToRunProps): JSX.Element { const { t, i18n } = useTranslation(['protocol_setup', 'shared']) const navigate = useNavigate() @@ -335,7 +351,6 @@ function PrepareToRun({ }, [mostRecentAnalysis?.status]) const robotType = useRobotType(robotName) - const { launchLPC, LPCWizard } = useLaunchLPC(runId, robotType, protocolName) const onConfirmCancelClose = (): void => { setShowConfirmCancelModal(false) @@ -381,12 +396,7 @@ function PrepareToRun({ : null const isMissingModules = missingModuleIds.length > 0 - const lpcDisabledReason = useLPCDisabledReason({ - runId, - hasMissingModulesForOdd: isMissingModules, - hasMissingCalForOdd: - incompleteInstrumentCount != null && incompleteInstrumentCount > 0, - }) + const moduleCalibrationStatus = useModuleCalibrationStatus(robotName, runId) const runTimeParameters = mostRecentAnalysis?.runTimeParameters ?? [] @@ -510,24 +520,25 @@ function PrepareToRun({ if (isDoorOpen) { makeSnackbar(t('shared:close_robot_door') as string) } else { - if ( - isHeaterShakerInProtocol && - isReadyToRun && - runStatus === RUN_STATUS_IDLE - ) { - confirmAttachment() - } else { - if (isReadyToRun) { + if (isReadyToRun) { + if (runStatus === RUN_STATUS_IDLE && isHeaterShakerInProtocol) { + confirmAttachment() + } else if ( + runStatus === RUN_STATUS_IDLE && + !(labwareConfirmed && offsetsConfirmed && liquidsConfirmed) + ) { + confirmStepsComplete() + } else { play() trackProtocolRunEvent({ name: ANALYTICS_PROTOCOL_RUN_ACTION.START, properties: robotAnalyticsData ?? {}, }) - } else { - makeSnackbar( - i18n.format(t('complete_setup_before_proceeding'), 'capitalize') - ) } + } else { + makeSnackbar( + i18n.format(t('complete_setup_before_proceeding'), 'capitalize') + ) } } } @@ -752,22 +763,16 @@ function PrepareToRun({ /> { - launchLPC() + setSetupScreen('offsets') }} title={t('labware_position_check')} - detail={t( - lpcDisabledReason != null - ? 'currently_unavailable' - : 'recommended' - )} + detail={t('recommended')} subDetail={ latestCurrentOffsets.length > 0 ? t('offsets_applied', { count: latestCurrentOffsets.length }) : null } - status="general" - disabled={lpcDisabledReason != null} - disabledReason={lpcDisabledReason} + status={offsetsConfirmed ? 'ready' : 'general'} /> { @@ -776,25 +781,25 @@ function PrepareToRun({ title={t('parameters')} detail={parametersDetail} subDetail={null} - status="general" - disabled={!hasRunTimeParameters} + status="ready" + interactionDisabled={!hasRunTimeParameters} /> { setSetupScreen('labware') }} - title={t('labware')} + title={i18n.format(t('labware'), 'capitalize')} detail={labwareDetail} subDetail={labwareSubDetail} - status="general" + status={labwareConfirmed ? 'ready' : 'general'} disabled={labwareDetail == null} /> { setSetupScreen('liquids') }} - title={t('liquids')} - status="general" + title={i18n.format(t('liquids'), 'capitalize')} + status={liquidsConfirmed ? 'ready' : 'general'} detail={ liquidsInProtocol.length > 0 ? t('initial_liquids_num', { @@ -809,7 +814,6 @@ function PrepareToRun({ )}
- {LPCWizard} {showConfirmCancelModal ? ( () as OnDeviceRouteParams const { data: runRecord } = useNotifyRunQuery(runId, { staleTime: Infinity }) const { analysisErrors } = useProtocolAnalysisErrors(runId) + const { t } = useTranslation(['protocol_setup']) const localRobot = useSelector(getLocalRobot) + const robotName = localRobot?.name != null ? localRobot.name : 'no name' const robotSerialNumber = localRobot?.status != null ? getRobotSerialNumber(localRobot) : null const trackEvent = useTrackEvent() @@ -849,7 +856,69 @@ export function ProtocolSetup(): JSX.Element { showAnalysisFailedModal, setShowAnalysisFailedModal, ] = React.useState(true) + const robotType = useRobotType(robotName) + const attachedModules = + useAttachedModules({ + refetchInterval: FETCH_DURATION_MS, + }) ?? [] + const protocolId = runRecord?.data?.protocolId ?? null + const { data: protocolRecord } = useProtocolQuery(protocolId, { + staleTime: Infinity, + }) + const mostRecentAnalysisSummary = last(protocolRecord?.data.analysisSummaries) + const [ + isPollingForCompletedAnalysis, + setIsPollingForCompletedAnalysis, + ] = React.useState(mostRecentAnalysisSummary?.status !== 'completed') + const { + data: mostRecentAnalysis = null, + } = useProtocolAnalysisAsDocumentQuery( + protocolId, + last(protocolRecord?.data.analysisSummaries)?.id ?? null, + { + enabled: protocolRecord != null && isPollingForCompletedAnalysis, + refetchInterval: ANALYSIS_POLL_MS, + } + ) + + React.useEffect(() => { + if (mostRecentAnalysis?.status === 'completed') { + setIsPollingForCompletedAnalysis(false) + } else { + setIsPollingForCompletedAnalysis(true) + } + }, [mostRecentAnalysis?.status]) + const deckDef = getDeckDefFromRobotType(robotType) + + const protocolModulesInfo = + mostRecentAnalysis != null + ? getProtocolModulesInfo(mostRecentAnalysis, deckDef) + : [] + + const { missingModuleIds } = getUnmatchedModulesForProtocol( + attachedModules, + protocolModulesInfo + ) + const isMissingModules = missingModuleIds.length > 0 + const { data: attachedInstruments } = useInstrumentsQuery() + + const incompleteInstrumentCount: number | null = + mostRecentAnalysis != null && attachedInstruments != null + ? getIncompleteInstrumentCount(mostRecentAnalysis, attachedInstruments) + : null + const lpcDisabledReason = useLPCDisabledReason({ + runId, + hasMissingModulesForOdd: isMissingModules, + hasMissingCalForOdd: + incompleteInstrumentCount != null && incompleteInstrumentCount > 0, + }) + const protocolName = + protocolRecord?.data.metadata.protocolName ?? + protocolRecord?.data.files[0].name ?? + '' + + const { launchLPC, LPCWizard } = useLaunchLPC(runId, robotType, protocolName) const handleProceedToRunClick = (): void => { trackEvent({ name: ANALYTICS_PROTOCOL_PROCEED_TO_RUN, @@ -862,8 +931,8 @@ export function ProtocolSetup(): JSX.Element { ) const { confirm: confirmAttachment, - showConfirmation: showConfirmationModal, - cancel: cancelExit, + showConfirmation: showHSConfirmationModal, + cancel: cancelExitHSConfirmation, } = useConditionalConfirm( handleProceedToRunClick, !configBypassHeaterShakerAttachmentConfirmation @@ -872,6 +941,22 @@ export function ProtocolSetup(): JSX.Element { const [providedFixtureOptions, setProvidedFixtureOptions] = React.useState< CutoutFixtureId[] >([]) + const [labwareConfirmed, setLabwareConfirmed] = React.useState(false) + const [liquidsConfirmed, setLiquidsConfirmed] = React.useState(false) + const [offsetsConfirmed, setOffsetsConfirmed] = React.useState(false) + const missingSteps = [ + !offsetsConfirmed ? t('applied_labware_offsets') : null, + !labwareConfirmed ? t('labware_placement') : null, + !liquidsConfirmed ? t('liquids') : null, + ].filter(s => s != null) + const { + confirm: confirmMissingSteps, + showConfirmation: showMissingStepsConfirmation, + cancel: cancelExitMissingStepsConfirmation, + } = useConditionalConfirm( + handleProceedToRunClick, + !(labwareConfirmed && liquidsConfirmed && offsetsConfirmed) + ) // orchestrate setup subpages/components const [setupScreen, setSetupScreen] = React.useState( @@ -883,9 +968,13 @@ export function ProtocolSetup(): JSX.Element { runId={runId} setSetupScreen={setSetupScreen} confirmAttachment={confirmAttachment} + confirmStepsComplete={confirmMissingSteps} play={play} - robotName={localRobot?.name != null ? localRobot.name : 'no name'} + robotName={robotName} runRecord={runRecord ?? null} + labwareConfirmed={labwareConfirmed} + liquidsConfirmed={liquidsConfirmed} + offsetsConfirmed={offsetsConfirmed} /> ), instruments: ( @@ -899,11 +988,32 @@ export function ProtocolSetup(): JSX.Element { setProvidedFixtureOptions={setProvidedFixtureOptions} /> ), + offsets: ( + + ), labware: ( - + ), liquids: ( - + ), 'deck configuration': ( error.detail)} /> ) : null} - {showConfirmationModal ? ( + {showMissingStepsConfirmation ? ( + + ) : null} + {showHSConfirmationModal ? ( From 837d5ae458c66e5072deef02e30f9a9669231a6d Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Wed, 7 Aug 2024 09:53:50 -0400 Subject: [PATCH 05/14] feat(api): Allow omitting `description` and `display_color` from `ProtocolContext.define_liquid()` (#15906) --- api/docs/v2/versioning.rst | 5 ++ .../protocol_api/protocol_context.py | 53 +++++++++++++++++-- api/src/opentrons/protocol_api/validation.py | 2 +- .../protocol_api/test_protocol_context.py | 39 +++++++++++++- .../errors/exceptions.py | 2 +- 5 files changed, 95 insertions(+), 6 deletions(-) diff --git a/api/docs/v2/versioning.rst b/api/docs/v2/versioning.rst index 081edca651a..9c4ccc62a0d 100644 --- a/api/docs/v2/versioning.rst +++ b/api/docs/v2/versioning.rst @@ -132,6 +132,11 @@ This table lists the correspondence between Protocol API versions and robot soft Changes in API Versions ======================= +Version 2.20 +------------ + +- You can now call :py:obj:`.ProtocolContext.define_liquid()` without supplying a ``description`` or ``display_color``. + Version 2.19 ------------ diff --git a/api/src/opentrons/protocol_api/protocol_context.py b/api/src/opentrons/protocol_api/protocol_context.py index 59b7d1d8aee..054af703fe7 100644 --- a/api/src/opentrons/protocol_api/protocol_context.py +++ b/api/src/opentrons/protocol_api/protocol_context.py @@ -87,6 +87,16 @@ ] +class _Unset: + """A sentinel value for when no value has been supplied for an argument, + when `None` is already taken for some other meaning. + + User code should never use this explicitly. + """ + + pass + + class ProtocolContext(CommandPublisher): """A context for the state of a protocol. @@ -1197,17 +1207,54 @@ def set_rail_lights(self, on: bool) -> None: @requires_version(2, 14) def define_liquid( - self, name: str, description: Optional[str], display_color: Optional[str] + self, + name: str, + description: Union[str, None, _Unset] = _Unset(), + display_color: Union[str, None, _Unset] = _Unset(), ) -> Liquid: + # This first line of the docstring overrides the method signature in our public + # docs, which would otherwise have the `_Unset()`s expanded to a bunch of junk. """ + define_liquid(self, name: str, description: Optional[str] = None, display_color: Optional[str] = None) + Define a liquid within a protocol. :param str name: A human-readable name for the liquid. - :param str description: An optional description of the liquid. - :param str display_color: An optional hex color code, with hash included, to represent the specified liquid. Standard three-value, four-value, six-value, and eight-value syntax are all acceptable. + :param Optional[str] description: An optional description of the liquid. + :param Optional[str] display_color: An optional hex color code, with hash included, + to represent the specified liquid. For example, ``"#48B1FA"``. + Standard three-value, four-value, six-value, and eight-value syntax are all + acceptable. :return: A :py:class:`~opentrons.protocol_api.Liquid` object representing the specified liquid. + + .. versionchanged:: 2.20 + You can now omit the ``description`` and ``display_color`` arguments. + Formerly, when you didn't want to provide values, you had to supply + ``description=None`` and ``display_color=None`` explicitly. """ + desc_and_display_color_omittable_since = APIVersion(2, 20) + if isinstance(description, _Unset): + if self._api_version < desc_and_display_color_omittable_since: + raise APIVersionError( + api_element="Calling `define_liquid()` without a `description`", + current_version=str(self._api_version), + until_version=str(desc_and_display_color_omittable_since), + message="Use a newer API version or explicitly supply `description=None`.", + ) + else: + description = None + if isinstance(display_color, _Unset): + if self._api_version < desc_and_display_color_omittable_since: + raise APIVersionError( + api_element="Calling `define_liquid()` without a `display_color`", + current_version=str(self._api_version), + until_version=str(desc_and_display_color_omittable_since), + message="Use a newer API version or explicitly supply `display_color=None`.", + ) + else: + display_color = None + return self._core.define_liquid( name=name, description=description, diff --git a/api/src/opentrons/protocol_api/validation.py b/api/src/opentrons/protocol_api/validation.py index 207c417cf5e..1ad6628ae24 100644 --- a/api/src/opentrons/protocol_api/validation.py +++ b/api/src/opentrons/protocol_api/validation.py @@ -208,7 +208,7 @@ def ensure_and_convert_deck_slot( api_element=f"Specifying a deck slot like '{deck_slot}'", until_version=f"{_COORDINATE_DECK_LABEL_VERSION_GATE}", current_version=f"{api_version}", - message=f" Increase your protocol's apiLevel, or use slot '{alternative}' instead.", + message=f"Increase your protocol's apiLevel, or use slot '{alternative}' instead.", ) return parsed_slot.to_equivalent_for_robot_type(robot_type) diff --git a/api/tests/opentrons/protocol_api/test_protocol_context.py b/api/tests/opentrons/protocol_api/test_protocol_context.py index 6674e228b2d..1e1dda706c6 100644 --- a/api/tests/opentrons/protocol_api/test_protocol_context.py +++ b/api/tests/opentrons/protocol_api/test_protocol_context.py @@ -1153,7 +1153,7 @@ def test_home( decoy.verify(mock_core.home(), times=1) -def test_add_liquid( +def test_define_liquid( decoy: Decoy, mock_core: ProtocolCore, subject: ProtocolContext ) -> None: """It should add a liquid to the state.""" @@ -1177,6 +1177,43 @@ def test_add_liquid( assert result == expected_result +@pytest.mark.parametrize( + ("api_version", "expect_success"), + [ + (APIVersion(2, 19), False), + (APIVersion(2, 20), True), + ], +) +def test_define_liquid_arg_defaulting( + expect_success: bool, + decoy: Decoy, + mock_core: ProtocolCore, + subject: ProtocolContext, +) -> None: + """Test API version dependent behavior for missing description and display_color.""" + success_result = Liquid( + _id="water-id", name="water", description=None, display_color=None + ) + decoy.when( + mock_core.define_liquid(name="water", description=None, display_color=None) + ).then_return(success_result) + + if expect_success: + assert ( + subject.define_liquid( + name="water" + # description and display_color omitted. + ) + == success_result + ) + else: + with pytest.raises(APIVersionError): + subject.define_liquid( + name="water" + # description and display_color omitted. + ) + + def test_bundled_data( decoy: Decoy, mock_core_map: LoadedCoreMap, mock_deck: Deck, mock_core: ProtocolCore ) -> None: diff --git a/shared-data/python/opentrons_shared_data/errors/exceptions.py b/shared-data/python/opentrons_shared_data/errors/exceptions.py index 888dc7f6763..e033ee144f7 100644 --- a/shared-data/python/opentrons_shared_data/errors/exceptions.py +++ b/shared-data/python/opentrons_shared_data/errors/exceptions.py @@ -964,7 +964,7 @@ def __init__( f"{api_element} is not yet available in the API version in use." ) if message: - checked_message = checked_message + message + checked_message = checked_message + " " + message checked_message = ( checked_message or "This feature is not yet available in the API version in use." From fe6252c268f51658e5de6e017cf6985e1f1f0e85 Mon Sep 17 00:00:00 2001 From: Ryan Howard Date: Wed, 7 Aug 2024 10:43:35 -0400 Subject: [PATCH 06/14] fix(api): made a mistake in math when i removed the isclose check (#15913) # Overview Should have realized it was wrong when I had to change the test ## Test Plan and Hands on Testing ## Changelog ## Review requests ## Risk assessment Co-authored-by: caila-marashaj --- api/src/opentrons/hardware_control/ot3api.py | 7 +++++-- api/tests/opentrons/hardware_control/test_ot3_api.py | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index 4f0cf262775..5f9c9840834 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -2714,7 +2714,10 @@ async def liquid_probe( error: Optional[PipetteLiquidNotFoundError] = None pos = await self.gantry_position(checked_mount, refresh=True) - while (probe_start_pos.z - pos.z) < max_z_dist: + # probe_start_pos.z + z_distance of pass - pos.z should be < max_z_dist + # due to rounding errors this can get caught in an infinite loop when the distance is almost equal + # so we check to see if they're within 0.01 which is 1/5th the minimum movement distance from move_utils.py + while (probe_start_pos.z - pos.z) < (max_z_dist + 0.01): # safe distance so we don't accidentally aspirate liquid if we're already close to liquid safe_plunger_pos = top_types.Point( pos.x, pos.y, pos.z + probe_safe_reset_mm @@ -2724,7 +2727,7 @@ async def liquid_probe( pos.x, pos.y, pos.z + probe_pass_z_offset_mm ) max_z_time = ( - max_z_dist - (probe_start_pos.z - safe_plunger_pos.z) + max_z_dist - probe_start_pos.z + pass_start_pos.z ) / probe_settings.mount_speed p_travel_required_for_z = max_z_time * probe_settings.plunger_speed p_pass_travel = min(p_travel_required_for_z, p_working_mm) diff --git a/api/tests/opentrons/hardware_control/test_ot3_api.py b/api/tests/opentrons/hardware_control/test_ot3_api.py index 21ab1ad8ef9..190f8841c13 100644 --- a/api/tests/opentrons/hardware_control/test_ot3_api.py +++ b/api/tests/opentrons/hardware_control/test_ot3_api.py @@ -838,7 +838,7 @@ async def test_liquid_probe( mock_move_to_plunger_bottom.call_count == 2 mock_liquid_probe.assert_called_once_with( mount, - 52, + 46, fake_settings_aspirate.mount_speed, (fake_settings_aspirate.plunger_speed * -1), fake_settings_aspirate.sensor_threshold_pascals, @@ -990,7 +990,7 @@ async def _fake_pos_update_and_raise( OT3Mount.LEFT, fake_max_z_dist, fake_settings_aspirate ) # assert that it went through 4 passes and then prepared to aspirate - assert mock_move_to_plunger_bottom.call_count == 5 + assert mock_move_to_plunger_bottom.call_count == 4 @pytest.mark.parametrize( From a4811c19561d798e7339f0bf73cedf0782c2276f Mon Sep 17 00:00:00 2001 From: Caila Marashaj <98041399+caila-marashaj@users.noreply.github.com> Date: Wed, 7 Aug 2024 10:45:10 -0400 Subject: [PATCH 07/14] feat(api): add tests for liquid probe movements (#15896) --- .../hardware_control/test_ot3_api.py | 181 +++++++++++++++++- 1 file changed, 179 insertions(+), 2 deletions(-) diff --git a/api/tests/opentrons/hardware_control/test_ot3_api.py b/api/tests/opentrons/hardware_control/test_ot3_api.py index 190f8841c13..a6ae8e870d1 100644 --- a/api/tests/opentrons/hardware_control/test_ot3_api.py +++ b/api/tests/opentrons/hardware_control/test_ot3_api.py @@ -14,11 +14,11 @@ TypedDict, ) from typing_extensions import Literal -from math import copysign +from math import copysign, isclose import pytest import types from decoy import Decoy -from mock import AsyncMock, patch, Mock, PropertyMock, MagicMock +from mock import AsyncMock, patch, Mock, PropertyMock, MagicMock, call from hypothesis import given, strategies, settings, HealthCheck, assume, example from opentrons.calibration_storage.types import CalibrationStatus, SourceType @@ -856,6 +856,183 @@ async def test_liquid_probe( ) # should raise no exceptions +@pytest.mark.parametrize( + "mount, head_node, pipette_node", + [ + (OT3Mount.LEFT, NodeId.head_l, NodeId.pipette_left), + (OT3Mount.RIGHT, NodeId.head_r, NodeId.pipette_right), + ], +) +async def test_liquid_probe_plunger_moves( + mock_move_to: AsyncMock, + ot3_hardware: ThreadManager[OT3API], + hardware_backend: OT3Simulator, + head_node: NodeId, + pipette_node: Axis, + mount: OT3Mount, + fake_liquid_settings: LiquidProbeSettings, + mock_current_position_ot3: AsyncMock, + mock_move_to_plunger_bottom: AsyncMock, + mock_gantry_position: AsyncMock, +) -> None: + """Verify the plunger moves in liquid_probe.""" + # This test verifies that both: + # - the plunger movements in each liquid probe pass are what we expect + # - liquid probe successfully chooses the correct distance to move + # when approaching its max z distance + instr_data = AttachedPipette( + config=load_pipette_data.load_definition( + PipetteModelType("p1000"), PipetteChannelType(1), PipetteVersionType(3, 4) + ), + id="fakepip", + ) + await ot3_hardware.cache_pipette(mount, instr_data, None) + pipette = ot3_hardware.hardware_pipettes[mount.to_mount()] + + assert pipette + await ot3_hardware.add_tip(mount, 100) + await ot3_hardware.home() + mock_move_to.return_value = None + + with patch.object( + hardware_backend, "liquid_probe", AsyncMock(spec=hardware_backend.liquid_probe) + ) as mock_liquid_probe: + + mock_liquid_probe.side_effect = [ + PipetteLiquidNotFoundError, + PipetteLiquidNotFoundError, + PipetteLiquidNotFoundError, + PipetteLiquidNotFoundError, + None, + ] + + fake_max_z_dist = 75.0 + config = ot3_hardware.config.liquid_sense + mount_speed = config.mount_speed + non_responsive_z_mm = ot3_hardware.liquid_probe_non_responsive_z_distance( + mount_speed + ) + + probe_pass_overlap = 0.1 + probe_pass_z_offset_mm = non_responsive_z_mm + probe_pass_overlap + probe_safe_reset_mm = max(2.0, probe_pass_z_offset_mm) + + # simulate multiple passes of liquid probe + mock_gantry_position.side_effect = [ + Point(x=0, y=0, z=100), + Point(x=0, y=0, z=100), + Point(x=0, y=0, z=100), + Point(x=0, y=0, z=82.15), + Point(x=0, y=0, z=64.3), + Point(x=0, y=0, z=46.45), + Point(x=0, y=0, z=28.6), + Point(x=0, y=0, z=25), + ] + probe_start_pos = await ot3_hardware.gantry_position(mount) + safe_plunger_pos = Point( + probe_start_pos.x, + probe_start_pos.y, + probe_start_pos.z + probe_safe_reset_mm, + ) + + p_impulse_mm = config.plunger_impulse_time * config.plunger_speed + p_total_mm = pipette.plunger_positions.bottom - pipette.plunger_positions.top + p_working_mm = p_total_mm - (pipette.backlash_distance + p_impulse_mm) + + max_z_time = ( + fake_max_z_dist - (probe_start_pos.z - safe_plunger_pos.z) + ) / config.mount_speed + p_travel_required_for_z = max_z_time * config.plunger_speed + await ot3_hardware.liquid_probe(mount, fake_max_z_dist) + + max_z_distance = fake_max_z_dist + # simulate multiple passes of liquid_probe plunger moves + for _pass in mock_liquid_probe.call_args_list: + plunger_move = _pass[0][1] + expected_plunger_move = ( + min(p_travel_required_for_z, p_working_mm) + p_impulse_mm + ) + assert isclose(plunger_move, expected_plunger_move) + + mount_travel_time = plunger_move / config.plunger_speed + mount_travel_distance = mount_speed * mount_travel_time + max_z_distance -= mount_travel_distance + + move_mount_z_time = (max_z_distance + probe_safe_reset_mm) / mount_speed + p_travel_required_for_z = move_mount_z_time * config.plunger_speed + + +@pytest.mark.parametrize( + "mount, head_node, pipette_node", + [ + (OT3Mount.LEFT, NodeId.head_l, NodeId.pipette_left), + (OT3Mount.RIGHT, NodeId.head_r, NodeId.pipette_right), + ], +) +async def test_liquid_probe_mount_moves( + mock_move_to: AsyncMock, + ot3_hardware: ThreadManager[OT3API], + hardware_backend: OT3Simulator, + head_node: NodeId, + pipette_node: Axis, + mount: OT3Mount, + fake_liquid_settings: LiquidProbeSettings, + mock_current_position_ot3: AsyncMock, + mock_move_to_plunger_bottom: AsyncMock, + mock_gantry_position: AsyncMock, +) -> None: + """Verify move targets for one singular liquid pass probe.""" + instr_data = AttachedPipette( + config=load_pipette_data.load_definition( + PipetteModelType("p1000"), PipetteChannelType(1), PipetteVersionType(3, 4) + ), + id="fakepip", + ) + await ot3_hardware.cache_pipette(mount, instr_data, None) + pipette = ot3_hardware.hardware_pipettes[mount.to_mount()] + + assert pipette + await ot3_hardware.add_tip(mount, 100) + await ot3_hardware.home() + mock_move_to.return_value = None + + with patch.object( + hardware_backend, "liquid_probe", AsyncMock(spec=hardware_backend.liquid_probe) + ): + + fake_max_z_dist = 10.0 + config = ot3_hardware.config.liquid_sense + mount_speed = config.mount_speed + non_responsive_z_mm = ot3_hardware.liquid_probe_non_responsive_z_distance( + mount_speed + ) + + probe_pass_overlap = 0.1 + probe_pass_z_offset_mm = non_responsive_z_mm + probe_pass_overlap + probe_safe_reset_mm = max(2.0, probe_pass_z_offset_mm) + + mock_gantry_position.return_value = Point(x=0, y=0, z=100) + probe_start_pos = await ot3_hardware.gantry_position(mount) + safe_plunger_pos = Point( + probe_start_pos.x, + probe_start_pos.y, + probe_start_pos.z + probe_safe_reset_mm, + ) + pass_start_pos = Point( + probe_start_pos.x, + probe_start_pos.y, + probe_start_pos.z + probe_pass_z_offset_mm, + ) + await ot3_hardware.liquid_probe(mount, fake_max_z_dist) + expected_moves = [ + call(mount, safe_plunger_pos), + call(mount, pass_start_pos), + call(mount, Point(z=probe_start_pos.z + 2)), + call(mount, probe_start_pos), + ] + assert mock_move_to.call_args_list == expected_moves + + async def test_multi_liquid_probe( mock_move_to: AsyncMock, ot3_hardware: ThreadManager[OT3API], From 4693d04e36daec4fab9b4be1e8d77931eb661646 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Wed, 7 Aug 2024 11:14:19 -0400 Subject: [PATCH 08/14] refactor(app): Refactor intervention modal render behavior (#15898) Closes RQA-2904 The logic for rendering InterventionModal on the ODD/Desktop is a little bit different when looking at the exact conditions, and this (likely) causes the InterventionModal to render on the ODD sometimes but not on the desktop app, and vice versa. This is a good opportunity to refactor all of this logic into its own hook and use that hook where we render InterventionModal. After thinking through the render logic, there's room to simplify it a bit, too. We don't actually need stateful storage of an intervention command key. Also, I decided to separate showModal from modalProps (which lets us pass all the non-null props simply), even though we could technically just do a truthy check for modalProps for rendering InterventionModal, since this is maybe a bit more intuitive. Lastly, a few missing tests are added. To help with bug testing intervention modals, I added a couple console.warns. --- .../__tests__/InterventionModal.test.tsx | 67 ++++++- app/src/organisms/InterventionModal/index.tsx | 180 +++++++++++++----- .../__tests__/RunProgressMeter.test.tsx | 48 ++--- app/src/organisms/RunProgressMeter/index.tsx | 49 ++--- .../__tests__/RunningProtocol.test.tsx | 22 +++ app/src/pages/RunningProtocol/index.tsx | 52 ++--- 6 files changed, 262 insertions(+), 156 deletions(-) diff --git a/app/src/organisms/InterventionModal/__tests__/InterventionModal.test.tsx b/app/src/organisms/InterventionModal/__tests__/InterventionModal.test.tsx index 06f4f0a22a3..e1a6830d251 100644 --- a/app/src/organisms/InterventionModal/__tests__/InterventionModal.test.tsx +++ b/app/src/organisms/InterventionModal/__tests__/InterventionModal.test.tsx @@ -1,11 +1,13 @@ import * as React from 'react' -import { fireEvent, screen } from '@testing-library/react' +import { fireEvent, renderHook, screen } from '@testing-library/react' import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { RUN_STATUS_RUNNING, RUN_STATUS_STOPPED } from '@opentrons/api-client' import { getLabwareDefURI } from '@opentrons/shared-data' -import { renderWithProviders } from '../../../__testing-utils__' +import { renderWithProviders } from '../../../__testing-utils__' +import { mockTipRackDefinition } from '../../../redux/custom-labware/__fixtures__' import { i18n } from '../../../i18n' -import { InterventionModal } from '..' import { mockPauseCommandWithoutStartTime, mockPauseCommandWithStartTime, @@ -13,9 +15,11 @@ import { mockMoveLabwareCommandFromModule, truncatedCommandMessage, } from '../__fixtures__' -import { mockTipRackDefinition } from '../../../redux/custom-labware/__fixtures__' +import { InterventionModal, useInterventionModal } from '..' import { useIsFlex } from '../../Devices/hooks' + import type { CompletedProtocolAnalysis } from '@opentrons/shared-data' +import type { RunData } from '@opentrons/api-client' const ROBOT_NAME = 'Otie' @@ -23,6 +27,61 @@ const mockOnResumeHandler = vi.fn() vi.mock('../../Devices/hooks') +describe('useInterventionModal', () => { + const defaultProps = { + runData: { id: 'run1' } as RunData, + lastRunCommand: mockPauseCommandWithStartTime, + runStatus: RUN_STATUS_RUNNING, + robotName: 'TestRobot', + analysis: null, + } + + it('should return showModal true when conditions are met', () => { + const { result } = renderHook(() => useInterventionModal(defaultProps)) + + expect(result.current.showModal).toBe(true) + expect(result.current.modalProps).not.toBeNull() + }) + + it('should return showModal false when runStatus is terminal', () => { + const props = { ...defaultProps, runStatus: RUN_STATUS_STOPPED } + + const { result } = renderHook(() => useInterventionModal(props)) + + expect(result.current.showModal).toBe(false) + expect(result.current.modalProps).toBeNull() + }) + + it('should return showModal false when lastRunCommand is null', () => { + const props = { ...defaultProps, lastRunCommand: null } + + const { result } = renderHook(() => useInterventionModal(props)) + + expect(result.current.showModal).toBe(false) + expect(result.current.modalProps).toBeNull() + }) + + it('should return showModal false when robotName is null', () => { + const props = { ...defaultProps, robotName: null } + + const { result } = renderHook(() => useInterventionModal(props)) + + expect(result.current.showModal).toBe(false) + expect(result.current.modalProps).toBeNull() + }) + + it('should return correct modalProps when showModal is true', () => { + const { result } = renderHook(() => useInterventionModal(defaultProps)) + + expect(result.current.modalProps).toEqual({ + command: mockPauseCommandWithStartTime, + run: defaultProps.runData, + robotName: 'TestRobot', + analysis: null, + }) + }) +}) + const render = (props: React.ComponentProps) => { return renderWithProviders(, { i18nInstance: i18n, diff --git a/app/src/organisms/InterventionModal/index.tsx b/app/src/organisms/InterventionModal/index.tsx index 1e7cb9e8475..6c3002ce26b 100644 --- a/app/src/organisms/InterventionModal/index.tsx +++ b/app/src/organisms/InterventionModal/index.tsx @@ -19,6 +19,12 @@ import { TYPOGRAPHY, LegacyStyledText, } from '@opentrons/components' +import { + RUN_STATUS_FAILED, + RUN_STATUS_FINISHING, + RUN_STATUS_STOPPED, + RUN_STATUS_SUCCEEDED, +} from '@opentrons/api-client' import { SmallButton } from '../../atoms/buttons' import { Modal } from '../../molecules/Modal' @@ -26,30 +32,66 @@ import { InterventionModal as InterventionModalMolecule } from '../../molecules/ import { getIsOnDevice } from '../../redux/config' import { PauseInterventionContent } from './PauseInterventionContent' import { MoveLabwareInterventionContent } from './MoveLabwareInterventionContent' +import { isInterventionCommand } from './utils' +import { useRobotType } from '../Devices/hooks' -import type { RunCommandSummary, RunData } from '@opentrons/api-client' import type { IconName } from '@opentrons/components' import type { CompletedProtocolAnalysis } from '@opentrons/shared-data' -import { useRobotType } from '../Devices/hooks' +import type { + RunCommandSummary, + RunData, + RunStatus, +} from '@opentrons/api-client' -const LEARN_ABOUT_MANUAL_STEPS_URL = - 'https://support.opentrons.com/s/article/Manual-protocol-steps' +const TERMINAL_RUN_STATUSES: RunStatus[] = [ + RUN_STATUS_STOPPED, + RUN_STATUS_FAILED, + RUN_STATUS_FINISHING, + RUN_STATUS_SUCCEEDED, +] -const CONTENT_STYLE = { - display: DISPLAY_FLEX, - flexDirection: DIRECTION_COLUMN, - alignItems: ALIGN_FLEX_START, - gridGap: SPACING.spacing24, - padding: SPACING.spacing32, - borderRadius: BORDERS.borderRadius8, -} as const +export interface UseInterventionModalProps { + runData: RunData | null + lastRunCommand: RunCommandSummary | null + runStatus: RunStatus | null + robotName: string | null + analysis: CompletedProtocolAnalysis | null +} -const FOOTER_STYLE = { - display: DISPLAY_FLEX, - width: '100%', - alignItems: ALIGN_CENTER, - justifyContent: JUSTIFY_SPACE_BETWEEN, -} as const +export type UseInterventionModalResult = + | { showModal: false; modalProps: null } + | { showModal: true; modalProps: Omit } + +// If showModal is true, modalProps are guaranteed not to be null. +export function useInterventionModal({ + runData, + lastRunCommand, + runStatus, + robotName, + analysis, +}: UseInterventionModalProps): UseInterventionModalResult { + const isValidIntervention = + lastRunCommand != null && + robotName != null && + isInterventionCommand(lastRunCommand) && + runData != null && + runStatus != null && + !TERMINAL_RUN_STATUSES.includes(runStatus) + + if (!isValidIntervention) { + return { showModal: false, modalProps: null } + } else { + return { + showModal: true, + modalProps: { + command: lastRunCommand, + run: runData, + robotName, + analysis, + }, + } + } +} export interface InterventionModalProps { robotName: string @@ -71,25 +113,28 @@ export function InterventionModal({ const robotType = useRobotType(robotName) const childContent = React.useMemo(() => { - if ( - command.commandType === 'waitForResume' || - command.commandType === 'pause' // legacy pause command - ) { - return ( - - ) - } else if (command.commandType === 'moveLabware') { - return ( - - ) - } else { - return null + switch (command.commandType) { + case 'waitForResume': + case 'pause': // legacy pause command + return ( + + ) + case 'moveLabware': + return ( + + ) + default: + console.warn( + 'Unhandled command passed to InterventionModal: ', + command.commandType + ) + return null } }, [ command.id, @@ -98,21 +143,33 @@ export function InterventionModal({ run.modules.map(m => m.id).join(), ]) - let iconName: IconName | null = null - let headerTitle = '' - let headerTitleOnDevice = '' - if ( - command.commandType === 'waitForResume' || - command.commandType === 'pause' // legacy pause command - ) { - iconName = 'pause-circle' - headerTitle = t('pause_on', { robot_name: robotName }) - headerTitleOnDevice = t('pause') - } else if (command.commandType === 'moveLabware') { - iconName = 'move-xy-circle' - headerTitle = t('move_labware_on', { robot_name: robotName }) - headerTitleOnDevice = t('move_labware') - } + const { iconName, headerTitle, headerTitleOnDevice } = (() => { + switch (command.commandType) { + case 'waitForResume': + case 'pause': + return { + iconName: 'pause-circle' as IconName, + headerTitle: t('pause_on', { robot_name: robotName }), + headerTitleOnDevice: t('pause'), + } + case 'moveLabware': + return { + iconName: 'move-xy-circle' as IconName, + headerTitle: t('move_labware_on', { robot_name: robotName }), + headerTitleOnDevice: t('move_labware'), + } + default: + console.warn( + 'Unhandled command passed to InterventionModal: ', + command.commandType + ) + return { + iconName: null, + headerTitle: '', + headerTitleOnDevice: '', + } + } + })() // TODO(bh, 2023-7-18): this is a one-off modal implementation for desktop // reimplement when design system shares a modal component between desktop/ODD @@ -171,3 +228,22 @@ export function InterventionModal({ ) } + +const LEARN_ABOUT_MANUAL_STEPS_URL = + 'https://support.opentrons.com/s/article/Manual-protocol-steps' + +const CONTENT_STYLE = { + display: DISPLAY_FLEX, + flexDirection: DIRECTION_COLUMN, + alignItems: ALIGN_FLEX_START, + gridGap: SPACING.spacing24, + padding: SPACING.spacing32, + borderRadius: BORDERS.borderRadius8, +} as const + +const FOOTER_STYLE = { + display: DISPLAY_FLEX, + width: '100%', + alignItems: ALIGN_CENTER, + justifyContent: JUSTIFY_SPACE_BETWEEN, +} as const diff --git a/app/src/organisms/RunProgressMeter/__tests__/RunProgressMeter.test.tsx b/app/src/organisms/RunProgressMeter/__tests__/RunProgressMeter.test.tsx index 0cab1ef5adb..10ecdb7bf9e 100644 --- a/app/src/organisms/RunProgressMeter/__tests__/RunProgressMeter.test.tsx +++ b/app/src/organisms/RunProgressMeter/__tests__/RunProgressMeter.test.tsx @@ -13,7 +13,10 @@ import { } from '@opentrons/api-client' import { i18n } from '../../../i18n' -import { InterventionModal } from '../../InterventionModal' +import { + useInterventionModal, + InterventionModal, +} from '../../InterventionModal' import { ProgressBar } from '../../../atoms/ProgressBar' import { useRunStatus } from '../../RunTimeControl/hooks' import { useMostRecentCompletedAnalysis } from '../../LabwarePositionCheck/useMostRecentCompletedAnalysis' @@ -27,11 +30,7 @@ import { mockUseCommandResultNonDeterministic, NON_DETERMINISTIC_COMMAND_KEY, } from '../__fixtures__' -import { - mockMoveLabwareCommandFromSlot, - mockPauseCommandWithStartTime, - mockRunData, -} from '../../InterventionModal/__fixtures__' + import { RunProgressMeter } from '..' import { renderWithProviders } from '../../../__testing-utils__' import { useLastRunCommand } from '../../Devices/hooks/useLastRunCommand' @@ -70,7 +69,7 @@ describe('RunProgressMeter', () => { beforeEach(() => { vi.mocked(ProgressBar).mockReturnValue(
MOCK PROGRESS BAR
) vi.mocked(InterventionModal).mockReturnValue( -
MOCK INTERVENTION MODAL
+
MOCK_INTERVENTION_MODAL
) vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_RUNNING) when(useMostRecentCompletedAnalysis) @@ -96,6 +95,10 @@ describe('RunProgressMeter', () => { currentStepNumber: null, hasRunDiverged: true, }) + vi.mocked(useInterventionModal).mockReturnValue({ + showModal: false, + modalProps: {} as any, + }) props = { runId: NON_DETERMINISTIC_RUN_ID, @@ -119,31 +122,18 @@ describe('RunProgressMeter', () => { screen.getByText('Not started yet') screen.getByText('Download run log') }) - it('should render an intervention modal when lastRunCommand is a pause command', () => { - vi.mocked(useNotifyAllCommandsQuery).mockReturnValue({ - data: { data: [mockPauseCommandWithStartTime], meta: { totalLength: 1 } }, - } as any) - vi.mocked(useNotifyRunQuery).mockReturnValue({ - data: { data: { labware: [] } }, - } as any) - vi.mocked(useCommandQuery).mockReturnValue({ data: null } as any) - vi.mocked(useMostRecentCompletedAnalysis).mockReturnValue({} as any) - render(props) - }) - it('should render an intervention modal when lastRunCommand is a move labware command', () => { - vi.mocked(useNotifyAllCommandsQuery).mockReturnValue({ - data: { - data: [mockMoveLabwareCommandFromSlot], - meta: { totalLength: 1 }, - }, - } as any) - vi.mocked(useNotifyRunQuery).mockReturnValue({ - data: { data: mockRunData }, - } as any) + it('should render an intervention modal when showInterventionModal is true', () => { vi.mocked(useCommandQuery).mockReturnValue({ data: null } as any) - vi.mocked(useMostRecentCompletedAnalysis).mockReturnValue({} as any) + vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_IDLE) + vi.mocked(useInterventionModal).mockReturnValue({ + showModal: true, + modalProps: {} as any, + }) + render(props) + + screen.getByText('MOCK_INTERVENTION_MODAL') }) it('should render the correct run status when run status is completed', () => { diff --git a/app/src/organisms/RunProgressMeter/index.tsx b/app/src/organisms/RunProgressMeter/index.tsx index 5f120904a41..eecf73a96f9 100644 --- a/app/src/organisms/RunProgressMeter/index.tsx +++ b/app/src/organisms/RunProgressMeter/index.tsx @@ -30,17 +30,15 @@ import { useMostRecentCompletedAnalysis } from '../LabwarePositionCheck/useMostR import { getModalPortalEl } from '../../App/portal' import { Tooltip } from '../../atoms/Tooltip' import { useRunStatus } from '../RunTimeControl/hooks' -import { InterventionModal } from '../InterventionModal' +import { InterventionModal, useInterventionModal } from '../InterventionModal' import { ProgressBar } from '../../atoms/ProgressBar' import { useDownloadRunLog, useRobotType } from '../Devices/hooks' import { InterventionTicks } from './InterventionTicks' -import { isInterventionCommand } from '../InterventionModal/utils' import { useNotifyRunQuery, useNotifyAllCommandsQuery, } from '../../resources/runs' import { useRunningStepCounts } from '../../resources/protocols/hooks' -import { TERMINAL_RUN_STATUSES } from './constants' import { useRunProgressCopy } from './hooks' interface RunProgressMeterProps { @@ -51,10 +49,6 @@ interface RunProgressMeterProps { } export function RunProgressMeter(props: RunProgressMeterProps): JSX.Element { const { runId, robotName, makeHandleJumpToStep, resumeRunHandler } = props - const [ - interventionModalCommandKey, - setInterventionModalCommandKey, - ] = React.useState(null) const { t } = useTranslation('run_details') const robotType = useRobotType(robotName) const runStatus = useRunStatus(runId) @@ -91,23 +85,6 @@ export function RunProgressMeter(props: RunProgressMeterProps): JSX.Element { const { downloadRunLog } = useDownloadRunLog(robotName, runId) - React.useEffect(() => { - if ( - lastRunCommand != null && - interventionModalCommandKey != null && - lastRunCommand.key !== interventionModalCommandKey - ) { - // set intervention modal command key to null if different from current command key - setInterventionModalCommandKey(null) - } else if ( - lastRunCommand?.key != null && - isInterventionCommand(lastRunCommand) && - interventionModalCommandKey === null - ) { - setInterventionModalCommandKey(lastRunCommand.key) - } - }, [lastRunCommand, interventionModalCommandKey]) - const onDownloadClick: React.MouseEventHandler = e => { if (downloadIsDisabled) return false e.preventDefault() @@ -115,6 +92,17 @@ export function RunProgressMeter(props: RunProgressMeterProps): JSX.Element { downloadRunLog() } + const { + showModal: showIntervention, + modalProps: interventionProps, + } = useInterventionModal({ + robotName, + runStatus, + runData, + analysis, + lastRunCommand, + }) + const { progressPercentage, stepCountStr, @@ -132,20 +120,11 @@ export function RunProgressMeter(props: RunProgressMeterProps): JSX.Element { return ( <> - {interventionModalCommandKey != null && - lastRunCommand != null && - isInterventionCommand(lastRunCommand) && - analysisCommands != null && - runStatus != null && - runData != null && - !TERMINAL_RUN_STATUSES.includes(runStatus) + {showIntervention ? createPortal( , getModalPortalEl() ) diff --git a/app/src/pages/RunningProtocol/__tests__/RunningProtocol.test.tsx b/app/src/pages/RunningProtocol/__tests__/RunningProtocol.test.tsx index 1114f4964eb..bddb00263d4 100644 --- a/app/src/pages/RunningProtocol/__tests__/RunningProtocol.test.tsx +++ b/app/src/pages/RunningProtocol/__tests__/RunningProtocol.test.tsx @@ -44,6 +44,10 @@ import { useErrorRecoveryFlows, } from '../../../organisms/ErrorRecoveryFlows' import { useLastRunCommand } from '../../../organisms/Devices/hooks/useLastRunCommand' +import { + useInterventionModal, + InterventionModal, +} from '../../../organisms/InterventionModal' import type { UseQueryResult } from 'react-query' import type { ProtocolAnalyses, RunCommandSummary } from '@opentrons/api-client' @@ -64,6 +68,7 @@ vi.mock('../../../resources/runs') vi.mock('../../../redux/config') vi.mock('../../../organisms/ErrorRecoveryFlows') vi.mock('../../../organisms/Devices/hooks/useLastRunCommand') +vi.mock('../../../organisms/InterventionModal') const RUN_ID = 'run_id' const ROBOT_NAME = 'otie' @@ -159,6 +164,13 @@ describe('RunningProtocol', () => { isERActive: false, failedCommand: {} as any, }) + vi.mocked(useInterventionModal).mockReturnValue({ + showModal: false, + modalProps: {} as any, + }) + vi.mocked(InterventionModal).mockReturnValue( +
MOCK_INTERVENTION_MODAL
+ ) }) afterEach(() => { @@ -219,6 +231,16 @@ describe('RunningProtocol', () => { screen.getByText('MOCK ERROR RECOVERY') }) + it('should render an InterventionModal appropriately', () => { + vi.mocked(useInterventionModal).mockReturnValue({ + showModal: true, + modalProps: {} as any, + }) + render(`/runs/${RUN_ID}/run`) + + screen.getByText('MOCK_INTERVENTION_MODAL') + }) + // ToDo (kj:04/04/2023) need to figure out the way to simulate swipe it.todo('should render RunningProtocolCommandList when swiping left') // const [{ getByText }] = render(`/runs/${RUN_ID}/run`) diff --git a/app/src/pages/RunningProtocol/index.tsx b/app/src/pages/RunningProtocol/index.tsx index df35c6fa846..f1ca179167d 100644 --- a/app/src/pages/RunningProtocol/index.tsx +++ b/app/src/pages/RunningProtocol/index.tsx @@ -24,14 +24,15 @@ import { import { RUN_STATUS_STOP_REQUESTED, RUN_STATUS_BLOCKED_BY_OPEN_DOOR, - RUN_STATUS_FINISHING, } from '@opentrons/api-client' import { StepMeter } from '../../atoms/StepMeter' import { useMostRecentCompletedAnalysis } from '../../organisms/LabwarePositionCheck/useMostRecentCompletedAnalysis' import { useNotifyRunQuery } from '../../resources/runs' -import { InterventionModal } from '../../organisms/InterventionModal' -import { isInterventionCommand } from '../../organisms/InterventionModal/utils' +import { + InterventionModal, + useInterventionModal, +} from '../../organisms/InterventionModal' import { useRunStatus, useRunTimestamps, @@ -89,10 +90,6 @@ export function RunningProtocol(): JSX.Element { showConfirmCancelRunModal, setShowConfirmCancelRunModal, ] = React.useState(false) - const [ - interventionModalCommandKey, - setInterventionModalCommandKey, - ] = React.useState(null) const lastAnimatedCommand = React.useRef(null) const { ref, style, swipeType, setSwipeType } = useSwipe() const robotSideAnalysis = useMostRecentCompletedAnalysis(runId) @@ -124,6 +121,16 @@ export function RunningProtocol(): JSX.Element { const robotAnalyticsData = useRobotAnalyticsData(robotName) const robotType = useRobotType(robotName) const { isERActive, failedCommand } = useErrorRecoveryFlows(runId, runStatus) + const { + showModal: showIntervention, + modalProps: interventionProps, + } = useInterventionModal({ + runStatus, + lastRunCommand, + runData: runRecord?.data ?? null, + robotName, + analysis: robotSideAnalysis, + }) React.useEffect(() => { if ( @@ -143,23 +150,6 @@ export function RunningProtocol(): JSX.Element { } }, [currentOption, swipeType, setSwipeType]) - React.useEffect(() => { - if ( - lastRunCommand != null && - interventionModalCommandKey != null && - lastRunCommand.key !== interventionModalCommandKey - ) { - // set intervention modal command key to null if different from current command key - setInterventionModalCommandKey(null) - } else if ( - lastRunCommand?.key != null && - isInterventionCommand(lastRunCommand) && - interventionModalCommandKey === null - ) { - setInterventionModalCommandKey(lastRunCommand.key) - } - }, [lastRunCommand, interventionModalCommandKey]) - return ( <> {isERActive ? ( @@ -202,18 +192,8 @@ export function RunningProtocol(): JSX.Element { isActiveRun={true} /> ) : null} - {interventionModalCommandKey != null && - runRecord?.data != null && - lastRunCommand != null && - isInterventionCommand(lastRunCommand) && - runStatus !== RUN_STATUS_FINISHING ? ( - + {showIntervention ? ( + ) : null} Date: Wed, 7 Aug 2024 11:37:44 -0400 Subject: [PATCH 09/14] feat(app, components): add modal for stacked entities (#15895) To give clarity to the contents of labware/adapter/module stacks, here, I add a modal when clicking a stack on Labware setup deck map (for both Desktop and ODD). Each element of the stack will be highlighted described in a list item containing the element's name, optional nickname, and isometric SVG or PNG representation depending on its type. Closes [PLAT-376](https://opentrons.atlassian.net/browse/PLAT-376), [PLAT-378](https://opentrons.atlassian.net/browse/PLAT-378) --- .../opentrons_flex_96_tiprack_adapter.png | Bin 0 -> 155090 bytes .../localization/en/protocol_setup.json | 1 + app/src/molecules/Modal/types.ts | 2 +- .../SetupLabware/LabwareStackModal.tsx | 252 ++++++++++++++++++ .../SetupLabware/SetupLabwareMap.tsx | 27 +- .../SetupLiquids/SetupLiquidsMap.tsx | 7 +- .../__tests__/getLocationInfoNames.test.ts | 2 + .../ProtocolRun/utils/getLocationInfoNames.ts | 7 + .../__tests__/ProtocolSetupLabware.test.tsx | 2 + .../organisms/ProtocolSetupLabware/index.tsx | 51 +++- .../hardware-sim/Labware/LabwareRender.tsx | 1 + .../Labware/LabwareStackRender.tsx | 211 ++++++++++----- .../labwareInternals/LabwareOutline.tsx | 16 +- .../labwareInternals/StaticLabware.tsx | 3 + 14 files changed, 503 insertions(+), 79 deletions(-) create mode 100644 app/src/assets/images/labware/opentrons_flex_96_tiprack_adapter.png create mode 100644 app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx diff --git a/app/src/assets/images/labware/opentrons_flex_96_tiprack_adapter.png b/app/src/assets/images/labware/opentrons_flex_96_tiprack_adapter.png new file mode 100644 index 0000000000000000000000000000000000000000..28a65ff766b0c882f5ba5c198cb61986b9976bb8 GIT binary patch literal 155090 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%0D-O+=p?}&!o;x@}Zply?m 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 R0RjL3002ovPDHLkV1nH+4mSV* literal 0 HcmV?d00001 diff --git a/app/src/assets/localization/en/protocol_setup.json b/app/src/assets/localization/en/protocol_setup.json index 2eef6a6c15c..2a21ab64da1 100644 --- a/app/src/assets/localization/en/protocol_setup.json +++ b/app/src/assets/localization/en/protocol_setup.json @@ -273,6 +273,7 @@ "setup_is_view_only": "Setup is view-only once run has started", "slot_location": "Slot {{slotName}}", "slot_number": "Slot Number", + "stacked_slot": "Stacked slot", "start_run": "Start run", "status": "Status", "step": "STEP {{index}}", diff --git a/app/src/molecules/Modal/types.ts b/app/src/molecules/Modal/types.ts index a9ddd05ffed..ae9758b1cdd 100644 --- a/app/src/molecules/Modal/types.ts +++ b/app/src/molecules/Modal/types.ts @@ -3,7 +3,7 @@ import type { IconName, StyleProps } from '@opentrons/components' export type ModalSize = 'small' | 'medium' | 'large' export interface ModalHeaderBaseProps extends StyleProps { - title: string + title: string | JSX.Element onClick?: React.MouseEventHandler hasExitIcon?: boolean iconName?: IconName diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx new file mode 100644 index 00000000000..2c89240bf6f --- /dev/null +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx @@ -0,0 +1,252 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' +import { css } from 'styled-components' +import { + ALIGN_CENTER, + Box, + COLORS, + DeckInfoLabel, + DIRECTION_COLUMN, + Flex, + JUSTIFY_CENTER, + JUSTIFY_SPACE_BETWEEN, + LabwareStackRender, + SPACING, + StyledText, +} from '@opentrons/components' +import { Modal } from '../../../../molecules/Modal' +import { getIsOnDevice } from '../../../../redux/config' +import { useMostRecentCompletedAnalysis } from '../../../LabwarePositionCheck/useMostRecentCompletedAnalysis' +import { LegacyModal } from '../../../../molecules/LegacyModal' +import { getLocationInfoNames } from '../utils/getLocationInfoNames' +import { getSlotLabwareDefinition } from '../utils/getSlotLabwareDefinition' +import { Divider } from '../../../../atoms/structure' +import { getModuleImage } from '../SetupModuleAndDeck/utils' +import { getModuleDisplayName } from '@opentrons/shared-data' +import tiprackAdapter from '../../../../assets/images/labware/opentrons_flex_96_tiprack_adapter.png' + +const HIDE_SCROLLBAR = css` + ::-webkit-scrollbar { + display: none; + } +` + +interface LabwareStackModalProps { + labwareIdTop: string + runId: string + closeModal: () => void +} + +export const LabwareStackModal = ( + props: LabwareStackModalProps +): JSX.Element | null => { + const { labwareIdTop, runId, closeModal } = props + const { t } = useTranslation('protocol_setup') + const isOnDevice = useSelector(getIsOnDevice) + const protocolData = useMostRecentCompletedAnalysis(runId) + if (protocolData == null) { + return null + } + const commands = protocolData?.commands ?? [] + const { + slotName, + adapterName, + adapterId, + moduleModel, + labwareName, + labwareNickname, + } = getLocationInfoNames(labwareIdTop, commands) + + const topDefinition = getSlotLabwareDefinition(labwareIdTop, commands) + const adapterDef = getSlotLabwareDefinition(adapterId ?? '', commands) + const moduleDisplayName = + moduleModel != null ? getModuleDisplayName(moduleModel) : null ?? '' + const tiprackAdapterImg = ( + + ) + const moduleImg = + moduleModel != null ? ( + + ) : null + + return isOnDevice ? ( + + + + + ), + onClick: closeModal, + }} + > + + <> + + + + + + + {adapterDef != null ? ( + <> + + + {adapterDef.parameters.loadName === + 'opentrons_flex_96_tiprack_adapter' ? ( + tiprackAdapterImg + ) : ( + + )} + + {moduleModel != null ? ( + + ) : null} + + ) : null} + {moduleModel != null ? ( + + + {moduleImg} + + ) : null} + + + ) : ( + + + + {t('stacked_slot')} +
+ } + childrenPadding={0} + > + + + <> + + + + + + + {adapterDef != null ? ( + <> + + + + + + + ) : null} + {moduleModel != null ? ( + + + {moduleImg} + + ) : null} + + + + ) +} + +interface LabwareStackLabelProps { + text: string + subText?: string + isOnDevice?: boolean +} +function LabwareStackLabel(props: LabwareStackLabelProps): JSX.Element { + const { text, subText, isOnDevice = false } = props + return isOnDevice ? ( + + {text} + {subText != null ? ( + + {subText} + + ) : null} + + ) : ( + + {text} + {subText != null ? ( + + {subText} + + ) : null} + + ) +} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx index 533f134590d..ae8f3bbea02 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx @@ -27,6 +27,7 @@ import type { CompletedProtocolAnalysis, ProtocolAnalysisOutput, } from '@opentrons/shared-data' +import { LabwareStackModal } from './LabwareStackModal' interface SetupLabwareMapProps { runId: string @@ -38,6 +39,11 @@ export function SetupLabwareMap({ protocolAnalysis, }: SetupLabwareMapProps): JSX.Element | null { // early return null if no protocol analysis + const [ + labwareStackDetailsLabwareId, + setLabwareStackDetailsLabwareId, + ] = React.useState(null) + if (protocolAnalysis == null) return null const commands = protocolAnalysis.commands @@ -76,7 +82,15 @@ export function SetupLabwareMap({ nestedLabwareDef: topLabwareDefinition, moduleChildren: ( - <> + // open modal + { + if (topLabwareDefinition != null) { + setLabwareStackDetailsLabwareId(topLabwareId) + } + }} + cursor="pointer" + > {topLabwareDefinition != null && topLabwareId != null ? ( ) : null} - + ), } }) @@ -143,6 +157,15 @@ export function SetupLabwareMap({ commands={commands} />
+ {labwareStackDetailsLabwareId != null && ( + { + setLabwareStackDetailsLabwareId(null) + }} + /> + )} ) } diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/SetupLiquidsMap.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/SetupLiquidsMap.tsx index 4a73d6a3906..3bfd0d9c294 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/SetupLiquidsMap.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/SetupLiquidsMap.tsx @@ -108,7 +108,9 @@ export function SetupLiquidsMap( setHoverLabwareId('') }} onClick={() => { - if (labwareHasLiquid) setLiquidDetailsLabwareId(topLabwareId) + if (labwareHasLiquid) { + setLiquidDetailsLabwareId(topLabwareId) + } }} cursor={labwareHasLiquid ? 'pointer' : ''} > @@ -169,8 +171,9 @@ export function SetupLiquidsMap( setHoverLabwareId('') }} onClick={() => { - if (labwareHasLiquid) + if (labwareHasLiquid) { setLiquidDetailsLabwareId(topLabwareId) + } }} cursor={labwareHasLiquid ? 'pointer' : ''} > diff --git a/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getLocationInfoNames.test.ts b/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getLocationInfoNames.test.ts index 5f6a14090f0..f917f64035f 100644 --- a/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getLocationInfoNames.test.ts +++ b/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getLocationInfoNames.test.ts @@ -151,6 +151,7 @@ describe('getLocationInfoNames', () => { labwareName: LABWARE_DISPLAY_NAME, moduleModel: MOCK_MODEL, adapterName: ADAPTER_DISPLAY_NAME, + adapterId: ADAPTER_ID, } expect( getLocationInfoNames(LABWARE_ID, MOCK_ADAPTER_MOD_COMMANDS as any) @@ -161,6 +162,7 @@ describe('getLocationInfoNames', () => { slotName: SLOT, labwareName: LABWARE_DISPLAY_NAME, adapterName: ADAPTER_DISPLAY_NAME, + adapterId: ADAPTER_ID, } expect( getLocationInfoNames(LABWARE_ID, MOCK_ADAPTER_COMMANDS as any) diff --git a/app/src/organisms/Devices/ProtocolRun/utils/getLocationInfoNames.ts b/app/src/organisms/Devices/ProtocolRun/utils/getLocationInfoNames.ts index c01d46259f5..c3404945dcb 100644 --- a/app/src/organisms/Devices/ProtocolRun/utils/getLocationInfoNames.ts +++ b/app/src/organisms/Devices/ProtocolRun/utils/getLocationInfoNames.ts @@ -9,8 +9,10 @@ import type { export interface LocationInfoNames { slotName: string labwareName: string + labwareNickname?: string adapterName?: string moduleModel?: ModuleModel + adapterId?: string } export function getLocationInfoNames( @@ -39,6 +41,7 @@ export function getLocationInfoNames( loadLabwareCommand.result?.definition != null ? getLabwareDisplayName(loadLabwareCommand.result?.definition) : '' + const labwareNickname = loadLabwareCommand.params.displayName const labwareLocation = loadLabwareCommand.params.location @@ -79,8 +82,10 @@ export function getLocationInfoNames( return { slotName: loadedAdapterCommand?.params.location.slotName, labwareName, + labwareNickname, adapterName: loadedAdapterCommand?.result?.definition.metadata.displayName, + adapterId: loadedAdapterCommand?.result?.labwareId, } } else if ( loadedAdapterCommand?.params.location !== 'offDeck' && @@ -96,8 +101,10 @@ export function getLocationInfoNames( ? { slotName: loadModuleCommandUnderAdapter.params.location.slotName, labwareName, + labwareNickname, adapterName: loadedAdapterCommand.result?.definition.metadata.displayName, + adapterId: loadedAdapterCommand?.result?.labwareId, moduleModel: loadModuleCommandUnderAdapter.params.model, } : { slotName: '', labwareName } diff --git a/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx b/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx index 0edc5a1ad1a..99b3c555dd5 100644 --- a/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx +++ b/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx @@ -123,6 +123,8 @@ describe('ProtocolSetupLabware', () => { expect(screen.queryByText('Map View')).toBeNull() fireEvent.click(screen.getByRole('button', { name: 'List View' })) screen.getByText('Labware') + screen.getByText('Labware name') + screen.getByText('Location') }) it('sends a latch-close command when the labware latch is open and the button is clicked', () => { diff --git a/app/src/organisms/ProtocolSetupLabware/index.tsx b/app/src/organisms/ProtocolSetupLabware/index.tsx index 1210c1887df..919d887f491 100644 --- a/app/src/organisms/ProtocolSetupLabware/index.tsx +++ b/app/src/organisms/ProtocolSetupLabware/index.tsx @@ -6,7 +6,6 @@ import styled, { css } from 'styled-components' import { ALIGN_CENTER, ALIGN_FLEX_START, - ALIGN_STRETCH, BORDERS, Box, COLORS, @@ -50,6 +49,7 @@ import { getProtocolModulesInfo } from '../Devices/ProtocolRun/utils/getProtocol import { getAttachedProtocolModuleMatches } from '../ProtocolSetupModulesAndDeck/utils' import { getNestedLabwareInfo } from '../Devices/ProtocolRun/SetupLabware/getNestedLabwareInfo' import { LabwareMapView } from './LabwareMapView' +import { LabwareStackModal } from '../Devices/ProtocolRun/SetupLabware/LabwareStackModal' import type { UseQueryResult } from 'react-query' import type { @@ -149,6 +149,7 @@ export function ProtocolSetupLabware({ } let location: JSX.Element | string | null = null + let topLabwareId: string | null = null if ( selectedLabware != null && typeof selectedLabware.location === 'object' && @@ -177,6 +178,17 @@ export function ProtocolSetupLabware({ module.moduleId === selectedLabware.location.moduleId ) if (matchedModule != null) { + topLabwareId = + mostRecentAnalysis?.commands.find( + (command): command is LoadLabwareRunTimeCommand => { + return ( + command.commandType === 'loadLabware' && + typeof command.params.location === 'object' && + 'moduleId' in command.params.location && + command.params.location.moduleId === matchedModule.moduleId + ) + } + )?.result?.labwareId ?? null location = } } else if ( @@ -191,6 +203,17 @@ export function ProtocolSetupLabware({ command.result?.labwareId === adapterId )?.params.location if (adapterLocation != null && adapterLocation !== 'offDeck') { + topLabwareId = + mostRecentAnalysis?.commands.find( + (command): command is LoadLabwareRunTimeCommand => { + return ( + command.commandType === 'loadLabware' && + typeof command.params.location === 'object' && + 'labwareId' in command.params.location && + command.params.location.labwareId === adapterId + ) + } + )?.result?.labwareId ?? null if ('slotName' in adapterLocation) { location = } else if ('moduleId' in adapterLocation) { @@ -208,19 +231,16 @@ export function ProtocolSetupLabware({ <> {createPortal( <> - {showLabwareDetailsModal && selectedLabware != null ? ( + {showLabwareDetailsModal && + topLabwareId == null && + selectedLabware != null ? ( { setShowLabwareDetailsModal(false) setSelectedLabware(null) }} > - - - - + + + + ) : null} @@ -332,6 +357,16 @@ export function ProtocolSetupLabware({ })} )} + {showLabwareDetailsModal && topLabwareId != null ? ( + { + setSelectedLabware(null) + setShowLabwareDetailsModal(false) + }} + /> + ) : null} { : `translate(${cornerOffsetFromSlot.x}, ${cornerOffsetFromSlot.y})` } ref={gRef} + onClick={props.onLabwareClick} > { + const { xDimension, yDimension } = definition.dimensions + return Math.round( + xDimension * Math.sin(SKEW_ANGLE_RADIANS) + + (yDimension / Math.cos(SKEW_ANGLE_RADIANS)) * + Math.cos(SKEW_ANGLE_RADIANS + ROTATE_ANGLE_RADIANS) + ) +} + +const getLabwareHeightIso = (definition: LabwareDefinition2): number => { + const { zDimension } = definition.dimensions + return Math.round(getLabwareFaceHeightIso(definition) + zDimension) +} + +const getXMinForViewbox = (definition: LabwareDefinition2): number => { + const { yDimension } = definition.dimensions + return Math.round( + (yDimension / Math.cos(SKEW_ANGLE_RADIANS)) * + Math.cos(Math.PI / 2 - (SKEW_ANGLE_RADIANS + ROTATE_ANGLE_RADIANS)) + ) +} + +const getLabwareWidthIso = (definition: LabwareDefinition2): number => { + const { xDimension } = definition.dimensions + return ( + getXMinForViewbox(definition) + xDimension * Math.cos(ROTATE_ANGLE_RADIANS) + ) +} + export const LabwareStackRender = ( props: LabwareStackRenderProps ): JSX.Element => { @@ -48,38 +82,58 @@ export const LabwareStackRender = ( const fillColorBottom = highlightBottom ? HIGHLIGHT_COLOR : COLORS.white // only one labware (top) - if (definitionBottom == null) { + if ( + definitionBottom == null || + definitionBottom.parameters.loadName === 'opentrons_flex_96_tiprack_adapter' + ) { const { xDimension, yDimension } = definitionTop.dimensions const isTopAdapter = definitionTop.metadata.displayCategory === 'adapter' return isTopAdapter ? ( // adapter render - - - - - + + ) : ( // isometric view of labware - + - - {wellLabelOption != null ? ( + + {wellLabelOption != null && + definitionTop.metadata.displayCategory !== 'adapter' ? ( ) : null} @@ -87,7 +141,7 @@ export const LabwareStackRender = ( - + ) } + const xMinForViewbox = Math.min( + ...[definitionTop, definitionBottom].map(def => getXMinForViewbox(def)) + ) + + const totalAssemblyHeight = + getLabwareHeightIso(definitionTop) + + STACK_SEPARATION_MM + + definitionBottom.dimensions.zDimension + return ( - + {/* bottom labware/adapter */} - - - {wellLabelOption != null && - definitionTop.metadata.displayCategory !== 'adapter' ? ( - + + + {wellLabelOption != null && + definitionBottom.metadata.displayCategory !== 'adapter' ? ( + + ) : null} + + - ) : null} - - - + + + )} {/* top labware/adapter */} - + {wellLabelOption != null && definitionTop.metadata.displayCategory !== 'adapter' ? ( - + ) } diff --git a/components/src/hardware-sim/Labware/labwareInternals/LabwareOutline.tsx b/components/src/hardware-sim/Labware/labwareInternals/LabwareOutline.tsx index 9eda65289ec..7478c671114 100644 --- a/components/src/hardware-sim/Labware/labwareInternals/LabwareOutline.tsx +++ b/components/src/hardware-sim/Labware/labwareInternals/LabwareOutline.tsx @@ -19,6 +19,7 @@ export interface LabwareOutlineProps { /** [legacy] override the border color */ stroke?: CSSProperties['stroke'] fill?: CSSProperties['fill'] + showRadius?: boolean } const OUTLINE_THICKNESS_MM = 1 @@ -32,6 +33,7 @@ export function LabwareOutline(props: LabwareOutlineProps): JSX.Element { highlight = false, stroke, fill, + showRadius = true, } = props const { parameters = { isTiprack }, @@ -62,6 +64,7 @@ export function LabwareOutline(props: LabwareOutlineProps): JSX.Element { stroke="#74B0FF" rx="8" ry="8" + showRadius={showRadius} /> ) : ( @@ -80,6 +84,7 @@ export function LabwareOutline(props: LabwareOutlineProps): JSX.Element { yDimension={dimensions.yDimension} stroke={stroke ?? (parameters.isTiprack ? '#979797' : COLORS.black90)} fill={backgroundFill} + showRadius={showRadius} /> )} @@ -90,9 +95,16 @@ interface LabwareBorderProps extends React.SVGProps { borderThickness: number xDimension: number yDimension: number + showRadius?: boolean } function LabwareBorder(props: LabwareBorderProps): JSX.Element { - const { borderThickness, xDimension, yDimension, ...svgProps } = props + const { + borderThickness, + xDimension, + yDimension, + showRadius = true, + ...svgProps + } = props return ( ) diff --git a/components/src/hardware-sim/Labware/labwareInternals/StaticLabware.tsx b/components/src/hardware-sim/Labware/labwareInternals/StaticLabware.tsx index c8341c94a07..4094ea1e038 100644 --- a/components/src/hardware-sim/Labware/labwareInternals/StaticLabware.tsx +++ b/components/src/hardware-sim/Labware/labwareInternals/StaticLabware.tsx @@ -24,6 +24,7 @@ export interface StaticLabwareProps { /** Optional callback to be executed when mouse leaves a well element */ onMouseLeaveWell?: (e: WellMouseEvent) => unknown fill?: CSSProperties['fill'] + showRadius?: boolean } const TipDecoration = React.memo(function TipDecoration(props: { @@ -58,6 +59,7 @@ export function StaticLabwareComponent(props: StaticLabwareProps): JSX.Element { onMouseEnterWell, onMouseLeaveWell, fill, + showRadius = true, } = props const { isTiprack } = definition.parameters @@ -68,6 +70,7 @@ export function StaticLabwareComponent(props: StaticLabwareProps): JSX.Element { definition={definition} highlight={highlight} fill={fill} + showRadius={showRadius} /> From 45344f591d3ffab015e693eecb703c676227e513 Mon Sep 17 00:00:00 2001 From: Nick Diehl <47604184+ncdiehl11@users.noreply.github.com> Date: Wed, 7 Aug 2024 13:08:52 -0400 Subject: [PATCH 10/14] fix(app): fix accessing file name on FileCard (#15917) Fixes accidental reversion resulting in populating file name on `FileCard` with empty string. We need to reach into `csvParam.file.file.name` rather than just `csvParam.file.name` on desktop. --- app/src/organisms/ChooseRobotSlideout/FileCard.tsx | 2 +- .../organisms/ChooseRobotSlideout/__tests__/FileCard.test.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/organisms/ChooseRobotSlideout/FileCard.tsx b/app/src/organisms/ChooseRobotSlideout/FileCard.tsx index 99d66bf54fe..0ed23c4aa2f 100644 --- a/app/src/organisms/ChooseRobotSlideout/FileCard.tsx +++ b/app/src/organisms/ChooseRobotSlideout/FileCard.tsx @@ -51,7 +51,7 @@ export function FileCard(props: FileCardProps): JSX.Element { white-space: nowrap; `} > - {truncateString(fileRunTimeParameter?.file?.name ?? '', 35, 18)} + {truncateString(fileRunTimeParameter?.file?.file?.name ?? '', 35, 18)} Date: Wed, 7 Aug 2024 13:09:27 -0400 Subject: [PATCH 11/14] feat(app): update LegacyModalHeader for new designs (#15914) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Overview Update `LegacyModalHeader` according to new designs to accept up to 2 elements ahead of title text. Add props to `LegacyModal` that are passed down to header, and implement at `LabwareStackRender` Screenshot 2024-08-07 at 11 57 09 AM Closes PLAT-395 --- .../LegacyModal/LegacyModalHeader.tsx | 20 ++++++++++++++++--- app/src/molecules/LegacyModal/index.tsx | 6 ++++++ .../SetupLabware/LabwareStackModal.tsx | 1 + 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/app/src/molecules/LegacyModal/LegacyModalHeader.tsx b/app/src/molecules/LegacyModal/LegacyModalHeader.tsx index 725df7c0c4b..8c884deef80 100644 --- a/app/src/molecules/LegacyModal/LegacyModalHeader.tsx +++ b/app/src/molecules/LegacyModal/LegacyModalHeader.tsx @@ -8,8 +8,8 @@ import { Icon, JUSTIFY_CENTER, JUSTIFY_SPACE_BETWEEN, - SPACING, LegacyStyledText, + SPACING, TYPOGRAPHY, } from '@opentrons/components' @@ -19,6 +19,8 @@ import type { IconProps } from '@opentrons/components' export interface LegacyModalHeaderProps { onClose?: React.MouseEventHandler title: React.ReactNode + titleElement1?: JSX.Element + titleElement2?: JSX.Element backgroundColor?: string color?: string icon?: IconProps @@ -44,7 +46,16 @@ const closeIconStyles = css` export const LegacyModalHeader = ( props: LegacyModalHeaderProps ): JSX.Element => { - const { icon, onClose, title, backgroundColor, color, closeButton } = props + const { + icon, + onClose, + title, + titleElement1, + titleElement2, + backgroundColor, + color, + closeButton, + } = props return ( <> - + {icon != null && } + {titleElement1} + {titleElement2} + {/* TODO (nd: 08/07/2024) Convert to StyledText once designs are resolved */} { childrenPadding = `${SPACING.spacing16} ${SPACING.spacing24} ${SPACING.spacing24}`, children, footer, + titleElement1, + titleElement2, ...styleProps } = props @@ -58,6 +62,8 @@ export const LegacyModal = (props: LegacyModalProps): JSX.Element => { } childrenPadding={0} + marginLeft="0" > From 927d36802bbedcbe72b8e891516059585870a6fb Mon Sep 17 00:00:00 2001 From: Nick Diehl <47604184+ncdiehl11@users.noreply.github.com> Date: Wed, 7 Aug 2024 13:39:34 -0400 Subject: [PATCH 12/14] feat(app): extend `getModuleImage` util for high res images (#15920) Adds optional `highRes` argument to `getModuleImage` util to retrieve higher res module images where applicable for use in `LabwareStackModal`. --- .../SetupLabware/LabwareStackModal.tsx | 6 +++- .../__tests__/utils.test.ts | 28 +++++++++++++++++++ .../ProtocolRun/SetupModuleAndDeck/utils.ts | 17 +++++++---- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx index 601cbc1d091..b65a8b38eb4 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx @@ -67,7 +67,11 @@ export const LabwareStackModal = ( ) const moduleImg = moduleModel != null ? ( - + ) : null return isOnDevice ? ( diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/utils.test.ts b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/utils.test.ts index 6a86b6daf55..2e4639a3c98 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/utils.test.ts +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/__tests__/utils.test.ts @@ -9,6 +9,13 @@ describe('getModuleImage', () => { ) }) + it('should render the high res magnetic module image when the model is a magnetic module gen 1 high res', () => { + const result = getModuleImage('magneticModuleV1', true) + expect(result).toEqual( + '/app/src/assets/images/modules/magneticModuleV2@3x.png' + ) + }) + it('should render the magnetic module image when the model is a magnetic module gen 2', () => { const result = getModuleImage('magneticModuleV2') expect(result).toEqual( @@ -30,6 +37,13 @@ describe('getModuleImage', () => { ) }) + it('should render the high res temperature module image when the model is a temperature module high res', () => { + const result = getModuleImage('temperatureModuleV2', true) + expect(result).toEqual( + '/app/src/assets/images/modules/temperatureModuleV2@3x.png' + ) + }) + it('should render the heater-shaker module image when the model is a heater-shaker module gen 1', () => { const result = getModuleImage('heaterShakerModuleV1') expect(result).toEqual( @@ -37,11 +51,25 @@ describe('getModuleImage', () => { ) }) + it('should render the high res heater-shaker module image when the model is a heater-shaker module gen 1 high res', () => { + const result = getModuleImage('heaterShakerModuleV1', true) + expect(result).toEqual( + '/app/src/assets/images/modules/heaterShakerModuleV1@3x.png' + ) + }) + it('should render the thermocycler module image when the model is a thermocycler module gen 1', () => { const result = getModuleImage('thermocyclerModuleV1') expect(result).toEqual('/app/src/assets/images/thermocycler_closed.png') }) + it('should render the high res thermocycler module image when the model is a thermocycler module gen 1 high res', () => { + const result = getModuleImage('thermocyclerModuleV1', true) + expect(result).toEqual( + '/app/src/assets/images/modules/thermocyclerModuleV1@3x.png' + ) + }) + it('should render the thermocycler module image when the model is a thermocycler module gen 2', () => { const result = getModuleImage('thermocyclerModuleV2') expect(result).toEqual( diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/utils.ts b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/utils.ts index c5ac5c7984e..f5bd5187ad1 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/utils.ts +++ b/app/src/organisms/Devices/ProtocolRun/SetupModuleAndDeck/utils.ts @@ -15,6 +15,10 @@ import magneticModule from '../../../../assets/images/magnetic_module_gen_2_tran import temperatureModule from '../../../../assets/images/temp_deck_gen_2_transparent.png' import thermoModuleGen1 from '../../../../assets/images/thermocycler_closed.png' import heaterShakerModule from '../../../../assets/images/heater_shaker_module_transparent.png' +import magneticModuleHighRes from '../../../../assets/images/modules/magneticModuleV2@3x.png' +import temperatureModuleHighRes from '../../../../assets/images/modules/temperatureModuleV2@3x.png' +import thermoModuleGen1HighRes from '../../../../assets/images/modules/thermocyclerModuleV1@3x.png' +import heaterShakerModuleHighRes from '../../../../assets/images/modules/heaterShakerModuleV1@3x.png' import thermoModuleGen2 from '../../../../assets/images/thermocycler_gen_2_closed.png' import magneticBlockGen1 from '../../../../assets/images/magnetic_block_gen_1.png' import stagingAreaMagneticBlockGen1 from '../../../../assets/images/staging_area_magnetic_block_gen_1.png' @@ -25,18 +29,21 @@ import wasteChuteStagingArea from '../../../../assets/images/waste_chute_with_st import type { CutoutFixtureId, ModuleModel } from '@opentrons/shared-data' -export function getModuleImage(model: ModuleModel): string { +export function getModuleImage( + model: ModuleModel, + highRes: boolean = false +): string { switch (model) { case 'magneticModuleV1': case 'magneticModuleV2': - return magneticModule + return highRes ? magneticModuleHighRes : magneticModule case 'temperatureModuleV1': case 'temperatureModuleV2': - return temperatureModule + return highRes ? temperatureModuleHighRes : temperatureModule case 'heaterShakerModuleV1': - return heaterShakerModule + return highRes ? heaterShakerModuleHighRes : heaterShakerModule case 'thermocyclerModuleV1': - return thermoModuleGen1 + return highRes ? thermoModuleGen1HighRes : thermoModuleGen1 case 'thermocyclerModuleV2': return thermoModuleGen2 case 'magneticBlockV1': From 90127ff5143f1236659d182ceb8c02080c3e5cc6 Mon Sep 17 00:00:00 2001 From: Sanniti Pimpley Date: Wed, 7 Aug 2024 14:18:53 -0400 Subject: [PATCH 13/14] fix(robot-server): fetching of data files used in runs of a protocol (#15908) Closes AUTH-638 # Overview Fixes a bug where CSV files used in protocol runs were not being saved to the CSV RTP table for runs, and as a result, were not being included in response for the `/protocols/{protocolId}/dataFiles` endpoint. ## Test Plan and Hands on Testing Testing with app & ODD: - [x] Upload a protocol that uses a CSV parameter - [x] Send the protocol to a robot, upload the csv file to use - [x] Run the protocol - [x] Run the protocol again with a different CSV file -> Do this 5 times (so total 6 runs with 6 different CSV files). By re-running the protocol 6 times, we are making the robot delete its oldest analysis (since max analyses per protocol is 5), essentially deleting the first CSV file from the *analysis* csv table, but not from runs table - [x] Check that when you run the protocol again on the ODD, it shows you all the 6 different CSV files previously uploaded Testing with Postman/ direct HTTP requests: - [x] Upload a few data files - [x] Upload a protocol that uses a CSV parameter and specify a data file (data_file_1) for the CSV param - [x] Start a new analysis for the same protocol by specifying a second data file (data_file_2) for the CSV param - [x] Create a run for the protocol by specifying data_file_1 for its CSV param - [x] Create another run for the protocol by specifying a third data file (data_file_3) for its CSV param - [x] Check that the response to `GET /protocols/{protocolId}/dataFiles` contains the 3 data files used with the runs & analyses. Check that they are listed in the order that the files were uploaded to the server (via `POST /dataFiles`) ## Changelog - wired up CSV RTP table insertion during run creation - updated the run deletion code to remove the CSV RTP entry from the `run_csv_rtp_table` before deleting the run. - updated the `../{protocolId}/dataFiles` response so that it lists the files in the order they were uploaded. - added tests ## Risk assessment Low. Fixes bug --- .../robot_server/protocols/protocol_store.py | 37 ++- .../robot_server/runs/run_data_manager.py | 6 +- robot-server/robot_server/runs/run_store.py | 8 +- ...lyses_with_csv_file_parameters.tavern.yaml | 1 + ...t_csv_files_used_with_protocol.tavern.yaml | 250 ++++++++++++++++++ ...t_run_with_run_time_parameters.tavern.yaml | 4 +- .../tests/protocols/test_protocol_store.py | 41 ++- .../tests/runs/test_run_data_manager.py | 6 + robot-server/tests/runs/test_run_store.py | 16 +- 9 files changed, 322 insertions(+), 47 deletions(-) create mode 100644 robot-server/tests/integration/http_api/protocols/test_get_csv_files_used_with_protocol.tavern.yaml diff --git a/robot-server/robot_server/protocols/protocol_store.py b/robot-server/robot_server/protocols/protocol_store.py index 13676a798eb..a3a4a954961 100644 --- a/robot-server/robot_server/protocols/protocol_store.py +++ b/robot-server/robot_server/protocols/protocol_store.py @@ -308,9 +308,11 @@ def get_usage_info(self) -> List[ProtocolUsageInfo]: return usage_info - # TODO (spp, 2024-07-22): get files referenced in runs as well async def get_referenced_data_files(self, protocol_id: str) -> List[DataFile]: - """Get a list of data files referenced in specified protocol's analyses and runs.""" + """Return a list of data files referenced in specified protocol's analyses and runs. + + List returned is in the order in which the data files were uploaded to the server. + """ # Get analyses and runs of protocol_id select_referencing_analysis_ids = sqlalchemy.select(analysis_table.c.id).where( analysis_table.c.protocol_id == protocol_id @@ -318,39 +320,34 @@ async def get_referenced_data_files(self, protocol_id: str) -> List[DataFile]: select_referencing_run_ids = sqlalchemy.select(run_table.c.id).where( run_table.c.protocol_id == protocol_id ) - # Get all entries in csv table that match the analyses - analysis_csv_file_ids = sqlalchemy.select( + # Get all entries in analysis_csv_table that match the analysis IDs above + select_analysis_csv_file_ids = sqlalchemy.select( analysis_csv_rtp_table.c.file_id ).where( analysis_csv_rtp_table.c.analysis_id.in_(select_referencing_analysis_ids) ) - run_csv_file_ids = sqlalchemy.select(run_csv_rtp_table.c.file_id).where( + # Get all entries in run_csv_table that match the run IDs above + select_run_csv_file_ids = sqlalchemy.select(run_csv_rtp_table.c.file_id).where( run_csv_rtp_table.c.run_id.in_(select_referencing_run_ids) ) - # Get list of data file IDs from the entries - select_analysis_data_file_rows_statement = data_files_table.select().where( - data_files_table.c.id.in_(analysis_csv_file_ids) - ) - select_run_data_file_rows_statement = data_files_table.select().where( - data_files_table.c.id.in_(run_csv_file_ids) - ) + with self._sql_engine.begin() as transaction: - analysis_data_files_rows = transaction.execute( - select_analysis_data_file_rows_statement - ).all() - run_data_files_rows = transaction.execute( - select_run_data_file_rows_statement + data_files_rows = transaction.execute( + data_files_table.select() + .where( + data_files_table.c.id.in_(select_analysis_csv_file_ids) + | data_files_table.c.id.in_(select_run_csv_file_ids) + ) + .order_by(sqlite_rowid) ).all() - combine_data_file_rows = set(analysis_data_files_rows + run_data_files_rows) - return [ DataFile( id=sql_row.id, name=sql_row.name, createdAt=sql_row.created_at, ) - for sql_row in combine_data_file_rows + for sql_row in data_files_rows ] def get_referencing_run_ids(self, protocol_id: str) -> List[str]: diff --git a/robot-server/robot_server/runs/run_data_manager.py b/robot-server/robot_server/runs/run_data_manager.py index 62b491e6617..7996d5c5237 100644 --- a/robot-server/robot_server/runs/run_data_manager.py +++ b/robot-server/robot_server/runs/run_data_manager.py @@ -207,6 +207,10 @@ async def create( created_at=created_at, protocol_id=protocol.protocol_id if protocol is not None else None, ) + run_time_parameters = self._run_orchestrator_store.get_run_time_parameters() + self._run_store.insert_csv_rtp( + run_id=run_id, run_time_parameters=run_time_parameters + ) await self._runs_publisher.start_publishing_for_run( get_current_command=self.get_current_command, get_recovery_target_command=self.get_recovery_target_command, @@ -218,7 +222,7 @@ async def create( run_resource=run_resource, state_summary=state_summary, current=True, - run_time_parameters=self._run_orchestrator_store.get_run_time_parameters(), + run_time_parameters=run_time_parameters, ) def get(self, run_id: str) -> Union[Run, BadRun]: diff --git a/robot-server/robot_server/runs/run_store.py b/robot-server/robot_server/runs/run_store.py index bbd50b1f713..0de7d08bac6 100644 --- a/robot-server/robot_server/runs/run_store.py +++ b/robot-server/robot_server/runs/run_store.py @@ -217,7 +217,7 @@ def get_all_csv_rtp(self) -> List[CSVParameterRunResource]: with self._sql_engine.begin() as transaction: csv_rtps = transaction.execute(select_all_csv_rtp).all() - return [_covert_row_to_csv_rtp(row) for row in csv_rtps] + return [_convert_row_to_csv_rtp(row) for row in csv_rtps] def insert_csv_rtp( self, run_id: str, run_time_parameters: List[RunTimeParameter] @@ -543,9 +543,13 @@ def remove(self, run_id: str) -> None: delete_commands = sqlalchemy.delete(run_command_table).where( run_command_table.c.run_id == run_id ) + delete_csv_rtps = sqlalchemy.delete(run_csv_rtp_table).where( + run_csv_rtp_table.c.run_id == run_id + ) with self._sql_engine.begin() as transaction: transaction.execute(delete_actions) transaction.execute(delete_commands) + transaction.execute(delete_csv_rtps) result = transaction.execute(delete_run) if result.rowcount < 1: @@ -574,7 +578,7 @@ def _clear_caches(self) -> None: _run_columns = [run_table.c.id, run_table.c.protocol_id, run_table.c.created_at] -def _covert_row_to_csv_rtp( +def _convert_row_to_csv_rtp( row: sqlalchemy.engine.Row, ) -> CSVParameterRunResource: run_id = row.run_id diff --git a/robot-server/tests/integration/http_api/protocols/test_analyses_with_csv_file_parameters.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_analyses_with_csv_file_parameters.tavern.yaml index 399fc6e445c..9c9980b9608 100644 --- a/robot-server/tests/integration/http_api/protocols/test_analyses_with_csv_file_parameters.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_analyses_with_csv_file_parameters.tavern.yaml @@ -96,6 +96,7 @@ stages: - displayName: Liquid handling CSV file variableName: liq_handling_csv_file description: A CSV file that contains wells to use for pipetting + type: csv_file file: id: '{csv_file_id}' name: 'sample_record.csv' diff --git a/robot-server/tests/integration/http_api/protocols/test_get_csv_files_used_with_protocol.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_get_csv_files_used_with_protocol.tavern.yaml new file mode 100644 index 00000000000..7868ece724f --- /dev/null +++ b/robot-server/tests/integration/http_api/protocols/test_get_csv_files_used_with_protocol.tavern.yaml @@ -0,0 +1,250 @@ +test_name: Test the /protocols/{protocolID}/dataFiles endpoint + +marks: + - usefixtures: + - ot2_server_base_url + +stages: + # The order of these data file uploads is important for this test, + # since the list of data files returned for the specified protocol is in upload order. + # The order in which the files are uploaded in this test is the same as the order in which + # these files are uploaded in the overall integration tests suite. + # Until we add data file cleanup after each test, maintaining this order within the suite + # will be important. + + # sample_record -> test + # sample_plates -> sample_record + # test -> sample_plates + - name: Upload data file 1 + request: + url: '{ot2_server_base_url}/dataFiles' + method: POST + files: + file: 'tests/integration/data_files/test.csv' + response: + save: + json: + data_file_1_id: data.id + data_file_1_name: data.name + status_code: + - 201 + - 200 + + - name: Upload data file 2 + request: + url: '{ot2_server_base_url}/dataFiles' + method: POST + files: + file: 'tests/integration/data_files/sample_record.csv' + response: + save: + json: + data_file_2_id: data.id + data_file_2_name: data.name + status_code: + - 201 + - 200 + + - name: Upload data file 3 + request: + url: '{ot2_server_base_url}/dataFiles' + method: POST + files: + file: 'tests/integration/data_files/sample_plates.csv' + response: + save: + json: + data_file_3_id: data.id + data_file_3_name: data.name + status_code: + - 201 + - 200 + + - name: Upload protocol with CSV file ID + request: + url: '{ot2_server_base_url}/protocols' + method: POST + data: + runTimeParameterFiles: '{{"liq_handling_csv_file": "{data_file_1_id}"}}' + files: + files: 'tests/integration/protocols/basic_transfer_with_run_time_parameters.py' + response: + save: + json: + protocol_id: data.id + analysis_id: data.analysisSummaries[0].id + run_time_parameters_data1: data.analysisSummaries[0].runTimeParameters + strict: + json:off + status_code: 201 + json: + data: + analysisSummaries: + - id: !anystr + status: pending + runTimeParameters: + - displayName: Liquid handling CSV file + variableName: liq_handling_csv_file + description: A CSV file that contains wells to use for pipetting + type: csv_file + file: + id: '{data_file_1_id}' + name: 'test.csv' + + - name: Wait until analysis is completed + max_retries: 5 + delay_after: 1 + request: + url: '{ot2_server_base_url}/protocols/{protocol_id}' + response: + status_code: 200 + json: + data: + analyses: [] + analysisSummaries: + - id: '{analysis_id}' + status: completed + id: !anything + protocolType: !anything + files: !anything + createdAt: !anything + robotType: !anything + protocolKind: !anything + metadata: !anything + links: !anything + + - name: Start a new analysis with a different CSV file + request: + url: '{ot2_server_base_url}/protocols/{protocol_id}/analyses' + method: POST + json: + data: + forceReAnalyze: true + runTimeParameterFiles: + liq_handling_csv_file: '{data_file_3_id}' + response: + strict: + - json:off + status_code: 201 + json: + data: + - id: '{analysis_id}' + status: completed + - id: !anystr + status: pending + runTimeParameters: + - displayName: Liquid handling CSV file + variableName: liq_handling_csv_file + description: A CSV file that contains wells to use for pipetting + type: csv_file + file: + id: '{data_file_3_id}' + name: 'sample_plates.csv' + + - name: Wait until analysis is completed + max_retries: 5 + delay_after: 1 + request: + url: '{ot2_server_base_url}/protocols/{protocol_id}' + response: + status_code: 200 + json: + data: + analyses: [] + analysisSummaries: + - id: '{analysis_id}' + status: completed + - id: !anystr + status: completed + id: !anything + protocolType: !anything + files: !anything + createdAt: !anything + robotType: !anything + protocolKind: !anything + metadata: !anything + links: !anything + + - name: Create a run from the protocol and a CSV file + request: + url: '{ot2_server_base_url}/runs' + method: POST + json: + data: + protocolId: '{protocol_id}' + runTimeParameterFiles: + liq_handling_csv_file: '{data_file_1_id}' + response: + status_code: 201 + save: + json: + run_id1: data.id + run_time_parameters_data2: data.runTimeParameters + strict: + json:off + json: + data: + id: !anystr + ok: True + createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" + status: idle + runTimeParameters: + - displayName: Liquid handling CSV file + variableName: liq_handling_csv_file + description: A CSV file that contains wells to use for pipetting + type: csv_file + file: + id: '{data_file_1_id}' + name: 'test.csv' + + - name: Create another run from the protocol and a different CSV file + request: + url: '{ot2_server_base_url}/runs' + method: POST + json: + data: + protocolId: '{protocol_id}' + runTimeParameterFiles: + liq_handling_csv_file: '{data_file_2_id}' + response: + status_code: 201 + save: + json: + run_id2: data.id + run_time_parameters_data3: data.runTimeParameters + strict: + json:off + json: + data: + id: !anystr + ok: True + createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" + status: idle + runTimeParameters: + - displayName: Liquid handling CSV file + variableName: liq_handling_csv_file + description: A CSV file that contains wells to use for pipetting + type: csv_file + file: + id: '{data_file_2_id}' + name: 'sample_record.csv' + + - name: Fetch data files used with the protocol so far + request: + url: '{ot2_server_base_url}/protocols/{protocol_id}/dataFiles' + response: + status_code: 200 + json: + meta: + cursor: 0 + totalLength: 3 + data: + - id: '{data_file_1_id}' + name: "test.csv" + createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" + - id: '{data_file_2_id}' + name: "sample_record.csv" + createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" + - id: '{data_file_3_id}' + name: "sample_plates.csv" + createdAt: !re_fullmatch "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+(Z|([+-]\\d{2}:\\d{2}))" 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 f029e945e20..0f729e62d8d 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 @@ -27,7 +27,9 @@ stages: save: json: data_file_id: data.id - status_code: 201 + status_code: + - 201 + - 200 json: data: id: !anystr diff --git a/robot-server/tests/protocols/test_protocol_store.py b/robot-server/tests/protocols/test_protocol_store.py index ff6d4ce7b49..5d413ad7fa3 100644 --- a/robot-server/tests/protocols/test_protocol_store.py +++ b/robot-server/tests/protocols/test_protocol_store.py @@ -585,7 +585,7 @@ async def test_get_referenced_data_files( subject.insert(protocol_resource_1) await data_files_store.insert( DataFileInfo( - id="data-file-id", + id="data-file-id-1", name="file-name", file_hash="abc123", created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), @@ -632,7 +632,7 @@ async def test_get_referenced_data_files( CSVParameterResource( analysis_id="analysis-id-1", parameter_variable_name="csv-var", - file_id="data-file-id", + file_id="data-file-id-1", ), CSVParameterResource( analysis_id="analysis-id-1", @@ -648,23 +648,20 @@ async def test_get_referenced_data_files( ) result = await subject.get_referenced_data_files("protocol-id") - for data_file in result: - assert data_file in [ - DataFile( - id="data-file-id", - name="file-name", - createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), - ), - DataFile( - id="data-file-id-2", - name="file-name", - createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), - ), - DataFile( - id="data-file-id-3", - name="file-name", - createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), - ), - ] - - assert len(result) == 3 + assert result == [ + DataFile( + id="data-file-id-1", + name="file-name", + createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ), + DataFile( + id="data-file-id-2", + name="file-name", + createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ), + DataFile( + id="data-file-id-3", + name="file-name", + createdAt=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ), + ] diff --git a/robot-server/tests/runs/test_run_data_manager.py b/robot-server/tests/runs/test_run_data_manager.py index dc19c8b4abc..ff1f70da399 100644 --- a/robot-server/tests/runs/test_run_data_manager.py +++ b/robot-server/tests/runs/test_run_data_manager.py @@ -214,6 +214,7 @@ async def test_create( modules=engine_state_summary.modules, liquids=engine_state_summary.liquids, ) + decoy.verify(mock_run_store.insert_csv_rtp(run_id=run_id, run_time_parameters=[])) async def test_create_with_options( @@ -299,6 +300,11 @@ async def test_create_with_options( liquids=engine_state_summary.liquids, runTimeParameters=[bool_parameter, file_parameter], ) + decoy.verify( + mock_run_store.insert_csv_rtp( + run_id=run_id, run_time_parameters=[bool_parameter, file_parameter] + ) + ) async def test_create_engine_error( diff --git a/robot-server/tests/runs/test_run_store.py b/robot-server/tests/runs/test_run_store.py index f4b2b8e154f..74dcffac14f 100644 --- a/robot-server/tests/runs/test_run_store.py +++ b/robot-server/tests/runs/test_run_store.py @@ -484,7 +484,12 @@ def test_get_all_runs( assert result == expected_result -def test_remove_run(subject: RunStore, mock_runs_publisher: mock.Mock) -> None: +async def test_remove_run( + subject: RunStore, + mock_runs_publisher: mock.Mock, + data_files_store: DataFilesStore, + run_time_parameters: List[pe_types.RunTimeParameter], +) -> None: """It can remove a previously stored run entry.""" action = RunAction( actionType=RunActionType.PLAY, @@ -498,6 +503,15 @@ def test_remove_run(subject: RunStore, mock_runs_publisher: mock.Mock) -> None: created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), ) subject.insert_action(run_id="run-id", action=action) + await data_files_store.insert( + DataFileInfo( + id="file-id", + name="my_csv_file.csv", + file_hash="file-hash", + created_at=datetime(year=2024, month=1, day=1, tzinfo=timezone.utc), + ) + ) + subject.insert_csv_rtp(run_id="run-id", run_time_parameters=run_time_parameters) subject.remove(run_id="run-id") assert subject.get_all(length=20) == [] From f851fc9a0255ac33ea5038ca57bb574d7b435019 Mon Sep 17 00:00:00 2001 From: Sanniti Pimpley Date: Wed, 7 Aug 2024 15:25:17 -0400 Subject: [PATCH 14/14] refactor(robot-server): RTP handling improvements (#15919) # Overview This PR addresses some refactor suggestions made in previous PRs ## Changelog - check if data file directory exists before deleting it - refactored `FileInUseError`'s message formatting - small code improvement in `completed_analysis_store` ## Risk assessment None. --- .../data_files/data_files_store.py | 23 ++++++------------- .../robot_server/data_files/models.py | 21 ++++++++++++++++- .../protocols/completed_analysis_store.py | 11 +++++---- .../tests/data_files/test_data_files_store.py | 6 +++++ 4 files changed, 40 insertions(+), 21 deletions(-) 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 785046d80a1..a209dfc8e3a 100644 --- a/robot-server/robot_server/data_files/data_files_store.py +++ b/robot-server/robot_server/data_files/data_files_store.py @@ -145,26 +145,17 @@ def remove(self, file_id: str) -> None: 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 ''}.", + ids_used_in_runs=files_used_in_runs, + ids_used_in_analyses=files_used_in_analyses, ) - transaction.execute(delete_statement) - + result = transaction.execute(delete_statement) + if result.rowcount < 1: + raise FileIdNotFoundError(file_id) file_dir = self._data_files_directory.joinpath(file_id) - if file_dir: + if file_dir.exists(): for file in file_dir.glob("*"): file.unlink() file_dir.rmdir() diff --git a/robot-server/robot_server/data_files/models.py b/robot-server/robot_server/data_files/models.py index f5a9800452b..77a9114203c 100644 --- a/robot-server/robot_server/data_files/models.py +++ b/robot-server/robot_server/data_files/models.py @@ -1,5 +1,6 @@ """Data files models.""" from datetime import datetime +from typing import Set from pydantic import Field @@ -30,7 +31,25 @@ def __init__(self, data_file_id: str) -> None: class FileInUseError(GeneralError): """Error raised when a file being removed is in use.""" - def __init__(self, data_file_id: str, message: str) -> None: + def __init__( + self, + data_file_id: str, + ids_used_in_runs: Set[str], + ids_used_in_analyses: Set[str], + ) -> None: + analysis_usage_text = ( + f" analyses: {ids_used_in_analyses}" + if len(ids_used_in_analyses) > 0 + else None + ) + runs_usage_text = ( + f" runs: {ids_used_in_runs}" if len(ids_used_in_runs) > 0 else None + ) + conjunction = " and " if analysis_usage_text and runs_usage_text else "" + message = ( + f"Cannot remove file {data_file_id} as it is being used in" + f" existing{analysis_usage_text or ''}{conjunction}{runs_usage_text or ''}." + ) super().__init__( message=message, detail={"dataFileId": data_file_id}, diff --git a/robot-server/robot_server/protocols/completed_analysis_store.py b/robot-server/robot_server/protocols/completed_analysis_store.py index bf8cca74871..eb473c7692d 100644 --- a/robot-server/robot_server/protocols/completed_analysis_store.py +++ b/robot-server/robot_server/protocols/completed_analysis_store.py @@ -280,10 +280,13 @@ def get_primitive_rtps_by_analysis_id( with self._sql_engine.begin() as transaction: results = transaction.execute(statement).all() - rtps: Dict[str, PrimitiveAllowedTypes] = {} - for row in results: - param = PrimitiveParameterResource.from_sql_row(row) - rtps.update({param.parameter_variable_name: param.parameter_value}) + param_resources = [ + PrimitiveParameterResource.from_sql_row(row) for row in results + ] + rtps = { + param.parameter_variable_name: param.parameter_value + for param in param_resources + } return rtps def get_csv_rtps_by_analysis_id( 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 33cb31e2621..caef1599961 100644 --- a/robot-server/tests/data_files/test_data_files_store.py +++ b/robot-server/tests/data_files/test_data_files_store.py @@ -265,3 +265,9 @@ async def test_remove_raises_in_file_in_use( 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") + + +def test_remove_raise_for_nonexistent_id(subject: DataFilesStore) -> None: + """It should raise FileIdNotFound error.""" + with pytest.raises(FileIdNotFoundError, match="Data file file-id was not found."): + subject.remove(file_id="file-id")