mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: allow setting an address book as default (#924)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
@@ -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([
|
||||
|
||||
@@ -2435,6 +2435,9 @@
|
||||
"renamed": "تمت إعادة تسمية دفتر العناوين",
|
||||
"rename_failed": "فشلت إعادة تسمية دفتر العناوين",
|
||||
"default": "افتراضي",
|
||||
"set_default": "تعيين كافتراضي",
|
||||
"default_updated": "تم تحديث دفتر العناوين الافتراضي",
|
||||
"set_default_failed": "فشل تعيين دفتر العناوين الافتراضي",
|
||||
"manage": "إدارة دفاتر العناوين",
|
||||
"share": "مشاركة دفتر العناوين",
|
||||
"new_contact_in_book": "جهة اتصال جديدة في دفتر العناوين هذا",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2435,6 +2435,9 @@
|
||||
"renamed": "نام دفترچه آدرس تغییر کرد",
|
||||
"rename_failed": "تغییر نام دفترچه آدرس ناموفق بود",
|
||||
"default": "پیشفرض",
|
||||
"set_default": "تنظیم به عنوان پیشفرض",
|
||||
"default_updated": "دفترچه آدرس پیشفرض بهروزرسانی شد",
|
||||
"set_default_failed": "تنظیم دفترچه آدرس پیشفرض ناموفق بود",
|
||||
"manage": "مدیریت دفترچههای آدرس",
|
||||
"share": "اشتراکگذاری دفترچه آدرس",
|
||||
"new_contact_in_book": "مخاطب جدید در این دفترچه آدرس",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2356,6 +2356,9 @@
|
||||
"renamed": "שם ספר הכתובות שונה",
|
||||
"rename_failed": "שינוי שם פנקס הכתובות נכשל",
|
||||
"default": "ברירת מחדל",
|
||||
"set_default": "הגדר כברירת מחדל",
|
||||
"default_updated": "פנקס הכתובות ברירת המחדל עודכן",
|
||||
"set_default_failed": "נכשל בהגדרת פנקס כתובות ברירת מחדל",
|
||||
"manage": "ניהול פנקסי כתובות",
|
||||
"create": "ספר כתובות חדש",
|
||||
"created": "ספר כתובות נוצר",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2434,6 +2434,9 @@
|
||||
"renamed": "アドレス帳の名前を変更しました",
|
||||
"rename_failed": "アドレス帳の名前変更に失敗しました",
|
||||
"default": "デフォルト",
|
||||
"set_default": "デフォルトに設定",
|
||||
"default_updated": "デフォルトのアドレス帳を更新しました",
|
||||
"set_default_failed": "デフォルトのアドレス帳を設定できませんでした",
|
||||
"manage": "アドレス帳を管理",
|
||||
"share": "アドレス帳を共有",
|
||||
"new_contact_in_book": "このアドレス帳に新規連絡先",
|
||||
|
||||
@@ -2434,6 +2434,9 @@
|
||||
"renamed": "주소록 이름이 변경되었습니다",
|
||||
"rename_failed": "주소록 이름 변경 실패",
|
||||
"default": "기본",
|
||||
"set_default": "기본으로 설정",
|
||||
"default_updated": "기본 주소록이 업데이트되었습니다",
|
||||
"set_default_failed": "기본 주소록을 설정하지 못했습니다",
|
||||
"manage": "주소록 관리",
|
||||
"share": "주소록 공유",
|
||||
"new_contact_in_book": "이 주소록에 새 연락처",
|
||||
|
||||
@@ -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ā",
|
||||
|
||||
@@ -2435,6 +2435,9 @@
|
||||
"renamed": "Хаягийн дэвтрийн нэрийг өөрчилсөн",
|
||||
"rename_failed": "Хаягийн дэвтрийн нэрийг өөрчилж чадсангүй",
|
||||
"default": "Өгөгдмөл",
|
||||
"set_default": "Өгөгдмөлөөр тохируулах",
|
||||
"default_updated": "Өгөгдмөл хаягийн дэвтэр шинэчлэгдсэн",
|
||||
"set_default_failed": "Өгөгдмөл хаягийн дэвтрийг тохируулж чадсангүй",
|
||||
"manage": "Хаягийн дэвтэр удирдах",
|
||||
"share": "Хаягийн дэвтэр хуваалцах",
|
||||
"new_contact_in_book": "Энэ хаягийн дэвтэрт шинэ харилцагч",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ă",
|
||||
|
||||
@@ -2434,6 +2434,9 @@
|
||||
"renamed": "Адресная книга переименована",
|
||||
"rename_failed": "Не удалось переименовать адресную книгу",
|
||||
"default": "По умолчанию",
|
||||
"set_default": "Сделать по умолчанию",
|
||||
"default_updated": "Адресная книга по умолчанию обновлена",
|
||||
"set_default_failed": "Не удалось установить адресную книгу по умолчанию",
|
||||
"manage": "Управление адресными книгами",
|
||||
"share": "Поделиться адресной книгой",
|
||||
"new_contact_in_book": "Новый контакт в этой адресной книге",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2434,6 +2434,9 @@
|
||||
"renamed": "Адресну книгу перейменовано",
|
||||
"rename_failed": "Не вдалося перейменувати адресну книгу",
|
||||
"default": "За замовчуванням",
|
||||
"set_default": "Зробити типовою",
|
||||
"default_updated": "Типову адресну книгу оновлено",
|
||||
"set_default_failed": "Не вдалося встановити типову адресну книгу",
|
||||
"manage": "Керуйте адресними книгами",
|
||||
"share": "Поділитися адресною книгою",
|
||||
"new_contact_in_book": "Новий контакт у цій адресній книзі",
|
||||
|
||||
@@ -2428,6 +2428,9 @@
|
||||
"renamed": "已重新命名通訊錄",
|
||||
"rename_failed": "無法重新命名通訊錄",
|
||||
"default": "預設",
|
||||
"set_default": "設為預設",
|
||||
"default_updated": "預設通訊錄已更新",
|
||||
"set_default_failed": "無法設定預設通訊錄",
|
||||
"manage": "管理通訊錄",
|
||||
"share": "分享通訊錄",
|
||||
"new_contact_in_book": "在此通訊錄中新增聯絡人",
|
||||
|
||||
@@ -2434,6 +2434,9 @@
|
||||
"renamed": "地址簿已重命名",
|
||||
"rename_failed": "重命名地址簿失败",
|
||||
"default": "默认",
|
||||
"set_default": "设为默认",
|
||||
"default_updated": "默认地址簿已更新",
|
||||
"set_default_failed": "无法设置默认地址簿",
|
||||
"manage": "管理地址簿",
|
||||
"share": "共享通讯录",
|
||||
"new_contact_in_book": "在此通讯录中新建联系人",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user