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:
@@ -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,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
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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(" ");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user