#708 apply strictier linting rules (#717)

* #708 apply strictier linting rules and fix simple eslint bugs

* #708 fix eslint errors relate to promise

* #708 fix eslint import/no-extraneous-dependencies

* #708 fix eslint errors of react-hook

* #708 enable eslint check for typescript

---------

Co-authored-by: lethemanh <lethemanh@lethemanhs-MacBook-Pro.local>
This commit is contained in:
lethemanh
2026-04-01 22:15:10 +07:00
committed by GitHub
parent 2bff6aae78
commit cadfa70e60
321 changed files with 27452 additions and 27600 deletions
+31 -31
View File
@@ -1,35 +1,35 @@
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";
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,
setShowDescription,
showMore,
description,
setDescription,
setDescription
}: {
showDescription: boolean;
setShowDescription: (b: boolean) => void;
showMore: boolean;
description: string;
setDescription: (d: string) => void;
showDescription: boolean
setShowDescription: (b: boolean) => void
showMore: boolean
description: string
setDescription: (d: string) => void
}) {
const { t } = useI18n();
const descriptionInputRef = React.useRef<HTMLTextAreaElement>(null);
const { t } = useI18n()
const descriptionInputRef = React.useRef<HTMLTextAreaElement>(null)
React.useEffect(() => {
if (showDescription) {
descriptionInputRef.current?.focus();
descriptionInputRef.current?.focus()
}
}, [showDescription]);
}, [showDescription])
const descriptionField = (
<FieldWithLabel
label={t("event.form.description")}
label={t('event.form.description')}
isExpanded={showMore}
sx={{ padding: 0, margin: 0 }}
>
@@ -37,31 +37,31 @@ export function AddDescButton({
fullWidth
label=""
inputRef={descriptionInputRef}
inputProps={{ "aria-label": t("event.form.description") }}
placeholder={t("event.form.descriptionPlaceholder")}
inputProps={{ 'aria-label': t('event.form.description') }}
placeholder={t('event.form.descriptionPlaceholder')}
value={description}
onChange={(e) => setDescription(e.target.value)}
onChange={e => setDescription(e.target.value)}
size="small"
margin="dense"
multiline
minRows={2}
maxRows={10}
sx={{
"& .MuiInputBase-root": {
maxHeight: "33%",
overflowY: "auto",
padding: 0,
},
"& textarea": {
resize: "vertical",
'& .MuiInputBase-root': {
maxHeight: '33%',
overflowY: 'auto',
padding: 0
},
'& textarea': {
resize: 'vertical'
}
}}
/>
</FieldWithLabel>
);
)
if (showMore) {
return descriptionField;
return descriptionField
}
return (
@@ -72,11 +72,11 @@ export function AddDescButton({
icon={<NotesIcon />}
onClick={() => setShowDescription(true)}
>
{t("event.form.addDescription")}
{t('event.form.addDescription')}
</SectionPreviewRow>
</FieldWithLabel>
)}
{showDescription && descriptionField}
</>
);
)
}
+29 -29
View File
@@ -7,64 +7,64 @@ import {
DialogTitle,
FormControlLabel,
Radio,
RadioGroup,
} from "@linagora/twake-mui";
import { useState } from "react";
import { useI18n } from "twake-i18n";
RadioGroup
} from '@linagora/twake-mui'
import { useState } from 'react'
import { useI18n } from 'twake-i18n'
export function EditModeDialog({
type,
setOpen,
eventAction,
eventAction
}: {
type: string | null;
setOpen: (e: string | null) => void;
eventAction: (type: "solo" | "all" | undefined) => void;
type: string | null
setOpen: (e: string | null) => void
eventAction: (type: 'solo' | 'all' | undefined) => void
}) {
const { t } = useI18n();
const [typeOfAction, setTypeOfAction] = useState<"solo" | "all" | undefined>(
"solo"
);
const handleEvent = async () => {
eventAction(typeOfAction);
handleClose();
};
const { t } = useI18n()
const [typeOfAction, setTypeOfAction] = useState<'solo' | 'all' | undefined>(
'solo'
)
const handleEvent = () => {
eventAction(typeOfAction)
handleClose()
}
const handleClose = () => {
setOpen(null);
setTypeOfAction("solo");
};
setOpen(null)
setTypeOfAction('solo')
}
return (
<Dialog open={Boolean(type)} onClose={handleClose}>
<DialogTitle>
{type === "edit" && t("editModeDialog.updateRecurrentEvent")}
{type === "delete" && t("editModeDialog.deleteRecurrentEvent")}
{type === "attendance" && t("editModeDialog.updateParticipationStatus")}
{type === 'edit' && t('editModeDialog.updateRecurrentEvent')}
{type === 'delete' && t('editModeDialog.deleteRecurrentEvent')}
{type === 'attendance' && t('editModeDialog.updateParticipationStatus')}
</DialogTitle>
<DialogContent>
<RadioGroup
value={typeOfAction}
onChange={(e) =>
setTypeOfAction(e.target.value as "solo" | "all" | undefined)
onChange={e =>
setTypeOfAction(e.target.value as 'solo' | 'all' | undefined)
}
>
<FormControlLabel
value="solo"
control={<Radio />}
label={t("editModeDialog.thisEvent")}
label={t('editModeDialog.thisEvent')}
/>
<FormControlLabel
value="all"
control={<Radio />}
label={t("editModeDialog.allEvents")}
label={t('editModeDialog.allEvents')}
/>
</RadioGroup>
</DialogContent>
<DialogActions>
<ButtonGroup>
<Button onClick={handleClose}>{t("common.cancel")}</Button>
<Button onClick={handleEvent}>{t("common.ok")}</Button>
<Button onClick={handleClose}>{t('common.cancel')}</Button>
<Button onClick={handleEvent}>{t('common.ok')}</Button>
</ButtonGroup>
</DialogActions>
</Dialog>
);
)
}
@@ -1,44 +1,44 @@
import { EventErrorHandler } from "@/components/Error/EventErrorHandler";
import { EventContentArg } from "@fullcalendar/core";
import { Card, Typography } from "@linagora/twake-mui";
import { useEffect, useRef } from "react";
import { EventErrorHandler } from '@/components/Error/EventErrorHandler'
import { EventContentArg } from '@fullcalendar/core'
import { Card, Typography } from '@linagora/twake-mui'
import { useEffect, useRef } from 'react'
export function ErrorEventChip({
event,
errorHandler,
error,
error
}: {
event: EventContentArg["event"];
errorHandler: EventErrorHandler;
error: unknown;
event: EventContentArg['event']
errorHandler: EventErrorHandler
error: unknown
}) {
const hasReported = useRef(false);
const hasReported = useRef(false)
useEffect(() => {
if (!hasReported.current) {
let message: string;
let message: string
if (error instanceof Error) {
message = error.message;
message = error.message
} else if (
typeof error === "object" &&
typeof error === 'object' &&
error !== null &&
"class" in error
'class' in error
) {
message = `${String(
(error as { class: string }).class
)} error during rendering ${event._def.extendedProps.uid || event.id}`;
)} error during rendering ${event._def.extendedProps.uid || event.id}`
} else {
message = `Unknown error during rendering ${event._def.extendedProps.uid || event.id}`;
message = `Unknown error during rendering ${event._def.extendedProps.uid || event.id}`
}
errorHandler.reportError(
event._def.extendedProps.uid || event.id,
message
);
hasReported.current = true;
)
hasReported.current = true
}
}, [event, errorHandler, error]);
}, [event, errorHandler, error])
return (
<Card
@@ -46,11 +46,11 @@ export function ErrorEventChip({
sx={{
px: 0.5,
py: 0.2,
borderRadius: "4px",
boxShadow: "none",
borderRadius: '4px',
boxShadow: 'none'
}}
>
<Typography variant="body2">{event.title}</Typography>
</Card>
);
)
}
+100 -104
View File
@@ -4,11 +4,11 @@ import {
Card,
CardContent,
CardHeader,
Typography,
} from "@linagora/twake-mui";
import { useRef } from "react";
import { stringAvatar } from "../utils/eventUtils";
import { ErrorEventChip } from "./ErrorEventChip";
Typography
} from '@linagora/twake-mui'
import { useRef } from 'react'
import { stringAvatar } from '../utils/eventUtils'
import { ErrorEventChip } from './ErrorEventChip'
import {
DisplayedIcons,
EventChipProps,
@@ -19,76 +19,71 @@ import {
getOwnerAttendee,
getTitleStyle,
IconDisplayConfig,
useCompactMode,
} from "./EventChipUtils";
useCompactMode
} from './EventChipUtils'
const PRIVATE_CLASSIFICATIONS = ["PRIVATE", "CONFIDENTIAL"];
const PRIVATE_CLASSIFICATIONS = ['PRIVATE', 'CONFIDENTIAL']
export const EVENT_DURATION = {
SHORT: 15,
MEDIUM: 30,
LONG: 60,
} as const;
LONG: 60
} as const
export function EventChip({
arg,
calendars,
tempcalendars,
errorHandler,
errorHandler
}: EventChipProps) {
const cardRef = useRef<HTMLDivElement>(null);
const showCompact = useCompactMode(cardRef);
const cardRef = useRef<HTMLDivElement>(null)
const showCompact = useCompactMode(cardRef)
const event = arg.event;
const props = event._def.extendedProps;
const {
calId,
temp,
attendee: attendees = [],
class: classification,
} = props;
const event = arg.event
const props = event._def.extendedProps
const { calId, temp, attendee: attendees = [], class: classification } = props
try {
// Calendar validation
const calendarsSource = temp ? tempcalendars : calendars;
const calendar = calendarsSource[calId];
if (!calendar) return null;
const calendarsSource = temp ? tempcalendars : calendars
const calendar = calendarsSource[calId]
if (!calendar) return null
// Event properties
const isPrivate = PRIVATE_CLASSIFICATIONS.includes(classification);
const isPrivate = PRIVATE_CLASSIFICATIONS.includes(classification)
const ownerEmails = new Set(
calendar.owner?.emails?.map((e) => e.toLowerCase())
);
calendar.owner?.emails?.map(e => e.toLowerCase())
)
// const delegated = calendar.delegated;
// Determine owner attendee
const ownerAttendee = getOwnerAttendee(attendees, ownerEmails);
const ownerAttendee = getOwnerAttendee(attendees, ownerEmails)
// Color and contrast logic
const bestColor = getBestColor(
(calendar.color as { light: string; dark: string }) ?? {
light: "#fff",
dark: "#000",
light: '#fff',
dark: '#000'
}
);
)
// Icon display configuration
const IconDisplayed: IconDisplayConfig = {
declined: ownerAttendee?.partstat === "DECLINED",
tentative: ownerAttendee?.partstat === "TENTATIVE",
needAction: ownerAttendee?.partstat === "NEEDS-ACTION",
private: isPrivate,
};
declined: ownerAttendee?.partstat === 'DECLINED',
tentative: ownerAttendee?.partstat === 'TENTATIVE',
needAction: ownerAttendee?.partstat === 'NEEDS-ACTION',
private: isPrivate
}
// View and time calculations
const isMonthView = arg.view.type === "dayGridMonth";
const timeZone = arg.view.calendar?.getOption("timeZone") || "UTC";
const { startTime, endTime } = getEventTimes(event, timeZone);
const eventLength = getEventDuration(event);
const isMonthView = arg.view.type === 'dayGridMonth'
const timeZone = arg.view.calendar?.getOption('timeZone') || 'UTC'
const { startTime, endTime } = getEventTimes(event, timeZone)
const eventLength = getEventDuration(event)
const isMoreThan15 = eventLength > EVENT_DURATION.SHORT;
const isMoreThan30 = eventLength > EVENT_DURATION.MEDIUM;
const isMoreThan60 = eventLength > EVENT_DURATION.LONG;
const isMoreThan15 = eventLength > EVENT_DURATION.SHORT
const isMoreThan30 = eventLength > EVENT_DURATION.MEDIUM
const isMoreThan60 = eventLength > EVENT_DURATION.LONG
// Style calculation
const titleStyle = getTitleStyle(
@@ -96,7 +91,7 @@ export function EventChip({
ownerAttendee?.partstat,
calendar,
isPrivate
);
)
const cardStyle = getCardStyle(
bestColor,
@@ -104,7 +99,7 @@ export function EventChip({
ownerAttendee?.partstat,
calendar,
isPrivate
);
)
// Organizer avatar
const OrganizerAvatar = event._def.extendedProps.organizer
@@ -112,28 +107,28 @@ export function EventChip({
event._def.extendedProps.organizer?.cn ??
event._def.extendedProps.organizer?.cal_address
)
: { color: undefined, children: null };
: { color: undefined, children: null }
return (
<Card
variant="outlined"
style={{
...cardStyle,
...(!isMoreThan15 || isMonthView ? { height: "auto" } : {}),
...(!isMoreThan15 || isMonthView ? { height: 'auto' } : {}),
...(!isMoreThan15 && !isMonthView
? { transform: "translateY(2px)" }
: {}),
? { transform: 'translateY(2px)' }
: {})
}}
ref={cardRef}
data-testid={`event-card-${event._def.extendedProps.uid}`}
>
<CardHeader
sx={{
py: "0px",
px: "0px",
"& .MuiCardHeader-content": {
overflow: "hidden",
},
py: '0px',
px: '0px',
'& .MuiCardHeader-content': {
overflow: 'hidden'
}
}}
title={
showCompact ? (
@@ -143,14 +138,14 @@ export function EventChip({
) : (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%'
}}
>
<Box
sx={{ display: "flex", alignItems: "center", minWidth: 0 }}
sx={{ display: 'flex', alignItems: 'center', minWidth: 0 }}
>
{(!isMoreThan30 || isMonthView) &&
!event._def.extendedProps.allday && (
@@ -159,12 +154,12 @@ export function EventChip({
className="compactStartTime"
style={{
...titleStyle,
textDecoration: "none",
overflow: "visible",
opacity: "70%",
letterSpacing: "0%",
fontSize: "10px",
marginRight: "4px",
textDecoration: 'none',
overflow: 'visible',
opacity: '70%',
letterSpacing: '0%',
fontSize: '10px',
marginRight: '4px'
}}
>
{startTime}
@@ -185,13 +180,13 @@ export function EventChip({
<Typography
style={{
color: titleStyle.color,
opacity: "70%",
fontFamily: "Inter",
fontWeight: "500",
fontSize: "10px",
lineHeight: "16px",
letterSpacing: "0%",
verticalAlign: "middle",
opacity: '70%',
fontFamily: 'Inter',
fontWeight: '500',
fontSize: '10px',
lineHeight: '16px',
letterSpacing: '0%',
verticalAlign: 'middle'
}}
>
{startTime} {!showCompact && ` - ${endTime}`}
@@ -206,50 +201,50 @@ export function EventChip({
<CardContent
sx={{
p: 0,
"& .MuiCardContent-content": {
overflow: "hidden",
},
'& .MuiCardContent-content': {
overflow: 'hidden'
}
}}
>
{event._def.extendedProps.location && (
<Typography
style={{
marginRight: 2,
fontFamily: "Roboto",
fontWeight: "500",
fontStyle: "Medium",
fontSize: "12px",
lineHeight: "16px",
letterSpacing: "0%",
verticalAlign: "middle",
fontFamily: 'Roboto',
fontWeight: '500',
fontStyle: 'Medium',
fontSize: '12px',
lineHeight: '16px',
letterSpacing: '0%',
verticalAlign: 'middle',
color: titleStyle.color,
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap'
}}
>
{event._def.extendedProps.location}
</Typography>
)}
<Box sx={{ display: "flex", alignItems: "flex-start", mt: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', mt: 0.5 }}>
{event._def.extendedProps.description && (
<Typography
sx={{
fontFamily: "Roboto",
fontFamily: 'Roboto',
fontWeight: 500,
fontSize: "10px",
lineHeight: "16px",
letterSpacing: "0%",
fontSize: '10px',
lineHeight: '16px',
letterSpacing: '0%',
opacity: 0.8,
overflow: "hidden",
textOverflow: "ellipsis",
display: "-webkit-box",
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
whiteSpace: "normal",
WebkitBoxOrient: 'vertical',
whiteSpace: 'normal',
flex: 1,
maxWidth: "75%",
color: titleStyle.color,
maxWidth: '75%',
color: titleStyle.color
}}
>
{event._def.extendedProps.description}
@@ -265,23 +260,24 @@ export function EventChip({
!showCompact &&
window.displayOrgAvatar && (
<Avatar
children={OrganizerAvatar.children}
color={OrganizerAvatar.color}
size="xs"
sx={{
bottom: 0,
right: 0,
margin: "8px",
position: "absolute",
border: "2px solid white",
margin: '8px',
position: 'absolute',
border: '2px solid white'
}}
/>
>
{OrganizerAvatar.children}
</Avatar>
)}
</Card>
);
)
} catch (e) {
return (
<ErrorEventChip event={event} errorHandler={errorHandler} error={e} />
);
)
}
}
+114 -116
View File
@@ -1,56 +1,54 @@
import { EventErrorHandler } from "@/components/Error/EventErrorHandler";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { userAttendee } from "@/features/User/models/attendee";
import { EventContentArg } from "@fullcalendar/core";
import { getContrastRatio } from "@linagora/twake-mui";
import CancelIcon from "@mui/icons-material/Cancel";
import HelpOutlineIcon from "@mui/icons-material/HelpOutline";
import LockOutlineIcon from "@mui/icons-material/LockOutline";
import moment from "moment";
import React, { useLayoutEffect, useState } from "react";
import { EVENT_DURATION } from "./EventChip";
import { EventErrorHandler } from '@/components/Error/EventErrorHandler'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { userAttendee } from '@/features/User/models/attendee'
import { EventContentArg } from '@fullcalendar/core'
import { getContrastRatio } from '@linagora/twake-mui'
import CancelIcon from '@mui/icons-material/Cancel'
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
import LockOutlineIcon from '@mui/icons-material/LockOutline'
import moment from 'moment'
import React, { useLayoutEffect, useState } from 'react'
import { EVENT_DURATION } from './EventChip'
const COMPACT_WIDTH_THRESHOLD = 100;
const COMPACT_WIDTH_THRESHOLD = 100
export interface EventChipProps {
arg: EventContentArg["event"];
calendars: Record<string, Calendar>;
tempcalendars: Record<string, Calendar>;
errorHandler: EventErrorHandler;
arg: EventContentArg['event']
calendars: Record<string, Calendar>
tempcalendars: Record<string, Calendar>
errorHandler: EventErrorHandler
}
export interface IconDisplayConfig {
declined: boolean;
tentative: boolean;
needAction: boolean;
private: boolean;
declined: boolean
tentative: boolean
needAction: boolean
private: boolean
}
export function getEventDuration(event: EventContentArg["event"]): number {
export function getEventDuration(event: EventContentArg['event']): number {
return moment(event._instance?.range.end).diff(
moment(event._instance?.range.start),
"minutes"
);
'minutes'
)
}
export function getBestColor(colors: { light: string; dark: string }): string {
const contrastToDark = getContrastRatio(colors?.dark, "#fff");
const contrastToLight = getContrastRatio(colors?.light, "#fff");
return contrastToDark > contrastToLight ? colors?.dark : colors?.light;
const contrastToDark = getContrastRatio(colors?.dark, '#fff')
const contrastToLight = getContrastRatio(colors?.light, '#fff')
return contrastToDark > contrastToLight ? colors?.dark : colors?.light
}
export function getEventTimes(
event: EventContentArg["event"],
event: EventContentArg['event'],
timeZone: string
) {
return {
startTime: moment.tz(event.start, timeZone).format("HH:mm"),
endTime: moment.tz(event.end, timeZone).format("HH:mm"),
};
startTime: moment.tz(event.start, timeZone).format('HH:mm'),
endTime: moment.tz(event.end, timeZone).format('HH:mm')
}
}
export function getOwnerAttendee(
attendees: userAttendee[],
ownerEmails: Set<string>
): userAttendee | undefined {
return attendees.find((att) =>
ownerEmails.has(att.cal_address.toLowerCase())
);
return attendees.find(att => ownerEmails.has(att.cal_address.toLowerCase()))
}
export function getTitleStyle(
@@ -60,41 +58,41 @@ export function getTitleStyle(
isPrivate?: boolean
): React.CSSProperties {
const baseStyle: React.CSSProperties = {
fontFamily: "Roboto",
fontWeight: "500",
fontStyle: "Medium",
fontSize: "12px",
lineHeight: "16px",
letterSpacing: "0.5px",
verticalAlign: "middle",
overflow: "hidden",
textOverflow: "ellipsis",
font: "Roboto",
whiteSpace: "nowrap",
color: bestColor,
};
fontFamily: 'Roboto',
fontWeight: '500',
fontStyle: 'Medium',
fontSize: '12px',
lineHeight: '16px',
letterSpacing: '0.5px',
verticalAlign: 'middle',
overflow: 'hidden',
textOverflow: 'ellipsis',
font: 'Roboto',
whiteSpace: 'nowrap',
color: bestColor
}
switch (partstat) {
case "DECLINED":
return { ...baseStyle, textDecoration: "line-through" };
case "TENTATIVE":
case "ACCEPTED":
return { ...baseStyle, color: calendar?.color?.dark };
case "NEEDS-ACTION":
return baseStyle;
case 'DECLINED':
return { ...baseStyle, textDecoration: 'line-through' }
case 'TENTATIVE':
case 'ACCEPTED':
return { ...baseStyle, color: calendar?.color?.dark }
case 'NEEDS-ACTION':
return baseStyle
default:
if (isPrivate) {
return { ...baseStyle, color: calendar?.color?.dark };
return { ...baseStyle, color: calendar?.color?.dark }
}
return baseStyle;
return baseStyle
}
}
function getEventVariant(duration: number) {
if (duration <= EVENT_DURATION.SHORT) return "short";
if (duration <= EVENT_DURATION.MEDIUM) return "medium";
if (duration <= EVENT_DURATION.LONG) return "long";
return "extraLong";
if (duration <= EVENT_DURATION.SHORT) return 'short'
if (duration <= EVENT_DURATION.MEDIUM) return 'medium'
if (duration <= EVENT_DURATION.LONG) return 'long'
return 'extraLong'
}
function getCardVariantStyle(
@@ -102,27 +100,27 @@ function getCardVariantStyle(
baseColor: string
): React.CSSProperties {
const shared: React.CSSProperties = {
width: "100%",
height: "100%",
borderRadius: "8px",
boxShadow: "none",
width: '100%',
height: '100%',
borderRadius: '8px',
boxShadow: 'none',
border: `1px solid ${baseColor}`,
color: baseColor,
display: "flex",
flexDirection: "column",
justifyContent: "flex-start",
};
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-start'
}
switch (variant) {
case "short":
return { ...shared, padding: "0px 6px", justifyContent: "center" };
case "medium":
return { ...shared, padding: "4px 6px", justifyContent: "center" };
case 'short':
return { ...shared, padding: '0px 6px', justifyContent: 'center' }
case 'medium':
return { ...shared, padding: '4px 6px', justifyContent: 'center' }
default:
return {
...shared,
padding: "4px 6px",
};
padding: '4px 6px'
}
}
}
@@ -136,40 +134,40 @@ export function getCardStyle(
const baseStyle: React.CSSProperties = getCardVariantStyle(
getEventVariant(eventLength),
bestColor
);
)
switch (partstat) {
case "DECLINED":
return baseStyle;
case "TENTATIVE":
case 'DECLINED':
return baseStyle
case 'TENTATIVE':
return {
...baseStyle,
backgroundColor: calendar?.color?.light,
color: calendar?.color?.dark,
border: "1px solid white",
};
case "NEEDS-ACTION":
border: '1px solid white'
}
case 'NEEDS-ACTION':
return {
...baseStyle,
backgroundColor: "#fff",
};
case "ACCEPTED":
backgroundColor: '#fff'
}
case 'ACCEPTED':
return {
...baseStyle,
backgroundColor: calendar?.color?.light,
color: calendar?.color?.dark,
border: "1px solid white",
};
border: '1px solid white'
}
default:
if (isPrivate) {
return {
...baseStyle,
backgroundColor: calendar?.color?.light,
color: calendar?.color?.dark,
border: "1px solid white",
};
border: '1px solid white'
}
}
return baseStyle;
return baseStyle
}
}
@@ -177,26 +175,26 @@ export function DisplayedIcons(
IconDisplayed: IconDisplayConfig,
iconColor?: string
) {
if (!Object.values(IconDisplayed).find((b) => b === true)) return;
if (!Object.values(IconDisplayed).find(b => b === true)) return
const iconStyle: React.CSSProperties = {
fontSize: "15px",
color: iconColor || "inherit",
marginRight: 2,
};
fontSize: '15px',
color: iconColor || 'inherit',
marginRight: 2
}
return (
<span
className="event-chip-icons"
style={{
display: "inline-flex",
flexDirection: "row",
gap: "1px",
color: iconColor || "inherit",
display: 'inline-flex',
flexDirection: 'row',
gap: '1px',
color: iconColor || 'inherit',
margin: 0,
marginRight: "4px",
fontFamily: "inherit",
fontWeight: "inherit",
lineHeight: "inherit",
letterSpacing: "inherit",
marginRight: '4px',
fontFamily: 'inherit',
fontWeight: 'inherit',
lineHeight: 'inherit',
letterSpacing: 'inherit'
}}
>
{IconDisplayed.needAction && <HelpOutlineIcon style={iconStyle} />}
@@ -204,34 +202,34 @@ export function DisplayedIcons(
{IconDisplayed.tentative && <HelpOutlineIcon style={iconStyle} />}
{IconDisplayed.private && <LockOutlineIcon style={iconStyle} />}
</span>
);
)
}
export function useCompactMode(
cardRef: React.RefObject<HTMLDivElement | null>
): boolean {
const [showCompact, setShowCompact] = useState(false);
const [showCompact, setShowCompact] = useState(false)
useLayoutEffect(() => {
if (!cardRef.current) return;
if (!cardRef.current) return
const checkWidth = () => {
const width = cardRef.current?.offsetWidth ?? 0;
const newCompact = width < COMPACT_WIDTH_THRESHOLD;
const width = cardRef.current?.offsetWidth ?? 0
const newCompact = width < COMPACT_WIDTH_THRESHOLD
setShowCompact((prev) => (prev !== newCompact ? newCompact : prev));
};
setShowCompact(prev => (prev !== newCompact ? newCompact : prev))
}
checkWidth();
checkWidth()
const resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(checkWidth);
});
requestAnimationFrame(checkWidth)
})
resizeObserver.observe(cardRef.current);
resizeObserver.observe(cardRef.current)
return () => resizeObserver.disconnect();
}, [cardRef]);
return () => resizeObserver.disconnect()
}, [cardRef])
return showCompact;
return showCompact
}
@@ -1,27 +1,27 @@
import { Card, Typography } from "@linagora/twake-mui";
import { Card, Typography } from '@linagora/twake-mui'
export function SimpleEventChip({ title }: { title: string }) {
return (
<Card
variant="outlined"
sx={{
borderRadius: "4px",
borderRadius: '4px',
px: 0.5,
py: 0.2,
boxShadow: "none",
boxShadow: 'none'
}}
>
<Typography
variant="body2"
sx={{
fontSize: "0.75rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontSize: '0.75rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{title}
</Typography>
</Card>
);
)
}
+8 -8
View File
@@ -1,19 +1,19 @@
import { MenuItem } from "@linagora/twake-mui";
import { useI18n } from "twake-i18n";
import { MenuItem } from '@linagora/twake-mui'
import { useI18n } from 'twake-i18n'
export default function EventDuplication({
onOpenDuplicate,
onOpenDuplicate
}: {
onOpenDuplicate?: () => void;
onOpenDuplicate?: () => void
}) {
const { t } = useI18n();
const { t } = useI18n()
return (
<MenuItem
onClick={() => {
onOpenDuplicate?.();
onOpenDuplicate?.()
}}
>
{t("eventDuplication.duplicateEvent")}
{t('eventDuplication.duplicateEvent')}
</MenuItem>
);
)
}
File diff suppressed because it is too large Load Diff
+105 -105
View File
@@ -1,4 +1,4 @@
import { RepetitionObject } from "@/features/Events/EventsTypes";
import { RepetitionObject } from '@/features/Events/EventsTypes'
import {
Box,
FormControl,
@@ -10,134 +10,134 @@ import {
SelectChangeEvent,
Stack,
TextField,
Typography,
} from "@linagora/twake-mui";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import dayjs from "dayjs";
import "dayjs/locale/en";
import "dayjs/locale/fr";
import "dayjs/locale/ru";
import "dayjs/locale/vi";
import { useI18n } from "twake-i18n";
import { ReadOnlyDateField } from "./components/ReadOnlyPickerField";
import { LONG_DATE_FORMAT } from "./utils/dateTimeFormatters";
import { FC_DAYS, WeekDaySelector } from "./WeekDaySelector";
Typography
} from '@linagora/twake-mui'
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
import { DatePicker } from '@mui/x-date-pickers/DatePicker'
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
import dayjs from 'dayjs'
import 'dayjs/locale/en'
import 'dayjs/locale/fr'
import 'dayjs/locale/ru'
import 'dayjs/locale/vi'
import { useI18n } from 'twake-i18n'
import { ReadOnlyDateField } from './components/ReadOnlyPickerField'
import { LONG_DATE_FORMAT } from './utils/dateTimeFormatters'
import { FC_DAYS, WeekDaySelector } from './WeekDaySelector'
export default function RepeatEvent({
repetition,
eventStart,
setRepetition,
isOwn = true,
isOwn = true
}: {
repetition: RepetitionObject;
eventStart: Date;
setRepetition: (repetition: RepetitionObject) => void;
isOwn?: boolean;
repetition: RepetitionObject
eventStart: Date
setRepetition: (repetition: RepetitionObject) => void
isOwn?: boolean
}) {
const { t } = useI18n();
const days = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
const day = new Date(eventStart);
const { t } = useI18n()
const days = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']
const day = new Date(eventStart)
const dateCalendarLayoutSx = {
"& .MuiDateCalendar-root.MuiDateCalendar-root": {
width: "260px",
maxWidth: "260px",
padding: "0 15px",
},
};
'& .MuiDateCalendar-root.MuiDateCalendar-root': {
width: '260px',
maxWidth: '260px',
padding: '0 15px'
}
}
// Fully derived — occurrences takes priority over endDate
const getEndOption = () => {
if (repetition.occurrences) return "after";
if (repetition.endDate) return "on";
return "never";
};
if (repetition.occurrences) return 'after'
if (repetition.endDate) return 'on'
return 'never'
}
const endOption = getEndOption();
const endOption = getEndOption()
const defaultEndDate = dayjs(eventStart).add(1, "day").format("YYYY-MM-DD");
const defaultEndDate = dayjs(eventStart).add(1, 'day').format('YYYY-MM-DD')
return (
<Box>
<Stack>
{/* Interval */}
<Box display="flex" alignItems="center" gap={2} mb={1}>
<Typography variant="h6">{t("event.repeat.every")}</Typography>
<Typography variant="h6">{t('event.repeat.every')}</Typography>
<TextField
type="number"
value={repetition.interval ?? 1}
disabled={!isOwn}
onChange={(e) =>
onChange={e =>
setRepetition({
...repetition,
interval: Number(e.target.value),
interval: Number(e.target.value)
})
}
size="small"
style={{ width: 80 }}
inputProps={{
min: 1,
"data-testid": "repeat-interval",
'data-testid': 'repeat-interval',
style: {
textAlign: "center",
paddingRight: 5,
},
textAlign: 'center',
paddingRight: 5
}
}}
/>
<FormControl size="small" style={{ minWidth: 120 }}>
<Select
value={repetition.freq ?? "daily"}
value={repetition.freq ?? 'daily'}
disabled={!isOwn}
onChange={(e: SelectChangeEvent) => {
if (e.target.value === "weekly") {
const jsDay = day.getDay();
const icsDay = days[(jsDay + 6) % 7];
if (e.target.value === 'weekly') {
const jsDay = day.getDay()
const icsDay = days[(jsDay + 6) % 7]
setRepetition({
...repetition,
freq: e.target.value,
byday: [icsDay],
});
byday: [icsDay]
})
} else {
setRepetition({
...repetition,
freq: e.target.value,
byday: null,
});
byday: null
})
}
}}
>
<MenuItem value={"daily"}>
{t("event.repeat.frequency.days")}
<MenuItem value="daily">
{t('event.repeat.frequency.days')}
</MenuItem>
<MenuItem value={"weekly"}>
{t("event.repeat.frequency.weeks")}
<MenuItem value="weekly">
{t('event.repeat.frequency.weeks')}
</MenuItem>
<MenuItem value={"monthly"}>
{t("event.repeat.frequency.months")}
<MenuItem value="monthly">
{t('event.repeat.frequency.months')}
</MenuItem>
<MenuItem value={"yearly"}>
{t("event.repeat.frequency.years")}
<MenuItem value="yearly">
{t('event.repeat.frequency.years')}
</MenuItem>
</Select>
</FormControl>
</Box>
{/* Weekly selection */}
{repetition.freq === "weekly" && (
{repetition.freq === 'weekly' && (
<Box mb={2}>
<WeekDaySelector
selectedDays={(repetition.byday ?? [])
.map((ics) => FC_DAYS.find((d) => d.ics === ics)?.fc ?? -1)
.filter((d) => d !== -1)}
onChange={(fcDays) => {
.map(ics => FC_DAYS.find(d => d.ics === ics)?.fc ?? -1)
.filter(d => d !== -1)}
onChange={fcDays => {
const icsDays = fcDays
.map((fc) => FC_DAYS.find((d) => d.fc === fc)?.ics ?? "")
.filter(Boolean);
.map(fc => FC_DAYS.find(d => d.fc === fc)?.ics ?? '')
.filter(Boolean)
setRepetition({
...repetition,
byday: icsDays.length > 0 ? icsDays : null,
});
byday: icsDays.length > 0 ? icsDays : null
})
}}
disabled={!isOwn}
/>
@@ -147,37 +147,37 @@ export default function RepeatEvent({
{/* End options */}
<Box>
<Typography variant="h6" gutterBottom>
{t("event.repeat.end.label")}
{t('event.repeat.end.label')}
</Typography>
<RadioGroup
value={endOption}
onChange={(e) => {
const value = e.target.value;
if (value === endOption) return;
onChange={e => {
const value = e.target.value
if (value === endOption) return
if (value === "never") {
if (value === 'never') {
setRepetition({
...repetition,
occurrences: null,
endDate: null,
});
endDate: null
})
}
if (value === "after") {
if (value === 'after') {
setRepetition({
...repetition,
occurrences:
repetition.occurrences && repetition.occurrences > 0
? repetition.occurrences
: 1,
endDate: null,
});
endDate: null
})
}
if (value === "on") {
if (value === 'on') {
setRepetition({
...repetition,
occurrences: null,
endDate: repetition.endDate || defaultEndDate,
});
endDate: repetition.endDate || defaultEndDate
})
}
}}
>
@@ -187,7 +187,7 @@ export default function RepeatEvent({
control={<Radio />}
label={
<Typography variant="h6">
{t("event.repeat.end.never")}
{t('event.repeat.end.never')}
</Typography>
}
/>
@@ -200,21 +200,21 @@ export default function RepeatEvent({
label={
<Box display="flex" alignItems="center" gap={1}>
<Typography variant="h6">
{t("event.repeat.end.on")}
{t('event.repeat.end.on')}
</Typography>
<LocalizationProvider
dateAdapter={AdapterDayjs}
adapterLocale={t("locale") ?? "en"}
adapterLocale={t('locale') ?? 'en'}
>
<Box
sx={{
width: 220,
"& .MuiInputBase-root": { fontSize: 14 },
"& .MuiInputBase-input": { fontSize: 14 },
'& .MuiInputBase-root': { fontSize: 14 },
'& .MuiInputBase-input': { fontSize: 14 }
}}
>
<DatePicker
sx={{ width: "100%" }}
sx={{ width: '100%' }}
format={LONG_DATE_FORMAT}
minDate={dayjs(eventStart)}
value={
@@ -222,27 +222,27 @@ export default function RepeatEvent({
? dayjs(repetition.endDate)
: dayjs(defaultEndDate)
}
onChange={(value) => {
if (!value || !value.isValid()) return;
const newDateStr = value.format("YYYY-MM-DD");
onChange={value => {
if (!value || !value.isValid()) return
const newDateStr = value.format('YYYY-MM-DD')
setRepetition({
...repetition,
occurrences: null,
endDate: newDateStr,
});
endDate: newDateStr
})
}}
onOpen={() => {
if (!isOwn || endOption === "on") return;
if (!isOwn || endOption === 'on') return
setRepetition({
...repetition,
occurrences: null,
endDate: repetition.endDate || defaultEndDate,
});
endDate: repetition.endDate || defaultEndDate
})
}}
slots={{ field: ReadOnlyDateField }}
slotProps={{
field: {},
layout: { sx: dateCalendarLayoutSx },
layout: { sx: dateCalendarLayoutSx }
}}
disabled={!isOwn}
/>
@@ -263,38 +263,38 @@ export default function RepeatEvent({
alignItems="center"
gap={1}
onClick={() => {
if (!isOwn || endOption === "after") return;
if (!isOwn || endOption === 'after') return
setRepetition({
...repetition,
endDate: null,
occurrences:
repetition.occurrences && repetition.occurrences > 0
? repetition.occurrences
: 1,
});
: 1
})
}}
>
<Typography variant="h6">
{t("event.repeat.end.after")}
{t('event.repeat.end.after')}
</Typography>
<TextField
type="number"
inputProps={{ min: 1, "data-testid": "occurrences-input" }}
inputProps={{ min: 1, 'data-testid': 'occurrences-input' }}
size="small"
value={repetition.occurrences || 1}
onChange={(e) => {
const value = Number(e.target.value);
onChange={e => {
const value = Number(e.target.value)
setRepetition({
...repetition,
endDate: null,
occurrences: value > 0 ? value : 1,
});
occurrences: value > 0 ? value : 1
})
}}
style={{ width: 100 }}
disabled={!isOwn}
/>
<Typography variant="h6">
{t("event.repeat.end.occurrences")}
{t('event.repeat.end.occurrences')}
</Typography>
</Box>
}
@@ -303,5 +303,5 @@ export default function RepeatEvent({
</Box>
</Stack>
</Box>
);
)
}
+34 -34
View File
@@ -1,23 +1,23 @@
import { Box, Link, Typography } from "@linagora/twake-mui";
import React from "react";
import { Box, Link, Typography } from '@linagora/twake-mui'
import React from 'react'
type InfoRowProps = {
icon: React.ReactNode;
text?: string;
error?: boolean;
data?: string; // optional link target
content?: React.ReactNode; // if provided, overrides text rendering
style?: React.CSSProperties;
alignItems?: React.CSSProperties["alignItems"];
flexWrap?: React.CSSProperties["flexWrap"];
};
icon: React.ReactNode
text?: string
error?: boolean
data?: string // optional link target
content?: React.ReactNode // if provided, overrides text rendering
style?: React.CSSProperties
alignItems?: React.CSSProperties['alignItems']
flexWrap?: React.CSSProperties['flexWrap']
}
function detectUrls(text: string) {
// Simple regex that captures whole URLs without splitting them apart
const urlRegex = /(https?:\/\/[^\s]+|www\.[^\s]+)/gi;
const urlRegex = /(https?:\/\/[^\s]+|www\.[^\s]+)/gi
const parts = [];
let lastIndex = 0;
const parts = []
let lastIndex = 0
text.replace(urlRegex, (match, _, offset) => {
// Push the text before the match
@@ -26,11 +26,11 @@ function detectUrls(text: string) {
<React.Fragment key={lastIndex}>
{text.slice(lastIndex, offset)}
</React.Fragment>
);
)
}
// Normalize href
const href = match.startsWith("http") ? match : `https://${match}`;
const href = match.startsWith('http') ? match : `https://${match}`
parts.push(
<Link
key={offset}
@@ -41,20 +41,20 @@ function detectUrls(text: string) {
>
{match}
</Link>
);
)
lastIndex = offset + match.length;
return match;
});
lastIndex = offset + match.length
return match
})
// Push remaining text after last URL
if (lastIndex < text.length) {
parts.push(
<React.Fragment key={lastIndex}>{text.slice(lastIndex)}</React.Fragment>
);
)
}
return parts;
return parts
}
export function InfoRow({
@@ -64,17 +64,17 @@ export function InfoRow({
data,
content,
style,
alignItems = "center",
flexWrap = "nowrap",
alignItems = 'center',
flexWrap = 'nowrap'
}: InfoRowProps) {
return (
<Box
style={{
display: "flex",
display: 'flex',
alignItems,
gap: 1,
marginBottom: 1,
flexWrap,
flexWrap
}}
>
{icon}
@@ -83,14 +83,14 @@ export function InfoRow({
) : (
<Typography
variant="body2"
color={error ? "error" : "textPrimary"}
color={error ? 'error' : 'textPrimary'}
sx={{
wordBreak: "break-word",
whiteSpace: "pre-line",
maxHeight: "33vh",
overflowY: "auto",
width: "100%",
...style,
wordBreak: 'break-word',
whiteSpace: 'pre-line',
maxHeight: '33vh',
overflowY: 'auto',
width: '100%',
...style
}}
>
{data ? (
@@ -108,5 +108,5 @@ export function InfoRow({
</Typography>
)}
</Box>
);
)
}
+48 -48
View File
@@ -1,55 +1,55 @@
import { Box } from "@linagora/twake-mui";
import { useI18n } from "twake-i18n";
import { Box } from '@linagora/twake-mui'
import { useI18n } from 'twake-i18n'
interface WeekDaySelectorProps {
selectedDays: number[]; // FullCalendar format: 0=Sun, 1=Mon...
onChange: (days: number[]) => void;
disabled?: boolean;
selectedDays: number[] // FullCalendar format: 0=Sun, 1=Mon...
onChange: (days: number[]) => void
disabled?: boolean
}
export const FC_DAYS = [
{ fc: 1, ics: "MO" },
{ fc: 2, ics: "TU" },
{ fc: 3, ics: "WE" },
{ fc: 4, ics: "TH" },
{ fc: 5, ics: "FR" },
{ fc: 6, ics: "SA" },
{ fc: 0, ics: "SU" },
];
{ fc: 1, ics: 'MO' },
{ fc: 2, ics: 'TU' },
{ fc: 3, ics: 'WE' },
{ fc: 4, ics: 'TH' },
{ fc: 5, ics: 'FR' },
{ fc: 6, ics: 'SA' },
{ fc: 0, ics: 'SU' }
]
export function WeekDaySelector({
selectedDays,
onChange,
disabled,
disabled
}: WeekDaySelectorProps) {
const { t } = useI18n();
const { t } = useI18n()
const getDayLabel = (ics: string) => {
const dayMap: Record<string, string> = {
MO: t("event.repeat.days.monday"),
TU: t("event.repeat.days.tuesday"),
WE: t("event.repeat.days.wednesday"),
TH: t("event.repeat.days.thursday"),
FR: t("event.repeat.days.friday"),
SA: t("event.repeat.days.saturday"),
SU: t("event.repeat.days.sunday"),
};
return dayMap[ics] || ics;
};
MO: t('event.repeat.days.monday'),
TU: t('event.repeat.days.tuesday'),
WE: t('event.repeat.days.wednesday'),
TH: t('event.repeat.days.thursday'),
FR: t('event.repeat.days.friday'),
SA: t('event.repeat.days.saturday'),
SU: t('event.repeat.days.sunday')
}
return dayMap[ics] || ics
}
const handleToggle = (fcDay: number) => {
if (disabled) return;
if (disabled) return
const updated = selectedDays.includes(fcDay)
? selectedDays.filter((d) => d !== fcDay)
: [...selectedDays, fcDay];
onChange(updated);
};
? selectedDays.filter(d => d !== fcDay)
: [...selectedDays, fcDay]
onChange(updated)
}
return (
<Box display="flex" gap={1}>
{FC_DAYS.map(({ fc, ics }) => {
const isSelected = selectedDays.includes(fc);
const fullLabel = getDayLabel(ics);
const isSelected = selectedDays.includes(fc)
const fullLabel = getDayLabel(ics)
return (
<Box
@@ -63,30 +63,30 @@ export function WeekDaySelector({
sx={{
width: 40,
height: 40,
borderRadius: "4px",
border: "1px solid",
borderColor: isSelected ? "primary.main" : "#AEAEC0",
color: isSelected ? "#fff" : "#8C9CAF",
borderRadius: '4px',
border: '1px solid',
borderColor: isSelected ? 'primary.main' : '#AEAEC0',
color: isSelected ? '#fff' : '#8C9CAF',
fontSize: 16,
fontWeight: 400,
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: isSelected ? "primary.main" : "transparent",
cursor: disabled ? "default" : "pointer",
"&:hover": !disabled
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: isSelected ? 'primary.main' : 'transparent',
cursor: disabled ? 'default' : 'pointer',
'&:hover': !disabled
? {
borderColor: "primary.main",
bgcolor: "primary.main",
color: "#fff",
borderColor: 'primary.main',
bgcolor: 'primary.main',
color: '#fff'
}
: undefined,
: undefined
}}
>
{fullLabel.charAt(0)}
</Box>
);
)
})}
</Box>
);
)
}
@@ -1,14 +1,14 @@
import { Box, Typography, useTheme } from "@linagora/twake-mui";
import { alpha } from "@mui/material/styles";
import React from "react";
import { Box, Typography, useTheme } from '@linagora/twake-mui'
import { alpha } from '@mui/material/styles'
import React from 'react'
interface ClickableFieldProps {
icon: React.ReactNode;
text?: string;
onClick: () => void;
iconColor?: string;
children?: React.ReactNode;
ariaLabel?: string;
icon: React.ReactNode
text?: string
onClick: () => void
iconColor?: string
children?: React.ReactNode
ariaLabel?: string
}
export const ClickableField: React.FC<ClickableFieldProps> = ({
@@ -17,9 +17,9 @@ export const ClickableField: React.FC<ClickableFieldProps> = ({
onClick,
iconColor,
children,
ariaLabel,
ariaLabel
}) => {
const theme = useTheme();
const theme = useTheme()
return (
<Box
@@ -27,44 +27,44 @@ export const ClickableField: React.FC<ClickableFieldProps> = ({
role="button"
tabIndex={0}
aria-label={ariaLabel ?? text}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onClick()
}
}}
sx={{
display: "flex",
alignItems: children ? "flex-start" : "center",
cursor: "pointer",
padding: "8px 12px",
borderRadius: "4px",
"&:hover": {
backgroundColor: "action.hover",
display: 'flex',
alignItems: children ? 'flex-start' : 'center',
cursor: 'pointer',
padding: '8px 12px',
borderRadius: '4px',
'&:hover': {
backgroundColor: 'action.hover'
},
"&:focus-visible": {
'&:focus-visible': {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: "2px",
},
outlineOffset: '2px'
}
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
maxWidth: "24px",
maxHeight: "24px",
marginRight: "12px",
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
maxWidth: '24px',
maxHeight: '24px',
marginRight: '12px',
color: iconColor || alpha(theme.palette.grey[900], 0.9),
"& svg": {
width: "24px",
height: "24px",
},
"& img": {
width: "24px",
height: "24px",
'& svg': {
width: '24px',
height: '24px'
},
'& img': {
width: '24px',
height: '24px'
}
}}
>
{icon}
@@ -74,14 +74,14 @@ export const ClickableField: React.FC<ClickableFieldProps> = ({
) : (
<Typography
sx={{
fontSize: "14px",
fontSize: '14px',
color: alpha(theme.palette.grey[900], 0.9),
fontWeight: 500,
fontWeight: 500
}}
>
{text}
</Typography>
)}
</Box>
);
};
)
}
+232 -232
View File
@@ -1,73 +1,73 @@
import { Box, TextFieldProps, Typography } from "@linagora/twake-mui";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { Box, TextFieldProps, Typography } from '@linagora/twake-mui'
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
import {
DatePicker,
DatePickerFieldProps,
} from "@mui/x-date-pickers/DatePicker";
import { PickerValue } from "@mui/x-date-pickers/internals";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
DatePickerFieldProps
} from '@mui/x-date-pickers/DatePicker'
import { PickerValue } from '@mui/x-date-pickers/internals'
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
import {
TimePicker,
TimePickerFieldProps,
} from "@mui/x-date-pickers/TimePicker";
import dayjs, { Dayjs } from "dayjs";
import "dayjs/locale/en";
import "dayjs/locale/fr";
import "dayjs/locale/ru";
import "dayjs/locale/vi";
import customParseFormat from "dayjs/plugin/customParseFormat";
import React, { useMemo } from "react";
import { useI18n } from "twake-i18n";
import { LONG_DATE_FORMAT } from "../utils/dateTimeFormatters";
import { dtDate, dtTime, toDateTime } from "../utils/dateTimeHelpers";
import { EditableTimeField } from "./EditableTimeField";
import { ReadOnlyDateField } from "./ReadOnlyPickerField";
TimePickerFieldProps
} from '@mui/x-date-pickers/TimePicker'
import dayjs, { Dayjs } from 'dayjs'
import 'dayjs/locale/en'
import 'dayjs/locale/fr'
import 'dayjs/locale/ru'
import 'dayjs/locale/vi'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import React, { useMemo } from 'react'
import { useI18n } from 'twake-i18n'
import { LONG_DATE_FORMAT } from '../utils/dateTimeFormatters'
import { dtDate, dtTime, toDateTime } from '../utils/dateTimeHelpers'
import { EditableTimeField } from './EditableTimeField'
import { ReadOnlyDateField } from './ReadOnlyPickerField'
dayjs.extend(customParseFormat);
dayjs.extend(customParseFormat)
const timePickerPopperSx = {
"& .MuiPaper-root": {
width: "110px",
minWidth: "110px",
'& .MuiPaper-root': {
width: '110px',
minWidth: '110px'
},
"& .MuiMultiSectionDigitalClockSection-item": {
justifyContent: "flex-start",
width: "100%",
textAlign: "left",
},
};
'& .MuiMultiSectionDigitalClockSection-item': {
justifyContent: 'flex-start',
width: '100%',
textAlign: 'left'
}
}
// 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",
},
};
'& .MuiDateCalendar-root.MuiDateCalendar-root': {
width: '260px',
maxWidth: '260px',
padding: '0 15px'
}
}
/**
* Props for DateTimeFields component
*/
export interface DateTimeFieldsProps {
startDate: string;
startTime: string;
endDate: string;
endTime: string;
allday: boolean;
showMore: boolean;
hasEndDateChanged: boolean;
showEndDate: boolean;
onToggleEndDate: () => void;
startDate: string
startTime: string
endDate: string
endTime: string
allday: boolean
showMore: boolean
hasEndDateChanged: boolean
showEndDate: boolean
onToggleEndDate: () => void
validation: {
errors: {
dateTime: string;
};
};
onStartDateChange: (date: string) => void;
onStartTimeChange: (time: string) => void;
onEndDateChange: (date: string, time?: string) => void;
onEndTimeChange: (time: string) => void;
dateTime: string
}
}
onStartDateChange: (date: string) => void
onStartTimeChange: (time: string) => void
onEndDateChange: (date: string, time?: string) => void
onEndTimeChange: (time: string) => void
}
/**
@@ -87,39 +87,39 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
onStartDateChange,
onStartTimeChange,
onEndDateChange,
onEndTimeChange,
onEndTimeChange
}) => {
const { t, lang } = useI18n();
const { t, lang } = useI18n()
const initialDurationRef = React.useRef<number | null>(null);
const isUserActionRef = React.useRef(false);
const initialDurationRef = React.useRef<number | null>(null)
const isUserActionRef = React.useRef(false)
const getCurrentDuration = React.useCallback((): number => {
const start = toDateTime(startDate, startTime);
const end = toDateTime(endDate, endTime);
const start = toDateTime(startDate, startTime)
const end = toDateTime(endDate, endTime)
if (allday) {
return Math.max(end.startOf("day").diff(start.startOf("day"), "day"), 0);
return Math.max(end.startOf('day').diff(start.startOf('day'), 'day'), 0)
} else {
return Math.max(end.diff(start, "minute"), 0);
return Math.max(end.diff(start, 'minute'), 0)
}
}, [startDate, startTime, endDate, endTime, allday]);
}, [startDate, startTime, endDate, endTime, allday])
React.useEffect(() => {
if (!isUserActionRef.current && startDate && endDate) {
initialDurationRef.current = getCurrentDuration();
initialDurationRef.current = getCurrentDuration()
}
queueMicrotask(() => {
isUserActionRef.current = false;
});
}, [startDate, endDate, startTime, endTime, getCurrentDuration]);
isUserActionRef.current = false
})
}, [startDate, endDate, startTime, endTime, getCurrentDuration])
const spansMultipleDays = React.useMemo(() => {
return startDate !== endDate;
}, [startDate, endDate]);
return startDate !== endDate
}, [startDate, endDate])
const isExpanded = showMore;
const shouldShowEndDateNormal = allday || showEndDate;
const isExpanded = showMore
const shouldShowEndDateNormal = allday || showEndDate
// Show full 4 fields when:
// 1. Non-allday with hasEndDateChanged
// 2. Multiple days with hasEndDateChanged (supports drag from week/month view grid with allday checked)
@@ -127,144 +127,144 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
const shouldShowFullFieldsInNormal =
(!allday && hasEndDateChanged) ||
(hasEndDateChanged && spansMultipleDays) ||
(spansMultipleDays && !allday);
(spansMultipleDays && !allday)
const showSingleDateField =
!isExpanded && !shouldShowEndDateNormal && !shouldShowFullFieldsInNormal;
!isExpanded && !shouldShowEndDateNormal && !shouldShowFullFieldsInNormal
// When shouldShowFullFieldsInNormal is true, show time fields even if allday is true
// This supports the case: drag from week/month view grid with multiple days
const shouldShowTimeFields = !allday || shouldShowFullFieldsInNormal;
const shouldShowTimeFields = !allday || shouldShowFullFieldsInNormal
const startDateLabel = showSingleDateField
? t("dateTimeFields.date")
: t("dateTimeFields.startDate");
? t('dateTimeFields.date')
: t('dateTimeFields.startDate')
const handleStartDateChange = (value: PickerValue) => {
if (!value || !value.isValid()) return;
if (!value || !value.isValid()) return
isUserActionRef.current = true;
const newDateStr = dtDate(value as Dayjs);
const duration = initialDurationRef.current ?? getCurrentDuration();
isUserActionRef.current = true
const newDateStr = dtDate(value as Dayjs)
const duration = initialDurationRef.current ?? getCurrentDuration()
onStartDateChange(newDateStr);
onStartDateChange(newDateStr)
// Preserve duration by adjusting end
const newStart = toDateTime(newDateStr, startTime);
let newEnd: Dayjs;
const newStart = toDateTime(newDateStr, startTime)
let newEnd: Dayjs
if (allday) {
newEnd = newStart.add(duration, "day");
newEnd = newStart.add(duration, 'day')
} else {
newEnd = newStart.add(duration, "minute");
newEnd = newStart.add(duration, 'minute')
}
const newEndDate = dtDate(newEnd);
const newEndTime = dtTime(newEnd);
const newEndDate = dtDate(newEnd)
const newEndTime = dtTime(newEnd)
if (newEndDate !== endDate) {
onEndDateChange(newEndDate);
onEndDateChange(newEndDate)
}
if (!allday && newEndTime !== endTime) {
onEndTimeChange(newEndTime);
onEndTimeChange(newEndTime)
}
};
}
const handleStartTimeChange = (value: PickerValue) => {
if (!value || !value.isValid()) return;
if (!value || !value.isValid()) return
isUserActionRef.current = true;
const newTimeStr = dtTime(value as Dayjs);
const duration = initialDurationRef.current ?? getCurrentDuration();
isUserActionRef.current = true
const newTimeStr = dtTime(value as Dayjs)
const duration = initialDurationRef.current ?? getCurrentDuration()
onStartTimeChange(newTimeStr);
onStartTimeChange(newTimeStr)
const newStart = toDateTime(startDate, newTimeStr);
const newEnd = newStart.add(duration, "minute");
const newStart = toDateTime(startDate, newTimeStr)
const newEnd = newStart.add(duration, 'minute')
const newEndDate = dtDate(newEnd);
const newEndTime = dtTime(newEnd);
const newEndDate = dtDate(newEnd)
const newEndTime = dtTime(newEnd)
if (newEndDate !== endDate && newEndTime !== endTime) {
onEndDateChange(newEndDate, newEndTime);
onEndDateChange(newEndDate, newEndTime)
} else {
if (newEndDate !== endDate) {
onEndDateChange(newEndDate);
onEndDateChange(newEndDate)
}
if (!allday && newEndTime !== endTime) {
onEndTimeChange(newEndTime);
onEndTimeChange(newEndTime)
}
}
};
}
const handleEndDateChange = (value: PickerValue) => {
if (!value || !value.isValid()) return;
if (!value || !value.isValid()) return
isUserActionRef.current = true;
const newDateStr = dtDate(value as Dayjs);
const newEnd = toDateTime(newDateStr, endTime);
const currentStart = toDateTime(startDate, startTime);
isUserActionRef.current = true
const newDateStr = dtDate(value as Dayjs)
const newEnd = toDateTime(newDateStr, endTime)
const currentStart = toDateTime(startDate, startTime)
if (newEnd.isBefore(currentStart)) {
const duration = initialDurationRef.current ?? getCurrentDuration();
let newStart: Dayjs;
const duration = initialDurationRef.current ?? getCurrentDuration()
let newStart: Dayjs
if (allday) {
newStart = newEnd.subtract(duration, "day");
newStart = newEnd.subtract(duration, 'day')
} else {
newStart = newEnd.subtract(duration, "minute");
newStart = newEnd.subtract(duration, 'minute')
}
const newStartDate = dtDate(newStart);
const newStartTime = dtTime(newStart);
const newStartDate = dtDate(newStart)
const newStartTime = dtTime(newStart)
if (newStartDate !== startDate) {
onStartDateChange(newStartDate);
onStartDateChange(newStartDate)
}
if (!allday && newStartTime !== startTime) {
onStartTimeChange(newStartTime);
onStartTimeChange(newStartTime)
}
} else {
initialDurationRef.current = newEnd.diff(
currentStart,
allday ? "day" : "minute"
);
allday ? 'day' : 'minute'
)
}
onEndDateChange(newDateStr);
};
onEndDateChange(newDateStr)
}
const handleEndTimeChange = (value: PickerValue) => {
if (!value || !value.isValid()) return;
if (!value || !value.isValid()) return
isUserActionRef.current = true;
const newTimeStr = dtTime(value as Dayjs);
const newEnd = toDateTime(endDate, newTimeStr);
const currentStart = toDateTime(startDate, startTime);
isUserActionRef.current = true
const newTimeStr = dtTime(value as Dayjs)
const newEnd = toDateTime(endDate, newTimeStr)
const currentStart = toDateTime(startDate, startTime)
// Always update duration ref to reflect current state, even if invalid (end < start)
// This prevents stale duration from being used when user edits start time
initialDurationRef.current = newEnd.diff(currentStart, "minute");
initialDurationRef.current = newEnd.diff(currentStart, 'minute')
onEndTimeChange(newTimeStr);
};
onEndTimeChange(newTimeStr)
}
// Memoize parsed date/time values
const startDateValue = useMemo(
() => (startDate ? dayjs(startDate) : null),
[startDate]
);
)
const startTimeValue = useMemo(
() => (startTime ? dayjs(startTime, "HH:mm") : null),
() => (startTime ? dayjs(startTime, 'HH:mm') : null),
[startTime]
);
)
const endDateValue = useMemo(
() => (endDate ? dayjs(endDate) : null),
[endDate]
);
)
const endTimeValue = useMemo(
() => (endTime ? dayjs(endTime, "HH:mm") : null),
() => (endTime ? dayjs(endTime, 'HH:mm') : null),
[endTime]
);
)
const getSlotProps = (
testId: string,
@@ -272,18 +272,18 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
testLabel?: string
) => ({
textField: {
size: "small" as const,
margin: "dense" as const,
size: 'small' as const,
margin: 'dense' as const,
fullWidth: true,
InputLabelProps: { shrink: true },
error: hasError,
sx: { width: "100%" },
sx: { width: '100%' },
inputProps: {
"data-testid": testId,
...(testLabel ? { "aria-label": testLabel } : {}),
},
},
});
'data-testid': testId,
...(testLabel ? { 'aria-label': testLabel } : {})
}
}
})
const getFieldSlotProps = (
testId: string,
@@ -292,25 +292,25 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
): Partial<DatePickerFieldProps | TimePickerFieldProps> &
Pick<
TextFieldProps,
| "size"
| "margin"
| "fullWidth"
| "InputLabelProps"
| "error"
| "sx"
| "inputProps"
| 'size'
| 'margin'
| 'fullWidth'
| 'InputLabelProps'
| 'error'
| 'sx'
| 'inputProps'
> => ({
size: "small" as const,
margin: "dense" as const,
size: 'small' as const,
margin: 'dense' as const,
fullWidth: true,
InputLabelProps: { shrink: true },
error: hasError,
sx: { width: "100%" },
sx: { width: '100%' },
inputProps: {
"data-testid": testId,
...(testLabel ? { "aria-label": testLabel } : {}),
},
});
'data-testid': testId,
...(testLabel ? { 'aria-label': testLabel } : {})
}
})
const getTimeFieldSlotProps = (
testId: string,
@@ -319,49 +319,49 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
): Partial<TimePickerFieldProps> &
Pick<
TextFieldProps,
| "size"
| "margin"
| "fullWidth"
| "InputLabelProps"
| "error"
| "sx"
| "inputProps"
| 'size'
| 'margin'
| 'fullWidth'
| 'InputLabelProps'
| 'error'
| 'sx'
| 'inputProps'
> => ({
size: "small" as const,
margin: "dense" as const,
size: 'small' as const,
margin: 'dense' as const,
fullWidth: true,
InputLabelProps: { shrink: true },
error: hasError,
sx: { width: "100%" },
sx: { width: '100%' },
inputProps: {
"data-testid": testId,
...(testLabel ? { "aria-label": testLabel } : {}),
},
});
'data-testid': testId,
...(testLabel ? { 'aria-label': testLabel } : {})
}
})
return (
<LocalizationProvider
key={lang}
dateAdapter={AdapterDayjs}
adapterLocale={lang ?? "en"}
adapterLocale={lang ?? 'en'}
localeText={{
okButtonLabel: t("common.ok"),
cancelButtonLabel: t("common.cancel"),
todayButtonLabel: t("menubar.today"),
okButtonLabel: t('common.ok'),
cancelButtonLabel: t('common.cancel'),
todayButtonLabel: t('menubar.today')
}}
>
<Box
display="flex"
flexDirection="column"
className={`date-time-group ${
isExpanded || shouldShowFullFieldsInNormal ? "show-full-field" : ""
isExpanded || shouldShowFullFieldsInNormal ? 'show-full-field' : ''
}`.trim()}
sx={{ maxWidth: showMore ? "calc(100% - 145px)" : "100%" }}
sx={{ maxWidth: showMore ? 'calc(100% - 145px)' : '100%' }}
>
{isExpanded || shouldShowFullFieldsInNormal ? (
<>
<Box display="flex" gap={1} flexDirection="row" alignItems="center">
<Box sx={{ maxWidth: "300px", width: "48%" }}>
<Box sx={{ maxWidth: '300px', width: '48%' }}>
<DatePicker
format={LONG_DATE_FORMAT}
value={startDateValue}
@@ -369,21 +369,21 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
slots={{ field: ReadOnlyDateField }}
slotProps={{
...getSlotProps(
"start-date-input",
'start-date-input',
false,
t("dateTimeFields.startDate")
t('dateTimeFields.startDate')
),
field: getFieldSlotProps(
"start-date-input",
'start-date-input',
false,
t("dateTimeFields.startDate")
t('dateTimeFields.startDate')
),
layout: { sx: dateCalendarLayoutSx },
layout: { sx: dateCalendarLayoutSx }
}}
/>
</Box>
{shouldShowTimeFields && (
<Box sx={{ width: "110px" }}>
<Box sx={{ width: '110px' }}>
<TimePicker
ampm={false}
value={startTimeValue}
@@ -392,23 +392,23 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
timeSteps={{ minutes: 30 }}
slots={{
field: EditableTimeField,
actionBar: () => null,
actionBar: () => null
}}
slotProps={{
openPickerButton: { sx: { display: "none" } },
openPickerButton: { sx: { display: 'none' } },
popper: { sx: timePickerPopperSx },
field: getTimeFieldSlotProps(
"start-time-input",
'start-time-input',
false,
t("dateTimeFields.startTime")
),
t('dateTimeFields.startTime')
)
}}
/>
</Box>
)}
</Box>
<Box display="flex" gap={1} flexDirection="row" alignItems="center">
<Box sx={{ maxWidth: "300px", width: "48%" }}>
<Box sx={{ maxWidth: '300px', width: '48%' }}>
<DatePicker
format={LONG_DATE_FORMAT}
value={endDateValue}
@@ -416,21 +416,21 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
slots={{ field: ReadOnlyDateField }}
slotProps={{
...getSlotProps(
"end-date-input",
'end-date-input',
!!validation.errors.dateTime,
t("dateTimeFields.endDate")
t('dateTimeFields.endDate')
),
field: getFieldSlotProps(
"end-date-input",
'end-date-input',
!!validation.errors.dateTime,
t("dateTimeFields.endDate")
t('dateTimeFields.endDate')
),
layout: { sx: dateCalendarLayoutSx },
layout: { sx: dateCalendarLayoutSx }
}}
/>
</Box>
{shouldShowTimeFields && (
<Box sx={{ width: "110px" }}>
<Box sx={{ width: '110px' }}>
<TimePicker
ampm={false}
value={endTimeValue}
@@ -439,16 +439,16 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
timeSteps={{ minutes: 30 }}
slots={{
field: EditableTimeField,
actionBar: () => null,
actionBar: () => null
}}
slotProps={{
openPickerButton: { sx: { display: "none" } },
openPickerButton: { sx: { display: 'none' } },
popper: { sx: timePickerPopperSx },
field: getTimeFieldSlotProps(
"end-time-input",
'end-time-input',
!!validation.errors.dateTime,
t("dateTimeFields.endTime")
),
t('dateTimeFields.endTime')
)
}}
/>
</Box>
@@ -457,7 +457,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
</>
) : shouldShowEndDateNormal ? (
<Box display="flex" gap={1} flexDirection="row" alignItems="center">
<Box sx={{ maxWidth: "300px", width: "48%" }}>
<Box sx={{ maxWidth: '300px', width: '48%' }}>
<DatePicker
format={LONG_DATE_FORMAT}
value={startDateValue}
@@ -465,20 +465,20 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
slots={{ field: ReadOnlyDateField }}
slotProps={{
...getSlotProps(
"start-date-input",
'start-date-input',
false,
t("dateTimeFields.startDate")
t('dateTimeFields.startDate')
),
field: getFieldSlotProps(
"start-date-input",
'start-date-input',
false,
t("dateTimeFields.startDate")
t('dateTimeFields.startDate')
),
layout: { sx: dateCalendarLayoutSx },
layout: { sx: dateCalendarLayoutSx }
}}
/>
</Box>
<Box sx={{ maxWidth: "300px", width: "48%" }}>
<Box sx={{ maxWidth: '300px', width: '48%' }}>
<DatePicker
format={LONG_DATE_FORMAT}
value={endDateValue}
@@ -486,40 +486,40 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
slots={{ field: ReadOnlyDateField }}
slotProps={{
...getSlotProps(
"end-date-input",
'end-date-input',
!!validation.errors.dateTime,
t("dateTimeFields.endDate")
t('dateTimeFields.endDate')
),
field: getFieldSlotProps(
"end-date-input",
'end-date-input',
!!validation.errors.dateTime,
t("dateTimeFields.endDate")
t('dateTimeFields.endDate')
),
layout: { sx: dateCalendarLayoutSx },
layout: { sx: dateCalendarLayoutSx }
}}
/>
</Box>
</Box>
) : (
<Box display="flex" gap={1} flexDirection="row" alignItems="center">
<Box sx={{ maxWidth: "300px", width: "48%" }}>
<Box sx={{ maxWidth: '300px', width: '48%' }}>
<DatePicker
format={LONG_DATE_FORMAT}
value={startDateValue}
onChange={handleStartDateChange}
slots={{ field: ReadOnlyDateField }}
slotProps={{
...getSlotProps("start-date-input", false, startDateLabel),
...getSlotProps('start-date-input', false, startDateLabel),
field: getFieldSlotProps(
"start-date-input",
'start-date-input',
false,
startDateLabel
),
layout: { sx: dateCalendarLayoutSx },
layout: { sx: dateCalendarLayoutSx }
}}
/>
</Box>
<Box sx={{ maxWidth: "110px" }}>
<Box sx={{ maxWidth: '110px' }}>
<TimePicker
ampm={false}
value={startTimeValue}
@@ -529,31 +529,31 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
timeSteps={{ minutes: 30 }}
slots={{
field: EditableTimeField,
actionBar: () => null,
actionBar: () => null
}}
slotProps={{
openPickerButton: { sx: { display: "none" } },
openPickerButton: { sx: { display: 'none' } },
popper: { sx: timePickerPopperSx },
field: getTimeFieldSlotProps(
"start-time-input",
'start-time-input',
false,
t("dateTimeFields.startTime")
),
t('dateTimeFields.startTime')
)
}}
/>
</Box>
{!allday && (
<Typography
sx={{
alignSelf: "center",
alignSelf: 'center',
mx: 0.5,
mt: 0.5,
mt: 0.5
}}
>
-
</Typography>
)}
<Box sx={{ maxWidth: "110px" }}>
<Box sx={{ maxWidth: '110px' }}>
<TimePicker
ampm={false}
value={endTimeValue}
@@ -563,16 +563,16 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
timeSteps={{ minutes: 30 }}
slots={{
field: EditableTimeField,
actionBar: () => null,
actionBar: () => null
}}
slotProps={{
openPickerButton: { sx: { display: "none" } },
openPickerButton: { sx: { display: 'none' } },
popper: { sx: timePickerPopperSx },
field: getTimeFieldSlotProps(
"end-time-input",
'end-time-input',
!!validation.errors.dateTime,
t("dateTimeFields.endTime")
),
t('dateTimeFields.endTime')
)
}}
/>
</Box>
@@ -584,11 +584,11 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
<Box display="flex" gap={1} flexDirection="row">
<Box
sx={{
width: "1%",
width: '1%'
}}
/>
<Box>
<Typography variant="caption" sx={{ color: "error.main" }}>
<Typography variant="caption" sx={{ color: 'error.main' }}>
{validation.errors.dateTime}
</Typography>
</Box>
@@ -596,5 +596,5 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
)}
</Box>
</LocalizationProvider>
);
};
)
}
@@ -1,29 +1,29 @@
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";
import { makeRecurrenceString } from "@/features/Events/EventPreview/utils/makeRecurrenceString";
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'
import { makeRecurrenceString } from '@/features/Events/EventPreview/utils/makeRecurrenceString'
interface DateTimeSummaryProps {
startDate: string;
startTime: string;
endDate: string;
endTime: string;
allday: boolean;
timezone: string;
repetition: RepetitionObject;
showEndDate: boolean;
onClick: () => void;
startDate: string
startTime: string
endDate: string
endTime: string
allday: boolean
timezone: string
repetition: RepetitionObject
showEndDate: boolean
onClick: () => void
}
export const DateTimeSummary: React.FC<DateTimeSummaryProps> = ({
@@ -35,111 +35,110 @@ export const DateTimeSummary: React.FC<DateTimeSummaryProps> = ({
timezone,
repetition,
showEndDate,
onClick,
onClick
}) => {
const { t, lang } = useI18n();
const theme = useTheme();
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 (!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}`;
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);
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);
const formatted = date.locale(locale).format(LONG_DATE_FORMAT)
if (locale === 'ru') {
return formatted.charAt(0).toUpperCase() + formatted.slice(1)
}
return formatted;
};
return formatted
}
// Format time in 24h: "03:30 - 16:30"
const formatTime = (startTimeStr: string, endTimeStr: string): string => {
if (allday || !startTimeStr || !endTimeStr) return "";
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")}`;
};
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)}`;
};
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 "";
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}`;
const dateForOffset = dateStr ? dayjs(dateStr).toDate() : new Date()
const offset = getTimezoneOffset(tz, dateForOffset)
const tzName = tz.replace(/_/g, ' ')
return `(${offset}) ${tzName}`
} catch {
return tz.replace(/_/g, " ");
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");
return t('event.repeat.doesNotRepeat')
}
return (
makeRecurrenceString({
repetition: rep,
t,
startText: rep.interval === 1 ? t("event.repeat.every") : "",
joinChar: "",
enableStrForOneTimeInterval: true,
}) || ""
);
};
startText: rep.interval === 1 ? t('event.repeat.every') : '',
joinChar: '',
enableStrForOneTimeInterval: true
}) || ''
)
}
// 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}`;
const startDateText = formatDate(startDate)
const endDateText = formatDate(endDate)
return `${startDateText} - ${endDateText}`
}
return formatDate(startDate);
};
return formatDate(startDate)
}
const dateText = formatDateText();
const timeText = formatTime(startTime, endTime);
const timezoneText = formatTimezone(timezone, startDate);
const repeatText = formatRepeat(repetition);
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;
return null
}
const primaryStyle = {
fontSize: "14px",
fontSize: '14px',
fontWeight: 500,
color: alpha(theme.palette.grey[900], 0.9),
};
color: alpha(theme.palette.grey[900], 0.9)
}
return (
<SectionPreviewRow
icon={<AccessTimeIcon sx={{ color: "text.secondary" }} />}
icon={<AccessTimeIcon sx={{ color: 'text.secondary' }} />}
onClick={onClick}
>
<Box>
@@ -153,14 +152,14 @@ export const DateTimeSummary: React.FC<DateTimeSummaryProps> = ({
)}
</Typography>
<Box display="flex" gap={2} alignItems="center" mt={0.5}>
<Typography variant="caption" sx={{ color: "#444746" }}>
<Typography variant="caption" sx={{ color: '#444746' }}>
{timezoneText}
</Typography>
<Typography variant="caption" sx={{ color: "#444746" }}>
<Typography variant="caption" sx={{ color: '#444746' }}>
{repeatText}
</Typography>
</Box>
</Box>
</SectionPreviewRow>
);
};
)
}
@@ -1,274 +1,294 @@
import { TextField } from "@linagora/twake-mui";
// EditableTimeField
import { TextField } from '@linagora/twake-mui'
import {
useParsedFormat,
usePickerActionsContext,
usePickerContext,
useSplitFieldProps,
} from "@mui/x-date-pickers/hooks";
import { PickerFieldProps } from "@mui/x-date-pickers/models";
import { TimePickerFieldProps } from "@mui/x-date-pickers/TimePicker";
useSplitFieldProps
} from '@mui/x-date-pickers/hooks'
import { PickerFieldProps } from '@mui/x-date-pickers/models'
import { TimePickerFieldProps } from '@mui/x-date-pickers/TimePicker'
import {
PickerFieldAdapter,
PickerValidationScope,
useValidation,
validateTime,
} from "@mui/x-date-pickers/validation";
import { Dayjs } from "dayjs";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { parseTimeInput } from "../utils/dateTimeHelpers";
validateTime
} from '@mui/x-date-pickers/validation'
import { Dayjs } from 'dayjs'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { parseTimeInput } from '../utils/dateTimeHelpers'
type FieldType = "date" | "time" | "date-time";
type FieldType = 'date' | 'time' | 'date-time'
type GenericPickerFieldProps = PickerFieldProps<Dayjs, false, false> & {
fieldType: FieldType;
fieldType: FieldType
validator: (
value: Dayjs | null,
context: PickerValidationScope,
adapter: PickerFieldAdapter<Dayjs>
) => string | null;
};
) => string | null
}
const TIME_DISPLAY_FORMAT = "HH:mm";
const TIME_DISPLAY_FORMAT = 'HH:mm'
/**
* Editable field for time pickers. Allows free typing with format on blur/enter.
* Click anywhere in the field to open picker.
*/
function EditableTimePickerField(props: GenericPickerFieldProps) {
const { fieldType, validator, ...fieldProps } = props;
const { fieldType, validator, ...fieldProps } = props
const { internalProps, forwardedProps } = useSplitFieldProps(
fieldProps,
fieldType
);
)
const pickerContext = usePickerContext();
const pickerActions = usePickerActionsContext();
const parsedFormat = useParsedFormat();
const inputRef = useRef<HTMLInputElement>(null);
const {
value: pickerValue,
open,
setOpen,
timezone,
triggerRef,
rootClassName,
rootSx,
rootRef,
name: pickerName
} = usePickerContext()
const pickerActions = usePickerActionsContext()
const parsedFormat = useParsedFormat()
const inputRef = useRef<HTMLInputElement>(null)
const getFormattedValue = useCallback(() => {
if (pickerContext.value == null) return "";
if (!pickerContext.value.isValid()) return "";
return pickerContext.value.format(TIME_DISPLAY_FORMAT);
}, [pickerContext.value]);
if (pickerValue == null) return ''
if (!pickerValue.isValid()) return ''
return pickerValue.format(TIME_DISPLAY_FORMAT)
}, [pickerValue])
const [inputValue, setInputValue] = useState(getFormattedValue);
const [isFocused, setIsFocused] = useState(false);
const [inputValue, setInputValue] = useState(getFormattedValue)
const [isFocused, setIsFocused] = useState(false)
const [pendingCommitValue, setPendingCommitValue] = useState<string | null>(
null
);
const prevFormattedValueRef = useRef(getFormattedValue());
const isDispatchingCloseEventRef = useRef(false);
const hasDispatchedInMicrotaskRef = useRef(false);
)
const prevFormattedValueRef = useRef(getFormattedValue())
const isDispatchingCloseEventRef = useRef(false)
const hasDispatchedInMicrotaskRef = useRef(false)
// Sync input value when picker value changes from dropdown selection or external
useEffect(() => {
const newFormattedValue = getFormattedValue();
const valueChanged = newFormattedValue !== prevFormattedValueRef.current;
const updateInputValue = () => {
const newFormattedValue = getFormattedValue()
const valueChanged = newFormattedValue !== prevFormattedValueRef.current
// Sync when:
// 1. Value changed (from dropdown selection or external)
// 2. Not focused (external change)
if (valueChanged || !isFocused) {
setInputValue(newFormattedValue);
// Sync when:
// 1. Value changed (from dropdown selection or external)
// 2. Not focused (external change)
if (valueChanged || !isFocused) {
setInputValue(newFormattedValue)
}
// Close dropdown after selection (value changed while dropdown is open)
if (valueChanged && open) {
setOpen(false)
// Clear focus after dropdown selection
setIsFocused(false)
inputRef.current?.blur()
}
prevFormattedValueRef.current = newFormattedValue
}
updateInputValue()
}, [getFormattedValue, isFocused, open, setOpen])
// Close dropdown after selection (value changed while dropdown is open)
if (valueChanged && pickerContext.open) {
pickerContext.setOpen(false);
// Clear focus after dropdown selection
setIsFocused(false);
inputRef.current?.blur();
}
prevFormattedValueRef.current = newFormattedValue;
}, [getFormattedValue, isFocused, pickerContext]);
const wasOpenRef = useRef(pickerContext.open);
const wasOpenRef = useRef(open)
// Handle dropdown open/close
useEffect(() => {
const wasOpen = wasOpenRef.current;
const isOpen = pickerContext.open;
const handleDropdown = () => {
const wasOpen = wasOpenRef.current
const isOpen = open
if (isOpen && !wasOpen && inputRef.current) {
// Dropdown just opened - refocus input to allow typing
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 10);
wasOpenRef.current = isOpen;
return () => clearTimeout(timer);
}
if (!isOpen && wasOpen) {
// Dropdown just closed - check if we need to commit user input
const current = getFormattedValue();
if (isFocused && inputValue.trim() && inputValue !== current) {
// Store input value to commit later (after parseAndUpdateTime is defined)
setPendingCommitValue(inputValue);
} else {
setInputValue(current);
if (isOpen && !wasOpen && inputRef.current) {
// Dropdown just opened - refocus input to allow typing
const timer = setTimeout(() => {
inputRef.current?.focus()
}, 10)
wasOpenRef.current = isOpen
return () => clearTimeout(timer)
}
setIsFocused(false);
// Blur input to clear DOM focus (removes Mui-focused class)
inputRef.current?.blur();
}
wasOpenRef.current = isOpen;
}, [pickerContext.open, getFormattedValue, isFocused, inputValue]);
if (!isOpen && wasOpen) {
// Dropdown just closed - check if we need to commit user input
const current = getFormattedValue()
if (isFocused && inputValue.trim() && inputValue !== current) {
// Store input value to commit later (after parseAndUpdateTime is defined)
setPendingCommitValue(inputValue)
} else {
setInputValue(current)
}
setIsFocused(false)
// Blur input to clear DOM focus (removes Mui-focused class)
inputRef.current?.blur()
}
wasOpenRef.current = isOpen
}
handleDropdown()
}, [open, getFormattedValue, isFocused, inputValue])
const { hasValidationError } = useValidation({
validator,
value: pickerContext.value,
timezone: pickerContext.timezone,
props: internalProps,
});
value: pickerValue,
timezone: timezone,
props: internalProps
})
const parseAndUpdateTime = useCallback(
(value: string) => {
const trimmed = value.trim();
const trimmed = value.trim()
if (!trimmed) {
setInputValue(getFormattedValue());
return;
setInputValue(getFormattedValue())
return
}
const newValue = parseTimeInput(
trimmed,
pickerContext.value as Dayjs | null
);
const newValue = parseTimeInput(trimmed, pickerValue as Dayjs | null)
if (newValue) {
// Update picker internal state and trigger onChange in one call
pickerActions.setValue(newValue, { changeImportance: "accept" });
setInputValue(newValue.format(TIME_DISPLAY_FORMAT));
pickerActions.setValue(newValue, { changeImportance: 'accept' })
setInputValue(newValue.format(TIME_DISPLAY_FORMAT))
} else {
// Invalid input - reset to current value
setInputValue(getFormattedValue());
setInputValue(getFormattedValue())
}
},
[pickerContext, pickerActions, getFormattedValue]
);
[pickerValue, pickerActions, getFormattedValue]
)
// Commit pending input value when picker closes (after parseAndUpdateTime is defined)
useEffect(() => {
if (pendingCommitValue !== null) {
parseAndUpdateTime(pendingCommitValue);
setPendingCommitValue(null);
const updateTimeAndClearPending = () => {
if (pendingCommitValue !== null) {
parseAndUpdateTime(pendingCommitValue)
setPendingCommitValue(null)
}
}
}, [pendingCommitValue, parseAndUpdateTime]);
updateTimeAndClearPending()
}, [pendingCommitValue, parseAndUpdateTime])
// Listen for close events from other picker fields
useEffect(() => {
const handleCloseOtherPickers = () => {
// Don't close if this field is the one dispatching the event
if (isDispatchingCloseEventRef.current) {
return;
return
}
// If this picker is open and another field is requesting to close others
if (pickerContext.open) {
pickerContext.setOpen(false);
if (open) {
setOpen(false)
}
};
}
window.addEventListener(
"close-other-time-pickers",
handleCloseOtherPickers
);
window.addEventListener('close-other-time-pickers', handleCloseOtherPickers)
return () => {
window.removeEventListener(
"close-other-time-pickers",
'close-other-time-pickers',
handleCloseOtherPickers
);
};
}, [pickerContext]);
)
}
}, [open, setOpen])
const dispatchCloseOtherPickers = useCallback(() => {
// Guard: prevent duplicate dispatch in the same microtask
if (hasDispatchedInMicrotaskRef.current) {
return;
return
}
// Mark that we've dispatched in this microtask
hasDispatchedInMicrotaskRef.current = true;
hasDispatchedInMicrotaskRef.current = true
// Set flag to prevent this field from closing its own picker
isDispatchingCloseEventRef.current = true;
isDispatchingCloseEventRef.current = true
// Notify other pickers to close
window.dispatchEvent(new CustomEvent("close-other-time-pickers"));
window.dispatchEvent(new CustomEvent('close-other-time-pickers'))
// Reset flags in next microtask to ensure event is processed first
Promise.resolve().then(() => {
isDispatchingCloseEventRef.current = false;
hasDispatchedInMicrotaskRef.current = false;
});
}, []);
Promise.resolve()
.then(() => {
isDispatchingCloseEventRef.current = false
hasDispatchedInMicrotaskRef.current = false
return
})
.catch(error => {
console.error('Error resetting picker close flags:', error)
})
}, [])
const handleClick = (e: React.MouseEvent) => {
if (forwardedProps.disabled || forwardedProps.readOnly) return;
if (forwardedProps.disabled || forwardedProps.readOnly) return
// Stop propagation to prevent parent from toggling the picker
e.stopPropagation();
e.stopPropagation()
dispatchCloseOtherPickers();
dispatchCloseOtherPickers()
// Always keep it open when clicked, or open it if closed
if (!pickerContext.open) {
pickerContext.setOpen(true);
if (!open) {
setOpen(true)
}
};
}
const handleFocus = () => {
if (forwardedProps.disabled || forwardedProps.readOnly) return;
if (forwardedProps.disabled || forwardedProps.readOnly) return
setIsFocused(true);
dispatchCloseOtherPickers();
};
setIsFocused(true)
dispatchCloseOtherPickers()
}
const handleBlur = (_e: React.FocusEvent<HTMLInputElement>) => {
// If dropdown is open, don't parse input
// MUI will handle selection and sync value via useEffect
if (pickerContext.open) {
return;
if (open) {
return
}
// Dropdown is closed - parse input and update value
setIsFocused(false);
parseAndUpdateTime(inputValue);
};
setIsFocused(false)
parseAndUpdateTime(inputValue)
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
setInputValue(e.target.value)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
parseAndUpdateTime(inputValue);
setIsFocused(false);
pickerContext.setOpen(false);
inputRef.current?.blur();
} else if (e.key === "Escape") {
setInputValue(getFormattedValue());
setIsFocused(false);
pickerContext.setOpen(false);
inputRef.current?.blur();
if (e.key === 'Enter') {
e.preventDefault()
parseAndUpdateTime(inputValue)
setIsFocused(false)
setOpen(false)
inputRef.current?.blur()
} else if (e.key === 'Escape') {
setInputValue(getFormattedValue())
setIsFocused(false)
setOpen(false)
inputRef.current?.blur()
}
};
}
const mergedInputProps = {
...forwardedProps.InputProps,
ref: pickerContext.triggerRef,
ref: triggerRef,
sx: {
cursor: "text",
...forwardedProps.InputProps?.sx,
},
};
cursor: 'text',
...forwardedProps.InputProps?.sx
}
}
return (
<TextField
{...forwardedProps}
value={inputValue}
onChange={handleChange}
placeholder={parsedFormat as string}
placeholder={parsedFormat}
InputProps={mergedInputProps}
inputRef={inputRef}
error={hasValidationError || forwardedProps.error}
@@ -277,17 +297,17 @@ function EditableTimePickerField(props: GenericPickerFieldProps) {
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className={pickerContext.rootClassName}
className={rootClassName}
sx={{
...pickerContext.rootSx,
"& input": {
textAlign: "center",
},
...rootSx,
'& input': {
textAlign: 'center'
}
}}
ref={pickerContext.rootRef}
name={pickerContext.name}
ref={rootRef}
name={pickerName}
/>
);
)
}
export function EditableTimeField(props: TimePickerFieldProps) {
@@ -297,5 +317,5 @@ export function EditableTimeField(props: TimePickerFieldProps) {
fieldType="time"
validator={validateTime}
/>
);
)
}
@@ -1,5 +1,5 @@
import { Box, SxProps, Theme, Typography } from "@linagora/twake-mui";
import React from "react";
import { Box, SxProps, Theme, Typography } from '@linagora/twake-mui'
import React from 'react'
/**
* Helper component for field with label
@@ -10,64 +10,64 @@ export const FieldWithLabel = React.memo(
label,
isExpanded,
children,
sx,
sx
}: {
label: string | React.ReactNode;
isExpanded: boolean;
children: React.ReactNode;
sx?: SxProps<Theme>;
label: string | React.ReactNode
isExpanded: boolean
children: React.ReactNode
sx?: SxProps<Theme>
}) => {
if (!isExpanded) {
// Normal mode: label on top
const isEmptyLabel =
label === null ||
label === undefined ||
(typeof label === "string" && label.trim() === "");
(typeof label === 'string' && label.trim() === '')
return (
<Box
sx={[
{
"& > *:not(:first-of-type)": {
marginTop: isEmptyLabel ? 0 : "6px",
'& > *:not(:first-of-type)': {
marginTop: isEmptyLabel ? 0 : '6px'
},
// Only apply margin to direct child MuiTextField-root
"& > .MuiFormControl-root": {
marginTop: "6px",
marginBottom: 0,
'& > .MuiFormControl-root': {
marginTop: '6px',
marginBottom: 0
},
"& > .MuiTextField-root": {
marginTop: "6px",
marginBottom: 0,
'& > .MuiTextField-root': {
marginTop: '6px',
marginBottom: 0
},
// Reset margin for nested MuiTextField-root
"& .MuiFormControl-root .MuiTextField-root": {
'& .MuiFormControl-root .MuiTextField-root': {
marginTop: 0,
marginBottom: 0,
marginBottom: 0
},
"& .MuiTextField-root .MuiTextField-root": {
'& .MuiTextField-root .MuiTextField-root': {
marginTop: 0,
marginBottom: 0,
marginBottom: 0
},
// Reset margin for nested Box children (DateTimeFields structure)
"& > .MuiBox-root > .MuiBox-root": {
marginTop: 0,
},
"& > .MuiBox-root > .MuiBox-root > .MuiBox-root": {
marginTop: 0,
'& > .MuiBox-root > .MuiBox-root': {
marginTop: 0
},
'& > .MuiBox-root > .MuiBox-root > .MuiBox-root': {
marginTop: 0
}
},
...(Array.isArray(sx) ? sx : sx ? [sx] : []),
...(Array.isArray(sx) ? sx : sx ? [sx] : [])
]}
>
{!isEmptyLabel && (
<Typography component="div" variant="h6" sx={{ display: "block" }}>
<Typography component="div" variant="h6" sx={{ display: 'block' }}>
{label}
</Typography>
)}
{children}
</Box>
);
)
}
// Extended mode: label on left
@@ -77,9 +77,9 @@ export const FieldWithLabel = React.memo(
component="div"
variant="h6"
sx={{
minWidth: "115px",
marginRight: "12px",
flexShrink: 0,
minWidth: '115px',
marginRight: '12px',
flexShrink: 0
}}
>
{label}
@@ -88,23 +88,25 @@ export const FieldWithLabel = React.memo(
flexGrow={1}
sx={{
// Set margin-top: 8px for second row in DateTimeFields (4 fields layout)
"& > .MuiBox-root > .MuiBox-root:nth-of-type(2)": {
marginTop: "8px",
'& > .MuiBox-root > .MuiBox-root:nth-of-type(2)': {
marginTop: '8px'
},
// Remove margin from MuiFormControl-root MuiFormControl-marginDense in extended mode
"& .MuiFormControl-root.MuiFormControl-marginDense": {
'& .MuiFormControl-root.MuiFormControl-marginDense': {
marginTop: 0,
marginBottom: 0,
marginBottom: 0
},
"& .MuiTextField-root.MuiFormControl-marginDense": {
'& .MuiTextField-root.MuiFormControl-marginDense': {
marginTop: 0,
marginBottom: 0,
},
marginBottom: 0
}
}}
>
{children}
</Box>
</Box>
);
)
}
);
)
FieldWithLabel.displayName = 'FieldWithLabel'
@@ -1,88 +1,98 @@
import { TextField } from "@linagora/twake-mui";
import { DatePickerFieldProps } from "@mui/x-date-pickers/DatePicker";
// ReadOnlyPickerField
import { TextField } from '@linagora/twake-mui'
import { DatePickerFieldProps } from '@mui/x-date-pickers/DatePicker'
import {
useParsedFormat,
usePickerContext,
useSplitFieldProps,
} from "@mui/x-date-pickers/hooks";
import { PickerFieldProps } from "@mui/x-date-pickers/models";
useSplitFieldProps
} from '@mui/x-date-pickers/hooks'
import { PickerFieldProps } from '@mui/x-date-pickers/models'
import {
PickerFieldAdapter,
PickerValidationScope,
useValidation,
validateDate,
} from "@mui/x-date-pickers/validation";
import { Dayjs } from "dayjs";
validateDate
} from '@mui/x-date-pickers/validation'
import { Dayjs } from 'dayjs'
type FieldType = "date" | "time" | "date-time";
type FieldType = 'date' | 'time' | 'date-time'
type GenericPickerFieldProps = PickerFieldProps<Dayjs, false, false> & {
fieldType: FieldType;
fieldType: FieldType
validator: (
value: Dayjs | null,
context: PickerValidationScope,
adapter: PickerFieldAdapter<Dayjs>
) => string | null;
};
) => string | null
}
/**
* Shared read-only field for date/time pickers. Disables typing, removes icon,
* and opens the picker when clicking anywhere in the field.
*/
function ReadOnlyPickerField(props: GenericPickerFieldProps) {
const { fieldType, validator, ...fieldProps } = props;
const { fieldType, validator, ...fieldProps } = props
const { internalProps, forwardedProps } = useSplitFieldProps(
fieldProps,
fieldType
);
)
const pickerContext = usePickerContext();
const parsedFormat = useParsedFormat();
const {
value,
timezone,
fieldFormat,
open,
setOpen,
triggerRef,
rootClassName,
rootSx,
rootRef,
name: pickerName
} = usePickerContext()
const parsedFormat = useParsedFormat()
const { hasValidationError } = useValidation({
validator,
value: pickerContext.value,
timezone: pickerContext.timezone,
props: internalProps,
});
value: value,
timezone: timezone,
props: internalProps
})
const valueToDisplay =
pickerContext.value == null
? ""
: pickerContext.value.isValid()
? pickerContext.value.format(pickerContext.fieldFormat)
: "";
value == null ? '' : value.isValid() ? value.format(fieldFormat) : ''
const mergedInputProps = {
...forwardedProps.InputProps,
ref: pickerContext.triggerRef,
ref: triggerRef,
readOnly: true,
sx: {
cursor: "pointer",
"& *": { cursor: "inherit" },
...forwardedProps.InputProps?.sx,
},
};
cursor: 'pointer',
'& *': { cursor: 'inherit' },
...forwardedProps.InputProps?.sx
}
}
return (
<TextField
{...forwardedProps}
value={valueToDisplay}
placeholder={parsedFormat as string}
placeholder={parsedFormat}
InputProps={mergedInputProps}
error={hasValidationError}
focused={pickerContext.open}
onClick={() => pickerContext.setOpen((prev) => !prev)}
className={pickerContext.rootClassName}
sx={pickerContext.rootSx}
ref={pickerContext.rootRef}
name={pickerContext.name}
focused={open}
onClick={() => setOpen((prev: boolean) => !prev)}
className={rootClassName}
sx={rootSx}
ref={rootRef}
name={pickerName}
/>
);
)
}
export function ReadOnlyDateField(props: DatePickerFieldProps) {
return (
<ReadOnlyPickerField {...props} fieldType="date" validator={validateDate} />
);
)
}
@@ -1,13 +1,13 @@
import { Box, Typography, useTheme } from "@linagora/twake-mui";
import { alpha } from "@mui/material/styles";
import React from "react";
import { Box, Typography, useTheme } from '@linagora/twake-mui'
import { alpha } from '@mui/material/styles'
import React from 'react'
export interface SectionPreviewRowProps {
icon: React.ReactNode;
icon: React.ReactNode
/** Primary text or custom content. If string, rendered with fontSize 14px, fontWeight 500. */
children: React.ReactNode;
onClick: () => void;
iconColor?: string;
children: React.ReactNode
onClick: () => void
iconColor?: string
}
/**
@@ -18,17 +18,17 @@ export const SectionPreviewRow: React.FC<SectionPreviewRowProps> = ({
icon,
children,
onClick,
iconColor,
iconColor
}) => {
const theme = useTheme();
const color = iconColor ?? alpha(theme.palette.grey[900], 0.9);
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();
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onClick()
}
};
}
return (
<Box
@@ -37,45 +37,45 @@ export const SectionPreviewRow: React.FC<SectionPreviewRowProps> = ({
onClick={onClick}
onKeyDown={handleKeyDown}
sx={{
display: "flex",
alignItems: "center",
cursor: "pointer",
padding: "8px 12px",
borderRadius: "4px",
"&:hover": {
backgroundColor: "action.hover",
},
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
padding: '8px 12px',
borderRadius: '4px',
'&:hover': {
backgroundColor: 'action.hover'
}
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
maxWidth: "24px",
maxHeight: "24px",
marginRight: "12px",
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
maxWidth: '24px',
maxHeight: '24px',
marginRight: '12px',
flexShrink: 0,
color,
"& svg": {
maxWidth: "24px",
maxHeight: "24px",
},
"& img": {
maxWidth: "24px",
maxHeight: "24px",
'& svg': {
maxWidth: '24px',
maxHeight: '24px'
},
'& img': {
maxWidth: '24px',
maxHeight: '24px'
}
}}
>
{icon}
</Box>
<Box flex={1} minWidth={0}>
{typeof children === "string" ? (
{typeof children === 'string' ? (
<Typography
sx={{
fontSize: "14px",
fontSize: '14px',
fontWeight: 500,
color,
color
}}
>
{children}
@@ -85,5 +85,5 @@ export const SectionPreviewRow: React.FC<SectionPreviewRowProps> = ({
)}
</Box>
</Box>
);
};
)
}
@@ -1,18 +1,18 @@
import { AppDispatch } from "@/app/store";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { AppDispatch } from '@/app/store'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import {
deleteEventAsync,
deleteEventInstanceAsync,
putEventAsync,
updateEventInstanceAsync,
} from "@/features/Calendars/services";
import { updateSeriesPartstat } from "@/features/Events/EventApi";
import { CalendarEvent } from "@/features/Events/EventsTypes";
import { PartStat } from "@/features/User/models/attendee";
import { createAttendee } from "@/features/User/models/attendee.mapper";
import { userData } from "@/features/User/userDataTypes";
import { buildFamilyName } from "@/utils/buildFamilyName";
import { isEventOrganiser } from "@/utils/isEventOrganiser";
updateEventInstanceAsync
} from '@/features/Calendars/services'
import { updateSeriesPartstat } from '@/features/Events/EventApi'
import { CalendarEvent } from '@/features/Events/EventsTypes'
import { PartStat } from '@/features/User/models/attendee'
import { createAttendee } from '@/features/User/models/attendee.mapper'
import { userData } from '@/features/User/userDataTypes'
import { buildFamilyName } from '@/utils/buildFamilyName'
import { isEventOrganiser } from '@/utils/isEventOrganiser'
function updateEventAttendees(
calendar: Calendar,
@@ -22,60 +22,60 @@ function updateEventAttendees(
) {
if (calendar.owner?.resource) {
const updatedAttendees =
event.attendee?.map((attendeeData) =>
attendeeData.cutype === "RESOURCE" && attendeeData.cn === calendar.name
event.attendee?.map(attendeeData =>
attendeeData.cutype === 'RESOURCE' && attendeeData.cn === calendar.name
? { ...attendeeData, partstat: rsvp }
: attendeeData
) || [];
) || []
return { attendee: updatedAttendees };
return { attendee: updatedAttendees }
}
if (!user) {
throw new Error("Cannot update attendees without user data");
throw new Error('Cannot update attendees without user data')
}
const eventHasNoAttendees = !event?.attendee || event.attendee.length === 0;
const isOrganizer = isEventOrganiser(event, user.email);
const eventHasNoAttendees = !event?.attendee || event.attendee.length === 0
const isOrganizer = isEventOrganiser(event, user.email)
if (eventHasNoAttendees) {
const userdata = createAttendee({
cal_address: user.email,
cn: buildFamilyName(user.given_name, user.family_name, user.email),
role: isOrganizer ? "CHAIR" : "REQ-PARTICIPANT",
partstat: rsvp,
});
role: isOrganizer ? 'CHAIR' : 'REQ-PARTICIPANT',
partstat: rsvp
})
return {
organizer: isOrganizer ? userdata : event.organizer,
attendee: [userdata],
};
attendee: [userdata]
}
}
return {
attendee: (() => {
const userEmailLower = user.email?.toLowerCase();
const userEmailLower = user.email?.toLowerCase()
const userExists = event.attendee.some(
(attendee) => attendee.cal_address?.toLowerCase() === userEmailLower
);
attendee => attendee.cal_address?.toLowerCase() === userEmailLower
)
const updatedAttendees = event.attendee.map((attendeeData) =>
const updatedAttendees = event.attendee.map(attendeeData =>
attendeeData.cal_address?.toLowerCase() === userEmailLower
? { ...attendeeData, partstat: rsvp }
: attendeeData
);
)
if (!userExists) {
const newUserAttendee = createAttendee({
cal_address: user.email,
cn: buildFamilyName(user.given_name, user.family_name, user.email),
role: "REQ-PARTICIPANT",
partstat: rsvp,
});
return [...updatedAttendees, newUserAttendee];
role: 'REQ-PARTICIPANT',
partstat: rsvp
})
return [...updatedAttendees, newUserAttendee]
}
return updatedAttendees;
})(),
};
return updatedAttendees
})()
}
}
async function handleSoloRSVP(
@@ -83,7 +83,7 @@ async function handleSoloRSVP(
calendar: Calendar,
event: CalendarEvent
) {
dispatch(updateEventInstanceAsync({ cal: calendar, event }));
await dispatch(updateEventInstanceAsync({ cal: calendar, event }))
}
async function handleAllRSVP(
@@ -91,7 +91,7 @@ async function handleAllRSVP(
userEmail: string,
rsvp: PartStat
) {
await updateSeriesPartstat(event, userEmail, rsvp);
await updateSeriesPartstat(event, userEmail, rsvp)
}
async function handleDefaultRSVP(
@@ -99,7 +99,7 @@ async function handleDefaultRSVP(
calendar: Calendar,
newEvent: CalendarEvent
) {
dispatch(putEventAsync({ cal: calendar, newEvent }));
await dispatch(putEventAsync({ cal: calendar, newEvent }))
}
export async function handleRSVP(
@@ -112,42 +112,42 @@ export async function handleRSVP(
) {
const newEvent = {
...event,
...updateEventAttendees(calendar, event, user, rsvp),
};
...updateEventAttendees(calendar, event, user, rsvp)
}
if (typeOfAction === "solo") {
await handleSoloRSVP(dispatch, calendar, newEvent);
} else if (typeOfAction === "all") {
if (typeOfAction === 'solo') {
await handleSoloRSVP(dispatch, calendar, newEvent)
} else if (typeOfAction === 'all') {
if (!user?.email) {
throw new Error("Cannot update all occurrences without user email");
throw new Error('Cannot update all occurrences without user email')
}
await handleAllRSVP(event, user.email, rsvp);
await handleAllRSVP(event, user.email, rsvp)
} else {
await handleDefaultRSVP(dispatch, calendar, newEvent);
await handleDefaultRSVP(dispatch, calendar, newEvent)
}
}
export function handleDelete(
isRecurring: boolean,
typeOfAction: "solo" | "all" | undefined,
onClose: (event: unknown, reason: "backdropClick" | "escapeKeyDown") => void,
typeOfAction: 'solo' | 'all' | undefined,
onClose: (event: unknown, reason: 'backdropClick' | 'escapeKeyDown') => void,
dispatch: AppDispatch,
calendar: Calendar,
event: CalendarEvent,
calId: string,
eventId: string
) {
onClose({}, "backdropClick");
onClose({}, 'backdropClick')
if (isRecurring && typeOfAction === "solo") {
dispatch(deleteEventInstanceAsync({ cal: calendar, event }));
if (isRecurring && typeOfAction === 'solo') {
dispatch(deleteEventInstanceAsync({ cal: calendar, event }))
} else {
dispatch(
deleteEventAsync({
calId,
eventId,
eventURL: event.URL,
eventURL: event.URL
})
);
)
}
}
+60 -60
View File
@@ -1,23 +1,23 @@
import React from "react";
import { combineDateTime } from "../utils/dateTimeHelpers";
import React from 'react'
import { combineDateTime } from '../utils/dateTimeHelpers'
/**
* Parameters for all-day toggle hook
*/
export interface AllDayToggleParams {
allday: boolean;
start: string;
end: string;
startDate: string;
startTime: string;
endDate: string;
endTime: string;
setStartTime: (time: string) => void;
setEndTime: (time: string) => void;
setStart: (start: string) => void;
setEnd: (end: string) => void;
setAllDay: (allday: boolean) => void;
onAllDayChange?: (allday: boolean, start: string, end: string) => void;
allday: boolean
start: string
end: string
startDate: string
startTime: string
endDate: string
endTime: string
setStartTime: (time: string) => void
setEndTime: (time: string) => void
setStart: (start: string) => void
setEnd: (end: string) => void
setAllDay: (allday: boolean) => void
onAllDayChange?: (allday: boolean, start: string, end: string) => void
}
/**
@@ -25,12 +25,12 @@ export interface AllDayToggleParams {
*/
export interface AllDayToggleHandlers {
originalTimeRef: React.MutableRefObject<{
start: string;
end: string;
endDate?: string;
fromAllDaySlot?: boolean;
} | null>;
handleAllDayToggle: () => void;
start: string
end: string
endDate?: string
fromAllDaySlot?: boolean
} | null>
handleAllDayToggle: () => void
}
/**
@@ -53,58 +53,58 @@ export function useAllDayToggle(
setStart,
setEnd,
setAllDay,
onAllDayChange,
} = params;
onAllDayChange
} = params
// Store original time before toggling to all-day
const originalTimeRef = React.useRef<{
start: string;
end: string;
endDate?: string;
fromAllDaySlot?: boolean;
} | null>(null);
start: string
end: string
endDate?: string
fromAllDaySlot?: boolean
} | null>(null)
const handleAllDayToggle = React.useCallback(() => {
const newAllDay = !allday;
let newStart = start;
let newEnd = end;
const newAllDay = !allday
let newStart = start
let newEnd = end
if (!newAllDay) {
const hasTimeParts = start.includes("T") && end.includes("T");
const hasTimeParts = start.includes('T') && end.includes('T')
if (!hasTimeParts && !startTime && !endTime) {
const now = new Date();
now.setSeconds(0);
now.setMilliseconds(0);
const nextHour = new Date(now);
nextHour.setMinutes(0);
nextHour.setHours(now.getHours() + 1);
const now = new Date()
now.setSeconds(0)
now.setMilliseconds(0)
const nextHour = new Date(now)
nextHour.setMinutes(0)
nextHour.setHours(now.getHours() + 1)
const startHours = String(nextHour.getHours()).padStart(2, "0");
const startMinutes = String(nextHour.getMinutes()).padStart(2, "0");
const startTimeStr = `${startHours}:${startMinutes}`;
const startHours = String(nextHour.getHours()).padStart(2, '0')
const startMinutes = String(nextHour.getMinutes()).padStart(2, '0')
const startTimeStr = `${startHours}:${startMinutes}`
const endHourDate = new Date(nextHour);
endHourDate.setHours(endHourDate.getHours() + 1);
const endHours = String(endHourDate.getHours()).padStart(2, "0");
const endMinutes = String(endHourDate.getMinutes()).padStart(2, "0");
const endTimeStr = `${endHours}:${endMinutes}`;
const endHourDate = new Date(nextHour)
endHourDate.setHours(endHourDate.getHours() + 1)
const endHours = String(endHourDate.getHours()).padStart(2, '0')
const endMinutes = String(endHourDate.getMinutes()).padStart(2, '0')
const endTimeStr = `${endHours}:${endMinutes}`
const startDateOnly = start.split("T")[0] || startDate;
const endDateOnly = end.split("T")[0] || endDate || startDateOnly;
newStart = combineDateTime(startDateOnly, startTimeStr);
newEnd = combineDateTime(endDateOnly, endTimeStr);
const startDateOnly = start.split('T')[0] || startDate
const endDateOnly = end.split('T')[0] || endDate || startDateOnly
newStart = combineDateTime(startDateOnly, startTimeStr)
newEnd = combineDateTime(endDateOnly, endTimeStr)
setStartTime(startTimeStr);
setEndTime(endTimeStr);
setStartTime(startTimeStr)
setEndTime(endTimeStr)
}
}
if (!onAllDayChange) {
setStart(newStart);
setEnd(newEnd);
setAllDay(newAllDay);
setStart(newStart)
setEnd(newEnd)
setAllDay(newAllDay)
} else {
onAllDayChange(newAllDay, newStart, newEnd);
onAllDayChange(newAllDay, newStart, newEnd)
}
}, [
allday,
@@ -119,11 +119,11 @@ export function useAllDayToggle(
setStart,
setEnd,
setAllDay,
onAllDayChange,
]);
onAllDayChange
])
return {
originalTimeRef,
handleAllDayToggle,
};
handleAllDayToggle
}
}
+17 -17
View File
@@ -1,12 +1,12 @@
// Date rules helpers to normalize end date/time behavior
import { combineDateTime } from "./dateTimeHelpers";
import { combineDateTime } from './dateTimeHelpers'
/** Adds a number of days to a YYYY-MM-DD string and returns YYYY-MM-DD */
export function addDays(dateStr: string, days: number): string {
const d = new Date(dateStr);
d.setDate(d.getDate() + days);
return d.toISOString().split("T")[0];
const d = new Date(dateStr)
d.setDate(d.getDate() + days)
return d.toISOString().split('T')[0]
}
/**
@@ -18,8 +18,8 @@ export function getEndDateForStartChange(
startDate: string,
isAllDay: boolean
): string {
if (!startDate) return "";
return isAllDay ? addDays(startDate, 1) : startDate;
if (!startDate) return ''
return isAllDay ? addDays(startDate, 1) : startDate
}
/**
@@ -32,23 +32,23 @@ export function getEndDateForStartChange(
* - end restored from originalEndDate if any, else previousEndDate, else start
*/
export function getEndDateForToggle(params: {
nextAllDay: boolean;
fromAllDaySlot?: boolean;
startDate: string;
previousEndDate: string;
originalEndDate?: string;
nextAllDay: boolean
fromAllDaySlot?: boolean
startDate: string
previousEndDate: string
originalEndDate?: string
}): string {
const {
nextAllDay,
fromAllDaySlot,
startDate,
previousEndDate,
originalEndDate,
} = params;
originalEndDate
} = params
if (nextAllDay) {
return fromAllDaySlot ? startDate : addDays(startDate, 1);
return fromAllDaySlot ? startDate : addDays(startDate, 1)
}
return originalEndDate || previousEndDate || startDate;
return originalEndDate || previousEndDate || startDate
}
/** Utility to combine date with a fallback time (HH:mm) safely */
@@ -57,6 +57,6 @@ export function combineWithFallback(
timeHHmm: string | undefined,
fallbackTime: string
): string {
const time = timeHHmm && timeHHmm.trim() ? timeHHmm : fallbackTime;
return combineDateTime(dateStr, time);
const time = timeHHmm && timeHHmm.trim() ? timeHHmm : fallbackTime
return combineDateTime(dateStr, time)
}
@@ -11,27 +11,27 @@
export function formatLocalDateTime(date: Date, timeZone?: string): string {
// Guard against invalid or undefined dates
if (!date || isNaN(date.getTime())) {
return "";
return ''
}
if (timeZone) {
const formatter = new Intl.DateTimeFormat("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
const formatter = new Intl.DateTimeFormat('en-CA', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone,
});
const formatted = formatter.format(date);
return formatted.replace(", ", "T");
timeZone
})
const formatted = formatter.format(date)
return formatted.replace(', ', 'T')
}
const pad = (n: number) => n.toString().padStart(2, "0");
const pad = (n: number) => n.toString().padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
date.getDate()
)}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
)}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
/**
@@ -45,24 +45,24 @@ export function formatDateTimeInTimezone(
timezone: string
): string {
// Parse the ISO string as UTC
const utcDate = new Date(isoString);
const utcDate = new Date(isoString)
// Format the date in the target timezone
const formatter = new Intl.DateTimeFormat("en-CA", {
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
const parts = formatter.formatToParts(utcDate);
const parts = formatter.formatToParts(utcDate)
const getValue = (type: string) =>
parts.find((p) => p.type === type)?.value || "";
parts.find(p => p.type === type)?.value || ''
return `${getValue("year")}-${getValue("month")}-${getValue("day")}T${getValue("hour")}:${getValue("minute")}`;
return `${getValue('year')}-${getValue('month')}-${getValue('day')}T${getValue('hour')}:${getValue('minute')}`
}
/**
@@ -70,14 +70,14 @@ export function formatDateTimeInTimezone(
* @returns Rounded Date object
*/
export function getRoundedCurrentTime(): Date {
const now = new Date();
const minutes = now.getMinutes();
const roundedMinutes = minutes < 30 ? 0 : 30;
now.setMinutes(roundedMinutes);
now.setSeconds(0);
now.setMilliseconds(0);
return now;
const now = new Date()
const minutes = now.getMinutes()
const roundedMinutes = minutes < 30 ? 0 : 30
now.setMinutes(roundedMinutes)
now.setSeconds(0)
now.setMilliseconds(0)
return now
}
/** Long date display format for date pickers */
export const LONG_DATE_FORMAT = "dddd, MMMM D, YYYY";
export const LONG_DATE_FORMAT = 'dddd, MMMM D, YYYY'
+41 -41
View File
@@ -1,21 +1,21 @@
import dayjs, { Dayjs } from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
import moment from "moment-timezone";
import dayjs, { Dayjs } from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import moment from 'moment-timezone'
dayjs.extend(customParseFormat);
dayjs.extend(customParseFormat)
/**
* Helper functions for date/time string manipulation
*/
export const DATETIME_WITH_SECONDS_LENGTH = 19;
export const DATETIME_FORMAT_WITH_SECONDS = "YYYY-MM-DDTHH:mm:ss";
export const DATETIME_FORMAT_WITHOUT_SECONDS = "YYYY-MM-DDTHH:mm";
export const DATETIME_WITH_SECONDS_LENGTH = 19
export const DATETIME_FORMAT_WITH_SECONDS = 'YYYY-MM-DDTHH:mm:ss'
export const DATETIME_FORMAT_WITHOUT_SECONDS = 'YYYY-MM-DDTHH:mm'
export const TIME_PARSE_FORMATS = ["HH:mm", "H:mm", "HHmm", "Hmm", "HH", "H"];
export const TIME_PARSE_FORMATS = ['HH:mm', 'H:mm', 'HHmm', 'Hmm', 'HH', 'H']
// Strict parsing mode - input must exactly match the provided format(s)
const STRICT_PARSING = true;
const STRICT_PARSING = true
/**
* Detect datetime format based on string length
@@ -25,7 +25,7 @@ const STRICT_PARSING = true;
export function detectDateTimeFormat(datetime: string): string {
return datetime.length >= DATETIME_WITH_SECONDS_LENGTH
? DATETIME_FORMAT_WITH_SECONDS
: DATETIME_FORMAT_WITHOUT_SECONDS;
: DATETIME_FORMAT_WITHOUT_SECONDS
}
/**
@@ -34,15 +34,15 @@ export function detectDateTimeFormat(datetime: string): string {
* @returns Object with date and time strings
*/
export function splitDateTime(datetime: string): {
date: string;
time: string;
date: string
time: string
} {
if (!datetime) return { date: "", time: "" };
const parts = datetime.split("T");
if (!datetime) return { date: '', time: '' }
const parts = datetime.split('T')
return {
date: parts[0] || "",
time: parts[1]?.slice(0, 5) || "", // HH:mm only
};
date: parts[0] || '',
time: parts[1]?.slice(0, 5) || '' // HH:mm only
}
}
/**
@@ -52,9 +52,9 @@ export function splitDateTime(datetime: string): {
* @returns Combined datetime string or date only if no time
*/
export function combineDateTime(date: string, time: string): string {
if (!date) return "";
if (!time) return date; // Date only for all-day
return `${date}T${time}`;
if (!date) return ''
if (!time) return date // Date only for all-day
return `${date}T${time}`
}
/**
@@ -65,32 +65,32 @@ export function convertFormDateTimeToISO(
datetime: string,
timezone: string
): string {
if (!datetime) return "";
const tz = timezone || "Etc/UTC";
const format = detectDateTimeFormat(datetime);
const momentDate = moment.tz(datetime, format, tz);
if (!datetime) return ''
const tz = timezone || 'Etc/UTC'
const format = detectDateTimeFormat(datetime)
const momentDate = moment.tz(datetime, format, tz)
if (!momentDate.isValid()) {
console.warn(
`[convertFormDateTimeToISO] Invalid datetime: "${datetime}" with format "${format}" in timezone "${tz}"`
);
return "";
)
return ''
}
return momentDate.toDate().toISOString();
return momentDate.toDate().toISOString()
}
/** Convert date + time strings → Dayjs */
export const toDateTime = (date: string, time: string): Dayjs => {
const d = dayjs(date, "YYYY-MM-DD", STRICT_PARSING);
if (!time) return d.startOf("day");
const [h, m] = time.split(":").map(Number);
return d.hour(h).minute(m).second(0).millisecond(0);
};
const d = dayjs(date, 'YYYY-MM-DD', STRICT_PARSING)
if (!time) return d.startOf('day')
const [h, m] = time.split(':').map(Number)
return d.hour(h).minute(m).second(0).millisecond(0)
}
/** Extract date “YYYY-MM-DD” */
export const dtDate = (d: Dayjs) => d.format("YYYY-MM-DD");
export const dtDate = (d: Dayjs) => d.format('YYYY-MM-DD')
/** Extract time “HH:mm” */
export const dtTime = (d: Dayjs) => d.format("HH:mm");
export const dtTime = (d: Dayjs) => d.format('HH:mm')
/**
* Parse flexible time string input into Dayjs object
@@ -101,25 +101,25 @@ export function parseTimeInput(
value: string,
currentDate: Dayjs | null
): Dayjs | null {
let trimmed = value.trim();
let trimmed = value.trim()
if (!trimmed) {
return null;
return null
}
// Normalize 3-digit input (e.g. "830" → "0830" → 08:30, "123" → "0123" → 01:23)
if (/^\d{3}$/.test(trimmed)) {
trimmed = trimmed.padStart(4, "0");
trimmed = trimmed.padStart(4, '0')
}
const parsed = dayjs(trimmed, TIME_PARSE_FORMATS, STRICT_PARSING);
const parsed = dayjs(trimmed, TIME_PARSE_FORMATS, STRICT_PARSING)
if (parsed.isValid()) {
const baseDate = currentDate || dayjs();
const baseDate = currentDate || dayjs()
return baseDate
.hour(parsed.hour())
.minute(parsed.minute())
.second(0)
.millisecond(0);
.millisecond(0)
}
return null;
return null
}
+50 -50
View File
@@ -1,17 +1,17 @@
import { AppDispatch } from "@/app/store";
import { emptyEventsCal } from "@/features/Calendars/CalendarSlice";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { AppDispatch } from '@/app/store'
import { emptyEventsCal } from '@/features/Calendars/CalendarSlice'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import {
getCalendarDetailAsync,
getCalendarsListAsync,
refreshCalendarWithSyncToken,
} from "@/features/Calendars/services";
import { userAttendee } from "@/features/User/models/attendee";
import { getInitials, stringToGradient } from "@/utils/avatarUtils";
import { formatDateToYYYYMMDDTHHMMSS } from "@/utils/dateUtils";
import { Avatar, Badge, Box, Typography } from "@linagora/twake-mui";
import CancelIcon from "@mui/icons-material/Cancel";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
refreshCalendarWithSyncToken
} from '@/features/Calendars/services'
import { userAttendee } from '@/features/User/models/attendee'
import { getInitials, stringToGradient } from '@/utils/avatarUtils'
import { formatDateToYYYYMMDDTHHMMSS } from '@/utils/dateUtils'
import { Avatar, Badge, Box, Typography } from '@linagora/twake-mui'
import CancelIcon from '@mui/icons-material/Cancel'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
export function renderAttendeeBadge(
a: userAttendee,
@@ -22,40 +22,40 @@ export function renderAttendeeBadge(
caption?: string
) {
const classIcon =
a.partstat === "ACCEPTED" ? (
a.partstat === 'ACCEPTED' ? (
<CheckCircleIcon fontSize="inherit" color="success" />
) : a.partstat === "DECLINED" ? (
) : a.partstat === 'DECLINED' ? (
<CancelIcon fontSize="inherit" color="error" />
) : null;
) : null
if (!isFull) {
return <Avatar key={key} {...stringAvatar(a.cn || a.cal_address)} />;
return <Avatar key={key} {...stringAvatar(a.cn || a.cal_address)} />
} else {
return (
<Box
key={key}
style={{
display: "flex",
alignItems: "center",
display: 'flex',
alignItems: 'center',
gap: 1.5,
marginBottom: 0.5,
padding: 0.5,
borderRadius: 1,
borderRadius: 1
}}
>
<Badge
overlap="circular"
sx={{ marginRight: 2 }}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
badgeContent={
classIcon && (
<Box
style={{
fontSize: 14,
lineHeight: 0,
backgroundColor: "white",
borderRadius: "50%",
padding: "1px",
backgroundColor: 'white',
borderRadius: '50%',
padding: '1px'
}}
>
{classIcon}
@@ -65,13 +65,13 @@ export function renderAttendeeBadge(
>
<Avatar {...stringAvatar(a.cn || a.cal_address)} />
</Badge>
<Box style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
<Box style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<Typography variant="body2" noWrap>
{a.cn || a.cal_address}
</Typography>
{isOrganizer && (
<Typography variant="caption" color="text.secondary">
{t("event.organizer")}
{t('event.organizer')}
</Typography>
)}
{caption && (
@@ -81,76 +81,76 @@ export function renderAttendeeBadge(
)}
</Box>
</Box>
);
)
}
}
export function stringToColor(string: string) {
let hash = 0;
let hash = 0
for (let i = 0; i < string.length; i++) {
hash = string.charCodeAt(i) + ((hash << 5) - hash);
hash = string.charCodeAt(i) + ((hash << 5) - hash)
}
let color = "#";
let color = '#'
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff;
color += `00${value.toString(16)}`.slice(-2);
const value = (hash >> (i * 8)) & 0xff
color += `00${value.toString(16)}`.slice(-2)
}
return color;
return color
}
export function stringAvatar(name: string) {
return {
color: stringToGradient(name),
children: getInitials(name),
};
children: getInitials(name)
}
}
export async function refreshCalendars(
dispatch: AppDispatch,
calendars: Calendar[],
calendarRange: {
start: Date;
end: Date;
start: Date
end: Date
},
calType?: "temp"
calType?: 'temp'
) {
if (process.env.NODE_ENV === "test") return;
if (process.env.NODE_ENV === 'test') return
if (!calType) {
await dispatch(getCalendarsListAsync());
await dispatch(getCalendarsListAsync())
}
const results = await Promise.allSettled(
calendars.map((calendar) =>
calendars.map(calendar =>
dispatch(
refreshCalendarWithSyncToken({ calendar, calType, calendarRange })
).unwrap()
)
);
)
results.forEach((result, index) => {
if (result.status === "rejected") {
if (result.status === 'rejected') {
console.error(
`Failed to refresh calendar ${calendars[index].id}:`,
result.reason
);
)
}
});
})
}
export async function refreshSingularCalendar(
dispatch: AppDispatch,
calendar: Calendar,
calendarRange: { start: Date; end: Date },
calType?: "temp"
calType?: 'temp'
) {
const isTestEnv = process.env.NODE_ENV === "test";
dispatch(emptyEventsCal({ calId: calendar.id, calType }));
const isTestEnv = process.env.NODE_ENV === 'test'
dispatch(emptyEventsCal({ calId: calendar.id, calType }))
if (isTestEnv) {
return;
return
}
await dispatch(
@@ -158,9 +158,9 @@ export async function refreshSingularCalendar(
calId: calendar.id,
match: {
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end)
},
calType,
calType
})
);
)
}
+61 -61
View File
@@ -1,27 +1,27 @@
import { combineDateTime } from "./dateTimeHelpers";
import { combineDateTime } from './dateTimeHelpers'
/**
* Validation parameters for event form
*/
export interface ValidationParams {
startDate: string;
startTime: string;
endDate: string;
endTime: string;
allday: boolean;
showValidationErrors: boolean;
hasEndDateChanged?: boolean;
showMore?: boolean;
startDate: string
startTime: string
endDate: string
endTime: string
allday: boolean
showValidationErrors: boolean
hasEndDateChanged?: boolean
showMore?: boolean
}
/**
* Validation result for event form
*/
export interface ValidationResult {
isValid: boolean;
isValid: boolean
errors: {
dateTime: string;
};
dateTime: string
}
}
/**
@@ -38,102 +38,102 @@ export function validateEventForm(params: ValidationParams): ValidationResult {
allday,
showValidationErrors,
hasEndDateChanged = false,
showMore = false,
} = params;
showMore = false
} = params
let isDateTimeValid = true;
let dateTimeError = "";
let isDateTimeValid = true
let dateTimeError = ''
// Determine which fields are visible based on UI mode
const showFullFields =
showMore ||
allday ||
hasEndDateChanged ||
(!showMore && !allday && startDate !== endDate);
const showTimeOnly = !allday && !showFullFields;
(!showMore && !allday && startDate !== endDate)
const showTimeOnly = !allday && !showFullFields
// Validate start date
if (!startDate || startDate.trim() === "") {
isDateTimeValid = false;
dateTimeError = "Start date is required";
if (!startDate || startDate.trim() === '') {
isDateTimeValid = false
dateTimeError = 'Start date is required'
}
// Validate start time (if not all-day)
else if (!allday && (!startTime || startTime.trim() === "")) {
isDateTimeValid = false;
dateTimeError = "Start time is required";
else if (!allday && (!startTime || startTime.trim() === '')) {
isDateTimeValid = false
dateTimeError = 'Start time is required'
}
// Validate end fields based on UI mode
else if (showFullFields) {
// 4 fields mode: validate both end date and end time
if (!endDate || endDate.trim() === "") {
isDateTimeValid = false;
dateTimeError = "End date is required";
} else if (!allday && (!endTime || endTime.trim() === "")) {
isDateTimeValid = false;
dateTimeError = "End time is required";
if (!endDate || endDate.trim() === '') {
isDateTimeValid = false
dateTimeError = 'End date is required'
} else if (!allday && (!endTime || endTime.trim() === '')) {
isDateTimeValid = false
dateTimeError = 'End time is required'
} else {
// Validate total datetime
if (allday) {
const toLocalDate = (ymd: string) => {
const [y, m, d] = ymd.split("-").map((v) => parseInt(v, 10));
if (!y || !m || !d) return new Date(NaN);
return new Date(y, m - 1, d);
};
const [y, m, d] = ymd.split('-').map(v => parseInt(v, 10))
if (!y || !m || !d) return new Date(NaN)
return new Date(y, m - 1, d)
}
const startOnly = toLocalDate(startDate);
const endOnly = toLocalDate(endDate);
const startOnly = toLocalDate(startDate)
const endOnly = toLocalDate(endDate)
if (isNaN(startOnly.getTime()) || isNaN(endOnly.getTime())) {
isDateTimeValid = false;
dateTimeError = "Invalid date";
isDateTimeValid = false
dateTimeError = 'Invalid date'
} else if (endOnly < startOnly) {
isDateTimeValid = false;
dateTimeError = "End date must be on or after start date";
isDateTimeValid = false
dateTimeError = 'End date must be on or after start date'
}
} else {
const startDateTime = new Date(combineDateTime(startDate, startTime));
const endDateTime = new Date(combineDateTime(endDate, endTime));
const startDateTime = new Date(combineDateTime(startDate, startTime))
const endDateTime = new Date(combineDateTime(endDate, endTime))
if (isNaN(startDateTime.getTime()) || isNaN(endDateTime.getTime())) {
isDateTimeValid = false;
dateTimeError = "Invalid date/time";
isDateTimeValid = false
dateTimeError = 'Invalid date/time'
} else if (endDateTime <= startDateTime) {
isDateTimeValid = false;
dateTimeError = "End date/time must be after start date/time";
isDateTimeValid = false
dateTimeError = 'End date/time must be after start date/time'
}
}
}
} else if (showTimeOnly) {
// 3 fields mode: validate time only (end time > start time, same day)
if (!endTime || endTime.trim() === "") {
isDateTimeValid = false;
dateTimeError = "End time is required";
if (!endTime || endTime.trim() === '') {
isDateTimeValid = false
dateTimeError = 'End time is required'
} else {
// Compare times only (same day is assumed)
const startTimeParts = startTime.split(":");
const endTimeParts = endTime.split(":");
const startTimeParts = startTime.split(':')
const endTimeParts = endTime.split(':')
if (startTimeParts.length === 2 && endTimeParts.length === 2) {
const startMinutes =
parseInt(startTimeParts[0]) * 60 + parseInt(startTimeParts[1]);
parseInt(startTimeParts[0]) * 60 + parseInt(startTimeParts[1])
const endMinutes =
parseInt(endTimeParts[0]) * 60 + parseInt(endTimeParts[1]);
parseInt(endTimeParts[0]) * 60 + parseInt(endTimeParts[1])
if (endMinutes <= startMinutes) {
isDateTimeValid = false;
dateTimeError = "End time must be after start time";
isDateTimeValid = false
dateTimeError = 'End time must be after start time'
}
} else {
isDateTimeValid = false;
dateTimeError = "Invalid time format";
isDateTimeValid = false
dateTimeError = 'Invalid time format'
}
}
}
const isValid = isDateTimeValid;
const isValid = isDateTimeValid
return {
isValid,
errors: {
dateTime: showValidationErrors ? dateTimeError : "",
},
};
dateTime: showValidationErrors ? dateTimeError : ''
}
}
}