559 delegation create event in another user calendar (#561)

* [#559] added delegated calendars in user selector
* [#559] selector for calendar display delegated and personal separatly

Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
Camille Moussu
2026-02-20 23:14:48 +01:00
committed by GitHub
parent 2e70928645
commit f40165d2e2
9 changed files with 107 additions and 52 deletions
+11 -15
View File
@@ -1,6 +1,5 @@
import { buildDelegatedEventURL } from "@/features/Events/eventUtils";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { CalendarEvent } from "@/features/Events/EventsTypes";
import { buildDelegatedEventURL } from "@/features/Events/eventUtils";
const makeCalendar = (link: string): Calendar =>
({
@@ -10,45 +9,42 @@ const makeCalendar = (link: string): Calendar =>
owner: { emails: ["owner@example.com"] },
}) as Calendar;
const makeEvent = (url: string): CalendarEvent =>
({ URL: url, calId: "user2/cal1" }) as CalendarEvent;
describe("buildDelegatedEventURL", () => {
it("rebases event filename onto calendar link base path", () => {
const calendar = makeCalendar("/calendars/user2/cal1.json");
const event = makeEvent("/calendars/someother/path/event-abc.ics");
expect(buildDelegatedEventURL(calendar, event)).toBe(
const eventURL = "/calendars/someother/path/event-abc.ics";
expect(buildDelegatedEventURL(calendar, eventURL)).toBe(
"/calendars/user2/cal1/event-abc.ics"
);
});
it("strips .json suffix from calendar link", () => {
const calendar = makeCalendar("/calendars/user2/cal1.json");
const event = makeEvent("/calendars/user2/cal1/event-abc.ics");
const result = buildDelegatedEventURL(calendar, event);
const eventURL = "/calendars/user2/cal1/event-abc.ics";
const result = buildDelegatedEventURL(calendar, eventURL);
expect(result).not.toContain(".json");
});
it("preserves the exact filename from the event URL", () => {
const calendar = makeCalendar("/calendars/user2/cal1.json");
const event = makeEvent("/calendars/user2/cal1/some-uid-with-dashes.ics");
expect(buildDelegatedEventURL(calendar, event)).toBe(
const eventURL = "/calendars/user2/cal1/some-uid-with-dashes.ics";
expect(buildDelegatedEventURL(calendar, eventURL)).toBe(
"/calendars/user2/cal1/some-uid-with-dashes.ics"
);
});
it("throws when event URL has no filename", () => {
const calendar = makeCalendar("/calendars/user2/cal1.json");
const event = makeEvent("");
expect(() => buildDelegatedEventURL(calendar, event)).toThrow(
const eventURL = "";
expect(() => buildDelegatedEventURL(calendar, eventURL)).toThrow(
/Cannot extract filename from event URL/
);
});
it("works with nested calendar link paths", () => {
const calendar = makeCalendar("/dav/calendars/users/user2/cal1.json");
const event = makeEvent("/other/path/event-xyz.ics");
expect(buildDelegatedEventURL(calendar, event)).toBe(
const eventURL = "/other/path/event-xyz.ics";
expect(buildDelegatedEventURL(calendar, eventURL)).toBe(
"/dav/calendars/users/user2/cal1/event-xyz.ics"
);
});
+11 -1
View File
@@ -11,6 +11,7 @@ import {
Box,
Button,
Checkbox,
Divider,
FormControl,
FormControlLabel,
IconButton,
@@ -183,6 +184,12 @@ export default function EventFormFields({
// Track if user has clicked on calendar section in normal mode
const [hasClickedCalendarSection, setHasClickedCalendarSection] =
React.useState(false);
const delegatedCalendars = userPersonalCalendars.filter(
(cal) => cal.delegated
);
const personalCalendars = userPersonalCalendars.filter(
(cal) => !cal.delegated
);
// Reset hasEndDateChanged and hasClickedDateTimeSection when modal closes
React.useEffect(() => {
@@ -790,7 +797,10 @@ export default function EventFormFields({
handleCalendarChange(e.target.value)
}
>
{CalendarItemList(userPersonalCalendars)}
{CalendarItemList(personalCalendars)}
{delegatedCalendars.length > 0 &&
personalCalendars.length > 0 && <Divider component={"li"} />}
{CalendarItemList(delegatedCalendars)}
</Select>
</FormControl>
)}
@@ -55,8 +55,7 @@ export const getTempCalendarsListAsync = createAsyncThunk<
id,
name,
link,
owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${ownerData.lastname}`,
ownerEmails: ownerData.emails,
owner: ownerData,
description,
delegated,
color: {
@@ -65,7 +65,7 @@ export const refreshCalendarWithSyncToken = createAsyncThunk<
calId: calendar.id,
deletedEvents: toDelete.map((eventURL) =>
calendar.delegated
? buildDelegatedEventURL(calendar, { URL: eventURL })
? buildDelegatedEventURL(calendar, eventURL)
: eventURL
),
createdOrUpdatedEvents: createdOrUpdatedEvents
+21 -19
View File
@@ -1,5 +1,4 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { RootState } from "@/app/store";
import { ResponsiveDialog } from "@/components/Dialog";
import EventFormFields from "@/components/Event/EventFormFields";
import { addDays } from "@/components/Event/utils/dateRules";
@@ -27,7 +26,6 @@ import { addVideoConferenceToDescription } from "@/utils/videoConferenceUtils";
import { CalendarApi, DateSelectArg } from "@fullcalendar/core";
import { Box, Button } from "@linagora/twake-mui";
import AddIcon from "@mui/icons-material/Add";
import { createSelector } from "@reduxjs/toolkit";
import React, {
startTransition,
useCallback,
@@ -42,6 +40,8 @@ import { putEventAsync } from "../Calendars/services";
import { AsyncThunkResult } from "../Calendars/types/AsyncThunkResult";
import { userAttendee } from "../User/models/attendee";
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
import { buildDelegatedEventURL } from "./eventUtils";
import { useEventOrganizer } from "./useEventOrganizer";
function EventPopover({
open,
@@ -62,24 +62,13 @@ function EventPopover({
const dispatch = useAppDispatch();
const { t } = useI18n();
const organizer = useAppSelector((state) => state.user.organiserData);
const userOrganizer = useAppSelector((state) => state.user.organiserData);
const userId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const calList = useAppSelector((state) => state.calendars.list);
const selectPersonalCalendars = createSelector(
(state: RootState) => state.calendars,
(calendars: RootState["calendars"]) =>
Object.keys(calendars.list || {})
.map((id) => {
if (id.split("/")[0] === userId) {
return calendars.list?.[id];
}
return {} as Calendar;
})
.filter((calendar) => calendar.id)
);
const userPersonalCalendars: Calendar[] = useAppSelector(
selectPersonalCalendars
const userPersonalCalendars: Calendar[] = Object.values(calList || {}).filter(
(cal) =>
cal.id?.split("/")[0] === userId || (cal.delegated && cal.access?.write)
);
const timezoneList = useMemo(() => {
@@ -121,6 +110,16 @@ function EventPopover({
const [repetition, setRepetition] = useState<RepetitionObject>(
event?.repetition ?? ({} as RepetitionObject)
);
// Derive the effective organizer based on the selected calendar.
// When a delegated calendar is selected, the organizer must be the
// calendar owner, not the logged-in user.
const { organizer } = useEventOrganizer({
calendarid,
calList,
userOrganizer,
});
const [attendees, setAttendees] = useState<userAttendee[]>(
event?.attendee
? event.attendee.filter((a) => a.cal_address !== organizer?.cal_address)
@@ -728,11 +727,13 @@ function EventPopover({
console.error("No target calendar available to save event");
return;
}
const newEventURL = `/calendars/${targetCalendar.id}/${newEventUID}.ics`;
const newEvent: CalendarEvent = {
calId: targetCalendar.id,
title,
URL: `/calendars/${targetCalendar.id}/${newEventUID}.ics`,
URL: targetCalendar.delegated
? buildDelegatedEventURL(targetCalendar, newEventURL)
: newEventURL,
start: "",
allday,
uid: newEventUID,
@@ -865,6 +866,7 @@ function EventPopover({
);
}
};
const dialogActions = (
<Box display="flex" justifyContent="space-between" width="100%" px={2}>
{!showMore && (
+10 -7
View File
@@ -76,14 +76,17 @@ function EventUpdateModal({
const user = useAppSelector((state) => state.user);
const calendarsList = useAppSelector((state) => state.calendars.list);
// if the event's calendar is delegated then it shall be the only calendar accessible from the event update modal
const userPersonalCalendars: Calendar[] = useMemo(() => {
const allCalendars = Object.values(calendarsList) as Calendar[];
const allCalendars = Object.values(calList) as Calendar[];
if (calList[calId]?.delegated) {
return [calList[calId]];
}
return allCalendars.filter(
(c: Calendar) => c.id?.split("/")[0] === user.userData?.openpaasId
(calendar: Calendar) =>
calendar.id?.split("/")[0] === user.userData?.openpaasId
);
}, [calendarsList, user.userData?.openpaasId]);
}, [calList, calId, user.userData?.openpaasId]);
const timezoneList = useMemo(() => {
const zones = Object.keys(TIMEZONES.zones).sort();
@@ -275,7 +278,7 @@ function EventUpdateModal({
// Handle repetition properly - check both current event and base event
const baseEventId = extractEventBaseUuid(eventToDisplay.uid);
const baseEvent = calendarsList[calId]?.events[baseEventId];
const baseEvent = calList[calId]?.events[baseEventId];
const repetitionSource =
eventToDisplay.repetition || baseEvent?.repetition;
@@ -343,7 +346,7 @@ function EventUpdateModal({
event,
calId,
userPersonalCalendars,
calendarsList,
calList,
masterEvent,
isLoadingMasterEvent,
]);
+4 -7
View File
@@ -183,10 +183,7 @@ export function parseCalendarEvent(
}
event.calId = calendar.id;
event.URL = calendar.delegated
? buildDelegatedEventURL(calendar, {
...event,
URL: eventURL,
} as CalendarEvent)
? buildDelegatedEventURL(calendar, eventURL)
: eventURL;
if (!event.uid || !event.start) {
console.error(
@@ -565,12 +562,12 @@ export function detectRecurringEventChanges(
export function buildDelegatedEventURL(
calendar: Calendar,
event: CalendarEvent
eventURL: string
): string {
const calendarBasePath = calendar.link.replace(/\.json$/, "");
const eventFilename = event.URL.split("/").pop();
const eventFilename = eventURL.split("/").pop();
if (!eventFilename) {
throw new Error(`Cannot extract filename from event URL: ${event.URL}`);
throw new Error(`Cannot extract filename from event URL: ${eventURL}`);
}
return `${calendarBasePath}/${eventFilename}`;
}
+34
View File
@@ -0,0 +1,34 @@
import { makeDisplayName } from "@/utils/makeDisplayName";
import { useMemo } from "react";
import { Calendar } from "../Calendars/CalendarTypes";
// Update event organizer accordingly to selected calendar's delegated status
export function useEventOrganizer({
calendarid,
calList,
userOrganizer,
}: {
calendarid: string;
calList: Record<string, Calendar>;
userOrganizer?: { cn: string; cal_address: string };
}) {
const selectedCalendar = useMemo(
() => calList?.[calendarid],
[calList, calendarid]
);
const organizer = useMemo(() => {
if (selectedCalendar?.delegated && selectedCalendar?.owner) {
return {
cn:
makeDisplayName(selectedCalendar) ??
selectedCalendar.owner.emails?.[0] ??
"",
cal_address: selectedCalendar.owner.emails?.[0] ?? "",
};
}
return userOrganizer;
}, [selectedCalendar, userOrganizer]);
return { organizer, selectedCalendar };
}
+14
View File
@@ -0,0 +1,14 @@
import { Calendar } from "../features/Calendars/CalendarTypes";
export function makeDisplayName(
selectedCalendar: Calendar
): string | undefined {
if (
!selectedCalendar ||
(!selectedCalendar.owner?.lastname && !selectedCalendar.owner?.firstname)
)
return;
return [selectedCalendar.owner?.firstname, selectedCalendar.owner?.lastname]
.filter(Boolean)
.join(" ");
}