mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: keep an occurrence's description or location empty once cleared #34
Clearing the description or location of one occurrence of a series in a calendar app removed the value from the occurrence's override. An override without a value of its own shows the series' one, so the write after the upload put "Daily agenda" or "Room 1" back into the occurrence and made it clean: the user's edit was gone without a trace. An existing override now gets an explicit empty value while the series has one to fall back to: the empty text for the description, and a location without a name, the only empty forms Stalwart keeps (calcard writes no LOCATION line for an empty map, which then reads back as the series' locations). Without a series value the override's own is removed as before. New exception rows keep treating an empty value as "inherit". Checked with a planner test and a run through the engine and the fake server: the row stays cleared and clean, and the echo writes nothing.
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
// The calendar planner through the engine and the fake JMAP server: device
|
||||
// edits reach the server as the user made them and the rows stay as the user
|
||||
// left them, end to end. Mapping details are the planner's own tests.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Events } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import { contactsPlanner } from '../../contacts/planner';
|
||||
import { CALENDAR_AUTHORITY, createHarness, type Harness } from '../engine/harness';
|
||||
|
||||
function real(options: Parameters<typeof createHarness>[0] = {}): Harness {
|
||||
const h = createHarness(options);
|
||||
h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner };
|
||||
return h;
|
||||
}
|
||||
|
||||
type Overrides = Record<string, Record<string, unknown>>;
|
||||
|
||||
describe('calendar device edits through the engine', () => {
|
||||
it('keeps a description and location cleared on one occurrence cleared', async () => {
|
||||
const h = real();
|
||||
const id = h.server.addEvent('a', {
|
||||
uid: 'standup-uid',
|
||||
title: 'Standup',
|
||||
description: 'Daily agenda',
|
||||
start: '2026-09-28T09:00:00',
|
||||
duration: 'PT15M',
|
||||
timeZone: 'Europe/Berlin',
|
||||
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily', count: 5 },
|
||||
locations: { locA: { '@type': 'Location', name: 'Room 1' } },
|
||||
recurrenceOverrides: {
|
||||
'2026-09-29T09:00:00': { title: 'Standup', description: 'Special agenda', locations: { locB: { '@type': 'Location', name: 'Room 2' } } },
|
||||
},
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const exception = () => h.events().find((e) => e[Events.ORIGINAL_SYNC_ID] === `a/${id}`)!;
|
||||
expect(exception()).toMatchObject({ [Events.DESCRIPTION]: 'Special agenda', [Events.EVENT_LOCATION]: 'Room 2' });
|
||||
|
||||
h.device.user.updateEvent(Number(exception()._id), { [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null });
|
||||
expect(await h.run(CALENDAR_AUTHORITY)).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { updated: 1 } } });
|
||||
|
||||
expect(exception()).toMatchObject({ [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null, [Events.DIRTY]: 0 });
|
||||
const overrides = h.server.get('CalendarEvent', 'a', id)!.recurrenceOverrides as Overrides;
|
||||
expect(overrides['2026-09-29T09:00:00']).toMatchObject({ description: '', locations: { locB: { name: '' } } });
|
||||
});
|
||||
});
|
||||
@@ -103,6 +103,49 @@ describe('calendar planner: recurrence edits made on the device', () => {
|
||||
expect(h.row(x.eventId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears an occurrence\'s own description and location instead of letting the series\' come back', async () => {
|
||||
const event = weekly();
|
||||
event.description = 'Daily agenda';
|
||||
event.recurrenceOverrides!['2026-10-07T09:00:00'] = {
|
||||
...event.recurrenceOverrides!['2026-10-07T09:00:00'],
|
||||
description: 'Special agenda',
|
||||
locations: { locB: { '@type': 'Location', name: 'Room 2' } },
|
||||
};
|
||||
const { h } = await synced(event);
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
expect(h.row(x.eventId)).toMatchObject({ [Events.DESCRIPTION]: 'Special agenda', [Events.EVENT_LOCATION]: 'Room 2' });
|
||||
h.fake.user.updateEvent(x.eventId, { [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null });
|
||||
const local = (await h.local('bl'))!;
|
||||
const plan = calendarPlanner.planUpload(local, h.ctx);
|
||||
const server = await accept(h, event, local, plan);
|
||||
expect(h.row(x.eventId)).toMatchObject({ [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null, [Events.DIRTY]: 0 });
|
||||
expect(calendarPlanner.planDownload(server, (await h.local('bl'))!, h.ctx).effect).toBe('none');
|
||||
// Without a value of its own the override would show the series' "Daily agenda" and "Room 1" again, so it
|
||||
// gets the empty ones Stalwart keeps (an empty map would read back as no locations, i.e. the series' ones).
|
||||
expect(patchOf(plan).patch).toEqual({
|
||||
'recurrenceOverrides/2026-10-07T09:00:00/description': '',
|
||||
'recurrenceOverrides/2026-10-07T09:00:00/locations': { locB: { '@type': 'Location', name: '' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('removes an occurrence\'s own description and location when the series has none to show instead', async () => {
|
||||
const event = weekly();
|
||||
delete event.description;
|
||||
delete event.locations;
|
||||
event.recurrenceOverrides!['2026-10-07T09:00:00'] = {
|
||||
...event.recurrenceOverrides!['2026-10-07T09:00:00'],
|
||||
description: 'Special agenda',
|
||||
locations: { locB: { '@type': 'Location', name: 'Room 2' } },
|
||||
};
|
||||
const { h } = await synced(event);
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
h.fake.user.updateEvent(x.eventId, { [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null });
|
||||
expect(patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).patch).toEqual({
|
||||
'recurrenceOverrides/2026-10-07T09:00:00/description': null,
|
||||
'recurrenceOverrides/2026-10-07T09:00:00/locations': null,
|
||||
});
|
||||
});
|
||||
|
||||
it('patches an existing override one level deep, never deeper', async () => {
|
||||
const { h } = await synced(weekly());
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
|
||||
@@ -454,10 +454,10 @@ function exceptionChanges(
|
||||
break;
|
||||
case 'description':
|
||||
if (x.baseline && isTruncatedBaseline(x.baseline.cells[Events.DESCRIPTION])) continue;
|
||||
values.description = descriptionValue(cells);
|
||||
values.description = descriptionValue(cells) ?? clearedInOverride('description', instance, shadow);
|
||||
break;
|
||||
case 'location':
|
||||
values.locations = locationChanges(instance, cells[Events.EVENT_LOCATION], ctx.mintKey).locations;
|
||||
values.locations = locationChanges(instance, cells[Events.EVENT_LOCATION], ctx.mintKey).locations ?? clearedInOverride('location', instance, shadow);
|
||||
break;
|
||||
case 'status':
|
||||
values.status = statusFromDevice(cells[Events.STATUS]);
|
||||
@@ -530,6 +530,22 @@ function isEmptyUnit(unit: string, cells: Row): boolean {
|
||||
return v === null || v === undefined || v === '';
|
||||
}
|
||||
|
||||
/**
|
||||
* A description or location cleared on an existing override. An override
|
||||
* without one of its own shows the series' again, so while the series has
|
||||
* one the override gets the empty value Stalwart keeps: the empty text, or a
|
||||
* location without a name (an empty map writes no LOCATION line and reads
|
||||
* back as none of its own). Null, a clean removal, when nothing would come
|
||||
* back.
|
||||
*/
|
||||
function clearedInOverride(unit: 'description' | 'location', instance: CalendarEventWire, master: CalendarEventWire): unknown {
|
||||
if (unit === 'description') return typeof master.description === 'string' && master.description ? '' : null;
|
||||
const series = Object.keys(master.locations ?? {});
|
||||
if (!series.length) return null;
|
||||
const key = Object.keys(instance.locations ?? {})[0] ?? series[0];
|
||||
return { [key]: { '@type': 'Location', name: '' } };
|
||||
}
|
||||
|
||||
/**
|
||||
* A new override: the changes plus the details Stalwart needs on it (title,
|
||||
* locations, participants, sequence, …; `withNewOverrideDetails`), minus what
|
||||
|
||||
Reference in New Issue
Block a user