mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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 &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user