Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import EventDuplication from "../../src/components/Event/EventDuplicate";
|
||||
import EventDisplayModal from "../../src/features/Events/EventDisplay";
|
||||
import EventPopover from "../../src/features/Events/EventModal";
|
||||
import { renderWithProviders } from "../utils/Renderwithproviders";
|
||||
import EventPreviewModal from "../../src/features/Events/EventDisplayPreview";
|
||||
import EventPreviewModal from "../../src/components/Event/EventDisplayPreview";
|
||||
|
||||
const day = new Date();
|
||||
const preloadedState = {
|
||||
@@ -75,10 +75,9 @@ describe("EventDuplication", () => {
|
||||
preloadedState
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(screen.getByText("Duplicate event"));
|
||||
|
||||
expect(screen.getByText(/Duplicate Event/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Duplicate Event/i)[1]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onClose when closing the popover", () => {
|
||||
@@ -94,7 +93,7 @@ describe("EventDuplication", () => {
|
||||
preloadedState
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
fireEvent.click(screen.getByText("Duplicate event"));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Cancel/i }));
|
||||
|
||||
@@ -159,13 +158,13 @@ describe("EventDisplayModal", () => {
|
||||
calId="667037022b752d0026472254/cal1"
|
||||
open={true}
|
||||
onClose={jest.fn()}
|
||||
anchorPosition={{ top: 0, left: 0 }}
|
||||
/>,
|
||||
preloadedState
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("AddToPhotosIcon"));
|
||||
expect(screen.getByText(/Duplicate Event/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId("MoreVertIcon"));
|
||||
fireEvent.click(screen.getByText("Duplicate event"));
|
||||
expect(screen.getAllByText(/Duplicate Event/i)[1]).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByDisplayValue(
|
||||
preloadedState.calendars.list["667037022b752d0026472254/cal1"].events
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import {
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
act,
|
||||
cleanup,
|
||||
} from "@testing-library/react";
|
||||
import * as eventThunks from "../../../src/features/Calendars/CalendarSlice";
|
||||
import { renderWithProviders } from "../../utils/Renderwithproviders";
|
||||
import EventDisplayModal, {
|
||||
InfoRow,
|
||||
stringAvatar,
|
||||
import EventDisplayModal from "../../../src/features/Events/EventDisplay";
|
||||
import EventPreviewModal from "../../../src/components/Event/EventDisplayPreview";
|
||||
import { InfoRow } from "../../../src/components/Event/InfoRow";
|
||||
import {
|
||||
stringToColor,
|
||||
} from "../../../src/features/Events/EventDisplay";
|
||||
import EventPreviewModal from "../../../src/features/Events/EventDisplayPreview";
|
||||
stringAvatar,
|
||||
} from "../../../src/components/Event/utils/eventUtils";
|
||||
|
||||
describe("Event Preview Display", () => {
|
||||
const mockOnClose = jest.fn();
|
||||
@@ -104,7 +111,6 @@ describe("Event Preview Display", () => {
|
||||
});
|
||||
renderWithProviders(
|
||||
<EventPreviewModal
|
||||
anchorPosition={{ top: 0, left: 0 }}
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calId={"667037022b752d0026472254/cal1"}
|
||||
@@ -118,9 +124,8 @@ describe("Event Preview Display", () => {
|
||||
|
||||
expect(screen.getByText("Test Event")).toBeInTheDocument();
|
||||
expect(screen.getByText(new RegExp(weekday, "i"))).toBeInTheDocument();
|
||||
expect(screen.getByText(new RegExp(month, "i"))).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(new RegExp(`\\b${dayOfMonth}\\b`))
|
||||
screen.getByText(new RegExp(`\\b${dayOfMonth}\\b ${month}`))
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/\d{2}:\d{2} – \d{2}:\d{2}/)).toBeInTheDocument();
|
||||
@@ -129,7 +134,6 @@ describe("Event Preview Display", () => {
|
||||
it("calls onClose when Cancel clicked", () => {
|
||||
renderWithProviders(
|
||||
<EventPreviewModal
|
||||
anchorPosition={{ top: 0, left: 0 }}
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calId={"667037022b752d0026472254/cal1"}
|
||||
@@ -145,7 +149,6 @@ describe("Event Preview Display", () => {
|
||||
// Renders the other cal event
|
||||
renderWithProviders(
|
||||
<EventPreviewModal
|
||||
anchorPosition={{ top: 0, left: 0 }}
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calId={"otherCal/cal"}
|
||||
@@ -153,11 +156,12 @@ describe("Event Preview Display", () => {
|
||||
/>,
|
||||
preloadedState
|
||||
);
|
||||
expect(screen.queryByTestId("DeleteIcon")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId("MoreVertIcon"));
|
||||
expect(screen.queryByText("Delete event")).not.toBeInTheDocument();
|
||||
cleanup();
|
||||
// Renders the personnal cal event
|
||||
renderWithProviders(
|
||||
<EventPreviewModal
|
||||
anchorPosition={{ top: 0, left: 0 }}
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calId={"667037022b752d0026472254/cal1"}
|
||||
@@ -165,12 +169,12 @@ describe("Event Preview Display", () => {
|
||||
/>,
|
||||
preloadedState
|
||||
);
|
||||
expect(screen.queryByTestId("DeleteIcon")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId("MoreVertIcon"));
|
||||
expect(screen.queryByText("Delete event")).toBeInTheDocument();
|
||||
});
|
||||
it("calls delete when Delete clicked", async () => {
|
||||
renderWithProviders(
|
||||
<EventPreviewModal
|
||||
anchorPosition={{ top: 0, left: 0 }}
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calId={"667037022b752d0026472254/cal1"}
|
||||
@@ -183,8 +187,8 @@ describe("Event Preview Display", () => {
|
||||
.mockImplementation((payload) => {
|
||||
return () => Promise.resolve(payload) as any;
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("DeleteIcon"));
|
||||
fireEvent.click(screen.getByTestId("MoreVertIcon"));
|
||||
fireEvent.click(screen.getByText("Delete event"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(spy).toHaveBeenCalled();
|
||||
@@ -237,7 +241,6 @@ describe("Event Preview Display", () => {
|
||||
|
||||
renderWithProviders(
|
||||
<EventPreviewModal
|
||||
anchorPosition={{ top: 0, left: 0 }}
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calId={"667037022b752d0026472254/cal1"}
|
||||
@@ -246,7 +249,7 @@ describe("Event Preview Display", () => {
|
||||
rsvpStateIsOrga
|
||||
);
|
||||
|
||||
expect(screen.getByText("Will you attend?")).toBeInTheDocument();
|
||||
expect(screen.getByText("Attending?")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Accept" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Maybe" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Decline" })).toBeInTheDocument();
|
||||
@@ -284,7 +287,6 @@ describe("Event Preview Display", () => {
|
||||
|
||||
renderWithProviders(
|
||||
<EventPreviewModal
|
||||
anchorPosition={{ top: 0, left: 0 }}
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calId={"667037022b752d0026472254/cal1"}
|
||||
@@ -293,7 +295,7 @@ describe("Event Preview Display", () => {
|
||||
rsvpStateIsOrga
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Will you attend?")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Attending?")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Accept" })
|
||||
).not.toBeInTheDocument();
|
||||
@@ -344,7 +346,6 @@ describe("Event Preview Display", () => {
|
||||
|
||||
renderWithProviders(
|
||||
<EventPreviewModal
|
||||
anchorPosition={{ top: 0, left: 0 }}
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calId={"667037022b752d0026472254/cal1"}
|
||||
@@ -403,7 +404,6 @@ describe("Event Preview Display", () => {
|
||||
|
||||
renderWithProviders(
|
||||
<EventPreviewModal
|
||||
anchorPosition={{ top: 0, left: 0 }}
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calId={"667037022b752d0026472254/cal1"}
|
||||
@@ -462,7 +462,6 @@ describe("Event Preview Display", () => {
|
||||
|
||||
renderWithProviders(
|
||||
<EventPreviewModal
|
||||
anchorPosition={{ top: 0, left: 0 }}
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calId={"667037022b752d0026472254/cal1"}
|
||||
@@ -496,7 +495,6 @@ describe("Event Preview Display", () => {
|
||||
|
||||
renderWithProviders(
|
||||
<EventPreviewModal
|
||||
anchorPosition={{ top: 0, left: 0 }}
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calId={"667037022b752d0026472254/cal1"}
|
||||
@@ -516,7 +514,6 @@ describe("Event Preview Display", () => {
|
||||
(window as any).MAIL_SPA_URL = "test";
|
||||
renderWithProviders(
|
||||
<EventPreviewModal
|
||||
anchorPosition={{ top: 0, left: 0 }}
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calId={"667037022b752d0026472254/cal1"}
|
||||
@@ -524,13 +521,13 @@ describe("Event Preview Display", () => {
|
||||
/>,
|
||||
preloadedState
|
||||
);
|
||||
expect(screen.getByTestId("EmailIcon")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId("MoreVertIcon"));
|
||||
expect(screen.getByText("Email attendees")).toBeInTheDocument();
|
||||
});
|
||||
it("doesnt render message button when MAIL_SPA_URL is not null and event has no attendees", () => {
|
||||
(window as any).MAIL_SPA_URL = "test";
|
||||
renderWithProviders(
|
||||
<EventPreviewModal
|
||||
anchorPosition={{ top: 0, left: 0 }}
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calId={"667037022b752d0026472254/cal1"}
|
||||
@@ -543,7 +540,6 @@ describe("Event Preview Display", () => {
|
||||
it("doesnt render message button when MAIL_SPA_URL is null and event has attendees", () => {
|
||||
renderWithProviders(
|
||||
<EventPreviewModal
|
||||
anchorPosition={{ top: 0, left: 0 }}
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calId={"667037022b752d0026472254/cal1"}
|
||||
@@ -560,7 +556,6 @@ describe("Event Preview Display", () => {
|
||||
|
||||
renderWithProviders(
|
||||
<EventPreviewModal
|
||||
anchorPosition={{ top: 0, left: 0 }}
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calId={"667037022b752d0026472254/cal1"}
|
||||
@@ -569,10 +564,11 @@ describe("Event Preview Display", () => {
|
||||
preloadedState
|
||||
);
|
||||
|
||||
const emailButton = screen.getByTestId("EmailIcon");
|
||||
fireEvent.click(screen.getByTestId("MoreVertIcon"));
|
||||
const emailButton = screen.getByText("Email attendees");
|
||||
expect(emailButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(emailButton.closest("button")!);
|
||||
fireEvent.click(emailButton);
|
||||
|
||||
const event =
|
||||
preloadedState.calendars.list["667037022b752d0026472254/cal1"].events[
|
||||
@@ -1403,7 +1399,7 @@ describe("Event Full Display", () => {
|
||||
videoState
|
||||
);
|
||||
|
||||
expect(screen.getByText("Video conference available")).toBeInTheDocument();
|
||||
expect(screen.getByText("Join the video conference")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1416,7 +1412,7 @@ describe("Helper functions", () => {
|
||||
it("stringAvatar returns correct props", () => {
|
||||
const result = stringAvatar("Alice");
|
||||
expect(result.children).toBe("A");
|
||||
expect(result.sx.bgcolor).toMatch(/^#/);
|
||||
expect(result.style.backgroundColor).toMatch(/^#/);
|
||||
});
|
||||
|
||||
it("InfoRow renders text and link if url is valid", () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from "../../utils/dateUtils";
|
||||
import { Calendars } from "../../features/Calendars/CalendarTypes";
|
||||
import { push } from "redux-first-history";
|
||||
import EventPreviewModal from "../../features/Events/EventDisplayPreview";
|
||||
import EventPreviewModal from "../Event/EventDisplayPreview";
|
||||
import { createSelector } from "@reduxjs/toolkit";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import { TempCalendarsInput } from "./TempCalendarsInput";
|
||||
@@ -161,10 +161,7 @@ export default function CalendarApp({
|
||||
}, [rangeKey, Object.keys(tempcalendars).join(","), pending]);
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [anchorPosition, setAnchorPosition] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
} | null>(null);
|
||||
|
||||
const [openEventDisplay, setOpenEventDisplay] = useState(false);
|
||||
const [eventDisplayedId, setEventDisplayedId] = useState("");
|
||||
const [eventDisplayedTemp, setEventDisplayedTemp] = useState(false);
|
||||
@@ -187,7 +184,6 @@ export default function CalendarApp({
|
||||
calendarRange,
|
||||
dispatch,
|
||||
setOpenEventDisplay,
|
||||
setAnchorPosition,
|
||||
setEventDisplayedId,
|
||||
setEventDisplayedCalId,
|
||||
setEventDisplayedTemp,
|
||||
@@ -431,7 +427,6 @@ export default function CalendarApp({
|
||||
eventId={eventDisplayedId}
|
||||
calId={eventDisplayedCalId}
|
||||
tempEvent={eventDisplayedTemp}
|
||||
anchorPosition={anchorPosition}
|
||||
open={openEventDisplay}
|
||||
onClose={eventHandlers.handleCloseEventDisplay}
|
||||
/>
|
||||
|
||||
@@ -217,7 +217,7 @@ function CalendarSelector({
|
||||
<div>
|
||||
<label>
|
||||
<Checkbox
|
||||
sx={{
|
||||
style={{
|
||||
color: calendars[id].color,
|
||||
"&.Mui-checked": { color: calendars[id].color },
|
||||
}}
|
||||
|
||||
@@ -20,7 +20,6 @@ export interface EventHandlersProps {
|
||||
calendarRange: { start: Date; end: Date };
|
||||
dispatch: any;
|
||||
setOpenEventDisplay: (open: boolean) => void;
|
||||
setAnchorPosition: (position: { top: number; left: number } | null) => void;
|
||||
setEventDisplayedId: (id: string) => void;
|
||||
setEventDisplayedCalId: (id: string) => void;
|
||||
setEventDisplayedTemp: (temp: boolean) => void;
|
||||
@@ -37,7 +36,6 @@ export const createEventHandlers = (props: EventHandlersProps) => {
|
||||
calendarRange,
|
||||
dispatch,
|
||||
setOpenEventDisplay,
|
||||
setAnchorPosition,
|
||||
setEventDisplayedId,
|
||||
setEventDisplayedCalId,
|
||||
setEventDisplayedTemp,
|
||||
@@ -79,7 +77,6 @@ export const createEventHandlers = (props: EventHandlersProps) => {
|
||||
};
|
||||
|
||||
const handleCloseEventDisplay = () => {
|
||||
setAnchorPosition(null);
|
||||
setOpenEventDisplay(false);
|
||||
};
|
||||
|
||||
@@ -109,10 +106,6 @@ export const createEventHandlers = (props: EventHandlersProps) => {
|
||||
} else {
|
||||
console.log(info.event);
|
||||
setOpenEventDisplay(true);
|
||||
setAnchorPosition({
|
||||
top: info.jsEvent.clientY,
|
||||
left: info.jsEvent.clientX,
|
||||
});
|
||||
setEventDisplayedId(info.event.extendedProps.uid);
|
||||
setEventDisplayedCalId(info.event.extendedProps.calId);
|
||||
setEventDisplayedTemp(info.event._def.extendedProps.temp);
|
||||
|
||||
@@ -136,7 +136,12 @@ export const createViewHandlers = (props: ViewHandlersProps) => {
|
||||
const handleEventContent = (arg: any) => {
|
||||
const event = arg.event;
|
||||
const props = event._def.extendedProps;
|
||||
const { calId, temp, attendees = [], class: classification } = props;
|
||||
const {
|
||||
calId,
|
||||
temp,
|
||||
attendee: attendees = [],
|
||||
class: classification,
|
||||
} = props;
|
||||
|
||||
const calendarsSource = temp ? tempcalendars : calendars;
|
||||
const calendar = calendarsSource[calId];
|
||||
|
||||
@@ -23,13 +23,12 @@ export const useCalendarEventHandlers = (props: EventHandlersProps) => {
|
||||
]),
|
||||
handleCloseEventDisplay: useCallback(
|
||||
eventHandlers.handleCloseEventDisplay,
|
||||
[props.setAnchorPosition, props.setOpenEventDisplay]
|
||||
[props.setOpenEventDisplay]
|
||||
),
|
||||
handleMonthUp: useCallback(eventHandlers.handleMonthUp, []),
|
||||
handleMonthDown: useCallback(eventHandlers.handleMonthDown, []),
|
||||
handleEventClick: useCallback(eventHandlers.handleEventClick, [
|
||||
props.setOpenEventDisplay,
|
||||
props.setAnchorPosition,
|
||||
props.setEventDisplayedId,
|
||||
props.setEventDisplayedCalId,
|
||||
props.setEventDisplayedTemp,
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
|
||||
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 MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone";
|
||||
import PeopleAltOutlinedIcon from "@mui/icons-material/PeopleAltOutlined";
|
||||
import SubjectIcon from "@mui/icons-material/Subject";
|
||||
import VideocamIcon from "@mui/icons-material/Videocam";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
DialogActions,
|
||||
Divider,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import AvatarGroup from "@mui/material/AvatarGroup";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||
import {
|
||||
deleteEventAsync,
|
||||
getEventAsync,
|
||||
putEventAsync,
|
||||
} from "../../features/Calendars/CalendarSlice";
|
||||
import { dlEvent } from "../../features/Events/EventApi";
|
||||
import EventDisplayModal from "../../features/Events/EventDisplay";
|
||||
import { ResponsiveDialog } from "../Dialog";
|
||||
import EventDuplication from "./EventDuplicate";
|
||||
import { InfoRow } from "./InfoRow";
|
||||
import { renderAttendeeBadge } from "./utils/eventUtils";
|
||||
export default function EventPreviewModal({
|
||||
eventId,
|
||||
calId,
|
||||
tempEvent,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
eventId: string;
|
||||
calId: string;
|
||||
tempEvent?: boolean;
|
||||
open: boolean;
|
||||
onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void;
|
||||
}) {
|
||||
const dispatch = useAppDispatch();
|
||||
const calendars = useAppSelector((state) => state.calendars);
|
||||
const calendar = tempEvent
|
||||
? calendars.templist[calId]
|
||||
: calendars.list[calId];
|
||||
const event = calendar.events[eventId];
|
||||
const user = useAppSelector((state) => state.user);
|
||||
const [showAllAttendees, setShowAllAttendees] = useState(false);
|
||||
const [openFullDisplay, setOpenFullDisplay] = useState(false);
|
||||
const [toggleActionMenu, setToggleActionMenu] = useState<Element | null>(
|
||||
null
|
||||
);
|
||||
const mailSpaUrl = (window as any).MAIL_SPA_URL ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!event || !calendar) {
|
||||
onClose({}, "backdropClick");
|
||||
}
|
||||
}, [event, calendar, onClose]);
|
||||
|
||||
if (!event || !calendar) return null;
|
||||
|
||||
const attendeeDisplayLimit = 3;
|
||||
|
||||
const attendees =
|
||||
event.attendee?.filter(
|
||||
(a) => a.cal_address !== event.organizer?.cal_address
|
||||
) || [];
|
||||
|
||||
const visibleAttendees = showAllAttendees
|
||||
? attendees
|
||||
: attendees.slice(0, attendeeDisplayLimit);
|
||||
|
||||
const currentUserAttendee = event.attendee?.find(
|
||||
(person) => person.cal_address === user.userData.email
|
||||
);
|
||||
|
||||
const organizer = event.attendee?.find(
|
||||
(a) => a.cal_address === event.organizer?.cal_address
|
||||
);
|
||||
|
||||
function handleRSVP(rsvp: string) {
|
||||
const newEvent = {
|
||||
...event,
|
||||
attendee: event.attendee?.map((a) =>
|
||||
a.cal_address === user.userData.email ? { ...a, partstat: rsvp } : a
|
||||
),
|
||||
};
|
||||
|
||||
dispatch(putEventAsync({ cal: calendar, newEvent }));
|
||||
onClose({}, "backdropClick");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveDialog
|
||||
open={open}
|
||||
onClose={() => onClose({}, "backdropClick")}
|
||||
style={{ overflow: "auto" }}
|
||||
title={
|
||||
event.title && (
|
||||
<>
|
||||
<DialogActions>
|
||||
<Box>
|
||||
{(window as any).DEBUG && (
|
||||
<IconButton
|
||||
onClick={async () => {
|
||||
const icsContent = await dlEvent(event);
|
||||
const blob = new Blob([icsContent], {
|
||||
type: "text/calendar",
|
||||
});
|
||||
const 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);
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
>
|
||||
<FileDownloadOutlinedIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
{user.userData.email === event.organizer?.cal_address && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
setOpenFullDisplay(!openFullDisplay);
|
||||
await dispatch(getEventAsync(event));
|
||||
}}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<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.userData.email)
|
||||
.join(",")}?subject=${event.title}`
|
||||
)
|
||||
}
|
||||
>
|
||||
Email attendees
|
||||
</MenuItem>
|
||||
)}
|
||||
<EventDuplication event={event} onClose={onClose} />
|
||||
{user.userData.email === event.organizer?.cal_address && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
onClose({}, "backdropClick");
|
||||
dispatch(
|
||||
deleteEventAsync({
|
||||
calId,
|
||||
eventId,
|
||||
eventURL: event.URL,
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
Delete event
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onClose({}, "backdropClick")}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</DialogActions>
|
||||
<Typography
|
||||
variant="inherit"
|
||||
fontWeight="bold"
|
||||
style={{
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
gutterBottom
|
||||
>
|
||||
{event.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" gutterBottom>
|
||||
{formatDate(event.start, event.allday)}
|
||||
{event.end &&
|
||||
formatEnd(event.start, event.end, event.allday) &&
|
||||
` – ${formatEnd(event.start, event.end, event.allday)}`}
|
||||
</Typography>
|
||||
</>
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
{currentUserAttendee &&
|
||||
event.uid?.split("/")[1] &&
|
||||
"event is a reccuring event, the interraction between acceptance and recurrence is broken and to avoid loosing reccuring events by accepting or refusing them this feature isn't available for now"}
|
||||
{currentUserAttendee && !event.uid?.split("/")[1] && (
|
||||
<Box style={{ display: "flex", flexDirection: "row" }}>
|
||||
<Typography variant="body2">Attending?</Typography>
|
||||
<ButtonGroup size="small" fullWidth>
|
||||
<Button
|
||||
color={
|
||||
currentUserAttendee?.partstat === "ACCEPTED"
|
||||
? "success"
|
||||
: "primary"
|
||||
}
|
||||
onClick={() => handleRSVP("ACCEPTED")}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
color={
|
||||
currentUserAttendee?.partstat === "TENTATIVE"
|
||||
? "warning"
|
||||
: "primary"
|
||||
}
|
||||
onClick={() => handleRSVP("TENTATIVE")}
|
||||
>
|
||||
Maybe
|
||||
</Button>
|
||||
<Button
|
||||
color={
|
||||
currentUserAttendee?.partstat === "DECLINED"
|
||||
? "error"
|
||||
: "primary"
|
||||
}
|
||||
onClick={() => handleRSVP("DECLINED")}
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Video */}
|
||||
{event.x_openpass_videoconference && (
|
||||
<InfoRow
|
||||
icon={<VideocamIcon style={{ fontSize: 18 }} />}
|
||||
content={
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => window.open(event.x_openpass_videoconference)}
|
||||
>
|
||||
Join the video conference
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{/* Attendees */}
|
||||
{attendees?.length > 0 && (
|
||||
<>
|
||||
<InfoRow
|
||||
icon={<PeopleAltOutlinedIcon />}
|
||||
content={
|
||||
<Box
|
||||
style={{
|
||||
marginBottom: 1,
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography>{attendees.length} guests</Typography>
|
||||
<Typography>
|
||||
{
|
||||
attendees.filter((a) => a.partstat === "ACCEPTED")
|
||||
.length
|
||||
}{" "}
|
||||
yes,{" "}
|
||||
{
|
||||
attendees.filter((a) => a.partstat === "DECLINED")
|
||||
.length
|
||||
}{" "}
|
||||
no
|
||||
</Typography>
|
||||
</Box>
|
||||
{!showAllAttendees && (
|
||||
<AvatarGroup max={attendeeDisplayLimit}>
|
||||
{organizer &&
|
||||
renderAttendeeBadge(
|
||||
organizer,
|
||||
"org",
|
||||
showAllAttendees,
|
||||
true
|
||||
)}
|
||||
{visibleAttendees.map((a, idx) =>
|
||||
renderAttendeeBadge(a, idx.toString(), showAllAttendees)
|
||||
)}
|
||||
</AvatarGroup>
|
||||
)}
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="primary"
|
||||
style={{ cursor: "pointer", marginTop: 0.5 }}
|
||||
onClick={() => setShowAllAttendees(!showAllAttendees)}
|
||||
>
|
||||
{showAllAttendees ? "Show less" : `Show more `}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{showAllAttendees &&
|
||||
organizer &&
|
||||
renderAttendeeBadge(organizer, "org", showAllAttendees, true)}
|
||||
{showAllAttendees &&
|
||||
visibleAttendees.map((a, idx) =>
|
||||
renderAttendeeBadge(a, idx.toString(), showAllAttendees)
|
||||
)}
|
||||
{/* Location */}
|
||||
{event.location && (
|
||||
<InfoRow icon={<LocationOnOutlinedIcon />} text={event.location} />
|
||||
)}
|
||||
{/* Description */}
|
||||
{event.description && (
|
||||
<InfoRow icon={<SubjectIcon />} text={event.description} />
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{event.alarm && (
|
||||
<InfoRow
|
||||
icon={<NotificationsNoneIcon />}
|
||||
text={`${event.alarm.trigger} before by ${event.alarm.action}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{event.error && (
|
||||
<InfoRow
|
||||
icon={<ErrorOutlineIcon color="error" style={{ fontSize: 18 }} />}
|
||||
text={event.error}
|
||||
error
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Calendar color dot */}
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
<CalendarTodayIcon style={{ fontSize: 16 }} />
|
||||
<CircleIcon
|
||||
style={{
|
||||
color: calendar.color ?? "#3788D8",
|
||||
width: 12,
|
||||
height: 12,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2">{calendar.name}</Typography>
|
||||
</Box>
|
||||
|
||||
{currentUserAttendee && !event.repetition && (
|
||||
<>
|
||||
<Divider variant="fullWidth" />
|
||||
</>
|
||||
)}
|
||||
</ResponsiveDialog>
|
||||
<EventDisplayModal
|
||||
open={openFullDisplay}
|
||||
onClose={() => setOpenFullDisplay(false)}
|
||||
eventId={eventId}
|
||||
calId={calId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(date: Date | string, allday?: boolean) {
|
||||
if (allday) {
|
||||
return new Date(date).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
} else {
|
||||
return new Date(date).toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatEnd(start: Date | string, end: Date | string, allday?: boolean) {
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
|
||||
const sameDay =
|
||||
startDate.getFullYear() === endDate.getFullYear() &&
|
||||
startDate.getMonth() === endDate.getMonth() &&
|
||||
startDate.getDate() === endDate.getDate();
|
||||
|
||||
if (allday) {
|
||||
return sameDay
|
||||
? null
|
||||
: endDate.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} else {
|
||||
if (sameDay) {
|
||||
return endDate.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
return endDate.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconButton } from "@mui/material";
|
||||
import { IconButton, MenuItem } from "@mui/material";
|
||||
import { CalendarEvent } from "../../features/Events/EventsTypes";
|
||||
import AddToPhotosIcon from "@mui/icons-material/AddToPhotos";
|
||||
import { useRef, useState } from "react";
|
||||
@@ -30,14 +30,13 @@ export default function EventDuplication({
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
size="small"
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setOpenModal(true);
|
||||
}}
|
||||
>
|
||||
<AddToPhotosIcon fontSize="small" />
|
||||
</IconButton>
|
||||
Duplicate event
|
||||
</MenuItem>
|
||||
<EventPopover
|
||||
anchorEl={null}
|
||||
open={openModal}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import React from "react";
|
||||
import { Box, Typography, Link } from "@mui/material";
|
||||
|
||||
type InfoRowProps = {
|
||||
icon: React.ReactNode;
|
||||
text?: string;
|
||||
error?: boolean;
|
||||
data?: string; // optional link target
|
||||
content?: React.ReactNode; // if provided, overrides text rendering
|
||||
};
|
||||
|
||||
function detectUrls(text: string) {
|
||||
// Simple regex that captures whole URLs without splitting them apart
|
||||
const urlRegex = /(https?:\/\/[^\s]+|www\.[^\s]+)/gi;
|
||||
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
text.replace(urlRegex, (match, _, offset) => {
|
||||
// Push the text before the match
|
||||
if (lastIndex < offset) {
|
||||
parts.push(
|
||||
<React.Fragment key={lastIndex}>
|
||||
{text.slice(lastIndex, offset)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
// Normalize href
|
||||
const href = match.startsWith("http") ? match : `https://${match}`;
|
||||
parts.push(
|
||||
<Link
|
||||
key={offset}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="always"
|
||||
>
|
||||
{match}
|
||||
</Link>
|
||||
);
|
||||
|
||||
lastIndex = offset + match.length;
|
||||
return match;
|
||||
});
|
||||
|
||||
// Push remaining text after last URL
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(
|
||||
<React.Fragment key={lastIndex}>{text.slice(lastIndex)}</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function InfoRow({
|
||||
icon,
|
||||
text,
|
||||
error = false,
|
||||
data,
|
||||
content,
|
||||
}: InfoRowProps) {
|
||||
return (
|
||||
<Box style={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }}>
|
||||
{icon}
|
||||
{content ? (
|
||||
content
|
||||
) : (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={error ? "error" : "textPrimary"}
|
||||
style={{ wordBreak: "break-word" }}
|
||||
>
|
||||
{data ? (
|
||||
<Link
|
||||
href={data}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="always"
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
) : text ? (
|
||||
detectUrls(text)
|
||||
) : null}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import CancelIcon from "@mui/icons-material/Cancel";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Badge from "@mui/material/Badge";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { userAttendee } from "../../../features/User/userDataTypes";
|
||||
|
||||
export function renderAttendeeBadge(
|
||||
a: userAttendee,
|
||||
key: string,
|
||||
isFull?: boolean,
|
||||
isOrganizer?: boolean
|
||||
) {
|
||||
const classIcon =
|
||||
a.partstat === "ACCEPTED" ? (
|
||||
<CheckCircleIcon fontSize="inherit" color="success" />
|
||||
) : a.partstat === "DECLINED" ? (
|
||||
<CancelIcon fontSize="inherit" color="error" />
|
||||
) : null;
|
||||
|
||||
if (!isFull) {
|
||||
return <Avatar {...stringAvatar(a.cn || a.cal_address)} />;
|
||||
} else {
|
||||
return (
|
||||
<Box
|
||||
key={key}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
marginBottom: 0.5,
|
||||
padding: 0.5,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Badge
|
||||
overlap="circular"
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||
badgeContent={
|
||||
classIcon && (
|
||||
<Box
|
||||
style={{
|
||||
fontSize: 14,
|
||||
lineHeight: 0,
|
||||
backgroundColor: "white",
|
||||
borderRadius: "50%",
|
||||
padding: "1px",
|
||||
}}
|
||||
>
|
||||
{classIcon}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Avatar {...stringAvatar(a.cn || a.cal_address)} />
|
||||
</Badge>
|
||||
<Box style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
noWrap
|
||||
style={{
|
||||
maxWidth: "180px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{a.cn || a.cal_address}
|
||||
</Typography>
|
||||
{isOrganizer && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
style={{ fontStyle: "italic" }}
|
||||
>
|
||||
Organizer
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function stringToColor(string: string) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
hash = string.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
let color = "#";
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 0xff;
|
||||
color += `00${value.toString(16)}`.slice(-2);
|
||||
}
|
||||
|
||||
return color;
|
||||
}
|
||||
|
||||
export function stringAvatar(name: string) {
|
||||
return {
|
||||
style: { backgroundColor: stringToColor(name) },
|
||||
children: name[0],
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import "./Menubar.styl";
|
||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||
import { stringToColor } from "../../features/Events/EventDisplay";
|
||||
import { stringToColor } from "../Event/utils/eventUtils";
|
||||
import {
|
||||
Avatar,
|
||||
IconButton,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import CircleIcon from "@mui/icons-material/Circle";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
|
||||
import VideocamIcon from "@mui/icons-material/Videocam";
|
||||
import {
|
||||
deleteEventAsync,
|
||||
moveEventAsync,
|
||||
@@ -8,42 +12,34 @@ import {
|
||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||
import AttendeeSelector from "../../components/Attendees/AttendeeSearch";
|
||||
import {
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Card,
|
||||
CardActions,
|
||||
CardContent,
|
||||
Divider,
|
||||
IconButton,
|
||||
Avatar,
|
||||
Badge,
|
||||
Modal,
|
||||
TextField,
|
||||
CardHeader,
|
||||
Checkbox,
|
||||
Divider,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Modal,
|
||||
Select,
|
||||
SelectChangeEvent,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
CardActions,
|
||||
Link,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import VideocamIcon from "@mui/icons-material/Videocam";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
|
||||
import CircleIcon from "@mui/icons-material/Circle";
|
||||
import CancelIcon from "@mui/icons-material/Cancel";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import { useEffect, useState } from "react";
|
||||
import RepeatEvent from "../../components/Event/EventRepeat";
|
||||
import { InfoRow } from "../../components/Event/InfoRow";
|
||||
import { userAttendee } from "../User/userDataTypes";
|
||||
import { Calendars } from "../Calendars/CalendarTypes";
|
||||
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
|
||||
import { isValidUrl } from "../../utils/apiUtils";
|
||||
import { formatLocalDateTime } from "./EventModal";
|
||||
import RepeatEvent from "../../components/Event/EventRepeat";
|
||||
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
|
||||
import { renderAttendeeBadge } from "../../components/Event/utils/eventUtils";
|
||||
|
||||
export default function EventDisplayModal({
|
||||
eventId,
|
||||
@@ -406,8 +402,16 @@ export default function EventDisplayModal({
|
||||
{event.x_openpass_videoconference && (
|
||||
<InfoRow
|
||||
icon={<VideocamIcon style={{ fontSize: 18 }} />}
|
||||
text="Video conference available"
|
||||
data={event.x_openpass_videoconference}
|
||||
content={
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() =>
|
||||
window.open(event.x_openpass_videoconference)
|
||||
}
|
||||
>
|
||||
Join the video conference
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -416,13 +420,13 @@ export default function EventDisplayModal({
|
||||
<Box style={{ marginBottom: 8 }}>
|
||||
<Typography variant="subtitle2">Attendees:</Typography>
|
||||
{organizer.cal_address &&
|
||||
renderAttendeeBadge(organizer, "org", true)}
|
||||
renderAttendeeBadge(organizer, "org", true, true)}
|
||||
{(showAllAttendees
|
||||
? attendees
|
||||
: attendees.slice(0, attendeeDisplayLimit)
|
||||
).map((a, idx) => (
|
||||
<Box key={a.cal_address}>
|
||||
{renderAttendeeBadge(a, idx.toString())}
|
||||
{renderAttendeeBadge(a, idx.toString(), true)}
|
||||
{isOwn && (
|
||||
<IconButton
|
||||
size="small"
|
||||
@@ -568,119 +572,3 @@ export default function EventDisplayModal({
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoRow({
|
||||
icon,
|
||||
text,
|
||||
error = false,
|
||||
data,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
error?: boolean;
|
||||
data?: string;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}
|
||||
>
|
||||
{icon}
|
||||
<Typography variant="body2" color={error ? "error" : "textPrimary"}>
|
||||
{isValidUrl(data) ? <Link href={data}>{text}</Link> : text}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderAttendeeBadge(
|
||||
a: userAttendee,
|
||||
key: string,
|
||||
isOrganizer?: boolean
|
||||
) {
|
||||
const classIcon =
|
||||
a.partstat === "ACCEPTED" ? (
|
||||
<CheckCircleIcon fontSize="inherit" color="success" />
|
||||
) : a.partstat === "DECLINED" ? (
|
||||
<CancelIcon fontSize="inherit" color="error" />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={key}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
marginBottom: 4,
|
||||
padding: 4,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<Badge
|
||||
overlap="circular"
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
badgeContent={
|
||||
classIcon && (
|
||||
<Box
|
||||
style={{
|
||||
fontSize: 14,
|
||||
lineHeight: 0,
|
||||
backgroundColor: "white",
|
||||
borderRadius: "50%",
|
||||
padding: "1px",
|
||||
}}
|
||||
>
|
||||
{classIcon}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Avatar {...stringAvatar(a.cn || a.cal_address)} />
|
||||
</Badge>
|
||||
<Box style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
noWrap
|
||||
style={{
|
||||
maxWidth: "180px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{a.cn || a.cal_address}
|
||||
</Typography>
|
||||
{isOrganizer && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
style={{ fontStyle: "italic" }}
|
||||
>
|
||||
Organizer
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function stringToColor(string: string) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
hash = string.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
let color = "#";
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 0xff;
|
||||
color += `00${value.toString(16)}`.slice(-2);
|
||||
}
|
||||
|
||||
return color;
|
||||
}
|
||||
|
||||
export function stringAvatar(name: string) {
|
||||
return {
|
||||
sx: { width: 24, height: 24, fontSize: 18, bgcolor: stringToColor(name) },
|
||||
children: name[0],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,410 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
deleteEventAsync,
|
||||
getEventAsync,
|
||||
putEventAsync,
|
||||
} from "../Calendars/CalendarSlice";
|
||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||
import {
|
||||
Popover,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
ButtonGroup,
|
||||
Card,
|
||||
CardContent,
|
||||
Divider,
|
||||
IconButton,
|
||||
PopoverPosition,
|
||||
} from "@mui/material";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import EmailIcon from "@mui/icons-material/Email";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
|
||||
import LocationOnIcon from "@mui/icons-material/LocationOn";
|
||||
import VideocamIcon from "@mui/icons-material/Videocam";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
|
||||
import CircleIcon from "@mui/icons-material/Circle";
|
||||
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
|
||||
import EventDisplayModal, {
|
||||
InfoRow,
|
||||
renderAttendeeBadge,
|
||||
} from "./EventDisplay";
|
||||
import { dlEvent } from "./EventApi";
|
||||
import EventDuplication from "../../components/Event/EventDuplicate";
|
||||
|
||||
export default function EventPreviewModal({
|
||||
eventId,
|
||||
calId,
|
||||
tempEvent,
|
||||
anchorPosition,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
eventId: string;
|
||||
calId: string;
|
||||
tempEvent?: boolean;
|
||||
anchorPosition: PopoverPosition | null;
|
||||
open: boolean;
|
||||
onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void;
|
||||
}) {
|
||||
const dispatch = useAppDispatch();
|
||||
const calendars = useAppSelector((state) => state.calendars);
|
||||
const calendar = tempEvent
|
||||
? calendars.templist[calId]
|
||||
: calendars.list[calId];
|
||||
const event = calendar.events[eventId];
|
||||
const user = useAppSelector((state) => state.user);
|
||||
const [showAllAttendees, setShowAllAttendees] = useState(false);
|
||||
const [openFullDisplay, setOpenFullDisplay] = useState(false);
|
||||
const mailSpaUrl = (window as any).MAIL_SPA_URL ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!event || !calendar) {
|
||||
onClose({}, "backdropClick");
|
||||
}
|
||||
}, [event, calendar, onClose]);
|
||||
|
||||
if (!event || !calendar) return null;
|
||||
|
||||
const attendeeDisplayLimit = 3;
|
||||
|
||||
const attendees =
|
||||
event.attendee?.filter(
|
||||
(a) => a.cal_address !== event.organizer?.cal_address
|
||||
) || [];
|
||||
|
||||
const visibleAttendees = showAllAttendees
|
||||
? attendees
|
||||
: attendees.slice(0, attendeeDisplayLimit);
|
||||
|
||||
const currentUserAttendee = event.attendee?.find(
|
||||
(person) => person.cal_address === user.userData.email
|
||||
);
|
||||
|
||||
const organizer = event.attendee?.find(
|
||||
(a) => a.cal_address === event.organizer?.cal_address
|
||||
);
|
||||
|
||||
function handleRSVP(rsvp: string) {
|
||||
const newEvent = {
|
||||
...event,
|
||||
attendee: event.attendee?.map((a) =>
|
||||
a.cal_address === user.userData.email ? { ...a, partstat: rsvp } : a
|
||||
),
|
||||
};
|
||||
|
||||
dispatch(putEventAsync({ cal: calendar, newEvent }));
|
||||
onClose({}, "backdropClick");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
open={open}
|
||||
anchorReference="anchorPosition"
|
||||
anchorPosition={anchorPosition ?? undefined}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Card style={{ width: 300, padding: 16, position: "relative" }}>
|
||||
{/* Top-right buttons */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{(window as any).DEBUG && (
|
||||
<IconButton
|
||||
onClick={async () => {
|
||||
const icsContent = await dlEvent(event);
|
||||
const blob = new Blob([icsContent], {
|
||||
type: "text/calendar",
|
||||
});
|
||||
const 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);
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
>
|
||||
<FileDownloadOutlinedIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<EventDuplication event={event} onClose={onClose} />
|
||||
{mailSpaUrl && attendees.length > 0 && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`${mailSpaUrl}/mailto/?uri=mailto:${event.attendee
|
||||
.map((a) => a.cal_address)
|
||||
.filter((mail) => mail !== user.userData.email)
|
||||
.join(",")}?subject=${event.title}`
|
||||
)
|
||||
}
|
||||
>
|
||||
<EmailIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
{user.userData.email !== event.organizer?.cal_address && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setOpenFullDisplay(!openFullDisplay);
|
||||
}}
|
||||
>
|
||||
<VisibilityIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
{user.userData.email === event.organizer?.cal_address && (
|
||||
<>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
setOpenFullDisplay(!openFullDisplay);
|
||||
await dispatch(getEventAsync(event));
|
||||
}}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
onClose({}, "backdropClick");
|
||||
dispatch(
|
||||
deleteEventAsync({ calId, eventId, eventURL: event.URL })
|
||||
);
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onClose({}, "backdropClick")}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<CardContent style={{ paddingTop: 12 }}>
|
||||
{event.title && (
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight="bold"
|
||||
style={{
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
gutterBottom
|
||||
>
|
||||
{event.title}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Time info*/}
|
||||
<Typography variant="body2" color="textSecondary" gutterBottom>
|
||||
{formatDate(event.start, event.allday)}
|
||||
{event.end &&
|
||||
formatEnd(event.start, event.end, event.allday) &&
|
||||
` – ${formatEnd(event.start, event.end, event.allday)}`}
|
||||
</Typography>
|
||||
|
||||
{/* Location */}
|
||||
{event.location && (
|
||||
<InfoRow
|
||||
icon={<LocationOnIcon style={{ fontSize: 18 }} />}
|
||||
text={event.location}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Video */}
|
||||
{event.x_openpass_videoconference && (
|
||||
<InfoRow
|
||||
icon={<VideocamIcon style={{ fontSize: 18 }} />}
|
||||
text="Video conference available"
|
||||
data={event.x_openpass_videoconference}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Attendees */}
|
||||
{event.attendee?.length > 0 && (
|
||||
<Box style={{ marginBottom: 8 }}>
|
||||
<Typography variant="subtitle2">Attendees:</Typography>
|
||||
{organizer && renderAttendeeBadge(organizer, "org", true)}
|
||||
{visibleAttendees.map((a, idx) =>
|
||||
renderAttendeeBadge(a, idx.toString())
|
||||
)}
|
||||
{attendees.length > attendeeDisplayLimit && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="primary"
|
||||
style={{ cursor: "pointer", marginTop: 4 }}
|
||||
onClick={() => setShowAllAttendees(!showAllAttendees)}
|
||||
>
|
||||
{showAllAttendees
|
||||
? "Show less"
|
||||
: `Show more (${
|
||||
attendees.length - attendeeDisplayLimit
|
||||
} more)`}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{event.error && (
|
||||
<InfoRow
|
||||
icon={
|
||||
<ErrorOutlineIcon color="error" style={{ fontSize: 18 }} />
|
||||
}
|
||||
text={event.error}
|
||||
error
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Calendar color dot */}
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<CalendarTodayIcon style={{ fontSize: 16 }} />
|
||||
<CircleIcon
|
||||
style={{
|
||||
color: calendar.color ?? "#3788D8",
|
||||
width: 12,
|
||||
height: 12,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2">{calendar.name}</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider style={{ marginBottom: 8 }} />
|
||||
|
||||
{/* RSVP */}
|
||||
{currentUserAttendee && (
|
||||
<Box>
|
||||
<Typography variant="body2" style={{ marginBottom: 8 }}>
|
||||
Will you attend?
|
||||
</Typography>
|
||||
<ButtonGroup size="small" fullWidth>
|
||||
<Button
|
||||
color={
|
||||
currentUserAttendee.partstat === "ACCEPTED"
|
||||
? "success"
|
||||
: "primary"
|
||||
}
|
||||
onClick={() => handleRSVP("ACCEPTED")}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
color={
|
||||
currentUserAttendee.partstat === "TENTATIVE"
|
||||
? "warning"
|
||||
: "primary"
|
||||
}
|
||||
onClick={() => handleRSVP("TENTATIVE")}
|
||||
>
|
||||
Maybe
|
||||
</Button>
|
||||
<Button
|
||||
color={
|
||||
currentUserAttendee.partstat === "DECLINED"
|
||||
? "error"
|
||||
: "primary"
|
||||
}
|
||||
onClick={() => handleRSVP("DECLINED")}
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{event.description && (
|
||||
<Typography variant="body2" style={{ marginTop: 8 }}>
|
||||
{event.description}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Popover>
|
||||
<EventDisplayModal
|
||||
open={openFullDisplay}
|
||||
onClose={() => setOpenFullDisplay(false)}
|
||||
eventId={eventId}
|
||||
calId={calId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(date: Date, allday?: boolean) {
|
||||
if (allday) {
|
||||
return new Date(date).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
} else {
|
||||
return new Date(date).toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatEnd(start: Date, end: Date, allday?: boolean) {
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
|
||||
const sameDay =
|
||||
startDate.getFullYear() === endDate.getFullYear() &&
|
||||
startDate.getMonth() === endDate.getMonth() &&
|
||||
startDate.getDate() === endDate.getDate();
|
||||
|
||||
if (allday) {
|
||||
return sameDay
|
||||
? null
|
||||
: endDate.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} else {
|
||||
if (sameDay) {
|
||||
return endDate.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
return endDate.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user