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

RFC: Form Validation #407

Closed
levithomason opened this issue Aug 19, 2016 · 24 comments
Closed

RFC: Form Validation #407

levithomason opened this issue Aug 19, 2016 · 24 comments

Comments

@levithomason
Copy link
Member

This is a placeholder issue. Form validation will be implemented apart from the Form update PR #400. We've had a few offline discussions surrounding the validation API. Ideas will be posted here soon as we're able.

Have any input regarding form validation? We'd love to hear your ideas.

@layershifter
Copy link
Member

Don't forget about i18n there.

@levithomason
Copy link
Member Author

Could you expound a little?

@layershifter
Copy link
Member

SUI form validation has default messages for shorthand rules.

image

There is must be ability for redefine these messages 🔨

@levithomason
Copy link
Member Author

Gotcha, agreed.

@chuan137
Copy link

Hi,

I am trying to write validation for form fields myself, and find this threads. It would be nice to have a simplified API outside Form as indicated on the top of this thread. But I think it would also be nice to provide some straight forward way/api to validate input fields manually. Maybe, like

<Button.Field label='email' name='email' validate={this.validateEmail} />

The validate method is called when the form is submit. When failed, the error message may be passed to its own state?

@levithomason
Copy link
Member Author

This was also my first inclination. Having the ability to validate a single field seems useful. I can see an equally valid argument that says, still validate the entire form model but only against a single field (e.g. do not specify validation for the other field names). What I'm less sure of is what the API looks like for wiring the model, field values, and error messages into the UI.

@davezuko did you begin work on validation the other day?

@chuan137
Copy link

What I'm less sure of is what the API looks like for wiring the model, field values, and error messages into the UI.

generally, serializer may pick up these information from states of sub-components? just my 2ct.

@levithomason
Copy link
Member Author

levithomason commented Sep 20, 2016

Yep, generally it is clear. Specifics on the best way to do this is what I'm not clear about. Would love to see some experiments on this though. Goals being:

  • loose coupling of model - form - validator - error messages
  • no DOM dependency
  • minimal boilerplate, but not at the expense of explicit, declarative, and decoupled code

@jeffcarbs
Copy link
Member

jeffcarbs commented Sep 22, 2016

@levithomason - Not sure if this is beyond the scope of this issue, if so lmk and I can possibly open a new issue.

I was having some discussion with my team about building a form component on top of stardust forms, but given some of the things mentioned here I'm wondering how much we're willing to build directly into stardust. We're planning to do the work anyway, so would be cool to spread the love 😄

Just going to copy/paste a comment I wrote in our project, would love your thoughts on this:


Some thoughts:

  • Form should use local state to store the values.
  • Each of the inputs within the Form component should be "controlled" meaning that the onChange updates the values key in local state.
  • Props:
    • formSchema: React.PropTypes.array - describes the fields of the form and the order/structure
    • onSubmit: React.PropTypes.func - called with values when the form is submitted
    • defaultValues: React.PropTypes.object - initial values for the form
  • Ideal usage would be:
  onFormSubmit (values) {
    model.updateValues(values)
  }

  renderForm () {
    <Form formSchema={formSchema} defaultValues={model.values}, onSubmit={this.onFormSubmit} />
  }

Form Schema

Given a schema:

[
  { type: 'text', name: 'firstName' },
  { type: 'text', name: 'lastName' },
  { type: 'submit', name: 'Submit' }
]

We should render:

<Form>
  <Form.Field control={Input} name='firstName' /* Other props */ />
  <Form.Field control={Input} name='lastName' /* Other props */ />

  <Form.Field control={Button}>Submit</Form.Field>
</Form>

For inputs that should show up next to each other:

[
  {
    type: 'group',
    fields: [
      { type: 'text', name: 'firstName' },
      { type: 'text', name: 'lastName' }
    ],
  },
  { type: 'text', name: 'title' },
  { type: 'submit', name: 'Submit' }
]
<Form>
  <Form.Group widths='equal'>
    <Form.Field control={Input} name='firstName' /* Other props */ />
    <Form.Field control={Input} name='lastName' /* Other props */ />
  </Form.Group>
  <Form.Field control={Input} name='title' /* Other props */ />

  <Form.Field control={Button}>Submit</Form.Field>
</Form>

For inputs that are collections (e.g. email addresses) :

[
  {
    type: 'group',
    fields: [
      { type: 'text', name: 'firstName' },
      { type: 'text', name: 'lastName' }
    ],
  },
  { type: 'text', name: 'title' },
  {
    type: 'nested',
    name: 'emailAddresses',
    fields: [
      { type: 'text', name: 'label' },
      { type: 'text', name: 'address' }
    ]
  },
  { type: 'submit', name: 'Submit' }
]
<Form>
  <Form.Group widths='equal'>
    <Form.Field control={Input} name='firstName' /* Other props */ />
    <Form.Field control={Input} name='lastName' /* Other props */ />
  </Form.Group>
  <Form.Field control={Input} name='title' /* Other props */ />
  <Form.Group widths='equal'>
    {
      emailAddresses.map((emailAddress, index) => (
        <Form.Field control={Input} name=`emailAddresses.${index}.label` /* Other props */ />
        <Form.Field control={Input} name=`emailAddresses.${index}.address` /* Other props */ />
      )
    }
    <Form.Field control={Button}>Add Email Address</Form.Field>
  </Form.Group>

  <Form.Field control={Button}>Submit</Form.Field>
</Form>

If you've gotten this far haha, regarding validations:

  • Could be passed within the field prop, e.g. { type: 'text', name: 'title', validation: (value) => value.length < 255 }
  • Could be passed within a group, e.g.
{
  type: 'group',
  fields: [
    { type: 'text', name: 'firstName' },
    { type: 'text', name: 'lastName' }
  ],
  validation: (data) => data.firstName || data.lastName
}
  • Validations could be optionally enforced onChange, onBlur, or onSubmit

The nicest thing with this approach is that you're keeping everything in data and wouldn't need to touch the DOM. Using naming like collection.0.field you can use _.get(values, keyPath) and _.set(values, keyPath, value) to get/set values.

@jeffcarbs
Copy link
Member

I wound up implementing some of the ideas I wrote about in the previous comment. See the PR that's referenced right above ^

@levithomason
Copy link
Member Author

Awesome, working on an appropriate response as well. Will post shortly.

@levithomason
Copy link
Member Author

This is definitely the right place, super thankful to see this. I agree with the goals here. I have mostly the same thoughts in terms of generating forms from an object, having a model, serializing on submit, validating that model, and showing errors. We also have a few form validation needs here at TA. Both in SPAs and static properties.

Separation of Concerns

I'd like to create a set of packages that can work in any context, whether Node, SPA, or vanilla browser JS. I see a few separate pieces to this issue as a whole. We have already solved many of these problems at TA. We just need to pull them together, tweak some, and fill in missing parts. I'll layout what I see so far as the separate packages, then describe a potential workflow.

Form

This is the Stardust Form and shorthand.

Done

  • Supports all SUI markup permutations
  • Serializes data onSubmit, demo
  • Allows passing a custom serializer function

Needs

  • Shorthand generation
  • Shorthand for passing errors or some API for handling Form/Field coupling
  • Decoupled from DOM, currently passes the form node to the serializer function.

I'm curious if the shorthand and/or Form/Field data/error coupling should be a separate wrapper component as well, <FormGenerator />? Not sure.

Serializer

This is currently baked into the Form component. Try it here.

Done

  • Handles all form inputs including groups
  • Includes edge case support and dev warnings for bad form design
  • Serializes form inputs into a single object lending well to a model or schema
  • Fully tested

Needs

  • Decoupled from DOM, currently takes a form node and traverses children
  • Should accept an array of form control objects IMO, either virtual or DOM nodes will do
  • packaged independently

Schema

We may have create something here, but TA has also solved this on a broader scale with Obey. Might be a long shot, but I want to explore pulling what we can from there. Maybe breaking things out for reuse.

Done

  • Possible reuse from Obey

Needs

  • Independent minimal package for defining a schema

Validator

Again, Obey includes this. I'm thinking this should also be a separate package though. I just want a package that knows how to validate an object and returns the errors.

Done

  • Possible reuse from Obey

Needs

  • Accept a schema
  • Accept a model
  • Return a list of errors

Theoretical Workflow

  1. Define a schema
  2. Create a Form, or generate one using some shorthand
  3. Capture Form data (model) onSubmit/onChange
  4. Pass schema/model to validator, capturing errors
  5. Pass errors to Form for display

If these are separate packages, you can use schema/serializer/validator you want. We'll just ship with a sane default set. You can also use these packages in any other customized workflow you want. We could just bundle them in a sane default workflow.

If we have a separate <FormGenerator /> (bad name) component, it would wrap these packages up in a workflow like described above. You could customize it's behavior. Perhaps showing inline error popups, or a single error message, etc. If it is not satisfactory, you can wrap the <Form /> yourself for a fully custom experience. This would also keep bloat out of the Form component.

Finally, since these packages are not DOM dependent, nor React dependent, you can use them in Node or a vanilla DOM experience. It also means that we can look to the community for pieces of the tool chain.

@notadamking
Copy link

Is form validation currently possible?

@davidsyoung
Copy link

+1. Really could do with this.

@Extra-lightwill
Copy link

Formsy (/~https://github.com/christianalfoni/formsy-react) works really well for validation and form building in general. already has integrations for material UI (/~https://github.com/mbrookes/formsy-material-ui) and bootstrap.

Would be cool to make a semantic-ui-react integration??

@levithomason
Copy link
Member Author

This is certainly something I'd like to explore. A simple wrapper around their API may be the best bet.

@levithomason
Copy link
Member Author

I'm going to freeze this issue here. We've got some good notes and input above to work with. We've also got the advanced form work as a reference, #527.

The immediate next step should be a PR exploring a thin wrapper around formsy-react. Future convo should happen on PRs related to this work.

@Semantic-Org Semantic-Org locked and limited conversation to collaborators Oct 17, 2016
@levithomason levithomason modified the milestone: v1.0 Oct 29, 2016
@levithomason
Copy link
Member Author

We're also considering options for implementing something similar to /~https://github.com/davezuko/react-reformed.

@Semantic-Org Semantic-Org unlocked this conversation Dec 16, 2016
@levithomason levithomason removed this from the v1.0 milestone Dec 16, 2016
@levithomason
Copy link
Member Author

I've pulled the 1.0 milestone from this issue. There are a lot of great ways to validate forms with extra packages. You can search our issues to find several. These should be used for now.

We still plan on adding validation, it is just not a priority item for the core team ATM.

@dijonkitchen
Copy link

dijonkitchen commented Jan 5, 2017

@radekmie
Copy link
Contributor

radekmie commented Jan 7, 2017

There's also my package called uniforms. Currently it's not using this package but only SUI classes, but the concept is to abstract schemas into unified schema called bridge and provide many fields which are usable with any schema.

(it's not an advertisement, but example of how to separate schema from components and DOM structure)

@zabute
Copy link

zabute commented Jan 13, 2017

I made a bunch of formsy-react wrappers for the form elements:

formsy-semantic-ui-react

@levithomason
Copy link
Member Author

Closing for housekeeping. We're still interested in elegant form validation within Semantic UI React and welcome PRs. Until then, there are plenty of workarounds above.

@reduxdj
Copy link

reduxdj commented Mar 9, 2018

I had the same struggle to do validation so i rolled out my own ADHOC validation, albeit, it can be better, one thing I struggled with, was that there is no clean API to update props outside of my render function, so each component needs a reference to the validator. The use of factories to create the components in SUI make them a bit of a locked box, if the form field could handle populating an error label it would great in addition to a label, here's some snippits:
Replace your Semantic Form with this ValidatingForm.

import React, { Component } from 'react'
import PropTypes from 'prop-types'
import ReactDOM from 'react-dom'
import _ from 'lodash'
import {
  Form,
  Label,
  Menu } from 'semantic-ui-react'
import FormInput from './form_input'
class ValidatingForm extends React.Component {
  validate() {
    const form = this
    const {
      validators,
      children
    } = this.props
    const findItem = (node, key) => {
      let parent
      let item
      const getChildren = (node, key) => {
        const parsedChildren = _.filter(node, item => item !== undefined && item !== false)
        for(let child of parsedChildren) {
           // console.log('child', child.type._meta)
           parent = child
           const children = _.get(child, 'props.children')
          if (_.isObject(children) && key === _.get(children, 'props.name')){
             item = children
           } else if (_.get(child, 'props.name') && key === _.get(child, 'props.name')) {
             item = child
             break
           } else if (_.isArray(children)) {
             getChildren(children, key)
           }
        }
      }
      getChildren(node, key)
      return { child: item, parent }
    }
    const newValidators = { }
    Object.entries(validators).forEach(([key, item]) => {
      const { child, parent } = findItem(children, key)
      const value = _.get(child, 'props.value') || _.get(child, 'props.defaultValue')
      const name =  _.get(child, 'props.name')
      const f = _.get(validators[name],'validator')
      const message = _.get(validators[name],'message')
      const isValid = _.isFunction(f) ? f(value) : undefined
      console.log(item.isValid, _.isFunction(f) ? f(value) : undefined)
      newValidators[name] = { validator: f, message, isValid }
    })
    form.props.onValidate(newValidators)
    const result = Object.entries(validators).find(([key, item]) => !item.isValid)
    return !(result && result.length > 0)
  }
  render() {
    return (<Form {...this.state} {...this.props} />)
  }
}
  1. Replace React Form.Input with FormInput:
import React, { Children, cloneElement, Component } from 'react'
import cx from 'classnames'
import _ from 'lodash'
import PropTypes from 'prop-types'

import {
  childrenUtils,
  createHTMLInput,
  createShorthandFactory,
  customPropTypes,
  getElementType,
  getUnhandledProps,
  META,
  partitionHTMLProps,
  SUI,
  useKeyOnly,
  useValueAndKey,
} from 'semantic-ui-react/dist/commonjs/lib'
import { Button, Icon, Label, FormField } from 'semantic-ui-react'

class Input extends Component {
  constructor(props) {
    super(props)
  }

  static propTypes = {
    as: customPropTypes.as,
    action: PropTypes.oneOfType([
      PropTypes.bool,
      customPropTypes.itemShorthand,
    ]),
    actionPosition: PropTypes.oneOf(['left']),
    children: PropTypes.node,
    className: PropTypes.string,
    disabled: PropTypes.bool,
    error: PropTypes.bool,
    fluid: PropTypes.bool,
    focus: PropTypes.bool,
    icon: PropTypes.oneOfType([
      PropTypes.bool,
      customPropTypes.itemShorthand,
    ]),
    iconPosition: PropTypes.oneOf(['left']),
    input: customPropTypes.itemShorthand,
    inverted: PropTypes.bool,
    label: customPropTypes.itemShorthand,
    labelPosition: PropTypes.oneOf(['left', 'right', 'left corner', 'right corner']),
    loading: PropTypes.bool,
    onChange: PropTypes.func,
    size: PropTypes.oneOf(SUI.SIZES),
    tabIndex: PropTypes.oneOfType([
      PropTypes.number,
      PropTypes.string,
    ]),
    transparent: PropTypes.bool,
    type: PropTypes.string,
    validate: PropTypes.func
  }

  static defaultProps = {
    type: 'text',
  //   validate: _.noop
  }

  static _meta = {
    name: 'Input',
    type: META.TYPES.ELEMENT,
  }

  computeIcon = () => {
    const { loading, icon } = this.props
    if (!_.isNil(icon)) return icon
    if (loading) return 'spinner'
  }

  computeTabIndex = () => {
    const { disabled, tabIndex } = this.props

    if (!_.isNil(tabIndex)) return tabIndex
    if (disabled) return -1
  }

  focus = () => (this.inputRef.focus())

  handleChange = (e) => {
    const value = _.get(e, 'target.value')

    _.invoke(this.props, 'onChange', e, { ...this.props, value })
  }

  handleChildOverrides = (child, defaultProps) => ({
    ...defaultProps,
    ...child.props,
    ref: (c) => {
      _.invoke(child, 'ref', c)
      this.handleInputRef(c)
    },
  })

  handleInputRef = c => (this.inputRef = c)

  partitionProps = () => {
    const { disabled, type, validate } = this.props
    const tabIndex = this.computeTabIndex()
    const unhandled = getUnhandledProps(Input, this.props)
    const [htmlInputProps, rest] = partitionHTMLProps(unhandled)
    return [{
      ...htmlInputProps,
      disabled,
      validate,
      type,
      tabIndex,
      onChange: this.handleChange,
      ref: this.handleInputRef,
    }, rest]
  }

  render() {
    const {
      action,
      actionPosition,
      children,
      className,
      disabled,
      error,
      fluid,
      focus,
      icon,
      iconPosition,
      input,
      inverted,
      label,
      labelPosition,
      loading,
      size,
      transparent,
      type,
      validate
    } = this.props

    const classes = cx(
      'ui',
      size,
      useKeyOnly(disabled, 'disabled'),
      useKeyOnly(error, 'error'),
      useKeyOnly(fluid, 'fluid'),
      useKeyOnly(focus, 'focus'),
      useKeyOnly(inverted, 'inverted'),
      useKeyOnly(loading, 'loading'),
      useKeyOnly(transparent, 'transparent'),
      useValueAndKey(actionPosition, 'action') || useKeyOnly(action, 'action'),
      useValueAndKey(iconPosition, 'icon') || useKeyOnly(icon || loading, 'icon'),
      useValueAndKey(labelPosition, 'labeled') || useKeyOnly(label, 'labeled'),
      'input',
      className,
    )
    const ElementType = getElementType(Input, this.props)
    const [htmlInputProps, rest] = this.partitionProps()

    // Render with children
    // ----------------------------------------
    if (!childrenUtils.isNil(children)) {
      // add htmlInputProps to the `<input />` child
      const childElements = _.map(Children.toArray(children), (child) => {
        if (child.type !== 'input') return child
        return cloneElement(child, this.handleChildOverrides(child, htmlInputProps))
      })

      return <ElementType {...rest}  validate={this.props.validate} className={classes}>{childElements}</ElementType>
    }

    // Render Shorthand
    // ----------------------------------------
    const actionElement = Button.create(action)
    const labelElement = Label.create(label, {
      defaultProps: {
        className: cx(
          'label',
          // add 'left|right corner'
          _.includes(labelPosition, 'corner') && labelPosition,
        ),
      },
    })

    return (
      <ElementType validate={this.props.validate} {...rest} className={classes}>
        {actionPosition === 'left' && actionElement}
        {labelPosition !== 'right' && labelElement}
        {createHTMLInput(input || type, { defaultProps: htmlInputProps })}
        {actionPosition !== 'left' && actionElement}
        {Icon.create(this.computeIcon())}
        {labelPosition === 'right' && labelElement}
      </ElementType>
    )
  }
}

Input.create = createShorthandFactory(Input, type => ({ type }))


class FormInput extends Component {
  static _meta = {
    name: 'FormInput',
    parent: 'Form',
    type: META.TYPES.COLLECTION,
  }

  static defaultProps = {
    as: FormField,
    control: Input
  }
  static propTypes = {
    as: customPropTypes.as,
    control: FormField.propTypes.control,
    validator: PropTypes.obj
  }

  render(){
    const { control } = this.props
    const errorStyle = { background: 'none',
         color: '#9f3a38',
         fontSize: '0.7em',
         verticalAlign: 'top',
         lineHeight: '14px',
         textTransform: 'uppercase'
       }
    const { error, label, validator: { isValid, message }, ...rest } = { ...getUnhandledProps(FormInput, this.props) }
    const errorLabel = <Label style={{ background:'none', color:'black', fontSize: '1em' }}
      content={<span>{label} <span style={errorStyle}>{message}</span>
      </span>}/>

    const newProps = { ...rest, error: error || isValid === false, label: isValid === false ? errorLabel : label  }
    const ElementType = getElementType(FormInput, newProps)
    return <ElementType {...newProps} control={control} />
  }
}

export default FormInput

To use, you i had to just import and muck with the Input, this only works with Input type fields.

To use:

  1. Add this to your state:
 validators: {
      isbn13: isIsbn13,
      title: isRequired,
      author: isRequired,
      weeklyPrice: isRequired,
      price: isRequired
  1. Add this callback on your view from your form
onValidate = (validators) => {
    this.setState({ validators })
  }
  1. Your form looks like this
<ValidatingForm ref="book_form"
                onValidate={this.onValidate}
                validators={this.state.validators}
</ValidatingForm>

  1. My new Form Input looks like this
<FormInput
                    onChange={this.handleChange}
                    onInput={this.handleChange}
                    width={5}
                    name="isbn13"
                    value={isbn13}
                    fluid
                    label="ISBN13 *"
                    type="text"
                    placeholder="ISBN13"
                    validator={validators.isbn13}
                  />
  1. And the validator example objects look like this:
export const isRequired = {
    validator: (item = '') => {
      console.log(item)
      return !(_.isEmpty(item))
    },
    message: 'Required'
}

export const isIsbn13 = {
  validator: (item = '') => {
    return !!_.get(item.match(/9\d{12}(?=(\b|[^\d]))/), '0')
  },
  message: 'Please enter a valid ISBN13'
}
  1. To invoke it, right now just do a on the validating form, returns false if invalid
 book_form.refs.validate()

Hope this helps, validation is a pretty important feature for forms, and the biggest lacking feature IMHO. Any ideas how to improve this gladly accepted.

Hope this helps

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests