♻️(front) integrate custom toolbar in Scheduler

- Integrate SchedulerToolbar component above calendar container
- Disable native headerToolbar in useSchedulerInit
- Add toolbar state (currentView, viewTitle) to Scheduler
- Use datesSet callback to sync toolbar with calendar navigation
- Update CalendarContext to use CalendarApi type for better type safety
- Pass calendarRef to toolbar for API method access

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nathan Panchout
2026-01-28 12:55:55 +01:00
parent 213d1d41c0
commit 8799579606
3 changed files with 220 additions and 124 deletions

View File

@@ -8,6 +8,7 @@
* - Click to edit (eventClick) * - Click to edit (eventClick)
* - Click to create (dateClick) * - Click to create (dateClick)
* - Select range to create (select) * - Select range to create (select)
* - Custom toolbar with navigation and view selection
* *
* Next.js consideration: This component must be client-side only * Next.js consideration: This component must be client-side only
* due to DOM manipulation. Use dynamic import with ssr: false if needed. * due to DOM manipulation. Use dynamic import with ssr: false if needed.
@@ -15,13 +16,13 @@
import "@event-calendar/core/index.css"; import "@event-calendar/core/index.css";
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useCalendarContext } from "../../contexts/CalendarContext"; import { useCalendarContext } from "../../contexts/CalendarContext";
import type { CalDavCalendar } from "../../services/dav/types/caldav-service"; import type { CalDavCalendar } from "../../services/dav/types/caldav-service";
import type { EventCalendarEvent } from "../../services/dav/types/event-calendar";
import { EventModal } from "./EventModal"; import { EventModal } from "./EventModal";
import { SchedulerToolbar } from "./SchedulerToolbar";
import type { SchedulerProps, EventModalState } from "./types"; import type { SchedulerProps, EventModalState } from "./types";
import { useSchedulerHandlers } from "./hooks/useSchedulerHandlers"; import { useSchedulerHandlers } from "./hooks/useSchedulerHandlers";
import { import {
@@ -29,17 +30,6 @@ import {
useSchedulingCapabilitiesCheck, useSchedulingCapabilitiesCheck,
} from "./hooks/useSchedulerInit"; } from "./hooks/useSchedulerInit";
type ECEvent = EventCalendarEvent;
// Calendar API interface
interface CalendarApi {
updateEvent: (event: ECEvent) => void;
addEvent: (event: ECEvent) => void;
unselect: () => void;
refetchEvents: () => void;
$destroy?: () => void;
}
export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => { export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
const { const {
caldavService, caldavService,
@@ -52,9 +42,13 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
} = useCalendarContext(); } = useCalendarContext();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const calendarRef = contextCalendarRef as React.MutableRefObject<CalendarApi | null>; const calendarRef = contextCalendarRef;
const [calendarUrl, setCalendarUrl] = useState(defaultCalendarUrl || ""); const [calendarUrl, setCalendarUrl] = useState(defaultCalendarUrl || "");
// Toolbar state
const [currentView, setCurrentView] = useState("timeGridWeek");
const [viewTitle, setViewTitle] = useState("");
// Modal state // Modal state
const [modalState, setModalState] = useState<EventModalState>({ const [modalState, setModalState] = useState<EventModalState>({
isOpen: false, isOpen: false,
@@ -102,6 +96,25 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
setModalState, setModalState,
}); });
// Callback to update toolbar state when calendar dates/view changes
const handleDatesSet = useCallback(
(info: { start: Date; end: Date; view?: { type: string; title: string } }) => {
// Update current date for MiniCalendar sync
const midTime = (info.start.getTime() + info.end.getTime()) / 2;
setCurrentDate(new Date(midTime));
// Update toolbar state
if (calendarRef.current) {
const view = calendarRef.current.getView();
if (view) {
setCurrentView(view.type);
setViewTitle(view.title);
}
}
},
[setCurrentDate, calendarRef]
);
// Initialize calendar // Initialize calendar
// Cast handlers to bypass library type differences between specific event types and unknown // Cast handlers to bypass library type differences between specific event types and unknown
useSchedulerInit({ useSchedulerInit({
@@ -113,7 +126,7 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
adapter, adapter,
visibleCalendarUrlsRef, visibleCalendarUrlsRef,
davCalendarsRef, davCalendarsRef,
setCurrentDate, setCurrentDate: handleDatesSet,
handleEventClick: handleEventClick as (info: unknown) => void, handleEventClick: handleEventClick as (info: unknown) => void,
handleEventDrop: handleEventDrop as unknown as (info: unknown) => void, handleEventDrop: handleEventDrop as unknown as (info: unknown) => void,
handleEventResize: handleEventResize as unknown as (info: unknown) => void, handleEventResize: handleEventResize as unknown as (info: unknown) => void,
@@ -121,6 +134,17 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
handleSelect: handleSelect as (info: unknown) => void, handleSelect: handleSelect as (info: unknown) => void,
}); });
// Update toolbar title on initial render
useEffect(() => {
if (calendarRef.current) {
const view = calendarRef.current.getView();
if (view) {
setCurrentView(view.type);
setViewTitle(view.title);
}
}
}, [isConnected]);
// Update eventFilter when visible calendars change // Update eventFilter when visible calendars change
useEffect(() => { useEffect(() => {
if (calendarRef.current) { if (calendarRef.current) {
@@ -130,12 +154,24 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
} }
}, [visibleCalendarUrls, davCalendars]); }, [visibleCalendarUrls, davCalendars]);
const handleViewChange = useCallback((view: string) => {
setCurrentView(view);
}, []);
return ( return (
<> <div className="scheduler">
<SchedulerToolbar
calendarRef={calendarRef}
currentView={currentView}
viewTitle={viewTitle}
onViewChange={handleViewChange}
/>
<div <div
ref={containerRef} ref={containerRef}
id="event-calendar" id="event-calendar"
style={{ height: "calc(100vh - 100px)" }} className="scheduler__calendar"
style={{ height: "calc(100vh - 160px)" }}
/> />
<EventModal <EventModal
@@ -150,7 +186,7 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
onRespondToInvitation={handleRespondToInvitation} onRespondToInvitation={handleRespondToInvitation}
onClose={handleModalClose} onClose={handleModalClose}
/> />
</> </div>
); );
}; };

View File

@@ -39,7 +39,7 @@ interface UseSchedulerInitProps {
adapter: EventCalendarAdapter; adapter: EventCalendarAdapter;
visibleCalendarUrlsRef: MutableRefObject<Set<string>>; visibleCalendarUrlsRef: MutableRefObject<Set<string>>;
davCalendarsRef: MutableRefObject<CalDavCalendar[]>; davCalendarsRef: MutableRefObject<CalDavCalendar[]>;
setCurrentDate: (date: Date) => void; setCurrentDate: (info: { start: Date; end: Date }) => void;
handleEventClick: (info: unknown) => void; handleEventClick: (info: unknown) => void;
handleEventDrop: (info: unknown) => void; handleEventDrop: (info: unknown) => void;
handleEventResize: (info: unknown) => void; handleEventResize: (info: unknown) => void;
@@ -75,20 +75,8 @@ export const useSchedulerInit = ({
{ {
// View configuration // View configuration
view: "timeGridWeek", view: "timeGridWeek",
headerToolbar: { // Native toolbar disabled - using custom React toolbar (SchedulerToolbar)
start: "prev,next today", headerToolbar: false,
center: "title",
end: "dayGridMonth,timeGridWeek,timeGridDay,listWeek",
},
// Button text translations
buttonText: {
today: t('calendar.views.today'),
dayGridMonth: t('calendar.views.month'),
timeGridWeek: t('calendar.views.week'),
timeGridDay: t('calendar.views.day'),
listWeek: t('calendar.views.listWeek'),
},
// Locale & time settings // Locale & time settings
locale: calendarLocale, locale: calendarLocale,
@@ -117,9 +105,7 @@ export const useSchedulerInit = ({
// Sync current date with MiniCalendar when navigating // Sync current date with MiniCalendar when navigating
datesSet: (info: { start: Date; end: Date }) => { datesSet: (info: { start: Date; end: Date }) => {
// Use the middle of the visible range as the "current" date setCurrentDate(info);
const midTime = (info.start.getTime() + info.end.getTime()) / 2;
setCurrentDate(new Date(midTime));
}, },
// Event display // Event display

View File

@@ -1,13 +1,25 @@
import { createContext, useContext, useRef, useMemo, useState, useEffect, useCallback, type ReactNode } from "react"; import {
import { Calendar } from "@event-calendar/core"; createContext,
useContext,
useRef,
useMemo,
useState,
useEffect,
useCallback,
type ReactNode,
} from "react";
import { CalDavService } from "../services/dav/CalDavService"; import { CalDavService } from "../services/dav/CalDavService";
import { EventCalendarAdapter } from "../services/dav/EventCalendarAdapter"; import { EventCalendarAdapter } from "../services/dav/EventCalendarAdapter";
import { caldavServerUrl, headers, fetchOptions } from "../utils/DavClient"; import { caldavServerUrl, headers, fetchOptions } from "../utils/DavClient";
import type { CalDavCalendar, CalDavCalendarCreate } from "../services/dav/types/caldav-service"; import type {
CalDavCalendar,
CalDavCalendarCreate,
} from "../services/dav/types/caldav-service";
import type { CalendarApi } from "../components/scheduler/types";
import { createCalendarApi } from "../api"; import { createCalendarApi } from "../api";
export interface CalendarContextType { export interface CalendarContextType {
calendarRef: React.RefObject<Calendar | null>; calendarRef: React.RefObject<CalendarApi | null>;
caldavService: CalDavService; caldavService: CalDavService;
adapter: EventCalendarAdapter; adapter: EventCalendarAdapter;
davCalendars: CalDavCalendar[]; davCalendars: CalDavCalendar[];
@@ -20,19 +32,33 @@ export interface CalendarContextType {
setSelectedDate: (date: Date) => void; setSelectedDate: (date: Date) => void;
refreshCalendars: () => Promise<void>; refreshCalendars: () => Promise<void>;
toggleCalendarVisibility: (calendarUrl: string) => void; toggleCalendarVisibility: (calendarUrl: string) => void;
createCalendar: (params: CalDavCalendarCreate) => Promise<{ success: boolean; error?: string }>; createCalendar: (
updateCalendar: (calendarUrl: string, params: { displayName?: string; color?: string; description?: string }) => Promise<{ success: boolean; error?: string }>; params: CalDavCalendarCreate,
deleteCalendar: (calendarUrl: string) => Promise<{ success: boolean; error?: string }>; ) => Promise<{ success: boolean; error?: string }>;
shareCalendar: (calendarUrl: string, email: string) => Promise<{ success: boolean; error?: string }>; updateCalendar: (
calendarUrl: string,
params: { displayName?: string; color?: string; description?: string },
) => Promise<{ success: boolean; error?: string }>;
deleteCalendar: (
calendarUrl: string,
) => Promise<{ success: boolean; error?: string }>;
shareCalendar: (
calendarUrl: string,
email: string,
) => Promise<{ success: boolean; error?: string }>;
goToDate: (date: Date) => void; goToDate: (date: Date) => void;
} }
const CalendarContext = createContext<CalendarContextType | undefined>(undefined); const CalendarContext = createContext<CalendarContextType | undefined>(
undefined,
);
export const useCalendarContext = () => { export const useCalendarContext = () => {
const context = useContext(CalendarContext); const context = useContext(CalendarContext);
if (!context) { if (!context) {
throw new Error("useCalendarContext must be used within a CalendarContextProvider"); throw new Error(
"useCalendarContext must be used within a CalendarContextProvider",
);
} }
return context; return context;
}; };
@@ -41,12 +67,16 @@ interface CalendarContextProviderProps {
children: ReactNode; children: ReactNode;
} }
export const CalendarContextProvider = ({ children }: CalendarContextProviderProps) => { export const CalendarContextProvider = ({
const calendarRef = useRef<Calendar | null>(null); children,
}: CalendarContextProviderProps) => {
const calendarRef = useRef<CalendarApi | null>(null);
const caldavService = useMemo(() => new CalDavService(), []); const caldavService = useMemo(() => new CalDavService(), []);
const adapter = useMemo(() => new EventCalendarAdapter(), []); const adapter = useMemo(() => new EventCalendarAdapter(), []);
const [davCalendars, setDavCalendars] = useState<CalDavCalendar[]>([]); const [davCalendars, setDavCalendars] = useState<CalDavCalendar[]>([]);
const [visibleCalendarUrls, setVisibleCalendarUrls] = useState<Set<string>>(new Set()); const [visibleCalendarUrls, setVisibleCalendarUrls] = useState<Set<string>>(
new Set(),
);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [currentDate, setCurrentDate] = useState<Date>(new Date()); const [currentDate, setCurrentDate] = useState<Date>(new Date());
@@ -59,7 +89,7 @@ export const CalendarContextProvider = ({ children }: CalendarContextProviderPro
if (result.success && result.data) { if (result.success && result.data) {
setDavCalendars(result.data); setDavCalendars(result.data);
// Initialize all calendars as visible // Initialize all calendars as visible
setVisibleCalendarUrls(new Set(result.data.map(cal => cal.url))); setVisibleCalendarUrls(new Set(result.data.map((cal) => cal.url)));
} else { } else {
console.error("Error fetching calendars:", result.error); console.error("Error fetching calendars:", result.error);
setDavCalendars([]); setDavCalendars([]);
@@ -75,7 +105,7 @@ export const CalendarContextProvider = ({ children }: CalendarContextProviderPro
}, [caldavService]); }, [caldavService]);
const toggleCalendarVisibility = useCallback((calendarUrl: string) => { const toggleCalendarVisibility = useCallback((calendarUrl: string) => {
setVisibleCalendarUrls(prev => { setVisibleCalendarUrls((prev) => {
const newSet = new Set(prev); const newSet = new Set(prev);
if (newSet.has(calendarUrl)) { if (newSet.has(calendarUrl)) {
newSet.delete(calendarUrl); newSet.delete(calendarUrl);
@@ -86,85 +116,124 @@ export const CalendarContextProvider = ({ children }: CalendarContextProviderPro
}); });
}, []); }, []);
const createCalendar = useCallback(async (params: CalDavCalendarCreate): Promise<{ success: boolean; error?: string }> => { const createCalendar = useCallback(
try { async (
// Use Django API to create calendar (creates both CalDAV and Django records) params: CalDavCalendarCreate,
await createCalendarApi({ ): Promise<{ success: boolean; error?: string }> => {
name: params.displayName, try {
color: params.color, // Use Django API to create calendar (creates both CalDAV and Django records)
description: params.description, await createCalendarApi({
}); name: params.displayName,
// Refresh CalDAV calendars list to show the new calendar color: params.color,
await refreshCalendars(); description: params.description,
return { success: true };
} catch (error) {
console.error("Error creating calendar:", error);
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}, [refreshCalendars]);
const updateCalendar = useCallback(async (
calendarUrl: string,
params: { displayName?: string; color?: string; description?: string }
): Promise<{ success: boolean; error?: string }> => {
try {
const result = await caldavService.updateCalendar(calendarUrl, params);
if (result.success) {
await refreshCalendars();
return { success: true };
}
return { success: false, error: result.error || 'Failed to update calendar' };
} catch (error) {
console.error("Error updating calendar:", error);
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}, [caldavService, refreshCalendars]);
const deleteCalendar = useCallback(async (calendarUrl: string): Promise<{ success: boolean; error?: string }> => {
try {
const result = await caldavService.deleteCalendar(calendarUrl);
if (result.success) {
// Remove from visible calendars
setVisibleCalendarUrls(prev => {
const newSet = new Set(prev);
newSet.delete(calendarUrl);
return newSet;
}); });
// Refresh CalDAV calendars list to show the new calendar
await refreshCalendars(); await refreshCalendars();
return { success: true }; return { success: true };
} catch (error) {
console.error("Error creating calendar:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
} }
return { success: false, error: result.error || 'Failed to delete calendar' }; },
} catch (error) { [refreshCalendars],
console.error("Error deleting calendar:", error); );
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}, [caldavService, refreshCalendars]);
const shareCalendar = useCallback(async ( const updateCalendar = useCallback(
calendarUrl: string, async (
email: string calendarUrl: string,
): Promise<{ success: boolean; error?: string }> => { params: { displayName?: string; color?: string; description?: string },
try { ): Promise<{ success: boolean; error?: string }> => {
const result = await caldavService.shareCalendar({ try {
calendarUrl, const result = await caldavService.updateCalendar(calendarUrl, params);
sharees: [{ if (result.success) {
href: `mailto:${email}`, await refreshCalendars();
privilege: 'read-write', // Same rights as principal return { success: true };
}], }
}); return {
if (result.success) { success: false,
return { success: true }; error: result.error || "Failed to update calendar",
};
} catch (error) {
console.error("Error updating calendar:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
} }
return { success: false, error: result.error || 'Failed to share calendar' }; },
} catch (error) { [caldavService, refreshCalendars],
console.error("Error sharing calendar:", error); );
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
} const deleteCalendar = useCallback(
}, [caldavService]); async (
calendarUrl: string,
): Promise<{ success: boolean; error?: string }> => {
try {
const result = await caldavService.deleteCalendar(calendarUrl);
if (result.success) {
// Remove from visible calendars
setVisibleCalendarUrls((prev) => {
const newSet = new Set(prev);
newSet.delete(calendarUrl);
return newSet;
});
await refreshCalendars();
return { success: true };
}
return {
success: false,
error: result.error || "Failed to delete calendar",
};
} catch (error) {
console.error("Error deleting calendar:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
},
[caldavService, refreshCalendars],
);
const shareCalendar = useCallback(
async (
calendarUrl: string,
email: string,
): Promise<{ success: boolean; error?: string }> => {
try {
const result = await caldavService.shareCalendar({
calendarUrl,
sharees: [
{
href: `mailto:${email}`,
privilege: "read-write", // Same rights as principal
},
],
});
if (result.success) {
return { success: true };
}
return {
success: false,
error: result.error || "Failed to share calendar",
};
} catch (error) {
console.error("Error sharing calendar:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
},
[caldavService],
);
const goToDate = useCallback((date: Date) => { const goToDate = useCallback((date: Date) => {
if (calendarRef.current) { if (calendarRef.current) {
calendarRef.current.setOption('date', date); calendarRef.current.setOption("date", date);
} }
}, []); }, []);
@@ -187,7 +256,9 @@ export const CalendarContextProvider = ({ children }: CalendarContextProviderPro
const calendarsResult = await caldavService.fetchCalendars(); const calendarsResult = await caldavService.fetchCalendars();
if (isMounted && calendarsResult.success && calendarsResult.data) { if (isMounted && calendarsResult.success && calendarsResult.data) {
setDavCalendars(calendarsResult.data); setDavCalendars(calendarsResult.data);
setVisibleCalendarUrls(new Set(calendarsResult.data.map(cal => cal.url))); setVisibleCalendarUrls(
new Set(calendarsResult.data.map((cal) => cal.url)),
);
} }
setIsLoading(false); setIsLoading(false);
} else if (isMounted) { } else if (isMounted) {
@@ -210,7 +281,6 @@ export const CalendarContextProvider = ({ children }: CalendarContextProviderPro
}; };
// Note: refreshCalendars is excluded to avoid dependency cycle // Note: refreshCalendars is excluded to avoid dependency cycle
// The initial fetch is done inline in this effect // The initial fetch is done inline in this effect
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [caldavService]); }, [caldavService]);
const value: CalendarContextType = { const value: CalendarContextType = {
@@ -234,5 +304,9 @@ export const CalendarContextProvider = ({ children }: CalendarContextProviderPro
goToDate, goToDate,
}; };
return <CalendarContext.Provider value={value}>{children}</CalendarContext.Provider>; return (
<CalendarContext.Provider value={value}>
{children}
</CalendarContext.Provider>
);
}; };