diff --git a/apps/studio/components/interfaces/Integrations/Warehouse/OverviewTab.test.tsx b/apps/studio/components/interfaces/Integrations/Warehouse/OverviewTab.test.tsx
index de1b43acbd9..4cfac24067c 100644
--- a/apps/studio/components/interfaces/Integrations/Warehouse/OverviewTab.test.tsx
+++ b/apps/studio/components/interfaces/Integrations/Warehouse/OverviewTab.test.tsx
@@ -1,10 +1,11 @@
-import { screen } from '@testing-library/react'
+import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { platformComponents as components } from 'api-types'
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import { mockAnimationsApi } from 'jsdom-testing-mocks'
import { HttpResponse } from 'msw'
+import { toast } from 'sonner'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { WarehouseOverviewTab } from './OverviewTab'
@@ -12,25 +13,47 @@ import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
type WarehouseSetupStatusResponse = components['schemas']['WarehouseSetupStatusResponse_Output']
+type WarehouseSetupBody = components['schemas']['WarehouseSetupBody']
+type WarehouseSetupResponse = components['schemas']['WarehouseSetupResponse_Output']
// Both integration shells are live, and the flag reads a context plus ConfigCat that
// `customRender` doesn't provide.
const mockIsMarketplaceEnabled = vi.fn(() => false)
+const mockTrack = vi.fn()
vi.mock('@/components/interfaces/App/FeaturePreview/FeaturePreviewContext', () => ({
useIsMarketplaceEnabled: () => mockIsMarketplaceEnabled(),
}))
+vi.mock('@/lib/telemetry/track', () => ({ useTrack: () => mockTrack }))
vi.mock('../Integration/IntegrationOverviewTab', () => ({
IntegrationOverviewTab: ({ children }: { children: React.ReactNode }) =>
{children}
,
}))
+vi.mock('sonner', () => ({
+ toast: { success: vi.fn(), error: vi.fn() },
+}))
+
// Exercised by its own unit tests, and it fires four upstream queries of its own.
vi.mock('./WarehouseSchemaTablePicker', () => ({
- WarehouseSchemaTablePicker: ({ error }: { error?: { message: string } | null }) => (
+ WarehouseSchemaTablePicker: ({
+ error,
+ isEditing,
+ onSubmit,
+ }: {
+ error?: { message: string } | null
+ isEditing?: boolean
+ onSubmit: (targets: [{ type: 'table'; schema: string; name: string }]) => void
+ }) => (
Tables
Replicated tables picker
{!!error && Picker error: {error.message} }
+ onSubmit([{ type: 'table', schema: 'public', name: 'orders' }])}
+ >
+ {isEditing ? 'Submit edited tables' : 'Submit initial tables'}
+
),
}))
@@ -81,6 +104,7 @@ const mockProject = () =>
describe('WarehouseOverviewTab', () => {
beforeEach(() => {
mockIsMarketplaceEnabled.mockReturnValue(true)
+ mockTrack.mockClear()
})
test.each([false, true])(
@@ -167,7 +191,7 @@ describe('WarehouseOverviewTab', () => {
expect(screen.queryByText('Initial setup failed')).not.toBeInTheDocument()
})
- test('shows Status, Tables, then Connect once setup is complete', async () => {
+ test('shows Status, Tables, Connect, then Disable once setup is complete', async () => {
mockSetupStatus({
setup_status: 'complete',
tables: [
@@ -192,7 +216,7 @@ describe('WarehouseOverviewTab', () => {
customRender( )
// findByRole throws on duplicates, so this also guards the section titles staying distinct.
- for (const name of ['Status', 'Tables', 'Connect']) {
+ for (const name of ['Status', 'Tables', 'Connect', 'Disable']) {
expect(await screen.findByRole('heading', { name })).toBeInTheDocument()
}
expect(screen.getByText('Replicated tables picker')).toBeInTheDocument()
@@ -203,10 +227,110 @@ describe('WarehouseOverviewTab', () => {
const headings = screen
.getAllByRole('heading')
.map((heading) => heading.textContent)
- .filter((heading) => ['Status', 'Tables', 'Connect'].includes(heading ?? ''))
+ .filter((heading) => ['Status', 'Tables', 'Connect', 'Disable'].includes(heading ?? ''))
- expect(headings).toEqual(['Status', 'Tables', 'Connect'])
- expect(screen.queryByRole('button', { name: 'Disable Warehouse' })).not.toBeInTheDocument()
+ expect(headings).toEqual(['Status', 'Tables', 'Connect', 'Disable'])
+ })
+
+ test('tracks initial setup but not table selection edits as enablement', async () => {
+ mockSetupStatus({ setup_status: 'not_started' })
+ addAPIMock({
+ method: 'post',
+ path: '/platform/warehouse/:ref/setup',
+ response: () => HttpResponse.json({ pipeline_id: 1, tables: [] }),
+ })
+
+ customRender( )
+
+ await userEvent.click(await screen.findByRole('button', { name: 'Submit initial tables' }))
+ await waitFor(() =>
+ expect(mockTrack).toHaveBeenCalledWith('warehouse_enabled', {
+ source: 'integrations_overview',
+ schemaTargetCount: 0,
+ tableTargetCount: 1,
+ })
+ )
+ })
+
+ test('does not track an edited table selection as enablement', async () => {
+ mockSetupStatus({ setup_status: 'complete' })
+ addAPIMock({
+ method: 'post',
+ path: '/platform/warehouse/:ref/setup',
+ response: () => HttpResponse.json({ pipeline_id: 1, tables: [] }),
+ })
+
+ customRender( )
+
+ await userEvent.click(await screen.findByRole('button', { name: 'Submit edited tables' }))
+ await waitFor(() => expect(screen.getByText('Replicated tables picker')).toBeInTheDocument())
+ expect(mockTrack).not.toHaveBeenCalledWith('warehouse_enabled', expect.anything())
+ })
+
+ test('disables Warehouse with an empty target list after confirmation', async () => {
+ mockSetupStatus({ setup_status: 'complete' })
+ const setupRequests: WarehouseSetupBody[] = []
+ addAPIMock({
+ method: 'post',
+ path: '/platform/warehouse/:ref/setup',
+ response: async ({ request }) => {
+ setupRequests.push((await request.json()) as WarehouseSetupBody)
+ return HttpResponse.json({ pipeline_id: 1, tables: [] })
+ },
+ })
+
+ customRender( )
+
+ await userEvent.click(await screen.findByRole('button', { name: 'Disable Warehouse' }))
+ expect(setupRequests).toEqual([])
+
+ const dialog = await screen.findByRole('alertdialog')
+ expect(dialog).toHaveTextContent('Copied data remains in DuckLake storage until deleted')
+ expect(screen.queryByPlaceholderText('Type the project ref to confirm')).not.toBeInTheDocument()
+
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Disable Warehouse' }))
+
+ await waitFor(() => expect(setupRequests).toEqual([{ targets: [] }]))
+ })
+
+ test('shows a disable error and allows retrying from the open confirmation', async () => {
+ mockSetupStatus({ setup_status: 'complete' })
+ let attempts = 0
+ addAPIMock({
+ method: 'post',
+ path: '/platform/warehouse/:ref/setup',
+ response: () => {
+ attempts += 1
+ if (attempts === 1) {
+ return HttpResponse.json(
+ { message: 'Disable request failed' },
+ { status: 500 }
+ )
+ }
+ return HttpResponse.json({ pipeline_id: 1, tables: [] })
+ },
+ })
+
+ customRender( )
+
+ await userEvent.click(await screen.findByRole('button', { name: 'Disable Warehouse' }))
+ const dialog = await screen.findByRole('alertdialog')
+ const confirm = within(dialog).getByRole('button', { name: 'Disable Warehouse' })
+
+ fireEvent.click(confirm)
+
+ await waitFor(() =>
+ expect(toast.error).toHaveBeenCalledWith(
+ 'Failed to disable Warehouse: Disable request failed'
+ )
+ )
+ expect(dialog).toBeVisible()
+ expect(confirm).toBeEnabled()
+
+ fireEvent.click(confirm)
+
+ await waitFor(() => expect(attempts).toBe(2))
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
})
test('shows a status query failure without blocking an unrelated route', async () => {
diff --git a/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseConnectSection.tsx b/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseConnectSection.tsx
index 60657aedc44..fe57fda34c8 100644
--- a/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseConnectSection.tsx
+++ b/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseConnectSection.tsx
@@ -413,7 +413,7 @@ export const WarehouseConnectionCard = ({ variant = 'default' }: WarehouseConnec
export const WarehouseConnectSection = () => {
return (
-
+
Connect
diff --git a/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseDisableCard.tsx b/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseDisableCard.tsx
new file mode 100644
index 00000000000..037d9434304
--- /dev/null
+++ b/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseDisableCard.tsx
@@ -0,0 +1,108 @@
+import { useQueryClient } from '@tanstack/react-query'
+import { useParams } from 'common'
+import { useState } from 'react'
+import { toast } from 'sonner'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogBody,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ Button,
+ Card,
+ CardContent,
+} from 'ui'
+import { FormLayout } from 'ui-patterns/form/Layout/FormLayout'
+import {
+ PageSection,
+ PageSectionContent,
+ PageSectionMeta,
+ PageSectionSummary,
+ PageSectionTitle,
+} from 'ui-patterns/PageSection'
+
+import { warehouseKeys } from '@/data/warehouse/keys'
+import { useWarehouseSetupMutation } from '@/data/warehouse/warehouse-setup-mutation'
+import { useTrack } from '@/lib/telemetry/track'
+
+export const WarehouseDisableCard = () => {
+ const { ref: projectRef } = useParams()
+ const queryClient = useQueryClient()
+ const track = useTrack()
+ const [isConfirming, setIsConfirming] = useState(false)
+
+ const setupMutation = useWarehouseSetupMutation({
+ onSuccess: async () => {
+ track('warehouse_disabled', {})
+ // Disabling also turns off catalog access server-side, which the setup mutation doesn't know
+ // to invalidate on its own.
+ await queryClient.invalidateQueries({ queryKey: warehouseKeys.catalog(projectRef) })
+ setIsConfirming(false)
+ toast.success('Warehouse disabled')
+ },
+ onError: (error) => toast.error(`Failed to disable Warehouse: ${error.message}`),
+ })
+
+ return (
+
+
+
+ Disable
+
+
+
+
+
+
+ setIsConfirming(true)}>
+ Disable Warehouse
+
+
+
+
+
+
+
+
+
+ Disable Warehouse
+
+
+
+
+
+ Disabling Warehouse stops replication and connections to its endpoint. Its
+ pipeline, publication, catalog access, and foreign tables are removed.
+
+
Copied data remains in DuckLake storage until deleted.
+
+
+
+
+ Cancel
+
+ projectRef
+ ? setupMutation.mutateAsync({ projectRef, body: { targets: [] } })
+ : undefined
+ }
+ >
+ Disable Warehouse
+
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseSchemaTablePicker.test.tsx b/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseSchemaTablePicker.test.tsx
new file mode 100644
index 00000000000..43c186a22e2
--- /dev/null
+++ b/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseSchemaTablePicker.test.tsx
@@ -0,0 +1,291 @@
+import { fireEvent, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import type { components } from 'api-types'
+import { mockAnimationsApi } from 'jsdom-testing-mocks'
+import { HttpResponse } from 'msw'
+import { describe, expect, test } from 'vitest'
+
+import { WarehouseSchemaTablePicker } from './WarehouseSchemaTablePicker'
+import type { SchemasData } from '@/data/database/schemas-query'
+import type { ProjectDetail } from '@/data/projects/project-detail-query'
+import type { TablesData } from '@/data/tables/tables-query'
+import { useWarehouseSetupMutation } from '@/data/warehouse/warehouse-setup-mutation'
+import { customRender } from '@/tests/lib/custom-render'
+import { addAPIMock } from '@/tests/lib/msw'
+
+type PublicationDetailsResponse = components['schemas']['PublicationDetailsResponse_Output']
+type ReplicationSourcesResponse = components['schemas']['SourcesResponse_Output']
+type RunQueryBody = components['schemas']['RunQueryBody']
+type WarehouseSetupBody = components['schemas']['WarehouseSetupBody']
+type WarehouseSetupResponse = components['schemas']['WarehouseSetupResponse_Output']
+
+mockAnimationsApi()
+
+const PROJECT: ProjectDetail = {
+ cloud_provider: 'AWS',
+ connectionString: 'postgresql://postgres@localhost:5432/postgres',
+ db_host: 'db.default.supabase.co',
+ high_availability: false,
+ id: 1,
+ inserted_at: '2026-01-01T00:00:00.000Z',
+ integration_source: null,
+ is_branch_enabled: false,
+ is_hibernating: false,
+ is_physical_backups_enabled: false,
+ name: 'Test project',
+ organization_id: 1,
+ ref: 'default',
+ region: 'us-east-1',
+ restUrl: 'https://default.supabase.co/rest/v1',
+ status: 'ACTIVE_HEALTHY',
+ subscription_id: 'subscription-1',
+ updated_at: '2026-01-01T00:00:00.000Z',
+}
+
+const SCHEMAS: SchemasData = [
+ { id: 1, name: 'public', owner: 'postgres', comment: null },
+ { id: 2, name: 'analytics', owner: 'postgres', comment: null },
+]
+
+const TABLES: TablesData = [
+ {
+ id: 1,
+ schema: 'public',
+ name: 'orders',
+ rls_enabled: false,
+ rls_forced: false,
+ replica_identity: 'DEFAULT',
+ bytes: 1024,
+ size: '1024 bytes',
+ live_rows_estimate: 10,
+ dead_rows_estimate: 0,
+ comment: null,
+ primary_keys: [],
+ relationships: [],
+ },
+ {
+ id: 2,
+ schema: 'public',
+ name: 'customers',
+ rls_enabled: false,
+ rls_forced: false,
+ replica_identity: 'DEFAULT',
+ bytes: 1024,
+ size: '1024 bytes',
+ live_rows_estimate: 10,
+ dead_rows_estimate: 0,
+ comment: null,
+ primary_keys: [],
+ relationships: [],
+ },
+ {
+ id: 3,
+ schema: 'analytics',
+ name: 'events',
+ rls_enabled: false,
+ rls_forced: false,
+ replica_identity: 'DEFAULT',
+ bytes: 1024,
+ size: '1024 bytes',
+ live_rows_estimate: 10,
+ dead_rows_estimate: 0,
+ comment: null,
+ primary_keys: [],
+ relationships: [],
+ },
+]
+
+const SOURCES: ReplicationSourcesResponse = {
+ sources: [
+ {
+ id: 1,
+ name: 'default',
+ tenant_id: 'tenant',
+ config: { host: 'db.internal', name: 'main-db', port: 5432, username: 'etl_user' },
+ },
+ ],
+}
+
+const PUBLICATION: PublicationDetailsResponse = {
+ name: 'supabase_warehouse',
+ config: {
+ type: 'tables',
+ tables: [
+ { id: 1, schema: 'public', name: 'orders', columns: null, row_filter: null },
+ { id: 2, schema: 'public', name: 'customers', columns: null, row_filter: null },
+ ],
+ operations: ['insert', 'update', 'delete', 'truncate'],
+ publish_via_partition_root: false,
+ },
+ tables: [
+ { id: 1, schema: 'public', name: 'orders', kind: 'table', partition_parent_id: null },
+ { id: 2, schema: 'public', name: 'customers', kind: 'table', partition_parent_id: null },
+ ],
+}
+
+const mockPickerQueries = ({
+ isEditing,
+ tables = TABLES,
+ schemas = SCHEMAS,
+}: {
+ isEditing: boolean
+ tables?: TablesData
+ schemas?: SchemasData
+}) => {
+ addAPIMock({ method: 'get', path: '/platform/projects/:ref', response: PROJECT })
+ addAPIMock({
+ method: 'post',
+ path: '/platform/pg-meta/:ref/query',
+ response: async ({ request }) => {
+ const body = (await request.json()) as RunQueryBody
+ const result = body.query.includes("obj_description(n.oid, 'pg_namespace')")
+ ? schemas
+ : tables
+ return HttpResponse.json(result)
+ },
+ })
+
+ if (isEditing) {
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/:ref/sources',
+ response: () => HttpResponse.json(SOURCES),
+ })
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/v2/:ref/sources/:source_id/publications/:publication_name',
+ response: () => HttpResponse.json(PUBLICATION),
+ })
+ }
+}
+
+const WarehousePickerHarness = ({ isEditing = false }: { isEditing?: boolean }) => {
+ const setupMutation = useWarehouseSetupMutation()
+ return (
+ setupMutation.mutate({ projectRef: 'default', body: { targets } })}
+ />
+ )
+}
+
+const mockSetupMutation = () => {
+ const requests: WarehouseSetupBody[] = []
+ addAPIMock({
+ method: 'post',
+ path: '/platform/warehouse/:ref/setup',
+ response: async ({ request }) => {
+ requests.push((await request.json()) as WarehouseSetupBody)
+ return HttpResponse.json({ pipeline_id: 1, tables: [] })
+ },
+ })
+ return requests
+}
+
+describe('WarehouseSchemaTablePicker', () => {
+ test('starts empty and supports selecting every table in a schema', async () => {
+ mockPickerQueries({ isEditing: false })
+ const requests = mockSetupMutation()
+
+ customRender( )
+
+ expect(await screen.findByText('0 tables selected')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Enable Warehouse' })).toBeDisabled()
+
+ fireEvent.click(screen.getByRole('combobox', { name: 'Select tables to replicate' }))
+ await userEvent.click(screen.getAllByText('Select all')[1])
+
+ expect(screen.getByText('2 tables selected')).toBeInTheDocument()
+ await userEvent.click(screen.getByRole('button', { name: 'Enable Warehouse' }))
+
+ await waitFor(() =>
+ expect(requests).toEqual([{ targets: [{ type: 'schema', schema: 'public' }] }])
+ )
+ })
+
+ test('preselects replicated tables and submits a replacement after removal', async () => {
+ mockPickerQueries({ isEditing: true })
+ const requests = mockSetupMutation()
+
+ customRender( )
+
+ expect(await screen.findByText('2 tables selected')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('combobox', { name: 'Select tables to replicate' }))
+ await userEvent.click(screen.getByText('customers'))
+
+ expect(screen.getByText('1 table selected')).toBeInTheDocument()
+ await userEvent.click(screen.getByRole('button', { name: 'Update replicated tables' }))
+
+ await waitFor(() =>
+ expect(requests).toEqual([{ targets: [{ type: 'table', schema: 'public', name: 'orders' }] }])
+ )
+ })
+
+ test('filters tables by schema name or qualified table name', async () => {
+ mockPickerQueries({ isEditing: false })
+
+ customRender( )
+
+ fireEvent.click(await screen.findByRole('combobox', { name: 'Select tables to replicate' }))
+ await userEvent.type(screen.getByPlaceholderText('Search schemas and tables...'), 'analytics')
+
+ expect(screen.getByText('events')).toBeInTheDocument()
+ expect(screen.queryByText('orders')).not.toBeInTheDocument()
+ expect(screen.queryByText('customers')).not.toBeInTheDocument()
+
+ await userEvent.clear(screen.getByPlaceholderText('Search schemas and tables...'))
+ await userEvent.type(screen.getByPlaceholderText('Search schemas and tables...'), 'public.ord')
+
+ expect(screen.getByText('orders')).toBeInTheDocument()
+ expect(screen.queryByText('customers')).not.toBeInTheDocument()
+ expect(screen.queryByText('events')).not.toBeInTheDocument()
+
+ await userEvent.clear(screen.getByPlaceholderText('Search schemas and tables...'))
+ await userEvent.type(screen.getByPlaceholderText('Search schemas and tables...'), 'orders')
+
+ expect(screen.getByText('orders')).toBeInTheDocument()
+ expect(screen.queryByText('customers')).not.toBeInTheDocument()
+ expect(screen.queryByText('events')).not.toBeInTheDocument()
+ })
+
+ test('wraps selected table badges and shows an overflow count past the limit', async () => {
+ const manyTables: TablesData = Array.from({ length: 12 }, (_, index) => ({
+ id: index + 1,
+ schema: 'public',
+ name: `table_${String(index + 1).padStart(2, '0')}`,
+ rls_enabled: false,
+ rls_forced: false,
+ replica_identity: 'DEFAULT',
+ bytes: 1024,
+ size: '1024 bytes',
+ live_rows_estimate: 10,
+ dead_rows_estimate: 0,
+ comment: null,
+ primary_keys: [],
+ relationships: [],
+ }))
+
+ mockPickerQueries({
+ isEditing: false,
+ schemas: [{ id: 1, name: 'public', owner: 'postgres', comment: null }],
+ tables: manyTables,
+ })
+
+ customRender( )
+
+ const trigger = await screen.findByRole('combobox', { name: 'Select tables to replicate' })
+ fireEvent.click(trigger)
+ await userEvent.click(screen.getByText('Select all'))
+ // Close the list so trigger textContent is only the selected badges.
+ fireEvent.click(trigger)
+
+ expect(screen.getByText('12 tables selected')).toBeInTheDocument()
+ expect(trigger.firstElementChild).toHaveClass('flex-wrap')
+ expect(trigger).toHaveTextContent('public.table_01')
+ expect(trigger).toHaveTextContent('public.table_10')
+ expect(trigger).toHaveTextContent('+2')
+ expect(trigger).not.toHaveTextContent('public.table_11')
+ expect(trigger).not.toHaveTextContent('public.table_12')
+ })
+})
diff --git a/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseSchemaTablePicker.tsx b/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseSchemaTablePicker.tsx
index f3d13467957..e35e6ff6353 100644
--- a/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseSchemaTablePicker.tsx
+++ b/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseSchemaTablePicker.tsx
@@ -1,16 +1,8 @@
import { useParams } from 'common'
-import { ChevronRight } from 'lucide-react'
import { useMemo, useState } from 'react'
-import {
- Button,
- Card,
- CardContent,
- CardFooter,
- Checkbox,
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger,
-} from 'ui'
+import { Button, Card, CardContent, CardFooter, CommandGroup } from 'ui'
+import { FormLayout } from 'ui-patterns/form/Layout/FormLayout'
+import { MultiSelector } from 'ui-patterns/multi-select'
import {
PageSection,
PageSectionContent,
@@ -24,7 +16,6 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import {
buildSelectionFromPublicationTables,
buildWarehouseSetupTargets,
- getSchemaCheckedState,
getSchemaTableKey,
getSelectedTableCount,
hasSelectionChanged,
@@ -40,12 +31,15 @@ import { useReplicationSourcesQuery } from '@/data/replication/sources-query'
import { useTablesQuery } from '@/data/tables/tables-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { WAREHOUSE_PUBLICATION_NAME } from '@/lib/warehouse'
+import type { ResponseError } from '@/types'
export interface WarehouseSchemaTablePickerProps {
onSubmit: (targets: WarehouseSetupTarget[]) => void
isSubmitting: boolean
+ /** Set when editing an already-enabled Warehouse rather than setting one up for the first time. */
isEditing?: boolean
- error?: { message: string } | null
+ /** Failure from the submit itself, rendered inline rather than as a toast the user can lose. */
+ error?: ResponseError | null
}
export const WarehouseSchemaTablePicker = ({
@@ -60,7 +54,6 @@ export const WarehouseSchemaTablePicker = ({
// `null` until the user touches a checkbox, so the selection seeded from the existing
// publication can arrive asynchronously without an effect syncing it into state.
const [selectionOverride, setSelectionOverride] = useState(null)
- const [expandedOverrides, setExpandedOverrides] = useState>({})
const {
data: schemas,
@@ -85,32 +78,34 @@ export const WarehouseSchemaTablePicker = ({
isLoading: isSourcesLoading,
isError: isSourcesError,
error: sourcesError,
- } = useReplicationSourcesQuery({ projectRef })
+ } = useReplicationSourcesQuery({ projectRef }, { enabled: isEditing })
const sourceId = sourcesData?.sources.find((source) => source.name === projectRef)?.id
const {
data: publication,
isError: isPublicationsError,
error: publicationsError,
- } = useReplicationPublicationQuery({
- projectRef,
- sourceId,
- publicationName: WAREHOUSE_PUBLICATION_NAME,
- })
+ } = useReplicationPublicationQuery(
+ {
+ projectRef,
+ sourceId,
+ publicationName: WAREHOUSE_PUBLICATION_NAME,
+ },
+ { enabled: isEditing }
+ )
// Derived from data presence rather than fetch status, so there's no render gap between the
// publication query becoming enabled and it actually starting to fetch.
const isSelectionPending =
- isSourcesLoading ||
- (sourceId !== undefined && publication === undefined && !isPublicationsError)
+ isEditing &&
+ (isSourcesLoading ||
+ (sourceId !== undefined && publication === undefined && !isPublicationsError))
const initialSelection = useMemo(
- () => buildSelectionFromPublicationTables(publication?.tables ?? []),
- [publication]
+ () => (isEditing ? buildSelectionFromPublicationTables(publication?.tables ?? []) : {}),
+ [isEditing, publication]
)
- const selection = selectionOverride ?? initialSelection
-
const schemasWithTables: SchemaWithTables[] = useMemo(() => {
if (!schemas || !tables) return []
return schemas
@@ -122,38 +117,26 @@ export const WarehouseSchemaTablePicker = ({
.sort((a, b) => a.schema.localeCompare(b.schema))
}, [schemas, tables])
+ const tableKeys = schemasWithTables.flatMap(({ schema, tables }) =>
+ tables.map((table) => getSchemaTableKey(schema, table))
+ )
+ const selection = selectionOverride ?? initialSelection
const selectedCount = getSelectedTableCount(selection)
+ const selectedTableKeys = tableKeys.filter((key) => selection[key])
const hasChanges = hasSelectionChanged(selection, initialSelection)
const updateSelection = (updater: (current: SchemaTableSelection) => SchemaTableSelection) => {
setSelectionOverride((prev) => updater(prev ?? initialSelection))
}
- const toggleTable = (schema: string, table: string) => {
- const key = getSchemaTableKey(schema, table)
- if (isEditing && initialSelection[key]) return
- updateSelection((current) => ({ ...current, [key]: !current[key] }))
- }
+ const handleSchemaSelection = (schema: SchemaWithTables) => {
+ const keys = schema.tables.map((table) => getSchemaTableKey(schema.schema, table))
+ const areAllSelected = keys.every((key) => selection[key])
- const toggleSchema = (schema: SchemaWithTables) => {
- const selectableTables = schema.tables.filter(
- (table) => !isEditing || !initialSelection[getSchemaTableKey(schema.schema, table)]
- )
- const areAllSelectableTablesSelected =
- selectableTables.length > 0 &&
- selectableTables.every((table) => selection[getSchemaTableKey(schema.schema, table)])
-
- updateSelection((current) => {
- const next = { ...current }
- selectableTables.forEach((table) => {
- next[getSchemaTableKey(schema.schema, table)] = !areAllSelectableTablesSelected
- })
- return next
- })
- }
-
- const setExpanded = (schemaName: string, isOpen: boolean) => {
- setExpandedOverrides((prev) => ({ ...prev, [schemaName]: isOpen }))
+ updateSelection((current) => ({
+ ...current,
+ ...Object.fromEntries(keys.map((key) => [key, !areAllSelected])),
+ }))
}
const handleSubmit = () => {
@@ -162,18 +145,18 @@ export const WarehouseSchemaTablePicker = ({
onSubmit(targets)
}
- // Waiting on the publication too, so the pre-checked selection is in place before the user can
- // start toggling (an early toggle would otherwise pin an override that omits existing tables).
+ // When editing, wait for the publication so its selection is in place before the user can
+ // start toggling. First-time setup always starts empty and does not need to wait for it.
if (isSchemasPending || isTablesPending || isSelectionPending) {
return (
-
+
Tables
{isEditing
- ? 'Add more schemas or tables to Warehouse.'
- : 'Choose which schemas or tables to replicate.'}
+ ? 'Tables currently replicating are selected. Changes apply on save.'
+ : 'Choose which schemas or tables to replicate. You can change this at any time.'}
@@ -187,129 +170,160 @@ export const WarehouseSchemaTablePicker = ({
)
}
- if (isSchemasError) return
- if (isTablesError) return
+ if (isSchemasError) {
+ return (
+
+ )
+ }
+ if (isTablesError) {
+ return (
+
+ )
+ }
if (isEditing && isSourcesError) {
- return
+ return (
+
+ )
}
// Only blocking when editing: a first-time setup starts from an empty selection anyway, so a
// failed publication lookup shouldn't stop the user from enabling Warehouse at all.
if (isEditing && isPublicationsError) {
- return
+ return (
+
+ )
}
return (
-
+
Tables
{isEditing
- ? 'Add more schemas or tables to Warehouse. Tables already replicating cannot be removed yet.'
- : 'Choose which schemas or tables to replicate.'}
+ ? 'Tables currently replicating are selected. Changes apply on save.'
+ : 'Choose which schemas or tables to replicate. You can change this at any time.'}
{!!error && (
)}
-
-
- Schemas and tables to replicate
-
-
- {schemasWithTables.map((schema) => {
- const keys = schema.tables.map((table) => getSchemaTableKey(schema.schema, table))
- const checkedCount = keys.filter((key) => selection[key]).length
- const checkedState = getSchemaCheckedState({
- selectedCount: checkedCount,
- totalCount: keys.length,
- })
- const isOpen = expandedOverrides[schema.schema] ?? checkedCount > 0
- const selectableTableCount = schema.tables.filter(
- (table) =>
- !isEditing || !initialSelection[getSchemaTableKey(schema.schema, table)]
- ).length
+
+
+ {selectedCount} table{selectedCount === 1 ? '' : 's'} selected
+
+ }
+ >
+ {
+ const normalizedSearch = search.toLowerCase()
+ return [value, ...(keywords ?? [])].some((candidate) =>
+ candidate.toLowerCase().includes(normalizedSearch)
+ )
+ ? 1
+ : 0
+ }}
+ onValuesChange={(values) => {
+ const nextValues = new Set(values)
+ updateSelection(() =>
+ Object.fromEntries(tableKeys.map((key) => [key, nextValues.has(key)]))
+ )
+ }}
+ className="w-full"
+ >
+ {value} }
+ className="w-full"
+ />
+
+
+
+ {schemasWithTables
+ .filter((schema) => schema.tables.length > 0)
+ .map((schema) => {
+ const keys = schema.tables.map((table) =>
+ getSchemaTableKey(schema.schema, table)
+ )
+ const areAllSelected = keys.every((key) => selection[key])
- return (
- setExpanded(schema.schema, open)}
- >
-
-
-
-
- toggleSchema(schema)}
- disabled={selectableTableCount === 0}
- aria-label={`Select all tables in ${schema.schema}`}
- // The shared Checkbox only fills itself for `data-state=checked`, so a partial
- // selection would otherwise render identically to an empty one. A muted fill
- // keeps all three states visually distinct.
- className="data-[state=indeterminate]:border-foreground-lighter data-[state=indeterminate]:bg-foreground-lighter"
- />
- {schema.schema}
-
- {checkedCount}/{keys.length} tables
-
-
-
- {schema.tables.map((table) => {
- const key = getSchemaTableKey(schema.schema, table)
return (
-
- toggleTable(schema.schema, table)}
- aria-label={`Select ${schema.schema}.${table}`}
- />
- {table}
-
+
+ {schema.schema}
+ handleSchemaSelection(schema)}
+ >
+ {areAllSelected ? 'Clear' : 'Select all'}
+
+
+ }
+ >
+ {schema.tables.map((table) => {
+ const key = getSchemaTableKey(schema.schema, table)
+ return (
+
+ {table}
+
+ )
+ })}
+
)
})}
- {schema.tables.length === 0 && (
-
- No tables in this schema.
-
- )}
-
-
- )
- })}
-
+
+
+
+
-
-
- {selectedCount} table{selectedCount === 1 ? '' : 's'} selected
-
-
- {hasChanges && setSelectionOverride(null)}>Cancel }
-
- {isEditing ? 'Add replicated tables' : 'Enable Warehouse'}
-
-
+
+ {/*
+ An empty selection is a valid request that tears Warehouse down, so submitting one
+ from here would destroy a project's Warehouse with no confirmation. Disabling keeps
+ teardown on the dedicated action, which asks first.
+ */}
+
+ {isEditing ? 'Update replicated tables' : 'Enable Warehouse'}
+
diff --git a/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseSetupPanel.tsx b/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseSetupPanel.tsx
index f347f0af05d..65ef82b7c20 100644
--- a/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseSetupPanel.tsx
+++ b/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseSetupPanel.tsx
@@ -9,6 +9,7 @@ import {
type WarehouseSetupTarget,
} from './Warehouse.utils'
import { WarehouseConnectSection } from './WarehouseConnectSection'
+import { WarehouseDisableCard } from './WarehouseDisableCard'
import { WarehouseSchemaTablePicker } from './WarehouseSchemaTablePicker'
import {
WarehouseEnablingProgress,
@@ -18,9 +19,11 @@ import { AlertError } from '@/components/ui/AlertError'
import { checkLocalETLNotSetUp } from '@/data/replication/utils'
import { useWarehouseSetupMutation } from '@/data/warehouse/warehouse-setup-mutation'
import { useWarehouseSetupStatusQuery } from '@/data/warehouse/warehouse-setup-status-query'
+import { useTrack } from '@/lib/telemetry/track'
export const WarehouseSetupPanel = () => {
const { ref: projectRef } = useParams()
+ const track = useTrack()
const { data, isPending, isFetching, isError, error, refetch } = useWarehouseSetupStatusQuery(
{ projectRef },
@@ -36,8 +39,22 @@ export const WarehouseSetupPanel = () => {
const handleSetup = (targets: WarehouseSetupTarget[]) => {
if (!projectRef || targets.length === 0) return
+ const isInitialSetup = data?.setup_status !== 'complete'
- setupMutation.mutate({ projectRef, body: { targets } })
+ setupMutation.mutate(
+ { projectRef, body: { targets } },
+ {
+ onSuccess: () => {
+ if (isInitialSetup) {
+ track('warehouse_enabled', {
+ source: 'integrations_overview',
+ schemaTargetCount: targets.filter((target) => target.type === 'schema').length,
+ tableTargetCount: targets.filter((target) => target.type === 'table').length,
+ })
+ }
+ },
+ }
+ )
}
if (isPending) return
@@ -123,6 +140,7 @@ export const WarehouseSetupPanel = () => {
error={setupMutation.error}
/>
+
>
)
}
diff --git a/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseTableStatusList.tsx b/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseTableStatusList.tsx
index f32c753b7c8..04a3719a3b5 100644
--- a/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseTableStatusList.tsx
+++ b/apps/studio/components/interfaces/Integrations/Warehouse/WarehouseTableStatusList.tsx
@@ -94,7 +94,7 @@ const WarehouseTableStatusList = ({ tables }: WarehouseTableStatusListProps) =>
}
export const WarehouseReplicatedTablesSection = ({ tables }: WarehouseTableStatusListProps) => (
-
+
Status
@@ -119,7 +119,7 @@ export const WarehouseEnablingProgress = ({ status }: WarehouseEnablingProgressP
: `Backfilling selected tables. ${syncedTableCount} of ${status.tables.length} tables synced.`
return (
-
+
Status
diff --git a/apps/studio/data/warehouse/warehouse-setup-mutation.ts b/apps/studio/data/warehouse/warehouse-setup-mutation.ts
index 579c2c43d18..426ffeda48f 100644
--- a/apps/studio/data/warehouse/warehouse-setup-mutation.ts
+++ b/apps/studio/data/warehouse/warehouse-setup-mutation.ts
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
import { warehouseKeys } from './keys'
import { handleError, post } from '@/data/fetchers'
+import { replicationKeys } from '@/data/replication/keys'
import type { ResponseError, UseCustomMutationOptions } from '@/types'
export type WarehouseSetupBody = components['schemas']['WarehouseSetupBody']
@@ -42,9 +43,14 @@ export const useWarehouseSetupMutation = ({
return useMutation({
mutationFn: (vars) => setupWarehouse(vars),
async onSuccess(data, variables, context) {
- await queryClient.invalidateQueries({
- queryKey: warehouseKeys.setupStatus(variables.projectRef),
- })
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: warehouseKeys.setupStatus(variables.projectRef),
+ }),
+ queryClient.invalidateQueries({
+ queryKey: replicationKeys.sources(variables.projectRef),
+ }),
+ ])
await onSuccess?.(data, variables, context)
},
async onError(error, variables, context) {
diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts
index b119fb0ba91..1a3f3caa2dd 100644
--- a/packages/common/telemetry-constants.ts
+++ b/packages/common/telemetry-constants.ts
@@ -3857,6 +3857,39 @@ export interface HeaderLocalVersionPopoverOpenedEvent {
groups: Partial
}
+/**
+ * User enabled Warehouse by submitting a schema and table selection.
+ *
+ * @group Events
+ * @source studio
+ * @page /dashboard/project/{ref}/integrations/warehouse/overview
+ */
+export interface WarehouseEnabledEvent {
+ action: 'warehouse_enabled'
+ properties: {
+ /** Where the user initiated Warehouse setup. */
+ source: 'integrations_overview'
+ /** Number of schemas replicated in full. */
+ schemaTargetCount: number
+ /** Number of tables replicated individually. */
+ tableTargetCount: number
+ }
+ groups: TelemetryGroups
+}
+
+/**
+ * User disabled Warehouse for a project.
+ *
+ * @group Events
+ * @source studio
+ * @page /dashboard/project/{ref}/integrations/warehouse/overview
+ */
+export interface WarehouseDisabledEvent {
+ action: 'warehouse_disabled'
+ properties: {}
+ groups: TelemetryGroups
+}
+
/**
* @hidden
*/
@@ -4075,3 +4108,5 @@ export type TelemetryEvent =
| HeaderUserDropdownOpenedEvent
| HeaderLocalDropdownOpenedEvent
| HeaderLocalVersionPopoverOpenedEvent
+ | WarehouseEnabledEvent
+ | WarehouseDisabledEvent