Просмотр исходного кода

feat(search-shortcuts): Group assignee search suggestions (#52764)

Scott Cooper 1 год назад
Родитель
Сommit
22cb937717

+ 101 - 1
static/app/components/smartSearchBar/index.spec.tsx

@@ -1,3 +1,5 @@
+import {Fragment} from 'react';
+
 import {
   act,
   fireEvent,
@@ -38,7 +40,6 @@ describe('SmartSearchBar', function () {
       },
     };
 
-    MockApiClient.clearMockResponses();
     MockApiClient.addMockResponse({
       url: '/organizations/org-slug/recent-searches/',
       body: [],
@@ -609,6 +610,105 @@ describe('SmartSearchBar', function () {
     jest.useRealTimers();
   });
 
+  it('autocompletes assigned from string values', async function () {
+    const mockOnChange = jest.fn();
+
+    render(
+      <SmartSearchBar
+        {...defaultProps}
+        query=""
+        onChange={mockOnChange}
+        supportedTags={{
+          assigned: {
+            key: 'assigned',
+            name: 'assigned',
+            predefined: true,
+            values: ['me', '[me, none]', '#team-a'],
+          },
+        }}
+      />
+    );
+
+    const textbox = screen.getByRole('textbox');
+    await userEvent.type(textbox, 'assigned:', {delay: null});
+
+    await userEvent.click(await screen.findByRole('option', {name: /#team-a/}), {
+      delay: null,
+    });
+
+    await waitFor(() => {
+      expect(mockOnChange).toHaveBeenLastCalledWith(
+        'assigned:#team-a ',
+        expect.anything()
+      );
+    });
+  });
+
+  it('autocompletes assigned from SearchGroup objects', async function () {
+    const mockOnChange = jest.fn();
+
+    render(
+      <SmartSearchBar
+        {...defaultProps}
+        query=""
+        onChange={mockOnChange}
+        supportedTags={{
+          assigned: {
+            key: 'assigned',
+            name: 'assigned',
+            predefined: true,
+            values: [
+              {
+                title: 'Suggested Values',
+                type: 'header',
+                icon: <Fragment />,
+                children: [
+                  {
+                    value: 'me',
+                    desc: 'me',
+                    type: ItemType.TAG_VALUE,
+                  },
+                ],
+              },
+              {
+                title: 'All Values',
+                type: 'header',
+                icon: <Fragment />,
+                children: [
+                  {
+                    value: '#team-a',
+                    desc: '#team-a',
+                    type: ItemType.TAG_VALUE,
+                  },
+                ],
+              },
+            ],
+          },
+        }}
+      />
+    );
+
+    const textbox = screen.getByRole('textbox');
+    await userEvent.type(textbox, 'assigned:', {delay: null});
+
+    expect(await screen.findByText('Suggested Values')).toBeInTheDocument();
+    expect(screen.getByText('All Values')).toBeInTheDocument();
+
+    // Filter down to "team"
+    await userEvent.type(textbox, 'team', {delay: null});
+
+    expect(screen.queryByText('Suggested Values')).not.toBeInTheDocument();
+
+    await userEvent.click(screen.getByRole('option', {name: /#team-a/}), {delay: null});
+
+    await waitFor(() => {
+      expect(mockOnChange).toHaveBeenLastCalledWith(
+        'assigned:#team-a ',
+        expect.anything()
+      );
+    });
+  });
+
   it('autocompletes tag values (predefined values with spaces)', async function () {
     jest.useFakeTimers();
     const mockOnChange = jest.fn();

+ 22 - 11
static/app/components/smartSearchBar/index.tsx

@@ -71,6 +71,7 @@ import {
 import {
   addSpace,
   createSearchGroups,
+  escapeTagValue,
   filterKeysFromQuery,
   generateOperatorEntryMap,
   getAutoCompleteGroupForInvalidWildcard,
@@ -108,13 +109,6 @@ const generateOpAutocompleteGroup = (
   };
 };
 
-const escapeValue = (value: string): string => {
-  // Wrap in quotes if there is a space
-  return value.includes(' ') || value.includes('"')
-    ? `"${value.replace(/"/g, '\\"')}"`
-    : value;
-};
-
 export type ActionProps = {
   api: Client;
   /**
@@ -1199,7 +1193,7 @@ class SmartSearchBar extends Component<DefaultProps & Props, State> {
       this.setState({noValueQuery});
 
       return values.map(value => {
-        const escapedValue = escapeValue(value);
+        const escapedValue = escapeTagValue(value);
         return {
           value: escapedValue,
           desc: escapedValue,
@@ -1215,11 +1209,27 @@ class SmartSearchBar extends Component<DefaultProps & Props, State> {
    * Returns array of tag values that substring match `query`; invokes `callback`
    * with results
    */
-  getPredefinedTagValues = (tag: Tag, query: string): SearchItem[] =>
-    (tag.values ?? [])
+  getPredefinedTagValues = (
+    tag: Tag,
+    query: string
+  ): AutocompleteGroup['searchItems'] => {
+    const groupOrValue = tag.values ?? [];
+
+    // Is an array of SearchGroup
+    if (groupOrValue.some(item => typeof item === 'object')) {
+      return (groupOrValue as SearchGroup[]).map(group => {
+        return {
+          ...group,
+          children: group.children?.filter(child => child.value?.includes(query)),
+        };
+      });
+    }
+
+    // Is an array of strings
+    return (groupOrValue as string[])
       .filter(value => value.includes(query))
       .map((value, i) => {
-        const escapedValue = escapeValue(value);
+        const escapedValue = escapeTagValue(value);
         return {
           value: escapedValue,
           desc: escapedValue,
@@ -1229,6 +1239,7 @@ class SmartSearchBar extends Component<DefaultProps & Props, State> {
             : false,
         };
       });
+  };
 
   /**
    * Get recent searches

+ 1 - 1
static/app/components/smartSearchBar/types.tsx

@@ -94,7 +94,7 @@ export type Shortcut = {
 
 export type AutocompleteGroup = {
   recentSearchItems: SearchItem[] | undefined;
-  searchItems: SearchItem[];
+  searchItems: SearchItem[] | SearchGroup[];
   tagName: string;
   type: ItemType;
 };

+ 55 - 8
static/app/components/smartSearchBar/utils.tsx

@@ -86,6 +86,7 @@ function getIconForTypeAndTag(type: ItemType, tagName: string) {
     case 'is':
       return <IconToggle size="xs" />;
     case 'assigned':
+    case 'assigned_or_suggested':
     case 'bookmarks':
       return <IconUser size="xs" />;
     case 'firstSeen':
@@ -152,8 +153,19 @@ interface SearchGroups {
   searchGroups: SearchGroup[];
 }
 
+function isSearchGroup(searchItem: SearchItem | SearchGroup): searchItem is SearchGroup {
+  return (
+    (searchItem as SearchGroup).children !== undefined && searchItem.type === 'header'
+  );
+}
+
+function isSearchGroupArray(items: SearchItem[] | SearchGroup[]): items is SearchGroup[] {
+  // Typescript doesn't like that there's no shared properties between SearchItem and SearchGroup
+  return (items as any[]).every(isSearchGroup);
+}
+
 export function createSearchGroups(
-  searchItems: SearchItem[],
+  searchGroupItems: SearchItem[] | SearchGroup[],
   recentSearchItems: SearchItem[] | undefined,
   tagName: string,
   type: ItemType,
@@ -163,19 +175,45 @@ export function createSearchGroups(
   defaultSearchGroup?: SearchGroup,
   fieldDefinitionGetter: typeof getFieldDefinition = getFieldDefinition
 ): SearchGroups {
-  const fieldDefinition = fieldDefinitionGetter(tagName);
-
-  const activeSearchItem = 0;
-  const {searchItems: filteredSearchItems, recentSearchItems: filteredRecentSearchItems} =
-    filterSearchItems(searchItems, recentSearchItems, maxSearchItems, queryCharsLeft);
-
   const searchGroup: SearchGroup = {
     title: getTitleForType(type),
     type: invalidTypes.includes(type) ? type : 'header',
     icon: getIconForTypeAndTag(type, tagName),
-    children: filteredSearchItems,
+    children: [],
   };
 
+  if (isSearchGroupArray(searchGroupItems)) {
+    // Autocomplete item has provided its own search groups
+    const searchGroups = searchGroupItems
+      .map(group => {
+        const {searchItems: filteredSearchItems} = filterSearchItems(
+          group.children,
+          recentSearchItems,
+          maxSearchItems,
+          queryCharsLeft
+        );
+        return {...group, children: filteredSearchItems};
+      })
+      .filter(group => group.children.length > 0);
+    return {
+      // Fallback to the blank search group when "no items found"
+      searchGroups: searchGroups.length ? searchGroups : [searchGroup],
+      flatSearchItems: searchGroups.flatMap(item => item.children ?? []),
+      activeSearchItem: -1,
+    };
+  }
+
+  const fieldDefinition = fieldDefinitionGetter(tagName);
+
+  const activeSearchItem = 0;
+  const {searchItems: filteredSearchItems, recentSearchItems: filteredRecentSearchItems} =
+    filterSearchItems(
+      searchGroupItems,
+      recentSearchItems,
+      maxSearchItems,
+      queryCharsLeft
+    );
+
   const recentSearchGroup: SearchGroup | undefined =
     filteredRecentSearchItems && filteredRecentSearchItems.length > 0
       ? {
@@ -186,6 +224,8 @@ export function createSearchGroups(
         }
       : undefined;
 
+  searchGroup.children = filteredSearchItems;
+
   if (searchGroup.children && !!searchGroup.children.length) {
     searchGroup.children[activeSearchItem] = {
       ...searchGroup.children[activeSearchItem],
@@ -652,3 +692,10 @@ export function getAutoCompleteGroupForInvalidWildcard(searchText: string) {
     },
   ];
 }
+
+export function escapeTagValue(value: string): string {
+  // Wrap in quotes if there is a space
+  return value.includes(' ') || value.includes('"')
+    ? `"${value.replace(/"/g, '\\"')}"`
+    : value;
+}

+ 6 - 2
static/app/types/group.tsx

@@ -1,5 +1,6 @@
+import type {SearchGroup} from 'sentry/components/smartSearchBar/types';
 import type {PlatformKey} from 'sentry/data/platformCategories';
-import {FieldKind} from 'sentry/utils/fields';
+import type {FieldKind} from 'sentry/utils/fields';
 
 import type {Actor, TimeseriesValue} from './core';
 import type {Event, EventMetadata, EventOrGroupType, Level} from './event';
@@ -132,7 +133,10 @@ export type Tag = {
   maxSuggestedValues?: number;
   predefined?: boolean;
   totalValues?: number;
-  values?: string[];
+  /**
+   * Usually values are strings, but a predefined tag can define its SearchGroups
+   */
+  values?: string[] | SearchGroup[];
 };
 
 export type TagCollection = Record<string, Tag>;

+ 35 - 1
static/app/utils/withIssueTags.spec.tsx

@@ -1,5 +1,6 @@
 import {act, render, screen, waitFor} from 'sentry-test/reactTestingLibrary';
 
+import {SearchGroup} from 'sentry/components/smartSearchBar/types';
 import MemberListStore from 'sentry/stores/memberListStore';
 import TagStore from 'sentry/stores/tagStore';
 import TeamStore from 'sentry/stores/teamStore';
@@ -23,6 +24,7 @@ function MyComponent(props: MyComponentProps) {
 
 describe('withIssueTags HoC', function () {
   beforeEach(() => {
+    TeamStore.reset();
     TagStore.reset();
     MemberListStore.loadInitialData([]);
   });
@@ -76,7 +78,7 @@ describe('withIssueTags HoC', function () {
 
     expect(
       screen.getByText(
-        /assigned: me, \[me, none\], foo@example.com, joe@example.com, #best-team-na/
+        /assigned: me, \[me, none\], #best-team-na, foo@example.com, joe@example.com/
       )
     ).toBeInTheDocument();
 
@@ -84,4 +86,36 @@ describe('withIssueTags HoC', function () {
       screen.getByText(/bookmarks: me, foo@example.com, joe@example.com/)
     ).toBeInTheDocument();
   });
+
+  it('groups assignees and puts suggestions first', function () {
+    const Container = withIssueTags(({tags}: MyComponentProps) => (
+      <div>
+        {(tags?.assigned?.values as SearchGroup[])?.map(searchGroup => (
+          <div data-test-id={searchGroup.title} key={searchGroup.title}>
+            {searchGroup.children?.map(item => item.desc).join(', ')}
+          </div>
+        ))}
+      </div>
+    ));
+    const organization = TestStubs.Organization({features: ['issue-search-shortcuts']});
+    TeamStore.loadInitialData([
+      TestStubs.Team({id: 1, slug: 'best-team', name: 'Best Team', isMember: true}),
+      TestStubs.Team({id: 2, slug: 'worst-team', name: 'Worst Team', isMember: false}),
+    ]);
+    MemberListStore.loadInitialData([
+      TestStubs.User(),
+      TestStubs.User({username: 'joe@example.com'}),
+    ]);
+    render(<Container organization={organization} forwardedValue="value" />, {
+      organization,
+    });
+
+    expect(screen.getByTestId('Suggested Values')).toHaveTextContent(
+      'me, [me, none], #best-team'
+    );
+
+    expect(screen.getByTestId('All Values')).toHaveTextContent(
+      'foo@example.com, joe@example.com, #worst-team'
+    );
+  });
 });

+ 70 - 81
static/app/utils/withIssueTags.tsx

@@ -1,9 +1,14 @@
-import {useCallback, useEffect, useState} from 'react';
+import {useEffect, useMemo, useState} from 'react';
 
+import {ItemType, SearchGroup} from 'sentry/components/smartSearchBar/types';
+import {escapeTagValue} from 'sentry/components/smartSearchBar/utils';
+import {IconStar, IconUser} from 'sentry/icons';
+import {t} from 'sentry/locale';
 import MemberListStore from 'sentry/stores/memberListStore';
 import TagStore from 'sentry/stores/tagStore';
 import TeamStore from 'sentry/stores/teamStore';
-import {Organization, TagCollection, Team, User} from 'sentry/types';
+import {useLegacyStore} from 'sentry/stores/useLegacyStore';
+import type {Organization, TagCollection, User} from 'sentry/types';
 import getDisplayName from 'sentry/utils/getDisplayName';
 
 export interface WithIssueTagsProps {
@@ -25,11 +30,15 @@ const getUsername = ({isManaged, username, email}: User) => {
   return !isManaged && username ? username : email;
 };
 
-type WrappedComponentState = {
-  tags: TagCollection;
-  teams: Team[];
-  users: User[];
-};
+function convertToSearchItem(value: string) {
+  const escapedValue = escapeTagValue(value);
+  return {
+    value: escapedValue,
+    desc: value,
+    type: ItemType.TAG_VALUE,
+  };
+}
+
 /**
  * HOC for getting tags and many useful issue attributes as 'tags' for use
  * in autocomplete selectors or condition builders.
@@ -38,91 +47,71 @@ function withIssueTags<Props extends WithIssueTagsProps>(
   WrappedComponent: React.ComponentType<Props>
 ) {
   function ComponentWithTags(props: Omit<Props, keyof WithIssueTagsProps> & HocProps) {
-    const [state, setState] = useState<WrappedComponentState>({
-      tags: TagStore.getIssueTags(props.organization),
-      users: MemberListStore.getAll(),
-      teams: TeamStore.getAll(),
-    });
-
-    const setAssigned = useCallback(
-      (newState: Partial<WrappedComponentState>) => {
-        setState(oldState => {
-          const usernames: string[] = newState.users
-            ? newState.users.map(getUsername)
-            : oldState.users.map(getUsername);
-
-          const teamnames: string[] = (newState.teams ? newState.teams : oldState.teams)
-            .filter(team => team.isMember)
-            .map(team => `#${team.slug}`);
-
-          const allAssigned = props.organization.features.includes('assign-to-me')
-            ? ['[me, my_teams, none]', ...usernames, ...teamnames]
-            : ['[me, none]', ...usernames, ...teamnames];
-
-          if (props.organization.features.includes('assign-to-me')) {
-            allAssigned.unshift('my_teams');
-          }
-          allAssigned.unshift('me');
-          usernames.unshift('me');
+    const {teams} = useLegacyStore(TeamStore);
+    const {members} = useLegacyStore(MemberListStore);
+    const [tags, setTags] = useState<TagCollection>(
+      TagStore.getIssueTags(props.organization)
+    );
 
-          return {
-            ...oldState,
-            ...newState,
-            tags: {
-              ...oldState.tags,
-              ...newState.tags,
-              assigned: {
-                ...(newState.tags?.assigned ?? oldState.tags?.assigned ?? {}),
-                values: allAssigned,
+    const issueTags = useMemo((): TagCollection => {
+      const usernames: string[] = members.map(getUsername);
+      const userTeams = teams.filter(team => team.isMember).map(team => `#${team.slug}`);
+      const nonMemberTeams = teams
+        .filter(team => !team.isMember)
+        .map(team => `#${team.slug}`);
+
+      const meAndMyTeams = props.organization.features.includes('assign-to-me')
+        ? ['my_teams', '[me, my_teams, none]']
+        : ['[me, none]'];
+      const suggestedAssignees: string[] = ['me', ...meAndMyTeams, ...userTeams];
+      const assigndValues: SearchGroup[] | string[] =
+        props.organization.features.includes('issue-search-shortcuts')
+          ? [
+              {
+                title: t('Suggested Values'),
+                type: 'header',
+                icon: <IconStar size="xs" />,
+                children: suggestedAssignees.map(convertToSearchItem),
               },
-              bookmarks: {
-                ...(newState.tags?.bookmarks ?? oldState.tags?.bookmarks ?? {}),
-                values: usernames,
+              {
+                title: t('All Values'),
+                type: 'header',
+                icon: <IconUser size="xs" />,
+                children: [
+                  ...usernames.map(convertToSearchItem),
+                  ...nonMemberTeams.map(convertToSearchItem),
+                ],
               },
-              assigned_or_suggested: {
-                ...(newState.tags?.assigned_or_suggested ??
-                  oldState.tags.assigned_or_suggested ??
-                  {}),
-                values: allAssigned,
-              },
-            },
-          };
-        });
-      },
-      [props.organization]
-    );
-
-    // Listen to team store updates and cleanup listener on unmount
-    useEffect(() => {
-      const unsubscribeTeam = TeamStore.listen(() => {
-        setAssigned({teams: TeamStore.getAll()});
-      }, undefined);
-
-      return () => unsubscribeTeam();
-    }, [setAssigned]);
+            ]
+          : [...suggestedAssignees, ...usernames, ...nonMemberTeams];
+
+      return {
+        ...tags,
+        assigned: {
+          ...tags.assigned,
+          values: assigndValues,
+        },
+        bookmarks: {
+          ...tags.bookmarks,
+          values: ['me', ...usernames],
+        },
+        assigned_or_suggested: {
+          ...tags.assigned_or_suggested,
+          values: assigndValues,
+        },
+      };
+    }, [props.organization, teams, members, tags]);
 
     // Listen to tag store updates and cleanup listener on unmount
     useEffect(() => {
       const unsubscribeTags = TagStore.listen(() => {
-        setAssigned({tags: TagStore.getIssueTags(props.organization)});
+        setTags(TagStore.getIssueTags(props.organization));
       }, undefined);
 
       return () => unsubscribeTags();
-    }, [props.organization, setAssigned]);
-
-    // Listen to member store updates and cleanup listener on unmount
-    useEffect(() => {
-      const unsubscribeMembers = MemberListStore.listen(
-        ({members}: typeof MemberListStore.state) => {
-          setAssigned({users: members});
-        },
-        undefined
-      );
-
-      return () => unsubscribeMembers();
-    }, [setAssigned]);
+    }, [props.organization, setTags]);
 
-    return <WrappedComponent {...(props as Props)} tags={state.tags} />;
+    return <WrappedComponent {...(props as Props)} tags={issueTags} />;
   }
   ComponentWithTags.displayName = `withIssueTags(${getDisplayName(WrappedComponent)})`;
   return ComponentWithTags;