Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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(<DetectorListConnectedAutomations automationIds={['1']} projectId="1" />);

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(<DetectorListConnectedAutomations automationIds={['1']} projectId="1" />);

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(<DetectorListConnectedAutomations automationIds={[]} projectId="1" />);

// 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(<DetectorListConnectedAutomations automationIds={['1']} projectId="1" />);

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/'
);
});
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {useMemo} from 'react';
import {ClassNames} from '@emotion/react';
import styled from '@emotion/styled';

Expand All @@ -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;
Comment on lines 22 to +23
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: could we use an Indexed Access Type for these? Project["id"] kind of thing, so we can rely on a single type defintion?

};

function ConnectedAutomationsHoverBody({automationIds}: {automationIds: string[]}) {
Expand All @@ -27,6 +30,9 @@ function ConnectedAutomationsHoverBody({automationIds}: {automationIds: string[]
ids: automationIds.slice(0, 5),
});
const hasMore = automationIds.length > 5;
const hasMoreText = hasMore ? (
<MoreText>{tn('%s more', '%s more', automationIds.length - 5)}</MoreText>
) : null;

if (isError) {
return <LoadingError />;
Expand All @@ -41,6 +47,7 @@ function ConnectedAutomationsHoverBody({automationIds}: {automationIds: string[]
<Placeholder height="18px" width="40%" />
</Stack>
))}
{hasMoreText}
</div>
);
}
Expand All @@ -65,17 +72,32 @@ function ConnectedAutomationsHoverBody({automationIds}: {automationIds: string[]
</HovercardRow>
);
})}
{hasMore && (
<MoreText>{tn('%s more', '%s more', automationIds.length - 5)}</MoreText>
)}
{hasMoreText}
</div>
);
}

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 <Placeholder height="20px" />;
}

if (!combinedAutomationIds.length) {
return <EmptyCell />;
}

Expand All @@ -84,13 +106,13 @@ export function DetectorListConnectedAutomations({
<ClassNames>
{({css}) => (
<Hovercard
body={<ConnectedAutomationsHoverBody automationIds={automationIds} />}
body={<ConnectedAutomationsHoverBody automationIds={combinedAutomationIds} />}
bodyClassName={css`
padding: 0;
`}
showUnderline
>
{tn('%s alert', '%s alerts', automationIds.length)}
{tn('%s alert', '%s alerts', combinedAutomationIds.length)}
</Hovercard>
)}
</ClassNames>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ export function DetectorListRow({detector, selected, onSelect}: DetectorListRowP
<DetectorAssigneeCell assignee={detector.owner} />
</SimpleTable.RowCell>
<SimpleTable.RowCell data-column-name="connected-automations">
<DetectorListConnectedAutomations automationIds={detector.workflowIds} />
<DetectorListConnectedAutomations
automationIds={detector.workflowIds}
projectId={detector.projectId}
Comment on lines +56 to +57
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: should we just pass the detector as the prop so we can strictly type it and have access to any other data we might need?

/>
</SimpleTable.RowCell>
{additionalColumns.map(col => (
<Fragment key={col.id}>{col.renderCell(detector)}</Fragment>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just curious.. how hard would it be to rename the IssueStream to Issue Feed for ui components? (something tells me that issue stream isn't going to stick around, so mostly just trying to understand the blast radius)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only user-facing place that we show "issue stream" is when connecting monitors to an alert:

CleanShot 2026-03-26 at 15 42 09@2x

The blast radius is pretty small

projects: [Number(projectId)],
includeIssueStreamDetectors: true,
},
{
staleTime: Infinity,
}
);
}
Loading