[#625] added freebusy handling in event preview
This commit is contained in:
committed by
Benoit TELLIER
parent
f41c3cbd8d
commit
5269c43354
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
useEffect(
|
||||
() => {
|
||||
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;
|
||||
|
||||
@@ -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];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user