feat: allow setting an address book as default (#924)

This commit is contained in:
Linus Rath
2026-08-29 17:15:50 +02:00
parent 39dcc6b219
commit 1807b2df6b
38 changed files with 399 additions and 13 deletions
@@ -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(
<ContactsSidebar
groups={[]}
individuals={[]}
addressBooks={books}
activeCategory="all"
onSelectCategory={vi.fn()}
onCreateGroup={vi.fn()}
onCreateContact={vi.fn()}
onSetDefaultAddressBook={onSetDefaultAddressBook}
/>,
);
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();
});
});
+6
View File
@@ -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);
+9
View File
@@ -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();
+23 -7
View File
@@ -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<ContactCard>();
const { contextMenu: bookContextMenu, openContextMenu: openBookContextMenu, closeContextMenu: closeBookContextMenu, menuRef: bookMenuRef } = useContextMenu<AddressBook>();
const hasBookMenu = !!(onRenameAddressBook || onShareAddressBook || onSetDefaultAddressBook || onCreateContactInBook || onDeleteAddressBook);
const { contextMenu: keywordContextMenu, openContextMenu: openKeywordContextMenu, closeContextMenu: closeKeywordContextMenu, menuRef: keywordMenuRef } = useContextMenu<string>();
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(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}
/>
))}
</div>
@@ -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}
/>
))}
</div>
@@ -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}
/>
))}
</div>
@@ -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}
/>
))}
</div>
@@ -601,13 +604,16 @@ export function ContactsSidebar({
</div>
{/* 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 (
<ContextMenu
ref={bookMenuRef}
@@ -645,6 +651,16 @@ export function ContactsSidebar({
}}
/>
)}
{canSetDefault && (
<ContextMenuItem
icon={Star}
label={t("address_books.set_default")}
onClick={() => {
closeBookContextMenu();
onSetDefaultAddressBook(book);
}}
/>
)}
{showSeparator && <ContextMenuSeparator />}
{canDelete && (
<ContextMenuItem
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Book, BookPlus, Pencil, Share2, Tag, Users } from "lucide-react";
import { Book, BookPlus, Pencil, Share2, Star, Tag, Users } from "lucide-react";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { useManagedAccountStore } from "@/stores/managed-account-store";
@@ -74,7 +74,7 @@ export function AddressBookManagementSettings() {
const tSettings = useTranslations("settings.contacts");
const { client } = useAuthStore();
const managedAccountId = useManagedAccountStore((s) => 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<string | null>(null);
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
const [sharingId, setSharingId] = useState<string | null>(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<string, { accountName: string; books: AddressBook[] }>();
@@ -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 (
<div
@@ -182,6 +198,17 @@ export function AddressBookManagementSettings() {
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{canSetDefault && (
<button
type="button"
onClick={() => handleSetDefault(book)}
disabled={isLoading}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
title={t("set_default")}
>
<Star className="w-3.5 h-3.5" />
</button>
)}
{!book.isShared && book.myRights?.mayShare && (
<button
type="button"