Files
Joshen Lim 38fa40e851 Joshenlim/fe 4445 explorer export as pdf option 2 (#50786)
## 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 -->
2026-09-24 13:23:38 +08:00

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>
)
}