@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Generated
+7
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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())}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -18,4 +18,6 @@ export interface CalendarEvent {
|
|||||||
allday?: Boolean;
|
allday?: Boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
timezone: string;
|
||||||
|
repetition?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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}`);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user