diff --git a/components/src/atoms/ListButton/ListButtonChildren/ListButtonAccordionContainer.tsx b/components/src/atoms/ListButton/ListButtonChildren/ListButtonAccordionContainer.tsx index 99fde7dd81f..85f76f901b2 100644 --- a/components/src/atoms/ListButton/ListButtonChildren/ListButtonAccordionContainer.tsx +++ b/components/src/atoms/ListButton/ListButtonChildren/ListButtonAccordionContainer.tsx @@ -16,7 +16,7 @@ export function ListButtonAccordionContainer( const { id, children } = props return ( - + {children} ) diff --git a/protocol-designer/src/assets/localization/en/starting_deck_state.json b/protocol-designer/src/assets/localization/en/starting_deck_state.json index 31be366c9d7..fcf88c2866e 100644 --- a/protocol-designer/src/assets/localization/en/starting_deck_state.json +++ b/protocol-designer/src/assets/localization/en/starting_deck_state.json @@ -1,6 +1,6 @@ { "adapter_compatible_lab": "Adapter compatible labware", - "adapter": "Adapter", + "adapter": "Adapters", "add_fixture": "Add a fixture", "add_hardware_labware": "Add hardware/labware", "add_hw_lw": "Add hardware/labware", @@ -9,7 +9,7 @@ "add_module": "Add a module", "add_rest": "Add labware and liquids to complete deck setup", "alter_pause": "You may also need to alter the time you pause while your magnet is engaged.", - "aluminumBlock": "Aluminum block", + "aluminumBlock": "Aluminum blocks", "clear_labware": "Clear labware", "clear_slot": "Clear slot", "clear": "Clear", @@ -47,16 +47,16 @@ "protocol_starting_deck": "Protocol starting deck", "read_more_gen1_gen2": "Read more about the differences between GEN1 and GEN2 Magnetic Modules", "rename_lab": "Rename labware", - "reservoir": "Reservoir", + "reservoir": "Reservoirs", "shift_click_to_select_all": "Shift + Click to select all", "starting_deck_state": "Starting deck state", "tc_slots_occupied_flex": "The Thermocycler needs slots A1 and B1. Slot A1 is occupied", "tc_slots_occupied_ot2": "The Thermocycler needs slots 7, 8, 10, and 11. One or more of those slots is occupied", - "tipRack": "Tip rack", + "tipRack": "Tip racks", "trash_required": "A trash bin or waste chute is required", - "tubeRack": "Tube rack", + "tubeRack": "Tube racks", "untitled_protocol": "Untitled protocol", "upload_custom_labware": "Upload custom labware", "we_added_hardware": "We've added your deck hardware!", - "wellPlate": "Well plate" + "wellPlate": "Well plates" } diff --git a/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx b/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx index 4e2db1df4b8..62a5f92d46e 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupTools.tsx @@ -52,11 +52,11 @@ import { getDismissedHints } from '../../../tutorial/selectors' import { createContainerAboveModule } from '../../../step-forms/actions/thunks' import { ConfirmDeleteStagingAreaModal } from '../../../organisms' import { BUTTON_LINK_STYLE } from '../../../atoms' -import { FIXTURES, MOAM_MODELS } from './constants' import { getSlotInformation } from '../utils' -import { getModuleModelsBySlot, getDeckErrors } from './utils' -import { MagnetModuleChangeContent } from './MagnetModuleChangeContent' +import { ALL_ORDERED_CATEGORIES, FIXTURES, MOAM_MODELS } from './constants' import { LabwareTools } from './LabwareTools' +import { MagnetModuleChangeContent } from './MagnetModuleChangeContent' +import { getModuleModelsBySlot, getDeckErrors } from './utils' import type { ModuleModel } from '@opentrons/shared-data' import type { ThunkDispatch } from '../../../types' @@ -71,6 +71,8 @@ interface DeckSetupToolsProps { } | null } +export type CategoryExpand = Record + export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { const { onCloseClick, setHoveredLabware, onDeckProps } = props const { t, i18n } = useTranslation(['starting_deck_state', 'shared']) @@ -117,6 +119,28 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { const [tab, setTab] = useState<'hardware' | 'labware'>( moduleModels?.length === 0 || slot === 'offDeck' ? 'labware' : 'hardware' ) + + const setAllCategories = (state: boolean): Record => + ALL_ORDERED_CATEGORIES.reduce>( + (acc, category) => ({ ...acc, [category]: state }), + {} + ) + const allCategoriesExpanded = setAllCategories(true) + const allCategoriesCollapsed = setAllCategories(false) + const [ + areCategoriesExpanded, + setAreCategoriesExpanded, + ] = useState(allCategoriesCollapsed) + const [searchTerm, setSearchTerm] = useState('') + + useEffect(() => { + if (searchTerm !== '') { + setAreCategoriesExpanded(allCategoriesExpanded) + } else { + setAreCategoriesExpanded(allCategoriesCollapsed) + } + }, [searchTerm]) + const hasMagneticModule = Object.values(deckSetup.modules).some( module => module.type === MAGNETIC_MODULE_TYPE ) @@ -124,6 +148,12 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { Object.values(deckSetup.modules).find(module => module.slot === slot) ?.model === MAGNETIC_MODULE_V1 + const handleCollapseAllCategories = (): void => { + setAreCategoriesExpanded(allCategoriesCollapsed) + } + const handleResetSearchTerm = (): void => { + setSearchTerm('') + } const changeModuleWarning = useBlockingHint({ hintKey: 'change_magnet_module_model', handleCancel: () => { @@ -207,6 +237,11 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { ) } + const handleResetLabwareTools = (): void => { + handleCollapseAllCategories() + handleResetSearchTerm() + } + const handleClear = (): void => { onDeckProps?.setHoveredModule(null) onDeckProps?.setHoveredFixture(null) @@ -242,7 +277,11 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { } } handleResetToolbox() + handleResetLabwareTools() setSelectedHardware(null) + if (selectedHardware != null) { + setTab('hardware') + } } const handleConfirm = (): void => { // clear entities first before recreating them @@ -548,7 +587,15 @@ export function DeckSetupTools(props: DeckSetupToolsProps): JSX.Element | null { )} ) : ( - + )} diff --git a/protocol-designer/src/pages/Designer/DeckSetup/LabwareTools.tsx b/protocol-designer/src/pages/Designer/DeckSetup/LabwareTools.tsx index dec0d114f83..be4f457429e 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/LabwareTools.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/LabwareTools.tsx @@ -1,4 +1,4 @@ -import * as React from 'react' +import { Fragment, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import reduce from 'lodash/reduce' import styled from 'styled-components' @@ -49,7 +49,11 @@ import { selectLabware, selectNestedLabware, } from '../../../labware-ingred/actions' -import { ORDERED_CATEGORIES } from './constants' +import { + ALL_ORDERED_CATEGORIES, + CUSTOM_CATEGORY, + ORDERED_CATEGORIES, +} from './constants' import { getLabwareIsRecommended, getLabwareCompatibleWithAdapter, @@ -59,8 +63,8 @@ import type { DeckSlotId, LabwareDefinition2 } from '@opentrons/shared-data' import type { ModuleOnDeck } from '../../../step-forms' import type { ThunkDispatch } from '../../../types' import type { LabwareDefByDefURI } from '../../../labware-defs' +import type { CategoryExpand } from './DeckSetupTools' -const CUSTOM_CATEGORY = 'custom' const STANDARD_X_DIMENSION = 127.75 const STANDARD_Y_DIMENSION = 85.48 const PLATE_READER_LOADNAME = @@ -68,10 +72,28 @@ const PLATE_READER_LOADNAME = interface LabwareToolsProps { slot: DeckSlotId setHoveredLabware: (defUri: string | null) => void + searchTerm: string + setSearchTerm: React.Dispatch> + areCategoriesExpanded: CategoryExpand + setAreCategoriesExpanded: React.Dispatch> + handleReset: () => void +} + +interface LabwareInfo { + uri: string + def: LabwareDefinition2 } export function LabwareTools(props: LabwareToolsProps): JSX.Element { - const { slot, setHoveredLabware } = props + const { + slot, + setHoveredLabware, + searchTerm, + setSearchTerm, + areCategoriesExpanded, + setAreCategoriesExpanded, + handleReset, + } = props const { t } = useTranslation(['starting_deck_state', 'shared']) const robotType = useSelector(getRobotType) const dispatch = useDispatch>() @@ -87,10 +109,6 @@ export function LabwareTools(props: LabwareToolsProps): JSX.Element { selectedModuleModel, selectedNestedLabwareDefUri, } = zoomedInSlotInfo - const [selectedCategory, setSelectedCategory] = React.useState( - null - ) - const [searchTerm, setSearchTerm] = React.useState('') const searchFilter = (termToCheck: string): boolean => termToCheck.toLowerCase().includes(searchTerm.toLowerCase()) @@ -101,7 +119,7 @@ export function LabwareTools(props: LabwareToolsProps): JSX.Element { const initialModules: ModuleOnDeck[] = Object.keys(modulesById).map( moduleId => modulesById[moduleId] ) - const [filterRecommended, setFilterRecommended] = React.useState( + const [filterRecommended, setFilterRecommended] = useState( moduleType != null ) // for OT-2 usage only due to H-S collisions @@ -110,11 +128,11 @@ export function LabwareTools(props: LabwareToolsProps): JSX.Element { hardwareModule.type === HEATERSHAKER_MODULE_TYPE && getAreSlotsHorizontallyAdjacent(hardwareModule.slot, slot) ) - const [filterHeight, setFilterHeight] = React.useState( + const [filterHeight, setFilterHeight] = useState( robotType === OT2_ROBOT_TYPE ? isNextToHeaterShaker : false ) - const getLabwareCompatible = React.useCallback( + const getLabwareCompatible = useCallback( (def: LabwareDefinition2) => { // assume that custom (non-standard) labware is (potentially) compatible if (moduleType == null || !getLabwareDefIsStandard(def)) { @@ -125,7 +143,7 @@ export function LabwareTools(props: LabwareToolsProps): JSX.Element { [moduleType] ) - const getIsLabwareFiltered = React.useCallback( + const getIsLabwareFiltered = useCallback( (labwareDef: LabwareDefinition2) => { const { dimensions, parameters } = labwareDef const { xDimension, yDimension } = dimensions @@ -155,11 +173,8 @@ export function LabwareTools(props: LabwareToolsProps): JSX.Element { }, [filterRecommended, filterHeight, getLabwareCompatible, moduleType, slot] ) - const customLabwareURIs: string[] = React.useMemo( - () => Object.keys(customLabwareDefs), - [customLabwareDefs] - ) - const labwareByCategory = React.useMemo(() => { + + const labwareByCategory = useMemo(() => { return reduce< LabwareDefByDefURI, { [category: string]: LabwareDefinition2[] } @@ -184,28 +199,51 @@ export function LabwareTools(props: LabwareToolsProps): JSX.Element { ) }, [permittedTipracks]) - const populatedCategories: { [category: string]: boolean } = React.useMemo( + const filteredLabwareByCategory: Record = useMemo( () => - ORDERED_CATEGORIES.reduce((acc, category) => { + ALL_ORDERED_CATEGORIES.reduce((acc, category) => { + if (category === 'custom') { + return { + ...acc, + [category]: filterRecommended + ? [] + : Object.entries(customLabwareDefs).reduce( + (accInner, [uri, def]) => { + return searchFilter(def.metadata.displayName) + ? [...accInner, { uri, def }] + : accInner + }, + [] + ), + } + } const isDeckLocationCategory = slot === 'offDeck' ? category !== 'adapter' : true - return category in labwareByCategory && - isDeckLocationCategory && - labwareByCategory[category].some(lw => - searchFilter(lw.metadata.displayName) - ) - ? { - ...acc, - [category]: labwareByCategory[category].some( - def => !getIsLabwareFiltered(def) - ), - } - : acc + if (!(category in labwareByCategory) || !isDeckLocationCategory) { + return { ...acc, [category]: [] } + } + return { + ...acc, + [category]: labwareByCategory[category].reduce( + (accInner, def) => { + return searchFilter(def.metadata.displayName) && + !getIsLabwareFiltered(def) + ? [...accInner, { def, uri: getLabwareDefURI(def) }] + : accInner + }, + [] + ), + } }, {}), [labwareByCategory, getIsLabwareFiltered, searchTerm] ) - const handleCategoryClick = (category: string): void => { - setSelectedCategory(selectedCategory === category ? null : category) + + const handleCategoryClick = (category: string, expand?: boolean): void => { + const updatedExpandState = { + ...areCategoriesExpanded, + [category]: expand ?? !areCategoriesExpanded[category], + } + setAreCategoriesExpanded(updatedExpandState) } return ( @@ -223,9 +261,7 @@ export function LabwareTools(props: LabwareToolsProps): JSX.Element { size="medium" leftIcon="search" showDeleteIcon - onDelete={() => { - setSearchTerm('') - }} + onDelete={handleReset} /> {moduleType != null || (isNextToHeaterShaker && robotType === OT2_ROBOT_TYPE) ? ( @@ -253,7 +289,7 @@ export function LabwareTools(props: LabwareToolsProps): JSX.Element { gridGap={SPACING.spacing4} paddingTop={SPACING.spacing8} > - {customLabwareURIs.length === 0 ? null : ( + {filteredLabwareByCategory[CUSTOM_CATEGORY].length > 0 ? ( - {customLabwareURIs.map((labwareURI, index) => ( - { - setHoveredLabware(null) - }} - setHovered={() => { - setHoveredLabware(labwareURI) - }} - buttonValue={labwareURI} - onChange={e => { - e.stopPropagation() - dispatch(selectLabware({ labwareDefUri: labwareURI })) - }} - isSelected={labwareURI === selectedLabwareDefUri} - /> - ))} + {filteredLabwareByCategory[CUSTOM_CATEGORY].map( + ({ uri }, index) => ( + { + setHoveredLabware(null) + }} + setHovered={() => { + setHoveredLabware(uri) + }} + buttonValue={uri} + onChange={e => { + e.stopPropagation() + dispatch(selectLabware({ labwareDefUri: uri })) + }} + isSelected={uri === selectedLabwareDefUri} + /> + ) + )} - )} + ) : null} {ORDERED_CATEGORIES.map(category => { - const isPopulated = populatedCategories[category] - if (isPopulated) { + if (filteredLabwareByCategory[category].length > 0) { return ( - {labwareByCategory[category]?.map((labwareDef, index) => { - const isFiltered = getIsLabwareFiltered(labwareDef) - const labwareURI = getLabwareDefURI(labwareDef) - const loadName = labwareDef.parameters.loadName - const isMatch = searchFilter( - labwareDef.metadata.displayName - ) - if (!isFiltered && isMatch) { - return ( - + {filteredLabwareByCategory[category]?.map( + ({ def, uri }, index) => { + const loadName = def.parameters.loadName + + return searchFilter(def.metadata.displayName) && + !getIsLabwareFiltered(def) ? ( + { setHoveredLabware(null) }} setHovered={() => { - setHoveredLabware(labwareURI) + setHoveredLabware(uri) }} id={`${index}_${category}_${loadName}`} - buttonText={labwareDef.metadata.displayName} - buttonValue={labwareURI} + buttonText={def.metadata.displayName} + buttonValue={uri} onChange={e => { e.stopPropagation() dispatch( selectLabware({ labwareDefUri: - labwareURI === selectedLabwareDefUri + uri === selectedLabwareDefUri ? null - : labwareURI, + : uri, }) ) // reset the nested labware def uri in case it is not compatible @@ -346,10 +376,10 @@ export function LabwareTools(props: LabwareToolsProps): JSX.Element { }) ) }} - isSelected={labwareURI === selectedLabwareDefUri} + isSelected={uri === selectedLabwareDefUri} /> - {labwareURI === selectedLabwareDefUri && + {uri === selectedLabwareDefUri && getLabwareCompatibleWithAdapter(loadName) ?.length > 0 && ( {has96Channel && loadName === ADAPTER_96_CHANNEL @@ -440,10 +468,10 @@ export function LabwareTools(props: LabwareToolsProps): JSX.Element { )} - - ) + + ) : null } - })} + )} @@ -464,8 +492,8 @@ export function LabwareTools(props: LabwareToolsProps): JSX.Element { data-testid="customLabwareInput" type="file" onChange={e => { - setSelectedCategory(CUSTOM_CATEGORY) dispatch(createCustomLabwareDef(e)) + handleCategoryClick(CUSTOM_CATEGORY, true) }} /> diff --git a/protocol-designer/src/pages/Designer/DeckSetup/__tests__/LabwareTools.test.tsx b/protocol-designer/src/pages/Designer/DeckSetup/__tests__/LabwareTools.test.tsx index b4d444a1cf3..479724f3527 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/__tests__/LabwareTools.test.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/__tests__/LabwareTools.test.tsx @@ -20,10 +20,6 @@ import { selectors } from '../../../../labware-ingred/selectors' import { createCustomLabwareDef } from '../../../../labware-defs/actions' import { getCustomLabwareDefsByURI } from '../../../../labware-defs/selectors' import { getRobotType } from '../../../../file-data/selectors' -import { - selectLabware, - selectNestedLabware, -} from '../../../../labware-ingred/actions' import { LabwareTools } from '../LabwareTools' import type { LabwareDefinition2, PipetteV2Specs } from '@opentrons/shared-data' @@ -48,6 +44,11 @@ describe('LabwareTools', () => { props = { slot: 'D3', setHoveredLabware: vi.fn(), + searchTerm: '', + setSearchTerm: vi.fn(), + areCategoriesExpanded: {}, + setAreCategoriesExpanded: vi.fn(), + handleReset: vi.fn(), } vi.mocked(getCustomLabwareDefsByURI).mockReturnValue({}) vi.mocked(getRobotType).mockReturnValue(FLEX_ROBOT_TYPE) @@ -80,18 +81,14 @@ describe('LabwareTools', () => { it('renders an empty slot with all the labware options', () => { render(props) screen.getByText('Add labware') - screen.getByText('Tube rack') - screen.getByText('Well plate') - screen.getByText('Reservoir') - screen.getByText('Aluminum block') - screen.getByText('Adapter') + screen.getByText('Tube racks') + screen.getByText('Well plates') + screen.getByText('Reservoirs') + screen.getByText('Aluminum blocks') + screen.getByText('Adapters') // click and expand well plate accordion fireEvent.click(screen.getAllByTestId('ListButton_noActive')[1]) - fireEvent.click( - screen.getByRole('label', { name: 'Corning 384 Well Plate' }) - ) - // set labware - expect(vi.mocked(selectLabware)).toHaveBeenCalled() + expect(props.setAreCategoriesExpanded).toBeCalled() }) it('renders deck slot and selects an adapter and labware', () => { vi.mocked(selectors.getZoomedInSlotInfo).mockReturnValue({ @@ -102,23 +99,10 @@ describe('LabwareTools', () => { selectedSlot: { slot: 'D3', cutout: 'cutoutD3' }, }) render(props) - screen.getByText('Adapter') + screen.getByText('Adapters') fireEvent.click(screen.getAllByTestId('ListButton_noActive')[4]) // set adapter - fireEvent.click( - screen.getByRole('label', { - name: 'Fixture Opentrons Universal Flat Heater-Shaker Adapter', - }) - ) - // set labware - screen.getByText('Adapter compatible labware') - screen.getByText('Fixture Corning 96 Well Plate 360 µL Flat') - fireEvent.click( - screen.getByRole('label', { - name: 'Fixture Corning 96 Well Plate 360 µL Flat', - }) - ) - expect(vi.mocked(selectNestedLabware)).toHaveBeenCalled() + expect(props.setAreCategoriesExpanded).toBeCalled() }) it('renders the custom labware flow', () => { diff --git a/protocol-designer/src/pages/Designer/DeckSetup/constants.ts b/protocol-designer/src/pages/Designer/DeckSetup/constants.ts index 53571367f8b..e1acb64424d 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/constants.ts +++ b/protocol-designer/src/pages/Designer/DeckSetup/constants.ts @@ -56,6 +56,8 @@ export const ORDERED_CATEGORIES: string[] = [ 'aluminumBlock', 'adapter', ] +export const CUSTOM_CATEGORY = 'custom' +export const ALL_ORDERED_CATEGORIES = [CUSTOM_CATEGORY, ...ORDERED_CATEGORIES] export const RECOMMENDED_LABWARE_BY_MODULE: { [K in ModuleType]: string[] } = { [TEMPERATURE_MODULE_TYPE]: [ diff --git a/protocol-designer/src/pages/Designer/DeckSetup/utils.ts b/protocol-designer/src/pages/Designer/DeckSetup/utils.ts index 524a19dfc1c..7a1c7c09be3 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/utils.ts +++ b/protocol-designer/src/pages/Designer/DeckSetup/utils.ts @@ -118,18 +118,16 @@ export const getLabwareIsRecommended = ( ): boolean => { // special-casing the thermocycler module V2 recommended labware since the thermocyclerModuleTypes // have different recommended labware - const moduleType = moduleModel != null ? getModuleType(moduleModel) : null - if (moduleModel === THERMOCYCLER_MODULE_V2) { - return ( - def.parameters.loadName === 'opentrons_96_wellplate_200ul_pcr_full_skirt' - ) - } else { - return moduleType != null - ? RECOMMENDED_LABWARE_BY_MODULE[moduleType].includes( - def.parameters.loadName - ) - : false + if (moduleModel == null) { + // permissive early exit if no module passed + return true } + const moduleType = getModuleType(moduleModel) + return moduleModel === THERMOCYCLER_MODULE_V2 + ? def.parameters.loadName === 'opentrons_96_wellplate_200ul_pcr_full_skirt' + : RECOMMENDED_LABWARE_BY_MODULE[moduleType].includes( + def.parameters.loadName + ) } export const getLabwareCompatibleWithAdapter = (