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
10 changes: 7 additions & 3 deletions dashboard/src/components/ActivityFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ActivityEvent, ActivityType } from '../types/activity';
import { formatTimestamp } 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) => {
Expand Down Expand Up @@ -200,9 +201,12 @@ export function ActivityFeed() {
{loading && events.length === 0 ? (
Array.from({ length: 5 }).map((_, i) => <ActivitySkeleton key={i} />)
) : displayedEvents.length === 0 ? (
<div className="activity-feed__empty" role="status">
<p>No activity yet</p>
</div>
<EmptyState
className="empty-state--compact"
icon="📋"
title="No activity yet"
description="System events, notification deliveries, and contract activity will appear here as they occur."
/>
) : (
displayedEvents.map(event => (
<ActivityEventCard key={event.id} event={event} />
Expand Down
33 changes: 33 additions & 0 deletions dashboard/src/components/CopyButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useCopyId } from '../hooks/useCopyId';

interface CopyButtonProps {
value: string;
/** Shown in aria-label, e.g. "event ID" → "Copy event ID" */
label: string;
/** Visual size variant */
size?: 'sm' | 'xs';
}

export function CopyButton({ value, label, size = 'sm' }: CopyButtonProps) {
const { copy, copied } = useCopyId();

return (
<button
type="button"
className={`copy-btn copy-btn--${size}${copied ? ' copy-btn--copied' : ''}`}
onClick={(e) => {
e.stopPropagation();
void copy(value);
}}
aria-label={copied ? `${label} copied` : `Copy ${label}`}
title={copied ? 'Copied!' : `Copy ${label}`}
>
{copied ? (
<span className="copy-btn__icon" aria-hidden="true">✓</span>
) : (
<span className="copy-btn__icon" aria-hidden="true">⎘</span>
)}
<span className="copy-btn__label">{copied ? 'Copied' : 'Copy'}</span>
</button>
);
}
45 changes: 45 additions & 0 deletions dashboard/src/components/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { ReactNode } from 'react';

interface EmptyStateProps {
icon: string;
title: string;
description: string;
/** Optional call-to-action button */
action?: {
label: string;
onClick: () => void;
};
/** Extra class for size/context variants */
className?: string;
/** aria role — defaults to "status" */
role?: string;
children?: ReactNode;
}

export function EmptyState({
icon,
title,
description,
action,
className = '',
role = 'status',
children,
}: EmptyStateProps) {
return (
<div className={`empty-state ${className}`} role={role}>
<span className="empty-state__icon" aria-hidden="true">{icon}</span>
<h2 className="empty-state__title">{title}</h2>
<p className="empty-state__description">{description}</p>
{children}
{action && (
<button
type="button"
className="empty-state__action"
onClick={action.onClick}
>
{action.label}
</button>
)}
</div>
);
}
6 changes: 5 additions & 1 deletion dashboard/src/components/EventCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { memo, type KeyboardEvent } from 'react';
import type { BlockchainEvent } from '../types/event';
import { formatTimestamp, formatTimestampShort } from '../utils/formatTime';
import { CopyButton } from './CopyButton';

export type EventCardVariant = 'compact' | 'expanded';

Expand Down Expand Up @@ -204,7 +205,10 @@ function ExpandedCard({ event, onClick }: { event: BlockchainEvent; onClick?: (e

<div className="event-card__field">
<dt>Event ID</dt>
<dd className="event-card__id">{event.eventId}</dd>
<dd className="event-card__id">
{event.eventId}
<CopyButton value={event.eventId} label="event ID" size="xs" />
</dd>
</div>
</dl>
</div>
Expand Down
17 changes: 13 additions & 4 deletions dashboard/src/components/EventExplorerCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { BlockchainEvent } from '../types/event';
import type { ContractStatus } from '../services/eventsApi';
import { formatTimestamp } from '../utils/formatTime';
import { CopyButton } from './CopyButton';

const EVENT_KIND_STYLES: Record<string, string> = {
contract: 'event-explorer__badge--blue',
Expand Down Expand Up @@ -109,13 +110,21 @@ export function EventExplorerCard({
</div>

<div className="event-explorer__cell" data-label="Ledger" role="cell">
<span>{event.ledger.toLocaleString()}</span>
<div className="event-explorer__id-cell">
<span>{event.ledger.toLocaleString()}</span>
<CopyButton value={event.eventId} label="event ID" size="xs" />
</div>
</div>

<div className="event-explorer__cell" data-label="Transaction" role="cell">
<span title={event.txHash ?? 'No transaction hash'}>
{event.txHash ? shortenAddress(event.txHash) : '—'}
</span>
{event.txHash ? (
<div className="event-explorer__id-cell">
<span title={event.txHash}>{shortenAddress(event.txHash)}</span>
<CopyButton value={event.txHash} label="tx hash" size="xs" />
</div>
) : (
<span>—</span>
)}
</div>
</article>
);
Expand Down
12 changes: 8 additions & 4 deletions dashboard/src/components/EventListPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { memo } from 'react';
import { useFilteredEvents } from '../hooks/useEventSelectors';
import { EventList } from './EventList';
import { EmptyState } from './EmptyState';

export const EventListPanel = memo(function EventListPanel() {
const events = useFilteredEvents();

if (events.length === 0) {
if (events.length === 0) {
return (
<div className="event-panel event-panel--empty" role="status" aria-live="polite">
<p>No events match the current filters.</p>
</div>
<EmptyState
className="empty-state--compact"
icon="🔍"
title="No events match"
description="No events match the current filters. Try adjusting or clearing your search."
/>
);
}

Expand Down
40 changes: 40 additions & 0 deletions dashboard/src/components/EventRow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { EventRow } from './EventRow';
import type { BlockchainEvent } from '../types/event';

const mockEvent: BlockchainEvent = {
eventId: 'notif-42',
type: 'TaskCreated',
eventName: 'TaskCreated',
ledger: 12345,
contractAddress: 'GABCDEF1234567890ABCDEF1234567890ABCDEF12',
receivedAt: Date.now(),
value: '100',
txHash: 'abcdef1234567890',
topic: [],
} as BlockchainEvent;

describe('EventRow notification ID copy action', () => {
beforeEach(() => {
Object.assign(navigator, {
clipboard: {
writeText: jest.fn().mockResolvedValue(undefined),
},
});
});

it('renders a copy button for the notification ID', () => {
render(<EventRow event={mockEvent} />);

expect(screen.getByRole('button', { name: /copy notification id/i })).toBeInTheDocument();
});

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

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();
});
});
2 changes: 2 additions & 0 deletions dashboard/src/components/EventRow.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { memo } from 'react';
import type { BlockchainEvent } from '../types/event';
import { formatTimestamp } from '../utils/formatTime';
import { CopyButton } from './CopyButton';

interface EventRowProps {
event: BlockchainEvent;
Expand Down Expand Up @@ -31,6 +32,7 @@ export const EventRow = memo(function EventRow({ event }: EventRowProps) {
<span className="sr-only">Received: </span>
{formatTimestamp(event.receivedAt)}
</span>
<CopyButton value={event.eventId} label="notification ID" size="xs" />
</div>
<div className="event-row__details">
<span>Value: {event.value}</span>
Expand Down
63 changes: 63 additions & 0 deletions dashboard/src/components/NotificationDetailsDrawer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,5 +107,68 @@ describe('NotificationDetailsDrawer', () => {

expect(await screen.findByText(/Failed to load details: boom/i)).toBeInTheDocument();
});

it('renders Notification ID row with copy button when relatedNotificationId is present', () => {
const notification = makeNotification({ relatedNotificationId: 'notif-42' });
const onClose = jest.fn();

render(
<NotificationDetailsDrawer
isOpen={true}
notification={notification}
onClose={onClose}
/>
);

expect(screen.getByText('Notification ID')).toBeInTheDocument();
// The shortened version should be displayed
expect(screen.getByTitle('notif-42')).toBeInTheDocument();
// A copy button for the Notification ID should be present
const copyButtons = screen.getAllByRole('button', { name: 'Copy' });
expect(copyButtons.length).toBeGreaterThanOrEqual(1);
});

it('does not render Notification ID row when relatedNotificationId is absent', () => {
const notification = makeNotification();
const onClose = jest.fn();

render(
<NotificationDetailsDrawer
isOpen={true}
notification={notification}
onClose={onClose}
/>
);

expect(screen.queryByText('Notification ID')).not.toBeInTheDocument();
});

it('copies notification ID to clipboard and shows toast feedback', async () => {
Object.assign(navigator, {
clipboard: {
writeText: jest.fn().mockResolvedValue(undefined),
},
});

const notification = makeNotification({ relatedNotificationId: 'notif-42' });
const onClose = jest.fn();

render(
<NotificationDetailsDrawer
isOpen={true}
notification={notification}
onClose={onClose}
/>
);

// Find the Notification ID row's copy button (first copy button in Blockchain Context)
const notifIdTitle = screen.getByTitle('notif-42');
const notifIdRow = notifIdTitle.closest('.drawer__row')!;
const copyBtn = notifIdRow.querySelector('button')!;
fireEvent.click(copyBtn);

expect(navigator.clipboard.writeText).toHaveBeenCalledWith('notif-42');
expect(await screen.findByText('Notification ID copied')).toBeInTheDocument();
});
});

15 changes: 15 additions & 0 deletions dashboard/src/components/NotificationDetailsDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,21 @@ export function NotificationDetailsDrawer({

<section className="drawer__section">
<h3 className="drawer__section-title">Blockchain Context</h3>
{notification.relatedNotificationId && (
<div className="drawer__row">
<span className="drawer__label">Notification ID</span>
<span className="drawer__value" title={notification.relatedNotificationId}>
{shorten(notification.relatedNotificationId, 10, 6)}
</span>
<button
type="button"
className="drawer__action"
onClick={() => void tryCopy('Notification ID', notification.relatedNotificationId!)}
>
Copy
</button>
</div>
)}
<div className="drawer__row">
<span className="drawer__label">Ledger</span>
<span className="drawer__value">{notification.ledger.toLocaleString()}</span>
Expand Down
58 changes: 58 additions & 0 deletions dashboard/src/components/NotificationTimelineView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,62 @@ describe('NotificationTimelineView', () => {
resolve(MOCK_TIMELINE);
await waitFor(() => expect(screen.getByRole('button', { name: /view timeline/i })).not.toBeDisabled());
});

it('renders a copy button for the notification ID in the timeline summary', async () => {
mockFetch.mockResolvedValue(MOCK_TIMELINE);
renderView();

fireEvent.change(screen.getByLabelText(/notification id/i), { target: { value: '7' } });
fireEvent.submit(screen.getByRole('search'));

await waitFor(() => expect(mockFetch).toHaveBeenCalledWith(7));

// Verify the copy button for notification ID is rendered
const copyBtn = screen.getByRole('button', { name: /copy notification id/i });
expect(copyBtn).toBeInTheDocument();
});

it('copies notification ID to clipboard when copy button is clicked in timeline', async () => {
Object.assign(navigator, {
clipboard: {
writeText: jest.fn().mockResolvedValue(undefined),
},
});

mockFetch.mockResolvedValue(MOCK_TIMELINE);
renderView();

fireEvent.change(screen.getByLabelText(/notification id/i), { target: { value: '7' } });
fireEvent.submit(screen.getByRole('search'));

await waitFor(() => expect(mockFetch).toHaveBeenCalledWith(7));

const copyBtn = screen.getByRole('button', { name: /copy notification id/i });
fireEvent.click(copyBtn);

expect(navigator.clipboard.writeText).toHaveBeenCalledWith('7');
});

it('shows copied feedback after clicking the notification ID copy button', async () => {
Object.assign(navigator, {
clipboard: {
writeText: jest.fn().mockResolvedValue(undefined),
},
});

mockFetch.mockResolvedValue(MOCK_TIMELINE);
renderView();

fireEvent.change(screen.getByLabelText(/notification id/i), { target: { value: '7' } });
fireEvent.submit(screen.getByRole('search'));

await waitFor(() => expect(mockFetch).toHaveBeenCalledWith(7));

const copyBtn = screen.getByRole('button', { name: /copy notification id/i });
fireEvent.click(copyBtn);

await waitFor(() => {
expect(screen.getByRole('button', { name: /notification id copied/i })).toBeInTheDocument();
});
});
});
Loading
Loading