mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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>
|
||||
|
||||
Reference in new issue
Block a user