diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js
index 87d3b6b06b..8b5f7ec78d 100644
--- a/awx/ui/client/src/app.js
+++ b/awx/ui/client/src/app.js
@@ -51,6 +51,7 @@ import activityStream from './activity-stream/main';
import standardOut from './standard-out/main';
import lookUpHelper from './lookup/main';
import JobTemplates from './job-templates/main';
+import search from './search/main';
import {ScheduleEditController} from './controllers/Schedules';
import {ProjectsList, ProjectsAdd, ProjectsEdit} from './controllers/Projects';
import OrganizationsList from './organizations/list/organizations-list.controller';
@@ -109,6 +110,7 @@ var tower = angular.module('Tower', [
standardOut.name,
access.name,
JobTemplates.name,
+ search.name,
'templates',
'Utilities',
'OrganizationFormDefinition',
diff --git a/awx/ui/client/src/helpers/search.js b/awx/ui/client/src/helpers/search.js
index 33f7ec5c86..de68732ed1 100644
--- a/awx/ui/client/src/helpers/search.js
+++ b/awx/ui/client/src/helpers/search.js
@@ -392,9 +392,6 @@ export default
scope[iterator + 'HoldInput' + modifier] = true;
if ($('#search-widget-container' + modifier) &&
list.fields[scope[iterator + 'SearchField' + modifier]] && !list.fields[scope[iterator + 'SearchField' + modifier]].searchObject) {
-
- // if the search widget exists and its value is not an object, add its parameters to the query
-
if (scope[iterator + 'SearchValue' + modifier]) {
// if user typed a value in the input box, show the reset link
scope[iterator + 'ShowStartBtn' + modifier] = false;
diff --git a/awx/ui/client/src/lists/Projects.js b/awx/ui/client/src/lists/Projects.js
index cd76d48dcd..43f9983339 100644
--- a/awx/ui/client/src/lists/Projects.js
+++ b/awx/ui/client/src/lists/Projects.js
@@ -30,6 +30,7 @@ export default
icon: "icon-job-{{ project.statusIcon }}",
columnClass: "List-staticColumn--smallStatus",
nosort: true,
+ searchLabel: 'Status',
searchType: 'select',
searchOptions: [], //set in the controller
excludeModal: true
diff --git a/awx/ui/client/src/search/getSearchHtml.service.js b/awx/ui/client/src/search/getSearchHtml.service.js
new file mode 100644
index 0000000000..2e941ce381
--- /dev/null
+++ b/awx/ui/client/src/search/getSearchHtml.service.js
@@ -0,0 +1,31 @@
+export default [function() {
+ // given the list, return the fields that need searching
+ this.getList = function(list) {
+ return JSON.stringify(Object
+ .keys(list.fields)
+ .filter(function(i) {
+ return (list.fields[i]
+ .searchable !== false);
+ }).map(function(i) {
+ return {[i]: list.fields[i]};
+ }).reduce(function (acc, i) {
+ var key = Object.keys(i);
+ acc[key] = i[key];
+ return acc;
+ }));
+ };
+
+ // given the list config object, return the basepath
+ this.getEndpoint = function(list) {
+ return list.basePath || list.name;
+ };
+
+ // inject the directive with the list and endpoint
+ this.inject = function(list, endpoint) {
+ return "";
+ };
+
+ return this;
+}];
diff --git a/awx/ui/client/src/search/main.js b/awx/ui/client/src/search/main.js
new file mode 100644
index 0000000000..69c7dbf40a
--- /dev/null
+++ b/awx/ui/client/src/search/main.js
@@ -0,0 +1,9 @@
+import tagSearchDirective from './tagSearch.directive';
+import tagSearchService from './tagSearch.service';
+import getSearchHtml from './getSearchHtml.service';
+
+export default
+ angular.module('search', [])
+ .directive('tagSearch', tagSearchDirective)
+ .factory('tagSearchService', tagSearchService)
+ .factory('getSearchHtml', getSearchHtml);
diff --git a/awx/ui/client/src/search/tagSearch.block.less b/awx/ui/client/src/search/tagSearch.block.less
new file mode 100644
index 0000000000..7e89864562
--- /dev/null
+++ b/awx/ui/client/src/search/tagSearch.block.less
@@ -0,0 +1,239 @@
+@import "../shared/branding/colors.default.less";
+
+.TagSearch {
+ margin-bottom: 10px;
+}
+
+.TagSearch-bar {
+ display: flex;
+ padding: 0;
+ font-size: 12px;
+ height: 35px;
+ align-items: stretch;
+ margin-bottom: 10px;
+}
+
+.TagSearch-bar i {
+ font-size: 16px;
+ color: @default-icon;
+}
+
+.TagSearch-typeDropdown {
+ color: @default-interface-txt;
+ flex: initial;
+ border: 1px solid @default-second-border;
+ border-top-left-radius: 5px;
+ border-bottom-left-radius: 5px;
+ padding: 0px 10px;
+ display: flex;
+ white-space: nowrap;
+ align-items: center;
+ max-height: 400px;
+ overflow-y: scroll;
+ width: 100px;
+ cursor: pointer;
+}
+
+.TagSearch-typeDropdown.is-open {
+ border-bottom-left-radius: 0;
+}
+
+.TagSearch-typeDropdownName {
+ width: 66px;
+ text-overflow: ellipsis;
+ display: block;
+ overflow: hidden;
+}
+
+.TagSearch-selectDownIcon {
+ margin-left: 10px;
+}
+
+.TagSearch-dropdownContainer {
+ position: absolute;
+ left: 15px;
+ top: 34px;
+ font-size: 14px;
+ border-radius: 5px;
+ border: 1px solid @default-second-border;
+ background: white;
+ padding: 5px 0;
+ border-top-left-radius: 0px;
+ border-top-right-radius: 0px;
+ z-index: 50000;
+ max-height: 200px;
+ overflow-y: scroll;
+ box-shadow: 2px 2px 2px 0px rgba(0, 0, 0, 0.1);
+}
+
+.TagSearch-dropdownContainer--searchTypes {
+ min-width: 96px;
+}
+
+.TagSearch-dropdownContainer--typeOptions {
+ right: 15px;
+ left: initial;
+ width: ~"calc(100% - 123px)";
+}
+
+.TagSearch-dropdownItem {
+ padding: 5px 10px;
+ cursor: pointer;
+}
+
+.TagSearch-dropdownItem:hover {
+ background-color: @default-tertiary-bg;
+}
+
+.TagSearch-dropdownItem.is-selected {
+ background-color: @default-button-hov;
+}
+
+.TagSearch-searchTermContainer {
+ flex: initial;
+ width: ~"calc(100% - 70px)";
+ border: 1px solid @default-second-border;
+ border-left: 0px;
+ border-top-right-radius: 5px;
+ border-bottom-right-radius: 5px;
+ display: flex;
+ background-color: @default-secondary-bg;
+}
+
+.TagSearch-searchTermContainer.is-open {
+ border-bottom-right-radius: 0;
+}
+
+.TagSearch-searchTermContainer input {
+ flex: 1 0 auto;
+ margin: 0 10px;
+ border: none;
+ font-size: 14px;
+}
+
+.TagSearch-searchTermContainer input:focus,
+.TagSearch-searchTermContainer input:active {
+ outline: 0;
+}
+
+.TagSearch-searchTermSelect {
+ padding: 0px 10px !important;
+ display: flex;
+ align-items: center;
+ width: 100%;
+}
+
+.TagSearch-searchTermSelectPlaceholder {
+ color: @default-icon !important;
+ text-transform: uppercase;
+ font-size: 14px;
+ flex: 1 0 auto;
+}
+
+.TagSearch-searchTermContainer input:placeholder-shown {
+ color: @default-icon !important;
+ text-transform: uppercase;
+}
+
+.TagSearch-searchTermSelect {
+ padding: 10px;
+ background-color: @default-bg;
+ border-top-right-radius: 5px;
+ border-bottom-right-radius: 5px;
+ cursor: pointer;
+}
+
+.TagSearch-searchButton {
+ flex: initial;
+ margin-left: auto;
+ padding: 8px 10px;
+ border-left: 1px solid @default-second-border;
+ background-color: @default-bg;
+ cursor: pointer;
+ border-top-right-radius: 5px;
+ border-bottom-right-radius: 5px;
+}
+
+.TagSearch-searchButton:hover {
+ background-color: @default-tertiary-bg;
+}
+
+.TagSearch-flexContainer {
+ display: flex;
+ width: 100%;
+ flex-wrap: wrap;
+}
+
+.TagSearch-tagContainer {
+ display: flex;
+ max-width: 100%;
+ margin-bottom: 10px;
+}
+
+.TagSearch-tag {
+ border-radius: 5px;
+ padding: 2px 10px;
+ margin: 4px 0px;
+ border: 1px solid @default-second-border;
+ font-size: 12px;
+ color: @default-interface-txt;
+ text-transform: uppercase;
+ background-color: @default-bg;
+ margin-right: 5px;
+ max-width: 100%;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+
+.TagSearch-tag--deletable {
+ margin-right: 0px;
+ border-top-right-radius: 0px;
+ border-bottom-right-radius: 0px;
+ border-right: 0;
+ max-wdith: ~"calc(100% - 23px)";
+}
+
+.TagSearch-deleteContainer {
+ border: 1px solid @default-second-border;
+ border-top-right-radius: 5px;
+ border-bottom-right-radius: 5px;
+ padding: 0 5px;
+ margin: 4px 0px;
+ margin-right: 5px;
+ align-items: center;
+ display: flex;
+ cursor: pointer;
+}
+
+.TagSearch-tagDelete {
+ font-size: 13px;
+ color: @default-icon;
+}
+
+.TagSearch-name {
+ flex: initial;
+ max-width: 100%;
+}
+
+.TagSearch-tag--deletable > .TagSearch-name {
+ max-width: ~"calc(100% - 23px)";
+}
+
+.TagSearch-deleteContainer:hover, {
+ border-color: @default-err;
+ background-color: @default-err;
+}
+
+.TagSearch-deleteContainer:hover > .TagSearch-tagDelete {
+ color: @default-bg;
+}
+
+// TODO: Actually Remove the old list search widgets from the partials
+.List-searchWidget {
+ display: none !important;
+}
+
+.List-searchRow {
+ display: none !important;
+}
diff --git a/awx/ui/client/src/search/tagSearch.controller.js b/awx/ui/client/src/search/tagSearch.controller.js
new file mode 100644
index 0000000000..a265873a5e
--- /dev/null
+++ b/awx/ui/client/src/search/tagSearch.controller.js
@@ -0,0 +1,105 @@
+export default ['$scope', 'Refresh', 'tagSearchService',
+ function($scope, Refresh, tagSearchService) {
+ // JSONify passed field elements that can be searched
+ $scope.list = JSON.parse($scope.list);
+
+ // Hotfix: GetBasePath to work with inventories
+ $scope.$watch("endpoint", function(val) {
+ if (val === 'inventories') {
+ $scope.endpoint = 'inventory';
+ }
+ });
+
+ // Grab options for the left-dropdown of the searchbar
+ tagSearchService.getSearchTypes($scope.list, $scope.endpoint)
+ .then(function(searchTypes) {
+ $scope.searchTypes = searchTypes;
+
+ // currently selected option of the left-dropdown
+ $scope.currentSearchType = $scope.searchTypes[0];
+ });
+
+ // shows/hide the search type dropdown
+ $scope.toggleTypeDropdown = function() {
+ $scope.showTypeDropdown = !$scope.showTypeDropdown;
+ };
+
+ // sets the search type dropdown and hides it
+ $scope.setSearchType = function(type) {
+ $scope.currentSearchType = type;
+ $scope.showTypeDropdown = false;
+ };
+
+ // if the current search type uses a list instead
+ // of a text input, this show hides that list
+ $scope.toggleCurrentSearchDropdown = function() {
+ $scope
+ .showCurrentSearchDropdown = !$scope
+ .showCurrentSearchDropdown;
+ };
+
+ $scope.updateSearch = function(tags) {
+ var iterator = $scope.$parent.list.iterator;
+ var pageSize = $scope
+ .$parent[iterator + "_page_size"];
+ var set = $scope.$parent.list.name;
+ var listScope = $scope.$parent;
+ var url = tagSearchService
+ .updateFilteredUrl($scope.endpoint, tags, pageSize);
+
+ $scope.$parent[iterator + "_active_search"] = true;
+
+ Refresh({
+ scope: listScope,
+ set: set,
+ iterator: iterator,
+ url: url
+ });
+
+ $scope.currentSearchFilters = tags;
+ };
+
+ // triggers a refilter of the list with the newTag
+ $scope.addTag = function(type) {
+ var newTag = tagSearchService
+ .getTag($scope.currentSearchType,
+ $scope.newSearchTag,
+ type);
+
+ // reset the search bar
+ $scope.resetSearchBar();
+
+ // make a clone of the currentSearchFilters
+ // array and push the newTag to this array
+ var tags = tagSearchService
+ .getCurrentTags($scope
+ .currentSearchFilters);
+
+ if (!tagSearchService.isDuplicate(tags, newTag)) {
+ tags.push(newTag);
+ $scope.updateSearch(tags);
+ }
+ };
+
+ // triggers a refilter of the list without the oldTag
+ $scope.deleteTag = function(oldTag) {
+ // make a clone of the currentSearchFilters
+ // array and remove oldTag from the array
+ var tags = tagSearchService
+ .getCurrentTags($scope
+ .currentSearchFilters)
+ .filter(function(tag) {
+ return tag.url !== oldTag.url;
+ });
+
+ $scope.updateSearch(tags);
+ };
+
+ // make sure all stateful UI triggers are reset
+ $scope.resetSearchBar = function() {
+ delete $scope.currentSearchSelectedOption;
+ $scope.newSearchTag = null;
+ $scope.showTypeDropdown = false;
+ $scope.showCurrentSearchDropdown = false;
+ };
+ }];
diff --git a/awx/ui/client/src/search/tagSearch.directive.js b/awx/ui/client/src/search/tagSearch.directive.js
new file mode 100644
index 0000000000..d1515ea03c
--- /dev/null
+++ b/awx/ui/client/src/search/tagSearch.directive.js
@@ -0,0 +1,30 @@
+import tagSearchController from './tagSearch.controller';
+
+/* jshint unused: vars */
+export default
+ ['templateUrl',
+ function(templateUrl) {
+ return {
+ restrict: 'E',
+ scope: {
+ list: '@',
+ endpoint: '@',
+ iterator: '@'
+ },
+ controller: tagSearchController,
+ templateUrl: templateUrl('search/tagSearch'),
+ link: function(scope, element, attrs) {
+ // make the enter button work as if clicking the
+ // search icon
+ element
+ .find('.TagSearch-searchTermInput')
+ .bind('keypress', function (e) {
+ var code = e.keyCode || e.which;
+ if (code === 13) {
+ scope.addTag();
+ }
+ });
+ }
+ };
+ }
+ ];
diff --git a/awx/ui/client/src/search/tagSearch.partial.html b/awx/ui/client/src/search/tagSearch.partial.html
new file mode 100644
index 0000000000..0e557dca2a
--- /dev/null
+++ b/awx/ui/client/src/search/tagSearch.partial.html
@@ -0,0 +1,78 @@
+
diff --git a/awx/ui/client/src/search/tagSearch.service.js b/awx/ui/client/src/search/tagSearch.service.js
new file mode 100644
index 0000000000..347e2df0f0
--- /dev/null
+++ b/awx/ui/client/src/search/tagSearch.service.js
@@ -0,0 +1,148 @@
+export default ['Rest', '$q', 'GetBasePath', function(Rest, $q, GetBasePath) {
+ var that = this;
+ // parse the field config object to return
+ // one of the searchTypes (for the left dropdown)
+ this.buildType = function (field, key, id) {
+ // build the value (key)
+ var value;
+ if (typeof(field.key) === String) {
+ value = field.key;
+ } else {
+ value = key;
+ }
+
+ // build the label
+ var label = field.searchLabel || field.label;
+
+ // build the search type
+ var type, typeOptions;
+ if (field.searchType === 'select') {
+ type = 'select';
+ typeOptions = [];
+ } else if (field.searchType === 'boolean') {
+ type = 'select';
+ typeOptions = [{label: "Yes", value: true},
+ {label: "No", value: false}];
+ } else {
+ type = 'text';
+ }
+
+ // return the built option
+ if (type === 'select') {
+ return {
+ id: id,
+ value: value,
+ label: label,
+ type: type,
+ typeOptions: typeOptions
+ };
+ } else {
+ return {
+ id: id,
+ value: value,
+ label: label,
+ type: type
+ };
+ }
+ };
+
+ // given the fields that are searchable,
+ // return searchTypes in the format the view can use
+ this.getSearchTypes = function(list, basePath) {
+ var defer = $q.defer();
+
+ var options = Object
+ .keys(list)
+ .map(function(key, id) {
+ return that.buildType(list[key], key, id);
+ });
+
+ var needsRequest, passThrough;
+
+ // splits off options that need a request from
+ // those that don't
+ var partitionedOptions = _.partition(options, function(opt) {
+ return (opt.typeOptions && !opt.typeOptions
+ .length) ? true : false;
+ });
+
+ needsRequest = partitionedOptions[0];
+ passThrough = partitionedOptions[1];
+
+ var joinOptions = function() {
+ return _.sortBy(_
+ .flatten([needsRequest, passThrough]), function(opt) {
+ return opt.id;
+ });
+ };
+
+ if (needsRequest.length) {
+ // make the options request to reutrn the typeOptions
+ Rest.setUrl(GetBasePath(basePath));
+ Rest.options()
+ .success(function (data) {
+ var options = data.actions.GET;
+ needsRequest = needsRequest
+ .map(function (option) {
+ option.typeOptions = options[option
+ .value]
+ .choices
+ .map(function(i) {
+ return {
+ value: i[0],
+ label: i[1]
+ };
+ });
+
+ return option;
+ });
+
+ defer.resolve(joinOptions());
+ });
+ } else {
+ defer.resolve(joinOptions());
+ }
+
+ return defer.promise;
+ };
+
+ // returns the url with filter params
+ this.updateFilteredUrl = function(basePath, tags, pageSize) {
+ return GetBasePath(basePath) + "?" +
+ (tags || []).map(function (t) {
+ return t.url;
+ }).join("&") + "&page_size=" + pageSize;
+ };
+
+ // given the field and input filters, create the tag object
+ this.getTag = function(field, textVal, selectVal) {
+ var tag = _.clone(field);
+ if (tag.type === "text") {
+ tag.url = tag.value + "__icontains=" + textVal;
+ tag.name = textVal;
+ } else {
+ tag.url = tag.value + "=" + selectVal.value;
+ tag.name = selectVal.label;
+ }
+ return tag;
+ };
+
+ // returns true if the newTag is already in the list of tags
+ this.isDuplicate = function(tags, newTag) {
+ return (tags
+ .filter(function(tag) {
+ return (tag.url === newTag.url);
+ }).length > 0);
+ };
+
+ // returns an array of tags (or empty array if there are none)
+ // .slice(0) is used so the currentTags variable is not directly mutated
+ this.getCurrentTags = function(currentTags) {
+ if (currentTags && currentTags.length) {
+ return currentTags.slice(0);
+ }
+ return [];
+ };
+
+ return this;
+}];
diff --git a/awx/ui/client/src/shared/branding/colors.default.less b/awx/ui/client/src/shared/branding/colors.default.less
index dd53943e88..1713540c93 100644
--- a/awx/ui/client/src/shared/branding/colors.default.less
+++ b/awx/ui/client/src/shared/branding/colors.default.less
@@ -4,7 +4,7 @@
@default-icon: #B7B7B7;
@default-icon-hov: #D7D7D7; // also selected button
@default-border: #E8E8E8;
-@default-second-border: #E1E1E1;
+@default-second-border: #D7D7D7;
@default-bg: #FFFFFF; // also selected btn txt
@default-secondary-bg: #FCFCFC; // page/input field bg, just adds depth
@default-tertiary-bg: #FAFAFA; // hover bg, alt-list
diff --git a/awx/ui/client/src/shared/list-generator/list-generator.factory.js b/awx/ui/client/src/shared/list-generator/list-generator.factory.js
index 2205f84390..d507fb8782 100644
--- a/awx/ui/client/src/shared/list-generator/list-generator.factory.js
+++ b/awx/ui/client/src/shared/list-generator/list-generator.factory.js
@@ -98,9 +98,9 @@
import {templateUrl} from '../../shared/template-url/template-url.factory';
-export default ['$location', '$compile', '$rootScope', 'SearchWidget', 'PaginateWidget', 'Attr', 'Icon',
+export default ['$location', '$compile', '$rootScope', 'SearchWidget', 'PaginateWidget', 'Attr', 'Icon', 'getSearchHtml',
'Column', 'DropDown', 'NavigationLink', 'SelectIcon',
- function ($location, $compile, $rootScope, SearchWidget, PaginateWidget, Attr, Icon, Column, DropDown, NavigationLink,
+ function ($location, $compile, $rootScope, SearchWidget, PaginateWidget, Attr, Icon, getSearchHtml, Column, DropDown, NavigationLink,
SelectIcon) {
return {
@@ -362,38 +362,9 @@ export default ['$location', '$compile', '$rootScope', 'SearchWidget', 'Paginate
html += (list.emptyListText) ? list.emptyListText : "PLEASE ADD ITEMS TO THIS LIST";
html += "";
if (options.showSearch=== undefined || options.showSearch === true) {
- // Only show the search bar if we are loading results or if we have at least 1 base result
- html += " 0)\">\n";
- if (options.searchSize) {
- html += SearchWidget({
- iterator: list.iterator,
- template: list,
- mini: true,
- size: options.searchSize,
- searchWidgets: list.searchWidgets
- });
- } else if (options.mode === 'summary') {
- html += SearchWidget({
- iterator: list.iterator,
- template: list,
- mini: true,
- size: 'col-lg-6'
- });
- } else if (options.mode === 'lookup' || options.id !== undefined) {
- html += SearchWidget({
- iterator: list.iterator,
- template: list,
- mini: true,
- size: 'col-lg-8'
- });
- } else {
- html += SearchWidget({
- iterator: list.iterator,
- template: list,
- mini: true
- });
- }
- html += "
\n";
+ html += getSearchHtml
+ .inject(getSearchHtml.getList(list),
+ getSearchHtml.getEndpoint(list));
// Message for when a search returns no results. This should only get shown after a search is executed with no results.
html += "\n";