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