Started to refactor some smart search functionality

This commit is contained in:
Michael Abashian 2017-01-10 14:49:58 -05:00
parent 6c4df56223
commit 2b0598f092
5 changed files with 215 additions and 27 deletions

View File

@ -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);

View File

@ -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;
},
};
}
];

View File

@ -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}));
}
}
});

View File

@ -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;
}
}
};
}];

View File

@ -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\'"]);
});
});
});