From 30e73ee44d0d6b1f6475d612688908cee85e35e1 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Tue, 16 Sep 2025 13:00:25 +1000 Subject: [PATCH] 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 --- .../HomeNew/CustomReportSection.tsx | 338 ++++++++++++++++++ .../components/interfaces/HomeNew/Home.tsx | 8 + .../interfaces/HomeNew/SnippetDropdown.tsx | 108 ++++++ packages/ui-patterns/src/Row/Row.utils.ts | 38 ++ packages/ui-patterns/src/Row/index.tsx | 145 ++++---- 5 files changed, 555 insertions(+), 82 deletions(-) create mode 100644 apps/studio/components/interfaces/HomeNew/CustomReportSection.tsx create mode 100644 apps/studio/components/interfaces/HomeNew/SnippetDropdown.tsx create mode 100644 packages/ui-patterns/src/Row/Row.utils.ts diff --git a/apps/studio/components/interfaces/HomeNew/CustomReportSection.tsx b/apps/studio/components/interfaces/HomeNew/CustomReportSection.tsx new file mode 100644 index 0000000000..19f479ada6 --- /dev/null +++ b/apps/studio/components/interfaces/HomeNew/CustomReportSection.tsx @@ -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( + 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; chartConfig?: Partial } + ) => { + 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 ( +
+
+

At a glance

+ {canUpdateReport || canCreateReport ? ( + }> + Add block + + } + side="bottom" + align="end" + autoFocus + /> + ) : null} +
+
+ {(() => { + if (layout.length === 0) { + return ( +
+ {canUpdateReport || canCreateReport ? ( + }> + Add your first chart + + } + side="bottom" + align="center" + autoFocus + /> + ) : ( +

No charts set up yet in report

+ )} +
+ ) + } + return ( + + String(x.id))} + strategy={rectSortingStrategy} + > + + {layout.map((item) => ( + +
+ handleUpdateChart(item.id, config)} + /> +
+
+ ))} +
+
+
+ ) + })()} +
+
+ ) +} + +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 ( +
+ {children} +
+ ) +} diff --git a/apps/studio/components/interfaces/HomeNew/Home.tsx b/apps/studio/components/interfaces/HomeNew/Home.tsx index e2e85de77f..6dd5069028 100644 --- a/apps/studio/components/interfaces/HomeNew/Home.tsx +++ b/apps/studio/components/interfaces/HomeNew/Home.tsx @@ -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 = () => { ) } + if (id === 'custom-report') { + return ( + + + + ) + } })} diff --git a/apps/studio/components/interfaces/HomeNew/SnippetDropdown.tsx b/apps/studio/components/interfaces/HomeNew/SnippetDropdown.tsx new file mode 100644 index 0000000000..b9f8fc56ab --- /dev/null +++ b/apps/studio/components/interfaces/HomeNew/SnippetDropdown.tsx @@ -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 + +export const SnippetDropdown = ({ + projectRef, + trigger, + side = 'bottom', + align = 'end', + className, + autoFocus = false, + onSelect, +}: SnippetDropdownProps) => { + const [snippetSearch, setSnippetSearch] = useState('') + const scrollRootRef = useRef(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 ( + + {trigger} + + + + + {isLoading ? ( + Loading… + ) : snippets.length === 0 ? ( + No snippets found + ) : null} + + {snippets.map((snippet) => ( + onSelect({ id: snippet.id, name: snippet.name })} + > + {snippet.name} + + ))} +
+ + + + + + ) +} diff --git a/packages/ui-patterns/src/Row/Row.utils.ts b/packages/ui-patterns/src/Row/Row.utils.ts new file mode 100644 index 0000000000..4abd879e8c --- /dev/null +++ b/packages/ui-patterns/src/Row/Row.utils.ts @@ -0,0 +1,38 @@ +import { useLayoutEffect, useState } from 'react' + +export const useMeasuredWidth = (ref: React.RefObject) => { + const [measuredWidth, setMeasuredWidth] = useState(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 +} diff --git a/packages/ui-patterns/src/Row/index.tsx b/packages/ui-patterns/src/Row/index.tsx index c28abb6e11..76a8922ec8 100644 --- a/packages/ui-patterns/src/Row/index.tsx +++ b/packages/ui-patterns/src/Row/index.tsx @@ -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 { - /** 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(function Row( ref ) { const containerRef = useRef(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(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 = (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 = (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 (
@@ -145,8 +120,9 @@ export const Row = forwardRef(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={} - /> + > + + )} {showArrows && canScrollRight && ( @@ -155,8 +131,9 @@ export const Row = forwardRef(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={} - /> + > + + )}
(function Row( role="region" aria-roledescription="carousel" aria-label="Horizontally scrollable content" + style={{ overscrollBehaviorX: 'contain' }} + onWheel={handleWheel} + onKeyDown={handleKeyDown} >