Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ FEATURE: AMP form dirtiness indicator class (#22640)
* FEATURE: AMP form dirtiness indicator class This currently supports detecting the dirtiness of text-typed `<input>` and `<textarea>`. This does not take the submitted value into account yet. * remove incorrect `@const` annotation * do not hook this up yet * remove by keeping the property instead * rename 'listeners' to 'handlers' * move `updateDirtinessClass_` up * use `utils/object#map` instead of `{}` It creates a new prototypeless object, which decreases the cost of property lookups under the hood. * refactoring for simplicity and readability * Added description for non-trivial methods
- Loading branch information
Showing
2 changed files
with
369 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,186 @@ | ||
/** | ||
* Copyright 2019 The AMP HTML Authors. All Rights Reserved. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS-IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
import {dev} from '../../../src/log'; | ||
import {isDisabled, isFieldDefault, isFieldEmpty} from '../../../src/form'; | ||
import {map} from '../../../src/utils/object'; | ||
|
||
export const DIRTINESS_INDICATOR_CLASS = 'amp-form-dirty'; | ||
|
||
/** @private {!Object<string, boolean>} */ | ||
const SUPPORTED_TYPES = { | ||
'text': true, | ||
'textarea': true, | ||
}; | ||
|
||
export class FormDirtiness { | ||
/** | ||
* @param {!HTMLFormElement} form | ||
*/ | ||
constructor(form) { | ||
/** @private @const {!HTMLFormElement} */ | ||
this.form_ = form; | ||
|
||
/** @private {number} */ | ||
this.dirtyFieldCount_ = 0; | ||
|
||
/** @private {!Object<string, boolean>} */ | ||
this.isFieldNameDirty_ = map(); | ||
|
||
/** @private {boolean} */ | ||
this.isSubmitting_ = false; | ||
|
||
this.installEventHandlers_(); | ||
} | ||
|
||
/** | ||
* Processes dirtiness state when a form is being submitted. This puts the | ||
* form in a "submitting" state, and temporarily clears the dirtiness state. | ||
*/ | ||
onSubmitting() { | ||
this.isSubmitting_ = true; | ||
this.updateDirtinessClass_(); | ||
} | ||
|
||
/** | ||
* Processes dirtiness state when the form submission fails. This clears the | ||
* "submitting" state and reverts the form's dirtiness state. | ||
*/ | ||
onSubmitError() { | ||
this.isSubmitting_ = false; | ||
this.updateDirtinessClass_(); | ||
} | ||
|
||
/** | ||
* Processes dirtiness state when the form submission succeeds. This clears | ||
* the "submitting" state and the form's overall dirtiness. | ||
*/ | ||
onSubmitSuccess() { | ||
this.isSubmitting_ = false; | ||
this.clearDirtyFields_(); | ||
this.updateDirtinessClass_(); | ||
} | ||
|
||
/** | ||
* Adds the `amp-form-dirty` class when there are dirty fields and the form | ||
* is not being submitted, otherwise removes the class. | ||
* @private | ||
*/ | ||
updateDirtinessClass_() { | ||
const isDirty = this.dirtyFieldCount_ > 0 && !this.isSubmitting_; | ||
this.form_.classList.toggle(DIRTINESS_INDICATOR_CLASS, isDirty); | ||
} | ||
|
||
/** | ||
* @private | ||
*/ | ||
installEventHandlers_() { | ||
this.form_.addEventListener('input', this.onInput_.bind(this)); | ||
this.form_.addEventListener('reset', this.onReset_.bind(this)); | ||
} | ||
|
||
/** | ||
* Listens to form field value changes, determines the field's dirtiness, and | ||
* updates the form's overall dirtiness. | ||
* @param {!Event} event | ||
* @private | ||
*/ | ||
onInput_(event) { | ||
const field = dev().assertElement(event.target); | ||
this.checkDirtinessAfterUserInteraction_(field); | ||
this.updateDirtinessClass_(); | ||
} | ||
|
||
/** | ||
* Listens to the form reset event, and clears the overall dirtiness. | ||
* @param {!Event} unusedEvent | ||
* @private | ||
*/ | ||
onReset_(unusedEvent) { | ||
this.clearDirtyFields_(); | ||
this.updateDirtinessClass_(); | ||
} | ||
|
||
/** | ||
* Determine the given field's dirtiness. | ||
* @param {!Element} field | ||
* @private | ||
*/ | ||
checkDirtinessAfterUserInteraction_(field) { | ||
if (shouldSkipDirtinessCheck(field)) { | ||
return; | ||
} | ||
|
||
if (isFieldEmpty(field) || isFieldDefault(field)) { | ||
this.removeDirtyField_(field.name); | ||
} else { | ||
this.addDirtyField_(field.name); | ||
} | ||
} | ||
|
||
/** | ||
* Mark the field as dirty and increase the overall dirty field count, if the | ||
* field is previously clean. | ||
* @param {string} fieldName | ||
* @private | ||
*/ | ||
addDirtyField_(fieldName) { | ||
if (!this.isFieldNameDirty_[fieldName]) { | ||
this.isFieldNameDirty_[fieldName] = true; | ||
++this.dirtyFieldCount_; | ||
} | ||
} | ||
|
||
/** | ||
* Mark the field as clean and decrease the overall dirty field count, if the | ||
* field is previously dirty. | ||
* @param {string} fieldName | ||
* @private | ||
*/ | ||
removeDirtyField_(fieldName) { | ||
if (this.isFieldNameDirty_[fieldName]) { | ||
this.isFieldNameDirty_[fieldName] = false; | ||
--this.dirtyFieldCount_; | ||
} | ||
} | ||
|
||
/** | ||
* Clears the dirty field name map and counter. | ||
* @private | ||
*/ | ||
clearDirtyFields_() { | ||
this.isFieldNameDirty_ = map(); | ||
this.dirtyFieldCount_ = 0; | ||
} | ||
} | ||
|
||
/** | ||
* Returns true if the form should be subject to dirtiness check. Unsupported | ||
* elements, disabled elements, hidden elements, or elements without the `name` | ||
* attribute are skipped. | ||
* @param {!Element} field | ||
* @return {boolean} | ||
*/ | ||
function shouldSkipDirtinessCheck(field) { | ||
const {type, name, hidden} = field; | ||
|
||
// TODO: add support for radio buttons, checkboxes, and dropdown menus | ||
if (!SUPPORTED_TYPES[type]) { | ||
return true; | ||
} | ||
|
||
return !name || hidden || isDisabled(field); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,183 @@ | ||
/** | ||
* Copyright 2019 The AMP HTML Authors. All Rights Reserved. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS-IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
import {DIRTINESS_INDICATOR_CLASS, FormDirtiness} from '../form-dirtiness'; | ||
|
||
function getForm(doc) { | ||
const form = doc.createElement('form'); | ||
form.setAttribute('method', 'POST'); | ||
doc.body.appendChild(form); | ||
|
||
return form; | ||
} | ||
|
||
function changeInput(element, value) { | ||
element.value = value; | ||
const event = new Event('input', {bubbles: true}); | ||
element.dispatchEvent(event); | ||
} | ||
|
||
describes.realWin('form-dirtiness', {}, env => { | ||
let doc, form, dirtinessHandler; | ||
|
||
beforeEach(() => { | ||
doc = env.win.document; | ||
form = getForm(doc); | ||
dirtinessHandler = new FormDirtiness(form); | ||
}); | ||
|
||
describe('ignored elements', () => { | ||
it('does not add dirtiness class if a nameless element changes', () => { | ||
const nameless = doc.createElement('input'); | ||
form.appendChild(nameless); | ||
|
||
changeInput(nameless, 'changed'); | ||
|
||
expect(form).to.not.have.class(DIRTINESS_INDICATOR_CLASS); | ||
}); | ||
|
||
it('does not add dirtiness class if a hidden element changes', () => { | ||
const hidden = doc.createElement('input'); | ||
hidden.name = 'name'; | ||
hidden.hidden = true; | ||
form.appendChild(hidden); | ||
|
||
changeInput(hidden, 'changed'); | ||
|
||
expect(form).to.not.have.class(DIRTINESS_INDICATOR_CLASS); | ||
}); | ||
|
||
it('does not add dirtiness class if a disabled element changes', () => { | ||
const disabled = doc.createElement('input'); | ||
disabled.name = 'name'; | ||
disabled.disabled = true; | ||
form.appendChild(disabled); | ||
|
||
changeInput(disabled, 'changed'); | ||
expect(form).to.not.have.class(DIRTINESS_INDICATOR_CLASS); | ||
}); | ||
}); | ||
|
||
describe('text field changes', () => { | ||
let textField; | ||
|
||
beforeEach(() => { | ||
// Element is inserted as HTML so that the `defaultValue` property is | ||
// generated correctly, since it returns "the default value as | ||
// **originally specified in the HTML** that created this object." | ||
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement#Properties | ||
const html = '<input name="name" type="text" value="default">'; | ||
form.insertAdjacentHTML('afterbegin', html); | ||
textField = form.querySelector('input'); | ||
}); | ||
|
||
it('removes dirtiness class when text field is in default state', () => { | ||
changeInput(textField, textField.defaultValue); | ||
expect(form).to.not.have.class(DIRTINESS_INDICATOR_CLASS); | ||
}); | ||
|
||
it('removes dirtiness class when text field is empty', () => { | ||
changeInput(textField, ''); | ||
expect(form).to.not.have.class(DIRTINESS_INDICATOR_CLASS); | ||
}); | ||
|
||
it('adds dirtiness class when text field is changed', () => { | ||
changeInput(textField, 'changed'); | ||
expect(form).to.have.class(DIRTINESS_INDICATOR_CLASS); | ||
}); | ||
}); | ||
|
||
describe('textarea changes', () => { | ||
let textarea; | ||
|
||
beforeEach(() => { | ||
const html = '<textarea name="comment">default</textarea>'; | ||
form.insertAdjacentHTML('afterbegin', html); | ||
textarea = form.querySelector('textarea'); | ||
}); | ||
|
||
it('removes dirtiness class when textarea is in default state', () => { | ||
changeInput(textarea, textarea.defaultValue); | ||
expect(form).to.not.have.class(DIRTINESS_INDICATOR_CLASS); | ||
}); | ||
|
||
it('removes dirtiness class when textarea is empty', () => { | ||
changeInput(textarea, ''); | ||
expect(form).to.not.have.class(DIRTINESS_INDICATOR_CLASS); | ||
}); | ||
|
||
it('adds dirtiness class when textarea is changed', () => { | ||
changeInput(textarea, 'changed'); | ||
expect(form).to.have.class(DIRTINESS_INDICATOR_CLASS); | ||
}); | ||
}); | ||
|
||
describe('#onSubmitting', () => { | ||
it('clears the dirtiness class', () => { | ||
const input = doc.createElement('input'); | ||
input.type = 'text'; | ||
input.name = 'text'; | ||
form.appendChild(input); | ||
|
||
changeInput(input, 'changed'); | ||
dirtinessHandler.onSubmitting(); | ||
|
||
expect(form).to.not.have.class(DIRTINESS_INDICATOR_CLASS); | ||
}); | ||
}); | ||
|
||
describe('#onSubmitError', () => { | ||
let input; | ||
|
||
beforeEach(() => { | ||
input = doc.createElement('input'); | ||
input.type = 'text'; | ||
input.name = 'text'; | ||
form.appendChild(input); | ||
}); | ||
|
||
it('adds the dirtiness class if the form was dirty before submitting', () => { | ||
changeInput(input, 'changed'); | ||
dirtinessHandler.onSubmitting(); | ||
dirtinessHandler.onSubmitError(); | ||
|
||
expect(form).to.have.class(DIRTINESS_INDICATOR_CLASS); | ||
}); | ||
|
||
it('does not add the dirtiness class if the form was clean before submitting', () => { | ||
changeInput(input, ''); | ||
dirtinessHandler.onSubmitting(); | ||
dirtinessHandler.onSubmitError(); | ||
|
||
expect(form).to.have.not.class(DIRTINESS_INDICATOR_CLASS); | ||
}); | ||
}); | ||
|
||
describe('#onSubmitSuccess', () => { | ||
it('clears the dirtiness class', () => { | ||
const input = doc.createElement('input'); | ||
input.type = 'text'; | ||
input.name = 'text'; | ||
form.appendChild(input); | ||
|
||
changeInput(input, 'changed'); | ||
dirtinessHandler.onSubmitting(); | ||
dirtinessHandler.onSubmitSuccess(); | ||
|
||
expect(form).to.not.have.class(DIRTINESS_INDICATOR_CLASS); | ||
}); | ||
}); | ||
}); |