-
Notifications
You must be signed in to change notification settings - Fork 180
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(protocol-designer): implement liquids page interactivity #2478
Changes from 5 commits
6a0ad8a
f831b26
678dcf4
5e022ef
e95e3e5
3b592f9
2d8035c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -18,7 +18,7 @@ | |
lost-column: 4/16; | ||
} | ||
|
||
.individualize { | ||
.serialize { | ||
lost-column: 6/16; | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
@import '@opentrons/components'; | ||
|
||
.form_card { | ||
margin: 1rem; | ||
padding: 1rem; | ||
} | ||
|
||
.section { | ||
padding-bottom: 2rem; | ||
} | ||
|
||
.info_text { | ||
padding-bottom: 1.5rem; | ||
} | ||
|
||
.button_row { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. one day, we will not need to keep writing |
||
lost-utility: clearfix; | ||
|
||
& > * { | ||
lost-column: 1/6; | ||
} | ||
|
||
& > *:nth-child(2) { | ||
lost-offset: 3/6; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,143 @@ | ||
// @flow | ||
import * as React from 'react' | ||
import {connect} from 'react-redux' | ||
import assert from 'assert' | ||
import i18n from '../../localization' | ||
|
||
import * as labwareIngredActions from '../../labware-ingred/actions' | ||
import {selectors as labwareIngredSelectors} from '../../labware-ingred/reducers' | ||
import type {IngredInputs} from '../../labware-ingred/types' | ||
import type {BaseState, ThunkDispatch} from '../../types' | ||
|
||
import { | ||
Card, | ||
CheckboxField, | ||
FormGroup, | ||
InputField, | ||
OutlineButton, | ||
PrimaryButton, | ||
} from '@opentrons/components' | ||
import styles from './LiquidEditForm.css' | ||
import formStyles from '../forms.css' | ||
|
||
type Props = { | ||
...$Exact<IngredInputs>, | ||
deleteLiquidGroup: () => mixed, | ||
cancelForm: () => mixed, | ||
saveForm: (IngredInputs) => mixed, | ||
} | ||
type State = IngredInputs | ||
|
||
type WrapperProps = {showForm: boolean, formKey: string, formProps: Props} | ||
|
||
type SP = { | ||
...IngredInputs, | ||
_liquidGroupId: ?string, | ||
showForm: boolean, | ||
} | ||
|
||
class LiquidEditForm extends React.Component<Props, State> { | ||
constructor (props: Props) { | ||
super(props) | ||
this.state = { | ||
name: props.name, | ||
description: props.description, | ||
serialize: props.serialize || false, | ||
} | ||
} | ||
|
||
updateForm = (fieldName: $Keys<IngredInputs>) => (e: SyntheticInputEvent<*>) => { | ||
// need to handle checkbox fields explicitly | ||
if (fieldName === 'serialize') { | ||
this.setState({[fieldName]: !this.state[fieldName]}) | ||
} else { | ||
this.setState({[fieldName]: e.currentTarget.value}) | ||
} | ||
} | ||
|
||
handleSaveForm = (e: SyntheticMouseEvent<*>) => { | ||
this.props.saveForm(this.state) | ||
} | ||
|
||
render () { | ||
const {deleteLiquidGroup, cancelForm} = this.props | ||
const {name, description, serialize} = this.state | ||
return ( | ||
<Card className={styles.form_card}> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not a change request, but a similar comment to the one I left the other day: this component (referring here to the entire file) is mixing a lot of presentational stuff, stateful stuff, and redux connection stuff. I think there's some opportunities here for:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do you have an example of this anywhere eg in Run App? I'm not sure how to do with without a bunch of boilerplate (or maybe, if I see it I'll see the boilerplate isn't as bad as I think) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think that form state (which is transient) is closer to UI/presentational logic than it is to domain layer/ non-presentational logic here. The React state in this component is tracking a UI concern, the presentational state of these interactive components. Their domain layer interface is really just saving/deleting the form or navigating away. I think the concerns are clearly organized in this file. If separating the domain layer connection to a new sibling file is clearer for @mcous, I would be fine with that, though I think it is actually clearer with the concerns collocated. As far as building out a component for removing boilerplate from our stateful form components, I've built a couple before and I think we could definitely benefit from throwing one in our component library, or using a third party open source component. Slotting in time to build one out seems like the limiting factor here, especially as we would probably want to slowly migrate all of our forms over. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hm maybe the cards in ...In the app we generally don't import I get the feeling that these sorts of container/presentation splits would be more immediately obvious if we were better about unit testing the React side of things, so (since last week) I've been trying to at least think about how I would test my components if I was writing tests when I encounter these sorts of "where to split" questions |
||
<section className={styles.section}> | ||
<div className={formStyles.header}>{i18n.t('liquids.details')}</div> | ||
<div className={formStyles.row_wrapper}> | ||
<FormGroup | ||
label={`${i18n.t('liquids.liquid_name')}:`} | ||
className={formStyles.column_1_2}> | ||
<InputField value={name} onChange={this.updateForm('name')} /> | ||
</FormGroup> | ||
<FormGroup | ||
label={`${i18n.t('liquids.description')}:`} | ||
className={formStyles.column_1_2}> | ||
<InputField value={description} onChange={this.updateForm('description')} /> | ||
</FormGroup> | ||
</div> | ||
</section> | ||
|
||
<section className={styles.section}> | ||
<div className={formStyles.header}>Serialization</div> | ||
<p className={styles.info_text}> | ||
{i18n.t('liquids.serialize_explanation')}</p> | ||
<CheckboxField label='Serialize' value={serialize} | ||
onChange={this.updateForm('serialize')} /> | ||
</section> | ||
|
||
<div className={styles.button_row}> | ||
<OutlineButton onClick={deleteLiquidGroup}>{i18n.t('button.delete')}</OutlineButton> | ||
<PrimaryButton onClick={cancelForm}>{i18n.t('button.cancel')}</PrimaryButton> | ||
<PrimaryButton onClick={this.handleSaveForm}>{i18n.t('button.save')}</PrimaryButton> | ||
</div> | ||
</Card> | ||
) | ||
} | ||
} | ||
|
||
function LiquidEditFormWrapper (props: WrapperProps) { | ||
const {showForm, formKey, formProps} = props | ||
return showForm | ||
? <LiquidEditForm {...formProps} key={formKey} /> | ||
: null | ||
} | ||
|
||
function mapStateToProps (state: BaseState): SP { | ||
const selectedLiquidGroupState = labwareIngredSelectors.getSelectedLiquidGroupState(state) | ||
const _liquidGroupId = (selectedLiquidGroupState && selectedLiquidGroupState.liquidGroupId) | ||
const allIngredientGroupFields = labwareIngredSelectors.allIngredientGroupFields(state) | ||
const selectedIngredFields = _liquidGroupId ? allIngredientGroupFields[_liquidGroupId] : {} | ||
const showForm = Boolean(selectedLiquidGroupState.liquidGroupId || selectedLiquidGroupState.newLiquidGroup) | ||
assert(!(_liquidGroupId && !selectedIngredFields), `Expected selected liquid group "${String(_liquidGroupId)}" to have fields in allIngredientGroupFields`) | ||
|
||
return { | ||
_liquidGroupId, | ||
showForm, | ||
name: selectedIngredFields.name, | ||
description: selectedIngredFields.description, | ||
serialize: selectedIngredFields.serialize, | ||
} | ||
} | ||
|
||
function mergeProps (stateProps: SP, dispatchProps: {dispatch: ThunkDispatch<*>}): WrapperProps { | ||
const {dispatch} = dispatchProps | ||
const {showForm, _liquidGroupId, ...passThruFormProps} = stateProps | ||
return { | ||
showForm, | ||
formKey: _liquidGroupId || '__new_form__', | ||
formProps: { | ||
...passThruFormProps, | ||
deleteLiquidGroup: () => window.alert('Deleting liquids is not yet implemented'), // TODO: Ian 2018-10-12 later ticket | ||
cancelForm: () => dispatch(labwareIngredActions.deselectLiquidGroup()), | ||
saveForm: (formData: IngredInputs) => dispatch(labwareIngredActions.editLiquidGroup({ | ||
...formData, | ||
liquidGroupId: _liquidGroupId, | ||
})), | ||
}, | ||
} | ||
} | ||
|
||
export default connect(mapStateToProps, null, mergeProps)(LiquidEditFormWrapper) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,7 @@ | ||
// @flow | ||
import * as React from 'react' | ||
import LiquidEditForm from './LiquidEditForm' | ||
|
||
export default function LiquidsPage () { | ||
return <div>TODO! This page will be implemented in the next ticket (#2427)</div> | ||
return <LiquidEditForm /> | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,17 +1,24 @@ | ||
// @flow | ||
import * as React from 'react' | ||
import {connect} from 'react-redux' | ||
import {SidePanel, swatchColors} from '@opentrons/components' | ||
import { | ||
PrimaryButton, | ||
SidePanel, | ||
swatchColors, | ||
} from '@opentrons/components' | ||
import {PDTitledList} from '../lists' | ||
import listButtonStyles from '../listButtons.css' | ||
|
||
import {selectors as labwareIngredSelectors} from '../../labware-ingred/reducers' | ||
import type {OrderedLiquids} from '../../labware-ingred/types' | ||
import * as labwareIngredActions from '../../labware-ingred/actions' | ||
import type {BaseState} from '../../types' | ||
|
||
type Props = { | ||
liquids: OrderedLiquids, | ||
selectedLiquid: ?string, | ||
handleClickLiquid: (liquidId: string) => () => mixed, | ||
createNewLiquid: () => mixed, | ||
selectLiquid: (liquidId: string) => mixed, | ||
} | ||
|
||
type SP = { | ||
|
@@ -22,33 +29,43 @@ type SP = { | |
type DP = $Diff<Props, SP> | ||
|
||
function LiquidsSidebar (props: Props) { | ||
const {liquids, selectedLiquid, handleClickLiquid} = props | ||
const {liquids, selectedLiquid, createNewLiquid, selectLiquid} = props | ||
return ( | ||
<SidePanel title='Liquids'> | ||
{liquids.map(({ingredientId, name}) => ( | ||
<PDTitledList | ||
key={ingredientId} | ||
selected={selectedLiquid === ingredientId} | ||
onClick={handleClickLiquid(ingredientId)} | ||
onClick={() => selectLiquid(ingredientId)} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could reduce redefining this function for every liquid in the sidebar on each render by making this a class component and binding There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure how to benchmark this with any accuracy b/c its so cheap in performance that running the benchmark loop itself is a majority of the compute cost, but with this: d = Date.now();
for(var x=0; x < 2000000; x++) {
const aaa = () => {};
}
console.log(Date.now() - d); 2 million assignments takes ~1 second on my computer, and without the So personally I think the whole function-binding-optimization thing is a premature optimization and not worth the code ugliness -- especially if React ever delivers on its promise of optimizing pure functional components 🤞 |
||
iconName='circle' | ||
iconProps={{style: {fill: swatchColors(Number(ingredientId))}}} | ||
title={name || `Unnamed Ingredient ${ingredientId}`} // fallback, should not happen | ||
/> | ||
))} | ||
<div className={listButtonStyles.list_item_button}> | ||
<PrimaryButton | ||
iconName='water' | ||
onClick={createNewLiquid}> | ||
New Liquid | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should be in i18n |
||
</PrimaryButton> | ||
</div> | ||
</SidePanel> | ||
) | ||
} | ||
|
||
function mapStateToProps (state: BaseState): SP { | ||
const selectedLiquidGroup = labwareIngredSelectors.getSelectedLiquidGroupState(state) | ||
return { | ||
liquids: labwareIngredSelectors.allIngredientNamesIds(state), | ||
selectedLiquid: '0', // TODO: Ian 2018-10-09 implement in #2427 | ||
selectedLiquid: selectedLiquidGroup && selectedLiquidGroup.liquidGroupId, | ||
} | ||
} | ||
|
||
function mapDispatchToProps (dispatch: Dispatch<*>): DP { | ||
return { | ||
handleClickLiquid: (liquidId) => () => console.log('TODO: select liquid', liquidId), // TODO: Ian 2018-10-09 implement in #2427 | ||
selectLiquid: (liquidGroupId) => | ||
dispatch(labwareIngredActions.selectLiquidGroup(liquidGroupId)), | ||
createNewLiquid: () => dispatch(labwareIngredActions.createNewLiquidGroup()), | ||
} | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -90,15 +90,6 @@ | |
|
||
/* form buttons */ | ||
|
||
.button_row { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. moved this most generic |
||
lost-utility: clearfix; | ||
margin-top: 2rem; | ||
|
||
& > * { | ||
lost-column: 1/6; | ||
} | ||
} | ||
|
||
.cancel_button { | ||
lost-offset: 2/6; | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it was confusing to have old "individualize" word still hanging around, so I globally renamed it to "serialize" for consistency. Sorry for the extra diff