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:
parent
ad7104f3b2
commit
e5cc736d41
|
@ -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;
|
||||
}
|
||||
};
|
||||
});
|
|
@ -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
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
|
@ -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;
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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> {{match.model.title}}
|
||||
</span>
|
||||
<span ng-switch-when="story">
|
||||
<i class="fa fa-list-ul text-muted"></i> {{match.model.value}}:
|
||||
{{match.model.title}}
|
||||
</span>
|
||||
<span ng-switch-when="project">
|
||||
<i class="fa fa-flag text-muted"></i> {{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>
|
|
@ -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> </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> </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>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -38,3 +38,4 @@
|
|||
@import './base/discussion.less';
|
||||
@import './base/typography.less';
|
||||
@import './base/tag_input.less';
|
||||
@import './base/header.less';
|
||||
|
|
Loading…
Reference in New Issue