Field.js

// Import modules
const mustache = require('mustache');
const utils = require('./utils.js');

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

    /**
     * Configuration defaults for field
     *
     * @memberof Field
     * @instance
     *
     * @public
     * @type {object}
     * @property {boolean} disabled - Whether this field is disabled.
     * @property {string} emptyOptionText - Text in `<option>` element where value is empty.
     * @property {string} errorsTemplate - Mustache.js template for rendering
     *     array of error messages for each field. Overrides errorsTemplate set by form
     *     if specified.
     * @property {object} fieldAttributes - Key-value pairs for attributes in `<div>` element
     *     containing the field. Use empty string if the attribute has no value, null to unset
     *     the attribute.
     * @property {string[]} fieldClasses - List of CSS classes for `<div>` element
     *     containing the field.
     * @property {string} fieldTemplate - Mustache.js template for rendering HTML for field
     *     element. The `errorsHtml` template variable is rendered using errorsTemplate.
     * @property {object} inputAttributes - Key-value pairs for attributes in
     *     input element. Use empty string if the attribute has no value, null to
     *     unset the attribute.
     * @property {string[]} inputClasses - List of CSS classes for input element.
     * @property {string} inputTemplate - Mustache.js template for rendering
     *     HTML for input element. 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} inputType - Type of input, e.g. text, select, textarea, radio, checkbox.
     * @property {string} label - Label for field.
     * @property {object} labelAttributes - Key-value pairs for attributes in
     *     <label>. Use empty string if the attribute has no value, null to
     *     unset the attribute.
     * @property {string[]} labelClasses - List of CSS classes for `<label>` element.
     * @property {string} name - Field name. Overrides name set by form if specified.
     * @property {string} note - Optional note displayed with input element.
     * @property {string[]} noteClasses - List of CSS classes for `<div>` element for note.
     * @property {object} options - Key-value pairs used for `<option>` elements in `<select>`
     *     element used as such: `<option value="${key}">${value}</option>`.
     * @property {boolean} readonly - Whether this field is readonly.
     * @property {boolean} required - Whether this field is required. In-built validation if this
     *     is true and field is not disabled/readonly.
     * @property {string} requiredText - Text to return for error message if value is empty for
     *     required field. Overrides required text set by form if specified.
     * @property {string|number|array} value - Default value for input. Use an array if input has
     *     multiple values, e.g. multi-select checkbox group. Note that field values in web form
     *     submissions are always of string type, hence no catering for null/undefined/boolean types.
     * @property {function(string,string,object): boolean} validateFunction - Function for
     *     validating submitted input value. Does not override in-built validation. Takes in
     *     (name of field in form, submitted value, values for all fields in form) and
     *     returns an array of error messages.
     */
    Field.prototype.config = { // in alphabetical order, properties before functions
        disabled: false,
        emptyOptionText: 'Please select an option',
        errorsTemplate: '',
        fieldAttributes: {},
        fieldClasses: [],
        fieldTemplate: '<div {{{fieldAttributes}}} class="{{{fieldClasses}}}">'
            + '<label for="{{{name}}}" {{{labelAttributes}}} class="{{{labelClasses}}}">'
            + '{{label}}</label>'
            + '{{{inputHtml}}}'
            + '{{#note}}<div class="{{{noteClasses}}}">{{{note}}}</div>{{/note}}'
            + '{{{errorsHtml}}}'
            + '</div>',
        inputAttributes: {},
        inputClasses: [],
        inputTemplate: '',
        inputType: 'text',
        label: '',
        labelAttributes: {},
        labelClasses: [],
        name: '',
        note: '',
        noteClasses: [],
        options: null,
        readonly: false,
        required: false,
        requiredText: '',
        value: '',
        validateFunction: null,
    };

    /**
     * List of error messages set when validate() is called
     *
     * @memberof Field
     * @instance
     *
     * @public
     * @type {string[]}
     */
    Field.prototype.errors = [];

    /**
     * Current value for field
     *
     * Note that field values in web form submissions are always of string type,
     * hence no catering for null/undefined/boolean types. An array is used
     * when there are multiple values, such as for multi-select checkbox groups.
     *
     * @memberof Field
     * @instance
     *
     * @public
     * @type {string|number|array}
     */
    Field.prototype.value = '';

    /**
     * Renders HTML for field
     *
     * @memberof Field
     * @instance
     *
     * @public
     * @param {(null|object)} [templateVariables=null] - Optional key-value pairs
     *     that can be used to add on to or override template variables for the
     *     final rendering (not for intermediate renders such as input and errors).
     * @returns {string}
     */
    Field.prototype.render = function (templateVariables = null) {
        let self = this; // for use inside callbacks

        // Resolve values for special keys that may be passed in via templateVariables
        // Values from field config will override those from templateVariables
        templateVariables = templateVariables || {};
        let name = this.config.name || templateVariables?.fallback?.name;
        let errorsTemplate = this.config.errorsTemplate
            || templateVariables?.fallback?.errorsTemplate;
        let inputTemplate = this.config.inputTemplate
            || templateVariables?.fallback?.inputTemplate;

        // Handling for select/checkbox/radio fields. May have multiple values passed as array.
        let selectOptions = [];
        let hasSelectedOption = false;
        let selectedOptionText = '';
        let values = self.value || self.config.value;
        if ([null, undefined, ''].includes(values)) { // cannot use `if (!values)` cos values may be 0 or false
            values = [];
        } else {
            // Cast all values to string cos ['1'].includes(1) and [1].includes['1'] return false
            values = (Array.isArray(values) ? values : [values]).map((val) => val.toString());
        }
        Object.keys(this.config.options || {}).forEach(function (optionValue) {
            let isSelected = values.includes(optionValue.toString()); // need to cast to string else may not work
            if (isSelected) {
                hasSelectedOption = true;
                selectedOptionText = self.config.options[optionValue];
            }

            selectOptions.push({ // keys correspond to Mustache.js template variables
                optionValue: optionValue,
                optionText: self.config.options[optionValue],
                optionSelected: isSelected,
            });
        });

        // Input element
        let inputHtml = mustache.render(
            inputTemplate || '',
            Object.assign(
                {
                    name: name,
                    type: this.config.inputType,
                    attributes: utils.attributesToString(Object.assign(
                        {
                            disabled: this.config.disabled ? '' : null,
                            readonly: this.config.readonly ? '' : null,
                            required: this.config.required ? '' : null,
                        },
                        this.config.inputAttributes || {},
                    )),
                    classes: this.config.inputClasses.join(' '),
                    emptyOptionText: this.config.emptyOptionText,
                    hasSelectedOption: hasSelectedOption,
                    options: selectOptions,
                    selectedOptionText: selectedOptionText,
                    value: this.value || this.config.value, // current value, then default value as fallback
                },
                templateVariables || {}
            )
        );

        // Errors
        let errorsHtml = mustache.render(
            errorsTemplate || '',
            Object.assign(
                {
                    errors: this.errors,
                },
                templateVariables || {}
            )
        );

        return mustache.render(
            this.config.fieldTemplate || '',
            Object.assign(
                {
                    name: name,
                    fieldAttributes: utils.attributesToString(this.config.fieldAttributes),
                    fieldClasses: this.config.fieldClasses.join(' '),
                    label: this.config.label,
                    labelAttributes: utils.attributesToString(this.config.labelAttributes),
                    labelClasses: this.config.labelClasses.join(' '),
                    note: this.config.note,
                    noteClasses: this.config.noteClasses.join(' '),
                    inputHtml: inputHtml,
                    errorsHtml: errorsHtml,
                },
                templateVariables || {}
            )
        );
    };

    /**
     * Validate field
     *
     * @memberof Field
     * @instance
     *
     * Values and errors, if any, will be stored in field after validation.
     * This is to aid when rendering the form after validation.
     *
     * @public
     * @param {mixed} fieldName - Name of field as in formData. Note that
     *     a field may have different names when used in different forms.
     * @param {mixed} fieldValue - Submitted value for field.
     * @param {object} formData - Submitted values for form as key-value pairs
     *     where the key is the field name and the value is the submitted value.
     *     This is passed in as the field may depend on the values of other
     *     fields in the form.
     * @param {string} requiredText - Optional required text that is used as
     *     fallback if it is not set in field config.
     * @returns {string[]} Empty array returned if field is valid, else array
     *     of error messages is returned. There may be more than 1 error
     *     message hence an array.
     */
    Field.prototype.validate = function (fieldName, fieldValue, formData, requiredText = '') {
        let errors = [];

        // Value from field config will override value passed in
        requiredText = this.config.requiredText || requiredText;

        // In-built validation for required check
        if (this.config.required) {
            // Do not run required check for disabled/readonly fields
            if (!this.config.disabled && !this.config.readonly
                && ['', undefined, null].includes(fieldValue) // does not check array or {}
            ) {
                errors.push(requiredText);
            }
        }

        // This does not override in-built validation cos field config not passed in,
        // i.e. cannot check config.required/disabled/readonly
        let validateFn = this.config.validateFunction;
        if (validateFn && 'function' === typeof validateFn) {
            errors.push(...validateFn(fieldName, fieldValue, formData));
        }

        // Store value and errors to aid rendering later
        this.value = fieldValue || this.config.value; // need fallback cos Submit button won't have value
        this.errors = errors;

        return errors;
    };

    return Field;
})();

// 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 = Field;