Files
supabase/apps/studio/components/ui/QueryBlock/QueryBlock.tsx
T
Jordi EnricandClaude Sonnet 4.6 bf16d7f613 fix(reports): fix report block styling issues FE-2844 (#44436)
## Problem

The Report Blocks section (custom dashboards) has four visual and UX
bugs: tooltip content overflows its container, Y-axis labels with 5+
digits get clipped (e.g. `10000` renders as `0000`), action buttons
become unreachable when a block title is long, and scrolling inside a
scrollable block also scrolls the parent page.

## Fix

- Remove the fixed `w-[200px]` class from `ChartTooltipContent` in
`ChartBlock` so tooltips auto-size to their content instead of
overflowing.
- Compute a dynamic Y-axis width in `ChartBlock` based on the string
length of the maximum data value, replacing the `undefined` default that
caused clipping.
- Add `min-w-0` to the label container and `shrink-0` to the actions
container in `ReportBlockContainer` so the truncation works correctly
and action buttons are never pushed off-screen.
- Add `overscroll-contain` to the scrollable SQL code and results table
divs in `QueryBlock` to stop scroll events from propagating to the page.

## How to test

- Navigate to a custom Report with multiple blocks
- Hover over a chart bar on a block with a long metric name. The tooltip
should be fully visible with no text overflow.
- Find or create a block whose Y-axis values exceed 9999 (e.g. disk
IOPS). The full number should appear on the Y-axis without any leading
digits being clipped.
- Use a block on a read replica so the label appends "of replica",
making it long. The chart-type toggle, log scale toggle, and remove
buttons should all remain visible and clickable.
- Add a SQL snippet block that returns a large table of results. Scroll
within the results table. The page should not scroll while the inner
table is scrolling.

## Before
<img width="1166" height="680" alt="CleanShot 2026-04-07 at 15 36 45@2x"
src="https://github.com/user-attachments/assets/8e7bd3c9-8319-47c9-b2d9-b194d2803809"
/>


## After
<img width="1166" height="680" alt="CleanShot 2026-04-07 at 15 36 15@2x"
src="https://github.com/user-attachments/assets/6ca5873a-cd09-4001-9cd0-932c12b6536e"
/>


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

* **Style**
* More consistent Y-axis sizing with dynamic widths for better label
fit.
* Improved Y-axis number formatting (K/M suffixes, sensible decimals)
for clearer tick labels.
* Simplified, more flexible chart tooltips (min-width applied; removed
fixed widths).
* Tighter report header layout so labels truncate predictably and
actions keep their size.
* Added overscroll containment to query results and SQL view to reduce
unwanted scrolling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:01:38 +00:00

383 lines
13 KiB
TypeScript

import dayjs from 'dayjs'
import { Code, Play } from 'lucide-react'
import { DragEvent, ReactNode, useEffect, useMemo, useRef, useState } from 'react'
import { Bar, BarChart, CartesianGrid, Cell, Tooltip, XAxis, YAxis } from 'recharts'
import { Badge, Button, ChartContainer, ChartTooltipContent, cn } from 'ui'
import { CodeBlock } from 'ui-patterns/CodeBlock'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { ButtonTooltip } from '../ButtonTooltip'
import { CHART_COLORS } from '../Charts/Charts.constants'
import { SqlWarningAdmonition } from '../SqlWarningAdmonition'
import { BlockViewConfiguration } from './BlockViewConfiguration'
import { EditQueryButton } from './EditQueryButton'
import {
checkHasNonPositiveValues,
computeYAxisWidth,
formatLogTick,
formatYAxisTick,
getCumulativeResults,
} from './QueryBlock.utils'
import { ReportBlockContainer } from '@/components/interfaces/Reports/ReportBlock/ReportBlockContainer'
import { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig'
import Results from '@/components/interfaces/SQLEditor/UtilityPanel/Results'
export const DEFAULT_CHART_CONFIG: ChartConfig = {
type: 'bar',
cumulative: false,
xKey: '',
yKey: '',
showLabels: false,
showGrid: false,
logScale: false,
view: 'table',
}
export interface QueryBlockProps {
id?: string
label: string
sql?: string
isWriteQuery?: boolean
chartConfig?: ChartConfig
actions?: ReactNode
results?: any[]
errorText?: string
isExecuting?: boolean
initialHideSql?: boolean
draggable?: boolean
disabled?: boolean
blockWriteQueries?: boolean
onExecute?: (queryType: 'select' | 'mutation') => void
onRemoveChart?: () => void
onUpdateChartConfig?: ({ chartConfig }: { chartConfig: Partial<ChartConfig> }) => void
onDragStart?: (e: DragEvent<Element>) => void
}
// [Joshen ReportsV2] JFYI we may adjust this in subsequent PRs when we implement this into Reports V2
// First iteration here is just to make this work with the AI Assistant first
export const QueryBlock = ({
id,
label,
sql,
chartConfig = DEFAULT_CHART_CONFIG,
actions,
results,
errorText,
isWriteQuery = false,
isExecuting = false,
initialHideSql = false,
draggable = false,
disabled = false,
blockWriteQueries = false,
onExecute,
onRemoveChart,
onUpdateChartConfig,
onDragStart,
}: QueryBlockProps) => {
const [chartSettings, setChartSettings] = useState<ChartConfig>(chartConfig)
const { xKey, yKey, view = 'table', logScale = false } = chartSettings
const [showSql, setShowSql] = useState(!results && !initialHideSql)
const [focusDataIndex, setFocusDataIndex] = useState<number>()
const [showWarning, setShowWarning] = useState<'hasWriteOperation' | 'hasUnknownFunctions'>()
const prevIsWriteQuery = useRef(isWriteQuery)
useEffect(() => {
if (!prevIsWriteQuery.current && isWriteQuery) {
setShowWarning('hasWriteOperation')
}
if (!isWriteQuery && showWarning === 'hasWriteOperation') {
setShowWarning(undefined)
}
prevIsWriteQuery.current = isWriteQuery
}, [isWriteQuery, showWarning])
useEffect(() => {
setChartSettings(chartConfig)
}, [chartConfig])
const formattedQueryResult = useMemo(() => {
return results?.map((row) => {
return Object.fromEntries(
Object.entries(row).map(([key, value]) => {
if (key === yKey) return [key, Number(value)]
return [key, value]
})
)
})
}, [results, yKey])
const chartData = chartSettings.cumulative
? getCumulativeResults({ rows: formattedQueryResult ?? [] }, chartSettings)
: formattedQueryResult
const hasNonPositiveValues = useMemo(() => {
if (!logScale || !yKey || !chartData?.length) return false
return checkHasNonPositiveValues(chartData, yKey)
}, [logScale, yKey, chartData])
const effectiveLogScale = logScale && !hasNonPositiveValues
const yAxisWidth = computeYAxisWidth(chartData ?? [], yKey ?? '', {
isLogScale: effectiveLogScale,
})
const getDateFormat = (key: any) => {
const value = chartData?.[0]?.[key] || ''
if (typeof value === 'number') return 'number'
if (dayjs(value).isValid()) return 'date'
return 'string'
}
const xKeyDateFormat = getDateFormat(xKey)
const hasResults = Array.isArray(results) && results.length > 0
const runSelect = () => {
if (!sql || disabled || isExecuting) return
if (isWriteQuery) {
setShowWarning('hasWriteOperation')
return
}
onExecute?.('select')
}
const runMutation = () => {
if (!sql || disabled || isExecuting) return
setShowWarning(undefined)
onExecute?.('mutation')
}
return (
<ReportBlockContainer
draggable={draggable}
showDragHandle={draggable}
onDragStart={(e: DragEvent<Element>) => onDragStart?.(e)}
loading={isExecuting}
label={label}
badge={isWriteQuery && <Badge variant="warning">Write</Badge>}
actions={
<>
{!disabled && (
<>
<ButtonTooltip
type="text"
size="tiny"
className="w-7 h-7"
icon={<Code size={14} strokeWidth={1.5} />}
onClick={() => setShowSql(!showSql)}
tooltip={{
content: { side: 'bottom', text: showSql ? 'Hide query' : 'Show query' },
}}
/>
{hasResults && (
<BlockViewConfiguration
view={view}
isChart={view === 'chart'}
lockColumns={false}
chartConfig={chartSettings}
columns={Object.keys(results?.[0] ?? {})}
changeView={(nextView) => {
if (onUpdateChartConfig)
onUpdateChartConfig({ chartConfig: { view: nextView } })
setChartSettings({ ...chartSettings, view: nextView })
}}
updateChartConfig={(config) => {
if (onUpdateChartConfig) onUpdateChartConfig({ chartConfig: config })
setChartSettings(config)
}}
/>
)}
<EditQueryButton id={id} title={label} sql={sql} />
<ButtonTooltip
type="text"
size="tiny"
className="w-7 h-7"
icon={<Play size={14} strokeWidth={1.5} />}
loading={isExecuting}
disabled={isExecuting || disabled || !sql}
onClick={runSelect}
tooltip={{
content: {
side: 'bottom',
className: 'max-w-56 text-center',
text: isExecuting
? 'Query is running. Check the SQL Editor to manage running queries.'
: 'Run query',
},
}}
/>
</>
)}
{actions}
</>
}
>
{!!showWarning && !blockWriteQueries && (
<SqlWarningAdmonition
warningType={showWarning}
className="border-b"
onCancel={() => setShowWarning(undefined)}
onConfirm={runMutation}
disabled={!sql}
{...(showWarning !== 'hasWriteOperation'
? {
message: 'Run this query now and send the results to the Assistant? ',
subMessage:
'We will execute the query and provide the result rows back to the Assistant to continue the conversation.',
cancelLabel: 'Skip',
confirmLabel: 'Run & send',
}
: {})}
/>
)}
{showSql && (
<div
className={cn(
'shrink-0 grow-1 w-full h-full overflow-y-auto overscroll-contain max-h-[min(300px, 100%)]',
{
'border-b': results !== undefined,
}
)}
>
<CodeBlock
hideLineNumbers
wrapLines={false}
value={sql}
language="sql"
className={cn(
'max-w-none block !bg-transparent !py-3 !px-3.5 prose dark:prose-dark border-0 text-foreground !rounded-none w-full',
'[&>code]:m-0 [&>code>span]:text-foreground'
)}
/>
</div>
)}
{isExecuting && !results && (
<div className="p-3 w-full border-t">
<ShimmeringLoader />
</div>
)}
{view === 'chart' && results !== undefined ? (
<>
{(results ?? []).length === 0 ? (
<div className="flex w-full h-full items-center justify-center py-3">
<p className="text-foreground-light text-xs">No results returned from query</p>
</div>
) : !xKey || !yKey ? (
<div className="flex w-full h-full items-center justify-center">
<p className="text-foreground-light text-xs">Select columns for the X and Y axes</p>
</div>
) : (
<div className="flex-1 w-full">
{hasNonPositiveValues && (
<p className="px-3 pt-1 text-xs text-foreground-light">
Log scale is unavailable because the data contains zero or negative values.
</p>
)}
<ChartContainer
className="aspect-auto px-3 py-2"
style={{ height: '230px', minHeight: '230px' }}
>
<BarChart
accessibilityLayer
margin={{ left: 0, right: 0, top: 10 }}
data={chartData}
onMouseMove={(e: any) => {
if (e.activeTooltipIndex !== focusDataIndex) {
setFocusDataIndex(e.activeTooltipIndex)
}
}}
onMouseLeave={() => setFocusDataIndex(undefined)}
>
<CartesianGrid vertical={false} stroke={CHART_COLORS.AXIS} />
<XAxis
dataKey={xKey}
tickLine={{ stroke: CHART_COLORS.AXIS }}
axisLine={{ stroke: CHART_COLORS.AXIS }}
interval="preserveStartEnd"
tickMargin={4}
minTickGap={32}
tickFormatter={(value) =>
xKeyDateFormat === 'date' ? dayjs(value).format('MMM D YYYY HH:mm') : value
}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={4}
scale={effectiveLogScale ? 'log' : 'auto'}
domain={effectiveLogScale ? [1, 'auto'] : undefined}
allowDataOverflow={effectiveLogScale}
width={yAxisWidth}
tickFormatter={effectiveLogScale ? formatLogTick : formatYAxisTick}
/>
<Tooltip
content={
<ChartTooltipContent
className="min-w-[200px]"
labelFormatter={(value) =>
xKeyDateFormat === 'date'
? dayjs(value).format('MMM D YYYY HH:mm')
: String(value)
}
/>
}
/>
<Bar radius={1} dataKey={yKey} fill="hsl(var(--chart-1))">
{chartData?.map((_: any, index: number) => (
<Cell
key={`cell-${index}`}
className="transition-all duration-100"
fill="hsl(var(--chart-1))"
opacity={focusDataIndex === undefined || focusDataIndex === index ? 1 : 0.4}
enableBackground={12}
/>
))}
</Bar>
</BarChart>
</ChartContainer>
</div>
)}
</>
) : (
<>
{isWriteQuery && blockWriteQueries ? (
<div className="flex flex-col h-full justify-center items-center text-center">
<p className="text-xs text-foreground-light">
SQL query is not read-only and cannot be rendered
</p>
<p className="text-xs text-foreground-lighter text-center">
Queries that involve any mutation will not be run in reports
</p>
{!!onRemoveChart && (
<Button type="default" className="mt-2" onClick={() => onRemoveChart()}>
Remove chart
</Button>
)}
</div>
) : !isExecuting && !!errorText ? (
<div className={cn('flex-1 w-full overflow-auto relative border-t px-3.5 py-2')}>
<span className="font-mono text-xs">ERROR: {errorText}</span>
</div>
) : (
results && (
<div
className={cn(
'flex flex-col flex-1 w-full overflow-auto overscroll-contain relative max-h-64'
)}
>
<Results rows={results} />
</div>
)
)}
</>
)}
</ReportBlockContainer>
)
}