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 && ( + + )} + + + ); + + 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 - - - {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 }} + /> + + + + - } - 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 - 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); - }} + + + + + - 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 - - + + )} - - Visibility - - - - is 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" + /> + + + + + + + {showRepeat && ( + + + + )} + + + + + + + + + + {hasVideoConference && meetingLink && ( + <> + + + + + + + )} - + + + + setLocation(e.target.value)} + size="small" + margin="dense" + /> + + + + {!showMore && ( + Calendar + )} + + + - - - - - - - - - + {/* Extended options */} + {showMore && ( + <> + + + + + + + + + + + + + + { + 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; +}