From da0dba423e20e67c7a9dc5d2e49f3912a78e4cd0 Mon Sep 17 00:00:00 2001 From: lenhanphung Date: Mon, 26 Jan 2026 17:49:48 +0700 Subject: [PATCH] update normal mode event createa update modal --- package-lock.json | 54 +- package.json | 2 +- src/components/Attendees/AttendeeSearch.tsx | 5 +- src/components/Dialog/README.md | 8 +- src/components/Dialog/ResponsiveDialog.tsx | 9 +- src/components/Event/AddDescButton.tsx | 38 +- src/components/Event/EventFormFields.tsx | 510 +++++++++++------- .../Event/components/DateTimeSummary.tsx | 161 ++++++ 8 files changed, 579 insertions(+), 208 deletions(-) create mode 100644 src/components/Event/components/DateTimeSummary.tsx diff --git a/package-lock.json b/package-lock.json index f06f5bb..5590b70 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.7", "@lottiefiles/dotlottie-react": "^0.17.13", "@mui/icons-material": "^7.1.2", "@mui/material": "^7.1.2", @@ -83,6 +83,52 @@ "node": "24.x" } }, + "../twake-ui/packages/twake-mui": { + "name": "@linagora/twake-mui", + "version": "1.1.4", + "extraneous": true, + "license": "MIT", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@babel/eslint-parser": "7.19.1", + "@types/react": "18.3.26", + "@types/react-dom": "18.3.7", + "@typescript-eslint/eslint-plugin": "6.21.0", + "@typescript-eslint/parser": "6.21.0", + "cozy-tsconfig": "1.8.1", + "eslint": "8.35.0", + "eslint-config-cozy-app": "6.7.1", + "eslint-config-prettier": "8.6.0", + "eslint-plugin-import": "2.27.5", + "eslint-plugin-jest": "27.9.0", + "eslint-plugin-prettier": "4.2.1", + "eslint-plugin-promise": "6.1.1", + "eslint-plugin-react": "7.32.2", + "eslint-plugin-react-hooks": "4.6.0", + "prettier": "2.8.4" + }, + "engines": { + "node": "24.x" + }, + "peerDependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/material": "^7.1.2", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": false + }, + "react-dom": { + "optional": false + } + } + }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -3136,9 +3182,9 @@ } }, "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.7", + "resolved": "https://npm.pkg.github.com/download/@linagora/twake-mui/1.1.7/dd45fa10aeb5c7e6b6e89f59ff9d88c7d2f7e507", + "integrity": "sha512-9ZRCeWUWObbIk9h2jg3N0fZlJ+QRBouXzhMNMC4rjQBgFvnCZxZHiMt6kGXQmOov8JsHFKSRhunwfNBYy1cv8Q==", "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/package.json b/package.json index 2ff1e1e..7f61359 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.7", "@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/Dialog/README.md b/src/components/Dialog/README.md index b8ef040..1592c5c 100644 --- a/src/components/Dialog/README.md +++ b/src/components/Dialog/README.md @@ -4,7 +4,7 @@ A highly reusable dialog component that supports both normal and expanded (fulls ## 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) - ✅ **Clean Expanded View**: No backdrop/shadow in expanded mode for seamless integration - ✅ **Instant Transition**: No animation when expanding for immediate feedback @@ -62,7 +62,7 @@ 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 | | `normalSpacing` | `number` | `2` | Stack spacing in normal mode (MUI spacing units: 1 = 8px) | @@ -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..0114bf9 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,7 +98,7 @@ function ResponsiveDialog({ actions, isExpanded = false, onExpandToggle, - normalMaxWidth = "685px", + normalMaxWidth = "570px", expandedContentMaxWidth = "990px", headerHeight = "90px", normalSpacing = 2, @@ -202,8 +202,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..5eb3f6a 100644 --- a/src/components/Event/AddDescButton.tsx +++ b/src/components/Event/AddDescButton.tsx @@ -1,5 +1,6 @@ -import { Box, Button, TextField } from "@linagora/twake-mui"; -import { Description as DescriptionIcon } from "@mui/icons-material"; +import { Box, Button, TextField, useTheme } from "@linagora/twake-mui"; +import { alpha } from "@mui/material/styles"; +import { Notes as NotesIcon } from "@mui/icons-material"; import { useI18n } from "twake-i18n"; import { FieldWithLabel } from "./components/FieldWithLabel"; @@ -28,21 +29,40 @@ export function AddDescButton({ | "warning"; }) { const { t } = useI18n(); + const theme = useTheme(); return ( <> {!showDescription && ( - - + + {!showMore ? ( - + ) : ( + + + + )} )} {showDescription && ( diff --git a/src/components/Event/EventFormFields.tsx b/src/components/Event/EventFormFields.tsx index 19f8c5c..4bfdba4 100644 --- a/src/components/Event/EventFormFields.tsx +++ b/src/components/Event/EventFormFields.tsx @@ -20,12 +20,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,6 +39,7 @@ 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 RepeatEvent from "./EventRepeat"; import { useAllDayToggle } from "./hooks/useAllDayToggle"; @@ -156,6 +161,7 @@ export default function EventFormFields({ onHasEndDateChangedChange, }: EventFormFieldsProps) { const { t } = useI18n(); + const theme = useTheme(); // Internal state for 4 separate fields const [startDate, setStartDate] = React.useState(""); @@ -165,11 +171,28 @@ export default function EventFormFields({ // Track if user has manually changed end date in extended mode const [hasEndDateChanged, setHasEndDateChanged] = React.useState(false); + + // 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); + + // Track if user has clicked on video meeting section in normal mode + const [hasClickedVideoMeetingSection, setHasClickedVideoMeetingSection] = React.useState(false); - // Reset hasEndDateChanged when modal closes + // Reset hasEndDateChanged and hasClickedDateTimeSection when modal closes React.useEffect(() => { if (!isOpen) { setHasEndDateChanged(false); + setHasClickedDateTimeSection(false); + setHasClickedLocationSection(false); + setHasClickedCalendarSection(false); + setHasClickedVideoMeetingSection(false); } }, [isOpen]); @@ -255,6 +278,7 @@ export default function EventFormFields({ prevShowMoreRef.current = showMore; }, [showMore, isOpen]); + // Sync start prop to startDate/startTime React.useEffect(() => { if (start) { @@ -389,6 +413,12 @@ export default function EventFormFields({ setDescription(updatedDescription); setHasVideoConference(true); setMeetingLink(newMeetingLink); + // Open description field when adding video conference + setShowDescription(true); + // Track click on video meeting section in normal mode + if (!showMore) { + setHasClickedVideoMeetingSection(true); + } }; const [openToast, setOpenToast] = React.useState(false); @@ -421,7 +451,10 @@ export default function EventFormFields({ return ( <> - + + + {!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={240} + size="small" + placeholder={t("event.form.timezonePlaceholder")} + /> + + + )} + + {!(!showMore && !hasClickedDateTimeSection) && + (showRepeat || (typeOfAction === "solo" && repetition?.freq)) && ( + + + + )} + + + } + /> + + + .MuiBox-root": { + marginTop: "0 !important", + }, + } + : undefined + } + > + {!showMore ? ( + + ) : ( + + + + {hasVideoConference && meetingLink && ( + <> + + + + + + + + + )} + + )} + + - - {}} - /> + + {!showMore && !hasClickedLocationSection ? ( + + ) : ( + setLocation(e.target.value)} + size="small" + margin="dense" + /> + )} - - - - } - 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); - } + + {!showMore && !hasClickedCalendarSection ? ( + - - {hasVideoConference && meetingLink && ( - <> - - - - - - - - - )} - - - - - setLocation(e.target.value)} - size="small" - margin="dense" - /> - - - - - - + ) : ( + + + + )} {showMore && ( diff --git a/src/components/Event/components/DateTimeSummary.tsx b/src/components/Event/components/DateTimeSummary.tsx new file mode 100644 index 0000000..7478871 --- /dev/null +++ b/src/components/Event/components/DateTimeSummary.tsx @@ -0,0 +1,161 @@ +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 React from "react"; +import { useI18n } from "twake-i18n"; +import { getTimezoneOffset } from "../../Calendar/TimezoneSelector"; +import { RepetitionObject } from "@/features/Events/EventsTypes"; +import { LONG_DATE_FORMAT } from "../utils/dateTimeFormatters"; +import { toDateTime } from "../utils/dateTimeHelpers"; + +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: "Wednesday, January 28, 2026" + const formatDate = (dateStr: string): string => { + if (!dateStr) return ""; + const date = dayjs(dateStr); + return date.format(LONG_DATE_FORMAT); + }; + + // Format time: "1:00pm - 2:00pm" + const formatTime = (startTimeStr: string, endTimeStr: string): string => { + if (allday || !startTimeStr || !endTimeStr) return ""; + + const format12Hour = (timeStr: string): string => { + const [hours, minutes] = timeStr.split(":").map(Number); + const hour12 = hours % 12 || 12; + const ampm = hours >= 12 ? "pm" : "am"; + const mins = minutes === 0 ? "" : `:${minutes.toString().padStart(2, "0")}`; + return `${hour12}${mins}${ampm}`; + }; + + const start = format12Hour(startTimeStr); + const end = format12Hour(endTimeStr); + return `${start} - ${end}`; + }; + + // Format timezone: "(UTC+2) Paris" + const formatTimezone = (tz: string): string => { + if (!tz) return ""; + try { + const offset = getTimezoneOffset(tz, new Date()); + 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 "Doesn't repeat"; + } + + 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 `Every ${freqText}`; + } + + return `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); + const repeatText = formatRepeat(repetition); + + // Don't render if no date + if (!startDate) { + return null; + } + + return ( + + + + + + {dateText} + + {timeText && ( + + {timeText} + + )} + + + + {timezoneText} + + + {repeatText} + + + + + ); +};