feat: Update Event Preview Modal UI with custom MUI theme (#240)

- Add custom MUI theme
- Update ResponsiveDialog with actionsBorderTop prop for modal footer border
- Redesign EventDisplayPreview modal with improved typography and spacing
- Replace ButtonGroup with individual RSVP buttons (Accept/Maybe/Decline)
- Add pill-shaped buttons for RSVP actions
- Ensure backward compatibility with existing modals
This commit is contained in:
lenhanphung
2025-10-27 12:23:42 +07:00
committed by GitHub
parent 51c9d68be2
commit 4774e78543
12 changed files with 358 additions and 118 deletions
+4 -25
View File
@@ -62,8 +62,9 @@ const preloadedState = {
};
describe("EventDuplication", () => {
it("opens EventPopover when button clicked", () => {
it("calls onOpenDuplicate when button clicked", () => {
const handleClose = jest.fn();
const onOpenDuplicate = jest.fn();
renderWithProviders(
<EventDuplication
event={
@@ -71,36 +72,14 @@ describe("EventDuplication", () => {
.event1
}
onClose={handleClose}
onOpenDuplicate={onOpenDuplicate}
/>,
preloadedState
);
fireEvent.click(screen.getByRole("menuitem", { name: /Duplicate event/i }));
expect(screen.getAllByText(/Duplicate Event/i)[1]).toBeInTheDocument();
});
it("calls onClose when closing the popover", () => {
const handleClose = jest.fn();
renderWithProviders(
<EventDuplication
event={
preloadedState.calendars.list["667037022b752d0026472254/cal1"].events
.event1
}
onClose={handleClose}
/>,
preloadedState
);
fireEvent.click(screen.getByRole("menuitem", { name: /Duplicate event/i }));
// Cancel button only appears in expanded mode
fireEvent.click(screen.getByRole("button", { name: /More options/i }));
fireEvent.click(screen.getByRole("button", { name: /Cancel/i }));
expect(handleClose).toHaveBeenCalled();
expect(onOpenDuplicate).toHaveBeenCalled();
});
});
+14 -10
View File
@@ -8,18 +8,22 @@ import { Loading } from "./components/Loading/Loading";
import HandleLogin from "./features/User/HandleLogin";
import CalendarLayout from "./components/Calendar/CalendarLayout";
import { Error } from "./components/Error/Error";
import { CustomThemeProvider } from "./theme/ThemeProvider";
function App() {
return (
<Suspense fallback={<Loading />}>
<Router history={history}>
<Routes>
<Route path="/" element={<HandleLogin />} />
<Route path="/calendar" element={<CalendarLayout />} />
<Route path="/callback" element={<CallbackResume />} />
<Route path="/error" element={<Error />} />
</Routes>
</Router>
</Suspense>
<CustomThemeProvider>
<Suspense fallback={<Loading />}>
<Router history={history}>
<Routes>
<Route path="/" element={<HandleLogin />} />
<Route path="/calendar" element={<CalendarLayout />} />
<Route path="/callback" element={<CallbackResume />} />
<Route path="/error" element={<Error />} />
</Routes>
</Router>
</Suspense>
</CustomThemeProvider>
);
}
+4 -2
View File
@@ -66,17 +66,19 @@ export function TimezoneSelector({ value, onChange }: TimezoneSelectProps) {
}}
slotProps={{
paper: {
sx: { width: 280, maxHeight: 400, overflow: "auto", p: 1 },
sx: { width: 280, maxHeight: 400, overflow: "hidden", p: 0 },
},
}}
>
<TimezoneAutocomplete
size="medium"
value={effectiveTimezone}
onChange={onChange}
zones={timezoneList.zones}
getTimezoneOffset={getTimezoneOffset}
autoFocus={true}
inputFontSize="10px"
showIcon={true}
inputFontSize="14px"
inputPadding="2px 4px"
onClose={handleClose}
disableClearable={true}
+24 -1
View File
@@ -78,6 +78,14 @@ interface ResponsiveDialogProps
dividers?: boolean;
/** Whether to show header action icons (expand/close) in normal mode (default: true) */
showHeaderActions?: boolean;
/** Whether to add border-top to DialogActions */
actionsBorderTop?: boolean;
/** Justify content alignment for DialogActions */
actionsJustifyContent?:
| "flex-start"
| "center"
| "flex-end"
| "space-between";
}
function ResponsiveDialog({
@@ -99,6 +107,8 @@ function ResponsiveDialog({
dialogTitleProps,
dividers = false,
showHeaderActions = true,
actionsBorderTop = false,
actionsJustifyContent = "flex-end",
sx,
...otherDialogProps
}: ResponsiveDialogProps) {
@@ -199,7 +209,20 @@ function ResponsiveDialog({
<Stack spacing={currentSpacing}>{children}</Stack>
)}
</DialogContent>
{actions && <DialogActions>{actions}</DialogActions>}
{actions && (
<DialogActions
sx={{
borderTop: actionsBorderTop
? (theme) => `1px solid ${theme.palette.divider}`
: undefined,
justifyContent: actionsJustifyContent,
paddingTop: "18px",
paddingBottom: "18px",
}}
>
{actions}
</DialogActions>
)}
</Dialog>
);
}
+10 -39
View File
@@ -1,51 +1,22 @@
import { IconButton, MenuItem } from "@mui/material";
import { MenuItem } from "@mui/material";
import { CalendarEvent } from "../../features/Events/EventsTypes";
import AddToPhotosIcon from "@mui/icons-material/AddToPhotos";
import { useRef, useState } from "react";
import EventPopover from "../../features/Events/EventModal";
import { CalendarApi, DateSelectArg } from "@fullcalendar/core";
export default function EventDuplication({
onClose,
event,
onOpenDuplicate,
}: {
onClose: Function;
event: CalendarEvent;
onOpenDuplicate?: () => void;
}) {
const [openModal, setOpenModal] = useState(false);
const [selectedRange, setSelectedRange] = useState<DateSelectArg | null>({
start: new Date(event.start),
startStr: event.start,
end: new Date(event.end ?? ""),
endStr: event.end ?? "",
allDay: event.allday ?? false,
} as DateSelectArg);
const calendarRef = useRef<CalendarApi | null>(null);
const handleClosePopover = () => {
calendarRef.current?.unselect();
setSelectedRange(null);
setOpenModal(!openModal);
onClose({}, "backdropClick");
};
return (
<>
<MenuItem
onClick={() => {
setOpenModal(true);
}}
>
Duplicate event
</MenuItem>
<EventPopover
anchorEl={null}
open={openModal}
selectedRange={selectedRange}
setSelectedRange={setSelectedRange}
calendarRef={calendarRef}
onClose={handleClosePopover}
event={event}
/>
</>
<MenuItem
onClick={() => {
onOpenDuplicate?.();
}}
>
Duplicate event
</MenuItem>
);
}
+3 -1
View File
@@ -77,12 +77,14 @@ export function InfoRow({
<Typography
variant="body2"
color={error ? "error" : "textPrimary"}
style={{
sx={{
wordBreak: "break-word",
whiteSpace: "pre-line",
maxHeight: "33vh",
overflowY: "auto",
width: "100%",
fontSize: "16px",
fontFamily: "'Inter', sans-serif",
}}
>
{data ? (
+2 -6
View File
@@ -44,6 +44,7 @@ export function renderAttendeeBadge(
>
<Badge
overlap="circular"
sx={{ marginRight: 2 }}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
badgeContent={
classIcon && (
@@ -65,7 +66,6 @@ export function renderAttendeeBadge(
</Badge>
<Box style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
<Typography
variant="body2"
noWrap
style={{
maxWidth: "180px",
@@ -76,11 +76,7 @@ export function renderAttendeeBadge(
{a.cn || a.cal_address}
</Typography>
{isOrganizer && (
<Typography
variant="caption"
color="text.secondary"
style={{ fontStyle: "italic" }}
>
<Typography variant="caption" color="text.secondary">
Organizer
</Typography>
)}
+119 -31
View File
@@ -12,13 +12,13 @@ import SubjectIcon from "@mui/icons-material/Subject";
import VideocamIcon from "@mui/icons-material/Videocam";
import RepeatIcon from "@mui/icons-material/Repeat";
import LockOutlineIcon from "@mui/icons-material/LockOutline";
import EventPopover from "./EventModal";
import { DateSelectArg } from "@fullcalendar/core";
import {
Box,
Button,
ButtonGroup,
Chip,
DialogActions,
Divider,
IconButton,
Menu,
MenuItem,
@@ -77,6 +77,7 @@ export default function EventPreviewModal({
const [showAllAttendees, setShowAllAttendees] = useState(false);
const [openFullDisplay, setOpenFullDisplay] = useState(false);
const [openUpdateModal, setOpenUpdateModal] = useState(false);
const [openDuplicateModal, setOpenDuplicateModal] = useState(false);
const [hidePreview, setHidePreview] = useState(false);
const [openEditModePopup, setOpenEditModePopup] = useState<string | null>(
null
@@ -136,6 +137,8 @@ export default function EventPreviewModal({
open={open && !hidePreview}
onClose={() => onClose({}, "backdropClick")}
showHeaderActions={false}
actionsBorderTop={true}
actionsJustifyContent="center"
style={{ overflow: "auto" }}
title={
event.title && (
@@ -210,7 +213,15 @@ export default function EventPreviewModal({
Email attendees
</MenuItem>
)}
<EventDuplication event={event} onClose={onClose} />
<EventDuplication
event={event}
onClose={() => setToggleActionMenu(null)}
onOpenDuplicate={() => {
setToggleActionMenu(null);
setHidePreview(true);
setOpenDuplicateModal(true);
}}
/>
{user.userData.email === event.organizer?.cal_address && (
<MenuItem
onClick={async () => {
@@ -270,10 +281,12 @@ export default function EventPreviewModal({
<LockOutlineIcon />
))}
<Typography
variant="inherit"
fontWeight="bold"
style={{
variant="h5"
sx={{
fontSize: "24px",
fontWeight: 600,
wordBreak: "break-word",
fontFamily: "Inter, sans-serif",
}}
gutterBottom
>
@@ -293,7 +306,7 @@ export default function EventPreviewModal({
</Tooltip>
)}
</Box>
<Typography variant="body2" color="textSecondary" gutterBottom>
<Typography color="text.secondaryContainer" gutterBottom>
{formatDate(event.start, event.allday)}
{event.end &&
formatEnd(event.start, event.end, event.allday) &&
@@ -305,15 +318,22 @@ export default function EventPreviewModal({
actions={
<>
{currentUserAttendee && (
<Box style={{ display: "flex", flexDirection: "row" }}>
<Typography variant="body2">Attending?</Typography>
<ButtonGroup size="small" fullWidth>
<>
<Typography sx={{ marginRight: 2 }}>Attending?</Typography>
<Box display="flex" gap="15px" alignItems="center">
<Button
variant={
currentUserAttendee?.partstat === "ACCEPTED"
? "contained"
: "outlined"
}
color={
currentUserAttendee?.partstat === "ACCEPTED"
? "success"
: "primary"
}
size="large"
sx={{ borderRadius: "50px" }}
onClick={() => {
if (isRecurring) {
setAfterChoiceFunc(
@@ -346,11 +366,18 @@ export default function EventPreviewModal({
Accept
</Button>
<Button
variant={
currentUserAttendee?.partstat === "TENTATIVE"
? "contained"
: "outlined"
}
color={
currentUserAttendee?.partstat === "TENTATIVE"
? "warning"
: "primary"
}
size="large"
sx={{ borderRadius: "50px" }}
onClick={() => {
if (isRecurring) {
setAfterChoiceFunc(
@@ -382,11 +409,18 @@ export default function EventPreviewModal({
Maybe
</Button>
<Button
variant={
currentUserAttendee?.partstat === "DECLINED"
? "contained"
: "outlined"
}
color={
currentUserAttendee?.partstat === "DECLINED"
? "error"
: "primary"
}
size="large"
sx={{ borderRadius: "50px" }}
onClick={() => {
if (isRecurring) {
setAfterChoiceFunc(
@@ -417,8 +451,8 @@ export default function EventPreviewModal({
>
Decline
</Button>
</ButtonGroup>
</Box>
</Box>
</>
)}
</>
}
@@ -428,7 +462,11 @@ export default function EventPreviewModal({
{/* Video */}
{event.x_openpass_videoconference && (
<InfoRow
icon={<VideocamIcon style={{ fontSize: 18 }} />}
icon={
<Box sx={{ minWidth: "25px", marginRight: 2 }}>
<VideocamIcon />
</Box>
}
content={
<Button
variant="contained"
@@ -445,7 +483,11 @@ export default function EventPreviewModal({
{attendees?.length > 0 && (
<>
<InfoRow
icon={<PeopleAltOutlinedIcon />}
icon={
<Box sx={{ minWidth: "25px", marginRight: 2 }}>
<PeopleAltOutlinedIcon />
</Box>
}
content={
<Box
style={{
@@ -454,9 +496,11 @@ export default function EventPreviewModal({
flexDirection: "row",
}}
>
<Box>
<Box sx={{ marginRight: 2 }}>
<Typography>{attendees.length} guests</Typography>
<Typography>
<Typography
sx={{ fontSize: "13px", color: "text.secondary" }}
>
{
attendees.filter((a) => a.partstat === "ACCEPTED")
.length
@@ -488,9 +532,13 @@ export default function EventPreviewModal({
</AvatarGroup>
)}
<Typography
variant="body2"
color="primary"
style={{ cursor: "pointer", marginTop: 0.5 }}
sx={{
cursor: "pointer",
marginLeft: 2,
fontSize: "14px",
color: "text.secondary",
alignSelf: "center",
}}
onClick={() => setShowAllAttendees(!showAllAttendees)}
>
{showAllAttendees ? "Show less" : `Show more `}
@@ -510,25 +558,44 @@ export default function EventPreviewModal({
{/* Location */}
{event.location && (
<InfoRow
icon={<LocationOnOutlinedIcon />}
icon={
<Box sx={{ minWidth: "25px", marginRight: 2 }}>
<LocationOnOutlinedIcon />
</Box>
}
text={event.location}
/>
)}
{/* Description */}
{event.description && (
<InfoRow icon={<SubjectIcon />} text={event.description} />
<InfoRow
icon={
<Box sx={{ minWidth: "25px", marginRight: 2 }}>
<SubjectIcon />
</Box>
}
text={event.description}
/>
)}
{/* ALARM */}
{event.alarm && (
<InfoRow
icon={<NotificationsNoneIcon />}
icon={
<Box sx={{ minWidth: "25px", marginRight: 2 }}>
<NotificationsNoneIcon />
</Box>
}
text={`${event.alarm.trigger} before by ${event.alarm.action}`}
/>
)}
{/* Repetition */}
{event.repetition && (
<InfoRow
icon={<RepeatIcon />}
icon={
<Box sx={{ minWidth: "25px", marginRight: 2 }}>
<RepeatIcon />
</Box>
}
text={makeRecurrenceString(event)}
/>
)}
@@ -560,7 +627,11 @@ export default function EventPreviewModal({
{/* Error */}
{event.error && (
<InfoRow
icon={<ErrorOutlineIcon color="error" style={{ fontSize: 18 }} />}
icon={
<Box sx={{ minWidth: "25px", marginRight: 2 }}>
<ErrorOutlineIcon color="error" />
</Box>
}
text={event.error}
error
/>
@@ -574,7 +645,9 @@ export default function EventPreviewModal({
marginBottom: 2,
}}
>
<CalendarTodayIcon style={{ fontSize: 16 }} />
<Box sx={{ minWidth: "25px", marginRight: 2 }}>
<CalendarTodayIcon />
</Box>
<CircleIcon
style={{
color: calendar.color?.light ?? "#3788D8",
@@ -582,13 +655,8 @@ export default function EventPreviewModal({
height: 12,
}}
/>
<Typography variant="body2">{calendar.name}</Typography>
<Typography>{calendar.name}</Typography>
</Box>
{currentUserAttendee && !event.repetition && (
<>
<Divider variant="fullWidth" />
</>
)}
</ResponsiveDialog>
<EditModeDialog
type={openEditModePopup}
@@ -620,6 +688,26 @@ export default function EventPreviewModal({
calId={calId}
typeOfAction={typeOfAction}
/>
<EventPopover
anchorEl={null}
open={openDuplicateModal}
selectedRange={
{
start: new Date(event.start),
startStr: event.start,
end: new Date(event.end ?? ""),
endStr: event.end ?? "",
allDay: event.allday ?? false,
} as DateSelectArg
}
setSelectedRange={() => {}}
calendarRef={{ current: null }}
onClose={() => {
setOpenDuplicateModal(false);
onClose({}, "backdropClick"); // Đóng cả preview modal
}}
event={event}
/>
</>
);
}
+3 -3
View File
@@ -104,7 +104,7 @@ function EventPopover({
);
const [attendees, setAttendees] = useState<userAttendee[]>(
event?.attendee
? event.attendee.filter((a) => a.cal_address !== organizer.cal_address)
? event.attendee.filter((a) => a.cal_address !== organizer?.cal_address)
: []
);
const [alarm, setAlarm] = useState(event?.alarm?.trigger ?? "");
@@ -482,8 +482,8 @@ function EventPopover({
timezone,
attendee: [
{
cn: organizer.cn,
cal_address: organizer.cal_address,
cn: organizer?.cn ?? "",
cal_address: organizer?.cal_address ?? "",
partstat: "ACCEPTED",
rsvp: "FALSE",
role: "CHAIR",
+118
View File
@@ -0,0 +1,118 @@
# Custom MUI Theme
## Overview
This project uses a custom MUI theme to customize Typography, colors and components according to Twake Calendar design.
## File Structure
```
src/theme/
├── theme.ts # Custom theme definition
├── ThemeProvider.tsx # ThemeProvider wrapper
└── README.md # This documentation
```
## Custom Theme Features
### Colors
- **Text Primary**: `#000000` (Black)
- **Text Secondary**: `#717D96` (Custom gray)
- **Text Secondary Container**: `#243B55` (Dark gray)
### Typography
- **Caption**: fontSize 13px, color `#717D96`
### Components
- **Typography**: Custom styled caption variant
## Usage
### Using CustomThemeProvider
Import and use `CustomThemeProvider` to wrap your application:
```tsx
import { CustomThemeProvider } from "./theme/ThemeProvider";
function App() {
return <CustomThemeProvider>{/* Your application */}</CustomThemeProvider>;
}
```
### Accessing the Theme
Use `useTheme` to access theme variables:
```tsx
import { useTheme } from "@mui/material/styles";
function MyComponent() {
const theme = useTheme();
const textColor = theme.palette.text.secondary;
}
```
### CSS Variables
Use theme variables directly in CSS:
```tsx
<Typography sx={{ color: "text.secondary" }}>
Text with secondary color
</Typography>
```
### Typography
Use predefined typography variants:
```tsx
<Typography variant="caption">Caption text</Typography>
```
### Customizing Styles
Modify styles in `theme.ts` to customize components:
```typescript
components: {
MuiTypography: {
styleOverrides: {
caption: {
fontSize: "13px",
color: "#717D96",
},
},
},
}
```
## Migration to Cozy UI
### Installation
```bash
npm install cozy-ui
```
### Replacing ThemeProvider
In your main file, replace `CustomThemeProvider` with `MuiCozyTheme`:
```tsx
import { MuiCozyTheme } from "cozy-ui/React/MuiCozyTheme";
<MuiCozyTheme>{children}</MuiCozyTheme>;
```
### Updating Palette
Adapt the color palette in `theme.ts` to use Cozy UI colors. Cozy UI provides its own colors that can be integrated into the theme configuration.
### Component Migration
Update components to use Cozy UI components instead of standard MUI components, following Cozy UI documentation for specific components.
+19
View File
@@ -0,0 +1,19 @@
import React from "react";
import { ThemeProvider } from "@mui/material/styles";
import { CssBaseline } from "@mui/material";
import { customTheme } from "./theme";
interface CustomThemeProviderProps {
children: React.ReactNode;
}
export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
return (
<ThemeProvider theme={customTheme}>
<CssBaseline />
{children}
</ThemeProvider>
);
}
export { customTheme };
+38
View File
@@ -0,0 +1,38 @@
import { createTheme } from "@mui/material/styles";
// Custom theme for Twake Calendar
export const customTheme = createTheme({
palette: {
text: {
primary: "#000000",
secondary: "#717D96",
secondaryContainer: "#243B55",
},
},
typography: {
caption: {
fontSize: "0.75rem",
fontWeight: 400,
lineHeight: 1.4,
color: "#8C9CAF", // Custom secondary text color
},
},
components: {
// Custom Typography component
MuiTypography: {
styleOverrides: {
// Custom variant for event info text
caption: {
fontSize: "13px",
color: "#717D96",
},
},
},
// Custom Button component
// Custom Dialog component
// Custom DialogActions component
},
});
// Export theme type for TypeScript
export type CustomTheme = typeof customTheme;