Search use case 1: Quicknav

This commit adds a typeahead-style autocompleting navigation text box
to storyboard's header. It does so by creating a centralized typeahead
format (called criteria) and a central browse service that collects
search results from multiple resources into one list.

Change-Id: I575f85a7916e7881df32e96e77585540cdc8e617
This commit is contained in:
Michael Krotscheck 2014-06-25 14:25:49 -07:00
parent ad7104f3b2
commit e5cc736d41
7 changed files with 414 additions and 90 deletions

View File

@ -0,0 +1,151 @@
/*
* 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

@ -0,0 +1,44 @@
/*
* 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 construction of search criteria.
*/
angular.module('sb.services').service('Criteria',
function () {
'use strict';
return {
/**
* 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

@ -15,11 +15,12 @@
*/
/**
* Controller for our application header.
* Controller for our application header. Includes a typeahead-style quicknav
* and search box.
*/
angular.module('storyboard').controller('HeaderController',
function ($scope, $rootScope, $state, NewStoryService, Session,
SessionState, CurrentUser) {
function ($q, $scope, $rootScope, $state, NewStoryService, Session,
SessionState, CurrentUser, Browse) {
'use strict';
function resolveCurrentUser() {
@ -59,11 +60,58 @@ angular.module('storyboard').controller('HeaderController',
Session.destroySession();
};
// Watch for changes to the session state.
$rootScope.$on(SessionState.LOGGED_IN, function () {
resolveCurrentUser();
});
$rootScope.$on(SessionState.LOGGED_OUT, function () {
$scope.currentUser = null;
});
/**
* Initialize the search string.
*/
$scope.searchString = '';
/**
* Send the user to search and clear the header search string.
*/
$scope.search = function (criteria) {
switch (criteria.type) {
case 'project':
$state.go('project.detail', {id: criteria.value});
break;
case 'story':
$state.go('story.detail', {storyId: criteria.value});
break;
}
$scope.searchString = '';
};
/**
* Filter down the search string to actual resources that we can
* browse to directly (Explicitly not including users here).
*/
$scope.quickSearch = function (searchString) {
var deferred = $q.defer();
searchString = searchString || '';
$q.all({
projects: Browse.project(searchString),
stories: Browse.story(searchString)
}).then(function (results) {
var criteria = [
];
// 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);
});
deferred.resolve(criteria);
});
// Return the search promise.
return deferred.promise;
};
});

View File

@ -0,0 +1,22 @@
<a ng-switch="match.model.type"
class="header-criteria-item">
<span ng-switch-when="text">
<i class="fa fa-search text-muted"></i>&emsp;{{match.model.title}}
</span>
<span ng-switch-when="story">
<i class="fa fa-list-ul text-muted"></i>&emsp;{{match.model.value}}:
{{match.model.title}}
</span>
<span ng-switch-when="project">
<i class="fa fa-flag text-muted"></i>&emsp;{{match.model.title}}
</span>
<span ng-switch-when="user">
<em class="pull-right text-muted">
<span>User</span>
</em>
{{match.model.title}}
</span>
<span ng-switch-default>
UNKNOWN TYPE: {{match.model.type}}
</span>
</a>

View File

@ -15,8 +15,7 @@
-->
<nav class="navbar navbar-default navbar-fixed-top"
role="navigation"
ng-controller="HeaderController">
role="navigation">
<div class="container-fluid visible-xs"
ng-include
src="'/inline/header_mobile.html'">
@ -29,99 +28,124 @@
<!-- The menu for regular sized screens -->
<script type="text/ng-template" id="/inline/header_regular.html">
<div ng-controller="HeaderController">
<div class="navbar-header">
<a class="navbar-brand" href="#!/">StoryBoard</a>
</div>
<div class="navbar-header">
<a class="navbar-brand" href="#!/">StoryBoard</a>
</div>
<ul class="nav navbar-nav">
<li>&nbsp;&nbsp;</li>
<li>
<button type="button"
class="btn btn-primary navbar-btn"
ng-click="newStory()">
<i class="fa fa-plus-circle"></i>
New Story
</button>
</li>
</ul>
<ul class="nav navbar-nav">
<li>&nbsp;&nbsp;</li>
<li>
<button type="button"
class="btn btn-primary navbar-btn"
ng-click="newStory()">
<i class="fa fa-plus-circle"></i>
New Story
</button>
</li>
</ul>
<ul class="nav navbar-nav navbar-right">
<li ng-hide="isLoggedIn">
<a href="#!/auth/authorize">
<i class="fa fa-sign-in"></i>
Login
</a>
</li>
<li class="dropdown" ng-show="isLoggedIn">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
<ul class="nav navbar-nav navbar-right">
<li ng-hide="isLoggedIn">
<a href="#!/auth/authorize">
<i class="fa fa-sign-in"></i>
Login
</a>
</li>
<li class="dropdown" ng-show="isLoggedIn">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
<span ng-show="currentUser.$resolved">
{{currentUser.full_name}}
</span>
<em class="text-muted"
ng-hide="currentUser.$resolved">
Loading...
</em>
<i class="fa fa-caret-down"></i>
</a>
<ul class="dropdown-menu">
<li active-path="^\/profile\/preferences*">
<a href="#!/profile/preferences">
<i class="fa fa-gear"></i>
Preferences
</a>
</li>
<li class="divider"></li>
<li>
<a href="#!/auth/deauthorize">
<i class="fa fa-sign-out"></i>
Logout
</a>
</li>
</ul>
</li>
</ul>
<em class="text-muted"
ng-hide="currentUser.$resolved">
Loading...
</em>
<i class="fa fa-caret-down"></i>
</a>
<ul class="dropdown-menu">
<li active-path="^\/profile\/preferences*">
<a href="#!/profile/preferences">
<i class="fa fa-gear"></i>
Preferences
</a>
</li>
<li class="divider"></li>
<li>
<a href="#!/auth/deauthorize">
<i class="fa fa-sign-out"></i>
Logout
</a>
</li>
</ul>
</li>
</ul>
<form class="navbar-form navbar-right" role="search">
<div class="form-group has-feedback">
<input type="text"
class="form-control"
placeholder="Quicknav"
ng-model="searchString"
typeahead-append-to-body="true"
typeahead-editable="false"
typeahead="criteria as criteria.title for criteria in quickSearch($viewValue)"
typeahead-loading="headerCriteriaLoading"
typeahead-on-select="search($model)"
typeahead-template-url="app/templates/header_criteria_item.html"
/>
<span class="form-control-feedback text-muted">
<i class="fa fa-search" ng-hide="headerCriteriaLoading"></i>
<i class="fa fa-spin fa-refresh"
ng-show="headerCriteriaLoading"></i>
</span>
</div>
</form>
</div>
</script>
<!-- The menu for xs sized screens -->
<script type="text/ng-template" id="/inline/header_mobile.html">
<div class="navbar-header">
<button type="button"
class="btn btn-sm btn-primary navbar-btn pull-right"
data-toggle="collapse"
data-target="#mobile-dropdown-menu"
ng-show="isLoggedIn">
<i class="fa fa-gear"></i>
</button>
<a href="#!/auth/authorize"
class="btn btn-sm btn-primary navbar-btn pull-right"
ng-hide="isLoggedIn">
<i class="fa fa-sign-in"></i>
</a>
<a class="navbar-brand" href="#!/">StoryBoard</a>
</div>
<div ng-controller="HeaderController">
<div class="navbar-header">
<button type="button"
class="btn btn-sm btn-primary navbar-btn pull-right"
data-toggle="collapse"
data-target="#mobile-dropdown-menu"
ng-show="isLoggedIn">
<i class="fa fa-gear"></i>
</button>
<a href="#!/auth/authorize"
class="btn btn-sm btn-primary navbar-btn pull-right"
ng-hide="isLoggedIn">
<i class="fa fa-sign-in"></i>
</a>
<a class="navbar-brand" href="#!/">StoryBoard</a>
</div>
<div class="collapse navbar-collapse" id="mobile-dropdown-menu">
<ul class="nav navbar-nav">
<li active-path="^\/profile\/preferences*">
<a href="#!/profile/preferences">
<i class="fa fa-user"></i>
<div class="collapse navbar-collapse" id="mobile-dropdown-menu">
<ul class="nav navbar-nav">
<li active-path="^\/profile\/preferences*">
<a href="#!/profile/preferences">
<i class="fa fa-user"></i>
<span ng-show="currentUser.$resolved">
{{currentUser.full_name}}
</span>
<em class="text-muted"
ng-hide="currentUser.$resolved">
Loading...
</em>
</a>
</li>
<li class="divider"></li>
<li><a href="#!/auth/deauthorize">
<i class="fa fa-sign-out"></i>
Logout
</a></li>
</ul>
<em class="text-muted"
ng-hide="currentUser.$resolved">
Loading...
</em>
</a>
</li>
<li class="divider"></li>
<li><a href="#!/auth/deauthorize">
<i class="fa fa-sign-out"></i>
Logout
</a></li>
</ul>
</div>
</div>
</script>

View File

@ -0,0 +1,34 @@
/*
* 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.
*/
/**
* Styles specific to the overall application.
*/
ul[typeahead-popup] {
z-index: 2000;
a.header-criteria-item {
white-space: nowrap;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
}
.active a .text-muted {
color: @dropdown-link-active-color;
}
}

View File

@ -38,3 +38,4 @@
@import './base/discussion.less';
@import './base/typography.less';
@import './base/tag_input.less';
@import './base/header.less';