From 919ce11b57e13daa0d98d4ff5081af4d8d2795b0 Mon Sep 17 00:00:00 2001 From: Joshua T Flowers Date: Mon, 5 Aug 2019 09:41:47 +0800 Subject: [PATCH] Add form component for handling form state (https://github.com/woocommerce/woocommerce-admin/pull/2742) * Add Form component to packages * Add form example to devdocs * Fix form validation check * Update store details to use Form component * Fix indentation issues --- .../profile-wizard/steps/store-details.js | 193 +++++++----------- .../client/devdocs/examples.json | 1 + .../packages/components/src/form/example.md | 53 +++++ .../packages/components/src/form/index.js | 127 ++++++++++++ .../packages/components/src/index.js | 1 + 5 files changed, 259 insertions(+), 116 deletions(-) create mode 100644 plugins/woocommerce-admin/packages/components/src/form/example.md create mode 100644 plugins/woocommerce-admin/packages/components/src/form/index.js diff --git a/plugins/woocommerce-admin/client/dashboard/profile-wizard/steps/store-details.js b/plugins/woocommerce-admin/client/dashboard/profile-wizard/steps/store-details.js index 0659893da83..257f8340db0 100644 --- a/plugins/woocommerce-admin/client/dashboard/profile-wizard/steps/store-details.js +++ b/plugins/woocommerce-admin/client/dashboard/profile-wizard/steps/store-details.js @@ -7,14 +7,13 @@ import { Button, SelectControl, TextControl, CheckboxControl } from 'newspack-co import { Component, Fragment } from '@wordpress/element'; import { compose } from '@wordpress/compose'; import { decodeEntities } from '@wordpress/html-entities'; -import { pickBy } from 'lodash'; import { withDispatch } from '@wordpress/data'; import { recordEvent } from 'lib/tracks'; /** * Internal depdencies */ -import { H, Card } from '@woocommerce/components'; +import { H, Card, Form } from '@woocommerce/components'; import withSelect from 'wc-api/with-select'; class StoreDetails extends Component { @@ -23,19 +22,18 @@ class StoreDetails extends Component { this.state = { countryStateOptions: [], - errors: {}, - fields: { - addressLine1: '', - addressLine2: '', - city: '', - countryState: '', - postCode: '', - isClient: false, - }, + }; + + this.initialValues = { + addressLine1: '', + addressLine2: '', + city: '', + countryState: '', + postCode: '', + isClient: false, }; this.onContinue = this.onContinue.bind( this ); - this.updateValue = this.updateValue.bind( this ); } componentWillMount() { @@ -43,49 +41,26 @@ class StoreDetails extends Component { this.setState( { countryStateOptions } ); } - validateField( name ) { - const { errors, fields } = this.state; + validate( values ) { + const errors = {}; - switch ( name ) { - case 'addressLine1': - errors.addressLine1 = fields.addressLine1.length - ? null - : __( 'Please add an address', 'woocommerce-admin' ); - break; - case 'countryState': - errors.countryState = fields.countryState.length - ? null - : __( 'Please select a country and state', 'woocommerce-admin' ); - break; - case 'city': - errors.city = fields.city.length ? null : __( 'Please add a city', 'woocommerce-admin' ); - break; - case 'postCode': - errors.postCode = fields.postCode.length - ? null - : __( 'Please add a post code', 'woocommerce-admin' ); - break; + if ( ! values.addressLine1.length ) { + errors.addressLine1 = __( 'Please add an address', 'woocommerce-admin' ); + } + if ( ! values.countryState.length ) { + errors.countryState = __( 'Please select a country and state', 'woocommerce-admin' ); + } + if ( ! values.city.length ) { + errors.city = __( 'Please add a city', 'woocommerce-admin' ); + } + if ( ! values.postCode.length ) { + errors.postCode = __( 'Please add a post code', 'woocommerce-admin' ); } - this.setState( { errors: pickBy( errors ) } ); + return errors; } - updateValue( name, value ) { - const fields = { ...this.state.fields, [ name ]: value }; - this.setState( { fields }, () => this.validateField( name ) ); - } - - async validateForm() { - const { fields } = this.state; - Object.keys( fields ).forEach( fieldName => this.validateField( fieldName ) ); - } - - async onContinue() { - await this.validateForm(); - if ( Object.keys( this.state.errors ).length ) { - return; - } - + async onContinue( values ) { const { createNotice, goToNextStep, @@ -94,31 +69,23 @@ class StoreDetails extends Component { updateProfileItems, isProfileItemsError, } = this.props; - const { - addressLine1, - addressLine2, - city, - countryState, - postCode, - isClient, - } = this.state.fields; recordEvent( 'storeprofiler_store_details_continue', { - store_country: countryState.split( ':' )[ 0 ], - setup_client: isClient, + store_country: values.countryState.split( ':' )[ 0 ], + setup_client: values.isClient, } ); await updateSettings( { general: { - woocommerce_store_address: addressLine1, - woocommerce_store_address_2: addressLine2, - woocommerce_default_country: countryState, - woocommerce_store_city: city, - woocommerce_store_postcode: postCode, + woocommerce_store_address: values.addressLine1, + woocommerce_store_address_2: values.addressLine2, + woocommerce_default_country: values.countryState, + woocommerce_store_city: values.city, + woocommerce_store_postcode: values.postCode, }, } ); - await updateProfileItems( { setup_client: isClient } ); + await updateProfileItems( { setup_client: values.isClient } ); if ( ! isSettingsError && ! isProfileItemsError ) { goToNextStep(); @@ -161,8 +128,7 @@ class StoreDetails extends Component { } render() { - const { countryStateOptions, errors, fields } = this.state; - const { addressLine1, addressLine2, city, countryState, postCode } = fields; + const { countryStateOptions } = this.state; return ( @@ -174,60 +140,55 @@ class StoreDetails extends Component { - this.updateValue( 'addressLine1', value ) } - required - value={ addressLine1 } - help={ errors.addressLine1 } - className={ errors.addressLine1 ? 'has-error' : null } - /> +
+ { ( { getInputProps, handleSubmit } ) => ( + + - this.updateValue( 'addressLine2', value ) } - required - value={ addressLine2 } - help={ errors.addressLine2 } - className={ errors.addressLine2 ? 'has-error' : null } - /> + - this.updateValue( 'countryState', value ) } - options={ countryStateOptions } - value={ countryState } - required - help={ errors.countryState } - className={ errors.countryState ? 'has-error' : null } - /> + - this.updateValue( 'city', value ) } - required - value={ city } - help={ errors.city } - className={ errors.city ? 'has-error' : null } - /> + - this.updateValue( 'postCode', value ) } - required - value={ postCode } - help={ errors.postCode } - className={ errors.postCode ? 'has-error' : null } - /> + - this.updateValue( 'isClient', value ) } - /> + - + + + ) } +
); diff --git a/plugins/woocommerce-admin/client/devdocs/examples.json b/plugins/woocommerce-admin/client/devdocs/examples.json index 132f04e21e3..6ae3a98302d 100644 --- a/plugins/woocommerce-admin/client/devdocs/examples.json +++ b/plugins/woocommerce-admin/client/devdocs/examples.json @@ -10,6 +10,7 @@ { "component": "EmptyContent" }, { "component": "Filters", "render": "MyReportFilters" }, { "component": "Flag" }, + { "component": "Form" }, { "component": "Gravatar" }, { "component": "ImageAsset" }, { "component": "Link" }, diff --git a/plugins/woocommerce-admin/packages/components/src/form/example.md b/plugins/woocommerce-admin/packages/components/src/form/example.md new file mode 100644 index 00000000000..63c12d51024 --- /dev/null +++ b/plugins/woocommerce-admin/packages/components/src/form/example.md @@ -0,0 +1,53 @@ +```jsx +import { Button TextControl } from '@wordpress/components'; +import { Form } from '@woocommerce/components'; + +const validate = ( values ) => { + const errors = {}; + if ( ! values.firstName ) { + errors.firstName = 'First name is required'; + } + if ( values.lastName.length < 3 ) { + errors.lastName = 'Last name must be at least 3 characters'; + } + return errors; +}; + +const onSubmitCallback = ( values ) => console.log( values ); +const initialValues = { firstName: '', lastName: '' }; + +const MyForm = () => ( +
+ { ( { + getInputProps, + values, + errors, + setValue, + handleSubmit, + } ) => ( +
+ + + +
+
+ Values: { JSON.stringify( values ) }
+ Errors: { JSON.stringify( errors ) }
+
+ ) } +
+); +``` + \ No newline at end of file diff --git a/plugins/woocommerce-admin/packages/components/src/form/index.js b/plugins/woocommerce-admin/packages/components/src/form/index.js new file mode 100644 index 00000000000..e1e18c13774 --- /dev/null +++ b/plugins/woocommerce-admin/packages/components/src/form/index.js @@ -0,0 +1,127 @@ +/** + * External dependencies + */ +import { cloneElement, Component } from '@wordpress/element'; +import { noop } from 'lodash'; +import PropTypes from 'prop-types'; + +/** + * A form component to handle form state and provide input helper props. + */ +class Form extends Component { + constructor( props ) { + super(); + + this.state = { + values: props.initialValues, + errors: props.errors, + touched: props.touched, + }; + + this.getInputProps = this.getInputProps.bind( this ); + this.handleSubmit = this.handleSubmit.bind( this ); + this.setValue = this.setValue.bind( this ); + } + + componentDidMount() { + this.validate(); + } + + async isValidForm() { + await this.validate(); + return ! Object.keys( this.state.errors ).length; + } + + validate() { + const { values } = this.state; + const errors = this.props.validate( values ); + this.setState( { errors } ); + } + + setValue( name, value ) { + this.setState( prevState => ( { + values: { ...prevState.values, [ name ]: value }, + } ), this.validate ); + } + + handleBlur( name ) { + this.setState( prevState => ( { + touched: { ...prevState.touched, [ name ]: true }, + } ) ); + } + + async handleSubmit() { + const { values } = this.state; + const touched = {}; + Object.keys( values ).map( name => touched[ name ] = true ); + this.setState( { touched } ); + + if ( await this.isValidForm() ) { + this.props.onSubmitCallback( values ); + } + } + + getInputProps( name ) { + const { errors, touched, values } = this.state; + + return { + value: values[ name ], + onChange: ( value ) => this.setValue( name, value ), + onBlur: () => this.handleBlur( name ), + className: touched[ name ] && errors[ name ] ? 'has-error' : null, + help: touched[ name ] ? errors[ name ] : null, + }; + } + + getStateAndHelpers() { + const { values, errors, touched } = this.state; + + return { + values, + errors, + touched, + setValue: this.setValue, + handleSubmit: this.handleSubmit, + getInputProps: this.getInputProps, + }; + } + + render() { + const element = this.props.children( this.getStateAndHelpers() ); + return cloneElement( element ); + } +} + +Form.propTypes = { + /** + * A renderable component in which to pass this component's state and helpers. + * Generally a number of input or other form elements. + */ + children: PropTypes.any, + /** + * Object of all initial errors to store in state. + */ + errors: PropTypes.object, + /** + * Object key:value pair list of all initial field values. + */ + initialValues: PropTypes.object.isRequired, + /** + * Function to call when a form is submitted with valid fields. + */ + onSubmitCallback: PropTypes.func, + /** + * A function that is passed a list of all values and + * should return an `errors` object with error response. + */ + validate: PropTypes.func, +}; + +Form.defaultProps = { + errors: {}, + initialValues: {}, + touched: {}, + onSubmitCallback: noop, +}; + +export default Form; diff --git a/plugins/woocommerce-admin/packages/components/src/index.js b/plugins/woocommerce-admin/packages/components/src/index.js index eadfd657cfc..8730b7b6ee4 100644 --- a/plugins/woocommerce-admin/packages/components/src/index.js +++ b/plugins/woocommerce-admin/packages/components/src/index.js @@ -20,6 +20,7 @@ export { default as DropdownButton } from './dropdown-button'; export { default as EllipsisMenu } from './ellipsis-menu'; export { default as EmptyContent } from './empty-content'; export { default as Flag } from './flag'; +export { default as Form } from './form'; export { default as FilterPicker } from './filters/filter'; export { default as Gravatar } from './gravatar'; export { H, Section } from './section';