diff --git a/dashboard/src/components/ActivityFeed.tsx b/dashboard/src/components/ActivityFeed.tsx index 0ea9a01..4ca686a 100644 --- a/dashboard/src/components/ActivityFeed.tsx +++ b/dashboard/src/components/ActivityFeed.tsx @@ -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) => { @@ -200,9 +201,12 @@ export function ActivityFeed() { {loading && events.length === 0 ? ( Array.from({ length: 5 }).map((_, i) => ) ) : displayedEvents.length === 0 ? ( -
-

No activity yet

-
+ ) : ( displayedEvents.map(event => ( diff --git a/dashboard/src/components/CopyButton.tsx b/dashboard/src/components/CopyButton.tsx new file mode 100644 index 0000000..d5d0a65 --- /dev/null +++ b/dashboard/src/components/CopyButton.tsx @@ -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 ( + + ); +} diff --git a/dashboard/src/components/EmptyState.tsx b/dashboard/src/components/EmptyState.tsx new file mode 100644 index 0000000..879a5e9 --- /dev/null +++ b/dashboard/src/components/EmptyState.tsx @@ -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 ( +
+ +

{title}

+

{description}

+ {children} + {action && ( + + )} +
+ ); +} diff --git a/dashboard/src/components/EventCard.tsx b/dashboard/src/components/EventCard.tsx index ce75d8d..c48a613 100644 --- a/dashboard/src/components/EventCard.tsx +++ b/dashboard/src/components/EventCard.tsx @@ -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'; @@ -204,7 +205,10 @@ function ExpandedCard({ event, onClick }: { event: BlockchainEvent; onClick?: (e
Event ID
-
{event.eventId}
+
+ {event.eventId} + +
diff --git a/dashboard/src/components/EventExplorerCard.tsx b/dashboard/src/components/EventExplorerCard.tsx index c837c72..badd7d0 100644 --- a/dashboard/src/components/EventExplorerCard.tsx +++ b/dashboard/src/components/EventExplorerCard.tsx @@ -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 = { contract: 'event-explorer__badge--blue', @@ -109,13 +110,21 @@ export function EventExplorerCard({
- {event.ledger.toLocaleString()} +
+ {event.ledger.toLocaleString()} + +
- - {event.txHash ? shortenAddress(event.txHash) : '—'} - + {event.txHash ? ( +
+ {shortenAddress(event.txHash)} + +
+ ) : ( + + )}
); diff --git a/dashboard/src/components/EventListPanel.tsx b/dashboard/src/components/EventListPanel.tsx index 8610c55..2d6f75f 100644 --- a/dashboard/src/components/EventListPanel.tsx +++ b/dashboard/src/components/EventListPanel.tsx @@ -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 ( -
-

No events match the current filters.

-
+ ); } diff --git a/dashboard/src/components/EventRow.test.tsx b/dashboard/src/components/EventRow.test.tsx new file mode 100644 index 0000000..4939ec1 --- /dev/null +++ b/dashboard/src/components/EventRow.test.tsx @@ -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(); + + expect(screen.getByRole('button', { name: /copy notification id/i })).toBeInTheDocument(); + }); + + it('copies the notification ID and shows success feedback', async () => { + render(); + + 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(); + }); +}); diff --git a/dashboard/src/components/EventRow.tsx b/dashboard/src/components/EventRow.tsx index e2bd171..f0a7101 100644 --- a/dashboard/src/components/EventRow.tsx +++ b/dashboard/src/components/EventRow.tsx @@ -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; @@ -31,6 +32,7 @@ export const EventRow = memo(function EventRow({ event }: EventRowProps) { Received: {formatTimestamp(event.receivedAt)} +
Value: {event.value} diff --git a/dashboard/src/components/NotificationDetailsDrawer.test.tsx b/dashboard/src/components/NotificationDetailsDrawer.test.tsx index c461856..2af0a9b 100644 --- a/dashboard/src/components/NotificationDetailsDrawer.test.tsx +++ b/dashboard/src/components/NotificationDetailsDrawer.test.tsx @@ -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( + + ); + + 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( + + ); + + 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( + + ); + + // 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(); + }); }); diff --git a/dashboard/src/components/NotificationDetailsDrawer.tsx b/dashboard/src/components/NotificationDetailsDrawer.tsx index 397f608..967680c 100644 --- a/dashboard/src/components/NotificationDetailsDrawer.tsx +++ b/dashboard/src/components/NotificationDetailsDrawer.tsx @@ -194,6 +194,21 @@ export function NotificationDetailsDrawer({

Blockchain Context

+ {notification.relatedNotificationId && ( +
+ Notification ID + + {shorten(notification.relatedNotificationId, 10, 6)} + + +
+ )}
Ledger {notification.ledger.toLocaleString()} diff --git a/dashboard/src/components/NotificationTimelineView.test.tsx b/dashboard/src/components/NotificationTimelineView.test.tsx index e518dbc..35f9a0c 100644 --- a/dashboard/src/components/NotificationTimelineView.test.tsx +++ b/dashboard/src/components/NotificationTimelineView.test.tsx @@ -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(); + }); + }); }); diff --git a/dashboard/src/components/NotificationTimelineView.tsx b/dashboard/src/components/NotificationTimelineView.tsx index 8062e80..b9cbce5 100644 --- a/dashboard/src/components/NotificationTimelineView.tsx +++ b/dashboard/src/components/NotificationTimelineView.tsx @@ -2,6 +2,8 @@ import { useState, useCallback } from 'react'; import type { NotificationTimeline, TimelineEntry, TimelineStatus } from '../types/timeline'; import { fetchTimeline } from '../services/timelineApi'; import { formatTimestamp } from '../utils/formatTime'; +import { EmptyState } from './EmptyState'; +import { CopyButton } from './CopyButton'; // ─── status helpers ────────────────────────────────────────────────────────── @@ -147,12 +149,16 @@ export function NotificationTimelineView() { {/* Empty state — searched but no entries */} {!loading && timeline && timeline.entries.length === 0 && ( -
-

No history entries found for notification #{timeline.notificationId}.

+

Current status: {STATUS_LABEL[overallStatus!] ?? overallStatus}

-
+ )} {/* Timeline entries */} @@ -162,6 +168,7 @@ export function NotificationTimelineView() { Notification #{timeline.notificationId} +
+ )}
); diff --git a/dashboard/src/components/WebhookDeliveryChart.tsx b/dashboard/src/components/WebhookDeliveryChart.tsx index 9e893bc..2502122 100644 --- a/dashboard/src/components/WebhookDeliveryChart.tsx +++ b/dashboard/src/components/WebhookDeliveryChart.tsx @@ -1,5 +1,6 @@ import { memo, useMemo } from 'react'; import type { WebhookMetricBucket } from '../types/webhook'; +import { EmptyState } from './EmptyState'; interface WebhookDeliveryChartProps { buckets: WebhookMetricBucket[]; @@ -147,9 +148,12 @@ export const WebhookDeliveryChart = memo(function WebhookDeliveryChart({ })} ) : buckets.length === 0 ? ( -
- No data for the selected range -
+ ) : ( ) ) : pageItems.length === 0 ? (
- - No failed deliveries for the selected filters + +
) : ( diff --git a/dashboard/src/hooks/useCopyId.ts b/dashboard/src/hooks/useCopyId.ts new file mode 100644 index 0000000..45aa8a8 --- /dev/null +++ b/dashboard/src/hooks/useCopyId.ts @@ -0,0 +1,26 @@ +import { useState, useCallback, useRef } from 'react'; +import { copyTextToClipboard } from '../utils/clipboard'; + +/** + * Returns a `copy` function and a boolean `copied` state that resets after + * `resetMs` milliseconds (default 1800). + */ +export function useCopyId(resetMs = 1800) { + const [copied, setCopied] = useState(false); + const timerRef = useRef | null>(null); + + const copy = useCallback( + async (text: string): Promise => { + const ok = await copyTextToClipboard(text); + if (ok) { + setCopied(true); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setCopied(false), resetMs); + } + return ok; + }, + [resetMs] + ); + + return { copy, copied }; +} diff --git a/dashboard/src/index.css b/dashboard/src/index.css index 9ef3df5..a0e6f79 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -637,6 +637,11 @@ body { padding: 16px; } + .app-tabs__btn { + padding: 8px 12px; + font-size: 0.88rem; + } + .app__topbar { flex-direction: column; align-items: stretch; @@ -2450,6 +2455,21 @@ body.event-explorer--resizing { min-width: 0; } +/* Row inside a cell that pairs a value with a copy button */ +.event-explorer__id-cell { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.event-explorer__id-cell span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + .event-explorer__contract { margin: 0; font-family: 'Courier New', Courier, monospace; @@ -2517,18 +2537,189 @@ body.event-explorer--resizing { outline: none; } -.event-explorer__empty-state { +/* ── Shared CopyButton component ────────────────────────────────────────── */ + +.copy-btn { + display: inline-flex; + align-items: center; + gap: 4px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + background: rgba(255, 255, 255, 0.04); + color: #9aa0a6; + font-size: 0.78rem; + font-family: inherit; + padding: 3px 8px; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; + white-space: nowrap; + flex-shrink: 0; + vertical-align: middle; +} + +.copy-btn:hover, +.copy-btn:focus-visible { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.22); + color: #e8eaed; + outline: none; +} + +.copy-btn--copied { + border-color: rgba(52, 211, 153, 0.4); + background: rgba(52, 211, 153, 0.08); + color: #34d399; +} + +.copy-btn--xs { + font-size: 0.72rem; + padding: 2px 6px; + border-radius: 4px; + gap: 3px; +} + +.copy-btn__icon { + font-size: 0.85em; + line-height: 1; +} + +/* In xs variant hide the text label to save space */ +.copy-btn--xs .copy-btn__label { + display: none; +} + +/* Light theme */ +[data-theme="light"] .copy-btn { + border-color: rgba(0, 0, 0, 0.14); + background: rgba(0, 0, 0, 0.03); + color: #6b7280; +} + +[data-theme="light"] .copy-btn:hover { + background: rgba(0, 0, 0, 0.06); + color: #1f2937; +} + +[data-theme="light"] .copy-btn--copied { + border-color: rgba(16, 185, 129, 0.4); + background: rgba(16, 185, 129, 0.08); + color: #059669; +} + +/* ── Shared empty state component ──────────────────────────────────────── */ + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; text-align: center; - padding: 48px 24px; - border: 1px dashed rgba(255, 255, 255, 0.16); + padding: 56px 32px; + border: 1px dashed rgba(255, 255, 255, 0.14); border-radius: 16px; background: rgba(255, 255, 255, 0.02); - color: #cbd5e1; + color: #9aa0a6; + gap: 4px; } -.event-explorer__empty-state h2 { - margin: 0 0 10px; - font-size: 1.4rem; +.empty-state__icon { + font-size: 2.5rem; + line-height: 1; + margin-bottom: 12px; + display: block; + /* Subtle lift so the icon doesn't compete with text */ + filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3)); +} + +.empty-state__title { + margin: 0 0 8px; + font-size: 1.25rem; + font-weight: 600; + color: #e8eaed; + letter-spacing: -0.01em; +} + +.empty-state__description { + margin: 0 0 20px; + font-size: 0.92rem; + line-height: 1.55; + max-width: 380px; + color: #9aa0a6; +} + +.empty-state__action { + padding: 9px 22px; + border-radius: 8px; + border: 1px solid rgba(26, 115, 232, 0.5); + background: rgba(26, 115, 232, 0.12); + color: #60a5fa; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; + font-family: inherit; +} + +.empty-state__action:hover, +.empty-state__action:focus-visible { + background: rgba(26, 115, 232, 0.22); + border-color: rgba(26, 115, 232, 0.8); + color: #93c5fd; + outline: none; +} + +/* Compact variant — less padding, used inside panels/tables */ +.empty-state--compact { + padding: 36px 24px; + border-radius: 12px; +} + +/* Inline variant — no border, minimal chrome, used inside chart areas */ +.empty-state--inline { + padding: 24px 16px; + border: none; + background: transparent; + gap: 2px; +} + +.empty-state--inline .empty-state__icon { + font-size: 1.75rem; + margin-bottom: 8px; +} + +.empty-state--inline .empty-state__title { + font-size: 1rem; +} + +/* Light theme overrides */ +[data-theme="light"] .empty-state { + border-color: rgba(0, 0, 0, 0.14); + background: rgba(0, 0, 0, 0.02); + color: #6b7280; +} + +[data-theme="light"] .empty-state__title { + color: #1f2937; +} + +[data-theme="light"] .empty-state__description { + color: #6b7280; +} + +[data-theme="light"] .empty-state__action { + border-color: rgba(26, 115, 232, 0.4); + background: rgba(26, 115, 232, 0.08); + color: #1a73e8; +} + +[data-theme="light"] .empty-state__action:hover { + background: rgba(26, 115, 232, 0.16); + color: #1558b0; +} + +/* Keep the old class as an alias so any remaining direct usages don't break */ +.event-explorer__empty-state { + /* delegated to .empty-state — kept for backward compat */ } /* ─── Contract Status ────────────────────────────────────────── */ @@ -3006,14 +3197,13 @@ body.event-explorer--resizing { } .timeline-view__empty { - text-align: center; - padding: 32px 0; - color: #9aa0a6; + /* uses .empty-state--compact */ } .timeline-view__empty-sub { - margin-top: 8px; - font-size: 0.9rem; + margin-top: 4px; + font-size: 0.88rem; + color: #9aa0a6; } .timeline-view__summary { @@ -3149,14 +3339,17 @@ body.event-explorer--resizing { /* ── App tab navigation ─────────────────────────────────────────────────── */ .app-tabs { display: flex; + flex-wrap: wrap; gap: 4px; - padding: 0 0 0 0; + padding: 0; border-bottom: 1px solid rgba(255,255,255,0.08); margin-bottom: 8px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; } .app-tabs__btn { - padding: 10px 20px; + padding: 10px 16px; background: none; border: none; border-bottom: 2px solid transparent; @@ -3164,6 +3357,8 @@ body.event-explorer--resizing { font-size: 0.95rem; cursor: pointer; margin-bottom: -1px; + white-space: nowrap; + flex-shrink: 0; } .app-tabs__btn--active, @@ -3372,12 +3567,7 @@ body.event-explorer--resizing { } .activity-feed__empty { - text-align: center; - padding: 48px 24px; - color: #9aa0a6; - border: 1px dashed rgba(255, 255, 255, 0.16); - border-radius: 12px; - background: rgba(255, 255, 255, 0.02); + /* uses .empty-state--compact */ } .activity-feed__load-more { @@ -3884,14 +4074,8 @@ body.event-explorer--resizing { } .webhook-delivery-chart__empty { - display: flex; - align-items: center; - justify-content: center; - height: 120px; - color: #9aa0a6; - font-size: 0.9rem; - border: 1px dashed rgba(255, 255, 255, 0.1); - border-radius: 10px; + /* uses .empty-state--inline */ + min-height: 120px; } /* ── Failed deliveries table ─────────────────────────────────────── */ @@ -4118,10 +4302,7 @@ body.event-explorer--resizing { /* Empty state */ .webhook-failed-table__empty { - text-align: center; - padding: 40px 24px; - color: #9aa0a6; - font-size: 0.9rem; + /* delegated to .empty-state */ } /* Skeleton rows */ @@ -4240,12 +4421,55 @@ body.event-explorer--resizing { .webhook-failed-table__pagination { flex-direction: column; align-items: flex-start; + gap: 8px; + } + + /* Stack table rows as labeled definition list on mobile */ + .webhook-failed-table thead { + display: none; + } + + .webhook-failed-table, + .webhook-failed-table tbody, + .webhook-failed-table__row, + .webhook-failed-table__detail-row { + display: block; + width: 100%; + } + + .webhook-failed-table__row { + padding: 14px 16px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + border-bottom: none; + } + + .webhook-failed-table__cell { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8px; + padding: 4px 0; + } + + .webhook-failed-table__cell[data-label]::before { + content: attr(data-label); + color: #9aa0a6; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + flex-shrink: 0; + } + + .webhook-failed-table__cell--url { + max-width: 100%; + word-break: break-all; } } @media (max-width: 420px) { .webhook-summary-cards { grid-template-columns: 1fr; + } } /* ── Export table: action cell ──────────────────────────────────────────── */ @@ -4841,19 +5065,15 @@ body.event-explorer--resizing { } .notif-search-page__empty { - text-align: center; - padding: 64px 24px; - color: #9aa0a6; + /* delegated to .empty-state */ } .notif-search-page__empty h2 { - margin: 0 0 8px; - font-size: 1.25rem; - color: #e8eaed; + /* delegated to .empty-state__title */ } .notif-search-page__empty p { - margin: 0; + /* delegated to .empty-state__description */ } .notif-search-results { diff --git a/dashboard/src/pages/EventExplorerPage.tsx b/dashboard/src/pages/EventExplorerPage.tsx index f656f10..c83c0e5 100644 --- a/dashboard/src/pages/EventExplorerPage.tsx +++ b/dashboard/src/pages/EventExplorerPage.tsx @@ -8,6 +8,7 @@ import { PaginationControls } from '../components/PaginationControls'; import { NotificationDetailsDrawer } from '../components/NotificationDetailsDrawer'; import { IndexingHealthPanel } from '../components/IndexingHealthPanel'; import { NotificationHealthPanel } from '../components/NotificationHealthPanel'; +import { EmptyState } from '../components/EmptyState'; import { useEventFilters, useEventLoadingState, useFilteredEvents } from '../hooks/useEventSelectors'; import { useEventStore } from '../store/eventStore'; import { fetchEvents, fetchStatus, type ContractStatus } from '../services/eventsApi'; @@ -50,6 +51,12 @@ export function EventExplorerPage() { const setEvents = useEventStore((state) => state.setEvents); const setLoading = useEventStore((state) => state.setLoading); const setError = useEventStore((state) => state.setError); + const setSearch = useEventStore((state) => state.setSearch); + const setContractFilter = useEventStore((state) => state.setContractFilter); + const setEventTypeFilter = useEventStore((state) => state.setEventTypeFilter); + const setStatusFilter = useEventStore((state) => state.setStatusFilter); + const setDateFrom = useEventStore((state) => state.setDateFrom); + const setDateTo = useEventStore((state) => state.setDateTo); // Re-fetch whenever lastFetchedAt is reset to 0 (via invalidateEvents()) so // that a successful blockchain status-change transaction is reflected on the // next render cycle without requiring a full hard refresh. @@ -183,6 +190,16 @@ export function EventExplorerPage() { const fromIndex = filteredEvents.length === 0 ? 0 : (page - 1) * limit + 1; const toIndex = Math.min(filteredEvents.length, page * limit); + const handleClearFilters = useCallback(() => { + setSearch(''); + setContractFilter(''); + setEventTypeFilter(''); + setStatusFilter(''); + setDateFrom(''); + setDateTo(''); + setPage(1); + }, [setSearch, setContractFilter, setEventTypeFilter, setStatusFilter, setDateFrom, setDateTo]); + const handleRetry = useCallback(async () => { setLoading(true); setError(null); @@ -274,13 +291,13 @@ export function EventExplorerPage() { contractStatuses={contractStatuses} /> ) : ( -
-

No events found

-

- Update the search, event type, or contract filter to uncover matching Soroban - contract events. -

- + + /> )} 0 ? ( ) : ( -
-

No export records found

-

- Try modifying your search query or status filter to locate matching exports. -

-
+ { setSearch(''); setStatusFilter('all'); } } : undefined} + /> )} {/* ── Pagination ───────────────────────────────────────────── */} diff --git a/dashboard/src/pages/NotificationSearchPage.test.tsx b/dashboard/src/pages/NotificationSearchPage.test.tsx index 008d1ae..f22624f 100644 --- a/dashboard/src/pages/NotificationSearchPage.test.tsx +++ b/dashboard/src/pages/NotificationSearchPage.test.tsx @@ -274,3 +274,95 @@ describe('searchNotifications query params', () => { expect(document.querySelector('.notif-result-card__status')).toHaveTextContent('PENDING'); }); }); + +describe('NotificationResultCard copy notification ID', () => { + beforeEach(() => { + mockedSearch.mockReset(); + jest.useFakeTimers(); + Object.assign(navigator, { + clipboard: { + writeText: jest.fn().mockResolvedValue(undefined), + }, + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders the notification ID with a copy button in result cards', async () => { + mockedSearch.mockResolvedValue(mockResult); + render(); + + // Trigger a search + fireEvent.change(screen.getByLabelText(/free-text search/i), { + target: { value: 'test' }, + }); + + await act(async () => { + jest.advanceTimersByTime(300); + }); + + await waitFor(() => { + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + // Verify the notification ID label and value are present + expect(screen.getByText('Notification ID')).toBeInTheDocument(); + + // Verify a CopyButton with the correct aria-label is present + const copyBtn = screen.getByRole('button', { name: /copy notification id/i }); + expect(copyBtn).toBeInTheDocument(); + }); + + it('copies notification ID to clipboard when copy button is clicked', async () => { + mockedSearch.mockResolvedValue(mockResult); + render(); + + fireEvent.change(screen.getByLabelText(/free-text search/i), { + target: { value: 'test' }, + }); + + await act(async () => { + jest.advanceTimersByTime(300); + }); + + await waitFor(() => { + expect(screen.getByText('Notification ID')).toBeInTheDocument(); + }); + + const copyBtn = screen.getByRole('button', { name: /copy notification id/i }); + await act(async () => { + fireEvent.click(copyBtn); + }); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('1'); + }); + + it('shows success feedback after copying notification ID', async () => { + mockedSearch.mockResolvedValue(mockResult); + render(); + + fireEvent.change(screen.getByLabelText(/free-text search/i), { + target: { value: 'test' }, + }); + + await act(async () => { + jest.advanceTimersByTime(300); + }); + + await waitFor(() => { + expect(screen.getByText('Notification ID')).toBeInTheDocument(); + }); + + const copyBtn = screen.getByRole('button', { name: /copy notification id/i }); + await act(async () => { + fireEvent.click(copyBtn); + }); + + // After clicking, the CopyButton should show "Copied" feedback + await waitFor(() => { + expect(screen.getByRole('button', { name: /notification id copied/i })).toBeInTheDocument(); + }); + }); +}); diff --git a/dashboard/src/pages/NotificationSearchPage.tsx b/dashboard/src/pages/NotificationSearchPage.tsx index bf0655c..b0b5b6f 100644 --- a/dashboard/src/pages/NotificationSearchPage.tsx +++ b/dashboard/src/pages/NotificationSearchPage.tsx @@ -2,7 +2,8 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { NotificationSearchSkeleton } from '../components/NotificationSearchSkeleton'; import { getEventsApiBaseUrl } from '../config/eventsApiUrl'; import { useDebounce } from '../hooks/useDebounce'; -import { getEventsApiBaseUrl } from '../config/eventsApiUrl'; +import { EmptyState } from '../components/EmptyState'; +import { CopyButton } from '../components/CopyButton'; import { searchNotifications, type NotificationSearchResult, @@ -281,17 +282,20 @@ export function NotificationSearchPage() { )} {!loading && !error && !hasParams && ( -
-

Start searching

-

Choose a type, delivery status, date range, or enter a query to find notifications.

-
+ )} {!loading && !error && hasParams && response?.results.length === 0 && ( -
-

No results found

-

Try different keywords or clear filters to broaden the search.

-
+ )} {!loading && !error && response && response.results.length > 0 && ( @@ -356,28 +360,45 @@ function NotificationResultCard({ result }: { result: NotificationSearchResult }
+
Notification ID
+
+ {result.id} + +
{result.eventId && ( <>
Event ID
-
{result.eventId}
+
+ {result.eventId} + +
)} {result.txHash && ( <>
Tx Hash
-
{result.txHash}
+
+ {result.txHash} + +
)} {result.contractAddress && ( <>
Contract
-
{result.contractAddress}
+
+ {result.contractAddress} + +
)} {result.targetRecipient && ( <>
Recipient
-
{result.targetRecipient}
+
+ {result.targetRecipient} + +
)}
Created
diff --git a/dashboard/src/pages/TemplatesPage.tsx b/dashboard/src/pages/TemplatesPage.tsx index 10b23ff..dfc1f1c 100644 --- a/dashboard/src/pages/TemplatesPage.tsx +++ b/dashboard/src/pages/TemplatesPage.tsx @@ -5,6 +5,7 @@ import { UpdateNotificationTemplateInput } from '../types/notificationTemplate'; import { templatesApi } from '../services/templatesApi'; +import { EmptyState } from '../components/EmptyState'; type ViewMode = 'list' | 'create' | 'edit' | 'preview'; @@ -257,7 +258,12 @@ export function TemplatesPage() { ))} {templates.length === 0 && ( -

No templates yet. Create your first template!

+ )}