judgegem/judge

Is it by design I have to write javascript for each validation?

rreynier opened this issue · 9 comments

To get client side validation to work, I have to set :validate => true for each form field I want to be validated on the client side. In addition I have to also write javascript for each input and how it should be validated.

This seems to be against the whole point of trying to avoid duplication between server side validation and client side validation. If my model says something is required, by default I would expect it to be validated on the client side without have to write a ton of code. Curious to your reasoning for the current implementation and if maybe I am missing something.

Thanks for all your work!

About the fact of "need to write code for each input", take a look to my script that bind the validation (use bootstrap classes but customizable).

/**
 * Make sure to use the same value than the server to run validations.
 * Used by validations that need access to the database in real time such as uniqueness.
 */
//judge.enginePath = '/validate';

/**
 * Dynamic validation field by field on events. (i.e: onblur, ...)
 * Icons require font-awesome.
 *
 * @see https://github.com/joecorcoran/judge/issues/13 - Original source code for this script.
 *
 * @example To not validate an entire form, just add the data-novalidate html5 attr.
 *            html: { 'data-novalidate' => true }
 *
 * @example Manage uniqueness validation. By default it is not case-sensitive.
 *  =f.input :username, validate: true, input_html: { 'data-initial-value' => "#{@user.username}" }
 *
 * @example Manage uniqueness validation and enable case sensitive on uniqueness.
 *  =f.input :username, validate: true, input_html: { 'data-initial-value' => "#{@user.username}", 'data-case-sensitive' => true }
 *
 */
(function($, window) {
  var __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; },
    __slice = [].slice;

  var FormValidator = (function() {
    // Parent of all inputs.
    var PARENT_INPUT_BASE_CLASSES = 'has-feedback';

    var PARENT_INPUT_ERROR_CLASS = 'has-error';
    var PARENT_INPUT_SUCCESS_CLASS = 'has-success';
    //var PARENT_INPUT_VALIDATING_CLASS = 'has-warning';

    // Inputs.
    var INPUT_ERROR_CLASS = 'error';
    var INPUT_SUCCESS_CLASS = 'success';

    // Error messages.
    var DIV_ERROR_MESSAGE_CLASS = 'help-block';

    // Icons.
    var ICON_BASE_CLASSES = 'form-control-feedback';

    var ICON_SUCCESS = 'fa fa-check';
    var ICON_ERROR = '';
    //var ICON_VALIDATING = 'fa fa-refresh';

    var KEY_INITIAL_VALUE = 'initial-value';
    var KEY_CASE_SENSITIVE = 'case-sensitive';

    // Default options to apply.
    FormValidator.prototype.defaultOptions = {
      // Performs live validation, on value change.
      live: {
        not_validated: true,
        valid: true,
        invalid: true
      }
    };

    /**
     * Initialize.
     * Bind functions.
     *
     * @param $el
     * @param options
     * @constructor
     */
    function FormValidator($el, options) {
      this._invalid = __bind(this._invalid, this);
      this._valid = __bind(this._valid, this);
      this.validateInput = __bind(this.validateInput, this);
      this.validateAll = __bind(this.validateAll, this);
      this.$el = $el;
      this.options = $.extend({}, this.defaultOptions, options);
      this.bindEvents();
    }

    /**
     * Bind all events on the input. (i.e: blur, ...)
     *
     * @returns {*}
     */
    FormValidator.prototype.bindEvents = function() {
      // Bind - Validate the field if it contains the data-validate attribute.
      this.$el.on('blur', '[data-validate]', (function(_this) {
        return function(e) {
          return _this.validateInput(e.currentTarget);
        };
      })(this));

      // Bind - Validate the field if the element has already been checked when we change the value.
      this.$el.on('input', this._generateSelectorOnInput(), (function(_this) {
        return function(e) {
          // Take care of the confirmation. (password, basically)
          var id = $(e.currentTarget).attr('id');
          if(id.indexOf('_confirmation') !== -1){
            // Validate the input that has the same id without the '_confirmation' part.
            _this.validateInput(document.getElementById(id.replace('_confirmation', '')));// Don't use jQuery, Judge don't expect a jQuery instance.
          }
          // Validate also the input itself.
          return _this.validateInput(e.currentTarget);
        };
      })(this));

      // Bind - Validate the field if the value is changed, for select inputs.
      this.$el.on('change', this._generateSelectorOnInput(), (function(_this) {
        return function(e) {
          return _this.validateInput(e.currentTarget);
        };
      })(this));

      // Bind - On form submit, validate all elements.
      return this.$el.on('submit', this.validateAll);
    };

    /**
     * Generate the string that contains the jQuery selector input to use to get inputs to validate.
     *
     * @returns {string}
     * @private
     */
    FormValidator.prototype._generateSelectorOnInput = function() {
      var selector = '';

      if(this.options.live && this.options.live.not_validated){
        selector += '[data-validate]:not(.' + INPUT_SUCCESS_CLASS + ', .' + INPUT_ERROR_CLASS + ')';
      }

      if(this.options.live && this.options.live.valid){
        selector += (selector.length ? ', ' : '') + '[data-validate].' + INPUT_SUCCESS_CLASS;
      }

      if(this.options.live && this.options.live.invalid){
        selector += (selector.length ? ', ' : '') + '[data-validate].' + INPUT_ERROR_CLASS;
      }

      return selector;
    };

    /**
     * Validate all inputs, it is possible to restrain the validated inputs by providing a container instead of a jQuery event.
     *
     * @param e - Event or jQuery selector.
     * @returns {*}
     */
    FormValidator.prototype.validateAll = function(e) {
      var event = false;
      var result = true;

      // Target can be an event or a jQuery selector. If it is an event we need to target it's own target.
      if(e.currentTarget){
        event = true;
        target = e.currentTarget
      }else{
        target = e;
      }

      // Run the validation for each field.
      $(target).find('[data-validate]').not('[data-no-validate=true]').each((function(_this) {
        return function(_, el) {
          return _this.validateInput(el);
        };
      })(this));

      // If an input has the error class then don't send the form.
      if ($(target).find('[data-validate].' + INPUT_ERROR_CLASS)[0]) {
        console.log($(target).attr('id') + ' element didn\'t pass the validation.');

        if(event){
          e.stopPropagation();
          e.preventDefault();
        }
        result = false;
      }

      // If a required input doesn't have the success class then it means that it wasn't validated yet.
      // It can happen when the validation is using ajax, then the user can click on the submit button
      // and because the ajax validation didn't run before it would pass the validation anyway.
      $(target).find('.required[data-validate]').not('.required[data-validate="false"]').each((function(_this) {
        return function(_, el) {
          if(!$(el).hasClass('success')){
            console.log($(el).attr('id') + ' element didn\'t run previously the validation, submit aborted.');

            if(event){
              e.stopPropagation();
              e.preventDefault();
            }
            result = false;
          }
        };
      })(this));

      return result;
    };

    /**
     * Validate a container. Take all inputs inside this container and validate them.
     * Useful to validate pages, one by one.
     * Return false if there is any error.
     *
     * @param page
     * @returns boolean
     */
    FormValidator.prototype.validatePage = function(page) {
      return this.validateAll(page);
    };

    /**
     * Validate one input.
     *
     * @param el
     * @returns {*}
     */
    FormValidator.prototype.validateInput = function(el) {
      var $el = $(el);

      // If the input has to check the uniqueness, validate it only if the value isn't equal to the initial value.
      if(_.contains(_.pluck($el.data('validate'), 'kind'), 'uniqueness')){
        if($el.data(KEY_INITIAL_VALUE)){
          // If the validation must be case sensitive.
          if(typeof $el.data(KEY_CASE_SENSITIVE) != 'undefined' && $el.data(KEY_CASE_SENSITIVE) == true){
            if($el.data(KEY_INITIAL_VALUE) == $el.val()){
              this._valid(el);
              console.debug('Input with uniqueness case sensitive validation was not validated because value did not change or was equal to initial value.');
              return el;
            }
          }else{
            // No case sensitive.
            if($el.data(KEY_INITIAL_VALUE).toUpperCase() == $el.val().toUpperCase()){
              this._valid(el);
              console.debug('Input with uniqueness validation was not validated because value did not change or was equal to initial value.');
              return el;
            }
          }
        }
      }

      return judge.validate(el, {
        valid: this._valid,
        invalid: this._invalid
      });
    };

    /**
     * Displays the first error message if exists or return null.
     *
     * @param el
     * @returns {*}
     */
    FormValidator.prototype.findOrCreateMsgItem = function(el) {
      var $item, x;
      if ((x = this.getMsgItem(el))) {
        return x;
      } else {
        $item = $("<div class='" + DIV_ERROR_MESSAGE_CLASS + "' />");

        if($(el).is(':checkbox')){
          $(el).parent().append($item);
        }else{
          $item.insertAfter($(el));
        }
        return $item;
      }
    };

    /**
     * Returns the first error message if exists, or null.
     *
     * @param el
     * @returns {*}
     */
    FormValidator.prototype.getMsgItem = function(el) {
      var x;
      if ((x = $(el).parent().find('.' + DIV_ERROR_MESSAGE_CLASS))[0]) {
        return x;
      } else {
        return null;
      }
    };

    /**
     * Called when an element is valid.
     * Remove error class/messages and add success class.
     *
     * @param el
     * @returns {*}
     * @private
     */
    FormValidator.prototype._valid = function(el) {
      var _ref;
      if ((_ref = this.getMsgItem(el)) != null) {
        _ref.remove();
      }
      this._addDefaultParentClasses(el);

      $(el).removeClass(INPUT_ERROR_CLASS).addClass(INPUT_SUCCESS_CLASS);
      $(el).parent().removeClass(PARENT_INPUT_ERROR_CLASS).addClass(PARENT_INPUT_SUCCESS_CLASS);

      this._refreshIcon(el, ICON_SUCCESS);

      return $(el);
    };

    /**
     * Refresh the displayed icon.
     * Remove the element from the DOM and recreate it.
     * Doesn't apply icon to checkboxes elements since we created a specific design for them.
     *
     * @param el
     * @param classToApply - Either ICON_SUCCESS or ICON_ERROR
     * @private
     */
    FormValidator.prototype._refreshIcon = function(el, classToApply) {
      if(!$(el).is(':checkbox')){
        $(el).parent().children('i').remove();
        $item = $("<i class='" + ICON_BASE_CLASSES + " " + classToApply + "' />");
        $item.insertAfter($(el));
      }
    };

    /**
     * Called when an element is not valid.
     * Add error class and remove success class/messages.
     *
     * @param el - Element where the errors appeared.
     * @param messages - Error messages to display.
     * @returns {*}
     * @private
     */
    FormValidator.prototype._invalid = function(el, messages) {
      // Don't display Judge specific errors.
      var messagesFiltered = _.filter(messages, function(message) {
        return message !== 'Request error: 0';
      });

      if(messagesFiltered != messages){
        // But log them.
        console.log('Request error: 0');
      }

      if(messagesFiltered.length){
        // Add a space between the icon added through CSS and capitalize the sentence.
        this.findOrCreateMsgItem(el).text(_.str.capitalize(messagesFiltered.join(', ')));
        this._addDefaultParentClasses(el);

        $(el).removeClass(INPUT_SUCCESS_CLASS).addClass(INPUT_ERROR_CLASS);
        $(el).parent().removeClass(PARENT_INPUT_SUCCESS_CLASS).addClass(PARENT_INPUT_ERROR_CLASS);

        this._refreshIcon(el, ICON_ERROR);
      }

      return $(el);
    };

    /**
     * Add the default class to the parent element if they don't exists.
     * TODO Works fine when PARENT_INPUT_BASE_CLASSES contains only one class, won't if several.
     * @param el
     * @private
     */
    FormValidator.prototype._addDefaultParentClasses = function(el) {
      if(!$(el).parent().hasClass(PARENT_INPUT_BASE_CLASSES)){
        $(el).parent().addClass(PARENT_INPUT_BASE_CLASSES);
      }
    };

    return FormValidator;

  })();

  // Form.
  // Key used as data to know if the form should not be validated.
  var FORM_NO_VALIDATE = 'novalidate';
  var FORM_INPUT_BASE_CLASSES = 'form-control';

  return $.fn.extend({
    /**
     * Bind the form validation to a form or an array of forms.
     *
     * @param options - Object that contains the options for the validator. [{}]
     * @param args [infinite]
     *
     * @returns {*}
     */
    addFormValidation: function() {
      var options = arguments[0];
      var args = ((2 <= arguments.length) ? __slice.call(arguments, 1) : []);

      return this.each(function() {
        var data;

        /*
         Auto add default classes.
         */
        $(this).each(function(){
          // Filter to take only inputs.
          _.map($(this).filter(':input').context, function(element){
            // Filter to take only element that have an ID. (All simple_form have an ID)
            if($(element).context.id && $(element).context.id.length > 0){
              // Add default classes to all elements except checkboxes and buttons.
              if($(element).attr('type') != 'checkbox' && $(element).attr('type') != 'button' && $(element).attr('type') != 'radio'){
                $(element).addClass(FORM_INPUT_BASE_CLASSES);
              }
            }
          });
        });

        /*
         Don't validate forms that should not be validated.
         data-novalidate="true"
         */
        if ($(this).data(FORM_NO_VALIDATE) === true) {
          console.log('Form: ' + $(this).attr('id') + ' not validated');
          return;
        }
        console.log('Form validation added to: #' + $(this).attr('id'));

        // Inject the form-validator data to the form, if that wasn't already done before.
        data = $(this).data('form-validator');
        if (!data) {
          $(this).data('form-validator', (formValidator = new FormValidator($(this), options)));
        }

        // If options is not an object but a string then call a method and pass args.
        if (typeof options === 'string') {
          return formValidator[options].apply(formValidator, args);
        }
      });
    }
  });
})(window.jQuery, window);

$(function(){
  // Add the form validation on all existing forms. Doesn't execute it.
  $('form').addFormValidation();
});

About the other part, I asked a few month ago to be able to set if we want to have the :validate => true by default or not, see here: #18


Explanations about the script

The script must be loaded once Judge assets are loaded (after), it should be required as soon as possible in your page. In my project, the script is located at: app/assets/javascripts/v3/application/common/judge.js.

In itself, the script will, once the DOM is loaded (need jQuery library), bind to all forms specific events. Just injecting this script in your code is enough, forms will get automatic validation using Judge assets behind, it is a kind of API that hide the judge logic behind it, bind event to perform validation on form submit, input check and more (you'll have to read the code a bit to understand what exactly does each function, everything is documented).

All "private" functions start with a _, you shouldn't use directly these functions, but you can use the public one for specific use, even if you will probably not need to.

It is a standalone plugin, you don't need to write anything specific, excepted in some case (read the doc: uniqueness validation, case sensitive)

It is configured to use bootstrap 3 classes to apply styling, but you can setup your own css classes to apply: INPUT_ERROR_CLASS would be the class to apply to an input when this input failed to pass the validation while PARENT_INPUT_ERROR_CLASS is some kind of wrapper that contains the input itself (bootstrap 3 needs it, but again, customize it as you want to).

The default options are important:

FormValidator.prototype.defaultOptions = {
      // Performs live validation, on value change.
      live: {
        not_validated: true,
        valid: true,
        invalid: true
      }
    };

They will be used to generate the jQuery selectors to apply (here only on value change: live). It is to set how will react your program, because you may want to change it, even if the default setup makes sense and would be the most common.

But I agree with you, it's quite weird that the JS code isn't included in the gem itself, doesn't make sense.

where to put this file, can you please clarify ?

@joecorcoran It's a javascript asset, just load it as if you wrote it, so in your own assets, not in a gem.
Just make sure it's loaded after judje gem assets and before you need to perform any validation.

Really failed to implement your code, could you please give me step by step guide, and main point what this script actually does, sorry for this baby question, I'm new to rails that's why I'm stuck, also I didn't find any detailed tutorial on using this gem through out the web.

@coderawal I've updated the script itself with a newer version and added some explanation.
There aren't any tutorial I guess, mostly because Judge itself provide a non-friendly usable tools, the client implemenation is up to you (where/when/how you want to apply the validation), so I made mine that I decided to share to simplify the use of Judge itself.

I hope I can help you figure out how to use it.

Okay, here's some explanation about why Judge is the way it is. I purposely decoupled the form validation from any event bindings and HTML, because:

  1. This functionality needs to be usable without jQuery;
  2. It is impossible to know how people want to build their client-side behaviour.

I am of the opinion that any Ruby project should keep away from JavaScript and CSS as much as possible, because it just causes further problems when those parts of the project are not customisable enough. The issues list here would be 20x longer, with people asking for different DOM structures, different key bindings, etc. It's not possible to provide something that "just works" for everyone, so I decided to provide basics that are easy to build upon.

It's trivial to use Judge along with a couple of lines of jQuery to handle the event binding, as you can see in the examples in the README. I think it's really great that @Vadorequest has shared the script above and it will help people who are trying to achieve something similar. But to add that kind of specificity to the project wouldn't be right IMO.

I'm gonna close this because it's not really an issue with the project, but everyone should feel free to continue the conversation if it's helpful. 👍

Thanks a lot Vadorequest and joecorcoran, you guys really helped me clarifying the code and
judge-concept.

@joecorcoran I perfectly understand why you didn't provide the client side stuff, because indeed it really depends how you want to implement it and would likely depend very much on each project, it makes sense, I'm not saying that Judge isn't good enough.

So I decided to make my own, based on another one, which wasn't good enough for my use so I improved it, and this could not be enough for your use and you'd need to improve it. I won't update it anymore since I'm not even working with RoR anymore. So this is like a default helper that you can use and customize as you need to, just to save your time.