From 4c527c0bdd1dd985c8d666d9189a86dd73bcc686 Mon Sep 17 00:00:00 2001 From: N-thnI Date: Thu, 25 Jun 2026 10:46:24 +0100 Subject: [PATCH] feat: add responsive notification details drawer with metadata and copy actions --- .../src/components/EventExplorerCard.tsx | 27 +- .../src/components/EventExplorerTable.tsx | 4 +- .../NotificationDetailsDrawer.test.tsx | 111 +++++++ .../components/NotificationDetailsDrawer.tsx | 273 ++++++++++++++++++ dashboard/src/index.css | 252 ++++++++++++++++ dashboard/src/pages/EventExplorerPage.tsx | 19 +- dashboard/src/utils/clipboard.ts | 27 ++ 7 files changed, 708 insertions(+), 5 deletions(-) create mode 100644 dashboard/src/components/NotificationDetailsDrawer.test.tsx create mode 100644 dashboard/src/components/NotificationDetailsDrawer.tsx create mode 100644 dashboard/src/utils/clipboard.ts diff --git a/dashboard/src/components/EventExplorerCard.tsx b/dashboard/src/components/EventExplorerCard.tsx index 8f4768e..a7b2fff 100644 --- a/dashboard/src/components/EventExplorerCard.tsx +++ b/dashboard/src/components/EventExplorerCard.tsx @@ -33,15 +33,33 @@ interface EventExplorerCardProps { event: BlockchainEvent; onCopyContract: (contractAddress: string) => void; isCopied: boolean; + onSelect?: (event: BlockchainEvent) => void; } -export function EventExplorerCard({ event, onCopyContract, isCopied }: EventExplorerCardProps) { +export function EventExplorerCard({ event, onCopyContract, isCopied, onSelect }: EventExplorerCardProps) { const label = event.eventName ?? event.type; const badgeClass = getEventKindClass(event.type); const kindLabel = getEventKindLabel(event.type); return ( -
+
onSelect(event) : undefined} + onKeyDown={ + onSelect + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(event); + } + } + : undefined + } + aria-label={onSelect ? `View details for ${label} notification` : undefined} + >

@@ -50,7 +68,10 @@ export function EventExplorerCard({ event, onCopyContract, isCopied }: EventExpl

diff --git a/dashboard/src/components/NotificationDetailsDrawer.test.tsx b/dashboard/src/components/NotificationDetailsDrawer.test.tsx new file mode 100644 index 0000000..c461856 --- /dev/null +++ b/dashboard/src/components/NotificationDetailsDrawer.test.tsx @@ -0,0 +1,111 @@ +import '@testing-library/jest-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { NotificationDetailsDrawer } from './NotificationDetailsDrawer'; +import type { BlockchainEvent } from '../types/event'; + +function makeNotification(overrides: Partial = {}): BlockchainEvent { + return { + eventId: 'evt-1', + contractAddress: 'GABCDEF', + eventName: 'TaskCreated', + ledger: 123, + type: 'contract', + topic: [], + value: '42', + txHash: 'TXHASH123', + receivedAt: Date.now(), + ...overrides, + }; +} + +describe('NotificationDetailsDrawer', () => { + it('mounts and renders core notification metadata', async () => { + const notification = makeNotification(); + const onClose = jest.fn(); + + render( + + ); + + expect(screen.getByRole('dialog', { name: 'Notification details' })).toBeInTheDocument(); + expect(screen.getByText('Sender Details')).toBeInTheDocument(); + expect(screen.getByText('Blockchain Context')).toBeInTheDocument(); + expect(screen.getByText('Notification Status History')).toBeInTheDocument(); + + expect(screen.getByText('Ledger')).toBeInTheDocument(); + expect(screen.getByText('123')).toBeInTheDocument(); + expect(screen.getByText('TXHASH123')).toBeInTheDocument(); + }); + + it('calls onClose when the close button is clicked', () => { + const notification = makeNotification(); + const onClose = jest.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Close drawer' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('renders status history from metadata and guards clipboard exceptions', async () => { + const notification = makeNotification({ contractAddress: 'GABCDEF', txHash: 'TXHASH123' }); + const onClose = jest.fn(); + + Object.assign(navigator, { + clipboard: { + writeText: jest.fn().mockRejectedValue(new Error('Denied')), + }, + }); + + render( + ({ + sender: { address: 'GABCDEF', metadata: { note: 'unit-test' } }, + statusHistory: [ + { label: 'Queued', timestampMs: 1, detail: 'Enqueued for delivery' }, + { label: 'Delivered', timestampMs: 2, detail: 'Sent successfully' }, + ], + })} + /> + ); + + expect(await screen.findByText('Queued')).toBeInTheDocument(); + expect(await screen.findByText('Delivered')).toBeInTheDocument(); + expect(await screen.findByText('unit-test')).toBeInTheDocument(); + + const txCopyButtons = screen.getAllByRole('button', { name: 'Copy' }); + fireEvent.click(txCopyButtons[txCopyButtons.length - 1]); + expect(await screen.findByText('Copy failed')).toBeInTheDocument(); + }); + + it('shows an error fallback when metadata fetch fails', async () => { + const notification = makeNotification(); + + render( + {}} + fetchMetadata={async () => { + throw new Error('boom'); + }} + /> + ); + + expect(await screen.findByText(/Failed to load details: boom/i)).toBeInTheDocument(); + }); +}); + diff --git a/dashboard/src/components/NotificationDetailsDrawer.tsx b/dashboard/src/components/NotificationDetailsDrawer.tsx new file mode 100644 index 0000000..397f608 --- /dev/null +++ b/dashboard/src/components/NotificationDetailsDrawer.tsx @@ -0,0 +1,273 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { BlockchainEvent } from '../types/event'; +import { formatTimestamp } from '../utils/formatTime'; +import { copyTextToClipboard } from '../utils/clipboard'; + +type FetchState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'success'; data: T } + | { status: 'error'; message: string }; + +export interface SenderDetails { + address: string; + metadata?: Record; +} + +export interface StatusHistoryEntry { + label: string; + timestampMs: number; + detail?: string; +} + +export interface NotificationDetailsMetadata { + sender: SenderDetails; + statusHistory: StatusHistoryEntry[]; +} + +function shorten(value: string, head = 10, tail = 8) { + if (value.length <= head + tail + 3) return value; + return `${value.slice(0, head)}...${value.slice(-tail)}`; +} + +function defaultMetadata(event: BlockchainEvent): NotificationDetailsMetadata { + return { + sender: { + address: event.contractAddress, + metadata: { + kind: event.type, + event: event.eventName ?? event.type, + }, + }, + statusHistory: [ + { + label: 'Observed', + timestampMs: event.receivedAt, + detail: 'Event received by the listener.', + }, + { + label: 'Visible', + timestampMs: Date.now(), + detail: 'Rendered in dashboard.', + }, + ], + }; +} + +export interface NotificationDetailsDrawerProps { + isOpen: boolean; + notification: BlockchainEvent | null; + onClose: () => void; + fetchMetadata?: (event: BlockchainEvent) => Promise; +} + +export function NotificationDetailsDrawer({ + isOpen, + notification, + onClose, + fetchMetadata, +}: NotificationDetailsDrawerProps) { + const [fetchState, setFetchState] = useState>({ + status: 'idle', + }); + const [copyMessage, setCopyMessage] = useState(null); + + const resolvedFetcher = useMemo( + () => fetchMetadata ?? (async (e: BlockchainEvent) => defaultMetadata(e)), + [fetchMetadata] + ); + + useEffect(() => { + if (!isOpen || !notification) { + setFetchState({ status: 'idle' }); + return; + } + + // Fast path: no async metadata provider, keep the drawer snappy and avoid + // unnecessary loading states. + if (!fetchMetadata) { + setFetchState({ status: 'success', data: defaultMetadata(notification) }); + return; + } + + let cancelled = false; + setFetchState({ status: 'loading' }); + resolvedFetcher(notification) + .then((data) => { + if (!cancelled) setFetchState({ status: 'success', data }); + }) + .catch((err) => { + if (cancelled) return; + const message = err instanceof Error ? err.message : String(err); + setFetchState({ status: 'error', message }); + }); + + return () => { + cancelled = true; + }; + }, [isOpen, notification, resolvedFetcher]); + + useEffect(() => { + if (!isOpen) return; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [isOpen, onClose]); + + useEffect(() => { + if (!copyMessage) return; + const id = window.setTimeout(() => setCopyMessage(null), 1500); + return () => window.clearTimeout(id); + }, [copyMessage]); + + const tryCopy = useCallback(async (label: string, value: string) => { + const ok = await copyTextToClipboard(value); + setCopyMessage(ok ? `${label} copied` : `Copy failed`); + }, []); + + if (!isOpen || !notification) { + return null; + } + + const sender = + fetchState.status === 'success' + ? fetchState.data.sender + : { address: notification.contractAddress, metadata: undefined }; + + const statusHistory = + fetchState.status === 'success' + ? fetchState.data.statusHistory + : []; + + const title = notification.eventName ?? notification.type; + + return ( +
+ + ); +} diff --git a/dashboard/src/index.css b/dashboard/src/index.css index f3b7ac7..e71b68a 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -460,6 +460,258 @@ body { padding: 24px 0 12px; } +.drawer { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + grid-template-columns: 1fr auto; +} + +.drawer__backdrop { + grid-column: 1 / -1; + grid-row: 1 / -1; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(2px); +} + +.drawer__panel { + grid-column: 2; + grid-row: 1; + width: min(92vw, 420px); + height: 100%; + background: #0b0d12; + border-left: 1px solid rgba(255, 255, 255, 0.08); + padding: 18px 16px 24px; + overflow: auto; + box-shadow: -12px 0 40px rgba(0, 0, 0, 0.55); + animation: drawer-slide-in 160ms ease-out; +} + +@keyframes drawer-slide-in { + from { transform: translateX(12px); opacity: 0.75; } + to { transform: translateX(0); opacity: 1; } +} + +.drawer__header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + padding-bottom: 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.drawer__eyebrow { + margin: 0 0 6px; + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #60a5fa; +} + +.drawer__title { + margin: 0; + font-size: 1.1rem; +} + +.drawer__close { + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.05); + color: inherit; + border-radius: 10px; + width: 38px; + height: 38px; + cursor: pointer; + font-size: 1.35rem; + line-height: 1; +} + +.drawer__close:hover, +.drawer__close:focus-visible { + border-color: rgba(255, 255, 255, 0.22); + background: rgba(255, 255, 255, 0.1); + outline: none; +} + +.drawer__toast { + margin-top: 12px; + padding: 10px 12px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 12px; + background: rgba(255, 255, 255, 0.06); + color: #e2e8f0; + font-size: 0.9rem; +} + +.drawer__section { + margin-top: 18px; + display: grid; + gap: 10px; +} + +.drawer__section-title { + margin: 0; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #9aa0a6; +} + +.drawer__row { + display: grid; + grid-template-columns: 90px 1fr auto; + gap: 10px; + align-items: center; + padding: 10px 12px; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); +} + +.drawer__label { + font-size: 0.78rem; + color: #9aa0a6; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.drawer__value { + font-family: 'Courier New', Courier, monospace; + font-size: 0.9rem; + color: #e2e8f0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.drawer__action { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + background: rgba(255, 255, 255, 0.05); + color: inherit; + padding: 8px 10px; + font-weight: 600; + cursor: pointer; +} + +.drawer__action:hover, +.drawer__action:focus-visible { + border-color: rgba(255, 255, 255, 0.22); + background: rgba(255, 255, 255, 0.1); + outline: none; +} + +.drawer__meta { + margin: 0; + display: grid; + gap: 8px; +} + +.drawer__meta-row { + display: grid; + grid-template-columns: 90px 1fr; + gap: 10px; + padding: 10px 12px; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); +} + +.drawer__meta-row dt { + margin: 0; + font-size: 0.78rem; + color: #9aa0a6; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.drawer__meta-row dd { + margin: 0; + color: #e2e8f0; + font-size: 0.9rem; +} + +.drawer__timeline { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 12px; +} + +.drawer__timeline-item { + display: grid; + grid-template-columns: 12px 1fr; + gap: 12px; + align-items: flex-start; +} + +.drawer__timeline-dot { + margin-top: 6px; + width: 10px; + height: 10px; + border-radius: 50%; + background: rgba(96, 165, 250, 0.9); + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.18); +} + +.drawer__timeline-body { + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.02); +} + +.drawer__timeline-title { + font-weight: 650; + margin-bottom: 4px; +} + +.drawer__timeline-time { + color: #9aa0a6; + font-size: 0.85rem; +} + +.drawer__timeline-detail { + margin-top: 6px; + color: #cbd5e1; + font-size: 0.9rem; +} + +.drawer__muted { + margin: 0; + color: #9aa0a6; + font-size: 0.9rem; +} + +.drawer__error { + margin: 0; + color: #f87171; + font-size: 0.9rem; +} + +@media (max-width: 600px) { + .drawer { + grid-template-columns: 1fr; + } + + .drawer__panel { + grid-column: 1; + width: 100%; + border-left: none; + border-top: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px 16px 0 0; + padding-top: 16px; + animation: drawer-slide-up 180ms ease-out; + } + + @keyframes drawer-slide-up { + from { transform: translateY(16px); opacity: 0.75; } + to { transform: translateY(0); opacity: 1; } + } +} + .event-explorer__eyebrow { margin: 0 0 10px; font-size: 0.85rem; diff --git a/dashboard/src/pages/EventExplorerPage.tsx b/dashboard/src/pages/EventExplorerPage.tsx index 724938b..14d590a 100644 --- a/dashboard/src/pages/EventExplorerPage.tsx +++ b/dashboard/src/pages/EventExplorerPage.tsx @@ -4,11 +4,13 @@ import { WalletConnectButton } from '../components/WalletConnectButton'; import { EventExplorerTable } from '../components/EventExplorerTable'; import { EventExplorerSkeleton } from '../components/EventExplorerSkeleton'; import { PaginationControls } from '../components/PaginationControls'; +import { NotificationDetailsDrawer } from '../components/NotificationDetailsDrawer'; import { useEventFilters, useEventLoadingState, useFilteredEvents } from '../hooks/useEventSelectors'; import { useEventStore } from '../store/eventStore'; import { fetchEvents } from '../services/eventsApi'; import { generateMockEvents } from '../utils/eventData'; import { restoreWalletSession } from '../services/wallet'; +import type { BlockchainEvent } from '../types/event'; const DEFAULT_EVENT_COUNT = 5000; const DEFAULT_LIMIT = 12; @@ -30,6 +32,7 @@ export function EventExplorerPage() { const initialSearch = typeof window !== 'undefined' ? window.location.search : ''; const [page, setPage] = useState(() => parsePageParam(initialSearch)); const [limit, setLimit] = useState(() => parseLimitParam(initialSearch)); + const [selectedNotification, setSelectedNotification] = useState(null); const setEvents = useEventStore((state) => state.setEvents); const setLoading = useEventStore((state) => state.setLoading); @@ -123,6 +126,14 @@ export function EventExplorerPage() { } }, [setError, setEvents, setLoading]); + const handleSelectEvent = useCallback((event: BlockchainEvent) => { + setSelectedNotification(event); + }, []); + + const handleCloseDrawer = useCallback(() => { + setSelectedNotification(null); + }, []); + return (
@@ -161,7 +172,7 @@ export function EventExplorerPage() { {isLoading ? ( ) : currentPageEvents.length > 0 ? ( - + ) : (

No events found

@@ -180,6 +191,12 @@ export function EventExplorerPage() { onPageChange={setPage} onLimitChange={setLimit} /> + +
); } diff --git a/dashboard/src/utils/clipboard.ts b/dashboard/src/utils/clipboard.ts new file mode 100644 index 0000000..1e53658 --- /dev/null +++ b/dashboard/src/utils/clipboard.ts @@ -0,0 +1,27 @@ +export async function copyTextToClipboard(text: string): Promise { + try { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + + if (typeof document === 'undefined') { + return false; + } + + const fallback = document.createElement('textarea'); + fallback.value = text; + fallback.setAttribute('readonly', ''); + fallback.style.position = 'absolute'; + fallback.style.left = '-9999px'; + document.body.appendChild(fallback); + fallback.select(); + + const successful = document.execCommand('copy'); + document.body.removeChild(fallback); + return successful; + } catch { + return false; + } +} +