From c6fbe4174be328c5c0b231c4b07b4325592bdaa6 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 11 Dec 2018 16:01:33 -0800 Subject: [PATCH] feat(events-stream): Fix search to support negation and wildcards (#10989) Strips `!` and `*` from query filters. --- .../sentry/app/components/smartSearchBar.jsx | 34 ++++++++++++++----- .../static/sentry/app/constants/index.jsx | 4 +++ .../views/organizationEvents/searchBar.jsx | 14 ++++++++ .../organizationEvents/searchBar.spec.jsx | 26 ++++++++++++++ 4 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/sentry/static/sentry/app/components/smartSearchBar.jsx b/src/sentry/static/sentry/app/components/smartSearchBar.jsx index a41599ff57d546..a699189d4682f8 100644 --- a/src/sentry/static/sentry/app/components/smartSearchBar.jsx +++ b/src/sentry/static/sentry/app/components/smartSearchBar.jsx @@ -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'; @@ -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, @@ -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 @@ -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, @@ -321,7 +334,7 @@ class SmartSearchBar extends React.Component { return void (tag.predefined ? this.getPredefinedTagValues : this.getTagValues)( tag, - query, + preparedQuery, this.updateAutoCompleteState ); } @@ -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 ":" + // 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)); } diff --git a/src/sentry/static/sentry/app/constants/index.jsx b/src/sentry/static/sentry/app/constants/index.jsx index 35009a8daf3250..9bd04fba866ba0 100644 --- a/src/sentry/static/sentry/app/constants/index.jsx +++ b/src/sentry/static/sentry/app/constants/index.jsx @@ -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 = '*'; diff --git a/src/sentry/static/sentry/app/views/organizationEvents/searchBar.jsx b/src/sentry/static/sentry/app/views/organizationEvents/searchBar.jsx index d69ab396c2bf84..ccfa0798a59d3d 100644 --- a/src/sentry/static/sentry/app/views/organizationEvents/searchBar.jsx +++ b/src/sentry/static/sentry/app/views/organizationEvents/searchBar.jsx @@ -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'; @@ -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, @@ -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 ( , 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(, 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" '); + }); });