Skip to content

Commit dce0ad1

Browse files
evanpurkhisergeorge-sentry
authored andcommitted
feat(slack): Add frontend pipeline step for Slack integration setup (#112417)
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)
1 parent a4f4821 commit dce0ad1

File tree

3 files changed

+156
-0
lines changed

3 files changed

+156
-0
lines changed
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import {act, render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
2+
3+
import {slackIntegrationPipeline} from './pipelineIntegrationSlack';
4+
import type {PipelineStepProps} from './types';
5+
6+
const SlackOAuthLoginStep = slackIntegrationPipeline.steps[0].component;
7+
8+
function makeStepProps<D, A>(
9+
overrides: Partial<PipelineStepProps<D, A>> & {stepData: D}
10+
): PipelineStepProps<D, A> {
11+
return {
12+
advance: jest.fn(),
13+
advanceError: null,
14+
isAdvancing: false,
15+
stepIndex: 0,
16+
totalSteps: 1,
17+
...overrides,
18+
};
19+
}
20+
21+
let mockPopup: Window;
22+
23+
function dispatchPipelineMessage({
24+
data,
25+
origin = document.location.origin,
26+
source = mockPopup,
27+
}: {
28+
data: Record<string, string>;
29+
origin?: string;
30+
source?: Window | MessageEventSource | null;
31+
}) {
32+
act(() => {
33+
const event = new MessageEvent('message', {data, origin});
34+
Object.defineProperty(event, 'source', {value: source});
35+
window.dispatchEvent(event);
36+
});
37+
}
38+
39+
beforeEach(() => {
40+
mockPopup = {
41+
closed: false,
42+
close: jest.fn(),
43+
focus: jest.fn(),
44+
} as unknown as Window;
45+
jest.spyOn(window, 'open').mockReturnValue(mockPopup);
46+
});
47+
48+
afterEach(() => {
49+
jest.restoreAllMocks();
50+
});
51+
52+
describe('SlackOAuthLoginStep', () => {
53+
it('renders the OAuth login step for Slack', () => {
54+
render(
55+
<SlackOAuthLoginStep
56+
{...makeStepProps({stepData: {oauthUrl: 'https://slack.com/oauth/authorize'}})}
57+
/>
58+
);
59+
60+
expect(screen.getByRole('button', {name: 'Authorize Slack'})).toBeInTheDocument();
61+
});
62+
63+
it('calls advance with code and state on OAuth callback', async () => {
64+
const advance = jest.fn();
65+
render(
66+
<SlackOAuthLoginStep
67+
{...makeStepProps({
68+
stepData: {oauthUrl: 'https://slack.com/oauth/authorize'},
69+
advance,
70+
})}
71+
/>
72+
);
73+
74+
await userEvent.click(screen.getByRole('button', {name: 'Authorize Slack'}));
75+
76+
dispatchPipelineMessage({
77+
data: {
78+
_pipeline_source: 'sentry-pipeline',
79+
code: 'auth-code-123',
80+
state: 'state-xyz',
81+
},
82+
});
83+
84+
expect(advance).toHaveBeenCalledWith({
85+
code: 'auth-code-123',
86+
state: 'state-xyz',
87+
});
88+
});
89+
90+
it('shows loading state when isAdvancing is true', () => {
91+
render(
92+
<SlackOAuthLoginStep
93+
{...makeStepProps({
94+
stepData: {oauthUrl: 'https://slack.com/oauth/authorize'},
95+
isAdvancing: true,
96+
})}
97+
/>
98+
);
99+
100+
expect(screen.getByRole('button', {name: 'Authorizing...'})).toBeDisabled();
101+
});
102+
103+
it('disables authorize button when oauthUrl is not provided', () => {
104+
render(<SlackOAuthLoginStep {...makeStepProps({stepData: {}})} />);
105+
106+
expect(screen.getByRole('button', {name: 'Authorize Slack'})).toBeDisabled();
107+
});
108+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {useCallback} from 'react';
2+
3+
import {t} from 'sentry/locale';
4+
import type {IntegrationWithConfig} from 'sentry/types/integrations';
5+
6+
import type {OAuthCallbackData} from './shared/oauthLoginStep';
7+
import {OAuthLoginStep} from './shared/oauthLoginStep';
8+
import type {PipelineDefinition, PipelineStepProps} from './types';
9+
import {pipelineComplete} from './types';
10+
11+
function SlackOAuthLoginStep({
12+
stepData,
13+
advance,
14+
isAdvancing,
15+
}: PipelineStepProps<{oauthUrl?: string}, {code: string; state: string}>) {
16+
const handleOAuthCallback = useCallback(
17+
(data: OAuthCallbackData) => {
18+
advance({code: data.code, state: data.state});
19+
},
20+
[advance]
21+
);
22+
23+
return (
24+
<OAuthLoginStep
25+
oauthUrl={stepData.oauthUrl}
26+
isLoading={isAdvancing}
27+
serviceName="Slack"
28+
onOAuthCallback={handleOAuthCallback}
29+
popup={{height: 900}}
30+
/>
31+
);
32+
}
33+
34+
export const slackIntegrationPipeline = {
35+
type: 'integration',
36+
provider: 'slack',
37+
actionTitle: t('Installing Slack Integration'),
38+
getCompletionData: pipelineComplete<IntegrationWithConfig>,
39+
steps: [
40+
{
41+
stepId: 'oauth_login',
42+
shortDescription: t('Authorizing via Slack OAuth'),
43+
component: SlackOAuthLoginStep,
44+
},
45+
],
46+
} as const satisfies PipelineDefinition;

static/app/components/pipeline/registry.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {dummyIntegrationPipeline} from './pipelineDummyProvider';
22
import {bitbucketIntegrationPipeline} from './pipelineIntegrationBitbucket';
33
import {githubIntegrationPipeline} from './pipelineIntegrationGitHub';
44
import {gitlabIntegrationPipeline} from './pipelineIntegrationGitLab';
5+
import {slackIntegrationPipeline} from './pipelineIntegrationSlack';
56

67
/**
78
* All registered pipeline definitions.
@@ -11,6 +12,7 @@ export const PIPELINE_REGISTRY = [
1112
dummyIntegrationPipeline,
1213
githubIntegrationPipeline,
1314
gitlabIntegrationPipeline,
15+
slackIntegrationPipeline,
1416
] as const;
1517

1618
type AllPipelines = (typeof PIPELINE_REGISTRY)[number];

0 commit comments

Comments
 (0)