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
27 changes: 27 additions & 0 deletions static/app/views/organizationJoinRequest/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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(<OrganizationJoinRequest />, {
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,
Expand Down
174 changes: 91 additions & 83 deletions static/app/views/organizationJoinRequest/index.tsx
Original file line number Diff line number Diff line change
@@ -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, ''),
});
Comment on lines +37 to +40

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.

Bug: The trackAnalytics call for join_request.created sends an organization property, which mismatches the backend's expected organization_id and the frontend's TypeScript definition.
Severity: LOW

Suggested Fix

Update the trackAnalytics call to send the correct properties as expected by the backend. This involves changing organization: orgId to organization_id: parseInt(orgId, 10) and adding the required member_id. Also, update the frontend TypeScript definition for the event to match the new payload structure to resolve the type error.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: static/app/views/organizationJoinRequest/index.tsx#L37-L40

Potential issue: The `trackAnalytics` call for the `'join_request.created'` event sends
a payload with an `organization` property. This is incorrect as the backend event schema
expects an integer `organization_id` and a `member_id`. Additionally, the frontend
TypeScript definition for this event only allows for a `referrer` property, creating a
type mismatch that should fail compilation. This discrepancy will cause the analytics
event to be recorded incorrectly, as the data sent from the frontend does not match the
schema expected by the backend.

Did we get this right? 👍 / 👎 to inform future reviews.

},
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 (
<NarrowLayout maxWidth="550px">
<SuccessModal>
<StyledIconMegaphone size="2xl" />
<StyledHeader>{t('Request Sent')}</StyledHeader>
<StyledText>{t('Your request to join has been sent.')}</StyledText>
<ReceiveEmailMessage>
{t('You will receive an email when your request is approved.')}
</ReceiveEmailMessage>
</SuccessModal>
<Stack align="center" gap="xl" paddingTop="lg" paddingBottom="3xl">
<IconMegaphone size="xl" />
<Stack align="center" gap="md">
<Heading as="h3" size="xl">
{t('Request Sent')}
</Heading>
<Text as="p" align="center">
{t('Your request to join has been sent.')}
</Text>
<Container maxWidth="250px">
<Text as="p" align="center">
{t('You will receive an email when your request is approved.')}
</Text>
</Container>
</Stack>
</Stack>
</NarrowLayout>
);
}

return (
<NarrowLayout maxWidth="650px">
<StyledIconMegaphone size="2xl" />
<StyledHeader data-test-id="join-request">{t('Request to Join')}</StyledHeader>
<StyledText>
{tct('Ask the admins if you can join the [orgId] organization.', {
orgId,
})}
</StyledText>
<Form
requireChanges
apiEndpoint={`/organizations/${orgId}/join-request/`}
apiMethod="POST"
submitLabel={t('Request to Join')}
onSubmitSuccess={handleSubmitSuccess}
onSubmitError={handleSubmitError}
onCancel={handleCancel}
>
<StyledEmailField
name="email"
inline={false}
label={t('Email Address')}
placeholder="name@example.com"
/>
</Form>
<Stack gap="xl">
<IconMegaphone size="xl" />
<Stack gap="md">
<Heading as="h3" size="xl" data-test-id="join-request">
{t('Request to Join')}
</Heading>
<Text as="p">
{tct('Ask the admins if you can join the [orgId] organization.', {
orgId,
})}
</Text>
</Stack>
<form.AppForm form={form}>
<Stack gap="xl">
<form.AppField name="email">
{field => (
<field.Layout.Stack label={t('Email Address')} required>
<field.Input
type="email"
value={field.state.value}
onChange={field.handleChange}
placeholder="name@example.com"
/>
</field.Layout.Stack>
)}
</form.AppField>
<Container borderTop="secondary" paddingTop="xl" paddingBottom="xl">
<Flex gap="md" justify="end">
<Button onClick={handleCancel}>{t('Cancel')}</Button>
<form.SubmitButton>{t('Request to Join')}</form.SubmitButton>
</Flex>
</Container>
</Stack>
</form.AppForm>
</Stack>
</NarrowLayout>
);
}

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;
`;
Loading