diff --git a/.github/workflows/app-test-build-deploy.yaml b/.github/workflows/app-test-build-deploy.yaml index 99c26379816..8c3bd21503d 100644 --- a/.github/workflows/app-test-build-deploy.yaml +++ b/.github/workflows/app-test-build-deploy.yaml @@ -185,24 +185,45 @@ jobs: echo "both develop builds for edge" echo 'variants=["release", "internal-release"]' >> $GITHUB_OUTPUT echo 'type=develop' >> $GITHUB_OUTPUT - elif [ "${{ format('{0}', endsWith(github.ref, 'app-build-internal')) }}" = "true" ] ; then - echo "internal-release builds for app-build-internal suffixes" + elif [ "${{ format('{0}', contains(github.ref, 'app-build-internal')) }}" = "true" ] ; then + echo 'variants=["internal-release"]' >> $GITHUB_OUTPUT - echo 'type=develop' >> $GITHUB_OUTPUT - elif [ "${{ format('{0}', endsWith(github.ref, 'app-build')) }}" = "true" ] ; then - echo "release develop builds for app-build suffixes" + if [ "${{ format('{0}', contains(github.ref, 'as-release')) }}" = "true" ] ; then + echo "internal-release as-release builds for app-build-internal + as-release suffixes" + echo 'type=as-release' >> $GITHUB_OUTPUT + else + echo "internal-release develop builds for app-build-internal suffixes" + echo 'type=develop' >> $GITHUB_OUTPUT + fi + elif [ "${{ format('{0}', contains(github.ref, 'app-build')) }}" = "true" ] ; then echo 'variants=["release"]' >> $GITHUB_OUTPUT - echo 'type=develop' >> $GITHUB_OUTPUT - elif [ "${{ format('{0}', endsWith(github.ref, 'app-build-both')) }}" = "true" ] ; then - echo "Both develop builds for app-build-both suffixes" + if [ "${{ format('{0}', contains(github.ref, 'as-release')) }}" = "true" ] ; then + echo "release as-release builds for app-build + as-release suffixes" + echo 'type=as-release' >> $GITHUB_OUTPUT + else + echo "release develop builds for app-build suffixes" + echo 'type=develop' >> $GITHUB_OUTPUT + fi + elif [ "${{ format('{0}', contains(github.ref, 'app-build-both')) }}" = "true" ] ; then + echo 'variants=["release", "internal-release"]' >> $GITHUB_OUTPUT - echo 'type=develop' >> $GITHUB_OUTPUT + if [ "${{ format('{0}', contains(github.ref, 'as-release')) }}" = "true" ] ; then + echo "Both as-release builds for app-build-both + as-release suffixes" + echo 'type=as-release' >> $GITHUB_OUTPUT + else + echo "Both develop builds for app-build-both + as-release suffixes" + echo 'type=develop' >> $GITHUB_OUTPUT + fi else echo "No build for ref ${{github.ref}} and event ${{github.event_type}}" echo 'variants=[]' >> $GITHUB_OUTPUT echo 'type=develop' >> $GITHUB_OUTPUT fi + - name: set summary + run: | + echo 'Type: ${{steps.determine-build-type.outputs.type}} Variants: ${{steps.determine-build-type.outputs.variants}}' >> $GITHUB_STEP_SUMMARY + build-app: needs: [determine-build-type] if: needs.determine-build-type.outputs.variants != '[]' @@ -278,6 +299,34 @@ jobs: npm config set cache ${{ github.workspace }}/.npm-cache yarn config set cache-folder ${{ github.workspace }}/.yarn-cache make setup-js + + - name: 'Configure Windows code signing environment' + if: startsWith(matrix.os, 'windows') && contains(needs.determine-build-type.outputs.type, 'release') + shell: bash + run: | + echo "${{ secrets.SM_CLIENT_CERT_FILE_B64 }}" | base64 --decode > /d/Certificate_pkcs12.p12 + echo "${{ secrets.WINDOWS_CSC_B64}}" | base64 --decode > /d/opentrons_labworks_inc.crt + echo "C:\Program Files (x86)\Windows Kits\10\App Certification Kit" >> $GITHUB_PATH + echo "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools" >> $GITHUB_PATH + echo "C:\Program Files\DigiCert\DigiCert Keylocker Tools" >> $GITHUB_PATH + + - name: 'Setup Windows code signing helpers' + if: startsWith(matrix.os, 'windows') && contains(needs.determine-build-type.outputs.type, 'release') + shell: cmd + env: + SM_HOST: ${{ secrets.SM_HOST }} + SM_CLIENT_CERT_FILE: "D:\\Certificate_pkcs12.p12" + SM_CLIENT_CERT_PASSWORD: ${{secrets.SM_CLIENT_CERT_PASSWORD}} + SM_API_KEY: ${{secrets.SM_API_KEY}} + run: | + curl -X GET https://one.digicert.com/signingmanager/api-ui/v1/releases/Keylockertools-windows-x64.msi/download -H "x-api-key:${{secrets.SM_API_KEY}}" -o Keylockertools-windows-x64.msi + msiexec /i Keylockertools-windows-x64.msi /quiet /qn + smksp_registrar.exe list + smctl.exe keypair ls + C:\Windows\System32\certutil.exe -csp "DigiCert Signing Manager KSP" -key -user + smksp_cert_sync.exe + smctl.exe healthcheck --all + # build the desktop app and deploy it - name: 'build ${{matrix.variant}} app for ${{ matrix.os }}' if: matrix.target == 'desktop' @@ -285,8 +334,14 @@ jobs: env: OT_APP_MIXPANEL_ID: ${{ secrets.OT_APP_MIXPANEL_ID }} OT_APP_INTERCOM_ID: ${{ secrets.OT_APP_INTERCOM_ID }} - WIN_CSC_LINK: ${{ secrets.OT_APP_CSC_WINDOWS }} - WIN_CSC_KEY_PASSWORD: ${{ secrets.OT_APP_CSC_KEY_WINDOWS }} + WINDOWS_SIGN: ${{ format('{0}', contains(needs.determine-build-type.outputs.type, 'release')) }} + SM_HOST: ${{secrets.SM_HOST}} + SM_CLIENT_CERT_FILE: "D:\\Certificate_pkcs12.p12" + SM_CLIENT_CERT_PASSWORD: ${{secrets.SM_CLIENT_CERT_PASSWORD}} + SM_API_KEY: ${{secrets.SM_API_KEY}} + SM_CODE_SIGNING_CERT_SHA1_HASH: ${{secrets.SM_CODE_SIGNING_CERT_SHA1_HASH}} + SM_KEYPAIR_ALIAS: ${{secrets.SM_KEYPAIR_ALIAS}} + WINDOWS_CSC_FILEPATH: "D:\\opentrons_labworks_inc.crt" CSC_LINK: ${{ secrets.OT_APP_CSC_MACOS }} CSC_KEY_PASSWORD: ${{ secrets.OT_APP_CSC_KEY_MACOS }} APPLE_ID: ${{ secrets.OT_APP_APPLE_ID }} diff --git a/Makefile b/Makefile index ffdbb8509c0..47191131c7b 100755 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ HARDWARE_DIR := hardware USB_BRIDGE_DIR := usb-bridge NODE_USB_BRIDGE_CLIENT_DIR := usb-bridge/node-client -PYTHON_DIRS := $(API_DIR) $(UPDATE_SERVER_DIR) $(ROBOT_SERVER_DIR) $(SERVER_UTILS_DIR) $(SHARED_DATA_DIR)/python $(G_CODE_TESTING_DIR) $(HARDWARE_DIR) $(USB_BRIDGE_DIR) +PYTHON_DIRS := $(API_DIR) $(UPDATE_SERVER_DIR) $(ROBOT_SERVER_DIR) $(SERVER_UTILS_DIR) $(SHARED_DATA_DIR)/python $(SYSTEM_SERVER_DIR) $(G_CODE_TESTING_DIR) $(HARDWARE_DIR) $(USB_BRIDGE_DIR) # This may be set as an environment variable (and is by CI tasks that upload # to test pypi) to add a .dev extension to the python package versions. If diff --git a/abr-testing/abr_testing/automation/google_sheets_tool.py b/abr-testing/abr_testing/automation/google_sheets_tool.py index 5e464754273..3a6590fa03b 100644 --- a/abr-testing/abr_testing/automation/google_sheets_tool.py +++ b/abr-testing/abr_testing/automation/google_sheets_tool.py @@ -136,7 +136,6 @@ def column_letter_to_index(column_letter: str) -> int: for col_offset, col_values in enumerate(data): column_index = start_column_index + col_offset - # column_letter = index_to_column_letter(column_index) for row_offset, value in enumerate(col_values): row_index = start_row + row_offset try: @@ -163,7 +162,10 @@ def column_letter_to_index(column_letter: str) -> int: ) body = {"requests": requests} - self.spread_sheet.batch_update(body=body) + try: + self.spread_sheet.batch_update(body=body) + except gspread.exceptions.APIError as e: + print(f"ERROR MESSAGE: {e}") def update_cell( self, sheet_title: str, row: int, column: int, single_data: Any diff --git a/abr-testing/abr_testing/data_collection/abr_google_drive.py b/abr-testing/abr_testing/data_collection/abr_google_drive.py index 31eba721503..3bd03cf3e3d 100644 --- a/abr-testing/abr_testing/data_collection/abr_google_drive.py +++ b/abr-testing/abr_testing/data_collection/abr_google_drive.py @@ -39,6 +39,8 @@ def create_data_dictionary( """Pull data from run files and format into a dictionary.""" runs_and_robots: List[Any] = [] runs_and_lpc: List[Dict[str, Any]] = [] + headers: List[str] = [] + headers_lpc: List[str] = [] for filename in os.listdir(storage_directory): file_path = os.path.join(storage_directory, filename) if file_path.endswith(".json"): @@ -49,7 +51,14 @@ def create_data_dictionary( if not isinstance(file_results, dict): continue run_id = file_results.get("run_id", "NaN") + try: + start_time_test = file_results["startedAt"] + completed_time_test = file_results["completedAt"] + except KeyError: + print(f"Run {run_id} is incomplete. Skipping run.") + continue if run_id in runs_to_save: + print("started reading run.") robot = file_results.get("robot_name") protocol_name = file_results["protocol"]["metadata"].get("protocolName", "") software_version = file_results.get("API_Version", "") @@ -74,13 +83,13 @@ def create_data_dictionary( ) try: start_time = datetime.strptime( - file_results.get("startedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + start_time_test, "%Y-%m-%dT%H:%M:%S.%f%z" ) adjusted_start_time = start_time - timedelta(hours=4) start_date = str(adjusted_start_time.date()) start_time_str = str(adjusted_start_time).split("+")[0] complete_time = datetime.strptime( - file_results.get("completedAt", ""), "%Y-%m-%dT%H:%M:%S.%f%z" + completed_time_test, "%Y-%m-%dT%H:%M:%S.%f%z" ) adjusted_complete_time = complete_time - timedelta(hours=4) complete_time_str = str(adjusted_complete_time).split("+")[0] @@ -130,8 +139,7 @@ def create_data_dictionary( **pipette_dict, **plate_measure, } - headers: List[str] = list(row_2.keys()) - # runs_and_robots[run_id] = row_2 + headers = list(row_2.keys()) runs_and_robots.append(list(row_2.values())) # LPC Data Recording runs_and_lpc, headers_lpc = read_robot_logs.lpc_data( @@ -139,6 +147,8 @@ def create_data_dictionary( ) else: continue + num_of_runs_read = len(runs_and_robots) + print(f"Number of runs read: {num_of_runs_read}") transposed_runs_and_robots = list(map(list, zip(*runs_and_robots))) transposed_runs_and_lpc = list(map(list, zip(*runs_and_lpc))) return transposed_runs_and_robots, headers, transposed_runs_and_lpc, headers_lpc @@ -207,7 +217,6 @@ def create_data_dictionary( start_row = google_sheet.get_index_row() + 1 print(start_row) google_sheet.batch_update_cells(transposed_runs_and_robots, "A", start_row, "0") - # Calculate Robot Lifetimes # Add LPC to google sheet google_sheet_lpc = google_sheets_tool.google_sheet(credentials_path, "ABR-LPC", 0) @@ -216,4 +225,5 @@ def create_data_dictionary( transposed_runs_and_lpc, "A", start_row_lpc, "0" ) robots = list(set(google_sheet.get_column(1))) + # Calculate Robot Lifetimes sync_abr_sheet.determine_lifetime(google_sheet) diff --git a/abr-testing/abr_testing/tools/abr_scale.py b/abr-testing/abr_testing/tools/abr_scale.py index ad526792cf8..d02bf0acfed 100644 --- a/abr-testing/abr_testing/tools/abr_scale.py +++ b/abr-testing/abr_testing/tools/abr_scale.py @@ -123,9 +123,25 @@ def get_most_recent_run_and_record( most_recent_run_id = run_list[-1]["id"] results = get_run_logs.get_run_data(most_recent_run_id, ip) # Save run information to local directory as .json file - read_robot_logs.save_run_log_to_json(ip, results, storage_directory) + saved_file_path = read_robot_logs.save_run_log_to_json( + ip, results, storage_directory + ) + # Check that last run is completed. + with open(saved_file_path) as file: + file_results = json.load(file) + try: + file_results["completedAt"] + except ValueError: + # no completedAt field, get run before the last run. + most_recent_run_id = run_list[-2]["id"] + results = get_run_logs.get_run_data(most_recent_run_id, ip) + # Save run information to local directory as .json file + saved_file_path = read_robot_logs.save_run_log_to_json( + ip, results, storage_directory + ) # Record run to google sheets. print(most_recent_run_id) + ( runs_and_robots, headers, diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[89a8226c4e][Flex_X_v2_16_P1000_96_TC_PartialTipPickupThermocyclerLidConflict].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[89a8226c4e][Flex_X_v2_16_P1000_96_TC_PartialTipPickupThermocyclerLidConflict].json index c30512b818b..cf0293eee21 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[89a8226c4e][Flex_X_v2_16_P1000_96_TC_PartialTipPickupThermocyclerLidConflict].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[89a8226c4e][Flex_X_v2_16_P1000_96_TC_PartialTipPickupThermocyclerLidConflict].json @@ -4889,39 +4889,6 @@ }, "startedAt": "TIMESTAMP", "status": "succeeded" - }, - { - "commandType": "dispense", - "completedAt": "TIMESTAMP", - "createdAt": "TIMESTAMP", - "id": "UUID", - "key": "517162d1e8d73c035348a1870a8abc8a", - "notes": [], - "params": { - "flowRate": 160.0, - "labwareId": "UUID", - "pipetteId": "UUID", - "volume": 20.0, - "wellLocation": { - "offset": { - "x": 0.0, - "y": 0.0, - "z": -9.8 - }, - "origin": "top" - }, - "wellName": "A2" - }, - "result": { - "position": { - "x": 23.28, - "y": 181.18, - "z": 4.5 - }, - "volume": 20.0 - }, - "startedAt": "TIMESTAMP", - "status": "succeeded" } ], "config": { @@ -4935,7 +4902,7 @@ "errors": [ { "createdAt": "TIMESTAMP", - "detail": "PartialTipMovementNotAllowedError [line 26]: Error 2004 MOTION_PLANNING_FAILURE (PartialTipMovementNotAllowedError): Moving to NEST 96 Well Plate 200 µL Flat in slot A2 with A12 nozzle partial configuration will result in collision with thermocycler lid in deck slot A1.", + "detail": "PartialTipMovementNotAllowedError [line 24]: Error 2004 MOTION_PLANNING_FAILURE (PartialTipMovementNotAllowedError): Requested motion with the A12 nozzle partial configuration is outside of robot bounds for the pipette.", "errorCode": "4000", "errorInfo": {}, "errorType": "ExceptionInProtocolError", @@ -4944,7 +4911,7 @@ "wrappedErrors": [ { "createdAt": "TIMESTAMP", - "detail": "Moving to NEST 96 Well Plate 200 µL Flat in slot A2 with A12 nozzle partial configuration will result in collision with thermocycler lid in deck slot A1.", + "detail": "Requested motion with the A12 nozzle partial configuration is outside of robot bounds for the pipette.", "errorCode": "2004", "errorInfo": {}, "errorType": "PartialTipMovementNotAllowedError", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[8fcfd2ced0][Flex_S_v2_16_P1000_96_TC_PartialTipPickupColumn].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[8fcfd2ced0][Flex_S_v2_16_P1000_96_TC_PartialTipPickupColumn].json index 10ee86bd162..02df13c1a33 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[8fcfd2ced0][Flex_S_v2_16_P1000_96_TC_PartialTipPickupColumn].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[8fcfd2ced0][Flex_S_v2_16_P1000_96_TC_PartialTipPickupColumn].json @@ -3606,82 +3606,6 @@ }, "startedAt": "TIMESTAMP", "status": "succeeded" - }, - { - "commandType": "dispense", - "completedAt": "TIMESTAMP", - "createdAt": "TIMESTAMP", - "id": "UUID", - "key": "ddccee6754fe0092b9c66898d66b79a7", - "notes": [], - "params": { - "flowRate": 160.0, - "labwareId": "UUID", - "pipetteId": "UUID", - "volume": 20.0, - "wellLocation": { - "offset": { - "x": 0.0, - "y": 0.0, - "z": -9.8 - }, - "origin": "top" - }, - "wellName": "A2" - }, - "result": { - "position": { - "x": 23.28, - "y": 181.18, - "z": 4.5 - }, - "volume": 20.0 - }, - "startedAt": "TIMESTAMP", - "status": "succeeded" - }, - { - "commandType": "moveToAddressableAreaForDropTip", - "completedAt": "TIMESTAMP", - "createdAt": "TIMESTAMP", - "id": "UUID", - "key": "5287b77e909d217f4b05e5006cf9ff25", - "notes": [], - "params": { - "addressableAreaName": "movableTrashA3", - "alternateDropLocation": true, - "forceDirect": false, - "ignoreTipConfiguration": true, - "offset": { - "x": 0.0, - "y": 0.0, - "z": 0.0 - }, - "pipetteId": "UUID" - }, - "result": { - "position": { - "x": 466.25, - "y": 364.0, - "z": 40.0 - } - }, - "startedAt": "TIMESTAMP", - "status": "succeeded" - }, - { - "commandType": "dropTipInPlace", - "completedAt": "TIMESTAMP", - "createdAt": "TIMESTAMP", - "id": "UUID", - "key": "b81364c35c04784c34f571446e64484c", - "notes": [], - "params": { - "pipetteId": "UUID" - }, - "result": {}, - "startedAt": "TIMESTAMP", - "status": "succeeded" } ], "config": { @@ -3692,7 +3616,29 @@ "protocolType": "python" }, "createdAt": "TIMESTAMP", - "errors": [], + "errors": [ + { + "createdAt": "TIMESTAMP", + "detail": "PartialTipMovementNotAllowedError [line 20]: Error 2004 MOTION_PLANNING_FAILURE (PartialTipMovementNotAllowedError): Requested motion with the A12 nozzle partial configuration is outside of robot bounds for the pipette.", + "errorCode": "4000", + "errorInfo": {}, + "errorType": "ExceptionInProtocolError", + "id": "UUID", + "isDefined": false, + "wrappedErrors": [ + { + "createdAt": "TIMESTAMP", + "detail": "Requested motion with the A12 nozzle partial configuration is outside of robot bounds for the pipette.", + "errorCode": "2004", + "errorInfo": {}, + "errorType": "PartialTipMovementNotAllowedError", + "id": "UUID", + "isDefined": false, + "wrappedErrors": [] + } + ] + } + ], "files": [ { "name": "Flex_S_v2_16_P1000_96_TC_PartialTipPickupColumn.py", @@ -3735,7 +3681,7 @@ "pipetteName": "p1000_96" } ], - "result": "ok", + "result": "not-ok", "robotType": "OT-3 Standard", "runTimeParameters": [] } diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[adc0621263][Flex_X_v2_16_P1000_96_TC_pipetteCollisionWithThermocyclerLid].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[adc0621263][Flex_X_v2_16_P1000_96_TC_pipetteCollisionWithThermocyclerLid].json index 66957b72660..a3cf2d44d05 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[adc0621263][Flex_X_v2_16_P1000_96_TC_pipetteCollisionWithThermocyclerLid].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[adc0621263][Flex_X_v2_16_P1000_96_TC_pipetteCollisionWithThermocyclerLid].json @@ -6116,39 +6116,6 @@ }, "startedAt": "TIMESTAMP", "status": "succeeded" - }, - { - "commandType": "dispense", - "completedAt": "TIMESTAMP", - "createdAt": "TIMESTAMP", - "id": "UUID", - "key": "e1b16944e3d0ff8ae0a964f7e638c1b3", - "notes": [], - "params": { - "flowRate": 160.0, - "labwareId": "UUID", - "pipetteId": "UUID", - "volume": 20.0, - "wellLocation": { - "offset": { - "x": 0.0, - "y": 0.0, - "z": -9.8 - }, - "origin": "top" - }, - "wellName": "A2" - }, - "result": { - "position": { - "x": 23.28, - "y": 181.18, - "z": 4.5 - }, - "volume": 20.0 - }, - "startedAt": "TIMESTAMP", - "status": "succeeded" } ], "config": { @@ -6162,7 +6129,7 @@ "errors": [ { "createdAt": "TIMESTAMP", - "detail": "PartialTipMovementNotAllowedError [line 28]: Error 2004 MOTION_PLANNING_FAILURE (PartialTipMovementNotAllowedError): Moving to NEST 96 Well Plate 200 µL Flat in slot A2 with A12 nozzle partial configuration will result in collision with thermocycler lid in deck slot A1.", + "detail": "PartialTipMovementNotAllowedError [line 25]: Error 2004 MOTION_PLANNING_FAILURE (PartialTipMovementNotAllowedError): Requested motion with the A12 nozzle partial configuration is outside of robot bounds for the pipette.", "errorCode": "4000", "errorInfo": {}, "errorType": "ExceptionInProtocolError", @@ -6171,7 +6138,7 @@ "wrappedErrors": [ { "createdAt": "TIMESTAMP", - "detail": "Moving to NEST 96 Well Plate 200 µL Flat in slot A2 with A12 nozzle partial configuration will result in collision with thermocycler lid in deck slot A1.", + "detail": "Requested motion with the A12 nozzle partial configuration is outside of robot bounds for the pipette.", "errorCode": "2004", "errorInfo": {}, "errorType": "PartialTipMovementNotAllowedError", diff --git a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[b0ce7dde5d][Flex_X_v2_16_P1000_96_TC_PartialTipPickupTryToReturnTip].json b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[b0ce7dde5d][Flex_X_v2_16_P1000_96_TC_PartialTipPickupTryToReturnTip].json index cdb9d4235a9..32e9e2f9294 100644 --- a/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[b0ce7dde5d][Flex_X_v2_16_P1000_96_TC_PartialTipPickupTryToReturnTip].json +++ b/analyses-snapshot-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[b0ce7dde5d][Flex_X_v2_16_P1000_96_TC_PartialTipPickupTryToReturnTip].json @@ -3606,39 +3606,6 @@ }, "startedAt": "TIMESTAMP", "status": "succeeded" - }, - { - "commandType": "dispense", - "completedAt": "TIMESTAMP", - "createdAt": "TIMESTAMP", - "id": "UUID", - "key": "ddccee6754fe0092b9c66898d66b79a7", - "notes": [], - "params": { - "flowRate": 160.0, - "labwareId": "UUID", - "pipetteId": "UUID", - "volume": 20.0, - "wellLocation": { - "offset": { - "x": 0.0, - "y": 0.0, - "z": -9.8 - }, - "origin": "top" - }, - "wellName": "A2" - }, - "result": { - "position": { - "x": 23.28, - "y": 181.18, - "z": 4.5 - }, - "volume": 20.0 - }, - "startedAt": "TIMESTAMP", - "status": "succeeded" } ], "config": { @@ -3652,7 +3619,7 @@ "errors": [ { "createdAt": "TIMESTAMP", - "detail": "UnexpectedProtocolError [line 22]: Error 4000 GENERAL_ERROR (UnexpectedProtocolError): Cannot return tip to a tiprack while the pipette is configured for partial tip.", + "detail": "PartialTipMovementNotAllowedError [line 21]: Error 2004 MOTION_PLANNING_FAILURE (PartialTipMovementNotAllowedError): Requested motion with the A12 nozzle partial configuration is outside of robot bounds for the pipette.", "errorCode": "4000", "errorInfo": {}, "errorType": "ExceptionInProtocolError", @@ -3661,10 +3628,10 @@ "wrappedErrors": [ { "createdAt": "TIMESTAMP", - "detail": "Cannot return tip to a tiprack while the pipette is configured for partial tip.", - "errorCode": "4000", + "detail": "Requested motion with the A12 nozzle partial configuration is outside of robot bounds for the pipette.", + "errorCode": "2004", "errorInfo": {}, - "errorType": "UnexpectedProtocolError", + "errorType": "PartialTipMovementNotAllowedError", "id": "UUID", "isDefined": false, "wrappedErrors": [] diff --git a/api-client/src/dataFiles/uploadCsvFile.ts b/api-client/src/dataFiles/uploadCsvFile.ts index 2c0e6ee39a5..8f48216379a 100644 --- a/api-client/src/dataFiles/uploadCsvFile.ts +++ b/api-client/src/dataFiles/uploadCsvFile.ts @@ -8,15 +8,14 @@ export function uploadCsvFile( config: HostConfig, data: FileData ): ResponsePromise { - let formData + const formData = new FormData() if (typeof data !== 'string') { - formData = new FormData() formData.append('file', data) } else { - formData = data + formData.append('filePath', data) } - return request( + return request( POST, '/dataFiles', formData, diff --git a/api/setup.py b/api/setup.py index 1b2a7dde508..8c1dd7cfa63 100755 --- a/api/setup.py +++ b/api/setup.py @@ -59,7 +59,7 @@ def get_version(): f"opentrons-shared-data=={VERSION}", "aionotify==0.3.1", "anyio>=3.6.1,<4.0.0", - "jsonschema>=3.0.1,<4.18.0", + "jsonschema>=3.0.1,<5", "numpy>=1.20.0,<2", "pydantic>=1.10.9,<2.0.0", "pyserial>=3.5", diff --git a/api/src/opentrons/protocol_api/core/engine/deck_conflict.py b/api/src/opentrons/protocol_api/core/engine/deck_conflict.py index 2a50964e757..405aa2256a7 100644 --- a/api/src/opentrons/protocol_api/core/engine/deck_conflict.py +++ b/api/src/opentrons/protocol_api/core/engine/deck_conflict.py @@ -16,7 +16,6 @@ from opentrons_shared_data.errors.exceptions import MotionPlanningFailureError from opentrons_shared_data.module import FLEX_TC_LID_COLLISION_ZONE -from opentrons.hardware_control.nozzle_manager import NozzleConfigurationType from opentrons.hardware_control.modules.types import ModuleType from opentrons.motion_planning import deck_conflict as wrapped_deck_conflict from opentrons.motion_planning import adjacent_slots_getters @@ -63,21 +62,6 @@ def __init__(self, message: str) -> None: _log = logging.getLogger(__name__) -# TODO (spp, 2023-12-06): move this to a location like motion planning where we can -# derive these values from geometry definitions -# Also, verify y-axis extents values for the nozzle columns. -# Bounding box measurements -A12_column_front_left_bound = Point(x=-11.03, y=2) -A12_column_back_right_bound = Point(x=526.77, y=506.2) - -_NOZZLE_PITCH = 9 -A1_column_front_left_bound = Point( - x=A12_column_front_left_bound.x - _NOZZLE_PITCH * 11, y=2 -) -A1_column_back_right_bound = Point( - x=A12_column_back_right_bound.x - _NOZZLE_PITCH * 11, y=506.2 -) - _FLEX_TC_LID_BACK_LEFT_PT = Point( x=FLEX_TC_LID_COLLISION_ZONE["back_left"]["x"], y=FLEX_TC_LID_COLLISION_ZONE["back_left"]["y"], @@ -244,8 +228,15 @@ def check_safe_for_pipette_movement( ) primary_nozzle = engine_state.pipettes.get_primary_nozzle(pipette_id) + pipette_bounds_at_well_location = ( + engine_state.pipettes.get_pipette_bounds_at_specified_move_to_position( + pipette_id=pipette_id, destination_position=well_location_point + ) + ) if not _is_within_pipette_extents( - engine_state=engine_state, pipette_id=pipette_id, location=well_location_point + engine_state=engine_state, + pipette_id=pipette_id, + pipette_bounding_box_at_loc=pipette_bounds_at_well_location, ): raise PartialTipMovementNotAllowedError( f"Requested motion with the {primary_nozzle} nozzle partial configuration" @@ -253,11 +244,7 @@ def check_safe_for_pipette_movement( ) labware_slot = engine_state.geometry.get_ancestor_slot_name(labware_id) - pipette_bounds_at_well_location = ( - engine_state.pipettes.get_pipette_bounds_at_specified_move_to_position( - pipette_id=pipette_id, destination_position=well_location_point - ) - ) + surrounding_slots = adjacent_slots_getters.get_surrounding_slots( slot=labware_slot.as_int(), robot_type=engine_state.config.robot_type ) @@ -423,42 +410,30 @@ def check_safe_for_tip_pickup_and_return( ) -# TODO (spp, 2023-02-06): update the extents check to use all nozzle bounds instead of -# just position of primary nozzle when checking if the pipette is out-of-bounds def _is_within_pipette_extents( engine_state: StateView, pipette_id: str, - location: Point, + pipette_bounding_box_at_loc: Tuple[Point, Point, Point, Point], ) -> bool: """Whether a given point is within the extents of a configured pipette on the specified robot.""" - robot_type = engine_state.config.robot_type - pipette_channels = engine_state.pipettes.get_channels(pipette_id) - nozzle_config = engine_state.pipettes.get_nozzle_layout_type(pipette_id) - primary_nozzle = engine_state.pipettes.get_primary_nozzle(pipette_id) - if robot_type == "OT-3 Standard": - if pipette_channels == 96 and nozzle_config == NozzleConfigurationType.COLUMN: - # TODO (spp, 2023-12-18): change this eventually to use column mappings in - # the pipette geometry definitions. - if primary_nozzle == "A12": - return ( - A12_column_front_left_bound.x - <= location.x - <= A12_column_back_right_bound.x - and A12_column_front_left_bound.y - <= location.y - <= A12_column_back_right_bound.y - ) - elif primary_nozzle == "A1": - return ( - A1_column_front_left_bound.x - <= location.x - <= A1_column_back_right_bound.x - and A1_column_front_left_bound.y - <= location.y - <= A1_column_back_right_bound.y - ) - # TODO (spp, 2023-11-07): check for 8-channel nozzle A1 & H1 extents on Flex & OT2 - return True + mount = engine_state.pipettes.get_mount(pipette_id) + robot_extent_per_mount = engine_state.geometry.absolute_deck_extents + pip_back_left_bound, pip_front_right_bound, _, _ = pipette_bounding_box_at_loc + pipette_bounds_offsets = engine_state.pipettes.get_pipette_bounding_box(pipette_id) + from_back_right = ( + robot_extent_per_mount.back_right[mount] + + pipette_bounds_offsets.back_right_corner + ) + from_front_left = ( + robot_extent_per_mount.front_left[mount] + + pipette_bounds_offsets.front_left_corner + ) + return ( + from_back_right.x >= pip_back_left_bound.x >= from_front_left.x + and from_back_right.y >= pip_back_left_bound.y >= from_front_left.y + and from_back_right.x >= pip_front_right_bound.x >= from_front_left.x + and from_back_right.y >= pip_front_right_bound.y >= from_front_left.y + ) def _map_labware( diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index c2cc70f39f3..d89e946dadc 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -2,7 +2,6 @@ from __future__ import annotations from typing import Optional, TYPE_CHECKING, cast, Union -from opentrons.protocol_engine.commands.liquid_probe import LiquidProbeResult from opentrons.protocols.api_support.types import APIVersion from opentrons.types import Location, Mount @@ -838,6 +837,44 @@ def retract(self) -> None: z_axis = self._engine_client.state.pipettes.get_z_axis(self._pipette_id) self._engine_client.execute_command(cmd.HomeParams(axes=[z_axis])) + def detect_liquid_presence(self, well_core: WellCore, loc: Location) -> bool: + labware_id = well_core.labware_id + well_name = well_core.get_name() + well_location = WellLocation( + origin=WellOrigin.TOP, offset=WellOffset(x=0, y=0, z=0) + ) + + # The error handling here is a bit nuanced and also a bit broken: + # + # - If the hardware detects liquid, the `tryLiquidProbe` engine command will + # succeed and return a height, which we'll convert to a `True` return. + # Okay so far. + # + # - If the hardware detects no liquid, the `tryLiquidProbe` engine command will + # succeed and return `None`, which we'll convert to a `False` return. + # Still okay so far. + # + # - If there is any other error within the `tryLiquidProbe` command, things get + # messy. It may kick the run into recovery mode. At that point, all bets are + # off--we lose our guarantee of having a `tryLiquidProbe` command whose + # `result` we can inspect. We don't know how to deal with that here, so we + # currently propagate the exception up, which will quickly kill the protocol, + # after a potential split second of recovery mode. It's unclear what would + # be good user-facing behavior here, but it's unfortunate to kill the protocol + # for an error that the engine thinks should be recoverable. + result = self._engine_client.execute_command_without_recovery( + cmd.TryLiquidProbeParams( + labwareId=labware_id, + wellName=well_name, + wellLocation=well_location, + pipetteId=self.pipette_id, + ) + ) + + self._protocol_core.set_last_location(location=loc, mount=self.get_mount()) + + return result.z_position is not None + def liquid_probe_with_recovery(self, well_core: WellCore, loc: Location) -> None: labware_id = well_core.labware_id well_name = well_core.get_name() @@ -874,5 +911,4 @@ def liquid_probe_without_recovery( self._protocol_core.set_last_location(location=loc, mount=self.get_mount()) - if result is not None and isinstance(result, LiquidProbeResult): - return result.z_position + return result.z_position diff --git a/api/src/opentrons/protocol_api/core/instrument.py b/api/src/opentrons/protocol_api/core/instrument.py index 2a66b8e513f..1695f96e5db 100644 --- a/api/src/opentrons/protocol_api/core/instrument.py +++ b/api/src/opentrons/protocol_api/core/instrument.py @@ -305,6 +305,12 @@ def retract(self) -> None: """Retract this instrument to the top of the gantry.""" ... + @abstractmethod + def detect_liquid_presence( + self, well_core: WellCoreType, loc: types.Location + ) -> bool: + """Do a liquid probe to detect whether there is liquid in the well.""" + @abstractmethod def liquid_probe_with_recovery( self, well_core: WellCoreType, loc: types.Location diff --git a/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py b/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py index adcc5137f93..a831a9113f2 100644 --- a/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py +++ b/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py @@ -566,6 +566,10 @@ def retract(self) -> None: """Retract this instrument to the top of the gantry.""" self._protocol_interface.get_hardware.retract(self._mount) # type: ignore [attr-defined] + def detect_liquid_presence(self, well_core: WellCore, loc: types.Location) -> bool: + """This will never be called because it was added in API 2.20.""" + assert False, "detect_liquid_presence only supported in API 2.20 & later" + def liquid_probe_with_recovery( self, well_core: WellCore, loc: types.Location ) -> None: diff --git a/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py b/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py index 9938fd35ec7..1471af79fe8 100644 --- a/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py +++ b/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py @@ -484,6 +484,10 @@ def retract(self) -> None: """Retract this instrument to the top of the gantry.""" self._protocol_interface.get_hardware.retract(self._mount) # type: ignore [attr-defined] + def detect_liquid_presence(self, well_core: WellCore, loc: types.Location) -> bool: + """This will never be called because it was added in API 2.20.""" + assert False, "detect_liquid_presence only supported in API 2.20 & later" + def liquid_probe_with_recovery( self, well_core: WellCore, loc: types.Location ) -> None: diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py index c2bf863cb19..05a8ecdc80c 100644 --- a/api/src/opentrons/protocol_api/instrument_context.py +++ b/api/src/opentrons/protocol_api/instrument_context.py @@ -2,8 +2,6 @@ import logging from contextlib import ExitStack from typing import Any, List, Optional, Sequence, Union, cast, Dict -from opentrons.protocol_engine.commands.pipetting_common import LiquidNotFoundError -from opentrons.protocol_engine.errors.error_occurrence import ProtocolCommandFailedError from opentrons_shared_data.errors.exceptions import ( CommandPreconditionViolated, CommandParameterLimitViolated, @@ -2112,15 +2110,7 @@ def detect_liquid_presence(self, well: labware.Well) -> bool: :returns: A boolean. """ loc = well.top() - try: - self._core.liquid_probe_without_recovery(well._core, loc) - except ProtocolCommandFailedError as e: - # if we handle the error, we change the protocl state from error to valid - if isinstance(e.original_error, LiquidNotFoundError): - return False - raise e - else: - return True + return self._core.detect_liquid_presence(well._core, loc) @requires_version(2, 20) def require_liquid_presence(self, well: labware.Well) -> None: diff --git a/api/src/opentrons/protocol_engine/clients/sync_client.py b/api/src/opentrons/protocol_engine/clients/sync_client.py index f772d81cea8..5750ba72d21 100644 --- a/api/src/opentrons/protocol_engine/clients/sync_client.py +++ b/api/src/opentrons/protocol_engine/clients/sync_client.py @@ -83,6 +83,12 @@ def execute_command_without_recovery( ) -> commands.LiquidProbeResult: pass + @overload + def execute_command_without_recovery( + self, params: commands.TryLiquidProbeParams + ) -> commands.TryLiquidProbeResult: + pass + def execute_command_without_recovery( self, params: commands.CommandParams ) -> commands.CommandResult: diff --git a/api/src/opentrons/protocol_engine/create_protocol_engine.py b/api/src/opentrons/protocol_engine/create_protocol_engine.py index fd7b1b8bd5f..8a6a4355fd7 100644 --- a/api/src/opentrons/protocol_engine/create_protocol_engine.py +++ b/api/src/opentrons/protocol_engine/create_protocol_engine.py @@ -7,6 +7,7 @@ from opentrons.hardware_control.types import DoorState from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryPolicy from opentrons.util.async_helpers import async_context_manager_in_thread +from opentrons_shared_data.robot import load as load_robot from .protocol_engine import ProtocolEngine from .resources import DeckDataProvider, ModuleDataProvider @@ -45,11 +46,12 @@ async def create_protocol_engine( else [] ) module_calibration_offsets = ModuleDataProvider.load_module_calibrations() - + robot_definition = load_robot(config.robot_type) state_store = StateStore( config=config, deck_definition=deck_definition, deck_fixed_labware=deck_fixed_labware, + robot_definition=robot_definition, is_door_open=hardware_api.door_state is DoorState.OPEN, module_calibration_offsets=module_calibration_offsets, deck_configuration=deck_configuration, diff --git a/api/src/opentrons/protocol_engine/state/addressable_areas.py b/api/src/opentrons/protocol_engine/state/addressable_areas.py index 85c61bfa917..7e3a0325ed4 100644 --- a/api/src/opentrons/protocol_engine/state/addressable_areas.py +++ b/api/src/opentrons/protocol_engine/state/addressable_areas.py @@ -1,8 +1,9 @@ """Basic addressable area data state and store.""" from dataclasses import dataclass +from functools import cached_property from typing import Dict, List, Optional, Set, Union -from opentrons_shared_data.robot.dev_types import RobotType +from opentrons_shared_data.robot.dev_types import RobotType, RobotDefinition from opentrons_shared_data.deck.dev_types import ( DeckDefinitionV5, SlotDefV3, @@ -77,6 +78,9 @@ class AddressableAreaState: use_simulated_deck_config: bool """See `Config.use_simulated_deck_config`.""" + """Information about the current robot model.""" + robot_definition: RobotDefinition + _OT2_ORDERED_SLOTS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"] _FLEX_ORDERED_SLOTS = [ @@ -164,6 +168,7 @@ def __init__( deck_configuration: DeckConfigurationType, config: Config, deck_definition: DeckDefinitionV5, + robot_definition: RobotDefinition, ) -> None: """Initialize an addressable area store and its state.""" if config.use_simulated_deck_config: @@ -183,6 +188,7 @@ def __init__( deck_definition=deck_definition, robot_type=config.robot_type, use_simulated_deck_config=config.use_simulated_deck_config, + robot_definition=robot_definition, ) def handle_action(self, action: Action) -> None: @@ -330,6 +336,22 @@ def __init__(self, state: AddressableAreaState) -> None: """ self._state = state + @cached_property + def deck_extents(self) -> Point: + """The maximum space on the deck.""" + extents = self._state.robot_definition["extents"] + return Point(x=extents[0], y=extents[1], z=extents[2]) + + @cached_property + def mount_offsets(self) -> Dict[str, Point]: + """The left and right mount offsets of the robot.""" + left_offset = self.state.robot_definition["mountOffsets"]["left"] + right_offset = self.state.robot_definition["mountOffsets"]["right"] + return { + "left": Point(x=left_offset[0], y=left_offset[1], z=left_offset[2]), + "right": Point(x=right_offset[0], y=right_offset[1], z=right_offset[2]), + } + def get_addressable_area(self, addressable_area_name: str) -> AddressableArea: """Get addressable area.""" if not self._state.use_simulated_deck_config: diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index 112d7d60ef4..904e0c470b2 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -3,6 +3,8 @@ from numpy import array, dot, double as npdouble from numpy.typing import NDArray from typing import Optional, List, Tuple, Union, cast, TypeVar, Dict +from dataclasses import dataclass +from functools import cached_property from opentrons.types import Point, DeckSlotName, StagingSlotName, MountType @@ -71,6 +73,12 @@ class _GripperMoveType(enum.Enum): DROP_LABWARE = enum.auto() +@dataclass +class _AbsoluteRobotExtents: + front_left: Dict[MountType, Point] + back_right: Dict[MountType, Point] + + _LabwareLocation = TypeVar("_LabwareLocation", bound=LabwareLocation) @@ -95,6 +103,24 @@ def __init__( self._addressable_areas = addressable_area_view self._last_drop_tip_location_spot: Dict[str, _TipDropSection] = {} + @cached_property + def absolute_deck_extents(self) -> _AbsoluteRobotExtents: + """The absolute deck extents for a given robot deck.""" + left_offset = self._addressable_areas.mount_offsets["left"] + right_offset = self._addressable_areas.mount_offsets["right"] + + front_left_abs = { + MountType.LEFT: Point(left_offset.x, -1 * left_offset.y, left_offset.z), + MountType.RIGHT: Point(right_offset.x, -1 * right_offset.y, right_offset.z), + } + back_right_abs = { + MountType.LEFT: self._addressable_areas.deck_extents + left_offset, + MountType.RIGHT: self._addressable_areas.deck_extents + right_offset, + } + return _AbsoluteRobotExtents( + front_left=front_left_abs, back_right=back_right_abs + ) + def get_labware_highest_z(self, labware_id: str) -> float: """Get the highest Z-point of a labware.""" labware_data = self._labware.get(labware_id) diff --git a/api/src/opentrons/protocol_engine/state/pipettes.py b/api/src/opentrons/protocol_engine/state/pipettes.py index cab42ac7238..92344dd9600 100644 --- a/api/src/opentrons/protocol_engine/state/pipettes.py +++ b/api/src/opentrons/protocol_engine/state/pipettes.py @@ -97,6 +97,8 @@ class PipetteBoundingBoxOffsets: back_left_corner: Point front_right_corner: Point + back_right_corner: Point + front_left_corner: Point @dataclass(frozen=True) @@ -194,6 +196,16 @@ def _handle_command( # noqa: C901 pipette_bounding_box_offsets=PipetteBoundingBoxOffsets( back_left_corner=config.back_left_corner_offset, front_right_corner=config.front_right_corner_offset, + back_right_corner=Point( + config.front_right_corner_offset.x, + config.back_left_corner_offset.y, + config.back_left_corner_offset.z, + ), + front_left_corner=Point( + config.back_left_corner_offset.x, + config.front_right_corner_offset.y, + config.back_left_corner_offset.z, + ), ), bounding_nozzle_offsets=BoundingNozzlesOffsets( back_left_offset=config.nozzle_map.back_left_nozzle_offset, @@ -788,6 +800,10 @@ def get_pipette_bounding_nozzle_offsets( """Get the nozzle offsets of the pipette's bounding nozzles.""" return self.get_config(pipette_id).bounding_nozzle_offsets + def get_pipette_bounding_box(self, pipette_id: str) -> PipetteBoundingBoxOffsets: + """Get the bounding box of the pipette.""" + return self.get_config(pipette_id).pipette_bounding_box_offsets + def get_pipette_bounds_at_specified_move_to_position( self, pipette_id: str, @@ -796,6 +812,7 @@ def get_pipette_bounds_at_specified_move_to_position( """Get the pipette's bounding offsets when primary nozzle is at the given position.""" primary_nozzle_offset = self.get_primary_nozzle_offset(pipette_id) tip = self.get_attached_tip(pipette_id) + # TODO update this for pipette robot stackup # Primary nozzle position at destination, in deck coordinates primary_nozzle_position = destination_position + Point( x=0, y=0, z=tip.length if tip else 0 diff --git a/api/src/opentrons/protocol_engine/state/state.py b/api/src/opentrons/protocol_engine/state/state.py index aa54383b379..e343a4dfde1 100644 --- a/api/src/opentrons/protocol_engine/state/state.py +++ b/api/src/opentrons/protocol_engine/state/state.py @@ -6,6 +6,7 @@ from typing_extensions import ParamSpec from opentrons_shared_data.deck.dev_types import DeckDefinitionV5 +from opentrons_shared_data.robot.dev_types import RobotDefinition from opentrons.protocol_engine.types import ModuleOffsetData from opentrons.util.change_notifier import ChangeNotifier @@ -144,6 +145,7 @@ def __init__( config: Config, deck_definition: DeckDefinitionV5, deck_fixed_labware: Sequence[DeckFixedLabware], + robot_definition: RobotDefinition, is_door_open: bool, change_notifier: Optional[ChangeNotifier] = None, module_calibration_offsets: Optional[Dict[str, ModuleOffsetData]] = None, @@ -162,6 +164,7 @@ def __init__( change_notifier: Internal state change notifier. module_calibration_offsets: Module offsets to preload. deck_configuration: The initial deck configuration the addressable area store will be instantiated with. + robot_definition: Static information about the robot type being used. notify_publishers: Notifies robot server publishers of internal state change. """ self._command_store = CommandStore(config=config, is_door_open=is_door_open) @@ -172,6 +175,7 @@ def __init__( deck_configuration=deck_configuration, config=config, deck_definition=deck_definition, + robot_definition=robot_definition, ) self._labware_store = LabwareStore( deck_fixed_labware=deck_fixed_labware, diff --git a/api/src/opentrons/protocols/advanced_control/transfers.py b/api/src/opentrons/protocols/advanced_control/transfers.py index df1c6961be6..41b69306805 100644 --- a/api/src/opentrons/protocols/advanced_control/transfers.py +++ b/api/src/opentrons/protocols/advanced_control/transfers.py @@ -16,12 +16,16 @@ from opentrons.protocol_api.labware import Labware, Well from opentrons import types from opentrons.protocols.api_support.types import APIVersion +from opentrons.hardware_control.nozzle_manager import NozzleConfigurationType if TYPE_CHECKING: from opentrons.protocol_api import InstrumentContext from opentrons.protocols.execution.dev_types import Dictable +_PARTIAL_TIP_SUPPORT_ADDED = APIVersion(2, 18) +"""The version after which partial tip support and nozzle maps were made available.""" + class MixStrategy(enum.Enum): BOTH = enum.auto() @@ -409,7 +413,15 @@ def __init__( # then avoid iterating through its Wells. # ii. if using single channel pipettes, flatten a multi-dimensional # list of Wells into a 1 dimensional list of Wells - if self._instr.channels > 1: + pipette_configuration_type = NozzleConfigurationType.FULL + if self._api_version >= _PARTIAL_TIP_SUPPORT_ADDED: + pipette_configuration_type = ( + self._instr._core.get_nozzle_map().configuration + ) + if ( + self._instr.channels > 1 + and pipette_configuration_type == NozzleConfigurationType.FULL + ): sources, dests = self._multichannel_transfer(sources, dests) else: if isinstance(sources, List) and isinstance(sources[0], List): diff --git a/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py b/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py index 82ce80695d3..c50ffe4687e 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py @@ -25,9 +25,12 @@ ModuleModel, StateView, ) +from opentrons.protocol_engine.state.geometry import _AbsoluteRobotExtents +from opentrons.protocol_engine.state.pipettes import PipetteBoundingBoxOffsets + from opentrons.protocol_engine.clients import SyncClient from opentrons.protocol_engine.errors import LabwareNotLoadedOnModuleError -from opentrons.types import DeckSlotName, Point, StagingSlotName +from opentrons.types import DeckSlotName, Point, StagingSlotName, MountType from opentrons.protocol_engine.types import ( DeckType, @@ -416,7 +419,7 @@ def test_maps_trash_bins( [("OT-3 Standard", DeckType.OT3_STANDARD)], ) @pytest.mark.parametrize( - ["pipette_bounds", "expected_raise"], + ["pipette_bounds", "expected_raise", "y_value"], [ ( # nozzles above highest Z ( @@ -426,6 +429,7 @@ def test_maps_trash_bins( Point(x=50, y=50, z=60), ), does_not_raise(), + 0, ), # X, Y, Z collisions ( @@ -439,6 +443,7 @@ def test_maps_trash_bins( deck_conflict.PartialTipMovementNotAllowedError, match="collision with items in deck slot D1", ), + 0, ), ( ( @@ -451,6 +456,7 @@ def test_maps_trash_bins( deck_conflict.PartialTipMovementNotAllowedError, match="collision with items in deck slot D2", ), + 0, ), ( # Collision with staging slot ( @@ -461,8 +467,9 @@ def test_maps_trash_bins( ), pytest.raises( deck_conflict.PartialTipMovementNotAllowedError, - match="collision with items in staging slot C4", + match="will result in collision with items in staging slot C4.", ), + 170, ), ], ) @@ -471,6 +478,7 @@ def test_deck_conflict_raises_for_bad_pipette_move( mock_state_view: StateView, pipette_bounds: Tuple[Point, Point, Point, Point], expected_raise: ContextManager[Any], + y_value: float, ) -> None: """It should raise errors when moving to locations with restrictions for partial pipette movement. @@ -485,7 +493,36 @@ def test_deck_conflict_raises_for_bad_pipette_move( in order to preserve readability of the test. That means the test does actual slot overlap checks. """ - destination_well_point = Point(x=123, y=123, z=123) + destination_well_point = Point(x=123, y=y_value, z=123) + decoy.when( + mock_state_view.pipettes.get_is_partially_configured("pipette-id") + ).then_return(True) + decoy.when(mock_state_view.pipettes.get_mount("pipette-id")).then_return( + MountType.LEFT + ) + decoy.when(mock_state_view.geometry.absolute_deck_extents).then_return( + _AbsoluteRobotExtents( + front_left={ + MountType.LEFT: Point(13.5, -60.5, 0.0), + MountType.RIGHT: Point(-40.5, -60.5, 0.0), + }, + back_right={ + MountType.LEFT: Point(463.7, 433.3, 0.0), + MountType.RIGHT: Point(517.7, 433.3), + }, + ) + ) + decoy.when( + mock_state_view.pipettes.get_pipette_bounding_box("pipette-id") + ).then_return( + # 96 chan outer bounds + PipetteBoundingBoxOffsets( + back_left_corner=Point(-36.0, -25.5, -259.15), + front_right_corner=Point(63.0, -88.5, -259.15), + front_left_corner=Point(-36.0, -88.5, -259.15), + back_right_corner=Point(63.0, -25.5, -259.15), + ) + ) decoy.when( mock_state_view.pipettes.get_is_partially_configured("pipette-id") ).then_return(True) @@ -589,7 +626,7 @@ def test_deck_conflict_raises_for_collision_with_tc_lid( destination_well_point = Point(x=123, y=123, z=123) pipette_bounds_at_destination = ( Point(x=50, y=350, z=204.5), - Point(x=150, y=450, z=204.5), + Point(x=150, y=429, z=204.5), Point(x=150, y=400, z=204.5), Point(x=50, y=300, z=204.5), ) @@ -616,6 +653,32 @@ def test_deck_conflict_raises_for_collision_with_tc_lid( pipette_id="pipette-id", destination_position=destination_well_point ) ).then_return(pipette_bounds_at_destination) + decoy.when(mock_state_view.pipettes.get_mount("pipette-id")).then_return( + MountType.LEFT + ) + decoy.when( + mock_state_view.pipettes.get_pipette_bounding_box("pipette-id") + ).then_return( + # 96 chan outer bounds + PipetteBoundingBoxOffsets( + back_left_corner=Point(-67.0, -3.5, -259.15), + front_right_corner=Point(94.0, -113.0, -259.15), + front_left_corner=Point(-67.0, -113.0, -259.15), + back_right_corner=Point(94.0, -3.5, -259.15), + ) + ) + decoy.when(mock_state_view.geometry.absolute_deck_extents).then_return( + _AbsoluteRobotExtents( + front_left={ + MountType.LEFT: Point(13.5, 60.5, 0.0), + MountType.RIGHT: Point(-40.5, 60.5, 0.0), + }, + back_right={ + MountType.LEFT: Point(463.7, 433.3, 0.0), + MountType.RIGHT: Point(517.7, 433.3), + }, + ) + ) decoy.when( adjacent_slots_getters.get_surrounding_slots(5, robot_type="OT-3 Standard") diff --git a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py index ac53bb55a59..c3adca3f5a8 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py @@ -1315,13 +1315,55 @@ def test_configure_for_volume_post_219( ) -@pytest.mark.parametrize("version", versions_at_or_above(APIVersion(2, 20))) +@pytest.mark.parametrize( + ("returned_from_engine", "expected_return_from_core"), + [ + (None, False), + (0, True), + (1, True), + ], +) +def test_detect_liquid_presence( + returned_from_engine: Optional[float], + expected_return_from_core: bool, + decoy: Decoy, + mock_protocol_core: ProtocolCore, + mock_engine_client: EngineClient, + subject: InstrumentCore, +) -> None: + """It should convert a height result from the engine to True/False.""" + well_core = WellCore( + name="my cool well", labware_id="123abc", engine_client=mock_engine_client + ) + decoy.when( + mock_engine_client.execute_command_without_recovery( + cmd.TryLiquidProbeParams( + pipetteId=subject.pipette_id, + wellLocation=WellLocation( + origin=WellOrigin.TOP, offset=WellOffset(x=0, y=0, z=0) + ), + wellName=well_core.get_name(), + labwareId=well_core.labware_id, + ) + ) + ).then_return( + cmd.TryLiquidProbeResult.construct( + z_position=returned_from_engine, + position=object(), # type: ignore[arg-type] + ) + ) + loc = Location(Point(0, 0, 0), None) + + result = subject.detect_liquid_presence(well_core=well_core, loc=loc) + assert result == expected_return_from_core + + decoy.verify(mock_protocol_core.set_last_location(loc, mount=subject.get_mount())) + + def test_liquid_probe_without_recovery( decoy: Decoy, mock_engine_client: EngineClient, - mock_protocol_core: ProtocolCore, subject: InstrumentCore, - version: APIVersion, ) -> None: """It should raise an exception on an empty well and return a float on a valid well.""" well_core = WellCore( @@ -1332,7 +1374,7 @@ def test_liquid_probe_without_recovery( cmd.LiquidProbeParams( pipetteId=subject.pipette_id, wellLocation=WellLocation( - origin=WellOrigin.TOP, offset=WellOffset(x=0, y=0, z=0) + origin=WellOrigin.TOP, offset=WellOffset(x=0, y=0, z=2) ), wellName=well_core.get_name(), labwareId=well_core.labware_id, @@ -1340,16 +1382,14 @@ def test_liquid_probe_without_recovery( ) ).then_raise(PipetteLiquidNotFoundError()) loc = Location(Point(0, 0, 0), None) - subject.liquid_probe_without_recovery(well_core=well_core, loc=loc) + with pytest.raises(PipetteLiquidNotFoundError): + subject.liquid_probe_without_recovery(well_core=well_core, loc=loc) -@pytest.mark.parametrize("version", versions_at_or_above(APIVersion(2, 20))) def test_liquid_probe_with_recovery( decoy: Decoy, mock_engine_client: EngineClient, - mock_protocol_core: ProtocolCore, subject: InstrumentCore, - version: APIVersion, ) -> None: """It should not raise an exception on an empty well.""" well_core = WellCore( diff --git a/api/tests/opentrons/protocol_api/test_instrument_context.py b/api/tests/opentrons/protocol_api/test_instrument_context.py index bde90981c93..d98b99a9a6d 100644 --- a/api/tests/opentrons/protocol_api/test_instrument_context.py +++ b/api/tests/opentrons/protocol_api/test_instrument_context.py @@ -1,18 +1,21 @@ """Tests for the InstrumentContext public interface.""" +import inspect +import pytest from collections import OrderedDict +from contextlib import nullcontext as does_not_raise from datetime import datetime -import inspect +from typing import ContextManager, Optional +from unittest.mock import sentinel + +from decoy import Decoy +from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped] + from opentrons.protocol_engine.commands.pipetting_common import LiquidNotFoundError from opentrons.protocol_engine.errors.error_occurrence import ( ProtocolCommandFailedError, ) -import pytest -from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped] -from decoy import Decoy from opentrons.legacy_broker import LegacyBroker -from typing import ContextManager, Optional -from contextlib import nullcontext as does_not_raise from opentrons.protocols.api_support import instrument as mock_instrument_support from opentrons.protocols.api_support.types import APIVersion @@ -1289,19 +1292,11 @@ def test_detect_liquid_presence( ) -> None: """It should only return booleans. Not raise an exception.""" mock_well = decoy.mock(cls=Well) - lnfe = LiquidNotFoundError(id="1234", createdAt=datetime.now()) - errorToRaise = ProtocolCommandFailedError( - original_error=lnfe, - message=f"{lnfe.errorType}: {lnfe.detail}", - ) decoy.when( - mock_instrument_core.liquid_probe_without_recovery( - mock_well._core, mock_well.top() - ) - ).then_raise(errorToRaise) - result = subject.detect_liquid_presence(mock_well) - assert isinstance(result, bool) - assert not result + mock_instrument_core.detect_liquid_presence(mock_well._core, mock_well.top()) + ).then_return(sentinel.inner_result) + outer_result = subject.detect_liquid_presence(mock_well) + assert outer_result is sentinel.inner_result @pytest.mark.parametrize("api_version", [APIVersion(2, 20)]) diff --git a/api/tests/opentrons/protocol_api_integration/test_pipette_movement_deck_conflicts.py b/api/tests/opentrons/protocol_api_integration/test_pipette_movement_deck_conflicts.py index 33e92086edb..59523fd2c91 100644 --- a/api/tests/opentrons/protocol_api_integration/test_pipette_movement_deck_conflicts.py +++ b/api/tests/opentrons/protocol_api_integration/test_pipette_movement_deck_conflicts.py @@ -3,7 +3,7 @@ import pytest from opentrons import simulate -from opentrons.protocol_api import COLUMN, ALL +from opentrons.protocol_api import COLUMN, ALL, SINGLE from opentrons.protocol_api.core.engine.deck_conflict import ( PartialTipMovementNotAllowedError, ) @@ -61,8 +61,14 @@ def test_deck_conflicts_for_96_ch_a12_column_configuration() -> None: ): instrument.pick_up_tip(badly_placed_tiprack.wells_by_name()["A1"]) - # No error since no tall item in west slot of destination slot - instrument.pick_up_tip(well_placed_tiprack.wells_by_name()["A1"]) + with pytest.raises( + PartialTipMovementNotAllowedError, match="outside of robot bounds" + ): + # Picking up from A1 in an east-most slot using a configuration with column 12 would + # result in a collision with the side of the robot. + instrument.pick_up_tip(well_placed_tiprack.wells_by_name()["A1"]) + + instrument.pick_up_tip(well_placed_tiprack.wells_by_name()["A12"]) instrument.aspirate(50, well_placed_labware.wells_by_name()["A4"]) with pytest.raises( @@ -75,14 +81,19 @@ def test_deck_conflicts_for_96_ch_a12_column_configuration() -> None: ): instrument.dispense(10, tc_adjacent_plate.wells_by_name()["A1"]) + instrument.dispense(10, tc_adjacent_plate.wells_by_name()["H2"]) + # No error cuz dispensing from high above plate, so it clears tuberack in west slot instrument.dispense(15, badly_placed_labware.wells_by_name()["A1"].top(150)) thermocycler.open_lid() # type: ignore[union-attr] - # Will NOT raise error since first column of TC labware is accessible - # (it is just a few mm away from the left bound) - instrument.dispense(25, accessible_plate.wells_by_name()["A1"]) + with pytest.raises( + PartialTipMovementNotAllowedError, match="outside of robot bounds" + ): + # Dispensing to A1 in an east-most slot using a configuration with column 12 would + # result in a collision with the side of the robot. + instrument.dispense(25, accessible_plate.wells_by_name()["A1"]) instrument.drop_tip() @@ -102,7 +113,7 @@ def test_deck_conflicts_for_96_ch_a12_column_configuration() -> None: @pytest.mark.ot3_only def test_close_shave_deck_conflicts_for_96_ch_a12_column_configuration() -> None: """Shouldn't raise errors for "almost collision"s.""" - protocol_context = simulate.get_protocol_api(version="2.16", robot_type="Flex") + protocol_context = simulate.get_protocol_api(version="2.20", robot_type="Flex") res12 = protocol_context.load_labware("nest_12_reservoir_15ml", "C3") # Mag block and tiprack adapter are very close to the destination reservoir labware @@ -113,18 +124,19 @@ def test_close_shave_deck_conflicts_for_96_ch_a12_column_configuration() -> None adapter="opentrons_flex_96_tiprack_adapter", ) tiprack_8 = protocol_context.load_labware("opentrons_flex_96_tiprack_200ul", "B2") - hs = protocol_context.load_module("heaterShakerModuleV1", "D1") + hs = protocol_context.load_module("heaterShakerModuleV1", "C1") hs_adapter = hs.load_adapter("opentrons_96_deep_well_adapter") deepwell = hs_adapter.load_labware("nest_96_wellplate_2ml_deep") protocol_context.load_trash_bin("A3") p1000_96 = protocol_context.load_instrument("flex_96channel_1000") - p1000_96.configure_nozzle_layout(style=COLUMN, start="A12", tip_racks=[tiprack_8]) + p1000_96.configure_nozzle_layout(style=SINGLE, start="A12", tip_racks=[tiprack_8]) hs.close_labware_latch() # type: ignore[union-attr] + # Note p1000_96.distribute( 15, - res12.wells()[0], - deepwell.rows()[0], + res12["A6"], + deepwell.columns()[6], disposal_vol=0, ) @@ -180,8 +192,15 @@ def test_deck_conflicts_for_96_ch_a1_column_configuration() -> None: with pytest.raises( PartialTipMovementNotAllowedError, match="outside of robot bounds" ): + # Moving the 96 channel in column configuration with column 1 + # is incompatible with moving to a plate in B3 in the right most + # column. instrument.aspirate(25, well_placed_plate.wells_by_name()["A11"]) + # No error because we're moving to column 1 of the plate with + # column 1 of the 96 channel. + instrument.aspirate(25, well_placed_plate.wells_by_name()["A1"]) + # No error cuz no taller labware on the right instrument.aspirate(10, my_tuberack.wells_by_name()["A1"]) diff --git a/api/tests/opentrons/protocol_engine/state/test_addressable_area_state.py b/api/tests/opentrons/protocol_engine/state/test_addressable_area_state.py index 7209e78bb90..66fa692fe25 100644 --- a/api/tests/opentrons/protocol_engine/state/test_addressable_area_state.py +++ b/api/tests/opentrons/protocol_engine/state/test_addressable_area_state.py @@ -28,6 +28,17 @@ def test_deck_configuration_setting( deck_type=DeckType.OT3_STANDARD, ), deck_definition=ot3_standard_deck_def, + robot_definition={ + "displayName": "OT-3", + "robotType": "OT-3 Standard", + "models": ["OT-3 Standard"], + "extents": [477.2, 493.8, 0.0], + "mountOffsets": { + "left": [-13.5, -60.5, 255.675], + "right": [40.5, -60.5, 255.675], + "gripper": [84.55, -12.75, 93.85], + }, + }, ) subject_view = AddressableAreaView(subject.state) diff --git a/api/tests/opentrons/protocol_engine/state/test_addressable_area_store.py b/api/tests/opentrons/protocol_engine/state/test_addressable_area_store.py index c3d52028647..fcadb43940e 100644 --- a/api/tests/opentrons/protocol_engine/state/test_addressable_area_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_addressable_area_store.py @@ -69,6 +69,17 @@ def simulated_subject( deck_type=DeckType.OT3_STANDARD, ), deck_definition=ot3_standard_deck_def, + robot_definition={ + "displayName": "OT-3", + "robotType": "OT-3 Standard", + "models": ["OT-3 Standard"], + "extents": [477.2, 493.8, 0.0], + "mountOffsets": { + "left": [-13.5, -60.5, 255.675], + "right": [40.5, -60.5, 255.675], + "gripper": [84.55, -12.75, 93.85], + }, + }, ) @@ -85,6 +96,17 @@ def subject( deck_type=DeckType.OT3_STANDARD, ), deck_definition=ot3_standard_deck_def, + robot_definition={ + "displayName": "OT-3", + "robotType": "OT-3 Standard", + "models": ["OT-3 Standard"], + "extents": [477.2, 493.8, 0.0], + "mountOffsets": { + "left": [-13.5, -60.5, 255.675], + "right": [40.5, -60.5, 255.675], + "gripper": [84.55, -12.75, 93.85], + }, + }, ) @@ -100,6 +122,17 @@ def test_initial_state_simulated( deck_configuration=[], robot_type="OT-3 Standard", use_simulated_deck_config=True, + robot_definition={ + "displayName": "OT-3", + "robotType": "OT-3 Standard", + "models": ["OT-3 Standard"], + "extents": [477.2, 493.8, 0.0], + "mountOffsets": { + "left": [-13.5, -60.5, 255.675], + "right": [40.5, -60.5, 255.675], + "gripper": [84.55, -12.75, 93.85], + }, + }, ) diff --git a/api/tests/opentrons/protocol_engine/state/test_addressable_area_view.py b/api/tests/opentrons/protocol_engine/state/test_addressable_area_view.py index 30ebe0d0341..3d1cbe9be1a 100644 --- a/api/tests/opentrons/protocol_engine/state/test_addressable_area_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_addressable_area_view.py @@ -64,6 +64,17 @@ def get_addressable_area_view( potential_cutout_fixtures_by_cutout_id=potential_cutout_fixtures_by_cutout_id or {}, deck_definition=deck_definition or cast(DeckDefinitionV5, {"otId": "fake"}), + robot_definition={ + "displayName": "OT-3", + "robotType": "OT-3 Standard", + "models": ["OT-3 Standard"], + "extents": [477.2, 493.8, 0.0], + "mountOffsets": { + "left": [-13.5, -60.5, 255.675], + "right": [40.5, -60.5, 255.675], + "gripper": [84.55, -12.75, 93.85], + }, + }, deck_configuration=deck_configuration or [], robot_type=robot_type, use_simulated_deck_config=use_simulated_deck_config, diff --git a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py index 58a4a49940e..9887a4ef76c 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -174,7 +174,20 @@ def addressable_area_store( ) -> AddressableAreaStore: """Get an addressable area store that can accept actions.""" return AddressableAreaStore( - deck_configuration=[], config=state_config, deck_definition=deck_definition + deck_configuration=[], + config=state_config, + deck_definition=deck_definition, + robot_definition={ + "displayName": "OT-3", + "robotType": "OT-3 Standard", + "models": ["OT-3 Standard"], + "extents": [477.2, 493.8, 0.0], + "mountOffsets": { + "left": [-13.5, -60.5, 255.675], + "right": [40.5, -60.5, 255.675], + "gripper": [84.55, -12.75, 93.85], + }, + }, ) @@ -2077,6 +2090,8 @@ def test_get_next_drop_tip_location( pipette_bounding_box_offsets=PipetteBoundingBoxOffsets( back_left_corner=Point(x=10, y=20, z=30), front_right_corner=Point(x=40, y=50, z=60), + front_left_corner=Point(x=10, y=50, z=60), + back_right_corner=Point(x=40, y=20, z=60), ), lld_settings={}, ) diff --git a/api/tests/opentrons/protocol_engine/state/test_module_store.py b/api/tests/opentrons/protocol_engine/state/test_module_store.py index e6de0a96ac0..0dabf508483 100644 --- a/api/tests/opentrons/protocol_engine/state/test_module_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_module_store.py @@ -74,6 +74,17 @@ def get_addressable_area_view( or {}, deck_definition=deck_definition or cast(DeckDefinitionV5, {"otId": "fake"}), deck_configuration=deck_configuration or [], + robot_definition={ + "displayName": "OT-3", + "robotType": "OT-3 Standard", + "models": ["OT-3 Standard"], + "extents": [477.2, 493.8, 0.0], + "mountOffsets": { + "left": [-13.5, -60.5, 255.675], + "right": [40.5, -60.5, 255.675], + "gripper": [84.55, -12.75, 93.85], + }, + }, robot_type=robot_type, use_simulated_deck_config=use_simulated_deck_config, ) diff --git a/api/tests/opentrons/protocol_engine/state/test_module_view.py b/api/tests/opentrons/protocol_engine/state/test_module_view.py index b840673f2e8..e308c09407d 100644 --- a/api/tests/opentrons/protocol_engine/state/test_module_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_module_view.py @@ -87,6 +87,17 @@ def get_addressable_area_view( or {}, deck_definition=deck_definition or cast(DeckDefinitionV5, {"otId": "fake"}), deck_configuration=deck_configuration or [], + robot_definition={ + "displayName": "OT-3", + "robotType": "OT-3 Standard", + "models": ["OT-3 Standard"], + "extents": [477.2, 493.8, 0.0], + "mountOffsets": { + "left": [-13.5, -60.5, 255.675], + "right": [40.5, -60.5, 255.675], + "gripper": [84.55, -12.75, 93.85], + }, + }, robot_type=robot_type, use_simulated_deck_config=use_simulated_deck_config, ) diff --git a/api/tests/opentrons/protocol_engine/state/test_pipette_store.py b/api/tests/opentrons/protocol_engine/state/test_pipette_store.py index a99ac90e9e2..8ccfc06fd07 100644 --- a/api/tests/opentrons/protocol_engine/state/test_pipette_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_pipette_store.py @@ -775,6 +775,8 @@ def test_add_pipette_config( pipette_bounding_box_offsets=PipetteBoundingBoxOffsets( back_left_corner=Point(x=1, y=2, z=3), front_right_corner=Point(x=4, y=5, z=6), + front_left_corner=Point(x=1, y=5, z=3), + back_right_corner=Point(x=4, y=2, z=3), ), lld_settings={}, ) diff --git a/api/tests/opentrons/protocol_engine/state/test_pipette_view.py b/api/tests/opentrons/protocol_engine/state/test_pipette_view.py index e15c8401699..1942a9a04e1 100644 --- a/api/tests/opentrons/protocol_engine/state/test_pipette_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_pipette_view.py @@ -46,7 +46,10 @@ back_left_offset=Point(x=10, y=20, z=30), front_right_offset=Point(x=40, y=50, z=60) ) _SAMPLE_PIPETTE_BOUNDING_BOX_OFFSETS = PipetteBoundingBoxOffsets( - back_left_corner=Point(x=10, y=20, z=30), front_right_corner=Point(x=40, y=50, z=60) + back_left_corner=Point(x=10, y=20, z=30), + front_right_corner=Point(x=40, y=50, z=60), + front_left_corner=Point(x=10, y=50, z=60), + back_right_corner=Point(x=40, y=20, z=60), ) @@ -594,6 +597,8 @@ class _PipetteSpecs(NamedTuple): bounding_box_offsets=PipetteBoundingBoxOffsets( back_left_corner=Point(0.0, 31.5, 35.52), front_right_corner=Point(0.0, -31.5, 35.52), + front_left_corner=Point(0.0, -31.5, 35.52), + back_right_corner=Point(0.0, 31.5, 35.52), ), nozzle_map=NozzleMap.build( physical_nozzles=EIGHT_CHANNEL_MAP, @@ -620,6 +625,8 @@ class _PipetteSpecs(NamedTuple): bounding_box_offsets=PipetteBoundingBoxOffsets( back_left_corner=Point(0.0, 31.5, 35.52), front_right_corner=Point(0.0, -31.5, 35.52), + front_left_corner=Point(0.0, -31.5, 35.52), + back_right_corner=Point(0.0, 31.5, 35.52), ), nozzle_map=NozzleMap.build( physical_nozzles=EIGHT_CHANNEL_MAP, @@ -646,6 +653,8 @@ class _PipetteSpecs(NamedTuple): bounding_box_offsets=PipetteBoundingBoxOffsets( back_left_corner=Point(-36.0, -25.5, -259.15), front_right_corner=Point(63.0, -88.5, -259.15), + front_left_corner=Point(-36.0, -88.5, -259.15), + back_right_corner=Point(63.0, -25.5, -259.15), ), nozzle_map=NozzleMap.build( physical_nozzles=NINETY_SIX_MAP, @@ -688,6 +697,8 @@ class _PipetteSpecs(NamedTuple): bounding_box_offsets=PipetteBoundingBoxOffsets( back_left_corner=Point(-36.0, -25.5, -259.15), front_right_corner=Point(63.0, -88.5, -259.15), + front_left_corner=Point(-36.0, -88.5, -259.15), + back_right_corner=Point(63.0, -25.5, -259.15), ), nozzle_map=NozzleMap.build( physical_nozzles=NINETY_SIX_MAP, @@ -712,6 +723,8 @@ class _PipetteSpecs(NamedTuple): bounding_box_offsets=PipetteBoundingBoxOffsets( back_left_corner=Point(-36.0, -25.5, -259.15), front_right_corner=Point(63.0, -88.5, -259.15), + front_left_corner=Point(-36.0, -88.5, -259.15), + back_right_corner=Point(63.0, -25.5, -259.15), ), nozzle_map=NozzleMap.build( physical_nozzles=NINETY_SIX_MAP, @@ -736,6 +749,8 @@ class _PipetteSpecs(NamedTuple): bounding_box_offsets=PipetteBoundingBoxOffsets( back_left_corner=Point(-36.0, -25.5, -259.15), front_right_corner=Point(63.0, -88.5, -259.15), + front_left_corner=Point(-36.0, -88.5, -259.15), + back_right_corner=Point(63.0, -25.5, -259.15), ), nozzle_map=NozzleMap.build( physical_nozzles=NINETY_SIX_MAP, diff --git a/api/tests/opentrons/protocol_engine/state/test_state_store.py b/api/tests/opentrons/protocol_engine/state/test_state_store.py index d69784c6834..26f50515317 100644 --- a/api/tests/opentrons/protocol_engine/state/test_state_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_state_store.py @@ -39,6 +39,13 @@ def subject( return StateStore( config=engine_config, deck_definition=ot2_standard_deck_def, + robot_definition={ + "displayName": "OT-2", + "robotType": "OT-2 Standard", + "models": ["OT-2 Standard", "OT-2 Refresh"], + "extents": [446.75, 347.5, 0.0], + "mountOffsets": {"left": [-34.0, 0.0, 0.0], "right": [0.0, 0.0, 0.0]}, + }, deck_fixed_labware=[], change_notifier=change_notifier, is_door_open=False, diff --git a/app-shell/build/license_en.txt b/app-shell/build/license_en.txt index f16605697b0..cf847badf81 100644 --- a/app-shell/build/license_en.txt +++ b/app-shell/build/license_en.txt @@ -1,6 +1,6 @@ Opentrons End-User License Agreement -Last updated: June 27, 2024 +Last updated: July 10, 2024 THIS END-USER LICENSE AGREEMENT (“EULA”) is a legal agreement between you (“User”), either as an individual or on behalf of an entity, and Opentrons Labworks Inc. (“Opentrons”) regarding your use of Opentrons robots, modules, software, and associated documentation (“Opentrons Products”) including, but not limited to, the Opentrons OT-2 robot and associated modules, the Opentrons Flex robot and associated modules, the Opentrons App, the Opentrons API, the Opentrons Protocol Designer and Protocol Library, the Opentrons Labware Library, and the Opentrons Website. By installing or using the Opentrons Products, you agree to be bound by the terms and conditions of this EULA. If you do not agree to the terms of this EULA, you must immediately cease use of the Opentrons Products. @@ -9,7 +9,7 @@ Use of Opentrons Products. Permitted Use. User shall use the Opentrons Products strictly in accordance with the terms of the EULA and Related Agreements. User shall use Opentrons Product software only in conjunction with Opentrons Product hardware. Restrictions on Use. Unless otherwise specified in a separate agreement entered into between Opentrons and User, User may not, and may not permit others to: reverse engineer, decompile or otherwise derive source code from the Opentrons Products; -disassemble the Opentrons Products, except as instructed by Opentrons employees or Opentrons technical product manuals; +disassemble or bypass protection on Opentrons Products to exceed authorized access to Opentrons systems, or to analyze or modify components of the Opentrons Products for the purpose of gaining unauthorized access to confidential Opentrons or Opentrons Product information; copy, modify, or create derivative works of the Opentrons Products for the purpose of competing with Opentrons; remove or alter any proprietary notices or marks on the Opentrons Products; use the Opentrons Products in any manner that does not comply with the applicable laws in the jurisdiction(s) in which such use takes place; diff --git a/app-shell/electron-builder.config.js b/app-shell/electron-builder.config.js index 49d58f9fcfa..1b048915255 100644 --- a/app-shell/electron-builder.config.js +++ b/app-shell/electron-builder.config.js @@ -8,6 +8,7 @@ const { } = process.env const DEV_MODE = process.env.NODE_ENV !== 'production' const USE_PYTHON = process.env.NO_PYTHON !== 'true' +const WINDOWS_SIGN = process.env.WINDOWS_SIGN === 'true' const project = process.env.OPENTRONS_PROJECT ?? 'robot-stack' // this will generate either @@ -72,6 +73,11 @@ module.exports = async () => ({ target: ['nsis'], publisherName: 'Opentrons Labworks Inc.', icon: project === 'robot-stack' ? 'build/icon.ico' : 'build/three.ico', + forceCodeSigning: WINDOWS_SIGN, + rfc3161TimeStampServer: 'http://timestamp.digicert.com', + sign: 'scripts/windows-custom-sign.js', + signDlls: true, + signingHashAlgorithms: ['sha256'], }, nsis: { oneClick: false, diff --git a/app-shell/scripts/windows-custom-sign.js b/app-shell/scripts/windows-custom-sign.js new file mode 100644 index 00000000000..90d7927ab6a --- /dev/null +++ b/app-shell/scripts/windows-custom-sign.js @@ -0,0 +1,62 @@ +// from https://github.com/electron-userland/electron-builder/issues/7605 + +'use strict' + +const { execSync } = require('node:child_process') + +exports.default = async configuration => { + const signCmd = `smctl sign --keypair-alias="${String( + process.env.SM_KEYPAIR_ALIAS + )}" --input "${String(configuration.path)}" --certificate="${String( + process.env.WINDOWS_CSC_FILEPATH + )}" --exit-non-zero-on-fail --failfast --verbose` + console.log(signCmd) + try { + const signProcess = execSync(signCmd, { + stdio: 'pipe', + }) + console.log(`Sign success!`) + console.log( + `Sign stdout: ${signProcess?.stdout?.toString() ?? ''}` + ) + console.log( + `Sign stderr: ${signProcess?.stderr?.toString() ?? ''}` + ) + console.log(`Sign code: ${signProcess.code}`) + } catch (err) { + console.error(`Exception running sign: ${err.status}! +Process stdout: + ${err?.stdout?.toString() ?? ''} +------------- +Process stderr: +${err?.stdout?.toString() ?? ''} +------------- +`) + throw err + } + const verifyCmd = `smctl sign verify --fingerprint="${String( + process.env.SM_CODE_SIGNING_CERT_SHA1_HASH + )}" --input="${String(configuration.path)}" --verbose` + console.log(verifyCmd) + try { + const verifyProcess = execSync(verifyCmd, { stdio: 'pipe' }) + console.log(`Verify success!`) + console.log( + `Verify stdout: ${verifyProcess?.stdout?.toString() ?? ''}` + ) + console.log( + `Verify stderr: ${verifyProcess?.stderr?.toString() ?? ''}` + ) + } catch (err) { + console.error(` +Exception running verification: ${err.status}! +Process stdout: + ${err?.stdout?.toString() ?? ''} +-------------- +Process stderr: + ${err?.stderr?.toString() ?? ''} +-------------- +`) + throw err + } +} diff --git a/app/src/assets/localization/en/error_recovery.json b/app/src/assets/localization/en/error_recovery.json index c08db68b61f..53d2a07df43 100644 --- a/app/src/assets/localization/en/error_recovery.json +++ b/app/src/assets/localization/en/error_recovery.json @@ -48,7 +48,7 @@ "retry_step": "Retry step", "retry_with_new_tips": "Retry with new tips", "retry_with_same_tips": "Retry with same tips", - "retrying_step_succeeded": "Retrying step {{step}} succeeded", + "retrying_step_succeeded": "Retrying step {{step}} succeeded.", "return_to_menu": "Return to menu", "return_to_the_menu": "Return to the menu to choose how to proceed.", "robot_door_is_open": "Robot door is open", @@ -62,7 +62,7 @@ "skip_to_next_step": "Skip to next step", "skip_to_next_step_new_tips": "Skip to next step with new tips", "skip_to_next_step_same_tips": "Skip to next step with same tips", - "skipping_to_step_succeeded": "Skipping to step {{step}} succeeded", + "skipping_to_step_succeeded": "Skipping to step {{step}} succeeded.", "stand_back": "Stand back, robot is in motion", "stand_back_picking_up_tips": "Stand back, picking up tips", "stand_back_resuming": "Stand back, resuming current step", diff --git a/app/src/assets/localization/en/protocol_command_text.json b/app/src/assets/localization/en/protocol_command_text.json index 069f6e13886..484ec67124a 100644 --- a/app/src/assets/localization/en/protocol_command_text.json +++ b/app/src/assets/localization/en/protocol_command_text.json @@ -16,37 +16,49 @@ "deactivating_tc_block": "Deactivating Thermocycler block", "deactivating_tc_lid": "Deactivating Thermocycler lid", "degrees_c": "{{temp}}°C", + "detect_liquid_presence": "Detecting liquid presence in well {{well_name}} of {{labware}} in {{labware_location}}", "disengaging_magnetic_module": "Disengaging Magnetic Module", - "dispense_push_out": "Dispensing {{volume}} µL into well {{well_name}} of {{labware}} in {{labware_location}} at {{flow_rate}} µL/sec and pushing out {{push_out_volume}} µL", "dispense": "Dispensing {{volume}} µL into well {{well_name}} of {{labware}} in {{labware_location}} at {{flow_rate}} µL/sec", "dispense_in_place": "Dispensing {{volume}} µL in place at {{flow_rate}} µL/sec", + "dispense_push_out": "Dispensing {{volume}} µL into well {{well_name}} of {{labware}} in {{labware_location}} at {{flow_rate}} µL/sec and pushing out {{push_out_volume}} µL", "drop_tip": "Dropping tip in {{well_name}} of {{labware}}", "drop_tip_in_place": "Dropping tip in place", "engaging_magnetic_module": "Engaging Magnetic Module", "fixed_trash": "Fixed Trash", "home_gantry": "Homing all gantry, pipette, and plunger axes", "latching_hs_latch": "Latching labware on Heater-Shaker", - "module_in_slot_plural": "{{module}}", + "left": "Left", + "load_labware_info_protocol_setup": "Load {{labware}} in {{module_name}} in Slot {{slot_name}}", + "load_labware_info_protocol_setup_adapter": "Load {{labware}} in {{adapter_name}} in Slot {{slot_name}}", + "load_labware_info_protocol_setup_adapter_module": "Load {{labware}} in {{adapter_name}} in {{module_name}} in Slot {{slot_name}}", + "load_labware_info_protocol_setup_adapter_off_deck": "Load {{labware}} in {{adapter_name}} off deck", + "load_labware_info_protocol_setup_no_module": "Load {{labware}} in Slot {{slot_name}}", + "load_labware_info_protocol_setup_off_deck": "Load {{labware}} off deck", + "load_liquids_info_protocol_setup": "Load {{liquid}} into {{labware}}", + "load_module_protocol_setup": "Load {{module}} in Slot {{slot_name}}", + "load_pipette_protocol_setup": "Load {{pipette_name}} in {{mount_name}} Mount", "module_in_slot": "{{module}} in Slot {{slot_name}}", + "module_in_slot_plural": "{{module}}", + "move_labware": "Move Labware", "move_labware_manually": "Manually move {{labware}} from {{old_location}} to {{new_location}}", "move_labware_on": "Move labware on {{robot_name}}", "move_labware_using_gripper": "Moving {{labware}} using gripper from {{old_location}} to {{new_location}}", - "move_labware": "Move Labware", "move_relative": "Moving {{distance}} mm along {{axis}} axis", + "move_to_addressable_area": "Moving to {{addressable_area}}", + "move_to_addressable_area_drop_tip": "Moving to {{addressable_area}}", "move_to_coordinates": "Moving to (X: {{x}}, Y: {{y}}, Z: {{z}})", "move_to_slot": "Moving to Slot {{slot_name}}", "move_to_well": "Moving to well {{well_name}} of {{labware}} in {{labware_location}}", - "move_to_addressable_area": "Moving to {{addressable_area}}", - "move_to_addressable_area_drop_tip": "Moving to {{addressable_area}}", "notes": "notes", "off_deck": "off deck", "offdeck": "offdeck", "opening_tc_lid": "Opening Thermocycler lid", - "pause_on": "Pause on {{robot_name}}", "pause": "Pause", + "pause_on": "Pause on {{robot_name}}", "pickup_tip": "Picking up tip(s) from {{well_range}} of {{labware}} in {{labware_location}}", "prepare_to_aspirate": "Preparing {{pipette}} to aspirate", "return_tip": "Returning tip to {{well_name}} of {{labware}} in {{labware_location}}", + "right": "Right", "save_position": "Saving position", "set_and_await_hs_shake": "Setting Heater-Shaker to shake at {{rpm}} rpm and waiting until reached", "setting_hs_temp": "Setting Target Temperature of Heater-Shaker to {{temp}}", @@ -58,8 +70,8 @@ "tc_awaiting_for_duration": "Waiting for Thermocycler profile to complete", "tc_run_profile_steps": "temperature: {{celsius}}°C, seconds: {{seconds}}", "tc_starting_profile": "Thermocycler starting {{repetitions}} repetitions of cycle composed of the following steps:", - "trash_bin_in_slot": "Trash Bin in {{slot_name}}", "touch_tip": "Touching tip", + "trash_bin_in_slot": "Trash Bin in {{slot_name}}", "unlatching_hs_latch": "Unlatching labware on Heater-Shaker", "wait_for_duration": "Pausing for {{seconds}} seconds. {{message}}", "wait_for_resume": "Pausing protocol", diff --git a/app/src/assets/localization/en/run_details.json b/app/src/assets/localization/en/run_details.json index 2a3e27cf0f3..9209e9e5fc2 100644 --- a/app/src/assets/localization/en/run_details.json +++ b/app/src/assets/localization/en/run_details.json @@ -49,17 +49,8 @@ "labware_offset_data": "labware offset data", "left": "Left", "listed_values": "Listed values are view-only", - "load_labware_info_protocol_setup": "Load {{labware}} in {{module_name}} in Slot {{slot_name}}", - "load_labware_info_protocol_setup_adapter": "Load {{labware}} in {{adapter_name}} in Slot {{slot_name}}", - "load_labware_info_protocol_setup_adapter_module": "Load {{labware}} in {{adapter_name}} in {{module_name}} in Slot {{slot_name}}", - "load_labware_info_protocol_setup_adapter_off_deck": "Load {{labware}} in {{adapter_name}} off deck", - "load_labware_info_protocol_setup_no_module": "Load {{labware}} in Slot {{slot_name}}", - "load_labware_info_protocol_setup_off_deck": "Load {{labware}} off deck", "load_labware_info_protocol_setup_plural": "Load {{labware}} in {{module_name}}", - "load_liquids_info_protocol_setup": "Load {{liquid}} into {{labware}}", - "load_module_protocol_setup": "Load {{module}} in Slot {{slot_name}}", "load_module_protocol_setup_plural": "Load {{module}}", - "load_pipette_protocol_setup": "Load {{pipette_name}} in {{mount_name}} Mount", "loading_data": "Loading data...", "loading_protocol": "Loading Protocol", "location": "location", diff --git a/app/src/atoms/Toast/index.tsx b/app/src/atoms/Toast/index.tsx index 018f2942429..2b81514641d 100644 --- a/app/src/atoms/Toast/index.tsx +++ b/app/src/atoms/Toast/index.tsx @@ -356,7 +356,7 @@ export function Toast(props: ToastProps): JSX.Element { fontWeight={ showODDStyle ? TYPOGRAPHY.fontWeightBold - : TYPOGRAPHY.fontWeightRegular + : TYPOGRAPHY.fontWeightSemiBold } lineHeight={ showODDStyle ? TYPOGRAPHY.lineHeight28 : TYPOGRAPHY.lineHeight20 diff --git a/app/src/atoms/buttons/RadioButton.tsx b/app/src/atoms/buttons/RadioButton.tsx index b176e330378..f22c19ef6e7 100644 --- a/app/src/atoms/buttons/RadioButton.tsx +++ b/app/src/atoms/buttons/RadioButton.tsx @@ -6,7 +6,7 @@ import { Flex, RESPONSIVENESS, SPACING, - LegacyStyledText, + StyledText, TYPOGRAPHY, } from '@opentrons/components' @@ -65,16 +65,17 @@ export function RadioButton(props: RadioButtonProps): JSX.Element { // TODO: (ew, 2023-04-21): button is not tabbable, so focus state // is not possible on ODD. It's testable in storybook but not in real life. const SettingButtonLabel = styled.label` - border-radius: ${BORDERS.borderRadius16}; - cursor: pointer; - padding: ${isLarge ? SPACING.spacing24 : SPACING.spacing20}; - width: 100%; + border-radius: ${BORDERS.borderRadius16}; + cursor: pointer; + padding: ${isLarge ? SPACING.spacing24 : SPACING.spacing20}; + width: 100%; - ${isSelected ? SELECTED_BUTTON_STYLE : AVAILABLE_BUTTON_STYLE} - ${disabled && DISABLED_BUTTON_STYLE} + ${isSelected ? SELECTED_BUTTON_STYLE : AVAILABLE_BUTTON_STYLE} + ${disabled && DISABLED_BUTTON_STYLE} @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { - cursor: default; + cursor: default; + } } ` @@ -89,19 +90,19 @@ export function RadioButton(props: RadioButtonProps): JSX.Element { value={buttonValue} /> - {buttonLabel} - + {subButtonLabel != null ? ( - + {subButtonLabel} - + ) : null} diff --git a/app/src/atoms/buttons/SmallButton.tsx b/app/src/atoms/buttons/SmallButton.tsx index 3402d59843a..25a494a1488 100644 --- a/app/src/atoms/buttons/SmallButton.tsx +++ b/app/src/atoms/buttons/SmallButton.tsx @@ -10,7 +10,7 @@ import { Icon, JUSTIFY_CENTER, SPACING, - LegacyStyledText, + StyledText, TYPOGRAPHY, } from '@opentrons/components' import { ODD_FOCUS_VISIBLE } from './constants' @@ -180,13 +180,12 @@ export function SmallButton(props: SmallButtonProps): JSX.Element { ) : null} - {buttonText} - + {iconPlacement === 'endIcon' && iconName != null ? ( ['as'] @@ -64,7 +31,7 @@ interface ModernSTProps { type STProps = LegacySTProps | ModernSTProps -interface Props extends StyleProps { +interface BaseProps extends StyleProps { command: RunTimeCommand commandTextData: CommandTextData robotType: RobotType @@ -72,372 +39,23 @@ interface Props extends StyleProps { propagateCenter?: boolean propagateTextLimit?: boolean } -export function CommandText(props: Props & STProps): JSX.Element | null { - const { - command, - commandTextData, - robotType, - propagateCenter = false, - propagateTextLimit = false, - ...styleProps - } = props - const { t } = useTranslation('protocol_command_text') - const shouldPropagateCenter = props.isOnDevice === true || propagateCenter - const shouldPropagateTextLimit = - props.isOnDevice === true || propagateTextLimit +export function CommandText(props: BaseProps & STProps): JSX.Element | null { + const { commandText, stepTexts } = useCommandTextString({ + ...props, + }) - switch (command.commandType) { - case 'aspirate': - case 'aspirateInPlace': - case 'dispense': - case 'dispenseInPlace': - case 'blowout': - case 'blowOutInPlace': - case 'dropTip': - case 'dropTipInPlace': - case 'pickUpTip': { - return ( - - - - ) - } - case 'loadLabware': - case 'loadPipette': - case 'loadModule': - case 'loadLiquid': { - return ( - - - - ) - } - case 'temperatureModule/setTargetTemperature': - case 'temperatureModule/waitForTemperature': - case 'thermocycler/setTargetBlockTemperature': - case 'thermocycler/setTargetLidTemperature': - case 'heaterShaker/setTargetTemperature': { - return ( - - - - ) - } + switch (props.command.commandType) { case 'thermocycler/runProfile': { - const { profile } = command.params - const steps = profile.map( - ({ holdSeconds, celsius }: { holdSeconds: number; celsius: number }) => - t('tc_run_profile_steps', { - celsius, - seconds: holdSeconds, - }).trim() - ) - return ( - // TODO(sfoster): Command sometimes wraps this in a cascaded display: -webkit-box - // to achieve multiline text clipping with an automatically inserted ellipsis, which works - // everywhere except for here where it overrides this property in the flex since this is - // the only place where CommandText uses a flex. - // The right way to handle this is probably to take the css that's in Command and make it - // live here instead, but that should be done in a followup since it would touch everything. - // See also the margin-left on the
  • s, which is needed to prevent their bullets from - // clipping if a container set overflow: hidden. - - - {t('tc_starting_profile', { - repetitions: Object.keys(steps).length, - })} - - -
      - {shouldPropagateTextLimit ? ( -
    • - {steps[0]} -
    • - ) : ( - steps.map((step: string, index: number) => ( -
    • - {' '} - {step} -
    • - )) - )} -
    -
    -
    - ) - } - case 'heaterShaker/setAndWaitForShakeSpeed': { - const { rpm } = command.params - return ( - - {t('set_and_await_hs_shake', { rpm })} - - ) - } - case 'moveToSlot': { - const { slotName } = command.params - return ( - - {t('move_to_slot', { slot_name: slotName })} - - ) - } - case 'moveRelative': { - const { axis, distance } = command.params - return ( - - {t('move_relative', { axis, distance })} - - ) - } - case 'moveToCoordinates': { - const { coordinates } = command.params - return ( - - {t('move_to_coordinates', coordinates)} - - ) - } - case 'moveToWell': { - const { wellName, labwareId } = command.params - const allPreviousCommands = commandTextData.commands.slice( - 0, - commandTextData.commands.findIndex(c => c.id === command.id) - ) - const labwareLocation = getFinalLabwareLocation( - labwareId, - allPreviousCommands - ) - const displayLocation = - labwareLocation != null - ? getLabwareDisplayLocation( - commandTextData, - labwareLocation, - t as TFunction, - robotType - ) - : '' - return ( - - {t('move_to_well', { - well_name: wellName, - labware: getLabwareName(commandTextData, labwareId), - labware_location: displayLocation, - })} - - ) - } - case 'moveLabware': { - return ( - - - - ) - } - case 'configureForVolume': { - const { volume, pipetteId } = command.params - const pipetteName = commandTextData.pipettes.find( - pip => pip.id === pipetteId - )?.pipetteName - - return ( - - {t('configure_for_volume', { - volume, - pipette: - pipetteName != null - ? getPipetteNameSpecs(pipetteName)?.displayName - : '', - })} - - ) - } - case 'configureNozzleLayout': { - const { configurationParams, pipetteId } = command.params - const pipetteName = commandTextData.pipettes.find( - pip => pip.id === pipetteId - )?.pipetteName - - // TODO (sb, 11/9/23): Add support for other configurations when needed - return ( - - {t('configure_nozzle_layout', { - amount: configurationParams.style === 'COLUMN' ? '8' : 'all', - pipette: - pipetteName != null - ? getPipetteNameSpecs(pipetteName)?.displayName - : '', - })} - - ) - } - case 'prepareToAspirate': { - const { pipetteId } = command.params - const pipetteName = commandTextData.pipettes.find( - pip => pip.id === pipetteId - )?.pipetteName - - return ( - - {t('prepare_to_aspirate', { - pipette: - pipetteName != null - ? getPipetteNameSpecs(pipetteName)?.displayName - : '', - })} - - ) - } - case 'moveToAddressableArea': { - const addressableAreaDisplayName = getAddressableAreaDisplayName( - commandTextData, - command.id, - t as TFunction - ) - - return ( - - {t('move_to_addressable_area', { - addressable_area: addressableAreaDisplayName, - })} - - ) - } - case 'moveToAddressableAreaForDropTip': { - const addressableAreaDisplayName = getAddressableAreaDisplayName( - commandTextData, - command.id, - t as TFunction - ) - return ( - - {t('move_to_addressable_area_drop_tip', { - addressable_area: addressableAreaDisplayName, - })} - - ) - } - case 'touchTip': - case 'home': - case 'savePosition': - case 'magneticModule/engage': - case 'magneticModule/disengage': - case 'temperatureModule/deactivate': - case 'thermocycler/waitForBlockTemperature': - case 'thermocycler/waitForLidTemperature': - case 'thermocycler/openLid': - case 'thermocycler/closeLid': - case 'thermocycler/deactivateBlock': - case 'thermocycler/deactivateLid': - case 'thermocycler/awaitProfileComplete': - case 'heaterShaker/deactivateHeater': - case 'heaterShaker/openLabwareLatch': - case 'heaterShaker/closeLabwareLatch': - case 'heaterShaker/deactivateShaker': - case 'heaterShaker/waitForTemperature': { - const simpleTKey = - SIMPLE_TRANSLATION_KEY_BY_COMMAND_TYPE[command.commandType] - return ( - - {simpleTKey != null ? t(simpleTKey) : null} - - ) - } - case 'waitForDuration': { - const { seconds, message } = command.params - return ( - - {t('wait_for_duration', { seconds, message: message ?? '' })} - - ) - } - case 'pause': // legacy pause command - case 'waitForResume': { - return ( - - {command.params?.message != null && command.params.message !== '' - ? command.params.message - : t('wait_for_resume')} - - ) - } - case 'delay': { - // legacy delay command - const { message = '' } = command.params - if ('waitForResume' in command.params) { - return ( - - {command.params?.message != null && command.params.message !== '' - ? command.params.message - : t('wait_for_resume')} - - ) - } else { - return ( - - {t('wait_for_duration', { - seconds: command.params.seconds, - message, - })} - - ) - } - } - case 'comment': { - const { message } = command.params - return {message} - } - case 'custom': { - const { legacyCommandText } = command.params ?? {} - const sanitizedCommandText = - typeof legacyCommandText === 'object' - ? JSON.stringify(legacyCommandText) - : String(legacyCommandText) return ( - - {legacyCommandText != null - ? sanitizedCommandText - : `${command.commandType}: ${JSON.stringify(command.params)}`} - + ) } default: { - console.warn( - 'CommandText encountered a command with an unrecognized commandType: ', - command - ) - return ( - - {JSON.stringify(command)} - - ) + return {commandText} } } } @@ -473,3 +91,82 @@ function CommandStyledText( ) } } + +type ThermocyclerRunProfileProps = BaseProps & + STProps & { + commandText: string + stepTexts?: string[] + } + +function ThermocyclerRunProfile( + props: ThermocyclerRunProfileProps +): JSX.Element { + const { + isOnDevice, + propagateCenter = false, + propagateTextLimit = false, + commandText, + stepTexts, + ...styleProps + } = props + + const shouldPropagateCenter = isOnDevice === true || propagateCenter + const shouldPropagateTextLimit = isOnDevice === true || propagateTextLimit + + // TODO(sfoster): Command sometimes wraps this in a cascaded display: -webkit-box + // to achieve multiline text clipping with an automatically inserted ellipsis, which works + // everywhere except for here where it overrides this property in the flex since this is + // the only place where CommandText uses a flex. + // The right way to handle this is probably to take the css that's in Command and make it + // live here instead, but that should be done in a followup since it would touch everything. + // See also the margin-left on the
  • s, which is needed to prevent their bullets from + // clipping if a container set overflow: hidden. + return ( + + + {commandText} + + +
      + {shouldPropagateTextLimit ? ( +
    • + {stepTexts?.[0]} +
    • + ) : ( + stepTexts?.map((step: string, index: number) => ( +
    • + {' '} + {step} +
    • + )) + )} +
    +
    +
    + ) +} diff --git a/app/src/molecules/Command/MoveLabwareCommandText.tsx b/app/src/molecules/Command/MoveLabwareCommandText.tsx deleted file mode 100644 index 030177fddfc..00000000000 --- a/app/src/molecules/Command/MoveLabwareCommandText.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { GRIPPER_WASTE_CHUTE_ADDRESSABLE_AREA } from '@opentrons/shared-data' -import { getLabwareName } from './utils' -import { getLabwareDisplayLocation } from './utils/getLabwareDisplayLocation' -import { getFinalLabwareLocation } from './utils/getFinalLabwareLocation' -import type { - MoveLabwareRunTimeCommand, - RobotType, -} from '@opentrons/shared-data' -import type { CommandTextData } from './types' -import type { TFunction } from 'i18next' - -interface MoveLabwareCommandTextProps { - command: MoveLabwareRunTimeCommand - commandTextData: CommandTextData - robotType: RobotType -} -export function MoveLabwareCommandText( - props: MoveLabwareCommandTextProps -): JSX.Element { - const { t } = useTranslation('protocol_command_text') - const { command, commandTextData, robotType } = props - const { labwareId, newLocation, strategy } = command.params - - const allPreviousCommands = commandTextData.commands.slice( - 0, - commandTextData.commands.findIndex(c => c.id === command.id) - ) - const oldLocation = getFinalLabwareLocation(labwareId, allPreviousCommands) - const newDisplayLocation = getLabwareDisplayLocation( - commandTextData, - newLocation, - t as TFunction, - robotType - ) - - const location = newDisplayLocation.includes( - GRIPPER_WASTE_CHUTE_ADDRESSABLE_AREA - ) - ? 'Waste Chute' - : newDisplayLocation - - return strategy === 'usingGripper' - ? t('move_labware_using_gripper', { - labware: getLabwareName(commandTextData, labwareId), - old_location: - oldLocation != null - ? getLabwareDisplayLocation( - commandTextData, - oldLocation, - t as TFunction, - robotType - ) - : '', - new_location: location, - }) - : t('move_labware_manually', { - labware: getLabwareName(commandTextData, labwareId), - old_location: - oldLocation != null - ? getLabwareDisplayLocation( - commandTextData, - oldLocation, - t as TFunction, - robotType - ) - : '', - new_location: location, - }) -} diff --git a/app/src/molecules/Command/__fixtures__/mockRobotSideAnalysis.json b/app/src/molecules/Command/__fixtures__/mockRobotSideAnalysis.json index 0f084ae89b6..b4a041b36f9 100644 --- a/app/src/molecules/Command/__fixtures__/mockRobotSideAnalysis.json +++ b/app/src/molecules/Command/__fixtures__/mockRobotSideAnalysis.json @@ -6391,6 +6391,46 @@ "addressableAreaName": "D3", "offset": { "x": 0, "y": 0, "z": 0 } } + }, + { + "id": "84f7af1d-c097-4d4b-9819-ad56479bbbb8", + "createdAt": "2023-01-31T21:53:04.965216+00:00", + "commandType": "liquidProbe", + "key": "1248111104", + "status": "succeeded", + "params": { + "labwareId": "b2a40c9d-31b0-4f27-ad4a-c92ced91204d", + "wellName": "A1", + "wellLocation": { + "origin": "top", + "offset": { + "x": 0, + "y": 0, + "z": 0 + } + }, + "pipetteId": "f6d1c83c-9d1b-4d0d-9de3-e6d649739cfb" + } + }, + { + "id": "84f7af1d-c097-4d4b-9819-ad56479bbbb8", + "createdAt": "2023-01-31T21:53:04.965216+00:00", + "commandType": "tryLiquidProbe", + "key": "1248111104", + "status": "succeeded", + "params": { + "labwareId": "b2a40c9d-31b0-4f27-ad4a-c92ced91204d", + "wellName": "A1", + "wellLocation": { + "origin": "top", + "offset": { + "x": 0, + "y": 0, + "z": 0 + } + }, + "pipetteId": "f6d1c83c-9d1b-4d0d-9de3-e6d649739cfb" + } } ], "errors": [], diff --git a/app/src/molecules/Command/__tests__/CommandText.test.tsx b/app/src/molecules/Command/__tests__/CommandText.test.tsx index 132105ecc44..9226f258830 100644 --- a/app/src/molecules/Command/__tests__/CommandText.test.tsx +++ b/app/src/molecules/Command/__tests__/CommandText.test.tsx @@ -1401,4 +1401,44 @@ describe('CommandText', () => { 'Moving NEST 96 Well Plate 100 µL PCR Full Skirt (1) using gripper from Magnetic Module GEN2 in Slot 1 to Magnetic Module GEN2 in Slot 1' ) }) + + it('renders correct text for liquidProbe', () => { + const command = mockCommandTextData.commands.find( + c => c.commandType === 'liquidProbe' + ) + expect(command).not.toBeUndefined() + if (command != null) { + renderWithProviders( + , + { i18nInstance: i18n } + ) + screen.getByText( + 'Detecting liquid presence in well A1 of Opentrons 96 Tip Rack 300 µL in Slot 9' + ) + } + }) + + it('renders correct text for tryLiquidProbe', () => { + const command = mockCommandTextData.commands.find( + c => c.commandType === 'tryLiquidProbe' + ) + expect(command).not.toBeUndefined() + if (command != null) { + renderWithProviders( + , + { i18nInstance: i18n } + ) + screen.getByText( + 'Detecting liquid presence in well A1 of Opentrons 96 Tip Rack 300 µL in Slot 9' + ) + } + }) }) diff --git a/app/src/molecules/Command/hooks/index.ts b/app/src/molecules/Command/hooks/index.ts new file mode 100644 index 00000000000..6b6545c7689 --- /dev/null +++ b/app/src/molecules/Command/hooks/index.ts @@ -0,0 +1,7 @@ +export { useCommandTextString } from './useCommandTextString' + +export type { + UseCommandTextStringParams, + GetCommandText, + GetCommandTextResult, +} from './useCommandTextString' diff --git a/app/src/molecules/Command/hooks/useCommandTextString/index.tsx b/app/src/molecules/Command/hooks/useCommandTextString/index.tsx new file mode 100644 index 00000000000..34df2f33c7f --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/index.tsx @@ -0,0 +1,231 @@ +import { useTranslation } from 'react-i18next' +import * as utils from './utils' + +import type { TFunction } from 'i18next' +import type { RunTimeCommand, RobotType } from '@opentrons/shared-data' +import type { CommandTextData } from '../../types' +import type { GetDirectTranslationCommandText } from './utils/getDirectTranslationCommandText' + +export interface UseCommandTextStringParams { + command: RunTimeCommand | null + commandTextData: CommandTextData | null + robotType: RobotType +} + +export type GetCommandText = UseCommandTextStringParams & { t: TFunction } +export interface GetCommandTextResult { + /* The actual command text. Ex "Homing all gantry, pipette, and plunger axes" */ + commandText: string + /* The TC run profile steps. */ + stepTexts?: string[] +} + +// TODO(jh, 07-18-24): Move the testing that covers this from CommandText to a new file, and verify that all commands are +// properly tested. + +// Get the full user-facing command text string from a given command. +export function useCommandTextString( + params: UseCommandTextStringParams +): GetCommandTextResult { + const { command } = params + const { t } = useTranslation('protocol_command_text') + + const fullParams = { ...params, t } + + switch (command?.commandType) { + case 'touchTip': + case 'home': + case 'savePosition': + case 'magneticModule/engage': + case 'magneticModule/disengage': + case 'temperatureModule/deactivate': + case 'thermocycler/waitForBlockTemperature': + case 'thermocycler/waitForLidTemperature': + case 'thermocycler/openLid': + case 'thermocycler/closeLid': + case 'thermocycler/deactivateBlock': + case 'thermocycler/deactivateLid': + case 'thermocycler/awaitProfileComplete': + case 'heaterShaker/deactivateHeater': + case 'heaterShaker/openLabwareLatch': + case 'heaterShaker/closeLabwareLatch': + case 'heaterShaker/deactivateShaker': + case 'heaterShaker/waitForTemperature': + return { + commandText: utils.getDirectTranslationCommandText( + fullParams as GetDirectTranslationCommandText + ), + } + + case 'aspirate': + case 'aspirateInPlace': + case 'dispense': + case 'dispenseInPlace': + case 'blowout': + case 'blowOutInPlace': + case 'dropTip': + case 'dropTipInPlace': + case 'pickUpTip': + return { + commandText: utils.getPipettingCommandText(fullParams), + } + + case 'loadLabware': + case 'loadPipette': + case 'loadModule': + case 'loadLiquid': + return { + commandText: utils.getLoadCommandText(fullParams), + } + + case 'liquidProbe': + case 'tryLiquidProbe': + return { + commandText: utils.getLiquidProbeCommandText({ + ...fullParams, + command, + }), + } + + case 'temperatureModule/setTargetTemperature': + case 'temperatureModule/waitForTemperature': + case 'thermocycler/setTargetBlockTemperature': + case 'thermocycler/setTargetLidTemperature': + case 'heaterShaker/setTargetTemperature': + return { + commandText: utils.getTemperatureCommandText({ + ...fullParams, + command, + }), + } + + case 'thermocycler/runProfile': + return utils.getTCRunProfileCommandText({ ...fullParams, command }) + + case 'heaterShaker/setAndWaitForShakeSpeed': + return { + commandText: utils.getHSShakeSpeedCommandText({ + ...fullParams, + command, + }), + } + + case 'moveToSlot': + return { + commandText: utils.getMoveToSlotCommandText({ ...fullParams, command }), + } + + case 'moveRelative': + return { + commandText: utils.getMoveRelativeCommandText({ + ...fullParams, + command, + }), + } + + case 'moveToCoordinates': + return { + commandText: utils.getMoveToCoordinatesCommandText({ + ...fullParams, + command, + }), + } + + case 'moveToWell': + return { + commandText: utils.getMoveToWellCommandText({ ...fullParams, command }), + } + + case 'moveLabware': + return { + commandText: utils.getMoveLabwareCommandText({ + ...fullParams, + command, + }), + } + + case 'configureForVolume': + return { + commandText: utils.getConfigureForVolumeCommandText({ + ...fullParams, + command, + }), + } + + case 'configureNozzleLayout': + return { + commandText: utils.getConfigureNozzleLayoutCommandText({ + ...fullParams, + command, + }), + } + + case 'prepareToAspirate': + return { + commandText: utils.getPrepareToAspirateCommandText({ + ...fullParams, + command, + }), + } + + case 'moveToAddressableArea': + return { + commandText: utils.getMoveToAddressableAreaCommandText({ + ...fullParams, + command, + }), + } + + case 'moveToAddressableAreaForDropTip': + return { + commandText: utils.getMoveToAddressableAreaForDropTipCommandText({ + ...fullParams, + command, + }), + } + + case 'waitForDuration': + return { + commandText: utils.getWaitForDurationCommandText({ + ...fullParams, + command, + }), + } + + case 'pause': // legacy pause command + case 'waitForResume': + return { + commandText: utils.getWaitForResumeCommandText({ + ...fullParams, + command, + }), + } + + case 'delay': + return { + commandText: utils.getDelayCommandText({ ...fullParams, command }), + } + + case 'comment': + return { + commandText: utils.getCommentCommandText({ ...fullParams, command }), + } + + case 'custom': + return { + commandText: utils.getCustomCommandText({ ...fullParams, command }), + } + + case null: + return { commandText: '' } + + default: + console.warn( + 'CommandText encountered a command with an unrecognized commandType: ', + command + ) + return { + commandText: utils.getUnknownCommandText({ ...fullParams, command }), + } + } +} diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getCommentCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getCommentCommandText.ts new file mode 100644 index 00000000000..3a1b7ce7e8a --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getCommentCommandText.ts @@ -0,0 +1,10 @@ +import type { CommentRunTimeCommand } from '@opentrons/shared-data/command' +import type { HandlesCommands } from './types' + +export function getCommentCommandText({ + command, +}: HandlesCommands): string { + const { message } = command.params + + return message +} diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getConfigureForVolumeCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getConfigureForVolumeCommandText.ts new file mode 100644 index 00000000000..1a4ee2e7c0e --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getConfigureForVolumeCommandText.ts @@ -0,0 +1,21 @@ +import { getPipetteSpecsV2 } from '@opentrons/shared-data' + +import type { ConfigureForVolumeRunTimeCommand } from '@opentrons/shared-data/command' +import type { HandlesCommands } from './types' + +export function getConfigureForVolumeCommandText({ + command, + commandTextData, + t, +}: HandlesCommands): string { + const { volume, pipetteId } = command.params + const pipetteName = commandTextData?.pipettes.find( + pip => pip.id === pipetteId + )?.pipetteName + + return t('configure_for_volume', { + volume, + pipette: + pipetteName != null ? getPipetteSpecsV2(pipetteName)?.displayName : '', + }) +} diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getConfigureNozzleLayoutCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getConfigureNozzleLayoutCommandText.ts new file mode 100644 index 00000000000..e6693a4b937 --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getConfigureNozzleLayoutCommandText.ts @@ -0,0 +1,21 @@ +import { getPipetteSpecsV2 } from '@opentrons/shared-data' + +import type { ConfigureNozzleLayoutRunTimeCommand } from '@opentrons/shared-data' +import type { HandlesCommands } from './types' + +export function getConfigureNozzleLayoutCommandText({ + command, + commandTextData, + t, +}: HandlesCommands): string { + const { configurationParams, pipetteId } = command.params + const pipetteName = commandTextData?.pipettes.find( + pip => pip.id === pipetteId + )?.pipetteName + + return t('configure_nozzle_layout', { + amount: configurationParams.style === 'COLUMN' ? '8' : 'all', + pipette: + pipetteName != null ? getPipetteSpecsV2(pipetteName)?.displayName : '', + }) +} diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getCustomCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getCustomCommandText.ts new file mode 100644 index 00000000000..da6d5a1d506 --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getCustomCommandText.ts @@ -0,0 +1,16 @@ +import type { CustomRunTimeCommand } from '@opentrons/shared-data/command' +import type { HandlesCommands } from './types' + +export function getCustomCommandText({ + command, +}: HandlesCommands): string { + const { legacyCommandText } = command.params ?? {} + const sanitizedCommandText = + typeof legacyCommandText === 'object' + ? JSON.stringify(legacyCommandText) + : String(legacyCommandText) + + return legacyCommandText != null + ? sanitizedCommandText + : `${command.commandType}: ${JSON.stringify(command.params)}` +} diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getDelayCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getDelayCommandText.ts new file mode 100644 index 00000000000..8bb24d99661 --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getDelayCommandText.ts @@ -0,0 +1,20 @@ +import type { DeprecatedDelayRunTimeCommand } from '@opentrons/shared-data/command' +import type { HandlesCommands } from './types' + +export function getDelayCommandText({ + command, + t, +}: HandlesCommands): string { + const { message = '' } = command.params + + if ('waitForResume' in command.params) { + return command.params?.message != null && command.params.message !== '' + ? command.params.message + : t('wait_for_resume') + } else { + return t('wait_for_duration', { + seconds: command.params.seconds, + message, + }) + } +} diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getDirectTranslationCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getDirectTranslationCommandText.ts new file mode 100644 index 00000000000..fd586136e90 --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getDirectTranslationCommandText.ts @@ -0,0 +1,44 @@ +import type { RunTimeCommand } from '@opentrons/shared-data/command' +import type { HandlesCommands } from './types' + +const SIMPLE_TRANSLATION_KEY_BY_COMMAND_TYPE: { + [commandType in RunTimeCommand['commandType']]?: string +} = { + home: 'home_gantry', + savePosition: 'save_position', + touchTip: 'touch_tip', + 'magneticModule/engage': 'engaging_magnetic_module', + 'magneticModule/disengage': 'disengaging_magnetic_module', + 'temperatureModule/deactivate': 'deactivate_temperature_module', + 'thermocycler/waitForBlockTemperature': 'waiting_for_tc_block_to_reach', + 'thermocycler/waitForLidTemperature': 'waiting_for_tc_lid_to_reach', + 'thermocycler/openLid': 'opening_tc_lid', + 'thermocycler/closeLid': 'closing_tc_lid', + 'thermocycler/deactivateBlock': 'deactivating_tc_block', + 'thermocycler/deactivateLid': 'deactivating_tc_lid', + 'thermocycler/awaitProfileComplete': 'tc_awaiting_for_duration', + 'heaterShaker/deactivateHeater': 'deactivating_hs_heater', + 'heaterShaker/openLabwareLatch': 'unlatching_hs_latch', + 'heaterShaker/closeLabwareLatch': 'latching_hs_latch', + 'heaterShaker/deactivateShaker': 'deactivate_hs_shake', + 'heaterShaker/waitForTemperature': 'waiting_for_hs_to_reach', +} + +type HandledCommands = Extract< + RunTimeCommand, + { commandType: keyof typeof SIMPLE_TRANSLATION_KEY_BY_COMMAND_TYPE } +> + +export type GetDirectTranslationCommandText = HandlesCommands + +export function getDirectTranslationCommandText({ + command, + t, +}: GetDirectTranslationCommandText): string { + const simpleTKey = + command != null + ? SIMPLE_TRANSLATION_KEY_BY_COMMAND_TYPE[command.commandType] + : null + + return simpleTKey != null ? t(simpleTKey) : '' +} diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getHSShakeSpeedCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getHSShakeSpeedCommandText.ts new file mode 100644 index 00000000000..3710e7f0930 --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getHSShakeSpeedCommandText.ts @@ -0,0 +1,11 @@ +import type { HeaterShakerSetAndWaitForShakeSpeedRunTimeCommand } from '@opentrons/shared-data/command' +import type { HandlesCommands } from './types' + +export function getHSShakeSpeedCommandText({ + command, + t, +}: HandlesCommands): string { + const { rpm } = command.params + + return t('set_and_await_hs_shake', { rpm }) +} diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getLiquidProbeCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getLiquidProbeCommandText.ts new file mode 100644 index 00000000000..a61a4bdf2a3 --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getLiquidProbeCommandText.ts @@ -0,0 +1,60 @@ +import { + getFinalLabwareLocation, + getLabwareDisplayLocation, + getLabwareName, +} from '../../../utils' + +import type { + LiquidProbeRunTimeCommand, + RunTimeCommand, + TryLiquidProbeRunTimeCommand, +} from '@opentrons/shared-data' +import type { HandlesCommands } from './types' +import type { TFunction } from 'i18next' + +type LiquidProbeRunTimeCommands = + | LiquidProbeRunTimeCommand + | TryLiquidProbeRunTimeCommand + +export function getLiquidProbeCommandText({ + command, + commandTextData, + t, + robotType, +}: HandlesCommands): string { + const { wellName, labwareId } = command.params + + const allPreviousCommands = commandTextData?.commands.slice( + 0, + commandTextData.commands.findIndex(c => c.id === command?.id) + ) + + const labwareLocation = + allPreviousCommands != null + ? getFinalLabwareLocation( + labwareId as string, + allPreviousCommands as RunTimeCommand[] + ) + : null + + const displayLocation = + labwareLocation != null && commandTextData != null + ? getLabwareDisplayLocation( + commandTextData, + labwareLocation, + t as TFunction, + robotType + ) + : '' + + const labware = + commandTextData != null + ? getLabwareName(commandTextData, labwareId as string) + : null + + return t('detect_liquid_presence', { + labware, + labware_location: displayLocation, + well_name: wellName, + }) +} diff --git a/app/src/molecules/Command/LoadCommandText.tsx b/app/src/molecules/Command/hooks/useCommandTextString/utils/getLoadCommandText.ts similarity index 70% rename from app/src/molecules/Command/LoadCommandText.tsx rename to app/src/molecules/Command/hooks/useCommandTextString/utils/getLoadCommandText.ts index 251d0f3d38d..b2da948d58d 100644 --- a/app/src/molecules/Command/LoadCommandText.tsx +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getLoadCommandText.ts @@ -1,48 +1,37 @@ -import { useTranslation } from 'react-i18next' import { getModuleDisplayName, getModuleType, getOccludedSlotCountForModule, - getPipetteNameSpecs, + getPipetteSpecsV2, } from '@opentrons/shared-data' + import { getLabwareName, getPipetteNameOnMount, getModuleModel, getModuleDisplayLocation, getLiquidDisplayName, -} from './utils' - -import type { - RunTimeCommand, - RobotType, - LoadLabwareRunTimeCommand, -} from '@opentrons/shared-data' -import type { CommandTextData } from './types' +} from '../../../utils' -interface LoadCommandTextProps { - command: RunTimeCommand - commandTextData: CommandTextData - robotType: RobotType -} +import type { LoadLabwareRunTimeCommand } from '@opentrons/shared-data' +import type { GetCommandText } from '..' -export const LoadCommandText = ({ +export const getLoadCommandText = ({ command, commandTextData, robotType, -}: LoadCommandTextProps): JSX.Element | null => { - const { t } = useTranslation('run_details') - - switch (command.commandType) { + t, +}: GetCommandText): string => { + switch (command?.commandType) { case 'loadPipette': { - const pipetteModel = getPipetteNameOnMount( - commandTextData, - command.params.mount - ) + const pipetteModel = + commandTextData != null + ? getPipetteNameOnMount(commandTextData, command.params.mount) + : null return t('load_pipette_protocol_setup', { pipette_name: pipetteModel != null - ? getPipetteNameSpecs(pipetteModel)?.displayName ?? '' + ? getPipetteSpecsV2(pipetteModel)?.displayName ?? '' : '', mount_name: command.params.mount === 'left' ? t('left') : t('right'), }) @@ -63,10 +52,10 @@ export const LoadCommandText = ({ command.params.location !== 'offDeck' && 'moduleId' in command.params.location ) { - const moduleModel = getModuleModel( - commandTextData, - command.params.location.moduleId - ) + const moduleModel = + commandTextData != null + ? getModuleModel(commandTextData, command.params.location.moduleId) + : null const moduleName = moduleModel != null ? getModuleDisplayName(moduleModel) : '' @@ -79,10 +68,13 @@ export const LoadCommandText = ({ ) : 1, labware: command.result?.definition.metadata.displayName, - slot_name: getModuleDisplayLocation( - commandTextData, - command.params.location.moduleId - ), + slot_name: + commandTextData != null + ? getModuleDisplayLocation( + commandTextData, + command.params.location.moduleId + ) + : null, module_name: moduleName, }) } else if ( @@ -91,7 +83,7 @@ export const LoadCommandText = ({ ) { const labwareId = command.params.location.labwareId const labwareName = command.result?.definition.metadata.displayName - const matchingAdapter = commandTextData.commands.find( + const matchingAdapter = commandTextData?.commands.find( (command): command is LoadLabwareRunTimeCommand => command.commandType === 'loadLabware' && command.result?.labwareId === labwareId @@ -111,24 +103,27 @@ export const LoadCommandText = ({ slot_name: adapterLoc?.slotName, }) } else if (adapterLoc != null && 'moduleId' in adapterLoc) { - const moduleModel = getModuleModel( - commandTextData, - adapterLoc?.moduleId ?? '' - ) + const moduleModel = + commandTextData != null + ? getModuleModel(commandTextData, adapterLoc?.moduleId ?? '') + : null const moduleName = moduleModel != null ? getModuleDisplayName(moduleModel) : '' return t('load_labware_info_protocol_setup_adapter_module', { labware: labwareName, adapter_name: adapterName, module_name: moduleName, - slot_name: getModuleDisplayLocation( - commandTextData, - adapterLoc?.moduleId ?? '' - ), + slot_name: + commandTextData != null + ? getModuleDisplayLocation( + commandTextData, + adapterLoc?.moduleId ?? '' + ) + : null, }) } else { // shouldn't reach here, adapter shouldn't have location type labwareId - return null + return '' } } else { const labware = @@ -148,8 +143,14 @@ export const LoadCommandText = ({ case 'loadLiquid': { const { liquidId, labwareId } = command.params return t('load_liquids_info_protocol_setup', { - liquid: getLiquidDisplayName(commandTextData, liquidId), - labware: getLabwareName(commandTextData, labwareId), + liquid: + commandTextData != null + ? getLiquidDisplayName(commandTextData, liquidId) + : null, + labware: + commandTextData != null + ? getLabwareName(commandTextData, labwareId) + : null, }) } default: { @@ -157,7 +158,7 @@ export const LoadCommandText = ({ 'LoadCommandText encountered a command with an unrecognized commandType: ', command ) - return null + return '' } } } diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveLabwareCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveLabwareCommandText.ts new file mode 100644 index 00000000000..71a0ac3e7d6 --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveLabwareCommandText.ts @@ -0,0 +1,72 @@ +import { GRIPPER_WASTE_CHUTE_ADDRESSABLE_AREA } from '@opentrons/shared-data' + +import { + getLabwareName, + getLabwareDisplayLocation, + getFinalLabwareLocation, +} from '../../../utils' + +import type { MoveLabwareRunTimeCommand } from '@opentrons/shared-data' +import type { HandlesCommands } from './types' + +export function getMoveLabwareCommandText({ + command, + t, + commandTextData, + robotType, +}: HandlesCommands): string { + const { labwareId, newLocation, strategy } = command.params + + const allPreviousCommands = commandTextData?.commands.slice( + 0, + commandTextData.commands.findIndex(c => c.id === command.id) + ) + const oldLocation = + allPreviousCommands != null + ? getFinalLabwareLocation(labwareId, allPreviousCommands) + : null + const newDisplayLocation = + commandTextData != null + ? getLabwareDisplayLocation(commandTextData, newLocation, t, robotType) + : null + + const location = newDisplayLocation?.includes( + GRIPPER_WASTE_CHUTE_ADDRESSABLE_AREA + ) + ? 'Waste Chute' + : newDisplayLocation + + return strategy === 'usingGripper' + ? t('move_labware_using_gripper', { + labware: + commandTextData != null + ? getLabwareName(commandTextData, labwareId) + : null, + old_location: + oldLocation != null && commandTextData != null + ? getLabwareDisplayLocation( + commandTextData, + oldLocation, + t, + robotType + ) + : '', + new_location: location, + }) + : t('move_labware_manually', { + labware: + commandTextData != null + ? getLabwareName(commandTextData, labwareId) + : null, + old_location: + oldLocation != null && commandTextData != null + ? getLabwareDisplayLocation( + commandTextData, + oldLocation, + t, + robotType + ) + : '', + new_location: location, + }) +} diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveRelativeCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveRelativeCommandText.ts new file mode 100644 index 00000000000..7f3f8bf0aaa --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveRelativeCommandText.ts @@ -0,0 +1,11 @@ +import type { MoveRelativeRunTimeCommand } from '@opentrons/shared-data/command' +import type { HandlesCommands } from './types' + +export function getMoveRelativeCommandText({ + command, + t, +}: HandlesCommands): string { + const { axis, distance } = command.params + + return t('move_relative', { axis, distance }) +} diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToAddressabelAreaForDropTipCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToAddressabelAreaForDropTipCommandText.ts new file mode 100644 index 00000000000..5788fbbdf62 --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToAddressabelAreaForDropTipCommandText.ts @@ -0,0 +1,19 @@ +import { getAddressableAreaDisplayName } from '../../../utils' + +import type { MoveToAddressableAreaForDropTipRunTimeCommand } from '@opentrons/shared-data/command' +import type { HandlesCommands } from './types' + +export function getMoveToAddressableAreaForDropTipCommandText({ + command, + commandTextData, + t, +}: HandlesCommands): string { + const addressableAreaDisplayName = + commandTextData != null + ? getAddressableAreaDisplayName(commandTextData, command.id, t) + : null + + return t('move_to_addressable_area_drop_tip', { + addressable_area: addressableAreaDisplayName, + }) +} diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToAddressableAreaCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToAddressableAreaCommandText.ts new file mode 100644 index 00000000000..e8366120a23 --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToAddressableAreaCommandText.ts @@ -0,0 +1,19 @@ +import { getAddressableAreaDisplayName } from '../../../utils' + +import type { MoveToAddressableAreaRunTimeCommand } from '@opentrons/shared-data/command' +import type { HandlesCommands } from './types' + +export function getMoveToAddressableAreaCommandText({ + command, + commandTextData, + t, +}: HandlesCommands): string { + const addressableAreaDisplayName = + commandTextData != null + ? getAddressableAreaDisplayName(commandTextData, command.id, t) + : null + + return t('move_to_addressable_area', { + addressable_area: addressableAreaDisplayName, + }) +} diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToCoordinatesCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToCoordinatesCommandText.ts new file mode 100644 index 00000000000..a3dc5ace9fe --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToCoordinatesCommandText.ts @@ -0,0 +1,11 @@ +import type { MoveToCoordinatesRunTimeCommand } from '@opentrons/shared-data/command' +import type { HandlesCommands } from './types' + +export function getMoveToCoordinatesCommandText({ + command, + t, +}: HandlesCommands): string { + const { coordinates } = command.params + + return t('move_to_coordinates', coordinates) +} diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToSlotCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToSlotCommandText.ts new file mode 100644 index 00000000000..b66f5d78513 --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToSlotCommandText.ts @@ -0,0 +1,11 @@ +import type { MoveToSlotRunTimeCommand } from '@opentrons/shared-data/command' +import type { HandlesCommands } from './types' + +export function getMoveToSlotCommandText({ + command, + t, +}: HandlesCommands): string { + const { slotName } = command.params + + return t('move_to_slot', { slot_name: slotName }) +} diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToWellCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToWellCommandText.ts new file mode 100644 index 00000000000..8c191f34b40 --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getMoveToWellCommandText.ts @@ -0,0 +1,44 @@ +import { + getFinalLabwareLocation, + getLabwareDisplayLocation, + getLabwareName, +} from '../../../utils' + +import type { TFunction } from 'i18next' +import type { MoveToWellRunTimeCommand } from '@opentrons/shared-data/command' +import type { HandlesCommands } from './types' + +export function getMoveToWellCommandText({ + command, + t, + commandTextData, + robotType, +}: HandlesCommands): string { + const { wellName, labwareId } = command.params + const allPreviousCommands = commandTextData?.commands.slice( + 0, + commandTextData.commands.findIndex(c => c.id === command.id) + ) + const labwareLocation = + allPreviousCommands != null + ? getFinalLabwareLocation(labwareId, allPreviousCommands) + : null + const displayLocation = + labwareLocation != null && commandTextData != null + ? getLabwareDisplayLocation( + commandTextData, + labwareLocation, + t as TFunction, + robotType + ) + : '' + + return t('move_to_well', { + well_name: wellName, + labware: + commandTextData != null + ? getLabwareName(commandTextData, labwareId) + : null, + labware_location: displayLocation, + }) +} diff --git a/app/src/molecules/Command/PipettingCommandText.tsx b/app/src/molecules/Command/hooks/useCommandTextString/utils/getPipettingCommandText.ts similarity index 51% rename from app/src/molecules/Command/PipettingCommandText.tsx rename to app/src/molecules/Command/hooks/useCommandTextString/utils/getPipettingCommandText.ts index 3037ce3e2c3..00c9eba08dd 100644 --- a/app/src/molecules/Command/PipettingCommandText.tsx +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getPipettingCommandText.ts @@ -1,50 +1,46 @@ -import { useTranslation } from 'react-i18next' - import { getLabwareDefURI } from '@opentrons/shared-data' -import { getLoadedLabware } from './utils/accessors' +import { getLoadedLabware } from '../../../utils/accessors' import { getLabwareName, getLabwareDisplayLocation, getFinalLabwareLocation, getWellRange, getLabwareDefinitionsFromCommands, -} from './utils' -import type { - PipetteName, - PipettingRunTimeCommand, - RobotType, -} from '@opentrons/shared-data' -import type { CommandTextData } from './types' -import type { TFunction } from 'i18next' +} from '../../../utils' -interface PipettingCommandTextProps { - command: PipettingRunTimeCommand - commandTextData: CommandTextData - robotType: RobotType -} +import type { PipetteName, RunTimeCommand } from '@opentrons/shared-data' +import type { TFunction } from 'i18next' +import type { GetCommandText } from '..' -export const PipettingCommandText = ({ +export const getPipettingCommandText = ({ command, commandTextData, robotType, -}: PipettingCommandTextProps): JSX.Element | null => { - const { t } = useTranslation('protocol_command_text') - + t, +}: GetCommandText): string => { const labwareId = - 'labwareId' in command.params ? command.params.labwareId : '' - const wellName = 'wellName' in command.params ? command.params.wellName : '' + command != null && 'labwareId' in command.params + ? (command.params.labwareId as string) + : '' + const wellName = + command != null && 'wellName' in command.params + ? command.params.wellName + : '' - const allPreviousCommands = commandTextData.commands.slice( + const allPreviousCommands = commandTextData?.commands.slice( 0, - commandTextData.commands.findIndex(c => c.id === command.id) - ) - const labwareLocation = getFinalLabwareLocation( - labwareId, - allPreviousCommands + commandTextData.commands.findIndex(c => c.id === command?.id) ) + const labwareLocation = + allPreviousCommands != null + ? getFinalLabwareLocation( + labwareId, + allPreviousCommands as RunTimeCommand[] + ) + : null const displayLocation = - labwareLocation != null + labwareLocation != null && commandTextData != null ? getLabwareDisplayLocation( commandTextData, labwareLocation, @@ -52,12 +48,15 @@ export const PipettingCommandText = ({ robotType ) : '' - switch (command.commandType) { + switch (command?.commandType) { case 'aspirate': { const { volume, flowRate } = command.params return t('aspirate', { well_name: wellName, - labware: getLabwareName(commandTextData, labwareId), + labware: + commandTextData != null + ? getLabwareName(commandTextData, labwareId) + : null, labware_location: displayLocation, volume, flow_rate: flowRate, @@ -68,7 +67,10 @@ export const PipettingCommandText = ({ return pushOut != null ? t('dispense_push_out', { well_name: wellName, - labware: getLabwareName(commandTextData, labwareId), + labware: + commandTextData != null + ? getLabwareName(commandTextData, labwareId) + : null, labware_location: displayLocation, volume, flow_rate: flowRate, @@ -76,7 +78,10 @@ export const PipettingCommandText = ({ }) : t('dispense', { well_name: wellName, - labware: getLabwareName(commandTextData, labwareId), + labware: + commandTextData != null + ? getLabwareName(commandTextData, labwareId) + : null, labware_location: displayLocation, volume, flow_rate: flowRate, @@ -86,46 +91,67 @@ export const PipettingCommandText = ({ const { flowRate } = command.params return t('blowout', { well_name: wellName, - labware: getLabwareName(commandTextData, labwareId), + labware: + commandTextData != null + ? getLabwareName(commandTextData, labwareId) + : null, labware_location: displayLocation, flow_rate: flowRate, }) } case 'dropTip': { - const loadedLabware = getLoadedLabware(commandTextData, labwareId) - const labwareDefinitions = getLabwareDefinitionsFromCommands( - commandTextData.commands - ) - const labwareDef = labwareDefinitions.find( + const loadedLabware = + commandTextData != null + ? getLoadedLabware(commandTextData, labwareId) + : null + const labwareDefinitions = + commandTextData != null + ? getLabwareDefinitionsFromCommands( + commandTextData.commands as RunTimeCommand[] + ) + : null + const labwareDef = labwareDefinitions?.find( lw => getLabwareDefURI(lw) === loadedLabware?.definitionUri ) return Boolean(labwareDef?.parameters.isTiprack) ? t('return_tip', { well_name: wellName, - labware: getLabwareName(commandTextData, labwareId), + labware: + commandTextData != null + ? getLabwareName(commandTextData, labwareId) + : null, labware_location: displayLocation, }) : t('drop_tip', { well_name: wellName, - labware: getLabwareName(commandTextData, labwareId), + labware: + commandTextData != null + ? getLabwareName(commandTextData, labwareId) + : null, }) } case 'pickUpTip': { const pipetteId = command.params.pipetteId const pipetteName: | PipetteName - | undefined = commandTextData.pipettes.find( + | undefined = commandTextData?.pipettes.find( pipette => pipette.id === pipetteId )?.pipetteName return t('pickup_tip', { - well_range: getWellRange( - pipetteId, - allPreviousCommands, - wellName, - pipetteName - ), - labware: getLabwareName(commandTextData, labwareId), + well_range: + allPreviousCommands != null + ? getWellRange( + pipetteId, + allPreviousCommands as RunTimeCommand[], + wellName as string, + pipetteName + ) + : null, + labware: + commandTextData != null + ? getLabwareName(commandTextData, labwareId) + : null, labware_location: displayLocation, }) } @@ -149,7 +175,7 @@ export const PipettingCommandText = ({ 'PipettingCommandText encountered a command with an unrecognized commandType: ', command ) - return null + return '' } } } diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getPrepareToAspirateCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getPrepareToAspirateCommandText.ts new file mode 100644 index 00000000000..13d32b6b7d6 --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getPrepareToAspirateCommandText.ts @@ -0,0 +1,20 @@ +import { getPipetteSpecsV2 } from '@opentrons/shared-data' + +import type { PrepareToAspirateRunTimeCommand } from '@opentrons/shared-data/command' +import type { HandlesCommands } from './types' + +export function getPrepareToAspirateCommandText({ + command, + commandTextData, + t, +}: HandlesCommands): string { + const { pipetteId } = command.params + const pipetteName = commandTextData?.pipettes.find( + pip => pip.id === pipetteId + )?.pipetteName + + return t('prepare_to_aspirate', { + pipette: + pipetteName != null ? getPipetteSpecsV2(pipetteName)?.displayName : '', + }) +} diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getTCRunProfileCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getTCRunProfileCommandText.ts new file mode 100644 index 00000000000..2d279fca850 --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getTCRunProfileCommandText.ts @@ -0,0 +1,24 @@ +import type { TCRunProfileRunTimeCommand } from '@opentrons/shared-data/command' +import type { GetCommandTextResult } from '..' +import type { HandlesCommands } from './types' + +export function getTCRunProfileCommandText({ + command, + t, +}: HandlesCommands): GetCommandTextResult { + const { profile } = command.params + + const stepTexts = profile.map( + ({ holdSeconds, celsius }: { holdSeconds: number; celsius: number }) => + t('tc_run_profile_steps', { + celsius, + seconds: holdSeconds, + }).trim() + ) + + const startingProfileText = t('tc_starting_profile', { + repetitions: Object.keys(stepTexts).length, + }) + + return { commandText: startingProfileText, stepTexts } +} diff --git a/app/src/molecules/Command/TemperatureCommandText.tsx b/app/src/molecules/Command/hooks/useCommandTextString/utils/getTemperatureCommandText.ts similarity index 78% rename from app/src/molecules/Command/TemperatureCommandText.tsx rename to app/src/molecules/Command/hooks/useCommandTextString/utils/getTemperatureCommandText.ts index 2d09926add2..ee60a76c289 100644 --- a/app/src/molecules/Command/TemperatureCommandText.tsx +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getTemperatureCommandText.ts @@ -1,23 +1,20 @@ -import { useTranslation } from 'react-i18next' import type { TemperatureModuleAwaitTemperatureCreateCommand, TemperatureModuleSetTargetTemperatureCreateCommand, TCSetTargetBlockTemperatureCreateCommand, TCSetTargetLidTemperatureCreateCommand, HeaterShakerSetTargetTemperatureCreateCommand, + RunTimeCommand, } from '@opentrons/shared-data' +import type { HandlesCommands } from './types' -type TemperatureCreateCommand = +export type TemperatureCreateCommand = | TemperatureModuleSetTargetTemperatureCreateCommand | TemperatureModuleAwaitTemperatureCreateCommand | TCSetTargetBlockTemperatureCreateCommand | TCSetTargetLidTemperatureCreateCommand | HeaterShakerSetTargetTemperatureCreateCommand -interface TemperatureCommandTextProps { - command: TemperatureCreateCommand -} - const T_KEYS_BY_COMMAND_TYPE: { [commandType in TemperatureCreateCommand['commandType']]: string } = { @@ -28,11 +25,17 @@ const T_KEYS_BY_COMMAND_TYPE: { 'heaterShaker/setTargetTemperature': 'setting_hs_temp', } -export const TemperatureCommandText = ({ - command, -}: TemperatureCommandTextProps): JSX.Element | null => { - const { t } = useTranslation('protocol_command_text') +type HandledCommands = Extract< + RunTimeCommand, + { commandType: keyof typeof T_KEYS_BY_COMMAND_TYPE } +> +type GetTemperatureCommandText = HandlesCommands + +export const getTemperatureCommandText = ({ + command, + t, +}: GetTemperatureCommandText): string => { return t(T_KEYS_BY_COMMAND_TYPE[command.commandType], { temp: command.params?.celsius != null diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getUnknownCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getUnknownCommandText.ts new file mode 100644 index 00000000000..4f2346c7c01 --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getUnknownCommandText.ts @@ -0,0 +1,5 @@ +import type { GetCommandText } from '..' + +export function getUnknownCommandText({ command }: GetCommandText): string { + return JSON.stringify(command) +} diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getWaitForDurationCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getWaitForDurationCommandText.ts new file mode 100644 index 00000000000..d3b3136be1f --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getWaitForDurationCommandText.ts @@ -0,0 +1,11 @@ +import type { WaitForDurationRunTimeCommand } from '@opentrons/shared-data/command' +import type { HandlesCommands } from './types' + +export function getWaitForDurationCommandText({ + command, + t, +}: HandlesCommands): string { + const { seconds, message } = command.params + + return t('wait_for_duration', { seconds, message: message ?? '' }) +} diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getWaitForResumeCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getWaitForResumeCommandText.ts new file mode 100644 index 00000000000..f1c7b7fcef6 --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getWaitForResumeCommandText.ts @@ -0,0 +1,11 @@ +import type { WaitForResumeRunTimeCommand } from '@opentrons/shared-data/command' +import type { HandlesCommands } from './types' + +export function getWaitForResumeCommandText({ + command, + t, +}: HandlesCommands): string { + return command.params?.message != null && command.params.message !== '' + ? command.params.message + : t('wait_for_resume') +} diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/index.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/index.ts new file mode 100644 index 00000000000..f7946ff1e47 --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/index.ts @@ -0,0 +1,23 @@ +export { getLoadCommandText } from './getLoadCommandText' +export { getTemperatureCommandText } from './getTemperatureCommandText' +export { getTCRunProfileCommandText } from './getTCRunProfileCommandText' +export { getHSShakeSpeedCommandText } from './getHSShakeSpeedCommandText' +export { getMoveToSlotCommandText } from './getMoveToSlotCommandText' +export { getMoveRelativeCommandText } from './getMoveRelativeCommandText' +export { getMoveToCoordinatesCommandText } from './getMoveToCoordinatesCommandText' +export { getMoveToWellCommandText } from './getMoveToWellCommandText' +export { getMoveLabwareCommandText } from './getMoveLabwareCommandText' +export { getConfigureForVolumeCommandText } from './getConfigureForVolumeCommandText' +export { getConfigureNozzleLayoutCommandText } from './getConfigureNozzleLayoutCommandText' +export { getPrepareToAspirateCommandText } from './getPrepareToAspirateCommandText' +export { getMoveToAddressableAreaCommandText } from './getMoveToAddressableAreaCommandText' +export { getMoveToAddressableAreaForDropTipCommandText } from './getMoveToAddressabelAreaForDropTipCommandText' +export { getDirectTranslationCommandText } from './getDirectTranslationCommandText' +export { getWaitForDurationCommandText } from './getWaitForDurationCommandText' +export { getWaitForResumeCommandText } from './getWaitForResumeCommandText' +export { getDelayCommandText } from './getDelayCommandText' +export { getCommentCommandText } from './getCommentCommandText' +export { getCustomCommandText } from './getCustomCommandText' +export { getUnknownCommandText } from './getUnknownCommandText' +export { getPipettingCommandText } from './getPipettingCommandText' +export { getLiquidProbeCommandText } from './getLiquidProbeCommandText' diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/types.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/types.ts new file mode 100644 index 00000000000..37dde8c783a --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/types.ts @@ -0,0 +1,7 @@ +import type { RunTimeCommand } from '@opentrons/shared-data' +import type { GetCommandText } from '..' + +export type HandlesCommands = Omit< + GetCommandText, + 'command' +> & { command: T } diff --git a/app/src/molecules/Command/index.ts b/app/src/molecules/Command/index.ts index 357acd6b85b..b4223d82beb 100644 --- a/app/src/molecules/Command/index.ts +++ b/app/src/molecules/Command/index.ts @@ -4,3 +4,4 @@ export * from './CommandIcon' export * from './CommandIndex' export * from './utils' export * from './types' +export * from './hooks' diff --git a/app/src/molecules/InProgressModal/InProgressModal.tsx b/app/src/molecules/InProgressModal/InProgressModal.tsx index 57d91940658..63ed2e61365 100644 --- a/app/src/molecules/InProgressModal/InProgressModal.tsx +++ b/app/src/molecules/InProgressModal/InProgressModal.tsx @@ -55,7 +55,8 @@ const MODAL_STYLE = css` padding: ${SPACING.spacing32}; height: 24.625rem; @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { - height: 29.5rem; + max-height: 29.5rem; + height: 100%; } ` const SPINNER_STYLE = css` diff --git a/app/src/molecules/InterventionModal/OneColumn.stories.tsx b/app/src/molecules/InterventionModal/OneColumn.stories.tsx index 60e4efa03b8..ef4e8a6a02f 100644 --- a/app/src/molecules/InterventionModal/OneColumn.stories.tsx +++ b/app/src/molecules/InterventionModal/OneColumn.stories.tsx @@ -1,39 +1,13 @@ import * as React from 'react' -import { - LegacyStyledText, - Box, - Flex, - BORDERS, - RESPONSIVENESS, - SPACING, - ALIGN_CENTER, - JUSTIFY_CENTER, -} from '@opentrons/components' +import { Box, RESPONSIVENESS } from '@opentrons/components' import { OneColumn as OneColumnComponent } from './' +import { StandInContent } from './story-utils/StandIn' import type { Meta, StoryObj } from '@storybook/react' -function StandInContent(): JSX.Element { - return ( - - - This is a standin for some other component - - - ) -} - -const meta: Meta> = { +const meta: Meta> = { title: 'App/Molecules/InterventionModal/OneColumn', component: OneColumnComponent, render: args => ( @@ -46,7 +20,7 @@ const meta: Meta> = { `} > - + This is a standin for another component ), @@ -54,6 +28,6 @@ const meta: Meta> = { export default meta -export type Story = StoryObj +export type Story = StoryObj export const ExampleOneColumn: Story = { args: {} } diff --git a/app/src/molecules/InterventionModal/OneColumn.tsx b/app/src/molecules/InterventionModal/OneColumn.tsx index 0c36b6ecac7..e92f3ffd51e 100644 --- a/app/src/molecules/InterventionModal/OneColumn.tsx +++ b/app/src/molecules/InterventionModal/OneColumn.tsx @@ -1,11 +1,28 @@ import * as React from 'react' -import { Box } from '@opentrons/components' +import { + Flex, + DIRECTION_COLUMN, + JUSTIFY_SPACE_BETWEEN, +} from '@opentrons/components' +import type { StyleProps } from '@opentrons/components' -export interface OneColumnProps { +export interface OneColumnProps extends StyleProps { children: React.ReactNode } -export function OneColumn({ children }: OneColumnProps): JSX.Element { - return {children} +export function OneColumn({ + children, + ...styleProps +}: OneColumnProps): JSX.Element { + return ( + + {children} + + ) } diff --git a/app/src/molecules/InterventionModal/OneColumnOrTwoColumn.stories.tsx b/app/src/molecules/InterventionModal/OneColumnOrTwoColumn.stories.tsx new file mode 100644 index 00000000000..791edcbdb83 --- /dev/null +++ b/app/src/molecules/InterventionModal/OneColumnOrTwoColumn.stories.tsx @@ -0,0 +1,68 @@ +import * as React from 'react' + +import { OneColumnOrTwoColumn } from './' + +import { StandInContent } from './story-utils/StandIn' +import { VisibleContainer } from './story-utils/VisibleContainer' +import { css } from 'styled-components' +import { + RESPONSIVENESS, + Flex, + ALIGN_CENTER, + JUSTIFY_SPACE_AROUND, + DIRECTION_COLUMN, +} from '@opentrons/components' + +import type { Meta, StoryObj } from '@storybook/react' + +function Wrapper(props: {}): JSX.Element { + return ( + + + + This component is the only one shown on the ODD. + + + + + This component is shown in the right column on desktop. + + + + ) +} + +const meta: Meta> = { + title: 'App/Molecules/InterventionModal/OneColumnOrTwoColumn', + component: Wrapper, + decorators: [ + Story => ( + + + + ), + ], +} + +export default meta + +type Story = StoryObj + +export const OneOrTwoColumn: Story = {} diff --git a/app/src/molecules/InterventionModal/OneColumnOrTwoColumn.tsx b/app/src/molecules/InterventionModal/OneColumnOrTwoColumn.tsx new file mode 100644 index 00000000000..8a6455d67e3 --- /dev/null +++ b/app/src/molecules/InterventionModal/OneColumnOrTwoColumn.tsx @@ -0,0 +1,55 @@ +import * as React from 'react' + +import { css } from 'styled-components' +import { + Flex, + Box, + DIRECTION_ROW, + SPACING, + WRAP, + RESPONSIVENESS, +} from '@opentrons/components' +import type { StyleProps } from '@opentrons/components' +import { TWO_COLUMN_ELEMENT_MIN_WIDTH } from './constants' + +export interface OneColumnOrTwoColumnProps extends StyleProps { + children: [React.ReactNode, React.ReactNode] +} + +export function OneColumnOrTwoColumn({ + children: [leftOrSingleElement, optionallyDisplayedRightElement], + ...styleProps +}: OneColumnOrTwoColumnProps): JSX.Element { + return ( + + + {leftOrSingleElement} + + + {optionallyDisplayedRightElement} + + + ) +} diff --git a/app/src/molecules/InterventionModal/TwoColumn.tsx b/app/src/molecules/InterventionModal/TwoColumn.tsx index 8e87a2d62b5..f0ed10ebf2a 100644 --- a/app/src/molecules/InterventionModal/TwoColumn.tsx +++ b/app/src/molecules/InterventionModal/TwoColumn.tsx @@ -1,20 +1,28 @@ import * as React from 'react' import { Flex, Box, DIRECTION_ROW, SPACING, WRAP } from '@opentrons/components' +import type { StyleProps } from '@opentrons/components' +import { TWO_COLUMN_ELEMENT_MIN_WIDTH } from './constants' -export interface TwoColumnProps { +export interface TwoColumnProps extends StyleProps { children: [React.ReactNode, React.ReactNode] } export function TwoColumn({ children: [leftElement, rightElement], + ...styleProps }: TwoColumnProps): JSX.Element { return ( - - + + {leftElement} - + {rightElement} diff --git a/app/src/molecules/InterventionModal/constants.ts b/app/src/molecules/InterventionModal/constants.ts new file mode 100644 index 00000000000..c5f1fbea4d0 --- /dev/null +++ b/app/src/molecules/InterventionModal/constants.ts @@ -0,0 +1 @@ +export const TWO_COLUMN_ELEMENT_MIN_WIDTH = '17.1875rem' as const diff --git a/app/src/molecules/InterventionModal/index.tsx b/app/src/molecules/InterventionModal/index.tsx index 4d2de359b60..3faa3b34f2c 100644 --- a/app/src/molecules/InterventionModal/index.tsx +++ b/app/src/molecules/InterventionModal/index.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { useSelector } from 'react-redux' +import { css } from 'styled-components' import { ALIGN_CENTER, @@ -15,6 +16,7 @@ import { POSITION_STICKY, SPACING, DIRECTION_COLUMN, + RESPONSIVENESS, } from '@opentrons/components' import { getIsOnDevice } from '../../redux/config' @@ -23,6 +25,7 @@ import type { IconName } from '@opentrons/components' import { ModalContentOneColSimpleButtons } from './ModalContentOneColSimpleButtons' import { TwoColumn } from './TwoColumn' import { OneColumn } from './OneColumn' +import { OneColumnOrTwoColumn } from './OneColumnOrTwoColumn' import { ModalContentMixed } from './ModalContentMixed' import { DescriptionContent } from './DescriptionContent' import { DeckMapContent } from './DeckMapContent' @@ -31,6 +34,7 @@ export { ModalContentOneColSimpleButtons, TwoColumn, OneColumn, + OneColumnOrTwoColumn, ModalContentMixed, DescriptionContent, DeckMapContent, @@ -155,12 +159,11 @@ export function InterventionModal({ {...headerStyle} backgroundColor={headerColor} justifyContent={headerJustifyContent} - onClick={iconHeadingOnClick} > {titleHeading} - + {iconName != null ? ( - + ) : null} {iconHeading != null ? iconHeading : null} @@ -171,3 +174,14 @@ export function InterventionModal({ ) } + +const ICON_STYLE = css` + width: ${SPACING.spacing16}; + height: ${SPACING.spacing16}; + margin: ${SPACING.spacing4}; + @media (${RESPONSIVENESS.touchscreenMediaQuerySpecs}) { + width: ${SPACING.spacing32}; + height: ${SPACING.spacing32}; + margin: ${SPACING.spacing12}; + } +` diff --git a/app/src/molecules/InterventionModal/story-utils/StandIn.tsx b/app/src/molecules/InterventionModal/story-utils/StandIn.tsx index 28992aba717..0fb46f44b8c 100644 --- a/app/src/molecules/InterventionModal/story-utils/StandIn.tsx +++ b/app/src/molecules/InterventionModal/story-utils/StandIn.tsx @@ -1,13 +1,19 @@ import * as React from 'react' import { Box, BORDERS } from '@opentrons/components' -export function StandInContent(): JSX.Element { +export function StandInContent({ + children, +}: { + children?: React.ReactNode +}): JSX.Element { return ( + > + {children} + ) } diff --git a/app/src/molecules/InterventionModal/story-utils/VisibleContainer.tsx b/app/src/molecules/InterventionModal/story-utils/VisibleContainer.tsx index ac80ecdb063..b716b3335ee 100644 --- a/app/src/molecules/InterventionModal/story-utils/VisibleContainer.tsx +++ b/app/src/molecules/InterventionModal/story-utils/VisibleContainer.tsx @@ -1,13 +1,15 @@ import * as React from 'react' import { Box, BORDERS, SPACING } from '@opentrons/components' +import type { StyleProps } from '@opentrons/components' -export interface VisibleContainerProps { +export interface VisibleContainerProps extends StyleProps { children: JSX.Element | JSX.Element[] } export function VisibleContainer({ children, + ...styleProps }: VisibleContainerProps): JSX.Element { return ( {children} diff --git a/app/src/organisms/ChooseProtocolSlideout/index.tsx b/app/src/organisms/ChooseProtocolSlideout/index.tsx index fb0bdcf6dde..cd82b1a48ba 100644 --- a/app/src/organisms/ChooseProtocolSlideout/index.tsx +++ b/app/src/organisms/ChooseProtocolSlideout/index.tsx @@ -446,6 +446,7 @@ export function ChooseProtocolSlideoutComponent( flexDirection={DIRECTION_COLUMN} alignItems={ALIGN_CENTER} gridgap={SPACING.spacing8} + key={runtimeParam.variableName} > - - {label} - ) : ( { diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareListItem.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareListItem.tsx index eabf0f12cea..80141c4f1e5 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareListItem.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareListItem.tsx @@ -6,6 +6,7 @@ import { ALIGN_CENTER, BORDERS, Btn, + LocationIcon, COLORS, DIRECTION_COLUMN, DIRECTION_ROW, @@ -14,10 +15,11 @@ import { Icon, JUSTIFY_CENTER, JUSTIFY_SPACE_BETWEEN, + MODULE_ICON_NAME_BY_TYPE, LabwareRender, SIZE_AUTO, SPACING, - LegacyStyledText, + StyledText, TYPOGRAPHY, WELL_LABEL_OPTIONS, } from '@opentrons/components' @@ -35,6 +37,7 @@ import { } from '@opentrons/shared-data' import { ToggleButton } from '../../../../atoms/buttons' +import { Divider } from '../../../../atoms/structure' import { SecureLabwareModal } from './SecureLabwareModal' import type { @@ -58,7 +61,10 @@ const LabwareRow = styled.div` border-width: 1px; border-color: ${COLORS.grey30}; border-radius: ${BORDERS.borderRadius4}; - padding: ${SPACING.spacing16}; + padding: ${(SPACING.spacing12, + SPACING.spacing16, + SPACING.spacing12, + SPACING.spacing24)}; ` interface LabwareListItemProps extends LabwareSetupItem { @@ -67,6 +73,7 @@ interface LabwareListItemProps extends LabwareSetupItem { isFlex: boolean commands: RunTimeCommand[] nestedLabwareInfo: NestedLabwareInfo | null + showLabwareSVG?: boolean } export function LabwareListItem( @@ -83,8 +90,9 @@ export function LabwareListItem( isFlex, commands, nestedLabwareInfo, + showLabwareSVG, } = props - const { t } = useTranslation('protocol_setup') + const { i18n, t } = useTranslation('protocol_setup') const [ secureLabwareModalType, setSecureLabwareModalType, @@ -103,10 +111,14 @@ export function LabwareListItem( 'addressableAreaName' in initialLocation ) { slotInfo = initialLocation.addressableAreaName + } else if (initialLocation === 'offDeck') { + slotInfo = i18n.format(t('off_deck'), 'upperCase') } let moduleDisplayName: string | null = null + let moduleType: ModuleType | null = null let extraAttentionText: JSX.Element | null = null + let secureLabwareInstructions: JSX.Element | null = null let isCorrectHeaterShakerAttached: boolean = false let isHeaterShakerInProtocol: boolean = false let latchCommand: @@ -144,7 +156,7 @@ export function LabwareListItem( moduleModel != null ) { const moduleName = getModuleDisplayName(moduleModel) - const moduleType = getModuleType(moduleModel) + moduleType = getModuleType(moduleModel) const moduleTypeNeedsAttention = extraAttentionModules.find( extraAttentionModType => extraAttentionModType === moduleType ) @@ -158,7 +170,7 @@ export function LabwareListItem( case MAGNETIC_MODULE_TYPE: case THERMOCYCLER_MODULE_TYPE: if (moduleModel !== THERMOCYCLER_MODULE_V2) { - extraAttentionText = ( + secureLabwareInstructions = ( - + - {t('secure_labware_instructions')} - + ) @@ -192,9 +206,9 @@ export function LabwareListItem( case HEATERSHAKER_MODULE_TYPE: isHeaterShakerInProtocol = true extraAttentionText = ( - + {t('heater_shaker_labware_list_view')} - + ) const matchingHeaterShaker = attachedModuleInfo != null && @@ -256,96 +270,128 @@ export function LabwareListItem( return ( - - - {slotInfo} - + + {slotInfo != null && isFlex ? ( + + ) : ( + + {slotInfo} + + )} + {nestedLabwareInfo != null || moduleDisplayName != null ? ( + + ) : null} - + - + {showLabwareSVG && } - + {labwareDisplayName} - - + + {nickName} - + {nestedLabwareInfo != null && nestedLabwareInfo?.sharedSlotId === slotInfo ? ( - - {nestedLabwareInfo.nestedLabwareDefinition != null ? ( - - ) : null} - - + + + - {nestedLabwareInfo.nestedLabwareDisplayName} - - - {nestedLabwareInfo.nestedLabwareNickName} - + + {nestedLabwareInfo.nestedLabwareDisplayName} + + + {nestedLabwareInfo.nestedLabwareNickName} + + - + ) : null} - - - - - {moduleDisplayName != null - ? moduleDisplayName - : t(initialLocation === 'offDeck' ? 'off_deck' : 'on_deck')} - - {extraAttentionText != null ? extraAttentionText : null} - - - {isHeaterShakerInProtocol ? ( - - - {t('labware_latch')} - + {moduleDisplayName != null ? ( + <> + - - - {hsLatchText} - + + {moduleType != null ? ( + + ) : null} + + + {moduleDisplayName} + + {extraAttentionText} + + + {secureLabwareInstructions} + {isHeaterShakerInProtocol ? ( + + + {t('labware_latch')} + + + + + {hsLatchText} + + + + ) : null} - + ) : null} {secureLabwareModalType != null && ( diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/OffDeckLabwareList.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/OffDeckLabwareList.tsx index 47c6df4bbb7..121f4588691 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/OffDeckLabwareList.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/OffDeckLabwareList.tsx @@ -35,6 +35,7 @@ export function OffDeckLabwareList( isFlex={isFlex} commands={commands} nestedLabwareInfo={null} + showLabwareSVG /> ))} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareList.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareList.tsx index ebaac5f8410..da69fb7169a 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareList.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareList.tsx @@ -5,23 +5,24 @@ import { DIRECTION_COLUMN, Flex, SPACING, - LegacyStyledText, - TYPOGRAPHY, + StyledText, + COLORS, } from '@opentrons/components' import { getLabwareSetupItemGroups } from '../../../../pages/Protocols/utils' import { LabwareListItem } from './LabwareListItem' -import { OffDeckLabwareList } from './OffDeckLabwareList' import { getNestedLabwareInfo } from './getNestedLabwareInfo' import type { RunTimeCommand } from '@opentrons/shared-data' import type { ModuleRenderInfoForProtocol } from '../../hooks' import type { ModuleTypesThatRequireExtraAttention } from '../utils/getModuleTypesThatRequireExtraAttention' +import type { LabwareSetupItem } from '../../../../pages/Protocols/utils' const HeaderRow = styled.div` display: grid; grid-template-columns: 1fr 5.2fr 5.3fr; - grid-gap: ${SPACING.spacing8}; - padding: ${SPACING.spacing8}; + grid-gap: ${SPACING.spacing16}; + padding-left: ${SPACING.spacing24}; + padding-top: ${SPACING.spacing20}; ` interface SetupLabwareListProps { attachedModuleInfo: { [moduleId: string]: ModuleRenderInfoForProtocol } @@ -35,6 +36,9 @@ export function SetupLabwareList( const { attachedModuleInfo, commands, extraAttentionModules, isFlex } = props const { t } = useTranslation('protocol_setup') const { offDeckItems, onDeckItems } = getLabwareSetupItemGroups(commands) + const allItems: LabwareSetupItem[] = [] + allItems.push.apply(allItems, onDeckItems) + allItems.push.apply(allItems, offDeckItems) return ( - + {t('location')} - - + + {t('labware_name')} - - - {t('placement')} - + - {onDeckItems.map((labwareItem, index) => { - const labwareOnAdapter = onDeckItems.find( + {allItems.map((labwareItem, index) => { + const labwareOnAdapter = allItems.find( item => labwareItem.initialLocation !== 'offDeck' && 'labwareId' in labwareItem.initialLocation && @@ -72,11 +73,6 @@ export function SetupLabwareList( /> ) })} - ) } diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/LabwareListItem.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/LabwareListItem.test.tsx index 2d4bea7e5a6..108439c1262 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/LabwareListItem.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/LabwareListItem.test.tsx @@ -172,6 +172,7 @@ describe('LabwareListItem', () => { }) screen.getByText('Mock Labware Definition') screen.getByTestId('slot_info_7') + screen.getByTestId('LocationIcon_stacked') screen.getByText('Magnetic Module GEN1') const button = screen.getByText('Secure labware instructions') fireEvent.click(button) @@ -206,6 +207,7 @@ describe('LabwareListItem', () => { }) screen.getByText('Mock Labware Definition') screen.getByTestId('slot_info_7') + screen.getByTestId('LocationIcon_stacked') screen.getByText('Temperature Module GEN1') screen.getByText('nickName') }) @@ -314,7 +316,6 @@ describe('LabwareListItem', () => { screen.getByText('mock nested display name') screen.getByText('nestedLabwareNickName') screen.getByText('nickName') - screen.getByText('On deck') }) it('renders the correct info for a labware on top of a heater shaker', () => { @@ -375,6 +376,6 @@ describe('LabwareListItem', () => { nestedLabwareInfo: null, }) screen.getByText('Mock Labware Definition') - screen.getByText('Off deck') + screen.getByTestId('slot_info_OFF DECK') }) }) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/SetupLabwareList.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/SetupLabwareList.test.tsx index f55a8fc1bfe..4228c517134 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/SetupLabwareList.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/SetupLabwareList.test.tsx @@ -1,19 +1,15 @@ import * as React from 'react' import { MemoryRouter } from 'react-router-dom' -import { describe, it, beforeEach, vi, expect } from 'vitest' +import { describe, it, beforeEach, vi } from 'vitest' import { screen } from '@testing-library/react' import { multiple_tipacks_with_tc } from '@opentrons/shared-data' import { renderWithProviders } from '../../../../../__testing-utils__' import { i18n } from '../../../../../i18n' -import { mockDefinition } from '../../../../../redux/custom-labware/__fixtures__' import { SetupLabwareList } from '../SetupLabwareList' import { LabwareListItem } from '../LabwareListItem' -import type { - CompletedProtocolAnalysis, - RunTimeCommand, -} from '@opentrons/shared-data' +import type { CompletedProtocolAnalysis } from '@opentrons/shared-data' vi.mock('../LabwareListItem') @@ -30,139 +26,6 @@ const render = (props: React.ComponentProps) => { )[0] } -const mockOffDeckCommands = ([ - { - id: '0abc1', - commandType: 'loadPipette', - params: { - pipetteId: 'pipetteId', - mount: 'left', - }, - }, - { - id: '0abc2', - commandType: 'loadLabware', - params: { - labwareId: 'fixedTrash', - location: { - slotName: '12', - }, - }, - result: { - labwareId: 'fixedTrash', - definition: { - ordering: [['A1']], - metadata: { - displayCategory: 'trash', - displayName: 'Opentrons Fixed Trash', - }, - }, - }, - }, - { - id: '0abc3', - commandType: 'loadLabware', - params: { - labwareId: 'tiprackId', - location: { - slotName: '1', - }, - }, - result: { - labwareId: 'labwareId', - definition: mockDefinition, - }, - }, - { - id: '0abc4', - commandType: 'loadLabware', - params: { - labwareId: 'sourcePlateId', - location: { - slotName: '2', - }, - }, - result: { - labwareId: 'labwareId', - definition: mockDefinition, - }, - }, - { - id: '0abc4', - commandType: 'loadLabware', - params: { - labwareId: 'destPlateId', - location: { - slotName: '3', - }, - }, - result: { - labwareId: 'labwareId', - definition: mockDefinition, - }, - }, - { - id: '0', - commandType: 'pickUpTip', - params: { - pipetteId: 'pipetteId', - labwareId: 'tiprackId', - wellName: 'B1', - }, - }, - { - id: '1', - commandType: 'aspirate', - params: { - pipetteId: 'pipetteId', - labwareId: 'sourcePlateId', - wellName: 'A1', - volume: 5, - flowRate: 3, - wellLocation: { - origin: 'bottom', - offset: { x: 0, y: 0, z: 2 }, - }, - }, - }, - { - id: '2', - commandType: 'dispense', - params: { - pipetteId: 'pipetteId', - labwareId: 'destPlateId', - wellName: 'B1', - volume: 4.5, - flowRate: 2.5, - wellLocation: { - origin: 'bottom', - offset: { x: 0, y: 0, z: 1 }, - }, - }, - }, - { - id: '3', - commandType: 'dropTip', - params: { - pipetteId: 'pipetteId', - labwareId: 'fixedTrash', - wellName: 'A1', - }, - }, - { - id: '4', - commandType: 'loadLabware', - params: { - labwareId: 'fixedTrash', - location: 'offDeck', - }, - result: { - labwareId: 'labwareId', - definition: mockDefinition, - }, - }, -] as any) as RunTimeCommand[] - describe('SetupLabwareList', () => { beforeEach(() => { vi.mocked(LabwareListItem).mockReturnValue( @@ -186,34 +49,5 @@ describe('SetupLabwareList', () => { screen.getAllByText('mock labware list item') screen.getByText('Labware name') screen.getByText('Location') - screen.getByText('Placement') - }) - it('renders null for the offdeck labware list when there are none', () => { - render({ - commands: protocolWithTC.commands, - extraAttentionModules: [], - attachedModuleInfo: { - x: 1, - y: 2, - z: 3, - attachedModuleMatch: null, - moduleId: 'moduleId', - } as any, - isFlex: false, - }) - expect( - screen.queryByText('Additional Off-Deck Labware') - ).not.toBeInTheDocument() - }) - - it('renders offdeck labware list when there are additional offdeck labwares', () => { - render({ - commands: mockOffDeckCommands, - extraAttentionModules: [], - attachedModuleInfo: {} as any, - isFlex: false, - }) - screen.getByText('Additional Off-Deck Labware') - screen.getAllByText('mock labware list item') }) }) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupStep.tsx b/app/src/organisms/Devices/ProtocolRun/SetupStep.tsx index 9289320b87d..9ddb1daf9af 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupStep.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupStep.tsx @@ -12,7 +12,7 @@ import { Icon, JUSTIFY_SPACE_BETWEEN, SPACING, - LegacyStyledText, + StyledText, TYPOGRAPHY, } from '@opentrons/components' @@ -23,8 +23,6 @@ interface SetupStepProps { title: React.ReactNode /** always shown text that provides a one sentence explanation of the contents */ description: string - /** always shown text that sits above title of step (used for step number) */ - label: string /** callback that should toggle the expanded state (managed by parent) */ toggleExpanded: () => void /** contents to be shown only when expanded */ @@ -58,7 +56,6 @@ export function SetupStep({ expanded, title, description, - label, toggleExpanded, children, rightElement, @@ -78,29 +75,21 @@ export function SetupStep({ gridGap={SPACING.spacing40} > - - {label} - - {title} - - + {description} - + {rightElement} diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/EmptySetupStep.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/EmptySetupStep.test.tsx index ffba66a5754..3c84e76468c 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/EmptySetupStep.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/EmptySetupStep.test.tsx @@ -18,7 +18,6 @@ describe('EmptySetupStep', () => { props = { title: 'mockTitle', description: 'mockDescription', - label: 'mockLabel', } }) @@ -26,6 +25,5 @@ describe('EmptySetupStep', () => { render(props) screen.getByText('mockTitle') screen.getByText('mockDescription') - screen.getByText('mockLabel') }) }) diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx index 0db69d94416..89238cbaa01 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunSetup.test.tsx @@ -279,7 +279,6 @@ describe('ProtocolRunSetup', () => { .thenReturn({ complete: false }) render() - screen.getByText('STEP 2') screen.getByText('Deck hardware') screen.getByText('Calibration needed') }) @@ -304,7 +303,6 @@ describe('ProtocolRunSetup', () => { .thenReturn({ complete: false }) render() - screen.getByText('STEP 2') screen.getByText('Deck hardware') screen.getByText('Action needed') }) @@ -338,7 +336,6 @@ describe('ProtocolRunSetup', () => { .thenReturn({ complete: false }) render() - screen.getByText('STEP 2') screen.getByText('Deck hardware') screen.getByText('Action needed') }) @@ -353,16 +350,13 @@ describe('ProtocolRunSetup', () => { it('renders correct text contents for multiple modules', () => { render() - screen.getByText('STEP 1') screen.getByText('Instruments') screen.getByText( 'Review required pipettes and tip length calibrations for this protocol.' ) - screen.getByText('STEP 2') screen.getByText('Deck hardware') screen.getByText('Install the required modules.') - screen.getByText('STEP 3') screen.getByText('Labware') screen.getByText( @@ -389,16 +383,13 @@ describe('ProtocolRunSetup', () => { ]) render() - screen.getByText('STEP 1') screen.getByText('Instruments') screen.getByText( 'Review required pipettes and tip length calibrations for this protocol.' ) - screen.getByText('STEP 2') screen.getByText('Deck hardware') screen.getByText('Install the required module.') - screen.getByText('STEP 3') screen.getByText('Labware') screen.getByText( 'Gather the following labware and full tip racks. To run your protocol without Labware Position Check, place and secure labware in their initial locations.' @@ -425,7 +416,6 @@ describe('ProtocolRunSetup', () => { ]) render() - screen.getByText('STEP 2') screen.getByText('Deck hardware') screen.getByText( 'Install and calibrate the required modules. Install the required fixtures.' diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/SetupStep.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/SetupStep.test.tsx index 9d37054705d..74b5ee7fb8e 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/SetupStep.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/SetupStep.test.tsx @@ -13,7 +13,6 @@ describe('SetupStep', () => { expanded = true, title = 'stub title', description = 'stub description', - label = 'stub label', toggleExpanded = toggleExpandedMock, children = , rightElement =
    right element
    , @@ -24,7 +23,6 @@ describe('SetupStep', () => { expanded, title, description, - label, toggleExpanded, children, rightElement, @@ -54,7 +52,6 @@ describe('SetupStep', () => { }) it('renders text nodes with prop contents', () => { render({ expanded: false }) - screen.getByText('stub label') screen.getByText('stub title') screen.queryAllByText('stub description') screen.queryAllByText('right element') diff --git a/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx b/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx index adadd2ad1ce..73b7a1a5ea1 100644 --- a/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' -import { LegacyStyledText } from '@opentrons/components' +import { StyledText } from '@opentrons/components' import { RecoveryError } from './RecoveryError' import { RecoveryDoorOpen } from './RecoveryDoorOpen' @@ -90,13 +90,20 @@ export function ErrorRecoveryComponent( const buildTitleHeading = (): JSX.Element => { const titleText = hasLaunchedRecovery ? t('recovery_mode') : t('cancel_run') - return {titleText} + return ( + + {titleText} + + ) } const buildIconHeading = (): JSX.Element => ( - + {t('view_error_details')} - + ) // TODO(jh, 07-16-24): Revisit making RecoveryDoorOpen a route. diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryDoorOpen.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryDoorOpen.tsx index 2683a8e3abe..17bf1cf0379 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryDoorOpen.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryDoorOpen.tsx @@ -16,7 +16,10 @@ import { } from '@opentrons/components' import { RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR } from '@opentrons/api-client' -import { RecoveryContentWrapper, RecoveryFooterButtons } from './shared' +import { + RecoverySingleColumnContentWrapper, + RecoveryFooterButtons, +} from './shared' import type { RecoveryContentProps } from './types' @@ -31,7 +34,7 @@ export function RecoveryDoorOpen({ const { t } = useTranslation('error_recovery') return ( - + - + ) } diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryError.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryError.tsx index 2c125f9897a..e38647927db 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryError.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryError.tsx @@ -13,7 +13,7 @@ import { } from '@opentrons/components' import { RECOVERY_MAP } from './constants' -import { RecoveryContentWrapper } from './shared' +import { RecoverySingleColumnContentWrapper } from './shared' import type { RecoveryContentProps } from './types' import { SmallButton } from '../../atoms/buttons' @@ -168,7 +168,7 @@ export function ErrorContent({ btnOnClick: () => void }): JSX.Element | null { return ( - + - + ) } diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/CancelRun.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/CancelRun.tsx index 5cf63db7c2f..154e1600cb0 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/CancelRun.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/CancelRun.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' +import { css } from 'styled-components' import { ALIGN_CENTER, @@ -8,11 +9,15 @@ import { Flex, Icon, SPACING, - LegacyStyledText, + StyledText, + RESPONSIVENESS, } from '@opentrons/components' import { RECOVERY_MAP } from '../constants' -import { RecoveryFooterButtons, RecoveryContentWrapper } from '../shared' +import { + RecoveryFooterButtons, + RecoverySingleColumnContentWrapper, +} from '../shared' import { SelectRecoveryOption } from './SelectRecoveryOption' import type { RecoveryContentProps } from '../types' @@ -56,7 +61,7 @@ function CancelRunConfirmation({ }) return ( - @@ -65,24 +70,25 @@ function CancelRunConfirmation({ alignItems={ALIGN_CENTER} gridGap={SPACING.spacing24} height="100%" - width="848px" + css={FLEX_WIDTH} > - + {t('are_you_sure_you_want_to_cancel')} - - + {t('if_tips_are_attached')} - +
    - + ) } @@ -140,3 +146,19 @@ export function useOnCancelRun({ return { showBtnLoadingState, handleCancelRunClick } } + +const FLEX_WIDTH = css` + width: 41.625rem; + @media (${RESPONSIVENESS.touchscreenMediaQuerySpecs}) { + width: 53rem; + } +` + +const ICON_SIZE = css` + width: ${SPACING.spacing40}; + height: ${SPACING.spacing40}; + @media (${RESPONSIVENESS.touchscreenMediaQuerySpecs}) { + width: ${SPACING.spacing60}; + height: ${SPACING.spacing60}; + } +` diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/FillWellAndSkip.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/FillWellAndSkip.tsx index 19d51269d53..fab5d36f8eb 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/FillWellAndSkip.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/FillWellAndSkip.tsx @@ -12,7 +12,7 @@ import { RECOVERY_MAP } from '../constants' import { CancelRun } from './CancelRun' import { RecoveryFooterButtons, - RecoveryContentWrapper, + RecoverySingleColumnContentWrapper, LeftColumnLabwareInfo, TwoColTextAndFailedStepNextStep, } from '../shared' @@ -49,7 +49,7 @@ export function FillWell(props: RecoveryContentProps): JSX.Element | null { const { goBackPrevStep, proceedNextStep } = routeUpdateActions return ( - + - + ) } @@ -86,7 +86,7 @@ export function SkipToNextStep( proceedToRouteAndStep, } = routeUpdateActions const { selectedRecoveryOption } = currentRecoveryOptionUtils - const { skipFailedCommand, resumeRun } = recoveryCommands + const { skipFailedCommand } = recoveryCommands const { ROBOT_SKIPPING_STEP, IGNORE_AND_SKIP } = RECOVERY_MAP const { t } = useTranslation('error_recovery') @@ -100,11 +100,9 @@ export function SkipToNextStep( } const primaryBtnOnClick = (): Promise => { - return setRobotInMotion(true, ROBOT_SKIPPING_STEP.ROUTE) - .then(() => skipFailedCommand()) - .then(() => { - resumeRun() - }) + return setRobotInMotion(true, ROBOT_SKIPPING_STEP.ROUTE).then(() => { + skipFailedCommand() + }) } const buildBodyText = (): JSX.Element => { diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/IgnoreErrorSkipStep.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/IgnoreErrorSkipStep.tsx index f3f255381ca..c5ecf84a61b 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/IgnoreErrorSkipStep.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/IgnoreErrorSkipStep.tsx @@ -11,7 +11,10 @@ import { import { ODD_SECTION_TITLE_STYLE, RECOVERY_MAP } from '../constants' import { SelectRecoveryOption } from './SelectRecoveryOption' -import { RecoveryFooterButtons, RecoveryContentWrapper } from '../shared' +import { + RecoveryFooterButtons, + RecoverySingleColumnContentWrapper, +} from '../shared' import { RadioButton } from '../../../atoms/buttons' import type { RecoveryContentProps } from '../types' @@ -78,7 +81,7 @@ export function IgnoreErrorStepHome({ } return ( - + {t('ignore_similar_errors_later_in_run')} @@ -93,7 +96,7 @@ export function IgnoreErrorStepHome({ primaryBtnOnClick={primaryOnClick} secondaryBtnOnClick={goBackPrevStep} /> - + ) } diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx index a5bfc5eede5..a80b777d4ba 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx @@ -1,18 +1,29 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import head from 'lodash/head' +import { css } from 'styled-components' import { DIRECTION_COLUMN, SPACING, Flex, - LegacyStyledText, + StyledText, + RESPONSIVENESS, } from '@opentrons/components' import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { RadioButton } from '../../../atoms/buttons' -import { ODD_SECTION_TITLE_STYLE, RECOVERY_MAP } from '../constants' -import { RecoveryFooterButtons, RecoveryContentWrapper } from '../shared' +import { + ODD_SECTION_TITLE_STYLE, + RECOVERY_MAP, + ODD_ONLY, + DESKTOP_ONLY, +} from '../constants' +import { + RecoveryFooterButtons, + RecoverySingleColumnContentWrapper, + RecoveryRadioGroup, +} from '../shared' import { DropTipWizardFlows } from '../../DropTipWizardFlows' import { DT_ROUTES } from '../../DropTipWizardFlows/constants' import { SelectRecoveryOption } from './SelectRecoveryOption' @@ -86,12 +97,32 @@ export function BeginRemoval({ } } + const DESKTOP_ONLY_GRID_GAP = css` + @media not (${RESPONSIVENESS.touchscreenMediaQuerySpecs}) { + gap: 0rem; + } + ` + + const RADIO_GROUP_MARGIN = css` + @media not (${RESPONSIVENESS.touchscreenMediaQuerySpecs}) { + margin-left: 0.5rem; + } + ` + return ( - - + + {t('you_may_want_to_remove', { mount })} - - + + + + ) => { + setSelected(e.currentTarget.value as RemovalOptions) + }} + options={[ + { + value: t('begin_removal'), + children: ( + + {t('begin_removal')} + + ), + }, + { + value: t('skip'), + children: ( + + {t('skip')} + + ), + }, + ]} + /> + - + ) } @@ -158,7 +228,7 @@ function DropTipFlowsContainer( const fixitCommandTypeUtils = useDropTipFlowUtils(props) return ( - + - + ) } diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx index 17ccd2e853d..a33f2fb7abc 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx @@ -6,16 +6,22 @@ import { DIRECTION_COLUMN, Flex, SPACING, - LegacyStyledText, + StyledText, } from '@opentrons/components' import { RECOVERY_MAP, ERROR_KINDS, ODD_SECTION_TITLE_STYLE, + ODD_ONLY, + DESKTOP_ONLY, } from '../constants' import { RadioButton } from '../../../atoms/buttons' -import { RecoveryFooterButtons, RecoveryContentWrapper } from '../shared' +import { + RecoveryODDOneDesktopTwoColumnContentWrapper, + RecoveryRadioGroup, + FailedStepNextStep, +} from '../shared' import type { ErrorKind, RecoveryContentProps, RecoveryRoute } from '../types' import type { PipetteWithTip } from '../../DropTipWizardFlows' @@ -45,6 +51,7 @@ export function SelectRecoveryOptionHome({ tipStatusUtils, currentRecoveryOptionUtils, getRecoveryOptionCopy, + ...rest }: RecoveryContentProps): JSX.Element | null { const { t } = useTranslation('error_recovery') const { proceedToRouteAndStep } = routeUpdateActions @@ -58,25 +65,41 @@ export function SelectRecoveryOptionHome({ useCurrentTipStatus(determineTipStatus) return ( - - - {t('choose_a_recovery_action')} - - - - - { + { setSelectedRecoveryOption(selectedRoute) void proceedToRouteAndStep(selectedRoute as RecoveryRoute) - }} - /> - + }, + }} + > + + + {t('choose_a_recovery_action')} + + + + + + + + + + ) } @@ -87,29 +110,66 @@ interface RecoveryOptionsProps { selectedRoute?: RecoveryRoute } // For ODD use only. -export function RecoveryOptions({ +export function ODDRecoveryOptions({ validRecoveryOptions, selectedRoute, setSelectedRoute, getRecoveryOptionCopy, -}: RecoveryOptionsProps): JSX.Element[] { - return validRecoveryOptions.map((recoveryOption: RecoveryRoute) => { - const optionName = getRecoveryOptionCopy(recoveryOption) - - return ( - { - setSelectedRoute(recoveryOption) - }} - isSelected={recoveryOption === selectedRoute} - /> - ) - }) +}: RecoveryOptionsProps): JSX.Element { + return ( + + {validRecoveryOptions.map((recoveryOption: RecoveryRoute) => { + const optionName = getRecoveryOptionCopy(recoveryOption) + return ( + { + setSelectedRoute(recoveryOption) + }} + isSelected={recoveryOption === selectedRoute} + /> + ) + })} + + ) } +export function DesktopRecoveryOptions({ + validRecoveryOptions, + selectedRoute, + setSelectedRoute, + getRecoveryOptionCopy, +}: RecoveryOptionsProps): JSX.Element { + return ( + { + setSelectedRoute(e.currentTarget.value) + }} + value={selectedRoute} + options={validRecoveryOptions.map( + (option: RecoveryRoute) => + ({ + value: option, + children: ( + + {getRecoveryOptionCopy(option)} + + ), + } as const) + )} + /> + ) +} // Pre-fetch tip attachment status. Users are not blocked from proceeding at this step. export function useCurrentTipStatus( determineTipStatus: () => Promise diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SkipStepNewTips.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SkipStepNewTips.tsx index d8c837b33bc..33c0f199cd8 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SkipStepNewTips.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SkipStepNewTips.tsx @@ -48,17 +48,15 @@ export function SkipStepNewTips( export function SkipStepWithNewTips(props: RecoveryContentProps): JSX.Element { const { recoveryCommands, routeUpdateActions } = props - const { skipFailedCommand, resumeRun } = recoveryCommands + const { skipFailedCommand } = recoveryCommands const { setRobotInMotion } = routeUpdateActions const { ROBOT_SKIPPING_STEP } = RECOVERY_MAP const { t } = useTranslation('error_recovery') const primaryBtnOnClick = (): Promise => { - return setRobotInMotion(true, ROBOT_SKIPPING_STEP.ROUTE) - .then(() => skipFailedCommand()) - .then(() => { - resumeRun() - }) + return setRobotInMotion(true, ROBOT_SKIPPING_STEP.ROUTE).then(() => { + skipFailedCommand() + }) } const buildBodyText = (): JSX.Element => { diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SkipStepSameTips.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SkipStepSameTips.tsx index 9a679ba8d17..aed84372ccf 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SkipStepSameTips.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SkipStepSameTips.tsx @@ -29,17 +29,15 @@ export function SkipStepSameTips(props: RecoveryContentProps): JSX.Element { export function SkipStepSameTipsInfo(props: RecoveryContentProps): JSX.Element { const { routeUpdateActions, recoveryCommands } = props - const { skipFailedCommand, resumeRun } = recoveryCommands + const { skipFailedCommand } = recoveryCommands const { setRobotInMotion } = routeUpdateActions const { ROBOT_SKIPPING_STEP } = RECOVERY_MAP const { t } = useTranslation('error_recovery') const primaryBtnOnClick = (): Promise => { - return setRobotInMotion(true, ROBOT_SKIPPING_STEP.ROUTE) - .then(() => skipFailedCommand()) - .then(() => { - resumeRun() - }) + return setRobotInMotion(true, ROBOT_SKIPPING_STEP.ROUTE).then(() => { + skipFailedCommand() + }) } const buildBodyText = (): JSX.Element => { diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/FillWellAndSkip.test.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/FillWellAndSkip.test.tsx index 541081f889a..123bbb33626 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/FillWellAndSkip.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/FillWellAndSkip.test.tsx @@ -141,14 +141,12 @@ describe('SkipToNextStep', () => { let mockGoBackPrevStep: Mock let mockProceedToRouteAndStep: Mock let mockSkipFailedCommand: Mock - let mockResumeRun: Mock beforeEach(() => { mockSetRobotInMotion = vi.fn(() => Promise.resolve()) mockGoBackPrevStep = vi.fn() mockProceedToRouteAndStep = vi.fn() mockSkipFailedCommand = vi.fn(() => Promise.resolve()) - mockResumeRun = vi.fn() props = { ...mockRecoveryContentProps, @@ -159,7 +157,6 @@ describe('SkipToNextStep', () => { } as any, recoveryCommands: { skipFailedCommand: mockSkipFailedCommand, - resumeRun: mockResumeRun, } as any, } }) @@ -197,15 +194,9 @@ describe('SkipToNextStep', () => { await waitFor(() => { expect(mockSkipFailedCommand).toHaveBeenCalled() }) - await waitFor(() => { - expect(mockResumeRun).toHaveBeenCalled() - }) expect(mockSetRobotInMotion.mock.invocationCallOrder[0]).toBeLessThan( mockSkipFailedCommand.mock.invocationCallOrder[0] ) - expect(mockSkipFailedCommand.mock.invocationCallOrder[0]).toBeLessThan( - mockResumeRun.mock.invocationCallOrder[0] - ) }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/IgnoreErrorSkipStep.test.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/IgnoreErrorSkipStep.test.tsx index b0dd4ec9f1d..d6241b7dcd9 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/IgnoreErrorSkipStep.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/IgnoreErrorSkipStep.test.tsx @@ -20,7 +20,9 @@ vi.mock('../shared', async () => { const actual = await vi.importActual('../shared') return { ...actual, - RecoveryContentWrapper: vi.fn(({ children }) =>
    {children}
    ), + RecoverySingleColumnContentWrapper: vi.fn(({ children }) => ( +
    {children}
    + )), } }) vi.mock('../SelectRecoveryOption') diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManageTips.test.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManageTips.test.tsx index d9fbd66af49..8e9327dd45f 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManageTips.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManageTips.test.tsx @@ -93,16 +93,16 @@ describe('ManageTips', () => { screen.getByText( 'You may want to remove the tips from the left pipette before using it again in a protocol' ) - screen.getByText('Begin removal') - screen.getByText('Skip') + screen.queryAllByText('Begin removal') + screen.queryAllByText('Skip') expect(screen.getAllByText('Continue').length).toBe(2) }) it('routes correctly when continuing on BeginRemoval', () => { render(props) - const beginRemovalBtn = screen.getByText('Begin removal') - const skipBtn = screen.getByText('Skip') + const beginRemovalBtn = screen.queryAllByText('Begin removal')[0] + const skipBtn = screen.queryAllByText('Skip')[0] fireEvent.click(beginRemovalBtn) clickButtonLabeled('Continue') @@ -124,7 +124,7 @@ describe('ManageTips', () => { } render(props) - const skipBtn = screen.getByText('Skip') + const skipBtn = screen.queryAllByText('Skip')[0] fireEvent.click(skipBtn) clickButtonLabeled('Continue') diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SelectRecoveryOptions.test.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SelectRecoveryOptions.test.tsx index 0db6521b2fc..a70e66d662e 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SelectRecoveryOptions.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SelectRecoveryOptions.test.tsx @@ -8,7 +8,8 @@ import { i18n } from '../../../../i18n' import { mockRecoveryContentProps } from '../../__fixtures__' import { SelectRecoveryOption, - RecoveryOptions, + ODDRecoveryOptions, + DesktopRecoveryOptions, getRecoveryOptions, GENERAL_ERROR_OPTIONS, OVERPRESSURE_WHILE_ASPIRATING_OPTIONS, @@ -24,15 +25,25 @@ import type { Mock } from 'vitest' const renderSelectRecoveryOption = ( props: React.ComponentProps ) => { - return renderWithProviders(, { + return renderWithProviders( + , + { + i18nInstance: i18n, + } + )[0] +} + +const renderODDRecoveryOptions = ( + props: React.ComponentProps +) => { + return renderWithProviders(, { i18nInstance: i18n, })[0] } - -const renderRecoveryOptions = ( - props: React.ComponentProps +const renderDesktopRecoveryOptions = ( + props: React.ComponentProps ) => { - return renderWithProviders(, { + return renderWithProviders(, { i18nInstance: i18n, })[0] } @@ -101,13 +112,13 @@ describe('SelectRecoveryOption', () => { screen.getByText('Choose a recovery action') - const retryStepOption = screen.getByRole('label', { name: 'Retry step' }) + const retryStepOption = screen.getAllByRole('label', { name: 'Retry step' }) clickButtonLabeled('Continue') expect( screen.queryByRole('button', { name: 'Go back' }) ).not.toBeInTheDocument() - fireEvent.click(retryStepOption) + fireEvent.click(retryStepOption[0]) clickButtonLabeled('Continue') expect(mockProceedToRouteAndStep).toHaveBeenCalledWith( @@ -125,14 +136,14 @@ describe('SelectRecoveryOption', () => { screen.getByText('Choose a recovery action') - const retryNewTips = screen.getByRole('label', { + const retryNewTips = screen.getAllByRole('label', { name: 'Retry with new tips', }) expect( screen.queryByRole('button', { name: 'Go back' }) ).not.toBeInTheDocument() - fireEvent.click(retryNewTips) + fireEvent.click(retryNewTips[0]) clickButtonLabeled('Continue') expect(mockProceedToRouteAndStep).toHaveBeenCalledWith(RETRY_NEW_TIPS.ROUTE) @@ -148,11 +159,11 @@ describe('SelectRecoveryOption', () => { screen.getByText('Choose a recovery action') - const fillManuallyAndSkip = screen.getByRole('label', { + const fillManuallyAndSkip = screen.getAllByRole('label', { name: 'Manually fill well and skip to next step', }) - fireEvent.click(fillManuallyAndSkip) + fireEvent.click(fillManuallyAndSkip[0]) clickButtonLabeled('Continue') expect(mockProceedToRouteAndStep).toHaveBeenCalledWith( @@ -170,11 +181,11 @@ describe('SelectRecoveryOption', () => { screen.getByText('Choose a recovery action') - const retrySameTips = screen.getByRole('label', { + const retrySameTips = screen.getAllByRole('label', { name: 'Retry with same tips', }) - fireEvent.click(retrySameTips) + fireEvent.click(retrySameTips[0]) clickButtonLabeled('Continue') expect(mockProceedToRouteAndStep).toHaveBeenCalledWith( @@ -192,11 +203,11 @@ describe('SelectRecoveryOption', () => { screen.getByText('Choose a recovery action') - const skipStepWithSameTips = screen.getByRole('label', { + const skipStepWithSameTips = screen.getAllByRole('label', { name: 'Skip to next step with same tips', }) - fireEvent.click(skipStepWithSameTips) + fireEvent.click(skipStepWithSameTips[0]) clickButtonLabeled('Continue') expect(mockProceedToRouteAndStep).toHaveBeenCalledWith( @@ -204,117 +215,123 @@ describe('SelectRecoveryOption', () => { ) }) }) +;([ + ['desktop', renderDesktopRecoveryOptions] as const, + ['odd', renderODDRecoveryOptions] as const, +] as const).forEach(([target, renderer]) => { + describe(`RecoveryOptions on ${target}`, () => { + let props: React.ComponentProps + let mockSetSelectedRoute: Mock + let mockGetRecoveryOptionCopy: Mock + + beforeEach(() => { + mockSetSelectedRoute = vi.fn() + mockGetRecoveryOptionCopy = vi.fn() + const generalRecoveryOptions = getRecoveryOptions( + ERROR_KINDS.GENERAL_ERROR + ) + + props = { + validRecoveryOptions: generalRecoveryOptions, + setSelectedRoute: mockSetSelectedRoute, + getRecoveryOptionCopy: mockGetRecoveryOptionCopy, + } + + when(mockGetRecoveryOptionCopy) + .calledWith(RECOVERY_MAP.RETRY_FAILED_COMMAND.ROUTE) + .thenReturn('Retry step') + when(mockGetRecoveryOptionCopy) + .calledWith(RECOVERY_MAP.CANCEL_RUN.ROUTE) + .thenReturn('Cancel run') + when(mockGetRecoveryOptionCopy) + .calledWith(RECOVERY_MAP.RETRY_NEW_TIPS.ROUTE) + .thenReturn('Retry with new tips') + when(mockGetRecoveryOptionCopy) + .calledWith(RECOVERY_MAP.FILL_MANUALLY_AND_SKIP.ROUTE) + .thenReturn('Manually fill well and skip to next step') + when(mockGetRecoveryOptionCopy) + .calledWith(RECOVERY_MAP.RETRY_SAME_TIPS.ROUTE) + .thenReturn('Retry with same tips') + when(mockGetRecoveryOptionCopy) + .calledWith(RECOVERY_MAP.SKIP_STEP_WITH_SAME_TIPS.ROUTE) + .thenReturn('Skip to next step with same tips') + when(mockGetRecoveryOptionCopy) + .calledWith(RECOVERY_MAP.SKIP_STEP_WITH_NEW_TIPS.ROUTE) + .thenReturn('Skip to next step with new tips') + when(mockGetRecoveryOptionCopy) + .calledWith(RECOVERY_MAP.IGNORE_AND_SKIP.ROUTE) + .thenReturn('Ignore error and skip to next step') + }) -describe('RecoveryOptions', () => { - let props: React.ComponentProps - let mockSetSelectedRoute: Mock - let mockGetRecoveryOptionCopy: Mock - - beforeEach(() => { - mockSetSelectedRoute = vi.fn() - mockGetRecoveryOptionCopy = vi.fn() - const generalRecoveryOptions = getRecoveryOptions(ERROR_KINDS.GENERAL_ERROR) - - props = { - validRecoveryOptions: generalRecoveryOptions, - setSelectedRoute: mockSetSelectedRoute, - getRecoveryOptionCopy: mockGetRecoveryOptionCopy, - } - - when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.RETRY_FAILED_COMMAND.ROUTE) - .thenReturn('Retry step') - when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.CANCEL_RUN.ROUTE) - .thenReturn('Cancel run') - when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.RETRY_NEW_TIPS.ROUTE) - .thenReturn('Retry with new tips') - when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.FILL_MANUALLY_AND_SKIP.ROUTE) - .thenReturn('Manually fill well and skip to next step') - when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.RETRY_SAME_TIPS.ROUTE) - .thenReturn('Retry with same tips') - when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.SKIP_STEP_WITH_SAME_TIPS.ROUTE) - .thenReturn('Skip to next step with same tips') - when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.SKIP_STEP_WITH_NEW_TIPS.ROUTE) - .thenReturn('Skip to next step with new tips') - when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.IGNORE_AND_SKIP.ROUTE) - .thenReturn('Ignore error and skip to next step') - }) - - it('renders valid recovery options for a general error errorKind', () => { - renderRecoveryOptions(props) + it('renders valid recovery options for a general error errorKind', () => { + renderer(props) - screen.getByRole('label', { name: 'Retry step' }) - screen.getByRole('label', { name: 'Cancel run' }) - }) + screen.getByRole('label', { name: 'Retry step' }) + screen.getByRole('label', { name: 'Cancel run' }) + }) - it(`renders valid recovery options for a ${ERROR_KINDS.OVERPRESSURE_WHILE_ASPIRATING} errorKind`, () => { - props = { - ...props, - validRecoveryOptions: OVERPRESSURE_WHILE_ASPIRATING_OPTIONS, - } + it(`renders valid recovery options for a ${ERROR_KINDS.OVERPRESSURE_WHILE_ASPIRATING} errorKind`, () => { + props = { + ...props, + validRecoveryOptions: OVERPRESSURE_WHILE_ASPIRATING_OPTIONS, + } - renderRecoveryOptions(props) + renderer(props) - screen.getByRole('label', { name: 'Retry with new tips' }) - screen.getByRole('label', { name: 'Cancel run' }) - }) + screen.getByRole('label', { name: 'Retry with new tips' }) + screen.getByRole('label', { name: 'Cancel run' }) + }) - it('updates the selectedRoute when a new option is selected', () => { - renderRecoveryOptions(props) + it('updates the selectedRoute when a new option is selected', () => { + renderer(props) - fireEvent.click(screen.getByRole('label', { name: 'Cancel run' })) + fireEvent.click(screen.getByRole('label', { name: 'Cancel run' })) - expect(mockSetSelectedRoute).toHaveBeenCalledWith( - RECOVERY_MAP.CANCEL_RUN.ROUTE - ) - }) + expect(mockSetSelectedRoute).toHaveBeenCalledWith( + RECOVERY_MAP.CANCEL_RUN.ROUTE + ) + }) - it(`renders valid recovery options for a ${ERROR_KINDS.NO_LIQUID_DETECTED} errorKind`, () => { - props = { - ...props, - validRecoveryOptions: NO_LIQUID_DETECTED_OPTIONS, - } + it(`renders valid recovery options for a ${ERROR_KINDS.NO_LIQUID_DETECTED} errorKind`, () => { + props = { + ...props, + validRecoveryOptions: NO_LIQUID_DETECTED_OPTIONS, + } - renderRecoveryOptions(props) + renderer(props) - screen.getByRole('label', { - name: 'Manually fill well and skip to next step', + screen.getByRole('label', { + name: 'Manually fill well and skip to next step', + }) + screen.getByRole('label', { name: 'Ignore error and skip to next step' }) + screen.getByRole('label', { name: 'Cancel run' }) }) - screen.getByRole('label', { name: 'Ignore error and skip to next step' }) - screen.getByRole('label', { name: 'Cancel run' }) - }) - it(`renders valid recovery options for a ${ERROR_KINDS.OVERPRESSURE_PREPARE_TO_ASPIRATE} errorKind`, () => { - props = { - ...props, - validRecoveryOptions: OVERPRESSURE_PREPARE_TO_ASPIRATE, - } + it(`renders valid recovery options for a ${ERROR_KINDS.OVERPRESSURE_PREPARE_TO_ASPIRATE} errorKind`, () => { + props = { + ...props, + validRecoveryOptions: OVERPRESSURE_PREPARE_TO_ASPIRATE, + } - renderRecoveryOptions(props) + renderer(props) - screen.getByRole('label', { name: 'Retry with new tips' }) - screen.getByRole('label', { name: 'Retry with same tips' }) - screen.getByRole('label', { name: 'Cancel run' }) - }) + screen.getByRole('label', { name: 'Retry with new tips' }) + screen.getByRole('label', { name: 'Retry with same tips' }) + screen.getByRole('label', { name: 'Cancel run' }) + }) - it(`renders valid recovery options for a ${ERROR_KINDS.OVERPRESSURE_WHILE_DISPENSING} errorKind`, () => { - props = { - ...props, - validRecoveryOptions: OVERPRESSURE_WHILE_DISPENSING_OPTIONS, - } + it(`renders valid recovery options for a ${ERROR_KINDS.OVERPRESSURE_WHILE_DISPENSING} errorKind`, () => { + props = { + ...props, + validRecoveryOptions: OVERPRESSURE_WHILE_DISPENSING_OPTIONS, + } - renderRecoveryOptions(props) + renderer(props) - screen.getByRole('label', { name: 'Skip to next step with same tips' }) - screen.getByRole('label', { name: 'Skip to next step with new tips' }) - screen.getByRole('label', { name: 'Cancel run' }) + screen.getByRole('label', { name: 'Skip to next step with same tips' }) + screen.getByRole('label', { name: 'Skip to next step with new tips' }) + screen.getByRole('label', { name: 'Cancel run' }) + }) }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SkipStepNewTips.test.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SkipStepNewTips.test.tsx index c7c9b6d27f2..afb8dfdee48 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SkipStepNewTips.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SkipStepNewTips.test.tsx @@ -124,12 +124,10 @@ describe('SkipStepWithNewTips', () => { let props: React.ComponentProps let mockSetRobotInMotion: Mock let mockSkipFailedCommand: Mock - let mockResumeRun: Mock beforeEach(() => { mockSetRobotInMotion = vi.fn(() => Promise.resolve()) mockSkipFailedCommand = vi.fn(() => Promise.resolve()) - mockResumeRun = vi.fn() props = { ...mockRecoveryContentProps, @@ -138,7 +136,6 @@ describe('SkipStepWithNewTips', () => { } as any, recoveryCommands: { skipFailedCommand: mockSkipFailedCommand, - resumeRun: mockResumeRun, } as any, } }) @@ -169,15 +166,9 @@ describe('SkipStepWithNewTips', () => { await waitFor(() => { expect(mockSkipFailedCommand).toHaveBeenCalled() }) - await waitFor(() => { - expect(mockResumeRun).toHaveBeenCalled() - }) expect(mockSetRobotInMotion.mock.invocationCallOrder[0]).toBeLessThan( mockSkipFailedCommand.mock.invocationCallOrder[0] ) - expect(mockSkipFailedCommand.mock.invocationCallOrder[0]).toBeLessThan( - mockResumeRun.mock.invocationCallOrder[0] - ) }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SkipStepSameTips.test.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SkipStepSameTips.test.tsx index 1b6cd3c275e..3459ce305dd 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SkipStepSameTips.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SkipStepSameTips.test.tsx @@ -72,12 +72,10 @@ describe('SkipStepSameTipsInfo', () => { let props: React.ComponentProps let mockSetRobotInMotion: Mock let mockSkipFailedCommand: Mock - let mockResumeRun: Mock beforeEach(() => { mockSetRobotInMotion = vi.fn(() => Promise.resolve()) mockSkipFailedCommand = vi.fn(() => Promise.resolve()) - mockResumeRun = vi.fn() props = { ...mockRecoveryContentProps, @@ -86,7 +84,6 @@ describe('SkipStepSameTipsInfo', () => { } as any, recoveryCommands: { skipFailedCommand: mockSkipFailedCommand, - resumeRun: mockResumeRun, } as any, } }) @@ -114,18 +111,13 @@ describe('SkipStepSameTipsInfo', () => { RECOVERY_MAP.ROBOT_SKIPPING_STEP.ROUTE ) }) + await waitFor(() => { expect(mockSkipFailedCommand).toHaveBeenCalled() }) - await waitFor(() => { - expect(mockResumeRun).toHaveBeenCalled() - }) expect(mockSetRobotInMotion.mock.invocationCallOrder[0]).toBeLessThan( mockSkipFailedCommand.mock.invocationCallOrder[0] ) - expect(mockSkipFailedCommand.mock.invocationCallOrder[0]).toBeLessThan( - mockResumeRun.mock.invocationCallOrder[0] - ) }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/RunPausedSplash.tsx b/app/src/organisms/ErrorRecoveryFlows/RunPausedSplash.tsx index 23633a8b20b..f9d253719ed 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RunPausedSplash.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RunPausedSplash.tsx @@ -30,7 +30,7 @@ import { LargeButton } from '../../atoms/buttons' import { RECOVERY_MAP } from './constants' import { RecoveryInterventionModal, - RecoveryContentWrapper, + RecoverySingleColumnContentWrapper, StepInfo, } from './shared' @@ -151,13 +151,12 @@ export function RunPausedSplash( titleHeading={buildTitleHeadingDesktop()} isOnDevice={isOnDevice} > - + - + ) } diff --git a/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts b/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts index 51639bb3981..7666d7beebf 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts +++ b/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts @@ -73,7 +73,7 @@ export const mockRecoveryContentProps: RecoveryContentProps = { failedPipetteInfo: {} as any, deckMapUtils: { setSelectedLocation: () => {} } as any, stepCounts: {} as any, - protocolAnalysis: { commands: [mockFailedCommand] } as any, + protocolAnalysis: mockRobotSideAnalysis, trackExternalMap: () => null, hasLaunchedRecovery: true, getRecoveryOptionCopy: () => 'MOCK_COPY', diff --git a/app/src/organisms/ErrorRecoveryFlows/constants.ts b/app/src/organisms/ErrorRecoveryFlows/constants.ts index 7e9a9ab2a9a..846f7e2efc0 100644 --- a/app/src/organisms/ErrorRecoveryFlows/constants.ts +++ b/app/src/organisms/ErrorRecoveryFlows/constants.ts @@ -1,6 +1,6 @@ import { css } from 'styled-components' -import { SPACING, TYPOGRAPHY } from '@opentrons/components' +import { SPACING, TYPOGRAPHY, RESPONSIVENESS } from '@opentrons/components' import type { StepOrder } from './types' @@ -211,3 +211,14 @@ export const BODY_TEXT_STYLE = css` export const ODD_SECTION_TITLE_STYLE = css` margin-bottom: ${SPACING.spacing16}; ` + +export const ODD_ONLY = css` + @media not (${RESPONSIVENESS.touchscreenMediaQuerySpecs}) { + display: none; + } +` +export const DESKTOP_ONLY = css` + @media (${RESPONSIVENESS.touchscreenMediaQuerySpecs}) { + display: none; + } +` diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts index c6a0764e796..d75387a99d4 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts @@ -33,13 +33,16 @@ const mockRouteUpdateActions = { } as any describe('useRecoveryCommands', () => { - const mockResumeRunFromRecovery = vi.fn() + const mockMakeSuccessToast = vi.fn() + const mockResumeRunFromRecovery = vi.fn(() => + Promise.resolve(mockMakeSuccessToast()) + ) const mockStopRun = vi.fn() const mockChainRunCommands = vi.fn().mockResolvedValue([]) beforeEach(() => { vi.mocked(useResumeRunFromRecoveryMutation).mockReturnValue({ - resumeRunFromRecovery: mockResumeRunFromRecovery, + mutateAsync: mockResumeRunFromRecovery, } as any) vi.mocked(useStopRunMutation).mockReturnValue({ stopRun: mockStopRun, @@ -56,6 +59,7 @@ describe('useRecoveryCommands', () => { failedCommand: mockFailedCommand, failedLabwareUtils: mockFailedLabwareUtils, routeUpdateActions: mockRouteUpdateActions, + recoveryToastUtils: {} as any, }) ) @@ -81,6 +85,7 @@ describe('useRecoveryCommands', () => { failedCommand: mockFailedCommand, failedLabwareUtils: mockFailedLabwareUtils, routeUpdateActions: mockRouteUpdateActions, + recoveryToastUtils: {} as any, }) ) @@ -107,6 +112,7 @@ describe('useRecoveryCommands', () => { failedCommand: mockFailedCommand, failedLabwareUtils: mockFailedLabwareUtils, routeUpdateActions: mockRouteUpdateActions, + recoveryToastUtils: {} as any, }) ) @@ -120,19 +126,23 @@ describe('useRecoveryCommands', () => { ) }) - it('should call resumeRun with runId', () => { + it('should call resumeRun with runId and show success toast on success', async () => { const { result } = renderHook(() => useRecoveryCommands({ runId: mockRunId, failedCommand: mockFailedCommand, failedLabwareUtils: mockFailedLabwareUtils, routeUpdateActions: mockRouteUpdateActions, + recoveryToastUtils: { makeSuccessToast: mockMakeSuccessToast } as any, }) ) - result.current.resumeRun() + await act(async () => { + await result.current.resumeRun() + }) expect(mockResumeRunFromRecovery).toHaveBeenCalledWith(mockRunId) + expect(mockMakeSuccessToast).toHaveBeenCalled() }) it('should call cancelRun with runId', () => { @@ -142,6 +152,7 @@ describe('useRecoveryCommands', () => { failedCommand: mockFailedCommand, failedLabwareUtils: mockFailedLabwareUtils, routeUpdateActions: mockRouteUpdateActions, + recoveryToastUtils: {} as any, }) ) @@ -157,6 +168,7 @@ describe('useRecoveryCommands', () => { failedCommand: mockFailedCommand, failedLabwareUtils: mockFailedLabwareUtils, routeUpdateActions: mockRouteUpdateActions, + recoveryToastUtils: {} as any, }) ) @@ -195,6 +207,7 @@ describe('useRecoveryCommands', () => { failedLabware: mockFailedLabware, }, routeUpdateActions: mockRouteUpdateActions, + recoveryToastUtils: {} as any, }) ) @@ -208,24 +221,23 @@ describe('useRecoveryCommands', () => { ) }) - it('should call skipFailedCommand and resolve after a timeout', async () => { + it('should call skipFailedCommand and show success toast on success', async () => { const { result } = renderHook(() => useRecoveryCommands({ runId: mockRunId, failedCommand: mockFailedCommand, failedLabwareUtils: mockFailedLabwareUtils, routeUpdateActions: mockRouteUpdateActions, + recoveryToastUtils: { makeSuccessToast: mockMakeSuccessToast } as any, }) ) - const consoleSpy = vi.spyOn(console, 'log') - await act(async () => { await result.current.skipFailedCommand() }) - expect(consoleSpy).toHaveBeenCalledWith('SKIPPING TO NEXT STEP') - expect(result.current.skipFailedCommand()).resolves.toBeUndefined() + expect(mockResumeRunFromRecovery).toHaveBeenCalledWith(mockRunId) + expect(mockMakeSuccessToast).toHaveBeenCalled() }) it('should call ignoreErrorKindThisRun and resolve immediately', async () => { @@ -235,6 +247,7 @@ describe('useRecoveryCommands', () => { failedCommand: mockFailedCommand, failedLabwareUtils: mockFailedLabwareUtils, routeUpdateActions: mockRouteUpdateActions, + recoveryToastUtils: {} as any, }) ) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryToasts.test.tsx b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryToasts.test.tsx index 9514931cf54..8766fc83590 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryToasts.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryToasts.test.tsx @@ -3,34 +3,54 @@ import { vi, describe, it, expect, beforeEach } from 'vitest' import { I18nextProvider } from 'react-i18next' import { i18n } from '../../../../i18n' import { renderHook, render, screen } from '@testing-library/react' + +import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' + import { useRecoveryToasts, - useToastText, + useRecoveryToastText, getStepNumber, + useRecoveryFullCommandText, } from '../useRecoveryToasts' import { RECOVERY_MAP } from '../../constants' import { useToaster } from '../../../ToasterOven' +import { useCommandTextString } from '../../../../molecules/Command' import type { Mock } from 'vitest' +import type { BuildToast } from '../useRecoveryToasts' vi.mock('../../../ToasterOven') +vi.mock('../../../../molecules/Command') + +const TEST_COMMAND = 'test command' +const TC_COMMAND = 'tc command cycle some more text' let mockMakeToast: Mock +const DEFAULT_PROPS: BuildToast = { + isOnDevice: false, + currentStepCount: 1, + selectedRecoveryOption: RECOVERY_MAP.RETRY_SAME_TIPS.ROUTE, + commandTextData: { commands: [] } as any, + robotType: FLEX_ROBOT_TYPE, +} + +// Utility function for rendering with I18nextProvider +const renderWithI18n = (component: React.ReactElement) => { + return render({component}) +} + describe('useRecoveryToasts', () => { beforeEach(() => { mockMakeToast = vi.fn() vi.mocked(useToaster).mockReturnValue({ makeToast: mockMakeToast } as any) + vi.mocked(useCommandTextString).mockReturnValue({ + commandText: TEST_COMMAND, + }) }) it('should return makeSuccessToast function', () => { - const { result } = renderHook(() => - useRecoveryToasts({ - isOnDevice: false, - currentStepCount: 1, - selectedRecoveryOption: RECOVERY_MAP.RETRY_SAME_TIPS.ROUTE, - }) - ) + const { result } = renderHook(() => useRecoveryToasts(DEFAULT_PROPS)) expect(result.current.makeSuccessToast).toBeInstanceOf(Function) }) @@ -38,67 +58,90 @@ describe('useRecoveryToasts', () => { it(`should not make toast for ${RECOVERY_MAP.CANCEL_RUN.ROUTE} option`, () => { const { result } = renderHook(() => useRecoveryToasts({ - isOnDevice: false, - currentStepCount: 1, + ...DEFAULT_PROPS, selectedRecoveryOption: RECOVERY_MAP.CANCEL_RUN.ROUTE, }) ) - const mockMakeToast = vi.fn() - vi.mocked(useToaster).mockReturnValue({ makeToast: mockMakeToast } as any) - result.current.makeSuccessToast() expect(mockMakeToast).not.toHaveBeenCalled() }) -}) -describe('useToastText', () => { - it(`should return correct text for ${RECOVERY_MAP.RETRY_SAME_TIPS.ROUTE} option`, () => { + it('should make toast with correct parameters for desktop', () => { + vi.mocked(useCommandTextString).mockReturnValue({ + commandText: TEST_COMMAND, + }) + const { result } = renderHook(() => - useToastText({ - currentStepCount: 2, - selectedRecoveryOption: RECOVERY_MAP.RETRY_SAME_TIPS.ROUTE, + useRecoveryToasts({ + ...DEFAULT_PROPS, + commandTextData: { commands: [TEST_COMMAND] } as any, }) ) - render( - -
    {result.current}
    -
    + vi.mocked(useCommandTextString).mockReturnValue({ + commandText: TEST_COMMAND, + stepTexts: undefined, + }) + + result.current.makeSuccessToast() + expect(mockMakeToast).toHaveBeenCalledWith( + TEST_COMMAND, + 'success', + expect.objectContaining({ + closeButton: true, + disableTimeout: true, + displayType: 'desktop', + heading: expect.any(String), + }) ) - screen.getByText('Retrying step 2 succeeded') }) - it(`should return correct text for ${RECOVERY_MAP.SKIP_STEP_WITH_SAME_TIPS.ROUTE} option`, () => { + it('should make toast with correct parameters for ODD', () => { const { result } = renderHook(() => - useToastText({ - currentStepCount: 2, - selectedRecoveryOption: RECOVERY_MAP.SKIP_STEP_WITH_SAME_TIPS.ROUTE, + useRecoveryToasts({ + ...DEFAULT_PROPS, + isOnDevice: true, }) ) - render( - -
    {result.current}
    -
    + result.current.makeSuccessToast() + expect(mockMakeToast).toHaveBeenCalledWith( + expect.any(String), + 'success', + expect.objectContaining({ + closeButton: true, + disableTimeout: true, + displayType: 'odd', + heading: undefined, + }) ) - screen.getByText('Skipping to step 3 succeeded') }) +}) - it('should handle a falsy currentStepCount', () => { +describe('useRecoveryToastText', () => { + it(`should return correct text for ${RECOVERY_MAP.RETRY_SAME_TIPS.ROUTE} option`, () => { const { result } = renderHook(() => - useToastText({ - currentStepCount: null, + useRecoveryToastText({ + stepNumber: 2, selectedRecoveryOption: RECOVERY_MAP.RETRY_SAME_TIPS.ROUTE, }) ) - render( - -
    {result.current}
    -
    + renderWithI18n(
    {result.current}
    ) + screen.getByText('Retrying step 2 succeeded.') + }) + + it(`should return correct text for ${RECOVERY_MAP.SKIP_STEP_WITH_SAME_TIPS.ROUTE} option`, () => { + const { result } = renderHook(() => + useRecoveryToastText({ + stepNumber: 3, + selectedRecoveryOption: RECOVERY_MAP.SKIP_STEP_WITH_SAME_TIPS.ROUTE, + }) ) - screen.getByText('Retrying step ? succeeded') + + renderWithI18n(
    {result.current}
    ) + screen.getByText('Skipping to step 3 succeeded.') }) }) @@ -123,3 +166,53 @@ describe('getStepNumber', () => { ) }) }) + +describe('useRecoveryFullCommandText', () => { + it('should return the correct command text', () => { + vi.mocked(useCommandTextString).mockReturnValue({ + commandText: TEST_COMMAND, + stepTexts: undefined, + }) + + const { result } = renderHook(() => + useRecoveryFullCommandText({ + robotType: FLEX_ROBOT_TYPE, + stepNumber: 1, + commandTextData: { commands: [TEST_COMMAND] } as any, + }) + ) + + expect(result.current).toBe(TEST_COMMAND) + }) + + it('should return stepNumber if it is a string', () => { + const { result } = renderHook(() => + useRecoveryFullCommandText({ + robotType: FLEX_ROBOT_TYPE, + stepNumber: '?', + commandTextData: { commands: [] } as any, + }) + ) + + expect(result.current).toBe('?') + }) + + it('should truncate TC command', () => { + vi.mocked(useCommandTextString).mockReturnValue({ + commandText: TC_COMMAND, + stepTexts: ['step'], + }) + + const { result } = renderHook(() => + useRecoveryFullCommandText({ + robotType: FLEX_ROBOT_TYPE, + stepNumber: 1, + commandTextData: { + commands: [TC_COMMAND], + } as any, + }) + ) + + expect(result.current).toBe('tc command cycle') + }) +}) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts index 030c95f9f11..ff05642ff18 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts @@ -14,8 +14,10 @@ import { import { useRecoveryOptionCopy } from './useRecoveryOptionCopy' import { useRecoveryActionMutation } from './useRecoveryActionMutation' import { useRunningStepCounts } from '../../../resources/protocols/hooks' +import { useRecoveryToasts } from './useRecoveryToasts' import type { PipetteData } from '@opentrons/api-client' +import type { RobotType } from '@opentrons/shared-data' import type { IRecoveryMap } from '../types' import type { ErrorRecoveryFlowsProps } from '..' import type { UseRouteUpdateActionsResult } from './useRouteUpdateActions' @@ -30,6 +32,8 @@ import type { StepCounts } from '../../../resources/protocols/hooks' type ERUtilsProps = ErrorRecoveryFlowsProps & { toggleERWizard: (launchER: boolean) => Promise hasLaunchedRecovery: boolean + isOnDevice: boolean + robotType: RobotType } export interface ERUtilsResults { @@ -58,6 +62,8 @@ export function useERUtils({ toggleERWizard, hasLaunchedRecovery, protocolAnalysis, + isOnDevice, + robotType, }: ERUtilsProps): ERUtilsResults { const { data: attachedInstruments } = useInstrumentsQuery() const { data: runRecord } = useNotifyRunQuery(runId) @@ -71,6 +77,8 @@ export function useERUtils({ pageLength: 999, }) + const stepCounts = useRunningStepCounts(runId, runCommands) + const { recoveryMap, setRM, @@ -78,6 +86,14 @@ export function useERUtils({ currentRecoveryOptionUtils, } = useRecoveryRouting() + const recoveryToastUtils = useRecoveryToasts({ + currentStepCount: stepCounts.currentStepNumber, + selectedRecoveryOption: currentRecoveryOptionUtils.selectedRecoveryOption, + isOnDevice, + commandTextData: protocolAnalysis, + robotType, + }) + const tipStatusUtils = useRecoveryTipStatus({ runId, isFlex, @@ -111,6 +127,7 @@ export function useERUtils({ failedCommand, failedLabwareUtils, routeUpdateActions, + recoveryToastUtils, }) const deckMapUtils = useDeckMapUtils({ @@ -120,8 +137,6 @@ export function useERUtils({ failedLabwareUtils, }) - const stepCounts = useRunningStepCounts(runId, runCommands) - const recoveryActionMutationUtils = useRecoveryActionMutation(runId) // TODO(jh, 06-14-24): Ensure other string build utilities that are internal to ErrorRecoveryFlows are exported under diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts index a1cc6a946fd..3ef8f5b3809 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts @@ -15,20 +15,22 @@ import type { WellGroup } from '@opentrons/components' import type { FailedCommand } from '../types' import type { UseFailedLabwareUtilsResult } from './useFailedLabwareUtils' import type { UseRouteUpdateActionsResult } from './useRouteUpdateActions' +import type { RecoveryToasts } from './useRecoveryToasts' interface UseRecoveryCommandsParams { runId: string failedCommand: FailedCommand | null failedLabwareUtils: UseFailedLabwareUtilsResult routeUpdateActions: UseRouteUpdateActionsResult + recoveryToastUtils: RecoveryToasts } export interface UseRecoveryCommandsResult { /* A terminal recovery command that causes ER to exit as the run status becomes "running" */ resumeRun: () => void /* A terminal recovery command that causes ER to exit as the run status becomes "stop-requested" */ cancelRun: () => void - /* A non-terminal recovery command, but should generally be chained with a resumeRun. */ - skipFailedCommand: () => Promise + /* A terminal recovery command, that causes ER to exit as the run status becomes "running" */ + skipFailedCommand: () => void /* A non-terminal recovery command. Ignore this errorKind for the rest of this run. */ ignoreErrorKindThisRun: () => Promise /* A non-terminal recovery command */ @@ -44,11 +46,15 @@ export function useRecoveryCommands({ failedCommand, failedLabwareUtils, routeUpdateActions, + recoveryToastUtils, }: UseRecoveryCommandsParams): UseRecoveryCommandsResult { const { proceedToRouteAndStep } = routeUpdateActions const { chainRunCommands } = useChainRunCommands(runId, failedCommand?.id) - const { resumeRunFromRecovery } = useResumeRunFromRecoveryMutation() + const { + mutateAsync: resumeRunFromRecovery, + } = useResumeRunFromRecoveryMutation() const { stopRun } = useStopRunMutation() + const { makeSuccessToast } = recoveryToastUtils const chainRunRecoveryCommands = React.useCallback( ( @@ -96,23 +102,20 @@ export function useRecoveryCommands({ }, [chainRunRecoveryCommands, failedCommand, failedLabwareUtils]) const resumeRun = React.useCallback((): void => { - resumeRunFromRecovery(runId) - }, [runId, resumeRunFromRecovery]) + void resumeRunFromRecovery(runId).then(() => { + makeSuccessToast() + }) + }, [runId, resumeRunFromRecovery, makeSuccessToast]) const cancelRun = React.useCallback((): void => { stopRun(runId) }, [runId]) - // TODO(jh, 06-18-24): If this command is actually terminal for error recovery, remove the resumeRun currently promise - // chained where this is used. Also update docstring in iface. - const skipFailedCommand = React.useCallback((): Promise => { - console.log('SKIPPING TO NEXT STEP') - return new Promise(resolve => { - setTimeout(() => { - resolve() - }, 2000) + const skipFailedCommand = React.useCallback((): void => { + void resumeRunFromRecovery(runId).then(() => { + makeSuccessToast() }) - }, []) + }, [runId, resumeRunFromRecovery, makeSuccessToast]) const ignoreErrorKindThisRun = React.useCallback((): Promise => { console.log('IGNORING ALL ERRORS OF THIS KIND THIS RUN') diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts index a22b2701f2b..632846329b5 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts @@ -1,10 +1,14 @@ +import { useTranslation } from 'react-i18next' + import { useToaster } from '../../ToasterOven' import { RECOVERY_MAP } from '../constants' -import type { CurrentRecoveryOptionUtils } from './useRecoveryRouting' -import { useTranslation } from 'react-i18next' +import { useCommandTextString } from '../../../molecules/Command' + import type { StepCounts } from '../../../resources/protocols/hooks' +import type { CurrentRecoveryOptionUtils } from './useRecoveryRouting' +import type { UseCommandTextStringParams } from '../../../molecules/Command' -interface BuildToast { +export type BuildToast = Omit & { isOnDevice: boolean currentStepCount: StepCounts['currentStepNumber'] selectedRecoveryOption: CurrentRecoveryOptionUtils['selectedRecoveryOption'] @@ -20,17 +24,35 @@ export function useRecoveryToasts({ currentStepCount, isOnDevice, selectedRecoveryOption, + ...rest }: BuildToast): RecoveryToasts { const { makeToast } = useToaster() + const displayType = isOnDevice ? 'odd' : 'desktop' + + const stepNumber = getStepNumber(selectedRecoveryOption, currentStepCount) + + const desktopFullCommandText = useRecoveryFullCommandText({ + ...rest, + stepNumber, + }) + const recoveryToastText = useRecoveryToastText({ + stepNumber, + selectedRecoveryOption, + }) - const toastText = useToastText({ currentStepCount, selectedRecoveryOption }) + // The "body" of the toast message. On ODD, this is the recovery-specific text. On desktop, this is the full command text. + const bodyText = + displayType === 'desktop' ? desktopFullCommandText : recoveryToastText + // The "heading" of the toast message. Currently, this text is only present on the desktop toasts. + const headingText = displayType === 'desktop' ? recoveryToastText : undefined const makeSuccessToast = (): void => { if (selectedRecoveryOption !== RECOVERY_MAP.CANCEL_RUN.ROUTE) { - makeToast(toastText, 'success', { + makeToast(bodyText, 'success', { closeButton: true, disableTimeout: true, - displayType: isOnDevice ? 'odd' : 'desktop', + displayType, + heading: headingText, }) } } @@ -39,14 +61,16 @@ export function useRecoveryToasts({ } // Return i18n toast text for the corresponding user selected recovery option. -export function useToastText({ - currentStepCount, +// Ex: "Skip to step <###> succeeded." +export function useRecoveryToastText({ + stepNumber, selectedRecoveryOption, -}: Omit): string { +}: { + stepNumber: ReturnType + selectedRecoveryOption: CurrentRecoveryOptionUtils['selectedRecoveryOption'] +}): string { const { t } = useTranslation('error_recovery') - const stepNumber = getStepNumber(selectedRecoveryOption, currentStepCount) - const currentStepReturnVal = t('retrying_step_succeeded', { step: stepNumber, }) as string @@ -63,6 +87,35 @@ export function useToastText({ return toastText } +type UseRecoveryFullCommandTextParams = Omit< + UseCommandTextStringParams, + 'command' +> & { + stepNumber: ReturnType +} + +// Return the full command text of the recovery command that is "retried" or "skipped". +export function useRecoveryFullCommandText( + props: UseRecoveryFullCommandTextParams +): string { + const { commandTextData, stepNumber } = props + + const relevantCmdIdx = typeof stepNumber === 'number' ? stepNumber : -1 + const relevantCmd = commandTextData?.commands[relevantCmdIdx] ?? null + + const { commandText, stepTexts } = useCommandTextString({ + ...props, + command: relevantCmd, + }) + + if (typeof stepNumber === 'string') { + return stepNumber + } else { + return truncateIfTCCommand(commandText, stepTexts != null) + } +} + +// Return the user-facing step number. If the step number cannot be determined, return '?'. export function getStepNumber( selectedRecoveryOption: BuildToast['selectedRecoveryOption'], currentStepCount: BuildToast['currentStepCount'] @@ -101,3 +154,20 @@ function handleRecoveryOptionAction( return 'HANDLE RECOVERY TOAST OPTION EXPLICITLY.' } } + +// Special case the TC text, so it make sense in a success toast. +function truncateIfTCCommand(commandText: string, isTCText: boolean): string { + if (isTCText) { + const indexOfCycle = commandText.indexOf('cycle') + + if (indexOfCycle === -1) { + console.warn( + 'TC cycle text has changed. Update Error Recovery TC text utility.' + ) + } + + return commandText.slice(0, indexOfCycle + 5) // +5 to include "cycle" + } else { + return commandText + } +} diff --git a/app/src/organisms/ErrorRecoveryFlows/index.tsx b/app/src/organisms/ErrorRecoveryFlows/index.tsx index 2a2a319499d..6e4e2bf1fd3 100644 --- a/app/src/organisms/ErrorRecoveryFlows/index.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/index.tsx @@ -117,18 +117,20 @@ export function ErrorRecoveryFlows( const { hasLaunchedRecovery, toggleERWizard, showERWizard } = useERWizard() + const isOnDevice = useSelector(getIsOnDevice) + const robotType = protocolAnalysis?.robotType ?? OT2_ROBOT_TYPE + const showSplash = useRunPausedSplash(isOnDevice, showERWizard) + const isDoorOpen = useShowDoorInfo(runStatus) const recoveryUtils = useERUtils({ ...props, hasLaunchedRecovery, toggleERWizard, + isOnDevice, + robotType, }) - const robotType = protocolAnalysis?.robotType ?? OT2_ROBOT_TYPE - const isOnDevice = useSelector(getIsOnDevice) - const showSplash = useRunPausedSplash(isOnDevice, showERWizard) - return ( <> {showERWizard || isDoorOpen ? ( diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx index 475ad3d02a0..de4829d937f 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx @@ -43,20 +43,7 @@ type ErrorDetailsModalProps = ErrorRecoveryFlowsProps & robotType: RobotType } -export function ErrorDetailsModal( - props: ErrorDetailsModalProps -): JSX.Element | null { - if (props.isOnDevice) { - return - } else { - return null - } -} - -// For ODD use only. -export function ErrorDetailsModalODD( - props: ErrorDetailsModalProps -): JSX.Element { +export function ErrorDetailsModal(props: ErrorDetailsModalProps): JSX.Element { const { failedCommand, toggleModal, isOnDevice } = props const errorKind = getErrorKind(failedCommand) const errorName = useErrorName(errorKind) diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/FailedStepNextStep.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/FailedStepNextStep.tsx new file mode 100644 index 00000000000..b29ade0d2eb --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/shared/FailedStepNextStep.tsx @@ -0,0 +1,62 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { CategorizedStepContent } from '../../../molecules/InterventionModal' +import type { RecoveryContentProps } from '../types' + +export function FailedStepNextStep({ + stepCounts, + failedCommand, + commandsAfterFailedCommand, + protocolAnalysis, + robotType, +}: Pick< + RecoveryContentProps, + | 'stepCounts' + | 'failedCommand' + | 'commandsAfterFailedCommand' + | 'protocolAnalysis' + | 'robotType' +>): JSX.Element { + const { t } = useTranslation('error_recovery') + + const nthStepAfter = (n: number): number | undefined => + stepCounts.currentStepNumber == null + ? undefined + : stepCounts.currentStepNumber + n + const nthCommand = (n: number): typeof failedCommand => + commandsAfterFailedCommand != null + ? n < commandsAfterFailedCommand.length + ? commandsAfterFailedCommand[n] + : null + : null + + const commandsAfter = [nthCommand(0), nthCommand(1)] as const + + const indexedCommandsAfter = [ + commandsAfter[0] != null + ? { command: commandsAfter[0], index: nthStepAfter(1) } + : null, + commandsAfter[1] != null + ? { command: commandsAfter[1], index: nthStepAfter(2) } + : null, + ] as const + return ( + + ) +} diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryContentWrapper.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryContentWrapper.tsx index 3c10279d50d..b9acdcc8cae 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryContentWrapper.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryContentWrapper.tsx @@ -10,19 +10,34 @@ import { Flex, RESPONSIVENESS, } from '@opentrons/components' - import type { StyleProps } from '@opentrons/components' +import { + OneColumn, + TwoColumn, + OneColumnOrTwoColumn, +} from '../../../molecules/InterventionModal' +import { RecoveryFooterButtons } from './RecoveryFooterButtons' -interface SingleColumnContentWrapperProps extends StyleProps { +interface SingleColumnContentWrapperProps { children: React.ReactNode + footerDetails?: React.ComponentProps +} + +interface TwoColumnContentWrapperProps { + children: [React.ReactNode, React.ReactNode] + footerDetails?: React.ComponentProps +} + +interface OneOrTwoColumnContentWrapperProps { + children: [React.ReactNode, React.ReactNode] + footerDetails?: React.ComponentProps } // For flex-direction: column recovery content with one column only. -// -// For ODD use only. -export function RecoveryContentWrapper({ +export function RecoverySingleColumnContentWrapper({ children, + footerDetails, ...styleProps -}: SingleColumnContentWrapperProps): JSX.Element { +}: SingleColumnContentWrapperProps & StyleProps): JSX.Element { return ( - {children} + + {children} + + {footerDetails != null ? ( + + ) : null} + + ) +} + +// For two-column recovery content +export function RecoveryTwoColumnContentWrapper({ + children, + footerDetails, +}: TwoColumnContentWrapperProps): JSX.Element { + const [leftChild, rightChild] = children + return ( + + + {leftChild} + {rightChild} + + {footerDetails != null ? ( + + ) : null} + + ) +} + +// For recovery content with one column on ODD and two columns on desktop +export function RecoveryODDOneDesktopTwoColumnContentWrapper({ + children: [leftOrSingleElement, optionallyShownRightElement], + footerDetails, +}: OneOrTwoColumnContentWrapperProps): JSX.Element { + return ( + + + {leftOrSingleElement} + {optionallyShownRightElement} + + {footerDetails != null ? ( + + ) : null} ) } @@ -38,8 +104,8 @@ export function RecoveryContentWrapper({ const STYLE = css` gap: ${SPACING.spacing24}; width: 100%; + height: 100%; @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { gap: none; - height: 100%; } ` diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryRadioGroup.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryRadioGroup.tsx new file mode 100644 index 00000000000..571f0b0333a --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryRadioGroup.tsx @@ -0,0 +1,42 @@ +import * as React from 'react' + +import type { ChangeEventHandler } from 'react' +import { RadioGroup, SPACING, Flex } from '@opentrons/components' + +// note: this typescript stuff is so that e.currentTarget.value in the ChangeEventHandler +// is deduced to a union of the values of the options passed to the radiogroup rather than +// just string +export interface Target extends Omit { + value: T +} + +export type Options = Array<{ + value: T + children: React.ReactNode +}> + +export interface RecoveryRadioGroupProps + extends Omit< + React.ComponentProps, + 'labelTextClassName' | 'options' | 'onchange' + > { + options: Options + onChange: ChangeEventHandler> +} + +export function RecoveryRadioGroup( + props: RecoveryRadioGroupProps +): JSX.Element { + return ( + ({ + name: '', + value: radioOption.value, + children: ( + {radioOption.children} + ), + }))} + /> + ) +} diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/ReplaceTips.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/ReplaceTips.tsx index e577abb7bb1..f7513af14c8 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/ReplaceTips.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/ReplaceTips.tsx @@ -3,7 +3,7 @@ import * as React from 'react' import { Flex } from '@opentrons/components' import { useTranslation } from 'react-i18next' -import { RecoveryContentWrapper } from './RecoveryContentWrapper' +import { RecoverySingleColumnContentWrapper } from './RecoveryContentWrapper' import { TwoColumn, DeckMapContent } from '../../../molecules/InterventionModal' import { RecoveryFooterButtons } from './RecoveryFooterButtons' import { LeftColumnLabwareInfo } from './LeftColumnLabwareInfo' @@ -36,7 +36,7 @@ export function ReplaceTips(props: RecoveryContentProps): JSX.Element | null { } return ( - + - + ) } diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/SelectTips.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/SelectTips.tsx index f51d9d2ddd6..0b6f66aa484 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/SelectTips.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/SelectTips.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import { RECOVERY_MAP } from '../constants' -import { RecoveryContentWrapper } from './RecoveryContentWrapper' +import { RecoverySingleColumnContentWrapper } from './RecoveryContentWrapper' import { TwoColumn } from '../../../molecules/InterventionModal' import { RecoveryFooterButtons } from './RecoveryFooterButtons' import { LeftColumnLabwareInfo } from './LeftColumnLabwareInfo' @@ -42,7 +42,7 @@ export function SelectTips(props: RecoveryContentProps): JSX.Element | null { toggleModal={toggleModal} /> )} - + - + ) } diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/TwoColTextAndFailedStepNextStep.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/TwoColTextAndFailedStepNextStep.tsx index a9bdd50399f..4ed62e8ff8d 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/TwoColTextAndFailedStepNextStep.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/TwoColTextAndFailedStepNextStep.tsx @@ -1,5 +1,4 @@ import * as React from 'react' -import { useTranslation } from 'react-i18next' import { css } from 'styled-components' import { DIRECTION_COLUMN, @@ -9,12 +8,10 @@ import { RESPONSIVENESS, } from '@opentrons/components' -import { RecoveryContentWrapper } from './RecoveryContentWrapper' -import { - TwoColumn, - CategorizedStepContent, -} from '../../../molecules/InterventionModal' +import { RecoverySingleColumnContentWrapper } from './RecoveryContentWrapper' +import { TwoColumn } from '../../../molecules/InterventionModal' import { RecoveryFooterButtons } from './RecoveryFooterButtons' +import { FailedStepNextStep } from './FailedStepNextStep' import type { RecoveryContentProps } from '../types' @@ -24,60 +21,34 @@ type TwoColTextAndFailedStepNextStepProps = RecoveryContentProps & { primaryBtnCopy: string primaryBtnOnClick: () => void secondaryBtnOnClickOverride?: () => void - secondaryBtnOnClickCopyOverride?: string } /** * Left Column: Title + body text * Right column: FailedStepNextStep */ -export function TwoColTextAndFailedStepNextStep({ - leftColBodyText, - leftColTitle, - primaryBtnCopy, - primaryBtnOnClick, - secondaryBtnOnClickOverride, - secondaryBtnOnClickCopyOverride, - routeUpdateActions, - failedCommand, - stepCounts, - commandsAfterFailedCommand, - protocolAnalysis, - robotType, -}: TwoColTextAndFailedStepNextStepProps): JSX.Element | null { +export function TwoColTextAndFailedStepNextStep( + props: TwoColTextAndFailedStepNextStepProps +): JSX.Element | null { + const { + leftColBodyText, + leftColTitle, + primaryBtnCopy, + primaryBtnOnClick, + secondaryBtnOnClickOverride, + routeUpdateActions, + } = props const { goBackPrevStep } = routeUpdateActions - const { t } = useTranslation('error_recovery') - const nthStepAfter = (n: number): number | undefined => - stepCounts.currentStepNumber == null - ? undefined - : stepCounts.currentStepNumber + n - const nthCommand = (n: number): typeof failedCommand => - commandsAfterFailedCommand != null - ? n < commandsAfterFailedCommand.length - ? commandsAfterFailedCommand[n] - : null - : null - - const commandsAfter = [nthCommand(0), nthCommand(1)] as const - - const indexedCommandsAfter = [ - commandsAfter[0] != null - ? { command: commandsAfter[0], index: nthStepAfter(1) } - : null, - commandsAfter[1] != null - ? { command: commandsAfter[1], index: nthStepAfter(2) } - : null, - ] as const return ( - + @@ -94,29 +65,13 @@ export function TwoColTextAndFailedStepNextStep({ {leftColBodyText} - + - + ) } diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/ErrorDetailsModal.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/ErrorDetailsModal.test.tsx index 80ebae71884..3eb590f1a35 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/ErrorDetailsModal.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/ErrorDetailsModal.test.tsx @@ -11,7 +11,6 @@ import { Modal } from '../../../../molecules/Modal' import { useErrorDetailsModal, ErrorDetailsModal, - ErrorDetailsModalODD, OverpressureBanner, } from '../ErrorDetailsModal' @@ -58,7 +57,7 @@ describe('ErrorDetailsModal', () => { vi.mocked(StepInfo).mockReturnValue(
    MOCK_STEP_INFO
    ) }) - it('renders ErrorDetailsModalODD', () => { + it('renders ErrorDetailsModal', () => { renderWithProviders(, { i18nInstance: i18n, }) @@ -66,14 +65,14 @@ describe('ErrorDetailsModal', () => { }) }) -const render = (props: React.ComponentProps) => { - return renderWithProviders(, { +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { i18nInstance: i18n, })[0] } -describe('ErrorDetailsModalODD', () => { - let props: React.ComponentProps +describe('ErrorDetailsModal', () => { + let props: React.ComponentProps beforeEach(() => { props = { diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/StepInfo.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/StepInfo.test.tsx index 54e579daf93..4e7e8b393fa 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/StepInfo.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/StepInfo.test.tsx @@ -3,7 +3,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { screen } from '@testing-library/react' import { renderWithProviders } from '../../../../__testing-utils__' -import { mockRecoveryContentProps } from '../../__fixtures__' +import { mockRecoveryContentProps, mockFailedCommand } from '../../__fixtures__' import { i18n } from '../../../../i18n' import { StepInfo } from '../StepInfo' import { CommandText } from '../../../../molecules/Command' @@ -21,7 +21,10 @@ describe('StepInfo', () => { beforeEach(() => { props = { - ...mockRecoveryContentProps, + ...{ + ...mockRecoveryContentProps, + protocolAnalysis: { commands: [mockFailedCommand] } as any, + }, textStyle: 'h4', stepCounts: { currentStepNumber: 5, diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/index.ts b/app/src/organisms/ErrorRecoveryFlows/shared/index.ts index 33c9299db44..955058e5311 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/index.ts +++ b/app/src/organisms/ErrorRecoveryFlows/shared/index.ts @@ -1,5 +1,9 @@ export { RecoveryFooterButtons } from './RecoveryFooterButtons' -export { RecoveryContentWrapper } from './RecoveryContentWrapper' +export { + RecoverySingleColumnContentWrapper, + RecoveryTwoColumnContentWrapper, + RecoveryODDOneDesktopTwoColumnContentWrapper, +} from './RecoveryContentWrapper' export { ReplaceTips } from './ReplaceTips' export { SelectTips } from './SelectTips' export { TwoColTextAndFailedStepNextStep } from './TwoColTextAndFailedStepNextStep' @@ -9,5 +13,7 @@ export { TipSelectionModal } from './TipSelectionModal' export { StepInfo } from './StepInfo' export { useErrorDetailsModal, ErrorDetailsModal } from './ErrorDetailsModal' export { RecoveryInterventionModal } from './RecoveryInterventionModal' +export { FailedStepNextStep } from './FailedStepNextStep' +export { RecoveryRadioGroup } from './RecoveryRadioGroup' export type { RecoveryInterventionModalProps } from './RecoveryInterventionModal' diff --git a/app/src/organisms/Navigation/NavigationMenu.tsx b/app/src/organisms/Navigation/NavigationMenu.tsx index 36a258ecca6..3b4f10752e8 100644 --- a/app/src/organisms/Navigation/NavigationMenu.tsx +++ b/app/src/organisms/Navigation/NavigationMenu.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import { createPortal } from 'react-dom' import { useDispatch } from 'react-redux' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' @@ -8,8 +9,8 @@ import { COLORS, Flex, Icon, - SPACING, LegacyStyledText, + SPACING, TYPOGRAPHY, } from '@opentrons/components' @@ -17,6 +18,7 @@ import { MenuList } from '../../atoms/MenuList' import { MenuItem } from '../../atoms/MenuList/MenuItem' import { home, ROBOT } from '../../redux/robot-controls' import { useLights } from '../Devices/hooks' +import { getTopPortalEl } from '../../App/portal' import { RestartRobotConfirmationModal } from './RestartRobotConfirmationModal' import type { Dispatch } from '../../redux/types' @@ -48,9 +50,7 @@ export function NavigationMenu(props: NavigationMenuProps): JSX.Element { setShowNavMenu(false) } - // ToDo (kk:10/02/2023) - // Need to update a function for onClick - return ( + return createPortal( <> {showRestartRobotConfirmationModal ? ( - + , + getTopPortalEl() ) } diff --git a/app/src/organisms/ProtocolSetupParameters/ChooseCsvFile.tsx b/app/src/organisms/ProtocolSetupParameters/ChooseCsvFile.tsx index f12516f38c4..5c0d5202c28 100644 --- a/app/src/organisms/ProtocolSetupParameters/ChooseCsvFile.tsx +++ b/app/src/organisms/ProtocolSetupParameters/ChooseCsvFile.tsx @@ -119,10 +119,9 @@ export function ChooseCsvFile({ csvFilesOnUSB.map(csvFilePath => { const fileName = last(csvFilePath.split('/')) return ( - <> + {csvFilePath.length !== 0 && fileName !== undefined ? ( ) : null} - + ) }) ) : ( diff --git a/app/src/organisms/ProtocolSetupParameters/__tests__/ProtocolSetupParameters.test.tsx b/app/src/organisms/ProtocolSetupParameters/__tests__/ProtocolSetupParameters.test.tsx index 38bd1c5e6d0..a80d8565748 100644 --- a/app/src/organisms/ProtocolSetupParameters/__tests__/ProtocolSetupParameters.test.tsx +++ b/app/src/organisms/ProtocolSetupParameters/__tests__/ProtocolSetupParameters.test.tsx @@ -141,12 +141,11 @@ describe('ProtocolSetupParameters', () => { screen.getByText('EtoH Volume') }) - // ToDo (kk:06/18/2024) comment-out will be removed in a following PR. - // it('renders the other setting when csv param', () => { - // vi.mocked(useFeatureFlag).mockReturnValue(true) - // render(props) - // screen.getByText('CSV File') - // }) + it('renders the other setting when csv param', () => { + vi.mocked(useFeatureFlag).mockReturnValue(true) + render(props) + screen.getByText('CSV File') + }) it('renders the back icon and calls useNavigate', () => { render(props) diff --git a/app/src/organisms/ProtocolSetupParameters/index.tsx b/app/src/organisms/ProtocolSetupParameters/index.tsx index 099a71ed0ea..66cad283b6c 100644 --- a/app/src/organisms/ProtocolSetupParameters/index.tsx +++ b/app/src/organisms/ProtocolSetupParameters/index.tsx @@ -89,13 +89,24 @@ export function ProtocolSetupParameters({ ({ ...parameter, value: parameter.default } as ValueRunTimeParameter) ) ) + const hasMissingFileParam = - runTimeParametersOverrides?.some( - parameter => - parameter.type === 'csv_file' && - ((parameter.file?.id == null && parameter.file?.file == null) || - parameter.file?.filePath == null) - ) ?? false + runTimeParametersOverrides?.some((parameter): boolean => { + if (parameter.type !== 'csv_file') { + return false + } + + if (parameter.file == null) { + return true + } + + return ( + parameter.file.id == null && + parameter.file.file == null && + parameter.file.filePath == null + ) + }) ?? false + const { makeSnackbar } = useToaster() const updateParameters = ( diff --git a/app/src/pages/ProtocolDashboard/ProtocolCard.tsx b/app/src/pages/ProtocolDashboard/ProtocolCard.tsx index d8fd9b1c662..f6acd4ed098 100644 --- a/app/src/pages/ProtocolDashboard/ProtocolCard.tsx +++ b/app/src/pages/ProtocolDashboard/ProtocolCard.tsx @@ -10,15 +10,15 @@ import { ALIGN_CENTER, ALIGN_END, BORDERS, + Chip, COLORS, DIRECTION_COLUMN, - DIRECTION_ROW, Flex, Icon, + LegacyStyledText, OVERFLOW_WRAP_ANYWHERE, OVERFLOW_WRAP_BREAK_WORD, SPACING, - LegacyStyledText, TYPOGRAPHY, useLongPress, } from '@opentrons/components' @@ -230,36 +230,14 @@ export function ProtocolCard(props: ProtocolCardProps): JSX.Element { gridGap={SPACING.spacing8} > {isFailedAnalysis ? ( - - - - {i18n.format(t('failed_analysis'), 'capitalize')} - - + ) : null} {isRequiredCSV ? ( - - - - {t('requires_csv')} - - + ) : null} { }) vi.mock('@opentrons/react-api-client') vi.mock('../../../redux/config') +vi.mock('@opentrons/components', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + Chip: vi.fn(() =>
    mock Chip
    ), + } +}) const mockProtocol: ProtocolResource = { id: 'mockProtocol1', @@ -119,10 +127,8 @@ describe('ProtocolCard', () => { } as UseQueryResult) render(props) - screen.getByLabelText('failedAnalysis_icon') - screen.getByText('Failed analysis') fireEvent.click(screen.getByText('yay mock protocol')) - screen.getByText('Protocol analysis failed') + screen.getByText('mock Chip') screen.getByText( 'Delete the protocol, make changes to address the error, and resend the protocol to this robot from the Opentrons App.' ) @@ -164,8 +170,7 @@ describe('ProtocolCard', () => { vi.advanceTimersByTime(1005) }) expect(props.longPress).toHaveBeenCalled() - screen.getByLabelText('failedAnalysis_icon') - screen.getByText('Failed analysis') + screen.getByText('mock Chip') const card = screen.getByTestId('protocol_card') expect(card).toHaveStyle(`background-color: ${COLORS.red35}`) fireEvent.click(screen.getByText('yay mock protocol')) @@ -204,8 +209,7 @@ describe('ProtocolCard', () => { data: { result: 'parameter-value-required' } as any, } as UseQueryResult) render({ ...props, protocol: mockProtocolWithCSV }) - screen.getByLabelText('requiresCsv_file_icon') - screen.getByText('Requires CSV') + screen.getByText('mock Chip') const card = screen.getByTestId('protocol_card') expect(card).toHaveStyle(`background-color: ${COLORS.yellow35}`) }) diff --git a/app/src/pages/ProtocolDashboard/index.tsx b/app/src/pages/ProtocolDashboard/index.tsx index 455980f1eeb..79118a42860 100644 --- a/app/src/pages/ProtocolDashboard/index.tsx +++ b/app/src/pages/ProtocolDashboard/index.tsx @@ -9,10 +9,10 @@ import { DIRECTION_COLUMN, DIRECTION_ROW, Flex, + LegacyStyledText, POSITION_STATIC, POSITION_STICKY, SPACING, - LegacyStyledText, } from '@opentrons/components' import { useAllProtocolsQuery } from '@opentrons/react-api-client' diff --git a/components/src/atoms/StyledText/StyledText.tsx b/components/src/atoms/StyledText/StyledText.tsx index 17e41ddc462..3bb124a3def 100644 --- a/components/src/atoms/StyledText/StyledText.tsx +++ b/components/src/atoms/StyledText/StyledText.tsx @@ -31,6 +31,14 @@ const helixProductStyleMap = { } `, }, + headingMediumBold: { + as: 'h3', + style: css` + @media not (${RESPONSIVENESS.touchscreenMediaQuerySpecs}) { + font: ${HELIX_TYPOGRAPHY.fontStyleHeadingMediumBold}; + } + `, + }, headingMediumSemiBold: { as: 'h3', style: css` @@ -55,6 +63,14 @@ const helixProductStyleMap = { } `, }, + headingSmallSemiBold: { + as: 'h4', + style: css` + @media not (${RESPONSIVENESS.touchscreenMediaQuerySpecs}) { + font: ${HELIX_TYPOGRAPHY.fontStyleHeadingSmallBold}; + } + `, + }, bodyLargeSemiBold: { as: 'p', style: css` @@ -171,7 +187,6 @@ const ODDStyleMap = { } `, }, - level3HeaderBold: { as: 'h3', style: css` diff --git a/components/src/atoms/StyledText/__tests__/StyledText.test.tsx b/components/src/atoms/StyledText/__tests__/StyledText.test.tsx deleted file mode 100644 index b6e0a3909f2..00000000000 --- a/components/src/atoms/StyledText/__tests__/StyledText.test.tsx +++ /dev/null @@ -1,290 +0,0 @@ -import * as React from 'react' -import { describe, it, expect } from 'vitest' -import '@testing-library/jest-dom/vitest' -import { screen } from '@testing-library/react' -import { TYPOGRAPHY } from '../../../ui-style-constants' -import { renderWithProviders } from '../../../testing/utils' -import { LegacyStyledText } from '..' - -const render = (props: React.ComponentProps) => { - return renderWithProviders()[0] -} - -describe('StyledText', () => { - let props: React.ComponentProps - // testing styles (font size, font weight, and line height) - it('should render h1 default style', () => { - props = { - as: 'h1', - children: 'h1Default', - } - render(props) - expect(screen.getByText('h1Default')).toHaveStyle( - `fontSize: ${TYPOGRAPHY.fontSizeH1}` - ) - expect(screen.getByText('h1Default')).toHaveStyle( - `fontWeight: ${TYPOGRAPHY.fontWeightSemiBold}` - ) - expect(screen.getByText('h1Default')).toHaveStyle( - `lineHeight: ${TYPOGRAPHY.lineHeight24}` - ) - }) - - it('should render h2 regular style', () => { - props = { - as: 'h2', - children: 'h2Regular', - } - render(props) - expect(screen.getByText('h2Regular')).toHaveStyle( - `fontSize: ${TYPOGRAPHY.fontSizeH2}` - ) - expect(screen.getByText('h2Regular')).toHaveStyle( - `fontWeight: ${TYPOGRAPHY.fontWeightRegular}` - ) - expect(screen.getByText('h2Regular')).toHaveStyle( - `lineHeight: ${TYPOGRAPHY.lineHeight20}` - ) - }) - - it('should render h3 regular style', () => { - props = { - as: 'h3', - children: 'h3Regular', - } - render(props) - expect(screen.getByText('h3Regular')).toHaveStyle( - `fontSize: ${TYPOGRAPHY.fontSizeH3}` - ) - expect(screen.getByText('h3Regular')).toHaveStyle( - `fontWeight: ${TYPOGRAPHY.fontWeightRegular}` - ) - expect(screen.getByText('h3Regular')).toHaveStyle( - `lineHeight: ${TYPOGRAPHY.lineHeight20}` - ) - }) - - it('should render h6 default style', () => { - props = { - as: 'h6', - children: 'h6Default', - } - render(props) - expect(screen.getByText('h6Default')).toHaveStyle( - `fontSize: ${TYPOGRAPHY.fontSizeH6}` - ) - expect(screen.getByText('h6Default')).toHaveStyle( - `fontWeight: ${TYPOGRAPHY.fontWeightRegular}` - ) - expect(screen.getByText('h6Default')).toHaveStyle( - `lineHeight: ${TYPOGRAPHY.lineHeight12}` - ) - expect(screen.getByText('h6Default')).toHaveStyle( - `textTransform: ${TYPOGRAPHY.textTransformUppercase}` - ) - }) - - it('should render p regular style', () => { - props = { - as: 'p', - children: 'pRegular', - } - render(props) - expect(screen.getByText('pRegular')).toHaveStyle( - `fontSize: ${TYPOGRAPHY.fontSizeP}` - ) - expect(screen.getByText('pRegular')).toHaveStyle( - `fontWeight: ${TYPOGRAPHY.fontWeightRegular}` - ) - expect(screen.getByText('pRegular')).toHaveStyle( - `lineHeight: ${TYPOGRAPHY.lineHeight20}` - ) - }) - - it('should render label regular style', () => { - props = { - as: 'label', - children: 'labelRegular', - } - render(props) - expect(screen.getByText('labelRegular')).toHaveStyle( - `fontSize: ${TYPOGRAPHY.fontSizeLabel}` - ) - expect(screen.getByText('labelRegular')).toHaveStyle( - `fontWeight: ${TYPOGRAPHY.fontWeightRegular}` - ) - expect(screen.getByText('labelRegular')).toHaveStyle( - `lineHeight: ${TYPOGRAPHY.lineHeight12}` - ) - }) - - it('should render h2 semibold style', () => { - props = { - as: 'h2SemiBold', - children: 'h2SemiBold', - } - render(props) - expect(screen.getByText('h2SemiBold')).toHaveStyle( - `fontSize: ${TYPOGRAPHY.fontSizeH2}` - ) - expect(screen.getByText('h2SemiBold')).toHaveStyle( - `fontWeight: ${TYPOGRAPHY.fontWeightSemiBold}` - ) - expect(screen.getByText('h2SemiBold')).toHaveStyle( - `lineHeight: ${TYPOGRAPHY.lineHeight20}` - ) - }) - - it('should render h3 semibold style', () => { - props = { - as: 'h3SemiBold', - children: 'h3SemiBold', - } - render(props) - expect(screen.getByText('h3SemiBold')).toHaveStyle( - `fontSize: ${TYPOGRAPHY.fontSizeH3}` - ) - expect(screen.getByText('h3SemiBold')).toHaveStyle( - `fontWeight: ${TYPOGRAPHY.fontWeightSemiBold}` - ) - expect(screen.getByText('h3SemiBold')).toHaveStyle( - `lineHeight: ${TYPOGRAPHY.lineHeight20}` - ) - }) - - it('should render h6 semibold style', () => { - props = { - as: 'h6SemiBold', - children: 'h6SemiBold', - } - render(props) - expect(screen.getByText('h6SemiBold')).toHaveStyle( - `fontSize: ${TYPOGRAPHY.fontSizeH6}` - ) - expect(screen.getByText('h6SemiBold')).toHaveStyle( - `fontWeight: ${TYPOGRAPHY.fontWeightSemiBold}` - ) - expect(screen.getByText('h6SemiBold')).toHaveStyle( - `lineHeight: ${TYPOGRAPHY.lineHeight12}` - ) - }) - - it('should render p semibold style', () => { - props = { - as: 'pSemiBold', - children: 'pSemiBold', - } - render(props) - expect(screen.getByText('pSemiBold')).toHaveStyle( - `fontSize: ${TYPOGRAPHY.fontSizeP}` - ) - expect(screen.getByText('pSemiBold')).toHaveStyle( - `fontWeight: ${TYPOGRAPHY.fontWeightSemiBold}` - ) - expect(screen.getByText('pSemiBold')).toHaveStyle( - `lineHeight: ${TYPOGRAPHY.lineHeight20}` - ) - }) - - it('should render label semibold style', () => { - props = { - as: 'labelSemiBold', - children: 'labelSemiBold', - } - render(props) - expect(screen.getByText('labelSemiBold')).toHaveStyle( - `fontSize: ${TYPOGRAPHY.fontSizeLabel}` - ) - expect(screen.getByText('labelSemiBold')).toHaveStyle( - `fontWeight: ${TYPOGRAPHY.fontWeightSemiBold}` - ) - expect(screen.getByText('labelSemiBold')).toHaveStyle( - `lineHeight: ${TYPOGRAPHY.lineHeight12}` - ) - }) - - it('should render header level 2 bold style', () => { - props = { - as: 'h2Bold', - children: 'h2Bold', - } - render(props) - expect(screen.getByText('h2Bold')).toHaveStyle( - `fontSize: ${TYPOGRAPHY.fontSize38}` - ) - expect(screen.getByText('h2Bold')).toHaveStyle( - `fontWeight: ${TYPOGRAPHY.fontWeightBold}` - ) - expect(screen.getByText('h2Bold')).toHaveStyle( - `lineHeight: ${TYPOGRAPHY.lineHeight48}` - ) - }) - - it('should render header level 3 bold style', () => { - props = { - as: 'h3Bold', - children: 'h3Bold', - } - render(props) - expect(screen.getByText('h3Bold')).toHaveStyle( - `fontSize: ${TYPOGRAPHY.fontSize32}` - ) - expect(screen.getByText('h3Bold')).toHaveStyle( - `fontWeight: ${TYPOGRAPHY.fontWeightBold}` - ) - expect(screen.getByText('h3Bold')).toHaveStyle( - `lineHeight: ${TYPOGRAPHY.lineHeight42}` - ) - }) - - it('should render header level 4 bold style', () => { - props = { - as: 'h4Bold', - children: 'h4Bold', - } - render(props) - expect(screen.getByText('h4Bold')).toHaveStyle( - `fontSize: ${TYPOGRAPHY.fontSize28}` - ) - expect(screen.getByText('h4Bold')).toHaveStyle( - `fontWeight: ${TYPOGRAPHY.fontWeightBold}` - ) - expect(screen.getByText('h4Bold')).toHaveStyle( - `lineHeight: ${TYPOGRAPHY.lineHeight36}` - ) - }) - - it('should render p bold style - bodyText bold', () => { - props = { - as: 'pBold', - children: 'pBold', - } - render(props) - expect(screen.getByText('pBold')).toHaveStyle( - `fontSize: ${TYPOGRAPHY.fontSize22}` - ) - expect(screen.getByText('pBold')).toHaveStyle( - `fontWeight: ${TYPOGRAPHY.fontWeightBold}` - ) - expect(screen.getByText('pBold')).toHaveStyle( - `lineHeight: ${TYPOGRAPHY.lineHeight28}` - ) - }) - - it('should render label bold style - smallBodyText bold', () => { - props = { - as: 'labelBold', - children: 'labelBold', - } - render(props) - expect(screen.getByText('labelBold')).toHaveStyle( - `fontSize: ${TYPOGRAPHY.fontSize20}` - ) - expect(screen.getByText('labelBold')).toHaveStyle( - `fontWeight: ${TYPOGRAPHY.fontWeightBold}` - ) - expect(screen.getByText('labelBold')).toHaveStyle( - `lineHeight: ${TYPOGRAPHY.lineHeight24}` - ) - }) -}) diff --git a/components/src/forms/RadioGroup.tsx b/components/src/forms/RadioGroup.tsx index d934616a227..5d409540032 100644 --- a/components/src/forms/RadioGroup.tsx +++ b/components/src/forms/RadioGroup.tsx @@ -50,7 +50,7 @@ export function RadioGroup(props: RadioGroupProps): JSX.Element { const useStyleUpdates = props.useBlueChecked && radio.value === props.value return ( -