✨(front) integrate mobile views into Scheduler component
Wire mobile components into the main Scheduler, adapt useSchedulerInit for mobile viewport, update EventModal and MiniCalendar for responsive behavior.
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import {
|
||||
addMonths,
|
||||
eachDayOfInterval,
|
||||
@@ -21,6 +22,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useCalendarContext } from "../../contexts";
|
||||
import { useCalendarLocale } from "../../hooks/useCalendarLocale";
|
||||
import { Button } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { useLeftPanel } from "@/features/layouts/contexts/LeftPanelContext";
|
||||
|
||||
interface MiniCalendarProps {
|
||||
selectedDate: Date;
|
||||
@@ -42,6 +44,9 @@ export const MiniCalendar = ({
|
||||
}: MiniCalendarProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { goToDate, currentDate } = useCalendarContext();
|
||||
const leftPanel = useLeftPanel();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const { dateFnsLocale, firstDayOfWeek } = useCalendarLocale();
|
||||
const [viewDate, setViewDate] = useState(selectedDate);
|
||||
|
||||
@@ -98,6 +103,9 @@ export const MiniCalendar = ({
|
||||
const handleDayClick = (day: Date) => {
|
||||
onDateSelect(day);
|
||||
goToDate(day);
|
||||
if (isMobile) {
|
||||
leftPanel.setIsLeftPanelOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -24,6 +24,7 @@ import { InvitationResponseSection } from "./event-modal-sections/InvitationResp
|
||||
import { FreeBusySection } from "./event-modal-sections/FreeBusySection";
|
||||
import { SectionPills } from "./event-modal-sections/SectionPills";
|
||||
import { useResourcePrincipals } from "@/features/resources/api/useResourcePrincipals";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import type { EventModalProps, RecurringDeleteOption } from "./types";
|
||||
import { SectionRow } from "./event-modal-sections/SectionRow";
|
||||
|
||||
@@ -41,6 +42,7 @@ export const EventModal = ({
|
||||
}: EventModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const isMobile = useIsMobile();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
||||
@@ -191,7 +193,7 @@ export const EventModal = ({
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={ModalSize.MEDIUM}
|
||||
size={isMobile ? ModalSize.FULL : ModalSize.MEDIUM}
|
||||
title={
|
||||
mode === "create"
|
||||
? t("calendar.event.createTitle")
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* - Click to create (dateClick)
|
||||
* - Select range to create (select)
|
||||
* - Custom toolbar with navigation and view selection
|
||||
* - Mobile-optimized views (1 day, 2 days, list)
|
||||
*
|
||||
* Next.js consideration: This component must be client-side only
|
||||
* due to DOM manipulation. Use dynamic import with ssr: false if needed.
|
||||
@@ -16,19 +17,57 @@
|
||||
|
||||
import "@event-calendar/core/index.css";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useCalendarContext } from "../../contexts/CalendarContext";
|
||||
import { useCalendarLocale } from "../../hooks/useCalendarLocale";
|
||||
import type { CalDavCalendar } from "../../services/dav/types/caldav-service";
|
||||
import type { CalDavExtendedProps } from "../../services/dav/EventCalendarAdapter";
|
||||
import type { EventCalendarEvent } from "../../services/dav/types/event-calendar";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
|
||||
import { EventModal } from "./EventModal";
|
||||
import { SchedulerToolbar } from "./SchedulerToolbar";
|
||||
import type { SchedulerProps, EventModalState } from "./types";
|
||||
import type { SchedulerProps, EventModalState, MobileListEvent } from "./types";
|
||||
import { useSchedulerHandlers } from "./hooks/useSchedulerHandlers";
|
||||
import {
|
||||
useSchedulerInit,
|
||||
useSchedulingCapabilitiesCheck,
|
||||
} from "./hooks/useSchedulerInit";
|
||||
import { useMobileNavigation } from "./hooks/useMobileNavigation";
|
||||
|
||||
const MobileToolbar = dynamic(
|
||||
() =>
|
||||
import("./mobile/MobileToolbar").then((m) => ({
|
||||
default: m.MobileToolbar,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const WeekDayBar = dynamic(
|
||||
() =>
|
||||
import("./mobile/WeekDayBar").then((m) => ({ default: m.WeekDayBar })),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const FloatingActionButton = dynamic(
|
||||
() =>
|
||||
import("./mobile/FloatingActionButton").then((m) => ({
|
||||
default: m.FloatingActionButton,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const MobileListView = dynamic(
|
||||
() =>
|
||||
import("./mobile/MobileListView").then((m) => ({
|
||||
default: m.MobileListView,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const BROWSER_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
|
||||
const {
|
||||
@@ -38,9 +77,13 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
|
||||
visibleCalendarUrls,
|
||||
isConnected,
|
||||
calendarRef: contextCalendarRef,
|
||||
currentDate,
|
||||
setCurrentDate,
|
||||
} = useCalendarContext();
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const { firstDayOfWeek, intlLocale } = useCalendarLocale();
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const calendarRef = contextCalendarRef;
|
||||
const [calendarUrl, setCalendarUrl] = useState(defaultCalendarUrl || "");
|
||||
@@ -103,8 +146,15 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
|
||||
view?: { type: string; title: string };
|
||||
}) => {
|
||||
// Update current date for MiniCalendar sync
|
||||
const midTime = (info.start.getTime() + info.end.getTime()) / 2;
|
||||
setCurrentDate(new Date(midTime));
|
||||
// Use start for short views (day, 2-day) to avoid mid-point drift,
|
||||
// use midpoint for longer views (week, month) for better centering
|
||||
const durationMs = info.end.getTime() - info.start.getTime();
|
||||
const threeDaysMs = 3 * 24 * 60 * 60 * 1000;
|
||||
const syncDate =
|
||||
durationMs <= threeDaysMs
|
||||
? info.start
|
||||
: new Date((info.start.getTime() + info.end.getTime()) / 2);
|
||||
setCurrentDate(syncDate);
|
||||
|
||||
// Update toolbar state
|
||||
if (calendarRef.current) {
|
||||
@@ -118,6 +168,12 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
|
||||
[setCurrentDate, calendarRef],
|
||||
);
|
||||
|
||||
// Counter incremented each time the calendar finishes loading events
|
||||
const [eventsLoadedCounter, setEventsLoadedCounter] = useState(0);
|
||||
const handleEventsLoaded = useCallback(() => {
|
||||
setEventsLoadedCounter((c) => c + 1);
|
||||
}, []);
|
||||
|
||||
// Initialize calendar
|
||||
// Cast handlers to bypass library type differences between specific event types and unknown
|
||||
useSchedulerInit({
|
||||
@@ -129,12 +185,14 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
|
||||
adapter,
|
||||
visibleCalendarUrlsRef,
|
||||
davCalendarsRef,
|
||||
initialView: isMobile ? "timeGridDay" : "timeGridWeek",
|
||||
setCurrentDate: handleDatesSet,
|
||||
handleEventClick: handleEventClick as (info: unknown) => void,
|
||||
handleEventDrop: handleEventDrop as unknown as (info: unknown) => void,
|
||||
handleEventResize: handleEventResize as unknown as (info: unknown) => void,
|
||||
handleDateClick: handleDateClick as (info: unknown) => void,
|
||||
handleSelect: handleSelect as (info: unknown) => void,
|
||||
onEventsLoaded: handleEventsLoaded,
|
||||
});
|
||||
|
||||
// Update toolbar title on initial render
|
||||
@@ -151,32 +209,173 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
|
||||
// Update eventFilter when visible calendars change
|
||||
useEffect(() => {
|
||||
if (calendarRef.current) {
|
||||
// The refs are already updated (synchronously during render)
|
||||
// Now trigger a re-evaluation of eventFilter by calling refetchEvents
|
||||
calendarRef.current.refetchEvents();
|
||||
}
|
||||
}, [visibleCalendarUrls, davCalendars]);
|
||||
|
||||
const handleViewChange = useCallback((view: string) => {
|
||||
setCurrentView(view);
|
||||
}, []);
|
||||
const handleViewChange = useCallback(
|
||||
(view: string) => {
|
||||
calendarRef.current?.setOption("view", view);
|
||||
setCurrentView(view);
|
||||
},
|
||||
[calendarRef],
|
||||
);
|
||||
|
||||
// Auto-switch view when crossing mobile/desktop breakpoint
|
||||
const prevIsMobileRef = useRef(isMobile);
|
||||
useEffect(() => {
|
||||
if (prevIsMobileRef.current === isMobile) return;
|
||||
prevIsMobileRef.current = isMobile;
|
||||
const targetView = isMobile ? "timeGridDay" : "timeGridWeek";
|
||||
handleViewChange(targetView);
|
||||
}, [isMobile, handleViewChange]);
|
||||
|
||||
// Mobile list view events (extracted from ref via effect to avoid ref access during render)
|
||||
const [listEvents, setListEvents] = useState<MobileListEvent[]>([]);
|
||||
const isListView = isMobile && currentView === "listWeek";
|
||||
const currentDateMs = currentDate.getTime();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isListView || !calendarRef.current) {
|
||||
setListEvents([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const extractTitle = (
|
||||
title: string | { html: string } | { domNodes: Node[] } | undefined,
|
||||
): string => {
|
||||
if (!title) return "";
|
||||
if (typeof title === "string") return title;
|
||||
if ("html" in title) {
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = title.html;
|
||||
return div.textContent || "";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const rawEvents = calendarRef.current.getEvents();
|
||||
const parsed: MobileListEvent[] = rawEvents.map((e) => ({
|
||||
id: String(e.id),
|
||||
title: extractTitle(e.title),
|
||||
start: new Date(e.start),
|
||||
end: e.end ? new Date(e.end) : new Date(e.start),
|
||||
allDay: e.allDay ?? false,
|
||||
backgroundColor: e.backgroundColor || "#2563eb",
|
||||
extendedProps: e.extendedProps ?? {},
|
||||
}));
|
||||
setListEvents(parsed);
|
||||
}, [isListView, currentDateMs, visibleCalendarUrls, eventsLoadedCounter]);
|
||||
|
||||
// Mobile navigation
|
||||
const {
|
||||
weekDays,
|
||||
handleWeekPrev,
|
||||
handleWeekNext,
|
||||
handleDayClick,
|
||||
handleTodayClick,
|
||||
} = useMobileNavigation({
|
||||
currentDate,
|
||||
firstDayOfWeek,
|
||||
calendarRef,
|
||||
});
|
||||
|
||||
// FAB click: open create modal with current date/time
|
||||
const handleFabClick = useCallback(() => {
|
||||
const now = new Date();
|
||||
const startDate = new Date(currentDate);
|
||||
startDate.setHours(now.getHours(), 0, 0, 0);
|
||||
const endDate = new Date(startDate);
|
||||
endDate.setHours(startDate.getHours() + 1);
|
||||
|
||||
const defaultUrl = calendarUrl || davCalendars[0]?.url || "";
|
||||
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
mode: "create",
|
||||
event: {
|
||||
uid: crypto.randomUUID(),
|
||||
stamp: { date: new Date() },
|
||||
start: { date: startDate, type: "DATE-TIME" },
|
||||
end: { date: endDate, type: "DATE-TIME" },
|
||||
},
|
||||
calendarUrl: defaultUrl,
|
||||
});
|
||||
}, [currentDate, calendarUrl, davCalendars]);
|
||||
|
||||
// Mobile list view event click
|
||||
const handleMobileEventClick = useCallback(
|
||||
(eventId: string, extendedProps: Record<string, unknown>) => {
|
||||
const events = calendarRef.current?.getEvents() ?? [];
|
||||
const event = events.find((e) => String(e.id) === eventId);
|
||||
if (!event) return;
|
||||
|
||||
const extProps = extendedProps as CalDavExtendedProps;
|
||||
const icsEvent = adapter.toIcsEvent(event as EventCalendarEvent, {
|
||||
defaultTimezone: extProps?.timezone || BROWSER_TIMEZONE,
|
||||
});
|
||||
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
mode: "edit",
|
||||
event: icsEvent,
|
||||
calendarUrl: extProps?.calendarUrl || calendarUrl,
|
||||
eventUrl: extProps?.eventUrl,
|
||||
etag: extProps?.etag,
|
||||
});
|
||||
},
|
||||
[adapter, calendarUrl, calendarRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="scheduler">
|
||||
<SchedulerToolbar
|
||||
calendarRef={calendarRef}
|
||||
currentView={currentView}
|
||||
viewTitle={viewTitle}
|
||||
onViewChange={handleViewChange}
|
||||
/>
|
||||
{isMobile ? (
|
||||
<>
|
||||
<MobileToolbar
|
||||
calendarRef={calendarRef}
|
||||
currentView={currentView}
|
||||
currentDate={currentDate}
|
||||
onViewChange={handleViewChange}
|
||||
onWeekPrev={handleWeekPrev}
|
||||
onWeekNext={handleWeekNext}
|
||||
onTodayClick={handleTodayClick}
|
||||
/>
|
||||
<WeekDayBar
|
||||
currentDate={currentDate}
|
||||
currentView={currentView}
|
||||
intlLocale={intlLocale}
|
||||
weekDays={weekDays}
|
||||
onDayClick={handleDayClick}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<SchedulerToolbar
|
||||
calendarRef={calendarRef}
|
||||
currentView={currentView}
|
||||
viewTitle={viewTitle}
|
||||
onViewChange={handleViewChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isListView && (
|
||||
<MobileListView
|
||||
weekDays={weekDays}
|
||||
events={listEvents}
|
||||
intlLocale={intlLocale}
|
||||
onEventClick={handleMobileEventClick}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
id="event-calendar"
|
||||
className="scheduler__calendar"
|
||||
style={{ height: "calc(100vh - 52px - 90px)" }}
|
||||
style={{
|
||||
display: isListView ? "none" : undefined,
|
||||
}}
|
||||
/>
|
||||
|
||||
{isMobile && <FloatingActionButton onClick={handleFabClick} />}
|
||||
|
||||
<EventModal
|
||||
isOpen={modalState.isOpen}
|
||||
mode={modalState.mode}
|
||||
|
||||
@@ -33,12 +33,14 @@ interface UseSchedulerInitProps {
|
||||
adapter: EventCalendarAdapter;
|
||||
visibleCalendarUrlsRef: MutableRefObject<Set<string>>;
|
||||
davCalendarsRef: MutableRefObject<CalDavCalendar[]>;
|
||||
initialView?: string;
|
||||
setCurrentDate: (info: { start: Date; end: Date }) => void;
|
||||
handleEventClick: (info: unknown) => void;
|
||||
handleEventDrop: (info: unknown) => void;
|
||||
handleEventResize: (info: unknown) => void;
|
||||
handleDateClick: (info: unknown) => void;
|
||||
handleSelect: (info: unknown) => void;
|
||||
onEventsLoaded?: () => void;
|
||||
}
|
||||
|
||||
// Helper to get current time as HH:MM string
|
||||
@@ -56,12 +58,14 @@ export const useSchedulerInit = ({
|
||||
adapter,
|
||||
visibleCalendarUrlsRef,
|
||||
davCalendarsRef,
|
||||
initialView = "timeGridWeek",
|
||||
setCurrentDate,
|
||||
handleEventClick,
|
||||
handleEventDrop,
|
||||
handleEventResize,
|
||||
handleDateClick,
|
||||
handleSelect,
|
||||
onEventsLoaded,
|
||||
}: UseSchedulerInitProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { calendarLocale, firstDayOfWeek, formatDayHeader } = useCalendarLocale();
|
||||
@@ -78,6 +82,7 @@ export const useSchedulerInit = ({
|
||||
handleDateClick,
|
||||
handleSelect,
|
||||
setCurrentDate,
|
||||
onEventsLoaded,
|
||||
});
|
||||
|
||||
// Update refs when handlers change (no effect dependencies = no calendar recreation)
|
||||
@@ -89,6 +94,7 @@ export const useSchedulerInit = ({
|
||||
handleDateClick,
|
||||
handleSelect,
|
||||
setCurrentDate,
|
||||
onEventsLoaded,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -100,10 +106,18 @@ export const useSchedulerInit = ({
|
||||
[TimeGrid, DayGrid, List, Interaction],
|
||||
{
|
||||
// View configuration
|
||||
view: "timeGridWeek",
|
||||
view: initialView,
|
||||
// Native toolbar disabled - using custom React toolbar (SchedulerToolbar)
|
||||
headerToolbar: false,
|
||||
|
||||
// Custom views
|
||||
views: {
|
||||
timeGridTwoDays: {
|
||||
type: "timeGridDay",
|
||||
duration: { days: 2 },
|
||||
},
|
||||
},
|
||||
|
||||
// Locale & time settings
|
||||
locale: calendarLocale,
|
||||
firstDay: firstDayOfWeek,
|
||||
@@ -246,7 +260,12 @@ export const useSchedulerInit = ({
|
||||
},
|
||||
],
|
||||
|
||||
// Loading state is handled internally by the calendar
|
||||
// Notify when events finish loading (used by mobile list view)
|
||||
loading: (isLoading: boolean) => {
|
||||
if (!isLoading) {
|
||||
handlersRef.current.onEventsLoaded?.();
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
@use "./../features/calendar/components/scheduler/event-modal-sections/InvitationResponseSection";
|
||||
@use "./../features/calendar/components/scheduler/event-modal-sections/SectionPill";
|
||||
@use "./../features/calendar/components/scheduler/FreeBusyTimeline";
|
||||
@use "./../features/calendar/components/scheduler/mobile/WeekDayBar";
|
||||
@use "./../features/calendar/components/scheduler/mobile/MobileToolbar";
|
||||
@use "./../features/calendar/components/scheduler/mobile/FloatingActionButton";
|
||||
@use "./../features/calendar/components/scheduler/mobile/MobileListView";
|
||||
@use "./../features/resources/components/Resources";
|
||||
@use "./../features/settings/components/WorkingHoursSettings";
|
||||
@use "./../pages/index" as *;
|
||||
@@ -103,6 +107,13 @@ body {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
// Scoped to full-screen modals (mobile event modal uses FULL size)
|
||||
.c__modal--full .c__modal__close button {
|
||||
@media (max-width: 768px) {
|
||||
right: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.c__modal__scroller {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user