Browse Source

feat(search): Redesign the search dropdown ui items (#35859)

Update the search dropdown UI to be way more informative including descriptions and field types. Also renders recent searches as pretty filter tokens. This will be followed up by a PR containing rich descriptions for each of the field keys.

Also changes the Issues screen default state to be the list of all available keys
Jenn Mueng 2 years ago
parent
commit
6e060d6d0c

+ 83 - 20
static/app/components/events/searchBar.tsx

@@ -7,7 +7,7 @@ import omit from 'lodash/omit';
 import {fetchTagValues} from 'sentry/actionCreators/tags';
 import SmartSearchBar from 'sentry/components/smartSearchBar';
 import {NEGATION_OPERATOR, SEARCH_WILDCARD} from 'sentry/constants';
-import {Organization, SavedSearchType, TagCollection} from 'sentry/types';
+import {Organization, SavedSearchType, Tag, TagCollection} from 'sentry/types';
 import {defined} from 'sentry/utils';
 import {
   Field,
@@ -22,12 +22,62 @@ import {
 import Measurements from 'sentry/utils/measurements/measurements';
 import useApi from 'sentry/utils/useApi';
 import withTags from 'sentry/utils/withTags';
+import {FieldValueKind} from 'sentry/views/eventsV2/table/types';
 
 const SEARCH_SPECIAL_CHARS_REGEXP = new RegExp(
   `^${NEGATION_OPERATOR}|\\${SEARCH_WILDCARD}`,
   'g'
 );
 
+const getFunctionTags = (fields: Readonly<Field[]>) =>
+  Object.fromEntries(
+    fields
+      .filter(
+        item => !Object.keys(FIELD_TAGS).includes(item.field) && !isEquation(item.field)
+      )
+      .map(item => [
+        item.field,
+        {key: item.field, name: item.field, kind: FieldValueKind.FUNCTION},
+      ])
+  );
+
+const getFieldTags = () =>
+  Object.fromEntries(
+    Object.keys(FIELD_TAGS).map(key => [
+      key,
+      {
+        ...FIELD_TAGS[key],
+        kind: FieldValueKind.FIELD,
+      },
+    ])
+  );
+
+const getMeasurementTags = (
+  measurements: Parameters<
+    React.ComponentProps<typeof Measurements>['children']
+  >[0]['measurements']
+) =>
+  Object.fromEntries(
+    Object.keys(measurements).map(key => [
+      key,
+      {
+        ...measurements[key],
+        kind: FieldValueKind.MEASUREMENT,
+      },
+    ])
+  );
+
+const getSemverTags = () =>
+  Object.fromEntries(
+    Object.keys(SEMVER_TAGS).map(key => [
+      key,
+      {
+        ...SEMVER_TAGS[key],
+        kind: FieldValueKind.FIELD,
+      },
+    ])
+  );
+
 export type SearchBarProps = Omit<React.ComponentProps<typeof SmartSearchBar>, 'tags'> & {
   organization: Organization;
   tags: TagCollection;
@@ -103,30 +153,43 @@ function SearchBar(props: SearchBarProps) {
       React.ComponentProps<typeof Measurements>['children']
     >[0]['measurements']
   ) => {
-    const functionTags = fields
-      ? Object.fromEntries(
-          fields
-            .filter(
-              item =>
-                !Object.keys(FIELD_TAGS).includes(item.field) && !isEquation(item.field)
-            )
-            .map(item => [item.field, {key: item.field, name: item.field}])
-        )
-      : {};
-
-    const fieldTags = organization.features.includes('performance-view')
-      ? Object.assign({}, measurements, FIELD_TAGS, functionTags)
-      : omit(FIELD_TAGS, TRACING_FIELDS);
-
-    const combined = assign({}, tags, fieldTags, SEMVER_TAGS);
-    combined.has = {
+    const functionTags = getFunctionTags(fields ?? []);
+    const fieldTags = getFieldTags();
+    const measurementsWithKind = getMeasurementTags(measurements);
+    const semverTags = getSemverTags();
+
+    const orgHasPerformanceView = organization.features.includes('performance-view');
+
+    const combinedTags: Record<string, Tag> = orgHasPerformanceView
+      ? Object.assign({}, measurementsWithKind, fieldTags, functionTags)
+      : omit(fieldTags, TRACING_FIELDS);
+
+    const tagsWithKind = Object.fromEntries(
+      Object.keys(tags).map(key => [
+        key,
+        {
+          ...tags[key],
+          kind: FieldValueKind.TAG,
+        },
+      ])
+    );
+
+    assign(combinedTags, tagsWithKind, fieldTags, semverTags);
+
+    const sortedTagKeys = Object.keys(combinedTags);
+    sortedTagKeys.sort((a, b) => {
+      return a.toLowerCase().localeCompare(b.toLowerCase());
+    });
+
+    combinedTags.has = {
       key: 'has',
       name: 'Has property',
-      values: Object.keys(combined),
+      values: sortedTagKeys,
       predefined: true,
+      kind: FieldValueKind.FIELD,
     };
 
-    return omit(combined, omitTags ?? []);
+    return omit(combinedTags, omitTags ?? []);
   };
 
   return (

+ 34 - 67
static/app/components/smartSearchBar/index.tsx

@@ -51,8 +51,9 @@ import {ItemType, SearchGroup, SearchItem, Shortcut, ShortcutType} from './types
 import {
   addSpace,
   createSearchGroups,
-  filterSearchGroupsByIndex,
   generateOperatorEntryMap,
+  getSearchGroupWithItemMarkedActive,
+  getTagItemsFromKeys,
   getValidOps,
   removeSpace,
   shortcuts,
@@ -678,21 +679,7 @@ class SmartSearchBar extends Component<Props, State> {
       evt.preventDefault();
 
       const {flatSearchItems, activeSearchItem} = this.state;
-      const searchGroups = [...this.state.searchGroups];
-
-      const [groupIndex, childrenIndex] = isSelectingDropdownItems
-        ? filterSearchGroupsByIndex(searchGroups, activeSearchItem)
-        : [];
-
-      // Remove the previous 'active' property
-      if (typeof groupIndex !== 'undefined') {
-        if (
-          childrenIndex !== undefined &&
-          searchGroups[groupIndex]?.children?.[childrenIndex]
-        ) {
-          delete searchGroups[groupIndex].children[childrenIndex].active;
-        }
-      }
+      let searchGroups = [...this.state.searchGroups];
 
       const currIndex = isSelectingDropdownItems ? activeSearchItem : 0;
       const totalItems = flatSearchItems.length;
@@ -705,23 +692,13 @@ class SmartSearchBar extends Component<Props, State> {
           ? (currIndex + 1) % totalItems
           : 0;
 
-      const [nextGroupIndex, nextChildrenIndex] = filterSearchGroupsByIndex(
-        searchGroups,
-        nextActiveSearchItem
-      );
+      // Clear previous selection
+      const prevItem = flatSearchItems[currIndex];
+      searchGroups = getSearchGroupWithItemMarkedActive(searchGroups, prevItem, false);
 
-      // Make sure search items exist (e.g. both groups could be empty) and
-      // attach the 'active' property to the item
-      if (
-        nextGroupIndex !== undefined &&
-        nextChildrenIndex !== undefined &&
-        searchGroups[nextGroupIndex]?.children
-      ) {
-        searchGroups[nextGroupIndex].children[nextChildrenIndex] = {
-          ...searchGroups[nextGroupIndex].children[nextChildrenIndex],
-          active: true,
-        };
-      }
+      // Set new selection
+      const activeItem = flatSearchItems[nextActiveSearchItem];
+      searchGroups = getSearchGroupWithItemMarkedActive(searchGroups, activeItem, true);
 
       this.setState({searchGroups, activeSearchItem: nextActiveSearchItem});
     }
@@ -733,15 +710,9 @@ class SmartSearchBar extends Component<Props, State> {
     ) {
       evt.preventDefault();
 
-      const {activeSearchItem, searchGroups} = this.state;
-      const [groupIndex, childrenIndex] = filterSearchGroupsByIndex(
-        searchGroups,
-        activeSearchItem
-      );
-      const item =
-        groupIndex !== undefined &&
-        childrenIndex !== undefined &&
-        searchGroups[groupIndex].children[childrenIndex];
+      const {activeSearchItem, flatSearchItems} = this.state;
+
+      const item = flatSearchItems[activeSearchItem];
 
       if (item) {
         if (item.callback) {
@@ -800,26 +771,28 @@ class SmartSearchBar extends Component<Props, State> {
     }
 
     evt.preventDefault();
-    const isSelectingDropdownItems = this.state.activeSearchItem > -1;
 
     if (!this.state.showDropdown) {
       this.blur();
       return;
     }
 
-    const {searchGroups, activeSearchItem} = this.state;
-    const [groupIndex, childrenIndex] = isSelectingDropdownItems
-      ? filterSearchGroupsByIndex(searchGroups, activeSearchItem)
-      : [];
+    const {flatSearchItems, activeSearchItem} = this.state;
+    const isSelectingDropdownItems = this.state.activeSearchItem > -1;
 
-    if (groupIndex !== undefined && childrenIndex !== undefined) {
-      delete searchGroups[groupIndex].children[childrenIndex].active;
+    let searchGroups = [...this.state.searchGroups];
+    if (isSelectingDropdownItems) {
+      searchGroups = getSearchGroupWithItemMarkedActive(
+        searchGroups,
+        flatSearchItems[activeSearchItem],
+        false
+      );
     }
 
     this.setState({
       activeSearchItem: -1,
       showDropdown: false,
-      searchGroups: [...this.state.searchGroups],
+      searchGroups,
     });
   };
 
@@ -917,8 +890,7 @@ class SmartSearchBar extends Component<Props, State> {
 
     const supportedTags = this.props.supportedTags ?? {};
 
-    // Return all if query is empty
-    let tagKeys = Object.keys(supportedTags).map(key => `${key}:`);
+    let tagKeys = Object.keys(supportedTags);
 
     if (query) {
       const preparedQuery =
@@ -929,19 +901,12 @@ class SmartSearchBar extends Component<Props, State> {
     // If the environment feature is active and excludeEnvironment = true
     // then remove the environment key
     if (this.props.excludeEnvironment) {
-      tagKeys = tagKeys.filter(key => key !== 'environment:');
+      tagKeys = tagKeys.filter(key => key !== 'environment');
     }
 
-    return [
-      tagKeys
-        .map(value => ({
-          value,
-          desc: value,
-          documentation: getFieldDoc?.(value.slice(0, -1)) ?? '',
-        }))
-        .sort((a, b) => a.value.localeCompare(b.value)),
-      supportedTagType ?? ItemType.TAG_KEY,
-    ];
+    const tagItems = getTagItemsFromKeys(tagKeys, supportedTags, getFieldDoc);
+
+    return [tagItems, supportedTagType ?? ItemType.TAG_KEY];
   }
 
   /**
@@ -1208,11 +1173,13 @@ class SmartSearchBar extends Component<Props, State> {
     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)
-      this.setState({searchTerm: query});
 
       const [tagKeys, tagType] = this.getTagKeys('');
       const recentSearches = await this.getRecentSearches();
@@ -1223,8 +1190,6 @@ class SmartSearchBar extends Component<Props, State> {
 
       return;
     }
-    // cursor on whitespace show default "help" search terms
-    this.setState({searchTerm: ''});
 
     this.updateAutoCompleteState(
       defaultSearchItems,
@@ -1340,7 +1305,8 @@ class SmartSearchBar extends Component<Props, State> {
       tagName,
       type,
       maxSearchItems,
-      queryCharsLeft
+      queryCharsLeft,
+      true
     );
 
     this.setState(searchGroups);
@@ -1365,7 +1331,8 @@ class SmartSearchBar extends Component<Props, State> {
           tagName,
           type,
           maxSearchItems,
-          queryCharsLeft
+          queryCharsLeft,
+          false
         )
       )
       .reduce(

+ 340 - 85
static/app/components/smartSearchBar/searchDropdown.tsx

@@ -3,14 +3,20 @@ import styled from '@emotion/styled';
 import color from 'color';
 
 import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {parseSearch} from 'sentry/components/searchSyntax/parser';
+import HighlightQuery from 'sentry/components/searchSyntax/renderer';
 import {t, tct} from 'sentry/locale';
 import space from 'sentry/styles/space';
+import {FieldValueKind} from 'sentry/views/eventsV2/table/types';
 
 import Button from '../button';
 import HotkeysLabel from '../hotkeysLabel';
+import Tag from '../tag';
 
 import {ItemType, SearchGroup, SearchItem, Shortcut} from './types';
 
+const getDropdownItemKey = (item: SearchItem) => item.value || item.desc || item.title;
+
 type Props = {
   items: SearchGroup[];
   loading: boolean;
@@ -28,75 +34,17 @@ class SearchDropdown extends PureComponent<Props> {
     onClick: function () {},
   };
 
-  renderDescription = (item: SearchItem) => {
-    const searchSubstring = this.props.searchSubstring;
-    if (!searchSubstring) {
-      if (item.type === ItemType.INVALID_TAG) {
-        return (
-          <Invalid>
-            {tct("The field [field] isn't supported here. ", {
-              field: <strong>{item.desc}</strong>,
-            })}
-            {tct('[highlight:See all searchable properties in the docs.]', {
-              highlight: <Highlight />,
-            })}
-          </Invalid>
-        );
-      }
-
-      return item.desc;
-    }
-
-    const text = item.desc;
-
-    if (!text) {
-      return null;
-    }
-
-    const idx = text.toLowerCase().indexOf(searchSubstring.toLowerCase());
-
-    if (idx === -1) {
-      return item.desc;
-    }
-
-    return (
-      <span>
-        {text.substr(0, idx)}
-        <strong>{text.substr(idx, searchSubstring.length)}</strong>
-        {text.substr(idx + searchSubstring.length)}
-      </span>
-    );
-  };
-
-  renderHeaderItem = (item: SearchGroup) => (
-    <SearchDropdownGroup key={item.title}>
-      <SearchDropdownGroupTitle>
-        {item.icon}
-        {item.title && item.title}
-        {item.desc && <span>{item.desc}</span>}
-      </SearchDropdownGroupTitle>
-    </SearchDropdownGroup>
-  );
-
-  renderItem = (item: SearchItem) => (
-    <SearchListItem
-      key={item.value || item.desc || item.title}
-      className={item.active ? 'active' : undefined}
-      data-test-id="search-autocomplete-item"
-      onClick={item.callback ?? this.props.onClick.bind(this, item.value, item)}
-      ref={element => item.active && element?.scrollIntoView?.({block: 'nearest'})}
-    >
-      <SearchItemTitleWrapper>
-        {item.title && `${item.title}${item.desc ? ' · ' : ''}`}
-        <Description>{this.renderDescription(item)}</Description>
-        <Documentation>{item.documentation}</Documentation>
-      </SearchItemTitleWrapper>
-    </SearchListItem>
-  );
-
   render() {
-    const {className, loading, items, runShortcut, visibleShortcuts, maxMenuHeight} =
-      this.props;
+    const {
+      className,
+      loading,
+      items,
+      runShortcut,
+      visibleShortcuts,
+      maxMenuHeight,
+      searchSubstring,
+      onClick,
+    } = this.props;
     return (
       <StyledSearchDropdown className={className}>
         {loading ? (
@@ -111,8 +59,16 @@ class SearchDropdown extends PureComponent<Props> {
               // Hide header if `item.children` is defined, an array, and is empty
               return (
                 <Fragment key={item.title}>
-                  {item.type === 'header' && this.renderHeaderItem(item)}
-                  {item.children && item.children.map(this.renderItem)}
+                  {item.type === 'header' && <HeaderItem group={item} />}
+                  {item.children &&
+                    item.children.map(child => (
+                      <DropdownItem
+                        key={getDropdownItemKey(child)}
+                        item={child}
+                        searchSubstring={searchSubstring}
+                        onClick={onClick}
+                      />
+                    ))}
                   {isEmpty && <Info>{t('No items found')}</Info>}
                 </Fragment>
               );
@@ -152,6 +108,242 @@ class SearchDropdown extends PureComponent<Props> {
 
 export default SearchDropdown;
 
+type HeaderItemProps = {
+  group: SearchGroup;
+};
+
+const HeaderItem = ({group}: HeaderItemProps) => {
+  return (
+    <SearchDropdownGroup key={group.title}>
+      <SearchDropdownGroupTitle>
+        {group.icon}
+        {group.title && group.title}
+        {group.desc && <span>{group.desc}</span>}
+      </SearchDropdownGroupTitle>
+    </SearchDropdownGroup>
+  );
+};
+
+type HighlightedRestOfWordsProps = {
+  combinedRestWords: string;
+  firstWord: string;
+  searchSubstring: string;
+  hasSplit?: boolean;
+  isFirstWordHidden?: boolean;
+};
+
+const HighlightedRestOfWords = ({
+  combinedRestWords,
+  searchSubstring,
+  firstWord,
+  isFirstWordHidden,
+  hasSplit,
+}: HighlightedRestOfWordsProps) => {
+  const remainingSubstr =
+    searchSubstring.indexOf(firstWord) === -1
+      ? searchSubstring
+      : searchSubstring.slice(firstWord.length + 1);
+  const descIdx = combinedRestWords.indexOf(remainingSubstr);
+
+  if (descIdx > -1) {
+    return (
+      <RestOfWordsContainer isFirstWordHidden={isFirstWordHidden} hasSplit={hasSplit}>
+        .{combinedRestWords.slice(0, descIdx)}
+        <strong>
+          {combinedRestWords.slice(descIdx, descIdx + remainingSubstr.length)}
+        </strong>
+        {combinedRestWords.slice(descIdx + remainingSubstr.length)}
+      </RestOfWordsContainer>
+    );
+  }
+  return (
+    <RestOfWordsContainer isFirstWordHidden={isFirstWordHidden} hasSplit={hasSplit}>
+      .{combinedRestWords}
+    </RestOfWordsContainer>
+  );
+};
+
+type ItemTitleProps = {
+  item: SearchItem;
+  searchSubstring: string;
+
+  isChild?: boolean;
+};
+
+const ItemTitle = ({item, searchSubstring, isChild}: ItemTitleProps) => {
+  if (!item.title) {
+    return null;
+  }
+
+  const fullWord = item.title;
+
+  const words = item.kind !== FieldValueKind.FUNCTION ? fullWord.split('.') : [fullWord];
+  const [firstWord, ...restWords] = words;
+  const isFirstWordHidden = isChild;
+
+  const combinedRestWords = restWords.length > 0 ? restWords.join('.') : null;
+
+  if (searchSubstring) {
+    const idx =
+      restWords.length === 0
+        ? fullWord.toLowerCase().indexOf(searchSubstring.split('.')[0])
+        : fullWord.toLowerCase().indexOf(searchSubstring);
+
+    // Below is the logic to make the current query bold inside the result.
+    if (idx !== -1) {
+      return (
+        <SearchItemTitleWrapper>
+          {!isFirstWordHidden && (
+            <FirstWordWrapper>
+              {firstWord.slice(0, idx)}
+              <strong>{firstWord.slice(idx, idx + searchSubstring.length)}</strong>
+              {firstWord.slice(idx + searchSubstring.length)}
+            </FirstWordWrapper>
+          )}
+          {combinedRestWords && (
+            <HighlightedRestOfWords
+              firstWord={firstWord}
+              isFirstWordHidden={isFirstWordHidden}
+              searchSubstring={searchSubstring}
+              combinedRestWords={combinedRestWords}
+              hasSplit={words.length > 1}
+            />
+          )}
+        </SearchItemTitleWrapper>
+      );
+    }
+  }
+
+  return (
+    <SearchItemTitleWrapper>
+      {!isFirstWordHidden && <FirstWordWrapper>{firstWord}</FirstWordWrapper>}
+      {combinedRestWords && (
+        <RestOfWordsContainer
+          isFirstWordHidden={isFirstWordHidden}
+          hasSplit={words.length > 1}
+        >
+          .{combinedRestWords}
+        </RestOfWordsContainer>
+      )}
+    </SearchItemTitleWrapper>
+  );
+};
+
+type KindTagProps = {kind: FieldValueKind};
+
+const KindTag = ({kind}: KindTagProps) => {
+  let text, tagType;
+  switch (kind) {
+    case FieldValueKind.FUNCTION:
+      text = 'f(x)';
+      tagType = 'success';
+      break;
+    case FieldValueKind.MEASUREMENT:
+      text = 'field';
+      tagType = 'highlight';
+      break;
+    case FieldValueKind.BREAKDOWN:
+      text = 'field';
+      tagType = 'highlight';
+      break;
+    case FieldValueKind.TAG:
+      text = kind;
+      tagType = 'warning';
+      break;
+    case FieldValueKind.NUMERIC_METRICS:
+      text = 'f(x)';
+      tagType = 'success';
+      break;
+    case FieldValueKind.FIELD:
+    default:
+      text = kind;
+  }
+  return <Tag type={tagType}>{text}</Tag>;
+};
+
+type DropdownItemProps = {
+  item: SearchItem;
+  onClick: (value: string, item: SearchItem) => void;
+  searchSubstring: string;
+  isChild?: boolean;
+};
+
+const DropdownItem = ({item, isChild, searchSubstring, onClick}: DropdownItemProps) => {
+  const isDisabled = item.value === null;
+
+  let children: React.ReactNode;
+  if (item.type === ItemType.RECENT_SEARCH) {
+    children = <QueryItem item={item} />;
+  } else if (item.type === ItemType.INVALID_TAG) {
+    children = (
+      <Invalid>
+        {tct("The field [field] isn't supported here. ", {
+          field: <strong>{item.desc}</strong>,
+        })}
+        {tct('[highlight:See all searchable properties in the docs.]', {
+          highlight: <Highlight />,
+        })}
+      </Invalid>
+    );
+  } else {
+    children = (
+      <Fragment>
+        <ItemTitle item={item} isChild={isChild} searchSubstring={searchSubstring} />
+        {item.desc && <Value hasDocs={!!item.documentation}>{item.desc}</Value>}
+        <Documentation>{item.documentation}</Documentation>
+        <TagWrapper>{item.kind && !isChild && <KindTag kind={item.kind} />}</TagWrapper>
+      </Fragment>
+    );
+  }
+
+  return (
+    <Fragment>
+      <SearchListItem
+        className={`${isChild ? 'group-child' : ''} ${item.active ? 'active' : ''}`}
+        data-test-id="search-autocomplete-item"
+        onClick={
+          !isDisabled ? item.callback ?? onClick.bind(this, item.value, item) : undefined
+        }
+        ref={element => item.active && element?.scrollIntoView?.({block: 'nearest'})}
+        isGrouped={isChild}
+        isDisabled={isDisabled}
+      >
+        {children}
+      </SearchListItem>
+      {!isChild &&
+        item.children?.map(child => (
+          <DropdownItem
+            key={getDropdownItemKey(child)}
+            item={child}
+            onClick={onClick}
+            searchSubstring={searchSubstring}
+            isChild
+          />
+        ))}
+    </Fragment>
+  );
+};
+
+type QueryItemProps = {item: SearchItem};
+
+const QueryItem = ({item}: QueryItemProps) => {
+  if (!item.value) {
+    return null;
+  }
+
+  const parsedQuery = parseSearch(item.value);
+
+  if (!parsedQuery) {
+    return null;
+  }
+
+  return (
+    <QueryItemWrapper>
+      <HighlightQuery parsedQuery={parsedQuery} />
+    </QueryItemWrapper>
+  );
+};
+
 const StyledSearchDropdown = styled('div')`
   /* Container has a border that we need to account for */
   position: absolute;
@@ -185,8 +377,8 @@ const Info = styled('div')`
 `;
 
 const ListItem = styled('li')`
-  &:not(:last-child) {
-    border-bottom: 1px solid ${p => p.theme.innerBorder};
+  &:not(:first-child):not(.group-child) {
+    border-top: 1px solid ${p => p.theme.innerBorder};
   }
 `;
 
@@ -227,37 +419,81 @@ const SearchItemsList = styled('ul')<{maxMenuHeight?: number}>`
   }}
 `;
 
-const SearchListItem = styled(ListItem)`
+const SearchListItem = styled(ListItem)<{isDisabled?: boolean; isGrouped?: boolean}>`
   scroll-margin: 40px 0;
   font-size: ${p => p.theme.fontSizeLarge};
-  padding: ${space(1)} ${space(2)};
-  cursor: pointer;
+  padding: 4px ${space(2)};
 
-  &:hover,
-  &.active {
-    background: ${p => p.theme.hover};
-  }
+  min-height: ${p => (p.isGrouped ? '30px' : '36px')};
+
+  ${p => {
+    if (!p.isDisabled) {
+      return `
+        cursor: pointer;
+
+        &:hover,
+        &.active {
+          background: ${p.theme.hover};
+        }
+      `;
+    }
+
+    return '';
+  }}
+
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+  width: 100%;
 `;
 
 const SearchItemTitleWrapper = styled('div')`
+  display: flex;
+  flex-grow: 1;
+  flex-shrink: 0;
+  max-width: min(280px, 50%);
+
   color: ${p => p.theme.textColor};
   font-weight: normal;
   font-size: ${p => p.theme.fontSizeMedium};
   margin: 0;
   line-height: ${p => p.theme.text.lineHeightHeading};
+
   ${p => p.theme.overflowEllipsis};
 `;
 
-const Description = styled('span')`
-  font-size: ${p => p.theme.fontSizeSmall};
-  font-family: ${p => p.theme.text.familyMono};
+const RestOfWordsContainer = styled('span')<{
+  hasSplit?: boolean;
+  isFirstWordHidden?: boolean;
+}>`
+  color: ${p => (p.hasSplit ? p.theme.blue400 : p.theme.textColor)};
+  margin-left: ${p => (p.isFirstWordHidden ? space(1) : '0px')};
+`;
+
+const FirstWordWrapper = styled('span')`
+  font-weight: medium;
+`;
+
+const TagWrapper = styled('span')`
+  width: 5%;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: flex-end;
 `;
 
 const Documentation = styled('span')`
-  font-size: ${p => p.theme.fontSizeSmall};
-  font-family: ${p => p.theme.text.familyMono};
-  float: right;
+  font-size: ${p => p.theme.fontSizeMedium};
+  font-family: ${p => p.theme.text.family};
   color: ${p => p.theme.gray300};
+  display: flex;
+  flex: 2;
+  padding: 0 ${space(1)};
+
+  @media (max-width: ${p => p.theme.breakpoints.small}) {
+    display: none;
+  }
 `;
 
 const DropdownFooter = styled(`div`)`
@@ -335,3 +571,22 @@ const Invalid = styled(`span`)`
 const Highlight = styled(`strong`)`
   color: ${p => p.theme.linkColor};
 `;
+
+const QueryItemWrapper = styled('span')`
+  font-size: ${p => p.theme.fontSizeSmall};
+  width: 100%;
+  gap: ${space(1)};
+  display: flex;
+  white-space: nowrap;
+  word-break: normal;
+  font-family: ${p => p.theme.text.familyMono};
+`;
+
+const Value = styled('span')<{hasDocs?: boolean}>`
+  font-family: ${p => p.theme.text.familyMono};
+  font-size: ${p => p.theme.fontSizeSmall};
+
+  max-width: ${p => (p.hasDocs ? '280px' : 'none')};
+
+  ${p => p.theme.overflowEllipsis};
+`;

+ 11 - 2
static/app/components/smartSearchBar/types.tsx

@@ -1,3 +1,5 @@
+import {FieldValueKind} from 'sentry/views/eventsV2/table/types';
+
 import {Token, TokenResult} from '../searchSyntax/parser';
 
 export enum ItemType {
@@ -26,13 +28,20 @@ export type SearchItem = {
    * Call a callback instead of setting a value in the search query
    */
   callback?: () => void;
-  children?: React.ReactNode[];
+  /**
+   * Child search items, we only support 1 level of nesting though.
+   */
+  children?: SearchItem[];
   desc?: string;
   documentation?: React.ReactNode;
   ignoreMaxSearchItems?: boolean;
+  kind?: FieldValueKind;
   title?: string;
   type?: ItemType;
-  value?: string;
+  /**
+   * A value of null means that this item is not selectable in the search dropdown
+   */
+  value?: string | null;
 };
 
 export type Tag = {

+ 187 - 45
static/app/components/smartSearchBar/utils.tsx

@@ -1,3 +1,4 @@
+// eslint-disable-next-line simple-import-sort/imports
 import {
   filterTypeConfig,
   interchangeableFilterOperators,
@@ -16,8 +17,10 @@ import {
   IconUser,
 } from 'sentry/icons';
 import {t} from 'sentry/locale';
+import {FieldValueKind} from 'sentry/views/eventsV2/table/types';
 
 import {ItemType, SearchGroup, SearchItem, Shortcut, ShortcutType} from './types';
+import {Tag} from 'sentry/types';
 
 export function addSpace(query = '') {
   if (query.length !== 0 && query[query.length - 1] !== ' ') {
@@ -56,7 +59,7 @@ export function getQueryTerms(query: string, cursor: number) {
 
 function getTitleForType(type: ItemType) {
   if (type === ItemType.TAG_VALUE) {
-    return t('Tag Values');
+    return t('Values');
   }
 
   if (type === ItemType.RECENT_SEARCH) {
@@ -75,7 +78,7 @@ function getTitleForType(type: ItemType) {
     return t('Properties');
   }
 
-  return t('Tags');
+  return t('Keys');
 }
 
 function getIconForTypeAndTag(type: ItemType, tagName: string) {
@@ -103,16 +106,12 @@ function getIconForTypeAndTag(type: ItemType, tagName: string) {
   }
 }
 
-export function createSearchGroups(
+const filterSearchItems = (
   searchItems: SearchItem[],
-  recentSearchItems: SearchItem[] | undefined,
-  tagName: string,
-  type: ItemType,
-  maxSearchItems: number | undefined,
+  recentSearchItems?: SearchItem[],
+  maxSearchItems?: number,
   queryCharsLeft?: number
-) {
-  const activeSearchItem = 0;
-
+) => {
   if (maxSearchItems && maxSearchItems > 0) {
     searchItems = searchItems.filter(
       (value: SearchItem, index: number) =>
@@ -121,32 +120,68 @@ export function createSearchGroups(
   }
 
   if (queryCharsLeft || queryCharsLeft === 0) {
+    searchItems = searchItems.flatMap(item => {
+      if (!item.children) {
+        if (!item.value || item.value.length <= queryCharsLeft) {
+          return [item];
+        }
+        return [];
+      }
+
+      const newItem = {
+        ...item,
+        children: item.children.filter(
+          child => !child.value || child.value.length <= queryCharsLeft
+        ),
+      };
+
+      if (newItem.children.length === 0) {
+        return [];
+      }
+
+      return [newItem];
+    });
     searchItems = searchItems.filter(
-      (value: SearchItem) =>
-        typeof value.value !== 'undefined' && value.value.length <= queryCharsLeft
+      (value: SearchItem) => !value.value || value.value.length <= queryCharsLeft
     );
+
     if (recentSearchItems) {
       recentSearchItems = recentSearchItems.filter(
-        (value: SearchItem) =>
-          typeof value.value !== 'undefined' && value.value.length <= queryCharsLeft
+        (value: SearchItem) => !value.value || value.value.length <= queryCharsLeft
       );
     }
   }
 
+  return {searchItems, recentSearchItems};
+};
+
+export function createSearchGroups(
+  searchItems: SearchItem[],
+  recentSearchItems: SearchItem[] | undefined,
+  tagName: string,
+  type: ItemType,
+  maxSearchItems?: number,
+  queryCharsLeft?: number,
+  isDefaultState?: boolean
+) {
+  const activeSearchItem = 0;
+  const {searchItems: filteredSearchItems, recentSearchItems: filteredRecentSearchItems} =
+    filterSearchItems(searchItems, recentSearchItems, maxSearchItems, queryCharsLeft);
+
   const searchGroup: SearchGroup = {
     title: getTitleForType(type),
     type: type === ItemType.INVALID_TAG ? type : 'header',
     icon: getIconForTypeAndTag(type, tagName),
-    children: [...searchItems],
+    children: [...filteredSearchItems],
   };
 
   const recentSearchGroup: SearchGroup | undefined =
-    recentSearchItems && recentSearchItems.length > 0
+    filteredRecentSearchItems && filteredRecentSearchItems.length > 0
       ? {
           title: t('Recent Searches'),
           type: 'header',
           icon: <IconClock size="xs" />,
-          children: [...recentSearchItems],
+          children: [...filteredRecentSearchItems],
         }
       : undefined;
 
@@ -156,40 +191,38 @@ export function createSearchGroups(
     };
   }
 
+  const flatSearchItems = filteredSearchItems.flatMap(item => {
+    if (item.children) {
+      if (!item.value) {
+        return [...item.children];
+      }
+      return [item, ...item.children];
+    }
+    return [item];
+  });
+
+  if (isDefaultState) {
+    // Recent searches first in default state.
+    return {
+      searchGroups: [...(recentSearchGroup ? [recentSearchGroup] : []), searchGroup],
+      flatSearchItems: [
+        ...(recentSearchItems ? recentSearchItems : []),
+        ...flatSearchItems,
+      ],
+      activeSearchItem: -1,
+    };
+  }
+
   return {
     searchGroups: [searchGroup, ...(recentSearchGroup ? [recentSearchGroup] : [])],
-    flatSearchItems: [...searchItems, ...(recentSearchItems ? recentSearchItems : [])],
+    flatSearchItems: [
+      ...flatSearchItems,
+      ...(recentSearchItems ? recentSearchItems : []),
+    ],
     activeSearchItem: -1,
   };
 }
 
-/**
- * Items is a list of dropdown groups that have a `children` field. Only the
- * `children` are selectable, so we need to find which child is selected given
- * an index that is in range of the sum of all `children` lengths
- *
- * @return Returns a tuple of [groupIndex, childrenIndex]
- */
-export function filterSearchGroupsByIndex(items: SearchGroup[], index: number) {
-  let _index = index;
-  let foundSearchItem: [number?, number?] = [undefined, undefined];
-
-  items.find(({children}, i) => {
-    if (!children || !children.length) {
-      return false;
-    }
-    if (_index < children.length) {
-      foundSearchItem = [i, _index];
-      return true;
-    }
-
-    _index -= children.length;
-    return false;
-  });
-
-  return foundSearchItem;
-}
-
 export function generateOperatorEntryMap(tag: string) {
   return {
     [TermOperator.Default]: {
@@ -317,3 +350,112 @@ export const shortcuts: Shortcut[] = [
     },
   },
 ];
+
+/**
+ * Groups tag keys based on the "." character in their key.
+ * For example, "device.arch" and "device.name" will be grouped together as children of "device", a non-interactive parent.
+ * The parent will become interactive if there exists a key "device".
+ */
+export const getTagItemsFromKeys = (
+  tagKeys: string[],
+  supportedTags: {
+    [key: string]: Tag;
+  },
+  getFieldDoc?: (key: string) => React.ReactNode
+) => {
+  return [...tagKeys]
+    .sort((a, b) => a.localeCompare(b))
+    .reduce((groups, key) => {
+      const keyWithColon = `${key}:`;
+      const sections = key.split('.');
+      const kind = supportedTags[key]?.kind;
+      const documentation = getFieldDoc?.(key) || '-';
+
+      const item: SearchItem = {
+        value: keyWithColon,
+        title: key,
+        documentation,
+        kind,
+      };
+
+      const lastGroup = groups.at(-1);
+
+      const [title] = sections;
+
+      if (kind !== FieldValueKind.FUNCTION && lastGroup) {
+        if (lastGroup.children && lastGroup.title === title) {
+          lastGroup.children.push(item);
+          return groups;
+        }
+
+        if (lastGroup.title && lastGroup.title.split('.')[0] === title) {
+          if (lastGroup.title === title) {
+            return [
+              ...groups.slice(0, -1),
+              {
+                title,
+                value: lastGroup.value,
+                documentation: lastGroup.documentation,
+                kind: lastGroup.kind,
+                children: [item],
+              },
+            ];
+          }
+
+          // Add a blank parent if the last group's full key is not the same as the title
+          return [
+            ...groups.slice(0, -1),
+            {
+              title,
+              value: null,
+              documentation: '-',
+              kind: lastGroup.kind,
+              children: [lastGroup, item],
+            },
+          ];
+        }
+      }
+
+      return [...groups, item];
+    }, [] as SearchItem[]);
+};
+
+/**
+ * Sets an item as active within a search group array and returns new search groups without mutating.
+ * the item is compared via value, so this function assumes that each value is unique.
+ */
+export const getSearchGroupWithItemMarkedActive = (
+  searchGroups: SearchGroup[],
+  currentItem: SearchItem,
+  active: boolean
+) => {
+  return searchGroups.map(group => ({
+    ...group,
+    children: group.children?.map(item => {
+      if (item.value === currentItem.value) {
+        return {
+          ...item,
+          active,
+        };
+      }
+
+      if (item.children && item.children.length > 0) {
+        return {
+          ...item,
+          children: item.children.map(child => {
+            if (child.value === currentItem.value) {
+              return {
+                ...child,
+                active,
+              };
+            }
+
+            return child;
+          }),
+        };
+      }
+
+      return item;
+    }),
+  }));
+};

+ 4 - 0
static/app/types/group.tsx

@@ -1,4 +1,5 @@
 import type {PlatformKey} from 'sentry/data/platformCategories';
+import {FieldValueKind} from 'sentry/views/eventsV2/table/types';
 
 import type {Actor, TimeseriesValue} from './core';
 import type {Event, EventMetadata, EventOrGroupType, Level} from './event';
@@ -63,7 +64,10 @@ export type EventAttachment = Omit<IssueAttachment, 'event_id'>;
 export type Tag = {
   key: string;
   name: string;
+
   isInput?: boolean;
+
+  kind?: FieldValueKind;
   /**
    * How many values should be suggested in autocomplete.
    * Overrides SmartSearchBar's `maxSearchItems` prop.

+ 5 - 1
static/app/views/dashboardsV2/widgetBuilder/buildSteps/filterResultsStep/eventsSearchBar.tsx

@@ -4,7 +4,10 @@ import SearchBar, {SearchBarProps} from 'sentry/components/events/searchBar';
 import {MAX_QUERY_LENGTH} from 'sentry/constants';
 import {Organization, PageFilters, SavedSearchType} from 'sentry/types';
 import {WidgetQuery} from 'sentry/views/dashboardsV2/types';
-import {MAX_MENU_HEIGHT} from 'sentry/views/dashboardsV2/widgetBuilder/utils';
+import {
+  MAX_MENU_HEIGHT,
+  MAX_SEARCH_ITEMS,
+} from 'sentry/views/dashboardsV2/widgetBuilder/utils';
 
 interface Props {
   onBlur: SearchBarProps['onBlur'];
@@ -34,6 +37,7 @@ export function EventsSearchBar({
       onBlur={onBlur}
       useFormWrapper={false}
       maxQueryLength={MAX_QUERY_LENGTH}
+      maxSearchItems={MAX_SEARCH_ITEMS}
       maxMenuHeight={MAX_MENU_HEIGHT}
       savedSearchType={SavedSearchType.EVENT}
     />

+ 5 - 1
static/app/views/dashboardsV2/widgetBuilder/buildSteps/filterResultsStep/issuesSearchBar.tsx

@@ -9,7 +9,10 @@ import {getUtcDateString} from 'sentry/utils/dates';
 import useApi from 'sentry/utils/useApi';
 import withIssueTags from 'sentry/utils/withIssueTags';
 import {WidgetQuery} from 'sentry/views/dashboardsV2/types';
-import {MAX_MENU_HEIGHT} from 'sentry/views/dashboardsV2/widgetBuilder/utils';
+import {
+  MAX_MENU_HEIGHT,
+  MAX_SEARCH_ITEMS,
+} from 'sentry/views/dashboardsV2/widgetBuilder/utils';
 import IssueListSearchBar from 'sentry/views/issueList/searchBar';
 
 interface Props {
@@ -56,6 +59,7 @@ function IssuesSearchBarContainer({
           placeholder={t('Search for issues, status, assigned, and more')}
           tagValueLoader={tagValueLoader}
           onSidebarToggle={() => undefined}
+          maxSearchItems={MAX_SEARCH_ITEMS}
           savedSearchType={SavedSearchType.ISSUE}
           dropdownClassName={css`
             max-height: ${MAX_MENU_HEIGHT}px;

+ 5 - 1
static/app/views/dashboardsV2/widgetBuilder/buildSteps/filterResultsStep/releaseSearchBar.tsx

@@ -10,7 +10,10 @@ import {t} from 'sentry/locale';
 import {Organization, PageFilters, SavedSearchType, Tag, TagValue} from 'sentry/types';
 import useApi from 'sentry/utils/useApi';
 import {WidgetQuery} from 'sentry/views/dashboardsV2/types';
-import {MAX_MENU_HEIGHT} from 'sentry/views/dashboardsV2/widgetBuilder/utils';
+import {
+  MAX_MENU_HEIGHT,
+  MAX_SEARCH_ITEMS,
+} from 'sentry/views/dashboardsV2/widgetBuilder/utils';
 
 import {SESSION_STATUSES, SESSIONS_FILTER_TAGS} from '../../releaseWidget/fields';
 
@@ -89,6 +92,7 @@ export function ReleaseSearchBar({
           onSearch={onSearch}
           onBlur={onBlur}
           maxQueryLength={MAX_QUERY_LENGTH}
+          maxSearchItems={MAX_SEARCH_ITEMS}
           searchSource="widget_builder"
           query={widgetQuery.conditions}
           savedSearchType={SavedSearchType.SESSION}

+ 3 - 0
static/app/views/dashboardsV2/widgetBuilder/utils.tsx

@@ -313,6 +313,9 @@ export function getMetricFields(queries: WidgetQuery[]) {
   }, [] as string[]);
 }
 
+// Used to limit the number of results of the "filter your results" fields dropdown
+export const MAX_SEARCH_ITEMS = 5;
+
 // Used to set the max height of the smartSearchBar menu
 export const MAX_MENU_HEIGHT = 250;
 

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