* #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:
@@ -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}
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 : ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user