@remit/web-client 0.0.231 → 0.0.232

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/src/components/calendar/CalendarComposePane.stories.tsx +101 -11
  3. package/src/components/calendar/CalendarComposePane.tsx +83 -23
  4. package/src/components/calendar/CalendarComposeSeed.tsx +35 -0
  5. package/src/components/calendar/CalendarEventPane.stories.tsx +30 -1
  6. package/src/components/calendar/CalendarEventPane.tsx +28 -5
  7. package/src/components/calendar/CalendarWorkspace.stories.tsx +22 -0
  8. package/src/components/calendar/CalendarWorkspace.tsx +31 -1
  9. package/src/components/calendar/OpenCalendarEvent.tsx +189 -7
  10. package/src/components/calendar/WriteCalendarEvent.tsx +94 -0
  11. package/src/components/calendar/calendar-write-flow.render.test.ts +356 -0
  12. package/src/hooks/calendar/calendar-reads.render.test.ts +265 -0
  13. package/src/hooks/calendar/calendar-writes.render.test.ts +305 -0
  14. package/src/hooks/calendar/draft.test.ts +241 -0
  15. package/src/hooks/calendar/draft.ts +168 -0
  16. package/src/hooks/calendar/index.ts +59 -0
  17. package/src/hooks/calendar/instance.ts +136 -0
  18. package/src/hooks/calendar/recurrence-rule.test.ts +92 -0
  19. package/src/hooks/calendar/recurrence-rule.ts +133 -0
  20. package/src/hooks/calendar/resource.test.ts +98 -0
  21. package/src/hooks/calendar/resource.ts +74 -0
  22. package/src/hooks/calendar/useCalendarEvent.ts +46 -0
  23. package/src/hooks/calendar/useCalendarEvents.ts +124 -0
  24. package/src/hooks/calendar/useCalendarWrites.ts +174 -0
  25. package/src/hooks/calendar/useCalendars.ts +43 -0
  26. package/src/hooks/calendar/window.test.ts +148 -0
  27. package/src/hooks/calendar/window.ts +163 -0
  28. package/src/hooks/useCalendarData.ts +70 -22
  29. package/src/lib/calendar-route.ts +7 -3
  30. package/src/routes/calendar/$view.$date/new.tsx +2 -2
  31. package/src/routes/calendar/$view.$date.tsx +43 -15
  32. package/src/routing/calendar.ts +19 -8
  33. package/src/test-support/http.ts +8 -0
  34. package/src/lib/calendar-fixtures.ts +0 -207
@@ -1,13 +1,42 @@
1
+ import {
2
+ type EventDraft,
3
+ type RecurrenceScope,
4
+ RecurrenceScopePrompt,
5
+ } from "@remit/ui";
6
+ import { useState } from "react";
7
+ import { CalendarComposePane } from "@/components/calendar/CalendarComposePane";
1
8
  import { CalendarEventPane } from "@/components/calendar/CalendarEventPane";
9
+ import {
10
+ type CalendarWriteOutcome,
11
+ calendarInstanceId,
12
+ draftFromEvent,
13
+ patchFromDrafts,
14
+ rruleFromIcalData,
15
+ type ScopedWrite,
16
+ textFromIcalData,
17
+ textFromRrule,
18
+ UNZONED_CALENDAR,
19
+ useCalendarEvent,
20
+ useCalendars,
21
+ useCalendarWrites,
22
+ } from "@/hooks/calendar";
2
23
  import { useCalendarData } from "@/hooks/useCalendarData";
24
+ import { formatEventWhen } from "@/lib/calendar-format";
3
25
  import { useCalendarAddress, useCalendarNavigation } from "@/routing";
4
26
 
5
27
  /**
6
- * The event the address names, resolved.
28
+ * The event the address names, resolved — and everything a reader can do to it.
7
29
  *
8
- * The two event routes — the series and one of its occurrences — differ only in
9
- * what the address says, so the lookup and the way back live here once rather
10
- * than in each of them.
30
+ * The two event routes differ only in what the address says, so the lookup, the
31
+ * scope question and the way back live here once rather than in each of them.
32
+ *
33
+ * An edit or a delete on a repeating event asks which occurrences it means
34
+ * before the form opens, not on the way out: which instances it reaches changes
35
+ * what the change is, so it is settled while it can still be answered.
36
+ *
37
+ * Every write carries the etag of the version on screen. A 412 means somebody
38
+ * replaced the event in between, and it is said out loud rather than resolved
39
+ * by overwriting them.
11
40
  */
12
41
  export interface OpenCalendarEventProps {
13
42
  calendarObjectId: string;
@@ -15,24 +44,177 @@ export interface OpenCalendarEventProps {
15
44
  recurrenceId?: string;
16
45
  }
17
46
 
47
+ const CONFLICT =
48
+ "This event changed somewhere else — over CalDAV, or in another tab. Nothing was saved. Close it and open it again to see the version that's stored now.";
49
+
50
+ type Intent = "edit" | "delete";
51
+
18
52
  export function OpenCalendarEvent({
19
53
  calendarObjectId,
20
54
  recurrenceId,
21
55
  }: OpenCalendarEventProps) {
22
56
  const { view, date, calendarIds } = useCalendarAddress();
23
- const { events, calendars } = useCalendarData({ view, date, calendarIds });
57
+ const { events } = useCalendarData({ view, date, calendarIds });
58
+ const { calendars, timeZoneByCalendarId } = useCalendars();
24
59
  const { closeEvent } = useCalendarNavigation();
60
+ const { updateEvent, deleteEvent, isWriting } = useCalendarWrites();
25
61
 
26
- const event = events.find((candidate) => candidate.id === calendarObjectId);
62
+ const event = events.find(
63
+ (candidate) =>
64
+ candidate.id === calendarInstanceId(calendarObjectId, recurrenceId ?? ""),
65
+ );
27
66
  const calendar = calendars.find(
28
67
  (candidate) => candidate.id === event?.calendarId,
29
68
  );
69
+ const { resource } = useCalendarEvent(
70
+ calendarObjectId,
71
+ event?.calendarId ?? "",
72
+ );
73
+
74
+ const [asking, setAsking] = useState<Intent | undefined>(undefined);
75
+ const [editing, setEditing] = useState<
76
+ { before: EventDraft; scope: RecurrenceScope | undefined } | undefined
77
+ >(undefined);
78
+ const [draft, setDraft] = useState<EventDraft | undefined>(undefined);
79
+ const [problem, setProblem] = useState("");
80
+
81
+ const stored = resource ? textFromIcalData(resource.icalData) : undefined;
82
+ const rrule = resource ? rruleFromIcalData(resource.icalData) : "";
83
+ const startTime = event && !event.allDay ? event.start.slice(11, 16) : "";
84
+ const repeat =
85
+ rrule === "" ? "" : (textFromRrule(rrule, startTime) ?? "Repeats");
86
+
87
+ // Scoping needs the occurrence to anchor at, so it is offered only where the
88
+ // address names one. A series opened by itself has one honest answer, which
89
+ // is the whole of it.
90
+ const canScope =
91
+ event !== undefined &&
92
+ event.seriesId !== "" &&
93
+ recurrenceId !== undefined &&
94
+ recurrenceId !== "";
95
+ // Until the resource is read there is no etag, and a write with no etag is a
96
+ // write that can silently overwrite somebody.
97
+ const writable = event !== undefined && resource !== undefined;
98
+
99
+ const settle = (outcome: CalendarWriteOutcome, done: () => void) => {
100
+ if (outcome.kind === "written") {
101
+ setProblem("");
102
+ done();
103
+ return;
104
+ }
105
+ setProblem(outcome.kind === "conflict" ? CONFLICT : outcome.message);
106
+ };
107
+
108
+ const write = (scope: RecurrenceScope | undefined): ScopedWrite => ({
109
+ calendarObjectId,
110
+ calendarId: event?.calendarId ?? "",
111
+ recurrenceId: recurrenceId ?? "",
112
+ scope,
113
+ etag: resource?.etag ?? "",
114
+ });
115
+
116
+ const startEdit = (scope: RecurrenceScope | undefined) => {
117
+ if (!event) return;
118
+ const before = draftFromEvent(event, {
119
+ repeat,
120
+ location: stored?.location ?? "",
121
+ notes: stored?.description ?? "",
122
+ });
123
+ setAsking(undefined);
124
+ setEditing({ before, scope });
125
+ setDraft(before);
126
+ setProblem("");
127
+ };
128
+
129
+ const removeEvent = (scope: RecurrenceScope | undefined) => {
130
+ setAsking(undefined);
131
+ void deleteEvent(write(scope)).then((outcome) =>
132
+ settle(outcome, closeEvent),
133
+ );
134
+ };
135
+
136
+ const saveEdit = () => {
137
+ if (!editing || !draft) return;
138
+ const patch = patchFromDrafts(
139
+ editing.before,
140
+ draft,
141
+ timeZoneByCalendarId[draft.calendarId] ?? UNZONED_CALENDAR,
142
+ );
143
+ if (!patch.ok) {
144
+ setProblem(patch.problem);
145
+ return;
146
+ }
147
+ void updateEvent(write(editing.scope), patch.patch).then((outcome) =>
148
+ settle(outcome, () => {
149
+ setEditing(undefined);
150
+ setDraft(undefined);
151
+ }),
152
+ );
153
+ };
154
+
155
+ if (asking !== undefined && event) {
156
+ return (
157
+ <div className="flex h-full flex-col justify-center bg-surface px-row-inset">
158
+ <RecurrenceScopePrompt
159
+ className="mx-auto w-full max-w-md"
160
+ title={event.title}
161
+ ruleText={repeat === "" ? "It repeats" : repeat}
162
+ instanceText={formatEventWhen(event)}
163
+ onChoose={asking === "edit" ? startEdit : removeEvent}
164
+ onCancel={() => setAsking(undefined)}
165
+ />
166
+ </div>
167
+ );
168
+ }
169
+
170
+ if (editing && draft) {
171
+ return (
172
+ <CalendarComposePane
173
+ title="Edit event"
174
+ subtitle={
175
+ editing.scope === "this" ? "This occurrence only" : draft.date
176
+ }
177
+ calendars={calendars}
178
+ draft={draft}
179
+ onChange={setDraft}
180
+ problem={problem}
181
+ saveLabel="Save"
182
+ isSaving={isWriting}
183
+ repeatEditable={editing.scope === undefined || editing.scope === "all"}
184
+ calendarEditable={false}
185
+ onSave={saveEdit}
186
+ onCancel={() => {
187
+ setEditing(undefined);
188
+ setDraft(undefined);
189
+ setProblem("");
190
+ }}
191
+ />
192
+ );
193
+ }
30
194
 
31
195
  return (
32
196
  <CalendarEventPane
33
- event={event}
197
+ event={
198
+ event && {
199
+ ...event,
200
+ location: stored?.location ?? "",
201
+ notes: stored?.description ?? "",
202
+ recurrenceRule: repeat,
203
+ }
204
+ }
34
205
  calendar={calendar}
35
206
  isOccurrence={recurrenceId !== undefined}
207
+ problem={problem}
208
+ onEdit={
209
+ writable
210
+ ? () => (canScope ? setAsking("edit") : startEdit(undefined))
211
+ : undefined
212
+ }
213
+ onDelete={
214
+ writable
215
+ ? () => (canScope ? setAsking("delete") : removeEvent(undefined))
216
+ : undefined
217
+ }
36
218
  onClose={closeEvent}
37
219
  />
38
220
  );
@@ -0,0 +1,94 @@
1
+ import type { EventDraft } from "@remit/ui";
2
+ import { useEffect, useState } from "react";
3
+ import { CalendarComposePane } from "@/components/calendar/CalendarComposePane";
4
+ import { useCalendarComposeSeed } from "@/components/calendar/CalendarComposeSeed";
5
+ import {
6
+ createInputFromDraft,
7
+ emptyDraft,
8
+ UNZONED_CALENDAR,
9
+ useCalendars,
10
+ useCalendarWrites,
11
+ } from "@/hooks/calendar";
12
+ import { useCalendarAddress } from "@/routing";
13
+
14
+ /**
15
+ * Writing a new event.
16
+ *
17
+ * The draft starts on the slot the reader dragged out of the grid, or on the
18
+ * day the calendar is showing when they opened the composer some other way. The
19
+ * write goes through the same service function every other path uses, so an
20
+ * event typed here, one accepted from an invitation and one written over CalDAV
21
+ * are the same bytes stored the same way.
22
+ */
23
+ export interface WriteCalendarEventProps {
24
+ onClose: () => void;
25
+ }
26
+
27
+ export function WriteCalendarEvent({ onClose }: WriteCalendarEventProps) {
28
+ const { date } = useCalendarAddress();
29
+ const seed = useCalendarComposeSeed();
30
+ const { calendars, timeZoneByCalendarId } = useCalendars();
31
+ const { createEvent, isWriting } = useCalendarWrites();
32
+ const [problem, setProblem] = useState("");
33
+ const [draft, setDraft] = useState<EventDraft>(() => {
34
+ const blank = emptyDraft(seed?.date ?? date, "");
35
+ if (!seed) return blank;
36
+ return {
37
+ ...blank,
38
+ allDay: seed.allDay,
39
+ startTime: seed.allDay ? "" : seed.startTime,
40
+ endTime: seed.allDay ? "" : seed.endTime,
41
+ };
42
+ });
43
+
44
+ // The default calendar is provisioned by the listing itself, so the picker
45
+ // has one the moment the calendars arrive rather than making the reader
46
+ // choose before they can type anything.
47
+ const firstCalendarId = calendars[0]?.id ?? "";
48
+ useEffect(() => {
49
+ if (firstCalendarId === "") return;
50
+ setDraft((current) =>
51
+ current.calendarId === ""
52
+ ? { ...current, calendarId: firstCalendarId }
53
+ : current,
54
+ );
55
+ }, [firstCalendarId]);
56
+
57
+ const save = () => {
58
+ const built = createInputFromDraft(
59
+ draft,
60
+ timeZoneByCalendarId[draft.calendarId] ?? UNZONED_CALENDAR,
61
+ );
62
+ if (!built.ok) {
63
+ setProblem(built.problem);
64
+ return;
65
+ }
66
+ void createEvent(built.input).then((outcome) => {
67
+ if (outcome.kind === "written") {
68
+ setProblem("");
69
+ onClose();
70
+ return;
71
+ }
72
+ setProblem(
73
+ outcome.kind === "conflict"
74
+ ? "This calendar changed while you were writing. Close the form and try again."
75
+ : outcome.message,
76
+ );
77
+ });
78
+ };
79
+
80
+ return (
81
+ <CalendarComposePane
82
+ title="New event"
83
+ subtitle={draft.date}
84
+ calendars={calendars}
85
+ draft={draft}
86
+ onChange={setDraft}
87
+ problem={problem}
88
+ saveLabel="Add"
89
+ isSaving={isWriting}
90
+ onSave={save}
91
+ onCancel={onClose}
92
+ />
93
+ );
94
+ }
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Writing an event and deleting one, through the addresses that carry them.
3
+ *
4
+ * The addresses are the real ones — `/calendar/{view}/{date}/new` and the event
5
+ * segment under the view — and so are the components each mounts. What is
6
+ * asserted is the request that left and the address the reader ends up at,
7
+ * because a form that posts and then leaves the composer up looks exactly like
8
+ * one that posted nothing.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { afterEach, describe, it } from "node:test";
13
+ import type {
14
+ RemitImapCalendarEventInstance,
15
+ RemitImapCalendarEventResponse,
16
+ RemitImapCalendarResponse,
17
+ } from "@remit/api-http-client/types.gen.ts";
18
+ import {
19
+ type AnyRouter,
20
+ createMemoryHistory,
21
+ createRootRoute,
22
+ createRoute,
23
+ createRouter,
24
+ Outlet,
25
+ type RouteComponent,
26
+ RouterProvider,
27
+ } from "@tanstack/react-router";
28
+ import { createElement } from "react";
29
+ import { CalendarComposeSeedProvider } from "@/components/calendar/CalendarComposeSeed";
30
+ import { calendarSearchSchema } from "@/lib/calendar-route";
31
+ import { stringifySearch } from "@/lib/search-params";
32
+ import { Route as OccurrenceRoute } from "../../routes/calendar/$view.$date/$calendarObjectId/$recurrenceId.js";
33
+ import { Route as EventIndexRoute } from "../../routes/calendar/$view.$date/$calendarObjectId/index.js";
34
+ import { Route as EventRoute } from "../../routes/calendar/$view.$date/$calendarObjectId.js";
35
+ import { Route as ComposeRoute } from "../../routes/calendar/$view.$date/new.js";
36
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
37
+ import {
38
+ type HttpCall,
39
+ type HttpMock,
40
+ mockFetch,
41
+ } from "../../test-support/http";
42
+
43
+ // The router reads `self` at construction; the shared globals stop at `window`.
44
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
45
+
46
+ const WORK = "11111111-1111-4111-8111-111111111111";
47
+ const OBJECT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
48
+ const WEEK = "/calendar/week/2026-06-10";
49
+
50
+ let harness: DomHarness | undefined;
51
+ let http: HttpMock | undefined;
52
+
53
+ afterEach(() => {
54
+ harness?.close();
55
+ harness = undefined;
56
+ http?.restore();
57
+ http = undefined;
58
+ });
59
+
60
+ /**
61
+ * The component a file route mounts, lifted off the route itself.
62
+ *
63
+ * The route objects are module singletons and re-parenting one mutates it for
64
+ * every other test in the process, so the tree below is built fresh per case
65
+ * out of the real components rather than out of the real routes. Which routes
66
+ * an address mounts is `routes/calendar/-calendar-tree.test.ts`'s subject; this
67
+ * one is about what those components then do.
68
+ */
69
+ const componentOf = (route: unknown): RouteComponent => {
70
+ const { component } = (route as { options: { component?: RouteComponent } })
71
+ .options;
72
+ if (!component) throw new Error("the route mounts no component");
73
+ return component;
74
+ };
75
+
76
+ const calendars: RemitImapCalendarResponse[] = [
77
+ {
78
+ calendarId: WORK,
79
+ accountConfigId: "cfg-1",
80
+ urlSegment: "work",
81
+ displayName: "Work",
82
+ color: "Cal1",
83
+ componentSet: "VeventOnly",
84
+ source: "UserCreated",
85
+ timezone: "Europe/Amsterdam",
86
+ syncSequence: 1,
87
+ createdAt: 0,
88
+ updatedAt: 0,
89
+ } as RemitImapCalendarResponse,
90
+ ];
91
+
92
+ const instance: RemitImapCalendarEventInstance = {
93
+ calendarId: WORK,
94
+ calendarObjectId: OBJECT,
95
+ recurrenceId: "",
96
+ icalUid: "uid-1",
97
+ summary: "Roadmap review",
98
+ start: "2026-06-10T10:00:00+02:00",
99
+ end: "2026-06-10T11:30:00+02:00",
100
+ allDay: false,
101
+ status: "Confirmed",
102
+ transparency: "Opaque",
103
+ zoneCertainty: "Explicit",
104
+ etag: "etag-1",
105
+ hasRecurrence: false,
106
+ } as RemitImapCalendarEventInstance;
107
+
108
+ const resource = {
109
+ calendarObjectId: OBJECT,
110
+ calendarId: WORK,
111
+ resourceName: "roadmap.ics",
112
+ icalUid: "uid-1",
113
+ icalData: [
114
+ "BEGIN:VCALENDAR",
115
+ "BEGIN:VEVENT",
116
+ "SUMMARY:Roadmap review",
117
+ "LOCATION:Room Zuid",
118
+ "END:VEVENT",
119
+ "END:VCALENDAR",
120
+ ].join("\r\n"),
121
+ etag: "etag-1",
122
+ } as RemitImapCalendarEventResponse;
123
+
124
+ /**
125
+ * The calendar's addresses, at the shape the generated tree gives them: the
126
+ * view, the composer beside the open event, and one occurrence under the series.
127
+ * The grid is left off — nothing here is about how a week is drawn, and the
128
+ * pane routes do not need one mounted beside them to be reached.
129
+ */
130
+ const testRouter = (entry: string): AnyRouter => {
131
+ const rootRoute = createRootRoute({ component: Outlet });
132
+ const viewRoute = createRoute({
133
+ getParentRoute: () => rootRoute,
134
+ path: "/calendar/$view/$date",
135
+ validateSearch: calendarSearchSchema,
136
+ component: () =>
137
+ createElement(
138
+ CalendarComposeSeedProvider,
139
+ { pick: undefined },
140
+ createElement(Outlet),
141
+ ),
142
+ });
143
+ const eventRoute = createRoute({
144
+ getParentRoute: () => viewRoute,
145
+ path: "$calendarObjectId",
146
+ component: componentOf(EventRoute),
147
+ });
148
+ const routeTree = rootRoute.addChildren([
149
+ viewRoute.addChildren([
150
+ createRoute({
151
+ getParentRoute: () => viewRoute,
152
+ path: "new",
153
+ component: componentOf(ComposeRoute),
154
+ }),
155
+ eventRoute.addChildren([
156
+ createRoute({
157
+ getParentRoute: () => eventRoute,
158
+ path: "/",
159
+ component: componentOf(EventIndexRoute),
160
+ }),
161
+ createRoute({
162
+ getParentRoute: () => eventRoute,
163
+ path: "$recurrenceId",
164
+ component: componentOf(OccurrenceRoute),
165
+ }),
166
+ ]),
167
+ ]),
168
+ ]);
169
+ return createRouter({
170
+ routeTree,
171
+ stringifySearch,
172
+ history: createMemoryHistory({ initialEntries: [entry] }),
173
+ }) as unknown as AnyRouter;
174
+ };
175
+
176
+ const mount = async (entry: string, respond: (call: HttpCall) => unknown) => {
177
+ http = mockFetch(respond);
178
+ const router = testRouter(entry);
179
+ await router.load();
180
+ harness = createDomHarness();
181
+ harness.renderApp(createElement(RouterProvider, { router }));
182
+ await settle();
183
+ return router;
184
+ };
185
+
186
+ const settle = async () => {
187
+ await harness?.flush();
188
+ await harness?.wait(30);
189
+ await harness?.flush();
190
+ };
191
+
192
+ const answering =
193
+ (
194
+ respond: (call: HttpCall) => unknown,
195
+ items: RemitImapCalendarEventInstance[] = [instance],
196
+ stored: RemitImapCalendarEventResponse = resource,
197
+ ) =>
198
+ (call: HttpCall) => {
199
+ if (call.path.endsWith("/calendars") && call.method === "GET")
200
+ return { items: calendars };
201
+ if (call.path.endsWith("/calendar-events") && call.method === "GET")
202
+ return { items };
203
+ if (call.path.includes("/calendar-events/") && call.method === "GET")
204
+ return stored;
205
+ return respond(call);
206
+ };
207
+
208
+ const RECURRENCE = "2026-06-10T07:15:00Z";
209
+
210
+ const occurrence = {
211
+ ...instance,
212
+ recurrenceId: RECURRENCE,
213
+ summary: "Standup",
214
+ start: "2026-06-10T09:15:00+02:00",
215
+ end: "2026-06-10T09:30:00+02:00",
216
+ hasRecurrence: true,
217
+ } as RemitImapCalendarEventInstance;
218
+
219
+ const series = {
220
+ ...resource,
221
+ icalData: [
222
+ "BEGIN:VCALENDAR",
223
+ "BEGIN:VEVENT",
224
+ "SUMMARY:Standup",
225
+ "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
226
+ "END:VEVENT",
227
+ "END:VCALENDAR",
228
+ ].join("\r\n"),
229
+ } as RemitImapCalendarEventResponse;
230
+
231
+ describe("writing an event from the composer route", () => {
232
+ it("posts what was typed and puts the reader back on the calendar", async () => {
233
+ const router = await mount(
234
+ `${WEEK}/new`,
235
+ answering(() => resource),
236
+ );
237
+
238
+ harness?.type(harness.byLabel("Title"), "Supplier call");
239
+ await settle();
240
+ harness?.click(harness.byText("button", "Add"));
241
+ await settle();
242
+
243
+ const posted = (http?.calls ?? []).filter(
244
+ (call) =>
245
+ call.method === "POST" && call.path.endsWith("/calendar-events"),
246
+ );
247
+ assert.equal(posted.length, 1, "the event was written once");
248
+ assert.equal(posted[0].body?.summary, "Supplier call");
249
+ assert.equal(posted[0].body?.calendarId, WORK);
250
+ assert.equal(
251
+ router.state.location.pathname,
252
+ WEEK,
253
+ "a saved event closes the composer rather than leaving it up",
254
+ );
255
+ });
256
+
257
+ it("says what is missing rather than posting a nameless event", async () => {
258
+ await mount(
259
+ `${WEEK}/new`,
260
+ answering(() => resource),
261
+ );
262
+
263
+ harness?.click(harness.byText("button", "Add"));
264
+ await settle();
265
+
266
+ assert.equal(
267
+ (http?.calls ?? []).filter((call) => call.method === "POST").length,
268
+ 0,
269
+ );
270
+ assert.match(harness?.text() ?? "", /title/i);
271
+ });
272
+ });
273
+
274
+ describe("deleting an event from the event route", () => {
275
+ it("sends the delete with the version on screen and closes the pane", async () => {
276
+ const router = await mount(
277
+ `${WEEK}/${OBJECT}`,
278
+ answering(() => ({})),
279
+ );
280
+
281
+ assert.match(harness?.text() ?? "", /Roadmap review/);
282
+ harness?.click(harness.byText("button", "Delete"));
283
+ await settle();
284
+
285
+ const deleted = (http?.calls ?? []).filter(
286
+ (call) => call.method === "DELETE",
287
+ );
288
+ assert.equal(deleted.length, 1);
289
+ assert.ok(deleted[0].url.includes(`calendarId=${WORK}`), deleted[0].url);
290
+ assert.equal(
291
+ deleted[0].headers["if-match"],
292
+ "etag-1",
293
+ "a delete built on a version nobody stated can remove somebody else's event",
294
+ );
295
+ assert.equal(router.state.location.pathname, WEEK);
296
+ });
297
+ });
298
+
299
+ describe("editing one morning of a repeating event", () => {
300
+ it("asks what the change applies to before opening the form", async () => {
301
+ await mount(
302
+ `${WEEK}/${OBJECT}/${RECURRENCE}`,
303
+ answering(() => ({}), [occurrence], series),
304
+ );
305
+
306
+ assert.doesNotMatch(
307
+ harness?.text() ?? "",
308
+ /What should the change apply to/,
309
+ "the question is asked when Edit is pressed, not before",
310
+ );
311
+ harness?.click(harness.byText("button", "Edit"));
312
+ await settle();
313
+
314
+ const asked = harness?.text() ?? "";
315
+ assert.match(asked, /Standup repeats/);
316
+ assert.match(asked, /What should the change apply to/);
317
+ assert.match(
318
+ asked,
319
+ /Every weekday, 09:15/,
320
+ "the rule is read back in words, not as an RRULE",
321
+ );
322
+ });
323
+
324
+ it("sends the answer with the edit, naming the occurrence it meant", async () => {
325
+ await mount(
326
+ `${WEEK}/${OBJECT}/${RECURRENCE}`,
327
+ answering(() => ({}), [occurrence], series),
328
+ );
329
+
330
+ harness?.click(harness.byText("button", "Edit"));
331
+ await settle();
332
+ harness?.click(harness.byText("button", "This event"));
333
+ await settle();
334
+
335
+ harness?.type(harness.byLabel("Title"), "Standup, short");
336
+ await settle();
337
+ harness?.click(harness.byText("button", "Save"));
338
+ await settle();
339
+
340
+ const patched = (http?.calls ?? []).filter(
341
+ (call) => call.method === "PATCH",
342
+ );
343
+ assert.equal(patched.length, 1);
344
+ assert.equal(patched[0].body?.summary, "Standup, short");
345
+ assert.equal(
346
+ patched[0].headers["if-match"],
347
+ "etag-1",
348
+ "the write is conditional on the version the form was opened from",
349
+ );
350
+ assert.ok(patched[0].url.includes("scope=This"), patched[0].url);
351
+ assert.ok(
352
+ patched[0].url.includes(encodeURIComponent(RECURRENCE)),
353
+ patched[0].url,
354
+ );
355
+ });
356
+ });