Implement form verification (async form validation)#9054
Implement form verification (async form validation)#9054aghassemi merged 22 commits intoampproject:masterfrom
Conversation
1b22b04 to
9b012b2
Compare
6195d98 to
26dff6f
Compare
examples/form/zipcodes.csv
Outdated
| @@ -0,0 +1,4431 @@ | |||
| RecordNumber,Zipcode,City,State | |||
There was a problem hiding this comment.
What is alllllllllllllllll of this needed for?
There was a problem hiding this comment.
The original title of the issue was forms: zip code input validation, but it was changed. I thought reading through real California zip codes and cities would be an interesting demo. If you think it's too heavy weight we can just change it to a smaller dict.
| * @return {?Element} | ||
| */ | ||
| function getConfig_(form) { | ||
| return form.getElementsByTagName('script')[0]; |
There was a problem hiding this comment.
This is finds deep elements. Should this be looking for a child script instead?
| }); | ||
| const config = json && json[CONFIG_KEY]; | ||
| if (!config.length) { | ||
| throw new Error(`The amp-form verification config should contain an array |
There was a problem hiding this comment.
Are there any future uses for this configuration script? Maybe we shouldn't be throwing an error if they don't specify a verification group?
There was a problem hiding this comment.
See #getFormVerifier. #parseConfig_ is not called if a verification group is not specified.
| throw new Error('Failed to parse amp-form config. Is it valid JSON?'); | ||
| }); | ||
| const config = json && json[CONFIG_KEY]; | ||
| if (!config.length) { |
There was a problem hiding this comment.
This will throw for an empty JSON object.
There was a problem hiding this comment.
Isn't that correct behavior? If the publisher specifies {"verificationGroups": []} or {} in a form config, it seems like it is a mistake they should be made aware of through an error. Let me know if you'd prefer to allow an empty config.
There was a problem hiding this comment.
It's fine for this to be an error, but it this line causes an error. As in, the test, not the error we throw on the line below.
| * Clear the validity state of this group's elements. | ||
| */ | ||
| clearErrors() { | ||
| this.elements_.forEach(element => element.setCustomValidity('')); |
There was a problem hiding this comment.
Are all elements guaranteed to have #setCustomValidity?
There was a problem hiding this comment.
The HTML Constraints spec specifies that submittable elements are guaranteed to have #setCustomValidity. A publisher would have to put a non-submittable element in a verification group for this to throw, and it seems unlikely. I could check that every element in the group has a #setCustomValidity method in #parseConfig, let me know if you think that's a good idea.
There was a problem hiding this comment.
Yah, I would just limit them to the form input elements.
| export class FormVerifier { | ||
| /** | ||
| * @param {!HTMLFormElement} form | ||
| * @param {!Array<!VerificationGroup>} config |
| // Set the error message on each element that caused an error. | ||
| for (let i = 0; i < errors.length; i++) { | ||
| const error = errors[i]; | ||
| const element = scopedQuerySelector(this.form_, `[name="${error.name}"]`); |
There was a problem hiding this comment.
Scoped query selector is unnecessary in this case.
There was a problem hiding this comment.
I got an error in the presubmit on Travis telling me not to this.form_.querySelector because of strange behavior. Why is it unnecessary in this case?
If you mean use document.querySelector, multiple elements can have identical names so it could select the wrong element.
There was a problem hiding this comment.
scopedQuerySelector is only necessary when there is a space in the selector (really, when you're matching a descendent of a descendent, see #7260). In this case, you're only matching a single descendent, so no issues.
There was a problem hiding this comment.
I see. We may want to update the message querySelectorAll produces during the travis presubmit or a comment in the jsdoc for scopedQuerySelector to make that clearer. Thanks!
| // Remove the dirty flag from the successful groups that have values. | ||
| for (let i = 0; i < this.groups_.length; i++) { | ||
| const group = this.groups_[i]; | ||
| if (group.isFilledOut() && group.containsNoElementOf(errorElements)) { |
There was a problem hiding this comment.
Use #shouldVerify instead.
There was a problem hiding this comment.
This statement and the function of #shouldVerify are not equivalent. Could you please explain why?
There was a problem hiding this comment.
This method is #verify, which means we're only verifying the elements that #shouldVerify returned true for.
| * @param {!Array<!Element>} elements | ||
| * @return {boolean} | ||
| */ | ||
| containsNoElementOf(elements) { |
There was a problem hiding this comment.
Nit: rename to #containsNone
| */ | ||
| maybeVerify_(afterVerify) { | ||
| if (this.shouldVerify_()) { | ||
| this.doXhr_().then(() => { |
There was a problem hiding this comment.
We're gonna get into a LOT of race conditions here. Need to verify that this is a response to the last issued XHR, not any before it.
82e22af to
61354f1
Compare
aghassemi
left a comment
There was a problem hiding this comment.
first round of comments for the first half.
| color: #dc4e41; | ||
| } | ||
|
|
||
| .verification-form label { |
There was a problem hiding this comment.
/cc @spacedino let's work with Abby on UI design of this
There was a problem hiding this comment.
This is just for the example page we use for development, right? It isn't baked in to amp-form. The publisher should be responsible for the spinner UX
There was a problem hiding this comment.
less so for this example page and more so to kick of the design for AmpByExample and/or AMP Start.
examples/forms.amp.html
Outdated
| }, | ||
| { | ||
| "name": "fullAddress", | ||
| "elements": ["[name='addressLine2']", "[name='city']", "[name='zip']"] |
There was a problem hiding this comment.
let's restrict this to just name (considering error response has name which is used to find the target elements later)
maybe: "fields" = ['addressLine2', 'city', 'zip'].
| checkUserValidityAfterInteraction_(dev().assertElement(e.target)); | ||
| this.validator_.onBlur(e); | ||
| }, true); | ||
| this.form_.addEventListener('change', e => { |
There was a problem hiding this comment.
should this be guarded by custom-validation-reporting='as-you-go?
There was a problem hiding this comment.
Nope, "as-you-go" is sort of poorly named. It should be called something that implies "use your own error message elements instead of using Chrome's validation tooltip, AND do so as the user fills out fields". The change listener is still necessary when using verification with Chrome's default validation UI.
There was a problem hiding this comment.
wow ok. so what is the default browser UI strategy? is it always "on-submit" and not changeable?
There was a problem hiding this comment.
That's right, unless you call form.reportValidation, which by default happens on submit.
| } | ||
| } | ||
|
|
||
| getVarSubsFields_() { |
There was a problem hiding this comment.
jsdoc for this a few other private methods added
extensions/amp-form/0.1/amp-form.js
Outdated
|
|
||
| this.setState_(FormState_.VERIFYING); | ||
| const verifyXhr = this.doVarSubs_(this.getVarSubsFields_()).then( | ||
| () => this.doXhr_(map({verify: true}))); |
There was a problem hiding this comment.
'verify' can collide with existing inputs named verify. Do __amp_form_verify and then check if any input has that name same way SOURCE_ORIGIN_PARAM is checked in the constructor of AMP form.
| function parseConfig_(script) { | ||
| if (isJsonScriptTag(script)) { | ||
| const json = tryParseJson(script.textContent, () => { | ||
| throw new Error('Failed to parse amp-form config. Is it valid JSON?'); |
There was a problem hiding this comment.
throw user().createError() here and couple of places below.
| * data in ways not possible with standard form validation, or check | ||
| * values against sets of data too large to fit in browser memory | ||
| * e.g. ensuring zip codes match with cities. | ||
| * @visibleForTesting |
extensions/amp-form/0.1/amp-form.js
Outdated
|
|
||
| this.setState_(FormState_.VERIFYING); | ||
| const verifyXhr = this.doVarSubs_(this.getVarSubsFields_()).then( | ||
| () => this.doXhr_(map({verify: true}))); |
There was a problem hiding this comment.
Probably not. Could you help me understand what use-cases require our map utility function?
There was a problem hiding this comment.
I thought it was to make for-in loops more robust. I iterate over this object using a for-in loop in #doXhr
There was a problem hiding this comment.
It has an old JS meaning and a valid CS meaning.
Old JS, people used to put enumerable properties on the Object.prototype object, which would then magically appear in all for-in loops. We don't do that. Actually we did, but Ali fixed that.
The CS meaning is when the object is treated as a true Map instance with arbitrary accesses. In that case, anything in the prototype chain could be accessed, which is incorrect in 99% of cases.
There was a problem hiding this comment.
I see, map is more for storing and accessing values, and we don't need it here because it's just a descriptive value and not used for storage.
extensions/amp-form/0.1/amp-form.js
Outdated
| const isHeadOrGet = this.method_ == 'GET' || this.method_ == 'HEAD'; | ||
|
|
||
| const p = this.doVarSubs_(varSubsFields) | ||
| .then(() => { |
| return; | ||
| } | ||
|
|
||
| this.setState_(FormState_.VERIFYING); |
There was a problem hiding this comment.
Are we going to do any blocking in this state?
There was a problem hiding this comment.
Like blocking of the submit button? No, that is not planned. This state is intended to allow a spinner to show during the verify request. If the user wants to submit while the verify request is pending, they should be able to.
a278ebf to
d2f3150
Compare
aghassemi
left a comment
There was a problem hiding this comment.
Second round.
Excellent PR overall Carlos!
| this.validator_.onBlur(e); | ||
| }, true); | ||
| this.form_.addEventListener('change', e => { | ||
| const input = dev().assertElement(e.target); |
There was a problem hiding this comment.
let's create an experiment (maybe amp-form-verifier?) and guard entry points to this. Ideally being sure when experiment is off, code paths to existing forms functionality does not change (much).
extensions/amp-form/0.1/amp-form.js
Outdated
|
|
||
| const verifyXhr = this.doVarSubs_(this.getVarSubsFields_()) | ||
| .then(() => this.doXhr_({[FORM_VERIFY_PARAM]: true})); | ||
| const reset = () => this.setState_(FormState_.INITIAL); |
There was a problem hiding this comment.
what if multiple are still running and one fails while others are still running? we don't want to lose VERIFYING state.
There was a problem hiding this comment.
Good catch, I'll move this to the afterVerify callback
| // Set the error message on each element that caused an error. | ||
| for (let i = 0; i < errors.length; i++) { | ||
| const error = errors[i]; | ||
| const element = this.form_./*OK*/querySelector(`[name="${error.name}"]`); |
There was a problem hiding this comment.
scopedQuerySelector(this.form_, [name="${error.name}"]) from dom.js
There was a problem hiding this comment.
@jridgewell and I discussed this above, and he shared that scopedQuerySelector is not necessary unless there is a space in the selector. Is that your understanding as well?
There was a problem hiding this comment.
My understanding is that scopedQuerySelector(parent) can match the parent element itself but parent.querySelector can never match parent. so in that sense yeah no need to use scopedQuerySelector.
With my comment I was hoping to get rid of /*OK*/ as they should be used for exceptions not common cases, looks like querySelector is allowed without `/OK/ anyway.
There was a problem hiding this comment.
Neither can match the parent. The issue is #querySelector is scoped to the document, then filtered to elements inside the parent (div span can match a <div> outside parent and then a <span> inside parent). scopedQuerySelector however, is scoped to the parent only (div span can only match a <div> inside parent and a <span> inside that <div>).
#querySelector is allowed when there is a single string without any spaces (meaning, you can not have '* *'). Because this is a template string, it could do all kinds of weird things. But we know better than the linter in this case.
There was a problem hiding this comment.
@jridgewell wow good to know (I probably should have known this but never too late to learn :) ). Good job preventing this with presubmit rules.
| const error = errors[i]; | ||
| const element = this.form_./*OK*/querySelector(`[name="${error.name}"]`); | ||
| if (element && element.checkValidity()) { | ||
| element.setCustomValidity(error.message); |
There was a problem hiding this comment.
I guess if multiple elements have the same name (common with radio buttons) the first one gets it which is fine, let's just document here as a comment.
src/service/xhr-impl.js
Outdated
| const status = response.status; | ||
| if (status < 200 || status >= 300) { | ||
| const retriable = isRetriable(status); | ||
| const err = user().createCustomError( |
There was a problem hiding this comment.
we should not log the full response (since we store logs and response body can contain sensitive info)
There was a problem hiding this comment.
The only thing sent in the error report is the message first argument. The other information is just tacked on to the error object and not sent, so the full response won't be logged.
Also, createError doesn't report errors so this error isn't logged anyway.
There was a problem hiding this comment.
well, createError don't report but whoever is catching .fetch() error may report it. (or we might be reporting all unhandledrejection error, @jridgewell do we report unhandledrejection?)
You are right that response does got get reported right now because it is just a field on FetchError, but do we need it? I like dump error objects that one can't do anything but log them instead of error objects that can be inspected and used to switch code path. (e.g. not a fan of .catch(err => if( err.response.code == 500) {...} )
There was a problem hiding this comment.
Removing the property from the error would involve slightly more refactoring effort. The code was already augmenting the error with the response and responseJson properties, just not doing so with a custom error class.
There was a problem hiding this comment.
We do log all unhandledrejections.
| /** | ||
| * Resolves with the result of the last promise added. | ||
| */ | ||
| export class LastAddedResolver { |
src/utils/promise.js
Outdated
| this.count_ = 0; | ||
|
|
||
| if (opt_promises) { | ||
| for (let i = 0; i > opt_promises.length; i++) { |
There was a problem hiding this comment.
please add a test that would catch the bug in this line.
There was a problem hiding this comment.
Ah that's a pretty goofy mistake I made. Adding the test.
extensions/amp-form/0.1/amp-form.js
Outdated
| inputs[i].name != SOURCE_ORIGIN_PARAM, | ||
| 'Illegal input name, %s found: %s', SOURCE_ORIGIN_PARAM, inputs[i]); | ||
| const name = inputs[i].name; | ||
| user().assert(!name || |
There was a problem hiding this comment.
First check is unnecessary.
| updatedElements.forEach(updatedElement => { | ||
| checkUserValidityAfterInteraction_(updatedElement); | ||
| }); | ||
| this.validator_.onBlur(e); |
There was a problem hiding this comment.
Why are we blurring here?
There was a problem hiding this comment.
This function's name is not very clear. It's part of the amp-form FormValidator API and it hides any displayed messages. Originally it was always called on blur in the form, but that behavior is also needed when the verifier calls afterVerify.
extensions/amp-form/0.1/amp-form.js
Outdated
|
|
||
| const verifyXhr = this.doVarSubs_(this.getVarSubsFields_()) | ||
| .then(() => this.doXhr_({[FORM_VERIFY_PARAM]: true})); | ||
| const reset = () => this.setState_(FormState_.INITIAL); |
There was a problem hiding this comment.
This probably needs to make sure form state is still VERIFYING
extensions/amp-form/0.1/amp-form.js
Outdated
| this.renderTemplate_(json || {}); | ||
| this.maybeHandleRedirect_(response); | ||
| }, error => { | ||
| rethrowAsync('Failed to parse response JSON:', error); |
There was a problem hiding this comment.
Thanks. Can you help me understand when something should be a user error? I haven't been able to find anything in the docs/comments.
There was a problem hiding this comment.
Developer errors are only for state that we absolutely know to be true, and isn't. So, if we expect our BaseElement instance to have an element attached, but it doesn't, that goes to dev logs.
But anything that has to do with user input, like parsing JSON that pub provides, or a response that pub provides, is on them. We don't need to know about it.
There was a problem hiding this comment.
It wasn't a user error before I made any changes, this is just split out from a larger function. A user error needs a tag, correct? What value should I put for tag given this is not in an amp element.
There was a problem hiding this comment.
That's a mistake then. This should be "FORM"
extensions/amp-form/0.1/amp-form.js
Outdated
| this.setState_(FormState_.SUBMIT_ERROR); | ||
| this.renderTemplate_(error.responseJson || {}); | ||
| this.maybeHandleRedirect_(error.response); | ||
| rethrowAsync('Form submission failed:', error); |
| // Set the error message on each element that caused an error. | ||
| for (let i = 0; i < errors.length; i++) { | ||
| const error = errors[i]; | ||
| const element = this.form_./*OK*/querySelector(`[name="${error.name}"]`); |
There was a problem hiding this comment.
Neither can match the parent. The issue is #querySelector is scoped to the document, then filtered to elements inside the parent (div span can match a <div> outside parent and then a <span> inside parent). scopedQuerySelector however, is scoped to the parent only (div span can only match a <div> inside parent and a <span> inside that <div>).
#querySelector is allowed when there is a single string without any spaces (meaning, you can not have '* *'). Because this is a template string, it could do all kinds of weird things. But we know better than the linter in this case.
src/utils/promise.js
Outdated
| }, error => { | ||
| // Match the behavior of standard functions by rejecting when an error | ||
| // occurs even if it's out of order. e.g. Promise.race and Promise.all | ||
| this.reject_(error); |
There was a problem hiding this comment.
This is incorrect, we're looking for more of a cancel behavior and not a race behavior. This should just reject if the most recently added one rejects.
There was a problem hiding this comment.
Yeah I was on the fence about which way to go. I think it can be made to be equivalent through adding promises with a catch at the end, but I agree it's convenient to not have to do that for our common use-case.
| /** | ||
| * Resolves with the result of the last promise added. | ||
| */ | ||
| export class LastAddedResolver { |
There was a problem hiding this comment.
@ampproject/a4a will be interested in this.
src/utils/promise.js
Outdated
| * Get the result promise. | ||
| * @return {!Promise} | ||
| */ | ||
| get() { |
There was a problem hiding this comment.
I'd be nice if this followed the thenable spec instead (we can get rid of this method then):
then(onFulfilled, onRejected) {
return this.promise_.then(onFulfilled, onRejected);
}
src/utils/promise.js
Outdated
| get() { | ||
| return this.promise_; | ||
| then(opt_resolve, opt_reject) { | ||
| if (!opt_resolve && !opt_reject) { |
There was a problem hiding this comment.
Let the promise implementation handle this.
17ed8cc to
fba0ab1
Compare
src/service/xhr-impl.js
Outdated
| if (status < 200 || status >= 300) { | ||
| const retriable = isRetriable(status); | ||
| const err = user().createCustomError( | ||
| new FetchError(`HTTP error ${status}`, response, retriable)); |
There was a problem hiding this comment.
Why are we creating custom Error classes?
There was a problem hiding this comment.
I was trying to adapt the existing code to work better with the type system, since gulp check-types would complain that I was accessing properties not available on an Error instance. The previous implementation instantiated a new Error and augmented it with the response and the responseJson, in effect rejecting an error and passing data together. It was added in #3669. This felt hacky and unclear since the type was sort of lying (it says it's an Error, but it has these extra fields) so I wanted to make the augmentation more formal.
src/log.js
Outdated
| /** | ||
| * @param {string} message | ||
| */ | ||
| constructor(message) { |
There was a problem hiding this comment.
Danger! This may or may not behave as you expect based on how compliant the browser is.
There was a problem hiding this comment.
I ran the unit test for this on our SauceLabs suite and it passed on all platforms. Does that mitigate the danger? Do those tests cover every browser/device we support?
There was a problem hiding this comment.
I'm more worried about older versions. Extending native classes has been fraught with bugs (see https://github.com/ampproject/amphtml/blob/master/src/custom-element.js#L409-L414).
extensions/amp-form/amp-form.md
Outdated
| #### As You Go | ||
| The `as-you-go` reporting option allows your user to see validation messages as they're interacting with the input. For example, if the user types an invalid email address, the user will see the error right away. Once they correct the value, the error goes away. | ||
|
|
||
| ## Verification |
There was a problem hiding this comment.
## Verification (Experimental)
also add a first sentence that points out the experimental nature and instructions on how to enable the experiment (e.g. link to https://www.ampproject.org/docs/reference/experimental )
- Prevent verify request race condition with LastAddedResolver - Add amp-form tests with verifier - Add custom error class to simplify logic with XHR errors
- Fix error on connection fail. - Fix for-loop bug. - Fix state bug.
|
I've tested this against some pages in the wild and our manual tests and I feel comfortable merging this now. |
|
merged |

Closes #8369
Needs documentation, opening this PR for feedback.