Working with ng-Model-Controller – Rhett Lowe – Directives



Working with ng-Model-Controller – Rhett Lowe – Directives

1 1


ngModelController-slides


On Github rhettl / ngModelController-slides

Working with ng-Model-Controller

Rhett Lowe

  • Work at i-Showcase, Inc.
  • AngularJS for 11+ months
  • GitHub
  • Google+

this is me. I am not in many places yet and I haven't been on the scene long, but I have been working hard and learning much.

Directives

For those who don't know

  • Encapsulated
  • DOM Manipulation
  • jQuery Goes Here
  • Create/Clean after itself

Examples

  • ngList
  • ngChange
  • ngSrc
  • ngRepeat

Directives

  • Should be self enclosed and should have as little communication in or out as possible
  • jQuery - If there was a place for jQuery in AngularJS, it is here. This is the best place for any form of DOM manipulation. Not in the controller
  • Directives should be in full control of its own instantiation and destruction.

Don't view your directive as a fine place to put repetitive HTML, instead view it as a way to teach HTML new tricks

Normal Usage

angular.module('testApp', []).directive('pagination', [function () {
    return {
        restrict: 'A',
        scope: {
            span: '=',
            current: '=',
            total: '=',
            nextPrev: '=?',
            firstLast: '=?',
            changePage: '&'
        },
        templateURL: "/partials/paginate.html",
        link: function(scope, element, attrs) {
            //Creation and DOM Manipulation data goes here.
        }
    };
}]);

Basic usage of a directive

I'd like to point out:

  • angular.directive -- registers the directive
  • Restrict -- limits where angular looks for this directive. i.e. Attribute, Element, Class, Comment
  • Scope -- create a child-scope to better encapsulate data
  • Template or templateUrl -- base for the dome before any manipulation
  • Link --
    • the main function
    • called to instantiate the new object
    • after the DOM is compiled
    • Where you place any events or watchers
    • Manipulation algorithms

Range Slider

<range-slider min="0" max="100" ng-model-low="low" ng-model-high="high"></range-slider>
angular.module('jQueryUI', []).directive('rangeSlider', [function() {
    var noop = angular.noop;

    return {
        restrict: "EA",
        replace: true,
        template: '<div class="slider"></div>',
        scope: {
            step: '@?',
            min: '@',
            max: '@',
            ngModelLow: '=',
            ngModelHigh: '=',
            stop: '&?',
            slide: '&?'
        },
        link: function(scope, elem, attrs) {
            var slider,
            externalChange = function(){
                slider.slider('values', [scope.ngModelLow, scope.ngModelHigh]);
            },
            setValues = function(values){
                values = checkLowHigh(values);
                scope.$apply(function(){
                    scope.ngModelLow = values[0];
                    scope.ngModelHigh = values[1];
                });
            },
            checkLowHigh = function(vals){
                if (typeof vals === 'undefined' || vals.length !== 2)
                    vals = [min, max];

                var low = vals[0], high = vals[1];

                if (high < low){
                    var temp = high;
                    high = low; low = temp;
                    bounceBack = true;
                }
                if (low < min) {
                    low = min;
                    bounceBack = true;
                }
                if (high > max) {
                    high = max;
                    bounceBack = true;
                }

                return [low, high];
            };

            scope.stop = scope.stop || noop;
            scope.slide = scope.slide || noop;

            scope.min = parseFloat(scope.min);
            scope.max = parseFloat(scope.max);

            scope.step = typeof scope.step !== 'undefined' ? scope.step : 1;
            scope.ngModelLow = typeof scope.ngModelLow !== 'undefined' ? scope.ngModelLow : scope.min;
            scope.ngModelHigh = typeof scope.ngModelHigh !== 'undefined' ? scope.ngModelHigh : scope.max;

            slider = elem.slider({
                animate: true,
                range: true,
                step: scope.step,
                min: scope.min,
                max: scope.max,
                values: [scope.ngModelLow, scope.ngModelHigh],
                slide: function(e, ui){
                    setValues(ui.values);

                    scope.slide({
                        values: ui.values,
                        low: ui.values[0],
                        high: ui.values[1]
                    });
                },
                stop: function(e, ui){
                    scope.stop({
                        values: ui.values,
                        low: ui.values[0],
                        high: ui.values[1]
                    });
                }
            });

            scope.$watch('ngModelLow', externalChange);
            scope.$watch('ngModelHigh', externalChange);
        }
    };
}]);

This is my first pass at a jQuery UI range slider.

A Few Problems:

  • Inefficient
  • Un-needed child scope
  • Bounce back
Would work fine if you only wanted to use a few of them on a page and don't care about the Inefficiencies.

Break it apart

(Complicated?) Child Scope scope.$watch() - ers While there are many things to point out in this I would like to mention 2:
  • There is a scope variable. This means it is creating a child scope. This is good and bad
  • We have to add Watchers to the Link function to listen to external changes

(Complicated?) Child Scope

angular.module('jQueryUI', []).directive('rangeSlider', [function() {
    var noop = angular.noop;
    
    return {
        restrict: "EA",
        scope: {
            step: '@?',
            min: '@',
            max: '@',
            ngModelLow: '=',
            ngModelHigh: '=',
            stop: '&?',
            slide: '&?'
        },
        link: function(scope, elem, attrs) {
            /*  Linking code Here  */
        }
    };
}]);

Child Scopes

Benefits
  • Easier to understand
  • Easier to read/write
  • Encapsulation
Detriments
  • More weight on memory
  • More weight on processor
  • Extra layer to Penetrate
  • More watchers

scope.$watch() - ers

slide: function(e, ui){
    setValues(ui.values);

    scope.slide({
        values: ui.values,
        low: ui.values[0],
        high: ui.values[1]
    });
}
/* ... */
setValues = function(values){
    console.log('internal', values);
    values = checkLowHigh(values);
    scope.$apply(function(){
        scope.ngModelLow = values[0];
        scope.ngModelHigh = values[1];
    });
}
externalChange = function(){
    console.log('external', [scope.ngModelLow, scope.ngModelHigh]);
    slider.slider('values', [scope.ngModelLow, scope.ngModelHigh]);
}
scope.$watch('ngModelLow', externalChange);
scope.$watch('ngModelHigh', externalChange);

Watchers and Bouncing

Ok, here is where we run into problems

Top

  • Inside the slider >> "Slide" function
  • Sliding triggers a function to update my model
  • I also trigger the exposed "Slide" function

Bottom

  • External Changes wont apply without watchers
  • When they occur, I update accordingly

This creates 2 way binding.

  • Slider >> Model
  • Model >> Slider

Problems

Slider>>Model
  • Slider Changes
  • setValues() is triggered
  • 'internal' is logged
Model>>Slider
  • Code changes model
  • Model triggers watcher triggering externalChange()
  • 'external' is logged
  • Slider updates
  • setValues() is triggered
  • 'internal' is logged
double update aka bouncing

Using ng-Model-Controller

  • functions for 2-directional updating
  • Parsers and Formatters
  • $pristine, $error, and other form validations
  • Cannot use additional sub-scope
  • ngModel gives you functions to control both directions of updates: i.e. model>>view/slider and slider/view>>model
  • Allows for a stack change functions in both directions: $formatters and $parsers
  • Allows for control over Error, Pristine, Required, Valid, Etc.
  • No sub-scope -- Might be seen as a downside, but help in memory and I quite like it after I got used to it.
angular.module('jQueryUI', []).directive('rangeSlider', [function() {
    var noop = angular.noop;

    return {
        restrict: "EA",
        require: 'ngModel',
        link: function(scope, elem, attrs, ngModel) {
            if (!ngModel
                    || typeof attrs.min === 'undefined'
                    || typeof attrs.max === 'undefined'){
                return;
            }

            var slider, bounceBack = false,
            step = scope.$eval(attrs.step) || 1,
            min = scope.$eval(attrs.min),
            max = scope.$eval(attrs.max),
            checkLowHigh = function(vals){
                if (typeof vals === 'undefined' || vals.length !== 2)
                    vals = [min, max];

                var low = vals[0], high = vals[1];

                if (high < low){
                    var temp = high;
                    high = low; low = temp;
                    bounceBack = true;
                }
                if (low < min) {
                    low = min;
                    bounceBack = true;
                }
                if (high > max) {
                    high = max;
                    bounceBack = true;
                }

                return [low, high];
            };


            //ngModel.$parsers.push(checkLowHigh);
            ngModel.$formatters.push(checkLowHigh);

            slider = elem.slider({
                animate: true,
                range: true,
                step: step,
                min: min,
                max: max,
                values: ngModel.$viewValue,
                slide: function(e, ui){
                    scope.$apply(function(){
                        ngModel.$setViewValue(ui.values);
                    });
                },
                stop: function(e, ui){
                    scope.$apply(function(){
                        scope.$eval(attrs.ngStop, {
                            values: ui.values,
                            low: ui.values[0],
                            high: ui.values[1]
                        });
                    });
                }
            });

            ngModel.$render = function(){
                if (bounceBack) {
                    bounceBack = false;
                    ngModel.$setViewValue(ngModel.$viewValue);
                }
                slider.slider('values', ngModel.$viewValue);
            };
        }
    };
}]);

New version

This is the new version of this directive. It utilizes the basics of ngModelController. 77 lines vs 91 Lines for those that care

Breaking it apart

Getting the ng-Model-Controller

angular.module('jQueryUI', []).directive('rangeSlider', [function() {
    var noop = angular.noop;

    return {
        restrict: "EA",
        require: 'ngModel',
        link: function(scope, elem, attrs, ngModel) {
            if (!ngModel
                    || typeof attrs.min === 'undefined'
                    || typeof attrs.max === 'undefined'){
                return;
            }
            /* ... */
        }
    }
}]);

Including the controller

  • require: "ngModel" -- This will add ngModel to the list of provided controllers
  • Add ngModel to the linking function.
  • Now you can start adding functions and checking data
  • I saw this if statement somewhere and liked it. I find that it helps to make dead sure I have all requirements

2-directional updating

2-directional updating

slider = elem.slider({
    /* ... */
    values: ngModel.$viewValue,
    slide: function(e, ui){
        scope.$apply(function(){
            ngModel.$setViewValue(ui.values);
        });
    }
    /* ... */
});
ngModel.$render = function(){
    /* ... */

    slider.slider('values', ngModel.$viewValue);
};

Parsers and Formatters

Modify and/or control the data comming in and going out of the ngModel

Parsers

  • "Displayable" >> "Data"
  • Call $setViewValue(val)
  • Saves new $viewValue
  • Input[n] = Output[n+1]
  • Check correctness of data
  • Outputs in $modelValue

Formatters

  • "Data" >> "Displayable"
  • $modelValue changes
  • Input[n] = Output[n-1]
  • Check correctness of data
  • Finalizes with $render()
  • $render is an interface

Example Formatting Function

var checkLowHigh = function(vals){
    if (typeof vals === 'undefined' || vals.length !== 2)
        vals = [min, max];

    var low = vals[0], high = vals[1];

    if (high < low){
        var temp = high;
        high = low; low = temp;
    }
    if (low < min) {
        low = min;
    }
    if (high > max) {
        high = max;
    }

    return [low, high];
};

//ngModel.$parsers.unshift(checkLowHigh);
ngModel.$formatters.push(checkLowHigh);

Recap

  • model.$viewValue = ''
  • model.$modelValue = 0
  •  
  • model.$parsers = []
  • model.$formatters = []
  •  
  • model.$setViewValue()
  • model.$render()
  • view value is the value that the directive reads
  • Model value holds the data that the rest of angular reads
  • parsers are for converting view >> model
  • formatters are for converting model >> view
  • Set View Value starts the view >> model chain
  • Render is an interface and ends the model >> view chain

Forms Functionality

  • $valid / $invalid
  • $pristine / $dirty
  • $error
  • $isEmpty

Angular Core Examples

  • ngList
  • ngChange
  • required

ngList

var ngListDirective = function() {
    return {
        require: 'ngModel',
        link: function(scope, element, attr, ctrl) {
            var match = /\/(.*)\//.exec(attr.ngList),
            separator = match && new RegExp(match[1])
                            || attr.ngList || ',';

            var parse = function(viewValue) {
                // If the viewValue is invalid (say required
                //   but empty) it will be `undefined`
                if (isUndefined(viewValue)) return;

                var list = [];

                if (viewValue) {
                    forEach(viewValue.split(separator), function(value) {
                        if (value) list.push(trim(value));
                    });
                }

                return list;
            };

            ctrl.$parsers.push(parse);
            ctrl.$formatters.push(function(value) {
                if (isArray(value)) {
                    return value.join(', ');
                }

                return undefined;
            });

            // Override the standard $isEmpty because an empty
            //      array means the input is empty.
            ctrl.$isEmpty = function(value) {
                return !value || !value.length;
            };
        }
    };
};
View in Github

ng-List

From the angular source -- I recommend reading the source from time to time.

  • Notice it requires ngModel and accesses throguh the controllers
  • it creates a parser and a formatter
    • the parser is splitting and returning an array
    • the formatter is joining and returning a string
  • no need for a $render() since it is using a standard text input and text inputs have a pre-set $render function.
  • $isEmpty is used in the form validation; obvious how it is set here.

ng-Change

var ngChangeDirective = valueFn({
    require: 'ngModel',
    link: function(scope, element, attr, ctrl) {
        ctrl.$viewChangeListeners.push(function() {
            scope.$eval(attr.ngChange);
        });
    }
});
View in Github

ngChange

Also from the angular source.

  • Short --- and I mean SHORT
  • I hope you can all see why ngChange doesn't work without ngModel and ngModelController
  • $viewChangeListeners is a set of listeners that fire just after setting the $modelValue IF modelValue changes.

Personal Example

ngDollar

ng-Dollar

var ngDollarDirective = ['$timeout', function($timeout) {
  return {
    restrict: 'A',
    require: 'ngModel',
    link: function(scope, elem, attrs, ngModel) {
        var deci = '.',
            comma = ',',
            deciPlaces = 2,
            curSign = '$',
            keyTimeoutPromise,
            addFormat = function(n) {
                var sign = curSign,
                c = isNaN(c = Math.abs(deciPlaces)) ? 2 : deciPlaces,
                d = deci ? deci : ".",
                t = comma ? comma : ",",
                s = n < 0 ? "-" : "",
                i = parseInt(n = Math.abs(+n || 0).toFixed(c)) + "",
                j = (j = i.length) > 3 ? j % 3 : 0;
                return s + sign + (j ? i.substr(0, j) + t : "")
                    + i.substr(j).replace(/(\d{3})(?=\d)/g, "$1" + t)
                    + (c ? d + Math.abs(n - i).toFixed(c).slice(2) : "");
            },
            removeFormat = function(n) {
                return parseFloat(n.replace(/[^0-9\.]/gi, '')) || 0;
            },
            refreshElem = function() {
                ngModel.$setViewValue(elem.val());
                elem.val(addFormat(removeFormat(elem.val())));
            };

        ngModel.$parsers.push(removeFormat);
        ngModel.$formatters.push(addFormat);

        elem.bind('keyup blur', function(e) {
            if (keyTimeoutPromise) {
                $timeout.cancel(keyTimeoutPromise);
                keyTimeoutPromise = null;
            }

            var commit = (e.type === 'blur'
                    || (e.type === 'keyup' && e.keyCode === 13));

            if (commit) {
                refreshElem();
            } else {
                keyTimeoutPromise = $timeout(refreshElem, 1200);
            }
        });

        //ngModel.$render = function(){
        //    elem.val(ngModel.$viewValue);
        //};
    }
  };
}];

Code included in js/iShowUI.js

ngDollar

This was intended to be an input that would always be formatted as a currency but would hold a number in the model.

  • Require: ngModel
  • Include in Linking function
  • Holds a timeout promise to prevent formatting while user is typing
  • Parser to convert from string to number
  • Formatter to convert from number to string
  • set up to auto formate any strings entered without formatting
  • Found I didn't need a $render() function since text fields have one already.

Online

github.com/rhettl/ngModelController-slides