From 0e4385ea746d7e7e719bb92c7283369437b21a2c Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Wed, 8 Jul 2026 10:18:40 +0200 Subject: [PATCH 1/9] ref(forms): Migrate organization join request off deprecatedforms to scraps form --- .../organizationJoinRequest/index.spec.tsx | 1 + .../views/organizationJoinRequest/index.tsx | 111 ++++++++++++------ 2 files changed, 73 insertions(+), 39 deletions(-) diff --git a/static/app/views/organizationJoinRequest/index.spec.tsx b/static/app/views/organizationJoinRequest/index.spec.tsx index a049fe049828..e748481b14d2 100644 --- a/static/app/views/organizationJoinRequest/index.spec.tsx +++ b/static/app/views/organizationJoinRequest/index.spec.tsx @@ -8,6 +8,7 @@ import OrganizationJoinRequest from 'sentry/views/organizationJoinRequest'; jest.mock('sentry/utils/analytics', () => ({ trackAdhocEvent: jest.fn(), + trackAnalytics: jest.fn(), })); jest.mock('sentry/actionCreators/indicator'); diff --git a/static/app/views/organizationJoinRequest/index.tsx b/static/app/views/organizationJoinRequest/index.tsx index 088075f83351..8111dded7fe9 100644 --- a/static/app/views/organizationJoinRequest/index.tsx +++ b/static/app/views/organizationJoinRequest/index.tsx @@ -1,43 +1,66 @@ -import {useCallback, useState} from 'react'; +import {useState} from 'react'; import type {MouseEvent} from 'react'; import styled from '@emotion/styled'; +import {useMutation} from '@tanstack/react-query'; +import {z} from 'zod'; + +import {Button} from '@sentry/scraps/button'; +import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form'; +import {Flex} from '@sentry/scraps/layout'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; -import {EmailField} from 'sentry/components/forms/fields/emailField'; -import {Form} from 'sentry/components/forms/form'; import {NarrowLayout} from 'sentry/components/narrowLayout'; import {IconMegaphone} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {trackAnalytics} from 'sentry/utils/analytics'; +import {fetchMutation} from 'sentry/utils/queryClient'; import {decodeScalar} from 'sentry/utils/queryString'; import {testableWindowLocation} from 'sentry/utils/testableWindowLocation'; import {useLocation} from 'sentry/utils/useLocation'; import {useParams} from 'sentry/utils/useParams'; +const joinRequestSchema = z.object({ + email: z.string().email(t('Please enter a valid email address')), +}); + export default function OrganizationJoinRequest() { const {orgId} = useParams<{orgId: string}>(); const location = useLocation(); const [submitSuccess, setSubmitSuccess] = useState(false); - const handleSubmitSuccess = useCallback(() => { - setSubmitSuccess(true); - trackAnalytics('join_request.created', { - organization: orgId, - referrer: decodeScalar(location.query.referrer, ''), - }); - }, [orgId, location.query.referrer]); - - const handleSubmitError = useCallback(() => { - addErrorMessage(t('Request to join failed')); - }, []); - - const handleCancel = useCallback( - (e: MouseEvent) => { - e.preventDefault(); - testableWindowLocation.assign(`/auth/login/${orgId}/`); + const mutation = useMutation({ + mutationFn: (data: {email: string}) => + fetchMutation({ + url: `/organizations/${orgId}/join-request/`, + method: 'POST', + data, + }), + }); + + const form = useScrapsForm({ + ...defaultFormOptions, + defaultValues: {email: ''}, + validators: {onDynamic: joinRequestSchema}, + onSubmit: async ({value}) => { + try { + await mutation.mutateAsync(value); + } catch { + addErrorMessage(t('Request to join failed')); + return; + } + + setSubmitSuccess(true); + trackAnalytics('join_request.created', { + organization: orgId, + referrer: decodeScalar(location.query.referrer, ''), + }); }, - [orgId] - ); + }); + + const handleCancel = (e: MouseEvent) => { + e.preventDefault(); + testableWindowLocation.assign(`/auth/login/${orgId}/`); + }; if (submitSuccess) { return ( @@ -63,22 +86,32 @@ export default function OrganizationJoinRequest() { orgId, })} -
- - + + + + {field => ( + + + + )} + + + state.isDirty}> + {isDirty => ( + + + + {t('Request to Join')} + + + )} + + ); } @@ -107,7 +140,7 @@ const ReceiveEmailMessage = styled(StyledText)` max-width: 250px; `; -const StyledEmailField = styled(EmailField)` +const FieldWrapper = styled('div')` padding-top: ${p => p.theme.space.xl}; - padding-left: 0; + padding-bottom: ${p => p.theme.space.xl}; `; From 22d935923c9cab61225e3b830254a46927d6249b Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 20 Jul 2026 10:39:55 +0200 Subject: [PATCH 2/9] ref(forms): Replace styled components with scraps primitives in join request --- .../views/organizationJoinRequest/index.tsx | 75 ++++++++----------- 1 file changed, 30 insertions(+), 45 deletions(-) diff --git a/static/app/views/organizationJoinRequest/index.tsx b/static/app/views/organizationJoinRequest/index.tsx index 8111dded7fe9..29710ad9e2f4 100644 --- a/static/app/views/organizationJoinRequest/index.tsx +++ b/static/app/views/organizationJoinRequest/index.tsx @@ -1,12 +1,12 @@ import {useState} from 'react'; import type {MouseEvent} from 'react'; -import styled from '@emotion/styled'; import {useMutation} from '@tanstack/react-query'; import {z} from 'zod'; import {Button} from '@sentry/scraps/button'; import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form'; -import {Flex} from '@sentry/scraps/layout'; +import {Container, Flex, Stack} from '@sentry/scraps/layout'; +import {Heading, Text} from '@sentry/scraps/text'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {NarrowLayout} from 'sentry/components/narrowLayout'; @@ -65,29 +65,43 @@ export default function OrganizationJoinRequest() { if (submitSuccess) { return ( - - - {t('Request Sent')} - {t('Your request to join has been sent.')} - - {t('You will receive an email when your request is approved.')} - - + + + + + + {t('Request Sent')} + + + {t('Your request to join has been sent.')} + + + + {t('You will receive an email when your request is approved.')} + + + ); } return ( - - {t('Request to Join')} - + + + + + + {t('Request to Join')} + + + {tct('Ask the admins if you can join the [orgId] organization.', { orgId, })} - + - + {field => ( @@ -100,7 +114,7 @@ export default function OrganizationJoinRequest() { )} - + state.isDirty}> {isDirty => ( @@ -115,32 +129,3 @@ export default function OrganizationJoinRequest() { ); } - -const SuccessModal = styled('div')` - display: grid; - justify-items: center; - text-align: center; - padding-top: 10px; - padding-bottom: ${p => p.theme.space['3xl']}; -`; - -const StyledIconMegaphone = styled(IconMegaphone)` - padding-bottom: ${p => p.theme.space['2xl']}; -`; - -const StyledHeader = styled('h3')` - margin-bottom: ${p => p.theme.space.md}; -`; - -const StyledText = styled('p')` - margin-bottom: 0; -`; - -const ReceiveEmailMessage = styled(StyledText)` - max-width: 250px; -`; - -const FieldWrapper = styled('div')` - padding-top: ${p => p.theme.space.xl}; - padding-bottom: ${p => p.theme.space.xl}; -`; From 7e2c435214fd062d7e6cfafee1fa80a5c9269f3e Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 20 Jul 2026 10:56:48 +0200 Subject: [PATCH 3/9] ref(forms): Add required indicator and use z.email validator --- static/app/views/organizationJoinRequest/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/app/views/organizationJoinRequest/index.tsx b/static/app/views/organizationJoinRequest/index.tsx index 29710ad9e2f4..93e32f7806d7 100644 --- a/static/app/views/organizationJoinRequest/index.tsx +++ b/static/app/views/organizationJoinRequest/index.tsx @@ -20,7 +20,7 @@ import {useLocation} from 'sentry/utils/useLocation'; import {useParams} from 'sentry/utils/useParams'; const joinRequestSchema = z.object({ - email: z.string().email(t('Please enter a valid email address')), + email: z.email(t('Please enter a valid email address')), }); export default function OrganizationJoinRequest() { @@ -104,7 +104,7 @@ export default function OrganizationJoinRequest() { {field => ( - + Date: Mon, 20 Jul 2026 11:05:49 +0200 Subject: [PATCH 4/9] test(forms): Add email validation test for join request form --- .../organizationJoinRequest/index.spec.tsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/static/app/views/organizationJoinRequest/index.spec.tsx b/static/app/views/organizationJoinRequest/index.spec.tsx index e748481b14d2..a2ea16e9a652 100644 --- a/static/app/views/organizationJoinRequest/index.spec.tsx +++ b/static/app/views/organizationJoinRequest/index.spec.tsx @@ -67,6 +67,32 @@ describe('OrganizationJoinRequest', () => { ).not.toBeInTheDocument(); }); + it('shows validation error for invalid email', async () => { + const postMock = MockApiClient.addMockResponse({ + url: endpoint, + method: 'POST', + }); + + render(, { + initialRouterConfig: { + location: { + pathname: `/join-request/${org.slug}/`, + }, + route: '/join-request/:orgId/', + }, + }); + + await userEvent.type( + screen.getByRole('textbox', {name: 'Email Address'}), + 'not-an-email{enter}' + ); + + expect( + await screen.findByText('Please enter a valid email address') + ).toBeInTheDocument(); + expect(postMock).not.toHaveBeenCalled(); + }); + it('errors', async () => { const postMock = MockApiClient.addMockResponse({ url: endpoint, From 4c8c63e6a1960e5fa37d6303d26293dd5ee8f320 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 20 Jul 2026 11:11:28 +0200 Subject: [PATCH 5/9] ref(forms): Keep submit button always enabled in join request form --- static/app/views/organizationJoinRequest/index.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/static/app/views/organizationJoinRequest/index.tsx b/static/app/views/organizationJoinRequest/index.tsx index 93e32f7806d7..1aa4030f47fe 100644 --- a/static/app/views/organizationJoinRequest/index.tsx +++ b/static/app/views/organizationJoinRequest/index.tsx @@ -115,16 +115,10 @@ export default function OrganizationJoinRequest() { )} - state.isDirty}> - {isDirty => ( - - - - {t('Request to Join')} - - - )} - + + + {t('Request to Join')} + ); From 917cbd7a10fcab8344b8254a6f8b43925d2a3687 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 20 Jul 2026 11:22:54 +0200 Subject: [PATCH 6/9] ref(forms): Use Stack gap and mutation state in join request form --- .../views/organizationJoinRequest/index.tsx | 54 ++++++++----------- 1 file changed, 21 insertions(+), 33 deletions(-) diff --git a/static/app/views/organizationJoinRequest/index.tsx b/static/app/views/organizationJoinRequest/index.tsx index 1aa4030f47fe..252165b30398 100644 --- a/static/app/views/organizationJoinRequest/index.tsx +++ b/static/app/views/organizationJoinRequest/index.tsx @@ -1,4 +1,3 @@ -import {useState} from 'react'; import type {MouseEvent} from 'react'; import {useMutation} from '@tanstack/react-query'; import {z} from 'zod'; @@ -26,7 +25,6 @@ const joinRequestSchema = z.object({ export default function OrganizationJoinRequest() { const {orgId} = useParams<{orgId: string}>(); const location = useLocation(); - const [submitSuccess, setSubmitSuccess] = useState(false); const mutation = useMutation({ mutationFn: (data: {email: string}) => @@ -35,26 +33,22 @@ export default function OrganizationJoinRequest() { method: 'POST', data, }), + onSuccess: () => { + trackAnalytics('join_request.created', { + organization: orgId, + referrer: decodeScalar(location.query.referrer, ''), + }); + }, + onError: () => { + addErrorMessage(t('Request to join failed')); + }, }); const form = useScrapsForm({ ...defaultFormOptions, defaultValues: {email: ''}, validators: {onDynamic: joinRequestSchema}, - onSubmit: async ({value}) => { - try { - await mutation.mutateAsync(value); - } catch { - addErrorMessage(t('Request to join failed')); - return; - } - - setSubmitSuccess(true); - trackAnalytics('join_request.created', { - organization: orgId, - referrer: decodeScalar(location.query.referrer, ''), - }); - }, + onSubmit: ({value}) => mutation.mutateAsync(value).catch(() => {}), }); const handleCancel = (e: MouseEvent) => { @@ -62,16 +56,12 @@ export default function OrganizationJoinRequest() { testableWindowLocation.assign(`/auth/login/${orgId}/`); }; - if (submitSuccess) { + if (mutation.isSuccess) { return ( - - - - - - {t('Request Sent')} - + + + {t('Request Sent')} {t('Your request to join has been sent.')} @@ -87,19 +77,17 @@ export default function OrganizationJoinRequest() { return ( - + - - {t('Request to Join')} - - - {tct('Ask the admins if you can join the [orgId] organization.', { - orgId, - })} - + + {tct('Ask the admins if you can join the [orgId] organization.', { + orgId, + })} + + From 621ea238b05e97ff0240f3ab7628e433bf55e82e Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 20 Jul 2026 11:37:07 +0200 Subject: [PATCH 7/9] ref(forms): Fix icon size, heading size, and footer spacing in join request --- .../views/organizationJoinRequest/index.tsx | 74 +++++++++++-------- 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/static/app/views/organizationJoinRequest/index.tsx b/static/app/views/organizationJoinRequest/index.tsx index 252165b30398..c8165d3691e5 100644 --- a/static/app/views/organizationJoinRequest/index.tsx +++ b/static/app/views/organizationJoinRequest/index.tsx @@ -60,8 +60,12 @@ export default function OrganizationJoinRequest() { return ( - - {t('Request Sent')} + + + + + {t('Request Sent')} + {t('Your request to join has been sent.')} @@ -77,37 +81,43 @@ export default function OrganizationJoinRequest() { return ( - - - - {t('Request to Join')} - - - {tct('Ask the admins if you can join the [orgId] organization.', { - orgId, - })} - + + + + + + {t('Request to Join')} + + + {tct('Ask the admins if you can join the [orgId] organization.', { + orgId, + })} + + + + + + + {field => ( + + + + )} + + + + + {t('Request to Join')} + + + + - - - - {field => ( - - - - )} - - - - - {t('Request to Join')} - - ); } From cd95ce025e1b805f6592ea38a96f173ba156f6f1 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 20 Jul 2026 11:41:01 +0200 Subject: [PATCH 8/9] ref(forms): Flatten nested Stack in join request form --- .../views/organizationJoinRequest/index.tsx | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/static/app/views/organizationJoinRequest/index.tsx b/static/app/views/organizationJoinRequest/index.tsx index c8165d3691e5..60a4d0126f92 100644 --- a/static/app/views/organizationJoinRequest/index.tsx +++ b/static/app/views/organizationJoinRequest/index.tsx @@ -82,18 +82,16 @@ export default function OrganizationJoinRequest() { return ( - - - - - {t('Request to Join')} - - - {tct('Ask the admins if you can join the [orgId] organization.', { - orgId, - })} - - + + + + {t('Request to Join')} + + + {tct('Ask the admins if you can join the [orgId] organization.', { + orgId, + })} + From df55e34398612242f0f4d0e836a084409ad40882 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 20 Jul 2026 11:46:22 +0200 Subject: [PATCH 9/9] ref(forms): Remove icon Container wrapper in join request success view --- .../views/organizationJoinRequest/index.tsx | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/static/app/views/organizationJoinRequest/index.tsx b/static/app/views/organizationJoinRequest/index.tsx index 60a4d0126f92..ad42c9697cde 100644 --- a/static/app/views/organizationJoinRequest/index.tsx +++ b/static/app/views/organizationJoinRequest/index.tsx @@ -59,21 +59,21 @@ export default function OrganizationJoinRequest() { if (mutation.isSuccess) { return ( - - - - - - {t('Request Sent')} - - - {t('Your request to join has been sent.')} - - + + + + + {t('Request Sent')} + - {t('You will receive an email when your request is approved.')} + {t('Your request to join has been sent.')} - + + + {t('You will receive an email when your request is approved.')} + + + );