Skip to content

Commit

Permalink
Merge pull request civicrm#11328 from colemanw/CRM-21483
Browse files Browse the repository at this point in the history
CRM-21483 - Move anguar module crmRouteBinder to core
  • Loading branch information
colemanw authored and sluc23 committed Jan 10, 2018
2 parents b0d8da1 + 16c45b9 commit 19ef530
Show file tree
Hide file tree
Showing 4 changed files with 212 additions and 0 deletions.
1 change: 1 addition & 0 deletions Civi/Angular/Manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ public function getModules() {
$angularModules['crmCxn'] = include "$civicrm_root/ang/crmCxn.ang.php";
// $angularModules['crmExample'] = include "$civicrm_root/ang/crmExample.ang.php";
$angularModules['crmResource'] = include "$civicrm_root/ang/crmResource.ang.php";
$angularModules['crmRouteBinder'] = include "$civicrm_root/ang/crmRouteBinder.ang.php";
$angularModules['crmUi'] = include "$civicrm_root/ang/crmUi.ang.php";
$angularModules['crmUtil'] = include "$civicrm_root/ang/crmUtil.ang.php";
$angularModules['dialogService'] = include "$civicrm_root/ang/dialogService.ang.php";
Expand Down
12 changes: 12 additions & 0 deletions ang/crmRouteBinder.ang.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php
// This file declares an Angular module which can be autoloaded
// in CiviCRM. See also:
// http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules

return array(
'ext' => 'civicrm',
'js' => array('ang/crmRouteBinder.js'),
'css' => array(),
'partials' => array(),
'requires' => array('ngRoute'),
);
95 changes: 95 additions & 0 deletions ang/crmRouteBinder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
(function(angular, $, _) {
angular.module('crmRouteBinder', CRM.angRequires('crmRouteBinder'));

// While processing a change from the $watch()'d data, we set the "pendingUpdates" flag
// so that automated URL changes don't cause a reload.
var pendingUpdates = null, activeTimer = null, registered = false, ignorable = {};

function registerGlobalListener($injector) {
if (registered) return;
registered = true;

$injector.get('$rootScope').$on('$routeUpdate', function () {
// Only reload if someone else -- like the user or an <a href> -- changed URL.
if (null === pendingUpdates) {
$injector.get('$route').reload();
}
});
}

var formats = {
json: {
watcher: '$watchCollection',
decode: angular.fromJson,
encode: angular.toJson,
default: {}
},
raw: {
watcher: '$watch',
decode: function(v) { return v; },
encode: function(v) { return v; },
default: ''
},
int: {
watcher: '$watch',
decode: function(v) { return parseInt(v); },
encode: function(v) { return v; },
default: 0
},
bool: {
watcher: '$watch',
decode: function(v) { return v === '1'; },
encode: function(v) { return v ? '1' : '0'; },
default: false
}
};

angular.module('crmRouteBinder').config(function ($provide) {
$provide.decorator('$rootScope', function ($delegate, $injector, $parse) {
Object.getPrototypeOf($delegate).$bindToRoute = function (options) {
registerGlobalListener($injector);

options.format = options.format || 'json';
var fmt = formats[options.format];
if (options.default === undefined) {
options.default = fmt.default;
}
var _scope = this;

var $route = $injector.get('$route'), $timeout = $injector.get('$timeout');

var value;
if (options.param in $route.current.params) {
value = fmt.decode($route.current.params[options.param]);
}
else {
value = _.isObject(options.default) ? angular.extend({}, options.default) : options.default;
ignorable[options.param] = fmt.encode(options.default);
}
$parse(options.expr).assign(_scope, value);

// Keep the URL bar up-to-date.
_scope[fmt.watcher](options.expr, function (newValue) {
var encValue = fmt.encode(newValue);
if ($route.current.params[options.param] === encValue) return;

pendingUpdates = pendingUpdates || {};
pendingUpdates[options.param] = encValue;
var p = angular.extend({}, $route.current.params, pendingUpdates);
angular.forEach(ignorable, function(v,k){ if (p[k] === v) delete p[k]; });
$route.updateParams(p);

if (activeTimer) $timeout.cancel(activeTimer);
activeTimer = $timeout(function () {
pendingUpdates = null;
activeTimer = null;
ignorable = {};
}, 50);
});
};

return $delegate;
});
});

})(angular, CRM.$, CRM._);
104 changes: 104 additions & 0 deletions ang/crmRouteBinder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# crmRouteBinder

Live-update the URL to stay in sync with controller data.

## Example

```js
angular.module('sandbox').config(function($routeProvider) {
$routeProvider.when('/example-route', {
reloadOnSearch: false,
template: '<input ng-model="filters.foo" />',
controller: function($scope) {
$scope.$bindToRoute({
param: 'f',
expr: 'filters',
default: {foo: 'default-value'}
});
}
});
});
```

Things to try out:

* Navigate to `#/example-route`. Observe that the URL automatically
updates to `#/example-route?f={"foo":"default-value"}`.
* Edit the content in the `<input>` field. Observe that the URL changes.
* Initiate a change in the browser -- by editing the URL bar or pressing
the "Back" button. The page should refresh.

## Functions

* `$scope.$bindToRoute(options)`
* The `options` object should contain keys:
* `expr` (string): The name of a scoped variable to sync.
* `param` (string): The name of a query-parameter to sync. (If the `param` is included in the URL, it will initialize the expr.)
* `format` (string): The type of data to put in `param`. May be one of:
* `json` (default): The `param` is JSON, and the `expr` is a decoded object.
* `raw`: The `param` is string, and the `expr` is a string.
* `int`: the `param` is an integer-like string, and the expr is an integer.
* `bool`: The `param` is '0'/'1', and the `expr` is false/true.
* `default` (object): The default data. (If the `param` is not included in the URL, it will initialize the expr.)

## Suggested Usage

`$bindToRoute()` was written for a complicated routing scenario with
multiple parameters, e.g. `caseFilters:Object`, `caseId:Int`, `tab:String`,
`activityFilters:Object`, `activityId:Int`. If you're use-case is one or
two scalar values, then stick to vanilla `ngRoute`. This is only for
complicated scenarios.

If you are using `$bindToRoute()`, should you split up parameters -- with
some using `ngRoute` and some using `$bindToRoute()`? I'd pick one style
and stick to it. You're in a complex use-case where `$bindToRoute()` makes
sense, then you already need to put thought into the different
flows/input-combinations. Having two technical styles will increase the
mental load.

A goal of `bindToRoute()` is to accept inputs interchangably from the URL or
HTML fields. Using `ngRoute`'s `resolve:` option only addresses the URL
half. If you want one piece of code handling all inputs the same way, you
should avoid `resolve:` and instead write a controller focused on
orchestrating I/O:

```js
angular.module('sandbox').config(function($routeProvider) {
$routeProvider.when('/example-route', {
reloadOnSearch: false,
template:
'<div filter-toolbar-a="filterSetA" />'
+ '<div filter-toolbar-b="filterSetB" />'
+ '<div filter-toolbar-c="filterSetC" />'
+ '<div data-set-a="dataSetA" />'
+ '<div data-set-b="dataSetB" />'
+ '<div data-set-c="dataSetC" />',
controller: function($scope) {
$scope.$bindToRoute({expr:'filterSetA', param:'a', default:{}});
$scope.$watchCollection('filterSetA', function(){
crmApi(...).then(function(...){
$scope.dataSetA = ...;
});
});

$scope.$bindToRoute({expr:'filterSetB', param:'b', default:{}});
$scope.$watchCollection('filterSetB', function(){
crmApi(...).then(function(...){
$scope.dataSetB = ...;
});
});

$scope.$bindToRoute({expr:'filterSetC', param:'c', default:{}});
$scope.$watchCollection('filterSetC', function(){
crmApi(...).then(function(...){
$scope.dataSetC = ...;
});
});
}
});
});
```

(This example is a little more symmetric than a real one -- because the A,
B, and C datasets look independent. In practice, their loading may be
intermingled.)

0 comments on commit 19ef530

Please sign in to comment.