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"
+63
View File
@@ -161,6 +161,69 @@ describe('JMAPClient contact methods', () => {
});
});
describe('setDefaultAddressBook', () => {
it('should send onSuccessSetIsDefault rather than an isDefault update', async () => {
const client = createClient();
const spy = mockFetch({
methodResponses: [['AddressBook/set', { updated: null }, '0']],
});
await client.setDefaultAddressBook('ab-2');
const body = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string);
expect(body.methodCalls[0][0]).toBe('AddressBook/set');
expect(body.methodCalls[0][1]).toMatchObject({
accountId: 'account-1',
onSuccessSetIsDefault: 'ab-2',
});
expect(body.methodCalls[0][1].update).toBeUndefined();
});
it('should target a shared account when one is given', async () => {
const client = createClient();
const spy = mockFetch({
methodResponses: [['AddressBook/set', { updated: null }, '0']],
});
await client.setDefaultAddressBook('ab-2', 'account-2');
const body = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string);
expect(body.methodCalls[0][1].accountId).toBe('account-2');
});
it('should throw on a JMAP method error', async () => {
const client = createClient();
mockFetch({
methodResponses: [['error', { type: 'forbidden', description: 'Not allowed' }, '0']],
});
await expect(client.setDefaultAddressBook('ab-2')).rejects.toThrow('Not allowed');
});
it('should throw for an unexpected response method', async () => {
const client = createClient();
mockFetch({
methodResponses: [['SomethingElse', {}, '0']],
});
await expect(client.setDefaultAddressBook('ab-2')).rejects.toThrow('Failed to set default address book');
});
});
describe('updateAddressBook', () => {
it('should drop isDefault, which AddressBook/set rejects as read-only', async () => {
const client = createClient();
const spy = mockFetch({
methodResponses: [['AddressBook/set', { updated: { 'ab-1': null } }, '0']],
});
await client.updateAddressBook('ab-1', { name: 'Work', isDefault: true });
const body = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string);
expect(body.methodCalls[0][1].update['ab-1']).toEqual({ name: 'Work' });
});
});
describe('getContacts', () => {
it('should return contacts from server', async () => {
const client = createClient();
+6
View File
@@ -779,6 +779,12 @@ export class DemoJMAPClient implements IJMAPClient {
if (book) Object.assign(book, updates);
}
async setDefaultAddressBook(addressBookId: string): Promise<void> {
for (const book of this.data.addressBooks) {
book.isDefault = book.id === addressBookId;
}
}
async deleteAddressBook(addressBookId: string): Promise<void> {
this.data.addressBooks = this.data.addressBooks.filter(b => b.id !== addressBookId);
this.data.contacts = this.data.contacts.filter(c => !c.addressBookIds?.[addressBookId]);
+1
View File
@@ -341,6 +341,7 @@ export interface IJMAPClient {
getAllAddressBooks(): Promise<AddressBook[]>;
createAddressBook(name: string): Promise<AddressBook>;
updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>;
setDefaultAddressBook(addressBookId: string, targetAccountId?: string): Promise<void>;
deleteAddressBook(addressBookId: string, targetAccountId?: string): Promise<void>;
getContacts(addressBookId?: string, options?: { throwOnError?: boolean }): Promise<ContactCard[]>;
getAllContacts(): Promise<ContactCard[]>;
+29 -3
View File
@@ -4815,13 +4815,14 @@ export class JMAPClient implements IJMAPClient {
async updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void> {
const accountId = targetAccountId || this.getContactsAccountId();
// Only forward server-settable properties
const { name, description, sortOrder, isDefault, color } = updates as Record<string, unknown>;
// Only forward server-settable properties. `isDefault` is deliberately not
// among them: it is read-only in AddressBook/set and including it fails the
// whole update with invalidProperties - use setDefaultAddressBook instead.
const { name, description, sortOrder, color } = updates as Record<string, unknown>;
const patch: Record<string, unknown> = {};
if (name !== undefined) patch.name = name;
if (description !== undefined) patch.description = description;
if (sortOrder !== undefined) patch.sortOrder = sortOrder;
if (isDefault !== undefined) patch.isDefault = isDefault;
if (color !== undefined) patch.color = color;
const response = await this.request([
@@ -4842,6 +4843,31 @@ export class JMAPClient implements IJMAPClient {
throw new Error("Failed to update address book");
}
/**
* Mark an address book as the account default. `isDefault` is read-only in
* AddressBook/set - the default is changed via the `onSuccessSetIsDefault`
* request argument instead (same shape as Calendar/set).
*/
async setDefaultAddressBook(addressBookId: string, targetAccountId?: string): Promise<void> {
const accountId = targetAccountId || this.getContactsAccountId();
const response = await this.request([
["AddressBook/set", {
accountId,
onSuccessSetIsDefault: addressBookId,
}, "0"]
], this.contactUsing());
const methodName = response.methodResponses?.[0]?.[0];
if (methodName === "error") {
const error = response.methodResponses?.[0]?.[1];
throw new Error(error?.description || error?.type || "Failed to set default address book");
}
if (methodName !== "AddressBook/set") {
throw new Error("Failed to set default address book");
}
}
async deleteAddressBook(addressBookId: string, targetAccountId?: string): Promise<void> {
const accountId = targetAccountId || this.getContactsAccountId();
const response = await this.request([
+3
View File
@@ -2435,6 +2435,9 @@
"renamed": "تمت إعادة تسمية دفتر العناوين",
"rename_failed": "فشلت إعادة تسمية دفتر العناوين",
"default": "افتراضي",
"set_default": "تعيين كافتراضي",
"default_updated": "تم تحديث دفتر العناوين الافتراضي",
"set_default_failed": "فشل تعيين دفتر العناوين الافتراضي",
"manage": "إدارة دفاتر العناوين",
"share": "مشاركة دفتر العناوين",
"new_contact_in_book": "جهة اتصال جديدة في دفتر العناوين هذا",
+3
View File
@@ -2435,6 +2435,9 @@
"renamed": "Nom de la llibreta d'adreces canviat",
"rename_failed": "No s'ha pogut canviar el nom de la llibreta d'adreces",
"default": "Predeterminada",
"set_default": "Estableix com a predeterminada",
"default_updated": "Llibreta d’adreces predeterminada actualitzada",
"set_default_failed": "No s’ha pogut establir la llibreta d’adreces predeterminada",
"manage": "Gestiona les llibretes d'adreces",
"share": "Comparteix la llibreta d'adreces",
"new_contact_in_book": "Contacte nou en aquesta llibreta d'adreces",
+3
View File
@@ -2434,6 +2434,9 @@
"renamed": "Adresář byl přejmenován",
"rename_failed": "Přejmenování adresáře selhalo",
"default": "Výchozí",
"set_default": "Nastavit jako výchozí",
"default_updated": "Výchozí adresář aktualizován",
"set_default_failed": "Nepodařilo se nastavit výchozí adresář",
"manage": "Spravovat adresáře",
"share": "Sdílet adresář",
"new_contact_in_book": "Nový kontakt v tomto adresáři",
+3
View File
@@ -2434,6 +2434,9 @@
"renamed": "Adressebog omdøbt",
"rename_failed": "Kunne ikke omdøbe adressebog",
"default": "Standard",
"set_default": "Angiv som standard",
"default_updated": "Standardadressebog opdateret",
"set_default_failed": "Kunne ikke angive standardadressebog",
"manage": "Administrér adressebøger",
"share": "Del adressebog",
"new_contact_in_book": "Ny kontakt i denne adressebog",
+3
View File
@@ -2434,6 +2434,9 @@
"renamed": "Adressbuch umbenannt",
"rename_failed": "Adressbuch konnte nicht umbenannt werden",
"default": "Standard",
"set_default": "Als Standard festlegen",
"default_updated": "Standardadressbuch aktualisiert",
"set_default_failed": "Standardadressbuch konnte nicht festgelegt werden",
"manage": "Adressbücher verwalten",
"share": "Adressbuch freigeben",
"new_contact_in_book": "Neuer Kontakt in diesem Adressbuch",
+3
View File
@@ -2435,6 +2435,9 @@
"renamed": "Address book renamed",
"rename_failed": "Failed to rename address book",
"default": "Default",
"set_default": "Set as default",
"default_updated": "Default address book updated",
"set_default_failed": "Failed to set default address book",
"manage": "Manage address books",
"share": "Share address book",
"new_contact_in_book": "New contact in this address book",
+3
View File
@@ -2434,6 +2434,9 @@
"renamed": "Libreta de direcciones renombrada",
"rename_failed": "Error al renombrar la libreta de direcciones",
"default": "Predeterminada",
"set_default": "Establecer como predeterminada",
"default_updated": "Libreta de direcciones predeterminada actualizada",
"set_default_failed": "Error al establecer la libreta de direcciones predeterminada",
"manage": "Administrar libretas de direcciones",
"share": "Compartir libreta de direcciones",
"new_contact_in_book": "Nuevo contacto en esta libreta",
+3
View File
@@ -2435,6 +2435,9 @@
"renamed": "نام دفترچه آدرس تغییر کرد",
"rename_failed": "تغییر نام دفترچه آدرس ناموفق بود",
"default": "پیش‌فرض",
"set_default": "تنظیم به عنوان پیش‌فرض",
"default_updated": "دفترچه آدرس پیش‌فرض به‌روزرسانی شد",
"set_default_failed": "تنظیم دفترچه آدرس پیش‌فرض ناموفق بود",
"manage": "مدیریت دفترچه‌های آدرس",
"share": "اشتراک‌گذاری دفترچه آدرس",
"new_contact_in_book": "مخاطب جدید در این دفترچه آدرس",
+3
View File
@@ -2434,6 +2434,9 @@
"renamed": "Carnet d'adresses renommé",
"rename_failed": "Échec du renommage du carnet d'adresses",
"default": "Par défaut",
"set_default": "Définir par défaut",
"default_updated": "Carnet d’adresses par défaut mis à jour",
"set_default_failed": "Échec de la définition du carnet d’adresses par défaut",
"manage": "Gérer les carnets d'adresses",
"share": "Partager le carnet d'adresses",
"new_contact_in_book": "Nouveau contact dans ce carnet d'adresses",
+3
View File
@@ -2356,6 +2356,9 @@
"renamed": "שם ספר הכתובות שונה",
"rename_failed": "שינוי שם פנקס הכתובות נכשל",
"default": "ברירת מחדל",
"set_default": "הגדר כברירת מחדל",
"default_updated": "פנקס הכתובות ברירת המחדל עודכן",
"set_default_failed": "נכשל בהגדרת פנקס כתובות ברירת מחדל",
"manage": "ניהול פנקסי כתובות",
"create": "ספר כתובות חדש",
"created": "ספר כתובות נוצר",
+3
View File
@@ -2435,6 +2435,9 @@
"renamed": "Címjegyzék átnevezve",
"rename_failed": "Nem sikerült átnevezni a címjegyzéket",
"default": "Alapértelmezett",
"set_default": "Beállítás alapértelmezettként",
"default_updated": "Alapértelmezett címjegyzék frissítve",
"set_default_failed": "Nem sikerült beállítani az alapértelmezett címjegyzéket",
"manage": "Címjegyzékek kezelése",
"share": "Címjegyzék megosztása",
"new_contact_in_book": "Új névjegy ebben a címjegyzékben",
+3
View File
@@ -2434,6 +2434,9 @@
"renamed": "Rubrica rinominata",
"rename_failed": "Impossibile rinominare la rubrica",
"default": "Predefinita",
"set_default": "Imposta come predefinita",
"default_updated": "Rubrica predefinita aggiornata",
"set_default_failed": "Impossibile impostare la rubrica predefinita",
"manage": "Gestisci rubriche",
"share": "Condividi rubrica",
"new_contact_in_book": "Nuovo contatto in questa rubrica",
+3
View File
@@ -2434,6 +2434,9 @@
"renamed": "アドレス帳の名前を変更しました",
"rename_failed": "アドレス帳の名前変更に失敗しました",
"default": "デフォルト",
"set_default": "デフォルトに設定",
"default_updated": "デフォルトのアドレス帳を更新しました",
"set_default_failed": "デフォルトのアドレス帳を設定できませんでした",
"manage": "アドレス帳を管理",
"share": "アドレス帳を共有",
"new_contact_in_book": "このアドレス帳に新規連絡先",
+3
View File
@@ -2434,6 +2434,9 @@
"renamed": "주소록 이름이 변경되었습니다",
"rename_failed": "주소록 이름 변경 실패",
"default": "기본",
"set_default": "기본으로 설정",
"default_updated": "기본 주소록이 업데이트되었습니다",
"set_default_failed": "기본 주소록을 설정하지 못했습니다",
"manage": "주소록 관리",
"share": "주소록 공유",
"new_contact_in_book": "이 주소록에 새 연락처",
+3
View File
@@ -2430,6 +2430,9 @@
"renamed": "Adrešu grāmata pārdēvēta",
"rename_failed": "Neizdevās pārdēvēt adrešu grāmatu",
"default": "Noklusējuma",
"set_default": "Iestatīt kā noklusējumu",
"default_updated": "Noklusējuma adrešu grāmata atjaunināta",
"set_default_failed": "Neizdevās iestatīt noklusējuma adrešu grāmatu",
"manage": "Pārvaldīt adrešu grāmatas",
"share": "Kopīgot adrešu grāmatu",
"new_contact_in_book": "Jauns kontakts šajā adrešu grāmatā",
+3
View File
@@ -2435,6 +2435,9 @@
"renamed": "Хаягийн дэвтрийн нэрийг өөрчилсөн",
"rename_failed": "Хаягийн дэвтрийн нэрийг өөрчилж чадсангүй",
"default": "Өгөгдмөл",
"set_default": "Өгөгдмөлөөр тохируулах",
"default_updated": "Өгөгдмөл хаягийн дэвтэр шинэчлэгдсэн",
"set_default_failed": "Өгөгдмөл хаягийн дэвтрийг тохируулж чадсангүй",
"manage": "Хаягийн дэвтэр удирдах",
"share": "Хаягийн дэвтэр хуваалцах",
"new_contact_in_book": "Энэ хаягийн дэвтэрт шинэ харилцагч",
+3
View File
@@ -2433,6 +2433,9 @@
"renamed": "Adresseboken har fått nytt navn",
"rename_failed": "Kunne ikke gi adresseboken nytt navn",
"default": "Standard",
"set_default": "Sett som standard",
"default_updated": "Standardadresseboken er oppdatert",
"set_default_failed": "Kunne ikke angi standardadressebok",
"manage": "Administrer adressebøker",
"share": "Del adressebok",
"new_contact_in_book": "Ny kontakt i denne adresseboken",
+3
View File
@@ -2434,6 +2434,9 @@
"renamed": "Adresboek hernoemd",
"rename_failed": "Adresboek hernoemen mislukt",
"default": "Standaard",
"set_default": "Als standaard instellen",
"default_updated": "Standaardadresboek bijgewerkt",
"set_default_failed": "Standaardadresboek instellen mislukt",
"manage": "Adresboeken beheren",
"share": "Adresboek delen",
"new_contact_in_book": "Nieuw contact in dit adresboek",
+3
View File
@@ -2434,6 +2434,9 @@
"renamed": "Zmieniono nazwę książki adresowej",
"rename_failed": "Nie udało się zmienić nazwy książki adresowej",
"default": "Domyślna",
"set_default": "Ustaw jako domyślną",
"default_updated": "Zaktualizowano domyślną książkę adresową",
"set_default_failed": "Nie udało się ustawić domyślnej książki adresowej",
"manage": "Zarządzaj książkami adresowymi",
"share": "Udostępnij książkę adresową",
"new_contact_in_book": "Nowy kontakt w tej książce adresowej",
+3
View File
@@ -2434,6 +2434,9 @@
"renamed": "Catálogo de endereços renomeado",
"rename_failed": "Falha ao renomear o catálogo de endereços",
"default": "Padrão",
"set_default": "Definir como padrão",
"default_updated": "Catálogo de endereços padrão atualizado",
"set_default_failed": "Falha ao definir o catálogo de endereços padrão",
"manage": "Gerenciar catálogos de endereços",
"share": "Compartilhar lista de contatos",
"new_contact_in_book": "Novo contato nesta lista",
+3
View File
@@ -2435,6 +2435,9 @@
"renamed": "Agenda de adrese a fost redenumită",
"rename_failed": "Nu s-a putut redenumi agenda de adrese",
"default": "Implicit",
"set_default": "Setați ca implicită",
"default_updated": "Agenda de adrese implicită a fost actualizată",
"set_default_failed": "Nu s-a reușit setarea agendei de adrese implicite",
"manage": "Gestionați agendele de adrese",
"share": "Partajați agenda de adrese",
"new_contact_in_book": "Contact nou în această agendă",
+3
View File
@@ -2434,6 +2434,9 @@
"renamed": "Адресная книга переименована",
"rename_failed": "Не удалось переименовать адресную книгу",
"default": "По умолчанию",
"set_default": "Сделать по умолчанию",
"default_updated": "Адресная книга по умолчанию обновлена",
"set_default_failed": "Не удалось установить адресную книгу по умолчанию",
"manage": "Управление адресными книгами",
"share": "Поделиться адресной книгой",
"new_contact_in_book": "Новый контакт в этой адресной книге",
+3
View File
@@ -2435,6 +2435,9 @@
"renamed": "Adresár bol premenovaný",
"rename_failed": "Premenovanie adresára zlyhalo",
"default": "Predvolený",
"set_default": "Nastaviť ako predvolený",
"default_updated": "Predvolený adresár aktualizovaný",
"set_default_failed": "Nepodarilo sa nastaviť predvolený adresár",
"manage": "Spravovať adresáre",
"share": "Zdieľať adresár",
"new_contact_in_book": "Nový kontakt v tomto adresári",
+3
View File
@@ -2434,6 +2434,9 @@
"renamed": "Adres defteri yeniden adlandırıldı",
"rename_failed": "Adres defteri yeniden adlandırılamadı",
"default": "Varsayılan",
"set_default": "Varsayılan olarak ayarla",
"default_updated": "Varsayılan adres defteri güncellendi",
"set_default_failed": "Varsayılan adres defteri ayarlanamadı",
"manage": "Adres defterlerini yönet",
"share": "Adres defterini paylaş",
"new_contact_in_book": "Bu adres defterinde yeni kişi",
+3
View File
@@ -2434,6 +2434,9 @@
"renamed": "Адресну книгу перейменовано",
"rename_failed": "Не вдалося перейменувати адресну книгу",
"default": "За замовчуванням",
"set_default": "Зробити типовою",
"default_updated": "Типову адресну книгу оновлено",
"set_default_failed": "Не вдалося встановити типову адресну книгу",
"manage": "Керуйте адресними книгами",
"share": "Поділитися адресною книгою",
"new_contact_in_book": "Новий контакт у цій адресній книзі",
+3
View File
@@ -2428,6 +2428,9 @@
"renamed": "已重新命名通訊錄",
"rename_failed": "無法重新命名通訊錄",
"default": "預設",
"set_default": "設為預設",
"default_updated": "預設通訊錄已更新",
"set_default_failed": "無法設定預設通訊錄",
"manage": "管理通訊錄",
"share": "分享通訊錄",
"new_contact_in_book": "在此通訊錄中新增聯絡人",
+3
View File
@@ -2434,6 +2434,9 @@
"renamed": "地址簿已重命名",
"rename_failed": "重命名地址簿失败",
"default": "默认",
"set_default": "设为默认",
"default_updated": "默认地址簿已更新",
"set_default_failed": "无法设置默认地址簿",
"manage": "管理地址簿",
"share": "共享通讯录",
"new_contact_in_book": "在此通讯录中新建联系人",
+73
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useContactStore, getContactPhotoUri, normalizeContactPhotoUri, TRUSTED_SENDERS_BOOK_NAME } from '../contact-store';
import type { ContactCard } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-0000-0000-000000000000' });
@@ -161,6 +162,78 @@ describe('contact-store', () => {
});
});
describe('setDefaultAddressBook', () => {
const makeClient = () => ({
setDefaultAddressBook: vi.fn().mockResolvedValue(undefined),
});
it('should move the default flag to the chosen book', async () => {
const books = [
{ id: 'ab-1', name: 'Personal', isDefault: true },
{ id: 'ab-2', name: 'Work', isDefault: false },
];
useContactStore.setState({ addressBooks: books });
const client = makeClient();
await useContactStore.getState().setDefaultAddressBook(
client as unknown as IJMAPClient,
books[1],
);
expect(client.setDefaultAddressBook).toHaveBeenCalledWith('ab-2', undefined);
expect(useContactStore.getState().addressBooks.map((b) => b.isDefault)).toEqual([false, true]);
});
it('should send the un-namespaced id and leave other accounts alone', async () => {
const book = {
id: 'local-2::ab-9',
originalId: 'ab-9',
localAccountId: 'local-2',
name: 'Work',
};
useContactStore.setState({
addressBooks: [
{ id: 'local-1::ab-1', originalId: 'ab-1', localAccountId: 'local-1', name: 'Personal', isDefault: true },
book,
],
});
const client = makeClient();
await useContactStore.getState().setDefaultAddressBook(client as unknown as IJMAPClient, book);
expect(client.setDefaultAddressBook).toHaveBeenCalledWith('ab-9', undefined);
// The other account keeps its own default.
expect(useContactStore.getState().addressBooks.map((b) => b.isDefault)).toEqual([true, true]);
});
it('should target the owning account for a shared book', async () => {
const book = { id: 'ab-3', name: 'Team', isShared: true, accountId: 'account-2' };
useContactStore.setState({ addressBooks: [book] });
const client = makeClient();
await useContactStore.getState().setDefaultAddressBook(client as unknown as IJMAPClient, book);
expect(client.setDefaultAddressBook).toHaveBeenCalledWith('ab-3', 'account-2');
});
it('should surface the error and keep the previous default on failure', async () => {
const books = [
{ id: 'ab-1', name: 'Personal', isDefault: true },
{ id: 'ab-2', name: 'Work', isDefault: false },
];
useContactStore.setState({ addressBooks: books });
const client = {
setDefaultAddressBook: vi.fn().mockRejectedValue(new Error('Not allowed')),
};
await expect(
useContactStore.getState().setDefaultAddressBook(client as unknown as IJMAPClient, books[1]),
).rejects.toThrow('Not allowed');
expect(useContactStore.getState().error).toBe('Not allowed');
expect(useContactStore.getState().addressBooks.map((b) => b.isDefault)).toEqual([true, false]);
});
});
describe('toggleContactSelection', () => {
it('should add id to selection', () => {
useContactStore.getState().toggleContactSelection('c1');
+30
View File
@@ -312,6 +312,7 @@ interface ContactStore {
moveContactToAddressBook: (client: IJMAPClient, contactIds: string[], addressBook: AddressBook) => Promise<void>;
createAddressBook: (client: IJMAPClient, name: string) => Promise<AddressBook>;
renameAddressBook: (client: IJMAPClient, addressBook: AddressBook, newName: string) => Promise<void>;
setDefaultAddressBook: (client: IJMAPClient, addressBook: AddressBook) => Promise<void>;
removeAddressBook: (client: IJMAPClient, addressBook: AddressBook) => Promise<void>;
shareAddressBook: (client: IJMAPClient, addressBook: AddressBook, principalId: string, rights: AddressBookRights | null) => Promise<void>;
renameKeyword: (client: IJMAPClient | null, oldKeyword: string, newKeyword: string) => Promise<void>;
@@ -1013,6 +1014,35 @@ export const useContactStore = create<ContactStore>()(
}
},
setDefaultAddressBook: async (client, addressBook) => {
set({ error: null });
try {
const originalId = addressBook.originalId || stripLocalAccountPrefix(addressBook.id, addressBook.localAccountId);
const accountId = addressBook.isShared ? addressBook.accountId : undefined;
client = resolveAccountClient(client, addressBook.localAccountId);
await client.setDefaultAddressBook(originalId, accountId);
set((state) => ({
addressBooks: state.addressBooks.map(b => {
if (b.id === addressBook.id) return { ...b, isDefault: true };
// Only one default per account - clear the flag on siblings in the
// same local account / shared-account scope.
if (
b.isDefault
&& (b.localAccountId ?? null) === (addressBook.localAccountId ?? null)
&& (b.accountId ?? null) === (addressBook.accountId ?? null)
) {
return { ...b, isDefault: false };
}
return b;
}),
}));
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to set default address book';
set({ error: msg });
throw error;
}
},
removeAddressBook: async (client, addressBook) => {
set({ error: null });
try {