From 454d51e283c507fdf24e3d5ad6eaa6dee51702a9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:56:43 +0100 Subject: [PATCH] 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. --- .../email/__tests__/email-list-item.test.tsx | 107 ++++++++++++ components/email/email-context-menu.tsx | 60 +++---- components/email/email-list-item.tsx | 29 +++- components/email/email-viewer.tsx | 81 +++++---- components/email/thread-list-item.tsx | 58 +++++-- .../__tests__/keyword-settings.test.tsx | 142 ++++++++++++++++ lib/__tests__/thread-utils.test.ts | 36 ++++ locales/de/common.json | 9 +- locales/en/common.json | 9 +- locales/es/common.json | 9 +- locales/fr/common.json | 9 +- locales/it/common.json | 9 +- locales/ja/common.json | 9 +- locales/nl/common.json | 9 +- locales/pt/common.json | 9 +- .../__tests__/settings-store-keywords.test.ts | 159 ++++++++++++++++++ 16 files changed, 610 insertions(+), 134 deletions(-) create mode 100644 components/email/__tests__/email-list-item.test.tsx create mode 100644 components/settings/__tests__/keyword-settings.test.tsx create mode 100644 stores/__tests__/settings-store-keywords.test.ts diff --git a/components/email/__tests__/email-list-item.test.tsx b/components/email/__tests__/email-list-item.test.tsx new file mode 100644 index 00000000..5d1e55f1 --- /dev/null +++ b/components/email/__tests__/email-list-item.test.tsx @@ -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 => ({ + 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(), + selectedMailbox: 'inbox', + }); + }); + + it('does not show tag badge when email has no label keyword', () => { + const email = makeEmail({ keywords: { $seen: true } }); + render(); + 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(); + expect(screen.getByText('Red')).toBeInTheDocument(); + }); + + it('shows tag badge for legacy $color: keyword', () => { + const email = makeEmail({ keywords: { $seen: true, '$color:blue': true } }); + render(); + 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(); + 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(); + expect(screen.getByText('Work')).toBeInTheDocument(); + }); + + it('updates badge when keyword definition changes', () => { + const email = makeEmail({ keywords: { $seen: true, '$label:red': true } }); + const { rerender } = render(); + expect(screen.getByText('Red')).toBeInTheDocument(); + + // Update label name + act(() => { + useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' }); + }); + rerender(); + expect(screen.getByText('Urgent')).toBeInTheDocument(); + expect(screen.queryByText('Red')).not.toBeInTheDocument(); + }); + + it('renders subject even without tag', () => { + const email = makeEmail({ subject: 'Hello World' }); + render(); + expect(screen.getByText('Hello World')).toBeInTheDocument(); + }); +}); diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 77dc07a4..4283ae05 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -21,6 +21,7 @@ import { FolderInput, Tag, X, + Check, Inbox, Send, File, @@ -270,49 +271,26 @@ export function EmailContextMenu({ - {/* Set color submenu - only for single email */} + {/* Set tag submenu - only for single email */} {!showBatchActions && ( -
{ - const buttons = Array.from( - e.currentTarget.querySelectorAll("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) => ( -
+ {colorOptions.map((option) => ( + + ))} {currentColor && ( <> diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 470f3336..edcee177 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -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 - {/* Second Line: Subject */} -
- {email.subject || t('no_subject')} + {/* Second Line: Subject + Tag */} +
+ + {email.subject || t('no_subject')} + + {keywordDef && ( + + + {keywordDef.label} + + )}
{/* Third Line: Preview (controlled by showPreview setting) */} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index e7c2cf89..10920123 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -33,7 +33,7 @@ import { File, Shield, Image, - Circle, + Tag, X, Check, AlertTriangle, @@ -797,57 +797,70 @@ export function EmailViewer({
- {/* Compact Dynamic Color Picker - hidden on mobile/tablet */} + {/* Tag Picker - hidden on mobile/tablet */}
- {/* Colors appear on hover */} -
-
- {colorOptions.map((option) => ( - + ))} + {currentColor && ( + <> +
- )} -
+ + )}
diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index a504f118..95a1b1f5 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -44,12 +44,12 @@ const SingleEmailItem = React.forwardRef( 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(
-
- {email.subject || "(no subject)"} +
+ + {email.subject || "(no subject)"} + + {resolvedKeywordDef && ( + + + {resolvedKeywordDef.label} + + )}
{showPreview && ( @@ -353,13 +364,24 @@ export const ThreadListItem = React.forwardRef
-
- {latestEmail.subject || "(no subject)"} +
+ + {latestEmail.subject || "(no subject)"} + + {keywordDef && ( + + + {keywordDef.label} + + )}
{showPreview && ( diff --git a/components/settings/__tests__/keyword-settings.test.tsx b/components/settings/__tests__/keyword-settings.test.tsx new file mode 100644 index 00000000..1c3c5f94 --- /dev/null +++ b/components/settings/__tests__/keyword-settings.test.tsx @@ -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 }) =>
{children}
, +})); + +describe('KeywordSettings', () => { + beforeEach(() => { + useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] }); + }); + + it('renders all default keywords', () => { + render(); + DEFAULT_KEYWORDS.forEach((kw) => { + expect(screen.getByText(kw.label)).toBeInTheDocument(); + }); + }); + + it('shows keyword JMAP id', () => { + render(); + expect(screen.getByText('$label:red')).toBeInTheDocument(); + expect(screen.getByText('$label:blue')).toBeInTheDocument(); + }); + + it('renders add keyword button', () => { + render(); + expect(screen.getByText('add_keyword')).toBeInTheDocument(); + }); + + it('renders reset defaults button', () => { + render(); + expect(screen.getByText('reset_defaults')).toBeInTheDocument(); + }); + + it('shows add form when add button clicked', () => { + render(); + 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(); + 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(); + 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(); + 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(); + // 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(); + 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(); + 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(); + fireEvent.click(screen.getByText('reset_defaults')); + + expect(useSettingsStore.getState().emailKeywords).toEqual(DEFAULT_KEYWORDS); + }); + + it('normalizes label to id correctly', () => { + render(); + 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!'); + }); +}); diff --git a/lib/__tests__/thread-utils.test.ts b/lib/__tests__/thread-utils.test.ts index 2c9c7dfd..d0b413c2 100644 --- a/lib/__tests__/thread-utils.test.ts +++ b/lib/__tests__/thread-utils.test.ts @@ -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)).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(); + }); }); diff --git a/locales/de/common.json b/locales/de/common.json index d1b4748f..e3b3fa0b 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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": { diff --git a/locales/en/common.json b/locales/en/common.json index d7c7b7d7..44dbd901 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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": { diff --git a/locales/es/common.json b/locales/es/common.json index 15dbe8b7..08c86780 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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": { diff --git a/locales/fr/common.json b/locales/fr/common.json index 9f9f5400..3cb1549a 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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": { diff --git a/locales/it/common.json b/locales/it/common.json index c263c165..a4bb77d0 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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": { diff --git a/locales/ja/common.json b/locales/ja/common.json index cefe324e..3edd0c40 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -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": { diff --git a/locales/nl/common.json b/locales/nl/common.json index d40ec70d..ae6a367f 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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": { diff --git a/locales/pt/common.json b/locales/pt/common.json index b4086fe3..ee6b3eb3 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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": { diff --git a/stores/__tests__/settings-store-keywords.test.ts b/stores/__tests__/settings-store-keywords.test.ts new file mode 100644 index 00000000..89b0aaa5 --- /dev/null +++ b/stores/__tests__/settings-store-keywords.test.ts @@ -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'); + }); + }); +});