mirror of
https://github.com/supabase/supabase.git
synced 2026-05-06 22:18:00 +08:00
Mark provenance of SQL via the branded types SafeSqlFragment and UntrustedSqlFragment. Only SafeSqlFragment should be executed; UntrustedSqlFragments require some kind of implicit user approval (show on screen + user has to click something) before they are promoted to SafeSqlFragment. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Editor and RLS tester show loading states for inferred/generated SQL and include a dedicated user SQL editor for safer edits. * **Refactor** * Platform-wide SQL handling tightened: snippets and AI-generated SQL are treated as untrusted/display-only until promoted, improving safety and consistency. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1933 lines
75 KiB
TypeScript
1933 lines
75 KiB
TypeScript
import fs from 'fs'
|
|
import path from 'path'
|
|
import { expect, Page } from '@playwright/test'
|
|
|
|
import { env } from '../env.config.js'
|
|
import { expectClipboardValue } from '../utils/clipboard.js'
|
|
import { dropTable, query } from '../utils/db/index.js'
|
|
import { createTable, createTableWithRLS } from '../utils/db/queries.js'
|
|
import { resetLocalStorage } from '../utils/reset-local-storage.js'
|
|
import { test, withSetupCleanup } from '../utils/test.js'
|
|
import { toUrl } from '../utils/to-url.js'
|
|
import { waitForApiResponseWithTimeout } from '../utils/wait-for-response-with-timeout.js'
|
|
import {
|
|
createApiResponseWaiter,
|
|
waitForApiResponse,
|
|
waitForGridDataToLoad,
|
|
waitForTableToLoad,
|
|
} from '../utils/wait-for-response.js'
|
|
|
|
const deleteTable = async (page: Page, ref: string, tableName: string) => {
|
|
const viewLocator = page.getByLabel(`View ${tableName}`)
|
|
if ((await viewLocator.count()) === 0) return
|
|
await viewLocator.nth(0).click()
|
|
await viewLocator.locator('button[aria-haspopup="menu"]').click({ force: true })
|
|
await page.getByText('Delete table').click()
|
|
await page.getByRole('checkbox', { name: 'Drop table with cascade?' }).click()
|
|
const apiPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-delete-', {
|
|
method: 'POST',
|
|
})
|
|
const revalidatePromise = waitForApiResponse(page, 'pg-meta', ref, `query?key=entity-types-`)
|
|
await page.getByRole('button', { name: 'Delete' }).click()
|
|
await Promise.all([apiPromise, revalidatePromise])
|
|
await expect(page.getByTestId('confirm-delete-table-modal')).not.toBeVisible()
|
|
}
|
|
|
|
const deleteEnumIfExist = async (page: Page, ref: string, enumName: string) => {
|
|
const loadTypesPromise = waitForApiResponse(page, 'pg-meta', ref, `types`)
|
|
await page.goto(toUrl(`/project/${ref}/database/types?schema=public`))
|
|
await loadTypesPromise
|
|
expect(page.getByText('public').first()).toBeVisible()
|
|
|
|
// if enum (test) exists, delete it.
|
|
const exists = (await page.getByRole('cell', { name: enumName, exact: true }).count()) > 0
|
|
if (!exists) return
|
|
|
|
await page
|
|
.getByRole('row', { name: `public ${enumName}` })
|
|
.getByRole('button')
|
|
.click()
|
|
await page.getByRole('menuitem', { name: 'Delete type' }).click()
|
|
await page.getByRole('heading', { name: 'Confirm to delete enumerated' }).click()
|
|
const deleteEnumPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByRole('button', { name: 'Confirm delete' }).click()
|
|
await deleteEnumPromise
|
|
}
|
|
|
|
// Due to rate API rate limits run this test in serial mode on platform.
|
|
const testRunner = env.IS_PLATFORM ? test.describe.serial : test.describe
|
|
testRunner('table editor', () => {
|
|
test('sidebar actions works as expected', async ({ page, ref }) => {
|
|
const tableNameActions = 'pw_table_actions'
|
|
const tableNameActionsDuplicate = 'pw_table_actions_duplicate'
|
|
|
|
// create table + verify that this exists.
|
|
await using _ = await withSetupCleanup(
|
|
() => createTableWithRLS(tableNameActions, 'pw_column'),
|
|
async () => {
|
|
await dropTable(tableNameActions)
|
|
await dropTable(tableNameActionsDuplicate)
|
|
}
|
|
)
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
|
|
// copies table name to clipboard when copy table name is clicked
|
|
await page.getByRole('button', { name: `View ${tableNameActions}`, exact: true }).click()
|
|
await page
|
|
.getByRole('button', { name: `View ${tableNameActions}`, exact: true })
|
|
.getByRole('button')
|
|
.nth(2)
|
|
.click()
|
|
await page.getByRole('menuitem', { name: 'Copy name' }).click()
|
|
// Make sure the dropdown has closed otherwise it would make the other assertions unstable
|
|
await expect(page.getByRole('menuitem', { name: 'Copy name' })).not.toBeVisible()
|
|
|
|
await expectClipboardValue({
|
|
page,
|
|
value: 'pw_table_actions',
|
|
exact: true,
|
|
})
|
|
|
|
// copies table schema to clipboard when copy schema option is clicked
|
|
await page
|
|
.getByRole('button', { name: `View ${tableNameActions}`, exact: true })
|
|
.getByRole('button')
|
|
.nth(2)
|
|
.click()
|
|
await page.getByRole('menuitem', { name: 'Copy table schema' }).click()
|
|
await expect(page.getByRole('menuitem', { name: 'Copy table schema' })).not.toBeVisible()
|
|
await expectClipboardValue({
|
|
page,
|
|
value: `create table public.pw_table_actions (
|
|
id bigint generated by default as identity not null,
|
|
created_at timestamp with time zone null default now(),
|
|
pw_column text null,
|
|
constraint pw_table_actions_pkey primary key (id)
|
|
) TABLESPACE pg_default;`,
|
|
exact: true,
|
|
})
|
|
|
|
// duplicates table
|
|
await page
|
|
.getByRole('button', { name: `View ${tableNameActions}`, exact: true })
|
|
.getByRole('button')
|
|
.nth(2)
|
|
.click()
|
|
await page.getByRole('menuitem', { name: 'Duplicate table' }).click()
|
|
await expect(page.getByRole('menuitem', { name: 'Duplicate table' })).not.toBeVisible()
|
|
const duplicatePromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByRole('button', { name: 'Save' }).click()
|
|
await duplicatePromise // create duplicate table
|
|
await waitForTableToLoad(page, ref) // load tables
|
|
await expect(
|
|
page.getByLabel(`View ${tableNameActionsDuplicate}`, { exact: true })
|
|
).toBeVisible()
|
|
})
|
|
|
|
test('switching schemas work as expected', async ({ page, ref }) => {
|
|
const authTableSso = 'identities'
|
|
const authTableMfa = 'mfa_factors'
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
|
|
// change schema from public to auth
|
|
await page.getByTestId('schema-selector').click()
|
|
await page.getByPlaceholder('Find schema...').fill('auth')
|
|
|
|
// Set up the waiter BEFORE clicking to avoid race condition
|
|
const tableLoadPromise = waitForTableToLoad(page, ref, 'auth')
|
|
await page.getByRole('option', { name: 'auth' }).click()
|
|
await tableLoadPromise // wait for auth tables to load
|
|
|
|
await expect(page.getByLabel(`View ${authTableSso}`)).toBeVisible()
|
|
await expect(page.getByLabel(`View ${authTableMfa}`)).toBeVisible()
|
|
|
|
// Search is client-side filtering - no API call needed
|
|
await page.getByRole('textbox', { name: 'Search tables...' }).fill('mfa')
|
|
|
|
// Wait for the UI to update after search (allow debounce to complete)
|
|
await page.waitForTimeout(300)
|
|
|
|
await expect(page.getByLabel(`View ${authTableSso}`)).not.toBeVisible()
|
|
await expect(page.getByLabel(`View ${authTableMfa}`)).toBeVisible()
|
|
})
|
|
|
|
test('should show rls accordingly', async ({ page, ref }) => {
|
|
const tableNameRlsEnabled = 'pw_table_rls_enabled'
|
|
const tableNameRlsDisabled = 'pw_table_rls_disabled'
|
|
|
|
// create table with RLS enabled and verify
|
|
await using _ = await withSetupCleanup(
|
|
async () => {
|
|
await createTableWithRLS(tableNameRlsEnabled, 'pw_column')
|
|
await createTable(tableNameRlsDisabled, 'pw_column')
|
|
},
|
|
async () => {
|
|
await dropTable(tableNameRlsEnabled)
|
|
await dropTable(tableNameRlsDisabled)
|
|
}
|
|
)
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
|
|
await page.getByRole('button', { name: `View ${tableNameRlsEnabled}` }).click()
|
|
await expect(page.getByRole('link', { name: 'Add RLS policy' })).toBeVisible()
|
|
|
|
await page.getByRole('button', { name: `View ${tableNameRlsDisabled}` }).click()
|
|
await expect(page.getByRole('button', { name: 'RLS disabled' })).toBeVisible()
|
|
})
|
|
|
|
test('add enums and show enums on table', async ({ page, ref }) => {
|
|
const tableNameEnum = 'pw_table_enum'
|
|
const columnNameEnum = 'pw_column_enum'
|
|
const enum_name = 'pw_enum'
|
|
|
|
let shouldCleanup = true
|
|
await using _ = await withSetupCleanup(
|
|
async () => {
|
|
await query(`drop table if exists ${tableNameEnum};`)
|
|
await query(`drop type if exists ${enum_name};`)
|
|
},
|
|
async () => {
|
|
if (shouldCleanup) {
|
|
await query(`drop table if exists ${tableNameEnum};`)
|
|
await query(`drop type if exists ${enum_name};`)
|
|
}
|
|
}
|
|
)
|
|
await page.goto(toUrl(`/project/${ref}/database/types?schema=public`))
|
|
|
|
// create a new enum
|
|
await page.getByRole('button', { name: 'Create type' }).click()
|
|
await page.getByRole('button', { name: 'Add value' }).click()
|
|
await page.locator('input[name="values.0.value"]').fill('value1')
|
|
await page.locator('input[name="values.1.value"]').fill('value2')
|
|
await page.getByRole('textbox', { name: 'Name' }).fill(enum_name)
|
|
await page.getByRole('button', { name: 'Create type' }).click()
|
|
|
|
// verify enum is created
|
|
await expect(page.getByRole('cell', { name: enum_name, exact: true })).toBeVisible()
|
|
await expect(page.getByRole('cell', { name: 'value1, value2', exact: true })).toBeVisible()
|
|
|
|
// create a new table with new column for enums
|
|
const tableEditorEnumLoadWait = createApiResponseWaiter(
|
|
page,
|
|
'pg-meta',
|
|
ref,
|
|
'query?key=entity-types-public-'
|
|
)
|
|
await page.goto(toUrl(`/project/${ref}/editor`))
|
|
await tableEditorEnumLoadWait // load tables
|
|
|
|
await page.getByRole('button', { name: 'New table', exact: true }).click()
|
|
await page.getByTestId('table-name-input').fill(tableNameEnum)
|
|
await page.getByTestId('created_at-extra-options').click()
|
|
await page.getByText('Is Nullable').click()
|
|
await page.getByTestId('created_at-extra-options').click()
|
|
await page.getByRole('button', { name: 'Add column' }).click()
|
|
await page.getByLabel('Column name').nth(2).fill(columnNameEnum)
|
|
await page.getByRole('combobox').filter({ hasText: 'Choose a column type...' }).click()
|
|
await page.getByPlaceholder('Search types...').fill(enum_name)
|
|
// wait for response, then click
|
|
await page.getByRole('option', { name: enum_name }).click()
|
|
await page.getByRole('button', { name: 'Save' }).click()
|
|
|
|
await expect(
|
|
page.getByText(`Table ${tableNameEnum} is good to go!`),
|
|
'Success toast should be visible after table creation'
|
|
).toBeVisible({
|
|
timeout: 50000,
|
|
})
|
|
await expect(page.getByTestId('table-editor-side-panel')).not.toBeVisible()
|
|
// Wait for the grid to be visible and data to be loaded
|
|
await expect(page.getByRole('grid'), 'Grid should be visible after inserting data').toBeVisible(
|
|
{ timeout: 50_000 }
|
|
)
|
|
await expect(page.getByRole('columnheader', { name: enum_name })).toBeVisible()
|
|
|
|
// insert row with enum value
|
|
await page.getByTestId('table-editor-insert-new-row').click()
|
|
await page.getByText('Insert row').click()
|
|
await page.getByRole('combobox').click()
|
|
await page.getByRole('option', { name: 'value1' }).click()
|
|
await page.getByTestId('action-bar-save-row').click()
|
|
await expect(page.getByTestId('side-panel-row-editor')).not.toBeVisible()
|
|
await expect(page.getByRole('gridcell', { name: 'value1' })).toBeVisible()
|
|
|
|
// insert row with another enum value
|
|
await page.getByTestId('table-editor-insert-new-row').click()
|
|
await page.getByText('Insert row').click()
|
|
await page.getByRole('combobox').click()
|
|
await page.getByRole('option', { name: 'value2' }).click()
|
|
await page.getByTestId('action-bar-save-row').click()
|
|
await expect(page.getByRole('gridcell', { name: 'value2' })).toBeVisible({ timeout: 10_000 })
|
|
|
|
// delete enum and enum table
|
|
await deleteTable(page, ref, tableNameEnum)
|
|
await page.goto(toUrl(`/project/${ref}/database/types?schema=public`))
|
|
await deleteEnumIfExist(page, ref, enum_name)
|
|
|
|
// clear local storage, as it might result in some flakiness
|
|
await resetLocalStorage(page, ref)
|
|
shouldCleanup = false
|
|
})
|
|
|
|
test('Grid editor exporting works as expected', async ({ page, ref }) => {
|
|
const tableNameGridEditor = ' pw_table_grid_editor'
|
|
const tableNameUpdated = 'pw_table_updated'
|
|
const columnNameUpdated = 'pw_column_updated'
|
|
|
|
// create a new table
|
|
await using _ = await withSetupCleanup(
|
|
async () => {
|
|
await createTableWithRLS(tableNameGridEditor, 'pw_column')
|
|
},
|
|
async () => {
|
|
await dropTable(tableNameUpdated)
|
|
}
|
|
)
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
|
|
await page.getByRole('button', { name: `View ${tableNameGridEditor}`, exact: true }).click()
|
|
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
|
|
|
// create 3 rows
|
|
for (const value of ['789', '456', '123']) {
|
|
await page.getByTestId('table-editor-insert-new-row').click()
|
|
await page.getByRole('menuitem', { name: 'Insert row' }).click()
|
|
await page.getByTestId('pw_column-input').fill(value)
|
|
const insertPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByTestId('action-bar-save-row').click()
|
|
await insertPromise // insert rows
|
|
}
|
|
|
|
// verify row content
|
|
expect(await page.getByRole('gridcell').nth(3).textContent()).toBe('789')
|
|
expect(await page.getByRole('gridcell').nth(8).textContent()).toBe('456')
|
|
expect(await page.getByRole('gridcell').nth(13).textContent()).toBe('123')
|
|
|
|
// edit table (rename table, rename column name)
|
|
await page
|
|
.getByRole('button', { name: `View ${tableNameGridEditor}`, exact: true })
|
|
.getByRole('button')
|
|
.nth(2)
|
|
.click()
|
|
await page.getByRole('menuitem', { name: 'Edit table' }).click()
|
|
await page.getByTestId('table-name-input').fill(tableNameUpdated)
|
|
await page.getByLabel('Column name').nth(2).fill(columnNameUpdated)
|
|
const updateTablePromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=column-update', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByRole('button', { name: 'Save' }).click()
|
|
await updateTablePromise // update table
|
|
await waitForTableToLoad(page, ref) // load tables
|
|
await expect(page.getByLabel(`View ${tableNameUpdated}`, { exact: true })).toBeVisible()
|
|
await expect(page.getByLabel(`View ${tableNameGridEditor}`, { exact: true })).not.toBeVisible()
|
|
await expect(page.getByRole('columnheader', { name: columnNameUpdated })).toBeVisible()
|
|
await expect(
|
|
page.getByRole('columnheader', { name: 'pw_column', exact: true })
|
|
).not.toBeVisible()
|
|
|
|
// test export data via csv
|
|
await page
|
|
.getByRole('button', { name: `View ${tableNameUpdated}`, exact: true })
|
|
.getByRole('button')
|
|
.nth(2)
|
|
.click()
|
|
// Open nested export submenu via keyboard (more stable than hover in headless)
|
|
const exportDataItemCsv = page.getByRole('menuitem', { name: 'Export data' })
|
|
await expect(exportDataItemCsv).toBeVisible()
|
|
await exportDataItemCsv.hover()
|
|
await expect(exportDataItemCsv).toHaveAttribute('data-state', /open/)
|
|
await expect(page.getByRole('menuitem', { name: 'Export table as CSV' })).toBeVisible()
|
|
const [downloadCsv] = await Promise.all([
|
|
page.waitForEvent('download'),
|
|
page.getByRole('menuitem', { name: 'Export table as CSV' }).click(),
|
|
])
|
|
expect(downloadCsv.suggestedFilename()).toContain('.csv')
|
|
const downloadCsvPath = await downloadCsv.path()
|
|
|
|
const csvContent = fs.readFileSync(downloadCsvPath, 'utf-8').replace(/\r?\n/g, '\n')
|
|
const rows = csvContent.trim().split('\n')
|
|
const columnData = rows.map((row) => {
|
|
const columns = row.split(',')
|
|
return columns[2].trim()
|
|
})
|
|
const expectedColumnData = `${columnNameUpdated}, 123, 456, 789`
|
|
columnData.forEach((expectedValue) => {
|
|
expect(expectedColumnData).toContain(expectedValue)
|
|
})
|
|
fs.unlinkSync(downloadCsvPath)
|
|
|
|
// Close submenu and parent menu to avoid UI leftovers
|
|
await page.keyboard.press('Escape')
|
|
await page.keyboard.press('Escape')
|
|
await page.waitForTimeout(500)
|
|
|
|
// expect to NOT find the Export data menu item
|
|
await expect(page.getByRole('menuitem', { name: 'Export data' })).not.toBeVisible()
|
|
|
|
// test export data via SQL + verify
|
|
await page
|
|
.getByRole('button', { name: `View ${tableNameUpdated}`, exact: true })
|
|
.getByRole('button')
|
|
.nth(2)
|
|
.click()
|
|
// Open nested export submenu via keyboard (more stable than hover in headless)
|
|
const exportDataItemSql = page.getByRole('menuitem', { name: 'Export data' })
|
|
await expect(exportDataItemSql).toBeVisible()
|
|
await exportDataItemSql.hover({
|
|
force: true,
|
|
})
|
|
await expect(exportDataItemSql).toHaveAttribute('data-state', /open/)
|
|
await expect(page.getByRole('menuitem', { name: 'Export table as SQL' })).toBeVisible()
|
|
const [downloadSql] = await Promise.all([
|
|
page.waitForEvent('download'),
|
|
page.getByRole('menuitem', { name: 'Export table as SQL' }).click(),
|
|
])
|
|
expect(downloadSql.suggestedFilename()).toContain('.sql')
|
|
const downloadSqlPath = await downloadSql.path()
|
|
const sqlContent = fs.readFileSync(downloadSqlPath, 'utf-8')
|
|
expect(sqlContent).toContain(
|
|
`INSERT INTO "public"."${tableNameUpdated}" ("id", "created_at", "${columnNameUpdated}") VALUES`
|
|
)
|
|
expect(sqlContent).toContain('789')
|
|
expect(sqlContent).toContain('456')
|
|
expect(sqlContent).toContain('123')
|
|
fs.unlinkSync(downloadSqlPath)
|
|
|
|
// Close submenu and parent menu to avoid UI leftovers
|
|
await page.keyboard.press('Escape')
|
|
await page.keyboard.press('Escape')
|
|
await page.waitForTimeout(500)
|
|
|
|
// test export data via CLI
|
|
await page
|
|
.getByRole('button', { name: `View ${tableNameUpdated}`, exact: true })
|
|
.getByRole('button')
|
|
.nth(2)
|
|
.click()
|
|
|
|
const exportDataItemCli = page.getByRole('menuitem', { name: 'Export data' })
|
|
await expect(exportDataItemCli).toBeVisible()
|
|
await exportDataItemCli.hover({
|
|
force: true,
|
|
})
|
|
await expect(page.getByRole('menuitem', { name: 'Export table via CLI' })).toBeVisible()
|
|
await page.getByRole('menuitem', { name: 'Export table via CLI' }).click()
|
|
await expect(page.getByRole('heading', { name: 'Export table data via CLI' })).toBeVisible()
|
|
await page.getByRole('button', { name: 'Close' }).first().click()
|
|
})
|
|
|
|
test('view table definition works as expected', async ({ page, ref }) => {
|
|
const tableName = 'pw_table_definition'
|
|
const colName = 'pw_column'
|
|
|
|
await using _ = await withSetupCleanup(
|
|
async () => {
|
|
await createTableWithRLS(tableName, 'pw_column')
|
|
},
|
|
async () => {
|
|
await dropTable(tableName)
|
|
}
|
|
)
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
|
|
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
|
const apiPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-definition-')
|
|
await page.getByText('definition', { exact: true }).click()
|
|
await apiPromise
|
|
await expect(page.locator('.view-lines')).toContainText(
|
|
`create table public.${tableName} ( id bigint generated by default as identity not null, created_at timestamp with time zone null default now(), ${colName} text null, constraint ${tableName}_pkey primary key (id)) TABLESPACE pg_default;`
|
|
)
|
|
})
|
|
|
|
test('view definition preserves security_invoker for security invoker views', async ({
|
|
page,
|
|
ref,
|
|
}) => {
|
|
const tableName = `pw_view_def_source_${test.info().parallelIndex}`
|
|
const viewName = `pw_view_def_invoker_${test.info().parallelIndex}`
|
|
|
|
await using _ = await withSetupCleanup(
|
|
async () => {
|
|
await query(`drop view if exists public.${viewName};`)
|
|
await dropTable(tableName)
|
|
|
|
await query(`
|
|
create table public.${tableName} (
|
|
id bigint generated by default as identity primary key,
|
|
secret text
|
|
);
|
|
|
|
alter table public.${tableName} enable row level security;
|
|
|
|
create policy "${tableName}_anon_denied"
|
|
on public.${tableName}
|
|
for select
|
|
to anon
|
|
using (false);
|
|
|
|
create view public.${viewName} with (security_invoker = true) as
|
|
select id, secret
|
|
from public.${tableName};
|
|
`)
|
|
},
|
|
async () => {
|
|
await query(`drop view if exists public.${viewName};`)
|
|
await dropTable(tableName)
|
|
}
|
|
)
|
|
|
|
const tableLoadWait = createApiResponseWaiter(
|
|
page,
|
|
'pg-meta',
|
|
ref,
|
|
'query?key=entity-types-public-'
|
|
)
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
await tableLoadWait
|
|
|
|
await page.getByRole('button', { name: `View ${viewName}`, exact: true }).click()
|
|
|
|
const definitionWait = waitForApiResponse(page, 'pg-meta', ref, 'query?key=view-definition-')
|
|
await page.getByText('definition', { exact: true }).click()
|
|
await definitionWait
|
|
|
|
await expect(page.locator('.view-lines')).toContainText(`create view public.${viewName}`)
|
|
await expect(page.locator('.view-lines')).toContainText(`security_invoker = true`)
|
|
|
|
const openInSqlEditorLink = page.getByRole('link', { name: 'Open in SQL Editor' })
|
|
await expect(openInSqlEditorLink).toHaveAttribute('href', /security_invoker%20%3D%20true/)
|
|
await openInSqlEditorLink.click()
|
|
await page.waitForURL(/\/sql\/new/)
|
|
await expect(page.locator('.view-lines')).toContainText(`create view public.${viewName}`)
|
|
await expect(page.locator('.view-lines')).toContainText(`security_invoker = true`)
|
|
})
|
|
|
|
test('sorting rows works as expected', async ({ page, ref }) => {
|
|
const tableName = 'pw_table_sorting'
|
|
const colName = 'pw_column'
|
|
|
|
await using _ = await withSetupCleanup(
|
|
async () => {
|
|
await createTableWithRLS(tableName, 'pw_column')
|
|
},
|
|
async () => {
|
|
await dropTable(tableName)
|
|
}
|
|
)
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
|
|
|
for (const value of ['789', '456', '123']) {
|
|
await page.getByTestId('table-editor-insert-new-row').click()
|
|
await page.getByRole('menuitem', { name: 'Insert row' }).click()
|
|
await page.getByTestId(`${colName}-input`).fill(value)
|
|
const insertPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByTestId('action-bar-save-row').click()
|
|
await insertPromise
|
|
}
|
|
|
|
// Apply sorting
|
|
await page.getByRole('button', { name: 'Sort', exact: true }).click()
|
|
await page.getByRole('button', { name: 'Pick a column to sort by' }).click()
|
|
await page.getByRole('menuitem', { name: colName }).click()
|
|
const waitForSortingApply = createApiResponseWaiter(
|
|
page,
|
|
'pg-meta',
|
|
ref,
|
|
'query?key=table-rows-'
|
|
)
|
|
await page.getByRole('button', { name: 'Apply sorting' }).click()
|
|
await waitForSortingApply
|
|
await page.getByRole('button', { name: 'Sorted by 1 rule' }).click()
|
|
|
|
// Verify sorted row content asc lexicographically for strings
|
|
await page.waitForTimeout(500)
|
|
expect(await page.getByRole('gridcell').nth(3).textContent()).toBe('123')
|
|
expect(await page.getByRole('gridcell').nth(8).textContent()).toBe('456')
|
|
expect(await page.getByRole('gridcell').nth(13).textContent()).toBe('789')
|
|
})
|
|
|
|
test('column actions works as expected', async ({ page, ref }) => {
|
|
const tableName = 'pw_table_column_menu'
|
|
const colName = 'pw_column'
|
|
|
|
// Create a small table and three rows
|
|
await using _ = await withSetupCleanup(
|
|
async () => {
|
|
await createTableWithRLS(tableName, 'pw_column')
|
|
},
|
|
async () => {
|
|
await dropTable(tableName)
|
|
}
|
|
)
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
|
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
|
|
|
// Copy the column name
|
|
await page
|
|
.getByRole('columnheader', { name: colName })
|
|
.getByRole('button', { name: `Column ${colName} actions` })
|
|
.click()
|
|
await page.getByRole('menuitem', { name: 'Copy name' }).click()
|
|
|
|
await expectClipboardValue({
|
|
page,
|
|
value: colName,
|
|
exact: true,
|
|
})
|
|
})
|
|
|
|
test('importing, pagination and large data actions works as expected', async ({ page, ref }) => {
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
const tableNameDataActions = 'pw_table_data'
|
|
|
|
// create table
|
|
await using _ = await withSetupCleanup(
|
|
async () => {
|
|
await createTableWithRLS(tableNameDataActions, 'pw_column')
|
|
},
|
|
async () => {
|
|
await dropTable(tableNameDataActions)
|
|
}
|
|
)
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
await page.getByRole('button', { name: `View ${tableNameDataActions}`, exact: true }).click()
|
|
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
|
|
|
// importing 50 data via csv file
|
|
const csvFilePath = path.join(import.meta.dirname, 'files', 'table-editor-import-file.csv')
|
|
await page.getByRole('button', { name: 'Import data from CSV' }).click()
|
|
await page.getByRole('tab', { name: 'Upload CSV' }).click()
|
|
await page.setInputFiles('input[type="file"]', csvFilePath)
|
|
await expect(page.getByText('A total of 50 rows will be')).toBeVisible()
|
|
const waitForCsvInsert = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByRole('button', { name: 'Import data' }).click()
|
|
await waitForCsvInsert // insert data
|
|
await waitForGridDataToLoad(page, ref) // retrieve updated data
|
|
await expect(page.getByText('50 records')).toBeVisible()
|
|
|
|
// importing 51 data via paste text
|
|
const filePath = path.join(import.meta.dirname, 'files', 'table-editor-import-paste.txt')
|
|
const fileContent = fs.readFileSync(filePath, 'utf-8')
|
|
await page.getByTestId('table-editor-insert-new-row').click()
|
|
await page.getByRole('menuitem', { name: 'Import data from CSV' }).click()
|
|
await page.getByRole('tab', { name: 'Paste text' }).click()
|
|
await page.getByRole('textbox').fill(fileContent)
|
|
await expect(page.getByText('A total of 51 rows will be')).toBeVisible()
|
|
const waitForPasteInsert = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByRole('button', { name: 'Import data' }).click()
|
|
await waitForPasteInsert // insert data
|
|
await waitForGridDataToLoad(page, ref) // retrieve updated data
|
|
await expect(page.getByText('101 records')).toBeVisible()
|
|
|
|
// test pagination (page 1 -> page 2)
|
|
await expect(page.getByRole('gridcell', { name: 'value 7', exact: true })).toBeVisible()
|
|
await expect(page.getByRole('gridcell', { name: 'value 101', exact: true })).not.toBeVisible()
|
|
const waitForPageChange = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=table-rows-')
|
|
await page.getByLabel('Table grid footer').getByRole('button').nth(1).click()
|
|
await waitForPageChange // retrieve next page data
|
|
await expect(page.getByRole('gridcell', { name: 'value 7', exact: true })).not.toBeVisible()
|
|
await expect(page.getByRole('gridcell', { name: 'value 101', exact: true })).toBeVisible()
|
|
|
|
// change pagination size (100 -> 500)
|
|
await page.getByRole('button', { name: '100 rows' }).click()
|
|
const waitForPaginationChange = createApiResponseWaiter(
|
|
page,
|
|
'pg-meta',
|
|
ref,
|
|
'query?key=table-rows-'
|
|
)
|
|
await page.getByRole('menuitem', { name: '500 rows' }).click()
|
|
await waitForPaginationChange // retrieve updated pagination size data
|
|
await expect(page.getByRole('gridcell', { name: 'value 7', exact: true })).toBeVisible()
|
|
await page.getByRole('grid').evaluate((element) => {
|
|
element.scrollTop = element.scrollHeight
|
|
}) // scroll to bottom
|
|
await expect(page.getByRole('gridcell', { name: 'value 101', exact: true })).toBeVisible()
|
|
|
|
// remove selected rows when multiple rows action is selected
|
|
await page.getByRole('grid').evaluate((element) => {
|
|
element.scrollTop = 0
|
|
}) // scroll to top
|
|
await page.getByRole('row', { name: 'value 1 to delete' }).getByRole('checkbox').click()
|
|
await page.getByRole('row', { name: 'value 2 to delete' }).getByRole('checkbox').click()
|
|
await page.getByRole('row', { name: 'value 3 to delete' }).getByRole('checkbox').click()
|
|
await page.getByRole('button', { name: 'Delete 3 rows' }).click()
|
|
await expect(page.getByText('delete the selected 3 rows')).toBeVisible()
|
|
const waitForDeleteRows = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByRole('button', { name: 'Delete' }).click()
|
|
await waitForDeleteRows // delete selected rows
|
|
await waitForGridDataToLoad(page, ref) // retrieve row data
|
|
|
|
// export selected rows when multiple rows action is selected
|
|
await page.getByRole('row', { name: 'value 4 to export' }).getByRole('checkbox').click()
|
|
await page.getByRole('row', { name: 'value 5 to export' }).getByRole('checkbox').click()
|
|
await page.getByRole('row', { name: 'value 6 to export' }).getByRole('checkbox').click()
|
|
|
|
await page.getByRole('button', { name: 'Export' }).click()
|
|
const [downloadSql] = await Promise.all([
|
|
page.waitForEvent('download'),
|
|
page.getByRole('menuitem', { name: 'Export as SQL' }).click(),
|
|
])
|
|
expect(downloadSql.suggestedFilename()).toContain('.sql')
|
|
const downloadSqlPath = await downloadSql.path()
|
|
const sqlContent = fs.readFileSync(downloadSqlPath, 'utf-8')
|
|
expect(sqlContent).toBe(
|
|
`INSERT INTO "public"."${tableNameDataActions}" ("id", "created_at", "pw_column") VALUES (4, '2025-01-01 12:00:00+00', 'value 4 to export'), (5, '2025-01-01 12:00:00+00', 'value 5 to export'), (6, '2025-01-01 12:00:00+00', 'value 6 to export');`
|
|
)
|
|
await page.waitForTimeout(1000) // wait for event processing to complete
|
|
fs.unlinkSync(downloadSqlPath)
|
|
|
|
// Close menu to prevent overlap with next export
|
|
await page.keyboard.press('Escape')
|
|
await page.keyboard.press('Escape')
|
|
await page.waitForTimeout(500)
|
|
|
|
await page.getByRole('button', { name: 'Export' }).click()
|
|
const [downloadJson] = await Promise.all([
|
|
page.waitForEvent('download'),
|
|
page.getByRole('menuitem', { name: 'Export as JSON' }).click(),
|
|
])
|
|
expect(downloadJson.suggestedFilename()).toContain('.json')
|
|
const downloadJsonPath = await downloadJson.path()
|
|
const jsonContent = fs.readFileSync(downloadJsonPath, 'utf-8')
|
|
expect(jsonContent).toBe(
|
|
`[{"id":4,"created_at":"2025-01-01 12:00:00+00","pw_column":"value 4 to export"},{"id":5,"created_at":"2025-01-01 12:00:00+00","pw_column":"value 5 to export"},{"id":6,"created_at":"2025-01-01 12:00:00+00","pw_column":"value 6 to export"}]`
|
|
)
|
|
await page.waitForTimeout(1000) // wait for event processing to complete
|
|
fs.unlinkSync(downloadJsonPath)
|
|
|
|
// Close menu to prevent overlap with next export
|
|
await page.keyboard.press('Escape')
|
|
await page.keyboard.press('Escape')
|
|
await page.waitForTimeout(500)
|
|
|
|
await page.getByRole('button', { name: 'Export' }).click()
|
|
const [downloadCsv] = await Promise.all([
|
|
page.waitForEvent('download'),
|
|
page.getByRole('menuitem', { name: 'Export as CSV' }).click(),
|
|
])
|
|
expect(downloadCsv.suggestedFilename()).toContain('.csv')
|
|
const downloadCsvPath = await downloadCsv.path()
|
|
const csvContent = fs.readFileSync(downloadCsvPath, 'utf-8').replace(/\r?\n/g, '\n')
|
|
const rows = csvContent.trim().split('\n')
|
|
const defaultColumnValues = rows.map((row) => {
|
|
const columns = row.split(',')
|
|
return columns[2].trim()
|
|
})
|
|
const expectedDefaultColumnValues = [
|
|
'pw_column',
|
|
'value 4 to export',
|
|
'value 5 to export',
|
|
'value 6 to export',
|
|
]
|
|
defaultColumnValues.forEach((expectedValue) => {
|
|
expect(expectedDefaultColumnValues).toContain(expectedValue)
|
|
})
|
|
await page.waitForTimeout(1000) // wait for event processing to complete
|
|
fs.unlinkSync(downloadCsvPath)
|
|
|
|
// Close menu to avoid leaving it open
|
|
await page.keyboard.press('Escape')
|
|
await page.keyboard.press('Escape')
|
|
await page.waitForTimeout(500)
|
|
|
|
// select all actions works (delete action)
|
|
await page.getByRole('checkbox', { name: 'Select All' }).click()
|
|
await page.getByRole('button', { name: 'Delete 98 rows' }).click()
|
|
const waitForDeleteAllRows = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByRole('button', { name: 'Delete' }).click()
|
|
await expect(page.getByText('delete the selected 98 rows')).toBeVisible()
|
|
await waitForDeleteAllRows // delete all rows
|
|
await waitForGridDataToLoad(page, ref) // retrieve rows data
|
|
await expect(page.getByRole('gridcell', { name: 'value 7' })).not.toBeVisible()
|
|
await expect(page.getByRole('gridcell', { name: 'value 101' })).not.toBeVisible()
|
|
})
|
|
|
|
test('copying cell values from first and second row works', async ({ page, ref }) => {
|
|
const tableName = 'pw_table_copy_rows'
|
|
const colName = 'pw_column'
|
|
|
|
// Create table and add two rows
|
|
await using _ = await withSetupCleanup(
|
|
async () => {
|
|
await createTableWithRLS(tableName, 'pw_column')
|
|
},
|
|
async () => {
|
|
await dropTable(tableName)
|
|
}
|
|
)
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
|
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
|
|
|
// Insert first row with value 'first_row_value'
|
|
await page.getByTestId('table-editor-insert-new-row').click()
|
|
await page.getByRole('menuitem', { name: 'Insert row' }).click()
|
|
await page.getByTestId(`${colName}-input`).fill('first_row_value')
|
|
const insertFirstPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByTestId('action-bar-save-row').click()
|
|
await insertFirstPromise
|
|
|
|
// Insert second row with value 'second_row_value'
|
|
await page.getByTestId('table-editor-insert-new-row').click()
|
|
await page.getByRole('menuitem', { name: 'Insert row' }).click()
|
|
await page.getByTestId(`${colName}-input`).fill('second_row_value')
|
|
const insertSecondPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByTestId('action-bar-save-row').click()
|
|
await insertSecondPromise
|
|
|
|
// Wait for grid to be visible
|
|
await expect(page.getByRole('grid')).toBeVisible()
|
|
|
|
// Right-click on the first row's cell to open context menu
|
|
const firstRowCell = page.getByRole('gridcell', { name: 'first_row_value' })
|
|
await expect(firstRowCell).toBeVisible()
|
|
await firstRowCell.click({ button: 'right' })
|
|
|
|
// Click "Copy cell" from context menu
|
|
await page.getByRole('menuitem', { name: 'Copy cell' }).click()
|
|
|
|
// Verify first row value was copied
|
|
await expectClipboardValue({
|
|
page,
|
|
value: 'first_row_value',
|
|
exact: true,
|
|
})
|
|
|
|
// Right-click on the second row's cell to open context menu
|
|
const secondRowCell = page.getByRole('gridcell', { name: 'second_row_value' })
|
|
await expect(secondRowCell).toBeVisible()
|
|
await secondRowCell.click({ button: 'right' })
|
|
|
|
// Click "Copy cell" from context menu
|
|
await page.getByRole('menuitem', { name: 'Copy cell' }).click()
|
|
|
|
// Verify second row value was copied
|
|
await expectClipboardValue({
|
|
page,
|
|
value: 'second_row_value',
|
|
exact: true,
|
|
})
|
|
})
|
|
|
|
test('boolean fields can be edited correctly', async ({ page, ref }) => {
|
|
const tableName = 'pw_table_boolean_edits'
|
|
const boolColName = 'is_active'
|
|
|
|
await using _ = await withSetupCleanup(
|
|
async () => {
|
|
await createTableWithRLS(tableName, 'pw_column')
|
|
},
|
|
async () => {
|
|
await dropTable(tableName)
|
|
}
|
|
)
|
|
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
|
await page
|
|
.getByRole('button', { name: `View ${tableName}`, exact: true })
|
|
.getByRole('button')
|
|
.nth(2)
|
|
.click()
|
|
await page.getByRole('menuitem', { name: 'Edit table' }).click()
|
|
|
|
// Add boolean column
|
|
await page.getByRole('button', { name: 'Add column' }).click()
|
|
await page.getByLabel('Column name').nth(3).fill(boolColName)
|
|
await page.getByText('Choose a column type...').click()
|
|
await page.getByPlaceholder('Search types...').fill('bool')
|
|
await page.getByRole('option', { name: 'bool' }).first().click()
|
|
|
|
await page.getByRole('button', { name: 'Save' }).click()
|
|
await expect(
|
|
page.getByText(`Successfully updated ${tableName}!`),
|
|
'Success toast should be visible after table update'
|
|
).toBeVisible({ timeout: 50000 })
|
|
|
|
// Navigate to the table
|
|
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
|
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
|
|
|
// Insert a row with TRUE value via side panel
|
|
await page.getByTestId('table-editor-insert-new-row').click()
|
|
await page.getByRole('menuitem', { name: 'Insert row' }).click()
|
|
await page.getByRole('combobox').click()
|
|
await page.getByRole('option', { name: 'TRUE' }).click()
|
|
const insertTruePromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByTestId('action-bar-save-row').click()
|
|
await insertTruePromise
|
|
|
|
await expect(
|
|
page.getByRole('gridcell', { name: 'TRUE' }),
|
|
'TRUE value should be displayed'
|
|
).toBeVisible()
|
|
|
|
// Insert a row with FALSE value via side panel
|
|
await page.getByTestId('table-editor-insert-new-row').click()
|
|
await page.getByRole('menuitem', { name: 'Insert row' }).click()
|
|
await page.getByRole('combobox').click()
|
|
await page.getByRole('option', { name: 'FALSE' }).click()
|
|
const insertFalsePromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByTestId('action-bar-save-row').click()
|
|
await insertFalsePromise
|
|
|
|
// Verify FALSE value is preserved
|
|
await expect(
|
|
page.getByRole('gridcell', { name: 'FALSE' }),
|
|
'FALSE value should be displayed and preserved'
|
|
).toBeVisible()
|
|
|
|
// Edit the FALSE value to TRUE using inline editor
|
|
const falseCell = page.getByRole('gridcell', { name: 'FALSE' }).first()
|
|
await falseCell.dblclick()
|
|
|
|
// Wait for boolean editor dropdown to appear
|
|
const booleanEditor = page.locator('#boolean-editor')
|
|
await expect(booleanEditor, 'Boolean editor should be visible').toBeVisible()
|
|
|
|
// Change from false to true
|
|
const updateTrueResponse = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await booleanEditor.click()
|
|
await page.getByRole('option', { name: 'true' }).click()
|
|
await page.getByRole('columnheader', { name: 'id' }).click()
|
|
await updateTrueResponse
|
|
|
|
// Verify the value changed to TRUE (now there should be 2 TRUE values in the table)
|
|
await expect(
|
|
page.getByRole('gridcell', { name: 'TRUE' }),
|
|
'Value should change to TRUE after inline edit'
|
|
).toHaveCount(2)
|
|
|
|
// Edit TRUE value back to FALSE using inline editor
|
|
// Use the second TRUE cell (the one we just edited from FALSE to TRUE)
|
|
const trueCell = page.getByRole('gridcell', { name: 'TRUE' }).nth(1)
|
|
await trueCell.dblclick()
|
|
|
|
await expect(booleanEditor, 'Boolean editor should be visible for second edit').toBeVisible()
|
|
const updateFalseResponse = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await booleanEditor.click()
|
|
await page.getByRole('option', { name: 'false' }).click()
|
|
await page.getByRole('columnheader', { name: 'id' }).click()
|
|
await updateFalseResponse
|
|
|
|
// Verify FALSE value is preserved and not converted to NULL (this is the critical regression test)
|
|
const falseCells = page.getByRole('gridcell', { name: 'FALSE' })
|
|
await expect(
|
|
falseCells.first(),
|
|
'FALSE value should be preserved and not become NULL after inline edit'
|
|
).toBeVisible()
|
|
})
|
|
|
|
test('nullable boolean fields support NULL values', async ({ page, ref }) => {
|
|
const tableName = 'pw_table_boolean_nullable'
|
|
const boolColName = 'is_enabled'
|
|
|
|
await using _ = await withSetupCleanup(
|
|
async () => {
|
|
await createTableWithRLS(tableName, 'pw_column')
|
|
},
|
|
async () => {
|
|
await dropTable(tableName)
|
|
}
|
|
)
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
|
await page
|
|
.getByRole('button', { name: `View ${tableName}`, exact: true })
|
|
.getByRole('button')
|
|
.nth(2)
|
|
.click()
|
|
await page.getByRole('menuitem', { name: 'Edit table' }).click()
|
|
|
|
// Add nullable boolean column
|
|
await page.getByRole('button', { name: 'Add column' }).click()
|
|
await page.getByLabel('Column name').nth(3).fill(boolColName)
|
|
await page.getByText('Choose a column type...').click()
|
|
await page.getByPlaceholder('Search types...').fill('bool')
|
|
await page.getByRole('option', { name: 'bool' }).first().click()
|
|
|
|
await page.getByRole('button', { name: 'Save' }).click()
|
|
await expect(
|
|
page.getByText(`Successfully updated ${tableName}!`),
|
|
'Success toast should be visible after table update'
|
|
).toBeVisible({ timeout: 50000 })
|
|
|
|
await expect(
|
|
page.getByRole('button', { name: `View ${tableName}`, exact: true }),
|
|
'Table should be visible after update'
|
|
).toBeVisible()
|
|
|
|
// Navigate to the table
|
|
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
|
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
|
|
|
// Insert a row with TRUE value
|
|
await page.getByTestId('table-editor-insert-new-row').click()
|
|
await page.getByRole('menuitem', { name: 'Insert row' }).click()
|
|
await page.getByRole('combobox').click()
|
|
await page.getByRole('option', { name: 'TRUE' }).click()
|
|
const insertTruePromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByTestId('action-bar-save-row').click()
|
|
await insertTruePromise
|
|
|
|
await expect(
|
|
page.getByRole('gridcell', { name: 'TRUE' }),
|
|
'TRUE value should be displayed'
|
|
).toBeVisible()
|
|
|
|
// Insert a row with FALSE value
|
|
await page.getByTestId('table-editor-insert-new-row').click()
|
|
await page.getByRole('menuitem', { name: 'Insert row' }).click()
|
|
await page.getByRole('combobox').click()
|
|
await page.getByRole('option', { name: 'FALSE' }).click()
|
|
const insertFalsePromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByTestId('action-bar-save-row').click()
|
|
await insertFalsePromise
|
|
|
|
await expect(
|
|
page.getByRole('gridcell', { name: 'FALSE' }),
|
|
'FALSE value should be displayed'
|
|
).toBeVisible()
|
|
|
|
// Edit FALSE to NULL using inline editor
|
|
const falseCellToNull = page.getByRole('gridcell', { name: 'FALSE' })
|
|
await falseCellToNull.dblclick()
|
|
|
|
const booleanEditor = page.locator('#boolean-editor')
|
|
await expect(booleanEditor, 'Boolean editor should be visible').toBeVisible()
|
|
|
|
const updateNullResponse = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await booleanEditor.click()
|
|
await page.getByRole('option', { name: 'null' }).click()
|
|
await page.getByRole('columnheader', { name: 'id' }).click()
|
|
await updateNullResponse
|
|
|
|
// Verify value changed to NULL on the second row
|
|
const nullCells = page.getByRole('gridcell', { name: 'NULL' }).nth(2)
|
|
await expect(nullCells, 'FALSE should change to NULL after inline edit').toBeVisible()
|
|
|
|
// Edit NULL to FALSE using inline editor
|
|
const nullCellToFalse = page.getByRole('gridcell', { name: 'NULL' }).nth(2)
|
|
await nullCellToFalse.dblclick()
|
|
|
|
const updateFalseResponse = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await booleanEditor.click()
|
|
await page.getByRole('option', { name: 'false' }).click()
|
|
await page.getByRole('columnheader', { name: 'id' }).click()
|
|
await updateFalseResponse
|
|
|
|
await expect(
|
|
page.getByRole('gridcell', { name: 'FALSE' }),
|
|
'NULL should change to FALSE after inline edit'
|
|
).toBeVisible()
|
|
})
|
|
|
|
test('can create and remove foreign key with column selection', async ({ page, ref }) => {
|
|
const sourceTableName = 'pw_table_fk_source'
|
|
const targetTableName = 'pw_table_fk_target'
|
|
|
|
await using _ = await withSetupCleanup(
|
|
async () => {
|
|
await createTableWithRLS(targetTableName, 'pw_column')
|
|
await createTableWithRLS(sourceTableName, 'pw_column')
|
|
},
|
|
async () => {
|
|
await dropTable(sourceTableName)
|
|
await dropTable(targetTableName)
|
|
}
|
|
)
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
|
|
await page.getByRole('button', { name: `View ${sourceTableName}`, exact: true }).click()
|
|
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
|
|
|
// Open edit table dialog
|
|
await page
|
|
.getByRole('button', { name: `View ${sourceTableName}`, exact: true })
|
|
.locator('button[aria-haspopup="menu"]')
|
|
.click()
|
|
await page.getByRole('menuitem', { name: 'Edit table' }).click()
|
|
|
|
// Select target table
|
|
const tableQueryPromise = waitForApiResponseWithTimeout(page, (response) =>
|
|
response.url().includes(`table-public-${targetTableName}`)
|
|
)
|
|
|
|
// Open foreign key selector
|
|
await page.getByRole('button', { name: 'Add foreign key relation' }).click()
|
|
|
|
// Select schema (should default to public)
|
|
await expect(page.getByRole('combobox', { name: 'Select a schema' })).toContainText('public')
|
|
|
|
await page.getByRole('combobox', { name: 'Select a table to reference to' }).click()
|
|
await page.getByRole('option', { name: `public ${targetTableName}` }).click()
|
|
|
|
// Wait for table columns to load
|
|
await tableQueryPromise
|
|
|
|
// Verify column selection UI appears
|
|
await expect(
|
|
page.getByText(`Select columns from public.${targetTableName} to reference to`)
|
|
).toBeVisible()
|
|
|
|
// Select source column (id from source table)
|
|
await page.getByRole('combobox', { name: 'Column from public.pw_table_fk_source' }).click()
|
|
await page.getByRole('option', { name: 'id int8' }).click()
|
|
|
|
// Select target column (id from target table)
|
|
await page.getByRole('combobox', { name: 'Column from public.pw_table_fk_target' }).click()
|
|
await page.getByRole('option', { name: 'id int8' }).click()
|
|
|
|
// Verify cascade action options are visible
|
|
await expect(page.getByText('Action if referenced row is updated')).toBeVisible()
|
|
await expect(page.getByText('Action if referenced row is removed')).toBeVisible()
|
|
|
|
// Verify save button is now enabled
|
|
const saveButton = page.getByRole('button', { name: 'Save' }).last()
|
|
await expect(saveButton).toBeEnabled()
|
|
|
|
// Save the foreign key
|
|
const fkCreatePromise = waitForApiResponseWithTimeout(page, (response) =>
|
|
response.url().includes('query?key=')
|
|
)
|
|
await saveButton.click()
|
|
await fkCreatePromise
|
|
|
|
// Verify foreign key selector closed
|
|
await expect(
|
|
page.getByRole('banner', { name: `Add foreign key relationship to ${sourceTableName}` })
|
|
).not.toBeVisible()
|
|
|
|
// Save table changes
|
|
const saveTablePromise = waitForApiResponseWithTimeout(
|
|
page,
|
|
(response) => response.url().includes('query?key=table-update'),
|
|
15000
|
|
)
|
|
await page.getByRole('button', { name: 'Save' }).first().click()
|
|
await saveTablePromise
|
|
|
|
// Wait for table editor side panel to close
|
|
await expect(page.getByTestId('table-editor-side-panel')).not.toBeVisible()
|
|
|
|
// Verify foreign key was created by opening edit table dialog again
|
|
await page
|
|
.getByRole('button', { name: `View ${sourceTableName}`, exact: true })
|
|
.locator('button[aria-haspopup="menu"]')
|
|
.click()
|
|
await page.getByRole('menuitem', { name: 'Edit table' }).click()
|
|
|
|
// Scroll down to see foreign key relations section
|
|
await page.getByRole('heading', { name: 'Foreign keys' }).scrollIntoViewIfNeeded()
|
|
|
|
// Verify foreign key relation exists
|
|
await expect(page.getByRole('link', { name: 'public.pw_table_fk_target' })).toBeVisible()
|
|
|
|
// Remove the foreign key relation
|
|
await page.getByRole('button', { name: 'Remove' }).click()
|
|
|
|
// Save the table changes after removing foreign key
|
|
const removeFkPromise = waitForApiResponseWithTimeout(
|
|
page,
|
|
(response) => response.url().includes('query?key=table-update'),
|
|
15000
|
|
)
|
|
await page.getByRole('button', { name: 'Save' }).first().click()
|
|
await removeFkPromise
|
|
|
|
// Wait for table editor side panel to close
|
|
await expect(page.getByTestId('table-editor-side-panel')).not.toBeVisible()
|
|
|
|
// Verify foreign key was removed by opening edit table dialog again
|
|
await page
|
|
.getByRole('button', { name: `View ${sourceTableName}`, exact: true })
|
|
.locator('button[aria-haspopup="menu"]')
|
|
.click()
|
|
await page.getByRole('menuitem', { name: 'Edit table' }).click()
|
|
|
|
// Scroll down to see foreign key relations section
|
|
await page.getByRole('heading', { name: 'Foreign keys' }).scrollIntoViewIfNeeded()
|
|
// Verify foreign key relation no longer exists
|
|
await expect(page.getByText(`public.${targetTableName}`, { exact: false })).not.toBeVisible()
|
|
|
|
// Close the edit table dialog
|
|
await page.getByRole('button', { name: 'Cancel' }).click()
|
|
await expect(page.getByTestId('table-editor-side-panel')).not.toBeVisible()
|
|
})
|
|
|
|
test('CSV drag and drop imports data on empty table', async ({ page, ref }) => {
|
|
const tableName = 'pw_table_csv_drag_drop'
|
|
|
|
await using _ = await withSetupCleanup(
|
|
async () => {
|
|
await createTableWithRLS(tableName, 'pw_column')
|
|
},
|
|
async () => {
|
|
await dropTable(tableName)
|
|
}
|
|
)
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
|
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
|
|
|
await expect(
|
|
page.getByText('or drag and drop a CSV file here'),
|
|
'Empty table should show drag and drop hint'
|
|
).toBeVisible()
|
|
|
|
const csvFilePath = path.join(import.meta.dirname, 'files', 'table-editor-drag-drop.csv')
|
|
const csvBuffer = fs.readFileSync(csvFilePath)
|
|
|
|
// Synthesize a DataTransfer with the CSV file to simulate a browser file drag-and-drop
|
|
const dataTransfer = await page.evaluateHandle((csvBase64: string) => {
|
|
const dt = new DataTransfer()
|
|
const bytes = Uint8Array.from(atob(csvBase64), (c) => c.charCodeAt(0))
|
|
const file = new File([bytes], 'table-editor-drag-drop.csv', { type: 'text/csv' })
|
|
dt.items.add(file)
|
|
return dt
|
|
}, csvBuffer.toString('base64'))
|
|
|
|
const gridContainer = page.getByTestId('table-editor-grid-container')
|
|
|
|
await gridContainer.dispatchEvent('dragover', { dataTransfer })
|
|
|
|
// After the refactor, the empty state text and import button remain visible during drag
|
|
// (no more "Drop your CSV file here" overlay), just a dashed border is shown
|
|
await expect(
|
|
page.getByText('This table is empty'),
|
|
'Empty table message should remain visible during drag'
|
|
).toBeVisible()
|
|
|
|
await gridContainer.dispatchEvent('drop', { dataTransfer })
|
|
|
|
await expect(
|
|
page.getByText('A total of 3 rows will be'),
|
|
'Import dialog should show correct row count from CSV'
|
|
).toBeVisible({ timeout: 10_000 })
|
|
|
|
const waitForCsvInsert = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByRole('button', { name: 'Import data' }).click()
|
|
await waitForCsvInsert
|
|
await waitForGridDataToLoad(page, ref)
|
|
|
|
await expect(
|
|
page.getByText('3 records'),
|
|
'Table should show 3 records after drag and drop import'
|
|
).toBeVisible()
|
|
await expect(page.getByRole('gridcell', { name: 'drag drop value 1' })).toBeVisible()
|
|
})
|
|
|
|
test('CSV import with extra column succeeds after deselecting incompatible header', async ({
|
|
page,
|
|
ref,
|
|
}) => {
|
|
const tableName = 'pw_table_csv_extra_col'
|
|
|
|
await using _ = await withSetupCleanup(
|
|
async () => {
|
|
await createTableWithRLS(tableName, 'pw_column')
|
|
},
|
|
async () => {
|
|
await dropTable(tableName)
|
|
}
|
|
)
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
|
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
|
|
|
// Upload a CSV that has an extra column not in the table
|
|
const csvFilePath = path.join(
|
|
import.meta.dirname,
|
|
'files',
|
|
'table-editor-import-extra-column.csv'
|
|
)
|
|
await page.getByRole('button', { name: 'Import data from CSV' }).click()
|
|
await page.getByRole('tab', { name: 'Upload CSV' }).click()
|
|
await page.setInputFiles('input[type="file"]', csvFilePath)
|
|
|
|
// The "Data incompatible" badge should be visible because of the extra column
|
|
await expect(page.getByText('Data incompatible')).toBeVisible()
|
|
|
|
// Expand the configuration section and deselect the extra column
|
|
await page.getByRole('button', { name: 'Toggle import configuration' }).click()
|
|
await expect(page.getByText('Select which columns to import')).toBeVisible()
|
|
await page.getByRole('button', { name: 'Toggle column extra_column' }).click()
|
|
|
|
// After deselecting, the incompatible badge should disappear
|
|
await expect(page.getByText('Data incompatible')).not.toBeVisible()
|
|
|
|
// Import should succeed now
|
|
const waitForCsvInsert = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByRole('button', { name: 'Import data' }).click()
|
|
await waitForCsvInsert
|
|
await waitForGridDataToLoad(page, ref)
|
|
|
|
await expect(page.getByText('3 records')).toBeVisible()
|
|
await expect(page.getByRole('gridcell', { name: 'value 1' })).toBeVisible()
|
|
})
|
|
|
|
test('CSV import syncs custom owned sequences before the next insert', async ({ page, ref }) => {
|
|
const tableName = 'pw_table_csv_sequence_sync'
|
|
const sequenceName = 'pw_table_csv_import_owned_seq'
|
|
|
|
await using _ = await withSetupCleanup(
|
|
async () => {
|
|
await query(`drop table if exists public.${tableName} cascade;`)
|
|
await query(`drop sequence if exists public.${sequenceName};`)
|
|
await query(`create sequence public.${sequenceName};`)
|
|
await query(`create table public.${tableName} (
|
|
id bigint primary key default nextval('public.${sequenceName}'),
|
|
name text
|
|
);`)
|
|
await query(`alter sequence public.${sequenceName} owned by public.${tableName}.id;`)
|
|
},
|
|
async () => {
|
|
await query(`drop table if exists public.${tableName} cascade;`)
|
|
await query(`drop sequence if exists public.${sequenceName};`)
|
|
}
|
|
)
|
|
|
|
const tableEditorLoadWait = createApiResponseWaiter(
|
|
page,
|
|
'pg-meta',
|
|
ref,
|
|
'query?key=entity-types-public-'
|
|
)
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
await tableEditorLoadWait
|
|
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
|
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
|
|
|
const csvFilePath = path.join(import.meta.dirname, 'files', 'table-editor-import-sequence.csv')
|
|
await page.getByRole('button', { name: 'Import data from CSV' }).click()
|
|
await page.getByRole('tab', { name: 'Upload CSV' }).click()
|
|
await page.setInputFiles('input[type="file"]', csvFilePath)
|
|
await expect(page.getByText('A total of 3 rows will be')).toBeVisible()
|
|
|
|
const waitForCsvInsert = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByRole('button', { name: 'Import data' }).click()
|
|
await waitForCsvInsert
|
|
await waitForGridDataToLoad(page, ref)
|
|
await expect(page.getByText('3 records')).toBeVisible()
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
const [{ state }] = await query<{ state: string }>(`
|
|
select format(
|
|
'%s|%s|%s',
|
|
(select coalesce(max(id), 0) from public.${tableName}),
|
|
last_value,
|
|
is_called
|
|
) as state
|
|
from public.${sequenceName};
|
|
`)
|
|
return state
|
|
})
|
|
.toBe('229|229|t')
|
|
|
|
await page.getByTestId('table-editor-insert-new-row').click()
|
|
await page.getByRole('menuitem', { name: 'Insert row' }).click()
|
|
await page.getByTestId('name-input').fill('Dave')
|
|
const insertPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByTestId('action-bar-save-row').click()
|
|
await insertPromise
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
const [{ id }] = await query<{ id: string }>(
|
|
`select id::text as id from public.${tableName} where name = 'Dave'`
|
|
)
|
|
return id
|
|
})
|
|
.toBe('230')
|
|
})
|
|
|
|
test('pasted CSV text syncs custom owned sequences before the next insert', async ({
|
|
page,
|
|
ref,
|
|
}) => {
|
|
const tableName = 'pw_table_paste_sequence_sync'
|
|
const sequenceName = 'pw_table_paste_import_owned_seq'
|
|
|
|
await using _ = await withSetupCleanup(
|
|
async () => {
|
|
await query(`drop table if exists public.${tableName} cascade;`)
|
|
await query(`drop sequence if exists public.${sequenceName};`)
|
|
await query(`create sequence public.${sequenceName};`)
|
|
await query(`create table public.${tableName} (
|
|
id bigint primary key default nextval('public.${sequenceName}'),
|
|
name text
|
|
);`)
|
|
await query(`alter sequence public.${sequenceName} owned by public.${tableName}.id;`)
|
|
},
|
|
async () => {
|
|
await query(`drop table if exists public.${tableName} cascade;`)
|
|
await query(`drop sequence if exists public.${sequenceName};`)
|
|
}
|
|
)
|
|
|
|
const waitForTable = waitForTableToLoad(page, ref)
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
await waitForTable
|
|
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
|
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
|
|
|
const csvFilePath = path.join(import.meta.dirname, 'files', 'table-editor-import-sequence.csv')
|
|
const csvText = fs.readFileSync(csvFilePath, 'utf-8')
|
|
await page.getByRole('button', { name: 'Import data from CSV' }).click()
|
|
await page.getByRole('tab', { name: 'Paste text' }).click()
|
|
await page.getByRole('textbox').fill(csvText)
|
|
await expect(page.getByText('A total of 3 rows will be')).toBeVisible()
|
|
|
|
const waitForCsvInsert = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByRole('button', { name: 'Import data' }).click()
|
|
await waitForCsvInsert
|
|
await waitForGridDataToLoad(page, ref)
|
|
await expect(page.getByText('3 records')).toBeVisible()
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
const [{ state }] = await query<{ state: string }>(`
|
|
select format(
|
|
'%s|%s|%s',
|
|
(select coalesce(max(id), 0) from public.${tableName}),
|
|
last_value,
|
|
is_called
|
|
) as state
|
|
from public.${sequenceName};
|
|
`)
|
|
return state
|
|
})
|
|
.toBe('229|229|t')
|
|
|
|
await page.getByTestId('table-editor-insert-new-row').click()
|
|
await page.getByRole('menuitem', { name: 'Insert row' }).click()
|
|
await page.getByTestId('name-input').fill('Dave')
|
|
const insertPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByTestId('action-bar-save-row').click()
|
|
await insertPromise
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
const [{ id }] = await query<{ id: string }>(
|
|
`select id::text as id from public.${tableName} where name = 'Dave'`
|
|
)
|
|
return id
|
|
})
|
|
.toBe('230')
|
|
})
|
|
|
|
test('row insert via side panel saves immediately', async ({ page, ref }) => {
|
|
const tableName = 'pw_table_row_insert'
|
|
const columnName = 'name'
|
|
|
|
await using _ = await withSetupCleanup(
|
|
async () => {
|
|
await createTable(tableName, columnName)
|
|
},
|
|
async () => {
|
|
await dropTable(tableName)
|
|
}
|
|
)
|
|
|
|
const tableEditorLoadWait = createApiResponseWaiter(
|
|
page,
|
|
'pg-meta',
|
|
ref,
|
|
'query?key=entity-types-public-'
|
|
)
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
await tableEditorLoadWait
|
|
|
|
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
|
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
|
|
|
// Open side panel to insert a new row
|
|
await page.getByTestId('table-editor-insert-new-row').click()
|
|
await page.getByRole('menuitem', { name: 'Insert row' }).click()
|
|
await page.getByTestId(`${columnName}-input`).fill('immediate insert')
|
|
|
|
// Wait for the POST mutation to complete when saving
|
|
const insertPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByTestId('action-bar-save-row').click()
|
|
await insertPromise
|
|
|
|
// Should show success toast
|
|
await expect(
|
|
page.getByText('Successfully created row'),
|
|
'Success toast should appear after immediate row creation'
|
|
).toBeVisible({ timeout: 10000 })
|
|
|
|
// Row should be visible in the grid
|
|
await expect(
|
|
page.getByRole('gridcell', { name: 'immediate insert' }),
|
|
'Newly inserted row should be visible in the grid'
|
|
).toBeVisible()
|
|
|
|
// Should NOT show pending changes (queue is off)
|
|
await expect(
|
|
page.getByText('pending change'),
|
|
'No pending changes should appear when queue is disabled'
|
|
).not.toBeVisible()
|
|
})
|
|
|
|
test('row edit via side panel saves immediately', async ({ page, ref }) => {
|
|
const tableName = 'pw_table_row_edit'
|
|
const columnName = 'name'
|
|
|
|
await using _ = await withSetupCleanup(
|
|
async () => {
|
|
await createTable(tableName, columnName, [{ name: 'original value' }])
|
|
},
|
|
async () => {
|
|
await dropTable(tableName)
|
|
}
|
|
)
|
|
|
|
const tableEditorLoadWait = createApiResponseWaiter(
|
|
page,
|
|
'pg-meta',
|
|
ref,
|
|
'query?key=entity-types-public-'
|
|
)
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
await tableEditorLoadWait
|
|
|
|
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
|
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
|
|
|
await expect(page.getByRole('gridcell', { name: 'original value' })).toBeVisible()
|
|
|
|
// Right-click to open context menu and edit the row
|
|
const cell = page.getByRole('gridcell', { name: 'original value' })
|
|
await cell.click({ button: 'right' })
|
|
await page.getByRole('menuitem', { name: 'Edit row' }).click()
|
|
|
|
// Update the value in the side panel
|
|
const input = page.getByTestId(`${columnName}-input`)
|
|
await expect(input).toBeVisible()
|
|
await input.clear()
|
|
await input.fill('updated value')
|
|
|
|
// Wait for the POST mutation to complete when saving
|
|
const updatePromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByTestId('action-bar-save-row').click()
|
|
await updatePromise
|
|
|
|
// Updated value should be visible in the grid after immediate save
|
|
await expect(
|
|
page.getByRole('gridcell', { name: 'updated value' }),
|
|
'Updated value should be visible in the grid'
|
|
).toBeVisible()
|
|
|
|
// Original value should be gone
|
|
await expect(
|
|
page.getByRole('gridcell', { name: 'original value' }),
|
|
'Original value should no longer be visible'
|
|
).not.toBeVisible()
|
|
|
|
// Should NOT show pending changes (queue is off)
|
|
await expect(
|
|
page.getByText('pending change'),
|
|
'No pending changes should appear when queue is disabled'
|
|
).not.toBeVisible()
|
|
})
|
|
|
|
test('editing multiple columns via side panel saves all changes', async ({ page, ref }) => {
|
|
const tableName = 'pw_table_multi_col_edit'
|
|
|
|
await using _ = await withSetupCleanup(
|
|
async () => {
|
|
await query(
|
|
`CREATE TABLE IF NOT EXISTS ${tableName} (
|
|
id bigint generated by default as identity primary key,
|
|
created_at timestamp with time zone null default now(),
|
|
first_name text,
|
|
last_name text
|
|
)`
|
|
)
|
|
await query(`INSERT INTO ${tableName} (first_name, last_name) VALUES ($1, $2)`, [
|
|
'Alice',
|
|
'Smith',
|
|
])
|
|
},
|
|
async () => {
|
|
await dropTable(tableName)
|
|
}
|
|
)
|
|
|
|
const tableEditorLoadWait = createApiResponseWaiter(
|
|
page,
|
|
'pg-meta',
|
|
ref,
|
|
'query?key=entity-types-public-'
|
|
)
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
await tableEditorLoadWait
|
|
|
|
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
|
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
|
|
|
await expect(page.getByRole('gridcell', { name: 'Alice' })).toBeVisible()
|
|
await expect(page.getByRole('gridcell', { name: 'Smith' })).toBeVisible()
|
|
|
|
// Right-click to open context menu and edit the row
|
|
const cell = page.getByRole('gridcell', { name: 'Alice' })
|
|
await cell.click({ button: 'right' })
|
|
await page.getByRole('menuitem', { name: 'Edit row' }).click()
|
|
|
|
// Update both columns in the side panel
|
|
const firstNameInput = page.getByTestId('first_name-input')
|
|
await expect(firstNameInput).toBeVisible()
|
|
await firstNameInput.clear()
|
|
await firstNameInput.fill('Bob')
|
|
|
|
const lastNameInput = page.getByTestId('last_name-input')
|
|
await lastNameInput.clear()
|
|
await lastNameInput.fill('Jones')
|
|
|
|
// Wait for the POST mutation to complete when saving
|
|
const updatePromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByTestId('action-bar-save-row').click()
|
|
await updatePromise
|
|
|
|
// Both columns should reflect the updated values
|
|
await expect(
|
|
page.getByRole('gridcell', { name: 'Bob' }),
|
|
'First name should be updated to Bob'
|
|
).toBeVisible()
|
|
await expect(
|
|
page.getByRole('gridcell', { name: 'Jones' }),
|
|
'Last name should be updated to Jones'
|
|
).toBeVisible()
|
|
|
|
// Original values should be gone
|
|
await expect(
|
|
page.getByRole('gridcell', { name: 'Alice' }),
|
|
'Original first name should no longer be visible'
|
|
).not.toBeVisible()
|
|
await expect(
|
|
page.getByRole('gridcell', { name: 'Smith' }),
|
|
'Original last name should no longer be visible'
|
|
).not.toBeVisible()
|
|
})
|
|
|
|
test('row delete via context menu shows confirmation dialog', async ({ page, ref }) => {
|
|
const tableName = 'pw_table_row_delete'
|
|
const columnName = 'name'
|
|
|
|
await using _ = await withSetupCleanup(
|
|
async () => {
|
|
await createTable(tableName, columnName, [{ name: 'row to delete' }])
|
|
},
|
|
async () => {
|
|
await dropTable(tableName)
|
|
}
|
|
)
|
|
|
|
const tableEditorLoadWait = createApiResponseWaiter(
|
|
page,
|
|
'pg-meta',
|
|
ref,
|
|
'query?key=entity-types-public-'
|
|
)
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
await tableEditorLoadWait
|
|
|
|
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
|
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
|
|
|
await expect(page.getByRole('gridcell', { name: 'row to delete' })).toBeVisible()
|
|
|
|
// Right-click to open context menu and delete the row
|
|
const cell = page.getByRole('gridcell', { name: 'row to delete' })
|
|
await cell.click({ button: 'right' })
|
|
await page.getByRole('menuitem', { name: 'Delete row' }).click()
|
|
|
|
// In non-queue mode, a confirmation dialog should appear
|
|
const confirmDialog = page.getByRole('dialog', { name: 'Confirm to delete the selected row' })
|
|
await expect(
|
|
confirmDialog,
|
|
'Confirmation dialog should appear for non-queue row deletion'
|
|
).toBeVisible({ timeout: 10000 })
|
|
|
|
// Confirm the deletion
|
|
const deletePromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await confirmDialog.getByRole('button', { name: 'Delete' }).click()
|
|
await deletePromise
|
|
|
|
// Row should be gone
|
|
await expect(
|
|
page.getByRole('gridcell', { name: 'row to delete' }),
|
|
'Deleted row should no longer be visible'
|
|
).not.toBeVisible()
|
|
|
|
// Should show 0 records
|
|
await expect(
|
|
page.getByText('0 records'),
|
|
'Table should show 0 records after deletion'
|
|
).toBeVisible()
|
|
|
|
// Should NOT show pending changes (queue is off)
|
|
await expect(
|
|
page.getByText('pending change'),
|
|
'No pending changes should appear when queue is disabled'
|
|
).not.toBeVisible()
|
|
})
|
|
|
|
test('create a table in a single transaction', async ({ page, ref }) => {
|
|
const tableName = 'pw_table_create_transaction'
|
|
|
|
await using _ = await withSetupCleanup(
|
|
async () => {
|
|
// Nothing
|
|
},
|
|
async () => {
|
|
await dropTable(tableName)
|
|
}
|
|
)
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
await page.getByRole('button', { name: 'New table' }).click()
|
|
await page.getByLabel('Name', { exact: true }).fill(tableName)
|
|
await page.getByRole('button', { name: 'Add column' }).click()
|
|
await page.getByLabel('Column name').nth(2).fill('pw_column')
|
|
await page.getByRole('combobox').filter({ hasText: 'Choose a column type...' }).click()
|
|
await page.getByRole('option').filter({ hasText: 'int8' }).click()
|
|
await page.getByLabel('Column default value').nth(2).fill('invalid')
|
|
|
|
await page.getByRole('button', { name: 'Save' }).click()
|
|
await expect(page.getByText('invalid input syntax')).toBeVisible()
|
|
await page.getByLabel('Column default value').nth(2).fill('10')
|
|
await page.getByRole('button', { name: 'Save' }).click()
|
|
await expect(page.getByText(`Table ${tableName} is good to go!`)).toBeVisible()
|
|
await expect(page.getByRole('button', { name: `View ${tableName}`, exact: true })).toBeVisible()
|
|
|
|
// copies table schema to clipboard when copy schema option is clicked
|
|
await page
|
|
.getByRole('button', { name: `View ${tableName}`, exact: true })
|
|
.getByRole('button')
|
|
.nth(2)
|
|
.click()
|
|
await page.getByRole('menuitem', { name: 'Copy table schema' }).click()
|
|
await expectClipboardValue({
|
|
page,
|
|
value: `create table public.${tableName} (
|
|
id bigint generated by default as identity not null,
|
|
created_at timestamp with time zone not null default now(),
|
|
pw_column bigint null default '10'::bigint,
|
|
constraint ${tableName}_pkey primary key (id)
|
|
) TABLESPACE pg_default;`,
|
|
exact: true,
|
|
})
|
|
})
|
|
|
|
test('inserting a row with NULL value for nullable text column with default', async ({
|
|
page,
|
|
ref,
|
|
}) => {
|
|
const tableName = 'pw_table_null_text_insert'
|
|
const columnName = 'description'
|
|
|
|
await using _ = await withSetupCleanup(
|
|
async () => {
|
|
await query(
|
|
`CREATE TABLE IF NOT EXISTS ${tableName} (
|
|
id bigint generated by default as identity primary key,
|
|
created_at timestamp with time zone null default now(),
|
|
${columnName} text null default 'hello world'
|
|
)`
|
|
)
|
|
},
|
|
async () => {
|
|
await dropTable(tableName)
|
|
}
|
|
)
|
|
|
|
const tableEditorLoadWait = createApiResponseWaiter(
|
|
page,
|
|
'pg-meta',
|
|
ref,
|
|
'query?key=entity-types-public-'
|
|
)
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
await tableEditorLoadWait
|
|
|
|
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
|
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
|
|
|
// Open side panel to insert a new row
|
|
await page.getByTestId('table-editor-insert-new-row').click()
|
|
await page.getByRole('menuitem', { name: 'Insert row' }).click()
|
|
|
|
// Open the field actions dropdown and set the text column to NULL
|
|
await page.getByTestId(`${columnName}-field-actions`).click()
|
|
await page.getByRole('menuitem', { name: 'Set to NULL' }).click()
|
|
|
|
// Save the row
|
|
const insertPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
|
method: 'POST',
|
|
})
|
|
await page.getByTestId('action-bar-save-row').click()
|
|
await insertPromise
|
|
|
|
// Should show success toast
|
|
await expect(
|
|
page.getByText('Successfully created row'),
|
|
'Success toast should appear after row creation'
|
|
).toBeVisible({ timeout: 10000 })
|
|
|
|
// The new row's text column should show NULL in the grid
|
|
await expect(
|
|
page.getByRole('gridcell', { name: 'NULL' }).first(),
|
|
'Text column should display NULL after setting to NULL via row editor'
|
|
).toBeVisible()
|
|
})
|
|
|
|
test('copying cell content from referencing record peek copies the correct value', async ({
|
|
page,
|
|
ref,
|
|
}) => {
|
|
const targetTable = 'pw_fk_peek_target'
|
|
const sourceTable = 'pw_fk_peek_source'
|
|
|
|
await using _ = await withSetupCleanup(
|
|
async () => {
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS ${targetTable} (
|
|
id bigint generated by default as identity primary key,
|
|
name text
|
|
)
|
|
`)
|
|
await query(`ALTER TABLE public.${targetTable} ENABLE ROW LEVEL SECURITY`)
|
|
await query(`INSERT INTO ${targetTable} (name) VALUES ('target_value')`)
|
|
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS ${sourceTable} (
|
|
id bigint generated by default as identity primary key,
|
|
label text,
|
|
target_id bigint references ${targetTable}(id)
|
|
)
|
|
`)
|
|
await query(`ALTER TABLE public.${sourceTable} ENABLE ROW LEVEL SECURITY`)
|
|
await query(`INSERT INTO ${sourceTable} (label, target_id) VALUES ('source_label', 1)`)
|
|
},
|
|
async () => {
|
|
await dropTable(sourceTable)
|
|
await dropTable(targetTable)
|
|
}
|
|
)
|
|
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
await page.getByRole('button', { name: `View ${sourceTable}`, exact: true }).click()
|
|
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
|
|
|
await expect(page.getByRole('grid')).toBeVisible()
|
|
await expect(page.getByRole('gridcell', { name: 'source_label' })).toBeVisible()
|
|
|
|
await page.getByRole('button', { name: 'View referencing record' }).click()
|
|
|
|
const popover = page.getByText(`Referencing record from public.${targetTable}`)
|
|
await expect(popover).toBeVisible()
|
|
|
|
const popoverContent = page.locator('[data-radix-popper-content-wrapper]')
|
|
await expect(popoverContent.getByRole('gridcell', { name: 'target_value' })).toBeVisible()
|
|
|
|
// Right-click on the target_value cell inside the popover to open the peek context menu.
|
|
// Before the fix, this would trigger the main grid's context menu via React portal
|
|
// event bubbling, and "Copy cell" would copy the wrong value from the main grid.
|
|
await popoverContent.getByRole('gridcell', { name: 'target_value' }).click({ button: 'right' })
|
|
|
|
// The peek grid's own context menu should appear with "Copy cell"
|
|
await page.getByRole('menuitem', { name: 'Copy cell' }).click()
|
|
|
|
// Verify the correct referenced value was copied (not the main grid's FK value "1")
|
|
await expectClipboardValue({
|
|
page,
|
|
value: 'target_value',
|
|
exact: true,
|
|
})
|
|
})
|
|
|
|
test('Can double-click a column name to copy it', async ({ page, ref }) => {
|
|
const tableName = 'pw_table_column_click'
|
|
|
|
// create table + verify that this exists.
|
|
await using _ = await withSetupCleanup(
|
|
() => createTableWithRLS(tableName, 'pw_column'),
|
|
async () => {
|
|
await dropTable(tableName)
|
|
}
|
|
)
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
await page.getByLabel(`View ${tableName}`).click()
|
|
await expect(page.getByRole('button', { name: 'pw_column', exact: true })).toBeVisible()
|
|
await page.getByRole('button', { name: 'pw_column', exact: true }).dblclick()
|
|
await page.keyboard.press('ControlOrMeta+C')
|
|
await expectClipboardValue({
|
|
page,
|
|
value: 'pw_column',
|
|
exact: true,
|
|
})
|
|
})
|
|
})
|