Repository: getsentry/sentry
Pull request: feat(replays): Link from Replay Details tags to replay search results for the tag value
Fixes #44463


Problem statement:
[Replay Details] Make tags clickable and bring you back to the index page 
### Problem Statement

On the Replay Details tag tab, would be cool if you clicked on a tab and it brough you to the index page with a search filter prefilled on the tag. may need to handle array fields in a clean way but should be straightforward

### Solution Brainstorm

_No response_

Hints from issue discussion:

Commit message: feat(replays): Link from Replay Details tags to replay search results for the tag value

Patch to review:
diff --git a/static/app/components/replays/replayTagsTableRow.spec.tsx b/static/app/components/replays/replayTagsTableRow.spec.tsx
index 69ade943ac284d..481ed7d7aaf608 100644
--- a/static/app/components/replays/replayTagsTableRow.spec.tsx
+++ b/static/app/components/replays/replayTagsTableRow.spec.tsx
@@ -2,19 +2,9 @@ import {render, screen} from 'sentry-test/reactTestingLibrary';
 
 import ReplayTagsTableRow from './replayTagsTableRow';
 
-const releaseTag = {
-  key: 'release',
-  value: ['1.0.0', '2.0.0'],
-};
-
-const genericTag = {
-  key: 'foo',
-  value: ['bar', 'baz'],
-};
-
 describe('ReplayTagsTableRow', () => {
   it('Should render tag key and value correctly', () => {
-    render(<ReplayTagsTableRow tag={genericTag} />);
+    render(<ReplayTagsTableRow name="foo" values={['bar', 'baz']} />);
 
     expect(screen.getByText('foo')).toBeInTheDocument();
     expect(screen.getByText('bar')).toBeInTheDocument();
@@ -22,7 +12,7 @@ describe('ReplayTagsTableRow', () => {
   });
 
   it('Should render release tags correctly', () => {
-    render(<ReplayTagsTableRow tag={releaseTag} />);
+    render(<ReplayTagsTableRow name="release" values={['1.0.0', '2.0.0']} />);
 
     expect(screen.getByText('release')).toBeInTheDocument();
     expect(screen.getByText('1.0.0')).toBeInTheDocument();
@@ -30,31 +20,16 @@ describe('ReplayTagsTableRow', () => {
   });
 
   it('Should render the tag value as a link if we get a link result from generateUrl', () => {
-    render(
-      <ReplayTagsTableRow tag={genericTag} generateUrl={() => 'https://foo.bar'} />,
-      {context: TestStubs.routerContext()}
-    );
-
-    expect(screen.getByText('foo')).toBeInTheDocument();
-    expect(screen.getByText('bar')).toBeInTheDocument();
-    expect(screen.getByText('bar').closest('a')).toHaveAttribute(
-      'href',
-      'https://foo.bar'
-    );
-  });
-
-  it('Should not render the tag value as a link if we get the value in the query prop', () => {
     render(
       <ReplayTagsTableRow
-        tag={genericTag}
-        generateUrl={() => 'https://foo.bar'}
-        query="foo:bar"
-      />
+        name="foo"
+        values={['bar', 'baz']}
+        generateUrl={(name, value) => ({pathname: '/home', query: {[name]: value}})}
+      />,
+      {context: TestStubs.routerContext()}
     );
 
-    expect(screen.getByText('foo')).toBeInTheDocument();
-    expect(screen.getByText('bar')).toBeInTheDocument();
-    // Expect bar to not be a link
-    expect(screen.getByText('bar').closest('a')).toBeNull();
+    expect(screen.getByText('bar').closest('a')).toHaveAttribute('href', '/home?foo=bar');
+    expect(screen.getByText('baz').closest('a')).toHaveAttribute('href', '/home?foo=baz');
   });
 });
diff --git a/static/app/components/replays/replayTagsTableRow.tsx b/static/app/components/replays/replayTagsTableRow.tsx
index a76ad52d81011d..438988def9d2f0 100644
--- a/static/app/components/replays/replayTagsTableRow.tsx
+++ b/static/app/components/replays/replayTagsTableRow.tsx
@@ -7,20 +7,17 @@ import {KeyValueTableRow} from 'sentry/components/keyValueTable';
 import Link from 'sentry/components/links/link';
 import {Tooltip} from 'sentry/components/tooltip';
 import Version from 'sentry/components/version';
-import {EventTag} from 'sentry/types/event';
-
-type Tag = {key: string; value: string[]};
 
 interface Props {
-  tag: Tag;
-  generateUrl?: (tag: EventTag) => LocationDescriptor;
-  query?: string;
+  name: string;
+  values: string[];
+  generateUrl?: (name: string, value: string) => LocationDescriptor;
 }
 
-function ReplayTagsTableRow({tag, query, generateUrl}: Props) {
+function ReplayTagsTableRow({name, values, generateUrl}: Props) {
   const renderTagValue = useMemo(() => {
-    if (tag.key === 'release') {
-      return tag.value.map((value, index) => {
+    if (name === 'release') {
+      return values.map((value, index) => {
         return (
           <Fragment key={value}>
             {index > 0 && ', '}
@@ -30,9 +27,8 @@ function ReplayTagsTableRow({tag, query, generateUrl}: Props) {
       });
     }
 
-    return tag.value.map((value, index) => {
-      const valueInQuery = query?.includes(`${tag.key}:${value}`);
-      const target = valueInQuery ? undefined : generateUrl?.({key: tag.key, value});
+    return values.map((value, index) => {
+      const target = generateUrl?.(name, value);
 
       return (
         <Fragment key={value}>
@@ -41,13 +37,13 @@ function ReplayTagsTableRow({tag, query, generateUrl}: Props) {
         </Fragment>
       );
     });
-  }, [tag, query, generateUrl]);
+  }, [name, values, generateUrl]);
 
   return (
     <KeyValueTableRow
       keyName={
-        <StyledTooltip title={tag.key} showOnlyOnOverflow>
-          {tag.key}
+        <StyledTooltip title={name} showOnlyOnOverflow>
+          {name}
         </StyledTooltip>
       }
       value={
diff --git a/static/app/views/replays/detail/tagPanel/index.tsx b/static/app/views/replays/detail/tagPanel/index.tsx
index 92958021b44a9d..49da1869ca15d7 100644
--- a/static/app/views/replays/detail/tagPanel/index.tsx
+++ b/static/app/views/replays/detail/tagPanel/index.tsx
@@ -1,4 +1,6 @@
+import {useCallback} from 'react';
 import styled from '@emotion/styled';
+import {LocationDescriptor} from 'history';
 
 import EmptyMessage from 'sentry/components/emptyMessage';
 import {KeyValueTable} from 'sentry/components/keyValueTable';
@@ -7,12 +9,26 @@ import Placeholder from 'sentry/components/placeholder';
 import {useReplayContext} from 'sentry/components/replays/replayContext';
 import ReplayTagsTableRow from 'sentry/components/replays/replayTagsTableRow';
 import {t} from 'sentry/locale';
+import useOrganization from 'sentry/utils/useOrganization';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
 import FluidPanel from 'sentry/views/replays/detail/layout/fluidPanel';
 
 function TagPanel() {
+  const organization = useOrganization();
   const {replay} = useReplayContext();
   const replayRecord = replay?.getReplay();
 
+  const generateUrl = useCallback(
+    (name: string, value: string) =>
+      ({
+        pathname: normalizeUrl(`/organizations/${organization.slug}/replays/`),
+        query: {
+          query: `${name}:"${value}"`,
+        },
+      } as LocationDescriptor),
+    [organization.slug]
+  );
+
   if (!replayRecord) {
     return <Placeholder testId="replay-tags-loading-placeholder" height="100%" />;
   }
@@ -24,8 +40,13 @@ function TagPanel() {
       <FluidPanel>
         {tags.length ? (
           <KeyValueTable>
-            {tags.map(([key, value]) => (
-              <ReplayTagsTableRow key={key} tag={{key, value}} />
+            {tags.map(([key, values]) => (
+              <ReplayTagsTableRow
+                key={key}
+                name={key}
+                values={values}
+                generateUrl={generateUrl}
+              />
             ))}
           </KeyValueTable>
         ) : (


Code review comments:
diff_hunk: @@ -7,12 +9,26 @@ import Placeholder from 'sentry/components/placeholder';
 import {useReplayContext} from 'sentry/components/replays/replayContext';
 import ReplayTagsTableRow from 'sentry/components/replays/replayTagsTableRow';
 import {t} from 'sentry/locale';
+import useOrganization from 'sentry/utils/useOrganization';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
 import FluidPanel from 'sentry/views/replays/detail/layout/fluidPanel';
 
 function TagPanel() {
+  const organization = useOrganization();
   const {replay} = useReplayContext();
   const replayRecord = replay?.getReplay();
 
+  const generateUrl = useCallback(
+    (name: string, value: string) =>
+      ({
+        pathname: normalizeUrl(`/organizations/${organization.slug}/replays/`),
+        query: {
+          query: `${name}:"${value}"`,
path: static/app/views/replays/detail/tagPanel/index.tsx
text: @user1:
What happens when value is an array-like value? Do we have a url per value, or is it a single url?

Will all "tags" that appear in this section have queries that work? I feel like I remember having to do some query trickery  with using `tag[]` to wrap a tag key.

With some tags (release specifically), we have some semi-standard behaviors. For release we generally show a release hovercard, will that be something we want to do in the future?



@author:
> What happens when value is an array-like value? Do we have a url per value, or is it a single url?

yes, it's one link per value

> Will all "tags" that appear in this section have queries that work? I feel like I remember having to do some query trickery with using tag[] to wrap a tag key.

that is something this should do, yeah. Then i'll need a list because some standard fields like 'browser.name' and `os.name` are exposed as tags, but they won't be searchable with the `tag[$name]` format.

> With some tags (release specifically), we have some semi-standard behaviors. For release we generally show a release hovercard, will that be something we want to do in the future?

That can be a future thing. I put together a ticket: https://github.com/getsentry/sentry/issues/45549