input-validator.js

// =============================================================================
/*! countinmultiples */
/*! author: Toni Price (https://toni.rbind.io) */
// =============================================================================

import { Level, ctxt, log } from './logging.js';

import { Config } from './config.js';

/**
 * Class to handle input validation for the grid configuration.
 * @typedef {object} InputValidator
 * @property {Array<object>} configCols - An array of HTML elements which are
 *   the containers for columns in the config form.
 * @property {Array<string>} keyOrder - An array of strings which are the HTML
 *   id's of the fields to validate.
 * @property {Array<object>} fields - An associative array of HTMLInputElement
 *   objects which are the input fields to validate. The keys must match those
 *   in `keyOrder`.
 * @property {Array<object>} valid - An associative array of boolean values,
 *   one for each of the input fields to validate, named according to the keys
 *   in `keyOrder`.
 */
export class InputValidator {

  #configCols = null;

  #keyOrder = null;
  #fields = null;

  #valid = null;

  /**
   * Sets the focus and selected text for an HTML input.
   * @param {object} field - HTMLInputElement for which to set the focus.
   */
  static setFieldFocus = (field) => {
    // TODO: Check if this is working properly, is selection always being set
    // as intended?
    if (field != null) {
      field.select();
      field.focus();
    }
  };

  /**
   * Constructor for the InputValidator.
   * @param {Array<string>} keyOrder - An array of strings which are the HTML
   *   id's of the fields to validate.
   * @param {Array<object>} fields - An associative array of HTMLInputElement
   *   objects which are the input fields to validate. The keys must match those
   *   in `keyOrder`.
   */
  constructor(keyOrder, fields) {

    log(Level.Debug, `-> Constructing input validator`, ctxt());

    const containerSel = `.${Config.configContainerClassName}`;
    const colSel = `.${Config.configColClassName}`;

    const configContainer = document.querySelector(containerSel);
    this.#configCols = configContainer.querySelectorAll(colSel);

    // TODO: If this.#configCols is not needed then remove it
    log(Level.Debug, 'Config cols:', ctxt());
    log(Level.Debug, this.#configCols, ctxt(), 'tab');

    this.#keyOrder = keyOrder;
    this.#fields = fields;

    this.#valid = {};
    for (const key of keyOrder) {
      this.#valid[key] = true;
    }

    log(Level.Debug, 'key order:', ctxt());
    log(Level.Debug, this.#keyOrder, ctxt(), 'dir');
    log(Level.Debug, 'validity status:', ctxt());
    log(Level.Debug, this.#valid, ctxt(), 'dir');
  }

  /**
   * Checks if a number is integer.
   * @param {number} num - The number to check.
   * @returns {boolean} true if the number is integer, false otherwise.
   */
  static isInt = function(num) {
    if (num % 1 === 0) {
      return true;
    } else {
      return false;
    }
  };

  /**
   * Checks if a number is not any of 0, NaN or null.
   * @param {number} num - The number to check.
   * @returns {boolean} true if the number is not any of 0, NaN or null; false
   *   otherwise.
   */
  static numIsPresent = function(num) {
    return !(num === null || isNaN(num) || num === 0);
  };

  /**
   * Gets the closest HTML container (with the specified class name) to the
   *   given element.
   * @param {object} field - The HTMLInputElement for which to get the closest
   *   container.
   * @param {string} containerClassNm - The class name of the HTML element's
   *   container to retrieve. Default: Config.configColClassName
   * @returns {object} The closest container with the specfied class name to the
   *   given element.
   */
  static getClosestColContainer = function(field, containerClassNm) {
    containerClassNm = containerClassNm || Config.configColClassName;
    return field.closest(`.${containerClassNm}`);
  };

  /**
   * Getter for the input validation flag.
   * @returns {Array<object>} An associative array of boolean values, one for
   *   each of the input fields to validate, named according to the keys in
   *   `keyOrder`.
   */
  get valid() {
    return this.#valid;
  }

  /**
   * Retrieves the current state of validity for the field with given key.
   * @param {string} key - The key for this field in the array of validity
   *   values.
   * @returns {boolean} true if the multiple input is valid, false if not.
   */
  fieldIsValid = (key) => {
    return this.#valid[key];
  };

  /**
   * Checks if every validatable input field is valid.
   * @returns {boolean} true if all fields are valid, false if at least one is
   *   invalid.
   */
  stateIsValid = () => {
    return Object.keys(this.#valid).every(this.fieldIsValid);
  };

  /**
   * Using the predefined order of keys which exist in the InputValidator, finds
   *   the key for the first invalid field (if any fields are invalid).
   * @returns {string} The key (which is the HTML id) of the first invalid
   *   field, according to the key order. If no fields are invalid, returns
   *   null.
   */
  getFirstInvalidKey = () => {
    let firstInvalidKey = null;
    for (const key of this.#keyOrder) {

      log(Level.Debug, `checking key '${key}'`, ctxt());

      if (!this.fieldIsValid(key)) {
        log(Level.Debug, `found invalid field for '${key}'`, ctxt());
        firstInvalidKey = key;
        break;
      }
    }

    log(Level.Debug, `first invalid html id: ${firstInvalidKey}`, ctxt());

    return firstInvalidKey;
  };

  /**
   * Using the predefined order of keys which exist in the InputValidator, finds
   *   the first invalid field (if any fields are invalid).
   * @returns {object} The first invalid HTMLInputElement field (according to
   *   the key order), named by its key. If no fields are invalid, returns null.
   */
  getFirstInvalidField = () => {

    const firstInvalidKey = this.getFirstInvalidKey();
    let firstInvalidField = null;

    if (firstInvalidKey != null) {
      firstInvalidField = this.#fields[firstInvalidKey];
    }

    log(Level.Debug, 'first invalid field:', ctxt());
    log(Level.Debug, firstInvalidField, ctxt(), 'dir');

    return firstInvalidField;
  };

  /**
   * Sets the validation status for the given field.
   * @param {object} field - The HTMLInputElement for which to set the status.
   * @param {string} fieldLbl - The field's label.
   * @param {string} errMsg - The error message to display if the field is not
   *   valid.
   * @param {string} status - The validation status indicator: 'success' for
   *   valid, 'error' for invalid.
   * @param {boolean} showLbl - Whether to prefix the error message with the
   *   given field label.
   */
  setStatus = (field, fieldLbl, errMsg, status, showLbl) => {

    const closestContainer = InputValidator.getClosestColContainer(field);

    // Get the container object which will hold the error
    const selector = `.${Config.errContainerClassName}`;
    const currErrField = closestContainer.querySelector(selector);

    // TODO: Change the status into an enum-style object
    if (status === 'success') {

      closestContainer.classList.remove(Config.inputErrClassName);

      currErrField.innerText = '';

    } else if (status === 'error') {

      closestContainer.classList.add(Config.inputErrClassName);

      if (showLbl) {
        currErrField.innerText = `${fieldLbl} ${errMsg}`;
      } else {
        currErrField.innerText = errMsg;
      }
    }
  };

  /**
   * Checks if the value of the specified field is a counting integer within a
   *   range.
   * @param {string} key - The key for this field which is the HTML id of the
   *   input element.
   * @param {number} v1 - Lower bound (an integer).
   * @param {number} v2 - Upper bound (an integer).
   * @param {string} msgType - Style of message if the input field violates.
   *   range restrictions. Valid options: 'range', 'max'. Default: 'range'
   * @param {boolean} resetToMax - If the field is a valid number but larger
   *   than the upper bound, should it be reset to its maximum value or left
   *   as-is? Default: false
   * @returns {boolean} true if the input field is valid, false if not.
   */
  validateCountField = (key, v1, v2, msgType, resetToMax) => {

    // TODO: In general this input validator could use some improvement!

    resetToMax = resetToMax || false;

    msgType = msgType || 'range';

    const field = this.#fields[key];

    const basicNumMsg = 'Must be a number';

    const fieldLbl = field.previousElementSibling.innerText;
    // For now don't include the field label but ideally make includeFieldLbl
    // into a parameter so as to provide the choice
    const includeFieldLbl = false;

    let isValid = true;
    let msg = '';

    const digitPattern = /^[-]?[0-9]+$/;

    // if (field.value.trim() === '')

    if (!field.value.match(digitPattern)) {
      isValid = false;
      msg = basicNumMsg;

    } else {

      const val = parseInt(field.value);

      if (isNaN(val)) {
        isValid = false;
      } else if (val < 0) {
        isValid = false;
        msg = 'Must not be negative';
      } else if (val == 0) {
        isValid = false;
        msg = 'Must not be zero';
      } else if (val < v1 || val > v2) {
        if (resetToMax && val > v2) {
          // TODO: Is resetToMax ever set to true? Remove if not.
          // TODO: Display a message (animated/transient?) in the UI to make it
          // clear that the value has been reset to its maximum possible value
          field.value = v2;
          isValid = true;
        } else {
          isValid = false;
        }
      }
    }

    log(Level.Debug, `${field.id}: '${field.value}'`, ctxt());
    log(Level.Debug, `${field.id} is valid? ${isValid}`, ctxt());

    this.#valid[key] = isValid;

    if (isValid) {
      this.setStatus(field, `'${fieldLbl}'`, null, 'success', includeFieldLbl);
    } else {
      if (msg === '') {
        msg = basicNumMsg;
        if (InputValidator.numIsPresent(v2)) {
          switch (msgType) {
          case 'range':
            if (InputValidator.numIsPresent(v1)) {
              msg = `Must be a number between ${v1} and ${v2}`;
            }
            break;
          case 'max':
            msg = `Must be a number less than or equal to ${v2}`;
            break;
          default:
            break;
          }
        }
      }
      this.setStatus(field, `'${fieldLbl}'`, msg, 'error', includeFieldLbl);
    }

    return isValid;
  };
}

// -----------------------------------------------------------------------------