feat: refactor email tagging system to use labels instead of colors
- Updated EmailContextMenu to replace color tag functionality with label tags. - Modified EmailListItem to display label badges for emails based on keywords. - Enhanced EmailViewer to include a tag picker for emails. - Adjusted ThreadListItem to show label badges for email subjects. - Added tests for email list item and keyword settings to ensure proper functionality of the new tagging system. - Updated localization files to reflect changes from color tags to labels in multiple languages.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
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,
|
||||
});
|
||||
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('does not show badge when keyword id not in settings', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } });
|
||||
render(<EmailListItem email={email} />);
|
||||
expect(screen.queryByText('unknown-tag')).not.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();
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FolderInput,
|
||||
Tag,
|
||||
X,
|
||||
Check,
|
||||
Inbox,
|
||||
Send,
|
||||
File,
|
||||
@@ -270,49 +271,26 @@ export function EmailContextMenu({
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
{/* Set color submenu - only for single email */}
|
||||
{/* Set tag submenu - only for single email */}
|
||||
{!showBatchActions && (
|
||||
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
||||
<div
|
||||
className="px-3 py-2 flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-label={t("color_tag")}
|
||||
onKeyDown={(e) => {
|
||||
const buttons = Array.from(
|
||||
e.currentTarget.querySelectorAll<HTMLButtonElement>("button")
|
||||
);
|
||||
const idx = buttons.indexOf(e.target as HTMLButtonElement);
|
||||
if (idx < 0) return;
|
||||
let next = -1;
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||
next = (idx + 1) % buttons.length;
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||
next = (idx - 1 + buttons.length) % buttons.length;
|
||||
}
|
||||
if (next >= 0) {
|
||||
e.preventDefault();
|
||||
buttons[next].focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{colorOptions.map((option, i) => (
|
||||
<button
|
||||
key={option.value}
|
||||
tabIndex={i === 0 ? 0 : -1}
|
||||
onClick={() =>
|
||||
handleAction(() => onSetColorTag?.(option.value))
|
||||
}
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full hover:scale-110 transition-transform focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
option.color,
|
||||
currentColor === option.value &&
|
||||
"ring-2 ring-offset-2 ring-offset-background ring-foreground"
|
||||
)}
|
||||
title={option.name}
|
||||
aria-label={option.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{colorOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
role="menuitem"
|
||||
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
|
||||
currentColor === option.value && "bg-accent font-medium"
|
||||
)}
|
||||
>
|
||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||
<span className="flex-1">{option.name}</span>
|
||||
{currentColor === option.value && (
|
||||
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{currentColor && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
|
||||
@@ -5,7 +5,7 @@ import { formatDate } from "@/lib/utils";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react";
|
||||
import { Paperclip, Star, Circle, CheckSquare, Square, Tag } from "lucide-react";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
@@ -157,14 +157,25 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Second Line: Subject */}
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{email.subject || t('no_subject')}
|
||||
{/* Second Line: Subject + Tag */}
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className={cn(
|
||||
"line-clamp-1 text-sm min-w-0",
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{email.subject || t('no_subject')}
|
||||
</span>
|
||||
{keywordDef && (
|
||||
<span className={cn(
|
||||
"flex-shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
|
||||
)}>
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
||||
{keywordDef.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Third Line: Preview (controlled by showPreview setting) */}
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
File,
|
||||
Shield,
|
||||
Image,
|
||||
Circle,
|
||||
Tag,
|
||||
X,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
@@ -797,57 +797,70 @@ export function EmailViewer({
|
||||
|
||||
<div className="w-px h-5 bg-border mx-1 hidden lg:block" />
|
||||
|
||||
{/* Compact Dynamic Color Picker - hidden on mobile/tablet */}
|
||||
{/* Tag Picker - hidden on mobile/tablet */}
|
||||
<div className="relative group hidden lg:block">
|
||||
<button
|
||||
className="h-8 w-8 rounded hover:bg-muted flex items-center justify-center"
|
||||
className={cn(
|
||||
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
|
||||
currentColor && "bg-muted/50"
|
||||
)}
|
||||
title={t('set_color')}
|
||||
>
|
||||
{(() => {
|
||||
const kw = currentColor ? emailKeywords.find(k => k.id === currentColor) : null;
|
||||
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
|
||||
return dotClass
|
||||
? <div className={cn("w-4 h-4 rounded-full", dotClass)} />
|
||||
: <Circle className="w-4 h-4 text-gray-400" />;
|
||||
return dotClass ? (
|
||||
<>
|
||||
<span className={cn("w-3 h-3 rounded-full", dotClass)} />
|
||||
<span className="text-xs font-medium text-foreground">{kw!.label}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tag className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">{t('tag')}</span>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</button>
|
||||
|
||||
{/* Colors appear on hover */}
|
||||
<div className="absolute right-0 top-full mt-1 p-1.5 bg-background rounded-lg shadow-lg border border-border opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-10">
|
||||
<div className="flex gap-1">
|
||||
{colorOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
if (email) {
|
||||
onSetColorTag?.(email.id, option.value);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full hover:scale-110 transition-transform",
|
||||
option.color,
|
||||
currentColor === option.value && "ring-2 ring-offset-1 ring-gray-400"
|
||||
)}
|
||||
title={option.name}
|
||||
/>
|
||||
))}
|
||||
{currentColor && (
|
||||
<div className="w-px bg-gray-200 dark:bg-gray-700 mx-0.5" />
|
||||
)}
|
||||
{currentColor && (
|
||||
{/* Tag dropdown on hover */}
|
||||
<div className="absolute right-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-10">
|
||||
{colorOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
if (email) {
|
||||
onSetColorTag?.(email.id, option.value);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
|
||||
currentColor === option.value && "bg-accent font-medium"
|
||||
)}
|
||||
>
|
||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||
<span className="truncate">{option.name}</span>
|
||||
{currentColor === option.value && (
|
||||
<Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{currentColor && (
|
||||
<>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
onClick={() => {
|
||||
if (email) {
|
||||
onSetColorTag?.(email.id, null);
|
||||
}
|
||||
}}
|
||||
className="w-6 h-6 rounded-full border border-gray-300 dark:border-gray-600 hover:bg-gray-100 hover:bg-muted flex items-center justify-center"
|
||||
title={t('remove_color')}
|
||||
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<X className="w-3 h-3 text-muted-foreground" />
|
||||
<X className="w-3 h-3 flex-shrink-0" />
|
||||
<span>{t('remove_color')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -44,12 +44,12 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
|
||||
// Resolve color from keyword definitions if not passed directly
|
||||
// Resolve color and keyword definition from keyword definitions if not passed directly
|
||||
const tagId = getEmailColorTag(email.keywords);
|
||||
const resolvedKeywordDef = tagId ? emailKeywords.find(k => k.id === tagId) : null;
|
||||
const resolvedColorTag = (() => {
|
||||
if (colorTag) return colorTag;
|
||||
const tagId = getEmailColorTag(email.keywords);
|
||||
const kw = tagId ? emailKeywords.find(k => k.id === tagId) : null;
|
||||
return kw ? KEYWORD_PALETTE[kw.color]?.bg ?? null : null;
|
||||
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
|
||||
})();
|
||||
|
||||
const { dragHandlers, isDragging } = useEmailDrag({
|
||||
@@ -139,13 +139,24 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{email.subject || "(no subject)"}
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className={cn(
|
||||
"line-clamp-1 text-sm min-w-0",
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{email.subject || "(no subject)"}
|
||||
</span>
|
||||
{resolvedKeywordDef && (
|
||||
<span className={cn(
|
||||
"flex-shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted"
|
||||
)}>
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} />
|
||||
{resolvedKeywordDef.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPreview && (
|
||||
@@ -353,13 +364,24 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
hasUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{latestEmail.subject || "(no subject)"}
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className={cn(
|
||||
"line-clamp-1 text-sm min-w-0",
|
||||
hasUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{latestEmail.subject || "(no subject)"}
|
||||
</span>
|
||||
{keywordDef && (
|
||||
<span className={cn(
|
||||
"flex-shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
|
||||
)}>
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
||||
{keywordDef.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPreview && (
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { KeywordSettings } from '../keyword-settings';
|
||||
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
||||
|
||||
// Mock SettingsSection to just render children
|
||||
vi.mock('../settings-section', () => ({
|
||||
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
describe('KeywordSettings', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] });
|
||||
});
|
||||
|
||||
it('renders all default keywords', () => {
|
||||
render(<KeywordSettings />);
|
||||
DEFAULT_KEYWORDS.forEach((kw) => {
|
||||
expect(screen.getByText(kw.label)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows keyword JMAP id', () => {
|
||||
render(<KeywordSettings />);
|
||||
expect(screen.getByText('$label:red')).toBeInTheDocument();
|
||||
expect(screen.getByText('$label:blue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders add keyword button', () => {
|
||||
render(<KeywordSettings />);
|
||||
expect(screen.getByText('add_keyword')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders reset defaults button', () => {
|
||||
render(<KeywordSettings />);
|
||||
expect(screen.getByText('reset_defaults')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows add form when add button clicked', () => {
|
||||
render(<KeywordSettings />);
|
||||
fireEvent.click(screen.getByText('add_keyword'));
|
||||
expect(screen.getByPlaceholderText('label_placeholder')).toBeInTheDocument();
|
||||
// Cancel and save buttons should appear
|
||||
expect(screen.getByText('cancel')).toBeInTheDocument();
|
||||
expect(screen.getByText('add')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a new keyword through the form', () => {
|
||||
render(<KeywordSettings />);
|
||||
fireEvent.click(screen.getByText('add_keyword'));
|
||||
|
||||
const input = screen.getByPlaceholderText('label_placeholder');
|
||||
fireEvent.change(input, { target: { value: 'Important' } });
|
||||
fireEvent.click(screen.getByText('add'));
|
||||
|
||||
const keywords = useSettingsStore.getState().emailKeywords;
|
||||
expect(keywords).toHaveLength(DEFAULT_KEYWORDS.length + 1);
|
||||
expect(keywords[keywords.length - 1].label).toBe('Important');
|
||||
expect(keywords[keywords.length - 1].id).toBe('important');
|
||||
});
|
||||
|
||||
it('prevents adding keyword with duplicate id', () => {
|
||||
render(<KeywordSettings />);
|
||||
fireEvent.click(screen.getByText('add_keyword'));
|
||||
|
||||
const input = screen.getByPlaceholderText('label_placeholder');
|
||||
fireEvent.change(input, { target: { value: 'Red' } });
|
||||
|
||||
// Should show duplicate warning
|
||||
expect(screen.getByText('id_exists')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('cancels add form when cancel clicked', () => {
|
||||
render(<KeywordSettings />);
|
||||
fireEvent.click(screen.getByText('add_keyword'));
|
||||
expect(screen.getByPlaceholderText('label_placeholder')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('cancel'));
|
||||
expect(screen.queryByPlaceholderText('label_placeholder')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('deletes keyword when delete button clicked', () => {
|
||||
render(<KeywordSettings />);
|
||||
// Find delete buttons (title="delete")
|
||||
const deleteButtons = screen.getAllByTitle('delete');
|
||||
expect(deleteButtons.length).toBe(DEFAULT_KEYWORDS.length);
|
||||
|
||||
// Delete the first keyword
|
||||
fireEvent.click(deleteButtons[0]);
|
||||
expect(useSettingsStore.getState().emailKeywords).toHaveLength(DEFAULT_KEYWORDS.length - 1);
|
||||
expect(useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('shows edit form when edit button clicked', () => {
|
||||
render(<KeywordSettings />);
|
||||
const editButtons = screen.getAllByTitle('edit');
|
||||
fireEvent.click(editButtons[0]); // edit first keyword (Red)
|
||||
|
||||
const input = screen.getByDisplayValue('Red');
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(screen.getByText('save')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates keyword label through edit form', () => {
|
||||
render(<KeywordSettings />);
|
||||
const editButtons = screen.getAllByTitle('edit');
|
||||
fireEvent.click(editButtons[0]); // edit "Red"
|
||||
|
||||
const input = screen.getByDisplayValue('Red');
|
||||
fireEvent.change(input, { target: { value: 'Crimson' } });
|
||||
fireEvent.click(screen.getByText('save'));
|
||||
|
||||
const kw = useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red');
|
||||
expect(kw?.label).toBe('Crimson');
|
||||
});
|
||||
|
||||
it('resets to defaults when reset button clicked', () => {
|
||||
// Modify keywords first
|
||||
useSettingsStore.getState().removeKeyword('red');
|
||||
useSettingsStore.getState().removeKeyword('blue');
|
||||
expect(useSettingsStore.getState().emailKeywords).toHaveLength(DEFAULT_KEYWORDS.length - 2);
|
||||
|
||||
render(<KeywordSettings />);
|
||||
fireEvent.click(screen.getByText('reset_defaults'));
|
||||
|
||||
expect(useSettingsStore.getState().emailKeywords).toEqual(DEFAULT_KEYWORDS);
|
||||
});
|
||||
|
||||
it('normalizes label to id correctly', () => {
|
||||
render(<KeywordSettings />);
|
||||
fireEvent.click(screen.getByText('add_keyword'));
|
||||
|
||||
const input = screen.getByPlaceholderText('label_placeholder');
|
||||
fireEvent.change(input, { target: { value: 'My Custom Tag!' } });
|
||||
fireEvent.click(screen.getByText('add'));
|
||||
|
||||
const keywords = useSettingsStore.getState().emailKeywords;
|
||||
const added = keywords[keywords.length - 1];
|
||||
expect(added.id).toBe('my-custom-tag');
|
||||
expect(added.label).toBe('My Custom Tag!');
|
||||
});
|
||||
});
|
||||
@@ -225,6 +225,22 @@ describe('getEmailColorTag', () => {
|
||||
it('returns null for undefined keywords', () => {
|
||||
expect(getEmailColorTag(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores keywords set to false', () => {
|
||||
expect(getEmailColorTag({ '$label:red': false } as unknown as Record<string, boolean>)).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers $label: over $color: when both exist', () => {
|
||||
expect(getEmailColorTag({ '$label:blue': true, '$color:red': true })).toBe('blue');
|
||||
});
|
||||
|
||||
it('handles custom keyword ids', () => {
|
||||
expect(getEmailColorTag({ '$label:my-custom-tag': true })).toBe('my-custom-tag');
|
||||
});
|
||||
|
||||
it('returns null for empty keywords object', () => {
|
||||
expect(getEmailColorTag({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThreadColorTag', () => {
|
||||
@@ -243,4 +259,24 @@ describe('getThreadColorTag', () => {
|
||||
];
|
||||
expect(getThreadColorTag(emails)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns first tag from earliest tagged email', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$label:blue': true } }),
|
||||
];
|
||||
expect(getThreadColorTag(emails)).toBe('red');
|
||||
});
|
||||
|
||||
it('returns legacy tag from thread emails', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$color:green': true } }),
|
||||
];
|
||||
expect(getThreadColorTag(emails)).toBe('green');
|
||||
});
|
||||
|
||||
it('returns null for empty email array', () => {
|
||||
expect(getThreadColorTag([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,9 +155,10 @@
|
||||
"back_to_list": "Zurück zur Liste",
|
||||
"message_details": "Nachrichtendetails",
|
||||
"more_reply_options": "Weitere Antwortoptionen",
|
||||
"set_color": "Farbe festlegen",
|
||||
"set_color": "Label setzen",
|
||||
"tag": "Label",
|
||||
"more_actions": "Weitere Aktionen",
|
||||
"remove_color": "Farbe entfernen",
|
||||
"remove_color": "Label entfernen",
|
||||
"more_count": "+{count} weitere",
|
||||
"characters_count": "{count} Zeichen",
|
||||
"quick_reply_placeholder": "Eine kurze Antwort schreiben...",
|
||||
@@ -929,8 +930,8 @@
|
||||
"delete": "Löschen",
|
||||
"mark_as_spam": "Spam melden",
|
||||
"not_spam": "Kein Spam",
|
||||
"color_tag": "Farb-Tag",
|
||||
"remove_color": "Farbe entfernen",
|
||||
"color_tag": "Label",
|
||||
"remove_color": "Label entfernen",
|
||||
"items_selected": "{count} E-Mails ausgewählt"
|
||||
},
|
||||
"shortcuts": {
|
||||
|
||||
@@ -156,9 +156,10 @@
|
||||
"back_to_list": "Back to list",
|
||||
"message_details": "Message Details",
|
||||
"more_reply_options": "More reply options",
|
||||
"set_color": "Set color",
|
||||
"set_color": "Set tag",
|
||||
"tag": "Tag",
|
||||
"more_actions": "More actions",
|
||||
"remove_color": "Remove color",
|
||||
"remove_color": "Remove tag",
|
||||
"more_count": "+{count} more",
|
||||
"characters_count": "{count} characters",
|
||||
"quick_reply_placeholder": "Write a quick reply...",
|
||||
@@ -974,8 +975,8 @@
|
||||
"delete": "Delete",
|
||||
"mark_as_spam": "Report spam",
|
||||
"not_spam": "Not spam",
|
||||
"color_tag": "Color Tag",
|
||||
"remove_color": "Remove Color",
|
||||
"color_tag": "Label",
|
||||
"remove_color": "Remove Label",
|
||||
"items_selected": "{count} emails selected"
|
||||
},
|
||||
"shortcuts": {
|
||||
|
||||
@@ -155,9 +155,10 @@
|
||||
"back_to_list": "Volver a la lista",
|
||||
"message_details": "Detalles del Mensaje",
|
||||
"more_reply_options": "Más opciones de respuesta",
|
||||
"set_color": "Establecer color",
|
||||
"set_color": "Establecer etiqueta",
|
||||
"tag": "Etiqueta",
|
||||
"more_actions": "Más acciones",
|
||||
"remove_color": "Eliminar color",
|
||||
"remove_color": "Eliminar etiqueta",
|
||||
"more_count": "+{count} más",
|
||||
"characters_count": "{count} caracteres",
|
||||
"quick_reply_placeholder": "Escriba una respuesta rápida...",
|
||||
@@ -929,8 +930,8 @@
|
||||
"delete": "Eliminar",
|
||||
"mark_as_spam": "Reportar spam",
|
||||
"not_spam": "No es spam",
|
||||
"color_tag": "Etiqueta de Color",
|
||||
"remove_color": "Eliminar Color",
|
||||
"color_tag": "Etiqueta",
|
||||
"remove_color": "Eliminar etiqueta",
|
||||
"items_selected": "{count} correos seleccionados"
|
||||
},
|
||||
"shortcuts": {
|
||||
|
||||
@@ -155,9 +155,10 @@
|
||||
"back_to_list": "Retour à la liste",
|
||||
"message_details": "Détails du message",
|
||||
"more_reply_options": "Plus d'options de réponse",
|
||||
"set_color": "Définir la couleur",
|
||||
"set_color": "Définir l'étiquette",
|
||||
"tag": "Étiquette",
|
||||
"more_actions": "Plus d'actions",
|
||||
"remove_color": "Retirer la couleur",
|
||||
"remove_color": "Retirer l'étiquette",
|
||||
"more_count": "+{count} de plus",
|
||||
"characters_count": "{count} caractères",
|
||||
"quick_reply_placeholder": "Écrivez une réponse rapide...",
|
||||
@@ -929,8 +930,8 @@
|
||||
"delete": "Supprimer",
|
||||
"mark_as_spam": "Signaler comme spam",
|
||||
"not_spam": "Pas un spam",
|
||||
"color_tag": "Étiquette de couleur",
|
||||
"remove_color": "Supprimer la couleur",
|
||||
"color_tag": "Étiquette",
|
||||
"remove_color": "Supprimer l'étiquette",
|
||||
"items_selected": "{count} emails sélectionnés"
|
||||
},
|
||||
"shortcuts": {
|
||||
|
||||
@@ -155,9 +155,10 @@
|
||||
"back_to_list": "Torna all'elenco",
|
||||
"message_details": "Dettagli del messaggio",
|
||||
"more_reply_options": "Più opzioni di risposta",
|
||||
"set_color": "Imposta colore",
|
||||
"set_color": "Imposta etichetta",
|
||||
"tag": "Etichetta",
|
||||
"more_actions": "Altre azioni",
|
||||
"remove_color": "Rimuovi colore",
|
||||
"remove_color": "Rimuovi etichetta",
|
||||
"more_count": "+{count} altri",
|
||||
"characters_count": "{count} caratteri",
|
||||
"quick_reply_placeholder": "Scrivi una risposta veloce...",
|
||||
@@ -929,8 +930,8 @@
|
||||
"delete": "Elimina",
|
||||
"mark_as_spam": "Segnala come spam",
|
||||
"not_spam": "Non spam",
|
||||
"color_tag": "Etichetta colore",
|
||||
"remove_color": "Rimuovi colore",
|
||||
"color_tag": "Etichetta",
|
||||
"remove_color": "Rimuovi etichetta",
|
||||
"items_selected": "{count} messaggi selezionati"
|
||||
},
|
||||
"shortcuts": {
|
||||
|
||||
@@ -155,9 +155,10 @@
|
||||
"back_to_list": "リストに戻る",
|
||||
"message_details": "メッセージの詳細",
|
||||
"more_reply_options": "その他の返信オプション",
|
||||
"set_color": "色を設定",
|
||||
"set_color": "ラベルを設定",
|
||||
"tag": "ラベル",
|
||||
"more_actions": "その他の操作",
|
||||
"remove_color": "色を削除",
|
||||
"remove_color": "ラベルを削除",
|
||||
"more_count": "他{count}件",
|
||||
"characters_count": "{count}文字",
|
||||
"quick_reply_placeholder": "クイック返信を入力...",
|
||||
@@ -929,8 +930,8 @@
|
||||
"delete": "削除",
|
||||
"mark_as_spam": "迷惑メールを報告",
|
||||
"not_spam": "迷惑メールでない",
|
||||
"color_tag": "カラータグ",
|
||||
"remove_color": "色を削除",
|
||||
"color_tag": "ラベル",
|
||||
"remove_color": "ラベルを削除",
|
||||
"items_selected": "{count}件のメールを選択"
|
||||
},
|
||||
"shortcuts": {
|
||||
|
||||
@@ -155,9 +155,10 @@
|
||||
"back_to_list": "Terug naar lijst",
|
||||
"message_details": "Berichtdetails",
|
||||
"more_reply_options": "Meer antwoordopties",
|
||||
"set_color": "Kleur instellen",
|
||||
"set_color": "Label instellen",
|
||||
"tag": "Label",
|
||||
"more_actions": "Meer acties",
|
||||
"remove_color": "Kleur verwijderen",
|
||||
"remove_color": "Label verwijderen",
|
||||
"more_count": "+{count} meer",
|
||||
"characters_count": "{count} tekens",
|
||||
"quick_reply_placeholder": "Schrijf een snel antwoord...",
|
||||
@@ -929,8 +930,8 @@
|
||||
"delete": "Verwijderen",
|
||||
"mark_as_spam": "Spam melden",
|
||||
"not_spam": "Geen spam",
|
||||
"color_tag": "Kleurtag",
|
||||
"remove_color": "Kleur verwijderen",
|
||||
"color_tag": "Label",
|
||||
"remove_color": "Label verwijderen",
|
||||
"items_selected": "{count} e-mails geselecteerd"
|
||||
},
|
||||
"shortcuts": {
|
||||
|
||||
@@ -155,9 +155,10 @@
|
||||
"back_to_list": "Voltar para a lista",
|
||||
"message_details": "Detalhes da Mensagem",
|
||||
"more_reply_options": "Mais opções de resposta",
|
||||
"set_color": "Definir cor",
|
||||
"set_color": "Definir etiqueta",
|
||||
"tag": "Etiqueta",
|
||||
"more_actions": "Mais ações",
|
||||
"remove_color": "Remover cor",
|
||||
"remove_color": "Remover etiqueta",
|
||||
"more_count": "+{count} mais",
|
||||
"characters_count": "{count} caracteres",
|
||||
"quick_reply_placeholder": "Escreva uma resposta rápida...",
|
||||
@@ -929,8 +930,8 @@
|
||||
"delete": "Excluir",
|
||||
"mark_as_spam": "Reportar spam",
|
||||
"not_spam": "Não é spam",
|
||||
"color_tag": "Etiqueta de Cor",
|
||||
"remove_color": "Remover Cor",
|
||||
"color_tag": "Etiqueta",
|
||||
"remove_color": "Remover etiqueta",
|
||||
"items_selected": "{count} e-mails selecionados"
|
||||
},
|
||||
"shortcuts": {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useSettingsStore, DEFAULT_KEYWORDS, KEYWORD_PALETTE } from '../settings-store';
|
||||
import type { KeywordDefinition } from '../settings-store';
|
||||
|
||||
describe('settings-store keywords', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] });
|
||||
});
|
||||
|
||||
describe('DEFAULT_KEYWORDS', () => {
|
||||
it('has 7 default keywords', () => {
|
||||
expect(DEFAULT_KEYWORDS).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('each default keyword has a valid palette color', () => {
|
||||
DEFAULT_KEYWORDS.forEach((kw) => {
|
||||
expect(KEYWORD_PALETTE[kw.color]).toBeDefined();
|
||||
expect(KEYWORD_PALETTE[kw.color].dot).toBeTruthy();
|
||||
expect(KEYWORD_PALETTE[kw.color].bg).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('all default keyword ids are unique', () => {
|
||||
const ids = DEFAULT_KEYWORDS.map((k) => k.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('KEYWORD_PALETTE', () => {
|
||||
it('has 13 colors', () => {
|
||||
expect(Object.keys(KEYWORD_PALETTE)).toHaveLength(13);
|
||||
});
|
||||
|
||||
it('each color has dot and bg classes', () => {
|
||||
Object.values(KEYWORD_PALETTE).forEach((entry) => {
|
||||
expect(entry.dot).toMatch(/^bg-/);
|
||||
expect(entry.bg).toMatch(/^bg-/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('addKeyword', () => {
|
||||
it('adds a new keyword to the list', () => {
|
||||
const newKw: KeywordDefinition = { id: 'custom', label: 'Custom', color: 'teal' };
|
||||
useSettingsStore.getState().addKeyword(newKw);
|
||||
const keywords = useSettingsStore.getState().emailKeywords;
|
||||
expect(keywords).toHaveLength(DEFAULT_KEYWORDS.length + 1);
|
||||
expect(keywords[keywords.length - 1]).toEqual(newKw);
|
||||
});
|
||||
|
||||
it('does not add duplicate keyword id', () => {
|
||||
const duplicate: KeywordDefinition = { id: 'red', label: 'Another Red', color: 'red' };
|
||||
useSettingsStore.getState().addKeyword(duplicate);
|
||||
expect(useSettingsStore.getState().emailKeywords).toHaveLength(DEFAULT_KEYWORDS.length);
|
||||
});
|
||||
|
||||
it('allows adding keyword after removing one with same id', () => {
|
||||
useSettingsStore.getState().removeKeyword('red');
|
||||
const newRed: KeywordDefinition = { id: 'red', label: 'New Red', color: 'red' };
|
||||
useSettingsStore.getState().addKeyword(newRed);
|
||||
const kw = useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red');
|
||||
expect(kw?.label).toBe('New Red');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateKeyword', () => {
|
||||
it('updates label of existing keyword', () => {
|
||||
useSettingsStore.getState().updateKeyword('red', { label: 'Crimson' });
|
||||
const kw = useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red');
|
||||
expect(kw?.label).toBe('Crimson');
|
||||
expect(kw?.color).toBe('red'); // color unchanged
|
||||
});
|
||||
|
||||
it('updates color of existing keyword', () => {
|
||||
useSettingsStore.getState().updateKeyword('blue', { color: 'cyan' });
|
||||
const kw = useSettingsStore.getState().emailKeywords.find((k) => k.id === 'blue');
|
||||
expect(kw?.color).toBe('cyan');
|
||||
expect(kw?.label).toBe('Blue'); // label unchanged
|
||||
});
|
||||
|
||||
it('updates both label and color', () => {
|
||||
useSettingsStore.getState().updateKeyword('green', { label: 'Emerald', color: 'teal' });
|
||||
const kw = useSettingsStore.getState().emailKeywords.find((k) => k.id === 'green');
|
||||
expect(kw?.label).toBe('Emerald');
|
||||
expect(kw?.color).toBe('teal');
|
||||
});
|
||||
|
||||
it('does not affect other keywords', () => {
|
||||
useSettingsStore.getState().updateKeyword('red', { label: 'Crimson' });
|
||||
const blue = useSettingsStore.getState().emailKeywords.find((k) => k.id === 'blue');
|
||||
expect(blue?.label).toBe('Blue');
|
||||
});
|
||||
|
||||
it('is a no-op for non-existent id', () => {
|
||||
const before = useSettingsStore.getState().emailKeywords;
|
||||
useSettingsStore.getState().updateKeyword('nonexistent', { label: 'Test' });
|
||||
const after = useSettingsStore.getState().emailKeywords;
|
||||
expect(after).toHaveLength(before.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeKeyword', () => {
|
||||
it('removes a keyword by id', () => {
|
||||
useSettingsStore.getState().removeKeyword('red');
|
||||
const keywords = useSettingsStore.getState().emailKeywords;
|
||||
expect(keywords).toHaveLength(DEFAULT_KEYWORDS.length - 1);
|
||||
expect(keywords.find((k) => k.id === 'red')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is a no-op for non-existent id', () => {
|
||||
useSettingsStore.getState().removeKeyword('nonexistent');
|
||||
expect(useSettingsStore.getState().emailKeywords).toHaveLength(DEFAULT_KEYWORDS.length);
|
||||
});
|
||||
|
||||
it('preserves order of remaining keywords', () => {
|
||||
useSettingsStore.getState().removeKeyword('green');
|
||||
const ids = useSettingsStore.getState().emailKeywords.map((k) => k.id);
|
||||
expect(ids).toEqual(['red', 'orange', 'yellow', 'blue', 'purple', 'pink']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorderKeywords', () => {
|
||||
it('replaces keyword list with new ordering', () => {
|
||||
const reversed = [...DEFAULT_KEYWORDS].reverse();
|
||||
useSettingsStore.getState().reorderKeywords(reversed);
|
||||
const ids = useSettingsStore.getState().emailKeywords.map((k) => k.id);
|
||||
expect(ids).toEqual(reversed.map((k) => k.id));
|
||||
});
|
||||
|
||||
it('can set to empty list', () => {
|
||||
useSettingsStore.getState().reorderKeywords([]);
|
||||
expect(useSettingsStore.getState().emailKeywords).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('can reset to defaults', () => {
|
||||
useSettingsStore.getState().removeKeyword('red');
|
||||
useSettingsStore.getState().removeKeyword('blue');
|
||||
useSettingsStore.getState().reorderKeywords(DEFAULT_KEYWORDS);
|
||||
expect(useSettingsStore.getState().emailKeywords).toEqual(DEFAULT_KEYWORDS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getKeywordById', () => {
|
||||
it('finds keyword by id', () => {
|
||||
const kw = useSettingsStore.getState().getKeywordById('blue');
|
||||
expect(kw).toEqual({ id: 'blue', label: 'Blue', color: 'blue' });
|
||||
});
|
||||
|
||||
it('returns undefined for non-existent id', () => {
|
||||
expect(useSettingsStore.getState().getKeywordById('nonexistent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns updated keyword data after updateKeyword', () => {
|
||||
useSettingsStore.getState().updateKeyword('red', { label: 'Scarlet' });
|
||||
const kw = useSettingsStore.getState().getKeywordById('red');
|
||||
expect(kw?.label).toBe('Scarlet');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user