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:
16 files changed
+1641
-676
No files matched your search
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user