Merge branch 'main' of https://github.com/bulwarkmail/webmail
# Conflicts: # app/api/dev-jmap/[...path]/route.ts
This commit is contained in:
@@ -1,154 +0,0 @@
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { EmailListItem } from '../email-list-item';
|
||||
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
|
||||
// Mock the drag hook
|
||||
vi.mock('@/hooks/use-email-drag', () => ({
|
||||
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
|
||||
}));
|
||||
|
||||
// Mock identity badge
|
||||
vi.mock('../email-identity-badge', () => ({
|
||||
EmailIdentityBadge: () => null,
|
||||
}));
|
||||
|
||||
// Mock auth store
|
||||
vi.mock('@/stores/auth-store', () => ({
|
||||
useAuthStore: () => ({ identities: [] }),
|
||||
}));
|
||||
|
||||
const makeEmail = (overrides: Partial<Email> = {}): Email => ({
|
||||
id: 'email-1',
|
||||
threadId: 'thread-1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
size: 1000,
|
||||
receivedAt: '2024-01-15T10:00:00Z',
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }],
|
||||
subject: 'Test Subject',
|
||||
hasAttachment: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('EmailListItem tag badge', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set<string>(),
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show tag badge when email has no label keyword', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true } });
|
||||
render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
||||
// No keyword label should appear
|
||||
DEFAULT_KEYWORDS.forEach((kw) => {
|
||||
expect(screen.queryByText(kw.label)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows tag badge with label when email has $label: keyword', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
|
||||
render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Red')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows tag badge for legacy $color: keyword', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true, '$color:blue': true } });
|
||||
render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Blue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a gray fallback badge when keyword id is not in settings', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } });
|
||||
render(<EmailListItem email={email} />);
|
||||
// Unknown tags fall back to the raw id as label with a gray dot
|
||||
// (see email-list-item.tsx: keywordDefs fallback).
|
||||
expect(screen.getByText('unknown-tag')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows custom keyword label', () => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [
|
||||
...DEFAULT_KEYWORDS,
|
||||
{ id: 'work', label: 'Work', color: 'teal' },
|
||||
],
|
||||
});
|
||||
const email = makeEmail({ keywords: { $seen: true, '$label:work': true } });
|
||||
render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Work')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates badge when keyword definition changes', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
|
||||
const { rerender } = render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Red')).toBeInTheDocument();
|
||||
|
||||
// Update label name
|
||||
act(() => {
|
||||
useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' });
|
||||
});
|
||||
rerender(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Urgent')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Red')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders subject even without tag', () => {
|
||||
const email = makeEmail({ subject: 'Hello World' });
|
||||
render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders inline preview text in focused mail layout', () => {
|
||||
useSettingsStore.setState({
|
||||
showPreview: true,
|
||||
mailLayout: 'focus',
|
||||
});
|
||||
const email = makeEmail({ preview: 'Inline preview content' });
|
||||
const { container } = render(<EmailListItem email={email} />);
|
||||
|
||||
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
|
||||
expect(container.querySelector('p')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmailListItem shift-range checkbox', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split' });
|
||||
});
|
||||
|
||||
it('shift-clicking the checkbox extends the selection from the anchor', () => {
|
||||
const e1 = makeEmail({ id: 'e1', threadId: 't1' });
|
||||
const e2 = makeEmail({ id: 'e2', threadId: 't2' });
|
||||
const e3 = makeEmail({ id: 'e3', threadId: 't3' });
|
||||
// selection mode active (so the checkbox renders), anchor on e1
|
||||
useEmailStore.setState({
|
||||
emails: [e1, e2, e3],
|
||||
selectedEmailIds: new Set(['e1']),
|
||||
lastSelectedEmailId: 'e1',
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
|
||||
render(<EmailListItem email={e3} />);
|
||||
// the checkbox is the first button in the row (shown in selection mode)
|
||||
const checkbox = screen.getAllByRole('button')[0];
|
||||
act(() => {
|
||||
checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }));
|
||||
});
|
||||
|
||||
const sel = useEmailStore.getState().selectedEmailIds;
|
||||
expect(sel.has('e1')).toBe(true);
|
||||
expect(sel.has('e2')).toBe(true); // the in-between row got filled in
|
||||
expect(sel.has('e3')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { TagBadge } from '../tag-badge';
|
||||
import { useSettingsStore, type KeywordDefinition } from '@/stores/settings-store';
|
||||
|
||||
const TAGS: KeywordDefinition[] = [
|
||||
{ id: 'work', label: 'Work', color: 'blue' },
|
||||
{ id: 'work/clients', label: 'Clients', color: 'green' },
|
||||
];
|
||||
|
||||
describe('TagBadge', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true });
|
||||
});
|
||||
|
||||
it('names the tag by its full path', () => {
|
||||
render(<TagBadge tagId="work/clients" variant="badge" />);
|
||||
expect(screen.getByText('Work/Clients')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names a tag it has no definition for by its id', () => {
|
||||
render(<TagBadge tagId="from-elsewhere" variant="badge" />);
|
||||
expect(screen.getByText('from-elsewhere')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers removal only when asked to', () => {
|
||||
const onRemove = vi.fn();
|
||||
const { rerender } = render(<TagBadge tagId="work" variant="badge" />);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<TagBadge tagId="work" variant="badge" onRemove={onRemove} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'remove_tag' }));
|
||||
expect(onRemove).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('leaves the dot alone, having nowhere to put the control', () => {
|
||||
render(<TagBadge tagId="work" variant="dot" onRemove={() => {}} />);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Work')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { TagPicker } from '../tag-picker';
|
||||
import { useSettingsStore, type KeywordDefinition } from '@/stores/settings-store';
|
||||
|
||||
const TAGS: KeywordDefinition[] = [
|
||||
{ id: 'work', label: 'Work', color: 'blue' },
|
||||
{ id: 'work/clients', label: 'Clients', color: 'green' },
|
||||
{ id: 'work/clients/acme', label: 'Acme', color: 'red' },
|
||||
{ id: 'personal', label: 'Personal', color: 'purple' },
|
||||
];
|
||||
|
||||
/** Ten tags is the point at which the filter box appears. */
|
||||
const MANY_TAGS: KeywordDefinition[] = Array.from({ length: 12 }, (_, i) => ({
|
||||
id: `tag-${i}`,
|
||||
label: i === 0 ? 'Invoices' : `Tag ${i}`,
|
||||
color: 'blue',
|
||||
}));
|
||||
|
||||
describe('TagPicker', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true });
|
||||
});
|
||||
|
||||
it('names a nested tag by its own label, not the whole path', () => {
|
||||
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
|
||||
// The tree conveys the hierarchy, so a child needs only its own name.
|
||||
expect(screen.getByText('Clients')).toBeInTheDocument();
|
||||
expect(screen.getByText('Acme')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Work/Clients')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('indents each level below its parent', () => {
|
||||
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
const acme = screen.getByText('Acme');
|
||||
|
||||
// Two levels down: two nested indent wrappers between it and the list.
|
||||
const indents = acme.closest('.ps-4')?.parentElement?.closest('.ps-4');
|
||||
expect(indents).not.toBeNull();
|
||||
expect(container.querySelectorAll('.ps-4').length).toBe(2);
|
||||
});
|
||||
|
||||
it('marks the applied tags and reports toggles by id', () => {
|
||||
const onToggle = vi.fn();
|
||||
render(<TagPicker selectedIds={['work/clients']} onToggle={onToggle} />);
|
||||
|
||||
const row = screen.getByText('Clients').closest('button')!;
|
||||
expect(row).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByText('Work').closest('button')).toHaveAttribute('aria-checked', 'false');
|
||||
|
||||
fireEvent.click(row);
|
||||
expect(onToggle).toHaveBeenCalledWith('work/clients');
|
||||
});
|
||||
|
||||
it('lists a tag it has no definition for, so it can be taken off', () => {
|
||||
const onToggle = vi.fn();
|
||||
const { rerender } = render(<TagPicker selectedIds={['from-elsewhere']} onToggle={onToggle} />);
|
||||
|
||||
const row = screen.getByText('from-elsewhere').closest('button')!;
|
||||
expect(row).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
fireEvent.click(row);
|
||||
expect(onToggle).toHaveBeenCalledWith('from-elsewhere');
|
||||
|
||||
// Nothing but the message says it exists, so deselecting is the last of it.
|
||||
rerender(<TagPicker selectedIds={[]} onToggle={onToggle} />);
|
||||
expect(screen.queryByText('from-elsewhere')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('counts undefined tags towards the filter box, and matches them', () => {
|
||||
const strays = Array.from({ length: 8 }, (_, i) => `stray-${i}`);
|
||||
const { container } = render(<TagPicker selectedIds={strays} onToggle={() => {}} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'stray-3' } });
|
||||
expect(within(container).getByText('stray-3')).toBeInTheDocument();
|
||||
expect(within(container).queryByText('Work')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the filter box until the list is long enough to need one', () => {
|
||||
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
expect(screen.queryByLabelText('tag_filter_placeholder')).not.toBeInTheDocument();
|
||||
|
||||
useSettingsStore.setState({ emailKeywords: MANY_TAGS });
|
||||
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
expect(screen.getAllByLabelText('tag_filter_placeholder').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('flattens to matches while filtering, and says so when there are none', () => {
|
||||
useSettingsStore.setState({ emailKeywords: MANY_TAGS });
|
||||
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'invo' } });
|
||||
expect(within(container).getByText('Invoices')).toBeInTheDocument();
|
||||
expect(within(container).queryByText('Tag 5')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'zzz' } });
|
||||
expect(within(container).getByText('tag_no_matches')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('matches the full path, so a child is reachable by its parent name', () => {
|
||||
useSettingsStore.setState({ emailKeywords: [...TAGS, ...MANY_TAGS] });
|
||||
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'work/cli' } });
|
||||
// Filtered rows are flat, so they carry the whole path.
|
||||
expect(within(container).getByText('Work/Clients')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists tags flat when nesting is off', () => {
|
||||
useSettingsStore.setState({ nestedTags: false });
|
||||
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
|
||||
expect(container.querySelectorAll('.ps-4').length).toBe(0);
|
||||
expect(screen.getByText('Clients')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ThreadListItem } from '../thread-list-item';
|
||||
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { groupEmailsByThread } from '@/lib/thread-utils';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
|
||||
vi.mock('@/hooks/use-email-drag', () => ({
|
||||
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/auth-store', () => ({
|
||||
useAuthStore: () => ({ identities: [] }),
|
||||
}));
|
||||
|
||||
const makeEmail = (overrides: Partial<Email> = {}): Email => ({
|
||||
id: 'email-1',
|
||||
threadId: 'thread-1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
size: 1000,
|
||||
receivedAt: '2024-01-15T10:00:00Z',
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }],
|
||||
subject: 'Test Subject',
|
||||
hasAttachment: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* A one-message thread, built through the real grouping so the fixture cannot
|
||||
* drift from what the list actually feeds this component. `ThreadListItem`
|
||||
* delegates to `SingleEmailItem` at that size, which is what draws every
|
||||
* single-message row in the app.
|
||||
*/
|
||||
function renderRow(email: Email) {
|
||||
const [thread] = groupEmailsByThread([email]);
|
||||
return render(
|
||||
<ThreadListItem
|
||||
thread={thread}
|
||||
isExpanded={false}
|
||||
onToggleExpand={() => {}}
|
||||
onEmailSelect={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ThreadListItem tag badge', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set<string>(),
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show a tag badge when the email has no label keyword', () => {
|
||||
renderRow(makeEmail({ keywords: { $seen: true } }));
|
||||
|
||||
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
||||
DEFAULT_KEYWORDS.forEach((kw) => {
|
||||
expect(screen.queryByText(kw.label)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a tag badge for a $label: keyword', () => {
|
||||
renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
|
||||
|
||||
expect(screen.getByText('Red')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a tag badge for the legacy $color: keyword', () => {
|
||||
renderRow(makeEmail({ keywords: { $seen: true, '$color:blue': true } }));
|
||||
|
||||
expect(screen.getByText('Blue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the raw id when the tag is not in settings', () => {
|
||||
// A keyword created by another client, or one whose definition was deleted.
|
||||
renderRow(makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } }));
|
||||
|
||||
expect(screen.getByText('unknown-tag')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a custom tag label', () => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS, { id: 'work', label: 'Work', color: 'teal' }],
|
||||
});
|
||||
renderRow(makeEmail({ keywords: { $seen: true, '$label:work': true } }));
|
||||
|
||||
expect(screen.getByText('Work')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('follows a renamed tag definition', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
|
||||
const { rerender } = renderRow(email);
|
||||
expect(screen.getByText('Red')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' });
|
||||
});
|
||||
const [thread] = groupEmailsByThread([email]);
|
||||
rerender(
|
||||
<ThreadListItem
|
||||
thread={thread}
|
||||
isExpanded={false}
|
||||
onToggleExpand={() => {}}
|
||||
onEmailSelect={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Urgent')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Red')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem multi-message thread', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set<string>(),
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
});
|
||||
|
||||
function renderThread(emails: Email[], expanded = false) {
|
||||
const [thread] = groupEmailsByThread(emails);
|
||||
return render(
|
||||
<ThreadListItem
|
||||
thread={thread}
|
||||
isExpanded={expanded}
|
||||
expandedEmails={expanded ? emails : undefined}
|
||||
onToggleExpand={() => {}}
|
||||
onEmailSelect={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it('carries the tags of every message, not just the first', () => {
|
||||
// A collapsed row stands in for the whole thread, so a tag applied only to
|
||||
// a later message still has to surface.
|
||||
renderThread([
|
||||
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:blue': true } }),
|
||||
]);
|
||||
|
||||
expect(screen.getByText('Red')).toBeInTheDocument();
|
||||
expect(screen.getByText('Blue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names a tag shared by several messages once', () => {
|
||||
renderThread([
|
||||
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:red': true } }),
|
||||
]);
|
||||
|
||||
expect(screen.getAllByText('Red')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows each message its own tags once the thread is expanded', () => {
|
||||
renderThread(
|
||||
[
|
||||
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:blue': true } }),
|
||||
],
|
||||
true,
|
||||
);
|
||||
|
||||
// Once on the header and once on the message that carries it.
|
||||
expect(screen.getAllByText('Red').length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem row content', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set<string>(),
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the subject without a tag', () => {
|
||||
renderRow(makeEmail({ subject: 'Hello World' }));
|
||||
|
||||
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders preview text inline in the focused layout', () => {
|
||||
useSettingsStore.setState({ showPreview: true, mailLayout: 'focus' });
|
||||
const { container } = renderRow(makeEmail({ preview: 'Inline preview content' }));
|
||||
|
||||
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
|
||||
// Focused rows are one line: the preview shares the subject's element
|
||||
// rather than getting a paragraph of its own.
|
||||
expect(container.querySelector('p')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem shift-range checkbox', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
});
|
||||
});
|
||||
|
||||
it('shift-clicking the checkbox extends the selection from the anchor', () => {
|
||||
const e1 = makeEmail({ id: 'e1', threadId: 't1' });
|
||||
const e2 = makeEmail({ id: 'e2', threadId: 't2' });
|
||||
const e3 = makeEmail({ id: 'e3', threadId: 't3' });
|
||||
// Selection mode active so the checkbox renders, with the anchor on e1.
|
||||
useEmailStore.setState({
|
||||
emails: [e1, e2, e3],
|
||||
selectedEmailIds: new Set(['e1']),
|
||||
lastSelectedEmailId: 'e1',
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
|
||||
renderRow(e3);
|
||||
const checkbox = screen.getAllByRole('button')[0];
|
||||
act(() => {
|
||||
checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }));
|
||||
});
|
||||
|
||||
const selected = useEmailStore.getState().selectedEmailIds;
|
||||
expect(selected.has('e1')).toBe(true);
|
||||
expect(selected.has('e2')).toBe(true); // the row in between got filled in
|
||||
expect(selected.has('e3')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem row tint', () => {
|
||||
const rowClasses = (container: HTMLElement) =>
|
||||
container.querySelector('[data-email-id="email-1"]')!.className.split(' ');
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
tintListRowsByTag: true,
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set(['email-1']),
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a checked row tinted, and says so to either theme', () => {
|
||||
const { container } = renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
|
||||
const classes = rowClasses(container);
|
||||
|
||||
expect(classes).toContain('bg-red-50');
|
||||
expect(classes).toContain('dark:bg-red-950/30');
|
||||
expect(classes).not.toContain('bg-accent/40');
|
||||
expect(classes).toContain('ring-primary/20');
|
||||
});
|
||||
|
||||
it('washes a checked row that has no tint to keep', () => {
|
||||
const { container } = renderRow(makeEmail({ keywords: { $seen: true } }));
|
||||
const classes = rowClasses(container);
|
||||
|
||||
expect(classes).toContain('bg-accent/40');
|
||||
expect(classes).toContain('ring-primary/20');
|
||||
});
|
||||
|
||||
it('leaves the tint alone when the setting is off', () => {
|
||||
useSettingsStore.setState({ tintListRowsByTag: false });
|
||||
const { container } = renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
|
||||
const classes = rowClasses(container);
|
||||
|
||||
expect(classes).not.toContain('bg-red-50');
|
||||
expect(classes).toContain('bg-accent/40');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user