fix: keep another account's event in that account when it's edited or deleted

This commit is contained in:
Linus Rath
2026-11-04 11:33:37 +01:00
parent 757dbdc1e5
commit 1eaae8ec30
4 changed files with 162 additions and 8 deletions
@@ -0,0 +1,93 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
import { useCalendarStore } from '@/stores/calendar-store';
import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useTaskStore } from '@/stores/task-store';
import { useAccountStore } from '@/stores/account-store';
// pim-12 review: a series of another login was deleted with the active
// login's addresses, so a meeting the user organizes as that login counted as
// someone else's: no "attendees will be told" note, and truncating the series
// didn't tell them. The event's own login's addresses decide now.
vi.mock('@/hooks/use-media-query', () => ({
useIsMobile: () => false,
useIsDesktop: () => true,
useIsFullDesktop: () => true,
useIsTablet: () => false,
useMediaQuery: () => false,
}));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
vi.mock('@/hooks/use-refresh-gesture', () => ({ useRefreshGesture: () => ({ indicator: null }) }));
vi.mock('@/hooks/use-calendar-push-refresh', () => ({ useCalendarPushRefresh: () => {} }));
vi.mock('@/hooks/use-account-principal-emails', () => ({ useAccountPrincipalEmails: () => [] }));
vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ demoMode: false }) }));
import { CalendarApp } from '../calendar-app';
vi.setConfig({ testTimeout: 30000 });
const RIGHTS = {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
};
const calendars = [{
id: 'cal', name: 'Personal', color: '#123456', isDefault: true, sortOrder: 0, description: null,
isSubscribed: true, isVisible: true, includeInAvailability: 'all', defaultAlertsWithTime: null,
defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, myRights: RIGHTS,
}, {
id: 'login-b:bcal', originalId: 'bcal', localAccountId: 'login-b', name: 'B Personal', color: '#654321', isDefault: true,
sortOrder: 0, description: null, isSubscribed: true, isVisible: true, includeInAvailability: 'all',
defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, myRights: RIGHTS,
}] as unknown as Calendar[];
const series = {
id: 'login-b:e1', originalId: 'e1', localAccountId: 'login-b', uid: 'u1', '@type': 'Event', title: 'Team sync',
description: '', start: '2026-10-12T10:00:00', duration: 'PT1H', timeZone: null, showWithoutTime: false,
calendarIds: { 'login-b:bcal': true }, organizerCalendarAddress: 'mailto:b@example.org',
participants: {
org: { '@type': 'Participant', calendarAddress: 'mailto:b@example.org', roles: { owner: true }, participationStatus: 'accepted' },
att: { '@type': 'Participant', calendarAddress: 'mailto:carol@example.net', roles: { attendee: true }, participationStatus: 'needs-action' },
},
alerts: null, recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }], isOrigin: false, status: 'confirmed',
} as unknown as CalendarEvent;
const client = {
getAccountId: () => 'acc',
getMaxParticipantsPerEvent: () => null,
supportsCalendars: () => true,
queryCalendarEvents: vi.fn().mockResolvedValue([]),
getCalendarEvent: vi.fn().mockResolvedValue(null),
getCalendarTasks: vi.fn().mockResolvedValue([]),
};
beforeEach(() => {
useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc', username: 'me@example.com' } as never);
useAccountStore.setState({
accounts: [{ id: 'acc', email: 'me@example.com', username: 'me@example.com' }, { id: 'login-b', email: 'b@example.org', username: 'b@example.org' }],
activeAccountId: 'acc',
} as never);
useCalendarStore.setState({
calendars, events: [series], supportsCalendar: true, selectedCalendarIds: ['cal', 'login-b:bcal'], viewMode: 'week',
selectedDate: new Date(2026, 9, 12), isLoading: false, isLoadingEvents: false, error: null,
fetchCalendars: vi.fn(), fetchEvents: vi.fn(), refreshAllSubscriptions: vi.fn(),
createEvent: vi.fn(), updateEvent: vi.fn(), deleteEvent: vi.fn(), rsvpEvent: vi.fn(),
} as never);
useSettingsStore.setState({ calendarHoverPreview: 'instant', enableCalendarTasks: false, proInterface: false } as never);
useTaskStore.setState({ tasks: [] } as never);
});
describe('CalendarApp series of another login', () => {
it("tells attendees when the user organizes it as that login", async () => {
render(<CalendarApp />);
const chips = await screen.findAllByText('Team sync');
fireEvent.contextMenu(chips[0]);
fireEvent.click(await screen.findByText('events.delete'));
expect(await screen.findByText('participants.cancel_notification')).toBeInTheDocument();
});
});
@@ -0,0 +1,54 @@
import { render, screen, fireEvent, within } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { EventModal } from '../event-modal';
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
// With two logins the editor lists every login's calendars. Moving an existing
// event into another login's calendar sent that calendar's raw id to the
// event's own server, which either moved it into an unrelated calendar there
// or failed. An existing event now moves only within its own login; a new
// event can still go anywhere.
vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
const rights = {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
};
const calendars = [
{ id: 'a-personal', name: 'A Personal', color: '#123456', isDefault: true, myRights: rights },
{ id: 'a-work', name: 'A Work', color: '#123456', isDefault: false, myRights: rights },
{ id: 'login-b:b', name: 'B Personal', color: '#654321', isDefault: true, myRights: rights, localAccountId: 'login-b', originalId: 'b' },
] as unknown as Calendar[];
const event = {
id: 'e1', uid: 'u1', '@type': 'Event', title: 'Planning', description: '', start: '2026-10-12T10:00:00',
duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false, calendarIds: { 'a-personal': true },
participants: null, alerts: null, recurrenceRules: null,
} as unknown as CalendarEvent;
function options(): string[] {
const select = document.querySelector('select[id$="calendar_select"]') as HTMLSelectElement | null;
return select ? within(select).getAllByRole('option').map((o) => o.textContent ?? '') : [];
}
describe('EventModal calendar choice with several logins', () => {
it("offers only the event's own login's calendars when editing", () => {
render(<EventModal event={event} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} currentUserEmails={['me@example.com']} />);
fireEvent.click(screen.getByText('events.edit'));
expect(options()).toEqual(['A Personal', 'A Work']);
});
it("keeps another login's event in that login", () => {
const other = { ...event, calendarIds: { 'login-b:b': true } } as CalendarEvent;
render(<EventModal event={other} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} currentUserEmails={['me@example.com']} />);
fireEvent.click(screen.getByText('events.edit'));
// One calendar left: no select at all.
expect(options()).toEqual([]);
});
it('offers every login for a new event', () => {
render(<EventModal calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} currentUserEmails={['me@example.com']} />);
expect(options()).toEqual(['A Personal', 'A Work', 'B Personal']);
});
});
+6 -6
View File
@@ -1365,7 +1365,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
// One event: asked here, from every place Delete is offered (R3). A
// series gets the scope question above instead.
const title = eventToDelete?.title || t("events.no_title");
const cancels = !!sendSchedulingMessages && !!eventToDelete && userOrganizes(eventToDelete, currentUserEmails);
const cancels = !!sendSchedulingMessages && !!eventToDelete && userOrganizes(eventToDelete, userEmailsFor(eventToDelete));
const ok = await confirmAction({
title: t("delete_dialog.event_title"),
message: cancels
@@ -1385,7 +1385,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
} catch (error) {
toast.error(describeError(error, t("notifications.event_delete_error")));
}
}, [client, deleteEvent, events, editEvent, showEventModal, confirmAction, currentUserEmails, describeError, t]);
}, [client, deleteEvent, events, editEvent, showEventModal, confirmAction, userEmailsFor, describeError, t]);
const submitRsvp = useCallback(async (
eventId: string,
@@ -1430,7 +1430,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
updates,
editor,
sendScheduling,
userEmails: currentUserEmails,
userEmails: userEmailsFor(event),
resolveName: resolveContactName,
}, { client, updateEvent, createEvent, findMasterEvent });
done = saved !== "stopped";
@@ -1445,7 +1445,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const deleted = await deleteOccurrence(scope, {
event,
sendScheduling,
userEmails: currentUserEmails,
userEmails: userEmailsFor(event),
}, { client, updateEvent, deleteEvent, findMasterEvent });
done = deleted !== "stopped";
if (!deleted) {
@@ -1468,7 +1468,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
}
toast.error(describeError(error, t(type === "delete" ? "notifications.event_delete_error" : "notifications.event_error")));
}
}, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, currentUserEmails, resolveContactName, refetchCurrentRange, submitRsvp, describeError, t, tOffline]);
}, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, userEmailsFor, resolveContactName, refetchCurrentRange, submitRsvp, describeError, t, tOffline]);
// A drag or resize. A series asks which occurrences it covers, as the
// editor does; one event moves at once and can be moved back.
@@ -2688,7 +2688,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
isOpen={!!pendingScopeAction}
actionType={pendingScopeAction?.type || "edit"}
note={pendingScopeAction?.type === "delete" && pendingScopeAction.sendScheduling
&& userOrganizes(pendingScopeAction.event, currentUserEmails)
&& userOrganizes(pendingScopeAction.event, userEmailsFor(pendingScopeAction.event))
? t("participants.cancel_notification")
: undefined}
eventTitle={pendingScopeAction?.event.title || t("events.no_title")}
+9 -2
View File
@@ -447,9 +447,16 @@ export function EventModal({
// Calendars offered as create/move targets: writable, non-subscription, plus
// the event's current calendar so an in-place edit never blanks the select.
// An existing event moves only within its own login: the update goes to
// that login's server, which doesn't know another login's calendar ids.
const eventLogin = useMemo(() => {
const primary = event?.calendarIds ? getPrimaryCalendarId(event) : undefined;
return primary ? { known: true, login: calendars.find(c => c.id === primary)?.localAccountId } : { known: false, login: undefined };
}, [event, calendars]);
const selectableCalendars = useMemo(
() => calendars.filter(c => canCreateEventsIn(c, isSubscriptionCalendar) || c.id === calendarId),
[calendars, isSubscriptionCalendar, calendarId]
() => calendars.filter(c => (canCreateEventsIn(c, isSubscriptionCalendar) || c.id === calendarId)
&& (!eventLogin.known || c.localAccountId === eventLogin.login)),
[calendars, isSubscriptionCalendar, calendarId, eventLogin]
);
const [recurrence, setRecurrence] = useState<RecurrenceOption>(() => {
if (!event?.recurrenceRules?.length) return "none";