mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45: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 -->
255 lines
8.2 KiB
TypeScript
255 lines
8.2 KiB
TypeScript
import { Line, Path, Rect, Svg, Text, View } from '@react-pdf/renderer'
|
|
import type { ReactElement } from 'react'
|
|
|
|
import { pdfColors, pdfStyles } from './theme'
|
|
import {
|
|
formatLogTick,
|
|
formatYAxisTick,
|
|
getCumulativeResults,
|
|
} from '@/components/ui/QueryBlock/QueryBlock.utils'
|
|
|
|
const CHART_WIDTH = 480
|
|
const CHART_HEIGHT = 200
|
|
const MAX_X_LABELS = 12
|
|
const SERIES_COLORS = [pdfColors.brand, '#3b82f6', '#f59e0b']
|
|
|
|
interface NotebookPdfChartConfig {
|
|
type: 'bar' | 'line'
|
|
x_column: string
|
|
y_series: readonly string[]
|
|
cumulative: boolean
|
|
scale: 'linear' | 'log'
|
|
show_labels: boolean
|
|
}
|
|
|
|
interface NotebookPdfChartProps {
|
|
chart: NotebookPdfChartConfig | undefined
|
|
rows: readonly Record<string, unknown>[]
|
|
}
|
|
|
|
function buildLogTicks(min: number, max: number): number[] {
|
|
const ticks: number[] = []
|
|
let tick = 10 ** Math.floor(Math.log10(min))
|
|
while (tick <= max) {
|
|
if (tick >= min) ticks.push(tick)
|
|
tick *= 10
|
|
}
|
|
return ticks.length > 0 ? ticks : [min, max]
|
|
}
|
|
|
|
export function NotebookPdfChart({ chart, rows }: NotebookPdfChartProps): ReactElement {
|
|
const {
|
|
type = 'bar',
|
|
x_column,
|
|
y_series = [],
|
|
cumulative = false,
|
|
scale = 'linear',
|
|
show_labels = false,
|
|
} = chart ?? {}
|
|
|
|
if (!x_column || y_series.length === 0) {
|
|
return (
|
|
<View style={{ paddingVertical: 24, alignItems: 'center' }}>
|
|
<Text style={pdfStyles.resultsPlaceholder}>Chart is not configured.</Text>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
const baseRows = rows.map((row) => {
|
|
const next: Record<string, unknown> = { [x_column]: row[x_column] }
|
|
y_series.forEach((column) => {
|
|
next[column] = Number(row[column]) || 0
|
|
})
|
|
return next
|
|
})
|
|
|
|
const plotRows: Record<string, unknown>[] = cumulative
|
|
? getCumulativeResults({ rows: baseRows }, { yKey: [...y_series] })
|
|
: baseRows
|
|
|
|
// Logarithmic scale only makes sense for a single series, mirroring DisplaySettingsButton's
|
|
// own rule (QueryResultChart resets `scale` to linear once a second Y column is added).
|
|
const isLogScale = y_series.length === 1 && scale === 'log'
|
|
|
|
let dataMax = 0
|
|
let dataMin = 0
|
|
let smallestPositive = Infinity
|
|
plotRows.forEach((row) => {
|
|
y_series.forEach((column) => {
|
|
const value = Number(row[column]) || 0
|
|
if (value > dataMax) dataMax = value
|
|
if (value < dataMin) dataMin = value
|
|
if (value > 0 && value < smallestPositive) smallestPositive = value
|
|
})
|
|
})
|
|
|
|
const domainMin = isLogScale
|
|
? Math.max(1, smallestPositive === Infinity ? 1 : smallestPositive)
|
|
: dataMin
|
|
const domainMax = isLogScale
|
|
? Math.max(domainMin * 10, dataMax)
|
|
: Math.max(dataMax, domainMin + 1)
|
|
|
|
const formatTick = isLogScale ? formatLogTick : formatYAxisTick
|
|
const yAxisWidth = show_labels
|
|
? Math.max(formatTick(domainMax).length, formatTick(domainMin).length) * 6 + 14
|
|
: 0
|
|
|
|
const margin = { top: 8, right: 8, bottom: show_labels ? 24 : 8, left: yAxisWidth }
|
|
const plotWidth = CHART_WIDTH - margin.left - margin.right
|
|
const plotHeight = CHART_HEIGHT - margin.top - margin.bottom
|
|
|
|
const mapValueToY = (value: number): number => {
|
|
if (isLogScale) {
|
|
const clamped = Math.max(value, domainMin)
|
|
const logMin = Math.log10(domainMin)
|
|
const logMax = Math.log10(domainMax)
|
|
const ratio = logMax === logMin ? 0 : (Math.log10(clamped) - logMin) / (logMax - logMin)
|
|
return margin.top + plotHeight * (1 - ratio)
|
|
}
|
|
const ratio = domainMax === domainMin ? 0 : (value - domainMin) / (domainMax - domainMin)
|
|
return margin.top + plotHeight * (1 - ratio)
|
|
}
|
|
|
|
const categoryCount = plotRows.length
|
|
const slotWidth = categoryCount > 0 ? plotWidth / categoryCount : plotWidth
|
|
const categoryCenterX = (index: number) => margin.left + index * slotWidth + slotWidth / 2
|
|
|
|
const yTicks = isLogScale
|
|
? buildLogTicks(domainMin, domainMax)
|
|
: Array.from({ length: 5 }, (_, index) => domainMin + ((domainMax - domainMin) * index) / 4)
|
|
|
|
const xLabelStep = Math.max(1, Math.ceil(categoryCount / MAX_X_LABELS))
|
|
const zeroY = mapValueToY(0)
|
|
|
|
return (
|
|
<View>
|
|
{y_series.length > 1 && (
|
|
<View style={{ flexDirection: 'row', gap: 10, paddingBottom: 6 }}>
|
|
{y_series.map((column, index) => (
|
|
<View key={column} style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
|
|
<View
|
|
style={{
|
|
width: 6,
|
|
height: 6,
|
|
backgroundColor: SERIES_COLORS[index % SERIES_COLORS.length],
|
|
}}
|
|
/>
|
|
<Text style={{ fontSize: 7, color: pdfColors.textTertiary }}>{column}</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
)}
|
|
|
|
<View style={{ position: 'relative', width: CHART_WIDTH, height: CHART_HEIGHT }}>
|
|
<Svg
|
|
width={CHART_WIDTH}
|
|
height={CHART_HEIGHT}
|
|
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
|
|
>
|
|
{show_labels &&
|
|
yTicks.map((tick, index) => (
|
|
<Line
|
|
key={index}
|
|
x1={margin.left}
|
|
x2={CHART_WIDTH - margin.right}
|
|
y1={mapValueToY(tick)}
|
|
y2={mapValueToY(tick)}
|
|
stroke={pdfColors.border}
|
|
strokeWidth={0.5}
|
|
/>
|
|
))}
|
|
|
|
<Line
|
|
x1={margin.left}
|
|
x2={CHART_WIDTH - margin.right}
|
|
y1={margin.top + plotHeight}
|
|
y2={margin.top + plotHeight}
|
|
stroke={pdfColors.border}
|
|
strokeWidth={1}
|
|
/>
|
|
|
|
{type === 'bar' &&
|
|
plotRows.map((row, rowIndex) => {
|
|
const groupWidth = slotWidth * 0.7
|
|
const barWidth = groupWidth / y_series.length
|
|
const groupStart = margin.left + rowIndex * slotWidth + (slotWidth - groupWidth) / 2
|
|
|
|
return y_series.map((column, seriesIndex) => {
|
|
const value = Number(row[column]) || 0
|
|
const y = mapValueToY(value)
|
|
const barTop = Math.min(y, zeroY)
|
|
const barHeight = Math.max(1, Math.abs(y - zeroY))
|
|
return (
|
|
<Rect
|
|
key={`${rowIndex}-${column}`}
|
|
x={groupStart + seriesIndex * barWidth}
|
|
y={barTop}
|
|
width={Math.max(1, barWidth - 1)}
|
|
height={barHeight}
|
|
fill={SERIES_COLORS[seriesIndex % SERIES_COLORS.length]}
|
|
/>
|
|
)
|
|
})
|
|
})}
|
|
|
|
{type === 'line' &&
|
|
y_series.map((column, seriesIndex) => {
|
|
const points = plotRows.map((row, rowIndex) => {
|
|
const value = Number(row[column]) || 0
|
|
return `${rowIndex === 0 ? 'M' : 'L'} ${categoryCenterX(rowIndex)},${mapValueToY(value)}`
|
|
})
|
|
return (
|
|
<Path
|
|
key={column}
|
|
d={points.join(' ')}
|
|
stroke={SERIES_COLORS[seriesIndex % SERIES_COLORS.length]}
|
|
strokeWidth={1.5}
|
|
fill="none"
|
|
/>
|
|
)
|
|
})}
|
|
</Svg>
|
|
|
|
{show_labels &&
|
|
yTicks.map((tick, index) => (
|
|
<Text
|
|
key={index}
|
|
style={{
|
|
position: 'absolute',
|
|
left: 0,
|
|
top: mapValueToY(tick) - 4,
|
|
width: margin.left - 4,
|
|
fontSize: 6,
|
|
color: pdfColors.textTertiary,
|
|
textAlign: 'right',
|
|
}}
|
|
>
|
|
{formatTick(tick)}
|
|
</Text>
|
|
))}
|
|
|
|
{show_labels &&
|
|
plotRows.map((row, rowIndex) =>
|
|
rowIndex % xLabelStep === 0 ? (
|
|
<Text
|
|
key={rowIndex}
|
|
style={{
|
|
position: 'absolute',
|
|
left: categoryCenterX(rowIndex) - slotWidth / 2,
|
|
top: CHART_HEIGHT - margin.bottom + 4,
|
|
width: slotWidth,
|
|
fontSize: 6,
|
|
color: pdfColors.textTertiary,
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
{String(row[x_column])}
|
|
</Text>
|
|
) : null
|
|
)}
|
|
</View>
|
|
</View>
|
|
)
|
|
}
|