diff --git a/components/calendar/__tests__/calendar-app-login-series-ux-pim.test.tsx b/components/calendar/__tests__/calendar-app-login-series-ux-pim.test.tsx new file mode 100644 index 000000000..10e31b832 --- /dev/null +++ b/components/calendar/__tests__/calendar-app-login-series-ux-pim.test.tsx @@ -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(); + 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(); + }); +}); diff --git a/components/calendar/__tests__/event-modal-login-calendars-ux-pim.test.tsx b/components/calendar/__tests__/event-modal-login-calendars-ux-pim.test.tsx new file mode 100644 index 000000000..63f1aac45 --- /dev/null +++ b/components/calendar/__tests__/event-modal-login-calendars-ux-pim.test.tsx @@ -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(); + 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(); + 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(); + expect(options()).toEqual(['A Personal', 'A Work', 'B Personal']); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 45f8a7f22..1263433fb 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -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")} diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 4138a67cf..c2ee2538a 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -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(() => { if (!event?.recurrenceRules?.length) return "none";