Complete recurrence editing and calendar preferences
This commit is contained in:
@@ -26,14 +26,17 @@ import {
|
||||
createSyncSource,
|
||||
deleteCalendar,
|
||||
deleteCalendarEvent,
|
||||
deleteCalendarEventOccurrence,
|
||||
discoverCalDavCalendars,
|
||||
getCalendarEvent,
|
||||
getCalendarViewPreferences,
|
||||
listCalendarEvents,
|
||||
listCalendarEventsDelta,
|
||||
listCalendars,
|
||||
listSyncSources,
|
||||
syncSyncSource,
|
||||
updateCalendar,
|
||||
updateCalendarEvent,
|
||||
updateCalendarEventOccurrence,
|
||||
updateSyncSource,
|
||||
type CalendarCalDavDiscoveryPayload,
|
||||
type CalendarCollection,
|
||||
@@ -41,6 +44,7 @@ import {
|
||||
type CalendarEvent,
|
||||
type CalendarEventCreatePayload,
|
||||
type CalendarSyncSource,
|
||||
type CalendarViewPreferences as CalendarViewPreferencesResponse,
|
||||
} from
|
||||
"../../api/calendar";
|
||||
import {
|
||||
@@ -66,6 +70,7 @@ import {
|
||||
agendaDateLabel,
|
||||
agendaGroupsForDays,
|
||||
calendarEventColorStyle,
|
||||
calendarEventInstanceId,
|
||||
continuousEventWindow,
|
||||
daysBetweenCount,
|
||||
daysForMode,
|
||||
@@ -76,7 +81,6 @@ import {
|
||||
headingForMode,
|
||||
loadCalendarMode,
|
||||
loadCalendarViewPreferences,
|
||||
mergeCalendarEventDelta,
|
||||
moveEventToDay,
|
||||
moveEventToTime,
|
||||
normalizeHexColor,
|
||||
@@ -89,10 +93,19 @@ import {
|
||||
type CalendarDropTarget,
|
||||
type CalendarMode,
|
||||
type CalendarResizeEdge,
|
||||
type CalendarViewPreferences,
|
||||
type ContinuousViewport,
|
||||
} from "./calendarViewModel";
|
||||
|
||||
type EventDialogState = {kind: "create";} | {kind: "edit";event: CalendarEvent;};
|
||||
type EventEditScope = "occurrence" | "series";
|
||||
type EventDialogState =
|
||||
| { kind: "create" }
|
||||
| {
|
||||
kind: "edit";
|
||||
occurrence: CalendarEvent;
|
||||
seriesEvent: CalendarEvent | null;
|
||||
editScope: EventEditScope;
|
||||
};
|
||||
const INITIAL_CONTINUOUS_WEEKS = { before: 4, after: 6 };
|
||||
|
||||
const modeOptions: {id: CalendarMode;label: string;}[] = [
|
||||
@@ -109,8 +122,6 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
const [visibleCalendarIds, setVisibleCalendarIds] = useState<string[]>([]);
|
||||
const [eventCalendarId, setEventCalendarId] = useState("");
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const eventsRef = useRef<CalendarEvent[]>([]);
|
||||
const eventDeltaRef = useRef<{key: string;watermark: string | null;}>({ key: "", watermark: null });
|
||||
const eventRequestRef = useRef(0);
|
||||
const [mode, setMode] = useState<CalendarMode>(() => loadCalendarMode());
|
||||
const [focusDate, setFocusDate] = useState(() => startOfDay(new Date()));
|
||||
@@ -132,7 +143,9 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
const pendingContinuousScrollDayRef = useRef<Date | null>(null);
|
||||
const dragActionRef = useRef<CalendarDragAction | null>(null);
|
||||
|
||||
const viewPreferences = useMemo(() => loadCalendarViewPreferences(), []);
|
||||
const [viewPreferences, setViewPreferences] = useState<CalendarViewPreferences>(
|
||||
() => loadCalendarViewPreferences()
|
||||
);
|
||||
const visibleCalendarIdSet = useMemo(() => new Set(visibleCalendarIds), [visibleCalendarIds]);
|
||||
const visibleEvents = useMemo(() => events.filter((event) => visibleCalendarIdSet.has(event.calendar_id)), [events, visibleCalendarIdSet]);
|
||||
const syncSourceByCalendarId = useMemo(() => new Map(syncSources.map((source) => [source.calendar_id, source])), [syncSources]);
|
||||
@@ -152,6 +165,12 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
const canManageCalendars = hasScope(auth, "calendar:calendar:write");
|
||||
const canDeleteCalendars = hasScope(auth, "calendar:calendar:admin");
|
||||
const heading = headingForMode(mode, focusDate, visibleRange);
|
||||
const dialogEvent =
|
||||
eventDialog?.kind === "edit"
|
||||
? eventDialog.editScope === "series"
|
||||
? eventDialog.seriesEvent
|
||||
: eventDialog.occurrence
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
void loadCalendars();
|
||||
@@ -161,11 +180,35 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
saveCalendarMode(mode);
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const preferences = await getCalendarViewPreferences(settings);
|
||||
if (active) setViewPreferences(calendarViewPreferences(preferences));
|
||||
} catch (err) {
|
||||
if (active) setError(errorText(err));
|
||||
}
|
||||
};
|
||||
const handlePreferenceChange = () => void load();
|
||||
void load();
|
||||
window.addEventListener(
|
||||
"govoplan:calendar-preferences-changed",
|
||||
handlePreferenceChange
|
||||
);
|
||||
return () => {
|
||||
active = false;
|
||||
window.removeEventListener(
|
||||
"govoplan:calendar-preferences-changed",
|
||||
handlePreferenceChange
|
||||
);
|
||||
};
|
||||
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (calendars.length) {
|
||||
void loadEvents();
|
||||
} else {
|
||||
eventsRef.current = [];
|
||||
setEvents([]);
|
||||
}
|
||||
}, [calendars.length, eventWindow.start.getTime(), eventWindow.end.getTime()]);
|
||||
@@ -222,29 +265,17 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
setError("");
|
||||
const start = eventWindow.start.toISOString();
|
||||
const end = eventWindow.end.toISOString();
|
||||
const deltaKey = `${start}|${end}`;
|
||||
try {
|
||||
let since = eventDeltaRef.current.key === deltaKey ? eventDeltaRef.current.watermark : null;
|
||||
let nextEvents = eventDeltaRef.current.key === deltaKey ? eventsRef.current : [];
|
||||
let response = null;
|
||||
do {
|
||||
response = await listCalendarEventsDelta(settings, {
|
||||
start_at: start,
|
||||
end_at: end,
|
||||
since
|
||||
});
|
||||
nextEvents = mergeCalendarEventDelta(nextEvents, response);
|
||||
since = response.watermark || null;
|
||||
} while (response.has_more && response.watermark);
|
||||
const response = await listCalendarEvents(settings, {
|
||||
start_at: start,
|
||||
end_at: end,
|
||||
expand_recurring: true
|
||||
});
|
||||
if (requestId !== eventRequestRef.current) return;
|
||||
eventsRef.current = nextEvents;
|
||||
eventDeltaRef.current = { key: deltaKey, watermark: since };
|
||||
setEvents(nextEvents);
|
||||
setEvents(response.events);
|
||||
} catch (err) {
|
||||
if (requestId !== eventRequestRef.current) return;
|
||||
setError(errorText(err));
|
||||
eventsRef.current = [];
|
||||
eventDeltaRef.current = { key: "", watermark: null };
|
||||
setEvents([]);
|
||||
} finally {
|
||||
if (requestId === eventRequestRef.current) setLoading(false);
|
||||
@@ -352,7 +383,6 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
if (calendars.length > 1 || payload.event_action === "move") {
|
||||
await loadEvents();
|
||||
} else {
|
||||
eventsRef.current = [];
|
||||
setEvents([]);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -469,15 +499,50 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
window.requestAnimationFrame(() => updateContinuousViewport(node));
|
||||
}
|
||||
|
||||
function openEventEditor(event: CalendarEvent) {
|
||||
const recurringOccurrence = Boolean(
|
||||
event.is_occurrence &&
|
||||
event.series_event_id &&
|
||||
event.recurrence_id
|
||||
);
|
||||
setEventDialog({
|
||||
kind: "edit",
|
||||
occurrence: event,
|
||||
seriesEvent: recurringOccurrence ? null : event,
|
||||
editScope: recurringOccurrence ? "occurrence" : "series"
|
||||
});
|
||||
if (!recurringOccurrence || !event.series_event_id) return;
|
||||
const instanceId = calendarEventInstanceId(event);
|
||||
void getCalendarEvent(settings, event.series_event_id)
|
||||
.then((seriesEvent) => {
|
||||
setEventDialog((current) =>
|
||||
current?.kind === "edit" &&
|
||||
calendarEventInstanceId(current.occurrence) === instanceId
|
||||
? { ...current, seriesEvent }
|
||||
: current
|
||||
);
|
||||
})
|
||||
.catch((err) => setError(errorText(err)));
|
||||
}
|
||||
|
||||
function setEventEditScope(scope: EventEditScope) {
|
||||
setEventDialog((current) =>
|
||||
current?.kind === "edit" &&
|
||||
(scope === "occurrence" || current.seriesEvent)
|
||||
? { ...current, editScope: scope }
|
||||
: current
|
||||
);
|
||||
}
|
||||
|
||||
function beginEventDrag(dragEvent: ReactDragEvent<HTMLElement>, action: CalendarDragAction) {
|
||||
if (!canWrite) {
|
||||
dragEvent.preventDefault();
|
||||
return;
|
||||
}
|
||||
dragActionRef.current = action;
|
||||
setDraggingEventId(action.event.id);
|
||||
setDraggingEventId(calendarEventInstanceId(action.event));
|
||||
dragEvent.dataTransfer.effectAllowed = "move";
|
||||
dragEvent.dataTransfer.setData("text/plain", action.event.id);
|
||||
dragEvent.dataTransfer.setData("text/plain", calendarEventInstanceId(action.event));
|
||||
}
|
||||
|
||||
function handleEventDragStart(dragEvent: ReactDragEvent<HTMLElement>, calendarEvent: CalendarEvent) {
|
||||
@@ -553,11 +618,25 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await updateCalendarEvent(settings, calendarEvent.id, {
|
||||
const payload = {
|
||||
start_at: next.startAt.toISOString(),
|
||||
end_at: next.endAt ? next.endAt.toISOString() : null,
|
||||
all_day: next.allDay
|
||||
});
|
||||
};
|
||||
if (
|
||||
calendarEvent.is_occurrence &&
|
||||
calendarEvent.series_event_id &&
|
||||
calendarEvent.recurrence_id
|
||||
) {
|
||||
await updateCalendarEventOccurrence(
|
||||
settings,
|
||||
calendarEvent.series_event_id,
|
||||
calendarEvent.recurrence_id,
|
||||
payload
|
||||
);
|
||||
} else {
|
||||
await updateCalendarEvent(settings, calendarEvent.id, payload);
|
||||
}
|
||||
await loadEvents();
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
@@ -576,11 +655,29 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSave(payload: CalendarEventCreatePayload, event: CalendarEvent | null): Promise<boolean> {
|
||||
async function handleSave(
|
||||
payload: CalendarEventCreatePayload,
|
||||
event: CalendarEvent | null,
|
||||
editScope: EventEditScope
|
||||
): Promise<boolean> {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
if (event) {
|
||||
const currentDialog = eventDialog;
|
||||
if (
|
||||
event &&
|
||||
currentDialog?.kind === "edit" &&
|
||||
editScope === "occurrence" &&
|
||||
currentDialog.occurrence.series_event_id &&
|
||||
currentDialog.occurrence.recurrence_id
|
||||
) {
|
||||
await updateCalendarEventOccurrence(
|
||||
settings,
|
||||
currentDialog.occurrence.series_event_id,
|
||||
currentDialog.occurrence.recurrence_id,
|
||||
payload
|
||||
);
|
||||
} else if (event) {
|
||||
await updateCalendarEvent(settings, event.id, payload);
|
||||
} else {
|
||||
await createCalendarEvent(settings, payload);
|
||||
@@ -600,11 +697,28 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(event: CalendarEvent) {
|
||||
async function handleDelete(
|
||||
event: CalendarEvent,
|
||||
editScope: EventEditScope
|
||||
) {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await deleteCalendarEvent(settings, event.id);
|
||||
const currentDialog = eventDialog;
|
||||
if (
|
||||
currentDialog?.kind === "edit" &&
|
||||
editScope === "occurrence" &&
|
||||
currentDialog.occurrence.series_event_id &&
|
||||
currentDialog.occurrence.recurrence_id
|
||||
) {
|
||||
await deleteCalendarEventOccurrence(
|
||||
settings,
|
||||
currentDialog.occurrence.series_event_id,
|
||||
currentDialog.occurrence.recurrence_id
|
||||
);
|
||||
} else {
|
||||
await deleteCalendarEvent(settings, event.id);
|
||||
}
|
||||
setEventDialog(null);
|
||||
await loadEvents();
|
||||
} catch (err) {
|
||||
@@ -690,15 +804,15 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
<h3>{agendaDateLabel(group.date)}</h3>
|
||||
{group.events.map((event) =>
|
||||
<button
|
||||
key={`${group.key}-${event.id}`}
|
||||
key={`${group.key}-${calendarEventInstanceId(event)}`}
|
||||
type="button"
|
||||
className={["calendar-agenda-item", hoveredEventId === event.id ? "is-linked-hover" : ""].filter(Boolean).join(" ")}
|
||||
className={["calendar-agenda-item", hoveredEventId === calendarEventInstanceId(event) ? "is-linked-hover" : ""].filter(Boolean).join(" ")}
|
||||
style={calendarEventColorStyle(calendarColorById.get(event.calendar_id))}
|
||||
onMouseEnter={() => setHoveredEventId(event.id)}
|
||||
onMouseLeave={() => setHoveredEventId((current) => current === event.id ? "" : current)}
|
||||
onFocus={() => setHoveredEventId(event.id)}
|
||||
onBlur={() => setHoveredEventId((current) => current === event.id ? "" : current)}
|
||||
onClick={() => setEventDialog({ kind: "edit", event })}>
|
||||
onMouseEnter={() => setHoveredEventId(calendarEventInstanceId(event))}
|
||||
onMouseLeave={() => setHoveredEventId((current) => current === calendarEventInstanceId(event) ? "" : current)}
|
||||
onFocus={() => setHoveredEventId(calendarEventInstanceId(event))}
|
||||
onBlur={() => setHoveredEventId((current) => current === calendarEventInstanceId(event) ? "" : current)}
|
||||
onClick={() => openEventEditor(event)}>
|
||||
|
||||
<EventInlineLabel event={event} />
|
||||
</button>
|
||||
@@ -779,7 +893,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
hoveredEventId={hoveredEventId}
|
||||
dropTarget={dropTarget}
|
||||
viewport={continuousViewport}
|
||||
onEventSelect={(event) => setEventDialog({ kind: "edit", event })}
|
||||
onEventSelect={openEventEditor}
|
||||
onEventHover={setHoveredEventId}
|
||||
onEventDragStart={handleEventDragStart}
|
||||
onEventDragEnd={handleEventDragEnd}
|
||||
@@ -800,7 +914,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
draggingEventId={draggingEventId}
|
||||
hoveredEventId={hoveredEventId}
|
||||
dropTarget={dropTarget}
|
||||
onEventSelect={(event) => setEventDialog({ kind: "edit", event })}
|
||||
onEventSelect={openEventEditor}
|
||||
onEventHover={setHoveredEventId}
|
||||
onEventDragStart={handleEventDragStart}
|
||||
onEventDragEnd={handleEventDragEnd}
|
||||
@@ -819,7 +933,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
draggingEventId={draggingEventId}
|
||||
hoveredEventId={hoveredEventId}
|
||||
dropTarget={dropTarget}
|
||||
onEventSelect={(event) => setEventDialog({ kind: "edit", event })}
|
||||
onEventSelect={openEventEditor}
|
||||
onEventHover={setHoveredEventId}
|
||||
onEventDragStart={handleEventDragStart}
|
||||
onEventResizeDragStart={handleEventResizeDragStart}
|
||||
@@ -838,13 +952,28 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
|
||||
{eventDialog && calendars.length > 0 &&
|
||||
<CalendarEventDialog
|
||||
key={
|
||||
eventDialog.kind === "edit"
|
||||
? `${calendarEventInstanceId(eventDialog.occurrence)}:${eventDialog.editScope}`
|
||||
: "new-event"
|
||||
}
|
||||
calendars={calendars}
|
||||
defaultCalendarId={targetCalendarId}
|
||||
event={eventDialog.kind === "edit" ? eventDialog.event : null}
|
||||
event={dialogEvent}
|
||||
editScope={eventDialog.kind === "edit" ? eventDialog.editScope : "series"}
|
||||
canChooseSeries={
|
||||
eventDialog.kind === "edit" &&
|
||||
Boolean(eventDialog.occurrence.is_occurrence)
|
||||
}
|
||||
seriesLoaded={
|
||||
eventDialog.kind !== "edit" ||
|
||||
eventDialog.seriesEvent !== null
|
||||
}
|
||||
focusDate={focusDate}
|
||||
saving={saving}
|
||||
canWrite={canWrite}
|
||||
canDelete={canDelete}
|
||||
onEditScopeChange={setEventEditScope}
|
||||
onCancel={() => setEventDialog(null)}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete} />
|
||||
@@ -887,3 +1016,17 @@ function compareCalendars(left: CalendarCollection, right: CalendarCollection):
|
||||
if (left.is_default !== right.is_default) return left.is_default ? -1 : 1;
|
||||
return left.name.localeCompare(right.name);
|
||||
}
|
||||
|
||||
function calendarViewPreferences(
|
||||
response: CalendarViewPreferencesResponse
|
||||
): CalendarViewPreferences {
|
||||
return {
|
||||
dimWeekends: response.dim_weekends,
|
||||
dimOffHours: response.dim_off_hours,
|
||||
workdayStartHour: response.workday_start_hour,
|
||||
workdayEndHour: response.workday_end_hour,
|
||||
continuousVirtualization: response.continuous_virtualization,
|
||||
continuousOverscanWeeks: response.continuous_overscan_weeks,
|
||||
alternateContinuousMonths: response.alternate_continuous_months
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user