diff --git a/dashboard/src/components/ActivityFeed.tsx b/dashboard/src/components/ActivityFeed.tsx index 259de616..aca80975 100644 --- a/dashboard/src/components/ActivityFeed.tsx +++ b/dashboard/src/components/ActivityFeed.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { fetchActivityFeed, generateMockActivityEvents } from '../services/activityApi'; import type { ActivityEvent, ActivityType } from '../types/activity'; -import { formatTimestamp } from '../utils/formatTime'; +import { formatRelativeTimestamp, formatTimestamp, parseToDate } from '../utils/formatTime'; import { PaginationControls } from './PaginationControls'; import { useWalletAccountSync } from '../hooks/useWalletAccountSync'; import { EmptyState } from './EmptyState'; @@ -9,14 +9,14 @@ import { EmptyState } from './EmptyState'; // Helper to get icon/color based on activity type const getActivityTypeStyle = (type: ActivityType) => { const styles: Record = { - 'notification_sent': { color: '#34d399', icon: '✓', bg: 'rgba(52, 211, 153, 0.12)' }, - 'notification_failed': { color: '#f87171', icon: '✕', bg: 'rgba(248, 113, 113, 0.12)' }, - 'notification_retried': { color: '#f4b400', icon: '↻', bg: 'rgba(244, 180, 0, 0.12)' }, - 'contract_event_received': { color: '#60a5fa', icon: '📡', bg: 'rgba(96, 165, 250, 0.12)' }, - 'preference_updated': { color: '#a78bfa', icon: '⚙', bg: 'rgba(167, 139, 250, 0.12)' }, - 'template_created': { color: '#38bdf8', icon: '📄', bg: 'rgba(56, 189, 248, 0.12)' }, - 'template_updated': { color: '#22d3ee', icon: '📝', bg: 'rgba(34, 211, 238, 0.12)' }, - 'webhook_received': { color: '#fb923c', icon: '🔗', bg: 'rgba(251, 146, 60, 0.12)' }, + notification_sent: { color: '#34d399', icon: '✓', bg: 'rgba(52, 211, 153, 0.12)' }, + notification_failed: { color: '#f87171', icon: '✕', bg: 'rgba(248, 113, 113, 0.12)' }, + notification_retried: { color: '#f4b400', icon: '↻', bg: 'rgba(244, 180, 0, 0.12)' }, + contract_event_received: { color: '#60a5fa', icon: '📡', bg: 'rgba(96, 165, 250, 0.12)' }, + preference_updated: { color: '#a78bfa', icon: '⚙', bg: 'rgba(167, 139, 250, 0.12)' }, + template_created: { color: '#38bdf8', icon: '📄', bg: 'rgba(56, 189, 248, 0.12)' }, + template_updated: { color: '#22d3ee', icon: '📝', bg: 'rgba(34, 211, 238, 0.12)' }, + webhook_received: { color: '#fb923c', icon: '🔗', bg: 'rgba(251, 146, 60, 0.12)' }, }; return styles[type] || styles['contract_event_received']; }; @@ -24,6 +24,7 @@ const getActivityTypeStyle = (type: ActivityType) => { // Individual activity event card const ActivityEventCard = ({ event }: { event: ActivityEvent }) => { const style = getActivityTypeStyle(event.type); + const timestamp = parseToDate(event.timestamp); return (
{ {event.type.replace(/_/g, ' ')} -

{event.message}

{Object.keys(event.metadata).length > 0 && (
- {Object.entries(event.metadata).map(([key, value]) => ( - value !== undefined && value !== null && ( - - {key}: - {String(value)} - - ) - ))} + {Object.entries(event.metadata).map( + ([key, value]) => + value !== undefined && + value !== null && ( + + {key}: + {String(value)} + + ), + )}
)} @@ -131,8 +138,8 @@ export function ActivityFeed() { const interval = setInterval(() => { const [newEvent] = generateMockActivityEvents(1); - setLiveEvents(prev => [newEvent, ...prev]); - setTotal(prev => prev + 1); + setLiveEvents((prev) => [newEvent, ...prev]); + setTotal((prev) => prev + 1); }, 15000); return () => clearInterval(interval); @@ -209,9 +216,7 @@ export function ActivityFeed() { className="empty-state--compact" /> ) : ( - displayedEvents.map(event => ( - - )) + displayedEvents.map((event) => ) )} diff --git a/dashboard/src/components/EventCard.test.tsx b/dashboard/src/components/EventCard.test.tsx index 43fcdd33..ef546d29 100644 --- a/dashboard/src/components/EventCard.test.tsx +++ b/dashboard/src/components/EventCard.test.tsx @@ -21,9 +21,7 @@ const mockEvent: BlockchainEvent = { } as BlockchainEvent; test('clickable EventCard has no accessibility violations', async () => { - const { container } = render( - {}} /> - ); + const { container } = render( {}} />); const results = await axe(container); expect(results).toHaveNoViolations(); }); @@ -31,7 +29,7 @@ test('clickable EventCard has no accessibility violations', async () => { test('activates on Space key, not just Enter', () => { const onClick = jest.fn(); const { getByRole } = render(); - const card = getByRole('button'); + const card = getByRole('group'); fireEvent.keyDown(card, { key: ' ' }); expect(onClick).toHaveBeenCalledTimes(1); @@ -40,6 +38,42 @@ test('activates on Space key, not just Enter', () => { expect(onClick).toHaveBeenCalledTimes(2); }); +describe('transaction hash copy action', () => { + beforeEach(() => { + Object.assign(navigator, { + clipboard: { + writeText: jest.fn().mockResolvedValue(undefined), + }, + }); + }); + + it('copies the full transaction hash from the compact card', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /copy transaction hash/i })); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(mockEvent.txHash); + expect( + await screen.findByRole('button', { name: /transaction hash copied/i }), + ).toBeInTheDocument(); + }); + + it('keeps the full transaction hash accessible in the expanded card', () => { + render(); + + expect(screen.getByText(mockEvent.txHash)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /copy transaction hash/i })).toBeInTheDocument(); + }); + + it('handles clipboard rejection without showing false success', async () => { + navigator.clipboard.writeText = jest.fn().mockRejectedValue(new Error('Denied')); + render(); + + fireEvent.click(screen.getByRole('button', { name: /copy transaction hash/i })); + + expect( + await screen.findByRole('button', { name: /copy transaction hash/i }), + ).toBeInTheDocument(); describe('EventCard mobile detail layout (#680)', () => { const breakpoints = [375, 390, 414, 600] as const; diff --git a/dashboard/src/components/EventCard.tsx b/dashboard/src/components/EventCard.tsx index e6dcd96c..a290782b 100644 --- a/dashboard/src/components/EventCard.tsx +++ b/dashboard/src/components/EventCard.tsx @@ -1,6 +1,6 @@ import { memo, type KeyboardEvent } from 'react'; import type { BlockchainEvent } from '../types/event'; -import { formatTimestamp, formatTimestampShort } from '../utils/formatTime'; +import { formatRelativeTimestamp, formatTimestamp } from '../utils/formatTime'; import { CopyButton } from './CopyButton'; export type EventCardVariant = 'compact' | 'expanded'; @@ -20,13 +20,7 @@ function shortenAddress(address: string): string { import { getEventBadgeClass } from '../utils/eventTypeMapping'; function SkeletonLine({ width = '100%', height = '14px' }: { width?: string; height?: string }) { - return ( -