diff --git a/apps/studio/app/dashboard/v2/[projectRef]/page.tsx b/apps/studio/app/dashboard/v2/[projectRef]/page.tsx deleted file mode 100644 index 17d7d1fad2a..00000000000 --- a/apps/studio/app/dashboard/v2/[projectRef]/page.tsx +++ /dev/null @@ -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}`) -} diff --git a/apps/studio/app/dashboard/v2/project/[projectRef]/ProjectShell.tsx b/apps/studio/app/dashboard/v2/project/[projectRef]/ProjectShell.tsx index 174d4db8f5e..132e9029fa4 100644 --- a/apps/studio/app/dashboard/v2/project/[projectRef]/ProjectShell.tsx +++ b/apps/studio/app/dashboard/v2/project/[projectRef]/ProjectShell.tsx @@ -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 ( - {children} + + {children} + ) } diff --git a/apps/studio/app/dashboard/v2/project/[projectRef]/data/tables/[tableId]/layout.tsx b/apps/studio/app/dashboard/v2/project/[projectRef]/data/tables/[tableId]/layout.tsx index fbaafaea94a..da053727304 100644 --- a/apps/studio/app/dashboard/v2/project/[projectRef]/data/tables/[tableId]/layout.tsx +++ b/apps/studio/app/dashboard/v2/project/[projectRef]/data/tables/[tableId]/layout.tsx @@ -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 ( -
-
- {SUB_TABS.map((tab) => { - const href = `${base}/${tab.slug}` - const isActive = pathname === href || pathname?.startsWith(href + '/') - return ( - - {tab.label} - - ) - })} + +
+
+ {SUB_TABS.map((tab) => { + const href = `${base}/${tab.slug}` + const isActive = pathname === href || pathname?.startsWith(href + '/') + return ( + + {tab.label} + + ) + })} +
+
{children}
-
{children}
-
+ ) } diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index 8e00a44a60d..a3a816c10b6 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -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 ? ( + + ) : openTabs.length > 0 ? ( + + ) : ( + + ) + ) : !!tabId ? ( @@ -145,13 +145,13 @@ export function LeftActivityBar() { }, { id: 'data', - icon: , + icon: , label: 'Data', href: `${base}/data/tables`, }, { id: 'obs', - icon: , + icon: , label: 'Observability', badge: hasAdvisorWarnings, href: `${base}/obs/logs`, diff --git a/apps/studio/components/v2/BrowserPanel.tsx b/apps/studio/components/v2/BrowserPanel.tsx index 522f0ea6d83..3671aae8ba7 100644 --- a/apps/studio/components/v2/BrowserPanel.tsx +++ b/apps/studio/components/v2/BrowserPanel.tsx @@ -184,7 +184,7 @@ export function BrowserPanel() { const groups = isData ? DATA_GROUPS : isObs ? OBS_GROUPS : isSettings ? SETTINGS_GROUPS : [] return ( -
+
{title}
@@ -234,7 +234,7 @@ export function BrowserPanel() { > {item.label} {'countKey' in item && ( - + {getCount(counts, String(item.countKey))} )} @@ -246,7 +246,7 @@ export function BrowserPanel() { })} {isData && (
- + + Add module
diff --git a/apps/studio/components/v2/DataListGrid.tsx b/apps/studio/components/v2/DataListGrid.tsx new file mode 100644 index 00000000000..cdd69f37106 --- /dev/null +++ b/apps/studio/components/v2/DataListGrid.tsx @@ -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 = { + 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 = { + /** 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[] + 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({ + toolbar, + tabs, + columns: columnDefs, + rows, + rowKeyGetter, + isLoading = false, + emptyMessage, + onRowClick, + onRowDoubleClick, + toolbarTrailing, + className, +}: DataListGridProps) { + const [sortKey, setSortKey] = useState(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[] = 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 ( + + ) + }, + renderCell: (props) => ( +
{col.renderCell(props.row)}
+ ), + })), + [columnDefs, sortKey, sortDir, onHeaderClick] + ) + + return ( +
+ {(toolbar != null || toolbarTrailing != null) && ( +
+
{toolbar}
+ {toolbarTrailing != null ? ( +
{toolbarTrailing}
+ ) : null} +
+ )} + {tabs != null ? ( +
{tabs}
+ ) : null} +
+ 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 ( + onRowClick?.(props.row)} + onDoubleClick={() => onRowDoubleClick?.(props.row)} + /> + ) + }, + noRowsFallback: isLoading ? ( +
+ +
+ ) : ( +
+ {emptyMessage ?? 'No rows'} +
+ ), + }} + /> +
+
+ ) +} diff --git a/apps/studio/components/v2/EditorFrame.tsx b/apps/studio/components/v2/EditorFrame.tsx index 7fb410b5dee..90d1a28fbd3 100644 --- a/apps/studio/components/v2/EditorFrame.tsx +++ b/apps/studio/components/v2/EditorFrame.tsx @@ -39,7 +39,12 @@ export function EditorFrame({ children }: { children: React.ReactNode }) {
{isDataCategory ? null : isHome ? null : } {isDataCategory && } -
+
{children}
diff --git a/apps/studio/components/v2/Navigation/ProjectBranchSelector.tsx b/apps/studio/components/v2/Navigation/ProjectBranchSelector.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/studio/components/v2/RightActivityBar.tsx b/apps/studio/components/v2/RightActivityBar.tsx index a6017c3cc9e..31650c041de 100644 --- a/apps/studio/components/v2/RightActivityBar.tsx +++ b/apps/studio/components/v2/RightActivityBar.tsx @@ -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 ( -
- - - - - - - - {filtered.map((table) => ( - - - - - ))} - -
NameSchema
- handleRowClick(String(table.id), table.name)} - className="text-foreground hover:underline" - > - {table.name} - - {table.schema ?? schema}
- )} -
+
+ + toolbar={ + <> + + + + + + {schemas?.map((s) => ( + + {s.name} + + ))} + + + 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)} + />
) } diff --git a/apps/studio/hooks/ui/useUrlState.ts b/apps/studio/hooks/ui/useUrlState.ts index 30af2d8a2bf..5a807094b94 100644 --- a/apps/studio/hooks/ui/useUrlState.ts +++ b/apps/studio/hooks/ui/useUrlState.ts @@ -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({ // 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({ return [key, value] }) ) - }, [arrayKeysSet, router.query]) + }, [arrayKeysSet, query]) const paramsRef = useLatest(params) @@ -45,11 +48,13 @@ export function useUrlState({ 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({ ) }, // eslint-disable-next-line react-hooks/exhaustive-deps - [router, replace] + [router, replace, pathname] ) return [params, setParams] as const diff --git a/packages/common/hooks/RouteParamsOverrideContext.tsx b/packages/common/hooks/RouteParamsOverrideContext.tsx new file mode 100644 index 00000000000..616b38dcc44 --- /dev/null +++ b/packages/common/hooks/RouteParamsOverrideContext.tsx @@ -0,0 +1,26 @@ +'use client' + +import { createContext, type PropsWithChildren, useContext, useMemo } from 'react' + +export type RouteParamsOverride = Record + +const RouteParamsOverrideContext = createContext(undefined) + +export function RouteParamsOverrideProvider({ + value, + children, +}: PropsWithChildren<{ value: RouteParamsOverride }>) { + // Keep referential stability for consumers. + const memoValue = useMemo(() => value, [value]) + + return ( + + {children} + + ) +} + +export function useRouteParamsOverride() { + return useContext(RouteParamsOverrideContext) +} + diff --git a/packages/common/hooks/index.ts b/packages/common/hooks/index.ts index 3d3b082f3d3..dd73b14055c 100644 --- a/packages/common/hooks/index.ts +++ b/packages/common/hooks/index.ts @@ -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' diff --git a/packages/common/hooks/useParams.ts b/packages/common/hooks/useParams.ts index eb8043a9139..479a572b603 100644 --- a/packages/common/hooks/useParams.ts +++ b/packages/common/hooks/useParams.ts @@ -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 = { ...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] ) }