From d5b7e613e642aa0a8542fe85ca9d748c2cc7c68c Mon Sep 17 00:00:00 2001 From: Shlok Amin Date: Fri, 22 Nov 2024 16:14:52 -0500 Subject: [PATCH] feat(protocol-designer): add ability to clear staging slots directly (#16930) closes RQA-3626 --- .../Designer/DeckSetup/DeckSetupTools.tsx | 37 ++++++++++++------- .../Designer/DeckSetup/SlotOverflowMenu.tsx | 29 ++++++++++++++- .../__tests__/DeckSetupTools.test.tsx | 13 ++++++- .../__tests__/SlotOverflowMenu.test.tsx | 35 +++++++++++++++++- shared-data/js/helpers/index.ts | 26 +++++++++++++ 5 files changed, 123 insertions(+), 17 deletions(-) diff --git a/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx b/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx index 62a5f92d46e..44732c1e0ed 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx @@ -18,6 +18,7 @@ import { } from '@opentrons/components' import { FLEX_ROBOT_TYPE, + FLEX_STAGING_AREA_SLOT_ADDRESSABLE_AREAS, getModuleDisplayName, getModuleType, MAGNETIC_MODULE_TYPE, @@ -58,7 +59,7 @@ import { LabwareTools } from './LabwareTools' import { MagnetModuleChangeContent } from './MagnetModuleChangeContent' import { getModuleModelsBySlot, getDeckErrors } from './utils' -import type { ModuleModel } from '@opentrons/shared-data' +import type { AddressableAreaName, ModuleModel } from '@opentrons/shared-data' import type { ThunkDispatch } from '../../../types' import type { Fixture } from './constants' @@ -242,7 +243,7 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { handleResetSearchTerm() } - const handleClear = (): void => { + const handleClear = (keepExistingLabware = false): void => { onDeckProps?.setHoveredModule(null) onDeckProps?.setHoveredFixture(null) if (slot !== 'offDeck') { @@ -250,31 +251,41 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { if (createdModuleForSlot != null) { dispatch(deleteModule(createdModuleForSlot.id)) } - // clear fixture(s) from slot - if (createFixtureForSlots != null && createFixtureForSlots.length > 0) { - createFixtureForSlots.forEach(fixture => - dispatch(deleteDeckFixture(fixture.id)) - ) - } // clear labware from slot if ( createdLabwareForSlot != null && - createdLabwareForSlot.labwareDefURI !== selectedLabwareDefUri + (!keepExistingLabware || + createdLabwareForSlot.labwareDefURI !== selectedLabwareDefUri) ) { dispatch(deleteContainer({ labwareId: createdLabwareForSlot.id })) } // clear nested labware from slot if ( createdNestedLabwareForSlot != null && - createdNestedLabwareForSlot.labwareDefURI !== - selectedNestedLabwareDefUri + (!keepExistingLabware || + createdNestedLabwareForSlot.labwareDefURI !== + selectedNestedLabwareDefUri) ) { dispatch(deleteContainer({ labwareId: createdNestedLabwareForSlot.id })) } // clear labware on staging area 4th column slot - if (matchingLabwareFor4thColumn != null) { + if (matchingLabwareFor4thColumn != null && !keepExistingLabware) { dispatch(deleteContainer({ labwareId: matchingLabwareFor4thColumn.id })) } + // clear fixture(s) from slot + if (createFixtureForSlots != null && createFixtureForSlots.length > 0) { + createFixtureForSlots.forEach(fixture => + dispatch(deleteDeckFixture(fixture.id)) + ) + // zoom out if you're clearing a staging area slot directly from a 4th column + if ( + FLEX_STAGING_AREA_SLOT_ADDRESSABLE_AREAS.includes( + slot as AddressableAreaName + ) + ) { + dispatch(selectZoomedIntoSlot({ slot: null, cutout: null })) + } + } } handleResetToolbox() handleResetLabwareTools() @@ -285,7 +296,7 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { } const handleConfirm = (): void => { // clear entities first before recreating them - handleClear() + handleClear(true) if (selectedFixture != null && cutout != null) { // create fixture(s) diff --git a/protocol-designer/src/pages/Designer/DeckSetup/SlotOverflowMenu.tsx b/protocol-designer/src/pages/Designer/DeckSetup/SlotOverflowMenu.tsx index 8cc15363ea6..c6c37c5be31 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/SlotOverflowMenu.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/SlotOverflowMenu.tsx @@ -15,6 +15,12 @@ import { StyledText, useOnClickOutside, } from '@opentrons/components' +import { + FLEX_ROBOT_TYPE, + FLEX_STAGING_AREA_SLOT_ADDRESSABLE_AREAS, + getCutoutIdFromAddressableArea, + getDeckDefFromRobotType, +} from '@opentrons/shared-data' import { getDeckSetupForActiveItem } from '../../../top-selectors/labware-locations' import { deleteModule } from '../../../step-forms/actions' @@ -32,10 +38,12 @@ import { getStagingAreaAddressableAreas } from '../../../utils' import { selectors as labwareIngredSelectors } from '../../../labware-ingred/selectors' import type { MouseEvent, SetStateAction } from 'react' import type { + AddressableAreaName, CoordinateTuple, CutoutId, DeckSlotId, } from '@opentrons/shared-data' + import type { LabwareOnDeck } from '../../../step-forms' import type { ThunkDispatch } from '../../../types' @@ -146,6 +154,10 @@ export function SlotOverflowMenu( const hasNoItems = moduleOnSlot == null && labwareOnSlot == null && fixturesOnSlot.length === 0 + const isStagingSlot = FLEX_STAGING_AREA_SLOT_ADDRESSABLE_AREAS.includes( + location as AddressableAreaName + ) + const handleClear = (): void => { // clear module from slot if (moduleOnSlot != null) { @@ -167,6 +179,21 @@ export function SlotOverflowMenu( if (matchingLabware != null) { dispatch(deleteContainer({ labwareId: matchingLabware.id })) } + // delete staging slot if addressable area is on staging slot + if (isStagingSlot) { + const deckDef = getDeckDefFromRobotType(FLEX_ROBOT_TYPE) + const cutoutId = getCutoutIdFromAddressableArea(location, deckDef) + const stagingAreaEquipmentId = Object.values( + additionalEquipmentOnDeck + ).find(({ location }) => location === cutoutId)?.id + if (stagingAreaEquipmentId != null) { + dispatch(deleteDeckFixture(stagingAreaEquipmentId)) + } else { + console.error( + `could not find equipment id for entity in ${location} with cutout id ${cutoutId}` + ) + } + } } const showDuplicateBtn = @@ -303,7 +330,7 @@ export function SlotOverflowMenu( ) : null} { if (matchingLabware != null) { setShowDeleteLabwareModal(true) diff --git a/protocol-designer/src/pages/Designer/DeckSetup/__tests__/DeckSetupTools.test.tsx b/protocol-designer/src/pages/Designer/DeckSetup/__tests__/DeckSetupTools.test.tsx index 5371faed57c..5eab480710e 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/__tests__/DeckSetupTools.test.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/__tests__/DeckSetupTools.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import '@testing-library/jest-dom/vitest' import { fireEvent, screen } from '@testing-library/react' import { @@ -67,6 +67,9 @@ describe('DeckSetupTools', () => { }) vi.mocked(getDismissedHints).mockReturnValue([]) }) + afterEach(() => { + vi.resetAllMocks() + }) it('should render the relevant modules and fixtures for slot D3 on Flex with tabs', () => { render(props) screen.getByText('Add a module') @@ -92,6 +95,14 @@ describe('DeckSetupTools', () => { screen.getByText('mock labware tools') }) it('should clear the slot from all items when the clear cta is called', () => { + vi.mocked(selectors.getZoomedInSlotInfo).mockReturnValue({ + selectedLabwareDefUri: 'mockUri', + selectedNestedLabwareDefUri: 'mockUri', + selectedFixture: null, + selectedModuleModel: null, + selectedSlot: { slot: 'D3', cutout: 'cutoutD3' }, + }) + vi.mocked(getDeckSetupForActiveItem).mockReturnValue({ labware: { labId: { diff --git a/protocol-designer/src/pages/Designer/DeckSetup/__tests__/SlotOverflowMenu.test.tsx b/protocol-designer/src/pages/Designer/DeckSetup/__tests__/SlotOverflowMenu.test.tsx index 2ba0d4df60f..56d5af2f806 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/__tests__/SlotOverflowMenu.test.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/__tests__/SlotOverflowMenu.test.tsx @@ -1,5 +1,5 @@ import type * as React from 'react' -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import '@testing-library/jest-dom/vitest' import { fireEvent, screen } from '@testing-library/react' import { fixture96Plate } from '@opentrons/shared-data' @@ -42,6 +42,8 @@ const render = (props: React.ComponentProps) => { })[0] } +const MOCK_STAGING_AREA_ID = 'MOCK_STAGING_AREA_ID' + describe('SlotOverflowMenu', () => { let props: React.ComponentProps @@ -78,7 +80,11 @@ describe('SlotOverflowMenu', () => { }, }, additionalEquipmentOnDeck: { - fixture: { name: 'stagingArea', id: 'mockId', location: 'cutoutD3' }, + fixture: { + name: 'stagingArea', + id: MOCK_STAGING_AREA_ID, + location: 'cutoutD3', + }, }, }) vi.mocked(EditNickNameModal).mockReturnValue( @@ -87,6 +93,10 @@ describe('SlotOverflowMenu', () => { vi.mocked(labwareIngredSelectors.getLiquidsByLabwareId).mockReturnValue({}) }) + afterEach(() => { + vi.restoreAllMocks() + }) + it('should renders all buttons as enabled and clicking on them calls ctas', () => { render(props) fireEvent.click( @@ -134,4 +144,25 @@ describe('SlotOverflowMenu', () => { expect(mockNavigate).toHaveBeenCalled() expect(vi.mocked(openIngredientSelector)).toHaveBeenCalled() }) + it('deletes the staging area slot and all labware and modules on top of it', () => { + vi.mocked(labwareIngredSelectors.getLiquidsByLabwareId).mockReturnValue({ + labId2: { well1: { '0': { volume: 10 } } }, + }) + render(props) + fireEvent.click(screen.getByRole('button', { name: 'Clear slot' })) + + expect(vi.mocked(deleteDeckFixture)).toHaveBeenCalledOnce() + expect(vi.mocked(deleteDeckFixture)).toHaveBeenCalledWith( + MOCK_STAGING_AREA_ID + ) + expect(vi.mocked(deleteContainer)).toHaveBeenCalledTimes(2) + expect(vi.mocked(deleteContainer)).toHaveBeenNthCalledWith(1, { + labwareId: 'labId', + }) + expect(vi.mocked(deleteContainer)).toHaveBeenNthCalledWith(2, { + labwareId: 'labId2', + }) + expect(vi.mocked(deleteModule)).toHaveBeenCalledOnce() + expect(vi.mocked(deleteModule)).toHaveBeenCalledWith('modId') + }) }) diff --git a/shared-data/js/helpers/index.ts b/shared-data/js/helpers/index.ts index 4c3fde2c91e..57cb24e31ee 100644 --- a/shared-data/js/helpers/index.ts +++ b/shared-data/js/helpers/index.ts @@ -10,6 +10,7 @@ import type { RobotType, ThermalAdapterName, } from '../types' +import type { AddressableAreaName, CutoutId } from '../../deck/types/schemaV5' export { getWellNamePerMultiTip } from './getWellNamePerMultiTip' export { getWellTotalVolume } from './getWellTotalVolume' @@ -373,3 +374,28 @@ export const getDeckDefFromRobotType = ( ? standardFlexDeckDef : standardOt2DeckDef } + +export const getCutoutIdFromAddressableArea = ( + addressableAreaName: string, + deckDefinition: DeckDefinition +): CutoutId | null => { + /** + * Given an addressable area name, returns the cutout ID associated with it, or null if there is none + */ + + for (const cutoutFixture of deckDefinition.cutoutFixtures) { + for (const [cutoutId, providedAreas] of Object.entries( + cutoutFixture.providesAddressableAreas + ) as Array<[CutoutId, AddressableAreaName[]]>) { + if (providedAreas.includes(addressableAreaName as AddressableAreaName)) { + return cutoutId + } + } + } + + console.error( + `${addressableAreaName} is not provided by any cutout fixtures in deck definition ${deckDefinition.otId}` + ) + + return null +}