Merge pull request #504 from linagora/UI/update-header-bar
UI/update header bar
This commit is contained in:
@@ -400,6 +400,10 @@ describe("CalendarApp integration", () => {
|
|||||||
preloadedState
|
preloadedState
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Expand to show date/time inputs (normal mode shows DateTimeSummary)
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "common.moreOptions" })
|
||||||
|
);
|
||||||
const startDateInput = await screen.findByTestId("start-time-input");
|
const startDateInput = await screen.findByTestId("start-time-input");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { Menubar } from "@/components/Menubar/Menubar";
|
import { Menubar } from "@/components/Menubar/Menubar";
|
||||||
import * as oidcAuth from "@/features/User/oidcAuth";
|
import * as oidcAuth from "@/features/User/oidcAuth";
|
||||||
|
import { redirectTo } from "@/utils/navigation";
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
import { renderWithProviders } from "../utils/Renderwithproviders";
|
import { renderWithProviders } from "../utils/Renderwithproviders";
|
||||||
|
|
||||||
|
jest.mock("@/utils/navigation", () => ({
|
||||||
|
redirectTo: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("Calendar App Component Display Tests", () => {
|
describe("Calendar App Component Display Tests", () => {
|
||||||
const preloadedState = {
|
const preloadedState = {
|
||||||
user: {
|
user: {
|
||||||
@@ -320,7 +325,7 @@ describe("Calendar App Component Display Tests", () => {
|
|||||||
expect(avatar).toBeInTheDocument();
|
expect(avatar).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows AppsIcon when applist is not empty", () => {
|
it("shows apps button when applist is not empty", () => {
|
||||||
(window as any).appList = [{ name: "test", icon: "test", link: "test" }];
|
(window as any).appList = [{ name: "test", icon: "test", link: "test" }];
|
||||||
const mockCalendarRef = { current: null };
|
const mockCalendarRef = { current: null };
|
||||||
const mockOnRefresh = jest.fn();
|
const mockOnRefresh = jest.fn();
|
||||||
@@ -335,10 +340,10 @@ describe("Calendar App Component Display Tests", () => {
|
|||||||
/>,
|
/>,
|
||||||
preloadedState
|
preloadedState
|
||||||
);
|
);
|
||||||
expect(screen.getByTestId("AppsIcon")).toBeInTheDocument();
|
expect(screen.getByLabelText("menubar.apps")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens popover when clicking AppsIcon", () => {
|
it("opens popover when clicking apps button", () => {
|
||||||
(window as any).appList = [
|
(window as any).appList = [
|
||||||
{ name: "Twake", icon: "twake.svg", link: "/twake" },
|
{ name: "Twake", icon: "twake.svg", link: "/twake" },
|
||||||
{ name: "Calendar", icon: "calendar.svg", link: "/calendar" },
|
{ name: "Calendar", icon: "calendar.svg", link: "/calendar" },
|
||||||
@@ -356,7 +361,7 @@ describe("Calendar App Component Display Tests", () => {
|
|||||||
/>,
|
/>,
|
||||||
preloadedState
|
preloadedState
|
||||||
);
|
);
|
||||||
const appsButton = screen.getByTestId("AppsIcon");
|
const appsButton = screen.getByLabelText("menubar.apps");
|
||||||
fireEvent.click(appsButton);
|
fireEvent.click(appsButton);
|
||||||
expect(screen.getByText("Twake")).toBeInTheDocument();
|
expect(screen.getByText("Twake")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Calendar")).toBeInTheDocument();
|
expect(screen.getByText("Calendar")).toBeInTheDocument();
|
||||||
@@ -377,7 +382,7 @@ describe("Calendar App Component Display Tests", () => {
|
|||||||
/>,
|
/>,
|
||||||
preloadedState
|
preloadedState
|
||||||
);
|
);
|
||||||
const appsButton = screen.getByTestId("AppsIcon");
|
const appsButton = screen.getByLabelText("menubar.apps");
|
||||||
fireEvent.click(appsButton);
|
fireEvent.click(appsButton);
|
||||||
|
|
||||||
const testLink = screen.getByRole("link", { name: /test/i });
|
const testLink = screen.getByRole("link", { name: /test/i });
|
||||||
@@ -532,7 +537,7 @@ describe("Menubar interaction with expanded Dialog", () => {
|
|||||||
preloadedState
|
preloadedState
|
||||||
);
|
);
|
||||||
|
|
||||||
const appsButton = screen.getByTestId("AppsIcon");
|
const appsButton = screen.getByLabelText("menubar.apps");
|
||||||
expect(appsButton).toBeVisible();
|
expect(appsButton).toBeVisible();
|
||||||
|
|
||||||
fireEvent.click(appsButton);
|
fireEvent.click(appsButton);
|
||||||
@@ -717,10 +722,17 @@ describe("Menubar interaction with expanded Dialog", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Menubar logout flow", () => {
|
describe("Menubar logout flow", () => {
|
||||||
|
const redirectToMock = redirectTo as jest.Mock;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
redirectToMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
redirectToMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderMenubar(user = { name: "John", email: "test@example.com" }) {
|
function renderMenubar(user = { name: "John", email: "test@example.com" }) {
|
||||||
@@ -756,6 +768,7 @@ describe("Menubar logout flow", () => {
|
|||||||
fireEvent.click(screen.getByText("menubar.logout"));
|
fireEvent.click(screen.getByText("menubar.logout"));
|
||||||
});
|
});
|
||||||
expect(logoutSpy).toHaveBeenCalled();
|
expect(logoutSpy).toHaveBeenCalled();
|
||||||
|
expect(redirectToMock).toHaveBeenCalledWith("https://logout.url/");
|
||||||
|
|
||||||
expect(sessionStorage.length).toBe(0);
|
expect(sessionStorage.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ describe("ResponsiveDialog", () => {
|
|||||||
onClose={mockOnClose}
|
onClose={mockOnClose}
|
||||||
title="Test"
|
title="Test"
|
||||||
isExpanded={true}
|
isExpanded={true}
|
||||||
headerHeight="100px"
|
headerHeight="70px"
|
||||||
>
|
>
|
||||||
<div>Custom Header Content</div>
|
<div>Custom Header Content</div>
|
||||||
</ResponsiveDialog>
|
</ResponsiveDialog>
|
||||||
|
|||||||
@@ -164,12 +164,12 @@ describe("Event Preview Display", () => {
|
|||||||
expect(screen.getByText(/January/i)).toBeInTheDocument();
|
expect(screen.getByText(/January/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/15/)).toBeInTheDocument();
|
expect(screen.getByText(/15/)).toBeInTheDocument();
|
||||||
|
|
||||||
// Check time is displayed with exact values
|
// Check time is displayed in 24h format (no AM/PM)
|
||||||
// Format: "Wednesday, January 15, 2025 at 10:00 AM" and " – 10:00 AM" are in separate elements
|
// Format: "Wednesday, January 15, 2025 at 10:00" and " – 10:00" are in separate elements
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/Wednesday, January 15, 2025 at 10:00 AM/)
|
screen.getByText(/Wednesday, January 15, 2025 at 10:00/)
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText(/– 10:00 AM/)).toBeInTheDocument();
|
expect(screen.getByText(/– 10:00/)).toBeInTheDocument();
|
||||||
|
|
||||||
expect(screen.getByText("Calendar")).toBeInTheDocument();
|
expect(screen.getByText("Calendar")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -1719,6 +1719,9 @@ describe("Event Full Display", () => {
|
|||||||
|
|
||||||
expect(screen.getByDisplayValue("Test Event")).toBeInTheDocument();
|
expect(screen.getByDisplayValue("Test Event")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Expand to show date/time inputs (normal mode shows DateTimeSummary)
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
|
||||||
|
|
||||||
const startDateInput = screen.getByTestId("start-date-input");
|
const startDateInput = screen.getByTestId("start-date-input");
|
||||||
const startTimeInput = screen.getByTestId("start-time-input");
|
const startTimeInput = screen.getByTestId("start-time-input");
|
||||||
const endTimeInput = screen.getByTestId("end-time-input");
|
const endTimeInput = screen.getByTestId("end-time-input");
|
||||||
@@ -1804,6 +1807,7 @@ describe("Event Full Display", () => {
|
|||||||
preloadedState
|
preloadedState
|
||||||
);
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
|
||||||
const allDayCheckbox = screen.getByLabelText("event.form.allDay");
|
const allDayCheckbox = screen.getByLabelText("event.form.allDay");
|
||||||
fireEvent.click(allDayCheckbox);
|
fireEvent.click(allDayCheckbox);
|
||||||
expect(allDayCheckbox).toBeChecked();
|
expect(allDayCheckbox).toBeChecked();
|
||||||
@@ -1878,6 +1882,7 @@ describe("Event Full Display", () => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
|
||||||
const calendarSelect = screen.getByLabelText("event.form.calendar");
|
const calendarSelect = screen.getByLabelText("event.form.calendar");
|
||||||
await act(async () => fireEvent.mouseDown(calendarSelect));
|
await act(async () => fireEvent.mouseDown(calendarSelect));
|
||||||
|
|
||||||
@@ -1947,9 +1952,9 @@ describe("Event Full Display", () => {
|
|||||||
stateWithTimezone
|
stateWithTimezone
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Expand to show timezone selector (normal mode hides it)
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
|
||||||
// The timezone select should have Asia/Bangkok selected
|
// The timezone select should have Asia/Bangkok selected
|
||||||
// Since the component uses formatLocalDateTime, the displayed time will be in local format
|
|
||||||
// but the timezone selector should show Asia/Bangkok
|
|
||||||
const timeZone = screen.getByDisplayValue(/Asia\/Bangkok/i);
|
const timeZone = screen.getByDisplayValue(/Asia\/Bangkok/i);
|
||||||
expect(timeZone).toBeInTheDocument();
|
expect(timeZone).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -124,8 +124,10 @@ describe("EventPopover", () => {
|
|||||||
});
|
});
|
||||||
expect(addDescriptionButton).toBeInTheDocument();
|
expect(addDescriptionButton).toBeInTheDocument();
|
||||||
|
|
||||||
const calendarSelect = screen.getByRole("combobox", { name: /calendar/i });
|
// In normal mode calendar is a SectionPreviewRow (button), not combobox
|
||||||
expect(calendarSelect).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Calendar 1" })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
// Check button
|
// Check button
|
||||||
expect(
|
expect(
|
||||||
@@ -154,7 +156,8 @@ describe("EventPopover", () => {
|
|||||||
allDay: false,
|
allDay: false,
|
||||||
} as unknown as DateSelectArg);
|
} as unknown as DateSelectArg);
|
||||||
|
|
||||||
// MUI DatePicker/TimePicker values are stored differently - just check elements exist
|
// Expand to show date/time inputs (normal mode shows DateTimeSummary)
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
|
||||||
expect(screen.getByTestId("start-date-input")).toBeInTheDocument();
|
expect(screen.getByTestId("start-date-input")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("start-time-input")).toBeInTheDocument();
|
expect(screen.getByTestId("start-time-input")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -179,6 +182,10 @@ describe("EventPopover", () => {
|
|||||||
"Event Description"
|
"Event Description"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Expand location section (normal mode shows SectionPreviewRow)
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "event.form.locationPlaceholder" })
|
||||||
|
);
|
||||||
fireEvent.change(screen.getByLabelText("event.form.location"), {
|
fireEvent.change(screen.getByLabelText("event.form.location"), {
|
||||||
target: { value: "Conference Room" },
|
target: { value: "Conference Room" },
|
||||||
});
|
});
|
||||||
@@ -190,13 +197,14 @@ describe("EventPopover", () => {
|
|||||||
it("changes selected calendar", async () => {
|
it("changes selected calendar", async () => {
|
||||||
renderPopover();
|
renderPopover();
|
||||||
|
|
||||||
|
// Expand to show calendar combobox (normal mode shows SectionPreviewRow)
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
|
||||||
const select = screen.getByLabelText("event.form.calendar");
|
const select = screen.getByLabelText("event.form.calendar");
|
||||||
fireEvent.mouseDown(select); // Open menu
|
fireEvent.mouseDown(select); // Open menu
|
||||||
|
|
||||||
const option = await screen.findByText("Calendar 2");
|
const option = await screen.findByText("Calendar 2");
|
||||||
fireEvent.click(option);
|
fireEvent.click(option);
|
||||||
|
|
||||||
// Find the calendar combobox specifically by its aria-labelledby
|
|
||||||
const calendarSelect = screen.getByRole("combobox", { name: /Calendar/i });
|
const calendarSelect = screen.getByRole("combobox", { name: /Calendar/i });
|
||||||
expect(calendarSelect).toHaveTextContent("Calendar 2");
|
expect(calendarSelect).toHaveTextContent("Calendar 2");
|
||||||
});
|
});
|
||||||
@@ -290,6 +298,9 @@ describe("EventPopover", () => {
|
|||||||
fireEvent.change(screen.getByLabelText("event.form.description"), {
|
fireEvent.change(screen.getByLabelText("event.form.description"), {
|
||||||
target: { value: newEvent.description },
|
target: { value: newEvent.description },
|
||||||
});
|
});
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "event.form.locationPlaceholder" })
|
||||||
|
);
|
||||||
fireEvent.change(screen.getByLabelText("event.form.location"), {
|
fireEvent.change(screen.getByLabelText("event.form.location"), {
|
||||||
target: { value: newEvent.location },
|
target: { value: newEvent.location },
|
||||||
});
|
});
|
||||||
@@ -338,10 +349,11 @@ describe("EventPopover", () => {
|
|||||||
|
|
||||||
it("BUGFIX: Prefill Calendar field", async () => {
|
it("BUGFIX: Prefill Calendar field", async () => {
|
||||||
renderPopover();
|
renderPopover();
|
||||||
|
// In normal mode calendar is a SectionPreviewRow (button) with calendar name
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByLabelText("event.form.calendar")).toHaveTextContent(
|
expect(
|
||||||
"Calendar 1"
|
screen.getByRole("button", { name: "Calendar 1" })
|
||||||
)
|
).toHaveTextContent("Calendar 1")
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -357,6 +369,9 @@ describe("EventPopover", () => {
|
|||||||
} as unknown as DateSelectArg;
|
} as unknown as DateSelectArg;
|
||||||
|
|
||||||
renderPopover(selectedRange);
|
renderPopover(selectedRange);
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "common.moreOptions" })
|
||||||
|
);
|
||||||
|
|
||||||
const allDayCheckbox = screen.getByLabelText("event.form.allDay");
|
const allDayCheckbox = screen.getByLabelText("event.form.allDay");
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -375,6 +390,9 @@ describe("EventPopover", () => {
|
|||||||
} as unknown as DateSelectArg;
|
} as unknown as DateSelectArg;
|
||||||
|
|
||||||
renderPopover(selectedRange);
|
renderPopover(selectedRange);
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "common.moreOptions" })
|
||||||
|
);
|
||||||
|
|
||||||
const allDayCheckbox = screen.getByLabelText("event.form.allDay");
|
const allDayCheckbox = screen.getByLabelText("event.form.allDay");
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -403,6 +421,9 @@ describe("EventPopover", () => {
|
|||||||
} as unknown as DateSelectArg;
|
} as unknown as DateSelectArg;
|
||||||
|
|
||||||
renderPopover(selectedRange);
|
renderPopover(selectedRange);
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "common.moreOptions" })
|
||||||
|
);
|
||||||
|
|
||||||
const allDayCheckbox = screen.getByLabelText("event.form.allDay");
|
const allDayCheckbox = screen.getByLabelText("event.form.allDay");
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -427,6 +448,9 @@ describe("EventPopover", () => {
|
|||||||
} as unknown as DateSelectArg;
|
} as unknown as DateSelectArg;
|
||||||
|
|
||||||
renderPopover(selectedRange);
|
renderPopover(selectedRange);
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "common.moreOptions" })
|
||||||
|
);
|
||||||
|
|
||||||
const allDayCheckbox = screen.getByLabelText("event.form.allDay");
|
const allDayCheckbox = screen.getByLabelText("event.form.allDay");
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -451,6 +475,9 @@ describe("EventPopover", () => {
|
|||||||
} as unknown as DateSelectArg;
|
} as unknown as DateSelectArg;
|
||||||
|
|
||||||
renderPopover(selectedRange);
|
renderPopover(selectedRange);
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "common.moreOptions" })
|
||||||
|
);
|
||||||
|
|
||||||
const allDayCheckbox = screen.getByLabelText("event.form.allDay");
|
const allDayCheckbox = screen.getByLabelText("event.form.allDay");
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -1176,7 +1176,8 @@ describe("Event URL handling for recurring events", () => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Change calendar
|
// Expand to show calendar combobox (normal mode shows SectionPreviewRow)
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
|
||||||
fireEvent.mouseDown(screen.getByLabelText("event.form.calendar"));
|
fireEvent.mouseDown(screen.getByLabelText("event.form.calendar"));
|
||||||
const option = await screen.findByText("Calendar 2");
|
const option = await screen.findByText("Calendar 2");
|
||||||
fireEvent.click(option);
|
fireEvent.click(option);
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
|
|||||||
screen.queryByDisplayValue("Modified Instance Title")
|
screen.queryByDisplayValue("Modified Instance Title")
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Expand more options to show timezone (normal mode shows summary first)
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "common.moreOptions" })
|
||||||
|
);
|
||||||
// Verify timezone dropdown shows master timezone
|
// Verify timezone dropdown shows master timezone
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByDisplayValue(/New York/i)).toBeDefined();
|
expect(screen.getByDisplayValue(/New York/i)).toBeDefined();
|
||||||
@@ -493,8 +497,12 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
|
|||||||
expect(screen.getByDisplayValue("Daily Standup")).toBeInTheDocument();
|
expect(screen.getByDisplayValue("Daily Standup")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Expand more options to show date/time inputs (normal mode shows DateTimeSummary first)
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "common.moreOptions" })
|
||||||
|
);
|
||||||
|
const input = await waitFor(() => screen.getByTestId("start-time-input"));
|
||||||
// Change time to 2:00 PM (14:00)
|
// Change time to 2:00 PM (14:00)
|
||||||
const input = screen.getByTestId("start-time-input");
|
|
||||||
await userEvent.click(input);
|
await userEvent.click(input);
|
||||||
await userEvent.clear(input);
|
await userEvent.clear(input);
|
||||||
await userEvent.type(input, "14:00{enter}");
|
await userEvent.type(input, "14:00{enter}");
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ describe("EventUpdateModal Timezone Handling", () => {
|
|||||||
const titleInput = screen.getByDisplayValue("Timezone Event");
|
const titleInput = screen.getByDisplayValue("Timezone Event");
|
||||||
expect(titleInput).toBeInTheDocument();
|
expect(titleInput).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Expand to show date/time inputs (normal mode shows DateTimeSummary)
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
|
||||||
|
|
||||||
// Verify the start date and time inputs exist
|
// Verify the start date and time inputs exist
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const startDateInput = screen.getByTestId("start-date-input");
|
const startDateInput = screen.getByTestId("start-date-input");
|
||||||
@@ -282,6 +285,7 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
|
|||||||
expect(mockGetMasterEvent).toHaveBeenCalled();
|
expect(mockGetMasterEvent).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
|
||||||
// Wait for the repeat checkbox to be checked (indicating form is initialized)
|
// Wait for the repeat checkbox to be checked (indicating form is initialized)
|
||||||
const repeatCheckbox = await waitFor(() => {
|
const repeatCheckbox = await waitFor(() => {
|
||||||
const checkbox = screen.getByLabelText("event.form.repeat");
|
const checkbox = screen.getByLabelText("event.form.repeat");
|
||||||
@@ -414,6 +418,7 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
|
|||||||
expect(screen.getByDisplayValue("Recurring Meeting")).toBeInTheDocument();
|
expect(screen.getByDisplayValue("Recurring Meeting")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
|
||||||
// Wait for repeat checkbox to be checked
|
// Wait for repeat checkbox to be checked
|
||||||
const repeatCheckbox = await waitFor(() => {
|
const repeatCheckbox = await waitFor(() => {
|
||||||
const checkbox = screen.getByLabelText("event.form.repeat");
|
const checkbox = screen.getByLabelText("event.form.repeat");
|
||||||
@@ -546,6 +551,7 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
|
|||||||
expect(screen.getByDisplayValue("Recurring Meeting")).toBeInTheDocument();
|
expect(screen.getByDisplayValue("Recurring Meeting")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
|
||||||
// Uncheck repeat and save
|
// Uncheck repeat and save
|
||||||
const repeatCheckbox = screen.getByLabelText("event.form.repeat");
|
const repeatCheckbox = screen.getByLabelText("event.form.repeat");
|
||||||
|
|
||||||
|
|||||||
Generated
+5
-4
@@ -17,7 +17,7 @@
|
|||||||
"@fullcalendar/moment-timezone": "^6.1.19",
|
"@fullcalendar/moment-timezone": "^6.1.19",
|
||||||
"@fullcalendar/react": "^6.1.17",
|
"@fullcalendar/react": "^6.1.17",
|
||||||
"@fullcalendar/timegrid": "^6.1.17",
|
"@fullcalendar/timegrid": "^6.1.17",
|
||||||
"@linagora/twake-mui": "1.1.4",
|
"@linagora/twake-mui": "1.1.8",
|
||||||
"@lottiefiles/dotlottie-react": "^0.17.13",
|
"@lottiefiles/dotlottie-react": "^0.17.13",
|
||||||
"@mui/icons-material": "^7.1.2",
|
"@mui/icons-material": "^7.1.2",
|
||||||
"@mui/material": "^7.1.2",
|
"@mui/material": "^7.1.2",
|
||||||
@@ -3136,9 +3136,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@linagora/twake-mui": {
|
"node_modules/@linagora/twake-mui": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.8",
|
||||||
"resolved": "https://npm.pkg.github.com/download/@linagora/twake-mui/1.1.4/56321d9e10cd99550e751f15c34aaac6842442c1",
|
"resolved": "https://npm.pkg.github.com/download/@linagora/twake-mui/1.1.8/e629a126e62dffd939a8d3d8de3ce024de5a2154",
|
||||||
"integrity": "sha512-L6e+6KgqFUxFY6avmk4UG4/bsdfzo6TxBkahUZlKxvCNEEikVe8z2Cxd0JtUUAWrPGZonTII9n2KhdFSNmNxvg==",
|
"integrity": "sha512-H2MxtZIy5w1A8VNTNAoM4Pjuph/et4YP8DfzQ+5HCsjRJn6SA/T3nZRZOqC26SxY9kl3XJCKIEKklL+x5af7Vg==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0"
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
"@fullcalendar/moment-timezone": "^6.1.19",
|
"@fullcalendar/moment-timezone": "^6.1.19",
|
||||||
"@fullcalendar/react": "^6.1.17",
|
"@fullcalendar/react": "^6.1.17",
|
||||||
"@fullcalendar/timegrid": "^6.1.17",
|
"@fullcalendar/timegrid": "^6.1.17",
|
||||||
"@linagora/twake-mui": "1.1.4",
|
"@linagora/twake-mui": "1.1.8",
|
||||||
"@lottiefiles/dotlottie-react": "^0.17.13",
|
"@lottiefiles/dotlottie-react": "^0.17.13",
|
||||||
"@mui/icons-material": "^7.1.2",
|
"@mui/icons-material": "^7.1.2",
|
||||||
"@mui/material": "^7.1.2",
|
"@mui/material": "^7.1.2",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export default function UserSearch({
|
|||||||
setAttendees,
|
setAttendees,
|
||||||
disabled,
|
disabled,
|
||||||
inputSlot,
|
inputSlot,
|
||||||
|
placeholder,
|
||||||
}: {
|
}: {
|
||||||
attendees: userAttendee[];
|
attendees: userAttendee[];
|
||||||
setAttendees: Function;
|
setAttendees: Function;
|
||||||
@@ -19,8 +20,9 @@ export default function UserSearch({
|
|||||||
inputSlot?: (
|
inputSlot?: (
|
||||||
params: ExtendedAutocompleteRenderInputParams
|
params: ExtendedAutocompleteRenderInputParams
|
||||||
) => React.ReactNode;
|
) => React.ReactNode;
|
||||||
|
placeholder?: string;
|
||||||
}) {
|
}) {
|
||||||
const [selectedUsers, setSelectedUsers] = useState(
|
const [selectedUsers, setSelectedUsers] = useState<User[]>(
|
||||||
attendees.map((attendee) => ({
|
attendees.map((attendee) => ({
|
||||||
email: attendee.cal_address,
|
email: attendee.cal_address,
|
||||||
displayName: attendee.cn ?? "",
|
displayName: attendee.cn ?? "",
|
||||||
@@ -44,6 +46,7 @@ export default function UserSearch({
|
|||||||
objectTypes={["user", "contact"]}
|
objectTypes={["user", "contact"]}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
inputSlot={inputSlot}
|
inputSlot={inputSlot}
|
||||||
|
placeholder={placeholder}
|
||||||
onChange={(event: any, value: User[]) => {
|
onChange={(event: any, value: User[]) => {
|
||||||
setAttendees(
|
setAttendees(
|
||||||
value.map((attendee: User) =>
|
value.map((attendee: User) =>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
.main-layout
|
.main-layout
|
||||||
display flex
|
display flex
|
||||||
height calc(100vh - 90px)
|
height calc(100vh - 70px)
|
||||||
|
|
||||||
.main-layout.calendar-layout
|
.main-layout.calendar-layout
|
||||||
background-color #fff
|
background-color #fff
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
margin-top 2px
|
margin-top 2px
|
||||||
width 6px
|
width 6px
|
||||||
height 6px
|
height 6px
|
||||||
background-color #d93025
|
background-color #1D192B1F
|
||||||
border-radius 50%
|
border-radius 50%
|
||||||
margin-left auto
|
margin-left auto
|
||||||
margin-right auto
|
margin-right auto
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ span.fc-daygrid-day-number.current-date
|
|||||||
color #fff
|
color #fff
|
||||||
|
|
||||||
span.fc-daygrid-day-number.current-date:after
|
span.fc-daygrid-day-number.current-date:after
|
||||||
background-color #f67e35
|
background-color #FB9E3A
|
||||||
|
|
||||||
td.fc-timegrid-slot.fc-timegrid-slot-label.fc-scrollgrid-shrink,
|
td.fc-timegrid-slot.fc-timegrid-slot-label.fc-scrollgrid-shrink,
|
||||||
td.fc-timegrid-slot.fc-timegrid-slot-label.fc-timegrid-slot-minor
|
td.fc-timegrid-slot.fc-timegrid-slot-label.fc-timegrid-slot-minor
|
||||||
@@ -244,7 +244,7 @@ tbody > tr.fc-scrollgrid-section:first-of-type .fc-scroller
|
|||||||
overflow hidden !important
|
overflow hidden !important
|
||||||
|
|
||||||
.navigation-controls
|
.navigation-controls
|
||||||
margin-right 40px
|
margin-right 16px
|
||||||
|
|
||||||
.fc .fc-timegrid-slot-minor
|
.fc .fc-timegrid-slot-minor
|
||||||
border-top-style: none
|
border-top-style: none
|
||||||
|
|||||||
@@ -50,21 +50,26 @@ export function SettingsTab({
|
|||||||
<>
|
<>
|
||||||
{/* Form group 1: Name field - first group, margin top 0 */}
|
{/* Form group 1: Name field - first group, margin top 0 */}
|
||||||
<Box mt={0}>
|
<Box mt={0}>
|
||||||
<TextField
|
<Typography variant="h6" sx={{ margin: 0 }}>
|
||||||
fullWidth
|
{t("calendarPopover.settings.calendarName")}
|
||||||
label=""
|
</Typography>
|
||||||
inputProps={{ "aria-label": t("common.name") }}
|
<Box sx={{ marginTop: "6px" }}>
|
||||||
placeholder={t("common.name")}
|
<TextField
|
||||||
value={name}
|
fullWidth
|
||||||
onChange={(e) => setName(e.target.value)}
|
label=""
|
||||||
size="small"
|
inputProps={{ "aria-label": t("common.name") }}
|
||||||
sx={{
|
placeholder={t("common.name")}
|
||||||
"&.MuiFormControl-root": {
|
value={name}
|
||||||
marginTop: 0,
|
onChange={(e) => setName(e.target.value)}
|
||||||
marginBottom: 0,
|
size="small"
|
||||||
},
|
sx={{
|
||||||
}}
|
"&.MuiFormControl-root": {
|
||||||
/>
|
marginTop: 0,
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Form group 2: Description */}
|
{/* Form group 2: Description */}
|
||||||
@@ -75,8 +80,6 @@ export function SettingsTab({
|
|||||||
showMore={false}
|
showMore={false}
|
||||||
description={description}
|
description={description}
|
||||||
setDescription={setDescription}
|
setDescription={setDescription}
|
||||||
buttonVariant="contained"
|
|
||||||
buttonColor="secondary"
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ A highly reusable dialog component that supports both normal and expanded (fulls
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- ✅ **Two Modes**: Normal popup (685px) and expanded fullscreen mode
|
- ✅ **Two Modes**: Normal popup (570px) and expanded fullscreen mode
|
||||||
- ✅ **Preserves Header**: Expanded mode doesn't cover app header (90px default)
|
- ✅ **Preserves Header**: Expanded mode doesn't cover app header (70px default)
|
||||||
- ✅ **Clean Expanded View**: No backdrop/shadow in expanded mode for seamless integration
|
- ✅ **Clean Expanded View**: No backdrop/shadow in expanded mode for seamless integration
|
||||||
- ✅ **Instant Transition**: No animation when expanding for immediate feedback
|
- ✅ **Instant Transition**: No animation when expanding for immediate feedback
|
||||||
- ✅ **Back Navigation**: Expanded mode shows back arrow icon in header for easy collapse
|
- ✅ **Back Navigation**: Expanded mode shows back arrow icon in header for easy collapse
|
||||||
@@ -62,9 +62,9 @@ function MyComponent() {
|
|||||||
| `actions` | `ReactNode` | - | Action buttons |
|
| `actions` | `ReactNode` | - | Action buttons |
|
||||||
| `isExpanded` | `boolean` | `false` | Toggle fullscreen mode |
|
| `isExpanded` | `boolean` | `false` | Toggle fullscreen mode |
|
||||||
| `onExpandToggle` | `() => void` | - | Handler for back button click when expanded |
|
| `onExpandToggle` | `() => void` | - | Handler for back button click when expanded |
|
||||||
| `normalMaxWidth` | `string` | `"685px"` | Max width in normal mode |
|
| `normalMaxWidth` | `string` | `"570px"` | Max width in normal mode |
|
||||||
| `expandedContentMaxWidth` | `string` | `"990px"` | Content container max-width in expanded mode |
|
| `expandedContentMaxWidth` | `string` | `"990px"` | Content container max-width in expanded mode |
|
||||||
| `headerHeight` | `string` | `"90px"` | App header height to preserve |
|
| `headerHeight` | `string` | `"70px"` | App header height to preserve |
|
||||||
| `normalSpacing` | `number` | `2` | Stack spacing in normal mode (MUI spacing units: 1 = 8px) |
|
| `normalSpacing` | `number` | `2` | Stack spacing in normal mode (MUI spacing units: 1 = 8px) |
|
||||||
| `expandedSpacing` | `number` | `2` | Stack spacing in expanded mode (MUI spacing units: 1 = 8px) |
|
| `expandedSpacing` | `number` | `2` | Stack spacing in expanded mode (MUI spacing units: 1 = 8px) |
|
||||||
| `contentSx` | `SxProps<Theme>` | - | Custom styles for DialogContent |
|
| `contentSx` | `SxProps<Theme>` | - | Custom styles for DialogContent |
|
||||||
@@ -149,7 +149,7 @@ For apps with different header heights:
|
|||||||
open={open}
|
open={open}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
title="Custom Header Height"
|
title="Custom Header Height"
|
||||||
headerHeight="80px"
|
headerHeight="70px"
|
||||||
isExpanded={true}
|
isExpanded={true}
|
||||||
>
|
>
|
||||||
<TextField label="Content" />
|
<TextField label="Content" />
|
||||||
@@ -160,7 +160,7 @@ For apps with different header heights:
|
|||||||
|
|
||||||
### Normal Mode (`isExpanded={false}`)
|
### Normal Mode (`isExpanded={false}`)
|
||||||
|
|
||||||
- Dialog: max-width = `normalMaxWidth` (default 685px)
|
- Dialog: max-width = `normalMaxWidth` (default 570px)
|
||||||
- Content: 100% width
|
- Content: 100% width
|
||||||
- Height: auto (fits content)
|
- Height: auto (fits content)
|
||||||
- Position: centered with 32px margin
|
- Position: centered with 32px margin
|
||||||
@@ -215,7 +215,7 @@ import { ResponsiveDialog } from "@/components/Dialog";
|
|||||||
1. **Use `isExpanded` for Show More/Less**: Toggle between modes for better UX
|
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
|
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
|
3. **Hide expand button in actions when expanded**: Back arrow in header provides collapse action
|
||||||
4. **Keep `normalMaxWidth` reasonable**: 685px works well for forms
|
4. **Keep `normalMaxWidth` reasonable**: 570px works well for forms
|
||||||
5. **Center content in expanded mode**: Default 990px provides good reading width
|
5. **Center content in expanded mode**: Default 990px provides good reading width
|
||||||
6. **Preserve header**: Default 90px - adjust via `headerHeight` prop if needed
|
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
|
7. **MUI Stack handles spacing**: Children wrapped in Stack with configurable spacing prop - override with `normalSpacing`/`expandedSpacing` if needed
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import React, { ReactNode } from "react";
|
|||||||
* ResponsiveDialog - A reusable dialog component that can switch between normal and expanded modes
|
* ResponsiveDialog - A reusable dialog component that can switch between normal and expanded modes
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Normal mode: Dialog with customizable max-width (default 685px)
|
* - Normal mode: Dialog with customizable max-width (default 570px)
|
||||||
* - Expanded mode: Full height dialog (excluding app header) with centered content container
|
* - Expanded mode: Full height dialog (excluding app header) with centered content container
|
||||||
* - Fully customizable with sx props for Dialog, DialogTitle, and DialogContent
|
* - Fully customizable with sx props for Dialog, DialogTitle, and DialogContent
|
||||||
* - Preserves app header visibility in expanded mode
|
* - Preserves app header visibility in expanded mode
|
||||||
@@ -58,7 +58,7 @@ interface ResponsiveDialogProps extends Omit<
|
|||||||
isExpanded?: boolean;
|
isExpanded?: boolean;
|
||||||
/** Callback when expand/collapse button is clicked (required if using isExpanded) */
|
/** Callback when expand/collapse button is clicked (required if using isExpanded) */
|
||||||
onExpandToggle?: () => void;
|
onExpandToggle?: () => void;
|
||||||
/** Max width in normal mode (default: "685px") */
|
/** Max width in normal mode (default: "570px") */
|
||||||
normalMaxWidth?: string;
|
normalMaxWidth?: string;
|
||||||
/** Max width of content container in expanded mode (default: "990px") */
|
/** Max width of content container in expanded mode (default: "990px") */
|
||||||
expandedContentMaxWidth?: string;
|
expandedContentMaxWidth?: string;
|
||||||
@@ -98,9 +98,9 @@ function ResponsiveDialog({
|
|||||||
actions,
|
actions,
|
||||||
isExpanded = false,
|
isExpanded = false,
|
||||||
onExpandToggle,
|
onExpandToggle,
|
||||||
normalMaxWidth = "685px",
|
normalMaxWidth = "570px",
|
||||||
expandedContentMaxWidth = "990px",
|
expandedContentMaxWidth = "990px",
|
||||||
headerHeight = "90px",
|
headerHeight = "70px",
|
||||||
normalSpacing = 2,
|
normalSpacing = 2,
|
||||||
expandedSpacing = 2,
|
expandedSpacing = 2,
|
||||||
contentSx,
|
contentSx,
|
||||||
@@ -116,6 +116,7 @@ function ResponsiveDialog({
|
|||||||
}: ResponsiveDialogProps) {
|
}: ResponsiveDialogProps) {
|
||||||
const baseSx: SxProps<Theme> = {
|
const baseSx: SxProps<Theme> = {
|
||||||
"& .MuiBackdrop-root": {
|
"& .MuiBackdrop-root": {
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.1)",
|
||||||
opacity: isExpanded ? "0 !important" : undefined,
|
opacity: isExpanded ? "0 !important" : undefined,
|
||||||
transition: isExpanded ? "none !important" : undefined,
|
transition: isExpanded ? "none !important" : undefined,
|
||||||
pointerEvents: isExpanded ? "none" : undefined,
|
pointerEvents: isExpanded ? "none" : undefined,
|
||||||
@@ -202,8 +203,9 @@ function ResponsiveDialog({
|
|||||||
onClick={onExpandToggle}
|
onClick={onExpandToggle}
|
||||||
aria-label="expand"
|
aria-label="expand"
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={{ marginRight: 1 }}
|
||||||
>
|
>
|
||||||
<OpenInFullIcon />
|
<OpenInFullIcon sx={{ padding: "2px" }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
<IconButton onClick={onClose} aria-label="close" size="small">
|
<IconButton onClick={onClose} aria-label="close" size="small">
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Box, Button, TextField } from "@linagora/twake-mui";
|
import { TextField } from "@linagora/twake-mui";
|
||||||
import { Description as DescriptionIcon } from "@mui/icons-material";
|
import { Notes as NotesIcon } from "@mui/icons-material";
|
||||||
|
import React from "react";
|
||||||
import { useI18n } from "twake-i18n";
|
import { useI18n } from "twake-i18n";
|
||||||
import { FieldWithLabel } from "./components/FieldWithLabel";
|
import { FieldWithLabel } from "./components/FieldWithLabel";
|
||||||
|
import { SectionPreviewRow } from "./components/SectionPreviewRow";
|
||||||
|
|
||||||
export function AddDescButton({
|
export function AddDescButton({
|
||||||
showDescription,
|
showDescription,
|
||||||
@@ -9,73 +11,72 @@ export function AddDescButton({
|
|||||||
showMore,
|
showMore,
|
||||||
description,
|
description,
|
||||||
setDescription,
|
setDescription,
|
||||||
buttonVariant,
|
|
||||||
buttonColor,
|
|
||||||
}: {
|
}: {
|
||||||
showDescription: boolean;
|
showDescription: boolean;
|
||||||
setShowDescription: (b: boolean) => void;
|
setShowDescription: (b: boolean) => void;
|
||||||
showMore: boolean;
|
showMore: boolean;
|
||||||
description: string;
|
description: string;
|
||||||
setDescription: (d: string) => void;
|
setDescription: (d: string) => void;
|
||||||
buttonVariant?: "text" | "outlined" | "contained";
|
|
||||||
buttonColor?:
|
|
||||||
| "inherit"
|
|
||||||
| "primary"
|
|
||||||
| "secondary"
|
|
||||||
| "success"
|
|
||||||
| "error"
|
|
||||||
| "info"
|
|
||||||
| "warning";
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const descriptionInputRef = React.useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (showDescription) {
|
||||||
|
descriptionInputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [showDescription]);
|
||||||
|
|
||||||
|
const descriptionField = (
|
||||||
|
<FieldWithLabel
|
||||||
|
label={t("event.form.description")}
|
||||||
|
isExpanded={showMore}
|
||||||
|
sx={{ padding: 0, margin: 0 }}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label=""
|
||||||
|
inputRef={descriptionInputRef}
|
||||||
|
inputProps={{ "aria-label": t("event.form.description") }}
|
||||||
|
placeholder={t("event.form.descriptionPlaceholder")}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
margin="dense"
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
maxRows={10}
|
||||||
|
sx={{
|
||||||
|
"& .MuiInputBase-root": {
|
||||||
|
maxHeight: "33%",
|
||||||
|
overflowY: "auto",
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
"& textarea": {
|
||||||
|
resize: "vertical",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FieldWithLabel>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (showMore) {
|
||||||
|
return descriptionField;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!showDescription && (
|
{!showDescription && (
|
||||||
<FieldWithLabel label=" " isExpanded={showMore}>
|
<FieldWithLabel label="" isExpanded={showMore}>
|
||||||
<Box display="flex" gap={1}>
|
<SectionPreviewRow
|
||||||
<Button
|
icon={<NotesIcon />}
|
||||||
startIcon={<DescriptionIcon />}
|
onClick={() => setShowDescription(true)}
|
||||||
onClick={() => setShowDescription(true)}
|
>
|
||||||
size="medium"
|
{t("event.form.addDescription")}
|
||||||
variant={buttonVariant}
|
</SectionPreviewRow>
|
||||||
color={buttonColor}
|
|
||||||
>
|
|
||||||
{t("event.form.addDescription")}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</FieldWithLabel>
|
|
||||||
)}
|
|
||||||
{showDescription && (
|
|
||||||
<FieldWithLabel
|
|
||||||
label={t("event.form.description")}
|
|
||||||
isExpanded={showMore}
|
|
||||||
sx={{ padding: 0, margin: 0 }}
|
|
||||||
>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label=""
|
|
||||||
inputProps={{ "aria-label": t("event.form.description") }}
|
|
||||||
placeholder={t("event.form.descriptionPlaceholder")}
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
size="small"
|
|
||||||
margin="dense"
|
|
||||||
multiline
|
|
||||||
minRows={2}
|
|
||||||
maxRows={10}
|
|
||||||
sx={{
|
|
||||||
"& .MuiInputBase-root": {
|
|
||||||
maxHeight: "33%",
|
|
||||||
overflowY: "auto",
|
|
||||||
padding: 0,
|
|
||||||
},
|
|
||||||
"& textarea": {
|
|
||||||
resize: "vertical",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FieldWithLabel>
|
</FieldWithLabel>
|
||||||
)}
|
)}
|
||||||
|
{showDescription && descriptionField}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import iconCamera from "@/static/images/icon-camera.svg";
|
|||||||
import {
|
import {
|
||||||
addVideoConferenceToDescription,
|
addVideoConferenceToDescription,
|
||||||
generateMeetingLink,
|
generateMeetingLink,
|
||||||
|
removeVideoConferenceFromDescription,
|
||||||
} from "@/utils/videoConferenceUtils";
|
} from "@/utils/videoConferenceUtils";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -20,12 +21,16 @@ import {
|
|||||||
ToggleButton,
|
ToggleButton,
|
||||||
ToggleButtonGroup,
|
ToggleButtonGroup,
|
||||||
Typography,
|
Typography,
|
||||||
|
useTheme,
|
||||||
} from "@linagora/twake-mui";
|
} from "@linagora/twake-mui";
|
||||||
|
import { alpha } from "@mui/material/styles";
|
||||||
import {
|
import {
|
||||||
Close as DeleteIcon,
|
Close as DeleteIcon,
|
||||||
ContentCopy as CopyIcon,
|
ContentCopy as CopyIcon,
|
||||||
Public as PublicIcon,
|
Public as PublicIcon,
|
||||||
|
LocationOn as LocationIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
|
import SquareRoundedIcon from "@mui/icons-material/SquareRounded";
|
||||||
import LockOutlineIcon from "@mui/icons-material/LockOutline";
|
import LockOutlineIcon from "@mui/icons-material/LockOutline";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useI18n } from "twake-i18n";
|
import { useI18n } from "twake-i18n";
|
||||||
@@ -35,7 +40,9 @@ import { SnackbarAlert } from "../Loading/SnackBarAlert";
|
|||||||
import { TimezoneAutocomplete } from "../Timezone/TimezoneAutocomplete";
|
import { TimezoneAutocomplete } from "../Timezone/TimezoneAutocomplete";
|
||||||
import { AddDescButton } from "./AddDescButton";
|
import { AddDescButton } from "./AddDescButton";
|
||||||
import { DateTimeFields } from "./components/DateTimeFields";
|
import { DateTimeFields } from "./components/DateTimeFields";
|
||||||
|
import { DateTimeSummary } from "./components/DateTimeSummary";
|
||||||
import { FieldWithLabel } from "./components/FieldWithLabel";
|
import { FieldWithLabel } from "./components/FieldWithLabel";
|
||||||
|
import { SectionPreviewRow } from "./components/SectionPreviewRow";
|
||||||
import RepeatEvent from "./EventRepeat";
|
import RepeatEvent from "./EventRepeat";
|
||||||
import { useAllDayToggle } from "./hooks/useAllDayToggle";
|
import { useAllDayToggle } from "./hooks/useAllDayToggle";
|
||||||
import { combineDateTime, splitDateTime } from "./utils/dateTimeHelpers";
|
import { combineDateTime, splitDateTime } from "./utils/dateTimeHelpers";
|
||||||
@@ -156,6 +163,7 @@ export default function EventFormFields({
|
|||||||
onHasEndDateChangedChange,
|
onHasEndDateChangedChange,
|
||||||
}: EventFormFieldsProps) {
|
}: EventFormFieldsProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
// Internal state for 4 separate fields
|
// Internal state for 4 separate fields
|
||||||
const [startDate, setStartDate] = React.useState("");
|
const [startDate, setStartDate] = React.useState("");
|
||||||
@@ -166,10 +174,26 @@ export default function EventFormFields({
|
|||||||
// Track if user has manually changed end date in extended mode
|
// Track if user has manually changed end date in extended mode
|
||||||
const [hasEndDateChanged, setHasEndDateChanged] = React.useState(false);
|
const [hasEndDateChanged, setHasEndDateChanged] = React.useState(false);
|
||||||
|
|
||||||
// Reset hasEndDateChanged when modal closes
|
// Track if user has clicked on datetime clickable section in normal mode
|
||||||
|
// Once clicked, normal mode will always show full fields until modal closes
|
||||||
|
const [hasClickedDateTimeSection, setHasClickedDateTimeSection] =
|
||||||
|
React.useState(false);
|
||||||
|
|
||||||
|
// Track if user has clicked on location section in normal mode
|
||||||
|
const [hasClickedLocationSection, setHasClickedLocationSection] =
|
||||||
|
React.useState(false);
|
||||||
|
|
||||||
|
// Track if user has clicked on calendar section in normal mode
|
||||||
|
const [hasClickedCalendarSection, setHasClickedCalendarSection] =
|
||||||
|
React.useState(false);
|
||||||
|
|
||||||
|
// Reset hasEndDateChanged and hasClickedDateTimeSection when modal closes
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
setHasEndDateChanged(false);
|
setHasEndDateChanged(false);
|
||||||
|
setHasClickedDateTimeSection(false);
|
||||||
|
setHasClickedLocationSection(false);
|
||||||
|
setHasClickedCalendarSection(false);
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
@@ -210,10 +234,18 @@ export default function EventFormFields({
|
|||||||
|
|
||||||
// Ref for title input field to enable auto-focus
|
// Ref for title input field to enable auto-focus
|
||||||
const titleInputRef = React.useRef<HTMLInputElement>(null);
|
const titleInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const locationInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Track previous showMore state to detect changes
|
// Track previous showMore state to detect changes
|
||||||
const prevShowMoreRef = React.useRef<boolean | undefined>(undefined);
|
const prevShowMoreRef = React.useRef<boolean | undefined>(undefined);
|
||||||
|
|
||||||
|
// Focus location field when user clicks the location preview row
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (hasClickedLocationSection && process.env.NODE_ENV !== "test") {
|
||||||
|
locationInputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [hasClickedLocationSection]);
|
||||||
|
|
||||||
// Auto-focus title field when modal opens (skip in test environment)
|
// Auto-focus title field when modal opens (skip in test environment)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -389,6 +421,9 @@ export default function EventFormFields({
|
|||||||
setDescription(updatedDescription);
|
setDescription(updatedDescription);
|
||||||
setHasVideoConference(true);
|
setHasVideoConference(true);
|
||||||
setMeetingLink(newMeetingLink);
|
setMeetingLink(newMeetingLink);
|
||||||
|
if (showMore) {
|
||||||
|
setShowDescription(true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [openToast, setOpenToast] = React.useState(false);
|
const [openToast, setOpenToast] = React.useState(false);
|
||||||
@@ -405,11 +440,7 @@ export default function EventFormFields({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteVideoConference = () => {
|
const handleDeleteVideoConference = () => {
|
||||||
const updatedDescription = description.replace(
|
setDescription(removeVideoConferenceFromDescription(description));
|
||||||
/\nVisio: https?:\/\/[^\s]+/,
|
|
||||||
""
|
|
||||||
);
|
|
||||||
setDescription(updatedDescription);
|
|
||||||
setHasVideoConference(false);
|
setHasVideoConference(false);
|
||||||
setMeetingLink(null);
|
setMeetingLink(null);
|
||||||
};
|
};
|
||||||
@@ -421,7 +452,10 @@ export default function EventFormFields({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FieldWithLabel label={t("event.form.title")} isExpanded={showMore}>
|
<FieldWithLabel
|
||||||
|
label={showMore ? t("event.form.title") : ""}
|
||||||
|
isExpanded={showMore}
|
||||||
|
>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label=""
|
label=""
|
||||||
@@ -437,156 +471,159 @@ export default function EventFormFields({
|
|||||||
/>
|
/>
|
||||||
</FieldWithLabel>
|
</FieldWithLabel>
|
||||||
|
|
||||||
<AddDescButton
|
<FieldWithLabel
|
||||||
showDescription={showDescription}
|
label={
|
||||||
setShowDescription={setShowDescription}
|
!showMore && !hasClickedDateTimeSection
|
||||||
showMore={showMore}
|
? ""
|
||||||
description={description}
|
: t("event.form.dateTime")
|
||||||
setDescription={setDescription}
|
}
|
||||||
buttonVariant="contained"
|
isExpanded={showMore}
|
||||||
buttonColor="secondary"
|
>
|
||||||
/>
|
{!showMore && !hasClickedDateTimeSection ? (
|
||||||
|
<DateTimeSummary
|
||||||
<FieldWithLabel label={t("event.form.dateTime")} isExpanded={showMore}>
|
startDate={startDate}
|
||||||
<DateTimeFields
|
startTime={startTime}
|
||||||
startDate={startDate}
|
endDate={endDate}
|
||||||
startTime={startTime}
|
endTime={endTime}
|
||||||
endDate={endDate}
|
allday={allday}
|
||||||
endTime={endTime}
|
timezone={timezone}
|
||||||
allday={allday}
|
|
||||||
showMore={showMore}
|
|
||||||
hasEndDateChanged={hasEndDateChanged}
|
|
||||||
validation={validation}
|
|
||||||
onStartDateChange={handleStartDateChange}
|
|
||||||
onStartTimeChange={handleStartTimeChange}
|
|
||||||
onEndDateChange={handleEndDateChange}
|
|
||||||
onEndTimeChange={handleEndTimeChange}
|
|
||||||
showEndDate={
|
|
||||||
showMore ||
|
|
||||||
allday ||
|
|
||||||
(hasEndDateChanged && startDate !== endDate) ||
|
|
||||||
(!showMore && !allday && startDate !== endDate)
|
|
||||||
}
|
|
||||||
onToggleEndDate={() => {}}
|
|
||||||
/>
|
|
||||||
</FieldWithLabel>
|
|
||||||
|
|
||||||
<FieldWithLabel label=" " isExpanded={showMore}>
|
|
||||||
<Box display="flex" gap={2} alignItems="center">
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox checked={allday} onChange={handleAllDayToggle} />
|
|
||||||
}
|
|
||||||
label={
|
|
||||||
<Typography variant="h6">{t("event.form.allDay")}</Typography>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={
|
|
||||||
showRepeat || (typeOfAction === "solo" && !!repetition?.freq)
|
|
||||||
}
|
|
||||||
disabled={typeOfAction === "solo"}
|
|
||||||
onChange={() => {
|
|
||||||
const newShowRepeat = !showRepeat;
|
|
||||||
setShowRepeat(newShowRepeat);
|
|
||||||
if (newShowRepeat) {
|
|
||||||
setRepetition({
|
|
||||||
freq: "daily",
|
|
||||||
interval: 1,
|
|
||||||
occurrences: 0,
|
|
||||||
endDate: "",
|
|
||||||
byday: null,
|
|
||||||
} as RepetitionObject);
|
|
||||||
} else {
|
|
||||||
setRepetition({
|
|
||||||
freq: "",
|
|
||||||
interval: 1,
|
|
||||||
occurrences: 0,
|
|
||||||
endDate: "",
|
|
||||||
byday: null,
|
|
||||||
} as RepetitionObject);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={
|
|
||||||
<Typography variant="h6">{t("event.form.repeat")}</Typography>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TimezoneAutocomplete
|
|
||||||
value={timezone}
|
|
||||||
onChange={setTimezone}
|
|
||||||
zones={timezoneList.zones}
|
|
||||||
getTimezoneOffset={(tzName: string) =>
|
|
||||||
timezoneList.getTimezoneOffset(tzName, new Date(start))
|
|
||||||
}
|
|
||||||
showIcon={false}
|
|
||||||
width={240}
|
|
||||||
size="small"
|
|
||||||
placeholder={t("event.form.timezonePlaceholder")}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</FieldWithLabel>
|
|
||||||
|
|
||||||
{(showRepeat || (typeOfAction === "solo" && repetition?.freq)) && (
|
|
||||||
<FieldWithLabel label=" " isExpanded={showMore}>
|
|
||||||
<RepeatEvent
|
|
||||||
repetition={repetition}
|
repetition={repetition}
|
||||||
eventStart={new Date(start)}
|
showEndDate={
|
||||||
setRepetition={setRepetition}
|
allday ||
|
||||||
isOwn={typeOfAction !== "solo"}
|
(hasEndDateChanged && startDate !== endDate) ||
|
||||||
|
(!allday && startDate !== endDate)
|
||||||
|
}
|
||||||
|
onClick={() => setHasClickedDateTimeSection(true)}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<DateTimeFields
|
||||||
|
startDate={startDate}
|
||||||
|
startTime={startTime}
|
||||||
|
endDate={endDate}
|
||||||
|
endTime={endTime}
|
||||||
|
allday={allday}
|
||||||
|
showMore={showMore}
|
||||||
|
hasEndDateChanged={hasEndDateChanged}
|
||||||
|
validation={validation}
|
||||||
|
onStartDateChange={handleStartDateChange}
|
||||||
|
onStartTimeChange={handleStartTimeChange}
|
||||||
|
onEndDateChange={handleEndDateChange}
|
||||||
|
onEndTimeChange={handleEndTimeChange}
|
||||||
|
showEndDate={
|
||||||
|
showMore ||
|
||||||
|
allday ||
|
||||||
|
(hasEndDateChanged && startDate !== endDate) ||
|
||||||
|
(!showMore && !allday && startDate !== endDate)
|
||||||
|
}
|
||||||
|
onToggleEndDate={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FieldWithLabel>
|
||||||
|
|
||||||
|
{!(!showMore && !hasClickedDateTimeSection) && (
|
||||||
|
<FieldWithLabel label=" " isExpanded={showMore}>
|
||||||
|
<Box display="flex" gap={2} alignItems="center">
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox checked={allday} onChange={handleAllDayToggle} />
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Typography variant="h6">{t("event.form.allDay")}</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
showRepeat ||
|
||||||
|
(typeOfAction === "solo" && !!repetition?.freq)
|
||||||
|
}
|
||||||
|
disabled={typeOfAction === "solo"}
|
||||||
|
onChange={() => {
|
||||||
|
const newShowRepeat = !showRepeat;
|
||||||
|
setShowRepeat(newShowRepeat);
|
||||||
|
if (newShowRepeat) {
|
||||||
|
setRepetition({
|
||||||
|
freq: "daily",
|
||||||
|
interval: 1,
|
||||||
|
occurrences: 0,
|
||||||
|
endDate: "",
|
||||||
|
byday: null,
|
||||||
|
} as RepetitionObject);
|
||||||
|
} else {
|
||||||
|
setRepetition({
|
||||||
|
freq: "",
|
||||||
|
interval: 1,
|
||||||
|
occurrences: 0,
|
||||||
|
endDate: "",
|
||||||
|
byday: null,
|
||||||
|
} as RepetitionObject);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Typography variant="h6">{t("event.form.repeat")}</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TimezoneAutocomplete
|
||||||
|
value={timezone}
|
||||||
|
onChange={setTimezone}
|
||||||
|
zones={timezoneList.zones}
|
||||||
|
getTimezoneOffset={(tzName: string) =>
|
||||||
|
timezoneList.getTimezoneOffset(tzName, new Date(start))
|
||||||
|
}
|
||||||
|
showIcon={false}
|
||||||
|
width={220}
|
||||||
|
size="small"
|
||||||
|
placeholder={t("event.form.timezonePlaceholder")}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</FieldWithLabel>
|
</FieldWithLabel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!(!showMore && !hasClickedDateTimeSection) &&
|
||||||
|
(showRepeat || (typeOfAction === "solo" && repetition?.freq)) && (
|
||||||
|
<FieldWithLabel label=" " isExpanded={showMore}>
|
||||||
|
<RepeatEvent
|
||||||
|
repetition={repetition}
|
||||||
|
eventStart={new Date(start)}
|
||||||
|
setRepetition={setRepetition}
|
||||||
|
isOwn={typeOfAction !== "solo"}
|
||||||
|
/>
|
||||||
|
</FieldWithLabel>
|
||||||
|
)}
|
||||||
|
|
||||||
<FieldWithLabel
|
<FieldWithLabel
|
||||||
label={t("event.form.participants")}
|
label={showMore ? t("event.form.participants") : ""}
|
||||||
isExpanded={showMore}
|
isExpanded={showMore}
|
||||||
>
|
>
|
||||||
<AttendeeSelector
|
<AttendeeSelector
|
||||||
attendees={attendees}
|
attendees={attendees}
|
||||||
setAttendees={setAttendees}
|
setAttendees={setAttendees}
|
||||||
|
placeholder={t("event.form.addGuestsPlaceholder")}
|
||||||
inputSlot={(params) => <TextField {...params} size="small" />}
|
inputSlot={(params) => <TextField {...params} size="small" />}
|
||||||
/>
|
/>
|
||||||
</FieldWithLabel>
|
</FieldWithLabel>
|
||||||
|
|
||||||
<FieldWithLabel
|
<FieldWithLabel
|
||||||
label={t("event.form.videoMeeting")}
|
label={showMore ? t("event.form.videoMeeting") : ""}
|
||||||
isExpanded={showMore}
|
isExpanded={showMore}
|
||||||
>
|
>
|
||||||
<Box display="flex" gap={1} alignItems="center">
|
{!showMore ? (
|
||||||
<Button
|
hasVideoConference && meetingLink ? (
|
||||||
startIcon={
|
<Box display="flex" gap={1} alignItems="center">
|
||||||
<img src={iconCamera} alt="camera" width={24} height={24} />
|
|
||||||
}
|
|
||||||
onClick={handleAddVideoConference}
|
|
||||||
size="medium"
|
|
||||||
variant="contained"
|
|
||||||
color="secondary"
|
|
||||||
sx={{
|
|
||||||
borderRadius: "4px",
|
|
||||||
display: hasVideoConference ? "none" : "flex",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("event.form.addVisioConference")}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{hasVideoConference && meetingLink && (
|
|
||||||
<>
|
|
||||||
<Button
|
<Button
|
||||||
startIcon={
|
startIcon={
|
||||||
<img src={iconCamera} alt="camera" width={24} height={24} />
|
<img src={iconCamera} alt="camera" width={24} height={24} />
|
||||||
}
|
}
|
||||||
onClick={() => window.open(meetingLink, "_blank")}
|
onClick={() =>
|
||||||
|
window.open(meetingLink, "_blank", "noopener,noreferrer")
|
||||||
|
}
|
||||||
size="medium"
|
size="medium"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
sx={{
|
sx={{ borderRadius: "4px", mr: 1 }}
|
||||||
borderRadius: "4px",
|
|
||||||
mr: 1,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t("event.form.joinVisioConference")}
|
{t("event.form.joinVisioConference")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -608,38 +645,153 @@ export default function EventFormFields({
|
|||||||
>
|
>
|
||||||
<DeleteIcon />
|
<DeleteIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</>
|
</Box>
|
||||||
)}
|
) : (
|
||||||
</Box>
|
<SectionPreviewRow
|
||||||
|
icon={
|
||||||
|
<img src={iconCamera} alt="camera" width={24} height={24} />
|
||||||
|
}
|
||||||
|
onClick={handleAddVideoConference}
|
||||||
|
>
|
||||||
|
{t("event.form.addVisioConference")}
|
||||||
|
</SectionPreviewRow>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Box display="flex" gap={1} alignItems="center">
|
||||||
|
<Button
|
||||||
|
startIcon={
|
||||||
|
<img src={iconCamera} alt="camera" width={24} height={24} />
|
||||||
|
}
|
||||||
|
onClick={handleAddVideoConference}
|
||||||
|
size="medium"
|
||||||
|
variant="contained"
|
||||||
|
color="secondary"
|
||||||
|
sx={{
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: hasVideoConference ? "none" : "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("event.form.addVisioConference")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{hasVideoConference && meetingLink && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
startIcon={
|
||||||
|
<img src={iconCamera} alt="camera" width={24} height={24} />
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
window.open(meetingLink, "_blank", "noopener,noreferrer")
|
||||||
|
}
|
||||||
|
size="medium"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
sx={{
|
||||||
|
borderRadius: "4px",
|
||||||
|
mr: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("event.form.joinVisioConference")}
|
||||||
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
onClick={handleCopyMeetingLink}
|
||||||
|
size="small"
|
||||||
|
sx={{ color: "primary.main" }}
|
||||||
|
aria-label={t("event.form.copyMeetingLink")}
|
||||||
|
title={t("event.form.copyMeetingLink")}
|
||||||
|
>
|
||||||
|
<CopyIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
onClick={handleDeleteVideoConference}
|
||||||
|
size="small"
|
||||||
|
sx={{ color: "error.main" }}
|
||||||
|
aria-label={t("event.form.removeVideoConference")}
|
||||||
|
title={t("event.form.removeVideoConference")}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</FieldWithLabel>
|
</FieldWithLabel>
|
||||||
|
|
||||||
<FieldWithLabel label={t("event.form.location")} isExpanded={showMore}>
|
<AddDescButton
|
||||||
<TextField
|
showDescription={showDescription}
|
||||||
fullWidth
|
setShowDescription={setShowDescription}
|
||||||
label=""
|
showMore={showMore}
|
||||||
inputProps={{ "aria-label": t("event.form.location") }}
|
description={description}
|
||||||
placeholder={t("event.form.locationPlaceholder")}
|
setDescription={setDescription}
|
||||||
value={location}
|
/>
|
||||||
onChange={(e) => setLocation(e.target.value)}
|
|
||||||
size="small"
|
|
||||||
margin="dense"
|
|
||||||
/>
|
|
||||||
</FieldWithLabel>
|
|
||||||
|
|
||||||
<FieldWithLabel label={t("event.form.calendar")} isExpanded={showMore}>
|
<FieldWithLabel
|
||||||
<FormControl fullWidth margin="dense" size="small">
|
label={
|
||||||
<Select
|
showMore || hasClickedLocationSection ? t("event.form.location") : ""
|
||||||
value={calendarid ?? ""}
|
}
|
||||||
label=""
|
isExpanded={showMore}
|
||||||
SelectDisplayProps={{ "aria-label": t("event.form.calendar") }}
|
>
|
||||||
displayEmpty
|
{!showMore && !hasClickedLocationSection ? (
|
||||||
onChange={(e: SelectChangeEvent) =>
|
<SectionPreviewRow
|
||||||
handleCalendarChange(e.target.value)
|
icon={<LocationIcon />}
|
||||||
}
|
onClick={() => setHasClickedLocationSection(true)}
|
||||||
>
|
>
|
||||||
{CalendarItemList(userPersonalCalendars)}
|
{location || t("event.form.locationPlaceholder")}
|
||||||
</Select>
|
</SectionPreviewRow>
|
||||||
</FormControl>
|
) : (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label=""
|
||||||
|
inputRef={locationInputRef}
|
||||||
|
inputProps={{ "aria-label": t("event.form.location") }}
|
||||||
|
placeholder={t("event.form.locationPlaceholder")}
|
||||||
|
value={location}
|
||||||
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
margin="dense"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FieldWithLabel>
|
||||||
|
|
||||||
|
<FieldWithLabel
|
||||||
|
label={
|
||||||
|
showMore || hasClickedCalendarSection ? t("event.form.calendar") : ""
|
||||||
|
}
|
||||||
|
isExpanded={showMore}
|
||||||
|
>
|
||||||
|
{!showMore && !hasClickedCalendarSection ? (
|
||||||
|
<SectionPreviewRow
|
||||||
|
icon={
|
||||||
|
<SquareRoundedIcon
|
||||||
|
sx={{
|
||||||
|
color:
|
||||||
|
userPersonalCalendars.find((cal) => cal.id === calendarid)
|
||||||
|
?.color?.light ?? "#3788D8",
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onClick={() => setHasClickedCalendarSection(true)}
|
||||||
|
>
|
||||||
|
{userPersonalCalendars.find((cal) => cal.id === calendarid)?.name ||
|
||||||
|
t("event.form.calendar")}
|
||||||
|
</SectionPreviewRow>
|
||||||
|
) : (
|
||||||
|
<FormControl fullWidth margin="dense" size="small">
|
||||||
|
<Select
|
||||||
|
value={calendarid ?? ""}
|
||||||
|
label=""
|
||||||
|
SelectDisplayProps={{ "aria-label": t("event.form.calendar") }}
|
||||||
|
displayEmpty
|
||||||
|
onChange={(e: SelectChangeEvent) =>
|
||||||
|
handleCalendarChange(e.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{CalendarItemList(userPersonalCalendars)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
</FieldWithLabel>
|
</FieldWithLabel>
|
||||||
|
|
||||||
{showMore && (
|
{showMore && (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type InfoRowProps = {
|
|||||||
data?: string; // optional link target
|
data?: string; // optional link target
|
||||||
content?: React.ReactNode; // if provided, overrides text rendering
|
content?: React.ReactNode; // if provided, overrides text rendering
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
|
alignItems?: React.CSSProperties["alignItems"];
|
||||||
};
|
};
|
||||||
|
|
||||||
function detectUrls(text: string) {
|
function detectUrls(text: string) {
|
||||||
@@ -62,12 +63,13 @@ export function InfoRow({
|
|||||||
data,
|
data,
|
||||||
content,
|
content,
|
||||||
style,
|
style,
|
||||||
|
alignItems = "center",
|
||||||
}: InfoRowProps) {
|
}: InfoRowProps) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems,
|
||||||
gap: 1,
|
gap: 1,
|
||||||
marginBottom: 1,
|
marginBottom: 1,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { Box, Typography, useTheme } from "@linagora/twake-mui";
|
||||||
|
import { alpha } from "@mui/material/styles";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface ClickableFieldProps {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
text?: string;
|
||||||
|
onClick: () => void;
|
||||||
|
iconColor?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
ariaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClickableField: React.FC<ClickableFieldProps> = ({
|
||||||
|
icon,
|
||||||
|
text,
|
||||||
|
onClick,
|
||||||
|
iconColor,
|
||||||
|
children,
|
||||||
|
ariaLabel,
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
onClick={onClick}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={ariaLabel ?? text}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: children ? "flex-start" : "center",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "action.hover",
|
||||||
|
},
|
||||||
|
"&:focus-visible": {
|
||||||
|
outline: `2px solid ${theme.palette.primary.main}`,
|
||||||
|
outlineOffset: "2px",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
maxWidth: "24px",
|
||||||
|
maxHeight: "24px",
|
||||||
|
marginRight: "12px",
|
||||||
|
color: iconColor || alpha(theme.palette.grey[900], 0.9),
|
||||||
|
"& svg": {
|
||||||
|
width: "24px",
|
||||||
|
height: "24px",
|
||||||
|
},
|
||||||
|
"& img": {
|
||||||
|
width: "24px",
|
||||||
|
height: "24px",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Box>
|
||||||
|
{children ? (
|
||||||
|
<Box flex={1}>{children}</Box>
|
||||||
|
) : (
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontSize: "14px",
|
||||||
|
color: alpha(theme.palette.grey[900], 0.9),
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -37,6 +37,15 @@ const timePickerPopperSx = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// twake-mui datePickerOverrides also uses this selector. Repeating ensures our override wins.
|
||||||
|
const dateCalendarLayoutSx = {
|
||||||
|
"& .MuiDateCalendar-root.MuiDateCalendar-root": {
|
||||||
|
width: "260px",
|
||||||
|
maxWidth: "260px",
|
||||||
|
padding: "0 15px",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for DateTimeFields component
|
* Props for DateTimeFields component
|
||||||
*/
|
*/
|
||||||
@@ -369,6 +378,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
|||||||
false,
|
false,
|
||||||
t("dateTimeFields.startDate")
|
t("dateTimeFields.startDate")
|
||||||
),
|
),
|
||||||
|
layout: { sx: dateCalendarLayoutSx },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -415,6 +425,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
|||||||
!!validation.errors.dateTime,
|
!!validation.errors.dateTime,
|
||||||
t("dateTimeFields.endDate")
|
t("dateTimeFields.endDate")
|
||||||
),
|
),
|
||||||
|
layout: { sx: dateCalendarLayoutSx },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -463,6 +474,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
|||||||
false,
|
false,
|
||||||
t("dateTimeFields.startDate")
|
t("dateTimeFields.startDate")
|
||||||
),
|
),
|
||||||
|
layout: { sx: dateCalendarLayoutSx },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -483,6 +495,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
|||||||
!!validation.errors.dateTime,
|
!!validation.errors.dateTime,
|
||||||
t("dateTimeFields.endDate")
|
t("dateTimeFields.endDate")
|
||||||
),
|
),
|
||||||
|
layout: { sx: dateCalendarLayoutSx },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -502,6 +515,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
|||||||
false,
|
false,
|
||||||
startDateLabel
|
startDateLabel
|
||||||
),
|
),
|
||||||
|
layout: { sx: dateCalendarLayoutSx },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { Box, Typography, useTheme } from "@linagora/twake-mui";
|
||||||
|
import { alpha } from "@mui/material/styles";
|
||||||
|
import AccessTimeIcon from "@mui/icons-material/AccessTime";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import "dayjs/locale/en";
|
||||||
|
import "dayjs/locale/fr";
|
||||||
|
import "dayjs/locale/ru";
|
||||||
|
import "dayjs/locale/vi";
|
||||||
|
import React from "react";
|
||||||
|
import { useI18n } from "twake-i18n";
|
||||||
|
import { getTimezoneOffset } from "@/utils/timezone";
|
||||||
|
import { RepetitionObject } from "@/features/Events/EventsTypes";
|
||||||
|
import { LONG_DATE_FORMAT } from "../utils/dateTimeFormatters";
|
||||||
|
import { SectionPreviewRow } from "./SectionPreviewRow";
|
||||||
|
|
||||||
|
interface DateTimeSummaryProps {
|
||||||
|
startDate: string;
|
||||||
|
startTime: string;
|
||||||
|
endDate: string;
|
||||||
|
endTime: string;
|
||||||
|
allday: boolean;
|
||||||
|
timezone: string;
|
||||||
|
repetition: RepetitionObject;
|
||||||
|
showEndDate: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DateTimeSummary: React.FC<DateTimeSummaryProps> = ({
|
||||||
|
startDate,
|
||||||
|
startTime,
|
||||||
|
endDate,
|
||||||
|
endTime,
|
||||||
|
allday,
|
||||||
|
timezone,
|
||||||
|
repetition,
|
||||||
|
showEndDate,
|
||||||
|
onClick,
|
||||||
|
}) => {
|
||||||
|
const { t, lang } = useI18n();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
// Format date with current locale. VI: "Thứ 4, 4 Tháng 2, 2026"; FR: "mercredi, 5 février 2026"; RU: first letter capitalized
|
||||||
|
const formatDate = (dateStr: string): string => {
|
||||||
|
if (!dateStr) return "";
|
||||||
|
const date = dayjs(dateStr);
|
||||||
|
const locale =
|
||||||
|
lang && ["en", "vi", "fr", "ru"].includes(lang) ? lang : "en";
|
||||||
|
|
||||||
|
if (locale === "vi") {
|
||||||
|
const dow = date.day(); // 0 = Sunday, 1 = Monday, ..., 6 = Saturday
|
||||||
|
const weekdayLabel = dow === 0 ? "Chủ nhật" : `Thứ ${dow + 1}`; // Mon=Thứ 2, Wed=Thứ 4, ...
|
||||||
|
const day = date.date();
|
||||||
|
const month = date.month() + 1;
|
||||||
|
const year = date.year();
|
||||||
|
return `${weekdayLabel}, ${day} Tháng ${month}, ${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// French: "5 février" (day before month), not "février 5"
|
||||||
|
if (locale === "fr") {
|
||||||
|
const formatted = date.locale("fr").format("dddd, D MMMM YYYY");
|
||||||
|
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = date.locale(locale).format(LONG_DATE_FORMAT);
|
||||||
|
if (locale === "ru") {
|
||||||
|
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
|
||||||
|
}
|
||||||
|
return formatted;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format time in 24h: "03:30 - 16:30"
|
||||||
|
const formatTime = (startTimeStr: string, endTimeStr: string): string => {
|
||||||
|
if (allday || !startTimeStr || !endTimeStr) return "";
|
||||||
|
|
||||||
|
const toHHmm = (timeStr: string): string => {
|
||||||
|
const [h, m] = timeStr.split(":").map((s) => parseInt(s, 10) || 0);
|
||||||
|
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return `${toHHmm(startTimeStr)} - ${toHHmm(endTimeStr)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format timezone: "(UTC+2) Paris". Use event date for offset (DST correctness).
|
||||||
|
const formatTimezone = (tz: string, dateStr?: string): string => {
|
||||||
|
if (!tz) return "";
|
||||||
|
try {
|
||||||
|
const dateForOffset = dateStr ? dayjs(dateStr).toDate() : new Date();
|
||||||
|
const offset = getTimezoneOffset(tz, dateForOffset);
|
||||||
|
const tzName = tz.replace(/_/g, " ");
|
||||||
|
return `(${offset}) ${tzName}`;
|
||||||
|
} catch (error) {
|
||||||
|
return tz.replace(/_/g, " ");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format repeat: "Doesn't repeat" or repeat info
|
||||||
|
const formatRepeat = (rep: RepetitionObject): string => {
|
||||||
|
if (!rep || !rep.freq) {
|
||||||
|
return t("event.repeat.doesNotRepeat");
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = rep.interval || 1;
|
||||||
|
const freqMap: { [key: string]: string } = {
|
||||||
|
daily: t("event.repeat.frequency.days"),
|
||||||
|
weekly: t("event.repeat.frequency.weeks"),
|
||||||
|
monthly: t("event.repeat.frequency.months"),
|
||||||
|
yearly: t("event.repeat.frequency.years"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const freqText = freqMap[rep.freq] || rep.freq;
|
||||||
|
|
||||||
|
if (interval === 1) {
|
||||||
|
return `${t("event.repeat.every")} ${freqText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${t("event.repeat.every")} ${interval} ${freqText}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format date text: show both start and end date if showEndDate is true
|
||||||
|
const formatDateText = (): string => {
|
||||||
|
if (showEndDate && endDate && endDate !== startDate) {
|
||||||
|
const startDateText = formatDate(startDate);
|
||||||
|
const endDateText = formatDate(endDate);
|
||||||
|
return `${startDateText} - ${endDateText}`;
|
||||||
|
}
|
||||||
|
return formatDate(startDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateText = formatDateText();
|
||||||
|
const timeText = formatTime(startTime, endTime);
|
||||||
|
const timezoneText = formatTimezone(timezone, startDate);
|
||||||
|
const repeatText = formatRepeat(repetition);
|
||||||
|
|
||||||
|
// Don't render if no date
|
||||||
|
if (!startDate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryStyle = {
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: alpha(theme.palette.grey[900], 0.9),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionPreviewRow
|
||||||
|
icon={<AccessTimeIcon sx={{ color: "text.secondary" }} />}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography component="p" sx={primaryStyle}>
|
||||||
|
{dateText}
|
||||||
|
{showEndDate && <br />}
|
||||||
|
{timeText && (
|
||||||
|
<Box component="span" sx={{ ml: showEndDate ? 0 : 2 }}>
|
||||||
|
{timeText}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
<Box display="flex" gap={2} alignItems="center" mt={0.5}>
|
||||||
|
<Typography variant="caption" sx={{ color: "#444746" }}>
|
||||||
|
{timezoneText}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "#444746" }}>
|
||||||
|
{repeatText}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</SectionPreviewRow>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Box, Typography, useTheme } from "@linagora/twake-mui";
|
||||||
|
import { alpha } from "@mui/material/styles";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export interface SectionPreviewRowProps {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
/** Primary text or custom content. If string, rendered with fontSize 14px, fontWeight 500. */
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
iconColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable preview row for event modal sections (normal mode).
|
||||||
|
* Layout: icon left (max 32x32), content right. No button.
|
||||||
|
*/
|
||||||
|
export const SectionPreviewRow: React.FC<SectionPreviewRowProps> = ({
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
iconColor,
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const color = iconColor ?? alpha(theme.palette.grey[900], 0.9);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "action.hover",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
maxWidth: "24px",
|
||||||
|
maxHeight: "24px",
|
||||||
|
marginRight: "12px",
|
||||||
|
flexShrink: 0,
|
||||||
|
color,
|
||||||
|
"& svg": {
|
||||||
|
maxWidth: "24px",
|
||||||
|
maxHeight: "24px",
|
||||||
|
},
|
||||||
|
"& img": {
|
||||||
|
maxWidth: "24px",
|
||||||
|
maxHeight: "24px",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Box>
|
||||||
|
<Box flex={1} minWidth={0}>
|
||||||
|
{typeof children === "string" ? (
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: 500,
|
||||||
|
color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -65,16 +65,7 @@ export function renderAttendeeBadge(
|
|||||||
<Avatar {...stringAvatar(a.cn || a.cal_address)} />
|
<Avatar {...stringAvatar(a.cn || a.cal_address)} />
|
||||||
</Badge>
|
</Badge>
|
||||||
<Box style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
|
<Box style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||||
<Typography
|
<Typography noWrap>{a.cn || a.cal_address}</Typography>
|
||||||
noWrap
|
|
||||||
style={{
|
|
||||||
maxWidth: "180px",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{a.cn || a.cal_address}
|
|
||||||
</Typography>
|
|
||||||
{isOrganizer && (
|
{isOrganizer && (
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{t("event.organizer")}
|
{t("event.organizer")}
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ export default function SearchBar() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!extended && (
|
{!extended && (
|
||||||
<IconButton onClick={() => setExtended(true)}>
|
<IconButton sx={{ mr: 1 }} onClick={() => setExtended(true)}>
|
||||||
<SearchIcon />
|
<SearchIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -38,20 +38,22 @@
|
|||||||
padding 0.5rem
|
padding 0.5rem
|
||||||
width 100%
|
width 100%
|
||||||
z-index 1300
|
z-index 1300
|
||||||
height 80px
|
height 60px
|
||||||
background-color #fff
|
background-color #fff
|
||||||
|
|
||||||
.menubar-item
|
.menubar-item
|
||||||
display flex
|
display flex
|
||||||
align-items center
|
align-items center
|
||||||
width calc(330px - 0.5rem)
|
margin-right 65px
|
||||||
|
|
||||||
.tc-home
|
.tc-home
|
||||||
cursor pointer
|
cursor pointer
|
||||||
|
|
||||||
.logo
|
.logo
|
||||||
padding 0.5rem 1rem
|
padding 0
|
||||||
font-size 1.5rem
|
font-size 1.5rem
|
||||||
|
max-width 230px
|
||||||
|
margin-left 1rem
|
||||||
|
|
||||||
.nav-month
|
.nav-month
|
||||||
padding-right 2px
|
padding-right 2px
|
||||||
@@ -91,6 +93,14 @@
|
|||||||
justify-content flex-end
|
justify-content flex-end
|
||||||
align-items center
|
align-items center
|
||||||
|
|
||||||
|
.current-date-time
|
||||||
|
font-family Roboto
|
||||||
|
font-size 22px
|
||||||
|
font-style normal
|
||||||
|
font-weight 400
|
||||||
|
line-height 36px
|
||||||
|
color #243B55
|
||||||
|
|
||||||
body.fullscreen-view .navigation-controls,
|
body.fullscreen-view .navigation-controls,
|
||||||
body.fullscreen-view .current-date-time,
|
body.fullscreen-view .current-date-time,
|
||||||
body.fullscreen-view .refresh-button,
|
body.fullscreen-view .refresh-button,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Logout } from "@/features/User/oidcAuth";
|
|||||||
import logo from "@/static/header-logo.svg";
|
import logo from "@/static/header-logo.svg";
|
||||||
import { getInitials, stringToGradient } from "@/utils/avatarUtils";
|
import { getInitials, stringToGradient } from "@/utils/avatarUtils";
|
||||||
import { getUserDisplayName } from "@/utils/userUtils";
|
import { getUserDisplayName } from "@/utils/userUtils";
|
||||||
|
import { redirectTo } from "@/utils/navigation";
|
||||||
import { CalendarApi } from "@fullcalendar/core";
|
import { CalendarApi } from "@fullcalendar/core";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -19,7 +20,8 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@linagora/twake-mui";
|
} from "@linagora/twake-mui";
|
||||||
import AppsIcon from "@mui/icons-material/Apps";
|
import WidgetsOutlinedIcon from "@mui/icons-material/WidgetsOutlined";
|
||||||
|
import HelpOutlineIcon from "@mui/icons-material/HelpOutline";
|
||||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||||
import LogoutIcon from "@mui/icons-material/Logout";
|
import LogoutIcon from "@mui/icons-material/Logout";
|
||||||
@@ -139,7 +141,7 @@ export function Menubar({
|
|||||||
const handleLogoutClick = async () => {
|
const handleLogoutClick = async () => {
|
||||||
const logoutUrl = await Logout();
|
const logoutUrl = await Logout();
|
||||||
sessionStorage.removeItem("tokenSet");
|
sessionStorage.removeItem("tokenSet");
|
||||||
window.location.assign(logoutUrl.href);
|
redirectTo(logoutUrl.href);
|
||||||
handleUserMenuClose();
|
handleUserMenuClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -195,9 +197,7 @@ export function Menubar({
|
|||||||
</div>
|
</div>
|
||||||
<div className="menu-items">
|
<div className="menu-items">
|
||||||
<div className="current-date-time">
|
<div className="current-date-time">
|
||||||
<Typography variant="h3" component="div">
|
<p>{dateLabel}</p>
|
||||||
{dateLabel}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,6 +211,7 @@ export function Menubar({
|
|||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
aria-label={t("menubar.refresh")}
|
aria-label={t("menubar.refresh")}
|
||||||
title={t("menubar.refresh")}
|
title={t("menubar.refresh")}
|
||||||
|
sx={{ mr: 1 }}
|
||||||
>
|
>
|
||||||
<RefreshIcon />
|
<RefreshIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -226,7 +227,11 @@ export function Menubar({
|
|||||||
onChange={(e) => handleViewChange(e.target.value)}
|
onChange={(e) => handleViewChange(e.target.value)}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
aria-label={t("menubar.viewSelector")}
|
aria-label={t("menubar.viewSelector")}
|
||||||
sx={{ height: "43px" }}
|
sx={{
|
||||||
|
borderRadius: "12px",
|
||||||
|
marginLeft: 1,
|
||||||
|
"& fieldset": { borderRadius: "12px" },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem value="dayGridMonth">
|
<MenuItem value="dayGridMonth">
|
||||||
{t("menubar.views.month")}
|
{t("menubar.views.month")}
|
||||||
@@ -240,6 +245,19 @@ export function Menubar({
|
|||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="menu-items">
|
||||||
|
<IconButton
|
||||||
|
component="a"
|
||||||
|
href="https://twake.app/support/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
aria-label={t("menubar.help")}
|
||||||
|
title={t("menubar.help")}
|
||||||
|
>
|
||||||
|
<HelpOutlineIcon />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
<div className="menu-items">
|
<div className="menu-items">
|
||||||
{applist.length > 0 && (
|
{applist.length > 0 && (
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -248,7 +266,7 @@ export function Menubar({
|
|||||||
aria-label={t("menubar.apps")}
|
aria-label={t("menubar.apps")}
|
||||||
title={t("menubar.apps")}
|
title={t("menubar.apps")}
|
||||||
>
|
>
|
||||||
<AppsIcon />
|
<WidgetsOutlinedIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import {
|
|||||||
MenuItem,
|
MenuItem,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
|
useTheme,
|
||||||
} from "@linagora/twake-mui";
|
} from "@linagora/twake-mui";
|
||||||
|
import { alpha } from "@mui/material/styles";
|
||||||
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
|
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
|
||||||
import CircleIcon from "@mui/icons-material/Circle";
|
import CircleIcon from "@mui/icons-material/Circle";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
@@ -73,6 +75,9 @@ export default function EventPreviewModal({
|
|||||||
: calendars.list[calId];
|
: calendars.list[calId];
|
||||||
const event = calendar.events[eventId];
|
const event = calendar.events[eventId];
|
||||||
const user = useAppSelector((state) => state.user.userData);
|
const user = useAppSelector((state) => state.user.userData);
|
||||||
|
const theme = useTheme();
|
||||||
|
const infoIconColor = alpha(theme.palette.grey[900], 0.9);
|
||||||
|
const infoIconSx = { minWidth: "25px", marginRight: 2, color: infoIconColor };
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
const isRecurring = event?.uid?.includes("/");
|
const isRecurring = event?.uid?.includes("/");
|
||||||
@@ -451,10 +456,10 @@ export default function EventPreviewModal({
|
|||||||
title={t("eventPreview.privateEvent.tooltipOwn")}
|
title={t("eventPreview.privateEvent.tooltipOwn")}
|
||||||
placement="top"
|
placement="top"
|
||||||
>
|
>
|
||||||
<LockOutlineIcon />
|
<LockOutlineIcon sx={{ color: infoIconColor }} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<LockOutlineIcon />
|
<LockOutlineIcon sx={{ color: infoIconColor }} />
|
||||||
))}
|
))}
|
||||||
<Typography
|
<Typography
|
||||||
variant="h5"
|
variant="h5"
|
||||||
@@ -488,8 +493,9 @@ export default function EventPreviewModal({
|
|||||||
{/* Video */}
|
{/* Video */}
|
||||||
{event.x_openpass_videoconference && (
|
{event.x_openpass_videoconference && (
|
||||||
<InfoRow
|
<InfoRow
|
||||||
|
alignItems="flex-start"
|
||||||
icon={
|
icon={
|
||||||
<Box sx={{ minWidth: "25px", marginRight: 2 }}>
|
<Box sx={{ ...infoIconSx, mt: 1 }}>
|
||||||
<VideocamOutlinedIcon />
|
<VideocamOutlinedIcon />
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
@@ -511,8 +517,9 @@ export default function EventPreviewModal({
|
|||||||
{attendees?.length > 0 && (
|
{attendees?.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<InfoRow
|
<InfoRow
|
||||||
|
alignItems="flex-start"
|
||||||
icon={
|
icon={
|
||||||
<Box sx={{ minWidth: "25px", marginRight: 2 }}>
|
<Box sx={{ ...infoIconSx, mt: 1 }}>
|
||||||
<PeopleAltOutlinedIcon />
|
<PeopleAltOutlinedIcon />
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
@@ -585,8 +592,9 @@ export default function EventPreviewModal({
|
|||||||
{/* Location */}
|
{/* Location */}
|
||||||
{event.location && (
|
{event.location && (
|
||||||
<InfoRow
|
<InfoRow
|
||||||
|
alignItems="flex-start"
|
||||||
icon={
|
icon={
|
||||||
<Box sx={{ minWidth: "25px", marginRight: 2 }}>
|
<Box sx={infoIconSx}>
|
||||||
<LocationOnOutlinedIcon />
|
<LocationOnOutlinedIcon />
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
@@ -600,8 +608,9 @@ export default function EventPreviewModal({
|
|||||||
{/* Description */}
|
{/* Description */}
|
||||||
{event.description && (
|
{event.description && (
|
||||||
<InfoRow
|
<InfoRow
|
||||||
|
alignItems="flex-start"
|
||||||
icon={
|
icon={
|
||||||
<Box sx={{ minWidth: "25px", marginRight: 2 }}>
|
<Box sx={infoIconSx}>
|
||||||
<SubjectIcon />
|
<SubjectIcon />
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
@@ -615,8 +624,9 @@ export default function EventPreviewModal({
|
|||||||
{/* ALARM */}
|
{/* ALARM */}
|
||||||
{event.alarm && (
|
{event.alarm && (
|
||||||
<InfoRow
|
<InfoRow
|
||||||
|
alignItems="flex-start"
|
||||||
icon={
|
icon={
|
||||||
<Box sx={{ minWidth: "25px", marginRight: 2 }}>
|
<Box sx={infoIconSx}>
|
||||||
<NotificationsNoneIcon />
|
<NotificationsNoneIcon />
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
@@ -641,8 +651,9 @@ export default function EventPreviewModal({
|
|||||||
{/* Repetition */}
|
{/* Repetition */}
|
||||||
{event.repetition && (
|
{event.repetition && (
|
||||||
<InfoRow
|
<InfoRow
|
||||||
|
alignItems="flex-start"
|
||||||
icon={
|
icon={
|
||||||
<Box sx={{ minWidth: "25px", marginRight: 2 }}>
|
<Box sx={infoIconSx}>
|
||||||
<RepeatIcon />
|
<RepeatIcon />
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
@@ -681,8 +692,9 @@ export default function EventPreviewModal({
|
|||||||
{/* Error */}
|
{/* Error */}
|
||||||
{event.error && (
|
{event.error && (
|
||||||
<InfoRow
|
<InfoRow
|
||||||
|
alignItems="flex-start"
|
||||||
icon={
|
icon={
|
||||||
<Box sx={{ minWidth: "25px", marginRight: 2 }}>
|
<Box sx={infoIconSx}>
|
||||||
<ErrorOutlineIcon color="error" />
|
<ErrorOutlineIcon color="error" />
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
@@ -698,12 +710,12 @@ export default function EventPreviewModal({
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "flex-start",
|
||||||
gap: 1,
|
gap: 1,
|
||||||
mb: 2,
|
mb: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ minWidth: "25px", marginRight: 2 }}>
|
<Box sx={infoIconSx}>
|
||||||
<CalendarTodayIcon />
|
<CalendarTodayIcon />
|
||||||
</Box>
|
</Box>
|
||||||
<CalendarName calendar={calendar} />
|
<CalendarName calendar={calendar} />
|
||||||
@@ -860,6 +872,7 @@ function formatDate(
|
|||||||
day: "numeric",
|
day: "numeric",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
timeZone,
|
timeZone,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -896,6 +909,7 @@ function formatEnd(
|
|||||||
return endDate.toLocaleTimeString(t("locale"), {
|
return endDate.toLocaleTimeString(t("locale"), {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
timeZone,
|
timeZone,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -905,6 +919,7 @@ function formatEnd(
|
|||||||
day: "numeric",
|
day: "numeric",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
timeZone,
|
timeZone,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
.main-layout.settings-layout
|
.main-layout.settings-layout
|
||||||
display flex
|
display flex
|
||||||
flex-direction row
|
flex-direction row
|
||||||
height calc(100vh - 90px)
|
height calc(100vh - 70px)
|
||||||
padding 0 10px 10px 0;
|
padding 0 10px 10px 0;
|
||||||
|
|
||||||
.settings-sidebar
|
.settings-sidebar
|
||||||
|
|||||||
@@ -76,6 +76,9 @@
|
|||||||
"addNew": "Add new calendar",
|
"addNew": "Add new calendar",
|
||||||
"access": "Access",
|
"access": "Access",
|
||||||
"import": "Import"
|
"import": "Import"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"calendarName": "Calendar name"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"colorPicker": {
|
"colorPicker": {
|
||||||
@@ -98,6 +101,8 @@
|
|||||||
"years": "Year(s)"
|
"years": "Year(s)"
|
||||||
},
|
},
|
||||||
"repeatOn": "Repeat on:",
|
"repeatOn": "Repeat on:",
|
||||||
|
"doesNotRepeat": "Doesn't repeat",
|
||||||
|
"every": "Every",
|
||||||
"days": {
|
"days": {
|
||||||
"monday": "MO",
|
"monday": "MO",
|
||||||
"tuesday": "TU",
|
"tuesday": "TU",
|
||||||
@@ -127,6 +132,7 @@
|
|||||||
"allDay": "All day",
|
"allDay": "All day",
|
||||||
"repeat": "Repeat",
|
"repeat": "Repeat",
|
||||||
"timezonePlaceholder": "Select timezone",
|
"timezonePlaceholder": "Select timezone",
|
||||||
|
"addGuestsPlaceholder": "Add guests",
|
||||||
"participants": "Participants",
|
"participants": "Participants",
|
||||||
"videoMeeting": "Video meeting",
|
"videoMeeting": "Video meeting",
|
||||||
"meetCopied": "Meeting link copied!",
|
"meetCopied": "Meeting link copied!",
|
||||||
|
|||||||
+7
-1
@@ -78,6 +78,9 @@
|
|||||||
"addNew": "Ajouter un nouveau calendrier",
|
"addNew": "Ajouter un nouveau calendrier",
|
||||||
"access": "Accès",
|
"access": "Accès",
|
||||||
"import": "Importer"
|
"import": "Importer"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"calendarName": "Nom du calendrier"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"colorPicker": {
|
"colorPicker": {
|
||||||
@@ -100,6 +103,8 @@
|
|||||||
"years": "année(s)"
|
"years": "année(s)"
|
||||||
},
|
},
|
||||||
"repeatOn": "Répéter le :",
|
"repeatOn": "Répéter le :",
|
||||||
|
"doesNotRepeat": "Ne se répète pas",
|
||||||
|
"every": "Tous les",
|
||||||
"days": {
|
"days": {
|
||||||
"monday": "LU",
|
"monday": "LU",
|
||||||
"tuesday": "MA",
|
"tuesday": "MA",
|
||||||
@@ -129,6 +134,7 @@
|
|||||||
"allDay": "Toute la journée",
|
"allDay": "Toute la journée",
|
||||||
"repeat": "Répéter",
|
"repeat": "Répéter",
|
||||||
"timezonePlaceholder": "Sélectionner un fuseau horaire",
|
"timezonePlaceholder": "Sélectionner un fuseau horaire",
|
||||||
|
"addGuestsPlaceholder": "Ajouter des invités",
|
||||||
"participants": "Participants",
|
"participants": "Participants",
|
||||||
"videoMeeting": "Visioconférence",
|
"videoMeeting": "Visioconférence",
|
||||||
"meetCopied": "Lien de la visio copié!",
|
"meetCopied": "Lien de la visio copié!",
|
||||||
@@ -288,7 +294,7 @@
|
|||||||
},
|
},
|
||||||
"dateTimeFields": {
|
"dateTimeFields": {
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
"startDate": "Daye du début",
|
"startDate": "Date du début",
|
||||||
"startTime": "Heure du début",
|
"startTime": "Heure du début",
|
||||||
"endDate": "Date de la fin",
|
"endDate": "Date de la fin",
|
||||||
"endTime": "Heure de la fin"
|
"endTime": "Heure de la fin"
|
||||||
|
|||||||
@@ -78,6 +78,9 @@
|
|||||||
"addNew": "Добавить новый календарь",
|
"addNew": "Добавить новый календарь",
|
||||||
"access": "Доступ",
|
"access": "Доступ",
|
||||||
"import": "Импорт"
|
"import": "Импорт"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"calendarName": "Название календаря"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"colorPicker": {
|
"colorPicker": {
|
||||||
@@ -100,6 +103,8 @@
|
|||||||
"years": "год(лет)"
|
"years": "год(лет)"
|
||||||
},
|
},
|
||||||
"repeatOn": "Повторять по:",
|
"repeatOn": "Повторять по:",
|
||||||
|
"doesNotRepeat": "Не повторяется",
|
||||||
|
"every": "Каждый",
|
||||||
"days": {
|
"days": {
|
||||||
"monday": "ПН",
|
"monday": "ПН",
|
||||||
"tuesday": "ВТ",
|
"tuesday": "ВТ",
|
||||||
@@ -129,6 +134,7 @@
|
|||||||
"allDay": "Весь день",
|
"allDay": "Весь день",
|
||||||
"repeat": "Повторять",
|
"repeat": "Повторять",
|
||||||
"timezonePlaceholder": "Выберите часовой пояс",
|
"timezonePlaceholder": "Выберите часовой пояс",
|
||||||
|
"addGuestsPlaceholder": "Добавить гостей",
|
||||||
"participants": "Участники",
|
"participants": "Участники",
|
||||||
"videoMeeting": "Видеовстреча",
|
"videoMeeting": "Видеовстреча",
|
||||||
"meetCopied": "Ссылка на встречу скопирована",
|
"meetCopied": "Ссылка на встречу скопирована",
|
||||||
|
|||||||
@@ -76,6 +76,9 @@
|
|||||||
"addNew": "Thêm lịch mới",
|
"addNew": "Thêm lịch mới",
|
||||||
"access": "Quyền truy cập",
|
"access": "Quyền truy cập",
|
||||||
"import": "Nhập"
|
"import": "Nhập"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"calendarName": "Tên lịch"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"colorPicker": {
|
"colorPicker": {
|
||||||
@@ -98,6 +101,8 @@
|
|||||||
"years": "Năm"
|
"years": "Năm"
|
||||||
},
|
},
|
||||||
"repeatOn": "Lặp vào:",
|
"repeatOn": "Lặp vào:",
|
||||||
|
"doesNotRepeat": "Không lặp lại",
|
||||||
|
"every": "Mỗi",
|
||||||
"days": {
|
"days": {
|
||||||
"monday": "Th 2",
|
"monday": "Th 2",
|
||||||
"tuesday": "Th 3",
|
"tuesday": "Th 3",
|
||||||
@@ -127,6 +132,7 @@
|
|||||||
"allDay": "Cả ngày",
|
"allDay": "Cả ngày",
|
||||||
"repeat": "Lặp lại",
|
"repeat": "Lặp lại",
|
||||||
"timezonePlaceholder": "Chọn múi giờ",
|
"timezonePlaceholder": "Chọn múi giờ",
|
||||||
|
"addGuestsPlaceholder": "Thêm khách",
|
||||||
"participants": "Người tham gia",
|
"participants": "Người tham gia",
|
||||||
"videoMeeting": "Cuộc họp video",
|
"videoMeeting": "Cuộc họp video",
|
||||||
"meetCopied": "Đã sao chép liên kết họp!",
|
"meetCopied": "Đã sao chép liên kết họp!",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
extractVideoConferenceFromDescription,
|
extractVideoConferenceFromDescription,
|
||||||
generateMeetingId,
|
generateMeetingId,
|
||||||
generateMeetingLink,
|
generateMeetingLink,
|
||||||
|
removeVideoConferenceFromDescription,
|
||||||
} from "../videoConferenceUtils";
|
} from "../videoConferenceUtils";
|
||||||
|
|
||||||
// Mock window object for Node.js environment
|
// Mock window object for Node.js environment
|
||||||
@@ -45,11 +46,11 @@ describe("videoConferenceUtils", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("addVideoConferenceToDescription", () => {
|
describe("addVideoConferenceToDescription", () => {
|
||||||
it("should add video conference footer to empty description", () => {
|
it("should add video conference on first line when description is empty", () => {
|
||||||
const description = "";
|
const description = "";
|
||||||
const meetingLink = "https://meet.linagora.com/abc-defg-hij";
|
const meetingLink = "https://meet.linagora.com/abc-defg-hij";
|
||||||
const result = addVideoConferenceToDescription(description, meetingLink);
|
const result = addVideoConferenceToDescription(description, meetingLink);
|
||||||
expect(result).toBe("\nVisio: https://meet.linagora.com/abc-defg-hij");
|
expect(result).toBe("Visio: https://meet.linagora.com/abc-defg-hij");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should add video conference footer to existing description", () => {
|
it("should add video conference footer to existing description", () => {
|
||||||
@@ -82,4 +83,39 @@ describe("videoConferenceUtils", () => {
|
|||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("removeVideoConferenceFromDescription", () => {
|
||||||
|
it("should return empty string when description is only the Visio line", () => {
|
||||||
|
const description = "Visio: https://meet.linagora.com/abc-defg-hij";
|
||||||
|
const result = removeVideoConferenceFromDescription(description);
|
||||||
|
expect(result).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove Visio line when at end of description", () => {
|
||||||
|
const description =
|
||||||
|
"This is a meeting description.\nVisio: https://meet.linagora.com/abc-defg-hij";
|
||||||
|
const result = removeVideoConferenceFromDescription(description);
|
||||||
|
expect(result).toBe("This is a meeting description.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove Visio line when in middle of description", () => {
|
||||||
|
const description =
|
||||||
|
"Line one\nVisio: https://meet.linagora.com/abc-defg-hij\nLine two";
|
||||||
|
const result = removeVideoConferenceFromDescription(description);
|
||||||
|
expect(result).toBe("Line one\nLine two");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should leave description unchanged when no Visio line present", () => {
|
||||||
|
const description = "Just a regular meeting description.";
|
||||||
|
const result = removeVideoConferenceFromDescription(description);
|
||||||
|
expect(result).toBe(description);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove Visio line when at start of description", () => {
|
||||||
|
const description =
|
||||||
|
"Visio: https://meet.linagora.com/abc-defg-hij\nRest of the text";
|
||||||
|
const result = removeVideoConferenceFromDescription(description);
|
||||||
|
expect(result).toBe("Rest of the text");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function redirectTo(url: string): void {
|
||||||
|
window.location.assign(url);
|
||||||
|
}
|
||||||
@@ -33,7 +33,8 @@ export function generateMeetingLink(baseUrl?: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add video conference footer to event description
|
* Add video conference footer to event description.
|
||||||
|
* If description is empty, adds on first line; otherwise adds on the line below existing content.
|
||||||
* @param {string} description - Original description
|
* @param {string} description - Original description
|
||||||
* @param {string} meetingLink - Generated meeting link
|
* @param {string} meetingLink - Generated meeting link
|
||||||
* @returns {string} Description with video conference footer
|
* @returns {string} Description with video conference footer
|
||||||
@@ -42,8 +43,9 @@ export function addVideoConferenceToDescription(
|
|||||||
description: string,
|
description: string,
|
||||||
meetingLink: string
|
meetingLink: string
|
||||||
): string {
|
): string {
|
||||||
const footer = `\nVisio: ${meetingLink}`;
|
const line = `Visio: ${meetingLink}`;
|
||||||
return description + footer;
|
const trimmed = description.trimEnd();
|
||||||
|
return trimmed ? `${trimmed}\n${line}` : line;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,3 +59,19 @@ export function extractVideoConferenceFromDescription(
|
|||||||
const match = description.match(/Visio:\s*(https?:\/\/[^\s]+)/);
|
const match = description.match(/Visio:\s*(https?:\/\/[^\s]+)/);
|
||||||
return match ? match[1] : null;
|
return match ? match[1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VISIO_LINE_REGEX = /^Visio:\s*https?:\/\/\S+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the Visio video conference line from description.
|
||||||
|
* Finds and removes the line matching "Visio: <url>" regardless of position (start, middle, end).
|
||||||
|
* @param {string} description - Event description
|
||||||
|
* @returns {string} Description with the Visio line removed
|
||||||
|
*/
|
||||||
|
export function removeVideoConferenceFromDescription(
|
||||||
|
description: string
|
||||||
|
): string {
|
||||||
|
const lines = description.split("\n");
|
||||||
|
const filtered = lines.filter((line) => !VISIO_LINE_REGEX.test(line.trim()));
|
||||||
|
return filtered.join("\n").trimEnd();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user