diff --git a/dashboard/src/components/EventExplorerCard.tsx b/dashboard/src/components/EventExplorerCard.tsx index 3e2857dd..30c863ee 100644 --- a/dashboard/src/components/EventExplorerCard.tsx +++ b/dashboard/src/components/EventExplorerCard.tsx @@ -12,28 +12,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[]; } -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 isUnknown = !isKnownEventType(event.type); + 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} >
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);