Skip to content

Commit a57d1f9

Browse files
authored
fix(protocol-designer): add drag selection to manual tip selector modal (#21191)
# Overview Use drag selection on manual tip selector modal. This is especially helpful when you are using a single channel and eight channel pipettes when you need to select multiple wells at once ## Test Plan and Hands on Testing - smoke tested on 1ch, 96ch, and 8ch. video of 1ch below. I did not include video for 96ch because there isn't much drag selection you can do since it you are blocked from picking up tips until the first group is selected. [1ch_protocol.py](https://github.com/user-attachments/files/26468917/1ch_protocol.py) https://github.com/user-attachments/assets/2c5ea014-d516-4d46-908f-45d05948f44c https://github.com/user-attachments/assets/77e2b740-26ca-42eb-b045-30be3bd01304 ## Changelog - added `INTERACTIVE_WELL_DATA_ATTRIBUTE` to `UsedTip` and `NewTip` to allow the selection rectangle to detect which tip it was selecting - added handleSelectionMove - this adds tips selected by the rectangle to a list of hoveredWells which changes the well appearance - added handleSelectionDone- when the selection is complete it adds the primary highlighted tip to the selected tips - made hoveredWells a list instead of one string to handle the multi hovering of the drag feature ## Review requests ## Risk assessment - high, changes well selection mode closes RQA-5304
1 parent f83cdd5 commit a57d1f9

File tree

4 files changed

+134
-59
lines changed

4 files changed

+134
-59
lines changed

components/src/hardware-sim/Labware/labwareInternals/Tips/NewTip.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1+
import { INTERACTIVE_WELL_DATA_ATTRIBUTE } from '@opentrons/shared-data'
2+
13
import { COLORS } from '../../../../helix-design-system'
24
import { DEFAULT_TIP_SIZE } from './constants'
35

4-
export function NewTip(props: { size?: string }): JSX.Element {
5-
const { size } = props
6+
export function NewTip(props: {
7+
wellName: string
8+
size?: string
9+
}): JSX.Element {
10+
const { size, wellName } = props
11+
const commonProps = {
12+
[INTERACTIVE_WELL_DATA_ATTRIBUTE]: wellName,
13+
}
614
const width = size ?? DEFAULT_TIP_SIZE
715
const height = size ?? DEFAULT_TIP_SIZE
16+
817
return (
918
<svg
1019
width={width}
@@ -13,7 +22,7 @@ export function NewTip(props: { size?: string }): JSX.Element {
1322
fill="none"
1423
xmlns="http://www.w3.org/2000/svg"
1524
>
16-
<circle cx="10" cy="10" r="10" fill={COLORS.blue35} />
25+
<circle cx="10" cy="10" r="10" fill={COLORS.blue35} {...commonProps} />
1726
<circle cx="10" cy="10" r="5" stroke={COLORS.grey50} strokeWidth="2" />
1827
</svg>
1928
)

components/src/hardware-sim/Labware/labwareInternals/Tips/TipStatus.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ export function TipStatus(props: TipStatusProps): JSX.Element {
2828
const { type, size, text, wellMap, wellName } = props
2929
switch (type) {
3030
case NEW:
31-
return <NewTip size={size} />
31+
return <NewTip size={size} wellName={wellName} />
3232
case USED:
33-
return <UsedTip size={size} />
33+
return <UsedTip size={size} wellName={wellName} />
3434
case SELECTED:
3535
return (
3636
<SelectedWell

components/src/hardware-sim/Labware/labwareInternals/Tips/UsedTip.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1+
import { INTERACTIVE_WELL_DATA_ATTRIBUTE } from '@opentrons/shared-data'
2+
13
import { COLORS } from '../../../../helix-design-system'
24
import { DEFAULT_TIP_SIZE } from './constants'
35

4-
export function UsedTip(props: { size?: string }): JSX.Element {
5-
const { size } = props
6+
export function UsedTip(props: {
7+
wellName: string
8+
size?: string
9+
}): JSX.Element {
10+
const { size, wellName } = props
611
const width = size ?? DEFAULT_TIP_SIZE
712
const height = size ?? DEFAULT_TIP_SIZE
13+
const commonProps = {
14+
[INTERACTIVE_WELL_DATA_ATTRIBUTE]: wellName,
15+
}
816
return (
917
<svg
1018
width={width}
@@ -13,7 +21,7 @@ export function UsedTip(props: { size?: string }): JSX.Element {
1321
fill="none"
1422
xmlns="http://www.w3.org/2000/svg"
1523
>
16-
<circle cx="10" cy="10" r="10" fill={COLORS.blue35} />
24+
<circle cx="10" cy="10" r="10" fill={COLORS.blue35} {...commonProps} />
1725
<circle
1826
cx="10"
1927
cy="10"

protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/TipSelectionWizard/SelectTips.tsx

Lines changed: 109 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,11 @@ import {
3030
import { EMPTY, getSlotInLocationStack } from '@opentrons/step-generation'
3131

3232
import { LabwareOnDeck } from '/protocol-designer/components/organisms'
33+
import { SelectionRect } from '/protocol-designer/components/organisms/Labware/SelectionRect'
3334
import { getRobotType } from '/protocol-designer/file-data/selectors'
3435
import { getRobotStateAtActiveItem } from '/protocol-designer/top-selectors/labware-locations'
3536
import { getLabwareNicknamesById } from '/protocol-designer/ui/labware/selectors'
37+
import { getCollidingWells } from '/protocol-designer/utils/index'
3638

3739
import { INACCESSIBLE_PARTIAL_TIP } from '../NozzleAndWellSelectionModal/constants'
3840
import { getEntireWellSelection } from '../NozzleAndWellSelectionModal/utils'
@@ -61,14 +63,13 @@ import type {
6163
PipetteV2Specs,
6264
PrimaryNozzleConfigurationStyle,
6365
} from '@opentrons/shared-data'
66+
import type { GenericRect } from '/protocol-designer/collision-types'
6467
import type {
6568
AccessibilityStatus,
6669
InaccessibleReason,
6770
TipSelectionBaseProps,
6871
} from './types'
6972

70-
const NINETY_SIX_ALL_TARGET_WELL = 'A1'
71-
7273
export function SelectTips(
7374
props: TipSelectionBaseProps & {
7475
pipetteSpecs: PipetteV2Specs
@@ -84,9 +85,9 @@ export function SelectTips(
8485
const { t } = useTranslation('tip_selection')
8586
const labwareNicknamesById = useSelector(getLabwareNicknamesById)
8687
const robotType = useSelector(getRobotType)
87-
const [hoveredWell, setHoveredWell] = useState<string | null>(null)
88+
const [hoveredWells, setHoveredWells] = useState<string[] | null>(null)
8889
const leaveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
89-
const currentHoveredWellRef = useRef<string | null>(null)
90+
const currentHoveredWellRef = useRef<string[] | null>(null)
9091

9192
const {
9293
pipetteSpecs,
@@ -126,13 +127,7 @@ export function SelectTips(
126127
? (tipAccessibilityStatus[selectedTiprackId] ?? {})
127128
: {}
128129

129-
const allWellsAffectedByHover = getEntireWellSelection(
130-
hoveredWell,
131-
labwareDef.ordering,
132-
nozzles,
133-
primaryNozzle,
134-
channels
135-
)
130+
const allWellsAffectedByHover = hoveredWells ?? []
136131

137132
const areAllHoveredWellsAccessibleAndOccupied = allWellsAffectedByHover.every(
138133
well =>
@@ -166,6 +161,22 @@ export function SelectTips(
166161
const numPickupsRemaining = numTotalPickups - selectedTips.length
167162
const hasPickupsRemaining = numPickupsRemaining > 0
168163

164+
const _getWellsFromRect: (rect: GenericRect) => string[][] = rect => {
165+
const wellsInRect = getCollidingWells(rect)
166+
const highlightedWells: string[][] = []
167+
for (const well in wellsInRect) {
168+
const wellSelection = getEntireWellSelection(
169+
well,
170+
labwareDef.ordering,
171+
nozzles,
172+
primaryNozzle,
173+
channels
174+
)
175+
highlightedWells.push(wellSelection)
176+
}
177+
return highlightedWells
178+
}
179+
169180
const handleUnselectWell = (unselectIndex: number): void => {
170181
setSelectedTips(selectedTips.slice(0, unselectIndex))
171182
}
@@ -180,7 +191,6 @@ export function SelectTips(
180191
},
181192
{}
182193
)
183-
184194
if (
185195
// always allow tip unselection
186196
!(wellName in prevSelectedTipsByIndex) &&
@@ -255,40 +265,91 @@ export function SelectTips(
255265

256266
const handleHoverWell = (e: WellMouseEvent): void => {
257267
const { wellName } = e
258-
let transformedWellName = wellName
259-
if (
260-
(channels === 8 && nozzles === ALL) ||
261-
(channels === 96 && nozzles === COLUMN)
262-
) {
263-
const column = wellName.slice(1, wellName.length)
264-
transformedWellName = `A${column}`
265-
} else if (channels === 96 && nozzles === ALL) {
266-
transformedWellName = NINETY_SIX_ALL_TARGET_WELL
267-
} else if (channels === 96 && nozzles === ROW) {
268-
const rowName = wellName.slice(0, 1)
269-
transformedWellName = `${rowName}1`
270-
}
268+
const allHoveredWells = getEntireWellSelection(
269+
wellName,
270+
labwareDef.ordering,
271+
nozzles,
272+
primaryNozzle,
273+
channels
274+
)
275+
271276
if (leaveTimeoutRef.current) {
272277
clearTimeout(leaveTimeoutRef.current)
273278
leaveTimeoutRef.current = null
274279
}
275-
setHoveredWell(transformedWellName)
276-
currentHoveredWellRef.current = transformedWellName
280+
setHoveredWells(allHoveredWells)
281+
currentHoveredWellRef.current = allHoveredWells
277282
}
278283

279284
const handleLeaveWell = (_: WellMouseEvent): void => {
280285
if (leaveTimeoutRef.current) {
281286
clearTimeout(leaveTimeoutRef.current)
282287
}
283288
leaveTimeoutRef.current = setTimeout(() => {
284-
if (currentHoveredWellRef.current === hoveredWell) {
285-
setHoveredWell(null)
286-
currentHoveredWellRef.current = null
287-
}
289+
setHoveredWells(null)
290+
currentHoveredWellRef.current = null
291+
288292
leaveTimeoutRef.current = null
289293
}, 300)
290294
}
295+
const selectedWellsByIndex = selectedTips.reduce<Record<string, number>>(
296+
(acc, tipList, index) => {
297+
const innerAcc = tipList.reduce<Record<string, number>>(
298+
(acc, tip) => ({ ...acc, [tip]: index }),
299+
{}
300+
)
301+
return { ...acc, ...innerAcc }
302+
},
303+
{}
304+
)
305+
const handleSelectionMove = (e: MouseEvent, rect: GenericRect): void => {
306+
if (!e.shiftKey) {
307+
const wellsUnderRect = _getWellsFromRect(rect)
308+
const flat = [...new Set(wellsUnderRect.flat())]
309+
setHoveredWells(flat)
310+
}
311+
}
312+
const handleSelectionDone = (e: MouseEvent, rect: GenericRect): void => {
313+
if (!e.shiftKey) {
314+
const wellsUnderRect = _getWellsFromRect(rect)
315+
const filteredWellsUnderRect = wellsUnderRect.filter(wellGroup =>
316+
wellGroup.every(
317+
wellName =>
318+
tipAccessibileStatusByWellName[wellName].isAccessible &&
319+
hasPickupsRemaining
320+
)
321+
)
322+
setSelectedTips(prev => {
323+
const next = [...prev]
324+
const selectedFlat = new Set(prev.flat())
325+
const allAlreadySelected = filteredWellsUnderRect.every(group =>
326+
group.every(well => selectedFlat.has(well))
327+
)
291328

329+
let updated: string[][]
330+
// Remove all wells if the entire selection is already selected
331+
if (allAlreadySelected) {
332+
const keysToRemove = new Set(wellsUnderRect.map(g => g[0]))
333+
updated = next.filter(group => !keysToRemove.has(group[0]))
334+
} else {
335+
// Add additional selected wells if there is a mixture of selected and unselected
336+
updated = [...next]
337+
wellsUnderRect.forEach(wellGroup => {
338+
const primaryWellInGroup = wellGroup[0]
339+
const exists = updated.some(
340+
wellGroup => wellGroup[0] === primaryWellInGroup
341+
)
342+
// Add to update list if the well does not currently exist in the list and is accessible
343+
if (!exists && selectedWellsByIndex[primaryWellInGroup] !== 1) {
344+
updated.push(wellGroup)
345+
}
346+
})
347+
}
348+
return updated
349+
})
350+
setHoveredWells(null)
351+
}
352+
}
292353
let controls: JSX.Element = <></>
293354
const labware = activeDeckSetup.labware[selectedTiprackId ?? '']
294355
const slot = getSlotInLocationStack(labware.stack)
@@ -299,16 +360,7 @@ export function SelectTips(
299360
controls = <></>
300361
} else {
301362
const tipState = robotState?.tipState.tipracks[selectedTiprackId ?? '']
302-
const selectedWellsByIndex = selectedTips.reduce<Record<string, number>>(
303-
(acc, tipList, index) => {
304-
const innerAcc = tipList.reduce<Record<string, number>>(
305-
(acc, tip) => ({ ...acc, [tip]: index }),
306-
{}
307-
)
308-
return { ...acc, ...innerAcc }
309-
},
310-
{}
311-
)
363+
312364
const is96Channel = channels === 96
313365
const tipStatusByWellName =
314366
tipState != null
@@ -363,20 +415,20 @@ export function SelectTips(
363415
borderStroke={COLORS.yellow40}
364416
ignoreMissingTips
365417
/>
366-
{hoveredWell != null ? (
418+
{hoveredWells != null ? (
367419
<PipetteShadow
368420
robotType={robotType}
369421
pipetteSpec={pipetteSpecs}
370422
slotPosition={slotPosition}
371-
hoveredWell={hoveredWell}
423+
hoveredWell={hoveredWells[0]}
372424
selectedLabwareId={selectedTiprackId}
373425
labwareState={activeDeckSetup.labware}
374426
isHoveredWellSelected={selectedTips
375427
.flat()
376-
.some(tip => tip === hoveredWell)}
428+
.some(tip => tip === hoveredWells[0])}
377429
hasPickupsRemaining={hasPickupsRemaining}
378430
isAccessible={hoveredWellsInaccessibilityStatus == null}
379-
inaccessibleReason={hoveredWellsInaccessibilityStatus}
431+
inaccessibleReason={hoveredWellsInaccessibilityStatus ?? null}
380432
primaryNozzle={primaryNozzle}
381433
enclosingViewbox={viewBox}
382434
nozzles={nozzles}
@@ -404,12 +456,18 @@ export function SelectTips(
404456
</Flex>
405457
<div className={styles.modal_body_select_tips}>
406458
<div className={styles.select_tips_deck_container}>
407-
<BaseDeckTipSelection
408-
viewBox={viewBox}
409-
showSlotLabels={false}
410-
controls={controls}
411-
labwareIdToHide={selectedTiprackId}
412-
/>
459+
<SelectionRect
460+
onSelectionMove={handleSelectionMove}
461+
onSelectionDone={handleSelectionDone}
462+
customWidth={45}
463+
>
464+
<BaseDeckTipSelection
465+
viewBox={viewBox}
466+
showSlotLabels={false}
467+
controls={controls}
468+
labwareIdToHide={selectedTiprackId}
469+
/>
470+
</SelectionRect>
413471
</div>
414472
<div className={styles.legend_box}>
415473
<SelectionLegend selectionType={TIP} size={DEFAULT_TIP_SIZE} />

0 commit comments

Comments
 (0)