diff --git a/__test__/components/EventModifications.test.tsx b/__test__/components/EventModifications.test.tsx index e713c75..8a20947 100644 --- a/__test__/components/EventModifications.test.tsx +++ b/__test__/components/EventModifications.test.tsx @@ -2,7 +2,7 @@ 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 { act, screen } from "@testing-library/react"; import * as appHooks from "../../src/app/hooks"; import CalendarApp from "../../src/components/Calendar/Calendar"; import { renderWithProviders } from "../utils/Renderwithproviders"; @@ -88,16 +88,17 @@ describe("CalendarApp integration", () => { { timeout: 3000 } ); expect(eventEl).toBeInTheDocument(); + act(() => { + if (calendarApi) { + const fcEvent = calendarApi.getEventById("event1"); + expect(fcEvent?.title).toBe("Test Event"); + const oldEnd = new Date(today.getTime() + 3600000); // +1 hour + const newEnd = new Date(oldEnd.getTime() + 1800000); // +30 min - if (calendarApi) { - const fcEvent = calendarApi.getEventById("event1"); - expect(fcEvent?.title).toBe("Test Event"); - const oldEnd = new Date(today.getTime() + 3600000); // +1 hour - const newEnd = new Date(oldEnd.getTime() + 1800000); // +30 min + fcEvent?.setEnd(newEnd); - fcEvent?.setEnd(newEnd); - - expect(dispatch).toHaveBeenCalled(); - } + expect(dispatch).toHaveBeenCalled(); + } + }); }); }); diff --git a/__test__/features/Events/EventApi.test.tsx b/__test__/features/Events/EventApi.test.tsx new file mode 100644 index 0000000..d6662fe --- /dev/null +++ b/__test__/features/Events/EventApi.test.tsx @@ -0,0 +1,107 @@ +import { + putEvent, + moveEvent, + deleteEvent, +} from "../../../src/features/Events/EventApi"; +import { calendarEventToJCal } from "../../../src/features/Events/eventUtils"; +import { clientConfig } from "../../../src/features/User/oidcAuth"; +import { api } from "../../../src/utils/apiUtils"; +clientConfig.url = "https://example.com"; + +jest.mock("../../../src/utils/apiUtils"); + +const day = new Date(); + +const mockEvent = { + uid: "event1", + title: "Test Event", + timezone: "UTC", + calId: "667037022b752d0026472254/cal1", + URL: "/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics", + start: day, + end: day, + status: "PUBLIC", + organizer: { cn: "test", cal_address: "test@test.com" }, + attendee: [ + { + cn: "test", + cal_address: "test@test.com", + partstat: "NEEDS-ACTION", + rsvp: "TRUE", + role: "REQ-PARTICIPANT", + cutype: "INDIVIDUAL", + }, + { + cn: "John", + cal_address: "john@test.com", + partstat: "NEEDS-ACTION", + rsvp: "TRUE", + role: "REQ-PARTICIPANT", + cutype: "INDIVIDUAL", + }, + ], +}; + +describe("eventApi", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("putEvent sends PUT request with JCal body", async () => { + const mockResponse = { status: 201, url: "/dav/cals/test.ics" }; + (api as unknown as jest.Mock).mockReturnValue(mockResponse); + const result = await putEvent(mockEvent); + const expectedResult = calendarEventToJCal(mockEvent); + expect(api).toHaveBeenCalledWith( + "dav/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics", + expect.objectContaining({ + method: "PUT", + headers: { "content-type": "text/calendar; charset=utf-8" }, + body: JSON.stringify(expectedResult), + }) + ); + expect(result).toBe(mockResponse); + }); + + test("putEvent logs when status is 201", async () => { + const mockResponse = { status: 201, url: "/dav/cals/test.ics" }; + (api as unknown as jest.Mock).mockReturnValue(mockResponse); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + await putEvent(mockEvent); + expect(logSpy).toHaveBeenCalledWith("PUT (201) :", "/dav/cals/test.ics"); + + logSpy.mockRestore(); + }); + + test("moveEvent sends MOVE request with destination header", async () => { + const mockResponse = { status: 204 }; + (api as unknown as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue(mockResponse), + }); + const result = await moveEvent(mockEvent, "newurl.ics"); + + expect(api).toHaveBeenCalledWith( + "dav/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics", + expect.objectContaining({ + method: "MOVE", + headers: { + destination: "newurl.ics", + }, + }) + ); + }); + + test("deleteEvent sends DELETE request and returns json response", async () => { + const mockResponse = { ok: true }; + (api as unknown as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue(mockResponse), + }); + + const result = await deleteEvent("/calendars/test.ics"); + + expect(api).toHaveBeenCalledWith("dav/calendars/test.ics", { + method: "DELETE", + }); + }); +}); diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx index 01edee8..d69b891 100644 --- a/__test__/features/Events/EventDisplay.test.tsx +++ b/__test__/features/Events/EventDisplay.test.tsx @@ -1,13 +1,14 @@ -import { screen, fireEvent, waitFor } from "@testing-library/react"; +import { screen, fireEvent, waitFor, act } 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"; +import EventDisplayModal, { + InfoRow, + stringAvatar, + stringToColor, +} from "../../../src/features/Events/EventDisplay"; +import EventPreviewModal from "../../../src/features/Events/EventDisplayPreview"; -describe("Event Display", () => { +describe("Event Preview Display", () => { const mockOnClose = jest.fn(); const day = new Date(); const RealDateToLocaleString = Date.prototype.toLocaleString; @@ -39,8 +40,28 @@ describe("Event Display", () => { event1: { id: "event1", title: "Test Event", + calId: "667037022b752d0026472254/cal1", start: day.toISOString(), end: day.toISOString(), + organizer: { cn: "test", cal_address: "test@test.com" }, + attendee: [ + { + cn: "test", + cal_address: "test@test.com", + partstat: "NEEDS-ACTION", + rsvp: "TRUE", + role: "REQ-PARTICIPANT", + cutype: "INDIVIDUAL", + }, + { + cn: "John", + cal_address: "john@test.com", + partstat: "NEEDS-ACTION", + rsvp: "TRUE", + role: "REQ-PARTICIPANT", + cutype: "INDIVIDUAL", + }, + ], }, }, }, @@ -51,9 +72,11 @@ describe("Event Display", () => { events: { event1: { id: "event1", + calId: "otherCal/cal", title: "Test Event Other cal", start: day.toISOString(), end: day.toISOString(), + organizer: { cn: "john", cal_address: "john@test.com" }, }, }, }, @@ -73,8 +96,8 @@ describe("Event Display", () => { return RealDateToLocaleString.call(this, "en-UK", options); }); renderWithProviders( - { }); it("calls onClose when Cancel clicked", () => { renderWithProviders( - { expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); }); + it("Shows delete button only when calendar is own", () => { + // Renders the other cal event + renderWithProviders( + , + preloadedState + ); + expect(screen.queryByTestId("DeleteIcon")).not.toBeInTheDocument(); + // Renders the personnal cal event + renderWithProviders( + , + preloadedState + ); + expect(screen.queryByTestId("DeleteIcon")).toBeInTheDocument(); + }); + it("calls delete when Delete clicked", async () => { + renderWithProviders( + , + 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"); + }); + 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"); + }); +}); + +describe("Event Full Display", () => { + const mockOnClose = jest.fn(); + const day = new Date(); + + 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: "First Calendar", + color: "#FF0000", + events: { + event1: { + id: "event1", + title: "Test Event", + calId: "667037022b752d0026472254/cal1", + start: day.toISOString(), + end: day.toISOString(), + organizer: { cn: "test", cal_address: "test@test.com" }, + attendee: [ + { + cn: "test", + cal_address: "test@test.com", + partstat: "NEEDS-ACTION", + rsvp: "TRUE", + role: "REQ-PARTICIPANT", + cutype: "INDIVIDUAL", + }, + { + cn: "John", + cal_address: "john@test.com", + partstat: "NEEDS-ACTION", + rsvp: "TRUE", + role: "REQ-PARTICIPANT", + cutype: "INDIVIDUAL", + }, + ], + }, + }, + }, + "otherCal/cal": { + id: "otherCal/cal", + name: "Calendar 1", + color: "#FF0000", + events: { + event1: { + id: "event1", + calId: "otherCal/cal", + title: "Test Event Other cal", + start: day.toISOString(), + end: day.toISOString(), + organizer: { cn: "john", cal_address: "john@test.com" }, + }, + }, + }, + }, + pending: false, + }, + }; + + it("renders correctly event data", () => { + renderWithProviders( + , + preloadedState + ); + const tzOffset = day.getTimezoneOffset() * 60000; // offset in ms + const date = new Date(day.getTime() - tzOffset).toISOString().slice(0, 16); + + expect(screen.getByDisplayValue("Test Event")).toBeInTheDocument(); + expect( + screen.getAllByDisplayValue(new RegExp(date, "i"))[0] + ).toBeInTheDocument(); + expect( + screen.getAllByDisplayValue(new RegExp(date, "i")).length + ).toBeLessThanOrEqual(2); + + expect(screen.getByText("First Calendar")).toBeInTheDocument(); + }); + it("calls onClose when Cancel clicked", () => { + renderWithProviders( + , + preloadedState + ); + fireEvent.click(screen.getAllByTestId("CloseIcon")[0]); + + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); it("Shows delete button only when calendar is own", () => { // Renders the other cal event renderWithProviders( { // Renders the personnal cal event renderWithProviders( { it("calls delete when Delete clicked", async () => { renderWithProviders( { 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(); @@ -254,7 +727,6 @@ describe("Event Display", () => { renderWithProviders( { rsvpStateIsOrga ); - expect(screen.queryByText("Will you attend?")).not.toBeInTheDocument(); expect( screen.queryByRole("button", { name: "Accept" }) ).not.toBeInTheDocument(); @@ -314,7 +785,6 @@ describe("Event Display", () => { renderWithProviders( { await waitFor(() => { expect(spy).toHaveBeenCalled(); - expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); }); const updatedEvent = spy.mock.calls[0][0].newEvent; @@ -373,7 +842,6 @@ describe("Event Display", () => { renderWithProviders( { await waitFor(() => { expect(spy).toHaveBeenCalled(); - expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); }); const updatedEvent = spy.mock.calls[0][0].newEvent; @@ -432,7 +899,6 @@ describe("Event Display", () => { renderWithProviders( { await waitFor(() => { expect(spy).toHaveBeenCalled(); - expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); }); const updatedEvent = spy.mock.calls[0][0].newEvent; expect(updatedEvent.attendee[0].partstat).toBe("DECLINED"); }); + it("toggle Show More reveals extra fields", async () => { + const spy = jest + .spyOn(eventThunks, "getEventAsync") + .mockImplementation((payload) => { + return () => + Promise.resolve({ + calId: payload.calId, + event: + preloadedState.calendars.list["667037022b752d0026472254/cal1"] + .events["event1"], + }) as any; + }); + + renderWithProviders( + , + preloadedState + ); + act(() => { + fireEvent.click(screen.getByText("Show More")); + }); + + await waitFor(() => { + expect(spy).toHaveBeenCalled(); + }); + console.log(spy); + await waitFor(() => { + expect(screen.getByLabelText(/Alarm/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Repetition/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Visibility/i)).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText("Show Less")); + }); + + it("can edit title when user is organizer", () => { + renderWithProviders( + , + preloadedState + ); + const titleField = screen.getByLabelText("Title"); + fireEvent.change(titleField, { target: { value: "New Title" } }); + expect(screen.getByDisplayValue("New Title")).toBeInTheDocument(); + }); + it("calendar select is disabled when not organizer", () => { + 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", + cn: "Edgar Organiser", + }, + }, + }, + }, + }, + }, + }; + + renderWithProviders( + , + rsvpState + ); + expect(screen.getByLabelText("Calendar")).toHaveClass("Mui-disabled"); + }); + it("toggle all-day updates end date correctly", () => { + renderWithProviders( + , + preloadedState + ); + const allDayCheckbox = screen.getByLabelText("All day"); + fireEvent.click(allDayCheckbox); + expect(allDayCheckbox).toBeChecked(); + const date = day.toISOString().split("T")[0]; + + expect( + screen.getAllByDisplayValue(new RegExp(date, "i"))[0] + ).toBeInTheDocument(); + }); +}); + +describe("Helper functions", () => { + it("stringToColor generates consistent color", () => { + expect(stringToColor("Alice")).toMatch(/^#[0-9a-f]{6}$/); + expect(stringToColor("Alice")).toBe(stringToColor("Alice")); + }); + + it("stringAvatar returns correct props", () => { + const result = stringAvatar("Alice"); + expect(result.children).toBe("A"); + expect(result.sx.bgcolor).toMatch(/^#/); + }); + + it("InfoRow renders text and link if url is valid", () => { + renderWithProviders( + ico} + text="Meeting" + data="https://example.com" + /> + ); + expect(screen.getByText("Meeting").closest("a")).toHaveAttribute( + "href", + "https://example.com" + ); + }); }); diff --git a/__test__/features/Events/EventModal.test.tsx b/__test__/features/Events/EventModal.test.tsx index 57a19b6..9016a34 100644 --- a/__test__/features/Events/EventModal.test.tsx +++ b/__test__/features/Events/EventModal.test.tsx @@ -116,8 +116,11 @@ describe("EventPopover", () => { expect(screen.getByLabelText("End")).toBeInTheDocument(); expect(screen.getByLabelText("Description")).toBeInTheDocument(); expect(screen.getByLabelText("Location")).toBeInTheDocument(); + expect(screen.getByText("Show More")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Show More")); expect(screen.getByLabelText("Repetition")).toBeInTheDocument(); - expect(screen.getByLabelText("Time Zone")).toBeInTheDocument(); + expect(screen.getByLabelText("Alarm")).toBeInTheDocument(); + expect(screen.getByLabelText("Visibility")).toBeInTheDocument(); // Calendar options const select = screen.getByLabelText("Calendar"); fireEvent.mouseDown(select); @@ -242,7 +245,6 @@ describe("EventPopover", () => { uid: "6045c603-11ab-43c5-bc30-0641420bb3a8", description: "Discuss project", location: "Zoom", - repetition: "", organizer: { cn: "test", cal_address: "test@test.com" }, timezone: "Europe/Paris", transp: "OPAQUE", @@ -297,7 +299,6 @@ describe("EventPopover", () => { ).toBe(formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.end)).split("T")[0]); expect(receivedPayload.newEvent.location).toBe(newEvent.location); expect(receivedPayload.newEvent.organizer).toEqual(newEvent.organizer); - expect(receivedPayload.newEvent.repetition).toEqual(newEvent.repetition); expect(receivedPayload.newEvent.color).toEqual( preloadedState.calendars.list["667037022b752d0026472254/cal1"].color ); diff --git a/__test__/features/Events/eventUtils.test.ts b/__test__/features/Events/eventUtils.test.ts index 0dd1b55..145af5e 100644 --- a/__test__/features/Events/eventUtils.test.ts +++ b/__test__/features/Events/eventUtils.test.ts @@ -31,7 +31,12 @@ describe("parseCalendarEvent", () => { ["DTSTAMP", {}, "date-time", "2025-07-18T08:00:00Z"], ] as unknown as [string, Record, string, any]; - const result = parseCalendarEvent(rawData, baseColor, calendarId); + const result = parseCalendarEvent( + rawData, + baseColor, + calendarId, + "/calendars/test.ics" + ); expect(result.uid).toBe("event-1"); expect(result.title).toBe("Team Meeting"); @@ -71,7 +76,12 @@ describe("parseCalendarEvent", () => { ["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"], ]; - const result = parseCalendarEvent(rawData, baseColor, calendarId); + const result = parseCalendarEvent( + rawData, + baseColor, + calendarId, + "/calendars/test.ics" + ); expect(result.uid).toBe("event-2/2025-07-18T09:00:00Z"); }); @@ -81,7 +91,12 @@ describe("parseCalendarEvent", () => { ["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"], ]; - const result = parseCalendarEvent(rawDataMissingUid, baseColor, calendarId); + const result = parseCalendarEvent( + rawDataMissingUid, + baseColor, + calendarId, + "/calendars/test.ics" + ); expect(result.error).toMatch(/missing crucial event param/); const rawDataMissingStart: any = [["UID", {}, "text", "event-3"]]; @@ -89,7 +104,8 @@ describe("parseCalendarEvent", () => { const result2 = parseCalendarEvent( rawDataMissingStart, baseColor, - calendarId + calendarId, + "/calendars/test.ics" ); expect(result2.error).toMatch(/missing crucial event param/); }); @@ -102,7 +118,12 @@ describe("parseCalendarEvent", () => { ["ORGANIZER", {}, "cal-address", "jane@example.com"], ] as unknown as [string, Record, string, any]; - const result = parseCalendarEvent(rawData, baseColor, calendarId); + const result = parseCalendarEvent( + rawData, + baseColor, + calendarId, + "/calendars/test.ics" + ); expect(result.attendee).toEqual([ { @@ -135,6 +156,8 @@ describe("calendarEventToJCal", () => { it("should convert a CalendarEvent to JCal format", () => { const mockEvent = { uid: "event-123", + URL: "/calendars/test.ics", + calId: "test/test", title: "Team Meeting", start: new Date("2025-07-23T10:00:00"), end: new Date("2025-07-23T11:00:00"), @@ -144,7 +167,7 @@ describe("calendarEventToJCal", () => { allday: false, location: "Room 101", description: "Discuss project roadmap.", - repetition: "WEEKLY", + repetition: { freq: "WEEKLY" }, organizer: { cn: "Alice", cal_address: "alice@example.com", @@ -246,6 +269,8 @@ describe("calendarEventToJCal", () => { it("should convert a CalendarEvent to JCal format, with all day activated", () => { const mockEvent = { uid: "event-123", + URL: "/calendars/test.ics", + calId: "test/test", title: "Team Meeting", start: new Date("2025-07-23"), end: new Date("2025-07-23"), @@ -255,7 +280,7 @@ describe("calendarEventToJCal", () => { allday: true, location: "Room 101", description: "Discuss project roadmap.", - repetition: "WEEKLY", + repetition: { freq: "WEEKLY" }, organizer: { cn: "Alice", cal_address: "alice@example.com", @@ -285,7 +310,7 @@ describe("calendarEventToJCal", () => { ["summary", {}, "text", "Team Meeting"], ["transp", {}, "text", "OPAQUE"], ["dtstart", { tzid: "Europe/Paris" }, "date", "2025-07-23"], - ["dtend", { tzid: "Europe/Paris" }, "date", "2025-07-23"], + ["dtend", { tzid: "Europe/Paris" }, "date", "2025-07-24"], ["class", {}, "text", "PUBLIC"], ["location", {}, "text", "Room 101"], ["description", {}, "text", "Discuss project roadmap."], diff --git a/src/components/Attendees/AttendeeSearch.tsx b/src/components/Attendees/AttendeeSearch.tsx index 6e17588..44636b5 100644 --- a/src/components/Attendees/AttendeeSearch.tsx +++ b/src/components/Attendees/AttendeeSearch.tsx @@ -9,6 +9,7 @@ import { } from "@mui/material"; import { useEffect, useState } from "react"; import { searchUsers } from "../../features/User/userAPI"; +import { userAttendee } from "../../features/User/userDataTypes"; interface User { email: string; @@ -17,9 +18,13 @@ interface User { } export default function UserSearch({ + attendees, setAttendees, + disabled, }: { + attendees: userAttendee[]; setAttendees: Function; + disabled?: boolean; }) { const [query, setQuery] = useState(""); const [options, setOptions] = useState([]); @@ -40,6 +45,7 @@ export default function UserSearch({ x} fullWidth @@ -75,14 +81,20 @@ export default function UserSearch({ }} /> )} - renderOption={(props, option) => ( - - - - - - - )} + renderOption={(props, option) => { + if (attendees.find((a) => a.cal_address === option.email)) return; + return ( + + + + + + + ); + }} /> ); } diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index ecea758..e1fec6e 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -25,7 +25,7 @@ import { } from "../../utils/dateUtils"; import { Calendars } from "../../features/Calendars/CalendarTypes"; import { push } from "redux-first-history"; -import EventDisplayModal from "../../features/Events/EventDisplay"; +import EventPreviewModal from "../../features/Events/EventDisplayPreview"; import { createSelector } from "@reduxjs/toolkit"; import HelpOutlineIcon from "@mui/icons-material/HelpOutline"; import ClearIcon from "@mui/icons-material/Clear"; @@ -109,8 +109,10 @@ export default function CalendarApp() { }, [rangeKey, selectedCalendars]); const [anchorEl, setAnchorEl] = useState(null); - const [anchorElEventDisplay, setAnchorElEventDisplay] = - useState(null); + const [anchorPosition, setAnchorPosition] = useState<{ + top: number; + left: number; + } | null>(null); const [openEventDisplay, setOpenEventDisplay] = useState(false); const [eventDisplayedId, setEventDisplayedId] = useState(""); const [eventDisplayedCalId, setEventDisplayedCalId] = useState(""); @@ -130,7 +132,7 @@ export default function CalendarApp() { setSelectedRange(null); }; const handleCloseEventDisplay = () => { - setAnchorElEventDisplay(null); + setAnchorPosition(null); setOpenEventDisplay(false); }; @@ -316,7 +318,10 @@ export default function CalendarApp() { } else { console.log(info.event); setOpenEventDisplay(true); - setAnchorElEventDisplay(info.el); + setAnchorPosition({ + top: info.jsEvent.clientY, + left: info.jsEvent.clientX, + }); setEventDisplayedId(info.event.extendedProps.uid); setEventDisplayedCalId(info.event.extendedProps.calId); } @@ -367,7 +372,7 @@ export default function CalendarApp() { start: computedNewStart, end: computedNewEnd, } as CalendarEvent; - + console.log(event , newEvent); dispatch( putEventAsync({ cal: calendars[newEvent.calId], newEvent }) ); @@ -468,10 +473,10 @@ export default function CalendarApp() { onClose={() => setAnchorElCal(null)} /> {openEventDisplay && eventDisplayedId && eventDisplayedCalId && ( - diff --git a/src/components/Event/EventRepeat.tsx b/src/components/Event/EventRepeat.tsx new file mode 100644 index 0000000..433fc2f --- /dev/null +++ b/src/components/Event/EventRepeat.tsx @@ -0,0 +1,164 @@ +import { + FormControl, + InputLabel, + Select, + SelectChangeEvent, + MenuItem, + Box, + Stack, + Paper, + Typography, + TextField, + Checkbox, + List, + ListItem, + FormControlLabel, + FormGroup, + Radio, + RadioGroup, +} from "@mui/material"; +import { useState } from "react"; +import { RepetitionObject } from "../../features/Events/EventsTypes"; + +export default function RepeatEvent({ + repetition, + setRepetition, + isOwn = true, +}: { + repetition: RepetitionObject; + setRepetition: Function; + isOwn?: boolean; +}) { + console.log(JSON.stringify(repetition)); + + const repetitionValues = ["day", "week", "month", "year"]; + const [interval, setInterval] = useState(repetition.interval ?? 0); + const [selectedDays, setSelectedDays] = useState( + repetition.selectedDays ?? [] + ); + const [endOption, setEndOption] = useState(""); + const [occurrences, setOccurrences] = useState(repetition.occurrences) ?? 0; + const [endDate, setEndDate] = useState(repetition.endDate ?? ""); + const days = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]; + + const handleDayChange = (day: string) => { + setSelectedDays((prev: string[]) => + prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day] + ); + }; + return ( + + Repetition + + {repetition.freq && ( + + + Interval: + setInterval(Number(e.target.value))} + size="small" + sx={{ width: 80 }} + /> + + + { + repetitionValues[ + repetitionValues.findIndex((el) => el === repetition.freq) + ] + } + + + {repetition.freq === "weekly" && ( + + + On days: + + + {days.map((day) => ( + handleDayChange(day)} + /> + } + label={day} + /> + ))} + + + )} + + + End: + + setEndOption(e.target.value)} + > + } + label="Never" + /> + + } + label={ + + After + setOccurrences(Number(e.target.value))} + sx={{ width: 100 }} + inputProps={{ min: 1 }} + disabled={endOption !== "after"} + /> + occurrences + + } + /> + + } + label={ + + On + setEndDate(e.target.value)} + disabled={endOption !== "on"} + /> + + } + /> + + + + )} + + ); +} diff --git a/src/components/Menubar/Menubar.tsx b/src/components/Menubar/Menubar.tsx index 37b21fb..cc3ee09 100644 --- a/src/components/Menubar/Menubar.tsx +++ b/src/components/Menubar/Menubar.tsx @@ -74,7 +74,7 @@ export function Menubar() { >
{applist.map((prop: AppIconProps) => ( - + ))}
@@ -96,7 +96,6 @@ export function MainTitle() { function AppIcon({ prop }: { prop: AppIconProps }) { return ( ("calendars/getEvent", async (event) => { + const response: CalendarEvent = await getEvent(event); + return { + calId: event.calId, + event: response, + }; +}); + +export const moveEventAsync = createAsyncThunk< + { calId: string; events: CalendarEvent[] }, // Return type + { cal: Calendars; newEvent: CalendarEvent; newURL: string } // Arg type +>("calendars/moveEvent", async ({ cal, newEvent, newURL }) => { + const response = await moveEvent(newEvent, newURL); + const calEvents = (await getCalendar(cal.id, { + start: formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.start)), + end: formatDateToYYYYMMDDTHHMMSS( + 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, eventURL); + }); + } + ); + + return { + calId: cal.id, + events, + }; +}); + export const deleteEventAsync = createAsyncThunk< { calId: string; eventId: string }, // Return type { calId: string; eventId: string; eventURL: string } // Arg type @@ -205,6 +245,50 @@ const CalendarSlice = createSlice({ }); } ) + .addCase( + getEventAsync.fulfilled, + ( + state, + action: PayloadAction<{ calId: string; event: CalendarEvent }> + ) => { + state.pending = false; + if (!state.list[action.payload.calId]) { + state.list[action.payload.calId] = { + id: action.payload.calId, + events: {}, + } as Calendars; + } + + state.list[action.payload.calId].events[action.payload.event.uid] = + action.payload.event; + } + ) + .addCase( + moveEventAsync.fulfilled, + ( + state, + action: PayloadAction<{ calId: string; events: CalendarEvent[] }> + ) => { + state.pending = false; + if (!state.list[action.payload.calId]) { + state.list[action.payload.calId] = { + id: action.payload.calId, + events: {}, + } as Calendars; + } + action.payload.events.forEach((event) => { + state.list[action.payload.calId].events[event.uid] = event; + }); + Object.keys(state.list[action.payload.calId].events).forEach((id) => { + state.list[action.payload.calId].events[id].color = + state.list[action.payload.calId].color; + state.list[action.payload.calId].events[id].calId = + action.payload.calId; + state.list[action.payload.calId].events[id].timezone = + Intl.DateTimeFormat().resolvedOptions().timeZone; + }); + } + ) .addCase(deleteEventAsync.fulfilled, (state, action) => { state.pending = false; delete state.list[action.payload.calId].events[action.payload.eventId]; @@ -212,12 +296,18 @@ const CalendarSlice = createSlice({ .addCase(getCalendarDetailAsync.pending, (state) => { state.pending = true; }) + .addCase(getEventAsync.pending, (state) => { + state.pending = true; + }) .addCase(getCalendarsListAsync.pending, (state) => { state.pending = true; }) .addCase(putEventAsync.pending, (state) => { state.pending = true; }) + .addCase(moveEventAsync.pending, (state) => { + state.pending = true; + }) .addCase(deleteEventAsync.pending, (state) => { state.pending = true; }); diff --git a/src/features/Calendars/CalendarTypes.ts b/src/features/Calendars/CalendarTypes.ts index 70fcf3d..f62d4ee 100644 --- a/src/features/Calendars/CalendarTypes.ts +++ b/src/features/Calendars/CalendarTypes.ts @@ -7,6 +7,7 @@ export interface Calendars { prodid?: string; color?: string; ownerEmails?: string[]; + owner: string; description?: string; calscale?: string; version?: string; diff --git a/src/features/Events/EventApi.ts b/src/features/Events/EventApi.ts index 2b211ad..734c9f3 100644 --- a/src/features/Events/EventApi.ts +++ b/src/features/Events/EventApi.ts @@ -1,6 +1,20 @@ import { api } from "../../utils/apiUtils"; import { CalendarEvent } from "./EventsTypes"; -import { calendarEventToJCal } from "./eventUtils"; +import { calendarEventToJCal, parseCalendarEvent } from "./eventUtils"; +import ICAL from "ical.js"; + +export async function getEvent(event: CalendarEvent) { + const response = await api.get(`dav${event.URL}`); + const eventData = await response.text(); + const eventical = ICAL.parse(eventData); + const eventjson = parseCalendarEvent( + eventical[2][1][1], + event.color ?? "", + event.calId, + event.URL + ); + return { ...eventjson, ...event }; +} export async function putEvent(event: CalendarEvent) { const response = await api(`dav${event.URL}`, { @@ -13,12 +27,22 @@ export async function putEvent(event: CalendarEvent) { if (response.status === 201) { console.log("PUT (201) :", response.url); } - return await response.json(); + return response; +} + +export async function moveEvent(event: CalendarEvent, newUrl: string) { + const response = await api(`dav${event.URL}`, { + method: "MOVE", + headers: { + destination: newUrl, + }, + }); + return response; } export async function deleteEvent(eventURL: string) { - const response = await api(eventURL, { + const response = await api(`dav${eventURL}`, { method: "DELETE", - }).json(); + }); return response; } diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index 01c8de6..d716b34 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -1,6 +1,13 @@ import { useEffect, useState } from "react"; -import { deleteEventAsync, putEventAsync } from "../Calendars/CalendarSlice"; +import { + deleteEventAsync, + getEventAsync, + moveEventAsync, + putEventAsync, + removeEvent, +} from "../Calendars/CalendarSlice"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; +import AttendeeSelector from "../../components/Attendees/AttendeeSearch"; import { Popover, Button, @@ -13,30 +20,42 @@ import { IconButton, Avatar, Badge, + Modal, + TextField, + CardHeader, + FormControl, + InputLabel, + MenuItem, + Select, + SelectChangeEvent, + Checkbox, + FormControlLabel, + CardActions, + Link, } from "@mui/material"; -import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; 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 CancelIcon from "@mui/icons-material/Cancel"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import { userAttendee } from "../User/userDataTypes"; -import { putEvent } from "./EventApi"; +import { TIMEZONES } from "../../utils/timezone-data"; +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"; -function EventDisplayModal({ +export default function EventDisplayModal({ eventId, calId, - anchorEl, open, onClose, }: { eventId: string; calId: string; - anchorEl: HTMLElement | null; open: boolean; onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void; }) { @@ -46,35 +65,69 @@ function EventDisplayModal({ (state) => state.calendars.list[calId]?.events[eventId] ); const user = useAppSelector((state) => state.user); + const [showAllAttendees, setShowAllAttendees] = useState(false); + const [showMore, setShowMore] = useState(false); + + const calendars = Object.values( + useAppSelector((state) => state.calendars.list) + ); + + const userPersonnalCalendars: Calendars[] = calendars.filter( + (c) => c.id?.split("/")[0] === user.userData.openpaasId + ); + + // Form state + const [title, setTitle] = useState(event?.title ?? ""); + const [description, setDescription] = useState(event?.description ?? ""); + const [location, setLocation] = useState(event?.location ?? ""); + const [start, setStart] = useState( + formatLocalDateTime(new Date(event?.start ?? Date.now())) + ); + const [end, setEnd] = useState( + formatLocalDateTime(new Date(event?.end ?? Date.now())) + ); + const [allday, setAllDay] = useState(event?.allday); + const [repetition, setRepetition] = useState( + event.repetition ?? ({} as RepetitionObject) + ); + const [alarm, setAlarm] = useState(""); + const [busy, setBusy] = useState(""); + const [eventClass, setEventClass] = useState(event?.class ?? "PUBLIC"); + const [timezone, setTimezone] = useState(event?.timezone ?? "UTC"); + const [newCalId, setNewCalId] = useState(event.calId); + const [calendarid, setCalendarid] = useState( + calId.split("/")[0] === user.userData.openpaasId + ? userPersonnalCalendars.findIndex((cal) => cal.id === calId) + : calendars.findIndex((cal) => cal.id === calId) + ); + + const [attendees, setAttendees] = useState( + (event?.attendee || []).filter( + (a) => a.cal_address !== event?.organizer?.cal_address + ) + ); + 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 + ) ?? ({} as userAttendee); + + const isOwn = organizer?.cal_address === user.userData.email; + const isOwnCal = userPersonnalCalendars.find((cal) => cal.id === calId); + const attendeeDisplayLimit = 3; useEffect(() => { if (!event || !calendar) { onClose({}, "backdropClick"); } - }, [event, calendar, onClose]); + }, [open, eventId, dispatch, 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, @@ -84,129 +137,118 @@ function EventDisplayModal({ }; dispatch(putEventAsync({ cal: calendar, newEvent })); - onClose({}, "backdropClick"); } + const handleSave = async () => { + const newEventUID = crypto.randomUUID(); + + const newEvent: CalendarEvent = { + calId, + title, + URL: event.URL ?? `/calendars/${calId}/${newEventUID}.ics`, + start: new Date(start), + end: new Date(end), + allday, + uid: event.uid ?? newEventUID, + description, + location, + repetition, + class: eventClass, + organizer: event.organizer, + timezone, + attendee: [organizer, ...attendees], + transp: "OPAQUE", + color: userPersonnalCalendars[calendarid]?.color, + }; + + await dispatch( + putEventAsync({ + cal: userPersonnalCalendars[calendarid], + newEvent, + }) + ); + + if (newCalId !== calId) { + dispatch( + moveEventAsync({ + cal: userPersonnalCalendars[calendarid], + newEvent, + newURL: `/calendars/${newCalId}/${event.uid}.ics`, + }) + ); + dispatch(removeEvent({ calendarUid: calId, eventUid: event.uid })); + } + onClose({}, "backdropClick"); + }; + + const [detailsLoaded, setDetailsLoaded] = useState(false); + + const handleToggleShowMore = async () => { + if (!detailsLoaded) { + await dispatch(getEventAsync(event)); + setDetailsLoaded(true); + } + setShowMore(!showMore); + }; + + const calList = + calId.split("/")[0] === user.userData.openpaasId + ? Object.keys(userPersonnalCalendars).map((calendar, index) => ( + + + + {userPersonnalCalendars[index].name} + + + )) + : Object.keys(calendars).map((calendar, index) => ( + + + + {calendars[index].name} - {calendars[index].owner} + + + )); + return ( - - - {/* Top-right buttons */} - - - - - {calendar.id.split("/")[0] === user.userData.openpaasId && ( - { - onClose({}, "backdropClick"); - dispatch(deleteEventAsync({ calId, eventId })); - }} - > - - - )} + + + {/* Close button */} + onClose({}, "backdropClick")}> - - {event.title && ( - - {event.title} - - )} + - {/* Time info*/} - - {formatDate(event.start)} - {event.end && - ` – ${new Date(event.end).toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - })}`} - - - {/* Location */} - {event.location && ( - } - text={event.location} - /> - )} - - {/* Video */} - {event.x_openpass_videoconference && ( - } - text="Video conference available" - /> - )} - - {/* Attendees */} - {event.attendee?.length > 0 && ( - - Attendees: - {organizer && renderAttendeeBadge(organizer, "org", true)} - {visibleAttendees.map((a, idx) => - renderAttendeeBadge(a, idx.toString()) - )} - {attendees.length > attendeeDisplayLimit && ( - setShowAllAttendees(!showAllAttendees)} - > - {showAllAttendees - ? "Show less" - : `Show more (${ - attendees.length - attendeeDisplayLimit - } more)`} - - )} - - )} - - {/* Error */} - {event.error && ( - } - text={event.error} - error - /> - )} - - {/* Calendar color dot */} - - - - {calendar.name} - - - + + {/* Title */} + setTitle(e.target.value)} + size="small" + margin="dense" + /> {/* RSVP */} - {currentUserAttendee && ( - - - Will you attend? - + {currentUserAttendee && isOwnCal && ( + + + + )} + + {/* Calendar selector */} + + Calendar + + + + {/* Dates */} + + setStart(formatLocalDateTime(new Date(e.target.value))) + } + size="small" + margin="dense" + InputLabelProps={{ shrink: true }} + /> + + + setEnd(formatLocalDateTime(new Date(e.target.value))) + } + size="small" + margin="dense" + InputLabelProps={{ shrink: true }} + /> + + { + const endDate = new Date(end); + const startDate = new Date(start); + setAllDay(!allday); + if (endDate.getDate() === startDate.getDate()) { + endDate.setDate(startDate.getDate() + 1); + setEnd(formatLocalDateTime(endDate)); + } + }} + /> + } + label="All day" + /> + + {/* Description & Location */} + setDescription(e.target.value)} + size="small" + margin="dense" + multiline + rows={2} + /> + + {isOwn && ( + { + const newAttendeeList = attendees.concat(value); + setAttendees(newAttendeeList); + }} + /> + )} + + setLocation(e.target.value)} + size="small" + margin="dense" + /> + + {/* Video */} + {event.x_openpass_videoconference && ( + } + text="Video conference available" + data={event.x_openpass_videoconference} + /> + )} + + {/* Attendees */} + {event.attendee?.length > 0 && ( + + Attendees: + {organizer.cal_address && + renderAttendeeBadge(organizer, "org", true)} + {(showAllAttendees + ? attendees + : attendees.slice(0, attendeeDisplayLimit) + ).map((a, idx) => ( + + {renderAttendeeBadge(a, idx.toString())} + {isOwn && ( + { + const newAttendeesList = [...attendees]; + newAttendeesList.splice(idx, 1); + setAttendees(newAttendeesList); + }} + > + + + )} + + ))} + {attendees.length > attendeeDisplayLimit && ( + setShowAllAttendees(!showAllAttendees)} + > + {showAllAttendees + ? "Show less" + : `Show more (${ + attendees.length - attendeeDisplayLimit + } more)`} + + )} )} - {/* Description */} - {event.description && ( - - {event.description} - + + + {/* Extended options */} + {showMore && ( + <> + + + + Alarm + + + + + Visibility + + + {/* Error */} + {event.error && ( + + } + text={event.error} + error + /> + )} + )} + + + + {isOwn && ( + { + onClose({}, "backdropClick"); + dispatch( + deleteEventAsync({ calId, eventId, eventURL: event.URL }) + ); + }} + > + + + )} + + + {isOwn && ( + + )} + + - + ); } -function InfoRow({ +export function InfoRow({ icon, text, error = false, + data, }: { icon: React.ReactNode; text: string; error?: boolean; + data?: string; }) { return ( {icon} - {text} + {isValidUrl(data) ? {text} : text} ); } -function renderAttendeeBadge( +export function renderAttendeeBadge( a: userAttendee, key: string, isOrganizer?: boolean ) { - const statusIcon = + const classIcon = a.partstat === "ACCEPTED" ? ( ) : a.partstat === "DECLINED" ? ( @@ -301,7 +586,7 @@ function renderAttendeeBadge( overlap="circular" anchorOrigin={{ vertical: "bottom", horizontal: "left" }} badgeContent={ - statusIcon && ( + classIcon && ( - {statusIcon} + {classIcon} ) } @@ -344,16 +629,6 @@ function renderAttendeeBadge( ); } -function formatDate(date: Date) { - return new Date(date).toLocaleString(undefined, { - weekday: "long", - month: "long", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); -} - export function stringToColor(string: string) { let hash = 0; for (let i = 0; i < string.length; i++) { @@ -369,11 +644,9 @@ export function stringToColor(string: string) { return color; } -function stringAvatar(name: string) { +export function stringAvatar(name: string) { return { sx: { width: 24, height: 24, fontSize: 18, bgcolor: stringToColor(name) }, children: name[0], }; } - -export default EventDisplayModal; diff --git a/src/features/Events/EventDisplayPreview.tsx b/src/features/Events/EventDisplayPreview.tsx new file mode 100644 index 0000000..3f9a220 --- /dev/null +++ b/src/features/Events/EventDisplayPreview.tsx @@ -0,0 +1,357 @@ +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, + Avatar, + Badge, + PopoverPosition, +} from "@mui/material"; +import EditIcon from "@mui/icons-material/Edit"; +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 CancelIcon from "@mui/icons-material/Cancel"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import { userAttendee } from "../User/userDataTypes"; +import EventDisplayModal, { + InfoRow, + renderAttendeeBadge, + stringAvatar, +} from "./EventDisplay"; +import { getEvent } from "./EventApi"; +import { CalendarEvent } from "./EventsTypes"; + +export default function EventPreviewModal({ + eventId, + calId, + anchorPosition, + open, + onClose, +}: { + eventId: string; + calId: string; + anchorPosition: PopoverPosition | null; + open: boolean; + onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void; +}) { + const dispatch = useAppDispatch(); + const calendars = useAppSelector((state) => state.calendars); + const calendar = 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 [openFullDisplay, setOpenFullDisplay] = useState(false); + + 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 ( + <> + + + {/* Top-right buttons */} + + {user.userData.email !== event.organizer?.cal_address && ( + { + setOpenFullDisplay(!openFullDisplay); + }} + > + + + )} + {user.userData.email === event.organizer?.cal_address && ( + <> + { + setOpenFullDisplay(!openFullDisplay); + }} + > + + + { + onClose({}, "backdropClick"); + dispatch( + deleteEventAsync({ calId, eventId, eventURL: event.URL }) + ); + }} + > + + + + )} + onClose({}, "backdropClick")} + > + + + + + + {event.title && ( + + {event.title} + + )} + + {/* Time info*/} + + {formatDate(event.start, event.allday)} + {event.end && + formatEnd(event.start, event.end, event.allday) && + ` – ${formatEnd(event.start, event.end, event.allday)}`} + + + {/* Location */} + {event.location && ( + } + text={event.location} + /> + )} + + {/* Video */} + {event.x_openpass_videoconference && ( + } + text="Video conference available" + data={event.x_openpass_videoconference} + /> + )} + + {/* Attendees */} + {event.attendee?.length > 0 && ( + + Attendees: + {organizer && renderAttendeeBadge(organizer, "org", true)} + {visibleAttendees.map((a, idx) => + renderAttendeeBadge(a, idx.toString()) + )} + {attendees.length > attendeeDisplayLimit && ( + setShowAllAttendees(!showAllAttendees)} + > + {showAllAttendees + ? "Show less" + : `Show more (${ + attendees.length - attendeeDisplayLimit + } more)`} + + )} + + )} + + {/* Error */} + {event.error && ( + } + text={event.error} + error + /> + )} + + {/* Calendar color dot */} + + + + {calendar.name} + + + + + {/* RSVP */} + {currentUserAttendee && ( + + + Will you attend? + + + + + + + + )} + + {/* Description */} + {event.description && ( + + {event.description} + + )} + + + + 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", + }); + } +} diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx index c269402..4720a34 100644 --- a/src/features/Events/EventModal.tsx +++ b/src/features/Events/EventModal.tsx @@ -2,6 +2,10 @@ import { CalendarApi, DateSelectArg } from "@fullcalendar/core"; import { Box, Button, + Card, + CardActions, + CardContent, + CardHeader, FormControl, InputLabel, MenuItem, @@ -18,8 +22,9 @@ import { TIMEZONES } from "../../utils/timezone-data"; import { putEventAsync } from "../Calendars/CalendarSlice"; import { Calendars } from "../Calendars/CalendarTypes"; import { userAttendee } from "../User/userDataTypes"; -import { CalendarEvent } from "./EventsTypes"; +import { CalendarEvent, RepetitionObject } from "./EventsTypes"; import { createSelector } from "@reduxjs/toolkit"; +import RepeatEvent from "../../components/Event/EventRepeat"; function EventPopover({ anchorEl, @@ -56,6 +61,7 @@ function EventPopover({ selectPersonnalCalendars ); const timezones = TIMEZONES.aliases; + const [showMore, setShowMore] = useState(false); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); @@ -64,8 +70,14 @@ function EventPopover({ const [end, setEnd] = useState(""); const [calendarid, setCalendarid] = useState(0); const [allday, setAllDay] = useState(false); - const [repetition, setRepetition] = useState(""); + const [repetition, setRepetition] = useState( + {} as RepetitionObject + ); const [attendees, setAttendees] = useState([]); + const [alarm, setAlarm] = useState(""); + const [eventClass, setEventClass] = useState("PUBLIC"); + const [busy, setBusy] = useState(""); + const [timezone, setTimezone] = useState( Intl.DateTimeFormat().resolvedOptions().timeZone ); @@ -78,14 +90,18 @@ function EventPopover({ }, [selectedRange]); const handleSave = async () => { + const newEventUID = crypto.randomUUID(); + const newEvent: CalendarEvent = { calId: userPersonnalCalendars[calendarid].id, title, + URL: `/calendars/${userPersonnalCalendars[calendarid].id}/${newEventUID}.ics`, start: new Date(start), allday, - uid: crypto.randomUUID(), + uid: newEventUID, description, location, + class: eventClass, repetition, organizer, timezone, @@ -130,172 +146,212 @@ function EventPopover({ open={open} anchorEl={anchorEl} onClose={onClose} - anchorOrigin={{ vertical: "top", horizontal: "left" }} - transformOrigin={{ vertical: "top", horizontal: "left" }} + anchorOrigin={{ + vertical: "center", + horizontal: "center", + }} + transformOrigin={{ + vertical: "center", + horizontal: "center", + }} > - - - Create Event - + + + + setTitle(e.target.value)} + size="small" + margin="dense" + /> + + Calendar + + - setTitle(e.target.value)} - size="small" - margin="dense" - /> - - Calendar - - - - { - const newStart = e.target.value; - setStart(newStart); - const newRange = { - ...selectedRange, - start: new Date(newStart), - startStr: newStart, - allDay: allday, - }; - setSelectedRange(newRange); - calendarRef.current?.select(newRange); - }} - size="small" - margin="dense" - InputLabelProps={{ shrink: true }} - /> - { - const newEnd = e.target.value; - setEnd(newEnd); - const newRange = { - ...selectedRange, - end: new Date(newEnd), - endStr: newEnd, - allDay: allday, - }; - setSelectedRange(newRange); - calendarRef.current?.select(newRange); - }} - size="small" - margin="dense" - InputLabelProps={{ shrink: true }} - /> - - setDescription(e.target.value)} - size="small" - margin="dense" - multiline - rows={2} - /> - setLocation(e.target.value)} - size="small" - margin="dense" - /> + { + const newEnd = e.target.value; + setEnd(newEnd); + const newRange = { + ...selectedRange, + end: new Date(newEnd), + endStr: newEnd, + allDay: allday, + }; + setSelectedRange(newRange); + calendarRef.current?.select(newRange); + }} + size="small" + margin="dense" + InputLabelProps={{ shrink: true }} + /> + + setDescription(e.target.value)} + size="small" + margin="dense" + multiline + rows={2} + /> + setLocation(e.target.value)} + size="small" + margin="dense" + /> + + {/* Extended options */} + {showMore && ( + <> + + + Alarm + + - - - - - + + Visibility + + + + )} + + + + + + + + + + ); } export default EventPopover; -function formatLocalDateTime(date: Date): string { +export function formatLocalDateTime(date: Date): string { const pad = (n: number) => n.toString().padStart(2, "0"); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad( date.getDate() diff --git a/src/features/Events/EventsTypes.ts b/src/features/Events/EventsTypes.ts index 7f4c744..eb0a13f 100644 --- a/src/features/Events/EventsTypes.ts +++ b/src/features/Events/EventsTypes.ts @@ -8,7 +8,7 @@ export interface CalendarEvent { start: Date; // ISO date end?: Date; class?: string; - x_openpass_videoconference?: unknown; + x_openpass_videoconference?: string; title?: string; description?: string; location?: string; @@ -17,9 +17,17 @@ export interface CalendarEvent { stamp?: Date; sequence?: Number; color?: string; - allday?: Boolean; + allday?: boolean; error?: string; status?: string; timezone: string; - repetition?: string; + repetition?: RepetitionObject; +} + +export interface RepetitionObject { + freq: string; + interval?: number; + selectedDays?: string[]; + occurrences?: number; + endDate?: string; } diff --git a/src/features/Events/eventUtils.ts b/src/features/Events/eventUtils.ts index 0a55eeb..088eb0f 100644 --- a/src/features/Events/eventUtils.ts +++ b/src/features/Events/eventUtils.ts @@ -13,6 +13,7 @@ export function parseCalendarEvent( ): CalendarEvent { const event: Partial = { color, attendee: [] }; let recurrenceId; + const dateRegex = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; for (const [key, params, type, value] of data) { switch (key.toLowerCase()) { @@ -24,9 +25,19 @@ export function parseCalendarEvent( break; case "dtstart": event.start = value; + if (dateRegex.test(value)) { + event.allday = true; + } else { + event.allday = false; + } break; case "dtend": event.end = value; + if (dateRegex.test(value)) { + event.allday = true; + } else { + event.allday = false; + } break; case "class": event.class = value; @@ -70,6 +81,22 @@ export function parseCalendarEvent( break; case "status": event.status = String(value); + break; + case "rrule": + event.repetition = { freq: value.freq.toLowerCase() }; + if (value.byday) { + event.repetition.selectedDays = value.byday; + } + if (value.until) { + event.repetition.selectedDays = value.endDate; + } + if (value.count) { + event.repetition.selectedDays = value.occurrences; + } + if (value.interval) { + event.repetition.interval = value.interval; + } + break; } } if (recurrenceId && event.uid) { @@ -94,7 +121,7 @@ export function calendarEventToJCal(event: CalendarEvent): any[] { const vevent: any[] = [ "vevent", [ - ["uid", {}, "text", event.uid], + ["uid", {}, "text", event.uid.split("/")[0]], ["transp", {}, "text", event.transp ?? "OPAQUE"], [ "dtstart", @@ -115,6 +142,9 @@ export function calendarEventToJCal(event: CalendarEvent): any[] { ]; if (event.end) { + if (event.allday && event.end.getTime() === event.start.getTime()) { + event.end.setDate(event.start.getDate() + 1); + } vevent[1].push([ "dtend", { tzid }, @@ -136,8 +166,21 @@ export function calendarEventToJCal(event: CalendarEvent): any[] { if (event.description) { vevent[1].push(["description", {}, "text", event.description]); } - if (event.repetition) { - vevent[1].push(["rrule", {}, "recur", { freq: event.repetition }]); + if (event.repetition?.freq) { + const repetitionRule: Record = { freq: event.repetition.freq }; + if (event.repetition.interval) { + repetitionRule["interval"] = event.repetition.interval; + } + if (event.repetition.occurrences) { + repetitionRule["count"] = event.repetition.occurrences; + } + if (event.repetition.endDate) { + repetitionRule["until"] = event.repetition.endDate; + } + if (event.repetition.selectedDays) { + repetitionRule["byday"] = event.repetition.selectedDays; + } + vevent[1].push(["rrule", {}, "recur", repetitionRule]); } event.attendee.forEach((att) => { diff --git a/src/features/User/userAPI.ts b/src/features/User/userAPI.ts index 9c042ac..d6bc2a8 100644 --- a/src/features/User/userAPI.ts +++ b/src/features/User/userAPI.ts @@ -16,14 +16,12 @@ export async function searchUsers(query: string) { }) .json(); - return response - .filter((user) => user.objectType === "user") - .map((user) => ({ - email: user.emailAddresses?.[0]?.value || "", - displayName: - user.names?.[0]?.displayName || user.emailAddresses?.[0]?.value, - avatarUrl: user.photos?.[0]?.url || "", - })); + return response.map((user) => ({ + email: user.emailAddresses?.[0]?.value || "", + displayName: + user.names?.[0]?.displayName || user.emailAddresses?.[0]?.value, + avatarUrl: user.photos?.[0]?.url || "", + })); } export async function getUserDetails(id: string) { diff --git a/src/utils/apiUtils.ts b/src/utils/apiUtils.ts index 924af3b..2050e24 100644 --- a/src/utils/apiUtils.ts +++ b/src/utils/apiUtils.ts @@ -43,3 +43,14 @@ export function redirectTo(url: URL) { export function getLocation() { return window.location.href; } + +export function isValidUrl(string?: string) { + let url; + + try { + url = new URL(string ?? ""); + } catch (_) { + return false; + } + return url; +}