Formalized Resource Criteria API

An effort to abstract query parameters so that the resources inform
the system itself how the API is exposed. Could be the first step in
making our API self-describing.

- Deprecated Browse, it was serving only as an abstraction layer
  for Criteria.
- The Criteria service now provides several lambda builders to assist
  in the generation of search criteria. Filtering criteria against a
  resource, mapping criteria against parameters, and generating search
  lambdas.
- Search Criteria controller now has an initialization method that
  allows you to configure it at runtime to the resource it's supposed to
  provide search criteria for.
- Criteria Tag Item has been split out to be more generic.
- Managing criteria has been delegated to the resource itself. Now
  it falls to Project, Story, etc. to inform the system what fields
  it accepts as query parameters.
- Common text criteria resolver has been added.
- Header controller has been switched to use new Resource Criteria API.
- Stories may now be queried on the Story Status field.
- Services are now generated via a service factory rather than by
  using storyboardApiSignature.
- Search is now handled via the resource.search() method.

Change-Id: I1e355320c90f7cdc8cb6ee2191fe819ff7094665
This commit is contained in:
Michael Krotscheck 2014-07-26 15:41:22 -07:00
parent 56004e375d
commit ad453400de
23 changed files with 739 additions and 562 deletions

View File

@ -0,0 +1,47 @@
/*
* Copyright (c) 2014 Hewlett-Packard Development Company, L.P.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
/**
* This controller provides initialization logic for the generic search view.
*/
angular.module('sb.search').controller('SearchController',
function ($log, $q, $scope, Criteria, $stateParams) {
'use strict';
/**
* Default criteria, potentially populated by the q param.
*
* @type {Array}
*/
$scope.defaultCriteria = [];
/**
* List of resource types which this view will be searching on.
*
* @type {string[]}
*/
$scope.resourceTypes = ['Story', 'Project', 'User', 'Task'];
/**
* If a 'q' exists in the state params, go ahead and add it.
*/
if ($stateParams.hasOwnProperty('q') && !!$stateParams.q) {
$scope.defaultCriteria.push(
Criteria.create('Text', $stateParams.q)
);
}
}
);

View File

@ -16,18 +16,19 @@
/**
* The sole purpose of this controller is to allow a user to search for valid
* search/filter criteria, and expose chosen criteria to the scope. These
* criteria may either be resources, resource identifiers (type/id pairs),
* or plain strings.
* search/filter criteria for various resources, and expose chosen criteria
* to the scope. These criteria may be static or asynchronously loaded, and
* may be property filters (title = foo) or resource filters (story_id = 22).
*/
angular.module('sb.search').controller('SearchCriteriaController',
function ($log, $q, $scope, Criteria, Browse, $stateParams) {
function ($log, $q, $scope, Criteria) {
'use strict';
/**
* Valid sets of resources that can be searched on.
* Valid sets of resources that can be searched on. The default
* assumes no resources may be searched.
*/
var resourceTypes = ['Story', 'Project', 'User', 'Task'];
var resourceTypes = [];
/**
* Managed list of active criteria tags.
@ -37,32 +38,34 @@ angular.module('sb.search').controller('SearchCriteriaController',
$scope.criteria = [];
/**
* When a criteria is added, make sure we remove duplicates - the
* control doesn't handle that for us.
* Initialize this controller with different resource types and
* default search criteria.
*
* @param types
* @param defaultCriteria
*/
$scope.init = function (types, defaultCriteria) {
resourceTypes = types || resourceTypes;
$scope.criteria = defaultCriteria || [];
$scope.searchForCriteria =
Criteria.buildCriteriaSearch(resourceTypes);
};
/**
* When a criteria is added, make sure we remove all previous criteria
* that have the same type.
*/
$scope.addCriteria = function (item) {
var idx = $scope.criteria.indexOf(item);
for (var i = 0; i < $scope.criteria.length; i++) {
for (var i = $scope.criteria.length - 1; i >= 0; i--) {
var cItem = $scope.criteria[i];
// Don't remove exact duplicates.
if (idx === i) {
if (cItem === item) {
continue;
}
// We can only search for one text type at a time.
if (item.type === 'text' &&
cItem.type === 'text') {
if (item.type === cItem.type) {
$scope.criteria.splice(i, 1);
break;
}
// Remove any duplicate value types.
if (item.type === cItem.type &&
item.value === cItem.value) {
$scope.criteria.splice(i, 1);
break;
}
}
};
@ -99,30 +102,10 @@ angular.module('sb.search').controller('SearchCriteriaController',
/**
* Search for available search criteria.
*/
$scope.searchForCriteria = function (searchString) {
$scope.searchForCriteria = function () {
var deferred = $q.defer();
searchString = searchString || '';
Browse.all(searchString).then(function (results) {
// Add text.
results.unshift(Criteria.create('text', searchString));
deferred.resolve(results);
});
// Return the search promise.
deferred.resolve([]);
return deferred.promise;
};
/**
* If a 'q' exists in the state params, go ahead and add it.
*/
if ($stateParams.hasOwnProperty('q') && !!$stateParams.q) {
$scope.criteria.push(
Criteria.create('text', $stateParams.q)
);
}
}
);

View File

@ -27,6 +27,7 @@ angular.module('sb.search',
$stateProvider
.state('search', {
url: '/search?q',
templateUrl: 'app/search/template/index.html'
templateUrl: 'app/search/template/index.html',
controller: 'SearchController'
});
});

View File

@ -0,0 +1,60 @@
<!--
~ Copyright (c) 2014 Hewlett-Packard Development Company, L.P.
~
~ Licensed under the Apache License, Version 2.0 (the "License"); you may
~ not use this file except in compliance with the License. You may obtain
~ a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
~ WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
~ License for the specific language governing permissions and limitations
~ under the License.
-->
<span ng-switch="tag.type">
<div class="tag" ng-switch-when="Text">
{{tag.value}}
<a class="tag-remove"
ng-click="removeTag({tag:tag})">
&times;
</a>
</div>
<div class="tag tag-success" ng-switch-when="User">
<i class="fa fa-user"></i> {{tag.title}}
<a class="tag-remove"
ng-click="removeTag({tag:tag})">
&times;
</a>
</div>
<div class="tag tag-warning" ng-switch-when="Story">
<i class="fa fa-sb-story"></i> {{tag.title}}
<a class="tag-remove"
ng-click="removeTag({tag:tag})">
&times;
</a>
</div>
<div class="tag tag-default" ng-switch-when="StoryStatus">
<i class="fa fa-flag"></i> Story Status: {{tag.title}}
<a class="tag-remove"
ng-click="removeTag({tag:tag})">
&times;
</a>
</div>
<div class="tag tag-info" ng-switch-when="Project">
<i class="fa fa-sb-project"></i> {{tag.title}}
<a class="tag-remove"
ng-click="removeTag({tag:tag})">
&times;
</a>
</div>
<div class="tag tag-danger" ng-switch-default>
<i class="fa fa-question"></i> {{tag.type}}: {{tag.value}}
<a class="tag-remove"
ng-click="removeTag({tag:tag})">
&times;
</a>
</div>
</span>

View File

@ -13,7 +13,8 @@
~ License for the specific language governing permissions and limitations
~ under the License.
-->
<div class="container" ng-controller="SearchCriteriaController">
<div class="container" ng-controller="SearchCriteriaController"
ng-init="init(resourceTypes, defaultCriteria)">
<div class="row">
<div class="col-xs-12">
<h1>Search</h1>
@ -28,7 +29,7 @@
tag-complete-tags="criteria"
tag-complete-label-field="title"
tag-complete-option-template-url="'app/search/template/typeahead_criteria_item.html'"
tag-complete-tag-template-url="'/inline/criteria_tag_item.html'"
tag-complete-tag-template-url="'app/search/template/criteria_tag_item.html'"
tag-complete-loading="loadingCriteria = isLoading"
tag-complete-on-select="addCriteria(tag)">
</div>
@ -235,44 +236,4 @@
</table>
</div>
</div>
</div>
<script type="text/ng-template" id="/inline/criteria_tag_item.html">
<span ng-switch="tag.type">
<div class="tag" ng-switch-when="text">
{{tag.value}}
<a class="tag-remove"
ng-click="removeTag({tag:tag})">
&times;
</a>
</div>
<div class="tag tag-success" ng-switch-when="user">
<i class="fa fa-user"></i> {{tag.title}}
<a class="tag-remove"
ng-click="removeTag({tag:tag})">
&times;
</a>
</div>
<div class="tag tag-warning" ng-switch-when="story">
<i class="fa fa-sb-story"></i> {{tag.title}}
<a class="tag-remove"
ng-click="removeTag({tag:tag})">
&times;
</a>
</div>
<div class="tag tag-info" ng-switch-when="project">
<i class="fa fa-sb-project"></i> {{tag.title}}
<a class="tag-remove"
ng-click="removeTag({tag:tag})">
&times;
</a>
</div>
<div class="tag tag-danger" ng-switch-default>
<i class="fa fa-question"></i> {{tag.type}}: {{tag.value}}
<a class="tag-remove"
ng-click="removeTag({tag:tag})">
&times;
</a>
</div>
</span>
</script>
</div>

View File

@ -1,16 +1,20 @@
<a ng-switch="match.model.type"
class="header-criteria-item">
<span ng-switch-when="text">
<span ng-switch-when="Text">
<i class="fa fa-search text-muted"></i>&emsp;{{match.model.title}}
</span>
<span ng-switch-when="story">
<span ng-switch-when="Story">
<i class="fa fa-sb-story text-muted"></i>&emsp;{{match.model.value}}:
{{match.model.title}}
</span>
<span ng-switch-when="project">
<span ng-switch-when="StoryStatus">
<i class="fa fa-flag text-muted"></i>&emsp;Story Status:
{{match.model.title}}
</span>
<span ng-switch-when="Project">
<i class="fa fa-sb-project text-muted"></i>&emsp;{{match.model.title}}
</span>
<span ng-switch-when="user">
<span ng-switch-when="User">
<i class="fa fa-sb-user text-muted"></i>&emsp;{{match.model.title}}
</span>
<span ng-switch-default>

View File

@ -0,0 +1,190 @@
/*
* Copyright (c) 2014 Hewlett-Packard Development Company, L.P.
*
* Licensed under the Apache License, Version 2.0 (the 'License'); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
/**
* A service which centralizes management of search criteria: Creation,
* validation, filtering, criteria-to-parameter mapping, and more.
*/
angular.module('sb.services').service('Criteria',
function ($q, $log, $injector) {
'use strict';
return {
/**
* This method takes a set of criteria, and filters out the
* ones not valid for the passed resource.
*
* @param resourceName The name of the resource to filter for.
* @param criteria The list of criteria.
* @return {Array} A map of URL parameters.
*/
filterCriteria: function (resourceName, criteria) {
var resource = $injector.get(resourceName);
// Sanity check: If we don't have this resource, wat?
if (!resource || !resource.hasOwnProperty('criteriaFilter')) {
$log.warn('Attempting to filter criteria for unknown ' +
'resource "' + resourceName + '"');
return [];
}
return resource.criteriaFilter(criteria);
},
/**
* This method takes a set of criteria, and maps them against the
* query parameters available for the provided resource. It will
* skip any items not valid for this resource, and return an
* array of criteria that are valid
*
* @param resourceName
* @param criteria
* @return A map of URL parameters.
*/
mapCriteria: function (resourceName, criteria) {
var resource = $injector.get(resourceName);
// Sanity check: If we don't have this resource, wat?
if (!resource || !resource.hasOwnProperty('criteriaMap')) {
$log.warn('Attempting to map criteria for unknown ' +
'resource "' + resourceName + '"');
return {};
}
return resource.criteriaMap(criteria);
},
/**
* Create a new build criteria object.
*
* @param type The type of the criteria tag.
* @param value Value of the tag. Unique DB ID, or text string.
* @param title The title of the criteria tag.
* @returns {Criteria}
*/
create: function (type, value, title) {
title = title || value;
return {
'type': type,
'value': value,
'title': title
};
},
/**
* Rather than actually performing a search, this method returns a
* customized lambda that will perform our browse search for us.
*
* @param types An array of resource types to browse.
*/
buildCriteriaSearch: function (types) {
var resolvers = [];
types.forEach(function (type) {
// Retrieve an instance of the declared resource.
var resource = $injector.get(type);
if (!resource.hasOwnProperty('criteriaResolvers')) {
$log.warn('Resource type "' + type +
'" does not implement criteriaResolvers.');
return;
}
resource.criteriaResolvers().forEach(function (resolver) {
if (resolvers.indexOf(resolver) === -1) {
resolvers.push(resolver);
}
});
});
/**
* Construct the search lambda that issues the search
* and assembles the results.
*/
return function (searchString) {
var deferred = $q.defer();
// Clear the criteria
var promises = [];
resolvers.forEach(function (resolver) {
promises.push(resolver(searchString));
});
// Wrap everything into a collective promise
$q.all(promises).then(function (results) {
var criteria = [];
results.forEach(function (result) {
result.forEach(function (item) {
criteria.push(item);
});
});
deferred.resolve(criteria);
});
// Return the search promise.
return deferred.promise;
};
},
/**
* This method takes a set of criteria, and filters out the
* ones not valid for the passed resource.
*
* @param parameterMap A map of criteria types and parameters
* in the search query they correspond to.
* @return {Function} A criteria filter for the passed parameters.
*/
buildCriteriaFilter: function (parameterMap) {
return function (criteria) {
var filteredCriteria = [];
criteria.forEach(function (item) {
if (parameterMap.hasOwnProperty(item.type)) {
filteredCriteria.push(item);
}
});
return filteredCriteria;
};
},
/**
* This method takes a set of criteria, and maps them against the
* query parameters available for the provided resource. It will
* skip any items not valid for this resource, and return an
* array of criteria that are valid
*
* @param parameterMap A map of criteria types and parameters
* in the search query they correspond to.
* @return {Function} A criteria mapper for the passed parameters.
*/
buildCriteriaMap: function (parameterMap) {
return function (criteria) {
var params = {};
criteria.forEach(function (item) {
if (parameterMap.hasOwnProperty(item.type)) {
params[parameterMap[item.type]] = item.value;
}
});
return params;
};
}
};
}
);

View File

@ -0,0 +1,62 @@
/*
* Copyright (c) 2014 Hewlett-Packard Development Company, L.P.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
/**
* This criteria resolver may be injected by individual resources that accept a
* Story Status search parameters.
*/
angular.module('sb.services').factory('StoryStatus',
function (Criteria, $q) {
'use strict';
/**
* A list of valid story status items.
*
* @type {*[]}
*/
var validStatusCriteria = [
Criteria.create('StoryStatus', 'active', 'Active'),
Criteria.create('StoryStatus', 'merged', 'Merged'),
Criteria.create('StoryStatus', 'invalid', 'Invalid')
];
/**
* Return a criteria resolver for story status.
*/
return {
criteriaResolver: function (searchString) {
var deferred = $q.defer();
searchString = searchString || ''; // Sanity check
searchString = searchString.toLowerCase(); // Lowercase search
var criteria = [];
validStatusCriteria.forEach(function (criteriaItem) {
var title = criteriaItem.title.toLowerCase();
// If we match the title, OR someone is explicitly typing in
// 'status'
if (title.indexOf(searchString) > -1 ||
'status'.indexOf(searchString) === 0) {
criteria.push(criteriaItem);
}
});
deferred.resolve(criteria);
return deferred.promise;
}
};
});

View File

@ -0,0 +1,39 @@
/*
* Copyright (c) 2014 Hewlett-Packard Development Company, L.P.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
/**
* This criteria resolver may be injected by individual resources that accept a
* plain text search parameter.
*/
angular.module('sb.services').factory('Text',
function (Criteria, $q) {
'use strict';
/**
* Return a text search parameter constructed from the passed search
* string.
*/
return {
criteriaResolver: function (searchString) {
var deferred = $q.defer();
deferred.resolve([Criteria.create('Text', searchString)]);
return deferred.promise;
}
};
});

View File

@ -1,51 +0,0 @@
/*
* Copyright (c) 2014 Hewlett-Packard Development Company, L.P.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
/**
* In lieu of extension, here we're injecting our common API signature that
* can be reused by all of our services.
*
* @author Michael Krotscheck
*/
angular.module('sb.services')
.factory('storyboardApiSignature', function (pageSize) {
'use strict';
return {
'create': {
method: 'POST'
},
'read': {
method: 'GET',
cache: false
},
'update': {
method: 'PUT'
},
'delete': {
method: 'DELETE'
},
'query': {
method: 'GET',
isArray: true,
responseType: 'json',
params: {
limit: pageSize
}
}
};
}
);

View File

@ -1,151 +0,0 @@
/*
* Copyright (c) 2014 Hewlett-Packard Development Company, L.P.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
/**
* A browse service, which wraps common resources and their typeahead
* resolution into a single service that returns a common result format.
* It is paired with the Criteria service to provide a consistent data
* format to identify resources independent of their actual schema.
*/
angular.module('sb.services').factory('Browse',
function ($q, $log, Project, Story, User, Criteria) {
'use strict';
return {
/**
* Browse projects by search string.
*
* @param searchString A string to search by.
* @return A promise that will resolve with the search results.
*/
project: function (searchString) {
// Search for projects...
var deferred = $q.defer();
Project.query({name: searchString},
function (result) {
// Transform the results to criteria tags.
var projResults = [];
result.forEach(function (item) {
projResults.push(
Criteria.create('project', item.id, item.name)
);
});
deferred.resolve(projResults);
}, function () {
deferred.resolve([]);
}
);
return deferred.promise;
},
/**
* Browse users by search string.
*
* @param searchString A string to search by.
* @return A promise that will resolve with the search results.
*/
user: function (searchString) {
// Search for users...
var deferred = $q.defer();
User.query({full_name: searchString},
function (result) {
// Transform the results to criteria tags.
var userResults = [];
result.forEach(function (item) {
userResults.push(
Criteria.create('user', item.id, item.full_name)
);
});
deferred.resolve(userResults);
}, function () {
deferred.resolve([]);
}
);
return deferred.promise;
},
/**
* Browse stories by search string.
*
* @param searchString A string to search by.
* @return A promise that will resolve with the search results.
*/
story: function (searchString) {
// Search for stories...
var deferred = $q.defer();
Story.query({title: searchString},
function (result) {
// Transform the results to criteria tags.
var storyResults = [];
result.forEach(function (item) {
storyResults.push(
Criteria.create('story', item.id, item.title)
);
});
deferred.resolve(storyResults);
}, function () {
deferred.resolve([]);
}
);
return deferred.promise;
},
/**
* Browse all resources by a provided search string.
*
* @param searchString
* @return A promise that will resolve with the search results.
*/
all: function (searchString) {
var deferred = $q.defer();
// Clear the criteria
var criteria = [];
// Wrap everything into a collective promise
$q.all({
projects: this.project(searchString),
stories: this.story(searchString),
users: this.user(searchString)
}).then(function (results) {
// Add the returned projects to the results list.
results.projects.forEach(function (item) {
criteria.push(item);
});
// Add the returned stories to the results list.
results.stories.forEach(function (item) {
criteria.push(item);
});
// Add the returned stories to the results list.
results.users.forEach(function (item) {
criteria.push(item);
});
deferred.resolve(criteria);
});
// Return the search promise.
return deferred.promise;
}
};
});

View File

@ -21,13 +21,15 @@
* @see storyboardApiSignature
*/
angular.module('sb.services').factory('Comment',
function ($resource, storyboardApiBase, storyboardApiSignature) {
function (ResourceFactory) {
'use strict';
return $resource(storyboardApiBase + '/stories/:story_id/comments/:id',
return ResourceFactory.build(
'/stories/:story_id/comments/:id',
'/stories/0/search',
{
id: '@id',
story_id: '@story_id'
},
storyboardApiSignature);
}
);
});

View File

@ -1,131 +0,0 @@
/*
* Copyright (c) 2014 Hewlett-Packard Development Company, L.P.
*
* Licensed under the Apache License, Version 2.0 (the 'License'); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
/**
* A service which centralizes management of search criteria: Creation,
* validation, filtering, criteria-to-parameter mapping, and more.
*/
angular.module('sb.services').service('Criteria',
function ($log) {
'use strict';
var resourceParams = {
Project: {
text: 'name'
},
Story: {
project: 'project_id',
text: 'title',
user: 'assignee_id'
},
Task: {
story: 'story_id',
user: 'assignee_id'
},
User: {
text: 'full_name'
}
};
return {
/**
* Is this resource name valid?
*
* @param resourceName
* @returns {boolean}
*/
isValidResource: function (resourceName) {
return resourceParams.hasOwnProperty(resourceName);
},
/**
* This method takes a set of criteria, and filters out the
* ones not valid for the passed resource.
*
* @param resourceName The name of the resource to filter for.
* @param criteria The list of criteria.
* @return {Array} A map of URL parameters.
*/
filterCriteria: function (resourceName, criteria) {
// Sanity check: If we don't have this resource, wat?
if (!this.isValidResource(resourceName)) {
$log.warn('Attempting to filter criteria for unknown ' +
'resource "' + resourceName + '"');
return [];
}
var filteredCriteria = [];
var mapping = resourceParams[resourceName];
criteria.forEach(function (item) {
if (mapping.hasOwnProperty(item.type)) {
filteredCriteria.push(item);
}
});
return filteredCriteria;
},
/**
* This method takes a set of criteria, and maps them against the
* query parameters available for the provided resource. It will
* skip any items not valid for this resource, and return an
* array of criteria that are valid
*
* @param resourceName
* @param criteria
* @return A map of URL parameters.
*/
mapCriteria: function (resourceName, criteria) {
// Sanity check: If we don't have this resource, wat?
if (!this.isValidResource(resourceName)) {
$log.warn('Attempting to filter criteria for unknown ' +
'resource "' + resourceName + '"');
return [];
}
var params = {};
var mapping = resourceParams[resourceName];
criteria.forEach(function (item) {
if (mapping.hasOwnProperty(item.type)) {
params[mapping[item.type]] = item.value;
}
});
return params;
},
/**
* Create a new build criteria object.
*
* @param type The type of the criteria tag.
* @param value Value of the tag. Unique DB ID, or text string.
* @param title The title of the criteria tag.
* @returns {Criteria}
*/
create: function (type, value, title) {
title = title || value;
return {
'type': type,
'value': value,
'title': title
};
}
};
}
);

View File

@ -18,14 +18,24 @@
* The angular resource abstraction that allows us to access projects and their
* details.
*
* @see storyboardApiSignature
* @see ResourceFactory
* @author Michael Krotscheck
*/
angular.module('sb.services').factory('Project',
function ($resource, storyboardApiBase, storyboardApiSignature) {
function (ResourceFactory) {
'use strict';
return $resource(storyboardApiBase + '/projects/:id',
{id: '@id'},
storyboardApiSignature);
var resource = ResourceFactory.build(
'/projects/:id',
'/projects/search',
{id: '@id'}
);
ResourceFactory.applyBrowse(
'Project',
resource,
{Text: 'name'}
);
return resource;
});

View File

@ -17,14 +17,16 @@
/**
* The angular resource abstraction that allows us to access projects groups.
*
* @see storyboardApiSignature
* @see ResourceFactory
* @author Michael Krotscheck
*/
angular.module('sb.services').factory('ProjectGroup',
function ($resource, storyboardApiBase, storyboardApiSignature) {
function (ResourceFactory) {
'use strict';
return $resource(storyboardApiBase + '/project_groups/:id',
{id: '@id'},
storyboardApiSignature);
return ResourceFactory.build(
'/project_groups/:id',
'/project_groups/search',
{id: '@id'}
);
});

View File

@ -20,10 +20,25 @@
* @see storyboardApiSignature
*/
angular.module('sb.services').factory('Story',
function ($resource, storyboardApiBase, storyboardApiSignature) {
function (ResourceFactory) {
'use strict';
return $resource(storyboardApiBase + '/stories/:id',
{id: '@id'},
storyboardApiSignature);
var resource = ResourceFactory.build(
'/stories/:id',
'/stories/search',
{id: '@id'}
);
ResourceFactory.applyBrowse(
'Story',
resource,
{
Text: 'title',
StoryStatus: 'status',
Project: 'project_id',
User: 'assignee_id'
}
);
return resource;
});

View File

@ -21,10 +21,23 @@
* @author Michael Krotscheck
*/
angular.module('sb.services').factory('Task',
function ($resource, storyboardApiBase, storyboardApiSignature) {
function (ResourceFactory) {
'use strict';
return $resource(storyboardApiBase + '/tasks/:id',
{id: '@id'},
storyboardApiSignature);
var resource = ResourceFactory.build(
'/tasks/:id',
'/tasks/search',
{id: '@id'}
);
ResourceFactory.applyBrowse(
'Task',
resource,
{
Story: 'story_id',
User: 'assignee_id'
}
);
return resource;
});

View File

@ -22,10 +22,14 @@
* @author Michael Krotscheck
*/
angular.module('sb.services').factory('Team',
function ($resource, storyboardApiBase, storyboardApiSignature) {
function (ResourceFactory) {
'use strict';
return $resource(storyboardApiBase + '/teams/:id',
{id: '@id'},
storyboardApiSignature);
return ResourceFactory.build(
'/teams/:id',
'/teams/search', // Not implemented.
{
id: '@id'
}
);
});

View File

@ -21,13 +21,15 @@
* @see storyboardApiSignature
*/
angular.module('sb.services').factory('TimelineEvent',
function ($resource, storyboardApiBase, storyboardApiSignature) {
function (ResourceFactory) {
'use strict';
return $resource(storyboardApiBase + '/stories/:story_id/events/:id',
return ResourceFactory.build(
'/stories/:story_id/events/:id',
'/stories/:story_id/events/search', // Not implemented.
{
id: '@id',
story_id: '@story_id'
},
storyboardApiSignature);
}
);
});

View File

@ -18,14 +18,24 @@
* The angular resource abstraction that allows us to search, access, and
* modify users.
*
* @see storyboardApiSignature
* @see ResourceFactory
* @author Michael Krotscheck
*/
angular.module('sb.services').factory('User',
function ($resource, storyboardApiBase, storyboardApiSignature) {
function (ResourceFactory) {
'use strict';
return $resource(storyboardApiBase + '/users/:id',
{id: '@id'},
storyboardApiSignature);
var resource = ResourceFactory.build(
'/users/:id',
'/users/search',
{id: '@id'}
);
ResourceFactory.applyBrowse(
'User',
resource,
{Text: 'full_name'}
);
return resource;
});

View File

@ -0,0 +1,198 @@
/*
* Copyright (c) 2014 Hewlett-Packard Development Company, L.P.
*
* Licensed under the Apache License, Version 2.0 (the 'License'); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
/**
* Factory methods that simply construction of storyboard API resources.
*
* @author Michael Krotscheck
*/
angular.module('sb.services')
.service('ResourceFactory',
function ($q, $log, $injector, Criteria, $resource, storyboardApiBase) {
'use strict';
/**
* This method is used in our API signature to return a recent value
* for the user's pageSize preference.
*
* @returns {*}
*/
function getLimit() {
return $injector.get('pageSize');
}
/**
* Construct a full API signature for a specific resource. Includes
* CRUD, Browse, and Search. If the resource doesn't support it,
* don't use it :).
*
* @param searchUrl
* @returns An API signature that may be used with a $resource.
*/
function buildSignature(searchUrl) {
return {
'create': {
method: 'POST'
},
'read': {
method: 'GET',
cache: false
},
'update': {
method: 'PUT'
},
'delete': {
method: 'DELETE'
},
'browse': {
method: 'GET',
isArray: true,
responseType: 'json',
params: {
limit: getLimit
}
},
'search': {
method: 'GET',
url: searchUrl,
isArray: true,
responseType: 'json',
params: {
limit: getLimit
}
}
};
}
return {
/**
* Build a resource URI.
*
* @param restUri
* @param searchUri
* @param resourceParameters
* @returns {*}
*/
build: function (restUri, searchUri, resourceParameters) {
if (!restUri) {
$log.error('Cannot use resource factory ' +
'without a base REST uri.');
return null;
}
var signature = buildSignature(storyboardApiBase + searchUri);
return $resource(storyboardApiBase + restUri,
resourceParameters, signature);
},
/**
* This method takes an already configured resource, and applies
* the static methods necessary to support the criteria browse API.
* Browse parameters should be formatted as an object containing
* 'injector name': 'param'. For example, {'Project': 'project_id'}.
*
* @param resourceName The explicit resource name of this resource
* within the injection scope.
* @param resource The configured resource.
* @param browseParameters The browse parameters to apply.
*/
applyBrowse: function (resourceName, resource, browseParameters) {
// List of criteria resolvers which we're building.
var criteriaResolvers = [];
var browseParameter = null; // Default is ''
for (var type in browseParameters) {
// Store the browse parameter for later.
if (type === 'Text') {
browseParameter = browseParameters[type];
}
// If the requested type exists and has a criteriaResolver
// method, add it to the list of resolvable browse criteria.
var typeResource = $injector.get(type);
if (!!typeResource &&
typeResource.hasOwnProperty('criteriaResolver')) {
criteriaResolvers.push(typeResource.criteriaResolver);
}
}
/**
* Return a list of promise-returning methods that, given a
* browse string, will provide a list of search criteria.
*
* @returns {*[]}
*/
resource.criteriaResolvers = function () {
return criteriaResolvers;
};
// If we found a browse parameter, add the ability to use
// this resource as a source of criteria.
if (!!browseParameter) {
/**
* Add the criteria resolver method.
*/
resource.criteriaResolver = function (searchString) {
var deferred = $q.defer();
// build the query parameters.
var queryParams = {};
queryParams[browseParameter] = searchString;
resource.query(queryParams,
function (result) {
// Transform the results to criteria tags.
var criteriaResults = [];
result.forEach(function (item) {
criteriaResults.push(
Criteria.create(resourceName,
item.id,
item[browseParameter])
);
});
deferred.resolve(criteriaResults);
}, function () {
deferred.resolve([]);
}
);
return deferred.promise;
};
}
/**
* The criteria filter.
*/
resource.criteriaFilter = Criteria
.buildCriteriaFilter(browseParameters);
/**
* The criteria map.
*/
resource.criteriaMap = Criteria
.buildCriteriaMap(browseParameters);
}
};
});

View File

@ -20,8 +20,8 @@
*/
angular.module('storyboard').controller('HeaderController',
function ($q, $scope, $rootScope, $state, NewStoryService, Session,
SessionState, CurrentUser, Browse, Criteria, Notification,
Priority) {
SessionState, CurrentUser, Criteria, Notification,
Priority, Project, Story) {
'use strict';
function resolveCurrentUser() {
@ -72,13 +72,13 @@ angular.module('storyboard').controller('HeaderController',
$scope.search = function (criteria) {
switch (criteria.type) {
case 'text':
case 'Text':
$state.go('search', {q: criteria.value});
break;
case 'project':
case 'Project':
$state.go('project.detail', {id: criteria.value});
break;
case 'story':
case 'Story':
$state.go('story.detail', {storyId: criteria.value});
break;
}
@ -96,12 +96,12 @@ angular.module('storyboard').controller('HeaderController',
searchString = searchString || '';
$q.all({
projects: Browse.project(searchString),
stories: Browse.story(searchString)
projects: Project.criteriaResolver(searchString),
stories: Story.criteriaResolver(searchString)
}).then(function (results) {
var criteria = [
Criteria.create('text', searchString)
Criteria.create('Text', searchString)
];
// Add the returned projects to the results list.

View File

@ -1,93 +0,0 @@
/*
* Copyright (c) 2014 Hewlett-Packard Development Company, L.P.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
/**
* This test suite verifies that our default API request signature is
* sane.
*/
describe('storyboardApiSignature', function () {
'use strict';
beforeEach(module('sb.services'));
it('should exist', function () {
inject(function (storyboardApiSignature) {
expect(storyboardApiSignature).toBeTruthy();
});
});
it('should declare CRUD methods', function () {
inject(function (storyboardApiSignature) {
expect(storyboardApiSignature.create).toBeTruthy();
expect(storyboardApiSignature.read).toBeTruthy();
expect(storyboardApiSignature.update).toBeTruthy();
expect(storyboardApiSignature.delete).toBeTruthy();
});
});
it('should declare a query method', function () {
inject(function (storyboardApiSignature) {
expect(storyboardApiSignature.query).toBeTruthy();
});
});
it('should use POST to create', function () {
inject(function (storyboardApiSignature) {
expect(storyboardApiSignature.create).toBeTruthy();
expect(storyboardApiSignature.create.method).toEqual('POST');
});
});
it('should use GET to read', function () {
inject(function (storyboardApiSignature) {
expect(storyboardApiSignature.read).toBeTruthy();
expect(storyboardApiSignature.read.method).toEqual('GET');
});
});
it('should use PUT to update', function () {
inject(function (storyboardApiSignature) {
expect(storyboardApiSignature.update).toBeTruthy();
expect(storyboardApiSignature.update.method).toEqual('PUT');
});
});
it('should use DELETE to delete', function () {
inject(function (storyboardApiSignature) {
expect(storyboardApiSignature.delete).toBeTruthy();
expect(storyboardApiSignature.delete.method).toEqual('DELETE');
});
});
it('should use GET to query', function () {
inject(function (storyboardApiSignature) {
expect(storyboardApiSignature.query).toBeTruthy();
expect(storyboardApiSignature.query.method).toEqual('GET');
});
});
it('should properly construct a resource', function () {
inject(function (storyboardApiSignature, $resource) {
var Resource = $resource('/path/:id',
{id: '@id'},
storyboardApiSignature);
expect(Resource.query).toBeTruthy();
expect(Resource.read).toBeTruthy();
var resourceInstance = new Resource();
expect(resourceInstance.$create).toBeTruthy();
expect(resourceInstance.$update).toBeTruthy();
expect(resourceInstance.$delete).toBeTruthy();
});
});
});