From 411333f437bfd130b6895c88f11452b6000bfc64 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Mon, 25 May 2026 14:19:32 -0600 Subject: [PATCH 1/7] added context menus for views --- .../DeleteConfirmationDialogs.tsx | 167 +++++++++++++++++- .../TableGridEditor/TableGridEditor.tsx | 2 +- .../TableEditorLayout/EntityListItem.tsx | 139 +++++++++++++++ .../materialized-view-delete-mutation.ts | 79 +++++++++ .../studio/data/views/view-delete-mutation.ts | 73 ++++++++ apps/studio/state/table-editor.tsx | 18 +- .../src/Dialogs/ConfirmationModal.tsx | 2 +- 7 files changed, 474 insertions(+), 6 deletions(-) create mode 100644 apps/studio/data/materialized-views/materialized-view-delete-mutation.ts create mode 100644 apps/studio/data/views/view-delete-mutation.ts diff --git a/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx b/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx index 5fc2646c4698f..54dd57f3450a9 100644 --- a/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx @@ -7,17 +7,19 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { useTableFilter } from '@/components/grid/hooks/useTableFilter' import type { SupaRow } from '@/components/grid/types' import { useDatabaseColumnDeleteMutation } from '@/data/database-columns/database-column-delete-mutation' -import { TableLike } from '@/data/table-editor/table-editor-types' +import { useMaterializedViewDeleteMutation } from '@/data/materialized-views/materialized-view-delete-mutation' +import { Entity } from '@/data/table-editor/table-editor-types' import { useTableRowDeleteAllMutation } from '@/data/table-rows/table-row-delete-all-mutation' import { useTableRowDeleteMutation } from '@/data/table-rows/table-row-delete-mutation' import { useTableRowTruncateMutation } from '@/data/table-rows/table-row-truncate-mutation' import { useTableDeleteMutation } from '@/data/tables/table-delete-mutation' +import { useViewDeleteMutation } from '@/data/views/view-delete-mutation' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useGetImpersonatedRoleState } from '@/state/role-impersonation-state' import { useTableEditorStateSnapshot } from '@/state/table-editor' export type DeleteConfirmationDialogsProps = { - selectedTable?: TableLike + selectedTable?: Entity onTableDeleted?: () => void } @@ -69,6 +71,32 @@ const DeleteConfirmationDialogs = ({ }, }) + const { mutate: deleteView } = useViewDeleteMutation({ + onSuccess: async () => { + toast.success(`Successfully deleted view "${selectedTable?.name}"`) + onTableDeleted?.() + }, + onError: (error) => { + toast.error(`Failed to delete ${selectedTable?.name}: ${error.message}`) + }, + onSettled: () => { + snap.closeConfirmationDialog() + }, + }) + + const { mutate: deleteMaterializedView } = useMaterializedViewDeleteMutation({ + onSuccess: async () => { + toast.success(`Successfully deleted materialized view "${selectedTable?.name}"`) + onTableDeleted?.() + }, + onError: (error) => { + toast.error(`Failed to delete ${selectedTable?.name}: ${error.message}`) + }, + onSettled: () => { + snap.closeConfirmationDialog() + }, + }) + const { mutate: deleteRows, isPending: isDeletingRows } = useTableRowDeleteMutation({ onSuccess: () => { if (snap.confirmationDialog?.type === 'row') { @@ -121,7 +149,10 @@ const DeleteConfirmationDialogs = ({ : 0 const isDeleteWithCascade = - snap.confirmationDialog?.type === 'column' || snap.confirmationDialog?.type === 'table' + snap.confirmationDialog?.type === 'column' || + snap.confirmationDialog?.type === 'table' || + snap.confirmationDialog?.type === 'view' || + snap.confirmationDialog?.type === 'materialized-view' ? snap.confirmationDialog.isDeleteWithCascade : false @@ -156,6 +187,34 @@ const DeleteConfirmationDialogs = ({ }) } + const onConfirmDeleteView = async () => { + if (snap.confirmationDialog?.type !== 'view') return + if (!project || !selectedTable) return + + deleteView({ + projectRef: project.ref, + connectionString: project.connectionString, + id: selectedTable.id, + name: selectedTable.name, + schema: selectedTable.schema, + cascade: isDeleteWithCascade, + }) + } + + const onConfirmDeleteMaterializedView = async () => { + if (snap.confirmationDialog?.type !== 'materialized-view') return + if (!project || !selectedTable) return + + deleteMaterializedView({ + projectRef: project.ref, + connectionString: project.connectionString, + id: selectedTable.id, + name: selectedTable.name, + schema: selectedTable.schema, + cascade: isDeleteWithCascade, + }) + } + const getImpersonatedRoleState = useGetImpersonatedRoleState() const onConfirmDeleteRow = async () => { @@ -320,6 +379,26 @@ const DeleteConfirmationDialogs = ({ + snap.toggleConfirmationIsWithCascade(!isDeleteWithCascade)} + onCancel={() => snap.closeConfirmationDialog()} + onConfirm={onConfirmDeleteView} + /> + + snap.toggleConfirmationIsWithCascade(!isDeleteWithCascade)} + onCancel={() => snap.closeConfirmationDialog()} + onConfirm={onConfirmDeleteMaterializedView} + /> + void + onCancel: () => void + onConfirm: () => void +} + +const DropEntityConfirmationModal = ({ + visible, + entityLabel, + entityName, + isDeleteWithCascade, + onToggleCascade, + onCancel, + onConfirm, +}: DropEntityConfirmationModalProps) => { + const checkboxId = `checkbox-cascade-${entityLabel.replace(/\s+/g, '-')}` + return ( + {`Confirm deletion of ${entityLabel} "${entityName ?? ''}"`} + } + confirmLabel="Delete" + confirmLabelLoading="Deleting" + onCancel={onCancel} + onConfirm={onConfirm} + > +
+

+ Are you sure you want to delete this {entityLabel}? This action cannot be undone. +

+
+ +
+ +

+ Deletes the {entityLabel} and its dependent objects +

+
+
+ {isDeleteWithCascade && ( + + + Warning: Dropping with cascade may result in unintended consequences + + + All dependent objects will be removed, as will any objects that depend on them, + recursively. + + + + + + )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index c116e66693b07..52851b70db1b5 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -193,7 +193,7 @@ export const TableGridEditor = ({ diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index 8308876173c08..0161d2ae2b195 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -29,6 +29,7 @@ import { getEntityLintDetails } from '@/components/interfaces/TableGridEditor/Ta import { EntityTypeIcon } from '@/components/ui/EntityTypeIcon' import { InlineLink } from '@/components/ui/InlineLink' import { getTableDefinition } from '@/data/database/table-definition-query' +import { getViewDefinition } from '@/data/database/view-definition-query' import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' import { Entity } from '@/data/entity-types/entity-types-infinite-query' import { useProjectLintsQuery } from '@/data/lint/lint-query' @@ -289,6 +290,48 @@ export const EntityListItem = ({ )} + {(entity.type === ENTITY_TYPE.VIEW || + entity.type === ENTITY_TYPE.MATERIALIZED_VIEW) && ( + { + e.stopPropagation() + const label = + entity.type === ENTITY_TYPE.MATERIALIZED_VIEW ? 'materialized view' : 'view' + const toastId = toast.loading(`Getting ${label} definition...`) + + const formattedDefinition = getViewDefinition({ + id: entity.id, + projectRef: project?.ref, + connectionString: project?.connectionString, + includeCreateStatement: true, + }).then((definition) => { + if (!definition) { + throw new Error(`Failed to get ${label} definition`) + } + return formatSql(definition) + }) + + try { + await copyToClipboard(formattedDefinition, () => { + toast.success( + `${label[0].toUpperCase() + label.slice(1)} definition copied to clipboard`, + { id: toastId } + ) + }) + } catch (err: any) { + toast.error(`Failed to copy ${label} definition: ` + (err.message || err), { + id: toastId, + }) + } + }} + > + + Copy view definition + + )} + {entity.type === ENTITY_TYPE.TABLE && ( <> @@ -378,6 +421,102 @@ export const EntityListItem = ({ )} + + {entity.type === ENTITY_TYPE.VIEW && ( + <> + + + + + + Export data + + + { + e.stopPropagation() + exportCsv() + }} + > + Export view as CSV + + { + e.stopPropagation() + exportSql() + }} + > + Export view as SQL + + + + + + { + e.stopPropagation() + snap.onDeleteView() + }} + > + + Delete view + + + )} + + {entity.type === ENTITY_TYPE.MATERIALIZED_VIEW && ( + <> + + + + + + Export data + + + { + e.stopPropagation() + exportCsv() + }} + > + Export view as CSV + + { + e.stopPropagation() + exportSql() + }} + > + Export view as SQL + + + + + + { + e.stopPropagation() + snap.onDeleteMaterializedView() + }} + > + + Delete view + + + )} )} diff --git a/apps/studio/data/materialized-views/materialized-view-delete-mutation.ts b/apps/studio/data/materialized-views/materialized-view-delete-mutation.ts new file mode 100644 index 0000000000000..ec53bc2831408 --- /dev/null +++ b/apps/studio/data/materialized-views/materialized-view-delete-mutation.ts @@ -0,0 +1,79 @@ +import { ident, safeSql } from '@supabase/pg-meta/src/pg-format' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { materializedViewKeys } from './keys' +import { entityTypeKeys } from '@/data/entity-types/keys' +import { executeSql } from '@/data/sql/execute-sql-query' +import { tableEditorKeys } from '@/data/table-editor/keys' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type MaterializedViewDeleteVariables = { + projectRef: string + connectionString?: string | null + id: number + name: string + schema: string + cascade?: boolean +} + +export async function deleteMaterializedView({ + projectRef, + connectionString, + id, + name, + schema, + cascade = false, +}: MaterializedViewDeleteVariables) { + const sql = safeSql`DROP MATERIALIZED VIEW ${ident(schema)}.${ident(name)}${cascade ? safeSql` CASCADE` : safeSql``};` + + const { result } = await executeSql({ + projectRef, + connectionString, + sql, + queryKey: ['materialized-view', 'delete', id], + }) + + return result +} + +type MaterializedViewDeleteData = Awaited> + +export const useMaterializedViewDeleteMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions< + MaterializedViewDeleteData, + ResponseError, + MaterializedViewDeleteVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => deleteMaterializedView(vars), + async onSuccess(data, variables, context) { + const { id, projectRef, schema } = variables + await Promise.all([ + queryClient.invalidateQueries({ queryKey: tableEditorKeys.tableEditor(projectRef, id) }), + queryClient.invalidateQueries({ + queryKey: materializedViewKeys.listBySchema(projectRef, schema), + }), + queryClient.invalidateQueries({ queryKey: entityTypeKeys.list(projectRef) }), + ]) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to delete materialized view: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/views/view-delete-mutation.ts b/apps/studio/data/views/view-delete-mutation.ts new file mode 100644 index 0000000000000..94a41687f9717 --- /dev/null +++ b/apps/studio/data/views/view-delete-mutation.ts @@ -0,0 +1,73 @@ +import { ident, safeSql } from '@supabase/pg-meta/src/pg-format' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { viewKeys } from './keys' +import { entityTypeKeys } from '@/data/entity-types/keys' +import { executeSql } from '@/data/sql/execute-sql-query' +import { tableEditorKeys } from '@/data/table-editor/keys' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type ViewDeleteVariables = { + projectRef: string + connectionString?: string | null + id: number + name: string + schema: string + cascade?: boolean +} + +export async function deleteView({ + projectRef, + connectionString, + id, + name, + schema, + cascade = false, +}: ViewDeleteVariables) { + const sql = safeSql`DROP VIEW ${ident(schema)}.${ident(name)}${cascade ? safeSql` CASCADE` : safeSql``};` + + const { result } = await executeSql({ + projectRef, + connectionString, + sql, + queryKey: ['view', 'delete', id], + }) + + return result +} + +type ViewDeleteData = Awaited> + +export const useViewDeleteMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => deleteView(vars), + async onSuccess(data, variables, context) { + const { id, projectRef, schema } = variables + await Promise.all([ + queryClient.invalidateQueries({ queryKey: tableEditorKeys.tableEditor(projectRef, id) }), + queryClient.invalidateQueries({ queryKey: viewKeys.listBySchema(projectRef, schema) }), + queryClient.invalidateQueries({ queryKey: entityTypeKeys.list(projectRef) }), + ]) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to delete view: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/state/table-editor.tsx b/apps/studio/state/table-editor.tsx index 018059fdfc8a9..59f6372cf0fea 100644 --- a/apps/studio/state/table-editor.tsx +++ b/apps/studio/state/table-editor.tsx @@ -47,6 +47,8 @@ export type SidePanel = export type ConfirmationDialog = | { type: 'table'; isDeleteWithCascade: boolean } + | { type: 'view'; isDeleteWithCascade: boolean } + | { type: 'materialized-view'; isDeleteWithCascade: boolean } | { type: 'column'; column: SafePostgresColumn; isDeleteWithCascade: boolean } // [Joshen] Just FYI callback, numRows, allRowsSelected is a temp workaround so that // DeleteConfirmationDialog can trigger dispatch methods after the successful deletion of rows. @@ -135,6 +137,18 @@ export const createTableEditorState = () => { confirmationDialog: { type: 'table', isDeleteWithCascade: false }, } }, + onDeleteView: () => { + state.ui = { + open: 'confirmation-dialog', + confirmationDialog: { type: 'view', isDeleteWithCascade: false }, + } + }, + onDeleteMaterializedView: () => { + state.ui = { + open: 'confirmation-dialog', + confirmationDialog: { type: 'materialized-view', isDeleteWithCascade: false }, + } + }, /* Columns */ onAddColumn: () => { @@ -225,7 +239,9 @@ export const createTableEditorState = () => { if ( state.ui.open === 'confirmation-dialog' && (state.ui.confirmationDialog.type === 'column' || - state.ui.confirmationDialog.type === 'table') + state.ui.confirmationDialog.type === 'table' || + state.ui.confirmationDialog.type === 'view' || + state.ui.confirmationDialog.type === 'materialized-view') ) { state.ui.confirmationDialog.isDeleteWithCascade = overrideIsDeleteWithCascade ?? !state.ui.confirmationDialog.isDeleteWithCascade diff --git a/packages/ui-patterns/src/Dialogs/ConfirmationModal.tsx b/packages/ui-patterns/src/Dialogs/ConfirmationModal.tsx index 659532377d5e6..d6a8cf61e424e 100644 --- a/packages/ui-patterns/src/Dialogs/ConfirmationModal.tsx +++ b/packages/ui-patterns/src/Dialogs/ConfirmationModal.tsx @@ -86,7 +86,7 @@ export const ConfirmationModal = forwardRef< }, [loading_]) const { title: _alertBaseTitle, children: _alertBaseChildren, ...alertBase } = alert?.base ?? {} - const alertTitleProps = alert?.title ? { label: alert.title } : {} + const alertTitleProps = alert?.title ? { title: alert.title } : {} return ( Date: Mon, 25 May 2026 14:43:28 -0600 Subject: [PATCH 2/7] added e2e tests --- .../features/table-editor-views.spec.ts | 288 ++++++++++++++++++ e2e/studio/utils/db/index.ts | 10 +- e2e/studio/utils/db/queries.ts | 38 +++ 3 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 e2e/studio/features/table-editor-views.spec.ts diff --git a/e2e/studio/features/table-editor-views.spec.ts b/e2e/studio/features/table-editor-views.spec.ts new file mode 100644 index 0000000000000..c4a9999ade32b --- /dev/null +++ b/e2e/studio/features/table-editor-views.spec.ts @@ -0,0 +1,288 @@ +import { expect, Page } from '@playwright/test' + +import { env } from '../env.config.js' +import { expectClipboardValue } from '../utils/clipboard.js' +import { + createMaterializedView, + createTable, + createView, + dropMaterializedView, + dropTable, + dropView, +} from '../utils/db/queries.js' +import { test, withSetupCleanup } from '../utils/test.js' +import { toUrl } from '../utils/to-url.js' +import { createApiResponseWaiter, waitForApiResponse } from '../utils/wait-for-response.js' + +/** + * Opens the entity context menu in the table editor sidebar. + * The entity must be the currently-selected one (canEdit = isActive && !isLocked). + */ +const openEntityContextMenu = async (page: Page, entityName: string) => { + const entityButton = page.getByRole('button', { name: `View ${entityName}`, exact: true }) + await entityButton.click() + await entityButton.hover() + const menuButton = entityButton.locator('button[aria-haspopup="menu"]') + await expect(menuButton).toBeVisible({ timeout: 30000 }) + await menuButton.click() +} + +const goToTableEditor = async (page: Page, ref: string) => { + const tableLoadWait = createApiResponseWaiter( + page, + 'pg-meta', + ref, + 'query?key=entity-types-public-' + ) + await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) + await tableLoadWait +} + +// Run on platform serially to avoid rate limits; parallel in self-hosted. +const testRunner = env.IS_PLATFORM ? test.describe.serial : test.describe + +testRunner('table editor — view context menu', () => { + const baseTable = 'pw_view_menu_base' + const viewName = 'pw_view_menu_view' + + const setupView = async () => { + await createTable(baseTable, 'note', [{ note: 'alpha' }, { note: 'beta' }]) + await createView(viewName, `SELECT id, note FROM public.${baseTable}`) + } + + const cleanupView = async () => { + await dropView(viewName) + await dropTable(baseTable) + } + + test('copy name copies the view name to clipboard', async ({ page, ref }) => { + await using _ = await withSetupCleanup(setupView, cleanupView) + await goToTableEditor(page, ref) + + await openEntityContextMenu(page, viewName) + await page.getByRole('menuitem', { name: 'Copy name' }).click() + await expect( + page.getByRole('menuitem', { name: 'Copy name' }), + 'menu should close after Copy name click' + ).not.toBeVisible() + + await expectClipboardValue({ page, value: viewName, exact: true }) + }) + + test('copy view definition copies CREATE VIEW statement to clipboard', async ({ page, ref }) => { + await using _ = await withSetupCleanup(setupView, cleanupView) + await goToTableEditor(page, ref) + + await openEntityContextMenu(page, viewName) + + const definitionWait = waitForApiResponse(page, 'pg-meta', ref, 'query?key=view-definition-') + await page.getByRole('menuitem', { name: 'Copy view definition' }).click() + await definitionWait + + await expect( + page.getByText('View definition copied to clipboard'), + 'success toast should appear after copy' + ).toBeVisible({ timeout: 15000 }) + + const clipboardText: string = await page.evaluate(() => navigator.clipboard.readText()) + expect(clipboardText.toLowerCase()).toContain(`create view`) + expect(clipboardText.toLowerCase()).toContain(viewName.toLowerCase()) + }) + + test('export view as CSV shows confirmation reason and downloads', async ({ page, ref }) => { + await using _ = await withSetupCleanup(setupView, cleanupView) + await goToTableEditor(page, ref) + + await openEntityContextMenu(page, viewName) + const exportItem = page.getByRole('menuitem', { name: 'Export data' }) + await expect(exportItem).toBeVisible() + await exportItem.hover() + await expect(exportItem).toHaveAttribute('data-state', /open/) + await page.getByRole('menuitem', { name: 'Export view as CSV' }).click() + + // Confirmation modal appears with reason text — guards the shared-component fix. + await expect( + page.getByText('Confirm to export data'), + 'export confirmation dialog should appear' + ).toBeVisible({ timeout: 15000 }) + await expect( + page.getByText(/Exporting a view may cause consistency issues/i), + 'confirmation reason text should be visible inside the modal' + ).toBeVisible() + + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('button', { name: 'Submit' }).click(), + ]) + expect(download.suggestedFilename()).toContain('.csv') + }) + + test('export view as SQL shows confirmation and downloads', async ({ page, ref }) => { + await using _ = await withSetupCleanup(setupView, cleanupView) + await goToTableEditor(page, ref) + + await openEntityContextMenu(page, viewName) + const exportItem = page.getByRole('menuitem', { name: 'Export data' }) + await expect(exportItem).toBeVisible() + await exportItem.hover() + await expect(exportItem).toHaveAttribute('data-state', /open/) + await page.getByRole('menuitem', { name: 'Export view as SQL' }).click() + + await expect( + page.getByText('Confirm to export data'), + 'export confirmation dialog should appear' + ).toBeVisible({ timeout: 15000 }) + + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('button', { name: 'Submit' }).click(), + ]) + expect(download.suggestedFilename()).toContain('.sql') + }) + + test('delete view runs DROP VIEW and removes it from the sidebar', async ({ page, ref }) => { + // No outer cleanup — the test deletes the view. Just clean the base table at the end. + await using _ = await withSetupCleanup( + async () => { + await createTable(baseTable, 'note', [{ note: 'alpha' }]) + await createView(viewName, `SELECT id, note FROM public.${baseTable}`) + }, + async () => { + await dropView(viewName) + await dropTable(baseTable) + } + ) + await goToTableEditor(page, ref) + + await openEntityContextMenu(page, viewName) + await page.getByRole('menuitem', { name: 'Delete view' }).click() + + await expect( + page.getByRole('heading', { name: `Confirm deletion of view "${viewName}"` }), + 'confirm dialog title should include the view name' + ).toBeVisible({ timeout: 15000 }) + + const deletePromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=view-delete-', { + method: 'POST', + }) + const entityTypesPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=entity-types-') + await page.getByRole('button', { name: 'Delete', exact: true }).click() + await Promise.all([deletePromise, entityTypesPromise]) + + await expect + .poll( + async () => + await page.getByRole('button', { name: `View ${viewName}`, exact: true }).count(), + { message: 'view should be removed from the sidebar after delete' } + ) + .toBe(0) + }) +}) + +testRunner('table editor — materialized view context menu', () => { + const baseTable = 'pw_mv_menu_base' + const mvName = 'pw_mv_menu_view' + + const setupMv = async () => { + await createTable(baseTable, 'note', [{ note: 'alpha' }, { note: 'beta' }]) + await createMaterializedView(mvName, `SELECT id, note FROM public.${baseTable}`) + } + + const cleanupMv = async () => { + await dropMaterializedView(mvName) + await dropTable(baseTable) + } + + test('copy name copies the materialized view name to clipboard', async ({ page, ref }) => { + await using _ = await withSetupCleanup(setupMv, cleanupMv) + await goToTableEditor(page, ref) + + await openEntityContextMenu(page, mvName) + await page.getByRole('menuitem', { name: 'Copy name' }).click() + await expect(page.getByRole('menuitem', { name: 'Copy name' })).not.toBeVisible() + + await expectClipboardValue({ page, value: mvName, exact: true }) + }) + + test('copy materialized view definition copies CREATE statement to clipboard', async ({ + page, + ref, + }) => { + await using _ = await withSetupCleanup(setupMv, cleanupMv) + await goToTableEditor(page, ref) + + await openEntityContextMenu(page, mvName) + + const definitionWait = waitForApiResponse(page, 'pg-meta', ref, 'query?key=view-definition-') + await page.getByRole('menuitem', { name: 'Copy materialized view definition' }).click() + await definitionWait + + await expect(page.getByText('Materialized view definition copied to clipboard')).toBeVisible({ + timeout: 15000, + }) + + const clipboardText: string = await page.evaluate(() => navigator.clipboard.readText()) + expect(clipboardText.toLowerCase()).toContain('create materialized view') + expect(clipboardText.toLowerCase()).toContain(mvName.toLowerCase()) + }) + + test('export materialized view as CSV shows confirmation and downloads', async ({ + page, + ref, + }) => { + await using _ = await withSetupCleanup(setupMv, cleanupMv) + await goToTableEditor(page, ref) + + await openEntityContextMenu(page, mvName) + const exportItem = page.getByRole('menuitem', { name: 'Export data' }) + await expect(exportItem).toBeVisible() + await exportItem.hover() + await expect(exportItem).toHaveAttribute('data-state', /open/) + await page.getByRole('menuitem', { name: 'Export view as CSV' }).click() + + await expect( + page.getByText(/Exporting a materialized view may cause performance issues/i), + 'materialized view-specific confirmation reason should appear' + ).toBeVisible({ timeout: 15000 }) + + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('button', { name: 'Submit' }).click(), + ]) + expect(download.suggestedFilename()).toContain('.csv') + }) + + test('delete materialized view runs DROP and removes it from the sidebar', async ({ + page, + ref, + }) => { + await using _ = await withSetupCleanup(setupMv, cleanupMv) + await goToTableEditor(page, ref) + + await openEntityContextMenu(page, mvName) + await page.getByRole('menuitem', { name: 'Delete materialized view' }).click() + + await expect( + page.getByRole('heading', { + name: `Confirm deletion of materialized view "${mvName}"`, + }) + ).toBeVisible({ timeout: 15000 }) + + const deletePromise = waitForApiResponse( + page, + 'pg-meta', + ref, + 'query?key=materialized-view-delete-', + { method: 'POST' } + ) + const entityTypesPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=entity-types-') + await page.getByRole('button', { name: 'Delete', exact: true }).click() + await Promise.all([deletePromise, entityTypesPromise]) + + await expect + .poll( + async () => await page.getByRole('button', { name: `View ${mvName}`, exact: true }).count() + ) + .toBe(0) + }) +}) diff --git a/e2e/studio/utils/db/index.ts b/e2e/studio/utils/db/index.ts index c5979283622da..177c8bb5111bb 100644 --- a/e2e/studio/utils/db/index.ts +++ b/e2e/studio/utils/db/index.ts @@ -1,2 +1,10 @@ export { query } from './client.js' -export { createTable, dropTable, tableExists } from './queries.js' +export { + createMaterializedView, + createTable, + createView, + dropMaterializedView, + dropTable, + dropView, + tableExists, +} from './queries.js' diff --git a/e2e/studio/utils/db/queries.ts b/e2e/studio/utils/db/queries.ts index cd6b8034fde65..90fbe0fbb12c9 100644 --- a/e2e/studio/utils/db/queries.ts +++ b/e2e/studio/utils/db/queries.ts @@ -69,3 +69,41 @@ export async function createTableWithRLS( export async function dropTable(tableName: string) { await query(`DROP TABLE IF EXISTS ${tableName} CASCADE`) } + +/** + * Create a view in the public schema. Assumes the underlying table already exists. + * + * @param viewName - The view name to create + * @param selectSql - The SELECT statement that defines the view (without trailing semicolon) + */ +export async function createView(viewName: string, selectSql: string) { + await query(`CREATE OR REPLACE VIEW public.${viewName} AS ${selectSql}`) +} + +/** + * Drop a view if it exists. + * + * @param viewName - The view name to drop + */ +export async function dropView(viewName: string) { + await query(`DROP VIEW IF EXISTS public.${viewName} CASCADE`) +} + +/** + * Create a materialized view in the public schema. + * + * @param viewName - The materialized view name to create + * @param selectSql - The SELECT statement that defines the view (without trailing semicolon) + */ +export async function createMaterializedView(viewName: string, selectSql: string) { + await query(`CREATE MATERIALIZED VIEW IF NOT EXISTS public.${viewName} AS ${selectSql}`) +} + +/** + * Drop a materialized view if it exists. + * + * @param viewName - The materialized view name to drop + */ +export async function dropMaterializedView(viewName: string) { + await query(`DROP MATERIALIZED VIEW IF EXISTS public.${viewName} CASCADE`) +} From d9c51340a01c3fd7b7363e76212d7dbea99de574 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Tue, 26 May 2026 07:28:17 -0600 Subject: [PATCH 3/7] added e2e tests for material views --- .../TableEditorLayout/EntityListItem.tsx | 8 +- .../features/table-editor-views.spec.ts | 133 +++++++++--------- 2 files changed, 75 insertions(+), 66 deletions(-) diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index 0161d2ae2b195..393231c6deef2 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -328,7 +328,11 @@ export const EntityListItem = ({ }} > - Copy view definition + + Copy{' '} + {entity.type === ENTITY_TYPE.MATERIALIZED_VIEW ? 'materialized view' : 'view'}{' '} + definition + )} @@ -513,7 +517,7 @@ export const EntityListItem = ({ }} > - Delete view + Delete materialized view )} diff --git a/e2e/studio/features/table-editor-views.spec.ts b/e2e/studio/features/table-editor-views.spec.ts index c4a9999ade32b..fccc785624b37 100644 --- a/e2e/studio/features/table-editor-views.spec.ts +++ b/e2e/studio/features/table-editor-views.spec.ts @@ -1,6 +1,6 @@ +import crypto from 'node:crypto' import { expect, Page } from '@playwright/test' -import { env } from '../env.config.js' import { expectClipboardValue } from '../utils/clipboard.js' import { createMaterializedView, @@ -10,7 +10,7 @@ import { dropTable, dropView, } from '../utils/db/queries.js' -import { test, withSetupCleanup } from '../utils/test.js' +import { test } from '../utils/test.js' import { toUrl } from '../utils/to-url.js' import { createApiResponseWaiter, waitForApiResponse } from '../utils/wait-for-response.js' @@ -38,42 +38,69 @@ const goToTableEditor = async (page: Page, ref: string) => { await tableLoadWait } -// Run on platform serially to avoid rate limits; parallel in self-hosted. -const testRunner = env.IS_PLATFORM ? test.describe.serial : test.describe +const uniqueSuffix = () => crypto.randomBytes(4).toString('hex') -testRunner('table editor — view context menu', () => { - const baseTable = 'pw_view_menu_base' - const viewName = 'pw_view_menu_view' - - const setupView = async () => { - await createTable(baseTable, 'note', [{ note: 'alpha' }, { note: 'beta' }]) - await createView(viewName, `SELECT id, note FROM public.${baseTable}`) +/** + * Each test owns its own base table + view so tests can run in parallel without + * stomping on each other's DB state. Use with `await using fixture = ...` so + * cleanup runs whether the test passes or fails. + */ +const setupViewFixture = async ( + rows: Array> = [{ note: 'alpha' }, { note: 'beta' }] +) => { + const suffix = uniqueSuffix() + const baseTable = `pw_view_menu_base_${suffix}` + const viewName = `pw_view_menu_view_${suffix}` + await createTable(baseTable, 'note', rows) + await createView(viewName, `SELECT id, note FROM public.${baseTable}`) + return { + baseTable, + viewName, + async [Symbol.asyncDispose]() { + await dropView(viewName) + await dropTable(baseTable) + }, } +} - const cleanupView = async () => { - await dropView(viewName) - await dropTable(baseTable) +const setupMaterializedViewFixture = async ( + rows: Array> = [{ note: 'alpha' }, { note: 'beta' }] +) => { + const suffix = uniqueSuffix() + const baseTable = `pw_mv_menu_base_${suffix}` + const mvName = `pw_mv_menu_view_${suffix}` + await createTable(baseTable, 'note', rows) + await createMaterializedView(mvName, `SELECT id, note FROM public.${baseTable}`) + return { + baseTable, + mvName, + async [Symbol.asyncDispose]() { + await dropMaterializedView(mvName) + await dropTable(baseTable) + }, } +} +test.describe('table editor — view context menu', () => { test('copy name copies the view name to clipboard', async ({ page, ref }) => { - await using _ = await withSetupCleanup(setupView, cleanupView) + await using fixture = await setupViewFixture() await goToTableEditor(page, ref) - await openEntityContextMenu(page, viewName) + await openEntityContextMenu(page, fixture.viewName) await page.getByRole('menuitem', { name: 'Copy name' }).click() await expect( page.getByRole('menuitem', { name: 'Copy name' }), 'menu should close after Copy name click' ).not.toBeVisible() - await expectClipboardValue({ page, value: viewName, exact: true }) + await expectClipboardValue({ page, value: fixture.viewName, exact: true }) }) test('copy view definition copies CREATE VIEW statement to clipboard', async ({ page, ref }) => { - await using _ = await withSetupCleanup(setupView, cleanupView) + await using fixture = await setupViewFixture() await goToTableEditor(page, ref) - await openEntityContextMenu(page, viewName) + await openEntityContextMenu(page, fixture.viewName) const definitionWait = waitForApiResponse(page, 'pg-meta', ref, 'query?key=view-definition-') await page.getByRole('menuitem', { name: 'Copy view definition' }).click() @@ -86,14 +113,14 @@ testRunner('table editor — view context menu', () => { const clipboardText: string = await page.evaluate(() => navigator.clipboard.readText()) expect(clipboardText.toLowerCase()).toContain(`create view`) - expect(clipboardText.toLowerCase()).toContain(viewName.toLowerCase()) + expect(clipboardText.toLowerCase()).toContain(fixture.viewName.toLowerCase()) }) test('export view as CSV shows confirmation reason and downloads', async ({ page, ref }) => { - await using _ = await withSetupCleanup(setupView, cleanupView) + await using fixture = await setupViewFixture() await goToTableEditor(page, ref) - await openEntityContextMenu(page, viewName) + await openEntityContextMenu(page, fixture.viewName) const exportItem = page.getByRole('menuitem', { name: 'Export data' }) await expect(exportItem).toBeVisible() await exportItem.hover() @@ -118,10 +145,10 @@ testRunner('table editor — view context menu', () => { }) test('export view as SQL shows confirmation and downloads', async ({ page, ref }) => { - await using _ = await withSetupCleanup(setupView, cleanupView) + await using fixture = await setupViewFixture() await goToTableEditor(page, ref) - await openEntityContextMenu(page, viewName) + await openEntityContextMenu(page, fixture.viewName) const exportItem = page.getByRole('menuitem', { name: 'Export data' }) await expect(exportItem).toBeVisible() await exportItem.hover() @@ -141,24 +168,14 @@ testRunner('table editor — view context menu', () => { }) test('delete view runs DROP VIEW and removes it from the sidebar', async ({ page, ref }) => { - // No outer cleanup — the test deletes the view. Just clean the base table at the end. - await using _ = await withSetupCleanup( - async () => { - await createTable(baseTable, 'note', [{ note: 'alpha' }]) - await createView(viewName, `SELECT id, note FROM public.${baseTable}`) - }, - async () => { - await dropView(viewName) - await dropTable(baseTable) - } - ) + await using fixture = await setupViewFixture([{ note: 'alpha' }]) await goToTableEditor(page, ref) - await openEntityContextMenu(page, viewName) + await openEntityContextMenu(page, fixture.viewName) await page.getByRole('menuitem', { name: 'Delete view' }).click() await expect( - page.getByRole('heading', { name: `Confirm deletion of view "${viewName}"` }), + page.getByRole('heading', { name: `Confirm deletion of view "${fixture.viewName}"` }), 'confirm dialog title should include the view name' ).toBeVisible({ timeout: 15000 }) @@ -172,46 +189,33 @@ testRunner('table editor — view context menu', () => { await expect .poll( async () => - await page.getByRole('button', { name: `View ${viewName}`, exact: true }).count(), + await page.getByRole('button', { name: `View ${fixture.viewName}`, exact: true }).count(), { message: 'view should be removed from the sidebar after delete' } ) .toBe(0) }) }) -testRunner('table editor — materialized view context menu', () => { - const baseTable = 'pw_mv_menu_base' - const mvName = 'pw_mv_menu_view' - - const setupMv = async () => { - await createTable(baseTable, 'note', [{ note: 'alpha' }, { note: 'beta' }]) - await createMaterializedView(mvName, `SELECT id, note FROM public.${baseTable}`) - } - - const cleanupMv = async () => { - await dropMaterializedView(mvName) - await dropTable(baseTable) - } - +test.describe('table editor — materialized view context menu', () => { test('copy name copies the materialized view name to clipboard', async ({ page, ref }) => { - await using _ = await withSetupCleanup(setupMv, cleanupMv) + await using fixture = await setupMaterializedViewFixture() await goToTableEditor(page, ref) - await openEntityContextMenu(page, mvName) + await openEntityContextMenu(page, fixture.mvName) await page.getByRole('menuitem', { name: 'Copy name' }).click() await expect(page.getByRole('menuitem', { name: 'Copy name' })).not.toBeVisible() - await expectClipboardValue({ page, value: mvName, exact: true }) + await expectClipboardValue({ page, value: fixture.mvName, exact: true }) }) test('copy materialized view definition copies CREATE statement to clipboard', async ({ page, ref, }) => { - await using _ = await withSetupCleanup(setupMv, cleanupMv) + await using fixture = await setupMaterializedViewFixture() await goToTableEditor(page, ref) - await openEntityContextMenu(page, mvName) + await openEntityContextMenu(page, fixture.mvName) const definitionWait = waitForApiResponse(page, 'pg-meta', ref, 'query?key=view-definition-') await page.getByRole('menuitem', { name: 'Copy materialized view definition' }).click() @@ -223,17 +227,17 @@ testRunner('table editor — materialized view context menu', () => { const clipboardText: string = await page.evaluate(() => navigator.clipboard.readText()) expect(clipboardText.toLowerCase()).toContain('create materialized view') - expect(clipboardText.toLowerCase()).toContain(mvName.toLowerCase()) + expect(clipboardText.toLowerCase()).toContain(fixture.mvName.toLowerCase()) }) test('export materialized view as CSV shows confirmation and downloads', async ({ page, ref, }) => { - await using _ = await withSetupCleanup(setupMv, cleanupMv) + await using fixture = await setupMaterializedViewFixture() await goToTableEditor(page, ref) - await openEntityContextMenu(page, mvName) + await openEntityContextMenu(page, fixture.mvName) const exportItem = page.getByRole('menuitem', { name: 'Export data' }) await expect(exportItem).toBeVisible() await exportItem.hover() @@ -256,15 +260,15 @@ testRunner('table editor — materialized view context menu', () => { page, ref, }) => { - await using _ = await withSetupCleanup(setupMv, cleanupMv) + await using fixture = await setupMaterializedViewFixture() await goToTableEditor(page, ref) - await openEntityContextMenu(page, mvName) + await openEntityContextMenu(page, fixture.mvName) await page.getByRole('menuitem', { name: 'Delete materialized view' }).click() await expect( page.getByRole('heading', { - name: `Confirm deletion of materialized view "${mvName}"`, + name: `Confirm deletion of materialized view "${fixture.mvName}"`, }) ).toBeVisible({ timeout: 15000 }) @@ -281,7 +285,8 @@ testRunner('table editor — materialized view context menu', () => { await expect .poll( - async () => await page.getByRole('button', { name: `View ${mvName}`, exact: true }).count() + async () => + await page.getByRole('button', { name: `View ${fixture.mvName}`, exact: true }).count() ) .toBe(0) }) From 149ec3e1b19949545f882e50b20cf07860656f1d Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Tue, 26 May 2026 08:08:51 -0600 Subject: [PATCH 4/7] updated type matching --- .../layouts/TableEditorLayout/EntityListItem.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index 393231c6deef2..dda636aadaac0 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -280,8 +280,12 @@ export const EntityListItem = ({ await copyToClipboard(formattedSchema, () => { toast.success('Table schema copied to clipboard', { id: toastId }) }) - } catch (err: any) { - toast.error('Failed to copy schema: ' + (err.message || err), { id: toastId }) + } catch (err: unknown) { + if (err instanceof Error) { + toast.error('Failed to copy schema: ' + (err.message || err), { + id: toastId, + }) + } } }} > From 6ffd927be4772c5a24a727ba901435d0fd274557 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Thu, 28 May 2026 09:28:05 -0600 Subject: [PATCH 5/7] updated text --- .../components/layouts/TableEditorLayout/EntityListItem.tsx | 6 +----- e2e/studio/features/table-editor-views.spec.ts | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index dda636aadaac0..13de0e6d6c109 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -332,11 +332,7 @@ export const EntityListItem = ({ }} > - - Copy{' '} - {entity.type === ENTITY_TYPE.MATERIALIZED_VIEW ? 'materialized view' : 'view'}{' '} - definition - + Copy definition )} diff --git a/e2e/studio/features/table-editor-views.spec.ts b/e2e/studio/features/table-editor-views.spec.ts index fccc785624b37..a2af47168f69d 100644 --- a/e2e/studio/features/table-editor-views.spec.ts +++ b/e2e/studio/features/table-editor-views.spec.ts @@ -218,7 +218,7 @@ test.describe('table editor — materialized view context menu', () => { await openEntityContextMenu(page, fixture.mvName) const definitionWait = waitForApiResponse(page, 'pg-meta', ref, 'query?key=view-definition-') - await page.getByRole('menuitem', { name: 'Copy materialized view definition' }).click() + await page.getByRole('menuitem', { name: 'Copy definition' }).click() await definitionWait await expect(page.getByText('Materialized view definition copied to clipboard')).toBeVisible({ From f8a9a7f480694c6fa3b869db8075cc2ff6b8e768 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Thu, 28 May 2026 09:40:47 -0600 Subject: [PATCH 6/7] updated spelling and style --- .../TableEditorLayout/EntityListItem.tsx | 28 +++++++++---------- .../features/table-editor-views.spec.ts | 2 +- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index 13de0e6d6c109..0cff02de1245d 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -244,7 +244,7 @@ export const EntityListItem = ({ onClick={(e) => e.preventDefault()} /> - + - + Copy name @@ -289,7 +289,7 @@ export const EntityListItem = ({ } }} > - + Copy table schema )} @@ -331,7 +331,7 @@ export const EntityListItem = ({ } }} > - + Copy definition )} @@ -348,7 +348,7 @@ export const EntityListItem = ({ snap.onEditTable() }} > - + Edit table - + Duplicate table @@ -367,14 +367,14 @@ export const EntityListItem = ({ key="view-policies" href={`/project/${projectRef}/auth/policies?schema=${encodeURIComponent(selectedSchema ?? '')}&search=${encodeURIComponent(String(entity.id))}`} > - + View policies - + Export data @@ -420,7 +420,7 @@ export const EntityListItem = ({ snap.onDeleteTable() }} > - + Delete table @@ -432,7 +432,7 @@ export const EntityListItem = ({ - + Export data @@ -468,7 +468,7 @@ export const EntityListItem = ({ snap.onDeleteView() }} > - + Delete view @@ -480,7 +480,7 @@ export const EntityListItem = ({ - + Export data @@ -516,8 +516,8 @@ export const EntityListItem = ({ snap.onDeleteMaterializedView() }} > - - Delete materialized view + + Delete view )} diff --git a/e2e/studio/features/table-editor-views.spec.ts b/e2e/studio/features/table-editor-views.spec.ts index a2af47168f69d..148bf1afa3278 100644 --- a/e2e/studio/features/table-editor-views.spec.ts +++ b/e2e/studio/features/table-editor-views.spec.ts @@ -264,7 +264,7 @@ test.describe('table editor — materialized view context menu', () => { await goToTableEditor(page, ref) await openEntityContextMenu(page, fixture.mvName) - await page.getByRole('menuitem', { name: 'Delete materialized view' }).click() + await page.getByRole('menuitem', { name: 'Delete view' }).click() await expect( page.getByRole('heading', { From 25b297984405651473607b565cd129cfbf062c68 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Thu, 28 May 2026 10:07:01 -0600 Subject: [PATCH 7/7] updated failing test --- e2e/studio/features/table-editor-views.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/studio/features/table-editor-views.spec.ts b/e2e/studio/features/table-editor-views.spec.ts index 148bf1afa3278..27e420ac467e1 100644 --- a/e2e/studio/features/table-editor-views.spec.ts +++ b/e2e/studio/features/table-editor-views.spec.ts @@ -103,7 +103,7 @@ test.describe('table editor — view context menu', () => { await openEntityContextMenu(page, fixture.viewName) const definitionWait = waitForApiResponse(page, 'pg-meta', ref, 'query?key=view-definition-') - await page.getByRole('menuitem', { name: 'Copy view definition' }).click() + await page.getByRole('menuitem', { name: 'Copy definition' }).click() await definitionWait await expect(