mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 11:25:06 +03:00
## What kind of change does this PR introduce? Studio UI cleanup and docs update. Resolves FE-3217. ## What is the current behavior? The table RLS confirmation dialogs differ across the Policies page and Table Editor surfaces. The table editor disable flow also uses a verbose confirmation modal with admonitions and repeated warning copy. | Policies | Table Editor | | --- | --- | | <img width="852" height="448" alt="25125" src="https://github.com/user-attachments/assets/baa9fafa-752c-4701-bf54-40da4e32030b" /> | <img width="1108" height="1016" alt="57354" src="https://github.com/user-attachments/assets/b1b88355-be39-49b9-aed2-a60f20c25104" /> | | <img width="866" height="434" alt="CleanShot 2026-05-15 at 18 01 15@2x" src="https://github.com/user-attachments/assets/d67f69ee-0a49-4dbd-939e-ca968361fc33" /> | <img width="840" height="426" alt="CleanShot 2026-05-15 at 18 01 52@2x" src="https://github.com/user-attachments/assets/a0b5f390-abeb-453f-8636-ad097f22308b" /> | ## What is the new behavior? Table RLS enable and disable confirmations now use one shared concise Alert Dialog treatment across the Policies page, Table Editor header, and Table Editor side panel. The server-backed toggles use the async Alert Dialog action behaviour from the stacked base branch, and the design-system docs now clarify when to start with Alert Dialog versus Confirmation Modal. This PR is stacked on #45960. | Before | After | | --- | --- | | <img width="852" height="448" alt="25125" src="https://github.com/user-attachments/assets/baa9fafa-752c-4701-bf54-40da4e32030b" /> | <img width="864" height="518" alt="CleanShot 2026-05-15 at 18 07 21@2x" src="https://github.com/user-attachments/assets/bda53246-164b-4d5b-81a1-25d3bc661eb3" /> | | <img width="1108" height="1016" alt="57354" src="https://github.com/user-attachments/assets/b1b88355-be39-49b9-aed2-a60f20c25104" /> | <img width="864" height="518" alt="CleanShot 2026-05-15 at 18 07 21@2x" src="https://github.com/user-attachments/assets/bda53246-164b-4d5b-81a1-25d3bc661eb3" /> | | <img width="866" height="434" alt="CleanShot 2026-05-15 at 18 01 15@2x" src="https://github.com/user-attachments/assets/d67f69ee-0a49-4dbd-939e-ca968361fc33" /> | <img width="860" height="488" alt="CleanShot 2026-05-15 at 18 08 41@2x" src="https://github.com/user-attachments/assets/4c67f797-e2cd-4d56-a49d-e6c6fc0edff3" /> | | <img width="840" height="426" alt="CleanShot 2026-05-15 at 18 01 52@2x" src="https://github.com/user-attachments/assets/a0b5f390-abeb-453f-8636-ad097f22308b" /> | <img width="860" height="488" alt="CleanShot 2026-05-15 at 18 08 41@2x" src="https://github.com/user-attachments/assets/4c67f797-e2cd-4d56-a49d-e6c6fc0edff3" /> | <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * Refined guidance and wording for dialog usage, clarifying when to use Alert Dialog, Confirmation Modal, and modal modality. * **New Features** * Reworked RLS confirmation flow with a streamlined toggle dialog, clearer messaging, and a “Learn more” link to docs. * **Tests** * Updated end-to-end tests to cover the updated RLS dialog flows and explanatory content. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45961?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 -->
2032 lines
79 KiB
TypeScript
2032 lines
79 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 page.getByRole('button', { name: 'RLS disabled' }).click()
|
|
await page.getByRole('button', { name: 'Enable RLS for this table' }).click()
|
|
await expect(page.getByRole('heading', { name: 'Enable Row Level Security' })).toBeVisible()
|
|
await expect(
|
|
page.getByText(
|
|
'RLS restricts table access until policies allow a request. Existing queries may stop returning rows until policies are added.'
|
|
)
|
|
).toBeVisible()
|
|
await page.getByRole('button', { name: 'Cancel' }).click()
|
|
})
|
|
|
|
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' })
|
|
// Accept either percent-encoded (`%20`) or form-encoded (`+`) spaces —
|
|
// some routers serialize search params via URLSearchParams which emits
|
|
// `+` for spaces. Both decode to the same SQL.
|
|
await expect(openInSqlEditorLink).toHaveAttribute(
|
|
'href',
|
|
/security_invoker(%20|\+)%3D(%20|\+)true/
|
|
)
|
|
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'),
|
|
30000
|
|
)
|
|
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'),
|
|
30000
|
|
)
|
|
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('shortcut saves fk before column update', async ({ page, ref }) => {
|
|
const runId = `${test.info().parallelIndex}_${test.info().repeatEachIndex}`
|
|
const sourceTableName = `pw_column_fk_shortcut_source_${runId}`
|
|
const targetTableName = `pw_column_fk_shortcut_target_${runId}`
|
|
const columnEditor = page.getByRole('dialog', {
|
|
name: `Update column ref_id from ${sourceTableName}`,
|
|
exact: true,
|
|
})
|
|
const foreignKeySchemaSelect = page.getByRole('combobox', { name: 'Select a schema' })
|
|
const foreignKeyLink = columnEditor.getByRole('link', {
|
|
name: `public.${targetTableName}`,
|
|
exact: true,
|
|
})
|
|
const openColumnEditor = async () => {
|
|
await page
|
|
.getByRole('columnheader', { name: 'ref_id' })
|
|
.getByRole('button', { name: 'Column ref_id actions' })
|
|
.click()
|
|
await page.getByRole('menuitem', { name: 'Edit column' }).click()
|
|
await expect(columnEditor).toBeVisible()
|
|
}
|
|
|
|
await using _ = await withSetupCleanup(
|
|
() =>
|
|
query(`
|
|
drop table if exists public.${sourceTableName} cascade;
|
|
drop table if exists public.${targetTableName} cascade;
|
|
|
|
create table public.${targetTableName} (
|
|
id text primary key
|
|
);
|
|
|
|
create table public.${sourceTableName} (
|
|
id bigint generated by default as identity primary key,
|
|
ref_id text
|
|
);
|
|
`),
|
|
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$/)
|
|
|
|
await openColumnEditor()
|
|
|
|
await columnEditor.getByRole('button', { name: 'Add foreign key' }).click()
|
|
await expect(foreignKeySchemaSelect).toContainText('public')
|
|
|
|
const tableQueryPromise = waitForApiResponseWithTimeout(page, (response) =>
|
|
response.url().includes(`table-public-${targetTableName}`)
|
|
)
|
|
|
|
await page.getByRole('combobox', { name: 'Select a table to reference to' }).click()
|
|
await page.getByRole('option', { name: `public ${targetTableName}` }).click()
|
|
await tableQueryPromise
|
|
|
|
await page.getByRole('combobox', { name: `Column from public.${targetTableName}` }).click()
|
|
await page.getByRole('option', { name: 'id text' }).click()
|
|
|
|
await page.keyboard.press('ControlOrMeta+Enter')
|
|
|
|
await expect(foreignKeySchemaSelect).not.toBeVisible()
|
|
await expect(foreignKeyLink).toBeVisible()
|
|
|
|
const updateColumnPromise = waitForApiResponse(
|
|
page,
|
|
'pg-meta',
|
|
ref,
|
|
'query?key=column-update',
|
|
{
|
|
method: 'POST',
|
|
}
|
|
)
|
|
await columnEditor.getByRole('button', { name: 'Save' }).click()
|
|
await updateColumnPromise
|
|
await expect(columnEditor).not.toBeVisible()
|
|
|
|
await openColumnEditor()
|
|
await expect(foreignKeyLink).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,
|
|
})
|
|
})
|
|
})
|