diff --git a/__test__/components/CalendarSelection.test.tsx b/__test__/components/CalendarSelection.test.tsx new file mode 100644 index 0000000..4326fde --- /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("Personnal Calendars")).toBeInTheDocument(); + expect(screen.getByText("Delegated Calendars")).toBeInTheDocument(); + expect(screen.getByText("Shared Calendars")).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("Personnal Calendars")).toBeInTheDocument(); + expect(screen.queryByText("Delegated Calendars")).not.toBeInTheDocument(); + expect(screen.queryByText("Shared Calendars")).not.toBeInTheDocument(); + + expect(screen.getByLabelText("Calendar personnal")).toBeInTheDocument(); + }); +}); 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", + }, + ], }, }, }, 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/__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"); + }); }); 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/__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/components/Calendar/Calendar.css b/src/components/Calendar/Calendar.css index 25b0eb7..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; @@ -27,6 +30,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; + } + }} /> 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] @@ -20,28 +26,11 @@ 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) => ( + +

Personnal Calendars

+
+ {personnalCalendars.map((id) => { + return (
- ))} + ); + })} + {delegatedCalendars.length > 0 && ( + <> + +

Delegated Calendars

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

Shared Calendars

+
+ {sharedCalendars.map((id) => ( +
+ +
+ ))} + + )}
); } diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index 6b1422f..1c165fe 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -2,11 +2,10 @@ import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit"; import { Calendars } from "./CalendarTypes"; import { CalendarEvent } from "../Events/EventsTypes"; import { getCalendar, getCalendars } from "./CalendarApi"; -import getOpenPaasUser 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"; -import { responsiveFontSizes } from "@mui/material"; export const getCalendarsListAsync = createAsyncThunk< Record // Return type @@ -19,14 +18,28 @@ 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] = { id, name, description, color, events: {} }; + importedCalendars[id] = { + id, + name, + ownerEmails: ownerData.emails, + description, + delegated, + color, + events: {}, + }; } return importedCalendars; @@ -41,8 +54,9 @@ export const getCalendarDetailAsync = createAsyncThunk< const events: CalendarEvent[] = calendar._embedded["dav:item"].flatMap( (eventdata: any) => { const vevents = eventdata.data[2] as any[][]; // array of ['vevent', RawEntry[], []] + const eventURL = eventdata._links.self.href; return vevents.map((vevent: any[]) => { - return parseCalendarEvent(vevent[1], color, calId); + return parseCalendarEvent(vevent[1], color, calId, eventURL); }); } ); @@ -54,18 +68,19 @@ export const putEventAsync = createAsyncThunk< { calId: string; events: CalendarEvent[] }, // Return type { cal: Calendars; newEvent: CalendarEvent } // Arg type >("calendars/putEvent", async ({ cal, newEvent }) => { - const response = await putEvent(cal, newEvent); + const response = await putEvent(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( (eventdata: any) => { const vevents = eventdata.data[2] as any[][]; + const eventURL = eventdata._links.self.href; return vevents.map((vevent: any[]) => { - return parseCalendarEvent(vevent[1], cal.color ?? "", cal.id); + return parseCalendarEvent(vevent[1], cal.color ?? "", cal.id, eventURL); }); } ); @@ -78,9 +93,9 @@ export const putEventAsync = createAsyncThunk< export const deleteEventAsync = createAsyncThunk< { calId: string; eventId: string }, // Return type - { calId: string; eventId: string } // Arg type ->("calendars/delEvent", async ({ calId, eventId }) => { - const response = await deleteEvent(calId, eventId); + { calId: string; eventId: string; eventURL: string } // Arg type +>("calendars/delEvent", async ({ calId, eventId, eventURL }) => { + const response = await deleteEvent(eventURL); return { calId, eventId }; }); @@ -105,6 +120,11 @@ const CalendarSlice = createSlice({ } state.list[action.payload.calendarUid].events[action.payload.event.uid] = action.payload.event; + state.list[action.payload.calendarUid].events[ + action.payload.event.uid + ].URL = `dav/calendars/${action.payload.calendarUid}/${ + action.payload.event.uid.split("/")[0] + }.isc`; }, removeEvent: ( state, diff --git a/src/features/Calendars/CalendarTypes.ts b/src/features/Calendars/CalendarTypes.ts index 1bb5aff..70fcf3d 100644 --- a/src/features/Calendars/CalendarTypes.ts +++ b/src/features/Calendars/CalendarTypes.ts @@ -3,8 +3,10 @@ import { CalendarEvent } from "../Events/EventsTypes"; export interface Calendars { id: string; name: string; + delegated?: boolean; prodid?: string; color?: string; + ownerEmails?: string[]; description?: string; calscale?: string; version?: string; diff --git a/src/features/Events/EventApi.ts b/src/features/Events/EventApi.ts index 87cd76c..2b211ad 100644 --- a/src/features/Events/EventApi.ts +++ b/src/features/Events/EventApi.ts @@ -1,28 +1,24 @@ import { api } from "../../utils/apiUtils"; -import { Calendars } from "../Calendars/CalendarTypes"; import { CalendarEvent } from "./EventsTypes"; import { calendarEventToJCal } from "./eventUtils"; -export async function putEvent(cal: Calendars, event: CalendarEvent) { - const response = await api( - `dav/calendars/${cal.id}/${event.uid.split("/")[0]}.isc`, - { - method: "PUT", - body: JSON.stringify(calendarEventToJCal(event)), - headers: { - "content-type": "text/calendar; charset=utf-8", - }, - } - ).json(); - return response; +export async function putEvent(event: CalendarEvent) { + const response = await api(`dav${event.URL}`, { + method: "PUT", + body: JSON.stringify(calendarEventToJCal(event)), + headers: { + "content-type": "text/calendar; charset=utf-8", + }, + }); + if (response.status === 201) { + console.log("PUT (201) :", response.url); + } + return await response.json(); } -export async function deleteEvent(calId: string, eventId: string) { - const response = await api( - `dav/calendars/${calId}/${eventId.split("/")[0]}.isc`, - { - method: "DELETE", - } - ).json(); +export async function deleteEvent(eventURL: string) { + const response = await api(eventURL, { + method: "DELETE", + }).json(); return response; } 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/EventsTypes.ts b/src/features/Events/EventsTypes.ts index 8680709..7f4c744 100644 --- a/src/features/Events/EventsTypes.ts +++ b/src/features/Events/EventsTypes.ts @@ -1,6 +1,7 @@ import { userAttendee, userOrganiser } from "../User/userDataTypes"; export interface CalendarEvent { + URL: string; calId: string; uid: string; transp?: string; diff --git a/src/features/Events/eventUtils.ts b/src/features/Events/eventUtils.ts index a592970..0a55eeb 100644 --- a/src/features/Events/eventUtils.ts +++ b/src/features/Events/eventUtils.ts @@ -8,7 +8,8 @@ type RawEntry = [string, Record, string, any]; export function parseCalendarEvent( data: RawEntry[], color: string, - calendarid: string + calendarid: string, + eventURL: string ): CalendarEvent { const event: Partial = { color, attendee: [] }; let recurrenceId; @@ -75,6 +76,7 @@ export function parseCalendarEvent( event.uid = `${event.uid}/${recurrenceId}`; } + event.URL = eventURL; if (!event.uid || !event.start) { console.error( `missing crucial event param in calendar ${calendarid} `, @@ -98,7 +100,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 +119,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) { @@ -139,15 +141,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/userAPI.ts b/src/features/User/userAPI.ts index 4fae660..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; } @@ -25,3 +25,8 @@ export async function searchUsers(query: string) { avatarUrl: user.photos?.[0]?.url || "", })); } + +export async function getUserDetails(id: string) { + const user = await api.get(`api/users/${id}`).json(); + return user; +} 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; diff --git a/src/features/User/userSlice.ts b/src/features/User/userSlice.ts index 747aa56..359945b 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",