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..644635783516 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,91 @@ 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 maybeFillField = (field, name) => { + // 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[name] || ''; + const type = field.getAttribute('type') || 'text'; + const tag = field.tagName; + + if (tag === 'INPUT') { + if (valueInputTypes.includes(type.toLocaleLowerCase())) { + if (field.value !== value) { + field.value = value; + } + } else if (checkedInputTypes.includes(type)) { + const checked = field.value === value; + if (field.checked !== checked) { + field.checked = checked; + } + } + } else if (valueTags.includes(tag)) { + if (field.value !== value) { + field.value = value; + } + } + }; + + const queryParams = parseQueryString(this.win_.location.search); + Object.keys(queryParams).forEach(key => { + // Typecast since Closure is missing NodeList union type in HTMLFormElement.elements. + const formControls = /** @type {(!Element|!NodeList)} */ (this.form_ + .elements[key]); + if (!formControls) { + return; + } + + if (formControls.nodeType === Node.ELEMENT_NODE) { + const field = dev().assertElement(formControls); + maybeFillField(field, key); + } else if (formControls.length) { + const fields = /** @type {!NodeList} */ (formControls); + iterateCursor(fields, field => maybeFillField(field, key)); + } + }); + } + /** * 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..c3c4438e9354 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,163 @@ describes.repeated( }); } ); + + describes.fakeWin( + 'Initialize from URL', + { + amp: { + ampdoc: variant.ampdoc, + extensions: ['amp-form'], + }, + 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); + }); + + 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); + + env.ampdoc.getBody().appendChild(form); + const ampForm = new AmpForm(form, 'amp-form-test-id'); + return Promise.resolve({ + 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..b80586102c83 100644 --- a/extensions/amp-form/amp-form.md +++ b/extensions/amp-form/amp-form.md @@ -129,6 +129,30 @@ 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 form fields from the window URL's search string, where the query parameter name matches the field's name. When this attribute is present, ``, `