From b3d85eb15cdfba86f4866b7e66204d4d3fd45387 Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Wed, 30 Jul 2025 16:26:08 +0200 Subject: [PATCH 01/12] [#4] added attendance management --- src/features/Calendars/CalendarSlice.ts | 4 +-- src/features/Events/EventDisplay.tsx | 48 ++++++++++++++++++++++--- src/features/Events/eventUtils.ts | 4 +-- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index 6b1422f..289babc 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -56,9 +56,9 @@ export const putEventAsync = createAsyncThunk< >("calendars/putEvent", async ({ cal, newEvent }) => { const response = await putEvent(cal, newEvent); const calEvents = (await getCalendar(cal.id, { - start: formatDateToYYYYMMDDTHHMMSS(newEvent.start), + start: formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.start)), end: formatDateToYYYYMMDDTHHMMSS( - new Date(newEvent.start.getTime() + 86400000) + new Date(new Date(newEvent.start).getTime() + 86400000) ), })) as Record; const events: CalendarEvent[] = calEvents._embedded["dav:item"].flatMap( diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index 777115d..01c8de6 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { deleteEventAsync } from "../Calendars/CalendarSlice"; +import { deleteEventAsync, putEventAsync } from "../Calendars/CalendarSlice"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { Popover, @@ -25,6 +25,7 @@ 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"; +import { putEvent } from "./EventApi"; function EventDisplayModal({ eventId, @@ -74,6 +75,18 @@ function EventDisplayModal({ (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 ( @@ -195,9 +208,36 @@ function EventDisplayModal({ Will you attend? - - - + + + )} diff --git a/src/features/Events/eventUtils.ts b/src/features/Events/eventUtils.ts index a592970..6751b78 100644 --- a/src/features/Events/eventUtils.ts +++ b/src/features/Events/eventUtils.ts @@ -98,7 +98,7 @@ export function calendarEventToJCal(event: CalendarEvent): any[] { "dtstart", { tzid }, event.allday ? "date" : "date-time", - formatDateToICal(event.start, event.allday ?? false), + formatDateToICal(new Date(event.start), event.allday ?? false), ], ["class", {}, "text", event.class ?? "PUBLIC"], [ @@ -117,7 +117,7 @@ export function calendarEventToJCal(event: CalendarEvent): any[] { "dtend", { tzid }, event.allday ? "date" : "date-time", - formatDateToICal(event.end, event.allday ?? false), + formatDateToICal(new Date(event.end), event.allday ?? false), ]); } if (event.organizer) { From 38dfb49dc8c008b120207014a7676dc0a24e14ac Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Wed, 30 Jul 2025 17:05:05 +0200 Subject: [PATCH 02/12] [#4] added tests --- .../features/Events/EventDisplay.test.tsx | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx index 7f9d361..01edee8 100644 --- a/__test__/features/Events/EventDisplay.test.tsx +++ b/__test__/features/Events/EventDisplay.test.tsx @@ -169,4 +169,286 @@ describe("Event Display", () => { expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); }); + it("renders RSVP buttons when user is an attendee", () => { + const rsvpStateIsOrga = { + ...preloadedState, + calendars: { + ...preloadedState.calendars, + list: { + ...preloadedState.calendars.list, + "667037022b752d0026472254/cal1": { + ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + events: { + event1: { + ...preloadedState.calendars.list[ + "667037022b752d0026472254/cal1" + ].events.event1, + attendee: [ + { + cal_address: "test@test.com", + cn: "Test User", + partstat: "NEEDS-ACTION", + }, + { + cal_address: "organizer@test.com", + cn: "Test Organizer", + partstat: "NEEDS-ACTION", + }, + ], + organizer: { + cal_address: "organizer@test.com", + }, + }, + }, + }, + }, + }, + }; + + renderWithProviders( + , + rsvpStateIsOrga + ); + + expect(screen.getByText("Will you attend?")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Accept" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Maybe" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Decline" })).toBeInTheDocument(); + }); + it("doesnt renders RSVP buttons when user isnt an attendee", () => { + const rsvpStateIsOrga = { + ...preloadedState, + calendars: { + ...preloadedState.calendars, + list: { + ...preloadedState.calendars.list, + "667037022b752d0026472254/cal1": { + ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + events: { + event1: { + ...preloadedState.calendars.list[ + "667037022b752d0026472254/cal1" + ].events.event1, + attendee: [ + { + cal_address: "organizer@test.com", + cn: "Test Organizer", + partstat: "NEEDS-ACTION", + }, + ], + organizer: { + cal_address: "organizer@test.com", + }, + }, + }, + }, + }, + }, + }; + + renderWithProviders( + , + rsvpStateIsOrga + ); + + expect(screen.queryByText("Will you attend?")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Accept" }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Maybe" }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Decline" }) + ).not.toBeInTheDocument(); + }); + + it("handles RSVP Accept click", async () => { + const spy = jest + .spyOn(eventThunks, "putEventAsync") + .mockImplementation((payload) => { + return () => Promise.resolve(payload) as any; + }); + + const rsvpState = { + ...preloadedState, + calendars: { + ...preloadedState.calendars, + list: { + ...preloadedState.calendars.list, + "667037022b752d0026472254/cal1": { + ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + events: { + event1: { + ...preloadedState.calendars.list[ + "667037022b752d0026472254/cal1" + ].events.event1, + attendee: [ + { + cal_address: "test@test.com", + cn: "Test User", + partstat: "NEEDS-ACTION", + }, + ], + organizer: { + cal_address: "organizer@test.com", + }, + }, + }, + }, + }, + }, + }; + + renderWithProviders( + , + rsvpState + ); + + fireEvent.click(screen.getByRole("button", { name: "Accept" })); + + await waitFor(() => { + expect(spy).toHaveBeenCalled(); + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); + + const updatedEvent = spy.mock.calls[0][0].newEvent; + expect(updatedEvent.attendee[0].partstat).toBe("ACCEPTED"); + }); + + it("handles RSVP Maybe click", async () => { + const spy = jest + .spyOn(eventThunks, "putEventAsync") + .mockImplementation((payload) => { + return () => Promise.resolve(payload) as any; + }); + + const rsvpState = { + ...preloadedState, + calendars: { + ...preloadedState.calendars, + list: { + ...preloadedState.calendars.list, + "667037022b752d0026472254/cal1": { + ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + events: { + event1: { + ...preloadedState.calendars.list[ + "667037022b752d0026472254/cal1" + ].events.event1, + attendee: [ + { + cal_address: "test@test.com", + cn: "Test User", + partstat: "NEEDS-ACTION", + }, + ], + organizer: { + cal_address: "organizer@test.com", + }, + }, + }, + }, + }, + }, + }; + + renderWithProviders( + , + rsvpState + ); + + fireEvent.click(screen.getByRole("button", { name: "Maybe" })); + + await waitFor(() => { + expect(spy).toHaveBeenCalled(); + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); + + const updatedEvent = spy.mock.calls[0][0].newEvent; + expect(updatedEvent.attendee[0].partstat).toBe("TENTATIVE"); + }); + + it("handles RSVP Decline click", async () => { + const spy = jest + .spyOn(eventThunks, "putEventAsync") + .mockImplementation((payload) => { + return () => Promise.resolve(payload) as any; + }); + + const rsvpState = { + ...preloadedState, + calendars: { + ...preloadedState.calendars, + list: { + ...preloadedState.calendars.list, + "667037022b752d0026472254/cal1": { + ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + events: { + event1: { + ...preloadedState.calendars.list[ + "667037022b752d0026472254/cal1" + ].events.event1, + attendee: [ + { + cal_address: "test@test.com", + cn: "Test User", + partstat: "NEEDS-ACTION", + }, + ], + organizer: { + cal_address: "organizer@test.com", + }, + }, + }, + }, + }, + }, + }; + + renderWithProviders( + , + rsvpState + ); + + fireEvent.click(screen.getByRole("button", { name: "Decline" })); + + await waitFor(() => { + expect(spy).toHaveBeenCalled(); + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); + + const updatedEvent = spy.mock.calls[0][0].newEvent; + expect(updatedEvent.attendee[0].partstat).toBe("DECLINED"); + }); }); From a71d1f12fb9dd1c37c05a91c70a5afe2e8c5e181 Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Fri, 1 Aug 2025 15:24:07 +0200 Subject: [PATCH 03/12] [#4] fixed duplication when managing attendance --- __test__/features/Events/eventUtils.test.ts | 8 ++++---- src/features/Events/eventUtils.ts | 17 ++++++++++------- src/features/User/userDataTypes.ts | 2 +- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/__test__/features/Events/eventUtils.test.ts b/__test__/features/Events/eventUtils.test.ts index 44bc8a5..0dd1b55 100644 --- a/__test__/features/Events/eventUtils.test.ts +++ b/__test__/features/Events/eventUtils.test.ts @@ -16,12 +16,12 @@ describe("parseCalendarEvent", () => { ["SUMMARY", {}, "text", "Team Meeting"], ["DESCRIPTION", {}, "text", "Discuss roadmap"], ["LOCATION", {}, "text", "Zoom"], - ["ORGANIZER", { cn: "Alice" }, "cal-address", "mailto:alice@example.com"], + ["ORGANIZER", { cn: "Alice" }, "cal-address", "alice@example.com"], [ "ATTENDEE", { cn: "Bob", partstat: "ACCEPTED" }, "cal-address", - "mailto:bob@example.com", + "bob@example.com", ], ["X-OPENPAAS-VIDEOCONFERENCE", {}, "text", "https://meet.link"], ["STATUS", {}, "text", "CONFIRMED"], @@ -98,8 +98,8 @@ describe("parseCalendarEvent", () => { const rawData = [ ["UID", {}, "text", "event-4"], ["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"], - ["ATTENDEE", {}, "cal-address", "mailto:john@example.com"], - ["ORGANIZER", {}, "cal-address", "mailto:jane@example.com"], + ["ATTENDEE", {}, "cal-address", "john@example.com"], + ["ORGANIZER", {}, "cal-address", "jane@example.com"], ] as unknown as [string, Record, string, any]; const result = parseCalendarEvent(rawData, baseColor, calendarId); diff --git a/src/features/Events/eventUtils.ts b/src/features/Events/eventUtils.ts index 6751b78..df9defe 100644 --- a/src/features/Events/eventUtils.ts +++ b/src/features/Events/eventUtils.ts @@ -139,15 +139,18 @@ export function calendarEventToJCal(event: CalendarEvent): any[] { } event.attendee.forEach((att) => { + const attendee: Record = { + partstat: att.partstat, + rsvp: att.rsvp, + role: att.role, + cutype: att.cutype, + }; + if (att.cn) { + attendee.cn = att.cn; + } vevent[1].push([ "attendee", - { - cn: att.cn, - partstat: att.partstat, - rsvp: att.rsvp, - role: att.role, - cutype: att.cutype, - }, + attendee, "cal-address", `mailto:${att.cal_address}`, ]); diff --git a/src/features/User/userDataTypes.ts b/src/features/User/userDataTypes.ts index a7fb841..907e9d3 100644 --- a/src/features/User/userDataTypes.ts +++ b/src/features/User/userDataTypes.ts @@ -14,7 +14,7 @@ export interface userOrganiser { } export interface userAttendee { - cn: string; + cn?: string; cal_address: string; partstat: string; rsvp: string; From cd829703e9124e62cf118488bfebeb7e17c43c1d Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Fri, 1 Aug 2025 17:41:13 +0200 Subject: [PATCH 04/12] [#4] attendance now change event display --- .../components/MiniCalendarColor.test.tsx | 2 + src/components/Calendar/Calendar.css | 12 +++ src/components/Calendar/Calendar.tsx | 81 +++++++++++++++++++ src/features/Calendars/CalendarSlice.ts | 13 ++- src/features/Calendars/CalendarTypes.ts | 1 + src/features/User/userAPI.ts | 5 ++ 6 files changed, 111 insertions(+), 3 deletions(-) diff --git a/__test__/components/MiniCalendarColor.test.tsx b/__test__/components/MiniCalendarColor.test.tsx index c89f957..6aff578 100644 --- a/__test__/components/MiniCalendarColor.test.tsx +++ b/__test__/components/MiniCalendarColor.test.tsx @@ -30,6 +30,7 @@ describe("MiniCalendar", () => { "667037022b752d0026472254/cal1": { name: "Calendar 1", color: "#FF0000", + ownerEmails: ["test@test.com"], events: { event1: { calId: "667037022b752d0026472254/cal1", @@ -139,6 +140,7 @@ describe("Found Bugs", () => { "667037022b752d0026472254/cal1": { name: "Calendar 1", color: "#FF0000", + ownerEmails: ["test.test@test.com"], events: { event1: { calId: "667037022b752d0026472254/cal1", diff --git a/src/components/Calendar/Calendar.css b/src/components/Calendar/Calendar.css index 25b0eb7..769dcac 100644 --- a/src/components/Calendar/Calendar.css +++ b/src/components/Calendar/Calendar.css @@ -27,6 +27,18 @@ main { position: relative; } +.declined-event { + opacity: 0.7; +} + +.tentative-event { +} + +.needs-action-event { + opacity: 0.7; + border: dashed 1px #ffffff; +} + .fc .fc-timegrid-slot { height: 30px !important; min-height: 30px !important; diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index 2018731..ecea758 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -27,12 +27,17 @@ import { Calendars } from "../../features/Calendars/CalendarTypes"; import { push } from "redux-first-history"; import EventDisplayModal from "../../features/Events/EventDisplay"; import { createSelector } from "@reduxjs/toolkit"; +import HelpOutlineIcon from "@mui/icons-material/HelpOutline"; +import ClearIcon from "@mui/icons-material/Clear"; +import AccessTimeIcon from "@mui/icons-material/AccessTime"; +import { userAttendee } from "../../features/User/userDataTypes"; export default function CalendarApp() { const calendarRef = useRef(null); const [selectedDate, setSelectedDate] = useState(new Date()); const [selectedMiniDate, setSelectedMiniDate] = useState(new Date()); const tokens = useAppSelector((state) => state.user.tokens); + const user = useAppSelector((state) => state.user.userData); const dispatch = useAppDispatch(); if (!tokens) { @@ -372,6 +377,82 @@ export default function CalendarApp() { center: "prev,today,next", right: "dayGridMonth,timeGridWeek,timeGridDay", }} + eventContent={(arg) => { + const event = arg.event; + if (!calendars[arg.event._def.extendedProps.calId]) return; + + const attendees = event._def.extendedProps.attendee || []; + let Icon = null; + let titleStyle: React.CSSProperties = {}; + const ownerEmails = new Set( + calendars[arg.event._def.extendedProps.calId].ownerEmails?.map( + (email) => email.toLowerCase() + ) + ); + const showSpecialDisplay = attendees.filter((att: userAttendee) => + ownerEmails.has(att.cal_address.toLowerCase()) + ); + if (!showSpecialDisplay[0]) return; + switch (showSpecialDisplay[0].partstat) { + case "DECLINED": + Icon = null; + titleStyle.textDecoration = "line-through"; + break; + case "TENTATIVE": + Icon = HelpOutlineIcon; + break; + case "NEEDS-ACTION": + Icon = AccessTimeIcon; + break; + case "ACCEPTED": + Icon = null; + break; + default: + break; + } + + return ( +
+ {Icon && } + {event.title} +
+ ); + }} + eventDidMount={(arg) => { + const attendees = arg.event._def.extendedProps.attendee || []; + if (!calendars[arg.event._def.extendedProps.calId]) return; + const ownerEmails = new Set( + calendars[arg.event._def.extendedProps.calId].ownerEmails?.map( + (email) => email.toLowerCase() + ) + ); + const showSpecialDisplay = attendees.filter((att: userAttendee) => + ownerEmails.has(att.cal_address.toLowerCase()) + ); + + if (!showSpecialDisplay[0]) return; + + // Clear previously applied classes + arg.el.classList.remove( + "declined-event", + "tentative-event", + "needs-action-event" + ); + + switch (showSpecialDisplay[0].partstat) { + case "DECLINED": + arg.el.classList.add("declined-event"); + break; + case "TENTATIVE": + arg.el.classList.add("tentative-event"); + break; + case "NEEDS-ACTION": + arg.el.classList.add("needs-action-event"); + break; + default: + break; + } + }} /> Date: Wed, 20 Aug 2025 09:40:52 +0200 Subject: [PATCH 05/12] [#4] fixed test for d&d --- .../components/EventModifications.test.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/__test__/components/EventModifications.test.tsx b/__test__/components/EventModifications.test.tsx index 9581bf3..e713c75 100644 --- a/__test__/components/EventModifications.test.tsx +++ b/__test__/components/EventModifications.test.tsx @@ -18,7 +18,6 @@ describe("CalendarApp integration", () => { jest.clearAllMocks(); const dispatch = jest.fn() as ThunkDispatch; jest.spyOn(appHooks, "useAppDispatch").mockReturnValue(dispatch); - jest.useFakeTimers(); // optional }); const renderCalendar = () => { @@ -36,7 +35,9 @@ describe("CalendarApp integration", () => { list: { "667037022b752d0026472254/cal1": { name: "Calendar 1", + id: "667037022b752d0026472254/cal1", color: "#FF0000", + ownerEmails: ["alice@example.com"], events: { event1: { id: "event1", @@ -45,6 +46,21 @@ describe("CalendarApp integration", () => { title: "Test Event", start: start.toISOString(), end: end.toISOString(), + partstat: "ACCEPTED", + organizer: { + cn: "Alice", + cal_address: "alice@example.com", + }, + attendee: [ + { + cn: "Alice", + partstat: "ACCEPTED", + rsvp: "TRUE", + role: "REQ-PARTICIPANT", + cutype: "INDIVIDUAL", + cal_address: "alice@example.com", + }, + ], }, }, }, From 5e410124cc72f0d2d8c90bf74c3470c2b2a319ec Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Wed, 20 Aug 2025 10:14:47 +0200 Subject: [PATCH 06/12] [#32] changed how userinfo is filled --- src/features/User/userAPI.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/User/userAPI.ts b/src/features/User/userAPI.ts index 1408641..9c042ac 100644 --- a/src/features/User/userAPI.ts +++ b/src/features/User/userAPI.ts @@ -1,6 +1,6 @@ import { api } from "../../utils/apiUtils"; -export default async function getOpenPaasUser() { +export async function getOpenPaasUser() { const user = await api.get(`api/user`).json(); return user; } From cc262d6816398008b447929712b8f3b05192d32a Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Wed, 20 Aug 2025 15:32:50 +0200 Subject: [PATCH 07/12] [#4] fix test and imports --- __test__/features/user/userAPI.test.tsx | 2 +- src/features/Calendars/CalendarSlice.ts | 2 +- src/features/User/userSlice.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/__test__/features/user/userAPI.test.tsx b/__test__/features/user/userAPI.test.tsx index 4739af2..3fad487 100644 --- a/__test__/features/user/userAPI.test.tsx +++ b/__test__/features/user/userAPI.test.tsx @@ -1,5 +1,5 @@ import { clientConfig } from "../../../src/features/User/oidcAuth"; -import getOpenPaasUser from "../../../src/features/User/userAPI"; +import { getOpenPaasUser } from "../../../src/features/User/userAPI"; import { api } from "../../../src/utils/apiUtils"; jest.mock("../../../src/utils/apiUtils"); diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index 0dd80b2..016bb50 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -2,7 +2,7 @@ import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit"; import { Calendars } from "./CalendarTypes"; import { CalendarEvent } from "../Events/EventsTypes"; import { getCalendar, getCalendars } from "./CalendarApi"; -import getOpenPaasUser, { getUserDetails } from "../User/userAPI"; +import { getOpenPaasUser, getUserDetails } from "../User/userAPI"; import { parseCalendarEvent } from "../Events/eventUtils"; import { deleteEvent, putEvent } from "../Events/EventApi"; import { formatDateToYYYYMMDDTHHMMSS } from "../../utils/dateUtils"; diff --git a/src/features/User/userSlice.ts b/src/features/User/userSlice.ts index 226a05e..5c0b524 100644 --- a/src/features/User/userSlice.ts +++ b/src/features/User/userSlice.ts @@ -1,6 +1,6 @@ import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; import { userData, userOrganiser } from "./userDataTypes"; -import getOpenPaasUser from "./userAPI"; +import { getOpenPaasUser } from "./userAPI"; export const getOpenPaasUserDataAsync = createAsyncThunk( "user/getOpenPaasUserData", From aaba50cf002c27217af103fbe850c9c24b58acf6 Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Thu, 21 Aug 2025 15:47:02 +0200 Subject: [PATCH 08/12] [#4] added delegated calendars --- .../components/CalendarSelection.test.tsx | 193 ++++++++++++++++++ src/components/Calendar/CalendarSelection.tsx | 73 ++++--- src/features/Calendars/CalendarSlice.ts | 16 +- src/features/Calendars/CalendarTypes.ts | 1 + 4 files changed, 254 insertions(+), 29 deletions(-) create mode 100644 __test__/components/CalendarSelection.test.tsx diff --git a/__test__/components/CalendarSelection.test.tsx b/__test__/components/CalendarSelection.test.tsx new file mode 100644 index 0000000..5ba982b --- /dev/null +++ b/__test__/components/CalendarSelection.test.tsx @@ -0,0 +1,193 @@ +import { CalendarApi } from "@fullcalendar/core"; +import { jest } from "@jest/globals"; +import { ThunkDispatch } from "@reduxjs/toolkit"; +import "@testing-library/jest-dom"; +import { screen } from "@testing-library/react"; +import * as appHooks from "../../src/app/hooks"; +import CalendarApp from "../../src/components/Calendar/Calendar"; +import { renderWithProviders } from "../utils/Renderwithproviders"; +import CalendarSelection from "../../src/components/Calendar/CalendarSelection"; + +describe("CalendarSelection", () => { + const today = new Date(); + const start = new Date(today); + start.setHours(10, 0, 0, 0); + const end = new Date(today); + end.setHours(11, 0, 0, 0); + + it("renders the all the calendars in list", async () => { + const preloadedState = { + user: { + userData: { + sub: "test", + email: "test@test.com", + sid: "mockSid", + openpaasId: "user1", + }, + tokens: { accessToken: "token" }, // required to avoid redirect + }, + calendars: { + list: { + "user1/cal1": { + name: "Calendar personnal", + id: "user1/cal1", + color: "#FF0000", + ownerEmails: ["alice@example.com"], + events: { + event1: { + id: "event1", + calId: "user1/cal1", + uid: "event1", + title: "Test Event", + start: start.toISOString(), + end: end.toISOString(), + partstat: "ACCEPTED", + organizer: { + cn: "Alice", + cal_address: "alice@example.com", + }, + attendee: [ + { + cn: "Alice", + partstat: "ACCEPTED", + rsvp: "TRUE", + role: "REQ-PARTICIPANT", + cutype: "INDIVIDUAL", + cal_address: "alice@example.com", + }, + ], + }, + }, + }, + "user2/cal1": { + name: "Calendar delegated", + delegated: true, + id: "user2/cal1", + color: "#FF0000", + ownerEmails: ["alice@example.com"], + events: { + event1: { + id: "event1", + calId: "user2/cal1", + uid: "event1", + title: "Test Event", + start: start.toISOString(), + end: end.toISOString(), + partstat: "ACCEPTED", + organizer: { + cn: "Alice", + cal_address: "alice@example.com", + }, + attendee: [ + { + cn: "Alice", + partstat: "ACCEPTED", + rsvp: "TRUE", + role: "REQ-PARTICIPANT", + cutype: "INDIVIDUAL", + cal_address: "alice@example.com", + }, + ], + }, + }, + }, + "user3/cal1": { + name: "Calendar shared", + id: "user3/cal1", + color: "#FF0000", + ownerEmails: ["alice@example.com"], + events: { + event1: { + id: "event1", + calId: "user3/cal1", + uid: "event1", + title: "Test Event", + start: start.toISOString(), + end: end.toISOString(), + partstat: "ACCEPTED", + organizer: { + cn: "Alice", + cal_address: "alice@example.com", + }, + attendee: [ + { + cn: "Alice", + partstat: "ACCEPTED", + rsvp: "TRUE", + role: "REQ-PARTICIPANT", + cutype: "INDIVIDUAL", + cal_address: "alice@example.com", + }, + ], + }, + }, + }, + }, + pending: false, + }, + }; + renderWithProviders(, preloadedState); + expect(screen.getByText("personnalCalendars")).toBeInTheDocument(); + expect(screen.getByText("delegatedCalendars")).toBeInTheDocument(); + expect(screen.getByText("sharedCalendars")).toBeInTheDocument(); + + expect(screen.getByLabelText("Calendar personnal")).toBeInTheDocument(); + expect(screen.getByLabelText("Calendar delegated")).toBeInTheDocument(); + expect(screen.getByLabelText("Calendar shared")).toBeInTheDocument(); + }); + it("renders only personnal calendars in list", async () => { + const preloadedState = { + user: { + userData: { + sub: "test", + email: "test@test.com", + sid: "mockSid", + openpaasId: "user1", + }, + tokens: { accessToken: "token" }, // required to avoid redirect + }, + calendars: { + list: { + "user1/cal1": { + name: "Calendar personnal", + id: "user1/cal1", + color: "#FF0000", + ownerEmails: ["alice@example.com"], + events: { + event1: { + id: "event1", + calId: "user1/cal1", + uid: "event1", + title: "Test Event", + start: start.toISOString(), + end: end.toISOString(), + partstat: "ACCEPTED", + organizer: { + cn: "Alice", + cal_address: "alice@example.com", + }, + attendee: [ + { + cn: "Alice", + partstat: "ACCEPTED", + rsvp: "TRUE", + role: "REQ-PARTICIPANT", + cutype: "INDIVIDUAL", + cal_address: "alice@example.com", + }, + ], + }, + }, + }, + }, + pending: false, + }, + }; + renderWithProviders(, preloadedState); + expect(screen.getByText("personnalCalendars")).toBeInTheDocument(); + expect(screen.queryByText("delegatedCalendars")).not.toBeInTheDocument(); + expect(screen.queryByText("sharedCalendars")).not.toBeInTheDocument(); + + expect(screen.getByLabelText("Calendar personnal")).toBeInTheDocument(); + }); +}); diff --git a/src/components/Calendar/CalendarSelection.tsx b/src/components/Calendar/CalendarSelection.tsx index 0c98fcc..4dab623 100644 --- a/src/components/Calendar/CalendarSelection.tsx +++ b/src/components/Calendar/CalendarSelection.tsx @@ -7,11 +7,18 @@ export default function CalendarSelection({ selectedCalendars: string[]; setSelectedCalendars: Function; }) { - const tokens = useAppSelector((state) => state.user.tokens); const userId = useAppSelector((state) => state.user.userData.openpaasId); const dispatch = useAppDispatch(); const calendars = useAppSelector((state) => state.calendars.list); - + const personnalCalendars = Object.keys(calendars).filter( + (id) => id.split("/")[0] === userId + ); + const delegatedCalendars = Object.keys(calendars).filter( + (id) => id.split("/")[0] !== userId && calendars[id].delegated + ); + const sharedCalendars = Object.keys(calendars).filter( + (id) => id.split("/")[0] !== userId && !calendars[id].delegated + ); const handleCalendarToggle = (name: string) => { setSelectedCalendars((prev: string[]) => prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name] @@ -21,27 +28,8 @@ export default function CalendarSelection({ return (

personnalCalendars

- {Object.keys(calendars) - .filter((id) => id.split("/")[0] === userId) - .map((id) => { - return ( -
- -
- ); - })} -

sharedCalendars

- {Object.keys(calendars) - .filter((id) => id.split("/")[0] !== userId) - .map((id) => ( + {personnalCalendars.map((id) => { + return (
- ))} + ); + })} + {delegatedCalendars.length > 0 && ( + <> +

delegatedCalendars

+ {delegatedCalendars.map((id) => ( +
+ +
+ ))} + + )} + {sharedCalendars.length > 0 && ( + <> +

sharedCalendars

+ {sharedCalendars.map((id) => ( +
+ +
+ ))} + + )}
); } diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index 016bb50..aaee08a 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -6,7 +6,6 @@ import { getOpenPaasUser, getUserDetails } from "../User/userAPI"; import { parseCalendarEvent } from "../Events/eventUtils"; import { deleteEvent, putEvent } from "../Events/EventApi"; import { formatDateToYYYYMMDDTHHMMSS } from "../../utils/dateUtils"; -import { responsiveFontSizes } from "@mui/material"; export const getCalendarsListAsync = createAsyncThunk< Record // Return type @@ -19,11 +18,17 @@ export const getCalendarsListAsync = createAsyncThunk< for (const cal of rawCalendars) { const name = cal["dav:name"]; const description = cal["dav:description"]; - const id = cal["calendarserver:source"] + let delegated = false; + let source = cal["calendarserver:source"] ? cal["calendarserver:source"]._links.self.href - .replace("/calendars/", "") - .replace(".json", "") - : cal._links.self.href.replace("/calendars/", "").replace(".json", ""); + : cal._links.self.href; + if (cal["calendarserver:delegatedsource"]) { + source = cal["calendarserver:delegatedsource"]; + delegated = true; + } + console.log(source); + const id = source.replace("/calendars/", "").replace(".json", ""); + const ownerData: any = await getUserDetails(id.split("/")[0]); const color = cal["apple:color"]; importedCalendars[id] = { @@ -31,6 +36,7 @@ export const getCalendarsListAsync = createAsyncThunk< name, ownerEmails: ownerData.emails, description, + delegated, color, events: {}, }; diff --git a/src/features/Calendars/CalendarTypes.ts b/src/features/Calendars/CalendarTypes.ts index 1d6556b..70fcf3d 100644 --- a/src/features/Calendars/CalendarTypes.ts +++ b/src/features/Calendars/CalendarTypes.ts @@ -3,6 +3,7 @@ import { CalendarEvent } from "../Events/EventsTypes"; export interface Calendars { id: string; name: string; + delegated?: boolean; prodid?: string; color?: string; ownerEmails?: string[]; From 6c58680450ba3627951ee6480fa3f6deccd832e4 Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Fri, 22 Aug 2025 10:48:09 +0200 Subject: [PATCH 09/12] [#4] added padding between calendar headers --- src/components/Calendar/Calendar.css | 3 +++ src/components/Calendar/CalendarSelection.tsx | 13 +++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/Calendar/Calendar.css b/src/components/Calendar/Calendar.css index 769dcac..998ca87 100644 --- a/src/components/Calendar/Calendar.css +++ b/src/components/Calendar/Calendar.css @@ -9,6 +9,9 @@ main { height: 90vh; } +.calendarListHeader { + padding-top: 10px; +} .sidebar { border-right: 2px gray; width: 20vw; diff --git a/src/components/Calendar/CalendarSelection.tsx b/src/components/Calendar/CalendarSelection.tsx index 4dab623..c354d82 100644 --- a/src/components/Calendar/CalendarSelection.tsx +++ b/src/components/Calendar/CalendarSelection.tsx @@ -8,7 +8,6 @@ export default function CalendarSelection({ setSelectedCalendars: Function; }) { const userId = useAppSelector((state) => state.user.userData.openpaasId); - const dispatch = useAppDispatch(); const calendars = useAppSelector((state) => state.calendars.list); const personnalCalendars = Object.keys(calendars).filter( (id) => id.split("/")[0] === userId @@ -27,7 +26,9 @@ export default function CalendarSelection({ return (
-

personnalCalendars

+

+

Personnal Calendars

+

{personnalCalendars.map((id) => { return (
@@ -45,7 +46,9 @@ export default function CalendarSelection({ })} {delegatedCalendars.length > 0 && ( <> -

delegatedCalendars

+

+

Delegated Calendars

+

{delegatedCalendars.map((id) => (