mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
2219 lines
64 KiB
TypeScript
2219 lines
64 KiB
TypeScript
import type {VFC} from 'react';
|
|
import {Component, createRef} from 'react';
|
|
import type {WithRouterProps} from 'react-router';
|
|
import styled from '@emotion/styled';
|
|
import * as Sentry from '@sentry/react';
|
|
import debounce from 'lodash/debounce';
|
|
import isEqual from 'lodash/isEqual';
|
|
|
|
import {addErrorMessage} from 'sentry/actionCreators/indicator';
|
|
import {fetchRecentSearches, saveRecentSearch} from 'sentry/actionCreators/savedSearches';
|
|
import type {Client} from 'sentry/api';
|
|
import ButtonBar from 'sentry/components/buttonBar';
|
|
import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
|
|
import type {
|
|
BooleanOperator,
|
|
ParseResult,
|
|
SearchConfig,
|
|
TermOperator,
|
|
TokenResult,
|
|
} from 'sentry/components/searchSyntax/parser';
|
|
import {
|
|
FilterType,
|
|
InvalidReason,
|
|
parseSearch,
|
|
Token,
|
|
} from 'sentry/components/searchSyntax/parser';
|
|
import HighlightQuery from 'sentry/components/searchSyntax/renderer';
|
|
import {
|
|
getKeyName,
|
|
isOperator,
|
|
isWithinToken,
|
|
treeResultLocator,
|
|
} from 'sentry/components/searchSyntax/utils';
|
|
import {
|
|
DEFAULT_DEBOUNCE_DURATION,
|
|
MAX_AUTOCOMPLETE_RELEASES,
|
|
NEGATION_OPERATOR,
|
|
} from 'sentry/constants';
|
|
import {IconClose, IconEllipsis, IconSearch} from 'sentry/icons';
|
|
import {t} from 'sentry/locale';
|
|
import MemberListStore from 'sentry/stores/memberListStore';
|
|
import {space} from 'sentry/styles/space';
|
|
import type {Organization, Tag, TagCollection, User} from 'sentry/types';
|
|
import {SavedSearchType} from 'sentry/types';
|
|
import {defined} from 'sentry/utils';
|
|
import {trackAnalytics} from 'sentry/utils/analytics';
|
|
import type {FieldDefinition} from 'sentry/utils/fields';
|
|
import {FieldKind, FieldValueType, getFieldDefinition} from 'sentry/utils/fields';
|
|
import withApi from 'sentry/utils/withApi';
|
|
import withOrganization from 'sentry/utils/withOrganization';
|
|
// eslint-disable-next-line no-restricted-imports
|
|
import withSentryRouter from 'sentry/utils/withSentryRouter';
|
|
|
|
import type {MenuItemProps} from '../dropdownMenu';
|
|
import {DropdownMenu} from '../dropdownMenu';
|
|
|
|
import {ActionButton} from './actionButton';
|
|
import SearchBarDatePicker from './searchBarDatePicker';
|
|
import SearchDropdown from './searchDropdown';
|
|
import SearchHotkeysListener from './searchHotkeysListener';
|
|
import type {AutocompleteGroup, SearchGroup, SearchItem, Shortcut} from './types';
|
|
import {ItemType, ShortcutType} from './types';
|
|
import {
|
|
addSpace,
|
|
createSearchGroups,
|
|
escapeTagValue,
|
|
filterKeysFromQuery,
|
|
generateOperatorEntryMap,
|
|
getAutoCompleteGroupForInvalidWildcard,
|
|
getDateTagAutocompleteGroups,
|
|
getSearchGroupWithItemMarkedActive,
|
|
getTagItemsFromKeys,
|
|
getValidOps,
|
|
removeSpace,
|
|
shortcuts,
|
|
} from './utils';
|
|
|
|
/**
|
|
* The max width in pixels of the search bar at which the buttons will
|
|
* have overflowed into the dropdown.
|
|
*/
|
|
const ACTION_OVERFLOW_WIDTH = 400;
|
|
|
|
/**
|
|
* Actions are moved to the overflow dropdown after each pixel step is reached.
|
|
*/
|
|
const ACTION_OVERFLOW_STEPS = 75;
|
|
|
|
const generateOpAutocompleteGroup = (
|
|
validOps: readonly TermOperator[],
|
|
tagName: string
|
|
): AutocompleteGroup => {
|
|
const operatorMap = generateOperatorEntryMap(tagName);
|
|
const operatorItems = validOps.map(op => operatorMap[op]);
|
|
return {
|
|
searchItems: operatorItems,
|
|
recentSearchItems: undefined,
|
|
tagName: '',
|
|
type: ItemType.TAG_OPERATOR,
|
|
};
|
|
};
|
|
|
|
function maybeFocusInput(input: HTMLTextAreaElement | null) {
|
|
// Cannot focus if there is no input or if the input is already focused
|
|
if (!input || document.activeElement === input) {
|
|
return;
|
|
}
|
|
|
|
input.focus();
|
|
}
|
|
|
|
const pickParserOptions = (props: Props) => {
|
|
const {
|
|
booleanKeys,
|
|
dateKeys,
|
|
durationKeys,
|
|
numericKeys,
|
|
percentageKeys,
|
|
sizeKeys,
|
|
textOperatorKeys,
|
|
getFilterWarning,
|
|
supportedTags,
|
|
highlightUnsupportedTags,
|
|
disallowedLogicalOperators,
|
|
disallowWildcard,
|
|
disallowFreeText,
|
|
invalidMessages,
|
|
} = props;
|
|
|
|
return {
|
|
booleanKeys,
|
|
dateKeys,
|
|
durationKeys,
|
|
numericKeys,
|
|
percentageKeys,
|
|
sizeKeys,
|
|
textOperatorKeys,
|
|
getFilterTokenWarning: getFilterWarning,
|
|
supportedTags,
|
|
validateKeys: highlightUnsupportedTags,
|
|
disallowedLogicalOperators,
|
|
disallowWildcard,
|
|
disallowFreeText,
|
|
invalidMessages,
|
|
} satisfies Partial<SearchConfig>;
|
|
};
|
|
|
|
export type ActionProps = {
|
|
api: Client;
|
|
/**
|
|
* The organization
|
|
*/
|
|
organization: Organization;
|
|
/**
|
|
* The current query
|
|
*/
|
|
query: string;
|
|
/**
|
|
* The saved search type passed to the search bar
|
|
*/
|
|
savedSearchType?: SavedSearchType;
|
|
};
|
|
|
|
export type ActionBarItem = {
|
|
/**
|
|
* Name of the action
|
|
*/
|
|
key: string;
|
|
makeAction: (props: ActionProps) => {Button: VFC; menuItem: MenuItemProps};
|
|
};
|
|
|
|
type DefaultProps = {
|
|
defaultQuery: string;
|
|
/**
|
|
* Search items to display when there's no tag key. Is a tuple of search
|
|
* items and recent search items
|
|
*/
|
|
defaultSearchItems: [SearchItem[], SearchItem[]];
|
|
/**
|
|
* The lookup strategy for field definitions.
|
|
* Each SmartSearchBar instance can support a different list of fields and tags,
|
|
* their definitions may not overlap.
|
|
*/
|
|
fieldDefinitionGetter: (key: string) => FieldDefinition | null;
|
|
id: string;
|
|
includeLabel: boolean;
|
|
name: string;
|
|
/**
|
|
* Called when the user makes a search
|
|
*/
|
|
onSearch: (query: string) => void;
|
|
/**
|
|
* Input placeholder
|
|
*/
|
|
placeholder: string;
|
|
query: string | null;
|
|
/**
|
|
* If this is defined, attempt to save search term scoped to the user and
|
|
* the current org
|
|
*/
|
|
savedSearchType: SavedSearchType;
|
|
/**
|
|
* Map of tags
|
|
*/
|
|
supportedTags: TagCollection;
|
|
/**
|
|
* Wrap the input with a form. Useful if search bar is used within a parent
|
|
* form
|
|
*/
|
|
useFormWrapper: boolean;
|
|
/**
|
|
* Allows for customization of the invalid token messages.
|
|
*/
|
|
invalidMessages?: SearchConfig['invalidMessages'];
|
|
};
|
|
|
|
type Props = WithRouterProps &
|
|
Partial<DefaultProps> & {
|
|
api: Client;
|
|
organization: Organization;
|
|
/**
|
|
* Additional components to render as actions on the right of the search bar
|
|
*/
|
|
actionBarItems?: ActionBarItem[];
|
|
/**
|
|
* Keys that have boolean values
|
|
*/
|
|
booleanKeys?: Set<string>;
|
|
className?: string;
|
|
/**
|
|
* A function that provides the current search item and can return a custom invalid tag error message for the drop-down.
|
|
*/
|
|
customInvalidTagMessage?: (item: SearchItem) => React.ReactNode;
|
|
|
|
/**
|
|
* Keys that have date values
|
|
*/
|
|
dateKeys?: Set<string>;
|
|
/**
|
|
* The default search group to show when there is no query
|
|
*/
|
|
defaultSearchGroup?: SearchGroup;
|
|
/**
|
|
* Disabled control (e.g. read-only)
|
|
*/
|
|
disabled?: boolean;
|
|
/**
|
|
* Disables free text searches
|
|
*/
|
|
disallowFreeText?: boolean;
|
|
/**
|
|
* Disables wildcard searches (in freeText and in the value of key:value searches mode)
|
|
*/
|
|
disallowWildcard?: boolean;
|
|
/**
|
|
* Disables specified boolean operators
|
|
*/
|
|
disallowedLogicalOperators?: Set<BooleanOperator>;
|
|
dropdownClassName?: string;
|
|
/**
|
|
* Keys that have duration values
|
|
*/
|
|
durationKeys?: Set<string>;
|
|
/**
|
|
* A list of tags to exclude from the autocompletion list, for ex environment may be excluded
|
|
* because we don't want to treat environment as a tag in some places such
|
|
* as the stream view where it is a top level concept
|
|
*/
|
|
excludedTags?: string[];
|
|
/**
|
|
* A function that returns a warning message for a given filter key
|
|
* will only show a render a warning if the value is truthy
|
|
*/
|
|
getFilterWarning?: (key) => React.ReactNode;
|
|
/**
|
|
* List user's recent searches
|
|
*/
|
|
hasRecentSearches?: boolean;
|
|
/**
|
|
* Whether or not to highlight unsupported tags red
|
|
*/
|
|
highlightUnsupportedTags?: boolean;
|
|
/**
|
|
* Allows additional content to be played before the search bar and icon
|
|
*/
|
|
inlineLabel?: React.ReactNode;
|
|
/**
|
|
* Maximum height for the search dropdown menu
|
|
*/
|
|
maxMenuHeight?: number;
|
|
/**
|
|
* Used to enforce length on the query
|
|
*/
|
|
maxQueryLength?: number;
|
|
/**
|
|
* Maximum number of search items to display or a falsey value for no
|
|
* maximum
|
|
*/
|
|
maxSearchItems?: number;
|
|
/**
|
|
* While the data is unused, this list of members can be updated to
|
|
* trigger re-renders.
|
|
*/
|
|
members?: User[];
|
|
/**
|
|
* Extend search group items with additional props
|
|
* Useful for providing descriptions to field parents with many children
|
|
*/
|
|
mergeSearchGroupWith?: Record<string, SearchItem>;
|
|
/**
|
|
* Keys that have numeric values
|
|
*/
|
|
numericKeys?: Set<string>;
|
|
/**
|
|
* Called when the search input is blurred.
|
|
* Note that the input may be blurred when the user selects an autocomplete
|
|
* value - if you don't want that, onClose may be a better option.
|
|
*/
|
|
onBlur?: (value: string) => void;
|
|
/**
|
|
* Called when the search input changes
|
|
*/
|
|
onChange?: (value: string, e: React.ChangeEvent | React.ClipboardEvent) => void;
|
|
/**
|
|
* Called when the user has closed the search dropdown.
|
|
* Occurs on escape, tab, or clicking outside the component.
|
|
*/
|
|
onClose?: (value: string, additionalSearchBarState: {validSearch: boolean}) => void;
|
|
/**
|
|
* Get a list of recent searches for the current query
|
|
*/
|
|
onGetRecentSearches?: (query: string) => Promise<SearchItem[]>;
|
|
/**
|
|
* Get a list of tag values for the passed tag
|
|
*/
|
|
onGetTagValues?: (tag: Tag, query: string, params: object) => Promise<string[]>;
|
|
/**
|
|
* Called on key down
|
|
*/
|
|
onKeyDown?: (evt: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
|
/**
|
|
* Called when a recent search is saved
|
|
*/
|
|
onSavedRecentSearch?: (query: string) => void;
|
|
/**
|
|
* Keys that have percentage values
|
|
*/
|
|
percentageKeys?: Set<string>;
|
|
/**
|
|
* Prepare query value before filtering dropdown items
|
|
*/
|
|
prepareQuery?: (query: string) => string;
|
|
/**
|
|
* Indicates the usage of the search bar for analytics
|
|
*/
|
|
searchSource?: string;
|
|
/**
|
|
* Keys that have size values
|
|
*/
|
|
sizeKeys?: Set<string>;
|
|
/**
|
|
* Type of supported tags
|
|
*/
|
|
supportedTagType?: ItemType;
|
|
/**
|
|
* Keys with text values that also allow additional operation like ">=" / "<=" / ">" / "<" / "=" / "!="
|
|
*/
|
|
textOperatorKeys?: Set<string>;
|
|
};
|
|
|
|
type State = {
|
|
/**
|
|
* Index of the focused search item
|
|
*/
|
|
activeSearchItem: number;
|
|
flatSearchItems: SearchItem[];
|
|
inputHasFocus: boolean;
|
|
loading: boolean;
|
|
/**
|
|
* The number of actions that are not in the overflow menu.
|
|
*/
|
|
numActionsVisible: number;
|
|
/**
|
|
* The query parsed into an AST. If the query fails to parse this will be
|
|
* null.
|
|
*/
|
|
parsedQuery: ParseResult | null;
|
|
/**
|
|
* The current search query in the input
|
|
*/
|
|
query: string;
|
|
searchGroups: SearchGroup[];
|
|
/**
|
|
* The current search term (or 'key') that that we will be showing
|
|
* autocompletion for.
|
|
*/
|
|
searchTerm: string;
|
|
|
|
/**
|
|
* Boolean indicating if dropdown should be shown
|
|
*/
|
|
showDropdown: boolean;
|
|
tags: Record<string, string>;
|
|
/**
|
|
* Indicates that we have a query that we've already determined not to have
|
|
* any values. This is used to stop the autocompleter from querying if we
|
|
* know we will find nothing.
|
|
*/
|
|
noValueQuery?: string;
|
|
/**
|
|
* The query in the input since we last updated our autocomplete list.
|
|
*/
|
|
previousQuery?: string;
|
|
};
|
|
|
|
class SmartSearchBar extends Component<DefaultProps & Props, State> {
|
|
static defaultProps = {
|
|
id: 'smart-search-input',
|
|
includeLabel: true,
|
|
defaultQuery: '',
|
|
query: null,
|
|
onSearch: function () {},
|
|
name: 'query',
|
|
placeholder: t('Search for events, users, tags, and more'),
|
|
supportedTags: {},
|
|
defaultSearchItems: [[], []],
|
|
useFormWrapper: true,
|
|
savedSearchType: SavedSearchType.ISSUE,
|
|
fieldDefinitionGetter: getFieldDefinition,
|
|
} as DefaultProps;
|
|
|
|
state: State = {
|
|
query: this.initialQuery,
|
|
showDropdown: false,
|
|
parsedQuery: parseSearch(this.initialQuery, pickParserOptions(this.props)),
|
|
searchTerm: '',
|
|
searchGroups: [],
|
|
flatSearchItems: [],
|
|
activeSearchItem: -1,
|
|
tags: {},
|
|
inputHasFocus: false,
|
|
loading: false,
|
|
numActionsVisible: this.props.actionBarItems?.length ?? 0,
|
|
};
|
|
|
|
componentDidMount() {
|
|
if (!window.ResizeObserver) {
|
|
return;
|
|
}
|
|
|
|
if (this.containerRef.current === null) {
|
|
return;
|
|
}
|
|
|
|
this.inputResizeObserver = new ResizeObserver(this.updateActionsVisible);
|
|
this.inputResizeObserver.observe(this.containerRef.current);
|
|
}
|
|
|
|
componentDidUpdate(prevProps: Props) {
|
|
const {query, actionBarItems} = this.props;
|
|
const parserOptions = pickParserOptions(this.props);
|
|
|
|
const {query: lastQuery, actionBarItems: lastActionBar} = prevProps;
|
|
const prevParserOptions = pickParserOptions(prevProps);
|
|
|
|
if (query !== lastQuery && (defined(query) || defined(lastQuery))) {
|
|
this.setState(this.makeQueryState(addSpace(query ?? undefined)));
|
|
} else if (!isEqual(parserOptions, prevParserOptions)) {
|
|
// Re-parse query to apply new options (without resetting it to the query prop value)
|
|
this.setState(this.makeQueryState(this.state.query));
|
|
}
|
|
|
|
if (lastActionBar?.length !== actionBarItems?.length) {
|
|
this.setState({numActionsVisible: actionBarItems?.length ?? 0});
|
|
}
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
this.inputResizeObserver?.disconnect();
|
|
this.updateAutoCompleteItems.cancel();
|
|
document.removeEventListener('pointerup', this.onBackgroundPointerUp);
|
|
}
|
|
|
|
get initialQuery() {
|
|
const {query, defaultQuery} = this.props;
|
|
return query !== null ? addSpace(query) : defaultQuery ?? '';
|
|
}
|
|
|
|
makeQueryState(query: string) {
|
|
const additionalConfig: Partial<SearchConfig> = pickParserOptions(this.props);
|
|
return {
|
|
query,
|
|
parsedQuery: parseSearch(query, additionalConfig),
|
|
};
|
|
}
|
|
/**
|
|
* Ref to the search element itself
|
|
*/
|
|
searchInput = createRef<HTMLTextAreaElement>();
|
|
|
|
/**
|
|
* Ref to the search container
|
|
*/
|
|
containerRef = createRef<HTMLDivElement>();
|
|
|
|
/**
|
|
* Used to determine when actions should be moved to the action overflow menu
|
|
*/
|
|
inputResizeObserver: ResizeObserver | null = null;
|
|
|
|
/**
|
|
* Only closes the dropdown when pointer events occur outside of this component
|
|
*/
|
|
onBackgroundPointerUp = (e: PointerEvent) => {
|
|
if (this.containerRef.current?.contains(e.target as Node)) {
|
|
return;
|
|
}
|
|
|
|
this.close();
|
|
};
|
|
|
|
/**
|
|
* Updates the numActionsVisible count as the search bar is resized
|
|
*/
|
|
updateActionsVisible = (entries: ResizeObserverEntry[]) => {
|
|
if (entries.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const entry = entries[0];
|
|
const {width} = entry.contentRect;
|
|
const actionCount = this.props.actionBarItems?.length ?? 0;
|
|
|
|
const numActionsVisible = Math.min(
|
|
actionCount,
|
|
Math.floor(Math.max(0, width - ACTION_OVERFLOW_WIDTH) / ACTION_OVERFLOW_STEPS)
|
|
);
|
|
|
|
if (this.state.numActionsVisible === numActionsVisible) {
|
|
return;
|
|
}
|
|
|
|
this.setState({numActionsVisible});
|
|
};
|
|
|
|
blur() {
|
|
if (!this.searchInput.current) {
|
|
return;
|
|
}
|
|
this.searchInput.current.blur();
|
|
this.close();
|
|
}
|
|
|
|
async doSearch() {
|
|
this.blur();
|
|
|
|
const query = removeSpace(this.state.query);
|
|
const {organization, savedSearchType, searchSource} = this.props;
|
|
|
|
if (!this.hasValidSearch) {
|
|
trackAnalytics('search.search_with_invalid', {
|
|
organization,
|
|
query,
|
|
search_type: savedSearchType === 0 ? 'issues' : 'events',
|
|
search_source: searchSource,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const {onSearch, onSavedRecentSearch, api} = this.props;
|
|
trackAnalytics('search.searched', {
|
|
organization,
|
|
query,
|
|
search_type: savedSearchType === 0 ? 'issues' : 'events',
|
|
search_source: searchSource,
|
|
});
|
|
|
|
onSearch?.(query);
|
|
|
|
// Only save recent search query if we have a savedSearchType (also 0 is a valid value)
|
|
// Do not save empty string queries (i.e. if they clear search)
|
|
if (typeof savedSearchType === 'undefined' || !query) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await saveRecentSearch(api, organization.slug, savedSearchType, query);
|
|
|
|
if (onSavedRecentSearch) {
|
|
onSavedRecentSearch(query);
|
|
}
|
|
} catch (err) {
|
|
// Silently capture errors if it fails to save
|
|
Sentry.captureException(err);
|
|
}
|
|
}
|
|
|
|
moveToNextToken = (filterTokens: TokenResult<Token.FILTER>[]) => {
|
|
const token = this.cursorToken;
|
|
|
|
if (this.searchInput.current && filterTokens.length > 0) {
|
|
maybeFocusInput(this.searchInput.current);
|
|
|
|
let offset = filterTokens[0].location.end.offset;
|
|
if (token) {
|
|
const tokenIndex = filterTokens.findIndex(tok => tok === token);
|
|
if (tokenIndex !== -1 && tokenIndex + 1 < filterTokens.length) {
|
|
offset = filterTokens[tokenIndex + 1].location.end.offset;
|
|
}
|
|
}
|
|
|
|
this.searchInput.current.selectionStart = offset;
|
|
this.searchInput.current.selectionEnd = offset;
|
|
this.updateAutoCompleteItems();
|
|
}
|
|
};
|
|
|
|
deleteToken = () => {
|
|
const {query} = this.state;
|
|
const token = this.cursorToken ?? undefined;
|
|
const filterTokens = this.filterTokens;
|
|
const hasExecCommand = typeof document.execCommand === 'function';
|
|
|
|
if (token && filterTokens.length > 0) {
|
|
const index = filterTokens.findIndex(tok => tok === token) ?? -1;
|
|
const newQuery =
|
|
// We trim to remove any remaining spaces
|
|
query.slice(0, token.location.start.offset).trim() +
|
|
(index > 0 && index < filterTokens.length - 1 ? ' ' : '') +
|
|
query.slice(token.location.end.offset).trim();
|
|
|
|
if (this.searchInput.current) {
|
|
// Only use exec command if exists
|
|
maybeFocusInput(this.searchInput.current);
|
|
|
|
this.searchInput.current.selectionStart = 0;
|
|
this.searchInput.current.selectionEnd = query.length;
|
|
|
|
// Because firefox doesn't support inserting an empty string, we insert a newline character instead
|
|
// But because of this, only on firefox, if you delete the last token you won't be able to undo.
|
|
if (
|
|
(navigator.userAgent.toLowerCase().includes('firefox') &&
|
|
newQuery.length === 0) ||
|
|
!hasExecCommand ||
|
|
!document.execCommand('insertText', false, newQuery)
|
|
) {
|
|
// This will run either when newQuery is empty on firefox or when execCommand fails.
|
|
this.updateQuery(newQuery);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
negateToken = () => {
|
|
const {query} = this.state;
|
|
const token = this.cursorToken ?? undefined;
|
|
const hasExecCommand = typeof document.execCommand === 'function';
|
|
|
|
if (token && token.type === Token.FILTER) {
|
|
if (token.negated) {
|
|
if (this.searchInput.current) {
|
|
maybeFocusInput(this.searchInput.current);
|
|
|
|
const tokenCursorOffset = this.cursorPosition - token.key.location.start.offset;
|
|
|
|
// Select the whole token so we can replace it.
|
|
this.searchInput.current.selectionStart = token.location.start.offset;
|
|
this.searchInput.current.selectionEnd = token.location.end.offset;
|
|
|
|
// We can't call insertText with an empty string on Firefox, so we have to do this.
|
|
if (
|
|
!hasExecCommand ||
|
|
!document.execCommand('insertText', false, token.text.slice(1))
|
|
) {
|
|
// Fallback when execCommand fails
|
|
const newQuery =
|
|
query.slice(0, token.location.start.offset) +
|
|
query.slice(token.key.location.start.offset);
|
|
this.updateQuery(newQuery, this.cursorPosition - 1);
|
|
}
|
|
|
|
// Return the cursor to where it should be
|
|
const newCursorPosition = token.location.start.offset + tokenCursorOffset;
|
|
this.searchInput.current.selectionStart = newCursorPosition;
|
|
this.searchInput.current.selectionEnd = newCursorPosition;
|
|
}
|
|
} else {
|
|
if (this.searchInput.current) {
|
|
maybeFocusInput(this.searchInput.current);
|
|
|
|
const tokenCursorOffset = this.cursorPosition - token.key.location.start.offset;
|
|
|
|
this.searchInput.current.selectionStart = token.location.start.offset;
|
|
this.searchInput.current.selectionEnd = token.location.start.offset;
|
|
|
|
if (!hasExecCommand || !document.execCommand('insertText', false, '!')) {
|
|
// Fallback when execCommand fails
|
|
const newQuery =
|
|
query.slice(0, token.key.location.start.offset) +
|
|
'!' +
|
|
query.slice(token.key.location.start.offset);
|
|
this.updateQuery(newQuery, this.cursorPosition + 1);
|
|
}
|
|
|
|
// Return the cursor to where it should be, +1 for the ! character we added
|
|
const newCursorPosition = token.location.start.offset + tokenCursorOffset + 1;
|
|
this.searchInput.current.selectionStart = newCursorPosition;
|
|
this.searchInput.current.selectionEnd = newCursorPosition;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
logShortcutEvent = (shortcutType: ShortcutType, shortcutMethod: 'click' | 'hotkey') => {
|
|
const {searchSource, savedSearchType, organization} = this.props;
|
|
const {query} = this.state;
|
|
trackAnalytics('search.shortcut_used', {
|
|
organization,
|
|
search_type: savedSearchType === 0 ? 'issues' : 'events',
|
|
search_source: searchSource,
|
|
shortcut_method: shortcutMethod,
|
|
shortcut_type: shortcutType,
|
|
query,
|
|
});
|
|
};
|
|
|
|
runShortcutOnClick = (shortcut: Shortcut) => {
|
|
this.runShortcut(shortcut);
|
|
this.logShortcutEvent(shortcut.shortcutType, 'click');
|
|
};
|
|
|
|
runShortcutOnHotkeyPress = (shortcut: Shortcut) => {
|
|
this.runShortcut(shortcut);
|
|
this.logShortcutEvent(shortcut.shortcutType, 'hotkey');
|
|
};
|
|
|
|
runShortcut = (shortcut: Shortcut) => {
|
|
const token = this.cursorToken;
|
|
const filterTokens = this.filterTokens;
|
|
|
|
const {shortcutType, canRunShortcut} = shortcut;
|
|
|
|
if (canRunShortcut(token, this.filterTokens.length)) {
|
|
switch (shortcutType) {
|
|
case ShortcutType.DELETE: {
|
|
this.deleteToken();
|
|
break;
|
|
}
|
|
case ShortcutType.NEGATE: {
|
|
this.negateToken();
|
|
break;
|
|
}
|
|
case ShortcutType.NEXT: {
|
|
this.moveToNextToken(filterTokens);
|
|
break;
|
|
}
|
|
case ShortcutType.PREVIOUS: {
|
|
this.moveToNextToken(filterTokens.reverse());
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
onSubmit = (evt: React.FormEvent) => {
|
|
evt.preventDefault();
|
|
this.doSearch();
|
|
};
|
|
|
|
clearSearch = () => {
|
|
this.setState(this.makeQueryState(''), () => {
|
|
this.close();
|
|
this.props.onSearch?.(this.state.query);
|
|
});
|
|
};
|
|
|
|
close = () => {
|
|
this.setState({showDropdown: false});
|
|
this.props.onClose?.(this.state.query, {validSearch: this.hasValidSearch});
|
|
document.removeEventListener('pointerup', this.onBackgroundPointerUp);
|
|
};
|
|
|
|
open = () => {
|
|
this.setState({showDropdown: true});
|
|
document.addEventListener('pointerup', this.onBackgroundPointerUp);
|
|
};
|
|
|
|
onQueryFocus = () => {
|
|
const txn = Sentry.startTransaction({
|
|
name: 'smart_search_bar.open',
|
|
op: 'ui.render',
|
|
});
|
|
|
|
if (typeof window.requestIdleCallback === 'function') {
|
|
txn.setTag('finish_strategy', 'idle_callback');
|
|
window.requestIdleCallback(() => {
|
|
txn.finish();
|
|
});
|
|
} else {
|
|
txn.setTag('finish_strategy', 'timeout');
|
|
setTimeout(() => {
|
|
txn.finish();
|
|
}, 1_000);
|
|
}
|
|
|
|
this.open();
|
|
this.setState({inputHasFocus: true});
|
|
};
|
|
|
|
onQueryBlur = (e: React.FocusEvent<HTMLTextAreaElement>) => {
|
|
this.setState({inputHasFocus: false});
|
|
this.props.onBlur?.(e.target.value);
|
|
};
|
|
|
|
onQueryChange = (evt: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
const query = evt.target.value.replace('\n', '');
|
|
|
|
this.setState(this.makeQueryState(query), this.updateAutoCompleteItems);
|
|
this.props.onChange?.(evt.target.value, evt);
|
|
};
|
|
|
|
/**
|
|
* Prevent pasting extra spaces from formatted text
|
|
*/
|
|
onPaste = (evt: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
|
// Cancel paste
|
|
evt.preventDefault();
|
|
|
|
// Get text representation of clipboard
|
|
const text = evt.clipboardData.getData('text/plain').replace('\n', '').trim();
|
|
|
|
// Create new query
|
|
const currentQuery = this.state.query;
|
|
const cursorPosStart = this.searchInput.current!.selectionStart;
|
|
const cursorPosEnd = this.searchInput.current!.selectionEnd;
|
|
const textBefore = currentQuery.substring(0, cursorPosStart);
|
|
const textAfter = currentQuery.substring(cursorPosEnd, currentQuery.length);
|
|
const mergedText = `${textBefore}${text}${textAfter}`;
|
|
|
|
// Insert text manually
|
|
this.setState(this.makeQueryState(mergedText), () => {
|
|
this.updateAutoCompleteItems();
|
|
// Update cursor position after updating text
|
|
const newCursorPosition = cursorPosStart + text.length;
|
|
this.searchInput.current!.selectionStart = newCursorPosition;
|
|
this.searchInput.current!.selectionEnd = newCursorPosition;
|
|
});
|
|
|
|
if (typeof this.props.onChange === 'function') {
|
|
this.props.onChange(mergedText, evt);
|
|
}
|
|
};
|
|
|
|
onInputClick = () => {
|
|
this.open();
|
|
this.updateAutoCompleteItems();
|
|
};
|
|
|
|
/**
|
|
* Handle keyboard navigation
|
|
*/
|
|
onKeyDown = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
const {onKeyDown} = this.props;
|
|
const {key} = evt;
|
|
|
|
onKeyDown?.(evt);
|
|
|
|
const hasSearchGroups = this.state.searchGroups.length > 0;
|
|
const isSelectingDropdownItems = this.state.activeSearchItem !== -1;
|
|
|
|
if (!this.state.showDropdown && key !== 'Escape') {
|
|
this.open();
|
|
}
|
|
|
|
if ((key === 'ArrowDown' || key === 'ArrowUp') && hasSearchGroups) {
|
|
evt.preventDefault();
|
|
|
|
const {flatSearchItems, activeSearchItem} = this.state;
|
|
let searchGroups = [...this.state.searchGroups];
|
|
|
|
const currIndex = isSelectingDropdownItems ? activeSearchItem : 0;
|
|
const totalItems = flatSearchItems.length;
|
|
|
|
// Move the selected index up/down
|
|
const nextActiveSearchItem =
|
|
key === 'ArrowUp'
|
|
? (currIndex - 1 + totalItems) % totalItems
|
|
: isSelectingDropdownItems
|
|
? (currIndex + 1) % totalItems
|
|
: 0;
|
|
|
|
// Clear previous selection
|
|
const prevItem = flatSearchItems[currIndex];
|
|
searchGroups = getSearchGroupWithItemMarkedActive(searchGroups, prevItem, false);
|
|
|
|
// Set new selection
|
|
const activeItem = flatSearchItems[nextActiveSearchItem];
|
|
searchGroups = getSearchGroupWithItemMarkedActive(searchGroups, activeItem, true);
|
|
|
|
this.setState({searchGroups, activeSearchItem: nextActiveSearchItem});
|
|
}
|
|
|
|
if (
|
|
(key === 'Tab' || key === 'Enter') &&
|
|
isSelectingDropdownItems &&
|
|
hasSearchGroups
|
|
) {
|
|
evt.preventDefault();
|
|
|
|
const {activeSearchItem, flatSearchItems} = this.state;
|
|
|
|
const item = flatSearchItems[activeSearchItem];
|
|
|
|
if (item) {
|
|
if (item.callback) {
|
|
item.callback();
|
|
} else {
|
|
this.onAutoComplete(item.value ?? '', item);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// If not selecting an item, allow tab to exit search and close the dropdown
|
|
if (key === 'Tab' && !isSelectingDropdownItems) {
|
|
this.close();
|
|
return;
|
|
}
|
|
|
|
if (key === 'Enter' && !isSelectingDropdownItems) {
|
|
this.doSearch();
|
|
return;
|
|
}
|
|
|
|
const cursorToken = this.cursorToken;
|
|
if (
|
|
key === '[' &&
|
|
cursorToken?.type === Token.FILTER &&
|
|
cursorToken.value.text.length === 0 &&
|
|
isWithinToken(cursorToken.value, this.cursorPosition)
|
|
) {
|
|
const {query} = this.state;
|
|
evt.preventDefault();
|
|
let clauseStart: null | number = null;
|
|
let clauseEnd: null | number = null;
|
|
// the new text that will exist between clauseStart and clauseEnd
|
|
const replaceToken = '[]';
|
|
const location = cursorToken.value.location;
|
|
const keyLocation = cursorToken.key.location;
|
|
// Include everything after the ':'
|
|
clauseStart = keyLocation.end.offset + 1;
|
|
clauseEnd = location.end.offset + 1;
|
|
const beforeClause = query.substring(0, clauseStart);
|
|
let endClause = query.substring(clauseEnd);
|
|
// Add space before next clause if it exists
|
|
if (endClause) {
|
|
endClause = ` ${endClause}`;
|
|
}
|
|
const newQuery = `${beforeClause}${replaceToken}${endClause}`;
|
|
// Place cursor between inserted brackets
|
|
this.updateQuery(newQuery, beforeClause.length + replaceToken.length - 1);
|
|
return;
|
|
}
|
|
};
|
|
|
|
onKeyUp = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (evt.key === 'ArrowLeft' || evt.key === 'ArrowRight') {
|
|
this.updateAutoCompleteItems();
|
|
}
|
|
|
|
// Other keys are managed at onKeyDown function
|
|
if (evt.key !== 'Escape') {
|
|
return;
|
|
}
|
|
|
|
evt.preventDefault();
|
|
|
|
if (!this.state.showDropdown) {
|
|
this.blur();
|
|
return;
|
|
}
|
|
|
|
const {flatSearchItems, activeSearchItem} = this.state;
|
|
const isSelectingDropdownItems = this.state.activeSearchItem > -1;
|
|
|
|
let searchGroups = [...this.state.searchGroups];
|
|
if (isSelectingDropdownItems) {
|
|
searchGroups = getSearchGroupWithItemMarkedActive(
|
|
searchGroups,
|
|
flatSearchItems[activeSearchItem],
|
|
false
|
|
);
|
|
}
|
|
|
|
this.setState({
|
|
activeSearchItem: -1,
|
|
searchGroups,
|
|
});
|
|
this.close();
|
|
};
|
|
|
|
/**
|
|
* Check if any filters are invalid within the search query
|
|
*/
|
|
get hasValidSearch() {
|
|
const {parsedQuery} = this.state;
|
|
|
|
// If we fail to parse be optimistic that it's valid
|
|
if (parsedQuery === null) {
|
|
return true;
|
|
}
|
|
|
|
return treeResultLocator<boolean>({
|
|
tree: parsedQuery,
|
|
noResultValue: true,
|
|
visitorTest: ({token, returnResult, skipToken}) => {
|
|
return token.type !== Token.FILTER &&
|
|
token.type !== Token.FREE_TEXT &&
|
|
token.type !== Token.LOGIC_BOOLEAN
|
|
? null
|
|
: token.invalid
|
|
? returnResult(false)
|
|
: skipToken;
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get the active filter or free text actively focused.
|
|
*/
|
|
get cursorToken() {
|
|
const matchedTokens = [Token.FILTER, Token.FREE_TEXT] as const;
|
|
return this.findTokensAtCursor(matchedTokens);
|
|
}
|
|
|
|
/**
|
|
* Get the active parsed text value
|
|
*/
|
|
get cursorValue() {
|
|
const matchedTokens = [Token.VALUE_TEXT] as const;
|
|
return this.findTokensAtCursor(matchedTokens);
|
|
}
|
|
|
|
/**
|
|
* Get the active filter
|
|
*/
|
|
get cursorFilter() {
|
|
const matchedTokens = [Token.FILTER] as const;
|
|
return this.findTokensAtCursor(matchedTokens);
|
|
}
|
|
|
|
get cursorValueIsoDate(): TokenResult<Token.VALUE_ISO_8601_DATE> | null {
|
|
const matchedTokens = [Token.VALUE_ISO_8601_DATE] as const;
|
|
return this.findTokensAtCursor(matchedTokens);
|
|
}
|
|
|
|
get cursorValueRelativeDate() {
|
|
const matchedTokens = [Token.VALUE_RELATIVE_DATE] as const;
|
|
return this.findTokensAtCursor(matchedTokens);
|
|
}
|
|
|
|
get currentFieldDefinition() {
|
|
if (!this.cursorToken || this.cursorToken.type !== Token.FILTER) {
|
|
return null;
|
|
}
|
|
|
|
const tagName = getKeyName(this.cursorToken.key, {aggregateWithArgs: true});
|
|
|
|
return this.props.fieldDefinitionGetter(tagName);
|
|
}
|
|
|
|
/**
|
|
* Determines when the date picker should be shown instead of normal dropdown options.
|
|
* This should return true when the cursor is within a date tag value and the user has
|
|
* typed in an operator (or already has a date value).
|
|
*/
|
|
get shouldShowDatePicker() {
|
|
if (
|
|
!this.state.showDropdown ||
|
|
!this.cursorToken ||
|
|
this.currentFieldDefinition?.valueType !== FieldValueType.DATE ||
|
|
this.cursorValueRelativeDate ||
|
|
!(
|
|
this.cursorToken.type === Token.FILTER &&
|
|
isWithinToken(this.cursorToken.value, this.cursorPosition)
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const textValue = this.cursorFilter?.value?.text ?? '';
|
|
|
|
if (
|
|
// Cursor is in a valid ISO date value
|
|
this.cursorValueIsoDate ||
|
|
// Cursor is in a value that has an operator
|
|
this.cursorFilter?.operator ||
|
|
// Cursor is in raw text value that matches one of the non-empty operators
|
|
(textValue && isOperator(textValue))
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
get shouldShowAutocomplete() {
|
|
return this.state.showDropdown && !this.shouldShowDatePicker;
|
|
}
|
|
|
|
/**
|
|
* Get the current cursor position within the input
|
|
*/
|
|
get cursorPosition() {
|
|
if (!this.searchInput.current) {
|
|
return -1;
|
|
}
|
|
|
|
return this.searchInput.current.selectionStart ?? -1;
|
|
}
|
|
|
|
/**
|
|
* Get the search term at the current cursor position
|
|
*/
|
|
get cursorSearchTerm() {
|
|
const cursorPosition = this.cursorPosition;
|
|
const cursorToken = this.cursorToken;
|
|
|
|
if (!cursorToken) {
|
|
return null;
|
|
}
|
|
|
|
const LIMITER_CHARS = [' ', ':'];
|
|
|
|
const innerStart = cursorPosition - cursorToken.location.start.offset;
|
|
|
|
let tokenStart = innerStart;
|
|
while (tokenStart > 0 && !LIMITER_CHARS.includes(cursorToken.text[tokenStart - 1])) {
|
|
tokenStart--;
|
|
}
|
|
let tokenEnd = innerStart;
|
|
while (
|
|
tokenEnd < cursorToken.text.length &&
|
|
!LIMITER_CHARS.includes(cursorToken.text[tokenEnd])
|
|
) {
|
|
tokenEnd++;
|
|
}
|
|
|
|
let searchTerm = cursorToken.text.slice(tokenStart, tokenEnd);
|
|
|
|
if (searchTerm.startsWith(NEGATION_OPERATOR)) {
|
|
tokenStart++;
|
|
}
|
|
searchTerm = searchTerm.replace(new RegExp(`^${NEGATION_OPERATOR}`), '');
|
|
|
|
return {
|
|
end: cursorToken.location.start.offset + tokenEnd,
|
|
searchTerm,
|
|
start: cursorToken.location.start.offset + tokenStart,
|
|
};
|
|
}
|
|
|
|
get filterTokens(): TokenResult<Token.FILTER>[] {
|
|
return (this.state.parsedQuery?.filter(tok => tok.type === Token.FILTER) ??
|
|
[]) as TokenResult<Token.FILTER>[];
|
|
}
|
|
|
|
/**
|
|
* Finds tokens that exist at the current cursor position
|
|
* @param matchedTokens acceptable list of tokens
|
|
*/
|
|
findTokensAtCursor<T extends readonly Token[]>(matchedTokens: T) {
|
|
const {parsedQuery} = this.state;
|
|
|
|
if (parsedQuery === null) {
|
|
return null;
|
|
}
|
|
|
|
const cursor = this.cursorPosition;
|
|
|
|
return treeResultLocator<TokenResult<T[number]> | null>({
|
|
tree: parsedQuery,
|
|
noResultValue: null,
|
|
visitorTest: ({token, returnResult, skipToken}) =>
|
|
!matchedTokens.includes(token.type)
|
|
? null
|
|
: isWithinToken(token, cursor)
|
|
? returnResult(token)
|
|
: skipToken,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns array of possible key values that substring match `query`
|
|
*/
|
|
getTagKeys(searchTerm: string): [SearchItem[], ItemType] {
|
|
const {
|
|
excludedTags,
|
|
fieldDefinitionGetter,
|
|
organization,
|
|
prepareQuery,
|
|
supportedTags = {},
|
|
supportedTagType,
|
|
} = this.props;
|
|
|
|
let tagKeys = Object.keys(supportedTags).sort((a, b) => a.localeCompare(b));
|
|
|
|
if (searchTerm) {
|
|
const preparedSearchTerm = prepareQuery ? prepareQuery(searchTerm) : searchTerm;
|
|
|
|
tagKeys = filterKeysFromQuery(tagKeys, preparedSearchTerm, fieldDefinitionGetter);
|
|
}
|
|
|
|
// removes any tags that are marked for exclusion
|
|
if (excludedTags) {
|
|
tagKeys = tagKeys.filter(key => !excludedTags?.includes(key));
|
|
}
|
|
|
|
const allTagItems = getTagItemsFromKeys(
|
|
tagKeys,
|
|
supportedTags,
|
|
fieldDefinitionGetter
|
|
);
|
|
|
|
// Filter out search items that are behind feature flags
|
|
const tagItems = allTagItems.filter(
|
|
item =>
|
|
item.featureFlag === undefined || organization.features.includes(item.featureFlag)
|
|
);
|
|
|
|
return [tagItems, supportedTagType ?? ItemType.TAG_KEY];
|
|
}
|
|
|
|
/**
|
|
* Returns array of tag values that substring match `query`; invokes `callback`
|
|
* with data when ready
|
|
*/
|
|
getTagValues = async (tag: Tag, query: string): Promise<SearchItem[]> => {
|
|
// Strip double quotes if there are any
|
|
query = query.replace(/"/g, '').trim();
|
|
|
|
if (!this.props.onGetTagValues) {
|
|
return [];
|
|
}
|
|
|
|
if (
|
|
this.state.noValueQuery !== undefined &&
|
|
query.startsWith(this.state.noValueQuery)
|
|
) {
|
|
return [];
|
|
}
|
|
|
|
const {location} = this.props;
|
|
const endpointParams = normalizeDateTimeParams(location.query);
|
|
|
|
this.setState({loading: true});
|
|
let values: string[] = [];
|
|
|
|
try {
|
|
values = await this.props.onGetTagValues(tag, query, endpointParams);
|
|
this.setState({loading: false});
|
|
} catch (err) {
|
|
this.setState({loading: false});
|
|
Sentry.captureException(err);
|
|
return [];
|
|
}
|
|
|
|
if (tag.key === 'release:' && !values.includes('latest')) {
|
|
values.unshift('latest');
|
|
}
|
|
|
|
const noValueQuery = values.length === 0 && query.length > 0 ? query : undefined;
|
|
this.setState({noValueQuery});
|
|
|
|
return values.map(value => {
|
|
const escapedValue = escapeTagValue(value);
|
|
return {
|
|
value: escapedValue,
|
|
desc: escapedValue,
|
|
type: ItemType.TAG_VALUE,
|
|
};
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Returns array of tag values that substring match `query`; invokes `callback`
|
|
* with results
|
|
*/
|
|
getPredefinedTagValues = (
|
|
tag: Tag,
|
|
query: string
|
|
): AutocompleteGroup['searchItems'] => {
|
|
const groupOrValue = tag.values ?? [];
|
|
|
|
// Is an array of SearchGroup
|
|
if (groupOrValue.some(item => typeof item === 'object')) {
|
|
return (groupOrValue as SearchGroup[]).map(group => {
|
|
return {
|
|
...group,
|
|
children: group.children?.filter(child => child.value?.includes(query)),
|
|
};
|
|
});
|
|
}
|
|
|
|
// Is an array of strings
|
|
return (groupOrValue as string[])
|
|
.filter(value => value.includes(query))
|
|
.map((value, i) => {
|
|
const escapedValue = escapeTagValue(value);
|
|
return {
|
|
value: escapedValue,
|
|
desc: escapedValue,
|
|
type: ItemType.TAG_VALUE,
|
|
ignoreMaxSearchItems: tag.maxSuggestedValues
|
|
? i < tag.maxSuggestedValues
|
|
: false,
|
|
};
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Get recent searches
|
|
*/
|
|
getRecentSearches = async () => {
|
|
const {savedSearchType, hasRecentSearches, onGetRecentSearches} = this.props;
|
|
|
|
// `savedSearchType` can be 0
|
|
if (!defined(savedSearchType) || !hasRecentSearches) {
|
|
return [];
|
|
}
|
|
|
|
const fetchFn = onGetRecentSearches || this.fetchRecentSearches;
|
|
return await fetchFn(this.state.query);
|
|
};
|
|
|
|
fetchRecentSearches = async (fullQuery: string): Promise<SearchItem[]> => {
|
|
const {api, organization, savedSearchType} = this.props;
|
|
if (savedSearchType === undefined) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
const recentSearches: any[] = await fetchRecentSearches(
|
|
api,
|
|
organization.slug,
|
|
savedSearchType,
|
|
fullQuery
|
|
);
|
|
|
|
// If `recentSearches` is undefined or not an array, the function will
|
|
// return an array anyway
|
|
return recentSearches.map(searches => ({
|
|
desc: searches.query,
|
|
value: searches.query,
|
|
type: ItemType.RECENT_SEARCH,
|
|
}));
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
getReleases = async (tag: Tag, query: string) => {
|
|
const releasePromise = this.fetchReleases(query);
|
|
|
|
const tags = this.getPredefinedTagValues(tag, query);
|
|
const tagValues = tags.map<SearchItem>(v => ({
|
|
...v,
|
|
type: ItemType.FIRST_RELEASE,
|
|
}));
|
|
|
|
const releases = await releasePromise;
|
|
const releaseValues = releases.map<SearchItem>((r: any) => ({
|
|
value: r.shortVersion,
|
|
desc: r.shortVersion,
|
|
type: ItemType.FIRST_RELEASE,
|
|
}));
|
|
|
|
return [...tagValues, ...releaseValues];
|
|
};
|
|
|
|
/**
|
|
* Fetches latest releases from a organization/project. Returns an empty array
|
|
* if an error is encountered.
|
|
*/
|
|
fetchReleases = async (releaseVersion: string): Promise<any[]> => {
|
|
const {api, location, organization} = this.props;
|
|
|
|
const project = location?.query ? location.query.projectId : undefined;
|
|
|
|
const url = `/organizations/${organization.slug}/releases/`;
|
|
const fetchQuery: {[key: string]: string | number} = {
|
|
per_page: MAX_AUTOCOMPLETE_RELEASES,
|
|
};
|
|
|
|
if (releaseVersion) {
|
|
fetchQuery.query = releaseVersion;
|
|
}
|
|
|
|
if (project) {
|
|
fetchQuery.project = project;
|
|
}
|
|
|
|
try {
|
|
return await api.requestPromise(url, {
|
|
method: 'GET',
|
|
query: fetchQuery,
|
|
});
|
|
} catch (e) {
|
|
addErrorMessage(t('Unable to fetch releases'));
|
|
Sentry.captureException(e);
|
|
}
|
|
|
|
return [];
|
|
};
|
|
|
|
async generateTagAutocompleteGroup(tagName: string): Promise<AutocompleteGroup> {
|
|
const [tagKeys, tagType] = this.getTagKeys(tagName);
|
|
const recentSearches = await this.getRecentSearches();
|
|
|
|
return {
|
|
searchItems: tagKeys,
|
|
recentSearchItems: recentSearches ?? [],
|
|
tagName,
|
|
type: tagType,
|
|
};
|
|
}
|
|
|
|
generateValueAutocompleteGroup = async (
|
|
tagName: string,
|
|
query: string
|
|
): Promise<AutocompleteGroup | null> => {
|
|
const {prepareQuery, excludedTags, organization, savedSearchType, searchSource} =
|
|
this.props;
|
|
const supportedTags = this.props.supportedTags ?? {};
|
|
|
|
const preparedQuery =
|
|
typeof prepareQuery === 'function' ? prepareQuery(query) : query;
|
|
|
|
// filter existing items immediately, until API can return
|
|
// with actual tag value results
|
|
const filteredSearchGroups = !preparedQuery
|
|
? this.state.searchGroups
|
|
: this.state.searchGroups.filter(item => item.value?.includes(preparedQuery));
|
|
|
|
this.setState({
|
|
searchTerm: query,
|
|
searchGroups: filteredSearchGroups,
|
|
});
|
|
|
|
const tag = supportedTags[tagName];
|
|
|
|
if (!tag) {
|
|
trackAnalytics('search.invalid_field', {
|
|
organization,
|
|
search_type: savedSearchType === 0 ? 'issues' : 'events',
|
|
search_source: searchSource,
|
|
attempted_field_name: tagName,
|
|
});
|
|
|
|
return {
|
|
searchItems: [
|
|
{
|
|
type: ItemType.INVALID_TAG,
|
|
desc: tagName,
|
|
callback: () =>
|
|
window.open(
|
|
'https://docs.sentry.io/product/sentry-basics/search/searchable-properties/'
|
|
),
|
|
},
|
|
],
|
|
recentSearchItems: [],
|
|
tagName,
|
|
type: ItemType.INVALID_TAG,
|
|
};
|
|
}
|
|
|
|
if (excludedTags?.includes(tagName)) {
|
|
return null;
|
|
}
|
|
|
|
const fetchTagValuesFn =
|
|
tag.key === 'firstRelease'
|
|
? this.getReleases
|
|
: tag.predefined
|
|
? this.getPredefinedTagValues
|
|
: this.getTagValues;
|
|
|
|
const [tagValues, recentSearches] = await Promise.all([
|
|
fetchTagValuesFn(tag, preparedQuery),
|
|
this.getRecentSearches(),
|
|
]);
|
|
|
|
return {
|
|
searchItems: tagValues ?? [],
|
|
recentSearchItems: recentSearches ?? [],
|
|
tagName: tag.key,
|
|
type: ItemType.TAG_VALUE,
|
|
};
|
|
};
|
|
|
|
showDefaultSearches = async () => {
|
|
const {query} = this.state;
|
|
const [defaultSearchItems, defaultRecentItems] = this.props.defaultSearchItems!;
|
|
|
|
// Always clear searchTerm on showing default state.
|
|
this.setState({searchTerm: ''});
|
|
|
|
if (!defaultSearchItems.length) {
|
|
// Update searchTerm, otherwise <SearchDropdown> will have wrong state
|
|
// (e.g. if you delete a query, the last letter will be highlighted if `searchTerm`
|
|
// does not get updated)
|
|
|
|
const [tagKeys, tagType] = this.getTagKeys('');
|
|
const recentSearches = await this.getRecentSearches();
|
|
|
|
if (this.state.query === query) {
|
|
this.updateAutoCompleteState(tagKeys, recentSearches ?? [], '', tagType);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
this.updateAutoCompleteState(
|
|
defaultSearchItems,
|
|
defaultRecentItems,
|
|
'',
|
|
ItemType.DEFAULT
|
|
);
|
|
return;
|
|
};
|
|
|
|
updateAutoCompleteFromAst = async () => {
|
|
const cursor = this.cursorPosition;
|
|
const cursorToken = this.cursorToken;
|
|
|
|
if (!cursorToken) {
|
|
this.showDefaultSearches();
|
|
return;
|
|
}
|
|
|
|
if (cursorToken.type === Token.FILTER) {
|
|
const tagName = getKeyName(cursorToken.key, {aggregateWithArgs: true});
|
|
// check if we are on the tag, value, or operator
|
|
if (isWithinToken(cursorToken.value, cursor)) {
|
|
const node = cursorToken.value;
|
|
const cursorValue = this.cursorValue;
|
|
let searchText = cursorValue?.text ?? node.text;
|
|
if (searchText === '[]' || cursorValue === null) {
|
|
searchText = '';
|
|
}
|
|
|
|
if (cursorToken.invalid?.type === InvalidReason.WILDCARD_NOT_ALLOWED) {
|
|
const groups = getAutoCompleteGroupForInvalidWildcard(searchText);
|
|
this.updateAutoCompleteStateMultiHeader(groups);
|
|
|
|
return;
|
|
}
|
|
|
|
const fieldDefinition = this.props.fieldDefinitionGetter(tagName);
|
|
const isDate = fieldDefinition?.valueType === FieldValueType.DATE;
|
|
|
|
if (isDate) {
|
|
const groups = getDateTagAutocompleteGroups(tagName);
|
|
|
|
this.updateAutoCompleteStateMultiHeader(groups);
|
|
|
|
return;
|
|
}
|
|
|
|
const valueGroup = await this.generateValueAutocompleteGroup(tagName, searchText);
|
|
const autocompleteGroups = valueGroup ? [valueGroup] : [];
|
|
|
|
// show operator group if at beginning of value
|
|
if (cursor === node.location.start.offset) {
|
|
const opGroup = generateOpAutocompleteGroup(getValidOps(cursorToken), tagName);
|
|
if (valueGroup?.type !== ItemType.INVALID_TAG && !isDate) {
|
|
autocompleteGroups.unshift(opGroup);
|
|
}
|
|
}
|
|
|
|
if (cursor === this.cursorPosition) {
|
|
this.updateAutoCompleteStateMultiHeader(autocompleteGroups);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (isWithinToken(cursorToken.key, cursor)) {
|
|
const node = cursorToken.key;
|
|
const autocompleteGroups = [await this.generateTagAutocompleteGroup(tagName)];
|
|
// show operator group if at end of key
|
|
if (cursor === node.location.end.offset) {
|
|
const opGroup = generateOpAutocompleteGroup(getValidOps(cursorToken), tagName);
|
|
autocompleteGroups.unshift(opGroup);
|
|
}
|
|
|
|
if (cursor === this.cursorPosition) {
|
|
this.setState({
|
|
searchTerm: tagName,
|
|
});
|
|
|
|
this.updateAutoCompleteStateMultiHeader(autocompleteGroups);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// show operator autocomplete group
|
|
const opGroup = generateOpAutocompleteGroup(getValidOps(cursorToken), tagName);
|
|
this.updateAutoCompleteStateMultiHeader([opGroup]);
|
|
return;
|
|
}
|
|
|
|
const cursorSearchTerm = this.cursorSearchTerm;
|
|
|
|
if (cursorToken.type === Token.FREE_TEXT && cursorSearchTerm) {
|
|
const groups: AutocompleteGroup[] | null =
|
|
cursorToken.invalid?.type === InvalidReason.WILDCARD_NOT_ALLOWED
|
|
? getAutoCompleteGroupForInvalidWildcard(cursorSearchTerm.searchTerm)
|
|
: [await this.generateTagAutocompleteGroup(cursorSearchTerm.searchTerm)];
|
|
|
|
if (cursor === this.cursorPosition) {
|
|
this.setState({
|
|
searchTerm: cursorSearchTerm.searchTerm,
|
|
});
|
|
|
|
this.updateAutoCompleteStateMultiHeader(groups);
|
|
}
|
|
return;
|
|
}
|
|
};
|
|
|
|
updateAutoCompleteItems = debounce(
|
|
() => {
|
|
this.updateAutoCompleteFromAst();
|
|
},
|
|
DEFAULT_DEBOUNCE_DURATION,
|
|
{leading: true}
|
|
);
|
|
|
|
/**
|
|
* Updates autocomplete dropdown items and autocomplete index state
|
|
*
|
|
* @param searchItems List of search item objects with keys: title, desc, value
|
|
* @param recentSearchItems List of recent search items, same format as searchItem
|
|
* @param tagName The current tag name in scope
|
|
* @param type Defines the type/state of the dropdown menu items
|
|
* @param skipDefaultGroup Force hide the default group even without a query
|
|
*/
|
|
updateAutoCompleteState(
|
|
searchItems: SearchItem[],
|
|
recentSearchItems: SearchItem[],
|
|
tagName: string,
|
|
type: ItemType,
|
|
skipDefaultGroup = false
|
|
) {
|
|
const {
|
|
fieldDefinitionGetter,
|
|
hasRecentSearches,
|
|
maxSearchItems,
|
|
maxQueryLength,
|
|
defaultSearchGroup,
|
|
} = this.props;
|
|
const {query} = this.state;
|
|
|
|
const queryCharsLeft =
|
|
maxQueryLength && query ? maxQueryLength - query.length : undefined;
|
|
|
|
const searchGroups = createSearchGroups(
|
|
searchItems,
|
|
hasRecentSearches ? recentSearchItems : undefined,
|
|
tagName,
|
|
type,
|
|
maxSearchItems,
|
|
queryCharsLeft,
|
|
true,
|
|
skipDefaultGroup ? undefined : defaultSearchGroup,
|
|
fieldDefinitionGetter
|
|
);
|
|
|
|
this.setState(searchGroups);
|
|
}
|
|
|
|
/**
|
|
* Updates autocomplete dropdown items and autocomplete index state
|
|
*
|
|
* @param groups Groups that will be used to populate the autocomplete dropdown
|
|
*/
|
|
updateAutoCompleteStateMultiHeader = (groups: AutocompleteGroup[]) => {
|
|
const {
|
|
fieldDefinitionGetter,
|
|
hasRecentSearches,
|
|
maxSearchItems,
|
|
maxQueryLength,
|
|
defaultSearchGroup,
|
|
} = this.props;
|
|
const {query} = this.state;
|
|
const queryCharsLeft =
|
|
maxQueryLength && query ? maxQueryLength - query.length : undefined;
|
|
|
|
const searchGroups = groups
|
|
.map(({searchItems, recentSearchItems, tagName, type}) =>
|
|
createSearchGroups(
|
|
searchItems,
|
|
hasRecentSearches ? recentSearchItems : undefined,
|
|
tagName,
|
|
type,
|
|
maxSearchItems,
|
|
queryCharsLeft,
|
|
false,
|
|
defaultSearchGroup,
|
|
fieldDefinitionGetter
|
|
)
|
|
)
|
|
.reduce<ReturnType<typeof createSearchGroups>>(
|
|
(acc, item) => ({
|
|
searchGroups: [...acc.searchGroups, ...item.searchGroups],
|
|
flatSearchItems: [...acc.flatSearchItems, ...item.flatSearchItems],
|
|
activeSearchItem: -1,
|
|
}),
|
|
{
|
|
searchGroups: [],
|
|
flatSearchItems: [],
|
|
activeSearchItem: -1,
|
|
}
|
|
);
|
|
|
|
this.setState(searchGroups);
|
|
};
|
|
|
|
updateQuery = (newQuery: string, cursorPosition?: number) =>
|
|
this.setState(this.makeQueryState(newQuery), () => {
|
|
// setting a new input value will lose focus; restore it
|
|
if (this.searchInput.current) {
|
|
maybeFocusInput(this.searchInput.current);
|
|
if (cursorPosition) {
|
|
this.searchInput.current.selectionStart = cursorPosition;
|
|
this.searchInput.current.selectionEnd = cursorPosition;
|
|
}
|
|
}
|
|
// then update the autocomplete box with new items
|
|
this.updateAutoCompleteItems();
|
|
this.props.onChange?.(newQuery, new MouseEvent('click') as any);
|
|
});
|
|
|
|
onAutoCompleteFromAst = (replaceText: string, item: SearchItem) => {
|
|
const cursor = this.cursorPosition;
|
|
const {query} = this.state;
|
|
|
|
const cursorToken = this.cursorToken;
|
|
|
|
if (!cursorToken) {
|
|
this.updateQuery(`${query}${replaceText}`);
|
|
return;
|
|
}
|
|
|
|
// the start and end of what to replace
|
|
let clauseStart: null | number = null;
|
|
let clauseEnd: null | number = null;
|
|
// the new text that will exist between clauseStart and clauseEnd
|
|
let replaceToken = replaceText;
|
|
if (cursorToken.type === Token.FILTER) {
|
|
if (item.type === ItemType.TAG_OPERATOR) {
|
|
trackAnalytics('search.operator_autocompleted', {
|
|
organization: this.props.organization,
|
|
query: removeSpace(query),
|
|
search_operator: replaceText,
|
|
search_type: this.props.savedSearchType === 0 ? 'issues' : 'events',
|
|
});
|
|
const valueLocation = cursorToken.value.location;
|
|
clauseStart = cursorToken.location.start.offset;
|
|
clauseEnd = valueLocation.start.offset;
|
|
if (replaceText === '!:') {
|
|
replaceToken = `!${cursorToken.key.text}:`;
|
|
} else {
|
|
replaceToken = `${cursorToken.key.text}${replaceText}`;
|
|
}
|
|
} else if (isWithinToken(cursorToken.value, cursor)) {
|
|
const valueToken = this.cursorValue ?? cursorToken.value;
|
|
const location = valueToken.location;
|
|
|
|
if (cursorToken.filter === FilterType.TEXT_IN) {
|
|
// Current value can be null when adding a 2nd value
|
|
// ▼ cursor
|
|
// key:[value1, ]
|
|
const currentValueNull = this.cursorValue === null;
|
|
clauseStart = currentValueNull
|
|
? this.cursorPosition
|
|
: valueToken.location.start.offset;
|
|
clauseEnd = currentValueNull
|
|
? this.cursorPosition
|
|
: valueToken.location.end.offset;
|
|
} else {
|
|
const keyLocation = cursorToken.key.location;
|
|
clauseStart = keyLocation.end.offset + 1;
|
|
clauseEnd = location.end.offset + 1;
|
|
// The user tag often contains : within its value and we need to quote it.
|
|
if (getKeyName(cursorToken.key) === 'user') {
|
|
replaceToken = `"${replaceText.trim()}"`;
|
|
}
|
|
// handle using autocomplete with key:[]
|
|
if (valueToken.text === '[]') {
|
|
clauseStart += 1;
|
|
clauseEnd -= 2;
|
|
// For ISO date values, we want to keep the cursor within the token
|
|
} else if (item.type !== ItemType.TAG_VALUE_ISO_DATE) {
|
|
replaceToken += ' ';
|
|
}
|
|
}
|
|
} else if (isWithinToken(cursorToken.key, cursor)) {
|
|
const location = cursorToken.key.location;
|
|
clauseStart = location.start.offset;
|
|
// If the token is a key, then trim off the end to avoid duplicate ':'
|
|
clauseEnd = location.end.offset + 1;
|
|
}
|
|
}
|
|
|
|
const cursorSearchTerm = this.cursorSearchTerm;
|
|
if (cursorToken.type === Token.FREE_TEXT && cursorSearchTerm) {
|
|
clauseStart = cursorSearchTerm.start;
|
|
clauseEnd = cursorSearchTerm.end;
|
|
}
|
|
|
|
if (clauseStart !== null && clauseEnd !== null) {
|
|
const beforeClause = query.substring(0, clauseStart);
|
|
const endClause = query.substring(clauseEnd);
|
|
// Adds a space between the replaceToken and endClause when necessary
|
|
const replaceTokenEndClauseJoiner =
|
|
!endClause ||
|
|
endClause.startsWith(' ') ||
|
|
replaceToken.endsWith(' ') ||
|
|
replaceToken.endsWith(':')
|
|
? ''
|
|
: ' ';
|
|
const newQuery = `${beforeClause}${replaceToken}${replaceTokenEndClauseJoiner}${endClause}`;
|
|
this.updateQuery(newQuery, beforeClause.length + replaceToken.length);
|
|
}
|
|
};
|
|
|
|
onAutoComplete = (replaceText: string, item: SearchItem) => {
|
|
if (item.type === ItemType.RECENT_SEARCH) {
|
|
trackAnalytics('search.searched', {
|
|
organization: this.props.organization,
|
|
query: replaceText,
|
|
search_type: this.props.savedSearchType === 0 ? 'issues' : 'events',
|
|
search_source: 'recent_search',
|
|
});
|
|
|
|
this.setState(this.makeQueryState(replaceText), () => {
|
|
// Propagate onSearch and save to recent searches
|
|
this.doSearch();
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
if (
|
|
item.kind === FieldKind.FIELD ||
|
|
item.kind === FieldKind.TAG ||
|
|
item.type === ItemType.RECOMMENDED
|
|
) {
|
|
trackAnalytics('search.key_autocompleted', {
|
|
organization: this.props.organization,
|
|
search_operator: replaceText,
|
|
search_source: this.props.searchSource,
|
|
item_name: item.title ?? item.value?.split(':')[0],
|
|
item_kind: item.kind,
|
|
item_type: item.type,
|
|
search_type: this.props.savedSearchType === 0 ? 'issues' : 'events',
|
|
});
|
|
}
|
|
|
|
if (item.applyFilter) {
|
|
const [tagKeys, tagType] = this.getTagKeys('');
|
|
this.updateAutoCompleteState(
|
|
tagKeys.filter(item.applyFilter),
|
|
[],
|
|
'',
|
|
tagType,
|
|
true
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.onAutoCompleteFromAst(replaceText, item);
|
|
};
|
|
|
|
onAutoCompleteIsoDate = (isoDate: string) => {
|
|
const dateItem = {type: ItemType.TAG_VALUE_ISO_DATE};
|
|
|
|
if (
|
|
this.cursorFilter?.filter === FilterType.DATE ||
|
|
this.cursorFilter?.filter === FilterType.SPECIFIC_DATE
|
|
) {
|
|
this.onAutoCompleteFromAst(`${this.cursorFilter.operator}${isoDate}`, dateItem);
|
|
} else if (this.cursorFilter?.filter === FilterType.TEXT) {
|
|
const valueText = this.cursorFilter.value.text;
|
|
|
|
if (valueText && isOperator(valueText)) {
|
|
this.onAutoCompleteFromAst(`${valueText}${isoDate}`, dateItem);
|
|
}
|
|
}
|
|
};
|
|
|
|
get showSearchDropdown(): boolean {
|
|
return this.state.loading || this.state.searchGroups.length > 0;
|
|
}
|
|
|
|
render() {
|
|
const {
|
|
api,
|
|
className,
|
|
id,
|
|
savedSearchType,
|
|
dropdownClassName,
|
|
actionBarItems,
|
|
organization,
|
|
placeholder,
|
|
disabled,
|
|
useFormWrapper,
|
|
includeLabel,
|
|
inlineLabel,
|
|
maxQueryLength,
|
|
maxMenuHeight,
|
|
name,
|
|
supportedTags,
|
|
} = this.props;
|
|
|
|
const {
|
|
query,
|
|
parsedQuery,
|
|
searchGroups,
|
|
searchTerm,
|
|
inputHasFocus,
|
|
numActionsVisible,
|
|
loading,
|
|
} = this.state;
|
|
|
|
const input = (
|
|
<SearchInput
|
|
placeholder={placeholder}
|
|
id={id}
|
|
data-test-id="smart-search-input"
|
|
name={name}
|
|
ref={this.searchInput}
|
|
autoComplete="off"
|
|
value={query}
|
|
onFocus={this.onQueryFocus}
|
|
onBlur={this.onQueryBlur}
|
|
onKeyUp={this.onKeyUp}
|
|
onKeyDown={this.onKeyDown}
|
|
onChange={this.onQueryChange}
|
|
onClick={this.onInputClick}
|
|
onPaste={this.onPaste}
|
|
disabled={disabled}
|
|
maxLength={maxQueryLength}
|
|
spellCheck={false}
|
|
/>
|
|
);
|
|
|
|
// Segment actions into visible and overflowed groups
|
|
const actionItems = actionBarItems ?? [];
|
|
const actionProps = {
|
|
api,
|
|
organization,
|
|
query,
|
|
savedSearchType,
|
|
};
|
|
|
|
const visibleActions = actionItems
|
|
.slice(0, numActionsVisible)
|
|
.map(({key, makeAction}) => {
|
|
const ActionBarButton = makeAction(actionProps).Button;
|
|
return <ActionBarButton key={key} />;
|
|
});
|
|
|
|
const hasOverflownActions = actionItems.length > numActionsVisible;
|
|
const cursor = this.cursorPosition;
|
|
|
|
const visibleShortcuts = shortcuts.filter(
|
|
shortcut =>
|
|
shortcut.hotkeys &&
|
|
shortcut.canRunShortcut(this.cursorToken, this.filterTokens.length)
|
|
);
|
|
|
|
return (
|
|
<Container
|
|
ref={this.containerRef}
|
|
className={className}
|
|
inputHasFocus={inputHasFocus}
|
|
data-test-id="smart-search-bar"
|
|
>
|
|
<SearchHotkeysListener
|
|
visibleShortcuts={visibleShortcuts}
|
|
runShortcut={this.runShortcutOnHotkeyPress}
|
|
/>
|
|
{includeLabel ? (
|
|
<SearchLabel htmlFor={id} aria-label={t('Search events')}>
|
|
<IconSearch />
|
|
{inlineLabel}
|
|
</SearchLabel>
|
|
) : (
|
|
<SearchIconContainer>
|
|
<IconSearch />
|
|
</SearchIconContainer>
|
|
)}
|
|
|
|
<InputWrapper>
|
|
<Highlight>
|
|
{parsedQuery !== null ? (
|
|
<HighlightQuery
|
|
parsedQuery={parsedQuery}
|
|
cursorPosition={this.state.showDropdown ? cursor : -1}
|
|
/>
|
|
) : (
|
|
query
|
|
)}
|
|
</Highlight>
|
|
{useFormWrapper ? <form onSubmit={this.onSubmit}>{input}</form> : input}
|
|
</InputWrapper>
|
|
|
|
<ActionsBar gap={0.5}>
|
|
{query !== '' && !disabled && (
|
|
<ActionButton
|
|
onClick={this.clearSearch}
|
|
icon={<IconClose size="xs" />}
|
|
title={t('Clear search')}
|
|
aria-label={t('Clear search')}
|
|
/>
|
|
)}
|
|
{visibleActions}
|
|
{hasOverflownActions ? (
|
|
<OverlowingActionsMenu
|
|
position="bottom-end"
|
|
trigger={props => (
|
|
<ActionButton
|
|
{...props}
|
|
size="sm"
|
|
aria-label={t('Show more')}
|
|
icon={<VerticalEllipsisIcon />}
|
|
/>
|
|
)}
|
|
triggerLabel={t('Show more')}
|
|
items={actionItems
|
|
.slice(numActionsVisible)
|
|
.map(({makeAction}) => makeAction(actionProps).menuItem)}
|
|
size="sm"
|
|
/>
|
|
) : null}
|
|
</ActionsBar>
|
|
|
|
{this.shouldShowDatePicker && (
|
|
<SearchBarDatePicker
|
|
date={this.cursorValueIsoDate?.value}
|
|
dateString={this.cursorValueIsoDate?.text}
|
|
handleSelectDateTime={this.onAutoCompleteIsoDate}
|
|
/>
|
|
)}
|
|
|
|
{this.shouldShowAutocomplete && (
|
|
<SearchDropdown
|
|
className={dropdownClassName}
|
|
items={searchGroups}
|
|
onClick={this.onAutoComplete}
|
|
loading={loading}
|
|
searchSubstring={searchTerm}
|
|
runShortcut={this.runShortcutOnClick}
|
|
visibleShortcuts={visibleShortcuts}
|
|
maxMenuHeight={maxMenuHeight}
|
|
supportedTags={supportedTags}
|
|
customInvalidTagMessage={this.props.customInvalidTagMessage}
|
|
mergeItemsWith={this.props.mergeSearchGroupWith}
|
|
invalidMessages={this.props.invalidMessages}
|
|
disallowWildcard={this.props.disallowWildcard}
|
|
disallowedLogicalOperators={this.props.disallowedLogicalOperators}
|
|
disallowFreeText={this.props.disallowFreeText}
|
|
booleanKeys={this.props.booleanKeys}
|
|
dateKeys={this.props.dateKeys}
|
|
durationKeys={this.props.durationKeys}
|
|
numericKeys={this.props.numericKeys}
|
|
percentageKeys={this.props.percentageKeys}
|
|
sizeKeys={this.props.sizeKeys}
|
|
textOperatorKeys={this.props.textOperatorKeys}
|
|
/>
|
|
)}
|
|
</Container>
|
|
);
|
|
}
|
|
}
|
|
|
|
type ContainerState = {
|
|
members: ReturnType<typeof MemberListStore.getAll>;
|
|
};
|
|
|
|
class SmartSearchBarContainer extends Component<Props, ContainerState> {
|
|
state: ContainerState = {
|
|
members: MemberListStore.getAll(),
|
|
};
|
|
|
|
componentWillUnmount() {
|
|
this.unsubscribe();
|
|
}
|
|
|
|
unsubscribe = MemberListStore.listen(
|
|
({members}: typeof MemberListStore.state) => this.setState({members}),
|
|
undefined
|
|
);
|
|
|
|
render() {
|
|
// SmartSearchBar doesn't use members, but we forward it to cause a re-render.
|
|
return <SmartSearchBar {...this.props} members={this.state.members} />;
|
|
}
|
|
}
|
|
|
|
export default withApi(withSentryRouter(withOrganization(SmartSearchBarContainer)));
|
|
|
|
export type {Props as SmartSearchBarProps};
|
|
export {SmartSearchBar};
|
|
|
|
const Container = styled('div')<{inputHasFocus: boolean}>`
|
|
min-height: ${p => p.theme.form.md.height}px;
|
|
border: ${p =>
|
|
p.inputHasFocus ? `1px solid ${p.theme.focusBorder}` : `1px solid ${p.theme.border}`};
|
|
box-shadow: ${p =>
|
|
p.inputHasFocus
|
|
? `0 0 0 1px ${p.theme.focusBorder}`
|
|
: `inset ${p.theme.dropShadowMedium}`};
|
|
background: ${p => p.theme.background};
|
|
padding: 6px ${space(1)};
|
|
position: relative;
|
|
display: grid;
|
|
grid-template-columns: max-content 1fr max-content;
|
|
gap: ${space(1)};
|
|
align-items: start;
|
|
|
|
border-radius: ${p => p.theme.borderRadius};
|
|
|
|
.show-sidebar & {
|
|
background: ${p => p.theme.backgroundSecondary};
|
|
}
|
|
`;
|
|
|
|
const SearchIconContainer = styled('div')`
|
|
display: flex;
|
|
padding: ${space(0.5)} 0;
|
|
margin: 0;
|
|
color: ${p => p.theme.gray300};
|
|
`;
|
|
|
|
const SearchLabel = styled('label')`
|
|
display: flex;
|
|
padding: ${space(0.5)} 0;
|
|
margin: 0;
|
|
color: ${p => p.theme.gray300};
|
|
`;
|
|
|
|
const InputWrapper = styled('div')`
|
|
position: relative;
|
|
width: 100%;
|
|
height: 100%;
|
|
`;
|
|
|
|
const Highlight = styled('div')`
|
|
width: 100%;
|
|
height: 100%;
|
|
user-select: none;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
line-height: 24px;
|
|
font-size: ${p => p.theme.fontSizeSmall};
|
|
font-family: ${p => p.theme.text.familyMono};
|
|
`;
|
|
|
|
const SearchInput = styled('textarea')`
|
|
position: absolute;
|
|
inset: 0;
|
|
resize: none;
|
|
outline: none;
|
|
border: 0;
|
|
width: 100%;
|
|
padding: 0;
|
|
line-height: 25px;
|
|
margin-bottom: -1px;
|
|
background: transparent;
|
|
font-size: ${p => p.theme.fontSizeSmall};
|
|
font-family: ${p => p.theme.text.familyMono};
|
|
caret-color: ${p => p.theme.subText};
|
|
color: transparent;
|
|
|
|
&::selection {
|
|
background: rgba(0, 0, 0, 0.2);
|
|
}
|
|
&::placeholder {
|
|
color: ${p => p.theme.formPlaceholder};
|
|
}
|
|
:placeholder-shown {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
[disabled] {
|
|
color: ${p => p.theme.disabled};
|
|
}
|
|
`;
|
|
|
|
const ActionsBar = styled(ButtonBar)`
|
|
height: 100%;
|
|
`;
|
|
|
|
const VerticalEllipsisIcon = styled(IconEllipsis)`
|
|
transform: rotate(90deg);
|
|
`;
|
|
|
|
const OverlowingActionsMenu = styled(DropdownMenu)`
|
|
display: flex;
|
|
`;
|