From f41c3cbd8dd886f5f5b5b828c8a533c35ccc4c5d Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Wed, 11 Mar 2026 16:05:17 +0100 Subject: [PATCH] [refactor] extracted event display preview components in preparation for #625 --- __test__/components/DuplicateEvent.test.tsx | 2 +- .../Calendars/TimezoneInGrid.test.tsx | 2 +- .../features/Events/EventDisplay.test.tsx | 2 +- .../features/Events/EventRepetition.test.tsx | 2 +- src/components/Calendar/Calendar.tsx | 10 +- src/features/Events/EventDisplayPreview.tsx | 969 ------------------ .../EventPreview/EventPreviewActionMenu.tsx | 59 ++ .../EventPreview/EventPreviewAttendees.tsx | 88 ++ .../EventPreview/EventPreviewDetails.tsx | 182 ++++ .../EventPreview/EventPreviewHeader.tsx | 85 ++ .../Events/EventPreview/EventPreviewModal.tsx | 237 +++++ src/features/Events/EventPreview/index.ts | 2 + .../EventPreview/useEventPreviewState.ts | 195 ++++ .../EventPreview/useEventUpdateModalReopen.ts | 173 ++++ .../Events/EventPreview/utils/formatDate.ts | 27 + .../Events/EventPreview/utils/formatEnd.ts | 46 + .../EventPreview/utils/makeAttendeePreview.ts | 31 + .../utils/makeRecurrenceString.ts | 64 ++ src/features/Search/SearchResultsPage.tsx | 2 +- 19 files changed, 1199 insertions(+), 979 deletions(-) delete mode 100644 src/features/Events/EventDisplayPreview.tsx create mode 100644 src/features/Events/EventPreview/EventPreviewActionMenu.tsx create mode 100644 src/features/Events/EventPreview/EventPreviewAttendees.tsx create mode 100644 src/features/Events/EventPreview/EventPreviewDetails.tsx create mode 100644 src/features/Events/EventPreview/EventPreviewHeader.tsx create mode 100644 src/features/Events/EventPreview/EventPreviewModal.tsx create mode 100644 src/features/Events/EventPreview/index.ts create mode 100644 src/features/Events/EventPreview/useEventPreviewState.ts create mode 100644 src/features/Events/EventPreview/useEventUpdateModalReopen.ts create mode 100644 src/features/Events/EventPreview/utils/formatDate.ts create mode 100644 src/features/Events/EventPreview/utils/formatEnd.ts create mode 100644 src/features/Events/EventPreview/utils/makeAttendeePreview.ts create mode 100644 src/features/Events/EventPreview/utils/makeRecurrenceString.ts diff --git a/__test__/components/DuplicateEvent.test.tsx b/__test__/components/DuplicateEvent.test.tsx index f112db5..e118d7a 100644 --- a/__test__/components/DuplicateEvent.test.tsx +++ b/__test__/components/DuplicateEvent.test.tsx @@ -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"; diff --git a/__test__/features/Calendars/TimezoneInGrid.test.tsx b/__test__/features/Calendars/TimezoneInGrid.test.tsx index 920fcc0..5923f86 100644 --- a/__test__/features/Calendars/TimezoneInGrid.test.tsx +++ b/__test__/features/Calendars/TimezoneInGrid.test.tsx @@ -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"; diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx index 16ea72d..a42bcd8 100644 --- a/__test__/features/Events/EventDisplay.test.tsx +++ b/__test__/features/Events/EventDisplay.test.tsx @@ -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"; diff --git a/__test__/features/Events/EventRepetition.test.tsx b/__test__/features/Events/EventRepetition.test.tsx index 0071ee4..4eb80af 100644 --- a/__test__/features/Events/EventRepetition.test.tsx +++ b/__test__/features/Events/EventRepetition.test.tsx @@ -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"; diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index 4c8b413..fee6832 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -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 = { diff --git a/src/features/Events/EventDisplayPreview.tsx b/src/features/Events/EventDisplayPreview.tsx deleted file mode 100644 index 3461fdb..0000000 --- a/src/features/Events/EventDisplayPreview.tsx +++ /dev/null @@ -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( - 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( - 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 ( - <> - onClose({}, "backdropClick")} - showHeaderActions={false} - actionsBorderTop={currentUserAttendee && isOwn} - actionsJustifyContent="center" - style={{ overflow: "auto" }} - title={ - - {window.DEBUG && ( - { - 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); - } - }} - > - - - )} - {isOrganizer && (isOwn || (isWriteDelegated && isNotPrivate)) && ( - { - if (isRecurring) { - setAfterChoiceFunc(() => () => { - setHidePreview(true); - setOpenUpdateModal(true); - }); - setOpenEditModePopup("edit"); - } else { - setHidePreview(true); - setOpenUpdateModal(true); - } - }} - > - - - )} - {((isNotPrivate && !isOwn) || isOwn) && ( - setToggleActionMenu(e.currentTarget)} - > - - - )} - setToggleActionMenu(null)} - anchorEl={toggleActionMenu} - > - {mailSpaUrl && attendees.length > 0 && ( - - 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")} - - )} - { - setToggleActionMenu(null); - setHidePreview(true); - setOpenDuplicateModal(true); - }} - /> - {(isOwn || isWriteDelegated) && ( - { - 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")} - - )} - - onClose({}, "backdropClick")} - > - - - - } - actions={ - - } - > - - - {event.class === "PRIVATE" && - (isOwn ? ( - - - - ) : ( - - ))} - - {formatEventChipTitle(event, t)} - - {event.transp === "TRANSPARENT" && ( - - } - label={t("eventPreview.free.label")} - /> - - )} - - - {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)) : ""}`} - - - {((isNotPrivate && !isOwn) || isOwn) && ( - <> - {/* Video */} - {event.x_openpass_videoconference && ( - - - - } - content={ - - } - /> - )} - {/* Attendees */} - {attendees?.length > 0 && ( - <> - - - - } - content={ - - - - {t("eventPreview.guests", { - count: event.attendee.length, - })} - - - {attendeePreview} - - - {!showAllAttendees && ( - - {organizer && - renderAttendeeBadge( - organizer, - "org", - t, - showAllAttendees, - true - )} - {attendees.map((a, idx) => - renderAttendeeBadge( - a, - idx.toString(), - t, - showAllAttendees - ) - )} - - )} - setShowAllAttendees(!showAllAttendees)} - > - {showAllAttendees - ? t("eventPreview.showLess") - : t("eventPreview.showMore")} - - - } - /> - - )} - {showAllAttendees && - organizer && - renderAttendeeBadge(organizer, "org", t, showAllAttendees, true)} - {showAllAttendees && - attendees.map((a, idx) => - renderAttendeeBadge(a, idx.toString(), t, showAllAttendees) - )} - {/* Location */} - {event.location && ( - - - - } - text={event.location} - style={{ - fontSize: "16px", - fontFamily: "'Inter', sans-serif", - }} - /> - )} - {/* Description */} - {event.description && ( - - - - } - text={event.description} - style={{ - fontSize: "16px", - fontFamily: "'Inter', sans-serif", - }} - /> - )} - {/* ALARM */} - {event.alarm && ( - - - - } - 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 && ( - - - - } - text={makeRecurrenceString(event, t)} - style={{ - fontSize: "16px", - fontFamily: "'Inter', sans-serif", - }} - /> - )} - - )} - {!isNotPrivate && !isOwn && ( - - - {t("eventPreview.privateEvent.hiddenDetails")} - - - )} - {/* Error */} - {event.error && ( - - - - } - text={event.error} - style={{ - fontSize: "16px", - fontFamily: "'Inter', sans-serif", - }} - error - /> - )} - {/* Calendar color dot */} - - - - - { - setTypeOfAction(type); - if (afterChoiceFunc) { - afterChoiceFunc(type); - } - }} - /> - { - 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; - })()} - /> - {}} - 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 = { - 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(", "); -} diff --git a/src/features/Events/EventPreview/EventPreviewActionMenu.tsx b/src/features/Events/EventPreview/EventPreviewActionMenu.tsx new file mode 100644 index 0000000..8fb210b --- /dev/null +++ b/src/features/Events/EventPreview/EventPreviewActionMenu.tsx @@ -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 ( + + {mailSpaUrl && otherAttendees.length > 0 && ( + + window.open( + `${mailSpaUrl}/mailto/?uri=mailto:${otherAttendees + .map((a) => a.cal_address) + .join(",")}&subject=${encodeURIComponent(event.title ?? "")}` + ) + } + > + {t("eventPreview.emailAttendees")} + + )} + { + onClose(); + onDuplicate(); + }} + /> + {(isOwn || isWriteDelegated) && ( + {t("eventPreview.deleteEvent")} + )} + + ); +} diff --git a/src/features/Events/EventPreview/EventPreviewAttendees.tsx b/src/features/Events/EventPreview/EventPreviewAttendees.tsx new file mode 100644 index 0000000..4da1b33 --- /dev/null +++ b/src/features/Events/EventPreview/EventPreviewAttendees.tsx @@ -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 ( + <> + + + + + + + + {t("eventPreview.guests", { count: allAttendees.length })} + + + {attendeePreview} + + + {!showAllAttendees && ( + + {organizer && + renderAttendeeBadge( + organizer, + "org", + t, + showAllAttendees, + true + )} + {attendees.map((a, idx) => + renderAttendeeBadge(a, idx.toString(), t, showAllAttendees) + )} + + )} + setShowAllAttendees((prev) => !prev)} + > + {showAllAttendees + ? t("eventPreview.showLess") + : t("eventPreview.showMore")} + + + + + {showAllAttendees && + organizer && + renderAttendeeBadge(organizer, "org", t, showAllAttendees, true)} + {showAllAttendees && + attendees.map((a, idx) => + renderAttendeeBadge(a, idx.toString(), t, showAllAttendees) + )} + + ); +} diff --git a/src/features/Events/EventPreview/EventPreviewDetails.tsx b/src/features/Events/EventPreview/EventPreviewDetails.tsx new file mode 100644 index 0000000..04af345 --- /dev/null +++ b/src/features/Events/EventPreview/EventPreviewDetails.tsx @@ -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 ( + + + {t("eventPreview.privateEvent.hiddenDetails")} + + + ); + } + + return ( + <> + {/* Video */} + {event.x_openpass_videoconference && ( + + + + } + content={ + + } + /> + )} + + {/* Attendees */} + {attendees.length > 0 && ( + + )} + + {/* Location */} + {event.location && ( + + + + } + text={event.location} + /> + )} + + {/* Description */} + {event.description && ( + + + + } + text={event.description} + /> + )} + + {/* Alarm */} + {event.alarm && ( + + + + } + 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 && ( + + + + } + text={makeRecurrenceString(event, t)} + /> + )} + + {/* Error */} + {event.error && ( + + + + } + text={event.error} + error + /> + )} + + ); +} diff --git a/src/features/Events/EventPreview/EventPreviewHeader.tsx b/src/features/Events/EventPreview/EventPreviewHeader.tsx new file mode 100644 index 0000000..8bd3cc3 --- /dev/null +++ b/src/features/Events/EventPreview/EventPreviewHeader.tsx @@ -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) => 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 ( + + {window.DEBUG && ( + { + 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); + } + }} + > + + + )} + {canEdit && ( + + + + )} + {canSeeMore && ( + + + + )} + + + + + ); +} diff --git a/src/features/Events/EventPreview/EventPreviewModal.tsx b/src/features/Events/EventPreview/EventPreviewModal.tsx new file mode 100644 index 0000000..6b686e9 --- /dev/null +++ b/src/features/Events/EventPreview/EventPreviewModal.tsx @@ -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 ( + <> + onClose({}, "backdropClick")} + showHeaderActions={false} + actionsBorderTop={ + !!(event.attendee?.find((p) => p.cal_address === user.email) && isOwn) + } + actionsJustifyContent="center" + style={{ overflow: "auto" }} + title={ + onClose({}, "backdropClick")} + onEdit={handleEditClick} + onMoreClick={(e) => setToggleActionMenu(e.currentTarget)} + /> + } + actions={ + contextualizedEvent && ( + + ) + } + > + {/* Title & date row */} + + + {event.class === "PRIVATE" && + (isOwn ? ( + + + + ) : ( + + ))} + + {formatEventChipTitle(event, t)} + + {event.transp === "TRANSPARENT" && ( + + } + label={t("eventPreview.free.label")} + /> + + )} + + + {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)) : ""}`} + + + + {/* Event details (attendees, location, description, etc.) */} + + + {/* Calendar label */} + + + + + + {/* Action menu (more vert) */} + setToggleActionMenu(null)} + onDuplicate={handleDuplicateClick} + onDelete={handleDeleteClick} + /> + + {/* Recurring edit/delete mode picker */} + { + setTypeOfAction(type); + afterChoiceFunc?.(type); + }} + /> + + {/* Edit modal */} + { + setOpenUpdateModal(false); + setHidePreview(false); + }} + onCloseAll={() => { + setOpenUpdateModal(false); + onClose({}, "backdropClick"); + }} + eventId={eventId} + calId={calId} + typeOfAction={resolvedTypeOfAction} + /> + + {/* Duplicate modal */} + {}} + calendarRef={{ current: null }} + onClose={() => { + setOpenDuplicateModal(false); + onClose({}, "backdropClick"); + }} + event={event} + /> + + ); +} diff --git a/src/features/Events/EventPreview/index.ts b/src/features/Events/EventPreview/index.ts new file mode 100644 index 0000000..aa7f1fe --- /dev/null +++ b/src/features/Events/EventPreview/index.ts @@ -0,0 +1,2 @@ +export { default } from "./EventPreviewModal"; +export { makeAttendeePreview } from "./utils/makeAttendeePreview"; diff --git a/src/features/Events/EventPreview/useEventPreviewState.ts b/src/features/Events/EventPreview/useEventPreviewState.ts new file mode 100644 index 0000000..66041cb --- /dev/null +++ b/src/features/Events/EventPreview/useEventPreviewState.ts @@ -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( + null + ); + + // Recurring event handling + const [openEditModePopup, setOpenEditModePopup] = useState( + 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, + }; +} diff --git a/src/features/Events/EventPreview/useEventUpdateModalReopen.ts b/src/features/Events/EventPreview/useEventUpdateModalReopen.ts new file mode 100644 index 0000000..33be010 --- /dev/null +++ b/src/features/Events/EventPreview/useEventUpdateModalReopen.ts @@ -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, + ]); +} diff --git a/src/features/Events/EventPreview/utils/formatDate.ts b/src/features/Events/EventPreview/utils/formatDate.ts new file mode 100644 index 0000000..2321919 --- /dev/null +++ b/src/features/Events/EventPreview/utils/formatDate.ts @@ -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, + }); + } +} diff --git a/src/features/Events/EventPreview/utils/formatEnd.ts b/src/features/Events/EventPreview/utils/formatEnd.ts new file mode 100644 index 0000000..acbe35d --- /dev/null +++ b/src/features/Events/EventPreview/utils/formatEnd.ts @@ -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, + }); + } +} diff --git a/src/features/Events/EventPreview/utils/makeAttendeePreview.ts b/src/features/Events/EventPreview/utils/makeAttendeePreview.ts new file mode 100644 index 0000000..176fc73 --- /dev/null +++ b/src/features/Events/EventPreview/utils/makeAttendeePreview.ts @@ -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(", "); +} diff --git a/src/features/Events/EventPreview/utils/makeRecurrenceString.ts b/src/features/Events/EventPreview/utils/makeRecurrenceString.ts new file mode 100644 index 0000000..7a7632f --- /dev/null +++ b/src/features/Events/EventPreview/utils/makeRecurrenceString.ts @@ -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 = { + 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(", "); +} diff --git a/src/features/Search/SearchResultsPage.tsx b/src/features/Search/SearchResultsPage.tsx index 022baca..effc815 100644 --- a/src/features/Search/SearchResultsPage.tsx +++ b/src/features/Search/SearchResultsPage.tsx @@ -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";