Skip to content

Commit

Permalink
feat(events-stream): Fix search to support negation and wildcards (#1…
Browse files Browse the repository at this point in the history
…0989)

Strips `!` and `*` from query filters.
  • Loading branch information
billyvg committed Dec 12, 2018
1 parent b9843d6 commit c6fbe41
Show file tree
Hide file tree
Showing 4 changed files with 70 additions and 8 deletions.
34 changes: 26 additions & 8 deletions src/sentry/static/sentry/app/components/smartSearchBar.jsx
Expand Up @@ -6,6 +6,7 @@ import classNames from 'classnames';
import createReactClass from 'create-react-class';
import styled from 'react-emotion';

import {NEGATION_OPERATOR, SEARCH_WILDCARD} from 'app/constants';
import {t} from 'app/locale';
import MemberListStore from 'app/stores/memberListStore';
import SearchDropdown from 'app/views/stream/searchDropdown';
Expand Down Expand Up @@ -35,6 +36,11 @@ class SmartSearchBar extends React.Component {

query: PropTypes.string,

/**
* Prepare query value before filtering dropdown items
*/
prepareQuery: PropTypes.func,

// Search items to display when there's no tag key
defaultSearchItems: PropTypes.array.isRequired,

Expand Down Expand Up @@ -176,13 +182,15 @@ class SmartSearchBar extends React.Component {
* e.g. ['is:', 'assigned:', 'url:', 'release:']
*/
getTagKeys = function(query) {
const {supportedTags} = this.props;
const {supportedTags, prepareQuery} = this.props;

// Return all if query is empty
let tagKeys = Object.keys(supportedTags).map(key => `${key}:`);

if (query) {
tagKeys = tagKeys.filter(key => key.indexOf(query) > -1);
const preparedQuery =
typeof prepareQuery === 'function' ? prepareQuery(query) : query;
tagKeys = tagKeys.filter(key => key.indexOf(preparedQuery) > -1);
}

// If the environment feature is active and excludeEnvironment = true
Expand Down Expand Up @@ -291,19 +299,24 @@ class SmartSearchBar extends React.Component {
this.setState({searchTerm: matchValue});
this.updateAutoCompleteState(autoCompleteItems, matchValue);
} else {
let {supportedTags} = this.props;
let {supportedTags, prepareQuery} = this.props;

// TODO(billy): Better parsing for these examples
// sentry:release:
// url:"http://with/colon"
tagName = last.slice(0, index);

// e.g. given "!gpu" we want "gpu"
tagName = tagName.replace(new RegExp(`^${NEGATION_OPERATOR}`), '');
query = last.slice(index + 1);
const preparedQuery =
typeof prepareQuery === 'function' ? prepareQuery(query) : query;

// filter existing items immediately, until API can return
// with actual tag value results
let filteredSearchItems = !query
let filteredSearchItems = !preparedQuery
? this.state.searchItems
: this.state.searchItems.filter(item => item.value.indexOf(query) !== -1);
: this.state.searchItems.filter(item => item.value.indexOf(preparedQuery) !== -1);

this.setState({
searchTerm: query,
Expand All @@ -321,7 +334,7 @@ class SmartSearchBar extends React.Component {

return void (tag.predefined ? this.getPredefinedTagValues : this.getTagValues)(
tag,
query,
preparedQuery,
this.updateAutoCompleteState
);
}
Expand Down Expand Up @@ -420,12 +433,17 @@ class SmartSearchBar extends React.Component {

newQuery = query.slice(0, lastTermIndex); // get text preceding last term

const prefix = newQuery.startsWith(NEGATION_OPERATOR) ? NEGATION_OPERATOR : '';
const valuePrefix = newQuery.endsWith(SEARCH_WILDCARD) ? SEARCH_WILDCARD : '';

// newQuery is "<term>:"
// replaceText should be the selected value
newQuery =
last.indexOf(':') > -1
? // tag key present: replace everything after colon with replaceText
newQuery.replace(/\:"[^"]*"?$|\:\S*$/, ':' + replaceText)
newQuery.replace(/\:"[^"]*"?$|\:\S*$/, `:${valuePrefix}` + replaceText)
: // no tag key present: replace last token with replaceText
newQuery.replace(/\S+$/, replaceText);
newQuery.replace(/\S+$/, `${prefix}${replaceText}`);

newQuery = newQuery.concat(query.slice(lastTermIndex));
}
Expand Down
4 changes: 4 additions & 0 deletions src/sentry/static/sentry/app/constants/index.jsx
Expand Up @@ -71,3 +71,7 @@ export const DEFAULT_RELATIVE_PERIODS = {
'14d': t('Last 14 days'),
'30d': t('Last 30 days'),
};

// Special Search characters
export const NEGATION_OPERATOR = '!';
export const SEARCH_WILDCARD = '*';
Expand Up @@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import React from 'react';

import {COLUMNS} from 'app/views/organizationDiscover/data';
import {NEGATION_OPERATOR, SEARCH_WILDCARD} from 'app/constants';
import {addErrorMessage} from 'app/actionCreators/indicator';
import {defined} from 'app/utils';
import {fetchEventFieldValues} from 'app/actionCreators/events';
Expand All @@ -23,6 +24,11 @@ const tagToObjectReducer = (acc, name) => {

const TAGS = COLUMNS.map(({name}) => name);

const SEARCH_SPECIAL_CHARS_REGEXP = new RegExp(
`^${NEGATION_OPERATOR}|\\${SEARCH_WILDCARD}`,
'g'
);

class SearchBar extends React.PureComponent {
static propTypes = {
api: PropTypes.object,
Expand Down Expand Up @@ -69,12 +75,20 @@ class SearchBar extends React.PureComponent {
.sort()
.reduce(tagToObjectReducer, {});

/**
* Prepare query string (e.g. strip special characters like negation operator)
*/
prepareQuery = query => {
return query.replace(SEARCH_SPECIAL_CHARS_REGEXP, '');
};

render() {
return (
<SmartSearchBar
{...this.props}
onGetTagValues={this.getEventFieldValues}
supportedTags={this.state.tags}
prepareQuery={this.prepareQuery}
excludeEnvironment
dropdownClassName={css`
max-height: 300px;
Expand Down
26 changes: 26 additions & 0 deletions tests/js/spec/views/organizationEvents/searchBar.spec.jsx
Expand Up @@ -118,4 +118,30 @@ describe('SearchBar', function() {
setQuery(wrapper, '');
expect(wrapper.find('.search-description strong')).toHaveLength(0);
});

it('ignores negation ("!") at the beginning of search term', async function() {
let wrapper = await mount(<SearchBar {...props} />, options);

setQuery(wrapper, '!gp');
await tick();
wrapper.update();

expect(wrapper.find('.search-autocomplete-item')).toHaveLength(1);
expect(wrapper.find('.search-autocomplete-item').text()).toBe('gpu:');
});

it('ignores wildcard ("*") at the beginning of tag value query', async function() {
let wrapper = await mount(<SearchBar {...props} />, options);

setQuery(wrapper, '!gpu:*');
await tick();
wrapper.update();

expect(tagValuesMock).toHaveBeenCalledWith(
'/organizations/org-slug/tags/gpu/values/',
expect.objectContaining({data: {query: ''}})
);
selectFirstAutocompleteItem(wrapper);
expect(wrapper.find('input').prop('value')).toBe('!gpu:*"Nvidia 1080ti" ');
});
});

0 comments on commit c6fbe41

Please sign in to comment.