Skip to content
Open
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
67 changes: 35 additions & 32 deletions dashboard/src/__tests__/wallet-integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ function writeReport(results: { wallet: string; passed: boolean }[]) {
wallets: results,
},
null,
2
)
2,
),
);
}

Expand Down Expand Up @@ -94,19 +94,22 @@ describe('Wallet connection flows', () => {
});

describe('Wallet switching', () => {
it.each(SUPPORTED_WALLETS)('switches to $label and updates persisted wallet id', async ({ id, address }) => {
const { wallet, store, kit } = await load();
it.each(SUPPORTED_WALLETS)(
'switches to $label and updates persisted wallet id',
async ({ id, address }) => {
const { wallet, store, kit } = await load();

kit.__control.authModalImpl = async () => {
kit.__emit('WALLET_SELECTED', { id });
kit.__emit('STATE_UPDATED', { address });
};
kit.__control.authModalImpl = async () => {
kit.__emit('WALLET_SELECTED', { id });
kit.__emit('STATE_UPDATED', { address });
};

await wallet.connectWallet();
await wallet.connectWallet();

expect(localStorage.getItem(WALLET_ID_KEY)).toBe(id);
expect(store.useWalletStore.getState().address).toBe(address);
});
expect(localStorage.getItem(WALLET_ID_KEY)).toBe(id);
expect(store.useWalletStore.getState().address).toBe(address);
},
);

it('switches wallets without leaving a stale session', async () => {
const { wallet, store, kit } = await load();
Expand All @@ -130,25 +133,23 @@ describe('Wallet switching', () => {

describe('Notification workflow with wallet connected', () => {
it('loads events while wallet session is active', async () => {
const fetchMock = jest
.fn<() => Promise<Response>>()
.mockResolvedValue({
ok: true,
json: async () => ({
events: [
{
eventId: 'evt-wallet-1',
contractAddress: 'CTEST',
eventName: 'task_created',
type: 'contract',
topic: [],
value: '',
ledger: 100,
receivedAt: Date.parse('2026-06-24T12:00:00Z'),
},
],
}),
} as unknown as Response);
const fetchMock = jest.fn<() => Promise<Response>>().mockResolvedValue({
ok: true,
json: async () => ({
events: [
{
eventId: 'evt-wallet-1',
contractAddress: 'CTEST',
eventName: 'task_created',
type: 'contract',
topic: [],
value: '',
ledger: 100,
receivedAt: Date.parse('2026-06-24T12:00:00Z'),
},
],
}),
} as unknown as Response);
global.fetch = fetchMock as unknown as typeof fetch;

const { fetchEvents } = await import('../services/eventsApi');
Expand Down Expand Up @@ -258,7 +259,9 @@ describe('Notification feed clears on wallet switch (issue #175)', () => {

// localStorage must reflect the new account only
expect(localStorage.getItem('notify-chain:wallet-address')).toBe(SUPPORTED_WALLETS[2].address);
expect(localStorage.getItem('notify-chain:wallet-address')).not.toBe(SUPPORTED_WALLETS[0].address);
expect(localStorage.getItem('notify-chain:wallet-address')).not.toBe(
SUPPORTED_WALLETS[0].address,
);
expect(store.useWalletStore.getState().address).toBe(SUPPORTED_WALLETS[2].address);
});

Expand Down
4 changes: 2 additions & 2 deletions dashboard/src/benchmark/render-benchmark.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('event list render performance', () => {
const { container } = render(
<div style={{ height: 600, width: 800 }}>
<EventList events={events} />
</div>
</div>,
);

const renderedRows = countRenderedRows(container);
Expand All @@ -38,7 +38,7 @@ describe('event list render performance', () => {
const virtualRender = render(
<div style={{ height: 600, width: 800 }}>
<EventList events={events} />
</div>
</div>,
);
const virtualDuration = performance.now() - virtualStart;
const virtualRows = countRenderedRows(virtualRender.container);
Expand Down
46 changes: 24 additions & 22 deletions dashboard/src/components/ActivityFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ import { EmptyState } from './EmptyState';
// Helper to get icon/color based on activity type
const getActivityTypeStyle = (type: ActivityType) => {
const styles: Record<ActivityType, { color: string; icon: string; bg: string }> = {
'notification_sent': { color: '#34d399', icon: '✓', bg: 'rgba(52, 211, 153, 0.12)' },
'notification_failed': { color: '#f87171', icon: '✕', bg: 'rgba(248, 113, 113, 0.12)' },
'notification_retried': { color: '#f4b400', icon: '↻', bg: 'rgba(244, 180, 0, 0.12)' },
'contract_event_received': { color: '#60a5fa', icon: '📡', bg: 'rgba(96, 165, 250, 0.12)' },
'preference_updated': { color: '#a78bfa', icon: '⚙', bg: 'rgba(167, 139, 250, 0.12)' },
'template_created': { color: '#38bdf8', icon: '📄', bg: 'rgba(56, 189, 248, 0.12)' },
'template_updated': { color: '#22d3ee', icon: '📝', bg: 'rgba(34, 211, 238, 0.12)' },
'webhook_received': { color: '#fb923c', icon: '🔗', bg: 'rgba(251, 146, 60, 0.12)' },
notification_sent: { color: '#34d399', icon: '✓', bg: 'rgba(52, 211, 153, 0.12)' },
notification_failed: { color: '#f87171', icon: '✕', bg: 'rgba(248, 113, 113, 0.12)' },
notification_retried: { color: '#f4b400', icon: '↻', bg: 'rgba(244, 180, 0, 0.12)' },
contract_event_received: { color: '#60a5fa', icon: '📡', bg: 'rgba(96, 165, 250, 0.12)' },
preference_updated: { color: '#a78bfa', icon: '⚙', bg: 'rgba(167, 139, 250, 0.12)' },
template_created: { color: '#38bdf8', icon: '📄', bg: 'rgba(56, 189, 248, 0.12)' },
template_updated: { color: '#22d3ee', icon: '📝', bg: 'rgba(34, 211, 238, 0.12)' },
webhook_received: { color: '#fb923c', icon: '🔗', bg: 'rgba(251, 146, 60, 0.12)' },
};
return styles[type] || styles['contract_event_received'];
};
Expand Down Expand Up @@ -49,14 +49,16 @@ const ActivityEventCard = ({ event }: { event: ActivityEvent }) => {
<p className="activity-event__message">{event.message}</p>
{Object.keys(event.metadata).length > 0 && (
<div className="activity-event__metadata">
{Object.entries(event.metadata).map(([key, value]) => (
value !== undefined && value !== null && (
<span key={key} className="activity-event__metadata-item">
<span className="activity-event__metadata-key">{key}:</span>
<span className="activity-event__metadata-value">{String(value)}</span>
</span>
)
))}
{Object.entries(event.metadata).map(
([key, value]) =>
value !== undefined &&
value !== null && (
<span key={key} className="activity-event__metadata-item">
<span className="activity-event__metadata-key">{key}:</span>
<span className="activity-event__metadata-value">{String(value)}</span>
</span>
),
)}
</div>
)}
</div>
Expand Down Expand Up @@ -131,8 +133,8 @@ export function ActivityFeed() {

const interval = setInterval(() => {
const [newEvent] = generateMockActivityEvents(1);
setLiveEvents(prev => [newEvent, ...prev]);
setTotal(prev => prev + 1);
setLiveEvents((prev) => [newEvent, ...prev]);
setTotal((prev) => prev + 1);
}, 15000);

return () => clearInterval(interval);
Expand Down Expand Up @@ -179,7 +181,9 @@ export function ActivityFeed() {
className={`activity-feed__toggle-realtime ${realTimeEnabled ? 'activity-feed__toggle-realtime--active' : ''}`}
onClick={() => setRealTimeEnabled(!realTimeEnabled)}
aria-pressed={realTimeEnabled}
aria-label={realTimeEnabled ? 'Pause live activity updates' : 'Resume live activity updates'}
aria-label={
realTimeEnabled ? 'Pause live activity updates' : 'Resume live activity updates'
}
>
{realTimeEnabled ? '🔴 Live' : '⏸ Paused'}
</button>
Expand Down Expand Up @@ -209,9 +213,7 @@ export function ActivityFeed() {
className="empty-state--compact"
/>
) : (
displayedEvents.map(event => (
<ActivityEventCard key={event.id} event={event} />
))
displayedEvents.map((event) => <ActivityEventCard key={event.id} event={event} />)
)}
</div>

Expand Down
8 changes: 6 additions & 2 deletions dashboard/src/components/CopyButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,13 @@ export function CopyButton({ value, label, size = 'sm' }: CopyButtonProps) {
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__icon" aria-hidden="true">⎘</span>
<span className="copy-btn__icon" aria-hidden="true">
</span>
)}
<span className="copy-btn__label">{copied ? 'Copied' : 'Copy'}</span>
</button>
Expand Down
29 changes: 9 additions & 20 deletions dashboard/src/components/DeliveryHeatmap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,12 @@ function DeliveryHeatmapInner({ events }: DeliveryHeatmapProps) {
const handleStartChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) =>
setDateRange((prev) => ({ ...prev, start: e.target.value })),
[]
[],
);
const handleEndChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) =>
setDateRange((prev) => ({ ...prev, end: e.target.value })),
[]
[],
);

const handlePreset = useCallback((days: number) => {
Expand All @@ -92,7 +92,7 @@ function DeliveryHeatmapInner({ events }: DeliveryHeatmapProps) {
/* ── Heatmap data (memoised) ── */
const heatmap = useMemo(
() => aggregateHeatmapData(events, startDate, endDate),
[events, startDate, endDate]
[events, startDate, endDate],
);

/* ── Tooltip handlers ── */
Expand All @@ -109,7 +109,7 @@ function DeliveryHeatmapInner({ events }: DeliveryHeatmapProps) {
y: cellRect.top - gridRect.top,
});
},
[]
[],
);

const handleCellLeave = useCallback(() => setTooltip(null), []);
Expand All @@ -133,17 +133,11 @@ function DeliveryHeatmapInner({ events }: DeliveryHeatmapProps) {

<div className="heatmap__stats">
<div className="heatmap__stat">
<span className="heatmap__stat-value">
{heatmap.totalCount.toLocaleString()}
</span>
<span className="heatmap__stat-label">
{hasFilters ? 'Filtered' : 'Total'} Events
</span>
<span className="heatmap__stat-value">{heatmap.totalCount.toLocaleString()}</span>
<span className="heatmap__stat-label">{hasFilters ? 'Filtered' : 'Total'} Events</span>
</div>
<div className="heatmap__stat">
<span className="heatmap__stat-value">
{heatmap.maxCount.toLocaleString()}
</span>
<span className="heatmap__stat-value">{heatmap.maxCount.toLocaleString()}</span>
<span className="heatmap__stat-label">Peak (per slot)</span>
</div>
</div>
Expand All @@ -153,10 +147,7 @@ function DeliveryHeatmapInner({ events }: DeliveryHeatmapProps) {
<div className="heatmap__filters" id="heatmap-date-filters">
<div className="heatmap__presets">
{PRESETS.map((preset) => {
const isActive =
preset.days === 0
? !dateRange.start && !dateRange.end
: false; // full active-state logic lives in CSS via data attribute
const isActive = preset.days === 0 ? !dateRange.start && !dateRange.end : false; // full active-state logic lives in CSS via data attribute
return (
<button
key={preset.label}
Expand Down Expand Up @@ -257,9 +248,7 @@ function DeliveryHeatmapInner({ events }: DeliveryHeatmapProps) {
handleCellEnter(cell.day, cell.hour, cell.count, e.currentTarget)
}
onMouseLeave={handleCellLeave}
onFocus={(e) =>
handleCellEnter(cell.day, cell.hour, cell.count, e.currentTarget)
}
onFocus={(e) => handleCellEnter(cell.day, cell.hour, cell.count, e.currentTarget)}
onBlur={handleCellLeave}
tabIndex={0}
role="gridcell"
Expand Down
9 changes: 2 additions & 7 deletions dashboard/src/components/EmptyState.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ expect.extend(toHaveNoViolations);

test('EmptyState has no accessibility violations', async () => {
const { container } = render(
<EmptyState title="No results" message="Try adjusting your filters." />
<EmptyState title="No results" message="Try adjusting your filters." />,
);
const results = await axe(container);
expect(results).toHaveNoViolations();
Expand All @@ -26,12 +26,7 @@ test('omits the title when none is provided', () => {

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 }}
/>
);
render(<EmptyState message="No templates yet." action={{ label: 'Create Template', onClick }} />);
fireEvent.click(screen.getByRole('button', { name: 'Create Template' }));
expect(onClick).toHaveBeenCalledTimes(1);
});
Expand Down
6 changes: 5 additions & 1 deletion dashboard/src/components/EmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,11 @@ export function EmptyState({
{body && <p className="empty-state__message empty-state__description">{body}</p>}
{children}
{action && (
<button type="button" className="empty-state__action button button--secondary" onClick={action.onClick}>
<button
type="button"
className="empty-state__action button button--secondary"
onClick={action.onClick}
>
{action.label}
</button>
)}
Expand Down
6 changes: 1 addition & 5 deletions dashboard/src/components/EventCard.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react';
import { EventCard } from './EventCard';
import {
sampleEvent,
sampleSystemEvent,
sampleWithdrawalEvent,
} from '../stories/fixtures/events';
import { sampleEvent, sampleSystemEvent, sampleWithdrawalEvent } from '../stories/fixtures/events';

const meta: Meta<typeof EventCard> = {
title: 'Components/EventCard',
Expand Down
6 changes: 2 additions & 4 deletions dashboard/src/components/EventCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,7 @@ const mockEvent: BlockchainEvent = {
} as BlockchainEvent;

test('clickable EventCard has no accessibility violations', async () => {
const { container } = render(
<EventCard event={mockEvent} onClick={() => {}} />
);
const { container } = render(<EventCard event={mockEvent} onClick={() => {}} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Expand All @@ -35,4 +33,4 @@ test('activates on Space key, not just Enter', () => {

fireEvent.keyDown(card, { key: 'Enter' });
expect(onClick).toHaveBeenCalledTimes(2);
});
});
Loading