Skip to content

Commit

Permalink
feat(protocol-designer): allow user to set touch-tip offset (#2691)
Browse files Browse the repository at this point in the history
* refactor tip offset components to allow them to also work with touch tip
* add new fields for forms and to step-generation command creator args to allow touch-tip offsets
* rename fields used only by mix to have mix_ prefix
* refactor field types

Closes #2540
  • Loading branch information
IanLondon authored Nov 19, 2018
1 parent ad00dd8 commit d5b7d8a
Show file tree
Hide file tree
Showing 28 changed files with 319 additions and 174 deletions.
6 changes: 4 additions & 2 deletions protocol-designer/src/components/StepEditForm/MixForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,15 @@ const MixForm = (props: MixFormProps): React.Element<typeof React.Fragment> => {
<StepCheckboxRow name="dispense_blowout_checkbox" label='Blow out'>
<LabwareDropdown name="dispense_blowout_labware" className={styles.full_width} {...focusHandlers} />
</StepCheckboxRow>
<StepCheckboxRow name="touchTip" label='Touch tip' />
<StepCheckboxRow name="touchTip" label='Touch tip'>
<TipPositionInput fieldName="mix_touchTipMmFromBottom" />
</StepCheckboxRow>
</FormGroup>
</div>

<div className={styles.middle_settings_column}>
<ChangeTipField stepType="mix" name="aspirate_changeTip" />
<TipPositionInput />
<TipPositionInput fieldName="mix_mmFromBottom" />
</div>
<div className={styles.right_settings_column}>
<FlowRateField
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
import * as React from 'react'
import {connect} from 'react-redux'
import {actions, selectors} from '../../steplist'
import {getFieldErrors, processField, type StepFieldName} from '../../steplist/fieldLevel'
import {getFieldErrors, processField} from '../../steplist/fieldLevel'
import getTooltipForField from './getTooltipForField'
import {HoverTooltip, type HoverTooltipHandlers} from '@opentrons/components'
import type {BaseState, ThunkDispatch} from '../../types'
import type {StepFieldName} from '../../form-types'

type FieldRenderProps = {
value: ?mixed,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import * as React from 'react'
import cx from 'classnames'
import {connect} from 'react-redux'
import clamp from 'lodash/clamp'
import round from 'lodash/round'
import {
Modal,
Expand All @@ -13,98 +14,121 @@ import {
HandleKeypress,
} from '@opentrons/components'
import i18n from '../../../localization'
import {
DEFAULT_MM_FROM_BOTTOM_ASPIRATE,
DEFAULT_MM_FROM_BOTTOM_DISPENSE,
} from '../../../constants'
import {Portal} from '../../portals/MainPageModalPortal'
import modalStyles from '../../modals/modal.css'
import {actions} from '../../../steplist'
import TipPositionZAxisViz from './TipPositionZAxisViz'

import styles from './TipPositionInput.css'
import * as utils from './utils'
import {getIsTouchTipField, type TipOffsetFields} from '../../../form-types'

const SMALL_STEP_MM = 1
const LARGE_STEP_MM = 10
const DECIMALS_ALLOWED = 1

type DP = { updateValue: (string) => mixed }
type DP = { updateValue: (?number) => mixed }

type OP = {
mmFromBottom: number,
wellHeightMM: number,
isOpen: boolean,
closeModal: () => mixed,
prefix: 'aspirate' | 'dispense',
defaultMm: number,
fieldName: TipOffsetFields,
}

type Props = OP & DP
type State = { value: string }
type State = { value: ?number }

const formatValue = (value: number | string): string => (
String(round(Number(value), DECIMALS_ALLOWED))
const roundValue = (value: number | string): number => (
round(Number(value), DECIMALS_ALLOWED)
)

class TipPositionModal extends React.Component<Props, State> {
constructor (props: Props) {
super(props)
this.state = { value: formatValue(props.mmFromBottom) }
const initialValue = props.mmFromBottom
? roundValue(props.mmFromBottom)
: roundValue(this.getDefaultMmFromBottom())
this.state = { value: initialValue }
}
componentDidUpdate (prevProps) {
if (prevProps.wellHeightMM !== this.props.wellHeightMM) {
this.setState({value: formatValue(this.props.mmFromBottom)})
this.setState({value: roundValue(this.props.mmFromBottom)})
}
}
applyChanges = () => {
this.props.updateValue(formatValue(this.state.value || 0))
const {value} = this.state
console.log('applying changes', value)
this.props.updateValue(value == null ? null : roundValue(value))
this.props.closeModal()
}
getDefaultMmFromBottom = (): number => {
const {fieldName, wellHeightMM} = this.props
return utils.getDefaultMmFromBottom({fieldName, wellHeightMM})
}
getMinMaxMmFromBottom = (): {maxMmFromBottom: number, minMmFromBottom: number} => {
if (getIsTouchTipField(this.props.fieldName)) {
return {
maxMmFromBottom: roundValue(this.props.wellHeightMM),
minMmFromBottom: roundValue(this.props.wellHeightMM / 2),
}
}
return {
maxMmFromBottom: roundValue(this.props.wellHeightMM * 2),
minMmFromBottom: 0,
}
}

handleReset = () => {
// NOTE: when `prefix` isn't set (eg in the Mix form), we'll use
// the value `DEFAULT_MM_FROM_BOTTOM_DISPENSE` (since we gotta pick something :/)
const defaultMm = this.props.prefix === 'aspirate'
? DEFAULT_MM_FROM_BOTTOM_ASPIRATE
: DEFAULT_MM_FROM_BOTTOM_DISPENSE
this.setState({value: formatValue(defaultMm)}, this.applyChanges)
this.props.closeModal()
this.setState({value: null}, this.applyChanges)
}
handleCancel = () => {
this.setState({value: formatValue(this.props.mmFromBottom)}, this.applyChanges)
this.props.closeModal()
this.setState({value: roundValue(this.props.mmFromBottom)}, this.props.closeModal)
}
handleDone = () => {
this.applyChanges()
this.props.closeModal()
}
handleChange = (e: SyntheticEvent<HTMLSelectElement>) => {
const {value} = e.currentTarget
const valueFloat = Number(formatValue(value))
const maximumHeightMM = (this.props.wellHeightMM * 2)

if (!value) {
this.setState({value})
} else if (valueFloat > maximumHeightMM) {
this.setState({value: formatValue(maximumHeightMM)})
} else if (valueFloat >= 0) {
const numericValue = value.replace(/[^.0-9]/, '')
this.setState({value: numericValue.replace(/(\d*[.]{1}\d{1})(\d*)/, (match, group1) => group1)})
} else {
this.setState({value: formatValue(0)})
handleChange = (newValueRaw: string | number) => {
const {maxMmFromBottom, minMmFromBottom} = this.getMinMaxMmFromBottom()
// if string, strip non-number characters from string and cast to number
const valueFloatUnrounded = (typeof newValueRaw === 'string')
? Number(newValueRaw
.replace(/[^.0-9]/, '')
.replace(/(\d*[.]{1}\d{1})(\d*)/, (match, group1) => group1))
: newValueRaw
const valueFloat = roundValue(valueFloatUnrounded)

if (!Number.isFinite(valueFloat)) {
return
}

this.setState({value: clamp(valueFloat, minMmFromBottom, maxMmFromBottom)})
}
makeHandleIncrement = (step: number) => () => {
handleInputFieldChange = (e: SyntheticEvent<HTMLSelectElement>) => {
this.handleChange(e.currentTarget.value)
}
handleIncrementDecrement = (delta: number) => {
const {value} = this.state
const incrementedValue = parseFloat(value || 0) + step
const maximumHeightMM = (this.props.wellHeightMM * 2)
this.setState({value: formatValue(Math.min(incrementedValue, maximumHeightMM))})
const prevValue = this.state.value == null
? this.getDefaultMmFromBottom()
: value

this.handleChange(prevValue + delta)
}
makeHandleIncrement = (step: number) => () => {
this.handleIncrementDecrement(step)
}
makeHandleDecrement = (step: number) => () => {
const nextValueFloat = parseFloat(this.state.value || 0) - step
this.setState({value: formatValue(nextValueFloat < 0 ? 0 : nextValueFloat)})
this.handleIncrementDecrement(step * -1)
}
render () {
if (!this.props.isOpen) return null
const {value} = this.state
const {wellHeightMM} = this.props
const {fieldName, wellHeightMM} = this.props
const {maxMmFromBottom, minMmFromBottom} = this.getMinMaxMmFromBottom()

return (
<Portal>
Expand All @@ -122,33 +146,37 @@ class TipPositionModal extends React.Component<Props, State> {
onCloseClick={this.handleCancel}>
<div className={styles.modal_header}>
<h4>{i18n.t('modal.tip_position.title')}</h4>
<p>{i18n.t('modal.tip_position.body')}</p>
<p>{i18n.t(`modal.tip_position.body.${fieldName}`)}</p>
</div>
<div className={styles.main_row}>
<div className={styles.leftHalf}>
<FormGroup label={i18n.t('modal.tip_position.field_label')}>
<InputField
className={styles.position_from_bottom_input}
onChange={this.handleChange}
onChange={this.handleInputFieldChange}
units="mm"
value={value } />
value={(value != null) ? String(value) : ''} />
</FormGroup>
<div className={styles.viz_group}>
<div className={styles.adjustment_buttons}>
<OutlineButton
className={styles.adjustment_button}
disabled={parseFloat(value) >= (wellHeightMM * 2)}
disabled={value != null && value >= maxMmFromBottom}
onClick={this.makeHandleIncrement(SMALL_STEP_MM)}>
<Icon name="plus" />
</OutlineButton>
<OutlineButton
className={styles.adjustment_button}
disabled={parseFloat(value) <= 0}
disabled={value != null && value <= minMmFromBottom}
onClick={this.makeHandleDecrement(SMALL_STEP_MM)}>
<Icon name="minus" />
</OutlineButton>
</div>
<TipPositionZAxisViz mmFromBottom={value} wellHeightMM={wellHeightMM} />
<TipPositionZAxisViz
mmFromBottom={(value != null)
? value
: this.getDefaultMmFromBottom()}
wellHeightMM={wellHeightMM} />
</div>
</div>
<div className={styles.rightHalf}>{/* TODO: xy tip positioning */}</div>
Expand All @@ -174,14 +202,9 @@ class TipPositionModal extends React.Component<Props, State> {
}

const mapDTP = (dispatch: Dispatch, ownProps: OP): DP => {
// NOTE: not interpolating prefix because breaks flow string enum

let fieldName = 'mmFromBottom'
if (ownProps.prefix === 'aspirate') fieldName = 'aspirate_mmFromBottom'
else if (ownProps.prefix === 'dispense') fieldName = 'dispense_mmFromBottom'
return {
updateValue: (value) => {
dispatch(actions.changeFormInput({update: {[fieldName]: value}}))
dispatch(actions.changeFormInput({update: {[ownProps.fieldName]: value}}))
},
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ import styles from './TipPositionInput.css'
const WELL_HEIGHT_PIXELS = 48
const PIXEL_DECIMALS = 2
type Props = {
mmFromBottom: string,
mmFromBottom: number,
wellHeightMM: number,
}

const TipPositionZAxisViz = (props: Props) => {
const fractionOfWellHeight = Number(props.mmFromBottom) / props.wellHeightMM
const fractionOfWellHeight = props.mmFromBottom / props.wellHeightMM
const pixelsFromBottom = (Number(fractionOfWellHeight) * WELL_HEIGHT_PIXELS) - WELL_HEIGHT_PIXELS
const roundedPixelsFromBottom = String(round(pixelsFromBottom, PIXEL_DECIMALS))
const bottomPx = props.wellHeightMM ? roundedPixelsFromBottom : (parseFloat(props.mmFromBottom) - WELL_HEIGHT_PIXELS)
const roundedPixelsFromBottom = round(pixelsFromBottom, PIXEL_DECIMALS)
const bottomPx = props.wellHeightMM ? roundedPixelsFromBottom : (props.mmFromBottom - WELL_HEIGHT_PIXELS)
return (
<div className={styles.viz_wrapper}>
<img
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,27 @@ import { getLabware } from '@opentrons/shared-data'
import {selectors as labwareIngredsSelectors} from '../../../labware-ingred/reducers'
import i18n from '../../../localization'
import {selectors} from '../../../steplist'
import stepFormStyles from '../StepEditForm.css'
import styles from './TipPositionInput.css'
import TipPositionModal from './TipPositionModal'
import {getIsTouchTipField} from '../../../form-types'
import {getDefaultMmFromBottom} from './utils'
import type {BaseState} from '../../../types'
import type {StepFieldName, TipOffsetFields} from '../../../form-types'

type OP = {prefix?: 'aspirate' | 'dispense'}
function getLabwareFieldForPositioningField (fieldName: TipOffsetFields): StepFieldName {
const fieldMap: {[TipOffsetFields]: StepFieldName} = {
aspirate_mmFromBottom: 'aspirate_labware',
aspirate_touchTipMmFromBottom: 'aspirate_labware',
dispense_mmFromBottom: 'dispense_labware',
dispense_touchTipMmFromBottom: 'dispense_labware',
mix_mmFromBottom: 'labware',
mix_touchTipMmFromBottom: 'labware',
}
return fieldMap[fieldName]
}

type OP = {fieldName: TipOffsetFields}
type SP = {
mmFromBottom: ?string,
wellHeightMM: ?number,
Expand All @@ -28,43 +44,58 @@ class TipPositionInput extends React.Component<OP & SP, TipPositionInputState> {
handleClose = () => { this.setState({isModalOpen: false}) }

render () {
const {fieldName, mmFromBottom, wellHeightMM} = this.props
const disabled = !this.props.wellHeightMM
const isTouchTipField = getIsTouchTipField(this.props.fieldName)

const Wrapper = ({children, hoverTooltipHandlers}) => isTouchTipField
? <React.Fragment>{children}</React.Fragment>
: <FormGroup
label={i18n.t('form.step_edit_form.field.tip_position.label')}
disabled={disabled}
className={styles.well_order_input}
hoverTooltipHandlers={hoverTooltipHandlers}>
{children}
</FormGroup>

let value = ''
if (wellHeightMM != null) {
// show default value for field in parens if no mmFromBottom value is selected
value = (mmFromBottom != null)
? mmFromBottom
: `Default (${getDefaultMmFromBottom({fieldName, wellHeightMM})})`
}

return (
<HoverTooltip
tooltipComponent={i18n.t('tooltip.step_fields.defaults.tipPosition')}
>{(hoverTooltipHandlers) => (
<FormGroup
label={i18n.t('form.step_edit_form.field.tip_position.label')}
disabled={!this.props.wellHeightMM}
className={styles.well_order_input}
hoverTooltipHandlers={hoverTooltipHandlers}
>
<Wrapper hoverTooltipHandlers={hoverTooltipHandlers}>
<TipPositionModal
prefix={this.props.prefix}
fieldName={fieldName}
closeModal={this.handleClose}
wellHeightMM={this.props.wellHeightMM}
mmFromBottom={this.props.mmFromBottom}
wellHeightMM={wellHeightMM}
mmFromBottom={mmFromBottom}
isOpen={this.state.isModalOpen} />
<InputField
className={isTouchTipField
? stepFormStyles.full_width
: undefined} // TODO Ian 2018-11-16 change InputField props.className to be `?string` so this can be `null` not `undefined`?
readOnly
onClick={this.handleOpen}
value={this.props.wellHeightMM ? this.props.mmFromBottom : null}
value={value}
units="mm" />
</FormGroup>
</Wrapper>
)}
</HoverTooltip>
)
}
}

const mapSTP = (state: BaseState, ownProps: OP): SP => {
const formData = selectors.getUnsavedForm(state)
// NOTE: not interpolating prefix because breaks flow string enum
let fieldName = 'mmFromBottom'
if (ownProps.prefix === 'aspirate') fieldName = 'aspirate_mmFromBottom'
else if (ownProps.prefix === 'dispense') fieldName = 'dispense_mmFromBottom'

let labwareFieldName = 'labware'
if (ownProps.prefix === 'aspirate') labwareFieldName = 'aspirate_labware'
else if (ownProps.prefix === 'dispense') labwareFieldName = 'dispense_labware'
const {fieldName} = ownProps
const labwareFieldName = getLabwareFieldForPositioningField(ownProps.fieldName)

let wellHeightMM = null
if (formData && formData[labwareFieldName]) {
Expand All @@ -79,6 +110,7 @@ const mapSTP = (state: BaseState, ownProps: OP): SP => {
console.warn('the specified source labware definition could not be located')
}
}

return {
wellHeightMM,
mmFromBottom: formData && formData[fieldName],
Expand Down
Loading

0 comments on commit d5b7d8a

Please sign in to comment.