fix: keep a new calendar's name and colour until the next refresh

This commit is contained in:
Linus Rath
2026-10-31 03:34:36 +01:00
parent 0e69c86c51
commit a2344ea0ae
3 changed files with 77 additions and 6 deletions
+30
View File
@@ -15,6 +15,7 @@ import {
queryEvents, queryEvents,
getEvents, getEvents,
createEvent, createEvent,
createCalendar,
updateEvent, updateEvent,
deleteEvents, deleteEvents,
batchCreateEvents, batchCreateEvents,
@@ -557,6 +558,35 @@ describe('calendar operations', () => {
}); });
}); });
describe('createCalendar', () => {
it('returns the new calendar with the name and colour sent when the server echoes only its id', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['Calendar/set', { created: { 'new-cal': { id: 'c9', color: null, myRights: { mayDelete: true } } } }, '0'],
['error', { type: 'serverFail' }, '1'],
],
});
const cal = await createCalendar('Work', '#123456');
expect(cal).toMatchObject({ id: 'c9', name: 'Work', color: '#123456', isVisible: true, myRights: { mayDelete: true } });
});
it('takes the read-back calendar with its rights', async () => {
const rights = { mayReadItems: true, mayWriteAll: true, mayDelete: true, mayShare: true };
mockRequest.mockResolvedValue({
methodResponses: [
['Calendar/set', { created: { 'new-cal': { id: 'c9' } } }, '0'],
['Calendar/get', { list: [{ id: 'c9', name: 'Work', color: '#123456', myRights: rights }] }, '1'],
],
});
const cal = await createCalendar('Work', '#123456');
expect(cal).toMatchObject({ id: 'c9', name: 'Work', color: '#123456', myRights: rights });
const calls = mockRequest.mock.calls[0][0];
expect(calls[1][0]).toBe('Calendar/get');
// The creation id: 0.16 refuses a result reference into `created`.
expect(calls[1][1].ids).toEqual(['#new-cal']);
});
});
describe('refusals for the participant limit (Stalwart 1.0)', () => { describe('refusals for the participant limit (Stalwart 1.0)', () => {
// 1.0 enforces maxParticipantsPerEvent on every create and update, an // 1.0 enforces maxParticipantsPerEvent on every create and update, an
// answer included; 0.16 advertises the limit without enforcing it. // answer included; 0.16 advertises the limit without enforcing it.
@@ -700,6 +700,27 @@ live('live Stalwart calendar', () => {
}, 60_000); }, 60_000);
}); });
describe('a new calendar', () => {
let newCalendarId = '';
afterAll(async () => {
if (newCalendarId) {
await raw(client, [['Calendar/set', { accountId: client.accountId, destroy: [newCalendarId], onDestroyRemoveEvents: true }, '0']]).catch(() => undefined);
}
}, 30_000);
it('has its name, colour and rights in the store right after it is made', async () => {
await connectSingleton();
await useCalendarStore.getState().fetchCalendars();
const made = await useCalendarStore.getState().createCalendar(`${RUN} new`, '#0f766e');
newCalendarId = made.id;
const inStore = useCalendarStore.getState().calendars.find((c) => c.id === made.id);
expect(inStore).toMatchObject({ name: `${RUN} new`, color: '#0f766e' });
expect(inStore?.myRights?.mayDelete).toBe(true);
expect(inStore?.myRights?.mayWriteAll).toBe(true);
}, 30_000);
});
describe('a calendar\'s default reminders (#1209)', () => { describe('a calendar\'s default reminders (#1209)', () => {
let defaultsCalendarId = ''; let defaultsCalendarId = '';
let otherCalendarId = ''; let otherCalendarId = '';
+26 -6
View File
@@ -1250,23 +1250,43 @@ export async function createCalendar(
const accountId = targetAccountId || jmapClient.accountId; const accountId = targetAccountId || jmapClient.accountId;
const props: Record<string, unknown> = { name, color, isVisible: true, isSubscribed: true }; const props: Record<string, unknown> = { name, color, isVisible: true, isSubscribed: true };
if (description?.trim()) props.description = description.trim(); if (description?.trim()) props.description = description.trim();
// Stalwart's create answer never carries the name sent, and 1.0 answers
// `color: null` for one it stored. A get in the same request reads the
// calendar back with its rights, as the webmail re-reads it. The creation
// id stands for the new id: 0.16 refuses a result reference into
// `created`, both take `#new-cal`.
const res = await jmapClient.request( const res = await jmapClient.request(
[['Calendar/set', { [
accountId, ['Calendar/set', {
create: { 'new-cal': props }, accountId,
}, '0']], create: { 'new-cal': props },
}, '0'],
['Calendar/get', {
accountId,
ids: ['#new-cal'],
properties: CALENDAR_PROPERTIES,
}, '1'],
],
USING, USING,
); );
const result = methodResult<{ const result = methodResult<{
created?: Record<string, Calendar>; created?: Record<string, Calendar>;
notCreated?: Record<string, { description?: string; type?: string }>; notCreated?: Record<string, { description?: string; type?: string }>;
}>(res); }>(res, '0');
const created = result.created?.['new-cal']; const created = result.created?.['new-cal'];
if (!created) { if (!created) {
const err = result.notCreated?.['new-cal']; const err = result.notCreated?.['new-cal'];
throw new Error(err?.description || err?.type || 'Failed to create calendar'); throw new Error(err?.description || err?.type || 'Failed to create calendar');
} }
return created; let stored: Calendar | undefined;
try {
stored = methodResult<{ list?: Calendar[] }>(res, '1').list?.find((c) => c.id === created.id);
} catch {
// The calendar exists; what was sent stands in for the read-back.
}
if (stored) return { ...props, ...created, ...stored } as Calendar;
const echoed = Object.fromEntries(Object.entries(created).filter(([, v]) => v !== null));
return { ...props, ...echoed } as Calendar;
} }
/** /**