Browse Source

feat(query-builder): Add to issue stream (#71824)

Behind the feature flag
`organizations:issue-stream-search-query-builder`, uses the new
component for issue search. When enabled, it will be displayed on the
issue stream, in the saved search modal, and when creating an issue
dashboard widget.

In order to do so, I needed to add some functionality to get it to work:

- `onSearch` callback which is called when enter is pressed
- `className` and `onChange` props
- A smaller value for `maxOptions` so that the dropdowns render quickly
Malachi Willey 9 months ago
parent
commit
4dcea88ab8

+ 1 - 1
static/app/components/searchQueryBuilder/combobox.tsx

@@ -113,7 +113,7 @@ export function SearchQueryBuilderCombobox({
             onExit?.();
             return;
           case 'Enter':
-            if (!state.inputValue || state.selectionManager.focusedKey) {
+            if (state.selectionManager.focusedKey) {
               return;
             }
             state.close();

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

@@ -10,6 +10,7 @@ interface ContextData {
   keys: TagCollection;
   parsedQuery: ParseResult | null;
   query: string;
+  onSearch?: (query: string) => void;
 }
 
 export function useSearchQueryBuilder() {
@@ -22,4 +23,5 @@ export const SearchQueryBuilerContext = createContext<ContextData>({
   getTagValues: () => Promise.resolve([]),
   dispatch: () => {},
   parsedQuery: null,
+  onSearch: () => {},
 });

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

@@ -68,6 +68,39 @@ describe('SearchQueryBuilder', function () {
     label: 'Query Builder',
   };
 
+  describe('callbacks', function () {
+    it('calls onChange, onBlur, and onSearch with the query string', async function () {
+      const mockOnChange = jest.fn();
+      const mockOnBlur = jest.fn();
+      const mockOnSearch = jest.fn();
+      render(
+        <SearchQueryBuilder
+          {...defaultProps}
+          initialQuery=""
+          onChange={mockOnChange}
+          onBlur={mockOnBlur}
+          onSearch={mockOnSearch}
+        />
+      );
+
+      await userEvent.click(screen.getByRole('grid'));
+      await userEvent.keyboard('foo{enter}');
+
+      // Should call onChange and onSearch after enter
+      await waitFor(() => {
+        expect(mockOnChange).toHaveBeenCalledWith('foo');
+        expect(mockOnSearch).toHaveBeenCalledWith('foo');
+      });
+
+      await userEvent.click(document.body);
+
+      // Clicking outside activates onBlur
+      await waitFor(() => {
+        expect(mockOnBlur).toHaveBeenCalledWith('foo');
+      });
+    });
+  });
+
   describe('actions', function () {
     it('can clear the query', async function () {
       const mockOnChange = jest.fn();

+ 16 - 3
static/app/components/searchQueryBuilder/index.tsx

@@ -28,8 +28,17 @@ interface SearchQueryBuilderProps {
   getTagValues: (key: Tag, query: string) => Promise<string[]>;
   initialQuery: string;
   supportedKeys: TagCollection;
+  className?: string;
   label?: string;
+  onBlur?: (query: string) => void;
+  /**
+   * Called when the query value changes
+   */
   onChange?: (query: string) => void;
+  /**
+   * Called when the user presses enter
+   */
+  onSearch?: (query: string) => void;
 }
 
 function ActionButtons() {
@@ -47,7 +56,7 @@ function ActionButtons() {
   return (
     <ButtonsWrapper>
       <ActionButton
-        title={interfaceToggleText}
+        title={!parsedQuery ? t('Search query parsing failed') : interfaceToggleText}
         aria-label={interfaceToggleText}
         size="zero"
         icon={<IconSync />}
@@ -73,11 +82,14 @@ function ActionButtons() {
 }
 
 export function SearchQueryBuilder({
+  className,
   label,
   initialQuery,
   supportedKeys,
   getTagValues,
   onChange,
+  onSearch,
+  onBlur,
 }: SearchQueryBuilderProps) {
   const {state, dispatch} = useQueryBuilderState({initialQuery});
   const [queryInterface] = useSyncedLocalStorageState(
@@ -101,13 +113,14 @@ export function SearchQueryBuilder({
       keys: supportedKeys,
       getTagValues,
       dispatch,
+      onSearch,
     };
-  }, [state, parsedQuery, supportedKeys, getTagValues, dispatch]);
+  }, [state, parsedQuery, supportedKeys, getTagValues, dispatch, onSearch]);
 
   return (
     <SearchQueryBuilerContext.Provider value={contextValue}>
       <PanelProvider>
-        <Wrapper>
+        <Wrapper className={className} onBlur={() => onBlur?.(state.query)}>
           <PositionedSearchIcon size="sm" />
           {!parsedQuery || queryInterface === QueryInterfaceType.TEXT ? (
             <PlainTextQueryInput label={label} />

+ 7 - 2
static/app/components/searchQueryBuilder/input.tsx

@@ -10,6 +10,7 @@ import {getEscapedKey} from 'sentry/components/compactSelect/utils';
 import {SearchQueryBuilderCombobox} from 'sentry/components/searchQueryBuilder/combobox';
 import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context';
 import {useQueryBuilderGridItem} from 'sentry/components/searchQueryBuilder/useQueryBuilderGridItem';
+import {replaceTokenWithPadding} from 'sentry/components/searchQueryBuilder/useQueryBuilderState';
 import {useShiftFocusToChild} from 'sentry/components/searchQueryBuilder/utils';
 import type {
   ParseResultToken,
@@ -161,7 +162,7 @@ function SearchQueryBuilderInputInternal({
 
   const filterValue = getWordAtCursorPosition(inputValue, selectionIndex);
 
-  const {keys, dispatch} = useSearchQueryBuilder();
+  const {query, keys, dispatch, onSearch} = useSearchQueryBuilder();
 
   const allKeys = useMemo(() => {
     return Object.values(keys).sort((a, b) => a.key.localeCompare(b.key));
@@ -216,6 +217,10 @@ function SearchQueryBuilderInputInternal({
       }}
       onCustomValueSelected={value => {
         dispatch({type: 'UPDATE_FREE_TEXT', token, text: value});
+
+        // Because the query does not change until a subsequent render,
+        // we need to do the replacement that is does in the ruducer here
+        onSearch?.(replaceTokenWithPadding(query, token, value));
       }}
       onExit={() => {
         if (inputValue !== token.value.trim()) {
@@ -241,7 +246,7 @@ function SearchQueryBuilderInputInternal({
       }}
       onKeyDown={onKeyDown}
       tabIndex={tabIndex}
-      maxOptions={100}
+      maxOptions={50}
     >
       {sections.map(({title, children}) => (
         <Section title={title} key={title}>

+ 22 - 9
static/app/components/searchQueryBuilder/plainTextQueryInput.tsx

@@ -1,5 +1,6 @@
 import {
   type ChangeEvent,
+  type KeyboardEvent,
   type SyntheticEvent,
   useCallback,
   useRef,
@@ -17,10 +18,10 @@ interface PlainTextQueryInputProps {
 
 export function PlainTextQueryInput({label}: PlainTextQueryInputProps) {
   const inputRef = useRef<HTMLTextAreaElement>(null);
-  const {query, parsedQuery, dispatch} = useSearchQueryBuilder();
+  const {query, parsedQuery, dispatch, onSearch} = useSearchQueryBuilder();
   const [cursorPosition, setCursorPosition] = useState(0);
 
-  const handler = (event: SyntheticEvent<HTMLTextAreaElement>) => {
+  const setCursorPositionOnEvent = (event: SyntheticEvent<HTMLTextAreaElement>) => {
     if (event.currentTarget !== document.activeElement) {
       setCursorPosition(-1);
     } else {
@@ -30,12 +31,24 @@ export function PlainTextQueryInput({label}: PlainTextQueryInputProps) {
 
   const onChange = useCallback(
     (e: ChangeEvent<HTMLTextAreaElement>) => {
-      setCursorPosition(e.target.selectionStart);
+      setCursorPositionOnEvent(e);
       dispatch({type: 'UPDATE_QUERY', query: e.target.value});
     },
     [dispatch]
   );
 
+  const onKeyDown = useCallback(
+    (e: KeyboardEvent<HTMLTextAreaElement>) => {
+      setCursorPositionOnEvent(e);
+
+      if (e.key === 'Enter') {
+        e.preventDefault();
+        onSearch?.(query);
+      }
+    },
+    [onSearch, query]
+  );
+
   return (
     <InputWrapper>
       {parsedQuery ? (
@@ -48,13 +61,13 @@ export function PlainTextQueryInput({label}: PlainTextQueryInputProps) {
         ref={inputRef}
         autoComplete="off"
         value={query}
-        onFocus={handler}
-        onBlur={handler}
-        onKeyUp={handler}
-        onKeyDown={handler}
+        onFocus={setCursorPositionOnEvent}
+        onBlur={setCursorPositionOnEvent}
+        onKeyUp={setCursorPositionOnEvent}
+        onKeyDown={onKeyDown}
         onChange={onChange}
-        onClick={handler}
-        onPaste={handler}
+        onClick={setCursorPositionOnEvent}
+        onPaste={setCursorPositionOnEvent}
         spellCheck={false}
       />
     </InputWrapper>

+ 1 - 1
static/app/components/searchQueryBuilder/tokenizedQueryGrid.tsx

@@ -93,7 +93,7 @@ export function TokenizedQueryGrid({label}: TokenizedQueryGridProps) {
 }
 
 const SearchQueryGridWrapper = styled('div')`
-  padding: ${space(0.75)} 48px ${space(0.75)} 36px;
+  padding: ${space(0.75)} 48px ${space(0.75)} 32px;
   display: flex;
   align-items: stretch;
   row-gap: ${space(0.5)};

+ 1 - 1
static/app/components/searchQueryBuilder/useQueryBuilderState.tsx

@@ -94,7 +94,7 @@ function replaceQueryToken(
 
 // Ensures that the replaced token is separated from the rest of the query
 // and cleans up any extra whitespace
-function replaceTokenWithPadding(
+export function replaceTokenWithPadding(
   query: string,
   token: TokenResult<Token>,
   value: string

+ 5 - 0
static/app/components/searchQueryBuilder/valueCombobox.tsx

@@ -262,6 +262,10 @@ export function SearchQueryBuilderValueCombobox({
 
   const handleSelectValue = useCallback(
     (value: string) => {
+      if (!value) {
+        return;
+      }
+
       if (canSelectMultipleValues) {
         dispatch({
           type: 'TOGGLE_FILTER_VALUE',
@@ -315,6 +319,7 @@ export function SearchQueryBuilderValueCombobox({
         onInputChange={e => setInputValue(e.target.value)}
         onKeyDown={onKeyDown}
         autoFocus
+        maxOptions={50}
       >
         {suggestionSectionItems.map(section => (
           <Section key={section.sectionText} title={section.sectionText}>

+ 16 - 0
static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/issuesSearchBar.tsx

@@ -18,6 +18,18 @@ interface Props {
 }
 
 function IssuesSearchBar({onClose, widgetQuery, organization}: Props) {
+  if (organization.features.includes('issue-stream-search-query-builder')) {
+    return (
+      <StyledIssueListSearchQueryBuilder
+        searchSource="widget_builder"
+        organization={organization}
+        query={widgetQuery.conditions || ''}
+        onClose={onClose}
+        placeholder={t('Search for issues, status, assigned, and more')}
+      />
+    );
+  }
+
   return (
     <ClassNames>
       {({css}) => (
@@ -40,6 +52,10 @@ function IssuesSearchBar({onClose, widgetQuery, organization}: Props) {
 
 export {IssuesSearchBar};
 
+const StyledIssueListSearchQueryBuilder = styled(IssueListSearchBar)`
+  flex-grow: 1;
+`;
+
 const StyledIssueListSearchBar = styled(IssueListSearchBar)`
   flex-grow: 1;
   button:not([aria-label='Clear search']) {

Some files were not shown because too many files changed in this diff