diff --git a/__test__/utils/buildDelegatedEvent.test.ts b/__test__/utils/buildDelegatedEvent.test.ts index 6bca6b4..f1249f7 100644 --- a/__test__/utils/buildDelegatedEvent.test.ts +++ b/__test__/utils/buildDelegatedEvent.test.ts @@ -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" ); }); diff --git a/src/components/Event/EventFormFields.tsx b/src/components/Event/EventFormFields.tsx index cc6bb46..e60b010 100644 --- a/src/components/Event/EventFormFields.tsx +++ b/src/components/Event/EventFormFields.tsx @@ -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 && } + {CalendarItemList(delegatedCalendars)} )} diff --git a/src/features/Calendars/services/getTempCalendarsListAsync.ts b/src/features/Calendars/services/getTempCalendarsListAsync.ts index 8b9dc3b..8e4fdc7 100644 --- a/src/features/Calendars/services/getTempCalendarsListAsync.ts +++ b/src/features/Calendars/services/getTempCalendarsListAsync.ts @@ -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: { diff --git a/src/features/Calendars/services/refreshCalendar.ts b/src/features/Calendars/services/refreshCalendar.ts index 65766d1..a1f06b6 100644 --- a/src/features/Calendars/services/refreshCalendar.ts +++ b/src/features/Calendars/services/refreshCalendar.ts @@ -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 diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx index 48def8a..8b8c46c 100644 --- a/src/features/Events/EventModal.tsx +++ b/src/features/Events/EventModal.tsx @@ -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( 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( 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 = ( {!showMore && ( diff --git a/src/features/Events/EventUpdateModal.tsx b/src/features/Events/EventUpdateModal.tsx index 0ba0a05..1ea5cca 100644 --- a/src/features/Events/EventUpdateModal.tsx +++ b/src/features/Events/EventUpdateModal.tsx @@ -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, ]); diff --git a/src/features/Events/eventUtils.ts b/src/features/Events/eventUtils.ts index c302e83..e719b20 100644 --- a/src/features/Events/eventUtils.ts +++ b/src/features/Events/eventUtils.ts @@ -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}`; } diff --git a/src/features/Events/useEventOrganizer.ts b/src/features/Events/useEventOrganizer.ts new file mode 100644 index 0000000..59f07fe --- /dev/null +++ b/src/features/Events/useEventOrganizer.ts @@ -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; + 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 }; +} diff --git a/src/utils/makeDisplayName.tsx b/src/utils/makeDisplayName.tsx new file mode 100644 index 0000000..8db7eba --- /dev/null +++ b/src/utils/makeDisplayName.tsx @@ -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(" "); +}