diff --git a/awx/ui/static/js/app.js b/awx/ui/static/js/app.js
index c3f37e1528..f5ca57ab69 100644
--- a/awx/ui/static/js/app.js
+++ b/awx/ui/static/js/app.js
@@ -33,6 +33,8 @@ import {PortalController} from 'tower/controllers/Portal';
import dataServices from 'tower/services/_data-services';
import dashboardGraphs from 'tower/directives/_dashboard-graphs';
+import routeExtensions from 'tower/shared/route-extensions/main';
+
import {JobDetailController} from 'tower/controllers/JobDetail';
import {JobStdoutController} from 'tower/controllers/JobStdout';
import {JobTemplatesList, JobTemplatesAdd, JobTemplatesEdit} from 'tower/controllers/JobTemplates';
@@ -71,6 +73,7 @@ var tower = angular.module('Tower', [
'RestServices',
dataServices.name,
dashboardGraphs.name,
+ routeExtensions.name,
'AuthService',
'Utilities',
'LicenseHelper',
@@ -184,6 +187,7 @@ var tower = angular.module('Tower', [
$routeProvider.
when('/jobs', {
+ name: 'jobs',
templateUrl: urlPrefix + 'partials/jobs.html',
controller: JobsListController,
resolve: {
@@ -204,6 +208,7 @@ var tower = angular.module('Tower', [
}).
when('/jobs/:id', {
+ name: 'jobDetail',
templateUrl: urlPrefix + 'partials/job_detail.html',
controller: JobDetailController,
resolve: {
diff --git a/awx/ui/static/js/docs.js b/awx/ui/static/js/docs.js
index 8f51c66bf2..6011068888 100644
--- a/awx/ui/static/js/docs.js
+++ b/awx/ui/static/js/docs.js
@@ -1 +1,2 @@
import 'tower/shared/multi-select-list/main.js';
+import 'tower/shared/route-extensions/main.js';
diff --git a/awx/ui/static/js/shared/route-extensions/link-to.directive.js b/awx/ui/static/js/shared/route-extensions/link-to.directive.js
new file mode 100644
index 0000000000..b79ffaef99
--- /dev/null
+++ b/awx/ui/static/js/shared/route-extensions/link-to.directive.js
@@ -0,0 +1,74 @@
+import {lookupRouteUrl} from './lookup-route-url';
+
+/**
+ *
+ * @ngdoc directive
+ * @name routeExtensions.directive:linkTo
+ * @desription
+ * The `linkTo` directive looks up a route's URL and generates a link to that route. When a user
+ * clicks the link, this directive calls the `transitionTo` factory to send them to the given
+ * URL. For accessibility and fallback purposes, it also sets the `href` attribute of the link
+ * to the path.
+ *
+ * Note that in this example the model object uses a key that matches up with the route parameteer
+ * name in the route url (in this case `:id`).
+ *
+ * **N.B.** The below example currently won't run. It's included to show an example of using
+ * the `linkTo` directive within code. In order for this to run, we will need to run
+ * the code in an iframe (using something like `dgeni` instead of `grunt-ngdocs`).
+ *
+ * @example
+ *
+
+
+ angular.module('simpleRouteExample', ['ngRoute', 'routeExtensions'])
+ .config(['$routeProvider', function($route) {
+ $route.when('/posts/:id', {
+ name: 'post',
+ template: '{{post.title}}
{{post.body}}
',
+ controller: 'post'
+ });
+ }]).controller('post', function($scope) {
+ });
+
+
+
+
+ {{featuredPost.title}}
+
+
+
+
+ *
+ */
+export default
+ [ '$route',
+ 'transitionTo',
+ function($routeProvider, transitionTo) {
+ return {
+ restrict: 'E',
+ transclude: true,
+ template: '',
+ scope: {
+ routeName: '@route',
+ model: '&'
+ },
+ link: function(scope, element) {
+
+ var model = scope.$eval(scope.model);
+ scope.url = lookupRouteUrl(scope.routeName, $routeProvider.routes, model);
+
+ element.find('[data-transition-to]').on('click', function(e) {
+ e.stopPropagation();
+ e.preventDefault();
+ transitionTo(scope.routeName, model);
+ });
+
+ }
+ };
+ }
+ ];
diff --git a/awx/ui/static/js/shared/route-extensions/lookup-route-url.js b/awx/ui/static/js/shared/route-extensions/lookup-route-url.js
new file mode 100644
index 0000000000..54e505a2ae
--- /dev/null
+++ b/awx/ui/static/js/shared/route-extensions/lookup-route-url.js
@@ -0,0 +1,43 @@
+export function lookupRouteUrl(name, routes, models) {
+ var route = _.find(routes, {name: name});
+
+ if (angular.isUndefined(route)) {
+ throw "Unknown route " + name;
+ }
+
+ var routeUrl = route.originalPath;
+
+ if (!angular.isUndefined(models) && angular.isObject(models)) {
+ var match = routeUrl.match(route.regexp);
+ var keyMatchers = match.slice(1);
+
+ routeUrl =
+ keyMatchers.reduce(function(url, keyMatcher) {
+ var value;
+ var key = keyMatcher.replace(/^:/, '');
+
+ var model = models[key];
+
+ if (angular.isArray(model)) {
+ value = _.compact(_.pluck(model, key));
+
+ if (_.isEmpty(value)) {
+ value = _.pluck(model, 'id');
+ }
+
+ value = value.join(',');
+ } else if (angular.isObject(model)) {
+ value = model[key];
+
+ if (_.isEmpty(value)) {
+ value = model.id;
+ }
+ }
+
+ return url.replace(keyMatcher, value);
+ }, routeUrl);
+
+ }
+
+ return routeUrl;
+}
diff --git a/awx/ui/static/js/shared/route-extensions/main.js b/awx/ui/static/js/shared/route-extensions/main.js
new file mode 100644
index 0000000000..f041598bd9
--- /dev/null
+++ b/awx/ui/static/js/shared/route-extensions/main.js
@@ -0,0 +1,28 @@
+import linkTo from './link-to.directive';
+import transitionTo from './transition-to.factory';
+import modelListener from './model-listener.config';
+
+/**
+ * @ngdoc overview
+ * @name routeExtensions
+ * @description
+ *
+ * # routeExtensions
+ *
+ * Adds a couple useful features to ngRoute:
+ * - Adds a `name` property to route objects; used to identify the route in transitions & links
+ * - Adds the ability to pass model data when clicking a link that goes to a route
+ * - Adds a directive that generates a route's URL from the route name & given models
+ * - Adds the ability to specify models in route resolvers
+ *
+ * ## Usage
+ *
+ * If you need to generate a link to a route, then use the {@link routeExtensions.directive:linkTo `linkTo directive`}. If you need to transition to a route in JavaScript code, then use the {@link routeExtensions.factory:transitionTo `transitionTo service`}.
+ *
+*/
+export default
+ angular.module('routeExtensions',
+ ['ngRoute'])
+ .factory('transitionTo', transitionTo)
+ .run(modelListener)
+ .directive('linkTo', linkTo);
diff --git a/awx/ui/static/js/shared/route-extensions/model-listener.config.js b/awx/ui/static/js/shared/route-extensions/model-listener.config.js
new file mode 100644
index 0000000000..7f7112f08a
--- /dev/null
+++ b/awx/ui/static/js/shared/route-extensions/model-listener.config.js
@@ -0,0 +1,17 @@
+export default
+ [ '$rootScope',
+ '$routeParams',
+ function($rootScope, $routeParams) {
+ $rootScope.$on('$routeChangeSuccess', function(e, newRoute) {
+ if (angular.isUndefined(newRoute.model)) {
+ var keys = Object.keys(newRoute.params);
+ var models = keys.reduce(function(model, key) {
+ model[key] = newRoute.locals[key];
+ return model;
+ }, {});
+
+ $routeParams.model = models;
+ }
+ });
+ }
+ ];
diff --git a/awx/ui/static/js/shared/route-extensions/transition-to.factory.js b/awx/ui/static/js/shared/route-extensions/transition-to.factory.js
new file mode 100644
index 0000000000..6acee4326f
--- /dev/null
+++ b/awx/ui/static/js/shared/route-extensions/transition-to.factory.js
@@ -0,0 +1,131 @@
+import {lookupRouteUrl} from './lookup-route-url';
+
+/**
+ * @ngdoc service
+ * @name routeExtensions.service:transitionTo
+ * @description
+ * The `transitionTo` service generates a URL given a route name and model parameters, then
+ * updates the browser's URL via `$location.path`. Use this in situations where you cannot
+ * use the `linkTo` directive, for example to redirect the user after saving an object.
+ *
+ * @param {string} routeName The name of the route whose URL you want to redirect to (corresponds
+ * name property of route)
+ * @param {object} model The model you want to use to generate the URL and be passed to the new
+ * route. This object follows a strict key/value naming convention where
+ * the keys match the parameters listed in the route's URL. For example,
+ * a URL of `/posts/:id` would require a model object like: `{ id: post }`,
+ * where `post` is the object you want to pass to the new route.
+ *
+ * **N.B.** The below example currently won't run. It's included to show an example of using
+ * the `transitionTo` function within code. In order for this to run, we will need to run
+ * the code in an iframe (using something like `dgeni` instead of `grunt-ngdocs`).
+ *
+ * @example
+ *
+
+
+ angular.module('transitionToExample',
+ ['ngRoute',
+ 'routeExtensions'
+ ])
+ .config(function($routeProvider, $locationProvider) {
+ $routeProvider
+ .when('/post/:id',
+ { name: 'post',
+ template: '{{post.title}}
{{post.body}}
'
+ });
+
+ $locationProvider.html5Mode(true);
+ $locationProvider.hashPrefix('!');
+ })
+ .controller('post', ['$scope', function($scope) {
+
+ }])
+ .controller('postForm', ['$scope', 'transitionTo', function($scope, transitionTo) {
+ $scope.post = {
+ id: 1,
+ title: 'A post',
+ body: 'Some text'
+ };
+
+ $scope.savePost = function() {
+ transitionTo('post', { id: $scope.post });
+ }
+ }]);
+
+
+
+
+
+
+
+ */
+
+function safeApply(fn, $rootScope) {
+ var currentPhase = $rootScope.$$phase;
+
+ if (currentPhase === '$apply' || currentPhase === '$digest') {
+ fn();
+ } else {
+ $rootScope.$apply(fn);
+ }
+}
+
+export default
+ [ '$location',
+ '$rootScope',
+ '$route',
+ '$q',
+ function($location, $rootScope, $route, $q) {
+ return function(routeName, model) {
+ var deferred = $q.defer();
+ var url = lookupRouteUrl(routeName, $route.routes, model);
+
+ var offRouteChangeStart =
+ $rootScope.$on('$routeChangeStart', function(e, newRoute) {
+ if (newRoute.$$route.name === routeName) {
+ deferred.resolve(newRoute, model);
+ newRoute.params.model = model;
+ }
+
+ offRouteChangeStart();
+ });
+
+ var offRouteChangeSuccess =
+ $rootScope.$on('$routeChangeSuccess', function(e, newRoute) {
+ if (newRoute.$$route.name === routeName) {
+ deferred.resolve(newRoute);
+ }
+
+ offRouteChangeSuccess();
+ });
+
+ var offRouteChangeError =
+ $rootScope.$on('$routeChangeError', function(e, newRoute, previousRoute, rejection) {
+ if (newRoute.$$route.name === routeName) {
+ deferred.reject(newRoute, previousRoute, rejection);
+ }
+
+ offRouteChangeError();
+ });
+
+ safeApply(function() {
+ $location.path(url);
+ }, $rootScope);
+
+ return deferred;
+ };
+ }
+ ];