Skip to content
Open
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
105 changes: 103 additions & 2 deletions static/app/views/slackLinkTeam/index.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import {OrganizationFixture} from 'sentry-fixture/organization';
import {TeamFixture} from 'sentry-fixture/team';

import {render, screen} from 'sentry-test/reactTestingLibrary';
import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
import {selectEvent} from 'sentry-test/selectEvent';

import SlackLinkTeam from 'sentry/views/slackLinkTeam';

const SIGNED_PARAMS = 'signed-params';
const CHANNEL_URL = 'slack://channel?team=TXXXXXXX1&id=CXXXXXXX1';

const initialRouterConfig = {
route: '/extensions/slack/link-team/:signedParams/',
Expand Down Expand Up @@ -36,7 +37,7 @@ describe('SlackLinkTeam', () => {
function mockContext(organizations = [usOrg, deOrg]) {
MockApiClient.addMockResponse({
url: `/integrations/slack/link-team/${SIGNED_PARAMS}/`,
body: {channelName: 'alerts', channelUrl: 'slack://channel', organizations},
body: {channelName: 'alerts', channelUrl: CHANNEL_URL, organizations},
});
}

Expand All @@ -48,6 +49,23 @@ describe('SlackLinkTeam', () => {
});
}

function mockLink(response: {statusCode: number; body?: unknown}) {
return MockApiClient.addMockResponse({
url: `/organizations/${deOrg.slug}/slack/link-team/${SIGNED_PARAMS}/`,
method: 'POST',
match: [(_url, options) => options.host === deOrg.links.regionUrl],
...response,
});
}

async function linkTeamInDeOrg() {
render(<SlackLinkTeam />, {initialRouterConfig});

await selectEvent.select(await screen.findByLabelText('Organization'), 'DE Org');
await selectEvent.select(screen.getByLabelText('Team'), '#backend');
await userEvent.click(screen.getByRole('button', {name: 'Link Team'}));
}

beforeEach(() => {
MockApiClient.clearMockResponses();
});
Expand All @@ -65,6 +83,89 @@ describe('SlackLinkTeam', () => {
expect(screen.getByText('#backend')).toBeInTheDocument();
});

it('links a team from the selected organization cell', async () => {
mockContext();
const teamsMock = mockTeams();
const linkMock = mockLink({statusCode: 201});

await linkTeamInDeOrg();

expect(teamsMock).toHaveBeenCalled();
expect(linkMock).toHaveBeenCalledWith(
`/organizations/${deOrg.slug}/slack/link-team/${SIGNED_PARAMS}/`,
expect.objectContaining({data: {team: '10'}})
);
expect(await screen.findByRole('heading', {name: 'Team linked'})).toBeInTheDocument();
expect(
screen.getByText(/will now receive issue alert notifications/)
).toBeInTheDocument();
expect(screen.queryByText(/Choose a team/)).not.toBeInTheDocument();
expect(screen.getByRole('button', {name: 'Go back to Slack'})).toHaveAttribute(
'href',
CHANNEL_URL
);
});

it('shows when the team is already linked', async () => {
mockContext();
mockTeams();
mockLink({
statusCode: 409,
body: {detail: 'The backend team has already been linked to a Slack channel.'},
});

await linkTeamInDeOrg();

expect(
await screen.findByText(
'The backend team has already been linked to a Slack channel.'
)
).toBeInTheDocument();
expect(screen.getByRole('button', {name: 'Go back to Slack'})).toBeInTheDocument();
});

it('shows why linking failed', async () => {
mockContext();
mockTeams();
mockLink({
statusCode: 403,
body: {
detail:
"The Slack account that ran this command isn't linked to Sentry. Run /sentry link, then try again.",
},
});

await linkTeamInDeOrg();

expect(
await screen.findByText(
"The Slack account that ran this command isn't linked to Sentry. Run /sentry link, then try again."
)
).toBeInTheDocument();
expect(screen.getByRole('button', {name: 'Link Team'})).toBeInTheDocument();
});

it('clears a failed link when the organization changes', async () => {
mockContext();
mockTeams();
mockTeams(usOrg);
mockLink({
statusCode: 403,
body: {detail: 'You do not have permission to link this team.'},
});

await linkTeamInDeOrg();
expect(
await screen.findByText('You do not have permission to link this team.')
).toBeInTheDocument();

await selectEvent.select(screen.getByLabelText('Organization'), 'US Org');

expect(
screen.queryByText('You do not have permission to link this team.')
).not.toBeInTheDocument();
});

it('lists teams from every page', async () => {
mockContext();
MockApiClient.addMockResponse({
Expand Down
50 changes: 47 additions & 3 deletions static/app/views/slackLinkTeam/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {useState} from 'react';
import {useQuery} from '@tanstack/react-query';

import {Alert} from '@sentry/scraps/alert';
import {Stack} from '@sentry/scraps/layout';
import {LinkButton} from '@sentry/scraps/button';
import {Flex, Stack} from '@sentry/scraps/layout';
import {Heading, Text} from '@sentry/scraps/text';

import {LoadingIndicator} from 'sentry/components/loadingIndicator';
Expand All @@ -13,9 +15,43 @@ import {getRequestErrorUserMessage} from 'sentry/utils/requestError/getRequestEr
import {useParams} from 'sentry/utils/useParams';

import {LinkTeamForm} from './linkTeamForm';
import type {LinkTeamContext} from './types';
import type {LinkResult, LinkTeamContext} from './types';

interface LinkResultViewProps {
context: LinkTeamContext;
result: LinkResult;
}

function LinkResultView({context, result}: LinkResultViewProps) {
const isLinked = result.kind === 'linked';

return (
<Stack gap="xl">
<Heading as="h3" size="xl">
{isLinked ? t('Team linked') : t('Team already linked')}
</Heading>
<Alert variant={isLinked ? 'success' : 'info'}>
{isLinked
? tct(
'The [team] team will now receive issue alert notifications in the [channel] channel.',
{
team: <strong>#{result.team.slug}</strong>,
channel: <strong>#{context.channelName}</strong>,
}
)
: result.detail}
</Alert>
<Flex justify="end">
<LinkButton variant="primary" href={context.channelUrl}>
{t('Go back to Slack')}
</LinkButton>
</Flex>
</Stack>
);
}

function SlackLinkTeamBody({signedParams}: {signedParams: string}) {
const [result, setResult] = useState<LinkResult | null>(null);
const {
data: context,
isPending,
Expand All @@ -37,6 +73,10 @@ function SlackLinkTeamBody({signedParams}: {signedParams: string}) {
return <Alert variant="danger">{getRequestErrorUserMessage(error)}</Alert>;
}

if (result) {
return <LinkResultView context={context} result={result} />;
}

const heading = (
<Heading as="h3" size="xl">
{tct('Link a team to [channel]', {channel: `#${context.channelName}`})}
Expand Down Expand Up @@ -66,7 +106,11 @@ function SlackLinkTeamBody({signedParams}: {signedParams: string}) {
)}
</Text>
</Stack>
<LinkTeamForm organizations={context.organizations} signedParams={signedParams} />
<LinkTeamForm
organizations={context.organizations}
signedParams={signedParams}
onLinked={setResult}
/>
</Stack>
);
}
Expand Down
79 changes: 73 additions & 6 deletions static/app/views/slackLinkTeam/linkTeamForm.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import {skipToken, useInfiniteQuery} from '@tanstack/react-query';
import {skipToken, useInfiniteQuery, useMutation} from '@tanstack/react-query';
import {z} from 'zod';

import {Alert} from '@sentry/scraps/alert';
import {defaultFormOptions, useScrapsForm, useStore} from '@sentry/scraps/form';
import {Stack} from '@sentry/scraps/layout';
import {Flex, Stack} from '@sentry/scraps/layout';

import {t} from 'sentry/locale';
import type {Team} from 'sentry/types/organization';
import {useFetchAllPages} from 'sentry/utils/api/apiFetch';
import {apiOptions} from 'sentry/utils/api/apiOptions';
import {getApiUrl} from 'sentry/utils/api/getApiUrl';
import {fetchMutation} from 'sentry/utils/queryClient';
import {getRequestErrorUserMessage} from 'sentry/utils/requestError/getRequestErrorUserMessage';
import {RequestError} from 'sentry/utils/requestError/requestError';

import type {LinkTeamOrganization} from './types';
import type {LinkResult, LinkTeamOrganization} from './types';

const schema = z.object({
organization: z
Expand All @@ -25,20 +28,66 @@ const schema = z.object({
});

interface LinkTeamFormProps {
onLinked: (result: LinkResult) => void;
organizations: LinkTeamOrganization[];
signedParams: string;
}

export function LinkTeamForm({organizations, signedParams}: LinkTeamFormProps) {
export function LinkTeamForm({organizations, signedParams, onLinked}: LinkTeamFormProps) {
const defaultValues: z.input<typeof schema> = {
organization: organizations.length === 1 ? (organizations[0]?.slug ?? null) : null,
team: null,
};

const linkTeam = useMutation({
mutationFn: async ({
organization,
team,
}: {
organization: LinkTeamOrganization;
team: Team;
}): Promise<LinkResult> => {
try {
await fetchMutation({
method: 'POST',
url: getApiUrl(
'/organizations/$organizationIdOrSlug/slack/link-team/$signedParams/',
{path: {organizationIdOrSlug: organization.slug, signedParams}}
),
data: {team: team.id},
options: {host: organization.links.regionUrl},
});
} catch (error) {
if (error instanceof RequestError && error.status === 409) {
return {
kind: 'alreadyLinked',
detail: getRequestErrorUserMessage(error),
};
}

throw error;
}

return {kind: 'linked', team};
Comment thread
sentry-junior[bot] marked this conversation as resolved.
Resolved
},
onSuccess: onLinked,
});

const form = useScrapsForm({
...defaultFormOptions,
defaultValues,
validators: {onDynamic: schema},
onSubmit: ({value}) => {
const parsed = schema.parse(value);
const organization = organizations.find(org => org.slug === parsed.organization);
const team = teams?.find(item => item.id === parsed.team);

if (!organization || !team) {
return Promise.resolve();
}

return linkTeam.mutateAsync({organization, team}).catch(() => {});
},
});

const selectedSlug = useStore(form.store, state => state.values.organization);
Expand Down Expand Up @@ -73,7 +122,12 @@ export function LinkTeamForm({organizations, signedParams}: LinkTeamFormProps) {
<Stack gap="xl">
<form.AppField
name="organization"
listeners={{onChange: () => form.setFieldValue('team', null)}}
listeners={{
onChange: () => {
form.setFieldValue('team', null);
linkTeam.reset();
},
}}
>
{field => (
<field.Layout.Stack label={t('Organization')} required>
Expand All @@ -88,7 +142,7 @@ export function LinkTeamForm({organizations, signedParams}: LinkTeamFormProps) {
</field.Layout.Stack>
)}
</form.AppField>
<form.AppField name="team">
<form.AppField name="team" listeners={{onChange: () => linkTeam.reset()}}>
{field => (
<field.Layout.Stack label={t('Team')} required>
<field.Select
Expand All @@ -114,6 +168,19 @@ export function LinkTeamForm({organizations, signedParams}: LinkTeamFormProps) {
)}
</Alert>
)}
{linkTeam.isError && (
<Alert variant="danger">{getRequestErrorUserMessage(linkTeam.error)}</Alert>
)}
<Flex justify="end" borderTop="secondary" paddingTop="xl">
<form.SubmitButton
disabled={
selectedOrganization !== undefined &&
(teams === undefined || isTeamsError || hasNoTeams)
}
>
{t('Link Team')}
</form.SubmitButton>
</Flex>
</Stack>
</form.AppForm>
);
Expand Down
6 changes: 5 additions & 1 deletion static/app/views/slackLinkTeam/types.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type {OrganizationSummary} from 'sentry/types/organization';
import type {OrganizationSummary, Team} from 'sentry/types/organization';

export type LinkTeamOrganization = Pick<
OrganizationSummary,
Expand All @@ -10,3 +10,7 @@ export interface LinkTeamContext {
channelUrl: string;
organizations: LinkTeamOrganization[];
}

export type LinkResult =
| {kind: 'linked'; team: Team}
| {detail: string; kind: 'alreadyLinked'};
Loading