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
32 changes: 32 additions & 0 deletions dashboard/src/components/EventsListSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
interface EventsListSkeletonProps {
rows?: number;
}

function SkeletonLine({ width }: { width: string }) {
return <span className="skeleton-block skeleton-block--inline" style={{ width }} aria-hidden="true" />;
}

export function EventsListSkeleton({ rows = 8 }: EventsListSkeletonProps) {
return (
<div className="event-panel event-panel--skeleton" aria-busy="true" aria-label="Loading events">
<div className="event-list" role="status">
{Array.from({ length: rows }).map((_, index) => (
<article key={index} className="event-row event-row--skeleton">
<div className="event-row__primary">
<SkeletonLine width="140px" />
<SkeletonLine width="90px" />
</div>
<div className="event-row__meta">
<SkeletonLine width="120px" />
<SkeletonLine width="160px" />
</div>
<div className="event-row__details">
<SkeletonLine width="80px" />
<SkeletonLine width="110px" />
</div>
</article>
))}
</div>
</div>
);
}
1 change: 1 addition & 0 deletions dashboard/src/components/NotificationHealthPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export function NotificationHealthPanel(props: { healthUrl: string; pollInterval

setLastUpdated(Date.now());
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return;
if (err instanceof Error && err.name === 'AbortError') return;
setError(err instanceof Error ? err.message : String(err));
} finally {
Expand Down
50 changes: 50 additions & 0 deletions dashboard/src/components/NotificationSearchSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
interface NotificationSearchSkeletonProps {
cards?: number;
}

function SkeletonLine({ width, height = '12px' }: { width: string; height?: string }) {
return (
<span
className="skeleton-block skeleton-block--inline"
style={{ width, height }}
aria-hidden="true"
/>
);
}

function NotificationResultCardSkeleton() {
return (
<article className="notif-result-card notif-result-card--skeleton" aria-hidden="true">
<div className="notif-result-card__header">
<SkeletonLine width="72px" height="20px" />
<SkeletonLine width="88px" height="20px" />
<SkeletonLine width="64px" height="20px" />
</div>
<div className="notif-result-card__fields notif-result-card__fields--skeleton">
<SkeletonLine width="70px" />
<SkeletonLine width="55%" />
<SkeletonLine width="60px" />
<SkeletonLine width="70%" />
<SkeletonLine width="70px" />
<SkeletonLine width="45%" />
<SkeletonLine width="55px" />
<SkeletonLine width="40%" />
</div>
</article>
);
}

export function NotificationSearchSkeleton({ cards = 4 }: NotificationSearchSkeletonProps) {
return (
<div
className="notif-search-results notif-search-results--skeleton"
aria-busy="true"
aria-label="Searching notifications"
role="status"
>
{Array.from({ length: cards }).map((_, index) => (
<NotificationResultCardSkeleton key={index} />
))}
</div>
);
}
56 changes: 56 additions & 0 deletions dashboard/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,34 @@ body {
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}

.event-row--skeleton {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 88px;
justify-content: center;
}

.event-row--skeleton .event-row__primary,
.event-row--skeleton .event-row__meta,
.event-row--skeleton .event-row__details {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}

.event-panel--skeleton .event-list {
overflow: hidden;
}

.skeleton-block--inline {
display: inline-block;
height: 12px;
border-radius: 4px;
flex-shrink: 0;
}

.event-row__primary {
display: flex;
justify-content: space-between;
Expand Down Expand Up @@ -4784,6 +4812,34 @@ body {
padding: 14px 16px;
}

.notif-result-card--skeleton {
pointer-events: none;
}

.notif-result-card__fields--skeleton {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px 16px;
align-items: center;
}

.templates-list__items--skeleton {
display: flex;
flex-direction: column;
gap: 12px;
}

.templates-list__item--skeleton {
padding: 0;
border: none;
background: transparent;
}

.templates-list__item--skeleton .skeleton-block--row {
height: 96px;
width: 100%;
}

.notif-result-card__header {
display: flex;
gap: 8px;
Expand Down
80 changes: 80 additions & 0 deletions dashboard/src/pages/EventsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import '@testing-library/jest-dom';
import { render, screen, waitFor, act } from '@testing-library/react';
import { EventsPage } from './EventsPage';
import { useEventStore } from '../store/eventStore';
import { generateMockEvents } from '../utils/eventData';
import { fetchEvents } from '../services/eventsApi';

jest.mock('../services/eventsApi', () => ({
fetchEvents: jest.fn(),
}));

jest.mock('../services/wallet', () => ({
restoreWalletSession: jest.fn(() => Promise.resolve()),
}));

jest.mock('../components/WalletConnectButton', () => ({
WalletConnectButton: () => <div data-testid="wallet-connect" />,
}));

const mockedFetchEvents = fetchEvents as jest.MockedFunction<typeof fetchEvents>;

describe('EventsPage loading skeletons', () => {
beforeEach(() => {
useEventStore.setState({
events: [],
filters: {
search: '',
contractAddress: 'all',
eventType: 'all',
status: 'all',
dateFrom: '',
dateTo: '',
},
isLoading: false,
error: null,
lastFetchedAt: 0,
});
mockedFetchEvents.mockReset();
});

it('shows event list skeletons while loading and hides loading text', async () => {
mockedFetchEvents.mockReturnValue(new Promise(() => {}));

render(<EventsPage />);

await waitFor(() => {
expect(screen.getByLabelText(/loading events/i)).toBeInTheDocument();
});
expect(screen.queryByText(/loading events\.\.\./i)).not.toBeInTheDocument();
expect(screen.getByLabelText(/loading events/i)).toHaveAttribute('aria-busy', 'true');
});

it('replaces skeletons with event content once data is available', async () => {
const events = generateMockEvents(3);
let resolveFetch!: (value: typeof events) => void;
mockedFetchEvents.mockImplementation(
() =>
new Promise((resolve) => {
resolveFetch = resolve;
})
);

render(<EventsPage />);

await waitFor(() => {
expect(screen.getByLabelText(/loading events/i)).toBeInTheDocument();
});

await act(async () => {
resolveFetch(events);
});

await waitFor(() => {
expect(screen.queryByLabelText(/loading events/i)).not.toBeInTheDocument();
});

expect(screen.getAllByRole('article').length).toBeGreaterThan(0);
expect(screen.queryByText(/no events match/i)).not.toBeInTheDocument();
});
});
10 changes: 4 additions & 6 deletions dashboard/src/pages/EventsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { useEffect } from 'react';
import { EventFiltersBar } from '../components/EventFiltersBar';
import { EventListPanel } from '../components/EventListPanel';
import { EventsListSkeleton } from '../components/EventsListSkeleton';
import { WalletConnectButton } from '../components/WalletConnectButton';
import { getEventsApiBaseUrl } from '../config/eventsApiUrl';
import { useEventLoadingState } from '../hooks/useEventSelectors';
import { useEventStore } from '../store/eventStore';
import { fetchEvents } from '../services/eventsApi';
import { generateMockEvents } from '../utils/eventData';
import { restoreWalletSession } from '../services/wallet';

const DEFAULT_EVENT_COUNT = 5000;
const API_URL =
import.meta.env.VITE_EVENTS_API_URL ?? 'http://localhost:8787/api/events';
const API_URL = `${getEventsApiBaseUrl()}/api/events`;
const POLL_INTERVAL_MS = 15_000;

export function EventsPage() {
Expand Down Expand Up @@ -91,14 +92,11 @@ export function EventsPage() {

<EventFiltersBar />

<div aria-live="polite" role="status">
{isLoading && <p className="events-page__status">Loading events...</p>}
</div>
<div aria-live="assertive" role="alert">
{error && <p className="events-page__status events-page__status--warning">{error}</p>}
</div>

<EventListPanel />
{isLoading ? <EventsListSkeleton /> : <EventListPanel />}
</main>
);
}
100 changes: 100 additions & 0 deletions dashboard/src/pages/NotificationSearchPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import '@testing-library/jest-dom';
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
import { NotificationSearchPage } from './NotificationSearchPage';
import { searchNotifications } from '../services/eventsApi';
import type { NotificationSearchResponse } from '../services/eventsApi';

jest.mock('../services/eventsApi', () => ({
searchNotifications: jest.fn(),
}));

const mockedSearch = searchNotifications as jest.MockedFunction<typeof searchNotifications>;

const mockResult: NotificationSearchResponse = {
results: [
{
id: 1,
source: 'scheduled',
eventId: 'evt-abc',
txHash: '0xdeadbeef',
contractAddress: 'CABCDEF',
notificationType: 'email',
targetRecipient: 'user@example.com',
status: 'PENDING',
createdAt: '2026-01-15T12:00:00.000Z',
payload: null,
},
],
total: 1,
limit: 20,
offset: 0,
itemCount: 1,
totalPages: 1,
};

describe('NotificationSearchPage loading skeletons', () => {
beforeEach(() => {
mockedSearch.mockReset();
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

it('shows result-card skeletons while searching and hides Searching text', async () => {
mockedSearch.mockReturnValue(new Promise(() => {}));

render(<NotificationSearchPage />);

fireEvent.change(screen.getByLabelText(/free-text search/i), {
target: { value: 'payment' },
});

await act(async () => {
jest.advanceTimersByTime(300);
});

await waitFor(() => {
expect(screen.getByLabelText(/searching notifications/i)).toBeInTheDocument();
});
expect(screen.queryByText(/searching…/i)).not.toBeInTheDocument();
expect(screen.getByLabelText(/searching notifications/i)).toHaveAttribute('aria-busy', 'true');
});

it('replaces skeletons with result cards once search completes', async () => {
let resolveSearch!: (value: NotificationSearchResponse) => void;
mockedSearch.mockImplementation(
() =>
new Promise((resolve) => {
resolveSearch = resolve;
})
);

render(<NotificationSearchPage />);

fireEvent.change(screen.getByLabelText(/free-text search/i), {
target: { value: 'payment' },
});

await act(async () => {
jest.advanceTimersByTime(300);
});

await waitFor(() => {
expect(screen.getByLabelText(/searching notifications/i)).toBeInTheDocument();
});

await act(async () => {
resolveSearch(mockResult);
});

await waitFor(() => {
expect(screen.queryByLabelText(/searching notifications/i)).not.toBeInTheDocument();
});

expect(screen.getByText(/1 result/i)).toBeInTheDocument();
expect(screen.getByText('evt-abc')).toBeInTheDocument();
expect(document.querySelector('.notif-result-card__status')).toHaveTextContent('PENDING');
});
});
Loading
Loading