mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 04:15:04 +03:00
data viewer
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
+25
-22
@@ -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"
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,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]
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user