mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
fix: keep the meeting link when duplicating an event #1170
This commit is contained in:
@@ -50,7 +50,7 @@ import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { useIsFocusedProTab, useIsPaneScoped } from "@/hooks/use-pane-context";
|
||||
import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||
import { buildDuplicateEventData } from "@/lib/calendar-duplicate";
|
||||
import { filterTasksByCalendars } from "@/lib/calendar-tasks";
|
||||
import {
|
||||
baseEventStoreId,
|
||||
@@ -1171,24 +1171,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
|
||||
const handleDuplicateFromDetail = useCallback(async () => {
|
||||
if (!detailEvent || !client) return;
|
||||
const start = parseISO(detailEvent.start);
|
||||
const newStart = addDays(start, 1);
|
||||
const data = sanitizeOutgoingCalendarEventData<Partial<CalendarEvent>>({
|
||||
title: detailEvent.title,
|
||||
description: detailEvent.description,
|
||||
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
duration: detailEvent.duration,
|
||||
timeZone: detailEvent.timeZone,
|
||||
showWithoutTime: detailEvent.showWithoutTime,
|
||||
calendarIds: { ...detailEvent.calendarIds },
|
||||
status: "confirmed",
|
||||
freeBusyStatus: detailEvent.freeBusyStatus,
|
||||
privacy: detailEvent.privacy,
|
||||
});
|
||||
if (detailEvent.locations) data.locations = structuredClone(detailEvent.locations);
|
||||
if (detailEvent.recurrenceRules) data.recurrenceRules = structuredClone(detailEvent.recurrenceRules);
|
||||
if (detailEvent.alerts) data.alerts = structuredClone(detailEvent.alerts);
|
||||
if (detailEvent.participants) data.participants = structuredClone(detailEvent.participants);
|
||||
const data = buildDuplicateEventData(detailEvent);
|
||||
closeDetail();
|
||||
try {
|
||||
const created = await createEvent(client, data);
|
||||
@@ -1219,24 +1202,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
|
||||
const handleDuplicateContextMenu = useCallback(async (event: CalendarEvent) => {
|
||||
if (!client) { toast.error(t("notifications.event_error")); return; }
|
||||
const start = parseISO(event.start);
|
||||
const newStart = addDays(start, 1);
|
||||
const data = sanitizeOutgoingCalendarEventData<Partial<CalendarEvent>>({
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
duration: event.duration,
|
||||
timeZone: event.timeZone,
|
||||
showWithoutTime: event.showWithoutTime,
|
||||
calendarIds: { ...event.calendarIds },
|
||||
status: "confirmed",
|
||||
freeBusyStatus: event.freeBusyStatus,
|
||||
privacy: event.privacy,
|
||||
});
|
||||
if (event.locations) data.locations = structuredClone(event.locations);
|
||||
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
|
||||
if (event.alerts) data.alerts = structuredClone(event.alerts);
|
||||
if (event.participants) data.participants = structuredClone(event.participants);
|
||||
const data = buildDuplicateEventData(event);
|
||||
try {
|
||||
const created = await createEvent(client, data);
|
||||
if (created) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LinkifiedText } from "@/components/ui/linkified-text";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "@/components/icons";
|
||||
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
|
||||
import { format, parseISO, addHours, isSameDay } from "date-fns";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
|
||||
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
@@ -28,6 +28,7 @@ import { canUserRsvp, getEventEditability, canCreateEventsIn } from "@/lib/calen
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { buildDuplicateEventData } from "@/lib/calendar-duplicate";
|
||||
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||
import { useIsPaneScoped } from "@/hooks/use-pane-context";
|
||||
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
|
||||
@@ -657,28 +658,7 @@ export function EventModal({
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!event || !onDuplicate) return;
|
||||
const start = getEventStartDate(event);
|
||||
const newStart = addDays(start, 1);
|
||||
const newUid = generateUUID();
|
||||
const data: Partial<CalendarEvent> = {
|
||||
uid: newUid,
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
start: event.showWithoutTime ? format(newStart, "yyyy-MM-dd") : format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
duration: event.duration,
|
||||
timeZone: event.timeZone,
|
||||
showWithoutTime: event.showWithoutTime,
|
||||
calendarIds: { ...event.calendarIds },
|
||||
status: "confirmed",
|
||||
freeBusyStatus: event.freeBusyStatus,
|
||||
privacy: event.privacy,
|
||||
};
|
||||
if (event.locations) data.locations = structuredClone(event.locations);
|
||||
if (event.virtualLocations) data.virtualLocations = structuredClone(event.virtualLocations);
|
||||
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
|
||||
if (event.alerts) data.alerts = structuredClone(event.alerts);
|
||||
if (event.participants) data.participants = structuredClone(event.participants);
|
||||
onDuplicate(data);
|
||||
onDuplicate(buildDuplicateEventData(event));
|
||||
}, [event, onDuplicate]);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
import { buildDuplicateEventData } from '../calendar-duplicate';
|
||||
|
||||
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
|
||||
return {
|
||||
id: 'ev1',
|
||||
uid: 'original-uid',
|
||||
title: 'Standup',
|
||||
description: 'Daily sync',
|
||||
start: '2026-10-05T09:30:00',
|
||||
duration: 'PT30M',
|
||||
timeZone: 'Europe/Berlin',
|
||||
showWithoutTime: false,
|
||||
calendarIds: { cal1: true },
|
||||
status: 'tentative',
|
||||
...overrides,
|
||||
} as CalendarEvent;
|
||||
}
|
||||
|
||||
describe('buildDuplicateEventData', () => {
|
||||
it('keeps the meeting link (#1170)', () => {
|
||||
const virtualLocations = {
|
||||
v1: { '@type': 'VirtualLocation', name: 'Video call', uri: 'https://meet.example.org/abc' },
|
||||
} as unknown as CalendarEvent['virtualLocations'];
|
||||
const data = buildDuplicateEventData(makeEvent({ virtualLocations }));
|
||||
expect(data.virtualLocations).toEqual(virtualLocations);
|
||||
expect(data.virtualLocations).not.toBe(virtualLocations);
|
||||
});
|
||||
|
||||
it('moves a timed event one day later in its own time zone with a fresh uid', () => {
|
||||
const data = buildDuplicateEventData(makeEvent());
|
||||
expect(data).toMatchObject({
|
||||
title: 'Standup',
|
||||
start: '2026-10-06T09:30:00',
|
||||
duration: 'PT30M',
|
||||
timeZone: 'Europe/Berlin',
|
||||
calendarIds: { cal1: true },
|
||||
status: 'confirmed',
|
||||
});
|
||||
expect(data.uid).toBeTruthy();
|
||||
expect(data.uid).not.toBe('original-uid');
|
||||
expect(data.id).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sends all-day copies as floating midnight starts', () => {
|
||||
const data = buildDuplicateEventData(makeEvent({
|
||||
start: '2026-10-05T00:00:00',
|
||||
duration: 'P1D',
|
||||
showWithoutTime: true,
|
||||
}));
|
||||
expect(data).toMatchObject({ start: '2026-10-06T00:00:00', showWithoutTime: true, timeZone: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { addDays, format, parseISO } from "date-fns";
|
||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Create payload for a copy of `event` one day later. Shared by the editor,
|
||||
* the detail popover and the context menu - each used to build its own copy
|
||||
* and two of them dropped the meeting link (`virtualLocations`, #1170).
|
||||
* The start stays in the event's own wall clock and time zone.
|
||||
*/
|
||||
export function buildDuplicateEventData(event: CalendarEvent): Partial<CalendarEvent> {
|
||||
const newStart = addDays(parseISO(event.start), 1);
|
||||
const data: Partial<CalendarEvent> = {
|
||||
uid: generateUUID(),
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
duration: event.duration,
|
||||
timeZone: event.timeZone,
|
||||
showWithoutTime: event.showWithoutTime,
|
||||
calendarIds: { ...event.calendarIds },
|
||||
status: "confirmed",
|
||||
freeBusyStatus: event.freeBusyStatus,
|
||||
privacy: event.privacy,
|
||||
};
|
||||
if (event.locations) data.locations = structuredClone(event.locations);
|
||||
if (event.virtualLocations) data.virtualLocations = structuredClone(event.virtualLocations);
|
||||
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
|
||||
if (event.alerts) data.alerts = structuredClone(event.alerts);
|
||||
if (event.participants) data.participants = structuredClone(event.participants);
|
||||
return sanitizeOutgoingCalendarEventData(data);
|
||||
}
|
||||
Reference in New Issue
Block a user