mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
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>
659 lines
23 KiB
TypeScript
659 lines
23 KiB
TypeScript
import {
|
|
DndContext,
|
|
DragEndEvent,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
} from '@dnd-kit/core'
|
|
import {
|
|
SortableContext,
|
|
sortableKeyboardCoordinates,
|
|
verticalListSortingStrategy,
|
|
} from '@dnd-kit/sortable'
|
|
import { acceptUntrustedSql } from '@supabase/pg-meta'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
|
import {
|
|
Check,
|
|
Copy,
|
|
Download,
|
|
FileText,
|
|
Keyboard,
|
|
Loader2,
|
|
MoreVertical,
|
|
Notebook,
|
|
NotebookText,
|
|
Play,
|
|
Save,
|
|
SearchX,
|
|
SquareCode,
|
|
Trash,
|
|
} from 'lucide-react'
|
|
import { useEffect, useEffectEvent, useRef, useState } from 'react'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
AiIconAnimation,
|
|
Badge,
|
|
Button,
|
|
Checkbox,
|
|
copyToClipboard,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from 'ui'
|
|
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
|
import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational'
|
|
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
|
|
|
import {
|
|
findQueryCellsMatchingSql,
|
|
isMutatingSql,
|
|
notebookToMarkdown,
|
|
type QueryCellSummary,
|
|
} from './ExplorerNotebookTab.utils'
|
|
import {
|
|
ExplorerToolbar,
|
|
ExplorerToolbarAction,
|
|
ExplorerToolbarActions,
|
|
ExplorerToolbarIcon,
|
|
ExplorerToolbarTitle,
|
|
} from './ExplorerToolbar'
|
|
import { useAnalyzeNotebook, useLoadNotebook } from './hooks'
|
|
import { MarkdownCell } from './MarkdownCell'
|
|
import { QueryCell } from './QueryCell'
|
|
import { type QueryEditorHandle } from './QueryEditor'
|
|
import { createMarkdownCellSkeleton, createQueryCellSkeleton } from './utils'
|
|
import { checkDestructiveQuery } from '@/components/interfaces/SQLEditor/SQLEditor.utils'
|
|
import { useExplorerDeleteItem } from '@/components/layouts/ExplorerLayout/ExplorerProvider'
|
|
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
|
import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
|
|
import {
|
|
evictNotebookFromCaches,
|
|
hasDiscardableChanges,
|
|
} from '@/data/content/notebooks/notebook-cache'
|
|
import {
|
|
isQueryCell,
|
|
WritableCell,
|
|
WritableNotebook,
|
|
} from '@/data/content/notebooks/notebook-schema'
|
|
import { useUpsertNotebookMutation } from '@/data/content/notebooks/notebook-upsert-mutation'
|
|
import { acceptUntrustedLogsSql } from '@/data/logs/safe-analytics-sql'
|
|
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
|
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
|
import { useTrack } from '@/lib/telemetry/track'
|
|
import {
|
|
getNotebooksStateSnapshot,
|
|
useCurrentNotebook,
|
|
useNotebooksStateSnapshot,
|
|
} from '@/state/notebooks/notebooks-state'
|
|
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
|
import { useShortcut } from '@/state/shortcuts/useShortcut'
|
|
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
|
|
|
|
export const ExplorerNotebookTab = () => {
|
|
const { id, ref } = useParams()
|
|
const tabs = useTabsStateSnapshot()
|
|
const snap = useNotebooksStateSnapshot()
|
|
const queryClient = useQueryClient()
|
|
const { analyzeNotebook, isCreating } = useAnalyzeNotebook()
|
|
const { onSelectDelete } = useExplorerDeleteItem()
|
|
|
|
const [isIntellisenseEnabled, setIsIntellisenseEnabled] = useLocalStorageQuery(
|
|
LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE,
|
|
true
|
|
)
|
|
|
|
const currentNotebook = useCurrentNotebook()
|
|
const { name, content } = currentNotebook?.notebook ?? {}
|
|
const { isNotFound } = useLoadNotebook({ id, projectRef: ref })
|
|
const { data: project } = useSelectedProjectQuery()
|
|
const track = useTrack()
|
|
const cells = content?.cells ?? []
|
|
const queryCellIds = cells.filter(isQueryCell).map((cell) => cell._id)
|
|
|
|
const [isRunningNotebook, setIsRunningNotebook] = useState(false)
|
|
const [isSaveBeforeAnalyzeOpen, setIsSaveBeforeAnalyzeOpen] = useState(false)
|
|
const [isSaveConflictOpen, setIsSaveConflictOpen] = useState(false)
|
|
const [pendingQueryMatches, setPendingQueryMatches] = useState<{
|
|
destructiveQueries: QueryCellSummary[]
|
|
mutatingQueries: QueryCellSummary[]
|
|
} | null>(null)
|
|
const [skipMutatingCells, setSkipMutatingCells] = useState(false)
|
|
const [isExportingPdf, setIsExportingPdf] = useState(false)
|
|
|
|
const queryCellRefs = useRef(new Map<string, QueryEditorHandle>())
|
|
const savedContentRef = useRef<typeof content>(undefined)
|
|
const confirmedCreatedNotebookIdsRef = useRef(new Set<string>())
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
|
|
|
const { mutate: updateNotebook, isPending: isUpdating } = useUpsertNotebookMutation<{
|
|
isCreation: boolean
|
|
}>({
|
|
onMutate: (variables) => ({
|
|
isCreation:
|
|
(currentNotebook?.status === 'new' ||
|
|
snap.serverDivergedWhileDirty.get(variables.id) === 'deleted') &&
|
|
!confirmedCreatedNotebookIdsRef.current.has(variables.id),
|
|
}),
|
|
onSuccess: (data, variables, context) => {
|
|
const { isCreation } = context
|
|
if (isCreation) confirmedCreatedNotebookIdsRef.current.add(variables.id)
|
|
|
|
track(
|
|
isCreation ? 'explorer_notebook_created' : 'explorer_notebook_updated',
|
|
{ notebookId: variables.id },
|
|
{ project: variables.projectRef }
|
|
)
|
|
|
|
if (id && content === savedContentRef.current) {
|
|
snap.markSaved({ id, updatedAt: data?.updated_at })
|
|
toast.success('Successfully saved notebook!')
|
|
if (isSaveBeforeAnalyzeOpen) {
|
|
setIsSaveBeforeAnalyzeOpen(false)
|
|
handleAnalyze()
|
|
}
|
|
}
|
|
},
|
|
})
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
|
)
|
|
|
|
const persistNotebookTab = () => {
|
|
const notebookId = currentNotebook?.notebook.id
|
|
if (!notebookId) return
|
|
|
|
tabs.makeTabPermanent(createTabId('notebook', { id: notebookId }))
|
|
}
|
|
|
|
const handleSaveTitle = (titleValue: string) => {
|
|
persistNotebookTab()
|
|
const trimmedName = titleValue.trim()
|
|
if (id && trimmedName && trimmedName !== name) {
|
|
snap.renameNotebook({ id, name: trimmedName })
|
|
tabs.updateTab(createTabId('notebook', { id }), { label: trimmedName })
|
|
}
|
|
}
|
|
|
|
const runNotebook = async ({
|
|
cellIdsToRun,
|
|
force = false,
|
|
}: {
|
|
cellIdsToRun: string[]
|
|
force?: boolean
|
|
}) => {
|
|
persistNotebookTab()
|
|
setIsRunningNotebook(true)
|
|
|
|
try {
|
|
await Promise.allSettled(
|
|
cellIdsToRun.map((cellId) => queryCellRefs.current.get(cellId)?.run(force))
|
|
)
|
|
} finally {
|
|
setIsRunningNotebook(false)
|
|
}
|
|
}
|
|
|
|
const getFreshCells = () => {
|
|
const freshNotebook = id ? getNotebooksStateSnapshot().notebooks[id] : undefined
|
|
if (!freshNotebook || freshNotebook.projectRef !== ref) return cells
|
|
return freshNotebook.notebook.content?.cells ?? []
|
|
}
|
|
|
|
const handleRunNotebook = () => {
|
|
const freshCells = getFreshCells()
|
|
const { destructiveQueries, mutatingQueries } = findQueryCellsMatchingSql({
|
|
cells: freshCells,
|
|
getLiveSql: (cellId) => queryCellRefs.current.get(cellId)?.getSql(),
|
|
matchers: {
|
|
destructiveQueries: checkDestructiveQuery,
|
|
mutatingQueries: isMutatingSql,
|
|
},
|
|
})
|
|
if (mutatingQueries.length === 0) {
|
|
runNotebook({ cellIdsToRun: freshCells.filter(isQueryCell).map((cell) => cell._id) })
|
|
} else {
|
|
setSkipMutatingCells(false)
|
|
setPendingQueryMatches({ destructiveQueries, mutatingQueries })
|
|
}
|
|
}
|
|
|
|
const handleConfirmRunNotebook = () => {
|
|
const mutatingCellIds = new Set(
|
|
(pendingQueryMatches?.mutatingQueries ?? []).map((cell) => cell.id)
|
|
)
|
|
const freshCells = getFreshCells()
|
|
const freshQueryCellIds = freshCells.filter(isQueryCell).map((cell) => cell._id)
|
|
const cellIdsToRun = skipMutatingCells
|
|
? freshQueryCellIds.filter((id) => !mutatingCellIds.has(id))
|
|
: freshQueryCellIds
|
|
|
|
setPendingQueryMatches(null)
|
|
runNotebook({ cellIdsToRun, force: true })
|
|
}
|
|
|
|
const persistNotebook = () => {
|
|
const notebookId = currentNotebook?.notebook.id
|
|
if (!ref || !notebookId || !name || !content) return
|
|
|
|
persistNotebookTab()
|
|
|
|
const writableContent: WritableNotebook = {
|
|
schema_version: content.schema_version,
|
|
cells: content.cells.map((cell): WritableCell => {
|
|
switch (cell._tag) {
|
|
case 'markdown_cell':
|
|
return cell
|
|
case 'database_cell': {
|
|
const { unchecked_sql, chart, ...rest } = cell
|
|
return {
|
|
...rest,
|
|
chart: chart ? { ...chart, y_series: [...chart.y_series] } : undefined,
|
|
sql: acceptUntrustedSql(unchecked_sql),
|
|
}
|
|
}
|
|
case 'log_cell': {
|
|
const { unchecked_sql, chart, ...rest } = cell
|
|
return {
|
|
...rest,
|
|
chart: chart ? { ...chart, y_series: [...chart.y_series] } : undefined,
|
|
sql: acceptUntrustedLogsSql(unchecked_sql),
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
}
|
|
|
|
if (snap.serverDivergedWhileDirty.get(notebookId) === 'deleted') {
|
|
writableContent.cells = writableContent.cells.map(({ _id: _, ...cell }) => cell)
|
|
}
|
|
|
|
// [Joshen] For tracking if a notebook is updated while being saved, so that we do not
|
|
// incorrectly show the saved toast if it's subsequently then saved once again while
|
|
// the initial save is midflight
|
|
savedContentRef.current = content
|
|
|
|
updateNotebook({
|
|
projectRef: ref,
|
|
id: notebookId,
|
|
name,
|
|
description: currentNotebook?.notebook.description ?? undefined,
|
|
content: writableContent,
|
|
})
|
|
}
|
|
|
|
const handleSaveNotebook = () => {
|
|
const notebookId = currentNotebook?.notebook.id
|
|
if (notebookId && snap.serverDivergedWhileDirty.get(notebookId)) {
|
|
setIsSaveConflictOpen(true)
|
|
return
|
|
}
|
|
|
|
persistNotebook()
|
|
}
|
|
|
|
useShortcut(SHORTCUT_IDS.EXPLORER_NOTEBOOK_SAVE, handleSaveNotebook, {
|
|
enabled: !!content && !isUpdating,
|
|
})
|
|
|
|
const handleSaveAnyway = () => {
|
|
setIsSaveConflictOpen(false)
|
|
persistNotebook()
|
|
}
|
|
|
|
const handleDiscardNotebookChanges = async () => {
|
|
if (!ref || !id) return
|
|
|
|
const wasDeletedOnServer = snap.serverDivergedWhileDirty.get(id) === 'deleted'
|
|
setIsSaveConflictOpen(false)
|
|
const evicted = await evictNotebookFromCaches({ queryClient, projectRef: ref, id })
|
|
if (wasDeletedOnServer && evicted) {
|
|
tabs.removeTab(createTabId('notebook', { id }))
|
|
}
|
|
}
|
|
|
|
const handleAnalyze = () => analyzeNotebook({ id, name })
|
|
|
|
const handleClickAnalyze = () => {
|
|
if (hasDiscardableChanges(currentNotebook)) {
|
|
setIsSaveBeforeAnalyzeOpen(true)
|
|
} else {
|
|
handleAnalyze()
|
|
}
|
|
}
|
|
|
|
const handleCopyAsMarkdown = async () => {
|
|
try {
|
|
await copyToClipboard(
|
|
notebookToMarkdown({
|
|
name: name ?? '',
|
|
cells,
|
|
getResult: (cellId) => queryCellRefs.current.get(cellId)?.getResult(),
|
|
}),
|
|
() => toast.success('Copied notebook as Markdown to clipboard')
|
|
)
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
toast.error('Failed to copy notebook as Markdown: ' + message)
|
|
}
|
|
}
|
|
|
|
const handleExportPdf = async () => {
|
|
setIsExportingPdf(true)
|
|
try {
|
|
const { exportNotebookToPdf } = await import('./NotebookPdf/exportNotebookToPdf')
|
|
await exportNotebookToPdf({
|
|
name: name ?? 'Untitled notebook',
|
|
projectName: project?.name,
|
|
cells,
|
|
getResult: (cellId) => queryCellRefs.current.get(cellId)?.getResult(),
|
|
})
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
toast.error('Failed to export notebook as PDF: ' + message)
|
|
} finally {
|
|
setIsExportingPdf(false)
|
|
}
|
|
}
|
|
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
|
persistNotebookTab()
|
|
|
|
const { active, over } = event
|
|
if (!id || !over || active.id === over.id) return
|
|
|
|
snap.reorderCells({ id, activeCellId: active.id, overCellId: over.id })
|
|
}
|
|
|
|
const onSelectAddCell = (type: 'markdown' | 'query') => {
|
|
persistNotebookTab()
|
|
|
|
const notebookId = currentNotebook?.notebook.id
|
|
if (!notebookId) return
|
|
|
|
const cell = type === 'markdown' ? createMarkdownCellSkeleton() : createQueryCellSkeleton()
|
|
const lastCellId = cells[cells.length - 1]?._id
|
|
|
|
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">
|
|
<EmptyStatePresentational
|
|
icon={<SearchX className="text-foreground-lighter" />}
|
|
title="Notebook not found"
|
|
description="This notebook may have been deleted or does not exist."
|
|
contentClassName="[&>h3]:text-sm [&>p]:text-xs"
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!content) {
|
|
return (
|
|
<div className="flex flex-col h-full items-center justify-center bg-surface-100">
|
|
<Loader2 className="animate-spin text-foreground-muted" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full bg-surface-100">
|
|
<ExplorerToolbar className="px-4">
|
|
<ExplorerToolbarIcon>
|
|
<NotebookText size={16} strokeWidth={2} />
|
|
</ExplorerToolbarIcon>
|
|
<ExplorerToolbarTitle onSaveTitle={handleSaveTitle}>{name ?? ''}</ExplorerToolbarTitle>
|
|
<ExplorerToolbarActions>
|
|
<ExplorerToolbarAction
|
|
className="group"
|
|
icon={
|
|
<AiIconAnimation
|
|
size={16}
|
|
className="text-tertiary-foreground group-hover:text-primary"
|
|
/>
|
|
}
|
|
loading={isCreating}
|
|
disabled={cells.length === 0}
|
|
tooltip={cells.length === 0 ? 'Add a cell to the notebook to analyze it' : undefined}
|
|
onClick={handleClickAnalyze}
|
|
>
|
|
Analyze
|
|
</ExplorerToolbarAction>
|
|
<ShortcutTooltip
|
|
side="bottom"
|
|
shortcutId={SHORTCUT_IDS.EXPLORER_NOTEBOOK_SAVE}
|
|
label="Save changes"
|
|
>
|
|
<ExplorerToolbarAction
|
|
aria-label="Save changes"
|
|
icon={<Save size={16} strokeWidth={2} />}
|
|
loading={isUpdating}
|
|
onClick={handleSaveNotebook}
|
|
/>
|
|
</ShortcutTooltip>
|
|
<ExplorerToolbarActions>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<ExplorerToolbarAction
|
|
aria-label="More options"
|
|
icon={<MoreVertical size={16} strokeWidth={2} />}
|
|
/>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-48">
|
|
<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-primary" size={16} />}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem className="gap-x-2" onClick={handleCopyAsMarkdown}>
|
|
<Copy size={14} />
|
|
<span>Copy as Markdown</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
className="gap-x-2"
|
|
disabled={isExportingPdf}
|
|
onClick={handleExportPdf}
|
|
>
|
|
{isExportingPdf ? (
|
|
<Loader2 size={14} className="animate-spin" />
|
|
) : (
|
|
<Download size={14} />
|
|
)}
|
|
<span>Export as PDF</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
className="gap-x-2"
|
|
onClick={() => id && onSelectDelete({ id, type: 'notebook', name: name ?? '' })}
|
|
>
|
|
<Trash size={14} />
|
|
<span>Delete notebook</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</ExplorerToolbarActions>
|
|
<ButtonTooltip
|
|
type="button"
|
|
variant="default"
|
|
size="tiny"
|
|
className="ml-1"
|
|
aria-label="Run notebook"
|
|
icon={<Play size={16} strokeWidth={2} />}
|
|
tooltip={{ content: { side: 'bottom', text: 'Run notebook' } }}
|
|
loading={isRunningNotebook}
|
|
disabled={queryCellIds.length === 0}
|
|
onClick={handleRunNotebook}
|
|
>
|
|
Run
|
|
</ButtonTooltip>
|
|
</ExplorerToolbarActions>
|
|
</ExplorerToolbar>
|
|
|
|
<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
|
|
icon={<Notebook className="text-foreground-lighter" />}
|
|
title="This notebook is empty"
|
|
description="Add a query cell to run SQL against your database or logs."
|
|
contentClassName="[&>h3]:text-sm [&>p]:text-xs"
|
|
>
|
|
<div className="flex items-center gap-x-2">
|
|
<Button onClick={() => onSelectAddCell('query')}>Add query</Button>
|
|
<Button onClick={() => onSelectAddCell('markdown')}>Add markdown</Button>
|
|
</div>
|
|
</EmptyStatePresentational>
|
|
)}
|
|
{cells.length > 0 && (
|
|
<>
|
|
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
|
<SortableContext
|
|
items={cells.map((cell) => cell._id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<div className="flex flex-col gap-y-4">
|
|
{cells.map((cell) =>
|
|
isQueryCell(cell) ? (
|
|
<QueryCell
|
|
key={cell._id}
|
|
cell={cell}
|
|
onEdit={persistNotebookTab}
|
|
onPrettifyQuery={() => queryCellRefs.current.get(cell._id)?.prettify()}
|
|
ref={(instance) => {
|
|
if (instance) queryCellRefs.current.set(cell._id, instance)
|
|
else queryCellRefs.current.delete(cell._id)
|
|
}}
|
|
/>
|
|
) : (
|
|
<MarkdownCell key={cell._id} cell={cell} onEdit={persistNotebookTab} />
|
|
)
|
|
)}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
|
|
<div className="flex items-center justify-center gap-x-2 mt-4">
|
|
<ButtonTooltip
|
|
variant="outline"
|
|
size="small"
|
|
icon={<SquareCode />}
|
|
className="w-[34px]"
|
|
onClick={() => onSelectAddCell('query')}
|
|
tooltip={{ content: { side: 'bottom', text: 'Add query' } }}
|
|
/>
|
|
<ButtonTooltip
|
|
variant="outline"
|
|
size="small"
|
|
icon={<FileText />}
|
|
className="w-[34px]"
|
|
onClick={() => onSelectAddCell('markdown')}
|
|
tooltip={{ content: { side: 'bottom', text: 'Add markdown' } }}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<ConfirmationModal
|
|
size="small"
|
|
visible={isSaveBeforeAnalyzeOpen}
|
|
title="Save notebook before analyzing?"
|
|
confirmLabel="Save and analyze"
|
|
confirmLabelLoading="Saving notebook"
|
|
loading={isUpdating}
|
|
onCancel={() => setIsSaveBeforeAnalyzeOpen(false)}
|
|
onConfirm={handleSaveNotebook}
|
|
>
|
|
<p className="text-sm">
|
|
This notebook has unsaved changes. Save it first so the assistant analyzes the latest
|
|
content.
|
|
</p>
|
|
</ConfirmationModal>
|
|
|
|
<ConfirmationModal
|
|
size="small"
|
|
visible={isSaveConflictOpen}
|
|
title="Notebook changed on the server"
|
|
additionalActionLabel="Discard changes"
|
|
confirmLabel={
|
|
id && snap.serverDivergedWhileDirty.get(id) === 'deleted' ? 'Recreate' : 'Save anyway'
|
|
}
|
|
onAdditionalAction={handleDiscardNotebookChanges}
|
|
onCancel={() => setIsSaveConflictOpen(false)}
|
|
onConfirm={handleSaveAnyway}
|
|
>
|
|
<p className="text-sm">
|
|
{id && snap.serverDivergedWhileDirty.get(id) === 'deleted'
|
|
? 'This notebook was deleted on the server after your local changes. Saving will recreate it.'
|
|
: 'This notebook changed on the server after your local changes. Saving will overwrite those changes.'}
|
|
</p>
|
|
</ConfirmationModal>
|
|
|
|
<ConfirmationModal
|
|
size="small"
|
|
visible={pendingQueryMatches !== null}
|
|
title="Confirm to run notebook"
|
|
confirmLabel={skipMutatingCells ? 'Run read-only cells' : 'Run all cells'}
|
|
variant="warning"
|
|
onCancel={() => setPendingQueryMatches(null)}
|
|
onConfirm={handleConfirmRunNotebook}
|
|
>
|
|
<p className="text-sm">
|
|
This notebook has {pendingQueryMatches?.mutatingQueries.length ?? 0}{' '}
|
|
{pendingQueryMatches?.mutatingQueries.length === 1 ? 'query' : 'queries'} that{' '}
|
|
{pendingQueryMatches?.mutatingQueries.length === 1 ? 'modifies' : 'modify'} data or schema
|
|
and cannot be undone once run:
|
|
</p>
|
|
<ul className="text-sm list-disc pl-4 mt-2">
|
|
{pendingQueryMatches?.mutatingQueries.map((cell) => (
|
|
<li key={cell.id} className="flex items-center gap-2">
|
|
{cell.title}
|
|
{pendingQueryMatches.destructiveQueries.some(({ id }) => id === cell.id) && (
|
|
<Badge variant="destructive">Destructive</Badge>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<FormItemLayout
|
|
isReactForm={false}
|
|
layout="flex"
|
|
id="skipMutatingCells"
|
|
label="Skip these queries"
|
|
description="Run only the read-only cells in this notebook"
|
|
className="mt-4 [&>div:first-child>button]:translate-y-0.5"
|
|
>
|
|
<Checkbox
|
|
id="skipMutatingCells"
|
|
checked={skipMutatingCells}
|
|
onCheckedChange={(value) => setSkipMutatingCells(!!value)}
|
|
/>
|
|
</FormItemLayout>
|
|
</ConfirmationModal>
|
|
</div>
|
|
)
|
|
}
|