From 2b0598f0929ed83df9da7d4dabb4946577a58577 Mon Sep 17 00:00:00 2001 From: Michael Abashian Date: Tue, 10 Jan 2017 14:49:58 -0500 Subject: [PATCH] Started to refactor some smart search functionality --- awx/ui/client/src/shared/smart-search/main.js | 3 +- .../shared/smart-search/queryset.service.js | 102 ++++++++++++++++-- .../smart-search/smart-search.controller.js | 76 +++++++++---- .../smart-search/smart-search.service.js | 19 ++++ .../smart-search/smart-search.service-test.js | 42 ++++++++ 5 files changed, 215 insertions(+), 27 deletions(-) create mode 100644 awx/ui/client/src/shared/smart-search/smart-search.service.js create mode 100644 awx/ui/tests/spec/smart-search/smart-search.service-test.js 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..ec18f1b0d1 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,114 @@ export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSear return angular.isObject(params) ? `?${queryset}` : ''; function encodeTerm(value, key){ + if (Array.isArray(value)){ - return _.map(value, (item) => `${key}=${item}`).join('&') + '&'; + return _.map(value, function(item){ + item = item.replace(/"|'/g, ""); + return `${key}=${item}`.join('&') + '&'; + }); } 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].startsWith("-")) { + 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'; + } + else if(params.relatedSearchTerm) { + paramString += keySplit[0] + '__search'; + } + 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/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 +246,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 95f3be3adb..7b09def616 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); }); } @@ -38,6 +39,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; }; @@ -56,7 +67,27 @@ 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); + } _.each(removed, (value, key) => { if (Array.isArray(queryset[key])){ _.remove(queryset[key], (item) => item === value); @@ -79,26 +110,35 @@ 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); + // if only a value is provided, search using default keys - if (term.split(':').length === 1) { + if (termParts.length === 1) { params = _.merge(params, setDefaults(term)); } 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})); + } + else { + params = _.merge(params, qs.encodeParam({term: term, searchTerm: true})); + } + } + // Its not a search term or a related search term + else { + params = _.merge(params, qs.encodeParam({term: term})); + } + } }); 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/smart-search.service-test.js b/awx/ui/tests/spec/smart-search/smart-search.service-test.js new file mode 100644 index 0000000000..679a5656b4 --- /dev/null +++ b/awx/ui/tests/spec/smart-search/smart-search.service-test.js @@ -0,0 +1,42 @@ +'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\'"]); + }); + }); + + 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\'"]); + }); + }); + +});