[#3] added pretty attendee list and tests + fixed broken tests due to dates

This commit is contained in:
Camille Moussu
2025-07-29 11:09:21 +02:00
parent a5c7265884
commit 2377cde057
4 changed files with 462 additions and 185 deletions
+16 -23
View File
@@ -47,10 +47,10 @@ describe("MiniCalendar", () => {
it("renders mini calendar with today in orange", async () => {
renderCalendar();
const today = new Date().getDate().toString();
const todayTile = screen
.getAllByText(today)
.find((el) => el.tagName.toLowerCase() === "abbr");
const today = new Date();
const dateTestId = `date-${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
const todayTile = screen.getByTestId(dateTestId);
expect(todayTile?.parentElement).toHaveClass("today");
});
@@ -65,13 +65,10 @@ describe("MiniCalendar", () => {
const date = new Date(sunday);
date.setDate(sunday.getDate() + i);
const dateTestId = `date-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
preview.debug();
const tile = screen.getByTestId(dateTestId);
if (date.getTime() !== today.setHours(0, 0, 0, 0)) {
preview.debug();
expect(tile?.parentElement).toHaveClass("selectedWeek");
}
}
@@ -84,10 +81,10 @@ describe("MiniCalendar", () => {
const dayViewButton = await screen.findByTitle(/day view/i);
fireEvent.click(dayViewButton);
const today = new Date().getDate().toString();
const todayTile = screen
.getAllByText(today)
.find((el) => el.tagName.toLowerCase() === "abbr");
const today = new Date();
const dateTestId = `date-${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
const todayTile = screen.getByTestId(dateTestId);
expect(todayTile?.parentElement).toHaveClass("selectedWeek");
});
@@ -164,23 +161,19 @@ describe("Found Bugs", () => {
const previousMonthButton = screen.getByText("<");
fireEvent.click(nextMonthButton);
fireEvent.click(previousMonthButton);
const shownDay = screen.getByText((content, element) => {
return (
element?.className.toLowerCase().includes("fc-daygrid-day-number") ??
false
);
});
const selectedTile = screen.getByText((content, element) => {
return element?.className.includes("selectedWeek") ?? false;
});
const supposedSelectedTile = screen
.getAllByText((content, element) => {
return element?.tagName.toLowerCase() === "abbr";
})
.find((el) => el.innerHTML === shownDay.innerHTML);
const ariaLabel = screen.getByRole("columnheader");
const shownDayDate = new Date(
ariaLabel.getAttribute("data-date") as string
);
const dateTestId = `date-${shownDayDate.getFullYear()}-${shownDayDate.getMonth()}-${shownDayDate.getDate()}`;
const supposedSelectedTile = screen.getByTestId(dateTestId);
expect(selectedTile.children[0].innerHTML).toBe(
supposedSelectedTile?.innerHTML
supposedSelectedTile.parentElement?.children[0]?.innerHTML
);
expect(supposedSelectedTile?.parentElement).toHaveClass("selectedWeek");
});
@@ -0,0 +1,172 @@
import { screen, fireEvent, waitFor } from "@testing-library/react";
import * as eventThunks from "../../../src/features/Calendars/CalendarSlice";
import { renderWithProviders } from "../../utils/Renderwithproviders";
import EventPopover from "../../../src/features/Events/EventModal";
import { DateSelectArg } from "@fullcalendar/core";
import preview from "jest-preview";
import { formatDateToYYYYMMDDTHHMMSS } from "../../../src/utils/dateUtils";
import EventDisplayModal from "../../../src/features/Events/EventDisplay";
describe("Event Display", () => {
const mockOnClose = jest.fn();
const day = new Date();
const RealDateToLocaleString = Date.prototype.toLocaleString;
beforeEach(() => {
jest.clearAllMocks();
});
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro",
openpaasId: "667037022b752d0026472254",
},
organiserData: {
cn: "test",
cal_address: "mailto:test@test.com",
},
},
calendars: {
list: {
"667037022b752d0026472254/cal1": {
id: "667037022b752d0026472254/cal1",
name: "Calendar 1",
color: "#FF0000",
events: {
event1: {
id: "event1",
title: "Test Event",
start: day.toISOString(),
end: day.toISOString(),
},
},
},
"otherCal/cal": {
id: "otherCal/cal",
name: "Calendar 1",
color: "#FF0000",
events: {
event1: {
id: "event1",
title: "Test Event Other cal",
start: day.toISOString(),
end: day.toISOString(),
},
},
},
},
pending: false,
},
};
it("renders correctly event data", () => {
jest
.spyOn(Date.prototype, "toLocaleString")
.mockImplementation(function (
this: Date,
locales?: Intl.LocalesArgument,
options?: Intl.DateTimeFormatOptions | undefined
): string {
return RealDateToLocaleString.call(this, "en-UK", options);
});
renderWithProviders(
<EventDisplayModal
anchorEl={document.body}
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
/>,
preloadedState
);
const weekday = day.toLocaleString("en-UK", { weekday: "long" });
const month = day.toLocaleString("en-UK", { month: "long" });
const dayOfMonth = day.getDate().toString();
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`))
).toBeInTheDocument();
expect(screen.getByText(/\d{2}:\d{2} \d{2}:\d{2}/)).toBeInTheDocument();
expect(screen.getByText("Calendar 1")).toBeInTheDocument();
});
it("calls onClose when Cancel clicked", () => {
renderWithProviders(
<EventDisplayModal
anchorEl={document.body}
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
/>,
preloadedState
);
fireEvent.click(screen.getByTestId("CloseIcon"));
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
});
it("Shows delete button only when calendar is own", () => {
// Renders the other cal event
renderWithProviders(
<EventDisplayModal
anchorEl={document.body}
open={true}
onClose={mockOnClose}
calId={"otherCal/cal"}
eventId={"event1"}
/>,
preloadedState
);
expect(screen.queryByTestId("DeleteIcon")).not.toBeInTheDocument();
// Renders the personnal cal event
renderWithProviders(
<EventDisplayModal
anchorEl={document.body}
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
/>,
preloadedState
);
expect(screen.queryByTestId("DeleteIcon")).toBeInTheDocument();
});
it("calls delete when Delete clicked", async () => {
renderWithProviders(
<EventDisplayModal
anchorEl={document.body}
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
/>,
preloadedState
);
const spy = jest
.spyOn(eventThunks, "deleteEventAsync")
.mockImplementation((payload) => {
return () => Promise.resolve(payload) as any;
});
fireEvent.click(screen.getByTestId("DeleteIcon"));
await waitFor(() => {
expect(spy).toHaveBeenCalled();
});
const receivedPayload = spy.mock.calls[0][0];
expect(receivedPayload).toEqual({
calId: "667037022b752d0026472254/cal1",
eventId: "event1",
});
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
});
});
@@ -165,7 +165,6 @@ describe("EventPopover", () => {
fireEvent.change(screen.getByLabelText("Location"), {
target: { value: newEvent.location },
});
preview.debug();
const spy = jest
.spyOn(eventThunks, "putEventAsync")
.mockImplementation((payload) => {
+274 -161
View File
@@ -11,6 +11,8 @@ import {
CardContent,
Divider,
IconButton,
Avatar,
Badge,
} from "@mui/material";
import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete";
@@ -20,6 +22,9 @@ 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 CancelIcon from "@mui/icons-material/Cancel";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import { userAttendee } from "../User/userDataTypes";
function EventDisplayModal({
eventId,
@@ -34,44 +39,58 @@ function EventDisplayModal({
open: boolean;
onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void;
}) {
if (calId && eventId) {
const dispatch = useAppDispatch();
const calendar = useAppSelector((state) => state.calendars.list[calId]);
const event = useAppSelector(
(state) => state.calendars.list[calId].events[eventId]
);
const user = useAppSelector((state) => state.user);
const [showAllAttendees, setShowAllAttendees] = useState(false);
const attendeeDisplayLimit = 3;
const dispatch = useAppDispatch();
const calendar = useAppSelector((state) => state.calendars.list[calId]);
const event = useAppSelector(
(state) => state.calendars.list[calId]?.events[eventId]
);
const user = useAppSelector((state) => state.user);
const [showAllAttendees, setShowAllAttendees] = useState(false);
useEffect(() => {
if (!event || !calendar) {
onClose({}, "backdropClick");
}
}, [event, calendar, onClose]);
useEffect(() => {
if (!event || !calendar) {
onClose({}, "backdropClick");
}
}, [event, calendar, onClose]);
if (!event || !calendar) return null;
if (!event || !calendar) return null;
const visibleAttendees = showAllAttendees
? event.attendee
: event.attendee.slice(0, attendeeDisplayLimit);
console.log(event);
return (
<Popover open={open} anchorEl={anchorEl} onClose={onClose}>
<Card sx={{ width: 300, p: 2, position: "relative" }}>
{/* Top-right buttons */}
<Box
sx={{
position: "absolute",
top: 8,
right: 8,
display: "flex",
gap: 1,
}}
>
<IconButton size="small">
<EditIcon fontSize="small" />
</IconButton>
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
);
return (
<Popover open={open} anchorEl={anchorEl} onClose={onClose}>
<Card sx={{ width: 300, p: 2, position: "relative" }}>
{/* Top-right buttons */}
<Box
sx={{
position: "absolute",
top: 8,
right: 8,
display: "flex",
gap: 1,
}}
>
<IconButton size="small">
<EditIcon fontSize="small" />
</IconButton>
{calendar.id.split("/")[0] === user.userData.openpaasId && (
<IconButton
size="small"
onClick={() => {
@@ -81,146 +100,240 @@ function EventDisplayModal({
>
<DeleteIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => onClose({}, "backdropClick")}
>
<CloseIcon fontSize="small" />
</IconButton>
)}
<IconButton size="small" onClick={() => onClose({}, "backdropClick")}>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
<CardContent sx={{ pt: 1.5 }}>
{event.title && (
<Typography variant="h6" fontWeight="bold" gutterBottom>
{event.title}
</Typography>
)}
{/* Time info*/}
<Typography variant="body2" color="textSecondary" gutterBottom>
{formatDate(event.start)}
{event.end &&
` ${new Date(event.end).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})}`}
</Typography>
{/* Location */}
{event.location && (
<InfoRow
icon={<LocationOnIcon sx={{ fontSize: 18 }} />}
text={event.location}
/>
)}
{/* Video */}
{event.x_openpass_videoconference && (
<InfoRow
icon={<VideocamIcon sx={{ fontSize: 18 }} />}
text="Video conference available"
/>
)}
{/* Attendees */}
{event.attendee?.length > 0 && (
<Box sx={{ mb: 1 }}>
<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"
sx={{ cursor: "pointer", mt: 0.5 }}
onClick={() => setShowAllAttendees(!showAllAttendees)}
>
{showAllAttendees
? "Show less"
: `Show more (${
attendees.length - attendeeDisplayLimit
} more)`}
</Typography>
)}
</Box>
)}
{/* Error */}
{event.error && (
<InfoRow
icon={<ErrorOutlineIcon color="error" sx={{ fontSize: 18 }} />}
text={event.error}
error
/>
)}
{/* Calendar color dot */}
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 2 }}>
<CalendarTodayIcon sx={{ fontSize: 16 }} />
<CircleIcon
sx={{
color: calendar.color ?? "#3788D8",
width: 12,
height: 12,
}}
/>
<Typography variant="body2">{calendar.name}</Typography>
</Box>
<CardContent sx={{ pt: 1.5 }}>
{/* Title */}
{event.title && (
<Typography variant="h6" fontWeight="bold" gutterBottom>
{event.title}
</Typography>
)}
<Divider sx={{ mb: 1 }} />
{/* Time info */}
<Typography variant="body2" color="textSecondary" gutterBottom>
{formatDate(event.start)}
{event.end &&
` ${new Date(event.end).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})}`}
</Typography>
{event.location && (
<Box
sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }}
>
<LocationOnIcon sx={{ fontSize: 18 }} />
<Typography variant="body2">{event.location}</Typography>
</Box>
)}
{/* Description */}
{event.description && (
{/* RSVP */}
{currentUserAttendee && (
<Box>
<Typography variant="body2" sx={{ mb: 1 }}>
{event.description}
Will you attend?
</Typography>
)}
{/* Video conference */}
{event.x_openpass_videoconference && (
<Box
sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }}
>
<VideocamIcon sx={{ fontSize: 18 }} />
<Typography variant="body2">
Video conference available
</Typography>
</Box>
)}
{/* Attendees */}
{/* Attendees with toggle */}
{event.attendee?.length > 0 && (
<Box sx={{ mb: 1 }}>
<Typography variant="subtitle2">Attendees:</Typography>
{visibleAttendees.map((a, idx) => (
<Typography key={idx} variant="body2">
{a.cn}
</Typography>
))}
{event.attendee.length > attendeeDisplayLimit && (
<Typography
variant="body2"
color="primary"
sx={{ cursor: "pointer", mt: 0.5 }}
onClick={() => setShowAllAttendees(!showAllAttendees)}
>
{showAllAttendees
? "Show less"
: `Show more (${
event.attendee.length - attendeeDisplayLimit
} more)`}
</Typography>
)}
</Box>
)}
{/* Error */}
{event.error && (
<Box
sx={{ display: "flex", alignItems: "center", gap: 1, mt: 1 }}
>
<ErrorOutlineIcon color="error" sx={{ fontSize: 18 }} />
<Typography variant="body2" color="error">
{event.error}
</Typography>
</Box>
)}
{/* Colored dot and calendar icon row */}
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 2 }}>
<CalendarTodayIcon sx={{ fontSize: 16 }} />
<CircleIcon
sx={{
color: calendar.color ?? "#3788D8",
width: 12,
height: 12,
}}
/>
<Typography variant="body2">{calendar.name}</Typography>
<ButtonGroup size="small" fullWidth>
<Button>Accept</Button>
<Button>Maybe</Button>
<Button>Decline</Button>
</ButtonGroup>
</Box>
)}
<Divider sx={{ mb: 1 }} />
{/* Attendance options */}
{event.attendee.find(
(person) => person.cal_address === user.userData.email
) && (
<Box>
<Typography variant="body2" sx={{ mb: 1 }}>
Will you attend?
</Typography>
<ButtonGroup size="small" fullWidth>
<Button>Accept</Button>
<Button>Maybe</Button>
<Button>Decline</Button>
</ButtonGroup>
</Box>
)}
</CardContent>
</Card>
</Popover>
);
} else {
return <></>;
}
{/* Description */}
{event.description && (
<Typography variant="body2" sx={{ mt: 1 }}>
{event.description}
</Typography>
)}
</CardContent>
</Card>
</Popover>
);
}
const formatDate = (date: Date) =>
new Date(date).toLocaleString(undefined, {
function InfoRow({
icon,
text,
error = false,
}: {
icon: React.ReactNode;
text: string;
error?: boolean;
}) {
return (
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }}>
{icon}
<Typography variant="body2" color={error ? "error" : "textPrimary"}>
{text}
</Typography>
</Box>
);
}
function renderAttendeeBadge(
a: userAttendee,
key: string,
isOrganizer?: boolean
) {
const statusIcon =
a.partstat === "ACCEPTED" ? (
<CheckCircleIcon fontSize="inherit" color="success" />
) : a.partstat === "DECLINED" ? (
<CancelIcon fontSize="inherit" color="error" />
) : null;
return (
<Box
key={key}
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
mb: 0.5,
p: 0.5,
borderRadius: 1,
}}
>
<Badge
overlap="circular"
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
badgeContent={
statusIcon && (
<Box
sx={{
fontSize: 14,
lineHeight: 0,
backgroundColor: "white",
borderRadius: "50%",
padding: "1px",
}}
>
{statusIcon}
</Box>
)
}
>
<Avatar {...stringAvatar(a.cn || a.cal_address)} />
</Badge>
<Box sx={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
<Typography
variant="body2"
noWrap
sx={{
maxWidth: "180px",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{a.cn || a.cal_address}
</Typography>
{isOrganizer && (
<Typography
variant="caption"
color="text.secondary"
sx={{ fontStyle: "italic" }}
>
Organizer
</Typography>
)}
</Box>
</Box>
);
}
function formatDate(date: Date) {
return new Date(date).toLocaleString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
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;
}
function stringAvatar(name: string) {
return {
sx: { width: 24, height: 24, fontSize: 18, bgcolor: stringToColor(name) },
children: name[0],
};
}
export default EventDisplayModal;