♻️(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 create (dateClick)
* - Select range to create (select)
* - Custom toolbar with navigation and view selection
*
* Next.js consideration: This component must be client-side only
* due to DOM manipulation. Use dynamic import with ssr: false if needed.
@@ -15,13 +16,13 @@
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 type { CalDavCalendar } from "../../services/dav/types/caldav-service";
import type { EventCalendarEvent } from "../../services/dav/types/event-calendar";
import { EventModal } from "./EventModal";
import { SchedulerToolbar } from "./SchedulerToolbar";
import type { SchedulerProps, EventModalState } from "./types";
import { useSchedulerHandlers } from "./hooks/useSchedulerHandlers";
import {
@@ -29,17 +30,6 @@ import {
useSchedulingCapabilitiesCheck,
} 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) => {
const {
caldavService,
@@ -52,9 +42,13 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
} = useCalendarContext();
const containerRef = useRef<HTMLDivElement>(null);
const calendarRef = contextCalendarRef as React.MutableRefObject<CalendarApi | null>;
const calendarRef = contextCalendarRef;
const [calendarUrl, setCalendarUrl] = useState(defaultCalendarUrl || "");
// Toolbar state
const [currentView, setCurrentView] = useState("timeGridWeek");
const [viewTitle, setViewTitle] = useState("");
// Modal state
const [modalState, setModalState] = useState<EventModalState>({
isOpen: false,
@@ -102,6 +96,25 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
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
// Cast handlers to bypass library type differences between specific event types and unknown
useSchedulerInit({
@@ -113,7 +126,7 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
adapter,
visibleCalendarUrlsRef,
davCalendarsRef,
setCurrentDate,
setCurrentDate: handleDatesSet,
handleEventClick: handleEventClick as (info: unknown) => void,
handleEventDrop: handleEventDrop 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,
});
// 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
useEffect(() => {
if (calendarRef.current) {
@@ -130,12 +154,24 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
}
}, [visibleCalendarUrls, davCalendars]);
const handleViewChange = useCallback((view: string) => {
setCurrentView(view);
}, []);
return (
<>
<div className="scheduler">
<SchedulerToolbar
calendarRef={calendarRef}
currentView={currentView}
viewTitle={viewTitle}
onViewChange={handleViewChange}
/>
<div
ref={containerRef}
id="event-calendar"
style={{ height: "calc(100vh - 100px)" }}
className="scheduler__calendar"
style={{ height: "calc(100vh - 160px)" }}
/>
<EventModal
@@ -150,7 +186,7 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
onRespondToInvitation={handleRespondToInvitation}
onClose={handleModalClose}
/>
</>
</div>
);
};

View File

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

View File

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