Merge pull request #75 from linagora/7-story-complex-events
complex display
This commit is contained in:
@@ -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();
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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."],
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" }}
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user