Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions apps/studio/components/ui/SteppedFlow/SteppedFlow.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<SteppedFlow steps={steps} currentStep="destination" onStepChange={vi.fn()}>
Step body
</SteppedFlow>
)

expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument()
})

test('shows Cancel on the first step when onCancel is provided', () => {
const onCancel = vi.fn()

customRender(
<SteppedFlow
steps={steps}
currentStep="destination"
onStepChange={vi.fn()}
onCancel={onCancel}
>
Step body
</SteppedFlow>
)

fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onCancel).toHaveBeenCalledOnce()
})

test('does not show Cancel after the first step', () => {
customRender(
<SteppedFlow steps={steps} currentStep="connection" onStepChange={vi.fn()} onCancel={vi.fn()}>
Step body
</SteppedFlow>
)

expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument()
})

test('shows Back after the first step', () => {
const onStepChange = vi.fn()

customRender(
<SteppedFlow steps={steps} currentStep="connection" onStepChange={onStepChange}>
Step body
</SteppedFlow>
)

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(
<SteppedFlow steps={steps} currentStep="destination" onStepChange={onStepChange}>
Step body
</SteppedFlow>
)

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(
<SteppedFlow
steps={steps}
currentStep="review"
onStepChange={vi.fn()}
onNext={vi.fn()}
finalAction={{ label: 'Create and start pipeline', onClick: onFinal }}
>
Step body
</SteppedFlow>
)

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(
<SteppedFlow steps={steps} currentStep="destination" onStepChange={vi.fn()}>
<SteppedFlowHeader title="Choose a destination" description="Where should data go?" />
</SteppedFlow>
)

expect(screen.getByRole('heading', { name: 'Choose a destination' })).toBeInTheDocument()
expect(screen.getByText('Where should data go?')).toBeInTheDocument()
})

test('renders optional header actions', () => {
customRender(
<SteppedFlow steps={steps} currentStep="connection" onStepChange={vi.fn()}>
<SteppedFlowHeader
title="Authorize the destination"
description="Name this pipeline."
actions={
<button type="button" tabIndex={0}>
Docs
</button>
}
/>
</SteppedFlow>
)

expect(screen.getByRole('button', { name: 'Docs' })).toBeInTheDocument()
})

test('disables Back while navigation is locked', () => {
customRender(
<SteppedFlow
steps={steps}
currentStep="review"
onStepChange={vi.fn()}
navigationDisabled
finalAction={{ label: 'Create and start pipeline', loading: true }}
>
Step body
</SteppedFlow>
)

expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled()
})

test('disables Next while navigation is locked', () => {
customRender(
<SteppedFlow
steps={steps}
currentStep="destination"
onStepChange={vi.fn()}
navigationDisabled
>
Step body
</SteppedFlow>
)

expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled()
})

test('disables the final action while navigation is locked', () => {
customRender(
<SteppedFlow
steps={steps}
currentStep="review"
onStepChange={vi.fn()}
navigationDisabled
finalAction={{ label: 'Create and start pipeline' }}
>
Step body
</SteppedFlow>
)

expect(screen.getByRole('button', { name: 'Create and start pipeline' })).toBeDisabled()
})
})
164 changes: 164 additions & 0 deletions apps/studio/components/ui/SteppedFlow/SteppedFlow.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<CardHeader>
<header className="flex flex-col space-y-1">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1 space-y-1">
<h2 className="text-lg text-foreground">{title}</h2>
{description ? <p className="text-sm text-foreground-light">{description}</p> : null}
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}
</div>
{children}
</header>
</CardHeader>
)
}

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 (
<div className="flex min-h-full flex-col">
<div className="mx-auto w-full max-w-[760px] flex-1 px-6 pt-8 pb-10">
<p className="mb-4 text-xs text-foreground-lighter" role="status">
Step {currentIndex + 1} of {stepCount}
{currentStepLabel ? ` 路 ${currentStepLabel}` : ''}
</p>
<Card
key={currentStep}
className="animate-in fade-in-0 duration-200 motion-reduce:animate-none"
>
{children}
<CardFooter
className={cn(currentIndex > 0 || showCancel ? 'justify-between' : 'justify-end')}
>
{currentIndex > 0 ? (
<Button
type="button"
variant="default"
disabled={navigationDisabled}
onClick={() => onStepChange(steps[currentIndex - 1].id)}
>
Back
</Button>
) : null}
{currentIndex === 0 && showCancel ? (
<Button
type="button"
variant="default"
disabled={navigationDisabled}
onClick={onCancel}
>
{cancelLabel}
</Button>
) : null}
<div className="flex items-center gap-2">
{isLastStep && finalAction ? (
<Button
type={finalAction.type ?? (finalAction.form ? 'submit' : 'button')}
form={finalAction.form}
variant="primary"
loading={finalAction.loading}
disabled={navigationDisabled || finalAction.disabled}
onClick={finalAction.onClick}
>
{finalAction.label}
</Button>
) : (
<Button
type="button"
variant="primary"
disabled={navigationDisabled || nextDisabled || !nextStepId}
loading={nextLoading}
onClick={handleNext}
>
{nextLabel}
</Button>
)}
</div>
</CardFooter>
</Card>
</div>
</div>
)
}
Loading