From 1ffaadad9153218f7bbd7e01356f306ebca5199f Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Thu, 26 Mar 2026 15:16:37 -0700 Subject: [PATCH] fix(aci): Include project alerts on monitor list page --- .../detectorListConnectedAutomations.spec.tsx | 95 +++++++++++++++++++ .../detectorListConnectedAutomations.tsx | 34 +++++-- .../detectorListTable/detectorListRow.tsx | 5 +- .../useIssueStreamDetectorsForProject.tsx | 15 ++- 4 files changed, 137 insertions(+), 12 deletions(-) create mode 100644 static/app/views/detectors/components/detectorListConnectedAutomations.spec.tsx diff --git a/static/app/views/detectors/components/detectorListConnectedAutomations.spec.tsx b/static/app/views/detectors/components/detectorListConnectedAutomations.spec.tsx new file mode 100644 index 00000000000000..e0d0f7485fb6d2 --- /dev/null +++ b/static/app/views/detectors/components/detectorListConnectedAutomations.spec.tsx @@ -0,0 +1,95 @@ +import {AutomationFixture} from 'sentry-fixture/automations'; +import {IssueStreamDetectorFixture} from 'sentry-fixture/detectors'; + +import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; + +import {DetectorListConnectedAutomations} from 'sentry/views/detectors/components/detectorListConnectedAutomations'; + +describe('DetectorListConnectedAutomations', () => { + beforeEach(() => { + MockApiClient.clearMockResponses(); + }); + + it('renders combined count of direct and project alerts', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/workflows/', + body: [ + AutomationFixture({id: '1', name: 'Direct Alert'}), + AutomationFixture({id: '2', name: 'Project Alert'}), + ], + }); + + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/detectors/', + body: [IssueStreamDetectorFixture({workflowIds: ['2']})], + }); + + render(); + + expect(await screen.findByText('2 alerts')).toBeInTheDocument(); + }); + + it('deduplicates automation ids from both sources', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/workflows/', + body: [AutomationFixture({id: '1', name: 'Shared Alert'})], + }); + + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/detectors/', + body: [IssueStreamDetectorFixture({workflowIds: ['1']})], + }); + + render(); + + expect(await screen.findByText('1 alert')).toBeInTheDocument(); + }); + + it('renders empty cell when no automations are connected', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/workflows/', + body: [], + }); + + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/detectors/', + body: [IssueStreamDetectorFixture({workflowIds: []})], + }); + + render(); + + // EmptyCell renders an em dash + expect(await screen.findByText('—')).toBeInTheDocument(); + }); + + it('shows automation names in hovercard on hover', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/workflows/', + body: [ + AutomationFixture({id: '1', name: 'Direct Alert'}), + AutomationFixture({id: '2', name: 'Project Alert'}), + ], + }); + + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/detectors/', + body: [IssueStreamDetectorFixture({workflowIds: ['2']})], + }); + + render(); + + await userEvent.hover(await screen.findByText('2 alerts')); + + const directAlert = await screen.findByText('Direct Alert'); + const projectAlert = screen.getByText('Project Alert'); + + expect(directAlert.closest('a')).toHaveAttribute( + 'href', + '/organizations/org-slug/monitors/alerts/1/' + ); + expect(projectAlert.closest('a')).toHaveAttribute( + 'href', + '/organizations/org-slug/monitors/alerts/2/' + ); + }); +}); diff --git a/static/app/views/detectors/components/detectorListConnectedAutomations.tsx b/static/app/views/detectors/components/detectorListConnectedAutomations.tsx index 912396682c29f2..1fd7c88a5e5ee4 100644 --- a/static/app/views/detectors/components/detectorListConnectedAutomations.tsx +++ b/static/app/views/detectors/components/detectorListConnectedAutomations.tsx @@ -1,3 +1,4 @@ +import {useMemo} from 'react'; import {ClassNames} from '@emotion/react'; import styled from '@emotion/styled'; @@ -15,9 +16,11 @@ import {AutomationActionSummary} from 'sentry/views/automations/components/autom import {useAutomationsQuery} from 'sentry/views/automations/hooks'; import {getAutomationActions} from 'sentry/views/automations/hooks/utils'; import {makeAutomationDetailsPathname} from 'sentry/views/automations/pathnames'; +import {useIssueStreamDetectorsForProject} from 'sentry/views/detectors/utils/useIssueStreamDetectorsForProject'; type DetectorListConnectedAutomationsProps = { automationIds: string[]; + projectId: string; }; function ConnectedAutomationsHoverBody({automationIds}: {automationIds: string[]}) { @@ -27,6 +30,9 @@ function ConnectedAutomationsHoverBody({automationIds}: {automationIds: string[] ids: automationIds.slice(0, 5), }); const hasMore = automationIds.length > 5; + const hasMoreText = hasMore ? ( + {tn('%s more', '%s more', automationIds.length - 5)} + ) : null; if (isError) { return ; @@ -41,6 +47,7 @@ function ConnectedAutomationsHoverBody({automationIds}: {automationIds: string[] ))} + {hasMoreText} ); } @@ -65,17 +72,32 @@ function ConnectedAutomationsHoverBody({automationIds}: {automationIds: string[] ); })} - {hasMore && ( - {tn('%s more', '%s more', automationIds.length - 5)} - )} + {hasMoreText} ); } export function DetectorListConnectedAutomations({ automationIds, + projectId, }: DetectorListConnectedAutomationsProps) { - if (!automationIds.length) { + const {data: issueStreamDetectors, isPending} = + useIssueStreamDetectorsForProject(projectId); + + // Combine the automation IDs from the project's issue stream detector with the directly-connected ones + const combinedAutomationIds = useMemo(() => { + if (isPending) { + return automationIds; + } + const issueStreamAutomationIds = issueStreamDetectors?.[0]?.workflowIds ?? []; + return [...new Set([...automationIds, ...issueStreamAutomationIds])]; + }, [automationIds, issueStreamDetectors, isPending]); + + if (isPending) { + return ; + } + + if (!combinedAutomationIds.length) { return ; } @@ -84,13 +106,13 @@ export function DetectorListConnectedAutomations({ {({css}) => ( } + body={} bodyClassName={css` padding: 0; `} showUnderline > - {tn('%s alert', '%s alerts', automationIds.length)} + {tn('%s alert', '%s alerts', combinedAutomationIds.length)} )} diff --git a/static/app/views/detectors/components/detectorListTable/detectorListRow.tsx b/static/app/views/detectors/components/detectorListTable/detectorListRow.tsx index 72c082bbccb326..43056f88ff8a63 100644 --- a/static/app/views/detectors/components/detectorListTable/detectorListRow.tsx +++ b/static/app/views/detectors/components/detectorListTable/detectorListRow.tsx @@ -52,7 +52,10 @@ export function DetectorListRow({detector, selected, onSelect}: DetectorListRowP - + {additionalColumns.map(col => ( {col.renderCell(detector)} diff --git a/static/app/views/detectors/utils/useIssueStreamDetectorsForProject.tsx b/static/app/views/detectors/utils/useIssueStreamDetectorsForProject.tsx index a2287f4c0b1537..fc9656bbbc03b3 100644 --- a/static/app/views/detectors/utils/useIssueStreamDetectorsForProject.tsx +++ b/static/app/views/detectors/utils/useIssueStreamDetectorsForProject.tsx @@ -5,9 +5,14 @@ import {useDetectorsQuery} from 'sentry/views/detectors/hooks'; * Issue stream detectors are used to connect automations to "all issues in a project". */ export function useIssueStreamDetectorsForProject(projectId: string | undefined) { - return useDetectorsQuery({ - query: 'type:issue_stream', - projects: [Number(projectId)], - includeIssueStreamDetectors: true, - }); + return useDetectorsQuery( + { + query: 'type:issue_stream', + projects: [Number(projectId)], + includeIssueStreamDetectors: true, + }, + { + staleTime: Infinity, + } + ); }