Merge pull request #172 from linagora/UI/create-event-popup
UI/create event popup
This commit is contained in:
@@ -217,7 +217,7 @@ describe("calendar Availability search", () => {
|
||||
|
||||
renderWithProviders(<CalendarTestWrapper />, preloadedState);
|
||||
|
||||
const input = screen.getByPlaceholderText(/search user/i);
|
||||
const input = screen.getByPlaceholderText(/start typing a name or email/i);
|
||||
userEvent.type(input, "New");
|
||||
|
||||
const option = await screen.findByText("New User");
|
||||
@@ -242,7 +242,7 @@ describe("calendar Availability search", () => {
|
||||
});
|
||||
renderWithProviders(<CalendarTestWrapper />, preloadedState);
|
||||
|
||||
const input = screen.getByPlaceholderText(/search user/i);
|
||||
const input = screen.getByPlaceholderText(/start typing a name or email/i);
|
||||
userEvent.type(input, "Alice");
|
||||
|
||||
const option = await screen.findByText("Alice");
|
||||
|
||||
@@ -98,72 +98,65 @@ async function setupEventPopover(
|
||||
/>,
|
||||
preloadedState
|
||||
);
|
||||
act(() => {
|
||||
fireEvent.change(screen.getByLabelText("Title"), {
|
||||
target: { value: "Meeting" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("All day"));
|
||||
fireEvent.change(screen.getByLabelText("Start"), {
|
||||
target: {
|
||||
value: (overrides?.start ?? "2025-07-18T00:00:00.000Z").split("T")[0],
|
||||
},
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("End"), {
|
||||
target: {
|
||||
value: (overrides?.end ?? "2025-07-19T00:00:00.000Z").split("T")[0],
|
||||
},
|
||||
});
|
||||
fireEvent.click(screen.getByText("Show More"));
|
||||
});
|
||||
const select = screen.getByLabelText(/repetition/i);
|
||||
userEvent.click(select);
|
||||
|
||||
return jest.spyOn(apiUtils, "api");
|
||||
}
|
||||
// Fill in title
|
||||
const titleInput = screen.getByLabelText("Title");
|
||||
fireEvent.change(titleInput, { target: { value: "Meeting" } });
|
||||
|
||||
async function expectRRule(expected: any) {
|
||||
const spyAPi = jest.spyOn(apiUtils, "api");
|
||||
// Click Show More to expand the dialog
|
||||
const showMoreButton = screen.getByText("Show More");
|
||||
fireEvent.click(showMoreButton);
|
||||
|
||||
act(() => fireEvent.click(screen.getByText("Save")));
|
||||
// Check Repeat checkbox to show repeat options
|
||||
const repeatCheckbox = screen.getByLabelText("Repeat");
|
||||
fireEvent.click(repeatCheckbox);
|
||||
|
||||
// Wait for RepeatEvent component to be rendered
|
||||
await waitFor(() => {
|
||||
expect(spyAPi).toHaveBeenCalled();
|
||||
expect(screen.getByText("Day(s)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const receivedPayload: string =
|
||||
spyAPi.mock.calls[0][1]?.body?.toString() ?? "";
|
||||
const [, , [vevent]] = JSON.parse(receivedPayload);
|
||||
const rrule = vevent[1].find(([name]: any) => name === "rrule");
|
||||
|
||||
if (rrule[3].byday) {
|
||||
expect({
|
||||
...rrule[3],
|
||||
byday: rrule[3].byday.sort(),
|
||||
}).toEqual({
|
||||
...expected,
|
||||
byday: expected.byday.sort(),
|
||||
});
|
||||
} else {
|
||||
expect(rrule[3]).toEqual(expected);
|
||||
}
|
||||
}
|
||||
|
||||
describe("RepeatEvent", () => {
|
||||
it("renders with no repetition by default", () => {
|
||||
setupRepeatEvent();
|
||||
expect(screen.getByLabelText(/repetition/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/daily/i)).not.toBeInTheDocument();
|
||||
async function expectRRule(expected: Partial<RepetitionObject>) {
|
||||
const spy = jest
|
||||
.spyOn(eventThunks, "putEventAsync")
|
||||
.mockImplementation((payload) => () => Promise.resolve(payload) as any);
|
||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||
act(() => fireEvent.click(saveButton));
|
||||
await waitFor(() => expect(spy).toHaveBeenCalled());
|
||||
|
||||
const received = spy.mock.calls[0][0];
|
||||
expect(received.newEvent.repetition).toMatchObject(expected);
|
||||
}
|
||||
|
||||
describe("RepeatEvent Component", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("allows selecting repetition frequency", async () => {
|
||||
it("renders with no repetition by default", () => {
|
||||
const { setRepetition } = setupRepeatEvent();
|
||||
const select = screen.getByLabelText(/repetition/i);
|
||||
act(() => {
|
||||
userEvent.click(select);
|
||||
});
|
||||
await waitFor(async () =>
|
||||
userEvent.click(await screen.findByText(/repeat weekly/i))
|
||||
);
|
||||
|
||||
// Check that interval input shows default value
|
||||
const intervalInput = screen.getByDisplayValue("1");
|
||||
expect(intervalInput).toBeInTheDocument();
|
||||
|
||||
// Check that frequency dropdown shows default value
|
||||
const frequencySelect = screen.getByRole("combobox");
|
||||
expect(frequencySelect).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows selecting repetition frequency", () => {
|
||||
const { setRepetition } = setupRepeatEvent();
|
||||
|
||||
// Click on frequency dropdown
|
||||
const frequencySelect = screen.getByRole("combobox");
|
||||
fireEvent.mouseDown(frequencySelect);
|
||||
|
||||
// Select Week(s)
|
||||
const weeklyOption = screen.getByText("Week(s)");
|
||||
fireEvent.click(weeklyOption);
|
||||
|
||||
expect(setRepetition).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ freq: "weekly" })
|
||||
@@ -171,98 +164,59 @@ describe("RepeatEvent", () => {
|
||||
});
|
||||
|
||||
it("renders interval input when frequency is selected", () => {
|
||||
setupRepeatEvent({ freq: "daily", interval: 2 });
|
||||
expect(screen.getByText(/interval/i)).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("2")).toBeInTheDocument();
|
||||
setupRepeatEvent({ freq: "daily" });
|
||||
|
||||
const intervalInput = screen.getByDisplayValue("1");
|
||||
expect(intervalInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updates interval value", () => {
|
||||
const { setRepetition } = setupRepeatEvent({ freq: "daily", interval: 1 });
|
||||
const input = screen.getByDisplayValue("1");
|
||||
fireEvent.change(input, { target: { value: "5" } });
|
||||
const { setRepetition } = setupRepeatEvent();
|
||||
|
||||
const intervalInput = screen.getByDisplayValue("1");
|
||||
fireEvent.change(intervalInput, { target: { value: "3" } });
|
||||
|
||||
expect(setRepetition).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ interval: 5 })
|
||||
expect.objectContaining({ interval: 3 })
|
||||
);
|
||||
});
|
||||
|
||||
it("toggles day selection for weekly frequency", () => {
|
||||
const { setRepetition } = setupRepeatEvent({
|
||||
freq: "weekly",
|
||||
selectedDays: [],
|
||||
});
|
||||
act(() => {
|
||||
const mondayCheckbox = screen.getByLabelText("MO");
|
||||
fireEvent.click(mondayCheckbox);
|
||||
});
|
||||
const { setRepetition } = setupRepeatEvent({ freq: "weekly" });
|
||||
|
||||
const mondayCheckbox = screen.getByLabelText("MO");
|
||||
fireEvent.click(mondayCheckbox);
|
||||
|
||||
expect(setRepetition).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ selectedDays: ["MO"] })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Repeat Event API calls", () => {
|
||||
describe("Repeat Event Integration Tests", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("sends correct CalendarEvent payload", async () => {
|
||||
setupEventPopover();
|
||||
|
||||
userEvent.click(await screen.findByText(/repeat weekly/i));
|
||||
|
||||
const spy = jest
|
||||
.spyOn(eventThunks, "putEventAsync")
|
||||
.mockImplementation((payload) => () => Promise.resolve(payload) as any);
|
||||
act(() => fireEvent.click(screen.getByText("Save")));
|
||||
await waitFor(() => expect(spy).toHaveBeenCalled());
|
||||
|
||||
const received = spy.mock.calls[0][0];
|
||||
expect(received.cal).toEqual(
|
||||
preloadedState.calendars.list["667037022b752d0026472254/cal1"]
|
||||
);
|
||||
expect(received.newEvent.title).toBe("Meeting");
|
||||
expect(
|
||||
formatDateToYYYYMMDDTHHMMSS(received.newEvent.start).split("T")[0]
|
||||
).toBe("20250718");
|
||||
expect(
|
||||
formatDateToYYYYMMDDTHHMMSS(received.newEvent.end || new Date()).split(
|
||||
"T"
|
||||
)[0]
|
||||
).toBe("20250719");
|
||||
expect(received.newEvent.organizer).toEqual(
|
||||
preloadedState.user.organiserData
|
||||
);
|
||||
const day = new Date(received.newEvent.start)
|
||||
.toLocaleString("en-UK", {
|
||||
weekday: "short",
|
||||
})
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
expect(received.newEvent.repetition).toEqual({
|
||||
freq: "weekly",
|
||||
selectedDays: [day],
|
||||
});
|
||||
expect(received.newEvent.color).toEqual(
|
||||
preloadedState.calendars.list["667037022b752d0026472254/cal1"].color
|
||||
);
|
||||
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
|
||||
});
|
||||
|
||||
it("sends correct API payload for repeat daily", async () => {
|
||||
it("sends correct CalendarEvent payload for daily repeat", async () => {
|
||||
await setupEventPopover();
|
||||
|
||||
userEvent.click(await screen.findByText(/repeat daily/i));
|
||||
// When Repeat checkbox is checked, repetition is set to empty object
|
||||
// We need to set the frequency manually
|
||||
const frequencySelect = screen.getByText("Day(s)");
|
||||
fireEvent.mouseDown(frequencySelect);
|
||||
const dailyOption = screen.getByRole("option", { name: "Day(s)" });
|
||||
fireEvent.click(dailyOption);
|
||||
|
||||
await expectRRule({ freq: "daily" });
|
||||
await expectRRule({ freq: "daily", interval: 1 });
|
||||
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
|
||||
});
|
||||
|
||||
it("sends correct API payload for repeat daily with 2 day interval", async () => {
|
||||
await setupEventPopover();
|
||||
|
||||
userEvent.click(await screen.findByText(/repeat daily/i));
|
||||
// Set interval to 2
|
||||
const intervalInput = screen.getByDisplayValue("1");
|
||||
fireEvent.change(intervalInput, { target: { value: "2" } });
|
||||
|
||||
@@ -273,94 +227,143 @@ describe("Repeat Event API calls", () => {
|
||||
it("sends correct API payload for repeat daily for 5 repetitions", async () => {
|
||||
await setupEventPopover();
|
||||
|
||||
userEvent.click(await screen.findByText(/repeat daily/i));
|
||||
userEvent.click(screen.getByLabelText(/after/i));
|
||||
const input = screen.getAllByRole("spinbutton")[1];
|
||||
fireEvent.change(input, { target: { value: "5" } });
|
||||
// Select "After" end option
|
||||
const afterRadio = screen.getByLabelText(/after/i);
|
||||
fireEvent.click(afterRadio);
|
||||
|
||||
await expectRRule({ freq: "daily", count: 5 });
|
||||
// Set occurrences to 5
|
||||
const occurrencesInput = screen.getAllByRole("spinbutton")[1];
|
||||
fireEvent.change(occurrencesInput, { target: { value: "5" } });
|
||||
|
||||
await expectRRule({ freq: "daily", interval: 1, occurrences: 5 });
|
||||
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
|
||||
});
|
||||
|
||||
it("sends correct API payload for repeat daily until now+5days", async () => {
|
||||
it("sends correct API payload for repeat daily until specific date", async () => {
|
||||
await setupEventPopover();
|
||||
|
||||
userEvent.click(await screen.findByText(/repeat daily/i));
|
||||
userEvent.click(screen.getAllByLabelText(/on/i)[3]);
|
||||
// Select "On" end option
|
||||
const onRadio = screen.getByLabelText(/on/i);
|
||||
fireEvent.click(onRadio);
|
||||
|
||||
const untilInput = screen.getByTestId("end-date");
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 5);
|
||||
// Set end date
|
||||
const endDateInput = screen.getByTestId("end-date");
|
||||
fireEvent.change(endDateInput, { target: { value: "2025-12-31" } });
|
||||
|
||||
await expectRRule({ freq: "daily", interval: 1, endDate: "2025-12-31" });
|
||||
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
|
||||
});
|
||||
|
||||
it("sends correct API payload for repeat weekly on specific days", async () => {
|
||||
await setupEventPopover();
|
||||
|
||||
// Select Week(s) frequency
|
||||
const frequencySelect = screen.getByText("Day(s)");
|
||||
fireEvent.mouseDown(frequencySelect);
|
||||
const weeklyOption = screen.getByRole("option", { name: "Week(s)" });
|
||||
fireEvent.click(weeklyOption);
|
||||
|
||||
// Select Thursday
|
||||
const thursdayCheckbox = screen.getByLabelText("TH");
|
||||
fireEvent.click(thursdayCheckbox);
|
||||
|
||||
fireEvent.change(untilInput, {
|
||||
target: { value: futureDate.toISOString().split("T")[0] },
|
||||
});
|
||||
await expectRRule({
|
||||
freq: "daily",
|
||||
until: futureDate.toISOString().split("T")[0],
|
||||
freq: "weekly",
|
||||
interval: 1,
|
||||
selectedDays: ["FR", "TH"],
|
||||
});
|
||||
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
|
||||
});
|
||||
|
||||
it("sends correct API payload for repeat weekly on Thursday and event day (Friday)", async () => {
|
||||
it("sends correct API payload for repeat weekly with 3 week interval", async () => {
|
||||
await setupEventPopover();
|
||||
|
||||
userEvent.click(await screen.findByText(/repeat weekly/i));
|
||||
userEvent.click(screen.getByLabelText("TH"));
|
||||
|
||||
await expectRRule({ freq: "weekly", byday: ["TH", "FR"] });
|
||||
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
|
||||
});
|
||||
it("sends correct API payload for repeat weekly on Thursday and event day (Friday) and an interval of 3 weeks", async () => {
|
||||
await setupEventPopover();
|
||||
|
||||
userEvent.click(await screen.findByText(/repeat weekly/i));
|
||||
|
||||
userEvent.click(screen.getByLabelText("TH"));
|
||||
// Select Week(s) frequency
|
||||
const frequencySelect = screen.getByText("Day(s)");
|
||||
fireEvent.mouseDown(frequencySelect);
|
||||
const weeklyOption = screen.getByRole("option", { name: "Week(s)" });
|
||||
fireEvent.click(weeklyOption);
|
||||
|
||||
// Set interval to 3
|
||||
const intervalInput = screen.getByDisplayValue("1");
|
||||
fireEvent.change(intervalInput, { target: { value: "3" } });
|
||||
|
||||
await expectRRule({ freq: "weekly", byday: ["TH", "FR"], interval: 3 });
|
||||
await expectRRule({ freq: "weekly", interval: 3 });
|
||||
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
|
||||
});
|
||||
|
||||
it("sends correct API payload for repeat monthly", async () => {
|
||||
await setupEventPopover();
|
||||
|
||||
userEvent.click(await screen.findByText(/repeat monthly/i));
|
||||
// Select Month(s) frequency
|
||||
const frequencySelect = screen.getByText("Day(s)");
|
||||
fireEvent.mouseDown(frequencySelect);
|
||||
const monthlyOption = screen.getByRole("option", { name: "Month(s)" });
|
||||
fireEvent.click(monthlyOption);
|
||||
|
||||
await expectRRule({ freq: "monthly" });
|
||||
await expectRRule({ freq: "monthly", interval: 1 });
|
||||
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
|
||||
});
|
||||
|
||||
it("sends correct API payload for repeat monthly and end after 5 occurrences", async () => {
|
||||
await setupEventPopover();
|
||||
|
||||
userEvent.click(await screen.findByText(/repeat monthly/i));
|
||||
userEvent.click(screen.getByLabelText(/after/i));
|
||||
const input = screen.getAllByRole("spinbutton")[1];
|
||||
fireEvent.change(input, { target: { value: "5" } });
|
||||
await expectRRule({ freq: "monthly", count: 5 });
|
||||
// Select Month(s) frequency
|
||||
const frequencySelect = screen.getByText("Day(s)");
|
||||
fireEvent.mouseDown(frequencySelect);
|
||||
const monthlyOption = screen.getByRole("option", { name: "Month(s)" });
|
||||
fireEvent.click(monthlyOption);
|
||||
|
||||
// Select "After" end option
|
||||
const afterRadio = screen.getByLabelText(/after/i);
|
||||
fireEvent.click(afterRadio);
|
||||
|
||||
// Set occurrences to 5
|
||||
const occurrencesInput = screen.getAllByRole("spinbutton")[1];
|
||||
fireEvent.change(occurrencesInput, { target: { value: "5" } });
|
||||
|
||||
await expectRRule({ freq: "monthly", interval: 1, occurrences: 5 });
|
||||
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
|
||||
});
|
||||
|
||||
it("sends correct API payload for repeat yearly", async () => {
|
||||
await setupEventPopover();
|
||||
|
||||
userEvent.click(await screen.findByText(/repeat yearly/i));
|
||||
// Select Year(s) frequency
|
||||
const frequencySelect = screen.getByText("Day(s)");
|
||||
fireEvent.mouseDown(frequencySelect);
|
||||
const yearlyOption = screen.getByRole("option", { name: "Year(s)" });
|
||||
fireEvent.click(yearlyOption);
|
||||
|
||||
await expectRRule({ freq: "yearly" });
|
||||
await expectRRule({ freq: "yearly", interval: 1 });
|
||||
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
|
||||
});
|
||||
|
||||
it("sends correct API payload for repeat yearly, but user first choose to end after 5 occurrences then changed mind and chose to not end", async () => {
|
||||
it("sends correct API payload for repeat yearly with end option changes", async () => {
|
||||
await setupEventPopover();
|
||||
|
||||
userEvent.click(await screen.findByText(/repeat yearly/i));
|
||||
userEvent.click(screen.getByLabelText(/after/i));
|
||||
const input = screen.getAllByRole("spinbutton")[1];
|
||||
fireEvent.change(input, { target: { value: "5" } });
|
||||
userEvent.click(screen.getByLabelText(/never/i));
|
||||
// Select Year(s) frequency
|
||||
const frequencySelect = screen.getByText("Day(s)");
|
||||
fireEvent.mouseDown(frequencySelect);
|
||||
const yearlyOption = screen.getByRole("option", { name: "Year(s)" });
|
||||
fireEvent.click(yearlyOption);
|
||||
|
||||
await expectRRule({ freq: "yearly" });
|
||||
// First choose "After" with 5 occurrences
|
||||
const afterRadio = screen.getByLabelText(/after/i);
|
||||
fireEvent.click(afterRadio);
|
||||
const occurrencesInput = screen.getAllByRole("spinbutton")[1];
|
||||
fireEvent.change(occurrencesInput, { target: { value: "5" } });
|
||||
|
||||
// Then change mind and choose "Never"
|
||||
const neverRadio = screen.getByLabelText(/never/i);
|
||||
fireEvent.click(neverRadio);
|
||||
|
||||
await expectRRule({
|
||||
freq: "yearly",
|
||||
interval: 1,
|
||||
occurrences: 0,
|
||||
endDate: "",
|
||||
});
|
||||
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { ResponsiveDialog } from "../../src/components/Dialog";
|
||||
import { Button, TextField } from "@mui/material";
|
||||
|
||||
describe("ResponsiveDialog", () => {
|
||||
const mockOnClose = jest.fn();
|
||||
const mockOnExpandToggle = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnClose.mockClear();
|
||||
mockOnExpandToggle.mockClear();
|
||||
});
|
||||
|
||||
it("renders in normal mode by default", () => {
|
||||
render(
|
||||
<ResponsiveDialog open={true} onClose={mockOnClose} title="Test Dialog">
|
||||
<TextField label="Name" />
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Test Dialog")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders title in normal mode", () => {
|
||||
render(
|
||||
<ResponsiveDialog
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
title="My Title"
|
||||
isExpanded={false}
|
||||
>
|
||||
<div>Content</div>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
|
||||
expect(screen.getByText("My Title")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("show less")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders back arrow in extended mode", () => {
|
||||
render(
|
||||
<ResponsiveDialog
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
title="My Title"
|
||||
isExpanded={true}
|
||||
onExpandToggle={mockOnExpandToggle}
|
||||
>
|
||||
<div>Content</div>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
|
||||
expect(screen.queryByText("My Title")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("show less")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onExpandToggle when back arrow is clicked", () => {
|
||||
render(
|
||||
<ResponsiveDialog
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
title="Test"
|
||||
isExpanded={true}
|
||||
onExpandToggle={mockOnExpandToggle}
|
||||
>
|
||||
<div>Content</div>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
|
||||
const backButton = screen.getByLabelText("show less");
|
||||
fireEvent.click(backButton);
|
||||
|
||||
expect(mockOnExpandToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders actions when provided", () => {
|
||||
render(
|
||||
<ResponsiveDialog
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
title="Test"
|
||||
actions={<Button>Custom Action</Button>}
|
||||
>
|
||||
<div>Content</div>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Custom Action")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render actions when not provided", () => {
|
||||
const { container } = render(
|
||||
<ResponsiveDialog open={true} onClose={mockOnClose} title="Test">
|
||||
<div>Content</div>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
|
||||
const dialogActions = container.querySelector(".MuiDialogActions-root");
|
||||
expect(dialogActions).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onClose when backdrop is clicked", () => {
|
||||
render(
|
||||
<ResponsiveDialog open={true} onClose={mockOnClose} title="Test">
|
||||
<div>Content</div>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
|
||||
const backdrop = document.querySelector(".MuiBackdrop-root");
|
||||
if (backdrop) {
|
||||
fireEvent.click(backdrop);
|
||||
}
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies custom normalMaxWidth", () => {
|
||||
render(
|
||||
<ResponsiveDialog
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
title="Test"
|
||||
normalMaxWidth="800px"
|
||||
>
|
||||
<div>Normal Width Content</div>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Normal Width Content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("wraps children in Stack component", () => {
|
||||
render(
|
||||
<ResponsiveDialog open={true} onClose={mockOnClose} title="Test">
|
||||
<TextField label="Field 1" />
|
||||
<TextField label="Field 2" />
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Field 1")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Field 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses correct spacing in normal mode", () => {
|
||||
render(
|
||||
<ResponsiveDialog
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
title="Test"
|
||||
isExpanded={false}
|
||||
normalSpacing={2}
|
||||
>
|
||||
<div>Normal Spacing Content</div>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Normal Spacing Content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses correct spacing in extended mode", () => {
|
||||
render(
|
||||
<ResponsiveDialog
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
title="Test"
|
||||
isExpanded={true}
|
||||
expandedSpacing={3}
|
||||
>
|
||||
<div>Extended Spacing Content</div>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Extended Spacing Content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies contentSx custom styles", () => {
|
||||
render(
|
||||
<ResponsiveDialog
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
title="Test"
|
||||
contentSx={{ padding: 4 }}
|
||||
>
|
||||
<div>Custom Styled Content</div>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Custom Styled Content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies titleSx custom styles", () => {
|
||||
const { container } = render(
|
||||
<ResponsiveDialog
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
title="Test"
|
||||
titleSx={{ color: "red" }}
|
||||
>
|
||||
<div>Content</div>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
|
||||
const title = screen.getByText("Test");
|
||||
expect(title).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows dividers when dividers prop is true", () => {
|
||||
render(
|
||||
<ResponsiveDialog
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
title="Test"
|
||||
dividers={true}
|
||||
>
|
||||
<div>Content with Dividers</div>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Content with Dividers")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show back arrow when onExpandToggle is not provided", () => {
|
||||
render(
|
||||
<ResponsiveDialog
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
title="Test Title"
|
||||
isExpanded={true}
|
||||
>
|
||||
<div>Content</div>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText("show less")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Test Title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("accepts custom headerHeight", () => {
|
||||
render(
|
||||
<ResponsiveDialog
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
title="Test"
|
||||
isExpanded={true}
|
||||
headerHeight="100px"
|
||||
>
|
||||
<div>Custom Header Content</div>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Custom Header Content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders with custom expandedContentMaxWidth", () => {
|
||||
render(
|
||||
<ResponsiveDialog
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
title="Test"
|
||||
isExpanded={true}
|
||||
expandedContentMaxWidth="1200px"
|
||||
>
|
||||
<div>Wide Content</div>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Wide Content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render dialog content when open is false", () => {
|
||||
render(
|
||||
<ResponsiveDialog open={false} onClose={mockOnClose} title="Test">
|
||||
<div>Test Content</div>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Test Content")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders correctly in extended mode", () => {
|
||||
render(
|
||||
<ResponsiveDialog
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
title="Test"
|
||||
isExpanded={true}
|
||||
onExpandToggle={mockOnExpandToggle}
|
||||
>
|
||||
<div>Extended Content</div>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Extended Content")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("show less")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -104,4 +104,56 @@ describe("eventApi", () => {
|
||||
method: "DELETE",
|
||||
});
|
||||
});
|
||||
|
||||
test("putEvent handles byday field correctly", async () => {
|
||||
const mockResponse = { status: 201, url: "/dav/cals/test.ics" };
|
||||
(api as unknown as jest.Mock).mockReturnValue(mockResponse);
|
||||
|
||||
const eventWithByday = {
|
||||
...mockEvent,
|
||||
repetition: {
|
||||
freq: "weekly",
|
||||
interval: 1,
|
||||
byday: ["MO", "WE", "FR"],
|
||||
},
|
||||
};
|
||||
|
||||
await putEvent(eventWithByday);
|
||||
const expectedResult = calendarEventToJCal(eventWithByday);
|
||||
|
||||
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),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("putEvent handles null byday field correctly", async () => {
|
||||
const mockResponse = { status: 201, url: "/dav/cals/test.ics" };
|
||||
(api as unknown as jest.Mock).mockReturnValue(mockResponse);
|
||||
|
||||
const eventWithNullByday = {
|
||||
...mockEvent,
|
||||
repetition: {
|
||||
freq: "daily",
|
||||
interval: 1,
|
||||
byday: null,
|
||||
},
|
||||
};
|
||||
|
||||
await putEvent(eventWithNullByday);
|
||||
const expectedResult = calendarEventToJCal(eventWithNullByday);
|
||||
|
||||
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),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1039,10 +1039,15 @@ describe("Event Full Display", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Alarm/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Repetition/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Visibility/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Notification/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Debug: Print DOM to see what's rendered
|
||||
console.log("DOM after Show More clicked:", document.body.innerHTML);
|
||||
|
||||
// EventDisplay modal doesn't have Repeat checkbox, only RepeatEvent component
|
||||
// which shows repetition settings when repetition data exists
|
||||
// Since test event has no repetition data, RepeatEvent component won't show Repeat checkbox
|
||||
fireEvent.click(screen.getByText("Show Less"));
|
||||
});
|
||||
|
||||
|
||||
@@ -109,22 +109,34 @@ describe("EventPopover", () => {
|
||||
it("renders correctly with inputs and calendar options", () => {
|
||||
renderPopover();
|
||||
|
||||
// Check dialog title
|
||||
expect(screen.getByText("Create Event")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Calendar")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Title")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Start")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("End")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Description")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Location")).toBeInTheDocument();
|
||||
|
||||
// Check inputs exist by their roles
|
||||
const titleInput = screen.getByRole("textbox", { name: /title/i });
|
||||
expect(titleInput).toBeInTheDocument();
|
||||
|
||||
// Description input is only visible after clicking "Add description" button
|
||||
const addDescriptionButton = screen.getByText("Add description");
|
||||
expect(addDescriptionButton).toBeInTheDocument();
|
||||
|
||||
const calendarSelect = screen.getByRole("combobox", { name: /calendar/i });
|
||||
expect(calendarSelect).toBeInTheDocument();
|
||||
|
||||
// Check button
|
||||
expect(screen.getByText("Show More")).toBeInTheDocument();
|
||||
|
||||
// Extended mode
|
||||
fireEvent.click(screen.getByText("Show More"));
|
||||
expect(screen.getByLabelText("Repetition")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Alarm")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Visibility")).toBeInTheDocument();
|
||||
// Calendar options
|
||||
const select = screen.getByLabelText("Calendar");
|
||||
fireEvent.mouseDown(select);
|
||||
expect(screen.getAllByRole("option")).toHaveLength(2);
|
||||
|
||||
// Back button appears
|
||||
expect(screen.getByLabelText("show less")).toBeInTheDocument();
|
||||
|
||||
// Extended labels appear
|
||||
expect(screen.getAllByText("Repeat")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Notification")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Visible to")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Show me as")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("fills start from selectedRange", () => {
|
||||
@@ -147,6 +159,9 @@ describe("EventPopover", () => {
|
||||
});
|
||||
expect(screen.getByLabelText("Title")).toHaveValue("My Event");
|
||||
|
||||
// Click "Add description" button first
|
||||
fireEvent.click(screen.getByText("Add description"));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Description"), {
|
||||
target: { value: "Event Description" },
|
||||
});
|
||||
@@ -169,7 +184,9 @@ describe("EventPopover", () => {
|
||||
const option = await screen.findByText("Calendar 2");
|
||||
fireEvent.click(option);
|
||||
|
||||
expect(screen.getAllByRole("combobox")[0]).toHaveTextContent("Calendar 2");
|
||||
// Find the calendar combobox specifically by its aria-labelledby
|
||||
const calendarSelect = screen.getByRole("combobox", { name: /Calendar/i });
|
||||
expect(calendarSelect).toHaveTextContent("Calendar 2");
|
||||
});
|
||||
it("adds a attendee", async () => {
|
||||
jest.useFakeTimers();
|
||||
@@ -177,7 +194,7 @@ describe("EventPopover", () => {
|
||||
fireEvent.change(screen.getByLabelText("Title"), {
|
||||
target: { value: "newEvent" },
|
||||
});
|
||||
const select = screen.getByLabelText("Search user");
|
||||
const select = screen.getByLabelText("Start typing a name or email");
|
||||
|
||||
act(() => {
|
||||
select.focus();
|
||||
@@ -262,6 +279,8 @@ describe("EventPopover", () => {
|
||||
target: { value: newEvent.end.split("T")[0] },
|
||||
});
|
||||
|
||||
// Click "Add description" button first
|
||||
fireEvent.click(screen.getByText("Add description"));
|
||||
fireEvent.change(screen.getByLabelText("Description"), {
|
||||
target: { value: newEvent.description },
|
||||
});
|
||||
@@ -289,11 +308,13 @@ describe("EventPopover", () => {
|
||||
expect(receivedPayload.newEvent.title).toBe(newEvent.title);
|
||||
expect(receivedPayload.newEvent.description).toBe(newEvent.description);
|
||||
expect(
|
||||
formatDateToYYYYMMDDTHHMMSS(receivedPayload.newEvent.start).split("T")[0]
|
||||
formatDateToYYYYMMDDTHHMMSS(
|
||||
new Date(receivedPayload.newEvent.start)
|
||||
).split("T")[0]
|
||||
).toBe(formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.start)).split("T")[0]);
|
||||
expect(
|
||||
formatDateToYYYYMMDDTHHMMSS(
|
||||
receivedPayload.newEvent.end || new Date()
|
||||
new Date(receivedPayload.newEvent.end || new Date())
|
||||
).split("T")[0]
|
||||
).toBe(formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.end)).split("T")[0]);
|
||||
expect(receivedPayload.newEvent.location).toBe(newEvent.location);
|
||||
|
||||
@@ -7,4 +7,5 @@ var SSO_CODE_CHALLENGE_METHOD = "S256";
|
||||
var SSO_POST_LOGOUT_REDIRECT = "http://example.com?logout=1";
|
||||
var CALENDAR_BASE_URL = "https://calendar.example.com";
|
||||
var MAIL_SPA_URL = "https://mail.example.com";
|
||||
var VIDEO_CONFERENCE_BASE_URL = "https://meet.linagora.com";
|
||||
var DEBUG = false;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { searchUsers } from "../../features/User/userAPI";
|
||||
import { userAttendee } from "../../features/User/userDataTypes";
|
||||
import { PeopleSearch, User } from "./PeopleSearch";
|
||||
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Card from "@mui/material/Card";
|
||||
import CardActions from "@mui/material/CardActions";
|
||||
import CardContent from "@mui/material/CardContent";
|
||||
import CardHeader from "@mui/material/CardHeader";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import ListItem from "@mui/material/ListItem";
|
||||
import ListItemAvatar from "@mui/material/ListItemAvatar";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import Modal from "@mui/material/Modal";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useState, useEffect } from "react";
|
||||
import { searchUsers } from "../../features/User/userAPI";
|
||||
import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined";
|
||||
@@ -102,8 +92,8 @@ export function PeopleSearch({
|
||||
{...params}
|
||||
error={!!error}
|
||||
helperText={error}
|
||||
placeholder="Search user"
|
||||
label="Search user"
|
||||
placeholder="Start typing a name or email"
|
||||
label="Start typing a name or email"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && onToggleEventPreview) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -11,29 +11,18 @@ import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||
import EventPopover from "../../features/Events/EventModal";
|
||||
import { CalendarEvent } from "../../features/Events/EventsTypes";
|
||||
import CalendarSelection from "./CalendarSelection";
|
||||
import {
|
||||
getCalendarDetailAsync,
|
||||
getEventAsync,
|
||||
putEventAsync,
|
||||
updateEventLocal,
|
||||
} from "../../features/Calendars/CalendarSlice";
|
||||
import { getCalendarDetailAsync } from "../../features/Calendars/CalendarSlice";
|
||||
import ImportAlert from "../../features/Events/ImportAlert";
|
||||
import {
|
||||
computeStartOfTheWeek,
|
||||
formatDateToYYYYMMDDTHHMMSS,
|
||||
getCalendarRange,
|
||||
getDeltaInMilliseconds,
|
||||
} from "../../utils/dateUtils";
|
||||
import { Calendars } from "../../features/Calendars/CalendarTypes";
|
||||
import { push } from "redux-first-history";
|
||||
import EventPreviewModal from "../../features/Events/EventDisplayPreview";
|
||||
import { createSelector } from "@reduxjs/toolkit";
|
||||
import HelpOutlineIcon from "@mui/icons-material/HelpOutline";
|
||||
import ClearIcon from "@mui/icons-material/Clear";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import AccessTimeIcon from "@mui/icons-material/AccessTime";
|
||||
import LockIcon from "@mui/icons-material/Lock";
|
||||
import { userAttendee } from "../../features/User/userDataTypes";
|
||||
import { TempCalendarsInput } from "./TempCalendarsInput";
|
||||
import Button from "@mui/material/Button";
|
||||
import {
|
||||
|
||||
@@ -16,8 +16,6 @@ export default function CalendarLayout() {
|
||||
const calendarRef = useRef<any>(null);
|
||||
const dispatch = useAppDispatch();
|
||||
const selectedCalendars = useAppSelector((state) => state.calendars.list);
|
||||
const userId =
|
||||
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
|
||||
const [currentDate, setCurrentDate] = useState<Date>(new Date());
|
||||
const [currentView, setCurrentView] = useState<string>("timeGridWeek");
|
||||
|
||||
|
||||
@@ -79,20 +79,6 @@ function CalendarPopover({
|
||||
}
|
||||
};
|
||||
|
||||
const palette = [
|
||||
"#D50000",
|
||||
"#E67C73",
|
||||
"#F4511E",
|
||||
"#F6BF26",
|
||||
"#33B679",
|
||||
"#0B8043",
|
||||
"#039BE5",
|
||||
"#3F51B5",
|
||||
"#7986CB",
|
||||
"#8E24AA",
|
||||
"#616161",
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={(e, reason) => onClose(e, reason)}>
|
||||
<DialogTitle style={{ backgroundColor: color }}>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
@@ -7,17 +6,11 @@ import Card from "@mui/material/Card";
|
||||
import CardActions from "@mui/material/CardActions";
|
||||
import CardContent from "@mui/material/CardContent";
|
||||
import CardHeader from "@mui/material/CardHeader";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import ListItem from "@mui/material/ListItem";
|
||||
import ListItemAvatar from "@mui/material/ListItemAvatar";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import Modal from "@mui/material/Modal";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useState } from "react";
|
||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||
import { searchUsers } from "../../features/User/userAPI";
|
||||
import { getCalendars } from "../../features/Calendars/CalendarApi";
|
||||
import { addSharedCalendarAsync } from "../../features/Calendars/CalendarSlice";
|
||||
import { ColorPicker } from "./CalendarColorPicker";
|
||||
|
||||
@@ -8,8 +8,6 @@ import { useState } from "react";
|
||||
import CalendarPopover from "./CalendarModal";
|
||||
import { Calendars } from "../../features/Calendars/CalendarTypes";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
|
||||
import CalendarSearch from "./CalendarSearch";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
|
||||
@@ -224,3 +224,6 @@ tbody > tr.fc-scrollgrid-section:first-of-type .fc-scroller
|
||||
|
||||
.navigation-controls
|
||||
margin-right 40px
|
||||
|
||||
.fc .fc-timegrid-slot-minor
|
||||
border-top-style: none
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useMemo } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||
import {
|
||||
getTempCalendarsListAsync,
|
||||
|
||||
@@ -47,14 +47,14 @@ export const createMouseHandlers = (props: MouseHandlersProps) => {
|
||||
if (targetColumn) {
|
||||
const rect = targetColumn.getBoundingClientRect();
|
||||
const relativeY = e.clientY - rect.top;
|
||||
const hourHeight = rect.height / 24;
|
||||
const hourIndex = Math.floor(relativeY / hourHeight);
|
||||
const slotHeight = rect.height / 48;
|
||||
const slotIndex = Math.floor(relativeY / slotHeight);
|
||||
|
||||
if (relativeY >= 0 && relativeY <= rect.height) {
|
||||
const highlight = document.createElement("div");
|
||||
highlight.className = "hour-highlight";
|
||||
highlight.style.top = `${hourIndex * hourHeight}px`;
|
||||
highlight.style.height = `${hourHeight}px`;
|
||||
highlight.style.top = `${slotIndex * slotHeight}px`;
|
||||
highlight.style.height = `${slotHeight}px`;
|
||||
|
||||
(targetColumn as HTMLElement).style.position = "relative";
|
||||
targetColumn.appendChild(highlight);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { CalendarApi } from "@fullcalendar/core";
|
||||
import {
|
||||
createEventHandlers,
|
||||
EventHandlersProps,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { CalendarApi } from "@fullcalendar/core";
|
||||
import {
|
||||
createViewHandlers,
|
||||
ViewHandlersProps,
|
||||
|
||||
@@ -5,6 +5,16 @@ import { getCalendarDetailAsync } from "../../../features/Calendars/CalendarSlic
|
||||
|
||||
export const updateSlotLabelVisibility = (currentTime: Date) => {
|
||||
const slotLabels = document.querySelectorAll(".fc-timegrid-slot-label");
|
||||
const isCurrentWeekOrDay = checkIfCurrentWeekOrDay();
|
||||
|
||||
if (!isCurrentWeekOrDay) {
|
||||
slotLabels.forEach((label) => {
|
||||
const labelElement = label as HTMLElement;
|
||||
labelElement.style.opacity = "1";
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const currentMinutes = currentTime.getHours() * 60 + currentTime.getMinutes();
|
||||
|
||||
slotLabels.forEach((label) => {
|
||||
@@ -30,6 +40,19 @@ export const updateSlotLabelVisibility = (currentTime: Date) => {
|
||||
});
|
||||
};
|
||||
|
||||
const checkIfCurrentWeekOrDay = (): boolean => {
|
||||
const todayColumn = document.querySelector(".fc-day-today");
|
||||
|
||||
if (!todayColumn) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nowIndicator = document.querySelector(
|
||||
".fc-timegrid-now-indicator-arrow"
|
||||
);
|
||||
return !!nowIndicator;
|
||||
};
|
||||
|
||||
export const eventToFullCalendarFormat = (
|
||||
filteredEvents: CalendarEvent[],
|
||||
filteredTempEvents: CalendarEvent[],
|
||||
@@ -51,7 +74,7 @@ export const extractEvents = (
|
||||
) => {
|
||||
let filteredEvents: CalendarEvent[] = [];
|
||||
selectedCalendars.forEach((id) => {
|
||||
if (calendars[id].events) {
|
||||
if (calendars[id] && calendars[id].events) {
|
||||
filteredEvents = filteredEvents
|
||||
.concat(
|
||||
Object.keys(calendars[id].events).map(
|
||||
@@ -95,16 +118,18 @@ export const updateCalsDetails = (
|
||||
|
||||
if (rangeKey !== previousRangeKey) {
|
||||
selectedCalendars?.forEach((id) => {
|
||||
dispatch(
|
||||
getCalendarDetailAsync({
|
||||
calId: id,
|
||||
match: {
|
||||
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
|
||||
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
|
||||
},
|
||||
calType,
|
||||
})
|
||||
);
|
||||
if (id) {
|
||||
dispatch(
|
||||
getCalendarDetailAsync({
|
||||
calId: id,
|
||||
match: {
|
||||
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
|
||||
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
|
||||
},
|
||||
calType,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
# ResponsiveDialog Component
|
||||
|
||||
A highly reusable dialog component that supports both normal and expanded (fullscreen) modes while preserving app header visibility.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Two Modes**: Normal popup (685px) and expanded fullscreen mode
|
||||
- ✅ **Preserves Header**: Expanded mode doesn't cover app header (90px default)
|
||||
- ✅ **Clean Expanded View**: No backdrop/shadow in expanded mode for seamless integration
|
||||
- ✅ **Instant Transition**: No animation when expanding for immediate feedback
|
||||
- ✅ **Back Navigation**: Expanded mode shows back arrow icon in header for easy collapse
|
||||
- ✅ **MUI Stack Spacing**: Uses MUI Stack component with configurable spacing prop (2=16px normal, 3=24px expanded)
|
||||
- ✅ **Fully Customizable**: Override styles with `sx`, `contentSx`, `titleSx` props
|
||||
- ✅ **Container Support**: Content container with configurable max-width
|
||||
- ✅ **Type Safe**: Full TypeScript support
|
||||
- ✅ **No Custom CSS**: Uses MUI `sx` prop pattern
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```tsx
|
||||
import { ResponsiveDialog } from "../../components/Dialog";
|
||||
import { useState } from "react";
|
||||
|
||||
function MyComponent() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
{!showMore && (
|
||||
<Button onClick={() => setShowMore(true)}>Show More</Button>
|
||||
)}
|
||||
<Button onClick={() => setOpen(false)}>Close</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title="My Dialog"
|
||||
isExpanded={showMore}
|
||||
onExpandToggle={() => setShowMore(!showMore)}
|
||||
actions={actions}
|
||||
>
|
||||
<TextField label="Name" fullWidth />
|
||||
<TextField label="Email" fullWidth />
|
||||
{/* Wrapped in Stack with spacing={2} (normal) or spacing={3} (expanded) */}
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------------------- | --------------------- | --------- | ----------------------------------------------------------- |
|
||||
| `open` | `boolean` | required | Whether dialog is open |
|
||||
| `onClose` | `() => void` | required | Close handler |
|
||||
| `title` | `string \| ReactNode` | required | Dialog title (replaced by back icon when expanded) |
|
||||
| `children` | `ReactNode` | required | Dialog content (wrapped in MUI Stack) |
|
||||
| `actions` | `ReactNode` | - | Action buttons |
|
||||
| `isExpanded` | `boolean` | `false` | Toggle fullscreen mode |
|
||||
| `onExpandToggle` | `() => void` | - | Handler for back button click when expanded |
|
||||
| `normalMaxWidth` | `string` | `"685px"` | Max width in normal mode |
|
||||
| `expandedContentMaxWidth` | `string` | `"990px"` | Content container max-width in expanded mode |
|
||||
| `headerHeight` | `string` | `"90px"` | App header height to preserve |
|
||||
| `normalSpacing` | `number` | `2` | Stack spacing in normal mode (MUI spacing units: 1 = 8px) |
|
||||
| `expandedSpacing` | `number` | `3` | Stack spacing in expanded mode (MUI spacing units: 1 = 8px) |
|
||||
| `contentSx` | `SxProps<Theme>` | - | Custom styles for DialogContent |
|
||||
| `titleSx` | `SxProps<Theme>` | - | Custom styles for DialogTitle |
|
||||
| `dialogContentProps` | `DialogContentProps` | - | Additional DialogContent props |
|
||||
| `dialogTitleProps` | `DialogTitleProps` | - | Additional DialogTitle props |
|
||||
| `dividers` | `boolean` | `false` | Show dividers between sections |
|
||||
|
||||
## Advanced Examples
|
||||
|
||||
### Custom Container Styles
|
||||
|
||||
```tsx
|
||||
<ResponsiveDialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title="Custom Styled Dialog"
|
||||
isExpanded={showMore}
|
||||
contentSx={{
|
||||
backgroundColor: "#f5f5f5",
|
||||
padding: 4,
|
||||
}}
|
||||
titleSx={{
|
||||
backgroundColor: "primary.main",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
<TextField label="Field" />
|
||||
</ResponsiveDialog>
|
||||
```
|
||||
|
||||
### With Dividers
|
||||
|
||||
```tsx
|
||||
<ResponsiveDialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title="Dialog with Dividers"
|
||||
dividers={true}
|
||||
actions={<Button>Save</Button>}
|
||||
>
|
||||
<TextField label="Content" />
|
||||
</ResponsiveDialog>
|
||||
```
|
||||
|
||||
### Different Sizes
|
||||
|
||||
```tsx
|
||||
<ResponsiveDialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title="Large Dialog"
|
||||
normalMaxWidth="900px"
|
||||
expandedContentMaxWidth="1200px"
|
||||
>
|
||||
<TextField label="Content" />
|
||||
</ResponsiveDialog>
|
||||
```
|
||||
|
||||
### Custom Spacing
|
||||
|
||||
```tsx
|
||||
<ResponsiveDialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title="Custom Spacing"
|
||||
normalSpacing={1} // 8px spacing in normal mode
|
||||
expandedSpacing={4} // 32px spacing in expanded mode
|
||||
>
|
||||
<TextField label="Field 1" />
|
||||
<TextField label="Field 2" />
|
||||
{/* MUI spacing units: 1=8px, 2=16px, 3=24px, 4=32px */}
|
||||
</ResponsiveDialog>
|
||||
```
|
||||
|
||||
### Custom Header Height
|
||||
|
||||
For apps with different header heights:
|
||||
|
||||
```tsx
|
||||
<ResponsiveDialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title="Custom Header Height"
|
||||
headerHeight="80px"
|
||||
isExpanded={true}
|
||||
>
|
||||
<TextField label="Content" />
|
||||
</ResponsiveDialog>
|
||||
```
|
||||
|
||||
## Layout Behavior
|
||||
|
||||
### Normal Mode (`isExpanded={false}`)
|
||||
|
||||
- Dialog: max-width = `normalMaxWidth` (default 685px)
|
||||
- Content: 100% width
|
||||
- Height: auto (fits content)
|
||||
- Position: centered with 32px margin
|
||||
- Children spacing: `normalSpacing={2}` (16px via MUI Stack component)
|
||||
|
||||
### Expanded Mode (`isExpanded={true}`)
|
||||
|
||||
- Dialog: full width, height = `calc(100vh - headerHeight)`
|
||||
- Content: max-width = `expandedContentMaxWidth` (default 990px), centered
|
||||
- Position: top = `headerHeight` (preserves header visibility - default 90px)
|
||||
- Backdrop: opacity = 0 (seamless integration with page)
|
||||
- Shadow: removed (no elevation in expanded mode)
|
||||
- Transition: disabled (instant expand/collapse for better UX)
|
||||
- Title: replaced by back arrow IconButton (calls `onExpandToggle`)
|
||||
- Children spacing: `expandedSpacing={3}` (24px via MUI Stack component)
|
||||
- Actions (MuiBox): max-width = `expandedContentMaxWidth` (990px), centered container with buttons right-aligned
|
||||
- padding: 0 12px
|
||||
- width: 100%
|
||||
- justifyContent: flex-end
|
||||
|
||||
## Style Merging
|
||||
|
||||
All `sx` props are **merged** with base styles, not overridden:
|
||||
|
||||
```tsx
|
||||
// Base styles are preserved, your styles are added
|
||||
<ResponsiveDialog
|
||||
contentSx={{
|
||||
padding: 5, // Adds to base styles
|
||||
}}
|
||||
>
|
||||
```
|
||||
|
||||
## TypeScript Support
|
||||
|
||||
Full type inference and validation:
|
||||
|
||||
```tsx
|
||||
import { ResponsiveDialog } from "../../components/Dialog";
|
||||
|
||||
// All props are type-checked
|
||||
<ResponsiveDialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title="Typed Dialog"
|
||||
// TypeScript will validate all props
|
||||
>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use `isExpanded` for Show More/Less**: Toggle between modes for better UX
|
||||
2. **Provide `onExpandToggle` handler**: Required for back button functionality in expanded mode
|
||||
3. **Hide expand button in actions when expanded**: Back arrow in header provides collapse action
|
||||
4. **Keep `normalMaxWidth` reasonable**: 685px works well for forms
|
||||
5. **Center content in expanded mode**: Default 990px provides good reading width
|
||||
6. **Preserve header**: Default 90px - adjust via `headerHeight` prop if needed
|
||||
7. **MUI Stack handles spacing**: Children wrapped in Stack with configurable spacing prop - override with `normalSpacing`/`expandedSpacing` if needed
|
||||
8. **Seamless expanded mode**: No backdrop/shadow/transition creates clean integration with page
|
||||
9. **Instant expand**: No animation when expanding provides immediate feedback
|
||||
10. **Customize with `sx` props**: Avoid creating custom CSS files
|
||||
|
||||
## See Also
|
||||
|
||||
- `EventModal.tsx` - Real-world example usage
|
||||
- MUI Dialog documentation: https://mui.com/material-ui/react-dialog/
|
||||
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentProps,
|
||||
DialogTitle,
|
||||
DialogTitleProps,
|
||||
DialogProps,
|
||||
IconButton,
|
||||
Stack,
|
||||
SxProps,
|
||||
Theme,
|
||||
} from "@mui/material";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import React, { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* ResponsiveDialog - A reusable dialog component that can switch between normal and expanded modes
|
||||
*
|
||||
* Features:
|
||||
* - Normal mode: Dialog with customizable max-width (default 685px)
|
||||
* - Expanded mode: Full height dialog (excluding app header) with centered content container
|
||||
* - Fully customizable with sx props for Dialog, DialogTitle, and DialogContent
|
||||
* - Preserves app header visibility in expanded mode
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ResponsiveDialog
|
||||
* open={open}
|
||||
* onClose={handleClose}
|
||||
* title="My Dialog"
|
||||
* isExpanded={showMore}
|
||||
* actions={<Button onClick={handleSave}>Save</Button>}
|
||||
* contentSx={{ padding: 3 }}
|
||||
* >
|
||||
* <TextField label="Name" />
|
||||
* </ResponsiveDialog>
|
||||
* ```
|
||||
*/
|
||||
interface ResponsiveDialogProps
|
||||
extends Omit<DialogProps, "maxWidth" | "title"> {
|
||||
/** Whether the dialog is open */
|
||||
open: boolean;
|
||||
/** Callback fired when the dialog should be closed */
|
||||
onClose: () => void;
|
||||
/** Dialog title - can be string or custom ReactNode */
|
||||
title: string | ReactNode;
|
||||
/** Dialog content - form fields, text, etc. */
|
||||
children: ReactNode;
|
||||
/** Optional actions rendered in DialogActions (buttons, etc.) */
|
||||
actions?: ReactNode;
|
||||
/** Toggle between normal and expanded (fullscreen) mode */
|
||||
isExpanded?: boolean;
|
||||
/** Callback when expand/collapse button is clicked (required if using isExpanded) */
|
||||
onExpandToggle?: () => void;
|
||||
/** Max width in normal mode (default: "685px") */
|
||||
normalMaxWidth?: string;
|
||||
/** Max width of content container in expanded mode (default: "990px") */
|
||||
expandedContentMaxWidth?: string;
|
||||
/** Height of app header to preserve visibility (default: "90px") */
|
||||
headerHeight?: string;
|
||||
/** Spacing between children in normal mode (default: 2 = 16px) */
|
||||
normalSpacing?: number;
|
||||
/** Spacing between children in expanded mode (default: 3 = 24px) */
|
||||
expandedSpacing?: number;
|
||||
/** Custom styles for DialogContent - merged with base styles */
|
||||
contentSx?: SxProps<Theme>;
|
||||
/** Custom styles for DialogTitle */
|
||||
titleSx?: SxProps<Theme>;
|
||||
/** Additional props for DialogContent (excluding sx) */
|
||||
dialogContentProps?: Omit<DialogContentProps, "sx">;
|
||||
/** Additional props for DialogTitle (excluding sx) */
|
||||
dialogTitleProps?: Omit<DialogTitleProps, "sx">;
|
||||
/** Whether to display dividers between title/content/actions */
|
||||
dividers?: boolean;
|
||||
}
|
||||
|
||||
function ResponsiveDialog({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
actions,
|
||||
isExpanded = false,
|
||||
onExpandToggle,
|
||||
normalMaxWidth = "685px",
|
||||
expandedContentMaxWidth = "990px",
|
||||
headerHeight = "90px",
|
||||
normalSpacing = 2,
|
||||
expandedSpacing = 3,
|
||||
contentSx,
|
||||
titleSx,
|
||||
dialogContentProps,
|
||||
dialogTitleProps,
|
||||
dividers = false,
|
||||
sx,
|
||||
...otherDialogProps
|
||||
}: ResponsiveDialogProps) {
|
||||
const baseSx: SxProps<Theme> = {
|
||||
"& .MuiBackdrop-root": {
|
||||
opacity: isExpanded ? "0 !important" : undefined,
|
||||
transition: isExpanded ? "none !important" : undefined,
|
||||
},
|
||||
"& .MuiDialog-paper": {
|
||||
maxWidth: isExpanded ? "100%" : normalMaxWidth,
|
||||
width: "100%",
|
||||
height: isExpanded ? `calc(100vh - ${headerHeight})` : "auto",
|
||||
margin: isExpanded ? `${headerHeight} 0 0 0` : "32px",
|
||||
maxHeight: isExpanded
|
||||
? `calc(100vh - ${headerHeight})`
|
||||
: `calc(100vh - 90px)`,
|
||||
boxShadow: isExpanded ? "none !important" : undefined,
|
||||
transition: isExpanded ? "none !important" : undefined,
|
||||
},
|
||||
"& .MuiDialogActions-root .MuiBox-root": {
|
||||
maxWidth: isExpanded ? expandedContentMaxWidth : undefined,
|
||||
margin: isExpanded ? "0 auto" : undefined,
|
||||
padding: isExpanded ? "0 12px" : undefined,
|
||||
width: isExpanded ? "100%" : undefined,
|
||||
justifyContent: isExpanded ? "flex-end" : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const baseContentSx: SxProps<Theme> = {
|
||||
width: "100%",
|
||||
padding: isExpanded ? "16px" : undefined,
|
||||
};
|
||||
|
||||
const contentWrapperSx: SxProps<Theme> = {
|
||||
maxWidth: isExpanded ? expandedContentMaxWidth : "100%",
|
||||
margin: isExpanded ? "0 auto" : "0",
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
const currentSpacing = isExpanded ? expandedSpacing : normalSpacing;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth={false}
|
||||
fullWidth
|
||||
transitionDuration={isExpanded ? 0 : 300}
|
||||
sx={[baseSx, ...(Array.isArray(sx) ? sx : [sx])]}
|
||||
{...otherDialogProps}
|
||||
>
|
||||
<DialogTitle sx={titleSx} {...dialogTitleProps}>
|
||||
{isExpanded && onExpandToggle ? (
|
||||
<IconButton
|
||||
onClick={onExpandToggle}
|
||||
aria-label="show less"
|
||||
sx={{ marginLeft: "-8px" }}
|
||||
>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogContent
|
||||
dividers={dividers}
|
||||
sx={[
|
||||
baseContentSx,
|
||||
...(Array.isArray(contentSx) ? contentSx : [contentSx]),
|
||||
]}
|
||||
{...dialogContentProps}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<Stack spacing={currentSpacing} sx={contentWrapperSx}>
|
||||
{children}
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack spacing={currentSpacing}>{children}</Stack>
|
||||
)}
|
||||
</DialogContent>
|
||||
{actions && <DialogActions>{actions}</DialogActions>}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResponsiveDialog;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ResponsiveDialog } from "./ResponsiveDialog";
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
SelectChangeEvent,
|
||||
MenuItem,
|
||||
@@ -28,7 +27,6 @@ export default function RepeatEvent({
|
||||
setRepetition: Function;
|
||||
isOwn?: boolean;
|
||||
}) {
|
||||
const repetitionValues = ["day", "week", "month", "year"];
|
||||
const days = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
|
||||
const day = new Date(eventStart);
|
||||
|
||||
@@ -43,9 +41,11 @@ export default function RepeatEvent({
|
||||
|
||||
// keep endOption in sync if repetition changes from parent
|
||||
useEffect(() => {
|
||||
if (!endOption) {
|
||||
setEndOption(getEndOption());
|
||||
const newEndOption = getEndOption();
|
||||
if (endOption !== newEndOption) {
|
||||
setEndOption(newEndOption);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [repetition.occurrences, repetition.endDate]);
|
||||
|
||||
const handleDayChange = (day: string) => {
|
||||
@@ -57,172 +57,154 @@ export default function RepeatEvent({
|
||||
};
|
||||
|
||||
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) => {
|
||||
if (e.target.value === "weekly") {
|
||||
setRepetition({
|
||||
...repetition,
|
||||
freq: e.target.value,
|
||||
selectedDays: [days[day.getDay() - 1]],
|
||||
});
|
||||
} else {
|
||||
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>
|
||||
{/* Interval */}
|
||||
<Box display="flex" alignItems="center" gap={2} mb={2}>
|
||||
<Typography>Interval:</Typography>
|
||||
<TextField
|
||||
type="number"
|
||||
value={repetition.interval ?? 1}
|
||||
onChange={(e) =>
|
||||
setRepetition({
|
||||
...repetition,
|
||||
interval: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
size="small"
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
<Typography>
|
||||
{
|
||||
repetitionValues[
|
||||
repetitionValues.findIndex((el) => el === repetition.freq)
|
||||
]
|
||||
}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Weekly selection */}
|
||||
{repetition.freq === "weekly" && (
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
On days:
|
||||
</Typography>
|
||||
<FormGroup row>
|
||||
{days.map((day) => (
|
||||
<FormControlLabel
|
||||
key={day}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={
|
||||
repetition.selectedDays?.includes(day) ?? false
|
||||
}
|
||||
onChange={() => handleDayChange(day)}
|
||||
/>
|
||||
}
|
||||
label={day}
|
||||
/>
|
||||
))}
|
||||
</FormGroup>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* End options */}
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom style={{ marginTop: 16 }}>
|
||||
End:
|
||||
</Typography>
|
||||
<RadioGroup
|
||||
value={endOption}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setEndOption(value);
|
||||
|
||||
if (value === "never") {
|
||||
setRepetition({ ...repetition, occurrences: 0, endDate: "" });
|
||||
}
|
||||
if (value === "after") {
|
||||
<Box>
|
||||
<Stack>
|
||||
{/* Interval */}
|
||||
<Box display="flex" alignItems="center" gap={2} mb={2}>
|
||||
<Typography>Repeat every</Typography>
|
||||
<TextField
|
||||
type="number"
|
||||
value={repetition.interval ?? 1}
|
||||
onChange={(e) =>
|
||||
setRepetition({
|
||||
...repetition,
|
||||
interval: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
size="small"
|
||||
style={{ width: 80 }}
|
||||
inputProps={{ min: 1 }}
|
||||
/>
|
||||
<FormControl size="small" style={{ minWidth: 120 }}>
|
||||
<Select
|
||||
value={repetition.freq ?? "daily"}
|
||||
onChange={(e: SelectChangeEvent) => {
|
||||
if (e.target.value === "weekly") {
|
||||
setRepetition({
|
||||
...repetition,
|
||||
occurrences: 0,
|
||||
endDate: "",
|
||||
});
|
||||
}
|
||||
if (value === "on") {
|
||||
setRepetition({
|
||||
...repetition,
|
||||
occurrences: 0,
|
||||
endDate: new Date().toISOString().slice(0, 16),
|
||||
freq: e.target.value,
|
||||
selectedDays: [days[day.getDay() - 1]],
|
||||
});
|
||||
} else {
|
||||
setRepetition({ ...repetition, freq: e.target.value });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FormControlLabel
|
||||
value="never"
|
||||
control={<Radio />}
|
||||
label="Never"
|
||||
/>
|
||||
<MenuItem value={"daily"}>Day(s)</MenuItem>
|
||||
<MenuItem value={"weekly"}>Week(s)</MenuItem>
|
||||
<MenuItem value={"monthly"}>Month(s)</MenuItem>
|
||||
<MenuItem value={"yearly"}>Year(s)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<FormControlLabel
|
||||
value="after"
|
||||
control={<Radio />}
|
||||
label={
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
After
|
||||
<TextField
|
||||
type="number"
|
||||
size="small"
|
||||
value={repetition.occurrences ?? 0}
|
||||
onChange={(e) =>
|
||||
setRepetition({
|
||||
...repetition,
|
||||
endDate: "",
|
||||
occurrences: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
style={{ width: 100 }}
|
||||
inputProps={{ min: 1 }}
|
||||
disabled={endOption !== "after"}
|
||||
{/* Weekly selection */}
|
||||
{repetition.freq === "weekly" && (
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
Repeat on:
|
||||
</Typography>
|
||||
<FormGroup row>
|
||||
{days.map((day) => (
|
||||
<FormControlLabel
|
||||
key={day}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={repetition.selectedDays?.includes(day) ?? false}
|
||||
onChange={() => handleDayChange(day)}
|
||||
/>
|
||||
occurrences
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
value="on"
|
||||
control={<Radio />}
|
||||
label={
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
On
|
||||
<TextField
|
||||
type="date"
|
||||
inputProps={{ "data-testid": "end-date" }}
|
||||
size="small"
|
||||
value={repetition.endDate ?? ""}
|
||||
onChange={(e) =>
|
||||
setRepetition({
|
||||
...repetition,
|
||||
occurrences: 0,
|
||||
endDate: e.target.value,
|
||||
})
|
||||
}
|
||||
disabled={endOption !== "on"}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</RadioGroup>
|
||||
}
|
||||
label={day}
|
||||
/>
|
||||
))}
|
||||
</FormGroup>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{/* End options */}
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
End:
|
||||
</Typography>
|
||||
<RadioGroup
|
||||
value={endOption}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setEndOption(value);
|
||||
|
||||
if (value === "never") {
|
||||
setRepetition({ ...repetition, occurrences: 0, endDate: "" });
|
||||
}
|
||||
if (value === "after") {
|
||||
setRepetition({
|
||||
...repetition,
|
||||
occurrences: 0,
|
||||
endDate: "",
|
||||
});
|
||||
}
|
||||
if (value === "on") {
|
||||
setRepetition({
|
||||
...repetition,
|
||||
occurrences: 0,
|
||||
endDate: new Date().toISOString().slice(0, 16),
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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={repetition.occurrences ?? 0}
|
||||
onChange={(e) =>
|
||||
setRepetition({
|
||||
...repetition,
|
||||
endDate: "",
|
||||
occurrences: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
style={{ 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"
|
||||
inputProps={{ "data-testid": "end-date" }}
|
||||
size="small"
|
||||
value={repetition.endDate ?? ""}
|
||||
onChange={(e) =>
|
||||
setRepetition({
|
||||
...repetition,
|
||||
occurrences: 0,
|
||||
endDate: e.target.value,
|
||||
})
|
||||
}
|
||||
disabled={endOption !== "on"}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,10 +98,6 @@ export function Menubar({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
import { getOpenPaasUser, getUserDetails } from "../User/userAPI";
|
||||
import { parseCalendarEvent } from "../Events/eventUtils";
|
||||
import { deleteEvent, getEvent, moveEvent, putEvent } from "../Events/EventApi";
|
||||
import { formatDateToYYYYMMDDTHHMMSS } from "../../utils/dateUtils";
|
||||
import {
|
||||
formatDateToYYYYMMDDTHHMMSS,
|
||||
computeWeekRange,
|
||||
} from "../../utils/dateUtils";
|
||||
import { User } from "../../components/Attendees/PeopleSearch";
|
||||
|
||||
export const getCalendarsListAsync = createAsyncThunk<
|
||||
@@ -121,18 +124,11 @@ export const putEventAsync = createAsyncThunk<
|
||||
{ calId: string; events: CalendarEvent[]; calType?: "temp" }, // Return type
|
||||
{ cal: Calendars; newEvent: CalendarEvent; calType?: "temp" } // Arg type
|
||||
>("calendars/putEvent", async ({ cal, newEvent, calType }) => {
|
||||
const response = await putEvent(
|
||||
newEvent,
|
||||
cal.ownerEmails ? cal.ownerEmails[0] : undefined
|
||||
);
|
||||
await putEvent(newEvent, cal.ownerEmails ? cal.ownerEmails[0] : undefined);
|
||||
const eventDate = new Date(newEvent.start);
|
||||
|
||||
const weekStart = new Date(eventDate);
|
||||
weekStart.setHours(0, 0, 0, 0);
|
||||
weekStart.setDate(eventDate.getDate() - eventDate.getDay());
|
||||
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekStart.getDate() + 7);
|
||||
// Calculate week range based on Monday as first day (consistent with FullCalendar firstDay={1})
|
||||
const { start: weekStart, end: weekEnd } = computeWeekRange(eventDate);
|
||||
|
||||
const calEvents = (await getCalendar(cal.id, {
|
||||
start: formatDateToYYYYMMDDTHHMMSS(weekStart),
|
||||
@@ -184,7 +180,7 @@ export const patchCalendarAsync = createAsyncThunk<
|
||||
patch: { name: string; desc: string; color: string };
|
||||
} // Arg type
|
||||
>("calendars/patchCalendar", async ({ calId, calLink, patch }) => {
|
||||
const response = await proppatchCalendar(calLink, patch);
|
||||
await proppatchCalendar(calLink, patch);
|
||||
return {
|
||||
calId,
|
||||
calLink,
|
||||
@@ -201,7 +197,7 @@ export const removeCalendarAsync = createAsyncThunk<
|
||||
calLink: string;
|
||||
}
|
||||
>("calendars/removeCalendar", async ({ calId, calLink }) => {
|
||||
const response = await removeCalendar(calLink);
|
||||
await removeCalendar(calLink);
|
||||
return {
|
||||
calId,
|
||||
calLink,
|
||||
@@ -212,12 +208,15 @@ 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);
|
||||
await moveEvent(newEvent, newURL);
|
||||
|
||||
// Calculate week range based on Monday as first day (consistent with FullCalendar firstDay={1})
|
||||
const eventDate = new Date(newEvent.start);
|
||||
const { start: weekStart, end: weekEnd } = computeWeekRange(eventDate);
|
||||
|
||||
const calEvents = (await getCalendar(cal.id, {
|
||||
start: formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.start)),
|
||||
end: formatDateToYYYYMMDDTHHMMSS(
|
||||
new Date(new Date(newEvent.start).getTime() + 86400000)
|
||||
),
|
||||
start: formatDateToYYYYMMDDTHHMMSS(weekStart),
|
||||
end: formatDateToYYYYMMDDTHHMMSS(weekEnd),
|
||||
})) as Record<string, any>;
|
||||
const events: CalendarEvent[] = calEvents._embedded["dav:item"].flatMap(
|
||||
(eventdata: any) => {
|
||||
@@ -239,7 +238,7 @@ export const deleteEventAsync = createAsyncThunk<
|
||||
{ calId: string; eventId: string }, // Return type
|
||||
{ calId: string; eventId: string; eventURL: string } // Arg type
|
||||
>("calendars/delEvent", async ({ calId, eventId, eventURL }) => {
|
||||
const response = await deleteEvent(eventURL);
|
||||
await deleteEvent(eventURL);
|
||||
return { calId, eventId };
|
||||
});
|
||||
|
||||
@@ -255,7 +254,7 @@ export const createCalendarAsync = createAsyncThunk<
|
||||
}, // Return type
|
||||
{ userId: string; calId: string; color: string; name: string; desc: string } // Arg type
|
||||
>("calendars/createCalendar", async ({ userId, calId, color, name, desc }) => {
|
||||
const response = await postCalendar(userId, calId, color, name, desc);
|
||||
await postCalendar(userId, calId, color, name, desc);
|
||||
const ownerData: any = await getUserDetails(userId.split("/")[0]);
|
||||
|
||||
return {
|
||||
@@ -283,7 +282,7 @@ export const addSharedCalendarAsync = createAsyncThunk<
|
||||
}, // Return type
|
||||
{ userId: string; calId: string; cal: Record<string, any> } // Arg type
|
||||
>("calendars/addSharedCalendar", async ({ userId, calId, cal }) => {
|
||||
const response = await addSharedCalendar(userId, calId, cal);
|
||||
await addSharedCalendar(userId, calId, cal);
|
||||
const ownerData: any = await getUserDetails(
|
||||
cal.cal._links.self.href
|
||||
.replace("/calendars/", "")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
deleteEventAsync,
|
||||
getEventAsync,
|
||||
moveEventAsync,
|
||||
putEventAsync,
|
||||
removeEvent,
|
||||
@@ -9,7 +8,6 @@ import {
|
||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||
import AttendeeSelector from "../../components/Attendees/AttendeeSearch";
|
||||
import {
|
||||
Popover,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
@@ -41,7 +39,6 @@ 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 { TIMEZONES } from "../../utils/timezone-data";
|
||||
import { Calendars } from "../Calendars/CalendarTypes";
|
||||
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
|
||||
import { isValidUrl } from "../../utils/apiUtils";
|
||||
@@ -94,7 +91,7 @@ export default function EventDisplayModal({
|
||||
const [alarm, setAlarm] = useState(event?.alarm?.trigger ?? "");
|
||||
const [busy, setBusy] = useState(event?.transp ?? "OPAQUE");
|
||||
const [eventClass, setEventClass] = useState(event?.class ?? "PUBLIC");
|
||||
const [timezone, setTimezone] = useState(event?.timezone ?? "UTC");
|
||||
const [timezone] = useState(event?.timezone ?? "UTC");
|
||||
const [newCalId, setNewCalId] = useState(event?.calId);
|
||||
const [calendarid, setCalendarid] = useState(
|
||||
calId.split("/")[0] === user.userData?.openpaasId
|
||||
@@ -469,17 +466,17 @@ export default function EventDisplayModal({
|
||||
isOwn={isOwn}
|
||||
/>
|
||||
<FormControl fullWidth margin="dense" size="small">
|
||||
<InputLabel id="alarm">Alarm</InputLabel>
|
||||
<InputLabel id="notification">Notification</InputLabel>
|
||||
<Select
|
||||
labelId="alarm"
|
||||
label="Alarm"
|
||||
labelId="notification"
|
||||
label="Notification"
|
||||
value={alarm}
|
||||
disabled={!isOwn}
|
||||
onChange={(e: SelectChangeEvent) =>
|
||||
setAlarm(e.target.value)
|
||||
}
|
||||
>
|
||||
<MenuItem value={""}>No Alarm</MenuItem>
|
||||
<MenuItem value={""}>No Notification</MenuItem>
|
||||
<MenuItem value={"-PT1M"}>1 minute</MenuItem>
|
||||
<MenuItem value={"-PT5M"}>2 minutes</MenuItem>
|
||||
<MenuItem value={"-PT10M"}>10 minutes</MenuItem>
|
||||
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
CardContent,
|
||||
Divider,
|
||||
IconButton,
|
||||
Avatar,
|
||||
Badge,
|
||||
PopoverPosition,
|
||||
} from "@mui/material";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
@@ -29,18 +27,13 @@ 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 FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
|
||||
import { userAttendee } from "../User/userDataTypes";
|
||||
import EventDisplayModal, {
|
||||
InfoRow,
|
||||
renderAttendeeBadge,
|
||||
stringAvatar,
|
||||
} from "./EventDisplay";
|
||||
import { dlEvent, getEvent } from "./EventApi";
|
||||
import { CalendarEvent } from "./EventsTypes";
|
||||
import { dlEvent } from "./EventApi";
|
||||
import EventDuplication from "../../components/Event/EventDuplicate";
|
||||
import { getCalendar } from "../Calendars/CalendarApi";
|
||||
|
||||
export default function EventPreviewModal({
|
||||
eventId,
|
||||
|
||||
+647
-226
@@ -2,29 +2,100 @@ import { CalendarApi, DateSelectArg } from "@fullcalendar/core";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardActions,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Popover,
|
||||
Select,
|
||||
SelectChangeEvent,
|
||||
TextField,
|
||||
Typography,
|
||||
ToggleButtonGroup,
|
||||
ToggleButton,
|
||||
} from "@mui/material";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Description as DescriptionIcon,
|
||||
Public as PublicIcon,
|
||||
Lock as LockIcon,
|
||||
CameraAlt as VideocamIcon,
|
||||
ContentCopy as CopyIcon,
|
||||
Close as DeleteIcon,
|
||||
} from "@mui/icons-material";
|
||||
import React, {
|
||||
useEffect,
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||
import AttendeeSelector from "../../components/Attendees/AttendeeSearch";
|
||||
import { TIMEZONES } from "../../utils/timezone-data";
|
||||
import { ResponsiveDialog } from "../../components/Dialog";
|
||||
import { putEventAsync } from "../Calendars/CalendarSlice";
|
||||
import { Calendars } from "../Calendars/CalendarTypes";
|
||||
import { userAttendee } from "../User/userDataTypes";
|
||||
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
|
||||
import { createSelector } from "@reduxjs/toolkit";
|
||||
import RepeatEvent from "../../components/Event/EventRepeat";
|
||||
import { TIMEZONES } from "../../utils/timezone-data";
|
||||
import {
|
||||
generateMeetingLink,
|
||||
addVideoConferenceToDescription,
|
||||
} from "../../utils/videoConferenceUtils";
|
||||
|
||||
// Helper component for field with label
|
||||
const FieldWithLabel = React.memo(
|
||||
({
|
||||
label,
|
||||
isExpanded,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
isExpanded: boolean;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
if (!isExpanded) {
|
||||
// Normal mode: label on top
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
component="label"
|
||||
sx={{
|
||||
display: "block",
|
||||
marginBottom: "4px",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Extended mode: label on left
|
||||
return (
|
||||
<Box display="flex" alignItems="center">
|
||||
<Typography
|
||||
component="label"
|
||||
sx={{
|
||||
minWidth: "115px",
|
||||
marginRight: "12px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Box flexGrow={1}>{children}</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FieldWithLabel.displayName = "FieldWithLabel";
|
||||
|
||||
function EventPopover({
|
||||
anchorEl,
|
||||
@@ -63,8 +134,52 @@ function EventPopover({
|
||||
const userPersonnalCalendars: Calendars[] = useAppSelector(
|
||||
selectPersonnalCalendars
|
||||
);
|
||||
const timezones = TIMEZONES.aliases;
|
||||
|
||||
// Helper function to resolve timezone aliases
|
||||
const resolveTimezone = (tzName: string): string => {
|
||||
if (TIMEZONES.zones[tzName]) {
|
||||
return tzName;
|
||||
}
|
||||
if (TIMEZONES.aliases[tzName]) {
|
||||
return TIMEZONES.aliases[tzName].aliasTo;
|
||||
}
|
||||
return tzName;
|
||||
};
|
||||
|
||||
const timezoneList = useMemo(() => {
|
||||
const zones = Object.keys(TIMEZONES.zones).sort();
|
||||
const browserTz = resolveTimezone(
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
);
|
||||
|
||||
const getTimezoneOffset = (tzName: string): string => {
|
||||
const resolvedTz = resolveTimezone(tzName);
|
||||
const tzData = TIMEZONES.zones[resolvedTz];
|
||||
if (!tzData) return "";
|
||||
|
||||
const icsMatch = tzData.ics.match(/TZOFFSETTO:([+-]\d{4})/);
|
||||
if (!icsMatch) return "";
|
||||
|
||||
const offset = icsMatch[1];
|
||||
const hours = parseInt(offset.slice(0, 3));
|
||||
const minutes = parseInt(offset.slice(3));
|
||||
|
||||
if (minutes === 0) {
|
||||
return `UTC${hours >= 0 ? "+" : ""}${hours}`;
|
||||
}
|
||||
return `UTC${hours >= 0 ? "+" : ""}${hours}:${Math.abs(minutes).toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
return { zones, browserTz, getTimezoneOffset };
|
||||
}, []);
|
||||
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
const [showDescription, setShowDescription] = useState(
|
||||
event?.description ? true : false
|
||||
);
|
||||
const [showRepeat, setShowRepeat] = useState(
|
||||
event?.repetition?.freq ? true : false
|
||||
);
|
||||
|
||||
const [title, setTitle] = useState(event?.title ?? "");
|
||||
|
||||
@@ -89,10 +204,47 @@ function EventPopover({
|
||||
const [alarm, setAlarm] = useState(event?.alarm?.trigger ?? "");
|
||||
const [eventClass, setEventClass] = useState(event?.class ?? "PUBLIC");
|
||||
const [busy, setBusy] = useState(event?.transp ?? "OPAQUE");
|
||||
|
||||
const [important, setImportant] = useState(false);
|
||||
const [timezone, setTimezone] = useState(
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
event?.timezone ? resolveTimezone(event.timezone) : timezoneList.browserTz
|
||||
);
|
||||
const [hasVideoConference, setHasVideoConference] = useState(
|
||||
event?.x_openpass_videoconference ? true : false
|
||||
);
|
||||
const [meetingLink, setMeetingLink] = useState<string | null>(
|
||||
event?.x_openpass_videoconference || null
|
||||
);
|
||||
|
||||
// Use ref to track if we've already initialized to avoid infinite loop
|
||||
const isInitializedRef = useRef(false);
|
||||
const userPersonnalCalendarsRef = useRef(userPersonnalCalendars);
|
||||
|
||||
// Update ref when userPersonnalCalendars changes
|
||||
useEffect(() => {
|
||||
userPersonnalCalendarsRef.current = userPersonnalCalendars;
|
||||
}, [userPersonnalCalendars]);
|
||||
|
||||
const resetAllStateToDefault = useCallback(() => {
|
||||
setShowMore(false);
|
||||
setShowDescription(false);
|
||||
setShowRepeat(false);
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setAttendees([]);
|
||||
setLocation("");
|
||||
setStart("");
|
||||
setEnd("");
|
||||
setCalendarid(0);
|
||||
setAllDay(false);
|
||||
setRepetition({} as RepetitionObject);
|
||||
setAlarm("");
|
||||
setEventClass("PUBLIC");
|
||||
setBusy("OPAQUE");
|
||||
setImportant(false);
|
||||
setTimezone(timezoneList.browserTz);
|
||||
setHasVideoConference(false);
|
||||
setMeetingLink(null);
|
||||
}, [timezoneList.browserTz]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRange) {
|
||||
@@ -101,22 +253,124 @@ function EventPopover({
|
||||
}
|
||||
}, [selectedRange]);
|
||||
|
||||
// Initialize state when event prop changes
|
||||
useEffect(() => {
|
||||
setTitle(event?.title ?? "");
|
||||
setAttendees(
|
||||
event?.attendee
|
||||
? event.attendee.filter((a) => a.cal_address !== organizer?.cal_address)
|
||||
: []
|
||||
if (event) {
|
||||
// Editing existing event - populate fields with event data
|
||||
setTitle(event.title ?? "");
|
||||
setDescription(event.description ?? "");
|
||||
setLocation(event.location ?? "");
|
||||
setStart(event.start ? event.start : "");
|
||||
setEnd(event.end ? event.end : "");
|
||||
setCalendarid(
|
||||
event.calId
|
||||
? userPersonnalCalendarsRef.current.findIndex(
|
||||
(e) => e.id === event.calId
|
||||
)
|
||||
: 0
|
||||
);
|
||||
setAllDay(event.allday ?? false);
|
||||
setRepetition(event.repetition ?? ({} as RepetitionObject));
|
||||
setShowRepeat(event.repetition?.freq ? true : false);
|
||||
setAttendees(
|
||||
event.attendee
|
||||
? event.attendee.filter(
|
||||
(a) => a.cal_address !== organizer?.cal_address
|
||||
)
|
||||
: []
|
||||
);
|
||||
setAlarm(event.alarm?.trigger ?? "");
|
||||
setEventClass(event.class ?? "PUBLIC");
|
||||
setBusy(event.transp ?? "OPAQUE");
|
||||
setTimezone(
|
||||
event.timezone
|
||||
? resolveTimezone(event.timezone)
|
||||
: timezoneList.browserTz
|
||||
);
|
||||
setHasVideoConference(event.x_openpass_videoconference ? true : false);
|
||||
setMeetingLink(event.x_openpass_videoconference || null);
|
||||
|
||||
// Update description to include video conference footer if exists
|
||||
if (event.x_openpass_videoconference && event.description) {
|
||||
const hasVideoFooter = event.description.includes("Visio:");
|
||||
if (!hasVideoFooter) {
|
||||
setDescription(
|
||||
addVideoConferenceToDescription(
|
||||
event.description,
|
||||
event.x_openpass_videoconference
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setDescription(event.description);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [event, organizer?.cal_address, timezoneList.browserTz]);
|
||||
|
||||
// Reset state when creating new event (event is undefined)
|
||||
useEffect(() => {
|
||||
if (!event && isInitializedRef.current) {
|
||||
// Creating new event - reset all fields to default
|
||||
setShowMore(false);
|
||||
setShowDescription(false);
|
||||
setShowRepeat(false);
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setAttendees([]);
|
||||
setLocation("");
|
||||
setStart("");
|
||||
setEnd("");
|
||||
setCalendarid(0);
|
||||
setAllDay(false);
|
||||
setRepetition({} as RepetitionObject);
|
||||
setAlarm("");
|
||||
setEventClass("PUBLIC");
|
||||
setBusy("OPAQUE");
|
||||
setImportant(false);
|
||||
setTimezone(timezoneList.browserTz);
|
||||
setHasVideoConference(false);
|
||||
setMeetingLink(null);
|
||||
}
|
||||
isInitializedRef.current = true;
|
||||
}, [event, timezoneList.browserTz]);
|
||||
|
||||
const handleAddVideoConference = () => {
|
||||
const newMeetingLink = generateMeetingLink();
|
||||
const updatedDescription = addVideoConferenceToDescription(
|
||||
description,
|
||||
newMeetingLink
|
||||
);
|
||||
}, [event, organizer?.cal_address]);
|
||||
setDescription(updatedDescription);
|
||||
setHasVideoConference(true);
|
||||
setMeetingLink(newMeetingLink);
|
||||
};
|
||||
|
||||
const handleCopyMeetingLink = async () => {
|
||||
if (meetingLink) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(meetingLink);
|
||||
// You could add a toast notification here
|
||||
console.log("Meeting link copied to clipboard");
|
||||
} catch (err) {
|
||||
console.error("Failed to copy link:", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteVideoConference = () => {
|
||||
// Remove video conference footer from description
|
||||
const updatedDescription = description.replace(
|
||||
/\nVisio: https?:\/\/[^\s]+/,
|
||||
""
|
||||
);
|
||||
setDescription(updatedDescription);
|
||||
setHasVideoConference(false);
|
||||
setMeetingLink(null);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose({}, "backdropClick"); // Reset
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setAttendees([]);
|
||||
setLocation("");
|
||||
setCalendarid(0);
|
||||
onClose({}, "backdropClick");
|
||||
resetAllStateToDefault();
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -126,7 +380,7 @@ function EventPopover({
|
||||
calId: userPersonnalCalendars[calendarid].id,
|
||||
title,
|
||||
URL: `/calendars/${userPersonnalCalendars[calendarid].id}/${newEventUID}.ics`,
|
||||
start: new Date(start),
|
||||
start: new Date(start).toISOString(),
|
||||
allday,
|
||||
uid: newEventUID,
|
||||
description,
|
||||
@@ -148,153 +402,91 @@ function EventPopover({
|
||||
transp: busy,
|
||||
color: userPersonnalCalendars[calendarid]?.color,
|
||||
alarm: { trigger: alarm, action: "EMAIL" },
|
||||
x_openpass_videoconference: meetingLink || undefined,
|
||||
};
|
||||
if (end) {
|
||||
newEvent.end = new Date(end);
|
||||
newEvent.end = new Date(end).toISOString();
|
||||
}
|
||||
|
||||
if (attendees.length > 0) {
|
||||
newEvent.attendee = newEvent.attendee.concat(attendees);
|
||||
}
|
||||
|
||||
await dispatch(
|
||||
// Close popup immediately
|
||||
onClose({}, "backdropClick");
|
||||
|
||||
// Reset all state to default values
|
||||
resetAllStateToDefault();
|
||||
|
||||
// Save to API in background
|
||||
dispatch(
|
||||
putEventAsync({
|
||||
cal: userPersonnalCalendars[calendarid],
|
||||
newEvent,
|
||||
})
|
||||
);
|
||||
onClose({}, "backdropClick");
|
||||
|
||||
// Reset
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setAttendees([]);
|
||||
setLocation("");
|
||||
setCalendarid(0);
|
||||
};
|
||||
|
||||
const dialogActions = (
|
||||
<Box display="flex" justifyContent="space-between" width="100%" px={2}>
|
||||
{!showMore && (
|
||||
<Button onClick={() => setShowMore(!showMore)}>Show More</Button>
|
||||
)}
|
||||
<Box display="flex" gap={1} ml={showMore ? "auto" : 0}>
|
||||
<Button variant="outlined" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="contained" onClick={handleSave} disabled={!title}>
|
||||
Save
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
<ResponsiveDialog
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{
|
||||
vertical: "center",
|
||||
horizontal: "center",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "center",
|
||||
horizontal: "center",
|
||||
}}
|
||||
title={event?.uid ? "Duplicate Event" : "Create Event"}
|
||||
isExpanded={showMore}
|
||||
onExpandToggle={() => setShowMore(!showMore)}
|
||||
actions={dialogActions}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader title={event?.uid ? "Duplicate Event" : "Create Event"} />
|
||||
<CardContent
|
||||
style={{ 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>
|
||||
<FieldWithLabel label="Title" isExpanded={showMore}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={!showMore ? "Title" : ""}
|
||||
placeholder="Add title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
size="small"
|
||||
margin="dense"
|
||||
/>
|
||||
</FieldWithLabel>
|
||||
|
||||
<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);
|
||||
}}
|
||||
<FieldWithLabel label=" " isExpanded={showMore}>
|
||||
<Box display="flex" gap={1} mb={1}>
|
||||
<Button
|
||||
startIcon={<DescriptionIcon />}
|
||||
onClick={() => setShowDescription(true)}
|
||||
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);
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
color: "text.secondary",
|
||||
display: showDescription ? "none" : "flex",
|
||||
}}
|
||||
size="small"
|
||||
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));
|
||||
}
|
||||
>
|
||||
Add description
|
||||
</Button>
|
||||
</Box>
|
||||
</FieldWithLabel>
|
||||
|
||||
const newRange = {
|
||||
...selectedRange,
|
||||
startStr: allday ? start.split("T")[0] : start,
|
||||
endStr: allday
|
||||
? endDate.toISOString().split("T")[0]
|
||||
: endDate.toISOString(),
|
||||
start: new Date(allday ? start.split("T")[0] : start),
|
||||
end: new Date(
|
||||
allday
|
||||
? endDate.toISOString().split("T")[0]
|
||||
: endDate.toISOString()
|
||||
),
|
||||
allDay: allday,
|
||||
};
|
||||
setSelectedRange(newRange);
|
||||
}}
|
||||
/>
|
||||
All day
|
||||
</label>
|
||||
{showDescription && (
|
||||
<FieldWithLabel label="Description" isExpanded={showMore}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Description"
|
||||
label={!showMore ? "Description" : ""}
|
||||
placeholder="Add description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
size="small"
|
||||
@@ -302,92 +494,321 @@ function EventPopover({
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
<TextField
|
||||
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}
|
||||
eventStart={selectedRange?.start ?? new Date()}
|
||||
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>
|
||||
</FieldWithLabel>
|
||||
)}
|
||||
|
||||
<FormControl fullWidth margin="dense" size="small">
|
||||
<InputLabel id="Visibility">Visibility</InputLabel>
|
||||
<Select
|
||||
labelId="Visibility"
|
||||
label="Visibility"
|
||||
value={eventClass}
|
||||
onChange={(e: SelectChangeEvent) =>
|
||||
setEventClass(e.target.value)
|
||||
<FieldWithLabel label="Date & Time" isExpanded={showMore}>
|
||||
<Box display="flex" gap={2}>
|
||||
<Box flexGrow={1}>
|
||||
{showMore && (
|
||||
<Typography variant="caption" display="block" mb={0.5}>
|
||||
Start
|
||||
</Typography>
|
||||
)}
|
||||
<TextField
|
||||
fullWidth
|
||||
label={!showMore ? "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 }}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
{showMore && (
|
||||
<Typography variant="caption" display="block" mb={0.5}>
|
||||
End
|
||||
</Typography>
|
||||
)}
|
||||
<TextField
|
||||
fullWidth
|
||||
label={!showMore ? "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 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</FieldWithLabel>
|
||||
<FieldWithLabel label=" " isExpanded={showMore}>
|
||||
<Box display="flex" gap={2} alignItems="center">
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={important}
|
||||
onChange={() => setImportant(!important)}
|
||||
/>
|
||||
}
|
||||
label="Mark as important"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<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));
|
||||
}
|
||||
>
|
||||
<MenuItem value={"PUBLIC"}>Public</MenuItem>
|
||||
<MenuItem value={"CONFIDENTIAL"}>Show time only</MenuItem>
|
||||
<MenuItem value={"PRIVATE"}>Private</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth margin="dense" size="small">
|
||||
<InputLabel id="busy">is Busy</InputLabel>
|
||||
<Select
|
||||
labelId="busy"
|
||||
value={busy}
|
||||
label="is busy"
|
||||
onChange={(e: SelectChangeEvent) => setBusy(e.target.value)}
|
||||
>
|
||||
<MenuItem value={"TRANSPARENT"}>Free</MenuItem>
|
||||
<MenuItem value={"OPAQUE"}>Busy </MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
const newRange = {
|
||||
...selectedRange,
|
||||
startStr: allday ? start.split("T")[0] : start,
|
||||
endStr: allday
|
||||
? endDate.toISOString().split("T")[0]
|
||||
: endDate.toISOString(),
|
||||
start: new Date(allday ? start.split("T")[0] : start),
|
||||
end: new Date(
|
||||
allday
|
||||
? endDate.toISOString().split("T")[0]
|
||||
: endDate.toISOString()
|
||||
),
|
||||
allDay: allday,
|
||||
};
|
||||
setSelectedRange(newRange);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="All day"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={showRepeat}
|
||||
onChange={() => {
|
||||
const newShowRepeat = !showRepeat;
|
||||
setShowRepeat(newShowRepeat);
|
||||
if (newShowRepeat) {
|
||||
setRepetition({
|
||||
freq: "daily",
|
||||
interval: 1,
|
||||
occurrences: 0,
|
||||
endDate: "",
|
||||
selectedDays: [],
|
||||
} as RepetitionObject);
|
||||
} else {
|
||||
setRepetition({
|
||||
freq: "",
|
||||
interval: 1,
|
||||
occurrences: 0,
|
||||
endDate: "",
|
||||
selectedDays: [],
|
||||
} as RepetitionObject);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Repeat"
|
||||
/>
|
||||
<FormControl size="small" sx={{ width: 160 }}>
|
||||
<Select
|
||||
value={timezone}
|
||||
onChange={(e: SelectChangeEvent) => setTimezone(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
{timezoneList.zones.map((tz) => (
|
||||
<MenuItem key={tz} value={tz}>
|
||||
({timezoneList.getTimezoneOffset(tz)}) {tz.replace(/_/g, " ")}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</FieldWithLabel>
|
||||
|
||||
{showRepeat && (
|
||||
<FieldWithLabel label=" " isExpanded={showMore}>
|
||||
<RepeatEvent
|
||||
repetition={repetition}
|
||||
eventStart={selectedRange?.start ?? new Date()}
|
||||
setRepetition={setRepetition}
|
||||
/>
|
||||
</FieldWithLabel>
|
||||
)}
|
||||
|
||||
<FieldWithLabel label="Participants" isExpanded={showMore}>
|
||||
<AttendeeSelector attendees={attendees} setAttendees={setAttendees} />
|
||||
</FieldWithLabel>
|
||||
|
||||
<FieldWithLabel label="Video meeting" isExpanded={showMore}>
|
||||
<Box display="flex" gap={1} alignItems="center">
|
||||
<Button
|
||||
startIcon={<VideocamIcon />}
|
||||
onClick={handleAddVideoConference}
|
||||
size="medium"
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
color: "text.secondary",
|
||||
display: hasVideoConference ? "none" : "flex",
|
||||
}}
|
||||
>
|
||||
Add Visio conference
|
||||
</Button>
|
||||
|
||||
{hasVideoConference && meetingLink && (
|
||||
<>
|
||||
<Button
|
||||
startIcon={<VideocamIcon />}
|
||||
onClick={() => window.open(meetingLink, "_blank")}
|
||||
size="medium"
|
||||
variant="contained"
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
mr: 1,
|
||||
}}
|
||||
>
|
||||
Join Visio conference
|
||||
</Button>
|
||||
<IconButton
|
||||
onClick={handleCopyMeetingLink}
|
||||
size="small"
|
||||
sx={{ color: "primary.main" }}
|
||||
aria-label="Copy meeting link"
|
||||
title="Copy meeting link"
|
||||
>
|
||||
<CopyIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleDeleteVideoConference}
|
||||
size="small"
|
||||
sx={{ color: "error.main" }}
|
||||
aria-label="Remove video conference"
|
||||
title="Remove video conference"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Box>
|
||||
</FieldWithLabel>
|
||||
<FieldWithLabel label="Location" isExpanded={showMore}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={!showMore ? "Location" : ""}
|
||||
placeholder="Add location"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
size="small"
|
||||
margin="dense"
|
||||
/>
|
||||
</FieldWithLabel>
|
||||
<FieldWithLabel label="Calendar" isExpanded={showMore}>
|
||||
<FormControl fullWidth margin="dense" size="small">
|
||||
{!showMore && (
|
||||
<InputLabel id="calendar-select-label">Calendar</InputLabel>
|
||||
)}
|
||||
<Select
|
||||
labelId="calendar-select-label"
|
||||
value={calendarid.toString()}
|
||||
label={!showMore ? "Calendar" : ""}
|
||||
displayEmpty
|
||||
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>
|
||||
</FieldWithLabel>
|
||||
|
||||
<CardActions>
|
||||
<Box mt={2} display="flex" justifyContent="flex-end" gap={1}>
|
||||
<Button variant="outlined" onClick={handleClose}>
|
||||
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>
|
||||
{/* Extended options */}
|
||||
{showMore && (
|
||||
<>
|
||||
<FieldWithLabel label="Notification" isExpanded={showMore}>
|
||||
<FormControl fullWidth margin="dense" size="small">
|
||||
<Select
|
||||
labelId="notification"
|
||||
value={alarm}
|
||||
onChange={(e: SelectChangeEvent) => setAlarm(e.target.value)}
|
||||
>
|
||||
<MenuItem value={""}>No Notification</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>
|
||||
</FieldWithLabel>
|
||||
|
||||
<FieldWithLabel label="Show me as" isExpanded={showMore}>
|
||||
<FormControl fullWidth margin="dense" size="small">
|
||||
<Select
|
||||
labelId="busy"
|
||||
value={busy}
|
||||
onChange={(e: SelectChangeEvent) => setBusy(e.target.value)}
|
||||
>
|
||||
<MenuItem value={"TRANSPARENT"}>Free</MenuItem>
|
||||
<MenuItem value={"OPAQUE"}>Busy </MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FieldWithLabel>
|
||||
|
||||
<FieldWithLabel label="Visible to" isExpanded={showMore}>
|
||||
<ToggleButtonGroup
|
||||
value={eventClass}
|
||||
exclusive
|
||||
onChange={(e, newValue) => {
|
||||
if (newValue !== null) {
|
||||
setEventClass(newValue);
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="PUBLIC" sx={{ width: "140px" }}>
|
||||
<PublicIcon sx={{ mr: 1, fontSize: "16px" }} />
|
||||
All
|
||||
</ToggleButton>
|
||||
<ToggleButton value="PRIVATE" sx={{ width: "140px" }}>
|
||||
<LockIcon sx={{ mr: 1, fontSize: "16px" }} />
|
||||
Participants
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</FieldWithLabel>
|
||||
</>
|
||||
)}
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface RepetitionObject {
|
||||
freq: string;
|
||||
interval?: number;
|
||||
selectedDays?: string[];
|
||||
byday?: string[] | null;
|
||||
occurrences?: number;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Calendars } from "../Calendars/CalendarTypes";
|
||||
import { userAttendee } from "../User/userDataTypes";
|
||||
import { AlarmObject, CalendarEvent } from "./EventsTypes";
|
||||
import ICAL from "ical.js";
|
||||
@@ -16,7 +15,7 @@ export function parseCalendarEvent(
|
||||
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, , value] of data) {
|
||||
switch (key.toLowerCase()) {
|
||||
case "uid":
|
||||
event.uid = value;
|
||||
@@ -86,7 +85,7 @@ export function parseCalendarEvent(
|
||||
case "rrule":
|
||||
event.repetition = { freq: value.freq.toLowerCase() };
|
||||
if (value.byday) {
|
||||
event.repetition.selectedDays = value.byday;
|
||||
event.repetition.byday = value.byday;
|
||||
}
|
||||
if (value.until) {
|
||||
event.repetition.endDate = value.until;
|
||||
@@ -106,7 +105,7 @@ export function parseCalendarEvent(
|
||||
|
||||
if (valarm) {
|
||||
event.alarm = {} as AlarmObject;
|
||||
for (const [key, params, type, value] of valarm[1]) {
|
||||
for (const [key, , , value] of valarm[1]) {
|
||||
switch (key.toLowerCase()) {
|
||||
case "action":
|
||||
event.alarm.action = value;
|
||||
@@ -175,14 +174,20 @@ export function calendarEventToJCal(
|
||||
vevent.push([]);
|
||||
|
||||
if (event.end) {
|
||||
if (event.allday && event.end.getTime() === event.start.getTime()) {
|
||||
event.end.setDate(event.start.getDate() + 1);
|
||||
const startDate = new Date(event.start);
|
||||
const endDate = new Date(event.end);
|
||||
let finalEndDate = endDate;
|
||||
|
||||
if (event.allday && endDate.getTime() === startDate.getTime()) {
|
||||
finalEndDate = new Date(endDate);
|
||||
finalEndDate.setDate(startDate.getDate() + 1);
|
||||
}
|
||||
|
||||
vevent[1].push([
|
||||
"dtend",
|
||||
{ tzid },
|
||||
event.allday ? "date" : "date-time",
|
||||
formatDateToICal(new Date(event.end), event.allday ?? false),
|
||||
formatDateToICal(finalEndDate, event.allday ?? false),
|
||||
]);
|
||||
}
|
||||
if (event.organizer) {
|
||||
@@ -210,8 +215,11 @@ export function calendarEventToJCal(
|
||||
if (event.repetition.endDate) {
|
||||
repetitionRule["until"] = event.repetition.endDate;
|
||||
}
|
||||
if (event.repetition.selectedDays) {
|
||||
repetitionRule["byday"] = event.repetition.selectedDays;
|
||||
if (
|
||||
event.repetition.byday !== null &&
|
||||
event.repetition.byday !== undefined
|
||||
) {
|
||||
repetitionRule["byday"] = event.repetition.byday;
|
||||
}
|
||||
vevent[1].push(["rrule", {}, "recur", repetitionRule]);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Callback } from "./oidcAuth";
|
||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||
import { useAppDispatch } from "../../app/hooks";
|
||||
import { push } from "redux-first-history";
|
||||
import { getOpenPaasUserDataAsync, setTokens, setUserData } from "./userSlice";
|
||||
import { Loading } from "../../components/Loading/Loading";
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
import "@testing-library/jest-dom";
|
||||
|
||||
import { TextEncoder, TextDecoder } from "util";
|
||||
import { clientConfig } from "./features/User/oidcAuth";
|
||||
import { TextEncoder } from "util";
|
||||
|
||||
global.TextEncoder = TextEncoder;
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
generateMeetingId,
|
||||
generateMeetingLink,
|
||||
addVideoConferenceToDescription,
|
||||
extractVideoConferenceFromDescription,
|
||||
} from "../videoConferenceUtils";
|
||||
|
||||
// Mock window object for Node.js environment
|
||||
const mockWindow = {
|
||||
VIDEO_CONFERENCE_BASE_URL: "https://meet.linagora.com",
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
global.window = mockWindow;
|
||||
|
||||
describe("videoConferenceUtils", () => {
|
||||
describe("generateMeetingId", () => {
|
||||
it("should generate meeting ID in correct format", () => {
|
||||
const meetingId = generateMeetingId();
|
||||
expect(meetingId).toMatch(/^[a-z]{3}-[a-z]{4}-[a-z]{3}$/);
|
||||
});
|
||||
|
||||
it("should generate different IDs each time", () => {
|
||||
const id1 = generateMeetingId();
|
||||
const id2 = generateMeetingId();
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateMeetingLink", () => {
|
||||
it("should generate link with default base URL", () => {
|
||||
const link = generateMeetingLink();
|
||||
expect(link).toMatch(
|
||||
/^https:\/\/meet\.linagora\.com\/[a-z]{3}-[a-z]{4}-[a-z]{3}$/
|
||||
);
|
||||
});
|
||||
|
||||
it("should generate link with custom base URL", () => {
|
||||
const customBase = "https://custom-meet.example.com";
|
||||
const link = generateMeetingLink(customBase);
|
||||
expect(link).toMatch(
|
||||
/^https:\/\/custom-meet\.example\.com\/[a-z]{3}-[a-z]{4}-[a-z]{3}$/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addVideoConferenceToDescription", () => {
|
||||
it("should add video conference footer to empty description", () => {
|
||||
const description = "";
|
||||
const meetingLink = "https://meet.linagora.com/abc-defg-hij";
|
||||
const result = addVideoConferenceToDescription(description, meetingLink);
|
||||
expect(result).toBe("\nVisio: https://meet.linagora.com/abc-defg-hij");
|
||||
});
|
||||
|
||||
it("should add video conference footer to existing description", () => {
|
||||
const description = "This is a meeting description.";
|
||||
const meetingLink = "https://meet.linagora.com/abc-defg-hij";
|
||||
const result = addVideoConferenceToDescription(description, meetingLink);
|
||||
expect(result).toBe(
|
||||
"This is a meeting description.\nVisio: https://meet.linagora.com/abc-defg-hij"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractVideoConferenceFromDescription", () => {
|
||||
it("should extract video conference link from description", () => {
|
||||
const description =
|
||||
"Meeting description.\nVisio: https://meet.linagora.com/abc-defg-hij";
|
||||
const result = extractVideoConferenceFromDescription(description);
|
||||
expect(result).toBe("https://meet.linagora.com/abc-defg-hij");
|
||||
});
|
||||
|
||||
it("should return null when no video conference link found", () => {
|
||||
const description = "Just a regular meeting description.";
|
||||
const result = extractVideoConferenceFromDescription(description);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for empty description", () => {
|
||||
const description = "";
|
||||
const result = extractVideoConferenceFromDescription(description);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -52,3 +52,10 @@ export const computeStartOfTheWeek = (date: Date): Date => {
|
||||
startOfWeek.setHours(0, 0, 0, 0);
|
||||
return startOfWeek;
|
||||
};
|
||||
|
||||
export const computeWeekRange = (date: Date): { start: Date; end: Date } => {
|
||||
const weekStart = computeStartOfTheWeek(date);
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekStart.getDate() + 7);
|
||||
return { start: weekStart, end: weekEnd };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Utility functions for video conference meeting generation
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate a random meeting ID in format xxx-xxxx-xxx
|
||||
* @returns {string} Random meeting ID
|
||||
*/
|
||||
export function generateMeetingId(): string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz";
|
||||
const generateSegment = (length: number): string => {
|
||||
return Array.from(
|
||||
{ length },
|
||||
() => chars[Math.floor(Math.random() * chars.length)]
|
||||
).join("");
|
||||
};
|
||||
|
||||
return `${generateSegment(3)}-${generateSegment(4)}-${generateSegment(3)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a complete meeting link
|
||||
* @param {string} baseUrl - Base URL for video conference (from .env.js)
|
||||
* @returns {string} Complete meeting link
|
||||
*/
|
||||
export function generateMeetingLink(baseUrl?: string): string {
|
||||
const base =
|
||||
baseUrl ||
|
||||
(window as any).VIDEO_CONFERENCE_BASE_URL ||
|
||||
"https://meet.linagora.com";
|
||||
const meetingId = generateMeetingId();
|
||||
return `${base}/${meetingId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add video conference footer to event description
|
||||
* @param {string} description - Original description
|
||||
* @param {string} meetingLink - Generated meeting link
|
||||
* @returns {string} Description with video conference footer
|
||||
*/
|
||||
export function addVideoConferenceToDescription(
|
||||
description: string,
|
||||
meetingLink: string
|
||||
): string {
|
||||
const footer = `\nVisio: ${meetingLink}`;
|
||||
return description + footer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract video conference link from description
|
||||
* @param {string} description - Event description
|
||||
* @returns {string | null} Video conference link if found, null otherwise
|
||||
*/
|
||||
export function extractVideoConferenceFromDescription(
|
||||
description: string
|
||||
): string | null {
|
||||
const match = description.match(/Visio:\s*(https?:\/\/[^\s]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
Reference in New Issue
Block a user