mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
fix: keep occurrence alert edits on 1.0 and count the organizer
Fixes from a second review of the Stalwart 1.0 calendar work.
- Changing the alerts of one occurrence of a series that uses the
calendar's default alerts said "Event updated" and kept the default
on 1.0: while a series uses them, 1.0 shows the defaults in place of
an occurrence's own alerts and refuses `useDefaultAlerts` on a single
occurrence. The series now stops using the defaults first, keeping
the alerts it shows under ids of their own, and then the occurrence's
alerts go out. 0.16.23 showed the edit already and still does.
- The participant limit counts the organizer entry a save adds (live on
1.0: 19 attendees and the organizer are accepted, 20 are refused), so
the editor warned one attendee late. It warns at the limit now, and
the warning and the refusal say the organizer counts, with plural
forms in every locale.
- Adding or answering an invitation from the mail view, and applying a
proposal, said "could not complete" when the server refused the
event: the import swallowed its SetError. The banner now asks the
import to reject with the refusal and names the participant limit
like the calendar does.
- A range load that finished after a later one could put back its own
events, range or failure. Only the latest load writes the store, and
a load for an account the user left is dropped.
- A search filter with an OR, a NOT or a condition of several keys next
to its date bounds goes out as it is instead of being expanded, which
1.0 refuses.
- A time zone setting typed in another letter case ("europe/berlin")
is used as Intl spells it. 1.0 refused it as a query argument, and
0.16 and 1.0 both dropped it from new events, which were stored
floating.
- A failed save no longer shows the store's English toast next to the
translated message.
- Tests for a limited read that asks later chunks of a request again
after a refused chunk or a full page.
This commit is contained in:
@@ -6,7 +6,8 @@ import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
// Stalwart 1.0 refuses to save an event with more participants than the
|
||||
// calendars capability's maxParticipantsPerEvent (20 by default); 0.16
|
||||
// advertises the same value without enforcing it. The editor warns, and
|
||||
// still saves.
|
||||
// still saves. The organizer entry a save adds counts: live on 1.0, 19
|
||||
// attendees and the organizer are accepted, 20 and the organizer refused.
|
||||
|
||||
vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
|
||||
|
||||
@@ -50,13 +51,13 @@ function editMeeting(attendees: number, maxParticipants: number | null) {
|
||||
}
|
||||
|
||||
describe('EventModal participant limit', () => {
|
||||
it('warns when the event has more attendees than the server allows on Stalwart 1.0', () => {
|
||||
editMeeting(3, 2);
|
||||
it('warns when the attendees and the organizer are more than the server allows on Stalwart 1.0', () => {
|
||||
editMeeting(20, 20);
|
||||
expect(screen.getByRole('status')).toHaveTextContent('participants.limit_warning');
|
||||
});
|
||||
|
||||
it('stays quiet within the limit', () => {
|
||||
editMeeting(2, 2);
|
||||
it('stays quiet within the limit, the organizer counted', () => {
|
||||
editMeeting(19, 20);
|
||||
expect(screen.queryByText('participants.limit_warning')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -480,8 +480,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
const fetchAllAccountsCalendarsFn = useCalendarStore((s) => s.fetchAllAccountsCalendars);
|
||||
const fetchAllAccountsEventsFn = useCalendarStore((s) => s.fetchAllAccountsEvents);
|
||||
|
||||
// Loads the range the view shows now - not the store's dateRange, which a
|
||||
// failed load leaves at the range loaded before it.
|
||||
// Loads the range the view shows now - after a failed load the same as the
|
||||
// store's dateRange, which then holds the range asked for.
|
||||
const retryEventsLoad = useCallback(() => {
|
||||
if (!client || !dateRange) return;
|
||||
if (multiAccountEnabled && accountClients.length > 0) {
|
||||
|
||||
@@ -1211,8 +1211,9 @@ export function EventModal({
|
||||
onRemove={handleRemoveAttendee}
|
||||
/>
|
||||
{/* Only a warning: 0.16 advertises the limit without enforcing
|
||||
it, Stalwart 1.0 refuses to save more attendees. */}
|
||||
{maxParticipants != null && attendees.length > maxParticipants && (
|
||||
it, Stalwart 1.0 refuses to save more participants - the
|
||||
organizer entry a save adds counts too. */}
|
||||
{maxParticipants != null && attendees.length > 0 && attendees.length + 1 > maxParticipants && (
|
||||
<p role="status" className="mt-1.5 flex items-start gap-1.5 text-xs text-warning">
|
||||
<AlertTriangle className="mt-px h-3.5 w-3.5 flex-shrink-0" />
|
||||
{t("participants.limit_warning", { max: maxParticipants })}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CalendarInvitationBanner } from '../calendar-invitation-banner';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
import { jmapSetError } from '@/lib/jmap/jmap-error';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const pushMock = vi.fn();
|
||||
@@ -113,6 +114,7 @@ vi.mock('next-intl', () => ({
|
||||
maybe: 'Maybe',
|
||||
decline: 'Decline',
|
||||
select_calendar: 'Select calendar',
|
||||
participant_limit: 'The server allows at most {max} participants per event.',
|
||||
};
|
||||
|
||||
let message = strings[key] ?? key;
|
||||
@@ -748,4 +750,86 @@ describe('CalendarInvitationBanner', () => {
|
||||
|
||||
expect(screen.getByText('Proposed changes applied.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Stalwart 1.0 refuses an event with more participants than
|
||||
// maxParticipantsPerEvent; the banner says so instead of "could not
|
||||
// complete".
|
||||
describe('when Stalwart 1.0 refuses the participant count', () => {
|
||||
const LIMIT = jmapSetError('An event cannot have more than 20 participants.', {
|
||||
type: 'invalidProperties', properties: ['participants'],
|
||||
});
|
||||
const invitation = {
|
||||
uid: 'uid-9',
|
||||
title: 'All hands',
|
||||
start: '2026-03-28T10:00:00Z',
|
||||
participants: {
|
||||
attendee: {
|
||||
'@type': 'Participant', email: 'user@example.com', name: 'User',
|
||||
roles: { attendee: true }, participationStatus: 'needs-action',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('says so when adding the invitation to the calendar', async () => {
|
||||
mocks.calendarState.importEvents.mockRejectedValue(LIMIT);
|
||||
mocks.clientMock.parseCalendarEvents.mockResolvedValue([invitation]);
|
||||
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-9' })} />);
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'All hands' })).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByText('Add to calendar'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('The server allows at most 20 participants per event.')).toBeInTheDocument();
|
||||
});
|
||||
expect(mocks.calendarState.importEvents).toHaveBeenCalledWith(
|
||||
expect.anything(), [expect.objectContaining({ uid: 'uid-9' })], 'cal-1', { throwIfNoneImported: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('says so when answering an invitation that is not in the calendar yet', async () => {
|
||||
mocks.calendarState.importEvents.mockRejectedValue(LIMIT);
|
||||
mocks.clientMock.parseCalendarEvents.mockResolvedValue([invitation]);
|
||||
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-10' })} />);
|
||||
await waitFor(() => expect(screen.getByText('Accept')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByText('Accept'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('The server allows at most 20 participants per event.')).toBeInTheDocument();
|
||||
});
|
||||
// Asked to reject: it used to resolve to 0 and say nothing more.
|
||||
expect(mocks.calendarState.importEvents).toHaveBeenCalledWith(
|
||||
expect.anything(), [expect.objectContaining({ uid: 'uid-9' })], 'cal-1', { throwIfNoneImported: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('says so when applying a proposal', async () => {
|
||||
mocks.calendarState.updateEvent = vi.fn().mockRejectedValue(LIMIT);
|
||||
mocks.calendarState.events = [{
|
||||
id: 'event-11', uid: 'uid-11', title: 'Sync', start: '2026-03-27T09:00:00Z', duration: 'PT1H',
|
||||
participants: {
|
||||
organizer: { '@type': 'Participant', email: 'user@example.com', roles: { owner: true }, participationStatus: 'accepted' },
|
||||
attendee: { '@type': 'Participant', email: 'alice@example.com', roles: { attendee: true }, participationStatus: 'tentative' },
|
||||
},
|
||||
}];
|
||||
mocks.clientMock.parseCalendarEvents.mockResolvedValue([{
|
||||
uid: 'uid-11', title: 'Sync', start: '2026-03-27T10:00:00Z', duration: 'PT1H',
|
||||
participants: {
|
||||
organizer: { '@type': 'Participant', email: 'user@example.com', roles: { owner: true }, participationStatus: 'accepted' },
|
||||
attendee: { '@type': 'Participant', email: 'alice@example.com', roles: { attendee: true }, participationStatus: 'tentative' },
|
||||
},
|
||||
}]);
|
||||
render(<CalendarInvitationBanner email={makeEmail({
|
||||
id: 'email-11',
|
||||
attachments: [{ partId: '1', blobId: 'blob-11', size: 256, type: 'text/calendar; method=COUNTER', name: 'counter.ics' }],
|
||||
})} />);
|
||||
await waitFor(() => expect(screen.getByText('Apply proposed changes')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByText('Apply proposed changes'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('The server allows at most 20 participants per event.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -627,7 +627,9 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
} else if (eventForRsvp) {
|
||||
setActionError(t('action_failed'));
|
||||
} else if (calId) {
|
||||
const imported = await importEvents(client, [parsedEvent], calId);
|
||||
// A refusal (Stalwart 1.0's participant limit, say) lands in the
|
||||
// catch below and is said as it is.
|
||||
const imported = await importEvents(client, [parsedEvent], calId, { throwIfNoneImported: true });
|
||||
if (imported > 0) {
|
||||
const newEvent = useCalendarStore.getState().events.find(
|
||||
(e) => e.uid === parsedEvent.uid
|
||||
@@ -683,15 +685,15 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
setActionError(null);
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const count = await importEvents(client, [parsedEvent], calId);
|
||||
const count = await importEvents(client, [parsedEvent], calId, { throwIfNoneImported: true });
|
||||
if (count > 0) {
|
||||
setActionNotice(t('added'));
|
||||
setState('parsed');
|
||||
} else {
|
||||
setActionError(t('action_failed'));
|
||||
}
|
||||
} catch {
|
||||
setActionError(t('action_failed'));
|
||||
} catch (err) {
|
||||
setActionError(describeError(err, t('action_failed')));
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
@@ -746,8 +748,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
setActionNotice(t('proposal_applied'));
|
||||
setState('parsed');
|
||||
setRsvpStatus(null);
|
||||
} catch {
|
||||
setActionError(t('action_failed'));
|
||||
} catch (err) {
|
||||
setActionError(describeError(err, t('action_failed')));
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user