Files
supabase/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx
T
Pamela ChiaandJoshen Lim 4a941518e3 feat(studio): track Explorer runs and saves (#51004)
I added outcome events for Explorer query runs and successful manual
notebook saves. Existing page visits and preview toggles do not show
whether users complete queries or persist notebooks.

**Changed:**
- **Query usage:** Accepted runs from query tabs and notebook cells emit
submitted and terminal outcome events with a shared run ID. Canceled
confirmations emit no run events.
- **Notebook adoption:** Successful manual saves emit created or updated
events. Recreated notebooks count as creations. Unsaved drafts and
failed saves emit neither.
- **Event metadata:** Explorer action events use `Explorer` as their
page title.

**Note:** Assistant-generated saves are outside this PR. Custom
properties omit SQL and notebook content. Page visits still carry the
browser title, which can include a notebook name.

## To test

Tested on the staging preview:
- [x] Run valid and invalid SQL from an Explorer query tab. Each run
emits one submitted event and one matching completed or failed event
with the same run ID.
- [x] Run database and Logs notebook query cells, then add a markdown
cell. The query cells emit matching event pairs; the markdown cell emits
no query event.
- [x] Save a new notebook, then edit and save it again. The successful
saves emit created and updated events.
- [x] Cancel a guarded query. It emits no query run event.
- [ ] Recreate a notebook deleted on the server after local edits. A
successful save emits created, not updated.
- [x] Inspect an Explorer action event request. Its page title is
`Explorer`; page visits still use the browser title.
- [ ] Force a notebook save failure. It should emit no save event. This
case was not tested manually.

## Linear
- fixes GROWTH-1298


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

* **Analytics**
* Explorer query runs are tracked for database and log queries,
including whether they complete or fail.
* Query activity is associated with its location in Explorer, such as a
query tab or notebook cell.
  * Successful notebook saves are tracked as creations or updates.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-09-29 13:54:13 -07:00

162 lines
5.7 KiB
TypeScript

import { AlignLeft } from 'lucide-react'
import { forwardRef, useState } from 'react'
import { KeyboardShortcut } from 'ui'
import { type Snapshot } from 'valtio'
import { AddCellDropdown } from '../AddCellDropdown'
import { ExplorerToolbarAction } from '../ExplorerToolbar'
import { MoveCellDropdownContent } from '../MoveCellDropdownContent'
import { QueryEditor, type QueryEditorHandle } from '../QueryEditor'
import { type QueryDisplay, type QueryResult } from '../types'
import {
changeCellSource,
cloneChartConfig,
cloneQueryCell,
getCellDisplay,
setCellRowLimit,
setCellSql,
shouldInvalidateResultOnSourceChange,
toQueryModel,
} from './QueryCell.utils'
import { SortableSection } from '@/components/ui/SortableSection'
import {
isQueryCell,
type QueryCell as QueryCellSchema,
} from '@/data/content/notebooks/notebook-schema'
import { type QuerySourceBinding } from '@/data/query-sources/query-source-registry'
import { useCurrentNotebook, useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
import { useLocalRoleImpersonationState } from '@/state/role-impersonation-state'
import { hotkeyToKeys } from '@/state/shortcuts/formatShortcut'
import { SHORTCUT_DEFINITIONS, SHORTCUT_IDS } from '@/state/shortcuts/registry'
const PRETTIFY_SHORTCUT_KEYS = hotkeyToKeys(
SHORTCUT_DEFINITIONS[SHORTCUT_IDS.SQL_EDITOR_FORMAT].sequence[0]
)
interface QueryCellProps {
cell: Snapshot<QueryCellSchema>
onEdit?: () => void
onPrettifyQuery?: () => void
}
/** Notebook adapter around the shared QueryEditor. */
export const QueryCell = forwardRef<QueryEditorHandle, QueryCellProps>(function QueryCell(
{ cell, onEdit, onPrettifyQuery },
ref
) {
const snap = useNotebooksStateSnapshot()
const currentNotebook = useCurrentNotebook()
const [sql, setSql] = useState<string>(cell.unchecked_sql)
const [result, setResult] = useState<QueryResult>()
const roleImpersonationState = useLocalRoleImpersonationState()
const title = cell.title ?? 'Untitled query'
const showQuery =
snap.cellLocalState.get(cell._id)?.showQuery ?? currentNotebook?.status === 'new'
/**
* Applies an update to this cell. The updater runs against the cell as the store holds
* it rather than the snapshot this component rendered with, so a concurrent edit isn't
* clobbered; `isQueryCell` keeps the per-backend helpers off a markdown cell that
* somehow shares the id.
*/
const updateQueryCell = (updater: (candidate: Snapshot<QueryCellSchema>) => QueryCellSchema) => {
const notebookId = currentNotebook?.notebook.id
if (!notebookId) return
onEdit?.()
snap.updateCell({
id: notebookId,
cellId: cell._id,
updater: (candidate) => {
if (!isQueryCell(candidate)) return candidate
return updater(candidate)
},
})
}
const handleSourceChange = (source: QuerySourceBinding) => {
// The query text carries over (see `changeCellSource`), so the editor's buffer stays
// valid — but a result run against the old source (backend or time range) does not.
if (shouldInvalidateResultOnSourceChange(cell, source)) setResult(undefined)
updateQueryCell((candidate) => changeCellSource(candidate, source))
}
const handleTitleChange = (value: string) => {
const nextTitle = value.trim()
if (!nextTitle) return
updateQueryCell((candidate) => ({ ...cloneQueryCell(candidate), title: nextTitle }))
}
// Running a cell re-commits its current SQL (see QueryEditor's handleRunQuery) even when
// nothing changed — skip the store write so that doesn't spuriously mark the notebook
// unsaved.
const handleSqlCommit = (value: string) => {
if (value === cell.unchecked_sql) return
updateQueryCell((candidate) => setCellSql(candidate, value))
}
const handleDisplayChange = (display: QueryDisplay) =>
updateQueryCell((candidate) => ({
...cloneQueryCell(candidate),
view: display.view,
chart: cloneChartConfig(display.chart),
}))
const handleRowLimitChange = (rowLimit: number) =>
updateQueryCell((candidate) => setCellRowLimit(candidate, rowLimit))
return (
<SortableSection
id={cell._id}
sectionWidth="48rem"
actions={<AddCellDropdown cellId={cell._id} />}
gripDropdownContent={<MoveCellDropdownContent cellId={cell._id} />}
gripClassName="mt-2 sm:opacity-0 group-hover:opacity-100 has-[[data-state=open]]:opacity-100 transition"
>
<QueryEditor
ref={ref}
id={cell._id}
location={
currentNotebook
? {
surface: 'notebook_cell',
notebookId: currentNotebook.notebook.id,
cellId: cell._id,
}
: undefined
}
variant="embedded"
className="min-h-0"
title={title}
query={toQueryModel(cell, sql)}
result={result}
showQuery={showQuery}
onShowQueryChange={(showQuery) => snap.setQueryVisibility({ cellId: cell._id, showQuery })}
roleImpersonationState={roleImpersonationState}
display={getCellDisplay(cell)}
onTitleChange={handleTitleChange}
onSqlChange={setSql}
onSqlCommit={handleSqlCommit}
onSourceChange={handleSourceChange}
onResultChange={setResult}
onRowLimitChange={handleRowLimitChange}
onDisplayChange={handleDisplayChange}
toolbarActions={
<ExplorerToolbarAction
icon={<AlignLeft size={16} strokeWidth={2} />}
tooltip={
<div className="flex items-center gap-2.5">
<span>Prettify SQL</span>
<KeyboardShortcut keys={PRETTIFY_SHORTCUT_KEYS} />
</div>
}
onClick={onPrettifyQuery}
/>
}
/>
</SortableSection>
)
})