From 53df997425c6976ab73b442106fd602bc566c6d9 Mon Sep 17 00:00:00 2001
From: Danny White <3104761+dnywh@users.noreply.github.com>
Date: Fri, 28 Aug 2026 09:43:32 +1000
Subject: [PATCH] feat(studio): add SteppedFlow component (#49583)
## What kind of change does this PR introduce?
Feature (shared UI primitive).
## What is the current behavior?
No shared stepped wizard shell in Studio. Upcoming create-pipeline work
needs a reusable step container with header, progress, and
primary/secondary actions.
## What is the new behavior?
Adds `SteppedFlow` and `SteppedFlowHeader` with unit tests: step list,
current step content, next/back, optional first-step cancel, and header
actions slot.
No product callsite yet. Safe to merge on its own; the create-pipeline
wizard PR will consume it.
## To test
Code review + vitest:
```sh
cd apps/studio && pnpm exec vitest run components/ui/SteppedFlow/SteppedFlow.test.tsx
```
## Summary by CodeRabbit
* **New Features**
* Added a reusable stepped-flow interface with progress indicators and
step-specific content.
* Supports optional headers, actions, loading and disabled states,
forms, and configurable button types.
* Added navigation controls for moving forward, going back, cancelling,
and completing the flow.
* Supports customizable final actions.
* **Tests**
* Added coverage for navigation, cancellation, headers, optional
actions, final actions, and disabled states.
---
.../ui/SteppedFlow/SteppedFlow.test.tsx | 173 ++++++++++++++++++
.../components/ui/SteppedFlow/SteppedFlow.tsx | 164 +++++++++++++++++
2 files changed, 337 insertions(+)
create mode 100644 apps/studio/components/ui/SteppedFlow/SteppedFlow.test.tsx
create mode 100644 apps/studio/components/ui/SteppedFlow/SteppedFlow.tsx
diff --git a/apps/studio/components/ui/SteppedFlow/SteppedFlow.test.tsx b/apps/studio/components/ui/SteppedFlow/SteppedFlow.test.tsx
new file mode 100644
index 0000000000000..c0c65150a3d0c
--- /dev/null
+++ b/apps/studio/components/ui/SteppedFlow/SteppedFlow.test.tsx
@@ -0,0 +1,173 @@
+import { fireEvent, screen } from '@testing-library/react'
+import { describe, expect, test, vi } from 'vitest'
+
+import { SteppedFlow, SteppedFlowHeader } from './SteppedFlow'
+import { customRender } from '@/tests/lib/custom-render'
+
+const steps = [
+ { id: 'destination', label: 'Destination' },
+ { id: 'connection', label: 'Connection' },
+ { id: 'review', label: 'Review' },
+]
+
+describe('SteppedFlow', () => {
+ test('does not show Back on the first step', () => {
+ customRender(
+
+ Step body
+
+ )
+
+ expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument()
+ })
+
+ test('shows Cancel on the first step when onCancel is provided', () => {
+ const onCancel = vi.fn()
+
+ customRender(
+
+ Step body
+
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+ expect(onCancel).toHaveBeenCalledOnce()
+ })
+
+ test('does not show Cancel after the first step', () => {
+ customRender(
+
+ Step body
+
+ )
+
+ expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument()
+ })
+
+ test('shows Back after the first step', () => {
+ const onStepChange = vi.fn()
+
+ customRender(
+
+ Step body
+
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Back' }))
+ expect(onStepChange).toHaveBeenCalledWith('destination')
+ })
+
+ test('advances to the next step when onNext is omitted', () => {
+ const onStepChange = vi.fn()
+
+ customRender(
+
+ Step body
+
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }))
+ expect(onStepChange).toHaveBeenCalledWith('connection')
+ })
+
+ test('shows the final action on the last step instead of Next', () => {
+ const onFinal = vi.fn()
+
+ customRender(
+
+ Step body
+
+ )
+
+ expect(screen.queryByRole('button', { name: /Next/ })).not.toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Create and start pipeline' }))
+ expect(onFinal).toHaveBeenCalledOnce()
+ })
+
+ test('renders a step header heading', () => {
+ customRender(
+
+
+
+ )
+
+ expect(screen.getByRole('heading', { name: 'Choose a destination' })).toBeInTheDocument()
+ expect(screen.getByText('Where should data go?')).toBeInTheDocument()
+ })
+
+ test('renders optional header actions', () => {
+ customRender(
+
+
+ Docs
+
+ }
+ />
+
+ )
+
+ expect(screen.getByRole('button', { name: 'Docs' })).toBeInTheDocument()
+ })
+
+ test('disables Back while navigation is locked', () => {
+ customRender(
+
+ Step body
+
+ )
+
+ expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled()
+ })
+
+ test('disables Next while navigation is locked', () => {
+ customRender(
+
+ Step body
+
+ )
+
+ expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled()
+ })
+
+ test('disables the final action while navigation is locked', () => {
+ customRender(
+
+ Step body
+
+ )
+
+ expect(screen.getByRole('button', { name: 'Create and start pipeline' })).toBeDisabled()
+ })
+})
diff --git a/apps/studio/components/ui/SteppedFlow/SteppedFlow.tsx b/apps/studio/components/ui/SteppedFlow/SteppedFlow.tsx
new file mode 100644
index 0000000000000..0d149df260cee
--- /dev/null
+++ b/apps/studio/components/ui/SteppedFlow/SteppedFlow.tsx
@@ -0,0 +1,164 @@
+import { type ReactNode } from 'react'
+import { Button, Card, CardFooter, CardHeader, cn } from 'ui'
+
+export type SteppedFlowStep = {
+ id: string
+ label: string
+}
+
+export type SteppedFlowFinalAction = {
+ label: string
+ onClick?: () => void
+ loading?: boolean
+ disabled?: boolean
+ form?: string
+ type?: 'button' | 'submit'
+}
+
+export const SteppedFlowHeader = ({
+ title,
+ description,
+ actions,
+ children,
+}: {
+ title: string
+ description?: ReactNode
+ actions?: ReactNode
+ children?: ReactNode
+}) => {
+ return (
+
+
+
+
+
{title}
+ {description ?
{description}
: null}
+
+ {actions ?
{actions}
: null}
+
+ {children}
+
+
+ )
+}
+
+export interface SteppedFlowProps {
+ steps: SteppedFlowStep[]
+ currentStep: string
+ onStepChange: (stepId: string) => void
+ nextDisabled?: boolean
+ nextLabel?: string
+ onNext?: () => void
+ nextLoading?: boolean
+ navigationDisabled?: boolean
+ onCancel?: () => void
+ cancelLabel?: string
+ finalAction?: SteppedFlowFinalAction
+ children: ReactNode
+}
+
+export const SteppedFlow = ({
+ steps,
+ currentStep,
+ onStepChange,
+ nextDisabled = false,
+ nextLabel = 'Next',
+ onNext,
+ nextLoading = false,
+ navigationDisabled = false,
+ onCancel,
+ cancelLabel = 'Cancel',
+ finalAction,
+ children,
+}: SteppedFlowProps) => {
+ const currentIndex = Math.max(
+ 0,
+ steps.findIndex((step) => step.id === currentStep)
+ )
+ const stepCount = steps.length
+ const isLastStep = stepCount > 0 && currentIndex === stepCount - 1
+ const isFirstStep = currentIndex === 0
+ const currentStepLabel = steps[currentIndex]?.label
+ const showCancel = isFirstStep && !!onCancel
+ const nextStepId = steps[currentIndex + 1]?.id
+
+ const handleNext = () => {
+ if (onNext) {
+ onNext()
+ return
+ }
+
+ if (nextStepId) {
+ onStepChange(nextStepId)
+ }
+ }
+
+ if (stepCount === 0) {
+ return null
+ }
+
+ return (
+
+
+
+ Step {currentIndex + 1} of {stepCount}
+ {currentStepLabel ? ` ยท ${currentStepLabel}` : ''}
+
+
+ {children}
+ 0 || showCancel ? 'justify-between' : 'justify-end')}
+ >
+ {currentIndex > 0 ? (
+
+ ) : null}
+ {currentIndex === 0 && showCancel ? (
+
+ ) : null}
+
+ {isLastStep && finalAction ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ )
+}