refactor(studio): share Explorer query editor (#49041)

## Stack

Depends on #49027. Followed by #49038.

## Summary

- extract a controlled `QueryEditor` from the existing notebook query
cell
- reuse it from `QueryCell`, leaving notebook persistence and
sortable-block behavior in the adapter
- make table/chart result settings controlled so other query surfaces
can share them
- persist notebook SQL on editor blur and query execution

## To test

1. Open a notebook query cell, edit and rename it, then run the query
and confirm results appear.
2. Switch between table and chart results and confirm notebook
move/delete actions still work.

## Why

Notebooks, query tabs, and future chat tabs need consistent query
actions and result rendering without duplicating the notebook
implementation.

## Impact

This is primarily a refactor of the existing notebook query experience.
It introduces no new query-tab routes or source-selection behavior.

## Validation

- fresh non-incremental Studio TypeScript check
- focused NotebookEditor component tests

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a shared query editor with SQL editing, execution, validation,
visibility controls, editable titles, row limits, and loading/error
states.
* Added table and chart result views, including customizable bar and
line charts.
* Added support for switching display modes and updating chart settings.

* **Improvements**
  * Improved query result handling and display-setting updates.
  * Repositioned the logarithmic-scale tooltip for better visibility.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Saxon Fletcher authored and GitHub committed 2026-08-13 17:59:30 +07:00
1 parent cc6fe2100a
commit ace9422bfd
6 files changed
+253 -198

No files matched your search

@@ -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<DatabaseCellSchema>
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<QueryChartConfig>) => {
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 = ({
</SelectItem>
</TooltipTrigger>
{!canToggleLogScale && (
<TooltipContent side="right">
<TooltipContent side="left">
{y_columns.length === 0
? 'Select a column for the Y axis first'
: 'Data contains zero or negative values'}
@@ -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<DatabaseCellSchema>
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
@@ -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<DatabaseCellSchema>
}
/**
* [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<string>(cell.unchecked_sql)
const [sql, setSql] = useState<string>(cell.unchecked_sql)
const [result, setResult] = useState<QueryResult>()
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 (
<SortableSection gripClassName="mt-2.5" id={cell.id}>
<ExplorerQuery className="max-w-4xl mx-auto">
<ExplorerToolbar>
<ExplorerToolbarIcon>
<CodeSquare size={14} />
</ExplorerToolbarIcon>
<ExplorerToolbarTitle onSaveTitle={(newTitle) => handleUpdateCell({ title: newTitle })}>
{title}
</ExplorerToolbarTitle>
<ExplorerToolbarActions>
<DisplaySettingsButton
cell={cell}
result={result}
columns={columns}
disabled={(result?.rows ?? []).length === 0}
/>
<ExplorerToolbarAction
icon={showQuery ? <EyeOff /> : <Eye />}
tooltip={showQuery ? 'Hide query' : 'Show query'}
onClick={() => setShowQuery((prev) => !prev)}
/>
<ExplorerToolbarAction
loading={isExecuting}
icon={<Play />}
tooltip="Run query"
onClick={onRunQuery}
/>
</ExplorerToolbarActions>
</ExplorerToolbar>
{showQuery && (
<ExplorerQueryEditor>
<CodeEditor
language="pgsql"
value={value}
onInputChange={(v) => setValue(v ?? '')}
className="h-32"
actions={{ runQuery: { enabled: true, callback: onRunQuery } }}
onMount={(editor) => {
editor.onDidBlurEditorWidget(() =>
handleUpdateCellRef.current({ sql: valueRef.current })
)
}}
/>
</ExplorerQueryEditor>
)}
<ExplorerQueryResults
className={cn(
(result?.rows ?? []).length === 0 && view === 'table'
? 'flex items-center justify-center'
: 'overflow-x-auto'
)}
>
{view === 'table' && <QueryResultTable result={result} />}
{view === 'chart' && <QueryResultChart cell={cell} result={result} />}
</ExplorerQueryResults>
<ExplorerQueryFooter className="flex items-center gap-x-2">
<p>{(result?.rows ?? []).length.toLocaleString()} rows</p>
<p>·</p>
<p>Limit {row_limit} rows</p>
</ExplorerQueryFooter>
</ExplorerQuery>
<QueryEditor
id={cell.id}
variant="embedded"
title={title}
sql={sql}
result={result}
rowLimit={cell.row_limit}
display={display}
onTitleChange={(title) => handleUpdateCell({ title })}
onSqlChange={setSql}
onSqlCommit={(sql) => handleUpdateCell({ sql })}
onResultChange={setResult}
onDisplayChange={(display) => handleUpdateCell({ display })}
/>
</SortableSection>
)
}
@@ -0,0 +1,172 @@
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 (
<Shell className={variant === 'embedded' ? 'mx-auto max-w-4xl' : undefined}>
<ExplorerToolbar>
<ExplorerToolbarIcon>
<CodeSquare size={14} />
</ExplorerToolbarIcon>
<ExplorerToolbarTitle onSaveTitle={onTitleChange}>{title}</ExplorerToolbarTitle>
<ExplorerToolbarActions>
{toolbarActions}
{display && onDisplayChange && (
<DisplaySettingsButton
result={result}
display={display}
columns={columns}
disabled={(result?.rows ?? []).length === 0}
onChange={onDisplayChange}
/>
)}
<ExplorerToolbarAction
icon={showQuery ? <EyeOff /> : <Eye />}
tooltip={showQuery ? 'Hide query' : 'Show query'}
onClick={() => setShowQuery((value) => !value)}
/>
<ExplorerToolbarAction
loading={isExecuting || isLoadingProject}
icon={<Play />}
tooltip="Run query"
disabled={isLoadingProject || isExecuting || sql.trim().length === 0}
onClick={() => handleRunQuery()}
>
Run
</ExplorerToolbarAction>
</ExplorerToolbarActions>
</ExplorerToolbar>
{showQuery && (
<ExplorerQueryEditor className={variant === 'viewport' ? 'h-[45%] min-h-48' : undefined}>
<CodeEditor
id={`explorer-query-${id}`}
language="pgsql"
value={sql}
placeholder="select * from your_table limit 100;"
className={variant === 'embedded' ? 'h-32' : undefined}
actions={{ runQuery: { enabled: true, callback: handleRunQuery } }}
options={{ minimap: { enabled: false }, padding: { top: 8 } }}
onInputChange={(value) => onSqlChange(value ?? '')}
onMount={(editor) => {
editor.onDidBlurEditorWidget(() => onSqlCommitRef.current?.(sqlRef.current))
}}
/>
</ExplorerQueryEditor>
)}
<ExplorerQueryResults
className={cn(
(result?.rows ?? []).length === 0 && view === 'table'
? 'items-center justify-center'
: 'overflow-x-auto'
)}
>
{view === 'table' && <QueryResultTable result={result} />}
{view === 'chart' && <QueryResultChart chart={display?.chart} result={result} />}
</ExplorerQueryResults>
<ExplorerQueryFooter className="flex items-center gap-x-2">
<p>{(result?.rows ?? []).length.toLocaleString()} rows</p>
<p>·</p>
<p>Limit {rowLimit} rows</p>
</ExplorerQueryFooter>
</Shell>
)
}
@@ -1,7 +1,19 @@
import { type ResponseError } from '@/types'
export type QueryResult = {
rows?: Record<string, unknown>[]
error?: ResponseError
rows?: readonly Record<string, unknown>[]
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
}
@@ -1,5 +1,7 @@
export const checkHasNonPositiveValues = (data: Record<string, unknown>[], key: string): boolean =>
data.some((row) => (row[key] as number) <= 0)
export const checkHasNonPositiveValues = (
data: readonly Record<string, unknown>[],
key: string
): boolean => data.some((row) => (row[key] as number) <= 0)
export const formatYAxisTick = (value: number): string => {
if (Math.abs(value) >= 1_000_000) {