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}} +
), })) @@ -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 + + + + + + + + + + + + + + + + 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} + +
+ } + > + {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 && } - -
+ + {/* + 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. + */} +
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