mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: stop reporting calendar, task and contact changes as done when a plugin stopped them
This commit is contained in:
@@ -667,20 +667,21 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
if (client) toggleTaskCompleteFn(client, task);
|
||||
}, [client, toggleTaskCompleteFn]);
|
||||
|
||||
// false: a plugin stopped it and said why, so the task form stays open.
|
||||
const handleSaveTask = useCallback(async (data: Partial<import("@/lib/jmap/types").CalendarTask>) => {
|
||||
if (!client) return;
|
||||
if (editTask) {
|
||||
await taskStore.updateTask(client, editTask.id, data);
|
||||
} else {
|
||||
await taskStore.createTask(client, data);
|
||||
}
|
||||
if (!client) return false;
|
||||
const saved = editTask
|
||||
? await taskStore.updateTask(client, editTask.id, data)
|
||||
: (await taskStore.createTask(client, data)) !== null;
|
||||
if (!saved) return false;
|
||||
setShowTaskModal(false);
|
||||
setEditTask(null);
|
||||
return true;
|
||||
}, [client, editTask, taskStore]);
|
||||
|
||||
const handleDeleteTask = useCallback(async (id: string) => {
|
||||
if (!client) return;
|
||||
await taskStore.deleteTask(client, id);
|
||||
if (!(await taskStore.deleteTask(client, id))) return;
|
||||
setShowTaskModal(false);
|
||||
setEditTask(null);
|
||||
}, [client, taskStore]);
|
||||
@@ -959,7 +960,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
setEditEvent(null);
|
||||
return;
|
||||
}
|
||||
await updateEvent(client, editEvent.id, data, sendSchedulingMessages);
|
||||
// false: a plugin stopped it and said why; the form stays open.
|
||||
if (!(await updateEvent(client, editEvent.id, data, sendSchedulingMessages))) return;
|
||||
if (data.start) {
|
||||
focusCalendarOnEvent({ start: data.start });
|
||||
}
|
||||
@@ -1106,17 +1108,17 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteEvent(client, id, sendSchedulingMessages);
|
||||
toast.success(t("notifications.event_deleted"));
|
||||
if (await deleteEvent(client, id, sendSchedulingMessages)) toast.success(t("notifications.event_deleted"));
|
||||
} catch {
|
||||
toast.error(t("notifications.event_error"));
|
||||
}
|
||||
}, [client, deleteEvent, events, editEvent, t]);
|
||||
|
||||
// null: no series found; "stopped": a plugin refused to shorten it.
|
||||
const truncateRecurrenceAtEvent = useCallback(async (event: CalendarEvent): Promise<{
|
||||
master: CalendarEvent;
|
||||
originalRules: CalendarEvent["recurrenceRules"];
|
||||
} | null> => {
|
||||
} | null | "stopped"> => {
|
||||
const master = await findMasterEvent(event);
|
||||
if (!master) return null;
|
||||
const originalRules = master.recurrenceRules
|
||||
@@ -1131,7 +1133,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
until,
|
||||
count: null,
|
||||
}));
|
||||
await updateEvent(client!, master.id, { recurrenceRules: truncatedRules });
|
||||
if (!(await updateEvent(client!, master.id, { recurrenceRules: truncatedRules }))) return "stopped";
|
||||
return { master, originalRules };
|
||||
}, [client, findMasterEvent, updateEvent]);
|
||||
|
||||
@@ -1143,8 +1145,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await rsvpEvent(client, eventId, participantId, status, undefined, scope);
|
||||
toast.success(t("notifications.rsvp_updated"));
|
||||
if (await rsvpEvent(client, eventId, participantId, status, undefined, scope)) toast.success(t("notifications.rsvp_updated"));
|
||||
} catch {
|
||||
toast.error(t("notifications.rsvp_error"));
|
||||
}
|
||||
@@ -1162,6 +1163,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
const updates = type === "edit" ? pendingScopeAction.updates : undefined;
|
||||
setPendingScopeAction(null);
|
||||
|
||||
// false when a plugin stopped the change (it says why): no "updated".
|
||||
let done = true;
|
||||
try {
|
||||
if (type === "edit" && updates) {
|
||||
switch (scope) {
|
||||
@@ -1170,7 +1173,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
// The store writes one occurrence through its own (synthetic)
|
||||
// id, or as a recurrence override on the base event it was
|
||||
// expanded from.
|
||||
await updateEvent(client, event.id, updates, sendScheduling);
|
||||
done = await updateEvent(client, event.id, updates, sendScheduling);
|
||||
break;
|
||||
}
|
||||
// An override outside an expanded series: override it on the master.
|
||||
@@ -1179,14 +1182,15 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
const overridePatch = occurrence
|
||||
&& buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates));
|
||||
if (master && overridePatch) {
|
||||
await updateEvent(client, master.id, overridePatch, sendScheduling);
|
||||
done = await updateEvent(client, master.id, overridePatch, sendScheduling);
|
||||
} else {
|
||||
await updateEvent(client, event.id, updates, sendScheduling);
|
||||
done = await updateEvent(client, event.id, updates, sendScheduling);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "this_and_future": {
|
||||
const result = await truncateRecurrenceAtEvent(event);
|
||||
if (result === "stopped") return;
|
||||
if (!result) {
|
||||
toast.error(t("notifications.event_error"));
|
||||
return;
|
||||
@@ -1234,7 +1238,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
}
|
||||
const allUpdates = { ...updates };
|
||||
delete (allUpdates as Record<string, unknown>).recurrenceId;
|
||||
await updateEvent(client, master.id, allUpdates, sendScheduling);
|
||||
done = await updateEvent(client, master.id, allUpdates, sendScheduling);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
@@ -1242,6 +1246,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
throw new Error(`Unhandled scope: ${_exhaustive}`);
|
||||
}
|
||||
}
|
||||
if (!done) return;
|
||||
toast.success(t("notifications.event_updated"));
|
||||
} else {
|
||||
switch (scope) {
|
||||
@@ -1249,21 +1254,22 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
if (isServerRecurrenceInstance(event) || isBrowserExpandedOccurrence(event)) {
|
||||
// The store destroys a server occurrence, or excludes a
|
||||
// browser-expanded one on its base event.
|
||||
await deleteEvent(client, event.id, sendScheduling);
|
||||
done = await deleteEvent(client, event.id, sendScheduling);
|
||||
break;
|
||||
}
|
||||
// An override outside an expanded series: exclude it on the master.
|
||||
const delMaster = await findMasterEvent(event);
|
||||
const excludePatch = delMaster && buildFallbackExcludePatch(overrideContextOf(event, delMaster));
|
||||
if (delMaster && excludePatch) {
|
||||
await updateEvent(client, delMaster.id, excludePatch, sendScheduling);
|
||||
done = await updateEvent(client, delMaster.id, excludePatch, sendScheduling);
|
||||
} else {
|
||||
await deleteEvent(client, event.id, sendScheduling);
|
||||
done = await deleteEvent(client, event.id, sendScheduling);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "this_and_future": {
|
||||
const result = await truncateRecurrenceAtEvent(event);
|
||||
if (result === "stopped") return;
|
||||
if (!result) {
|
||||
toast.error(t("notifications.event_error"));
|
||||
return;
|
||||
@@ -1276,7 +1282,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
toast.error(t("notifications.event_error"));
|
||||
return;
|
||||
}
|
||||
await deleteEvent(client, master.id, sendScheduling);
|
||||
done = await deleteEvent(client, master.id, sendScheduling);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
@@ -1284,6 +1290,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
throw new Error(`Unhandled scope: ${_exhaustive}`);
|
||||
}
|
||||
}
|
||||
if (!done) return;
|
||||
toast.success(t("notifications.event_deleted"));
|
||||
}
|
||||
try {
|
||||
@@ -1338,7 +1345,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
? `${detailEvent.description}${separator}${note}`
|
||||
: `--- ${timestamp} ---\n${note}`;
|
||||
try {
|
||||
await updateEvent(client, detailEvent.id, { description: newDescription });
|
||||
if (!(await updateEvent(client, detailEvent.id, { description: newDescription }))) return;
|
||||
setDetailEvent({ ...detailEvent, description: newDescription });
|
||||
toast.success(t("detail.note_saved"));
|
||||
} catch {
|
||||
|
||||
@@ -189,6 +189,11 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
|
||||
setStep("importing");
|
||||
try {
|
||||
const count = await importEvents(client, eventsToImport, calendarId);
|
||||
if (count === null) {
|
||||
// A plugin stopped the import and said why.
|
||||
setStep("preview");
|
||||
return;
|
||||
}
|
||||
toast.success(t("success", { count }));
|
||||
onClose();
|
||||
} catch {
|
||||
|
||||
@@ -14,7 +14,8 @@ import { taskToView } from "@/lib/plugin-projection";
|
||||
interface TaskModalProps {
|
||||
task?: CalendarTask | null;
|
||||
calendars: Calendar[];
|
||||
onSave: (data: Partial<CalendarTask>) => void | Promise<void>;
|
||||
/** Resolving false keeps the form open (a plugin stopped the save). */
|
||||
onSave: (data: Partial<CalendarTask>) => void | boolean | Promise<void | boolean>;
|
||||
onDelete?: (id: string) => void;
|
||||
onClose: () => void;
|
||||
isMobile?: boolean;
|
||||
@@ -145,7 +146,7 @@ export function TaskModal({
|
||||
data.id = task.id;
|
||||
}
|
||||
|
||||
await onSave(data);
|
||||
if ((await onSave(data)) === false) return;
|
||||
onClose();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
|
||||
@@ -338,21 +338,23 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
const handleDropContactsToCategory = useCallback(async (contactIds: string[], keyword: string) => {
|
||||
if (!client && supportsSync) return;
|
||||
try {
|
||||
// Contacts in the category afterwards; a plugin may stop some updates.
|
||||
let added = 0;
|
||||
for (const contactId of contactIds) {
|
||||
const contact = contacts.find(c => c.id === contactId);
|
||||
if (!contact) continue;
|
||||
const existingKeywords = contact.keywords || {};
|
||||
if (existingKeywords[keyword]) continue; // already has this keyword
|
||||
if (existingKeywords[keyword]) { added++; continue; } // already has this keyword
|
||||
const updatedKeywords = { ...existingKeywords, [keyword]: true };
|
||||
if (supportsSync && client) {
|
||||
await updateContact(client, contactId, { keywords: updatedKeywords });
|
||||
} else {
|
||||
await updateLocalContact(contactId, { keywords: updatedKeywords });
|
||||
}
|
||||
const updated = supportsSync && client
|
||||
? await updateContact(client, contactId, { keywords: updatedKeywords })
|
||||
: await updateLocalContact(contactId, { keywords: updatedKeywords });
|
||||
if (updated !== false) added++;
|
||||
}
|
||||
const msg = contactIds.length === 1
|
||||
if (added === 0) return;
|
||||
const msg = added === 1
|
||||
? t("category_added", { name: keyword })
|
||||
: t("category_added_plural", { count: contactIds.length, name: keyword });
|
||||
: t("category_added_plural", { count: added, name: keyword });
|
||||
toast.success(msg);
|
||||
} catch (error) {
|
||||
console.error('Failed to add contacts to category:', error);
|
||||
@@ -694,10 +696,10 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await bulkDeleteContacts(
|
||||
if (!(await bulkDeleteContacts(
|
||||
supportsSync && client ? client : null,
|
||||
Array.from(selectedContactIds)
|
||||
);
|
||||
))) return;
|
||||
toast.success(t("bulk.deleted", { count: selectedContactIds.size }));
|
||||
setView("list");
|
||||
} catch (error) {
|
||||
|
||||
@@ -522,7 +522,7 @@ describe('CalendarInvitationBanner', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
mocks.calendarState.rsvpEvent.mockResolvedValue(undefined);
|
||||
mocks.calendarState.rsvpEvent.mockResolvedValue(true);
|
||||
|
||||
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-6' })} />);
|
||||
|
||||
@@ -543,6 +543,73 @@ describe('CalendarInvitationBanner', () => {
|
||||
'accepted',
|
||||
null,
|
||||
);
|
||||
expect(await screen.findByText('Response sent')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says nothing was sent when a plugin stops the answer', async () => {
|
||||
mocks.calendarState.events = [
|
||||
{
|
||||
id: 'event-6',
|
||||
uid: 'uid-6',
|
||||
start: '2026-03-28T09:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
|
||||
{
|
||||
uid: 'uid-6',
|
||||
title: 'Sparse Event',
|
||||
start: '2026-03-28T09:00:00Z',
|
||||
participants: {
|
||||
parsedAttendee: {
|
||||
'@type': 'Participant',
|
||||
email: 'user@example.com',
|
||||
name: 'User',
|
||||
roles: { required: true },
|
||||
participationStatus: 'needs-action',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mocks.clientMock.getCalendarEvent.mockResolvedValue({
|
||||
id: 'event-6',
|
||||
uid: 'uid-6',
|
||||
start: '2026-03-28T09:00:00Z',
|
||||
participants: {
|
||||
canonicalAttendee: {
|
||||
'@type': 'Participant',
|
||||
email: 'user@example.com',
|
||||
name: 'User',
|
||||
roles: { attendee: true },
|
||||
participationStatus: 'needs-action',
|
||||
},
|
||||
},
|
||||
});
|
||||
mocks.calendarState.rsvpEvent.mockResolvedValue(false);
|
||||
|
||||
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-6' })} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Accept')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Accept'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.clientMock.getCalendarEvent).toHaveBeenCalledWith('event-6');
|
||||
});
|
||||
|
||||
expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith(
|
||||
mocks.clientMock,
|
||||
'event-6',
|
||||
'canonicalAttendee',
|
||||
'accepted',
|
||||
null,
|
||||
);
|
||||
// The plugin that stopped it says why; the banner must not claim it went.
|
||||
await waitFor(() => expect(mocks.calendarState.rsvpEvent).toHaveBeenCalled());
|
||||
expect(screen.queryByText('Response sent')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to organizerCalendarAddress when replyTo is missing', async () => {
|
||||
@@ -580,7 +647,7 @@ describe('CalendarInvitationBanner', () => {
|
||||
},
|
||||
},
|
||||
]);
|
||||
mocks.calendarState.rsvpEvent.mockResolvedValue(undefined);
|
||||
mocks.calendarState.rsvpEvent.mockResolvedValue(true);
|
||||
|
||||
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-7' })} />);
|
||||
|
||||
@@ -639,7 +706,7 @@ describe('CalendarInvitationBanner', () => {
|
||||
},
|
||||
},
|
||||
]);
|
||||
mocks.calendarState.updateEvent.mockResolvedValue(undefined);
|
||||
mocks.calendarState.updateEvent.mockResolvedValue(true);
|
||||
|
||||
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-8' })} />);
|
||||
|
||||
@@ -671,7 +738,7 @@ describe('CalendarInvitationBanner', () => {
|
||||
});
|
||||
|
||||
it('shows counter changes and lets organizers apply the proposal', async () => {
|
||||
mocks.calendarState.updateEvent = vi.fn().mockResolvedValue(undefined);
|
||||
mocks.calendarState.updateEvent = vi.fn().mockResolvedValue(true);
|
||||
mocks.calendarState.events = [
|
||||
{
|
||||
id: 'event-5',
|
||||
|
||||
@@ -599,7 +599,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
&& (!eventForRsvp?.participants || Object.keys(eventForRsvp.participants).length === 0)
|
||||
);
|
||||
if (eventForRsvp && existingEventParticipant) {
|
||||
await rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp);
|
||||
// false: a plugin stopped it and said why; keep the old answer.
|
||||
if (!(await rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp))) return;
|
||||
setRsvpStatus(status);
|
||||
setActionNotice(t('rsvp_sent'));
|
||||
setState('parsed');
|
||||
@@ -609,7 +610,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
if (!repairedParticipants) {
|
||||
setActionError(t('action_failed'));
|
||||
} else {
|
||||
await updateEvent(client, eventForRsvp.id, {
|
||||
const updated = await updateEvent(client, eventForRsvp.id, {
|
||||
participants: repairedParticipants,
|
||||
// Stalwart routes the iTIP REPLY via the stored ORGANIZER
|
||||
// (organizerCalendarAddress; the RFC 8984 replyTo is retired).
|
||||
@@ -618,6 +619,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
? { organizerCalendarAddress: replyToForRsvp.imip }
|
||||
: {}),
|
||||
}, true);
|
||||
if (!updated) return;
|
||||
setRsvpStatus(status);
|
||||
setActionNotice(t('rsvp_sent'));
|
||||
setState('parsed');
|
||||
@@ -626,6 +628,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
setActionError(t('action_failed'));
|
||||
} else if (calId) {
|
||||
const imported = await importEvents(client, [parsedEvent], calId);
|
||||
if (imported === null) return;
|
||||
if (imported > 0) {
|
||||
const newEvent = useCalendarStore.getState().events.find(
|
||||
(e) => e.uid === parsedEvent.uid
|
||||
@@ -633,7 +636,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
const participant = myParticipant
|
||||
|| (newEvent && currentUserEmail ? findParticipantByEmail(newEvent, currentUserEmail) : null);
|
||||
if (newEvent && participant) {
|
||||
await rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp);
|
||||
if (!(await rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp))) return;
|
||||
setRsvpStatus(status);
|
||||
setActionNotice(t('rsvp_sent'));
|
||||
} else {
|
||||
@@ -682,6 +685,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const count = await importEvents(client, [parsedEvent], calId);
|
||||
if (count === null) return;
|
||||
if (count > 0) {
|
||||
setActionNotice(t('added'));
|
||||
setState('parsed');
|
||||
@@ -740,7 +744,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
await updateEvent(client, existingEvent.id, proposalPatch, true);
|
||||
if (!(await updateEvent(client, existingEvent.id, proposalPatch, true))) return;
|
||||
setActionNotice(t('proposal_applied'));
|
||||
setState('parsed');
|
||||
setRsvpStatus(null);
|
||||
|
||||
@@ -299,7 +299,9 @@ function TasksPanel({ onClose }: { onClose: () => void }) {
|
||||
const title = draft.trim();
|
||||
if (!title) return;
|
||||
setDraft("");
|
||||
await createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [taskCalendar.id]: true } });
|
||||
// null: a plugin stopped it; give the user their text back.
|
||||
const created = await createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [taskCalendar.id]: true } });
|
||||
if (!created) setDraft(title);
|
||||
}}
|
||||
>
|
||||
<label className="flex items-center gap-3 rounded-full px-1 py-1.5 text-sm text-primary hover:bg-muted/60">
|
||||
|
||||
@@ -101,7 +101,7 @@ describe('updateEvent hooks', () => {
|
||||
const after = vi.fn();
|
||||
calendarHooks.onAfterEventUpdate.register('t', after);
|
||||
|
||||
await useCalendarStore.getState().updateEvent(client, 'e1', { title: 'Renamed' });
|
||||
expect(await useCalendarStore.getState().updateEvent(client, 'e1', { title: 'Renamed' })).toBe(true);
|
||||
|
||||
expect(before).toHaveBeenCalledWith({ eventId: 'e1', patch: { title: 'Renamed' } });
|
||||
expect(after).toHaveBeenCalledTimes(1);
|
||||
@@ -115,7 +115,7 @@ describe('updateEvent hooks', () => {
|
||||
const after = vi.fn();
|
||||
calendarHooks.onAfterEventUpdate.register('t', after);
|
||||
|
||||
await useCalendarStore.getState().updateEvent(client, 'e1', { title: 'Renamed' });
|
||||
expect(await useCalendarStore.getState().updateEvent(client, 'e1', { title: 'Renamed' })).toBe(false);
|
||||
|
||||
expect(client.updateCalendarEvent).not.toHaveBeenCalled();
|
||||
expect(useCalendarStore.getState().events[0].title).toBe('Planning');
|
||||
@@ -161,7 +161,7 @@ describe('deleteEvent hooks', () => {
|
||||
const after = vi.fn();
|
||||
calendarHooks.onAfterEventDelete.register('t', after);
|
||||
|
||||
await useCalendarStore.getState().deleteEvent(client, 'e1');
|
||||
expect(await useCalendarStore.getState().deleteEvent(client, 'e1')).toBe(true);
|
||||
|
||||
expect(before).toHaveBeenCalledWith({ eventId: 'e1', title: 'Planning' });
|
||||
expect(after).toHaveBeenCalledTimes(1);
|
||||
@@ -175,7 +175,7 @@ describe('deleteEvent hooks', () => {
|
||||
const after = vi.fn();
|
||||
calendarHooks.onAfterEventDelete.register('t', after);
|
||||
|
||||
await useCalendarStore.getState().deleteEvent(client, 'e1');
|
||||
expect(await useCalendarStore.getState().deleteEvent(client, 'e1')).toBe(false);
|
||||
|
||||
expect(client.deleteCalendarEvent).not.toHaveBeenCalled();
|
||||
expect(useCalendarStore.getState().events).toHaveLength(1);
|
||||
@@ -204,7 +204,7 @@ describe('rsvpEvent hooks', () => {
|
||||
const after = vi.fn();
|
||||
calendarHooks.onEventRsvp.register('t', after);
|
||||
|
||||
await useCalendarStore.getState().rsvpEvent(client, 'e1', 'p1', 'accepted');
|
||||
expect(await useCalendarStore.getState().rsvpEvent(client, 'e1', 'p1', 'accepted')).toBe(true);
|
||||
|
||||
expect(before).toHaveBeenCalledWith({ eventId: 'e1', status: 'accepted' });
|
||||
expect(after).toHaveBeenCalledTimes(1);
|
||||
@@ -218,7 +218,7 @@ describe('rsvpEvent hooks', () => {
|
||||
const after = vi.fn();
|
||||
calendarHooks.onEventRsvp.register('t', after);
|
||||
|
||||
await useCalendarStore.getState().rsvpEvent(client, 'e1', 'p1', 'accepted');
|
||||
expect(await useCalendarStore.getState().rsvpEvent(client, 'e1', 'p1', 'accepted')).toBe(false);
|
||||
|
||||
expect(client.updateCalendarEvent).not.toHaveBeenCalled();
|
||||
expect(useCalendarStore.getState().events[0].participants?.p1.participationStatus).toBe('needs-action');
|
||||
@@ -268,7 +268,8 @@ describe('importEvents hooks', () => {
|
||||
|
||||
const count = await useCalendarStore.getState().importEvents(client, file, 'c1');
|
||||
|
||||
expect(count).toBe(0);
|
||||
// null, not 0: "0 events imported" would be a success message.
|
||||
expect(count).toBeNull();
|
||||
expect(client.getCalendarEvents).not.toHaveBeenCalled();
|
||||
expect(client.batchCreateCalendarEvents).not.toHaveBeenCalled();
|
||||
expect(after).not.toHaveBeenCalled();
|
||||
|
||||
@@ -224,7 +224,7 @@ describe('contact-store plugin hooks: contact CRUD', () => {
|
||||
contactHooks.onBeforeContactDelete.register('test', before);
|
||||
contactHooks.onAfterContactDelete.register('test', after);
|
||||
|
||||
await useContactStore.getState().bulkDeleteContacts(client, ['a', 'b', 'c']);
|
||||
expect(await useContactStore.getState().bulkDeleteContacts(client, ['a', 'b', 'c'])).toBe(true);
|
||||
await flush();
|
||||
|
||||
expect(before).toHaveBeenCalledOnce();
|
||||
@@ -241,7 +241,7 @@ describe('contact-store plugin hooks: contact CRUD', () => {
|
||||
const client = makeClient();
|
||||
contactHooks.onBeforeContactDelete.register('test', () => false);
|
||||
|
||||
await useContactStore.getState().bulkDeleteContacts(client, ['a', 'b']);
|
||||
expect(await useContactStore.getState().bulkDeleteContacts(client, ['a', 'b'])).toBe(false);
|
||||
|
||||
expect(client.deleteContact).not.toHaveBeenCalled();
|
||||
expect(useContactStore.getState().contacts).toHaveLength(2);
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('create, update and delete', () => {
|
||||
const after = vi.fn();
|
||||
taskHooks.onTaskUpdate.register('t', after);
|
||||
|
||||
await useTaskStore.getState().updateTask(client, 't1', { title: 'Renamed' });
|
||||
expect(await useTaskStore.getState().updateTask(client, 't1', { title: 'Renamed' })).toBe(true);
|
||||
|
||||
expect(before).toHaveBeenCalledWith({ taskId: 't1', patch: { title: 'Renamed' } });
|
||||
expect(after).toHaveBeenCalledTimes(1);
|
||||
@@ -111,7 +111,7 @@ describe('create, update and delete', () => {
|
||||
const after = vi.fn();
|
||||
taskHooks.onTaskUpdate.register('t', after);
|
||||
|
||||
await useTaskStore.getState().updateTask(client, 't1', { title: 'Renamed' });
|
||||
expect(await useTaskStore.getState().updateTask(client, 't1', { title: 'Renamed' })).toBe(false);
|
||||
|
||||
expect(client.updateCalendarTask).not.toHaveBeenCalled();
|
||||
expect(useTaskStore.getState().tasks[0].title).toBe('Write report');
|
||||
@@ -137,7 +137,7 @@ describe('create, update and delete', () => {
|
||||
const after = vi.fn();
|
||||
taskHooks.onTaskDelete.register('t', after);
|
||||
|
||||
await useTaskStore.getState().deleteTask(client, 't1');
|
||||
expect(await useTaskStore.getState().deleteTask(client, 't1')).toBe(true);
|
||||
|
||||
expect(before).toHaveBeenCalledWith({ taskId: 't1', title: 'Write report' });
|
||||
expect(after).toHaveBeenCalledWith({ taskId: 't1', title: 'Write report' });
|
||||
@@ -149,7 +149,7 @@ describe('create, update and delete', () => {
|
||||
const after = vi.fn();
|
||||
taskHooks.onTaskDelete.register('t', after);
|
||||
|
||||
await useTaskStore.getState().deleteTask(client, 't1');
|
||||
expect(await useTaskStore.getState().deleteTask(client, 't1')).toBe(false);
|
||||
expect(client.deleteCalendarTask).not.toHaveBeenCalled();
|
||||
expect(useTaskStore.getState().tasks).toHaveLength(1);
|
||||
|
||||
|
||||
+14
-11
@@ -590,10 +590,10 @@ interface CalendarStore {
|
||||
fetchAllAccountsCalendars: (accounts: CalendarAccountClient[]) => Promise<void>;
|
||||
fetchAllAccountsEvents: (accounts: CalendarAccountClient[], start: string, end: string, options?: FetchEventsOptions) => Promise<void>;
|
||||
createEvent: (client: IJMAPClient, event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<CalendarEvent | null>;
|
||||
updateEvent: (client: IJMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<void>;
|
||||
deleteEvent: (client: IJMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>;
|
||||
rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null, scope?: 'occurrence' | 'series') => Promise<void>;
|
||||
importEvents: (client: IJMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
|
||||
updateEvent: (client: IJMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<boolean>;
|
||||
deleteEvent: (client: IJMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<boolean>;
|
||||
rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null, scope?: 'occurrence' | 'series') => Promise<boolean>;
|
||||
importEvents: (client: IJMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number | null>;
|
||||
updateCalendar: (client: IJMAPClient, calendarId: string, updates: Partial<Calendar>) => Promise<void>;
|
||||
setDefaultCalendar: (client: IJMAPClient, calendarId: string) => Promise<void>;
|
||||
shareCalendar: (client: IJMAPClient, calendarId: string, principalId: string, rights: CalendarRights | null) => Promise<void>;
|
||||
@@ -890,8 +890,8 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
updateEvent: async (client, id, updates, sendSchedulingMessages) => {
|
||||
if (!(await calendarHooks.onBeforeEventUpdate.intercept({ eventId: id, patch: updates }))) return;
|
||||
updateEvent: async (client, id, updates, sendSchedulingMessages): Promise<boolean> => {
|
||||
if (!(await calendarHooks.onBeforeEventUpdate.intercept({ eventId: id, patch: updates }))) return false;
|
||||
set({ error: null });
|
||||
try {
|
||||
// Resolve shared event IDs and client-side expanded occurrence IDs
|
||||
@@ -971,9 +971,10 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
set({ error: 'Failed to update event' });
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
rsvpEvent: async (client, eventId, participantId, status, replyTo, scope = 'series') => {
|
||||
rsvpEvent: async (client, eventId, participantId, status, replyTo, scope = 'series'): Promise<boolean> => {
|
||||
set({ error: null });
|
||||
// JMAP participant IDs are opaque strings - they can contain @, ., :,
|
||||
// / etc. The id is RFC 6901-escaped below before being embedded in the
|
||||
@@ -982,7 +983,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
set({ error: 'Invalid participant ID' });
|
||||
throw new Error('Invalid participant ID');
|
||||
}
|
||||
if (!(await calendarHooks.onBeforeEventRsvp.intercept({ eventId, status }))) return;
|
||||
if (!(await calendarHooks.onBeforeEventRsvp.intercept({ eventId, status }))) return false;
|
||||
try {
|
||||
// Resolve shared event IDs and client-side expanded occurrence IDs.
|
||||
// Scope 'series' answers for the whole series, so an expanded
|
||||
@@ -1041,18 +1042,19 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
set({ error: 'Failed to update RSVP' });
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
importEvents: async (client, events, calendarId) => {
|
||||
if (!(await calendarHooks.onBeforeEventsImport.intercept({ calendarId, count: events.length }))) return 0;
|
||||
if (!(await calendarHooks.onBeforeEventsImport.intercept({ calendarId, count: events.length }))) return null;
|
||||
const imported = await importEventsUnhooked(client, events, calendarId);
|
||||
void calendarHooks.onEventsImport.emit({ calendarId, imported, skipped: Math.max(0, events.length - imported) });
|
||||
return imported;
|
||||
},
|
||||
|
||||
deleteEvent: async (client, id, sendSchedulingMessages) => {
|
||||
deleteEvent: async (client, id, sendSchedulingMessages): Promise<boolean> => {
|
||||
const title = get().events.find((e) => e.id === id)?.title || '';
|
||||
if (!(await calendarHooks.onBeforeEventDelete.intercept({ eventId: id, title }))) return;
|
||||
if (!(await calendarHooks.onBeforeEventDelete.intercept({ eventId: id, title }))) return false;
|
||||
set({ error: null });
|
||||
try {
|
||||
// Resolve shared event IDs and client-side expanded occurrence IDs
|
||||
@@ -1091,6 +1093,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
set({ error: 'Failed to delete event' });
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
setSelectedDate: (date) => {
|
||||
|
||||
@@ -388,7 +388,8 @@ interface ContactStore {
|
||||
selectRangeContacts: (targetId: string, sortedIds: string[]) => void;
|
||||
selectAllContacts: (ids: string[]) => void;
|
||||
clearSelection: () => void;
|
||||
bulkDeleteContacts: (client: IJMAPClient | null, ids: string[]) => Promise<void>;
|
||||
/** false when a plugin stopped it (the plugin tells the user why). */
|
||||
bulkDeleteContacts: (client: IJMAPClient | null, ids: string[]) => Promise<boolean>;
|
||||
bulkAddToGroup: (client: IJMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
|
||||
moveContactToAddressBook: (client: IJMAPClient, contactIds: string[], addressBook: AddressBook) => Promise<void>;
|
||||
createAddressBook: (client: IJMAPClient, name: string) => Promise<AddressBook>;
|
||||
@@ -997,8 +998,8 @@ export const useContactStore = create<ContactStore>()(
|
||||
clearSelection: () => set({ selectedContactIds: new Set<string>(), lastSelectedContactId: null }),
|
||||
|
||||
bulkDeleteContacts: async (client, ids) => {
|
||||
if (ids.length === 0) return;
|
||||
if (!(await contactHooks.onBeforeContactDelete.intercept({ contactIds: [...ids] }))) return;
|
||||
if (ids.length === 0) return true;
|
||||
if (!(await contactHooks.onBeforeContactDelete.intercept({ contactIds: [...ids] }))) return false;
|
||||
set({ error: null });
|
||||
const { supportsSync, contacts } = get();
|
||||
const deletedIds = new Set(ids);
|
||||
@@ -1031,6 +1032,7 @@ export const useContactStore = create<ContactStore>()(
|
||||
if (deletedIds.size > 0) {
|
||||
void contactHooks.onAfterContactDelete.emit({ contactIds: ids.filter(id => deletedIds.has(id)) });
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
bulkAddToGroup: async (client, groupId, contactIds) => {
|
||||
|
||||
@@ -21,8 +21,9 @@ interface TaskStore {
|
||||
fetchTasks: (client: IJMAPClient, calendarIds?: string[]) => Promise<void>;
|
||||
/** Resolves to null when a plugin stopped the create. */
|
||||
createTask: (client: IJMAPClient, task: Partial<CalendarTask>) => Promise<CalendarTask | null>;
|
||||
updateTask: (client: IJMAPClient, id: string, updates: Partial<CalendarTask>) => Promise<void>;
|
||||
deleteTask: (client: IJMAPClient, id: string) => Promise<void>;
|
||||
/** false when a plugin stopped it (the plugin tells the user why). */
|
||||
updateTask: (client: IJMAPClient, id: string, updates: Partial<CalendarTask>) => Promise<boolean>;
|
||||
deleteTask: (client: IJMAPClient, id: string) => Promise<boolean>;
|
||||
toggleTaskComplete: (client: IJMAPClient, task: CalendarTask) => Promise<void>;
|
||||
clearTasks: () => void;
|
||||
}
|
||||
@@ -79,7 +80,7 @@ export const useTaskStore = create<TaskStore>((set, get) => ({
|
||||
},
|
||||
|
||||
updateTask: async (client, id, updates) => {
|
||||
if (!(await taskHooks.onBeforeTaskUpdate.intercept({ taskId: id, patch: updates }))) return;
|
||||
if (!(await taskHooks.onBeforeTaskUpdate.intercept({ taskId: id, patch: updates }))) return false;
|
||||
const before = get().tasks.find(t => t.id === id);
|
||||
const newId = await client.updateCalendarTask(id, updates);
|
||||
if (newId && newId !== id) {
|
||||
@@ -90,24 +91,26 @@ export const useTaskStore = create<TaskStore>((set, get) => ({
|
||||
const moved = get().tasks.find(t => t.id === newId)
|
||||
?? ({ ...before, ...updates, id: newId } as CalendarTask);
|
||||
void taskHooks.onTaskUpdate.emit(taskToView(moved));
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
const updated = { ...(before ?? { id }), ...updates, updated: new Date().toISOString() } as CalendarTask;
|
||||
set({
|
||||
tasks: get().tasks.map(t => t.id === id ? updated : t),
|
||||
});
|
||||
void taskHooks.onTaskUpdate.emit(taskToView(updated));
|
||||
return true;
|
||||
},
|
||||
|
||||
deleteTask: async (client, id) => {
|
||||
const title = get().tasks.find(t => t.id === id)?.title || '';
|
||||
if (!(await taskHooks.onBeforeTaskDelete.intercept({ taskId: id, title }))) return;
|
||||
if (!(await taskHooks.onBeforeTaskDelete.intercept({ taskId: id, title }))) return false;
|
||||
await client.deleteCalendarTask(id);
|
||||
set({
|
||||
tasks: get().tasks.filter(t => t.id !== id),
|
||||
selectedTaskId: get().selectedTaskId === id ? null : get().selectedTaskId,
|
||||
});
|
||||
void taskHooks.onTaskDelete.emit({ taskId: id, title });
|
||||
return true;
|
||||
},
|
||||
|
||||
toggleTaskComplete: async (client, task) => {
|
||||
|
||||
Reference in New Issue
Block a user