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'
3 import ScriptLoaderMixin from 'react-script-loader'
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 onboardStyles from '../styles/onboarding.scss'
9 import config from '../config'
11 export default React.createClass({
12 displayName: 'PaymentMethodPage',
13 mixins: [ScriptLoaderMixin.ReactScriptLoaderMixin, React.addons.LinkedStateMixin],
15 return 'https://js.stripe.com/v2/'
21 stripeFailedToLoad: false,
32 nameValidationOutputs: {},
33 numberValidationOutputs: {},
34 cvcValidationOutputs: {},
35 expirationValidationOutputs: {},
38 Stripe.setPublishableKey(config.stripeKey)
39 this.setState({stripeLoading: false})
43 this.setState({stripeFailedToLoad: true})
47 let created = new Date()
48 if (app.me && app.me.profile && app.me.profile.created && app.me.profile.created < created) {
49 console.log("using register date...")
50 created = app.me.profile.created
52 let month = created.getMonth()
53 let day = created.getDate()
59 let result = new Date(created.toString())
61 result.setMonth(month)
67 if (this.state.stripeLoading || this.state.stripeFailedToLoad) {
70 this.setState({active: true})
73 Stripe.card.createToken({
74 number: this.state.number,
76 exp_month: this.state.expireMonth,
77 exp_year: this.state.expireYear,
78 name: this.state.name,
79 }, function(status, response) {
83 if (response.error.type == 'card_error') {
84 switch (response.error.code) {
85 case 'incorrect_number':
86 errors.push({'error': 'invalid_value', 'field': '/number'})
88 case 'invalid_number':
89 errors.push({'error': 'invalid_format', 'field': '/number'})
91 case 'invalid_expiry_month':
92 errors.push({'error': 'invalid_format', 'field': '/expireMonth'})
94 case 'invalid_expiry_year':
95 errors.push({'error': 'invalid_format', 'field': '/expireYear'})
98 errors.push({'error': 'invalid_format', 'field': '/cvc'})
101 errors.push({'error': 'invalid_value', 'field': '/expiration'})
103 case 'incorrect_cvc':
104 errors.push({'error': 'invalid_value', 'field': '/cvc'})
106 case 'incorrect_zip':
107 errors.push({'error': 'invalid_value', 'field': '/zip'})
109 case 'card_declined':
110 errors.push({'error': 'insufficient', 'field': '/balance'})
113 errors.push({'error': 'missing', 'field': '/customer/card'})
115 case 'processing_error':
116 errors.push({'error': 'act_of_god', 'field': '/'})
119 errors.push({'error': 'access_denied', 'field': '/rate'})
122 errors.push({'error': 'act_of_god', 'field': '/'})
126 console.log('Error:', response.error.message)
129 console.log('Sending '+response.id+' to server to create customer')
131 t.setState({active: false, errors: errors})
137 <div className='container'>
138 <HeroUnit title='Add a Payment Method'>Gotta keep our servers online and food on the table.</HeroUnit>
139 <article className='onboarding payment'>
140 <p>Ducky costs $2 a month to use. We’ll charge the card you enter below on the first of every month until your account is disabled. You won’t be charged before {this.getChargeDate().toLocaleDateString(navigator.languages, {month: 'long', year: 'numeric', day: 'numeric'})}.</p>
141 <form onSubmit={this.addCard}>
143 <label htmlFor='name'>Cardholder Name</label>
144 <input id='name' type='text' placeholder='This is the name on your card' valueLink={this.linkState('name')} />
145 <ValidationError errors={this.state.errors} field='/name' outputs={this.nameValidationOutputs} />
147 <label htmlFor='cardNumber'>Card Number</label>
148 <input id='cardNumber' type='text' placeholder='4242 4242 4242 4242' valueLink={this.linkState('number')} />
149 <ValidationError errors={this.state.errors} field='/number' outputs={this.numberValidationOutputs} />
151 <label htmlFor='cvc'>Security Code / CVC</label>
152 <input id='cvc' className='cvc' type='password' placeholder='123' valueLink={this.linkState('cvc')} />
154 <label htmlFor='expireMonth' className='expiration'>Expires</label>
155 <input id='expireMonth' className='expiration month' type='text' placeholder='01' valueLink={this.linkState('expireMonth')} />
156 <input id='expireYear' className='expiration year' type='text' placeholder='15' valueLink={this.linkState('expireYear')} />
157 <ValidationError errors={this.state.errors} field='/cvc' outputs={this.cvcValidationOutputs} />
158 <ValidationError errors={this.state.errors} field='/expiration' outputs={this.expirationValidationOutputs} />
160 <div className='actionbuttons'>
161 <button type='button' onClick={this.skip} className='ladda-button' disabled={this.state.active}>Not Now</button>
162 <LaddaButton style='expand-right' active={this.state.active}>
163 <button type='submit' className='primary' disabled={this.state.stripeLoading || this.state.stripeFailedToLoad || this.state.active || this.state.errors.length}>Add Card</button>