diff --git a/awx/ui/client/src/shared/form-generator.js b/awx/ui/client/src/shared/form-generator.js
index 02d94665cd..7c0804d499 100644
--- a/awx/ui/client/src/shared/form-generator.js
+++ b/awx/ui/client/src/shared/form-generator.js
@@ -1830,7 +1830,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat
// smart-search directive
html += `
+ ng-hide="${itm}.length === 0 && (searchTags | isEmpty)">
+ ng-show="${itm}.length === 0 && !(searchTags | isEmpty)">
No records matched your search.
@@ -1865,7 +1865,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat
// Show the "no items" box when loading is done and the user isn't actively searching and there are no results
var emptyListText = (collection.emptyListText) ? collection.emptyListText : i18n._("PLEASE ADD ITEMS TO THIS LIST");
html += ``;
- html += `
${emptyListText}
`;
+ html += `
${emptyListText}
`;
html += '
';
html += `
diff --git a/awx/ui/client/src/shared/paginate/paginate.controller.js b/awx/ui/client/src/shared/paginate/paginate.controller.js
index fdd80000c7..407f9e10f6 100644
--- a/awx/ui/client/src/shared/paginate/paginate.controller.js
+++ b/awx/ui/client/src/shared/paginate/paginate.controller.js
@@ -18,7 +18,7 @@ export default ['$scope', '$stateParams', '$state', '$filter', 'GetBasePath', 'Q
return;
}
path = GetBasePath($scope.basePath) || $scope.basePath;
- queryset = _.merge($stateParams[`${$scope.iterator}_search`], { page: page });
+ queryset = _.merge($stateParams[`${$scope.iterator}_search`], { page: page.toString() });
$state.go('.', {
[$scope.iterator + '_search']: queryset
});
diff --git a/awx/ui/client/src/shared/smart-search/main.js b/awx/ui/client/src/shared/smart-search/main.js
index 7653df7bd7..e7aaf825a7 100644
--- a/awx/ui/client/src/shared/smart-search/main.js
+++ b/awx/ui/client/src/shared/smart-search/main.js
@@ -2,11 +2,12 @@ import directive from './smart-search.directive';
import controller from './smart-search.controller';
import service from './queryset.service';
import DjangoSearchModel from './django-search-model.class';
-
+import smartSearchService from './smart-search.service';
export default
angular.module('SmartSearchModule', [])
.directive('smartSearch', directive)
.controller('SmartSearchController', controller)
.service('QuerySet', service)
+ .service('SmartSearchService', smartSearchService)
.constant('DjangoSearchModel', DjangoSearchModel);
diff --git a/awx/ui/client/src/shared/smart-search/queryset.service.js b/awx/ui/client/src/shared/smart-search/queryset.service.js
index 359245bed4..86a2bc2b20 100644
--- a/awx/ui/client/src/shared/smart-search/queryset.service.js
+++ b/awx/ui/client/src/shared/smart-search/queryset.service.js
@@ -1,5 +1,5 @@
-export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSearchModel', '$cacheFactory',
- function($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearchModel, $cacheFactory) {
+export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSearchModel', '$cacheFactory', 'SmartSearchService',
+ function($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearchModel, $cacheFactory, SmartSearchService) {
return {
// kick off building a model for a specific endpoint
// this is usually a list's basePath
@@ -67,29 +67,120 @@ export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSear
return angular.isObject(params) ? `?${queryset}` : '';
function encodeTerm(value, key){
+
+ key = key.replace(/__icontains_DEFAULT/g, "__icontains");
+ key = key.replace(/__search_DEFAULT/g, "__search");
+
if (Array.isArray(value)){
- return _.map(value, (item) => `${key}=${item}`).join('&') + '&';
+ let concated = '';
+ angular.forEach(value, function(item){
+ item = item.replace(/"|'/g, "");
+ concated += `${key}=${item}&`;
+ });
+ return concated;
}
else {
+ value = value.replace(/"|'/g, "");
return `${key}=${value}&`;
}
}
},
// encodes a ui smart-search param to a django-friendly param
// operand:key:comparator:value => {operand__key__comparator: value}
- encodeParam(param){
- let split = param.split(':');
- return {[split.slice(0,split.length -1).join('__')] : split[split.length-1]};
+ encodeParam(params){
+ // Assumption here is that we have a key and a value so the length
+ // of the paramParts array will be 2. [0] is the key and [1] the value
+ let paramParts = SmartSearchService.splitTermIntoParts(params.term);
+ let keySplit = paramParts[0].split('.');
+ let exclude = false;
+ let lessThanGreaterThan = paramParts[1].match(/^(>|<).*$/) ? true : false;
+ if(keySplit[0].match(/^-/g)) {
+ exclude = true;
+ keySplit[0] = keySplit[0].replace(/^-/, '');
+ }
+ let paramString = exclude ? "not__" : "";
+ let valueString = paramParts[1];
+ if(keySplit.length === 1) {
+ if(params.searchTerm && !lessThanGreaterThan) {
+ paramString += keySplit[0] + '__icontains_DEFAULT';
+ }
+ else if(params.relatedSearchTerm) {
+ paramString += keySplit[0] + '__search_DEFAULT';
+ }
+ else {
+ paramString += keySplit[0];
+ }
+ }
+ else {
+ paramString += keySplit.join('__');
+ }
+
+ if(lessThanGreaterThan) {
+ if(paramParts[1].match(/^>=.*$/)) {
+ paramString += '__gte';
+ valueString = valueString.replace(/^(>=)/,"");
+ }
+ else if(paramParts[1].match(/^<=.*$/)) {
+ paramString += '__lte';
+ valueString = valueString.replace(/^(<=)/,"");
+ }
+ else if(paramParts[1].match(/^<.*$/)) {
+ paramString += '__lt';
+ valueString = valueString.replace(/^(<)/,"");
+ }
+ else if(paramParts[1].match(/^>.*$/)) {
+ paramString += '__gt';
+ valueString = valueString.replace(/^(>)/,"");
+ }
+ }
+
+ return {[paramString] : valueString};
},
// decodes a django queryset param into a ui smart-search tag or set of tags
decodeParam(value, key){
+
+ let decodeParamString = function(searchString) {
+ if(key === 'search') {
+ // Don't include 'search:' in the search tag
+ return decodeURIComponent(`${searchString}`);
+ }
+ else {
+ key = key.replace(/__icontains_DEFAULT/g, "");
+ key = key.replace(/__search_DEFAULT/g, "");
+ let split = key.split('__');
+ let decodedParam = searchString;
+ let exclude = false;
+ if(key.startsWith('not__')) {
+ exclude = true;
+ split = split.splice(1, split.length);
+ }
+ if(key.endsWith('__gt')) {
+ decodedParam = '>' + decodedParam;
+ split = split.splice(0, split.length-1);
+ }
+ else if(key.endsWith('__lt')) {
+ decodedParam = '<' + decodedParam;
+ split = split.splice(0, split.length-1);
+ }
+ else if(key.endsWith('__gte')) {
+ decodedParam = '>=' + decodedParam;
+ split = split.splice(0, split.length-1);
+ }
+ else if(key.endsWith('__lte')) {
+ decodedParam = '<=' + decodedParam;
+ split = split.splice(0, split.length-1);
+ }
+ return exclude ? `-${split.join('.')}:${decodedParam}` : `${split.join('.')}:${decodedParam}`;
+ }
+ };
+
if (Array.isArray(value)){
return _.map(value, (item) => {
- return `${key.split('__').join(':')}:${item}`;
+ return decodeParamString(item);
});
}
else {
- return `${key.split('__').join(':')}:${value}`;
+ return decodeParamString(value);
}
},
@@ -161,6 +252,7 @@ export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSear
success(data) {
return data;
},
+
};
}
];
diff --git a/awx/ui/client/src/shared/smart-search/smart-search.controller.js b/awx/ui/client/src/shared/smart-search/smart-search.controller.js
index 580a56c6f3..b1baca4346 100644
--- a/awx/ui/client/src/shared/smart-search/smart-search.controller.js
+++ b/awx/ui/client/src/shared/smart-search/smart-search.controller.js
@@ -1,5 +1,5 @@
-export default ['$stateParams', '$scope', '$state', 'QuerySet', 'GetBasePath', 'QuerySet',
- function($stateParams, $scope, $state, QuerySet, GetBasePath, qs) {
+export default ['$stateParams', '$scope', '$state', 'QuerySet', 'GetBasePath', 'QuerySet', 'SmartSearchService',
+ function($stateParams, $scope, $state, QuerySet, GetBasePath, qs, SmartSearchService) {
let path, relations,
// steps through the current tree of $state configurations, grabs default search params
@@ -17,6 +17,7 @@ export default ['$stateParams', '$scope', '$state', 'QuerySet', 'GetBasePath', '
$scope.searchTags = stripDefaultParams($state.params[`${$scope.iterator}_search`]);
qs.initFieldset(path, $scope.djangoModel, relations).then((data) => {
$scope.models = data.models;
+ $scope.options = data.options.data;
$scope.$emit(`${$scope.list.iterator}_options`, data.options);
});
}
@@ -51,6 +52,16 @@ export default ['$stateParams', '$scope', '$state', 'QuerySet', 'GetBasePath', '
return flat;
}
+ function setDefaults(term) {
+ if ($scope.list.defaultSearchParams) {
+ return $scope.list.defaultSearchParams(term);
+ } else {
+ return {
+ search: encodeURIComponent(term)
+ };
+ }
+ }
+
$scope.toggleKeyPane = function() {
$scope.showKeyPane = !$scope.showKeyPane;
};
@@ -69,10 +80,37 @@ export default ['$stateParams', '$scope', '$state', 'QuerySet', 'GetBasePath', '
// remove tag, merge new queryset, $state.go
$scope.remove = function(index) {
- let removed = qs.encodeParam($scope.searchTags.splice(index, 1)[0]);
+ let tagToRemove = $scope.searchTags.splice(index, 1)[0];
+ let termParts = SmartSearchService.splitTermIntoParts(tagToRemove);
+ let removed;
+ if (termParts.length === 1) {
+ removed = setDefaults(tagToRemove);
+ }
+ else {
+ let root = termParts[0].split(".")[0].replace(/^-/, '');
+ let encodeParams = {
+ term: tagToRemove
+ };
+ if(_.has($scope.options.actions.GET, root)) {
+ if($scope.options.actions.GET[root].type && $scope.options.actions.GET[root].type === 'field') {
+ encodeParams.relatedSearchTerm = true;
+ }
+ else {
+ encodeParams.searchTerm = true;
+ }
+ removed = qs.encodeParam(encodeParams);
+ }
+ else {
+ removed = setDefaults(tagToRemove);
+ }
+ }
_.each(removed, (value, key) => {
if (Array.isArray(queryset[key])){
_.remove(queryset[key], (item) => item === value);
+ // If the array is now empty, remove that key
+ if(queryset[key].length === 0) {
+ delete queryset[key];
+ }
}
else {
delete queryset[key];
@@ -92,26 +130,46 @@ export default ['$stateParams', '$scope', '$state', 'QuerySet', 'GetBasePath', '
let params = {},
origQueryset = _.clone(queryset);
- function setDefaults(term) {
- // "name" and "description" are sane defaults for MOST models, but not ALL!
- // defaults may be configured in ListDefinition.defaultSearchParams
- if ($scope.list.defaultSearchParams) {
- return $scope.list.defaultSearchParams(term);
- } else {
- return {
- or__name__icontains: term,
- or__description__icontains: term
- };
- }
- }
+ // Remove leading/trailing whitespace if there is any
+ terms = terms.trim();
if(terms && terms !== '') {
- _.forEach(terms.split(' '), (term) => {
+ // Split the terms up
+ let splitTerms = SmartSearchService.splitSearchIntoTerms(terms);
+ _.forEach(splitTerms, (term) => {
+
+ let termParts = SmartSearchService.splitTermIntoParts(term);
+
+ function combineSameSearches(a,b){
+ if (_.isArray(a)) {
+ return a.concat(b);
+ }
+ else {
+ if(a) {
+ return [a,b];
+ }
+ }
+ }
+
// if only a value is provided, search using default keys
- if (term.split(':').length === 1) {
- params = _.merge(params, setDefaults(term));
+ if (termParts.length === 1) {
+ params = _.merge(params, setDefaults(term), combineSameSearches);
} else {
- params = _.merge(params, qs.encodeParam(term));
+ // Figure out if this is a search term
+ let root = termParts[0].split(".")[0].replace(/^-/, '');
+ if(_.has($scope.options.actions.GET, root)) {
+ if($scope.options.actions.GET[root].type && $scope.options.actions.GET[root].type === 'field') {
+ params = _.merge(params, qs.encodeParam({term: term, relatedSearchTerm: true}), combineSameSearches);
+ }
+ else {
+ params = _.merge(params, qs.encodeParam({term: term, searchTerm: true}), combineSameSearches);
+ }
+ }
+ // Its not a search term or a related search term - treat it as a string
+ else {
+ params = _.merge(params, setDefaults(term), combineSameSearches);
+ }
+
}
});
diff --git a/awx/ui/client/src/shared/smart-search/smart-search.service.js b/awx/ui/client/src/shared/smart-search/smart-search.service.js
new file mode 100644
index 0000000000..fe683c08d7
--- /dev/null
+++ b/awx/ui/client/src/shared/smart-search/smart-search.service.js
@@ -0,0 +1,19 @@
+export default [function() {
+ return {
+ splitSearchIntoTerms(searchString) {
+ return searchString.match(/(?:[^\s("')]+|"[^"]*"|'[^']*')+/g);
+ },
+ splitTermIntoParts(searchTerm) {
+ let breakOnColon = searchTerm.match(/(?:[^:"]+|"[^"]*")+/g);
+
+ if(breakOnColon.length > 2) {
+ // concat all the strings after the first one together
+ let stringsToJoin = breakOnColon.slice(1,breakOnColon.length);
+ return [breakOnColon[0], stringsToJoin.join(':')];
+ }
+ else {
+ return breakOnColon;
+ }
+ }
+ };
+}];
diff --git a/awx/ui/tests/spec/smart-search/queryset.service-test.js b/awx/ui/tests/spec/smart-search/queryset.service-test.js
index b932925171..3d49440920 100644
--- a/awx/ui/tests/spec/smart-search/queryset.service-test.js
+++ b/awx/ui/tests/spec/smart-search/queryset.service-test.js
@@ -3,7 +3,8 @@
describe('Service: QuerySet', () => {
let $httpBackend,
QuerySet,
- Authorization;
+ Authorization,
+ SmartSearchService;
beforeEach(angular.mock.module('Tower', ($provide) =>{
// @todo: improve app source / write testing utilities for interim
@@ -17,9 +18,10 @@ describe('Service: QuerySet', () => {
}));
beforeEach(angular.mock.module('RestServices'));
- beforeEach(angular.mock.inject((_$httpBackend_, _QuerySet_) => {
+ beforeEach(angular.mock.inject((_$httpBackend_, _QuerySet_, _SmartSearchService_) => {
$httpBackend = _$httpBackend_;
QuerySet = _QuerySet_;
+ SmartSearchService = _SmartSearchService_;
// @todo: improve app source
// config.js / local_settings emit $http requests in the app's run block
@@ -33,24 +35,27 @@ describe('Service: QuerySet', () => {
.respond(200, '');
}));
- describe('fn encodeQuery', () => {
- xit('null/undefined params should return an empty string', () => {
- expect(QuerySet.encodeQuery(null)).toEqual('');
- expect(QuerySet.encodeQuery(undefined)).toEqual('');
+ describe('fn encodeParam', () => {
+ it('should encode parameters properly', () =>{
+ expect(QuerySet.encodeParam({term: "name:foo", searchTerm: true})).toEqual({"name__icontains_DEFAULT" : "foo"});
+ expect(QuerySet.encodeParam({term: "-name:foo", searchTerm: true})).toEqual({"not__name__icontains_DEFAULT" : "foo"});
+ expect(QuerySet.encodeParam({term: "name:'foo bar'", searchTerm: true})).toEqual({"name__icontains_DEFAULT" : "'foo bar'"});
+ expect(QuerySet.encodeParam({term: "-name:'foo bar'", searchTerm: true})).toEqual({"not__name__icontains_DEFAULT" : "'foo bar'"});
+ expect(QuerySet.encodeParam({term: "organization:foo", relatedSearchTerm: true})).toEqual({"organization__search_DEFAULT" : "foo"});
+ expect(QuerySet.encodeParam({term: "-organization:foo", relatedSearchTerm: true})).toEqual({"not__organization__search_DEFAULT" : "foo"});
+ expect(QuerySet.encodeParam({term: "organization.name:foo", relatedSearchTerm: true})).toEqual({"organization__name" : "foo"});
+ expect(QuerySet.encodeParam({term: "-organization.name:foo", relatedSearchTerm: true})).toEqual({"not__organization__name" : "foo"});
+ expect(QuerySet.encodeParam({term: "id:11", searchTerm: true})).toEqual({"id__icontains_DEFAULT" : "11"});
+ expect(QuerySet.encodeParam({term: "-id:11", searchTerm: true})).toEqual({"not__id__icontains_DEFAULT" : "11"});
+ expect(QuerySet.encodeParam({term: "id:>11", searchTerm: true})).toEqual({"id__gt" : "11"});
+ expect(QuerySet.encodeParam({term: "-id:>11", searchTerm: true})).toEqual({"not__id__gt" : "11"});
+ expect(QuerySet.encodeParam({term: "id:>=11", searchTerm: true})).toEqual({"id__gte" : "11"});
+ expect(QuerySet.encodeParam({term: "-id:>=11", searchTerm: true})).toEqual({"not__id__gte" : "11"});
+ expect(QuerySet.encodeParam({term: "id:<11", searchTerm: true})).toEqual({"id__lt" : "11"});
+ expect(QuerySet.encodeParam({term: "-id:<11", searchTerm: true})).toEqual({"not__id__lt" : "11"});
+ expect(QuerySet.encodeParam({term: "id:<=11", searchTerm: true})).toEqual({"id__lte" : "11"});
+ expect(QuerySet.encodeParam({term: "-id:<=11", searchTerm: true})).toEqual({"not__id__lte" : "11"});
});
- xit('should encode params to a string', () => {
- let params = {
- or__created_by: 'Jenkins',
- or__modified_by: 'Jenkins',
- and__not__status: 'success',
- },
- result = '?or__created_by=Jenkins&or__modified_by=Jenkins&and__not__status=success';
- expect(QuerySet.encodeQuery(params)).toEqual(result);
- });
- });
-
- xdescribe('fn decodeQuery', () => {
-
});
diff --git a/awx/ui/tests/spec/smart-search/smart-search.service-test.js b/awx/ui/tests/spec/smart-search/smart-search.service-test.js
new file mode 100644
index 0000000000..d5c35a08c2
--- /dev/null
+++ b/awx/ui/tests/spec/smart-search/smart-search.service-test.js
@@ -0,0 +1,43 @@
+'use strict';
+
+describe('Service: SmartSearch', () => {
+ let SmartSearchService;
+
+ beforeEach(angular.mock.module('Tower'));
+
+ beforeEach(angular.mock.module('SmartSearchModule'));
+
+ beforeEach(angular.mock.inject((_SmartSearchService_) => {
+ SmartSearchService = _SmartSearchService_;
+ }));
+
+ describe('fn splitSearchIntoTerms', () => {
+ it('should convert the search string to an array tag strings', () =>{
+ expect(SmartSearchService.splitSearchIntoTerms('foo')).toEqual(["foo"]);
+ expect(SmartSearchService.splitSearchIntoTerms('foo bar')).toEqual(["foo", "bar"]);
+ expect(SmartSearchService.splitSearchIntoTerms('name:foo bar')).toEqual(["name:foo", "bar"]);
+ expect(SmartSearchService.splitSearchIntoTerms('name:foo description:bar')).toEqual(["name:foo", "description:bar"]);
+ expect(SmartSearchService.splitSearchIntoTerms('name:"foo bar"')).toEqual(["name:\"foo bar\""]);
+ expect(SmartSearchService.splitSearchIntoTerms('name:"foo bar" description:"bar foo"')).toEqual(["name:\"foo bar\"", "description:\"bar foo\""]);
+ expect(SmartSearchService.splitSearchIntoTerms('name:"foo bar" description:"bar foo"')).toEqual(["name:\"foo bar\"", "description:\"bar foo\""]);
+ expect(SmartSearchService.splitSearchIntoTerms('name:\'foo bar\'')).toEqual(["name:\'foo bar\'"]);
+ expect(SmartSearchService.splitSearchIntoTerms('name:\'foo bar\' description:\'bar foo\'')).toEqual(["name:\'foo bar\'", "description:\'bar foo\'"]);
+ expect(SmartSearchService.splitSearchIntoTerms('name:\'foo bar\' description:\'bar foo\'')).toEqual(["name:\'foo bar\'", "description:\'bar foo\'"]);
+ expect(SmartSearchService.splitSearchIntoTerms('name:\"foo bar\" description:\'bar foo\'')).toEqual(["name:\"foo bar\"", "description:\'bar foo\'"]);
+ expect(SmartSearchService.splitSearchIntoTerms('name:\"foo bar\" foo')).toEqual(["name:\"foo bar\"", "foo"]);
+ });
+ });
+
+ describe('fn splitTermIntoParts', () => {
+ it('should convert the search term to a key and value', () =>{
+ expect(SmartSearchService.splitTermIntoParts('foo')).toEqual(["foo"]);
+ expect(SmartSearchService.splitTermIntoParts('foo:bar')).toEqual(["foo", "bar"]);
+ expect(SmartSearchService.splitTermIntoParts('foo:bar:foobar')).toEqual(["foo", "bar:foobar"]);
+ expect(SmartSearchService.splitTermIntoParts('name:\"foo bar\"')).toEqual(["name", "\"foo bar\""]);
+ expect(SmartSearchService.splitTermIntoParts('name:\"foo:bar\"')).toEqual(["name", "\"foo:bar\""]);
+ expect(SmartSearchService.splitTermIntoParts('name:\'foo bar\'')).toEqual(["name", "\'foo bar\'"]);
+ expect(SmartSearchService.splitTermIntoParts('name:\'foo:bar\'')).toEqual(["name", "\'foo:bar\'"]);
+ });
+ });
+
+});