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,
+ }
+ );
}