diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Classic/ExperimentalTokenDropdown.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Classic/ExperimentalTokenDropdown.tsx index 07efe99200679..04452562840ac 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Classic/ExperimentalTokenDropdown.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Classic/ExperimentalTokenDropdown.tsx @@ -35,7 +35,7 @@ export const ExperimentalTokenDropdown = ({ onCreateToken }: ExperimentalTokenDr diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx index e7a70677a581e..7caf6a1190d79 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx @@ -109,7 +109,7 @@ export const NewScopedTokenSheet = ({ onCreateExperimentalToken }: NewScopedToke
- diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/CustomEmailTemplateRestrictionAdmonition.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/CustomEmailTemplateRestrictionAdmonition.tsx index 63a04b6ced915..23c6ba82701ea 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/CustomEmailTemplateRestrictionAdmonition.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/CustomEmailTemplateRestrictionAdmonition.tsx @@ -28,7 +28,7 @@ export const CustomEmailTemplateRestrictionAdmonition = () => { @@ -37,7 +37,7 @@ export const CustomEmailTemplateRestrictionAdmonition = () => {
diff --git a/apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.tsx b/apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.tsx new file mode 100644 index 0000000000000..14e8e58284106 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.tsx @@ -0,0 +1,45 @@ +import { useParams } from 'common' +import { useContext, useEffect } from 'react' + +import { explorerQueryState } from '@/state/explorer-query' +import { TabsStateContext } from '@/state/tabs' + +/** + * Owns local query-draft cleanup and close confirmation for every Explorer page, + * including Explorer home where no individual query editor is mounted. + */ +export const ExplorerQueryTabCoordinator = () => { + const { ref } = useParams() + const tabs = useContext(TabsStateContext) + + useEffect(() => { + return tabs.registerTabTypeHandler('query', { + confirmClose: (queryTabs) => { + const populatedDraftCount = queryTabs.filter((tab) => { + const queryId = tab.metadata?.queryId + if (!ref || !queryId) return false + + explorerQueryState.restoreDraft({ id: queryId, projectRef: ref }) + + return explorerQueryState.drafts[queryId]?.uncheckedSql.trim().length > 0 + }).length + + if (populatedDraftCount === 0) return null + + return { + title: populatedDraftCount === 1 ? 'Discard query?' : 'Discard queries?', + description: + populatedDraftCount === 1 + ? 'This ad-hoc query is stored only in this browser. Closing the tab will discard it.' + : `These ${populatedDraftCount} ad-hoc queries are stored only in this browser. Closing their tabs will discard them.`, + } + }, + onClose: (tab) => { + const queryId = tab.metadata?.queryId + if (ref && queryId) explorerQueryState.removeDraft({ id: queryId, projectRef: ref }) + }, + }) + }, [ref, tabs]) + + return null +} diff --git a/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx b/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx index bb680a21b1d16..6cc223ce9e270 100644 --- a/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx +++ b/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx @@ -14,7 +14,6 @@ import { } from '@dnd-kit/sortable' import { useParams } from 'common' import { Notebook, NotebookText, Play, Save } from 'lucide-react' -import { useEffect, useEffectEvent } from 'react' import { AiIconAnimation, Button } from 'ui' import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' @@ -63,19 +62,6 @@ export const NotebookEditor = () => { snap.updateCells({ id, cells: arrayMove([...cells], oldIndex, newIndex) }) } - const registerTab = useEffectEvent(() => { - if (!id) return - tabs.addTab({ - id: createTabId('notebook', { id }), - type: 'notebook', - label: name ?? 'New Notebook', - metadata: { notebookId: id }, - isPreview: false, - }) - }) - - useEffect(() => registerTab(), [id]) - return (
diff --git a/apps/studio/components/interfaces/Explorer/QueryCell/DisplaySettingsButton.tsx b/apps/studio/components/interfaces/Explorer/QueryCell/DisplaySettingsButton.tsx index 29a8ee384277d..70c07a3bae55b 100644 --- a/apps/studio/components/interfaces/Explorer/QueryCell/DisplaySettingsButton.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryCell/DisplaySettingsButton.tsx @@ -18,34 +18,29 @@ import { TooltipTrigger, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { type Snapshot } from 'valtio' import { ExplorerToolbarAction } from '../ExplorerToolbar' -import { type QueryResult } from '../types' +import { type QueryChartConfig, type QueryDisplay, type QueryResult } from '../types' import { checkHasNonPositiveValues } from '@/components/ui/QueryBlock/QueryBlock.utils' -import { type DatabaseCell as DatabaseCellSchema } from '@/data/content/notebooks/notebook-schema' -import { useCurrentNotebook, useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' interface DisplaySettingsButtonProps { - cell: Snapshot + display: QueryDisplay result?: QueryResult columns: string[] disabled: boolean + onChange: (display: QueryDisplay) => void } // [Joshen] TODO support multiple y axis charts export const DisplaySettingsButton = ({ - cell, + display, result, columns, disabled, + onChange, }: DisplaySettingsButtonProps) => { - const snap = useNotebooksStateSnapshot() - const currentNotebook = useCurrentNotebook() - const cells = currentNotebook?.notebook.content?.cells ?? [] - - const { view, chart } = cell + const { view, chart } = display const { type = 'bar', x_column, @@ -66,44 +61,22 @@ export const DisplaySettingsButton = ({ }, [hasNonPositiveValues, result, y_columns.length]) const onChangeView = (view: 'table' | 'chart') => { - const notebookId = currentNotebook?.notebook.id - if (!notebookId) return - - const nextCells = cells.map((c) => - c.id === cell.id && c._tag === 'database_cell' ? { ...c, view } : c - ) - snap.updateCells({ id: notebookId, cells: nextCells }) + onChange({ ...display, view }) } - const onUpdateChartConfig = ( - payload: - | { type: 'bar' | 'line' } - | { x_column: string } - | { y_columns: string[] } - | { cumulative: boolean } - | { show_labels: boolean } - | { scale: 'linear' | 'log' } - ) => { - const notebookId = currentNotebook?.notebook.id - if (!notebookId) return - - const nextCells = cells.map((c) => { - if (c.id !== cell.id || c._tag !== 'database_cell') return c - - return { - ...c, - chart: { - type: c.chart?.type ?? 'bar', - x_column: c.chart?.x_column ?? '', - y_columns: c.chart?.y_columns ?? [], - cumulative: c.chart?.cumulative ?? false, - scale: c.chart?.scale ?? 'linear', - show_labels: c.chart?.show_labels ?? false, - ...payload, - }, - } + const onUpdateChartConfig = (payload: Partial) => { + onChange({ + ...display, + chart: { + type: chart?.type ?? 'bar', + x_column: chart?.x_column ?? '', + y_columns: chart?.y_columns ?? [], + cumulative: chart?.cumulative ?? false, + scale: chart?.scale ?? 'linear', + show_labels: chart?.show_labels ?? false, + ...payload, + }, }) - snap.updateCells({ id: notebookId, cells: nextCells }) } const resetToLinearScale = useEffectEvent(() => { @@ -230,7 +203,7 @@ export const DisplaySettingsButton = ({ {!canToggleLogScale && ( - + {y_columns.length === 0 ? 'Select a column for the Y axis first' : 'Data contains zero or negative values'} diff --git a/apps/studio/components/interfaces/Explorer/QueryCell/QueryResultChart.tsx b/apps/studio/components/interfaces/Explorer/QueryCell/QueryResultChart.tsx index ddee91b17b7f5..da90821dbd208 100644 --- a/apps/studio/components/interfaces/Explorer/QueryCell/QueryResultChart.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryCell/QueryResultChart.tsx @@ -1,14 +1,12 @@ import { useMemo } from 'react' import { Chart, ChartBar, ChartCard, ChartContent, ChartLine } from 'ui-patterns/Chart' -import { type Snapshot } from 'valtio' -import { type QueryResult } from '../types' +import { type QueryChartConfig, type QueryResult } from '../types' import NoDataPlaceholder from '@/components/ui/Charts/NoDataPlaceholder' import { formatLogTick, getCumulativeResults } from '@/components/ui/QueryBlock/QueryBlock.utils' -import { type DatabaseCell as DatabaseCellSchema } from '@/data/content/notebooks/notebook-schema' interface QueryResultChartProps { - cell: Snapshot + chart?: QueryChartConfig result?: QueryResult } @@ -21,8 +19,7 @@ const toChartValue = (value: unknown): string | number => { return String(value) } -export const QueryResultChart = ({ cell, result }: QueryResultChartProps) => { - const { chart } = cell +export const QueryResultChart = ({ chart, result }: QueryResultChartProps) => { const { type, x_column, y_columns = [], cumulative, show_labels, scale } = chart ?? {} const hasConfig = !!x_column && y_columns.length > 0 diff --git a/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx b/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx index 2a2a6a8ff1f6f..9553fa6426270 100644 --- a/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx @@ -1,177 +1,76 @@ -import { acceptUntrustedSql, untrustedSql } from '@supabase/pg-meta' -import { CodeSquare, Eye, EyeOff, Play } from 'lucide-react' +import { untrustedSql } from '@supabase/pg-meta' import { useState } from 'react' -import { cn } from 'ui' import { type Snapshot } from 'valtio' -import { - ExplorerQuery, - ExplorerQueryEditor, - ExplorerQueryFooter, - ExplorerQueryResults, -} from '../ExplorerQuery' -import { - ExplorerToolbar, - ExplorerToolbarAction, - ExplorerToolbarActions, - ExplorerToolbarIcon, - ExplorerToolbarTitle, -} from '../ExplorerToolbar' -import { QueryResultTable } from '../QueryResultTable' -import { type QueryResult } from '../types' -import { DisplaySettingsButton } from './DisplaySettingsButton' -import { QueryResultChart } from './QueryResultChart' -import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' +import { QueryEditor } from '../QueryEditor' +import { type QueryDisplay, type QueryResult } from '../types' import { SortableSection } from '@/components/ui/SortableSection' import { type DatabaseCell as DatabaseCellSchema } from '@/data/content/notebooks/notebook-schema' -import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' -import { useLatest } from '@/hooks/misc/useLatest' -import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useCurrentNotebook, useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' -import { type ResponseError } from '@/types' interface QueryCellProps { cell: Snapshot } -/** - * [Joshen] Aiming to keep PRs small so the following are deliberating missing for now: - * - Auto limit logic - * - Database selection logic - * - Data display logic - * - * QueryCell atm minimally supports running queries and rendering results - */ +type QueryCellUpdate = { sql: string } | { title: string } | { display: QueryDisplay } +/** Notebook adapter around the shared QueryEditor. */ export const QueryCell = ({ cell }: QueryCellProps) => { const snap = useNotebooksStateSnapshot() const currentNotebook = useCurrentNotebook() - const { data: project } = useSelectedProjectQuery() const cells = currentNotebook?.notebook.content?.cells ?? [] - const { title = 'Untitled snippet', row_limit, view } = cell - - const [showQuery, setShowQuery] = useState(true) - const [value, setValue] = useState(cell.unchecked_sql) + const [sql, setSql] = useState(cell.unchecked_sql) const [result, setResult] = useState() - const columns = Object.keys(result?.rows?.[0] ?? {}) - - const valueRef = useLatest(value) - - const { mutateAsync: executeQuery, isPending: isExecuting } = useExecuteSqlMutation({ - onSuccess: (data) => - setResult({ - rows: data.result, - error: undefined, - autoLimit: undefined, - }), - onError: (error) => - setResult({ - rows: undefined, - error: error as unknown as ResponseError, - autoLimit: undefined, - }), - }) - - const onRunQuery = async () => { - if (!project) return console.error('Project is required') - handleUpdateCell({ sql: value }) - - executeQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - sql: acceptUntrustedSql(untrustedSql(value)), - }) + const title = cell.title ?? 'Untitled snippet' + const display: QueryDisplay = { + view: cell.view ?? 'table', + chart: cell.chart ? { ...cell.chart, y_columns: [...cell.chart.y_columns] } : undefined, } - const handleUpdateCell = (payload: { sql: string } | { title: string }) => { + const handleUpdateCell = (payload: QueryCellUpdate) => { const notebookId = currentNotebook?.notebook.id if (!notebookId) return - const nextCells = cells.map((c) => { - if (c.id !== cell.id || c._tag !== 'database_cell') { - return c - } + const nextCells = cells.map((candidate) => { + if (candidate.id !== cell.id || candidate._tag !== 'database_cell') return candidate if ('sql' in payload) { - return { ...c, unchecked_sql: untrustedSql(payload.sql) } + return { ...candidate, unchecked_sql: untrustedSql(payload.sql) } } - const trimmedTitle = payload.title.trim() - return trimmedTitle ? { ...c, title: trimmedTitle } : c + if ('title' in payload) { + const nextTitle = payload.title.trim() + return nextTitle ? { ...candidate, title: nextTitle } : candidate + } + + return { + ...candidate, + view: payload.display.view, + chart: payload.display.chart, + } }) snap.updateCells({ id: notebookId, cells: nextCells }) } - const handleUpdateCellRef = useLatest(handleUpdateCell) - return ( - - - - - - handleUpdateCell({ title: newTitle })}> - {title} - - - - : } - tooltip={showQuery ? 'Hide query' : 'Show query'} - onClick={() => setShowQuery((prev) => !prev)} - /> - } - tooltip="Run query" - onClick={onRunQuery} - /> - - - - {showQuery && ( - - setValue(v ?? '')} - className="h-32" - actions={{ runQuery: { enabled: true, callback: onRunQuery } }} - onMount={(editor) => { - editor.onDidBlurEditorWidget(() => - handleUpdateCellRef.current({ sql: valueRef.current }) - ) - }} - /> - - )} - - - {view === 'table' && } - {view === 'chart' && } - - - -

{(result?.rows ?? []).length.toLocaleString()} rows

-

·

-

Limit {row_limit} rows

-
-
+ handleUpdateCell({ title })} + onSqlChange={setSql} + onSqlCommit={(sql) => handleUpdateCell({ sql })} + onResultChange={setResult} + onDisplayChange={(display) => handleUpdateCell({ display })} + />
) } diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor.tsx new file mode 100644 index 0000000000000..d1ecf65fd9f8a --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/QueryEditor.tsx @@ -0,0 +1,175 @@ +import { acceptUntrustedSql, untrustedSql } from '@supabase/pg-meta' +import { CodeSquare, Eye, EyeOff, Play } from 'lucide-react' +import { useState, type ReactNode } from 'react' +import { cn } from 'ui' + +import { + ExplorerQuery, + ExplorerQueryEditor, + ExplorerQueryFooter, + ExplorerQueryResults, + ExplorerQueryViewport, +} from './ExplorerQuery' +import { + ExplorerToolbar, + ExplorerToolbarAction, + ExplorerToolbarActions, + ExplorerToolbarIcon, + ExplorerToolbarTitle, +} from './ExplorerToolbar' +import { DisplaySettingsButton } from './QueryCell/DisplaySettingsButton' +import { QueryResultChart } from './QueryCell/QueryResultChart' +import { QueryResultTable } from './QueryResultTable' +import { type QueryDisplay, type QueryResult } from './types' +import { applyAutoLimit } from '@/components/interfaces/SQLEditor/SQLEditor.utils' +import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' +import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' +import { useLatest } from '@/hooks/misc/useLatest' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' + +export type QueryEditorProps = { + id: string + variant: 'embedded' | 'viewport' + title: string + sql: string + result?: QueryResult + rowLimit: number + display?: QueryDisplay + toolbarActions?: ReactNode + onTitleChange: (title: string) => void + onSqlChange: (sql: string) => void + onSqlCommit?: (sql: string) => void + onResultChange: (result: QueryResult) => void + onDisplayChange?: (display: QueryDisplay) => void +} + +/** + * Shared query editor used by query tabs, notebook cells, and other Explorer surfaces. + * The consuming surface owns persistence and surrounding chrome; this component owns + * query-level UI and execution behavior. + */ +export const QueryEditor = ({ + id, + variant, + title, + sql, + result, + rowLimit, + display, + toolbarActions, + onTitleChange, + onSqlChange, + onSqlCommit, + onResultChange, + onDisplayChange, +}: QueryEditorProps) => { + const sqlRef = useLatest(sql) + const onSqlCommitRef = useLatest(onSqlCommit) + + const { data: project, isPending: isLoadingProject } = useSelectedProjectQuery() + + const view = display?.view ?? 'table' + const columns = Object.keys(result?.rows?.[0] ?? {}) + + const [showQuery, setShowQuery] = useState(true) + + const { mutate: executeSql, isPending: isExecuting } = useExecuteSqlMutation({ + onSuccess: (data) => onResultChange({ rows: data.result }), + onError: (error) => onResultChange({ error }), + }) + + const handleRunQuery = (sqlToRun: string = sql) => { + if (!project || isLoadingProject || isExecuting || sqlToRun.trim().length === 0) return + + onSqlCommit?.(sql) + + const safeSql = acceptUntrustedSql(untrustedSql(sqlToRun)) + const limitedSql = applyAutoLimit(safeSql, rowLimit) + + executeSql({ + projectRef: project.ref, + connectionString: project.connectionString, + sql: limitedSql.sql, + autoLimit: limitedSql.appendAutoLimit ? rowLimit : undefined, + contextualInvalidation: true, + isStatementTimeoutDisabled: true, + }) + } + + const Shell = variant === 'viewport' ? ExplorerQueryViewport : ExplorerQuery + + return ( + + + + + + {title} + + {toolbarActions} + {display && onDisplayChange && ( + + )} + : } + tooltip={showQuery ? 'Hide query' : 'Show query'} + onClick={() => setShowQuery((value) => !value)} + /> + } + tooltip="Run query" + disabled={isLoadingProject || isExecuting || sql.trim().length === 0} + onClick={() => handleRunQuery()} + > + Run + + + + + {showQuery && ( + + onSqlChange(value ?? '')} + onMount={(editor) => { + editor.onDidBlurEditorWidget(() => onSqlCommitRef.current?.(sqlRef.current)) + }} + /> + + )} + + + {view === 'table' && } + {view === 'chart' && } + + + +

{(result?.rows ?? []).length.toLocaleString()} rows

+

·

+

Limit {rowLimit} rows

+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Explorer/QueryTab.tsx b/apps/studio/components/interfaces/Explorer/QueryTab.tsx new file mode 100644 index 0000000000000..432aba351d50c --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/QueryTab.tsx @@ -0,0 +1,89 @@ +import { useParams } from 'common' +import { Loader2, SquareCode } from 'lucide-react' +import { useRouter } from 'next/router' +import { useContext, useEffect, useState } from 'react' +import { Button } from 'ui' + +import { QueryEditor } from './QueryEditor' +import { type QueryResult } from './types' +import { explorerQueryState, useExplorerQueryStateSnapshot } from '@/state/explorer-query' +import { createTabId, TabsStateContext } from '@/state/tabs' + +const QUERY_ROW_LIMIT = 100 + +/** Query-tab lifecycle adapter around the shared QueryEditor. */ +export const QueryTab = () => { + const { id, ref } = useParams() + const router = useRouter() + const tabs = useContext(TabsStateContext) + const querySnap = useExplorerQueryStateSnapshot() + const [hasRestored, setHasRestored] = useState(false) + const stateDraft = id ? querySnap.drafts[id] : undefined + const draft = stateDraft?.projectRef === ref ? stateDraft : undefined + const result = draft && id ? querySnap.results[id] : undefined + + useEffect(() => { + if (!id || !ref) return + + const restored = explorerQueryState.restoreDraft({ id, projectRef: ref }) + const restoredDraft = explorerQueryState.drafts[id] + if (restored && restoredDraft) { + tabs.addTab({ + id: createTabId('query', { id }), + type: 'query', + label: restoredDraft.name, + metadata: { queryId: id }, + isPreview: false, + }) + } + setHasRestored(true) + }, [id, ref, tabs]) + + if (!hasRestored) { + return ( +
+ +
+ ) + } + + if (!id || !draft) { + return ( +
+ +
+

Query draft not found

+

+ This local draft may have been closed or cleared from this browser. +

+
+ +
+ ) + } + + const handleResultChange = (nextResult: QueryResult) => { + explorerQueryState.setResult({ + id, + result: { ...nextResult, executedAt: Date.now() }, + }) + } + + return ( + { + const name = value.trim() || 'Untitled query' + explorerQueryState.updateDraft({ id, name }) + tabs.updateTab(createTabId('query', { id }), { label: name }) + }} + onSqlChange={(sql) => explorerQueryState.updateDraft({ id, sql })} + onResultChange={handleResultChange} + /> + ) +} diff --git a/apps/studio/components/interfaces/Explorer/__tests__/NotebookEditor.test.tsx b/apps/studio/components/interfaces/Explorer/__tests__/NotebookEditor.test.tsx deleted file mode 100644 index 24e63f419a5b8..0000000000000 --- a/apps/studio/components/interfaces/Explorer/__tests__/NotebookEditor.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { NotebookEditor } from '../NotebookEditor' -import { notebooksState } from '@/state/notebooks/notebooks-state' -import type { Notebook } from '@/state/notebooks/types' -import { customRender } from '@/tests/lib/custom-render' - -const { mockUseParams, mockAddTab } = vi.hoisted(() => ({ - mockUseParams: vi.fn(), - mockAddTab: vi.fn(), -})) - -vi.mock('common', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useParams: () => mockUseParams(), - } -}) - -vi.mock('@/state/tabs', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useTabsStateSnapshot: () => ({ addTab: mockAddTab }), - } -}) - -function makeNotebook(id: string, overrides: Partial = {}): Notebook { - return { - id, - type: 'notebook', - name: 'My Notebook', - description: '', - visibility: 'project', - favorite: false, - owner_id: 7, - project_id: 42, - content: { schema_version: 1, cells: [] }, - ...overrides, - } -} - -describe('NotebookEditor tab registration', () => { - beforeEach(() => { - mockAddTab.mockClear() - mockUseParams.mockReturnValue({ ref: 'default', id: 'notebook-1' }) - - // notebooksState is a module-level singleton, so reset the state these tests touch - for (const id of Object.keys(notebooksState.notebooks)) { - delete notebooksState.notebooks[id] - } - notebooksState.needsSaving.clear() - }) - - it('registers a tab with the notebook id, type, loaded name, and metadata', () => { - notebooksState.setNotebook({ - projectRef: 'default', - notebook: makeNotebook('notebook-1', { name: 'My Notebook' }), - }) - - customRender() - - expect(mockAddTab).toHaveBeenCalledTimes(1) - expect(mockAddTab).toHaveBeenCalledWith({ - id: 'notebook-notebook-1', - type: 'notebook', - label: 'My Notebook', - metadata: { notebookId: 'notebook-1' }, - isPreview: false, - }) - }) - - it('falls back to "New Notebook" as the label when the notebook has not loaded yet', () => { - customRender() - - expect(mockAddTab).toHaveBeenCalledWith({ - id: 'notebook-notebook-1', - type: 'notebook', - label: 'New Notebook', - metadata: { notebookId: 'notebook-1' }, - isPreview: false, - }) - }) - - it('does not register a tab when there is no id in the route', () => { - mockUseParams.mockReturnValue({ ref: 'default', id: undefined }) - - customRender() - - expect(mockAddTab).not.toHaveBeenCalled() - }) -}) diff --git a/apps/studio/components/interfaces/Explorer/hooks.ts b/apps/studio/components/interfaces/Explorer/hooks.ts index d4eedf60c547a..99e21109f2a6b 100644 --- a/apps/studio/components/interfaces/Explorer/hooks.ts +++ b/apps/studio/components/interfaces/Explorer/hooks.ts @@ -4,6 +4,7 @@ import { useRouter } from 'next/router' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { generateUuid } from '@/lib/api/snippets.browser' import { useProfile } from '@/lib/profile' +import { useExplorerQueryStateSnapshot } from '@/state/explorer-query' import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' import { type Notebook } from '@/state/notebooks/types' import { Notebooks } from '@/types' @@ -78,3 +79,22 @@ This is a sample paragraph to demonstrate the Markdown cells return { createNotebook } } + +export const useCreateQuery = () => { + const router = useRouter() + const { data: project } = useSelectedProjectQuery() + const querySnap = useExplorerQueryStateSnapshot() + + const createQuery = () => { + if (!project) return console.error('Project is required') + + const id = generateUuid() + querySnap.createDraft({ id, projectRef: project.ref }) + + router.push(`/project/${project.ref}/explorer/query/${id}`) + + return id + } + + return { createQuery } +} diff --git a/apps/studio/components/interfaces/Explorer/types.ts b/apps/studio/components/interfaces/Explorer/types.ts index 91c1087fd094b..c3b07cad34e5c 100644 --- a/apps/studio/components/interfaces/Explorer/types.ts +++ b/apps/studio/components/interfaces/Explorer/types.ts @@ -1,7 +1,19 @@ -import { type ResponseError } from '@/types' - export type QueryResult = { - rows?: Record[] - error?: ResponseError + rows?: readonly Record[] + error?: { message: string; formattedError?: string } autoLimit?: number } + +export type QueryChartConfig = { + type: 'bar' | 'line' + x_column: string + y_columns: string[] + cumulative: boolean + scale: 'linear' | 'log' + show_labels: boolean +} + +export type QueryDisplay = { + view: 'table' | 'chart' + chart?: QueryChartConfig +} diff --git a/apps/studio/components/interfaces/LogDrains/OrgAuditLogDrains.tsx b/apps/studio/components/interfaces/LogDrains/OrgAuditLogDrains.tsx index aa728dc79360a..2ea83e97751bf 100644 --- a/apps/studio/components/interfaces/LogDrains/OrgAuditLogDrains.tsx +++ b/apps/studio/components/interfaces/LogDrains/OrgAuditLogDrains.tsx @@ -157,7 +157,7 @@ export function OrgAuditLogDrains() { disabled={!canManageLogDrains} onClick={handleAddDestinationClick} variant="primary" - className="rounded-r-none px-3" + className="rounded-r-none px-3 hover:z-10 focus-visible:z-10" > Add destination @@ -167,7 +167,7 @@ export function OrgAuditLogDrains() {
} > +
{ - const router = useRouter() - const { ref } = useParams() - const isActive = router.pathname.endsWith('/explorer') + const tabs = useTabsStateSnapshot() + + const openTabs = tabs.openTabs + .map((id) => tabs.tabsMap[id]) + .filter((tab) => tab !== undefined) as Tab[] + const explorerTabs = openTabs.filter((tab) => editorEntityTypes['explorer']?.includes(tab.type)) + + const ensureHomeTab = useEffectEvent(() => { + tabs.ensurePinnedTab(EXPLORER_HOME_TAB) + }) + + useEffect(() => ensureHomeTab(), []) return ( - + Open Explorer home - +
+ ) } const NewTabButton = () => { const { createNotebook } = useCreateNotebook() + const { createQuery } = useCreateQuery() return ( {}} initial={{ opacity: 0, scale: 0.8, x: -10 }} animate={{ opacity: 1, scale: 1, x: 0 }} @@ -106,6 +138,10 @@ const NewTabButton = () => { + createQuery()}> + + New query + createNotebook()}> New notebook diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx index 0fad5971029ee..3c3d66e8355cc 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx @@ -2,6 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useDebounce } from '@uidotdev/usehooks' import { LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common' import { FilePlus, FolderPlus, Plus, ScrollText, X } from 'lucide-react' +import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import { toast } from 'sonner' @@ -24,6 +25,7 @@ import { import { SearchList } from './SQLEditorNavV2/SearchList' import { SQLEditorNav } from './SQLEditorNavV2/SQLEditorNav' +import { useIsDatabaseConnectionsEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { type SqlSnippetSource } from '@/components/interfaces/SQLEditor/querySource' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useLocalStorage } from '@/hooks/misc/useLocalStorage' @@ -39,7 +41,7 @@ export const SQLEditorMenu = () => { const { data: project } = useSelectedProjectQuery() const snapV2 = useSqlEditorV2StateSnapshot() - const topForPostgres = useFlag('topForPostgres') + const isDatabaseConnectionsEnabled = useIsDatabaseConnectionsEnabled() const sqlEditorLogsSource = useFlag('sqlEditorLogsSource') const otelLegacyLogs = useFlag('otelLegacyLogs') const canCreateLogsSnippet = sqlEditorLogsSource && otelLegacyLogs @@ -178,13 +180,17 @@ export const SQLEditorMenu = () => { {showSearch ? : }
- {!topForPostgres && ( -
+
+ {isDatabaseConnectionsEnabled ? ( + + ) : ( -
- )} + )} +
) } diff --git a/apps/studio/components/layouts/Tabs/SortableTab.tsx b/apps/studio/components/layouts/Tabs/SortableTab.tsx index 8c3eccdd38c2a..28ad583c34fec 100644 --- a/apps/studio/components/layouts/Tabs/SortableTab.tsx +++ b/apps/studio/components/layouts/Tabs/SortableTab.tsx @@ -65,6 +65,7 @@ export const SortableTab = ({ const isActive = tabs.activeTab === tab.id const closeTabFromKeyboard = (event: KeyboardEvent) => { + if (tab.closable === false) return if (event.key !== 'Delete' && event.key !== 'Backspace') return event.preventDefault() event.stopPropagation() @@ -85,7 +86,7 @@ export const SortableTab = ({ value={tab.id} onAuxClick={(e) => { // Middle click closes tab - if (e.button === 1) { + if (e.button === 1 && tab.closable !== false) { e.preventDefault() onClose(tab.id) } @@ -137,33 +138,35 @@ export const SortableTab = ({ {/* Sibling of TabsTrigger — not nested inside the tab button. Only the active tab's close is in the tab order (roving tabs). Delete/Backspace on the focused tab also closes. */} - + {tab.closable !== false && ( + + )}
{index < openTabs.length && (
diff --git a/apps/studio/components/layouts/Tabs/Tabs.tsx b/apps/studio/components/layouts/Tabs/Tabs.tsx index 73b0dd1c43a94..734071d8879b9 100644 --- a/apps/studio/components/layouts/Tabs/Tabs.tsx +++ b/apps/studio/components/layouts/Tabs/Tabs.tsx @@ -50,7 +50,7 @@ export const EditorTabs = ({ newTabButton, isCollapseButtonHidden, }: EditorTabsProps) => { - const { ref, id } = useParams() + const { ref } = useParams() const router = useRouter() const { setLastVisitedSnippet, setLastVisitedTable } = useDashboardHistory() @@ -76,6 +76,7 @@ export const EditorTabs = ({ const editorTabs = !!editor ? openTabs.filter((tab) => editorEntityTypes[editor]?.includes(tab.type)) : [] + const editorTabIds = editorTabs.map((tab) => tab.id) const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event @@ -119,44 +120,32 @@ export const EditorTabs = ({ const handleCloseAll = () => { if (editor) { - const tabsToClose = - editor === 'table' - ? tabs.openTabs.filter((x) => !x.startsWith('sql')) - : tabs.openTabs.filter((x) => x.startsWith('sql')) + const tabsToClose = editorTabIds closeWithConfirmation(tabsToClose, () => { tabs.closeTabs(tabsToClose) onClearDashboardHistory() - router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}`) + const editorPath = editor === 'table' ? 'editor' : editor + router.push(`/project/${ref}/${editorPath}`) }) } } const handleCloseOthers = (tabId: string) => { if (editor) { - const tabsToClose = - editor === 'table' - ? tabs.openTabs.filter((x) => !x.startsWith('sql') && x !== tabId) - : tabs.openTabs.filter((x) => x.startsWith('sql') && x !== tabId) + const tabsToClose = editorTabIds.filter((id) => id !== tabId) closeWithConfirmation(tabsToClose, () => { tabs.closeTabs(tabsToClose) onClearDashboardHistory() - - const entityId = editor === 'table' ? tabId.split('-')[1] : tabId.split('sql-')[1] - if (id !== entityId) { - router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}/${entityId}`) - } + tabs.handleTabNavigation(tabId, router) }) } } const handleCloseRight = (tabId: string) => { if (editor) { - const openedTabs = - editor === 'table' - ? tabs.openTabs.filter((x) => !x.startsWith('sql')) - : tabs.openTabs.filter((x) => x.startsWith('sql')) + const openedTabs = editorTabIds const tabIdx = openedTabs.indexOf(tabId) const activeTabIdx = openedTabs.indexOf(tabs.activeTab!) const tabsToClose = openedTabs.slice(tabIdx + 1) @@ -166,8 +155,7 @@ export const EditorTabs = ({ const isActiveTabClosed = tabIdx < activeTabIdx if (isActiveTabClosed) { - const id = editor === 'table' ? tabId.split('-')[1] : tabId.split('sql-')[1] - router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}/${id}`) + tabs.handleTabNavigation(tabId, router) } }) } @@ -189,123 +177,128 @@ export const EditorTabs = ({ > {!isCollapseButtonHidden && } - {customTabs} - - tab.id)} - strategy={horizontalListSortingStrategy} - > - {editorTabs.map((tab, index) => ( - - - handleClose(tab.id)} - /> - - - handleClose(tab.id)}>Close - handleCloseOthers(tab.id)}> - Close Others - - handleCloseRight(tab.id)}> - Close to the Right - - Close All - - - ))} - + {/* Pinned outside the scrollable segment below, so it never scrolls out of view. */} + {customTabs} - {/* Non-draggable new tab */} - {hasNewTab && ( -
- { - if (e.key !== 'Delete' && e.key !== 'Backspace') return - e.preventDefault() - e.stopPropagation() - handleClose('new') - }} - className={cn( - 'flex items-center gap-2 px-3 text-xs', - 'bg-dash-sidebar/50 dark:bg-surface-100/50', - 'data-[state=active]:bg-dash-sidebar dark:data-[state=active]:bg-surface-100', - 'relative group h-full border-t-2 border-b-0!', - 'hover:bg-surface-300 dark:hover:bg-surface-100' - )} - > - -
- New -
- {/* Reserve close-icon width; close is a sibling overlay. */} - -
- - -
- )} +
+ tab.id)} + strategy={horizontalListSortingStrategy} + > + {editorTabs.map((tab, index) => ( + + + handleClose(tab.id)} + /> + + + handleClose(tab.id)}>Close + handleCloseOthers(tab.id)}> + Close Others + + handleCloseRight(tab.id)}> + Close to the Right + + Close All + + + ))} + - - {!hasNewTab && - (newTabButton ?? ( - - router.push( - `/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}/new?skip=true` - ) - } - initial={{ opacity: 0, scale: 0.8, x: -10 }} - animate={{ opacity: 1, scale: 1, x: 0 }} - transition={{ duration: 0.2 }} + {/* Non-draggable new tab */} + {hasNewTab && ( +
+ { + if (e.key !== 'Delete' && e.key !== 'Backspace') return + e.preventDefault() + e.stopPropagation() + handleClose('new') + }} + className={cn( + 'flex items-center gap-2 px-3 text-xs', + 'bg-dash-sidebar/50 dark:bg-surface-100/50', + 'data-[state=active]:bg-dash-sidebar dark:data-[state=active]:bg-surface-100', + 'relative group h-full border-t-2 border-b-0!', + 'hover:bg-surface-300 dark:hover:bg-surface-100' + )} > - - - ))} - -
+ +
+ New +
+ {/* Reserve close-icon width; close is a sibling overlay. */} + +
+ + +
+ )} + + + {!hasNewTab && + (newTabButton ?? ( + + router.push( + `/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}/new?skip=true` + ) + } + initial={{ opacity: 0, scale: 0.8, x: -10 }} + animate={{ opacity: 1, scale: 1, x: 0 }} + transition={{ duration: 0.2 }} + > + + + ))} + +
+
diff --git a/apps/studio/components/ui/AiAssistantDropdown.tsx b/apps/studio/components/ui/AiAssistantDropdown.tsx index 10acb318719f6..c62dc11310869 100644 --- a/apps/studio/components/ui/AiAssistantDropdown.tsx +++ b/apps/studio/components/ui/AiAssistantDropdown.tsx @@ -133,7 +133,11 @@ export function AiAssistantDropdown({ disabled={disabled} onClick={handleOpenAssistant} icon={} - className={cn('rounded-r-none border-r-0', iconOnly && 'px-1.5', className)} + className={cn( + 'rounded-r-none border-r-0 focus-visible:z-10', + iconOnly && 'px-1.5', + className + )} > {!iconOnly && label} @@ -146,7 +150,7 @@ export function AiAssistantDropdown({ variant={variant} size={size} disabled={disabled} - className={cn('rounded-l-none px-1', iconOnly && 'px-1')} + className={cn('rounded-l-none px-1 focus-visible:z-10', iconOnly && 'px-1')} icon={} /> diff --git a/apps/studio/components/ui/CodeEditor/CodeEditor.tsx b/apps/studio/components/ui/CodeEditor/CodeEditor.tsx index f912bdf6e496e..ffd6e738af7c0 100644 --- a/apps/studio/components/ui/CodeEditor/CodeEditor.tsx +++ b/apps/studio/components/ui/CodeEditor/CodeEditor.tsx @@ -42,6 +42,7 @@ interface CodeEditorProps { hideLineNumbers?: boolean className?: string wrapperClassName?: string + placeholderClassName?: string loading?: boolean options?: EditorProps['options'] value?: string @@ -71,6 +72,7 @@ export const CodeEditor = ({ hideLineNumbers = false, className, wrapperClassName, + placeholderClassName, loading, options, value, @@ -273,9 +275,10 @@ export const CodeEditor = ({ {placeholder !== undefined && (
div>p]:text-foreground-lighter [&>div>p]:m-0! tracking-tighter', - showPlaceholder ? 'block' : 'hidden' + showPlaceholder ? 'block' : 'hidden', + placeholderClassName )} > diff --git a/apps/studio/components/ui/EntityTypeIcon.tsx b/apps/studio/components/ui/EntityTypeIcon.tsx index f238dcab366ea..755e3d0ba8788 100644 --- a/apps/studio/components/ui/EntityTypeIcon.tsx +++ b/apps/studio/components/ui/EntityTypeIcon.tsx @@ -1,4 +1,4 @@ -import { Eye, GitBranch, NotebookText, ScrollText, Table2 } from 'lucide-react' +import { Eye, GitBranch, NotebookText, ScrollText, SquareCode, Table2 } from 'lucide-react' import { cn, SQL_ICON } from 'ui' import type { SqlSnippetSource } from '@/components/interfaces/SQLEditor/querySource' @@ -26,7 +26,18 @@ export const LogsSnippetIcon = ({ ) interface EntityTypeIconProps { - type: 'sql' | 'schema' | 'new' | 'r' | 'v' | 'm' | 'f' | 'p' | 'notebook' + type: + | 'sql' + | 'schema' + | 'new' + | 'r' + | 'v' + | 'm' + | 'f' + | 'p' + | 'notebook' + | 'query' + | 'explorer-home' size?: number strokeWidth?: number isActive?: boolean @@ -107,6 +118,10 @@ export const EntityTypeIcon = ({ return } + if (type === 'query') { + return + } + return (
[], key: string): boolean => - data.some((row) => (row[key] as number) <= 0) +export const checkHasNonPositiveValues = ( + data: readonly Record[], + key: string +): boolean => data.some((row) => (row[key] as number) <= 0) export const formatYAxisTick = (value: number): string => { if (Math.abs(value) >= 1_000_000) { diff --git a/apps/studio/pages/project/[ref]/explorer/index.tsx b/apps/studio/pages/project/[ref]/explorer/index.tsx index d44d4c1a2b70d..48a1e2bf30a97 100644 --- a/apps/studio/pages/project/[ref]/explorer/index.tsx +++ b/apps/studio/pages/project/[ref]/explorer/index.tsx @@ -1,9 +1,20 @@ +import { useEffect, useEffectEvent } from 'react' + import { ExplorerHome } from '@/components/interfaces/Explorer/ExplorerHome' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { ExplorerLayout } from '@/components/layouts/ExplorerLayout/ExplorerLayout' +import { EXPLORER_HOME_TAB, useTabsStateSnapshot } from '@/state/tabs' import type { NextPageWithLayout } from '@/types' const ProjectExplorerPage: NextPageWithLayout = () => { + const tabs = useTabsStateSnapshot() + + const activateHomeTab = useEffectEvent(() => { + tabs.activatePinnedTab(EXPLORER_HOME_TAB) + }) + + useEffect(() => activateHomeTab(), []) + return } diff --git a/apps/studio/pages/project/[ref]/explorer/notebook/[id].tsx b/apps/studio/pages/project/[ref]/explorer/notebook/[id].tsx index 40dc4b68e8ffd..3178327f0124a 100644 --- a/apps/studio/pages/project/[ref]/explorer/notebook/[id].tsx +++ b/apps/studio/pages/project/[ref]/explorer/notebook/[id].tsx @@ -1,9 +1,33 @@ +import { useParams } from 'common' +import { useEffect, useEffectEvent } from 'react' + import { NotebookEditor } from '@/components/interfaces/Explorer/NotebookEditor' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { ExplorerLayout } from '@/components/layouts/ExplorerLayout/ExplorerLayout' +import { useCurrentNotebook } from '@/state/notebooks/notebooks-state' +import { createTabId, useTabsStateSnapshot } from '@/state/tabs' import type { NextPageWithLayout } from '@/types' const NotebookPage: NextPageWithLayout = () => { + const { id } = useParams() + const tabs = useTabsStateSnapshot() + + const currentNotebook = useCurrentNotebook() + const { name } = currentNotebook?.notebook ?? {} + + const registerTab = useEffectEvent(() => { + if (!id) return + tabs.addTab({ + id: createTabId('notebook', { id }), + type: 'notebook', + label: name ?? 'New Notebook', + metadata: { notebookId: id }, + isPreview: false, + }) + }) + + useEffect(() => registerTab(), [id]) + return } diff --git a/apps/studio/pages/project/[ref]/explorer/query/[id].tsx b/apps/studio/pages/project/[ref]/explorer/query/[id].tsx new file mode 100644 index 0000000000000..5d3aeac84109c --- /dev/null +++ b/apps/studio/pages/project/[ref]/explorer/query/[id].tsx @@ -0,0 +1,16 @@ +import { QueryTab } from '@/components/interfaces/Explorer/QueryTab' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' +import { ExplorerLayout } from '@/components/layouts/ExplorerLayout/ExplorerLayout' +import type { NextPageWithLayout } from '@/types' + +const QueryPage: NextPageWithLayout = () => { + return +} + +QueryPage.getLayout = (page) => ( + + {page} + +) + +export default QueryPage diff --git a/apps/studio/pages/project/[ref]/settings/log-drains.tsx b/apps/studio/pages/project/[ref]/settings/log-drains.tsx index 3dac9cc99be67..d92f7971b5e68 100644 --- a/apps/studio/pages/project/[ref]/settings/log-drains.tsx +++ b/apps/studio/pages/project/[ref]/settings/log-drains.tsx @@ -215,7 +215,7 @@ const LogDrainsSettings: NextPageWithLayout = () => { disabled={!hasAccessToLogDrains || !canManageLogDrains} onClick={handleAddDestinationClick} variant="primary" - className="rounded-r-none px-3" + className="rounded-r-none px-3 hover:z-10 focus-visible:z-10" > Add destination @@ -225,7 +225,7 @@ const LogDrainsSettings: NextPageWithLayout = () => {