Merge pull request #75 from linagora/7-story-complex-events

complex display
This commit is contained in:
Camille Moussu
2025-09-03 16:55:33 +02:00
committed by GitHub
19 changed files with 2181 additions and 400 deletions
+11 -10
View File
@@ -2,7 +2,7 @@ import { CalendarApi } from "@fullcalendar/core";
import { jest } from "@jest/globals"; import { jest } from "@jest/globals";
import { ThunkDispatch } from "@reduxjs/toolkit"; import { ThunkDispatch } from "@reduxjs/toolkit";
import "@testing-library/jest-dom"; import "@testing-library/jest-dom";
import { screen } from "@testing-library/react"; import { act, screen } from "@testing-library/react";
import * as appHooks from "../../src/app/hooks"; import * as appHooks from "../../src/app/hooks";
import CalendarApp from "../../src/components/Calendar/Calendar"; import CalendarApp from "../../src/components/Calendar/Calendar";
import { renderWithProviders } from "../utils/Renderwithproviders"; import { renderWithProviders } from "../utils/Renderwithproviders";
@@ -88,16 +88,17 @@ describe("CalendarApp integration", () => {
{ timeout: 3000 } { timeout: 3000 }
); );
expect(eventEl).toBeInTheDocument(); expect(eventEl).toBeInTheDocument();
act(() => {
if (calendarApi) {
const fcEvent = calendarApi.getEventById("event1");
expect(fcEvent?.title).toBe("Test Event");
const oldEnd = new Date(today.getTime() + 3600000); // +1 hour
const newEnd = new Date(oldEnd.getTime() + 1800000); // +30 min
if (calendarApi) { fcEvent?.setEnd(newEnd);
const fcEvent = calendarApi.getEventById("event1");
expect(fcEvent?.title).toBe("Test Event");
const oldEnd = new Date(today.getTime() + 3600000); // +1 hour
const newEnd = new Date(oldEnd.getTime() + 1800000); // +30 min
fcEvent?.setEnd(newEnd); expect(dispatch).toHaveBeenCalled();
}
expect(dispatch).toHaveBeenCalled(); });
}
}); });
}); });
+107
View File
@@ -0,0 +1,107 @@
import {
putEvent,
moveEvent,
deleteEvent,
} from "../../../src/features/Events/EventApi";
import { calendarEventToJCal } from "../../../src/features/Events/eventUtils";
import { clientConfig } from "../../../src/features/User/oidcAuth";
import { api } from "../../../src/utils/apiUtils";
clientConfig.url = "https://example.com";
jest.mock("../../../src/utils/apiUtils");
const day = new Date();
const mockEvent = {
uid: "event1",
title: "Test Event",
timezone: "UTC",
calId: "667037022b752d0026472254/cal1",
URL: "/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics",
start: day,
end: day,
status: "PUBLIC",
organizer: { cn: "test", cal_address: "test@test.com" },
attendee: [
{
cn: "test",
cal_address: "test@test.com",
partstat: "NEEDS-ACTION",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
},
{
cn: "John",
cal_address: "john@test.com",
partstat: "NEEDS-ACTION",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
},
],
};
describe("eventApi", () => {
beforeEach(() => {
jest.clearAllMocks();
});
test("putEvent sends PUT request with JCal body", async () => {
const mockResponse = { status: 201, url: "/dav/cals/test.ics" };
(api as unknown as jest.Mock).mockReturnValue(mockResponse);
const result = await putEvent(mockEvent);
const expectedResult = calendarEventToJCal(mockEvent);
expect(api).toHaveBeenCalledWith(
"dav/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics",
expect.objectContaining({
method: "PUT",
headers: { "content-type": "text/calendar; charset=utf-8" },
body: JSON.stringify(expectedResult),
})
);
expect(result).toBe(mockResponse);
});
test("putEvent logs when status is 201", async () => {
const mockResponse = { status: 201, url: "/dav/cals/test.ics" };
(api as unknown as jest.Mock).mockReturnValue(mockResponse);
const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
await putEvent(mockEvent);
expect(logSpy).toHaveBeenCalledWith("PUT (201) :", "/dav/cals/test.ics");
logSpy.mockRestore();
});
test("moveEvent sends MOVE request with destination header", async () => {
const mockResponse = { status: 204 };
(api as unknown as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockResponse),
});
const result = await moveEvent(mockEvent, "newurl.ics");
expect(api).toHaveBeenCalledWith(
"dav/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics",
expect.objectContaining({
method: "MOVE",
headers: {
destination: "newurl.ics",
},
})
);
});
test("deleteEvent sends DELETE request and returns json response", async () => {
const mockResponse = { ok: true };
(api as unknown as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockResponse),
});
const result = await deleteEvent("/calendars/test.ics");
expect(api).toHaveBeenCalledWith("dav/calendars/test.ics", {
method: "DELETE",
});
});
});
+630 -24
View File
@@ -1,13 +1,14 @@
import { screen, fireEvent, waitFor } from "@testing-library/react"; import { screen, fireEvent, waitFor, act } from "@testing-library/react";
import * as eventThunks from "../../../src/features/Calendars/CalendarSlice"; import * as eventThunks from "../../../src/features/Calendars/CalendarSlice";
import { renderWithProviders } from "../../utils/Renderwithproviders"; import { renderWithProviders } from "../../utils/Renderwithproviders";
import EventPopover from "../../../src/features/Events/EventModal"; import EventDisplayModal, {
import { DateSelectArg } from "@fullcalendar/core"; InfoRow,
import preview from "jest-preview"; stringAvatar,
import { formatDateToYYYYMMDDTHHMMSS } from "../../../src/utils/dateUtils"; stringToColor,
import EventDisplayModal from "../../../src/features/Events/EventDisplay"; } from "../../../src/features/Events/EventDisplay";
import EventPreviewModal from "../../../src/features/Events/EventDisplayPreview";
describe("Event Display", () => { describe("Event Preview Display", () => {
const mockOnClose = jest.fn(); const mockOnClose = jest.fn();
const day = new Date(); const day = new Date();
const RealDateToLocaleString = Date.prototype.toLocaleString; const RealDateToLocaleString = Date.prototype.toLocaleString;
@@ -39,8 +40,28 @@ describe("Event Display", () => {
event1: { event1: {
id: "event1", id: "event1",
title: "Test Event", title: "Test Event",
calId: "667037022b752d0026472254/cal1",
start: day.toISOString(), start: day.toISOString(),
end: day.toISOString(), end: day.toISOString(),
organizer: { cn: "test", cal_address: "test@test.com" },
attendee: [
{
cn: "test",
cal_address: "test@test.com",
partstat: "NEEDS-ACTION",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
},
{
cn: "John",
cal_address: "john@test.com",
partstat: "NEEDS-ACTION",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
},
],
}, },
}, },
}, },
@@ -51,9 +72,11 @@ describe("Event Display", () => {
events: { events: {
event1: { event1: {
id: "event1", id: "event1",
calId: "otherCal/cal",
title: "Test Event Other cal", title: "Test Event Other cal",
start: day.toISOString(), start: day.toISOString(),
end: day.toISOString(), end: day.toISOString(),
organizer: { cn: "john", cal_address: "john@test.com" },
}, },
}, },
}, },
@@ -73,8 +96,8 @@ describe("Event Display", () => {
return RealDateToLocaleString.call(this, "en-UK", options); return RealDateToLocaleString.call(this, "en-UK", options);
}); });
renderWithProviders( renderWithProviders(
<EventDisplayModal <EventPreviewModal
anchorEl={document.body} anchorPosition={{ top: 0, left: 0 }}
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"} calId={"667037022b752d0026472254/cal1"}
@@ -98,8 +121,8 @@ describe("Event Display", () => {
}); });
it("calls onClose when Cancel clicked", () => { it("calls onClose when Cancel clicked", () => {
renderWithProviders( renderWithProviders(
<EventDisplayModal <EventPreviewModal
anchorEl={document.body} anchorPosition={{ top: 0, left: 0 }}
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"} calId={"667037022b752d0026472254/cal1"}
@@ -111,11 +134,465 @@ describe("Event Display", () => {
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
}); });
it("Shows delete button only when calendar is own", () => {
// Renders the other cal event
renderWithProviders(
<EventPreviewModal
anchorPosition={{ top: 0, left: 0 }}
open={true}
onClose={mockOnClose}
calId={"otherCal/cal"}
eventId={"event1"}
/>,
preloadedState
);
expect(screen.queryByTestId("DeleteIcon")).not.toBeInTheDocument();
// Renders the personnal cal event
renderWithProviders(
<EventPreviewModal
anchorPosition={{ top: 0, left: 0 }}
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
/>,
preloadedState
);
expect(screen.queryByTestId("DeleteIcon")).toBeInTheDocument();
});
it("calls delete when Delete clicked", async () => {
renderWithProviders(
<EventPreviewModal
anchorPosition={{ top: 0, left: 0 }}
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
/>,
preloadedState
);
const spy = jest
.spyOn(eventThunks, "deleteEventAsync")
.mockImplementation((payload) => {
return () => Promise.resolve(payload) as any;
});
fireEvent.click(screen.getByTestId("DeleteIcon"));
await waitFor(() => {
expect(spy).toHaveBeenCalled();
});
const receivedPayload = spy.mock.calls[0][0];
expect(receivedPayload).toEqual({
calId: "667037022b752d0026472254/cal1",
eventId: "event1",
});
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
});
it("renders RSVP buttons when user is an attendee", () => {
const rsvpStateIsOrga = {
...preloadedState,
calendars: {
...preloadedState.calendars,
list: {
...preloadedState.calendars.list,
"667037022b752d0026472254/cal1": {
...preloadedState.calendars.list["667037022b752d0026472254/cal1"],
events: {
event1: {
...preloadedState.calendars.list[
"667037022b752d0026472254/cal1"
].events.event1,
attendee: [
{
cal_address: "test@test.com",
cn: "Test User",
partstat: "NEEDS-ACTION",
},
{
cal_address: "organizer@test.com",
cn: "Test Organizer",
partstat: "NEEDS-ACTION",
},
],
organizer: {
cal_address: "organizer@test.com",
},
},
},
},
},
},
};
renderWithProviders(
<EventPreviewModal
anchorPosition={{ top: 0, left: 0 }}
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
/>,
rsvpStateIsOrga
);
expect(screen.getByText("Will you attend?")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Accept" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Maybe" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Decline" })).toBeInTheDocument();
});
it("doesnt renders RSVP buttons when user isnt an attendee", () => {
const rsvpStateIsOrga = {
...preloadedState,
calendars: {
...preloadedState.calendars,
list: {
...preloadedState.calendars.list,
"667037022b752d0026472254/cal1": {
...preloadedState.calendars.list["667037022b752d0026472254/cal1"],
events: {
event1: {
...preloadedState.calendars.list[
"667037022b752d0026472254/cal1"
].events.event1,
attendee: [
{
cal_address: "organizer@test.com",
cn: "Test Organizer",
partstat: "NEEDS-ACTION",
},
],
organizer: {
cal_address: "organizer@test.com",
},
},
},
},
},
},
};
renderWithProviders(
<EventPreviewModal
anchorPosition={{ top: 0, left: 0 }}
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
/>,
rsvpStateIsOrga
);
expect(screen.queryByText("Will you attend?")).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Accept" })
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Maybe" })
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Decline" })
).not.toBeInTheDocument();
});
it("handles RSVP Accept click", async () => {
const spy = jest
.spyOn(eventThunks, "putEventAsync")
.mockImplementation((payload) => {
return () => Promise.resolve(payload) as any;
});
const rsvpState = {
...preloadedState,
calendars: {
...preloadedState.calendars,
list: {
...preloadedState.calendars.list,
"667037022b752d0026472254/cal1": {
...preloadedState.calendars.list["667037022b752d0026472254/cal1"],
events: {
event1: {
...preloadedState.calendars.list[
"667037022b752d0026472254/cal1"
].events.event1,
attendee: [
{
cal_address: "test@test.com",
cn: "Test User",
partstat: "NEEDS-ACTION",
},
],
organizer: {
cal_address: "organizer@test.com",
},
},
},
},
},
},
};
renderWithProviders(
<EventPreviewModal
anchorPosition={{ top: 0, left: 0 }}
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
/>,
rsvpState
);
fireEvent.click(screen.getByRole("button", { name: "Accept" }));
await waitFor(() => {
expect(spy).toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
});
const updatedEvent = spy.mock.calls[0][0].newEvent;
expect(updatedEvent.attendee[0].partstat).toBe("ACCEPTED");
});
it("handles RSVP Maybe click", async () => {
const spy = jest
.spyOn(eventThunks, "putEventAsync")
.mockImplementation((payload) => {
return () => Promise.resolve(payload) as any;
});
const rsvpState = {
...preloadedState,
calendars: {
...preloadedState.calendars,
list: {
...preloadedState.calendars.list,
"667037022b752d0026472254/cal1": {
...preloadedState.calendars.list["667037022b752d0026472254/cal1"],
events: {
event1: {
...preloadedState.calendars.list[
"667037022b752d0026472254/cal1"
].events.event1,
attendee: [
{
cal_address: "test@test.com",
cn: "Test User",
partstat: "NEEDS-ACTION",
},
],
organizer: {
cal_address: "organizer@test.com",
},
},
},
},
},
},
};
renderWithProviders(
<EventPreviewModal
anchorPosition={{ top: 0, left: 0 }}
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
/>,
rsvpState
);
fireEvent.click(screen.getByRole("button", { name: "Maybe" }));
await waitFor(() => {
expect(spy).toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
});
const updatedEvent = spy.mock.calls[0][0].newEvent;
expect(updatedEvent.attendee[0].partstat).toBe("TENTATIVE");
});
it("handles RSVP Decline click", async () => {
const spy = jest
.spyOn(eventThunks, "putEventAsync")
.mockImplementation((payload) => {
return () => Promise.resolve(payload) as any;
});
const rsvpState = {
...preloadedState,
calendars: {
...preloadedState.calendars,
list: {
...preloadedState.calendars.list,
"667037022b752d0026472254/cal1": {
...preloadedState.calendars.list["667037022b752d0026472254/cal1"],
events: {
event1: {
...preloadedState.calendars.list[
"667037022b752d0026472254/cal1"
].events.event1,
attendee: [
{
cal_address: "test@test.com",
cn: "Test User",
partstat: "NEEDS-ACTION",
},
],
organizer: {
cal_address: "organizer@test.com",
},
},
},
},
},
},
};
renderWithProviders(
<EventPreviewModal
anchorPosition={{ top: 0, left: 0 }}
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
/>,
rsvpState
);
fireEvent.click(screen.getByRole("button", { name: "Decline" }));
await waitFor(() => {
expect(spy).toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
});
const updatedEvent = spy.mock.calls[0][0].newEvent;
expect(updatedEvent.attendee[0].partstat).toBe("DECLINED");
});
});
describe("Event Full Display", () => {
const mockOnClose = jest.fn();
const day = new Date();
beforeEach(() => {
jest.clearAllMocks();
});
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro",
openpaasId: "667037022b752d0026472254",
},
organiserData: {
cn: "test",
cal_address: "mailto:test@test.com",
},
},
calendars: {
list: {
"667037022b752d0026472254/cal1": {
id: "667037022b752d0026472254/cal1",
name: "First Calendar",
color: "#FF0000",
events: {
event1: {
id: "event1",
title: "Test Event",
calId: "667037022b752d0026472254/cal1",
start: day.toISOString(),
end: day.toISOString(),
organizer: { cn: "test", cal_address: "test@test.com" },
attendee: [
{
cn: "test",
cal_address: "test@test.com",
partstat: "NEEDS-ACTION",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
},
{
cn: "John",
cal_address: "john@test.com",
partstat: "NEEDS-ACTION",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
},
],
},
},
},
"otherCal/cal": {
id: "otherCal/cal",
name: "Calendar 1",
color: "#FF0000",
events: {
event1: {
id: "event1",
calId: "otherCal/cal",
title: "Test Event Other cal",
start: day.toISOString(),
end: day.toISOString(),
organizer: { cn: "john", cal_address: "john@test.com" },
},
},
},
},
pending: false,
},
};
it("renders correctly event data", () => {
renderWithProviders(
<EventDisplayModal
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
/>,
preloadedState
);
const tzOffset = day.getTimezoneOffset() * 60000; // offset in ms
const date = new Date(day.getTime() - tzOffset).toISOString().slice(0, 16);
expect(screen.getByDisplayValue("Test Event")).toBeInTheDocument();
expect(
screen.getAllByDisplayValue(new RegExp(date, "i"))[0]
).toBeInTheDocument();
expect(
screen.getAllByDisplayValue(new RegExp(date, "i")).length
).toBeLessThanOrEqual(2);
expect(screen.getByText("First Calendar")).toBeInTheDocument();
});
it("calls onClose when Cancel clicked", () => {
renderWithProviders(
<EventDisplayModal
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
/>,
preloadedState
);
fireEvent.click(screen.getAllByTestId("CloseIcon")[0]);
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
});
it("Shows delete button only when calendar is own", () => { it("Shows delete button only when calendar is own", () => {
// Renders the other cal event // Renders the other cal event
renderWithProviders( renderWithProviders(
<EventDisplayModal <EventDisplayModal
anchorEl={document.body}
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
calId={"otherCal/cal"} calId={"otherCal/cal"}
@@ -127,7 +604,6 @@ describe("Event Display", () => {
// Renders the personnal cal event // Renders the personnal cal event
renderWithProviders( renderWithProviders(
<EventDisplayModal <EventDisplayModal
anchorEl={document.body}
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"} calId={"667037022b752d0026472254/cal1"}
@@ -140,7 +616,6 @@ describe("Event Display", () => {
it("calls delete when Delete clicked", async () => { it("calls delete when Delete clicked", async () => {
renderWithProviders( renderWithProviders(
<EventDisplayModal <EventDisplayModal
anchorEl={document.body}
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"} calId={"667037022b752d0026472254/cal1"}
@@ -207,7 +682,6 @@ describe("Event Display", () => {
renderWithProviders( renderWithProviders(
<EventDisplayModal <EventDisplayModal
anchorEl={document.body}
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"} calId={"667037022b752d0026472254/cal1"}
@@ -216,7 +690,6 @@ describe("Event Display", () => {
rsvpStateIsOrga rsvpStateIsOrga
); );
expect(screen.getByText("Will you attend?")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Accept" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Accept" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Maybe" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Maybe" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Decline" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Decline" })).toBeInTheDocument();
@@ -254,7 +727,6 @@ describe("Event Display", () => {
renderWithProviders( renderWithProviders(
<EventDisplayModal <EventDisplayModal
anchorEl={document.body}
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"} calId={"667037022b752d0026472254/cal1"}
@@ -263,7 +735,6 @@ describe("Event Display", () => {
rsvpStateIsOrga rsvpStateIsOrga
); );
expect(screen.queryByText("Will you attend?")).not.toBeInTheDocument();
expect( expect(
screen.queryByRole("button", { name: "Accept" }) screen.queryByRole("button", { name: "Accept" })
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
@@ -314,7 +785,6 @@ describe("Event Display", () => {
renderWithProviders( renderWithProviders(
<EventDisplayModal <EventDisplayModal
anchorEl={document.body}
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"} calId={"667037022b752d0026472254/cal1"}
@@ -327,7 +797,6 @@ describe("Event Display", () => {
await waitFor(() => { await waitFor(() => {
expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
}); });
const updatedEvent = spy.mock.calls[0][0].newEvent; const updatedEvent = spy.mock.calls[0][0].newEvent;
@@ -373,7 +842,6 @@ describe("Event Display", () => {
renderWithProviders( renderWithProviders(
<EventDisplayModal <EventDisplayModal
anchorEl={document.body}
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"} calId={"667037022b752d0026472254/cal1"}
@@ -386,7 +854,6 @@ describe("Event Display", () => {
await waitFor(() => { await waitFor(() => {
expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
}); });
const updatedEvent = spy.mock.calls[0][0].newEvent; const updatedEvent = spy.mock.calls[0][0].newEvent;
@@ -432,7 +899,6 @@ describe("Event Display", () => {
renderWithProviders( renderWithProviders(
<EventDisplayModal <EventDisplayModal
anchorEl={document.body}
open={true} open={true}
onClose={mockOnClose} onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"} calId={"667037022b752d0026472254/cal1"}
@@ -445,10 +911,150 @@ describe("Event Display", () => {
await waitFor(() => { await waitFor(() => {
expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
}); });
const updatedEvent = spy.mock.calls[0][0].newEvent; const updatedEvent = spy.mock.calls[0][0].newEvent;
expect(updatedEvent.attendee[0].partstat).toBe("DECLINED"); expect(updatedEvent.attendee[0].partstat).toBe("DECLINED");
}); });
it("toggle Show More reveals extra fields", async () => {
const spy = jest
.spyOn(eventThunks, "getEventAsync")
.mockImplementation((payload) => {
return () =>
Promise.resolve({
calId: payload.calId,
event:
preloadedState.calendars.list["667037022b752d0026472254/cal1"]
.events["event1"],
}) as any;
});
renderWithProviders(
<EventDisplayModal
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
/>,
preloadedState
);
act(() => {
fireEvent.click(screen.getByText("Show More"));
});
await waitFor(() => {
expect(spy).toHaveBeenCalled();
});
console.log(spy);
await waitFor(() => {
expect(screen.getByLabelText(/Alarm/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Repetition/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Visibility/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Show Less"));
});
it("can edit title when user is organizer", () => {
renderWithProviders(
<EventDisplayModal
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
/>,
preloadedState
);
const titleField = screen.getByLabelText("Title");
fireEvent.change(titleField, { target: { value: "New Title" } });
expect(screen.getByDisplayValue("New Title")).toBeInTheDocument();
});
it("calendar select is disabled when not organizer", () => {
const rsvpState = {
...preloadedState,
calendars: {
...preloadedState.calendars,
list: {
...preloadedState.calendars.list,
"667037022b752d0026472254/cal1": {
...preloadedState.calendars.list["667037022b752d0026472254/cal1"],
events: {
event1: {
...preloadedState.calendars.list[
"667037022b752d0026472254/cal1"
].events.event1,
attendee: [
{
cal_address: "test@test.com",
cn: "Test User",
partstat: "NEEDS-ACTION",
},
],
organizer: {
cal_address: "organizer@test.com",
cn: "Edgar Organiser",
},
},
},
},
},
},
};
renderWithProviders(
<EventDisplayModal
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
/>,
rsvpState
);
expect(screen.getByLabelText("Calendar")).toHaveClass("Mui-disabled");
});
it("toggle all-day updates end date correctly", () => {
renderWithProviders(
<EventDisplayModal
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
/>,
preloadedState
);
const allDayCheckbox = screen.getByLabelText("All day");
fireEvent.click(allDayCheckbox);
expect(allDayCheckbox).toBeChecked();
const date = day.toISOString().split("T")[0];
expect(
screen.getAllByDisplayValue(new RegExp(date, "i"))[0]
).toBeInTheDocument();
});
});
describe("Helper functions", () => {
it("stringToColor generates consistent color", () => {
expect(stringToColor("Alice")).toMatch(/^#[0-9a-f]{6}$/);
expect(stringToColor("Alice")).toBe(stringToColor("Alice"));
});
it("stringAvatar returns correct props", () => {
const result = stringAvatar("Alice");
expect(result.children).toBe("A");
expect(result.sx.bgcolor).toMatch(/^#/);
});
it("InfoRow renders text and link if url is valid", () => {
renderWithProviders(
<InfoRow
icon={<span>ico</span>}
text="Meeting"
data="https://example.com"
/>
);
expect(screen.getByText("Meeting").closest("a")).toHaveAttribute(
"href",
"https://example.com"
);
});
}); });
+4 -3
View File
@@ -116,8 +116,11 @@ describe("EventPopover", () => {
expect(screen.getByLabelText("End")).toBeInTheDocument(); expect(screen.getByLabelText("End")).toBeInTheDocument();
expect(screen.getByLabelText("Description")).toBeInTheDocument(); expect(screen.getByLabelText("Description")).toBeInTheDocument();
expect(screen.getByLabelText("Location")).toBeInTheDocument(); expect(screen.getByLabelText("Location")).toBeInTheDocument();
expect(screen.getByText("Show More")).toBeInTheDocument();
fireEvent.click(screen.getByText("Show More"));
expect(screen.getByLabelText("Repetition")).toBeInTheDocument(); expect(screen.getByLabelText("Repetition")).toBeInTheDocument();
expect(screen.getByLabelText("Time Zone")).toBeInTheDocument(); expect(screen.getByLabelText("Alarm")).toBeInTheDocument();
expect(screen.getByLabelText("Visibility")).toBeInTheDocument();
// Calendar options // Calendar options
const select = screen.getByLabelText("Calendar"); const select = screen.getByLabelText("Calendar");
fireEvent.mouseDown(select); fireEvent.mouseDown(select);
@@ -242,7 +245,6 @@ describe("EventPopover", () => {
uid: "6045c603-11ab-43c5-bc30-0641420bb3a8", uid: "6045c603-11ab-43c5-bc30-0641420bb3a8",
description: "Discuss project", description: "Discuss project",
location: "Zoom", location: "Zoom",
repetition: "",
organizer: { cn: "test", cal_address: "test@test.com" }, organizer: { cn: "test", cal_address: "test@test.com" },
timezone: "Europe/Paris", timezone: "Europe/Paris",
transp: "OPAQUE", transp: "OPAQUE",
@@ -297,7 +299,6 @@ describe("EventPopover", () => {
).toBe(formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.end)).split("T")[0]); ).toBe(formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.end)).split("T")[0]);
expect(receivedPayload.newEvent.location).toBe(newEvent.location); expect(receivedPayload.newEvent.location).toBe(newEvent.location);
expect(receivedPayload.newEvent.organizer).toEqual(newEvent.organizer); expect(receivedPayload.newEvent.organizer).toEqual(newEvent.organizer);
expect(receivedPayload.newEvent.repetition).toEqual(newEvent.repetition);
expect(receivedPayload.newEvent.color).toEqual( expect(receivedPayload.newEvent.color).toEqual(
preloadedState.calendars.list["667037022b752d0026472254/cal1"].color preloadedState.calendars.list["667037022b752d0026472254/cal1"].color
); );
+33 -8
View File
@@ -31,7 +31,12 @@ describe("parseCalendarEvent", () => {
["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];
const result = parseCalendarEvent(rawData, baseColor, calendarId); const result = parseCalendarEvent(
rawData,
baseColor,
calendarId,
"/calendars/test.ics"
);
expect(result.uid).toBe("event-1"); expect(result.uid).toBe("event-1");
expect(result.title).toBe("Team Meeting"); expect(result.title).toBe("Team Meeting");
@@ -71,7 +76,12 @@ describe("parseCalendarEvent", () => {
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"], ["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
]; ];
const result = parseCalendarEvent(rawData, baseColor, calendarId); const result = parseCalendarEvent(
rawData,
baseColor,
calendarId,
"/calendars/test.ics"
);
expect(result.uid).toBe("event-2/2025-07-18T09:00:00Z"); expect(result.uid).toBe("event-2/2025-07-18T09:00:00Z");
}); });
@@ -81,7 +91,12 @@ describe("parseCalendarEvent", () => {
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"], ["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
]; ];
const result = parseCalendarEvent(rawDataMissingUid, baseColor, calendarId); const result = parseCalendarEvent(
rawDataMissingUid,
baseColor,
calendarId,
"/calendars/test.ics"
);
expect(result.error).toMatch(/missing crucial event param/); expect(result.error).toMatch(/missing crucial event param/);
const rawDataMissingStart: any = [["UID", {}, "text", "event-3"]]; const rawDataMissingStart: any = [["UID", {}, "text", "event-3"]];
@@ -89,7 +104,8 @@ describe("parseCalendarEvent", () => {
const result2 = parseCalendarEvent( const result2 = parseCalendarEvent(
rawDataMissingStart, rawDataMissingStart,
baseColor, baseColor,
calendarId calendarId,
"/calendars/test.ics"
); );
expect(result2.error).toMatch(/missing crucial event param/); expect(result2.error).toMatch(/missing crucial event param/);
}); });
@@ -102,7 +118,12 @@ describe("parseCalendarEvent", () => {
["ORGANIZER", {}, "cal-address", "jane@example.com"], ["ORGANIZER", {}, "cal-address", "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,
"/calendars/test.ics"
);
expect(result.attendee).toEqual([ expect(result.attendee).toEqual([
{ {
@@ -135,6 +156,8 @@ describe("calendarEventToJCal", () => {
it("should convert a CalendarEvent to JCal format", () => { it("should convert a CalendarEvent to JCal format", () => {
const mockEvent = { const mockEvent = {
uid: "event-123", uid: "event-123",
URL: "/calendars/test.ics",
calId: "test/test",
title: "Team Meeting", title: "Team Meeting",
start: new Date("2025-07-23T10:00:00"), start: new Date("2025-07-23T10:00:00"),
end: new Date("2025-07-23T11:00:00"), end: new Date("2025-07-23T11:00:00"),
@@ -144,7 +167,7 @@ describe("calendarEventToJCal", () => {
allday: false, allday: false,
location: "Room 101", location: "Room 101",
description: "Discuss project roadmap.", description: "Discuss project roadmap.",
repetition: "WEEKLY", repetition: { freq: "WEEKLY" },
organizer: { organizer: {
cn: "Alice", cn: "Alice",
cal_address: "alice@example.com", cal_address: "alice@example.com",
@@ -246,6 +269,8 @@ describe("calendarEventToJCal", () => {
it("should convert a CalendarEvent to JCal format, with all day activated", () => { it("should convert a CalendarEvent to JCal format, with all day activated", () => {
const mockEvent = { const mockEvent = {
uid: "event-123", uid: "event-123",
URL: "/calendars/test.ics",
calId: "test/test",
title: "Team Meeting", title: "Team Meeting",
start: new Date("2025-07-23"), start: new Date("2025-07-23"),
end: new Date("2025-07-23"), end: new Date("2025-07-23"),
@@ -255,7 +280,7 @@ describe("calendarEventToJCal", () => {
allday: true, allday: true,
location: "Room 101", location: "Room 101",
description: "Discuss project roadmap.", description: "Discuss project roadmap.",
repetition: "WEEKLY", repetition: { freq: "WEEKLY" },
organizer: { organizer: {
cn: "Alice", cn: "Alice",
cal_address: "alice@example.com", cal_address: "alice@example.com",
@@ -285,7 +310,7 @@ describe("calendarEventToJCal", () => {
["summary", {}, "text", "Team Meeting"], ["summary", {}, "text", "Team Meeting"],
["transp", {}, "text", "OPAQUE"], ["transp", {}, "text", "OPAQUE"],
["dtstart", { tzid: "Europe/Paris" }, "date", "2025-07-23"], ["dtstart", { tzid: "Europe/Paris" }, "date", "2025-07-23"],
["dtend", { tzid: "Europe/Paris" }, "date", "2025-07-23"], ["dtend", { tzid: "Europe/Paris" }, "date", "2025-07-24"],
["class", {}, "text", "PUBLIC"], ["class", {}, "text", "PUBLIC"],
["location", {}, "text", "Room 101"], ["location", {}, "text", "Room 101"],
["description", {}, "text", "Discuss project roadmap."], ["description", {}, "text", "Discuss project roadmap."],
+20 -8
View File
@@ -9,6 +9,7 @@ import {
} from "@mui/material"; } from "@mui/material";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { searchUsers } from "../../features/User/userAPI"; import { searchUsers } from "../../features/User/userAPI";
import { userAttendee } from "../../features/User/userDataTypes";
interface User { interface User {
email: string; email: string;
@@ -17,9 +18,13 @@ interface User {
} }
export default function UserSearch({ export default function UserSearch({
attendees,
setAttendees, setAttendees,
disabled,
}: { }: {
attendees: userAttendee[];
setAttendees: Function; setAttendees: Function;
disabled?: boolean;
}) { }) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [options, setOptions] = useState<User[]>([]); const [options, setOptions] = useState<User[]>([]);
@@ -40,6 +45,7 @@ export default function UserSearch({
<Autocomplete <Autocomplete
multiple multiple
options={options} options={options}
disabled={disabled}
loading={loading} loading={loading}
filterOptions={(x) => x} filterOptions={(x) => x}
fullWidth fullWidth
@@ -75,14 +81,20 @@ export default function UserSearch({
}} }}
/> />
)} )}
renderOption={(props, option) => ( renderOption={(props, option) => {
<ListItem {...props} key={option.email} disableGutters> if (attendees.find((a) => a.cal_address === option.email)) return;
<ListItemAvatar> return (
<Avatar src={option.avatarUrl} alt={option.displayName} /> <ListItem {...props} key={option.email} disableGutters>
</ListItemAvatar> <ListItemAvatar>
<ListItemText primary={option.displayName} secondary={option.email} /> <Avatar src={option.avatarUrl} alt={option.displayName} />
</ListItem> </ListItemAvatar>
)} <ListItemText
primary={option.displayName}
secondary={option.email}
/>
</ListItem>
);
}}
/> />
); );
} }
+13 -8
View File
@@ -25,7 +25,7 @@ import {
} from "../../utils/dateUtils"; } from "../../utils/dateUtils";
import { Calendars } from "../../features/Calendars/CalendarTypes"; import { Calendars } from "../../features/Calendars/CalendarTypes";
import { push } from "redux-first-history"; import { push } from "redux-first-history";
import EventDisplayModal from "../../features/Events/EventDisplay"; import EventPreviewModal from "../../features/Events/EventDisplayPreview";
import { createSelector } from "@reduxjs/toolkit"; import { createSelector } from "@reduxjs/toolkit";
import HelpOutlineIcon from "@mui/icons-material/HelpOutline"; import HelpOutlineIcon from "@mui/icons-material/HelpOutline";
import ClearIcon from "@mui/icons-material/Clear"; import ClearIcon from "@mui/icons-material/Clear";
@@ -109,8 +109,10 @@ export default function CalendarApp() {
}, [rangeKey, selectedCalendars]); }, [rangeKey, selectedCalendars]);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null); const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [anchorElEventDisplay, setAnchorElEventDisplay] = const [anchorPosition, setAnchorPosition] = useState<{
useState<HTMLElement | null>(null); top: number;
left: number;
} | null>(null);
const [openEventDisplay, setOpenEventDisplay] = useState(false); const [openEventDisplay, setOpenEventDisplay] = useState(false);
const [eventDisplayedId, setEventDisplayedId] = useState(""); const [eventDisplayedId, setEventDisplayedId] = useState("");
const [eventDisplayedCalId, setEventDisplayedCalId] = useState(""); const [eventDisplayedCalId, setEventDisplayedCalId] = useState("");
@@ -130,7 +132,7 @@ export default function CalendarApp() {
setSelectedRange(null); setSelectedRange(null);
}; };
const handleCloseEventDisplay = () => { const handleCloseEventDisplay = () => {
setAnchorElEventDisplay(null); setAnchorPosition(null);
setOpenEventDisplay(false); setOpenEventDisplay(false);
}; };
@@ -316,7 +318,10 @@ export default function CalendarApp() {
} else { } else {
console.log(info.event); console.log(info.event);
setOpenEventDisplay(true); setOpenEventDisplay(true);
setAnchorElEventDisplay(info.el); setAnchorPosition({
top: info.jsEvent.clientY,
left: info.jsEvent.clientX,
});
setEventDisplayedId(info.event.extendedProps.uid); setEventDisplayedId(info.event.extendedProps.uid);
setEventDisplayedCalId(info.event.extendedProps.calId); setEventDisplayedCalId(info.event.extendedProps.calId);
} }
@@ -367,7 +372,7 @@ export default function CalendarApp() {
start: computedNewStart, start: computedNewStart,
end: computedNewEnd, end: computedNewEnd,
} as CalendarEvent; } as CalendarEvent;
console.log(event , newEvent);
dispatch( dispatch(
putEventAsync({ cal: calendars[newEvent.calId], newEvent }) putEventAsync({ cal: calendars[newEvent.calId], newEvent })
); );
@@ -468,10 +473,10 @@ export default function CalendarApp() {
onClose={() => setAnchorElCal(null)} onClose={() => setAnchorElCal(null)}
/> />
{openEventDisplay && eventDisplayedId && eventDisplayedCalId && ( {openEventDisplay && eventDisplayedId && eventDisplayedCalId && (
<EventDisplayModal <EventPreviewModal
eventId={eventDisplayedId} eventId={eventDisplayedId}
calId={eventDisplayedCalId} calId={eventDisplayedCalId}
anchorEl={anchorElEventDisplay} anchorPosition={anchorPosition}
open={openEventDisplay} open={openEventDisplay}
onClose={handleCloseEventDisplay} onClose={handleCloseEventDisplay}
/> />
+164
View File
@@ -0,0 +1,164 @@
import {
FormControl,
InputLabel,
Select,
SelectChangeEvent,
MenuItem,
Box,
Stack,
Paper,
Typography,
TextField,
Checkbox,
List,
ListItem,
FormControlLabel,
FormGroup,
Radio,
RadioGroup,
} from "@mui/material";
import { useState } from "react";
import { RepetitionObject } from "../../features/Events/EventsTypes";
export default function RepeatEvent({
repetition,
setRepetition,
isOwn = true,
}: {
repetition: RepetitionObject;
setRepetition: Function;
isOwn?: boolean;
}) {
console.log(JSON.stringify(repetition));
const repetitionValues = ["day", "week", "month", "year"];
const [interval, setInterval] = useState(repetition.interval ?? 0);
const [selectedDays, setSelectedDays] = useState<string[]>(
repetition.selectedDays ?? []
);
const [endOption, setEndOption] = useState("");
const [occurrences, setOccurrences] = useState(repetition.occurrences) ?? 0;
const [endDate, setEndDate] = useState(repetition.endDate ?? "");
const days = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
const handleDayChange = (day: string) => {
setSelectedDays((prev: string[]) =>
prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day]
);
};
return (
<FormControl fullWidth margin="dense" size="small">
<InputLabel id="repeat">Repetition</InputLabel>
<Select
labelId="repeat"
value={repetition.freq ?? ""}
disabled={!isOwn}
label="Repetition"
onChange={(e: SelectChangeEvent) =>
setRepetition({ ...repetition, freq: 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>
{repetition.freq && (
<Stack>
<Box display="flex" alignItems="center" gap={2} mb={2}>
<Typography>Interval:</Typography>
<TextField
type="number"
value={interval}
onChange={(e) => setInterval(Number(e.target.value))}
size="small"
sx={{ width: 80 }}
/>
<Typography>
{
repetitionValues[
repetitionValues.findIndex((el) => el === repetition.freq)
]
}
</Typography>
</Box>
{repetition.freq === "weekly" && (
<Box>
<Typography variant="body2" gutterBottom>
On days:
</Typography>
<FormGroup row>
{days.map((day) => (
<FormControlLabel
key={day}
control={
<Checkbox
checked={selectedDays.includes(day)}
onChange={() => handleDayChange(day)}
/>
}
label={day}
/>
))}
</FormGroup>
</Box>
)}
<Box>
<Typography variant="body2" gutterBottom sx={{ mt: 2 }}>
End:
</Typography>
<RadioGroup
value={endOption}
onChange={(e) => setEndOption(e.target.value)}
>
<FormControlLabel
value="never"
control={<Radio />}
label="Never"
/>
<FormControlLabel
value="after"
control={<Radio />}
label={
<Box display="flex" alignItems="center" gap={1}>
After
<TextField
type="number"
size="small"
value={occurrences}
onChange={(e) => setOccurrences(Number(e.target.value))}
sx={{ width: 100 }}
inputProps={{ min: 1 }}
disabled={endOption !== "after"}
/>
occurrences
</Box>
}
/>
<FormControlLabel
value="on"
control={<Radio />}
label={
<Box display="flex" alignItems="center" gap={1}>
On
<TextField
type="date"
size="small"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
disabled={endOption !== "on"}
/>
</Box>
}
/>
</RadioGroup>
</Box>
</Stack>
)}
</FormControl>
);
}
+1 -2
View File
@@ -74,7 +74,7 @@ export function Menubar() {
> >
<div className="app-grid"> <div className="app-grid">
{applist.map((prop: AppIconProps) => ( {applist.map((prop: AppIconProps) => (
<AppIcon prop={prop} /> <AppIcon key={prop.name} prop={prop} />
))} ))}
</div> </div>
</Popover> </Popover>
@@ -96,7 +96,6 @@ export function MainTitle() {
function AppIcon({ prop }: { prop: AppIconProps }) { function AppIcon({ prop }: { prop: AppIconProps }) {
return ( return (
<a <a
key={prop.name}
href={prop.link} href={prop.link}
target="_blank" target="_blank"
style={{ textDecoration: "none", color: "inherit" }} style={{ textDecoration: "none", color: "inherit" }}
+92 -2
View File
@@ -4,7 +4,7 @@ import { CalendarEvent } from "../Events/EventsTypes";
import { getCalendar, getCalendars } from "./CalendarApi"; import { getCalendar, getCalendars } from "./CalendarApi";
import { getOpenPaasUser, getUserDetails } from "../User/userAPI"; import { getOpenPaasUser, getUserDetails } from "../User/userAPI";
import { parseCalendarEvent } from "../Events/eventUtils"; import { parseCalendarEvent } from "../Events/eventUtils";
import { deleteEvent, putEvent } from "../Events/EventApi"; import { deleteEvent, getEvent, moveEvent, putEvent } from "../Events/EventApi";
import { formatDateToYYYYMMDDTHHMMSS } from "../../utils/dateUtils"; import { formatDateToYYYYMMDDTHHMMSS } from "../../utils/dateUtils";
export const getCalendarsListAsync = createAsyncThunk< export const getCalendarsListAsync = createAsyncThunk<
@@ -26,7 +26,6 @@ export const getCalendarsListAsync = createAsyncThunk<
source = cal["calendarserver:delegatedsource"]; source = cal["calendarserver:delegatedsource"];
delegated = true; delegated = true;
} }
console.log(source);
const id = source.replace("/calendars/", "").replace(".json", ""); const id = source.replace("/calendars/", "").replace(".json", "");
const ownerData: any = await getUserDetails(id.split("/")[0]); const ownerData: any = await getUserDetails(id.split("/")[0]);
@@ -34,6 +33,9 @@ export const getCalendarsListAsync = createAsyncThunk<
importedCalendars[id] = { importedCalendars[id] = {
id, id,
name, name,
owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${
ownerData.lastname
}`,
ownerEmails: ownerData.emails, ownerEmails: ownerData.emails,
description, description,
delegated, delegated,
@@ -91,6 +93,44 @@ export const putEventAsync = createAsyncThunk<
}; };
}); });
export const getEventAsync = createAsyncThunk<
{ calId: string; event: CalendarEvent }, // Return type
CalendarEvent // Arg type
>("calendars/getEvent", async (event) => {
const response: CalendarEvent = await getEvent(event);
return {
calId: event.calId,
event: response,
};
});
export const moveEventAsync = createAsyncThunk<
{ calId: string; events: CalendarEvent[] }, // Return type
{ cal: Calendars; newEvent: CalendarEvent; newURL: string } // Arg type
>("calendars/moveEvent", async ({ cal, newEvent, newURL }) => {
const response = await moveEvent(newEvent, newURL);
const calEvents = (await getCalendar(cal.id, {
start: formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.start)),
end: formatDateToYYYYMMDDTHHMMSS(
new Date(new Date(newEvent.start).getTime() + 86400000)
),
})) as Record<string, any>;
const events: CalendarEvent[] = calEvents._embedded["dav:item"].flatMap(
(eventdata: any) => {
const vevents = eventdata.data[2] as any[][];
const eventURL = eventdata._links.self.href;
return vevents.map((vevent: any[]) => {
return parseCalendarEvent(vevent[1], cal.color ?? "", cal.id, eventURL);
});
}
);
return {
calId: cal.id,
events,
};
});
export const deleteEventAsync = createAsyncThunk< export const deleteEventAsync = createAsyncThunk<
{ calId: string; eventId: string }, // Return type { calId: string; eventId: string }, // Return type
{ calId: string; eventId: string; eventURL: string } // Arg type { calId: string; eventId: string; eventURL: string } // Arg type
@@ -205,6 +245,50 @@ const CalendarSlice = createSlice({
}); });
} }
) )
.addCase(
getEventAsync.fulfilled,
(
state,
action: PayloadAction<{ calId: string; event: CalendarEvent }>
) => {
state.pending = false;
if (!state.list[action.payload.calId]) {
state.list[action.payload.calId] = {
id: action.payload.calId,
events: {},
} as Calendars;
}
state.list[action.payload.calId].events[action.payload.event.uid] =
action.payload.event;
}
)
.addCase(
moveEventAsync.fulfilled,
(
state,
action: PayloadAction<{ calId: string; events: CalendarEvent[] }>
) => {
state.pending = false;
if (!state.list[action.payload.calId]) {
state.list[action.payload.calId] = {
id: action.payload.calId,
events: {},
} as Calendars;
}
action.payload.events.forEach((event) => {
state.list[action.payload.calId].events[event.uid] = event;
});
Object.keys(state.list[action.payload.calId].events).forEach((id) => {
state.list[action.payload.calId].events[id].color =
state.list[action.payload.calId].color;
state.list[action.payload.calId].events[id].calId =
action.payload.calId;
state.list[action.payload.calId].events[id].timezone =
Intl.DateTimeFormat().resolvedOptions().timeZone;
});
}
)
.addCase(deleteEventAsync.fulfilled, (state, action) => { .addCase(deleteEventAsync.fulfilled, (state, action) => {
state.pending = false; state.pending = false;
delete state.list[action.payload.calId].events[action.payload.eventId]; delete state.list[action.payload.calId].events[action.payload.eventId];
@@ -212,12 +296,18 @@ const CalendarSlice = createSlice({
.addCase(getCalendarDetailAsync.pending, (state) => { .addCase(getCalendarDetailAsync.pending, (state) => {
state.pending = true; state.pending = true;
}) })
.addCase(getEventAsync.pending, (state) => {
state.pending = true;
})
.addCase(getCalendarsListAsync.pending, (state) => { .addCase(getCalendarsListAsync.pending, (state) => {
state.pending = true; state.pending = true;
}) })
.addCase(putEventAsync.pending, (state) => { .addCase(putEventAsync.pending, (state) => {
state.pending = true; state.pending = true;
}) })
.addCase(moveEventAsync.pending, (state) => {
state.pending = true;
})
.addCase(deleteEventAsync.pending, (state) => { .addCase(deleteEventAsync.pending, (state) => {
state.pending = true; state.pending = true;
}); });
+1
View File
@@ -7,6 +7,7 @@ export interface Calendars {
prodid?: string; prodid?: string;
color?: string; color?: string;
ownerEmails?: string[]; ownerEmails?: string[];
owner: string;
description?: string; description?: string;
calscale?: string; calscale?: string;
version?: string; version?: string;
+28 -4
View File
@@ -1,6 +1,20 @@
import { api } from "../../utils/apiUtils"; import { api } from "../../utils/apiUtils";
import { CalendarEvent } from "./EventsTypes"; import { CalendarEvent } from "./EventsTypes";
import { calendarEventToJCal } from "./eventUtils"; import { calendarEventToJCal, parseCalendarEvent } from "./eventUtils";
import ICAL from "ical.js";
export async function getEvent(event: CalendarEvent) {
const response = await api.get(`dav${event.URL}`);
const eventData = await response.text();
const eventical = ICAL.parse(eventData);
const eventjson = parseCalendarEvent(
eventical[2][1][1],
event.color ?? "",
event.calId,
event.URL
);
return { ...eventjson, ...event };
}
export async function putEvent(event: CalendarEvent) { export async function putEvent(event: CalendarEvent) {
const response = await api(`dav${event.URL}`, { const response = await api(`dav${event.URL}`, {
@@ -13,12 +27,22 @@ export async function putEvent(event: CalendarEvent) {
if (response.status === 201) { if (response.status === 201) {
console.log("PUT (201) :", response.url); console.log("PUT (201) :", response.url);
} }
return await response.json(); return response;
}
export async function moveEvent(event: CalendarEvent, newUrl: string) {
const response = await api(`dav${event.URL}`, {
method: "MOVE",
headers: {
destination: newUrl,
},
});
return response;
} }
export async function deleteEvent(eventURL: string) { export async function deleteEvent(eventURL: string) {
const response = await api(eventURL, { const response = await api(`dav${eventURL}`, {
method: "DELETE", method: "DELETE",
}).json(); });
return response; return response;
} }
+438 -165
View File
@@ -1,6 +1,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { deleteEventAsync, putEventAsync } from "../Calendars/CalendarSlice"; import {
deleteEventAsync,
getEventAsync,
moveEventAsync,
putEventAsync,
removeEvent,
} from "../Calendars/CalendarSlice";
import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { useAppDispatch, useAppSelector } from "../../app/hooks";
import AttendeeSelector from "../../components/Attendees/AttendeeSearch";
import { import {
Popover, Popover,
Button, Button,
@@ -13,30 +20,42 @@ import {
IconButton, IconButton,
Avatar, Avatar,
Badge, Badge,
Modal,
TextField,
CardHeader,
FormControl,
InputLabel,
MenuItem,
Select,
SelectChangeEvent,
Checkbox,
FormControlLabel,
CardActions,
Link,
} from "@mui/material"; } from "@mui/material";
import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
import LocationOnIcon from "@mui/icons-material/LocationOn";
import VideocamIcon from "@mui/icons-material/Videocam"; import VideocamIcon from "@mui/icons-material/Videocam";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline"; import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import CircleIcon from "@mui/icons-material/Circle"; import CircleIcon from "@mui/icons-material/Circle";
import CancelIcon from "@mui/icons-material/Cancel"; import CancelIcon from "@mui/icons-material/Cancel";
import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import { userAttendee } from "../User/userDataTypes"; import { userAttendee } from "../User/userDataTypes";
import { putEvent } from "./EventApi"; import { TIMEZONES } from "../../utils/timezone-data";
import { Calendars } from "../Calendars/CalendarTypes";
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
import { isValidUrl } from "../../utils/apiUtils";
import { formatLocalDateTime } from "./EventModal";
import RepeatEvent from "../../components/Event/EventRepeat";
function EventDisplayModal({ export default function EventDisplayModal({
eventId, eventId,
calId, calId,
anchorEl,
open, open,
onClose, onClose,
}: { }: {
eventId: string; eventId: string;
calId: string; calId: string;
anchorEl: HTMLElement | null;
open: boolean; open: boolean;
onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void; onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void;
}) { }) {
@@ -46,35 +65,69 @@ function EventDisplayModal({
(state) => state.calendars.list[calId]?.events[eventId] (state) => state.calendars.list[calId]?.events[eventId]
); );
const user = useAppSelector((state) => state.user); const user = useAppSelector((state) => state.user);
const [showAllAttendees, setShowAllAttendees] = useState(false); const [showAllAttendees, setShowAllAttendees] = useState(false);
const [showMore, setShowMore] = useState(false);
const calendars = Object.values(
useAppSelector((state) => state.calendars.list)
);
const userPersonnalCalendars: Calendars[] = calendars.filter(
(c) => c.id?.split("/")[0] === user.userData.openpaasId
);
// Form state
const [title, setTitle] = useState(event?.title ?? "");
const [description, setDescription] = useState(event?.description ?? "");
const [location, setLocation] = useState(event?.location ?? "");
const [start, setStart] = useState(
formatLocalDateTime(new Date(event?.start ?? Date.now()))
);
const [end, setEnd] = useState(
formatLocalDateTime(new Date(event?.end ?? Date.now()))
);
const [allday, setAllDay] = useState(event?.allday);
const [repetition, setRepetition] = useState<RepetitionObject>(
event.repetition ?? ({} as RepetitionObject)
);
const [alarm, setAlarm] = useState("");
const [busy, setBusy] = useState("");
const [eventClass, setEventClass] = useState(event?.class ?? "PUBLIC");
const [timezone, setTimezone] = useState(event?.timezone ?? "UTC");
const [newCalId, setNewCalId] = useState(event.calId);
const [calendarid, setCalendarid] = useState(
calId.split("/")[0] === user.userData.openpaasId
? userPersonnalCalendars.findIndex((cal) => cal.id === calId)
: calendars.findIndex((cal) => cal.id === calId)
);
const [attendees, setAttendees] = useState(
(event?.attendee || []).filter(
(a) => a.cal_address !== event?.organizer?.cal_address
)
);
const currentUserAttendee = event?.attendee?.find(
(person) => person.cal_address === user.userData.email
);
const organizer =
event.attendee?.find(
(a) => a.cal_address === event?.organizer?.cal_address
) ?? ({} as userAttendee);
const isOwn = organizer?.cal_address === user.userData.email;
const isOwnCal = userPersonnalCalendars.find((cal) => cal.id === calId);
const attendeeDisplayLimit = 3;
useEffect(() => { useEffect(() => {
if (!event || !calendar) { if (!event || !calendar) {
onClose({}, "backdropClick"); onClose({}, "backdropClick");
} }
}, [event, calendar, onClose]); }, [open, eventId, dispatch, onClose]);
if (!event || !calendar) return null; if (!event || !calendar) return null;
const attendeeDisplayLimit = 3;
const attendees =
event.attendee?.filter(
(a) => a.cal_address !== event.organizer?.cal_address
) || [];
const visibleAttendees = showAllAttendees
? attendees
: attendees.slice(0, attendeeDisplayLimit);
const currentUserAttendee = event.attendee?.find(
(person) => person.cal_address === user.userData.email
);
const organizer = event.attendee?.find(
(a) => a.cal_address === event.organizer?.cal_address
);
function handleRSVP(rsvp: string) { function handleRSVP(rsvp: string) {
const newEvent = { const newEvent = {
...event, ...event,
@@ -84,129 +137,118 @@ function EventDisplayModal({
}; };
dispatch(putEventAsync({ cal: calendar, newEvent })); dispatch(putEventAsync({ cal: calendar, newEvent }));
onClose({}, "backdropClick");
} }
const handleSave = async () => {
const newEventUID = crypto.randomUUID();
const newEvent: CalendarEvent = {
calId,
title,
URL: event.URL ?? `/calendars/${calId}/${newEventUID}.ics`,
start: new Date(start),
end: new Date(end),
allday,
uid: event.uid ?? newEventUID,
description,
location,
repetition,
class: eventClass,
organizer: event.organizer,
timezone,
attendee: [organizer, ...attendees],
transp: "OPAQUE",
color: userPersonnalCalendars[calendarid]?.color,
};
await dispatch(
putEventAsync({
cal: userPersonnalCalendars[calendarid],
newEvent,
})
);
if (newCalId !== calId) {
dispatch(
moveEventAsync({
cal: userPersonnalCalendars[calendarid],
newEvent,
newURL: `/calendars/${newCalId}/${event.uid}.ics`,
})
);
dispatch(removeEvent({ calendarUid: calId, eventUid: event.uid }));
}
onClose({}, "backdropClick");
};
const [detailsLoaded, setDetailsLoaded] = useState(false);
const handleToggleShowMore = async () => {
if (!detailsLoaded) {
await dispatch(getEventAsync(event));
setDetailsLoaded(true);
}
setShowMore(!showMore);
};
const calList =
calId.split("/")[0] === user.userData.openpaasId
? Object.keys(userPersonnalCalendars).map((calendar, index) => (
<MenuItem key={index} value={index}>
<Typography variant="body2">
<CircleIcon
sx={{
color: userPersonnalCalendars[index].color ?? "#3788D8",
width: 12,
height: 12,
}}
/>
{userPersonnalCalendars[index].name}
</Typography>
</MenuItem>
))
: Object.keys(calendars).map((calendar, index) => (
<MenuItem key={index} value={index}>
<Typography variant="body2">
<CircleIcon
sx={{
color: calendars[index].color ?? "#3788D8",
width: 12,
height: 12,
}}
/>
{calendars[index].name} - {calendars[index].owner}
</Typography>
</MenuItem>
));
return ( return (
<Popover open={open} anchorEl={anchorEl} onClose={onClose}> <Modal open={open} onClose={onClose}>
<Card sx={{ width: 300, p: 2, position: "relative" }}> <Card sx={{ minWidth: 300, width: "50vw", p: 2, position: "absolute" }}>
{/* Top-right buttons */} {/* Close button */}
<Box <Box sx={{ position: "absolute", top: 8, right: 8 }}>
sx={{
position: "absolute",
top: 8,
right: 8,
display: "flex",
gap: 1,
}}
>
<IconButton size="small">
<EditIcon fontSize="small" />
</IconButton>
{calendar.id.split("/")[0] === user.userData.openpaasId && (
<IconButton
size="small"
onClick={() => {
onClose({}, "backdropClick");
dispatch(deleteEventAsync({ calId, eventId }));
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
)}
<IconButton size="small" onClick={() => onClose({}, "backdropClick")}> <IconButton size="small" onClick={() => onClose({}, "backdropClick")}>
<CloseIcon fontSize="small" /> <CloseIcon fontSize="small" />
</IconButton> </IconButton>
</Box> </Box>
<CardContent sx={{ pt: 1.5 }}> <CardHeader title={isOwn ? "Edit Event" : "Event Details"} />
{event.title && (
<Typography variant="h6" fontWeight="bold" gutterBottom>
{event.title}
</Typography>
)}
{/* Time info*/} <CardContent sx={{ overflow: "auto" }}>
<Typography variant="body2" color="textSecondary" gutterBottom> {/* Title */}
{formatDate(event.start)} <TextField
{event.end && fullWidth
` ${new Date(event.end).toLocaleTimeString(undefined, { disabled={!isOwn}
hour: "2-digit", label="Title"
minute: "2-digit", value={title}
})}`} onChange={(e) => setTitle(e.target.value)}
</Typography> size="small"
margin="dense"
{/* Location */} />
{event.location && (
<InfoRow
icon={<LocationOnIcon sx={{ fontSize: 18 }} />}
text={event.location}
/>
)}
{/* Video */}
{event.x_openpass_videoconference && (
<InfoRow
icon={<VideocamIcon sx={{ fontSize: 18 }} />}
text="Video conference available"
/>
)}
{/* Attendees */}
{event.attendee?.length > 0 && (
<Box sx={{ mb: 1 }}>
<Typography variant="subtitle2">Attendees:</Typography>
{organizer && renderAttendeeBadge(organizer, "org", true)}
{visibleAttendees.map((a, idx) =>
renderAttendeeBadge(a, idx.toString())
)}
{attendees.length > attendeeDisplayLimit && (
<Typography
variant="body2"
color="primary"
sx={{ cursor: "pointer", mt: 0.5 }}
onClick={() => setShowAllAttendees(!showAllAttendees)}
>
{showAllAttendees
? "Show less"
: `Show more (${
attendees.length - attendeeDisplayLimit
} more)`}
</Typography>
)}
</Box>
)}
{/* Error */}
{event.error && (
<InfoRow
icon={<ErrorOutlineIcon color="error" sx={{ fontSize: 18 }} />}
text={event.error}
error
/>
)}
{/* Calendar color dot */}
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 2 }}>
<CalendarTodayIcon sx={{ fontSize: 16 }} />
<CircleIcon
sx={{
color: calendar.color ?? "#3788D8",
width: 12,
height: 12,
}}
/>
<Typography variant="body2">{calendar.name}</Typography>
</Box>
<Divider sx={{ mb: 1 }} />
{/* RSVP */} {/* RSVP */}
{currentUserAttendee && ( {currentUserAttendee && isOwnCal && (
<Box> <Card sx={{ my: 1 }}>
<Typography variant="body2" sx={{ mb: 1 }}>
Will you attend?
</Typography>
<ButtonGroup size="small" fullWidth> <ButtonGroup size="small" fullWidth>
<Button <Button
color={ color={
@@ -238,47 +280,290 @@ function EventDisplayModal({
> >
Decline Decline
</Button> </Button>
<Button
color="primary"
onClick={() => console.log("proposenewtime")}
>
Propose new time
</Button>
</ButtonGroup> </ButtonGroup>
</Card>
)}
{/* Calendar selector */}
<FormControl fullWidth margin="dense" size="small">
<InputLabel id="calendar-select-label">Calendar</InputLabel>
<Select
disabled={!isOwn}
labelId="calendar-select-label"
value={calendarid.toString()}
label="Calendar"
onChange={(e: SelectChangeEvent) => {
const newId = Number(e.target.value);
setCalendarid(newId);
setNewCalId(userPersonnalCalendars[newId].id);
}}
>
{calList}
</Select>
</FormControl>
{/* Dates */}
<TextField
fullWidth
label="Start"
disabled={!isOwn}
type={allday ? "date" : "datetime-local"}
value={allday ? start.split("T")[0] : start.slice(0, 16)}
onChange={(e) =>
setStart(formatLocalDateTime(new Date(e.target.value)))
}
size="small"
margin="dense"
InputLabelProps={{ shrink: true }}
/>
<TextField
fullWidth
disabled={!isOwn}
label="End"
type={allday ? "date" : "datetime-local"}
value={allday ? end.split("T")[0] : end.slice(0, 16)}
onChange={(e) =>
setEnd(formatLocalDateTime(new Date(e.target.value)))
}
size="small"
margin="dense"
InputLabelProps={{ shrink: true }}
/>
<FormControlLabel
control={
<Checkbox
disabled={!isOwn}
checked={allday}
onChange={() => {
const endDate = new Date(end);
const startDate = new Date(start);
setAllDay(!allday);
if (endDate.getDate() === startDate.getDate()) {
endDate.setDate(startDate.getDate() + 1);
setEnd(formatLocalDateTime(endDate));
}
}}
/>
}
label="All day"
/>
{/* Description & Location */}
<TextField
fullWidth
disabled={!isOwn}
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
size="small"
margin="dense"
multiline
rows={2}
/>
{isOwn && (
<AttendeeSelector
attendees={attendees}
setAttendees={(value: userAttendee[]) => {
const newAttendeeList = attendees.concat(value);
setAttendees(newAttendeeList);
}}
/>
)}
<TextField
fullWidth
label="Location"
disabled={!isOwn}
value={location}
onChange={(e) => setLocation(e.target.value)}
size="small"
margin="dense"
/>
{/* Video */}
{event.x_openpass_videoconference && (
<InfoRow
icon={<VideocamIcon sx={{ fontSize: 18 }} />}
text="Video conference available"
data={event.x_openpass_videoconference}
/>
)}
{/* Attendees */}
{event.attendee?.length > 0 && (
<Box sx={{ mb: 1 }}>
<Typography variant="subtitle2">Attendees:</Typography>
{organizer.cal_address &&
renderAttendeeBadge(organizer, "org", true)}
{(showAllAttendees
? attendees
: attendees.slice(0, attendeeDisplayLimit)
).map((a, idx) => (
<Box key={a.cal_address}>
{renderAttendeeBadge(a, idx.toString())}
{isOwn && (
<IconButton
size="small"
onClick={() => {
const newAttendeesList = [...attendees];
newAttendeesList.splice(idx, 1);
setAttendees(newAttendeesList);
}}
>
<CloseIcon fontSize="small" />
</IconButton>
)}
</Box>
))}
{attendees.length > attendeeDisplayLimit && (
<Typography
variant="body2"
color="primary"
sx={{ cursor: "pointer", mt: 0.5 }}
onClick={() => setShowAllAttendees(!showAllAttendees)}
>
{showAllAttendees
? "Show less"
: `Show more (${
attendees.length - attendeeDisplayLimit
} more)`}
</Typography>
)}
</Box> </Box>
)} )}
{/* Description */} <Divider sx={{ my: 1 }} />
{event.description && (
<Typography variant="body2" sx={{ mt: 1 }}> {/* Extended options */}
{event.description} {showMore && (
</Typography> <>
<RepeatEvent
repetition={repetition}
setRepetition={setRepetition}
isOwn={isOwn}
/>
<FormControl fullWidth margin="dense" size="small">
<InputLabel id="alarm">Alarm</InputLabel>
<Select
labelId="alarm"
value={alarm}
disabled={!isOwn}
onChange={(e: SelectChangeEvent) => setAlarm(e.target.value)}
>
<MenuItem value={""}>No Alarm</MenuItem>
<MenuItem value={"-PT1M"}>1 minute</MenuItem>
<MenuItem value={"-PT5M"}>2 minutes</MenuItem>
<MenuItem value={"-PT10M"}>10 minutes</MenuItem>
<MenuItem value={"-PT15M"}>15 minutes</MenuItem>
<MenuItem value={"-PT30M"}>30 minutes</MenuItem>
<MenuItem value={"-PT1H"}>1 hours</MenuItem>
<MenuItem value={"-PT2H"}>2 hours</MenuItem>
<MenuItem value={"-PT5H"}>5 hours</MenuItem>
<MenuItem value={"-PT12H"}>12 hours</MenuItem>
<MenuItem value={"-PT1D"}>1 day</MenuItem>
<MenuItem value={"-PT2D"}>2 days</MenuItem>
<MenuItem value={"-PT1W"}>1 week</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth margin="dense" size="small">
<InputLabel id="Visibility">Visibility</InputLabel>
<Select
labelId="Visibility"
label="Visibility"
value={eventClass}
disabled={!isOwn}
onChange={(e: SelectChangeEvent) =>
setEventClass(e.target.value)
}
>
<MenuItem value={"PUBLIC"}>Public</MenuItem>
<MenuItem value={"CONFIDENTIAL"}>Show time only</MenuItem>
<MenuItem value={"PRIVATE"}>Private</MenuItem>
</Select>
</FormControl>
{/* Error */}
{event.error && (
<InfoRow
icon={
<ErrorOutlineIcon color="error" sx={{ fontSize: 18 }} />
}
text={event.error}
error
/>
)}
</>
)} )}
</CardContent> </CardContent>
<CardActions>
<ButtonGroup>
{isOwn && (
<IconButton
size="small"
onClick={() => {
onClose({}, "backdropClick");
dispatch(
deleteEventAsync({ calId, eventId, eventURL: event.URL })
);
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
)}
<Button size="small" onClick={handleToggleShowMore}>
{showMore ? "Show Less" : "Show More"}
</Button>
{isOwn && (
<Button size="small" onClick={handleSave}>
Save
</Button>
)}
</ButtonGroup>
</CardActions>
</Card> </Card>
</Popover> </Modal>
); );
} }
function InfoRow({ export function InfoRow({
icon, icon,
text, text,
error = false, error = false,
data,
}: { }: {
icon: React.ReactNode; icon: React.ReactNode;
text: string; text: string;
error?: boolean; error?: boolean;
data?: string;
}) { }) {
return ( return (
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }}>
{icon} {icon}
<Typography variant="body2" color={error ? "error" : "textPrimary"}> <Typography variant="body2" color={error ? "error" : "textPrimary"}>
{text} {isValidUrl(data) ? <Link href={data}>{text}</Link> : text}
</Typography> </Typography>
</Box> </Box>
); );
} }
function renderAttendeeBadge( export function renderAttendeeBadge(
a: userAttendee, a: userAttendee,
key: string, key: string,
isOrganizer?: boolean isOrganizer?: boolean
) { ) {
const statusIcon = const classIcon =
a.partstat === "ACCEPTED" ? ( a.partstat === "ACCEPTED" ? (
<CheckCircleIcon fontSize="inherit" color="success" /> <CheckCircleIcon fontSize="inherit" color="success" />
) : a.partstat === "DECLINED" ? ( ) : a.partstat === "DECLINED" ? (
@@ -301,7 +586,7 @@ function renderAttendeeBadge(
overlap="circular" overlap="circular"
anchorOrigin={{ vertical: "bottom", horizontal: "left" }} anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
badgeContent={ badgeContent={
statusIcon && ( classIcon && (
<Box <Box
sx={{ sx={{
fontSize: 14, fontSize: 14,
@@ -311,7 +596,7 @@ function renderAttendeeBadge(
padding: "1px", padding: "1px",
}} }}
> >
{statusIcon} {classIcon}
</Box> </Box>
) )
} }
@@ -344,16 +629,6 @@ function renderAttendeeBadge(
); );
} }
function formatDate(date: Date) {
return new Date(date).toLocaleString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export function stringToColor(string: string) { export function stringToColor(string: string) {
let hash = 0; let hash = 0;
for (let i = 0; i < string.length; i++) { for (let i = 0; i < string.length; i++) {
@@ -369,11 +644,9 @@ export function stringToColor(string: string) {
return color; return color;
} }
function stringAvatar(name: string) { export function stringAvatar(name: string) {
return { return {
sx: { width: 24, height: 24, fontSize: 18, bgcolor: stringToColor(name) }, sx: { width: 24, height: 24, fontSize: 18, bgcolor: stringToColor(name) },
children: name[0], children: name[0],
}; };
} }
export default EventDisplayModal;
+357
View File
@@ -0,0 +1,357 @@
import { useEffect, useState } from "react";
import {
deleteEventAsync,
getEventAsync,
putEventAsync,
} from "../Calendars/CalendarSlice";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import {
Popover,
Button,
Box,
Typography,
ButtonGroup,
Card,
CardContent,
Divider,
IconButton,
Avatar,
Badge,
PopoverPosition,
} from "@mui/material";
import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete";
import VisibilityIcon from "@mui/icons-material/Visibility";
import CloseIcon from "@mui/icons-material/Close";
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
import LocationOnIcon from "@mui/icons-material/LocationOn";
import VideocamIcon from "@mui/icons-material/Videocam";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import CircleIcon from "@mui/icons-material/Circle";
import CancelIcon from "@mui/icons-material/Cancel";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import { userAttendee } from "../User/userDataTypes";
import EventDisplayModal, {
InfoRow,
renderAttendeeBadge,
stringAvatar,
} from "./EventDisplay";
import { getEvent } from "./EventApi";
import { CalendarEvent } from "./EventsTypes";
export default function EventPreviewModal({
eventId,
calId,
anchorPosition,
open,
onClose,
}: {
eventId: string;
calId: string;
anchorPosition: PopoverPosition | null;
open: boolean;
onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void;
}) {
const dispatch = useAppDispatch();
const calendars = useAppSelector((state) => state.calendars);
const calendar = calendars.list[calId];
const event = useAppSelector(
(state) => state.calendars.list[calId]?.events[eventId]
);
const user = useAppSelector((state) => state.user);
const [showAllAttendees, setShowAllAttendees] = useState(false);
const [openFullDisplay, setOpenFullDisplay] = useState(false);
useEffect(() => {
if (!event || !calendar) {
onClose({}, "backdropClick");
}
}, [event, calendar, onClose]);
if (!event || !calendar) return null;
const attendeeDisplayLimit = 3;
const attendees =
event.attendee?.filter(
(a) => a.cal_address !== event.organizer?.cal_address
) || [];
const visibleAttendees = showAllAttendees
? attendees
: attendees.slice(0, attendeeDisplayLimit);
const currentUserAttendee = event.attendee?.find(
(person) => person.cal_address === user.userData.email
);
const organizer = event.attendee?.find(
(a) => a.cal_address === event.organizer?.cal_address
);
function handleRSVP(rsvp: string) {
const newEvent = {
...event,
attendee: event.attendee?.map((a) =>
a.cal_address === user.userData.email ? { ...a, partstat: rsvp } : a
),
};
dispatch(putEventAsync({ cal: calendar, newEvent }));
onClose({}, "backdropClick");
}
return (
<>
<Popover
open={open}
anchorReference="anchorPosition"
anchorPosition={anchorPosition ?? undefined}
onClose={onClose}
>
<Card sx={{ width: 300, p: 2, position: "relative" }}>
{/* Top-right buttons */}
<Box
sx={{
position: "absolute",
top: 8,
right: 8,
display: "flex",
gap: 1,
}}
>
{user.userData.email !== event.organizer?.cal_address && (
<IconButton
size="small"
onClick={() => {
setOpenFullDisplay(!openFullDisplay);
}}
>
<VisibilityIcon fontSize="small" />
</IconButton>
)}
{user.userData.email === event.organizer?.cal_address && (
<>
<IconButton
size="small"
onClick={async () => {
setOpenFullDisplay(!openFullDisplay);
}}
>
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => {
onClose({}, "backdropClick");
dispatch(
deleteEventAsync({ calId, eventId, eventURL: event.URL })
);
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</>
)}
<IconButton
size="small"
onClick={() => onClose({}, "backdropClick")}
>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
<CardContent sx={{ pt: 1.5 }}>
{event.title && (
<Typography variant="h6" fontWeight="bold" gutterBottom>
{event.title}
</Typography>
)}
{/* Time info*/}
<Typography variant="body2" color="textSecondary" gutterBottom>
{formatDate(event.start, event.allday)}
{event.end &&
formatEnd(event.start, event.end, event.allday) &&
` ${formatEnd(event.start, event.end, event.allday)}`}
</Typography>
{/* Location */}
{event.location && (
<InfoRow
icon={<LocationOnIcon sx={{ fontSize: 18 }} />}
text={event.location}
/>
)}
{/* Video */}
{event.x_openpass_videoconference && (
<InfoRow
icon={<VideocamIcon sx={{ fontSize: 18 }} />}
text="Video conference available"
data={event.x_openpass_videoconference}
/>
)}
{/* Attendees */}
{event.attendee?.length > 0 && (
<Box sx={{ mb: 1 }}>
<Typography variant="subtitle2">Attendees:</Typography>
{organizer && renderAttendeeBadge(organizer, "org", true)}
{visibleAttendees.map((a, idx) =>
renderAttendeeBadge(a, idx.toString())
)}
{attendees.length > attendeeDisplayLimit && (
<Typography
variant="body2"
color="primary"
sx={{ cursor: "pointer", mt: 0.5 }}
onClick={() => setShowAllAttendees(!showAllAttendees)}
>
{showAllAttendees
? "Show less"
: `Show more (${
attendees.length - attendeeDisplayLimit
} more)`}
</Typography>
)}
</Box>
)}
{/* Error */}
{event.error && (
<InfoRow
icon={<ErrorOutlineIcon color="error" sx={{ fontSize: 18 }} />}
text={event.error}
error
/>
)}
{/* Calendar color dot */}
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 2 }}>
<CalendarTodayIcon sx={{ fontSize: 16 }} />
<CircleIcon
sx={{
color: calendar.color ?? "#3788D8",
width: 12,
height: 12,
}}
/>
<Typography variant="body2">{calendar.name}</Typography>
</Box>
<Divider sx={{ mb: 1 }} />
{/* RSVP */}
{currentUserAttendee && (
<Box>
<Typography variant="body2" sx={{ mb: 1 }}>
Will you attend?
</Typography>
<ButtonGroup size="small" fullWidth>
<Button
color={
currentUserAttendee.partstat === "ACCEPTED"
? "success"
: "primary"
}
onClick={() => handleRSVP("ACCEPTED")}
>
Accept
</Button>
<Button
color={
currentUserAttendee.partstat === "TENTATIVE"
? "warning"
: "primary"
}
onClick={() => handleRSVP("TENTATIVE")}
>
Maybe
</Button>
<Button
color={
currentUserAttendee.partstat === "DECLINED"
? "error"
: "primary"
}
onClick={() => handleRSVP("DECLINED")}
>
Decline
</Button>
</ButtonGroup>
</Box>
)}
{/* Description */}
{event.description && (
<Typography variant="body2" sx={{ mt: 1 }}>
{event.description}
</Typography>
)}
</CardContent>
</Card>
</Popover>
<EventDisplayModal
open={openFullDisplay}
onClose={() => setOpenFullDisplay(false)}
eventId={eventId}
calId={calId}
/>
</>
);
}
function formatDate(date: Date, allday?: boolean) {
if (allday) {
return new Date(date).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
weekday: "long",
day: "numeric",
});
} else {
return new Date(date).toLocaleString(undefined, {
year: "numeric",
month: "long",
weekday: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
}
function formatEnd(start: Date, end: Date, allday?: boolean) {
const startDate = new Date(start);
const endDate = new Date(end);
const sameDay =
startDate.getFullYear() === endDate.getFullYear() &&
startDate.getMonth() === endDate.getMonth() &&
startDate.getDate() === endDate.getDate();
if (allday) {
return sameDay
? null
: endDate.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
} else {
if (sameDay) {
return endDate.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
});
}
return endDate.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
}
+208 -152
View File
@@ -2,6 +2,10 @@ import { CalendarApi, DateSelectArg } from "@fullcalendar/core";
import { import {
Box, Box,
Button, Button,
Card,
CardActions,
CardContent,
CardHeader,
FormControl, FormControl,
InputLabel, InputLabel,
MenuItem, MenuItem,
@@ -18,8 +22,9 @@ import { TIMEZONES } from "../../utils/timezone-data";
import { putEventAsync } from "../Calendars/CalendarSlice"; import { putEventAsync } from "../Calendars/CalendarSlice";
import { Calendars } from "../Calendars/CalendarTypes"; import { Calendars } from "../Calendars/CalendarTypes";
import { userAttendee } from "../User/userDataTypes"; import { userAttendee } from "../User/userDataTypes";
import { CalendarEvent } from "./EventsTypes"; import { CalendarEvent, RepetitionObject } from "./EventsTypes";
import { createSelector } from "@reduxjs/toolkit"; import { createSelector } from "@reduxjs/toolkit";
import RepeatEvent from "../../components/Event/EventRepeat";
function EventPopover({ function EventPopover({
anchorEl, anchorEl,
@@ -56,6 +61,7 @@ function EventPopover({
selectPersonnalCalendars selectPersonnalCalendars
); );
const timezones = TIMEZONES.aliases; const timezones = TIMEZONES.aliases;
const [showMore, setShowMore] = useState(false);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
@@ -64,8 +70,14 @@ function EventPopover({
const [end, setEnd] = useState(""); const [end, setEnd] = useState("");
const [calendarid, setCalendarid] = useState(0); const [calendarid, setCalendarid] = useState(0);
const [allday, setAllDay] = useState(false); const [allday, setAllDay] = useState(false);
const [repetition, setRepetition] = useState(""); const [repetition, setRepetition] = useState<RepetitionObject>(
{} as RepetitionObject
);
const [attendees, setAttendees] = useState<userAttendee[]>([]); const [attendees, setAttendees] = useState<userAttendee[]>([]);
const [alarm, setAlarm] = useState("");
const [eventClass, setEventClass] = useState("PUBLIC");
const [busy, setBusy] = useState("");
const [timezone, setTimezone] = useState( const [timezone, setTimezone] = useState(
Intl.DateTimeFormat().resolvedOptions().timeZone Intl.DateTimeFormat().resolvedOptions().timeZone
); );
@@ -78,14 +90,18 @@ function EventPopover({
}, [selectedRange]); }, [selectedRange]);
const handleSave = async () => { const handleSave = async () => {
const newEventUID = crypto.randomUUID();
const newEvent: CalendarEvent = { const newEvent: CalendarEvent = {
calId: userPersonnalCalendars[calendarid].id, calId: userPersonnalCalendars[calendarid].id,
title, title,
URL: `/calendars/${userPersonnalCalendars[calendarid].id}/${newEventUID}.ics`,
start: new Date(start), start: new Date(start),
allday, allday,
uid: crypto.randomUUID(), uid: newEventUID,
description, description,
location, location,
class: eventClass,
repetition, repetition,
organizer, organizer,
timezone, timezone,
@@ -130,172 +146,212 @@ function EventPopover({
open={open} open={open}
anchorEl={anchorEl} anchorEl={anchorEl}
onClose={onClose} onClose={onClose}
anchorOrigin={{ vertical: "top", horizontal: "left" }} anchorOrigin={{
transformOrigin={{ vertical: "top", horizontal: "left" }} vertical: "center",
horizontal: "center",
}}
transformOrigin={{
vertical: "center",
horizontal: "center",
}}
> >
<Box p={2} width={500}> <Card>
<Typography variant="h6" gutterBottom> <CardHeader title="Create Event" />
Create Event <CardContent
</Typography> sx={{ maxHeight: "85vh", maxWidth: "40vw", overflow: "auto" }}
>
<TextField
fullWidth
label="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
size="small"
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="Title" label="Start"
value={title} type={allday ? "date" : "datetime-local"}
onChange={(e) => setTitle(e.target.value)} value={allday ? start.split("T")[0] : start}
size="small" onChange={(e) => {
margin="dense" const newStart = e.target.value;
/> setStart(newStart);
<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
fullWidth
label="Start"
type={allday ? "date" : "datetime-local"}
value={allday ? start.split("T")[0] : start}
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"
margin="dense"
InputLabelProps={{ shrink: true }}
/>
<TextField
fullWidth
label="End"
type={allday ? "date" : "datetime-local"}
value={allday ? end.split("T")[0] : end}
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"
margin="dense"
InputLabelProps={{ shrink: true }}
/>
<label>
<input
type="checkbox"
checked={allday}
onChange={() => {
setAllDay(!allday);
const newRange = { 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, ...selectedRange,
start: new Date(newStart),
startStr: newStart,
allDay: allday,
}; };
setSelectedRange(newRange); setSelectedRange(newRange);
calendarRef.current?.select(newRange); calendarRef.current?.select(newRange);
}} }}
size="small"
margin="dense"
InputLabelProps={{ shrink: true }}
/> />
All day <TextField
</label> fullWidth
<TextField label="End"
fullWidth type={allday ? "date" : "datetime-local"}
label="Description" value={allday ? end.split("T")[0] : end}
value={description} onChange={(e) => {
onChange={(e) => setDescription(e.target.value)} const newEnd = e.target.value;
size="small" setEnd(newEnd);
margin="dense" const newRange = {
multiline ...selectedRange,
rows={2} end: new Date(newEnd),
/> endStr: newEnd,
<TextField allDay: allday,
fullWidth };
label="Location" setSelectedRange(newRange);
value={location} calendarRef.current?.select(newRange);
onChange={(e) => setLocation(e.target.value)} }}
size="small" size="small"
margin="dense" margin="dense"
/> InputLabelProps={{ shrink: true }}
/>
<label>
<input
type="checkbox"
checked={allday}
onChange={() => {
const endDate = new Date(end);
const startDate = new Date(start);
setAllDay(!allday);
if (endDate.getDate() === startDate.getDate()) {
endDate.setDate(startDate.getDate() + 1);
setEnd(formatLocalDateTime(endDate));
}
<FormControl fullWidth margin="dense" size="small"> const newRange = {
<InputLabel id="repeat">Repetition</InputLabel> ...selectedRange,
<Select startStr: allday ? start.split("T")[0] : start,
labelId="repeat" endStr: allday
value={repetition} ? endDate.toISOString().split("T")[0]
label="Time Zone" : endDate.toISOString(),
onChange={(e: SelectChangeEvent) => setRepetition(e.target.value)} start: new Date(allday ? start.split("T")[0] : start),
> end: new Date(
<MenuItem value={""}>No Repetition</MenuItem> allday
<MenuItem value={"daily"}>Repeat daily</MenuItem> ? endDate.toISOString().split("T")[0]
<MenuItem value={"weekly"}>Repeat weekly</MenuItem> : endDate.toISOString()
<MenuItem value={"monthly"}>Repeat monthly</MenuItem> ),
<MenuItem value={"yearly"}>Repeat yearly</MenuItem> allDay: allday,
</Select> };
</FormControl> setSelectedRange(newRange);
<AttendeeSelector setAttendees={setAttendees} /> }}
<FormControl fullWidth margin="dense" size="small"> />
<InputLabel id="timezone-select-label">Time Zone</InputLabel> All day
<Select </label>
labelId="timezone-select-label" <TextField
value={timezone} fullWidth
label="Time Zone" label="Description"
onChange={(e: SelectChangeEvent) => setTimezone(e.target.value)} value={description}
> onChange={(e) => setDescription(e.target.value)}
{Object.keys(timezones).map((key) => ( size="small"
<MenuItem key={key} value={timezones[key].aliasTo}> margin="dense"
{key} multiline
</MenuItem> rows={2}
))} />
</Select> <TextField
</FormControl> fullWidth
label="Location"
value={location}
onChange={(e) => setLocation(e.target.value)}
size="small"
margin="dense"
/>
<AttendeeSelector attendees={attendees} setAttendees={setAttendees} />
{/* Extended options */}
{showMore && (
<>
<RepeatEvent
repetition={repetition}
setRepetition={setRepetition}
/>
<FormControl fullWidth margin="dense" size="small">
<InputLabel id="alarm">Alarm</InputLabel>
<Select
labelId="alarm"
value={alarm}
onChange={(e: SelectChangeEvent) => setAlarm(e.target.value)}
>
<MenuItem value={""}>No Alarm</MenuItem>
<MenuItem value={"-PT1M"}>1 minute</MenuItem>
<MenuItem value={"-PT5M"}>2 minutes</MenuItem>
<MenuItem value={"-PT10M"}>10 minutes</MenuItem>
<MenuItem value={"-PT15M"}>15 minutes</MenuItem>
<MenuItem value={"-PT30M"}>30 minutes</MenuItem>
<MenuItem value={"-PT1H"}>1 hours</MenuItem>
<MenuItem value={"-PT2H"}>2 hours</MenuItem>
<MenuItem value={"-PT5H"}>5 hours</MenuItem>
<MenuItem value={"-PT12H"}>12 hours</MenuItem>
<MenuItem value={"-PT1D"}>1 day</MenuItem>
<MenuItem value={"-PT2D"}>2 days</MenuItem>
<MenuItem value={"-PT1W"}>1 week</MenuItem>
</Select>
</FormControl>
<Box mt={2} display="flex" justifyContent="flex-end" gap={1}> <FormControl fullWidth margin="dense" size="small">
<Button <InputLabel id="Visibility">Visibility</InputLabel>
variant="outlined" <Select
onClick={() => onClose({}, "backdropClick")} labelId="Visibility"
> label="Visibility"
Cancel value={eventClass}
</Button> onChange={(e: SelectChangeEvent) =>
<Button variant="contained" onClick={handleSave} disabled={!title}> setEventClass(e.target.value)
Save }
</Button> >
</Box> <MenuItem value={"PUBLIC"}>Public</MenuItem>
</Box> <MenuItem value={"CONFIDENTIAL"}>Show time only</MenuItem>
<MenuItem value={"PRIVATE"}>Private</MenuItem>
</Select>
</FormControl>
</>
)}
</CardContent>
<CardActions>
<Box mt={2} display="flex" justifyContent="flex-end" gap={1}>
<Button
variant="outlined"
onClick={() => onClose({}, "backdropClick")}
>
Cancel
</Button>
<Button size="small" onClick={() => setShowMore(!showMore)}>
{showMore ? "Show Less" : "Show More"}
</Button>
<Button variant="contained" onClick={handleSave} disabled={!title}>
Save
</Button>
</Box>
</CardActions>
</Card>
</Popover> </Popover>
); );
} }
export default EventPopover; export default EventPopover;
function formatLocalDateTime(date: Date): string { export function formatLocalDateTime(date: Date): string {
const pad = (n: number) => n.toString().padStart(2, "0"); const pad = (n: number) => n.toString().padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad( return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
date.getDate() date.getDate()
+11 -3
View File
@@ -8,7 +8,7 @@ export interface CalendarEvent {
start: Date; // ISO date start: Date; // ISO date
end?: Date; end?: Date;
class?: string; class?: string;
x_openpass_videoconference?: unknown; x_openpass_videoconference?: string;
title?: string; title?: string;
description?: string; description?: string;
location?: string; location?: string;
@@ -17,9 +17,17 @@ export interface CalendarEvent {
stamp?: Date; stamp?: Date;
sequence?: Number; sequence?: Number;
color?: string; color?: string;
allday?: Boolean; allday?: boolean;
error?: string; error?: string;
status?: string; status?: string;
timezone: string; timezone: string;
repetition?: string; repetition?: RepetitionObject;
}
export interface RepetitionObject {
freq: string;
interval?: number;
selectedDays?: string[];
occurrences?: number;
endDate?: string;
} }
+46 -3
View File
@@ -13,6 +13,7 @@ export function parseCalendarEvent(
): CalendarEvent { ): CalendarEvent {
const event: Partial<CalendarEvent> = { color, attendee: [] }; const event: Partial<CalendarEvent> = { color, attendee: [] };
let recurrenceId; let recurrenceId;
const dateRegex = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
for (const [key, params, type, value] of data) { for (const [key, params, type, value] of data) {
switch (key.toLowerCase()) { switch (key.toLowerCase()) {
@@ -24,9 +25,19 @@ export function parseCalendarEvent(
break; break;
case "dtstart": case "dtstart":
event.start = value; event.start = value;
if (dateRegex.test(value)) {
event.allday = true;
} else {
event.allday = false;
}
break; break;
case "dtend": case "dtend":
event.end = value; event.end = value;
if (dateRegex.test(value)) {
event.allday = true;
} else {
event.allday = false;
}
break; break;
case "class": case "class":
event.class = value; event.class = value;
@@ -70,6 +81,22 @@ export function parseCalendarEvent(
break; break;
case "status": case "status":
event.status = String(value); event.status = String(value);
break;
case "rrule":
event.repetition = { freq: value.freq.toLowerCase() };
if (value.byday) {
event.repetition.selectedDays = value.byday;
}
if (value.until) {
event.repetition.selectedDays = value.endDate;
}
if (value.count) {
event.repetition.selectedDays = value.occurrences;
}
if (value.interval) {
event.repetition.interval = value.interval;
}
break;
} }
} }
if (recurrenceId && event.uid) { if (recurrenceId && event.uid) {
@@ -94,7 +121,7 @@ export function calendarEventToJCal(event: CalendarEvent): any[] {
const vevent: any[] = [ const vevent: any[] = [
"vevent", "vevent",
[ [
["uid", {}, "text", event.uid], ["uid", {}, "text", event.uid.split("/")[0]],
["transp", {}, "text", event.transp ?? "OPAQUE"], ["transp", {}, "text", event.transp ?? "OPAQUE"],
[ [
"dtstart", "dtstart",
@@ -115,6 +142,9 @@ export function calendarEventToJCal(event: CalendarEvent): any[] {
]; ];
if (event.end) { if (event.end) {
if (event.allday && event.end.getTime() === event.start.getTime()) {
event.end.setDate(event.start.getDate() + 1);
}
vevent[1].push([ vevent[1].push([
"dtend", "dtend",
{ tzid }, { tzid },
@@ -136,8 +166,21 @@ export function calendarEventToJCal(event: CalendarEvent): any[] {
if (event.description) { if (event.description) {
vevent[1].push(["description", {}, "text", event.description]); vevent[1].push(["description", {}, "text", event.description]);
} }
if (event.repetition) { if (event.repetition?.freq) {
vevent[1].push(["rrule", {}, "recur", { freq: event.repetition }]); const repetitionRule: Record<string, any> = { freq: event.repetition.freq };
if (event.repetition.interval) {
repetitionRule["interval"] = event.repetition.interval;
}
if (event.repetition.occurrences) {
repetitionRule["count"] = event.repetition.occurrences;
}
if (event.repetition.endDate) {
repetitionRule["until"] = event.repetition.endDate;
}
if (event.repetition.selectedDays) {
repetitionRule["byday"] = event.repetition.selectedDays;
}
vevent[1].push(["rrule", {}, "recur", repetitionRule]);
} }
event.attendee.forEach((att) => { event.attendee.forEach((att) => {
+6 -8
View File
@@ -16,14 +16,12 @@ export async function searchUsers(query: string) {
}) })
.json(); .json();
return response return response.map((user) => ({
.filter((user) => user.objectType === "user") email: user.emailAddresses?.[0]?.value || "",
.map((user) => ({ displayName:
email: user.emailAddresses?.[0]?.value || "", user.names?.[0]?.displayName || user.emailAddresses?.[0]?.value,
displayName: avatarUrl: user.photos?.[0]?.url || "",
user.names?.[0]?.displayName || user.emailAddresses?.[0]?.value, }));
avatarUrl: user.photos?.[0]?.url || "",
}));
} }
export async function getUserDetails(id: string) { export async function getUserDetails(id: string) {
+11
View File
@@ -43,3 +43,14 @@ export function redirectTo(url: URL) {
export function getLocation() { export function getLocation() {
return window.location.href; return window.location.href;
} }
export function isValidUrl(string?: string) {
let url;
try {
url = new URL(string ?? "");
} catch (_) {
return false;
}
return url;
}