data viewer

This commit is contained in:
Francesco Sansalvadore committed 2026-03-19 18:29:34 +01:00
1 parent 567cc96c65
commit 57c4681765
19 files changed
+515 -141

No files matched your search

@@ -1,10 +0,0 @@
import { redirect } from 'next/navigation'
export default async function V2ProjectShortcutPage({
params,
}: {
params: Promise<{ projectRef: string }>
}) {
const { projectRef } = await params
redirect(`/dashboard/v2/project/${projectRef}`)
}
@@ -5,6 +5,7 @@ import { useMemo } from 'react'
import { V2ParamsProvider } from '@/app/v2/V2ParamsContext'
import { Shell } from '@/components/v2/Shell'
import { ProjectContextProvider } from '@/components/layouts/ProjectLayout/ProjectContext'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
@@ -26,7 +27,9 @@ export function ProjectShell({ children }: { children: React.ReactNode }) {
return (
<V2ParamsProvider projectRef={projectRef} orgSlug={orgSlug}>
<Shell>{children}</Shell>
<ProjectContextProvider projectRef={projectRef}>
<Shell>{children}</Shell>
</ProjectContextProvider>
</V2ParamsProvider>
)
}
@@ -5,6 +5,7 @@ import { useParams, usePathname } from 'next/navigation'
import { cn } from 'ui'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { StudioDataWorkspace } from '@/components/v2/data/StudioDataWorkspace'
const SUB_TABS = [
{ slug: 'data', label: 'Data' },
@@ -27,28 +28,30 @@ export default function TableDetailLayout({
const base = projectRef ? `/dashboard/v2/project/${projectRef}/data/tables/${tableId}` : ''
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-1 px-4 py-2 border-b border-border shrink-0">
{SUB_TABS.map((tab) => {
const href = `${base}/${tab.slug}`
const isActive = pathname === href || pathname?.startsWith(href + '/')
return (
<Link
key={tab.slug}
href={href}
className={cn(
'px-3 py-1.5 text-xs rounded',
isActive
? 'bg-sidebar-accent text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground'
)}
>
{tab.label}
</Link>
)
})}
<StudioDataWorkspace projectRef={projectRef} id={tableId}>
<div className="flex flex-col h-full">
<div className="flex items-center gap-1 px-4 py-2 border-b border-border shrink-0">
{SUB_TABS.map((tab) => {
const href = `${base}/${tab.slug}`
const isActive = pathname === href || pathname?.startsWith(href + '/')
return (
<Link
key={tab.slug}
href={href}
className={cn(
'px-3 py-1.5 text-xs rounded',
isActive
? 'bg-sidebar-accent text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground'
)}
>
{tab.label}
</Link>
)
})}
</div>
<div className="flex-1 min-h-0 overflow-auto">{children}</div>
</div>
<div className="flex-1 min-h-0 overflow-auto">{children}</div>
</div>
</StudioDataWorkspace>
)
}
@@ -1,7 +1,7 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useCallback } from 'react'
import { useRouter } from 'next/compat/router'
import { useParams } from 'common'
import { SupabaseGrid } from 'components/grid/SupabaseGrid'
@@ -30,11 +30,13 @@ import { TableDefinition } from './TableDefinition'
export interface TableGridEditorProps {
isLoadingSelectedTable?: boolean
selectedTable?: Entity
variant?: 'pages' | 'v2'
}
export const TableGridEditor = ({
isLoadingSelectedTable = false,
selectedTable,
variant = 'pages',
}: TableGridEditorProps) => {
const router = useRouter()
const { ref: projectRef, id } = useParams()
@@ -63,26 +65,48 @@ export const TableGridEditor = ({
const onTableCreated = useCallback(
(table: { id: number }) => {
if (variant === 'v2') {
const base = projectRef ? `/dashboard/v2/project/${projectRef}` : ''
router.push(
`${base}/data/tables/${table.id}/data${
!!selectedSchema ? `?schema=${selectedSchema}` : ''
}`
)
return
}
router.push(
`/project/${projectRef}/editor/${table.id}${!!selectedSchema ? `?schema=${selectedSchema}` : ''}`
`/project/${projectRef}/editor/${table.id}${
!!selectedSchema ? `?schema=${selectedSchema}` : ''
}`
)
},
[projectRef, router]
[projectRef, router, selectedSchema, variant]
)
const onTableDeleted = useCallback(async () => {
// For simplicity for now, we just open the first table within the same schema
if (selectedTable) {
// Close tab
const tabId = createTabId(selectedTable.entity_type, { id: selectedTable.id })
tabs.handleTabClose({
id: tabId,
router,
editor: 'table',
onClearDashboardHistory: () => setLastVisitedTable(undefined),
})
if (!selectedTable) return
// For now, just close the deleted table tab.
// v2 uses v2 URLs, pages uses the legacy tab-close router logic.
const tabId = createTabId(selectedTable.entity_type, { id: selectedTable.id })
if (variant === 'v2') {
tabs.removeTab(tabId)
setLastVisitedTable(undefined)
const base = projectRef ? `/dashboard/v2/project/${projectRef}` : ''
router.push(`${base}/data/tables`)
return
}
}, [router, selectedTable, tabs])
tabs.handleTabClose({
id: tabId,
router,
editor: 'table',
onClearDashboardHistory: () => setLastVisitedTable(undefined),
})
}, [projectRef, router, selectedTable, tabs, setLastVisitedTable, variant])
const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedTable?.schema ?? '' })
@@ -126,7 +150,44 @@ export const TableGridEditor = ({
title={`Unable to find your table with ID ${id}`}
description="This table doesn't exist in your database"
>
{!!tabId ? (
{variant === 'v2' ? (
!!tabId ? (
<Button
type="default"
className="mt-2"
onClick={() => {
tabs.removeTab(tabId)
setLastVisitedTable(undefined)
const base = projectRef ? `/dashboard/v2/project/${projectRef}` : ''
router.push(`${base}/data/tables`)
}}
>
Close tab
</Button>
) : openTabs.length > 0 ? (
<Button
asChild
type="default"
className="mt-2"
onClick={() => setLastVisitedTable(undefined)}
>
<Link
href={`/dashboard/v2/project/${projectRef}/data/tables/${openTabs[0].split('-')[1]}/data`}
>
Close tab
</Link>
</Button>
) : (
<Button
asChild
type="default"
className="mt-2"
onClick={() => setLastVisitedTable(undefined)}
>
<Link href={`/dashboard/v2/project/${projectRef}/data/tables`}>Head back</Link>
</Button>
)
) : !!tabId ? (
<Button
type="default"
className="mt-2"
+7 -7
View File
@@ -1,7 +1,7 @@
'use client'
import { useProjectLintsQuery } from 'data/lint/lint-query'
import { Database, Home, LayoutDashboard, Settings } from 'lucide-react'
import { ChartArea, Database, Home, Settings, Table } from 'lucide-react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
@@ -38,7 +38,7 @@ export function ActivityBar({
return (
<aside
className={cn(
'w-11 flex flex-col shrink-0 border-border bg-background',
'w-11 flex flex-col shrink-0 border-border bg-dash-sidebar',
side === 'left' && 'border-r',
side === 'right' && 'border-l'
)}
@@ -53,7 +53,7 @@ export function ActivityBar({
<Link
href={item.href}
className={cn(
'relative flex items-center justify-center w-full aspect-square rounded-lg shrink-0 text-muted-foreground hover:text-foreground hover:bg-sidebar-accent',
'relative flex items-center justify-center w-full aspect-square rounded-lg shrink-0 text-foreground-lighter hover:text-foreground hover:bg-sidebar-accent',
isActive && 'bg-sidebar-accent text-foreground',
side === 'left' && isActive && 'border-1 border-foreground',
side === 'right' && isActive && 'border-1 border-foreground'
@@ -71,7 +71,7 @@ export function ActivityBar({
type="button"
onClick={() => onSelect?.(item.id)}
className={cn(
'relative flex items-center justify-center w-full aspect-square rounded-lg shrink-0 text-muted-foreground hover:text-foreground hover:bg-sidebar-accent',
'relative flex items-center justify-center w-full aspect-square rounded-lg shrink-0 text-foreground-lighter hover:text-foreground hover:bg-sidebar-accent',
isActive && 'bg-sidebar-accent text-foreground',
side === 'left' && isActive && 'border-l-2 border-l-foreground',
side === 'right' && isActive && 'border-r-2 border-r-foreground'
@@ -80,7 +80,7 @@ export function ActivityBar({
<span className="relative">
{item.icon}
{item.badge && (
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-amber-500" />
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-red-500" />
)}
</span>
</button>
@@ -145,13 +145,13 @@ export function LeftActivityBar() {
},
{
id: 'data',
icon: <Database className="h-4 w-4" strokeWidth={1.5} />,
icon: <Table className="h-4 w-4" strokeWidth={1.5} />,
label: 'Data',
href: `${base}/data/tables`,
},
{
id: 'obs',
icon: <LayoutDashboard className="h-4 w-4" strokeWidth={1.5} />,
icon: <ChartArea className="h-4 w-4" strokeWidth={1.5} />,
label: 'Observability',
badge: hasAdvisorWarnings,
href: `${base}/obs/logs`,
+3 -3
View File
@@ -184,7 +184,7 @@ export function BrowserPanel() {
const groups = isData ? DATA_GROUPS : isObs ? OBS_GROUPS : isSettings ? SETTINGS_GROUPS : []
return (
<div className="w-full h-full flex flex-col border-r border-border bg-background">
<div className="w-full h-full flex flex-col border-r border-border bg-dash-sidebar">
<div className="px-3 py-2 border-b border-border text-sm font-medium text-foreground">
{title}
</div>
@@ -234,7 +234,7 @@ export function BrowserPanel() {
>
<span className="truncate">{item.label}</span>
{'countKey' in item && (
<span className="text-xs text-muted-foreground shrink-0">
<span className="text-xs text-foreground-muted shrink-0">
{getCount(counts, String(item.countKey))}
</span>
)}
@@ -246,7 +246,7 @@ export function BrowserPanel() {
})}
{isData && (
<div className="px-3 py-2 mt-2">
<Link href="#" className="text-xs text-muted-foreground hover:text-foreground">
<Link href="#" className="text-xs text-foreground-muted hover:text-foreground">
+ Add module
</Link>
</div>
+192
View File
@@ -0,0 +1,192 @@
'use client'
import { ArrowDown, ArrowUp } from 'lucide-react'
import { useCallback, useMemo, useState, type ReactNode } from 'react'
import DataGrid, { type Column, Row } from 'react-data-grid'
import { cn } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
export type DataListColumnDef<TRow> = {
key: string
name: string
minWidth?: number
/** When set, clicking the header sorts by this accessor */
sortValue?: (row: TRow) => string | number | boolean | null | undefined
renderCell: (row: TRow) => ReactNode
}
export type DataListGridProps<TRow> = {
/** Top chrome (schema picker, search, actions) — same row height as Table Editor header */
toolbar?: ReactNode
/** Optional tab strip under the toolbar (e.g. Data / Definition later) */
tabs?: ReactNode
columns: DataListColumnDef<TRow>[]
rows: TRow[]
rowKeyGetter: (row: TRow) => string | number
isLoading?: boolean
emptyMessage?: ReactNode
onRowClick?: (row: TRow) => void
onRowDoubleClick?: (row: TRow) => void
/** Status text on the right of the toolbar (e.g. row count) */
toolbarTrailing?: ReactNode
className?: string
}
function compareSortValues(
a: string | number | boolean | null | undefined,
b: string | number | boolean | null | undefined
): number {
if (a === b) return 0
if (a == null) return 1
if (b == null) return -1
if (typeof a === 'number' && typeof b === 'number') return a - b
return String(a).localeCompare(String(b), undefined, { sensitivity: 'base' })
}
/**
* Read-only list grid with the same shell classes as Table Editor / SupabaseGrid
* (`sb-grid`, header chrome). Intended for v2 Data layer list routes before a
* fuller generic DataView exists.
*/
export function DataListGrid<TRow>({
toolbar,
tabs,
columns: columnDefs,
rows,
rowKeyGetter,
isLoading = false,
emptyMessage,
onRowClick,
onRowDoubleClick,
toolbarTrailing,
className,
}: DataListGridProps<TRow>) {
const [sortKey, setSortKey] = useState<string | null>(null)
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
const sortableColumn = useMemo(
() => columnDefs.find((c) => c.key === sortKey),
[columnDefs, sortKey]
)
const sortedRows = useMemo(() => {
if (!sortKey || !sortableColumn?.sortValue) return rows
const accessor = sortableColumn.sortValue
const next = [...rows].sort((ra, rb) => {
const cmp = compareSortValues(accessor(ra), accessor(rb))
return sortDir === 'asc' ? cmp : -cmp
})
return next
}, [rows, sortKey, sortableColumn, sortDir])
const onHeaderClick = useCallback(
(key: string) => {
const def = columnDefs.find((c) => c.key === key)
if (!def?.sortValue) return
if (sortKey !== key) {
setSortKey(key)
setSortDir('asc')
} else {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
}
},
[columnDefs, sortKey]
)
const columns: Column<TRow>[] = useMemo(
() =>
columnDefs.map((col) => ({
key: col.key,
name: col.name,
resizable: true,
minWidth: col.minWidth ?? 120,
sortable: false,
headerCellClass: 'sb-grid-column-header sb-grid-column-header--cursor',
renderHeaderCell: () => {
const active = sortKey === col.key
const canSort = Boolean(col.sortValue)
return (
<button
type="button"
className={cn(
'sb-grid-column-header flex h-full w-full items-center px-2 text-left',
canSort && 'hover:bg-surface-200/80 dark:hover:bg-surface-200/20'
)}
disabled={!canSort}
onClick={() => canSort && onHeaderClick(col.key)}
>
<div className="sb-grid-column-header__inner min-w-0 flex-1">
<span className="sb-grid-column-header__inner__name">{col.name}</span>
</div>
{canSort && active ? (
sortDir === 'asc' ? (
<ArrowUp className="h-3.5 w-3.5 shrink-0 text-foreground-light" />
) : (
<ArrowDown className="h-3.5 w-3.5 shrink-0 text-foreground-light" />
)
) : null}
</button>
)
},
renderCell: (props) => (
<div className="flex h-full w-full items-center px-2 text-xs">{col.renderCell(props.row)}</div>
),
})),
[columnDefs, sortKey, sortDir, onHeaderClick]
)
return (
<div className={cn('sb-grid flex h-full min-h-0 flex-col', className)}>
{(toolbar != null || toolbarTrailing != null) && (
<div className="flex h-10 shrink-0 items-center justify-between gap-2 overflow-x-auto border-b border-border bg-dash-sidebar px-1.5 py-1.5 dark:bg-surface-100">
<div className="flex min-w-0 flex-1 items-center gap-2">{toolbar}</div>
{toolbarTrailing != null ? (
<div className="shrink-0 text-xs text-muted-foreground">{toolbarTrailing}</div>
) : null}
</div>
)}
{tabs != null ? (
<div className="flex h-9 shrink-0 items-center border-b border-border px-2">{tabs}</div>
) : null}
<div className="relative min-h-0 flex-1">
<DataGrid
className="h-full flex-1"
style={{ height: '100%' }}
rowHeight={36}
headerRowHeight={34}
columns={columns}
rows={sortedRows}
rowKeyGetter={(row) => rowKeyGetter(row)}
rowClass={() =>
cn(
'[&>.rdg-cell]:flex [&>.rdg-cell]:items-center',
'[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none',
(onRowClick || onRowDoubleClick) && 'cursor-pointer'
)
}
renderers={{
renderRow(_, props) {
return (
<Row
key={rowKeyGetter(props.row)}
{...props}
onClick={() => onRowClick?.(props.row)}
onDoubleClick={() => onRowDoubleClick?.(props.row)}
/>
)
},
noRowsFallback: isLoading ? (
<div className="absolute top-10 w-full px-4">
<GenericSkeletonLoader />
</div>
) : (
<div className="flex h-full items-center justify-center p-8 text-center text-sm text-muted-foreground">
{emptyMessage ?? 'No rows'}
</div>
),
}}
/>
</div>
</div>
)
}
+6 -1
View File
@@ -39,7 +39,12 @@ export function EditorFrame({ children }: { children: React.ReactNode }) {
<div className="flex flex-col flex-1 min-w-0 bg-background">
{isDataCategory ? null : isHome ? null : <StaticTitle />}
{isDataCategory && <TabBar />}
<div className="flex-1 flex flex-col min-h-0 overflow-auto">
<div
className={cn(
'flex min-h-0 flex-1 flex-col',
isDataCategory ? 'overflow-hidden' : 'overflow-auto'
)}
>
{children}
</div>
<V2EditorFooter />
Whitespace-only changes.
@@ -3,7 +3,6 @@
import { AdvisorButton } from 'components/layouts/AppLayout/AdvisorButton'
import { AssistantButton } from 'components/layouts/AppLayout/AssistantButton'
import { InlineEditorButton } from 'components/layouts/AppLayout/InlineEditorButton'
import { HelpButton } from 'components/ui/HelpPanel/HelpButton'
import { useV2Params } from '@/app/v2/V2ParamsContext'
@@ -12,7 +11,7 @@ export function RightActivityBar() {
const { projectRef } = useV2Params()
return (
<aside className="w-11 flex flex-col shrink-0 border-l border-border bg-background py-2 h-full">
<aside className="w-11 flex flex-col shrink-0 border-l border-border bg-dash-sidebar py-2 h-full">
<div className="flex flex-col items-center gap-2">
<AssistantButton />
<InlineEditorButton />
+1 -1
View File
@@ -30,7 +30,7 @@ export function Shell({ children }: { children: React.ReactNode }) {
<SidebarProvider defaultOpen={false}>
<MobileSheetProvider>
<V2LayoutSidebarProvider>
<div className="flex flex-col h-screen w-screen bg-background text-foreground">
<div className="flex flex-col h-screen w-screen bg-dash-sidebar text-foreground">
<TopBar />
<div className="flex flex-1 min-h-0">
<LeftActivityBar />
+2 -2
View File
@@ -2,12 +2,12 @@
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { Button, cn } from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { V2BranchSelector } from './V2BranchSelector'
import { V2OrgProjectSelector } from './V2OrgProjectSelector'
import { useV2Params } from '@/app/v2/V2ParamsContext'
export function TopBar() {
const { orgSlug, projectRef } = useV2Params()
@@ -23,7 +23,7 @@ export function TopBar() {
return (
<header
className={cn(
'h-[38px] flex items-center justify-between px-2 border-b border-border bg-background shrink-0'
'h-11 md:h-12 flex items-center justify-between px-2 border-b border-border bg-dash-sidebar shrink-0'
)}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
@@ -0,0 +1,40 @@
'use client'
import { type ReactNode } from 'react'
import { RouteParamsOverrideProvider, type RouteParamsOverride } from 'common'
/**
* Shared provider wrapper for v2 `/data/*` routes.
*
* Why: the existing `TableGridEditor` / grid hooks were built around
* `common/useParams()` (Pages Router + `next/compat/router` query). On v2
* (App Router), we bridge the needed params (`ref`, `id`, etc.) via an
* override context so the grid stack can run unchanged.
*
* Future extension points:
* - render different list surfaces (tabs, filtering, sorting)
* - swap editable vs read-only grid presentations
* - plug in per-entity loaders (tables, functions, types, roles, ...)
*/
export function StudioDataWorkspace({
projectRef,
id,
additionalParams,
children,
}: {
projectRef?: string
/** Mapped to the legacy `id` param used by the grid stack. */
id?: string
additionalParams?: RouteParamsOverride
children: ReactNode
}) {
const value: RouteParamsOverride = {
ref: projectRef,
id,
...(additionalParams ?? {}),
}
return <RouteParamsOverrideProvider value={value}>{children}</RouteParamsOverrideProvider>
}
@@ -3,6 +3,8 @@
import { useParams } from 'next/navigation'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { TableGridEditor } from 'components/interfaces/TableGridEditor/TableGridEditor'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
import { useTableEditorQuery } from 'data/table-editor/table-editor-query'
@@ -35,6 +37,14 @@ export function V2TableDetailView({ subTab }: { subTab: string }) {
return <ShimmeringLoader className="m-4 h-8 rounded" />
}
if (subTab === 'data') {
return (
<div className="h-full min-h-0">
<TableGridEditor isLoadingSelectedTable={isPending} selectedTable={table} variant="v2" />
</div>
)
}
return (
<div className="p-4">
<h2 className="text-sm font-medium text-foreground mb-2">
@@ -1,5 +1,6 @@
'use client'
import type { PostgresTable } from '@supabase/postgres-meta'
import Link from 'next/link'
import { useMemo, useState } from 'react'
import {
@@ -9,11 +10,13 @@ import {
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Input,
Badge,
} from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { DataListGrid, type DataListColumnDef } from '@/components/v2/DataListGrid'
import { useV2DashboardStore } from '@/stores/v2-dashboard'
import { isValidConnString } from 'data/fetchers'
import { useSchemasQuery } from 'data/database/schemas-query'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
import { useTablesQuery } from 'data/tables/tables-query'
@@ -24,22 +27,25 @@ export function V2TablesList() {
const [schema, setSchema] = useState('public')
const [search, setSearch] = useState('')
const { data: project } = useProjectDetailQuery(
const { data: project, isPending: isProjectPending } = useProjectDetailQuery(
{ ref: projectRef },
{ enabled: Boolean(projectRef) }
)
const shouldFetch = Boolean(projectRef) && isValidConnString(project?.connectionString)
const { data: schemas } = useSchemasQuery(
{ projectRef, connectionString: project?.connectionString },
{ enabled: Boolean(projectRef) }
{ enabled: shouldFetch }
)
const { data: tables, isPending, isError } = useTablesQuery(
const { data: tables, isPending: isTablesPending, isError: isTablesError } = useTablesQuery(
{
projectRef,
connectionString: project?.connectionString,
schema,
includeColumns: false,
},
{ enabled: Boolean(projectRef) }
{ enabled: shouldFetch }
)
const filtered = useMemo(() => {
@@ -51,75 +57,98 @@ export function V2TablesList() {
const base = projectRef ? `/dashboard/v2/project/${projectRef}` : ''
const handleRowClick = (tableId: string, tableName: string) => {
const path = `${base}/data/tables/${tableId}`
addDetailTab({ id: tableId, label: tableName, path })
const openTable = (table: PostgresTable) => {
const path = `${base}/data/tables/${table.id}`
addDetailTab({ id: String(table.id), label: table.name, path })
}
if (isError) {
return (
<div className="p-4 text-destructive text-sm">Failed to load tables.</div>
)
const columns = useMemo<DataListColumnDef<PostgresTable>[]>(
() => [
{
key: 'name',
name: 'Name',
minWidth: 220,
sortValue: (row) => row.name,
renderCell: (row) => (
<Link
href={`${base}/data/tables/${row.id}`}
onClick={() => openTable(row)}
className="truncate font-mono text-foreground hover:underline"
>
{row.name}
</Link>
),
},
{
key: 'schema',
name: 'Schema',
minWidth: 140,
sortValue: (row) => row.schema ?? schema,
renderCell: (row) => (
<span className="truncate text-muted-foreground">{row.schema ?? schema}</span>
),
},
{
key: 'rls',
name: 'RLS',
minWidth: 100,
sortValue: (row) => (row.rls_enabled ? 1 : 0),
renderCell: (row) =>
row.rls_enabled ? (
<Badge variant="success">On</Badge>
) : (
<span className="text-muted-foreground">Off</span>
),
},
],
[base, schema]
)
if (isTablesError) {
return <div className="p-4 text-destructive text-sm">Failed to load tables.</div>
}
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-2 px-4 py-2 border-b border-border shrink-0">
<Select_Shadcn_ value={schema} onValueChange={setSchema}>
<SelectTrigger_Shadcn_ className="w-[140px] h-8 text-xs">
<SelectValue_Shadcn_ />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{schemas?.map((s) => (
<SelectItem_Shadcn_ key={s.name} value={s.name}>
{s.name}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
<Input
placeholder="Search tables..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-[200px] h-8 text-xs"
/>
<span className="text-xs text-muted-foreground ml-auto">
{Array.isArray(tables) ? tables.length : 0} tables
</span>
</div>
<div className="flex-1 overflow-auto p-4">
{isPending ? (
<ShimmeringLoader className="h-8 w-full rounded" />
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-muted-foreground">
<th className="pb-2 pr-4 font-medium">Name</th>
<th className="pb-2 font-medium">Schema</th>
</tr>
</thead>
<tbody>
{filtered.map((table) => (
<tr
key={table.id}
className="border-b border-border/50 hover:bg-sidebar-accent/50"
>
<td className="py-2 pr-4">
<Link
href={`${base}/data/tables/${table.id}`}
onClick={() => handleRowClick(String(table.id), table.name)}
className="text-foreground hover:underline"
>
{table.name}
</Link>
</td>
<td className="py-2 text-muted-foreground">{table.schema ?? schema}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="flex h-full min-h-0 flex-col">
<DataListGrid<PostgresTable>
toolbar={
<>
<Select_Shadcn_ value={schema} onValueChange={setSchema}>
<SelectTrigger_Shadcn_ className="h-8 w-[140px] text-xs">
<SelectValue_Shadcn_ />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{schemas?.map((s) => (
<SelectItem_Shadcn_ key={s.name} value={s.name}>
{s.name}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
<Input
placeholder="Filter tables…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 max-w-[240px] text-xs"
/>
</>
}
toolbarTrailing={
<>
{Array.isArray(tables) ? tables.length : 0} table
{Array.isArray(tables) && tables.length === 1 ? '' : 's'}
{search.trim() ? ` · ${filtered.length} shown` : null}
</>
}
columns={columns}
rows={filtered}
rowKeyGetter={(row) => row.id}
isLoading={isProjectPending || !shouldFetch || isTablesPending}
emptyMessage={
search.trim() ? 'No tables match your filter.' : 'No tables in this schema.'
}
onRowDoubleClick={(row) => openTable(row)}
/>
</div>
)
}
+11 -6
View File
@@ -1,5 +1,5 @@
import useLatest from 'hooks/misc/useLatest'
import { useRouter } from 'next/router'
import { useRouter } from 'next/compat/router'
import { type Dispatch, type SetStateAction, useCallback, useMemo } from 'react'
export type UrlStateParams = {
@@ -21,10 +21,13 @@ export function useUrlState<ValueParams extends UrlStateParams>({
// eslint-disable-next-line react-hooks/exhaustive-deps
const arrayKeysSet = useMemo(() => new Set(arrayKeys), [stringifiedArrayKeys])
const router = useRouter()
const query = router?.query ?? {}
const pathname =
router?.pathname ?? (typeof window !== 'undefined' ? window.location.pathname : '')
const params: ValueParams = useMemo(() => {
return Object.fromEntries(
Object.entries(router.query).map(([key, value]) => {
Object.entries(query).map(([key, value]) => {
if (arrayKeysSet.has(key)) {
return Array.isArray(value) ? [key, value] : [key, [value]]
}
@@ -32,7 +35,7 @@ export function useUrlState<ValueParams extends UrlStateParams>({
return [key, value]
})
)
}, [arrayKeysSet, router.query])
}, [arrayKeysSet, query])
const paramsRef = useLatest(params)
@@ -45,11 +48,13 @@ export function useUrlState<ValueParams extends UrlStateParams>({
Object.entries({ ...params, ...nextParams }).filter(([, value]) => Boolean(value))
)
const replaceOrPush = replace ? router.replace : router.push
const replaceOrPush = replace ? router?.replace : router?.push
if (!replaceOrPush || !pathname) return
replaceOrPush(
{
pathname: router.pathname,
pathname,
query: newQuery,
},
undefined,
@@ -57,7 +62,7 @@ export function useUrlState<ValueParams extends UrlStateParams>({
)
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[router, replace]
[router, replace, pathname]
)
return [params, setParams] as const
@@ -0,0 +1,26 @@
'use client'
import { createContext, type PropsWithChildren, useContext, useMemo } from 'react'
export type RouteParamsOverride = Record<string, string | undefined>
const RouteParamsOverrideContext = createContext<RouteParamsOverride | undefined>(undefined)
export function RouteParamsOverrideProvider({
value,
children,
}: PropsWithChildren<{ value: RouteParamsOverride }>) {
// Keep referential stability for consumers.
const memoValue = useMemo(() => value, [value])
return (
<RouteParamsOverrideContext.Provider value={memoValue}>
{children}
</RouteParamsOverrideContext.Provider>
)
}
export function useRouteParamsOverride() {
return useContext(RouteParamsOverrideContext)
}
+1
View File
@@ -11,6 +11,7 @@ export * from './useEffectEvent'
export * from './useIsomorphicLayoutEffect'
export * from './useOnChange'
export * from './useParams'
export * from './RouteParamsOverrideContext'
export * from './useSearchParamsShallow'
export * from './useTelemetryCookie'
export * from './useThemeSandbox'
+12 -2
View File
@@ -12,6 +12,8 @@
import { useRouter } from 'next/compat/router'
import { useMemo } from 'react'
import { useRouteParamsOverride } from './RouteParamsOverrideContext'
/**
* Helper to convert kebab case to camel case
*/
@@ -33,10 +35,11 @@ function convertToCamelCase(key: string): string {
export function useParams(): {
[k: string]: string | undefined
} {
const override = useRouteParamsOverride()
const router = useRouter()
const query = router?.query
const modifiedQuery = {
const modifiedQuery: Record<string, unknown> = {
...query,
}
@@ -49,6 +52,13 @@ export function useParams(): {
}
})
if (override) {
Object.keys(override).forEach((key) => {
const modifiedKey = convertToCamelCase(key)
modifiedQuery[modifiedKey] = override[key]
})
}
return useMemo(
() =>
Object.fromEntries(
@@ -60,6 +70,6 @@ export function useParams(): {
}
})
),
[query]
[query, override]
)
}