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' ? (