mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 08:12:43 +00:00
fix: stop the mail list hiding dividers between some rows
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { render, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { CalendarMonthView } from '../calendar-month-view';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import type { CalendarEvent, Calendar } from '@/lib/jmap/types';
|
||||
|
||||
// #1119: dropping an event on another day of the month grid moves it there
|
||||
// at the same time of day. An all-day event keeps a full LocalDateTime
|
||||
// start - Stalwart drops a date-only one and leaves the event without a
|
||||
// start, so it vanished from the calendar.
|
||||
|
||||
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
|
||||
|
||||
function makeEvent(id: string, overrides: Partial<CalendarEvent>): CalendarEvent {
|
||||
return {
|
||||
id,
|
||||
'@type': 'Event',
|
||||
uid: id,
|
||||
title: 'Event ' + id,
|
||||
calendarIds: { 'cal-1': true },
|
||||
...overrides,
|
||||
} as unknown as CalendarEvent;
|
||||
}
|
||||
|
||||
function renderView(events: CalendarEvent[]) {
|
||||
return render(
|
||||
<CalendarMonthView
|
||||
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
|
||||
windowKey="month:2026-09-09"
|
||||
// Monday 28 Sep to Sunday 4 Oct.
|
||||
rangeStart={new Date(2026, 8, 28)}
|
||||
rangeEnd={new Date(2026, 9, 4)}
|
||||
selectedDate={new Date(2026, 8, 30)}
|
||||
events={events}
|
||||
calendars={calendars}
|
||||
onSelectDate={vi.fn()}
|
||||
onSelectEvent={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function dropOn(dayIndex: number, event: CalendarEvent) {
|
||||
const cell = document.querySelectorAll<HTMLElement>('[data-week="2026-09-28"] [role="gridcell"]')[dayIndex];
|
||||
const json = JSON.stringify({ type: 'calendar-event', eventId: event.id, originalStart: event.start });
|
||||
fireEvent.drop(cell, {
|
||||
dataTransfer: { types: ['application/x-calendar-event'], getData: () => json },
|
||||
});
|
||||
}
|
||||
|
||||
describe('CalendarMonthView drop', () => {
|
||||
const updateEvent = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
updateEvent.mockClear();
|
||||
useAuthStore.setState({ client: {} as never });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
useCalendarStore.setState({ events: [] });
|
||||
});
|
||||
|
||||
it('moves an all-day event with a full start, not a bare date', () => {
|
||||
const event = makeEvent('a', { start: '2026-10-02T00:00:00', duration: 'P1D', showWithoutTime: true });
|
||||
useCalendarStore.setState({ events: [event], updateEvent });
|
||||
renderView([event]);
|
||||
|
||||
dropOn(3, event); // Thursday 1 Oct
|
||||
|
||||
expect(updateEvent).toHaveBeenCalledWith(expect.anything(), 'a', { start: '2026-10-01T00:00:00' }, undefined);
|
||||
});
|
||||
|
||||
it('leaves an all-day event dropped on its own day alone', () => {
|
||||
const event = makeEvent('a', { start: '2026-10-02T00:00:00', duration: 'P1D', showWithoutTime: true });
|
||||
useCalendarStore.setState({ events: [event], updateEvent });
|
||||
renderView([event]);
|
||||
|
||||
dropOn(4, event); // Friday 2 Oct
|
||||
|
||||
expect(updateEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the time of day of a timed event', () => {
|
||||
const event = makeEvent('t', { start: '2026-10-02T17:30:00', duration: 'PT1H', timeZone: 'Europe/Berlin' });
|
||||
useCalendarStore.setState({ events: [event], updateEvent });
|
||||
renderView([event]);
|
||||
|
||||
dropOn(3, event);
|
||||
|
||||
expect(updateEvent).toHaveBeenCalledWith(expect.anything(), 't', { start: '2026-10-01T17:30:00' }, undefined);
|
||||
});
|
||||
});
|
||||
@@ -255,7 +255,9 @@ export function CalendarMonthView({
|
||||
const isAllDay = event?.showWithoutTime;
|
||||
const newStart = new Date(day);
|
||||
newStart.setHours(originalStart.getHours(), originalStart.getMinutes(), originalStart.getSeconds(), 0);
|
||||
const newStartISO = isAllDay ? format(newStart, "yyyy-MM-dd") : format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
|
||||
// `start` is a LocalDateTime for all-day events too: Stalwart drops a
|
||||
// date-only value and the event is left without a start (#1119).
|
||||
const newStartISO = format(newStart, isAllDay ? "yyyy-MM-dd'T'00:00:00" : "yyyy-MM-dd'T'HH:mm:ss");
|
||||
if (newStartISO === data.originalStart) return;
|
||||
const client = useAuthStore.getState().client;
|
||||
if (!client) return;
|
||||
|
||||
@@ -238,6 +238,11 @@ export function EmailList({
|
||||
estimateSize,
|
||||
overscan: 5,
|
||||
getItemKey,
|
||||
// Keep sub-pixel row heights. The default measurer rounds them, so a
|
||||
// row could start up to half a pixel inside the one above it and paint
|
||||
// over that row's divider (at 125% or 150% display scaling).
|
||||
measureElement: (element, entry) =>
|
||||
entry?.borderBoxSize?.[0]?.blockSize ?? element.getBoundingClientRect().height,
|
||||
});
|
||||
|
||||
const LoadingSkeleton = () => (
|
||||
@@ -586,10 +591,11 @@ export function EmailList({
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
// `top`, not translateY: layout snaps it to device
|
||||
// pixels, so the fractional offsets stay crisp.
|
||||
top: virtualItem.start,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<ThreadListItem
|
||||
|
||||
@@ -100,8 +100,9 @@ describe('updateEvent on an expanded occurrence', () => {
|
||||
|
||||
expect(client.updateCalendarEvent).toHaveBeenCalledTimes(1);
|
||||
// The occurrence has no override yet: its details (here the participants) go along.
|
||||
// The start was read in the occurrence's zone, so that zone goes along too.
|
||||
expect(client.updateCalendarEvent).toHaveBeenCalledWith(
|
||||
'maaaaab', { title: 'Renamed', start: '2026-09-08T11:00:00', participants: occurrence().participants },
|
||||
'maaaaab', { title: 'Renamed', start: '2026-09-08T11:00:00', timeZone: 'Europe/Berlin', participants: occurrence().participants },
|
||||
undefined, undefined, undefined,
|
||||
);
|
||||
const stored = useCalendarStore.getState().events[0];
|
||||
@@ -202,6 +203,61 @@ describe('updateEvent on an expanded occurrence', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a new start for an expanded instance (#1119)', () => {
|
||||
// Stalwart expands an event whose own start is UTC (every one-off event
|
||||
// in a Google Calendar export) in the request's zone, so the instance
|
||||
// reads 17:30 Europe/Berlin while the base event is 15:30 Etc/UTC.
|
||||
const imported = (overrides: Partial<CalendarEvent> = {}) => occurrence({
|
||||
id: 'eaaaaad', originalId: 'eaaaaad', baseEventId: 'd', recurrenceId: null, recurrenceIdTimeZone: null,
|
||||
recurrenceRules: null, recurrenceOverrides: null, participants: undefined,
|
||||
start: '2026-10-02T17:30:00', timeZone: 'Europe/Berlin', utcStart: '2026-10-02T15:30:00Z', utcEnd: '2026-10-02T16:30:00Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('writes it to the base event with the zone it was read in', async () => {
|
||||
const client = fakeClient();
|
||||
useCalendarStore.setState({ events: [imported()] });
|
||||
|
||||
await useCalendarStore.getState().updateEvent(client, 'eaaaaad', { start: '2026-10-01T17:30:00' });
|
||||
|
||||
expect(client.updateCalendarEvent).toHaveBeenCalledWith(
|
||||
'd', { start: '2026-10-01T17:30:00', timeZone: 'Europe/Berlin' }, undefined, undefined,
|
||||
);
|
||||
expect(useCalendarStore.getState().events[0].utcStart).toBe('2026-10-01T15:30:00.000Z');
|
||||
});
|
||||
|
||||
it('keeps a zone the caller chose', async () => {
|
||||
const client = fakeClient();
|
||||
useCalendarStore.setState({ events: [imported()] });
|
||||
|
||||
await useCalendarStore.getState().updateEvent(client, 'eaaaaad', { start: '2026-10-01T15:30:00', timeZone: 'Etc/UTC' });
|
||||
|
||||
expect(client.updateCalendarEvent).toHaveBeenCalledWith(
|
||||
'd', { start: '2026-10-01T15:30:00', timeZone: 'Etc/UTC' }, undefined, undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves an all-day start floating and changes without a start alone', async () => {
|
||||
const client = fakeClient();
|
||||
useCalendarStore.setState({ events: [imported({ start: '2026-10-02T00:00:00', duration: 'P1D', showWithoutTime: true })] });
|
||||
|
||||
await useCalendarStore.getState().updateEvent(client, 'eaaaaad', { start: '2026-10-01T00:00:00' });
|
||||
await useCalendarStore.getState().updateEvent(client, 'eaaaaad', { title: 'Renamed' });
|
||||
|
||||
expect(client.updateCalendarEvent).toHaveBeenNthCalledWith(1, 'd', { start: '2026-10-01T00:00:00' }, undefined, undefined);
|
||||
expect(client.updateCalendarEvent).toHaveBeenNthCalledWith(2, 'd', { title: 'Renamed' }, undefined, undefined);
|
||||
});
|
||||
|
||||
it('leaves events without synthetic ids alone', async () => {
|
||||
const client = fakeClient();
|
||||
useCalendarStore.setState({ events: [imported({ id: 'd', originalId: 'd', baseEventId: undefined, timeZone: 'Etc/UTC' })] });
|
||||
|
||||
await useCalendarStore.getState().updateEvent(client, 'd', { start: '2026-10-01T15:30:00' });
|
||||
|
||||
expect(client.updateCalendarEvent).toHaveBeenCalledWith('d', { start: '2026-10-01T15:30:00' }, undefined, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteEvent on an expanded occurrence', () => {
|
||||
it('destroys the occurrence through its synthetic id and drops it from the store', async () => {
|
||||
const client = fakeClient();
|
||||
|
||||
@@ -124,6 +124,24 @@ export function resolveMutationTarget(
|
||||
return { realId: id, isOccurrence: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* A server-expanded instance reports `start` in the zone it was expanded in -
|
||||
* the request's zone whenever the event's own start is UTC or floating, as in
|
||||
* a Google Calendar export - not in its base event's zone. Stalwart reads a
|
||||
* written `start` in the base event's zone, so a start worked out from the
|
||||
* instance has to carry the instance's zone along, or the event shifts by the
|
||||
* offset between the two (#1119). All-day starts stay floating.
|
||||
*/
|
||||
function withInstanceTimeZone(
|
||||
storeEvent: CalendarEvent | undefined,
|
||||
updates: Partial<CalendarEvent>,
|
||||
): Partial<CalendarEvent> {
|
||||
if (!storeEvent || !isServerRecurrenceInstance(storeEvent)) return updates;
|
||||
if (updates.start === undefined || updates.timeZone !== undefined) return updates;
|
||||
if (storeEvent.showWithoutTime || updates.showWithoutTime || !storeEvent.timeZone) return updates;
|
||||
return { ...updates, timeZone: storeEvent.timeZone };
|
||||
}
|
||||
|
||||
/** True when the store shows server-expanded occurrences of base event `baseId` on the given account. */
|
||||
function hasExpandedOccurrencesOf(events: CalendarEvent[], baseId: string, target: MutationTarget): boolean {
|
||||
return events.some(e =>
|
||||
@@ -881,7 +899,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
updateKeys: Object.keys(updates),
|
||||
});
|
||||
// Remap namespaced calendarIds back to original IDs
|
||||
const cleanUpdates = sanitizeOutgoingCalendarEventData({ ...updates });
|
||||
const cleanUpdates = sanitizeOutgoingCalendarEventData(withInstanceTimeZone(storeEvent, { ...updates }));
|
||||
if (cleanUpdates.calendarIds) {
|
||||
const remapped: Record<string, boolean> = {};
|
||||
for (const [calId, v] of Object.entries(cleanUpdates.calendarIds)) {
|
||||
|
||||
Reference in New Issue
Block a user