Add DownloadResultsButton to explorer query editor (#50563)

## Context

Adds the `DownloadResultsButton` component to the footer of the
explorer's query editor - will show up in notebook + query tab

<img width="1147" height="907" alt="image"
src="https://github.com/user-attachments/assets/141278a8-1e00-424e-84ec-8ddb7cf0a96b"
/>
<img width="1152" height="913" alt="image"
src="https://github.com/user-attachments/assets/0e4a48b0-ebc3-473e-8bc0-19ab633e1904"
/>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added a results footer displaying row counts and optional row limits.
  - Added download and export actions when query results are available.
  - Standardized the results footer across query and notebook previews.
- Added keyboard shortcut hints to export options when shortcuts are
enabled.

- **Improvements**
- Export actions now support read-only result sets without changing
displayed output.
- Export menu sizing and shortcut labels adapt to the enabled shortcut
configuration.
  - Improved accessibility with a label for refreshing logs.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Joshen Lim authored and GitHub committed 2026-09-18 17:13:44 +08:00
1 parent c92ed0b219
commit cdbe2963fa
7 files changed
+104 -57

No files matched your search

@@ -0,0 +1,42 @@
import { ExplorerQueryFooter } from '../ExplorerQuery'
import { DownloadResultsButton } from '@/components/ui/DownloadResultsButton'
import { pluralize } from '@/lib/helpers'
interface QueryResultFooterProps {
count: number
rowLimit?: number
results?: readonly Record<string, unknown>[]
fileName?: string
}
export const QueryResultFooter = ({
count,
rowLimit,
results,
fileName,
}: QueryResultFooterProps) => {
return (
<ExplorerQueryFooter className="flex items-center justify-between gap-x-2">
<div className="flex items-center gap-x-2">
<p>
{count.toLocaleString()} {pluralize(count, 'row')}
</p>
{rowLimit && (
<>
<p>·</p>
<p>{rowLimit < 0 ? 'No row limit' : `Limit ${rowLimit} rows`}</p>
</>
)}
</div>
{results && results.length > 0 && (
<DownloadResultsButton
enableCopyShortcuts={false}
align="end"
variant="text"
results={results}
fileName={fileName ?? 'Results'}
/>
)}
</ExplorerQueryFooter>
)
}
@@ -18,7 +18,6 @@ import { resolveLogTimeRange } from '../../QuerySources/LogTimeRange.utils'
import {
ExplorerQuery,
ExplorerQueryEditor,
ExplorerQueryFooter,
ExplorerQueryResults,
ExplorerQueryViewport,
} from '../ExplorerQuery'
@@ -31,6 +30,7 @@ import {
} from '../ExplorerToolbar'
import { type QueryDisplay, type QueryResult } from '../types'
import { DisplaySettingsButton } from './DisplaySettingsButton'
import { QueryResultFooter } from './QueryResultFooter'
import { QueryResultRenderer } from './QueryResultRenderer'
import { QueryRunButton } from './QueryRunButton'
import { QuerySourceMenu } from './QuerySourceMenu'
@@ -190,6 +190,7 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
const columns = Object.keys(result?.rows?.[0] ?? {})
const rowLimit = query._tag === 'database' ? query.rowLimit : undefined
const databaseIdentifier = query._tag === 'database' ? query.database_identifier : undefined
const resultsRowCount = (result?.rows ?? []).length
const [promptInput, setPromptInput] = useState('')
const [pendingRun, setPendingRun] = useState<{ sql: string; issues: PotentialIssues }>()
@@ -614,15 +615,12 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
</>
)}
<ExplorerQueryFooter className="flex items-center gap-x-2">
<p>{(result?.rows ?? []).length.toLocaleString()} rows</p>
{rowLimit && (
<>
<p>·</p>
<p>{rowLimit < 0 ? 'No row limit' : `Limit ${rowLimit} rows`}</p>
</>
)}
</ExplorerQueryFooter>
<QueryResultFooter
results={result?.rows}
count={resultsRowCount}
rowLimit={rowLimit}
fileName={title}
/>
</Shell>
{query._tag === 'database' && (
@@ -4,7 +4,7 @@ import Papa from 'papaparse'
type ResultRow = Record<string, unknown>
export function formatResults(
results: ResultRow[]
results: readonly ResultRow[]
): Record<string, string | number | boolean | null | undefined>[] {
return results.map((row) => {
const formatted: Record<string, string | number | boolean> = {}
@@ -17,7 +17,7 @@ export function formatResults(
})
}
export function convertResultsToMarkdown(results: ResultRow[]): string | undefined {
export function convertResultsToMarkdown(results: readonly ResultRow[]): string | undefined {
const formatted = formatResults(results)
if (formatted.length === 0) return undefined
@@ -32,18 +32,18 @@ export function convertResultsToMarkdown(results: ResultRow[]): string | undefin
return markdownTable(table)
}
export function convertResultsToJSON(results: ResultRow[]): string | undefined {
export function convertResultsToJSON(results: readonly ResultRow[]): string | undefined {
if (results.length === 0) return undefined
return JSON.stringify(results, null, 2)
}
export function getResultsHeaders(results: ResultRow[]): string[] | undefined {
export function getResultsHeaders(results: readonly ResultRow[]): string[] | undefined {
const firstRow = Array.from(results)[0]
if (firstRow) return Object.keys(firstRow)
return undefined
}
export function convertResultsToCSV(results: ResultRow[]): string | undefined {
export function convertResultsToCSV(results: readonly ResultRow[]): string | undefined {
if (results.length === 0) return undefined
const headers = getResultsHeaders(results)
@@ -19,7 +19,7 @@ import { calculateBarClickTimeRange } from './LogsBarChart.utils'
import { LogTable } from './LogTable'
import UpgradePrompt from './UpgradePrompt'
import { useLogsPreviewShortcuts } from './useLogsPreviewShortcuts'
import PreviewFilterPanel from '@/components/interfaces/Settings/Logs/PreviewFilterPanel'
import { PreviewFilterPanel } from '@/components/interfaces/Settings/Logs/PreviewFilterPanel'
import LoadingOpacity from '@/components/ui/LoadingOpacity'
import ShimmerLine from '@/components/ui/ShimmerLine'
import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
@@ -62,7 +62,7 @@ interface PreviewFilterPanelProps {
/**
* Logs control panel header + wrapper
*/
const PreviewFilterPanel = ({
export const PreviewFilterPanel = ({
isLoading,
newCount,
onRefresh,
@@ -189,6 +189,7 @@ const PreviewFilterPanel = ({
<ShortcutTooltip shortcutId={SHORTCUT_IDS.LOGS_PREVIEW_REFRESH} side="bottom">
<Button
aria-label="Refresh logs"
title="refresh"
className="px-1.5"
icon={
@@ -305,5 +306,3 @@ const PreviewFilterPanel = ({
</div>
)
}
export default PreviewFilterPanel
@@ -18,10 +18,8 @@ import {
getEntryMetadata,
type NotebookDatabaseContext,
} from './AssistantNotebookPreview.utils'
import {
ExplorerQueryFooter,
ExplorerQueryResults,
} from '@/components/interfaces/Explorer/ExplorerQuery'
import { ExplorerQueryResults } from '@/components/interfaces/Explorer/ExplorerQuery'
import { QueryResultFooter } from '@/components/interfaces/Explorer/QueryEditor/QueryResultFooter'
import { QueryResultRenderer } from '@/components/interfaces/Explorer/QueryEditor/QueryResultRenderer'
import type { QueryResult } from '@/components/interfaces/Explorer/types'
import { DiffEditor } from '@/components/ui/DiffEditor'
@@ -139,17 +137,12 @@ const QueryCellResult = ({
>
<QueryResultRenderer view={cell.view ?? 'table'} result={result} chart={cell.chart} />
</ExplorerQueryResults>
<ExplorerQueryFooter className="flex items-center gap-x-2">
<p>
{rowCount.toLocaleString()} {rowCount === 1 ? 'row' : 'rows'}
</p>
{rowLimit !== undefined && (
<>
<p>·</p>
<p>{rowLimit < 0 ? 'No row limit' : `Limit ${rowLimit} rows`}</p>
</>
)}
</ExplorerQueryFooter>
<QueryResultFooter
results={result.rows}
count={rowCount}
rowLimit={rowLimit}
fileName={cell.title}
/>
</>
)
}
@@ -15,6 +15,7 @@ import {
KeyboardShortcut,
} from 'ui'
import { ButtonTooltip } from './ButtonTooltip'
import {
convertResultsToCSV,
convertResultsToJSON,
@@ -28,7 +29,7 @@ interface DownloadResultsButtonProps {
variant?: 'text' | 'default'
text?: string
align?: 'start' | 'center' | 'end'
results: any[]
results: readonly any[]
fileName: string
enableCopyShortcuts?: boolean
onDownloadAsCSV?: () => void
@@ -127,17 +128,23 @@ export const DownloadResultsButton = ({
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant={variant}
icon={iconOnly ? <Download /> : undefined}
iconRight={iconOnly ? undefined : <ChevronDown />}
disabled={results.length === 0}
className={iconOnly ? 'w-7' : ''}
>
{!iconOnly && text}
</Button>
{iconOnly ? (
<ButtonTooltip
variant={variant}
aria-label="Download results"
aria-describedby={undefined}
icon={<Download />}
disabled={results.length === 0}
className="w-7"
tooltip={{ content: { side: 'bottom', text: 'Download results' } }}
/>
) : (
<Button variant={variant} iconRight={<ChevronDown />} disabled={results.length === 0}>
{text}
</Button>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className="w-60">
<DropdownMenuContent align={align} className={enableCopyShortcuts ? 'w-60' : 'w-44'}>
{isLogs && IS_PLATFORM && (
<DropdownMenuItem asChild className="gap-x-2">
<Link href={`/project/${ref}/settings/log-drains`}>
@@ -149,30 +156,38 @@ export const DownloadResultsButton = ({
<DropdownMenuItem onClick={copyAsMarkdown} className="gap-x-2">
<Copy size={14} />
<p>Copy as Markdown</p>
<span className="ml-auto">
<KeyboardShortcut keys={['Shift', 'Meta', 'm']} />
</span>
{enableCopyShortcuts && (
<span className="ml-auto">
<KeyboardShortcut keys={['Shift', 'Meta', 'm']} />
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={copyAsJSON} className="gap-x-2">
<Copy size={14} />
<p>Copy as JSON</p>
<span className="ml-auto">
<KeyboardShortcut keys={['Shift', 'Meta', 'j']} />
</span>
{enableCopyShortcuts && (
<span className="ml-auto">
<KeyboardShortcut keys={['Shift', 'Meta', 'j']} />
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={copyAsCSV} className="gap-x-2">
<Copy size={14} />
<p>Copy as CSV</p>
<span className="ml-auto">
<KeyboardShortcut keys={['Shift', 'Meta', 'c']} />
</span>
{enableCopyShortcuts && (
<span className="ml-auto">
<KeyboardShortcut keys={['Shift', 'Meta', 'c']} />
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem className="gap-x-2" onClick={() => downloadAsCSV()}>
<Download size={14} />
<p>Download CSV</p>
<span className="ml-auto">
<KeyboardShortcut keys={['Shift', 'Meta', 'd']} />
</span>
{enableCopyShortcuts && (
<span className="ml-auto">
<KeyboardShortcut keys={['Shift', 'Meta', 'd']} />
</span>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>