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