mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: treat CalDAV tasks marked COMPLETED, NEEDS_ACTION or canceled as such
Tasks created over CalDAV can come back with their iCalendar STATUS spelling or as `canceled` (stalwartlabs/calcard#20). The app compared against the JSCalendar values only, so a completed CalDAV task counted as pending, still raised its reminder, and a tap on it completed it instead of reopening it. - normalize the known spellings when events are read, like webmail's `normalizeCalendarTask` (5f1d2795), and keep unknown values as they are - reopen a cancelled task on tap, since the tasks sheet shows it ticked
This commit is contained in:
@@ -209,6 +209,55 @@ describe('calendar operations', () => {
|
||||
const [event] = await getEvents(['ev1']);
|
||||
expect(event.recurrenceRules).toEqual([{ frequency: 'weekly', interval: 2 }]);
|
||||
});
|
||||
|
||||
it('normalizes the CalDAV spellings of task progress (calcard#20)', async () => {
|
||||
const list = [
|
||||
{ id: 't1', progress: 'COMPLETED' },
|
||||
{ id: 't2', progress: 'NEEDS_ACTION' },
|
||||
{ id: 't3', progress: 'IN_PROCESS' },
|
||||
{ id: 't4', progress: 'canceled' },
|
||||
{ id: 't5', progress: 'CANCELLED' },
|
||||
{ id: 't6', progress: 'needs-action' },
|
||||
];
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/get', { list }, '0']],
|
||||
});
|
||||
|
||||
const result = await getEvents(list.map((t) => t.id));
|
||||
expect(result.map((t) => t.progress)).toEqual([
|
||||
'completed',
|
||||
'needs-action',
|
||||
'in-process',
|
||||
'cancelled',
|
||||
'cancelled',
|
||||
'needs-action',
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves unknown and non-string task progress untouched', async () => {
|
||||
const list = [
|
||||
{ id: 't1', progress: 'constructor' },
|
||||
{ id: 't2', progress: '__proto__' },
|
||||
{ id: 't3', progress: 'failed' },
|
||||
{ id: 't4', progress: 42 },
|
||||
{ id: 't5', progress: null },
|
||||
{ id: 'ev1', title: 'Event' },
|
||||
];
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/get', { list }, '0']],
|
||||
});
|
||||
|
||||
const result = await getEvents(list.map((t) => t.id));
|
||||
expect(result.map((t) => t.progress as unknown)).toEqual([
|
||||
'constructor',
|
||||
'__proto__',
|
||||
'failed',
|
||||
42,
|
||||
null,
|
||||
undefined,
|
||||
]);
|
||||
expect(result[5]).not.toHaveProperty('progress');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEvent', () => {
|
||||
|
||||
+27
-1
@@ -147,6 +147,32 @@ export function normalizeRecurrenceProperties<T extends Partial<CalendarEvent>>(
|
||||
return result;
|
||||
}
|
||||
|
||||
const TASK_PROGRESS_NORMALIZATION = new Map<string, string>([
|
||||
['needs-action', 'needs-action'],
|
||||
['needs_action', 'needs-action'],
|
||||
['in-process', 'in-process'],
|
||||
['in_process', 'in-process'],
|
||||
['completed', 'completed'],
|
||||
['cancelled', 'cancelled'],
|
||||
['canceled', 'cancelled'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* CalDAV-created tasks can come back with their iCalendar STATUS spelling
|
||||
* (`COMPLETED`, `NEEDS_ACTION`) or as `canceled` (stalwartlabs/calcard#20).
|
||||
* Map the known spellings onto the JSCalendar values the task filters, the
|
||||
* checkbox and the reminders compare against; leave unknown values alone.
|
||||
* Mirrors webmail's normalizeCalendarTask.
|
||||
*/
|
||||
export function normalizeTaskProgress<T extends Partial<CalendarEvent>>(event: T): T {
|
||||
const progress = (event as { progress?: unknown }).progress;
|
||||
if (typeof progress !== 'string') return event;
|
||||
const normalized = TASK_PROGRESS_NORMALIZATION.get(progress.toLowerCase());
|
||||
return normalized === undefined || normalized === progress
|
||||
? event
|
||||
: { ...event, progress: normalized };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the client's plural recurrence arrays to the singular JSCalendar 2.0
|
||||
* properties Stalwart expects on write, dropping null rule fields. An empty
|
||||
@@ -357,7 +383,7 @@ export async function getEvents(ids: string[], accountId?: string): Promise<Cale
|
||||
USING,
|
||||
);
|
||||
const list = methodResult<{ list: CalendarEvent[] }>(res).list ?? [];
|
||||
all.push(...list.map(normalizeRecurrenceProperties));
|
||||
all.push(...list.map((e) => normalizeTaskProgress(normalizeRecurrenceProperties(e))));
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
@@ -674,6 +674,22 @@ describe('calendar-store', () => {
|
||||
expect(useCalendarStore.getState().tasks[0].progress).toBe('needs-action');
|
||||
});
|
||||
|
||||
it('reopens a cancelled task, which the tasks sheet shows as ticked', async () => {
|
||||
useCalendarStore.setState({
|
||||
tasks: [{ id: 't1', progress: 'cancelled' } as any],
|
||||
});
|
||||
mockUpdateEvent.mockResolvedValue(undefined);
|
||||
|
||||
await useCalendarStore.getState().toggleTaskComplete('t1');
|
||||
|
||||
expect(mockUpdateEvent).toHaveBeenCalledWith(
|
||||
't1',
|
||||
{ progress: 'needs-action', percentComplete: 0 },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('flips the task at once and reverts it when the server refuses', async () => {
|
||||
useCalendarStore.setState({
|
||||
tasks: [{ id: 't1', progress: 'needs-action', percentComplete: 20 } as any],
|
||||
|
||||
@@ -711,7 +711,8 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
toggleTaskComplete: async (id) => {
|
||||
const task = get().tasks.find((t) => t.id === id);
|
||||
if (!task) return;
|
||||
const completed = task.progress === 'completed';
|
||||
// The tasks sheet ticks cancelled tasks too, so a tap on one reopens it.
|
||||
const completed = task.progress === 'completed' || task.progress === 'cancelled';
|
||||
// Un-completing goes back to needs-action (not in-process), like webmail.
|
||||
// No progressUpdated: JSCalendar 2.0 dropped it and Stalwart rejects the
|
||||
// whole update with invalidProperties (#958).
|
||||
|
||||
Reference in New Issue
Block a user