From 1807b2df6b174ccf9e2873f2d88dd4e61925ed1d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:15:50 +0200 Subject: [PATCH] feat: allow setting an address book as default (#924) --- .../__tests__/contacts-sidebar.test.tsx | 50 ++++++++++++- components/contacts/contact-form.tsx | 6 ++ components/contacts/contacts-app.tsx | 9 +++ components/contacts/contacts-sidebar.tsx | 30 ++++++-- .../address-book-management-settings.tsx | 31 +++++++- lib/__tests__/jmap-contact-client.test.ts | 63 ++++++++++++++++ lib/demo/demo-client.ts | 6 ++ lib/jmap/client-interface.ts | 1 + lib/jmap/client.ts | 32 +++++++- locales/ar/common.json | 3 + locales/ca/common.json | 3 + locales/cs/common.json | 3 + locales/da/common.json | 3 + locales/de/common.json | 3 + locales/en/common.json | 3 + locales/es/common.json | 3 + locales/fa/common.json | 3 + locales/fr/common.json | 3 + locales/he/common.json | 3 + locales/hu/common.json | 3 + locales/it/common.json | 3 + locales/ja/common.json | 3 + locales/ko/common.json | 3 + locales/lv/common.json | 3 + locales/mn/common.json | 3 + locales/nb/common.json | 3 + locales/nl/common.json | 3 + locales/pl/common.json | 3 + locales/pt/common.json | 3 + locales/ro/common.json | 3 + locales/ru/common.json | 3 + locales/sk/common.json | 3 + locales/tr/common.json | 3 + locales/uk/common.json | 3 + locales/zh-TW/common.json | 3 + locales/zh/common.json | 3 + stores/__tests__/contact-store.test.ts | 73 +++++++++++++++++++ stores/contact-store.ts | 30 ++++++++ 38 files changed, 399 insertions(+), 13 deletions(-) diff --git a/components/contacts/__tests__/contacts-sidebar.test.tsx b/components/contacts/__tests__/contacts-sidebar.test.tsx index a47534d0a..11a15b9a3 100644 --- a/components/contacts/__tests__/contacts-sidebar.test.tsx +++ b/components/contacts/__tests__/contacts-sidebar.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'; import { fireEvent } from '@testing-library/dom'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ContactsSidebar } from '../contacts-sidebar'; -import type { ContactCard } from '@/lib/jmap/types'; +import type { AddressBook, ContactCard } from '@/lib/jmap/types'; // next-intl + next/navigation are mocked globally in vitest.setup (t returns the key). vi.mock('@/stores/account-store', () => { @@ -63,3 +63,51 @@ describe('ContactsSidebar — compose to group', () => { expect(onComposeGroup).toHaveBeenCalledWith('g1', 'cc'); }); }); + +const books: AddressBook[] = [ + { id: 'ab-1', name: 'Personal', isDefault: true }, + { id: 'ab-2', name: 'Work' }, + { id: 'ab-3', name: 'Team', isShared: true, accountId: 'account-2', accountName: 'Team account' }, +]; + +function renderBooksSidebar(onSetDefaultAddressBook = vi.fn()) { + render( + , + ); + return onSetDefaultAddressBook; +} + +describe('ContactsSidebar - set default address book', () => { + beforeEach(() => vi.clearAllMocks()); + + it('offers "Set as default" for an owned, non-default book', () => { + const onSetDefaultAddressBook = renderBooksSidebar(); + fireEvent.contextMenu(screen.getByText('Work')); + + fireEvent.click(screen.getByText('address_books.set_default')); + expect(onSetDefaultAddressBook).toHaveBeenCalledWith(expect.objectContaining({ id: 'ab-2' })); + }); + + it('hides it for the book that is already the default', () => { + renderBooksSidebar(); + fireEvent.contextMenu(screen.getByText('Personal')); + + expect(screen.queryByText('address_books.set_default')).not.toBeInTheDocument(); + }); + + it('hides it for a book shared from another account', () => { + renderBooksSidebar(); + fireEvent.contextMenu(screen.getByText('Team')); + + expect(screen.queryByText('address_books.set_default')).not.toBeInTheDocument(); + }); +}); diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index 566d53011..b8265c545 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -359,6 +359,12 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress if (defaultAddressBookId && addressBooks?.some(b => b.id === defaultAddressBookId)) { return defaultAddressBookId; } + // New contacts land in the account's default book unless told otherwise - + // preselect it so the form shows where the contact will actually go. + if (!contact) { + const ownDefault = addressBooks?.find(b => b.isDefault && !b.isShared); + if (ownDefault) return ownDefault.id; + } return ""; }, [contact, defaultAddressBookId, addressBooks]); const [selectedBookId, setSelectedBookId] = useState(currentBookId); diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index a9def7097..ff7292a0f 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -98,6 +98,7 @@ export function ContactsApp({ linkSegments }: ContactsAppProps = {}) { renameAddressBook, removeAddressBook, shareAddressBook, + setDefaultAddressBook, renameKeyword, importContacts, } = useContactStore(); @@ -950,6 +951,14 @@ export function ContactsApp({ linkSegments }: ContactsAppProps = {}) { onDropContactsToCategory={handleDropContactsToCategory} onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined} onShareAddressBook={client ? (book) => setSharingAddressBookId(book.id) : undefined} + onSetDefaultAddressBook={client ? async (book) => { + try { + await setDefaultAddressBook(client, book); + toast.success(t("address_books.default_updated")); + } catch { + toast.error(t("address_books.set_default_failed")); + } + } : undefined} onCreateContactInBook={(book) => { setDefaultBookIdForCreate(book.id); handleCreateNew(); diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx index c645f2d3d..dc6ed5ad5 100644 --- a/components/contacts/contacts-sidebar.tsx +++ b/components/contacts/contacts-sidebar.tsx @@ -2,7 +2,7 @@ import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react"; import { useTranslations } from "next-intl"; -import { BookUser, User, Users, Plus, Share2, Book, BookPlus, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings, Mail } from "lucide-react"; +import { BookUser, User, Users, Plus, Share2, Book, BookPlus, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings, Mail, Star } from "lucide-react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu"; @@ -31,6 +31,7 @@ interface ContactsSidebarProps { onDropContactsToCategory?: (contactIds: string[], keyword: string) => void; onRenameAddressBook?: (addressBook: AddressBook) => void; onShareAddressBook?: (addressBook: AddressBook) => void; + onSetDefaultAddressBook?: (addressBook: AddressBook) => void; onCreateContactInBook?: (addressBook: AddressBook) => void; onDeleteAddressBook?: (addressBook: AddressBook) => void; onRenameKeyword?: (keyword: string) => void; @@ -101,6 +102,7 @@ export function ContactsSidebar({ onDropContactsToCategory, onRenameAddressBook, onShareAddressBook, + onSetDefaultAddressBook, onCreateContactInBook, onDeleteAddressBook, onRenameKeyword, @@ -111,6 +113,7 @@ export function ContactsSidebar({ const router = useRouter(); const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu(); const { contextMenu: bookContextMenu, openContextMenu: openBookContextMenu, closeContextMenu: closeBookContextMenu, menuRef: bookMenuRef } = useContextMenu(); + const hasBookMenu = !!(onRenameAddressBook || onShareAddressBook || onSetDefaultAddressBook || onCreateContactInBook || onDeleteAddressBook); const { contextMenu: keywordContextMenu, openContextMenu: openKeywordContextMenu, closeContextMenu: closeKeywordContextMenu, menuRef: keywordMenuRef } = useContextMenu(); const [collapsed, setCollapsed] = useState>(loadCollapsed); @@ -383,7 +386,7 @@ export function ContactsSidebar({ contactCount={contactCountByBook[book.id] || 0} onSelect={() => onSelectCategory({ addressBookId: book.id })} onDropContacts={onDropContacts} - onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined} + onContextMenu={hasBookMenu ? (e) => openBookContextMenu(e, book) : undefined} /> ))} @@ -402,7 +405,7 @@ export function ContactsSidebar({ contactCount={contactCountByBook[book.id] || 0} onSelect={() => onSelectCategory({ addressBookId: book.id })} onDropContacts={onDropContacts} - onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined} + onContextMenu={hasBookMenu ? (e) => openBookContextMenu(e, book) : undefined} /> ))} @@ -449,7 +452,7 @@ export function ContactsSidebar({ contactCount={contactCountByBook[book.id] || 0} onSelect={() => onSelectCategory({ addressBookId: book.id })} onDropContacts={onDropContacts} - onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined} + onContextMenu={hasBookMenu ? (e) => openBookContextMenu(e, book) : undefined} /> ))} @@ -593,7 +596,7 @@ export function ContactsSidebar({ contactCount={contactCountByBook[book.id] || 0} onSelect={() => onSelectCategory({ addressBookId: book.id })} onDropContacts={onDropContacts} - onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined} + onContextMenu={hasBookMenu ? (e) => openBookContextMenu(e, book) : undefined} /> ))} @@ -601,13 +604,16 @@ export function ContactsSidebar({ {/* Address book context menu */} - {bookContextMenu.data && (onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) && (() => { + {bookContextMenu.data && hasBookMenu && (() => { const book = bookContextMenu.data; const canCreate = onCreateContactInBook && book.myRights?.mayWrite !== false; const canRename = onRenameAddressBook && book.myRights?.mayWrite !== false; const canShare = onShareAddressBook && book.myRights?.mayShare && !book.isShared; + // The default only applies to books the account owns, and re-setting the + // current default is a no-op, so hide it in both cases. + const canSetDefault = onSetDefaultAddressBook && !book.isShared && !book.isDefault; const canDelete = onDeleteAddressBook && !book.isDefault && !book.isShared && book.myRights?.mayDelete !== false; - const showSeparator = (canCreate || canRename || canShare) && canDelete; + const showSeparator = (canCreate || canRename || canShare || canSetDefault) && canDelete; return ( )} + {canSetDefault && ( + { + closeBookContextMenu(); + onSetDefaultAddressBook(book); + }} + /> + )} {showSeparator && } {canDelete && ( s.managedAccountId); - const { addressBooks, contacts, supportsSync, fetchAddressBooks, createAddressBook, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore(); + const { addressBooks, contacts, supportsSync, fetchAddressBooks, createAddressBook, renameAddressBook, shareAddressBook, setDefaultAddressBook, renameKeyword } = useContactStore(); const [editingId, setEditingId] = useState(null); const [editingKeyword, setEditingKeyword] = useState(null); const [sharingId, setSharingId] = useState(null); @@ -116,6 +116,19 @@ export function AddressBookManagementSettings() { } }; + const handleSetDefault = async (book: AddressBook) => { + if (!client) return; + setIsLoading(true); + try { + await setDefaultAddressBook(client, book); + toast.success(t("default_updated")); + } catch { + toast.error(t("set_default_failed")); + } finally { + setIsLoading(false); + } + }; + // Group: personal first, then by shared account const personal = addressBooks.filter((b) => !b.isShared); const sharedGroups = new Map(); @@ -141,6 +154,9 @@ export function AddressBookManagementSettings() { } const canRename = !book.isShared || book.myRights?.mayWrite !== false; + // The default only applies to books the account owns, and re-setting the + // current default is a no-op, so hide it in both cases. + const canSetDefault = !!client && !book.isShared && !book.isDefault; return (
)} + {canSetDefault && ( + + )} {!book.isShared && book.myRights?.mayShare && (