QueryBlock.tsx382 lines · main
| 1 | import dayjs from 'dayjs' |
| 2 | import { Code, Play } from 'lucide-react' |
| 3 | import { DragEvent, ReactNode, useEffect, useMemo, useRef, useState } from 'react' |
| 4 | import { Bar, BarChart, CartesianGrid, Cell, Tooltip, XAxis, YAxis } from 'recharts' |
| 5 | import { Badge, Button, ChartContainer, ChartTooltipContent, cn } from 'ui' |
| 6 | import { CodeBlock } from 'ui-patterns/CodeBlock' |
| 7 | import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' |
| 8 | |
| 9 | import { ButtonTooltip } from '../ButtonTooltip' |
| 10 | import { CHART_COLORS } from '../Charts/Charts.constants' |
| 11 | import { SqlWarningAdmonition } from '../SqlWarningAdmonition' |
| 12 | import { BlockViewConfiguration } from './BlockViewConfiguration' |
| 13 | import { EditQueryButton } from './EditQueryButton' |
| 14 | import { |
| 15 | checkHasNonPositiveValues, |
| 16 | computeYAxisWidth, |
| 17 | formatLogTick, |
| 18 | formatYAxisTick, |
| 19 | getCumulativeResults, |
| 20 | } from './QueryBlock.utils' |
| 21 | import { ReportBlockContainer } from '@/components/interfaces/Reports/ReportBlock/ReportBlockContainer' |
| 22 | import { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig' |
| 23 | import Results from '@/components/interfaces/SQLEditor/UtilityPanel/Results' |
| 24 | |
| 25 | export const DEFAULT_CHART_CONFIG: ChartConfig = { |
| 26 | type: 'bar', |
| 27 | cumulative: false, |
| 28 | xKey: '', |
| 29 | yKey: '', |
| 30 | showLabels: false, |
| 31 | showGrid: false, |
| 32 | logScale: false, |
| 33 | view: 'table', |
| 34 | } |
| 35 | |
| 36 | export interface QueryBlockProps { |
| 37 | id?: string |
| 38 | label: string |
| 39 | sql?: string |
| 40 | isWriteQuery?: boolean |
| 41 | chartConfig?: ChartConfig |
| 42 | actions?: ReactNode |
| 43 | results?: any[] |
| 44 | errorText?: string |
| 45 | isExecuting?: boolean |
| 46 | initialHideSql?: boolean |
| 47 | draggable?: boolean |
| 48 | disabled?: boolean |
| 49 | blockWriteQueries?: boolean |
| 50 | onExecute?: (queryType: 'select' | 'mutation') => void |
| 51 | onRemoveChart?: () => void |
| 52 | onUpdateChartConfig?: ({ chartConfig }: { chartConfig: Partial<ChartConfig> }) => void |
| 53 | onDragStart?: (e: DragEvent<Element>) => void |
| 54 | } |
| 55 | |
| 56 | // [Joshen ReportsV2] JFYI we may adjust this in subsequent PRs when we implement this into Reports V2 |
| 57 | // First iteration here is just to make this work with the AI Assistant first |
| 58 | export const QueryBlock = ({ |
| 59 | id, |
| 60 | label, |
| 61 | sql, |
| 62 | chartConfig = DEFAULT_CHART_CONFIG, |
| 63 | actions, |
| 64 | results, |
| 65 | errorText, |
| 66 | isWriteQuery = false, |
| 67 | isExecuting = false, |
| 68 | initialHideSql = false, |
| 69 | draggable = false, |
| 70 | disabled = false, |
| 71 | blockWriteQueries = false, |
| 72 | onExecute, |
| 73 | onRemoveChart, |
| 74 | onUpdateChartConfig, |
| 75 | onDragStart, |
| 76 | }: QueryBlockProps) => { |
| 77 | const [chartSettings, setChartSettings] = useState<ChartConfig>(chartConfig) |
| 78 | const { xKey, yKey, view = 'table', logScale = false } = chartSettings |
| 79 | |
| 80 | const [showSql, setShowSql] = useState(!results && !initialHideSql) |
| 81 | const [focusDataIndex, setFocusDataIndex] = useState<number>() |
| 82 | const [showWarning, setShowWarning] = useState<'hasWriteOperation' | 'hasUnknownFunctions'>() |
| 83 | |
| 84 | const prevIsWriteQuery = useRef(isWriteQuery) |
| 85 | |
| 86 | useEffect(() => { |
| 87 | if (!prevIsWriteQuery.current && isWriteQuery) { |
| 88 | setShowWarning('hasWriteOperation') |
| 89 | } |
| 90 | if (!isWriteQuery && showWarning === 'hasWriteOperation') { |
| 91 | setShowWarning(undefined) |
| 92 | } |
| 93 | prevIsWriteQuery.current = isWriteQuery |
| 94 | }, [isWriteQuery, showWarning]) |
| 95 | |
| 96 | useEffect(() => { |
| 97 | setChartSettings(chartConfig) |
| 98 | }, [chartConfig]) |
| 99 | |
| 100 | const formattedQueryResult = useMemo(() => { |
| 101 | return results?.map((row) => { |
| 102 | return Object.fromEntries( |
| 103 | Object.entries(row).map(([key, value]) => { |
| 104 | if (key === yKey) return [key, Number(value)] |
| 105 | return [key, value] |
| 106 | }) |
| 107 | ) |
| 108 | }) |
| 109 | }, [results, yKey]) |
| 110 | |
| 111 | const chartData = chartSettings.cumulative |
| 112 | ? getCumulativeResults({ rows: formattedQueryResult ?? [] }, chartSettings) |
| 113 | : formattedQueryResult |
| 114 | |
| 115 | const hasNonPositiveValues = useMemo(() => { |
| 116 | if (!logScale || !yKey || !chartData?.length) return false |
| 117 | return checkHasNonPositiveValues(chartData, yKey) |
| 118 | }, [logScale, yKey, chartData]) |
| 119 | |
| 120 | const effectiveLogScale = logScale && !hasNonPositiveValues |
| 121 | |
| 122 | const yAxisWidth = computeYAxisWidth(chartData ?? [], yKey ?? '', { |
| 123 | isLogScale: effectiveLogScale, |
| 124 | }) |
| 125 | |
| 126 | const getDateFormat = (key: any) => { |
| 127 | const value = chartData?.[0]?.[key] || '' |
| 128 | if (typeof value === 'number') return 'number' |
| 129 | if (dayjs(value).isValid()) return 'date' |
| 130 | return 'string' |
| 131 | } |
| 132 | const xKeyDateFormat = getDateFormat(xKey) |
| 133 | |
| 134 | const hasResults = Array.isArray(results) && results.length > 0 |
| 135 | |
| 136 | const runSelect = () => { |
| 137 | if (!sql || disabled || isExecuting) return |
| 138 | if (isWriteQuery) { |
| 139 | setShowWarning('hasWriteOperation') |
| 140 | return |
| 141 | } |
| 142 | onExecute?.('select') |
| 143 | } |
| 144 | |
| 145 | const runMutation = () => { |
| 146 | if (!sql || disabled || isExecuting) return |
| 147 | setShowWarning(undefined) |
| 148 | onExecute?.('mutation') |
| 149 | } |
| 150 | |
| 151 | return ( |
| 152 | <ReportBlockContainer |
| 153 | draggable={draggable} |
| 154 | showDragHandle={draggable} |
| 155 | onDragStart={(e: DragEvent<Element>) => onDragStart?.(e)} |
| 156 | loading={isExecuting} |
| 157 | label={label} |
| 158 | badge={isWriteQuery && <Badge variant="warning">Write</Badge>} |
| 159 | actions={ |
| 160 | <> |
| 161 | {!disabled && ( |
| 162 | <> |
| 163 | <ButtonTooltip |
| 164 | type="text" |
| 165 | size="tiny" |
| 166 | className="w-7 h-7" |
| 167 | icon={<Code size={14} strokeWidth={1.5} />} |
| 168 | onClick={() => setShowSql(!showSql)} |
| 169 | tooltip={{ |
| 170 | content: { side: 'bottom', text: showSql ? 'Hide query' : 'Show query' }, |
| 171 | }} |
| 172 | /> |
| 173 | {hasResults && ( |
| 174 | <BlockViewConfiguration |
| 175 | view={view} |
| 176 | isChart={view === 'chart'} |
| 177 | lockColumns={false} |
| 178 | chartConfig={chartSettings} |
| 179 | columns={Object.keys(results?.[0] ?? {})} |
| 180 | changeView={(nextView) => { |
| 181 | if (onUpdateChartConfig) |
| 182 | onUpdateChartConfig({ chartConfig: { view: nextView } }) |
| 183 | setChartSettings({ ...chartSettings, view: nextView }) |
| 184 | }} |
| 185 | updateChartConfig={(config) => { |
| 186 | if (onUpdateChartConfig) onUpdateChartConfig({ chartConfig: config }) |
| 187 | setChartSettings(config) |
| 188 | }} |
| 189 | /> |
| 190 | )} |
| 191 | |
| 192 | <EditQueryButton id={id} title={label} sql={sql} /> |
| 193 | <ButtonTooltip |
| 194 | type="text" |
| 195 | size="tiny" |
| 196 | className="w-7 h-7" |
| 197 | icon={<Play size={14} strokeWidth={1.5} />} |
| 198 | loading={isExecuting} |
| 199 | disabled={isExecuting || disabled || !sql} |
| 200 | onClick={runSelect} |
| 201 | tooltip={{ |
| 202 | content: { |
| 203 | side: 'bottom', |
| 204 | className: 'max-w-56 text-center', |
| 205 | text: isExecuting |
| 206 | ? 'Query is running. Check the SQL Editor to manage running queries.' |
| 207 | : 'Run query', |
| 208 | }, |
| 209 | }} |
| 210 | /> |
| 211 | </> |
| 212 | )} |
| 213 | |
| 214 | {actions} |
| 215 | </> |
| 216 | } |
| 217 | > |
| 218 | {!!showWarning && !blockWriteQueries && ( |
| 219 | <SqlWarningAdmonition |
| 220 | warningType={showWarning} |
| 221 | className="border-b" |
| 222 | onCancel={() => setShowWarning(undefined)} |
| 223 | onConfirm={runMutation} |
| 224 | disabled={!sql} |
| 225 | {...(showWarning !== 'hasWriteOperation' |
| 226 | ? { |
| 227 | message: 'Run this query now and send the results to the Assistant? ', |
| 228 | subMessage: |
| 229 | 'We will execute the query and provide the result rows back to the Assistant to continue the conversation.', |
| 230 | cancelLabel: 'Skip', |
| 231 | confirmLabel: 'Run & send', |
| 232 | } |
| 233 | : {})} |
| 234 | /> |
| 235 | )} |
| 236 | |
| 237 | {showSql && ( |
| 238 | <div |
| 239 | className={cn( |
| 240 | 'shrink-0 grow w-full h-full overflow-y-auto overscroll-contain max-h-[min(300px, 100%)]', |
| 241 | { |
| 242 | 'border-b': results !== undefined, |
| 243 | } |
| 244 | )} |
| 245 | > |
| 246 | <CodeBlock |
| 247 | hideLineNumbers |
| 248 | wrapLines={false} |
| 249 | value={sql} |
| 250 | language="sql" |
| 251 | className={cn( |
| 252 | 'max-w-none block bg-transparent! py-3! px-3.5! prose dark:prose-dark border-0 text-foreground rounded-none! w-full', |
| 253 | '[&>code]:m-0 [&>code>span]:text-foreground' |
| 254 | )} |
| 255 | /> |
| 256 | </div> |
| 257 | )} |
| 258 | |
| 259 | {isExecuting && !results && ( |
| 260 | <div className="p-3 w-full border-t"> |
| 261 | <ShimmeringLoader /> |
| 262 | </div> |
| 263 | )} |
| 264 | |
| 265 | {view === 'chart' && results !== undefined ? ( |
| 266 | <> |
| 267 | {(results ?? []).length === 0 ? ( |
| 268 | <div className="flex w-full h-full items-center justify-center py-3"> |
| 269 | <p className="text-foreground-light text-xs">No results returned from query</p> |
| 270 | </div> |
| 271 | ) : !xKey || !yKey ? ( |
| 272 | <div className="flex w-full h-full items-center justify-center"> |
| 273 | <p className="text-foreground-light text-xs">Select columns for the X and Y axes</p> |
| 274 | </div> |
| 275 | ) : ( |
| 276 | <div className="flex-1 w-full"> |
| 277 | {hasNonPositiveValues && ( |
| 278 | <p className="px-3 pt-1 text-xs text-foreground-light"> |
| 279 | Log scale is unavailable because the data contains zero or negative values. |
| 280 | </p> |
| 281 | )} |
| 282 | <ChartContainer |
| 283 | className="aspect-auto px-3 py-2" |
| 284 | style={{ height: '230px', minHeight: '230px' }} |
| 285 | > |
| 286 | <BarChart |
| 287 | accessibilityLayer |
| 288 | margin={{ left: 0, right: 0, top: 10 }} |
| 289 | data={chartData} |
| 290 | onMouseMove={(e: any) => { |
| 291 | if (e.activeTooltipIndex !== focusDataIndex) { |
| 292 | setFocusDataIndex(e.activeTooltipIndex) |
| 293 | } |
| 294 | }} |
| 295 | onMouseLeave={() => setFocusDataIndex(undefined)} |
| 296 | > |
| 297 | <CartesianGrid vertical={false} stroke={CHART_COLORS.AXIS} /> |
| 298 | <XAxis |
| 299 | dataKey={xKey} |
| 300 | tickLine={{ stroke: CHART_COLORS.AXIS }} |
| 301 | axisLine={{ stroke: CHART_COLORS.AXIS }} |
| 302 | interval="preserveStartEnd" |
| 303 | tickMargin={4} |
| 304 | minTickGap={32} |
| 305 | tickFormatter={(value) => |
| 306 | xKeyDateFormat === 'date' ? dayjs(value).format('MMM D YYYY HH:mm') : value |
| 307 | } |
| 308 | /> |
| 309 | <YAxis |
| 310 | tickLine={false} |
| 311 | axisLine={false} |
| 312 | tickMargin={4} |
| 313 | scale={effectiveLogScale ? 'log' : 'auto'} |
| 314 | domain={effectiveLogScale ? [1, 'auto'] : undefined} |
| 315 | allowDataOverflow={effectiveLogScale} |
| 316 | width={yAxisWidth} |
| 317 | tickFormatter={effectiveLogScale ? formatLogTick : formatYAxisTick} |
| 318 | /> |
| 319 | <Tooltip |
| 320 | content={ |
| 321 | <ChartTooltipContent |
| 322 | className="min-w-[200px]" |
| 323 | labelFormatter={(value) => |
| 324 | xKeyDateFormat === 'date' |
| 325 | ? dayjs(value).format('MMM D YYYY HH:mm') |
| 326 | : String(value) |
| 327 | } |
| 328 | /> |
| 329 | } |
| 330 | /> |
| 331 | <Bar radius={1} dataKey={yKey} fill="hsl(var(--chart-1))"> |
| 332 | {chartData?.map((_: any, index: number) => ( |
| 333 | <Cell |
| 334 | key={`cell-${index}`} |
| 335 | className="transition-all duration-100" |
| 336 | fill="hsl(var(--chart-1))" |
| 337 | opacity={focusDataIndex === undefined || focusDataIndex === index ? 1 : 0.4} |
| 338 | enableBackground={12} |
| 339 | /> |
| 340 | ))} |
| 341 | </Bar> |
| 342 | </BarChart> |
| 343 | </ChartContainer> |
| 344 | </div> |
| 345 | )} |
| 346 | </> |
| 347 | ) : ( |
| 348 | <> |
| 349 | {isWriteQuery && blockWriteQueries ? ( |
| 350 | <div className="flex flex-col h-full justify-center items-center text-center"> |
| 351 | <p className="text-xs text-foreground-light"> |
| 352 | SQL query is not read-only and cannot be rendered |
| 353 | </p> |
| 354 | <p className="text-xs text-foreground-lighter text-center"> |
| 355 | Queries that involve any mutation will not be run in reports |
| 356 | </p> |
| 357 | {!!onRemoveChart && ( |
| 358 | <Button type="default" className="mt-2" onClick={() => onRemoveChart()}> |
| 359 | Remove chart |
| 360 | </Button> |
| 361 | )} |
| 362 | </div> |
| 363 | ) : !isExecuting && !!errorText ? ( |
| 364 | <div className={cn('flex-1 w-full overflow-auto relative border-t px-3.5 py-2')}> |
| 365 | <span className="font-mono text-xs">ERROR: {errorText}</span> |
| 366 | </div> |
| 367 | ) : ( |
| 368 | results && ( |
| 369 | <div |
| 370 | className={cn( |
| 371 | 'flex flex-col flex-1 w-full overflow-auto overscroll-contain relative max-h-64' |
| 372 | )} |
| 373 | > |
| 374 | <Results rows={results} /> |
| 375 | </div> |
| 376 | ) |
| 377 | )} |
| 378 | </> |
| 379 | )} |
| 380 | </ReportBlockContainer> |
| 381 | ) |
| 382 | } |