Christian Oestreich

   two guys in design - software.development.professional

Knockout Validation Rules Engine

| Comments

We have recently begun working with Knockout.js and needed a way to run validation on the models. We found the amazing plugin Knockout.Validation. This plugin did an awesome job and we were able to port over all our jQuery Validation rules fairly easy. As I was working with the plugin, my model grew and additional models were being created it became tedious having to append every rule to every model property and not remember which rules applied to which model properties across model contexts. Thus the concept of the Knockout Validation Rule Engine was born. A working example can be found via github pages

Getting Started

Download the latest knockout-rule-engine file.

Define a rule set that uses the parent key as the name of the model property you want to map to. If you wanted to set an email rule for a model with a property of userEmail, you would provide the following rule set.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
define(['knockout', 'knockout-rule-engine'], function (ko, RuleEngine) {
    var ruleSet = {
        userEmail: { email: true, required: true }
    };

    var ruleEngine = new RuleEngine(ruleSet);

    var model = {
        userEmail: ko.observable('')
    };

    ruleEngine.apply(model);

    ko.applyBindings(model, $('html')[0]);
});

This would be equivalent to the following code.

1
2
3
4
5
6
7
define(['knockout'], function (ko) {
    var model = {
        userEmail: ko.observable('').extend({email: true, required: true});
    };

    ko.applyBindings(model, $('html')[0]);
});

Override Knockout Validation Options

You can pass in the options you want to use for the knockout.validation library as the optional second param in the constructor. For example if you wanted to disable the validation plugin from auto inserting messages you would use the following.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
define(['knockout', 'knockout-rule-engine'], function (ko, RuleEngine) {
    var ruleSet = {
        userEmail: { email: true, required: true }
    };

    var ruleEngine = new RuleEngine(ruleSet, {insertMessages: false});

    var model = {
        userEmail: ko.observable('')
    };

    ruleEngine.apply(model);

    ko.applyBindings(model, $('html')[0]);
});

See Configuration Options for details on all the Knockout.Validation options.

Deep Mapping

By default the plugin will attempt to recurse your model tree and look at all properties and try and match rules against them. If you only want to apply rules to the first level object simply pass a flag with deep set to false in the options param.

1
2
3
4
define(["knockout", "knockout-rule-engine", "rules/address/rules"], function (ko, RuleEngine, personRules) {
    var ruleEngine = new RuleEngine(personRules, {deep: false});
    ... do work ...
});

Reusing rules

If you store your rules in a common directory and include them via require into your models you will ensure you have a common experience across your site. See main.js for more detailed examples.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
define(['filters/filters'], function (filters) {
    return {
        address1: {
            required: true,
            noSpecialChars: true,
            filter: [filters.noSpecialChars, filters.ltrim]
        },
        address2: {
            noSpecialChars: true,
            filter: [filters.noSpecialChars, filters.ltrim]
        },
        city: {
            required: true,
            noSpecialChars: true,
            filter: [filters.noSpecialChars, filters.ltrim]
        },
        state: {
            validSelectValue: {
                message: 'Please select a state.'
            }
        },
        zipCode: {
            required: true,
            validDigitLength: {
                params: 5,
                message: 'Please enter a valid zip code (XXXXX).'
            },
            filter: filters.onlyDigits
        },
        phone: {
            required: true,
            pattern: {
                message: 'Invalid phone number. (XXX-XXX-XXXX)',
                params: /^\D?(\d{3})\D?\D?(\d{3})\D?(\d{4})$/
            }
        }
    };
});

Then you can include this module named rules/address/rules.js into any model that has address or nested address properties that match the keys above (address1, address2, etc).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
define(["knockout", "knockout-rule-engine", "rules/address/rules"], function (ko, RuleEngine, personRules) {

    // set deep to false if you do not want to traverse child properties on the model
    // var ruleEngine = new RuleEngine(personRules, {deep: false});
    var ruleEngine = new RuleEngine(personRules);

    var PhoneModel = function () {
        return {
            phone: ko.observable('')
        };
    };

    var AddressModel = function () {
        return {
            address1: ko.observable(''),
            address2: ko.observable(''),
            city: ko.observable(''),
            state: ko.observable(''),
            zipCode: ko.observable(''),
            phone: new PhoneModel()
        };
    };

    var personModel = {
        firstName: ko.observable(''),
        lastName: ko.observable(''),
        middleName: ko.observable(''),
        address: new AddressModel()
    };

    // example of wiring a field at apply time
    ruleEngine.apply(personModel);

    ko.applyBindings(personModel, $('html')[0]);
});

Adding Validation Rules At Runtime

If you have already instantiated the RuleEngine and need to add a rule later at runtime you can do so via the addRule method.

1
2
3
4
5
6
ruleEngine.addRule('nameNotTom', {
    validator: function (val) {
        return val !== 'Tom';
    },
    message: 'Your name can not be Tom!'
});

Adding Rule Sets At Runtime

You can add additional rule sets to your model via the following code.

1
ruleEngine.addRuleSet('firstName', { nameNotTom: true });

This is extremely handy if you make use of the onlyIf clause in knockout.validation that depends on other model data. You can add these rules later and not have to inject your model into your rule definitions and keep the them clean.

1
2
3
4
5
6
7
8
9
10
11
12
13
var model = {
    firstName: ko.observable('');
    foo: ko.observable('');
}

//do other work

ruleEngine.addRuleSet('firstName', {
    nameNotTom: true,
    onlyIf: function(){
        return model.foo() === 'bar';
    }
});

Using The Filter Extender

It is pretty common that you must also filter the input of data on the knockout model via a form. This is an example filter extender that can be used in conjunction with the rules definitions as in the above example.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
ko.extenders.filter = function (target, filter) {
    var writeFilter = function (newValue) {
        var newValueAdjusted = (typeof filter === 'function') ? filter(newValue) : newValue;
        if ($.isArray(filter)) {
            $.each(filter, function (o) {
                if (typeof o === 'function') {
                    newValueAdjusted = o(newValueAdjusted);
                }
            });
        }
        var currentValue = target();
        if (newValueAdjusted !== currentValue) {
            target(newValueAdjusted);
        } else {
            if (newValue !== currentValue) {
                target.notifySubscribers(newValueAdjusted);
            }
        }
    };

    var result = ko.computed({
        read: target,
        write: writeFilter
    }).extend({ notify: 'always', throttle: 1 });

    result(target());

    target.subscribe(writeFilter);

    return target;
};

Global filters can be setup to be reused via something similar to the following. See Filters for more information.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
define(function () {
    return {
        ltrim: function (value) {
            return (typeof value === 'string') ? value.replace(/^\s+/, "") : value;
        },

        onlyDigits: function (value) {
            return (typeof value === 'string') ? value.replace(/[^0-9]/g, '') : value;
        },

        onlyAlpha: function (value) {
            return (typeof value === 'string') ? value.replace(/[^A-Za-z _\-']/g, '') : value;
        },

        noSpecialChars: function (value) {
            return (typeof value === 'string') ? value.replace(/[^\/A-Za-z0-9 '\.,#\-]*$/g, '') : value;
        }
    };
});

Without RequireJS

You can still include the plugin without require js. The plugin adds a global ko.RuleEngine singleton that you can instantiate. This is done in the Inline Tests.

1
2
3
4
5
6
<script src="../app/js/knockout-rule-engine.js"></script>
<script>
    var ruleSet = {firstName: {required: true, validName: true, filter: function(){}}};
    var ruleEngine = new ko.RuleEngine(ruleSet);
    ...
</script>

Comments