123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204 |
- import {useCallback} from 'react';
- import {getFocusableTreeWalker} from '@react-aria/focus';
- import type {ListState} from '@react-stately/list';
- import type {Node} from '@react-types/shared';
- import {
- filterTypeConfig,
- interchangeableFilterOperators,
- type ParseResult,
- type ParseResultToken,
- parseSearch,
- type SearchConfig,
- type TermOperator,
- Token,
- type TokenResult,
- } from 'sentry/components/searchSyntax/parser';
- import type {Tag, TagCollection} from 'sentry/types';
- import {escapeDoubleQuotes} from 'sentry/utils';
- import {FieldValueType, getFieldDefinition} from 'sentry/utils/fields';
- export const INTERFACE_TYPE_LOCALSTORAGE_KEY = 'search-query-builder-interface';
- function getSearchConfigFromKeys(keys: TagCollection): Partial<SearchConfig> {
- const config = {
- booleanKeys: new Set<string>(),
- numericKeys: new Set<string>(),
- dateKeys: new Set<string>(),
- durationKeys: new Set<string>(),
- } satisfies Partial<SearchConfig>;
- for (const key in keys) {
- const fieldDef = getFieldDefinition(key);
- if (!fieldDef) {
- continue;
- }
- switch (fieldDef.valueType) {
- case FieldValueType.BOOLEAN:
- config.booleanKeys.add(key);
- break;
- case FieldValueType.NUMBER:
- case FieldValueType.INTEGER:
- config.numericKeys.add(key);
- break;
- case FieldValueType.DATE:
- config.dateKeys.add(key);
- break;
- case FieldValueType.DURATION:
- config.durationKeys.add(key);
- break;
- default:
- break;
- }
- }
- return config;
- }
- export function parseQueryBuilderValue(
- value: string,
- options?: {keys: TagCollection}
- ): ParseResult | null {
- return collapseTextTokens(
- parseSearch(value || ' ', {
- flattenParenGroups: true,
- ...getSearchConfigFromKeys(options?.keys ?? {}),
- })
- );
- }
- /**
- * Generates a unique key for the given token.
- *
- * It's important that the key is as stable as possible. Since we derive tokens
- * from the a simple query string, this is difficult to guarantee. The best we
- * can do is to use the token type and which iteration of that type it is.
- *
- * Example for query "is:unresolved foo assignee:me bar":
- * Keys: ["freeText:0", "filter:0", "freeText:1" "filter:1", "freeText:2"]
- */
- export function makeTokenKey(token: ParseResultToken, allTokens: ParseResult | null) {
- const tokenTypeIndex =
- allTokens?.filter(t => t.type === token.type).indexOf(token) ?? 0;
- return `${token.type}:${tokenTypeIndex}`;
- }
- const isSimpleTextToken = (
- token: ParseResultToken
- ): token is TokenResult<Token.FREE_TEXT> | TokenResult<Token.SPACES> => {
- return [Token.FREE_TEXT, Token.SPACES].includes(token.type);
- };
- export function getKeyLabel(key: Tag) {
- return key.alias ?? key.key;
- }
- /**
- * Collapse adjacent FREE_TEXT and SPACES tokens into a single token.
- * This is useful for rendering the minimum number of inputs in the UI.
- */
- export function collapseTextTokens(tokens: ParseResult | null) {
- if (!tokens) {
- return null;
- }
- return tokens.reduce<ParseResult>((acc, token) => {
- // For our purposes, SPACES are equivalent to FREE_TEXT
- // Combining them ensures that keys don't change when text is added or removed,
- // which would cause the cursor to jump around.
- if (isSimpleTextToken(token)) {
- token.type = Token.FREE_TEXT;
- }
- if (acc.length === 0) {
- return [token];
- }
- const lastToken = acc[acc.length - 1];
- if (isSimpleTextToken(token) && isSimpleTextToken(lastToken)) {
- lastToken.value += token.value;
- lastToken.text += token.text;
- lastToken.location.end = token.location.end;
- return acc;
- }
- return [...acc, token];
- }, []);
- }
- export function getValidOpsForFilter(
- filterToken: TokenResult<Token.FILTER>
- ): readonly TermOperator[] {
- // If the token is invalid we want to use the possible expected types as our filter type
- const validTypes = filterToken.invalid?.expectedType ?? [filterToken.filter];
- // Determine any interchangeable filter types for our valid types
- const interchangeableTypes = validTypes.map(
- type => interchangeableFilterOperators[type] ?? []
- );
- // Combine all types
- const allValidTypes = [...new Set([...validTypes, ...interchangeableTypes.flat()])];
- // Find all valid operations
- const validOps = new Set<TermOperator>(
- allValidTypes.flatMap(type => filterTypeConfig[type].validOps)
- );
- return [...validOps];
- }
- export function escapeTagValue(value: string): string {
- // Wrap in quotes if there is a space
- return value.includes(' ') || value.includes('"')
- ? `"${escapeDoubleQuotes(value)}"`
- : value;
- }
- export function unescapeTagValue(value: string): string {
- return value.replace(/\\"/g, '"');
- }
- export function formatFilterValue(token: TokenResult<Token.FILTER>['value']): string {
- switch (token.type) {
- case Token.VALUE_TEXT:
- return unescapeTagValue(token.value);
- default:
- return token.text;
- }
- }
- export function shiftFocusToChild(
- element: HTMLElement,
- item: Node<ParseResultToken>,
- state: ListState<ParseResultToken>
- ) {
- // Ensure that the state is updated correctly
- state.selectionManager.setFocusedKey(item.key);
- // When this row gains focus, immediately shift focus to the input
- const walker = getFocusableTreeWalker(element);
- const nextNode = walker.nextNode();
- if (nextNode) {
- (nextNode as HTMLElement).focus();
- }
- }
- export function useShiftFocusToChild(
- item: Node<ParseResultToken>,
- state: ListState<ParseResultToken>
- ) {
- const onFocus = useCallback(
- (e: React.FocusEvent<HTMLDivElement, Element>) => {
- shiftFocusToChild(e.currentTarget, item, state);
- },
- [item, state]
- );
- return {
- shiftFocusProps: {onFocus},
- };
- }
|