feat: redesign event modal with responsive dialog and improved layout
Implemented a new ResponsiveDialog component and redesigned EventModal with better UX for both normal and extended modes. New Features: - Created reusable ResponsiveDialog component (src/components/Dialog/) * Normal mode: 685px centered popup * Extended mode: fullscreen with 90px header preservation * Auto spacing via MUI Stack (16px normal, 24px extended) * Back arrow navigation in extended mode * No backdrop/shadow in extended mode for seamless integration * Configurable props for all dimensions and behaviors EventModal Improvements: - Replaced native checkbox with MUI Checkbox component - Migrated from Popover to ResponsiveDialog - Reorganized field layout => Comprehensive documentation in Dialog/README.md Note: RepeatEvent integration tests need additional refactoring (tracked separately)
This commit is contained in:
@@ -99,7 +99,7 @@ async function setupEventPopover(
|
|||||||
preloadedState
|
preloadedState
|
||||||
);
|
);
|
||||||
act(() => {
|
act(() => {
|
||||||
fireEvent.change(screen.getByLabelText("Title"), {
|
fireEvent.change(screen.getByRole("textbox", { name: /title/i }), {
|
||||||
target: { value: "Meeting" },
|
target: { value: "Meeting" },
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByLabelText("All day"));
|
fireEvent.click(screen.getByLabelText("All day"));
|
||||||
@@ -113,7 +113,10 @@ async function setupEventPopover(
|
|||||||
value: (overrides?.end ?? "2025-07-19T00:00:00.000Z").split("T")[0],
|
value: (overrides?.end ?? "2025-07-19T00:00:00.000Z").split("T")[0],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByText("Show More"));
|
const showMoreButton = screen.queryByText("Show More");
|
||||||
|
if (showMoreButton) {
|
||||||
|
fireEvent.click(showMoreButton);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
const select = screen.getByLabelText(/repetition/i);
|
const select = screen.getByLabelText(/repetition/i);
|
||||||
userEvent.click(select);
|
userEvent.click(select);
|
||||||
@@ -124,7 +127,8 @@ async function setupEventPopover(
|
|||||||
async function expectRRule(expected: any) {
|
async function expectRRule(expected: any) {
|
||||||
const spyAPi = jest.spyOn(apiUtils, "api");
|
const spyAPi = jest.spyOn(apiUtils, "api");
|
||||||
|
|
||||||
act(() => fireEvent.click(screen.getByText("Save")));
|
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||||
|
act(() => fireEvent.click(saveButton));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(spyAPi).toHaveBeenCalled();
|
expect(spyAPi).toHaveBeenCalled();
|
||||||
@@ -214,7 +218,8 @@ describe("Repeat Event API calls", () => {
|
|||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(eventThunks, "putEventAsync")
|
.spyOn(eventThunks, "putEventAsync")
|
||||||
.mockImplementation((payload) => () => Promise.resolve(payload) as any);
|
.mockImplementation((payload) => () => Promise.resolve(payload) as any);
|
||||||
act(() => fireEvent.click(screen.getByText("Save")));
|
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||||
|
act(() => fireEvent.click(saveButton));
|
||||||
await waitFor(() => expect(spy).toHaveBeenCalled());
|
await waitFor(() => expect(spy).toHaveBeenCalled());
|
||||||
|
|
||||||
const received = spy.mock.calls[0][0];
|
const received = spy.mock.calls[0][0];
|
||||||
@@ -223,12 +228,14 @@ describe("Repeat Event API calls", () => {
|
|||||||
);
|
);
|
||||||
expect(received.newEvent.title).toBe("Meeting");
|
expect(received.newEvent.title).toBe("Meeting");
|
||||||
expect(
|
expect(
|
||||||
formatDateToYYYYMMDDTHHMMSS(received.newEvent.start).split("T")[0]
|
formatDateToYYYYMMDDTHHMMSS(new Date(received.newEvent.start)).split(
|
||||||
).toBe("20250718");
|
|
||||||
expect(
|
|
||||||
formatDateToYYYYMMDDTHHMMSS(received.newEvent.end || new Date()).split(
|
|
||||||
"T"
|
"T"
|
||||||
)[0]
|
)[0]
|
||||||
|
).toBe("20250718");
|
||||||
|
expect(
|
||||||
|
formatDateToYYYYMMDDTHHMMSS(
|
||||||
|
new Date(received.newEvent.end || new Date())
|
||||||
|
).split("T")[0]
|
||||||
).toBe("20250719");
|
).toBe("20250719");
|
||||||
expect(received.newEvent.organizer).toEqual(
|
expect(received.newEvent.organizer).toEqual(
|
||||||
preloadedState.user.organiserData
|
preloadedState.user.organiserData
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import React from "react";
|
||||||
|
import { ResponsiveDialog } from "../../src/components/Dialog";
|
||||||
|
import { Button, TextField } from "@mui/material";
|
||||||
|
|
||||||
|
describe("ResponsiveDialog", () => {
|
||||||
|
const mockOnClose = jest.fn();
|
||||||
|
const mockOnExpandToggle = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockOnClose.mockClear();
|
||||||
|
mockOnExpandToggle.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders in normal mode by default", () => {
|
||||||
|
render(
|
||||||
|
<ResponsiveDialog open={true} onClose={mockOnClose} title="Test Dialog">
|
||||||
|
<TextField label="Name" />
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Test Dialog")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders title in normal mode", () => {
|
||||||
|
render(
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
title="My Title"
|
||||||
|
isExpanded={false}
|
||||||
|
>
|
||||||
|
<div>Content</div>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("My Title")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText("show less")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders back arrow in extended mode", () => {
|
||||||
|
render(
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
title="My Title"
|
||||||
|
isExpanded={true}
|
||||||
|
onExpandToggle={mockOnExpandToggle}
|
||||||
|
>
|
||||||
|
<div>Content</div>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText("My Title")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("show less")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onExpandToggle when back arrow is clicked", () => {
|
||||||
|
render(
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
title="Test"
|
||||||
|
isExpanded={true}
|
||||||
|
onExpandToggle={mockOnExpandToggle}
|
||||||
|
>
|
||||||
|
<div>Content</div>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
const backButton = screen.getByLabelText("show less");
|
||||||
|
fireEvent.click(backButton);
|
||||||
|
|
||||||
|
expect(mockOnExpandToggle).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders actions when provided", () => {
|
||||||
|
render(
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
title="Test"
|
||||||
|
actions={<Button>Custom Action</Button>}
|
||||||
|
>
|
||||||
|
<div>Content</div>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Custom Action")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render actions when not provided", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ResponsiveDialog open={true} onClose={mockOnClose} title="Test">
|
||||||
|
<div>Content</div>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialogActions = container.querySelector(".MuiDialogActions-root");
|
||||||
|
expect(dialogActions).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose when backdrop is clicked", () => {
|
||||||
|
render(
|
||||||
|
<ResponsiveDialog open={true} onClose={mockOnClose} title="Test">
|
||||||
|
<div>Content</div>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
const backdrop = document.querySelector(".MuiBackdrop-root");
|
||||||
|
if (backdrop) {
|
||||||
|
fireEvent.click(backdrop);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies custom normalMaxWidth", () => {
|
||||||
|
render(
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
title="Test"
|
||||||
|
normalMaxWidth="800px"
|
||||||
|
>
|
||||||
|
<div>Normal Width Content</div>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Normal Width Content")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps children in Stack component", () => {
|
||||||
|
render(
|
||||||
|
<ResponsiveDialog open={true} onClose={mockOnClose} title="Test">
|
||||||
|
<TextField label="Field 1" />
|
||||||
|
<TextField label="Field 2" />
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Field 1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Field 2")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses correct spacing in normal mode", () => {
|
||||||
|
render(
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
title="Test"
|
||||||
|
isExpanded={false}
|
||||||
|
normalSpacing={2}
|
||||||
|
>
|
||||||
|
<div>Normal Spacing Content</div>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Normal Spacing Content")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses correct spacing in extended mode", () => {
|
||||||
|
render(
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
title="Test"
|
||||||
|
isExpanded={true}
|
||||||
|
expandedSpacing={3}
|
||||||
|
>
|
||||||
|
<div>Extended Spacing Content</div>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Extended Spacing Content")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies contentSx custom styles", () => {
|
||||||
|
render(
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
title="Test"
|
||||||
|
contentSx={{ padding: 4 }}
|
||||||
|
>
|
||||||
|
<div>Custom Styled Content</div>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Custom Styled Content")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies titleSx custom styles", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
title="Test"
|
||||||
|
titleSx={{ color: "red" }}
|
||||||
|
>
|
||||||
|
<div>Content</div>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
const title = screen.getByText("Test");
|
||||||
|
expect(title).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows dividers when dividers prop is true", () => {
|
||||||
|
render(
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
title="Test"
|
||||||
|
dividers={true}
|
||||||
|
>
|
||||||
|
<div>Content with Dividers</div>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Content with Dividers")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show back arrow when onExpandToggle is not provided", () => {
|
||||||
|
render(
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
title="Test Title"
|
||||||
|
isExpanded={true}
|
||||||
|
>
|
||||||
|
<div>Content</div>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByLabelText("show less")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Test Title")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts custom headerHeight", () => {
|
||||||
|
render(
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
title="Test"
|
||||||
|
isExpanded={true}
|
||||||
|
headerHeight="100px"
|
||||||
|
>
|
||||||
|
<div>Custom Header Content</div>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Custom Header Content")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders with custom expandedContentMaxWidth", () => {
|
||||||
|
render(
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
title="Test"
|
||||||
|
isExpanded={true}
|
||||||
|
expandedContentMaxWidth="1200px"
|
||||||
|
>
|
||||||
|
<div>Wide Content</div>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Wide Content")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render dialog content when open is false", () => {
|
||||||
|
render(
|
||||||
|
<ResponsiveDialog open={false} onClose={mockOnClose} title="Test">
|
||||||
|
<div>Test Content</div>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText("Test Content")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders correctly in extended mode", () => {
|
||||||
|
render(
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
title="Test"
|
||||||
|
isExpanded={true}
|
||||||
|
onExpandToggle={mockOnExpandToggle}
|
||||||
|
>
|
||||||
|
<div>Extended Content</div>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Extended Content")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("show less")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -109,22 +109,35 @@ describe("EventPopover", () => {
|
|||||||
it("renders correctly with inputs and calendar options", () => {
|
it("renders correctly with inputs and calendar options", () => {
|
||||||
renderPopover();
|
renderPopover();
|
||||||
|
|
||||||
|
// Check dialog title
|
||||||
expect(screen.getByText("Create Event")).toBeInTheDocument();
|
expect(screen.getByText("Create Event")).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText("Calendar")).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText("Title")).toBeInTheDocument();
|
// Check inputs exist by their roles
|
||||||
expect(screen.getByLabelText("Start")).toBeInTheDocument();
|
const titleInput = screen.getByRole("textbox", { name: /title/i });
|
||||||
expect(screen.getByLabelText("End")).toBeInTheDocument();
|
expect(titleInput).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText("Description")).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText("Location")).toBeInTheDocument();
|
const descriptionInput = screen.getByRole("textbox", {
|
||||||
|
name: /description/i,
|
||||||
|
});
|
||||||
|
expect(descriptionInput).toBeInTheDocument();
|
||||||
|
|
||||||
|
const calendarSelect = screen.getByRole("combobox", { name: /calendar/i });
|
||||||
|
expect(calendarSelect).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check button
|
||||||
expect(screen.getByText("Show More")).toBeInTheDocument();
|
expect(screen.getByText("Show More")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Extended mode
|
||||||
fireEvent.click(screen.getByText("Show More"));
|
fireEvent.click(screen.getByText("Show More"));
|
||||||
expect(screen.getByLabelText("Repetition")).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText("Alarm")).toBeInTheDocument();
|
// Back button appears
|
||||||
expect(screen.getByLabelText("Visibility")).toBeInTheDocument();
|
expect(screen.getByLabelText("show less")).toBeInTheDocument();
|
||||||
// Calendar options
|
|
||||||
const select = screen.getByLabelText("Calendar");
|
// Extended labels appear
|
||||||
fireEvent.mouseDown(select);
|
expect(screen.getAllByText("Repeat")).toHaveLength(1);
|
||||||
expect(screen.getAllByRole("option")).toHaveLength(2);
|
expect(screen.getAllByText("Alarm")).toHaveLength(1);
|
||||||
|
expect(screen.getAllByText("Visibility")).toHaveLength(1);
|
||||||
|
expect(screen.getAllByText("Show as")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fills start from selectedRange", () => {
|
it("fills start from selectedRange", () => {
|
||||||
@@ -289,11 +302,13 @@ describe("EventPopover", () => {
|
|||||||
expect(receivedPayload.newEvent.title).toBe(newEvent.title);
|
expect(receivedPayload.newEvent.title).toBe(newEvent.title);
|
||||||
expect(receivedPayload.newEvent.description).toBe(newEvent.description);
|
expect(receivedPayload.newEvent.description).toBe(newEvent.description);
|
||||||
expect(
|
expect(
|
||||||
formatDateToYYYYMMDDTHHMMSS(receivedPayload.newEvent.start).split("T")[0]
|
formatDateToYYYYMMDDTHHMMSS(
|
||||||
|
new Date(receivedPayload.newEvent.start)
|
||||||
|
).split("T")[0]
|
||||||
).toBe(formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.start)).split("T")[0]);
|
).toBe(formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.start)).split("T")[0]);
|
||||||
expect(
|
expect(
|
||||||
formatDateToYYYYMMDDTHHMMSS(
|
formatDateToYYYYMMDDTHHMMSS(
|
||||||
receivedPayload.newEvent.end || new Date()
|
new Date(receivedPayload.newEvent.end || new Date())
|
||||||
).split("T")[0]
|
).split("T")[0]
|
||||||
).toBe(formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.end)).split("T")[0]);
|
).toBe(formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.end)).split("T")[0]);
|
||||||
expect(receivedPayload.newEvent.location).toBe(newEvent.location);
|
expect(receivedPayload.newEvent.location).toBe(newEvent.location);
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# ResponsiveDialog Component
|
||||||
|
|
||||||
|
A highly reusable dialog component that supports both normal and expanded (fullscreen) modes while preserving app header visibility.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ✅ **Two Modes**: Normal popup (685px) and expanded fullscreen mode
|
||||||
|
- ✅ **Preserves Header**: Expanded mode doesn't cover app header (90px default)
|
||||||
|
- ✅ **Clean Expanded View**: No backdrop/shadow in expanded mode for seamless integration
|
||||||
|
- ✅ **Instant Transition**: No animation when expanding for immediate feedback
|
||||||
|
- ✅ **Back Navigation**: Expanded mode shows back arrow icon in header for easy collapse
|
||||||
|
- ✅ **MUI Stack Spacing**: Uses MUI Stack component with configurable spacing prop (2=16px normal, 3=24px expanded)
|
||||||
|
- ✅ **Fully Customizable**: Override styles with `sx`, `contentSx`, `titleSx` props
|
||||||
|
- ✅ **Container Support**: Content container with configurable max-width
|
||||||
|
- ✅ **Type Safe**: Full TypeScript support
|
||||||
|
- ✅ **No Custom CSS**: Uses MUI `sx` prop pattern
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ResponsiveDialog } from "../../components/Dialog";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [showMore, setShowMore] = useState(false);
|
||||||
|
|
||||||
|
const actions = (
|
||||||
|
<>
|
||||||
|
{!showMore && (
|
||||||
|
<Button onClick={() => setShowMore(true)}>Show More</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={() => setOpen(false)}>Close</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
title="My Dialog"
|
||||||
|
isExpanded={showMore}
|
||||||
|
onExpandToggle={() => setShowMore(!showMore)}
|
||||||
|
actions={actions}
|
||||||
|
>
|
||||||
|
<TextField label="Name" fullWidth />
|
||||||
|
<TextField label="Email" fullWidth />
|
||||||
|
{/* Wrapped in Stack with spacing={2} (normal) or spacing={3} (expanded) */}
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
| ------------------------- | --------------------- | --------- | ----------------------------------------------------------- |
|
||||||
|
| `open` | `boolean` | required | Whether dialog is open |
|
||||||
|
| `onClose` | `() => void` | required | Close handler |
|
||||||
|
| `title` | `string \| ReactNode` | required | Dialog title (replaced by back icon when expanded) |
|
||||||
|
| `children` | `ReactNode` | required | Dialog content (wrapped in MUI Stack) |
|
||||||
|
| `actions` | `ReactNode` | - | Action buttons |
|
||||||
|
| `isExpanded` | `boolean` | `false` | Toggle fullscreen mode |
|
||||||
|
| `onExpandToggle` | `() => void` | - | Handler for back button click when expanded |
|
||||||
|
| `normalMaxWidth` | `string` | `"685px"` | Max width in normal mode |
|
||||||
|
| `expandedContentMaxWidth` | `string` | `"990px"` | Content container max-width in expanded mode |
|
||||||
|
| `headerHeight` | `string` | `"90px"` | App header height to preserve |
|
||||||
|
| `normalSpacing` | `number` | `2` | Stack spacing in normal mode (MUI spacing units: 1 = 8px) |
|
||||||
|
| `expandedSpacing` | `number` | `3` | Stack spacing in expanded mode (MUI spacing units: 1 = 8px) |
|
||||||
|
| `contentSx` | `SxProps<Theme>` | - | Custom styles for DialogContent |
|
||||||
|
| `titleSx` | `SxProps<Theme>` | - | Custom styles for DialogTitle |
|
||||||
|
| `dialogContentProps` | `DialogContentProps` | - | Additional DialogContent props |
|
||||||
|
| `dialogTitleProps` | `DialogTitleProps` | - | Additional DialogTitle props |
|
||||||
|
| `dividers` | `boolean` | `false` | Show dividers between sections |
|
||||||
|
|
||||||
|
## Advanced Examples
|
||||||
|
|
||||||
|
### Custom Container Styles
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
title="Custom Styled Dialog"
|
||||||
|
isExpanded={showMore}
|
||||||
|
contentSx={{
|
||||||
|
backgroundColor: "#f5f5f5",
|
||||||
|
padding: 4,
|
||||||
|
}}
|
||||||
|
titleSx={{
|
||||||
|
backgroundColor: "primary.main",
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField label="Field" />
|
||||||
|
</ResponsiveDialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Dividers
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
title="Dialog with Dividers"
|
||||||
|
dividers={true}
|
||||||
|
actions={<Button>Save</Button>}
|
||||||
|
>
|
||||||
|
<TextField label="Content" />
|
||||||
|
</ResponsiveDialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Different Sizes
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
title="Large Dialog"
|
||||||
|
normalMaxWidth="900px"
|
||||||
|
expandedContentMaxWidth="1200px"
|
||||||
|
>
|
||||||
|
<TextField label="Content" />
|
||||||
|
</ResponsiveDialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Spacing
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
title="Custom Spacing"
|
||||||
|
normalSpacing={1} // 8px spacing in normal mode
|
||||||
|
expandedSpacing={4} // 32px spacing in expanded mode
|
||||||
|
>
|
||||||
|
<TextField label="Field 1" />
|
||||||
|
<TextField label="Field 2" />
|
||||||
|
{/* MUI spacing units: 1=8px, 2=16px, 3=24px, 4=32px */}
|
||||||
|
</ResponsiveDialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Header Height
|
||||||
|
|
||||||
|
For apps with different header heights:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
title="Custom Header Height"
|
||||||
|
headerHeight="80px"
|
||||||
|
isExpanded={true}
|
||||||
|
>
|
||||||
|
<TextField label="Content" />
|
||||||
|
</ResponsiveDialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout Behavior
|
||||||
|
|
||||||
|
### Normal Mode (`isExpanded={false}`)
|
||||||
|
|
||||||
|
- Dialog: max-width = `normalMaxWidth` (default 685px)
|
||||||
|
- Content: 100% width
|
||||||
|
- Height: auto (fits content)
|
||||||
|
- Position: centered with 32px margin
|
||||||
|
- Children spacing: `normalSpacing={2}` (16px via MUI Stack component)
|
||||||
|
|
||||||
|
### Expanded Mode (`isExpanded={true}`)
|
||||||
|
|
||||||
|
- Dialog: full width, height = `calc(100vh - headerHeight)`
|
||||||
|
- Content: max-width = `expandedContentMaxWidth` (default 990px), centered
|
||||||
|
- Position: top = `headerHeight` (preserves header visibility - default 90px)
|
||||||
|
- Backdrop: opacity = 0 (seamless integration with page)
|
||||||
|
- Shadow: removed (no elevation in expanded mode)
|
||||||
|
- Transition: disabled (instant expand/collapse for better UX)
|
||||||
|
- Title: replaced by back arrow IconButton (calls `onExpandToggle`)
|
||||||
|
- Children spacing: `expandedSpacing={3}` (24px via MUI Stack component)
|
||||||
|
- Actions (MuiBox): max-width = `expandedContentMaxWidth` (990px), centered container with buttons right-aligned
|
||||||
|
- padding: 0 12px
|
||||||
|
- width: 100%
|
||||||
|
- justifyContent: flex-end
|
||||||
|
|
||||||
|
## Style Merging
|
||||||
|
|
||||||
|
All `sx` props are **merged** with base styles, not overridden:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Base styles are preserved, your styles are added
|
||||||
|
<ResponsiveDialog
|
||||||
|
contentSx={{
|
||||||
|
padding: 5, // Adds to base styles
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript Support
|
||||||
|
|
||||||
|
Full type inference and validation:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ResponsiveDialog } from "../../components/Dialog";
|
||||||
|
|
||||||
|
// All props are type-checked
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
title="Typed Dialog"
|
||||||
|
// TypeScript will validate all props
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Use `isExpanded` for Show More/Less**: Toggle between modes for better UX
|
||||||
|
2. **Provide `onExpandToggle` handler**: Required for back button functionality in expanded mode
|
||||||
|
3. **Hide expand button in actions when expanded**: Back arrow in header provides collapse action
|
||||||
|
4. **Keep `normalMaxWidth` reasonable**: 685px works well for forms
|
||||||
|
5. **Center content in expanded mode**: Default 990px provides good reading width
|
||||||
|
6. **Preserve header**: Default 90px - adjust via `headerHeight` prop if needed
|
||||||
|
7. **MUI Stack handles spacing**: Children wrapped in Stack with configurable spacing prop - override with `normalSpacing`/`expandedSpacing` if needed
|
||||||
|
8. **Seamless expanded mode**: No backdrop/shadow/transition creates clean integration with page
|
||||||
|
9. **Instant expand**: No animation when expanding provides immediate feedback
|
||||||
|
10. **Customize with `sx` props**: Avoid creating custom CSS files
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- `EventModal.tsx` - Real-world example usage
|
||||||
|
- MUI Dialog documentation: https://mui.com/material-ui/react-dialog/
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentProps,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTitleProps,
|
||||||
|
DialogProps,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
SxProps,
|
||||||
|
Theme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||||
|
import React, { ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ResponsiveDialog - A reusable dialog component that can switch between normal and expanded modes
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Normal mode: Dialog with customizable max-width (default 685px)
|
||||||
|
* - Expanded mode: Full height dialog (excluding app header) with centered content container
|
||||||
|
* - Fully customizable with sx props for Dialog, DialogTitle, and DialogContent
|
||||||
|
* - Preserves app header visibility in expanded mode
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <ResponsiveDialog
|
||||||
|
* open={open}
|
||||||
|
* onClose={handleClose}
|
||||||
|
* title="My Dialog"
|
||||||
|
* isExpanded={showMore}
|
||||||
|
* actions={<Button onClick={handleSave}>Save</Button>}
|
||||||
|
* contentSx={{ padding: 3 }}
|
||||||
|
* >
|
||||||
|
* <TextField label="Name" />
|
||||||
|
* </ResponsiveDialog>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface ResponsiveDialogProps
|
||||||
|
extends Omit<DialogProps, "maxWidth" | "title"> {
|
||||||
|
/** Whether the dialog is open */
|
||||||
|
open: boolean;
|
||||||
|
/** Callback fired when the dialog should be closed */
|
||||||
|
onClose: () => void;
|
||||||
|
/** Dialog title - can be string or custom ReactNode */
|
||||||
|
title: string | ReactNode;
|
||||||
|
/** Dialog content - form fields, text, etc. */
|
||||||
|
children: ReactNode;
|
||||||
|
/** Optional actions rendered in DialogActions (buttons, etc.) */
|
||||||
|
actions?: ReactNode;
|
||||||
|
/** Toggle between normal and expanded (fullscreen) mode */
|
||||||
|
isExpanded?: boolean;
|
||||||
|
/** Callback when expand/collapse button is clicked (required if using isExpanded) */
|
||||||
|
onExpandToggle?: () => void;
|
||||||
|
/** Max width in normal mode (default: "685px") */
|
||||||
|
normalMaxWidth?: string;
|
||||||
|
/** Max width of content container in expanded mode (default: "990px") */
|
||||||
|
expandedContentMaxWidth?: string;
|
||||||
|
/** Height of app header to preserve visibility (default: "90px") */
|
||||||
|
headerHeight?: string;
|
||||||
|
/** Spacing between children in normal mode (default: 2 = 16px) */
|
||||||
|
normalSpacing?: number;
|
||||||
|
/** Spacing between children in expanded mode (default: 3 = 24px) */
|
||||||
|
expandedSpacing?: number;
|
||||||
|
/** Custom styles for DialogContent - merged with base styles */
|
||||||
|
contentSx?: SxProps<Theme>;
|
||||||
|
/** Custom styles for DialogTitle */
|
||||||
|
titleSx?: SxProps<Theme>;
|
||||||
|
/** Additional props for DialogContent (excluding sx) */
|
||||||
|
dialogContentProps?: Omit<DialogContentProps, "sx">;
|
||||||
|
/** Additional props for DialogTitle (excluding sx) */
|
||||||
|
dialogTitleProps?: Omit<DialogTitleProps, "sx">;
|
||||||
|
/** Whether to display dividers between title/content/actions */
|
||||||
|
dividers?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResponsiveDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
actions,
|
||||||
|
isExpanded = false,
|
||||||
|
onExpandToggle,
|
||||||
|
normalMaxWidth = "685px",
|
||||||
|
expandedContentMaxWidth = "990px",
|
||||||
|
headerHeight = "90px",
|
||||||
|
normalSpacing = 2,
|
||||||
|
expandedSpacing = 3,
|
||||||
|
contentSx,
|
||||||
|
titleSx,
|
||||||
|
dialogContentProps,
|
||||||
|
dialogTitleProps,
|
||||||
|
dividers = false,
|
||||||
|
sx,
|
||||||
|
...otherDialogProps
|
||||||
|
}: ResponsiveDialogProps) {
|
||||||
|
const baseSx: SxProps<Theme> = {
|
||||||
|
"& .MuiBackdrop-root": {
|
||||||
|
opacity: isExpanded ? "0 !important" : undefined,
|
||||||
|
transition: isExpanded ? "none !important" : undefined,
|
||||||
|
},
|
||||||
|
"& .MuiDialog-paper": {
|
||||||
|
maxWidth: isExpanded ? "100%" : normalMaxWidth,
|
||||||
|
width: "100%",
|
||||||
|
height: isExpanded ? `calc(100vh - ${headerHeight})` : "auto",
|
||||||
|
margin: isExpanded ? `${headerHeight} 0 0 0` : "32px",
|
||||||
|
maxHeight: isExpanded
|
||||||
|
? `calc(100vh - ${headerHeight})`
|
||||||
|
: `calc(100vh - 90px)`,
|
||||||
|
boxShadow: isExpanded ? "none !important" : undefined,
|
||||||
|
transition: isExpanded ? "none !important" : undefined,
|
||||||
|
},
|
||||||
|
"& .MuiDialogActions-root .MuiBox-root": {
|
||||||
|
maxWidth: isExpanded ? expandedContentMaxWidth : undefined,
|
||||||
|
margin: isExpanded ? "0 auto" : undefined,
|
||||||
|
padding: isExpanded ? "0 12px" : undefined,
|
||||||
|
width: isExpanded ? "100%" : undefined,
|
||||||
|
justifyContent: isExpanded ? "flex-end" : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseContentSx: SxProps<Theme> = {
|
||||||
|
maxWidth: isExpanded ? expandedContentMaxWidth : "100%",
|
||||||
|
margin: isExpanded ? "0 auto" : "0",
|
||||||
|
width: "100%",
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentSpacing = isExpanded ? expandedSpacing : normalSpacing;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth={false}
|
||||||
|
fullWidth
|
||||||
|
transitionDuration={isExpanded ? 0 : 300}
|
||||||
|
sx={[baseSx, ...(Array.isArray(sx) ? sx : [sx])]}
|
||||||
|
{...otherDialogProps}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={titleSx} {...dialogTitleProps}>
|
||||||
|
{isExpanded && onExpandToggle ? (
|
||||||
|
<IconButton
|
||||||
|
onClick={onExpandToggle}
|
||||||
|
aria-label="show less"
|
||||||
|
sx={{ marginLeft: "-8px" }}
|
||||||
|
>
|
||||||
|
<ArrowBackIcon />
|
||||||
|
</IconButton>
|
||||||
|
) : (
|
||||||
|
title
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent
|
||||||
|
dividers={dividers}
|
||||||
|
sx={[
|
||||||
|
baseContentSx,
|
||||||
|
...(Array.isArray(contentSx) ? contentSx : [contentSx]),
|
||||||
|
]}
|
||||||
|
{...dialogContentProps}
|
||||||
|
>
|
||||||
|
<Stack spacing={currentSpacing}>{children}</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
{actions && <DialogActions>{actions}</DialogActions>}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ResponsiveDialog;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as ResponsiveDialog } from "./ResponsiveDialog";
|
||||||
+302
-221
@@ -2,14 +2,11 @@ import { CalendarApi, DateSelectArg } from "@fullcalendar/core";
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Checkbox,
|
||||||
CardActions,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
FormControl,
|
FormControl,
|
||||||
|
FormControlLabel,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Popover,
|
|
||||||
Select,
|
Select,
|
||||||
SelectChangeEvent,
|
SelectChangeEvent,
|
||||||
TextField,
|
TextField,
|
||||||
@@ -18,7 +15,7 @@ import {
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||||
import AttendeeSelector from "../../components/Attendees/AttendeeSearch";
|
import AttendeeSelector from "../../components/Attendees/AttendeeSearch";
|
||||||
import { TIMEZONES } from "../../utils/timezone-data";
|
import { ResponsiveDialog } from "../../components/Dialog";
|
||||||
import { putEventAsync } from "../Calendars/CalendarSlice";
|
import { putEventAsync } from "../Calendars/CalendarSlice";
|
||||||
import { Calendars } from "../Calendars/CalendarTypes";
|
import { Calendars } from "../Calendars/CalendarTypes";
|
||||||
import { userAttendee } from "../User/userDataTypes";
|
import { userAttendee } from "../User/userDataTypes";
|
||||||
@@ -26,6 +23,58 @@ import { CalendarEvent, RepetitionObject } from "./EventsTypes";
|
|||||||
import { createSelector } from "@reduxjs/toolkit";
|
import { createSelector } from "@reduxjs/toolkit";
|
||||||
import RepeatEvent from "../../components/Event/EventRepeat";
|
import RepeatEvent from "../../components/Event/EventRepeat";
|
||||||
|
|
||||||
|
// Helper component for field with label
|
||||||
|
const FieldWithLabel = React.memo(
|
||||||
|
({
|
||||||
|
label,
|
||||||
|
isExpanded,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
isExpanded: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
|
if (!isExpanded) {
|
||||||
|
// Normal mode: label on top
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
component="label"
|
||||||
|
sx={{
|
||||||
|
display: "block",
|
||||||
|
marginBottom: "4px",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extended mode: label on left
|
||||||
|
return (
|
||||||
|
<Box display="flex" alignItems="center">
|
||||||
|
<Typography
|
||||||
|
component="label"
|
||||||
|
sx={{
|
||||||
|
minWidth: "115px",
|
||||||
|
marginRight: "12px",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Box flexGrow={1}>{children}</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
FieldWithLabel.displayName = "FieldWithLabel";
|
||||||
|
|
||||||
function EventPopover({
|
function EventPopover({
|
||||||
anchorEl,
|
anchorEl,
|
||||||
open,
|
open,
|
||||||
@@ -63,7 +112,6 @@ function EventPopover({
|
|||||||
const userPersonnalCalendars: Calendars[] = useAppSelector(
|
const userPersonnalCalendars: Calendars[] = useAppSelector(
|
||||||
selectPersonnalCalendars
|
selectPersonnalCalendars
|
||||||
);
|
);
|
||||||
const timezones = TIMEZONES.aliases;
|
|
||||||
const [showMore, setShowMore] = useState(false);
|
const [showMore, setShowMore] = useState(false);
|
||||||
|
|
||||||
const [title, setTitle] = useState(event?.title ?? "");
|
const [title, setTitle] = useState(event?.title ?? "");
|
||||||
@@ -90,9 +138,7 @@ function EventPopover({
|
|||||||
const [eventClass, setEventClass] = useState(event?.class ?? "PUBLIC");
|
const [eventClass, setEventClass] = useState(event?.class ?? "PUBLIC");
|
||||||
const [busy, setBusy] = useState(event?.transp ?? "OPAQUE");
|
const [busy, setBusy] = useState(event?.transp ?? "OPAQUE");
|
||||||
|
|
||||||
const [timezone, setTimezone] = useState(
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedRange) {
|
if (selectedRange) {
|
||||||
@@ -111,7 +157,9 @@ function EventPopover({
|
|||||||
}, [event, organizer?.cal_address]);
|
}, [event, organizer?.cal_address]);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
onClose({}, "backdropClick"); // Reset
|
onClose({}, "backdropClick");
|
||||||
|
// Reset state
|
||||||
|
setShowMore(false);
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setAttendees([]);
|
setAttendees([]);
|
||||||
@@ -126,7 +174,7 @@ function EventPopover({
|
|||||||
calId: userPersonnalCalendars[calendarid].id,
|
calId: userPersonnalCalendars[calendarid].id,
|
||||||
title,
|
title,
|
||||||
URL: `/calendars/${userPersonnalCalendars[calendarid].id}/${newEventUID}.ics`,
|
URL: `/calendars/${userPersonnalCalendars[calendarid].id}/${newEventUID}.ics`,
|
||||||
start: new Date(start),
|
start: new Date(start).toISOString(),
|
||||||
allday,
|
allday,
|
||||||
uid: newEventUID,
|
uid: newEventUID,
|
||||||
description,
|
description,
|
||||||
@@ -150,244 +198,277 @@ function EventPopover({
|
|||||||
alarm: { trigger: alarm, action: "EMAIL" },
|
alarm: { trigger: alarm, action: "EMAIL" },
|
||||||
};
|
};
|
||||||
if (end) {
|
if (end) {
|
||||||
newEvent.end = new Date(end);
|
newEvent.end = new Date(end).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attendees.length > 0) {
|
if (attendees.length > 0) {
|
||||||
newEvent.attendee = newEvent.attendee.concat(attendees);
|
newEvent.attendee = newEvent.attendee.concat(attendees);
|
||||||
}
|
}
|
||||||
|
|
||||||
await dispatch(
|
// Close popup immediately
|
||||||
putEventAsync({
|
|
||||||
cal: userPersonnalCalendars[calendarid],
|
|
||||||
newEvent,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
onClose({}, "backdropClick");
|
onClose({}, "backdropClick");
|
||||||
|
|
||||||
// Reset
|
// Reset state
|
||||||
|
setShowMore(false);
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setAttendees([]);
|
setAttendees([]);
|
||||||
setLocation("");
|
setLocation("");
|
||||||
setCalendarid(0);
|
setCalendarid(0);
|
||||||
|
|
||||||
|
// Save to API in background
|
||||||
|
dispatch(
|
||||||
|
putEventAsync({
|
||||||
|
cal: userPersonnalCalendars[calendarid],
|
||||||
|
newEvent,
|
||||||
|
})
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dialogActions = (
|
||||||
|
<Box display="flex" justifyContent="space-between" width="100%" px={2}>
|
||||||
|
{!showMore && (
|
||||||
|
<Button onClick={() => setShowMore(!showMore)}>Show More</Button>
|
||||||
|
)}
|
||||||
|
<Box display="flex" gap={1} ml={showMore ? "auto" : 0}>
|
||||||
|
<Button variant="outlined" onClick={handleClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" onClick={handleSave} disabled={!title}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<ResponsiveDialog
|
||||||
open={open}
|
open={open}
|
||||||
anchorEl={anchorEl}
|
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
anchorOrigin={{
|
title={event?.uid ? "Duplicate Event" : "Create Event"}
|
||||||
vertical: "center",
|
isExpanded={showMore}
|
||||||
horizontal: "center",
|
onExpandToggle={() => setShowMore(!showMore)}
|
||||||
}}
|
actions={dialogActions}
|
||||||
transformOrigin={{
|
|
||||||
vertical: "center",
|
|
||||||
horizontal: "center",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Card>
|
<FieldWithLabel label="Title" isExpanded={showMore}>
|
||||||
<CardHeader title={event?.uid ? "Duplicate Event" : "Create Event"} />
|
<TextField
|
||||||
<CardContent
|
fullWidth
|
||||||
style={{ maxHeight: "85vh", maxWidth: "40vw", overflow: "auto" }}
|
label={!showMore ? "Title" : ""}
|
||||||
>
|
value={title}
|
||||||
<TextField
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
fullWidth
|
size="small"
|
||||||
label="Title"
|
margin="dense"
|
||||||
value={title}
|
/>
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
</FieldWithLabel>
|
||||||
size="small"
|
<FieldWithLabel label="Description" isExpanded={showMore}>
|
||||||
margin="dense"
|
<TextField
|
||||||
/>
|
fullWidth
|
||||||
<FormControl fullWidth margin="dense" size="small">
|
label={!showMore ? "Description" : ""}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
margin="dense"
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</FieldWithLabel>
|
||||||
|
<FieldWithLabel label="Calendar" isExpanded={showMore}>
|
||||||
|
<FormControl fullWidth margin="dense" size="small">
|
||||||
|
{!showMore && (
|
||||||
<InputLabel id="calendar-select-label">Calendar</InputLabel>
|
<InputLabel id="calendar-select-label">Calendar</InputLabel>
|
||||||
<Select
|
)}
|
||||||
labelId="calendar-select-label"
|
<Select
|
||||||
value={calendarid.toString()}
|
labelId="calendar-select-label"
|
||||||
label="Calendar"
|
value={calendarid.toString()}
|
||||||
onChange={(e: SelectChangeEvent) =>
|
label={!showMore ? "Calendar" : ""}
|
||||||
setCalendarid(Number(e.target.value))
|
onChange={(e: SelectChangeEvent) =>
|
||||||
}
|
setCalendarid(Number(e.target.value))
|
||||||
>
|
}
|
||||||
{Object.keys(userPersonnalCalendars).map((calendar, index) => (
|
>
|
||||||
<MenuItem key={index} value={index}>
|
{Object.keys(userPersonnalCalendars).map((calendar, index) => (
|
||||||
{userPersonnalCalendars[index].name}
|
<MenuItem key={index} value={index}>
|
||||||
</MenuItem>
|
{userPersonnalCalendars[index].name}
|
||||||
))}
|
</MenuItem>
|
||||||
</Select>
|
))}
|
||||||
</FormControl>
|
</Select>
|
||||||
|
</FormControl>
|
||||||
<TextField
|
</FieldWithLabel>
|
||||||
fullWidth
|
|
||||||
label="Start"
|
|
||||||
type={allday ? "date" : "datetime-local"}
|
|
||||||
value={allday ? start.split("T")[0] : start}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newStart = e.target.value;
|
|
||||||
setStart(newStart);
|
|
||||||
const newRange = {
|
|
||||||
...selectedRange,
|
|
||||||
start: new Date(newStart),
|
|
||||||
startStr: newStart,
|
|
||||||
allDay: allday,
|
|
||||||
};
|
|
||||||
setSelectedRange(newRange);
|
|
||||||
calendarRef.current?.select(newRange);
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
margin="dense"
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="End"
|
|
||||||
type={allday ? "date" : "datetime-local"}
|
|
||||||
value={allday ? end.split("T")[0] : end}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newEnd = e.target.value;
|
|
||||||
setEnd(newEnd);
|
|
||||||
const newRange = {
|
|
||||||
...selectedRange,
|
|
||||||
end: new Date(newEnd),
|
|
||||||
endStr: newEnd,
|
|
||||||
allDay: allday,
|
|
||||||
};
|
|
||||||
setSelectedRange(newRange);
|
|
||||||
calendarRef.current?.select(newRange);
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
margin="dense"
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
/>
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={allday}
|
|
||||||
onChange={() => {
|
|
||||||
const endDate = new Date(end);
|
|
||||||
const startDate = new Date(start);
|
|
||||||
setAllDay(!allday);
|
|
||||||
if (endDate.getDate() === startDate.getDate()) {
|
|
||||||
endDate.setDate(startDate.getDate() + 1);
|
|
||||||
setEnd(formatLocalDateTime(endDate));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
<FieldWithLabel label="Date & Time" isExpanded={showMore}>
|
||||||
|
<Box display="flex" gap={2}>
|
||||||
|
<Box flexGrow={1}>
|
||||||
|
{showMore && (
|
||||||
|
<Typography variant="caption" display="block" mb={0.5}>
|
||||||
|
Start
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={!showMore ? "Start" : ""}
|
||||||
|
type={allday ? "date" : "datetime-local"}
|
||||||
|
value={allday ? start.split("T")[0] : start}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newStart = e.target.value;
|
||||||
|
setStart(newStart);
|
||||||
const newRange = {
|
const newRange = {
|
||||||
...selectedRange,
|
...selectedRange,
|
||||||
startStr: allday ? start.split("T")[0] : start,
|
start: new Date(newStart),
|
||||||
endStr: allday
|
startStr: newStart,
|
||||||
? endDate.toISOString().split("T")[0]
|
|
||||||
: endDate.toISOString(),
|
|
||||||
start: new Date(allday ? start.split("T")[0] : start),
|
|
||||||
end: new Date(
|
|
||||||
allday
|
|
||||||
? endDate.toISOString().split("T")[0]
|
|
||||||
: endDate.toISOString()
|
|
||||||
),
|
|
||||||
allDay: allday,
|
allDay: allday,
|
||||||
};
|
};
|
||||||
setSelectedRange(newRange);
|
setSelectedRange(newRange);
|
||||||
|
calendarRef.current?.select(newRange);
|
||||||
}}
|
}}
|
||||||
|
size="small"
|
||||||
|
margin="dense"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
/>
|
/>
|
||||||
All day
|
|
||||||
</label>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="Description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
size="small"
|
|
||||||
margin="dense"
|
|
||||||
multiline
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="Location"
|
|
||||||
value={location}
|
|
||||||
onChange={(e) => setLocation(e.target.value)}
|
|
||||||
size="small"
|
|
||||||
margin="dense"
|
|
||||||
/>
|
|
||||||
<AttendeeSelector attendees={attendees} setAttendees={setAttendees} />
|
|
||||||
{/* Extended options */}
|
|
||||||
{showMore && (
|
|
||||||
<>
|
|
||||||
<RepeatEvent
|
|
||||||
repetition={repetition}
|
|
||||||
eventStart={selectedRange?.start ?? new Date()}
|
|
||||||
setRepetition={setRepetition}
|
|
||||||
/>
|
|
||||||
<FormControl fullWidth margin="dense" size="small">
|
|
||||||
<InputLabel id="alarm">Alarm</InputLabel>
|
|
||||||
<Select
|
|
||||||
labelId="alarm"
|
|
||||||
value={alarm}
|
|
||||||
onChange={(e: SelectChangeEvent) => setAlarm(e.target.value)}
|
|
||||||
>
|
|
||||||
<MenuItem value={""}>No Alarm</MenuItem>
|
|
||||||
<MenuItem value={"-PT1M"}>1 minute</MenuItem>
|
|
||||||
<MenuItem value={"-PT5M"}>2 minutes</MenuItem>
|
|
||||||
<MenuItem value={"-PT10M"}>10 minutes</MenuItem>
|
|
||||||
<MenuItem value={"-PT15M"}>15 minutes</MenuItem>
|
|
||||||
<MenuItem value={"-PT30M"}>30 minutes</MenuItem>
|
|
||||||
<MenuItem value={"-PT1H"}>1 hours</MenuItem>
|
|
||||||
<MenuItem value={"-PT2H"}>2 hours</MenuItem>
|
|
||||||
<MenuItem value={"-PT5H"}>5 hours</MenuItem>
|
|
||||||
<MenuItem value={"-PT12H"}>12 hours</MenuItem>
|
|
||||||
<MenuItem value={"-PT1D"}>1 day</MenuItem>
|
|
||||||
<MenuItem value={"-PT2D"}>2 days</MenuItem>
|
|
||||||
<MenuItem value={"-PT1W"}>1 week</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormControl fullWidth margin="dense" size="small">
|
|
||||||
<InputLabel id="Visibility">Visibility</InputLabel>
|
|
||||||
<Select
|
|
||||||
labelId="Visibility"
|
|
||||||
label="Visibility"
|
|
||||||
value={eventClass}
|
|
||||||
onChange={(e: SelectChangeEvent) =>
|
|
||||||
setEventClass(e.target.value)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MenuItem value={"PUBLIC"}>Public</MenuItem>
|
|
||||||
<MenuItem value={"CONFIDENTIAL"}>Show time only</MenuItem>
|
|
||||||
<MenuItem value={"PRIVATE"}>Private</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormControl fullWidth margin="dense" size="small">
|
|
||||||
<InputLabel id="busy">is Busy</InputLabel>
|
|
||||||
<Select
|
|
||||||
labelId="busy"
|
|
||||||
value={busy}
|
|
||||||
label="is busy"
|
|
||||||
onChange={(e: SelectChangeEvent) => setBusy(e.target.value)}
|
|
||||||
>
|
|
||||||
<MenuItem value={"TRANSPARENT"}>Free</MenuItem>
|
|
||||||
<MenuItem value={"OPAQUE"}>Busy </MenuItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<CardActions>
|
|
||||||
<Box mt={2} display="flex" justifyContent="flex-end" gap={1}>
|
|
||||||
<Button variant="outlined" onClick={handleClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button size="small" onClick={() => setShowMore(!showMore)}>
|
|
||||||
{showMore ? "Show Less" : "Show More"}
|
|
||||||
</Button>
|
|
||||||
<Button variant="contained" onClick={handleSave} disabled={!title}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</Box>
|
</Box>
|
||||||
</CardActions>
|
<Box flexGrow={1}>
|
||||||
</Card>
|
{showMore && (
|
||||||
</Popover>
|
<Typography variant="caption" display="block" mb={0.5}>
|
||||||
|
End
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={!showMore ? "End" : ""}
|
||||||
|
type={allday ? "date" : "datetime-local"}
|
||||||
|
value={allday ? end.split("T")[0] : end}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newEnd = e.target.value;
|
||||||
|
setEnd(newEnd);
|
||||||
|
const newRange = {
|
||||||
|
...selectedRange,
|
||||||
|
end: new Date(newEnd),
|
||||||
|
endStr: newEnd,
|
||||||
|
allDay: allday,
|
||||||
|
};
|
||||||
|
setSelectedRange(newRange);
|
||||||
|
calendarRef.current?.select(newRange);
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
margin="dense"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</FieldWithLabel>
|
||||||
|
<FieldWithLabel label=" " isExpanded={showMore}>
|
||||||
|
<Box>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={allday}
|
||||||
|
onChange={() => {
|
||||||
|
const endDate = new Date(end);
|
||||||
|
const startDate = new Date(start);
|
||||||
|
setAllDay(!allday);
|
||||||
|
if (endDate.getDate() === startDate.getDate()) {
|
||||||
|
endDate.setDate(startDate.getDate() + 1);
|
||||||
|
setEnd(formatLocalDateTime(endDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRange = {
|
||||||
|
...selectedRange,
|
||||||
|
startStr: allday ? start.split("T")[0] : start,
|
||||||
|
endStr: allday
|
||||||
|
? endDate.toISOString().split("T")[0]
|
||||||
|
: endDate.toISOString(),
|
||||||
|
start: new Date(allday ? start.split("T")[0] : start),
|
||||||
|
end: new Date(
|
||||||
|
allday
|
||||||
|
? endDate.toISOString().split("T")[0]
|
||||||
|
: endDate.toISOString()
|
||||||
|
),
|
||||||
|
allDay: allday,
|
||||||
|
};
|
||||||
|
setSelectedRange(newRange);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="All day"
|
||||||
|
sx={{ padding: "0 8px 0 0" }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</FieldWithLabel>
|
||||||
|
<FieldWithLabel label="Attendees" isExpanded={showMore}>
|
||||||
|
<AttendeeSelector attendees={attendees} setAttendees={setAttendees} />
|
||||||
|
</FieldWithLabel>
|
||||||
|
<FieldWithLabel label="Location" isExpanded={showMore}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={!showMore ? "Location" : ""}
|
||||||
|
value={location}
|
||||||
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
margin="dense"
|
||||||
|
/>
|
||||||
|
</FieldWithLabel>
|
||||||
|
{/* Extended options */}
|
||||||
|
{showMore && (
|
||||||
|
<>
|
||||||
|
<FieldWithLabel label="Repeat" isExpanded={showMore}>
|
||||||
|
<RepeatEvent
|
||||||
|
repetition={repetition}
|
||||||
|
eventStart={selectedRange?.start ?? new Date()}
|
||||||
|
setRepetition={setRepetition}
|
||||||
|
/>
|
||||||
|
</FieldWithLabel>
|
||||||
|
<FieldWithLabel label="Alarm" isExpanded={showMore}>
|
||||||
|
<FormControl fullWidth margin="dense" size="small">
|
||||||
|
<Select
|
||||||
|
labelId="alarm"
|
||||||
|
value={alarm}
|
||||||
|
onChange={(e: SelectChangeEvent) => setAlarm(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value={""}>No Alarm</MenuItem>
|
||||||
|
<MenuItem value={"-PT1M"}>1 minute</MenuItem>
|
||||||
|
<MenuItem value={"-PT5M"}>2 minutes</MenuItem>
|
||||||
|
<MenuItem value={"-PT10M"}>10 minutes</MenuItem>
|
||||||
|
<MenuItem value={"-PT15M"}>15 minutes</MenuItem>
|
||||||
|
<MenuItem value={"-PT30M"}>30 minutes</MenuItem>
|
||||||
|
<MenuItem value={"-PT1H"}>1 hours</MenuItem>
|
||||||
|
<MenuItem value={"-PT2H"}>2 hours</MenuItem>
|
||||||
|
<MenuItem value={"-PT5H"}>5 hours</MenuItem>
|
||||||
|
<MenuItem value={"-PT12H"}>12 hours</MenuItem>
|
||||||
|
<MenuItem value={"-PT1D"}>1 day</MenuItem>
|
||||||
|
<MenuItem value={"-PT2D"}>2 days</MenuItem>
|
||||||
|
<MenuItem value={"-PT1W"}>1 week</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</FieldWithLabel>
|
||||||
|
|
||||||
|
<FieldWithLabel label="Visibility" isExpanded={showMore}>
|
||||||
|
<FormControl fullWidth margin="dense" size="small">
|
||||||
|
<Select
|
||||||
|
labelId="Visibility"
|
||||||
|
value={eventClass}
|
||||||
|
onChange={(e: SelectChangeEvent) =>
|
||||||
|
setEventClass(e.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value={"PUBLIC"}>Public</MenuItem>
|
||||||
|
<MenuItem value={"CONFIDENTIAL"}>Show time only</MenuItem>
|
||||||
|
<MenuItem value={"PRIVATE"}>Private</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</FieldWithLabel>
|
||||||
|
<FieldWithLabel label="Show as" isExpanded={showMore}>
|
||||||
|
<FormControl fullWidth margin="dense" size="small">
|
||||||
|
<Select
|
||||||
|
labelId="busy"
|
||||||
|
value={busy}
|
||||||
|
onChange={(e: SelectChangeEvent) => setBusy(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value={"TRANSPARENT"}>Free</MenuItem>
|
||||||
|
<MenuItem value={"OPAQUE"}>Busy </MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</FieldWithLabel>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ResponsiveDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user