mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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)
|
||||
|
||||
Reference in new issue
Block a user