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
28 changes: 27 additions & 1 deletion dashboard/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,35 @@
import { useState } from 'react';
import { EventExplorerPage } from './pages/EventExplorerPage';
import { NotificationTimelineView } from './components/NotificationTimelineView';

type Tab = 'explorer' | 'timeline';

export function App() {
const [tab, setTab] = useState<Tab>('explorer');

return (
<div className="app">
<EventExplorerPage />
<nav className="app-tabs" role="tablist" aria-label="Main navigation">
<button
role="tab"
aria-selected={tab === 'explorer'}
className={`app-tabs__btn${tab === 'explorer' ? ' app-tabs__btn--active' : ''}`}
onClick={() => setTab('explorer')}
>
Event Explorer
</button>
<button
role="tab"
aria-selected={tab === 'timeline'}
className={`app-tabs__btn${tab === 'timeline' ? ' app-tabs__btn--active' : ''}`}
onClick={() => setTab('timeline')}
>
Delivery Timeline
</button>
</nav>

{tab === 'explorer' && <EventExplorerPage />}
{tab === 'timeline' && <NotificationTimelineView />}
</div>
);
}
118 changes: 118 additions & 0 deletions dashboard/src/components/NotificationSearchBar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { render, screen, fireEvent, act } from '@testing-library/react';
import { NotificationSearchBar } from './NotificationSearchBar';
import { useEventStore } from '../store/eventStore';

// Reset store between tests
beforeEach(() => {
useEventStore.setState({
filters: { search: '', contractAddress: 'all', eventType: 'all', status: 'all', dateFrom: '', dateTo: '' },
});
jest.useFakeTimers();
});

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

function getStore() {
return useEventStore.getState();
}

describe('NotificationSearchBar', () => {
it('renders search input and all status buttons', () => {
render(<NotificationSearchBar />);
expect(screen.getByLabelText(/search notifications/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^all$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^unread$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^read$/i })).toBeInTheDocument();
});

it('debounces search input — store not updated immediately', () => {
render(<NotificationSearchBar />);
fireEvent.change(screen.getByLabelText(/search notifications/i), {
target: { value: 'TaskCreated' },
});
// Before debounce fires, store should still be empty
expect(getStore().filters.search).toBe('');
});

it('updates store after debounce delay', () => {
render(<NotificationSearchBar />);
fireEvent.change(screen.getByLabelText(/search notifications/i), {
target: { value: 'TaskCreated' },
});
act(() => jest.advanceTimersByTime(300));
expect(getStore().filters.search).toBe('TaskCreated');
});

it('sets status filter when status button clicked', () => {
render(<NotificationSearchBar />);
fireEvent.click(screen.getByRole('button', { name: /^unread$/i }));
expect(getStore().filters.status).toBe('unread');
expect(screen.getByRole('button', { name: /^unread$/i })).toHaveAttribute('aria-pressed', 'true');
});

it('sets dateFrom and dateTo', () => {
render(<NotificationSearchBar />);
fireEvent.change(screen.getByLabelText(/filter from date/i), {
target: { value: '2026-01-01' },
});
fireEvent.change(screen.getByLabelText(/filter to date/i), {
target: { value: '2026-01-31' },
});
expect(getStore().filters.dateFrom).toBe('2026-01-01');
expect(getStore().filters.dateTo).toBe('2026-01-31');
});

it('shows clear button only when filters are active', () => {
render(<NotificationSearchBar />);
expect(screen.queryByRole('button', { name: /clear/i })).not.toBeInTheDocument();

fireEvent.click(screen.getByRole('button', { name: /^unread$/i }));
expect(screen.getByRole('button', { name: /clear/i })).toBeInTheDocument();
});

it('clear button resets all filters', () => {
render(<NotificationSearchBar />);
fireEvent.click(screen.getByRole('button', { name: /^unread$/i }));
fireEvent.change(screen.getByLabelText(/filter from date/i), {
target: { value: '2026-01-01' },
});

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

act(() => jest.advanceTimersByTime(300));

const f = getStore().filters;
expect(f.status).toBe('all');
expect(f.dateFrom).toBe('');
expect(f.search).toBe('');
});
});

describe('filterEvents with new filter fields', () => {
it('filters by status=unread correctly', () => {
const { filterEvents } = require('../utils/eventData');

Check failure on line 95 in dashboard/src/components/NotificationSearchBar.test.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint, typecheck, test)

Require statement not part of import statement
const events = [
{ eventId: '1', read: false, contractAddress: 'A', eventName: 'X', receivedAt: Date.now(), ledger: 1, type: 'c', topic: [], value: '' },
{ eventId: '2', read: true, contractAddress: 'A', eventName: 'X', receivedAt: Date.now(), ledger: 2, type: 'c', topic: [], value: '' },
];
const result = filterEvents(events, '', 'all', 'all', 'unread', '', '');
expect(result).toHaveLength(1);
expect(result[0].eventId).toBe('1');
});

it('filters by date range', () => {
const { filterEvents } = require('../utils/eventData');

Check failure on line 106 in dashboard/src/components/NotificationSearchBar.test.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint, typecheck, test)

Require statement not part of import statement
const jan1 = new Date('2026-01-01').getTime() + 1000;
const jan15 = new Date('2026-01-15').getTime() + 1000;
const feb1 = new Date('2026-02-01').getTime() + 1000;
const events = [
{ eventId: '1', contractAddress: 'A', eventName: 'X', receivedAt: jan1, ledger: 1, type: 'c', topic: [], value: '' },
{ eventId: '2', contractAddress: 'A', eventName: 'X', receivedAt: jan15, ledger: 2, type: 'c', topic: [], value: '' },
{ eventId: '3', contractAddress: 'A', eventName: 'X', receivedAt: feb1, ledger: 3, type: 'c', topic: [], value: '' },
];
const result = filterEvents(events, '', 'all', 'all', 'all', '2026-01-01', '2026-01-20');
expect(result.map((e: { eventId: string }) => e.eventId)).toEqual(['1', '2']);
});
});
126 changes: 126 additions & 0 deletions dashboard/src/components/NotificationSearchBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { useState, useEffect, memo } from 'react';
import { useEventStore } from '../store/eventStore';
import { useEventFilters } from '../hooks/useEventSelectors';
import { useDebounce } from '../hooks/useDebounce';
import type { NotificationStatus } from '../types/event';

const STATUS_OPTIONS: { value: NotificationStatus; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'unread', label: 'Unread' },
{ value: 'read', label: 'Read' },
];

export const NotificationSearchBar = memo(function NotificationSearchBar() {
const filters = useEventFilters();
const setSearch = useEventStore((s) => s.setSearch);
const setStatusFilter = useEventStore((s) => s.setStatusFilter);
const setDateFrom = useEventStore((s) => s.setDateFrom);
const setDateTo = useEventStore((s) => s.setDateTo);

// Local state for the text input so debounce doesn't block typing
const [inputValue, setInputValue] = useState(filters.search);
const debouncedSearch = useDebounce(inputValue, 250);

useEffect(() => {
setSearch(debouncedSearch);
}, [debouncedSearch, setSearch]);

// Keep local value in sync if store is cleared externally
useEffect(() => {
if (filters.search === '' && inputValue !== '') setInputValue('');
// intentionally only react to store reset, not every keystroke
// eslint-disable-next-line react-hooks/exhaustive-deps

Check failure on line 32 in dashboard/src/components/NotificationSearchBar.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint, typecheck, test)

Definition for rule 'react-hooks/exhaustive-deps' was not found
}, [filters.search]);

return (
<section className="notif-search" aria-label="Notification search and filters">
{/* Text search */}
<div className="notif-search__group notif-search__group--wide">
<label htmlFor="notif-search-input" className="notif-search__label">
Search notifications
</label>
<input
id="notif-search-input"
type="search"
className="notif-search__input"
placeholder="Event name, ID, contract, tx hash…"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
aria-label="Search notifications"
/>
</div>

{/* Status filter */}
<div className="notif-search__group">
<span className="notif-search__label" id="notif-status-label">
Status
</span>
<div
className="notif-search__status-group"
role="group"
aria-labelledby="notif-status-label"
>
{STATUS_OPTIONS.map(({ value, label }) => (
<button
key={value}
type="button"
className={`notif-search__status-btn${filters.status === value ? ' notif-search__status-btn--active' : ''}`}
aria-pressed={filters.status === value}
onClick={() => setStatusFilter(value)}
>
{label}
</button>
))}
</div>
</div>

{/* Date range */}
<div className="notif-search__group">
<label htmlFor="notif-date-from" className="notif-search__label">
From
</label>
<input
id="notif-date-from"
type="date"
className="notif-search__input notif-search__input--date"
value={filters.dateFrom}
max={filters.dateTo || undefined}
onChange={(e) => setDateFrom(e.target.value)}
aria-label="Filter from date"
/>
</div>

<div className="notif-search__group">
<label htmlFor="notif-date-to" className="notif-search__label">
To
</label>
<input
id="notif-date-to"
type="date"
className="notif-search__input notif-search__input--date"
value={filters.dateTo}
min={filters.dateFrom || undefined}
onChange={(e) => setDateTo(e.target.value)}
aria-label="Filter to date"
/>
</div>

{/* Clear all */}
{(inputValue || filters.status !== 'all' || filters.dateFrom || filters.dateTo) && (
<button
type="button"
className="notif-search__clear"
onClick={() => {
setInputValue('');
setStatusFilter('all');
setDateFrom('');
setDateTo('');
}}
aria-label="Clear all filters"
>
Clear filters
</button>
)}
</section>
);
});
112 changes: 112 additions & 0 deletions dashboard/src/components/NotificationTimelineView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { NotificationTimelineView } from './NotificationTimelineView';
import * as timelineApi from '../services/timelineApi';
import type { NotificationTimeline } from '../types/timeline';

jest.mock('../services/timelineApi');
const mockFetch = timelineApi.fetchTimeline as jest.MockedFunction<typeof timelineApi.fetchTimeline>;

const MOCK_TIMELINE: NotificationTimeline = {
notificationId: 7,
status: 'COMPLETED',
retryCount: 1,
maxRetries: 3,
createdAt: '2026-01-01T10:00:00.000Z',
nextRetryAt: null,
lastError: null,
entries: [
{
attempt: 1,
status: 'RETRY',
executionTime: '2026-01-01T10:00:05.000Z',
errorMessage: 'network timeout',
durationMs: 500,
},
{
attempt: 2,
status: 'COMPLETED',
executionTime: '2026-01-01T10:00:15.000Z',
errorMessage: null,
durationMs: 120,
},
],
};

function renderView() {
return render(<NotificationTimelineView />);
}

describe('NotificationTimelineView', () => {
beforeEach(() => jest.clearAllMocks());

it('renders initial empty state with prompt', () => {
renderView();
expect(screen.getByText(/enter a notification id/i)).toBeInTheDocument();
});

it('shows validation error for invalid id', async () => {
renderView();
fireEvent.change(screen.getByLabelText(/notification id/i), { target: { value: '-1' } });
fireEvent.submit(screen.getByRole('search'));
expect(await screen.findByRole('alert')).toHaveTextContent(/valid notification id/i);
expect(mockFetch).not.toHaveBeenCalled();
});

it('fetches and renders timeline entries in chronological order', 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));

const items = screen.getAllByRole('listitem');
// entries should be chronological: RETRY first, COMPLETED second
expect(items[0]).toHaveTextContent(/retrying/i);
expect(items[1]).toHaveTextContent(/delivered/i);
});

it('shows error message from failed entries', async () => {
mockFetch.mockResolvedValue(MOCK_TIMELINE);
renderView();
fireEvent.change(screen.getByLabelText(/notification id/i), { target: { value: '7' } });
fireEvent.submit(screen.getByRole('search'));

await waitFor(() => expect(screen.getByText(/network timeout/i)).toBeInTheDocument());
});

it('shows empty state when no entries returned', async () => {
mockFetch.mockResolvedValue({ ...MOCK_TIMELINE, entries: [] });
renderView();
fireEvent.change(screen.getByLabelText(/notification id/i), { target: { value: '7' } });
fireEvent.submit(screen.getByRole('search'));

await waitFor(() =>
expect(screen.getByText(/no history entries found/i)).toBeInTheDocument()
);
});

it('shows API error message on fetch failure', async () => {
mockFetch.mockRejectedValue(new Error('Failed to fetch timeline: 404'));
renderView();
fireEvent.change(screen.getByLabelText(/notification id/i), { target: { value: '99' } });
fireEvent.submit(screen.getByRole('search'));

expect(await screen.findByRole('alert')).toHaveTextContent(/404/);
});

it('disables button while loading', async () => {
let resolve!: (v: NotificationTimeline) => void;
mockFetch.mockReturnValue(new Promise((r) => { resolve = r; }));

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

expect(screen.getByRole('button', { name: /loading/i })).toBeDisabled();

resolve(MOCK_TIMELINE);
await waitFor(() => expect(screen.getByRole('button', { name: /view timeline/i })).not.toBeDisabled());
});
});
Loading
Loading