diff --git a/__test__/components/EventModifications.test.tsx b/__test__/components/EventModifications.test.tsx index d36bdff..1851c5a 100644 --- a/__test__/components/EventModifications.test.tsx +++ b/__test__/components/EventModifications.test.tsx @@ -400,6 +400,10 @@ describe("CalendarApp integration", () => { 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"); await act(async () => { diff --git a/__test__/components/Menubar.test.tsx b/__test__/components/Menubar.test.tsx index e95bf60..2e39e87 100644 --- a/__test__/components/Menubar.test.tsx +++ b/__test__/components/Menubar.test.tsx @@ -1,9 +1,14 @@ import { Menubar } from "@/components/Menubar/Menubar"; import * as oidcAuth from "@/features/User/oidcAuth"; +import { redirectTo } from "@/utils/navigation"; import "@testing-library/jest-dom"; import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { renderWithProviders } from "../utils/Renderwithproviders"; +jest.mock("@/utils/navigation", () => ({ + redirectTo: jest.fn(), +})); + describe("Calendar App Component Display Tests", () => { const preloadedState = { user: { @@ -320,7 +325,7 @@ describe("Calendar App Component Display Tests", () => { 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" }]; const mockCalendarRef = { current: null }; const mockOnRefresh = jest.fn(); @@ -335,10 +340,10 @@ describe("Calendar App Component Display Tests", () => { />, 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 = [ { name: "Twake", icon: "twake.svg", link: "/twake" }, { name: "Calendar", icon: "calendar.svg", link: "/calendar" }, @@ -356,7 +361,7 @@ describe("Calendar App Component Display Tests", () => { />, preloadedState ); - const appsButton = screen.getByTestId("AppsIcon"); + const appsButton = screen.getByLabelText("menubar.apps"); fireEvent.click(appsButton); expect(screen.getByText("Twake")).toBeInTheDocument(); expect(screen.getByText("Calendar")).toBeInTheDocument(); @@ -377,7 +382,7 @@ describe("Calendar App Component Display Tests", () => { />, preloadedState ); - const appsButton = screen.getByTestId("AppsIcon"); + const appsButton = screen.getByLabelText("menubar.apps"); fireEvent.click(appsButton); const testLink = screen.getByRole("link", { name: /test/i }); @@ -532,7 +537,7 @@ describe("Menubar interaction with expanded Dialog", () => { preloadedState ); - const appsButton = screen.getByTestId("AppsIcon"); + const appsButton = screen.getByLabelText("menubar.apps"); expect(appsButton).toBeVisible(); fireEvent.click(appsButton); @@ -717,10 +722,17 @@ describe("Menubar interaction with expanded Dialog", () => { }); describe("Menubar logout flow", () => { + const redirectToMock = redirectTo as jest.Mock; + beforeEach(() => { localStorage.clear(); sessionStorage.clear(); jest.clearAllMocks(); + redirectToMock.mockReset(); + }); + + afterEach(() => { + redirectToMock.mockReset(); }); function renderMenubar(user = { name: "John", email: "test@example.com" }) { @@ -756,6 +768,7 @@ describe("Menubar logout flow", () => { fireEvent.click(screen.getByText("menubar.logout")); }); expect(logoutSpy).toHaveBeenCalled(); + expect(redirectToMock).toHaveBeenCalledWith("https://logout.url/"); expect(sessionStorage.length).toBe(0); }); diff --git a/__test__/components/ResponsiveDialog.test.tsx b/__test__/components/ResponsiveDialog.test.tsx index 14da4ff..f0e0d2e 100644 --- a/__test__/components/ResponsiveDialog.test.tsx +++ b/__test__/components/ResponsiveDialog.test.tsx @@ -248,7 +248,7 @@ describe("ResponsiveDialog", () => { onClose={mockOnClose} title="Test" isExpanded={true} - headerHeight="100px" + headerHeight="70px" >
Custom Header Content
diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx index 7dc3a6d..8ae1e48 100644 --- a/__test__/features/Events/EventDisplay.test.tsx +++ b/__test__/features/Events/EventDisplay.test.tsx @@ -164,12 +164,12 @@ describe("Event Preview Display", () => { expect(screen.getByText(/January/i)).toBeInTheDocument(); expect(screen.getByText(/15/)).toBeInTheDocument(); - // Check time is displayed with exact values - // Format: "Wednesday, January 15, 2025 at 10:00 AM" and " – 10:00 AM" are in separate elements + // Check time is displayed in 24h format (no AM/PM) + // Format: "Wednesday, January 15, 2025 at 10:00" and " – 10:00" are in separate elements expect( - screen.getByText(/Wednesday, January 15, 2025 at 10:00 AM/) + screen.getByText(/Wednesday, January 15, 2025 at 10:00/) ).toBeInTheDocument(); - expect(screen.getByText(/– 10:00 AM/)).toBeInTheDocument(); + expect(screen.getByText(/– 10:00/)).toBeInTheDocument(); expect(screen.getByText("Calendar")).toBeInTheDocument(); }); @@ -1719,6 +1719,9 @@ describe("Event Full Display", () => { 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 startTimeInput = screen.getByTestId("start-time-input"); const endTimeInput = screen.getByTestId("end-time-input"); @@ -1804,6 +1807,7 @@ describe("Event Full Display", () => { preloadedState ); + fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); const allDayCheckbox = screen.getByLabelText("event.form.allDay"); fireEvent.click(allDayCheckbox); 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"); await act(async () => fireEvent.mouseDown(calendarSelect)); @@ -1947,9 +1952,9 @@ describe("Event Full Display", () => { 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 - // 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); expect(timeZone).toBeInTheDocument(); }); diff --git a/__test__/features/Events/EventModal.test.tsx b/__test__/features/Events/EventModal.test.tsx index 8ae1037..51094ba 100644 --- a/__test__/features/Events/EventModal.test.tsx +++ b/__test__/features/Events/EventModal.test.tsx @@ -124,8 +124,10 @@ describe("EventPopover", () => { }); expect(addDescriptionButton).toBeInTheDocument(); - const calendarSelect = screen.getByRole("combobox", { name: /calendar/i }); - expect(calendarSelect).toBeInTheDocument(); + // In normal mode calendar is a SectionPreviewRow (button), not combobox + expect( + screen.getByRole("button", { name: "Calendar 1" }) + ).toBeInTheDocument(); // Check button expect( @@ -154,7 +156,8 @@ describe("EventPopover", () => { allDay: false, } 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-time-input")).toBeInTheDocument(); }); @@ -179,6 +182,10 @@ describe("EventPopover", () => { "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"), { target: { value: "Conference Room" }, }); @@ -190,13 +197,14 @@ describe("EventPopover", () => { it("changes selected calendar", async () => { 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"); fireEvent.mouseDown(select); // Open menu const option = await screen.findByText("Calendar 2"); fireEvent.click(option); - // Find the calendar combobox specifically by its aria-labelledby const calendarSelect = screen.getByRole("combobox", { name: /Calendar/i }); expect(calendarSelect).toHaveTextContent("Calendar 2"); }); @@ -290,6 +298,9 @@ describe("EventPopover", () => { fireEvent.change(screen.getByLabelText("event.form.description"), { target: { value: newEvent.description }, }); + fireEvent.click( + screen.getByRole("button", { name: "event.form.locationPlaceholder" }) + ); fireEvent.change(screen.getByLabelText("event.form.location"), { target: { value: newEvent.location }, }); @@ -338,10 +349,11 @@ describe("EventPopover", () => { it("BUGFIX: Prefill Calendar field", async () => { renderPopover(); + // In normal mode calendar is a SectionPreviewRow (button) with calendar name await waitFor(() => - expect(screen.getByLabelText("event.form.calendar")).toHaveTextContent( - "Calendar 1" - ) + expect( + screen.getByRole("button", { name: "Calendar 1" }) + ).toHaveTextContent("Calendar 1") ); }); @@ -357,6 +369,9 @@ describe("EventPopover", () => { } as unknown as DateSelectArg; renderPopover(selectedRange); + fireEvent.click( + screen.getByRole("button", { name: "common.moreOptions" }) + ); const allDayCheckbox = screen.getByLabelText("event.form.allDay"); await waitFor(() => { @@ -375,6 +390,9 @@ describe("EventPopover", () => { } as unknown as DateSelectArg; renderPopover(selectedRange); + fireEvent.click( + screen.getByRole("button", { name: "common.moreOptions" }) + ); const allDayCheckbox = screen.getByLabelText("event.form.allDay"); await waitFor(() => { @@ -403,6 +421,9 @@ describe("EventPopover", () => { } as unknown as DateSelectArg; renderPopover(selectedRange); + fireEvent.click( + screen.getByRole("button", { name: "common.moreOptions" }) + ); const allDayCheckbox = screen.getByLabelText("event.form.allDay"); await waitFor(() => { @@ -427,6 +448,9 @@ describe("EventPopover", () => { } as unknown as DateSelectArg; renderPopover(selectedRange); + fireEvent.click( + screen.getByRole("button", { name: "common.moreOptions" }) + ); const allDayCheckbox = screen.getByLabelText("event.form.allDay"); await waitFor(() => { @@ -451,6 +475,9 @@ describe("EventPopover", () => { } as unknown as DateSelectArg; renderPopover(selectedRange); + fireEvent.click( + screen.getByRole("button", { name: "common.moreOptions" }) + ); const allDayCheckbox = screen.getByLabelText("event.form.allDay"); await waitFor(() => { diff --git a/__test__/features/Events/EventRepetition.test.tsx b/__test__/features/Events/EventRepetition.test.tsx index 727cefc..0e500dd 100644 --- a/__test__/features/Events/EventRepetition.test.tsx +++ b/__test__/features/Events/EventRepetition.test.tsx @@ -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")); const option = await screen.findByText("Calendar 2"); fireEvent.click(option); diff --git a/__test__/features/Events/EventUpdateModal.recurring.test.tsx b/__test__/features/Events/EventUpdateModal.recurring.test.tsx index a33ef31..5454fe0 100644 --- a/__test__/features/Events/EventUpdateModal.recurring.test.tsx +++ b/__test__/features/Events/EventUpdateModal.recurring.test.tsx @@ -129,6 +129,10 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => { screen.queryByDisplayValue("Modified Instance Title") ).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 await waitFor(() => { expect(screen.getByDisplayValue(/New York/i)).toBeDefined(); @@ -493,8 +497,12 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => { 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) - const input = screen.getByTestId("start-time-input"); await userEvent.click(input); await userEvent.clear(input); await userEvent.type(input, "14:00{enter}"); diff --git a/__test__/features/Events/EventUpdateModal.test.tsx b/__test__/features/Events/EventUpdateModal.test.tsx index 502fdfc..f7340cd 100644 --- a/__test__/features/Events/EventUpdateModal.test.tsx +++ b/__test__/features/Events/EventUpdateModal.test.tsx @@ -93,6 +93,9 @@ describe("EventUpdateModal Timezone Handling", () => { const titleInput = screen.getByDisplayValue("Timezone Event"); 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 await waitFor(() => { const startDateInput = screen.getByTestId("start-date-input"); @@ -282,6 +285,7 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => { expect(mockGetMasterEvent).toHaveBeenCalled(); }); + fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); // Wait for the repeat checkbox to be checked (indicating form is initialized) const repeatCheckbox = await waitFor(() => { const checkbox = screen.getByLabelText("event.form.repeat"); @@ -414,6 +418,7 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => { expect(screen.getByDisplayValue("Recurring Meeting")).toBeInTheDocument(); }); + fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); // Wait for repeat checkbox to be checked const repeatCheckbox = await waitFor(() => { const checkbox = screen.getByLabelText("event.form.repeat"); @@ -546,6 +551,7 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => { expect(screen.getByDisplayValue("Recurring Meeting")).toBeInTheDocument(); }); + fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); // Uncheck repeat and save const repeatCheckbox = screen.getByLabelText("event.form.repeat"); diff --git a/package-lock.json b/package-lock.json index f06f5bb..7583aac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@fullcalendar/moment-timezone": "^6.1.19", "@fullcalendar/react": "^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", "@mui/icons-material": "^7.1.2", "@mui/material": "^7.1.2", @@ -3136,9 +3136,10 @@ } }, "node_modules/@linagora/twake-mui": { - "version": "1.1.4", - "resolved": "https://npm.pkg.github.com/download/@linagora/twake-mui/1.1.4/56321d9e10cd99550e751f15c34aaac6842442c1", - "integrity": "sha512-L6e+6KgqFUxFY6avmk4UG4/bsdfzo6TxBkahUZlKxvCNEEikVe8z2Cxd0JtUUAWrPGZonTII9n2KhdFSNmNxvg==", + "version": "1.1.8", + "resolved": "https://npm.pkg.github.com/download/@linagora/twake-mui/1.1.8/e629a126e62dffd939a8d3d8de3ce024de5a2154", + "integrity": "sha512-H2MxtZIy5w1A8VNTNAoM4Pjuph/et4YP8DfzQ+5HCsjRJn6SA/T3nZRZOqC26SxY9kl3XJCKIEKklL+x5af7Vg==", + "license": "MIT", "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/package.json b/package.json index 2ff1e1e..416d7d8 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "@fullcalendar/moment-timezone": "^6.1.19", "@fullcalendar/react": "^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", "@mui/icons-material": "^7.1.2", "@mui/material": "^7.1.2", diff --git a/src/components/Attendees/AttendeeSearch.tsx b/src/components/Attendees/AttendeeSearch.tsx index 647ce7c..5324bea 100644 --- a/src/components/Attendees/AttendeeSearch.tsx +++ b/src/components/Attendees/AttendeeSearch.tsx @@ -12,6 +12,7 @@ export default function UserSearch({ setAttendees, disabled, inputSlot, + placeholder, }: { attendees: userAttendee[]; setAttendees: Function; @@ -19,8 +20,9 @@ export default function UserSearch({ inputSlot?: ( params: ExtendedAutocompleteRenderInputParams ) => React.ReactNode; + placeholder?: string; }) { - const [selectedUsers, setSelectedUsers] = useState( + const [selectedUsers, setSelectedUsers] = useState( attendees.map((attendee) => ({ email: attendee.cal_address, displayName: attendee.cn ?? "", @@ -44,6 +46,7 @@ export default function UserSearch({ objectTypes={["user", "contact"]} disabled={disabled} inputSlot={inputSlot} + placeholder={placeholder} onChange={(event: any, value: User[]) => { setAttendees( value.map((attendee: User) => diff --git a/src/components/Calendar/Calendar.styl b/src/components/Calendar/Calendar.styl index fe3e3bf..238a4fe 100644 --- a/src/components/Calendar/Calendar.styl +++ b/src/components/Calendar/Calendar.styl @@ -6,7 +6,7 @@ .main-layout display flex - height calc(100vh - 90px) + height calc(100vh - 70px) .main-layout.calendar-layout background-color #fff @@ -117,7 +117,7 @@ margin-top 2px width 6px height 6px - background-color #d93025 + background-color #1D192B1F border-radius 50% margin-left auto margin-right auto diff --git a/src/components/Calendar/CustomCalendar.styl b/src/components/Calendar/CustomCalendar.styl index 633fe5b..6165078 100644 --- a/src/components/Calendar/CustomCalendar.styl +++ b/src/components/Calendar/CustomCalendar.styl @@ -73,7 +73,7 @@ span.fc-daygrid-day-number.current-date color #fff 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-timegrid-slot-minor @@ -244,7 +244,7 @@ tbody > tr.fc-scrollgrid-section:first-of-type .fc-scroller overflow hidden !important .navigation-controls - margin-right 40px + margin-right 16px .fc .fc-timegrid-slot-minor border-top-style: none diff --git a/src/components/Calendar/SettingsTab.tsx b/src/components/Calendar/SettingsTab.tsx index 3d80a97..c445093 100644 --- a/src/components/Calendar/SettingsTab.tsx +++ b/src/components/Calendar/SettingsTab.tsx @@ -50,21 +50,26 @@ export function SettingsTab({ <> {/* Form group 1: Name field - first group, margin top 0 */} - setName(e.target.value)} - size="small" - sx={{ - "&.MuiFormControl-root": { - marginTop: 0, - marginBottom: 0, - }, - }} - /> + + {t("calendarPopover.settings.calendarName")} + + + setName(e.target.value)} + size="small" + sx={{ + "&.MuiFormControl-root": { + marginTop: 0, + marginBottom: 0, + }, + }} + /> + {/* Form group 2: Description */} @@ -75,8 +80,6 @@ export function SettingsTab({ showMore={false} description={description} setDescription={setDescription} - buttonVariant="contained" - buttonColor="secondary" /> diff --git a/src/components/Dialog/README.md b/src/components/Dialog/README.md index b8ef040..3639d05 100644 --- a/src/components/Dialog/README.md +++ b/src/components/Dialog/README.md @@ -4,8 +4,8 @@ A highly reusable dialog component that supports both normal and expanded (fulls ## Features -- ✅ **Two Modes**: Normal popup (685px) and expanded fullscreen mode -- ✅ **Preserves Header**: Expanded mode doesn't cover app header (90px default) +- ✅ **Two Modes**: Normal popup (570px) and expanded fullscreen mode +- ✅ **Preserves Header**: Expanded mode doesn't cover app header (70px default) - ✅ **Clean Expanded View**: No backdrop/shadow in expanded mode for seamless integration - ✅ **Instant Transition**: No animation when expanding for immediate feedback - ✅ **Back Navigation**: Expanded mode shows back arrow icon in header for easy collapse @@ -62,9 +62,9 @@ function MyComponent() { | `actions` | `ReactNode` | - | Action buttons | | `isExpanded` | `boolean` | `false` | Toggle fullscreen mode | | `onExpandToggle` | `() => void` | - | Handler for back button click when expanded | -| `normalMaxWidth` | `string` | `"685px"` | Max width in normal mode | +| `normalMaxWidth` | `string` | `"570px"` | Max width in normal 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) | | `expandedSpacing` | `number` | `2` | Stack spacing in expanded mode (MUI spacing units: 1 = 8px) | | `contentSx` | `SxProps` | - | Custom styles for DialogContent | @@ -149,7 +149,7 @@ For apps with different header heights: open={open} onClose={handleClose} title="Custom Header Height" - headerHeight="80px" + headerHeight="70px" isExpanded={true} > @@ -160,7 +160,7 @@ For apps with different header heights: ### Normal Mode (`isExpanded={false}`) -- Dialog: max-width = `normalMaxWidth` (default 685px) +- Dialog: max-width = `normalMaxWidth` (default 570px) - Content: 100% width - Height: auto (fits content) - 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 2. **Provide `onExpandToggle` handler**: Required for back button functionality in expanded mode 3. **Hide expand button in actions when expanded**: Back arrow in header provides collapse action -4. **Keep `normalMaxWidth` reasonable**: 685px works well for forms +4. **Keep `normalMaxWidth` reasonable**: 570px works well for forms 5. **Center content in expanded mode**: Default 990px provides good reading width 6. **Preserve header**: Default 90px - adjust via `headerHeight` prop if needed 7. **MUI Stack handles spacing**: Children wrapped in Stack with configurable spacing prop - override with `normalSpacing`/`expandedSpacing` if needed diff --git a/src/components/Dialog/ResponsiveDialog.tsx b/src/components/Dialog/ResponsiveDialog.tsx index 332dc02..03a3ee3 100644 --- a/src/components/Dialog/ResponsiveDialog.tsx +++ b/src/components/Dialog/ResponsiveDialog.tsx @@ -21,7 +21,7 @@ import React, { ReactNode } from "react"; * ResponsiveDialog - A reusable dialog component that can switch between normal and expanded modes * * Features: - * - Normal mode: Dialog with customizable max-width (default 685px) + * - Normal mode: Dialog with customizable max-width (default 570px) * - Expanded mode: Full height dialog (excluding app header) with centered content container * - Fully customizable with sx props for Dialog, DialogTitle, and DialogContent * - Preserves app header visibility in expanded mode @@ -58,7 +58,7 @@ interface ResponsiveDialogProps extends Omit< isExpanded?: boolean; /** Callback when expand/collapse button is clicked (required if using isExpanded) */ onExpandToggle?: () => void; - /** Max width in normal mode (default: "685px") */ + /** Max width in normal mode (default: "570px") */ normalMaxWidth?: string; /** Max width of content container in expanded mode (default: "990px") */ expandedContentMaxWidth?: string; @@ -98,9 +98,9 @@ function ResponsiveDialog({ actions, isExpanded = false, onExpandToggle, - normalMaxWidth = "685px", + normalMaxWidth = "570px", expandedContentMaxWidth = "990px", - headerHeight = "90px", + headerHeight = "70px", normalSpacing = 2, expandedSpacing = 2, contentSx, @@ -116,6 +116,7 @@ function ResponsiveDialog({ }: ResponsiveDialogProps) { const baseSx: SxProps = { "& .MuiBackdrop-root": { + backgroundColor: "rgba(0, 0, 0, 0.1)", opacity: isExpanded ? "0 !important" : undefined, transition: isExpanded ? "none !important" : undefined, pointerEvents: isExpanded ? "none" : undefined, @@ -202,8 +203,9 @@ function ResponsiveDialog({ onClick={onExpandToggle} aria-label="expand" size="small" + sx={{ marginRight: 1 }} > - + )} diff --git a/src/components/Event/AddDescButton.tsx b/src/components/Event/AddDescButton.tsx index 068f3a0..d09513d 100644 --- a/src/components/Event/AddDescButton.tsx +++ b/src/components/Event/AddDescButton.tsx @@ -1,7 +1,9 @@ -import { Box, Button, TextField } from "@linagora/twake-mui"; -import { Description as DescriptionIcon } from "@mui/icons-material"; +import { TextField } from "@linagora/twake-mui"; +import { Notes as NotesIcon } from "@mui/icons-material"; +import React from "react"; import { useI18n } from "twake-i18n"; import { FieldWithLabel } from "./components/FieldWithLabel"; +import { SectionPreviewRow } from "./components/SectionPreviewRow"; export function AddDescButton({ showDescription, @@ -9,73 +11,72 @@ export function AddDescButton({ showMore, description, setDescription, - buttonVariant, - buttonColor, }: { showDescription: boolean; setShowDescription: (b: boolean) => void; showMore: boolean; description: string; setDescription: (d: string) => void; - buttonVariant?: "text" | "outlined" | "contained"; - buttonColor?: - | "inherit" - | "primary" - | "secondary" - | "success" - | "error" - | "info" - | "warning"; }) { const { t } = useI18n(); + const descriptionInputRef = React.useRef(null); + + React.useEffect(() => { + if (showDescription) { + descriptionInputRef.current?.focus(); + } + }, [showDescription]); + + const descriptionField = ( + + 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", + }, + }} + /> + + ); + + if (showMore) { + return descriptionField; + } + return ( <> {!showDescription && ( - - - - - - )} - {showDescription && ( - - 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", - }, - }} - /> + + } + onClick={() => setShowDescription(true)} + > + {t("event.form.addDescription")} + )} + {showDescription && descriptionField} ); } diff --git a/src/components/Event/EventFormFields.tsx b/src/components/Event/EventFormFields.tsx index 19f8c5c..592bd92 100644 --- a/src/components/Event/EventFormFields.tsx +++ b/src/components/Event/EventFormFields.tsx @@ -5,6 +5,7 @@ import iconCamera from "@/static/images/icon-camera.svg"; import { addVideoConferenceToDescription, generateMeetingLink, + removeVideoConferenceFromDescription, } from "@/utils/videoConferenceUtils"; import { Box, @@ -20,12 +21,16 @@ import { ToggleButton, ToggleButtonGroup, Typography, + useTheme, } from "@linagora/twake-mui"; +import { alpha } from "@mui/material/styles"; import { Close as DeleteIcon, ContentCopy as CopyIcon, Public as PublicIcon, + LocationOn as LocationIcon, } from "@mui/icons-material"; +import SquareRoundedIcon from "@mui/icons-material/SquareRounded"; import LockOutlineIcon from "@mui/icons-material/LockOutline"; import React from "react"; import { useI18n } from "twake-i18n"; @@ -35,7 +40,9 @@ import { SnackbarAlert } from "../Loading/SnackBarAlert"; import { TimezoneAutocomplete } from "../Timezone/TimezoneAutocomplete"; import { AddDescButton } from "./AddDescButton"; import { DateTimeFields } from "./components/DateTimeFields"; +import { DateTimeSummary } from "./components/DateTimeSummary"; import { FieldWithLabel } from "./components/FieldWithLabel"; +import { SectionPreviewRow } from "./components/SectionPreviewRow"; import RepeatEvent from "./EventRepeat"; import { useAllDayToggle } from "./hooks/useAllDayToggle"; import { combineDateTime, splitDateTime } from "./utils/dateTimeHelpers"; @@ -156,6 +163,7 @@ export default function EventFormFields({ onHasEndDateChangedChange, }: EventFormFieldsProps) { const { t } = useI18n(); + const theme = useTheme(); // Internal state for 4 separate fields const [startDate, setStartDate] = React.useState(""); @@ -166,10 +174,26 @@ export default function EventFormFields({ // Track if user has manually changed end date in extended mode 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(() => { if (!isOpen) { setHasEndDateChanged(false); + setHasClickedDateTimeSection(false); + setHasClickedLocationSection(false); + setHasClickedCalendarSection(false); } }, [isOpen]); @@ -210,10 +234,18 @@ export default function EventFormFields({ // Ref for title input field to enable auto-focus const titleInputRef = React.useRef(null); + const locationInputRef = React.useRef(null); // Track previous showMore state to detect changes const prevShowMoreRef = React.useRef(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) React.useEffect(() => { if (isOpen) { @@ -389,6 +421,9 @@ export default function EventFormFields({ setDescription(updatedDescription); setHasVideoConference(true); setMeetingLink(newMeetingLink); + if (showMore) { + setShowDescription(true); + } }; const [openToast, setOpenToast] = React.useState(false); @@ -405,11 +440,7 @@ export default function EventFormFields({ }; const handleDeleteVideoConference = () => { - const updatedDescription = description.replace( - /\nVisio: https?:\/\/[^\s]+/, - "" - ); - setDescription(updatedDescription); + setDescription(removeVideoConferenceFromDescription(description)); setHasVideoConference(false); setMeetingLink(null); }; @@ -421,7 +452,10 @@ export default function EventFormFields({ return ( <> - + - - - - {}} - /> - - - - - - } - label={ - {t("event.form.allDay")} - } - /> - { - 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={ - {t("event.form.repeat")} - } - /> - - timezoneList.getTimezoneOffset(tzName, new Date(start)) - } - showIcon={false} - width={240} - size="small" - placeholder={t("event.form.timezonePlaceholder")} - /> - - - - {(showRepeat || (typeOfAction === "solo" && repetition?.freq)) && ( - - + {!showMore && !hasClickedDateTimeSection ? ( + setHasClickedDateTimeSection(true)} /> + ) : ( + {}} + /> + )} + + + {!(!showMore && !hasClickedDateTimeSection) && ( + + + + } + label={ + {t("event.form.allDay")} + } + /> + { + 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={ + {t("event.form.repeat")} + } + /> + + timezoneList.getTimezoneOffset(tzName, new Date(start)) + } + showIcon={false} + width={220} + size="small" + placeholder={t("event.form.timezonePlaceholder")} + /> + )} + {!(!showMore && !hasClickedDateTimeSection) && + (showRepeat || (typeOfAction === "solo" && repetition?.freq)) && ( + + + + )} + } /> - - - - {hasVideoConference && meetingLink && ( - <> + {!showMore ? ( + hasVideoConference && meetingLink ? ( + @@ -608,38 +645,153 @@ export default function EventFormFields({ > - - )} - + + ) : ( + + } + onClick={handleAddVideoConference} + > + {t("event.form.addVisioConference")} + + ) + ) : ( + + + + {hasVideoConference && meetingLink && ( + <> + + + + + + + + + )} + + )} - - setLocation(e.target.value)} - size="small" - margin="dense" - /> - + - - - - + {location || t("event.form.locationPlaceholder")} + + ) : ( + setLocation(e.target.value)} + size="small" + margin="dense" + /> + )} + + + + {!showMore && !hasClickedCalendarSection ? ( + cal.id === calendarid) + ?.color?.light ?? "#3788D8", + width: 24, + height: 24, + }} + /> + } + onClick={() => setHasClickedCalendarSection(true)} + > + {userPersonalCalendars.find((cal) => cal.id === calendarid)?.name || + t("event.form.calendar")} + + ) : ( + + + + )} {showMore && ( diff --git a/src/components/Event/InfoRow.tsx b/src/components/Event/InfoRow.tsx index de21085..436eaea 100644 --- a/src/components/Event/InfoRow.tsx +++ b/src/components/Event/InfoRow.tsx @@ -8,6 +8,7 @@ type InfoRowProps = { data?: string; // optional link target content?: React.ReactNode; // if provided, overrides text rendering style?: React.CSSProperties; + alignItems?: React.CSSProperties["alignItems"]; }; function detectUrls(text: string) { @@ -62,12 +63,13 @@ export function InfoRow({ data, content, style, + alignItems = "center", }: InfoRowProps) { return ( void; + iconColor?: string; + children?: React.ReactNode; + ariaLabel?: string; +} + +export const ClickableField: React.FC = ({ + icon, + text, + onClick, + iconColor, + children, + ariaLabel, +}) => { + const theme = useTheme(); + + return ( + { + 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", + }, + }} + > + + {icon} + + {children ? ( + {children} + ) : ( + + {text} + + )} + + ); +}; diff --git a/src/components/Event/components/DateTimeFields.tsx b/src/components/Event/components/DateTimeFields.tsx index 0e26a70..b380784 100644 --- a/src/components/Event/components/DateTimeFields.tsx +++ b/src/components/Event/components/DateTimeFields.tsx @@ -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 */ @@ -369,6 +378,7 @@ export const DateTimeFields: React.FC = ({ false, t("dateTimeFields.startDate") ), + layout: { sx: dateCalendarLayoutSx }, }} /> @@ -415,6 +425,7 @@ export const DateTimeFields: React.FC = ({ !!validation.errors.dateTime, t("dateTimeFields.endDate") ), + layout: { sx: dateCalendarLayoutSx }, }} /> @@ -463,6 +474,7 @@ export const DateTimeFields: React.FC = ({ false, t("dateTimeFields.startDate") ), + layout: { sx: dateCalendarLayoutSx }, }} /> @@ -483,6 +495,7 @@ export const DateTimeFields: React.FC = ({ !!validation.errors.dateTime, t("dateTimeFields.endDate") ), + layout: { sx: dateCalendarLayoutSx }, }} /> @@ -502,6 +515,7 @@ export const DateTimeFields: React.FC = ({ false, startDateLabel ), + layout: { sx: dateCalendarLayoutSx }, }} /> diff --git a/src/components/Event/components/DateTimeSummary.tsx b/src/components/Event/components/DateTimeSummary.tsx new file mode 100644 index 0000000..91c1d11 --- /dev/null +++ b/src/components/Event/components/DateTimeSummary.tsx @@ -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 = ({ + 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 ( + } + onClick={onClick} + > + + + {dateText} + {showEndDate &&
} + {timeText && ( + + {timeText} + + )} +
+ + + {timezoneText} + + + {repeatText} + + +
+
+ ); +}; diff --git a/src/components/Event/components/SectionPreviewRow.tsx b/src/components/Event/components/SectionPreviewRow.tsx new file mode 100644 index 0000000..4732b63 --- /dev/null +++ b/src/components/Event/components/SectionPreviewRow.tsx @@ -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 = ({ + 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 ( + + + {icon} + + + {typeof children === "string" ? ( + + {children} + + ) : ( + children + )} + + + ); +}; diff --git a/src/components/Event/utils/eventUtils.tsx b/src/components/Event/utils/eventUtils.tsx index a3253d8..a5a6207 100644 --- a/src/components/Event/utils/eventUtils.tsx +++ b/src/components/Event/utils/eventUtils.tsx @@ -65,16 +65,7 @@ export function renderAttendeeBadge( - - {a.cn || a.cal_address} - + {a.cn || a.cal_address} {isOrganizer && ( {t("event.organizer")} diff --git a/src/components/Menubar/EventSearchBar.tsx b/src/components/Menubar/EventSearchBar.tsx index f099562..228fb4c 100644 --- a/src/components/Menubar/EventSearchBar.tsx +++ b/src/components/Menubar/EventSearchBar.tsx @@ -214,7 +214,7 @@ export default function SearchBar() { }} > {!extended && ( - setExtended(true)}> + setExtended(true)}> )} diff --git a/src/components/Menubar/Menubar.styl b/src/components/Menubar/Menubar.styl index ade328e..402d18b 100644 --- a/src/components/Menubar/Menubar.styl +++ b/src/components/Menubar/Menubar.styl @@ -38,20 +38,22 @@ padding 0.5rem width 100% z-index 1300 - height 80px + height 60px background-color #fff .menubar-item display flex align-items center - width calc(330px - 0.5rem) + margin-right 65px .tc-home cursor pointer .logo - padding 0.5rem 1rem + padding 0 font-size 1.5rem + max-width 230px + margin-left 1rem .nav-month padding-right 2px @@ -91,6 +93,14 @@ justify-content flex-end 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 .current-date-time, body.fullscreen-view .refresh-button, diff --git a/src/components/Menubar/Menubar.tsx b/src/components/Menubar/Menubar.tsx index f858024..0c1d2fd 100644 --- a/src/components/Menubar/Menubar.tsx +++ b/src/components/Menubar/Menubar.tsx @@ -4,6 +4,7 @@ import { Logout } from "@/features/User/oidcAuth"; import logo from "@/static/header-logo.svg"; import { getInitials, stringToGradient } from "@/utils/avatarUtils"; import { getUserDisplayName } from "@/utils/userUtils"; +import { redirectTo } from "@/utils/navigation"; import { CalendarApi } from "@fullcalendar/core"; import { Avatar, @@ -19,7 +20,8 @@ import { Select, Typography, } 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 ChevronRightIcon from "@mui/icons-material/ChevronRight"; import LogoutIcon from "@mui/icons-material/Logout"; @@ -139,7 +141,7 @@ export function Menubar({ const handleLogoutClick = async () => { const logoutUrl = await Logout(); sessionStorage.removeItem("tokenSet"); - window.location.assign(logoutUrl.href); + redirectTo(logoutUrl.href); handleUserMenuClose(); }; @@ -195,9 +197,7 @@ export function Menubar({
- - {dateLabel} - +

{dateLabel}

@@ -211,6 +211,7 @@ export function Menubar({ onClick={onRefresh} aria-label={t("menubar.refresh")} title={t("menubar.refresh")} + sx={{ mr: 1 }} >
@@ -226,7 +227,11 @@ export function Menubar({ onChange={(e) => handleViewChange(e.target.value)} variant="outlined" aria-label={t("menubar.viewSelector")} - sx={{ height: "43px" }} + sx={{ + borderRadius: "12px", + marginLeft: 1, + "& fieldset": { borderRadius: "12px" }, + }} > {t("menubar.views.month")} @@ -240,6 +245,19 @@ export function Menubar({ +
+ + + +
{applist.length > 0 && ( - + )}
diff --git a/src/features/Events/EventDisplayPreview.tsx b/src/features/Events/EventDisplayPreview.tsx index f88d122..e3705ca 100644 --- a/src/features/Events/EventDisplayPreview.tsx +++ b/src/features/Events/EventDisplayPreview.tsx @@ -20,7 +20,9 @@ import { MenuItem, Tooltip, Typography, + useTheme, } from "@linagora/twake-mui"; +import { alpha } from "@mui/material/styles"; import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; import CircleIcon from "@mui/icons-material/Circle"; import CloseIcon from "@mui/icons-material/Close"; @@ -73,6 +75,9 @@ export default function EventPreviewModal({ : calendars.list[calId]; const event = calendar.events[eventId]; 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; const isRecurring = event?.uid?.includes("/"); @@ -451,10 +456,10 @@ export default function EventPreviewModal({ title={t("eventPreview.privateEvent.tooltipOwn")} placement="top" > - + ) : ( - + ))} + } @@ -511,8 +517,9 @@ export default function EventPreviewModal({ {attendees?.length > 0 && ( <> + } @@ -585,8 +592,9 @@ export default function EventPreviewModal({ {/* Location */} {event.location && ( + } @@ -600,8 +608,9 @@ export default function EventPreviewModal({ {/* Description */} {event.description && ( + } @@ -615,8 +624,9 @@ export default function EventPreviewModal({ {/* ALARM */} {event.alarm && ( + } @@ -641,8 +651,9 @@ export default function EventPreviewModal({ {/* Repetition */} {event.repetition && ( + } @@ -681,8 +692,9 @@ export default function EventPreviewModal({ {/* Error */} {event.error && ( + } @@ -698,12 +710,12 @@ export default function EventPreviewModal({ - + @@ -860,6 +872,7 @@ function formatDate( day: "numeric", hour: "2-digit", minute: "2-digit", + hour12: false, timeZone, }); } @@ -896,6 +909,7 @@ function formatEnd( return endDate.toLocaleTimeString(t("locale"), { hour: "2-digit", minute: "2-digit", + hour12: false, timeZone, }); } @@ -905,6 +919,7 @@ function formatEnd( day: "numeric", hour: "2-digit", minute: "2-digit", + hour12: false, timeZone, }); } diff --git a/src/features/Settings/SettingsPage.styl b/src/features/Settings/SettingsPage.styl index 60fdab9..28673ea 100644 --- a/src/features/Settings/SettingsPage.styl +++ b/src/features/Settings/SettingsPage.styl @@ -1,7 +1,7 @@ .main-layout.settings-layout display flex flex-direction row - height calc(100vh - 90px) + height calc(100vh - 70px) padding 0 10px 10px 0; .settings-sidebar diff --git a/src/locales/en.json b/src/locales/en.json index b050570..041356e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -76,6 +76,9 @@ "addNew": "Add new calendar", "access": "Access", "import": "Import" + }, + "settings": { + "calendarName": "Calendar name" } }, "colorPicker": { @@ -98,6 +101,8 @@ "years": "Year(s)" }, "repeatOn": "Repeat on:", + "doesNotRepeat": "Doesn't repeat", + "every": "Every", "days": { "monday": "MO", "tuesday": "TU", @@ -127,6 +132,7 @@ "allDay": "All day", "repeat": "Repeat", "timezonePlaceholder": "Select timezone", + "addGuestsPlaceholder": "Add guests", "participants": "Participants", "videoMeeting": "Video meeting", "meetCopied": "Meeting link copied!", diff --git a/src/locales/fr.json b/src/locales/fr.json index ba412e9..7cde95b 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -78,6 +78,9 @@ "addNew": "Ajouter un nouveau calendrier", "access": "Accès", "import": "Importer" + }, + "settings": { + "calendarName": "Nom du calendrier" } }, "colorPicker": { @@ -100,6 +103,8 @@ "years": "année(s)" }, "repeatOn": "Répéter le :", + "doesNotRepeat": "Ne se répète pas", + "every": "Tous les", "days": { "monday": "LU", "tuesday": "MA", @@ -129,6 +134,7 @@ "allDay": "Toute la journée", "repeat": "Répéter", "timezonePlaceholder": "Sélectionner un fuseau horaire", + "addGuestsPlaceholder": "Ajouter des invités", "participants": "Participants", "videoMeeting": "Visioconférence", "meetCopied": "Lien de la visio copié!", @@ -288,7 +294,7 @@ }, "dateTimeFields": { "date": "Date", - "startDate": "Daye du début", + "startDate": "Date du début", "startTime": "Heure du début", "endDate": "Date de la fin", "endTime": "Heure de la fin" diff --git a/src/locales/ru.json b/src/locales/ru.json index 5540f4c..1eff762 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -78,6 +78,9 @@ "addNew": "Добавить новый календарь", "access": "Доступ", "import": "Импорт" + }, + "settings": { + "calendarName": "Название календаря" } }, "colorPicker": { @@ -100,6 +103,8 @@ "years": "год(лет)" }, "repeatOn": "Повторять по:", + "doesNotRepeat": "Не повторяется", + "every": "Каждый", "days": { "monday": "ПН", "tuesday": "ВТ", @@ -129,6 +134,7 @@ "allDay": "Весь день", "repeat": "Повторять", "timezonePlaceholder": "Выберите часовой пояс", + "addGuestsPlaceholder": "Добавить гостей", "participants": "Участники", "videoMeeting": "Видеовстреча", "meetCopied": "Ссылка на встречу скопирована", diff --git a/src/locales/vi.json b/src/locales/vi.json index 8ae8f63..b717413 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -76,6 +76,9 @@ "addNew": "Thêm lịch mới", "access": "Quyền truy cập", "import": "Nhập" + }, + "settings": { + "calendarName": "Tên lịch" } }, "colorPicker": { @@ -98,6 +101,8 @@ "years": "Năm" }, "repeatOn": "Lặp vào:", + "doesNotRepeat": "Không lặp lại", + "every": "Mỗi", "days": { "monday": "Th 2", "tuesday": "Th 3", @@ -127,6 +132,7 @@ "allDay": "Cả ngày", "repeat": "Lặp lại", "timezonePlaceholder": "Chọn múi giờ", + "addGuestsPlaceholder": "Thêm khách", "participants": "Người tham gia", "videoMeeting": "Cuộc họp video", "meetCopied": "Đã sao chép liên kết họp!", diff --git a/src/utils/__test__/videoConferenceUtils.test.ts b/src/utils/__test__/videoConferenceUtils.test.ts index 906c99c..12e1ff8 100644 --- a/src/utils/__test__/videoConferenceUtils.test.ts +++ b/src/utils/__test__/videoConferenceUtils.test.ts @@ -3,6 +3,7 @@ import { extractVideoConferenceFromDescription, generateMeetingId, generateMeetingLink, + removeVideoConferenceFromDescription, } from "../videoConferenceUtils"; // Mock window object for Node.js environment @@ -45,11 +46,11 @@ describe("videoConferenceUtils", () => { }); 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 meetingLink = "https://meet.linagora.com/abc-defg-hij"; 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", () => { @@ -82,4 +83,39 @@ describe("videoConferenceUtils", () => { 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"); + }); + }); }); diff --git a/src/utils/navigation.ts b/src/utils/navigation.ts new file mode 100644 index 0000000..843bf95 --- /dev/null +++ b/src/utils/navigation.ts @@ -0,0 +1,3 @@ +export function redirectTo(url: string): void { + window.location.assign(url); +} diff --git a/src/utils/videoConferenceUtils.ts b/src/utils/videoConferenceUtils.ts index d2ee7f2..e2b6325 100644 --- a/src/utils/videoConferenceUtils.ts +++ b/src/utils/videoConferenceUtils.ts @@ -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} meetingLink - Generated meeting link * @returns {string} Description with video conference footer @@ -42,8 +43,9 @@ export function addVideoConferenceToDescription( description: string, meetingLink: string ): string { - const footer = `\nVisio: ${meetingLink}`; - return description + footer; + const line = `Visio: ${meetingLink}`; + 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]+)/); 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: " 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(); +}