Merge pull request #78 from QRun-IO/feature/sql-workbench-theme

feat(sql-workbench): theme-aware workbench built from the standard controls (light and dark)
This commit was merged in pull request #78.
This commit is contained in:
james.maes authored and GitHub committed 2026-10-12 13:45:08 -05:00
commit a724fc191c
16 files changed
+1641 -676

No files matched your search

+3 -15
View File
@@ -28,26 +28,14 @@ import { Loader2 } from 'lucide-react'
import type { QFieldMetaData, QRecord } from '@/types'
import { testScript } from '@/lib/api/developer'
import { HANDLES_OWN_ERRORS } from '@/lib/query-client'
import { cn } from '@/lib/utils/cn'
import { getErrorMessage } from '@/lib/utils/error-utils'
import { CodeBlock } from './HighlightedCode'
import { dateTimeText, domId, text } from './script-utils'
import { DateTimeText } from '@/components/ui/DateTimeText'
import { ALERT_CLASS, BUTTON_CLASS, INPUT_CLASS, PRIMARY_BUTTON_CLASS } from '@/components/ui/controls'
export const BUTTON_CLASS = cn(
'inline-flex items-center justify-center gap-2 rounded-md border border-input bg-card px-3 py-1.5 text-sm font-medium text-foreground',
'hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50'
)
export const PRIMARY_BUTTON_CLASS = cn(
'inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50'
)
export const INPUT_CLASS =
'w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring'
export const ALERT_CLASS =
'whitespace-pre-wrap rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive'
// The shared control styles moved to the design-system module; re-exported for existing imports.
export { ALERT_CLASS, BUTTON_CLASS, INPUT_CLASS, PRIMARY_BUTTON_CLASS }
/** Tester input and output fields of a script type. */
export interface ScriptTestFields {
@@ -91,7 +91,10 @@ export function MarkdownView({ source }: { source: string }) {
while (i < lines.length && !lines[i].trim().startsWith('```')) code.push(lines[i++])
i++
blocks.push(
<pre key={key} className="overflow-x-auto rounded bg-muted p-2 font-mono text-xs">
<pre
key={key}
className="whitespace-pre-wrap break-words rounded-md border border-border bg-muted p-3 font-mono text-xs"
>
{code.join('\n')}
</pre>
)
@@ -147,5 +150,5 @@ export function MarkdownView({ source }: { source: string }) {
paragraph.push(lines[i++])
blocks.push(<p key={key}>{renderInline(paragraph.join(' '), key)}</p>)
}
return <div className="space-y-2 text-sm">{blocks}</div>
return <div className="space-y-2 break-words text-sm text-foreground">{blocks}</div>
}
+452 -282
View File
@@ -24,7 +24,23 @@
'use client'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import * as AlertDialog from '@radix-ui/react-alert-dialog'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
ArrowDown,
ArrowUp,
Check,
Copy,
Eye,
History,
Loader2,
Pencil,
Plus,
RotateCcw,
Save,
Share2,
Trash2,
} from 'lucide-react'
import { toast } from 'sonner'
import type {
@@ -48,6 +64,17 @@ import {
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
import { getErrorMessage } from '@/lib/utils/error-utils'
import { coerceParameterValue, extractSqlParameters, newCellKey } from '@/lib/utils/sql-workbench'
import { cn } from '@/lib/utils/cn'
import {
ALERT_CLASS,
BADGE_CLASS,
buttonClass,
CARD_CLASS,
iconButtonClass,
INPUT_CLASS,
SELECT_CLASS,
} from '@/components/ui/controls'
import { DateTimeText } from '@/components/ui/DateTimeText'
import { MarkdownView } from './MarkdownView'
import { SqlCell } from './SqlCell'
@@ -77,6 +104,16 @@ const PARAMETER_TYPES: NonNullable<SqlNotebookParameter['type']>[] = [
'DATE_TIME',
]
/** Plain labels of the parameter types. */
const PARAMETER_TYPE_LABELS: Record<NonNullable<SqlNotebookParameter['type']>, string> = {
STRING: 'Text',
INTEGER: 'Whole number',
DECIMAL: 'Decimal',
BOOLEAN: 'Yes / no',
DATE: 'Date',
DATE_TIME: 'Date and time',
}
/**
* Loads the notebook (when saved) and renders its editor.
*
@@ -94,14 +131,15 @@ export function NotebookEditor(props: NotebookEditorProps) {
if (notebookId !== null && isLoading) {
return (
<p className="text-sm text-muted-foreground" role="status">
<p className="inline-flex items-center gap-2 text-sm text-muted-foreground" role="status">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Loading notebook…
</p>
)
}
if (notebookId !== null && (error || !data)) {
return (
<p className="text-sm text-destructive" role="alert">
<p className={ALERT_CLASS} role="alert">
Could not open the notebook: {getErrorMessage(error, 'not found')}
</p>
)
@@ -158,6 +196,7 @@ function NotebookDraft(props: NotebookEditorProps & { notebook: SqlNotebook; sav
const [saving, setSaving] = useState(false)
const [showVersions, setShowVersions] = useState(false)
const [previewing, setPreviewing] = useState<Record<string, boolean>>({})
const [confirmingDelete, setConfirmingDelete] = useState(false)
const canEdit = notebook.canEdit
// The request is parent state that outlives this draft (it remounts on save, restore,
@@ -284,13 +323,61 @@ function NotebookDraft(props: NotebookEditorProps & { notebook: SqlNotebook; sav
updateCells(next)
}
const button = 'rounded border px-2 py-1 text-xs disabled:opacity-50'
const addCell = (cellType: SqlNotebookCell['cellType']) => {
const cellKey = newCellKey()
if (cellType === 'MARKDOWN') setPreviewing({ ...previewing, [cellKey]: false })
updateCells([...cells, { cellKey, cellType, content: '', title: null, backendName: null }])
}
/**
* The move and delete buttons of a cell, shown in its header.
*
* @param index - The cell's position.
* @returns The buttons, or nothing for a read-only notebook.
*/
const cellActions = (index: number) =>
canEdit ? (
<span className="inline-flex items-center">
<button
type="button"
className={iconButtonClass()}
onClick={() => move(index, -1)}
disabled={index === 0}
aria-label={`Move cell ${index + 1} up`}
title="Move up"
data-qqq-id={`button-sql-cell-up-${index}`}
>
<ArrowUp className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
className={iconButtonClass()}
onClick={() => move(index, 1)}
disabled={index === cells.length - 1}
aria-label={`Move cell ${index + 1} down`}
title="Move down"
data-qqq-id={`button-sql-cell-down-${index}`}
>
<ArrowDown className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
className={iconButtonClass('destructive')}
onClick={() => updateCells(cells.filter((_, i) => i !== index))}
aria-label={`Delete cell ${index + 1}`}
title="Delete cell"
data-qqq-id={`button-sql-cell-delete-${index}`}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
</span>
) : null
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<div className="@container space-y-4">
<div className="space-y-3">
<input
className="min-w-0 flex-1 rounded border px-2 py-1 text-base font-semibold"
className={cn(INPUT_CLASS, 'h-10 text-base font-semibold')}
value={title}
aria-label="Notebook title"
onChange={(e) => {
@@ -300,299 +387,382 @@ function NotebookDraft(props: NotebookEditorProps & { notebook: SqlNotebook; sav
readOnly={!canEdit}
data-qqq-id="input-sql-notebook-title"
/>
<label className="inline-flex items-center gap-1 text-xs">
Default backend
<select
className="rounded border px-1 py-0.5"
value={defaultBackendName}
disabled={!canEdit}
onChange={(e) => {
setDefaultBackendName(e.target.value)
setDirty(true)
}}
data-qqq-id="select-sql-notebook-backend"
>
{capabilities.backends.map((b) => (
<option key={b.name} value={b.name}>
{b.label}
</option>
))}
</select>
</label>
{canEdit && (
<button
type="button"
className={`${button} bg-primary text-primary-foreground`}
onClick={save}
disabled={saving || !dirty}
data-qqq-id="button-sql-notebook-save"
>
{saving ? 'Saving…' : dirty ? 'Save' : 'Saved'}
</button>
)}
{saved && canEdit && capabilities.canShare && (
<button
type="button"
className={button}
onClick={() =>
act(
() => publishSqlNotebook(notebook.id, notebook.visibility !== 'SHARED'),
notebook.visibility === 'SHARED'
? 'Removed from the library'
: 'Published to the library'
)
}
data-qqq-id="button-sql-notebook-publish"
>
{notebook.visibility === 'SHARED' ? 'Unpublish' : 'Publish'}
</button>
)}
{saved && (
<button
type="button"
className={button}
onClick={() =>
act(
() => duplicateSqlNotebook(notebook.id),
'Copied to your notebooks',
(n) => (n ? n.id : null)
)
}
data-qqq-id="button-sql-notebook-duplicate"
>
Duplicate
</button>
)}
{saved && (
<button
type="button"
className={button}
onClick={() => setShowVersions(!showVersions)}
aria-expanded={showVersions}
data-qqq-id="button-sql-notebook-versions"
>
Versions
</button>
)}
{saved && canEdit && (
<button
type="button"
className={`${button} text-destructive`}
onClick={() => {
if (window.confirm(`Delete “${notebook.title}” and its versions?`))
void act(
() => deleteSqlNotebook(notebook.id),
'Notebook deleted',
() => null
)
}}
data-qqq-id="button-sql-notebook-delete"
>
Delete
</button>
)}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<label className="inline-flex min-w-0 items-center gap-2 text-sm text-foreground">
<span className="whitespace-nowrap">Default backend</span>
<select
className={SELECT_CLASS}
value={defaultBackendName}
disabled={!canEdit}
onChange={(e) => {
setDefaultBackendName(e.target.value)
setDirty(true)
}}
data-qqq-id="select-sql-notebook-backend"
>
{capabilities.backends.map((b) => (
<option key={b.name} value={b.name}>
{b.label}
</option>
))}
</select>
</label>
<div className="flex flex-wrap items-center gap-2 @2xl:ml-auto">
{canEdit && (
<button
type="button"
className={buttonClass('primary', 'sm')}
onClick={save}
disabled={saving || !dirty}
data-qqq-id="button-sql-notebook-save"
>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
) : dirty ? (
<Save className="h-4 w-4" aria-hidden="true" />
) : (
<Check className="h-4 w-4" aria-hidden="true" />
)}
{saving ? 'Saving…' : dirty ? 'Save' : 'Saved'}
</button>
)}
{saved && canEdit && capabilities.canShare && (
<button
type="button"
className={buttonClass('secondary', 'sm')}
onClick={() =>
act(
() => publishSqlNotebook(notebook.id, notebook.visibility !== 'SHARED'),
notebook.visibility === 'SHARED'
? 'Removed from the library'
: 'Published to the library'
)
}
data-qqq-id="button-sql-notebook-publish"
>
<Share2 className="h-4 w-4" aria-hidden="true" />
{notebook.visibility === 'SHARED' ? 'Unpublish' : 'Publish'}
</button>
)}
{saved && (
<button
type="button"
className={buttonClass('secondary', 'sm')}
onClick={() =>
act(
() => duplicateSqlNotebook(notebook.id),
'Copied to your notebooks',
(n) => (n ? n.id : null)
)
}
data-qqq-id="button-sql-notebook-duplicate"
>
<Copy className="h-4 w-4" aria-hidden="true" />
Duplicate
</button>
)}
{saved && (
<button
type="button"
className={buttonClass('secondary', 'sm')}
onClick={() => setShowVersions(!showVersions)}
aria-expanded={showVersions}
data-qqq-id="button-sql-notebook-versions"
>
<History className="h-4 w-4" aria-hidden="true" />
Versions
</button>
)}
{saved && canEdit && (
<button
type="button"
className={buttonClass('destructive', 'sm')}
onClick={() => setConfirmingDelete(true)}
data-qqq-id="button-sql-notebook-delete"
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
Delete
</button>
)}
</div>
</div>
</div>
{!canEdit && (
<p className="text-xs text-muted-foreground">
<p className="text-sm text-muted-foreground">
Shared by {notebook.ownerName ?? notebook.ownerUserId}. You can run it; duplicate it to
keep changes.
</p>
)}
{confirmingDelete && (
<ConfirmDeleteDialog
title={notebook.title}
onCancel={() => setConfirmingDelete(false)}
onConfirm={() => {
setConfirmingDelete(false)
void act(
() => deleteSqlNotebook(notebook.id),
'Notebook deleted',
() => null
)
}}
/>
)}
{showVersions && (
<ul className="space-y-1 rounded-md border p-2 text-xs" aria-label="Versions">
{versions.map((v) => (
<li key={v.versionNumber} className="flex items-center gap-2">
<span className="font-mono">v{v.versionNumber}</span>
<span>{v.savedByName}</span>
<span className="text-muted-foreground">{v.createDate}</span>
{v.comment && <span className="truncate">{v.comment}</span>}
{canEdit && v.versionNumber !== notebook.currentVersion && (
<button
type="button"
className={`${button} ml-auto`}
onClick={() =>
act(
() => restoreSqlNotebookVersion(notebook.id, v.versionNumber),
`Restored version ${v.versionNumber}`
)
}
data-qqq-id={`button-sql-restore-${v.versionNumber}`}
>
Restore
</button>
)}
</li>
))}
</ul>
<section className={cn(CARD_CLASS, 'p-3')} aria-labelledby="sql-versions-heading">
<h3 id="sql-versions-heading" className="mb-2 text-sm font-semibold">
Versions
</h3>
<ul className="divide-y divide-border" aria-label="Versions">
{versions.map((v) => (
<li
key={v.versionNumber}
className="flex flex-wrap items-start gap-x-3 gap-y-1 py-2 text-sm"
>
<span className={BADGE_CLASS}>Version {v.versionNumber}</span>
<span className="min-w-0 flex-1">
<span className="block text-foreground">
{v.comment ?? (v.versionNumber === notebook.currentVersion ? 'Current' : '')}
</span>
<span className="flex flex-wrap gap-x-2 text-xs text-muted-foreground">
{v.savedByName && <span>{v.savedByName}</span>}
<DateTimeText
value={v.createDate}
type="DATE_TIME"
className="text-xs text-muted-foreground"
/>
</span>
</span>
{canEdit && v.versionNumber !== notebook.currentVersion && (
<button
type="button"
className={buttonClass('secondary', 'sm')}
onClick={() =>
act(
() => restoreSqlNotebookVersion(notebook.id, v.versionNumber),
`Restored version ${v.versionNumber}`
)
}
data-qqq-id={`button-sql-restore-${v.versionNumber}`}
>
<RotateCcw className="h-4 w-4" aria-hidden="true" />
Restore
</button>
)}
</li>
))}
</ul>
</section>
)}
{parameterNames.length > 0 && (
<fieldset className="flex flex-wrap gap-3 rounded-md border p-2 text-xs">
<legend className="px-1 font-medium">Parameters</legend>
{parameterNames.map((name) => {
const type = parameters.find((p) => p.name === name)?.type ?? 'STRING'
return (
<span key={name} className="inline-flex items-center gap-1">
<label className="inline-flex items-center gap-1">
<span className="font-mono">:{name}</span>
<input
className="w-36 rounded border px-1 py-0.5"
type={
type === 'DATE'
? 'date'
: type === 'DATE_TIME'
? 'datetime-local'
: type === 'INTEGER' || type === 'DECIMAL'
? 'number'
: 'text'
}
value={values[name] ?? ''}
onChange={(e) => setValues({ ...values, [name]: e.target.value })}
data-qqq-id={`input-sql-param-${name}`}
/>
</label>
<select
className="rounded border px-1 py-0.5"
value={type}
aria-label={`Type of :${name}`}
onChange={(e) =>
setParameterType(name, e.target.value as SqlNotebookParameter['type'])
}
data-qqq-id={`select-sql-param-type-${name}`}
>
{PARAMETER_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</span>
)
})}
<fieldset className={cn(CARD_CLASS, 'p-3')}>
<legend className="px-1 text-sm font-semibold">Parameters</legend>
<div className="grid gap-3 @lg:grid-cols-[repeat(auto-fill,minmax(18rem,1fr))]">
{parameterNames.map((name) => {
const type = parameters.find((p) => p.name === name)?.type ?? 'STRING'
return (
<div key={name} className="space-y-1">
<label
htmlFor={`sql-param-${name}`}
className="block font-mono text-sm text-foreground"
>
:{name}
</label>
<div className="flex gap-2">
<input
id={`sql-param-${name}`}
className={cn(INPUT_CLASS, 'h-8 min-w-0 flex-1 py-1')}
type={
type === 'DATE'
? 'date'
: type === 'DATE_TIME'
? 'datetime-local'
: type === 'INTEGER' || type === 'DECIMAL'
? 'number'
: 'text'
}
value={values[name] ?? ''}
onChange={(e) => setValues({ ...values, [name]: e.target.value })}
data-qqq-id={`input-sql-param-${name}`}
/>
<select
className={cn(SELECT_CLASS, 'w-32')}
value={type}
aria-label={`Type of :${name}`}
onChange={(e) =>
setParameterType(name, e.target.value as SqlNotebookParameter['type'])
}
data-qqq-id={`select-sql-param-type-${name}`}
>
{PARAMETER_TYPES.map((t) => (
<option key={t} value={t}>
{PARAMETER_TYPE_LABELS[t]}
</option>
))}
</select>
</div>
</div>
)
})}
</div>
</fieldset>
)}
{cells.map((cell, index) => (
<div key={cell.cellKey} className="group relative">
{canEdit && (
<div className="absolute -right-1 -top-2 z-10 hidden gap-1 group-focus-within:flex group-hover:flex">
<button
type="button"
className="rounded border bg-background p-0.5"
onClick={() => move(index, -1)}
disabled={index === 0}
aria-label={`Move cell ${index + 1} up`}
data-qqq-id={`button-sql-cell-up-${index}`}
>
<span aria-hidden>▲</span>
</button>
<button
type="button"
className="rounded border bg-background p-0.5"
onClick={() => move(index, 1)}
disabled={index === cells.length - 1}
aria-label={`Move cell ${index + 1} down`}
data-qqq-id={`button-sql-cell-down-${index}`}
>
<span aria-hidden>▼</span>
</button>
<button
type="button"
className="rounded border bg-background p-0.5 text-destructive"
onClick={() => updateCells(cells.filter((_, i) => i !== index))}
aria-label={`Delete cell ${index + 1}`}
data-qqq-id={`button-sql-cell-delete-${index}`}
>
<span aria-hidden>✕</span>
</button>
{cells.map((cell, index) =>
cell.cellType === 'MARKDOWN' ? (
<section
key={cell.cellKey}
className={cn(CARD_CLASS, 'space-y-2 p-3')}
aria-label={`Markdown cell ${index + 1}`}
data-qqq-id={`section-sql-markdown-${index}`}
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">Notes</span>
<span className="ml-auto inline-flex items-center gap-1">
{canEdit && (
<button
type="button"
className={buttonClass('ghost', 'sm')}
onClick={() =>
setPreviewing({ ...previewing, [cell.cellKey]: !previewing[cell.cellKey] })
}
data-qqq-id={`button-sql-markdown-toggle-${index}`}
>
{previewing[cell.cellKey] === false ? (
<Eye className="h-4 w-4" aria-hidden="true" />
) : (
<Pencil className="h-4 w-4" aria-hidden="true" />
)}
{previewing[cell.cellKey] === false ? 'Preview' : 'Edit'}
</button>
)}
{cellActions(index)}
</span>
</div>
)}
{cell.cellType === 'MARKDOWN' ? (
<section className="rounded-md border p-3" aria-label={`Markdown cell ${index + 1}`}>
<button
type="button"
className="mb-1 text-xs text-muted-foreground underline"
onClick={() =>
setPreviewing({ ...previewing, [cell.cellKey]: !previewing[cell.cellKey] })
{previewing[cell.cellKey] === false ? (
<textarea
className={cn(INPUT_CLASS, 'block min-h-28 resize-y font-mono')}
value={cell.content}
aria-label={`Markdown for cell ${index + 1}`}
onChange={(e) =>
updateCells(
cells.map((c, i) => (i === index ? { ...c, content: e.target.value } : c))
)
}
data-qqq-id={`button-sql-markdown-toggle-${index}`}
>
{previewing[cell.cellKey] === false ? 'Preview' : 'Edit'}
</button>
{previewing[cell.cellKey] === false ? (
<textarea
className="block h-28 w-full rounded border p-2 font-mono text-sm"
value={cell.content}
aria-label={`Markdown for cell ${index + 1}`}
onChange={(e) =>
updateCells(
cells.map((c, i) => (i === index ? { ...c, content: e.target.value } : c))
)
}
data-qqq-id={`textarea-sql-markdown-${index}`}
/>
) : (
<MarkdownView source={cell.content || '*Empty markdown cell*'} />
)}
</section>
) : (
<SqlCell
cell={cell}
index={index}
capabilities={capabilities}
defaultBackendName={defaultBackendName}
paramsFor={paramsFor}
notebookId={saved ? notebook.id : null}
canEdit={canEdit}
snapshots={snapshots.filter((s) => s.cellKey === cell.cellKey)}
onChange={(next) => updateCells(cells.map((c, i) => (i === index ? next : c)))}
onSnapshotsChanged={() =>
queryClient.invalidateQueries({
queryKey: queryKeys.sqlNotebookSnapshots(notebook.id),
})
}
/>
)}
</div>
))}
data-qqq-id={`textarea-sql-markdown-${index}`}
/>
) : (
<MarkdownView source={cell.content || '*Empty notes*'} />
)}
</section>
) : (
<SqlCell
key={cell.cellKey}
cell={cell}
index={index}
capabilities={capabilities}
defaultBackendName={defaultBackendName}
paramsFor={paramsFor}
notebookId={saved ? notebook.id : null}
canEdit={canEdit}
actions={cellActions(index)}
snapshots={snapshots.filter((s) => s.cellKey === cell.cellKey)}
onChange={(next) => updateCells(cells.map((c, i) => (i === index ? next : c)))}
onSnapshotsChanged={() =>
queryClient.invalidateQueries({
queryKey: queryKeys.sqlNotebookSnapshots(notebook.id),
})
}
/>
)
)}
<div className="flex gap-2">
<button
type="button"
className={button}
onClick={() =>
updateCells([
...cells,
{
cellKey: newCellKey(),
cellType: 'SQL',
content: '',
title: null,
backendName: null,
},
])
}
data-qqq-id="button-sql-add-sql-cell"
>
+ SQL cell
</button>
<button
type="button"
className={button}
onClick={() => {
const cellKey = newCellKey()
setPreviewing({ ...previewing, [cellKey]: false })
updateCells([
...cells,
{ cellKey, cellType: 'MARKDOWN', content: '', title: null, backendName: null },
])
}}
data-qqq-id="button-sql-add-markdown-cell"
>
+ Markdown cell
</button>
</div>
{canEdit && (
<div className="flex flex-wrap gap-2">
<button
type="button"
className={buttonClass('secondary', 'sm')}
onClick={() => addCell('SQL')}
data-qqq-id="button-sql-add-sql-cell"
>
<Plus className="h-4 w-4" aria-hidden="true" />
Add SQL cell
</button>
<button
type="button"
className={buttonClass('secondary', 'sm')}
onClick={() => addCell('MARKDOWN')}
data-qqq-id="button-sql-add-markdown-cell"
>
<Plus className="h-4 w-4" aria-hidden="true" />
Add notes cell
</button>
</div>
)}
</div>
)
}
/**
* Asks before a notebook and its versions are deleted (the app's standard alert dialog).
*
* @param props - Component props.
* @param props.title - The notebook's title.
* @param props.onConfirm - Deletes it.
* @param props.onCancel - Closes the dialog.
* @returns The dialog (open while mounted).
*/
function ConfirmDeleteDialog({
title,
onConfirm,
onCancel,
}: {
title: string
onConfirm: () => void
onCancel: () => void
}) {
return (
<AlertDialog.Root open onOpenChange={(open) => !open && onCancel()}>
<AlertDialog.Portal>
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
<AlertDialog.Content
className={cn(
'fixed left-1/2 top-1/2 z-50 max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 overflow-y-auto',
'rounded-xl border border-border bg-background text-foreground shadow-lg focus:outline-none'
)}
data-qqq-id="dialog-sql-notebook-delete"
>
<div className="space-y-2 p-6">
<AlertDialog.Title className="text-lg font-semibold">
Delete notebook?
</AlertDialog.Title>
<AlertDialog.Description className="text-sm text-muted-foreground">
“{title}” and all of its versions will be deleted. This cannot be undone.
</AlertDialog.Description>
</div>
<div className="flex flex-wrap items-center justify-end gap-3 rounded-b-xl bg-muted px-6 py-4">
<AlertDialog.Cancel asChild>
<button
type="button"
className={buttonClass('secondary')}
data-qqq-id="button-sql-notebook-delete-cancel"
>
Cancel
</button>
</AlertDialog.Cancel>
<button
type="button"
className={buttonClass('danger')}
onClick={onConfirm}
data-qqq-id="button-sql-notebook-delete-confirm"
>
Delete
</button>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
)
}
+28 -18
View File
@@ -17,7 +17,7 @@
/**
* @file PlanTree — a query plan as a collapsible tree: each node's operation, target, cost range,
* estimated rows and (with ANALYZE) actual time, rows and loops, with a bar scaled to the most
* expensive node. A toggle shows the raw plan text.
* expensive node. A toggle button shows the raw plan text instead.
*/
'use client'
@@ -26,6 +26,8 @@ import React, { useState } from 'react'
import type { SqlExplainResponse, SqlPlanNode } from '@/types'
import { planMax } from '@/lib/utils/sql-workbench'
import { cn } from '@/lib/utils/cn'
import { ALERT_CLASS, buttonClass } from '@/components/ui/controls'
/**
* Formats a plan number.
@@ -60,36 +62,41 @@ function PlanNodeItem({
const extras = Object.entries(node.extra ?? {})
const summary = (
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-0.5">
<span className="font-medium">{node.label}</span>
<span className="font-semibold text-foreground">{node.label}</span>
{node.detail && <span className="text-muted-foreground">{node.detail}</span>}
{node.totalCost !== null && (
<span className="font-mono text-xs">
<span className="font-mono text-xs text-muted-foreground">
cost {n(node.startupCost)}..{n(node.totalCost)}
</span>
)}
{node.planRows !== null && <span className="font-mono text-xs">rows {n(node.planRows)}</span>}
{node.planRows !== null && (
<span className="font-mono text-xs text-muted-foreground">rows {n(node.planRows)}</span>
)}
{node.actualTimeMs !== null && (
<span className="font-mono text-xs">
<span className="font-mono text-xs text-foreground">
actual {n(node.actualTimeMs)} ms · {n(node.actualRows)} rows × {n(node.loops)}
</span>
)}
{width > 0 && (
<span className="inline-block h-1.5 w-24 rounded bg-muted" aria-hidden>
<span className="block h-1.5 rounded bg-primary" style={{ width: `${width}%` }} />
<span className="inline-block h-1.5 w-24 rounded-full bg-muted" aria-hidden>
{/* the bar's length is data, so it is the one inline style */}
<span className="block h-1.5 rounded-full bg-primary" style={{ width: `${width}%` }} />
</span>
)}
</span>
)
return (
<li className="my-1">
<li className="my-1.5">
<details open>
<summary className="cursor-pointer">{summary}</summary>
<summary className="cursor-pointer rounded-md px-1 py-0.5 marker:text-muted-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{summary}
</summary>
{extras.length > 0 && (
<dl className="ml-4 grid grid-cols-[auto_1fr] gap-x-2 text-xs text-muted-foreground">
<dl className="ml-5 mt-0.5 grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 text-xs text-muted-foreground">
{extras.map(([k, v]) => (
<React.Fragment key={k}>
<dt>{k}</dt>
<dd className="truncate font-mono">
<dd className="break-words font-mono text-foreground">
{typeof v === 'string' ? v : JSON.stringify(v)}
</dd>
</React.Fragment>
@@ -97,7 +104,7 @@ function PlanNodeItem({
</dl>
)}
{node.children.length > 0 && (
<ul className="ml-4 border-l pl-3">
<ul className="ml-3 border-l border-border pl-3">
{node.children.map((child, i) => (
<PlanNodeItem key={i} node={child} max={max} useActual={useActual} />
))}
@@ -119,7 +126,7 @@ export function PlanTree({ explain }: { explain: SqlExplainResponse }) {
const [raw, setRaw] = useState(false)
if (explain.status !== 'SUCCEEDED' || !explain.plan) {
return (
<p className="text-sm text-destructive" role="alert">
<p className={ALERT_CLASS} role="alert">
{explain.error ?? 'The plan could not be produced.'}
</p>
)
@@ -128,21 +135,24 @@ export function PlanTree({ explain }: { explain: SqlExplainResponse }) {
const max = planMax(explain.plan, useActual)
return (
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>
{explain.analyze ? 'EXPLAIN ANALYZE (executed, rolled back)' : 'EXPLAIN (estimated)'}
{explain.analyze
? 'Actual plan: the statement ran and was rolled back'
: 'Estimated plan'}
</span>
<button
type="button"
className="ml-auto rounded border px-2 py-0.5"
className={cn(buttonClass('secondary', 'sm'), 'ml-auto')}
aria-pressed={raw}
onClick={() => setRaw(!raw)}
data-qqq-id="button-sql-plan-raw"
>
{raw ? 'Tree' : 'Raw'}
Raw text
</button>
</div>
{raw ? (
<pre className="max-h-96 overflow-auto rounded bg-muted p-2 font-mono text-xs">
<pre className="whitespace-pre-wrap break-words rounded-md border border-border bg-muted p-3 font-mono text-xs text-foreground">
{explain.raw}
</pre>
) : (
+70 -37
View File
@@ -16,7 +16,8 @@
/**
* @file QuickChart — a bar, line or pie chart of a result's current rows: pick the category
* column and the value columns. Uses Recharts, as the dashboard chart widgets do.
* column and the value columns. Uses Recharts, as the dashboard chart widgets do, with the
* theme's chart palette and surfaces so it reads in light and dark mode.
*/
'use client'
@@ -40,17 +41,27 @@ import {
import type { SqlColumn, SqlValue } from '@/types'
import { numericColumnIndexes, toChartRows } from '@/lib/utils/sql-workbench'
import { CHECKBOX_CLASS, SELECT_CLASS } from '@/components/ui/controls'
const COLORS = [
'#6366f1',
'#10b981',
'#f59e0b',
'#ef4444',
'#06b6d4',
'#8b5cf6',
'#84cc16',
'#ec4899',
]
/**
* Series colors: the theme's chart palette tokens, which the dark theme redefines, so the chart
* follows light and dark mode (Recharts writes them as SVG presentation values).
*/
export const CHART_COLORS = Array.from({ length: 10 }, (_, i) => `var(--color-chart-${i + 1})`)
/** Axis ticks and legend text: the muted text color of the surrounding card. */
const TICK = { fontSize: 12, fill: 'currentColor' }
/** Tooltip box on the popover surface. */
const TOOLTIP_STYLE: React.CSSProperties = {
backgroundColor: 'var(--color-popover)',
border: '1px solid var(--color-border)',
borderRadius: '0.5rem',
color: 'var(--color-popover-foreground)',
fontSize: '12px',
}
const TOOLTIP_ITEM_STYLE: React.CSSProperties = { color: 'var(--color-popover-foreground)' }
const TOOLTIP_CURSOR = { fill: 'var(--color-muted)', opacity: 0.6 }
/**
* Renders the chart builder.
@@ -82,11 +93,11 @@ export function QuickChart({ columns, rows }: { columns: SqlColumn[]; rows: SqlV
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-3 text-sm">
<label className="inline-flex items-center gap-1">
Chart
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 text-sm text-foreground">
<label className="inline-flex items-center gap-2">
Chart type
<select
className="rounded border px-1 py-0.5"
className={SELECT_CLASS}
value={type}
onChange={(e) => setType(e.target.value as 'bar' | 'line' | 'pie')}
data-qqq-id="select-sql-chart-type"
@@ -96,10 +107,10 @@ export function QuickChart({ columns, rows }: { columns: SqlColumn[]; rows: SqlV
<option value="pie">Pie</option>
</select>
</label>
<label className="inline-flex items-center gap-1">
<label className="inline-flex min-w-0 items-center gap-2">
Category
<select
className="rounded border px-1 py-0.5"
className={SELECT_CLASS}
value={xIndex}
onChange={(e) => setXIndex(Number(e.target.value))}
data-qqq-id="select-sql-chart-x"
@@ -111,12 +122,13 @@ export function QuickChart({ columns, rows }: { columns: SqlColumn[]; rows: SqlV
))}
</select>
</label>
<fieldset className="inline-flex flex-wrap items-center gap-2">
<fieldset className="inline-flex flex-wrap items-center gap-x-3 gap-y-1">
<legend className="sr-only">Values</legend>
<span>Values</span>
<span aria-hidden="true">Values</span>
{numeric.map((i) => (
<label key={i} className="inline-flex items-center gap-1">
<label key={i} className="inline-flex cursor-pointer items-center gap-1.5">
<input
className={CHECKBOX_CLASS}
type={type === 'pie' ? 'radio' : 'checkbox'}
name="sql-chart-y"
checked={yIndexes.includes(i)}
@@ -137,47 +149,68 @@ export function QuickChart({ columns, rows }: { columns: SqlColumn[]; rows: SqlV
</fieldset>
</div>
<div
className="h-72 w-full"
className="h-80 w-full text-muted-foreground"
role="img"
aria-label={`${type} chart of ${series.join(', ') || 'no values'} by ${columns[xIndex]?.label}`}
>
<ResponsiveContainer width="100%" height="100%">
{type === 'pie' ? (
<PieChart>
<Pie data={data} dataKey={series[0] ?? ''} nameKey="x" outerRadius="80%" label>
<Pie
data={data}
dataKey={series[0] ?? ''}
nameKey="x"
outerRadius="75%"
label={TICK}
stroke="var(--color-card)"
>
{data.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
<Tooltip contentStyle={TOOLTIP_STYLE} itemStyle={TOOLTIP_ITEM_STYLE} />
<Legend wrapperStyle={{ fontSize: '12px' }} />
</PieChart>
) : type === 'line' ? (
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="x" />
<YAxis />
<Tooltip />
<Legend />
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" strokeOpacity={0.15} />
<XAxis dataKey="x" tick={TICK} stroke="currentColor" strokeOpacity={0.4} />
<YAxis tick={TICK} stroke="currentColor" strokeOpacity={0.4} />
<Tooltip
contentStyle={TOOLTIP_STYLE}
itemStyle={TOOLTIP_ITEM_STYLE}
cursor={{ stroke: 'currentColor', strokeOpacity: 0.3 }}
/>
<Legend wrapperStyle={{ fontSize: '12px' }} />
{series.map((s, i) => (
<Line
key={s}
type="monotone"
dataKey={s}
stroke={COLORS[i % COLORS.length]}
stroke={CHART_COLORS[i % CHART_COLORS.length]}
strokeWidth={2}
dot={false}
/>
))}
</LineChart>
) : (
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="x" />
<YAxis />
<Tooltip />
<Legend />
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" strokeOpacity={0.15} />
<XAxis dataKey="x" tick={TICK} stroke="currentColor" strokeOpacity={0.4} />
<YAxis tick={TICK} stroke="currentColor" strokeOpacity={0.4} />
<Tooltip
contentStyle={TOOLTIP_STYLE}
itemStyle={TOOLTIP_ITEM_STYLE}
cursor={TOOLTIP_CURSOR}
/>
<Legend wrapperStyle={{ fontSize: '12px' }} />
{series.map((s, i) => (
<Bar key={s} dataKey={s} fill={COLORS[i % COLORS.length]} />
<Bar
key={s}
dataKey={s}
fill={CHART_COLORS[i % CHART_COLORS.length]}
radius={[3, 3, 0, 0]}
/>
))}
</BarChart>
)}
+129 -43
View File
@@ -18,20 +18,49 @@
* @file ResultGrid — typed, sortable, paginated view of a workbench result. The first page comes
* with the run; later pages and sorts are read from the server's cached copy of the result
* (`GET /executions/{uuid}/rows`), so SQL never re-runs. Copies the page as TSV and links the
* CSV / XLSX / JSON exports of the whole (capped) result.
* CSV / Excel / JSON exports of the whole (capped) result from an Export menu.
*/
'use client'
import React, { useState } from 'react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { useQuery } from '@tanstack/react-query'
import {
ArrowDown,
ArrowUp,
ChevronDown,
ChevronLeft,
ChevronRight,
ClipboardCopy,
Download,
} from 'lucide-react'
import { toast } from 'sonner'
import type { SqlColumn, SqlValue } from '@/types'
import type { SqlColumn, SqlColumnKind, SqlExportFormat, SqlValue } from '@/types'
import { getSqlRows, sqlExportUrl } from '@/lib/api/sql-workbench'
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
import { formatSqlValue, isBinaryValue, prettyJson, rowsToTsv } from '@/lib/utils/sql-workbench'
import { cn } from '@/lib/utils/cn'
import {
ALERT_CLASS,
buttonClass,
MENU_CONTENT_CLASS,
MENU_ITEM_CLASS,
} from '@/components/ui/controls'
/** Column kinds aligned right (as numbers are in the record grid). */
const RIGHT_ALIGNED = new Set<SqlColumnKind>(['number'])
/** Column kinds kept on one line. */
const NO_WRAP = new Set<SqlColumnKind>(['number', 'boolean', 'date', 'datetime', 'time'])
/** The export formats, with plain labels. */
const EXPORT_FORMATS: { format: SqlExportFormat; label: string }[] = [
{ format: 'csv', label: 'CSV' },
{ format: 'xlsx', label: 'Excel (XLSX)' },
{ format: 'json', label: 'JSON' },
]
/** Props for {@link ResultGrid}. */
export interface ResultGridProps {
@@ -69,8 +98,10 @@ export function SqlValueCell({ value, column }: { value: SqlValue; column: SqlCo
if (column.kind === 'json' && typeof value === 'string') {
return (
<details>
<summary className="max-w-xs cursor-pointer truncate font-mono text-xs">{value}</summary>
<pre className="mt-1 max-h-64 overflow-auto rounded bg-muted p-2 text-xs">
<summary className="max-w-xs cursor-pointer truncate font-mono text-xs text-foreground">
{value}
</summary>
<pre className="mt-1 max-w-md whitespace-pre-wrap break-words rounded-md border border-border bg-muted p-2 font-mono text-xs text-foreground">
{prettyJson(value)}
</pre>
</details>
@@ -78,7 +109,12 @@ export function SqlValueCell({ value, column }: { value: SqlValue; column: SqlCo
}
if (column.kind === 'boolean') return <span className="font-mono">{String(value)}</span>
return (
<span className={cn(column.kind === 'number' && 'font-mono tabular-nums')}>
<span
className={cn(
(column.kind === 'number' || NO_WRAP.has(column.kind)) && 'tabular-nums',
column.kind === 'number' && 'font-mono'
)}
>
{formatSqlValue(value)}
</span>
)
@@ -149,27 +185,45 @@ export function ResultGrid(props: ResultGridProps) {
const from = rowCount === 0 ? 0 : page * pageSize + 1
const to = Math.min(rowCount, (page + 1) * pageSize)
// The record grid's look (DataGrid, standard density): muted header, bordered rows, row hover.
// Only the grid scrolls sideways or down, inside its own box; the page never does.
return (
<div className="space-y-2">
<div className="max-h-[28rem] overflow-auto rounded-md border">
<div
className="relative max-h-[32rem] w-full overflow-auto rounded-md border border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
tabIndex={0}
role="region"
aria-label="Query result rows"
data-qqq-id="grid-sql-result"
>
{isFetching && (
<div className="sticky left-0 top-0 z-[4] h-0.5 w-full overflow-hidden bg-primary/20">
<div className="h-full w-1/3 animate-pulse bg-primary" />
</div>
)}
<table
className="w-full border-collapse text-sm"
aria-label="Query result"
aria-busy={isFetching}
>
<thead className="sticky top-0 bg-muted">
<tr>
<thead className="sticky top-0 z-[3]">
<tr className="border-b border-border bg-muted">
{columns.map((column, index) => (
<th
key={index}
scope="col"
className="whitespace-nowrap border-b px-2 py-1 text-left font-medium"
className={cn(
'whitespace-nowrap bg-muted px-4 py-2 text-left align-top font-semibold text-foreground',
column.kind === 'number' && 'text-right'
)}
aria-sort={
sort?.column === index
? sort.dir === 'asc'
? 'ascending'
: 'descending'
: 'none'
staticRows
? undefined
: sort?.column === index
? sort.dir === 'asc'
? 'ascending'
: 'descending'
: 'none'
}
>
{staticRows ? (
@@ -177,17 +231,17 @@ export function ResultGrid(props: ResultGridProps) {
) : (
<button
type="button"
className="inline-flex items-center gap-1 hover:underline"
className="inline-flex items-center gap-1 rounded hover:text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => toggleSort(index)}
title={`${column.typeName} — sort`}
title={`${column.typeName}: select to sort`}
data-qqq-id={`button-sql-sort-${index}`}
>
{column.label}
{sort?.column === index &&
(sort.dir === 'asc' ? (
<span aria-hidden>▲</span>
<ArrowUp className="h-3 w-3 text-primary" aria-hidden="true" />
) : (
<span aria-hidden>▼</span>
<ArrowDown className="h-3 w-3 text-primary" aria-hidden="true" />
))}
</button>
)}
@@ -197,13 +251,17 @@ export function ResultGrid(props: ResultGridProps) {
</thead>
<tbody>
{rows.map((row, r) => (
<tr key={r} className="odd:bg-background even:bg-muted/30">
<tr
key={r}
className="border-b border-border transition-colors last:border-b-0 hover:bg-muted/50"
>
{columns.map((column, c) => (
<td
key={c}
className={cn(
'border-b px-2 py-1 align-top',
column.kind === 'number' && 'text-right'
'px-4 py-2 align-top text-foreground',
RIGHT_ALIGNED.has(column.kind) && 'text-right',
NO_WRAP.has(column.kind) && 'whitespace-nowrap'
)}
>
<SqlValueCell value={row[c] ?? null} column={column} />
@@ -215,65 +273,93 @@ export function ResultGrid(props: ResultGridProps) {
</table>
</div>
{error && (
<p className="text-sm text-destructive" role="alert">
<p className={ALERT_CLASS} role="alert">
Could not load rows: {error instanceof Error ? error.message : 'error'}
</p>
)}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground">
<span>
Rows {from}–{to} of {rowCount}
Rows {from.toLocaleString()}–{to.toLocaleString()} of {rowCount.toLocaleString()}
{truncated && (
<span className="ml-1 text-amber-600">
<span className="ml-1 font-medium text-amber-800 dark:text-amber-300">
(capped by the {truncatedReason === 'BYTES' ? 'size' : 'row'} limit)
</span>
)}
</span>
{!staticRows && pages > 1 && (
<span className="inline-flex items-center gap-1">
<span className="inline-flex items-center gap-2">
<button
type="button"
className="rounded border px-2 py-0.5 disabled:opacity-50"
className={buttonClass('secondary', 'sm')}
disabled={page === 0}
onClick={() => setPage(page - 1)}
data-qqq-id="button-sql-page-prev"
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
Previous
</button>
<span>
<span className="whitespace-nowrap">
Page {page + 1} of {pages}
</span>
<button
type="button"
className="rounded border px-2 py-0.5 disabled:opacity-50"
className={buttonClass('secondary', 'sm')}
disabled={page + 1 >= pages}
onClick={() => setPage(page + 1)}
data-qqq-id="button-sql-page-next"
>
Next
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</button>
</span>
)}
<span className="ml-auto inline-flex items-center gap-1">
<span className="inline-flex items-center gap-2 @lg:ml-auto">
<button
type="button"
className="inline-flex items-center gap-1 rounded border px-2 py-0.5"
className={buttonClass('secondary', 'sm')}
onClick={copy}
data-qqq-id="button-sql-copy"
>
<ClipboardCopy className="h-4 w-4" aria-hidden="true" />
Copy page
</button>
{!staticRows &&
(['csv', 'xlsx', 'json'] as const).map((format) => (
<a
key={format}
href={sqlExportUrl(executionUuid, format)}
download
className="inline-flex items-center gap-1 rounded border px-2 py-0.5"
data-qqq-id={`link-sql-export-${format}`}
>
{format.toUpperCase()}
</a>
))}
{!staticRows && (
<DropdownMenu.Root modal={false}>
<DropdownMenu.Trigger asChild>
<button
type="button"
className={buttonClass('secondary', 'sm')}
data-qqq-id="button-sql-export"
>
<Download className="h-4 w-4" aria-hidden="true" />
Export
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
align="end"
sideOffset={4}
collisionPadding={8}
className={MENU_CONTENT_CLASS}
aria-label="Export formats"
>
{EXPORT_FORMATS.map(({ format, label }) => (
<DropdownMenu.Item key={format} asChild>
<a
href={sqlExportUrl(executionUuid, format)}
download
className={MENU_ITEM_CLASS}
data-qqq-id={`link-sql-export-${format}`}
>
{label}
</a>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)}
</span>
</div>
</div>
+180 -88
View File
@@ -25,6 +25,7 @@
import React, { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { ChevronRight, Loader2, Lock, Pin, Play, Square, WrapText } from 'lucide-react'
import { toast } from 'sonner'
import type {
@@ -45,12 +46,26 @@ import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
import { getErrorMessage } from '@/lib/utils/error-utils'
import { formatDuration, newCellKey } from '@/lib/utils/sql-workbench'
import { cn } from '@/lib/utils/cn'
import {
ALERT_CLASS,
BADGE_CLASS,
buttonClass,
CARD_CLASS,
CHECKBOX_CLASS,
SELECT_CLASS,
WARNING_CLASS,
} from '@/components/ui/controls'
import { DateTimeText } from '@/components/ui/DateTimeText'
import { PlanTree } from './PlanTree'
import { QuickChart } from './QuickChart'
import { ResultGrid } from './ResultGrid'
import { SqlEditor } from './SqlEditor'
import { WorkbenchTabs } from './WorkbenchTabs'
import { WriteConfirmDialog } from './WriteConfirmDialog'
/** The result views of a cell. */
type ResultTab = 'grid' | 'chart' | 'plan'
/** Props for {@link SqlCell}. */
export interface SqlCellProps {
cell: SqlNotebookCell
@@ -61,6 +76,8 @@ export interface SqlCellProps {
paramsFor: (sql: string) => Record<string, string | number | boolean | null>
notebookId: number | null
canEdit: boolean
/** Cell controls (move, delete) shown in the header. */
actions?: React.ReactNode
snapshots: SqlResultSnapshot[]
onChange: (cell: SqlNotebookCell) => void
onSnapshotsChanged: () => void
@@ -81,6 +98,7 @@ export function SqlCell(props: SqlCellProps) {
paramsFor,
notebookId,
canEdit,
actions,
snapshots,
onChange,
onSnapshotsChanged,
@@ -95,7 +113,8 @@ export function SqlCell(props: SqlCellProps) {
const [preview, setPreview] = useState<SqlExecuteResponse | null>(null)
const [committing, setCommitting] = useState(false)
const [requestError, setRequestError] = useState<string | null>(null)
const [tab, setTab] = useState<'grid' | 'chart' | 'plan'>('grid')
const [tab, setTab] = useState<ResultTab>('grid')
const resultsId = React.useId()
const { data: schema } = useQuery({
queryKey: queryKeys.sqlWorkbenchSchema(backendName),
@@ -215,27 +234,35 @@ export function SqlCell(props: SqlCellProps) {
}
}
const button = 'rounded border px-2 py-1 text-xs disabled:opacity-50'
const resultTabs: { value: ResultTab; label: string; qqqId: string }[] = [
{ value: 'grid', label: 'Results', qqqId: `tab-sql-grid-${index}` },
{ value: 'chart', label: 'Chart', qqqId: `tab-sql-chart-${index}` },
{ value: 'plan', label: 'Plan', qqqId: `tab-sql-plan-${index}` },
]
return (
<section
className="space-y-2 rounded-md border p-3"
className={cn(CARD_CLASS, 'space-y-3 p-3')}
aria-label={`SQL cell ${index + 1}`}
data-qqq-id={`section-sql-cell-${index}`}
>
<div className="flex flex-wrap items-center gap-2 text-xs">
<div className="flex items-center gap-2">
<input
className="min-w-0 flex-1 rounded border-transparent bg-transparent px-1 py-0.5 text-sm font-medium hover:border-border focus:border-border"
className="h-8 min-w-0 flex-1 rounded-md border border-transparent bg-transparent px-2 text-sm font-semibold text-foreground placeholder:text-muted-foreground hover:border-input focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring"
value={cell.title ?? ''}
placeholder={`SQL cell ${index + 1}`}
aria-label={`Title of SQL cell ${index + 1}`}
onChange={(e) => onChange({ ...cell, title: e.target.value })}
data-qqq-id={`input-sql-cell-title-${index}`}
/>
<label className="inline-flex items-center gap-1">
Backend
{actions}
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<label className="inline-flex min-w-0 max-w-full items-center gap-2 text-sm text-foreground">
<span className="whitespace-nowrap">Backend</span>
<select
className="rounded border px-1 py-0.5"
className={SELECT_CLASS}
value={backendName}
onChange={(e) => onChange({ ...cell, backendName: e.target.value })}
data-qqq-id={`select-sql-backend-${index}`}
@@ -250,12 +277,15 @@ export function SqlCell(props: SqlCellProps) {
{capabilities.canWrite ? (
<label
className={cn(
'inline-flex items-center gap-1 rounded px-1.5 py-0.5',
writeMode ? 'bg-destructive/15 text-destructive' : 'bg-muted'
'inline-flex h-8 cursor-pointer items-center gap-2 rounded-md border px-2.5 text-sm font-medium transition-colors',
writeMode
? 'border-destructive/50 bg-destructive/10 text-destructive'
: 'border-input bg-card text-foreground hover:bg-accent'
)}
>
<input
type="checkbox"
className={CHECKBOX_CLASS}
checked={writeMode}
onChange={(e) => setWriteMode(e.target.checked)}
data-qqq-id={`checkbox-sql-write-mode-${index}`}
@@ -263,7 +293,10 @@ export function SqlCell(props: SqlCellProps) {
Write mode
</label>
) : (
<span className="rounded bg-muted px-1.5 py-0.5">Read-only</span>
<span className={BADGE_CLASS}>
<Lock className="h-3.5 w-3.5" aria-hidden="true" />
Read-only
</span>
)}
</div>
@@ -281,26 +314,28 @@ export function SqlCell(props: SqlCellProps) {
{runningUuid ? (
<button
type="button"
className={button}
className={buttonClass('secondary', 'sm')}
onClick={cancel}
data-qqq-id={`button-sql-cancel-${index}`}
>
<Square className="h-4 w-4" aria-hidden="true" />
Cancel
</button>
) : (
<button
type="button"
className={cn(button, 'bg-primary text-primary-foreground')}
className={buttonClass(writeMode ? 'danger' : 'primary', 'sm')}
onClick={run}
disabled={!backend}
data-qqq-id={`button-sql-run-${index}`}
>
<Play className="h-4 w-4" aria-hidden="true" />
{writeMode ? 'Preview write' : 'Run'}
</button>
)}
<button
type="button"
className={button}
className={buttonClass('secondary', 'sm')}
onClick={() => runExplain(false)}
disabled={!backend || !!runningUuid}
data-qqq-id={`button-sql-explain-${index}`}
@@ -310,7 +345,7 @@ export function SqlCell(props: SqlCellProps) {
{capabilities.canWrite && (
<button
type="button"
className={button}
className={buttonClass('secondary', 'sm')}
onClick={() => runExplain(true)}
disabled={!backend || !!runningUuid}
title="Executes the statement in a transaction that is rolled back"
@@ -321,26 +356,32 @@ export function SqlCell(props: SqlCellProps) {
)}
<button
type="button"
className={button}
className={buttonClass('ghost', 'sm')}
onClick={format}
data-qqq-id={`button-sql-format-${index}`}
>
<WrapText className="h-4 w-4" aria-hidden="true" />
Format
</button>
{runningUuid && (
<span className="text-xs text-muted-foreground" role="status">
<span
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground"
role="status"
>
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Running…
</span>
)}
{backend && !backend.readOnlyEnforced && !writeMode && (
<span className="text-xs text-amber-600">
This backend does not enforce read-only sessions; only the statement check applies.
</span>
)}
</div>
{backend && !backend.readOnlyEnforced && !writeMode && (
<p className={WARNING_CLASS}>
This backend does not enforce read-only sessions; only the statement check applies.
</p>
)}
{requestError && (
<p className="text-sm text-destructive" role="alert">
<p className={ALERT_CLASS} role="alert">
{requestError}
</p>
)}
@@ -356,78 +397,96 @@ export function SqlCell(props: SqlCellProps) {
)}
{(result || explain) && (
<div className="space-y-2">
<div className="flex items-center gap-1 text-xs" role="tablist" aria-label="Result views">
{(['grid', 'chart', 'plan'] as const).map((t) => (
<button
key={t}
type="button"
role="tab"
aria-selected={tab === t}
className={cn(
'rounded px-2 py-0.5',
tab === t ? 'bg-muted font-medium' : 'text-muted-foreground'
)}
onClick={() => setTab(t)}
data-qqq-id={`tab-sql-${t}-${index}`}
>
{t === 'grid' ? 'Grid' : t === 'chart' ? 'Chart' : 'Plan'}
</button>
))}
{result && (
<span className="ml-2 text-muted-foreground">
{result.status}
{result.durationMs != null && ` · ${formatDuration(result.durationMs)}`}
{result.rowsAffected != null && ` · ${result.rowsAffected} rows affected`}
</span>
)}
{result?.status === 'SUCCEEDED' &&
notebookId &&
canEdit &&
(result.columns?.length ?? 0) > 0 && (
<button
type="button"
className={cn(button, 'ml-auto')}
onClick={pin}
data-qqq-id={`button-sql-pin-${index}`}
<div className="space-y-3">
<WorkbenchTabs
label="Result views"
idBase={resultsId}
value={tab}
onChange={setTab}
tabs={resultTabs}
>
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 py-1.5">
{result && (
<span
className="text-sm text-muted-foreground"
data-qqq-id={`text-sql-status-${index}`}
>
Pin result
</button>
{resultSummary(result)}
</span>
)}
{result?.status === 'SUCCEEDED' &&
notebookId &&
canEdit &&
(result.columns?.length ?? 0) > 0 && (
<button
type="button"
className={buttonClass('secondary', 'sm')}
onClick={pin}
data-qqq-id={`button-sql-pin-${index}`}
>
<Pin className="h-4 w-4" aria-hidden="true" />
Pin result
</button>
)}
</span>
</WorkbenchTabs>
<div
role="tabpanel"
id={`${resultsId}-panel`}
aria-labelledby={`${resultsId}-tab-${tab}`}
>
{tab === 'grid' &&
(result ? (
<ResultView
key={result.executionUuid}
result={result}
pageSize={capabilities.limits.pageSize}
/>
) : (
<p className="text-sm text-muted-foreground">Run the cell to see results.</p>
))}
{tab === 'chart' &&
(result?.status === 'SUCCEEDED' ? (
<QuickChart
key={result.executionUuid}
columns={result.columns ?? []}
rows={result.rows ?? []}
/>
) : (
<p className="text-sm text-muted-foreground">Run the cell to chart its results.</p>
))}
{tab === 'plan' &&
(explain ? (
<PlanTree explain={explain} />
) : (
<p className="text-sm text-muted-foreground">Use Explain to see the plan.</p>
))}
</div>
{tab === 'grid' && result && (
<ResultView
key={result.executionUuid}
result={result}
pageSize={capabilities.limits.pageSize}
/>
)}
{tab === 'chart' && result?.status === 'SUCCEEDED' && (
<QuickChart
key={result.executionUuid}
columns={result.columns ?? []}
rows={result.rows ?? []}
/>
)}
{tab === 'plan' &&
(explain ? (
<PlanTree explain={explain} />
) : (
<p className="text-sm text-muted-foreground">Use Explain to see the plan.</p>
))}
</div>
)}
{snapshots.length > 0 && (
<details className="text-sm">
<summary className="cursor-pointer text-xs text-muted-foreground">
<details className="group">
<summary className="inline-flex cursor-pointer list-none items-center gap-1.5 rounded-md text-sm font-medium text-muted-foreground hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring [&::-webkit-details-marker]:hidden">
<ChevronRight
className="h-4 w-4 transition-transform group-open:rotate-90"
aria-hidden="true"
/>
Pinned results ({snapshots.length})
</summary>
{snapshots.map((s) => (
<div key={s.id} className="mt-2 space-y-1">
<p className="text-xs text-muted-foreground">
{s.label ?? 'Snapshot'} · {s.createDate ?? ''} · {s.rowCount} rows
{s.truncated ? ' (truncated)' : ''}
<div key={s.id} className="mt-3 space-y-2">
<p className="flex flex-wrap gap-x-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{s.label ?? 'Pinned result'}</span>
<DateTimeText
value={s.createDate}
type="DATE_TIME"
className="text-muted-foreground"
/>
<span>
{s.rowCount} {s.rowCount === 1 ? 'row' : 'rows'}
{s.truncated ? ' (truncated)' : ''}
</span>
</p>
<ResultGrid
executionUuid={s.executionUuid ?? String(s.id)}
@@ -445,6 +504,39 @@ export function SqlCell(props: SqlCellProps) {
)
}
/** Plain names of the run statuses. */
const STATUS_LABELS: Record<SqlExecuteResponse['status'], string> = {
SUCCEEDED: 'Succeeded',
FAILED: 'Failed',
TIMED_OUT: 'Timed out',
CANCELLED: 'Cancelled',
REJECTED: 'Rejected',
PREVIEW: 'Preview',
DRIFTED: 'Not committed',
}
/**
* One line about a run: its status, duration, and rows returned or affected.
*
* @param result - The run.
* @returns The summary.
*/
export function resultSummary(result: SqlExecuteResponse): string {
const parts = [STATUS_LABELS[result.status] ?? result.status]
if (result.durationMs != null) parts.push(formatDuration(result.durationMs))
if (result.rowsAffected != null)
parts.push(
`${result.rowsAffected.toLocaleString()} ${result.rowsAffected === 1 ? 'row' : 'rows'} affected`
)
else if (
result.status === 'SUCCEEDED' &&
(result.columns?.length ?? 0) > 0 &&
result.rowCount != null
)
parts.push(`${result.rowCount.toLocaleString()} ${result.rowCount === 1 ? 'row' : 'rows'}`)
return parts.join(' · ')
}
/**
* Renders a run's outcome: the grid, or the failure, drift or rejection message.
*
@@ -456,7 +548,7 @@ export function SqlCell(props: SqlCellProps) {
function ResultView({ result, pageSize }: { result: SqlExecuteResponse; pageSize: number }) {
if (result.status === 'DRIFTED') {
return (
<p className="text-sm text-amber-700" role="alert">
<p className={WARNING_CLASS} role="alert">
Nothing was committed: the affected rows changed since the preview (
{result.previewAffectedRows} → {result.actualAffectedRows}). Preview again.
</p>
@@ -464,13 +556,13 @@ function ResultView({ result, pageSize }: { result: SqlExecuteResponse; pageSize
}
if (result.status !== 'SUCCEEDED') {
return (
<p className="whitespace-pre-wrap text-sm text-destructive" role="alert">
<p className={cn(ALERT_CLASS, 'font-mono')} role="alert">
{result.status === 'TIMED_OUT'
? 'Timed out: '
: result.status === 'CANCELLED'
? 'Cancelled: '
: ''}
{result.error ?? result.status}
{result.error ?? STATUS_LABELS[result.status] ?? result.status}
</p>
)
}
+6 -16
View File
@@ -22,7 +22,8 @@
* Keyboard (WCAG 2.1.2 No Keyboard Trap): Tab is not bound, so Tab and Shift+Tab always move
* focus out of the editor; indentation uses the editor's indent commands (Ctrl+] / Ctrl+[).
* Loaded only inside the lazily imported workbench widget, so CodeMirror stays out of the
* initial bundle.
* initial bundle. Its colors come from the app theme (see `editor-theme.ts`), so it follows the
* light / dark toggle live.
*/
'use client'
@@ -37,12 +38,13 @@ import {
} from '@codemirror/autocomplete'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { MySQL, PostgreSQL, SQLite, StandardSQL, sql, type SQLDialect } from '@codemirror/lang-sql'
import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { bracketMatching } from '@codemirror/language'
import { Compartment, EditorState } from '@codemirror/state'
import { EditorView, highlightActiveLine, keymap, lineNumbers, placeholder } from '@codemirror/view'
import type { SqlSchema, SqlWorkbenchDialect } from '@/types'
import { editorDialectName, editorSchema, qqqNameCompletions } from '@/lib/utils/sql-workbench'
import { workbenchEditorTheme, workbenchSyntaxHighlighting } from './editor-theme'
const DIALECTS: Record<ReturnType<typeof editorDialectName>, SQLDialect> = {
PostgreSQL,
@@ -129,7 +131,7 @@ export function SqlEditor(props: SqlEditorProps) {
bracketMatching(),
closeBrackets(),
autocompletion({ activateOnTyping: true }),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
workbenchSyntaxHighlighting,
placeholder('SELECT … (Ctrl/Cmd+Enter to run)'),
keymap.of([
{
@@ -151,19 +153,7 @@ export function SqlEditor(props: SqlEditorProps) {
EditorView.updateListener.of((update) => {
if (update.docChanged) onChangeRef.current(update.state.doc.toString())
}),
EditorView.theme({
'&': {
fontSize: '13px',
border: '1px solid var(--border, #d4d4d8)',
borderRadius: '6px',
},
'&.cm-focused': { outline: '2px solid var(--ring, #6366f1)', outlineOffset: '1px' },
'.cm-content': {
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
minHeight: '5rem',
},
'.cm-gutters': { backgroundColor: 'transparent', border: 'none' },
}),
workbenchEditorTheme,
],
}),
})
@@ -145,7 +145,7 @@ describe('PlanTree', () => {
expect(within(tree).getByText('cost 10..12.5')).toBeInTheDocument()
expect(within(tree).getByText('Sort Key')).toBeInTheDocument()
expect(await axe(container)).toHaveNoViolations()
await user.click(screen.getByRole('button', { name: 'Raw' }))
await user.click(screen.getByRole('button', { name: 'Raw text' }))
expect(screen.queryByRole('list', { name: 'Query plan' })).not.toBeInTheDocument()
})
@@ -220,7 +220,7 @@ describe('ResultGrid', () => {
/>
</QueryClientProvider>
)
expect(screen.queryByRole('link', { name: /CSV/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Export' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /Copy page/ }))
})
})
@@ -242,9 +242,9 @@ describe('QuickChart', () => {
/>
)
expect(screen.getByRole('img', { name: /bar chart of signups by month/ })).toBeInTheDocument()
await user.selectOptions(screen.getByLabelText('Chart'), 'line')
await user.selectOptions(screen.getByLabelText('Chart type'), 'line')
expect(screen.getByRole('img', { name: /line chart/ })).toBeInTheDocument()
await user.selectOptions(screen.getByLabelText('Chart'), 'pie')
await user.selectOptions(screen.getByLabelText('Chart type'), 'pie')
await user.click(screen.getByLabelText('signups'))
expect(screen.getByRole('img', { name: /pie chart of signups/ })).toBeInTheDocument()
await user.selectOptions(screen.getByLabelText('Category'), '1')
@@ -99,13 +99,13 @@ describe('SqlWorkbenchWidget', () => {
expect(screen.getByText('month')).toBeInTheDocument()
expect(screen.getByText(':since')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'My notebooks' })).toHaveAttribute(
'aria-pressed',
expect(screen.getByRole('tab', { name: 'My notebooks' })).toHaveAttribute(
'aria-selected',
'true'
)
await user.click(screen.getByRole('button', { name: 'Shared library' }))
expect(screen.getByRole('button', { name: 'Shared library' })).toHaveAttribute(
'aria-pressed',
await user.click(screen.getByRole('tab', { name: 'Shared library' }))
expect(screen.getByRole('tab', { name: 'Shared library' })).toHaveAttribute(
'aria-selected',
'true'
)
expect(await screen.findByRole('button', { name: /Orphan media/ })).toBeInTheDocument()
@@ -127,7 +127,7 @@ describe('SqlWorkbenchWidget', () => {
expect(screen.getAllByLabelText(/^SQL for cell/)).toHaveLength(before + 1)
// Switching notebooks must not inject the old request into the other notebook.
await user.click(screen.getByRole('button', { name: '+ New notebook' }))
await user.click(screen.getByRole('button', { name: 'New notebook' }))
expect(await screen.findByDisplayValue('Untitled notebook')).toBeInTheDocument()
expect(screen.getAllByLabelText(/^SQL for cell/)).toHaveLength(1)
expect(screen.getByLabelText('SQL for cell 1')).toHaveValue('')
@@ -186,16 +186,19 @@ describe('SqlWorkbenchWidget', () => {
expect(sent!.params).toEqual({ since: '2026-01-01' })
expect(sent!.cellKey).toBe('c1')
expect(sent!.executionUuid).toMatch(/[0-9a-f-]{36}/)
expect(screen.getByRole('link', { name: /CSV/ })).toHaveAttribute(
expect(screen.getByText(/Succeeded · .* · 3 rows/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Export' }))
expect(await screen.findByRole('menuitem', { name: 'CSV' })).toHaveAttribute(
'href',
'/qqq/v1/sql-workbench/executions/exec-1/export?format=csv'
)
await user.keyboard('{Escape}')
await user.click(screen.getByRole('button', { name: 'Next' }))
expect(await within(table).findByText('Tim')).toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: 'Chart' }))
expect(screen.getByLabelText('Chart')).toBeInTheDocument()
expect(screen.getByLabelText('Chart type')).toBeInTheDocument()
})
it('shows SQL errors as results', async () => {
@@ -227,7 +230,7 @@ describe('SqlWorkbenchWidget', () => {
await user.click(screen.getByRole('button', { name: 'Preview write' }))
const dialog = await screen.findByRole('dialog')
expect(within(dialog).getByText(/1204 rows affected/)).toBeInTheDocument()
expect(within(dialog).getByText(/1,204 rows affected/)).toBeInTheDocument()
const commit = within(dialog).getByRole('button', { name: 'Commit write' })
expect(commit).toBeDisabled()
await user.type(within(dialog).getByLabelText(/Type postgres to confirm/), 'postgres')
@@ -243,7 +246,7 @@ describe('SqlWorkbenchWidget', () => {
confirmToken: 'token-1',
acknowledgeRowGuard: true,
})
expect(await screen.findByText(/1204 rows affected/)).toBeInTheDocument()
expect(await screen.findByText(/1,204 rows affected/)).toBeInTheDocument()
})
it('explains a statement as a plan tree', async () => {
@@ -253,7 +256,7 @@ describe('SqlWorkbenchWidget', () => {
await user.click(screen.getByRole('button', { name: 'Explain' }))
const plan = await screen.findByRole('list', { name: 'Query plan' })
expect(within(plan).getByText('Seq Scan')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Raw' }))
await user.click(screen.getByRole('button', { name: 'Raw text' }))
expect(screen.getByText('[{"Plan": {}}]')).toBeInTheDocument()
})
@@ -263,7 +266,7 @@ describe('SqlWorkbenchWidget', () => {
const title = await screen.findByLabelText('Notebook title')
await user.clear(title)
await user.type(title, 'Fresh')
await user.click(screen.getByRole('button', { name: '+ Markdown cell' }))
await user.click(screen.getByRole('button', { name: 'Add notes cell' }))
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(window.location.search).toBe('?notebook=9'))
})
@@ -290,7 +293,7 @@ describe('SqlWorkbenchWidget', () => {
const { container } = renderWithClient(
<WidgetRenderer widgetMetaData={widgetMetaData} data={{}} />
)
await screen.findByRole('button', { name: '+ New notebook' })
await screen.findByRole('button', { name: 'New notebook' })
expect(await axe(container)).toHaveNoViolations()
})
})
@@ -38,6 +38,10 @@ import { getErrorMessage } from '@/lib/utils/error-utils'
import { formatDuration } from '@/lib/utils/sql-workbench'
import { cn } from '@/lib/utils/cn'
import { NotebookEditor, type InsertCellRequest } from './NotebookEditor'
import { WorkbenchTabs } from './WorkbenchTabs'
import { CheckCircle2, Loader2, Plus, XCircle } from 'lucide-react'
import { DateTimeText } from '@/components/ui/DateTimeText'
import { ALERT_CLASS, buttonClass } from '@/components/ui/controls'
/**
* The notebook id in the page URL.
@@ -66,6 +70,7 @@ export default function SqlWorkbenchWidget({
const [scope, setScope] = useState<'mine' | 'shared'>('mine')
const [insertRequest, setInsertRequest] = useState<InsertCellRequest | null>(null)
const clearInsertRequest = useCallback(() => setInsertRequest(null), [])
const libraryId = React.useId()
const {
data: capabilities,
@@ -103,14 +108,15 @@ export default function SqlWorkbenchWidget({
if (isLoading) {
return (
<p className="text-sm text-muted-foreground" role="status">
<p className="inline-flex items-center gap-2 text-sm text-muted-foreground" role="status">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Loading SQL Workbench…
</p>
)
}
if (error) {
return (
<p className="text-sm text-destructive" role="alert">
<p className={ALERT_CLASS} role="alert">
Failed to load the SQL Workbench: {getErrorMessage(error)}
</p>
)
@@ -133,113 +139,145 @@ export default function SqlWorkbenchWidget({
const openFromHistory = (entry: SqlHistoryEntry) =>
setInsertRequest({ sql: entry.sqlText, backendName: entry.backendName, nonce: Date.now() })
// One column until the widget is wide enough for a readable notebook beside the library (a
// container query, so the app sidebar and the surrounding layout count): the library, then the
// notebook, then the recent runs.
return (
<div
className="grid gap-4 lg:grid-cols-[16rem_1fr]"
data-qqq-id={`widget-${widgetMetaData.name}`}
>
<div className="space-y-4">
<nav aria-label="Notebooks" className="space-y-2">
<div className="flex gap-1 text-xs" role="group" aria-label="Notebook library">
{(['mine', 'shared'] as const).map((s) => (
<button
key={s}
type="button"
aria-pressed={scope === s}
className={cn(
'rounded px-2 py-1',
scope === s ? 'bg-muted font-medium' : 'text-muted-foreground'
)}
onClick={() => setScope(s)}
data-qqq-id={`tab-sql-notebooks-${s}`}
>
{s === 'mine' ? 'My notebooks' : 'Shared library'}
</button>
))}
</div>
<div className="@container" data-qqq-id={`widget-${widgetMetaData.name}`}>
<div className="grid items-start gap-6 [grid-template-areas:'library'_'notebook'_'history'] @5xl:grid-cols-[17rem_minmax(0,1fr)] @5xl:grid-rows-[auto_1fr] @5xl:[grid-template-areas:'library_notebook'_'history_notebook']">
<nav aria-label="Notebooks" className="min-w-0 space-y-3 [grid-area:library]">
<WorkbenchTabs
label="Notebook library"
idBase={libraryId}
value={scope}
onChange={setScope}
tabs={[
{ value: 'mine', label: 'My notebooks', qqqId: 'tab-sql-notebooks-mine' },
{ value: 'shared', label: 'Shared library', qqqId: 'tab-sql-notebooks-shared' },
]}
/>
<button
type="button"
className="w-full rounded border px-2 py-1 text-left text-sm"
className={cn(buttonClass('secondary', 'md'), 'w-full')}
onClick={() => select(null)}
data-qqq-id="button-sql-new-notebook"
>
+ New notebook
<Plus className="h-4 w-4" aria-hidden="true" />
New notebook
</button>
{notebooksLoading ? (
<p className="text-xs text-muted-foreground">Loading…</p>
) : notebooksError ? (
<p className="text-xs text-destructive" role="alert">
Could not load notebooks: {getErrorMessage(notebooksError)}
</p>
) : notebooks.length === 0 ? (
<p className="text-xs text-muted-foreground">
{scope === 'mine' ? 'No notebooks yet.' : 'Nothing has been shared yet.'}
</p>
<div
role="tabpanel"
id={`${libraryId}-panel`}
aria-labelledby={`${libraryId}-tab-${scope}`}
>
{notebooksLoading ? (
<p className="text-sm text-muted-foreground" role="status">
Loading notebooks…
</p>
) : notebooksError ? (
<p className={ALERT_CLASS} role="alert">
Could not load notebooks: {getErrorMessage(notebooksError)}
</p>
) : notebooks.length === 0 ? (
<p className="text-sm text-muted-foreground">
{scope === 'mine' ? 'No notebooks yet.' : 'Nothing has been shared yet.'}
</p>
) : (
<ul className="space-y-1">
{notebooks.map((n) => (
<li key={n.id}>
<button
type="button"
className={cn(
'block w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
n.id === notebookId ? 'bg-primary/10 font-medium' : 'hover:bg-accent'
)}
onClick={() => select(n.id)}
aria-current={n.id === notebookId ? 'page' : undefined}
title={n.description ?? n.title}
data-qqq-id={`button-sql-notebook-${n.id}`}
>
<span className="block break-words">{n.title}</span>
{n.visibility === 'SHARED' && scope === 'mine' && (
<span className="block text-xs font-normal text-muted-foreground">
Shared
</span>
)}
{scope === 'shared' && (
<span className="block text-xs font-normal text-muted-foreground">
{n.ownerName}
</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
</nav>
<div className="min-w-0 [grid-area:notebook]">
<NotebookEditor
notebookId={notebookId}
capabilities={capabilities}
insertRequest={insertRequest}
onInsertConsumed={clearInsertRequest}
onSelect={select}
/>
</div>
<section
aria-labelledby="sql-history-heading"
className="min-w-0 space-y-2 [grid-area:history]"
>
<h3 id="sql-history-heading" className="text-sm font-semibold text-foreground">
Recent runs
</h3>
{history.length === 0 ? (
<p className="text-sm text-muted-foreground">No runs yet.</p>
) : (
<ul className="space-y-0.5 text-sm">
{notebooks.map((n) => (
<li key={n.id}>
<ul className="space-y-1">
{history.map((h) => (
<li key={h.executionUuid}>
<button
type="button"
className={cn(
'w-full truncate rounded px-2 py-1 text-left hover:bg-muted',
n.id === notebookId && 'bg-muted font-medium'
)}
onClick={() => select(n.id)}
aria-current={n.id === notebookId ? 'page' : undefined}
title={n.description ?? n.title}
data-qqq-id={`button-sql-notebook-${n.id}`}
className="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => openFromHistory(h)}
title="Add as a new cell"
data-qqq-id={`button-sql-history-${h.executionUuid}`}
>
{n.title}
{n.visibility === 'SHARED' && scope === 'mine' && (
<span className="ml-1 text-xs text-muted-foreground">(shared)</span>
)}
{scope === 'shared' && (
<span className="block text-xs text-muted-foreground">{n.ownerName}</span>
{h.status === 'SUCCEEDED' ? (
<CheckCircle2
className="mt-0.5 h-4 w-4 shrink-0 text-emerald-700 dark:text-emerald-400"
aria-label="Succeeded"
/>
) : (
<XCircle
className="mt-0.5 h-4 w-4 shrink-0 text-destructive"
aria-label="Failed"
/>
)}
<span className="min-w-0 flex-1">
<span className="line-clamp-2 break-all font-mono text-xs text-foreground">
{h.sqlText}
</span>
<span className="mt-0.5 flex flex-wrap gap-x-2 text-xs text-muted-foreground">
{h.startTime && (
<DateTimeText
value={h.startTime}
type="DATE_TIME"
relative
className="text-xs text-muted-foreground"
/>
)}
<span>{formatDuration(h.durationMs)}</span>
</span>
</span>
</button>
</li>
))}
</ul>
)}
</nav>
<section aria-label="History" className="space-y-1">
<h3 className="text-xs font-medium text-muted-foreground">Recent runs</h3>
<ul className="space-y-0.5 text-xs">
{history.map((h) => (
<li key={h.executionUuid}>
<button
type="button"
className="w-full rounded px-1 py-0.5 text-left hover:bg-muted"
onClick={() => openFromHistory(h)}
title={`${h.mode} ${h.status} — add as a new cell`}
data-qqq-id={`button-sql-history-${h.executionUuid}`}
>
<span
className={cn(
'mr-1',
h.status === 'SUCCEEDED' ? 'text-emerald-600' : 'text-destructive'
)}
>
{h.status === 'SUCCEEDED' ? '✓' : '✗'}
</span>
<span className="font-mono">{h.sqlText.slice(0, 60)}</span>
<span className="ml-1 text-muted-foreground">{formatDuration(h.durationMs)}</span>
</button>
</li>
))}
</ul>
</section>
</div>
<div className="min-w-0">
<NotebookEditor
notebookId={notebookId}
capabilities={capabilities}
insertRequest={insertRequest}
onInsertConsumed={clearInsertRequest}
onSelect={select}
/>
</div>
</div>
)
}
@@ -0,0 +1,103 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file WorkbenchTabs — the standard underlined tab bar (as on the record view and the script
* viewer) for the workbench's library and result views, with the WAI-ARIA tabs keyboard model:
* one tab stop, arrow keys, Home and End.
*/
'use client'
import React, { useRef } from 'react'
import { cn } from '@/lib/utils/cn'
import { TAB_LIST_CLASS, tabClass } from '@/components/ui/controls'
/** One tab. */
export interface WorkbenchTab<T extends string> {
value: T
label: string
qqqId?: string
}
/** Props for {@link WorkbenchTabs}. */
export interface WorkbenchTabsProps<T extends string> {
/** Accessible name of the tab list. */
label: string
tabs: WorkbenchTab<T>[]
value: T
onChange: (value: T) => void
/** When set, tab ids are `${idBase}-tab-${value}` and they control `${idBase}-panel`. */
idBase?: string
/** Content after the tabs, on the same row (e.g. a status). */
children?: React.ReactNode
className?: string
}
/**
* Renders the tab bar.
*
* @param props - Component props.
* @returns The tab list.
*/
export function WorkbenchTabs<T extends string>(props: WorkbenchTabsProps<T>) {
const { label, tabs, value, onChange, idBase, children, className } = props
const refs = useRef<(HTMLButtonElement | null)[]>([])
const onKeyDown = (event: React.KeyboardEvent, index: number) => {
const last = tabs.length - 1
const targets: Record<string, number> = {
ArrowRight: index === last ? 0 : index + 1,
ArrowLeft: index === 0 ? last : index - 1,
Home: 0,
End: last,
}
const next = targets[event.key]
if (next === undefined) return
event.preventDefault()
onChange(tabs[next].value)
refs.current[next]?.focus()
}
return (
<div className={cn('flex flex-wrap items-end gap-x-3', className)}>
<div role="tablist" aria-label={label} className={cn(TAB_LIST_CLASS, 'flex-[1_0_auto]')}>
{tabs.map((tab, index) => (
<button
key={tab.value}
ref={(element) => {
refs.current[index] = element
}}
type="button"
role="tab"
id={idBase ? `${idBase}-tab-${tab.value}` : undefined}
aria-controls={idBase ? `${idBase}-panel` : undefined}
aria-selected={tab.value === value}
tabIndex={tab.value === value ? 0 : -1}
className={tabClass(tab.value === value)}
onClick={() => onChange(tab.value)}
onKeyDown={(event) => onKeyDown(event, index)}
data-qqq-id={tab.qqqId}
>
{tab.label}
</button>
))}
</div>
{children}
</div>
)
}
@@ -24,8 +24,30 @@
import React, { useState } from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import { AlertTriangle, Loader2 } from 'lucide-react'
import type { SqlExecuteResponse } from '@/types'
import type { SqlExecuteResponse, SqlStatementView } from '@/types'
import { cn } from '@/lib/utils/cn'
import { buttonClass, CHECKBOX_CLASS, INPUT_CLASS, WARNING_CLASS } from '@/components/ui/controls'
/** Plain names of the statement kinds. */
const STATEMENT_KIND_LABELS: Record<SqlStatementView['kind'], string> = {
READ: 'Read',
EXPLAIN_ANALYZE: 'Explain',
WRITE: 'Write',
DDL: 'Schema change',
FORBIDDEN: 'Not allowed',
}
/**
* Counts affected rows in words.
*
* @param count - The number of rows.
* @returns E.g. `1 row affected`, `1,204 rows affected`.
*/
function rowsAffected(count: number): string {
return `${count.toLocaleString()} ${count === 1 ? 'row' : 'rows'} affected`
}
/** Props for {@link WriteConfirmDialog}. */
export interface WriteConfirmDialogProps {
@@ -53,78 +75,101 @@ export function WriteConfirmDialog({
const [acknowledged, setAcknowledged] = useState(false)
const guard = preview.rowGuardExceeded === true
const canCommit = typed === backendName && (!guard || acknowledged) && !pending
const inputId = React.useId()
return (
<Dialog.Root open onOpenChange={(open) => !open && onCancel()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
<Dialog.Content
className="fixed left-1/2 top-1/2 z-50 w-[min(42rem,95vw)] -translate-x-1/2 -translate-y-1/2 space-y-3 rounded-lg border bg-background p-4 shadow-lg"
className={cn(
'fixed left-1/2 top-1/2 z-50 flex max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-2xl -translate-x-1/2 -translate-y-1/2 flex-col',
'rounded-xl border border-border bg-background text-foreground shadow-lg focus:outline-none'
)}
data-qqq-id="dialog-sql-write-confirm"
>
<Dialog.Title className="text-base font-semibold">
Confirm write on “{backendName}”
</Dialog.Title>
<Dialog.Description className="flex gap-2 rounded bg-amber-50 p-2 text-sm text-amber-900 dark:bg-amber-950 dark:text-amber-100">
<span aria-hidden>⚠</span>
Raw SQL bypasses QQQ record security, audits, automations and search indexing. It runs
in one transaction and is audited.
</Dialog.Description>
<table className="w-full text-sm" aria-label="Statements to commit">
<thead>
<tr className="text-left text-xs text-muted-foreground">
<th scope="col">#</th>
<th scope="col">Kind</th>
<th scope="col">Statement</th>
<th scope="col" className="text-right">
Rows
</th>
</tr>
</thead>
<tbody>
{(preview.statements ?? []).map((s) => (
<tr key={s.index} className="align-top">
<td>{s.index + 1}</td>
<td className="font-mono text-xs">{s.kind}</td>
<td className="max-w-md truncate font-mono text-xs" title={s.text}>
{s.text}
</td>
<td className="text-right tabular-nums">{s.affectedRows ?? '—'}</td>
</tr>
))}
</tbody>
</table>
<p className="text-sm">
{preview.dryRunSupported
? `Dry run (rolled back): ${preview.totalAffectedRows ?? 0} rows affected.`
: (preview.dryRunNote ?? 'This write could not be dry-run.')}
</p>
{guard && (
<label className="flex items-center gap-2 text-sm">
<div className="min-h-0 space-y-4 overflow-y-auto p-6">
<Dialog.Title className="text-lg font-semibold">
Confirm write on “{backendName}”
</Dialog.Title>
<Dialog.Description className={cn(WARNING_CLASS, 'flex gap-2')}>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<span>
Raw SQL bypasses QQQ record security, audits, automations and search indexing. It
runs in one transaction and is audited.
</span>
</Dialog.Description>
<div className="rounded-md border border-border">
<table className="w-full border-collapse text-sm" aria-label="Statements to commit">
<thead>
<tr className="border-b border-border bg-muted text-left">
<th scope="col" className="bg-muted px-3 py-2 font-semibold">
#
</th>
<th scope="col" className="bg-muted px-3 py-2 font-semibold">
Type
</th>
<th scope="col" className="bg-muted px-3 py-2 font-semibold">
Statement
</th>
<th scope="col" className="bg-muted px-3 py-2 text-right font-semibold">
Rows
</th>
</tr>
</thead>
<tbody>
{(preview.statements ?? []).map((s) => (
<tr key={s.index} className="border-b border-border align-top last:border-b-0">
<td className="px-3 py-2 tabular-nums">{s.index + 1}</td>
<td className="whitespace-nowrap px-3 py-2">
{STATEMENT_KIND_LABELS[s.kind] ?? s.kind}
</td>
<td className="break-all px-3 py-2 font-mono text-xs">{s.text}</td>
<td className="px-3 py-2 text-right tabular-nums">
{s.affectedRows?.toLocaleString() ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-sm">
{preview.dryRunSupported
? `Dry run (rolled back): ${rowsAffected(preview.totalAffectedRows ?? 0)}.`
: (preview.dryRunNote ?? 'This write could not be dry-run.')}
</p>
{guard && (
<label className="flex cursor-pointer items-start gap-2 text-sm font-medium">
<input
type="checkbox"
className={cn(CHECKBOX_CLASS, 'mt-0.5')}
checked={acknowledged}
onChange={(e) => setAcknowledged(e.target.checked)}
data-qqq-id="checkbox-sql-row-guard"
/>
I understand this affects more than {preview.rowGuardLimit?.toLocaleString()} rows.
</label>
)}
<div className="space-y-1">
<label htmlFor={inputId} className="block text-sm font-medium">
Type <span className="font-mono">{backendName}</span> to confirm
</label>
<input
type="checkbox"
checked={acknowledged}
onChange={(e) => setAcknowledged(e.target.checked)}
data-qqq-id="checkbox-sql-row-guard"
id={inputId}
className={cn(INPUT_CLASS, 'font-mono')}
value={typed}
onChange={(e) => setTyped(e.target.value)}
autoComplete="off"
spellCheck={false}
data-qqq-id="input-sql-confirm-backend"
/>
I understand this affects more than {preview.rowGuardLimit} rows.
</label>
)}
<label className="block text-sm">
Type <span className="font-mono">{backendName}</span> to confirm
<input
className="mt-1 block w-full rounded border px-2 py-1 font-mono"
value={typed}
onChange={(e) => setTyped(e.target.value)}
autoComplete="off"
data-qqq-id="input-sql-confirm-backend"
/>
</label>
<div className="flex justify-end gap-2">
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-3 rounded-b-xl border-t border-border bg-muted px-6 py-4">
<Dialog.Close asChild>
<button
type="button"
className="rounded border px-3 py-1 text-sm"
className={buttonClass('secondary')}
data-qqq-id="button-sql-write-cancel"
>
Cancel
@@ -132,11 +177,12 @@ export function WriteConfirmDialog({
</Dialog.Close>
<button
type="button"
className="rounded bg-destructive px-3 py-1 text-sm text-destructive-foreground disabled:opacity-50"
className={buttonClass('danger')}
disabled={!canCommit}
onClick={() => onConfirm(acknowledged)}
data-qqq-id="button-sql-write-commit"
>
{pending && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{pending ? 'Committing…' : 'Commit write'}
</button>
</div>
@@ -0,0 +1,141 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file The SQL editor's CodeMirror theme. Every color is a CSS custom property of the app theme
* (surfaces, borders, the focus ring) or of the code palette (`--qqq-code-*`, the colors the
* read-only code blocks use, 4.5:1 or more on the card and muted surfaces in both modes). The
* properties change with the `.dark` class, so the editor follows the theme toggle live,
* without being reconfigured.
*/
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { EditorView } from '@codemirror/view'
import { tags as t } from '@lezer/highlight'
/** Selection and bracket-match fill: the primary color, translucent over the editor surface. */
const SELECTION = 'color-mix(in srgb, var(--color-primary) 28%, transparent)'
/** Editor chrome: surface, gutter, cursor, selection, active line, placeholder and the autocomplete popup. */
export const workbenchEditorTheme = EditorView.theme({
'&': {
color: 'var(--color-foreground)',
backgroundColor: 'var(--color-background)',
fontSize: '13px',
border: '1px solid var(--color-input)',
borderRadius: '0.375rem',
},
'&.cm-focused': {
outline: 'none',
borderColor: 'var(--color-primary)',
boxShadow: '0 0 0 1px var(--color-ring)',
},
'.cm-scroller': {
fontFamily: 'var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
lineHeight: '1.6',
},
'.cm-content': {
caretColor: 'var(--color-foreground)',
minHeight: '5rem',
padding: '0.5rem 0',
},
'.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--color-foreground)' },
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
{ backgroundColor: SELECTION },
'.cm-activeLine': { backgroundColor: 'color-mix(in srgb, var(--color-muted) 70%, transparent)' },
'.cm-gutters': {
backgroundColor: 'var(--color-muted)',
color: 'var(--color-muted-foreground)',
border: 'none',
borderRight: '1px solid var(--color-border)',
borderTopLeftRadius: '0.375rem',
borderBottomLeftRadius: '0.375rem',
},
'.cm-activeLineGutter': { backgroundColor: 'transparent', color: 'var(--color-foreground)' },
'.cm-placeholder': { color: 'var(--color-muted-foreground)' },
'.cm-matchingBracket, &.cm-focused .cm-matchingBracket': {
backgroundColor: SELECTION,
outline: '1px solid var(--color-ring)',
},
'.cm-nonmatchingBracket, &.cm-focused .cm-nonmatchingBracket': {
backgroundColor: 'color-mix(in srgb, var(--color-destructive) 25%, transparent)',
},
'.cm-tooltip': {
backgroundColor: 'var(--color-popover)',
color: 'var(--color-popover-foreground)',
border: '1px solid var(--color-border)',
borderRadius: '0.5rem',
boxShadow: 'var(--qqq-shadow-md)',
overflow: 'hidden',
},
'.cm-tooltip.cm-tooltip-autocomplete > ul': {
fontFamily: 'var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
maxHeight: '16rem',
padding: '0.25rem 0',
},
'.cm-tooltip.cm-tooltip-autocomplete > ul > li': {
padding: '0.25rem 0.75rem 0.25rem 0.5rem',
lineHeight: '1.4',
},
'.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]': {
backgroundColor: 'var(--color-primary)',
color: 'var(--color-primary-foreground)',
},
'.cm-completionDetail': {
color: 'inherit',
opacity: '0.8',
fontStyle: 'normal',
marginLeft: '0.75rem',
},
'.cm-completionMatchedText': { textDecoration: 'none', fontWeight: '700' },
'.cm-completionIcon': { opacity: '0.8', width: '1.25em' },
'.cm-tooltip .cm-completionInfo': {
backgroundColor: 'var(--color-popover)',
color: 'var(--color-popover-foreground)',
border: '1px solid var(--color-border)',
borderRadius: '0.5rem',
},
})
/** SQL token colors from the shared code palette. */
const workbenchHighlightStyle = HighlightStyle.define([
{
tag: [t.keyword, t.operatorKeyword, t.modifier, t.controlKeyword, t.definitionKeyword],
color: 'var(--qqq-code-keyword)',
fontWeight: '600',
},
{ tag: [t.string, t.special(t.string), t.regexp], color: 'var(--qqq-code-string)' },
{ tag: [t.number, t.integer, t.float], color: 'var(--qqq-code-number)' },
{ tag: [t.bool, t.null, t.atom], color: 'var(--qqq-code-literal)' },
{
tag: [t.comment, t.lineComment, t.blockComment],
color: 'var(--qqq-code-comment)',
fontStyle: 'italic',
},
{
tag: [t.typeName, t.standard(t.name), t.function(t.variableName)],
color: 'var(--qqq-code-property)',
},
{
tag: [t.special(t.name), t.special(t.variableName), t.labelName],
color: 'var(--qqq-code-variable)',
},
{ tag: [t.propertyName, t.attributeName], color: 'var(--qqq-code-attribute)' },
{ tag: [t.invalid], color: 'var(--color-destructive)' },
])
/** The token colors as an editor extension. */
export const workbenchSyntaxHighlighting = syntaxHighlighting(workbenchHighlightStyle)
+156
View File
@@ -0,0 +1,156 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file The standard control styles (the variants catalogued in `Button.stories.tsx`): buttons,
* inputs, selects, tabs, menus and alerts. Every color is a theme token (or a token-paired
* `dark:` variant), so a control drawn with these classes follows light and dark mode.
*/
import { cn } from '@/lib/utils/cn'
const FOCUS =
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background'
const DISABLED = 'disabled:cursor-not-allowed disabled:opacity-50'
/** Secondary button: Cancel, Back and other secondary actions. */
export const BUTTON_CLASS = cn(
'inline-flex items-center justify-center gap-2 rounded-md border border-input bg-card px-3 py-1.5 text-sm font-medium text-foreground',
'hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
DISABLED
)
/** Primary button: Save, Run and other primary actions. */
export const PRIMARY_BUTTON_CLASS = cn(
'inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
DISABLED
)
/** Single-line text input. */
export const INPUT_CLASS =
'w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring'
/** Error message box. */
export const ALERT_CLASS =
'whitespace-pre-wrap rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive'
/** Warning message box. */
export const WARNING_CLASS =
'rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-100'
/** Visual size of a {@link buttonClass} button. */
export type ButtonSize = 'sm' | 'md'
/** Visual variant of a {@link buttonClass} button. */
export type ButtonVariant = 'primary' | 'secondary' | 'destructive' | 'danger' | 'ghost'
const BUTTON_SIZES: Record<ButtonSize, string> = {
sm: 'h-8 px-2.5 text-xs',
md: 'h-9 px-3 text-sm',
}
const BUTTON_VARIANTS: Record<ButtonVariant, string> = {
// Hover darkens the fill (a translucent fill would lighten it on light surfaces and drop the
// label below 4.5:1). The dark theme's brighter red is darkened behind white text likewise.
primary:
'bg-primary text-primary-foreground shadow-sm hover:bg-[color-mix(in_srgb,var(--color-primary)_85%,black)]',
secondary: 'border border-input bg-card text-foreground shadow-sm hover:bg-accent',
destructive:
'border border-destructive/40 bg-card text-destructive shadow-sm hover:bg-destructive/10',
danger:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-[color-mix(in_srgb,var(--color-destructive)_85%,black)] dark:bg-[color-mix(in_srgb,var(--color-destructive)_75%,black)] dark:hover:bg-[color-mix(in_srgb,var(--color-destructive)_65%,black)]',
ghost: 'text-foreground hover:bg-accent',
}
/**
* Classes of a standard button.
*
* @param variant - Primary (filled), secondary (bordered), destructive (bordered, red text),
* danger (filled red: the confirming button of an irreversible action) or ghost.
* @param size - `sm` for toolbars and inline actions, `md` for forms and dialogs.
* @returns The class string.
*/
export function buttonClass(variant: ButtonVariant = 'secondary', size: ButtonSize = 'md'): string {
return cn(
'inline-flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-md font-medium transition-colors',
BUTTON_SIZES[size],
BUTTON_VARIANTS[variant],
FOCUS,
DISABLED
)
}
/**
* Classes of a square icon-only button (give it an `aria-label`).
*
* @param variant - Ghost (default) or destructive.
* @returns The class string.
*/
export function iconButtonClass(variant: 'ghost' | 'destructive' = 'ghost'): string {
return cn(
'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md transition-colors',
variant === 'destructive'
? 'text-destructive hover:bg-destructive/10'
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
FOCUS,
DISABLED
)
}
/** Native select, matching {@link INPUT_CLASS}. */
export const SELECT_CLASS =
'h-8 min-w-0 max-w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50'
/** Native checkbox or radio. */
export const CHECKBOX_CLASS =
'h-4 w-4 shrink-0 cursor-pointer rounded border-input accent-primary focus:ring-ring'
/** Field label above or beside a control. */
export const LABEL_CLASS = 'text-sm font-medium text-foreground'
/** Tab bar (`role="tablist"`): an underlined row, as on the record view and script viewer. */
export const TAB_LIST_CLASS = 'flex gap-1 overflow-x-auto border-b border-border'
/**
* Classes of one tab in a {@link TAB_LIST_CLASS} bar.
*
* @param selected - Whether the tab is the selected one.
* @returns The class string.
*/
export function tabClass(selected: boolean): string {
return cn(
'-mb-px whitespace-nowrap border-b-2 px-3 py-2 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',
selected
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'
)
}
/** Dropdown menu panel. */
export const MENU_CONTENT_CLASS =
'z-50 min-w-40 max-w-[calc(100vw-16px)] overflow-y-auto rounded-xl border border-border bg-popover py-1 text-popover-foreground shadow-md'
/** Dropdown menu item. */
export const MENU_ITEM_CLASS =
'flex w-full cursor-pointer items-center gap-2 px-4 py-2 text-left text-sm text-popover-foreground outline-none hover:bg-accent focus:bg-accent data-[highlighted]:bg-accent'
/** Small neutral badge (status, mode). */
export const BADGE_CLASS =
'inline-flex items-center gap-1 whitespace-nowrap rounded-md border border-border bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground'
/** Card surface (sections, notebook cells). */
export const CARD_CLASS = 'rounded-lg border border-border bg-card text-card-foreground'
+112 -6
View File
@@ -16,11 +16,44 @@
// E2E: SQL Workbench — the SQL_WORKBENCH widget on the qbit's app page, with a mocked backend
import AxeBuilder from '@axe-core/playwright'
import { test, expect, type Page } from '@playwright/test'
import { METADATA, setupApiMocks } from './api-mocks'
import { checkA11y } from './a11y-helpers'
const BASE = '**/qqq/v1/sql-workbench'
const WIDGET = '[data-qqq-id="widget-sqlWorkbenchWidget"]'
/**
* Asserts WCAG AA color contrast inside the workbench (the suite-wide check leaves this rule off
* until the rest of the app passes it; the workbench passes it in both modes).
*
* @param page - The page.
* @param selector - The part of the page to check (default: the workbench widget).
*/
async function expectWorkbenchContrast(page: Page, selector = WIDGET): Promise<void> {
const results = await new AxeBuilder({ page })
.include(selector)
.withRules(['color-contrast'])
.analyze()
expect(results.violations.flatMap((v) => v.nodes.map((n) => n.target.join(' ')))).toEqual([])
}
/**
* The editor's background and text colors.
*
* @param page - The page.
* @returns The computed colors.
*/
async function editorColors(page: Page): Promise<{ background: string; color: string }> {
return page
.locator('.cm-editor')
.first()
.evaluate((element) => {
const style = getComputedStyle(element)
return { background: style.backgroundColor, color: style.color }
})
}
const CAPABILITIES = {
version: '1.0.0-RC.1',
@@ -103,12 +136,19 @@ const PREVIEW = {
* Adds the workbench app, widget and endpoints to the mocked backend.
*
* @param page - The page.
* @param options - Options.
* @param options.brandAccent - Keep the mocked application's brand accent color (default true).
* @returns The list of execute request bodies, filled as the page sends them.
*/
async function setupWorkbench(page: Page): Promise<Record<string, unknown>[]> {
async function setupWorkbench(
page: Page,
{ brandAccent = true }: { brandAccent?: boolean } = {}
): Promise<Record<string, unknown>[]> {
const executes: Record<string, unknown>[] = []
const metaData = {
...METADATA,
// without the brand accent, the page uses the theme's own primary color
branding: brandAccent ? METADATA.branding : { ...METADATA.branding, accentColor: null },
apps: {
...METADATA.apps,
sqlWorkbench: {
@@ -196,7 +236,7 @@ test.describe('SQL Workbench', () => {
const executes = await setupWorkbench(page)
await page.goto('/app/sqlWorkbench')
await expect(page.getByRole('button', { name: '+ New notebook' })).toBeVisible({
await expect(page.getByRole('button', { name: 'New notebook' })).toBeVisible({
timeout: 30000,
})
const editor = page.getByRole('textbox', { name: 'SQL for cell 1' })
@@ -212,11 +252,12 @@ test.describe('SQL Workbench', () => {
mode: 'READ_ONLY',
sql: 'SELECT id, first_name FROM person',
})
await expect(page.getByRole('link', { name: /XLSX/ })).toHaveAttribute(
await checkA11y(page)
await page.getByRole('button', { name: 'Export' }).click()
await expect(page.getByRole('menuitem', { name: /XLSX/ })).toHaveAttribute(
'href',
/executions\/exec-1\/export\?format=xlsx/
)
await checkA11y(page)
})
test('write mode commits only after the confirmation dialog', async ({ page }) => {
@@ -230,12 +271,77 @@ test.describe('SQL Workbench', () => {
await page.getByRole('button', { name: 'Preview write' }).click()
const dialog = page.getByRole('dialog')
await expect(dialog.getByText('Dry run (rolled back): 1 rows affected.')).toBeVisible()
await expect(dialog.getByText('Dry run (rolled back): 1 row affected.')).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Commit write' })).toBeDisabled()
await dialog.getByLabel(/Type postgres to confirm/).fill('postgres')
await dialog.getByRole('button', { name: 'Commit write' }).click()
await expect(page.getByText(/SUCCEEDED · .* · 1 rows affected/)).toBeVisible()
await expect(page.getByText(/Succeeded · .* · 1 row affected/)).toBeVisible()
expect(executes.map((e) => e.confirmToken ?? null)).toEqual([null, 'tok'])
})
test('follows the light / dark toggle live, with AA contrast in both modes', async ({ page }) => {
// The theme's own palette: a brand accent is the application's choice (the mocked one,
// #6366f1, is under 4.5:1 behind white text everywhere in the app; see a11y-helpers.ts).
await setupApiMocks(page)
await setupWorkbench(page, { brandAccent: false })
await page.goto('/app/sqlWorkbench')
const editor = page.getByRole('textbox', { name: 'SQL for cell 1' })
await editor.click()
await page.keyboard.type('SELECT id, first_name FROM person')
await page.getByRole('button', { name: 'Run' }).click()
await expect(page.getByRole('table', { name: 'Query result' })).toBeVisible()
await expectWorkbenchContrast(page)
const light = await editorColors(page)
// switch to dark mode through the user preferences, without reloading
await page.locator('[data-qqq-id="sidebar-user-button"]').click()
await page.locator('[data-qqq-id="menu-item-preferences"]').click()
await page.locator('[data-qqq-id="pref-appearance-dark"]').check()
await page.keyboard.press('Escape')
await expect(page.locator('html')).toHaveClass(/dark/)
const dark = await editorColors(page)
expect(dark.background).not.toBe(light.background)
expect(dark.color).not.toBe(light.color)
// the editor surface is the app's input surface, not CodeMirror's built-in light theme
const appBackground = await page.evaluate(() => {
const probe = document.createElement('div')
probe.style.backgroundColor = 'var(--color-background)'
document.body.appendChild(probe)
const color = getComputedStyle(probe).backgroundColor
probe.remove()
return color
})
expect(dark.background).toBe(appBackground)
await expectWorkbenchContrast(page)
await page.getByRole('tab', { name: 'Chart' }).click()
await expect(page.getByLabel('Chart type')).toBeVisible()
await expectWorkbenchContrast(page)
await page.getByLabel('Write mode').check()
await page.getByRole('button', { name: 'Preview write' }).click()
await expect(page.getByRole('dialog')).toBeVisible()
await expectWorkbenchContrast(page, '[data-qqq-id="dialog-sql-write-confirm"]')
})
test('fits a phone screen without sideways page scrolling', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 800 })
await setupApiMocks(page)
await setupWorkbench(page)
await page.goto('/app/sqlWorkbench')
await page.getByRole('textbox', { name: 'SQL for cell 1' }).click()
await page.keyboard.type('SELECT id, first_name FROM person')
await page.getByRole('button', { name: 'Run' }).click()
await expect(page.getByRole('table', { name: 'Query result' })).toBeVisible()
await expect(page.getByRole('tab', { name: 'Results' })).toBeVisible()
// nothing in the workbench is wider than the page; only the result grid may scroll inside
const overflow = await page.evaluate(() => {
const root = document.documentElement
return root.scrollWidth - root.clientWidth
})
expect(overflow).toBe(0)
})
})