Browse Source

feat(query-builder): Add unsubmitted search indicator (#77411)

Adds an indicator to the search icon when the internal search state does
not match the actual query state (`initialQuery`):

This is configured using `showUnsubmittedIndicator`, which I've added to
all uses of the search bar which require you to press enter to search.
Malachi Willey 6 months ago
parent
commit
312f0d6c4f

+ 1 - 0
static/app/components/feedback/feedbackSearch.tsx

@@ -228,6 +228,7 @@ export default function FeedbackSearch({className, style}: Props) {
         onSearch={onSearch}
         searchSource={'feedback-list'}
         placeholder={t('Search Feedback')}
+        showUnsubmittedIndicator
       />
     );
   }

+ 1 - 0
static/app/components/performance/spanSearchQueryBuilder.tsx

@@ -143,6 +143,7 @@ export function SpanSearchQueryBuilder({
       disallowFreeText
       disallowUnsupportedFilters
       recentSearches={SavedSearchType.SPAN}
+      showUnsubmittedIndicator
     />
   );
 }

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

@@ -682,6 +682,32 @@ export default storyBook(SearchQueryBuilder, story => {
     );
   });
 
+  story('Unsubmitted search indicator', () => {
+    const [query, setQuery] = useState('is:unresolved assigned:me');
+
+    return (
+      <Fragment>
+        <p>
+          You can display an indicator when the search query has been modified but not
+          fully submitted using the <code>showUnsubmittedIndicator</code> prop. This can
+          be useful to remind the user that they have unsaved changes for use cases which
+          require manual submission.
+        </p>
+        <p>
+          Current query: <code>{query}</code>
+        </p>
+        <SearchQueryBuilder
+          initialQuery={query}
+          filterKeys={FILTER_KEYS}
+          getTagValues={getTagValues}
+          searchSource="storybook"
+          showUnsubmittedIndicator
+          onSearch={setQuery}
+        />
+      </Fragment>
+    );
+  });
+
   story('Disabled', () => {
     return (
       <SearchQueryBuilder

+ 58 - 5
static/app/components/searchQueryBuilder/index.tsx

@@ -1,4 +1,4 @@
-import {forwardRef, useMemo, useRef} from 'react';
+import {forwardRef, useLayoutEffect, useMemo, useRef} from 'react';
 import styled from '@emotion/styled';
 
 import {Button} from 'sentry/components/button';
@@ -23,6 +23,7 @@ import {
   queryIsValid,
 } from 'sentry/components/searchQueryBuilder/utils';
 import type {SearchConfig} from 'sentry/components/searchSyntax/parser';
+import {Tooltip} from 'sentry/components/tooltip';
 import {IconClose, IconSearch} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
@@ -101,6 +102,11 @@ export interface SearchQueryBuilderProps {
    * If provided, saves and displays recent searches of the given type.
    */
   recentSearches?: SavedSearchType;
+  /**
+   * When true, will display a visual indicator when there are unsaved changes.
+   * This search is considered unsubmitted when query !== initialQuery.
+   */
+  showUnsubmittedIndicator?: boolean;
   /**
    * Render custom content in the trailing section of the search bar, located
    * to the left of the clear button.
@@ -108,6 +114,35 @@ export interface SearchQueryBuilderProps {
   trailingItems?: React.ReactNode;
 }
 
+function SearchIndicator({
+  initialQuery,
+  showUnsubmittedIndicator,
+}: {
+  initialQuery?: string;
+  showUnsubmittedIndicator?: boolean;
+}) {
+  const {size, query} = useSearchQueryBuilder();
+
+  if (size === 'small') {
+    return null;
+  }
+
+  const unSubmittedChanges = query !== initialQuery;
+  const showIndicator = showUnsubmittedIndicator && unSubmittedChanges;
+
+  return (
+    <PositionedSearchIconContainer>
+      <Tooltip
+        title={t('The current search query is not active. Press Enter to submit.')}
+        disabled={!showIndicator}
+      >
+        <SearchIcon size="sm" />
+        {showIndicator ? <UnSubmittedDot /> : null}
+      </Tooltip>
+    </PositionedSearchIconContainer>
+  );
+}
+
 const ActionButtons = forwardRef<HTMLDivElement, {trailingItems?: React.ReactNode}>(
   ({trailingItems = null}, ref) => {
     const {dispatch, handleSearch, disabled} = useSearchQueryBuilder();
@@ -156,6 +191,7 @@ export function SearchQueryBuilder({
   queryInterface = QueryInterfaceType.TOKENIZED,
   recentSearches,
   searchSource,
+  showUnsubmittedIndicator,
   trailingItems,
 }: SearchQueryBuilderProps) {
   const wrapperRef = useRef<HTMLDivElement>(null);
@@ -188,7 +224,7 @@ export function SearchQueryBuilder({
     ]
   );
 
-  useEffectAfterFirstRender(() => {
+  useLayoutEffect(() => {
     dispatch({type: 'UPDATE_QUERY', query: initialQuery});
   }, [dispatch, initialQuery]);
 
@@ -257,7 +293,10 @@ export function SearchQueryBuilder({
           ref={wrapperRef}
           aria-disabled={disabled}
         >
-          {size !== 'small' && <PositionedSearchIcon size="sm" />}
+          <SearchIndicator
+            initialQuery={initialQuery}
+            showUnsubmittedIndicator={showUnsubmittedIndicator}
+          />
           {!parsedQuery || queryInterface === QueryInterfaceType.TEXT ? (
             <PlainTextQueryInput label={label} />
           ) : (
@@ -301,10 +340,24 @@ const ActionButton = styled(Button)`
   color: ${p => p.theme.subText};
 `;
 
-const PositionedSearchIcon = styled(IconSearch)`
-  color: ${p => p.theme.subText};
+const PositionedSearchIconContainer = styled('div')`
   position: absolute;
   left: ${space(1.5)};
   top: ${space(0.75)};
+`;
+
+const SearchIcon = styled(IconSearch)`
+  color: ${p => p.theme.subText};
   height: 22px;
 `;
+
+const UnSubmittedDot = styled('div')`
+  position: absolute;
+  top: 0;
+  right: 0;
+  width: 9px;
+  height: 9px;
+  border-radius: 50%;
+  background: ${p => p.theme.active};
+  border: solid 2px ${p => p.theme.background};
+`;

+ 1 - 0
static/app/views/discover/resultsSearchQueryBuilder.tsx

@@ -290,6 +290,7 @@ function ResultsSearchQueryBuilder(props: Props) {
       filterKeySections={filterKeySections}
       getTagValues={getEventFieldValues}
       recentSearches={SavedSearchType.EVENT}
+      showUnsubmittedIndicator
     />
   );
 }

+ 1 - 0
static/app/views/issueDetails/streamline/eventSearch.tsx

@@ -193,6 +193,7 @@ export function EventSearch({
       label={t('Search events')}
       searchSource="issue_events_tab"
       className={className}
+      showUnsubmittedIndicator
       {...queryBuilderProps}
     />
   );

+ 1 - 0
static/app/views/issueList/searchBar.tsx

@@ -233,6 +233,7 @@ function IssueListSearchBar({organization, tags, onClose, ...props}: Props) {
         recentSearches={SavedSearchType.ISSUE}
         disallowLogicalOperators
         placeholder={props.placeholder}
+        showUnsubmittedIndicator
       />
     );
   }

+ 1 - 0
static/app/views/projectDetail/projectFilters.tsx

@@ -54,6 +54,7 @@ function ProjectFilters({query, relativeDateOptions, tagValueLoader, onSearch}:
           filterKeys={SUPPORTED_TAGS}
           onSearch={onSearch}
           getTagValues={getTagValues}
+          showUnsubmittedIndicator
         />
       ) : (
         <SmartSearchBar

+ 1 - 0
static/app/views/releases/list/index.tsx

@@ -576,6 +576,7 @@ class ReleasesList extends DeprecatedAsyncView<Props, State> {
                       getTagValues={this.getTagValues}
                       placeholder={t('Search by version, build, package, or stage')}
                       searchSource="releases"
+                      showUnsubmittedIndicator
                     />
                   ) : (
                     <StyledSmartSearchBar

+ 1 - 0
static/app/views/replays/list/replaySearchBar.tsx

@@ -235,6 +235,7 @@ function ReplaySearchBar(props: Props) {
           t('Search for users, duration, clicked elements, count_errors, and more')
         }
         recentSearches={SavedSearchType.REPLAY}
+        showUnsubmittedIndicator
       />
     );
   }