refactor(studio): extract destination validation for wizard reuse (#51470)

## Problem

The pipeline creation wizard in #49243 needs to validate its destination
connection before table selection. The sheet currently builds that
request inside its full configuration validation.

## Solution

Extract `useDestinationValidation` and use it from the sheet. It builds
and sends the existing destination request, with optional pipeline
settings. The sheet still validates destination and pipeline settings in
parallel and retains its existing loading, error and warning handling.

This foundation adds no visible changes or wizard routing.

## Review instructions

Use a test project with Pipelines enabled, a publication containing
tables, and working BigQuery credentials.

1. Open the [deploy preview at Database →
Pipelines](https://studio-staging-git-dnywh-refactorpipeline-desti-ebcb9d-supabase.vercel.app/dashboard/project/_/database/pipelines),
select your test project and click **Add pipeline**. Confirm the
existing sheet opens with BigQuery selected.
2. Enter the required fields and select a publication. Use valid
service-account JSON, but set **Dataset ID** to a dataset that does not
exist. Click **Start pipeline**. While the request runs, expect a
loading state and disabled submission. Once validation completes, expect
configuration issues in the sheet, retained field values and no pipeline
creation.
3. Correct the dataset ID and click **Start pipeline** again. Expect
validation to recover and open the **Create and start pipeline** cost
confirmation when there are no remaining issues. Click **Cancel** to
avoid creating the pipeline.
4. If your test project returns warnings, confirm they appear inline and
the action changes to **Start pipeline anyway**. Click it again:
unchanged warnings should open the warning confirmation, and continuing
should open the cost confirmation. Critical issues must still block
progression.
This commit is contained in:
Danny White authored and GitHub committed 2026-10-12 10:18:56 +11:00
1 parent 325a5a0da7
commit ae69908aed
3 files changed
+194 -17

No files matched your search

@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DestinationPanelSchemaType } from './DestinationForm.schema'
import { useDestinationForm } from './useDestinationForm'
import { useDestinationValidation } from './useDestinationValidation'
import { replicationKeys } from '@/data/replication/keys'
import {
PipelineRequestStatusProvider,
@@ -18,6 +19,8 @@ import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
type ValidationResponse = components['schemas']['ValidatePipelineResponse_Output']
const updateRequests: unknown[] = []
const validationRequests: unknown[] = []
const destinationRequests: unknown[] = []
let destinationResponse: ValidationResponse
const startRequests = vi.fn()
const createRequests = vi.fn()
let validationResponse: ValidationResponse
@@ -71,6 +74,8 @@ describe('useDestinationForm', () => {
})
updateRequests.length = 0
validationRequests.length = 0
destinationRequests.length = 0
destinationResponse = { validation_failures: [] }
startRequests.mockClear()
createRequests.mockClear()
validationResponse = { validation_failures: [] }
@@ -105,10 +110,12 @@ describe('useDestinationForm', () => {
addAPIMock({
method: 'post',
path: '/platform/replication/:ref/destinations/validate',
response: () =>
HttpResponse.json<components['schemas']['ValidateDestinationResponse_Output']>({
validation_failures: [],
}),
response: async ({ request }) => {
destinationRequests.push(await request.json())
return HttpResponse.json<components['schemas']['ValidateDestinationResponse_Output']>(
destinationResponse
)
},
})
addAPIMock({
method: 'post',
@@ -180,6 +187,151 @@ describe('useDestinationForm', () => {
])
})
it('sends the same pipeline settings to both validation endpoints', async () => {
const expectedPipelineConfig = {
publication_name: 'analytics',
table_sync_copy: { type: 'include_tables', table_ids: [101] },
batch: { max_fill_ms: 500 },
max_table_sync_workers: 4,
max_copy_connections_per_table: 1,
}
const { result } = await renderDestinationForm()
await act(async () => {
await result.current.validateConfiguration({
data: { ...formData, serviceAccountKey: '{"type":"service_account"}' },
onValidationFail: vi.fn(),
})
})
expect(destinationRequests).toEqual([
{
config: {
big_query: {
project_id: 'example-project',
dataset_id: 'analytics',
service_account_key: '{"type":"service_account"}',
connection_pool_size: 5,
max_staleness_mins: 0,
},
},
source_id: 42,
pipeline_config: expectedPipelineConfig,
},
])
expect(validationRequests).toEqual([{ source_id: 42, config: expectedPipelineConfig }])
})
it.each<{ failure_type: 'critical' | 'warning' }>([
{ failure_type: 'critical' },
{ failure_type: 'warning' },
])('preserves destination $failure_type handling', async ({ failure_type }) => {
const failure = { failure_type, name: 'Connection issue', reason: 'Check credentials.' }
destinationResponse = { validation_failures: [failure] }
const onValidationFail = vi.fn()
const { result } = await renderDestinationForm()
await act(async () => {
expect(
await result.current.validateConfiguration({
data: { ...formData, serviceAccountKey: '{"type":"service_account"}' },
onValidationFail,
})
).toEqual({
canContinue: failure_type === 'warning',
warnings: failure_type === 'warning' ? [failure] : [],
})
})
expect(result.current.destinationValidationFailures).toEqual([failure])
expect(onValidationFail).toHaveBeenCalledOnce()
act(() => result.current.resetValidation())
expect(result.current.hasRunValidation).toBe(false)
expect(result.current.destinationValidationFailures).toEqual([])
})
it('recovers after a destination validation request fails', async () => {
addAPIMock({
method: 'post',
path: '/platform/replication/:ref/destinations/validate',
response: () => HttpResponse.json<APIErrorBody>({ message: 'Unavailable' }, { status: 503 }),
})
const { result } = await renderDestinationForm()
const onValidationFail = vi.fn()
const options = {
data: { ...formData, serviceAccountKey: '{"type":"service_account"}' },
onValidationFail,
}
await act(async () => {
expect(await result.current.validateConfiguration(options)).toEqual({
canContinue: false,
warnings: [],
})
})
expect(result.current.hasRunValidation).toBe(false)
expect(onValidationFail).not.toHaveBeenCalled()
addAPIMock({
method: 'post',
path: '/platform/replication/:ref/destinations/validate',
response: () => HttpResponse.json<ValidationResponse>({ validation_failures: [] }),
})
await act(async () => {
expect(await result.current.validateConfiguration(options)).toEqual({
canContinue: true,
warnings: [],
})
})
expect(result.current.hasRunValidation).toBe(true)
expect(result.current.destinationValidationFailures).toEqual([])
})
it('reports destination validation as pending until the request completes', async () => {
const response = Promise.withResolvers<void>()
addAPIMock({
method: 'post',
path: '/platform/replication/:ref/destinations/validate',
response: async () => {
await response.promise
return HttpResponse.json<ValidationResponse>({ validation_failures: [] })
},
})
const { result } = customRenderHook(() =>
useDestinationValidation({ projectRef: 'default', selectedType: 'BigQuery' })
)
let request: Promise<ValidationResponse>
act(() => {
request = result.current.validateDestination({
data: { ...formData, serviceAccountKey: '{}' },
})
})
await waitFor(() => expect(result.current.isValidatingDestination).toBe(true))
await act(async () => {
response.resolve()
await request
})
await waitFor(() => expect(result.current.isValidatingDestination).toBe(false))
})
it('validates a connection without source or pipeline settings', async () => {
const { result } = customRenderHook(() =>
useDestinationValidation({ projectRef: 'default', selectedType: 'BigQuery' })
)
await act(async () => {
expect(
await result.current.validateDestination({
data: { ...formData, serviceAccountKey: '{"type":"service_account"}' },
})
).toEqual({ validation_failures: [] })
})
expect(destinationRequests).toEqual([
{
config: expect.objectContaining({
big_query: expect.objectContaining({
project_id: 'example-project',
dataset_id: 'analytics',
}),
}),
},
])
expect(validationRequests).toEqual([])
})
it('blocks creation when pipeline validation returns a critical failure', async () => {
validationResponse = {
validation_failures: [
@@ -9,19 +9,16 @@ import { DestinationPanelFormSchema as FormSchema } from './DestinationForm.sche
import {
buildBatchConfig,
buildDestinationConfig,
buildDestinationConfigForValidation,
buildTableSyncCopyConfig,
} from './DestinationForm.utils'
import { useDestinationValidation } from './useDestinationValidation'
import { useCreateDestinationPipelineMutation } from '@/data/replication/create-destination-pipeline-mutation'
import type { ReplicationPipelineByIdData } from '@/data/replication/pipeline-by-id-query'
import { useReplicationSourcesQuery } from '@/data/replication/sources-query'
import { useStartPipelineMutation } from '@/data/replication/start-pipeline-mutation'
import { type BatchConfig } from '@/data/replication/types'
import { useUpdateDestinationPipelineMutation } from '@/data/replication/update-destination-pipeline-mutation'
import {
useValidateDestinationMutation,
type ValidationFailure,
} from '@/data/replication/validate-destination-mutation'
import type { ValidationFailure } from '@/data/replication/validate-destination-mutation'
import { useValidatePipelineMutation } from '@/data/replication/validate-pipeline-mutation'
import { useIcebergNamespaceCreateMutation } from '@/data/storage/iceberg-namespace-create-mutation'
import { useS3AccessKeyCreateMutation } from '@/data/storage/s3-access-key-create-mutation'
@@ -46,8 +43,10 @@ export const useDestinationForm = ({ selectedType }: { selectedType: Destination
const { data: sourcesData } = useReplicationSourcesQuery({ projectRef })
const sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id
const { mutateAsync: validateDestination, isPending: isValidatingDestination } =
useValidateDestinationMutation()
const { validateDestination, isValidatingDestination } = useDestinationValidation({
projectRef,
selectedType,
})
const { mutateAsync: validatePipeline, isPending: isValidatingPipeline } =
useValidatePipelineMutation()
@@ -131,12 +130,7 @@ export const useDestinationForm = ({ selectedType }: { selectedType: Destination
// even if one fails - this makes the validation feel like a single operation
const results = await Promise.allSettled([
validateDestination({
projectRef,
destinationConfig: buildDestinationConfigForValidation({
projectRef,
selectedType,
data,
}),
data,
sourceId,
publicationName: data.publicationName,
maxFillMs: data.maxFillMs,
@@ -0,0 +1,31 @@
import type { DestinationType } from '../DestinationPanel.types'
import type { DestinationPanelSchemaType } from './DestinationForm.schema'
import { buildDestinationConfigForValidation } from './DestinationForm.utils'
import { useValidateDestinationMutation } from '@/data/replication/validate-destination-mutation'
export const useDestinationValidation = ({
projectRef,
selectedType,
}: {
projectRef?: string
selectedType: DestinationType
}) => {
const { mutateAsync, isPending: isValidatingDestination } = useValidateDestinationMutation()
const validateDestination = ({
data,
...pipelineOptions
}: {
data: DestinationPanelSchemaType
} & Omit<Parameters<typeof mutateAsync>[0], 'projectRef' | 'destinationConfig'>) => {
if (!projectRef) throw new Error('Project ref is required')
return mutateAsync({
projectRef,
destinationConfig: buildDestinationConfigForValidation({ projectRef, selectedType, data }),
...pipelineOptions,
})
}
return { validateDestination, isValidatingDestination }
}