Thursday, June 04, 2026 4:00:27 AM
> validator.js
import $ from "cash-dom";
import * as R from "ramda";
import { debounce } from "lodash";
import axios from "axios";

export default class Validator {
  constructor(form, options = {}) {
    this.form = $(form)[0];
    this.fields = [];
    this.errors = {};
    this.validationCache = new Map();

    this.options = {
      errorClass: "is-invalid",
      successClass: "is-valid",
      errorElement: "div",
      errorClass: "invalid-feedback",
      validateOnBlur: true,
      validateOnInput: true,
      ...options,
    };

    this.callbacks = {
      onSuccess: [],
      onFail: [],
    };

    this.isSubmitting = false;

    this.#initialize();
  }

  //////////////////////////////////////////////////////////////////////////////
  // Public API
  //////////////////////////////////////////////////////////////////////////////

  addField(selector, rules = []) {
    const field = { selector, rules, validated: false, touched: false };
    this.fields.push(field);

    const el = $(selector)[0];
    if (!el) return this;

    this.#bindFieldEvents(field, el);
    return this;
  }

  async validate() {
    this.errors = {};

    // Use Ramda to process fields until we find an invalid one
    const validateField = async (field) => {
      const isValid = await this.#validateField(field, true);
      if (!isValid) throw field; // Use throw to break early
      return field;
    };

    try {
      await R.pipe(R.map(validateField), (promises) => Promise.all(promises))(
        this.fields
      );

      // All fields valid
      this.#executeCallbacks("onSuccess");
      this.#handleFormSubmission();
      return true;
    } catch (firstErrorField) {
      // First invalid field found
      this.#scrollToError(firstErrorField);
      this.#executeCallbacks("onFail");
      return false;
    }
  }

  clearAllErrors() {
    R.forEach((field) => {
      this.#clearFieldError(field.selector);
      field.validated = false;
      field.touched = false;
    })(this.fields);

    this.errors = {};
  }

  clearCache(selector = null) {
    if (selector) {
      const keysToDelete = R.filter(
        (key) => key.startsWith(selector),
        Array.from(this.validationCache.keys())
      );
      R.forEach((key) => this.validationCache.delete(key))(keysToDelete);
    } else {
      this.validationCache.clear();
    }
  }

  onSuccess(callback) {
    this.callbacks.onSuccess.push(callback);
    return this;
  }

  onFail(callback) {
    this.callbacks.onFail.push(callback);
    return this;
  }

  //////////////////////////////////////////////////////////////////////////////
  // Private Methods
  //////////////////////////////////////////////////////////////////////////////

  #initialize() {
    if (!this.form) return;

    $(this.form).on("submit", async (e) => {
      if (this.isSubmitting) {
        this.isSubmitting = false;
        return;
      }

      e.preventDefault();
      await this.validate();
    });
  }

  #bindFieldEvents(field, el) {
    const { validateOnBlur, validateOnInput } = this.options;

    if (validateOnBlur) {
      const blurTarget = this.#isSlimSelect(el)
        ? this.#getSlimSelectContainer(el)
        : el;

      if (blurTarget) {
        $(blurTarget).on("blur", async () => {
          field.touched = true;
          await this.#validateField(field);
        });
      }
    }

    if (validateOnInput) {
      $(el).on(
        "input",
        debounce(async () => {
          field.touched = true;
          await this.#validateField(field);
        }, 300)
      );
    }
  }

  async #validateField(field, forceValidation = false) {
    const el = $(field.selector)[0];
    if (!el) return true;

    if (!field.touched && !forceValidation) return true;

    const value = el.value;
    this.#clearFieldError(field.selector);

    // Process rules with early exit on first failure
    for (const rule of field.rules) {
      const { isValid, errorMsg } = await this.#processRule(
        rule,
        value,
        el,
        field
      );

      if (!isValid) {
        this.#showFieldError(field.selector, errorMsg);
        field.validated = false;
        return false;
      }
    }

    this.#showFieldSuccess(field.selector);
    field.validated = true;
    return true;
  }

  async #processRule(rule, value, el, field) {
    const { rule: ruleName, errorMessage: customErrorMsg } = rule;
    let isValid = true;
    let errorMsg = customErrorMsg || "Invalid value";

    try {
      switch (ruleName) {
        case "required":
          isValid = R.trim(value) !== "";
          errorMsg = customErrorMsg || "This field is required";
          break;

        case "minLength":
          isValid = value.length >= rule.value;
          errorMsg = customErrorMsg || `Minimum length is ${rule.value}`;
          break;

        case "maxLength":
          isValid = value.length <= rule.value;
          errorMsg = customErrorMsg || `Maximum length is ${rule.value}`;
          break;

        case "customRegexp":
          isValid = rule.value.test(value);
          errorMsg = customErrorMsg || "Invalid format";
          break;

        case "ajax":
          isValid = await this.#handleAjaxRule(rule, value, field);
          errorMsg = customErrorMsg || "Validation failed";
          break;

        default:
          if (typeof rule.validator === "function") {
            isValid = await this.#handleCustomValidator(rule, value, el, field);
          }
      }
    } catch (error) {
      isValid = false;
      errorMsg = error.message || errorMsg;
    }

    return { isValid, errorMsg };
  }

  async #handleAjaxRule(rule, value, field) {
    const cacheKey = rule.cache ? `${field.selector}-ajax-${value}` : null;

    if (cacheKey && this.validationCache.has(cacheKey)) {
      return this.validationCache.get(cacheKey);
    }

    const isValid = await this.#performAjaxValidation(value, rule);

    if (cacheKey) {
      this.validationCache.set(cacheKey, isValid);
    }

    return isValid;
  }

  async #handleCustomValidator(rule, value, el, field) {
    const cacheKey = rule.cache ? `${field.selector}-custom-${value}` : null;

    if (cacheKey && this.validationCache.has(cacheKey)) {
      return this.validationCache.get(cacheKey);
    }

    const result = await Promise.resolve(rule.validator(value, el));

    if (cacheKey) {
      this.validationCache.set(cacheKey, result);
    }

    return result;
  }

  async #performAjaxValidation(value, rule) {
    try {
      const config = {
        url: rule.url,
        method: rule.method || "GET",
        ...rule.config,
      };

      const isGet = config.method.toUpperCase() === "GET";
      const paramData = rule.params ? rule.params(value) : { value };

      if (isGet) {
        config.params = paramData;
      } else {
        config.data = rule.data ? rule.data(value) : paramData;
      }

      const response = await axios(config);

      return rule.responseHandler
        ? rule.responseHandler(response)
        : response.data === true || response.data.valid === true;
    } catch (error) {
      console.error("AJAX validation failed:", error);
      return false;
    }
  }

  #executeCallbacks(type) {
    R.forEach((callback) => callback())(this.callbacks[type]);
  }

  #handleFormSubmission() {
    if (this.callbacks.onSuccess.length > 0 || !this.form) return;

    const turboDisabled = R.any(
      (value) => value === "false" || value === false,
      [this.form.dataset.turbo, this.form.getAttribute("data-turbo")]
    );

    if (turboDisabled) {
      this.form.submit();
    } else {
      this.isSubmitting = true;

      setTimeout(() => {
        this.form.requestSubmit();
      }, 0);
    }
  }

  #scrollToError(errorField) {
    const el = $(errorField.selector)[0];
    if (!el) return;

    const scrollTarget = this.#isSlimSelect(el)
      ? this.#getSlimSelectContainer(el) || el
      : el;

    const offset = 400;
    const elementPosition =
      scrollTarget.getBoundingClientRect().top + window.pageYOffset;
    const offsetPosition = elementPosition - offset;

    window.scrollTo({
      top: offsetPosition,
      behavior: "smooth",
    });

    setTimeout(() => {
      if (!this.#isSlimSelect(el) && el.focus) {
        el.focus();
      }
    }, 500);
  }

  //////////////////////////////////////////////////////////////////////////////
  // SlimSelect Helpers
  //////////////////////////////////////////////////////////////////////////////

  #isSlimSelect(el) {
    return (
      el?.tagName === "SELECT" &&
      (el.style.display === "none" || $(el).hasClass("ss-hide")) &&
      $(el).siblings(".ss-main").length > 0
    );
  }

  #getSlimSelectContainer(el) {
    return $(el).siblings(".ss-main")[0];
  }

  //////////////////////////////////////////////////////////////////////////////
  // Error Display Methods
  //////////////////////////////////////////////////////////////////////////////

  #showFieldError(selector, message) {
    const el = $(selector)[0];
    if (!el) return;

    if (this.#isSlimSelect(el)) {
      this.#showSlimSelectError(el, message);
    } else {
      this.#showRegularError(el, message);
    }
  }

  #showSlimSelectError(el, message) {
    const container = this.#getSlimSelectContainer(el);
    if (!container) return;

    $(container).removeClass(this.options.successClass).addClass("is-invalid");

    let errorEl = $(container).siblings(".invalid-feedback")[0];

    if (!errorEl) {
      errorEl = $(`<div class="invalid-feedback d-block"></div>`)[0];
      $(container).after(errorEl);
    }

    $(errorEl).text(message).show();
  }

  #showRegularError(el, message) {
    $(el).removeClass(this.options.successClass).addClass("is-invalid");

    let errorEl =
      $(el).siblings(".invalid-feedback")[0] ||
      $(el).parent().find(".invalid-feedback")[0];

    if (!errorEl) {
      errorEl = $(`<div class="invalid-feedback"></div>`)[0];
      $(el).after(errorEl);
    }

    $(errorEl).text(message).show();
  }

  #showFieldSuccess(selector) {
    const el = $(selector)[0];
    if (!el) return;

    if (this.#isSlimSelect(el)) {
      this.#showSlimSelectSuccess(el);
    } else {
      this.#showRegularSuccess(el);
    }
  }

  #showSlimSelectSuccess(el) {
    const container = this.#getSlimSelectContainer(el);
    if (!container) return;

    $(container).removeClass("is-invalid");
    $(container).siblings(".invalid-feedback").remove();
    $(container).parent().find(".invalid-feedback").remove();
  }

  #showRegularSuccess(el) {
    $(el).removeClass("is-invalid");
    $(el).siblings(".invalid-feedback").hide();
    $(el).parent().find(".invalid-feedback").hide();
  }

  #clearFieldError(selector) {
    const el = $(selector)[0];
    if (!el) return;

    if (this.#isSlimSelect(el)) {
      this.#showSlimSelectSuccess(el); // Same as success for clearing
    } else {
      this.#showRegularSuccess(el); // Same as success for clearing
    }
  }

  //////////////////////////////////////////////////////////////////////////////
  // Static Rule Helpers
  //////////////////////////////////////////////////////////////////////////////

  static rules = {
    required: (errorMessage = "This field is required") => ({
      rule: "required",
      errorMessage,
    }),

    minLength: (length, errorMessage) => ({
      rule: "minLength",
      value: length,
      errorMessage: errorMessage || `Minimum length is ${length}`,
    }),

    maxLength: (length, errorMessage) => ({
      rule: "maxLength",
      value: length,
      errorMessage: errorMessage || `Maximum length is ${length}`,
    }),

    email: (errorMessage = "Please enter a valid email") => ({
      rule: "customRegexp",
      value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
      errorMessage,
    }),

    ajax: (url, options = {}) => ({
      rule: "ajax",
      url,
      method: options.method || "GET",
      params: options.params,
      data: options.data,
      config: options.config,
      responseHandler: options.responseHandler,
      cache: options.cache !== false,
      errorMessage: options.errorMessage || "Validation failed",
    }),

    custom: (validator, errorMessage = "Invalid value", cache = false) => ({
      validator,
      errorMessage,
      cache,
    }),
  };
}
All opinions represented herein are my own
- © 2024 - 2026 itsthedevman
- build 4294fb2