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
+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([