Skip to content

Commit

Permalink
Implement inline editing for "Change Email" and "Change Password" for…
Browse files Browse the repository at this point in the history
…ms. (#3911)

* Implement support for forms which allow editing multiple fields at once

This commit introduces a couple of new features to FormController that
are needed by the 'Change Email' and 'Change Password' forms:

 1. Support for multiple fields being in the editing state at the same time.

    This is done by making `state.editingFields` a list and introducing
    `state.focusedField` which is the most recent field within the
    editing set that a user focused, indicated by a thick border.

    Currently there are only two cases we need to support for the edit
    set - forms which edit one field at a time and forms which edit all
    fields at once.

 2. Support for hidden fields which are shown only when the user starts
    editing the form.

    These are indicated by a `data-hide-until-active` attribute on the
    field's container.

* Enable inline editing for Change Email and Change Password forms

 * Add use_inline_editing flag to Change Email and Change Password forms

 * Mark the 'Confirm password', 'New password' and 'Confirm new password'
   fields as being hidden until the user starts interacting with the
   form

 * Change label for saving changes to 'Save' as per the mock

* Hide hidden-by-default fields on load

Avoid FOUC for fields which are hidden until the form is being edited by
applying the `is-hidden-when-loading` class.

Once FormController is initialized, this class will be removed and
replaced with the `is-hidden` class.

* Adjust labels depending on active/inactive state of Change Password form

 * Label the field 'Password' rather than 'Current password'
   when the form is inactive, since this makes more sense when the 'New
   password' field is not visible

 * Add a placeholder to password fields when the form is
   inactive so that the field does not appear empty if the UA has not
   autofilled the password

* Extract field update logic into a separate method

Put all the field DOM update logic together in one method

* Add additional API documentation to FormController
  • Loading branch information
robertknight authored and sheetaluk committed Oct 26, 2016
1 parent 12c72fc commit 554857d
Show file tree
Hide file tree
Showing 8 changed files with 317 additions and 50 deletions.
12 changes: 8 additions & 4 deletions h/accounts/schemas.py
Expand Up @@ -295,7 +295,8 @@ def validator(self, node, value):
class EmailChangeSchema(CSRFSchema):
email = email_node(title=_('Email address'))
# No validators: all validation is done on the email field
password = password_node(title=_('Confirm password'))
password = password_node(title=_('Confirm password'),
hide_until_form_active=True)

def validator(self, node, value):
super(EmailChangeSchema, self).validator(node, value)
Expand All @@ -311,14 +312,17 @@ def validator(self, node, value):


class PasswordChangeSchema(CSRFSchema):
password = password_node(title=_('Current password'))
new_password = password_node(title=_('New password'))
password = password_node(title=_('Current password'),
inactive_label=_('Password'))
new_password = password_node(title=_('New password'),
hide_until_form_active=True)
# No validators: all validation is done on the new_password field and we
# merely assert that the confirmation field is the same.
new_password_confirm = colander.SchemaNode(
colander.String(),
title=_('Confirm new password'),
widget=deform.widget.PasswordWidget())
widget=deform.widget.PasswordWidget(),
hide_until_form_active=True)

def validator(self, node, value):
super(PasswordChangeSchema, self).validator(node, value)
Expand Down
146 changes: 121 additions & 25 deletions h/static/scripts/controllers/form-controller.js
Expand Up @@ -11,7 +11,34 @@ function shouldAutosubmit(type) {
}

/**
* A controller which adds inline editing functionality to forms
* Return true if a form field should be hidden until the user starts editing
* the form.
*
* @param {Element} el - The container for an individual form field, which may
* have a "data-hide-until-active" attribute.
*/
function isHiddenField(el) {
return el.dataset.hideUntilActive;
}

/**
* @typedef {Object} Field
* @property {Element} container - The container element for an input field
* @property {HTMLInputElement} input - The <input> element for an input field
* @property {HTMLLabelElement} label - The <label> element for a field
*/

/**
* A controller which adds inline editing functionality to forms.
*
* When forms have inline editing enabled, individual fields can be edited and
* changes can be saved without a full page reload.
*
* Instead when the user focuses a field, Save/Cancel buttons are shown beneath
* the field and everything else on the page is dimmed. When the user clicks 'Save'
* the form is submitted to the server via a `fetch()` request and the HTML of
* the form is updated with the result, which may be a successfully updated form
* or a re-rendered version of the form with validation errors indicated.
*/
class FormController extends Controller {
constructor(element, options) {
Expand All @@ -27,7 +54,11 @@ class FormController extends Controller {
this._fields = Array.from(element.querySelectorAll('.js-form-input'))
.map(el => {
var parts = findRefs(el);
return {container: el, input: parts.formInput};
return {
container: el,
input: parts.formInput,
label: parts.label,
};
});

this.on('focus', event => {
Expand All @@ -36,7 +67,10 @@ class FormController extends Controller {
return;
}

this.setState({editingField: field});
this.setState({
editingFields: this._editSet(field),
focusedField: field,
});
}, true /* capture - focus does not bubble */);

this.on('change', event => {
Expand Down Expand Up @@ -78,9 +112,10 @@ class FormController extends Controller {
// True if the user has made changes to the field they are currently
// editing
dirty: false,
// The group of elements (container, input) for the form field currently
// being edited
editingField: null,
// The set of fields currently being edited
editingFields: [],
// The field within the `editingFields` set that was last focused
focusedField: null,
// Markup for the original form. Used to revert the form to its original
// state when the user cancels editing
originalForm: this.element.outerHTML,
Expand All @@ -92,33 +127,71 @@ class FormController extends Controller {
}

update(state, prevState) {
if (prevState.editingField &&
state.editingField !== prevState.editingField) {
setElementState(prevState.editingField.container, {editing: false});
}

if (state.editingField) {
// Display Save/Cancel buttons below the field that we are currently
// editing
state.editingField.container.parentElement.insertBefore(
// In forms that support editing a single field at a time, show the
// Save/Cancel buttons below the field that we are currently editing.
//
// In the current forms that support editing multiple fields at once,
// we always display the Save/Cancel buttons in their default position
if (state.editingFields.length === 1) {
state.editingFields[0].container.parentElement.insertBefore(
this.refs.formActions,
state.editingField.container.nextSibling
state.editingFields[0].container.nextSibling
);
setElementState(state.editingField.container, {editing: true});
}

if (state.editingFields.length > 0 &&
state.editingFields !== prevState.editingFields) {
this._trapFocus();
}

var isEditing = !!state.editingField;
var isEditing = state.editingFields.length > 0;
setElementState(this.element, {editing: isEditing});
setElementState(this.refs.formActions, {
hidden: !isEditing || shouldAutosubmit(state.editingField.input.type),
hidden: !isEditing || shouldAutosubmit(state.editingFields[0].input.type),
saving: state.saving,
});

setElementState(this.refs.formSubmitError, {
visible: state.submitError.length > 0,
});
this.refs.formSubmitErrorMessage.textContent = state.submitError;

this._updateFields(state);
}

/**
* Update the appearance of individual form fields to match the current state
* of the form.
*
* @param {Object} state - The internal state of the form
*/
_updateFields(state) {
this._fields.forEach(field => {
setElementState(field.container, {
editing: state.editingFields.includes(field),
focused: field === state.focusedField,
hidden: isHiddenField(field.container) &&
!state.editingFields.includes(field),
});

// Update labels
var activeLabel = field.container.dataset.activeLabel;
var inactiveLabel = field.container.dataset.inactiveLabel;
var isEditing = state.editingFields.includes(field);

if (activeLabel && inactiveLabel) {
field.label.textContent = isEditing ? activeLabel : inactiveLabel;
}

// Update placeholder
//
// The UA may or may not autofill password fields.
// Set a dummy password as a placeholder when the field is not being edited
// so that it appears non-empty if the UA doesn't autofill it.
if (field.input.type === 'password') {
field.input.setAttribute('placeholder', !isEditing ? '••••••••' : '');
}
});
}

beforeRemove() {
Expand All @@ -135,8 +208,8 @@ class FormController extends Controller {
var originalForm = this.state.originalForm;

var activeInputId;
if (this.state.editingField) {
activeInputId = this.state.editingField.input.id;
if (this.state.editingFields.length > 0) {
activeInputId = this.state.editingFields[0].input.id;
}

this.setState({saving: true});
Expand Down Expand Up @@ -182,31 +255,54 @@ class FormController extends Controller {
* depending upon the field which is currently focused.
*/
_focusGroup() {
if (!this.state.editingField) {
var fieldContainers = this.state.editingFields.map(field => field.container);
if (fieldContainers.length === 0) {
return null;
}

return [this.refs.formActions, this.state.editingField.container];
return [this.refs.formActions].concat(fieldContainers);
}

/**
* Trap focus within the set of form fields currently being edited.
*/
_trapFocus() {
this._releaseFocus = modalFocus.trap(this._focusGroup(), newFocusedElement => {
// Keep focus in the current field when it has unsaved changes,
// otherwise let the user focus another field in the form or move focus
// outside the form entirely.
if (this.state.dirty) {
return this.state.editingField.input;
return this.state.editingFields[0].input;
}

// If the user tabs out of the form, clear the editing state
if (!this.element.contains(newFocusedElement)) {
this.setState({editingField: null});
this.setState({editingFields: []});
}

return null;
});
}

/**
* Return the set of fields that should be displayed in the editing state
* when a given field is selected.
*
* @param {Field} - The field that was focused
* @return {Field[]} - Set of fields that should be active for editing
*/
_editSet(field) {
// Currently we have two types of form:
// 1. Forms which only edit one field at a time
// 2. Forms with hidden fields (eg. the Change Email, Change Password forms)
// which should enable editing all fields when any is focused
if (this._fields.some(field => isHiddenField(field.container))) {
return this._fields;
} else {
return [field];
}
}

/**
* Cancel editing for the currently active field and revert any unsaved
* changes.
Expand Down
4 changes: 4 additions & 0 deletions h/static/scripts/polyfills.js
Expand Up @@ -5,9 +5,13 @@ require('core-js/es6/promise');
require('core-js/fn/array/find');
require('core-js/fn/array/find-index');
require('core-js/fn/array/from');
require('core-js/fn/array/includes');
require('core-js/fn/object/assign');
require('core-js/fn/string/starts-with');

// Element.prototype.dataset, required by IE 10
require('element-dataset')();

// URL constructor, required by IE 10/11,
// early versions of Microsoft Edge.
try {
Expand Down

0 comments on commit 554857d

Please sign in to comment.