ChartConfig.tsx 9.4 KB

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