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