Skip to content

Commit

Permalink
feat(protocol-designer): add tooltip for labware name/type on steplist (
Browse files Browse the repository at this point in the history
#2497)

Because of the cramped space on the step list, it is often the case that the labware name is
truncated. For enhanced context, this adds a tooltip that launches on hover of the labware name and
displays the full name and labware type. Also create interface for tooltips to render into a given portal.

Closes #2421
  • Loading branch information
b-cooper authored Oct 18, 2018
1 parent a6e3a24 commit 4890374
Show file tree
Hide file tree
Showing 15 changed files with 192 additions and 45 deletions.
1 change: 1 addition & 0 deletions components/src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

// ========= STYLE ================

export const AIR = '__air__'
export const MIXED_WELL_COLOR = '#9b9b9b' // NOTE: matches `--c-med-gray` in colors.css

// TODO factor into CSS or constants or elsewhere
Expand Down
16 changes: 14 additions & 2 deletions components/src/tooltips/HoverTooltip.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ export type HoverTooltipHandlers = {
type PopperProps = React.ElementProps<typeof Popper>
type Props = {
tooltipComponent?: React.Node,
portal?: React.ComponentType<*>,
placement?: $PropertyType<PopperProps, 'placement'>,
positionFixed?: $PropertyType<PopperProps, 'positionFixed'>,
modifiers?: $PropertyType<PopperProps, 'modifiers'>,
children: (?HoverTooltipHandlers) => React.Node,
forceOpen?: boolean, // NOTE: mostly for debugging/positioning
}
type State = {isOpen: boolean}
class HoverTooltip extends React.Component<Props, State> {
Expand All @@ -34,6 +36,11 @@ class HoverTooltip extends React.Component<Props, State> {
this.state = {isOpen: false}
}

componentWillUnmount () {
if (this.closeTimeout) clearTimeout(this.closeTimeout)
if (this.openTimeout) clearTimeout(this.openTimeout)
}

delayedOpen = () => {
if (this.closeTimeout) clearTimeout(this.closeTimeout)
this.openTimeout = setTimeout(() => this.setState({isOpen: true}), OPEN_DELAY_MS)
Expand All @@ -52,7 +59,7 @@ class HoverTooltip extends React.Component<Props, State> {
{({ref}) => this.props.children({ref, onMouseEnter: this.delayedOpen, onMouseLeave: this.delayedClose})}
</Reference>
{
this.state.isOpen &&
(this.props.forceOpen || this.state.isOpen) &&
<Popper
placement={this.props.placement}
modifiers={{
Expand All @@ -66,12 +73,17 @@ class HoverTooltip extends React.Component<Props, State> {
if (placement === 'left' || placement === 'right') {
arrowStyle = {top: '0.6em'}
}
return (
const tooltipContents = (
<div ref={ref} className={styles.tooltip_box} style={style} data-placement={placement}>
{this.props.tooltipComponent}
<div className={cx(styles.arrow, styles[placement])} ref={arrowProps.ref} style={arrowStyle} />
</div>
)
if (this.props.portal) {
const PortalClass = this.props.portal
return <PortalClass>{tooltipContents}</PortalClass>
}
return tooltipContents
}}
</Popper>
}
Expand Down
8 changes: 5 additions & 3 deletions components/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
MIXED_WELL_COLOR,
} from '@opentrons/components'

import {AIR} from './constants'

export const humanizeLabwareType = startCase

export const wellNameSplit = (wellName: string): [string, string] => {
Expand All @@ -26,9 +28,9 @@ export const wellNameSplit = (wellName: string): [string, string] => {
return [letters, numbers]
}

// TODO Ian 2018-07-20: make sure '__air__' or other pseudo-ingredients don't get in here
export const ingredIdsToColor = (groupIds: Array<string>): ?string => {
if (groupIds.length === 0) return null
if (groupIds.length === 1) return swatchColors(Number(groupIds[0]))
const filteredIngredIds = groupIds.filter(id => id !== AIR)
if (filteredIngredIds.length === 0) return null
if (filteredIngredIds.length === 1) return swatchColors(Number(filteredIngredIds[0]))
return MIXED_WELL_COLOR
}
Original file line number Diff line number Diff line change
@@ -1,31 +1,51 @@
// @flow
import * as React from 'react'
import cx from 'classnames'
import {Icon} from '@opentrons/components'
import {Icon, HoverTooltip} from '@opentrons/components'
import {PDListItem} from '../lists'
import styles from './StepItem.css'
import LabwareTooltipContents from './LabwareTooltipContents'
import type {Labware} from '../../labware-ingred/types'
import {labwareToDisplayName} from '../../labware-ingred/utils'
import {Portal} from './TooltipPortal'

type AspirateDispenseHeaderProps = {
sourceLabwareName: ?string,
destLabwareName: ?string,
sourceLabware: ?Labware,
destLabware: ?Labware,
}

function AspirateDispenseHeader (props: AspirateDispenseHeaderProps) {
const {sourceLabwareName, destLabwareName} = props
const {sourceLabware, destLabware} = props

return (
<React.Fragment>
<li className={styles.aspirate_dispense}>
<span>ASPIRATE</span>
<span className={styles.spacer}/>
<span>DISPENSE</span>
<span>ASPIRATE</span>
<span className={styles.spacer}/>
<span>DISPENSE</span>
</li>

<PDListItem className={cx(styles.step_subitem_column_header, styles.emphasized_cell)}>
<span>{sourceLabwareName}</span>
<HoverTooltip
portal={Portal}
tooltipComponent={<LabwareTooltipContents labware={sourceLabware} />}>
{(hoverTooltipHandlers) => (
<span {...hoverTooltipHandlers} className={styles.labware_display_name}>
{sourceLabware && labwareToDisplayName(sourceLabware)}
</span>
)}
</HoverTooltip>
{/* This is always a "transfer icon" (arrow pointing right) for any step: */}
<Icon name='ot-transfer' />
<span>{destLabwareName}</span>
<HoverTooltip
portal={Portal}
tooltipComponent={<LabwareTooltipContents labware={destLabware} />}>
{(hoverTooltipHandlers) => (
<span {...hoverTooltipHandlers} className={styles.labware_display_name}>
{destLabware && labwareToDisplayName(destLabware)}
</span>
)}
</HoverTooltip>
</PDListItem>
</React.Fragment>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// @flow
import * as React from 'react'
import type {Labware} from '../../labware-ingred/types'
import {labwareToDisplayName} from '../../labware-ingred/utils'
import styles from './StepItem.css'

type LabwareTooltipContentsProps = {labware: ?Labware}
const LabwareTooltipContents = ({labware}: LabwareTooltipContentsProps) => {
const displayName = labware && labwareToDisplayName(labware)
return (
<div className={styles.labware_tooltip_contents}>
<p className={styles.labware_name}>{displayName}</p>
{labware && labware.type !== displayName &&
<React.Fragment>
<div className={styles.labware_spacer} />
<p>{labware && labware.type}</p>
</React.Fragment>
}
</div>
)
}

export default LabwareTooltipContents
29 changes: 22 additions & 7 deletions protocol-designer/src/components/steplist/MixHeader.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,34 @@
// @flow
import * as React from 'react'
import cx from 'classnames'
import {HoverTooltip} from '@opentrons/components'
import {PDListItem} from '../lists'
import styles from './StepItem.css'
import type {Labware} from '../../labware-ingred/types'
import LabwareTooltipContents from './LabwareTooltipContents'
import {Portal} from './TooltipPortal'

type Props = {
volume: ?string,
times: ?string,
labwareName: ?string,
labware: ?Labware,
}

export default function MixHeader (props: Props) {
const {volume, times, labwareName} = props
return <PDListItem className={styles.step_subitem}>
<span className={styles.emphasized_cell}>{labwareName}</span>
<span>{volume} uL</span>
<span>{times}x</span>
</PDListItem>
const {volume, times, labware} = props
return (
<PDListItem className={styles.step_subitem}>
<HoverTooltip
portal={Portal}
tooltipComponent={<LabwareTooltipContents labware={labware} />}>
{(hoverTooltipHandlers) => (
<span {...hoverTooltipHandlers} className={cx(styles.emphasized_cell, styles.labware_display_name)}>
{labware && labware.name}
</span>
)}
</HoverTooltip>
<span>{volume} uL</span>
<span>{times}x</span>
</PDListItem>
)
}
22 changes: 22 additions & 0 deletions protocol-designer/src/components/steplist/StepItem.css
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@

.liquid_tooltip_contents {
margin: 0.5em;
max-width: 20rem;
}

.ingred_row {
Expand Down Expand Up @@ -129,3 +130,24 @@
.multi_substep_header {
font-style: italic;
}

.labware_name {
font-weight: var(--fw-semibold);
}

.labware_spacer {
width: 0.5rem;
height: 0.5rem;
}

.labware_tooltip_contents {
margin: 0.5rem;
max-width: 20rem;
display: flex;
justify-content: space-between;
align-items: center;
}

.labware_display_name {
cursor: default;
}
14 changes: 7 additions & 7 deletions protocol-designer/src/components/steplist/StepItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import MixHeader from './MixHeader'
import PauseStepItems from './PauseStepItems'
import StepDescription from '../StepDescription'
import {stepIconsByType, type StepIdType} from '../../form-types'

import type {Labware} from '../../labware-ingred/types'
import type {
SubstepIdentifier,
StepItemData,
Expand All @@ -28,7 +28,7 @@ type StepItemProps = {
hoveredSubstep: ?SubstepIdentifier,
ingredNames: WellIngredientNames,

getLabwareName: (labwareId: ?string) => ?string,
getLabware: (labwareId: ?string) => ?Labware,
handleSubstepHover: SubstepIdentifier => mixed,
onStepClick?: (event?: SyntheticEvent<>) => mixed,
onStepItemCollapseToggle?: (event?: SyntheticEvent<>) => mixed,
Expand Down Expand Up @@ -76,7 +76,7 @@ function getStepItemContents (stepItemProps: StepItemProps) {
const {
step,
substeps,
getLabwareName,
getLabware,
hoveredSubstep,
handleSubstepHover,
ingredNames,
Expand All @@ -103,13 +103,13 @@ function getStepItemContents (stepItemProps: StepItemProps) {
formData.stepType === 'distribute'
)
) {
const sourceLabwareName = getLabwareName(formData['aspirate_labware'])
const destLabwareName = getLabwareName(formData['dispense_labware'])
const sourceLabware = getLabware(formData['aspirate_labware'])
const destLabware = getLabware(formData['dispense_labware'])

result.push(
<AspirateDispenseHeader
key='transferlike-header'
{...{sourceLabwareName, destLabwareName}}
{...{sourceLabware, destLabware}}
/>
)
}
Expand All @@ -119,7 +119,7 @@ function getStepItemContents (stepItemProps: StepItemProps) {
<MixHeader key='mix-header'
volume={formData.volume}
times={formData.times}
labwareName={getLabwareName(formData.labware)}
labware={getLabware(formData.labware)}
/>
)
}
Expand Down
23 changes: 12 additions & 11 deletions protocol-designer/src/components/steplist/StepList.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {END_TERMINAL_TITLE} from '../../constants'
import {END_TERMINAL_ITEM_ID} from '../../steplist'

import type {StepIdType} from '../../form-types'
import {PortalRoot} from './TooltipPortal'

type StepListProps = {
orderedSteps: Array<StepIdType>,
Expand All @@ -18,18 +19,18 @@ type StepListProps = {

export default function StepList (props: StepListProps) {
return (
<SidePanel
title='Protocol Timeline'
onMouseLeave={props.handleStepHoverById && props.handleStepHoverById(null)}
>
<StartingDeckStateTerminalItem />
<React.Fragment>
<SidePanel
title='Protocol Timeline'
onMouseLeave={props.handleStepHoverById && props.handleStepHoverById(null)}>
<StartingDeckStateTerminalItem />

{props.orderedSteps.map((stepId: StepIdType) => (
<StepItem key={stepId} stepId={stepId} />
))}
{props.orderedSteps.map((stepId: StepIdType) => <StepItem key={stepId} stepId={stepId} />)}

<StepCreationButton />
<TerminalItem id={END_TERMINAL_ITEM_ID} title={END_TERMINAL_TITLE} />
</SidePanel>
<StepCreationButton />
<TerminalItem id={END_TERMINAL_ITEM_ID} title={END_TERMINAL_TITLE} />
</SidePanel>
<PortalRoot />
</React.Fragment>
)
}
3 changes: 3 additions & 0 deletions protocol-designer/src/components/steplist/SubstepRow.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import IngredPill from './IngredPill'
import {PDListItem} from '../lists'
import styles from './StepItem.css'
import {formatVolume, formatPercentage} from './utils'
import {Portal} from './TooltipPortal'

type SubstepRowProps = {|
volume?: ?number | ?string,
Expand Down Expand Up @@ -77,6 +78,7 @@ export default function SubstepRow (props: SubstepRowProps) {
onMouseEnter={props.onMouseEnter}
onMouseLeave={props.onMouseLeave}>
<HoverTooltip
portal={Portal}
tooltipComponent={(
<PillTooltipContents
well={props.source ? props.source.well : ''}
Expand All @@ -94,6 +96,7 @@ export default function SubstepRow (props: SubstepRowProps) {
<span className={styles.volume_cell}>{`${formatVolume(props.volume)} μL`}</span>
<span className={styles.emphasized_cell}>{props.dest && props.dest.well}</span>
<HoverTooltip
portal={Portal}
tooltipComponent={(
<PillTooltipContents
well={props.dest ? props.dest.well : ''}
Expand Down
39 changes: 39 additions & 0 deletions protocol-designer/src/components/steplist/TooltipPortal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// @flow
import * as React from 'react'
import ReactDom from 'react-dom'

type Props = {children: React.Node}

type State = {hasRoot: boolean}

const PORTAL_ROOT_ID = 'steplist-tooltip-portal-root'
const getPortalRoot = () => global.document.getElementById(PORTAL_ROOT_ID)

export function PortalRoot () {
return <div id={PORTAL_ROOT_ID} />
}

// the children of Portal are rendered into the PortalRoot if it exists in DOM
export class Portal extends React.Component<Props, State> {
$root: ?Element

constructor (props: Props) {
super(props)
this.$root = getPortalRoot()
this.state = {hasRoot: !!this.$root}
}

// on first launch, $portalRoot isn't in DOM; double check once we're mounted
// TODO(mc, 2018-10-08): prerender UI instead
componentDidMount () {
if (!this.$root) {
this.$root = getPortalRoot()
this.setState({hasRoot: !!this.$root})
}
}

render () {
if (!this.$root) return null
return ReactDom.createPortal(this.props.children, this.$root)
}
}
Loading

0 comments on commit 4890374

Please sign in to comment.