Merge pull request #135 from Coffey-Labs/calendar-event-scope
Resolve the base event id in the calendar store, not at the call sites
This commit is contained in:
@@ -0,0 +1,163 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { CAP, client } from "@/jmap/client";
|
||||||
|
import { eventIdForScope, isOccurrence, useCalendar } from "@/store/calendar";
|
||||||
|
import type { CalendarEvent, JmapSession } from "@/jmap/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Through 0.16.19 the server caught a synthetic id for us: `CalendarEvent/set`
|
||||||
|
* refused one outright, so a mutation aimed at the wrong id of an expanded
|
||||||
|
* occurrence arrived as a toast rather than as data loss.
|
||||||
|
*
|
||||||
|
* 0.16.20 accepts it and writes a `recurrenceOverrides` entry instead — a
|
||||||
|
* destroy that meant the series removes one date and reports success, under a
|
||||||
|
* dialog that said "Delete all occurrences?". The resolution therefore lives in
|
||||||
|
* the store behind a required `scope`, and these tests are what stops it
|
||||||
|
* drifting back out to the callers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The shape a live 0.16.19 returns for one occurrence of a weekly series. */
|
||||||
|
const OCCURRENCE: CalendarEvent = {
|
||||||
|
id: "iaaaaas",
|
||||||
|
baseEventId: "i",
|
||||||
|
"@type": "Event",
|
||||||
|
uid: "u1",
|
||||||
|
calendarIds: { c1: true },
|
||||||
|
start: "2026-09-02T09:00:00",
|
||||||
|
duration: "PT30M",
|
||||||
|
recurrenceId: "2026-09-02T09:00:00",
|
||||||
|
participants: {
|
||||||
|
me: { "@type": "Participant", calendarAddress: "mailto:[email protected]", participationStatus: "needs-action", roles: { attendee: true } },
|
||||||
|
},
|
||||||
|
} as unknown as CalendarEvent;
|
||||||
|
|
||||||
|
/** A one-off, which an expanded query still hands back with a base of its own. */
|
||||||
|
const ONE_OFF: CalendarEvent = { ...OCCURRENCE, id: "eaaaaai", baseEventId: "i", recurrenceId: undefined } as unknown as CalendarEvent;
|
||||||
|
|
||||||
|
/** A master, fetched by id rather than expanded. */
|
||||||
|
const MASTER: CalendarEvent = { ...OCCURRENCE, id: "i", baseEventId: undefined, recurrenceId: undefined } as unknown as CalendarEvent;
|
||||||
|
|
||||||
|
interface SetCall { update?: Record<string, unknown>; destroy?: string[] }
|
||||||
|
|
||||||
|
function server() {
|
||||||
|
const calls: SetCall[] = [];
|
||||||
|
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
|
||||||
|
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||||
|
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
||||||
|
if (name === "CalendarEvent/set") {
|
||||||
|
calls.push({ update: args.update as Record<string, unknown>, destroy: args.destroy as string[] });
|
||||||
|
return [name, {
|
||||||
|
accountId: "a1", oldState: "1", newState: "2",
|
||||||
|
updated: Object.fromEntries(Object.keys((args.update ?? {}) as object).map((k) => [k, null])),
|
||||||
|
destroyed: (args.destroy ?? []) as string[],
|
||||||
|
notUpdated: {}, notDestroyed: {},
|
||||||
|
}, id];
|
||||||
|
}
|
||||||
|
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
|
||||||
|
});
|
||||||
|
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client.session = {
|
||||||
|
capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.calendars]: {} },
|
||||||
|
accounts: {},
|
||||||
|
primaryAccounts: {},
|
||||||
|
state: "s1",
|
||||||
|
} as unknown as JmapSession;
|
||||||
|
useCalendar.setState({
|
||||||
|
accountId: "a1",
|
||||||
|
available: true,
|
||||||
|
calendars: {},
|
||||||
|
events: { [OCCURRENCE.id]: OCCURRENCE },
|
||||||
|
ranges: {},
|
||||||
|
identities: [{ id: "id1", name: "Me", calendarAddress: "mailto:[email protected]", sendTo: {}, isDefault: true }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("eventIdForScope", () => {
|
||||||
|
it("walks an occurrence up to its master for the series", () => {
|
||||||
|
expect(eventIdForScope(OCCURRENCE, "series")).toBe("i");
|
||||||
|
});
|
||||||
|
it("sends the instance as it came for a single occurrence", () => {
|
||||||
|
expect(eventIdForScope(OCCURRENCE, "occurrence")).toBe("iaaaaas");
|
||||||
|
});
|
||||||
|
it("resolves a master to itself under either scope", () => {
|
||||||
|
expect(eventIdForScope(MASTER, "series")).toBe("i");
|
||||||
|
expect(eventIdForScope(MASTER, "occurrence")).toBe("i");
|
||||||
|
});
|
||||||
|
it("treats a one-off's synthetic id as a series id, because its base is real", () => {
|
||||||
|
// An expanded query gives a one-off an instance id over a different base.
|
||||||
|
// Stalwart resolves a synthetic id on a component that is neither recurrent
|
||||||
|
// nor an override back to the base event, so both scopes are safe here —
|
||||||
|
// but only `series` sends the id that is unambiguously the event.
|
||||||
|
expect(eventIdForScope(ONE_OFF, "series")).toBe("i");
|
||||||
|
expect(isOccurrence(ONE_OFF)).toBe(true);
|
||||||
|
});
|
||||||
|
it("does not call a master an occurrence", () => {
|
||||||
|
expect(isOccurrence(MASTER)).toBe(false);
|
||||||
|
expect(isOccurrence({ ...MASTER, baseEventId: "i" } as CalendarEvent)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("destroyEvent", () => {
|
||||||
|
it("sends the master id for a series, never the synthetic one", async () => {
|
||||||
|
const calls = server();
|
||||||
|
await useCalendar.getState().destroyEvent(OCCURRENCE, false, "series");
|
||||||
|
expect(calls[0]!.destroy).toEqual(["i"]);
|
||||||
|
expect(calls[0]!.destroy).not.toContain("iaaaaas");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the synthetic id for a single occurrence", async () => {
|
||||||
|
const calls = server();
|
||||||
|
await useCalendar.getState().destroyEvent(OCCURRENCE, false, "occurrence");
|
||||||
|
expect(calls[0]!.destroy).toEqual(["iaaaaas"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the occurrence from the cache without evicting the master", async () => {
|
||||||
|
server();
|
||||||
|
useCalendar.setState({ events: { i: MASTER, iaaaaas: OCCURRENCE } });
|
||||||
|
await useCalendar.getState().destroyEvent(OCCURRENCE, false, "occurrence");
|
||||||
|
expect(useCalendar.getState().events.iaaaaas).toBeUndefined();
|
||||||
|
expect(useCalendar.getState().events.i).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateEvent", () => {
|
||||||
|
it("patches the master for a series", async () => {
|
||||||
|
const calls = server();
|
||||||
|
await useCalendar.getState().updateEvent(OCCURRENCE, { color: "#f00" }, false, "series");
|
||||||
|
expect(Object.keys(calls[0]!.update!)).toEqual(["i"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("patches the instance for a single occurrence", async () => {
|
||||||
|
const calls = server();
|
||||||
|
await useCalendar.getState().updateEvent(OCCURRENCE, { color: "#f00" }, false, "occurrence");
|
||||||
|
expect(Object.keys(calls[0]!.update!)).toEqual(["iaaaaas"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rsvp", () => {
|
||||||
|
it("answers for the series even when handed an occurrence", async () => {
|
||||||
|
// The patch itself survives either scope: `participationStatus` is one of
|
||||||
|
// the pointers 0.16.20 allows on an occurrence, so an RSVP aimed at an
|
||||||
|
// instance would quietly mean "only that day" and nothing would say so.
|
||||||
|
const calls = server();
|
||||||
|
await useCalendar.getState().rsvp(OCCURRENCE, "accepted");
|
||||||
|
expect(Object.keys(calls[0]!.update!)).toEqual(["i"]);
|
||||||
|
expect(calls[0]!.update!.i).toEqual({ "participants/me/participationStatus": "accepted" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses when the signed-in identity is not a participant", async () => {
|
||||||
|
server();
|
||||||
|
useCalendar.setState({ identities: [{ id: "id2", name: "Someone", calendarAddress: "mailto:[email protected]", sendTo: {}, isDefault: true }] });
|
||||||
|
await expect(useCalendar.getState().rsvp(OCCURRENCE, "accepted")).rejects.toThrow(/not a participant/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
+63
-11
@@ -46,6 +46,43 @@ export const CALENDAR_PROPS = [
|
|||||||
"myRights",
|
"myRights",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which of an event's two ids a mutation means.
|
||||||
|
*
|
||||||
|
* `CalendarEvent/query` runs with `expandRecurrences`, so an occurrence arrives
|
||||||
|
* carrying a synthetic `id` of its own *and* a `baseEventId` pointing at the
|
||||||
|
* master it was expanded from. Sending one where the other was meant is not a
|
||||||
|
* distinction the server will make for us:
|
||||||
|
*
|
||||||
|
* - Through 0.16.19 a synthetic id was refused outright — *"Updating synthetic
|
||||||
|
* ids is not yet supported"* — so a slip was loud and arrived as a toast.
|
||||||
|
* - 0.16.20 accepts it, and writes a `recurrenceOverrides` entry instead. A
|
||||||
|
* destroy that meant the series now removes one date and reports success,
|
||||||
|
* under a dialog that said "Delete all occurrences?".
|
||||||
|
*
|
||||||
|
* So the choice is named and required rather than left to each caller to
|
||||||
|
* remember a `??`. There is exactly one place that turns an event into an id,
|
||||||
|
* and it is below.
|
||||||
|
*/
|
||||||
|
export type EventScope = "series" | "occurrence";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The id to send for `scope`.
|
||||||
|
*
|
||||||
|
* `series` walks up to the master; `occurrence` sends the instance as it came.
|
||||||
|
* A one-off is safe either way — it has a synthetic id like everything an
|
||||||
|
* expanded query returns, and Stalwart resolves a synthetic id on a component
|
||||||
|
* that is neither recurrent nor an override back to the base event itself.
|
||||||
|
*/
|
||||||
|
export function eventIdForScope(event: CalendarEvent, scope: EventScope): Id {
|
||||||
|
return scope === "series" ? (event.baseEventId ?? event.id) : event.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this object is an expanded occurrence rather than a master. */
|
||||||
|
export function isOccurrence(event: CalendarEvent): boolean {
|
||||||
|
return event.baseEventId != null && event.baseEventId !== event.id;
|
||||||
|
}
|
||||||
|
|
||||||
/** A calendar somebody else shared, and the account it lives in. */
|
/** A calendar somebody else shared, and the account it lives in. */
|
||||||
export interface SharedCalendar {
|
export interface SharedCalendar {
|
||||||
accountId: Id;
|
accountId: Id;
|
||||||
@@ -85,9 +122,9 @@ interface CalendarState {
|
|||||||
instancesIn(start: Date, end: Date): EventInstance[];
|
instancesIn(start: Date, end: Date): EventInstance[];
|
||||||
getEvent(id: Id): Promise<CalendarEvent | null>;
|
getEvent(id: Id): Promise<CalendarEvent | null>;
|
||||||
createEvent(event: Partial<CalendarEvent>, calendarId: Id, sendInvites: boolean): Promise<Id>;
|
createEvent(event: Partial<CalendarEvent>, calendarId: Id, sendInvites: boolean): Promise<Id>;
|
||||||
updateEvent(id: Id, patch: Record<string, unknown>, sendInvites: boolean): Promise<void>;
|
updateEvent(event: CalendarEvent, patch: Record<string, unknown>, sendInvites: boolean, scope: EventScope): Promise<void>;
|
||||||
destroyEvent(id: Id, sendInvites: boolean): Promise<void>;
|
destroyEvent(event: CalendarEvent, sendInvites: boolean, scope: EventScope): Promise<void>;
|
||||||
rsvp(id: Id, status: "accepted" | "tentative" | "declined", comment?: string): Promise<void>;
|
rsvp(event: CalendarEvent, status: "accepted" | "tentative" | "declined", comment?: string): Promise<void>;
|
||||||
createCalendar(data: Partial<Calendar>): Promise<Id>;
|
createCalendar(data: Partial<Calendar>): Promise<Id>;
|
||||||
updateCalendar(id: Id, patch: Partial<Calendar>): Promise<void>;
|
updateCalendar(id: Id, patch: Partial<Calendar>): Promise<void>;
|
||||||
destroyCalendar(id: Id): Promise<void>;
|
destroyCalendar(id: Id): Promise<void>;
|
||||||
@@ -359,39 +396,46 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
|||||||
return res.created!.e!.id;
|
return res.created!.e!.id;
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateEvent(id, patch, sendInvites) {
|
async updateEvent(event, patch, sendInvites, scope) {
|
||||||
const accountId = get().accountId!;
|
const accountId = get().accountId!;
|
||||||
|
const id = eventIdForScope(event, scope);
|
||||||
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, update: { [id]: patch }, sendSchedulingMessages: sendInvites });
|
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, update: { [id]: patch }, sendSchedulingMessages: sendInvites });
|
||||||
const err = res.notUpdated?.[id];
|
const err = res.notUpdated?.[id];
|
||||||
if (err) throw new Error(setErrorMessage(err));
|
if (err) throw new Error(setErrorMessage(err));
|
||||||
get().invalidate();
|
get().invalidate();
|
||||||
},
|
},
|
||||||
|
|
||||||
async destroyEvent(id, sendInvites) {
|
async destroyEvent(event, sendInvites, scope) {
|
||||||
const accountId = get().accountId!;
|
const accountId = get().accountId!;
|
||||||
|
const id = eventIdForScope(event, scope);
|
||||||
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, destroy: [id], sendSchedulingMessages: sendInvites });
|
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, destroy: [id], sendSchedulingMessages: sendInvites });
|
||||||
const err = res.notDestroyed?.[id];
|
const err = res.notDestroyed?.[id];
|
||||||
if (err) throw new Error(setErrorMessage(err));
|
if (err) throw new Error(setErrorMessage(err));
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const events = { ...s.events };
|
const events = { ...s.events };
|
||||||
|
// Drop both ids: the one that was sent, and the object as the caller
|
||||||
|
// held it. An occurrence destroy leaves the master alone on purpose.
|
||||||
delete events[id];
|
delete events[id];
|
||||||
|
if (scope === "occurrence") delete events[event.id];
|
||||||
return { events };
|
return { events };
|
||||||
});
|
});
|
||||||
get().invalidate();
|
get().invalidate();
|
||||||
},
|
},
|
||||||
|
|
||||||
async rsvp(id, status, comment) {
|
async rsvp(event, status, comment) {
|
||||||
const ev = get().events[id] ?? (await get().getEvent(id));
|
const mine = myParticipantKeys(event, get().identities);
|
||||||
if (!ev) throw new Error("Event not found");
|
|
||||||
id = ev.baseEventId ?? id;
|
|
||||||
const mine = myParticipantKeys(ev, get().identities);
|
|
||||||
if (!mine.length) throw new Error("You are not a participant of this event");
|
if (!mine.length) throw new Error("You are not a participant of this event");
|
||||||
const patch: Record<string, unknown> = {};
|
const patch: Record<string, unknown> = {};
|
||||||
for (const k of mine) {
|
for (const k of mine) {
|
||||||
patch[`participants/${k}/participationStatus`] = status;
|
patch[`participants/${k}/participationStatus`] = status;
|
||||||
if (comment) patch[`participants/${k}/participationComment`] = comment;
|
if (comment) patch[`participants/${k}/participationComment`] = comment;
|
||||||
}
|
}
|
||||||
await get().updateEvent(id, patch, true);
|
// Answering for the series, not for one date. The patch itself survives
|
||||||
|
// either scope -- `participants/{key}/participationStatus` is one of the
|
||||||
|
// pointers 0.16.20 allows on an occurrence -- so this would silently mean
|
||||||
|
// "only that day" if it were aimed at an instance. Accepting an invitation
|
||||||
|
// means accepting the series.
|
||||||
|
await get().updateEvent(event, patch, true, "series");
|
||||||
},
|
},
|
||||||
|
|
||||||
async createCalendar(data) {
|
async createCalendar(data) {
|
||||||
@@ -436,6 +480,14 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
|||||||
return res.list ?? [];
|
return res.list ?? [];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event with this uid, as a master rather than an occurrence.
|
||||||
|
*
|
||||||
|
* The query deliberately omits `expandRecurrences`, so what comes back is the
|
||||||
|
* stored event and `id` is a real id. Callers rely on that — `InviteCard`
|
||||||
|
* removes a cancelled event by handing this straight to `destroyEvent` — so
|
||||||
|
* it is a property of this method, not an accident of the default.
|
||||||
|
*/
|
||||||
async findByUid(uid) {
|
async findByUid(uid) {
|
||||||
const accountId = get().accountId;
|
const accountId = get().accountId;
|
||||||
if (!accountId) return null;
|
if (!accountId) return null;
|
||||||
|
|||||||
@@ -60,14 +60,13 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
|
|||||||
|
|
||||||
const { inst } = ctx;
|
const { inst } = ctx;
|
||||||
const ev = inst.event;
|
const ev = inst.event;
|
||||||
const baseId = ev.baseEventId ?? ev.id;
|
|
||||||
const canEdit = inst.calendar?.myRights.mayWriteAll || inst.calendar?.myRights.mayWriteOwn || !inst.calendar;
|
const canEdit = inst.calendar?.myRights.mayWriteAll || inst.calendar?.myRights.mayWriteOwn || !inst.calendar;
|
||||||
const currentCat = categoryOf(ev, categories);
|
const currentCat = categoryOf(ev, categories);
|
||||||
const participants = Object.keys(ev.participants ?? {}).length;
|
const participants = Object.keys(ev.participants ?? {}).length;
|
||||||
|
|
||||||
const patch = async (p: Record<string, unknown>, msg: string) => {
|
const patch = async (p: Record<string, unknown>, msg: string) => {
|
||||||
try {
|
try {
|
||||||
await cal.updateEvent(baseId, p, false);
|
await cal.updateEvent(ev, p, false, "series");
|
||||||
toast.success(msg);
|
toast.success(msg);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error((err as Error).message);
|
toast.error((err as Error).message);
|
||||||
@@ -92,7 +91,7 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
|
|||||||
const recurring = isRecurring(ev);
|
const recurring = isRecurring(ev);
|
||||||
if (!(await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", confirmLabel: "Delete", danger: true }))) return;
|
if (!(await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", confirmLabel: "Delete", danger: true }))) return;
|
||||||
try {
|
try {
|
||||||
await cal.destroyEvent(baseId, participants > 1);
|
await cal.destroyEvent(ev, participants > 1, "series");
|
||||||
toast.success("Event deleted");
|
toast.success("Event deleted");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error((err as Error).message);
|
toast.error((err as Error).message);
|
||||||
|
|||||||
@@ -178,7 +178,9 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
|||||||
const patch: Record<string, unknown> = {};
|
const patch: Record<string, unknown> = {};
|
||||||
for (const [k, v] of Object.entries(obj)) patch[k] = v === undefined ? null : v;
|
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 };
|
if (Object.keys(ev.calendarIds)[0] !== calendarId) patch.calendarIds = { [calendarId]: true };
|
||||||
await cal.updateEvent(ev.id, patch, invites);
|
// `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");
|
toast.success("Event updated");
|
||||||
} else {
|
} else {
|
||||||
const clean: Record<string, unknown> = {};
|
const clean: Record<string, unknown> = {};
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
|
|||||||
const myStatus = myKeys.length ? ev.participants?.[myKeys[0]!]?.participationStatus : undefined;
|
const myStatus = myKeys.length ? ev.participants?.[myKeys[0]!]?.participationStatus : undefined;
|
||||||
const isOrganizer = ev.isOrigin !== false && (!participants.length || participants.some(([k, p]) => p.roles?.owner && myKeys.includes(k)));
|
const isOrganizer = ev.isOrigin !== false && (!participants.length || participants.some(([k, p]) => p.roles?.owner && myKeys.includes(k)));
|
||||||
const canEdit = inst.calendar?.myRights.mayWriteAll || (inst.calendar?.myRights.mayWriteOwn && isOrganizer) || !inst.calendar;
|
const canEdit = inst.calendar?.myRights.mayWriteAll || (inst.calendar?.myRights.mayWriteOwn && isOrganizer) || !inst.calendar;
|
||||||
const baseId = ev.baseEventId ?? ev.id;
|
|
||||||
const location = Object.values(ev.locations ?? {})[0];
|
const location = Object.values(ev.locations ?? {})[0];
|
||||||
const vloc = Object.values(ev.virtualLocations ?? {})[0];
|
const vloc = Object.values(ev.virtualLocations ?? {})[0];
|
||||||
const alerts = Object.values(ev.alerts ?? {});
|
const alerts = Object.values(ev.alerts ?? {});
|
||||||
@@ -34,7 +33,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
|
|||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await cal.destroyEvent(baseId, participants.length > 1);
|
await cal.destroyEvent(ev, participants.length > 1, "series");
|
||||||
toast.success("Event deleted");
|
toast.success("Event deleted");
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -47,7 +46,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
|
|||||||
const rsvp = async (status: "accepted" | "tentative" | "declined") => {
|
const rsvp = async (status: "accepted" | "tentative" | "declined") => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await cal.rsvp(baseId, status);
|
await cal.rsvp(ev, status);
|
||||||
toast.success("Response sent");
|
toast.success("Response sent");
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
|
|||||||
target = await cal.getEvent(id);
|
target = await cal.getEvent(id);
|
||||||
}
|
}
|
||||||
if (!target) throw new Error("Could not add the event to your calendar");
|
if (!target) throw new Error("Could not add the event to your calendar");
|
||||||
await cal.rsvp(target.id, status);
|
await cal.rsvp(target, status);
|
||||||
setExisting(await cal.getEvent(target.id));
|
setExisting(await cal.getEvent(target.id));
|
||||||
toast.success(status === "accepted" ? "Invitation accepted" : status === "declined" ? "Invitation declined" : "Marked as tentative");
|
toast.success(status === "accepted" ? "Invitation accepted" : status === "declined" ? "Invitation declined" : "Marked as tentative");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -115,7 +115,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
|
|||||||
)}
|
)}
|
||||||
{method === "CANCEL" && existing && (
|
{method === "CANCEL" && existing && (
|
||||||
<div className="rsvp">
|
<div className="rsvp">
|
||||||
<button className="btn btn-sm btn-danger" disabled={Boolean(busy)} onClick={async () => { try { await cal.destroyEvent(existing.id, false); setExisting(null); toast.success("Removed from calendar"); } catch (err) { toast.error((err as Error).message); } }}>Remove from calendar</button>
|
<button className="btn btn-sm btn-danger" disabled={Boolean(busy)} onClick={async () => { try { await cal.destroyEvent(existing, false, "series"); setExisting(null); toast.success("Removed from calendar"); } catch (err) { toast.error((err as Error).message); } }}>Remove from calendar</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="sr-only">{email.id}</span>
|
<span className="sr-only">{email.id}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user