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

@@ -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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Button asChild type="outline" icon={<ChevronLeft />} style={{ padding: '5px' }}>
<Link href={`/project/${ref}/database/tables`} />
</Button>
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div className="w-full lg:w-52">
<Input
size="small"
size="tiny"
placeholder="Filter columns"
value={filterString}
onChange={(e: any) => setFilterString(e.target.value)}
onChange={(e) => setFilterString(e.target.value)}
icon={<Search />}
/>
</div>
@@ -108,115 +113,163 @@ export const ColumnList = ({
<ProtectedSchemaWarning schema={selectedTable?.schema ?? ''} entity="columns" />
)}
{isLoading && <GenericSkeletonLoader />}
<Card>
{isLoading ? (
<div className="p-4">
<GenericSkeletonLoader />
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className={columns.length === 0 ? 'text-foreground-muted' : undefined}>
Name
</TableHead>
<TableHead className={columns.length === 0 ? 'text-foreground-muted' : undefined}>
Data Type
</TableHead>
<TableHead className={columns.length === 0 ? 'text-foreground-muted' : undefined}>
Format
</TableHead>
<TableHead
className={
columns.length === 0 ? 'text-right text-foreground-muted' : 'text-right'
}
>
Nullable
</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{isError && (
<TableRow className="[&>td]:hover:bg-inherit">
<TableCell colSpan={5}>
<AlertError
error={error}
subject={`Failed to retrieve columns for table "${selectedTable?.schema}.${selectedTable?.name}"`}
/>
</TableCell>
</TableRow>
)}
{isError && (
<AlertError
error={error as any}
subject={`Failed to retrieve columns for table "${selectedTable?.schema}.${selectedTable?.name}"`}
/>
)}
{isSuccess && columns.length === 0 && filterString.length > 0 && (
<TableRow className="[&>td]:hover:bg-inherit">
<TableCell colSpan={5}>
<NoSearchResults
withinTableCell
searchString={filterString}
onResetFilter={() => setFilterString('')}
/>
</TableCell>
</TableRow>
)}
{isSuccess && (
<>
{columns.length === 0 ? (
<NoSearchResults
searchString={filterString}
onResetFilter={() => setFilterString('')}
/>
) : (
<div>
<Table
head={[
<Table.th key="name">Name</Table.th>,
<Table.th key="description" className="hidden lg:table-cell">
Description
</Table.th>,
<Table.th key="type">Data Type</Table.th>,
<Table.th key="format">Format</Table.th>,
<Table.th key="format" className="text-center">
Nullable
</Table.th>,
<Table.th key="buttons"></Table.th>,
]}
body={columns.map((x) => (
<Table.tr className="border-t" key={x.name}>
<Table.td>
<p>{x.name}</p>
</Table.td>
<Table.td className="break-all whitespace-normal hidden xl:table-cell">
{x.comment !== null ? (
<p title={x.comment}>{x.comment}</p>
{isSuccess && columns.length === 0 && filterString.length === 0 && (
<TableRow className="[&>td]:hover:bg-inherit">
<TableCell colSpan={5}>
<p className="text-sm text-foreground">No columns created yet</p>
<p className="text-sm text-foreground-light">
There are no columns in "{selectedTable?.schema}.{selectedTable?.name}"
</p>
</TableCell>
</TableRow>
)}
{isSuccess &&
columns.length > 0 &&
columns.map((column) => (
<TableRow key={column.name}>
<TableCell>
<div className="flex min-w-0 flex-col">
<p>{column.name}</p>
{column.comment !== null ? (
<span
className="max-w-md truncate text-foreground-lighter"
title={column.comment}
>
{column.comment}
</span>
) : null}
</div>
</TableCell>
<TableCell>
<code className="text-code-inline">{column.data_type}</code>
</TableCell>
<TableCell className="font-mono text-xs">
<code className="text-code-inline">{column.format}</code>
</TableCell>
<TableCell className="text-right">
{column.is_nullable ? (
<div className="flex justify-end">
<Check size={16} strokeWidth={2} className="text-brand" />
</div>
) : (
<p className="text-border-stronger">No description</p>
<div className="flex justify-end">
<X size={16} strokeWidth={2} className="text-foreground-lighter" />
</div>
)}
</Table.td>
<Table.td>
<code className="text-code-inline">{x.data_type}</code>
</Table.td>
<Table.td className="font-mono text-xs">
<code className="text-code-inline">{x.format}</code>
</Table.td>
<Table.td className="font-mono text-xs">
{x.is_nullable ? (
<Check size={16} className="mx-auto" />
) : (
<X size={16} className="mx-auto" />
)}
</Table.td>
<Table.td className="text-right">
</TableCell>
<TableCell className="text-right">
{!isSchemaLocked && isTableEntity && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-32">
<Tooltip>
<TooltipTrigger>
<DropdownMenuItem
disabled={!canUpdateColumns}
onClick={() => onEditColumn(x)}
className="space-x-2"
>
<Edit size={12} />
<p>Edit column</p>
</DropdownMenuItem>
</TooltipTrigger>
{!canUpdateColumns && (
<TooltipContent side="bottom">
Additional permissions required to edit column
</TooltipContent>
)}
</Tooltip>
<Tooltip>
<TooltipTrigger>
<DropdownMenuItem
disabled={!canUpdateColumns || isSchemaLocked}
onClick={() => onDeleteColumn(x)}
className="space-x-2"
>
<Trash stroke="red" size={12} />
<p>Delete column</p>
</DropdownMenuItem>
</TooltipTrigger>
{!canUpdateColumns && (
<TooltipContent side="bottom">
Additional permissions required to delete column
</TooltipContent>
)}
</Tooltip>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex justify-end gap-2">
<ButtonTooltip
type="default"
disabled={!canUpdateColumns}
onClick={() => onEditColumn(column)}
tooltip={{
content: {
side: 'bottom',
text: !canUpdateColumns
? 'Additional permissions required to edit column'
: undefined,
},
}}
>
Edit
</ButtonTooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-32">
<Tooltip>
<TooltipTrigger>
<DropdownMenuItem
disabled={!canUpdateColumns || isSchemaLocked}
onClick={() => onDeleteColumn(column)}
className="space-x-2"
>
<Trash size={12} />
<p>Delete column</p>
</DropdownMenuItem>
</TooltipTrigger>
{!canUpdateColumns && (
<TooltipContent side="bottom">
Additional permissions required to delete column
</TooltipContent>
)}
</Tooltip>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</Table.td>
</Table.tr>
</TableCell>
</TableRow>
))}
/>
</div>
)}
</>
)}
</TableBody>
{isSuccess && (
<TableFooter className="font-normal">
<TableRow className="border-b-0 [&>td]:hover:bg-inherit">
<TableCell colSpan={5} className="text-foreground-muted">
{columns.length} {columns.length === 1 ? 'column' : 'columns'}
</TableCell>
</TableRow>
</TableFooter>
)}
</Table>
)}
</Card>
</div>
)
}
@@ -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 = ({
<TableHeader>
<TableRow>
<TableHead key="icon" className="!px-0" />
<TableHead key="name">Name</TableHead>
<TableHead key="description" className="hidden lg:table-cell">
Description
<TableHead key="name" className="max-w-[160px] sm:max-w-[280px]">
Name
</TableHead>
<TableHead key="rows" className="hidden text-right xl:table-cell">
<TableHead key="columns" className="text-right">
Columns
</TableHead>
<TableHead key="rows" className="text-right">
Rows (Estimated)
</TableHead>
<TableHead key="size" className="hidden text-right xl:table-cell">
<TableHead key="size" className="text-right">
Size (Estimated)
</TableHead>
<TableHead key="realtime" className="hidden xl:table-cell text-right">
<TableHead key="realtime" className="text-right">
Realtime Enabled
</TableHead>
<TableHead key="buttons"></TableHead>
@@ -387,10 +378,6 @@ export const TableList = ({
<TableCell className="!pl-5 !pr-1">
<Tooltip>
<TooltipTrigger className="cursor-default">
{/* [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. */}
<EntityTypeIcon type={x.type} />
</TooltipTrigger>
<TooltipContent side="bottom">
@@ -398,66 +385,70 @@ export const TableList = ({
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell>
{/* only show tooltips if required, to reduce noise */}
{x.name.length > 20 ? (
<Tooltip disableHoverableContent={true}>
<TooltipTrigger
asChild
className="max-w-[95%] overflow-hidden text-ellipsis whitespace-nowrap"
>
<p>{x.name}</p>
</TooltipTrigger>
<TableCell className="max-w-[160px] sm:max-w-[280px]">
<div className="flex min-w-0 flex-col">
{/* only show tooltips if required, to reduce noise */}
{x.name.length > 20 ? (
<Tooltip disableHoverableContent={true}>
<TooltipTrigger
asChild
className="max-w-[95%] overflow-hidden text-ellipsis whitespace-nowrap"
>
<p>{x.name}</p>
</TooltipTrigger>
<TooltipContent side="bottom">{x.name}</TooltipContent>
</Tooltip>
<TooltipContent side="bottom">{x.name}</TooltipContent>
</Tooltip>
) : (
<p>{x.name}</p>
)}
{x.comment !== null ? (
<span
className="max-w-md truncate text-foreground-lighter"
title={x.comment}
>
{x.comment}
</span>
) : null}
</div>
</TableCell>
<TableCell className="text-right">
<p className="text-foreground-light">
{x.columns.length.toLocaleString()}
</p>
</TableCell>
<TableCell className="text-right">
{x.rows !== undefined ? (
<p className="text-foreground-light">{x.rows.toLocaleString()}</p>
) : (
<p>{x.name}</p>
<p className="text-foreground-muted">–</p>
)}
</TableCell>
<TableCell className="hidden lg:table-cell ">
{x.comment !== null ? (
<span className="lg:max-w-48 truncate inline-block" title={x.comment}>
{x.comment}
</span>
) : (
<p className="text-border-stronger">No description</p>
)}
</TableCell>
<TableCell className="hidden text-right xl:table-cell">
{x.rows !== undefined ? x.rows.toLocaleString() : '-'}
</TableCell>
<TableCell className="hidden text-right xl:table-cell">
<TableCell className="text-right">
{x.size !== undefined ? (
<code className="text-code-inline">{x.size}</code>
<p className="text-foreground-light">{x.size}</p>
) : (
'-'
<p className="text-foreground-muted">–</p>
)}
</TableCell>
<TableCell className="hidden xl:table-cell text-center">
<TableCell className="text-right">
{(realtimePublication?.tables ?? []).find(
(table) => table.id === x.id
) ? (
<div className="flex justify-end">
<Check size={18} strokeWidth={2} className="text-brand" />
<Check size={16} strokeWidth={2} className="text-brand" />
</div>
) : (
<div className="flex justify-end">
<X size={18} strokeWidth={2} className="text-foreground-lighter" />
<X size={16} strokeWidth={2} className="text-foreground-lighter" />
</div>
)}
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<Button
asChild
type="default"
iconRight={<Columns size={14} className="text-foreground-light" />}
className="whitespace-nowrap hover:border-muted"
style={{ paddingTop: 3, paddingBottom: 3 }}
>
<Button asChild type="default">
<Link href={`/project/${ref}/database/tables/${x.id}`}>
{x.columns.length} columns
View columns
</Link>
</Button>
@@ -554,6 +545,13 @@ export const TableList = ({
))}
</>
</TableBody>
<TableFooter className="font-normal">
<TableRow className="border-b-0 [&>td]:hover:bg-inherit">
<TableCell colSpan={7} className="text-foreground-muted">
{entities.length} {entities.length === 1 ? 'table' : 'tables'}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</Card>
</div>
@@ -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 (
<>
<ScaffoldContainer>
<ScaffoldSection>
<div className="col-span-12 space-y-6">
<div className="flex items-center space-x-2">
<FormHeader className="!mb-0 !w-fit !whitespace-nowrap" title="Database Tables" />
<ChevronRight size={18} strokeWidth={1.5} className="text-foreground-light" />
{isLoading ? (
<ShimmeringLoader className="w-40" />
) : (
<FormHeader className="!mb-0" title={selectedTable?.name ?? ''} />
)}
</div>
<ColumnList
onAddColumn={snap.onAddColumn}
onEditColumn={snap.onEditColumn}
onDeleteColumn={snap.onDeleteColumn}
/>
</div>
</ScaffoldSection>
</ScaffoldContainer>
<PageLayout
title={isLoading ? <ShimmeringLoader className="w-40" /> : (selectedTable?.name ?? '')}
breadcrumbs={[
{
label: 'Tables',
href: `/project/${ref}/database/tables`,
},
]}
size="large"
>
<PageContainer size="large">
<PageSection>
<PageSectionContent>
<ColumnList
onAddColumn={snap.onAddColumn}
onEditColumn={snap.onEditColumn}
onDeleteColumn={snap.onDeleteColumn}
/>
</PageSectionContent>
</PageSection>
</PageContainer>
</PageLayout>
{project?.ref !== undefined && selectedTable !== undefined && isTableLike(selectedTable) && (
<TableEditorTableStateContextProvider
@@ -5,18 +5,13 @@ 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 { PageLayout } from 'components/layouts/PageLayout/PageLayout'
import { Entity, isTableLike, postgresTableToEntity } from 'data/table-editor/table-editor-types'
import { useState } from '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 {
PageHeader,
PageHeaderMeta,
PageHeaderSummary,
PageHeaderTitle,
} from 'ui-patterns/PageHeader'
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
const DatabaseTables: NextPageWithLayout = () => {
@@ -26,34 +21,29 @@ const DatabaseTables: NextPageWithLayout = () => {
return (
<>
<PageHeader size="large">
<PageHeaderMeta>
<PageHeaderSummary>
<PageHeaderTitle>Database Tables</PageHeaderTitle>
</PageHeaderSummary>
</PageHeaderMeta>
</PageHeader>
<PageContainer size="large">
<PageSection>
<PageSectionContent>
<TableList
onAddTable={snap.onAddTable}
onEditTable={(table) => {
setSelectedTableToEdit(postgresTableToEntity(table))
snap.onEditTable()
}}
onDeleteTable={(table) => {
setSelectedTableToEdit(postgresTableToEntity(table))
snap.onDeleteTable()
}}
onDuplicateTable={(table) => {
setSelectedTableToEdit(postgresTableToEntity(table))
snap.onDuplicateTable()
}}
/>
</PageSectionContent>
</PageSection>
</PageContainer>
<PageLayout title="Database Tables" size="large">
<PageContainer size="large">
<PageSection>
<PageSectionContent>
<TableList
onAddTable={snap.onAddTable}
onEditTable={(table) => {
setSelectedTableToEdit(postgresTableToEntity(table))
snap.onEditTable()
}}
onDeleteTable={(table) => {
setSelectedTableToEdit(postgresTableToEntity(table))
snap.onDeleteTable()
}}
onDuplicateTable={(table) => {
setSelectedTableToEdit(postgresTableToEntity(table))
snap.onDuplicateTable()
}}
/>
</PageSectionContent>
</PageSection>
</PageContainer>
</PageLayout>
{projectRef !== undefined &&
selectedTableToEdit !== undefined &&
+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
}