Start tracking server errors.
We've been storing client-side errors for a while now, so we can say "hey that
input you entered is really screwed up, please don't even bother submitting the
form." But once we pass that hurdle, it's still possible that the server will
say "I don't care what the client-side validation said, this input is still
wrong."
To achieve this, we needed to start tracking server errors and client errors
_separately_. The plan, originally, was just to put them in a single array, all
mixed together--and, of course, that's what our ValidationError component
requires--but there's a usability problem with this. The server error could be
"you don't have an internet connection" (using 'server-error' loosely...) or
"hey, the server had a problem handling your request, try again." The problem
with putting everything together is that all these "try again" errors should
get displayed to the user, but anything displayed to the user causes the
Register button to be disabled. Meaning they can't try again until they
(unintuitively) change pretty much any field in the form.
The answer was to separate the two types of errors into their own arrays, and
disable the button based on the client-side error array. When displaying errors,
we concatenate the arrays. This means server-side errors only get displayed, but
do not disable the button.
Of course, we can have server errors that can mean "your input is bad and you
should feel bad", which are decidedly different from "try again later" errors.
These "your input is bad" errors hypothetically _should_ keep the form from
being submitted until the value is changed to a valid input. Unfortunately, if
we get to the point where a server error is returned, we've already proven the
client is incapable of determining between invalid and valid inputs for that
specific condition. If the client were capable of knowing, the input never would
have reached the server in the first place. So considering the point of
disabling the form input is to prevent unnecessary requests to the server, we
should never disable the form based on a server error, because (by definition)
those requests _must_ be necessary, or the client-side validation would have
caught them.
TL;DR: disabling a form because of server-side errors is silly.
We also fixed a bug in the ValidationError component that would cause any error
without a field, param, or header value set (e.g., an error that only contains
the error field) would be considered a match for _every_ input, which is not
what we want.
We still need to decide on logic for displaying global errors. On the naive
level, it's easy: match '/' or '' as the field. But that ignores any error
responses that we are not specifically looking for. I'd much rather have a
catch-all ValidationError handler that will catch any error not already handled
by the other ValidationError instances. Unfortunately, I can't wrap my head
around a good way to denote that a ValidationError instance has handled a
specific error. The best I've got is to do an InverseValidationError that tracks
a list of fields, params, and headers (the inputs available for ValidationError)
and handles any error that _doesn't_ match them. Unfortunately, for complex
forms, this would involve quite a bit of manual bookkeeping, updating
ValidationError and InverseValidationError components in sync and never
forgetting to update one or the other.
1 import app from 'ampersand-app'
2 import React from 'react/addons'
3 import localLinks from 'local-links'
4 import LaddaButton from 'react-ladda'
5 import LaddaCSS from '../../node_modules/ladda/dist/ladda.min.css'
6 import HeroUnit from '../components/hero'
7 import ValidationError from '../components/validation-error'
8 import onboardingStyles from '../styles/onboarding.scss'
9 import flashStyles from '../styles/_flashes.scss'
10 import debounce from 'lodash.debounce'
12 export default React.createClass({
13 displayName: 'RegisterPage',
14 mixins: [React.addons.LinkedStateMixin],
19 emailConfirmation: null,
21 passphraseConfirmation: null,
28 emailValidationOutputs: {
29 'missing': 'We need to know how to contact you. We promise not to share it.',
30 'overflow': 'Hm, that’s a bit long. Do you have a shorter email address?',
31 'invalid_format': 'That doesn’t look like an email address… Double check it?',
34 emailConfirmationValidationOutputs: {
35 'invalid_value': 'Oops! Those emails don’t match. Maybe double-check them?',
38 passphraseValidationOutputs: {
39 'insufficient': 'A longer passphrase would be better.',
40 'missing': 'Looks like you forgot to enter a passphrase. You need one!',
41 'overflow': 'We can’t store that long a passphrase. Try a shorter one.',
44 passphraseConfirmationValidationOutputs: {
45 'invalid_value': 'Oops! Those passphrases don’t match. Maybe double-check them?',
48 debouncedValidateForm (event) {
49 this._validateForm(event)
52 _validateForm (event) {
54 email: this.state.email,
55 emailConfirmation: this.state.emailConfirmation,
56 passphrase: this.state.passphrase,
57 passphraseConfirmation: this.state.passphraseConfirmation,
60 if (event.target.id == 'emailInput') {
61 fields.email = event.target.value
63 else if (event.target.id == 'emailConfirmationInput') {
64 fields.emailConfirmation = event.target.value
66 else if (event.target.id == 'passphraseInput') {
67 fields.passphrase = event.target.value
69 else if (event.target.id == 'passphraseConfirmationInput') {
70 fields.passphraseConfirmation = event.target.value
73 const errors = this.validate(fields, event == null)
74 this.setState({clientErrors: errors})
75 return errors.length <= 0
78 validateForm (event) {
80 this.debouncedValidateForm(event)
83 validate (fields, all) {
85 if (fields.email != null) {
86 if (fields.email.length == 0) {
87 errors.push({'error': 'missing', 'field': '/email'})
88 } else if (fields.email.length > 64) {
89 errors.push({'error': 'overflow', 'field': '/email'})
90 } else if (!fields.email.match(/.+@.+\..+/)) {
91 errors.push({'error': 'invalid_format', 'field': '/email'})
94 errors.push({'error': 'missing', 'field': '/email'})
96 if (fields.emailConfirmation != null || all) {
97 if (fields.emailConfirmation != fields.email && (fields.email || fields.emailConfirmation)) {
98 errors.push({'error': 'invalid_value', 'field': '/email_confirmation'})
101 if (fields.passphrase != null) {
102 if (fields.passphrase.length == 0) {
103 errors.push({'error': 'missing', 'field': '/passphrase'})
104 } else if (fields.passphrase.length < 6) {
105 errors.push({'error': 'insufficient', 'field': '/passphrase'})
106 } else if (fields.passphrase.length > 64) {
107 errors.push({'error': 'overflow', 'field': '/passphrase'})
110 errors.push({'error': 'missing', 'field': '/passphrase'})
112 if (fields.passphraseConfirmation != null || all) {
113 if (fields.passphraseConfirmation != fields.passphrase && (fields.passphrase || fields.passphraseConfirmation)) {
114 errors.push({'error': 'invalid_value', 'field': '/passphrase_confirmation'})
120 componentDidMount () {
121 app.profiles.on('request', (moc, xhr, options) => {
122 this.setState({active: true})
124 app.profiles.on('error', (moc, xhr, options) => {
125 let state = {active: false}
126 if (xhr.errors && xhr.errors.length) {
127 state.serverErrors = this.state.serverErrors.concat(xhr.errors)
129 state.serverErrors = [{'error': 'act_of_god'}]
133 app.profiles.on('sync', (moc, xhr, options) => {
134 app.me.login(this.state.email, this.state.passphrase)
136 app.me.on('sync', (moc, xhr, options) => {
137 this.setState({active: false})
138 app.router.navigate('/register/payment')
140 app.me.on('error', (moc, xhr, options) => {
141 let state = {active: false}
142 if (xhr.errors && xhr.errors.length) {
143 state.serverErrors = this.state.serverErrors.concat(xhr.errors)
145 state.serverErrors = [{'error': 'act_of_god'}]
151 componentWillMount () {
152 this.debouncedValidateForm = debounce(this.debouncedValidateForm, 900)
157 const success = this._validateForm(null)
161 app.profiles.register(this.state.email, this.state.passphrase)
164 onBackClick (event) {
165 event.preventDefault()
166 window.history.back()
171 <div className='container'>
172 <HeroUnit title='Create an Account'>We’d like to get to know you better.</HeroUnit>
173 <article className='onboarding register'>
174 <form onSubmit={this.register}>
176 <label htmlFor='emailInput'>Email</label>
177 <input id='emailInput' type='email' placeholder='Ours is quack@useducky.com' valueLink={this.linkState('email')} disabled={this.state.active} onInput={this.validateForm} />
178 <ValidationError errors={this.state.clientErrors.concat(this.state.serverErrors)} field='/email' outputs={this.emailValidationOutputs} />
180 <label htmlFor='emailConfirmationInput'>Verify Email</label>
181 <input id='emailConfirmationInput' type='email' placeholder='Typos are the absolute worst.' valueLink={this.linkState('emailConfirmation')} disabled={this.state.active} onInput={this.validateForm} />
182 <ValidationError errors={this.state.clientErrors.concat(this.state.serverErrors)} field='/email_confirmation' outputs={this.emailConfirmationValidationOutputs} />
184 <label htmlFor='passphraseInput'>Passphrase</label>
185 <input id='passphraseInput' type='password' placeholder='We use a sentence. Try it!' valueLink={this.linkState('passphrase')} disabled={this.state.active} onInput={this.validateForm} />
186 <ValidationError errors={this.state.clientErrors.concat(this.state.serverErrors)} field='/passphrase' outputs={this.passphraseValidationOutputs} />
188 <label htmlFor='passphraseConfirmationInput'>Verify Passphrase</label>
189 <input id='passphraseConfirmationInput' type='password' placeholder='Just to make sure you know it.' valueLink={this.linkState('passphraseConfirmation')} disabled={this.state.active} onInput={this.validateForm} />
190 <ValidationError errors={this.state.clientErrors.concat(this.state.serverErrors)} field='/passphrase_confirmation' outputs={this.passphraseConfirmationValidationOutputs} />
192 <div className='actionbuttons'>
193 <button onClick={this.onBackClick} disabled={this.state.active} type='button' className='ladda-button'>Back</button>
194 <LaddaButton style='expand-right' active={this.state.active}>
195 <button type='submit' className='primary' disabled={this.state.active || this.state.clientErrors.length}>Register</button>