Merge pull request #44 from linagora/31-event-creation

event creation
This commit is contained in:
Camille Moussu
2025-07-25 14:56:51 +02:00
committed by GitHub
17 changed files with 3360 additions and 183 deletions
+131 -114
View File
@@ -1,69 +1,68 @@
import { screen, fireEvent } from "@testing-library/react"; import { screen, fireEvent, waitFor } from "@testing-library/react";
import { import * as eventThunks from "../../../src/features/Calendars/CalendarSlice";
addEvent,
createCalendar,
} from "../../../src/features/Calendars/CalendarSlice";
import { renderWithProviders } from "../../utils/Renderwithproviders"; import { renderWithProviders } from "../../utils/Renderwithproviders";
import EventPopover from "../../../src/features/Events/EventModal"; import EventPopover from "../../../src/features/Events/EventModal";
import { DateSelectArg } from "@fullcalendar/core"; import { DateSelectArg } from "@fullcalendar/core";
import { randomUUID } from "crypto";
import { formatDateToYYYYMMDDTHHMMSS } from "../../../src/utils/dateUtils";
import preview from "jest-preview"; import preview from "jest-preview";
import * as appHooks from "../../../src/app/hooks"; // Import the module import { formatDateToYYYYMMDDTHHMMSS } from "../../../src/utils/dateUtils";
describe("EventPopover", () => { describe("EventPopover", () => {
const mockOnClose = jest.fn(); const mockOnClose = jest.fn();
jest.mock("crypto"); const mockSetSelectedRange = jest.fn();
const dispatch = jest.fn(); const mockCalendarRef = { current: { select: jest.fn() } } as any;
jest.spyOn(appHooks, "useAppDispatch").mockReturnValue(dispatch);
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
const renderPopover = ( const preloadedState = {
selectedRange = { user: {
startStr: "2025-07-18T09:00", userData: {
endStr: "2025-07-18T10:00", sub: "test",
start: new Date("2025-07-18T09:00"), email: "test@test.com",
end: new Date("2025-07-18T10:00"), sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro",
allDay: false, openpaasId: "667037022b752d0026472254",
resource: undefined, },
} as unknown as DateSelectArg organiserData: {
) => { cn: "test",
const preloadedState = { cal_address: "mailto:test@test.com",
user: { },
userData: { },
sub: "test", calendars: {
email: "test@test.com", list: {
sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", "667037022b752d0026472254/cal1": {
openpaasId: "667037022b752d0026472254", id: "667037022b752d0026472254/cal1",
name: "Calendar 1",
color: "#FF0000",
}, },
organiserData: { "667037022b752d0026472254/cal2": {
cn: "test", id: "667037022b752d0026472254/cal2",
cal_address: "mailto:test@test.com", name: "Calendar 2",
color: "#00FF00",
}, },
}, },
calendars: { pending: false,
list: { },
"667037022b752d0026472254/cal1": { };
name: "Calendar 1",
color: "#FF0000", const defaultSelectedRange = {
}, startStr: "2025-07-18T09:00",
"667037022b752d0026472254/cal2": { endStr: "2025-07-18T10:00",
name: "Calendar 2", start: new Date("2025-07-18T09:00"),
color: "#00FF00", end: new Date("2025-07-18T10:00"),
}, allDay: false,
}, resource: undefined,
pending: false, } as unknown as DateSelectArg;
},
}; const renderPopover = (selectedRange = defaultSelectedRange) => {
renderWithProviders( renderWithProviders(
<EventPopover <EventPopover
anchorEl={document.body} anchorEl={document.body}
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
selectedRange={selectedRange} selectedRange={selectedRange}
setSelectedRange={mockSetSelectedRange}
calendarRef={mockCalendarRef}
/>, />,
preloadedState preloadedState
); );
@@ -72,118 +71,136 @@ describe("EventPopover", () => {
it("renders correctly with inputs and calendar options", () => { it("renders correctly with inputs and calendar options", () => {
renderPopover(); renderPopover();
expect(screen.getByText(/Create Event/i)).toBeInTheDocument(); expect(screen.getByText("Create Event")).toBeInTheDocument();
expect(screen.getByLabelText(/title/i)).toBeInTheDocument(); expect(screen.getByLabelText("Calendar")).toBeInTheDocument();
expect(screen.getByLabelText(/Start/i)).toBeInTheDocument(); expect(screen.getByLabelText("Title")).toBeInTheDocument();
expect(screen.getByLabelText(/End/i)).toBeInTheDocument(); expect(screen.getByLabelText("Start")).toBeInTheDocument();
expect(screen.getByLabelText(/Description/i)).toBeInTheDocument(); expect(screen.getByLabelText("End")).toBeInTheDocument();
expect(screen.getByLabelText(/Location/i)).toBeInTheDocument(); expect(screen.getByLabelText("Description")).toBeInTheDocument();
preview.debug(); expect(screen.getByLabelText("Location")).toBeInTheDocument();
expect(screen.getByLabelText("Repetition")).toBeInTheDocument();
expect(screen.getByLabelText("Time Zone")).toBeInTheDocument();
// Calendar options // Calendar options
const select = screen.getByRole("combobox"); const select = screen.getByLabelText("Calendar");
fireEvent.mouseDown(select); fireEvent.mouseDown(select);
expect(screen.getByText("Calendar 1")).toBeInTheDocument(); expect(screen.getAllByRole("option")).toHaveLength(2);
expect(screen.getByText("Calendar 2")).toBeInTheDocument();
}); });
it("fills start and end from selectedRange", () => { it("fills start from selectedRange", () => {
const selectedRange = { renderPopover({
startStr: "2026-07-20T10:00", startStr: "2026-07-20T10:00",
endStr: "2026-07-20T12:00", endStr: "2026-07-20T12:00",
start: new Date("2026-07-20T10:00"), start: new Date("2026-07-20T10:00"),
end: new Date("2026-07-20T12:00"), end: new Date("2026-07-20T12:00"),
allDay: false, allDay: false,
resource: undefined, } as unknown as DateSelectArg);
};
renderPopover(selectedRange as unknown as DateSelectArg); expect(screen.getByLabelText("Start")).toHaveValue("2026-07-20T10:00");
expect(screen.getByLabelText(/Start/i)).toHaveValue("2026-07-20T10:00");
expect(screen.getByLabelText(/End/i)).toHaveValue("2026-07-20T12:00");
}); });
it("updates inputs on change", () => { it("updates inputs on change", () => {
renderWithProviders( renderPopover();
<EventPopover
anchorEl={document.body}
open={true}
onClose={mockOnClose}
selectedRange={null}
/>
);
fireEvent.change(screen.getByLabelText(/title/i), { fireEvent.change(screen.getByLabelText("Title"), {
target: { value: "My Event" }, target: { value: "My Event" },
}); });
expect(screen.getByLabelText(/title/i)).toHaveValue("My Event"); expect(screen.getByLabelText("Title")).toHaveValue("My Event");
fireEvent.change(screen.getByLabelText(/Description/i), { fireEvent.change(screen.getByLabelText("Description"), {
target: { value: "Event Description" }, target: { value: "Event Description" },
}); });
expect(screen.getByLabelText(/Description/i)).toHaveValue( expect(screen.getByLabelText("Description")).toHaveValue(
"Event Description" "Event Description"
); );
fireEvent.change(screen.getByLabelText(/Location/i), { fireEvent.change(screen.getByLabelText("Location"), {
target: { value: "Conference Room" }, target: { value: "Conference Room" },
}); });
expect(screen.getByLabelText(/Location/i)).toHaveValue("Conference Room"); expect(screen.getByLabelText("Location")).toHaveValue("Conference Room");
}); });
it("changes selected calendar", async () => { it("changes selected calendar", async () => {
renderPopover(); renderPopover();
const select = screen.getByRole("combobox"); const select = screen.getByLabelText("Calendar");
fireEvent.mouseDown(select); // Open menu fireEvent.mouseDown(select); // Open menu
const option = await screen.findByText("Calendar 2"); const option = await screen.findByText("Calendar 2");
fireEvent.click(option); fireEvent.click(option);
// The Select control value isn't easy to check directly because MUI Select wraps input, expect(screen.getAllByRole("combobox")[0]).toHaveTextContent("Calendar 2");
// but we can test by triggering save later
}); });
it("dispatches addEvent and calls onClose when Save is clicked", () => { it("dispatches putEventAsync and calls onClose when Save is clicked", async () => {
renderPopover(); renderPopover();
const newEvent = {
// Select calendar id title: "Meeting",
const select = screen.getByRole("combobox"); start: "2025-07-18T00:00:00.000Z",
fireEvent.mouseDown(select); end: "2025-07-19T00:00:00.000Z",
fireEvent.click(screen.getByText("Calendar 1")); allday: false,
uid: "6045c603-11ab-43c5-bc30-0641420bb3a8",
description: "Discuss project",
location: "Zoom",
repetition: "",
organizer: { cn: "test", cal_address: "mailto:test@test.com" },
timezone: "Europe/Paris",
transp: "OPAQUE",
};
// Fill inputs // Fill inputs
fireEvent.change(screen.getByLabelText(/title/i), { fireEvent.change(screen.getByLabelText("Title"), {
target: { value: "Meeting" }, target: { value: newEvent.title },
}); });
fireEvent.change(screen.getByLabelText(/Start/i), { fireEvent.click(screen.getByLabelText("All day"));
target: { value: "2025-07-18T09:00" }, fireEvent.change(screen.getByLabelText("Start"), {
target: { value: newEvent.start.split("T")[0] },
}); });
fireEvent.change(screen.getByLabelText(/End/i), { fireEvent.change(screen.getByLabelText("End"), {
target: { value: "2025-07-18T10:00" }, target: { value: newEvent.end.split("T")[0] },
});
fireEvent.change(screen.getByLabelText(/Description/i), {
target: { value: "Discuss project" },
});
fireEvent.change(screen.getByLabelText(/Location/i), {
target: { value: "Zoom" },
}); });
fireEvent.click(screen.getByText(/Save/i)); fireEvent.change(screen.getByLabelText("Description"), {
target: { value: newEvent.description },
expect(dispatch).toHaveBeenCalled();
// Extract dispatched action argument
const dispatchedArg = dispatch.mock.calls[0][0].payload;
console.log(dispatchedArg);
expect(dispatchedArg.calendarUid).toBe("667037022b752d0026472254/cal1");
expect(dispatchedArg.event.title).toBe("Meeting");
expect(dispatchedArg.event.description).toBe("Discuss project");
expect(dispatchedArg.event.location).toBe("Zoom");
expect(dispatchedArg.event.color).toBe("#FF0000");
expect(dispatchedArg.event.organizer).toEqual({
cn: "test",
cal_address: "mailto:test@test.com",
}); });
fireEvent.change(screen.getByLabelText("Location"), {
target: { value: newEvent.location },
});
preview.debug();
const spy = jest
.spyOn(eventThunks, "putEventAsync")
.mockImplementation((payload) => {
return () => Promise.resolve(payload) as any;
});
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(spy).toHaveBeenCalled();
});
const receivedPayload = spy.mock.calls[0][0];
expect(receivedPayload.cal).toEqual(
preloadedState.calendars.list["667037022b752d0026472254/cal1"]
);
console.log(receivedPayload.newEvent.start);
expect(receivedPayload.newEvent.title).toBe(newEvent.title);
expect(receivedPayload.newEvent.description).toBe(newEvent.description);
expect(
formatDateToYYYYMMDDTHHMMSS(receivedPayload.newEvent.start).split("T")[0]
).toBe(formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.start)).split("T")[0]);
expect(
formatDateToYYYYMMDDTHHMMSS(
receivedPayload.newEvent.end || new Date()
).split("T")[0]
).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
);
// onClose should be called // onClose should be called
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
@@ -192,7 +209,7 @@ describe("EventPopover", () => {
it("calls onClose when Cancel clicked", () => { it("calls onClose when Cancel clicked", () => {
renderPopover(); renderPopover();
fireEvent.click(screen.getByText(/Cancel/i)); fireEvent.click(screen.getByText("Cancel"));
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
}); });
+237 -5
View File
@@ -1,4 +1,8 @@
import { parseCalendarEvent } from "../../../src/features/Events/eventUtils"; import {
calendarEventToJCal,
parseCalendarEvent,
} from "../../../src/features/Events/eventUtils";
import { TIMEZONES } from "../../../src/utils/timezone-data";
describe("parseCalendarEvent", () => { describe("parseCalendarEvent", () => {
const baseColor = "#00FF00"; const baseColor = "#00FF00";
@@ -22,6 +26,8 @@ describe("parseCalendarEvent", () => {
["X-OPENPAAS-VIDEOCONFERENCE", {}, "text", "https://meet.link"], ["X-OPENPAAS-VIDEOCONFERENCE", {}, "text", "https://meet.link"],
["STATUS", {}, "text", "CONFIRMED"], ["STATUS", {}, "text", "CONFIRMED"],
["SEQUENCE", {}, "integer", "2"], ["SEQUENCE", {}, "integer", "2"],
["TRANSP", {}, "text", "OPAQUE"],
["CLASS", {}, "text", "PUBLIC"],
["DTSTAMP", {}, "date-time", "2025-07-18T08:00:00Z"], ["DTSTAMP", {}, "date-time", "2025-07-18T08:00:00Z"],
] as unknown as [string, Record<string, string>, string, any]; ] as unknown as [string, Record<string, string>, string, any];
@@ -37,6 +43,8 @@ describe("parseCalendarEvent", () => {
expect(result.sequence).toBe(2); expect(result.sequence).toBe(2);
expect(result.color).toBe(baseColor); expect(result.color).toBe(baseColor);
expect(result.status).toBe("CONFIRMED"); expect(result.status).toBe("CONFIRMED");
expect(result.transp).toBe("OPAQUE");
expect(result.class).toBe("PUBLIC");
expect(result.x_openpass_videoconference).toBe("https://meet.link"); expect(result.x_openpass_videoconference).toBe("https://meet.link");
expect(result.organizer).toEqual({ expect(result.organizer).toEqual({
@@ -88,10 +96,10 @@ describe("parseCalendarEvent", () => {
it("handles optional organizer and attendee fields gracefully", () => { it("handles optional organizer and attendee fields gracefully", () => {
const rawData = [ const rawData = [
["UID", {}, "text", "event-4"], ["UID", {}, "text", "event-4"],
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"], ["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
["ATTENDEE", {}, "cal-address", "mailto:john@example.com"], ["ATTENDEE", {}, "cal-address", "mailto:john@example.com"],
["ORGANIZER", {}, "cal-address", "mailto:jane@example.com"], ["ORGANIZER", {}, "cal-address", "mailto:jane@example.com"],
] as unknown as [string, Record<string, string>, string, any]; ] as unknown as [string, Record<string, string>, string, any];
const result = parseCalendarEvent(rawData, baseColor, calendarId); const result = parseCalendarEvent(rawData, baseColor, calendarId);
@@ -113,3 +121,227 @@ describe("parseCalendarEvent", () => {
}); });
}); });
}); });
describe("calendarEventToJCal", () => {
beforeAll(() => {
jest.mock("ical.js", () => ({
Timezone: jest.fn().mockImplementation(({ component, tzid }) => ({
component: {
jCal: [`vtimezone`, [], [["tzid", {}, "text", tzid]]],
},
})),
}));
});
it("should convert a CalendarEvent to JCal format", () => {
const mockEvent = {
uid: "event-123",
title: "Team Meeting",
start: new Date("2025-07-23T10:00:00"),
end: new Date("2025-07-23T11:00:00"),
timezone: "Europe/Paris",
transp: "OPAQUE",
class: "PUBLIC",
allday: false,
location: "Room 101",
description: "Discuss project roadmap.",
repetition: "WEEKLY",
organizer: {
cn: "Alice",
cal_address: "mailto:alice@example.com",
},
attendee: [
{
cn: "Bob",
partstat: "ACCEPTED",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
cal_address: "mailto:bob@example.com",
},
],
};
const result = calendarEventToJCal(mockEvent);
expect(result[0]).toBe("vcalendar");
const [vevent, vtimezone] = result[2];
expect(vevent[0]).toBe("vevent");
const props = vevent[1];
expect(props).toEqual(
expect.arrayContaining([
["uid", {}, "text", "event-123"],
["summary", {}, "text", "Team Meeting"],
["transp", {}, "text", "OPAQUE"],
["dtstart", { tzid: "Europe/Paris" }, "date-time", "20250723T100000"],
["dtend", { tzid: "Europe/Paris" }, "date-time", "20250723T110000"],
["class", {}, "text", "PUBLIC"],
["location", {}, "text", "Room 101"],
["description", {}, "text", "Discuss project roadmap."],
["x-openpaas-videoconference", {}, "unknown", null],
["rrule", {}, "recur", { freq: "WEEKLY" }],
[
"organizer",
{ cn: "Alice" },
"cal-address",
"mailto:alice@example.com",
],
[
"attendee",
{
cn: "Bob",
partstat: "ACCEPTED",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
},
"cal-address",
"mailto:bob@example.com",
],
])
);
expect(vtimezone[0]).toBe("vtimezone");
expect(vtimezone[2]).toEqual(
expect.arrayContaining([
[
"daylight",
[
["tzoffsetfrom", {}, "utc-offset", "+01:00"],
["tzoffsetto", {}, "utc-offset", "+02:00"],
["tzname", {}, "text", "CEST"],
["dtstart", {}, "date-time", "1970-03-29T02:00:00"],
[
"rrule",
{},
"recur",
{ byday: "-1SU", bymonth: 3, freq: "YEARLY" },
],
],
[],
],
[
"standard",
[
["tzoffsetfrom", {}, "utc-offset", "+02:00"],
["tzoffsetto", {}, "utc-offset", "+01:00"],
["tzname", {}, "text", "CET"],
["dtstart", {}, "date-time", "1970-10-25T03:00:00"],
[
"rrule",
{},
"recur",
{ byday: "-1SU", bymonth: 10, freq: "YEARLY" },
],
],
[],
],
])
);
});
it("should convert a CalendarEvent to JCal format, with all day activated", () => {
const mockEvent = {
uid: "event-123",
title: "Team Meeting",
start: new Date("2025-07-23"),
end: new Date("2025-07-23"),
timezone: "Europe/Paris",
transp: "OPAQUE",
class: "PUBLIC",
allday: true,
location: "Room 101",
description: "Discuss project roadmap.",
repetition: "WEEKLY",
organizer: {
cn: "Alice",
cal_address: "mailto:alice@example.com",
},
attendee: [
{
cn: "Bob",
partstat: "ACCEPTED",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
cal_address: "mailto:bob@example.com",
},
],
};
const result = calendarEventToJCal(mockEvent);
expect(result[0]).toBe("vcalendar");
const [vevent, vtimezone] = result[2];
expect(vevent[0]).toBe("vevent");
const props = vevent[1];
expect(props).toEqual(
expect.arrayContaining([
["uid", {}, "text", "event-123"],
["summary", {}, "text", "Team Meeting"],
["transp", {}, "text", "OPAQUE"],
["dtstart", { tzid: "Europe/Paris" }, "date", "2025-07-23"],
["dtend", { tzid: "Europe/Paris" }, "date", "2025-07-23"],
["class", {}, "text", "PUBLIC"],
["location", {}, "text", "Room 101"],
["description", {}, "text", "Discuss project roadmap."],
["x-openpaas-videoconference", {}, "unknown", null],
["rrule", {}, "recur", { freq: "WEEKLY" }],
[
"organizer",
{ cn: "Alice" },
"cal-address",
"mailto:alice@example.com",
],
[
"attendee",
{
cn: "Bob",
partstat: "ACCEPTED",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
},
"cal-address",
"mailto:bob@example.com",
],
])
);
expect(vtimezone[0]).toBe("vtimezone");
expect(vtimezone[2]).toEqual(
expect.arrayContaining([
[
"daylight",
[
["tzoffsetfrom", {}, "utc-offset", "+01:00"],
["tzoffsetto", {}, "utc-offset", "+02:00"],
["tzname", {}, "text", "CEST"],
["dtstart", {}, "date-time", "1970-03-29T02:00:00"],
[
"rrule",
{},
"recur",
{ byday: "-1SU", bymonth: 3, freq: "YEARLY" },
],
],
[],
],
[
"standard",
[
["tzoffsetfrom", {}, "utc-offset", "+02:00"],
["tzoffsetto", {}, "utc-offset", "+01:00"],
["tzname", {}, "text", "CET"],
["dtstart", {}, "date-time", "1970-10-25T03:00:00"],
[
"rrule",
{},
"recur",
{ byday: "-1SU", bymonth: 10, freq: "YEARLY" },
],
],
[],
],
])
);
});
});
+7
View File
@@ -23,6 +23,7 @@
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"i18next": "^23.7.9", "i18next": "^23.7.9",
"ical.js": "^2.2.0",
"ky": "^1.8.1", "ky": "^1.8.1",
"openid-client": "^6.5.3", "openid-client": "^6.5.3",
"react": "^19.1.0", "react": "^19.1.0",
@@ -7450,6 +7451,12 @@
"i18next-resources-for-ts": "bin/i18next-resources-for-ts.js" "i18next-resources-for-ts": "bin/i18next-resources-for-ts.js"
} }
}, },
"node_modules/ical.js": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ical.js/-/ical.js-2.2.0.tgz",
"integrity": "sha512-P8gjWkTEd5M/SEEvBVPPO/KC+V+HRNRZh3xfCDTVWmUTEfVbL8JaK5GTWS2MJ55aLMhfXhbh7kYzd0nrBARjsA==",
"license": "MPL-2.0"
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"dev": true, "dev": true,
+1
View File
@@ -18,6 +18,7 @@
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"i18next": "^23.7.9", "i18next": "^23.7.9",
"ical.js": "^2.2.0",
"ky": "^1.8.1", "ky": "^1.8.1",
"openid-client": "^6.5.3", "openid-client": "^6.5.3",
"react": "^19.1.0", "react": "^19.1.0",
+2 -3
View File
@@ -1,10 +1,9 @@
.App { .App {
text-align: center; text-align: center;
font-family:"Roboto"; font-family: "Roboto";
height: 100vh;
} }
.App-header { .App-header {
background-color: #282c34; background-color: #282c34;
min-height: 100vh; min-height: 100vh;
-2
View File
@@ -1,6 +1,5 @@
import { combineReducers, configureStore } from "@reduxjs/toolkit"; import { combineReducers, configureStore } from "@reduxjs/toolkit";
import userReducer from "../features/User/userSlice"; import userReducer from "../features/User/userSlice";
import eventsReducer from "../features/Events/EventsSlice";
import eventsCalendar from "../features/Calendars/CalendarSlice"; import eventsCalendar from "../features/Calendars/CalendarSlice";
import { createReduxHistoryContext } from "redux-first-history"; import { createReduxHistoryContext } from "redux-first-history";
import { createBrowserHistory } from "history"; import { createBrowserHistory } from "history";
@@ -11,7 +10,6 @@ const { createReduxHistory, routerMiddleware, routerReducer } =
const rootReducer = combineReducers({ const rootReducer = combineReducers({
router: routerReducer, router: routerReducer,
user: userReducer, user: userReducer,
events: eventsReducer,
calendars: eventsCalendar, calendars: eventsCalendar,
}); });
+29 -2
View File
@@ -4,12 +4,14 @@ main {
} }
.calendar { .calendar {
width: 100%; flex-grow: 1;
height: 100%; overflow: "hidden";
height: 90vh;
} }
.sidebar { .sidebar {
border-right: 2px gray; border-right: 2px gray;
width: 20vw;
} }
.fc .fc-daygrid-day { .fc .fc-daygrid-day {
@@ -21,6 +23,31 @@ main {
height: 60px !important; /* Default is ~40px */ height: 60px !important; /* Default is ~40px */
} }
.fc-timegrid-slot {
position: relative;
}
.fc-timegrid-slot::after {
content: "";
position: absolute;
top: 50%;
left: 0;
right: 0;
border-top: 1px dotted #ccc;
pointer-events: none;
z-index: 1;
}
.fc .fc-timegrid-slot-label {
display: flex;
align-items: flex-start;
padding-top: 2px;
height: 100%;
}
[data-theme="dark"] .fc-timegrid-slot::after {
border-top: 1px dotted rgba(255, 255, 255, 0.2);
}
#calendar { #calendar {
flex: 1; flex: 1;
overflow: hidden; /* Important: let FullCalendar handle internal scroll */ overflow: hidden; /* Important: let FullCalendar handle internal scroll */
+7 -4
View File
@@ -104,6 +104,7 @@ export default function CalendarApp() {
}; };
const handleClosePopover = () => { const handleClosePopover = () => {
calendarRef.current?.unselect();
setAnchorEl(null); setAnchorEl(null);
setSelectedRange(null); setSelectedRange(null);
}; };
@@ -217,9 +218,9 @@ export default function CalendarApp() {
initialView="timeGridWeek" initialView="timeGridWeek"
editable={true} editable={true}
selectable={true} selectable={true}
height={"100%"}
select={handleDateSelect} select={handleDateSelect}
nowIndicator nowIndicator
selectMirror={true}
views={{ views={{
timeGridWeek: { titleFormat: { month: "long", year: "numeric" } }, timeGridWeek: { titleFormat: { month: "long", year: "numeric" } },
}} }}
@@ -227,9 +228,9 @@ export default function CalendarApp() {
events={filteredEvents} events={filteredEvents}
weekNumbers weekNumbers
weekNumberFormat={{ week: "long" }} weekNumberFormat={{ week: "long" }}
slotDuration={"00:30:00"} slotDuration={"01:00:00"}
slotLabelInterval={"00:30:00"} scrollTime={"08:00:00"}
scrollTime={"09:00:00"} unselectAuto={false}
allDayText="" allDayText=""
slotLabelFormat={{ slotLabelFormat={{
hour: "2-digit", hour: "2-digit",
@@ -285,6 +286,8 @@ export default function CalendarApp() {
open={Boolean(anchorEl)} open={Boolean(anchorEl)}
onClose={handleClosePopover} onClose={handleClosePopover}
selectedRange={selectedRange} selectedRange={selectedRange}
setSelectedRange={setSelectedRange}
calendarRef={calendarRef}
/> />
<CalendarPopover <CalendarPopover
anchorEl={anchorElCal} anchorEl={anchorElCal}
+1
View File
@@ -42,6 +42,7 @@
padding: 0.5rem; padding: 0.5rem;
width: 100%; width: 100%;
z-index: 1000; z-index: 1000;
height: 7vh;
} }
.menubar-item { .menubar-item {
+53
View File
@@ -4,6 +4,8 @@ import { CalendarEvent } from "../Events/EventsTypes";
import { getCalendar, getCalendars } from "./CalendarApi"; import { getCalendar, getCalendars } from "./CalendarApi";
import getOpenPaasUserId from "../User/userAPI"; import getOpenPaasUserId from "../User/userAPI";
import { parseCalendarEvent } from "../Events/eventUtils"; import { parseCalendarEvent } from "../Events/eventUtils";
import { putEvent } from "../Events/EventApi";
import { formatDateToYYYYMMDDTHHMMSS } from "../../utils/dateUtils";
export const getCalendarsListAsync = createAsyncThunk< export const getCalendarsListAsync = createAsyncThunk<
Record<string, Calendars> // Return type Record<string, Calendars> // Return type
@@ -47,6 +49,32 @@ export const getCalendarDetailAsync = createAsyncThunk<
return { calId, events }; return { calId, events };
}); });
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 calEvents = (await getCalendar(cal.id, {
start: formatDateToYYYYMMDDTHHMMSS(newEvent.start),
end: formatDateToYYYYMMDDTHHMMSS(
new Date(newEvent.start.getTime() + 86400000)
),
})) as Record<string, any>;
const events: CalendarEvent[] = calEvents._embedded["dav:item"].flatMap(
(eventdata: any) => {
const vevents = eventdata.data[2] as any[][];
return vevents.map((vevent: any[]) => {
return parseCalendarEvent(vevent[1], cal.color ?? "", cal.id);
});
}
);
return {
calId: cal.id,
events,
};
});
const CalendarSlice = createSlice({ const CalendarSlice = createSlice({
name: "calendars", name: "calendars",
initialState: { list: {} as Record<string, Calendars>, pending: false }, initialState: { list: {} as Record<string, Calendars>, pending: false },
@@ -111,11 +139,36 @@ const CalendarSlice = createSlice({
}); });
} }
) )
.addCase(
putEventAsync.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;
});
}
)
.addCase(getCalendarDetailAsync.pending, (state) => { .addCase(getCalendarDetailAsync.pending, (state) => {
state.pending = true; state.pending = true;
}) })
.addCase(getCalendarsListAsync.pending, (state) => { .addCase(getCalendarsListAsync.pending, (state) => {
state.pending = true; state.pending = true;
})
.addCase(putEventAsync.pending, (state) => {
state.pending = true;
}); });
}, },
}); });
+28
View File
@@ -0,0 +1,28 @@
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 deleteEvent(calId: string, eventId: string) {
const response = await api(
`dav/calendars/${calId}/${eventId.split(".")[0]}.isc`,
{
method: "DELETE",
}
).json();
return response;
}
+160 -36
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { addEvent } from "../Calendars/CalendarSlice"; import { addEvent, putEventAsync } from "../Calendars/CalendarSlice";
import { CalendarEvent } from "./EventsTypes"; import { CalendarEvent } from "./EventsTypes";
import { DateSelectArg } from "@fullcalendar/core"; import { CalendarApi, DateSelectArg } from "@fullcalendar/core";
import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { import {
Popover, Popover,
@@ -12,46 +12,73 @@ import {
Select, Select,
MenuItem, MenuItem,
SelectChangeEvent, SelectChangeEvent,
FormControl,
InputLabel,
} from "@mui/material"; } from "@mui/material";
import { Calendars } from "../Calendars/CalendarTypes";
import { putEvent } from "./EventApi";
import { TIMEZONES } from "../../utils/timezone-data";
import { CheckBox, Repeat } from "@mui/icons-material";
function EventPopover({ function EventPopover({
anchorEl, anchorEl,
open, open,
onClose, onClose,
selectedRange, selectedRange,
setSelectedRange,
calendarRef,
}: { }: {
anchorEl: HTMLElement | null; anchorEl: HTMLElement | null;
open: boolean; open: boolean;
onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void; onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void;
selectedRange: DateSelectArg | null; selectedRange: DateSelectArg | null;
setSelectedRange: Function;
calendarRef: React.RefObject<CalendarApi | null>;
}) { }) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const organizer = useAppSelector((state) => state.user.organiserData); const organizer = useAppSelector((state) => state.user.organiserData);
const calendars = useAppSelector((state) => state.calendars.list); const userId = useAppSelector((state) => state.user.userData.openpaasId);
const userPersonnalCalendars: Calendars[] = useAppSelector((state) =>
Object.keys(state.calendars.list).map((id) => {
if (id.split("/")[0] === userId) {
return state.calendars.list[id];
}
return {} as Calendars;
})
).filter((calendar) => calendar.id);
const timezones = TIMEZONES.aliases;
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [location, setLocation] = useState(""); const [location, setLocation] = useState("");
const [start, setStart] = useState(""); const [start, setStart] = useState("");
const [end, setEnd] = useState(""); const [end, setEnd] = useState("");
const [calendarid, setCalendarid] = useState(""); const [calendarid, setCalendarid] = useState(0);
const [allday, setAllDay] = useState(false);
const [repetition, setRepetition] = useState("");
const [timezone, setTimezone] = useState(
Intl.DateTimeFormat().resolvedOptions().timeZone
);
useEffect(() => { useEffect(() => {
if (selectedRange) { if (selectedRange) {
setStart(selectedRange.startStr); setStart(selectedRange ? formatLocalDateTime(selectedRange.start) : "");
setEnd(selectedRange.endStr ?? ""); setEnd(selectedRange ? formatLocalDateTime(selectedRange.end) : "");
} }
}, [selectedRange]); }, [selectedRange]);
const handleSave = () => { const handleSave = async () => {
const newEvent: CalendarEvent = { const newEvent: CalendarEvent = {
title, title,
start: new Date(start ?? ""), start: new Date(start),
end: new Date(end ?? ""), allday,
uid: Date.now().toString(36), uid: crypto.randomUUID(),
description, description,
location, location,
repetition,
organizer, organizer,
timezone,
attendee: [ attendee: [
{ {
cn: organizer.cn, cn: organizer.cn,
@@ -63,15 +90,24 @@ function EventPopover({
}, },
], ],
transp: "OPAQUE", transp: "OPAQUE",
color: calendars[calendarid].color, color: userPersonnalCalendars[calendarid]?.color,
}; };
dispatch(addEvent({ calendarUid: calendarid, event: newEvent })); if (end) {
newEvent.end = new Date(end);
}
dispatch(
putEventAsync({
cal: userPersonnalCalendars[calendarid],
newEvent,
})
);
onClose({}, "backdropClick"); onClose({}, "backdropClick");
// Reset // Reset
setTitle(""); setTitle("");
setDescription(""); setDescription("");
setLocation(""); setLocation("");
setCalendarid(0);
}; };
return ( return (
@@ -79,40 +115,57 @@ function EventPopover({
open={open} open={open}
anchorEl={anchorEl} anchorEl={anchorEl}
onClose={onClose} onClose={onClose}
anchorOrigin={{ anchorOrigin={{ vertical: "top", horizontal: "left" }}
vertical: "top", transformOrigin={{ vertical: "top", horizontal: "left" }}
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
> >
<Box p={2} width={300}> <Box p={2} width={500}>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Create Event Create Event
</Typography> </Typography>
<Select
onChange={(e: SelectChangeEvent) => setCalendarid(e.target.value)}
>
{Object.keys(calendars).map((calendar) => (
<MenuItem value={calendar}>{calendars[calendar].name}</MenuItem>
))}
</Select>
<TextField <TextField
fullWidth fullWidth
label="title" label="Title"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
size="small" size="small"
margin="dense" margin="dense"
/> />
<FormControl fullWidth margin="dense" size="small">
<InputLabel id="calendar-select-label">Calendar</InputLabel>
<Select
labelId="calendar-select-label"
value={calendarid.toString()}
label="Calendar"
onChange={(e: SelectChangeEvent) =>
setCalendarid(Number(e.target.value))
}
>
{Object.keys(userPersonnalCalendars).map((calendar, index) => (
<MenuItem key={index} value={index}>
{userPersonnalCalendars[index].name}
</MenuItem>
))}
</Select>
</FormControl>
<TextField <TextField
fullWidth fullWidth
label="Start" label="Start"
type="datetime-local" type={allday ? "date" : "datetime-local"}
value={start} value={allday ? start.split("T")[0] : start}
onChange={(e) => setStart(e.target.value)} onChange={(e) => {
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" size="small"
margin="dense" margin="dense"
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
@@ -120,13 +173,44 @@ function EventPopover({
<TextField <TextField
fullWidth fullWidth
label="End" label="End"
type="datetime-local" type={allday ? "date" : "datetime-local"}
value={end} value={allday ? end.split("T")[0] : end}
onChange={(e) => setEnd(e.target.value)} onChange={(e) => {
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" size="small"
margin="dense" margin="dense"
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
/> />
<label>
<input
type="checkbox"
checked={allday}
onChange={() => {
setAllDay(!allday);
const newRange = {
startStr: allday ? start.split("T")[0] : start,
endStr: allday ? end.split("T")[0] : end,
start: new Date(allday ? start.split("T")[0] : start),
end: new Date(allday ? end.split("T")[0] : end),
allday,
...selectedRange,
};
setSelectedRange(newRange);
calendarRef.current?.select(newRange);
}}
/>
All day
</label>
<TextField <TextField
fullWidth fullWidth
label="Description" label="Description"
@@ -145,6 +229,39 @@ function EventPopover({
size="small" size="small"
margin="dense" margin="dense"
/> />
<FormControl fullWidth margin="dense" size="small">
<InputLabel id="repeat">Repetition</InputLabel>
<Select
labelId="repeat"
value={repetition}
label="Time Zone"
onChange={(e: SelectChangeEvent) => setRepetition(e.target.value)}
>
<MenuItem value={""}>No Repetition</MenuItem>
<MenuItem value={"daily"}>Repeat daily</MenuItem>
<MenuItem value={"weekly"}>Repeat weekly</MenuItem>
<MenuItem value={"monthly"}>Repeat monthly</MenuItem>
<MenuItem value={"yearly"}>Repeat yearly</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth margin="dense" size="small">
<InputLabel id="timezone-select-label">Time Zone</InputLabel>
<Select
labelId="timezone-select-label"
value={timezone}
label="Time Zone"
onChange={(e: SelectChangeEvent) => setTimezone(e.target.value)}
>
{Object.keys(timezones).map((key) => (
<MenuItem key={key} value={timezones[key].aliasTo}>
{key}
</MenuItem>
))}
</Select>
</FormControl>
<Box mt={2} display="flex" justifyContent="flex-end" gap={1}> <Box mt={2} display="flex" justifyContent="flex-end" gap={1}>
<Button <Button
variant="outlined" variant="outlined"
@@ -152,7 +269,7 @@ function EventPopover({
> >
Cancel Cancel
</Button> </Button>
<Button variant="contained" onClick={handleSave}> <Button variant="contained" onClick={handleSave} disabled={!title}>
Save Save
</Button> </Button>
</Box> </Box>
@@ -162,3 +279,10 @@ function EventPopover({
} }
export default EventPopover; export default EventPopover;
function formatLocalDateTime(date: Date): string {
const pad = (n: number) => n.toString().padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
date.getDate()
)}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
-16
View File
@@ -1,16 +0,0 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { CalendarEvent } from "./EventsTypes";
const eventsSlice = createSlice({
name: "events",
initialState: [] as CalendarEvent[],
reducers: {
addEvent: (state, action: PayloadAction<CalendarEvent>) => {
state.push(action.payload);
},
},
});
export const { addEvent } = eventsSlice.actions;
export default eventsSlice.reducer;
+2
View File
@@ -18,4 +18,6 @@ export interface CalendarEvent {
allday?: Boolean; allday?: Boolean;
error?: string; error?: string;
status?: string; status?: string;
timezone: string;
repetition?: string;
} }
+92 -1
View File
@@ -1,6 +1,8 @@
import { Calendars } from "../Calendars/CalendarTypes";
import { userAttendee } from "../User/userDataTypes"; import { userAttendee } from "../User/userDataTypes";
import { CalendarEvent } from "./EventsTypes"; import { CalendarEvent } from "./EventsTypes";
import ICAL from "ical.js";
import { TIMEZONES } from "../../utils/timezone-data";
type RawEntry = [string, Record<string, string>, string, any]; type RawEntry = [string, Record<string, string>, string, any];
export function parseCalendarEvent( export function parseCalendarEvent(
@@ -83,3 +85,92 @@ export function parseCalendarEvent(
return event as CalendarEvent; return event as CalendarEvent;
} }
export function calendarEventToJCal(event: CalendarEvent): any[] {
const tzid = event.timezone; // Fallback to UTC if no timezone provided
const vevent: any[] = [
"vevent",
[
["uid", {}, "text", event.uid],
["transp", {}, "text", event.transp ?? "OPAQUE"],
[
"dtstart",
{ tzid },
event.allday ? "date" : "date-time",
formatDateToICal(event.start, event.allday ?? false),
],
["class", {}, "text", event.class ?? "PUBLIC"],
[
"x-openpaas-videoconference",
{},
"unknown",
event.x_openpass_videoconference ?? null,
],
["summary", {}, "text", event.title ?? ""],
],
[],
];
if (event.end) {
vevent[1].push([
"dtend",
{ tzid },
event.allday ? "date" : "date-time",
formatDateToICal(event.end, event.allday ?? false),
]);
}
if (event.organizer) {
vevent[1].push([
"organizer",
{ cn: event.organizer.cn },
"cal-address",
event.organizer.cal_address,
]);
}
if (event.location) {
vevent[1].push(["location", {}, "text", event.location]);
}
if (event.description) {
vevent[1].push(["description", {}, "text", event.description]);
}
if (event.repetition) {
vevent[1].push(["rrule", {}, "recur", { freq: event.repetition }]);
}
event.attendee.forEach((att) => {
vevent[1].push([
"attendee",
{
cn: att.cn,
partstat: att.partstat,
rsvp: att.rsvp,
role: att.role,
cutype: att.cutype,
},
"cal-address",
att.cal_address,
]);
});
const vtimezone = new ICAL.Timezone({
component: TIMEZONES.zones[event.timezone].ics,
tzid: event.timezone,
});
return ["vcalendar", [], [vevent, vtimezone.component.jCal]];
}
function formatDateToICal(date: Date, allday: Boolean) {
// Format date like: 20250214T110000 (local time)
const pad = (n: number) => n.toString().padStart(2, "0");
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
const seconds = pad(date.getSeconds());
if (allday) {
return `${year}-${month}-${day}`;
}
return `${year}${month}${day}T${hours}${minutes}${seconds}`;
}
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
// timezone.ts
// Ensure ICAL is imported before using this module.
import ICAL from "ical.js";
// TIMEZONES data must be imported or defined separately.
import { TIMEZONES } from "./timezone-data";
// Core timezone registration functionality
export function registerTimezones() {
for (const [key, data] of Object.entries(TIMEZONES.zones)) {
ICAL.TimezoneService.register(key, buildTimezone(key, data.ics));
}
for (const [key, data] of Object.entries(TIMEZONES.aliases)) {
ICAL.TimezoneService.register(key, findTimezone(data.aliasTo));
}
}
function buildTimezone(tzid: string, ics: string): any {
return (
ICAL.TimezoneService.get(tzid) ||
new ICAL.Timezone(new ICAL.Component(ICAL.parse(ics)))
);
}
function findTimezone(tzid: string): any {
if (TIMEZONES.zones[tzid]) {
return buildTimezone(tzid, TIMEZONES.zones[tzid].ics);
}
const alias = TIMEZONES.aliases[tzid];
if (alias && alias.aliasTo) {
return findTimezone(alias.aliasTo);
}
throw new Error(`Unknown timezone alias: ${tzid}`);
}