mirror of
https://github.com/supabase/supabase.git
synced 2026-10-13 13:19:09 +03:00
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:
1 parent
325a5a0da7
commit
ae69908aed
3 files changed
+194
-17
No files matched your search
+156
-4
@@ -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: [
|
||||
|
||||
+7
-13
@@ -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,
|
||||
|
||||
+31
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user