chore(studio): database tables UI improvements (#44163)

## What kind of change does this PR introduce?

UI improvements

## What is the current behavior?

- The database tables list and columns list use inconsistent page shells
and table primitives
- The child columns page has weaker information hierarchy and row
actions than the parent tables page
- Responsive column priority on the tables list does not reflect the
most important data on smaller breakpoints
- Table actions and counts are harder to scan than they should be

## What is the new behavior?

- Both pages now use `PageLayout` with matching large-width content
containers
- `ColumnList` now uses the latest `ui` Table primitives instead of the
legacy cleaned-up-later table
- Both pages now show totals in a table footer
- `ColumnList` now uses a tiny filter input, case-insensitive filtering,
inline descriptions under the name, and a primary `Edit` button with
overflow actions
- `TableList` now has improved responsive column priority:
  - smallest breakpoint keeps `Rows`
  - `Columns` appears from `sm`
  - `Size` appears from `lg`
  - `Realtime Enabled` appears from `2xl`
- `TableList` now uses `View columns` as the CTA, removes the ambiguous
icon from that CTA, restores the entity icon from `sm` upwards only, and
tightens the name column on the smallest breakpoint only
- Boolean icon columns are right-aligned consistently, with the same
Realtime icon tones applied to both `Realtime Enabled` and `Nullable`
- The columns detail page now uses breadcrumbs for navigation back to
Tables instead of an inline back button

| Before | After |
| --- | --- |
| <img width="1728" height="997" alt="Tables Database Mallet Toolshed
Supabase-0E0E3DE0-4EA1-407F-88D4-B85664D26D8E"
src="https://github.com/user-attachments/assets/3a2e265c-394e-432c-8c29-12317b60fda8"
/> | <img width="1728" height="997" alt="Tables Database Mallet Toolshed
Supabase-C8FC339C-E9DA-4ADB-8458-C7EFF55F2AEC"
src="https://github.com/user-attachments/assets/50c83a3f-a70c-4d09-a8c3-1eeaed68b68b"
/> |
| <img width="1728" height="997" alt="Tables Database Mallet Toolshed
Supabase-FE9196A0-BEAF-4BA5-8A2C-06F934A62C38"
src="https://github.com/user-attachments/assets/707a564a-e764-45ac-8470-8532e22d39bc"
/> | <img width="1728" height="997" alt="Tables Database Mallet Toolshed
Supabase-36E93C1E-7943-4C98-8119-CAF48E2FE5BA"
src="https://github.com/user-attachments/assets/4cba5791-a4d7-4f43-aea0-8277b2ec5d28"
/> |

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Danny WhiteandJoshen Lim authored and GitHub committed 2026-03-26 10:48:15 +11:00
1 parent 10e9cdd1f4
commit 64c37ca72b
6 files changed
+355 -294

No files matched your search

+54 -42
View File
@@ -89,12 +89,14 @@ test.describe('Database', () => {
// validates table and column exists
await expect(page.getByText(databaseTableName, { exact: true })).toBeVisible()
// test we can edit the column
await page.getByText(`${databaseTableName} actions`).click()
await page.getByText(`${databaseTableName} actions`).click()
await expect(page.getByRole('menuitem', { name: 'Edit table' })).toBeVisible()
await page.getByRole('menuitem', { name: 'Edit table' }).click({ force: true })
await expect(page.getByRole('menuitem', { name: 'Edit table' })).not.toBeVisible()
const tableActionsButton = page.getByRole('button', {
name: `${databaseTableName} actions`,
})
await tableActionsButton.click()
const editTableMenuItem = page.getByRole('menuitem', { name: 'Edit table' })
await expect(editTableMenuItem).toBeVisible()
await editTableMenuItem.press('Enter')
await expect(editTableMenuItem).not.toBeVisible()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible()
await expect(dialog.getByText('timestamptz')).toBeVisible()
@@ -104,10 +106,10 @@ test.describe('Database', () => {
await expect(page.getByRole('dialog')).not.toBeVisible()
// test the schema view has been refreshed
await page.getByText(`${databaseTableName} actions`).click()
await expect(page.getByRole('menuitem', { name: 'Edit table' })).toBeVisible()
await page.getByRole('menuitem', { name: 'Edit table' }).click()
await expect(page.getByRole('menuitem', { name: 'Edit table' })).not.toBeVisible()
await tableActionsButton.click()
await expect(editTableMenuItem).toBeVisible()
await editTableMenuItem.press('Enter')
await expect(editTableMenuItem).not.toBeVisible()
await expect(page.getByRole('dialog')).toBeVisible()
// FIXME: For some reason, the dialog is not stable and rerenders, sometimes preventing the description to be filled
await page.waitForTimeout(500)
@@ -115,15 +117,19 @@ test.describe('Database', () => {
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(page.getByRole('dialog')).not.toBeVisible()
await page.getByText(`${databaseTableName} actions`).click()
await expect(page.getByRole('menuitem', { name: 'Copy name' })).toBeVisible()
await page.getByRole('menuitem', { name: 'Copy name' }).click()
await expect(page.getByRole('menuitem', { name: 'Copy name' })).not.toBeVisible()
await tableActionsButton.click()
const copyTableNameMenuItem = page.getByRole('menuitem', { name: 'Copy name' })
await expect(copyTableNameMenuItem).toBeVisible()
await copyTableNameMenuItem.press('Enter')
await expect(copyTableNameMenuItem).not.toBeVisible()
await expectClipboardValue({ page, value: databaseTableName, exact: true })
await page.getByText(`${databaseTableName} actions`).click()
await expect(page.getByRole('menuitem', { name: 'View in Table Editor' })).toBeVisible()
await page.getByRole('menuitem', { name: 'View in Table Editor' }).click()
await tableActionsButton.click()
const viewInTableEditorMenuItem = page.getByRole('menuitem', {
name: 'View in Table Editor',
})
await expect(viewInTableEditorMenuItem).toBeVisible()
await viewInTableEditorMenuItem.press('Enter')
await page.waitForURL(/.*\/editor\/\d+/)
await expect(page.getByRole('tab', { name: databaseTableName })).toBeVisible()
})
@@ -152,11 +158,13 @@ test.describe('Database', () => {
await expect(page.getByText(databaseTableName, { exact: true })).toBeVisible()
await expect(page.getByText(databaseColumnName, { exact: true })).toBeVisible()
// test we can edit the column
await page
.getByText(`${databaseTableName} ${databaseColumnName} actions`)
.click({ force: true })
await expect(page.getByRole('menuitem', { name: 'Edit column' })).toBeVisible()
await page.getByRole('menuitem', { name: 'Edit column' }).click()
const columnActionsButton = page.getByRole('button', {
name: `${databaseTableName} ${databaseColumnName} actions`,
})
await columnActionsButton.click()
const editColumnMenuItem = page.getByRole('menuitem', { name: 'Edit column' })
await expect(editColumnMenuItem).toBeVisible()
await editColumnMenuItem.press('Enter')
await page.getByLabel('Description').fill('Bazinga')
await page.getByRole('button', { name: 'Save' }).click()
await expect(
@@ -165,20 +173,17 @@ test.describe('Database', () => {
await expect(page.getByRole('dialog')).not.toBeVisible()
// test the schema view has been refreshed
await page
.getByText(`${databaseTableName} ${databaseColumnName} actions`)
.click({ force: true })
await expect(page.getByRole('menuitem', { name: 'Edit column' })).toBeVisible()
await page.getByRole('menuitem', { name: 'Edit column' }).click()
await columnActionsButton.click()
await expect(editColumnMenuItem).toBeVisible()
await editColumnMenuItem.press('Enter')
await expect(page.getByLabel('Description')).toHaveValue('Bazinga')
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(page.getByRole('dialog')).not.toBeVisible()
await page
.getByText(`${databaseTableName} ${databaseColumnName} actions`)
.click({ force: true })
await expect(page.getByRole('menuitem', { name: 'Copy name' })).toBeVisible()
await page.getByRole('menuitem', { name: 'Copy name' }).click()
await columnActionsButton.click()
const copyColumnNameMenuItem = page.getByRole('menuitem', { name: 'Copy name' })
await expect(copyColumnNameMenuItem).toBeVisible()
await copyColumnNameMenuItem.press('Enter')
await expectClipboardValue({ page, value: databaseColumnName, exact: true })
})
})
@@ -208,11 +213,13 @@ test.describe('Database', () => {
await expect(page.getByRole('button', { name: 'New table' })).toBeVisible()
// validates database name is present and has accurate number of columns
const tableRow = page.getByRole('row', {
name: `${databaseTableName} No description`,
})
const tableRow = page
.getByRole('row')
.filter({ has: page.getByText(databaseTableName, { exact: true }) })
.first()
await expect(tableRow).toContainText(databaseTableName)
await expect(tableRow).toContainText('3 columns')
await expect(tableRow.getByRole('cell').filter({ hasText: /^3$/ }).first()).toBeVisible()
await expect(tableRow.getByRole('link', { name: 'View columns' })).toBeVisible()
// change schema -> auth
await page.getByTestId('schema-selector').click()
@@ -364,14 +371,20 @@ test.describe('Database', () => {
}
)
const databaseWait = createApiResponseWaiter(
page,
'pg-meta',
ref,
'tables?include_columns=true&included_schemas=public'
)
await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/tables?schema=public`))
// Wait for database tables to be populated
await waitForDatabaseToLoad(page, ref)
await databaseWait
// navigate to table columns
const databaseRow = page.getByRole('row', { name: databaseTableName })
await databaseRow.getByRole('link', { name: '3 columns' }).click()
await databaseRow.getByRole('link', { name: 'View columns' }).click()
await page.waitForURL(/.*\/database\/tables\/\d+/)
// validate and display everything correctly
@@ -407,8 +420,7 @@ test.describe('Database', () => {
await expect(columnDatabase2Row).toContainText('numeric')
// update table column
await columnDatabase2Row.getByRole('button').click()
await page.getByRole('button', { name: 'Edit column' }).click()
await columnDatabase2Row.getByRole('button', { name: 'Edit' }).click()
await page.getByLabel('name').fill(databaseColumnName3)
const columnUpdateWait = createApiResponseWaiter(
page,
@@ -430,8 +442,8 @@ test.describe('Database', () => {
// delete table column
const columnDatabase3Row = page.getByRole('row', { name: databaseColumnName3 })
await columnDatabase3Row.getByRole('button').click()
await page.getByRole('button', { name: 'Delete column' }).click()
await columnDatabase3Row.getByRole('button').last().click()
await page.getByRole('menuitem', { name: 'Delete column' }).click()
await page.getByRole('checkbox', { name: 'Drop column with cascade?' }).check()
const columnDeleteWait = createApiResponseWaiter(
page,
+12 -6
View File
@@ -24,19 +24,25 @@ function buildUrlMatcher(basePath: string, ref: string, action: string, method?:
// Normalize inputs and build a tolerant matcher that works across environments
const trimmedBasePath = basePath.replace(/^\/+|\/+$/g, '')
const refAlternatives = [ref, 'default']
const [actionPath, actionQuery] = action.split('?')
const trimmedActionPath = actionPath.replace(/^\/+/, '')
const expectedSearchParams = new URLSearchParams(actionQuery ?? '')
return (response: any) => {
const url = response.url()
const url = new URL(response.url())
const requestMethod = response.request().method()
// Must include base path and one of the ref alternatives
const hasBasePath = url.includes(`${trimmedBasePath}/`)
const hasRef = refAlternatives.some((r) => url.includes(`/${r}/`))
const hasBasePath = url.pathname.includes(`/${trimmedBasePath}/`)
const hasRef = refAlternatives.some((r) => url.pathname.includes(`/${r}/`))
// Action match should be tolerant to extra query params ordering
const hasAction = url.includes(action)
const hasActionPath =
trimmedActionPath.length === 0 || url.pathname.includes(`/${trimmedActionPath}`)
const hasExpectedSearchParams = [...expectedSearchParams.entries()].every(([key, value]) =>
url.searchParams.getAll(key).some((actualValue) => actualValue.includes(value))
)
const urlMatches = hasBasePath && hasRef && hasAction
const urlMatches = hasBasePath && hasRef && hasActionPath && hasExpectedSearchParams
if (method) return urlMatches && requestMethod === method
return urlMatches
}