6
\$\begingroup\$

I've just released a jQuery plugin that conditionally displays elements based on form values. I would appreciate any suggestions on how to improve both the code and its usefulness.

Here's the demo page

  1. Would this be useful to you?
  2. What other functionality should I include?
  3. Any refactoring or structure changes that I should make?
  4. I am worried about exposing 4 different functions. Is this too many?

The Goal

The plugin helps in situations where an element should hide or show based on values in other elements. In particular, it excels when there are multiple conditions that need to be satisfied in order to hide or show.

Additional concepts to keep in mind

  • Use as jQuery plugin
  • Easy to chain rules together
  • Understandable by reading a line of code
  • Can build/add custom conditions

Example of business rules to solve

Let's say we have the following rules for elements:

  • Display a set of checkboxes if

    1. Zip is between 19000 and 20000
    2. Income is lower than 15000
  • Display another set of checkboxes if

    1. City is 'Philadelphia'
    2. Income is lower than 40000
  • Display city select box if

    1. Zip is between 19100 and 19400

Ideal look

$('.elements_to_display')
   .reactIf( '#some_form_element', SatisfiesFirstCondition)
   .reactIf( '#some_other_form_element', SatisfiesAnotherCondition)
   .reactIf( '#some_other_form_element', SatisfiesAnotherCondition);

Page JS

var IS = $.extend({}, $.fn.reactor.helpers);

$('.cities')    
    .reactIf('#zip', IS.Between(19100, 19400))
    .reactIf('#zip', IS.NotBlank);

$('.philly_middle_to_low_income')
    .reactIf('#income_2011', IS.LessThan(40000))
    .reactIf('#cities_select', IS.EqualTo('philadelphia'));

$('.low_income_select_zips')
    .reactIf('#income_2011', IS.LessThan(15000))
    .reactIf('#zip', IS.BetweenSameLength(19000, 20000))
    .reactIf('#zip', IS.NotBlank);

$('.reactor').trigger('change.reactor');

Plugin react.js

(function($){

    $.fn.reactTo = function(selector) {
        var $elements = $(selector),
            $reactor_element = $(this),
            _proxy_event = function() {
                $reactor_element.trigger('change.reactor');
            };

        $elements.filter('select').bind('change.reactor', _proxy_event);
        $elements.filter('input').bind('keyup.reactor', _proxy_event);  

        return this;
    };

    $.fn.reactIf = function(sel, exp_func) {

        var $sel = $(sel);
        var _func = function() {
            return exp_func.apply($sel);                               
        };

        this.each(function() {
            if (!$(this).hasClass('reactor')) { $(this).reactor(); }

            var conditions_arry = $(this).data('conditions.reactor');
            if (!$.isArray(conditions_arry)) { conditions_arry = []};

            conditions_arry.push(_func);

            $(this).data('conditions.reactor', conditions_arry);
        }); 

        $(this).reactTo(sel);

        return this;
    };

    $.fn.react = function() {

        this.each(function() {
           $(this).trigger('change.reactor')
        }); 

        return this;
    };

    $.fn.reactor = function(options) {
        var settings = $.extend({}, $.fn.reactor.defaults, options);

        this.each(function() {
            // var opts = $.meta ? $.extend({}, settings, $this.data()) : settings;

            var $element = $(this);

            if (!$element.hasClass('reactor')) { $element.data('conditions.reactor', []).addClass('reactor'); }

            var is_reactionary = function() {  
                var conditionalArray = $(this).data('conditions.reactor'); 
                var r = true;

                $.each(conditionalArray, function() {
                    r = (r && this.call());
                });



                return r;                                
            }

            var reaction = function(evt) {
                evt.stopPropagation();
                if (is_reactionary.apply(this)) {
                   settings.compliant.apply($element);
                } else {
                   settings.uncompliant.apply($element);
                }
            }

            $element.bind('change.reactor', reaction);
        });

        return this;  
    };

    $.fn.reactor.defaults = {
        compliant: function() {
            $(this).show();
        },
        uncompliant: function() {
            $(this).hide();    
        }        
    };

    $.fn.reactor.helpers = {

        NotBlank: function() {
            return( $(this).val().toString() != "" )
        },

        Blank: function() {
            return( $(this).val().toString() == "" )
        },

        EqualTo: function(matchStr) {
            var _func = function() { 
                var v = $(this).val();
                if (v) { return( v.toString() == matchStr ); } 
                else { return false; }
            } 
            return _func;
        },

        LessThan: function(number) {
            var _func = function() {
                var v = $(this).val();
                return(!(v && parseInt(v) > number));
            }
            return _func;   
        },

        MoreThan: function(number) {
            var _func = function() {
                var v = $(this).val();
                return(!(v && parseInt(v) < number));
            }
            return _func;  
        },

        Between: function(min, max) {
            var _func = function() {
                var v = $(this).val();
                return(!(v && (parseInt(v) > max || parseInt(v) < min)));
            }
            return _func;
        },

        BetweenSameLength: function(min, max) {
            var len = min.toString().length;
            var _func = function() {
                var v = $(this).val();
                return(!(v && v.length == len && (parseInt(v) > max || parseInt(v) < min)));
            }
            return _func;
        }
    };

})(jQuery);

HTML react.html

<form id="portfolio_form">
    <fieldset>

        <label>Zip</label>
        <input id="zip" type="text" value="" /><br />

        <label>2011 Income</label>
        <input id="income_2011" name="income[2011]" />

    </fieldset>  

    <p>Display cities only when zip is between 19100 and 19400</p>

    <fieldset class="cities">
        <label>Cities</label>
        <select id="cities_select">
            <option value=""></option>
            <option value="philadelphia">Philadelphia</option>
            <option value="media">Media</option>
            <option value="doylestown">Doylestown</option>
        </select>    
    </fieldset>

    <p>Display checkboxes only for Philadelphia and income less than 40000</p>
    <fieldset class="philly_middle_to_low_income">
        <input type="checkbox" /> Check One<br />
        <input type="checkbox" /> Check Two<br />
        <input type="checkbox" /> Check Three<br />
        <input type="checkbox" /> Check Four<br />
    </fieldset>   

   <p>Display checkboxes when zip is between 19000 and 20000 and income is lower than 25000</p>
   <fieldset class="low_income_select_zips">
        <input type="checkbox" /> Check One<br />
        <input type="checkbox" /> Check Two<br />
        <input type="checkbox" /> Check Three<br />
        <input type="checkbox" /> Check Four<br />
    </fieldset>    

</form>
\$\endgroup\$
  • \$\begingroup\$ looks nice! i would prefer to call it like this: $('.elements_to_display').reactIf( $("div") , SatisfiesFirstCondition) if you can provide a jquery element directly, you can be more flexible on the way to specify the element that you are dependent on. $('.elements_to_display').reactIf( $("div").parents(0) , SatisfiesFirstCondition) \$\endgroup\$ – meo Jun 28 '11 at 20:32
  • \$\begingroup\$ @meo - yup that first argument can be a selector or a jquery object, so either will work. \$\endgroup\$ – natedavisolds Jun 28 '11 at 21:15
5
\$\begingroup\$
  • The syntax is nice, but the necessity for the user to declare IS themselves is not ideal. You should look for a different solution. One possibility could be to supply the name of the conditional function as a string and its arguments as additional arguments of reactIf. That way the conditional functions would no longer need to be of higher-order (not that that is a bad thing). Example:

    $('.cities').reactIf('#zip', "Between", 19100, 19400);
    
    // ...
    $.fn.reactIf = function(sel, exp_func) {
    
        var $sel = $(sel);
        var args = arguments.slice(2);
        var _func = function() {
            return $.fn.reactor.helpers[exp_func].apply($sel, args);                               
        };
    
        // ...
    }
    
    $.fn.reactor.helpers = {
      // ...
      Between: function(min, max) {
        var v = $(this).val();
         return(!(v && (parseInt(v) > max || parseInt(v) < min)));
      },
      // ...
    }
    
  • These is one more problem with the conditional functions: You supply a jQuery object as the this argument toapply, so it's not needed to wrap this in another jQuery call inside the conditional functions. You should either change to apply call to:

    return exp_func.apply($sel[0]);
    

    or in the conditional functions:

    var v = this.val();
    
  • I'm not sure if it's a good idea to mark elements with a class. This can go wrong, for example, if a second JavaScript removes all classes from an element. Instead of

     if (!$(this).hasClass('reactor')) { $(this).reactor(); }
    
     var conditions_arry = $(this).data('conditions.reactor');
     if (!$.isArray(conditions_arry)) { conditions_arry = []};
    

    I would use

     var conditions_arry = $(this).data('conditions.reactor');
     if (!$.isArray(conditions_arry)) {
       $(this).reactor();
       conditions_arry = [];
     };
    

    and similarly in reactor().

  • You should consider short-circuiting the $.each() loop calling the conditional functions (which also makes the && unnecessary):

      $.each(conditionalArray, function() {
        r = this.call();
        return r; // Stops the `each` loop if r is `false`
      });
    
\$\endgroup\$
  • \$\begingroup\$ Thanks for your feedback. Very nice insights. I've incorporated all these into the plugin except for the first. I will probably check the type of the second argument. If it is string then do as you've suggested. If a function then I will just add it like I've done. The "IS" variable isn't required at all. I just put it in because it looks better. All of your suggestions made it in, can I give you credit in the plugin code? \$\endgroup\$ – natedavisolds Jun 28 '11 at 16:50
  • \$\begingroup\$ You are welcome. I just saw one more thing: You can simplify this.filter(...).length > 0 to this.is(...). \$\endgroup\$ – RoToRa Jun 29 '11 at 12:27

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.