mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: let tasks be completed again and say when the server refuses #958
Ticking a task sent `progressUpdated`, which JSCalendar 2.0 dropped and Stalwart rejects with `invalidProperties`, so the whole update failed. The checkbox fired the toggle with `void`, so the task silently stayed open. - drop `progressUpdated` from the toggle patch, like webmail 6b30e91c - flip the checkbox optimistically and put it back when the update fails - show a "Failed to update task" alert with the server's reason
This commit is contained in:
+2
-1
@@ -79,7 +79,8 @@
|
|||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"add_placeholder": "Add a task…",
|
"add_placeholder": "Add a task…",
|
||||||
"add_time": "Add time"
|
"add_time": "Add time",
|
||||||
|
"update_error": "Failed to update task"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
|||||||
@@ -609,6 +609,20 @@ export default function CalendarScreen() {
|
|||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The store flips the checkbox optimistically and reverts it when the
|
||||||
|
// server refuses; say so instead of leaving the user guessing.
|
||||||
|
const handleToggleTask = React.useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
toggleTaskComplete(id).catch((err: unknown) => {
|
||||||
|
Alert.alert(
|
||||||
|
t('calendar.tasks.update_error', 'Failed to update task'),
|
||||||
|
err instanceof Error ? err.message : undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[toggleTaskComplete, t],
|
||||||
|
);
|
||||||
|
|
||||||
// Clone the event one day later and open it in the editor (webmail's
|
// Clone the event one day later and open it in the editor (webmail's
|
||||||
// handleDuplicateFromDetail).
|
// handleDuplicateFromDetail).
|
||||||
const handleDuplicateFromDetail = React.useCallback(
|
const handleDuplicateFromDetail = React.useCallback(
|
||||||
@@ -942,7 +956,7 @@ export default function CalendarScreen() {
|
|||||||
onClose={() => { setTasksVisible(false); setTasksInitialId(null); }}
|
onClose={() => { setTasksVisible(false); setTasksInitialId(null); }}
|
||||||
onCreate={createTask}
|
onCreate={createTask}
|
||||||
onUpdate={updateTask}
|
onUpdate={updateTask}
|
||||||
onToggle={toggleTaskComplete}
|
onToggle={handleToggleTask}
|
||||||
onDelete={deleteTask}
|
onDelete={deleteTask}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -638,4 +638,58 @@ describe('calendar-store', () => {
|
|||||||
expect(mockGetEvents).not.toHaveBeenCalled();
|
expect(mockGetEvents).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('toggleTaskComplete', () => {
|
||||||
|
it('completes a task without sending progressUpdated, which Stalwart rejects (#958)', async () => {
|
||||||
|
useCalendarStore.setState({
|
||||||
|
tasks: [{ id: 't1', '@type': 'Task', progress: 'needs-action' } as any],
|
||||||
|
});
|
||||||
|
mockUpdateEvent.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await useCalendarStore.getState().toggleTaskComplete('t1');
|
||||||
|
|
||||||
|
expect(mockUpdateEvent).toHaveBeenCalledWith(
|
||||||
|
't1',
|
||||||
|
{ progress: 'completed', percentComplete: 100 },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(useCalendarStore.getState().tasks[0].progress).toBe('completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reopens a completed task as needs-action on its owning account', async () => {
|
||||||
|
useCalendarStore.setState({
|
||||||
|
tasks: [{ id: 'acc-2:t1', originalId: 't1', accountId: 'acc-2', progress: 'completed' } as any],
|
||||||
|
});
|
||||||
|
mockUpdateEvent.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await useCalendarStore.getState().toggleTaskComplete('acc-2:t1');
|
||||||
|
|
||||||
|
expect(mockUpdateEvent).toHaveBeenCalledWith(
|
||||||
|
't1',
|
||||||
|
{ progress: 'needs-action', percentComplete: 0 },
|
||||||
|
undefined,
|
||||||
|
'acc-2',
|
||||||
|
);
|
||||||
|
expect(useCalendarStore.getState().tasks[0].progress).toBe('needs-action');
|
||||||
|
});
|
||||||
|
|
||||||
|
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],
|
||||||
|
});
|
||||||
|
let reject!: (err: Error) => void;
|
||||||
|
mockUpdateEvent.mockReturnValue(new Promise((_, r) => { reject = r; }));
|
||||||
|
|
||||||
|
const pending = useCalendarStore.getState().toggleTaskComplete('t1');
|
||||||
|
expect(useCalendarStore.getState().tasks[0].progress).toBe('completed');
|
||||||
|
|
||||||
|
reject(new Error('invalidProperties'));
|
||||||
|
await expect(pending).rejects.toThrow('invalidProperties');
|
||||||
|
expect(useCalendarStore.getState().tasks[0]).toMatchObject({
|
||||||
|
progress: 'needs-action',
|
||||||
|
percentComplete: 20,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -712,12 +712,22 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
const task = get().tasks.find((t) => t.id === id);
|
const task = get().tasks.find((t) => t.id === id);
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
const completed = task.progress === 'completed';
|
const completed = task.progress === 'completed';
|
||||||
// Un-completing goes back to needs-action (not in-process), like webmail;
|
// Un-completing goes back to needs-action (not in-process), like webmail.
|
||||||
// progressUpdated records when the state changed (RFC 8984 §5.2.4).
|
// No progressUpdated: JSCalendar 2.0 dropped it and Stalwart rejects the
|
||||||
|
// whole update with invalidProperties (#958).
|
||||||
const next: Partial<CalendarEvent> = completed
|
const next: Partial<CalendarEvent> = completed
|
||||||
? { progress: 'needs-action', percentComplete: 0, progressUpdated: new Date().toISOString() }
|
? { progress: 'needs-action', percentComplete: 0 }
|
||||||
: { progress: 'completed', percentComplete: 100, progressUpdated: new Date().toISOString() };
|
: { progress: 'completed', percentComplete: 100 };
|
||||||
await get().updateTask(id, next);
|
// Flip the checkbox right away and put it back if the server refuses.
|
||||||
|
const patchTask = (changes: Partial<CalendarEvent>) =>
|
||||||
|
set({ tasks: get().tasks.map((t) => (t.id === id ? { ...t, ...changes } : t)) });
|
||||||
|
patchTask(next);
|
||||||
|
try {
|
||||||
|
await apiUpdateEvent(task.originalId || id, next, undefined, task.accountId);
|
||||||
|
} catch (err) {
|
||||||
|
patchTask({ progress: task.progress, percentComplete: task.percentComplete });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteTask: async (id) => {
|
deleteTask: async (id) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user