From 64c37ca72b409d00c64fa85cf4e3372833083f80 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:48:15 +1100 Subject: [PATCH] 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 | | --- | --- | | Tables Database Mallet Toolshed
Supabase-0E0E3DE0-4EA1-407F-88D4-B85664D26D8E | Tables Database Mallet Toolshed
Supabase-C8FC339C-E9DA-4ADB-8458-C7EFF55F2AEC | | Tables Database Mallet Toolshed
Supabase-FE9196A0-BEAF-4BA5-8A2C-06F934A62C38 | Tables Database Mallet Toolshed
Supabase-36E93C1E-7943-4C98-8119-CAF48E2FE5BA | --------- Co-authored-by: Joshen Lim --- .../interfaces/Database/Tables/ColumnList.tsx | 291 +++++++++++------- .../interfaces/Database/Tables/TableList.tsx | 136 ++++---- .../project/[ref]/database/tables/[id].tsx | 50 +-- .../project/[ref]/database/tables/index.tsx | 58 ++-- e2e/studio/features/database.spec.ts | 96 +++--- e2e/studio/utils/wait-for-response.ts | 18 +- 6 files changed, 355 insertions(+), 294 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx index 8a836b701cc..622161c0f39 100644 --- a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx @@ -1,12 +1,6 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { noop } from 'lodash' -import { Check, ChevronLeft, Edit, MoreVertical, Plus, Search, Trash, X } from 'lucide-react' -import Link from 'next/link' -import { useState } from 'react' - import { PostgresColumn } from '@supabase/postgres-meta' +import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' -import Table from 'components/to-be-cleaned/Table' import AlertError from 'components/ui/AlertError' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { NoSearchResults } from 'components/ui/NoSearchResults' @@ -15,18 +9,30 @@ import { isTableLike } from 'data/table-editor/table-editor-types' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' +import { noop } from 'lodash' +import { Check, Edit, MoreVertical, Plus, Search, Trash, X } from 'lucide-react' +import { useState } from 'react' import { Button, + Card, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - Input, + Table, + TableBody, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, Tooltip, TooltipContent, TooltipTrigger, } from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning' interface ColumnListProps { @@ -40,7 +46,7 @@ export const ColumnList = ({ onEditColumn = noop, onDeleteColumn = noop, }: ColumnListProps) => { - const { id: _id, ref } = useParams() + const { id: _id } = useParams() const id = _id ? Number(_id) : undefined const { data: project } = useSelectedProjectQuery() @@ -62,7 +68,9 @@ export const ColumnList = ({ const columns = (filterString.length === 0 ? (selectedTable?.columns ?? []) - : selectedTable?.columns?.filter((column) => column.name.includes(filterString))) ?? [] + : selectedTable?.columns?.filter((column) => + column.name.toLowerCase().includes(filterString.toLowerCase()) + )) ?? [] const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedTable?.schema ?? '' }) const { can: canUpdateColumns } = useAsyncCheckPermissions( @@ -72,16 +80,13 @@ export const ColumnList = ({ return (
-
-
- +
+
setFilterString(e.target.value)} + onChange={(e) => setFilterString(e.target.value)} icon={} />
@@ -108,115 +113,163 @@ export const ColumnList = ({ )} - {isLoading && } + + {isLoading ? ( +
+ +
+ ) : ( + + + + + Name + + + Data Type + + + Format + + + Nullable + + + + + + {isError && ( + + + + + + )} - {isError && ( - - )} + {isSuccess && columns.length === 0 && filterString.length > 0 && ( + + + setFilterString('')} + /> + + + )} - {isSuccess && ( - <> - {columns.length === 0 ? ( - setFilterString('')} - /> - ) : ( -
-
Name, - - Description - , - Data Type, - Format, - - Nullable - , - , - ]} - body={columns.map((x) => ( - - -

{x.name}

-
- - {x.comment !== null ? ( -

{x.comment}

+ {isSuccess && columns.length === 0 && filterString.length === 0 && ( + + +

No columns created yet

+

+ There are no columns in "{selectedTable?.schema}.{selectedTable?.name}" +

+
+
+ )} + + {isSuccess && + columns.length > 0 && + columns.map((column) => ( + + +
+

{column.name}

+ {column.comment !== null ? ( + + {column.comment} + + ) : null} +
+
+ + {column.data_type} + + + {column.format} + + + {column.is_nullable ? ( +
+ +
) : ( -

No description

+
+ +
)} -
- - {x.data_type} - - - {x.format} - - - {x.is_nullable ? ( - - ) : ( - - )} - - + + {!isSchemaLocked && isTableEntity && ( - - -
+ )} +
) } diff --git a/apps/studio/components/interfaces/Database/Tables/TableList.tsx b/apps/studio/components/interfaces/Database/Tables/TableList.tsx index 298047e4084..a8bf7d7106e 100644 --- a/apps/studio/components/interfaces/Database/Tables/TableList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/TableList.tsx @@ -1,24 +1,5 @@ import type { PostgresTable } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { noop } from 'lodash' -import { - Check, - Columns, - Copy, - Edit, - Eye, - Filter, - MoreVertical, - Plus, - Search, - Trash, - X, -} from 'lucide-react' -import Link from 'next/link' -import { useRouter } from 'next/router' -import { parseAsString, useQueryState } from 'nuqs' -import { useState } from 'react' - import { useParams } from 'common' import { buildTableEditorUrl } from 'components/grid/SupabaseGrid.utils' import AlertError from 'components/ui/AlertError' @@ -37,6 +18,12 @@ import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' +import { noop } from 'lodash' +import { Check, Copy, Edit, Eye, Filter, MoreVertical, Plus, Search, Trash, X } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { parseAsString, useQueryState } from 'nuqs' +import { useState } from 'react' import { Button, Card, @@ -47,12 +34,13 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, Label_Shadcn_, + Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, - Popover_Shadcn_, Table, TableBody, TableCell, + TableFooter, TableHead, TableHeader, TableRow, @@ -62,6 +50,7 @@ import { } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning' import { formatAllEntities } from './Tables.utils' @@ -318,17 +307,19 @@ export const TableList = ({ - Name - - Description + + Name - + + Columns + + Rows (Estimated) - + Size (Estimated) - + Realtime Enabled @@ -387,10 +378,6 @@ export const TableList = ({ - {/* [Alaister]: EntityTypeIcon supports PARTITIONED_TABLE, but formatAllEntities - doesn't distinguish between tables and partitioned tables yet. - Once the endpoint/formatAllEntities is updated to include partitioned tables, - EntityTypeIcon will automatically style them correctly. */} @@ -398,66 +385,70 @@ export const TableList = ({ - - {/* only show tooltips if required, to reduce noise */} - {x.name.length > 20 ? ( - - -

{x.name}

-
+ +
+ {/* only show tooltips if required, to reduce noise */} + {x.name.length > 20 ? ( + + +

{x.name}

+
- {x.name} -
+ {x.name} + + ) : ( +

{x.name}

+ )} + {x.comment !== null ? ( + + {x.comment} + + ) : null} +
+
+ +

+ {x.columns.length.toLocaleString()} +

+
+ + {x.rows !== undefined ? ( +

{x.rows.toLocaleString()}

) : ( -

{x.name}

+

–

)}
- - {x.comment !== null ? ( - - {x.comment} - - ) : ( -

No description

- )} -
- - {x.rows !== undefined ? x.rows.toLocaleString() : '-'} - - + {x.size !== undefined ? ( - {x.size} +

{x.size}

) : ( - '-' +

–

)}
- + {(realtimePublication?.tables ?? []).find( (table) => table.id === x.id ) ? (
- +
) : (
- +
)}
- @@ -554,6 +545,13 @@ export const TableList = ({ ))} + + + + {entities.length} {entities.length === 1 ? 'table' : 'tables'} + + +
diff --git a/apps/studio/pages/project/[ref]/database/tables/[id].tsx b/apps/studio/pages/project/[ref]/database/tables/[id].tsx index 94b4a029eda..e64808aa785 100644 --- a/apps/studio/pages/project/[ref]/database/tables/[id].tsx +++ b/apps/studio/pages/project/[ref]/database/tables/[id].tsx @@ -4,21 +4,21 @@ import DeleteConfirmationDialogs from 'components/interfaces/TableGridEditor/Del import { SidePanelEditor } from 'components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor' import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' import DefaultLayout from 'components/layouts/DefaultLayout' -import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' -import { FormHeader } from 'components/ui/Forms/FormHeader' +import { PageLayout } from 'components/layouts/PageLayout/PageLayout' import { useTableEditorQuery } from 'data/table-editor/table-editor-query' import { isTableLike } from 'data/table-editor/table-editor-types' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { ChevronRight } from 'lucide-react' import { useTableEditorStateSnapshot } from 'state/table-editor' import { TableEditorTableStateContextProvider } from 'state/table-editor-table' import type { NextPageWithLayout } from 'types' +import { PageContainer } from 'ui-patterns/PageContainer' +import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' const DatabaseTables: NextPageWithLayout = () => { const snap = useTableEditorStateSnapshot() - const { id: _id } = useParams() + const { id: _id, ref } = useParams() const id = _id ? Number(_id) : undefined const { data: project } = useSelectedProjectQuery() @@ -30,26 +30,28 @@ const DatabaseTables: NextPageWithLayout = () => { return ( <> - - -
-
- - - {isLoading ? ( - - ) : ( - - )} -
- -
-
-
+ : (selectedTable?.name ?? '')} + breadcrumbs={[ + { + label: 'Tables', + href: `/project/${ref}/database/tables`, + }, + ]} + size="large" + > + + + + + + + + {project?.ref !== undefined && selectedTable !== undefined && isTableLike(selectedTable) && ( { @@ -26,34 +21,29 @@ const DatabaseTables: NextPageWithLayout = () => { return ( <> - - - - Database Tables - - - - - - - { - setSelectedTableToEdit(postgresTableToEntity(table)) - snap.onEditTable() - }} - onDeleteTable={(table) => { - setSelectedTableToEdit(postgresTableToEntity(table)) - snap.onDeleteTable() - }} - onDuplicateTable={(table) => { - setSelectedTableToEdit(postgresTableToEntity(table)) - snap.onDuplicateTable() - }} - /> - - - + + + + + { + setSelectedTableToEdit(postgresTableToEntity(table)) + snap.onEditTable() + }} + onDeleteTable={(table) => { + setSelectedTableToEdit(postgresTableToEntity(table)) + snap.onDeleteTable() + }} + onDuplicateTable={(table) => { + setSelectedTableToEdit(postgresTableToEntity(table)) + snap.onDuplicateTable() + }} + /> + + + + {projectRef !== undefined && selectedTableToEdit !== undefined && diff --git a/e2e/studio/features/database.spec.ts b/e2e/studio/features/database.spec.ts index ace3323d0d0..d8dfcf2085a 100644 --- a/e2e/studio/features/database.spec.ts +++ b/e2e/studio/features/database.spec.ts @@ -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, diff --git a/e2e/studio/utils/wait-for-response.ts b/e2e/studio/utils/wait-for-response.ts index c40feb5566c..0a0ab48ad4e 100644 --- a/e2e/studio/utils/wait-for-response.ts +++ b/e2e/studio/utils/wait-for-response.ts @@ -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 }