mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
## Context Another take on [this PR](https://github.com/supabase/supabase/pull/50733), decided that it's better UX + DX to use a PDF library (`@react-pdf/renderer`) instead for handling exporting Notebooks to PDF. The previous method using `print` involved a lot of sparse patching in various places + had a lot of limitations, not to mention its awkward UX to "Print". There's a lot of code changes in this PR but it's because we're constructing the PDF document based on the notebook's contents using primitives from `@react-pdf/renderer` which gives us a lot more control over the PDF's output. PDF output will differ slightly from the Notebook UI in the browser - Added a custom header in the report which covers the notebook name + project name, and when its exported - Empty markdown cells are skipped - Query cells will show both SQL content + results (either table or chart) - Opting to skip syntax highlighting for SQL content (Can consider separately) - Results will only be included in the PDF if the notebook's been run (Aligns with Copy as Markdown CTA) - For table results, _all_ rows will be rendered (Aligns with Copy as Markdown CTA) Feel free to give it a spin! 🙂 🙏 <img width="248" height="207" alt="image" src="https://github.com/user-attachments/assets/e38c14ba-7ee8-40ce-99b1-fa126453bf0f" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added an “Export as PDF” option for notebooks. - PDFs include notebook details, Markdown content, SQL queries, query results, tables, and charts. - Query exports show errors, empty results, row counts, and applicable row limits. - Export progress is displayed while the PDF is generated, and failures are reported via notifications. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
116 lines
3.2 KiB
TypeScript
116 lines
3.2 KiB
TypeScript
import { Text, View } from '@react-pdf/renderer'
|
|
import type { ReactElement } from 'react'
|
|
import type { Snapshot } from 'valtio'
|
|
|
|
import { type QueryResult } from '../types'
|
|
import { NotebookPdfChart } from './NotebookPdfChart'
|
|
import { NotebookPdfTable } from './NotebookPdfTable'
|
|
import { pdfStyles } from './theme'
|
|
import { formatTimeRange } from '@/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils'
|
|
import { type QueryCell } from '@/data/content/notebooks/notebook-schema'
|
|
import { pluralize } from '@/lib/helpers'
|
|
|
|
function SqlBlock({ sql }: { sql: string }) {
|
|
return (
|
|
<View style={pdfStyles.sqlBox}>
|
|
<Text style={pdfStyles.sqlText}>{sql}</Text>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
function ResultsTable({ rows }: { rows: readonly Record<string, unknown>[] }) {
|
|
return (
|
|
<View>
|
|
<NotebookPdfTable rows={rows} />
|
|
</View>
|
|
)
|
|
}
|
|
|
|
function QueryResultsSection({
|
|
result,
|
|
view,
|
|
chart,
|
|
}: {
|
|
result: QueryResult | undefined
|
|
view: 'table' | 'chart'
|
|
chart: Snapshot<QueryCell>['chart']
|
|
}): ReactElement {
|
|
if (!result) {
|
|
return (
|
|
<View style={pdfStyles.resultsBox}>
|
|
<Text style={pdfStyles.resultsPlaceholder}>Query has not been run.</Text>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
if (result.error) {
|
|
return (
|
|
<View style={pdfStyles.resultsBox}>
|
|
<Text style={pdfStyles.resultsError}>Error: {result.error.message}</Text>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
const rows = result.rows ?? []
|
|
if (rows.length === 0) {
|
|
return (
|
|
<View style={pdfStyles.resultsBox}>
|
|
<Text style={pdfStyles.resultsPlaceholder}>Success. No rows returned</Text>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
if (view === 'chart') {
|
|
return (
|
|
<View style={pdfStyles.resultsBox}>
|
|
<NotebookPdfChart chart={chart} rows={rows} />
|
|
</View>
|
|
)
|
|
}
|
|
|
|
return <ResultsTable rows={rows} />
|
|
}
|
|
|
|
interface NotebookPdfQueryCellProps {
|
|
cell: Snapshot<QueryCell>
|
|
result: QueryResult | undefined
|
|
}
|
|
|
|
export function NotebookPdfQueryCell({ cell, result }: NotebookPdfQueryCellProps) {
|
|
const view = cell.view ?? 'table'
|
|
const rowCount = result?.rows?.length ?? 0
|
|
const rowLimit = cell._tag === 'database_cell' ? cell.row_limit : undefined
|
|
|
|
return (
|
|
<View style={pdfStyles.queryCard} wrap>
|
|
<View style={pdfStyles.queryHeaderRow}>
|
|
<Text style={pdfStyles.queryTitle}>{cell.title ?? 'Untitled query'}</Text>
|
|
{cell._tag === 'log_cell' && (
|
|
// react-pdf's default font (Helvetica) can't render the '→' from formatTimeRange
|
|
<Text style={pdfStyles.footerText}>
|
|
{formatTimeRange(cell.time_range).replace('→', '-')}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
|
|
<SqlBlock sql={result?.sql ?? cell.unchecked_sql} />
|
|
|
|
<QueryResultsSection result={result} view={view} chart={cell.chart} />
|
|
|
|
<View style={pdfStyles.footerRow}>
|
|
<Text style={pdfStyles.footerText}>
|
|
{rowCount.toLocaleString()} {pluralize(rowCount, 'row')}
|
|
</Text>
|
|
{rowLimit !== undefined && (
|
|
<>
|
|
<Text style={pdfStyles.footerText}>·</Text>
|
|
<Text style={pdfStyles.footerText}>
|
|
{rowLimit < 0 ? 'No row limit' : `Limit ${rowLimit} rows`}
|
|
</Text>
|
|
</>
|
|
)}
|
|
</View>
|
|
</View>
|
|
)
|
|
}
|