CustomReportSection.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. import {
  2. closestCenter,
  3. DndContext,
  4. DragEndEvent,
  5. PointerSensor,
  6. useSensor,
  7. useSensors,
  8. } from '@dnd-kit/core'
  9. import { arrayMove, rectSortingStrategy, SortableContext, useSortable } from '@dnd-kit/sortable'
  10. import { PermissionAction } from '@supabase/shared-types/out/constants'
  11. import { keepPreviousData } from '@tanstack/react-query'
  12. import { useParams } from 'common'
  13. import dayjs from 'dayjs'
  14. import { Plus, RefreshCw } from 'lucide-react'
  15. import type { CSSProperties, DragEvent, ReactNode } from 'react'
  16. import { useCallback, useEffect, useMemo, useState } from 'react'
  17. import { toast } from 'sonner'
  18. import { Button } from 'ui'
  19. import { Row } from 'ui-patterns'
  20. import { SnippetDropdown } from '@/components/interfaces/ProjectHome/SnippetDropdown'
  21. import { ReportBlock } from '@/components/interfaces/Reports/ReportBlock/ReportBlock'
  22. import { createSqlSnippetSkeletonV2 } from '@/components/interfaces/SQLEditor/SQLEditor.utils'
  23. import type { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig'
  24. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  25. import { DEFAULT_CHART_CONFIG } from '@/components/ui/QueryBlock/QueryBlock'
  26. import { AnalyticsInterval } from '@/data/analytics/constants'
  27. import { useInvalidateAnalyticsQuery } from '@/data/analytics/utils'
  28. import { useContentInfiniteQuery } from '@/data/content/content-infinite-query'
  29. import { Content } from '@/data/content/content-query'
  30. import {
  31. UpsertContentPayload,
  32. useContentUpsertMutation,
  33. } from '@/data/content/content-upsert-mutation'
  34. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  35. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  36. import { uuidv4 } from '@/lib/helpers'
  37. import { useProfile } from '@/lib/profile'
  38. import { useTrack } from '@/lib/telemetry/track'
  39. import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
  40. import type { Dashboards } from '@/types'
  41. export function CustomReportSection() {
  42. const startDate = dayjs().subtract(7, 'day').toISOString()
  43. const endDate = dayjs().toISOString()
  44. const { ref } = useParams()
  45. const { profile } = useProfile()
  46. const state = useDatabaseSelectorStateSnapshot()
  47. const track = useTrack()
  48. const { invalidateInfraMonitoringQuery } = useInvalidateAnalyticsQuery()
  49. const { data: project } = useSelectedProjectQuery()
  50. const [isRefreshing, setIsRefreshing] = useState<boolean>(false)
  51. const { data: reportsData } = useContentInfiniteQuery(
  52. { projectRef: ref, type: 'report', name: 'Home', limit: 1 },
  53. { placeholderData: keepPreviousData }
  54. )
  55. const homeReport = reportsData?.pages?.[0]?.content?.[0] as Content | undefined
  56. const reportContent = homeReport?.content as Dashboards.Content | undefined
  57. const [editableReport, setEditableReport] = useState<Dashboards.Content | undefined>(
  58. reportContent
  59. )
  60. const [isDraggingOver, setIsDraggingOver] = useState(false)
  61. const { can: canCreateReport } = useAsyncCheckPermissions(
  62. PermissionAction.CREATE,
  63. 'user_content',
  64. { resource: { type: 'report', owner_id: profile?.id }, subject: { id: profile?.id } }
  65. )
  66. const { can: canUpdateReport } = useAsyncCheckPermissions(
  67. PermissionAction.UPDATE,
  68. 'user_content',
  69. {
  70. resource: {
  71. type: 'report',
  72. visibility: homeReport?.visibility,
  73. owner_id: homeReport?.owner_id,
  74. },
  75. subject: { id: profile?.id },
  76. }
  77. )
  78. const { mutate: upsertContent } = useContentUpsertMutation()
  79. const persistReport = useCallback(
  80. (updated: Dashboards.Content) => {
  81. if (!ref || !homeReport) return
  82. upsertContent({ projectRef: ref, payload: { ...homeReport, content: updated } })
  83. },
  84. [homeReport, ref, upsertContent]
  85. )
  86. const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
  87. const handleDragStart = () => {}
  88. const recomputeSimpleGrid = useCallback(
  89. (layout: Dashboards.Chart[]) =>
  90. layout.map(
  91. (block, idx): Dashboards.Chart => ({
  92. ...block,
  93. x: idx % 2,
  94. y: Math.floor(idx / 2),
  95. w: 1,
  96. h: 1,
  97. })
  98. ),
  99. []
  100. )
  101. const handleDragEnd = useCallback(
  102. (event: DragEndEvent) => {
  103. const { active, over } = event
  104. if (!editableReport || !active || !over || active.id === over.id) return
  105. const items = editableReport.layout.map((x) => String(x.id))
  106. const oldIndex = items.indexOf(String(active.id))
  107. const newIndex = items.indexOf(String(over.id))
  108. if (oldIndex === -1 || newIndex === -1) return
  109. const moved = arrayMove(editableReport.layout, oldIndex, newIndex)
  110. const recomputed = recomputeSimpleGrid(moved)
  111. const updated = { ...editableReport, layout: recomputed }
  112. setEditableReport(updated)
  113. persistReport(updated)
  114. },
  115. [editableReport, persistReport, recomputeSimpleGrid]
  116. )
  117. const findNextPlacement = useCallback((current: Dashboards.Chart[]) => {
  118. const occupied = new Set(current.map((c) => `${c.y}-${c.x}`))
  119. let y = 0
  120. for (; ; y++) {
  121. const left = occupied.has(`${y}-0`)
  122. const right = occupied.has(`${y}-1`)
  123. if (!left || !right) {
  124. const x = left ? 1 : 0
  125. return { x, y }
  126. }
  127. }
  128. }, [])
  129. const createSnippetChartBlock = useCallback(
  130. (
  131. snippet: { id: string; name: string },
  132. position: { x: number; y: number }
  133. ): Dashboards.Chart => ({
  134. x: position.x,
  135. y: position.y,
  136. w: 1,
  137. h: 1,
  138. id: snippet.id,
  139. label: snippet.name,
  140. attribute: `snippet_${snippet.id}` as unknown as Dashboards.Chart['attribute'],
  141. provider: 'daily-stats',
  142. chart_type: 'bar',
  143. chartConfig: DEFAULT_CHART_CONFIG,
  144. }),
  145. []
  146. )
  147. const addSnippetToReport = useCallback(
  148. (snippet: { id: string; name: string }) => {
  149. if (
  150. editableReport?.layout?.some(
  151. (x) =>
  152. String(x.id) === String(snippet.id) || String(x.attribute) === `snippet_${snippet.id}`
  153. )
  154. ) {
  155. toast('This block is already in your report')
  156. return
  157. }
  158. // If the Home report doesn't exist yet, create it with the new block
  159. if (!editableReport || !homeReport) {
  160. if (!ref || !profile) return
  161. // Initial placement for first block
  162. const initialBlock = createSnippetChartBlock(snippet, { x: 0, y: 0 })
  163. const newReport: Dashboards.Content = {
  164. schema_version: 1,
  165. period_start: { time_period: '7d', date: '' },
  166. period_end: { time_period: 'today', date: '' },
  167. interval: '1d',
  168. layout: [initialBlock],
  169. }
  170. setEditableReport(newReport)
  171. upsertContent({
  172. projectRef: ref,
  173. payload: {
  174. id: uuidv4(),
  175. type: 'report',
  176. name: 'Home',
  177. description: '',
  178. visibility: 'project',
  179. owner_id: profile.id,
  180. content: newReport,
  181. },
  182. })
  183. track('home_custom_report_block_added', { block_id: snippet.id, position: 0 })
  184. return
  185. }
  186. const current = [...editableReport.layout]
  187. const { x, y } = findNextPlacement(current)
  188. current.push(createSnippetChartBlock(snippet, { x, y }))
  189. const updated = { ...editableReport, layout: current }
  190. setEditableReport(updated)
  191. persistReport(updated)
  192. track('home_custom_report_block_added', {
  193. block_id: snippet.id,
  194. position: current.length - 1,
  195. })
  196. },
  197. [
  198. editableReport,
  199. homeReport,
  200. ref,
  201. profile,
  202. upsertContent,
  203. track,
  204. findNextPlacement,
  205. createSnippetChartBlock,
  206. persistReport,
  207. ]
  208. )
  209. const handleRemoveChart = ({ metric }: { metric: { key: string } }) => {
  210. if (!editableReport) return
  211. const removedChart = editableReport.layout.find(
  212. (x) => x.attribute === (metric.key as unknown as Dashboards.Chart['attribute'])
  213. )
  214. const nextLayout = editableReport.layout.filter(
  215. (x) => x.attribute !== (metric.key as unknown as Dashboards.Chart['attribute'])
  216. )
  217. const updated = { ...editableReport, layout: nextLayout }
  218. setEditableReport(updated)
  219. persistReport(updated)
  220. if (removedChart) {
  221. track('home_custom_report_block_removed', { block_id: String(removedChart.id) })
  222. }
  223. }
  224. const handleUpdateChart = (
  225. id: string,
  226. {
  227. chart,
  228. chartConfig,
  229. }: { chart?: Partial<Dashboards.Chart>; chartConfig?: Partial<ChartConfig> }
  230. ) => {
  231. if (!editableReport) return
  232. const currentChart = editableReport.layout.find((x) => x.id === id)
  233. if (!currentChart) return
  234. const updatedChart: Dashboards.Chart = { ...currentChart, ...(chart ?? {}) }
  235. if (chartConfig) {
  236. updatedChart.chartConfig = { ...(currentChart.chartConfig ?? {}), ...chartConfig }
  237. }
  238. const updatedLayouts = editableReport.layout.map((x) => (x.id === id ? updatedChart : x))
  239. const updated = { ...editableReport, layout: updatedLayouts }
  240. setEditableReport(updated)
  241. persistReport(updated)
  242. }
  243. const handleDrop = useCallback(
  244. async (e: DragEvent<HTMLDivElement>) => {
  245. e.preventDefault()
  246. setIsDraggingOver(false)
  247. if (!ref || !profile || !project) return
  248. const data = e.dataTransfer.getData('application/json')
  249. if (!data) return
  250. const { label, sql } = JSON.parse(data)
  251. if (!label || !sql) return
  252. const toastId = toast.loading(`Creating new query: ${label}`)
  253. const payload = createSqlSnippetSkeletonV2({
  254. name: label,
  255. sql,
  256. owner_id: profile.id,
  257. project_id: project.id,
  258. }) as UpsertContentPayload
  259. upsertContent({ projectRef: ref, payload })
  260. // Handle success optimistically
  261. toast.success(`Successfully created new query: ${label}`, { id: toastId })
  262. addSnippetToReport({ id: payload.id, name: label })
  263. track('custom_report_assistant_sql_block_added')
  264. },
  265. [ref, profile, project, upsertContent, addSnippetToReport, track]
  266. )
  267. const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
  268. setIsDraggingOver(true)
  269. e.preventDefault()
  270. }
  271. const handleDragLeave = () => {
  272. setIsDraggingOver(false)
  273. }
  274. const onRefreshReport = () => {
  275. if (!ref) return
  276. setIsRefreshing(true)
  277. const monitoringCharts = editableReport?.layout.filter(
  278. (x) => x.provider === 'infra-monitoring' || x.provider === 'daily-stats'
  279. )
  280. monitoringCharts?.forEach((x) => {
  281. invalidateInfraMonitoringQuery(ref, {
  282. attribute: x.attribute,
  283. startDate,
  284. endDate,
  285. interval: editableReport?.interval || '1d',
  286. databaseIdentifier: state.selectedDatabaseId,
  287. })
  288. })
  289. setTimeout(() => setIsRefreshing(false), 1000)
  290. }
  291. const layout = useMemo(() => editableReport?.layout ?? [], [editableReport])
  292. useEffect(() => {
  293. if (reportContent) setEditableReport(reportContent)
  294. }, [reportContent])
  295. return (
  296. <div className="space-y-6">
  297. <div className="flex items-center justify-between">
  298. <h3 className="heading-section">Reports</h3>
  299. <div className="flex items-center gap-x-2">
  300. {layout.length > 0 && (
  301. <ButtonTooltip
  302. type="default"
  303. icon={<RefreshCw className={isRefreshing ? 'animate-spin' : ''} />}
  304. className="w-7"
  305. disabled={isRefreshing}
  306. tooltip={{ content: { side: 'bottom', text: 'Refresh report' } }}
  307. onClick={onRefreshReport}
  308. />
  309. )}
  310. {canUpdateReport || canCreateReport ? (
  311. <SnippetDropdown
  312. projectRef={ref}
  313. onSelect={addSnippetToReport}
  314. trigger={
  315. <Button type="default" icon={<Plus />}>
  316. Add block
  317. </Button>
  318. }
  319. side="bottom"
  320. align="end"
  321. autoFocus
  322. />
  323. ) : null}
  324. </div>
  325. </div>
  326. <div className="relative">
  327. {isDraggingOver && (
  328. <div className="absolute inset-0 rounded-sm bg-brand/10 pointer-events-none z-10" />
  329. )}
  330. {layout.length === 0 ? (
  331. <div
  332. className="h-64 flex flex-col items-center justify-center rounded-sm border-2 border-dashed p-16 transition-colors"
  333. onDrop={handleDrop}
  334. onDragOver={handleDragOver}
  335. onDragLeave={handleDragLeave}
  336. >
  337. <h4>Build a custom report</h4>
  338. <p className="text-sm text-foreground-light mb-4">
  339. Keep track of your most important metrics
  340. </p>
  341. {canUpdateReport || canCreateReport ? (
  342. <SnippetDropdown
  343. projectRef={ref}
  344. onSelect={addSnippetToReport}
  345. trigger={
  346. <Button type="default" iconRight={<Plus size={14} />}>
  347. Add your first block
  348. </Button>
  349. }
  350. side="bottom"
  351. align="center"
  352. autoFocus
  353. />
  354. ) : (
  355. <p className="text-sm text-foreground-light">No charts set up yet in report</p>
  356. )}
  357. </div>
  358. ) : (
  359. <DndContext
  360. sensors={sensors}
  361. collisionDetection={closestCenter}
  362. onDragStart={handleDragStart}
  363. onDragEnd={handleDragEnd}
  364. >
  365. <SortableContext
  366. items={(editableReport?.layout ?? []).map((x) => String(x.id))}
  367. strategy={rectSortingStrategy}
  368. >
  369. <Row
  370. maxColumns={4}
  371. minWidth={280}
  372. onDrop={handleDrop}
  373. onDragOver={handleDragOver}
  374. onDragLeave={handleDragLeave}
  375. >
  376. {layout.map((item) => (
  377. <SortableReportBlock key={item.id} id={String(item.id)}>
  378. <div className="h-64">
  379. <ReportBlock
  380. key={item.id}
  381. item={item}
  382. startDate={startDate}
  383. endDate={endDate}
  384. interval={
  385. (editableReport?.interval as AnalyticsInterval) ??
  386. ('1d' as AnalyticsInterval)
  387. }
  388. disableUpdate={false}
  389. isRefreshing={isRefreshing}
  390. onRemoveChart={handleRemoveChart}
  391. onUpdateChart={(config) => handleUpdateChart(item.id, config)}
  392. />
  393. </div>
  394. </SortableReportBlock>
  395. ))}
  396. </Row>
  397. </SortableContext>
  398. </DndContext>
  399. )}
  400. </div>
  401. </div>
  402. )
  403. }
  404. function SortableReportBlock({ id, children }: { id: string; children: ReactNode }) {
  405. const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
  406. id,
  407. })
  408. const style: CSSProperties = {
  409. transform: transform
  410. ? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)`
  411. : undefined,
  412. transition,
  413. }
  414. return (
  415. <div
  416. ref={setNodeRef}
  417. style={style}
  418. className={isDragging ? 'opacity-70 will-change-transform' : 'will-change-transform'}
  419. {...attributes}
  420. {...(listeners ?? {})}
  421. >
  422. {children}
  423. </div>
  424. )
  425. }