Skip to content

Commit

Permalink
feat(app): add input screen for ODD numerical runtime parameters (#14858
Browse files Browse the repository at this point in the history
  • Loading branch information
ncdiehl11 authored Apr 11, 2024
1 parent 4c83fc1 commit 8bb14f4
Show file tree
Hide file tree
Showing 15 changed files with 299 additions and 219 deletions.
5 changes: 4 additions & 1 deletion app/src/assets/localization/en/protocol_setup.json
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,8 @@
"resolve": "Resolve",
"restart_setup_and_try": "Restart setup and try using different parameter values.",
"restart_setup": "Restart setup",
"restore_default": "Restore default values",
"restore_defaults": "Restore default values",
"restore_default": "Restore default value",
"robot_cal_description": "Robot calibration establishes how the robot knows where it is in relation to the deck. Accurate Robot calibration is essential to run protocols successfully. Robot calibration has 3 parts: Deck calibration, Tip Length calibration and Pipette Offset calibration.",
"robot_cal_help_title": "How Robot Calibration Works",
"robot_calibration_step_description_pipettes_only": "Review required instruments and calibrations for this protocol.",
Expand Down Expand Up @@ -265,6 +266,8 @@
"usb_port_connected": "USB Port {{port}}",
"value": "Value",
"values_are_view_only": "Values are view-only",
"value_out_of_range_generic": "Value must be in range",
"value_out_of_range": "Value must be between {{min}}-{{max}}",
"view_current_offsets": "View current offsets",
"view_moam": "View setup instructions for placing modules of the same type to the robot.",
"view_setup_instructions": "View setup instructions",
Expand Down
61 changes: 33 additions & 28 deletions app/src/atoms/InputField/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,15 +101,16 @@ function Input(props: InputFieldProps): JSX.Element {
tooltipText,
...inputProps
} = props
const error = props.error != null
const hasError = props.error != null
const value = props.isIndeterminate ?? false ? '' : props.value ?? ''
const placeHolder = props.isIndeterminate ?? false ? '-' : props.placeholder
const [targetProps, tooltipProps] = useHoverTooltip()

const OUTER_CSS = css`
@media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} {
grid-gap: ${SPACING.spacing8};
&:focus-within {
filter: ${error
filter: ${hasError
? 'none'
: `drop-shadow(0px 0px 10px ${COLORS.blue50})`};
}
Expand All @@ -121,7 +122,7 @@ function Input(props: InputFieldProps): JSX.Element {
background-color: ${COLORS.white};
border-radius: ${BORDERS.borderRadius4};
padding: ${SPACING.spacing8};
border: 1px ${BORDERS.styleSolid} ${error ? COLORS.red50 : COLORS.grey50};
border: 1px ${BORDERS.styleSolid} ${hasError ? COLORS.red50 : COLORS.grey50};
font-size: ${TYPOGRAPHY.fontSizeP};
width: 100%;
height: 2rem;
Expand All @@ -144,17 +145,20 @@ function Input(props: InputFieldProps): JSX.Element {
}
&:hover {
border: 1px ${BORDERS.styleSolid} ${error ? COLORS.red50 : COLORS.grey60};
border: 1px ${BORDERS.styleSolid}
${hasError ? COLORS.red50 : COLORS.grey60};
}
&:focus-visible {
border: 1px ${BORDERS.styleSolid} ${error ? COLORS.red50 : COLORS.grey60};
border: 1px ${BORDERS.styleSolid}
${hasError ? COLORS.red50 : COLORS.grey60};
outline: 2px ${BORDERS.styleSolid} ${COLORS.blue50};
outline-offset: 3px;
}
&:focus-within {
border: 1px ${BORDERS.styleSolid} ${error ? COLORS.red50 : COLORS.blue50};
border: 1px ${BORDERS.styleSolid}
${hasError ? COLORS.red50 : COLORS.blue50};
}
&:disabled {
Expand All @@ -168,15 +172,16 @@ function Input(props: InputFieldProps): JSX.Element {
@media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} {
height: ${size === 'small' ? '4.25rem' : '5rem'};
box-shadow: ${error ? BORDERS.shadowBig : 'none'};
box-shadow: ${hasError ? BORDERS.shadowBig : 'none'};
font-size: ${TYPOGRAPHY.fontSize28};
padding: ${SPACING.spacing16} ${SPACING.spacing24};
border: 2px ${BORDERS.styleSolid} ${error ? COLORS.red50 : COLORS.grey50};
border: 2px ${BORDERS.styleSolid}
${hasError ? COLORS.red50 : COLORS.grey50};
&:focus-within {
box-shadow: none;
border: ${error ? '2px' : '3px'} ${BORDERS.styleSolid}
${error ? COLORS.red50 : COLORS.blue50};
border: ${hasError ? '2px' : '3px'} ${BORDERS.styleSolid}
${hasError ? COLORS.red50 : COLORS.blue50};
}
& input {
Expand All @@ -191,19 +196,17 @@ function Input(props: InputFieldProps): JSX.Element {
`

const FORM_BOTTOM_SPACE_STYLE = css`
padding: ${SPACING.spacing4} 0rem;
padding-top: ${SPACING.spacing4};
@media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} {
padding: ${SPACING.spacing8} 0rem;
padding-bottom: 0;
}
`

const TITLE_STYLE = css`
color: ${error ? COLORS.red50 : COLORS.black90};
color: ${hasError ? COLORS.red50 : COLORS.black90};
padding-bottom: ${SPACING.spacing8};
font-size: ${TYPOGRAPHY.fontSizeLabel};
font-weight: ${TYPOGRAPHY.fontWeightSemiBold};
line-height: ${TYPOGRAPHY.lineHeight12};
align-text: ${textAlign};
text-align: ${textAlign};
@media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} {
font-size: ${TYPOGRAPHY.fontSize22};
font-weight: ${TYPOGRAPHY.fontWeightRegular};
Expand All @@ -214,9 +217,11 @@ function Input(props: InputFieldProps): JSX.Element {

const ERROR_TEXT_STYLE = css`
color: ${COLORS.red50};
padding-top: ${SPACING.spacing4};
@media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} {
font-size: ${TYPOGRAPHY.fontSize22};
color: ${COLORS.red50};
padding-top: ${SPACING.spacing8};
}
`

Expand All @@ -239,9 +244,14 @@ function Input(props: InputFieldProps): JSX.Element {
<Flex flexDirection={DIRECTION_COLUMN} width="100%">
{title != null ? (
<Flex gridGap={SPACING.spacing8}>
<Flex as="label" htmlFor={props.id} css={TITLE_STYLE}>
<StyledText
as="label"
fontWeight={TYPOGRAPHY.fontWeightSemiBold}
htmlFor={props.id}
css={TITLE_STYLE}
>
{title}
</Flex>
</StyledText>
{tooltipText != null ? (
<>
<Flex {...targetProps}>
Expand Down Expand Up @@ -277,16 +287,6 @@ function Input(props: InputFieldProps): JSX.Element {
<Flex css={UNITS_STYLE}>{props.units}</Flex>
) : null}
</Flex>
{props.error != null ? (
<Flex
color={COLORS.grey60}
fontSize={TYPOGRAPHY.fontSizeLabel}
paddingTop={SPACING.spacing4}
flexDirection={DIRECTION_COLUMN}
>
<Flex css={ERROR_TEXT_STYLE}>{props.error}</Flex>
</Flex>
) : null}
</Flex>
{props.caption != null ? (
<StyledText
Expand All @@ -306,6 +306,11 @@ function Input(props: InputFieldProps): JSX.Element {
{props.secondaryCaption}
</StyledText>
) : null}
{hasError ? (
<StyledText as="label" css={ERROR_TEXT_STYLE}>
{props.error}
</StyledText>
) : null}
</Flex>
)
}
1 change: 1 addition & 0 deletions app/src/organisms/ChooseProtocolSlideout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ export function ChooseProtocolSlideoutComponent(
key={runtimeParam.variableName}
type="number"
units={runtimeParam.suffix}
placeholder={runtimeParam.default.toString()}
value={value}
title={runtimeParam.displayName}
tooltipText={runtimeParam.description}
Expand Down
1 change: 1 addition & 0 deletions app/src/organisms/ChooseRobotSlideout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ export function ChooseRobotSlideout(
key={runtimeParam.variableName}
type="number"
units={runtimeParam.suffix}
placeholder={runtimeParam.default.toString()}
value={value}
title={runtimeParam.displayName}
tooltipText={runtimeParam.description}
Expand Down
14 changes: 14 additions & 0 deletions app/src/organisms/Devices/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import type {
Instruments,
PipetteData,
PipetteOffsetCalibration,
RunTimeParameterCreateData,
} from '@opentrons/api-client'
import type { RunTimeParameter } from '@opentrons/shared-data'

/**
* formats a string if it is in ISO 8601 date format
Expand Down Expand Up @@ -89,3 +91,15 @@ export function getShowPipetteCalibrationWarning(
}) ?? false
)
}

export function getRunTimeParameterValuesForRun(
runTimeParameters: RunTimeParameter[]
): RunTimeParameterCreateData {
return runTimeParameters.reduce(
(acc, param) =>
param.value !== param.default
? { ...acc, [param.variableName]: param.value }
: acc,
{}
)
}
7 changes: 1 addition & 6 deletions app/src/organisms/ProtocolSetupParameters/ChooseEnum.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,7 @@ export function ChooseEnum({
const { makeSnackbar } = useToaster()

const { t } = useTranslation(['protocol_setup', 'shared'])
if (parameter.type !== 'str') {
console.error(
`parameter type is expected to be a string for parameter ${parameter.displayName}`
)
}
const options = parameter.type === 'str' ? parameter.choices : undefined
const options = 'choices' in parameter ? parameter.choices : null
const handleOnClick = (newValue: string | number | boolean): void => {
setParameter(newValue, parameter.variableName)
}
Expand Down
164 changes: 164 additions & 0 deletions app/src/organisms/ProtocolSetupParameters/ChooseNumber.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import {
ALIGN_CENTER,
DIRECTION_COLUMN,
Flex,
SPACING,
StyledText,
TYPOGRAPHY,
} from '@opentrons/components'
import { InputField } from '../../atoms/InputField'
import { useToaster } from '../ToasterOven'
import { ChildNavigation } from '../ChildNavigation'
import { NumericalKeyboard } from '../../atoms/SoftwareKeyboard'
import type { NumberParameter } from '@opentrons/shared-data'

interface ChooseNumberProps {
handleGoBack: () => void
parameter: NumberParameter
setParameter: (value: number, variableName: string) => void
}

export function ChooseNumber({
handleGoBack,
parameter,
setParameter,
}: ChooseNumberProps): JSX.Element | null {
const { makeSnackbar } = useToaster()

const { i18n, t } = useTranslation(['protocol_setup', 'shared'])
const keyboardRef = React.useRef(null)
const [paramValue, setParamValue] = React.useState<string>(
String(parameter.value)
)

// We need to arbitrarily set the value of the keyboard to a string the
// same length as the initial parameter value (as string) when the component mounts
// so that the delete button operates properly on the exisiting input field value.
const [prevKeyboardValue, setPrevKeyboardValue] = React.useState<string>('')
React.useEffect(() => {
const arbitraryInput = new Array(paramValue).join('*')
// @ts-expect-error keyboard should expose for `setInput` method
keyboardRef.current?.setInput(arbitraryInput)
setPrevKeyboardValue(arbitraryInput)
}, [])

if (parameter.type !== 'int' && parameter.type !== 'float') {
console.log(`Incorrect parameter type: ${parameter.type}`)
return null
}
const handleClickGoBack = (newValue: number): void => {
if (error != null) {
makeSnackbar(t('value_out_of_range_generic'))
} else {
setParameter(newValue, parameter.variableName)
handleGoBack()
}
}

const handleKeyboardInput = (e: string): void => {
if (prevKeyboardValue.length < e.length) {
const lastDigit = e.slice(-1)
if (
!'.-'.includes(lastDigit) ||
(lastDigit === '.' && !paramValue.includes('.')) ||
(lastDigit === '-' && paramValue.length === 0)
) {
setParamValue(paramValue + lastDigit)
}
} else {
setParamValue(paramValue.slice(0, paramValue.length - 1))
}
setPrevKeyboardValue(e)
}

const paramValueAsNumber = Number(paramValue)
const resetValueDisabled = parameter.default === paramValueAsNumber
const { min, max } = parameter
const error =
paramValue === '' ||
Number.isNaN(paramValueAsNumber) ||
paramValueAsNumber < min ||
paramValueAsNumber > max
? t(`value_out_of_range`, {
min: parameter.type === 'int' ? min : min.toFixed(1),
max: parameter.type === 'int' ? max : max.toFixed(1),
})
: null

return (
<>
<ChildNavigation
header={i18n.format(parameter.displayName, 'sentenceCase')}
onClickBack={() => {
handleClickGoBack(paramValueAsNumber)
}}
buttonType="tertiaryLowLight"
buttonText={t('restore_default')}
onClickButton={() =>
resetValueDisabled
? makeSnackbar(t('no_custom_values'))
: setParamValue(String(parameter.default))
}
/>
<Flex
alignSelf={ALIGN_CENTER}
gridGap={SPACING.spacing48}
paddingX={SPACING.spacing40}
padding={`${SPACING.spacing16} ${SPACING.spacing40} ${SPACING.spacing40}`}
marginTop="7.75rem" // using margin rather than justify due to content moving with error message
alignItems={ALIGN_CENTER}
height="22rem"
>
<Flex
width="30.5rem"
height="100%"
gridGap={SPACING.spacing24}
flexDirection={DIRECTION_COLUMN}
marginTop="7.75rem"
>
<StyledText as="h4" textAlign={TYPOGRAPHY.textAlignLeft}>
{parameter.description}
</StyledText>
<InputField
type="text"
units={parameter.suffix}
placeholder={parameter.default.toString()}
value={paramValue}
title={parameter.displayName}
caption={
parameter.type === 'int'
? `${parameter.min}-${parameter.max}`
: `${parameter.min.toFixed(1)}-${parameter.max.toFixed(1)}`
}
error={error}
onChange={e => {
const updatedValue =
parameter.type === 'int'
? Math.round(e.target.valueAsNumber)
: e.target.valueAsNumber
setParamValue(
Number.isNaN(updatedValue) ? '' : String(updatedValue)
)
}}
/>
</Flex>
<Flex
paddingX={SPACING.spacing24}
height="21.25rem"
marginTop="7.75rem"
>
<NumericalKeyboard
keyboardRef={keyboardRef}
isDecimal={parameter.type === 'float'}
hasHyphen
onChange={e => {
handleKeyboardInput(e)
}}
/>
</Flex>
</Flex>
</>
)
}
Loading

0 comments on commit 8bb14f4

Please sign in to comment.