Skip to content

Commit

Permalink
✨ FEATURE: AMP form dirtiness indicator class (#22640)
Browse files Browse the repository at this point in the history
* 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
xrav3nz authored and cvializ committed Jun 4, 2019
1 parent c23c179 commit ce9bfba
Show file tree
Hide file tree
Showing 2 changed files with 369 additions and 0 deletions.
186 changes: 186 additions & 0 deletions extensions/amp-form/0.1/form-dirtiness.js
@@ -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);
}
183 changes: 183 additions & 0 deletions extensions/amp-form/0.1/test/test-form-dirtiness.js
@@ -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);
});
});
});

0 comments on commit ce9bfba

Please sign in to comment.