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