refactor(studio): compose the pipeline data section for wizard reuse (#51572)

## Problem

The pipeline wizard in #49243 needs publication, initial sync and
replication settings together on its data step.

## Solution

Compose `PipelineDataSection` from the existing controls and use it in
preview-enabled creation. It includes the publication selector,
initial-sync controls and data advanced settings, including BigQuery
table layout.

This preserves the grouping from #51570. Preview off and editing keep
the existing layout. Validation and creation behaviour stay the same; no
wizard routing is added. Stacked on #51570.

## Review instructions

1. Open the [Pipelines
preview](https://studio-staging-git-dnywh-featpipeline-data-section-supabase.vercel.app/dashboard/project/_/database/pipelines),
select a test project with Pipelines enabled and go to **Database →
Pipelines**. Enable **Pipeline creation wizard** in Feature Previews,
then click **Add pipeline**. Confirm **Data** follows **Destination
connection**, with publication and initial sync above its **Advanced
settings**.
2. Select a publication and change the initial-sync option. For
selected-table options, confirm the publication tables are available.
Click **New publication** in the publication selector and confirm the
existing creation panel opens, then cancel it.
3. Expand **Advanced settings** under **Data**. Confirm replication
settings appear, plus table layout for BigQuery, without connection pool
size, staleness or role. Change **Batch wait time**, collapse and reopen
the accordion, and confirm the value remains. Check desktop and phone
widths. Cancel and discard changes; no credentials or pipeline creation
are needed.
4. Turn the preview off and reopen **Add pipeline**. Confirm the
original layout remains. With the preview on, edit an existing pipeline
and confirm its original layout also remains.
This commit is contained in:
Danny White authored and GitHub committed 2026-10-14 10:28:21 +11:00
1 parent 37fb536e6c
commit 7fa436050a
4 files changed
+182 -37

No files matched your search

@@ -0,0 +1,119 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import type { components } from 'api-types'
import { mockAnimationsApi } from 'jsdom-testing-mocks'
import { HttpResponse } from 'msw'
import { useForm } from 'react-hook-form'
import { Form } from 'ui'
import { describe, expect, it, vi } from 'vitest'
import type { DestinationPanelSchemaType } from '../DestinationPanel/DestinationForm/DestinationForm.schema'
import type { DestinationType } from '../DestinationPanel/DestinationPanel.types'
import { PipelineDataSection } from './PipelineDataSection'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
mockAnimationsApi()
type ReplicationSourcesResponse = components['schemas']['SourcesResponse_Output']
type PublicationDetailsResponse = components['schemas']['PublicationDetailsResponse_Output']
type PublicationNamesResponse = components['schemas']['ReadPublicationsResponse_Output']
const mockSources: ReplicationSourcesResponse = {
sources: [
{
id: 1,
name: 'default',
tenant_id: 'tenant',
config: { host: 'db.internal', name: 'main-db', port: 5432, username: 'etl_user' },
},
],
}
const mockPublicationDetails = (publishViaPartitionRoot: boolean): PublicationDetailsResponse => ({
name: 'analytics',
config: {
type: 'tables',
tables: [],
operations: ['insert', 'update', 'delete', 'truncate'],
publish_via_partition_root: publishViaPartitionRoot,
},
tables: [],
})
const mockPublicationRequests = (publishViaPartitionRoot: boolean) => {
addAPIMock({
method: 'get',
path: '/platform/replication/:ref/sources',
response: () => HttpResponse.json<ReplicationSourcesResponse>(mockSources),
})
addAPIMock({
method: 'get',
path: '/platform/replication/v2/:ref/sources/:source_id/publications',
response: () =>
HttpResponse.json<PublicationNamesResponse>({
publications: [{ name: 'analytics' }],
}),
})
addAPIMock({
method: 'get',
path: '/platform/replication/v2/:ref/sources/:source_id/publications/:publication_name',
response: () =>
HttpResponse.json<PublicationDetailsResponse>(
mockPublicationDetails(publishViaPartitionRoot)
),
})
}
const TestSection = ({ type }: { type: DestinationType }) => {
const form = useForm<DestinationPanelSchemaType>({
defaultValues: {
publicationName: 'analytics',
tableSyncCopyMode: 'include_all_tables',
tableSyncCopyTableIds: [],
maxFillMs: 500,
maxTableSyncWorkers: 4,
maxCopyConnectionsPerTable: 1,
invalidatedSlotBehavior: 'error',
tableOptions: [],
},
})
return (
<Form {...form}>
<PipelineDataSection type={type} form={form} onSelectNewPublication={vi.fn()} />
</Form>
)
}
describe('PipelineDataSection', () => {
it.each<DestinationType>(['BigQuery', 'DuckLake', 'Snowflake', 'ClickHouse', 'Analytics Bucket'])(
'groups %s publication, initial sync and replication settings without connection fields',
async (type) => {
mockPublicationRequests(false)
customRender(<TestSection type={type} />)
expect(screen.getByRole('region', { name: 'Data' })).toBeInTheDocument()
expect(screen.getByText('Initial sync')).toBeInTheDocument()
await waitFor(() =>
expect(screen.getByRole('status')).toHaveTextContent(
'Each partition is replicated separately.'
)
)
fireEvent.click(screen.getByRole('button', { name: /Advanced settings/ }))
expect(screen.getByLabelText('Batch wait time')).toHaveValue(500)
expect(screen.getByLabelText('Table sync workers')).toHaveValue(4)
expect(screen.queryByText('Table layout') !== null).toBe(type === 'BigQuery')
for (const label of [
'Pipeline name',
'Pipeline region',
'Connection pool size',
'Maximum staleness',
'Pool size',
'Role',
]) {
expect(screen.queryByText(label)).not.toBeInTheDocument()
}
fireEvent.change(screen.getByLabelText('Batch wait time'), { target: { value: '750' } })
fireEvent.click(screen.getByRole('button', { name: /Advanced settings/ }))
fireEvent.click(screen.getByRole('button', { name: /Advanced settings/ }))
expect(screen.getByLabelText('Batch wait time')).toHaveValue(750)
}
)
})
@@ -0,0 +1,28 @@
import type { UseFormReturn } from 'react-hook-form'
import { AdvancedSettings } from '../DestinationPanel/DestinationForm/AdvancedSettings'
import type { DestinationPanelSchemaType } from '../DestinationPanel/DestinationForm/DestinationForm.schema'
import { PublicationSelection } from '../DestinationPanel/DestinationForm/PublicationSelection'
import { TableCopySelection } from '../DestinationPanel/DestinationForm/TableCopySelection'
import type { DestinationType } from '../DestinationPanel/DestinationPanel.types'
export const PipelineDataSection = ({
type,
form,
onSelectNewPublication,
}: {
type: DestinationType
form: UseFormReturn<DestinationPanelSchemaType>
onSelectNewPublication: () => void
}) => (
<section aria-label="Data">
<div className="p-5 flex flex-col gap-y-6">
<h3 className="text-sm font-medium text-foreground">Data</h3>
<div className="flex flex-col gap-y-4">
<PublicationSelection form={form} onSelectNewPublication={onSelectNewPublication} />
<TableCopySelection form={form} editMode={false} />
</div>
</div>
<AdvancedSettings type={type} form={form} group="data" />
</section>
)
@@ -317,6 +317,7 @@ describe('DestinationForm edit submission', () => {
onClose={vi.fn()}
/>
)
expect(screen.queryByRole('region', { name: 'Data' }) !== null).toBe(shouldShowSection)
expect(screen.queryByRole('region', { name: 'Destination connection' }) !== null).toBe(
shouldShowSection
)
@@ -11,6 +11,7 @@ import { Admonition } from 'ui-patterns/Admonition'
import * as z from 'zod'
import { PipelineConnectionSection } from '../../CreatePipeline/PipelineConnectionSection'
import { PipelineDataSection } from '../../CreatePipeline/PipelineDataSection'
import {
useIsETLBigQueryPrivateAlpha,
useIsETLClickHousePrivateAlpha,
@@ -497,49 +498,45 @@ export const DestinationForm = ({
<DialogSectionSeparator />
</>
)}
<div className="p-5 flex flex-col gap-y-6">
{isErrorExistingConfig && (
<Admonition type="warning">
<p className="leading-normal!">
The existing destination or pipeline settings could not be loaded. Refresh
before applying changes.
</p>
</Admonition>
)}
<p className="text-sm font-medium text-foreground">
{shouldShowConnectionSection ? 'Data' : 'Destination details'}
</p>
<div className="flex flex-col gap-y-4">
{!shouldShowConnectionSection && (
<DestinationNameInput form={form} destinationType={selectedType} />
)}
<PublicationSelection
form={form}
onSelectNewPublication={() => setPublicationPanelVisible(true)}
/>
<TableCopySelection form={form} editMode={editMode} />
{!shouldShowConnectionSection && (
<PipelineRegionField destinationType={selectedType} />
)}
</div>
</div>
{!shouldShowConnectionSection && (
{shouldShowConnectionSection ? (
<PipelineDataSection
type={selectedType}
form={form}
onSelectNewPublication={() => setPublicationPanelVisible(true)}
/>
) : (
<>
<div className="p-5 flex flex-col gap-y-6">
{isErrorExistingConfig && (
<Admonition type="warning">
<p className="leading-normal!">
The existing destination or pipeline settings could not be loaded.
Refresh before applying changes.
</p>
</Admonition>
)}
<p className="text-sm font-medium text-foreground">Destination details</p>
<div className="flex flex-col gap-y-4">
<DestinationNameInput form={form} destinationType={selectedType} />
<PublicationSelection
form={form}
onSelectNewPublication={() => setPublicationPanelVisible(true)}
/>
<TableCopySelection form={form} editMode={editMode} />
<PipelineRegionField destinationType={selectedType} />
</div>
</div>
<DialogSectionSeparator />
{destinationFields}
<DialogSectionSeparator />
<AdvancedSettings type={selectedType} form={form} group="all" />
</>
)}
{!shouldShowConnectionSection && <DialogSectionSeparator />}
<AdvancedSettings
type={selectedType}
form={form}
group={shouldShowConnectionSection ? 'data' : 'all'}
/>
{!editMode && hasRunValidation && !isValidating && (
<>
<DialogSectionSeparator />