[#17] added event counter popup (#665)

Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
Camille Moussu
2026-03-25 11:14:16 +01:00
committed by GitHub
parent fcccb1fc55
commit 35cd128106
11 changed files with 405 additions and 25 deletions
@@ -89,7 +89,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
onEndDateChange,
onEndTimeChange,
}) => {
const { t } = useI18n();
const { t, lang } = useI18n();
const initialDurationRef = React.useRef<number | null>(null);
const isUserActionRef = React.useRef(false);
@@ -341,8 +341,9 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
return (
<LocalizationProvider
key={lang}
dateAdapter={AdapterDayjs}
adapterLocale={t("locale") ?? "en"}
adapterLocale={lang ?? "en"}
localeText={{
okButtonLabel: t("common.ok"),
cancelButtonLabel: t("common.cancel"),
@@ -4,6 +4,7 @@ import { Box, Typography } from "@linagora/twake-mui";
import { Dispatch, SetStateAction, useState } from "react";
import { useI18n } from "twake-i18n";
import { ContextualizedEvent } from "../EventsTypes";
import { EventCounterModal } from "./EventCounterModal";
import { RSVPButton } from "./RSVPButton";
interface AttendanceValidationProps {
@@ -25,8 +26,8 @@ export function AttendanceValidation({
const { t } = useI18n();
const [isLoading, setIsLoading] = useState(false);
const [loadingValue, setLoadingValue] = useState<PartStat | null>(null);
const [openCounterModal, setOpenCounterModal] = useState(false);
// Check if we should show RSVP buttons
const hasNoAttendeesOrOrganizer =
!(contextualizedEvent.event?.attendee?.length > 0) &&
!contextualizedEvent.event?.organizer;
@@ -68,16 +69,30 @@ export function AttendanceValidation({
return (
<>
<Typography sx={{ marginRight: 2 }}>
<Typography variant="body2" sx={{ marginRight: 1 }}>
{calendar.owner?.resource
? t("eventPreview.authorizeQuestion")
: t("eventPreview.attendingQuestion")}
</Typography>
<Box display="flex" gap="15px" alignItems="center">
<Box display="flex" gap={1} mx={1} alignItems="center">
<RSVPButton rsvpValue="ACCEPTED" {...commonButtonProps} />
<RSVPButton rsvpValue="TENTATIVE" {...commonButtonProps} />
<RSVPButton rsvpValue="DECLINED" {...commonButtonProps} />
<RSVPButton rsvpValue="TENTATIVE" {...commonButtonProps} />
</Box>
{!contextualizedEvent.isOrganizer && (
<Typography
variant="body2"
onClick={() => setOpenCounterModal(!openCounterModal)}
sx={{ marginLeft: 1, cursor: "pointer" }}
>
{t("eventPreview.proposeNewTime")}
</Typography>
)}
<EventCounterModal
open={openCounterModal}
setOpen={setOpenCounterModal}
contextualizedEvent={contextualizedEvent}
/>
</>
);
}
@@ -0,0 +1,243 @@
import { formatEventChipTitle } from "@/components/Calendar/utils/calendarUtils";
import { ResponsiveDialog } from "@/components/Dialog";
import { DateTimeFields } from "@/components/Event/components/DateTimeFields";
import { FieldWithLabel } from "@/components/Event/components/FieldWithLabel";
import { splitDateTime } from "@/components/Event/utils/dateTimeHelpers";
import { Box, Button, TextField, Typography } from "@linagora/twake-mui";
import moment from "moment-timezone";
import { useEffect, useState } from "react";
import { useI18n } from "twake-i18n";
import { postCounterProposal } from "../api/sendCounterProposal";
import { EventTimeSubtitle } from "../EventPreview/EventTimeSubtitle";
import { ContextualizedEvent } from "../EventsTypes";
export function EventCounterModal({
open,
setOpen,
contextualizedEvent,
}: {
open: boolean;
setOpen: (b: boolean) => void;
contextualizedEvent: ContextualizedEvent;
}) {
const { t } = useI18n();
const [isSubmitting, setIsSubmitting] = useState(false);
const allday = contextualizedEvent.event.allday ?? false;
const timezone = contextualizedEvent.event.timezone;
const startSplit = splitDateTime(
moment
.tz(contextualizedEvent.event.start, timezone)
.format("YYYY-MM-DDTHH:mm")
);
const endSplit = splitDateTime(
moment
.tz(
contextualizedEvent.event.end ?? contextualizedEvent.event.start,
timezone
)
.format("YYYY-MM-DDTHH:mm")
);
const [startDate, setStartDate] = useState(startSplit.date);
const [startTime, setStartTime] = useState(startSplit.time);
const [endDate, setEndDate] = useState(endSplit.date);
const [endTime, setEndTime] = useState(endSplit.time);
const [showMore, setShowMore] = useState(false);
const [hasEndDateChanged, setHasEndDateChanged] = useState(false);
const [message, setMessage] = useState("");
const [validation, setValidation] = useState<{
errors: { dateTime: string };
}>({
errors: { dateTime: "" },
});
const handleStartDateChange = (value: string) => {
setStartDate(value);
if (value > endDate) {
setEndDate(value);
setHasEndDateChanged(true);
}
setValidation({ errors: { dateTime: "" } });
};
const handleStartTimeChange = (value: string) => {
setStartTime(value);
setValidation({ errors: { dateTime: "" } });
};
const handleEndDateChange = (value: string) => {
setEndDate(value);
setHasEndDateChanged(true);
setValidation({ errors: { dateTime: "" } });
};
const handleEndTimeChange = (value: string) => {
setEndTime(value);
setValidation({ errors: { dateTime: "" } });
};
const validate = (): boolean => {
if (!startDate || !endDate) {
setValidation({
errors: { dateTime: t("event.validation.startRequired") },
});
return false;
}
if (!allday && (!startTime || !endTime)) {
setValidation({
errors: { dateTime: t("event.validation.startRequired") },
});
return false;
}
if (
endDate < startDate ||
(!allday && endDate === startDate && endTime <= startTime)
) {
setValidation({
errors: { dateTime: t("event.validation.endAfterStart") },
});
return false;
}
setValidation({ errors: { dateTime: "" } });
return true;
};
const handleSubmit = async () => {
if (!validate()) return;
if (
!contextualizedEvent.currentUserAttendee?.cal_address ||
!contextualizedEvent.event.organizer?.cal_address
) {
setValidation({ errors: { dateTime: t("error.unknown") } });
return;
}
setIsSubmitting(true);
try {
await postCounterProposal({
event: contextualizedEvent.event,
senderEmail: contextualizedEvent.currentUserAttendee.cal_address,
recipientEmail: contextualizedEvent.event.organizer.cal_address,
proposedStart: contextualizedEvent.event.allday
? startDate
: `${startDate}T${startTime}`,
proposedEnd: contextualizedEvent.event.allday
? endDate
: `${endDate}T${endTime}`,
message,
});
setOpen(false);
} catch (error) {
console.error(error);
setValidation({ errors: { dateTime: t("error.unknown") } });
} finally {
setIsSubmitting(false);
}
};
useEffect(() => {
if (!open) return;
setStartDate(startSplit.date);
setStartTime(startSplit.time);
setEndDate(endSplit.date);
setEndTime(endSplit.time);
setShowMore(false);
setHasEndDateChanged(false);
setMessage("");
setValidation({ errors: { dateTime: "" } });
}, [open, startSplit.date, startSplit.time, endSplit.date, endSplit.time]);
return (
<ResponsiveDialog
open={open}
onClose={() => setOpen(false)}
title={t("eventPreview.proposeNewTime")}
>
{/* Event title */}
<Box display="flex" alignItems="center" gap={1} mb={2}>
<Typography
variant="h3"
sx={{
wordBreak: "break-word",
}}
>
{formatEventChipTitle(contextualizedEvent.event, t)}
</Typography>
</Box>
{/* Current event time */}
<EventTimeSubtitle
event={contextualizedEvent.event}
t={t}
timezone={contextualizedEvent.event.timezone}
/>
{/* Your proposal label */}
<FieldWithLabel label={t("eventPreview.yourProposal")} isExpanded={false}>
<DateTimeFields
startDate={startDate}
startTime={startTime}
endDate={endDate}
endTime={endTime}
allday={allday}
showMore={showMore}
hasEndDateChanged={hasEndDateChanged}
validation={validation}
onStartDateChange={handleStartDateChange}
onStartTimeChange={handleStartTimeChange}
onEndDateChange={handleEndDateChange}
onEndTimeChange={handleEndTimeChange}
showEndDate={
showMore ||
allday ||
(hasEndDateChanged && startDate !== endDate) ||
(!showMore && !allday && startDate !== endDate)
}
onToggleEndDate={() => setShowMore((prev) => !prev)}
/>
</FieldWithLabel>
{/* Optional message */}
<Box mt={2}>
<TextField
margin="dense"
multiline
size="small"
minRows={2}
maxRows={10}
fullWidth
placeholder={t("eventPreview.optionalMessage")}
value={message}
onChange={(e) => setMessage(e.target.value)}
sx={{
mt: 2,
"& .MuiInputBase-root": {
overflowY: "auto",
padding: 0,
},
"& textarea": {
resize: "vertical",
},
}}
/>
</Box>
{/* Actions */}
<Box display="flex" justifyContent="flex-end" gap={2} mt={3}>
<Button variant="text" onClick={() => setOpen(false)}>
{t("common.cancel")}
</Button>
<Button
variant="contained"
color="primary"
onClick={handleSubmit}
disabled={isSubmitting}
>
{t("eventPreview.sendProposal")}
</Button>
</Box>
</ResponsiveDialog>
);
}
@@ -2,7 +2,6 @@ import { CalendarName } from "@/components/Calendar/CalendarName";
import { formatEventChipTitle } from "@/components/Calendar/utils/calendarUtils";
import ResponsiveDialog from "@/components/Dialog/ResponsiveDialog";
import { EditModeDialog } from "@/components/Event/EditModeDialog";
import { getTimezoneOffset } from "@/utils/timezone";
import { DateSelectArg } from "@fullcalendar/core";
import { Box, Chip, Tooltip, Typography } from "@linagora/twake-mui";
import CircleIcon from "@mui/icons-material/Circle";
@@ -16,8 +15,7 @@ import { EventPreviewDetails } from "../EventPreview/EventPreviewDetails";
import { EventPreviewHeader } from "../EventPreview/EventPreviewHeader";
import { useEventPreviewState } from "../EventPreview/useEventPreviewState";
import EventUpdateModal from "../EventUpdateModal";
import { formatDate } from "./utils/formatDate";
import { formatEnd } from "./utils/formatEnd";
import { EventTimeSubtitle } from "./EventTimeSubtitle";
export default function EventPreviewModal({
eventId,
@@ -160,12 +158,7 @@ export default function EventPreviewModal({
</Tooltip>
)}
</Box>
<Typography color="text.secondaryContainer">
{formatDate(event.start, t, timezone, event.allday)}
{event.end &&
formatEnd(event.start, event.end, t, timezone, event.allday) &&
` ${formatEnd(event.start, event.end, t, timezone, event.allday)} ${!event.allday ? getTimezoneOffset(timezone, new Date(event.start)) : ""}`}
</Typography>
<EventTimeSubtitle event={event} t={t} timezone={timezone} />
</Box>
{/* Event details (attendees, location, description, etc.) */}
@@ -0,0 +1,27 @@
import { getTimezoneOffset } from "@/utils/timezone";
import { Typography } from "@linagora/twake-mui";
import { CalendarEvent } from "../EventsTypes";
import { formatDate } from "./utils/formatDate";
import { formatEnd } from "./utils/formatEnd";
export function EventTimeSubtitle({
event,
t,
timezone,
}: {
event: CalendarEvent;
t: (k: string, p?: string | object | undefined) => string;
timezone: string;
}) {
const formattedEnd = event.end
? formatEnd(event.start, event.end, t, timezone, event.allday)
: null;
return (
<Typography color="text.secondaryContainer">
{formatDate(event.start, t, timezone, event.allday)}
{formattedEnd &&
` ${formattedEnd} ${!event.allday ? getTimezoneOffset(timezone, new Date(event.start)) : ""}`}
</Typography>
);
}
@@ -0,0 +1,84 @@
import { api } from "@/utils/apiUtils";
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
import { TIMEZONES } from "@/utils/timezone-data";
import ICAL from "ical.js";
import { CalendarEvent } from "../EventsTypes";
import { makeTimezone, makeVevent } from "../utils";
export async function postCounterProposal({
event,
senderEmail,
recipientEmail,
proposedStart,
proposedEnd,
message,
}: {
event: CalendarEvent;
senderEmail: string;
recipientEmail: string;
proposedStart: string;
proposedEnd: string;
message?: string;
}): Promise<Response> {
// Build the counter event with proposed dates
const counterEvent: CalendarEvent = {
...event,
start: proposedStart,
end: proposedEnd,
sequence: event.sequence ?? 0,
};
// Build vevent jCal
const vevent = makeVevent(
counterEvent,
counterEvent.timezone,
senderEmail,
!event.recurrenceId
);
if (message) {
vevent?.[1]?.push(["comment", {}, "text", message]);
}
// Build vtimezone
const timezoneData = TIMEZONES.zones[counterEvent.timezone];
const vtimezone = makeTimezone(timezoneData, counterEvent);
// Assemble full vcalendar with METHOD:COUNTER
const jcal = [
"vcalendar",
[
["version", {}, "text", "2.0"],
["prodid", {}, "text", "-//OpenPaaS//OpenPaaS//EN"],
["method", {}, "text", "COUNTER"],
],
[vevent, vtimezone.component.jCal],
];
// Serialize to raw ICS
const counterICS = new ICAL.Component(jcal).toString();
const postResponse = await api(`dav${event.URL}`, {
method: "POST",
body: JSON.stringify({
ical: counterICS,
sender: senderEmail,
recipient: recipientEmail,
uid: extractEventBaseUuid(event.uid),
sequence: counterEvent.sequence,
method: "COUNTER",
}),
headers: {
accept: "application/json,text/plain,*/*",
"content-type": "application/calendar+json",
Prefer: "return=representation",
"X-Http-Method-Override": "ITIP",
},
});
if (!postResponse.ok) {
throw new Error(
`postCounterProposal failed with status ${postResponse.status}`
);
}
return postResponse;
}
+4 -3
View File
@@ -9,9 +9,6 @@ export function createEventContext(
): ContextualizedEvent {
const isOwn = calendar.owner?.emails?.includes(user.email) ?? false;
const isRecurring = event?.uid?.includes("/") ?? false;
const isOrganizer = event.organizer
? user?.email === event.organizer.cal_address
: isOwn;
const attendeeEmail = calendar.delegated
? calendar.owner?.emails?.[0]
: user.email;
@@ -20,6 +17,10 @@ export function createEventContext(
(person) => person.cutype === "RESOURCE" && person.cn === calendar.name
)
: event.attendee?.find((person) => person.cal_address === attendeeEmail);
const isOrganizer = event.organizer
? attendeeEmail === event.organizer.cal_address
: isOwn;
return {
event,
calendar,
+6 -2
View File
@@ -282,9 +282,13 @@
},
"attendingQuestion": "Attending?",
"authorizeQuestion": "Authorize?",
"ACCEPTED": "Accept",
"proposeNewTime": "New time",
"yourProposal": "Your proposal",
"optionalMessage": "Add optional message to the organizer",
"sendProposal": "Send proposal",
"ACCEPTED": "Yes",
"TENTATIVE": "Maybe",
"DECLINED": "Decline",
"DECLINED": "No",
"NEEDS-ACTION": "Pending",
"attendingStatus": {
"ACCEPTED": "Accepted",
+6 -2
View File
@@ -283,9 +283,13 @@
},
"attendingQuestion": "Participer ?",
"authorizeQuestion": "Autoriser ?",
"ACCEPTED": "Accepter",
"proposeNewTime": "Nouvel horaire",
"yourProposal": "Votre proposition",
"optionalMessage": "Ajouter un message optionnel à l'organisateur",
"sendProposal": "Envoyer la proposition",
"ACCEPTED": "Oui",
"TENTATIVE": "Peut-être",
"DECLINED": "Décliner",
"DECLINED": "Non",
"NEEDS-ACTION": "En attente",
"attendingStatus": {
"ACCEPTED": "Accepté",
+4
View File
@@ -283,6 +283,10 @@
},
"attendingQuestion": "Присоединитесь?",
"authorizeQuestion": "Авторизовать?",
"proposeNewTime": "Предложить новое время",
"yourProposal": "Ваше предложение",
"optionalMessage": "Добавить необязательное сообщение организатору",
"sendProposal": "Отправить предложение",
"ACCEPTED": "Да",
"TENTATIVE": "Возможно",
"DECLINED": "Нет",
+7 -3
View File
@@ -279,11 +279,15 @@
"label": "Rảnh",
"tooltip": "Người khác sẽ thấy bạn rảnh trong khoảng thời gian này."
},
"attendingQuestion": "Tham gia?",
"attendingQuestion": "Tham dự?",
"authorizeQuestion": "Cho phép?",
"ACCEPTED": "Chấp nhận",
"proposeNewTime": "Hẹn lại",
"yourProposal": "Đề xuất của bạn",
"optionalMessage": "Thêm tin nhắn tùy chọn cho người tổ chức",
"sendProposal": "Gửi đề xuất",
"ACCEPTED": "Có",
"TENTATIVE": "Có thể",
"DECLINED": "Từ chối",
"DECLINED": "Không",
"NEEDS-ACTION": "Đang chờ",
"attendingStatus": {
"ACCEPTED": "Đã chấp nhận",