From 56f93cfc7884aa0b10ee2e8e165dee82230b932e Mon Sep 17 00:00:00 2001 From: Matt Mower Date: Sun, 3 Nov 2019 16:16:50 -0800 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8amp-form:=20Initialize=20form=20fr?= =?UTF-8?q?om=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce amp-form attribute `data-initialize-from-url` and form field attribute `data-allow-initialization` to selectively populate form fields from URL query parameter values on page load. Fixes #24533 --- examples/amp-form-initialization-test.html | 288 ++++++++++++++++++ extensions/amp-form/0.1/amp-form.js | 90 +++++- extensions/amp-form/0.1/test/test-amp-form.js | 167 ++++++++++ extensions/amp-form/amp-form.md | 23 ++ 4 files changed, 567 insertions(+), 1 deletion(-) create mode 100644 examples/amp-form-initialization-test.html diff --git a/examples/amp-form-initialization-test.html b/examples/amp-form-initialization-test.html new file mode 100644 index 000000000000..6153ad99e3ab --- /dev/null +++ b/examples/amp-form-initialization-test.html @@ -0,0 +1,288 @@ + + + + + amp-form initialization test + + + + + + + + + + + +

Test URL (or populate form manually and submit with button below)

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
The following form fields have attribute data-allow-initialization, so their values should be set from the URL on page load.
TypeFieldDefault value
<input type="color">
<input type="datetime-local">
<input type="email">
<input type="month">
<input type="number">
<input type="range">
<input type="search">
<input type="tel">
<input type="text">
<input type="time">
<input type="url">
<input type="week">
<input type="hidden">
<input type="checkbox"> + + + + + +
<input type="radio"> + + + + + +
<select> + + + +
<textarea>
The following form fields do not have attribute data-allow-initialization, so their values should not be set from the URL on page load.
TypeFieldDefault value
<input type="text">
<input type="checkbox"> + + + + + +
<input type="radio"> + + + + + +
<select> + + + +
<textarea>
Sample layout hacking
:checked + + +

This checkbox is

+
:invalid + +

This input is

+
[type="hidden"][value="0"] + +

Input value is

+
+ +
+ + + diff --git a/extensions/amp-form/0.1/amp-form.js b/extensions/amp-form/0.1/amp-form.js index 0f4c9650b1a4..eb79317d3959 100644 --- a/extensions/amp-form/0.1/amp-form.js +++ b/extensions/amp-form/0.1/amp-form.js @@ -31,7 +31,12 @@ import { import {FormDirtiness} from './form-dirtiness'; import {FormEvents} from './form-events'; import {FormSubmitService} from './form-submit-service'; -import {SOURCE_ORIGIN_PARAM, addParamsToUrl} from '../../../src/url'; +import { + SOURCE_ORIGIN_PARAM, + addParamsToUrl, + isProxyOrigin, + parseQueryString, +} from '../../../src/url'; import {Services} from '../../../src/services'; import {SsrTemplateHelper} from '../../../src/ssr-template-helper'; import { @@ -214,6 +219,7 @@ export class AmpForm { ); this.installEventHandlers_(); this.installInputMasking_(); + this.maybeInitializeFromUrl_(); /** @private {?Promise} */ this.xhrSubmitPromise_ = null; @@ -1223,6 +1229,88 @@ export class AmpForm { } } + /** + * Initialize form fields from query parameter values if attribute + * 'data-initialize-from-url' is present on the form and attribute + * 'data-allow-initialization' is present on the field. + * @private + */ + maybeInitializeFromUrl_() { + if ( + isProxyOrigin(this.win_.location) || + !this.form_.hasAttribute('data-initialize-from-url') + ) { + return; + } + + const valueTags = ['SELECT', 'TEXTAREA']; + const valueInputTypes = [ + 'color', + 'date', + 'datetime-local', + 'email', + 'hidden', + 'month', + 'number', + 'range', + 'search', + 'tel', + 'text', + 'time', + 'url', + 'week', + ]; + const checkedInputTypes = ['checkbox', 'radio']; + + const queryParams = parseQueryString(this.win_.location.search); + Object.keys(queryParams).forEach(key => { + const field = this.form_./*OK*/ querySelector(`[name="${key}"]`); + if (!field) { + return; + } + // Do not interfere with form fields that utilize variable substitutions. + // These fields are populated at time of form submission. + if (field.hasAttribute('data-amp-replace')) { + return; + } + // Form fields must be whitelisted + if (!field.hasAttribute('data-allow-initialization')) { + return; + } + + const value = queryParams[key] || ''; + const type = field.getAttribute('type') || 'text'; + const tag = field.tagName; + + if ( + (tag === 'INPUT' && + valueInputTypes.includes(type.toLocaleLowerCase())) || + valueTags.includes(tag) + ) { + if (field.value !== value) { + try { + field.value = value; + } catch (e) { + dev().error(TAG, 'unable to initialize form element', e); + } + } + } else if (tag === 'INPUT' && checkedInputTypes.includes(type)) { + const inputs = this.form_./*OK*/ querySelectorAll( + `[name="${key}"][type="${type}"]` + ); + iterateCursor(inputs, input => { + if (input.checked !== (input.value === value)) { + try { + input.checked = input.value === value; + } catch (e) { + dev().error(TAG, 'unable to initialize form element', e); + } + } + }); + } + }); + } + /** * Returns a promise that resolves when tempalte render finishes. The promise * will be null if the template render has not started. diff --git a/extensions/amp-form/0.1/test/test-amp-form.js b/extensions/amp-form/0.1/test/test-amp-form.js index c1fe4ee11fe6..a8b93b996c40 100644 --- a/extensions/amp-form/0.1/test/test-amp-form.js +++ b/extensions/amp-form/0.1/test/test-amp-form.js @@ -3165,5 +3165,172 @@ describes.repeated( }); } ); + + describes.fakeWin( + 'Initialize from URL', + { + amp: { + runtimeOn: true, + ampdoc: variant.ampdoc, + extensions: ['amp-form', 'amp-selector'], // amp-form is installed as service. + }, + win: { + location: + 'https://example.com/amps.html?textNoInit=1&hiddenAmpReplace=1&text=1&radio=1&checkbox=on&select=1&textarea=1', + top: { + location: 'https://example-top.com', + }, + }, + }, + env => { + let doc; + let createElement; + + beforeEach(() => { + doc = env.ampdoc.getRootNode(); + const ownerDoc = doc.ownerDocument || doc; + createElement = ownerDoc.createElement.bind(ownerDoc); + Services.resourcesForDoc(env.ampdoc); + }); + + function getAmpForm(form, canonical = 'https://example.com/amps.html') { + new AmpFormService(env.ampdoc); + Services.documentInfoForDoc(env.ampdoc).canonicalUrl = canonical; + env.ampdoc.getBody().appendChild(form); + return Promise.resolve(new AmpForm(form, 'amp-form-test-id')); + } + + function getAmpFormWithInitialization(initFromUrl) { + const form = createElement('form'); + form.setAttribute('method', 'GET'); + if (initFromUrl) { + form.setAttribute('data-initialize-from-url', ''); + } + + // Create inputs that do not support initialization from URL + const textNoInit = createElement('input'); + textNoInit.setAttribute('type', 'text'); + textNoInit.setAttribute('name', 'textNoInit'); + textNoInit.setAttribute('value', '0'); + form.appendChild(textNoInit); + + const hiddenAmpReplace = createElement('input'); + hiddenAmpReplace.setAttribute('type', 'hidden'); + hiddenAmpReplace.setAttribute('name', 'hiddenAmpReplace'); + hiddenAmpReplace.setAttribute('value', '0'); + hiddenAmpReplace.setAttribute('data-amp-replace', ''); + hiddenAmpReplace.setAttribute('data-allow-initialization', ''); + form.appendChild(hiddenAmpReplace); + + // Create inputs that support initialization from URL + const text = createElement('input'); + text.setAttribute('type', 'text'); + text.setAttribute('name', 'text'); + text.setAttribute('value', '0'); + text.setAttribute('data-allow-initialization', ''); + form.appendChild(text); + + const radio0 = createElement('input'); + radio0.setAttribute('type', 'radio'); + radio0.setAttribute('name', 'radio'); + radio0.setAttribute('checked', ''); + radio0.setAttribute('value', '0'); + radio0.setAttribute('data-allow-initialization', ''); + form.appendChild(radio0); + + const radio1 = createElement('input'); + radio1.setAttribute('type', 'radio'); + radio1.setAttribute('name', 'radio'); + radio1.setAttribute('value', '1'); + radio1.setAttribute('data-allow-initialization', ''); + form.appendChild(radio1); + + const checkbox = createElement('input'); + checkbox.setAttribute('type', 'checkbox'); + checkbox.setAttribute('name', 'checkbox'); + checkbox.setAttribute('data-allow-initialization', ''); + form.appendChild(checkbox); + + const select = createElement('select'); + select.setAttribute('name', 'select'); + select.setAttribute('data-allow-initialization', ''); + const option1 = createElement('option'); + option1.setAttribute('value', '0'); + option1.setAttribute('selected', ''); + const option2 = createElement('option'); + option2.setAttribute('value', '1'); + select.appendChild(option1); + select.appendChild(option2); + form.appendChild(select); + + const textarea = createElement('textarea'); + textarea.setAttribute('name', 'textarea'); + textarea.setAttribute('data-allow-initialization', ''); + textarea.innerHTML = '0'; + form.appendChild(textarea); + + return getAmpForm(form).then(ampForm => { + return { + ampForm, + fields: { + textNoInit, + hiddenAmpReplace, + text, + radio0, + radio1, + checkbox, + select, + textarea, + }, + }; + }); + } + + it('should not be dirty', () => { + return getAmpFormWithInitialization(true).then(response => { + const {ampForm} = response; + const form = ampForm.form_; + + expect(form).to.not.have.class(DIRTINESS_INDICATOR_CLASS); + }); + }); + + it('should selectively initialize fields', () => { + return getAmpFormWithInitialization(true).then(response => { + const {fields} = response; + + // URL: ?textNoInit=1&hiddenAmpReplace=1&text=1&radio=1&checkbox=on&select=1&textarea=1 + + // field value/checked should not change + expect(fields['textNoInit'].value).to.equal('0'); + expect(fields['hiddenAmpReplace'].value).to.equal('0'); + + // field value/checked should change + expect(fields['text'].value).to.equal('1'); + expect(fields['radio0'].checked).to.be.false; + expect(fields['radio1'].checked).to.be.true; + expect(fields['checkbox'].checked).to.be.true; + expect(fields['select'].value).to.equal('1'); + expect(fields['textarea'].value).to.equal('1'); + }); + }); + + it('should not initialize fields in unsupported form', () => { + return getAmpFormWithInitialization(false).then(response => { + const {fields} = response; + + // field value/checked should not change + expect(fields['textNoInit'].value).to.equal('0'); + expect(fields['hiddenAmpReplace'].value).to.equal('0'); + expect(fields['text'].value).to.equal('0'); + expect(fields['radio0'].checked).to.be.true; + expect(fields['radio1'].checked).to.be.false; + expect(fields['checkbox'].checked).to.be.false; + expect(fields['select'].value).to.equal('0'); + expect(fields['textarea'].value).to.equal('0'); + }); + }); + } + ); } ); diff --git a/extensions/amp-form/amp-form.md b/extensions/amp-form/amp-form.md index a179a054f9b5..31c1fd8545f0 100644 --- a/extensions/amp-form/amp-form.md +++ b/extensions/amp-form/amp-form.md @@ -129,6 +129,29 @@ The value for `action-xhr` can be the same or a different endpoint than `action` To learn about redirecting the user after successfully submitting the form, see the [Redirecting after a submission](#redirecting-after-a-submission) section below. +##### data-initialize-from-url + +Initializes supported form fields from the URL. When this attribute is present, ``, `