[refactor] extracted event display preview components in preparation for #625

This commit is contained in:
Camille Moussu
2026-03-11 16:05:17 +01:00
committed by Benoit TELLIER
parent 0dd7f1b2fd
commit f41c3cbd8d
19 changed files with 1199 additions and 979 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
import { RootState } from "@/app/store";
import EventDuplication from "@/components/Event/EventDuplicate";
import EventPreviewModal from "@/features/Events/EventDisplayPreview";
import EventPreviewModal from "@/features/Events/EventPreview";
import EventPopover from "@/features/Events/EventModal";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../utils/Renderwithproviders";
@@ -1,7 +1,7 @@
import CalendarApp from "@/components/Calendar/Calendar";
import * as calendarUtils from "@/components/Calendar/utils/calendarUtils";
import { updateSlotLabelVisibility } from "@/components/Calendar/utils/calendarUtils";
import EventPreviewModal from "@/features/Events/EventDisplayPreview";
import EventPreviewModal from "@/features/Events/EventPreview";
import { CalendarEvent } from "@/features/Events/EventsTypes";
import * as SettingsSlice from "@/features/Settings/SettingsSlice";
import { fireEvent, screen, waitFor } from "@testing-library/react";
@@ -7,7 +7,7 @@ import {
} from "@/components/Event/utils/eventUtils";
import { DelegationAccess } from "@/features/Calendars/CalendarTypes";
import * as eventThunks from "@/features/Calendars/services";
import EventPreviewModal from "@/features/Events/EventDisplayPreview";
import EventPreviewModal from "@/features/Events/EventPreview";
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../../utils/Renderwithproviders";
@@ -6,7 +6,7 @@ import {
import { EditModeDialog } from "@/components/Event/EditModeDialog";
import * as eventThunks from "@/features/Calendars/services";
import * as EventApi from "@/features/Events/EventApi";
import EventPreviewModal from "@/features/Events/EventDisplayPreview";
import EventPreviewModal from "@/features/Events/EventPreview";
import EventUpdateModal from "@/features/Events/EventUpdateModal";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import preview from "jest-preview";
+5 -5
View File
@@ -1,6 +1,6 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks";
import EventPreviewModal from "@/features/Events/EventDisplayPreview";
import EventPopover from "@/features/Events/EventModal";
import EventPreviewModal from "@/features/Events/EventPreview";
import { CalendarEvent } from "@/features/Events/EventsTypes";
import ImportAlert from "@/features/Events/ImportAlert";
import SearchResultsPage from "@/features/Search/SearchResultsPage";
@@ -24,6 +24,7 @@ import { Box, Button, radius, useTheme } from "@linagora/twake-mui";
import AddIcon from "@mui/icons-material/Add";
import { MutableRefObject, useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "twake-i18n";
import { useCalendarDataLoader } from "../../features/Calendars/useCalendarLoader";
import { User } from "../Attendees/PeopleSearch";
import { EventErrorSnackbar } from "../Error/ErrorSnackbar";
import { EventErrorHandler } from "../Error/EventErrorHandler";
@@ -37,12 +38,11 @@ import { useCalendarViewHandlers } from "./hooks/useCalendarViewHandlers";
import { MiniCalendar } from "./MiniCalendar";
import { TempCalendarsInput } from "./TempCalendarsInput";
import { TimezoneSelector } from "./TimezoneSelector";
import { useCalendarDataLoader } from "../../features/Calendars/useCalendarLoader";
import { updateDarkColor } from "./utils/calendarColorsUtils";
import {
eventToFullCalendarFormat,
extractEvents,
updateSlotLabelVisibility,
eventToFullCalendarFormat,
extractEvents,
updateSlotLabelVisibility
} from "./utils/calendarUtils";
const localeMap: Record<string, LocaleInput | undefined> = {
-969
View File
@@ -1,969 +0,0 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { useAppDispatch, 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 EventDuplication from "@/components/Event/EventDuplicate";
import { handleDelete } from "@/components/Event/eventHandlers/eventHandlers";
import { InfoRow } from "@/components/Event/InfoRow";
import { renderAttendeeBadge } from "@/components/Event/utils/eventUtils";
import { getEffectiveEmail } from "@/utils/getEffectiveEmail";
import { isEventOrganiser } from "@/utils/isEventOrganiser";
import { browserDefaultTimeZone, getTimezoneOffset } from "@/utils/timezone";
import { DateSelectArg } from "@fullcalendar/core";
import {
AvatarGroup,
Box,
Button,
Chip,
IconButton,
Menu,
MenuItem,
Tooltip,
Typography,
useTheme,
} from "@linagora/twake-mui";
import CircleIcon from "@mui/icons-material/Circle";
import CloseIcon from "@mui/icons-material/Close";
import EditIcon from "@mui/icons-material/Edit";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
import LockOutlineIcon from "@mui/icons-material/LockOutline";
import MoreVertIcon from "@mui/icons-material/MoreVert";
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 } from "@mui/material/styles";
import { useEffect, useRef, useState } from "react";
import { useI18n } from "twake-i18n";
import { deleteEventAsync } from "../Calendars/services";
import { userAttendee } from "../User/models/attendee";
import { ToUserData } from "../User/type/OpenPaasUserData";
import { AttendanceValidation } from "./AttendanceValidation/AttendanceValidation";
import { createEventContext } from "./createEventContext";
import { dlEvent } from "./EventApi";
import EventPopover from "./EventModal";
import { CalendarEvent } from "./EventsTypes";
import EventUpdateModal from "./EventUpdateModal";
export default function EventPreviewModal({
eventId,
calId,
tempEvent,
open,
onClose,
}: {
eventId: string;
calId: string;
tempEvent?: boolean;
open: boolean;
onClose: (event: unknown, reason: "backdropClick" | "escapeKeyDown") => void;
}) {
const { t } = useI18n();
const dispatch = useAppDispatch();
const calendars = useAppSelector((state) => state.calendars);
const timezone =
useAppSelector((state) => state.settings.timeZone) ??
browserDefaultTimeZone;
const calendar = tempEvent
? calendars.templist[calId]
: calendars.list[calId];
const event = calendar?.events[eventId];
const user = useAppSelector((state) => state.user.userData);
const theme = useTheme();
const infoIconColor = alpha(theme.palette.grey[900], 0.9);
const infoIconSx = { minWidth: "25px", marginRight: 2, color: infoIconColor };
if (!user) return null;
const isRecurring = event?.uid?.includes("/");
const isOwn = calendar.owner?.emails?.includes(user.email) ?? false;
const isDelegated = calendar.delegated;
const isWriteDelegated = (isDelegated && calendar.access?.write) ?? false;
const effectiveEmail = getEffectiveEmail(
calendar,
isWriteDelegated,
user.email
);
const isOrganizer = event.organizer
? isEventOrganiser(event, effectiveEmail)
: isOwn;
const isNotPrivate =
event.class !== "PRIVATE" && event.class !== "CONFIDENTIAL";
const [showAllAttendees, setShowAllAttendees] = useState(false);
const [openUpdateModal, setOpenUpdateModal] = useState(false);
const [openDuplicateModal, setOpenDuplicateModal] = useState(false);
const [hidePreview, setHidePreview] = useState(false);
const [openEditModePopup, setOpenEditModePopup] = useState<string | null>(
null
);
const [typeOfAction, setTypeOfAction] = useState<"solo" | "all" | undefined>(
undefined
);
const [afterChoiceFunc, setAfterChoiceFunc] = useState<
((type: "solo" | "all" | undefined) => void) | undefined
>();
const attendeePreview = makeAttendeePreview(event.attendee, t);
const hasCheckedSessionStorageRef = useRef(false);
const [toggleActionMenu, setToggleActionMenu] = useState<Element | null>(
null
);
const mailSpaUrl = window.MAIL_SPA_URL ?? null;
useEffect(() => {
if (!event || !calendar) {
onClose({}, "backdropClick");
}
}, [event, calendar, onClose]);
// Check sessionStorage when component mounts or when open becomes true (only once per open)
useEffect(() => {
if (!open) {
hasCheckedSessionStorageRef.current = false;
return;
}
// Only check once when open becomes true
if (hasCheckedSessionStorageRef.current) return;
hasCheckedSessionStorageRef.current = true;
const checkAndReopen = () => {
try {
const stored = sessionStorage.getItem("eventUpdateModalReopen");
if (stored) {
const data = JSON.parse(stored);
// Check if stored data matches current preview
// For recurring events, typeOfAction from sessionStorage should be used
// Allow undefined to match undefined, or use stored typeOfAction if current is undefined
const typeOfActionMatch =
data.typeOfAction === typeOfAction ||
(data.typeOfAction === undefined && typeOfAction === undefined) ||
(data.typeOfAction !== undefined && typeOfAction === undefined); // Allow stored typeOfAction to match when current is undefined
if (
data.eventId === eventId &&
data.calId === calId &&
typeOfActionMatch
) {
// Restore typeOfAction from sessionStorage if it exists
if (data.typeOfAction !== undefined && typeOfAction === undefined) {
setTypeOfAction(data.typeOfAction);
// Open modal immediately with the typeOfAction from sessionStorage
// Use setTimeout to ensure state is set before opening
setTimeout(() => {
setOpenUpdateModal(true);
setHidePreview(true);
sessionStorage.removeItem("eventUpdateModalReopen");
}, 50);
} else {
// Small delay to ensure component is fully mounted
setTimeout(() => {
setOpenUpdateModal(true);
setHidePreview(true);
sessionStorage.removeItem("eventUpdateModalReopen");
}, 100);
}
}
}
} catch {
// Ignore sessionStorage errors
}
};
checkAndReopen();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, eventId, calId]); // Removed typeOfAction from dependencies to prevent re-checking when typeOfAction changes
// Listen for eventUpdateModalReopen event to reopen update modal on API failure
useEffect(() => {
const handleUpdateModalReopen = (event: CustomEvent) => {
const detail = event.detail;
// Check if this event matches current preview
// For recurring events, typeOfAction from event should be used
// Allow undefined to match undefined, or use event typeOfAction if current is undefined
const typeOfActionMatch =
detail?.typeOfAction === typeOfAction ||
(detail?.typeOfAction === undefined && typeOfAction === undefined) ||
(detail?.typeOfAction !== undefined && typeOfAction === undefined); // Allow event typeOfAction to match when current is undefined
if (
detail?.eventId === eventId &&
detail?.calId === calId &&
typeOfActionMatch
) {
// Restore typeOfAction from event if it exists
if (detail?.typeOfAction !== undefined && typeOfAction === undefined) {
setTypeOfAction(detail.typeOfAction);
// Open modal immediately with the typeOfAction from event
setTimeout(() => {
setOpenUpdateModal(true);
setHidePreview(true);
}, 50);
} else {
setOpenUpdateModal(true);
setHidePreview(true);
}
// Clear sessionStorage after reopening
try {
sessionStorage.removeItem("eventUpdateModalReopen");
} catch {
// Ignore sessionStorage errors
}
}
};
// Also check sessionStorage on mount or when eventId/calId changes
const checkSessionStorage = () => {
try {
const stored = sessionStorage.getItem("eventUpdateModalReopen");
if (stored) {
const data = JSON.parse(stored);
// Check if stored data matches current preview
// For recurring events, typeOfAction from sessionStorage should be used
// Allow undefined to match undefined, or use stored typeOfAction if current is undefined
const typeOfActionMatch =
data.typeOfAction === typeOfAction ||
(data.typeOfAction === undefined && typeOfAction === undefined) ||
(data.typeOfAction !== undefined && typeOfAction === undefined); // Allow stored typeOfAction to match when current is undefined
if (
data.eventId === eventId &&
data.calId === calId &&
typeOfActionMatch
) {
// Restore typeOfAction from sessionStorage if it exists
if (data.typeOfAction !== undefined && typeOfAction === undefined) {
setTypeOfAction(data.typeOfAction);
// Open modal immediately with the typeOfAction from sessionStorage
setTimeout(() => {
setOpenUpdateModal(true);
setHidePreview(true);
sessionStorage.removeItem("eventUpdateModalReopen");
}, 50);
} else {
setOpenUpdateModal(true);
setHidePreview(true);
sessionStorage.removeItem("eventUpdateModalReopen");
}
}
}
} catch {
// Ignore sessionStorage errors
}
};
// Check on mount and when relevant props change
checkSessionStorage();
window.addEventListener(
"eventUpdateModalReopen",
handleUpdateModalReopen as EventListener
);
return () => {
window.removeEventListener(
"eventUpdateModalReopen",
handleUpdateModalReopen as EventListener
);
};
}, [eventId, calId, typeOfAction]);
if (!event || !calendar) return null;
const attendeeDisplayLimit = 3;
const attendees =
event.attendee?.filter(
(a) => a.cal_address !== event.organizer?.cal_address
) || [];
const currentUserAttendee = event.attendee?.find(
(person) => person.cal_address === user.email
);
const contextualizedEvent = createEventContext(event, calendar, user);
const organizer = event.attendee?.find(
(a) => a.cal_address === event.organizer?.cal_address
);
return (
<>
<ResponsiveDialog
open={open && !hidePreview}
onClose={() => onClose({}, "backdropClick")}
showHeaderActions={false}
actionsBorderTop={currentUserAttendee && isOwn}
actionsJustifyContent="center"
style={{ overflow: "auto" }}
title={
<Box
display="flex"
justifyContent="flex-end"
alignItems="center"
gap={0.5}
width="100%"
>
{window.DEBUG && (
<IconButton
size="small"
onClick={async () => {
let url: string | null = null;
try {
const icsContent = await dlEvent(event);
const blob = new Blob([icsContent], {
type: "text/calendar",
});
url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${eventId}.ics`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error("Failed to download ICS file:", error);
} finally {
if (url) URL.revokeObjectURL(url);
}
}}
>
<FileDownloadOutlinedIcon />
</IconButton>
)}
{isOrganizer && (isOwn || (isWriteDelegated && isNotPrivate)) && (
<IconButton
size="small"
onClick={() => {
if (isRecurring) {
setAfterChoiceFunc(() => () => {
setHidePreview(true);
setOpenUpdateModal(true);
});
setOpenEditModePopup("edit");
} else {
setHidePreview(true);
setOpenUpdateModal(true);
}
}}
>
<EditIcon />
</IconButton>
)}
{((isNotPrivate && !isOwn) || isOwn) && (
<IconButton
size="small"
onClick={(e) => setToggleActionMenu(e.currentTarget)}
>
<MoreVertIcon />
</IconButton>
)}
<Menu
open={Boolean(toggleActionMenu)}
onClose={() => setToggleActionMenu(null)}
anchorEl={toggleActionMenu}
>
{mailSpaUrl && attendees.length > 0 && (
<MenuItem
onClick={() =>
window.open(
`${mailSpaUrl}/mailto/?uri=mailto:${event.attendee
.map((a) => a.cal_address)
.filter((mail) => mail !== user.email)
.join(
","
)}&subject=${encodeURIComponent(event.title ?? "")}`
)
}
>
{t("eventPreview.emailAttendees")}
</MenuItem>
)}
<EventDuplication
onOpenDuplicate={() => {
setToggleActionMenu(null);
setHidePreview(true);
setOpenDuplicateModal(true);
}}
/>
{(isOwn || isWriteDelegated) && (
<MenuItem
onClick={async () => {
if (isRecurring) {
setAfterChoiceFunc(
() => (typeOfAction?: "solo" | "all" | undefined) =>
handleDelete(
isRecurring,
typeOfAction,
onClose,
dispatch,
calendar,
event,
calId,
eventId
)
);
setOpenEditModePopup("delete");
} else {
onClose({}, "backdropClick");
try {
const result = await dispatch(
deleteEventAsync({
calId,
eventId,
eventURL: event.URL,
})
);
// For compatibility with tests that may not mock unwrap
if (result && typeof result.unwrap === "function") {
await result.unwrap();
}
} catch (error) {
console.error("Failed to delete event:", error);
}
}
}}
>
{t("eventPreview.deleteEvent")}
</MenuItem>
)}
</Menu>
<IconButton
size="small"
onClick={() => onClose({}, "backdropClick")}
>
<CloseIcon />
</IconButton>
</Box>
}
actions={
<AttendanceValidation
contextualizedEvent={contextualizedEvent}
user={
isWriteDelegated && calendar.owner
? ToUserData(calendar.owner)
: user
}
setAfterChoiceFunc={setAfterChoiceFunc}
setOpenEditModePopup={setOpenEditModePopup}
/>
}
>
<Box mb={3}>
<Box
display="flex"
flexDirection="row"
alignItems="center"
gap={1}
mb={1}
>
{event.class === "PRIVATE" &&
(isOwn ? (
<Tooltip
title={t("eventPreview.privateEvent.tooltipOwn")}
placement="top"
>
<LockOutlineIcon sx={{ color: infoIconColor }} />
</Tooltip>
) : (
<LockOutlineIcon sx={{ color: infoIconColor }} />
))}
<Typography
variant="h5"
sx={{
fontSize: "24px",
fontWeight: 600,
wordBreak: "break-word",
fontFamily: "Inter, sans-serif",
}}
>
{formatEventChipTitle(event, t)}
</Typography>
{event.transp === "TRANSPARENT" && (
<Tooltip title={t("eventPreview.free.tooltip")} placement="top">
<Chip
icon={<CircleIcon color="success" fontSize="small" />}
label={t("eventPreview.free.label")}
/>
</Tooltip>
)}
</Box>
<Typography color="text.secondaryContainer">
{formatDate(event.start, t, timezone, event.allday)}
{event.end &&
formatEnd(event.start, event.end, t, timezone, event.allday) &&
` ${formatEnd(event.start, event.end, t, timezone, event.allday)} ${!event.allday ? getTimezoneOffset(timezone, new Date(event.start)) : ""}`}
</Typography>
</Box>
{((isNotPrivate && !isOwn) || isOwn) && (
<>
{/* Video */}
{event.x_openpass_videoconference && (
<InfoRow
alignItems="flex-start"
icon={
<Box sx={{ ...infoIconSx, mt: 1 }}>
<VideocamOutlinedIcon />
</Box>
}
content={
<Button
variant="contained"
size="medium"
onClick={() =>
window.open(event.x_openpass_videoconference)
}
sx={{ borderRadius: "4px" }}
>
{t("eventPreview.joinVideo")}
</Button>
}
/>
)}
{/* Attendees */}
{attendees?.length > 0 && (
<>
<InfoRow
alignItems="flex-start"
icon={
<Box sx={{ ...infoIconSx, mt: 1 }}>
<PeopleAltOutlinedIcon />
</Box>
}
content={
<Box
style={{
marginBottom: 1,
display: "flex",
flexDirection: "row",
}}
>
<Box sx={{ marginRight: 2 }}>
<Typography>
{t("eventPreview.guests", {
count: event.attendee.length,
})}
</Typography>
<Typography
sx={{ fontSize: "13px", color: "text.secondary" }}
>
{attendeePreview}
</Typography>
</Box>
{!showAllAttendees && (
<AvatarGroup max={attendeeDisplayLimit}>
{organizer &&
renderAttendeeBadge(
organizer,
"org",
t,
showAllAttendees,
true
)}
{attendees.map((a, idx) =>
renderAttendeeBadge(
a,
idx.toString(),
t,
showAllAttendees
)
)}
</AvatarGroup>
)}
<Typography
sx={{
cursor: "pointer",
marginLeft: 2,
fontSize: "14px",
color: "text.secondary",
alignSelf: "center",
}}
onClick={() => setShowAllAttendees(!showAllAttendees)}
>
{showAllAttendees
? t("eventPreview.showLess")
: t("eventPreview.showMore")}
</Typography>
</Box>
}
/>
</>
)}
{showAllAttendees &&
organizer &&
renderAttendeeBadge(organizer, "org", t, showAllAttendees, true)}
{showAllAttendees &&
attendees.map((a, idx) =>
renderAttendeeBadge(a, idx.toString(), t, showAllAttendees)
)}
{/* Location */}
{event.location && (
<InfoRow
alignItems="flex-start"
icon={
<Box sx={infoIconSx}>
<LocationOnOutlinedIcon />
</Box>
}
text={event.location}
style={{
fontSize: "16px",
fontFamily: "'Inter', sans-serif",
}}
/>
)}
{/* Description */}
{event.description && (
<InfoRow
alignItems="flex-start"
icon={
<Box sx={infoIconSx}>
<SubjectIcon />
</Box>
}
text={event.description}
style={{
fontSize: "16px",
fontFamily: "'Inter', sans-serif",
}}
/>
)}
{/* ALARM */}
{event.alarm && (
<InfoRow
alignItems="flex-start"
icon={
<Box sx={infoIconSx}>
<NotificationsNoneIcon />
</Box>
}
text={t("eventPreview.alarmText", {
trigger: t(`event.form.notifications.${event.alarm.trigger}`),
action: (() => {
if (!event.alarm.action) return "";
const translationKey = `event.form.notifications.${event.alarm.action}`;
const translated = t(translationKey);
// If translation returns the key itself, it means translation not found, use raw value
return translated === translationKey
? event.alarm.action
: translated;
})(),
})}
style={{
fontSize: "16px",
fontFamily: "'Inter', sans-serif",
}}
/>
)}
{/* Repetition */}
{event.repetition && (
<InfoRow
alignItems="flex-start"
icon={
<Box sx={infoIconSx}>
<RepeatIcon />
</Box>
}
text={makeRecurrenceString(event, t)}
style={{
fontSize: "16px",
fontFamily: "'Inter', sans-serif",
}}
/>
)}
</>
)}
{!isNotPrivate && !isOwn && (
<Box
sx={{
backgroundColor: "#F3F4F6",
height: 48,
borderRadius: "8px",
gap: "16px",
paddingTop: "16px",
paddingBottom: " 16px",
}}
>
<Typography
fontFamily={"Inter"}
fontWeight={500}
fontSize={"12px"}
lineHeight={"16px"}
letterSpacing={"0.5px"}
textAlign={"center"}
>
{t("eventPreview.privateEvent.hiddenDetails")}
</Typography>
</Box>
)}
{/* Error */}
{event.error && (
<InfoRow
alignItems="flex-start"
icon={
<Box sx={infoIconSx}>
<ErrorOutlineIcon color="error" />
</Box>
}
text={event.error}
style={{
fontSize: "16px",
fontFamily: "'Inter', sans-serif",
}}
error
/>
)}
{/* Calendar color dot */}
<Box
sx={{
display: "flex",
alignItems: "flex-start",
gap: 1,
mb: 2,
}}
>
<CalendarName calendar={calendar} />
</Box>
</ResponsiveDialog>
<EditModeDialog
type={openEditModePopup}
setOpen={setOpenEditModePopup}
eventAction={(type: "solo" | "all" | undefined) => {
setTypeOfAction(type);
if (afterChoiceFunc) {
afterChoiceFunc(type);
}
}}
/>
<EventUpdateModal
open={openUpdateModal}
onClose={() => {
setOpenUpdateModal(false);
setHidePreview(false);
}}
onCloseAll={() => {
setOpenUpdateModal(false);
onClose({}, "backdropClick");
}}
eventId={eventId}
calId={calId}
typeOfAction={(() => {
// Get typeOfAction from state or sessionStorage
if (typeOfAction) {
return typeOfAction;
}
// Fallback: try to get typeOfAction from sessionStorage if state is not set yet
try {
const stored = sessionStorage.getItem("eventUpdateModalReopen");
if (stored) {
const data = JSON.parse(stored);
if (
data.eventId === eventId &&
data.calId === calId &&
data.typeOfAction
) {
return data.typeOfAction;
}
}
} catch {
// Ignore
}
return undefined;
})()}
/>
<EventPopover
anchorEl={null}
open={openDuplicateModal}
selectedRange={
{
start: new Date(event.start),
startStr: event.start,
end: new Date(event.end ?? ""),
endStr: event.end ?? "",
allDay: event.allday ?? false,
} as DateSelectArg
}
setSelectedRange={() => {}}
calendarRef={{ current: null }}
onClose={() => {
setOpenDuplicateModal(false);
onClose({}, "backdropClick"); // Close preview modal as well
}}
event={event}
/>
</>
);
}
function makeRecurrenceString(
event: CalendarEvent,
t: (k: string, p?: string | object) => string
): string | undefined {
if (!event.repetition) return;
const recur: string[] = [
`${t("eventPreview.recurrentEvent")} · ${t(
`eventPreview.freq.${event.repetition.freq}`,
event.repetition.freq
)}`,
];
const recurType: Record<string, string> = {
daily: t("event.repeat.frequency.days"),
weekly: t("event.repeat.frequency.weeks"),
monthly: t("event.repeat.frequency.months"),
yearly: t("event.repeat.frequency.years"),
};
if (event.repetition.interval && event.repetition.interval > 1) {
recur.push(
t("eventPreview.everyInterval", {
interval: event.repetition.interval,
unit: recurType[event.repetition.freq] ?? event.repetition.freq,
})
);
}
if (event.repetition.byday) {
recur.push(
t("eventPreview.recurrenceOnDays", {
days: event.repetition.byday
.map((s) => t(`eventPreview.onDays.${s}`))
.join(", "),
})
);
}
if (event.repetition.occurrences) {
recur.push(
t("eventPreview.forOccurrences", {
count: event.repetition.occurrences,
})
);
}
if (event.repetition.endDate) {
recur.push(
t("eventPreview.until", {
date: new Date(event.repetition.endDate).toLocaleDateString(
t("locale"),
{
year: "numeric",
month: "long",
day: "numeric",
}
),
})
);
}
return recur.join(", ");
}
function formatDate(
date: Date | string,
t: (k: string, p?: string | object) => string,
timeZone: string,
allday?: boolean
) {
if (allday) {
return new Date(date).toLocaleDateString(t("locale"), {
year: "numeric",
month: "long",
weekday: "long",
day: "numeric",
timeZone,
});
} else {
return new Date(date).toLocaleString(t("locale"), {
year: "numeric",
month: "long",
weekday: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone,
});
}
}
function formatEnd(
start: Date | string,
end: Date | string,
t: (k: string, p?: string | object) => string,
timeZone: string,
allday?: boolean
) {
const startDate = new Date(start);
const endDate = new Date(end);
const formatDatePart = (d: Date) =>
d.toLocaleDateString("en-CA", { timeZone }); // YYYY-MM-DD format
if (allday) {
const inclusiveEndDate = new Date(endDate);
inclusiveEndDate.setDate(inclusiveEndDate.getDate() - 1);
const sameDay =
formatDatePart(startDate) === formatDatePart(inclusiveEndDate);
return sameDay
? null
: inclusiveEndDate.toLocaleDateString(t("locale"), {
year: "numeric",
month: "short",
day: "numeric",
timeZone,
});
} else {
const sameDay = formatDatePart(startDate) === formatDatePart(endDate);
if (sameDay) {
return endDate.toLocaleTimeString(t("locale"), {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone,
});
}
return endDate.toLocaleString(t("locale"), {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone,
});
}
}
export function makeAttendeePreview(
attendees: userAttendee[],
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;
if (yesCount) {
attendeePreview.push(t("eventPreview.yesCount", { count: yesCount }));
}
if (maybeCount) {
attendeePreview.push(t("eventPreview.maybeCount", { count: maybeCount }));
}
if (needActionCount) {
attendeePreview.push(
t("eventPreview.needActionCount", { count: needActionCount })
);
}
if (noCount) {
attendeePreview.push(t("eventPreview.noCount", { count: noCount }));
}
return attendeePreview.join(", ");
}
@@ -0,0 +1,59 @@
import EventDuplication from "@/components/Event/EventDuplicate";
import { Menu, MenuItem } from "@linagora/twake-mui";
import { useI18n } from "twake-i18n";
import { CalendarEvent } from "../EventsTypes";
interface EventPreviewActionMenuProps {
anchorEl: Element | null;
event: CalendarEvent;
userEmail: string;
isOwn: boolean;
isWriteDelegated: boolean;
onClose: () => void;
onDuplicate: () => void;
onDelete: () => void;
}
export function EventPreviewActionMenu({
anchorEl,
event,
userEmail,
isOwn,
isWriteDelegated,
onClose,
onDuplicate,
onDelete,
}: EventPreviewActionMenuProps) {
const { t } = useI18n();
const mailSpaUrl = window.MAIL_SPA_URL ?? null;
const attendees = event.attendee ?? [];
const otherAttendees = attendees.filter((a) => a.cal_address !== userEmail);
return (
<Menu open={Boolean(anchorEl)} onClose={onClose} anchorEl={anchorEl}>
{mailSpaUrl && otherAttendees.length > 0 && (
<MenuItem
onClick={() =>
window.open(
`${mailSpaUrl}/mailto/?uri=mailto:${otherAttendees
.map((a) => a.cal_address)
.join(",")}&subject=${encodeURIComponent(event.title ?? "")}`
)
}
>
{t("eventPreview.emailAttendees")}
</MenuItem>
)}
<EventDuplication
onOpenDuplicate={() => {
onClose();
onDuplicate();
}}
/>
{(isOwn || isWriteDelegated) && (
<MenuItem onClick={onDelete}>{t("eventPreview.deleteEvent")}</MenuItem>
)}
</Menu>
);
}
@@ -0,0 +1,88 @@
import { renderAttendeeBadge } from "@/components/Event/utils/eventUtils";
import { AvatarGroup, Box, Typography } from "@linagora/twake-mui";
import PeopleAltOutlinedIcon from "@mui/icons-material/PeopleAltOutlined";
import { alpha, useTheme } from "@mui/material/styles";
import { useState } from "react";
import { useI18n } from "twake-i18n";
import { makeAttendeePreview } from ".";
import { userAttendee } from "../../User/models/attendee";
interface EventPreviewAttendeesProps {
attendees: userAttendee[];
organizer: userAttendee | undefined;
allAttendees: userAttendee[];
}
const ATTENDEE_DISPLAY_LIMIT = 3;
export function EventPreviewAttendees({
attendees,
organizer,
allAttendees,
}: EventPreviewAttendeesProps) {
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 attendeePreview = makeAttendeePreview(allAttendees, t);
return (
<>
<Box display="flex" alignItems="flex-start">
<Box sx={{ ...infoIconSx, mt: 1 }}>
<PeopleAltOutlinedIcon />
</Box>
<Box style={{ marginBottom: 1, display: "flex", flexDirection: "row" }}>
<Box sx={{ marginRight: 2 }}>
<Typography>
{t("eventPreview.guests", { count: allAttendees.length })}
</Typography>
<Typography sx={{ fontSize: "13px", color: "text.secondary" }}>
{attendeePreview}
</Typography>
</Box>
{!showAllAttendees && (
<AvatarGroup max={ATTENDEE_DISPLAY_LIMIT}>
{organizer &&
renderAttendeeBadge(
organizer,
"org",
t,
showAllAttendees,
true
)}
{attendees.map((a, idx) =>
renderAttendeeBadge(a, idx.toString(), t, showAllAttendees)
)}
</AvatarGroup>
)}
<Typography
sx={{
cursor: "pointer",
marginLeft: 2,
fontSize: "14px",
color: "text.secondary",
alignSelf: "center",
}}
onClick={() => setShowAllAttendees((prev) => !prev)}
>
{showAllAttendees
? t("eventPreview.showLess")
: t("eventPreview.showMore")}
</Typography>
</Box>
</Box>
{showAllAttendees &&
organizer &&
renderAttendeeBadge(organizer, "org", t, showAllAttendees, true)}
{showAllAttendees &&
attendees.map((a, idx) =>
renderAttendeeBadge(a, idx.toString(), t, showAllAttendees)
)}
</>
);
}
@@ -0,0 +1,182 @@
import { InfoRow } from "@/components/Event/InfoRow";
import { renderAttendeeBadge } from "@/components/Event/utils/eventUtils";
import { AvatarGroup, 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";
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;
if (!showDetails) {
return (
<Box
sx={{
backgroundColor: "#F3F4F6",
height: 48,
borderRadius: "8px",
gap: "14px",
paddingTop: "14px",
paddingBottom: "14px",
}}
>
<Typography variant="caption" textAlign="center">
{t("eventPreview.privateEvent.hiddenDetails")}
</Typography>
</Box>
);
}
return (
<>
{/* Video */}
{event.x_openpass_videoconference && (
<InfoRow
alignItems="flex-start"
icon={
<Box sx={{ ...infoIconSx, mt: 1 }}>
<VideocamOutlinedIcon />
</Box>
}
content={
<Button
variant="contained"
size="medium"
onClick={() => window.open(event.x_openpass_videoconference)}
sx={{ borderRadius: "4px" }}
>
{t("eventPreview.joinVideo")}
</Button>
}
/>
)}
{/* Attendees */}
{attendees.length > 0 && (
<EventPreviewAttendees
attendees={attendees}
organizer={organizer}
allAttendees={event.attendee}
/>
)}
{/* Location */}
{event.location && (
<InfoRow
alignItems="flex-start"
icon={
<Box sx={infoIconSx}>
<LocationOnOutlinedIcon />
</Box>
}
text={event.location}
/>
)}
{/* Description */}
{event.description && (
<InfoRow
alignItems="flex-start"
icon={
<Box sx={infoIconSx}>
<SubjectIcon />
</Box>
}
text={event.description}
/>
)}
{/* Alarm */}
{event.alarm && (
<InfoRow
alignItems="flex-start"
icon={
<Box sx={infoIconSx}>
<NotificationsNoneIcon />
</Box>
}
text={t("eventPreview.alarmText", {
trigger: t(`event.form.notifications.${event.alarm.trigger}`),
action: (() => {
if (!event.alarm.action) return "";
const translationKey = `event.form.notifications.${event.alarm.action}`;
const translated = t(translationKey);
return translated === translationKey
? event.alarm.action
: translated;
})(),
})}
/>
)}
{/* Repetition */}
{event.repetition && (
<InfoRow
alignItems="flex-start"
icon={
<Box sx={infoIconSx}>
<RepeatIcon />
</Box>
}
text={makeRecurrenceString(event, t)}
/>
)}
{/* Error */}
{event.error && (
<InfoRow
alignItems="flex-start"
icon={
<Box sx={infoIconSx}>
<ErrorOutlineIcon color="error" />
</Box>
}
text={event.error}
error
/>
)}
</>
);
}
@@ -0,0 +1,85 @@
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";
interface EventPreviewHeaderProps {
event: CalendarEvent;
eventId: string;
isOrganizer: boolean;
isOwn: boolean;
isWriteDelegated: boolean;
isNotPrivate: boolean;
isRecurring: boolean;
onClose: () => void;
onEdit: () => void;
onMoreClick: (e: React.MouseEvent<HTMLElement>) => void;
}
export function EventPreviewHeader({
event,
eventId,
isOrganizer,
isOwn,
isWriteDelegated,
isNotPrivate,
isRecurring,
onClose,
onEdit,
onMoreClick,
}: EventPreviewHeaderProps) {
const canEdit = isOrganizer && (isOwn || (isWriteDelegated && isNotPrivate));
const canSeeMore = (isNotPrivate && !isOwn) || isOwn;
return (
<Box
display="flex"
justifyContent="flex-end"
alignItems="center"
gap={0.5}
width="100%"
>
{window.DEBUG && (
<IconButton
size="small"
onClick={async () => {
let url: string | null = null;
try {
const icsContent = await dlEvent(event);
const blob = new Blob([icsContent], { type: "text/calendar" });
url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${eventId}.ics`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error("Failed to download ICS file:", error);
} finally {
if (url) URL.revokeObjectURL(url);
}
}}
>
<FileDownloadOutlinedIcon />
</IconButton>
)}
{canEdit && (
<IconButton size="small" onClick={onEdit}>
<EditIcon />
</IconButton>
)}
{canSeeMore && (
<IconButton size="small" onClick={onMoreClick}>
<MoreVertIcon />
</IconButton>
)}
<IconButton size="small" onClick={onClose}>
<CloseIcon />
</IconButton>
</Box>
);
}
@@ -0,0 +1,237 @@
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 { DateSelectArg } from "@fullcalendar/core";
import { Box, Chip, Tooltip, Typography } from "@linagora/twake-mui";
import CircleIcon from "@mui/icons-material/Circle";
import LockOutlineIcon from "@mui/icons-material/LockOutline";
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 { formatDate } from "./utils/formatDate";
export default function EventPreviewModal({
eventId,
calId,
tempEvent,
open,
onClose,
}: {
eventId: string;
calId: string;
tempEvent?: boolean;
open: boolean;
onClose: (event: unknown, reason: "backdropClick" | "escapeKeyDown") => void;
}) {
const { t } = useI18n();
const {
event,
calendar,
user,
timezone,
contextualizedEvent,
attendanceUser,
isRecurring,
isOwn,
isWriteDelegated,
isOrganizer,
isNotPrivate,
openUpdateModal,
setOpenUpdateModal,
openDuplicateModal,
setOpenDuplicateModal,
hidePreview,
setHidePreview,
toggleActionMenu,
setToggleActionMenu,
openEditModePopup,
setOpenEditModePopup,
typeOfAction,
setTypeOfAction,
afterChoiceFunc,
setAfterChoiceFunc,
resolvedTypeOfAction,
handleEditClick,
handleDeleteClick,
handleDuplicateClick,
} = useEventPreviewState(eventId, calId, tempEvent, open, onClose);
useEffect(() => {
if (!event || !calendar) {
onClose({}, "backdropClick");
}
}, [event, calendar, onClose]);
if (!user || !event || !calendar) return null;
return (
<>
<ResponsiveDialog
open={open && !hidePreview}
onClose={() => onClose({}, "backdropClick")}
showHeaderActions={false}
actionsBorderTop={
!!(event.attendee?.find((p) => p.cal_address === user.email) && isOwn)
}
actionsJustifyContent="center"
style={{ overflow: "auto" }}
title={
<EventPreviewHeader
event={event}
eventId={eventId}
isOrganizer={isOrganizer}
isOwn={isOwn}
isWriteDelegated={isWriteDelegated}
isNotPrivate={isNotPrivate}
isRecurring={isRecurring}
onClose={() => onClose({}, "backdropClick")}
onEdit={handleEditClick}
onMoreClick={(e) => setToggleActionMenu(e.currentTarget)}
/>
}
actions={
contextualizedEvent && (
<AttendanceValidation
contextualizedEvent={contextualizedEvent}
user={attendanceUser}
setAfterChoiceFunc={setAfterChoiceFunc}
setOpenEditModePopup={setOpenEditModePopup}
/>
)
}
>
{/* Title & date row */}
<Box mb={3}>
<Box
display="flex"
flexDirection="row"
alignItems="center"
gap={1}
mb={1}
>
{event.class === "PRIVATE" &&
(isOwn ? (
<Tooltip
title={t("eventPreview.privateEvent.tooltipOwn")}
placement="top"
>
<LockOutlineIcon />
</Tooltip>
) : (
<LockOutlineIcon />
))}
<Typography
variant="h5"
sx={{
fontSize: "24px",
fontWeight: 600,
wordBreak: "break-word",
fontFamily: "Inter, sans-serif",
}}
>
{formatEventChipTitle(event, t)}
</Typography>
{event.transp === "TRANSPARENT" && (
<Tooltip title={t("eventPreview.free.tooltip")} placement="top">
<Chip
icon={<CircleIcon color="success" fontSize="small" />}
label={t("eventPreview.free.label")}
/>
</Tooltip>
)}
</Box>
<Typography color="text.secondaryContainer">
{formatDate(event.start, t, timezone, event.allday)}
{event.end &&
formatEnd(event.start, event.end, t, timezone, event.allday) &&
` ${formatEnd(event.start, event.end, t, timezone, event.allday)} ${!event.allday ? getTimezoneOffset(timezone, new Date(event.start)) : ""}`}
</Typography>
</Box>
{/* Event details (attendees, location, description, etc.) */}
<EventPreviewDetails
event={event}
isOwn={isOwn}
isNotPrivate={isNotPrivate}
userEmail={user.email}
/>
{/* Calendar label */}
<Box sx={{ display: "flex", alignItems: "flex-start", gap: 1, mb: 2 }}>
<CalendarName calendar={calendar} />
</Box>
</ResponsiveDialog>
{/* Action menu (more vert) */}
<EventPreviewActionMenu
anchorEl={toggleActionMenu}
event={event}
userEmail={user.email}
isOwn={isOwn}
isWriteDelegated={isWriteDelegated}
onClose={() => setToggleActionMenu(null)}
onDuplicate={handleDuplicateClick}
onDelete={handleDeleteClick}
/>
{/* Recurring edit/delete mode picker */}
<EditModeDialog
type={openEditModePopup}
setOpen={setOpenEditModePopup}
eventAction={(type: "solo" | "all" | undefined) => {
setTypeOfAction(type);
afterChoiceFunc?.(type);
}}
/>
{/* Edit modal */}
<EventUpdateModal
open={openUpdateModal}
onClose={() => {
setOpenUpdateModal(false);
setHidePreview(false);
}}
onCloseAll={() => {
setOpenUpdateModal(false);
onClose({}, "backdropClick");
}}
eventId={eventId}
calId={calId}
typeOfAction={resolvedTypeOfAction}
/>
{/* Duplicate modal */}
<EventPopover
anchorEl={null}
open={openDuplicateModal}
selectedRange={
{
start: new Date(event.start),
startStr: event.start,
end: new Date(event.end ?? ""),
endStr: event.end ?? "",
allDay: event.allday ?? false,
} as DateSelectArg
}
setSelectedRange={() => {}}
calendarRef={{ current: null }}
onClose={() => {
setOpenDuplicateModal(false);
onClose({}, "backdropClick");
}}
event={event}
/>
</>
);
}
@@ -0,0 +1,2 @@
export { default } from "./EventPreviewModal";
export { makeAttendeePreview } from "./utils/makeAttendeePreview";
@@ -0,0 +1,195 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { assertThunkSuccess } from "@/utils/assertThunkSuccess";
import { getEffectiveEmail } from "@/utils/getEffectiveEmail";
import { isEventOrganiser } from "@/utils/isEventOrganiser";
import { browserDefaultTimeZone } from "@/utils/timezone";
import { useState } from "react";
import { deleteEventAsync } from "../../Calendars/services";
import { ToUserData } from "../../User/type/OpenPaasUserData";
import { createEventContext } from "../createEventContext";
import { handleDelete } from "@/components/Event/eventHandlers/eventHandlers";
import { useEventUpdateModalReopen } from "./useEventUpdateModalReopen";
export function useEventPreviewState(
eventId: string,
calId: string,
tempEvent: boolean | undefined,
open: boolean,
onClose: (event: unknown, reason: "backdropClick" | "escapeKeyDown") => void
) {
const dispatch = useAppDispatch();
const calendars = useAppSelector((state) => state.calendars);
const timezone =
useAppSelector((state) => state.settings.timeZone) ??
browserDefaultTimeZone;
const user = useAppSelector((state) => state.user.userData);
const calendar = tempEvent
? calendars.templist[calId]
: calendars.list[calId];
const event = calendar?.events[eventId];
// Modal visibility
const [openUpdateModal, setOpenUpdateModal] = useState(false);
const [openDuplicateModal, setOpenDuplicateModal] = useState(false);
const [hidePreview, setHidePreview] = useState(false);
const [toggleActionMenu, setToggleActionMenu] = useState<Element | null>(
null
);
// Recurring event handling
const [openEditModePopup, setOpenEditModePopup] = useState<string | null>(
null
);
const [typeOfAction, setTypeOfAction] = useState<"solo" | "all" | undefined>(
undefined
);
const [afterChoiceFunc, setAfterChoiceFunc] = useState<
((type: "solo" | "all" | undefined) => void) | undefined
>();
// Reopen logic (sessionStorage + custom event)
useEventUpdateModalReopen({
open,
eventId,
calId,
typeOfAction,
setTypeOfAction,
setOpenUpdateModal,
setHidePreview,
});
// Derived access flags
const isRecurring = event?.uid?.includes("/") ?? false;
const isOwn = calendar?.owner?.emails?.includes(user?.email ?? "") ?? false;
const isDelegated = calendar?.delegated;
const isWriteDelegated = (isDelegated && calendar?.access?.write) ?? false;
const effectiveEmail = user
? getEffectiveEmail(calendar, isWriteDelegated, user.email)
: "";
const isOrganizer = event?.organizer
? isEventOrganiser(event, effectiveEmail)
: isOwn;
const isNotPrivate =
event?.class !== "PRIVATE" && event?.class !== "CONFIDENTIAL";
const contextualizedEvent =
event && calendar && user
? createEventContext(event, calendar, user)
: null;
const attendanceUser =
isWriteDelegated && calendar?.owner ? ToUserData(calendar.owner) : user;
// Resolve typeOfAction for EventUpdateModal (state or sessionStorage fallback)
const resolvedTypeOfAction = (() => {
if (typeOfAction) return typeOfAction;
try {
const stored = sessionStorage.getItem("eventUpdateModalReopen");
if (stored) {
const data = JSON.parse(stored);
if (
data.eventId === eventId &&
data.calId === calId &&
data.typeOfAction
) {
return data.typeOfAction as "solo" | "all";
}
}
} catch {
// Ignore
}
return undefined;
})();
// Action handlers
const handleEditClick = () => {
if (isRecurring) {
setAfterChoiceFunc(() => () => {
setHidePreview(true);
setOpenUpdateModal(true);
});
setOpenEditModePopup("edit");
} else {
setHidePreview(true);
setOpenUpdateModal(true);
}
};
const handleDeleteClick = async () => {
if (isRecurring) {
setAfterChoiceFunc(
() => (type?: "solo" | "all" | undefined) =>
handleDelete(
isRecurring,
type,
onClose,
dispatch,
calendar,
event,
calId,
eventId
)
);
setOpenEditModePopup("delete");
} else {
onClose({}, "backdropClick");
try {
const result = await dispatch(
deleteEventAsync({ calId, eventId, eventURL: event.URL })
);
assertThunkSuccess(result);
} catch (error) {
console.error("Failed to delete event:", error);
}
}
};
const handleDuplicateClick = () => {
setHidePreview(true);
setOpenDuplicateModal(true);
};
return {
// Data
event,
calendar,
user,
timezone,
contextualizedEvent,
attendanceUser,
// Access flags
isRecurring,
isOwn,
isWriteDelegated,
isOrganizer,
isNotPrivate,
// Modal state
openUpdateModal,
setOpenUpdateModal,
openDuplicateModal,
setOpenDuplicateModal,
hidePreview,
setHidePreview,
toggleActionMenu,
setToggleActionMenu,
// Recurring state
openEditModePopup,
setOpenEditModePopup,
typeOfAction,
setTypeOfAction,
afterChoiceFunc,
setAfterChoiceFunc,
resolvedTypeOfAction,
// Handlers
handleEditClick,
handleDeleteClick,
handleDuplicateClick,
dispatch,
};
}
@@ -0,0 +1,173 @@
import { useEffect, useRef } from "react";
interface UseEventUpdateModalReopenProps {
open: boolean;
eventId: string;
calId: string;
typeOfAction: "solo" | "all" | undefined;
setTypeOfAction: (type: "solo" | "all" | undefined) => void;
setOpenUpdateModal: (open: boolean) => void;
setHidePreview: (hide: boolean) => void;
}
function applyReopenData(
data: { typeOfAction?: "solo" | "all"; eventId: string; calId: string },
eventId: string,
calId: string,
typeOfAction: "solo" | "all" | undefined,
setTypeOfAction: (type: "solo" | "all" | undefined) => void,
setOpenUpdateModal: (open: boolean) => void,
setHidePreview: (hide: boolean) => void,
delayMs = 100,
clearSessionStorage = false
) {
const typeOfActionMatch =
data.typeOfAction === typeOfAction ||
data.typeOfAction === undefined ||
typeOfAction === undefined;
if (data.eventId !== eventId || data.calId !== calId || !typeOfActionMatch) {
return false;
}
const delay =
data.typeOfAction !== undefined && typeOfAction === undefined
? 50
: delayMs;
if (data.typeOfAction !== undefined && typeOfAction === undefined) {
setTypeOfAction(data.typeOfAction);
}
setTimeout(() => {
setOpenUpdateModal(true);
setHidePreview(true);
if (clearSessionStorage) {
try {
sessionStorage.removeItem("eventUpdateModalReopen");
} catch {
// Ignore sessionStorage errors
}
}
}, delay);
return true;
}
function readSessionStorage(): {
typeOfAction?: "solo" | "all";
eventId: string;
calId: string;
} | null {
try {
const stored = sessionStorage.getItem("eventUpdateModalReopen");
if (stored) return JSON.parse(stored);
} catch {
// Ignore
}
return null;
}
/**
* Handles re-opening the EventUpdateModal after a page navigation or API failure.
* Reads state from sessionStorage (for page reloads) and a custom DOM event
* (for in-session failures without reload).
*/
export function useEventUpdateModalReopen({
open,
eventId,
calId,
typeOfAction,
setTypeOfAction,
setOpenUpdateModal,
setHidePreview,
}: UseEventUpdateModalReopenProps) {
const hasCheckedSessionStorageRef = useRef(false);
// Check sessionStorage once when the preview opens
useEffect(() => {
if (!open) {
hasCheckedSessionStorageRef.current = false;
return;
}
if (hasCheckedSessionStorageRef.current) return;
hasCheckedSessionStorageRef.current = true;
const data = readSessionStorage();
if (data) {
applyReopenData(
data,
eventId,
calId,
typeOfAction,
setTypeOfAction,
setOpenUpdateModal,
setHidePreview,
100,
true
);
}
// typeOfAction intentionally omitted — we only want to run this once per open
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, eventId, calId]);
// Listen for the custom event dispatched on API failure
useEffect(() => {
const handleUpdateModalReopen = (e: CustomEvent) => {
const detail = e.detail;
const matched = applyReopenData(
detail,
eventId,
calId,
typeOfAction,
setTypeOfAction,
setOpenUpdateModal,
setHidePreview,
50,
true
);
if (matched) {
try {
sessionStorage.removeItem("eventUpdateModalReopen");
} catch {
// Ignore
}
}
};
// Also check sessionStorage whenever eventId/calId/typeOfAction change
const data = readSessionStorage();
if (data) {
applyReopenData(
data,
eventId,
calId,
typeOfAction,
setTypeOfAction,
setOpenUpdateModal,
setHidePreview,
100,
true
);
}
window.addEventListener(
"eventUpdateModalReopen",
handleUpdateModalReopen as EventListener
);
return () => {
window.removeEventListener(
"eventUpdateModalReopen",
handleUpdateModalReopen as EventListener
);
};
}, [
eventId,
calId,
typeOfAction,
setTypeOfAction,
setOpenUpdateModal,
setHidePreview,
]);
}
@@ -0,0 +1,27 @@
export function formatDate(
date: Date | string,
t: (k: string, p?: string | object) => string,
timeZone: string,
allday?: boolean
) {
if (allday) {
return new Date(date).toLocaleDateString(t("locale"), {
year: "numeric",
month: "long",
weekday: "long",
day: "numeric",
timeZone,
});
} else {
return new Date(date).toLocaleString(t("locale"), {
year: "numeric",
month: "long",
weekday: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone,
});
}
}
@@ -0,0 +1,46 @@
export function formatEnd(
start: Date | string,
end: Date | string,
t: (k: string, p?: string | object) => string,
timeZone: string,
allday?: boolean
) {
const startDate = new Date(start);
const endDate = new Date(end);
const formatDatePart = (d: Date) =>
d.toLocaleDateString("en-CA", { timeZone }); // YYYY-MM-DD format
if (allday) {
const inclusiveEndDate = new Date(endDate);
inclusiveEndDate.setDate(inclusiveEndDate.getDate() - 1);
const sameDay =
formatDatePart(startDate) === formatDatePart(inclusiveEndDate);
return sameDay
? null
: inclusiveEndDate.toLocaleDateString(t("locale"), {
year: "numeric",
month: "short",
day: "numeric",
timeZone,
});
} else {
const sameDay = formatDatePart(startDate) === formatDatePart(endDate);
if (sameDay) {
return endDate.toLocaleTimeString(t("locale"), {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone,
});
}
return endDate.toLocaleString(t("locale"), {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone,
});
}
}
@@ -0,0 +1,31 @@
import { userAttendee } from "@/src/features/User/models/attendee";
export function makeAttendeePreview(
attendees: userAttendee[],
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;
if (yesCount) {
attendeePreview.push(t("eventPreview.yesCount", { count: yesCount }));
}
if (maybeCount) {
attendeePreview.push(t("eventPreview.maybeCount", { count: maybeCount }));
}
if (needActionCount) {
attendeePreview.push(
t("eventPreview.needActionCount", { count: needActionCount })
);
}
if (noCount) {
attendeePreview.push(t("eventPreview.noCount", { count: noCount }));
}
return attendeePreview.join(", ");
}
@@ -0,0 +1,64 @@
import { CalendarEvent } from "../../EventsTypes";
export function makeRecurrenceString(
event: CalendarEvent,
t: (k: string, p?: string | object) => string
): string | undefined {
if (!event.repetition) return;
const recur: string[] = [
`${t("eventPreview.recurrentEvent")} · ${t(
`eventPreview.freq.${event.repetition.freq}`,
event.repetition.freq
)}`,
];
const recurType: Record<string, string> = {
daily: t("event.repeat.frequency.days"),
weekly: t("event.repeat.frequency.weeks"),
monthly: t("event.repeat.frequency.months"),
yearly: t("event.repeat.frequency.years"),
};
if (event.repetition.interval && event.repetition.interval > 1) {
recur.push(
t("eventPreview.everyInterval", {
interval: event.repetition.interval,
unit: recurType[event.repetition.freq] ?? event.repetition.freq,
})
);
}
if (event.repetition.byday) {
recur.push(
t("eventPreview.recurrenceOnDays", {
days: event.repetition.byday
.map((s) => t(`eventPreview.onDays.${s}`))
.join(", "),
})
);
}
if (event.repetition.occurrences) {
recur.push(
t("eventPreview.forOccurrences", {
count: event.repetition.occurrences,
})
);
}
if (event.repetition.endDate) {
recur.push(
t("eventPreview.until", {
date: new Date(event.repetition.endDate).toLocaleDateString(
t("locale"),
{
year: "numeric",
month: "long",
day: "numeric",
}
),
})
);
}
return recur.join(", ");
}
+1 -1
View File
@@ -18,7 +18,7 @@ import { useState } from "react";
import { useI18n } from "twake-i18n";
import logo from "../../static/noResult-logo.svg";
import { getEventAsync } from "../Calendars/services";
import EventPreviewModal from "../Events/EventDisplayPreview";
import EventPreviewModal from "@/features/Events/EventPreview";
import { CalendarEvent } from "../Events/EventsTypes";
import { setView } from "../Settings/SettingsSlice";
import "./searchResult.styl";