diff --git a/static/app/router/routes.tsx b/static/app/router/routes.tsx index 25a0f098990d..6922f4706a35 100644 --- a/static/app/router/routes.tsx +++ b/static/app/router/routes.tsx @@ -193,6 +193,10 @@ function buildRoutes(): RouteObject[] { path: '/sentry-apps/:sentryAppSlug/external-install/', component: make(() => import('sentry/views/sentryAppExternalInstallation')), }, + { + path: '/extensions/slack/link-team/:signedParams/', + component: make(() => import('sentry/views/slackLinkTeam')), + }, { path: '/account/', redirectTo: '/settings/account/details/', diff --git a/static/app/utils/useParams.tsx b/static/app/utils/useParams.tsx index 607448b591de..07ca37eca17b 100644 --- a/static/app/utils/useParams.tsx +++ b/static/app/utils/useParams.tsx @@ -63,6 +63,7 @@ type ParamKeys = | 'searchId' | 'sentryAppSlug' | 'shareId' + | 'signedParams' | 'snapshotId' | 'spanSlug' | 'step' diff --git a/static/app/views/slackLinkTeam/index.spec.tsx b/static/app/views/slackLinkTeam/index.spec.tsx new file mode 100644 index 000000000000..4b523af68af0 --- /dev/null +++ b/static/app/views/slackLinkTeam/index.spec.tsx @@ -0,0 +1,154 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {TeamFixture} from 'sentry-fixture/team'; + +import {render, screen} from 'sentry-test/reactTestingLibrary'; +import {selectEvent} from 'sentry-test/selectEvent'; + +import SlackLinkTeam from 'sentry/views/slackLinkTeam'; + +const SIGNED_PARAMS = 'signed-params'; + +const initialRouterConfig = { + route: '/extensions/slack/link-team/:signedParams/', + location: {pathname: `/extensions/slack/link-team/${SIGNED_PARAMS}/`}, +}; + +describe('SlackLinkTeam', () => { + const usOrg = OrganizationFixture({ + slug: 'us-org', + name: 'US Org', + links: { + organizationUrl: 'https://us-org.sentry.io', + regionUrl: 'https://us.sentry.io', + }, + }); + const deOrg = OrganizationFixture({ + id: '2', + slug: 'de-org', + name: 'DE Org', + links: { + organizationUrl: 'https://de-org.sentry.io', + regionUrl: 'https://de.sentry.io', + }, + }); + const team = TeamFixture({id: '10', slug: 'backend'}); + + function mockContext(organizations = [usOrg, deOrg]) { + MockApiClient.addMockResponse({ + url: `/integrations/slack/link-team/${SIGNED_PARAMS}/`, + body: {channelName: 'alerts', channelUrl: 'slack://channel', organizations}, + }); + } + + function mockTeams(organization = deOrg, teams = [team]) { + return MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/slack/link-team/${SIGNED_PARAMS}/`, + body: teams, + match: [(_url, options) => options.host === organization.links.regionUrl], + }); + } + + beforeEach(() => { + MockApiClient.clearMockResponses(); + }); + + it('lists teams from the selected organization cell', async () => { + mockContext(); + const teamsMock = mockTeams(); + + render(, {initialRouterConfig}); + + await selectEvent.select(await screen.findByLabelText('Organization'), 'DE Org'); + await selectEvent.openMenu(screen.getByLabelText('Team')); + + expect(teamsMock).toHaveBeenCalled(); + expect(screen.getByText('#backend')).toBeInTheDocument(); + }); + + it('lists teams from every page', async () => { + mockContext(); + MockApiClient.addMockResponse({ + url: `/organizations/${deOrg.slug}/slack/link-team/${SIGNED_PARAMS}/`, + body: [team], + match: [(_url, options) => options.host === deOrg.links.regionUrl], + headers: { + Link: + '; rel="previous"; results="false"; cursor="0:0:1", ' + + '; rel="next"; results="true"; cursor="0:100:0"', + }, + }); + MockApiClient.addMockResponse({ + url: `/organizations/${deOrg.slug}/slack/link-team/${SIGNED_PARAMS}/`, + body: [TeamFixture({id: '11', slug: 'frontend'})], + match: [MockApiClient.matchQuery({cursor: '0:100:0'})], + }); + + render(, {initialRouterConfig}); + + await selectEvent.select(await screen.findByLabelText('Organization'), 'DE Org'); + await selectEvent.openMenu(screen.getByLabelText('Team')); + + expect(await screen.findByText('#frontend')).toBeInTheDocument(); + expect(screen.getByText('#backend')).toBeInTheDocument(); + }); + + it('shows why the teams could not be listed', async () => { + mockContext(); + MockApiClient.addMockResponse({ + url: `/organizations/${deOrg.slug}/slack/link-team/${SIGNED_PARAMS}/`, + statusCode: 404, + body: {detail: 'The Slack workspace is not installed in this organization.'}, + }); + + render(, {initialRouterConfig}); + + await selectEvent.select(await screen.findByLabelText('Organization'), 'DE Org'); + + expect( + await screen.findByText( + 'The Slack workspace is not installed in this organization.' + ) + ).toBeInTheDocument(); + expect(screen.getByLabelText('Team')).toBeDisabled(); + }); + + it('selects the only organization', async () => { + mockContext([usOrg]); + mockTeams(usOrg, []); + + render(, {initialRouterConfig}); + + expect( + await screen.findByText( + /You don't have any teams you can link in this organization/ + ) + ).toBeInTheDocument(); + }); + + it('explains when no organization has the workspace installed', async () => { + mockContext([]); + + render(, {initialRouterConfig}); + + expect( + await screen.findByText(/None of your organizations have this Slack workspace/) + ).toBeInTheDocument(); + expect(screen.queryByText(/Choose a team/)).not.toBeInTheDocument(); + }); + + it('shows an invalid link error', async () => { + MockApiClient.addMockResponse({ + url: `/integrations/slack/link-team/${SIGNED_PARAMS}/`, + statusCode: 400, + body: {detail: 'This link is invalid or has expired. Run the command again.'}, + }); + + render(, {initialRouterConfig}); + + expect( + await screen.findByText( + 'This link is invalid or has expired. Run the command again.' + ) + ).toBeInTheDocument(); + }); +}); diff --git a/static/app/views/slackLinkTeam/index.tsx b/static/app/views/slackLinkTeam/index.tsx new file mode 100644 index 000000000000..138ab37abc52 --- /dev/null +++ b/static/app/views/slackLinkTeam/index.tsx @@ -0,0 +1,83 @@ +import {useQuery} from '@tanstack/react-query'; + +import {Alert} from '@sentry/scraps/alert'; +import {Stack} from '@sentry/scraps/layout'; +import {Heading, Text} from '@sentry/scraps/text'; + +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import {NarrowLayout} from 'sentry/components/narrowLayout'; +import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; +import {t, tct} from 'sentry/locale'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {getRequestErrorUserMessage} from 'sentry/utils/requestError/getRequestErrorUserMessage'; +import {useParams} from 'sentry/utils/useParams'; + +import {LinkTeamForm} from './linkTeamForm'; +import type {LinkTeamContext} from './types'; + +function SlackLinkTeamBody({signedParams}: {signedParams: string}) { + const { + data: context, + isPending, + isError, + error, + } = useQuery({ + ...apiOptions.as()('/integrations/slack/link-team/$signedParams/', { + path: {signedParams}, + staleTime: Infinity, + }), + retry: false, + }); + + if (isPending) { + return ; + } + + if (isError) { + return {getRequestErrorUserMessage(error)}; + } + + const heading = ( + + {tct('Link a team to [channel]', {channel: `#${context.channelName}`})} + + ); + + if (context.organizations.length === 0) { + return ( + + {heading} + + {t( + 'None of your organizations have this Slack workspace installed. Ask an organization owner or manager to install it first.' + )} + + + ); + } + + return ( + + + {heading} + + {t( + 'Choose a team. Its issue alert notifications will be sent to this Slack channel.' + )} + + + + + ); +} + +export default function SlackLinkTeam() { + const {signedParams} = useParams<{signedParams: string}>(); + + return ( + + + + + ); +} diff --git a/static/app/views/slackLinkTeam/linkTeamForm.tsx b/static/app/views/slackLinkTeam/linkTeamForm.tsx new file mode 100644 index 000000000000..23158fa6797c --- /dev/null +++ b/static/app/views/slackLinkTeam/linkTeamForm.tsx @@ -0,0 +1,120 @@ +import {skipToken, useInfiniteQuery} 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 {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 {getRequestErrorUserMessage} from 'sentry/utils/requestError/getRequestErrorUserMessage'; + +import type {LinkTeamOrganization} from './types'; + +const schema = z.object({ + organization: z + .string() + .nullable() + .refine(value => value !== null, t('Select an organization')), + team: z + .string() + .nullable() + .refine(value => value !== null, t('Select a team')), +}); + +interface LinkTeamFormProps { + organizations: LinkTeamOrganization[]; + signedParams: string; +} + +export function LinkTeamForm({organizations, signedParams}: LinkTeamFormProps) { + const defaultValues: z.input = { + organization: organizations.length === 1 ? (organizations[0]?.slug ?? null) : null, + team: null, + }; + + const form = useScrapsForm({ + ...defaultFormOptions, + defaultValues, + validators: {onDynamic: schema}, + }); + + const selectedSlug = useStore(form.store, state => state.values.organization); + const selectedOrganization = organizations.find(org => org.slug === selectedSlug); + + const teamsQuery = useInfiniteQuery({ + ...apiOptions.asInfinite()( + '/organizations/$organizationIdOrSlug/slack/link-team/$signedParams/', + { + path: selectedOrganization + ? {organizationIdOrSlug: selectedOrganization.slug, signedParams} + : skipToken, + host: selectedOrganization?.links.regionUrl, + staleTime: 0, + } + ), + select: ({pages}) => pages.flatMap(page => page.json), + retry: false, + }); + useFetchAllPages({result: teamsQuery}); + const { + data: teams, + isFetching: isFetchingTeams, + isError: isTeamsError, + error: teamsError, + } = teamsQuery; + + const hasNoTeams = teams !== undefined && teams.length === 0; + + return ( + + + form.setFieldValue('team', null)}} + > + {field => ( + + ({ + label: org.name, + value: org.slug, + }))} + /> + + )} + + + {field => ( + + ({ + label: `#${team.slug}`, + value: team.id, + }))} + /> + + )} + + {isTeamsError && ( + {getRequestErrorUserMessage(teamsError)} + )} + {hasNoTeams && ( + + {t( + "You don't have any teams you can link in this organization. You need to be on a team to link it, and unless you're an organization owner, manager, or admin, you also need to be its team admin." + )} + + )} + + + ); +} diff --git a/static/app/views/slackLinkTeam/types.tsx b/static/app/views/slackLinkTeam/types.tsx new file mode 100644 index 000000000000..89ac7c3f2b4a --- /dev/null +++ b/static/app/views/slackLinkTeam/types.tsx @@ -0,0 +1,12 @@ +import type {OrganizationSummary} from 'sentry/types/organization'; + +export type LinkTeamOrganization = Pick< + OrganizationSummary, + 'id' | 'slug' | 'name' | 'links' +>; + +export interface LinkTeamContext { + channelName: string; + channelUrl: string; + organizations: LinkTeamOrganization[]; +}