diff --git a/Gruntfile.coffee b/Gruntfile.coffee
index d9b9359..71fb634 100644
--- a/Gruntfile.coffee
+++ b/Gruntfile.coffee
@@ -13,7 +13,7 @@ module.exports = (grunt) ->
copy:
build:
cwd: '<%= src %>/'
- src: ['scripts/**/*.js', 'scripts/**/*.coffee', 'views/**/*']
+ src: ['**/*.js', '**/*.coffee', '**/*.html', '**/*.jade']
dest: '<%= build %>/'
expand: true
@@ -22,7 +22,7 @@ module.exports = (grunt) ->
dist:
files:
'<%= dist %>/rally.js': [
- '<%= build %>/scripts/**/*.js'
+ '<%= build %>/**/*.js'
]
clean:
@@ -35,9 +35,9 @@ module.exports = (grunt) ->
bare: true
expand: true
flatten: false
- cwd: '<%= build %>/scripts'
+ cwd: '<%= build %>'
src: '**/*.coffee'
- dest: '<%= build %>/scripts/'
+ dest: '<%= build %>'
ext: '.js'
# Compile all Jade templates
@@ -45,8 +45,8 @@ module.exports = (grunt) ->
views:
files: [{
expand: true
- cwd: '<%= build %>/views'
- dest: '<%= build %>/views/'
+ cwd: '<%= build %>'
+ dest: '<%= build %>'
ext: '.html'
src: ["**/*.jade"]
}]
@@ -62,5 +62,5 @@ module.exports = (grunt) ->
grunt.registerTask('build', ['clean', 'copy:build', 'coffee', 'jade:views'])
grunt.registerTask('dist', ['build', 'concat:dist'])
- grunt.registerTask('test', ['build', 'karma:unit'])
- grunt.registerTask('default', ['build', 'test'])
+ grunt.registerTask('test', ['karma:unit'])
+ grunt.registerTask('default', ['test', 'dist'])
diff --git a/README.md b/README.md
index 93d66ec..f45c177 100644
--- a/README.md
+++ b/README.md
@@ -7,81 +7,7 @@ adapter, the primary purpose is to support the patterns and style of usage obser
TODO
----
-
-- [ ] Build distribution script
-- [ ] Bower file with version
-
-Use Angular in ExtJS
-====================
-
-We've extended ```Ext.Component``` to bootstrap an angular module anywhere within your ext app.
-The component will very simple call [angular.bootstrap](http://docs.angularjs.org/api/angular.bootstrap)
-on the component's element after it renders and set up event bubbling between Ext and Angular.
-
-```javascript
-template = new Ext.XTemplate('
');
-Ext.create('Angular.Component', {
- module: 'myModule',
- renderTo: 'angular-container',
- renderTpl: template
-})
-```
-
-Use ExtJS in Angular
-====================
-
-Attributes and Configs
--------
-Ext components and widgets are created in code, but we want to leverage Angular's support for attributes.
-While angular makes it easy enough to define interpolated, evaluated, and isolate scope bound attributes,
-it's not simple to do this dynamically nor where the value type is unknown.
-
-The value of all attributes (except explicitly stated special ones) will be passed as a part of the config object
-to Ext.create().
-
-### Interpolation
-This is the default behavior for config attributes. Standard attributes will be compiled and evaluated as strings.
-```
-
-```
-
-### Evaluation
-Using the prefix 'e-' in hungarian style notation, the value of the attribute will be assumed to be an expression
-evaluated on the parent scope.
-```
-
-```
-
-### Binding
-Perhaps the least likely scenario is to set up two-way property binding. The title will be set to the value of the
-parent scope, and a two-way binding will be set up with the component's property.
-```
-
-```
-
-### Special attributes
-In order to configure components with some reasonable defaults, there are a few special directive attributes.
-
-- config - An angular expression that returns an object used for Ext.create(). All other config attributes will be added to this config object with ```configs = _.extend(configs, attributeValue)```
- - example - ```config="{title:myTitle}"```
-- bindTo - A variable name on the parent scope to bind the ext component to.
-- renderTo - Establishes what DOM element to render the component to.
- - ```false``` or ```''```: Doesn't set the config at all (You'll probably need to use 'add')
- - ```true```: sets 'renderTo' to the directives HTML element.
- - ```[string]```: passes the id through to the Ext renderTo config.
-- add - For adding components to parent containers.
- - ```false``` or ```''```: No behavior. You probably want to use 'renderTo' in this case.
- - ```true```: Adds this component to the immediate parent directive/component (assuming the parent is a container)
- - ```[string]```: Add this component to the component found at given property on the parent scope (Least likely)
-
-TODO
-----
-
-- [ ] Hungarian style attribute evaluation
- - [ ] Interpolated attributes
- - [ ] Evaluated attributes
- - [ ] Two-way binding
-- [ ] Virtual element directives that add to parent 'items'
+Much of this repo and library is a work in progress. There aren't any special Angular-EXT adapters or bindings yet, but this will be the place for them.
Angular's Digest and Ext Events
-------------------------------
@@ -96,73 +22,8 @@ a $digest as well as $emit those events on the parent scope.
DataStores
----------
-???
-Things like find() and datastore results can be set up as functions.
-```javascript
-$scope.results = function() { return store.find(...) }
-```
-
-Stores should be observables that can be bound to the digest cycle just like components. They'll get constructed
-on the scope and manipulated with $watch if they need to be bound to things like filter changes.
+WIP. Conceptually, data stores can be adapted to provide angular promises.
Motivation
----------
-The goal is to be able to do the following
-
-```javascript
-$scope.title = 'Border Layout';
-```
-```html
-
-
-
-
-
-```
-
-### TODO
-- [ ] - Determine how child elements get rolled into 'items' for their parent. Shouldn't this be the default behavior?
-- [ ] - Component directive that includes child html as 'template' for creating containers with markup
-
-Instead of this
-
-```javascript
-var panelVar = Ext.create('Ext.panel.Panel', {
- width: 500,
- height: 300,
- title: 'Border Layout',
- layout: 'border',
- items: [{
- title: 'South Region is resizable',
- region: 'south', // position for region
- xtype: 'panel',
- height: 100,
- split: true, // enable resizing
- margins: '0 5 5 5'
- },{
- // xtype: 'panel' implied by default
- title: 'West Region is collapsible',
- region:'west',
- xtype: 'panel',
- margins: '5 0 0 5',
- width: 200,
- collapsible: true, // make collapsible
- id: 'west-region-container',
- layout: 'fit'
- },{
- title: 'Center Region',
- region: 'center', // center region is required, no width/height specified
- xtype: 'panel',
- layout: 'fit',
- margins: '5 5 0 0'
- }],
- renderTo: Ext.getBody()
-});
-```
-And, to allow the App SDK to effortlessly include angular apps and components.
-```
-Ext.create('Rally.anuglar.app', {module: 'myModule'})
-```
-
-### TODO
-- [ ] Create an ext container class to bootstrap angular.
+To allow the App SDK to effortlessly include angular apps and components.
diff --git a/bower.json b/bower.json
index bdfbaec..ab6699d 100644
--- a/bower.json
+++ b/bower.json
@@ -5,7 +5,8 @@
"node_modules",
"bower_components",
"test",
- "src"
+ "src",
+ "build"
],
"dependencies": {
"angular": "latest",
diff --git a/dist/rally.js b/dist/rally.js
index ca6c646..9021c14 100644
--- a/dist/rally.js
+++ b/dist/rally.js
@@ -1,3 +1,213 @@
+angular.module('rally.api', ['rally.api.services']);
+
+angular.module('rally.api.services', ['rally.api.services.slm', 'rally.api.services.wsapi', 'rally.api.services.lookback']);
+
+/**
+ * @ngdoc service
+ * @name rally.api.services:$lookback
+ * @description
+ * @example
+*/
+
+var __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; };
+
+angular.module('rally.api.services.lookback', []).provider('$lookback', function() {
+ var LookbackApi,
+ _this = this;
+ this.baseUrl = '/analytics/v2.0/service/rally';
+ this.setBaseUrl = function(baseUrl) {
+ _this.baseUrl = baseUrl;
+ };
+ LookbackApi = (function() {
+ function LookbackApi($http, baseUrl) {
+ this.$http = $http;
+ this.baseUrl = baseUrl;
+ this.artifactSnapshots = __bind(this.artifactSnapshots, this);
+ }
+
+ LookbackApi.prototype.artifactSnapshots = function(workspaceOid, query, config) {
+ return this.$http.post("" + this.baseUrl + "/workspace/" + workspaceOid + "/artifact/snapshot/query", JSON.stringify(query), config);
+ };
+
+ return LookbackApi;
+
+ })();
+ this.$get = function($http) {
+ return new LookbackApi($http, _this.baseUrl);
+ };
+ return this;
+});
+
+/**
+ * @ngdoc service
+ * @name rally.api.services:$slm
+ * @description
+ * @example
+*/
+
+angular.module('rally.api.services.slm', ['rally.util.http']).provider('$slm', function() {
+ var _this = this;
+ this.baseUrl = '/slm/';
+ this.setBaseUrl = function(baseUrl) {
+ _this.baseUrl = baseUrl;
+ };
+ this.$get = function(rallyHttpWrapper) {
+ return rallyHttpWrapper(_this.baseUrl);
+ };
+ return this;
+});
+
+/**
+ * @ngdoc service
+ * @name rally.api.services:$wsapi
+ * @description
+ * Abstracts $http calls to a wsapi base url. Can be configured at config/provider time to use a particular base url.
+ * @example
+
+
+ angular.module('App', ['rally.api.services.wsapi'])
+ .config(function($wsapiProvider){
+ $wsapiProvider.setBaseUrl('/test/');
+ })
+ .controller('Ctrl',
+ function Ctrl($scope, $httpBackend, $wsapi) {
+ $scope.$wsapi = $wsapi;
+ console.log('backend', $httpBackend)
+ console.log('backend.whenGET', $httpBackend.whenGET)
+ $httpBackend.whenGET('/test/sub/url').respond(200, {foo: 'bar'});
+ $wsapi({method: 'GET', url: '/sub/url/'}).then(function(data, status){
+ $scope.data = data
+ $scope.status = status
+ });
+ }
+ );
+
+
+
+
Wsapi base url: {{$wsapi.baseUrl}}
+
data: {{data}}
+
status: {{status}}
+
+
+
+*/
+
+angular.module('rally.api.services.wsapi', ['rally.api.services.slm', 'rally.util.http']).provider('$wsapi', function() {
+ var _this = this;
+ this.baseUrl = '/webservice/v2.0/';
+ this.setBaseUrl = function(baseUrl) {
+ _this.baseUrl = baseUrl;
+ };
+ this.$get = function(rallyHttpWrapper, $slm) {
+ return rallyHttpWrapper(_this.baseUrl, $slm);
+ };
+ return this;
+});
+
+angular.module('rally.api.wsapi', ['rally.api.services.wsapi', 'rally.api.wsapi.projects']);
+
+var RallyApiWsapiProjects,
+ __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; };
+
+angular.module('rally.api.wsapi.projects', ['rally.util.http.services.promise', 'rally.api.services.wsapi', 'rally.util.async']).run(function($wsapi, rallyApiWsapiProjects) {
+ return $wsapi.projects = rallyApiWsapiProjects;
+}).service('rallyApiWsapiProjects', RallyApiWsapiProjects = (function() {
+ function RallyApiWsapiProjects($q, $wsapi, httpPromise) {
+ this.$q = $q;
+ this.$wsapi = $wsapi;
+ this.httpPromise = httpPromise;
+ this._scopeUp = __bind(this._scopeUp, this);
+ this._scopeDown = __bind(this._scopeDown, this);
+ this.scope = __bind(this.scope, this);
+ this.children = __bind(this.children, this);
+ this.concurrencyLimit = 4;
+ }
+
+ /*
+ @param {object} projectScope - an object with 'oid' and 'workspaceOid' properties
+ Workspaces should have identical oid and workspaceOid
+ */
+
+
+ RallyApiWsapiProjects.prototype.children = function(projectScope, onlyOpen) {
+ var type, url;
+ if (onlyOpen == null) {
+ onlyOpen = true;
+ }
+ type = projectScope.oid === projectScope.workspaceOid ? 'workspace' : 'project';
+ url = "/" + type + "/" + projectScope.oid + "/Children";
+ if (onlyOpen) {
+ url += '?query=(State != "Closed")';
+ }
+ return this.httpPromise.asArray(this.$wsapi({
+ url: url,
+ method: 'JSONP',
+ params: {
+ 'jsonp': 'JSON_CALLBACK'
+ }
+ }).then(function(response) {
+ return response.data.QueryResult.Results;
+ }));
+ };
+
+ /*
+ @param {object} projectScope - an object with 'oid' and 'workspaceOid' properties
+ Workspaces should have identical oid and workspaceOid
+ @param {string} direction - 'up' or 'down' [default] to load a project tree
+ */
+
+
+ RallyApiWsapiProjects.prototype.scope = function(projectScope, direction) {
+ if (direction == null) {
+ direction = 'down';
+ }
+ switch (direction) {
+ case 'down':
+ return this._scopeDown(projectScope);
+ case 'up':
+ return this._scopeUp(projectScope);
+ }
+ };
+
+ RallyApiWsapiProjects.prototype._scopeDown = function(projectScope, concurrency) {
+ var deferred, queue,
+ _this = this;
+ if (concurrency == null) {
+ concurrency = this.concurrencyLimit;
+ }
+ deferred = this.$q.defer();
+ queue = async.queue(function(_arg, callback) {
+ var projectScope;
+ projectScope = _arg.projectScope;
+ deferred.notify(projectScope);
+ projectScope.children = _this.children(projectScope);
+ projectScope.name = projectScope.Name;
+ return projectScope.children.$promise.then(function(children) {
+ _.each(children, function(child) {
+ child.oid = child.ObjectID;
+ child.workspaceOid = projectScope.workspaceOid;
+ return queue.push({
+ projectScope: child
+ });
+ });
+ return callback();
+ });
+ }, concurrency);
+ queue.drain = function() {
+ return deferred.resolve();
+ };
+ queue.push({
+ projectScope: projectScope
+ });
+ return deferred.promise;
+ };
+
+ RallyApiWsapiProjects.prototype._scopeUp = function(projectScope) {};
+
+ return RallyApiWsapiProjects;
+
+})());
+
var __slice = [].slice;
angular.module('rally.app.iframe.decorators.rootScope', []).config(function($provide) {
@@ -79,28 +289,16 @@ angular.module('rally.app.iframe.services.messageBus', ['rally.app.iframe.decora
angular.module('rally.app.iframe.services', ['rally.app.iframe.services.messageBus', 'rally.app.iframe.services.appService']);
-angular.module('rally.app', []);
-
-
-
-angular.module('Ext', []).factory('Ext', function() {
- return Ext;
-});
-
-angular.module('Rally', []).factory('Rally', function() {
- return Rally;
-});
-
-angular.module('rally', ['rally.services', 'rally.app']);
+angular.module('rally.app', ['rally.app.services']);
-angular.module('rally.services', ['rally.services.rally']);
+angular.module('rally.app.services', ['rally.app.services.rally']);
-var RallyService,
+var RallyAppService,
__bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; },
__slice = [].slice;
-angular.module('rally.services.rally', ['Ext', 'Rally']).service('$rally', RallyService = (function() {
- function RallyService($q, $rootScope, $log, Ext, Rally) {
+angular.module('rally.app.services.rally', ['Ext', 'Rally']).service('$rally', RallyAppService = (function() {
+ function RallyAppService($q, $rootScope, $log, Ext, Rally) {
this.$q = $q;
this.$rootScope = $rootScope;
this.$log = $log;
@@ -110,7 +308,7 @@ angular.module('rally.services.rally', ['Ext', 'Rally']).service('$rally', Rally
this.launchApp = __bind(this.launchApp, this);
}
- RallyService.prototype.launchApp = function(appName, options, scope) {
+ RallyAppService.prototype.launchApp = function(appName, options, scope) {
var defaults, deferred, self, theApp;
if (options == null) {
options = {};
@@ -137,7 +335,7 @@ angular.module('rally.services.rally', ['Ext', 'Rally']).service('$rally', Rally
return deferred.promise;
};
- RallyService.prototype.bind = function(scope, observable) {
+ RallyAppService.prototype.bind = function(scope, observable) {
var _this = this;
this.$log.debug('Binding to Rally app events');
Ext.util.Observable.capture(observable, function() {
@@ -152,6 +350,179 @@ angular.module('rally.services.rally', ['Ext', 'Rally']).service('$rally', Rally
return observable;
};
- return RallyService;
+ return RallyAppService;
+
+})());
+
+
+
+angular.module('Ext', []).factory('Ext', function() {
+ return Ext;
+});
+
+angular.module('Rally', []).factory('Rally', function() {
+ return Rally;
+});
+
+angular.module('rally', ['rally.api', 'rally.app', 'rally.util']);
+
+angular.module('rally.util.async', []).factory('async', function() {
+ return async;
+});
+
+/**
+ * @ngdoc service
+ * @name rally.util.cache:$cacheWrap
+ * @type function
+ * @description
+ * Helper function that returns a cached value or runs your function to cache the result and return your value as a promise.
+ * If you value function returns a promise, the wrapper will wait for resolution and put the result in your cache.
+*/
+
+angular.module('rally.util.cache.factories.wrap', ['rally.util.lodash']).factory('$cacheWrap', function($q) {
+ var $cacheWrap;
+ $cacheWrap = function(cache, keyFn) {
+ if (keyFn == null) {
+ keyFn = _.identity;
+ }
+ return function(key, func) {
+ var deferred;
+ key = keyFn(key);
+ if (cache.get(key)) {
+ return $q.when(cache.get(key));
+ }
+ deferred = $q.defer();
+ cache.put(key, deferred.promise);
+ deferred.promise.then(function(result) {
+ return cache.put(key, result);
+ }, function() {
+ return cache.remove(key);
+ });
+ deferred.resolve($q.when(func()));
+ return deferred.promise;
+ };
+ };
+ return $cacheWrap;
+});
+
+angular.module('rally.util.cache', ['rally.util.cache.factories.wrap']);
+
+var __slice = [].slice;
+
+angular.module('rally.util.http.factories.httpWrapper', ['rally.util.lodash']).factory('rallyHttpWrapper', function($http) {
+ return function(baseUrl, http) {
+ var getUrl, wrapper;
+ if (http == null) {
+ http = $http;
+ }
+ getUrl = function(url) {
+ var toAdd;
+ toAdd = url;
+ if (baseUrl[baseUrl.length - 1] === '/' && url.indexOf('/') === 0) {
+ toAdd = toAdd.substring(1);
+ }
+ return baseUrl + toAdd;
+ };
+ wrapper = function(urlOrConfig, config) {
+ if (_.isString(urlOrConfig)) {
+ urlOrConfig = getUrl(urlOrConfig);
+ } else {
+ urlOrConfig.url = getUrl(urlOrConfig.url);
+ }
+ return http.call(this, urlOrConfig, config);
+ };
+ _.each(['GET', 'DELETE', 'POST', 'PUT'], function(method) {
+ return wrapper[method.toLowerCase()] = function() {
+ var args, url, _ref;
+ url = arguments[0], args = 2 <= arguments.length ? __slice.call(arguments, 1) : [];
+ url = getUrl(url);
+ return (_ref = http[method.toLowerCase()]).call.apply(_ref, [this, url].concat(__slice.call(args)));
+ };
+ });
+ return wrapper;
+ };
+});
+
+angular.module('rally.util.http', ['rally.util.http.factories.httpWrapper', 'rally.util.http.services.promise']);
+
+var HttpPromise;
+
+angular.module('rally.util.http.services.promise', ['rally.util.lodash']).service('httpPromise', HttpPromise = (function() {
+ function HttpPromise() {}
+
+ HttpPromise.prototype.asArray = function(promise) {
+ var data;
+ data = [];
+ data.$promise = promise;
+ promise.then(function(results) {
+ return _.each(results, function(r) {
+ return data.push(r);
+ });
+ });
+ return data;
+ };
+
+ HttpPromise.prototype.asObject = function(promise) {
+ var data;
+ data = {};
+ data.$promise = promise;
+ promise.then(function(result) {
+ return _.merge(data, result);
+ });
+ return data;
+ };
+
+ return HttpPromise;
})());
+
+angular.module('rally.util.lodash', ['rally.util.lodash.sortedInsert']);
+
+angular.module('rally.util.lodash.sortedInsert', []).run(function() {
+ _.sortedInsert = function(array, value, pluck) {
+ var index;
+ index = _.sortedIndex(array, value, pluck);
+ array.splice(index, 0, value);
+ return array;
+ };
+ return _.sortedReverseInsert = function(array, value, pluck) {
+ _.sortedInsert(array.reverse(), value, pluck);
+ return array.reverse();
+ };
+});
+
+var __slice = [].slice;
+
+angular.module('rally.util.timeout', []).factory('$rallyTimeoutThrottleFactory', function($timeout) {
+ return function(max) {
+ var processNext, queue, running;
+ queue = [];
+ running = 0;
+ processNext = function() {
+ var args, context, next;
+ if (running >= max) {
+ return;
+ }
+ next = queue.shift();
+ context = next[0];
+ args = next.slice(1);
+ running = running + 1;
+ return $timeout.apply(context, args)['finally'](function() {
+ running = running - 1;
+ return processNext();
+ });
+ };
+ return function() {
+ var args;
+ args = 1 <= arguments.length ? __slice.call(arguments, 0) : [];
+ queue.push([this].concat(args));
+ return processNext();
+ };
+ };
+}).factory('$rallyTimeoutThrottle', function($rallyTimeoutThrottleFactory) {
+ return $rallyTimeoutThrottleFactory(10);
+});
+
+var util;
+
+util = angular.module('rally.util', ['rally.util.async', 'rally.util.cache', 'rally.util.http', 'rally.util.lodash', 'rally.util.timeout']);
diff --git a/karma.conf.js b/karma.conf.js
index b6bd609..a3dae36 100644
--- a/karma.conf.js
+++ b/karma.conf.js
@@ -9,13 +9,14 @@ module.exports = function(config) {
// list of files / patterns to load in the browser
files: [
- 'bower_components/angular/angular.js',
+ 'bower_components/lodash/dist/lodash.min.js',
+ 'bower_components/angular/angular.min.js',
'bower_components/angular-mocks/angular-mocks.js',
'bower_components/jasmine.async/lib/jasmine.async.js',
- 'src/scripts/**/*.js',
- 'src/scripts/**/*.coffee',
- 'src/views/**/*.html',
+ 'src/**/*.js',
+ 'src/**/*.coffee',
+ 'src/**/*.html',
'test/**/*.js',
'test/**/*.coffee'
],
@@ -26,7 +27,7 @@ module.exports = function(config) {
},
ngHtml2JsPreprocessor: {
- stripPrefix: '<%= src %>/views/',
+ stripPrefix: '<%= src %>/',
prependPredix: 'templates/',
moduleName: 'rally.templates'
},
diff --git a/src/rally/api/index.coffee b/src/rally/api/index.coffee
new file mode 100644
index 0000000..c2c8f12
--- /dev/null
+++ b/src/rally/api/index.coffee
@@ -0,0 +1,3 @@
+angular.module('rally.api', [
+ 'rally.api.services'
+])
diff --git a/src/rally/api/services/index.coffee b/src/rally/api/services/index.coffee
new file mode 100644
index 0000000..dd7fcaf
--- /dev/null
+++ b/src/rally/api/services/index.coffee
@@ -0,0 +1,5 @@
+angular.module('rally.api.services',[
+ 'rally.api.services.slm'
+ 'rally.api.services.wsapi'
+ 'rally.api.services.lookback'
+])
diff --git a/src/rally/api/services/lookback.coffee b/src/rally/api/services/lookback.coffee
new file mode 100644
index 0000000..3e2c3e5
--- /dev/null
+++ b/src/rally/api/services/lookback.coffee
@@ -0,0 +1,21 @@
+###*
+ * @ngdoc service
+ * @name rally.api.services:$lookback
+ * @description
+ * @example
+###
+angular.module('rally.api.services.lookback', [
+]).provider '$lookback', () ->
+ @baseUrl = '/analytics/v2.0/service/rally'
+ @setBaseUrl = (@baseUrl)=>
+
+ class LookbackApi
+ constructor: (@$http, @baseUrl) ->
+
+ artifactSnapshots: (workspaceOid, query, config) =>
+ # Must use JSON.stringify, angular.toJson removes keys with $, which are kind of important in lookback queries :)
+ return @$http.post("#{@baseUrl}/workspace/#{workspaceOid}/artifact/snapshot/query", JSON.stringify(query), config)
+
+ @$get = ($http) =>
+ return new LookbackApi($http, @baseUrl)
+ return @
diff --git a/src/rally/api/services/slm.coffee b/src/rally/api/services/slm.coffee
new file mode 100644
index 0000000..7961fb5
--- /dev/null
+++ b/src/rally/api/services/slm.coffee
@@ -0,0 +1,14 @@
+###*
+ * @ngdoc service
+ * @name rally.api.services:$slm
+ * @description
+ * @example
+###
+angular.module('rally.api.services.slm', [
+ 'rally.util.http'
+]).provider '$slm', ()->
+ @baseUrl = '/slm/'
+ @setBaseUrl = (@baseUrl)=>
+ @$get = (rallyHttpWrapper)=>
+ return rallyHttpWrapper(@baseUrl)
+ return @
diff --git a/src/rally/api/services/wsapi.coffee b/src/rally/api/services/wsapi.coffee
new file mode 100644
index 0000000..6315472
--- /dev/null
+++ b/src/rally/api/services/wsapi.coffee
@@ -0,0 +1,43 @@
+###*
+ * @ngdoc service
+ * @name rally.api.services:$wsapi
+ * @description
+ * Abstracts $http calls to a wsapi base url. Can be configured at config/provider time to use a particular base url.
+ * @example
+
+
+ angular.module('App', ['rally.api.services.wsapi'])
+ .config(function($wsapiProvider){
+ $wsapiProvider.setBaseUrl('/test/');
+ })
+ .controller('Ctrl',
+ function Ctrl($scope, $httpBackend, $wsapi) {
+ $scope.$wsapi = $wsapi;
+ console.log('backend', $httpBackend)
+ console.log('backend.whenGET', $httpBackend.whenGET)
+ $httpBackend.whenGET('/test/sub/url').respond(200, {foo: 'bar'});
+ $wsapi({method: 'GET', url: '/sub/url/'}).then(function(data, status){
+ $scope.data = data
+ $scope.status = status
+ });
+ }
+ );
+
+
+
+
Wsapi base url: {{$wsapi.baseUrl}}
+
data: {{data}}
+
status: {{status}}
+
+
+
+###
+angular.module('rally.api.services.wsapi', [
+ 'rally.api.services.slm'
+ 'rally.util.http'
+]).provider '$wsapi', ()->
+ @baseUrl = '/webservice/v2.0/'
+ @setBaseUrl = (@baseUrl)=>
+ @$get = (rallyHttpWrapper, $slm)=>
+ return rallyHttpWrapper(@baseUrl, $slm)
+ return @
diff --git a/src/rally/api/wsapi/index.coffee b/src/rally/api/wsapi/index.coffee
new file mode 100644
index 0000000..fec90f1
--- /dev/null
+++ b/src/rally/api/wsapi/index.coffee
@@ -0,0 +1,4 @@
+angular.module('rally.api.wsapi', [
+ 'rally.api.services.wsapi'
+ 'rally.api.wsapi.projects'
+])
diff --git a/src/rally/api/wsapi/projects.coffee b/src/rally/api/wsapi/projects.coffee
new file mode 100644
index 0000000..f1ecbe9
--- /dev/null
+++ b/src/rally/api/wsapi/projects.coffee
@@ -0,0 +1,62 @@
+angular.module('rally.api.wsapi.projects', [
+ 'rally.util.http.services.promise'
+ 'rally.api.services.wsapi'
+ 'rally.util.async'
+])
+
+.run ($wsapi, rallyApiWsapiProjects)->
+ $wsapi.projects = rallyApiWsapiProjects
+
+.service 'rallyApiWsapiProjects',
+ class RallyApiWsapiProjects
+ constructor: (@$q, @$wsapi, @httpPromise)->
+ @concurrencyLimit = 4
+
+ ###
+ @param {object} projectScope - an object with 'oid' and 'workspaceOid' properties
+ Workspaces should have identical oid and workspaceOid
+ ###
+ children: (projectScope, onlyOpen=true)=>
+ type = if projectScope.oid is projectScope.workspaceOid then 'workspace' else 'project'
+ url = "/#{type}/#{projectScope.oid}/Children"
+ if onlyOpen then url += '?query=(State != "Closed")'
+
+ return @httpPromise.asArray @$wsapi({
+ url: url
+ method: 'JSONP',
+ params: {
+ 'jsonp': 'JSON_CALLBACK'
+ }
+ }).then (response)->
+ return response.data.QueryResult.Results
+
+ ###
+ @param {object} projectScope - an object with 'oid' and 'workspaceOid' properties
+ Workspaces should have identical oid and workspaceOid
+ @param {string} direction - 'up' or 'down' [default] to load a project tree
+ ###
+ scope: (projectScope, direction='down')=>
+ switch direction
+ when 'down' then return @_scopeDown(projectScope)
+ when 'up' then return @_scopeUp(projectScope)
+
+ _scopeDown: (projectScope, concurrency=@concurrencyLimit)=>
+ deferred = @$q.defer()
+ queue = async.queue(({projectScope}, callback)=>
+ deferred.notify(projectScope)
+ projectScope.children = @children(projectScope)
+ projectScope.name = projectScope.Name
+ # Only finish when children have finished loading
+ projectScope.children.$promise.then (children)->
+ _.each children, (child)->
+ child.oid = child.ObjectID
+ child.workspaceOid = projectScope.workspaceOid
+ queue.push({projectScope: child})
+ callback()
+ , concurrency)
+ queue.drain = ()->
+ deferred.resolve()
+ queue.push({projectScope})
+ return deferred.promise
+
+ _scopeUp: (projectScope)=>
diff --git a/src/scripts/app/iframe/decorators/rootScope.coffee b/src/rally/app/iframe/decorators/rootScope.coffee
similarity index 100%
rename from src/scripts/app/iframe/decorators/rootScope.coffee
rename to src/rally/app/iframe/decorators/rootScope.coffee
diff --git a/src/scripts/app/iframe/index.coffee b/src/rally/app/iframe/index.coffee
similarity index 100%
rename from src/scripts/app/iframe/index.coffee
rename to src/rally/app/iframe/index.coffee
diff --git a/src/scripts/app/iframe/services/iframeAppService.coffee b/src/rally/app/iframe/services/iframeAppService.coffee
similarity index 100%
rename from src/scripts/app/iframe/services/iframeAppService.coffee
rename to src/rally/app/iframe/services/iframeAppService.coffee
diff --git a/src/scripts/app/iframe/services/iframeMessageBus.coffee b/src/rally/app/iframe/services/iframeMessageBus.coffee
similarity index 100%
rename from src/scripts/app/iframe/services/iframeMessageBus.coffee
rename to src/rally/app/iframe/services/iframeMessageBus.coffee
diff --git a/src/scripts/app/iframe/services/index.coffee b/src/rally/app/iframe/services/index.coffee
similarity index 100%
rename from src/scripts/app/iframe/services/index.coffee
rename to src/rally/app/iframe/services/index.coffee
diff --git a/src/rally/app/index.coffee b/src/rally/app/index.coffee
new file mode 100644
index 0000000..cc6eefa
--- /dev/null
+++ b/src/rally/app/index.coffee
@@ -0,0 +1,3 @@
+angular.module('rally.app', [
+ 'rally.app.services'
+])
diff --git a/src/rally/app/services/index.coffee b/src/rally/app/services/index.coffee
new file mode 100644
index 0000000..a22673d
--- /dev/null
+++ b/src/rally/app/services/index.coffee
@@ -0,0 +1,3 @@
+angular.module('rally.app.services', [
+ 'rally.app.services.rally'
+])
diff --git a/src/scripts/services/rally.coffee b/src/rally/app/services/rally.coffee
similarity index 90%
rename from src/scripts/services/rally.coffee
rename to src/rally/app/services/rally.coffee
index 43ff252..fe1fbe0 100644
--- a/src/scripts/services/rally.coffee
+++ b/src/rally/app/services/rally.coffee
@@ -1,6 +1,5 @@
-angular.module('rally.services.rally', ['Ext', 'Rally']).service '$rally',
-
- class RallyService
+angular.module('rally.app.services.rally', ['Ext', 'Rally']).service '$rally',
+ class RallyAppService
constructor: (@$q, @$rootScope, @$log, @Ext, @Rally) ->
diff --git a/src/scripts/directives/component-directive.coffee b/src/rally/directives/component-directive.coffee
similarity index 100%
rename from src/scripts/directives/component-directive.coffee
rename to src/rally/directives/component-directive.coffee
diff --git a/src/scripts/index.coffee b/src/rally/index.coffee
similarity index 64%
rename from src/scripts/index.coffee
rename to src/rally/index.coffee
index 5dd6d97..263bfbf 100644
--- a/src/scripts/index.coffee
+++ b/src/rally/index.coffee
@@ -1,3 +1,7 @@
angular.module('Ext', []).factory('Ext', () -> return Ext )
angular.module('Rally', []).factory('Rally', () -> return Rally )
-angular.module 'rally', ['rally.services', 'rally.app']
+angular.module 'rally', [
+ 'rally.api',
+ 'rally.app'
+ 'rally.util'
+]
diff --git a/src/rally/util/async/index.coffee b/src/rally/util/async/index.coffee
new file mode 100644
index 0000000..1b13f71
--- /dev/null
+++ b/src/rally/util/async/index.coffee
@@ -0,0 +1 @@
+angular.module('rally.util.async', []).factory('async', ()-> return async)
diff --git a/src/rally/util/cache/factories/wrap.coffee b/src/rally/util/cache/factories/wrap.coffee
new file mode 100644
index 0000000..120a2f3
--- /dev/null
+++ b/src/rally/util/cache/factories/wrap.coffee
@@ -0,0 +1,30 @@
+###*
+ * @ngdoc service
+ * @name rally.util.cache:$cacheWrap
+ * @type function
+ * @description
+ * Helper function that returns a cached value or runs your function to cache the result and return your value as a promise.
+ * If you value function returns a promise, the wrapper will wait for resolution and put the result in your cache.
+###
+angular.module('rally.util.cache.factories.wrap', ['rally.util.lodash']).factory '$cacheWrap', ($q)->
+ $cacheWrap = (cache, keyFn=_.identity) ->
+ return (key, func)->
+ key = keyFn(key)
+ if cache.get(key) then return $q.when(cache.get(key))
+
+ # Run the function
+ deferred = $q.defer()
+ cache.put(key, deferred.promise)
+
+ deferred.promise.then(
+ (result)->
+ cache.put(key, result)
+ ,
+ ()->
+ cache.remove(key)
+ )
+
+ deferred.resolve($q.when(func()))
+ return deferred.promise
+
+ return $cacheWrap
diff --git a/src/rally/util/cache/index.coffee b/src/rally/util/cache/index.coffee
new file mode 100644
index 0000000..6ae203e
--- /dev/null
+++ b/src/rally/util/cache/index.coffee
@@ -0,0 +1,3 @@
+angular.module('rally.util.cache',[
+ 'rally.util.cache.factories.wrap'
+])
diff --git a/src/rally/util/http/factories/httpWrapper.coffee b/src/rally/util/http/factories/httpWrapper.coffee
new file mode 100644
index 0000000..a6fabf9
--- /dev/null
+++ b/src/rally/util/http/factories/httpWrapper.coffee
@@ -0,0 +1,24 @@
+angular.module('rally.util.http.factories.httpWrapper', [
+ 'rally.util.lodash'
+]).factory 'rallyHttpWrapper', ($http)->
+ return (baseUrl, http=$http)->
+ getUrl = (url)->
+ toAdd = url
+ if baseUrl[baseUrl.length - 1] is '/' and url.indexOf('/') is 0
+ toAdd = toAdd.substring(1)
+ return baseUrl+toAdd
+
+ wrapper = (urlOrConfig, config)->
+ if _.isString(urlOrConfig)
+ urlOrConfig = getUrl(urlOrConfig)
+ else
+ urlOrConfig.url = getUrl(urlOrConfig.url)
+ http.call(this, urlOrConfig, config)
+
+ # Write shortcut methods for GET/POST/PUT/DELETE/etc
+ _.each ['GET', 'DELETE', 'POST', 'PUT'], (method)->
+ wrapper[method.toLowerCase()] = (url, args...)->
+ url = getUrl(url)
+ http[method.toLowerCase()].call(@, url, args...)
+
+ return wrapper
diff --git a/src/rally/util/http/index.coffee b/src/rally/util/http/index.coffee
new file mode 100644
index 0000000..211acd4
--- /dev/null
+++ b/src/rally/util/http/index.coffee
@@ -0,0 +1,4 @@
+angular.module('rally.util.http', [
+ 'rally.util.http.factories.httpWrapper'
+ 'rally.util.http.services.promise'
+])
diff --git a/src/rally/util/http/services/promise.coffee b/src/rally/util/http/services/promise.coffee
new file mode 100644
index 0000000..767f5a4
--- /dev/null
+++ b/src/rally/util/http/services/promise.coffee
@@ -0,0 +1,19 @@
+angular.module('rally.util.http.services.promise', [
+ 'rally.util.lodash'
+])
+.service 'httpPromise',
+
+ class HttpPromise
+ asArray: (promise)->
+ data = []
+ data.$promise = promise
+ promise.then (results)->
+ _.each results, (r)-> data.push(r)
+ return data
+
+ asObject: (promise)->
+ data = {}
+ data.$promise = promise
+ promise.then (result)->
+ _.merge data, result
+ return data
diff --git a/src/rally/util/lodash/index.coffee b/src/rally/util/lodash/index.coffee
new file mode 100644
index 0000000..8883786
--- /dev/null
+++ b/src/rally/util/lodash/index.coffee
@@ -0,0 +1,3 @@
+angular.module('rally.util.lodash', [
+ 'rally.util.lodash.sortedInsert'
+])
diff --git a/src/rally/util/lodash/sortedInsert.coffee b/src/rally/util/lodash/sortedInsert.coffee
new file mode 100644
index 0000000..bb7afc8
--- /dev/null
+++ b/src/rally/util/lodash/sortedInsert.coffee
@@ -0,0 +1,8 @@
+angular.module('rally.util.lodash.sortedInsert', []).run ->
+ _.sortedInsert = (array, value, pluck)->
+ index = _.sortedIndex( array, value, pluck )
+ array.splice( index, 0, value )
+ return array
+ _.sortedReverseInsert = (array, value, pluck)->
+ _.sortedInsert(array.reverse(), value, pluck)
+ return array.reverse()
diff --git a/src/rally/util/timeout/index.coffee b/src/rally/util/timeout/index.coffee
new file mode 100644
index 0000000..b9bdb49
--- /dev/null
+++ b/src/rally/util/timeout/index.coffee
@@ -0,0 +1,25 @@
+# Throttles and queues timeouts so you don't overload the worker thread and block the browser.
+angular.module('rally.util.timeout', [])
+.factory '$rallyTimeoutThrottleFactory', ($timeout)->
+ return (max)->
+ queue = []
+ running = 0
+
+ processNext = ()->
+ if running >= max then return
+ next = queue.shift()
+ context = next[0]
+ args = next[1...]
+ running = running + 1
+ $timeout.apply(context, args)['finally'](()->
+ running = running - 1
+ processNext()
+
+ )
+ return (args...)->
+ queue.push([this].concat(args))
+ processNext()
+
+# Default throttle processes 10 timeouts at a time.
+.factory '$rallyTimeoutThrottle', ($rallyTimeoutThrottleFactory)->
+ return $rallyTimeoutThrottleFactory(10)
diff --git a/src/rally/util/util.coffee b/src/rally/util/util.coffee
new file mode 100644
index 0000000..ef5520d
--- /dev/null
+++ b/src/rally/util/util.coffee
@@ -0,0 +1,7 @@
+util = angular.module 'rally.util', [
+ 'rally.util.async'
+ 'rally.util.cache'
+ 'rally.util.http'
+ 'rally.util.lodash'
+ 'rally.util.timeout'
+]
diff --git a/src/scripts/app/index.coffee b/src/scripts/app/index.coffee
deleted file mode 100644
index d930bc9..0000000
--- a/src/scripts/app/index.coffee
+++ /dev/null
@@ -1 +0,0 @@
-angular.module('rally.app', [])
\ No newline at end of file
diff --git a/src/scripts/services/index.coffee b/src/scripts/services/index.coffee
deleted file mode 100644
index eab0b62..0000000
--- a/src/scripts/services/index.coffee
+++ /dev/null
@@ -1 +0,0 @@
-angular.module('rally.services', ['rally.services.rally'])
diff --git a/test/rally/api/services/lookbackSpec.coffee b/test/rally/api/services/lookbackSpec.coffee
new file mode 100644
index 0000000..fa5e841
--- /dev/null
+++ b/test/rally/api/services/lookbackSpec.coffee
@@ -0,0 +1,15 @@
+describe 'rally.api.services.lookback', ->
+
+ describe '$lookbackProvider', ->
+ beforeEach ->
+ testModule = angular.module('test.rally.api.services.lookback', ['rally.api.services.lookback']).config (@$lookbackProvider) =>
+ @$lookbackProvider.setBaseUrl('/analytics')
+ angular.mock.module('rally.api.services.lookback', 'test.rally.api.services.lookback')
+
+ it 'should post a query to the lookback API', inject ($lookback, $httpBackend)->
+ $httpBackend.expectPOST('/analytics/workspace/123/artifact/snapshot/query', (body) =>
+ return body == '{"find":{"$pleaseDontFilterMe":"test"}}'
+ ).respond(200)
+ find = { $pleaseDontFilterMe: 'test' }
+ $lookback.artifactSnapshots(123, {find})
+ $httpBackend.flush()
diff --git a/test/rally/api/services/wsapiSpec.coffee b/test/rally/api/services/wsapiSpec.coffee
new file mode 100644
index 0000000..67719ad
--- /dev/null
+++ b/test/rally/api/services/wsapiSpec.coffee
@@ -0,0 +1,13 @@
+describe 'rally.api.services.wsapi', ->
+
+ describe '$wsapiProvider', ->
+ beforeEach ->
+ testModule = angular.module('test.rally.api.services.wsapi', ['rally.api.services.wsapi']).config (@$slmProvider, @$wsapiProvider) =>
+ @$slmProvider.setBaseUrl('/slm/')
+ @$wsapiProvider.setBaseUrl('wsapi/')
+ angular.mock.module('rally.api.services.wsapi', 'test.rally.api.services.wsapi')
+
+ it 'should append my request to the slm url', inject ($wsapi, $httpBackend)->
+ $httpBackend.expectGET('/slm/wsapi/query').respond(200)
+ $wsapi.get('query')
+ $httpBackend.flush()
diff --git a/test/app/iframe/decorators/rootScope_spec.coffee b/test/rally/app/iframe/decorators/rootScope_spec.coffee
similarity index 100%
rename from test/app/iframe/decorators/rootScope_spec.coffee
rename to test/rally/app/iframe/decorators/rootScope_spec.coffee
diff --git a/test/app/iframe/services/iframeMessageBus_spec.coffee b/test/rally/app/iframe/services/iframeMessageBus_spec.coffee
similarity index 100%
rename from test/app/iframe/services/iframeMessageBus_spec.coffee
rename to test/rally/app/iframe/services/iframeMessageBus_spec.coffee
diff --git a/test/rally/util/http/factories/httpWrapperSpec.coffee b/test/rally/util/http/factories/httpWrapperSpec.coffee
new file mode 100644
index 0000000..465d058
--- /dev/null
+++ b/test/rally/util/http/factories/httpWrapperSpec.coffee
@@ -0,0 +1,25 @@
+describe 'rally.util.http.factories.httpWrapper.rallyHttpWrapper', ->
+
+ beforeEach angular.mock.module 'rally.util.http.factories.httpWrapper'
+ beforeEach inject (@rallyHttpWrapper, @$httpBackend)->
+
+ beforeEach ->
+ @composition = @rallyHttpWrapper('/baseUrl/')
+ afterEach ->
+ @$httpBackend.flush()
+
+ it 'should append my request to the base url', ->
+ @$httpBackend.expectGET('/baseUrl/query').respond(200)
+ @composition({method: 'GET', url: 'query'})
+
+ it 'should remove double slashes', ->
+ @$httpBackend.expectGET('/baseUrl/query').respond(200)
+ @composition({method: 'GET', url: '/query'})
+
+ for method in ['GET','POST','PUT','DELETE']
+ do (method) ->
+ it "should support the shortcut method #{method}", inject ($http)->
+ spyOn($http, method.toLowerCase()).andCallThrough()
+ @$httpBackend.expect(method, '/baseUrl/query').respond(200)
+ @composition[method.toLowerCase()]('/query')
+ expect($http[method.toLowerCase()]).toHaveBeenCalled()
diff --git a/test/rally/util/timeout/indexSpec.coffee b/test/rally/util/timeout/indexSpec.coffee
new file mode 100644
index 0000000..97fba7b
--- /dev/null
+++ b/test/rally/util/timeout/indexSpec.coffee
@@ -0,0 +1,63 @@
+describe 'rally.util.timeout', ->
+
+ beforeEach angular.mock.module('rally.util.timeout')
+
+ describe '$rallyTimeoutThrottleFactory', ->
+ beforeEach ->
+ @$timeout = jasmine.createSpy('$timeout')
+ angular.module('test.rally.util.timeout', []).value('$timeout', @$timeout)
+ angular.mock.module('test.rally.util.timeout')
+
+ beforeEach inject (@$rallyTimeoutThrottleFactory, @$q)->
+ @$throttle = @$rallyTimeoutThrottleFactory(2)
+
+ it 'should delegate to $timeout to run my function', ->
+ @$timeout.andReturn(@$q.when())
+ spy = jasmine.createSpy('func')
+ @$throttle(spy, 'foo', 'bar')
+ expect(@$timeout).toHaveBeenCalledWith(spy, 'foo', 'bar')
+
+ it 'should queue my timeouts', ->
+ spies = _.map([0,1,2], (i)-> jasmine.createSpy("throttled:#{i}"))
+ timeoutCalls = []
+ @$timeout.andCallFake (args...)=>
+ deferred = @$q.defer()
+ timeoutCalls.push(deferred)
+ return deferred.promise
+
+ _.each(spies, (spy)=>@$throttle(spy))
+ expect(timeoutCalls.length).toEqual(2)
+ expect(@$timeout).toHaveBeenCalledWith(spies[0])
+ expect(@$timeout).toHaveBeenCalledWith(spies[1])
+ expect(@$timeout).not.toHaveBeenCalledWith(spies[2])
+
+ it 'should run timeouts when queue frees up', inject ($rootScope)->
+ spies = _.map([0,1,2], (i)-> jasmine.createSpy("throttled:#{i}"))
+ timeoutCalls = []
+ @$timeout.andCallFake (args...)=>
+ deferred = @$q.defer()
+ timeoutCalls.push(deferred)
+ return deferred.promise
+
+ _.each(spies, (spy)=>@$throttle(spy))
+ expect(timeoutCalls.length).toEqual(2)
+ expect(@$timeout).toHaveBeenCalledWith(spies[0])
+ expect(@$timeout).toHaveBeenCalledWith(spies[1])
+ expect(@$timeout).not.toHaveBeenCalledWith(spies[2])
+
+ # Resolve a queued timeout and
+ timeoutCalls[1].resolve()
+ $rootScope.$digest()
+
+ expect(@$timeout).toHaveBeenCalledWith(spies[2])
+ expect(timeoutCalls.length).toEqual(3)
+
+ describe 'default $rallyTimeoutThrottle', ->
+
+ beforeEach ->
+ @factorySpy = jasmine.createSpy()
+ angular.module('test.rally.util.timeout', []).value('$rallyTimeoutThrottleFactory', @factorySpy)
+ angular.mock.module('test.rally.util.timeout')
+
+ it 'should create a timeout throttle with a default throttle value of 10', inject ($rallyTimeoutThrottle)->
+ expect(@factorySpy).toHaveBeenCalledWith(10)