Calendar: edit and delete a single occurrence
Closes #132. Stalwart 0.16.20 accepts a synthetic id on `CalendarEvent/set`, writing a `recurrenceOverrides` entry rather than touching the series, so editing one date of a recurring event is now something the server does and this does too. Editing asks the scope *before* the form opens, because it decides which event the form is even about: a form populated from the master shows the series' start date, so editing Wednesday's standup would have offered to move Monday's. Deleting asks in place of the old confirm. The patch is narrowed rather than posted hopefully. 0.16.20 sorts per-occurrence properties into three groups and only one is honest: ten are refused with `invalidProperties`, twelve more are dropped from the patch while the response still reports success, and the rest are applied. That silent middle group is how #26 reached a live server - a successful response is not evidence anything was written - so `occurrencePatch` throws on the first group, reports the second to the caller, and the editor leaves out the five it always sends. A patch that would be entirely dropped is not sent at all. The refusal for an occurrence of a this-and-future change offers the series instead of a bare error toast. Nothing here writes one of those, but an event synced from another client can carry one. Two things the scope prompt cost, both worth knowing. A dialog is queued in a store the moment it is asked for, so it outlives the effect that asked: without a ref guard a remount queues a second prompt the first answer cannot retract. And gating the *answer* on the effect's cleanup flag is worse - StrictMode runs mount, cleanup, mount, so the flag is already set by the time anyone clicks and the editor never opens. The mock expands recurrences for the first time, which is what makes any of this developable. It hands out synthetic ids for everything including one-offs, gives occurrences a `recurrenceId` and no rule, and reproduces the refusals - including the silent drops, since a mock that applied them would let a client that sends them look correct everywhere but a real server.
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
import { Calendar as CalIcon, CalendarDays, Copy, ExternalLink, Palette, Pencil, Plus, Tag, Trash2, X } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import type { CalendarEvent } from "@/jmap/types";
|
||||
import { useCalendar, isRecurring, type EventInstance } from "@/store/calendar";
|
||||
import { useCalendar, isRecurring, isOccurrence, type EventInstance, type EventScope } from "@/store/calendar";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { formatDayMonth } from "@/lib/datetime";
|
||||
import { MenuItem, MenuSep, MenuTitle, Popover, type Anchor } from "@/ui/popover";
|
||||
import { CALENDAR_COLORS } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, MenuTitle, Popover, type Anchor } from "@/ui/popover";
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { askDeleteScope, askEditScope, droppedMessage, runScoped } from "./scope";
|
||||
import { toLocalDateOnly } from "@/lib/dates";
|
||||
import { formatTime } from "@/lib/format";
|
||||
|
||||
@@ -65,9 +66,13 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
|
||||
const participants = Object.keys(ev.participants ?? {}).length;
|
||||
|
||||
const patch = async (p: Record<string, unknown>, msg: string) => {
|
||||
const scope = await askEditScope(ev);
|
||||
if (!scope) return;
|
||||
try {
|
||||
await cal.updateEvent(ev, p, false, "series");
|
||||
toast.success(msg);
|
||||
const dropped = await runScoped(scope, (s) => cal.updateEvent(ev, p, false, s));
|
||||
if (!dropped) return;
|
||||
// A per-occurrence change can be accepted in part. Say which part.
|
||||
toast.success(droppedMessage(dropped) ?? (scope === "occurrence" ? `${msg} for this date` : msg));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
@@ -88,11 +93,16 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
|
||||
};
|
||||
const del = async () => {
|
||||
onClose();
|
||||
const recurring = isRecurring(ev);
|
||||
if (!(await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", confirmLabel: "Delete", danger: true }))) return;
|
||||
let scope: EventScope | null = "series";
|
||||
if (isRecurring(ev) && isOccurrence(ev)) {
|
||||
scope = await askDeleteScope(ev);
|
||||
} else if (!(await confirmDialog({ title: "Delete this event?", confirmLabel: "Delete", danger: true }))) {
|
||||
scope = null;
|
||||
}
|
||||
if (!scope) return;
|
||||
try {
|
||||
await cal.destroyEvent(ev, participants > 1, "series");
|
||||
toast.success("Event deleted");
|
||||
await runScoped(scope, (s) => cal.destroyEvent(ev, participants > 1, s));
|
||||
toast.success(scope === "occurrence" ? "Occurrence deleted" : "Event deleted");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Plus, Trash2, Users } from "lucide-react";
|
||||
import type { BusyPeriod, CalendarEvent, EmailAddress, JSCalendarAlert, JSCalendarParticipant, JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types";
|
||||
import { useCalendar, myParticipantKeys, isRecurring, eventRule, makeParticipant, participantEmail } from "@/store/calendar";
|
||||
import { useCalendar, myParticipantKeys, isRecurring, isOccurrence, eventRule, makeParticipant, participantEmail, type EventScope } from "@/store/calendar";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
@@ -14,6 +14,7 @@ import { browserTimeZone, dateToZonedLocal, formatDuration, fromInputDateTime, l
|
||||
import { formatClock, formatNumericDate, formatWeekday } from "@/lib/datetime";
|
||||
import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence";
|
||||
import { newKey } from "@/lib/contacts";
|
||||
import { askEditScope, droppedMessage, runScoped } from "./scope";
|
||||
|
||||
export interface EditorInit {
|
||||
event?: CalendarEvent;
|
||||
@@ -24,25 +25,68 @@ export interface EditorInit {
|
||||
|
||||
const ALERT_OPTIONS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880, 10080];
|
||||
|
||||
/**
|
||||
* Fields this form always sends that a single occurrence will not take.
|
||||
*
|
||||
* `useDefaultAlerts` and `calendarIds` are refused with `invalidProperties`;
|
||||
* the rest are dropped from the patch while the response still reports
|
||||
* success. Both halves are reasons not to send them — the second more so,
|
||||
* because nothing would say it had happened.
|
||||
*/
|
||||
const OCCURRENCE_OMIT = new Set(["useDefaultAlerts", "calendarIds", "recurrenceRule", "privacy", "organizerCalendarAddress"]);
|
||||
|
||||
export function EventEditor({ init, onClose }: { init: EditorInit; onClose: () => void }) {
|
||||
const cal = useCalendar();
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const session = useSession((s) => s.session);
|
||||
const [base, setBase] = useState<CalendarEvent | null | undefined>(init.event && !init.event.baseEventId ? init.event : undefined);
|
||||
const [scope, setScope] = useState<EventScope | undefined>(init.event?.baseEventId ? undefined : "series");
|
||||
const editing = Boolean(init.event);
|
||||
|
||||
// Load base event for recurring instances
|
||||
/*
|
||||
* Which event this form is even about has to be settled before it opens.
|
||||
*
|
||||
* A form populated from the master shows the series' start date, so editing
|
||||
* Wednesday's standup would offer to move Monday's — right for the series and
|
||||
* wrong for one date. So the scope is asked first, and the occurrence itself
|
||||
* is what the form loads when the answer is "this occurrence".
|
||||
*/
|
||||
/*
|
||||
* Asked once per event, and deliberately not tied to the effect's lifetime.
|
||||
*
|
||||
* Two things make the obvious version wrong. A dialog is queued in a store
|
||||
* the moment it is requested, so it outlives the effect that asked for it: a
|
||||
* re-run queues a second prompt the first answer cannot retract, and the
|
||||
* reader is asked the same question twice. And gating the *answer* on a
|
||||
* cleanup flag is worse — React's StrictMode runs mount, cleanup, mount, so
|
||||
* the flag is already set by the time anyone clicks and the editor never
|
||||
* opens at all. The ref is what makes this once; the answer is applied
|
||||
* whenever it arrives.
|
||||
*/
|
||||
const asked = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (init.event?.baseEventId) void cal.getEvent(init.event.baseEventId).then((e) => setBase(e));
|
||||
else if (!init.event) setBase(null);
|
||||
const ev = init.event;
|
||||
if (!ev) { setBase(null); setScope("series"); return; }
|
||||
if (!ev.baseEventId) { setBase(ev); setScope("series"); return; }
|
||||
if (asked.current === ev.id) return;
|
||||
asked.current = ev.id;
|
||||
void (async () => {
|
||||
const chosen = isRecurring(ev) && isOccurrence(ev) ? await askEditScope(ev) : "series";
|
||||
if (!chosen) { onClose(); return; }
|
||||
setScope(chosen);
|
||||
if (chosen === "occurrence") setBase(ev);
|
||||
else void cal.getEvent(ev.baseEventId!).then(setBase);
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [init.event?.id]);
|
||||
|
||||
if (base === undefined) return null;
|
||||
return <EventForm key={base?.id ?? "new"} init={init} base={base} editing={editing} onClose={onClose} settingsTz={settings.timeZone ?? browserTimeZone} defaultAlert={settings.defaultAlertMinutes} myEmail={session?.username ?? ""} />;
|
||||
if (base === undefined || scope === undefined) return null;
|
||||
return <EventForm key={base?.id ?? "new"} init={init} base={base} scope={scope} editing={editing} onClose={onClose} settingsTz={settings.timeZone ?? browserTimeZone} defaultAlert={settings.defaultAlertMinutes} myEmail={session?.username ?? ""} />;
|
||||
}
|
||||
|
||||
function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myEmail }: { init: EditorInit; base: CalendarEvent | null; editing: boolean; onClose: () => void; settingsTz: string; defaultAlert: number; myEmail: string }) {
|
||||
function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAlert, myEmail }: { init: EditorInit; base: CalendarEvent | null; scope: EventScope; editing: boolean; onClose: () => void; settingsTz: string; defaultAlert: number; myEmail: string }) {
|
||||
/** This form is editing one date rather than the series behind it. */
|
||||
const oneDate = scope === "occurrence";
|
||||
const cal = useCalendar();
|
||||
const contacts = useContacts();
|
||||
const ev = base;
|
||||
@@ -175,13 +219,23 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
||||
};
|
||||
const invites = sendInvites && attendees.length > 0;
|
||||
if (ev) {
|
||||
/*
|
||||
* A single occurrence takes less than the series does. Four of the
|
||||
* fields this form always sends are among them — `useDefaultAlerts`
|
||||
* and `calendarIds` are refused outright, `recurrenceRule`,
|
||||
* `privacy` and `organizerCalendarAddress` are dropped in silence —
|
||||
* so they are left out here rather than sent and believed. The store
|
||||
* still checks; this is what stops it having to complain.
|
||||
*/
|
||||
const source = oneDate
|
||||
? Object.fromEntries(Object.entries(obj).filter(([k]) => !OCCURRENCE_OMIT.has(k)))
|
||||
: obj;
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj)) patch[k] = v === undefined ? null : v;
|
||||
if (Object.keys(ev.calendarIds)[0] !== calendarId) patch.calendarIds = { [calendarId]: true };
|
||||
// `ev` is the master: EventEditor resolves `baseEventId` when it opens
|
||||
// on an occurrence, so the whole series is what this form edits.
|
||||
await cal.updateEvent(ev, patch, invites, "series");
|
||||
toast.success("Event updated");
|
||||
for (const [k, v] of Object.entries(source)) patch[k] = v === undefined ? null : v;
|
||||
if (!oneDate && Object.keys(ev.calendarIds)[0] !== calendarId) patch.calendarIds = { [calendarId]: true };
|
||||
const dropped = await runScoped(scope, (s) => cal.updateEvent(ev, patch, invites, s));
|
||||
if (!dropped) { setBusy(false); return; }
|
||||
toast.success(droppedMessage(dropped) ?? (oneDate ? "This occurrence updated" : "Event updated"));
|
||||
} else {
|
||||
const clean: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj)) if (v !== undefined) clean[k] = v;
|
||||
@@ -206,7 +260,13 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={editing ? "Edit event" : "New event"} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : editing ? "Save" : attendees.length && sendInvites ? "Send invites" : "Create"}</button></>}>
|
||||
<div className="event-form">
|
||||
{ev && isRecurring(ev) && <div className="info-box mb-16">This is a recurring event — changes apply to the whole series.</div>}
|
||||
{ev && isRecurring(ev) && (
|
||||
<div className="info-box mb-16">
|
||||
{oneDate
|
||||
? `Editing ${formatNumericDate(start)} only — the rest of the series is unchanged. Repeat, calendar and privacy belong to the series and are not shown.`
|
||||
: "This is a recurring event — changes apply to the whole series."}
|
||||
</div>
|
||||
)}
|
||||
<div className="field"><input className="input" style={{ fontSize: "1.1em", height: 44 }} placeholder="Add title" autoFocus value={title} onChange={(e) => setTitle(e.target.value)} /></div>
|
||||
<div className="time-row mb-8">
|
||||
{allDay ? (
|
||||
@@ -231,6 +291,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
||||
{listTimeZones().map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{!oneDate && (
|
||||
<select className="select" style={{ width: "auto", height: 32 }} value={preset} onChange={(e) => { const p = e.target.value as RecurrencePreset; setPreset(p); if (p === "custom") setRule(rule ?? { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: WEEKDAYS[(start.getDay() + 6) % 7]!.key }] }); else setRule(ruleFromPreset(p, start)); }}>
|
||||
<option value="none">Does not repeat</option>
|
||||
<option value="daily">Daily</option>
|
||||
@@ -240,8 +301,9 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
||||
<option value="yearly">Yearly</option>
|
||||
<option value="custom">Custom…</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{preset === "custom" && (
|
||||
{!oneDate && preset === "custom" && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div className="row wrap" style={{ gap: 8 }}>
|
||||
<span>Repeat every</span>
|
||||
@@ -271,7 +333,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
||||
)}
|
||||
<div className="field-row">
|
||||
<div className="field"><label>Calendar</label>
|
||||
<select className="select" value={calendarId} onChange={(e) => setCalendarId(e.target.value)}>
|
||||
<select className="select" value={calendarId} disabled={oneDate} title={oneDate ? "An occurrence cannot be moved to another calendar on its own" : undefined} onChange={(e) => setCalendarId(e.target.value)}>
|
||||
{calendars.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
@@ -329,7 +391,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
||||
<div className="field-row">
|
||||
<div className="field"><label>Status</label><select className="select" value={status} onChange={(e) => setStatus(e.target.value as typeof status)}><option value="confirmed">Confirmed</option><option value="tentative">Tentative</option><option value="cancelled">Cancelled</option></select></div>
|
||||
<div className="field"><label>Show as</label><select className="select" value={freeBusy} onChange={(e) => setFreeBusy(e.target.value as typeof freeBusy)}><option value="busy">Busy</option><option value="free">Free</option></select></div>
|
||||
<div className="field"><label>Visibility</label><select className="select" value={privacy} onChange={(e) => setPrivacy(e.target.value as typeof privacy)}><option value="public">Default</option><option value="private">Private</option><option value="secret">Secret</option></select></div>
|
||||
{!oneDate && <div className="field"><label>Visibility</label><select className="select" value={privacy} onChange={(e) => setPrivacy(e.target.value as typeof privacy)}><option value="public">Default</option><option value="private">Private</option><option value="secret">Secret</option></select></div>}
|
||||
</div>
|
||||
<div className="field"><label>Category</label>
|
||||
<select className="select" value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { AlignLeft, Bell, Calendar as CalIcon, Check, Clock, HelpCircle, Link2, MapPin, Pencil, Repeat, Trash2, Users, X, Mail } from "lucide-react";
|
||||
import { useCalendar, myParticipantKeys, isRecurring, eventRule, participantEmail, type EventInstance } from "@/store/calendar";
|
||||
import { useCalendar, myParticipantKeys, isRecurring, isOccurrence, eventRule, participantEmail, type EventInstance, type EventScope } from "@/store/calendar";
|
||||
import { Popover, type Anchor } from "@/ui/popover";
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { askDeleteScope, runScoped } from "./scope";
|
||||
import { formatTimeRange, humanDuration, parseDuration } from "@/lib/dates";
|
||||
import { describeRule } from "@/lib/recurrence";
|
||||
import { useCompose } from "@/store/compose";
|
||||
@@ -28,13 +29,20 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
|
||||
const openCompose = useCompose((s) => s.open);
|
||||
|
||||
const del = async () => {
|
||||
const recurring = isRecurring(ev);
|
||||
const ok = await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", message: recurring ? "This will delete the entire series." : undefined, confirmLabel: "Delete", danger: true });
|
||||
if (!ok) return;
|
||||
// A series asks which; anything else is a plain confirm. `askDeleteScope`
|
||||
// returns null for a dismissed dialog, which is a cancel and not a series.
|
||||
let scope: EventScope | null = "series";
|
||||
if (isRecurring(ev) && isOccurrence(ev)) {
|
||||
scope = await askDeleteScope(ev);
|
||||
} else {
|
||||
const ok = await confirmDialog({ title: "Delete this event?", confirmLabel: "Delete", danger: true });
|
||||
if (!ok) scope = null;
|
||||
}
|
||||
if (!scope) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await cal.destroyEvent(ev, participants.length > 1, "series");
|
||||
toast.success("Event deleted");
|
||||
await runScoped(scope, (s) => cal.destroyEvent(ev, participants.length > 1, s));
|
||||
toast.success(scope === "occurrence" ? "Occurrence deleted" : "Event deleted");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { choiceDialog, confirmDialog } from "@/ui/dialog";
|
||||
import { isOccurrence, isRecurring, isThisAndFutureRefusal, type EventScope } from "@/store/calendar";
|
||||
import type { CalendarEvent } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* Ask which of a series a change is meant for, when there is a choice.
|
||||
*
|
||||
* There is only a choice when the object in hand is an occurrence of a real
|
||||
* series: a one-off has a synthetic id too, but its only occurrence *is* the
|
||||
* event, so asking would be a question with one true answer. `null` means the
|
||||
* dialog was dismissed, which is not the same as "the whole series" — every
|
||||
* caller has to treat it as a cancel.
|
||||
*
|
||||
* Until 0.16.20 there was nothing to ask: the server refused a write aimed at
|
||||
* an occurrence, so "the whole series" was the only thing that could happen.
|
||||
*/
|
||||
export async function askScope(
|
||||
event: CalendarEvent,
|
||||
opts: { title: string; occurrenceLabel: string; seriesLabel: string; danger?: boolean; occurrenceHint?: string; seriesHint?: string },
|
||||
): Promise<EventScope | null> {
|
||||
if (!isRecurring(event) || !isOccurrence(event)) return "series";
|
||||
const answer = await choiceDialog({
|
||||
title: opts.title,
|
||||
choices: [
|
||||
{ value: "occurrence", label: opts.occurrenceLabel, hint: opts.occurrenceHint, danger: opts.danger },
|
||||
{ value: "series", label: opts.seriesLabel, hint: opts.seriesHint, danger: opts.danger },
|
||||
],
|
||||
});
|
||||
return answer === "occurrence" || answer === "series" ? answer : null;
|
||||
}
|
||||
|
||||
/** The scope question for deleting. */
|
||||
export const askDeleteScope = (event: CalendarEvent): Promise<EventScope | null> =>
|
||||
askScope(event, {
|
||||
title: "Delete this event?",
|
||||
occurrenceLabel: "This occurrence",
|
||||
occurrenceHint: "Removes this date and leaves the rest of the series.",
|
||||
seriesLabel: "All occurrences",
|
||||
seriesHint: "Deletes the whole series. This cannot be undone.",
|
||||
danger: true,
|
||||
});
|
||||
|
||||
/** The scope question for editing. */
|
||||
export const askEditScope = (event: CalendarEvent): Promise<EventScope | null> =>
|
||||
askScope(event, {
|
||||
title: "Change this event?",
|
||||
occurrenceLabel: "This occurrence",
|
||||
occurrenceHint: "Applies to this date only.",
|
||||
seriesLabel: "All occurrences",
|
||||
seriesHint: "Applies to every date in the series.",
|
||||
});
|
||||
|
||||
/**
|
||||
* What to say when the server kept some of a per-occurrence change for the
|
||||
* series. `dropped` comes back from `updateEvent`; an empty list says nothing.
|
||||
*/
|
||||
export function droppedMessage(dropped: string[]): string | null {
|
||||
if (!dropped.length) return null;
|
||||
const names = dropped.map((d) => d.replace(/^@/, "")).join(", ");
|
||||
return `Saved for this date. ${names} ${dropped.length === 1 ? "applies" : "apply"} to the whole series and was left unchanged.`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Run a scoped change, and offer the series if the server will not do one date.
|
||||
*
|
||||
* Stalwart refuses an occurrence that belongs to a this-and-future override —
|
||||
* *"Occurrences of a this-and-future change cannot be modified individually."*
|
||||
* Nothing ihasmail writes creates one, but an event synced from another client
|
||||
* can carry one, so the refusal is reachable and a bare error toast would leave
|
||||
* the reader with no way forward.
|
||||
*
|
||||
* The series is offered rather than silently substituted: they asked for one
|
||||
* date, and doing the larger thing without saying so is the failure this whole
|
||||
* area exists to avoid.
|
||||
*/
|
||||
export async function runScoped<T>(scope: EventScope, run: (scope: EventScope) => Promise<T>): Promise<T | null> {
|
||||
try {
|
||||
return await run(scope);
|
||||
} catch (err) {
|
||||
if (scope !== "occurrence" || !isThisAndFutureRefusal(err)) throw err;
|
||||
const ok = await confirmDialog({
|
||||
title: "This date cannot be changed on its own",
|
||||
message: "It belongs to a change that was applied to this and all later occurrences, which the server will only edit as a whole. Apply to the entire series instead?",
|
||||
confirmLabel: "Apply to series",
|
||||
});
|
||||
return ok ? await run("series") : null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user