Form.js

// Import modules
const mustache = require('mustache');

/**
 * @class Form
 * @hideconstructor
 */
const Form = (function () {
    /**
     * Constructor.
     *
     * @memberof Form
     * @method new Form(config)
     *
     * @public
     * @param {object} config - Form configuration. See `Form.prototype.config`.
     * @returns {Form}
     */
    function Form(config) {
        // Create new object to prevent mutation of defaults and parameter
        this.config = Object.assign({}, Form.prototype.config, config || {});
    }

    /**
     * Configuration defaults for form.
     *
     * @memberof Form
     * @instance
     *
     * @public
     * @type {object}
     * @property {string} action - URL for form submission.
     * @property {object} attributes - Key-value pairs for attributes in <form>
     *     element. Use empty string if the attribute has no value, null to
     *     unset the attribute. E.g.:
     *     `{ enctype: 'multipart/form-data', novalidate: '', required: null }`
     *     will produce `<form enctype="multipart/form-data" novalidate>`.
     * @property {string[]} classes - List of CSS classes for `<form>` element.
     * @property {string} formTemplate - Mustache.js template for rendering
     *     HTML for <form> element.
     * @property {string} errorsTemplate - Mustache.js template for rendering
     *     array of error messages for each field. It may be overridden in field
     *     config. For simplicity, the classes are embedded in the template
     *     instead of having an `errorClasses` key.
     * @property {object} inputTemplates - Key-value pairs where key is input
     *     type (e.g. text, select) and value is Mustache.js template for
     *     rendering HTML for input element. The appropriate input template is
     *     used when rendering the input for a field and may be overridden in
     *     field config. For dropdowns/checkboxes/radio buttons, the following
     *     template variables are available: `hasSelectedOption` (indicates if
     *     an option is selected), `emptyOptionText` (the text for the option
     *     with empty value), `selectedOptionText` (the text for the selected
     *     option if any) and `options` (an array of objects, each having the
     *     keys `optionValue`, `optionText` and `optionSelected`).
     * @property {string} method - HTTP method for form submission.
     * @property {string} name - Form name.
     * @property {string} requiredText - Text to return for error message if
     *     value is empty for required fields. Can be overridden in field
     *     config.
     */
    Form.prototype.config = { // in alphabetical order, properties before functions
        action: '',
        attributes: {},
        classes: [],
        errorsTemplate: '<div class="errors">{{#errors}}<ul>'
            + '<li>{{.}}</li>'
            + '</ul>{{/errors}}</div>',
        formTemplate: '<form name="{{name}}" method="{{method}}" action="{{{action}}}" '
            + '{{{attributes}}} class="{{{classes}}}">{{{formHtml}}}</form>',
        inputTemplates: {
            'input': '<input name="{{name}}" type="{{type}}" value="{{value}}" '
                + '{{{attributes}}} class="{{{classes}}}" />',

            'checkbox': '{{#options}}'
                + '<input name="{{name}}" type="{{type}}" value="{{optionValue}}" '
                + '{{{attributes}}} {{#optionSelected}}checked{{/optionSelected}} '
                + 'class="{{{classes}}}" />{{optionText}}'
                + '{{/options}}',

            'html': '{{{value}}}',

            'radio': '{{#options}}'
                + '<input name="{{name}}" type="{{type}}" value="{{optionValue}}" '
                + '{{{attributes}}} {{#optionSelected}}checked{{/optionSelected}} '
                + 'class="{{{classes}}}" />{{optionText}}'
                + '{{/options}}',

            'select': '<select name="{{name}}" {{{attributes}}} class="{{{classes}}}"> '
                + '<option value="" {{^hasSelectedOption}}selected{{/hasSelectedOption}}>'
                + '{{emptyOptionText}}</option>'
                + '{{#options}}'
                + '  <option value="{{optionValue}}"'
                + '    {{#optionSelected}}selected{{/optionSelected}}>{{optionText}}</option>'
                + '{{/options}}'
                + '</select>',

            'textarea': '<textarea name="{{name}}" {{{attributes}}} '
                + 'class="{{{classes}}}">{{{value}}}</textarea>',

        },
        method: 'POST',
        name: '',
        requiredText: 'This field is required.',
    };

    /**
     * Groups of fields, with each group rendered as a `<fieldset>` element.
     *
     * Key is fieldset name, value is `Fieldset` object.
     * Uses `Map` instead of `Object/Array` so that rendering order can be guaranteed by insertion
     * order and specific fieldsets can be referenced easily by name instead of looping each time,
     * e.g. `fieldsets.get('myfieldset')`.
     *
     * Name is made optional in the `Fieldset` object to reduce repetitions, e.g.
     * `fieldsets.set('myset', new Fieldset({}))` instead of
     * `fieldsets.set('myset', new Fieldset({ name:'myset' }))`. It is also becos
     * the name of the fieldset is dependent on the form that it is used in.
     *
     * If at least one fieldset is specified, any fields not belonging to a fieldset will
     * not be rendered.
     *
     * @memberof Form
     * @instance
     *
     * @public
     * @type {Map.<string, Fieldset>}
     */
    Form.prototype.fieldsets = new Map();

    /**
     * Fields.
     *
     * Key is fieldset name, value is `Field` object.
     * Uses `Map` instead of `Object/Array` so that rendering order can be guaranteed by insertion
     * order and specific fields can be referenced easily by name instead of looping each time,
     * e.g. `fields.get('myfield')`.
     *
     * Name is made optional in the `Field` object to reduce repetitions, e.g.
     * `field.set('myfield', new Field({}))` instead of
     * `field.set('myfield', new Field({ name:'myfield' }))`. It is also becos
     * the name of the field is dependent on the form that it is used in.
     *
     * @memberof Form
     * @instance
     *
     * @public
     * @type {Map.<string, Fieldset>}
     */
    Form.prototype.fields = new Map();

    /**
     * Clear form data.
     *
     * Each field will be set to its default value as per `field.config.value`,
     * not an empty string, else buttons and html type fields will lose their
     * text.
     *
     * @memberof Form
     * @instance
     *
     * @public
     * @returns {void}
     */
    Form.prototype.clearData = function () {
        this.fields.forEach(function (field, fieldName) {
            field.value = field.config.value;
        });
    };

    /**
     * Get form data.
     *
     * @memberof Form
     * @instance
     *
     * @public
     * @returns {object} Key-value pairs where key is field name and value is
     *     field value. Type of value may be string, number or array (multiple values such as for
     *     multi-select checkbox group).
     */
    Form.prototype.getData = function () {
        // Not saving in private instance variable so that data is always the most updated copy
        let result = {};

        this.fields.forEach(function (field, fieldName) {
            result[fieldName] = field.value;
        });

        return result;
    };

    /**
     * Renders HTML for entire form.
     *
     * @memberof Form
     * @instance
     *
     * @public
     * @param {(null|object)} [templateVariables=null] - Optional key-value pairs
     *     that can be used to add on to or override current template variables.
     * @returns {string}
     */
    Form.prototype.render = function (templateVariables = null) {
        let self = this; // for use inside callbacks

        let attributes = [];
        for (const [key, value] of Object.entries(this.config.attributes)) {
            if (value !== null) {
                attributes.push(key + ('' === value ? '' : `="${value}"`));
            }
        }

        // Since all fields need to be rendered, might as well store the HTML
        // and index by the field names, for use with fieldsets
        let htmlByField = {};
        this.fields.forEach(function (field, fieldName) {
            // Note that the values from the field config will
            // override the values for the same keys in the fallback
            // template variable.
            htmlByField[fieldName] = field.render(Object.assign(
                {
                    fallback: {
                        name: fieldName,
                        errorsTemplate: self.config.errorsTemplate,
                        inputTemplate:
                            self.config.inputTemplates[field.config.inputType]
                            || self.config.inputTemplates['input'],
                    }
                },
                templateVariables || {}
            ));
        });

        // Render all fields if there are no fieldsets, else render fieldsets only
        // Interestingly, '\n' works like "\n". Double quotes disallowed in ESLint.
        let formHtml = '';
        if (0 === this.fieldsets.size) {
            formHtml = Object.values(htmlByField).join('\n');
        } else {
            this.fieldsets.forEach(function (fieldset, fieldsetName) {
                let fieldsHtml = '';
                fieldset.config.fieldNames.forEach(function (fieldName) {
                    fieldsHtml += (htmlByField[fieldName] || '') + '\n';
                });

                // Note that if the template variables passed in here are only
                // fallbacks, i.e. if the fieldset config has these keys
                // specified, the values from the fieldset config will
                // override these values.
                formHtml += fieldset.render(Object.assign(
                    {
                        fieldsHtml: fieldsHtml,
                        fallback: {
                            name: fieldsetName,
                        },
                    },
                    templateVariables || {}
                ));
            });
        }

        return mustache.render(
            this.config.formTemplate || '',
            Object.assign(
                {
                    name: this.config.name,
                    method: this.config.method,
                    action: this.config.action,
                    attributes: attributes ? attributes.join(' ') : '',
                    classes: this.config.classes.join(' '),
                    formHtml: formHtml,
                },
                templateVariables || {}
            )
        );
    };

    /**
     * Set form data.
     *
     * @memberof Form
     * @instance
     *
     * @public
     * @param {object} formData - Key-value pairs where key is field name and value is
     *     field value. Type of value may be string, number or array (multiple values such as for
     *     multi-select checkbox group).
     * @returns {void}
     */
    Form.prototype.setData = function (formData) {
        // Not saving in private instance variable as the values are saved in the fields themselves
        Object.keys(formData || {}).forEach((fieldName) => {
            if (this.fields.has(fieldName)) {
                this.fields.get(fieldName).value = formData[fieldName];
            }
        });
    };

    /**
     * Validate form.
     *
     * Values and errors, if any, will be stored in fields after validation.
     * This is to aid when rendering the form after validation.
     *
     * The method signature allows the validation of a form submission in one
     * line instead of having to call `form.setData()` each time before calling
     * `form.validate()`. Example scenario in an Express app:
     *
     *     // If need form.setData() then code for response will be duplicated
     *     if ('GET' === request.method
     *         || ('POST' === request.method && !form.validate(request.body))
     *     ) {
     *         response.send(mustache.render({ // Mustache.js
     *             viewHtml, // template
     *             { record: record }, // template variables
     *             { form_html: form.render() } // partials
     *         }));
     *     }
     *
     * @memberof Form
     * @instance
     *
     * @public
     * @param {(null|object)} [formData=null] - Optional key-value pairs
     *     where the key is the field name and the value is the submitted value.
     *     If null or unspecified, current values in fields will be used for
     *     validation.
     * @returns {(null|object)} Null returned if form is valid, else key-value
     *     pairs are returned, with key being field name and value being
     *     the error message for the field.
     */
    Form.prototype.validate = function (formData = null) {
        if (!formData) {
            formData = this.getData();
        }

        let errors = {};
        let hasErrors = false;
        this.fields.forEach((field, fieldName) => {
            let fieldErrors = field.validate(
                fieldName,
                formData[fieldName],
                formData,
                this.config.requiredText
            );

            if (fieldErrors.length > 0) {
                hasErrors = true;
                errors[fieldName] = fieldErrors;
            }
        });

        return (hasErrors ? errors : null);
    };

    return Form; // refers to `function Form()` which Form.prototype builds upon
})();

// JSDoc: Need to assign IIFE to variable instead of module.exports
// and add @memberof/@instance tags to all properties/methods else docs cannot be generated
module.exports = Form;