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 ? ( + + ) : ( + + )} +
+
+
+
+
+ ) +}