[refactor] extracted event display preview components in preparation for #625
This commit is contained in:
committed by
Benoit TELLIER
parent
0dd7f1b2fd
commit
f41c3cbd8d
@@ -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";
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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(", ");
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user