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..8570237b32
--- /dev/null
+++ b/awx/ui/client/src/search/getSearchHtml.service.js
@@ -0,0 +1,39 @@
+export default ['GetBasePath', function(GetBasePath) {
+ // given the list, return the fields that need searching
+ this.getList = function(list) {
+ var f = _.clone(list.fields);
+ return JSON.stringify(Object
+ .keys(f)
+ .filter(function(i) {
+ return (f[i]
+ .searchable !== false);
+ }).map(function(i) {
+ delete f[i].awToolTip;
+ delete f[i].ngClass;
+ return {[i]: f[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) {
+ var endPoint = (list.basePath || list.name);
+ if (endPoint === 'inventories') {
+ endPoint = 'inventory';
+ }
+ return GetBasePath(endPoint);
+ };
+
+ // inject the directive with the list and endpoint
+ this.inject = function(list, endpoint, set, iterator) {
+ 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..8edcfdce12
--- /dev/null
+++ b/awx/ui/client/src/search/tagSearch.controller.js
@@ -0,0 +1,98 @@
+export default ['$scope', 'Refresh', 'tagSearchService',
+ function($scope, Refresh, tagSearchService) {
+ // JSONify passed field elements that can be searched
+ $scope.list = JSON.parse($scope.list);
+
+ // 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.iterator;
+ var pageSize = $scope
+ .$parent[iterator + "_page_size"];
+ var set = $scope.set;
+ 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..1f5e562b30
--- /dev/null
+++ b/awx/ui/client/src/search/tagSearch.directive.js
@@ -0,0 +1,31 @@
+import tagSearchController from './tagSearch.controller';
+
+/* jshint unused: vars */
+export default
+ ['templateUrl',
+ function(templateUrl) {
+ return {
+ restrict: 'E',
+ scope: {
+ list: '@',
+ endpoint: '@',
+ set: '@',
+ 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..de379cf3d9
--- /dev/null
+++ b/awx/ui/client/src/search/tagSearch.partial.html
@@ -0,0 +1,79 @@
+
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..4f8f7fa48a
--- /dev/null
+++ b/awx/ui/client/src/search/tagSearch.service.js
@@ -0,0 +1,156 @@
+export default ['Rest', '$q', 'GetBasePath', 'Wait', 'ProcessErrors', function(Rest, $q, GetBasePath, Wait, ProcessErrors) {
+ 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 = field.searchOptions || [];
+ } 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) {
+ Wait("start");
+ 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(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;
+ });
+ Wait("stop");
+ defer.resolve(joinOptions());
+ })
+ .error(function (data, status) {
+ Wait("stop");
+ defer.reject("options request failed");
+ ProcessErrors(null, data, status, null, {
+ hdr: 'Error!',
+ msg: 'Getting type options failed'});
+ });
+ } else {
+ Wait("stop");
+ defer.resolve(joinOptions());
+ }
+
+ return defer.promise;
+ };
+
+ // returns the url with filter params
+ this.updateFilteredUrl = function(basePath, tags, pageSize) {
+ return 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/form-generator.js b/awx/ui/client/src/shared/form-generator.js
index 0f3ea7445a..f5d0d83316 100644
--- a/awx/ui/client/src/shared/form-generator.js
+++ b/awx/ui/client/src/shared/form-generator.js
@@ -142,10 +142,10 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat
.factory('GenerateForm', ['$rootScope', '$location', '$compile', 'generateList',
'SearchWidget', 'PaginateWidget', 'Attr', 'Icon', 'Column',
'NavigationLink', 'HelpCollapse', 'DropDown', 'Empty', 'SelectIcon',
- 'Store', 'ActionButton',
+ 'Store', 'ActionButton', 'getSearchHtml', '$state',
function ($rootScope, $location, $compile, GenerateList, SearchWidget,
PaginateWidget, Attr, Icon, Column, NavigationLink, HelpCollapse,
- DropDown, Empty, SelectIcon, Store, ActionButton) {
+ DropDown, Empty, SelectIcon, Store, ActionButton, getSearchHtml, $state) {
return {
setForm: function (form) { this.form = form; },
@@ -1692,28 +1692,38 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat
html += "
Hint: " + collection.instructions + "\n";
html += "
\n";
}
+ var rootID = $location.$$path.split("/")[2];
+ var endpoint = "/api/v1/" + collection.basePath
+ .replace(":id", rootID);
+ var tagSearch = getSearchHtml
+ .inject(getSearchHtml.getList(collection),
+ endpoint, itm, collection.iterator);
- //html += "
\n";
- html += "
\n";
+ var actionButtons = "";
+ Object.keys(collection.actions || {})
+ .forEach(act => {
+ actionButtons += ActionButton(collection
+ .actions[act]);
+ });
- html += SearchWidget({
- iterator: collection.iterator,
- template: collection,
- mini: true,
- ngShow: collection.iterator + "Loading == true || " + collection.iterator + "_active_search == true || (" + collection.iterator + "Loading == false && " + collection.iterator + "_active_search == false && " + collection.iterator + "_total_rows > 0)"
- });
-
- html += "
\n";
- html += "
\n";
-
- for (act in collection.actions) {
- action = collection.actions[act];
- html += ActionButton(action);
- }
-
- html += "
\n";
- html += "
\n";
- html += "
\n";
+ html += `
+
+
0
+ )\">
+ ${tagSearch}
+
+
+
+ `;
// Message for when a search returns no results. This should only get shown after a search is executed with no results.
html += "
\n";
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..2c15e72679 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,39 +362,19 @@ 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";
-
+ var tagSearch = getSearchHtml
+ .inject(getSearchHtml.getList(list),
+ getSearchHtml.getEndpoint(list),
+ list.name,
+ list.iterator);
+ html += `
+
+ ${tagSearch}
+
+ `;
// Message for when a search returns no results. This should only get shown after a search is executed with no results.
html += "
\n";
html += "
No records matched your search.
\n";