From aaa574753c50b64ea376b1fbbea40e608821df5c Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Wed, 25 Mar 2026 14:09:17 -0400 Subject: [PATCH] feat(slack): Add frontend pipeline step for Slack integration setup Register the Slack integration in the pipeline registry with a single OAuthLoginStep component. This is the simplest integration frontend since Slack only requires an OAuth authorization flow with no additional configuration steps. Ref [VDY-42](https://linear.app/getsentry/issue/VDY-42/slack-api-driven-integration-setup) --- .../pipelineIntegrationSlack.spec.tsx | 108 ++++++++++++++++++ .../pipeline/pipelineIntegrationSlack.tsx | 46 ++++++++ static/app/components/pipeline/registry.tsx | 2 + 3 files changed, 156 insertions(+) create mode 100644 static/app/components/pipeline/pipelineIntegrationSlack.spec.tsx create mode 100644 static/app/components/pipeline/pipelineIntegrationSlack.tsx diff --git a/static/app/components/pipeline/pipelineIntegrationSlack.spec.tsx b/static/app/components/pipeline/pipelineIntegrationSlack.spec.tsx new file mode 100644 index 00000000000000..740343d5d25a09 --- /dev/null +++ b/static/app/components/pipeline/pipelineIntegrationSlack.spec.tsx @@ -0,0 +1,108 @@ +import {act, render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; + +import {slackIntegrationPipeline} from './pipelineIntegrationSlack'; +import type {PipelineStepProps} from './types'; + +const SlackOAuthLoginStep = slackIntegrationPipeline.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('SlackOAuthLoginStep', () => { + it('renders the OAuth login step for Slack', () => { + render( + + ); + + expect(screen.getByRole('button', {name: 'Authorize Slack'})).toBeInTheDocument(); + }); + + it('calls advance with code and state on OAuth callback', async () => { + const advance = jest.fn(); + render( + + ); + + await userEvent.click(screen.getByRole('button', {name: 'Authorize Slack'})); + + dispatchPipelineMessage({ + data: { + _pipeline_source: 'sentry-pipeline', + code: 'auth-code-123', + state: 'state-xyz', + }, + }); + + expect(advance).toHaveBeenCalledWith({ + code: 'auth-code-123', + state: 'state-xyz', + }); + }); + + 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 Slack'})).toBeDisabled(); + }); +}); diff --git a/static/app/components/pipeline/pipelineIntegrationSlack.tsx b/static/app/components/pipeline/pipelineIntegrationSlack.tsx new file mode 100644 index 00000000000000..ecd4433cb99e8c --- /dev/null +++ b/static/app/components/pipeline/pipelineIntegrationSlack.tsx @@ -0,0 +1,46 @@ +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 SlackOAuthLoginStep({ + stepData, + advance, + isAdvancing, +}: PipelineStepProps<{oauthUrl?: string}, {code: string; state: string}>) { + const handleOAuthCallback = useCallback( + (data: OAuthCallbackData) => { + advance({code: data.code, state: data.state}); + }, + [advance] + ); + + return ( + + ); +} + +export const slackIntegrationPipeline = { + type: 'integration', + provider: 'slack', + actionTitle: t('Installing Slack Integration'), + getCompletionData: pipelineComplete, + steps: [ + { + stepId: 'oauth_login', + shortDescription: t('Authorizing via Slack OAuth'), + component: SlackOAuthLoginStep, + }, + ], +} as const satisfies PipelineDefinition; diff --git a/static/app/components/pipeline/registry.tsx b/static/app/components/pipeline/registry.tsx index 4c8c5be60ded33..0fc5c31e5a7b80 100644 --- a/static/app/components/pipeline/registry.tsx +++ b/static/app/components/pipeline/registry.tsx @@ -1,6 +1,7 @@ import {dummyIntegrationPipeline} from './pipelineDummyProvider'; import {githubIntegrationPipeline} from './pipelineIntegrationGitHub'; import {gitlabIntegrationPipeline} from './pipelineIntegrationGitLab'; +import {slackIntegrationPipeline} from './pipelineIntegrationSlack'; /** * All registered pipeline definitions. @@ -9,6 +10,7 @@ export const PIPELINE_REGISTRY = [ dummyIntegrationPipeline, githubIntegrationPipeline, gitlabIntegrationPipeline, + slackIntegrationPipeline, ] as const; type AllPipelines = (typeof PIPELINE_REGISTRY)[number];