diff --git a/docs/content/guide/decorators.ngdoc b/docs/content/guide/decorators.ngdoc new file mode 100644 index 000000000000..5422f6d32dc5 --- /dev/null +++ b/docs/content/guide/decorators.ngdoc @@ -0,0 +1,486 @@ +@ngdoc overview +@name Decorators +@sortOrder 345 +@description + +# Decorators in AngularJS + +
+ **NOTE:** This guide is targeted towards developers who are already familiar with AngularJS basics. + If you're just getting started, we recommend the {@link tutorial/ tutorial} first. +
+ +## What are decorators? + +Decorators are a design pattern that is used to separate modification or *decoration* of a class without modifying the +original source code. In Angular, decorators are functions that allow a service, directive or filter to be modified +prior to its usage. + +## How to use decorators + +There are two ways to register decorators + +- `$provide.decorator`, and +- `module.decorator` + +Each provide access to a `$delegate`, which is the instantiated service/directive/filter, prior to being passed to the +service that required it. + +### $provide.decorator + +The {@link api/auto/service/$provide#decorator decorator function} allows access to a $delegate of the service once it +has been instantiated. For example: + +```js +angular.module('myApp', []) + +.config([ '$provide', function($provide) { + + $provide.decorator('$log', [ + '$delegate', + function $logDecorator($delegate) { + + var originalWarn = $delegate.warn; + $delegate.warn = function decoratedWarn(msg) { + msg = 'Decorated Warn: ' + msg; + originalWarn.apply($delegate, arguments); + }; + + return $delegate; + } + ]); +}]); +``` + +After the `$log` service has been instantiated the decorator is fired. The decorator function has a `$delegate` object +injected to provide access to the service that matches the selector in the decorator. This `$delegate` will be the +service you are decorating. The return value of the function *provided to the decorator* will take place of the service, +directive, or filter being decorated. + +
+ +The `$delegate` may be either modified or completely replaced. Given a service `myService` with a method `someFn`, the +following could all be viable solutions: + + +#### Completely Replace the $delegate +```js +angular.module('myApp', []) + +.config([ '$provide', function($provide) { + + $provide.decorator('myService', [ + '$delegate', + function myServiceDecorator($delegate) { + + var myDecoratedService = { + // new service object to replace myService + }; + return myDecoratedService; + } + ]); +}]); +``` + +#### Patch the $delegate +```js +angular.module('myApp', []) + +.config([ '$provide', function($provide) { + + $provide.decorator('myService', [ + '$delegate', + function myServiceDecorator($delegate) { + + var someFn = $delegate.someFn; + + function aNewFn() { + // new service function + someFn.apply($delegate, arguments); + } + + $delegate.someFn = aNewFn; + return $delegate; + } + ]); +}]); +``` + +#### Augment the $delegate +```js +angular.module('myApp', []) + +.config([ '$provide', function($provide) { + + $provide.decorator('myService', [ + '$delegate', + function myServiceDecorator($delegate) { + + function helperFn() { + // an additional fn to add to the service + } + + $delegate.aHelpfulAddition = helperFn; + return $delegate; + } + ]); +}]); +``` + +
+ Note that whatever is returned by the decorator function will replace that which is being decorated. For example, a + missing return statement will wipe out the entire object being decorated. +
+ +
+ +Decorators have different rules for different services. This is because services are registered in different ways. +Services are selected by name, however filters and directives are selected by appending `"Filter"` or `"Directive"` to +the end of the name. The `$delegate` provided is dictated by the type of service. + +| Service Type | Selector | $delegate | +|--------------|-------------------------------|-----------------------------------------------------------------------| +| Service | `serviceName` | The `object` or `function` returned by the service | +| Directive | `directiveName + 'Directive'` | An `Array.`{@link guide/decorators#drtvArray 1} | +| Filter | `filterName + 'Filter'` | The `function` returned by the filter | + +1. Multiple directives may be registered to the same selector/name + +
+ **NOTE:** Developers should take care in how and why they are modifying the `$delegate` for the service. Not only + should expectations for the consumer be kept, but some functionality (such as directive registration) does not take + place after decoration, but during creation/registration of the original service. This means, for example, that + an action such as pushing a directive object to a directive `$delegate` will likely result in unexpected behavior. + + Furthermore, great care should be taken when decorating core services, directives, or filters as this may unexpectedly + or adversely affect the functionality of the framework. +
+ +### module.decorator + +This {@link api/ng/type/angular.Module#decorator function} is the same as the `$provide.decorator` function except it is +exposed through the module API. This allows you to separate your decorator patterns from your module config blocks. The +main caveat here is that you will need to take note the order in which you create your decorators. + +Unlike in the module config block (which allows configuration of services prior to their creation), the service must be +registered prior to the decorator (see {@link guide/providers#provider-recipe Provider Recipe}). For example, the +following would not work because you are attempting to decorate outside of the configuration phase and the service +hasn't been created yet: + +```js +// will cause an error since 'someService' hasn't been registered +angular.module('myApp').decorator('someService', ...); + +angular.module('myApp').factory('someService', ...); +``` + +## Example Applications + +The following sections provide examples each of a service decorator, a directive decorator, and a filter decorator. + +### Service Decorator Example + +This example shows how we can replace the $log service with our own to display log messages. + + + + angular.module('myServiceDecorator', []). + + controller('Ctrl', [ + '$scope', + '$log', + '$timeout', + function($scope, $log, $timeout) { + var types = ['error', 'warn', 'log', 'info' ,'debug'], i; + + for (i = 0; i < types.length; i++) { + $log[types[i]](types[i] + ': message ' + (i + 1)); + } + + $timeout(function() { + $log.info('info: message logged in timeout'); + }); + } + ]). + + directive('myLog', [ + '$log', + function($log) { + return { + restrict: 'E', + template: '
  • {{l.message}}
', + scope: {}, + compile: function() { + return function(scope) { + scope.myLog = $log.stack; + }; + } + }; + } + ]). + + config([ + '$provide', + function($provide) { + + $provide.decorator('$log', [ + '$delegate', + function logDecorator($delegate) { + + var myLog = { + warn: function(msg) { + log(msg, 'warn'); + }, + error: function(msg) { + log(msg, 'error'); + }, + info: function(msg) { + log(msg, 'info'); + }, + debug: function(msg) { + log(msg, 'debug'); + }, + log: function(msg) { + log(msg, 'log'); + }, + stack: [] + }; + + function log(msg, type) { + myLog.stack.push({ type: type, message: msg.toString() }); + if (console && console[type]) console[type](msg); + } + + return myLog; + + } + ]); + + } + ]); +
+ + +
+

Logs

+ +
+
+ + + li.warn { color: yellow; } + li.error { color: red; } + li.info { color: blue } + li.log { color: black } + li.debug { color: green } + + + + it('should display log messages in dom', function() { + element.all(by.repeater('l in myLog')).count().then(function(count) { + expect(count).toEqual(6); + }); + }); + +
+ +### Directive Decorator Example + +Failed interpolated expressions in `ng-href` attributes can easily go unnoticed. We can decorate `ngHref` to warn us of +those conditions. + + + + angular.module('urlDecorator', []). + + controller('Ctrl', ['$scope', function ($scope) { + $scope.id = 3; + $scope.warnCount = 0; // for testing + }]). + + config(['$provide', function($provide) { + + // matchExpressions looks for interpolation markup in the directive attribute, extracts the expressions + // from that markup (if they exist) and returns an array of those expressions + function matchExpressions(str) { + var exps = str.match(/{{([^}]+)}}/g); + + // if there isn't any, get out of here + if (exps === null) return; + + exps = exps.map(function(exp) { + var prop = exp.match(/[^{}]+/); + return prop === null ? null : prop[0]; + }); + + return exps; + } + + // remember: directives must be selected by appending 'Directive' to the directive selector + $provide.decorator('ngHrefDirective', [ + '$delegate', + '$log', + '$parse', + function($delegate, $log, $parse) { + + // store the original link fn + var originalLinkFn = $delegate[0].link; + + // replace the compile fn + $delegate[0].compile = function(tElem, tAttr) { + + // store the original exp in the directive attribute for our warning message + var originalExp = tAttr.ngHref; + + // get the interpolated expressions + var exps = matchExpressions(originalExp); + + // create and store the getters using $parse + var getters = exps.map(function(el) { + if (el) return $parse(el); + }); + + return function newLinkFn(scope, elem, attr) { + // fire the originalLinkFn + originalLinkFn.apply($delegate[0], arguments); + + // observe the directive attr and check the expressions + attr.$observe('ngHref', function(val) { + + // if we have getters and getters is an array... + if (getters && angular.isArray(getters)) { + + // loop through the getters and process them + angular.forEach(getters, function(g, idx) { + + // if val is truthy, then the warning won't log + var val = angular.isFunction(g) ? g(scope) : true; + if (!val) { + $log.warn('NgHref Warning: "' + exps[idx] + '" in the expression "' + originalExp + + '" is falsy!'); + + scope.warnCount++; // for testing + } + + }); + + } + + }); + + }; + + }; + + // get rid of the old link function since we return a link function in compile + delete $delegate[0].link; + + // return the $delegate + return $delegate; + + } + + ]); + + }]); + + + +
+ View Product {{ id }} + - id == 3, so no warning
+ View Product {{ id + 5 }} + - id + 5 == 8, so no warning
+ View Product {{ someOtherId }} + - someOtherId == undefined, so warn
+ View Product {{ someOtherId + 5 }} + - someOtherId + 5 == 5, so no warning
+
Warn Count: {{ warnCount }}
+
+
+ + + it('should warn when an expression in the interpolated value is falsy', function() { + var id3 = element(by.id('id3')); + var id8 = element(by.id('id8')); + var someOther = element(by.id('someOtherId')); + var someOther5 = element(by.id('someOtherId5')); + + expect(id3.getText()).toEqual('View Product 3'); + expect(id3.getAttribute('href')).toContain('/products/3/view'); + + expect(id8.getText()).toEqual('View Product 8'); + expect(id8.getAttribute('href')).toContain('/products/8/view'); + + expect(someOther.getText()).toEqual('View Product'); + expect(someOther.getAttribute('href')).toContain('/products//view'); + + expect(someOther5.getText()).toEqual('View Product 5'); + expect(someOther5.getAttribute('href')).toContain('/products/5/view'); + + expect(element(by.binding('warnCount')).getText()).toEqual('Warn Count: 1'); + }); + +
+ +### Filter Decorator Example + +Let's say we have created an app that uses the default format for many of our `Date` filters. Suddenly requirements have +changed (that never happens) and we need all of our default dates to be `'shortDate'` instead of `'mediumDate'`. + + + + angular.module('filterDecorator', []). + + controller('Ctrl', ['$scope', function ($scope) { + $scope.genesis = new Date(2010, 0, 5); + $scope.ngConf = new Date(2016, 4, 4); + }]). + + config(['$provide', function($provide) { + + $provide.decorator('dateFilter', [ + '$delegate', + function dateDecorator($delegate) { + + // store the original filter + var originalFilter = $delegate; + + // return our filter + return shortDateDefault; + + // shortDateDefault sets the format to shortDate if it is falsy + function shortDateDefault(date, format, timezone) { + if (!format) format = 'shortDate'; + + // return the result of the original filter + return originalFilter(date, format, timezone); + } + + } + + ]); + + }]); + + + +
+
Initial Commit default to short date: {{ genesis | date }}
+
ng-conf 2016 default short date: {{ ngConf | date }}
+
ng-conf 2016 with full date format: {{ ngConf | date:'fullDate' }}
+
+
+ + + it('should default date filter to short date format', function() { + expect(element(by.id('genesis')).getText()) + .toMatch(/Initial Commit default to short date: \d{1,2}\/\d{1,2}\/\d{2}/); + }); + + it('should still allow dates to be formatted', function() { + expect(element(by.id('ngConf')).getText()) + .toMatch(/ng-conf 2016 with full date format\: [A-Za-z]+, [A-Za-z]+ \d{1,2}, \d{4}/); + }); + +