diff --git a/static/app/views/organizationJoinRequest/index.spec.tsx b/static/app/views/organizationJoinRequest/index.spec.tsx
index a049fe049828..a2ea16e9a652 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');
@@ -66,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,
diff --git a/static/app/views/organizationJoinRequest/index.tsx b/static/app/views/organizationJoinRequest/index.tsx
index 088075f83351..ad42c9697cde 100644
--- a/static/app/views/organizationJoinRequest/index.tsx
+++ b/static/app/views/organizationJoinRequest/index.tsx
@@ -1,113 +1,121 @@
-import {useCallback, 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 {Container, Flex, Stack} from '@sentry/scraps/layout';
+import {Heading, Text} from '@sentry/scraps/text';
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.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 mutation = useMutation({
+ mutationFn: (data: {email: string}) =>
+ fetchMutation({
+ url: `/organizations/${orgId}/join-request/`,
+ method: 'POST',
+ data,
+ }),
+ onSuccess: () => {
+ trackAnalytics('join_request.created', {
+ organization: orgId,
+ referrer: decodeScalar(location.query.referrer, ''),
+ });
+ },
+ onError: () => {
+ addErrorMessage(t('Request to join failed'));
+ },
+ });
- const handleSubmitError = useCallback(() => {
- addErrorMessage(t('Request to join failed'));
- }, []);
+ const form = useScrapsForm({
+ ...defaultFormOptions,
+ defaultValues: {email: ''},
+ validators: {onDynamic: joinRequestSchema},
+ onSubmit: ({value}) => mutation.mutateAsync(value).catch(() => {}),
+ });
- const handleCancel = useCallback(
- (e: MouseEvent) => {
- e.preventDefault();
- testableWindowLocation.assign(`/auth/login/${orgId}/`);
- },
- [orgId]
- );
+ const handleCancel = (e: MouseEvent) => {
+ e.preventDefault();
+ testableWindowLocation.assign(`/auth/login/${orgId}/`);
+ };
- if (submitSuccess) {
+ if (mutation.isSuccess) {
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')}
-
- {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')}
+
+
+
+
+
);
}
-
-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 StyledEmailField = styled(EmailField)`
- padding-top: ${p => p.theme.space.xl};
- padding-left: 0;
-`;