Browse Source

feat(trace) trigger search on icon click (#80137)

Populate search with project:value on project icon click, which will
highlight the rows that belong to that project inside the trace view.
Jonas 4 months ago
parent
commit
04d60ffe98

+ 3 - 1
static/app/components/idBadge/baseBadge.tsx

@@ -16,6 +16,7 @@ export interface BaseBadgeProps {
   // Hides the main display name
   hideAvatar?: boolean;
   hideName?: boolean;
+  onClick?: React.HTMLAttributes<HTMLDivElement>['onClick'];
 }
 
 interface AllBaseBadgeProps extends BaseBadgeProps {
@@ -35,6 +36,7 @@ export const BaseBadge = memo(
     avatarProps = {},
     avatarSize = 24,
     description,
+    onClick,
     team,
     user,
     organization,
@@ -46,7 +48,7 @@ export const BaseBadge = memo(
     const wrapperGap: ValidSize = avatarSize <= 14 ? 0.5 : avatarSize <= 20 ? 0.75 : 1;
 
     return (
-      <Wrapper className={className} style={{gap: space(wrapperGap)}}>
+      <Wrapper className={className} style={{gap: space(wrapperGap)}} onClick={onClick}>
         {!hideAvatar && (
           <Avatar
             {...avatarProps}

+ 9 - 4
static/app/components/idBadge/projectBadge.tsx

@@ -25,6 +25,10 @@ export interface ProjectBadgeProps extends BaseBadgeProps {
    * If true, will use default max-width, or specify one as a string
    */
   hideOverflow?: boolean | string;
+  /**
+   * Overrides the onClick handler for the project badge
+   */
+  onClick?: React.HTMLAttributes<HTMLDivElement>['onClick'];
   /**
    * Overrides where the project badge links
    */
@@ -34,6 +38,7 @@ export interface ProjectBadgeProps extends BaseBadgeProps {
 function ProjectBadge({
   project,
   to,
+  onClick,
   hideOverflow = true,
   hideName = false,
   disableLink = false,
@@ -42,16 +47,16 @@ function ProjectBadge({
   ...props
 }: ProjectBadgeProps) {
   const organization = useOrganization({allowNull: true});
-  const {slug, id} = project;
 
   const badge = (
     <BaseBadge
       hideName={hideName}
+      onClick={onClick}
       displayName={
         <BadgeDisplayName hideOverflow={hideOverflow}>
           {displayPlatformName && project.platform
             ? getPlatformName(project.platform)
-            : slug}
+            : project.slug}
         </BadgeDisplayName>
       }
       project={project}
@@ -60,8 +65,8 @@ function ProjectBadge({
   );
 
   if (!disableLink && organization?.slug) {
-    const defaultTo = `/organizations/${organization.slug}/projects/${slug}/${
-      id ? `?project=${id}` : ''
+    const defaultTo = `/organizations/${organization.slug}/projects/${project.slug}/${
+      project.id ? `?project=${project.id}` : ''
     }`;
 
     return (

+ 7 - 1
static/app/views/explore/tables/tracesTable/fieldRenderers.tsx

@@ -56,7 +56,9 @@ export function SpanDescriptionRenderer({span}: {span: SpanResult<Field>}) {
 
 interface ProjectsRendererProps {
   projectSlugs: string[];
+  disableLink?: boolean;
   maxVisibleProjects?: number;
+  onProjectClick?: (projectSlug: string) => void;
   visibleAvatarSize?: number;
 }
 
@@ -64,6 +66,8 @@ export function ProjectsRenderer({
   projectSlugs,
   visibleAvatarSize,
   maxVisibleProjects = 2,
+  onProjectClick,
+  disableLink,
 }: ProjectsRendererProps) {
   const organization = useOrganization();
   const {projects} = useProjects({slugs: projectSlugs, orgId: organization.slug});
@@ -101,8 +105,10 @@ export function ProjectsRenderer({
       )}
       {visibleProjectAvatars.map(project => (
         <StyledProjectBadge
-          key={project.slug}
           hideName
+          key={project.slug}
+          onClick={() => onProjectClick?.(project.slug)}
+          disableLink={disableLink}
           project={project}
           avatarSize={visibleAvatarSize ?? 16}
           avatarProps={{hasTooltip: true, tooltip: project.slug}}

+ 31 - 6
static/app/views/performance/newTraceDetails/traceHeader/index.tsx

@@ -1,4 +1,4 @@
-import {useCallback} from 'react';
+import {useCallback, useMemo} from 'react';
 import styled from '@emotion/styled';
 
 import Breadcrumbs from 'sentry/components/breadcrumbs';
@@ -22,6 +22,7 @@ import {hasDatasetSelector} from 'sentry/views/dashboards/utils';
 import {ProjectsRenderer} from 'sentry/views/explore/tables/tracesTable/fieldRenderers';
 import {useModuleURLBuilder} from 'sentry/views/insights/common/utils/useModuleURL';
 import {useDomainViewFilters} from 'sentry/views/insights/pages/useFilters';
+import {useTraceStateDispatch} from 'sentry/views/performance/newTraceDetails/traceState/traceStateProvider';
 
 import type {TraceMetaQueryResults} from '../traceApi/useTraceMeta';
 import TraceConfigurations from '../traceConfigurations';
@@ -154,6 +155,19 @@ export function TraceMetaDataHeader(props: TraceMetadataHeaderProps) {
   const {view} = useDomainViewFilters();
   const moduleURLBuilder = useModuleURLBuilder(true);
 
+  const dispatch = useTraceStateDispatch();
+
+  const onProjectClick = useCallback(
+    (projectSlug: string) => {
+      dispatch({type: 'set query', query: `project:${projectSlug}`});
+    },
+    [dispatch]
+  );
+
+  const projectSlugs = useMemo(() => {
+    return Array.from(props.tree.projects).map(p => p.slug);
+  }, [props.tree]);
+
   if (!hasNewTraceViewUi) {
     return <LegacyTraceMetadataHeader {...props} />;
   }
@@ -195,11 +209,15 @@ export function TraceMetaDataHeader(props: TraceMetadataHeaderProps) {
             <StyledWrapper>
               <HighlightsIconSummary event={props.rootEventResults.data} />
             </StyledWrapper>
-            <ProjectsRenderer
-              projectSlugs={Array.from(props.tree.projects).map(({slug}) => slug)}
-              visibleAvatarSize={24}
-              maxVisibleProjects={3}
-            />
+            <ProjectsRendererWrapper>
+              <ProjectsRenderer
+                disableLink
+                onProjectClick={onProjectClick}
+                projectSlugs={projectSlugs}
+                visibleAvatarSize={24}
+                maxVisibleProjects={3}
+              />
+            </ProjectsRendererWrapper>
           </HeaderRow>
         ) : null}
       </HeaderContent>
@@ -207,6 +225,13 @@ export function TraceMetaDataHeader(props: TraceMetadataHeaderProps) {
   );
 }
 
+// We cannot change the cursor of the ProjectBadge component so we need to wrap it in a div
+const ProjectsRendererWrapper = styled('div')`
+  img {
+    cursor: pointer;
+  }
+`;
+
 const HeaderLayout = styled(Layout.Header)`
   padding: ${space(2)} ${space(2)} !important;
 `;

+ 23 - 0
static/app/views/performance/newTraceDetails/traceSearch/traceSearchEvaluator.spec.tsx

@@ -432,4 +432,27 @@ describe('TraceSearchEvaluator', () => {
       });
     });
   });
+
+  describe('project aliases', () => {
+    it('project -> project_slug', async () => {
+      const tree = makeTree([makeTransaction({project_slug: 'test_project'})]);
+
+      const cb = jest.fn();
+      search('project:test_project', tree, cb);
+      await waitFor(() => expect(cb).toHaveBeenCalled());
+      expect(cb.mock.calls[0][0][1].size).toBe(1);
+      expect(cb.mock.calls[0][0][0]).toEqual([{index: 0, value: tree.list[0]}]);
+      expect(cb.mock.calls[0][0][2]).toBe(null);
+    });
+    it('project.name -> project_slug', async () => {
+      const tree = makeTree([makeTransaction({project_slug: 'test_project'})]);
+
+      const cb = jest.fn();
+      search('project.name:test_project', tree, cb);
+      await waitFor(() => expect(cb).toHaveBeenCalled());
+      expect(cb.mock.calls[0][0][1].size).toBe(1);
+      expect(cb.mock.calls[0][0][0]).toEqual([{index: 0, value: tree.list[0]}]);
+      expect(cb.mock.calls[0][0][2]).toBe(null);
+    });
+  });
 });

+ 7 - 0
static/app/views/performance/newTraceDetails/traceSearch/traceSearchEvaluator.tsx

@@ -507,6 +507,13 @@ function resolveValueFromKey(
         }
       }
 
+      // Aliases for fields that do not exist on raw data
+      if (key === 'project' || key === 'project.name') {
+        // project.name and project fields do not exist on raw data and are
+        // aliases for project_slug key that does exist.
+        key = 'project_slug';
+      }
+
       // Check for direct key access.
       if (value[key] !== undefined) {
         return value[key];

+ 12 - 1
static/app/views/performance/newTraceDetails/traceSearch/traceSearchInput.tsx

@@ -166,6 +166,16 @@ export function TraceSearchInput(props: TraceSearchInputProps) {
     traceDispatch({type: 'go to previous match'});
   }, [traceDispatch, organization]);
 
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  useLayoutEffect(() => {
+    if (inputRef.current) {
+      inputRef.current.focus();
+      inputRef.current.value = traceState.search.query ?? '';
+      onChange({target: inputRef.current} as React.ChangeEvent<HTMLInputElement>);
+    }
+  }, [traceState.search.query, onChange]);
+
   return (
     <StyledSearchBar>
       <InputGroup.LeadingItems>
@@ -181,12 +191,13 @@ export function TraceSearchInput(props: TraceSearchInputProps) {
         )}
       </InputGroup.LeadingItems>
       <InputGroup.Input
+        ref={inputRef}
         size="xs"
         type="text"
         name="query"
         autoComplete="off"
         placeholder={t('Search in trace')}
-        defaultValue={traceState.search.query ?? ''}
+        defaultValue={traceState.search.query}
         onChange={onChange}
         onKeyDown={onKeyDown}
         onFocus={onSearchFocus}