From f8def7767d6a237c64cb18560fd24c02c45c0a17 Mon Sep 17 00:00:00 2001 From: Nick Diehl <47604184+ncdiehl11@users.noreply.github.com> Date: Wed, 18 Dec 2024 09:35:48 -0500 Subject: [PATCH] feat(protocol-designer,-shared-data): add liquid class scaffolding to PD (#17126) This PR introduces initial work for liquid class support in protocol designer. I create a full liquid class interface based on the API's Pydantic model. I also add utilities for getting all liquid class definitions and a feature flag to hide liquid classes from the UI. Lastly, I add some of the basic functionality for assigning a liquid class to a defined liquid, and displaying that liquid class in LiquidCard. A liquid class assigned to a defined liquid will be accessible through extended existing selectors (allIngredientNamesIds) for step forms. closes AUTH-965 closes AUTH-968 --- .../assets/localization/en/feature_flags.json | 4 + .../src/assets/localization/en/liquids.json | 4 + .../src/feature-flags/reducers.ts | 2 + .../src/feature-flags/selectors.ts | 4 + protocol-designer/src/feature-flags/types.ts | 2 + .../__tests__/ingredients.test.ts | 2 + .../src/labware-ingred/selectors.ts | 1 + protocol-designer/src/labware-ingred/types.ts | 19 ++- protocol-designer/src/liquid-defs/utils.ts | 15 +++ .../AssignLiquidsModal/LiquidCard.tsx | 17 ++- .../AssignLiquidsModal/LiquidToolbox.tsx | 5 + .../organisms/DefineLiquidsModal/index.tsx | 65 +++++++++- .../__tests__/MaterialsListModal.test.tsx | 1 + .../__tests__/LiquidsOverflowMenu.test.tsx | 1 + .../ProtocolOverview/LiquidDefinitions.tsx | 111 ++++++++++++------ .../__tests__/LiquidDefinitions.test.tsx | 5 + shared-data/js/index.ts | 1 + shared-data/js/liquidClasses.ts | 8 ++ shared-data/js/types.ts | 100 ++++++++++++++++ 19 files changed, 312 insertions(+), 55 deletions(-) create mode 100644 protocol-designer/src/liquid-defs/utils.ts create mode 100644 shared-data/js/liquidClasses.ts diff --git a/protocol-designer/src/assets/localization/en/feature_flags.json b/protocol-designer/src/assets/localization/en/feature_flags.json index 9a13b327be8..92a074088ba 100644 --- a/protocol-designer/src/assets/localization/en/feature_flags.json +++ b/protocol-designer/src/assets/localization/en/feature_flags.json @@ -31,5 +31,9 @@ "OT_PD_ENABLE_REACT_SCAN": { "title": "Enable React Scan", "description": "Enable React Scan support for components rendering check" + }, + "OT_PD_ENABLE_LIQUID_CLASSES": { + "title": "Enable liquid classes", + "description": "Enable liquid classes support" } } diff --git a/protocol-designer/src/assets/localization/en/liquids.json b/protocol-designer/src/assets/localization/en/liquids.json index ebd4800dacb..36a31cf8116 100644 --- a/protocol-designer/src/assets/localization/en/liquids.json +++ b/protocol-designer/src/assets/localization/en/liquids.json @@ -10,6 +10,10 @@ "display_color": "Color", "liquid_volume": "Liquid volume by well", "liquid": "Liquid", + "liquid_class": { + "title": "Liquid class", + "tooltip": "Applies predefined pipetting settings to transfer and mix steps using this liquid" + }, "liquids_added": "Liquids added", "liquids": "Liquids", "microliters": "µL", diff --git a/protocol-designer/src/feature-flags/reducers.ts b/protocol-designer/src/feature-flags/reducers.ts index 42782c88479..bcffd39c5e7 100644 --- a/protocol-designer/src/feature-flags/reducers.ts +++ b/protocol-designer/src/feature-flags/reducers.ts @@ -30,6 +30,8 @@ const initialFlags: Flags = { OT_PD_ENABLE_HOT_KEYS_DISPLAY: process.env.OT_PD_ENABLE_HOT_KEYS_DISPLAY === '1' || true, OT_PD_ENABLE_REACT_SCAN: process.env.OT_PD_ENABLE_REACT_SCAN === '1' || false, + OT_PD_ENABLE_LIQUID_CLASSES: + process.env.OT_PD_ENABLE_REACT_SCAN === '1' || false, } // @ts-expect-error(sa, 2021-6-10): cannot use string literals as action type // TODO IMMEDIATELY: refactor this to the old fashioned way if we cannot have type safety: https://github.com/redux-utilities/redux-actions/issues/282#issuecomment-595163081 diff --git a/protocol-designer/src/feature-flags/selectors.ts b/protocol-designer/src/feature-flags/selectors.ts index 72b25fca895..6b8a70f8b30 100644 --- a/protocol-designer/src/feature-flags/selectors.ts +++ b/protocol-designer/src/feature-flags/selectors.ts @@ -45,3 +45,7 @@ export const getEnableReactScan: Selector = createSelector( getFeatureFlagData, flags => flags.OT_PD_ENABLE_REACT_SCAN ?? false ) +export const getEnableLiquidClasses: Selector = createSelector( + getFeatureFlagData, + flags => flags.OT_PD_ENABLE_LIQUID_CLASSES ?? false +) diff --git a/protocol-designer/src/feature-flags/types.ts b/protocol-designer/src/feature-flags/types.ts index 84bab18e474..6840786d149 100644 --- a/protocol-designer/src/feature-flags/types.ts +++ b/protocol-designer/src/feature-flags/types.ts @@ -36,6 +36,7 @@ export type FlagTypes = | 'OT_PD_ENABLE_RETURN_TIP' | 'OT_PD_ENABLE_HOT_KEYS_DISPLAY' | 'OT_PD_ENABLE_REACT_SCAN' + | 'OT_PD_ENABLE_LIQUID_CLASSES' // flags that are not in this list only show in prerelease mode export const userFacingFlags: FlagTypes[] = [ 'OT_PD_DISABLE_MODULE_RESTRICTIONS', @@ -49,5 +50,6 @@ export const allFlags: FlagTypes[] = [ 'OT_PD_ENABLE_COMMENT', 'OT_PD_ENABLE_RETURN_TIP', 'OT_PD_ENABLE_REACT_SCAN', + 'OT_PD_ENABLE_LIQUID_CLASSES', ] export type Flags = Partial> diff --git a/protocol-designer/src/labware-ingred/__tests__/ingredients.test.ts b/protocol-designer/src/labware-ingred/__tests__/ingredients.test.ts index b771cd16bbf..c81883001ac 100644 --- a/protocol-designer/src/labware-ingred/__tests__/ingredients.test.ts +++ b/protocol-designer/src/labware-ingred/__tests__/ingredients.test.ts @@ -19,6 +19,7 @@ describe('DUPLICATE_LABWARE action', () => { wellDetailsByLocation: null, concentration: '50 mol/ng', description: '', + liquidClass: null, displayColor: '#b925ff', serialize: false, }, @@ -27,6 +28,7 @@ describe('DUPLICATE_LABWARE action', () => { wellDetailsByLocation: null, concentration: '100%', description: '', + liquidClass: null, displayColor: '#ffd600', serialize: false, }, diff --git a/protocol-designer/src/labware-ingred/selectors.ts b/protocol-designer/src/labware-ingred/selectors.ts index eb7767af225..25ee8bc0966 100644 --- a/protocol-designer/src/labware-ingred/selectors.ts +++ b/protocol-designer/src/labware-ingred/selectors.ts @@ -113,6 +113,7 @@ const allIngredientNamesIds: Selector< ingredientId: ingredId, name: ingreds[ingredId].name, displayColor: ingreds[ingredId].displayColor, + liquidClass: ingreds[ingredId].liquidClass, })) }) const getLabwareSelectionMode: Selector = createSelector( diff --git a/protocol-designer/src/labware-ingred/types.ts b/protocol-designer/src/labware-ingred/types.ts index 6e9567722f3..6b7628735d8 100644 --- a/protocol-designer/src/labware-ingred/types.ts +++ b/protocol-designer/src/labware-ingred/types.ts @@ -19,23 +19,22 @@ export interface WellContents { selected?: boolean maxVolume?: number } -export type ContentsByWell = { - [wellName: string]: WellContents -} | null -export interface WellContentsByLabware { - [labwareId: string]: ContentsByWell -} +export type ContentsByWell = Record | null +export type WellContentsByLabware = Record // ==== INGREDIENTS ==== +// TODO(ND: 12/17/2024): add migration for liquids in >8.3.0 export type OrderedLiquids = Array<{ ingredientId: string - name: string | null | undefined - displayColor: string | null | undefined + name?: string | null + displayColor?: string | null + liquidClass?: string | null }> // TODO: Ian 2018-10-15 audit & rename these confusing types export interface LiquidGroup { - name: string | null | undefined - description: string | null | undefined + name: string | null + description: string | null displayColor: string + liquidClass: string | null serialize: boolean } export type IngredInputs = LiquidGroup & { diff --git a/protocol-designer/src/liquid-defs/utils.ts b/protocol-designer/src/liquid-defs/utils.ts new file mode 100644 index 00000000000..cb9bc9398c9 --- /dev/null +++ b/protocol-designer/src/liquid-defs/utils.ts @@ -0,0 +1,15 @@ +import { getAllLiquidClassDefs } from '@opentrons/shared-data' + +const liquidClassDefs = getAllLiquidClassDefs() +export const getLiquidClassDisplayName = ( + liquidClass: string | null +): string | null => { + if (liquidClass == null) { + return null + } + if (!(liquidClass in liquidClassDefs)) { + console.warn(`Liquid class ${liquidClass} not found`) + return null + } + return liquidClassDefs[liquidClass].displayName +} diff --git a/protocol-designer/src/organisms/AssignLiquidsModal/LiquidCard.tsx b/protocol-designer/src/organisms/AssignLiquidsModal/LiquidCard.tsx index a422b4b210e..4f33b968db4 100644 --- a/protocol-designer/src/organisms/AssignLiquidsModal/LiquidCard.tsx +++ b/protocol-designer/src/organisms/AssignLiquidsModal/LiquidCard.tsx @@ -20,6 +20,7 @@ import { } from '@opentrons/components' import { LINE_CLAMP_TEXT_STYLE } from '../../atoms' +import { getEnableLiquidClasses } from '../../feature-flags/selectors' import { removeWellsContents } from '../../labware-ingred/actions' import { selectors as labwareIngredSelectors } from '../../labware-ingred/selectors' import { getLabwareEntities } from '../../step-forms/selectors' @@ -34,7 +35,7 @@ interface LiquidCardProps { export function LiquidCard(props: LiquidCardProps): JSX.Element { const { info } = props - const { name, color, liquidIndex } = info + const { name, color, liquidClassDisplayName, liquidIndex } = info const { t } = useTranslation('liquids') const dispatch = useDispatch() const [isExpanded, setIsExpanded] = useState(false) @@ -49,6 +50,7 @@ export function LiquidCard(props: LiquidCardProps): JSX.Element { const allWellContentsForActiveItem = useSelector( wellContentsSelectors.getAllWellContentsForActiveItem ) + const enableLiquidClasses = useSelector(getEnableLiquidClasses) const wellContents = allWellContentsForActiveItem != null && labwareId != null ? allWellContentsForActiveItem[labwareId] @@ -104,13 +106,24 @@ export function LiquidCard(props: LiquidCardProps): JSX.Element { > - + {name} + {liquidClassDisplayName != null && enableLiquidClasses ? ( + + ) : null} { if (liquidGroupId != null) { @@ -107,13 +117,14 @@ export function DefineLiquidsModal( const initialValues: LiquidEditFormValues = { name: selectedIngredFields?.name ?? '', displayColor: selectedIngredFields?.displayColor ?? swatchColors(liquidId), + liquidClass: selectedIngredFields?.liquidClass ?? '', description: selectedIngredFields?.description ?? '', serialize: selectedIngredFields?.serialize ?? false, } const { handleSubmit, - formState: { errors, touchedFields }, + formState, control, watch, setValue, @@ -125,12 +136,15 @@ export function DefineLiquidsModal( }) const name = watch('name') const color = watch('displayColor') + const liquidClass = watch('liquidClass') + const { errors, touchedFields } = formState const handleLiquidEdits = (values: LiquidEditFormValues): void => { saveForm({ name: values.name, displayColor: values.displayColor, - description: values.description ?? null, + liquidClass: values.liquidClass ? values.liquidClass : null, + description: values.description ? values.description : null, serialize: values.serialize ?? false, }) } @@ -142,6 +156,15 @@ export function DefineLiquidsModal( return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(alpha)}` } + const liquidClassOptions = [ + { name: 'Choose an option', value: '' }, + ...Object.entries(liquidClassDefs).map( + ([liquidClassDefName, { displayName }]) => { + return { name: displayName, value: liquidClassDefName } + } + ), + ] + return ( { @@ -182,6 +205,7 @@ export function DefineLiquidsModal( left="4.375rem" top="4.6875rem" ref={chooseColorWrapperRef} + zIndex={2} > - + + {enableLiquidClasses ? ( + + ( + value === liquidClass + ) ?? liquidClassOptions[0] + } + onClick={value => { + field.onChange(value) + setValue('liquidClass', value) + }} + /> + )} + /> + + ) : null} { ingredientId: mockId, name: 'mockName', displayColor: 'mockDisplayColor', + liquidClass: null, }, ], } diff --git a/protocol-designer/src/pages/Designer/__tests__/LiquidsOverflowMenu.test.tsx b/protocol-designer/src/pages/Designer/__tests__/LiquidsOverflowMenu.test.tsx index 45fbebe5494..6e646fb6db7 100644 --- a/protocol-designer/src/pages/Designer/__tests__/LiquidsOverflowMenu.test.tsx +++ b/protocol-designer/src/pages/Designer/__tests__/LiquidsOverflowMenu.test.tsx @@ -42,6 +42,7 @@ describe('SlotOverflowMenu', () => { displayColor: 'mockColor', name: 'mockname', ingredientId: '0', + liquidClass: null, }, ]) }) diff --git a/protocol-designer/src/pages/ProtocolOverview/LiquidDefinitions.tsx b/protocol-designer/src/pages/ProtocolOverview/LiquidDefinitions.tsx index 378f14e13ad..e9da36c4bae 100644 --- a/protocol-designer/src/pages/ProtocolOverview/LiquidDefinitions.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/LiquidDefinitions.tsx @@ -1,4 +1,5 @@ import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' import { ALIGN_CENTER, DIRECTION_COLUMN, @@ -9,10 +10,42 @@ import { ListItemDescriptor, SPACING, StyledText, + Tag, } from '@opentrons/components' import { LINE_CLAMP_TEXT_STYLE } from '../../atoms' +import { getEnableLiquidClasses } from '../../feature-flags/selectors' +import { getLiquidClassDisplayName } from '../../liquid-defs/utils' -import type { AllIngredGroupFields } from '../../labware-ingred/types' +import type { + AllIngredGroupFields, + IngredInputs, +} from '../../labware-ingred/types' + +const getLiquidDescription = ( + liquid: IngredInputs, + enableLiquidClasses: boolean +): JSX.Element | null => { + const { description, liquidClass } = liquid + const liquidClassDisplayName = getLiquidClassDisplayName(liquidClass) + const liquidClassInfo = + !enableLiquidClasses || liquidClassDisplayName == null ? null : ( + + ) + + return liquidClassInfo == null && !description ? null : ( + + {description ? ( + + {description} + + ) : null} + {liquidClassInfo} + + ) +} interface LiquidDefinitionsProps { allIngredientGroupFields: AllIngredGroupFields @@ -22,6 +55,7 @@ export function LiquidDefinitions({ allIngredientGroupFields, }: LiquidDefinitionsProps): JSX.Element { const { t } = useTranslation('protocol_overview') + const enableLiquidClasses = useSelector(getEnableLiquidClasses) return ( @@ -30,43 +64,46 @@ export function LiquidDefinitions({ {Object.keys(allIngredientGroupFields).length > 0 ? ( - Object.values(allIngredientGroupFields).map((liquid, index) => ( - - - - { + console.log(getLiquidDescription(liquid, enableLiquidClasses)) + return ( + + - {liquid.name} - - - } - content={ - - {liquid.description != null && liquid.description !== '' - ? liquid.description - : t('na')} - - } - /> - - )) + + + {liquid.name} + + + } + content={ + getLiquidDescription(liquid, enableLiquidClasses) ?? ( + + {t('na')} + + ) + } + /> + + ) + }) ) : ( )} diff --git a/protocol-designer/src/pages/ProtocolOverview/__tests__/LiquidDefinitions.test.tsx b/protocol-designer/src/pages/ProtocolOverview/__tests__/LiquidDefinitions.test.tsx index 832cea4d800..ff4af37fa4a 100644 --- a/protocol-designer/src/pages/ProtocolOverview/__tests__/LiquidDefinitions.test.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/__tests__/LiquidDefinitions.test.tsx @@ -8,6 +8,8 @@ import { LiquidDefinitions } from '../LiquidDefinitions' import type { ComponentProps } from 'react' import type { InfoScreen } from '@opentrons/components' +vi.mock('../../../feature-flags/selectors') + vi.mock('@opentrons/components', async importOriginal => { const actual = await importOriginal() return { @@ -21,6 +23,7 @@ const mockAllIngredientGroupFields = { name: 'EtOH', displayColor: '#b925ff', description: 'Immer fisch Hergestllter EtOH', + liquidClass: null, serialize: false, liquidGroupId: '0', }, @@ -28,6 +31,7 @@ const mockAllIngredientGroupFields = { name: '10mM Tris pH8,5', displayColor: '#ffd600', description: null, + liquidClass: null, serialize: false, liquidGroupId: '1', }, @@ -35,6 +39,7 @@ const mockAllIngredientGroupFields = { name: 'Amplicon PCR sample + AMPure XP beads', displayColor: '#9dffd8', description: '25µl Amplicon PCR + 20 µl AMPure XP beads', + liquidClass: 'Water', serialize: false, liquidGroupId: '2', }, diff --git a/shared-data/js/index.ts b/shared-data/js/index.ts index 9a8d2e6c39f..50be81a940e 100644 --- a/shared-data/js/index.ts +++ b/shared-data/js/index.ts @@ -10,6 +10,7 @@ export * from './gripper' export * from './helpers' export * from './labware' export * from './labwareTools' +export * from './liquidClasses' export * from './modules' export * from './pipettes' export * from './protocols' diff --git a/shared-data/js/liquidClasses.ts b/shared-data/js/liquidClasses.ts new file mode 100644 index 00000000000..d97db8afa47 --- /dev/null +++ b/shared-data/js/liquidClasses.ts @@ -0,0 +1,8 @@ +import waterV1Uncasted from '../liquid-class/definitions/1/water.json' +import type { LiquidClass } from '.' + +const waterV1 = waterV1Uncasted as LiquidClass + +const defs = { waterV1 } + +export const getAllLiquidClassDefs = (): Record => defs diff --git a/shared-data/js/types.ts b/shared-data/js/types.ts index 71c932a35d4..6abe511bb8f 100644 --- a/shared-data/js/types.ts +++ b/shared-data/js/types.ts @@ -693,6 +693,106 @@ export interface Liquid { displayColor?: string } +// TODO(ND, 12/17/2024): investigate why typescript doesn't allow Array<[number, number]> +type LiquidHandlingPropertyByVolume = number[][] +type PositionReference = + | 'well-bottom' + | 'well-top' + | 'well-center' + | 'liquid-meniscus' +type BlowoutLocation = 'source' | 'destination' | 'trash' +interface DelayParams { + duration: number +} +interface DelayProperties { + enable: boolean + params?: DelayParams +} +interface TouchTipParams { + zOffset: number + mmToEdge: number + speed: number +} +interface TouchTipProperties { + enable: boolean + params?: TouchTipParams +} + +interface MixParams { + repetitions: number + volume: number +} +interface MixProperties { + enable: boolean + params?: MixParams +} +interface BlowoutParams { + location: BlowoutLocation + flowRate: number +} +interface BlowoutProperties { + enable: boolean + params?: BlowoutParams +} +interface Submerge { + positionReference: PositionReference + offset: Coordinates + speed: number + delay: DelayProperties +} +interface BaseRetract { + positionReference: PositionReference + offset: Coordinates + speed: number + airGapByVolume: LiquidHandlingPropertyByVolume + touchTip: TouchTipProperties + delay: DelayProperties +} +type RetractAspirate = BaseRetract +interface RetractDispense extends BaseRetract { + blowout: BlowoutProperties +} +interface BaseLiquidHandlingProperties { + submerge: Submerge + retract: RetractType + positionReference: PositionReference + offset: Coordinates + flowRateByVolume: LiquidHandlingPropertyByVolume + correctionByVolume: LiquidHandlingPropertyByVolume + delay: DelayProperties +} +interface AspirateProperties + extends BaseLiquidHandlingProperties { + preWet: boolean + mix: MixProperties +} +interface SingleDispenseProperties + extends BaseLiquidHandlingProperties { + mix: MixProperties + pushOutByVolume: LiquidHandlingPropertyByVolume +} +interface MultiDispenseProperties { + conditioningByVolume: LiquidHandlingPropertyByVolume + disposalByVolume: LiquidHandlingPropertyByVolume +} +interface ByTipTypeSetting { + tiprack: string + aspirate: AspirateProperties + singleDispense: SingleDispenseProperties + multiDispense?: MultiDispenseProperties +} +interface ByPipetteSetting { + pipetteModel: string + byTipType: ByTipTypeSetting[] +} +export interface LiquidClass { + liquidClassName: string + displayName: string + schemaVersion: number + namespace: string + byPipette: ByPipetteSetting[] +} + export interface AnalysisError { id: string detail: string