Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
import { Card, Typography } from "@mui/material";
|
||||
import { EventErrorHandler } from "../../Error/EventErrorHandler";
|
||||
|
||||
export function ErrorEventChip({
|
||||
event,
|
||||
errorHandler,
|
||||
error,
|
||||
}: {
|
||||
event: any;
|
||||
errorHandler: EventErrorHandler;
|
||||
error: any;
|
||||
}) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unknown error during rendering";
|
||||
errorHandler.reportError(event._def.extendedProps.uid || event.id, message);
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
px: 0.5,
|
||||
py: 0.2,
|
||||
borderRadius: "4px",
|
||||
boxShadow: "none",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">{event.title}</Typography>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { stringAvatar } from "../utils/eventUtils";
|
||||
import { ErrorEventChip } from "./ErrorEventChip";
|
||||
import {
|
||||
DisplayedIcons,
|
||||
EventChipProps,
|
||||
getBestColor,
|
||||
getCardStyle,
|
||||
getEventDuration,
|
||||
getEventTimes,
|
||||
getOwnerAttendee,
|
||||
getTitleStyle,
|
||||
IconDisplayConfig,
|
||||
} from "./EventChipUtils";
|
||||
import { SimpleEventChip } from "./SimpleEventChip";
|
||||
|
||||
const COMPACT_WIDTH_THRESHOLD = 100;
|
||||
const PRIVATE_CLASSIFICATIONS = ["PRIVATE", "CONFIDENTIAL"];
|
||||
|
||||
export const EVENT_DURATION = {
|
||||
SHORT: 15,
|
||||
MEDIUM: 30,
|
||||
LONG: 60,
|
||||
} as const;
|
||||
|
||||
function useCompactMode(
|
||||
cardRef: React.RefObject<HTMLDivElement | null>
|
||||
): boolean {
|
||||
const [showCompact, setShowCompact] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkWidth = () => {
|
||||
if (cardRef.current) {
|
||||
const width = cardRef.current.offsetWidth;
|
||||
setShowCompact(width < COMPACT_WIDTH_THRESHOLD);
|
||||
}
|
||||
};
|
||||
|
||||
checkWidth();
|
||||
|
||||
const resizeObserver = new ResizeObserver(checkWidth);
|
||||
if (cardRef.current) {
|
||||
resizeObserver.observe(cardRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return showCompact;
|
||||
}
|
||||
|
||||
export function EventChip({
|
||||
arg,
|
||||
calendars,
|
||||
tempcalendars,
|
||||
errorHandler,
|
||||
}: EventChipProps) {
|
||||
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;
|
||||
|
||||
try {
|
||||
// Calendar validation
|
||||
const calendarsSource = temp ? tempcalendars : calendars;
|
||||
const calendar = calendarsSource[calId];
|
||||
if (!calendar) return null;
|
||||
|
||||
// Event properties
|
||||
const isPrivate = PRIVATE_CLASSIFICATIONS.includes(classification);
|
||||
const ownerEmails = new Set(
|
||||
calendar.ownerEmails?.map((e) => e.toLowerCase())
|
||||
);
|
||||
const delegated = calendar.delegated;
|
||||
|
||||
// Determine owner attendee
|
||||
const ownerAttendee = getOwnerAttendee(attendees, ownerEmails);
|
||||
|
||||
// Handle non-owner attendee case
|
||||
if (attendees.length && !delegated && !ownerAttendee) {
|
||||
return <SimpleEventChip title={event.title} />;
|
||||
}
|
||||
|
||||
// Color and contrast logic
|
||||
const bestColor = getBestColor(
|
||||
(calendar.color as { light: string; dark: string }) ?? {
|
||||
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,
|
||||
};
|
||||
|
||||
// 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 isMoreThan15 = eventLength > EVENT_DURATION.SHORT;
|
||||
const isMoreThan30 = eventLength > EVENT_DURATION.MEDIUM;
|
||||
const isMoreThan60 = eventLength > EVENT_DURATION.LONG;
|
||||
|
||||
// Style calculation
|
||||
const titleStyle = getTitleStyle(
|
||||
bestColor,
|
||||
ownerAttendee?.partstat,
|
||||
calendar
|
||||
);
|
||||
const cardStyle = getCardStyle(
|
||||
bestColor,
|
||||
eventLength,
|
||||
ownerAttendee?.partstat,
|
||||
calendar
|
||||
);
|
||||
|
||||
// Organizer avatar
|
||||
const OrganizerAvatar = event._def.extendedProps.organizer
|
||||
? stringAvatar(
|
||||
event._def.extendedProps.organizer?.cn ??
|
||||
event._def.extendedProps.organizer?.cal_address
|
||||
)
|
||||
: { style: {}, children: null };
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="outlined"
|
||||
style={
|
||||
!isMoreThan15 || isMonthView
|
||||
? { ...cardStyle, height: "auto" }
|
||||
: { ...cardStyle }
|
||||
}
|
||||
ref={cardRef}
|
||||
data-testid={`event-card-${event._def.extendedProps.uid}`}
|
||||
>
|
||||
<CardHeader
|
||||
sx={{
|
||||
py: "0px",
|
||||
px: "0px",
|
||||
"& .MuiCardHeader-content": {
|
||||
overflow: "hidden",
|
||||
},
|
||||
}}
|
||||
title={
|
||||
showCompact ? (
|
||||
<Typography variant="body2" style={titleStyle}>
|
||||
{event.title}
|
||||
</Typography>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{ display: "flex", alignItems: "center", minWidth: 0 }}
|
||||
>
|
||||
{(!isMoreThan30 || isMonthView) &&
|
||||
!event._def.extendedProps.allday && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="compactStartTime"
|
||||
style={{
|
||||
...titleStyle,
|
||||
textDecoration: "none",
|
||||
overflow: "visible",
|
||||
opacity: "70%",
|
||||
letterSpacing: "0%",
|
||||
fontSize: "10px",
|
||||
marginRight: "4px",
|
||||
}}
|
||||
>
|
||||
{startTime}
|
||||
</Typography>
|
||||
)}
|
||||
{DisplayedIcons(IconDisplayed)}
|
||||
<Typography variant="body2" noWrap style={titleStyle}>
|
||||
{event.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
subheader={
|
||||
isMoreThan30 &&
|
||||
!isMonthView &&
|
||||
!event._def.extendedProps.allday && (
|
||||
<Typography
|
||||
style={{
|
||||
color: titleStyle.color,
|
||||
opacity: "70%",
|
||||
fontFamily: "Inter",
|
||||
fontWeight: "500",
|
||||
fontSize: "10px",
|
||||
lineHeight: "16px",
|
||||
letterSpacing: "0%",
|
||||
verticalAlign: "middle",
|
||||
}}
|
||||
>
|
||||
{startTime} {!showCompact && ` - ${endTime}`}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{isMoreThan60 &&
|
||||
!showCompact &&
|
||||
!isMonthView &&
|
||||
!event._def.extendedProps.allday && (
|
||||
<CardContent
|
||||
sx={{
|
||||
p: 0,
|
||||
"& .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",
|
||||
color: titleStyle.color,
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{event._def.extendedProps.location}
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ display: "flex", alignItems: "flex-start", mt: 0.5 }}>
|
||||
{event._def.extendedProps.description && (
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: "Roboto",
|
||||
fontWeight: 500,
|
||||
fontSize: "10px",
|
||||
lineHeight: "16px",
|
||||
letterSpacing: "0%",
|
||||
opacity: 0.8,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
whiteSpace: "normal",
|
||||
flex: 1,
|
||||
maxWidth: "75%",
|
||||
}}
|
||||
>
|
||||
{event._def.extendedProps.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
)}
|
||||
{(isMoreThan60 || eventLength === 60) &&
|
||||
!isMonthView &&
|
||||
!event._def.extendedProps.allday &&
|
||||
event._def.extendedProps.organizer &&
|
||||
!showCompact &&
|
||||
(window as any).displayOrgAvatar && (
|
||||
<Avatar
|
||||
children={OrganizerAvatar.children}
|
||||
style={{
|
||||
...OrganizerAvatar.style,
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
margin: "8px",
|
||||
position: "absolute",
|
||||
border: "2px solid white",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
} catch (e) {
|
||||
return (
|
||||
<ErrorEventChip event={event} errorHandler={errorHandler} error={e} />
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import CancelIcon from "@mui/icons-material/Cancel";
|
||||
import HelpOutlineIcon from "@mui/icons-material/HelpOutline";
|
||||
import LockOutlineIcon from "@mui/icons-material/LockOutline";
|
||||
import { Box, getContrastRatio } from "@mui/material";
|
||||
import moment from "moment";
|
||||
import React from "react";
|
||||
import { Calendars } from "../../../features/Calendars/CalendarTypes";
|
||||
import { userAttendee } from "../../../features/User/userDataTypes";
|
||||
import { EventErrorHandler } from "../../Error/EventErrorHandler";
|
||||
import { EVENT_DURATION } from "./EventChip";
|
||||
|
||||
export interface EventChipProps {
|
||||
arg: any;
|
||||
calendars: Record<string, Calendars>;
|
||||
tempcalendars: Record<string, Calendars>;
|
||||
errorHandler: EventErrorHandler;
|
||||
}
|
||||
export interface IconDisplayConfig {
|
||||
declined: boolean;
|
||||
tentative: boolean;
|
||||
needAction: boolean;
|
||||
private: boolean;
|
||||
}
|
||||
export function getEventDuration(event: any): number {
|
||||
return moment(event._instance.range.end).diff(
|
||||
moment(event._instance.range.start),
|
||||
"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;
|
||||
}
|
||||
export function getEventTimes(event: any, timeZone: string) {
|
||||
return {
|
||||
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())
|
||||
);
|
||||
}
|
||||
|
||||
export function getTitleStyle(
|
||||
bestColor: string,
|
||||
partstat?: string,
|
||||
calendar?: any
|
||||
): 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,
|
||||
};
|
||||
|
||||
switch (partstat) {
|
||||
case "DECLINED":
|
||||
return { ...baseStyle, textDecoration: "line-through" };
|
||||
case "TENTATIVE":
|
||||
case "ACCEPTED":
|
||||
return { ...baseStyle, color: calendar?.color?.dark };
|
||||
case "NEEDS-ACTION":
|
||||
return baseStyle;
|
||||
default:
|
||||
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";
|
||||
}
|
||||
|
||||
function getCardVariantStyle(
|
||||
variant: string,
|
||||
baseColor: string
|
||||
): React.CSSProperties {
|
||||
const shared: React.CSSProperties = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "none",
|
||||
border: `1px solid ${baseColor}`,
|
||||
color: baseColor,
|
||||
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" };
|
||||
default:
|
||||
return {
|
||||
...shared,
|
||||
padding: "4px 6px",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getCardStyle(
|
||||
bestColor: string,
|
||||
eventLength: number,
|
||||
partstat?: string,
|
||||
calendar?: any
|
||||
): React.CSSProperties {
|
||||
const baseStyle: React.CSSProperties = getCardVariantStyle(
|
||||
getEventVariant(eventLength),
|
||||
bestColor
|
||||
);
|
||||
|
||||
switch (partstat) {
|
||||
case "DECLINED":
|
||||
return baseStyle;
|
||||
case "TENTATIVE":
|
||||
return {
|
||||
...baseStyle,
|
||||
backgroundColor: calendar?.color?.light,
|
||||
color: calendar?.color?.dark,
|
||||
border: "1px solid white",
|
||||
};
|
||||
case "NEEDS-ACTION":
|
||||
return {
|
||||
...baseStyle,
|
||||
backgroundColor: "#fff",
|
||||
};
|
||||
case "ACCEPTED":
|
||||
return {
|
||||
...baseStyle,
|
||||
backgroundColor: calendar?.color?.light,
|
||||
color: calendar?.color?.dark,
|
||||
border: "1px solid white",
|
||||
};
|
||||
default:
|
||||
return baseStyle;
|
||||
}
|
||||
}
|
||||
|
||||
export function DisplayedIcons(IconDisplayed: IconDisplayConfig) {
|
||||
if (!Object.values(IconDisplayed).find((b) => b === true)) return;
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: "1px",
|
||||
marginRight: "4px",
|
||||
}}
|
||||
>
|
||||
{IconDisplayed.needAction && (
|
||||
<HelpOutlineIcon style={{ fontSize: "15px" }} />
|
||||
)}
|
||||
{IconDisplayed.declined && (
|
||||
<CancelIcon color="error" style={{ fontSize: "15px" }} />
|
||||
)}
|
||||
{IconDisplayed.tentative && (
|
||||
<HelpOutlineIcon style={{ fontSize: "15px" }} />
|
||||
)}
|
||||
{IconDisplayed.private && (
|
||||
<LockOutlineIcon style={{ fontSize: "15px" }} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
import { Card, Typography } from "@mui/material";
|
||||
|
||||
export function SimpleEventChip({ title }: { title: string }) {
|
||||
return (
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: "4px",
|
||||
px: 0.5,
|
||||
py: 0.2,
|
||||
boxShadow: "none",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ type InfoRowProps = {
|
||||
error?: boolean;
|
||||
data?: string; // optional link target
|
||||
content?: React.ReactNode; // if provided, overrides text rendering
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
function detectUrls(text: string) {
|
||||
@@ -60,6 +61,7 @@ export function InfoRow({
|
||||
error = false,
|
||||
data,
|
||||
content,
|
||||
style,
|
||||
}: InfoRowProps) {
|
||||
return (
|
||||
<Box
|
||||
@@ -83,8 +85,7 @@ export function InfoRow({
|
||||
maxHeight: "33vh",
|
||||
overflowY: "auto",
|
||||
width: "100%",
|
||||
fontSize: "16px",
|
||||
fontFamily: "'Inter', sans-serif",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{data ? (
|
||||
|
||||
Reference in New Issue
Block a user