@remit/web-client 0.0.291 → 0.0.293

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 (27) hide show
  1. package/package.json +1 -1
  2. package/src/components/calendar/CalendarComposePane.stories.tsx +19 -0
  3. package/src/components/calendar/CalendarComposePane.tsx +30 -10
  4. package/src/components/calendar/OpenCalendarEvent.tsx +7 -0
  5. package/src/components/calendar/WriteCalendarEvent.tsx +4 -0
  6. package/src/components/onboarding/OnboardingWizard.flow.test.ts +45 -0
  7. package/src/components/onboarding/OnboardingWizard.tsx +2 -8
  8. package/src/components/onboarding/onboarding-servers.stories.tsx +49 -0
  9. package/src/components/settings/CalendarCreateCard.stories.tsx +33 -0
  10. package/src/components/settings/CalendarCreateCard.tsx +149 -0
  11. package/src/components/settings/CalendarDetailsForm.stories.tsx +57 -0
  12. package/src/components/settings/CalendarDetailsForm.tsx +136 -0
  13. package/src/components/settings/CalendarFeedCard.stories.tsx +18 -0
  14. package/src/components/settings/CalendarFeedCard.tsx +7 -1
  15. package/src/components/settings/CalendarFeedPanel.tsx +4 -0
  16. package/src/components/settings/CalendarSettingsPanel.tsx +54 -0
  17. package/src/components/settings/TimeZoneSelect.tsx +43 -0
  18. package/src/components/settings/calendar-collection-cards.render.test.ts +149 -0
  19. package/src/hooks/calendar/calendar-collections.render.test.ts +259 -0
  20. package/src/hooks/calendar/index.ts +6 -0
  21. package/src/hooks/calendar/useCalendarCollectionWrites.ts +130 -0
  22. package/src/hooks/calendar/useDraftClashes.ts +91 -0
  23. package/src/lib/calendar-collection-copy.test.ts +23 -0
  24. package/src/lib/calendar-collection-copy.ts +21 -0
  25. package/src/lib/provider-presets.test.ts +33 -2
  26. package/src/lib/provider-presets.ts +14 -1
  27. package/src/routes/settings/calendars.tsx +28 -6
@@ -17,7 +17,7 @@ import {
17
17
  ConfirmDialog,
18
18
  } from "@remit/ui";
19
19
  import { Check, Copy, Link2, RefreshCw, Trash2 } from "lucide-react";
20
- import { useEffect, useId, useRef, useState } from "react";
20
+ import { type ReactNode, useEffect, useId, useRef, useState } from "react";
21
21
  import { ErrorState, formatErrorMessage } from "@/components/ui/ErrorState";
22
22
  import type { CalendarFeedState } from "@/hooks/calendar/useCalendarFeed";
23
23
  import {
@@ -28,6 +28,8 @@ import { formatDatePreset } from "@/lib/format";
28
28
 
29
29
  export interface CalendarFeedCardProps {
30
30
  calendarName: string;
31
+ /** The calendar's own settings, drawn above its subscription address. */
32
+ details?: ReactNode;
31
33
  state: CalendarFeedState;
32
34
  /** The address just minted, shown once. Empty at every other moment. */
33
35
  mintedUrl: string;
@@ -167,6 +169,7 @@ function ActiveFeed({
167
169
 
168
170
  export function CalendarFeedCard({
169
171
  calendarName,
172
+ details,
170
173
  state,
171
174
  mintedUrl,
172
175
  isBusy,
@@ -200,6 +203,9 @@ export function CalendarFeedCard({
200
203
  <CardTitle id={titleId}>{calendarName}</CardTitle>
201
204
  </CardHeader>
202
205
  <CardBody>
206
+ {details && (
207
+ <div className="mb-4 border-b border-line pb-4">{details}</div>
208
+ )}
203
209
  {state.status === "loading" && mintedUrl === "" && (
204
210
  // biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-label on a loading skeleton is what assistive tech has to go on
205
211
  <div
@@ -4,23 +4,27 @@
4
4
  * A component rather than a loop body in the settings route: each calendar
5
5
  * reads and writes its own feed, and a hook cannot be called per iteration.
6
6
  */
7
+ import type { ReactNode } from "react";
7
8
  import { CalendarFeedCard } from "@/components/settings/CalendarFeedCard";
8
9
  import { useCalendarFeed } from "@/hooks/calendar/useCalendarFeed";
9
10
 
10
11
  export interface CalendarFeedPanelProps {
11
12
  calendarId: string;
12
13
  calendarName: string;
14
+ details?: ReactNode;
13
15
  }
14
16
 
15
17
  export function CalendarFeedPanel({
16
18
  calendarId,
17
19
  calendarName,
20
+ details,
18
21
  }: CalendarFeedPanelProps) {
19
22
  const feed = useCalendarFeed(calendarId);
20
23
 
21
24
  return (
22
25
  <CalendarFeedCard
23
26
  calendarName={calendarName}
27
+ details={details}
24
28
  state={feed.state}
25
29
  mintedUrl={feed.mintedUrl}
26
30
  isBusy={feed.isBusy}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * One calendar on the settings page: its name and zone, deleting it, and its
3
+ * subscription address, wired to the server.
4
+ */
5
+ import { useState } from "react";
6
+ import { CalendarDetailsForm } from "@/components/settings/CalendarDetailsForm";
7
+ import { CalendarFeedPanel } from "@/components/settings/CalendarFeedPanel";
8
+ import {
9
+ type CalendarCollectionOutcome,
10
+ useCalendarCollectionWrites,
11
+ } from "@/hooks/calendar/useCalendarCollectionWrites";
12
+
13
+ export interface CalendarSettingsPanelProps {
14
+ calendarId: string;
15
+ calendarName: string;
16
+ timezone: string;
17
+ }
18
+
19
+ export function CalendarSettingsPanel({
20
+ calendarId,
21
+ calendarName,
22
+ timezone,
23
+ }: CalendarSettingsPanelProps) {
24
+ const { updateCalendar, deleteCalendar, isWriting } =
25
+ useCalendarCollectionWrites();
26
+ const [problem, setProblem] = useState("");
27
+
28
+ const settle = (outcome: CalendarCollectionOutcome) =>
29
+ setProblem(outcome.kind === "refused" ? outcome.message : "");
30
+
31
+ return (
32
+ <CalendarFeedPanel
33
+ calendarId={calendarId}
34
+ calendarName={calendarName}
35
+ details={
36
+ <CalendarDetailsForm
37
+ key={`${calendarName}\u0000${timezone}`}
38
+ calendarName={calendarName}
39
+ timezone={timezone}
40
+ isBusy={isWriting}
41
+ problem={problem}
42
+ onSave={(details) => {
43
+ setProblem("");
44
+ void updateCalendar(calendarId, details).then(settle);
45
+ }}
46
+ onDelete={() => {
47
+ setProblem("");
48
+ void deleteCalendar(calendarId).then(settle);
49
+ }}
50
+ />
51
+ }
52
+ />
53
+ );
54
+ }
@@ -0,0 +1,43 @@
1
+ import { Select } from "@remit/ui";
2
+ import { UNZONED_CALENDAR } from "@/hooks/calendar";
3
+
4
+ /**
5
+ * The zone a calendar's floating times are read in. The empty choice is how
6
+ * the API spells "no zone", which reads as UTC — there is no IANA name for UTC
7
+ * the server accepts, so it is offered as its own option rather than as a zone.
8
+ */
9
+ export interface TimeZoneSelectProps {
10
+ id: string;
11
+ value: string;
12
+ disabled?: boolean;
13
+ onChange: (timeZone: string) => void;
14
+ }
15
+
16
+ const ZONES = Intl.supportedValuesOf("timeZone");
17
+
18
+ export function TimeZoneSelect({
19
+ id,
20
+ value,
21
+ disabled,
22
+ onChange,
23
+ }: TimeZoneSelectProps) {
24
+ const zones =
25
+ value === UNZONED_CALENDAR || ZONES.includes(value)
26
+ ? ZONES
27
+ : [value, ...ZONES];
28
+ return (
29
+ <Select
30
+ id={id}
31
+ value={value}
32
+ disabled={disabled}
33
+ onChange={(event) => onChange(event.target.value)}
34
+ >
35
+ <option value={UNZONED_CALENDAR}>None (read as UTC)</option>
36
+ {zones.map((zone) => (
37
+ <option key={zone} value={zone}>
38
+ {zone}
39
+ </option>
40
+ ))}
41
+ </Select>
42
+ );
43
+ }
@@ -0,0 +1,149 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { CalendarClash, EventDraft } from "@remit/ui";
4
+ import React, { createElement } from "react";
5
+ import { renderToString } from "react-dom/server";
6
+ import { CalendarComposePane } from "@/components/calendar/CalendarComposePane";
7
+ import { CalendarCreateCard } from "./CalendarCreateCard";
8
+ import { CalendarDetailsForm } from "./CalendarDetailsForm";
9
+ import { CalendarFeedCard } from "./CalendarFeedCard";
10
+
11
+ // The node test loader transpiles remit-ui's `.tsx` with the classic JSX
12
+ // runtime, which references a global `React`.
13
+ (globalThis as { React?: typeof React }).React = React;
14
+
15
+ const details = (problem = "", timezone = "Europe/Amsterdam") =>
16
+ renderToString(
17
+ createElement(CalendarDetailsForm, {
18
+ calendarName: "Work",
19
+ timezone,
20
+ isBusy: false,
21
+ problem,
22
+ onSave: () => undefined,
23
+ onDelete: () => undefined,
24
+ }) as never,
25
+ );
26
+
27
+ describe("CalendarDetailsForm", () => {
28
+ it("offers the name, the zone, a save and a delete", () => {
29
+ const html = details();
30
+ assert.match(html, /value="Work"/);
31
+ assert.match(html, /Europe\/Amsterdam/);
32
+ assert.match(html, /Save changes/);
33
+ assert.match(html, /Delete calendar/);
34
+ });
35
+
36
+ it("offers no zone as its own choice, read as UTC", () => {
37
+ assert.match(details("", ""), /None \(read as UTC\)/);
38
+ });
39
+
40
+ it("keeps a zone the list does not hold rather than dropping it", () => {
41
+ assert.match(details("", "Mars/Olympus"), /Mars\/Olympus/);
42
+ });
43
+
44
+ it("states a refusal in the server's words", () => {
45
+ const html = details("cannot be removed");
46
+ assert.match(html, /role="alert"/);
47
+ assert.match(html.replaceAll("<!-- -->", ""), /Work was not changed/);
48
+ assert.match(html, /cannot be removed/);
49
+ });
50
+ });
51
+
52
+ describe("CalendarCreateCard", () => {
53
+ const create = (problem = "") =>
54
+ renderToString(
55
+ createElement(CalendarCreateCard, {
56
+ isBusy: false,
57
+ problem,
58
+ onCreate: () => Promise.resolve(true),
59
+ }) as never,
60
+ );
61
+
62
+ it("asks for a name, an address and a zone", () => {
63
+ const html = create();
64
+ assert.match(html, /New calendar/);
65
+ assert.match(html, />Name</);
66
+ assert.match(html, />Address</);
67
+ assert.match(html, />Time zone</);
68
+ assert.match(html, /Add calendar/);
69
+ });
70
+
71
+ it("states a refused create where the button is", () => {
72
+ const html = create("already addresses a calendar");
73
+ assert.match(html, /The calendar was not added/);
74
+ assert.match(html, /already addresses a calendar/);
75
+ });
76
+ });
77
+
78
+ describe("CalendarFeedCard with details", () => {
79
+ it("draws the calendar's settings above its address", () => {
80
+ const html = renderToString(
81
+ createElement(CalendarFeedCard, {
82
+ calendarName: "Work",
83
+ details: createElement("p", null, "calendar details here"),
84
+ state: { status: "absent" },
85
+ mintedUrl: "",
86
+ isBusy: false,
87
+ actionError: undefined,
88
+ onMint: () => undefined,
89
+ onRevoke: () => undefined,
90
+ onDismissMinted: () => undefined,
91
+ onRetry: () => undefined,
92
+ }) as never,
93
+ );
94
+ assert.ok(
95
+ html.indexOf("calendar details here") <
96
+ html.indexOf("Create subscription address"),
97
+ );
98
+ });
99
+ });
100
+
101
+ describe("CalendarComposePane clashes", () => {
102
+ const draft: EventDraft = {
103
+ title: "Roadmap review",
104
+ date: "2026-06-10",
105
+ endDate: "2026-06-10",
106
+ startTime: "10:00",
107
+ endTime: "11:00",
108
+ allDay: false,
109
+ calendarId: "cal_work",
110
+ location: "",
111
+ guests: "",
112
+ notes: "",
113
+ repeat: "",
114
+ };
115
+ const pane = (clashes: CalendarClash[] | undefined) =>
116
+ renderToString(
117
+ createElement(CalendarComposePane, {
118
+ title: "New event",
119
+ calendars: [],
120
+ draft,
121
+ onChange: () => undefined,
122
+ problem: "",
123
+ saveLabel: "Add",
124
+ isSaving: false,
125
+ clashes,
126
+ onSave: () => undefined,
127
+ onCancel: () => undefined,
128
+ }) as never,
129
+ );
130
+
131
+ it("names what the span runs into", () => {
132
+ const html = pane([{ id: "a", label: "Board prep, Wed 10 June" }]);
133
+ assert.match(
134
+ html,
135
+ /This clashes with something you have already agreed to/,
136
+ );
137
+ assert.match(html, /Board prep, Wed 10 June/);
138
+ });
139
+
140
+ it("says a checked span is clear", () => {
141
+ assert.match(pane([]), /Nothing else is booked at this time/);
142
+ });
143
+
144
+ it("draws nothing before there is an answer", () => {
145
+ const html = pane(undefined);
146
+ assert.doesNotMatch(html, /Nothing else is booked/);
147
+ assert.doesNotMatch(html, /This clashes/);
148
+ });
149
+ });
@@ -0,0 +1,259 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, describe, it } from "node:test";
3
+ import type {
4
+ RemitImapCalendarEventInstance,
5
+ RemitImapCalendarResponse,
6
+ } from "@remit/api-http-client/types.gen.ts";
7
+ import type { CalendarClash, EventDraft } from "@remit/ui";
8
+ import { act, createElement } from "react";
9
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
10
+ import {
11
+ type HttpCall,
12
+ type HttpMock,
13
+ httpError,
14
+ mockFetch,
15
+ } from "../../test-support/http";
16
+ import {
17
+ type CalendarCollectionOutcome,
18
+ type CalendarCollectionWrites,
19
+ useCalendarCollectionWrites,
20
+ } from "./useCalendarCollectionWrites";
21
+ import { useCalendars } from "./useCalendars";
22
+ import { useDraftClashes } from "./useDraftClashes";
23
+
24
+ const WORK = "11111111-1111-4111-8111-111111111111";
25
+
26
+ let harness: DomHarness | undefined;
27
+ let http: HttpMock | undefined;
28
+ let writes: CalendarCollectionWrites | undefined;
29
+ let clashes: CalendarClash[] | undefined;
30
+
31
+ afterEach(() => {
32
+ harness?.close();
33
+ harness = undefined;
34
+ http?.restore();
35
+ http = undefined;
36
+ writes = undefined;
37
+ clashes = undefined;
38
+ });
39
+
40
+ const calendars: RemitImapCalendarResponse[] = [
41
+ {
42
+ calendarId: WORK,
43
+ accountConfigId: "cfg-1",
44
+ urlSegment: "work",
45
+ displayName: "Work",
46
+ color: "Cal1",
47
+ componentSet: "VeventOnly",
48
+ source: "UserCreated",
49
+ timezone: "",
50
+ syncSequence: 1,
51
+ createdAt: 0,
52
+ updatedAt: 0,
53
+ } as RemitImapCalendarResponse,
54
+ ];
55
+
56
+ const instance = (
57
+ over: Partial<RemitImapCalendarEventInstance>,
58
+ ): RemitImapCalendarEventInstance =>
59
+ ({
60
+ calendarId: WORK,
61
+ calendarObjectId: "obj-1",
62
+ recurrenceId: "",
63
+ icalUid: "uid-1",
64
+ summary: "Board prep",
65
+ start: "2026-06-10T10:30:00+00:00",
66
+ end: "2026-06-10T11:00:00+00:00",
67
+ allDay: false,
68
+ status: "Confirmed",
69
+ transparency: "Opaque",
70
+ zoneCertainty: "Explicit",
71
+ etag: "etag-1",
72
+ hasRecurrence: false,
73
+ ...over,
74
+ }) as RemitImapCalendarEventInstance;
75
+
76
+ const draft = (over: Partial<EventDraft>): EventDraft => ({
77
+ title: "Roadmap review",
78
+ date: "2026-06-10",
79
+ endDate: "2026-06-10",
80
+ startTime: "10:00",
81
+ endTime: "11:00",
82
+ allDay: false,
83
+ calendarId: WORK,
84
+ location: "",
85
+ guests: "",
86
+ notes: "",
87
+ repeat: "",
88
+ ...over,
89
+ });
90
+
91
+ const settle = async () => {
92
+ await harness?.flush();
93
+ await harness?.wait(20);
94
+ await harness?.flush();
95
+ };
96
+
97
+ const serve = (respond: (call: HttpCall) => unknown) => {
98
+ http = mockFetch((call) =>
99
+ call.path.endsWith("/calendars") && call.method === "GET"
100
+ ? { items: calendars }
101
+ : respond(call),
102
+ );
103
+ harness = createDomHarness();
104
+ };
105
+
106
+ function WritesProbe() {
107
+ useCalendars();
108
+ writes = useCalendarCollectionWrites();
109
+ return null;
110
+ }
111
+
112
+ const write = async (
113
+ run: (writes: CalendarCollectionWrites) => Promise<CalendarCollectionOutcome>,
114
+ ): Promise<CalendarCollectionOutcome> => {
115
+ const held = writes;
116
+ if (!held) throw new Error("the writes are not mounted");
117
+ let outcome: CalendarCollectionOutcome | undefined;
118
+ await act(async () => {
119
+ outcome = await run(held);
120
+ });
121
+ await settle();
122
+ if (!outcome) throw new Error("the write returned nothing");
123
+ return outcome;
124
+ };
125
+
126
+ const listingReads = (): number =>
127
+ (http?.calls ?? []).filter(
128
+ (call) => call.method === "GET" && call.path.endsWith("/calendars"),
129
+ ).length;
130
+
131
+ describe("managing a calendar collection", () => {
132
+ it("creates one and reads the listing back", async () => {
133
+ serve(() => calendars[0]);
134
+ harness?.renderApp(createElement(WritesProbe));
135
+ await settle();
136
+ const before = listingReads();
137
+
138
+ const outcome = await write((writes) =>
139
+ writes.createCalendar({ displayName: "Harbour", urlSegment: "harbour" }),
140
+ );
141
+
142
+ assert.deepEqual(outcome, { kind: "written" });
143
+ const posted = (http?.calls ?? []).find((call) => call.method === "POST");
144
+ assert.equal(posted?.body?.urlSegment, "harbour");
145
+ assert.ok(listingReads() > before, "the listing has to be read again");
146
+ });
147
+
148
+ it("renames and rezones one through a patch", async () => {
149
+ serve(() => calendars[0]);
150
+ harness?.renderApp(createElement(WritesProbe));
151
+ await settle();
152
+
153
+ await write((writes) =>
154
+ writes.updateCalendar(WORK, {
155
+ displayName: "Works",
156
+ timezone: "Europe/Lisbon",
157
+ }),
158
+ );
159
+
160
+ const patched = (http?.calls ?? []).find((call) => call.method === "PATCH");
161
+ assert.equal(patched?.path.endsWith(`/calendars/${WORK}`), true);
162
+ assert.equal(patched?.body?.timezone, "Europe/Lisbon");
163
+ });
164
+
165
+ it("hands back the server's words when it refuses a delete", async () => {
166
+ serve((call) =>
167
+ call.method === "DELETE"
168
+ ? httpError(400, '"default" cannot be removed')
169
+ : calendars[0],
170
+ );
171
+ harness?.renderApp(createElement(WritesProbe));
172
+ await settle();
173
+
174
+ const outcome = await write((writes) => writes.deleteCalendar(WORK));
175
+ assert.equal(outcome.kind, "refused");
176
+ assert.match(
177
+ outcome.kind === "refused" ? outcome.message : "",
178
+ /cannot be removed/,
179
+ );
180
+ });
181
+ });
182
+
183
+ function ClashProbe({
184
+ of,
185
+ editing = "",
186
+ }: {
187
+ of: EventDraft;
188
+ editing?: string;
189
+ }) {
190
+ clashes = useDraftClashes(of, editing);
191
+ return null;
192
+ }
193
+
194
+ const clashesFor = async (
195
+ of: EventDraft,
196
+ items: RemitImapCalendarEventInstance[],
197
+ editing = "",
198
+ ): Promise<CalendarClash[] | undefined> => {
199
+ serve(() => ({ items }));
200
+ harness?.renderApp(createElement(ClashProbe, { of, editing }));
201
+ await settle();
202
+ return clashes;
203
+ };
204
+
205
+ describe("the clashes a draft runs into", () => {
206
+ it("names an event the span overlaps", async () => {
207
+ const found = await clashesFor(draft({}), [instance({})]);
208
+ assert.equal(found?.length, 1);
209
+ assert.match(found?.[0]?.label ?? "", /^Board prep, /);
210
+ });
211
+
212
+ it("finds none for a span in a free slot", async () => {
213
+ const found = await clashesFor(
214
+ draft({ startTime: "14:00", endTime: "15:00" }),
215
+ [instance({})],
216
+ );
217
+ assert.deepEqual(found, []);
218
+ });
219
+
220
+ it("never counts the event being edited against itself", async () => {
221
+ const found = await clashesFor(draft({}), [instance({})], "obj-1");
222
+ assert.deepEqual(found, []);
223
+ });
224
+
225
+ it("checks a multi-day span through its last day", async () => {
226
+ const found = await clashesFor(
227
+ draft({
228
+ endDate: "2026-06-16",
229
+ startTime: "09:00",
230
+ endTime: "17:00",
231
+ }),
232
+ [
233
+ instance({
234
+ start: "2026-06-15T12:00:00+00:00",
235
+ end: "2026-06-15T13:00:00+00:00",
236
+ }),
237
+ ],
238
+ );
239
+ assert.equal(found?.length, 1);
240
+ const read = http?.to("/calendar-events")[0]?.url ?? "";
241
+ assert.ok(
242
+ read.includes(`to=${encodeURIComponent("2026-06-22")}`),
243
+ "the read has to cover the week of the last day",
244
+ );
245
+ });
246
+
247
+ it("checks nothing when the end is not after the start", async () => {
248
+ const found = await clashesFor(
249
+ draft({ startTime: "10:00", endTime: "10:00" }),
250
+ [instance({})],
251
+ );
252
+ assert.equal(found, undefined);
253
+ });
254
+
255
+ it("checks nothing for an all-day draft", async () => {
256
+ const found = await clashesFor(draft({ allDay: true }), [instance({})]);
257
+ assert.equal(found, undefined);
258
+ });
259
+ });
@@ -57,6 +57,11 @@ export {
57
57
  useCalendarSelection,
58
58
  useDrawnEvents,
59
59
  } from "./selection";
60
+ export {
61
+ type CalendarCollectionOutcome,
62
+ type CalendarCollectionWrites,
63
+ useCalendarCollectionWrites,
64
+ } from "./useCalendarCollectionWrites";
60
65
  export {
61
66
  type CalendarEventResource,
62
67
  useCalendarEvent,
@@ -82,6 +87,7 @@ export {
82
87
  useCalendarWrites,
83
88
  useInvalidateCalendarReads,
84
89
  } from "./useCalendarWrites";
90
+ export { useDraftClashes } from "./useDraftClashes";
85
91
  export {
86
92
  addDays,
87
93
  type CalendarWindow,