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
@@ -2,12 +2,14 @@
|
||||
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { ConversationsPage } from '../../apps/dashboard/src/features/conversations/conversations-page.js';
|
||||
import { resetComposerDrafts } from '../../apps/dashboard/src/features/conversations/composer-drafts.js';
|
||||
|
||||
interface ChatFixtureOptions {
|
||||
username: string;
|
||||
@@ -81,9 +83,11 @@ function renderConversations() {
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConversationsPage />
|
||||
</QueryClientProvider>,
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConversationsPage />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
return queryClient;
|
||||
}
|
||||
@@ -92,6 +96,7 @@ beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
server.resetHandlers();
|
||||
resetComposerDrafts();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
@@ -152,8 +157,7 @@ describe('ConversationsPage', () => {
|
||||
);
|
||||
expect(screen.getByText('2h ago')).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).toHaveClass('is-blurred');
|
||||
expect(screen.getByRole('button', { name: 'View source image full screen: Looking for a chat' })).toBeDisabled();
|
||||
await user.click(screen.getByRole('button', { name: 'Reveal original image' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Reveal source image: Looking for a chat' }));
|
||||
expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).not.toHaveClass('is-blurred');
|
||||
const previewTrigger = screen.getByRole('button', { name: 'View source image full screen: Looking for a chat' });
|
||||
await user.click(previewTrigger);
|
||||
@@ -321,6 +325,7 @@ describe('ConversationsPage', () => {
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderConversations();
|
||||
await user.click(screen.getByText('Processed user lookup'));
|
||||
await user.type(screen.getByLabelText('Lookup Reddit username'), 'u/alice');
|
||||
await user.click(screen.getByRole('button', { name: 'Look up user' }));
|
||||
expect(await screen.findByRole('list', { name: 'Processed posts for u/alice' })).toHaveTextContent('A processed post');
|
||||
@@ -438,4 +443,124 @@ describe('ConversationsPage', () => {
|
||||
expect(message).toHaveValue('');
|
||||
expect(startRequests).toBe(2);
|
||||
});
|
||||
|
||||
it('explains how to enable the start-conversation form when no accounts are running', async () => {
|
||||
server.use(
|
||||
http.get('/api/conversations', () => HttpResponse.json([])),
|
||||
http.get('/api/accounts', () => HttpResponse.json([])),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderConversations();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'New conversation' }));
|
||||
expect(await screen.findByText(/No running accounts/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Settings' })).toHaveAttribute('href', '/settings');
|
||||
expect(screen.queryByRole('button', { name: 'Start conversation' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the unread count and marks unread rows with state and time in the label', async () => {
|
||||
installConversationList();
|
||||
renderConversations();
|
||||
|
||||
const aliceButton = await screen.findByRole('button', { name: /u\/alice/ });
|
||||
expect(screen.getByText('1 unread')).toBeInTheDocument();
|
||||
expect(aliceButton.getAttribute('aria-label')).toMatch(/^u\/alice, Unread, .+ ago/);
|
||||
expect(aliceButton.closest('li')).toHaveClass('is-unread');
|
||||
expect(screen.getByRole('button', { name: /u\/bob/ }).closest('li')).not.toHaveClass('is-unread');
|
||||
});
|
||||
|
||||
it('offers a jump-to-latest control when the operator scrolls up in the history', async () => {
|
||||
installConversationList();
|
||||
const user = userEvent.setup();
|
||||
renderConversations();
|
||||
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
|
||||
|
||||
const history = await screen.findByRole('list', { name: 'Message history' });
|
||||
expect(screen.queryByRole('button', { name: 'Jump to latest' })).not.toBeInTheDocument();
|
||||
|
||||
Object.defineProperty(history, 'scrollHeight', { configurable: true, value: 1000 });
|
||||
Object.defineProperty(history, 'clientHeight', { configurable: true, value: 300 });
|
||||
history.scrollTop = 100;
|
||||
fireEvent.scroll(history);
|
||||
|
||||
const jumpButton = await screen.findByRole('button', { name: 'Jump to latest' });
|
||||
await user.click(jumpButton);
|
||||
expect(history.scrollTop).toBe(1000);
|
||||
expect(screen.queryByRole('button', { name: 'Jump to latest' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('preserves an unsent draft when switching away from and back to a conversation', async () => {
|
||||
installConversationList();
|
||||
const user = userEvent.setup();
|
||||
renderConversations();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
|
||||
const composer = await screen.findByRole('textbox', { name: 'Reply to u/alice' });
|
||||
await user.type(composer, 'Draft in progress');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Close conversation' }));
|
||||
expect(screen.queryByRole('textbox', { name: 'Reply to u/alice' })).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /u\/alice/ }));
|
||||
expect(await screen.findByRole('textbox', { name: 'Reply to u/alice' })).toHaveValue('Draft in progress');
|
||||
});
|
||||
|
||||
it('lets the operator undo a suggestion that replaced a typed draft', async () => {
|
||||
installConversationList();
|
||||
server.use(
|
||||
http.post('/api/conversations/room-alice/suggest-reply', () =>
|
||||
HttpResponse.json({ suggestedReply: 'A generated suggestion' }),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderConversations();
|
||||
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
|
||||
|
||||
const composer = await screen.findByRole('textbox', { name: 'Reply to u/alice' });
|
||||
await user.type(composer, 'My own words');
|
||||
expect(screen.getByText('12 / 10000')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Suggest reply' }));
|
||||
await waitFor(() => expect(composer).toHaveValue('A generated suggestion'));
|
||||
expect(screen.getByText('The suggestion replaced your draft.')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Undo' }));
|
||||
expect(composer).toHaveValue('My own words');
|
||||
expect(screen.queryByRole('button', { name: 'Undo' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the confirmation image blurred when saved images were re-blurred before review', async () => {
|
||||
const asset = {
|
||||
id: '22222222-2222-4222-8222-222222222222', tags: ['selfie'], safety: 'sfw',
|
||||
classificationStatus: 'ready', description: 'A selfie', confidence: 0.95,
|
||||
fileName: 'selfie.jpg', mimeType: 'image/jpeg', sizeBytes: 1024, width: 800, height: 1000,
|
||||
enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1,
|
||||
sendCount: 0, lastSentAt: null, urlPath: '/library-media/selfie.jpg',
|
||||
};
|
||||
server.use(
|
||||
http.get('/api/conversations', () => HttpResponse.json([alice])),
|
||||
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture())),
|
||||
http.get('/api/conversations/room-alice/shareable-media', () => HttpResponse.json({
|
||||
requestKind: 'selfie', assets: [asset], reason: null,
|
||||
})),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderConversations();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
|
||||
await user.click(await screen.findByText('Share a saved image'));
|
||||
|
||||
expect(await screen.findByText('Reveal images to select one.')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Reveal saved images' }));
|
||||
expect(screen.queryByText('Reveal images to select one.')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('radio', { name: /Saved image tagged selfie/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'Blur saved images' }));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Review image share' }));
|
||||
const dialog = screen.getByRole('dialog', { name: 'Share this saved image?' });
|
||||
const dialogImage = within(dialog).getByRole('img', { name: 'Saved image tagged selfie' });
|
||||
expect(dialogImage).toHaveClass('is-blurred');
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Reveal image' }));
|
||||
expect(dialogImage).not.toHaveClass('is-blurred');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user