mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
fix: keep another account's event in that account when it's edited or deleted
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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")}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user