Source: index.js

/**
 *
 * Get successful control from form and assemble into object
 * @see {@link http://www.w3.org/TR/html401/interact/forms.html#h-17.13.2}
 * @module FormSerialization
 */

// types which indicate a submit action and are not successful controls
// these will be ignored
const kRSubmitter = /^(?:submit|button|image|reset|file)$/i;

// node names which could be successful controls
const kRSuccessContrls = /^(?:input|select|textarea|keygen)/i;

// Matches bracket notation.
const brackets = /(\[[^[\]]*\])/g;

/**
 * @callback module:FormSerialization.Serializer
 * @param {PlainObject|string|*} result
 * @param {string} key
 * @param {string} value
 * @returns {PlainObject|string|*} New result
*/

/**
 * @typedef {PlainObject} module:FormSerialization.Options
 * @property {boolean} [hash] Configure the output type. If true, the
 *  output will be a JavaScript object.
 * @property {module:FormSerialization.Serializer} [serializer] Optional
 *   serializer function to override the default one. Otherwise, hash
 *   and URL-encoded string serializers are provided with this module,
 *   depending on the setting of `hash`.
 * @property {boolean} [disabled] If true serialize disabled fields.
 * @property {boolean} [empty] If true serialize empty fields
*/

/**
 * Serializes form fields.
 * @function module:FormSerialization.serialize
 * @param {HTMLFormElement} form MUST be an `HTMLFormElement`
 * @param {module:FormSerialization.Options} options is an optional argument
 *   to configure the serialization.
 * @returns {*|string|PlainObject} Default output with no options specified is
 *   a url encoded string
 */
export function serialize (form, options) {
  if (typeof options !== 'object') {
    options = {hash: Boolean(options)};
  } else if (options.hash === undefined) {
    options.hash = true;
  }

  let result = options.hash ? {} : '';
  const serializer = options.serializer ||
    (options.hash ? hashSerializer : strSerialize);

  const elements = form && form.elements ? [...form.elements] : [];

  // Object store each radio and set if it's empty or not
  const radioStore = Object.create(null);

  elements.forEach((element) => {
    // ignore disabled fields
    if ((!options.disabled && element.disabled) || !element.name) {
      return;
    }
    // ignore anything that is not considered a success field
    if (!kRSuccessContrls.test(element.nodeName) ||
      kRSubmitter.test(element.type)) {
      return;
    }

    const {name: key, type, name, checked} = element;
    let {value} = element;

    // We can't just use element.value for checkboxes cause some
    //   browsers lie to us; they say "on" for value when the
    //   box isn't checked
    if ((type === 'checkbox' || type === 'radio') && !checked) {
      value = undefined;
    }

    // If we want empty elements
    if (options.empty) {
      // for checkbox
      if (type === 'checkbox' && !checked) {
        value = '';
      }

      // for radio
      if (type === 'radio') {
        if (!radioStore[name] && !checked) {
          radioStore[name] = false;
        } else if (checked) {
          radioStore[name] = true;
        }
        if (value === undefined) {
          return;
        }
      }
    } else if (!value) {
      // value-less fields are ignored unless options.empty is true
      return;
    }

    // multi select boxes
    if (type === 'select-multiple') {
      value = [];

      let isSelectedOptions = false;
      [...element.options].forEach((option) => {
        const allowedEmpty = options.empty && !option.value;
        const hasValue = (option.value || allowedEmpty);
        if (option.selected && hasValue) {
          isSelectedOptions = true;

          // If using a hash serializer be sure to add the
          // correct notation for an array in the multi-select
          // context. Here the name attribute on the select element
          // might be missing the trailing bracket pair. Both names
          // "foo" and "foo[]" should be arrays.
          if (options.hash && key.slice(key.length - 2) !== '[]') {
            result = serializer(result, key + '[]', option.value);
          } else {
            result = serializer(result, key, option.value);
          }
        }
      });

      // Serialize if no selected options and options.empty is true
      if (!isSelectedOptions && options.empty) {
        result = serializer(result, key, '');
      }

      return;
    }

    result = serializer(result, key, value);
  });

  // Check for all empty radio buttons and serialize them with key=""
  if (options.empty) {
    Object.entries(radioStore).forEach(([key, value]) => {
      if (!value) {
        result = serializer(result, key, '');
      }
    });
  }

  return result;
}

/**
 *
 * @param {string} string
 * @returns {string[]}
 */
function parseKeys (string) {
  const keys = [];
  const prefix = /^([^[\]]*)/;
  const children = new RegExp(brackets);
  let match = prefix.exec(string);

  if (match[1]) {
    keys.push(match[1]);
  }

  while ((match = children.exec(string)) !== null) {
    keys.push(match[1]);
  }

  return keys;
}

/**
 *
 * @param {PlainObject|Array} result
 * @param {string[]} keys
 * @param {string} value
 * @returns {string|PlainObject|Array}
 */
function hashAssign (result, keys, value) {
  if (keys.length === 0) {
    return value;
  }

  const key = keys.shift();
  const between = key.match(/^\[(.+?)\]$/);

  if (key === '[]') {
    result = result || [];

    if (Array.isArray(result)) {
      result.push(hashAssign(null, keys, value));
    } else {
      // This might be the result of bad name attributes like "[][foo]",
      // in this case the original `result` object will already be
      // assigned to an object literal. Rather than coerce the object to
      // an array, or cause an exception the attribute "_values" is
      // assigned as an array.
      result._values = result._values || [];
      result._values.push(hashAssign(null, keys, value));
    }
    return result;
  }

  // Key is an attribute name and can be assigned directly.
  if (!between) {
    result[key] = hashAssign(result[key], keys, value);
  } else {
    const string = between[1];
    // +var converts the variable into a number
    // better than parseInt because it doesn't truncate away trailing
    // letters and actually fails if whole thing is not a number
    const index = Number(string);

    // If the characters between the brackets is not a number it is an
    // attribute name and can be assigned directly.
    if (isNaN(index)) {
      result = result || {};
      result[string] = hashAssign(result[string], keys, value);
    } else {
      result = result || [];
      result[index] = hashAssign(result[index], keys, value);
    }
  }
  return result;
}

/**
 * Object/hash encoding serializer.
 * @param {PlainObject} result
 * @param {string} key
 * @param {string} value
 * @returns {PlainObject}
 */
function hashSerializer (result, key, value) {
  const hasBrackets = key.match(brackets);

  // Has brackets? Use the recursive assignment function to walk the keys,
  // construct any missing objects in the result tree and make the assignment
  // at the end of the chain.
  if (hasBrackets) {
    const keys = parseKeys(key);
    hashAssign(result, keys, value);
  } else {
    // Non bracket notation can make assignments directly.
    const existing = result[key];

    // If the value has been assigned already (for instance when a radio and
    // a checkbox have the same name attribute) convert the previous value
    // into an array before pushing into it.
    //
    // NOTE: If this requirement were removed all hash creation and
    // assignment could go through `hashAssign`.
    if (existing) {
      if (!Array.isArray(existing)) {
        result[key] = [existing];
      }

      result[key].push(value);
    } else {
      result[key] = value;
    }
  }
  return result;
}

/**
 * URL form encoding serializer.
 * @param {string} result
 * @param {string} key
 * @param {string} value
 * @returns {string} New result
 */
function strSerialize (result, key, value) {
  // encode newlines as \r\n cause the html spec says so
  value = value.replace(/(\r)?\n/g, '\r\n');
  value = encodeURIComponent(value);

  // spaces should be '+' rather than '%20'.
  value = value.replace(/%20/g, '+');
  return result + (result ? '&' : '') + encodeURIComponent(key) + '=' + value;
}

/**
 * @function module:FormSerialization.deserialize
 * @param {HTMLFormElement} form
 * @param {PlainObject} hash
 * @returns {void}
 */
export function deserialize (form, hash) {
  // input(text|radio|checkbox)|select(multiple)|textarea|keygen
  Object.entries(hash).forEach(([name, value]) => {
    let control = form[name];
    let hasBrackets = false;
    if (!control) { // Try again for jsdom
      control = form.querySelector(`[name="${name}"]`);
      if (!control) {
        // We want this for `RadioNodeList` so setting value
        //  auto-disables other boxes
        control = form[name + '[]'];
        if (!control || typeof control !== 'object' || !('length' in control)) {
          // The latter query would only get a single
          //  element, so if not a `RadioNodeList`, we get
          //  all values here
          control = form.querySelectorAll(`[name="${name}[]"]`);
          if (!control) {
            throw new Error(`Name not found ${name}`);
          }
        }
        hasBrackets = true;
      }
    }
    const {type} = control;
    if (type === 'checkbox') {
      control.checked = value !== '';
    }
    if (type === 'radio' || (control[0] && control[0].type === 'radio')) {
      [...form.querySelectorAll(
        `[name="${name + (hasBrackets ? '[]' : '')}"]`
      )].forEach((radio) => {
        radio.checked = value === radio.value;
      });
    }
    if (control[0] && control[0].type === 'select-multiple') {
      [...control[0].options].forEach((o) => {
        if (value.includes(o.value)) {
          o.selected = true;
        }
      });
      return;
    }
    if (Array.isArray(value)) {
      if (type === 'select-multiple') {
        [...control.options].forEach((o) => {
          if (value.includes(o.value)) {
            o.selected = true;
          }
        });
        return;
      }
      value.forEach((v, i) => {
        const c = control[i];
        if (c.type === 'checkbox') {
          const isMatch = c.value === v || v === 'on';
          c.checked = isMatch;
          return;
        }
        if (c.type === 'select-multiple') {
          [...c.options].forEach((o) => {
            if (v === o.value) {
              o.selected = true;
            }
          });
          return;
        }
        c.value = v;
      });
    } else {
      control.value = value;
    }
  });
}