From b0e7bc52d30015e8a773191f267dd35307bb6ea2 Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Thu, 19 Mar 2026 23:50:09 +0100 Subject: [PATCH] tabs system --- .../[projectRef]/data/buckets/page.tsx | 2 +- .../[projectRef]/data/channels/page.tsx | 2 +- .../[projectRef]/data/edge-functions/page.tsx | 2 +- .../[projectRef]/data/extensions/page.tsx | 2 +- .../[projectRef]/data/functions/page.tsx | 2 +- .../[projectRef]/data/indexes/page.tsx | 2 +- .../[projectRef]/data/oauth-apps/page.tsx | 2 +- .../v2/project/[projectRef]/data/page.tsx | 5 + .../[projectRef]/data/providers/page.tsx | 2 +- .../[projectRef]/data/publications/page.tsx | 2 +- .../project/[projectRef]/data/roles/page.tsx | 2 +- .../project/[projectRef]/data/types/page.tsx | 2 +- .../project/[projectRef]/data/users/page.tsx | 2 +- apps/studio/components/v2/Chooser.tsx | 233 ++++++++++++++++++ apps/studio/components/v2/DataTabBar.tsx | 92 +++++++ apps/studio/components/v2/EditorFrame.tsx | 47 ++-- apps/studio/components/v2/Shell.tsx | 17 +- apps/studio/components/v2/TypeBadge.tsx | 57 +++++ .../components/v2/views/V2PlaceholderView.tsx | 38 ++- .../components/v2/views/V2TableDetailView.tsx | 16 ++ .../components/v2/views/V2TablesList.tsx | 41 ++- .../hooks/misc/useTableEditorFiltersSort.ts | 77 +++--- apps/studio/stores/v2-dashboard.tsx | 164 ++++++------ 23 files changed, 659 insertions(+), 152 deletions(-) create mode 100644 apps/studio/app/dashboard/v2/project/[projectRef]/data/page.tsx create mode 100644 apps/studio/components/v2/Chooser.tsx create mode 100644 apps/studio/components/v2/DataTabBar.tsx create mode 100644 apps/studio/components/v2/TypeBadge.tsx diff --git a/apps/studio/app/dashboard/v2/project/[projectRef]/data/buckets/page.tsx b/apps/studio/app/dashboard/v2/project/[projectRef]/data/buckets/page.tsx index 72ec5997b4f..42a7be701ad 100644 --- a/apps/studio/app/dashboard/v2/project/[projectRef]/data/buckets/page.tsx +++ b/apps/studio/app/dashboard/v2/project/[projectRef]/data/buckets/page.tsx @@ -1,5 +1,5 @@ import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView' export default function DataBucketsPage() { - return + return } diff --git a/apps/studio/app/dashboard/v2/project/[projectRef]/data/channels/page.tsx b/apps/studio/app/dashboard/v2/project/[projectRef]/data/channels/page.tsx index 90f8a6e6f81..991a30d0dd8 100644 --- a/apps/studio/app/dashboard/v2/project/[projectRef]/data/channels/page.tsx +++ b/apps/studio/app/dashboard/v2/project/[projectRef]/data/channels/page.tsx @@ -1,5 +1,5 @@ import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView' export default function DataChannelsPage() { - return + return } diff --git a/apps/studio/app/dashboard/v2/project/[projectRef]/data/edge-functions/page.tsx b/apps/studio/app/dashboard/v2/project/[projectRef]/data/edge-functions/page.tsx index df8dcfe2125..84f3122fac0 100644 --- a/apps/studio/app/dashboard/v2/project/[projectRef]/data/edge-functions/page.tsx +++ b/apps/studio/app/dashboard/v2/project/[projectRef]/data/edge-functions/page.tsx @@ -1,5 +1,5 @@ import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView' export default function DataEdgeFunctionsPage() { - return + return } diff --git a/apps/studio/app/dashboard/v2/project/[projectRef]/data/extensions/page.tsx b/apps/studio/app/dashboard/v2/project/[projectRef]/data/extensions/page.tsx index ae8745bd11f..ad7e8d5a7c8 100644 --- a/apps/studio/app/dashboard/v2/project/[projectRef]/data/extensions/page.tsx +++ b/apps/studio/app/dashboard/v2/project/[projectRef]/data/extensions/page.tsx @@ -1,5 +1,5 @@ import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView' export default function DataExtensionsPage() { - return + return } diff --git a/apps/studio/app/dashboard/v2/project/[projectRef]/data/functions/page.tsx b/apps/studio/app/dashboard/v2/project/[projectRef]/data/functions/page.tsx index 86d8e57617c..aa9e25dd632 100644 --- a/apps/studio/app/dashboard/v2/project/[projectRef]/data/functions/page.tsx +++ b/apps/studio/app/dashboard/v2/project/[projectRef]/data/functions/page.tsx @@ -1,5 +1,5 @@ import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView' export default function DataFunctionsPage() { - return + return } diff --git a/apps/studio/app/dashboard/v2/project/[projectRef]/data/indexes/page.tsx b/apps/studio/app/dashboard/v2/project/[projectRef]/data/indexes/page.tsx index ef6d011283e..4ded51b797a 100644 --- a/apps/studio/app/dashboard/v2/project/[projectRef]/data/indexes/page.tsx +++ b/apps/studio/app/dashboard/v2/project/[projectRef]/data/indexes/page.tsx @@ -1,5 +1,5 @@ import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView' export default function DataIndexesPage() { - return + return } diff --git a/apps/studio/app/dashboard/v2/project/[projectRef]/data/oauth-apps/page.tsx b/apps/studio/app/dashboard/v2/project/[projectRef]/data/oauth-apps/page.tsx index 27a3d7d38c5..a06651798cd 100644 --- a/apps/studio/app/dashboard/v2/project/[projectRef]/data/oauth-apps/page.tsx +++ b/apps/studio/app/dashboard/v2/project/[projectRef]/data/oauth-apps/page.tsx @@ -1,5 +1,5 @@ import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView' export default function DataOAuthAppsPage() { - return + return } diff --git a/apps/studio/app/dashboard/v2/project/[projectRef]/data/page.tsx b/apps/studio/app/dashboard/v2/project/[projectRef]/data/page.tsx new file mode 100644 index 00000000000..4a98c1e2e03 --- /dev/null +++ b/apps/studio/app/dashboard/v2/project/[projectRef]/data/page.tsx @@ -0,0 +1,5 @@ +import { Chooser } from '@/components/v2/Chooser' + +export default function DataChooserPage() { + return +} diff --git a/apps/studio/app/dashboard/v2/project/[projectRef]/data/providers/page.tsx b/apps/studio/app/dashboard/v2/project/[projectRef]/data/providers/page.tsx index 2c9b9b2175c..486a96dc64e 100644 --- a/apps/studio/app/dashboard/v2/project/[projectRef]/data/providers/page.tsx +++ b/apps/studio/app/dashboard/v2/project/[projectRef]/data/providers/page.tsx @@ -1,5 +1,5 @@ import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView' export default function DataProvidersPage() { - return + return } diff --git a/apps/studio/app/dashboard/v2/project/[projectRef]/data/publications/page.tsx b/apps/studio/app/dashboard/v2/project/[projectRef]/data/publications/page.tsx index 96688665f86..2f0d6bf16dd 100644 --- a/apps/studio/app/dashboard/v2/project/[projectRef]/data/publications/page.tsx +++ b/apps/studio/app/dashboard/v2/project/[projectRef]/data/publications/page.tsx @@ -1,5 +1,5 @@ import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView' export default function DataPublicationsPage() { - return + return } diff --git a/apps/studio/app/dashboard/v2/project/[projectRef]/data/roles/page.tsx b/apps/studio/app/dashboard/v2/project/[projectRef]/data/roles/page.tsx index bd6617a547f..26b9b99169c 100644 --- a/apps/studio/app/dashboard/v2/project/[projectRef]/data/roles/page.tsx +++ b/apps/studio/app/dashboard/v2/project/[projectRef]/data/roles/page.tsx @@ -1,5 +1,5 @@ import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView' export default function DataRolesPage() { - return + return } diff --git a/apps/studio/app/dashboard/v2/project/[projectRef]/data/types/page.tsx b/apps/studio/app/dashboard/v2/project/[projectRef]/data/types/page.tsx index b59e212011d..18a50cae5a6 100644 --- a/apps/studio/app/dashboard/v2/project/[projectRef]/data/types/page.tsx +++ b/apps/studio/app/dashboard/v2/project/[projectRef]/data/types/page.tsx @@ -1,5 +1,5 @@ import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView' export default function DataTypesPage() { - return + return } diff --git a/apps/studio/app/dashboard/v2/project/[projectRef]/data/users/page.tsx b/apps/studio/app/dashboard/v2/project/[projectRef]/data/users/page.tsx index f3a410d2e77..c2c034ae66d 100644 --- a/apps/studio/app/dashboard/v2/project/[projectRef]/data/users/page.tsx +++ b/apps/studio/app/dashboard/v2/project/[projectRef]/data/users/page.tsx @@ -1,5 +1,5 @@ import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView' export default function DataUsersPage() { - return + return } diff --git a/apps/studio/components/v2/Chooser.tsx b/apps/studio/components/v2/Chooser.tsx new file mode 100644 index 00000000000..b8a7d0eb014 --- /dev/null +++ b/apps/studio/components/v2/Chooser.tsx @@ -0,0 +1,233 @@ +'use client' + +import { + Box, + Database, + File, + Fingerprint, + FunctionSquare, + Globe, + HardDrive, + KeyRound, + Layers, + ShieldCheck, + Table2, + Users, + Zap, +} from 'lucide-react' +import { useRouter } from 'next/navigation' +import { useState } from 'react' +import { cn } from 'ui' +import { Input } from 'ui/src/components/shadcn/ui/input' + +import { TypeBadge } from './TypeBadge' +import { useV2DataCounts } from './useV2DataCounts' +import { useV2Params } from '@/app/v2/V2ParamsContext' +import { + CATEGORY_DOMAIN, + CATEGORY_LABELS, + useV2DashboardStore, + type DataTabDomain, + type RecentItem, +} from '@/stores/v2-dashboard' + +const CATEGORY_ICONS: Record = { + tables: , + functions: , + types: , + roles: , + extensions: , + indexes: , + publications: , + users: , + providers: , + 'oauth-apps': , + buckets: , + 'edge-functions': , + channels: , +} + +const CATEGORY_GROUPS: Array<{ label: string; categories: string[] }> = [ + { + label: 'Database', + categories: ['tables', 'functions', 'types', 'roles', 'extensions', 'indexes', 'publications'], + }, + { + label: 'Auth', + categories: ['users', 'providers', 'oauth-apps'], + }, + { + label: 'Storage', + categories: ['buckets'], + }, + { + label: 'Compute', + categories: ['edge-functions', 'channels'], + }, +] + +const COUNT_KEY_MAP: Record> = { + tables: 'tables', + functions: 'functions', + types: 'types', + roles: 'roles', + extensions: 'extensions', + indexes: 'indexes', + publications: 'publications', + users: 'users', + buckets: 'buckets', + 'edge-functions': 'edgeFunctions', +} + +function formatCount(n: number, label: string) { + return `${n} ${label.toLowerCase()}` +} + +function timeAgo(timestamp: number): string { + const diff = Date.now() - timestamp + const minutes = Math.floor(diff / 60_000) + if (minutes < 1) return 'just now' + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} + +export function Chooser() { + const router = useRouter() + const { projectRef } = useV2Params() + const { openDataTab, recentItems } = useV2DashboardStore() + const counts = useV2DataCounts(projectRef) + const [search, setSearch] = useState('') + const lowerSearch = search.toLowerCase() + + const base = projectRef ? `/dashboard/v2/project/${projectRef}` : '' + + const openCategory = (category: string) => { + const domain = CATEGORY_DOMAIN[category] ?? 'db' + const path = `${base}/data/${category}` + openDataTab({ + id: category, + label: CATEGORY_LABELS[category] ?? category, + type: 'list', + category, + domain, + path, + }) + router.push(path) + } + + const openRecent = (item: RecentItem) => { + openDataTab({ + id: item.id, + label: item.label, + type: 'detail', + category: item.category, + domain: item.domain, + path: item.path, + }) + router.push(item.path) + } + + const filteredGroups = CATEGORY_GROUPS.map((group) => ({ + ...group, + categories: group.categories.filter((c) => { + if (!lowerSearch) return true + return ( + c.toLowerCase().includes(lowerSearch) || + (CATEGORY_LABELS[c] ?? c).toLowerCase().includes(lowerSearch) + ) + }), + })).filter((g) => g.categories.length > 0) + + const filteredRecents = recentItems.filter( + (r) => + !lowerSearch || + r.label.toLowerCase().includes(lowerSearch) || + r.category.toLowerCase().includes(lowerSearch) + ) + + return ( +
+ {/* Search */} +
+ setSearch(e.target.value)} + placeholder="Search tables, users, buckets, functions…" + className="w-full" + /> +
+ + {/* Category groups */} + {filteredGroups.map((group) => ( +
+

+ {group.label} +

+
+ {group.categories.map((cat) => { + const domain = CATEGORY_DOMAIN[cat] ?? 'db' + const label = CATEGORY_LABELS[cat] ?? cat + const countKey = COUNT_KEY_MAP[cat] + const count = countKey != null ? counts[countKey] : undefined + return ( + + ) + })} +
+
+ ))} + + {/* Recents */} + {filteredRecents.length > 0 && ( +
+

Recent

+
+ {filteredRecents.slice(0, 10).map((item) => ( + + ))} +
+
+ )} + + {filteredGroups.length === 0 && filteredRecents.length === 0 && ( +

No results for "{search}"

+ )} +
+ ) +} diff --git a/apps/studio/components/v2/DataTabBar.tsx b/apps/studio/components/v2/DataTabBar.tsx new file mode 100644 index 00000000000..c8183b6d21c --- /dev/null +++ b/apps/studio/components/v2/DataTabBar.tsx @@ -0,0 +1,92 @@ +'use client' + +import { Plus, X } from 'lucide-react' +import { useRouter, usePathname } from 'next/navigation' +import { cn } from 'ui' + +import { useV2Params } from '@/app/v2/V2ParamsContext' +import { useV2DashboardStore, type DataTab } from '@/stores/v2-dashboard' +import { TypeBadge } from './TypeBadge' + +/** Returns the most specific (longest-path) tab that the current pathname is under. */ +function useActiveTab(dataTabs: DataTab[], pathname: string | null): DataTab | null { + if (!pathname) return null + const matches = dataTabs.filter( + (t) => pathname === t.path || pathname.startsWith(t.path + '/') + ) + if (matches.length === 0) return null + return matches.reduce((best, t) => (t.path.length > best.path.length ? t : best)) +} + +export function DataTabBar() { + const router = useRouter() + const pathname = usePathname() + const { projectRef } = useV2Params() + const { dataTabs, closeDataTab } = useV2DashboardStore() + + const activeTab = useActiveTab(dataTabs, pathname) + const chooserPath = projectRef ? `/dashboard/v2/project/${projectRef}/data` : '#' + + const handleClose = (tab: DataTab, e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + closeDataTab(tab.id) + if (activeTab?.id === tab.id) { + const remaining = dataTabs.filter((t) => t.id !== tab.id) + if (remaining.length > 0) { + const idx = dataTabs.indexOf(tab) + const next = remaining[Math.min(idx, remaining.length - 1)] + router.push(next.path) + } else { + router.push(chooserPath) + } + } + } + + const isChooser = !activeTab && pathname !== null + + return ( +
+ {dataTabs.map((tab) => { + const isActive = activeTab?.id === tab.id + return ( +
router.push(tab.path)} + > + + {tab.label} + +
+ ) + })} + + {/* "+" button — opens chooser */} + +
+ ) +} diff --git a/apps/studio/components/v2/EditorFrame.tsx b/apps/studio/components/v2/EditorFrame.tsx index a01e8b99648..74d81753947 100644 --- a/apps/studio/components/v2/EditorFrame.tsx +++ b/apps/studio/components/v2/EditorFrame.tsx @@ -5,7 +5,7 @@ import { cn } from 'ui' import { useV2Params } from '@/app/v2/V2ParamsContext' -import { TabBar } from './TabBar' +import { DataTabBar } from './DataTabBar' import { StaticTitle } from './StaticTitle' import { V2EditorFooter } from './V2EditorFooter' @@ -13,36 +13,45 @@ export function EditorFrame({ children }: { children: React.ReactNode }) { const pathname = usePathname() const { projectRef } = useV2Params() - const isDataCategory = - pathname?.includes('/data/tables') || - pathname?.includes('/data/functions') || - pathname?.includes('/data/types') || - pathname?.includes('/data/roles') || - pathname?.includes('/data/extensions') || - pathname?.includes('/data/indexes') || - pathname?.includes('/data/publications') || - pathname?.includes('/data/users') || - pathname?.includes('/data/providers') || - pathname?.includes('/data/oauth-apps') || - pathname?.includes('/data/buckets') || - pathname?.includes('/data/edge-functions') || - pathname?.includes('/data/channels') + // Any /data route (including the chooser at /data with no further segment) + const isDataActivity = + Boolean(projectRef) && + Boolean(pathname?.includes('/data/') || pathname?.endsWith('/data')) + + // Sub-routes deeper than /data (list + detail views) need overflow:hidden for grids + const isDataSubRoute = + Boolean(projectRef) && + Boolean( + pathname?.includes('/data/tables') || + pathname?.includes('/data/functions') || + pathname?.includes('/data/types') || + pathname?.includes('/data/roles') || + pathname?.includes('/data/extensions') || + pathname?.includes('/data/indexes') || + pathname?.includes('/data/publications') || + pathname?.includes('/data/users') || + pathname?.includes('/data/providers') || + pathname?.includes('/data/oauth-apps') || + pathname?.includes('/data/buckets') || + pathname?.includes('/data/edge-functions') || + pathname?.includes('/data/channels') + ) const isHome = Boolean(projectRef) && pathname?.endsWith(`/${projectRef}`) && - !pathname?.includes('/data/') && + !pathname?.includes('/data') && !pathname?.includes('/obs/') && !pathname?.includes('/settings/') return (
- {isDataCategory ? null : isHome ? null : } - {isDataCategory && } + {!isDataActivity && !isHome && } + {isDataActivity && }
{children} diff --git a/apps/studio/components/v2/Shell.tsx b/apps/studio/components/v2/Shell.tsx index 8c0e84f7865..354aa47b22b 100644 --- a/apps/studio/components/v2/Shell.tsx +++ b/apps/studio/components/v2/Shell.tsx @@ -16,15 +16,22 @@ import { V2DashboardProvider } from '@/stores/v2-dashboard' export function Shell({ children }: { children: React.ReactNode }) { const pathname = usePathname() - const { projectRef, orgSlug } = useV2Params() + const { projectRef } = useV2Params() const isHomeActive = - Boolean(projectRef && orgSlug) && + Boolean(projectRef) && pathname?.endsWith(`/${projectRef}`) && - !pathname?.includes('/data/') && + !pathname?.includes('/data') && !pathname?.includes('/obs/') && !pathname?.includes('/settings/') + // Data activity also hides the browser panel (Figma tab model) + const isDataActivity = + Boolean(projectRef) && + Boolean(pathname?.includes('/data/') || pathname?.endsWith('/data')) + + const hideBrowser = isHomeActive || isDataActivity + return ( @@ -37,9 +44,9 @@ export function Shell({ children }: { children: React.ReactNode }) { - {!isHomeActive && ( + {!hideBrowser && ( <> = { + list: { + label: 'LIST', + className: 'bg-muted text-foreground-lighter', + }, + db: { + label: 'DB', + className: 'bg-blue-500/15 text-blue-600 dark:text-blue-400', + }, + auth: { + label: 'AUTH', + className: 'bg-purple-500/15 text-purple-600 dark:text-purple-400', + }, + st: { + label: 'STOR', + className: 'bg-green-500/15 text-green-600 dark:text-green-400', + }, + fn: { + label: 'FN', + className: 'bg-amber-500/15 text-amber-600 dark:text-amber-400', + }, + rt: { + label: 'RT', + className: 'bg-amber-500/15 text-amber-600 dark:text-amber-400', + }, +} + +interface TypeBadgeProps { + domain: DataTabDomain + type: DataTabType + className?: string +} + +export function TypeBadge({ domain, type, className }: TypeBadgeProps) { + const variant: BadgeVariant = type === 'list' ? 'list' : domain + const { label, className: badgeClass } = BADGE_CONFIG[variant] + + return ( + + {label} + + ) +} diff --git a/apps/studio/components/v2/views/V2PlaceholderView.tsx b/apps/studio/components/v2/views/V2PlaceholderView.tsx index cbbd9fa3114..23212360d6d 100644 --- a/apps/studio/components/v2/views/V2PlaceholderView.tsx +++ b/apps/studio/components/v2/views/V2PlaceholderView.tsx @@ -1,8 +1,42 @@ 'use client' -export function V2PlaceholderView({ title }: { title: string }) { +import { useEffect } from 'react' + +import { useV2Params } from '@/app/v2/V2ParamsContext' +import { CATEGORY_DOMAIN, useV2DashboardStore, type DataTabDomain } from '@/stores/v2-dashboard' + +interface V2PlaceholderViewProps { + title: string + /** If provided, registers this view as a list tab on mount */ + tabCategory?: string + tabLabel?: string + tabDomain?: DataTabDomain +} + +export function V2PlaceholderView({ + title, + tabCategory, + tabLabel, + tabDomain, +}: V2PlaceholderViewProps) { + const { projectRef } = useV2Params() + const openDataTab = useV2DashboardStore((s) => s.openDataTab) + + useEffect(() => { + if (!tabCategory || !projectRef) return + const domain = tabDomain ?? CATEGORY_DOMAIN[tabCategory] ?? 'db' + openDataTab({ + id: tabCategory, + label: tabLabel ?? title, + type: 'list', + category: tabCategory, + domain, + path: `/dashboard/v2/project/${projectRef}/data/${tabCategory}`, + }) + }, [projectRef, tabCategory, tabLabel, tabDomain, title, openDataTab]) + return ( -
+

{title}

This view will show real data from the API. Placeholder for the v2 prototype.

diff --git a/apps/studio/components/v2/views/V2TableDetailView.tsx b/apps/studio/components/v2/views/V2TableDetailView.tsx index dcb884b82a3..428b5c42a26 100644 --- a/apps/studio/components/v2/views/V2TableDetailView.tsx +++ b/apps/studio/components/v2/views/V2TableDetailView.tsx @@ -1,17 +1,20 @@ 'use client' +import { useEffect } from 'react' 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 { useV2DashboardStore } from '@/stores/v2-dashboard' import { useProjectDetailQuery } from 'data/projects/project-detail-query' import { useTableEditorQuery } from 'data/table-editor/table-editor-query' export function V2TableDetailView({ subTab }: { subTab: string }) { const params = useParams() const { projectRef } = useV2Params() + const openDataTab = useV2DashboardStore((s) => s.openDataTab) const tableId = params?.tableId as string const id = tableId ? Number(tableId) : undefined @@ -28,6 +31,19 @@ export function V2TableDetailView({ subTab }: { subTab: string }) { { enabled: Boolean(projectRef) && typeof id === 'number' && !Number.isNaN(id) } ) + // Register this detail tab once we know the table name + useEffect(() => { + if (!table || !projectRef || !tableId) return + openDataTab({ + id: `tables-${tableId}`, + label: table.name, + type: 'detail', + category: 'tables', + domain: 'db', + path: `/dashboard/v2/project/${projectRef}/data/tables/${tableId}/data`, + }) + }, [table, projectRef, tableId, openDataTab]) + if (isError) { return (
Failed to load table.
diff --git a/apps/studio/components/v2/views/V2TablesList.tsx b/apps/studio/components/v2/views/V2TablesList.tsx index be4f2091907..fca13e850ab 100644 --- a/apps/studio/components/v2/views/V2TablesList.tsx +++ b/apps/studio/components/v2/views/V2TablesList.tsx @@ -2,7 +2,7 @@ import type { PostgresTable } from '@supabase/postgres-meta' import Link from 'next/link' -import { useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { Select_Shadcn_, SelectContent_Shadcn_, @@ -23,8 +23,21 @@ import { useTablesQuery } from 'data/tables/tables-query' export function V2TablesList() { const { projectRef } = useV2Params() - const addDetailTab = useV2DashboardStore((s) => s.addDetailTab) + const openDataTab = useV2DashboardStore((s) => s.openDataTab) const [schema, setSchema] = useState('public') + + // Register this list as an open tab + useEffect(() => { + if (!projectRef) return + openDataTab({ + id: 'tables', + label: 'Tables', + type: 'list', + category: 'tables', + domain: 'db', + path: `/dashboard/v2/project/${projectRef}/data/tables`, + }) + }, [projectRef, openDataTab]) const [search, setSearch] = useState('') const { data: project, isPending: isProjectPending } = useProjectDetailQuery( @@ -57,10 +70,20 @@ export function V2TablesList() { const base = projectRef ? `/dashboard/v2/project/${projectRef}` : '' - const openTable = (table: PostgresTable) => { - const path = `${base}/data/tables/${table.id}` - addDetailTab({ id: String(table.id), label: table.name, path }) - } + const openTable = useCallback( + (table: PostgresTable) => { + const path = `${base}/data/tables/${table.id}/data` + openDataTab({ + id: `tables-${table.id}`, + label: table.name, + type: 'detail', + category: 'tables', + domain: 'db', + path, + }) + }, + [base, openDataTab] + ) const columns = useMemo[]>( () => [ @@ -71,7 +94,7 @@ export function V2TablesList() { sortValue: (row) => row.name, renderCell: (row) => ( openTable(row)} className="truncate font-mono text-foreground hover:underline" > @@ -101,7 +124,7 @@ export function V2TablesList() { ), }, ], - [base, schema] + [base, schema, openTable] ) if (isTablesError) { @@ -147,7 +170,7 @@ export function V2TablesList() { emptyMessage={ search.trim() ? 'No tables match your filter.' : 'No tables in this schema.' } - onRowDoubleClick={(row) => openTable(row)} + onRowDoubleClick={openTable} />
) diff --git a/apps/studio/hooks/misc/useTableEditorFiltersSort.ts b/apps/studio/hooks/misc/useTableEditorFiltersSort.ts index 5851a8e4d90..57e4ff33636 100644 --- a/apps/studio/hooks/misc/useTableEditorFiltersSort.ts +++ b/apps/studio/hooks/misc/useTableEditorFiltersSort.ts @@ -1,20 +1,26 @@ -import { useRouter } from 'next/router' +import { useRouter as useCompatRouter } from 'next/compat/router' +import { usePathname, useRouter as useAppRouter, useSearchParams } from 'next/navigation' import { useCallback, useMemo } from 'react' export const useTableEditorFiltersSort = () => { - const router = useRouter() + // Returns the pages-router instance, or null when running in the app router. + const pagesRouter = useCompatRouter() + // Always call navigation hooks unconditionally (Rules of Hooks). + const appRouter = useAppRouter() + const appSearchParams = useSearchParams() + const pathname = usePathname() + + const isAppRouter = pagesRouter === null const urlParams = useMemo(() => { - return new URLSearchParams(router.asPath.split('?')[1]) - }, [router.asPath]) + if (isAppRouter) { + return appSearchParams ?? new URLSearchParams() + } + return new URLSearchParams(pagesRouter?.asPath.split('?')[1]) + }, [isAppRouter, pagesRouter, appSearchParams]) - const filters = useMemo(() => { - return urlParams.getAll('filter') - }, [urlParams]) - - const sorts = useMemo(() => { - return urlParams.getAll('sort') - }, [urlParams]) + const filters = useMemo(() => urlParams.getAll('filter'), [urlParams]) + const sorts = useMemo(() => urlParams.getAll('sort'), [urlParams]) type SetParamsArgs = { filter?: string[] @@ -26,28 +32,37 @@ export const useTableEditorFiltersSort = () => { const prevParams = { filter: filters, sort: sorts } const newParams = fn(prevParams) - const hasFilter = newParams.filter !== undefined - const hasSort = newParams.sort !== undefined - - router.push( - { - query: { - ...router.query, - ...(hasFilter ? { filter: newParams.filter } : {}), - ...(hasSort ? { sort: newParams.sort } : {}), + if (isAppRouter) { + // Build a new search-params string and replace the URL shallowly. + const next = new URLSearchParams(appSearchParams?.toString() ?? '') + if (newParams.filter !== undefined) { + next.delete('filter') + for (const f of newParams.filter) next.append('filter', f) + } + if (newParams.sort !== undefined) { + next.delete('sort') + for (const s of newParams.sort) next.append('sort', s) + } + const qs = next.toString() + appRouter.replace(`${pathname}${qs ? `?${qs}` : ''}`, { scroll: false }) + } else { + const hasFilter = newParams.filter !== undefined + const hasSort = newParams.sort !== undefined + pagesRouter?.push( + { + query: { + ...pagesRouter.query, + ...(hasFilter ? { filter: newParams.filter } : {}), + ...(hasSort ? { sort: newParams.sort } : {}), + }, }, - }, - undefined, - { shallow: true } - ) + undefined, + { shallow: true } + ) + } }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [filters, sorts] + [isAppRouter, pagesRouter, appRouter, appSearchParams, pathname, filters, sorts] ) - return { - filters, - sorts, - setParams, - } + return { filters, sorts, setParams } } diff --git a/apps/studio/stores/v2-dashboard.tsx b/apps/studio/stores/v2-dashboard.tsx index 98714757695..547044dc489 100644 --- a/apps/studio/stores/v2-dashboard.tsx +++ b/apps/studio/stores/v2-dashboard.tsx @@ -11,10 +11,15 @@ import { } from 'react' export type RightPanelType = 'chat' | 'sql' | 'adv' | null +export type DataTabDomain = 'db' | 'auth' | 'st' | 'fn' | 'rt' +export type DataTabType = 'list' | 'detail' -export interface DetailTab { +export interface DataTab { id: string label: string + type: DataTabType + category: string + domain: DataTabDomain path: string } @@ -22,18 +27,47 @@ export interface RecentItem { id: string label: string path: string - type: string + category: string + domain: DataTabDomain timestamp: number } +// Exported so components can reference canonical mappings +export const CATEGORY_DOMAIN: Record = { + tables: 'db', + functions: 'db', + types: 'db', + roles: 'db', + extensions: 'db', + indexes: 'db', + publications: 'db', + users: 'auth', + providers: 'auth', + 'oauth-apps': 'auth', + buckets: 'st', + 'edge-functions': 'fn', + channels: 'rt', +} + +export const CATEGORY_LABELS: Record = { + tables: 'Tables', + functions: 'Functions', + types: 'Enumerated types', + roles: 'Roles', + extensions: 'Extensions', + indexes: 'Indexes', + publications: 'Publications', + users: 'Users', + providers: 'Providers', + 'oauth-apps': 'OAuth apps', + buckets: 'Buckets', + 'edge-functions': 'Edge functions', + channels: 'Channels', +} + const RECENT_ITEMS_STORAGE_KEY = 'v2-recent-items' const defaultExpandedGroups: Record = { - 'data-database': true, - 'data-auth': true, - 'data-storage': true, - 'data-edge-functions': true, - 'data-realtime': true, 'obs-logs': true, 'obs-metrics': true, 'obs-alerts': true, @@ -44,15 +78,15 @@ const defaultExpandedGroups: Record = { } interface V2DashboardState { - detailTabs: DetailTab[] + // Flat data tabs (Figma model) + dataTabs: DataTab[] expandedGroups: Record rightPanel: RightPanelType recentItems: RecentItem[] - addDetailTab: (tab: DetailTab) => void + openDataTab: (tab: DataTab) => void + closeDataTab: (id: string) => void addRecentItem: (item: Omit) => void - removeDetailTab: (id: string) => void - setDetailTabs: (tabs: DetailTab[]) => void toggleGroup: (groupId: string) => void setExpandedGroup: (groupId: string, expanded: boolean) => void toggleRightPanel: (panel: 'chat' | 'sql' | 'adv') => void @@ -61,29 +95,13 @@ interface V2DashboardState { const V2DashboardContext = createContext(null) -const inferRecentTypeFromPath = (path: string): string => { - if (path.includes('/data/tables/')) return 'Table' - if (path.includes('/data/functions/')) return 'Function' - if (path.includes('/data/types/')) return 'Type' - if (path.includes('/data/roles/')) return 'Role' - if (path.includes('/data/users/')) return 'User' - if (path.includes('/data/buckets/')) return 'Bucket' - if (path.includes('/data/extensions/')) return 'Extension' - if (path.includes('/data/indexes/')) return 'Index' - if (path.includes('/data/providers/')) return 'Provider' - if (path.includes('/data/oauth-apps/')) return 'OAuth App' - if (path.includes('/data/publications/')) return 'Publication' - if (path.includes('/data/edge-functions/')) return 'Edge Function' - if (path.includes('/data/channels/')) return 'Channel' - return 'Item' -} - export function V2DashboardProvider({ children }: { children: ReactNode }) { - const [detailTabs, setDetailTabsState] = useState([]) + const [dataTabs, setDataTabs] = useState([]) const [expandedGroups, setExpandedGroups] = useState>(defaultExpandedGroups) const [rightPanel, setRightPanel] = useState(null) const [recentItems, setRecentItems] = useState([]) + // Load persisted recents from localStorage once useEffect(() => { if (typeof window === 'undefined') return try { @@ -94,65 +112,69 @@ export function V2DashboardProvider({ children }: { children: ReactNode }) { const normalized = parsed .filter((x) => x && typeof x === 'object') .map((x) => x as Partial) - .filter((x) => typeof x.id === 'string' && typeof x.label === 'string' && typeof x.path === 'string') + .filter( + (x) => + typeof x.id === 'string' && + typeof x.label === 'string' && + typeof x.path === 'string' + ) .map((x) => ({ id: x.id as string, label: x.label as string, path: x.path as string, - type: typeof x.type === 'string' ? x.type : 'Item', + category: typeof x.category === 'string' ? x.category : 'tables', + domain: (typeof x.domain === 'string' ? x.domain : 'db') as DataTabDomain, timestamp: typeof x.timestamp === 'number' ? x.timestamp : 0, })) - setRecentItems(normalized.sort((a, b) => b.timestamp - a.timestamp).slice(0, 20)) } catch { - // Ignore corrupted localStorage. + // Ignore corrupted localStorage } }, []) + // Persist recents to localStorage on change useEffect(() => { if (typeof window === 'undefined') return try { localStorage.setItem(RECENT_ITEMS_STORAGE_KEY, JSON.stringify(recentItems)) } catch { - // Ignore write failures (e.g. quota). + // Ignore write failures } }, [recentItems]) const addRecentItem = useCallback((item: Omit) => { - const now = Date.now() setRecentItems((prev) => { - const withoutDup = prev.filter((x) => x.id !== item.id) - const next: RecentItem = { ...item, timestamp: now } - return [next, ...withoutDup].slice(0, 20) + const without = prev.filter((x) => x.id !== item.id) + return [{ ...item, timestamp: Date.now() }, ...without].slice(0, 20) }) }, []) - const addDetailTab = useCallback((tab: DetailTab) => { - addRecentItem({ - id: tab.id, - label: tab.label, - path: tab.path, - type: inferRecentTypeFromPath(tab.path), - }) + const openDataTab = useCallback( + (tab: DataTab) => { + setDataTabs((prev) => { + if (prev.some((t) => t.id === tab.id)) return prev + return [...prev, tab] + }) + // Add to recents for detail tabs only + if (tab.type === 'detail') { + addRecentItem({ + id: tab.id, + label: tab.label, + path: tab.path, + category: tab.category, + domain: tab.domain, + }) + } + }, + [addRecentItem] + ) - setDetailTabsState((state) => - state.some((t) => t.id === tab.id) ? state : [...state, tab] - ) - }, [addRecentItem]) - - const removeDetailTab = useCallback((id: string) => { - setDetailTabsState((state) => state.filter((t) => t.id !== id)) - }, []) - - const setDetailTabs = useCallback((tabs: DetailTab[]) => { - setDetailTabsState(tabs) + const closeDataTab = useCallback((id: string) => { + setDataTabs((prev) => prev.filter((t) => t.id !== id)) }, []) const toggleGroup = useCallback((groupId: string) => { - setExpandedGroups((state) => ({ - ...state, - [groupId]: !state[groupId], - })) + setExpandedGroups((state) => ({ ...state, [groupId]: !state[groupId] })) }, []) const setExpandedGroup = useCallback((groupId: string, expanded: boolean) => { @@ -167,28 +189,26 @@ export function V2DashboardProvider({ children }: { children: ReactNode }) { const value = useMemo( () => ({ - detailTabs, + dataTabs, expandedGroups, rightPanel, recentItems, - addDetailTab, + openDataTab, + closeDataTab, addRecentItem, - removeDetailTab, - setDetailTabs, toggleGroup, setExpandedGroup, toggleRightPanel, closeRightPanel, }), [ - detailTabs, + dataTabs, expandedGroups, rightPanel, recentItems, - addDetailTab, + openDataTab, + closeDataTab, addRecentItem, - removeDetailTab, - setDetailTabs, toggleGroup, setExpandedGroup, toggleRightPanel, @@ -196,18 +216,14 @@ export function V2DashboardProvider({ children }: { children: ReactNode }) { ] ) - return ( - {children} - ) + return {children} } export function useV2DashboardStore(): V2DashboardState export function useV2DashboardStore(selector: (s: V2DashboardState) => T): T export function useV2DashboardStore(selector?: (s: V2DashboardState) => T) { const ctx = useContext(V2DashboardContext) - if (!ctx) { - throw new Error('useV2DashboardStore must be used within V2DashboardProvider') - } + if (!ctx) throw new Error('useV2DashboardStore must be used within V2DashboardProvider') if (selector) return selector(ctx) return ctx }