Skip to content

Commit 3f79427

Browse files
committed
feat(pipeline): Add completion view support to pipeline definitions
Add a completionView property to PipelineDefinition that allows pipelines to render a custom view after completion before firing onComplete. When set, onComplete is deferred until the component calls finish(). When null, existing behavior is preserved. Adds JSDoc comments to PipelineDefinition and PipelineStepDefinition.
1 parent 42d5f41 commit 3f79427

File tree

9 files changed

+177
-47
lines changed

9 files changed

+177
-47
lines changed

static/app/components/pipeline/modal.tsx

Lines changed: 44 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -61,49 +61,51 @@ function PipelineModal<
6161
</Header>
6262
<Body>
6363
<Stack gap="2xl">
64-
<Grid columns="1fr max-content">
65-
<Flex gap="md" align="center">
66-
<ProgressRing
67-
maxValue={pipeline.totalSteps}
68-
value={pipeline.stepIndex + 1}
69-
text={pipeline.stepIndex + 1}
70-
animate
71-
/>
72-
<Grid>
73-
<AnimatePresence>
74-
<motion.div
75-
key={stepDefinition?.stepId}
76-
initial={pipeline.stepIndex === 0 ? {} : {y: -15, opacity: 0}}
77-
animate={{y: 0, opacity: 1}}
78-
exit={{y: 15, opacity: 0}}
79-
transition={{duration: 0.2}}
80-
style={{gridColumn: 1, gridRow: 1}}
81-
>
82-
{stepText}
83-
</motion.div>
84-
</AnimatePresence>
85-
</Grid>
86-
</Flex>
87-
<Flex gap="md" align="center">
88-
{loading && (
89-
<LoadingIndicator
90-
mini
91-
size={20}
92-
style={{margin: 0, height: 20, width: 20}}
64+
{!pipeline.isComplete && (
65+
<Grid columns="1fr max-content">
66+
<Flex gap="md" align="center">
67+
<ProgressRing
68+
maxValue={pipeline.totalSteps}
69+
value={pipeline.stepIndex + 1}
70+
text={pipeline.stepIndex + 1}
71+
animate
9372
/>
94-
)}
95-
{pipeline.stepIndex !== 0 && (
96-
<Button
97-
size="zero"
98-
priority="transparent"
99-
onClick={pipeline.restart}
100-
icon={<IconRefresh size="xs" variant="muted" />}
101-
tooltipProps={{title: t('Restart flow')}}
102-
aria-label={t('Restart flow')}
103-
/>
104-
)}
105-
</Flex>
106-
</Grid>
73+
<Grid>
74+
<AnimatePresence>
75+
<motion.div
76+
key={stepDefinition?.stepId}
77+
initial={pipeline.stepIndex === 0 ? {} : {y: -15, opacity: 0}}
78+
animate={{y: 0, opacity: 1}}
79+
exit={{y: 15, opacity: 0}}
80+
transition={{duration: 0.2}}
81+
style={{gridColumn: 1, gridRow: 1}}
82+
>
83+
{stepText}
84+
</motion.div>
85+
</AnimatePresence>
86+
</Grid>
87+
</Flex>
88+
<Flex gap="md" align="center">
89+
{loading && (
90+
<LoadingIndicator
91+
mini
92+
size={20}
93+
style={{margin: 0, height: 20, width: 20}}
94+
/>
95+
)}
96+
{pipeline.stepIndex !== 0 && (
97+
<Button
98+
size="zero"
99+
priority="transparent"
100+
onClick={pipeline.restart}
101+
icon={<IconRefresh size="xs" variant="muted" />}
102+
tooltipProps={{title: t('Restart flow')}}
103+
aria-label={t('Restart flow')}
104+
/>
105+
)}
106+
</Flex>
107+
</Grid>
108+
)}
107109

108110
{pipeline.error && (
109111
<Alert

static/app/components/pipeline/pipelineDummyProvider.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import {Text} from '@sentry/scraps/text';
77

88
import {t} from 'sentry/locale';
99

10-
import type {PipelineDefinition, PipelineStepProps} from './types';
10+
import type {
11+
PipelineCompletionProps,
12+
PipelineDefinition,
13+
PipelineStepProps,
14+
} from './types';
1115
import {pipelineComplete} from './types';
1216

1317
function DummyStepOne({
@@ -63,11 +67,26 @@ type DummyCompletionData = {
6367
result: string;
6468
};
6569

70+
function DummyCompletionView({
71+
data,
72+
finish,
73+
}: PipelineCompletionProps<DummyCompletionData>) {
74+
return (
75+
<Stack gap="md">
76+
<Text>{data.result}</Text>
77+
<Button size="sm" priority="primary" onClick={finish}>
78+
{t('Done')}
79+
</Button>
80+
</Stack>
81+
);
82+
}
83+
6684
export const dummyIntegrationPipeline = {
6785
type: 'integration',
6886
provider: 'dummy',
6987
actionTitle: t('Dummy Integration Pipeline'),
7088
getCompletionData: pipelineComplete<DummyCompletionData>,
89+
completionView: DummyCompletionView,
7190
steps: [
7291
{
7392
stepId: 'step_one',

static/app/components/pipeline/pipelineIntegrationBitbucket.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export const bitbucketIntegrationPipeline = {
8686
provider: 'bitbucket',
8787
actionTitle: t('Installing Bitbucket Integration'),
8888
getCompletionData: pipelineComplete<IntegrationWithConfig>,
89+
completionView: null,
8990
steps: [
9091
{
9192
stepId: 'authorize',

static/app/components/pipeline/pipelineIntegrationGitHub.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ export const githubIntegrationPipeline = {
261261
provider: 'github',
262262
actionTitle: t('Installing GitHub Integration'),
263263
getCompletionData: pipelineComplete<IntegrationWithConfig>,
264+
completionView: null,
264265
steps: [
265266
{
266267
stepId: 'oauth_login',

static/app/components/pipeline/pipelineIntegrationGitLab.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ export const gitlabIntegrationPipeline = {
279279
provider: 'gitlab',
280280
actionTitle: t('Installing GitLab Integration'),
281281
getCompletionData: pipelineComplete<IntegrationWithConfig>,
282+
completionView: null,
282283
steps: [
283284
{
284285
stepId: 'installation_config',

static/app/components/pipeline/pipelineIntegrationSlack.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export const slackIntegrationPipeline = {
3636
provider: 'slack',
3737
actionTitle: t('Installing Slack Integration'),
3838
getCompletionData: pipelineComplete<IntegrationWithConfig>,
39+
completionView: null,
3940
steps: [
4041
{
4142
stepId: 'oauth_login',

static/app/components/pipeline/types.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ export interface PipelineStepDefinition<StepId extends string = string> {
2828
stepId: StepId;
2929
}
3030

31+
/**
32+
* Props passed to a pipeline's completion view component.
33+
*/
34+
export interface PipelineCompletionProps<D = unknown> {
35+
data: D;
36+
finish: () => void;
37+
}
38+
3139
/**
3240
* Defines a complete pipeline with its type, provider, and ordered steps.
3341
*/
@@ -39,6 +47,12 @@ export interface PipelineDefinition<
3947
* Title displayed in the pipeline modal header.
4048
*/
4149
actionTitle: string;
50+
/**
51+
* Component rendered after the pipeline completes. When set, `onComplete`
52+
* is deferred until the component calls `finish()`. When null, `onComplete`
53+
* fires immediately on completion.
54+
*/
55+
completionView: React.ComponentType<PipelineCompletionProps<any>> | null;
4256
/**
4357
* Casts the raw completion response to the typed completion data shape.
4458
* Use the {@link pipelineComplete} helper for this.

static/app/components/pipeline/usePipeline.spec.tsx

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import {usePipeline} from './usePipeline';
77
const organization = OrganizationFixture();
88
const API_URL = `/organizations/${organization.slug}/pipeline/integration_pipeline/`;
99

10-
function TestHarness() {
11-
const pipeline = usePipeline('integration', 'dummy');
10+
function TestHarness({onComplete}: {onComplete?: (data: any) => void} = {}) {
11+
const pipeline = usePipeline('integration', 'dummy', {onComplete});
1212

1313
return (
1414
<div>
@@ -239,6 +239,71 @@ describe('usePipeline', () => {
239239
expect(screen.getByTestId('step-index')).toHaveTextContent('0');
240240
});
241241

242+
it('renders the completion view and defers onComplete until finish is called', async () => {
243+
const onComplete = jest.fn();
244+
245+
MockApiClient.addMockResponse({
246+
url: API_URL,
247+
method: 'POST',
248+
body: {
249+
step: 'step_one',
250+
stepIndex: 0,
251+
totalSteps: 2,
252+
provider: 'dummy',
253+
data: {message: 'Enter your name'},
254+
},
255+
match: [MockApiClient.matchData({action: 'initialize', provider: 'dummy'})],
256+
});
257+
258+
MockApiClient.addMockResponse({
259+
url: API_URL,
260+
method: 'POST',
261+
body: {
262+
status: 'advance',
263+
step: 'step_two',
264+
stepIndex: 1,
265+
totalSteps: 2,
266+
provider: 'dummy',
267+
data: {greeting: 'Hello, Test!'},
268+
},
269+
match: [MockApiClient.matchData({name: 'Test'})],
270+
});
271+
272+
render(<TestHarness onComplete={onComplete} />, {organization});
273+
274+
// Advance through step one
275+
expect(await screen.findByText('Enter your name')).toBeInTheDocument();
276+
await userEvent.type(screen.getByRole('textbox', {name: 'Your name'}), 'Test');
277+
await userEvent.click(screen.getByRole('button', {name: 'Continue'}));
278+
279+
// Advance through step two
280+
expect(await screen.findByText('Hello, Test!')).toBeInTheDocument();
281+
282+
MockApiClient.addMockResponse({
283+
url: API_URL,
284+
method: 'POST',
285+
body: {
286+
status: 'complete',
287+
data: {result: 'Pipeline finished successfully'},
288+
},
289+
});
290+
291+
await userEvent.click(screen.getByRole('button', {name: 'Finish'}));
292+
293+
// Completion view should render with the result text and a Done button
294+
expect(await screen.findByText('Pipeline finished successfully')).toBeInTheDocument();
295+
expect(screen.getByRole('button', {name: 'Done'})).toBeInTheDocument();
296+
expect(screen.getByTestId('is-complete')).toHaveTextContent('true');
297+
298+
// onComplete should NOT have been called yet — it's deferred
299+
expect(onComplete).not.toHaveBeenCalled();
300+
301+
// Clicking Done calls finish(), which fires onComplete
302+
await userEvent.click(screen.getByRole('button', {name: 'Done'}));
303+
304+
expect(onComplete).toHaveBeenCalledWith({result: 'Pipeline finished successfully'});
305+
});
306+
242307
it('does not initialize when enabled is false', async () => {
243308
const initRequest = MockApiClient.addMockResponse({
244309
url: API_URL,

static/app/components/pipeline/usePipeline.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
import type {
1313
ApiPipeline,
1414
PipelineAdvanceResponse,
15+
PipelineCompletionProps,
1516
PipelineStepProps,
1617
PipelineStepResponse,
1718
} from './types';
@@ -189,7 +190,11 @@ export function usePipeline<
189190
const data = definition.getCompletionData(rawData) as CompletionDataFor<T, P>;
190191

191192
setState({status: 'complete', data});
192-
onCompleteRef.current?.(data);
193+
194+
// If there's no completion view, fire onComplete immediately.
195+
if (!definition.completionView) {
196+
onCompleteRef.current?.(data);
197+
}
193198
break;
194199
}
195200
case 'error':
@@ -237,7 +242,20 @@ export function usePipeline<
237242
return null;
238243
}, [state, definition]);
239244

245+
const finish = useCallback(() => {
246+
if (state.status === 'complete') {
247+
onCompleteRef.current?.(state.data);
248+
}
249+
}, [state]);
250+
240251
const view = useMemo(() => {
252+
// Render completion view when the pipeline is complete and one is defined
253+
if (state.status === 'complete' && definition.completionView) {
254+
const CompletionView: React.ComponentType<PipelineCompletionProps<any>> =
255+
definition.completionView;
256+
return <CompletionView data={state.data} finish={finish} />;
257+
}
258+
241259
if (!stepDefinition) {
242260
return null;
243261
}
@@ -259,7 +277,15 @@ export function usePipeline<
259277
advanceError={advanceError}
260278
/>
261279
);
262-
}, [state, stepDefinition, definition, advanceMutate, isAdvancePending, advanceError]);
280+
}, [
281+
state,
282+
stepDefinition,
283+
definition,
284+
advanceMutate,
285+
isAdvancePending,
286+
advanceError,
287+
finish,
288+
]);
263289

264290
const {stepIndex, totalSteps} =
265291
state.status === 'active'

0 commit comments

Comments
 (0)