ducky/web

Paddy 2015-05-31 Parent:b9d0efb44eaa Child:a641906b8267

4:bd64a7d043d0 Go to Latest

ducky/web/src/pages/register.jsx

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.

History
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],
16 getInitialState () {
17 return {
18 email: null,
19 emailConfirmation: null,
20 passphrase: null,
21 passphraseConfirmation: null,
22 active: false,
23 clientErrors: [],
24 serverErrors: [],
25 }
26 },
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?',
32 },
34 emailConfirmationValidationOutputs: {
35 'invalid_value': 'Oops! Those emails don’t match. Maybe double-check them?',
36 },
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.',
42 },
44 passphraseConfirmationValidationOutputs: {
45 'invalid_value': 'Oops! Those passphrases don’t match. Maybe double-check them?',
46 },
48 debouncedValidateForm (event) {
49 this._validateForm(event)
50 },
52 _validateForm (event) {
53 const fields = {
54 email: this.state.email,
55 emailConfirmation: this.state.emailConfirmation,
56 passphrase: this.state.passphrase,
57 passphraseConfirmation: this.state.passphraseConfirmation,
58 }
59 if (event != null) {
60 if (event.target.id == 'emailInput') {
61 fields.email = event.target.value
62 }
63 else if (event.target.id == 'emailConfirmationInput') {
64 fields.emailConfirmation = event.target.value
65 }
66 else if (event.target.id == 'passphraseInput') {
67 fields.passphrase = event.target.value
68 }
69 else if (event.target.id == 'passphraseConfirmationInput') {
70 fields.passphraseConfirmation = event.target.value
71 }
72 }
73 const errors = this.validate(fields, event == null)
74 this.setState({clientErrors: errors})
75 return errors.length <= 0
76 },
78 validateForm (event) {
79 event.persist()
80 this.debouncedValidateForm(event)
81 },
83 validate (fields, all) {
84 const errors = []
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'})
92 }
93 } else if (all) {
94 errors.push({'error': 'missing', 'field': '/email'})
95 }
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'})
99 }
100 }
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'})
108 }
109 } else if (all) {
110 errors.push({'error': 'missing', 'field': '/passphrase'})
111 }
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'})
115 }
116 }
117 return errors
118 },
120 componentDidMount () {
121 app.profiles.on('request', (moc, xhr, options) => {
122 this.setState({active: true})
123 })
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)
128 } else {
129 state.serverErrors = [{'error': 'act_of_god'}]
130 }
131 this.setState(state)
132 })
133 app.profiles.on('sync', (moc, xhr, options) => {
134 app.me.login(this.state.email, this.state.passphrase)
135 })
136 app.me.on('sync', (moc, xhr, options) => {
137 this.setState({active: false})
138 app.router.navigate('/register/payment')
139 })
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)
144 } else {
145 state.serverErrors = [{'error': 'act_of_god'}]
146 }
147 this.setState(state)
148 })
149 },
151 componentWillMount () {
152 this.debouncedValidateForm = debounce(this.debouncedValidateForm, 900)
153 },
155 register (e) {
156 e.preventDefault()
157 const success = this._validateForm(null)
158 if (!success) {
159 return
160 }
161 app.profiles.register(this.state.email, this.state.passphrase)
162 },
164 onBackClick (event) {
165 event.preventDefault()
166 window.history.back()
167 },
169 render () {
170 return (
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}>
175 <div>
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} />
191 </div>
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>
196 </LaddaButton>
197 </div>
198 </form>
199 </article>
200 </div>
201 )
202 }
203 })