diff --git a/__test__/components/Calendar.test.tsx b/__test__/components/Calendar.test.tsx
index 50abad8..7560a5a 100644
--- a/__test__/components/Calendar.test.tsx
+++ b/__test__/components/Calendar.test.tsx
@@ -217,7 +217,7 @@ describe("calendar Availability search", () => {
renderWithProviders( , 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( , 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");
diff --git a/__test__/components/RepeatEvent.test.tsx b/__test__/components/RepeatEvent.test.tsx
index 0a89775..8504051 100644
--- a/__test__/components/RepeatEvent.test.tsx
+++ b/__test__/components/RepeatEvent.test.tsx
@@ -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) {
+ 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");
});
});
diff --git a/__test__/components/ResponsiveDialog.test.tsx b/__test__/components/ResponsiveDialog.test.tsx
new file mode 100644
index 0000000..a78fd4c
--- /dev/null
+++ b/__test__/components/ResponsiveDialog.test.tsx
@@ -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(
+
+
+
+ );
+
+ expect(screen.getByText("Test Dialog")).toBeInTheDocument();
+ expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
+ });
+
+ it("renders title in normal mode", () => {
+ render(
+
+ Content
+
+ );
+
+ expect(screen.getByText("My Title")).toBeInTheDocument();
+ expect(screen.queryByLabelText("show less")).not.toBeInTheDocument();
+ });
+
+ it("renders back arrow in extended mode", () => {
+ render(
+
+ Content
+
+ );
+
+ expect(screen.queryByText("My Title")).not.toBeInTheDocument();
+ expect(screen.getByLabelText("show less")).toBeInTheDocument();
+ });
+
+ it("calls onExpandToggle when back arrow is clicked", () => {
+ render(
+
+ Content
+
+ );
+
+ const backButton = screen.getByLabelText("show less");
+ fireEvent.click(backButton);
+
+ expect(mockOnExpandToggle).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders actions when provided", () => {
+ render(
+ Custom Action}
+ >
+ Content
+
+ );
+
+ expect(screen.getByText("Custom Action")).toBeInTheDocument();
+ });
+
+ it("does not render actions when not provided", () => {
+ const { container } = render(
+
+ Content
+
+ );
+
+ const dialogActions = container.querySelector(".MuiDialogActions-root");
+ expect(dialogActions).not.toBeInTheDocument();
+ });
+
+ it("calls onClose when backdrop is clicked", () => {
+ render(
+
+ Content
+
+ );
+
+ const backdrop = document.querySelector(".MuiBackdrop-root");
+ if (backdrop) {
+ fireEvent.click(backdrop);
+ }
+
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+
+ it("applies custom normalMaxWidth", () => {
+ render(
+
+ Normal Width Content
+
+ );
+
+ expect(screen.getByText("Normal Width Content")).toBeInTheDocument();
+ });
+
+ it("wraps children in Stack component", () => {
+ render(
+
+
+
+
+ );
+
+ expect(screen.getByLabelText("Field 1")).toBeInTheDocument();
+ expect(screen.getByLabelText("Field 2")).toBeInTheDocument();
+ });
+
+ it("uses correct spacing in normal mode", () => {
+ render(
+
+ Normal Spacing Content
+
+ );
+
+ expect(screen.getByText("Normal Spacing Content")).toBeInTheDocument();
+ });
+
+ it("uses correct spacing in extended mode", () => {
+ render(
+
+ Extended Spacing Content
+
+ );
+
+ expect(screen.getByText("Extended Spacing Content")).toBeInTheDocument();
+ });
+
+ it("applies contentSx custom styles", () => {
+ render(
+
+ Custom Styled Content
+
+ );
+
+ expect(screen.getByText("Custom Styled Content")).toBeInTheDocument();
+ });
+
+ it("applies titleSx custom styles", () => {
+ const { container } = render(
+
+ Content
+
+ );
+
+ const title = screen.getByText("Test");
+ expect(title).toBeInTheDocument();
+ });
+
+ it("shows dividers when dividers prop is true", () => {
+ render(
+
+ Content with Dividers
+
+ );
+
+ expect(screen.getByText("Content with Dividers")).toBeInTheDocument();
+ });
+
+ it("does not show back arrow when onExpandToggle is not provided", () => {
+ render(
+
+ Content
+
+ );
+
+ expect(screen.queryByLabelText("show less")).not.toBeInTheDocument();
+ expect(screen.getByText("Test Title")).toBeInTheDocument();
+ });
+
+ it("accepts custom headerHeight", () => {
+ render(
+
+ Custom Header Content
+
+ );
+
+ expect(screen.getByText("Custom Header Content")).toBeInTheDocument();
+ });
+
+ it("renders with custom expandedContentMaxWidth", () => {
+ render(
+
+ Wide Content
+
+ );
+
+ expect(screen.getByText("Wide Content")).toBeInTheDocument();
+ });
+
+ it("does not render dialog content when open is false", () => {
+ render(
+
+ Test Content
+
+ );
+
+ expect(screen.queryByText("Test Content")).not.toBeInTheDocument();
+ });
+
+ it("renders correctly in extended mode", () => {
+ render(
+
+ Extended Content
+
+ );
+
+ expect(screen.getByText("Extended Content")).toBeInTheDocument();
+ expect(screen.getByLabelText("show less")).toBeInTheDocument();
+ });
+});
diff --git a/__test__/features/Events/EventApi.test.tsx b/__test__/features/Events/EventApi.test.tsx
index d6662fe..f1553d9 100644
--- a/__test__/features/Events/EventApi.test.tsx
+++ b/__test__/features/Events/EventApi.test.tsx
@@ -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),
+ })
+ );
+ });
});
diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx
index cb51add..aeaa59b 100644
--- a/__test__/features/Events/EventDisplay.test.tsx
+++ b/__test__/features/Events/EventDisplay.test.tsx
@@ -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"));
});
diff --git a/__test__/features/Events/EventModal.test.tsx b/__test__/features/Events/EventModal.test.tsx
index 8aff878..03275b8 100644
--- a/__test__/features/Events/EventModal.test.tsx
+++ b/__test__/features/Events/EventModal.test.tsx
@@ -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);
diff --git a/public/.env.example.js b/public/.env.example.js
index 4a32816..5937664 100644
--- a/public/.env.example.js
+++ b/public/.env.example.js
@@ -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;
diff --git a/src/components/Attendees/AttendeeSearch.tsx b/src/components/Attendees/AttendeeSearch.tsx
index db6c9f2..2616053 100644
--- a/src/components/Attendees/AttendeeSearch.tsx
+++ b/src/components/Attendees/AttendeeSearch.tsx
@@ -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";
diff --git a/src/components/Attendees/PeopleSearch.tsx b/src/components/Attendees/PeopleSearch.tsx
index 110fa27..1a08a1b 100644
--- a/src/components/Attendees/PeopleSearch.tsx
+++ b/src/components/Attendees/PeopleSearch.tsx
@@ -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();
diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx
index 754ff56..efeb23a 100644
--- a/src/components/Calendar/Calendar.tsx
+++ b/src/components/Calendar/Calendar.tsx
@@ -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 {
diff --git a/src/components/Calendar/CalendarLayout.tsx b/src/components/Calendar/CalendarLayout.tsx
index c9b8bc1..4d44d42 100644
--- a/src/components/Calendar/CalendarLayout.tsx
+++ b/src/components/Calendar/CalendarLayout.tsx
@@ -16,8 +16,6 @@ export default function CalendarLayout() {
const calendarRef = useRef(null);
const dispatch = useAppDispatch();
const selectedCalendars = useAppSelector((state) => state.calendars.list);
- const userId =
- useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const [currentDate, setCurrentDate] = useState(new Date());
const [currentView, setCurrentView] = useState("timeGridWeek");
diff --git a/src/components/Calendar/CalendarModal.tsx b/src/components/Calendar/CalendarModal.tsx
index 6dd4fbb..5d2c5c8 100644
--- a/src/components/Calendar/CalendarModal.tsx
+++ b/src/components/Calendar/CalendarModal.tsx
@@ -79,20 +79,6 @@ function CalendarPopover({
}
};
- const palette = [
- "#D50000",
- "#E67C73",
- "#F4511E",
- "#F6BF26",
- "#33B679",
- "#0B8043",
- "#039BE5",
- "#3F51B5",
- "#7986CB",
- "#8E24AA",
- "#616161",
- ];
-
return (
onClose(e, reason)}>
diff --git a/src/components/Calendar/CalendarSearch.tsx b/src/components/Calendar/CalendarSearch.tsx
index b2d9e49..b1d4d1e 100644
--- a/src/components/Calendar/CalendarSearch.tsx
+++ b/src/components/Calendar/CalendarSearch.tsx
@@ -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";
diff --git a/src/components/Calendar/CalendarSelection.tsx b/src/components/Calendar/CalendarSelection.tsx
index 550dc6f..7a43327 100644
--- a/src/components/Calendar/CalendarSelection.tsx
+++ b/src/components/Calendar/CalendarSelection.tsx
@@ -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";
diff --git a/src/components/Calendar/CustomCalendar.styl b/src/components/Calendar/CustomCalendar.styl
index 09adb52..45db72a 100644
--- a/src/components/Calendar/CustomCalendar.styl
+++ b/src/components/Calendar/CustomCalendar.styl
@@ -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
diff --git a/src/components/Calendar/TempCalendarsInput.tsx b/src/components/Calendar/TempCalendarsInput.tsx
index 4f481f2..3a38092 100644
--- a/src/components/Calendar/TempCalendarsInput.tsx
+++ b/src/components/Calendar/TempCalendarsInput.tsx
@@ -1,4 +1,4 @@
-import { useState, useRef, useMemo } from "react";
+import { useState, useRef } from "react";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import {
getTempCalendarsListAsync,
diff --git a/src/components/Calendar/handlers/mouseHandlers.ts b/src/components/Calendar/handlers/mouseHandlers.ts
index fe73bd3..ad4bcf9 100644
--- a/src/components/Calendar/handlers/mouseHandlers.ts
+++ b/src/components/Calendar/handlers/mouseHandlers.ts
@@ -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);
diff --git a/src/components/Calendar/hooks/useCalendarEventHandlers.ts b/src/components/Calendar/hooks/useCalendarEventHandlers.ts
index f44cf38..ca1a948 100644
--- a/src/components/Calendar/hooks/useCalendarEventHandlers.ts
+++ b/src/components/Calendar/hooks/useCalendarEventHandlers.ts
@@ -1,5 +1,4 @@
import { useCallback } from "react";
-import { CalendarApi } from "@fullcalendar/core";
import {
createEventHandlers,
EventHandlersProps,
diff --git a/src/components/Calendar/hooks/useCalendarViewHandlers.ts b/src/components/Calendar/hooks/useCalendarViewHandlers.ts
index cb00b62..77df655 100644
--- a/src/components/Calendar/hooks/useCalendarViewHandlers.ts
+++ b/src/components/Calendar/hooks/useCalendarViewHandlers.ts
@@ -1,5 +1,4 @@
import { useCallback } from "react";
-import { CalendarApi } from "@fullcalendar/core";
import {
createViewHandlers,
ViewHandlersProps,
diff --git a/src/components/Calendar/utils/calendarUtils.ts b/src/components/Calendar/utils/calendarUtils.ts
index 85ae9d8..70dd735 100644
--- a/src/components/Calendar/utils/calendarUtils.ts
+++ b/src/components/Calendar/utils/calendarUtils.ts
@@ -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,
+ })
+ );
+ }
});
}
};
diff --git a/src/components/Dialog/README.md b/src/components/Dialog/README.md
new file mode 100644
index 0000000..df79a66
--- /dev/null
+++ b/src/components/Dialog/README.md
@@ -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 && (
+ setShowMore(true)}>Show More
+ )}
+ setOpen(false)}>Close
+ >
+ );
+
+ return (
+ setOpen(false)}
+ title="My Dialog"
+ isExpanded={showMore}
+ onExpandToggle={() => setShowMore(!showMore)}
+ actions={actions}
+ >
+
+
+ {/* Wrapped in Stack with spacing={2} (normal) or spacing={3} (expanded) */}
+
+ );
+}
+```
+
+## 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` | - | Custom styles for DialogContent |
+| `titleSx` | `SxProps` | - | 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
+
+
+
+```
+
+### With Dividers
+
+```tsx
+Save}
+>
+
+
+```
+
+### Different Sizes
+
+```tsx
+
+
+
+```
+
+### Custom Spacing
+
+```tsx
+
+
+
+ {/* MUI spacing units: 1=8px, 2=16px, 3=24px, 4=32px */}
+
+```
+
+### Custom Header Height
+
+For apps with different header heights:
+
+```tsx
+
+
+
+```
+
+## 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
+
+```
+
+## TypeScript Support
+
+Full type inference and validation:
+
+```tsx
+import { ResponsiveDialog } from "../../components/Dialog";
+
+// All props are type-checked
+
+```
+
+## 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/
diff --git a/src/components/Dialog/ResponsiveDialog.tsx b/src/components/Dialog/ResponsiveDialog.tsx
new file mode 100644
index 0000000..fefb9d9
--- /dev/null
+++ b/src/components/Dialog/ResponsiveDialog.tsx
@@ -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
+ * Save}
+ * contentSx={{ padding: 3 }}
+ * >
+ *
+ *
+ * ```
+ */
+interface ResponsiveDialogProps
+ extends Omit {
+ /** 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;
+ /** Custom styles for DialogTitle */
+ titleSx?: SxProps;
+ /** Additional props for DialogContent (excluding sx) */
+ dialogContentProps?: Omit;
+ /** Additional props for DialogTitle (excluding sx) */
+ dialogTitleProps?: Omit;
+ /** 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 = {
+ "& .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 = {
+ width: "100%",
+ padding: isExpanded ? "16px" : undefined,
+ };
+
+ const contentWrapperSx: SxProps = {
+ maxWidth: isExpanded ? expandedContentMaxWidth : "100%",
+ margin: isExpanded ? "0 auto" : "0",
+ width: "100%",
+ };
+
+ const currentSpacing = isExpanded ? expandedSpacing : normalSpacing;
+
+ return (
+
+
+ {isExpanded && onExpandToggle ? (
+
+
+
+ ) : (
+ title
+ )}
+
+
+ {isExpanded ? (
+
+ {children}
+
+ ) : (
+ {children}
+ )}
+
+ {actions && {actions} }
+
+ );
+}
+
+export default ResponsiveDialog;
diff --git a/src/components/Dialog/index.ts b/src/components/Dialog/index.ts
new file mode 100644
index 0000000..31a5b06
--- /dev/null
+++ b/src/components/Dialog/index.ts
@@ -0,0 +1 @@
+export { default as ResponsiveDialog } from "./ResponsiveDialog";
diff --git a/src/components/Event/EventRepeat.tsx b/src/components/Event/EventRepeat.tsx
index df065f9..0b19074 100644
--- a/src/components/Event/EventRepeat.tsx
+++ b/src/components/Event/EventRepeat.tsx
@@ -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 (
-
- Repetition
- {
- if (e.target.value === "weekly") {
- setRepetition({
- ...repetition,
- freq: e.target.value,
- selectedDays: [days[day.getDay() - 1]],
- });
- } else {
- setRepetition({ ...repetition, freq: e.target.value });
- }
- }}
- >
- No Repetition
- Repeat daily
- Repeat weekly
- Repeat monthly
- Repeat yearly
-
-
- {repetition.freq && (
-
- {/* Interval */}
-
- Interval:
-
- setRepetition({
- ...repetition,
- interval: Number(e.target.value),
- })
- }
- size="small"
- style={{ width: 80 }}
- />
-
- {
- repetitionValues[
- repetitionValues.findIndex((el) => el === repetition.freq)
- ]
- }
-
-
-
- {/* Weekly selection */}
- {repetition.freq === "weekly" && (
-
-
- On days:
-
-
- {days.map((day) => (
- handleDayChange(day)}
- />
- }
- label={day}
- />
- ))}
-
-
- )}
-
- {/* End options */}
-
-
- End:
-
- {
- const value = e.target.value;
- setEndOption(value);
-
- if (value === "never") {
- setRepetition({ ...repetition, occurrences: 0, endDate: "" });
- }
- if (value === "after") {
+
+
+ {/* Interval */}
+
+ Repeat every
+
+ setRepetition({
+ ...repetition,
+ interval: Number(e.target.value),
+ })
+ }
+ size="small"
+ style={{ width: 80 }}
+ inputProps={{ min: 1 }}
+ />
+
+ {
+ 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 });
}
}}
>
- }
- label="Never"
- />
+ Day(s)
+ Week(s)
+ Month(s)
+ Year(s)
+
+
+
- }
- label={
-
- After
-
- setRepetition({
- ...repetition,
- endDate: "",
- occurrences: Number(e.target.value),
- })
- }
- style={{ width: 100 }}
- inputProps={{ min: 1 }}
- disabled={endOption !== "after"}
+ {/* Weekly selection */}
+ {repetition.freq === "weekly" && (
+
+
+ Repeat on:
+
+
+ {days.map((day) => (
+ handleDayChange(day)}
/>
- occurrences
-
- }
- />
-
- }
- label={
-
- On
-
- setRepetition({
- ...repetition,
- occurrences: 0,
- endDate: e.target.value,
- })
- }
- disabled={endOption !== "on"}
- />
-
- }
- />
-
+ }
+ label={day}
+ />
+ ))}
+
-
- )}
-
+ )}
+
+ {/* End options */}
+
+
+ End:
+
+ {
+ 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),
+ });
+ }
+ }}
+ >
+ } label="Never" />
+
+ }
+ label={
+
+ After
+
+ setRepetition({
+ ...repetition,
+ endDate: "",
+ occurrences: Number(e.target.value),
+ })
+ }
+ style={{ width: 100 }}
+ inputProps={{ min: 1 }}
+ disabled={endOption !== "after"}
+ />
+ occurrences
+
+ }
+ />
+
+ }
+ label={
+
+ On
+
+ setRepetition({
+ ...repetition,
+ occurrences: 0,
+ endDate: e.target.value,
+ })
+ }
+ disabled={endOption !== "on"}
+ />
+
+ }
+ />
+
+
+
+
);
}
diff --git a/src/components/Menubar/Menubar.tsx b/src/components/Menubar/Menubar.tsx
index bbcb17f..87a9377 100644
--- a/src/components/Menubar/Menubar.tsx
+++ b/src/components/Menubar/Menubar.tsx
@@ -98,10 +98,6 @@ export function Menubar({
}
};
- const handleRefresh = () => {
- onRefresh();
- };
-
const open = Boolean(anchorEl);
return (
<>
diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts
index 0f10074..234845e 100644
--- a/src/features/Calendars/CalendarSlice.ts
+++ b/src/features/Calendars/CalendarSlice.ts
@@ -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;
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 } // 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/", "")
diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx
index b95b188..e8b3974 100644
--- a/src/features/Events/EventDisplay.tsx
+++ b/src/features/Events/EventDisplay.tsx
@@ -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}
/>
- Alarm
+ Notification
setAlarm(e.target.value)
}
>
- No Alarm
+ No Notification
1 minute
2 minutes
10 minutes
diff --git a/src/features/Events/EventDisplayPreview.tsx b/src/features/Events/EventDisplayPreview.tsx
index f70c989..7b4b52c 100644
--- a/src/features/Events/EventDisplayPreview.tsx
+++ b/src/features/Events/EventDisplayPreview.tsx
@@ -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,
diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx
index b3c5bf9..09e1f00 100644
--- a/src/features/Events/EventModal.tsx
+++ b/src/features/Events/EventModal.tsx
@@ -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 (
+
+
+ {label}
+
+ {children}
+
+ );
+ }
+
+ // Extended mode: label on left
+ return (
+
+
+ {label}
+
+ {children}
+
+ );
+ }
+);
+
+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(
+ 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 = (
+
+ {!showMore && (
+ setShowMore(!showMore)}>Show More
+ )}
+
+
+ Cancel
+
+
+ Save
+
+
+
+ );
+
return (
- setShowMore(!showMore)}
+ actions={dialogActions}
>
-
-
-
- setTitle(e.target.value)}
- size="small"
- margin="dense"
- />
-
- Calendar
-
- setCalendarid(Number(e.target.value))
- }
- >
- {Object.keys(userPersonnalCalendars).map((calendar, index) => (
-
- {userPersonnalCalendars[index].name}
-
- ))}
-
-
+
+ setTitle(e.target.value)}
+ size="small"
+ margin="dense"
+ />
+
- {
- const newStart = e.target.value;
- setStart(newStart);
- const newRange = {
- ...selectedRange,
- start: new Date(newStart),
- startStr: newStart,
- allDay: allday,
- };
- setSelectedRange(newRange);
- calendarRef.current?.select(newRange);
- }}
+
+
+ }
+ onClick={() => setShowDescription(true)}
size="small"
- margin="dense"
- InputLabelProps={{ shrink: true }}
- />
- {
- 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 }}
- />
-
- {
- 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
+
+
+
- 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
-
+ {showDescription && (
+
setDescription(e.target.value)}
size="small"
@@ -302,92 +494,321 @@ function EventPopover({
multiline
rows={2}
/>
- setLocation(e.target.value)}
- size="small"
- margin="dense"
- />
-
- {/* Extended options */}
- {showMore && (
- <>
-
-
- Alarm
- setAlarm(e.target.value)}
- >
- No Alarm
- 1 minute
- 2 minutes
- 10 minutes
- 15 minutes
- 30 minutes
- 1 hours
- 2 hours
- 5 hours
- 12 hours
- 1 day
- 2 days
- 1 week
-
-
+
+ )}
-
- Visibility
-
- setEventClass(e.target.value)
+
+
+
+ {showMore && (
+
+ Start
+
+ )}
+ {
+ 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 }}
+ />
+
+
+ {showMore && (
+
+ End
+
+ )}
+ {
+ 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 }}
+ />
+
+
+
+
+
+ setImportant(!important)}
+ />
+ }
+ label="Mark as important"
+ />
+ {
+ 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));
}
- >
- Public
- Show time only
- Private
-
-
-
- is Busy
- setBusy(e.target.value)}
- >
- Free
- Busy
-
-
+
+ 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"
+ />
+ {
+ 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"
+ />
+
+ setTimezone(e.target.value)}
+ displayEmpty
+ >
+ {timezoneList.zones.map((tz) => (
+
+ ({timezoneList.getTimezoneOffset(tz)}) {tz.replace(/_/g, " ")}
+
+ ))}
+
+
+
+
+
+ {showRepeat && (
+
+
+
+ )}
+
+
+
+
+
+
+
+ }
+ onClick={handleAddVideoConference}
+ size="medium"
+ sx={{
+ textTransform: "none",
+ color: "text.secondary",
+ display: hasVideoConference ? "none" : "flex",
+ }}
+ >
+ Add Visio conference
+
+
+ {hasVideoConference && meetingLink && (
+ <>
+ }
+ onClick={() => window.open(meetingLink, "_blank")}
+ size="medium"
+ variant="contained"
+ sx={{
+ textTransform: "none",
+ mr: 1,
+ }}
+ >
+ Join Visio conference
+
+
+
+
+
+
+
>
)}
-
+
+
+
+ setLocation(e.target.value)}
+ size="small"
+ margin="dense"
+ />
+
+
+
+ {!showMore && (
+ Calendar
+ )}
+
+ setCalendarid(Number(e.target.value))
+ }
+ >
+ {Object.keys(userPersonnalCalendars).map((calendar, index) => (
+
+ {userPersonnalCalendars[index].name}
+
+ ))}
+
+
+
-
-
-
- Cancel
-
- setShowMore(!showMore)}>
- {showMore ? "Show Less" : "Show More"}
-
-
- Save
-
-
-
-
-
+ {/* Extended options */}
+ {showMore && (
+ <>
+
+
+ setAlarm(e.target.value)}
+ >
+ No Notification
+ 1 minute
+ 2 minutes
+ 10 minutes
+ 15 minutes
+ 30 minutes
+ 1 hours
+ 2 hours
+ 5 hours
+ 12 hours
+ 1 day
+ 2 days
+ 1 week
+
+
+
+
+
+
+ setBusy(e.target.value)}
+ >
+ Free
+ Busy
+
+
+
+
+
+ {
+ if (newValue !== null) {
+ setEventClass(newValue);
+ }
+ }}
+ size="small"
+ >
+
+
+ All
+
+
+
+ Participants
+
+
+
+ >
+ )}
+
);
}
diff --git a/src/features/Events/EventsTypes.ts b/src/features/Events/EventsTypes.ts
index 13105a3..42263b0 100644
--- a/src/features/Events/EventsTypes.ts
+++ b/src/features/Events/EventsTypes.ts
@@ -29,6 +29,7 @@ export interface RepetitionObject {
freq: string;
interval?: number;
selectedDays?: string[];
+ byday?: string[] | null;
occurrences?: number;
endDate?: string;
}
diff --git a/src/features/Events/eventUtils.ts b/src/features/Events/eventUtils.ts
index 0fcd71f..6415a65 100644
--- a/src/features/Events/eventUtils.ts
+++ b/src/features/Events/eventUtils.ts
@@ -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]);
}
diff --git a/src/features/User/LoginCallback.tsx b/src/features/User/LoginCallback.tsx
index 336a4e4..2220cf3 100644
--- a/src/features/User/LoginCallback.tsx
+++ b/src/features/User/LoginCallback.tsx
@@ -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";
diff --git a/src/setupTests.ts b/src/setupTests.ts
index aaccf0e..156e73a 100644
--- a/src/setupTests.ts
+++ b/src/setupTests.ts
@@ -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;
diff --git a/src/utils/__test__/videoConferenceUtils.test.ts b/src/utils/__test__/videoConferenceUtils.test.ts
new file mode 100644
index 0000000..32a105a
--- /dev/null
+++ b/src/utils/__test__/videoConferenceUtils.test.ts
@@ -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();
+ });
+ });
+});
diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts
index 3a89796..2c6846d 100644
--- a/src/utils/dateUtils.ts
+++ b/src/utils/dateUtils.ts
@@ -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 };
+};
diff --git a/src/utils/videoConferenceUtils.ts b/src/utils/videoConferenceUtils.ts
new file mode 100644
index 0000000..d2ee7f2
--- /dev/null
+++ b/src/utils/videoConferenceUtils.ts
@@ -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;
+}