diff --git a/static/app/components/pipeline/pipelineIntegrationDiscord.spec.tsx b/static/app/components/pipeline/pipelineIntegrationDiscord.spec.tsx new file mode 100644 index 00000000000000..775b67af32dfb1 --- /dev/null +++ b/static/app/components/pipeline/pipelineIntegrationDiscord.spec.tsx @@ -0,0 +1,112 @@ +import {act, render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; + +import {discordIntegrationPipeline} from './pipelineIntegrationDiscord'; +import type {PipelineStepProps} from './types'; + +const DiscordOAuthLoginStep = discordIntegrationPipeline.steps[0].component; + +function makeStepProps( + overrides: Partial> & {stepData: D} +): PipelineStepProps { + return { + advance: jest.fn(), + advanceError: null, + isAdvancing: false, + stepIndex: 0, + totalSteps: 1, + ...overrides, + }; +} + +let mockPopup: Window; + +function dispatchPipelineMessage({ + data, + origin = document.location.origin, + source = mockPopup, +}: { + data: Record; + origin?: string; + source?: Window | MessageEventSource | null; +}) { + act(() => { + const event = new MessageEvent('message', {data, origin}); + Object.defineProperty(event, 'source', {value: source}); + window.dispatchEvent(event); + }); +} + +beforeEach(() => { + mockPopup = { + closed: false, + close: jest.fn(), + focus: jest.fn(), + } as unknown as Window; + jest.spyOn(window, 'open').mockReturnValue(mockPopup); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('DiscordOAuthLoginStep', () => { + it('renders the OAuth login step for Discord', () => { + render( + + ); + + expect(screen.getByRole('button', {name: 'Authorize Discord'})).toBeInTheDocument(); + }); + + it('calls advance with code, state, and guildId on OAuth callback', async () => { + const advance = jest.fn(); + render( + + ); + + await userEvent.click(screen.getByRole('button', {name: 'Authorize Discord'})); + + dispatchPipelineMessage({ + data: { + _pipeline_source: 'sentry-pipeline', + code: 'auth-code-123', + state: 'state-xyz', + guild_id: '1234567890', + }, + }); + + expect(advance).toHaveBeenCalledWith({ + code: 'auth-code-123', + state: 'state-xyz', + guildId: '1234567890', + }); + }); + + it('shows loading state when isAdvancing is true', () => { + render( + + ); + + expect(screen.getByRole('button', {name: 'Authorizing...'})).toBeDisabled(); + }); + + it('disables authorize button when oauthUrl is not provided', () => { + render(); + + expect(screen.getByRole('button', {name: 'Authorize Discord'})).toBeDisabled(); + }); +}); diff --git a/static/app/components/pipeline/pipelineIntegrationDiscord.tsx b/static/app/components/pipeline/pipelineIntegrationDiscord.tsx new file mode 100644 index 00000000000000..3f5a4d30405c3c --- /dev/null +++ b/static/app/components/pipeline/pipelineIntegrationDiscord.tsx @@ -0,0 +1,50 @@ +import {useCallback} from 'react'; + +import {t} from 'sentry/locale'; +import type {IntegrationWithConfig} from 'sentry/types/integrations'; + +import type {OAuthCallbackData} from './shared/oauthLoginStep'; +import {OAuthLoginStep} from './shared/oauthLoginStep'; +import type {PipelineDefinition, PipelineStepProps} from './types'; +import {pipelineComplete} from './types'; + +function DiscordOAuthLoginStep({ + stepData, + advance, + isAdvancing, +}: PipelineStepProps< + {oauthUrl?: string}, + {code: string; guildId: string; state: string} +>) { + const handleOAuthCallback = useCallback( + (data: OAuthCallbackData) => { + advance({code: data.code, state: data.state, guildId: data.rest.guild_id ?? ''}); + }, + [advance] + ); + + return ( + + ); +} + +export const discordIntegrationPipeline = { + type: 'integration', + provider: 'discord', + actionTitle: t('Installing Discord Integration'), + getCompletionData: pipelineComplete, + completionView: null, + steps: [ + { + stepId: 'oauth_login', + shortDescription: t('Authorizing via Discord OAuth'), + component: DiscordOAuthLoginStep, + }, + ], +} as const satisfies PipelineDefinition; diff --git a/static/app/components/pipeline/registry.tsx b/static/app/components/pipeline/registry.tsx index 5c049b0a09a7d6..b31bbf356c0a12 100644 --- a/static/app/components/pipeline/registry.tsx +++ b/static/app/components/pipeline/registry.tsx @@ -1,6 +1,7 @@ import {dummyIntegrationPipeline} from './pipelineDummyProvider'; import {awsLambdaIntegrationPipeline} from './pipelineIntegrationAwsLambda'; import {bitbucketIntegrationPipeline} from './pipelineIntegrationBitbucket'; +import {discordIntegrationPipeline} from './pipelineIntegrationDiscord'; import {githubIntegrationPipeline} from './pipelineIntegrationGitHub'; import {gitlabIntegrationPipeline} from './pipelineIntegrationGitLab'; import {slackIntegrationPipeline} from './pipelineIntegrationSlack'; @@ -11,6 +12,7 @@ import {slackIntegrationPipeline} from './pipelineIntegrationSlack'; export const PIPELINE_REGISTRY = [ awsLambdaIntegrationPipeline, bitbucketIntegrationPipeline, + discordIntegrationPipeline, dummyIntegrationPipeline, githubIntegrationPipeline, gitlabIntegrationPipeline,