Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(components, app): add custom pipette select with category support #3996

Merged
merged 11 commits into from
Sep 17, 2019
Next Next commit
customize select a nit
  • Loading branch information
b-cooper committed Sep 17, 2019
commit 67d6c3730fd0b9963c49baa153f0601527d8e4e8
2 changes: 1 addition & 1 deletion components/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export * from './CenteredTextSvg'
export * from './deck'
export * from './forms'
export * from './icons'
export * from './instrument-diagram'
export * from './instrument'
export * from './interaction-enhancers'
export * from './lists'
export * from './modals'
Expand Down
55 changes: 55 additions & 0 deletions components/src/instrument/PipetteSelect.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
@import '@opentrons/components';

.dropdown_icon_wrapper {
position: absolute;
top: 0.25rem;
right: 0.25rem;
width: 1.25rem;
pointer-events: none;

& > svg {
color: var(--c-dark-gray);
}

&.flipped > svg {
transform: rotate(180deg);
}
}

.select_control {
@apply --font-body-1-dark;

background-color: var(--c-light-gray);
border: none;
padding: 0.25rem 0;
outline: none;
border-radius: var(--bd-radius-form-field);
height: 1.75rem;
box-shadow: none;

&.focus {
background-color: var(--c-white);
box-shadow: var(--shadow-lvl-1);
}
}

.select_menu {
@apply --font-body-1-dark;

background-color: var(--c-light-gray);
border-radius: var(--bd-radius-form-field);
}

.select_group {
&:not(:first-child) {
border-top: 1px solid color(var(--c-med-gray) alpha(0.4));
}
}

.select_caption {
font-size: var(--fs-caption);
}

.pipette_option {

}
179 changes: 179 additions & 0 deletions components/src/instrument/PipetteSelect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
// @flow
import * as React from 'react'
import cx from 'classnames'
import find from 'lodash/find'
import flatMap from 'lodash/flatMap'

import Select, { components } from 'react-select'

import { type PipetteNameSpec } from '@opentrons/shared-data'
import { Icon } from '../icons'
import styles from './PipetteSelect.css'

// TODO(mc, 2018-10-23): we use "name", react-select uses "label"; align usage
export type ValueType = ?string
export type OptionType = {|
value: ValueType,
label: React.Node,
isDisabled?: boolean,
|}
export type GroupType = {| options: Array<OptionType>, label: React.Node |}
export type SelectOption = OptionType | GroupType

export type MenuPosition = 'absolute' | 'fixed'

type OptionList = Array<OptionType>

type SelectProps = {
/** optional HTML id for container */
id?: string,
/** React-Select option, usually label, value */
options: Array<SelectOption>,
/** currently selected value */
value: ValueType,
/** change handler called with (name, value) */
onValueChange: (name: string, value: ValueType) => mixed,
}

const SELECT_STYLES = {
input: () => ({ padding: 0 }),
groupHeading: () => ({ margin: 0 }),
menu: () => ({ margin: 0 }),
menuList: () => ({ padding: 0 }),
}

const clearStyles = () => null

const getOpts = (og: OptionType | GroupType): OptionList => og.options || [og]

export default class SelectField extends React.Component<SelectProps> {
handleChange = (option: OptionType) => {
const { name, onValueChange } = this.props
onValueChange(name, option.value)
}

handleBlur = () => {
const { name, onLoseFocus } = this.props
if (onLoseFocus) onLoseFocus(name)
}

render() {
const {
id,
name,
options,
disabled,
placeholder,
className,
error,
menuPosition,
} = this.props
const allOptions = flatMap(options, getOpts)
const value = find(allOptions, { value: this.props.value }) || null
const caption = error || this.props.caption
const captionCx = cx(styles.select_caption, { [styles.error_color]: error })

return (
<div>
<Select
id={id}
name={name}
// $FlowFixMe: our types are more strict than react-select
options={options}
value={value}
error={error}
// $FlowFixMe: our types are more strict than react-select
onChange={this.handleChange}
onBlur={this.handleBlur}
isDisabled={disabled}
placeholder={placeholder}
styles={SELECT_STYLES}
components={{
Control,
DropdownIndicator,
Menu,
Group,
Option,
IndicatorSeparator: null,
}}
className={className}
menuPosition={menuPosition || 'absolute'}
/>
{caption && <p className={captionCx}>{caption}</p>}
</div>
)
}
}

function Control(props: *) {
mcous marked this conversation as resolved.
Show resolved Hide resolved
mcous marked this conversation as resolved.
Show resolved Hide resolved
return (
<components.Control
{...props}
getStyles={clearStyles}
className={cx(styles.select_control, {
[styles.focus]: props.isFocused,
})}
/>
)
}

function DropdownIndicator(props: *) {
mcous marked this conversation as resolved.
Show resolved Hide resolved
const iconWrapperCx = cx(styles.dropdown_icon_wrapper, {
[styles.flipped]: props.selectProps.menuIsOpen,
})

return (
components.DropdownIndicator && (
<components.DropdownIndicator {...props}>
<div className={iconWrapperCx}>
<Icon name="menu-down" width="100%" />
</div>
</components.DropdownIndicator>
)
)
}
// custom Menu (options dropdown) component
function Menu(props: *) {
mcous marked this conversation as resolved.
Show resolved Hide resolved
return (
<components.Menu {...props}>
<div className={styles.select_menu}>{props.children}</div>
</components.Menu>
)
}

// custom option group wrapper component
function Group(props: *) {
mcous marked this conversation as resolved.
Show resolved Hide resolved
return (
<components.Group
{...props}
className={styles.select_group}
getStyles={clearStyles}
/>
)
}

function Option(props: *) {
const { innerRef, innerProps, data } = props
const { channels, displayName, displayCategory } = data

const volumeClassMaybeMatch = displayName.match(/P\d+/)
const volumeClass = volumeClassMaybeMatch ? volumeClassMaybeMatch[0] : ''

let displayChannels = ''
switch (channels) {
case 1:
displayChannels = 'Single Channel'
case 8:
displayChannels = '8-Channel'
}

const cleanDisplayCategory = displayCategory === 'OG' ? '' : displayCategory

return (
<div ref={innerRef} className={styles.pipette_option} {...innerProps}>
<span>{volumeClass}</span>
<span>{displayChannels}</span>
<span>{cleanDisplayCategory}</span>
</div>
)
}
91 changes: 91 additions & 0 deletions components/src/instrument/PipetteSelect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
This component uses the `react-select` library. So the change/blur events are not
normal DOM events, but special ones. To make the difference clear, `SelectField`
b-cooper marked this conversation as resolved.
Show resolved Hide resolved
doesn't have `onChange` and `onBlur` but instead `onValueChange` and `onLoseFocus`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy paste typo and are we going to have onValueChange and onLoseFocus props in PipetteSelect? It seems like our reasoning for having those props in the SelectField component still apply here

b-cooper marked this conversation as resolved.
Show resolved Hide resolved

To use `<SelectField>` with Formik, **do not** try to of pass `formikProps.handleChange` and `formikProps.handleBlur` to `onChange` and `onBlur`. Instead, pass `formikProps.setFieldValue` and `formikProps.setFieldTouched` to `onValueChange` and `onLoseFocus`.

```js
initialState = { selectedValue: null }
;<div style={{ paddingBottom: '10rem' }}>
{/* Add some space because options menu does not behave well when overlapping with styleguidist's code blocks! */}
<SelectField
onValueChange={(name, value) => setState({ selectedValue: value })}
value={state.selectedValue}
caption={`Selected value: ${state.selectedValue}`}
error={state.selectedValue === 'dna' ? 'DNA IS NOT ALLOWED!' : null}
options={[
{
options: [
{
data: {
channels: 1,
displayName: 'P90 Single-Channel',
displayCategory: 'GEN2',
},
},
{
data: {
channels: 1,
displayName: 'P40 Single-Channel',
displayCategory: 'GEN2',
},
},
],
},
{
options: [
{
data: {
channels: 1,
displayName: 'P30 Single-Channel',
displayCategory: 'OG',
},
},
{
data: {
channels: 8,
displayName: 'P30 8-Channel',
displayCategory: 'OG',
},
},
{
data: {
channels: 1,
displayName: 'P70 Single-Channel',
displayCategory: 'OG',
},
},
{
data: {
channels: 8,
displayName: 'P70 8-Channel',
displayCategory: 'OG',
},
},
{
data: {
channels: 1,
displayName: 'P150 Single-Channel',
displayCategory: 'OG',
},
},
{
data: {
channels: 8,
displayName: 'P150 8-Channel',
displayCategory: 'OG',
},
},
{
data: {
channels: 1,
displayName: 'P900 Single-Channel',
displayCategory: 'OG',
},
},
],
},
]}
/>
</div>
```
4 changes: 2 additions & 2 deletions components/styleguide.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ module.exports = {
components: 'src/deck/[A-Z]*.js',
},
{
name: 'Instrument Diagram',
components: 'src/instrument-diagram/[A-Z]*.js',
name: 'Instrument',
components: 'src/instrument/[A-Z]*.js',
},
{
name: 'Structure',
Expand Down
21 changes: 21 additions & 0 deletions shared-data/js/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,24 @@ export type ModuleDefinition = {|
loadName: string,
quirks: Array<string>,
|}

export type FlowRateSpec = {|
value: number,
min: number,
max: number,
|}
export type PipetteNameSpec = {|
displayName: string,
displayCategory?: string,
minVolume: number,
maxVolume: number,
channels: number,
defaultAspirateFlowRate: FlowRateSpec,
defaultDispenseFlowRate: FlowRateSpec,
defaultBlowOutFlowRate: FlowRateSpec,
smoothieConfigs?: {
stepsPerMM: number,
homePosition: number,
travelDistance: number,
},
|}