diff --git a/static/app/components/workflowEngine/layout/edit.tsx b/static/app/components/workflowEngine/layout/edit.tsx index d25aa7a7dff28c..347db7b256953a 100644 --- a/static/app/components/workflowEngine/layout/edit.tsx +++ b/static/app/components/workflowEngine/layout/edit.tsx @@ -81,7 +81,7 @@ function Title({title, project}: {title: string; project?: AvatarProject}) { function Actions({children}: RequiredChildren) { return ( - {children} + {children} ); } diff --git a/static/app/types/workflowEngine/detectors.tsx b/static/app/types/workflowEngine/detectors.tsx index e49f93f5364feb..6f759ef1a323e3 100644 --- a/static/app/types/workflowEngine/detectors.tsx +++ b/static/app/types/workflowEngine/detectors.tsx @@ -131,7 +131,7 @@ type BaseDetector = Readonly<{ createdBy: string | null; dateCreated: string; dateUpdated: string; - disabled: boolean; + enabled: boolean; id: string; lastTriggered: string; name: string; @@ -197,6 +197,7 @@ export interface BaseDetectorUpdatePayload { projectId: Detector['projectId']; type: Detector['type']; workflowIds: string[]; + enabled?: boolean; } export interface UptimeDetectorUpdatePayload extends BaseDetectorUpdatePayload { diff --git a/static/app/views/automations/components/editAutomationActions.tsx b/static/app/views/automations/components/editAutomationActions.tsx index 3f412cacbc558e..11047f06fc1b55 100644 --- a/static/app/views/automations/components/editAutomationActions.tsx +++ b/static/app/views/automations/components/editAutomationActions.tsx @@ -34,9 +34,9 @@ export function EditAutomationActions({automation}: EditAutomationActionsProps) enabled: newEnabled, }, { - onSuccess: () => { + onSuccess: data => { addSuccessMessage( - newEnabled ? t('Automation enabled') : t('Automation disabled') + data.enabled ? t('Automation enabled') : t('Automation disabled') ); }, } @@ -58,16 +58,15 @@ export function EditAutomationActions({automation}: EditAutomationActionsProps) return (
- + ); } @@ -54,3 +78,37 @@ export function EditDetectorAction({detector}: {detector: Detector}) { ); } + +export function DeleteDetectorAction({detector}: {detector: Detector}) { + const organization = useOrganization(); + const navigate = useNavigate(); + const {mutateAsync: deleteDetector, isPending: isDeleting} = + useDeleteDetectorMutation(); + + const handleDelete = useCallback(() => { + openConfirmModal({ + message: t('Are you sure you want to delete this monitor?'), + confirmText: t('Delete'), + priority: 'danger', + onConfirm: async () => { + await deleteDetector(detector.id); + navigate(makeMonitorBasePathname(organization.slug)); + }, + }); + }, [deleteDetector, detector.id, navigate, organization.slug]); + + const canEdit = useCanEditDetector({ + detectorType: detector.type, + projectId: detector.projectId, + }); + + if (!canEdit) { + return null; + } + + return ( + + ); +} diff --git a/static/app/views/detectors/components/detectorListTable/detectorListRow.tsx b/static/app/views/detectors/components/detectorListTable/detectorListRow.tsx index 456aed8878629e..e2d08b6c6600b5 100644 --- a/static/app/views/detectors/components/detectorListTable/detectorListRow.tsx +++ b/static/app/views/detectors/components/detectorListTable/detectorListRow.tsx @@ -19,7 +19,7 @@ export function DetectorListRow({detector}: DetectorListRowProps) { return ( diff --git a/static/app/views/detectors/components/forms/editDetectorActions.spec.tsx b/static/app/views/detectors/components/forms/editDetectorActions.spec.tsx deleted file mode 100644 index 16e7af578163e4..00000000000000 --- a/static/app/views/detectors/components/forms/editDetectorActions.spec.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import {MetricDetectorFixture} from 'sentry-fixture/detectors'; -import {OrganizationFixture} from 'sentry-fixture/organization'; - -import { - render, - renderGlobalModal, - screen, - userEvent, - within, -} from 'sentry-test/reactTestingLibrary'; - -import {EditDetectorActions} from './editDetectorActions'; - -describe('EditDetectorActions', () => { - it('calls delete mutation when deletion is confirmed', async () => { - const detector = MetricDetectorFixture(); - const organization = OrganizationFixture(); - - const mockDeleteDetector = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/detectors/${detector.id}/`, - method: 'DELETE', - }); - - const {router} = render(); - renderGlobalModal(); - - await userEvent.click(screen.getByRole('button', {name: 'Delete'})); - - // Confirm the deletion - const dialog = await screen.findByRole('dialog'); - await userEvent.click(within(dialog).getByRole('button', {name: 'Delete'})); - - expect(mockDeleteDetector).toHaveBeenCalledWith( - `/organizations/${organization.slug}/detectors/${detector.id}/`, - expect.anything() - ); - - // Redirect to the monitors list - expect(router.location.pathname).toBe( - `/organizations/${organization.slug}/issues/monitors/` - ); - }); -}); diff --git a/static/app/views/detectors/components/forms/editDetectorActions.tsx b/static/app/views/detectors/components/forms/editDetectorActions.tsx deleted file mode 100644 index 859f566a49cfa2..00000000000000 --- a/static/app/views/detectors/components/forms/editDetectorActions.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import {useCallback} from 'react'; - -import {openConfirmModal} from 'sentry/components/confirm'; -import {Button} from 'sentry/components/core/button'; -import {ButtonBar} from 'sentry/components/core/button/buttonBar'; -import {t} from 'sentry/locale'; -import type {Detector} from 'sentry/types/workflowEngine/detectors'; -import {useNavigate} from 'sentry/utils/useNavigate'; -import useOrganization from 'sentry/utils/useOrganization'; -import {useDeleteDetectorMutation} from 'sentry/views/detectors/hooks/useDeleteDetectorMutation'; -import {makeMonitorBasePathname} from 'sentry/views/detectors/pathnames'; - -interface EditDetectorActionsProps { - detectorId: Detector['id']; -} - -export function EditDetectorActions({detectorId}: EditDetectorActionsProps) { - const organization = useOrganization(); - const navigate = useNavigate(); - const {mutate: deleteDetector, isPending: isDeleting} = useDeleteDetectorMutation(); - - const handleDelete = useCallback(() => { - openConfirmModal({ - message: t('Are you sure you want to delete this monitor?'), - confirmText: t('Delete'), - priority: 'danger', - onConfirm: () => { - deleteDetector(detectorId, { - onSuccess: () => { - navigate(makeMonitorBasePathname(organization.slug)); - }, - }); - }, - }); - }, [deleteDetector, detectorId, navigate, organization.slug]); - - return ( -
- - - - - -
- ); -} diff --git a/static/app/views/detectors/components/forms/editDetectorLayout.tsx b/static/app/views/detectors/components/forms/editDetectorLayout.tsx index b7ab3c2f9a33ca..8bb3fdefe639ed 100644 --- a/static/app/views/detectors/components/forms/editDetectorLayout.tsx +++ b/static/app/views/detectors/components/forms/editDetectorLayout.tsx @@ -1,14 +1,19 @@ import {useMemo} from 'react'; +import {Button} from 'sentry/components/core/button'; import type {Data} from 'sentry/components/forms/types'; import EditLayout from 'sentry/components/workflowEngine/layout/edit'; +import {t} from 'sentry/locale'; import type { BaseDetectorUpdatePayload, Detector, } from 'sentry/types/workflowEngine/detectors'; +import { + DeleteDetectorAction, + DisableDetectorAction, +} from 'sentry/views/detectors/components/details/common/actions'; import {EditDetectorBreadcrumbs} from 'sentry/views/detectors/components/forms/common/breadcrumbs'; import {DetectorBaseFields} from 'sentry/views/detectors/components/forms/detectorBaseFields'; -import {EditDetectorActions} from 'sentry/views/detectors/components/forms/editDetectorActions'; import {useEditDetectorFormSubmit} from 'sentry/views/detectors/hooks/useEditDetectorFormSubmit'; type EditDetectorLayoutProps = { @@ -52,7 +57,11 @@ export function EditDetectorLayout< - + + + diff --git a/static/app/views/detectors/edit.spec.tsx b/static/app/views/detectors/edit.spec.tsx index dbd5346b6017c1..56aaec904af6bd 100644 --- a/static/app/views/detectors/edit.spec.tsx +++ b/static/app/views/detectors/edit.spec.tsx @@ -6,6 +6,7 @@ import {ProjectFixture} from 'sentry-fixture/project'; import { render, + renderGlobalModal, screen, userEvent, waitFor, @@ -20,7 +21,7 @@ describe('DetectorEdit', () => { const organization = OrganizationFixture({ features: ['workflow-engine-ui', 'visibility-explore-view'], }); - const project = ProjectFixture({organization, environments: ['production']}); + const project = ProjectFixture({id: '1', organization, environments: ['production']}); const initialRouterConfig = { route: '/organizations/:orgId/issues/monitors/:detectorId/edit/', location: { @@ -34,6 +35,12 @@ describe('DetectorEdit', () => { ProjectsStore.loadInitialData([project]); MockApiClient.clearMockResponses(); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/projects/`, + body: [project], + }); + MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/members/`, body: [], @@ -87,6 +94,88 @@ describe('DetectorEdit', () => { }); }); + describe('EditDetectorActions', () => { + const mockDetector = MetricDetectorFixture(); + + it('calls delete mutation when deletion is confirmed', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/detectors/${mockDetector.id}/`, + body: mockDetector, + }); + + const mockDeleteDetector = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/detectors/${mockDetector.id}/`, + method: 'DELETE', + }); + + const {router} = render(, {organization, initialRouterConfig}); + renderGlobalModal(); + + expect( + await screen.findByRole('link', {name: mockDetector.name}) + ).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', {name: 'Delete'})); + + // Confirm the deletion + const dialog = await screen.findByRole('dialog'); + await userEvent.click(within(dialog).getByRole('button', {name: 'Delete'})); + + expect(mockDeleteDetector).toHaveBeenCalledWith( + `/organizations/${organization.slug}/detectors/${mockDetector.id}/`, + expect.anything() + ); + + // Redirect to the monitors list + expect(router.location.pathname).toBe( + `/organizations/${organization.slug}/issues/monitors/` + ); + }); + + it('calls update mutation when enabling/disabling automation', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/detectors/${mockDetector.id}/`, + body: mockDetector, + }); + + const mockUpdateDetector = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/detectors/${mockDetector.id}/`, + method: 'PUT', + body: {...mockDetector, enabled: !mockDetector.enabled}, + }); + + render(, {organization, initialRouterConfig}); + + expect( + await screen.findByRole('link', {name: mockDetector.name}) + ).toBeInTheDocument(); + + // Wait for the component to load and display automation actions + expect(await screen.findByRole('button', {name: 'Disable'})).toBeInTheDocument(); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/detectors/${mockDetector.id}/`, + body: {...mockDetector, enabled: !mockDetector.enabled}, + }); + + // Click the toggle button to enable/disable the automation + await userEvent.click(screen.getByRole('button', {name: 'Disable'})); + + // Verify the mutation was called with correct data + await waitFor(() => { + expect(mockUpdateDetector).toHaveBeenCalledWith( + `/organizations/${organization.slug}/detectors/${mockDetector.id}/`, + expect.objectContaining({ + data: {detectorId: mockDetector.id, enabled: !mockDetector.enabled}, + }) + ); + }); + + // Verify the button text has changed to "Enable" + expect(await screen.findByRole('button', {name: 'Enable'})).toBeInTheDocument(); + }); + }); + describe('Error', () => { const name = 'Test Error Detector'; const mockDetector = ErrorDetectorFixture({id: '1', name, projectId: project.id}); diff --git a/static/app/views/detectors/hooks/index.ts b/static/app/views/detectors/hooks/index.ts index 2c3330f95bb6aa..418fe5e2c78e3e 100644 --- a/static/app/views/detectors/hooks/index.ts +++ b/static/app/views/detectors/hooks/index.ts @@ -95,7 +95,11 @@ export function useUpdateDetector() { const api = useApi({persistInFlight: true}); const queryClient = useQueryClient(); - return useMutation({ + return useMutation< + Detector, + void, + {detectorId: string} & Partial + >({ mutationFn: data => api.requestPromise(`/organizations/${org.slug}/detectors/${data.detectorId}/`, { method: 'PUT', diff --git a/tests/js/fixtures/detectors.ts b/tests/js/fixtures/detectors.ts index c59dd3a73ac27d..9a543e39421a64 100644 --- a/tests/js/fixtures/detectors.ts +++ b/tests/js/fixtures/detectors.ts @@ -30,7 +30,7 @@ export function MetricDetectorFixture( thresholdPeriod: 1, }, type: 'metric_issue', - disabled: false, + enabled: true, conditionGroup: params.conditionGroup ?? DataConditionGroupFixture(), dataSources: params.dataSources ?? [SnubaQueryDataSourceFixture()], owner: null, @@ -45,7 +45,7 @@ export function ErrorDetectorFixture(params: Partial = {}): Error createdBy: null, dateCreated: '2025-01-01T00:00:00.000Z', dateUpdated: '2025-01-01T00:00:00.000Z', - disabled: false, + enabled: true, id: '2', lastTriggered: '2025-01-01T00:00:00.000Z', owner: null, @@ -64,7 +64,7 @@ export function UptimeDetectorFixture( createdBy: null, dateCreated: '2025-01-01T00:00:00.000Z', dateUpdated: '2025-01-01T00:00:00.000Z', - disabled: false, + enabled: true, id: '3', lastTriggered: '2025-01-01T00:00:00.000Z', owner: null, @@ -131,7 +131,7 @@ export function CronDetectorFixture(params: Partial = {}): CronDet createdBy: null, dateCreated: '2025-01-01T00:00:00.000Z', dateUpdated: '2025-01-01T00:00:00.000Z', - disabled: false, + enabled: true, id: '3', lastTriggered: '2025-01-01T00:00:00.000Z', owner: null,