-
Notifications
You must be signed in to change notification settings - Fork 179
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): use formik for liquid edit form #2512
Changes from all commits
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 |
---|---|---|
@@ -0,0 +1,67 @@ | ||
// flow-typed signature: 2eb43e07f430c2c2afdede8041c6d9d3 | ||
// flow-typed version: <<STUB>>/formik_v1.3.1/flow_v0.82.0 | ||
|
||
/** | ||
* This is an autogenerated libdef stub for: | ||
* | ||
* 'formik' | ||
* | ||
* Fill this stub out by replacing all the `any` types. | ||
* | ||
* Once filled out, we encourage you to share your work with the | ||
* community by sending a pull request to: | ||
* https://github.com/flowtype/flow-typed | ||
*/ | ||
|
||
declare module 'formik' { | ||
declare module.exports: any; | ||
} | ||
|
||
/** | ||
* We include stubs for each file inside this npm package in case you need to | ||
* require those files directly. Feel free to delete any files that aren't | ||
* needed. | ||
*/ | ||
declare module 'formik/dist/formik.cjs.development' { | ||
declare module.exports: any; | ||
} | ||
|
||
declare module 'formik/dist/formik.cjs.production' { | ||
declare module.exports: any; | ||
} | ||
|
||
declare module 'formik/dist/formik.esm' { | ||
declare module.exports: any; | ||
} | ||
|
||
declare module 'formik/dist/formik.umd.development' { | ||
declare module.exports: any; | ||
} | ||
|
||
declare module 'formik/dist/formik.umd.production' { | ||
declare module.exports: any; | ||
} | ||
|
||
declare module 'formik/dist/index' { | ||
declare module.exports: any; | ||
} | ||
|
||
// Filename aliases | ||
declare module 'formik/dist/formik.cjs.development.js' { | ||
declare module.exports: $Exports<'formik/dist/formik.cjs.development'>; | ||
} | ||
declare module 'formik/dist/formik.cjs.production.js' { | ||
declare module.exports: $Exports<'formik/dist/formik.cjs.production'>; | ||
} | ||
declare module 'formik/dist/formik.esm.js' { | ||
declare module.exports: $Exports<'formik/dist/formik.esm'>; | ||
} | ||
declare module 'formik/dist/formik.umd.development.js' { | ||
declare module.exports: $Exports<'formik/dist/formik.umd.development'>; | ||
} | ||
declare module 'formik/dist/formik.umd.production.js' { | ||
declare module.exports: $Exports<'formik/dist/formik.umd.production'>; | ||
} | ||
declare module 'formik/dist/index.js' { | ||
declare module.exports: $Exports<'formik/dist/index'>; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,14 +1,8 @@ | ||
// @flow | ||
import * as React from 'react' | ||
import {connect} from 'react-redux' | ||
import assert from 'assert' | ||
import {Formik} from 'formik' | ||
import * as Yup from 'yup' | ||
import i18n from '../../localization' | ||
|
||
import * as labwareIngredActions from '../../labware-ingred/actions' | ||
import {selectors as labwareIngredSelectors} from '../../labware-ingred/reducers' | ||
import type {LiquidGroup} from '../../labware-ingred/types' | ||
import type {BaseState, ThunkDispatch} from '../../types' | ||
|
||
import { | ||
Card, | ||
CheckboxField, | ||
|
@@ -19,136 +13,95 @@ import { | |
} from '@opentrons/components' | ||
import styles from './LiquidEditForm.css' | ||
import formStyles from '../forms.css' | ||
import type {LiquidGroup} from '../../labware-ingred/types' | ||
|
||
type Props = { | ||
...$Exact<LiquidGroup>, | ||
deleteLiquidGroup: () => mixed, | ||
cancelForm: () => mixed, | ||
saveForm: (LiquidGroup) => mixed, | ||
} | ||
type State = LiquidGroup | ||
|
||
type WrapperProps = {showForm: boolean, formKey: string, formProps: Props} | ||
|
||
type SP = { | ||
...LiquidGroup, | ||
_liquidGroupId: ?string, | ||
showForm: boolean, | ||
type LiquidEditFormValues = { | ||
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. If we use this style, this type is important to pay attention to when maintaining form code. It must exactly match the type of the Yup schema, otherwise when the form communicates its values (eg dispatches some submitForm action) on the reciever's end the values could be different than what Flow expects, but Flow won't be able to catch it since the form values are untyped. |
||
name: string, | ||
description?: ?string, | ||
serialize?: 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, | ||
} | ||
export const liquidEditFormSchema = Yup.object().shape({ | ||
name: Yup.string().required('Name is required'), | ||
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. it might be nice to throw these validation strings into i18n 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. good idea, will do in follow-up PR, I wanna make sure I'm putting them in a place that works for multiple forms when I do it |
||
description: Yup.string(), | ||
serialize: Yup.boolean(), | ||
}) | ||
|
||
export default function LiquidEditForm (props: Props) { | ||
const {deleteLiquidGroup, cancelForm, saveForm} = props | ||
|
||
const initialValues = { | ||
name: props.name, | ||
description: props.description || '', | ||
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. Without this "cast null to empty string", the form will be seen as "dirty" if you click into description field and click out of it. That's because the initial value is technically different from the current value ( |
||
serialize: props.serialize || false, | ||
} | ||
|
||
updateForm = (fieldName: $Keys<LiquidGroup>) => (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) | ||
} | ||
|
||
disableSave = () => { | ||
const hasChanges = Object.keys(this.state).some(field => | ||
(this.state[field] || null) !== (this.props[field] || null) | ||
) | ||
return !hasChanges | ||
} | ||
|
||
render () { | ||
const {deleteLiquidGroup, cancelForm} = this.props | ||
const {name, description, serialize} = this.state | ||
return ( | ||
<Card className={styles.form_card}> | ||
<section className={styles.section}> | ||
<div className={formStyles.header}>{i18n.t('form.liquid.details')}</div> | ||
<div className={formStyles.row_wrapper}> | ||
<FormGroup | ||
label={`${i18n.t('form.liquid.name')}:`} | ||
className={formStyles.column_1_2}> | ||
<InputField value={name} onChange={this.updateForm('name')} /> | ||
</FormGroup> | ||
<FormGroup | ||
label={`${i18n.t('form.liquid.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('form.liquid.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 | ||
disabled={this.disableSave()} | ||
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: LiquidGroup) => dispatch(labwareIngredActions.editLiquidGroup({ | ||
...formData, | ||
liquidGroupId: _liquidGroupId, | ||
})), | ||
}, | ||
} | ||
return ( | ||
<Formik | ||
initialValues={initialValues} | ||
validationSchema={liquidEditFormSchema} | ||
onSubmit={(values: LiquidEditFormValues) => saveForm({ | ||
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. note that I am typing the untyped |
||
name: values.name, | ||
description: values.description || null, | ||
serialize: values.serialize || false, | ||
})} | ||
render={({handleChange, handleBlur, handleSubmit, dirty, errors, isValid, touched, values}) => ( | ||
<Card className={styles.form_card}> | ||
<form onSubmit={handleSubmit}> | ||
<section className={styles.section}> | ||
<div className={formStyles.header}>{i18n.t('form.liquid.details')}</div> | ||
<div className={formStyles.row_wrapper}> | ||
<FormGroup | ||
label={`${i18n.t('form.liquid.name')}:`} | ||
className={formStyles.column_1_2}> | ||
<InputField | ||
name='name' | ||
error={touched.name && errors.name} | ||
value={values.name} | ||
onChange={handleChange} | ||
onBlur={handleBlur} | ||
/> | ||
</FormGroup> | ||
<FormGroup | ||
label={`${i18n.t('form.liquid.description')}:`} | ||
className={formStyles.column_1_2}> | ||
<InputField | ||
name='description' | ||
value={values.description} | ||
onChange={handleChange} /> | ||
</FormGroup> | ||
</div> | ||
</section> | ||
|
||
<section className={styles.section}> | ||
<div className={formStyles.header}>{i18n.t('form.liquid.serialize_title')}</div> | ||
<p className={styles.info_text}> | ||
{i18n.t('form.liquid.serialize_explanation')}</p> | ||
<CheckboxField | ||
name='serialize' | ||
label={i18n.t('form.liquid.serialize')} | ||
value={values.serialize} | ||
onChange={handleChange} /> | ||
</section> | ||
|
||
<div className={styles.button_row}> | ||
<OutlineButton onClick={deleteLiquidGroup}>{i18n.t('button.delete')}</OutlineButton> | ||
<PrimaryButton onClick={cancelForm}>{i18n.t('button.cancel')}</PrimaryButton> | ||
<PrimaryButton | ||
disabled={!dirty} | ||
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. In Formik, |
||
type='submit' > | ||
{i18n.t('button.save')} | ||
</PrimaryButton> | ||
</div> | ||
</form> | ||
</Card> | ||
)} | ||
/> | ||
) | ||
} | ||
|
||
export default connect(mapStateToProps, null, mergeProps)(LiquidEditFormWrapper) |
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.
Hey so @Kadee80 and I happen to have made a similar change on a WIP branch. We need to put some inputs outside of their labels in the DOM, so we're also proposing that
name
is assigned toinput.id
. Does that sound ok?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.
I think Formik will handle that fine, let me try it out
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.
From Slack convo: in an upcoming Run App PR, we'll add
id
as a pass-thru prop like<input id={props.id}
to our Input components, and not require it, and not force it to be the same asname