From ae69908aedd0f9c84ed5bee2d06ffce1366e1033 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:18:56 +1100 Subject: [PATCH] refactor(studio): extract destination validation for wizard reuse (#51470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- .../useDestinationForm.test.tsx | 160 +++++++++++++++++- .../DestinationForm/useDestinationForm.ts | 20 +-- .../useDestinationValidation.ts | 31 ++++ 3 files changed, 194 insertions(+), 17 deletions(-) create mode 100644 apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationValidation.ts diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.test.tsx index f161df2b25a..9b7b66e0ddf 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.test.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.test.tsx @@ -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({ - validation_failures: [], - }), + response: async ({ request }) => { + destinationRequests.push(await request.json()) + return HttpResponse.json( + 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({ 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({ 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() + addAPIMock({ + method: 'post', + path: '/platform/replication/:ref/destinations/validate', + response: async () => { + await response.promise + return HttpResponse.json({ validation_failures: [] }) + }, + }) + const { result } = customRenderHook(() => + useDestinationValidation({ projectRef: 'default', selectedType: 'BigQuery' }) + ) + let request: Promise + 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: [ diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.ts index f84b944f9d0..4de45a0c231 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.ts @@ -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, diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationValidation.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationValidation.ts new file mode 100644 index 00000000000..d050259ad1d --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationValidation.ts @@ -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[0], 'projectRef' | 'destinationConfig'>) => { + if (!projectRef) throw new Error('Project ref is required') + + return mutateAsync({ + projectRef, + destinationConfig: buildDestinationConfigForValidation({ projectRef, selectedType, data }), + ...pipelineOptions, + }) + } + + return { validateDestination, isValidatingDestination } +}