Repository: getsentry/sentry
Pull request: ref(similarity-embeddings): Modify existing similarity UI
Change the existing similarity UI in the following ways:
- Convert Exception & Message UI to numbers (i.e. remove the bars UI)
- Remove color legend
- Remove the hover experience on “Exception” and “Message”
- Remove the button to show issues below the threshold, we’ll just show everything

with flag:
<img width="1487" alt="Screenshot 2023-12-27 at 9 25 04 AM" src="https://github.com/getsentry/sentry/assets/116035587/09fe3c7b-719c-4188-828a-43803c298c70">

without flag:
<img width="1478" alt="Screenshot 2023-12-27 at 11 53 23 AM" src="https://github.com/getsentry/sentry/assets/116035587/6ea8a7cc-fb2b-457d-a68e-307944554860">


closes https://github.com/getsentry/sentry/issues/62240

Problem statement:
Change existing similarity UI to use numbers
Change the existing similarity UI in the following ways:

- Convert Exception & Message UI to numbers (i.e. remove the bars UI)
- Remove color legend
- Remove the hover experience on “Exception” and “Message”
- Remove the button to show issues below the threshold, we’ll just show everything

Hints from issue discussion:

Commit message: fix: Use useOrganization for feature check

Patch to review:
diff --git a/static/app/views/issueDetails/groupSimilarIssues/index.spec.tsx b/static/app/views/issueDetails/groupSimilarIssues/index.spec.tsx
index 50ee5df6ad35da..328dd936d36cc2 100644
--- a/static/app/views/issueDetails/groupSimilarIssues/index.spec.tsx
+++ b/static/app/views/issueDetails/groupSimilarIssues/index.spec.tsx
@@ -1,4 +1,5 @@
 import {Groups} from 'sentry-fixture/groups';
+import {Organization} from 'sentry-fixture/organization';
 import {Project as ProjectFixture} from 'sentry-fixture/project';
 import RouterContextFixture from 'sentry-fixture/routerContextFixture';
 import RouterFixture from 'sentry-fixture/routerFixture';
@@ -11,8 +12,11 @@ import {
   waitFor,
 } from 'sentry-test/reactTestingLibrary';
 
+import useOrganization from 'sentry/utils/useOrganization';
 import GroupSimilarIssues from 'sentry/views/issueDetails/groupSimilarIssues';
 
+jest.mock('sentry/utils/useOrganization');
+
 const MockNavigate = jest.fn();
 jest.mock('sentry/utils/useNavigate', () => ({
   useNavigate: () => MockNavigate,
@@ -47,11 +51,17 @@ describe('Issues Similar View', function () {
 
   const router = RouterFixture();
 
+  function mockOrganization(props?: {features: string[]}) {
+    const features = props?.features ?? [];
+    jest.mocked(useOrganization).mockReturnValue(Organization({features}));
+  }
+
   beforeEach(function () {
     mock = MockApiClient.addMockResponse({
       url: '/organizations/org-slug/issues/group-id/similar/?limit=50',
       body: mockData.similar,
     });
+    mockOrganization();
   });
 
   afterEach(() => {
@@ -59,6 +69,18 @@ describe('Issues Similar View', function () {
     jest.clearAllMocks();
   });
 
+  const selectNthSimilarItem = async index => {
+    const items = await screen.findAllByTestId('similar-item-row');
+
+    const item = items.at(index);
+
+    if (item === undefined) {
+      throw new Error('Invalid item index');
+    }
+
+    await userEvent.click(item);
+  };
+
   it('renders with mocked data', async function () {
     render(
       <GroupSimilarIssues
@@ -76,6 +98,8 @@ describe('Issues Similar View', function () {
     expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
 
     await waitFor(() => expect(mock).toHaveBeenCalled());
+
+    expect(screen.getByText('Show 3 issues below threshold')).toBeInTheDocument();
   });
 
   it('can merge and redirect to new parent', async function () {
@@ -101,7 +125,7 @@ describe('Issues Similar View', function () {
     );
     renderGlobalModal();
 
-    await userEvent.click(await screen.findByTestId('similar-item-row'));
+    await selectNthSimilarItem(0);
     await userEvent.click(await screen.findByRole('button', {name: 'Merge (1)'}));
     await userEvent.click(screen.getByRole('button', {name: 'Confirm'}));
 
@@ -118,4 +142,29 @@ describe('Issues Similar View', function () {
       '/organizations/org-slug/issues/321/similar/'
     );
   });
+
+  it('renders all filtered issues with issues-similarity-embeddings flag', async function () {
+    mockOrganization({
+      features: ['issues-similarity-embeddings'],
+    });
+
+    render(
+      <GroupSimilarIssues
+        project={project}
+        params={{orgId: 'org-slug', groupId: 'group-id'}}
+        location={router.location}
+        router={router}
+        routeParams={router.params}
+        routes={router.routes}
+        route={{}}
+      />,
+      {context: routerContext}
+    );
+
+    expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
+
+    await waitFor(() => expect(mock).toHaveBeenCalled());
+
+    expect(screen.queryByText('Show 3 issues below threshold')).not.toBeInTheDocument();
+  });
 });
diff --git a/static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/index.tsx b/static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/index.tsx
index 36ab7bdc6490fa..a3e61400f32f2d 100644
--- a/static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/index.tsx
+++ b/static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/index.tsx
@@ -14,6 +14,7 @@ import GroupingStore, {SimilarItem} from 'sentry/stores/groupingStore';
 import {space} from 'sentry/styles/space';
 import {Project} from 'sentry/types';
 import {useNavigate} from 'sentry/utils/useNavigate';
+import useOrganization from 'sentry/utils/useOrganization';
 import usePrevious from 'sentry/utils/usePrevious';
 
 import List from './list';
@@ -47,6 +48,10 @@ function SimilarStackTrace({params, location, project}: Props) {
   const navigate = useNavigate();
   const prevLocationSearch = usePrevious(location.search);
   const hasSimilarityFeature = project.features.includes('similarity-view');
+  const organization = useOrganization();
+  const hasSimilarityEmbeddingsFeature = organization?.features?.includes(
+    'issues-similarity-embeddings'
+  );
 
   const fetchData = useCallback(() => {
     setStatus('loading');
@@ -158,13 +163,26 @@ function SimilarStackTrace({params, location, project}: Props) {
             </EmptyStateWarning>
           </Panel>
         )}
-        {status === 'ready' && hasSimilarItems && (
+        {status === 'ready' && hasSimilarItems && !hasSimilarityEmbeddingsFeature && (
           <List
             items={items.similar}
             filteredItems={items.filtered}
             onMerge={handleMerge}
             orgId={orgId}
             project={project}
+            organization={organization}
+            groupId={groupId}
+            pageLinks={items.pageLinks}
+          />
+        )}
+        {status === 'ready' && hasSimilarItems && hasSimilarityEmbeddingsFeature && (
+          <List
+            items={items.similar.concat(items.filtered)}
+            filteredItems={[]}
+            onMerge={handleMerge}
+            orgId={orgId}
+            project={project}
+            organization={organization}
             groupId={groupId}
             pageLinks={items.pageLinks}
           />
diff --git a/static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/item.tsx b/static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/item.tsx
index b14bb4fc2ebbdd..83f8fc1353669d 100644
--- a/static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/item.tsx
+++ b/static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/item.tsx
@@ -22,6 +22,7 @@ type Props = {
   groupId: Group['id'];
   issue: Group;
   orgId: Organization['id'];
+  organization: Organization;
   project: Project;
   aggregate?: {
     exception: number;
@@ -94,9 +95,12 @@ class Item extends Component<Props, State> {
   };
 
   render() {
-    const {aggregate, scoresByInterface, issue} = this.props;
+    const {aggregate, scoresByInterface, issue, organization} = this.props;
     const {visible, busy} = this.state;
     const similarInterfaces = ['exception', 'message'];
+    const hasSimilarityEmbeddingsFeature = organization?.features?.includes(
+      'issues-similarity-embeddings'
+    );
 
     if (!visible) {
       return null;
@@ -134,7 +138,6 @@ class Item extends Component<Props, State> {
 
         <Columns>
           <StyledCount value={issue.count} />
-
           {similarInterfaces.map(interfaceName => {
             const avgScore = aggregate?.[interfaceName];
             const scoreList = scoresByInterface?.[interfaceName] || [];
@@ -144,11 +147,14 @@ class Item extends Component<Props, State> {
 
             return (
               <Column key={interfaceName}>
-                <Hovercard
-                  body={scoreList.length && <SimilarScoreCard scoreList={scoreList} />}
-                >
-                  <ScoreBar vertical score={Math.round(scoreValue * 5)} />
-                </Hovercard>
+                {!hasSimilarityEmbeddingsFeature && (
+                  <Hovercard
+                    body={scoreList.length && <SimilarScoreCard scoreList={scoreList} />}
+                  >
+                    <ScoreBar vertical score={Math.round(scoreValue * 5)} />
+                  </Hovercard>
+                )}
+                {hasSimilarityEmbeddingsFeature && <div>{scoreValue.toFixed(4)}</div>}
               </Column>
             );
           })}
diff --git a/static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/list.tsx b/static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/list.tsx
index 0509004a6c537d..6a548ec25851d1 100644
--- a/static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/list.tsx
+++ b/static/app/views/issueDetails/groupSimilarIssues/similarStackTrace/list.tsx
@@ -24,6 +24,7 @@ type Props = {
   items: Array<SimilarItem>;
   onMerge: () => void;
   orgId: Organization['id'];
+  organization: Organization;
   pageLinks: string | null;
   project: Project;
 } & DefaultProps;
@@ -44,6 +45,7 @@ function List({
   orgId,
   groupId,
   project,
+  organization,
   items,
   filteredItems = [],
   pageLinks,
@@ -54,6 +56,9 @@ function List({
   const hasHiddenItems = !!filteredItems.length;
   const hasResults = items.length > 0 || hasHiddenItems;
   const itemsWithFiltered = items.concat(showAllItems ? filteredItems : []);
+  const hasSimilarityEmbeddingsFeature = organization?.features?.includes(
+    'issues-similarity-embeddings'
+  );
 
   if (!hasResults) {
     return <Empty />;
@@ -61,10 +66,11 @@ function List({
 
   return (
     <Fragment>
-      <Header>
-        <SimilarSpectrum />
-      </Header>
-
+      {!hasSimilarityEmbeddingsFeature && (
+        <Header>
+          <SimilarSpectrum />
+        </Header>
+      )}
       <Panel>
         <Toolbar onMerge={onMerge} />
 
@@ -75,11 +81,12 @@ function List({
               orgId={orgId}
               groupId={groupId}
               project={project}
+              organization={organization}
               {...item}
             />
           ))}
 
-          {hasHiddenItems && !showAllItems && (
+          {hasHiddenItems && !showAllItems && !hasSimilarityEmbeddingsFeature && (
             <Footer>
               <Button onClick={() => setShowAllItems(true)}>
                 {t('Show %s issues below threshold', filteredItems.length)}


Code review comments:
diff_hunk: @@ -47,18 +51,36 @@ describe('Issues Similar View', function () {
 
   const router = RouterFixture();
 
+  function mockOrganization(props?: {features: string[]}) {
+    const features = props?.features ?? [];
+    jest.mocked(useOrganization).mockReturnValue(Organization({features}));
+  }
+
   beforeEach(function () {
     mock = MockApiClient.addMockResponse({
       url: '/organizations/org-slug/issues/group-id/similar/?limit=50',
       body: mockData.similar,
     });
+    mockOrganization();
   });
 
   afterEach(() => {
     MockApiClient.clearMockResponses();
     jest.clearAllMocks();
   });
 
+  const selectNthSimilarItem = async index => {
+    const items = await screen.findAllByTestId('similar-item-row');
+
+    const item = items.at(index);
+
+    if (item === undefined) {
+      throw new Error('Invalid item index');
+    }
path: static/app/views/issueDetails/groupSimilarIssues/index.spec.tsx
text: @user1:
```suggestion
    expect(item).toBeDefined()
```

diff_hunk: @@ -47,18 +51,36 @@ describe('Issues Similar View', function () {
 
   const router = RouterFixture();
 
+  function mockOrganization(props?: {features: string[]}) {
path: static/app/views/issueDetails/groupSimilarIssues/index.spec.tsx
text: @user1:
So this works, but the preferred way of providing the org value is to pass it into the render function, which gets passed to the org provider. This way nothing needs to be mocked:

```tsx
render(<GroupSimilarIssues />, { organization: Organization({features}) })
```

@author:
thanks! I didn't know about this

diff_hunk: @@ -47,18 +51,36 @@ describe('Issues Similar View', function () {
 
   const router = RouterFixture();
 
+  function mockOrganization(props?: {features: string[]}) {
+    const features = props?.features ?? [];
+    jest.mocked(useOrganization).mockReturnValue(Organization({features}));
+  }
+
   beforeEach(function () {
     mock = MockApiClient.addMockResponse({
       url: '/organizations/org-slug/issues/group-id/similar/?limit=50',
       body: mockData.similar,
     });
+    mockOrganization();
   });
 
   afterEach(() => {
     MockApiClient.clearMockResponses();
     jest.clearAllMocks();
   });
 
+  const selectNthSimilarItem = async index => {
path: static/app/views/issueDetails/groupSimilarIssues/index.spec.tsx
text: @user1:
```suggestion
  const selectNthSimilarItem = async (index: number) => {
```