Home New: Report (#38341)

* new home top

* advisors

* fix ts

* add report section

* add report

* Nit refactor

* refactor row

* prevent adding snippet twice

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Saxon Fletcher
2025-09-16 13:00:25 +10:00
committed by GitHub
parent 99be74735f
commit 30e73ee44d
5 changed files with 555 additions and 82 deletions

View File

@@ -0,0 +1,338 @@
import {
DndContext,
DragEndEvent,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core'
import { SortableContext, arrayMove, rectSortingStrategy, useSortable } from '@dnd-kit/sortable'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import dayjs from 'dayjs'
import { Plus } from 'lucide-react'
import type { CSSProperties, ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useParams } from 'common'
import { SnippetDropdown } from 'components/interfaces/HomeNew/SnippetDropdown'
import { ReportBlock } from 'components/interfaces/Reports/ReportBlock/ReportBlock'
import type { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig'
import { DEFAULT_CHART_CONFIG } from 'components/ui/QueryBlock/QueryBlock'
import { AnalyticsInterval } from 'data/analytics/constants'
import { useContentInfiniteQuery } from 'data/content/content-infinite-query'
import { Content } from 'data/content/content-query'
import { useContentUpsertMutation } from 'data/content/content-upsert-mutation'
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
import { uuidv4 } from 'lib/helpers'
import { useProfile } from 'lib/profile'
import type { Dashboards } from 'types'
import { Button } from 'ui'
import { Row } from 'ui-patterns'
import { toast } from 'sonner'
export function CustomReportSection() {
const startDate = dayjs().subtract(7, 'day').toISOString()
const endDate = dayjs().toISOString()
const { ref } = useParams()
const { profile } = useProfile()
const { data: reportsData } = useContentInfiniteQuery(
{ projectRef: ref, type: 'report', name: 'Home', limit: 1 },
{ keepPreviousData: true }
)
const homeReport = reportsData?.pages?.[0]?.content?.[0] as Content | undefined
const reportContent = homeReport?.content as Dashboards.Content | undefined
const [editableReport, setEditableReport] = useState<Dashboards.Content | undefined>(
reportContent
)
useEffect(() => {
if (reportContent) setEditableReport(reportContent)
}, [reportContent])
const { can: canUpdateReport } = useAsyncCheckProjectPermissions(
PermissionAction.UPDATE,
'user_content',
{
resource: {
type: 'report',
visibility: homeReport?.visibility,
owner_id: homeReport?.owner_id,
},
subject: { id: profile?.id },
}
)
const { can: canCreateReport } = useAsyncCheckProjectPermissions(
PermissionAction.CREATE,
'user_content',
{ resource: { type: 'report', owner_id: profile?.id }, subject: { id: profile?.id } }
)
const { mutate: upsertContent } = useContentUpsertMutation()
const persistReport = useCallback(
(updated: Dashboards.Content) => {
if (!ref || !homeReport) return
upsertContent({ projectRef: ref, payload: { ...homeReport, content: updated } })
},
[homeReport, ref, upsertContent]
)
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
const handleDragStart = () => {}
const recomputeSimpleGrid = useCallback(
(layout: Dashboards.Chart[]) =>
layout.map(
(block, idx): Dashboards.Chart => ({
...block,
x: idx % 2,
y: Math.floor(idx / 2),
w: 1,
h: 1,
})
),
[]
)
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event
if (!editableReport || !active || !over || active.id === over.id) return
const items = editableReport.layout.map((x) => String(x.id))
const oldIndex = items.indexOf(String(active.id))
const newIndex = items.indexOf(String(over.id))
if (oldIndex === -1 || newIndex === -1) return
const moved = arrayMove(editableReport.layout, oldIndex, newIndex)
const recomputed = recomputeSimpleGrid(moved)
const updated = { ...editableReport, layout: recomputed }
setEditableReport(updated)
persistReport(updated)
},
[editableReport, persistReport, recomputeSimpleGrid]
)
const findNextPlacement = useCallback((current: Dashboards.Chart[]) => {
const occupied = new Set(current.map((c) => `${c.y}-${c.x}`))
let y = 0
for (; ; y++) {
const left = occupied.has(`${y}-0`)
const right = occupied.has(`${y}-1`)
if (!left || !right) {
const x = left ? 1 : 0
return { x, y }
}
}
}, [])
const createSnippetChartBlock = useCallback(
(
snippet: { id: string; name: string },
position: { x: number; y: number }
): Dashboards.Chart => ({
x: position.x,
y: position.y,
w: 1,
h: 1,
id: snippet.id,
label: snippet.name,
attribute: `snippet_${snippet.id}` as unknown as Dashboards.Chart['attribute'],
provider: 'daily-stats',
chart_type: 'bar',
chartConfig: DEFAULT_CHART_CONFIG,
}),
[]
)
const addSnippetToReport = (snippet: { id: string; name: string }) => {
if (
editableReport?.layout?.some(
(x) =>
String(x.id) === String(snippet.id) || String(x.attribute) === `snippet_${snippet.id}`
)
) {
toast('This block is already in your report')
return
}
// If the Home report doesn't exist yet, create it with the new block
if (!editableReport || !homeReport) {
if (!ref || !profile) return
// Initial placement for first block
const initialBlock = createSnippetChartBlock(snippet, { x: 0, y: 0 })
const newReport: Dashboards.Content = {
schema_version: 1,
period_start: { time_period: '7d', date: '' },
period_end: { time_period: 'today', date: '' },
interval: '1d',
layout: [initialBlock],
}
setEditableReport(newReport)
upsertContent({
projectRef: ref,
payload: {
id: uuidv4(),
type: 'report',
name: 'Home',
description: '',
visibility: 'project',
owner_id: profile.id,
content: newReport,
},
})
return
}
const current = [...editableReport.layout]
const { x, y } = findNextPlacement(current)
current.push(createSnippetChartBlock(snippet, { x, y }))
const updated = { ...editableReport, layout: current }
setEditableReport(updated)
persistReport(updated)
}
const handleRemoveChart = ({ metric }: { metric: { key: string } }) => {
if (!editableReport) return
const nextLayout = editableReport.layout.filter(
(x) => x.attribute !== (metric.key as unknown as Dashboards.Chart['attribute'])
)
const updated = { ...editableReport, layout: nextLayout }
setEditableReport(updated)
persistReport(updated)
}
const handleUpdateChart = (
id: string,
{
chart,
chartConfig,
}: { chart?: Partial<Dashboards.Chart>; chartConfig?: Partial<ChartConfig> }
) => {
if (!editableReport) return
const currentChart = editableReport.layout.find((x) => x.id === id)
if (!currentChart) return
const updatedChart: Dashboards.Chart = { ...currentChart, ...(chart ?? {}) }
if (chartConfig) {
updatedChart.chartConfig = { ...(currentChart.chartConfig ?? {}), ...chartConfig }
}
const updatedLayouts = editableReport.layout.map((x) => (x.id === id ? updatedChart : x))
const updated = { ...editableReport, layout: updatedLayouts }
setEditableReport(updated)
persistReport(updated)
}
const layout = useMemo(() => editableReport?.layout ?? [], [editableReport])
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="heading-section">At a glance</h3>
{canUpdateReport || canCreateReport ? (
<SnippetDropdown
projectRef={ref}
onSelect={addSnippetToReport}
trigger={
<Button type="default" icon={<Plus />}>
Add block
</Button>
}
side="bottom"
align="end"
autoFocus
/>
) : null}
</div>
<div className="relative">
{(() => {
if (layout.length === 0) {
return (
<div className="flex min-h-[270px] items-center justify-center rounded border-2 border-dashed p-16 border-default">
{canUpdateReport || canCreateReport ? (
<SnippetDropdown
projectRef={ref}
onSelect={addSnippetToReport}
trigger={
<Button type="default" iconRight={<Plus size={14} />}>
Add your first chart
</Button>
}
side="bottom"
align="center"
autoFocus
/>
) : (
<p className="text-sm text-foreground-light">No charts set up yet in report</p>
)}
</div>
)
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={(editableReport?.layout ?? []).map((x) => String(x.id))}
strategy={rectSortingStrategy}
>
<Row columns={[3, 2, 1]}>
{layout.map((item) => (
<SortableReportBlock key={item.id} id={String(item.id)}>
<div className="h-64">
<ReportBlock
key={item.id}
item={item}
startDate={startDate}
endDate={endDate}
interval={
(editableReport?.interval as AnalyticsInterval) ??
('1d' as AnalyticsInterval)
}
disableUpdate={false}
isRefreshing={false}
onRemoveChart={handleRemoveChart}
onUpdateChart={(config) => handleUpdateChart(item.id, config)}
/>
</div>
</SortableReportBlock>
))}
</Row>
</SortableContext>
</DndContext>
)
})()}
</div>
</div>
)
}
function SortableReportBlock({ id, children }: { id: string; children: ReactNode }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
})
const style: CSSProperties = {
transform: transform
? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)`
: undefined,
transition,
}
return (
<div
ref={setNodeRef}
style={style}
className={isDragging ? 'opacity-70 will-change-transform' : 'will-change-transform'}
{...attributes}
{...(listeners ?? {})}
>
{children}
</div>
)
}

View File

@@ -17,6 +17,7 @@ import {
import { PROJECT_STATUS } from 'lib/constants'
import { useAppStateSnapshot } from 'state/app-state'
import { AdvisorSection } from './AdvisorSection'
import { CustomReportSection } from './CustomReportSection'
import {
GettingStartedSection,
type GettingStartedState,
@@ -131,6 +132,13 @@ export const HomeV2 = () => {
</SortableSection>
)
}
if (id === 'custom-report') {
return (
<SortableSection key={id} id={id}>
<CustomReportSection />
</SortableSection>
)
}
})}
</SortableContext>
</DndContext>

View File

@@ -0,0 +1,108 @@
import { useIntersectionObserver } from '@uidotdev/usehooks'
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'
import { useContentInfiniteQuery } from 'data/content/content-infinite-query'
import type { Content } from 'data/content/content-query'
import { SNIPPET_PAGE_LIMIT } from 'data/content/sql-folders-query'
import {
Command_Shadcn_,
CommandEmpty_Shadcn_,
CommandGroup_Shadcn_,
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from 'ui'
type SnippetDropdownProps = {
projectRef?: string
trigger: ReactNode
side?: 'top' | 'bottom' | 'left' | 'right'
align?: 'start' | 'center' | 'end'
className?: string
autoFocus?: boolean
onSelect: (snippet: { id: string; name: string }) => void
}
type SqlContentItem = Extract<Content, { type: 'sql' }>
export const SnippetDropdown = ({
projectRef,
trigger,
side = 'bottom',
align = 'end',
className,
autoFocus = false,
onSelect,
}: SnippetDropdownProps) => {
const [snippetSearch, setSnippetSearch] = useState('')
const scrollRootRef = useRef<HTMLDivElement | null>(null)
const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } =
useContentInfiniteQuery(
{
projectRef,
type: 'sql',
limit: SNIPPET_PAGE_LIMIT,
name: snippetSearch.length === 0 ? undefined : snippetSearch,
},
{ keepPreviousData: true }
)
const snippets = useMemo(() => {
const items = data?.pages.flatMap((page) => page.content) ?? []
return items as SqlContentItem[]
}, [data?.pages])
const [sentinelRef, entry] = useIntersectionObserver({
root: scrollRootRef.current,
threshold: 0,
rootMargin: '0px',
})
useEffect(() => {
if (entry?.isIntersecting && hasNextPage && !isFetchingNextPage && !isLoading) {
fetchNextPage()
}
}, [entry?.isIntersecting, hasNextPage, isFetchingNextPage, isLoading, fetchNextPage])
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent
side={side}
align={align}
className={['w-80 p-0', className].filter(Boolean).join(' ')}
>
<Command_Shadcn_ shouldFilter={false}>
<CommandInput_Shadcn_
autoFocus={autoFocus}
placeholder="Search snippets..."
value={snippetSearch}
onValueChange={setSnippetSearch}
/>
<CommandList_Shadcn_ ref={scrollRootRef}>
{isLoading ? (
<CommandEmpty_Shadcn_>Loading</CommandEmpty_Shadcn_>
) : snippets.length === 0 ? (
<CommandEmpty_Shadcn_>No snippets found</CommandEmpty_Shadcn_>
) : null}
<CommandGroup_Shadcn_>
{snippets.map((snippet) => (
<CommandItem_Shadcn_
key={snippet.id}
onSelect={() => onSelect({ id: snippet.id, name: snippet.name })}
>
{snippet.name}
</CommandItem_Shadcn_>
))}
<div ref={sentinelRef} className="h-1" />
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,38 @@
import { useLayoutEffect, useState } from 'react'
export const useMeasuredWidth = <T extends HTMLElement>(ref: React.RefObject<T>) => {
const [measuredWidth, setMeasuredWidth] = useState<number | null>(null)
useLayoutEffect(() => {
const element = ref.current
if (!element) return
const initial = element.getBoundingClientRect().width
setMeasuredWidth((prev) => (prev === initial ? prev : initial))
if (typeof ResizeObserver !== 'undefined') {
let frame = 0
const resizeObserver = new ResizeObserver((entries) => {
const width = entries[0]?.contentRect.width ?? 0
if (frame) cancelAnimationFrame(frame)
frame = requestAnimationFrame(() => {
setMeasuredWidth((prev) => (prev === width ? prev : width))
})
})
resizeObserver.observe(element)
return () => {
if (frame) cancelAnimationFrame(frame)
resizeObserver.disconnect()
}
} else {
const handleResize = () => {
const width = element.getBoundingClientRect().width
setMeasuredWidth((prev) => (prev === width ? prev : width))
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}
}, [ref])
return measuredWidth
}

View File

@@ -1,13 +1,14 @@
'use client'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import type React from 'react'
import type { ReactNode } from 'react'
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'
import { Button, cn } from 'ui'
import type { ReactNode } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useMeasuredWidth } from './Row.utils'
interface RowProps extends React.HTMLAttributes<HTMLDivElement> {
/** columns can be a fixed number or an array [lg, md, sm] */
// columns can be a fixed number or an array [lg, md, sm]
columns: number | [number, number, number]
children: ReactNode
className?: string
@@ -24,13 +25,11 @@ export const Row = forwardRef<HTMLDivElement, RowProps>(function Row(
ref
) {
const containerRef = useRef<HTMLDivElement>(null)
// We forward the ref to the outer wrapper; consumers needing the scroll container
// can use a separate ref prop in the future if required.
const childrenArray = useMemo(() => (Array.isArray(children) ? children : [children]), [children])
const [scrollPosition, setScrollPosition] = useState(0)
const [maxScroll, setMaxScroll] = useState(0)
const measuredWidth = useMeasuredWidth(containerRef)
const resolveColumnsForWidth = (width: number): number => {
if (!Array.isArray(columns)) return columns
@@ -41,101 +40,77 @@ export const Row = forwardRef<HTMLDivElement, RowProps>(function Row(
return smCols
}
const getRenderColumns = (): number => {
const width = containerRef.current?.getBoundingClientRect().width ?? 0
return resolveColumnsForWidth(width)
}
const renderColumns = useMemo(
() => resolveColumnsForWidth(measuredWidth ?? 0),
[measuredWidth, columns]
)
const scrollByStep = (direction: -1 | 1) => {
const el = containerRef.current
if (!el) return
const widthLocal = el.getBoundingClientRect().width
const colsLocal = resolveColumnsForWidth(widthLocal)
const widthLocal = measuredWidth ?? el.getBoundingClientRect().width
const colsLocal = renderColumns
const columnWidth = (widthLocal - (colsLocal - 1) * gap) / colsLocal
const scrollAmount = columnWidth + gap
setScrollPosition((prev) => Math.max(0, Math.min(maxScroll, prev + direction * scrollAmount)))
setScrollPosition((prev) => {
const next = Math.max(0, Math.min(maxScroll, prev + direction * scrollAmount))
return next === prev ? prev : next
})
}
const scrollLeft = () => scrollByStep(-1)
const scrollRight = () => scrollByStep(1)
const maxScroll = useMemo(() => {
if (measuredWidth == null) return -1
const colsLocal = renderColumns
const columnWidth = (measuredWidth - (colsLocal - 1) * gap) / colsLocal
const totalWidth = childrenArray.length * columnWidth + (childrenArray.length - 1) * gap
return Math.max(0, totalWidth - measuredWidth)
}, [measuredWidth, renderColumns, childrenArray.length, gap])
const canScrollLeft = scrollPosition > 0
const canScrollRight = scrollPosition < maxScroll
useEffect(() => {
const element = containerRef.current
if (!element) return
const rafIdRef = useRef(0 as number)
const pendingDeltaRef = useRef(0)
const computeMaxScroll = (width: number) => {
const colsLocal = resolveColumnsForWidth(width)
const columnWidth = (width - (colsLocal - 1) * gap) / colsLocal
const totalWidth = childrenArray.length * columnWidth + (childrenArray.length - 1) * gap
const maxScrollValue = Math.max(0, totalWidth - width)
setMaxScroll(maxScrollValue)
}
const handleWheel: React.WheelEventHandler<HTMLDivElement> = (e) => {
if (e.deltaX === 0) return
// Initial calculation
computeMaxScroll(element.getBoundingClientRect().width)
const delta = Math.abs(e.deltaX) * 2 * (e.deltaX > 0 ? 1 : -1)
pendingDeltaRef.current += delta
if (typeof ResizeObserver !== 'undefined') {
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
computeMaxScroll(entry.contentRect.width)
}
if (!rafIdRef.current) {
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = 0
const accumulated = pendingDeltaRef.current
pendingDeltaRef.current = 0
setScrollPosition((prev) => {
const target = prev + accumulated
const next = Math.max(0, Math.min(maxScroll, target))
return next === prev ? prev : next
})
})
resizeObserver.observe(element)
return () => resizeObserver.disconnect()
} else {
const handleResize = () => computeMaxScroll(element.getBoundingClientRect().width)
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}
}, [childrenArray.length, gap, columns])
}
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
if (containerRef.current && containerRef.current.contains(e.target as Node)) {
if (e.deltaX !== 0) {
e.preventDefault()
const scrollAmount = Math.abs(e.deltaX) * 2
const direction = e.deltaX > 0 ? 1 : -1
setScrollPosition((prev) => {
const newPosition = prev + scrollAmount * direction
return Math.max(0, Math.min(maxScroll, newPosition))
})
}
}
}
const container = containerRef.current
if (container) {
container.addEventListener('wheel', handleWheel, { passive: false })
return () => container.removeEventListener('wheel', handleWheel)
}
setScrollPosition((prev) => {
const next = Math.min(prev, maxScroll)
return next === prev ? prev : next
})
}, [maxScroll])
useEffect(() => {
setScrollPosition((prev) => Math.min(prev, maxScroll))
}, [maxScroll])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (containerRef.current && document.activeElement === containerRef.current) {
if (e.key === 'ArrowLeft' && canScrollLeft) {
e.preventDefault()
scrollLeft()
} else if (e.key === 'ArrowRight' && canScrollRight) {
e.preventDefault()
scrollRight()
}
}
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
if (e.key === 'ArrowLeft' && canScrollLeft) {
e.preventDefault()
scrollLeft()
} else if (e.key === 'ArrowRight' && canScrollRight) {
e.preventDefault()
scrollRight()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [canScrollLeft, canScrollRight])
}
return (
<div ref={ref} className={cn('relative w-full', className)} {...rest}>
@@ -145,8 +120,9 @@ export const Row = forwardRef<HTMLDivElement, RowProps>(function Row(
onClick={scrollLeft}
className="absolute w-8 h-8 left-0 top-1/2 -translate-y-1/2 z-10 rounded-full p-2"
aria-label="Scroll left"
icon={<ChevronLeft className="w-4 h-4" />}
/>
>
<ChevronLeft className="w-4 h-4" />
</Button>
)}
{showArrows && canScrollRight && (
@@ -155,8 +131,9 @@ export const Row = forwardRef<HTMLDivElement, RowProps>(function Row(
onClick={scrollRight}
className="absolute w-8 h-8 right-0 top-1/2 -translate-y-1/2 z-10 rounded-full p-2"
aria-label="Scroll right"
icon={<ChevronRight className="w-4 h-4" />}
/>
>
<ChevronRight className="w-4 h-4" />
</Button>
)}
<div
@@ -166,14 +143,18 @@ export const Row = forwardRef<HTMLDivElement, RowProps>(function Row(
role="region"
aria-roledescription="carousel"
aria-label="Horizontally scrollable content"
style={{ overscrollBehaviorX: 'contain' }}
onWheel={handleWheel}
onKeyDown={handleKeyDown}
>
<div
className="flex items-stretch min-w-full transition-transform duration-300 ease-out"
style={
{
gap: `${gap}px`,
'--column-width': `calc((100% - ${(getRenderColumns() - 1) * gap}px) / ${getRenderColumns()})`,
'--column-width': `calc((100% - ${(renderColumns - 1) * gap}px) / ${renderColumns})`,
transform: `translateX(-${scrollPosition}px)`,
willChange: 'transform',
} as React.CSSProperties
}
>