Files
supabase/e2e/studio/features/table-editor-views.spec.ts
Ali Waseem c39bb96d74 feat: Context view actions for views and material views (#46383)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Right now material views and views don't have any options on the context
menu, they only have a copy name. This adds copy schema, export CSV,
export SQL and delete table to that list

Added E2E tests to cover the use cases

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Delete views and materialized views via confirmation dialogs with
optional cascade
  * Copy SQL definitions for views and materialized views
* Export views and materialized views as CSV and SQL from the entity
menu
* Confirmation modals now show dependency warnings and cascade toggle
consistently

* **Tests**
* End-to-end tests covering copy, export, and delete flows for views and
materialized views in the table editor

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46383?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-28 16:19:27 +00:00

294 lines
10 KiB
TypeScript

import crypto from 'node:crypto'
import { expect, Page } from '@playwright/test'
import { expectClipboardValue } from '../utils/clipboard.js'
import {
createMaterializedView,
createTable,
createView,
dropMaterializedView,
dropTable,
dropView,
} from '../utils/db/queries.js'
import { test } 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
}
const uniqueSuffix = () => crypto.randomBytes(4).toString('hex')
/**
* 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<Record<string, string>> = [{ 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 setupMaterializedViewFixture = async (
rows: Array<Record<string, string>> = [{ 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 fixture = await setupViewFixture()
await goToTableEditor(page, ref)
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: fixture.viewName, exact: true })
})
test('copy view definition copies CREATE VIEW statement to clipboard', async ({ page, ref }) => {
await using fixture = await setupViewFixture()
await goToTableEditor(page, ref)
await openEntityContextMenu(page, fixture.viewName)
const definitionWait = waitForApiResponse(page, 'pg-meta', ref, 'query?key=view-definition-')
await page.getByRole('menuitem', { name: 'Copy 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(fixture.viewName.toLowerCase())
})
test('export view as CSV shows confirmation reason and downloads', async ({ page, ref }) => {
await using fixture = await setupViewFixture()
await goToTableEditor(page, ref)
await openEntityContextMenu(page, fixture.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 fixture = await setupViewFixture()
await goToTableEditor(page, ref)
await openEntityContextMenu(page, fixture.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 }) => {
await using fixture = await setupViewFixture([{ note: 'alpha' }])
await goToTableEditor(page, ref)
await openEntityContextMenu(page, fixture.viewName)
await page.getByRole('menuitem', { name: 'Delete view' }).click()
await expect(
page.getByRole('heading', { name: `Confirm deletion of view "${fixture.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 ${fixture.viewName}`, exact: true }).count(),
{ message: 'view should be removed from the sidebar after delete' }
)
.toBe(0)
})
})
test.describe('table editor — materialized view context menu', () => {
test('copy name copies the materialized view name to clipboard', async ({ page, ref }) => {
await using fixture = await setupMaterializedViewFixture()
await goToTableEditor(page, ref)
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: fixture.mvName, exact: true })
})
test('copy materialized view definition copies CREATE statement to clipboard', async ({
page,
ref,
}) => {
await using fixture = await setupMaterializedViewFixture()
await goToTableEditor(page, ref)
await openEntityContextMenu(page, fixture.mvName)
const definitionWait = waitForApiResponse(page, 'pg-meta', ref, 'query?key=view-definition-')
await page.getByRole('menuitem', { name: 'Copy 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(fixture.mvName.toLowerCase())
})
test('export materialized view as CSV shows confirmation and downloads', async ({
page,
ref,
}) => {
await using fixture = await setupMaterializedViewFixture()
await goToTableEditor(page, ref)
await openEntityContextMenu(page, fixture.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 fixture = await setupMaterializedViewFixture()
await goToTableEditor(page, ref)
await openEntityContextMenu(page, fixture.mvName)
await page.getByRole('menuitem', { name: 'Delete view' }).click()
await expect(
page.getByRole('heading', {
name: `Confirm deletion of materialized view "${fixture.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 ${fixture.mvName}`, exact: true }).count()
)
.toBe(0)
})
})