[#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);
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);
});
@@ -721,7 +721,7 @@ describe("Event Preview Display", () => {
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);
});
+10 -2
View File
@@ -18,7 +18,8 @@ export function renderAttendeeBadge(
key: string,
t: (key: string) => string,
isFull?: boolean,
isOrganizer?: boolean
isOrganizer?: boolean,
caption?: string
) {
const classIcon =
a.partstat === "ACCEPTED" ? (
@@ -65,12 +66,19 @@ export function renderAttendeeBadge(
<Avatar {...stringAvatar(a.cn || a.cal_address)} />
</Badge>
<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 && (
<Typography variant="caption" color="text.secondary">
{t("event.organizer")}
</Typography>
)}
{caption && (
<Typography variant="caption" color="text.secondary">
{caption}
</Typography>
)}
</Box>
</Box>
);
@@ -36,9 +36,9 @@ export function EventPreviewActionMenu({
<MenuItem
onClick={() =>
window.open(
`${mailSpaUrl}/mailto/?uri=mailto:${otherAttendees
.map((a) => a.cal_address)
.join(",")}&subject=${encodeURIComponent(event.title ?? "")}`
`${mailSpaUrl}/mailto/?uri=${encodeURIComponent(
`mailto:${otherAttendees.map((a) => a.cal_address).join(",")}`
)}&subject=${encodeURIComponent(event.title ?? "")}`
)
}
>
@@ -1,3 +1,4 @@
import { useAttendeesFreeBusy } from "@/components/Attendees/useFreeBusy";
import { renderAttendeeBadge } from "@/components/Event/utils/eventUtils";
import { AvatarGroup, Box, Typography } from "@linagora/twake-mui";
import PeopleAltOutlinedIcon from "@mui/icons-material/PeopleAltOutlined";
@@ -11,6 +12,10 @@ interface EventPreviewAttendeesProps {
attendees: userAttendee[];
organizer: userAttendee | undefined;
allAttendees: userAttendee[];
start?: string;
end?: string;
timezone?: string;
eventUid?: string | null;
}
const ATTENDEE_DISPLAY_LIMIT = 3;
@@ -19,6 +24,10 @@ export function EventPreviewAttendees({
attendees,
organizer,
allAttendees,
start,
end,
timezone,
eventUid,
}: EventPreviewAttendeesProps) {
const { t } = useI18n();
const theme = useTheme();
@@ -29,6 +38,26 @@ export function EventPreviewAttendees({
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 (
<>
<Box display="flex" alignItems="flex-start">
@@ -78,10 +107,24 @@ export function EventPreviewAttendees({
{showAllAttendees &&
organizer &&
renderAttendeeBadge(organizer, "org", t, showAllAttendees, true)}
renderAttendeeBadge(
organizer,
"org",
t,
showAllAttendees,
true,
busyCaption(organizer)
)}
{showAllAttendees &&
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 { renderAttendeeBadge } from "@/components/Event/utils/eventUtils";
import { AvatarGroup, Box, Button, Typography } from "@linagora/twake-mui";
import { Box, Button, Typography } from "@linagora/twake-mui";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone";
import PeopleAltOutlinedIcon from "@mui/icons-material/PeopleAltOutlined";
import RepeatIcon from "@mui/icons-material/Repeat";
import SubjectIcon from "@mui/icons-material/Subject";
import VideocamOutlinedIcon from "@mui/icons-material/VideocamOutlined";
import { alpha, useTheme } from "@mui/material/styles";
import { useState } from "react";
import { useI18n } from "twake-i18n";
import { makeAttendeePreview } from ".";
import { CalendarEvent } from "../EventsTypes";
import { EventPreviewAttendees } from "./EventPreviewAttendees";
import { makeRecurrenceString } from "./utils/makeRecurrenceString";
@@ -20,35 +16,26 @@ interface EventPreviewDetailsProps {
event: CalendarEvent;
isOwn: boolean;
isNotPrivate: boolean;
userEmail: string;
}
const ATTENDEE_DISPLAY_LIMIT = 3;
export function EventPreviewDetails({
event,
isOwn,
isNotPrivate,
userEmail,
}: EventPreviewDetailsProps) {
const { t } = useI18n();
const theme = useTheme();
const infoIconColor = alpha(theme.palette.grey[900], 0.9);
const infoIconSx = { minWidth: "25px", marginRight: 2, color: infoIconColor };
const [showAllAttendees, setShowAllAttendees] = useState(false);
const attendees =
event.attendee?.filter(
(a) => a.cal_address !== event.organizer?.cal_address
) || [];
const organizer = event.attendee?.find(
(a) => a.cal_address === event.organizer?.cal_address
);
const attendeePreview = makeAttendeePreview(event.attendee, t);
const showDetails = (isNotPrivate && !isOwn) || isOwn;
const showDetails = isNotPrivate || isOwn;
if (!showDetails) {
return (
@@ -84,7 +71,13 @@ export function EventPreviewDetails({
<Button
variant="contained"
size="medium"
onClick={() => window.open(event.x_openpass_videoconference)}
onClick={() =>
window.open(
event.x_openpass_videoconference,
"_blank",
"noopener,noreferrer"
)
}
sx={{ borderRadius: "4px" }}
>
{t("eventPreview.joinVideo")}
@@ -98,7 +91,11 @@ export function EventPreviewDetails({
<EventPreviewAttendees
attendees={attendees}
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 CloseIcon from "@mui/icons-material/Close";
import EditIcon from "@mui/icons-material/Edit";
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import { dlEvent } from "../EventApi";
import { CalendarEvent } from "../EventsTypes";
interface EventPreviewHeaderProps {
event: CalendarEvent;
@@ -13,7 +13,6 @@ interface EventPreviewHeaderProps {
isOwn: boolean;
isWriteDelegated: boolean;
isNotPrivate: boolean;
isRecurring: boolean;
onClose: () => void;
onEdit: () => void;
onMoreClick: (e: React.MouseEvent<HTMLElement>) => void;
@@ -26,13 +25,12 @@ export function EventPreviewHeader({
isOwn,
isWriteDelegated,
isNotPrivate,
isRecurring,
onClose,
onEdit,
onMoreClick,
}: EventPreviewHeaderProps) {
const canEdit = isOrganizer && (isOwn || (isWriteDelegated && isNotPrivate));
const canSeeMore = (isNotPrivate && !isOwn) || isOwn;
const canSeeMore = isNotPrivate || isOwn;
return (
<Box
@@ -1,9 +1,8 @@
import { useAppSelector } from "@/app/hooks";
import { CalendarName } from "@/components/Calendar/CalendarName";
import { formatEventChipTitle } from "@/components/Calendar/utils/calendarUtils";
import ResponsiveDialog from "@/components/Dialog/ResponsiveDialog";
import { EditModeDialog } from "@/components/Event/EditModeDialog";
import { browserDefaultTimeZone, getTimezoneOffset } from "@/utils/timezone";
import { getTimezoneOffset } from "@/utils/timezone";
import { DateSelectArg } from "@fullcalendar/core";
import { Box, Chip, Tooltip, Typography } from "@linagora/twake-mui";
import CircleIcon from "@mui/icons-material/Circle";
@@ -12,13 +11,13 @@ import { useEffect } from "react";
import { useI18n } from "twake-i18n";
import { AttendanceValidation } from "../AttendanceValidation/AttendanceValidation";
import EventPopover from "../EventModal";
import EventUpdateModal from "../EventUpdateModal";
import { EventPreviewActionMenu } from "../EventPreview/EventPreviewActionMenu";
import { EventPreviewDetails } from "../EventPreview/EventPreviewDetails";
import { EventPreviewHeader } from "../EventPreview/EventPreviewHeader";
import { useEventPreviewState } from "../EventPreview/useEventPreviewState";
import { formatEnd } from "./utils/formatEnd";
import EventUpdateModal from "../EventUpdateModal";
import { formatDate } from "./utils/formatDate";
import { formatEnd } from "./utils/formatEnd";
export default function EventPreviewModal({
eventId,
@@ -42,7 +41,6 @@ export default function EventPreviewModal({
timezone,
contextualizedEvent,
attendanceUser,
isRecurring,
isOwn,
isWriteDelegated,
isOrganizer,
@@ -57,7 +55,6 @@ export default function EventPreviewModal({
setToggleActionMenu,
openEditModePopup,
setOpenEditModePopup,
typeOfAction,
setTypeOfAction,
afterChoiceFunc,
setAfterChoiceFunc,
@@ -67,11 +64,14 @@ export default function EventPreviewModal({
handleDuplicateClick,
} = useEventPreviewState(eventId, calId, tempEvent, open, onClose);
useEffect(() => {
if (!event || !calendar) {
onClose({}, "backdropClick");
}
}, [event, calendar, onClose]);
useEffect(
() => {
if (open && (!event || !calendar)) {
onClose({}, "backdropClick");
}
}, // eslint-disable-next-line react-hooks/exhaustive-deps
[open, event, calendar]
);
if (!user || !event || !calendar) return null;
@@ -94,7 +94,6 @@ export default function EventPreviewModal({
isOwn={isOwn}
isWriteDelegated={isWriteDelegated}
isNotPrivate={isNotPrivate}
isRecurring={isRecurring}
onClose={() => onClose({}, "backdropClick")}
onEdit={handleEditClick}
onMoreClick={(e) => setToggleActionMenu(e.currentTarget)}
@@ -164,7 +163,6 @@ export default function EventPreviewModal({
event={event}
isOwn={isOwn}
isNotPrivate={isNotPrivate}
userEmail={user.email}
/>
{/* Calendar label */}
@@ -219,8 +217,8 @@ export default function EventPreviewModal({
{
start: new Date(event.start),
startStr: event.start,
end: new Date(event.end ?? ""),
endStr: event.end ?? "",
end: new Date(event.end ?? event.start),
endStr: event.end ?? event.start,
allDay: event.allday ?? false,
} as DateSelectArg
}
@@ -138,7 +138,7 @@ export function useEventPreviewState(
const result = await dispatch(
deleteEventAsync({ calId, eventId, eventURL: event.URL })
);
assertThunkSuccess(result);
await assertThunkSuccess(result);
} catch (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(
attendees: userAttendee[],
attendees: userAttendee[] | undefined,
t: (k: string, p?: string | object) => string
) {
const attendeePreview = [];
const yesCount = attendees?.filter((a) => a.partstat === "ACCEPTED").length;
const noCount = attendees?.filter((a) => a.partstat === "DECLINED").length;
const maybeCount = attendees?.filter(
(a) => a.partstat === "TENTATIVE"
).length;
const needActionCount = attendees?.filter(
(a) => a.partstat === "NEEDS-ACTION"
).length;
const counts = (attendees ?? []).reduce(
(acc, a) => {
if (a.partstat === "ACCEPTED") acc.yes++;
else if (a.partstat === "DECLINED") acc.no++;
else if (a.partstat === "TENTATIVE") acc.maybe++;
else if (a.partstat === "NEEDS-ACTION") acc.needAction++;
return acc;
},
{ yes: 0, no: 0, maybe: 0, needAction: 0 }
);
const {
yes: yesCount,
no: noCount,
maybe: maybeCount,
needAction: needActionCount,
} = counts;
if (yesCount) {
attendeePreview.push(t("eventPreview.yesCount", { count: yesCount }));
}
@@ -1,4 +1,21 @@
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(
userIds: string[],
@@ -13,10 +30,16 @@ export async function getFreeBusyForEventAttendeesPOST(
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const { users } = (await r.json()) as {
users: { id: string; calendars: { busy: unknown[] }[] }[];
};
const { users } = (await r.json()) as { users: UserFreeBusy[] };
const eventUidBase = extractEventBaseUuid(eventUid);
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];
})
);
}