From 3f1f820dbe63e56d2cbc31681b15f7333f2539f1 Mon Sep 17 00:00:00 2001 From: tobitaiwo16 Date: Mon, 28 Sep 2026 18:23:56 +0100 Subject: [PATCH] feat: virtualize Event Explorer table rows Window the Event Explorer table body so only visible rows are mounted, and repair the table and row card. --- .../src/components/EventExplorerCard.tsx | 59 +++-- .../src/components/EventExplorerTable.tsx | 218 ++++++++++++++++-- ...EventExplorerTable.virtualization.test.tsx | 62 +++++ dashboard/src/index.css | 24 ++ 4 files changed, 323 insertions(+), 40 deletions(-) create mode 100644 dashboard/src/components/EventExplorerTable.virtualization.test.tsx diff --git a/dashboard/src/components/EventExplorerCard.tsx b/dashboard/src/components/EventExplorerCard.tsx index c828896e..5a48fccf 100644 --- a/dashboard/src/components/EventExplorerCard.tsx +++ b/dashboard/src/components/EventExplorerCard.tsx @@ -1,13 +1,12 @@ -import { memo, useMemo } from 'react'; +import { memo, useCallback } from 'react'; +import type { KeyboardEvent } from 'react'; import type { BlockchainEvent } from '../types/event'; import type { ContractStatus } from '../services/eventsApi'; import { formatTimestamp, parseToDate } from '../utils/formatTime'; -import { formatTimestamp } from '../utils/formatTime'; -import { CopyButton } from './CopyButton'; - import { getEventKindClass, getEventKindLabel } from '../utils/eventTypeMapping'; +import { CopyButton } from './CopyButton'; -function shortenAddress(address: string) { +function shortenAddress(address: string): string { if (address.length <= 14) { return address; } @@ -15,29 +14,47 @@ function shortenAddress(address: string) { return `${address.slice(0, 6)}...${address.slice(-4)}`; } -interface EventExplorerCardProps { +export interface EventExplorerCardProps { event: BlockchainEvent; - onCopyContract: (contractAddress: string) => void; - isCopied: boolean; + onCopyContract?: (contractAddress: string) => void; + isCopied?: boolean; onSelect?: (event: BlockchainEvent) => void; - contractStatuses: ContractStatus[]; contractStatuses?: ContractStatus[]; } -export function EventExplorerCard({ +export const EventExplorerCard = memo(function EventExplorerCard({ event, onCopyContract, - isCopied, + isCopied = false, onSelect, contractStatuses = [], }: EventExplorerCardProps) { - const contractStatus = contractStatuses.find((c) => c.address === event.contractAddress); + const contractStatus = contractStatuses.find( + (status) => status.address === event.contractAddress, + ); const isPaused = contractStatus?.paused ?? false; const label = event.eventName ?? event.type; const badgeClass = getEventKindClass(event.type); const kindLabel = getEventKindLabel(event.type); const receivedAt = parseToDate(event.receivedAt); + const handleCopyClick = useCallback(() => { + onCopyContract?.(event.contractAddress); + }, [onCopyContract, event.contractAddress]); + + const handleKeyDown = useCallback( + (keyboardEvent: KeyboardEvent) => { + if (!onSelect) { + return; + } + if (keyboardEvent.key === 'Enter' || keyboardEvent.key === ' ') { + keyboardEvent.preventDefault(); + onSelect(event); + } + }, + [onSelect, event], + ); + return (
onSelect(event) : undefined} - onKeyDown={ - onSelect - ? (e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onSelect(event); - } - } - : undefined - } + onKeyDown={onSelect ? handleKeyDown : undefined} aria-label={onSelect ? `View details for ${label} notification` : undefined} >

- {shortenedContract} + {shortenAddress(event.contractAddress)}

+ {isPaused && ( + PAUSED + )}
diff --git a/dashboard/src/components/EventExplorerTable.tsx b/dashboard/src/components/EventExplorerTable.tsx index 71a9bc38..aabd5500 100644 --- a/dashboard/src/components/EventExplorerTable.tsx +++ b/dashboard/src/components/EventExplorerTable.tsx @@ -1,16 +1,37 @@ -import { useState, useCallback, memo, useMemo } from 'react'; +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type UIEvent, +} from 'react'; import type { BlockchainEvent } from '../types/event'; +import type { ContractStatus } from '../services/eventsApi'; import { EventExplorerCard } from './EventExplorerCard'; const STORAGE_KEY = 'notify-chain-event-table-widths'; +const COMPACT_LAYOUT_QUERY = '(max-width: 640px)'; +const DEFAULT_VIEWPORT_HEIGHT = 600; export const DEFAULT_COLUMN_WIDTHS = [220, 160, 110, 180, 100, 160] as const; export const MIN_COLUMN_WIDTH = 80; +/** Fixed row height (px) assumed by the virtualizer — mirrors `.event-explorer__row`. */ +export const ROW_HEIGHT = 112; +/** Extra rows rendered above and below the viewport to keep scrolling smooth. */ +export const OVERSCAN = 6; const COLUMN_LABELS = ['Contract', 'Event', 'Kind', 'Received', 'Ledger', 'Transaction'] as const; -interface EventExplorerTableProps { +export interface EventExplorerTableProps { events: BlockchainEvent[]; + onSelectEvent?: (event: BlockchainEvent) => void; + contractStatuses?: ContractStatus[]; +} + +export function widthsToGridTemplate(widths: readonly number[]): string { + return widths.map((width) => `${width}px`).join(' '); } export function loadColumnWidths(): number[] { @@ -30,7 +51,18 @@ export function loadColumnWidths(): number[] { }); } catch { return [...DEFAULT_COLUMN_WIDTHS]; -async function syncCopyText(text: string) { + } +} + +export function persistColumnWidths(widths: readonly number[]): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(widths)); + } catch { + // Persistence is best-effort; ignore storage-quota / privacy-mode failures. + } +} + +async function syncCopyText(text: string): Promise { if (navigator.clipboard?.writeText) { return navigator.clipboard.writeText(text); } @@ -51,21 +83,141 @@ async function syncCopyText(text: string) { } } -export const EventExplorerTable = memo(function EventExplorerTable({ events }: EventExplorerTableProps) { +/** + * The fixed-height windowing maths below assumes the desktop grid row. On the + * stacked card layout (`max-width: 640px`) rows are variable height, so the + * table falls back to rendering the plain list there. + */ +function useIsCompactLayout(): boolean { + const [isCompact, setIsCompact] = useState(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return false; + } + return window.matchMedia(COMPACT_LAYOUT_QUERY).matches; + }); + + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return; + } + const query = window.matchMedia(COMPACT_LAYOUT_QUERY); + const handleChange = (mediaEvent: MediaQueryListEvent) => { + setIsCompact(mediaEvent.matches); + }; + query.addEventListener('change', handleChange); + return () => query.removeEventListener('change', handleChange); + }, []); + + return isCompact; +} + +export const EventExplorerTable = memo(function EventExplorerTable({ + events, + onSelectEvent, + contractStatuses, +}: EventExplorerTableProps) { + const scrollRef = useRef(null); + const resizeRef = useRef<{ index: number; startX: number; startWidths: number[] } | null>(null); + + const [columnWidths, setColumnWidths] = useState(() => loadColumnWidths()); const [copiedAddress, setCopiedAddress] = useState(null); + const [scrollTop, setScrollTop] = useState(0); + const [viewportHeight, setViewportHeight] = useState(DEFAULT_VIEWPORT_HEIGHT); + const isCompactLayout = useIsCompactLayout(); + // ── Copy contract address ──────────────────────────────────────────────── const handleCopyContract = useCallback(async (address: string) => { try { await syncCopyText(address); setCopiedAddress(address); - window.setTimeout(() => setCopiedAddress(null), 1800); + window.setTimeout(() => { + setCopiedAddress((current) => (current === address ? null : current)); + }, 1800); } catch { setCopiedAddress(null); } }, []); - const isCopied = useMemo(() => (address: string) => copiedAddress === address, [copiedAddress]); + const isCopied = useCallback( + (address: string) => copiedAddress === address, + [copiedAddress], + ); + // ── Column resizing (persisted) ────────────────────────────────────────── + const handleResizeMove = useCallback((mouseEvent: MouseEvent) => { + const state = resizeRef.current; + if (!state) return; + const delta = mouseEvent.clientX - state.startX; + const next = state.startWidths.map((width, index) => + index === state.index ? Math.max(MIN_COLUMN_WIDTH, width + delta) : width, + ); + setColumnWidths(next); + persistColumnWidths(next); + }, []); + + const stopResize = useCallback(() => { + if (!resizeRef.current) return; + resizeRef.current = null; + document.body.classList.remove('event-explorer--resizing'); + window.removeEventListener('mousemove', handleResizeMove); + window.removeEventListener('mouseup', stopResize); + }, [handleResizeMove]); + + const startResize = useCallback( + (index: number, startX: number) => { + resizeRef.current = { index, startX, startWidths: columnWidths }; + document.body.classList.add('event-explorer--resizing'); + window.addEventListener('mousemove', handleResizeMove); + window.addEventListener('mouseup', stopResize); + }, + [columnWidths, handleResizeMove, stopResize], + ); + + useEffect( + () => () => { + document.body.classList.remove('event-explorer--resizing'); + window.removeEventListener('mousemove', handleResizeMove); + window.removeEventListener('mouseup', stopResize); + }, + [handleResizeMove, stopResize], + ); + + // ── Windowed rendering ─────────────────────────────────────────────────── + useEffect(() => { + setScrollTop(0); + if (scrollRef.current) { + scrollRef.current.scrollTop = 0; + } + }, [events]); + + const handleScroll = useCallback((scrollEvent: UIEvent) => { + setScrollTop(scrollEvent.currentTarget.scrollTop); + }, []); + + const attachScrollRef = useCallback((node: HTMLDivElement | null) => { + scrollRef.current = node; + if (node) { + setViewportHeight(node.clientHeight || DEFAULT_VIEWPORT_HEIGHT); + } + }, []); + + const windowState = useMemo(() => { + const totalHeight = events.length * ROW_HEIGHT; + if (isCompactLayout) { + return { startIndex: 0, endIndex: events.length, totalHeight }; + } + const visibleCount = Math.ceil(viewportHeight / ROW_HEIGHT) + OVERSCAN; + const maxScrollTop = Math.max(0, totalHeight - viewportHeight); + const clampedScrollTop = Math.min(scrollTop, maxScrollTop); + const startIndex = Math.max(0, Math.floor(clampedScrollTop / ROW_HEIGHT) - OVERSCAN); + const endIndex = Math.min(events.length, startIndex + visibleCount + OVERSCAN); + return { startIndex, endIndex, totalHeight }; + }, [events, isCompactLayout, scrollTop, viewportHeight]); + + const isWindowed = !isCompactLayout; + const visibleEvents = isWindowed + ? events.slice(windowState.startIndex, windowState.endIndex) + : events; const gridTemplate = widthsToGridTemplate(columnWidths); return ( @@ -94,18 +246,54 @@ export const EventExplorerTable = memo(function EventExplorerTable({ events }: E
- {events.map((event) => ( - - ))} + {isWindowed ? ( +
+ {visibleEvents.map((event, index) => { + const position = windowState.startIndex + index; + return ( +
+ +
+ ); + })} +
+ ) : ( + visibleEvents.map((event) => ( + + )) + )}
); diff --git a/dashboard/src/components/EventExplorerTable.virtualization.test.tsx b/dashboard/src/components/EventExplorerTable.virtualization.test.tsx new file mode 100644 index 00000000..8e281b99 --- /dev/null +++ b/dashboard/src/components/EventExplorerTable.virtualization.test.tsx @@ -0,0 +1,62 @@ +import { fireEvent, render } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from '@jest/globals'; +import { EventExplorerTable, ROW_HEIGHT } from './EventExplorerTable'; +import { generateMockEvents } from '../utils/eventData'; +import type { BlockchainEvent } from '../types/event'; + +const EVENT_COUNT = 5000; +const SCROLL_TARGET_INDEX = 50; + +function renderTable(events: BlockchainEvent[]) { + const utils = render(); + const body = utils.container.querySelector('.event-explorer__table-body'); + if (!body) { + throw new Error('EventExplorerTable did not render its scrollable body.'); + } + return { ...utils, body: body as HTMLElement }; +} + +function renderedEventIds(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('.event-explorer__row')).map( + (row) => row.getAttribute('data-event-id') ?? '', + ); +} + +describe('EventExplorerTable virtualization', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('renders only a window of rows for a 5,000 event dataset', () => { + const { container } = renderTable(generateMockEvents(EVENT_COUNT)); + + // jest.setup.cjs reports a 600px clientHeight for every jsdom element. + const rendered = renderedEventIds(container); + expect(rendered.length).toBeGreaterThan(0); + expect(rendered.length).toBeLessThan(100); + expect(rendered.length).toBeLessThan(EVENT_COUNT); + + const spacer = container.querySelector('.event-explorer__table-spacer') as HTMLElement | null; + expect(spacer).not.toBeNull(); + expect(spacer?.style.height).toBe(`${EVENT_COUNT * ROW_HEIGHT}px`); + }); + + it('moves the rendered window when the body scrolls', () => { + const { container, body } = renderTable(generateMockEvents(EVENT_COUNT)); + + const initialIds = renderedEventIds(container); + expect(initialIds[0]).toBe('event-0'); + + fireEvent.scroll(body, { target: { scrollTop: SCROLL_TARGET_INDEX * ROW_HEIGHT } }); + + const scrolledIds = renderedEventIds(container); + expect(scrolledIds.length).toBeGreaterThan(0); + expect(scrolledIds[0]).not.toBe(initialIds[0]); + expect(scrolledIds).toContain(`event-${SCROLL_TARGET_INDEX}`); + }); + + it('renders every row when the dataset already fits the viewport', () => { + const { container } = renderTable(generateMockEvents(5)); + expect(renderedEventIds(container)).toHaveLength(5); + }); +}); diff --git a/dashboard/src/index.css b/dashboard/src/index.css index bdf0eed6..5e053781 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -2952,6 +2952,30 @@ body.event-explorer--resizing { --event-explorer-columns: 1.5fr 1fr 0.8fr 1.3fr 0.8fr 1fr; } +/* Windowed rendering (issue #652): the body becomes the scroll viewport and + only the visible slice of rows is mounted into the spacer. */ +.event-explorer__table-body--virtualized { + display: block; + position: relative; + overflow-y: auto; + overscroll-behavior: contain; + max-height: min(70vh, 640px); +} + +.event-explorer__table-spacer { + position: relative; + width: 100%; +} + +.event-explorer__virtual-row { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 112px; + overflow: hidden; +} + .event-explorer__row { display: grid; grid-template-columns: var(--event-explorer-columns, 1.5fr 1fr 0.8fr 1.3fr 0.8fr 1fr);