CustomReportSection.tsx467 lines · main
| 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 | |
| 21 | import { SnippetDropdown } from '@/components/interfaces/ProjectHome/SnippetDropdown' |
| 22 | import { ReportBlock } from '@/components/interfaces/Reports/ReportBlock/ReportBlock' |
| 23 | import { createSqlSnippetSkeletonV2 } from '@/components/interfaces/SQLEditor/SQLEditor.utils' |
| 24 | import type { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig' |
| 25 | import { ButtonTooltip } from '@/components/ui/ButtonTooltip' |
| 26 | import { DEFAULT_CHART_CONFIG } from '@/components/ui/QueryBlock/QueryBlock' |
| 27 | import { AnalyticsInterval } from '@/data/analytics/constants' |
| 28 | import { useInvalidateAnalyticsQuery } from '@/data/analytics/utils' |
| 29 | import { useContentInfiniteQuery } from '@/data/content/content-infinite-query' |
| 30 | import { Content } from '@/data/content/content-query' |
| 31 | import { |
| 32 | UpsertContentPayload, |
| 33 | useContentUpsertMutation, |
| 34 | } from '@/data/content/content-upsert-mutation' |
| 35 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 36 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 37 | import { uuidv4 } from '@/lib/helpers' |
| 38 | import { useProfile } from '@/lib/profile' |
| 39 | import { useTrack } from '@/lib/telemetry/track' |
| 40 | import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector' |
| 41 | import type { Dashboards } from '@/types' |
| 42 | |
| 43 | export 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 | |
| 444 | function 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 | } |