feat: dashboard UI/UX overhaul from full review
Shared foundation: - New Modal component on native <dialog>.showModal() (focus trap, inert background, Escape, focus restore, scroll lock); all eight dialogs and both globalThis.confirm calls migrated to it - Design tokens for success/warning/danger surfaces; pill danger variant; .sr-only utility; toast layering above dialog backdrops; not-allowed cursor for validity-disabled buttons Conversations/attention: auto-scroll with jump-to-latest, per-conversation draft persistence, suggestion undo, 30s auto-refresh with refresh controls, unread emphasis and counts, blur-consistent share dialog, empty-accounts guidance, collapsed user lookup, relative timestamps, danger pills for critical items Media/library/outreach: click-blurred-to-reveal, lightbox with arrow navigation and captions, dirty-form preservation across refetches, upload validation with thumbnail preview, save feedback, result counts with load-more and clear-filters, ticking countdowns from a single clock Overview/performance/operations/settings: mutation errors inside the confirm dialog, live sending-window countdown, offline-aware loading states, per-panel loading/error with retry, 7/30/90-day range toggle, console scroll yielding with shown/retained counts, risky-save matching by subreddit name instead of index, useBlocker unsaved-changes guard, single-pass validation with focus-to-first-error, access panel JSON validation and input reset Tests: 806 -> 844 passing; both typechecks clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
82be197b79
commit
e984bf20ac
46 files changed
+2462
-495
No files matched your search
@@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { OperationsPage } from '../../apps/dashboard/src/features/operations/operations-page.js';
|
||||
import { PerformancePage } from '../../apps/dashboard/src/features/performance/performance-page.js';
|
||||
|
||||
@@ -97,27 +97,102 @@ describe('PerformancePage', () => {
|
||||
renderPage(<PerformancePage />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Performance' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Outcome funnel' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'AI cost and reliability' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('heading', { name: 'Outcome funnel' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('heading', { name: 'AI cost and reliability' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('table', { name: 'AI usage by model' })).toHaveTextContent('model-a');
|
||||
expect(screen.getByLabelText('AI usage totals')).toHaveTextContent('$0.0123');
|
||||
expect(screen.getByText('5 awaiting maturity')).toBeInTheDocument();
|
||||
const subredditTable = screen.getByRole('table', { name: 'Subreddit performance' });
|
||||
const subredditTable = await screen.findByRole('table', { name: 'Subreddit performance' });
|
||||
expect(within(subredditTable).getByText('n = 40')).toBeInTheDocument();
|
||||
expect(within(subredditTable).getByText('n = 3')).toBeInTheDocument();
|
||||
expect(within(subredditTable).getAllByText('Low confidence')).toHaveLength(2);
|
||||
expect(within(subredditTable).getByRole('columnheader', { name: 'Images' })).toBeInTheDocument();
|
||||
expect(within(subredditTable).getByText('15%')).toBeInTheDocument();
|
||||
expect(within(subredditTable).getByText('9 pending')).toBeInTheDocument();
|
||||
const strategyTable = screen.getByRole('table', { name: 'Strategy performance' });
|
||||
const strategyTable = await screen.findByRole('table', { name: 'Strategy performance' });
|
||||
expect(within(strategyTable).getByText('n = 25')).toBeInTheDocument();
|
||||
expect(within(strategyTable).getAllByText('Configured')).toHaveLength(2);
|
||||
expect(within(strategyTable).getByText('7 pending')).toBeInTheDocument();
|
||||
expect(screen.getByRole('list', { name: '30 day response trend' })).toHaveTextContent('50%');
|
||||
expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('A thoughtful opener');
|
||||
expect(await screen.findByRole('list', { name: '30 day response trend' })).toHaveTextContent('50%');
|
||||
expect(await screen.findByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('A thoughtful opener');
|
||||
expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('That sounds lovely!');
|
||||
expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('Image received');
|
||||
});
|
||||
|
||||
it('renders each panel independently and retries only the failed panel', async () => {
|
||||
let funnelRequests = 0;
|
||||
server.use(
|
||||
http.get('/api/analytics/by-subreddit', () =>
|
||||
HttpResponse.json([
|
||||
{ subreddit: 'askuk', totalSent: 40, totalResponses: 12, responseRate: 30, totalImages: 6, imageRate: 15 },
|
||||
]),
|
||||
),
|
||||
http.get('/api/analytics/by-strategy', () => HttpResponse.json([])),
|
||||
http.get('/api/analytics/trend', () => HttpResponse.json([])),
|
||||
http.get('/api/successful', () => HttpResponse.json([])),
|
||||
http.get('/api/analytics/funnel', () => {
|
||||
funnelRequests += 1;
|
||||
if (funnelRequests === 1) return HttpResponse.json({ error: 'Funnel unavailable right now' }, { status: 503 });
|
||||
return HttpResponse.json({
|
||||
responseWindowHours: 48, processed: 100, sent: 40, pending: 5, mature: 35,
|
||||
responded: 12, engaged: 4, declined: 2, boundary: 1, upsell: 3, images: 2,
|
||||
});
|
||||
}),
|
||||
http.get('/api/analytics/ai-usage', () => HttpResponse.json({
|
||||
days: 30, requests: 0, successes: 0, refusals: 0, errors: 0,
|
||||
promptTokens: 0, completionTokens: 0, totalTokens: 0, costUsd: 0, averageLatencyMs: 0, models: [],
|
||||
})),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderPage(<PerformancePage />);
|
||||
|
||||
// The failed funnel query renders its own inline error…
|
||||
const funnelError = await screen.findByRole('alert');
|
||||
expect(funnelError).toHaveTextContent('Funnel unavailable right now');
|
||||
// …while sibling panels still render their data and empty states.
|
||||
expect(await screen.findByRole('table', { name: 'Subreddit performance' })).toHaveTextContent('askuk');
|
||||
expect(screen.getAllByText('No sends recorded yet.').length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await user.click(within(funnelError).getByRole('button', { name: 'Retry outcome funnel' }));
|
||||
expect(await screen.findByRole('heading', { name: 'Outcome funnel' })).toBeInTheDocument();
|
||||
expect(screen.getByText('5 awaiting maturity')).toBeInTheDocument();
|
||||
expect(funnelRequests).toBe(2);
|
||||
});
|
||||
|
||||
it('switches the trend and AI usage range with the day toggle', async () => {
|
||||
const trendDays: Array<string | null> = [];
|
||||
const usageDays: Array<string | null> = [];
|
||||
server.use(
|
||||
http.get('/api/analytics/by-subreddit', () => HttpResponse.json([])),
|
||||
http.get('/api/analytics/by-strategy', () => HttpResponse.json([])),
|
||||
http.get('/api/successful', () => HttpResponse.json([])),
|
||||
http.get('/api/analytics/funnel', () => HttpResponse.json({
|
||||
responseWindowHours: 48, processed: 0, sent: 0, pending: 0, mature: 0,
|
||||
responded: 0, engaged: 0, declined: 0, boundary: 0, upsell: 0, images: 0,
|
||||
})),
|
||||
http.get('/api/analytics/trend', ({ request }) => {
|
||||
trendDays.push(new URL(request.url).searchParams.get('days'));
|
||||
return HttpResponse.json([
|
||||
{ date: '2026-08-07', totalSent: 10, totalResponses: 2, responseRate: 20, totalImages: 1, imageRate: 10 },
|
||||
]);
|
||||
}),
|
||||
http.get('/api/analytics/ai-usage', ({ request }) => {
|
||||
usageDays.push(new URL(request.url).searchParams.get('days'));
|
||||
return HttpResponse.json({
|
||||
days: 7, requests: 0, successes: 0, refusals: 0, errors: 0,
|
||||
promptTokens: 0, completionTokens: 0, totalTokens: 0, costUsd: 0, averageLatencyMs: 0, models: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderPage(<PerformancePage />);
|
||||
|
||||
expect(await screen.findByRole('list', { name: '30 day response trend' })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: '7 days' }));
|
||||
expect(await screen.findByRole('list', { name: '7 day response trend' })).toBeInTheDocument();
|
||||
expect(trendDays).toEqual(['30', '7']);
|
||||
expect(usageDays).toEqual(['30', '7']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OperationsPage', () => {
|
||||
@@ -181,11 +256,14 @@ describe('OperationsPage', () => {
|
||||
'href',
|
||||
'https://www.reddit.com/u/operator',
|
||||
);
|
||||
expect(within(consoleRegion).getByText('2 retained')).toBeInTheDocument();
|
||||
await user.selectOptions(within(consoleRegion).getByLabelText('Log level'), 'error');
|
||||
expect(consoleRegion).not.toHaveTextContent('Sync complete');
|
||||
expect(consoleRegion).toHaveTextContent('Matrix request failed: M_LIMIT_EXCEEDED');
|
||||
expect(within(consoleRegion).getByText('1 shown · 2 retained')).toBeInTheDocument();
|
||||
await user.type(within(consoleRegion).getByLabelText('Search logs'), 'no match');
|
||||
expect(consoleRegion).toHaveTextContent('No logs match the current filters.');
|
||||
expect(within(consoleRegion).getByText('0 shown · 2 retained')).toBeInTheDocument();
|
||||
expect(within(consoleRegion).getByRole('button', { name: 'Pause console' })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(within(consoleRegion).getByLabelText('Auto-scroll')).toBeChecked();
|
||||
await user.clear(within(consoleRegion).getByLabelText('Search logs'));
|
||||
@@ -204,19 +282,29 @@ describe('OperationsPage', () => {
|
||||
http.post('/api/chats/sync/full', () => { fullCalls += 1; return HttpResponse.json({ success: true, kind: 'full' }); }),
|
||||
http.post('/api/chats/fetch-all', () => HttpResponse.json({ success: true, count: 7 })),
|
||||
);
|
||||
const confirmSpy = vi.spyOn(globalThis, 'confirm').mockReturnValue(false);
|
||||
const user = userEvent.setup();
|
||||
renderPage(<OperationsPage realtimeEnabled={false} />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Sync latest chats' }));
|
||||
expect(await screen.findByText(/Delta sync started/)).toBeInTheDocument();
|
||||
expect(deltaCalls).toBe(1);
|
||||
|
||||
// Expensive operations require an in-app confirmation dialog with action-specific copy.
|
||||
await user.click(screen.getByRole('button', { name: 'Full cache sync' }));
|
||||
const fullDialog = screen.getByRole('dialog', { name: 'Run a full cache sync?' });
|
||||
expect(fullDialog).toHaveTextContent('can take significantly longer than a delta sync');
|
||||
await user.click(within(fullDialog).getByRole('button', { name: 'Cancel' }));
|
||||
expect(fullCalls).toBe(0);
|
||||
confirmSpy.mockReturnValue(true);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Full cache sync' }));
|
||||
await user.click(within(screen.getByRole('dialog', { name: 'Run a full cache sync?' })).getByRole('button', { name: 'Run full sync' }));
|
||||
expect(await screen.findByText(/Full sync started/)).toBeInTheDocument();
|
||||
expect(fullCalls).toBe(1);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Fetch all chats' }));
|
||||
const fetchAllDialog = screen.getByRole('dialog', { name: 'Fetch every chat now?' });
|
||||
expect(fetchAllDialog).toHaveTextContent('legacy blocking operation');
|
||||
await user.click(within(fetchAllDialog).getByRole('button', { name: 'Start fetching' }));
|
||||
expect(await screen.findByText('Fetched 7 chats.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user