mirror of
https://github.com/supabase/supabase.git
synced 2026-10-19 16:19:10 +03:00
polish create pipeline wizard UI and docs
- Replace sidebar stepper with single-column Card layout using CardContent for automatic section dividers - Add radio card variant to DestinationTypeSelection for the wizard - Replace Enable Pipelines full-page gate with a simple AlertDialog - Add 200ms fade transition between steps - autoFocus the name input on the connection step - Guard against alpha gate flash by waiting for flag store - Add beforeunload guard for dirty forms - Document title case convention for browser tab titles - Title case the "New Pipeline" browser tab title
This commit is contained in:
1 parent
e5f23b8824
commit
d2a8da188b
10 files changed
+309
-231
No files matched your search
@@ -127,7 +127,7 @@ Supabase UI copy is:
|
||||
|
||||
### Use title case for page titles and global navigation
|
||||
|
||||
Use title case for page names in the main nav and document titles (e.g. "Database Settings", "Project Settings"). This distinguishes the page as a destination from section labels and in-page headings, which use sentence case.
|
||||
Use title case for page names in the main nav and document titles (e.g. "Database Settings", "Project Settings"). This includes browser tab titles set via the `browserTitle` prop on layout components (e.g. "New Pipeline", not "New pipeline").
|
||||
|
||||
### Use declarative page descriptions
|
||||
|
||||
|
||||
+164
-71
@@ -1,6 +1,6 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import { useFeatureFlags, useParams } from 'common'
|
||||
import { ArrowUpRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
@@ -8,7 +8,21 @@ import { parseAsStringEnum, useQueryState } from 'nuqs'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useForm, useWatch } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import { Button, cn, Form } from 'ui'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
Button,
|
||||
CardContent,
|
||||
cn,
|
||||
Form,
|
||||
} from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { AdvancedSettings } from '../DestinationPanel/DestinationForm/AdvancedSettings'
|
||||
@@ -40,7 +54,6 @@ import { ValidationFailuresSection } from '../DestinationPanel/DestinationForm/V
|
||||
import { ValidationWarningsDialog } from '../DestinationPanel/DestinationForm/ValidationWarningsDialog'
|
||||
import type { DestinationType } from '../DestinationPanel/DestinationPanel.types'
|
||||
import { DestinationTypeSelection } from '../DestinationPanel/DestinationTypeSelection'
|
||||
import { EnablePipelinesCallout } from '../EnablePipelinesCallout'
|
||||
import { LocalReplicationUnavailableAdmonition } from '../LocalReplicationUnavailableAdmonition'
|
||||
import {
|
||||
useIsETLBigQueryPrivateAlpha,
|
||||
@@ -66,12 +79,14 @@ import { DocsButton } from '@/components/ui/DocsButton'
|
||||
import { SteppedFlow } from '@/components/ui/SteppedFlow/SteppedFlow'
|
||||
import { useAPIKeys } from '@/data/api-keys/api-keys-query'
|
||||
import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
|
||||
import { useCreateTenantSourceMutation } from '@/data/replication/create-tenant-source-mutation'
|
||||
import { useReplicationPublicationsQuery } from '@/data/replication/publications-query'
|
||||
import {
|
||||
useReplicationSourceId,
|
||||
useReplicationSourcesQuery,
|
||||
} from '@/data/replication/sources-query'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
|
||||
@@ -80,6 +95,9 @@ const formId = 'create-pipeline'
|
||||
export const CreatePipelineWizard = () => {
|
||||
const router = useRouter()
|
||||
const { ref: projectRef } = useParams()
|
||||
const { isLoading: isOrgLoading } = useSelectedOrganizationQuery()
|
||||
const { configcat: flagStore } = useFeatureFlags()
|
||||
const isFlagStoreLoaded = Object.keys(flagStore).length > 0
|
||||
const enablePgReplicate = useIsETLPrivateAlpha()
|
||||
const etlEnableBigQuery = useIsETLBigQueryPrivateAlpha()
|
||||
const etlEnableIceberg = useIsETLIcebergPrivateAlpha()
|
||||
@@ -125,6 +143,12 @@ export const CreatePipelineWizard = () => {
|
||||
)
|
||||
const replicationNotEnabled = isSourcesSuccess && !externalReplicationSource
|
||||
|
||||
const { mutate: createTenantSource, isPending: isEnablingPipelines } =
|
||||
useCreateTenantSourceMutation({
|
||||
onSuccess: () => toast.success('Pipelines enabled'),
|
||||
onError: (error) => toast.error(`Failed to enable Pipelines: ${error.message}`),
|
||||
})
|
||||
|
||||
const availableDestinations = useMemo(() => {
|
||||
const destinations: DestinationType[] = []
|
||||
if (etlEnableBigQuery) destinations.push('BigQuery')
|
||||
@@ -276,6 +300,16 @@ export const CreatePipelineWizard = () => {
|
||||
})
|
||||
useRegisterIsolatedStudioFlowClose(confirmOnClose)
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (isDirty || step !== 'destination') {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [isDirty, step])
|
||||
|
||||
const canContinueFromDestination = selectedType !== null
|
||||
const canContinueFromConnection =
|
||||
selectedType !== null && hasValidConnection({ type: selectedType, data: formValues })
|
||||
@@ -404,6 +438,14 @@ export const CreatePipelineWizard = () => {
|
||||
? `${DOCS_URL}/guides/database/replication/bigquery#configure-bigquery-as-a-destination`
|
||||
: `${DOCS_URL}/guides/database/replication/pipelines#step-3-configure-a-destination`
|
||||
|
||||
if (isOrgLoading || !isFlagStoreLoaded) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[760px] px-6 py-8">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!enablePgReplicate) {
|
||||
return (
|
||||
<CreatePipelineGate
|
||||
@@ -437,12 +479,13 @@ export const CreatePipelineWizard = () => {
|
||||
|
||||
if (replicationNotEnabled) {
|
||||
return (
|
||||
<CreatePipelineGate
|
||||
title="Enable Pipelines"
|
||||
description="Turn on Pipelines for this project before creating a destination."
|
||||
>
|
||||
<EnablePipelinesCallout className="p-6!" type={selectedType} />
|
||||
</CreatePipelineGate>
|
||||
<EnablePipelinesAlertDialog
|
||||
onEnable={() => {
|
||||
if (projectRef) createTenantSource({ projectRef })
|
||||
}}
|
||||
isEnabling={isEnablingPipelines}
|
||||
onCancel={goToList}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -475,92 +518,110 @@ export const CreatePipelineWizard = () => {
|
||||
}}
|
||||
>
|
||||
{step === 'destination' && (
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg text-foreground">Choose a destination</h2>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Where should this database be replicated?
|
||||
</p>
|
||||
<LocalReplicationUnavailableAdmonition className="mt-4" />
|
||||
<DestinationTypeSelection hideReadReplica className="p-0 pt-4" />
|
||||
</section>
|
||||
<>
|
||||
<CardContent className="space-y-1">
|
||||
<h2 className="text-lg text-foreground">Choose a destination</h2>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Where should this database be replicated?
|
||||
</p>
|
||||
<LocalReplicationUnavailableAdmonition className="pt-2" />
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<DestinationTypeSelection variant="radio" hideReadReplica />
|
||||
</CardContent>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'connection' && selectedType && (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<>
|
||||
<CardContent className="space-y-1">
|
||||
<h2 className="text-lg text-foreground">Authorize the destination</h2>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Name this pipeline and enter credentials for {selectedType}.
|
||||
</p>
|
||||
</div>
|
||||
<DestinationNameInput form={form} />
|
||||
<PipelineRegionField />
|
||||
{selectedType === 'BigQuery' && etlEnableBigQuery && (
|
||||
<BigQueryFields form={form} editMode={false} className="p-0" />
|
||||
)}
|
||||
{selectedType === 'Analytics Bucket' && etlEnableIceberg && (
|
||||
<AnalyticsBucketFields
|
||||
form={form}
|
||||
editMode={false}
|
||||
className="p-0"
|
||||
onSelectNewBucket={() => setNewBucketSheetVisible(true)}
|
||||
/>
|
||||
)}
|
||||
{selectedType === 'DuckLake' && etlEnableDucklake && (
|
||||
<DuckLakeFields form={form} editMode={false} className="p-0" />
|
||||
)}
|
||||
{selectedType === 'Snowflake' && etlEnableSnowflake && (
|
||||
<SnowflakeFields form={form} editMode={false} className="p-0" />
|
||||
)}
|
||||
{selectedType === 'ClickHouse' && etlEnableClickHouse && (
|
||||
<ClickHouseFields form={form} editMode={false} className="p-0" />
|
||||
)}
|
||||
</section>
|
||||
</CardContent>
|
||||
<CardContent className="space-y-6">
|
||||
<DestinationNameInput form={form} />
|
||||
<PipelineRegionField />
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
{selectedType === 'BigQuery' && etlEnableBigQuery && (
|
||||
<BigQueryFields form={form} editMode={false} className="p-0" />
|
||||
)}
|
||||
{selectedType === 'Analytics Bucket' && etlEnableIceberg && (
|
||||
<AnalyticsBucketFields
|
||||
form={form}
|
||||
editMode={false}
|
||||
className="p-0"
|
||||
onSelectNewBucket={() => setNewBucketSheetVisible(true)}
|
||||
/>
|
||||
)}
|
||||
{selectedType === 'DuckLake' && etlEnableDucklake && (
|
||||
<DuckLakeFields form={form} editMode={false} className="p-0" />
|
||||
)}
|
||||
{selectedType === 'Snowflake' && etlEnableSnowflake && (
|
||||
<SnowflakeFields form={form} editMode={false} className="p-0" />
|
||||
)}
|
||||
{selectedType === 'ClickHouse' && etlEnableClickHouse && (
|
||||
<ClickHouseFields form={form} editMode={false} className="p-0" />
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'data' && (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<>
|
||||
<CardContent className="space-y-1">
|
||||
<h2 className="text-lg text-foreground">Choose what to replicate</h2>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Select a publication and which existing rows to copy during initial sync.
|
||||
</p>
|
||||
</div>
|
||||
<PublicationSelection
|
||||
form={form}
|
||||
onSelectNewPublication={() => setPublicationPanelVisible(true)}
|
||||
/>
|
||||
<TableCopySelection form={form} editMode={false} />
|
||||
</section>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<PublicationSelection
|
||||
form={form}
|
||||
onSelectNewPublication={() => setPublicationPanelVisible(true)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<TableCopySelection form={form} editMode={false} />
|
||||
</CardContent>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'review' && selectedType && (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<>
|
||||
<CardContent className="space-y-1">
|
||||
<h2 className="text-lg text-foreground">Review and create</h2>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Confirm optional settings, then create and start the pipeline.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-light">
|
||||
<span className="text-foreground">{name || 'Untitled pipeline'}</span>
|
||||
{' · '}
|
||||
{selectedType}
|
||||
{publicationName ? ` · ${publicationName}` : ''}
|
||||
</p>
|
||||
<div className="-mx-5">
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<p className="text-sm text-foreground-light">
|
||||
<span className="text-foreground">{name || 'Untitled pipeline'}</span>
|
||||
{' · '}
|
||||
{selectedType}
|
||||
{publicationName ? ` · ${publicationName}` : ''}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<AdvancedSettings type={selectedType} form={form} />
|
||||
</div>
|
||||
</CardContent>
|
||||
{hasRunValidation && !isValidating && (
|
||||
<div ref={validationSectionRef}>
|
||||
<ValidationFailuresSection
|
||||
destinationFailures={destinationValidationFailures}
|
||||
pipelineFailures={pipelineValidationFailures}
|
||||
/>
|
||||
</div>
|
||||
<CardContent>
|
||||
<div ref={validationSectionRef}>
|
||||
<ValidationFailuresSection
|
||||
destinationFailures={destinationValidationFailures}
|
||||
pipelineFailures={pipelineValidationFailures}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
<DocsButton href={docsUrl} topic={`${selectedType} pipeline settings`} />
|
||||
</section>
|
||||
<CardContent>
|
||||
<DocsButton href={docsUrl} topic={`${selectedType} pipeline settings`} />
|
||||
</CardContent>
|
||||
</>
|
||||
)}
|
||||
</SteppedFlow>
|
||||
</form>
|
||||
@@ -610,3 +671,35 @@ export const CreatePipelineWizard = () => {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function EnablePipelinesAlertDialog({
|
||||
onEnable,
|
||||
isEnabling,
|
||||
onCancel,
|
||||
}: {
|
||||
onEnable: () => void
|
||||
isEnabling: boolean
|
||||
onCancel: () => void
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog open>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Enable Pipelines</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Pipelines replicates your database to external destinations. It is billed for configured
|
||||
pipeline hours and data processed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isEnabling} onClick={onCancel}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onEnable} disabled={isEnabling}>
|
||||
Enable
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -16,7 +16,7 @@ export const DestinationNameInput = ({ form }: DestinationNameInputProps) => {
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="Name" layout="horizontal">
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="My destination" />
|
||||
<Input {...field} autoFocus placeholder="My destination" />
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
|
||||
+68
-29
@@ -1,3 +1,4 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import { parseAsInteger, parseAsStringEnum, useQueryState } from 'nuqs'
|
||||
import {
|
||||
Badge,
|
||||
@@ -38,12 +39,27 @@ interface DestinationTypeGroup {
|
||||
options: DestinationTypeOption[]
|
||||
}
|
||||
|
||||
function StageBadge({ stage }: { stage: DestinationTypeOption['stage'] }) {
|
||||
if (!stage) return null
|
||||
return (
|
||||
<Badge
|
||||
variant={
|
||||
stage === 'Early Access' ? 'warning' : stage === 'Deprecated' ? 'destructive' : 'default'
|
||||
}
|
||||
>
|
||||
{stage}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
interface DestinationTypeSelectionProps {
|
||||
variant?: 'select' | 'radio'
|
||||
hideReadReplica?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const DestinationTypeSelection = ({
|
||||
variant = 'select',
|
||||
hideReadReplica = false,
|
||||
className,
|
||||
}: DestinationTypeSelectionProps) => {
|
||||
@@ -144,9 +160,9 @@ export const DestinationTypeSelection = ({
|
||||
.map((group) => ({ ...group, options: group.options.filter((option) => option.enabled) }))
|
||||
.filter((group) => group.options.length > 0)
|
||||
|
||||
const selectedOption = visibleGroups
|
||||
.flatMap((group) => group.options)
|
||||
.find((option) => option.value === destinationType)
|
||||
const allVisibleOptions = visibleGroups.flatMap((group) => group.options)
|
||||
|
||||
const selectedOption = allVisibleOptions.find((option) => option.value === destinationType)
|
||||
|
||||
const stageDescription =
|
||||
selectedOption?.stage === 'Public Alpha' ? (
|
||||
@@ -167,6 +183,53 @@ export const DestinationTypeSelection = ({
|
||||
'This destination type is deprecated.'
|
||||
) : null
|
||||
|
||||
if (variant === 'radio') {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div role="radiogroup" aria-label="Destination type" className="flex flex-col gap-3">
|
||||
{allVisibleOptions.map((option) => {
|
||||
const selected = destinationType === option.value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => setDestinationType(option.value)}
|
||||
className={cn(
|
||||
'relative flex items-start gap-x-4 rounded-md border p-4 text-left transition',
|
||||
'hover:border-control-hover',
|
||||
selected ? 'border-control-hover bg-surface-300' : 'border-default bg-surface-100'
|
||||
)}
|
||||
>
|
||||
<DestinationIcon
|
||||
type={option.value}
|
||||
size={20}
|
||||
className="mt-0.5 shrink-0 text-foreground-light"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-y-0.5">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="text-sm text-foreground">{option.label}</span>
|
||||
<StageBadge stage={option.stage} />
|
||||
</div>
|
||||
<span className="text-xs text-foreground-lighter">{option.description}</span>
|
||||
</div>
|
||||
{selected ? (
|
||||
<Check size={16} className="mt-0.5 shrink-0 text-brand" />
|
||||
) : (
|
||||
<span className="mt-0.5 h-4 w-4 shrink-0 rounded-full border border-strong" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{stageDescription && (
|
||||
<p className="mt-3 text-xs text-foreground-lighter">{stageDescription}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const typeDescription =
|
||||
!editMode || stageDescription ? (
|
||||
<span>
|
||||
@@ -199,19 +262,7 @@ export const DestinationTypeSelection = ({
|
||||
/>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="text-sm text-foreground">{selectedOption.label}</span>
|
||||
{selectedOption.stage && (
|
||||
<Badge
|
||||
variant={
|
||||
selectedOption.stage === 'Early Access'
|
||||
? 'warning'
|
||||
: selectedOption.stage === 'Deprecated'
|
||||
? 'destructive'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{selectedOption.stage}
|
||||
</Badge>
|
||||
)}
|
||||
<StageBadge stage={selectedOption.stage} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -234,19 +285,7 @@ export const DestinationTypeSelection = ({
|
||||
<div className="flex flex-col gap-y-0.5">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="text-foreground">{option.label}</span>
|
||||
{option.stage && (
|
||||
<Badge
|
||||
variant={
|
||||
option.stage === 'Early Access'
|
||||
? 'warning'
|
||||
: option.stage === 'Deprecated'
|
||||
? 'destructive'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{option.stage}
|
||||
</Badge>
|
||||
)}
|
||||
<StageBadge stage={option.stage} />
|
||||
</div>
|
||||
<span className="text-xs text-foreground-lighter">{option.description}</span>
|
||||
</div>
|
||||
|
||||
@@ -24,12 +24,13 @@ import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
|
||||
type EnablePipelinesModalProps =
|
||||
| { open: boolean; onOpenChange: (open: boolean) => void }
|
||||
| { open?: never; onOpenChange?: never }
|
||||
| { open: boolean; onOpenChange: (open: boolean) => void; onCancel?: () => void }
|
||||
| { open?: never; onOpenChange?: never; onCancel?: never }
|
||||
|
||||
export const EnablePipelinesModal = ({
|
||||
open: extOpen,
|
||||
onOpenChange,
|
||||
onCancel,
|
||||
}: EnablePipelinesModalProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const [_open, _setOpen] = useState(false)
|
||||
@@ -101,7 +102,14 @@ export const EnablePipelinesModal = ({
|
||||
</Admonition>
|
||||
</DialogSection>
|
||||
<DialogFooter>
|
||||
<Button variant="default" disabled={creatingTenantSource} onClick={() => setOpen(false)}>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={creatingTenantSource}
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onCancel?.()
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{hasAccess ? (
|
||||
|
||||
@@ -5,23 +5,19 @@ import { SteppedFlow } from './SteppedFlow'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
|
||||
const steps = [
|
||||
{ id: 'destination', label: 'Destination', description: 'Choose where data should go' },
|
||||
{ id: 'connection', label: 'Connection', description: 'Authorize the destination' },
|
||||
{ id: 'review', label: 'Review', description: 'Confirm and create' },
|
||||
{ id: 'destination', label: 'Destination' },
|
||||
{ id: 'connection', label: 'Connection' },
|
||||
{ id: 'review', label: 'Review' },
|
||||
]
|
||||
|
||||
describe('SteppedFlow', () => {
|
||||
test('does not let the user jump ahead of the current step', () => {
|
||||
const onStepChange = vi.fn()
|
||||
|
||||
test('does not show Back on the first step', () => {
|
||||
customRender(
|
||||
<SteppedFlow steps={steps} currentStep="destination" onStepChange={onStepChange}>
|
||||
<SteppedFlow steps={steps} currentStep="destination" onStepChange={vi.fn()}>
|
||||
Step body
|
||||
</SteppedFlow>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connection, not yet available' }))
|
||||
expect(onStepChange).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -38,19 +34,6 @@ describe('SteppedFlow', () => {
|
||||
expect(onStepChange).toHaveBeenCalledWith('destination')
|
||||
})
|
||||
|
||||
test('lets the user return to a completed step', () => {
|
||||
const onStepChange = vi.fn()
|
||||
|
||||
customRender(
|
||||
<SteppedFlow steps={steps} currentStep="connection" onStepChange={onStepChange}>
|
||||
Step body
|
||||
</SteppedFlow>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Go to completed step: Destination' }))
|
||||
expect(onStepChange).toHaveBeenCalledWith('destination')
|
||||
})
|
||||
|
||||
test('shows the final action on the last step instead of Continue', () => {
|
||||
const onFinal = vi.fn()
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { Button, cn } from 'ui'
|
||||
import { Button, Card, cn } from 'ui'
|
||||
|
||||
export type SteppedFlowStep = {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type SteppedFlowFinalAction = {
|
||||
@@ -48,107 +47,52 @@ export const SteppedFlow = ({
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col">
|
||||
<div className="mx-auto grid w-full max-w-[924px] flex-1 grid-cols-1 gap-6 px-6 pt-8 pb-10 lg:grid-cols-[minmax(0,760px)_140px]">
|
||||
<div className="min-w-0">
|
||||
<p className="mb-4 text-xs text-foreground-lighter lg:hidden">
|
||||
Step {currentIndex + 1} of {steps.length}
|
||||
{currentStepLabel ? ` · ${currentStepLabel}` : ''}
|
||||
</p>
|
||||
<main className="min-w-0">{children}</main>
|
||||
<footer
|
||||
className={cn(
|
||||
'mt-6 flex items-center border-t pt-4',
|
||||
currentIndex > 0 ? 'justify-between' : 'justify-end'
|
||||
)}
|
||||
>
|
||||
{currentIndex > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
onClick={() => onStepChange(steps[currentIndex - 1].id)}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
{isLastStep && finalAction ? (
|
||||
<Button
|
||||
type={finalAction.type ?? (finalAction.form ? 'submit' : 'button')}
|
||||
form={finalAction.form}
|
||||
variant="primary"
|
||||
loading={finalAction.loading}
|
||||
disabled={finalAction.disabled}
|
||||
onClick={finalAction.onClick}
|
||||
>
|
||||
{finalAction.label}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={nextDisabled}
|
||||
loading={nextLoading}
|
||||
onClick={onNext}
|
||||
>
|
||||
{nextLabel}
|
||||
</Button>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
aria-label="Setup progress"
|
||||
className="sticky top-6 hidden h-fit flex-col gap-0.5 lg:flex"
|
||||
<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">
|
||||
Step {currentIndex + 1} of {steps.length}
|
||||
{currentStepLabel ? ` · ${currentStepLabel}` : ''}
|
||||
</p>
|
||||
<Card key={currentStep} className="animate-in fade-in-0 duration-200">
|
||||
{children}
|
||||
</Card>
|
||||
<footer
|
||||
className={cn(
|
||||
'mt-6 flex items-center',
|
||||
currentIndex > 0 ? 'justify-between' : 'justify-end'
|
||||
)}
|
||||
>
|
||||
{steps.map((step, index) => {
|
||||
const isCurrent = index === currentIndex
|
||||
const isComplete = index < currentIndex
|
||||
const isReachable = index <= currentIndex
|
||||
|
||||
return (
|
||||
<button
|
||||
key={step.id}
|
||||
type="button"
|
||||
tabIndex={isReachable ? 0 : -1}
|
||||
aria-current={isCurrent ? 'step' : undefined}
|
||||
aria-label={
|
||||
isCurrent
|
||||
? `Current step: ${step.label}`
|
||||
: isComplete
|
||||
? `Go to completed step: ${step.label}`
|
||||
: `${step.label}, not yet available`
|
||||
}
|
||||
disabled={!isReachable}
|
||||
onClick={() => {
|
||||
if (!isReachable) return
|
||||
onStepChange(step.id)
|
||||
}}
|
||||
className={cn(
|
||||
'grid grid-cols-[12px_minmax(0,1fr)] items-start gap-2 rounded-md px-2 py-1.5 text-left',
|
||||
isCurrent ? 'text-foreground' : 'text-foreground-light',
|
||||
isReachable && 'hover:bg-surface-200 hover:text-foreground',
|
||||
!isReachable && 'cursor-not-allowed opacity-60'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'mt-1.5 h-2 w-2 rounded-full border',
|
||||
isComplete && 'border-brand bg-brand',
|
||||
isCurrent && !isComplete && 'border-foreground bg-foreground',
|
||||
!isCurrent && !isComplete && 'border-foreground-muted bg-transparent'
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<strong className="block text-sm font-medium">{step.label}</strong>
|
||||
{step.description && (
|
||||
<small className="block text-xs font-normal text-foreground-lighter">
|
||||
{step.description}
|
||||
</small>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
{currentIndex > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
onClick={() => onStepChange(steps[currentIndex - 1].id)}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
{isLastStep && finalAction ? (
|
||||
<Button
|
||||
type={finalAction.type ?? (finalAction.form ? 'submit' : 'button')}
|
||||
form={finalAction.form}
|
||||
variant="primary"
|
||||
loading={finalAction.loading}
|
||||
disabled={finalAction.disabled}
|
||||
onClick={finalAction.onClick}
|
||||
>
|
||||
{finalAction.label}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={nextDisabled}
|
||||
loading={nextLoading}
|
||||
onClick={onNext}
|
||||
>
|
||||
{nextLabel}
|
||||
</Button>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
/**
|
||||
* Browser tab title segments.
|
||||
*
|
||||
* Use title case for `section` and `entity` values
|
||||
* (e.g. "New Pipeline", not "New pipeline").
|
||||
* Product names like "Database" or "Auth" are proper nouns and keep their
|
||||
* original casing in `surface`.
|
||||
*
|
||||
* The final title is built as:
|
||||
* entity | section | surface | project | org | brand
|
||||
*/
|
||||
export interface StudioPageTitleParts {
|
||||
entity?: string
|
||||
section?: string
|
||||
|
||||
@@ -57,7 +57,7 @@ DatabaseReplicationNewPage.getLayout = (page) => (
|
||||
<DefaultLayout hideMobileMenu headerTitle="New pipeline">
|
||||
<ProjectLayoutWithAuth
|
||||
product="Database"
|
||||
browserTitle={{ section: 'New pipeline' }}
|
||||
browserTitle={{ section: 'New Pipeline' }}
|
||||
isBlocking={false}
|
||||
>
|
||||
{page}
|
||||
|
||||
@@ -14,7 +14,7 @@ function DatabaseReplicationNewRoute() {
|
||||
return (
|
||||
<ProjectLayoutWithAuth
|
||||
product="Database"
|
||||
browserTitle={{ section: 'New pipeline' }}
|
||||
browserTitle={{ section: 'New Pipeline' }}
|
||||
isBlocking={false}
|
||||
>
|
||||
<DatabaseReplicationNewPage dehydratedState={undefined} />
|
||||
|
||||
Reference in new issue
Block a user