Revamp calendar dialog (#158)
Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
@@ -122,7 +122,7 @@ describe("CalendarSelection", () => {
|
||||
fireEvent.click(addButtons[1]);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("Calendar configuration")).toBeInTheDocument()
|
||||
expect(screen.getByText(/Add new calendar/i)).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import CalendarPopover from "../../../src/components/Calendar/CalendarModal";
|
||||
import { renderWithProviders } from "../../utils/Renderwithproviders";
|
||||
import * as eventThunks from "../../../src/features/Calendars/CalendarSlice";
|
||||
import { Calendars } from "../../../src/features/Calendars/CalendarTypes";
|
||||
|
||||
describe("CalendarPopover", () => {
|
||||
const mockOnClose = jest.fn();
|
||||
@@ -32,8 +33,7 @@ describe("CalendarPopover", () => {
|
||||
renderPopover();
|
||||
|
||||
expect(screen.getByLabelText(/Name/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Description/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("Calendar configuration")).toBeInTheDocument();
|
||||
expect(screen.getByText(/add description/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updates name and description fields", () => {
|
||||
@@ -43,26 +43,12 @@ describe("CalendarPopover", () => {
|
||||
fireEvent.change(nameInput, { target: { value: "My Calendar" } });
|
||||
expect(nameInput).toHaveValue("My Calendar");
|
||||
|
||||
fireEvent.click(screen.getByText(/add description/i));
|
||||
const descInput = screen.getByLabelText(/Description/i);
|
||||
fireEvent.change(descInput, { target: { value: "Test description" } });
|
||||
expect(descInput).toHaveValue("Test description");
|
||||
});
|
||||
|
||||
it("selects a color when a color button is clicked", () => {
|
||||
renderPopover();
|
||||
|
||||
// There are multiple color buttons; pick the first
|
||||
const colorButtons = screen.getAllByRole("button", {
|
||||
name: /select color/i,
|
||||
});
|
||||
fireEvent.click(colorButtons[0]);
|
||||
|
||||
// The header background should update (check via inline style)
|
||||
expect(
|
||||
screen.getByText("Calendar configuration").style.backgroundColor
|
||||
).toBe("rgb(52, 211, 153)");
|
||||
});
|
||||
|
||||
it("dispatches createCalendar and calls onClose when Save clicked", () => {
|
||||
const spy = jest
|
||||
.spyOn(eventThunks, "createCalendarAsync")
|
||||
@@ -74,6 +60,7 @@ describe("CalendarPopover", () => {
|
||||
fireEvent.change(screen.getByLabelText(/Name/i), {
|
||||
target: { value: "Test Calendar" },
|
||||
});
|
||||
fireEvent.click(screen.getByText(/add description/i));
|
||||
fireEvent.change(screen.getByLabelText(/Description/i), {
|
||||
target: { value: "Test Description" },
|
||||
});
|
||||
@@ -83,7 +70,7 @@ describe("CalendarPopover", () => {
|
||||
});
|
||||
fireEvent.click(colorButtons[0]);
|
||||
|
||||
fireEvent.click(screen.getByText(/Save/i));
|
||||
fireEvent.click(screen.getByText(/Create/));
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
|
||||
@@ -111,7 +98,7 @@ describe("CalendarPopover (editing mode)", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const existingCalendar = {
|
||||
const existingCalendar: Calendars = {
|
||||
id: "user1/cal1",
|
||||
link: "/calendars/user/cal1",
|
||||
name: "Work Calendar",
|
||||
@@ -119,6 +106,7 @@ describe("CalendarPopover (editing mode)", () => {
|
||||
color: "#33B679",
|
||||
owner: "alice",
|
||||
ownerEmails: ["alice@example.com"],
|
||||
visibility: "public",
|
||||
events: {},
|
||||
};
|
||||
|
||||
@@ -138,9 +126,6 @@ describe("CalendarPopover (editing mode)", () => {
|
||||
|
||||
expect(screen.getByLabelText(/Name/i)).toHaveValue("Work Calendar");
|
||||
expect(screen.getByLabelText(/Description/i)).toHaveValue("Team meetings");
|
||||
expect(screen.getByText("Calendar configuration")).toHaveStyle({
|
||||
backgroundColor: "#33B679",
|
||||
});
|
||||
});
|
||||
|
||||
test("Save button is disabled when name is empty or whitespace only", () => {
|
||||
@@ -148,7 +133,7 @@ describe("CalendarPopover (editing mode)", () => {
|
||||
user: baseUser,
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||
const saveButton = screen.getByRole("button", { name: /create/i });
|
||||
expect(saveButton).toBeDisabled();
|
||||
// only spaces
|
||||
const nameInput = screen.getByLabelText(/name/i);
|
||||
@@ -201,3 +186,246 @@ describe("CalendarPopover (editing mode)", () => {
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CalendarPopover - Tabs Scenarios", () => {
|
||||
const mockOnClose = jest.fn();
|
||||
const baseUser = {
|
||||
userData: {
|
||||
openpaasId: "user1",
|
||||
},
|
||||
};
|
||||
|
||||
const writeText = jest.fn();
|
||||
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText,
|
||||
},
|
||||
});
|
||||
|
||||
const existingCalendar: Calendars = {
|
||||
id: "user1/cal1",
|
||||
link: "/calendars/user1/cal1.json",
|
||||
name: "Work Calendar",
|
||||
description: "Team meetings",
|
||||
color: "#33B679",
|
||||
owner: "alice",
|
||||
ownerEmails: ["alice@example.com"],
|
||||
visibility: "public",
|
||||
events: {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("resets state after closing and reopening", () => {
|
||||
const { rerender } = renderWithProviders(
|
||||
<CalendarPopover open={true} onClose={mockOnClose} />,
|
||||
{ user: baseUser }
|
||||
);
|
||||
|
||||
// Enter some data
|
||||
fireEvent.change(screen.getByLabelText(/Name/i), {
|
||||
target: { value: "Temp Calendar" },
|
||||
});
|
||||
fireEvent.click(screen.getByText(/Cancel/i));
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
|
||||
// Reopen: state should be reset
|
||||
rerender(<CalendarPopover open={true} onClose={mockOnClose} />);
|
||||
expect(screen.getByLabelText(/Name/i)).toHaveValue("");
|
||||
});
|
||||
|
||||
it("shows Access tab only when editing an existing calendar", () => {
|
||||
renderWithProviders(
|
||||
<CalendarPopover
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calendar={existingCalendar}
|
||||
/>,
|
||||
{ user: baseUser }
|
||||
);
|
||||
|
||||
expect(screen.getByRole("tab", { name: /Access/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show Access tab when creating new calendar", () => {
|
||||
renderWithProviders(<CalendarPopover open={true} onClose={mockOnClose} />, {
|
||||
user: baseUser,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByRole("tab", { name: /Access/i })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("patches ACL when visibility changes", async () => {
|
||||
const patchSpy = jest
|
||||
.spyOn(eventThunks, "patchACLCalendarAsync")
|
||||
.mockImplementation((payload) => {
|
||||
return () => Promise.resolve(payload) as any;
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<CalendarPopover
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calendar={existingCalendar}
|
||||
/>,
|
||||
{ user: baseUser }
|
||||
);
|
||||
|
||||
// By default: "All" (public) is selected
|
||||
const publicButton = screen.getByRole("button", { name: /All/i });
|
||||
const privateButton = screen.getByRole("button", { name: /You/i });
|
||||
|
||||
expect(publicButton).toHaveAttribute("aria-pressed", "true");
|
||||
expect(privateButton).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
// Change to private
|
||||
fireEvent.click(privateButton);
|
||||
|
||||
expect(privateButton).toHaveAttribute("aria-pressed", "true");
|
||||
expect(publicButton).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
// Save
|
||||
fireEvent.click(screen.getByText(/Save/i));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(patchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
calId: "user1/cal1",
|
||||
request: "",
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("copies CalDAV link from Access tab", async () => {
|
||||
(window as any).CALENDAR_BASE_URL = "https://cal.example.org";
|
||||
Object.assign(navigator, {
|
||||
clipboard: { writeText: jest.fn() },
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<CalendarPopover
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calendar={existingCalendar}
|
||||
/>,
|
||||
{ user: baseUser }
|
||||
);
|
||||
|
||||
// Switch to Access tab
|
||||
fireEvent.click(screen.getByRole("tab", { name: /Access/i }));
|
||||
|
||||
// Expect text field with caldav link
|
||||
const input = screen.getByLabelText(/CalDAV access/i);
|
||||
expect(input).toHaveValue("https://cal.example.org/calendars/user1/cal1");
|
||||
|
||||
// Click copy button
|
||||
const copyButton = screen.getAllByRole("button")[0];
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
"https://cal.example.org/calendars/user1/cal1"
|
||||
);
|
||||
|
||||
// Snackbar should appear
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/Link copied!/i)).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
describe("Import flow", () => {
|
||||
const file = new File(["test"], "events.ics", { type: "text/calendar" });
|
||||
|
||||
it("creates a new calendar and imports events when Import with 'new' target", async () => {
|
||||
const createSpy = jest
|
||||
.spyOn(eventThunks, "createCalendarAsync")
|
||||
.mockImplementation((payload) => {
|
||||
return () => Promise.resolve(payload) as any;
|
||||
});
|
||||
const importSpy = jest
|
||||
.spyOn(eventThunks, "importEventFromFileAsync")
|
||||
.mockImplementation((payload) => {
|
||||
return () => Promise.resolve(payload) as any;
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<CalendarPopover open={true} onClose={mockOnClose} />,
|
||||
{
|
||||
user: baseUser,
|
||||
}
|
||||
);
|
||||
|
||||
// Switch to Import tab
|
||||
fireEvent.click(screen.getByRole("tab", { name: /Import/i }));
|
||||
|
||||
// Provide new calendar params
|
||||
fireEvent.change(screen.getByLabelText(/Name/i), {
|
||||
target: { value: "Imported Calendar" },
|
||||
});
|
||||
const fileInput = screen.getByLabelText(/select file/i);
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
|
||||
// Click Import
|
||||
fireEvent.click(screen.getByRole("button", { name: "Import" }));
|
||||
|
||||
await waitFor(() => expect(createSpy).toHaveBeenCalled());
|
||||
await waitFor(() => expect(importSpy).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("imports into an existing calendar when target is set", async () => {
|
||||
const importSpy = jest
|
||||
.spyOn(eventThunks, "importEventFromFileAsync")
|
||||
.mockImplementation((payload) => {
|
||||
return () => Promise.resolve(payload) as any;
|
||||
});
|
||||
|
||||
const calendars = {
|
||||
"user1/cal1": existingCalendar,
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<CalendarPopover
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calendar={existingCalendar}
|
||||
/>,
|
||||
{ user: baseUser, calendars: { list: calendars } }
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /Import/i }));
|
||||
const fileInput = screen.getByLabelText(/select file/i);
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Import" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(importSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
calLink: "/calendars/user1/cal1.json",
|
||||
file,
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("disables Import button until a file is uploaded", () => {
|
||||
renderWithProviders(
|
||||
<CalendarPopover open={true} onClose={mockOnClose} />,
|
||||
{
|
||||
user: baseUser,
|
||||
}
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /Import/i }));
|
||||
|
||||
const importButton = screen.getByRole("button", { name: /Import/i });
|
||||
expect(importButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import reducer, {
|
||||
addEvent,
|
||||
removeEvent,
|
||||
createCalendar,
|
||||
updateEventLocal,
|
||||
removeTempCal,
|
||||
getCalendarsListAsync,
|
||||
getTempCalendarsListAsync,
|
||||
getCalendarDetailAsync,
|
||||
putEventAsync,
|
||||
getEventAsync,
|
||||
patchCalendarAsync,
|
||||
removeCalendarAsync,
|
||||
moveEventAsync,
|
||||
patchACLCalendarAsync,
|
||||
createCalendarAsync,
|
||||
addSharedCalendarAsync,
|
||||
deleteEventAsync,
|
||||
} from "../../../src/features/Calendars/CalendarSlice";
|
||||
|
||||
import * as calAPI from "../../../src/features/Calendars/CalendarApi";
|
||||
import * as userAPI from "../../../src/features/User/userAPI";
|
||||
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import { Calendars } from "../../../src/features/Calendars/CalendarTypes";
|
||||
import { CalendarEvent } from "../../../src/features/Events/EventsTypes";
|
||||
|
||||
jest.mock("../../../src/features/Calendars/CalendarApi");
|
||||
jest.mock("../../../src/features/User/userAPI");
|
||||
jest.mock("../../../src/features/Events/EventApi");
|
||||
jest.mock("../../../src/features/Events/eventUtils");
|
||||
jest.mock("../../../src/utils/apiUtils");
|
||||
|
||||
describe("CalendarSlice", () => {
|
||||
const initialState = {
|
||||
list: {},
|
||||
templist: {},
|
||||
pending: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("reducers", () => {
|
||||
it("createCalendar adds new calendar", () => {
|
||||
const action = createCalendar({
|
||||
name: "Test Cal",
|
||||
color: "#ff0000",
|
||||
description: "desc",
|
||||
});
|
||||
const state = reducer(initialState, action);
|
||||
const values = Object.values(state.list);
|
||||
expect(values[0].name).toBe("Test Cal");
|
||||
expect(values[0].color).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("addEvent adds event to calendar", () => {
|
||||
const calId = "user/cal";
|
||||
const event = { uid: "event1", title: "My Event" } as any;
|
||||
const stateWithCal = {
|
||||
...initialState,
|
||||
list: { [calId]: { id: calId, events: {} } as any },
|
||||
};
|
||||
const state = reducer(
|
||||
stateWithCal,
|
||||
addEvent({ calendarUid: calId, event })
|
||||
);
|
||||
expect(state.list[calId].events["event1"]).toEqual(
|
||||
expect.objectContaining({ uid: "event1" })
|
||||
);
|
||||
});
|
||||
|
||||
it("removeEvent deletes event", () => {
|
||||
const calId = "user/cal";
|
||||
const stateWithEvent = {
|
||||
...initialState,
|
||||
list: {
|
||||
[calId]: {
|
||||
id: calId,
|
||||
events: { e1: { uid: "e1" } },
|
||||
} as unknown as Calendars,
|
||||
},
|
||||
};
|
||||
const state = reducer(
|
||||
stateWithEvent,
|
||||
removeEvent({ calendarUid: calId, eventUid: "e1" })
|
||||
);
|
||||
expect(state.list[calId].events).toEqual({});
|
||||
});
|
||||
|
||||
it("updateEventLocal updates an event", () => {
|
||||
const calId = "user/cal";
|
||||
const stateWithEvent = {
|
||||
...initialState,
|
||||
list: {
|
||||
[calId]: {
|
||||
id: calId,
|
||||
events: { e1: { uid: "e1", title: "Old" } },
|
||||
} as unknown as Calendars,
|
||||
},
|
||||
};
|
||||
const state = reducer(
|
||||
stateWithEvent,
|
||||
updateEventLocal({ calId, event: { uid: "e1", title: "New" } as any })
|
||||
);
|
||||
expect(state.list[calId].events.e1.title).toBe("New");
|
||||
});
|
||||
|
||||
it("removeTempCal deletes temp calendar", () => {
|
||||
const stateWithTemp = {
|
||||
...initialState,
|
||||
templist: { temp1: { id: "temp1" } as any },
|
||||
};
|
||||
const state = reducer(stateWithTemp, removeTempCal("temp1"));
|
||||
expect(state.templist).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("extraReducers (thunks)", () => {
|
||||
const storeFactory = () =>
|
||||
configureStore({
|
||||
reducer: { calendars: reducer },
|
||||
});
|
||||
|
||||
it("getCalendarsListAsync.fulfilled replaces list", async () => {
|
||||
(userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: "u1" });
|
||||
(calAPI.getCalendars as jest.Mock).mockResolvedValue({
|
||||
_embedded: { "dav:calendar": [] },
|
||||
});
|
||||
(userAPI.getUserDetails as jest.Mock).mockResolvedValue({
|
||||
firstname: "Alice",
|
||||
lastname: "Smith",
|
||||
emails: ["a@b.com"],
|
||||
});
|
||||
|
||||
const store = storeFactory();
|
||||
await store.dispatch(getCalendarsListAsync() as any);
|
||||
const state = store.getState().calendars;
|
||||
expect(state.list).toEqual({});
|
||||
});
|
||||
|
||||
it("patchCalendarAsync.fulfilled updates calendar fields", () => {
|
||||
const calId = "c1";
|
||||
const prev = {
|
||||
...initialState,
|
||||
list: { c1: { id: calId, events: { e1: { uid: "e1" } } } as any },
|
||||
};
|
||||
const patch = { name: "N", desc: "D", color: "#00f" };
|
||||
const state = reducer(
|
||||
prev,
|
||||
patchCalendarAsync.fulfilled(
|
||||
{ calId, calLink: "link", patch },
|
||||
"req1",
|
||||
{
|
||||
calId,
|
||||
calLink: "link",
|
||||
patch,
|
||||
}
|
||||
)
|
||||
);
|
||||
expect(state.list[calId].name).toBe("N");
|
||||
expect(state.list[calId].description).toBe("D");
|
||||
expect(state.list[calId].color).toBe("#00f");
|
||||
});
|
||||
|
||||
it("removeCalendarAsync.fulfilled deletes calendar", () => {
|
||||
const prev = {
|
||||
...initialState,
|
||||
list: { c1: { id: "c1" } as any },
|
||||
};
|
||||
const state = reducer(
|
||||
prev,
|
||||
removeCalendarAsync.fulfilled({ calId: "c1" }, "req2", {
|
||||
calId: "c1",
|
||||
calLink: "l",
|
||||
})
|
||||
);
|
||||
expect(state.list).toEqual({});
|
||||
});
|
||||
|
||||
it("patchACLCalendarAsync.fulfilled sets visibility", () => {
|
||||
const prev = {
|
||||
...initialState,
|
||||
list: { c1: { id: "c1", visibility: "public" } as any },
|
||||
};
|
||||
const state = reducer(
|
||||
prev,
|
||||
patchACLCalendarAsync.fulfilled(
|
||||
{ calId: "c1", calLink: "l", request: "" },
|
||||
"req3",
|
||||
{ calId: "c1", calLink: "l", request: "" }
|
||||
)
|
||||
);
|
||||
expect(state.list.c1.visibility).toBe("private");
|
||||
});
|
||||
|
||||
it("createCalendarAsync.fulfilled adds a new calendar", () => {
|
||||
const payload = {
|
||||
userId: "u1",
|
||||
calId: "cal1",
|
||||
color: "#f00",
|
||||
name: "Test",
|
||||
desc: "Desc",
|
||||
owner: "Owner",
|
||||
ownerEmails: ["o@example.com"],
|
||||
};
|
||||
const state = reducer(
|
||||
initialState,
|
||||
createCalendarAsync.fulfilled(payload, "req4", payload)
|
||||
);
|
||||
expect(state.list["u1/cal1"].name).toBe("Test");
|
||||
expect(state.list["u1/cal1"].color).toBe("#f00");
|
||||
});
|
||||
|
||||
it("addSharedCalendarAsync.fulfilled adds shared calendar", () => {
|
||||
const payload = {
|
||||
calId: "c1",
|
||||
color: "#0f0",
|
||||
link: "/calendars/u1/c1.json",
|
||||
name: "Shared",
|
||||
desc: "Shared Desc",
|
||||
owner: "O",
|
||||
ownerEmails: ["o@example.com"],
|
||||
};
|
||||
const mockCal = {
|
||||
cal: {
|
||||
_links: { self: { href: "/calendars/u1/c1.json" } },
|
||||
"apple:color": "#0f0",
|
||||
"caldav:description": "Shared Desc",
|
||||
"dav:name": "Shared",
|
||||
},
|
||||
};
|
||||
const state = reducer(
|
||||
initialState,
|
||||
addSharedCalendarAsync.fulfilled(payload, "req5", {
|
||||
userId: "u1",
|
||||
calId: "c1",
|
||||
cal: mockCal,
|
||||
})
|
||||
);
|
||||
expect(state.list["c1"].name).toBe("Shared");
|
||||
});
|
||||
|
||||
it("deleteEventAsync.fulfilled removes event", () => {
|
||||
const calId = "c1";
|
||||
const prev = {
|
||||
...initialState,
|
||||
list: {
|
||||
[calId]: {
|
||||
id: calId,
|
||||
events: { e1: { uid: "e1" } },
|
||||
} as unknown as Calendars,
|
||||
},
|
||||
};
|
||||
const state = reducer(
|
||||
prev,
|
||||
deleteEventAsync.fulfilled({ calId, eventId: "e1" }, "req6", {
|
||||
calId,
|
||||
eventId: "e1",
|
||||
eventURL: "e1.ics",
|
||||
})
|
||||
);
|
||||
expect(state.list[calId].events).toEqual({});
|
||||
});
|
||||
|
||||
it("getTempCalendarsListAsync.fulfilled updates templist", () => {
|
||||
const payload = {
|
||||
t1: {
|
||||
id: "t1",
|
||||
name: "Temp",
|
||||
color: "#aaa",
|
||||
events: {},
|
||||
visibility: "public",
|
||||
owner: "O",
|
||||
ownerEmails: ["o@o.com"],
|
||||
link: "/calendars/t1.json",
|
||||
description: "desc",
|
||||
} as Calendars,
|
||||
};
|
||||
const state = reducer(
|
||||
initialState,
|
||||
getTempCalendarsListAsync.fulfilled(payload, "req7", {
|
||||
openpaasId: "u1",
|
||||
color: "#aaa",
|
||||
displayName: "test",
|
||||
avatarUrl: "",
|
||||
email: "test@test.com",
|
||||
})
|
||||
);
|
||||
expect(state.templist.t1.name).toBe("Temp");
|
||||
});
|
||||
|
||||
it("putEventAsync.fulfilled updates calendar events", () => {
|
||||
const cal = { id: "c1", events: {} } as any;
|
||||
const payload = { calId: "c1", events: [{ uid: "e1" }] as any[] };
|
||||
const state = reducer(
|
||||
initialState,
|
||||
putEventAsync.fulfilled(payload, "req8", {
|
||||
cal,
|
||||
newEvent: { uid: "e1" } as CalendarEvent,
|
||||
})
|
||||
);
|
||||
expect(state.list.c1.events.e1.uid).toBe("e1");
|
||||
});
|
||||
|
||||
it("getEventAsync.fulfilled adds single event", () => {
|
||||
const payload = { calId: "c1", event: { uid: "e1" } as any };
|
||||
const state = reducer(
|
||||
initialState,
|
||||
getEventAsync.fulfilled(payload, "req9", { uid: "e1" } as any)
|
||||
);
|
||||
expect(state.list.c1.events.e1.uid).toBe("e1");
|
||||
});
|
||||
|
||||
it("moveEventAsync.fulfilled updates events after move", () => {
|
||||
const cal = { id: "c1", events: {} } as any;
|
||||
const payload = { calId: "c1", events: [{ uid: "e1" }] as any[] };
|
||||
const state = reducer(
|
||||
initialState,
|
||||
moveEventAsync.fulfilled(payload, "req10", {
|
||||
cal,
|
||||
newEvent: { uid: "e1" } as CalendarEvent,
|
||||
newURL: "e1.ics",
|
||||
})
|
||||
);
|
||||
expect(state.list.c1.events.e1.uid).toBe("e1");
|
||||
});
|
||||
|
||||
it("getCalendarDetailAsync.fulfilled adds calendar events", () => {
|
||||
const payload = { calId: "c1", events: [{ uid: "e1" }] as any[] };
|
||||
const state = reducer(
|
||||
initialState,
|
||||
getCalendarDetailAsync.fulfilled(payload, "req11", {
|
||||
calId: "c1",
|
||||
match: { start: "", end: "" },
|
||||
})
|
||||
);
|
||||
expect(state.list.c1.events.e1.uid).toBe("e1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,9 @@ import {
|
||||
putEvent,
|
||||
moveEvent,
|
||||
deleteEvent,
|
||||
importEventFromFile,
|
||||
} from "../../../src/features/Events/EventApi";
|
||||
import { CalendarEvent } from "../../../src/features/Events/EventsTypes";
|
||||
import { calendarEventToJCal } from "../../../src/features/Events/eventUtils";
|
||||
import { clientConfig } from "../../../src/features/User/oidcAuth";
|
||||
import { api } from "../../../src/utils/apiUtils";
|
||||
@@ -18,8 +20,8 @@ const mockEvent = {
|
||||
timezone: "UTC",
|
||||
calId: "667037022b752d0026472254/cal1",
|
||||
URL: "/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics",
|
||||
start: day,
|
||||
end: day,
|
||||
start: day.toISOString(),
|
||||
end: day.toISOString(),
|
||||
status: "PUBLIC",
|
||||
organizer: { cn: "test", cal_address: "test@test.com" },
|
||||
attendee: [
|
||||
@@ -40,14 +42,14 @@ const mockEvent = {
|
||||
cutype: "INDIVIDUAL",
|
||||
},
|
||||
],
|
||||
};
|
||||
} as CalendarEvent;
|
||||
|
||||
describe("eventApi", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test("putEvent sends PUT request with JCal body", async () => {
|
||||
it("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);
|
||||
@@ -63,7 +65,7 @@ describe("eventApi", () => {
|
||||
expect(result).toBe(mockResponse);
|
||||
});
|
||||
|
||||
test("putEvent logs when status is 201", async () => {
|
||||
it("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(() => {});
|
||||
@@ -74,7 +76,7 @@ describe("eventApi", () => {
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("moveEvent sends MOVE request with destination header", async () => {
|
||||
it("moveEvent sends MOVE request with destination header", async () => {
|
||||
const mockResponse = { status: 204 };
|
||||
(api as unknown as jest.Mock).mockReturnValue({
|
||||
json: jest.fn().mockResolvedValue(mockResponse),
|
||||
@@ -92,7 +94,7 @@ describe("eventApi", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("deleteEvent sends DELETE request and returns json response", async () => {
|
||||
it("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),
|
||||
@@ -105,6 +107,22 @@ describe("eventApi", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("import event file", async () => {
|
||||
const mockResponse = { status: 202 };
|
||||
(api.post as unknown as jest.Mock).mockReturnValue({
|
||||
json: jest.fn().mockResolvedValue(mockResponse),
|
||||
});
|
||||
|
||||
await importEventFromFile("123456789", "/calendar/calLink.json");
|
||||
|
||||
expect(api.post).toHaveBeenCalledWith("api/import", {
|
||||
body: JSON.stringify({
|
||||
fileId: "123456789",
|
||||
target: "/calendar/calLink.json",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("putEvent handles byday field correctly", async () => {
|
||||
const mockResponse = { status: 201, url: "/dav/cals/test.ics" };
|
||||
(api as unknown as jest.Mock).mockReturnValue(mockResponse);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { getCalendarVisibility } from "../../src/components/Calendar/utils/calendarUtils";
|
||||
|
||||
interface AclEntry {
|
||||
privilege: string;
|
||||
principal: string;
|
||||
protected: boolean;
|
||||
}
|
||||
|
||||
describe("getCalendarVisibility", () => {
|
||||
it("returns 'public' when {DAV:}authenticated has {DAV:}read", () => {
|
||||
const acl: AclEntry[] = [
|
||||
{
|
||||
privilege: "{DAV:}read",
|
||||
principal: "{DAV:}authenticated",
|
||||
protected: true,
|
||||
},
|
||||
];
|
||||
expect(getCalendarVisibility(acl)).toBe("public");
|
||||
});
|
||||
|
||||
it("returns 'private' when {DAV:}authenticated only has read-free-busy", () => {
|
||||
const acl: AclEntry[] = [
|
||||
{
|
||||
privilege: "{urn:ietf:params:xml:ns:caldav}read-free-busy",
|
||||
principal: "{DAV:}authenticated",
|
||||
protected: true,
|
||||
},
|
||||
];
|
||||
expect(getCalendarVisibility(acl)).toBe("private");
|
||||
});
|
||||
|
||||
it("returns 'private' when {DAV:}authenticated is not present", () => {
|
||||
const acl: AclEntry[] = [
|
||||
{
|
||||
privilege: "{DAV:}read",
|
||||
principal: "principals/users/123",
|
||||
protected: true,
|
||||
},
|
||||
];
|
||||
expect(getCalendarVisibility(acl)).toBe("private");
|
||||
});
|
||||
|
||||
it("ignores non-authenticated principals and still returns 'private'", () => {
|
||||
const acl: AclEntry[] = [
|
||||
{
|
||||
privilege: "{DAV:}read",
|
||||
principal: "principals/users/other",
|
||||
protected: true,
|
||||
},
|
||||
{
|
||||
privilege: "{DAV:}write",
|
||||
principal: "principals/users/other",
|
||||
protected: true,
|
||||
},
|
||||
];
|
||||
expect(getCalendarVisibility(acl)).toBe("private");
|
||||
});
|
||||
|
||||
it("stops early when it finds a {DAV:}read for {DAV:}authenticated", () => {
|
||||
const acl: AclEntry[] = [
|
||||
{
|
||||
privilege: "{DAV:}read",
|
||||
principal: "{DAV:}authenticated",
|
||||
protected: true,
|
||||
},
|
||||
{
|
||||
privilege: "{DAV:}write",
|
||||
principal: "{DAV:}authenticated",
|
||||
protected: true,
|
||||
},
|
||||
];
|
||||
expect(getCalendarVisibility(acl)).toBe("public");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||
import { Box, IconButton, TextField } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import { Calendars } from "../../features/Calendars/CalendarTypes";
|
||||
import { SnackbarAlert } from "../Loading/SnackBarAlert";
|
||||
|
||||
export function AccessTab({ calendar }: { calendar: Calendars }) {
|
||||
const calDAVLink = `${(window as any).CALENDAR_BASE_URL}${calendar.link.replace(".json", "")}`;
|
||||
const [open, setOpen] = useState(false);
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(calDAVLink);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box mt={2}>
|
||||
<TextField
|
||||
disabled
|
||||
fullWidth
|
||||
label="CalDAV access"
|
||||
value={calDAVLink}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<IconButton onClick={handleCopy} edge="end">
|
||||
<ContentCopyIcon />
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<SnackbarAlert setOpen={setOpen} open={open} message="Link copied!" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,17 @@
|
||||
import { Button, DialogActions, Tab, Tabs } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
createCalendarAsync /*, updateCalendarAsync */,
|
||||
patchCalendarAsync,
|
||||
} from "../../features/Calendars/CalendarSlice";
|
||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||
import {
|
||||
TextField,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
import { ColorPicker } from "./CalendarColorPicker";
|
||||
createCalendarAsync,
|
||||
importEventFromFileAsync,
|
||||
patchACLCalendarAsync,
|
||||
patchCalendarAsync,
|
||||
} from "../../features/Calendars/CalendarSlice";
|
||||
import { Calendars } from "../../features/Calendars/CalendarTypes";
|
||||
import { ResponsiveDialog } from "../Dialog";
|
||||
import { AccessTab } from "./AccessTab";
|
||||
import { ImportTab } from "./ImportTab";
|
||||
import { SettingsTab } from "./SettingsTab";
|
||||
|
||||
function CalendarPopover({
|
||||
open,
|
||||
@@ -30,101 +28,215 @@ function CalendarPopover({
|
||||
const dispatch = useAppDispatch();
|
||||
const userId =
|
||||
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
|
||||
const [name, setName] = useState(calendar?.name ?? "");
|
||||
const [description, setDescription] = useState(calendar?.description ?? "");
|
||||
const [color, setColor] = useState(calendar?.color ?? "");
|
||||
const calendars = useAppSelector((state) => state.calendars.list);
|
||||
const isOwn = calendar ? calendar?.id.split("/")[0] === userId : true;
|
||||
|
||||
// existing calendar params
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [color, setColor] = useState("");
|
||||
const [visibility, setVisibility] = useState<"private" | "public">("public");
|
||||
|
||||
// import tab state
|
||||
const [tab, setTab] = useState<"settings" | "access" | "import">("settings");
|
||||
const [importedContent, setImportedContent] = useState<File | null>(null);
|
||||
const [importTarget, setImportTarget] = useState("new");
|
||||
|
||||
// new calendar params (for import new)
|
||||
const [newCalName, setNewCalName] = useState("");
|
||||
const [newCalDescription, setNewCalDescription] = useState("");
|
||||
const [newCalColor, setNewCalColor] = useState("");
|
||||
const [newCalVisibility, setNewCalVisibility] = useState<
|
||||
"public" | "private"
|
||||
>("public");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (calendar) {
|
||||
setName(calendar.name);
|
||||
setDescription(calendar.description ?? "");
|
||||
setColor(calendar.color ?? "");
|
||||
} else {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setColor("");
|
||||
}
|
||||
if (!open) return;
|
||||
if (calendar) {
|
||||
setName(calendar.name);
|
||||
setDescription(calendar.description ?? "");
|
||||
setColor(calendar.color ?? "");
|
||||
setVisibility(calendar.visibility ?? "public");
|
||||
setImportTarget(calendar.id ?? "new");
|
||||
} else {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setColor("");
|
||||
setVisibility("public");
|
||||
setImportTarget("new");
|
||||
}
|
||||
}, [calendar, open]);
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmedName = name.trim();
|
||||
const trimmedDesc = description.trim();
|
||||
|
||||
if (trimmedName) {
|
||||
const calId = calendar ? calendar.id : crypto.randomUUID();
|
||||
|
||||
if (calendar?.id) {
|
||||
dispatch(
|
||||
patchCalendarAsync({
|
||||
calId: calendar.id,
|
||||
calLink: calendar.link,
|
||||
patch: { name: trimmedName, desc: trimmedDesc, color },
|
||||
})
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
createCalendarAsync({
|
||||
name: trimmedName,
|
||||
desc: trimmedDesc,
|
||||
color,
|
||||
userId,
|
||||
calId,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
onClose({}, "backdropClick");
|
||||
const updateCalendar = (calId: string, calLink: string) => {
|
||||
dispatch(
|
||||
patchCalendarAsync({
|
||||
calId,
|
||||
calLink,
|
||||
patch: { name: name.trim(), desc: description.trim(), color },
|
||||
})
|
||||
);
|
||||
if (visibility !== calendar?.visibility) {
|
||||
dispatch(
|
||||
patchACLCalendarAsync({
|
||||
calId,
|
||||
calLink,
|
||||
request: visibility === "public" ? "{DAV:}read" : "",
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={(e, reason) => onClose(e, reason)}>
|
||||
<DialogTitle style={{ backgroundColor: color }}>
|
||||
Calendar configuration
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
size="small"
|
||||
margin="dense"
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
size="small"
|
||||
margin="dense"
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
<ColorPicker
|
||||
onChange={(color) => setColor(color)}
|
||||
selectedColor={color}
|
||||
/>
|
||||
</DialogContent>
|
||||
const createCalendar = async (
|
||||
calId: string,
|
||||
name: string,
|
||||
desc: string,
|
||||
color: string,
|
||||
visibility: string
|
||||
) => {
|
||||
await dispatch(
|
||||
createCalendarAsync({
|
||||
name: name.trim(),
|
||||
desc: desc.trim(),
|
||||
color: color,
|
||||
userId,
|
||||
calId,
|
||||
})
|
||||
);
|
||||
dispatch(
|
||||
patchACLCalendarAsync({
|
||||
calId: `${userId}/${calId}`,
|
||||
calLink: `/calendars/${userId}/${calId}.json`,
|
||||
request: visibility === "public" ? "{DAV:}read" : "",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={(e) => onClose({}, "backdropClick")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!name.trim()}
|
||||
variant="contained"
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
const handleSave = () => {
|
||||
if (!name.trim()) return;
|
||||
|
||||
if (calendar) {
|
||||
updateCalendar(calendar.id, calendar.link);
|
||||
} else {
|
||||
createCalendar(crypto.randomUUID(), name, description, color, visibility);
|
||||
}
|
||||
handleClose({}, "backdropClick");
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (importTarget === "new") {
|
||||
const calId = crypto.randomUUID();
|
||||
if (newCalName.trim()) {
|
||||
await createCalendar(
|
||||
calId,
|
||||
newCalName,
|
||||
newCalDescription,
|
||||
newCalColor,
|
||||
newCalVisibility
|
||||
);
|
||||
importedContent &&
|
||||
dispatch(
|
||||
importEventFromFileAsync({
|
||||
calLink: `/calendar/${userId}/${calId}.json`,
|
||||
file: importedContent,
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
importedContent &&
|
||||
dispatch(
|
||||
importEventFromFileAsync({
|
||||
calLink: calendars[importTarget].link,
|
||||
file: importedContent,
|
||||
})
|
||||
);
|
||||
}
|
||||
handleClose({}, "backdropClick");
|
||||
};
|
||||
|
||||
const handleClose = (
|
||||
e: {},
|
||||
reason: "backdropClick" | "escapeKeyDown"
|
||||
): void => {
|
||||
onClose(e, reason);
|
||||
setName("");
|
||||
setDescription("");
|
||||
setColor("");
|
||||
setTab("settings");
|
||||
setVisibility("public");
|
||||
setImportTarget("new");
|
||||
setImportedContent(null);
|
||||
|
||||
setNewCalName("");
|
||||
setNewCalDescription("");
|
||||
setNewCalColor("");
|
||||
setNewCalVisibility("public");
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open={open}
|
||||
onClose={() => handleClose({}, "backdropClick")}
|
||||
title={
|
||||
<Tabs value={tab} onChange={(e, v) => setTab(v)}>
|
||||
<Tab
|
||||
value="settings"
|
||||
label={calendar ? "Settings" : "Add new calendar"}
|
||||
/>
|
||||
{calendar && <Tab value="access" label="Access" />}
|
||||
{isOwn && <Tab value="import" label="Import" />}
|
||||
</Tabs>
|
||||
}
|
||||
actions={
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={(e) => handleClose({}, "backdropClick")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={tab === "import" ? !importedContent : !name.trim()}
|
||||
variant="contained"
|
||||
onClick={tab === "import" ? handleImport : handleSave}
|
||||
>
|
||||
{tab === "import" ? "Import" : calendar ? "Save" : "Create"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
}
|
||||
>
|
||||
{tab === "import" && (
|
||||
<ImportTab
|
||||
importTarget={importTarget}
|
||||
setImportTarget={setImportTarget}
|
||||
setImportedContent={setImportedContent}
|
||||
userId={userId}
|
||||
newCalParams={{
|
||||
name: newCalName,
|
||||
setName: setNewCalName,
|
||||
description: newCalDescription,
|
||||
setDescription: setNewCalDescription,
|
||||
color: newCalColor,
|
||||
setColor: setNewCalColor,
|
||||
visibility: newCalVisibility,
|
||||
setVisibility: setNewCalVisibility,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{tab === "settings" && (
|
||||
<SettingsTab
|
||||
name={name}
|
||||
setName={setName}
|
||||
description={description}
|
||||
setDescription={setDescription}
|
||||
color={color}
|
||||
setColor={setColor}
|
||||
visibility={visibility}
|
||||
setVisibility={setVisibility}
|
||||
calendar={calendar}
|
||||
/>
|
||||
)}
|
||||
{tab === "access" && calendar && <AccessTab calendar={calendar} />}
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAppSelector } from "../../app/hooks";
|
||||
import { SettingsTab } from "./SettingsTab";
|
||||
|
||||
export function ImportTab({
|
||||
userId,
|
||||
importTarget,
|
||||
setImportTarget,
|
||||
setImportedContent,
|
||||
newCalParams,
|
||||
}: {
|
||||
userId: string;
|
||||
importTarget: string;
|
||||
setImportTarget: Function;
|
||||
setImportedContent: Function;
|
||||
newCalParams: {
|
||||
name: string;
|
||||
setName: Function;
|
||||
description: string;
|
||||
setDescription: Function;
|
||||
color: string;
|
||||
setColor: Function;
|
||||
visibility: "public" | "private";
|
||||
setVisibility: Function;
|
||||
};
|
||||
}) {
|
||||
const [importMode, setImportMode] = useState<"file" | "url">("file");
|
||||
const [importFile, setImportFile] = useState<File | null>(null);
|
||||
const [importUrl, setImportUrl] = useState("");
|
||||
const calendars = useAppSelector((state) => state.calendars.list);
|
||||
const personnalCalendars = Object.keys(calendars).filter(
|
||||
(id) => id.split("/")[0] === userId
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setImportedContent(importMode === "file" ? importFile : null);
|
||||
}, [importFile, importUrl, importMode]);
|
||||
|
||||
return (
|
||||
<Box mt={2}>
|
||||
<ToggleButtonGroup
|
||||
value={importMode}
|
||||
exclusive
|
||||
onChange={(e, val) => val && setImportMode(val)}
|
||||
fullWidth
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
<ToggleButton value="file">File</ToggleButton>
|
||||
{/* <ToggleButton value="url">URL</ToggleButton> */}
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{importMode === "file" && (
|
||||
<>
|
||||
<Button variant="outlined" component="label" sx={{ mb: 1 }}>
|
||||
Select file
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
accept=".ics"
|
||||
onChange={(e) => setImportFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</Button>
|
||||
{importFile && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{importFile.name}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
display="block"
|
||||
mb={2}
|
||||
>
|
||||
Import events from an ICS file to one of your calendars.
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
{importMode === "url" && (
|
||||
<TextField
|
||||
fullWidth
|
||||
label="ICS feed URL"
|
||||
value={importUrl}
|
||||
onChange={(e) => setImportUrl(e.target.value)}
|
||||
size="small"
|
||||
margin="dense"
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormControl fullWidth size="small" sx={{ mt: 2 }}>
|
||||
<InputLabel id="import-to-label">Import to</InputLabel>
|
||||
<Select
|
||||
labelId="import-to-label"
|
||||
label="Import to"
|
||||
value={importTarget}
|
||||
onChange={(e) => setImportTarget(e.target.value)}
|
||||
>
|
||||
<MenuItem value="new">New calendar</MenuItem>
|
||||
{personnalCalendars.map((id) => (
|
||||
<MenuItem key={id} value={id}>
|
||||
{calendars[id].name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{importTarget === "new" && <SettingsTab {...newCalParams} />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted";
|
||||
import LockIcon from "@mui/icons-material/Lock";
|
||||
import PublicIcon from "@mui/icons-material/Public";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import { useAppSelector } from "../../app/hooks";
|
||||
import { Calendars } from "../../features/Calendars/CalendarTypes";
|
||||
import { ColorPicker } from "./CalendarColorPicker";
|
||||
|
||||
export function SettingsTab({
|
||||
name,
|
||||
setName,
|
||||
description,
|
||||
setDescription,
|
||||
color,
|
||||
setColor,
|
||||
visibility,
|
||||
setVisibility,
|
||||
calendar,
|
||||
}: {
|
||||
name: string;
|
||||
setName: Function;
|
||||
description: string;
|
||||
setDescription: Function;
|
||||
color: string;
|
||||
setColor: Function;
|
||||
visibility: "public" | "private";
|
||||
setVisibility: Function;
|
||||
calendar?: Calendars;
|
||||
}) {
|
||||
const [toggleDesc, setToggleDesc] = useState(Boolean(description));
|
||||
const userId =
|
||||
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
|
||||
const isOwn = calendar ? calendar.id.split("/")[0] === userId : true;
|
||||
|
||||
return (
|
||||
<Box mt={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
size="small"
|
||||
margin="dense"
|
||||
/>
|
||||
|
||||
{!toggleDesc && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => setToggleDesc(!toggleDesc)}
|
||||
startIcon={<FormatListBulletedIcon />}
|
||||
>
|
||||
Add description
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{toggleDesc && (
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
size="small"
|
||||
margin="dense"
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box mt={2}>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
Color
|
||||
</Typography>
|
||||
<ColorPicker
|
||||
onChange={(color) => setColor(color)}
|
||||
selectedColor={color}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{isOwn && (
|
||||
<Box mt={2}>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
New events created will be visible to:
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
value={visibility}
|
||||
exclusive
|
||||
onChange={(e, val) => val && setVisibility(val)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="public">
|
||||
<PublicIcon fontSize="small" />
|
||||
All
|
||||
</ToggleButton>
|
||||
|
||||
<ToggleButton value="private">
|
||||
<LockIcon fontSize="small" />
|
||||
You
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -133,3 +133,26 @@ export const updateCalsDetails = (
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
interface AclEntry {
|
||||
privilege: string;
|
||||
principal: string;
|
||||
protected: boolean;
|
||||
}
|
||||
|
||||
export function getCalendarVisibility(acl: AclEntry[]): "private" | "public" {
|
||||
let hasRead = false;
|
||||
let hasFreeBusy = false;
|
||||
|
||||
for (const entry of acl) {
|
||||
if (entry.principal !== "{DAV:}authenticated") continue;
|
||||
|
||||
if (entry.privilege === "{DAV:}read") {
|
||||
hasRead = true;
|
||||
break; // highest visibility, can stop
|
||||
}
|
||||
}
|
||||
|
||||
if (hasRead) return "public";
|
||||
return "private";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Snackbar from "@mui/material/Snackbar";
|
||||
|
||||
export function SnackbarAlert({
|
||||
open,
|
||||
setOpen,
|
||||
message,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: Function;
|
||||
message: string;
|
||||
}) {
|
||||
return (
|
||||
<Snackbar
|
||||
open={open}
|
||||
autoHideDuration={2000}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||
>
|
||||
<Alert onClose={() => setOpen(false)} sx={{ width: "100%" }}>
|
||||
{message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
);
|
||||
}
|
||||
@@ -112,3 +112,14 @@ export async function removeCalendar(calLink: string) {
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function updateAclCalendar(calLink: string, request: string) {
|
||||
const response = await api(`dav${calLink}`, {
|
||||
method: "ACL",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({ public_right: request }),
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -8,15 +8,24 @@ import {
|
||||
postCalendar,
|
||||
proppatchCalendar,
|
||||
removeCalendar,
|
||||
updateAclCalendar,
|
||||
} from "./CalendarApi";
|
||||
import { getOpenPaasUser, getUserDetails } from "../User/userAPI";
|
||||
import { parseCalendarEvent } from "../Events/eventUtils";
|
||||
import { deleteEvent, getEvent, moveEvent, putEvent } from "../Events/EventApi";
|
||||
import {
|
||||
formatDateToYYYYMMDDTHHMMSS,
|
||||
deleteEvent,
|
||||
getEvent,
|
||||
importEventFromFile,
|
||||
moveEvent,
|
||||
putEvent,
|
||||
} from "../Events/EventApi";
|
||||
import {
|
||||
computeWeekRange,
|
||||
formatDateToYYYYMMDDTHHMMSS,
|
||||
} from "../../utils/dateUtils";
|
||||
import { User } from "../../components/Attendees/PeopleSearch";
|
||||
import { getCalendarVisibility } from "../../components/Calendar/utils/calendarUtils";
|
||||
import { importFile } from "../../utils/apiUtils";
|
||||
|
||||
export const getCalendarsListAsync = createAsyncThunk<
|
||||
Record<string, Calendars> // Return type
|
||||
@@ -39,7 +48,7 @@ export const getCalendarsListAsync = createAsyncThunk<
|
||||
delegated = true;
|
||||
}
|
||||
const id = source.replace("/calendars/", "").replace(".json", "");
|
||||
|
||||
const visibility = getCalendarVisibility(cal["acl"]);
|
||||
const ownerData: any = await getUserDetails(id.split("/")[0]);
|
||||
const color = cal["apple:color"];
|
||||
importedCalendars[id] = {
|
||||
@@ -53,6 +62,7 @@ export const getCalendarsListAsync = createAsyncThunk<
|
||||
description,
|
||||
delegated,
|
||||
color,
|
||||
visibility,
|
||||
events: {},
|
||||
};
|
||||
}
|
||||
@@ -82,6 +92,7 @@ export const getTempCalendarsListAsync = createAsyncThunk<
|
||||
const link = cal._links.self.href;
|
||||
|
||||
const id = source.replace("/calendars/", "").replace(".json", "");
|
||||
const visibility = getCalendarVisibility(cal["acl"]);
|
||||
const ownerData: any = await getUserDetails(id.split("/")[0]);
|
||||
|
||||
importedCalendars[id] = {
|
||||
@@ -93,6 +104,7 @@ export const getTempCalendarsListAsync = createAsyncThunk<
|
||||
description,
|
||||
delegated,
|
||||
color: tempUser.color ?? "#a8a8a8ff",
|
||||
visibility,
|
||||
events: {},
|
||||
};
|
||||
}
|
||||
@@ -234,6 +246,26 @@ export const moveEventAsync = createAsyncThunk<
|
||||
};
|
||||
});
|
||||
|
||||
export const patchACLCalendarAsync = createAsyncThunk<
|
||||
{
|
||||
calId: string;
|
||||
calLink: string;
|
||||
request: string;
|
||||
}, // Return type
|
||||
{
|
||||
calId: string;
|
||||
calLink: string;
|
||||
request: string;
|
||||
} // Arg type
|
||||
>("calendars/requestACLCalendar", async ({ calId, calLink, request }) => {
|
||||
const response = await updateAclCalendar(calLink, request);
|
||||
return {
|
||||
calId,
|
||||
calLink,
|
||||
request,
|
||||
};
|
||||
});
|
||||
|
||||
export const deleteEventAsync = createAsyncThunk<
|
||||
{ calId: string; eventId: string }, // Return type
|
||||
{ calId: string; eventId: string; eventURL: string } // Arg type
|
||||
@@ -305,6 +337,17 @@ export const addSharedCalendarAsync = createAsyncThunk<
|
||||
};
|
||||
});
|
||||
|
||||
export const importEventFromFileAsync = createAsyncThunk<
|
||||
void,
|
||||
{
|
||||
calLink: string;
|
||||
file: File;
|
||||
}
|
||||
>("calendars/importEvent", async ({ calLink, file }) => {
|
||||
const id = ((await importFile(file)) as Record<string, string>)._id;
|
||||
const response = await importEventFromFile(id, calLink);
|
||||
});
|
||||
|
||||
const CalendarSlice = createSlice({
|
||||
name: "calendars",
|
||||
initialState: {
|
||||
@@ -507,6 +550,7 @@ const CalendarSlice = createSlice({
|
||||
state.list[`${action.payload.userId}/${action.payload.calId}`] = {
|
||||
color: action.payload.color,
|
||||
id: `${action.payload.userId}/${action.payload.calId}`,
|
||||
link: `/calendars/${action.payload.userId}/${action.payload.calId}.json`,
|
||||
description: action.payload.desc,
|
||||
name: action.payload.name,
|
||||
owner: action.payload.owner,
|
||||
@@ -557,6 +601,11 @@ const CalendarSlice = createSlice({
|
||||
state.pending = false;
|
||||
delete state.list[action.payload.calId];
|
||||
})
|
||||
.addCase(patchACLCalendarAsync.fulfilled, (state, action) => {
|
||||
state.pending = false;
|
||||
state.list[action.payload.calId].visibility =
|
||||
action.payload.request !== "" ? "public" : "private";
|
||||
})
|
||||
.addCase(getCalendarDetailAsync.pending, (state) => {
|
||||
state.pending = true;
|
||||
})
|
||||
@@ -589,6 +638,9 @@ const CalendarSlice = createSlice({
|
||||
})
|
||||
.addCase(removeCalendarAsync.pending, (state) => {
|
||||
state.pending = true;
|
||||
})
|
||||
.addCase(patchACLCalendarAsync.pending, (state) => {
|
||||
state.pending = true;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -13,4 +13,5 @@ export interface Calendars {
|
||||
calscale?: string;
|
||||
version?: string;
|
||||
events: Record<string, CalendarEvent>;
|
||||
visibility: "private" | "public";
|
||||
}
|
||||
|
||||
@@ -53,3 +53,10 @@ export async function deleteEvent(eventURL: string) {
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function importEventFromFile(id: string, calLink: string) {
|
||||
const response = await api.post(`api/import`, {
|
||||
body: JSON.stringify({ fileId: id, target: calLink }),
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -54,3 +54,11 @@ export function isValidUrl(string?: string) {
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function importFile(file: File) {
|
||||
const response = await api.post(
|
||||
`api/files?mimetype=${file.type}&name=${file.name}&size=${file.size}`,
|
||||
{ body: await file.text() }
|
||||
);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user