diff --git a/components/src/atoms/ListItem/index.tsx b/components/src/atoms/ListItem/index.tsx index a9b97c529f0..39367f935c1 100644 --- a/components/src/atoms/ListItem/index.tsx +++ b/components/src/atoms/ListItem/index.tsx @@ -16,6 +16,8 @@ interface ListItemProps extends StyleProps { /** ListItem contents */ children: React.ReactNode onClick?: () => void + onMouseEnter?: () => void + onMouseLeave?: () => void } const LISTITEM_PROPS_BY_TYPE: Record< @@ -40,7 +42,14 @@ const LISTITEM_PROPS_BY_TYPE: Record< ListItem is used in ODD and helix **/ export function ListItem(props: ListItemProps): JSX.Element { - const { type, children, onClick, ...styleProps } = props + const { + type, + children, + onClick, + onMouseEnter, + onMouseLeave, + ...styleProps + } = props const listItemProps = LISTITEM_PROPS_BY_TYPE[type] const LIST_ITEM_STYLE = css` @@ -60,6 +69,8 @@ export function ListItem(props: ListItemProps): JSX.Element { diff --git a/components/src/hardware-sim/Labware/labwareInternals/StyledWells.tsx b/components/src/hardware-sim/Labware/labwareInternals/StyledWells.tsx index c72a621aa3d..bb377135024 100644 --- a/components/src/hardware-sim/Labware/labwareInternals/StyledWells.tsx +++ b/components/src/hardware-sim/Labware/labwareInternals/StyledWells.tsx @@ -27,7 +27,7 @@ export const STYLE_BY_WELL_CONTENTS: { highlightedWell: { stroke: COLORS.blue50, fill: COLORS.transparent, - strokeWidth: 0.5, + strokeWidth: 1, }, disabledWell: { stroke: '#C6C6C6', // LEGACY --light-grey-hover diff --git a/protocol-designer/src/assets/localization/en/protocol_steps.json b/protocol-designer/src/assets/localization/en/protocol_steps.json index 16277667e06..fc2725fdb9e 100644 --- a/protocol-designer/src/assets/localization/en/protocol_steps.json +++ b/protocol-designer/src/assets/localization/en/protocol_steps.json @@ -1,12 +1,18 @@ { "add_details": "Add step details", + "aspirated": "Aspirated", "change_tips": "Change tips", "default_tip_option": "Default - get next tip", "delete": "Delete step", + "dispensed": "Dispensed", "duplicate": "Duplicate step", "edit_step": "Edit step", "final_deck_state": "Final deck state", + "from": "from", "heater_shaker_settings": "Heater-shaker settings", + "in": "in", + "into": "into", + "mix": "Mix", "module": "Module", "multiAspirate": "Consolidate path", "multiDispense": "Distribute path", @@ -33,7 +39,9 @@ "shake": "Shake", "single": "Single path", "starting_deck_state": "Starting deck state", + "step_substeps": "{{stepType}} details", "temperature": "Temperature", "time": "Time", - "view_commands": "View commands" + "view_details": "View details", + "well_name": "Well {{wellName}}" } diff --git a/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupDetails.tsx b/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupDetails.tsx index b2943062e68..0b23fa3315e 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupDetails.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupDetails.tsx @@ -23,6 +23,7 @@ import { getStagingAreaAddressableAreas } from '../../../utils' import { editSlotInfo } from '../../../labware-ingred/actions' import { getRobotType } from '../../../file-data/selectors' import { getSlotInformation } from '../utils' +import { HighlightLabware } from '../HighlightLabware' import { DeckItemHover } from './DeckItemHover' import { SlotOverflowMenu } from './SlotOverflowMenu' import { HoveredItems } from './HoveredItems' @@ -205,6 +206,10 @@ export function DeckSetupDetails(props: DeckSetupDetailsProps): JSX.Element { y={0} labwareOnDeck={labwareLoadedOnModule} /> + + + + ) + } + return null +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/ConnectedStepInfo.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/ConnectedStepInfo.tsx index d2b2e8e0f32..318fa72e1ea 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/ConnectedStepInfo.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/ConnectedStepInfo.tsx @@ -18,6 +18,10 @@ import { ConfirmDeleteModal, } from '../../../../components/modals/ConfirmDeleteModal' import { stepIconsByType } from '../../../../form-types' +import { + hoverOnStep, + toggleViewSubstep, +} from '../../../../ui/steps/actions/actions' import { getOrderedStepIds } from '../../../../step-forms/selectors' import { StepContainer } from './StepContainer' @@ -41,6 +45,7 @@ export function ConnectedStepInfo(props: ConnectedStepInfoProps): JSX.Element { const argsAndErrors = useSelector(stepFormSelectors.getArgsAndErrorsByStepId)[ stepId ] + const selectedStep = useSelector(getSelectedStepId) const errorStepId = useSelector(fileDataSelectors.getErrorStepId) const hasError = errorStepId === stepId || argsAndErrors.errors != null const hasTimelineWarningsPerStep = useSelector( @@ -77,17 +82,31 @@ export function ConnectedStepInfo(props: ConnectedStepInfoProps): JSX.Element { dispatch(stepsActions.hoverOnStep(stepId)) const unhighlightStep = (): HoverOnStepAction => dispatch(stepsActions.hoverOnStep(null)) + const handleSelectStep = (): void => { + selectStep() + if (selectedStep !== stepId) { + dispatch(toggleViewSubstep(null)) + dispatch(hoverOnStep(null)) + } + } + const handleSelectDoubleStep = (): void => { + selectStepOnDoubleClick() + if (selectedStep !== stepId) { + dispatch(toggleViewSubstep(null)) + dispatch(hoverOnStep(null)) + } + } const { confirm: confirmDoubleClick, showConfirmation: showConfirmationDoubleClick, cancel: cancelDoubleClick, } = useConditionalConfirm( - selectStepOnDoubleClick, + handleSelectDoubleStep, currentFormIsPresaved || singleEditFormHasUnsavedChanges ) const { confirm, showConfirmation, cancel } = useConditionalConfirm( - selectStep, + handleSelectStep, currentFormIsPresaved || singleEditFormHasUnsavedChanges ) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/MultichannelSubstep.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/MultichannelSubstep.tsx new file mode 100644 index 00000000000..6dd9aebf527 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/MultichannelSubstep.tsx @@ -0,0 +1,121 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + ALIGN_CENTER, + DIRECTION_COLUMN, + DeckInfoLabel, + Flex, + JUSTIFY_SPACE_BETWEEN, + ListButton, + SPACING, + StyledText, + Tag, +} from '@opentrons/components' +import { Substep } from './Substep' +import { formatVolume } from './utils' +import type { AdditionalEquipmentName } from '@opentrons/step-generation' +import type { + StepItemSourceDestRow, + SubstepIdentifier, + WellIngredientNames, +} from '../../../../steplist' + +interface MultichannelSubstepProps { + trashName: AdditionalEquipmentName | null + rowGroup: StepItemSourceDestRow[] + ingredNames: WellIngredientNames + stepId: string + substepIndex: number + selectSubstep: (substepIdentifier: SubstepIdentifier) => void + highlighted?: boolean +} + +export function MultichannelSubstep( + props: MultichannelSubstepProps +): JSX.Element { + const { + rowGroup, + stepId, + selectSubstep, + substepIndex, + ingredNames, + trashName, + } = props + const { t } = useTranslation('application') + const [collapsed, setCollapsed] = useState(true) + const handleToggleCollapsed = (): void => { + setCollapsed(!collapsed) + } + + const firstChannelSource = rowGroup[0].source + const lastChannelSource = rowGroup[rowGroup.length - 1].source + const sourceWellRange = `${ + firstChannelSource ? firstChannelSource.well : '' + }:${lastChannelSource ? lastChannelSource.well : ''}` + const firstChannelDest = rowGroup[0].dest + const lastChannelDest = rowGroup[rowGroup.length - 1].dest + const destWellRange = `${ + firstChannelDest ? firstChannelDest.well ?? 'Trash' : '' + }:${lastChannelDest ? lastChannelDest.well : ''}` + + return ( + { + selectSubstep({ stepId, substepIndex }) + }} + onMouseLeave={() => { + selectSubstep(null) + }} + > + {/* TODO: need to update this to match designs! */} + + + + Multi + {firstChannelSource != null ? ( + + ) : null} + + {firstChannelDest != null ? ( + + ) : null} + + + {!collapsed && + rowGroup.map((row, rowKey) => { + return ( + + ) + })} + + + + + ) +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/PipettingSubsteps.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/PipettingSubsteps.tsx new file mode 100644 index 00000000000..7973c5ef376 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/PipettingSubsteps.tsx @@ -0,0 +1,73 @@ +import { DIRECTION_COLUMN, Flex, SPACING } from '@opentrons/components' +import { Substep } from './Substep' +import { MultichannelSubstep } from './MultichannelSubstep' +import type { + SourceDestSubstepItem, + SubstepIdentifier, + WellIngredientNames, +} from '../../../../steplist' +import { useSelector } from 'react-redux' +import { + getAdditionalEquipment, + getSavedStepForms, +} from '../../../../step-forms/selectors' + +interface PipettingSubstepsProps { + substeps: SourceDestSubstepItem + ingredNames: WellIngredientNames + selectSubstep: (substepIdentifier: SubstepIdentifier) => void + hoveredSubstep?: SubstepIdentifier | null +} + +export function PipettingSubsteps(props: PipettingSubstepsProps): JSX.Element { + const { substeps, selectSubstep, hoveredSubstep, ingredNames } = props + const stepId = substeps.parentStepId + const formData = useSelector(getSavedStepForms)[stepId] + const additionalEquipment = useSelector(getAdditionalEquipment) + const destLocationId = formData.dispense_labware + const trashName = + additionalEquipment[destLocationId] != null + ? additionalEquipment[destLocationId]?.name + : null + + const renderSubsteps = substeps.multichannel + ? substeps.multiRows.map((rowGroup, groupKey) => ( + + )) + : substeps.rows.map((row, substepIndex) => ( + + )) + + return ( + + {renderSubsteps} + + ) +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/StepOverflowMenu.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/StepOverflowMenu.tsx index 614a70e0968..0191ab9969d 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/StepOverflowMenu.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/StepOverflowMenu.tsx @@ -16,16 +16,21 @@ import { } from '@opentrons/components' import { actions as steplistActions } from '../../../../steplist' import { actions as stepsActions } from '../../../../ui/steps' -import { populateForm } from '../../../../ui/steps/actions/actions' import { CLOSE_STEP_FORM_WITH_CHANGES, CLOSE_UNSAVED_STEP_FORM, ConfirmDeleteModal, DELETE_STEP_FORM, } from '../../../../components/modals/ConfirmDeleteModal' +import { + hoverOnStep, + toggleViewSubstep, + populateForm, +} from '../../../../ui/steps/actions/actions' import { getCurrentFormHasUnsavedChanges, getCurrentFormIsPresaved, + getSavedStepForms, getUnsavedForm, } from '../../../../step-forms/selectors' import type * as React from 'react' @@ -49,6 +54,7 @@ export function StepOverflowMenu(props: StepOverflowMenuProps): JSX.Element { dispatch(steplistActions.deleteStep(stepId)) } const formData = useSelector(getUnsavedForm) + const savedStepFormData = useSelector(getSavedStepForms)[stepId] const currentFormIsPresaved = useSelector(getCurrentFormIsPresaved) const singleEditFormHasUnsavedChanges = useSelector( getCurrentFormHasUnsavedChanges @@ -90,6 +96,10 @@ export function StepOverflowMenu(props: StepOverflowMenuProps): JSX.Element { return CLOSE_STEP_FORM_WITH_CHANGES } } + const isPipetteStep = + savedStepFormData.stepType === 'moveLiquid' || + savedStepFormData.stepType === 'mix' + const isThermocyclerStep = savedStepFormData.stepType === 'thermocycler' return ( <> @@ -128,14 +138,16 @@ export function StepOverflowMenu(props: StepOverflowMenuProps): JSX.Element { {formData != null ? null : ( {t('edit_step')} )} - { - console.log('wire this up') - }} - > - {t('view_commands')} - + {isPipetteStep || isThermocyclerStep ? ( + { + dispatch(hoverOnStep(stepId)) + dispatch(toggleViewSubstep(stepId)) + }} + > + {t('view_details')} + + ) : null} { duplicateStep(stepId) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/Substep.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/Substep.tsx new file mode 100644 index 00000000000..a731a975b48 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/Substep.tsx @@ -0,0 +1,213 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' +import noop from 'lodash/noop' +import { AIR } from '@opentrons/step-generation' +import { + ALIGN_CENTER, + COLORS, + DIRECTION_COLUMN, + DeckInfoLabel, + Flex, + JUSTIFY_SPACE_BETWEEN, + LiquidIcon, + ListItem, + SPACING, + StyledText, + Tag, +} from '@opentrons/components' +import { selectors } from '../../../../labware-ingred/selectors' +import { + MIXED_WELL_COLOR, + swatchColors, +} from '../../../../components/swatchColors' +import { compactPreIngreds, formatVolume } from './utils' +import type { AdditionalEquipmentName } from '@opentrons/step-generation' + +import type { + SubstepIdentifier, + SubstepWellData, + WellIngredientNames, +} from '../../../../steplist' + +interface SubstepProps { + trashName: AdditionalEquipmentName | null + ingredNames: WellIngredientNames + stepId: string + substepIndex: number + volume?: number | string | null + source?: SubstepWellData + dest?: SubstepWellData + selectSubstep?: (substepIdentifier: SubstepIdentifier) => void +} + +function SubstepComponent(props: SubstepProps): JSX.Element { + const { + volume, + ingredNames, + stepId, + substepIndex, + source, + dest, + trashName, + selectSubstep: propSelectSubstep, + } = props + const { t } = useTranslation(['application', 'protocol_steps', 'shared']) + const compactedSourcePreIngreds = source + ? compactPreIngreds(source.preIngreds) + : {} + + const selectSubstep = propSelectSubstep ?? noop + + const ingredIds: string[] = Object.keys(compactedSourcePreIngreds) + const liquidDisplayColors = useSelector(selectors.getLiquidDisplayColors) + const noColor = ingredIds.filter(id => id !== AIR).length === 0 + let color = MIXED_WELL_COLOR + if (ingredIds.length === 1) { + color = + liquidDisplayColors[Number(ingredIds[0])] ?? swatchColors(ingredIds[0]) + } else if (noColor) { + color = COLORS.transparent + } + + const volumeTag = ( + + ) + + const isMix = source?.well === dest?.well + + return ( + { + selectSubstep({ + stepId, + substepIndex, + }) + }} + onMouseLeave={() => { + selectSubstep(null) + }} + flexDirection={DIRECTION_COLUMN} + gridGap={SPACING.spacing4} + > + {isMix ? ( + + + {ingredIds.length > 0 ? ( + + + + + {ingredIds.map(groupId => ingredNames[groupId]).join(',')} + + + ) : null} + + + + {t('protocol_steps:mix')} + + {volumeTag} + + {t('protocol_steps:in')} + + + + + + ) : ( + <> + + + {ingredIds.length > 0 ? ( + + + + + {ingredIds.map(groupId => ingredNames[groupId]).join(',')} + + + ) : null} + {source != null ? ( + + + {t('protocol_steps:aspirated')} + + {volumeTag} + + {t('protocol_steps:from')} + + + + ) : null} + + + + + {ingredIds.length > 0 ? ( + + + + {ingredIds.map(groupId => ingredNames[groupId]).join(',')} + + + ) : null} + {dest != null || trashName != null ? ( + + + {t('protocol_steps:dispensed')} + + {volumeTag} + + {t('protocol_steps:into')} + + + + + ) : null} + + + + )} + + ) +} + +export const Substep = React.memo(SubstepComponent) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/SubstepsToolbox.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/SubstepsToolbox.tsx new file mode 100644 index 00000000000..2be387661fb --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/SubstepsToolbox.tsx @@ -0,0 +1,97 @@ +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' +import { + Flex, + PrimaryButton, + SPACING, + StyledText, + Toolbox, +} from '@opentrons/components' +import { selectors as labwareIngredSelectors } from '../../../../labware-ingred/selectors' +import { getSubsteps } from '../../../../file-data/selectors' +import { getHoveredSubstep } from '../../../../ui/steps' +import { + hoverOnStep, + hoverOnSubstep, + toggleViewSubstep, +} from '../../../../ui/steps/actions/actions' +import { THERMOCYCLER_PROFILE } from '../../../../constants' +import { getSavedStepForms } from '../../../../step-forms/selectors' +import { PipettingSubsteps } from './PipettingSubsteps' +import { ThermocyclerProfileSubsteps } from './ThermocyclerProfileSubsteps' +import type { SubstepIdentifier } from '../../../../steplist' +import type { HoverOnSubstepAction } from '../../../../ui/steps' + +interface SubstepsToolboxProps { + stepId: string +} + +export function SubstepsToolbox( + props: SubstepsToolboxProps +): JSX.Element | null { + const { stepId } = props + const { t, i18n } = useTranslation([ + 'application', + 'protocol_steps', + 'shared', + ]) + const dispatch = useDispatch() + const substeps = useSelector(getSubsteps)[stepId] + const formData = useSelector(getSavedStepForms)[stepId] + const hoveredSubstep = useSelector(getHoveredSubstep) + const ingredNames = useSelector(labwareIngredSelectors.getLiquidNamesById) + const highlightSubstep = (payload: SubstepIdentifier): HoverOnSubstepAction => + dispatch(hoverOnSubstep(payload)) + + if (substeps == null) { + return null + } + + const uiStepType = t(`application:stepType.${formData.stepType}`) + + return ('commandCreatorFnName' in substeps && + (substeps.commandCreatorFnName === 'transfer' || + substeps.commandCreatorFnName === 'consolidate' || + substeps.commandCreatorFnName === 'distribute' || + substeps.commandCreatorFnName === 'mix')) || + substeps.substepType === THERMOCYCLER_PROFILE ? ( + { + dispatch(toggleViewSubstep(null)) + dispatch(hoverOnStep(null)) + }} + width="100%" + > + {t('shared:done')} + + } + height="calc(100vh - 64px)" + title={ + + {i18n.format( + t(`protocol_steps:step_substeps`, { stepType: uiStepType }), + 'capitalize' + )} + + } + > + + {substeps.substepType === THERMOCYCLER_PROFILE ? ( + + ) : ( + + )} + + + ) : null +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/TerminalItemStep.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/TerminalItemStep.tsx index 54cd0effd9d..7104e43e5c1 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/TerminalItemStep.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/TerminalItemStep.tsx @@ -15,6 +15,10 @@ import { CLOSE_UNSAVED_STEP_FORM, ConfirmDeleteModal, } from '../../../../components/modals/ConfirmDeleteModal' +import { + hoverOnStep, + toggleViewSubstep, +} from '../../../../ui/steps/actions/actions' import { StepContainer } from './StepContainer' import type { @@ -40,14 +44,17 @@ export function TerminalItemStep(props: TerminalItemStepProps): JSX.Element { const selectItem = (): SelectTerminalItemAction => dispatch(stepsActions.selectTerminalItem(id)) - const onMouseEnter = (): HoverOnTerminalItemAction => dispatch(stepsActions.hoverOnTerminalItem(id)) const onMouseLeave = (): HoverOnTerminalItemAction => dispatch(stepsActions.hoverOnTerminalItem(null)) - + const handleConfirm = (): void => { + dispatch(toggleViewSubstep(null)) + dispatch(hoverOnStep(null)) + selectItem() + } const { confirm, showConfirmation, cancel } = useConditionalConfirm( - selectItem, + handleConfirm, currentFormIsPresaved || formHasChanges ) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/ThermocyclerProfileSubsteps.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/ThermocyclerProfileSubsteps.tsx new file mode 100644 index 00000000000..138982b0900 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/ThermocyclerProfileSubsteps.tsx @@ -0,0 +1,3 @@ +export function ThermocyclerProfileSubsteps(): JSX.Element { + return
Wire this up
+} 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 7b1748abf7e..3defbe61099 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 @@ -8,8 +8,13 @@ import { StepOverflowMenu } from '../StepOverflowMenu' import { getCurrentFormHasUnsavedChanges, getCurrentFormIsPresaved, + getSavedStepForms, getUnsavedForm, } from '../../../../../step-forms/selectors' +import { + hoverOnStep, + toggleViewSubstep, +} from '../../../../../ui/steps/actions/actions' import type * as React from 'react' import type * as OpentronsComponents from '@opentrons/components' @@ -37,12 +42,13 @@ const render = (props: React.ComponentProps) => { })[0] } +const moveLiquidStepId = 'mockId' describe('StepOverflowMenu', () => { let props: React.ComponentProps beforeEach(() => { props = { - stepId: 'mockId', + stepId: moveLiquidStepId, top: 0, menuRootRef: { current: null }, setStepOverflowMenu: vi.fn(), @@ -50,6 +56,12 @@ describe('StepOverflowMenu', () => { vi.mocked(getCurrentFormIsPresaved).mockReturnValue(false) vi.mocked(getCurrentFormHasUnsavedChanges).mockReturnValue(false) vi.mocked(getUnsavedForm).mockReturnValue(null) + vi.mocked(getSavedStepForms).mockReturnValue({ + [moveLiquidStepId]: { + stepType: 'moveLiquid', + id: moveLiquidStepId, + }, + }) }) it('renders each button and clicking them calls the action', () => { @@ -62,7 +74,8 @@ describe('StepOverflowMenu', () => { expect(vi.mocked(duplicateStep)).toHaveBeenCalled() fireEvent.click(screen.getByText('Edit step')) expect(mockConfirm).toHaveBeenCalled() - fireEvent.click(screen.getByText('View commands')) - // TODO: wire up view commands + fireEvent.click(screen.getByText('View details')) + expect(vi.mocked(hoverOnStep)).toHaveBeenCalled() + expect(vi.mocked(toggleViewSubstep)).toHaveBeenCalled() }) }) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/index.ts b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/index.ts index 6c1a85d1efc..2b4945b756b 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/index.ts +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/index.ts @@ -1 +1,2 @@ +export * from './SubstepsToolbox' export * from './TimelineToolbox' diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/utils.ts b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/utils.ts index 660ac545474..ef502c2a53f 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/utils.ts +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/utils.ts @@ -1,5 +1,52 @@ +import round from 'lodash/round' +import omitBy from 'lodash/omitBy' +import type { WellIngredientVolumeData } from '../../../../steplist' + export const capitalizeFirstLetterAfterNumber = (title: string): string => title.replace( /(^[\d\W]*)([a-zA-Z])/, (match, prefix, firstLetter) => `${prefix}${firstLetter.toUpperCase()}` ) + +const VOLUME_SIG_DIGITS_DEFAULT = 2 +export function formatVolume( + inputVolume?: string | number | null, + sigDigits: number = VOLUME_SIG_DIGITS_DEFAULT +): string { + if (typeof inputVolume === 'number') { + // don't add digits to numbers with nothing to the right of the decimal + const digits = inputVolume.toString().split('.')[1] ? sigDigits : 0 + return String(round(inputVolume, digits)) + } + + return inputVolume || '' +} +const PERCENTAGE_DECIMALS_ALLOWED = 1 +export const formatPercentage = (part: number, total: number): string => { + return `${round((part / total) * 100, PERCENTAGE_DECIMALS_ALLOWED)}%` +} + +export const compactPreIngreds = ( + preIngreds: WellIngredientVolumeData +): Partial< + | { + [ingredId: string]: + | { + volume: number + } + | undefined + } + | { + [well: string]: + | { + [ingredId: string]: { + volume: number + } + } + | undefined + } +> => { + return omitBy(preIngreds, ingred => { + return typeof ingred?.volume === 'number' && ingred.volume <= 0 + }) +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/__tests__/ProtocolSteps.test.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/__tests__/ProtocolSteps.test.tsx index b9f0f0a9adb..41f6f014227 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/__tests__/ProtocolSteps.test.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/__tests__/ProtocolSteps.test.tsx @@ -3,13 +3,15 @@ import '@testing-library/jest-dom/vitest' import { fireEvent, screen } from '@testing-library/react' import { renderWithProviders } from '../../../../__testing-utils__' import { getUnsavedForm } from '../../../../step-forms/selectors' +import { getSelectedSubstep } from '../../../../ui/steps/selectors' import { DeckSetupContainer } from '../../DeckSetup' import { OffDeck } from '../../Offdeck' import { ProtocolSteps } from '..' -import { TimelineToolbox } from '../Timeline' +import { SubstepsToolbox, TimelineToolbox } from '../Timeline' vi.mock('../../Offdeck') vi.mock('../../../../step-forms/selectors') +vi.mock('../../../../ui/steps/selectors') vi.mock('../StepForm') vi.mock('../../DeckSetup') vi.mock('../Timeline') @@ -25,6 +27,8 @@ describe('ProtocolSteps', () => { ) vi.mocked(OffDeck).mockReturnValue(
mock OffDeck
) vi.mocked(getUnsavedForm).mockReturnValue(null) + vi.mocked(getSelectedSubstep).mockReturnValue(null) + vi.mocked(SubstepsToolbox).mockReturnValue(
mock SubstepsToolbox
) }) it('renders each component in ProtocolSteps', () => { @@ -48,4 +52,10 @@ describe('ProtocolSteps', () => { render() expect(screen.queryByText('offDeck')).not.toBeInTheDocument() }) + + it('renders the substepToolbox when selectedSubstep is not null', () => { + vi.mocked(getSelectedSubstep).mockReturnValue('mockId') + render() + screen.getByText('mock SubstepsToolbox') + }) }) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx index e4c6171a9ae..f2e02876b52 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx @@ -11,14 +11,16 @@ import { ToggleGroup, } from '@opentrons/components' import { getUnsavedForm } from '../../../step-forms/selectors' +import { getSelectedSubstep } from '../../../ui/steps/selectors' import { DeckSetupContainer } from '../DeckSetup' import { OffDeck } from '../Offdeck' -import { TimelineToolbox } from './Timeline' +import { TimelineToolbox, SubstepsToolbox } from './Timeline' import { StepForm } from './StepForm' export function ProtocolSteps(): JSX.Element { const { t } = useTranslation(['starting_deck_state']) const formData = useSelector(getUnsavedForm) + const selectedSubstep = useSelector(getSelectedSubstep) const leftString = t('onDeck') const rightString = t('offDeck') const [deckView, setDeckView] = useState< @@ -35,6 +37,7 @@ export function ProtocolSteps(): JSX.Element { return ( <> + {selectedSubstep ? : null} ({ + type: 'TOGGLE_VIEW_SUBSTEP', + payload: stepId, +}) diff --git a/protocol-designer/src/ui/steps/actions/types.ts b/protocol-designer/src/ui/steps/actions/types.ts index 0205c9eac52..566f3f1eff0 100644 --- a/protocol-designer/src/ui/steps/actions/types.ts +++ b/protocol-designer/src/ui/steps/actions/types.ts @@ -89,3 +89,9 @@ export interface SelectMultipleStepsAction { lastSelected: StepIdType } } + +export type ViewSubstep = StepIdType | null +export interface ToggleViewSubstepAction { + type: 'TOGGLE_VIEW_SUBSTEP' + payload: ViewSubstep +} diff --git a/protocol-designer/src/ui/steps/reducers.ts b/protocol-designer/src/ui/steps/reducers.ts index 6654a55b32c..2b16233a83a 100644 --- a/protocol-designer/src/ui/steps/reducers.ts +++ b/protocol-designer/src/ui/steps/reducers.ts @@ -191,12 +191,25 @@ const wellSelectionLabwareKey: Reducer = handleActions( }, null ) + +const selectedSubstep: Reducer = handleActions( + { + TOGGLE_VIEW_SUBSTEP: ( + state, + action: { + payload: StepIdType + } + ) => action.payload, + }, + null +) export interface StepsState { collapsedSteps: CollapsedStepsState selectedItem: SelectedItemState hoveredItem: HoveredItemState hoveredSubstep: SubstepIdentifier wellSelectionLabwareKey: string | null + selectedSubstep: StepIdType | null } export const _allReducers = { collapsedSteps, @@ -204,6 +217,7 @@ export const _allReducers = { hoveredItem, hoveredSubstep, wellSelectionLabwareKey, + selectedSubstep, } export const rootReducer: Reducer = combineReducers( _allReducers diff --git a/protocol-designer/src/ui/steps/selectors.ts b/protocol-designer/src/ui/steps/selectors.ts index fe7a7ee4b3d..53848c4a28a 100644 --- a/protocol-designer/src/ui/steps/selectors.ts +++ b/protocol-designer/src/ui/steps/selectors.ts @@ -420,3 +420,8 @@ function getMixMultiSelectDisabledFields(forms: FormData[]): DisabledFields { } return disabledFields } + +export const getSelectedSubstep: Selector = createSelector( + rootSelector, + (state: StepsState) => state.selectedSubstep +)