update normal mode event createa update modal

This commit is contained in:
lenhanphung
2026-01-26 17:49:48 +07:00
parent 1d6a597c46
commit da0dba423e
8 changed files with 579 additions and 208 deletions
+50 -4
View File
@@ -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.7",
"@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",
@@ -83,6 +83,52 @@
"node": "24.x" "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": { "node_modules/@adobe/css-tools": {
"version": "4.4.4", "version": "4.4.4",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
@@ -3136,9 +3182,9 @@
} }
}, },
"node_modules/@linagora/twake-mui": { "node_modules/@linagora/twake-mui": {
"version": "1.1.4", "version": "1.1.7",
"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.7/dd45fa10aeb5c7e6b6e89f59ff9d88c7d2f7e507",
"integrity": "sha512-L6e+6KgqFUxFY6avmk4UG4/bsdfzo6TxBkahUZlKxvCNEEikVe8z2Cxd0JtUUAWrPGZonTII9n2KhdFSNmNxvg==", "integrity": "sha512-9ZRCeWUWObbIk9h2jg3N0fZlJ+QRBouXzhMNMC4rjQBgFvnCZxZHiMt6kGXQmOov8JsHFKSRhunwfNBYy1cv8Q==",
"dependencies": { "dependencies": {
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0" "react-dom": "^18.2.0"
+1 -1
View File
@@ -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.7",
"@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",
+4 -1
View File
@@ -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) =>
+4 -4
View File
@@ -4,7 +4,7 @@ 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 (90px 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
@@ -62,7 +62,7 @@ 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` | `"90px"` | 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) |
@@ -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
+5 -4
View File
@@ -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,7 +98,7 @@ function ResponsiveDialog({
actions, actions,
isExpanded = false, isExpanded = false,
onExpandToggle, onExpandToggle,
normalMaxWidth = "685px", normalMaxWidth = "570px",
expandedContentMaxWidth = "990px", expandedContentMaxWidth = "990px",
headerHeight = "90px", headerHeight = "90px",
normalSpacing = 2, normalSpacing = 2,
@@ -202,8 +202,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">
+29 -9
View File
@@ -1,5 +1,6 @@
import { Box, Button, TextField } from "@linagora/twake-mui"; import { Box, Button, TextField, useTheme } from "@linagora/twake-mui";
import { Description as DescriptionIcon } from "@mui/icons-material"; import { alpha } from "@mui/material/styles";
import { Notes as NotesIcon } from "@mui/icons-material";
import { useI18n } from "twake-i18n"; import { useI18n } from "twake-i18n";
import { FieldWithLabel } from "./components/FieldWithLabel"; import { FieldWithLabel } from "./components/FieldWithLabel";
@@ -28,21 +29,40 @@ export function AddDescButton({
| "warning"; | "warning";
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const theme = useTheme();
return ( return (
<> <>
{!showDescription && ( {!showDescription && (
<FieldWithLabel label=" " isExpanded={showMore}> <FieldWithLabel
<Box display="flex" gap={1}> label={showMore || showDescription ? t("event.form.description") : ""}
isExpanded={showMore}
>
{!showMore ? (
<Button <Button
startIcon={<DescriptionIcon />} startIcon={<NotesIcon />}
onClick={() => setShowDescription(true)} onClick={() => setShowDescription(true)}
size="medium" fullWidth
variant={buttonVariant} sx={{
color={buttonColor} justifyContent: "flex-start",
borderRadius: "4px",
color: alpha(theme.palette.grey[900], 0.9),
}}
> >
{t("event.form.addDescription")} {t("event.form.addDescription")}
</Button> </Button>
</Box> ) : (
<Box display="flex" gap={1}>
<Button
startIcon={<NotesIcon />}
onClick={() => setShowDescription(true)}
size="medium"
variant={buttonVariant}
color={buttonColor}
>
{t("event.form.addDescription")}
</Button>
</Box>
)}
</FieldWithLabel> </FieldWithLabel>
)} )}
{showDescription && ( {showDescription && (
+325 -185
View File
@@ -20,12 +20,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,6 +39,7 @@ 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 RepeatEvent from "./EventRepeat"; import RepeatEvent from "./EventRepeat";
import { useAllDayToggle } from "./hooks/useAllDayToggle"; import { useAllDayToggle } from "./hooks/useAllDayToggle";
@@ -156,6 +161,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 +172,27 @@ 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);
// Track if user has clicked on video meeting section in normal mode
const [hasClickedVideoMeetingSection, setHasClickedVideoMeetingSection] = 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);
setHasClickedVideoMeetingSection(false);
} }
}, [isOpen]); }, [isOpen]);
@@ -255,6 +278,7 @@ export default function EventFormFields({
prevShowMoreRef.current = showMore; prevShowMoreRef.current = showMore;
}, [showMore, isOpen]); }, [showMore, isOpen]);
// Sync start prop to startDate/startTime // Sync start prop to startDate/startTime
React.useEffect(() => { React.useEffect(() => {
if (start) { if (start) {
@@ -389,6 +413,12 @@ export default function EventFormFields({
setDescription(updatedDescription); setDescription(updatedDescription);
setHasVideoConference(true); setHasVideoConference(true);
setMeetingLink(newMeetingLink); 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); const [openToast, setOpenToast] = React.useState(false);
@@ -421,7 +451,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,6 +470,224 @@ export default function EventFormFields({
/> />
</FieldWithLabel> </FieldWithLabel>
<FieldWithLabel
label={
!showMore && !hasClickedDateTimeSection ? "" : t("event.form.dateTime")
}
isExpanded={showMore}
>
{!showMore && !hasClickedDateTimeSection ? (
<DateTimeSummary
startDate={startDate}
startTime={startTime}
endDate={endDate}
endTime={endTime}
allday={allday}
timezone={timezone}
repetition={repetition}
showEndDate={
allday ||
(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={240}
size="small"
placeholder={t("event.form.timezonePlaceholder")}
/>
</Box>
</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 label="" isExpanded={showMore}>
<AttendeeSelector
attendees={attendees}
setAttendees={setAttendees}
placeholder="Add guests"
inputSlot={(params) => <TextField {...params} size="small" />}
/>
</FieldWithLabel>
<FieldWithLabel
label={showMore ? t("event.form.videoMeeting") : ""}
isExpanded={showMore}
sx={
hasClickedVideoMeetingSection && !showMore
? {
marginTop: "0 !important",
"& > .MuiBox-root": {
marginTop: "0 !important",
},
}
: undefined
}
>
{!showMore ? (
<Button
startIcon={
<img src={iconCamera} alt="camera" width={24} height={24} />
}
onClick={handleAddVideoConference}
fullWidth
sx={{
justifyContent: "flex-start",
display: hasVideoConference ? "none" : "flex",
borderRadius: "4px",
color: alpha(theme.palette.grey[900], 0.9),
}}
>
{t("event.form.addVisioConference")}
</Button>
) : (
<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")}
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>
<AddDescButton <AddDescButton
showDescription={showDescription} showDescription={showDescription}
setShowDescription={setShowDescription} setShowDescription={setShowDescription}
@@ -447,199 +698,88 @@ export default function EventFormFields({
buttonColor="secondary" buttonColor="secondary"
/> />
<FieldWithLabel label={t("event.form.dateTime")} isExpanded={showMore}> <FieldWithLabel
<DateTimeFields label={
startDate={startDate} showMore || hasClickedLocationSection
startTime={startTime} ? t("event.form.location")
endDate={endDate} : ""
endTime={endTime} }
allday={allday} isExpanded={showMore}
showMore={showMore} >
hasEndDateChanged={hasEndDateChanged} {!showMore && !hasClickedLocationSection ? (
validation={validation} <Button
onStartDateChange={handleStartDateChange} startIcon={<LocationIcon />}
onStartTimeChange={handleStartTimeChange} onClick={() => setHasClickedLocationSection(true)}
onEndDateChange={handleEndDateChange} fullWidth
onEndTimeChange={handleEndTimeChange} sx={{
showEndDate={ justifyContent: "flex-start",
showMore || borderRadius: "4px",
allday || color: alpha(theme.palette.grey[900], 0.9),
(hasEndDateChanged && startDate !== endDate) || }}
(!showMore && !allday && startDate !== endDate) >
} {location || t("event.form.locationPlaceholder")}
onToggleEndDate={() => {}} </Button>
/> ) : (
<TextField
fullWidth
label=""
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>
<FieldWithLabel label=" " isExpanded={showMore}> <FieldWithLabel
<Box display="flex" gap={2} alignItems="center"> label={
<FormControlLabel showMore || hasClickedCalendarSection
control={ ? t("event.form.calendar")
<Checkbox checked={allday} onChange={handleAllDayToggle} /> : ""
} }
label={ isExpanded={showMore}
<Typography variant="h6">{t("event.form.allDay")}</Typography> >
} {!showMore && !hasClickedCalendarSection ? (
/> <Button
<FormControlLabel startIcon={
control={ <SquareRoundedIcon
<Checkbox sx={{
checked={ color:
showRepeat || (typeOfAction === "solo" && !!repetition?.freq) userPersonalCalendars.find((cal) => cal.id === calendarid)
} ?.color?.light ?? "#3788D8",
disabled={typeOfAction === "solo"} width: 24,
onChange={() => { height: 24,
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={ onClick={() => setHasClickedCalendarSection(true)}
<Typography variant="h6">{t("event.form.repeat")}</Typography> fullWidth
}
/>
<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}
eventStart={new Date(start)}
setRepetition={setRepetition}
isOwn={typeOfAction !== "solo"}
/>
</FieldWithLabel>
)}
<FieldWithLabel
label={t("event.form.participants")}
isExpanded={showMore}
>
<AttendeeSelector
attendees={attendees}
setAttendees={setAttendees}
inputSlot={(params) => <TextField {...params} size="small" />}
/>
</FieldWithLabel>
<FieldWithLabel
label={t("event.form.videoMeeting")}
isExpanded={showMore}
>
<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={{ sx={{
justifyContent: "flex-start",
borderRadius: "4px", borderRadius: "4px",
display: hasVideoConference ? "none" : "flex", color: alpha(theme.palette.grey[900], 0.9),
}} }}
> >
{t("event.form.addVisioConference")} {userPersonalCalendars.find((cal) => cal.id === calendarid)?.name ||
t("event.form.calendar")}
</Button> </Button>
) : (
{hasVideoConference && meetingLink && ( <FormControl fullWidth margin="dense" size="small">
<> <Select
<Button value={calendarid ?? ""}
startIcon={ label=""
<img src={iconCamera} alt="camera" width={24} height={24} /> SelectDisplayProps={{ "aria-label": t("event.form.calendar") }}
} displayEmpty
onClick={() => window.open(meetingLink, "_blank")} onChange={(e: SelectChangeEvent) =>
size="medium" handleCalendarChange(e.target.value)
variant="contained" }
color="primary" >
sx={{ {CalendarItemList(userPersonalCalendars)}
borderRadius: "4px", </Select>
mr: 1, </FormControl>
}} )}
>
{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 label={t("event.form.location")} isExpanded={showMore}>
<TextField
fullWidth
label=""
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={t("event.form.calendar")} isExpanded={showMore}>
<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 && (
@@ -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<DateTimeSummaryProps> = ({
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 (
<Box
onClick={onClick}
sx={{
display: "flex",
alignItems: "flex-start",
gap: 1.5,
cursor: "pointer",
padding: 1,
borderRadius: 1,
"&:hover": {
backgroundColor: "action.hover",
},
}}
>
<AccessTimeIcon sx={{ color: "text.secondary", mt: 0.5 }} />
<Box flex={1}>
<Box display="flex" gap={2} alignItems="center" mb={0.5}>
<Typography
variant="body2"
sx={{ color: alpha(theme.palette.grey[900], 0.9), fontWeight: 500 }}
>
{dateText}
</Typography>
{timeText && (
<Typography
variant="body2"
sx={{ color: alpha(theme.palette.grey[900], 0.9), fontWeight: 500 }}
>
{timeText}
</Typography>
)}
</Box>
<Box display="flex" gap={2} alignItems="center">
<Typography variant="caption" sx={{ color: "#444746" }}>
{timezoneText}
</Typography>
<Typography variant="caption" sx={{ color: "#444746" }}>
{repeatText}
</Typography>
</Box>
</Box>
</Box>
);
};