Joshenlim/fe 4290 add save to notebook button in query tabs (#49667)

## Context

Adds a "Save" action for query tabs in the explorer, which opts for 2
options to either add to an existing notebook, or create a new notebook.
Both of these actions just opens the notebook in a new tab with unsaved
changes - the changes will only be persisted in the DB when the user
hits "Save" on the notebook.

For adding to an existing notebook, the snippet will be appended to the
bottom of the notebook - UI will scroll to the bottom after navigating
to the notebook.
<img width="469" height="368" alt="image"
src="https://github.com/user-attachments/assets/19d16940-89e2-4d10-b71e-8d501f749668"
/>
This commit is contained in:
Joshen Lim authored and GitHub committed 2026-08-28 08:21:16 -04:00
1 parent f5f897a29b
commit c4fe153fd8
4 files changed
+189 -18

No files matched your search

@@ -29,7 +29,7 @@ import {
Trash,
} from 'lucide-react'
import { useRouter } from 'next/router'
import { useRef, useState } from 'react'
import { useEffect, useEffectEvent, useRef, useState } from 'react'
import { toast } from 'sonner'
import {
AiIconAnimation,
@@ -108,6 +108,7 @@ export const ExplorerNotebookTab = () => {
const [skipMutatingCells, setSkipMutatingCells] = useState(false)
const queryCellRefs = useRef(new Map<string, QueryEditorHandle>())
const savedContentRef = useRef<typeof content>(undefined)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const { mutate: updateNotebook, isPending: isUpdating } = useUpsertNotebookMutation({
onSuccess: () => {
@@ -322,6 +323,17 @@ export const ExplorerNotebookTab = () => {
snap.insertCellAfter({ id: notebookId, cellId: lastCellId, cell })
}
const scrollToBottomIfPending = useEffectEvent(() => {
if (!id || snap.pendingScrollToBottom !== id || !scrollContainerRef.current) return
scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight,
})
snap.clearPendingScrollToBottom()
})
useEffect(() => scrollToBottomIfPending(), [id, snap.pendingScrollToBottom, content])
if (isNotFound) {
return (
<div className="p-4 h-full bg-surface-100">
@@ -358,14 +370,6 @@ export const ExplorerNotebookTab = () => {
>
Analyze
</ExplorerToolbarAction>
<ExplorerToolbarAction
aria-label="Run notebook"
icon={<Play />}
tooltip="Run notebook"
loading={isRunningNotebook}
disabled={queryCellIds.length === 0}
onClick={handleRunNotebook}
/>
<ExplorerToolbarAction
aria-label="Save changes"
icon={<Save />}
@@ -397,10 +401,20 @@ export const ExplorerNotebookTab = () => {
</DropdownMenuContent>
</DropdownMenu>
</ExplorerToolbarActions>
<ExplorerToolbarAction
aria-label="Run notebook"
icon={<Play />}
tooltip="Run notebook"
loading={isRunningNotebook}
disabled={queryCellIds.length === 0}
onClick={handleRunNotebook}
>
Run
</ExplorerToolbarAction>
</ExplorerToolbarActions>
</ExplorerToolbar>
<div className="w-full mx-auto flex-grow min-h-0 overflow-y-auto">
<div ref={scrollContainerRef} className="w-full mx-auto flex-grow min-h-0 overflow-y-auto">
<div className="p-4 pb-10">
{cells.length === 0 && (
<EmptyStatePresentational
@@ -1,25 +1,69 @@
import { useParams } from 'common'
import { Loader2, SquareCode } from 'lucide-react'
import { useDebounce } from '@uidotdev/usehooks'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { Check, Keyboard, Loader2, MoreVertical, Save, SquareCode } from 'lucide-react'
import { useRouter } from 'next/router'
import { useCallback, useContext, useEffect, useState } from 'react'
import { Button } from 'ui'
import { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
Button,
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { ExplorerToolbarAction } from './ExplorerToolbar'
import { useCreateNotebook } from './hooks'
import { QueryEditor, type ExplorerQueryModel } from './QueryEditor'
import { type QueryDisplay, type QueryResult } from './types'
import { createQueryCellSkeleton } from './utils'
import { getNotebook } from '@/data/content/notebooks/notebook-query'
import { useNotebooksInfiniteQuery } from '@/data/content/notebooks/notebooks-infinite-query'
import { toQuerySourceBinding } from '@/data/query-sources/query-source-registry'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { explorerQueryState, useExplorerQueryStateSnapshot } from '@/state/explorer-query'
import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
import { useControlledRoleImpersonationState } from '@/state/role-impersonation-state'
import { createTabId, TabsStateContext } from '@/state/tabs'
/** Query-tab lifecycle adapter around the shared QueryEditor. */
export const ExplorerQueryTab = () => {
const { id, ref } = useParams()
const router = useRouter()
const { id, ref } = useParams()
const tabs = useContext(TabsStateContext)
const querySnap = useExplorerQueryStateSnapshot()
const { createNotebook } = useCreateNotebook()
const notebooksSnap = useNotebooksStateSnapshot()
const [isIntellisenseEnabled, setIsIntellisenseEnabled] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE,
true
)
const [restoredQueryKey, setRestoredQueryKey] = useState<string>()
const [showQuery, setShowQuery] = useState(true)
const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 500)
const { data: notebooksData, isPending } = useNotebooksInfiniteQuery({
projectRef: ref,
limit: 100,
name: search.length === 0 ? search : debouncedSearch,
})
const notebooks = useMemo(() => {
const items = notebooksData?.pages.flatMap((page) => page.content) ?? []
return items
}, [notebooksData?.pages])
const stateDraft = id ? querySnap.drafts[id] : undefined
const draft = stateDraft?.projectRef === ref ? stateDraft : undefined
@@ -94,6 +138,32 @@ export const ExplorerQueryTab = () => {
})
}
const onAddToNewNotebook = () => {
createNotebook({
cells: [createQueryCellSkeleton({ title: draft.name, sql: draft.uncheckedSql })],
})
}
const onAddToExistingNotebook = async (notebookId: string) => {
if (!ref) return
try {
if (!notebooksSnap.notebooks[notebookId]?.notebook.content) {
const notebook = await getNotebook({ projectRef: ref, id: notebookId })
notebooksSnap.setNotebook({ projectRef: ref, notebook })
}
notebooksSnap.insertCellAfter({
id: notebookId,
cell: createQueryCellSkeleton({ title: draft.name, sql: draft.uncheckedSql }),
})
notebooksSnap.requestScrollToBottom(notebookId)
router.push(`/project/${ref}/explorer/notebook/${notebookId}`)
} catch (error) {
toast.error('Failed to add query to notebook')
}
}
return (
<QueryEditor
id={id}
@@ -128,6 +198,75 @@ export const ExplorerQueryTab = () => {
persistTab()
explorerQueryState.setDisplay({ id, display })
}}
toolbarActions={
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ExplorerToolbarAction icon={<Save />} tooltip="Save query" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="end">
<DropdownMenuSub>
<DropdownMenuSubTrigger>Add to existing notebook</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="p-0">
<Command shouldFilter={false}>
<CommandInput
autoFocus
placeholder="Search notebooks..."
className="text-xs"
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandGroup>
{isPending ? (
<div className="flex flex-col p-1 gap-y-1">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
</div>
) : !notebooks?.length ? (
<p className="text-xs text-center text-foreground-lighter py-3">
No notebooks found
</p>
) : null}
{notebooks?.map((notebook) => (
<CommandItem
key={notebook.id}
value={notebook.id}
className="cursor-pointer"
onSelect={() => onAddToExistingNotebook(notebook.id)}
>
{notebook.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem onClick={onAddToNewNotebook}>
Create a new notebook
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ExplorerToolbarAction icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
className="justify-between"
onClick={() => setIsIntellisenseEnabled(!isIntellisenseEnabled)}
>
<div className="flex items-center gap-x-2">
<Keyboard size={14} />
<span>Intellisense enabled</span>
</div>
{isIntellisenseEnabled && <Check className="text-brand" size={16} />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
}
/>
)
}
@@ -366,13 +366,12 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
return (
<>
<Shell className={cn(variant === 'embedded' && 'mx-auto max-w-6xl', className)}>
<ExplorerToolbar>
<ExplorerToolbar className={cn(variant === 'viewport' && 'px-4')}>
<ExplorerToolbarIcon>
<CodeSquare size={14} />
</ExplorerToolbarIcon>
<ExplorerToolbarTitle onSaveTitle={onTitleChange}>{title}</ExplorerToolbarTitle>
<ExplorerToolbarActions>
{toolbarActions}
{onSourceChange && (
<QuerySourceMenu
disabled={pendingProposal !== null}
@@ -386,6 +385,7 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
roleImpersonationState={roleImpersonationState}
/>
)}
{display && onDisplayChange && (
<DisplaySettingsButton
result={result}
@@ -401,9 +401,12 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
tooltip={showQuery ? 'Hide query' : 'Show query'}
onClick={() => onShowQueryChange(!showQuery)}
/>
{toolbarActions}
<ExplorerToolbarAction
loading={isExecuting}
icon={<Play />}
loading={isExecuting}
tooltip={hasSelection ? 'Run selected query' : 'Run query'}
disabled={
isBusy || pendingProposal !== null || isRunDisabled || sql.trim().length === 0
@@ -25,6 +25,13 @@ export const notebooksState = proxy({
cellLocalState: proxyMap<string, NotebookCellLocalState>([]),
/** Session-only conflicts where an assistant changed the server while local edits remain. */
serverDivergedWhileDirty: proxyMap<string, 'updated' | 'deleted'>([]),
/**
* Id of the notebook the tab should scroll to the bottom of once rendered —
* set by a surface that adds a cell to a notebook it's about to navigate to
* (e.g. "Add to existing notebook" from a query tab), so the newly added
* cell lands in view.
*/
pendingScrollToBottom: undefined as string | undefined,
/**
* Load notebook into the Valtio store. No-ops if already present.
@@ -242,6 +249,14 @@ export const notebooksState = proxy({
},
addNeedsSaving: (id: string) => notebooksState.needsSaving.set(id, true),
requestScrollToBottom: (id: string) => {
notebooksState.pendingScrollToBottom = id
},
clearPendingScrollToBottom: () => {
notebooksState.pendingScrollToBottom = undefined
},
})
export const getNotebooksStateSnapshot = () => snapshot(notebooksState)