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
3 changes: 1 addition & 2 deletions dashboard/reports/wallet-integration.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
{
"generatedAt": "2026-06-26T13:01:40.990Z",
"generatedAt": "2026-06-27T12:47:07.864Z",
"generatedAt": "2026-07-24T23:59:45.120Z",
"total": 3,
"passed": 3,
"failed": 0,
Expand Down
3 changes: 3 additions & 0 deletions dashboard/src/components/ActivityFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,9 +202,12 @@
Array.from({ length: 5 }).map((_, i) => <ActivitySkeleton key={i} />)
) : displayedEvents.length === 0 ? (
<EmptyState
size="compact"
title="No activity yet"
message="Actions and system events will show up here as they happen."
className="empty-state--compact"
icon="📋"
title="No activity yet"

Check failure on line 210 in dashboard/src/components/ActivityFeed.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint, typecheck, test)

No duplicate props allowed
description="System events, notification deliveries, and contract activity will appear here as they occur."
/>
) : (
Expand Down
63 changes: 63 additions & 0 deletions dashboard/src/components/EmptyState.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { EmptyState } from './EmptyState';

expect.extend(toHaveNoViolations);

test('EmptyState has no accessibility violations', async () => {
const { container } = render(
<EmptyState title="No results" message="Try adjusting your filters." />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

test('renders the required message and optional title', () => {
render(<EmptyState title="No events found" message="Update your filters to see results." />);
expect(screen.getByText('No events found')).toBeInTheDocument();
expect(screen.getByText('Update your filters to see results.')).toBeInTheDocument();
});

test('omits the title when none is provided', () => {
render(<EmptyState message="Nothing here yet." />);
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
expect(screen.getByText('Nothing here yet.')).toBeInTheDocument();
});

test('renders an action button and fires its callback on click', () => {
const onClick = jest.fn();
render(
<EmptyState
message="No templates yet."
action={{ label: 'Create Template', onClick }}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Create Template' }));
expect(onClick).toHaveBeenCalledTimes(1);
});

test('omits the action button when none is provided', () => {
render(<EmptyState message="No templates yet." />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});

test('applies the default size class unless overridden', () => {
const { container, rerender } = render(<EmptyState message="No data" />);
expect(container.firstChild).toHaveClass('empty-state--default');

rerender(<EmptyState message="No data" size="compact" />);
expect(container.firstChild).toHaveClass('empty-state--compact');

rerender(<EmptyState message="No data" size="inline" />);
expect(container.firstChild).toHaveClass('empty-state--inline');
});

test('renders a custom icon in place of the default one', () => {
render(<EmptyState message="No data" icon={<span data-testid="custom-icon" />} />);
expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
});

test('exposes a status role so screen readers announce the empty state', () => {
render(<EmptyState message="No data available." />);
expect(screen.getByRole('status')).toHaveTextContent('No data available.');
});
67 changes: 67 additions & 0 deletions dashboard/src/components/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,72 @@
import type { ReactNode } from 'react';

export interface EmptyStateAction {
label: string;
onClick: () => void;
}

export interface EmptyStateProps {
/** Short heading. Omit for compact/inline placements that only need a message. */
title?: string;
/** Helpful message guiding the user on what to do next. */
message: string;
/** Custom icon/illustration. Falls back to a generic empty-tray icon. */
icon?: ReactNode;
/** Optional call-to-action rendered below the message. */
action?: EmptyStateAction;
/**
* Visual density:
* - "default": large dashed card for standalone page/section placeholders.
* - "compact": smaller dashed card for placeholders inside a page section.
* - "inline": no border/background — for placeholders already nested inside
* a bordered container (a panel, card, or table cell).
*/
size?: 'default' | 'compact' | 'inline';
className?: string;
}

function DefaultEmptyIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 13.5 5.5 5h13L21 13.5" />
<path d="M3 13.5V19a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1v-5.5" />
<path d="M3 13.5h5a1 1 0 0 1 1 1 3 3 0 0 0 6 0 1 1 0 0 1 1-1h5" />
</svg>
);
}

/**
* Reusable placeholder for any screen or section with no data to show.
* Pairs an icon with a short title and a helpful message, and optionally
* a call-to-action, so empty screens always guide the user to a next step.
*/
export function EmptyState({
title,
message,
icon,
action,
size = 'default',
className,
}: EmptyStateProps) {
const classes = ['empty-state', `empty-state--${size}`, className].filter(Boolean).join(' ');

return (
<div className={classes} role="status" aria-live="polite">
<div className="empty-state__icon">{icon ?? <DefaultEmptyIcon />}</div>
{title && <h2 className="empty-state__title">{title}</h2>}
<p className="empty-state__message">{message}</p>
{action && (
<button
type="button"
className="empty-state__action button button--secondary"
interface EmptyStateProps {
icon: string;
title: string;
Expand Down
6 changes: 6 additions & 0 deletions dashboard/src/components/EventListPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ export const EventListPanel = memo(function EventListPanel() {

if (events.length === 0) {
return (
<div className="event-panel event-panel--empty">
<EmptyState
size="inline"
message="No events match the current filters. Try widening your search or clearing filters."
/>
</div>
<EmptyState
className="empty-state--compact"
icon="🔍"
Expand Down
5 changes: 5 additions & 0 deletions dashboard/src/components/NotificationTimelineView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ export function NotificationTimelineView() {
{/* Empty state — searched but no entries */}
{!loading && timeline && timeline.entries.length === 0 && (
<EmptyState
size="inline"
title={`No history entries found for notification #${timeline.notificationId}`}
message={`Current status: ${STATUS_LABEL[overallStatus!] ?? overallStatus}`}
/>
className="empty-state--compact"
icon="📭"
title="No history entries"
Expand Down Expand Up @@ -206,6 +210,7 @@ export function NotificationTimelineView() {

{/* Initial empty state — nothing searched yet */}
{!loading && !timeline && !error && (
<EmptyState size="inline" message="Enter a notification ID above to view its delivery history." />
<EmptyState
className="empty-state--compact"
icon="🕐"
Expand Down
6 changes: 6 additions & 0 deletions dashboard/src/components/WebhookDeliveryChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,12 @@ export const WebhookDeliveryChart = memo(function WebhookDeliveryChart({
})}
</svg>
) : buckets.length === 0 ? (
<div className="webhook-delivery-chart__empty">
<EmptyState
size="inline"
message="No delivery data for the selected range. Try a wider date range."
/>
</div>
<EmptyState
className="empty-state--inline"
icon="📊"
Expand Down
4 changes: 4 additions & 0 deletions dashboard/src/components/WebhookFailedTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ export const WebhookFailedTable = memo(function WebhookFailedTable({
Array.from({ length: 5 }).map((_, i) => <SkeletonRow key={i} />)
) : pageItems.length === 0 ? (
<tr>
<td colSpan={6}>
<EmptyState
size="inline"
message="No failed deliveries for the selected filters. Try widening the date range or clearing filters."
<td colSpan={6} style={{ padding: 0, border: 'none' }}>
<EmptyState
className="empty-state--compact"
Expand Down
93 changes: 93 additions & 0 deletions dashboard/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,99 @@ body {
outline-offset: 2px;
}

/* ─── EmptyState (shared component) ──────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: #9aa0a6;
}

.empty-state--default,
.empty-state--compact {
border: 1px dashed rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.02);
}

.empty-state--default {
padding: 48px 24px;
border-radius: 16px;
}

.empty-state--compact {
padding: 28px 20px;
border-radius: 12px;
}

.empty-state--inline {
padding: 16px;
}

.empty-state__icon {
display: flex;
color: #6b7280;
margin-bottom: 12px;
}

.empty-state__icon svg {
width: 40px;
height: 40px;
}

.empty-state--compact .empty-state__icon svg,
.empty-state--inline .empty-state__icon svg {
width: 28px;
height: 28px;
}

.empty-state__title {
margin: 0 0 6px;
font-size: 1.25rem;
font-weight: 600;
color: #e8eaed;
}

.empty-state--compact .empty-state__title,
.empty-state--inline .empty-state__title {
font-size: 1rem;
}

.empty-state__message {
margin: 0;
max-width: 480px;
font-size: 0.95rem;
line-height: 1.5;
}

.empty-state--compact .empty-state__message,
.empty-state--inline .empty-state__message {
font-size: 0.9rem;
}

.empty-state__action {
margin-top: 16px;
}

[data-theme="light"] .empty-state {
color: #4b5563;
}

[data-theme="light"] .empty-state--default,
[data-theme="light"] .empty-state--compact {
border-color: rgba(0, 0, 0, 0.16);
background: rgba(0, 0, 0, 0.02);
}

[data-theme="light"] .empty-state__icon {
color: #9ca3af;
}

[data-theme="light"] .empty-state__title {
color: #1a1a2e;
}

/* Template Preview Styles */
.template-preview {
display: flex;
Expand Down
4 changes: 4 additions & 0 deletions dashboard/src/pages/ExportHistoryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,10 @@ export function ExportHistoryPage() {
{/* ── Table or empty state ─────────────────────────────────── */}
{!isLoading && displayedExports.length > 0 && (
<ExportHistoryTable exports={displayedExports} onDownload={handleDownload} />
) : (
<EmptyState
title="No export records found"
message="Try modifying your search query or status filter to locate matching exports."
)}

{!isLoading && displayedExports.length === 0 && (
Expand Down
12 changes: 12 additions & 0 deletions dashboard/src/pages/NotificationSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type NotificationSearchResponse,
type NotificationSearchParams,
} from '../services/eventsApi';
import { EmptyState } from '../components/EmptyState';
import {
buildNotificationExportBlob,
downloadBlob,
Expand Down Expand Up @@ -422,6 +423,14 @@ export function NotificationSearchPage() {

{!loading && !error && !hasParams && (
<EmptyState
size="compact"
title="Start searching"
message="Enter a query above to find notifications by sender, transaction hash, event ID, or type."
/>
<div className="notif-search-page__empty" role="status">
<h2>Start searching</h2>
<p>Choose a type, delivery status, date range, or enter a query to find notifications.</p>
</div>
icon="🔔"
title="Search notifications"
description="Choose a type, delivery status, date range, or enter a query to find notifications."
Expand All @@ -430,6 +439,9 @@ export function NotificationSearchPage() {

{!loading && !error && hasParams && response?.results.length === 0 && (
<EmptyState
size="compact"
title="No results found"
message="Try different keywords or clear filters to broaden the search."
icon="🕵️"
title="No results found"
description="No notifications match your current filters. Try different keywords or broaden the search."
Expand Down
3 changes: 3 additions & 0 deletions dashboard/src/pages/TemplatesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,10 +259,13 @@
))}
{templates.length === 0 && (
<EmptyState
title="No templates yet"
message="Create a notification template to start sending emails, Discord, Slack, or Telegram alerts."
action={{ label: 'Create Template', onClick: handleCreateClick }}
icon="📝"
title="No templates yet"

Check failure on line 266 in dashboard/src/pages/TemplatesPage.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint, typecheck, test)

No duplicate props allowed
description="Create reusable notification templates for email, Discord, Slack, and more."
action={{ label: 'Create your first template', onClick: handleCreateClick }}

Check failure on line 268 in dashboard/src/pages/TemplatesPage.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint, typecheck, test)

No duplicate props allowed
/>
)}
</div>
Expand Down
Loading