@remit/web-client 0.0.312 → 0.0.314

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 (29) hide show
  1. package/package.json +1 -1
  2. package/src/components/calendar/CalendarComposePane.tsx +15 -2
  3. package/src/components/calendar/CalendarWaiting.tsx +3 -0
  4. package/src/components/calendar/ChangeCalendarSuggestion.stories.tsx +182 -0
  5. package/src/components/calendar/ChangeCalendarSuggestion.tsx +159 -0
  6. package/src/components/calendar/PendingSuggestions.tsx +35 -5
  7. package/src/components/calendar/calendar-story-server.tsx +5 -0
  8. package/src/components/calendar/change-suggestion-first.render.test.ts +299 -0
  9. package/src/components/calendar/pending-suggestions.render.test.ts +68 -3
  10. package/src/components/compose/ComposeForm.tsx +38 -4
  11. package/src/components/compose/compose-inline-image.render.test.ts +542 -0
  12. package/src/hooks/calendar/draft.ts +1 -1
  13. package/src/hooks/calendar/index.ts +7 -0
  14. package/src/hooks/calendar/suggestion-draft.ts +77 -0
  15. package/src/hooks/calendar/useCalendarSuggestions.ts +59 -1
  16. package/src/hooks/intelligence-calendar.render.test.ts +135 -0
  17. package/src/hooks/useComposeAttachments.ts +6 -1
  18. package/src/hooks/useInlineImages.ts +93 -0
  19. package/src/hooks/useIntelligenceCalendar.ts +36 -5
  20. package/src/lib/calendar-suggestion.test.ts +10 -1
  21. package/src/lib/calendar-suggestion.ts +12 -2
  22. package/src/lib/compose-attachment-controller.test.ts +110 -0
  23. package/src/lib/compose-attachment-controller.ts +120 -16
  24. package/src/routeTree.gen.ts +23 -0
  25. package/src/routes/calendar/$view.$date/suggestion.$suggestionId.tsx +22 -0
  26. package/src/routes/calendar/$view.$date.tsx +9 -3
  27. package/src/routes/calendar/-calendar-tree.test.ts +18 -0
  28. package/src/routing/calendar.ts +36 -1
  29. package/src/routing/index.ts +2 -0
@@ -0,0 +1,299 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, afterEach, before, describe, it } from "node:test";
3
+ import type {
4
+ RemitImapCalendarResponse,
5
+ RemitImapCalendarSuggestionResponse,
6
+ } from "@remit/api-http-client/types.gen.ts";
7
+ import {
8
+ type AnyRouter,
9
+ createMemoryHistory,
10
+ createRootRoute,
11
+ createRoute,
12
+ createRouter,
13
+ Outlet,
14
+ type RouteComponent,
15
+ RouterProvider,
16
+ } from "@tanstack/react-router";
17
+ import { createElement } from "react";
18
+ import { calendarSearchSchema } from "@/lib/calendar-route";
19
+ import { stringifySearch } from "@/lib/search-params";
20
+ import { Route as SuggestionRoute } from "../../routes/calendar/$view.$date/suggestion.$suggestionId.js";
21
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
22
+ import {
23
+ type HttpCall,
24
+ type HttpMock,
25
+ httpError,
26
+ mockFetch,
27
+ } from "../../test-support/http";
28
+
29
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
30
+
31
+ const WORK = "11111111-1111-4111-8111-111111111111";
32
+ const SUGGESTION = "22222222-2222-4222-8222-222222222222";
33
+ const WEEK = "/calendar/week/2026-06-10";
34
+ const ENTRY = `${WEEK}/suggestion/${SUGGESTION}`;
35
+
36
+ const calendars = [
37
+ {
38
+ calendarId: WORK,
39
+ accountConfigId: "cfg-1",
40
+ urlSegment: "work",
41
+ displayName: "Work",
42
+ color: "Cal1",
43
+ componentSet: "VeventOnly",
44
+ source: "Default",
45
+ timezone: "Europe/Amsterdam",
46
+ syncSequence: 1,
47
+ subscriptionUrl: "",
48
+ subscriptionEnabled: false,
49
+ subscriptionCheckedAt: 0,
50
+ subscriptionFetchedAt: 0,
51
+ subscriptionError: "",
52
+ createdAt: 0,
53
+ updatedAt: 0,
54
+ },
55
+ ] satisfies RemitImapCalendarResponse[];
56
+
57
+ const reading = (
58
+ overrides: Partial<RemitImapCalendarSuggestionResponse> = {},
59
+ ): RemitImapCalendarSuggestionResponse => ({
60
+ suggestionId: SUGGESTION,
61
+ accountConfigId: "cfg-1",
62
+ messageId: "msg-1",
63
+ bodyPartId: "part-1",
64
+ icalUid: "uid-1",
65
+ sequence: 0,
66
+ method: "Request",
67
+ source: "IcalendarPart",
68
+ state: "Pending",
69
+ summary: "Billing migration kickoff",
70
+ dtStart: "2026-06-11T12:00:00+00:00",
71
+ dtEnd: "2026-06-11T13:00:00+00:00",
72
+ allDay: false,
73
+ location: "Room Noord",
74
+ organizer: "priya@example.test",
75
+ zoneCertainty: "Explicit",
76
+ acceptedCalendarObjectId: "",
77
+ createdAt: 0,
78
+ updatedAt: 0,
79
+ ...overrides,
80
+ });
81
+
82
+ let harness: DomHarness | undefined;
83
+ let http: HttpMock | undefined;
84
+ const runnerZone = process.env.TZ;
85
+
86
+ before(() => {
87
+ process.env.TZ = "Europe/Amsterdam";
88
+ });
89
+
90
+ after(() => {
91
+ if (runnerZone === undefined) Reflect.deleteProperty(process.env, "TZ");
92
+ else process.env.TZ = runnerZone;
93
+ });
94
+
95
+ afterEach(() => {
96
+ harness?.close();
97
+ harness = undefined;
98
+ http?.restore();
99
+ http = undefined;
100
+ });
101
+
102
+ const componentOf = (route: unknown): RouteComponent => {
103
+ const { component } = (route as { options: { component?: RouteComponent } })
104
+ .options;
105
+ if (!component) throw new Error("the route mounts no component");
106
+ return component;
107
+ };
108
+
109
+ const testRouter = (): AnyRouter => {
110
+ const rootRoute = createRootRoute({ component: Outlet });
111
+ const viewRoute = createRoute({
112
+ getParentRoute: () => rootRoute,
113
+ path: "/calendar/$view/$date",
114
+ validateSearch: calendarSearchSchema,
115
+ component: Outlet,
116
+ });
117
+ const routeTree = rootRoute.addChildren([
118
+ viewRoute.addChildren([
119
+ createRoute({
120
+ getParentRoute: () => viewRoute,
121
+ path: "/",
122
+ component: () => null,
123
+ }),
124
+ createRoute({
125
+ getParentRoute: () => viewRoute,
126
+ path: "suggestion/$suggestionId",
127
+ component: componentOf(SuggestionRoute),
128
+ }),
129
+ ]),
130
+ ]);
131
+ return createRouter({
132
+ routeTree,
133
+ stringifySearch,
134
+ history: createMemoryHistory({ initialEntries: [ENTRY] }),
135
+ }) as unknown as AnyRouter;
136
+ };
137
+
138
+ const server = (
139
+ suggestion: RemitImapCalendarSuggestionResponse,
140
+ answer: (call: HttpCall) => Response | undefined = () => undefined,
141
+ ) => {
142
+ let pending = [suggestion];
143
+ return (call: HttpCall): unknown => {
144
+ if (call.path.endsWith("/calendars")) return { items: calendars };
145
+ if (call.path.endsWith("/calendar-suggestions")) return { items: pending };
146
+ if (call.path.endsWith("/accept")) {
147
+ const refusal = answer(call);
148
+ if (refusal) return refusal;
149
+ pending = [];
150
+ return { ...suggestion, state: "Accepted" };
151
+ }
152
+ return { items: [] };
153
+ };
154
+ };
155
+
156
+ const mount = async (respond: (call: HttpCall) => unknown) => {
157
+ http = mockFetch(respond);
158
+ const router = testRouter();
159
+ await router.load();
160
+ harness = createDomHarness();
161
+ harness.renderApp(createElement(RouterProvider, { router }));
162
+ await harness.waitFor(
163
+ () =>
164
+ (harness?.queryAll<HTMLInputElement>("input") ?? []).some(
165
+ (input) => input.value === "Billing migration kickoff",
166
+ ),
167
+ "the editor to open seeded from the suggestion",
168
+ );
169
+ await harness.waitFor(
170
+ () => harness?.query("input[type=radio]:checked") !== null,
171
+ "the default calendar to be picked",
172
+ );
173
+ return router;
174
+ };
175
+
176
+ const fieldValue = (label: string): string => {
177
+ if (!harness) throw new Error("not mounted");
178
+ return (harness.byLabel(label) as HTMLInputElement).value;
179
+ };
180
+
181
+ const acceptedEdits = (): HttpCall[] =>
182
+ http?.to(`/calendar-suggestions/${SUGGESTION}/accept`) ?? [];
183
+
184
+ describe("changing a suggestion before adding it", () => {
185
+ it("opens the editor on what the mail said, on this device's clock", async () => {
186
+ await mount(server(reading()));
187
+
188
+ assert.equal(fieldValue("Title"), "Billing migration kickoff");
189
+ assert.equal(fieldValue("Location"), "Room Noord");
190
+ assert.equal(fieldValue("Date"), "2026-06-11");
191
+ assert.equal(fieldValue("Start time"), "14:00");
192
+ assert.equal(fieldValue("End time"), "15:00");
193
+ });
194
+
195
+ it("adds the corrected event, settles the suggestion and closes the editor", async () => {
196
+ const router = await mount(server(reading()));
197
+
198
+ harness?.type(harness.byLabel("Title"), "Billing migration kickoff, moved");
199
+ harness?.type(harness.byLabel("Start time"), "16:00");
200
+ harness?.type(harness.byLabel("End time"), "17:30");
201
+ await harness?.flush();
202
+ harness?.click(harness.byText("button", "Add"));
203
+
204
+ await harness?.waitFor(
205
+ () => router.state.location.pathname === WEEK,
206
+ "the editor to close once the server settled the suggestion",
207
+ );
208
+ const sent = acceptedEdits();
209
+ assert.equal(sent.length, 1);
210
+ assert.deepEqual(sent[0].body, {
211
+ calendarId: WORK,
212
+ event: {
213
+ summary: "Billing migration kickoff, moved",
214
+ start: "2026-06-11T16:00:00+02:00",
215
+ end: "2026-06-11T17:30:00+02:00",
216
+ allDay: false,
217
+ timeZone: "Europe/Amsterdam",
218
+ },
219
+ });
220
+ const listings = http?.to("/calendar-suggestions") ?? [];
221
+ assert.ok(
222
+ listings.length > 1,
223
+ "the waiting list is read again once the suggestion is settled",
224
+ );
225
+ });
226
+
227
+ it("places a reading nobody could put on a clock, even when the time is left alone", async () => {
228
+ await mount(
229
+ server(
230
+ reading({
231
+ dtStart: "2026-06-11T09:00:00+00:00",
232
+ dtEnd: "2026-06-11T10:00:00+00:00",
233
+ zoneCertainty: "Ambiguous",
234
+ }),
235
+ ),
236
+ );
237
+
238
+ assert.equal(fieldValue("Start time"), "09:00");
239
+ assert.match(harness?.text() ?? "", /time zone nothing could resolve/);
240
+ harness?.click(harness.byText("button", "Add"));
241
+ await harness?.waitFor(
242
+ () => acceptedEdits().length === 1,
243
+ "the correction to be sent",
244
+ );
245
+
246
+ assert.deepEqual(acceptedEdits()[0].body?.event, {
247
+ start: "2026-06-11T09:00:00+02:00",
248
+ end: "2026-06-11T10:00:00+02:00",
249
+ allDay: false,
250
+ timeZone: "Europe/Amsterdam",
251
+ });
252
+ });
253
+
254
+ it("states a refusal and keeps the editor open with what was typed", async () => {
255
+ const router = await mount(
256
+ server(reading(), () => httpError(400, "Already declined.")),
257
+ );
258
+
259
+ harness?.type(harness.byLabel("Title"), "Kickoff, renamed");
260
+ await harness?.flush();
261
+ harness?.click(harness.byText("button", "Add"));
262
+
263
+ await harness?.waitFor(
264
+ () => harness?.query('[role="alert"]') !== null,
265
+ "the refusal to be stated",
266
+ );
267
+ assert.match(harness?.text() ?? "", /Couldn't add this to your calendar/);
268
+ assert.equal(fieldValue("Title"), "Kickoff, renamed");
269
+ assert.equal(router.state.location.pathname, ENTRY);
270
+ });
271
+
272
+ it("finds a suggestion that is not on the first page of what waits", async () => {
273
+ http = mockFetch((call: HttpCall): unknown => {
274
+ if (call.path.endsWith("/calendars")) return { items: calendars };
275
+ if (call.path.endsWith("/calendar-suggestions"))
276
+ return call.url.includes("continuationToken=page-2")
277
+ ? { items: [reading()] }
278
+ : {
279
+ items: [{ ...reading(), suggestionId: "other" }],
280
+ continuationToken: "page-2",
281
+ };
282
+ return { items: [] };
283
+ });
284
+ const router = testRouter();
285
+ await router.load();
286
+ harness = createDomHarness();
287
+ harness.renderApp(createElement(RouterProvider, { router }));
288
+
289
+ await harness.waitFor(
290
+ () =>
291
+ harness
292
+ ?.queryAll<HTMLInputElement>("input")
293
+ .some((input) => input.value === "Billing migration kickoff") ===
294
+ true,
295
+ "the editor to open on the suggestion from the second page",
296
+ );
297
+ assert.doesNotMatch(harness.text(), /no longer waiting/);
298
+ });
299
+ });
@@ -69,16 +69,21 @@ const pending: RemitImapCalendarSuggestionResponse = {
69
69
 
70
70
  let harness: DomHarness | undefined;
71
71
  let http: HttpMock | undefined;
72
+ let changedFirst: { suggestionId: string; date: string }[] = [];
72
73
 
73
74
  afterEach(() => {
74
75
  harness?.close();
75
76
  harness = undefined;
76
77
  http?.restore();
77
78
  http = undefined;
79
+ changedFirst = [];
78
80
  });
79
81
 
80
- const server = (accept: () => Response | undefined) => {
81
- let waiting = [pending];
82
+ const server = (
83
+ accept: () => Response | undefined,
84
+ reading: RemitImapCalendarSuggestionResponse = pending,
85
+ ) => {
86
+ let waiting = [reading];
82
87
  return (call: HttpCall): unknown => {
83
88
  if (call.path.endsWith("/calendars")) return { items: calendars };
84
89
  if (call.path.endsWith("/calendar-suggestions")) return { items: waiting };
@@ -95,7 +100,12 @@ const server = (accept: () => Response | undefined) => {
95
100
  const mount = async (respond: (call: HttpCall) => unknown) => {
96
101
  http = mockFetch(respond);
97
102
  harness = createDomHarness();
98
- harness.renderApp(createElement(PendingSuggestions));
103
+ harness.renderApp(
104
+ createElement(PendingSuggestions, {
105
+ onChangeFirst: (suggestionId: string, date: string) =>
106
+ changedFirst.push({ suggestionId, date }),
107
+ }),
108
+ );
99
109
  await harness.waitFor(
100
110
  () => harness?.text().includes("Quarterly review") === true,
101
111
  "the waiting reading to be drawn",
@@ -134,4 +144,59 @@ describe("what the mail is waiting on, beside the calendar", () => {
134
144
  assert.match(harness?.text() ?? "", /Couldn't add this to your calendar/);
135
145
  assert.match(harness?.text() ?? "", /Quarterly review/);
136
146
  });
147
+
148
+ it("hands the top reading to the editor on the day it falls", async () => {
149
+ await mount(server(() => undefined));
150
+ harness?.click(harness.byText("button", "Change first"));
151
+
152
+ assert.deepEqual(changedFirst, [
153
+ { suggestionId: SUGGESTION, date: "2026-09-01" },
154
+ ]);
155
+ assert.equal(
156
+ http?.to(`/calendar-suggestions/${SUGGESTION}/accept`).length,
157
+ 0,
158
+ );
159
+ });
160
+
161
+ it("sends a reading nobody could put on a clock to the editor instead of adding it", async () => {
162
+ await mount(
163
+ server(() => undefined, { ...pending, zoneCertainty: "Ambiguous" }),
164
+ );
165
+ harness?.click(harness.byText("button", "Add"));
166
+
167
+ assert.deepEqual(
168
+ changedFirst.map((entry) => entry.suggestionId),
169
+ [SUGGESTION],
170
+ );
171
+ assert.equal(
172
+ http?.to(`/calendar-suggestions/${SUGGESTION}/accept`).length,
173
+ 0,
174
+ );
175
+ assert.match(harness?.text() ?? "", /Quarterly review/);
176
+ });
177
+
178
+ it("applies a cancellation nobody could put on a clock, and offers no editor for it", async () => {
179
+ await mount(
180
+ server(() => undefined, {
181
+ ...pending,
182
+ method: "Cancel",
183
+ zoneCertainty: "Ambiguous",
184
+ }),
185
+ );
186
+ assert.equal(
187
+ harness
188
+ ?.queryAll("button")
189
+ .some((button) => button.textContent?.includes("Change first")),
190
+ false,
191
+ );
192
+
193
+ harness?.click(harness.byText("button", "Add"));
194
+ await harness?.waitFor(
195
+ () =>
196
+ (http?.to(`/calendar-suggestions/${SUGGESTION}/accept`).length ?? 0) >
197
+ 0,
198
+ "the cancellation to be applied",
199
+ );
200
+ assert.deepEqual(changedFirst, []);
201
+ });
137
202
  });
@@ -31,7 +31,7 @@ import {
31
31
  unwrapLanguage,
32
32
  } from "@remit/ui";
33
33
  import type { ComposeBodyMode, ComposeInsertion } from "@remit/ui/rich-text";
34
- import { useMutation, useQuery } from "@tanstack/react-query";
34
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
35
35
  import type { RefObject } from "react";
36
36
  import {
37
37
  lazy,
@@ -46,6 +46,7 @@ import {
46
46
  type DraftForAttachment,
47
47
  useComposeAttachments,
48
48
  } from "../../hooks/useComposeAttachments";
49
+ import { useInlineImages } from "../../hooks/useInlineImages";
49
50
  import { useMessageBodyContent } from "../../hooks/useMessageBodyContent";
50
51
  import { useSaveDraft } from "../../hooks/useSaveDraft";
51
52
  import { useSignature } from "../../hooks/useSignature.js";
@@ -462,9 +463,7 @@ export const ComposeForm = ({
462
463
  * failure to `draft` (#933), so opening a Failed message and pressing Escape
463
464
  * would take it out of the Outbox with the reader having changed nothing. The
464
465
  * first run after a load records what was loaded; only a payload that differs
465
- * from it is an edit. Nothing updates it afterwards — once the reader has
466
- * touched the document, every later run saves as it always did, retries of a
467
- * failed write included.
466
+ * from it is an edit.
468
467
  */
469
468
  const loadedPayloadRef = useRef<string | undefined>(undefined);
470
469
  const captureLoadedPayloadRef = useRef(false);
@@ -877,11 +876,41 @@ export const ComposeForm = ({
877
876
  remove: removeAttachment,
878
877
  load: loadAttachments,
879
878
  reset: resetAttachments,
879
+ owns: ownsAttachment,
880
+ isLoaded: attachmentsLoaded,
881
+ refreshLinks: refreshAttachmentLinks,
882
+ storedContent,
883
+ onRemoved: onAttachmentRemoved,
880
884
  blockingReason: attachmentBlockingReason,
881
885
  } = useComposeAttachments({
882
886
  ensureDraft: ensureDraftForAttachment,
883
887
  });
884
888
 
889
+ const queryClient = useQueryClient();
890
+ const inlineImages = useInlineImages({
891
+ attach,
892
+ owns: ownsAttachment,
893
+ isLoaded: attachmentsLoaded,
894
+ storedContent,
895
+ refreshLinks: () => {
896
+ if (!outboxMessageId) return Promise.resolve();
897
+ return queryClient
898
+ .fetchQuery({
899
+ ...outboxDetailOperationsGetOutboxMessageOptions({
900
+ path: { outboxMessageId },
901
+ }),
902
+ staleTime: 0,
903
+ })
904
+ .then((draft) => refreshAttachmentLinks(draft.attachments));
905
+ },
906
+ onRemoved: onAttachmentRemoved,
907
+ draftAttachments:
908
+ draftData && draftData.outboxMessageId === outboxMessageId
909
+ ? draftData.attachments
910
+ : [],
911
+ pushError,
912
+ });
913
+
885
914
  const attachmentDocumentRef = useRef(outboxMessageId);
886
915
  useEffect(() => {
887
916
  const previous = attachmentDocumentRef.current;
@@ -1086,6 +1115,7 @@ export const ComposeForm = ({
1086
1115
  return;
1087
1116
  }
1088
1117
  if (loadedPayloadRef.current === fingerprint) return;
1118
+ loadedPayloadRef.current = undefined;
1089
1119
 
1090
1120
  saveDraft(payload);
1091
1121
  }, [
@@ -1283,6 +1313,9 @@ export const ComposeForm = ({
1283
1313
  return (
1284
1314
  <ComposeFormShell
1285
1315
  layout={layout}
1316
+ onDropFiles={(files) => {
1317
+ void attach(files);
1318
+ }}
1286
1319
  banner={
1287
1320
  <>
1288
1321
  {selectedAccount && selectedAccountMissingSmtp ? (
@@ -1381,6 +1414,7 @@ export const ComposeForm = ({
1381
1414
  spellcheck={spellcheck}
1382
1415
  insertion={pendingInsertion}
1383
1416
  onInserted={setInsertedVersion}
1417
+ inlineImages={inlineImages}
1384
1418
  />
1385
1419
  </Suspense>
1386
1420
  </ComposeFormShell>