From b34375568fc5f102426b2c2b01060f81ffebca70 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Wed, 27 May 2026 16:01:27 +0200 Subject: [PATCH 1/4] ref(settings): Replace legacy FieldGroup with Scraps layout primitives in service hooks Migrate projectServiceHooks and projectServiceHookDetails away from the legacy FieldGroup form component. Both files used FieldGroup purely for presentational layout (no form submission), so replace with Flex and Text primitives from @sentry/scraps. Co-Authored-By: Claude Opus 4.6 --- .../project/projectServiceHookDetails.tsx | 49 ++++++++++++------- .../settings/project/projectServiceHooks.tsx | 34 +++++++------ 2 files changed, 49 insertions(+), 34 deletions(-) diff --git a/static/app/views/settings/project/projectServiceHookDetails.tsx b/static/app/views/settings/project/projectServiceHookDetails.tsx index e50af26d21baa1..67d0931c6ee03b 100644 --- a/static/app/views/settings/project/projectServiceHookDetails.tsx +++ b/static/app/views/settings/project/projectServiceHookDetails.tsx @@ -2,6 +2,8 @@ import {Fragment, useState} from 'react'; import {useMutation} from '@tanstack/react-query'; import {Button} from '@sentry/scraps/button'; +import {Flex} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; import { addErrorMessage, @@ -11,7 +13,6 @@ import { import {MiniBarChart} from 'sentry/components/charts/miniBarChart'; import {EmptyMessage} from 'sentry/components/emptyMessage'; import {ErrorBoundary} from 'sentry/components/errorBoundary'; -import {FieldGroup} from 'sentry/components/forms/fieldGroup'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {Panel} from 'sentry/components/panels/panel'; @@ -195,29 +196,41 @@ export default function ProjectServiceHookDetails() { HMAC(SHA256, [secret], [payload]). You should always verify this signature before trusting the information provided in the webhook. - + + + {t('Secret')} + + {t('The shared secret used for generating event HMAC signatures.')} + + {hook.secret} - + {t('Delete Hook')} - -
- -
-
+ + + {t('Delete Hook')} + + {t('Removing this hook is immediate and permanent.')} + + + +
+ +
+
+
diff --git a/static/app/views/settings/project/projectServiceHooks.tsx b/static/app/views/settings/project/projectServiceHooks.tsx index c632999ba8b843..7f87db9861cda3 100644 --- a/static/app/views/settings/project/projectServiceHooks.tsx +++ b/static/app/views/settings/project/projectServiceHooks.tsx @@ -2,8 +2,10 @@ import {Fragment} from 'react'; import {useMutation, useQueryClient} from '@tanstack/react-query'; import {LinkButton} from '@sentry/scraps/button'; +import {Flex} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; import {Switch} from '@sentry/scraps/switch'; +import {Text} from '@sentry/scraps/text'; import { addErrorMessage, @@ -11,7 +13,6 @@ import { clearIndicators, } from 'sentry/actionCreators/indicator'; import {EmptyMessage} from 'sentry/components/emptyMessage'; -import {FieldGroup} from 'sentry/components/forms/fieldGroup'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {Panel} from 'sentry/components/panels/panel'; @@ -38,27 +39,28 @@ type RowProps = { function ServiceHookRow({orgId, projectId, hook, onToggleActive}: RowProps) { return ( - + - } - help={ - - {hook.events && hook.events.length !== 0 ? ( - hook.events.join(', ') - ) : ( - {t('no events configured')} - )} - - } - > - - + {hook.events && hook.events.length !== 0 ? ( + + {hook.events.join(', ')} + + ) : ( + + {t('no events configured')} + + )} + + + + + ); } From b0c6dd90c6e4c11168009bf5b11dbd4ca963b3c9 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Wed, 27 May 2026 16:22:35 +0200 Subject: [PATCH 2/4] ref: use gap over padding --- .../settings/project/projectServiceHookDetails.tsx | 12 +++--------- .../views/settings/project/projectServiceHooks.tsx | 6 +++--- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/static/app/views/settings/project/projectServiceHookDetails.tsx b/static/app/views/settings/project/projectServiceHookDetails.tsx index 67d0931c6ee03b..a84ec63898ceb1 100644 --- a/static/app/views/settings/project/projectServiceHookDetails.tsx +++ b/static/app/views/settings/project/projectServiceHookDetails.tsx @@ -210,20 +210,14 @@ export default function ProjectServiceHookDetails() { {t('Delete Hook')} - - + + {t('Delete Hook')} {t('Removing this hook is immediate and permanent.')} - +
-
-
-
-
-
- - ); -} diff --git a/static/app/views/settings/project/projectServiceHooks.tsx b/static/app/views/settings/project/projectServiceHooks.tsx deleted file mode 100644 index 3945e4fe94e0d4..00000000000000 --- a/static/app/views/settings/project/projectServiceHooks.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import {Fragment} from 'react'; -import {useMutation, useQueryClient} from '@tanstack/react-query'; - -import {LinkButton} from '@sentry/scraps/button'; -import {Flex} from '@sentry/scraps/layout'; -import {Link} from '@sentry/scraps/link'; -import {Switch} from '@sentry/scraps/switch'; -import {Text} from '@sentry/scraps/text'; - -import { - addErrorMessage, - addLoadingMessage, - clearIndicators, -} from 'sentry/actionCreators/indicator'; -import {EmptyMessage} from 'sentry/components/emptyMessage'; -import {LoadingError} from 'sentry/components/loadingError'; -import {LoadingIndicator} from 'sentry/components/loadingIndicator'; -import {Panel} from 'sentry/components/panels/panel'; -import {PanelAlert} from 'sentry/components/panels/panelAlert'; -import {PanelBody} from 'sentry/components/panels/panelBody'; -import {PanelHeader} from 'sentry/components/panels/panelHeader'; -import {Truncate} from 'sentry/components/truncate'; -import {IconAdd} from 'sentry/icons'; -import {t} from 'sentry/locale'; -import type {ServiceHook} from 'sentry/types/integrations'; -import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import {setApiQueryData, useApiQuery} from 'sentry/utils/queryClient'; -import {useApi} from 'sentry/utils/useApi'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import {useParams} from 'sentry/utils/useParams'; -import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageHeader'; - -type RowProps = { - hook: ServiceHook; - onToggleActive: () => void; - orgId: string; - projectId: string; -}; - -function ServiceHookRow({orgId, projectId, hook, onToggleActive}: RowProps) { - return ( - - - - - - {hook.events && hook.events.length !== 0 ? ( - - {hook.events.join(', ')} - - ) : ( - - {t('no events configured')} - - )} - - - - - - ); -} - -export default function ProjectServiceHooks() { - const organization = useOrganization(); - const {projectId} = useParams<{projectId: string}>(); - const api = useApi({persistInFlight: true}); - const queryClient = useQueryClient(); - - const { - data: hookList, - isPending, - isError, - refetch, - } = useApiQuery( - [ - getApiUrl('/projects/$organizationIdOrSlug/$projectIdOrSlug/hooks/', { - path: {organizationIdOrSlug: organization.slug, projectIdOrSlug: projectId}, - }), - ], - { - staleTime: 0, - } - ); - - const onToggleActiveMutation = useMutation({ - mutationFn: ({hook}: {hook: ServiceHook}) => { - return api.requestPromise( - `/projects/${organization.slug}/${projectId}/hooks/${hook.id}/`, - { - method: 'PUT', - data: { - isActive: hook.status !== 'active', - }, - } - ); - }, - onMutate: () => { - addLoadingMessage(t('Saving changes\u2026')); - }, - onSuccess: data => { - clearIndicators(); - setApiQueryData( - queryClient, - [ - getApiUrl('/projects/$organizationIdOrSlug/$projectIdOrSlug/hooks/', { - path: {organizationIdOrSlug: organization.slug, projectIdOrSlug: projectId}, - }), - ], - oldHookList => { - return oldHookList?.map(h => { - if (h.id === data.id) { - return { - ...h, - ...data, - }; - } - return h; - }); - } - ); - }, - onError: () => { - addErrorMessage(t('Unable to remove application. Please try again.')); - }, - }); - - if (isPending) { - return ; - } - - if (isError) { - return ; - } - - const renderEmpty = () => { - return ( - - {t('There are no service hooks associated with this project.')} - - ); - }; - - const renderResults = () => { - return ( - - {t('Service Hook')} - - - {t( - 'Service Hooks are an early adopter preview feature and will change in the future.' - )} - - {hookList?.map(hook => ( - onToggleActiveMutation.mutate({hook})} - /> - ))} - - - ); - }; - - const body = hookList && hookList.length > 0 ? renderResults() : renderEmpty(); - - return ( - - } - > - {t('Create New Hook')} - - ) : null - } - /> - {body} - - ); -} diff --git a/static/app/views/settings/project/serviceHookSettingsForm.tsx b/static/app/views/settings/project/serviceHookSettingsForm.tsx deleted file mode 100644 index c6ffb10c49586c..00000000000000 --- a/static/app/views/settings/project/serviceHookSettingsForm.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import {ApiForm} from 'sentry/components/forms/apiForm'; -import {MultipleCheckbox} from 'sentry/components/forms/controls/multipleCheckbox'; -import {BooleanField} from 'sentry/components/forms/fields/booleanField'; -import {TextField} from 'sentry/components/forms/fields/textField'; -import {FormField} from 'sentry/components/forms/formField'; -import {Panel} from 'sentry/components/panels/panel'; -import {PanelBody} from 'sentry/components/panels/panelBody'; -import {PanelHeader} from 'sentry/components/panels/panelHeader'; -import {t} from 'sentry/locale'; -import type {ServiceHook} from 'sentry/types/integrations'; -import type {Organization} from 'sentry/types/organization'; -import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; -import {useNavigate} from 'sentry/utils/useNavigate'; - -const EVENT_CHOICES = ['event.alert', 'event.created']; - -type Props = { - initialData: Partial & {isActive: boolean}; - organization: Organization; - projectId: string; - hookId?: string; -}; - -export function ServiceHookSettingsForm({ - initialData, - organization, - projectId, - hookId, -}: Props) { - const navigate = useNavigate(); - - const onSubmitSuccess = () => { - navigate(normalizeUrl(`/settings/${organization.slug}/projects/${projectId}/hooks/`)); - }; - - const endpoint = hookId - ? `/projects/${organization.slug}/${projectId}/hooks/${hookId}/` - : `/projects/${organization.slug}/${projectId}/hooks/`; - - return ( - - - {t('Hook Configuration')} - - - - - {({name, value, onChange}: any) => ( - - {EVENT_CHOICES.map(event => ( - - {event} - - ))} - - )} - - - - - ); -} From 75e463433f355bf04449a51db50158d335c60437 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Wed, 27 May 2026 17:54:06 +0200 Subject: [PATCH 4/4] fix: remove acceptance tests for deleted forms --- tests/acceptance/test_project_servicehooks.py | 48 ------------------- 1 file changed, 48 deletions(-) delete mode 100644 tests/acceptance/test_project_servicehooks.py diff --git a/tests/acceptance/test_project_servicehooks.py b/tests/acceptance/test_project_servicehooks.py deleted file mode 100644 index 24dc8b309db960..00000000000000 --- a/tests/acceptance/test_project_servicehooks.py +++ /dev/null @@ -1,48 +0,0 @@ -from sentry.sentry_apps.models.servicehook import ServiceHook -from sentry.testutils.cases import AcceptanceTestCase -from sentry.testutils.silo import no_silo_test - - -@no_silo_test -class ProjectServiceHooksTest(AcceptanceTestCase): - def setUp(self) -> None: - super().setUp() - self.user = self.create_user("foo@example.com") - self.org = self.create_organization(name="Rowdy Tiger", owner=None) - self.team = self.create_team(organization=self.org, name="Mariachi Band") - self.project = self.create_project(organization=self.org, teams=[self.team], name="Bengal") - self.create_member(user=self.user, organization=self.org, role="owner", teams=[self.team]) - - self.login_as(self.user) - self.list_hooks_path = f"/settings/{self.org.slug}/projects/{self.project.slug}/hooks/" - self.new_hook_path = f"/settings/{self.org.slug}/projects/{self.project.slug}/hooks/new/" - - def test_simple(self) -> None: - with self.feature("projects:servicehooks"): - self.browser.get(self.list_hooks_path) - self.browser.wait_until_not('[data-test-id="loading-indicator"]') - # click "New" - self.browser.click('[data-test-id="new-service-hook"]') - - self.browser.wait_until_not('[data-test-id="loading-indicator"]') - assert self.browser.current_url == f"{self.browser.live_server_url}{self.new_hook_path}" - self.browser.element('input[name="url"]').send_keys("https://example.com/hook") - # click "Save Changes" - self.browser.click('form [data-test-id="form-submit"]') - - self.browser.wait_until_not('[data-test-id="loading-indicator"]') - assert ( - self.browser.current_url == f"{self.browser.live_server_url}{self.list_hooks_path}" - ) - - hook = ServiceHook.objects.get(project_id=self.project.id) - assert hook.url == "https://example.com/hook" - assert not hook.events - - # hopefully click the first service hook - self.browser.click('[data-test-id="project-service-hook"]') - self.browser.wait_until_not('[data-test-id="loading-indicator"]') - assert self.browser.current_url == "{}{}".format( - self.browser.live_server_url, - f"/settings/{self.org.slug}/projects/{self.project.slug}/hooks/{hook.guid}/", - )