Browse Source

feat(query-builder): Add new basic search component (#69248)

Closes https://github.com/getsentry/sentry/issues/69244

This is the first step, a component that simply parses and renders the
given query string. So far, only filter tokens are supported, and there
are no interactions besides being able to delete a token.

The general architecture of the component is:

- `<SearchQueryBuilder />`: The top level component which receives an
initial query, change handler, tag value fetcher, and the supported keys
(usually called tags, but renamed to be more specific since tags are
their own thing). This mostly matches the existing SmartSearchBar props,
but lacks many of the customizations which can be added later down the
road.
- `useQueryBuilderState.tsx`: Contains all the component state in a
`useReducer`. So far this is only the query string, but it will also
track focus later on.
- `<SearchQueryBuilderFilter />`: Accepts a parsed filter token and
renders it (with the key, op, value, and delete button)
- `SearchQueryBuilderContext`: Provides the component state and props to
subcomponents with a `useQueryBuilder()` hook

The DOM layout uses the [grid
patten](https://www.w3.org/WAI/ARIA/apg/patterns/grid/) (grid, row, and
griditem roles), and later on we will implement all the keyboard
interactions detailed in the ARIA guide.
Malachi Willey 10 months ago
parent
commit
5cc64f3733

+ 25 - 0
static/app/components/searchQueryBuilder/context.tsx

@@ -0,0 +1,25 @@
+import {createContext, type Dispatch, useContext} from 'react';
+
+import type {QueryBuilderActions} from 'sentry/components/searchQueryBuilder/useQueryBuilderState';
+import type {ParseResult} from 'sentry/components/searchSyntax/parser';
+import type {Tag, TagCollection} from 'sentry/types';
+
+interface ContextData {
+  dispatch: Dispatch<QueryBuilderActions>;
+  getTagValues: (tag: Tag, query: string) => Promise<string[]>;
+  keys: TagCollection;
+  parsedQuery: ParseResult | null;
+  query: string;
+}
+
+export function useSearchQueryBuilder() {
+  return useContext(SearchQueryBuilerContext);
+}
+
+export const SearchQueryBuilerContext = createContext<ContextData>({
+  query: '',
+  keys: {},
+  getTagValues: () => Promise.resolve([]),
+  dispatch: () => {},
+  parsedQuery: null,
+});

+ 184 - 0
static/app/components/searchQueryBuilder/filter.tsx

@@ -0,0 +1,184 @@
+import {useRef} from 'react';
+import styled from '@emotion/styled';
+
+import InteractionStateLayer from 'sentry/components/interactionStateLayer';
+import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context';
+import {
+  TermOperator,
+  type Token,
+  type TokenResult,
+} from 'sentry/components/searchSyntax/parser';
+import {IconClose} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {defined} from 'sentry/utils';
+
+type SearchQueryTokenProps = {
+  token: TokenResult<Token.FILTER>;
+};
+
+const OP_LABELS = {
+  [TermOperator.DEFAULT]: 'is',
+  [TermOperator.GREATER_THAN]: '>',
+  [TermOperator.GREATER_THAN_EQUAL]: '>=',
+  [TermOperator.LESS_THAN]: '<',
+  [TermOperator.LESS_THAN_EQUAL]: '<=',
+  [TermOperator.NOT_EQUAL]: 'is not',
+};
+
+const getOpLabel = (token: TokenResult<Token.FILTER>) => {
+  if (token.negated) {
+    return OP_LABELS[TermOperator.NOT_EQUAL];
+  }
+
+  return OP_LABELS[token.operator] ?? token.operator;
+};
+
+function FilterOperator({token}: SearchQueryTokenProps) {
+  // TODO(malwilley): Add edit functionality
+
+  return (
+    <OpDiv tabIndex={-1} role="gridcell" aria-label={t('Edit token operator')}>
+      <InteractionStateLayer />
+      {getOpLabel(token)}
+    </OpDiv>
+  );
+}
+
+function FilterKey({token}: SearchQueryTokenProps) {
+  const ref = useRef<HTMLDivElement>(null);
+  const label = token.key.text;
+
+  // TODO(malwilley): Add edit functionality
+
+  return (
+    <KeyDiv tabIndex={-1} role="gridcell" ref={ref} aria-label={t('Edit token key')}>
+      <InteractionStateLayer />
+      {label}
+    </KeyDiv>
+  );
+}
+
+function FilterValue({token}: SearchQueryTokenProps) {
+  // TODO(malwilley): Add edit functionality
+
+  return (
+    <ValueDiv tabIndex={-1} role="gridcell" aira-label={t('Edit token value')}>
+      <InteractionStateLayer />
+      {token.value.text}
+    </ValueDiv>
+  );
+}
+
+function FilterDelete({token}: SearchQueryTokenProps) {
+  const {dispatch} = useSearchQueryBuilder();
+
+  // TODO(malwilley): Add edit functionality
+
+  return (
+    <DeleteDiv
+      tabIndex={-1}
+      role="gridcell"
+      aria-label={t('Remove token')}
+      onClick={() => dispatch({type: 'DELETE_TOKEN', token})}
+    >
+      <InteractionStateLayer />
+      <IconClose legacySize="8px" />
+    </DeleteDiv>
+  );
+}
+
+export function SearchQueryBuilderFilter({token}: SearchQueryTokenProps) {
+  // TODO(malwilley): Add better error messaging
+  const tokenHasError = 'invalid' in token && defined(token.invalid);
+
+  return (
+    <FilterWrapper
+      onClick={e => {
+        e.stopPropagation();
+      }}
+      aria-label={token.text}
+      role="row"
+      tabIndex={-1}
+      data-invalid={tokenHasError}
+    >
+      <FilterKey token={token} />
+      <FilterOperator token={token} />
+      <FilterValue token={token} />
+      <FilterDelete token={token} />
+    </FilterWrapper>
+  );
+}
+
+const FilterWrapper = styled('div')<{invalid?: boolean}>`
+  position: relative;
+  display: grid;
+  grid-template-columns: auto auto auto auto;
+  align-items: stretch;
+  border: 1px solid ${p => p.theme.innerBorder};
+  border-radius: ${p => p.theme.borderRadius};
+  height: 24px;
+
+  [data-invalid] {
+    border-color: ${p => p.theme.red300};
+  }
+`;
+
+const BaseTokenPart = styled('div')`
+  display: flex;
+  align-items: center;
+  position: relative;
+  user-select: none;
+  cursor: pointer;
+`;
+
+const KeyDiv = styled(BaseTokenPart)`
+  padding: 0 ${space(0.5)} 0 ${space(0.75)};
+  border-radius: 3px 0 0 3px;
+  border-right: 1px solid transparent;
+
+  :focus,
+  :focus-within {
+    background-color: ${p => p.theme.translucentGray100};
+    border-right: 1px solid ${p => p.theme.innerBorder};
+  }
+`;
+
+const OpDiv = styled(BaseTokenPart)`
+  padding: 0 ${space(0.5)};
+  color: ${p => p.theme.subText};
+  height: 100%;
+  border-left: 1px solid transparent;
+  border-right: 1px solid transparent;
+
+  :focus {
+    background-color: ${p => p.theme.translucentGray100};
+    border-right: 1px solid ${p => p.theme.innerBorder};
+    border-left: 1px solid ${p => p.theme.innerBorder};
+  }
+`;
+
+const ValueDiv = styled(BaseTokenPart)`
+  padding: 0 ${space(0.5)};
+  color: ${p => p.theme.purple400};
+  border-left: 1px solid transparent;
+  border-right: 1px solid transparent;
+
+  :focus,
+  :focus-within {
+    background-color: ${p => p.theme.purple100};
+    border-left: 1px solid ${p => p.theme.innerBorder};
+    border-right: 1px solid ${p => p.theme.innerBorder};
+  }
+`;
+
+const DeleteDiv = styled(BaseTokenPart)`
+  padding: 0 ${space(0.75)} 0 ${space(0.5)};
+  border-radius: 0 3px 3px 0;
+  color: ${p => p.theme.subText};
+
+  border-left: 1px solid transparent;
+  :focus {
+    border-left: 1px solid ${p => p.theme.innerBorder};
+  }
+`;

+ 62 - 0
static/app/components/searchQueryBuilder/index.spec.tsx

@@ -0,0 +1,62 @@
+import type {ComponentProps} from 'react';
+
+import {render, screen, userEvent, within} from 'sentry-test/reactTestingLibrary';
+
+import {SearchQueryBuilder} from 'sentry/components/searchQueryBuilder';
+import type {TagCollection} from 'sentry/types';
+import {FieldKey, FieldKind} from 'sentry/utils/fields';
+
+const MOCK_SUPPORTED_KEYS: TagCollection = {
+  [FieldKey.AGE]: {key: FieldKey.AGE, name: 'Age', kind: FieldKind.FIELD},
+  [FieldKey.ASSIGNED]: {
+    key: FieldKey.ASSIGNED,
+    name: 'Assigned To',
+    kind: FieldKind.FIELD,
+    predefined: true,
+    values: ['me', 'unassigned', 'person@sentry.io'],
+  },
+  [FieldKey.BROWSER_NAME]: {
+    key: FieldKey.BROWSER_NAME,
+    name: 'Browser Name',
+    kind: FieldKind.FIELD,
+    predefined: true,
+    values: ['Chrome', 'Firefox', 'Safari', 'Edge'],
+  },
+  custom_tag_name: {key: 'custom_tag_name', name: 'Custom_Tag_Name', kind: FieldKind.TAG},
+};
+
+describe('SearchQueryBuilder', function () {
+  const defaultProps: ComponentProps<typeof SearchQueryBuilder> = {
+    getTagValues: jest.fn(),
+    initialQuery: '',
+    supportedKeys: MOCK_SUPPORTED_KEYS,
+    label: 'Query Builder',
+  };
+
+  it('can remove a token by clicking the delete button', async function () {
+    render(
+      <SearchQueryBuilder
+        {...defaultProps}
+        initialQuery="browser.name:firefox custom_tag_name:123"
+      />
+    );
+
+    expect(screen.getByRole('row', {name: 'browser.name:firefox'})).toBeInTheDocument();
+    expect(screen.getByRole('row', {name: 'custom_tag_name:123'})).toBeInTheDocument();
+
+    await userEvent.click(
+      within(screen.getByRole('row', {name: 'browser.name:firefox'})).getByRole(
+        'gridcell',
+        {name: 'Remove token'}
+      )
+    );
+
+    // Browser name token should be removed
+    expect(
+      screen.queryByRole('row', {name: 'browser.name:firefox'})
+    ).not.toBeInTheDocument();
+
+    // Custom tag token should still be present
+    expect(screen.getByRole('row', {name: 'custom_tag_name:123'})).toBeInTheDocument();
+  });
+});

+ 57 - 0
static/app/components/searchQueryBuilder/index.stories.tsx

@@ -0,0 +1,57 @@
+import {Fragment} from 'react';
+import styled from '@emotion/styled';
+
+import Alert from 'sentry/components/alert';
+import {SearchQueryBuilder} from 'sentry/components/searchQueryBuilder';
+import SizingWindow from 'sentry/components/stories/sizingWindow';
+import storyBook from 'sentry/stories/storyBook';
+import type {TagCollection} from 'sentry/types';
+import {FieldKey, FieldKind} from 'sentry/utils/fields';
+
+const SUPPORTED_KEYS: TagCollection = {
+  [FieldKey.AGE]: {key: FieldKey.AGE, name: 'Age', kind: FieldKind.FIELD},
+  [FieldKey.ASSIGNED]: {
+    key: FieldKey.ASSIGNED,
+    name: 'Assigned To',
+    kind: FieldKind.FIELD,
+    predefined: true,
+    values: ['me', 'unassigned', 'person@sentry.io'],
+  },
+  [FieldKey.BROWSER_NAME]: {
+    key: FieldKey.BROWSER_NAME,
+    name: 'Browser Name',
+    kind: FieldKind.FIELD,
+    predefined: true,
+    values: ['Chrome', 'Firefox', 'Safari', 'Edge'],
+  },
+  custom_tag_name: {key: 'custom_tag_name', name: 'Custom_Tag_Name', kind: FieldKind.TAG},
+};
+
+const getTagValues = (): Promise<string[]> => {
+  return new Promise(resolve => {
+    setTimeout(() => {
+      resolve(['tag value one', 'tag value two', 'tag value three']);
+    }, 500);
+  });
+};
+
+export default storyBook(SearchQueryBuilder, story => {
+  story('Default', () => {
+    return (
+      <Fragment>
+        <Alert type="warning">This component and story is a WIP.</Alert>
+        <MinHeightSizingWindow>
+          <SearchQueryBuilder
+            initialQuery="browser.name:Firefox assigned:me"
+            supportedKeys={SUPPORTED_KEYS}
+            getTagValues={getTagValues}
+          />
+        </MinHeightSizingWindow>
+      </Fragment>
+    );
+  });
+});
+
+const MinHeightSizingWindow = styled(SizingWindow)`
+  min-height: 500px;
+`;

+ 100 - 0
static/app/components/searchQueryBuilder/index.tsx

@@ -0,0 +1,100 @@
+import {useEffect, useMemo, useRef} from 'react';
+import styled from '@emotion/styled';
+
+import {inputStyles} from 'sentry/components/input';
+import {SearchQueryBuilerContext} from 'sentry/components/searchQueryBuilder/context';
+import {SearchQueryBuilderFilter} from 'sentry/components/searchQueryBuilder/filter';
+import {useQueryBuilderState} from 'sentry/components/searchQueryBuilder/useQueryBuilderState';
+import {parseSearch, Token} from 'sentry/components/searchSyntax/parser';
+import {IconSearch} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {Tag, TagCollection} from 'sentry/types';
+import PanelProvider from 'sentry/utils/panelProvider';
+
+interface SearchQueryBuilderProps {
+  getTagValues: (key: Tag, query: string) => Promise<string[]>;
+  initialQuery: string;
+  supportedKeys: TagCollection;
+  label?: string;
+  onChange?: (query: string) => void;
+}
+
+export function SearchQueryBuilder({
+  label,
+  initialQuery,
+  supportedKeys,
+  getTagValues,
+  onChange,
+}: SearchQueryBuilderProps) {
+  const {state, dispatch} = useQueryBuilderState({initialQuery});
+
+  const parsedQuery = useMemo(() => parseSearch(state.query), [state.query]);
+
+  useEffect(() => {
+    onChange?.(state.query);
+  }, [onChange, state.query]);
+
+  const contextValue = useMemo(() => {
+    return {
+      ...state,
+      parsedQuery,
+      keys: supportedKeys,
+      getTagValues,
+      dispatch,
+    };
+  }, [state, parsedQuery, supportedKeys, getTagValues, dispatch]);
+
+  const ref = useRef(null);
+
+  return (
+    <SearchQueryBuilerContext.Provider value={contextValue}>
+      <Wrapper ref={ref} role="grid" aria-label={label ?? t('Create a search query')}>
+        <PositionedSearchIcon size="sm" />
+        <PanelProvider>
+          {parsedQuery?.map(token => {
+            switch (token?.type) {
+              case Token.FILTER:
+                return (
+                  <SearchQueryBuilderFilter
+                    key={token.location.start.offset}
+                    token={token}
+                  />
+                );
+              // TODO(malwilley): Add other token types
+              default:
+                return null;
+            }
+          }) ?? null}
+        </PanelProvider>
+        {/* TODO(malwilley): Add action buttons */}
+      </Wrapper>
+    </SearchQueryBuilerContext.Provider>
+  );
+}
+
+const Wrapper = styled('div')`
+  ${inputStyles}
+  height: auto;
+  position: relative;
+
+  display: flex;
+  gap: ${space(1)};
+  flex-wrap: wrap;
+  font-size: ${p => p.theme.fontSizeMedium};
+  padding: ${space(0.75)} ${space(0.75)} ${space(0.75)} 36px;
+  cursor: text;
+
+  :focus-within {
+    border: 1px solid ${p => p.theme.focusBorder};
+    box-shadow: 0 0 0 1px ${p => p.theme.focusBorder};
+  }
+`;
+
+const PositionedSearchIcon = styled(IconSearch)`
+  color: ${p => p.theme.subText};
+  position: absolute;
+  left: ${space(1.5)};
+  top: ${space(0.75)};
+  height: 22px;
+`;

+ 53 - 0
static/app/components/searchQueryBuilder/useQueryBuilderState.tsx

@@ -0,0 +1,53 @@
+import {type Reducer, useCallback, useReducer} from 'react';
+
+import type {
+  ParseResultToken,
+  Token,
+  TokenResult,
+} from 'sentry/components/searchSyntax/parser';
+
+type QueryBuilderState = {
+  focus: null; // TODO(malwilley): Implement focus state
+  query: string;
+};
+
+type DeleteTokenAction = {
+  token: ParseResultToken;
+  type: 'DELETE_TOKEN';
+};
+
+export type QueryBuilderActions = DeleteTokenAction;
+
+function removeQueryToken(query: string, token: TokenResult<Token>): string {
+  return (
+    query.substring(0, token.location.start.offset) +
+    query.substring(token.location.end.offset)
+  );
+}
+
+export function useQueryBuilderState({initialQuery}: {initialQuery: string}) {
+  const initialState: QueryBuilderState = {query: initialQuery, focus: null};
+
+  const reducer: Reducer<QueryBuilderState, QueryBuilderActions> = useCallback(
+    (state, action): QueryBuilderState => {
+      switch (action.type) {
+        case 'DELETE_TOKEN':
+          return {
+            ...state,
+            query: removeQueryToken(state.query, action.token),
+            focus: null,
+          };
+        default:
+          return state;
+      }
+    },
+    []
+  );
+
+  const [state, dispatch] = useReducer(reducer, initialState);
+
+  return {
+    state,
+    dispatch,
+  };
+}

+ 7 - 6
static/app/components/searchSyntax/parser.tsx

@@ -1112,16 +1112,17 @@ type KVConverter<T extends Token> = ConverterResultMap[KVTokens] & {type: T};
  */
 export type TokenResult<T extends Token> = ConverterResultMap[Converter] & {type: T};
 
-/**
- * Result from parsing a search query.
- */
-export type ParseResult = Array<
+export type ParseResultToken =
   | TokenResult<Token.LOGIC_BOOLEAN>
   | TokenResult<Token.LOGIC_GROUP>
   | TokenResult<Token.FILTER>
   | TokenResult<Token.FREE_TEXT>
-  | TokenResult<Token.SPACES>
->;
+  | TokenResult<Token.SPACES>;
+
+/**
+ * Result from parsing a search query.
+ */
+export type ParseResult = ParseResultToken[];
 
 /**
  * Configures behavior of search parsing