diff --git a/protocol-designer/src/assets/localization/en/protocol_steps.json b/protocol-designer/src/assets/localization/en/protocol_steps.json
index 3b00d2f7d5c..24e42424355 100644
--- a/protocol-designer/src/assets/localization/en/protocol_steps.json
+++ b/protocol-designer/src/assets/localization/en/protocol_steps.json
@@ -1,10 +1,15 @@
{
"add_details": "Add step details",
"aspirated": "Aspirated",
+ "batch_edit_steps": "Batch edit steps",
+ "batch_edit": "Batch edit",
+ "batch_edits_saved": "Batch edits saved",
"change_tips": "Change tips",
"default_tip_option": "Default - get next tip",
+ "delete_steps": "Delete steps",
"delete": "Delete step",
"dispensed": "Dispensed",
+ "duplicate_steps": "Duplicate steps",
"duplicate": "Duplicate step",
"edit_step": "Edit step",
"engage_height": "Engage height",
diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/BatchEditMixTools.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/BatchEditMixTools.tsx
new file mode 100644
index 00000000000..29a7080f76c
--- /dev/null
+++ b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/BatchEditMixTools.tsx
@@ -0,0 +1,3 @@
+export function BatchEditMixTools(): JSX.Element {
+ return
Todo: wire this up
+}
diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/BatchEditMoveLiquidTools.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/BatchEditMoveLiquidTools.tsx
new file mode 100644
index 00000000000..58f3e9d8c26
--- /dev/null
+++ b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/BatchEditMoveLiquidTools.tsx
@@ -0,0 +1,3 @@
+export function BatchEditMoveLiquidTools(): JSX.Element {
+ return Todo: wire this up
+}
diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/index.tsx
new file mode 100644
index 00000000000..0f66e7d21f0
--- /dev/null
+++ b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/index.tsx
@@ -0,0 +1,90 @@
+import { useTranslation } from 'react-i18next'
+import { useDispatch, useSelector } from 'react-redux'
+import { Icon, PrimaryButton, StyledText, Toolbox } from '@opentrons/components'
+import {
+ getBatchEditSelectedStepTypes,
+ getMultiSelectDisabledFields,
+ getMultiSelectFieldValues,
+ getMultiSelectItemIds,
+} from '../../../../ui/steps/selectors'
+import { useKitchen } from '../../../../organisms/Kitchen/hooks'
+import { deselectAllSteps } from '../../../../ui/steps/actions/actions'
+import {
+ // changeBatchEditField,
+ resetBatchEditFieldChanges,
+ saveStepFormsMulti,
+} from '../../../../step-forms/actions'
+import { BatchEditMoveLiquidTools } from './BatchEditMoveLiquidTools'
+import { BatchEditMixTools } from './BatchEditMixTools'
+// import { maskField } from '../../../../steplist/fieldLevel'
+
+// import type { StepFieldName } from '../../../../steplist/fieldLevel'
+import type { ThunkDispatch } from 'redux-thunk'
+import type { BaseState } from '../../../../types'
+
+export const BatchEditToolbox = (): JSX.Element | null => {
+ const { t } = useTranslation(['tooltip', 'protocol_steps', 'shared'])
+ const { makeSnackbar } = useKitchen()
+ const dispatch = useDispatch>()
+ const fieldValues = useSelector(getMultiSelectFieldValues)
+ const stepTypes = useSelector(getBatchEditSelectedStepTypes)
+ const disabledFields = useSelector(getMultiSelectDisabledFields)
+ const selectedStepIds = useSelector(getMultiSelectItemIds)
+
+ // const handleChangeFormInput = (name: StepFieldName, value: unknown): void => {
+ // const maskedValue = maskField(name, value)
+ // dispatch(changeBatchEditField({ [name]: maskedValue }))
+ // }
+
+ const handleSave = (): void => {
+ dispatch(saveStepFormsMulti(selectedStepIds))
+ makeSnackbar(t('batch_edits_saved') as string)
+ dispatch(deselectAllSteps('EXIT_BATCH_EDIT_MODE_BUTTON_PRESS'))
+ }
+
+ const handleCancel = (): void => {
+ dispatch(resetBatchEditFieldChanges())
+ dispatch(deselectAllSteps('EXIT_BATCH_EDIT_MODE_BUTTON_PRESS'))
+ }
+
+ const stepType = stepTypes.length === 1 ? stepTypes[0] : null
+
+ if (stepType !== null && fieldValues !== null && disabledFields !== null) {
+ // const propsForFields = makeBatchEditFieldProps(
+ // fieldValues,
+ // disabledFields,
+ // handleChangeFormInput,
+ // t
+ // )
+ if (stepType === 'moveLiquid' || stepType === 'mix') {
+ return (
+
+ {t('protocol_steps:batch_edit')}
+
+ }
+ childrenPadding="0"
+ onCloseClick={handleCancel}
+ closeButton={}
+ confirmButton={
+
+ {t('shared:save')}
+
+ }
+ >
+ {stepType === 'moveLiquid' ? (
+
+ ) : (
+
+ )}
+
+ )
+ } else {
+ return null
+ }
+ } else {
+ return null
+ }
+}
diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/utils.ts b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/utils.ts
new file mode 100644
index 00000000000..4d8f581acea
--- /dev/null
+++ b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/utils.ts
@@ -0,0 +1,48 @@
+import noop from 'lodash/noop'
+import {
+ getFieldDefaultTooltip,
+ getFieldIndeterminateTooltip,
+} from '../StepForm/utils'
+import type {
+ DisabledFields,
+ MultiselectFieldValues,
+} from '../../../../ui/steps/selectors'
+import type { StepFieldName } from '../../../../form-types'
+import type { FieldPropsByName } from '../StepForm/types'
+export const makeBatchEditFieldProps = (
+ fieldValues: MultiselectFieldValues,
+ disabledFields: DisabledFields,
+ handleChangeFormInput: (name: string, value: unknown) => void,
+ t: any
+): FieldPropsByName => {
+ const fieldNames: StepFieldName[] = Object.keys(fieldValues)
+ return fieldNames.reduce((acc, name) => {
+ const defaultTooltip = getFieldDefaultTooltip(name, t)
+ const isIndeterminate = fieldValues[name].isIndeterminate
+ const indeterminateTooltip = getFieldIndeterminateTooltip(name, t)
+ let tooltipContent = defaultTooltip // Default to the default content (or blank)
+
+ if (isIndeterminate && indeterminateTooltip) {
+ tooltipContent = indeterminateTooltip
+ }
+
+ if (name in disabledFields) {
+ tooltipContent = disabledFields[name] // Use disabled content if field is disabled, override indeterminate tooltip if applicable
+ }
+
+ acc[name] = {
+ disabled: name in disabledFields,
+ name,
+ updateValue: value => {
+ handleChangeFormInput(name, value)
+ },
+ value: fieldValues[name].value,
+ errorToShow: null,
+ onFieldBlur: noop,
+ onFieldFocus: noop,
+ isIndeterminate: isIndeterminate,
+ tooltipContent: tooltipContent,
+ }
+ return acc
+ }, {})
+}
diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/ConnectedStepInfo.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/ConnectedStepInfo.tsx
index 318fa72e1ea..1e533b2bfd3 100644
--- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/ConnectedStepInfo.tsx
+++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/ConnectedStepInfo.tsx
@@ -10,6 +10,8 @@ import {
getHoveredSubstep,
getMultiSelectItemIds,
getSelectedStepId,
+ getMultiSelectLastSelected,
+ getIsMultiSelectMode,
} from '../../../../ui/steps'
import { selectors as fileDataSelectors } from '../../../../file-data'
import {
@@ -24,9 +26,19 @@ import {
} from '../../../../ui/steps/actions/actions'
import { getOrderedStepIds } from '../../../../step-forms/selectors'
import { StepContainer } from './StepContainer'
+import {
+ getMetaSelectedSteps,
+ getMouseClickKeyInfo,
+ getShiftSelectedSteps,
+ nonePressed,
+} from './utils'
+import type * as React from 'react'
import type { ThunkDispatch } from 'redux-thunk'
-import type { HoverOnStepAction } from '../../../../ui/steps'
+import type {
+ HoverOnStepAction,
+ SelectMultipleStepsAction,
+} from '../../../../ui/steps'
import type { StepIdType } from '../../../../form-types'
import type { BaseState, ThunkAction } from '../../../../types'
import type { DeleteModalType } from '../../../../components/modals/ConfirmDeleteModal'
@@ -65,6 +77,9 @@ export function ConnectedStepInfo(props: ConnectedStepInfoProps): JSX.Element {
const hoveredStep = useSelector(getHoveredStepId)
const selectedStepId = useSelector(getSelectedStepId)
const multiSelectItemIds = useSelector(getMultiSelectItemIds)
+ const orderedStepIds = useSelector(stepFormSelectors.getOrderedStepIds)
+ const lastMultiSelectedStepId = useSelector(getMultiSelectLastSelected)
+ const isMultiSelectMode = useSelector(getIsMultiSelectMode)
const selected: boolean = multiSelectItemIds?.length
? multiSelectItemIds.includes(stepId)
: selectedStepId === stepId
@@ -74,6 +89,15 @@ export function ConnectedStepInfo(props: ConnectedStepInfoProps): JSX.Element {
const singleEditFormHasUnsavedChanges = useSelector(
stepFormSelectors.getCurrentFormHasUnsavedChanges
)
+ const batchEditFormHasUnsavedChanges = useSelector(
+ stepFormSelectors.getBatchEditFormHasUnsavedChanges
+ )
+ const selectMultipleSteps = (
+ steps: StepIdType[],
+ lastSelected: StepIdType
+ ): ThunkAction =>
+ dispatch(stepsActions.selectMultipleSteps(steps, lastSelected))
+
const selectStep = (): ThunkAction =>
dispatch(stepsActions.resetSelectStep(stepId))
const selectStepOnDoubleClick = (): ThunkAction =>
@@ -82,15 +106,51 @@ export function ConnectedStepInfo(props: ConnectedStepInfoProps): JSX.Element {
dispatch(stepsActions.hoverOnStep(stepId))
const unhighlightStep = (): HoverOnStepAction =>
dispatch(stepsActions.hoverOnStep(null))
- const handleSelectStep = (): void => {
- selectStep()
+ const handleSelectStep = (event: React.MouseEvent): void => {
if (selectedStep !== stepId) {
dispatch(toggleViewSubstep(null))
dispatch(hoverOnStep(null))
}
+ const { isShiftKeyPressed, isMetaKeyPressed } = getMouseClickKeyInfo(event)
+ let stepsToSelect: StepIdType[] = []
+
+ // if user clicked on the last multi-selected step, shift/meta keys don't matter
+ const toggledLastSelected = stepId === lastMultiSelectedStepId
+ const noModifierKeys =
+ nonePressed([isShiftKeyPressed, isMetaKeyPressed]) || toggledLastSelected
+
+ if (noModifierKeys) {
+ selectStep()
+ } else if (
+ (isMetaKeyPressed || isShiftKeyPressed) &&
+ currentFormIsPresaved
+ ) {
+ // current form is presaved, enter batch edit mode with only the clicked
+ stepsToSelect = [stepId]
+ } else {
+ if (isShiftKeyPressed) {
+ stepsToSelect = getShiftSelectedSteps(
+ selectedStepId,
+ orderedStepIds,
+ stepId,
+ multiSelectItemIds,
+ lastMultiSelectedStepId
+ )
+ } else if (isMetaKeyPressed) {
+ stepsToSelect = getMetaSelectedSteps(
+ multiSelectItemIds,
+ stepId,
+ selectedStepId
+ )
+ }
+ }
+ if (stepsToSelect.length > 0) {
+ selectMultipleSteps(stepsToSelect, stepId)
+ }
}
const handleSelectDoubleStep = (): void => {
selectStepOnDoubleClick()
+
if (selectedStep !== stepId) {
dispatch(toggleViewSubstep(null))
dispatch(hoverOnStep(null))
@@ -105,9 +165,12 @@ export function ConnectedStepInfo(props: ConnectedStepInfoProps): JSX.Element {
handleSelectDoubleStep,
currentFormIsPresaved || singleEditFormHasUnsavedChanges
)
+
const { confirm, showConfirmation, cancel } = useConditionalConfirm(
handleSelectStep,
- currentFormIsPresaved || singleEditFormHasUnsavedChanges
+ isMultiSelectMode
+ ? batchEditFormHasUnsavedChanges
+ : currentFormIsPresaved || singleEditFormHasUnsavedChanges
)
const getModalType = (): DeleteModalType => {
diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/StepOverflowMenu.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/StepOverflowMenu.tsx
index 0191ab9969d..5078ff4c0e5 100644
--- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/StepOverflowMenu.tsx
+++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/StepOverflowMenu.tsx
@@ -15,24 +15,33 @@ import {
useConditionalConfirm,
} from '@opentrons/components'
import { actions as steplistActions } from '../../../../steplist'
-import { actions as stepsActions } from '../../../../ui/steps'
import {
+ getMultiSelectItemIds,
+ actions as stepsActions,
+} from '../../../../ui/steps'
+import {
+ CLOSE_BATCH_EDIT_FORM,
CLOSE_STEP_FORM_WITH_CHANGES,
CLOSE_UNSAVED_STEP_FORM,
ConfirmDeleteModal,
+ DELETE_MULTIPLE_STEP_FORMS,
DELETE_STEP_FORM,
} from '../../../../components/modals/ConfirmDeleteModal'
import {
hoverOnStep,
toggleViewSubstep,
populateForm,
+ deselectAllSteps,
} from '../../../../ui/steps/actions/actions'
import {
+ getBatchEditFormHasUnsavedChanges,
getCurrentFormHasUnsavedChanges,
getCurrentFormIsPresaved,
getSavedStepForms,
getUnsavedForm,
} from '../../../../step-forms/selectors'
+import { deleteMultipleSteps } from '../../../../steplist/actions'
+import { duplicateMultipleSteps } from '../../../../ui/steps/actions/thunks'
import type * as React from 'react'
import type { ThunkDispatch } from 'redux-thunk'
import type { BaseState } from '../../../../types'
@@ -49,6 +58,7 @@ interface StepOverflowMenuProps {
export function StepOverflowMenu(props: StepOverflowMenuProps): JSX.Element {
const { stepId, menuRootRef, top, setStepOverflowMenu } = props
const { t } = useTranslation('protocol_steps')
+ const multiSelectItemIds = useSelector(getMultiSelectItemIds)
const dispatch = useDispatch>()
const deleteStep = (stepId: StepIdType): void => {
dispatch(steplistActions.deleteStep(stepId))
@@ -59,6 +69,9 @@ export function StepOverflowMenu(props: StepOverflowMenuProps): JSX.Element {
const singleEditFormHasUnsavedChanges = useSelector(
getCurrentFormHasUnsavedChanges
)
+ const batchEditFormHasUnsavedChanges = useSelector(
+ getBatchEditFormHasUnsavedChanges
+ )
const duplicateStep = (
stepId: StepIdType
): ReturnType =>
@@ -77,11 +90,44 @@ export function StepOverflowMenu(props: StepOverflowMenuProps): JSX.Element {
)
}
}
+ const onDuplicateClickAction = (): void => {
+ if (multiSelectItemIds) {
+ dispatch(duplicateMultipleSteps(multiSelectItemIds))
+ } else {
+ console.warn(
+ 'something went wrong, you cannot duplicate multiple steps if none are selected'
+ )
+ }
+ }
+ const onDeleteClickAction = (): void => {
+ if (multiSelectItemIds) {
+ dispatch(deleteMultipleSteps(multiSelectItemIds))
+ dispatch(deselectAllSteps('EXIT_BATCH_EDIT_MODE_BUTTON_PRESS'))
+ } else {
+ console.warn(
+ 'something went wrong, you cannot delete multiple steps if none are selected'
+ )
+ }
+ }
const { confirm, showConfirmation, cancel } = useConditionalConfirm(
handleStepItemSelection,
currentFormIsPresaved || singleEditFormHasUnsavedChanges
)
+ const {
+ confirm: confirmDuplicate,
+ showConfirmation: showDuplicateConfirmation,
+ cancel: cancelDuplicate,
+ } = useConditionalConfirm(
+ onDuplicateClickAction,
+ batchEditFormHasUnsavedChanges
+ )
+
+ const {
+ confirm: confirmMultiDelete,
+ showConfirmation: showMultiDeleteConfirmation,
+ cancel: cancelMultiDelete,
+ } = useConditionalConfirm(onDeleteClickAction, true)
const {
confirm: confirmDelete,
@@ -112,6 +158,22 @@ export function StepOverflowMenu(props: StepOverflowMenuProps): JSX.Element {
/>
)}
{/* TODO: update this modal */}
+ {showDuplicateConfirmation && (
+
+ )}
+ {/* TODO: update this modal */}
+ {showMultiDeleteConfirmation && (
+
+ )}
+ {/* TODO: update this modal */}
{showDeleteConfirmation && (
- {formData != null ? null : (
- {t('edit_step')}
+ {multiSelectItemIds != null && multiSelectItemIds.length > 0 ? (
+ <>
+
+ {t('duplicate_steps')}
+
+
+ {t('delete_steps')}
+
+ >
+ ) : (
+ <>
+ {formData != null ? null : (
+ {t('edit_step')}
+ )}
+ {isPipetteStep || isThermocyclerStep ? (
+ {
+ dispatch(hoverOnStep(stepId))
+ dispatch(toggleViewSubstep(stepId))
+ }}
+ >
+ {t('view_details')}
+
+ ) : null}
+ {
+ duplicateStep(stepId)
+ }}
+ >
+ {t('duplicate')}
+
+
+ {t('delete')}
+ >
)}
- {isPipetteStep || isThermocyclerStep ? (
- {
- dispatch(hoverOnStep(stepId))
- dispatch(toggleViewSubstep(stepId))
- }}
- >
- {t('view_details')}
-
- ) : null}
- {
- duplicateStep(stepId)
- }}
- >
- {t('duplicate')}
-
-
- {t('delete')}
>
)
diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/TerminalItemStep.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/TerminalItemStep.tsx
index 7104e43e5c1..f9c80080dad 100644
--- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/TerminalItemStep.tsx
+++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/TerminalItemStep.tsx
@@ -16,6 +16,7 @@ import {
ConfirmDeleteModal,
} from '../../../../components/modals/ConfirmDeleteModal'
import {
+ deselectAllSteps,
hoverOnStep,
toggleViewSubstep,
} from '../../../../ui/steps/actions/actions'
@@ -26,6 +27,7 @@ import type {
HoverOnTerminalItemAction,
} from '../../../../ui/steps'
import type { TerminalItemId } from '../../../../steplist'
+import type { ThunkDispatch } from '../../../../types'
export interface TerminalItemStepProps {
id: TerminalItemId
@@ -40,7 +42,7 @@ export function TerminalItemStep(props: TerminalItemStepProps): JSX.Element {
const formHasChanges = useSelector(getCurrentFormHasUnsavedChanges)
const isMultiSelectMode = useSelector(getIsMultiSelectMode)
- const dispatch = useDispatch()
+ const dispatch = useDispatch>()
const selectItem = (): SelectTerminalItemAction =>
dispatch(stepsActions.selectTerminalItem(id))
@@ -58,7 +60,12 @@ export function TerminalItemStep(props: TerminalItemStepProps): JSX.Element {
currentFormIsPresaved || formHasChanges
)
- const onClick = isMultiSelectMode ? () => null : confirm
+ const onClick = isMultiSelectMode
+ ? () => {
+ dispatch(deselectAllSteps('EXIT_BATCH_EDIT_MODE_BUTTON_PRESS'))
+ handleConfirm()
+ }
+ : confirm
return (
<>
diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/StepOverflowMenu.test.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/StepOverflowMenu.test.tsx
index 3defbe61099..f37d2114c74 100644
--- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/StepOverflowMenu.test.tsx
+++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/StepOverflowMenu.test.tsx
@@ -3,7 +3,10 @@ import '@testing-library/jest-dom/vitest'
import { fireEvent, screen } from '@testing-library/react'
import { renderWithProviders } from '../../../../../__testing-utils__'
import { i18n } from '../../../../../assets/localization'
-import { duplicateStep } from '../../../../../ui/steps/actions/thunks'
+import {
+ getMultiSelectItemIds,
+ actions as stepsActions,
+} from '../../../../../ui/steps'
import { StepOverflowMenu } from '../StepOverflowMenu'
import {
getCurrentFormHasUnsavedChanges,
@@ -21,6 +24,7 @@ import type * as OpentronsComponents from '@opentrons/components'
const mockConfirm = vi.fn()
const mockCancel = vi.fn()
+vi.mock('../../../../../ui/steps')
vi.mock('../../../../../step-forms/selectors')
vi.mock('../../../../../ui/steps/actions/actions')
vi.mock('../../../../../ui/steps/actions/thunks')
@@ -53,6 +57,7 @@ describe('StepOverflowMenu', () => {
menuRootRef: { current: null },
setStepOverflowMenu: vi.fn(),
}
+ vi.mocked(getMultiSelectItemIds).mockReturnValue(null)
vi.mocked(getCurrentFormIsPresaved).mockReturnValue(false)
vi.mocked(getCurrentFormHasUnsavedChanges).mockReturnValue(false)
vi.mocked(getUnsavedForm).mockReturnValue(null)
@@ -71,11 +76,19 @@ describe('StepOverflowMenu', () => {
fireEvent.click(screen.getByText('delete step'))
expect(mockConfirm).toHaveBeenCalled()
fireEvent.click(screen.getByText('Duplicate step'))
- expect(vi.mocked(duplicateStep)).toHaveBeenCalled()
+ expect(vi.mocked(stepsActions.duplicateStep)).toHaveBeenCalled()
fireEvent.click(screen.getByText('Edit step'))
expect(mockConfirm).toHaveBeenCalled()
fireEvent.click(screen.getByText('View details'))
expect(vi.mocked(hoverOnStep)).toHaveBeenCalled()
expect(vi.mocked(toggleViewSubstep)).toHaveBeenCalled()
})
+
+ it('renders the multi select overflow menu', () => {
+ vi.mocked(getMultiSelectItemIds).mockReturnValue(['1', '2'])
+ render(props)
+ screen.getByText('Duplicate steps')
+ screen.getByText('Delete steps')
+ screen.getByText('Delete multiple steps')
+ })
})
diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/utils.ts b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/utils.ts
index ef502c2a53f..c7f6f812dc2 100644
--- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/utils.ts
+++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/utils.ts
@@ -1,6 +1,9 @@
import round from 'lodash/round'
import omitBy from 'lodash/omitBy'
+import uniq from 'lodash/uniq'
+import { UAParser } from 'ua-parser-js'
import type { WellIngredientVolumeData } from '../../../../steplist'
+import type { StepIdType } from '../../../../form-types'
export const capitalizeFirstLetterAfterNumber = (title: string): string =>
title.replace(
@@ -50,3 +53,99 @@ export const compactPreIngreds = (
return typeof ingred?.volume === 'number' && ingred.volume <= 0
})
}
+
+export const getMetaSelectedSteps = (
+ multiSelectItemIds: StepIdType[] | null,
+ stepId: StepIdType,
+ selectedStepId: StepIdType | null
+): StepIdType[] => {
+ let stepsToSelect: StepIdType[]
+ if (multiSelectItemIds?.length) {
+ // already have a selection, add/remove the meta-clicked item
+ stepsToSelect = multiSelectItemIds.includes(stepId)
+ ? multiSelectItemIds.filter(id => id !== stepId)
+ : [...multiSelectItemIds, stepId]
+ } else if (selectedStepId && selectedStepId === stepId) {
+ // meta-clicked on the selected single step
+ stepsToSelect = [selectedStepId]
+ } else if (selectedStepId) {
+ // meta-clicked on a different step, multi-select both
+ stepsToSelect = [selectedStepId, stepId]
+ } else {
+ // meta-clicked on a step when a terminal item was selected
+ stepsToSelect = [stepId]
+ }
+ return stepsToSelect
+}
+
+export const getShiftSelectedSteps = (
+ selectedStepId: StepIdType | null,
+ orderedStepIds: StepIdType[],
+ stepId: StepIdType,
+ multiSelectItemIds: StepIdType[] | null,
+ lastMultiSelectedStepId: StepIdType | null
+): StepIdType[] => {
+ let stepsToSelect: StepIdType[]
+ if (selectedStepId) {
+ stepsToSelect = getOrderedStepsInRange(
+ selectedStepId,
+ stepId,
+ orderedStepIds
+ )
+ } else if (multiSelectItemIds?.length && lastMultiSelectedStepId) {
+ const potentialStepsToSelect = getOrderedStepsInRange(
+ lastMultiSelectedStepId,
+ stepId,
+ orderedStepIds
+ )
+
+ const allSelected: boolean = potentialStepsToSelect
+ .slice(1)
+ .every(stepId => multiSelectItemIds.includes(stepId))
+
+ if (allSelected) {
+ // if they're all selected, deselect them all
+ if (multiSelectItemIds.length - potentialStepsToSelect.length > 0) {
+ stepsToSelect = multiSelectItemIds.filter(
+ (id: StepIdType) => !potentialStepsToSelect.includes(id)
+ )
+ } else {
+ // unless deselecting them all results in none being selected
+ stepsToSelect = [potentialStepsToSelect[0]]
+ }
+ } else {
+ stepsToSelect = uniq([...multiSelectItemIds, ...potentialStepsToSelect])
+ }
+ } else {
+ stepsToSelect = [stepId]
+ }
+ return stepsToSelect
+}
+
+const getOrderedStepsInRange = (
+ lastSelectedStepId: StepIdType,
+ stepId: StepIdType,
+ orderedStepIds: StepIdType[]
+): StepIdType[] => {
+ const prevIndex: number = orderedStepIds.indexOf(lastSelectedStepId)
+ const currentIndex: number = orderedStepIds.indexOf(stepId)
+
+ const [startIndex, endIndex] = [prevIndex, currentIndex].sort((a, b) => a - b)
+ const orderedSteps = orderedStepIds.slice(startIndex, endIndex + 1)
+ return orderedSteps
+}
+
+export const nonePressed = (keysPressed: boolean[]): boolean =>
+ keysPressed.every(keyPress => keyPress === false)
+
+export const getMouseClickKeyInfo = (
+ event: React.MouseEvent
+): { isShiftKeyPressed: boolean; isMetaKeyPressed: boolean } => {
+ const isMac: boolean = getUserOS() === 'Mac OS'
+ const isShiftKeyPressed: boolean = event.shiftKey
+ const isMetaKeyPressed: boolean =
+ (isMac && event.metaKey) || (!isMac && event.ctrlKey)
+ return { isShiftKeyPressed, isMetaKeyPressed }
+}
+
+const getUserOS = (): string | undefined => new UAParser().getOS().name
diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx
index 7ff29ec1c30..800f1115633 100644
--- a/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx
+++ b/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx
@@ -14,16 +14,21 @@ import {
ToggleGroup,
} from '@opentrons/components'
import { getUnsavedForm } from '../../../step-forms/selectors'
-import { getSelectedSubstep } from '../../../ui/steps/selectors'
import { getEnableHotKeysDisplay } from '../../../feature-flags/selectors'
+import {
+ getIsMultiSelectMode,
+ getSelectedSubstep,
+} from '../../../ui/steps/selectors'
import { DeckSetupContainer } from '../DeckSetup'
import { OffDeck } from '../Offdeck'
import { TimelineToolbox, SubstepsToolbox } from './Timeline'
import { StepForm } from './StepForm'
+import { BatchEditToolbox } from './BatchEditToolbox'
export function ProtocolSteps(): JSX.Element {
const { t } = useTranslation('starting_deck_state')
const formData = useSelector(getUnsavedForm)
+ const isMultiSelectMode = useSelector(getIsMultiSelectMode)
const selectedSubstep = useSelector(getSelectedSubstep)
const enableHoyKeyDisplay = useSelector(getEnableHotKeysDisplay)
const leftString = t('onDeck')
@@ -54,6 +59,7 @@ export function ProtocolSteps(): JSX.Element {
justifyContent={JUSTIFY_CENTER}
>
+ {isMultiSelectMode ? : null}
{formData == null || formType === 'moveLabware' ? (