ChartConfig.tsx304 lines · main
1import { LOCAL_STORAGE_KEYS, useParams } from 'common'
2import dayjs from 'dayjs'
3import { ArrowUpDown, X } from 'lucide-react'
4import Link from 'next/link'
5import { useMemo } from 'react'
6import {
7 Badge,
8 Button,
9 Checkbox,
10 Label,
11 ResizableHandle,
12 ResizablePanel,
13 ResizablePanelGroup,
14 Select,
15 SelectContent,
16 SelectGroup,
17 SelectItem,
18 SelectTrigger,
19 Tooltip,
20 TooltipContent,
21 TooltipTrigger,
22} from 'ui'
23import { Admonition } from 'ui-patterns'
24
25import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
26import BarChart from '@/components/ui/Charts/BarChart'
27import NoDataPlaceholder from '@/components/ui/Charts/NoDataPlaceholder'
28import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
29
30type Results = { rows: readonly any[] }
31
32export type ChartConfig = {
33 view?: 'table' | 'chart'
34 type: 'bar' | 'line'
35 cumulative: boolean
36 xKey: string
37 yKey: string
38 showLabels?: boolean
39 showGrid?: boolean
40 logScale?: boolean
41}
42
43const getCumulativeResults = (results: Results, config: ChartConfig) => {
44 if (!results?.rows?.length) {
45 return []
46 }
47
48 const cumulativeResults = results.rows.reduce((acc, row) => {
49 const prev = acc[acc.length - 1] || {}
50 const next = {
51 ...row,
52 [config.yKey]: (prev[config.yKey] || 0) + row[config.yKey],
53 }
54 return [...acc, next]
55 }, [])
56 return cumulativeResults
57}
58const VALID_RESULT_KEY_TYPES = ['number', 'string', 'date']
59
60type ChartConfigProps = {
61 results: Results
62 config: ChartConfig
63 onConfigChange: (config: ChartConfig) => void
64}
65
66export const ChartConfig = ({
67 results = { rows: [] },
68 config,
69 onConfigChange,
70}: ChartConfigProps) => {
71 const { ref } = useParams()
72
73 const [acknowledged, setAcknowledged] = useLocalStorageQuery(
74 LOCAL_STORAGE_KEYS.SQL_EDITOR_SQL_BLOCK_ACKNOWLEDGED(ref as string),
75 false
76 )
77
78 // If a result key is not valid, it will be filtered out
79 const resultKeys = useMemo(() => {
80 return Object.keys(results.rows[0] || {}).filter((key) => {
81 const type = typeof results.rows[0][key]
82 return VALID_RESULT_KEY_TYPES.includes(type)
83 })
84 }, [results])
85
86 // Only allow Y-axis keys that are numbers
87 const yAxisKeys = useMemo(() => {
88 if (!results.rows[0]) return []
89 return Object.keys(results.rows[0]).filter((key) => {
90 const value = results.rows[0][key]
91 return typeof value === 'number' || !isNaN(Number(value))
92 })
93 }, [results])
94
95 const hasConfig = config.xKey && config.yKey
96
97 const canFlip = useMemo(() => {
98 if (!hasConfig) return false
99 const xKeyType = typeof results.rows[0]?.[config.xKey]
100 const yKeyType = typeof results.rows[0]?.[config.yKey]
101 return xKeyType === 'number' && yKeyType === 'number'
102 }, [hasConfig, results.rows, config.xKey, config.yKey])
103
104 // Compute cumulative results only if necessary
105 const cumulativeResults = useMemo(() => getCumulativeResults(results, config), [results, config])
106
107 const resultToRender = config.cumulative ? cumulativeResults : results.rows
108
109 const getDateFormat = (key: any) => {
110 const value = resultToRender?.[0]?.[key] || ''
111 if (typeof value === 'number') return 'number'
112 if (dayjs(value).isValid()) return 'date'
113 return 'string'
114 }
115
116 const xKeyDateFormat = getDateFormat(config.xKey)
117
118 const onFlip = () => {
119 const newY = config.xKey
120 const newX = config.yKey
121 onConfigChange({ ...config, xKey: newX, yKey: newY })
122 }
123
124 if (!resultKeys.length) {
125 return (
126 <div className="p-2">
127 <NoDataPlaceholder
128 size="normal"
129 description="Execute a query and configure the chart options."
130 />
131 </div>
132 )
133 }
134
135 return (
136 <ResizablePanelGroup orientation="horizontal" className="grow h-full">
137 <ResizablePanel className="p-4 h-full" defaultSize="75">
138 {!hasConfig ? (
139 <ResizablePanel className="p-4 h-full" defaultSize="75">
140 <NoDataPlaceholder
141 size="normal"
142 title="Configure your chart"
143 description="Select your X and Y axis in the chart options panel"
144 />
145 </ResizablePanel>
146 ) : config.type === 'bar' ? (
147 <BarChart
148 showLegend
149 size="normal"
150 xAxisIsDate={xKeyDateFormat === 'date'}
151 data={resultToRender}
152 xAxisKey={config.xKey}
153 yAxisKey={config.yKey}
154 showGrid={config.showGrid}
155 XAxisProps={{
156 angle: 0,
157 interval: 'preserveStart',
158 hide: !config.showLabels,
159 tickFormatter: (idx: string) => {
160 const value = resultToRender[+idx][config.xKey]
161 if (xKeyDateFormat === 'date') {
162 return dayjs(value).format('MMM D YYYY HH:mm')
163 }
164 return value
165 },
166 }}
167 YAxisProps={{
168 tickFormatter: (value: number) => value.toLocaleString(),
169 hide: !config.showLabels,
170 domain: [0, 'dataMax'],
171 }}
172 />
173 ) : null}
174 </ResizablePanel>
175 <ResizableHandle withHandle />
176 <ResizablePanel
177 defaultSize="25"
178 minSize="15"
179 className="px-3 py-3 space-y-4 overflow-y-auto!"
180 >
181 <div className="flex justify-between items-center h-5">
182 <h2 className="text-sm text-foreground-lighter">Chart options</h2>
183 {config.xKey && config.yKey && (
184 <ButtonTooltip
185 type="text"
186 size="tiny"
187 onClick={onFlip}
188 disabled={!canFlip}
189 icon={<ArrowUpDown size="15" className="text-foreground-lighter" />}
190 tooltip={{
191 content: {
192 side: 'bottom',
193 className: 'w-64 text-center',
194 text: canFlip
195 ? 'Swap X and Y axis'
196 : 'Unable to swap X and Y axis - both axes need to numerical values',
197 },
198 }}
199 >
200 Flip
201 </ButtonTooltip>
202 )}
203 </div>
204
205 {!acknowledged && (
206 <Admonition showIcon={false} type="tip" className="p-2 relative group">
207 <Tooltip>
208 <TooltipTrigger
209 onClick={() => setAcknowledged(true)}
210 className="absolute top-3 right-3 opacity-30 group-hover:opacity-100 transition-opacity"
211 >
212 <X size={14} className="text-foreground-light" />
213 </TooltipTrigger>
214 <TooltipContent side="bottom">Dismiss</TooltipContent>
215 </Tooltip>
216 <div className="flex items-center gap-x-2">
217 <Badge variant="success">New</Badge>
218 <p className="text-xs">Add this chart to custom reports</p>
219 </div>
220 <p className="text-xs text-foreground-light mt-1!">
221 SQL snippets can now be added and saved to your custom reports. Try it out now!
222 </p>
223 <Button asChild size="tiny" type="default" className="mt-1">
224 <Link href={`/project/${ref}/reports`}>Head to Reports</Link>
225 </Button>
226 </Admonition>
227 )}
228
229 <div>
230 <Label className="text-xs text-foreground-light">X Axis</Label>
231 <Select
232 value={config.xKey}
233 onValueChange={(value) => {
234 onConfigChange({ ...config, xKey: value })
235 }}
236 >
237 <SelectTrigger>{config.xKey || 'Select X Axis'}</SelectTrigger>
238 <SelectContent>
239 <SelectGroup>
240 {resultKeys.map((key) => (
241 <SelectItem value={key} key={key}>
242 {key}
243 </SelectItem>
244 ))}
245 </SelectGroup>
246 </SelectContent>
247 </Select>
248 </div>
249
250 <div>
251 <Label className="text-xs text-foreground-light">Y Axis</Label>
252 <Select
253 value={config.yKey}
254 onValueChange={(value) => {
255 onConfigChange({ ...config, yKey: value })
256 }}
257 >
258 <SelectTrigger>{config.yKey || 'Select Y Axis'}</SelectTrigger>
259 <SelectContent>
260 <SelectGroup>
261 {yAxisKeys.map((key) => (
262 <SelectItem value={key} key={key}>
263 {key}
264 </SelectItem>
265 ))}
266 </SelectGroup>
267 </SelectContent>
268 </Select>
269 </div>
270 <div className="*:flex *:gap-2 *:items-center grid gap-2 *:text-foreground-light *:p-1.5 *:pl-0">
271 <Label className="" htmlFor="cumulative">
272 <Checkbox
273 id="cumulative"
274 name="cumulative"
275 checked={config.cumulative}
276 onClick={() => onConfigChange({ ...config, cumulative: !config.cumulative })}
277 />
278 Cumulative
279 </Label>
280
281 <Label htmlFor="showLabels">
282 <Checkbox
283 id="showLabels"
284 name="showLabels"
285 checked={config.showLabels}
286 onClick={() => onConfigChange({ ...config, showLabels: !config.showLabels })}
287 />
288 Show labels
289 </Label>
290
291 <Label htmlFor="showGrid">
292 <Checkbox
293 id="showGrid"
294 name="showGrid"
295 checked={config.showGrid}
296 onClick={() => onConfigChange({ ...config, showGrid: !config.showGrid })}
297 />
298 Show grid
299 </Label>
300 </div>
301 </ResizablePanel>
302 </ResizablePanelGroup>
303 )
304}