[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 { RootState } from "@/app/store";
|
||||||
import EventDuplication from "@/components/Event/EventDuplicate";
|
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 EventPopover from "@/features/Events/EventModal";
|
||||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
import { renderWithProviders } from "../utils/Renderwithproviders";
|
import { renderWithProviders } from "../utils/Renderwithproviders";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import CalendarApp from "@/components/Calendar/Calendar";
|
import CalendarApp from "@/components/Calendar/Calendar";
|
||||||
import * as calendarUtils from "@/components/Calendar/utils/calendarUtils";
|
import * as calendarUtils from "@/components/Calendar/utils/calendarUtils";
|
||||||
import { updateSlotLabelVisibility } 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 { CalendarEvent } from "@/features/Events/EventsTypes";
|
||||||
import * as SettingsSlice from "@/features/Settings/SettingsSlice";
|
import * as SettingsSlice from "@/features/Settings/SettingsSlice";
|
||||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from "@/components/Event/utils/eventUtils";
|
} from "@/components/Event/utils/eventUtils";
|
||||||
import { DelegationAccess } from "@/features/Calendars/CalendarTypes";
|
import { DelegationAccess } from "@/features/Calendars/CalendarTypes";
|
||||||
import * as eventThunks from "@/features/Calendars/services";
|
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 { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
import { renderWithProviders } from "../../utils/Renderwithproviders";
|
import { renderWithProviders } from "../../utils/Renderwithproviders";
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
import { EditModeDialog } from "@/components/Event/EditModeDialog";
|
import { EditModeDialog } from "@/components/Event/EditModeDialog";
|
||||||
import * as eventThunks from "@/features/Calendars/services";
|
import * as eventThunks from "@/features/Calendars/services";
|
||||||
import * as EventApi from "@/features/Events/EventApi";
|
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 EventUpdateModal from "@/features/Events/EventUpdateModal";
|
||||||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
import preview from "jest-preview";
|
import preview from "jest-preview";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useAppDispatch, useAppSelector } from "@/app/hooks";
|
import { useAppDispatch, useAppSelector } from "@/app/hooks";
|
||||||
import EventPreviewModal from "@/features/Events/EventDisplayPreview";
|
|
||||||
import EventPopover from "@/features/Events/EventModal";
|
import EventPopover from "@/features/Events/EventModal";
|
||||||
|
import EventPreviewModal from "@/features/Events/EventPreview";
|
||||||
import { CalendarEvent } from "@/features/Events/EventsTypes";
|
import { CalendarEvent } from "@/features/Events/EventsTypes";
|
||||||
import ImportAlert from "@/features/Events/ImportAlert";
|
import ImportAlert from "@/features/Events/ImportAlert";
|
||||||
import SearchResultsPage from "@/features/Search/SearchResultsPage";
|
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 AddIcon from "@mui/icons-material/Add";
|
||||||
import { MutableRefObject, useEffect, useMemo, useRef, useState } from "react";
|
import { MutableRefObject, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useI18n } from "twake-i18n";
|
import { useI18n } from "twake-i18n";
|
||||||
|
import { useCalendarDataLoader } from "../../features/Calendars/useCalendarLoader";
|
||||||
import { User } from "../Attendees/PeopleSearch";
|
import { User } from "../Attendees/PeopleSearch";
|
||||||
import { EventErrorSnackbar } from "../Error/ErrorSnackbar";
|
import { EventErrorSnackbar } from "../Error/ErrorSnackbar";
|
||||||
import { EventErrorHandler } from "../Error/EventErrorHandler";
|
import { EventErrorHandler } from "../Error/EventErrorHandler";
|
||||||
@@ -37,12 +38,11 @@ import { useCalendarViewHandlers } from "./hooks/useCalendarViewHandlers";
|
|||||||
import { MiniCalendar } from "./MiniCalendar";
|
import { MiniCalendar } from "./MiniCalendar";
|
||||||
import { TempCalendarsInput } from "./TempCalendarsInput";
|
import { TempCalendarsInput } from "./TempCalendarsInput";
|
||||||
import { TimezoneSelector } from "./TimezoneSelector";
|
import { TimezoneSelector } from "./TimezoneSelector";
|
||||||
import { useCalendarDataLoader } from "../../features/Calendars/useCalendarLoader";
|
|
||||||
import { updateDarkColor } from "./utils/calendarColorsUtils";
|
import { updateDarkColor } from "./utils/calendarColorsUtils";
|
||||||
import {
|
import {
|
||||||
eventToFullCalendarFormat,
|
eventToFullCalendarFormat,
|
||||||
extractEvents,
|
extractEvents,
|
||||||
updateSlotLabelVisibility,
|
updateSlotLabelVisibility
|
||||||
} from "./utils/calendarUtils";
|
} from "./utils/calendarUtils";
|
||||||
|
|
||||||
const localeMap: Record<string, LocaleInput | undefined> = {
|
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 { useI18n } from "twake-i18n";
|
||||||
import logo from "../../static/noResult-logo.svg";
|
import logo from "../../static/noResult-logo.svg";
|
||||||
import { getEventAsync } from "../Calendars/services";
|
import { getEventAsync } from "../Calendars/services";
|
||||||
import EventPreviewModal from "../Events/EventDisplayPreview";
|
import EventPreviewModal from "@/features/Events/EventPreview";
|
||||||
import { CalendarEvent } from "../Events/EventsTypes";
|
import { CalendarEvent } from "../Events/EventsTypes";
|
||||||
import { setView } from "../Settings/SettingsSlice";
|
import { setView } from "../Settings/SettingsSlice";
|
||||||
import "./searchResult.styl";
|
import "./searchResult.styl";
|
||||||
|
|||||||
Reference in New Issue
Block a user