[#625] added freebusy handling in event preview

This commit is contained in:
Camille Moussu
2026-03-12 09:26:58 +01:00
committed by Benoit TELLIER
parent f41c3cbd8d
commit 5269c43354
10 changed files with 137 additions and 61 deletions
@@ -650,7 +650,7 @@ describe("Event Preview Display", () => {
fireEvent.click(emailButton); fireEvent.click(emailButton);
const expectedUrl = `test/mailto/?uri=mailto:john@test.com&subject=Test%20Event`; const expectedUrl = `test/mailto/?uri=mailto%3Ajohn%40test.com&subject=Test%20Event`;
expect(mockOpen).toHaveBeenCalledWith(expectedUrl); expect(mockOpen).toHaveBeenCalledWith(expectedUrl);
}); });
@@ -721,7 +721,7 @@ describe("Event Preview Display", () => {
fireEvent.click(emailButton); fireEvent.click(emailButton);
const expectedUrl = `test/mailto/?uri=mailto:john@test.com&subject=Meeting%20%26%20Discussion%3F%20%23Important`; const expectedUrl = `test/mailto/?uri=mailto%3Ajohn%40test.com&subject=Meeting%20%26%20Discussion%3F%20%23Important`;
expect(mockOpen).toHaveBeenCalledWith(expectedUrl); expect(mockOpen).toHaveBeenCalledWith(expectedUrl);
}); });
+10 -2
View File
@@ -18,7 +18,8 @@ export function renderAttendeeBadge(
key: string, key: string,
t: (key: string) => string, t: (key: string) => string,
isFull?: boolean, isFull?: boolean,
isOrganizer?: boolean isOrganizer?: boolean,
caption?: string
) { ) {
const classIcon = const classIcon =
a.partstat === "ACCEPTED" ? ( a.partstat === "ACCEPTED" ? (
@@ -65,12 +66,19 @@ export function renderAttendeeBadge(
<Avatar {...stringAvatar(a.cn || a.cal_address)} /> <Avatar {...stringAvatar(a.cn || a.cal_address)} />
</Badge> </Badge>
<Box style={{ display: "flex", flexDirection: "column", minWidth: 0 }}> <Box style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
<Typography noWrap>{a.cn || a.cal_address}</Typography> <Typography variant="body2" noWrap>
{a.cn || a.cal_address}
</Typography>
{isOrganizer && ( {isOrganizer && (
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{t("event.organizer")} {t("event.organizer")}
</Typography> </Typography>
)} )}
{caption && (
<Typography variant="caption" color="text.secondary">
{caption}
</Typography>
)}
</Box> </Box>
</Box> </Box>
); );
@@ -36,9 +36,9 @@ export function EventPreviewActionMenu({
<MenuItem <MenuItem
onClick={() => onClick={() =>
window.open( window.open(
`${mailSpaUrl}/mailto/?uri=mailto:${otherAttendees `${mailSpaUrl}/mailto/?uri=${encodeURIComponent(
.map((a) => a.cal_address) `mailto:${otherAttendees.map((a) => a.cal_address).join(",")}`
.join(",")}&subject=${encodeURIComponent(event.title ?? "")}` )}&subject=${encodeURIComponent(event.title ?? "")}`
) )
} }
> >
@@ -1,3 +1,4 @@
import { useAttendeesFreeBusy } from "@/components/Attendees/useFreeBusy";
import { renderAttendeeBadge } from "@/components/Event/utils/eventUtils"; import { renderAttendeeBadge } from "@/components/Event/utils/eventUtils";
import { AvatarGroup, Box, Typography } from "@linagora/twake-mui"; import { AvatarGroup, Box, Typography } from "@linagora/twake-mui";
import PeopleAltOutlinedIcon from "@mui/icons-material/PeopleAltOutlined"; import PeopleAltOutlinedIcon from "@mui/icons-material/PeopleAltOutlined";
@@ -11,6 +12,10 @@ interface EventPreviewAttendeesProps {
attendees: userAttendee[]; attendees: userAttendee[];
organizer: userAttendee | undefined; organizer: userAttendee | undefined;
allAttendees: userAttendee[]; allAttendees: userAttendee[];
start?: string;
end?: string;
timezone?: string;
eventUid?: string | null;
} }
const ATTENDEE_DISPLAY_LIMIT = 3; const ATTENDEE_DISPLAY_LIMIT = 3;
@@ -19,6 +24,10 @@ export function EventPreviewAttendees({
attendees, attendees,
organizer, organizer,
allAttendees, allAttendees,
start,
end,
timezone,
eventUid,
}: EventPreviewAttendeesProps) { }: EventPreviewAttendeesProps) {
const { t } = useI18n(); const { t } = useI18n();
const theme = useTheme(); const theme = useTheme();
@@ -29,6 +38,26 @@ export function EventPreviewAttendees({
const attendeePreview = makeAttendeePreview(allAttendees, t); const attendeePreview = makeAttendeePreview(allAttendees, t);
const toFreeBusyAttendee = (a: userAttendee) => ({
email: a.cal_address,
userId: null,
});
const freeBusyMap = useAttendeesFreeBusy({
existingAttendees: allAttendees.map(toFreeBusyAttendee),
newAttendees: [],
start: start ?? "",
end: end ?? "",
timezone: timezone ?? "UTC",
eventUid,
enabled: !!(start && end && showAllAttendees),
});
const busyCaption = (a: userAttendee) =>
freeBusyMap[a.cal_address] === "busy"
? t("event.freeBusy.busy")
: undefined;
return ( return (
<> <>
<Box display="flex" alignItems="flex-start"> <Box display="flex" alignItems="flex-start">
@@ -78,10 +107,24 @@ export function EventPreviewAttendees({
{showAllAttendees && {showAllAttendees &&
organizer && organizer &&
renderAttendeeBadge(organizer, "org", t, showAllAttendees, true)} renderAttendeeBadge(
organizer,
"org",
t,
showAllAttendees,
true,
busyCaption(organizer)
)}
{showAllAttendees && {showAllAttendees &&
attendees.map((a, idx) => attendees.map((a, idx) =>
renderAttendeeBadge(a, idx.toString(), t, showAllAttendees) renderAttendeeBadge(
a,
idx.toString(),
t,
showAllAttendees,
false,
busyCaption(a)
)
)} )}
</> </>
); );
@@ -1,17 +1,13 @@
import { InfoRow } from "@/components/Event/InfoRow"; import { InfoRow } from "@/components/Event/InfoRow";
import { renderAttendeeBadge } from "@/components/Event/utils/eventUtils"; import { Box, Button, Typography } from "@linagora/twake-mui";
import { AvatarGroup, Box, Button, Typography } from "@linagora/twake-mui";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline"; import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined"; import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone"; import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone";
import PeopleAltOutlinedIcon from "@mui/icons-material/PeopleAltOutlined";
import RepeatIcon from "@mui/icons-material/Repeat"; import RepeatIcon from "@mui/icons-material/Repeat";
import SubjectIcon from "@mui/icons-material/Subject"; import SubjectIcon from "@mui/icons-material/Subject";
import VideocamOutlinedIcon from "@mui/icons-material/VideocamOutlined"; import VideocamOutlinedIcon from "@mui/icons-material/VideocamOutlined";
import { alpha, useTheme } from "@mui/material/styles"; import { alpha, useTheme } from "@mui/material/styles";
import { useState } from "react";
import { useI18n } from "twake-i18n"; import { useI18n } from "twake-i18n";
import { makeAttendeePreview } from ".";
import { CalendarEvent } from "../EventsTypes"; import { CalendarEvent } from "../EventsTypes";
import { EventPreviewAttendees } from "./EventPreviewAttendees"; import { EventPreviewAttendees } from "./EventPreviewAttendees";
import { makeRecurrenceString } from "./utils/makeRecurrenceString"; import { makeRecurrenceString } from "./utils/makeRecurrenceString";
@@ -20,35 +16,26 @@ interface EventPreviewDetailsProps {
event: CalendarEvent; event: CalendarEvent;
isOwn: boolean; isOwn: boolean;
isNotPrivate: boolean; isNotPrivate: boolean;
userEmail: string;
} }
const ATTENDEE_DISPLAY_LIMIT = 3;
export function EventPreviewDetails({ export function EventPreviewDetails({
event, event,
isOwn, isOwn,
isNotPrivate, isNotPrivate,
userEmail,
}: EventPreviewDetailsProps) { }: EventPreviewDetailsProps) {
const { t } = useI18n(); const { t } = useI18n();
const theme = useTheme(); const theme = useTheme();
const infoIconColor = alpha(theme.palette.grey[900], 0.9); const infoIconColor = alpha(theme.palette.grey[900], 0.9);
const infoIconSx = { minWidth: "25px", marginRight: 2, color: infoIconColor }; const infoIconSx = { minWidth: "25px", marginRight: 2, color: infoIconColor };
const [showAllAttendees, setShowAllAttendees] = useState(false);
const attendees = const attendees =
event.attendee?.filter( event.attendee?.filter(
(a) => a.cal_address !== event.organizer?.cal_address (a) => a.cal_address !== event.organizer?.cal_address
) || []; ) || [];
const organizer = event.attendee?.find( const organizer = event.attendee?.find(
(a) => a.cal_address === event.organizer?.cal_address (a) => a.cal_address === event.organizer?.cal_address
); );
const showDetails = isNotPrivate || isOwn;
const attendeePreview = makeAttendeePreview(event.attendee, t);
const showDetails = (isNotPrivate && !isOwn) || isOwn;
if (!showDetails) { if (!showDetails) {
return ( return (
@@ -84,7 +71,13 @@ export function EventPreviewDetails({
<Button <Button
variant="contained" variant="contained"
size="medium" size="medium"
onClick={() => window.open(event.x_openpass_videoconference)} onClick={() =>
window.open(
event.x_openpass_videoconference,
"_blank",
"noopener,noreferrer"
)
}
sx={{ borderRadius: "4px" }} sx={{ borderRadius: "4px" }}
> >
{t("eventPreview.joinVideo")} {t("eventPreview.joinVideo")}
@@ -98,7 +91,11 @@ export function EventPreviewDetails({
<EventPreviewAttendees <EventPreviewAttendees
attendees={attendees} attendees={attendees}
organizer={organizer} organizer={organizer}
allAttendees={event.attendee} allAttendees={event.attendee ?? []}
start={event.start}
end={event.end}
timezone={event.timezone}
eventUid={event.uid}
/> />
)} )}
@@ -1,10 +1,10 @@
import { dlEvent } from "../EventApi";
import { CalendarEvent } from "../EventsTypes";
import { Box, IconButton } from "@linagora/twake-mui"; import { Box, IconButton } from "@linagora/twake-mui";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import EditIcon from "@mui/icons-material/Edit"; import EditIcon from "@mui/icons-material/Edit";
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined"; import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
import MoreVertIcon from "@mui/icons-material/MoreVert"; import MoreVertIcon from "@mui/icons-material/MoreVert";
import { dlEvent } from "../EventApi";
import { CalendarEvent } from "../EventsTypes";
interface EventPreviewHeaderProps { interface EventPreviewHeaderProps {
event: CalendarEvent; event: CalendarEvent;
@@ -13,7 +13,6 @@ interface EventPreviewHeaderProps {
isOwn: boolean; isOwn: boolean;
isWriteDelegated: boolean; isWriteDelegated: boolean;
isNotPrivate: boolean; isNotPrivate: boolean;
isRecurring: boolean;
onClose: () => void; onClose: () => void;
onEdit: () => void; onEdit: () => void;
onMoreClick: (e: React.MouseEvent<HTMLElement>) => void; onMoreClick: (e: React.MouseEvent<HTMLElement>) => void;
@@ -26,13 +25,12 @@ export function EventPreviewHeader({
isOwn, isOwn,
isWriteDelegated, isWriteDelegated,
isNotPrivate, isNotPrivate,
isRecurring,
onClose, onClose,
onEdit, onEdit,
onMoreClick, onMoreClick,
}: EventPreviewHeaderProps) { }: EventPreviewHeaderProps) {
const canEdit = isOrganizer && (isOwn || (isWriteDelegated && isNotPrivate)); const canEdit = isOrganizer && (isOwn || (isWriteDelegated && isNotPrivate));
const canSeeMore = (isNotPrivate && !isOwn) || isOwn; const canSeeMore = isNotPrivate || isOwn;
return ( return (
<Box <Box
@@ -1,9 +1,8 @@
import { useAppSelector } from "@/app/hooks";
import { CalendarName } from "@/components/Calendar/CalendarName"; import { CalendarName } from "@/components/Calendar/CalendarName";
import { formatEventChipTitle } from "@/components/Calendar/utils/calendarUtils"; import { formatEventChipTitle } from "@/components/Calendar/utils/calendarUtils";
import ResponsiveDialog from "@/components/Dialog/ResponsiveDialog"; import ResponsiveDialog from "@/components/Dialog/ResponsiveDialog";
import { EditModeDialog } from "@/components/Event/EditModeDialog"; import { EditModeDialog } from "@/components/Event/EditModeDialog";
import { browserDefaultTimeZone, getTimezoneOffset } from "@/utils/timezone"; import { getTimezoneOffset } from "@/utils/timezone";
import { DateSelectArg } from "@fullcalendar/core"; import { DateSelectArg } from "@fullcalendar/core";
import { Box, Chip, Tooltip, Typography } from "@linagora/twake-mui"; import { Box, Chip, Tooltip, Typography } from "@linagora/twake-mui";
import CircleIcon from "@mui/icons-material/Circle"; import CircleIcon from "@mui/icons-material/Circle";
@@ -12,13 +11,13 @@ import { useEffect } from "react";
import { useI18n } from "twake-i18n"; import { useI18n } from "twake-i18n";
import { AttendanceValidation } from "../AttendanceValidation/AttendanceValidation"; import { AttendanceValidation } from "../AttendanceValidation/AttendanceValidation";
import EventPopover from "../EventModal"; import EventPopover from "../EventModal";
import EventUpdateModal from "../EventUpdateModal";
import { EventPreviewActionMenu } from "../EventPreview/EventPreviewActionMenu"; import { EventPreviewActionMenu } from "../EventPreview/EventPreviewActionMenu";
import { EventPreviewDetails } from "../EventPreview/EventPreviewDetails"; import { EventPreviewDetails } from "../EventPreview/EventPreviewDetails";
import { EventPreviewHeader } from "../EventPreview/EventPreviewHeader"; import { EventPreviewHeader } from "../EventPreview/EventPreviewHeader";
import { useEventPreviewState } from "../EventPreview/useEventPreviewState"; import { useEventPreviewState } from "../EventPreview/useEventPreviewState";
import { formatEnd } from "./utils/formatEnd"; import EventUpdateModal from "../EventUpdateModal";
import { formatDate } from "./utils/formatDate"; import { formatDate } from "./utils/formatDate";
import { formatEnd } from "./utils/formatEnd";
export default function EventPreviewModal({ export default function EventPreviewModal({
eventId, eventId,
@@ -42,7 +41,6 @@ export default function EventPreviewModal({
timezone, timezone,
contextualizedEvent, contextualizedEvent,
attendanceUser, attendanceUser,
isRecurring,
isOwn, isOwn,
isWriteDelegated, isWriteDelegated,
isOrganizer, isOrganizer,
@@ -57,7 +55,6 @@ export default function EventPreviewModal({
setToggleActionMenu, setToggleActionMenu,
openEditModePopup, openEditModePopup,
setOpenEditModePopup, setOpenEditModePopup,
typeOfAction,
setTypeOfAction, setTypeOfAction,
afterChoiceFunc, afterChoiceFunc,
setAfterChoiceFunc, setAfterChoiceFunc,
@@ -67,11 +64,14 @@ export default function EventPreviewModal({
handleDuplicateClick, handleDuplicateClick,
} = useEventPreviewState(eventId, calId, tempEvent, open, onClose); } = useEventPreviewState(eventId, calId, tempEvent, open, onClose);
useEffect(() => { useEffect(
if (!event || !calendar) { () => {
onClose({}, "backdropClick"); if (open && (!event || !calendar)) {
} onClose({}, "backdropClick");
}, [event, calendar, onClose]); }
}, // eslint-disable-next-line react-hooks/exhaustive-deps
[open, event, calendar]
);
if (!user || !event || !calendar) return null; if (!user || !event || !calendar) return null;
@@ -94,7 +94,6 @@ export default function EventPreviewModal({
isOwn={isOwn} isOwn={isOwn}
isWriteDelegated={isWriteDelegated} isWriteDelegated={isWriteDelegated}
isNotPrivate={isNotPrivate} isNotPrivate={isNotPrivate}
isRecurring={isRecurring}
onClose={() => onClose({}, "backdropClick")} onClose={() => onClose({}, "backdropClick")}
onEdit={handleEditClick} onEdit={handleEditClick}
onMoreClick={(e) => setToggleActionMenu(e.currentTarget)} onMoreClick={(e) => setToggleActionMenu(e.currentTarget)}
@@ -164,7 +163,6 @@ export default function EventPreviewModal({
event={event} event={event}
isOwn={isOwn} isOwn={isOwn}
isNotPrivate={isNotPrivate} isNotPrivate={isNotPrivate}
userEmail={user.email}
/> />
{/* Calendar label */} {/* Calendar label */}
@@ -219,8 +217,8 @@ export default function EventPreviewModal({
{ {
start: new Date(event.start), start: new Date(event.start),
startStr: event.start, startStr: event.start,
end: new Date(event.end ?? ""), end: new Date(event.end ?? event.start),
endStr: event.end ?? "", endStr: event.end ?? event.start,
allDay: event.allday ?? false, allDay: event.allday ?? false,
} as DateSelectArg } as DateSelectArg
} }
@@ -138,7 +138,7 @@ export function useEventPreviewState(
const result = await dispatch( const result = await dispatch(
deleteEventAsync({ calId, eventId, eventURL: event.URL }) deleteEventAsync({ calId, eventId, eventURL: event.URL })
); );
assertThunkSuccess(result); await assertThunkSuccess(result);
} catch (error) { } catch (error) {
console.error("Failed to delete event:", error); console.error("Failed to delete event:", error);
} }
@@ -1,18 +1,27 @@
import { userAttendee } from "@/src/features/User/models/attendee"; import { userAttendee } from "@/features/User/models/attendee";
export function makeAttendeePreview( export function makeAttendeePreview(
attendees: userAttendee[], attendees: userAttendee[] | undefined,
t: (k: string, p?: string | object) => string t: (k: string, p?: string | object) => string
) { ) {
const attendeePreview = []; const attendeePreview = [];
const yesCount = attendees?.filter((a) => a.partstat === "ACCEPTED").length; const counts = (attendees ?? []).reduce(
const noCount = attendees?.filter((a) => a.partstat === "DECLINED").length; (acc, a) => {
const maybeCount = attendees?.filter( if (a.partstat === "ACCEPTED") acc.yes++;
(a) => a.partstat === "TENTATIVE" else if (a.partstat === "DECLINED") acc.no++;
).length; else if (a.partstat === "TENTATIVE") acc.maybe++;
const needActionCount = attendees?.filter( else if (a.partstat === "NEEDS-ACTION") acc.needAction++;
(a) => a.partstat === "NEEDS-ACTION" return acc;
).length; },
{ yes: 0, no: 0, maybe: 0, needAction: 0 }
);
const {
yes: yesCount,
no: noCount,
maybe: maybeCount,
needAction: needActionCount,
} = counts;
if (yesCount) { if (yesCount) {
attendeePreview.push(t("eventPreview.yesCount", { count: yesCount })); attendeePreview.push(t("eventPreview.yesCount", { count: yesCount }));
} }
@@ -1,4 +1,21 @@
import { api } from "@/utils/apiUtils"; import { api } from "@/utils/apiUtils";
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
interface BusySlot {
uid: string;
start: string;
end: string;
}
interface CalendarFreeBusy {
id: string;
busy: BusySlot[];
}
interface UserFreeBusy {
id: string;
calendars: CalendarFreeBusy[];
}
export async function getFreeBusyForEventAttendeesPOST( export async function getFreeBusyForEventAttendeesPOST(
userIds: string[], userIds: string[],
@@ -13,10 +30,16 @@ export async function getFreeBusyForEventAttendeesPOST(
}); });
if (!r.ok) throw new Error(`HTTP ${r.status}`); if (!r.ok) throw new Error(`HTTP ${r.status}`);
const { users } = (await r.json()) as { const { users } = (await r.json()) as { users: UserFreeBusy[] };
users: { id: string; calendars: { busy: unknown[] }[] }[];
}; const eventUidBase = extractEventBaseUuid(eventUid);
return Object.fromEntries( return Object.fromEntries(
users.map((u) => [u.id, u.calendars.some((cal) => cal.busy.length > 0)]) users.map((u) => {
const isBusy = u.calendars.some((cal) =>
cal.busy.some((slot) => extractEventBaseUuid(slot.uid) !== eventUidBase)
);
return [u.id, isBusy];
})
); );
} }