Merge pull request #172 from linagora/UI/create-event-popup

UI/create event popup
This commit is contained in:
lenhanphung
2025-10-06 12:20:55 +07:00
committed by GitHub
36 changed files with 2013 additions and 696 deletions
+2 -2
View File
@@ -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");
+168 -165
View File
@@ -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"));
});
+38 -17
View File
@@ -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);
+1
View File
@@ -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";
+2 -12
View File
@@ -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();
+1 -12
View File
@@ -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");
-14
View File
@@ -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,
+36 -11
View File
@@ -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,
})
);
}
});
}
};
+229
View File
@@ -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/
+181
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
export { default as ResponsiveDialog } from "./ResponsiveDialog";
+144 -162
View File
@@ -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>
);
}
-4
View File
@@ -98,10 +98,6 @@ export function Menubar({
}
};
const handleRefresh = () => {
onRefresh();
};
const open = Boolean(anchorEl);
return (
<>
+20 -21
View File
@@ -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/", "")
+5 -8
View File
@@ -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>
+1 -8
View File
@@ -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
View File
@@ -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>
);
}
+1
View File
@@ -29,6 +29,7 @@ export interface RepetitionObject {
freq: string;
interval?: number;
selectedDays?: string[];
byday?: string[] | null;
occurrences?: number;
endDate?: string;
}
+17 -9
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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();
});
});
});
+7
View File
@@ -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 };
};
+59
View File
@@ -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;
}