Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 29 additions & 24 deletions dashboard/src/components/ActivityFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,30 @@
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';

// Helper to get icon/color based on activity type
const getActivityTypeStyle = (type: ActivityType) => {
const styles: Record<ActivityType, { color: string; icon: string; bg: string }> = {
'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'];
};

// Individual activity event card
const ActivityEventCard = ({ event }: { event: ActivityEvent }) => {
const style = getActivityTypeStyle(event.type);
const timestamp = parseToDate(event.timestamp);
return (
<div
className={`activity-event ${!event.read ? 'activity-event--unread' : ''}`}
Expand All @@ -42,21 +43,27 @@ const ActivityEventCard = ({ event }: { event: ActivityEvent }) => {
<span className="activity-event__type" style={{ color: style.color }}>
{event.type.replace(/_/g, ' ')}
</span>
<time className="activity-event__time" dateTime={new Date(event.timestamp).toISOString()}>
{formatTimestamp(event.timestamp)}
<time
className="activity-event__time"
dateTime={timestamp?.toISOString()}
title={formatTimestamp(event.timestamp)}
>
{formatRelativeTimestamp(event.timestamp)}
</time>
</div>
<p className="activity-event__message">{event.message}</p>
{Object.keys(event.metadata).length > 0 && (
<div className="activity-event__metadata">
{Object.entries(event.metadata).map(([key, value]) => (
value !== undefined && value !== null && (
<span key={key} className="activity-event__metadata-item">
<span className="activity-event__metadata-key">{key}:</span>
<span className="activity-event__metadata-value">{String(value)}</span>
</span>
)
))}
{Object.entries(event.metadata).map(
([key, value]) =>
value !== undefined &&
value !== null && (
<span key={key} className="activity-event__metadata-item">
<span className="activity-event__metadata-key">{key}:</span>
<span className="activity-event__metadata-value">{String(value)}</span>
</span>
),
)}
</div>
)}
</div>
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -209,9 +216,7 @@ export function ActivityFeed() {
className="empty-state--compact"
/>
) : (
displayedEvents.map(event => (
<ActivityEventCard key={event.id} event={event} />
))
displayedEvents.map((event) => <ActivityEventCard key={event.id} event={event} />)
)}
</div>

Expand Down
42 changes: 38 additions & 4 deletions dashboard/src/components/EventCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,15 @@ const mockEvent: BlockchainEvent = {
} as BlockchainEvent;

test('clickable EventCard has no accessibility violations', async () => {
const { container } = render(
<EventCard event={mockEvent} onClick={() => {}} />
);
const { container } = render(<EventCard event={mockEvent} onClick={() => {}} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

test('activates on Space key, not just Enter', () => {
const onClick = jest.fn();
const { getByRole } = render(<EventCard event={mockEvent} onClick={onClick} />);
const card = getByRole('button');
const card = getByRole('group');

fireEvent.keyDown(card, { key: ' ' });
expect(onClick).toHaveBeenCalledTimes(1);
Expand All @@ -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(<EventCard event={mockEvent} />);

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(<EventCard event={mockEvent} variant="expanded" />);

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(<EventCard event={mockEvent} />);

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;

Expand Down
44 changes: 30 additions & 14 deletions dashboard/src/components/EventCard.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 (
<span
className="event-card__skeleton"
style={{ width, height }}
aria-hidden="true"
/>
);
return <span className="event-card__skeleton" style={{ width, height }} aria-hidden="true" />;
}

function LoadingCard({ variant }: { variant: EventCardVariant }) {
Expand Down Expand Up @@ -90,6 +84,13 @@ function handleActivationKey(onClick: (e: BlockchainEvent) => void, event: Block
};
}

function CompactCard({
event,
onClick,
}: {
event: BlockchainEvent;
onClick?: (e: BlockchainEvent) => void;
}) {
function IdValue({ value, label }: { value: string; label: string }) {
return (
<dd className="event-card__id-value" title={value}>
Expand All @@ -109,7 +110,7 @@ function CompactCard({ event, onClick }: { event: BlockchainEvent; onClick?: (e:
className={`event-card event-card--compact${onClick ? ' event-card--clickable' : ''}`}
data-event-id={event.eventId}
onClick={onClick ? () => onClick(event) : undefined}
role={onClick ? 'button' : undefined}
role={onClick ? 'group' : undefined}
tabIndex={onClick ? 0 : undefined}
aria-label={onClick ? `View details for ${displayName} event` : undefined}
onKeyDown={onClick ? handleActivationKey(onClick, event) : undefined}
Expand All @@ -123,22 +124,31 @@ function CompactCard({ event, onClick }: { event: BlockchainEvent; onClick?: (e:
{shortenAddress(event.contractAddress)}
</span>
<span className="event-card__time" title={formatTimestamp(event.receivedAt)}>
{formatTimestampShort(event.receivedAt)}
{formatRelativeTimestamp(event.receivedAt)}
</span>
</div>
<div className="event-card__details">
<span className="event-card__value-preview" title={event.value}>
Value: {event.value}
</span>
{event.txHash && (
<span title={event.txHash}>Tx: {shortenAddress(event.txHash)}</span>
<span title={event.txHash}>
Tx: {shortenAddress(event.txHash)}{' '}
<CopyButton value={event.txHash} label="transaction hash" size="xs" />
</span>
)}
</div>
</Wrapper>
);
}

function ExpandedCard({ event, onClick }: { event: BlockchainEvent; onClick?: (e: BlockchainEvent) => void }) {
function ExpandedCard({
event,
onClick,
}: {
event: BlockchainEvent;
onClick?: (e: BlockchainEvent) => void;
}) {
const displayName = event.eventName ?? event.type;
const badgeClass = getEventBadgeClass(event.eventName);
const Wrapper = onClick ? 'div' : 'article';
Expand All @@ -148,7 +158,7 @@ function ExpandedCard({ event, onClick }: { event: BlockchainEvent; onClick?: (e
className={`event-card event-card--expanded${onClick ? ' event-card--clickable' : ''}`}
data-event-id={event.eventId}
onClick={onClick ? () => onClick(event) : undefined}
role={onClick ? 'button' : undefined}
role={onClick ? 'group' : undefined}
tabIndex={onClick ? 0 : undefined}
aria-label={onClick ? `View details for ${displayName} event` : undefined}
onKeyDown={onClick ? handleActivationKey(onClick, event) : undefined}
Expand All @@ -168,6 +178,10 @@ function ExpandedCard({ event, onClick }: { event: BlockchainEvent; onClick?: (e
{event.txHash && (
<div className="event-card__field">
<dt>Tx Hash</dt>
<dd title={event.txHash}>
{event.txHash}
<CopyButton value={event.txHash} label="transaction hash" size="xs" />
</dd>
<IdValue value={event.txHash} label="tx hash" />
</div>
)}
Expand All @@ -188,7 +202,9 @@ function ExpandedCard({ event, onClick }: { event: BlockchainEvent; onClick?: (e
<dd>
<ul className="event-card__topics">
{event.topic.map((t, i) => (
<li key={i} className="event-card__topic-item">{t}</li>
<li key={i} className="event-card__topic-item">
{t}
</li>
))}
</ul>
</dd>
Expand Down
27 changes: 26 additions & 1 deletion dashboard/src/components/EventRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,31 @@ describe('EventRow notification ID copy action', () => {
fireEvent.click(screen.getByRole('button', { name: /copy notification id/i }));

expect(navigator.clipboard.writeText).toHaveBeenCalledWith('notif-42');
expect(await screen.findByRole('button', { name: /notification id copied/i })).toBeInTheDocument();
expect(
await screen.findByRole('button', { name: /notification id copied/i }),
).toBeInTheDocument();
});

it('copies the full transaction hash and shows success feedback', async () => {
render(<EventRow event={mockEvent} />);

expect(screen.getByTitle(mockEvent.txHash)).toBeInTheDocument();
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('handles transaction hash clipboard failures gracefully', async () => {
navigator.clipboard.writeText = jest.fn().mockRejectedValue(new Error('Denied'));
render(<EventRow event={mockEvent} />);

fireEvent.click(screen.getByRole('button', { name: /copy transaction hash/i }));

expect(
await screen.findByRole('button', { name: /copy transaction hash/i }),
).toBeInTheDocument();
});
});
20 changes: 13 additions & 7 deletions dashboard/src/components/EventRow.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { memo } from 'react';
import type { BlockchainEvent } from '../types/event';
import { formatTimestamp } from '../utils/formatTime';
import { formatRelativeTimestamp, formatTimestamp, parseToDate } from '../utils/formatTime';
import { CopyButton } from './CopyButton';

interface EventRowProps {
Expand All @@ -16,9 +16,14 @@ function shortenAddress(address: string): string {

export const EventRow = memo(function EventRow({ event }: EventRowProps) {
const label = event.eventName ?? event.type;
const receivedAt = parseToDate(event.receivedAt);

return (
<article className="event-row" data-event-id={event.eventId} aria-label={`${label}, ledger ${event.ledger}`}>
<article
className="event-row"
data-event-id={event.eventId}
aria-label={`${label}, ledger ${event.ledger}`}
>
<div className="event-row__primary">
<span className="event-row__name">{label}</span>
<span className="event-row__ledger">Ledger {event.ledger}</span>
Expand All @@ -28,18 +33,19 @@ export const EventRow = memo(function EventRow({ event }: EventRowProps) {
<span className="sr-only">Contract: </span>
{shortenAddress(event.contractAddress)}
</span>
<span>
<time dateTime={receivedAt?.toISOString()} title={formatTimestamp(event.receivedAt)}>
<span className="sr-only">Received: </span>
{formatTimestamp(event.receivedAt)}
</span>
{formatRelativeTimestamp(event.receivedAt)}
</time>
<CopyButton value={event.eventId} label="notification ID" size="xs" />
</div>
<div className="event-row__details">
<span>Value: {event.value}</span>
{event.txHash && (
<span>
<span title={event.txHash}>
<span className="sr-only">Transaction: </span>
Tx: {shortenAddress(event.txHash)}
Tx: {shortenAddress(event.txHash)}{' '}
<CopyButton value={event.txHash} label="transaction hash" size="xs" />
</span>
)}
</div>
Expand Down
32 changes: 31 additions & 1 deletion dashboard/src/utils/formatTime.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { formatTimestamp, formatTimestampShort, parseToDate } from './formatTime';
import {
formatRelativeTimestamp,
formatTimestamp,
formatTimestampShort,
parseToDate,
RELATIVE_TIMESTAMP_THRESHOLD_MS,
} from './formatTime';

// 2024-01-15 14:30:45 UTC
const FIXED_TIMESTAMP_MS = 1705329045000;
Expand Down Expand Up @@ -140,3 +146,27 @@ describe('formatTimestampShort', () => {
expect(formatTimestampShort('invalid date string')).toBe('Unknown time');
});
});

describe('formatRelativeTimestamp', () => {
const now = FIXED_TIMESTAMP_MS;

it('formats recent past timestamps with relative values', () => {
expect(formatRelativeTimestamp(now - 2 * 60 * 1000, now)).toBe('2 minutes ago');
});

it('formats recent future timestamps correctly', () => {
expect(formatRelativeTimestamp(now + 2 * 60 * 1000, now)).toBe('in 2 minutes');
});

it('uses the exact timestamp at the relative threshold', () => {
expect(formatRelativeTimestamp(now - RELATIVE_TIMESTAMP_THRESHOLD_MS, now)).toContain('2024');
});

it('uses "just now" for timestamps less than one second old', () => {
expect(formatRelativeTimestamp(now - 500, now)).toBe('just now');
});

it('returns the fallback for invalid timestamps', () => {
expect(formatRelativeTimestamp('invalid date string', now)).toBe('Unknown time');
});
});
Loading