mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
Joshen/fe 4077 explorer query cells (#48989)
## Context Related to Explorer / Notebooks - adds a barebones query cell that minimally can run SQL queries + render results The intention is to migrate the components used in the SQL Editor into this new Query cell since all the functionality is very similar, but the SQL Editor component is tightly coupled to the SQL Editor valtio store. So we'll be duplicating a bit of UI for now - which will also make deprecating the SQL Editor eventually a bit easier by just deleting them Have deliberately omitted a lot of details for now just to keep the PRs small, so will be continuing to build out the QueryCell's functionality in subsequent PRs. This includes - Source selector - Data display (Table / Chart) - Autolimit logic Other changes also includes - Updating NotebookEditor to use the new Explorer UI components that Saxon introduced <img width="500" alt="image" src="https://github.com/user-attachments/assets/d709f6f1-f6cc-4e6a-babd-f5b68bba2a55" /> <img width="500" alt="image" src="https://github.com/user-attachments/assets/a286ceca-ac84-4fbd-afd5-b24f940e2e29" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added notebook database cells for writing, editing, and running SQL queries. * Added query result displays with loading, empty, error, row-limit, and result states. * Added editable notebook titles with save and cancel controls. * New notebooks can start with customizable Markdown and SQL cells. * Added helpful SQL error actions, including copying messages, database connection guidance, and AI Assistant support where available. * **Improvements** * Improved notebook spacing, section layout, toolbar tooltips, and empty-result presentation. * Markdown changes now save automatically through the notebook editor. * Improved drag-and-drop controls and query visibility management. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
cf36ad9e52
commit
77c5a0b9d9
15 files changed
+581
-163
No files matched your search
@@ -1,9 +1,11 @@
|
||||
import { untrustedSql } from '@supabase/pg-meta'
|
||||
import { MessageCirclePlus, NotebookText, SquareCode } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useCreateNotebook } from './hooks'
|
||||
import { ActionCard } from '@/components/layouts/Tabs/ActionCard'
|
||||
import { AssistantChatForm } from '@/components/ui/AIAssistantPanel/AssistantChatForm'
|
||||
import { generateUuid } from '@/lib/api/snippets.browser'
|
||||
import { AssistantModel } from '@/state/ai-assistant-state'
|
||||
|
||||
export const ExplorerHome = () => {
|
||||
@@ -43,14 +45,26 @@ export const ExplorerHome = () => {
|
||||
title="Create a notebook"
|
||||
description="Combine notes, queries, and results"
|
||||
bgColor="bg-blue-500"
|
||||
onClick={createNotebook}
|
||||
onClick={() => createNotebook()}
|
||||
/>
|
||||
<ActionCard
|
||||
icon={<SquareCode className="h-4 w-4 text-foreground" strokeWidth={1.5} />}
|
||||
title="Run SQL"
|
||||
description="Write and run an ad-hoc query"
|
||||
bgColor="bg-blue-500"
|
||||
onClick={onCreateNotebook}
|
||||
onClick={() =>
|
||||
createNotebook({
|
||||
name: 'SQL query',
|
||||
cells: [
|
||||
{
|
||||
_tag: 'database_cell',
|
||||
id: generateUuid(),
|
||||
unchecked_sql: untrustedSql(''),
|
||||
row_limit: 100,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
File renamed without changes.
+79
-13
@@ -1,5 +1,9 @@
|
||||
import React from 'react'
|
||||
import { Edit } from 'lucide-react'
|
||||
import React, { useState } from 'react'
|
||||
import { Button, cn } from 'ui'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
|
||||
export type ExplorerToolbarProps = React.ComponentProps<'div'>
|
||||
|
||||
@@ -15,7 +19,7 @@ const ExplorerToolbar = ({ className, role = 'toolbar', ...props }: ExplorerTool
|
||||
data-slot="explorer-toolbar"
|
||||
role={role}
|
||||
className={cn(
|
||||
'flex h-10 w-full shrink-0 items-center gap-2 border-b bg-transparent px-3 md:min-h-[var(--header-height,2.5rem)]',
|
||||
'flex h-10 w-full shrink-0 items-center gap-2 border-b bg-transparent px-3',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -40,16 +44,74 @@ const ExplorerToolbarIcon = ({
|
||||
)
|
||||
ExplorerToolbarIcon.displayName = 'ExplorerToolbarIcon'
|
||||
|
||||
export type ExplorerToolbarTitleProps = React.ComponentProps<'div'>
|
||||
export type ExplorerToolbarTitleProps = Omit<React.ComponentProps<'div'>, 'children'> & {
|
||||
children: string
|
||||
onSaveTitle?: (value: string) => void
|
||||
}
|
||||
|
||||
/** Flexible title region for static text or an editable resource name. */
|
||||
const ExplorerToolbarTitle = ({ className, ...props }: ExplorerToolbarTitleProps) => (
|
||||
<div
|
||||
data-slot="explorer-toolbar-title"
|
||||
className={cn('min-w-0 flex-1 truncate text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
const ExplorerToolbarTitle = ({
|
||||
children: title,
|
||||
className,
|
||||
onSaveTitle,
|
||||
...props
|
||||
}: ExplorerToolbarTitleProps) => {
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false)
|
||||
const [value, setValue] = useState(title)
|
||||
|
||||
const handleStartEditing = () => {
|
||||
setValue(title)
|
||||
setIsEditingTitle(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="explorer-toolbar-title"
|
||||
className={cn('min-w-0 flex-1 truncate text-sm', className)}
|
||||
{...props}
|
||||
>
|
||||
{isEditingTitle ? (
|
||||
<Input
|
||||
autoFocus
|
||||
size="tiny"
|
||||
containerClassName="max-w-64 has-[[data-slot=input-group-control]:focus-visible]:ring-0"
|
||||
className="outline-none"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (isEditingTitle) {
|
||||
setIsEditingTitle(false)
|
||||
onSaveTitle?.(value)
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsEditingTitle(false)
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsEditingTitle(false)
|
||||
onSaveTitle?.(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : onSaveTitle ? (
|
||||
<Button
|
||||
variant="text"
|
||||
className="group"
|
||||
onClick={handleStartEditing}
|
||||
iconRight={<Edit className="opacity-0 group-hover:opacity-100 transition" />}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
ExplorerToolbarTitle.displayName = 'ExplorerToolbarTitle'
|
||||
|
||||
export type ExplorerToolbarActionsProps = React.ComponentProps<'div'>
|
||||
@@ -70,7 +132,9 @@ ExplorerToolbarActions.displayName = 'ExplorerToolbarActions'
|
||||
export type ExplorerToolbarActionProps = Omit<
|
||||
React.ComponentPropsWithRef<typeof Button>,
|
||||
'size' | 'variant'
|
||||
>
|
||||
> & {
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The standard tiny, text-style button used for a direct toolbar action.
|
||||
@@ -80,18 +144,20 @@ const ExplorerToolbarAction = ({
|
||||
children,
|
||||
className,
|
||||
ref,
|
||||
tooltip,
|
||||
...props
|
||||
}: ExplorerToolbarActionProps) => (
|
||||
<Button
|
||||
<ButtonTooltip
|
||||
ref={ref}
|
||||
data-slot="explorer-toolbar-action"
|
||||
variant="text"
|
||||
size="tiny"
|
||||
className={cn(children == null && 'w-7 px-0', className)}
|
||||
tooltip={{ content: { side: 'bottom', text: tooltip } }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</ButtonTooltip>
|
||||
)
|
||||
ExplorerToolbarAction.displayName = 'ExplorerToolbarAction'
|
||||
|
||||
@@ -8,18 +8,21 @@ import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
|
||||
import { SortableSection } from '@/components/ui/SortableSection'
|
||||
import { type MarkdownCell as MarkdownCellSchema } from '@/data/content/notebooks/notebook-schema'
|
||||
import { useLatest } from '@/hooks/misc/useLatest'
|
||||
import { useCurrentNotebook, useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
|
||||
|
||||
interface MarkdownCellProps {
|
||||
cell: MarkdownCellSchema
|
||||
onCommitChanges: (text: string) => void
|
||||
}
|
||||
|
||||
export const MarkdownCell = ({ cell, onCommitChanges }: MarkdownCellProps) => {
|
||||
export const MarkdownCell = ({ cell }: MarkdownCellProps) => {
|
||||
const snap = useNotebooksStateSnapshot()
|
||||
const currentNotebook = useCurrentNotebook()
|
||||
const cells = currentNotebook?.notebook.content?.cells ?? []
|
||||
|
||||
const [value, setValue] = useState(cell.text)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
const valueRef = useLatest(value)
|
||||
const onCommitChangesRef = useLatest(onCommitChanges)
|
||||
|
||||
const handleStartEditing = () => {
|
||||
setValue(cell.text)
|
||||
@@ -30,11 +33,17 @@ export const MarkdownCell = ({ cell, onCommitChanges }: MarkdownCellProps) => {
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const handleCommit = () => {
|
||||
onCommitChangesRef.current(valueRef.current)
|
||||
const handleUpdateMarkdown = (cellId: string, text: string) => {
|
||||
const notebookId = currentNotebook?.notebook.id
|
||||
if (!notebookId) return
|
||||
|
||||
const nextCells = cells.map((c) => (c.id === cellId ? { ...c, text } : c))
|
||||
snap.updateCells({ id: notebookId, cells: nextCells })
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const handleUpdateMarkdownRef = useLatest(handleUpdateMarkdown)
|
||||
|
||||
return (
|
||||
<SortableSection gripClassName="mt-2.5" id={cell.id}>
|
||||
{isEditing ? (
|
||||
@@ -65,8 +74,12 @@ export const MarkdownCell = ({ cell, onCommitChanges }: MarkdownCellProps) => {
|
||||
'!inlineSuggestionVisible',
|
||||
].join(' && ')
|
||||
)
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, handleCommit)
|
||||
editor.onDidBlurEditorWidget(handleCommit)
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () =>
|
||||
handleUpdateMarkdownRef.current(cell.id, valueRef.current)
|
||||
)
|
||||
editor.onDidBlurEditorWidget(() =>
|
||||
handleUpdateMarkdownRef.current(cell.id, valueRef.current)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<div className="border-t flex items-center justify-between pl-3 pr-1 py-1">
|
||||
@@ -75,7 +88,11 @@ export const MarkdownCell = ({ cell, onCommitChanges }: MarkdownCellProps) => {
|
||||
<Button variant="text" onMouseDown={(e) => e.preventDefault()} onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="text" onMouseDown={(e) => e.preventDefault()} onClick={handleCommit}>
|
||||
<Button
|
||||
variant="text"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handleUpdateMarkdown(cell.id, value)}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -13,28 +13,30 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { useParams } from 'common'
|
||||
import { Edit, Notebook, NotebookText, Play, Save } from 'lucide-react'
|
||||
import { useEffect, useEffectEvent, useState } from 'react'
|
||||
import { Notebook, NotebookText, Play, Save } from 'lucide-react'
|
||||
import { useEffect, useEffectEvent } from 'react'
|
||||
import { AiIconAnimation, Button } from 'ui'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational'
|
||||
|
||||
import {
|
||||
ExplorerToolbar,
|
||||
ExplorerToolbarAction,
|
||||
ExplorerToolbarActions,
|
||||
ExplorerToolbarIcon,
|
||||
ExplorerToolbarTitle,
|
||||
} from './ExplorerToolbar'
|
||||
import { MarkdownCell } from './MarkdownCell'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
|
||||
import { QueryCell } from './QueryCell'
|
||||
import { useCurrentNotebook, useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
|
||||
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
|
||||
|
||||
export const NotebookEditor = () => {
|
||||
const { id } = useParams()
|
||||
const tabs = useTabsStateSnapshot()
|
||||
const snap = useNotebooksStateSnapshot()
|
||||
const stateNotebook = id ? snap.notebooks[id] : undefined
|
||||
|
||||
const { name, content } = stateNotebook?.notebook ?? {}
|
||||
|
||||
const [titleValue, setTitleValue] = useState<string>(name ?? '')
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false)
|
||||
|
||||
const currentNotebook = useCurrentNotebook()
|
||||
const { name, content } = currentNotebook?.notebook ?? {}
|
||||
const cells = content?.cells ?? []
|
||||
|
||||
const sensors = useSensors(
|
||||
@@ -42,20 +44,12 @@ export const NotebookEditor = () => {
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
)
|
||||
|
||||
const handleSaveTitle = () => {
|
||||
const handleSaveTitle = (titleValue: string) => {
|
||||
const trimmedName = titleValue.trim()
|
||||
if (id && trimmedName && trimmedName !== name) {
|
||||
snap.renameNotebook({ id, name: trimmedName })
|
||||
tabs.updateTab(createTabId('notebook', { id }), { label: trimmedName })
|
||||
}
|
||||
setIsEditingTitle(false)
|
||||
}
|
||||
|
||||
const handleUpdateCellText = (cellId: string, text: string) => {
|
||||
if (!id) return
|
||||
|
||||
const nextCells = cells.map((cell) => (cell.id === cellId ? { ...cell, text } : cell))
|
||||
snap.updateCells({ id, cells: nextCells })
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
@@ -84,66 +78,22 @@ export const NotebookEditor = () => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-surface-100">
|
||||
<div className="border-b min-h-10 flex items-center justify-between px-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<ExplorerToolbar className="px-4">
|
||||
<ExplorerToolbarIcon>
|
||||
<NotebookText size={14} className="text-foreground-light" />
|
||||
{isEditingTitle ? (
|
||||
<Input
|
||||
autoFocus
|
||||
size="tiny"
|
||||
value={titleValue}
|
||||
onChange={(e) => setTitleValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (isEditingTitle) handleSaveTitle()
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsEditingTitle(false)
|
||||
setTitleValue(name ?? '')
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleSaveTitle()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="text"
|
||||
className="group"
|
||||
onClick={() => {
|
||||
setTitleValue(name ?? '')
|
||||
setIsEditingTitle(true)
|
||||
}}
|
||||
iconRight={<Edit className="opacity-0 group-hover:opacity-100" />}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button variant="outline" icon={<AiIconAnimation size={16} />}>
|
||||
</ExplorerToolbarIcon>
|
||||
<ExplorerToolbarTitle onSaveTitle={handleSaveTitle}>{name ?? ''}</ExplorerToolbarTitle>
|
||||
<ExplorerToolbarActions>
|
||||
<ExplorerToolbarAction icon={<AiIconAnimation size={16} />}>
|
||||
Analyze
|
||||
</Button>
|
||||
<ButtonTooltip
|
||||
variant="outline"
|
||||
icon={<Play />}
|
||||
className="px-1"
|
||||
tooltip={{ content: { side: 'bottom', text: 'Run notebook' } }}
|
||||
/>
|
||||
<ButtonTooltip
|
||||
variant="outline"
|
||||
icon={<Save />}
|
||||
className="px-1"
|
||||
tooltip={{ content: { side: 'bottom', text: 'Save changes' } }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ExplorerToolbarAction>
|
||||
<ExplorerToolbarAction icon={<Play />} tooltip="Run notebook" />
|
||||
<ExplorerToolbarAction icon={<Save />} tooltip="Save changes" />
|
||||
</ExplorerToolbarActions>
|
||||
</ExplorerToolbar>
|
||||
|
||||
<div className="w-full mx-auto flex-grow min-h-0 overflow-y-auto">
|
||||
<div className="p-4">
|
||||
<div className="p-4 pb-10">
|
||||
{cells.length === 0 && (
|
||||
<EmptyStatePresentational
|
||||
icon={<Notebook className="text-foreground-lighter" />}
|
||||
@@ -163,18 +113,15 @@ export const NotebookEditor = () => {
|
||||
items={cells.map((cell) => cell.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div className="flex flex-col gap-y-3">
|
||||
{cells.map((cell) => {
|
||||
switch (cell._tag) {
|
||||
case 'markdown_cell':
|
||||
return (
|
||||
<MarkdownCell
|
||||
key={cell.id}
|
||||
cell={cell}
|
||||
onCommitChanges={(text) => handleUpdateCellText(cell.id, text)}
|
||||
/>
|
||||
)
|
||||
return <MarkdownCell key={cell.id} cell={cell} />
|
||||
|
||||
case 'database_cell':
|
||||
return <QueryCell key={cell.id} cell={cell} />
|
||||
|
||||
case 'log_cell':
|
||||
// [Joshen] Will eventually hook it up
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { acceptUntrustedSql, untrustedSql } from '@supabase/pg-meta'
|
||||
import { CodeSquare, Eye, EyeOff, Play, Settings2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
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 { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
|
||||
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: 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
|
||||
*/
|
||||
|
||||
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 } = cell
|
||||
|
||||
const [showQuery, setShowQuery] = useState(true)
|
||||
const [value, setValue] = useState<string>(cell.unchecked_sql)
|
||||
const [result, setResult] = useState<QueryResult>()
|
||||
|
||||
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 handleUpdateCell = (payload: { sql: string } | { title: string }) => {
|
||||
const notebookId = currentNotebook?.notebook.id
|
||||
if (!notebookId) return
|
||||
|
||||
const nextCells = cells.map((c) => {
|
||||
if (c.id !== cell.id || c._tag !== 'database_cell') {
|
||||
return c
|
||||
}
|
||||
|
||||
if ('sql' in payload) {
|
||||
return { ...c, unchecked_sql: untrustedSql(payload.sql) }
|
||||
}
|
||||
|
||||
const trimmedTitle = payload.title.trim()
|
||||
return trimmedTitle ? { ...c, title: trimmedTitle } : c
|
||||
})
|
||||
|
||||
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>
|
||||
<ExplorerToolbarAction
|
||||
disabled={(result?.rows ?? []).length === 0}
|
||||
icon={<Settings2 />}
|
||||
tooltip="Result settings"
|
||||
/>
|
||||
<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
|
||||
? 'flex items-center justify-center'
|
||||
: 'overflow-x-auto'
|
||||
)}
|
||||
>
|
||||
<QueryResultTable 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>
|
||||
</SortableSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useParams } from 'common'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs'
|
||||
import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils'
|
||||
import { Results } from '../SQLEditor/UtilityPanel/Results'
|
||||
import { getSqlErrorLines } from '../SQLEditor/UtilityPanel/UtilityTabResults.utils'
|
||||
import { type QueryResult } from './types'
|
||||
import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown'
|
||||
import CopyButton from '@/components/ui/CopyButton'
|
||||
import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink'
|
||||
import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
|
||||
import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
|
||||
interface QueryResultTableProps {
|
||||
result?: QueryResult
|
||||
}
|
||||
|
||||
// [Joshen] This is essentially a duplicate of UtilityTabResults from the SQL Editor
|
||||
// I'll eventually migrate the Results component over - just trying to avoid bloating
|
||||
// changes wherever possible
|
||||
|
||||
export const QueryResultTable = ({ result }: QueryResultTableProps) => {
|
||||
const { rows, error, autoLimit } = result ?? {}
|
||||
|
||||
if (!result) {
|
||||
return <p className="text-xs text-foreground-light">Run the query to see results</p>
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <QueryError error={error} autoLimit={autoLimit} />
|
||||
}
|
||||
|
||||
if ((rows ?? []).length === 0) {
|
||||
return <p className="text-xs text-foreground-light">Success. No rows returned</p>
|
||||
}
|
||||
|
||||
if (rows && rows.length > 0) {
|
||||
return <QueryResults rows={rows} />
|
||||
}
|
||||
}
|
||||
|
||||
const QueryError = ({
|
||||
error,
|
||||
autoLimit,
|
||||
}: {
|
||||
error: NonNullable<QueryResult['error']>
|
||||
autoLimit?: QueryResult['autoLimit']
|
||||
}) => {
|
||||
const { ref } = useParams()
|
||||
|
||||
const { data: org } = useSelectedOrganizationQuery()
|
||||
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: org?.slug })
|
||||
const { data: projectSettings } = useProjectSettingsV2Query({ projectRef: ref })
|
||||
const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) && projectSettings?.is_sensitive
|
||||
|
||||
const [, setShowConnect] = useQueryState('showConnect', parseAsBoolean.withDefault(false))
|
||||
|
||||
const isTimeout =
|
||||
error.message?.includes('canceling statement due to statement timeout') ||
|
||||
error.message?.includes('upstream request timeout') ||
|
||||
error.message?.includes('Query read timeout')
|
||||
const isNetWorkError = error.message?.includes('EHOSTUNREACH')
|
||||
|
||||
const errorLines = getSqlErrorLines(error)
|
||||
// [Joshen] Need to check if a replica is selected as well
|
||||
const readReplicaError = error.message.includes('in a read-only transaction')
|
||||
const payloadTooLargeError = error.message.includes(
|
||||
'Query is too large to be run via the SQL Editor'
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-table-header-light in-data-[theme*=dark]:bg-table-header-dark overflow-y-auto">
|
||||
<div className="flex flex-row justify-between items-start py-4 px-6 gap-x-4">
|
||||
{isTimeout ? (
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<p className="font-mono text-sm tracking-tight">
|
||||
Error: SQL query ran into an upstream timeout
|
||||
</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
You can either{' '}
|
||||
<InlineLink
|
||||
href={`${DOCS_URL}/guides/platform/performance#examining-query-performance`}
|
||||
>
|
||||
optimize your query
|
||||
</InlineLink>
|
||||
, or{' '}
|
||||
<InlineLink href={`${DOCS_URL}/guides/database/timeouts`}>
|
||||
increase the statement timeout
|
||||
</InlineLink>
|
||||
{' or '}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
className={cn(InlineLinkClassName, 'cursor-pointer')}
|
||||
onClick={() => setShowConnect(true)}
|
||||
>
|
||||
connect to your database directly
|
||||
</button>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-y-1">
|
||||
{errorLines.length > 0 ? (
|
||||
errorLines.map((x: string, i: number) => (
|
||||
<pre key={`error-${i}`} className="font-mono text-sm text-wrap">
|
||||
{x}
|
||||
</pre>
|
||||
))
|
||||
) : (
|
||||
<p className="font-mono text-sm tracking-tight">Error: {error.message}</p>
|
||||
)}
|
||||
{!isTimeout && !isNetWorkError && autoLimit && (
|
||||
<p className="text-sm text-foreground-light">
|
||||
Note: A limit of {autoLimit} was applied to your query. If this was the cause of a
|
||||
syntax error, try selecting "No limit" instead and re-run the query.
|
||||
</p>
|
||||
)}
|
||||
{readReplicaError && (
|
||||
<p className="text-sm text-foreground-light">
|
||||
Note: Read replicas are for read only queries. Run write queries on the primary
|
||||
database instead.
|
||||
</p>
|
||||
)}
|
||||
{payloadTooLargeError && (
|
||||
<p className="text-sm text-foreground-light flex items-center gap-x-1">
|
||||
Run this query by{' '}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setShowConnect(true)}
|
||||
className={cn(InlineLinkClassName, 'flex items-center gap-x-1')}
|
||||
>
|
||||
connecting to your database directly
|
||||
<ExternalLink size={12} />
|
||||
</button>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
{readReplicaError && (
|
||||
<Button
|
||||
className="py-2"
|
||||
variant="default"
|
||||
// [Joshen] TODO
|
||||
onClick={() => {}}
|
||||
>
|
||||
Switch to primary database
|
||||
</Button>
|
||||
)}
|
||||
{errorLines.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<CopyButton iconOnly variant="default" text={errorLines.join('\n')} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center">
|
||||
<span>Copy error</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!hasHipaaAddon && (
|
||||
// [Joshen] TODO
|
||||
<AiAssistantDropdown
|
||||
telemetrySource="sql_debug"
|
||||
label="Debug with Assistant"
|
||||
buildPrompt={() => ''}
|
||||
onOpenAssistant={() => {}}
|
||||
disabled={false}
|
||||
loading={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// [Joshen] Eventually migrate the Results component here from SQL Editor
|
||||
const QueryResults = ({ rows }: { rows: NonNullable<QueryResult['rows']> }) => {
|
||||
return <Results rows={rows} />
|
||||
}
|
||||
+2
-2
@@ -2,14 +2,14 @@ import { render, screen } from '@testing-library/react'
|
||||
import { createRef } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ExplorerToolbar, ExplorerToolbarTitle } from '../ExplorerToolbar'
|
||||
import {
|
||||
ExplorerQuery,
|
||||
ExplorerQueryEditor,
|
||||
ExplorerQueryFooter,
|
||||
ExplorerQueryResults,
|
||||
ExplorerQueryViewport,
|
||||
} from './index'
|
||||
} from '../ExplorerQuery'
|
||||
import { ExplorerToolbar, ExplorerToolbarTitle } from '../ExplorerToolbar'
|
||||
|
||||
describe('ExplorerQuery', () => {
|
||||
it('composes the toolbar, editor, results, and footer without owning their behavior', () => {
|
||||
+3
-18
@@ -1,4 +1,4 @@
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import { createRef } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
ExplorerToolbarActions,
|
||||
ExplorerToolbarIcon,
|
||||
ExplorerToolbarTitle,
|
||||
} from './index'
|
||||
} from '../ExplorerToolbar'
|
||||
import { customRender as render } from '@/tests/lib/custom-render'
|
||||
|
||||
describe('ExplorerToolbar', () => {
|
||||
it('composes the resource icon, title, direct actions, and custom controls', () => {
|
||||
@@ -65,20 +66,4 @@ describe('ExplorerToolbar', () => {
|
||||
expect(action).not.toHaveClass('w-7')
|
||||
expect(actionRef.current).toBe(action)
|
||||
})
|
||||
|
||||
it('defaults its height but lets a consumer drive it', () => {
|
||||
const { rerender } = render(<ExplorerToolbar aria-label="Default height" />)
|
||||
|
||||
// The header height variable stays consumer-owned, but it carries a fallback
|
||||
// so apps that leave it unset keep the default row instead of an invalid
|
||||
// declaration the browser drops.
|
||||
expect(screen.getByRole('toolbar')).toHaveClass('h-10')
|
||||
expect(screen.getByRole('toolbar').className).toContain('var(--header-height,2.5rem)')
|
||||
|
||||
rerender(<ExplorerToolbar aria-label="Taller height" className="h-12" />)
|
||||
|
||||
const toolbar = screen.getByRole('toolbar')
|
||||
expect(toolbar).toHaveClass('h-12')
|
||||
expect(toolbar).not.toHaveClass('h-10')
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { untrustedSql } from '@supabase/pg-meta'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
@@ -5,6 +6,7 @@ import { generateUuid } from '@/lib/api/snippets.browser'
|
||||
import { useProfile } from '@/lib/profile'
|
||||
import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
|
||||
import { type Notebook } from '@/state/notebooks/types'
|
||||
import { Notebooks } from '@/types'
|
||||
|
||||
export const useCreateNotebook = () => {
|
||||
const router = useRouter()
|
||||
@@ -12,10 +14,43 @@ export const useCreateNotebook = () => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const notebooksSnap = useNotebooksStateSnapshot()
|
||||
|
||||
const createNotebook = ({ id: idOverride, name }: { id?: string; name?: string } = {}) => {
|
||||
const createNotebook = ({
|
||||
id: idOverride,
|
||||
name,
|
||||
cells,
|
||||
}: { id?: string; name?: string; cells?: Notebooks.Content['cells'] } = {}) => {
|
||||
if (!profile) return console.error('Profile is required')
|
||||
if (!project) return console.error('Project is required')
|
||||
|
||||
// [Joshen] Just adding sample data to play around with, keep for now - clean up at the end
|
||||
const DEFAULT_CELLS = [
|
||||
{
|
||||
_tag: 'markdown_cell',
|
||||
id: generateUuid(),
|
||||
text: `
|
||||
# Title
|
||||
A brief description on what this notebook is about
|
||||
`.trim(),
|
||||
},
|
||||
{
|
||||
_tag: 'markdown_cell',
|
||||
id: generateUuid(),
|
||||
text: `
|
||||
## Section
|
||||
This is a sample paragraph to demonstrate the Markdown cells
|
||||
1. List item 1
|
||||
2. List item 2
|
||||
3. List item 3
|
||||
`,
|
||||
},
|
||||
{
|
||||
_tag: 'database_cell',
|
||||
id: generateUuid(),
|
||||
unchecked_sql: untrustedSql('select * from colors;'),
|
||||
row_limit: 100,
|
||||
},
|
||||
] as Notebooks.Content['cells']
|
||||
|
||||
const id = idOverride ?? generateUuid()
|
||||
|
||||
const notebook: Notebook = {
|
||||
@@ -27,28 +62,7 @@ export const useCreateNotebook = () => {
|
||||
favorite: false,
|
||||
content: {
|
||||
schema_version: 1,
|
||||
// [Joshen] Just adding sample data to play around with, keep for now - clean up at the end
|
||||
cells: [
|
||||
{
|
||||
_tag: 'markdown_cell',
|
||||
id: '1',
|
||||
text: `
|
||||
# Title
|
||||
A brief description on what this notebook is about
|
||||
`.trim(),
|
||||
},
|
||||
{
|
||||
_tag: 'markdown_cell',
|
||||
id: '2',
|
||||
text: `
|
||||
## Section
|
||||
This is a sample paragraph to demonstrate the Markdown cells
|
||||
1. List item 1
|
||||
2. List item 2
|
||||
3. List item 3
|
||||
`,
|
||||
},
|
||||
],
|
||||
cells: cells ?? DEFAULT_CELLS,
|
||||
},
|
||||
owner_id: profile.id,
|
||||
project_id: project.id,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { type ResponseError } from '@/types'
|
||||
|
||||
export type QueryResult = {
|
||||
rows?: Record<string, unknown>[]
|
||||
error?: ResponseError
|
||||
autoLimit?: number
|
||||
}
|
||||
@@ -33,7 +33,7 @@ import { IS_PLATFORM, PROJECT_STATUS } from '@/lib/constants'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
import { useAppStateSnapshot } from '@/state/app-state'
|
||||
|
||||
const SORT_GRIP_CLASS = 'absolute -left-6 top-1.5'
|
||||
const SORT_GRIP_CLASS = 'absolute -left-10'
|
||||
|
||||
export const ProjectHome = () => {
|
||||
const { enableBranching } = useParams()
|
||||
|
||||
@@ -79,11 +79,9 @@ export const Results = ({ rows }: { rows: readonly any[] }) => {
|
||||
return (
|
||||
<>
|
||||
{rows.length === 0 ? (
|
||||
<div className="bg-table-header-light in-data-[theme*=dark]:bg-table-header-dark">
|
||||
<p className="m-0 border-0 px-4 py-3 font-mono text-sm text-foreground-light">
|
||||
Success. No rows returned
|
||||
</p>
|
||||
</div>
|
||||
<p className="px-4 py-3 font-mono text-sm text-foreground-light">
|
||||
Success. No rows returned
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<ContextMenu modal={false}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { GripVertical } from 'lucide-react'
|
||||
import type { CSSProperties, PropsWithChildren } from 'react'
|
||||
import { cn } from 'ui'
|
||||
import { Button, cn } from 'ui'
|
||||
|
||||
export const SortableSection = ({
|
||||
id,
|
||||
@@ -23,22 +23,22 @@ export const SortableSection = ({
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="relative will-change-transform flex items-start gap-x-2"
|
||||
className="relative will-change-transform flex items-start gap-x-4"
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
aria-label="Drag to reorder section"
|
||||
className={cn(
|
||||
'text-foreground-muted hover:text-foreground cursor-grab active:cursor-grabbing',
|
||||
'w-6 text-foreground-muted hover:text-foreground cursor-grab active:cursor-grabbing',
|
||||
'rounded-sm focus-ring',
|
||||
gripClassName
|
||||
)}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
tabIndex={0}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</button>
|
||||
icon={<GripVertical />}
|
||||
/>
|
||||
<div className={cn('w-full', isDragging && 'opacity-70')}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useParams } from 'common'
|
||||
import { useMemo } from 'react'
|
||||
import { proxy, snapshot, useSnapshot } from 'valtio'
|
||||
import { proxyMap } from 'valtio/utils'
|
||||
@@ -109,3 +110,13 @@ export const useNotebooks = (projectRef: string) => {
|
||||
[projectRef, snapshot.notebooks]
|
||||
)
|
||||
}
|
||||
|
||||
export const useCurrentNotebook = () => {
|
||||
const { id, ref } = useParams()
|
||||
const snapshot = useNotebooksStateSnapshot()
|
||||
const currentNotebook = id ? snapshot.notebooks[id] : undefined
|
||||
|
||||
if (!currentNotebook || currentNotebook.projectRef !== ref) return undefined
|
||||
|
||||
return currentNotebook
|
||||
}
|
||||
Reference in new issue
Block a user