@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
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Creating, renaming, rezoning and deleting a calendar collection.
3
+ *
4
+ * A refusal comes back as an outcome carrying the server's own words — a url
5
+ * segment already taken, a zone it cannot resolve, the default calendar that
6
+ * cannot be removed — so the settings card states it where the reader acted.
7
+ * A lapsed session is not among the statuses owned here and escalates.
8
+ *
9
+ * Every write invalidates the listing, which is what the settings page, the
10
+ * editor's picker and the address filter all read. A delete or a rezone also
11
+ * moves or removes occurrences, so the event reads are invalidated with it.
12
+ */
13
+ import {
14
+ calendarDetailOperationsDeleteCalendarMutation,
15
+ calendarDetailOperationsUpdateCalendarMutation,
16
+ calendarOperationsCreateCalendarMutation,
17
+ calendarOperationsListCalendarsQueryKey,
18
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
19
+ import type {
20
+ RemitImapCreateCalendarInput,
21
+ RemitImapUpdateCalendarInput,
22
+ } from "@remit/api-http-client/types.gen.ts";
23
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
24
+ import { useCallback, useMemo } from "react";
25
+ import { formatErrorMessage } from "@/components/ui/ErrorState";
26
+ import { softErrorStatuses } from "@/lib/error-classifier";
27
+ import { useInvalidateCalendarReads } from "./useCalendarWrites";
28
+
29
+ const COLLECTION_WRITE_META = softErrorStatuses(400, 404);
30
+
31
+ export type CalendarCollectionOutcome =
32
+ | { kind: "written" }
33
+ | { kind: "refused"; message: string };
34
+
35
+ const WRITTEN: CalendarCollectionOutcome = { kind: "written" };
36
+
37
+ const refused = (error: unknown): CalendarCollectionOutcome => ({
38
+ kind: "refused",
39
+ message: formatErrorMessage(error),
40
+ });
41
+
42
+ export interface CalendarCollectionWrites {
43
+ createCalendar: (
44
+ input: RemitImapCreateCalendarInput,
45
+ ) => Promise<CalendarCollectionOutcome>;
46
+ updateCalendar: (
47
+ calendarId: string,
48
+ patch: RemitImapUpdateCalendarInput,
49
+ ) => Promise<CalendarCollectionOutcome>;
50
+ deleteCalendar: (calendarId: string) => Promise<CalendarCollectionOutcome>;
51
+ isWriting: boolean;
52
+ }
53
+
54
+ export function useCalendarCollectionWrites(): CalendarCollectionWrites {
55
+ const queryClient = useQueryClient();
56
+ const invalidateReads = useInvalidateCalendarReads();
57
+
58
+ const invalidateListing = useCallback(
59
+ () =>
60
+ queryClient.invalidateQueries({
61
+ queryKey: calendarOperationsListCalendarsQueryKey(),
62
+ }),
63
+ [queryClient],
64
+ );
65
+
66
+ const invalidateAll = useCallback(() => {
67
+ invalidateReads();
68
+ return invalidateListing();
69
+ }, [invalidateReads, invalidateListing]);
70
+
71
+ const create = useMutation({
72
+ ...calendarOperationsCreateCalendarMutation(),
73
+ meta: COLLECTION_WRITE_META,
74
+ onSuccess: invalidateListing,
75
+ });
76
+ const update = useMutation({
77
+ ...calendarDetailOperationsUpdateCalendarMutation(),
78
+ meta: COLLECTION_WRITE_META,
79
+ onSuccess: invalidateAll,
80
+ });
81
+ const remove = useMutation({
82
+ ...calendarDetailOperationsDeleteCalendarMutation(),
83
+ meta: COLLECTION_WRITE_META,
84
+ onSuccess: invalidateAll,
85
+ });
86
+
87
+ const createCalendar = useCallback(
88
+ (input: RemitImapCreateCalendarInput) =>
89
+ create
90
+ .mutateAsync({ body: input })
91
+ .then(() => WRITTEN)
92
+ .catch(refused),
93
+ [create],
94
+ );
95
+
96
+ const updateCalendar = useCallback(
97
+ (calendarId: string, patch: RemitImapUpdateCalendarInput) =>
98
+ update
99
+ .mutateAsync({ path: { calendarId }, body: patch })
100
+ .then(() => WRITTEN)
101
+ .catch(refused),
102
+ [update],
103
+ );
104
+
105
+ const deleteCalendar = useCallback(
106
+ (calendarId: string) =>
107
+ remove
108
+ .mutateAsync({ path: { calendarId } })
109
+ .then(() => WRITTEN)
110
+ .catch(refused),
111
+ [remove],
112
+ );
113
+
114
+ return useMemo(
115
+ () => ({
116
+ createCalendar,
117
+ updateCalendar,
118
+ deleteCalendar,
119
+ isWriting: create.isPending || update.isPending || remove.isPending,
120
+ }),
121
+ [
122
+ createCalendar,
123
+ updateCalendar,
124
+ deleteCalendar,
125
+ create.isPending,
126
+ update.isPending,
127
+ remove.isPending,
128
+ ],
129
+ );
130
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * What the span a draft names runs into, across every calendar the reader
3
+ * holds — so the editor says so before Save rather than the grid after it.
4
+ *
5
+ * Read on the weeks from the draft's first day through its last, which for a
6
+ * single-day draft is the grid's own cache entry, and measured by the same rule the invitation card uses. Undefined
7
+ * while there is no span to check (all day, a time missing, an end not after
8
+ * the start) or no answer to
9
+ * check it against: drawing "nothing else is booked" over a read that has not
10
+ * landed is a claim, not a delay.
11
+ */
12
+ import {
13
+ type CalendarClash,
14
+ type CalendarEventData,
15
+ clashesWith,
16
+ type EventDraft,
17
+ } from "@remit/ui";
18
+ import { useMemo } from "react";
19
+ import { formatEventWhen } from "@/lib/calendar-format";
20
+ import {
21
+ isDrawnInstance,
22
+ toCalendarEventData,
23
+ UNZONED_CALENDAR,
24
+ } from "./instance";
25
+ import { useCalendarEventWindow } from "./useCalendarEvents";
26
+ import { useCalendars } from "./useCalendars";
27
+ import { type CalendarWindow, calendarWindow, isoAtInZone } from "./window";
28
+
29
+ const EVERY_CALENDAR: readonly string[] = [];
30
+ const NO_WINDOW: CalendarWindow = { from: "", to: "" };
31
+
32
+ export function useDraftClashes(
33
+ draft: EventDraft,
34
+ /** The resource being edited, which never clashes with itself. */
35
+ calendarObjectId = "",
36
+ ): CalendarClash[] | undefined {
37
+ const { timeZoneByCalendarId } = useCalendars();
38
+ const checkable =
39
+ draft.date !== "" &&
40
+ !draft.allDay &&
41
+ draft.endDate !== "" &&
42
+ draft.startTime !== "" &&
43
+ draft.endTime !== "" &&
44
+ `${draft.endDate}T${draft.endTime}` > `${draft.date}T${draft.startTime}`;
45
+ const { instances, isLoading, error } = useCalendarEventWindow({
46
+ ...(checkable
47
+ ? {
48
+ from: calendarWindow("week", draft.date).from,
49
+ to: calendarWindow("week", draft.endDate).to,
50
+ }
51
+ : NO_WINDOW),
52
+ calendarIds: EVERY_CALENDAR,
53
+ enabled: checkable,
54
+ });
55
+
56
+ const zone = timeZoneByCalendarId[draft.calendarId] ?? UNZONED_CALENDAR;
57
+
58
+ return useMemo(() => {
59
+ if (!checkable || isLoading || error) return undefined;
60
+ const start = isoAtInZone(draft.date, draft.startTime, zone);
61
+ const end = isoAtInZone(draft.endDate, draft.endTime, zone);
62
+ const others: CalendarEventData[] = instances
63
+ .filter(
64
+ (instance) =>
65
+ isDrawnInstance(instance) &&
66
+ instance.calendarObjectId !== calendarObjectId,
67
+ )
68
+ .map((instance) =>
69
+ toCalendarEventData(
70
+ instance,
71
+ timeZoneByCalendarId[instance.calendarId] ?? UNZONED_CALENDAR,
72
+ ),
73
+ );
74
+ return clashesWith({ start, end }, others).map((event) => ({
75
+ id: event.id,
76
+ label: `${event.title}, ${formatEventWhen(event)}`,
77
+ }));
78
+ }, [
79
+ checkable,
80
+ isLoading,
81
+ error,
82
+ draft.date,
83
+ draft.endDate,
84
+ draft.startTime,
85
+ draft.endTime,
86
+ zone,
87
+ instances,
88
+ calendarObjectId,
89
+ timeZoneByCalendarId,
90
+ ]);
91
+ }
@@ -0,0 +1,23 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import { calendarUrlSegmentFor } from "./calendar-collection-copy.js";
4
+
5
+ describe("calendarUrlSegmentFor", () => {
6
+ test("folds a name into a path segment", () => {
7
+ assert.strictEqual(
8
+ calendarUrlSegmentFor("Harbour Projects"),
9
+ "harbour-projects",
10
+ );
11
+ });
12
+
13
+ test("drops accents and punctuation at either end", () => {
14
+ assert.strictEqual(
15
+ calendarUrlSegmentFor(" Café & Crèche! "),
16
+ "cafe-creche",
17
+ );
18
+ });
19
+
20
+ test("stays within the 64 characters the API accepts", () => {
21
+ assert.strictEqual(calendarUrlSegmentFor("a".repeat(80)).length, 64);
22
+ });
23
+ });
@@ -0,0 +1,21 @@
1
+ export interface CollectionConfirmCopy {
2
+ title: string;
3
+ description: string;
4
+ }
5
+
6
+ export const deleteCalendarConfirmCopy = (
7
+ calendarName: string,
8
+ ): CollectionConfirmCopy => ({
9
+ title: `Delete ${calendarName}?`,
10
+ description: `This removes ${calendarName} and every event in it, and any subscription address stops working. It cannot be undone.`,
11
+ });
12
+
13
+ /** A starting address for a new calendar, from the name typed so far. */
14
+ export const calendarUrlSegmentFor = (displayName: string): string =>
15
+ displayName
16
+ .normalize("NFKD")
17
+ .replace(/[̀-ͯ]/g, "")
18
+ .toLowerCase()
19
+ .replace(/[^a-z0-9]+/g, "-")
20
+ .replace(/^-+|-+$/g, "")
21
+ .slice(0, 64);
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
+ import { APP_PASSWORD_URLS, discoverSettings } from "./autodiscovery.js";
3
4
  import {
4
5
  getPresetById,
5
6
  PROVIDER_PRESETS,
@@ -29,12 +30,16 @@ const EXPECTED: Record<string, ExpectedSettings> = {
29
30
  imap: { host: "imap.fastmail.com", port: 993, security: "tls" },
30
31
  smtp: { host: "smtp.fastmail.com", port: 465, security: "tls" },
31
32
  },
33
+ gmail: {
34
+ imap: { host: "imap.gmail.com", port: 993, security: "tls" },
35
+ smtp: { host: "smtp.gmail.com", port: 587, security: "starttls" },
36
+ },
32
37
  };
33
38
 
34
39
  describe("provider presets", () => {
35
- it("exposes exactly the four documented providers", () => {
40
+ it("exposes exactly the five documented providers", () => {
36
41
  const ids = PROVIDER_PRESETS.map((p) => p.id).sort();
37
- assert.deepEqual(ids, ["aol", "fastmail", "icloud", "yahoo"]);
42
+ assert.deepEqual(ids, ["aol", "fastmail", "gmail", "icloud", "yahoo"]);
38
43
  });
39
44
 
40
45
  for (const preset of PROVIDER_PRESETS) {
@@ -71,6 +76,18 @@ describe("provider presets", () => {
71
76
  assert.equal(preset.label, "iCloud");
72
77
  });
73
78
 
79
+ it("tells a Gmail user to turn on 2-Step Verification before creating an app password", () => {
80
+ const gmail = getPresetById("gmail");
81
+ assert.ok(gmail);
82
+ const { text, url } = gmail.passwordHelp;
83
+ const twoStep = text.search(/2-Step Verification/);
84
+ const appPassword = text.search(/create an app password/i);
85
+ assert.ok(twoStep >= 0, "names 2-Step Verification");
86
+ assert.ok(appPassword > twoStep, "2-Step Verification comes first");
87
+ assert.equal(url, APP_PASSWORD_URLS["gmail.com"]);
88
+ assert.equal(url, APP_PASSWORD_URLS["googlemail.com"]);
89
+ });
90
+
74
91
  it("returns undefined for an unknown id", () => {
75
92
  assert.equal(getPresetById("custom"), undefined);
76
93
  });
@@ -82,6 +99,20 @@ describe("provider presets", () => {
82
99
  assert.equal(presetIdForEmail("carol@yahoo.com"), "yahoo");
83
100
  assert.equal(presetIdForEmail("dave@aol.com"), "aol");
84
101
  assert.equal(presetIdForEmail("erin@fastmail.com"), "fastmail");
102
+ assert.equal(presetIdForEmail("frank@gmail.com"), "gmail");
103
+ assert.equal(presetIdForEmail("grace@googlemail.com"), "gmail");
104
+ });
105
+
106
+ it("resolves a Gmail address to the preset its server lookup agrees with", async () => {
107
+ for (const email of ["user@gmail.com", "User@GoogleMail.com"]) {
108
+ const preset = getPresetById(presetIdForEmail(email));
109
+ assert.ok(preset, email);
110
+ assert.equal(preset.id, "gmail");
111
+ const discovered = await discoverSettings(email);
112
+ assert.ok(discovered, email);
113
+ assert.deepEqual(preset.imap, discovered.imap);
114
+ assert.deepEqual(preset.smtp, discovered.smtp);
115
+ }
85
116
  });
86
117
 
87
118
  it("is case-insensitive on the domain", () => {
@@ -1,4 +1,4 @@
1
- import type { ServerSettings } from "./autodiscovery.js";
1
+ import { APP_PASSWORD_URLS, type ServerSettings } from "./autodiscovery.js";
2
2
 
3
3
  export interface ProviderPreset {
4
4
  id: string;
@@ -54,6 +54,17 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
54
54
  url: "https://www.fastmail.help/hc/en-us/articles/360058752854",
55
55
  },
56
56
  },
57
+ {
58
+ id: "gmail",
59
+ label: "Gmail",
60
+ username: "full-email",
61
+ imap: { host: "imap.gmail.com", port: 993, security: "tls" },
62
+ smtp: { host: "smtp.gmail.com", port: 587, security: "starttls" },
63
+ passwordHelp: {
64
+ text: "Gmail requires an app password, not your Google password. First turn on 2-Step Verification for your Google Account, then create an app password under Security → 2-Step Verification → App passwords.",
65
+ url: APP_PASSWORD_URLS["gmail.com"],
66
+ },
67
+ },
57
68
  ];
58
69
 
59
70
  export function getPresetById(id: string): ProviderPreset | undefined {
@@ -70,6 +81,8 @@ const DOMAIN_TO_PRESET: Record<string, string> = {
70
81
  "aol.com": "aol",
71
82
  "fastmail.com": "fastmail",
72
83
  "fastmail.fm": "fastmail",
84
+ "gmail.com": "gmail",
85
+ "googlemail.com": "gmail",
73
86
  };
74
87
 
75
88
  /** Pre-select a provider preset from a known email domain; "" for Custom. */
@@ -1,7 +1,9 @@
1
1
  import { SettingsShell } from "@remit/ui";
2
2
  import { createFileRoute, useNavigate } from "@tanstack/react-router";
3
3
  import { useState } from "react";
4
- import { CalendarFeedPanel } from "@/components/settings/CalendarFeedPanel";
4
+ import { CalendarCreateCard } from "@/components/settings/CalendarCreateCard";
5
+ import { CalendarSettingsPanel } from "@/components/settings/CalendarSettingsPanel";
6
+ import { useCalendarCollectionWrites } from "@/hooks/calendar/useCalendarCollectionWrites";
5
7
  import { useCalendars } from "@/hooks/calendar/useCalendars";
6
8
  import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
7
9
 
@@ -11,6 +13,13 @@ export const Route = createFileRoute("/settings/calendars")({
11
13
 
12
14
  const calendarsHelp = (
13
15
  <div className="space-y-3">
16
+ <p>
17
+ Each calendar has a name, a time zone its floating times are read in, and
18
+ an address CalDAV clients reach it by. The address is fixed when the
19
+ calendar is added; the name and zone can change. Deleting a calendar
20
+ deletes every event in it. The default calendar is where accepted
21
+ invitations land, so it cannot be deleted.
22
+ </p>
14
23
  <p>
15
24
  A subscription address lets Apple Calendar, Google Calendar, Outlook and
16
25
  Thunderbird show a calendar from here, read-only and kept up to date.
@@ -31,14 +40,16 @@ const calendarsHelp = (
31
40
  function CalendarsSettings() {
32
41
  const navigate = useNavigate();
33
42
  const [helpOpen, setHelpOpen] = useState(true);
34
- const { calendars, isLoading } = useCalendars();
43
+ const { calendars, timeZoneByCalendarId, isLoading } = useCalendars();
44
+ const { createCalendar, isWriting } = useCalendarCollectionWrites();
45
+ const [createProblem, setCreateProblem] = useState("");
35
46
 
36
47
  return (
37
48
  <SettingsShell
38
49
  items={SETTINGS_NAV_ITEMS}
39
50
  activeId="calendars"
40
51
  title="Calendars"
41
- description="Share a calendar with any client that can subscribe to a URL."
52
+ description="Add, rename and delete calendars, and share one with any client that can subscribe to a URL."
42
53
  help={calendarsHelp}
43
54
  helpOpen={helpOpen}
44
55
  onToggleHelp={() => setHelpOpen((open) => !open)}
@@ -55,17 +66,28 @@ function CalendarsSettings() {
55
66
  aria-busy="true"
56
67
  aria-label="Loading calendars"
57
68
  />
58
- ) : calendars.length === 0 ? (
59
- <p className="py-12 text-sm text-fg-muted">No calendars yet.</p>
60
69
  ) : (
61
70
  <div className="space-y-4">
62
71
  {calendars.map((calendar) => (
63
- <CalendarFeedPanel
72
+ <CalendarSettingsPanel
64
73
  key={calendar.id}
65
74
  calendarId={calendar.id}
66
75
  calendarName={calendar.name}
76
+ timezone={timeZoneByCalendarId[calendar.id] ?? ""}
67
77
  />
68
78
  ))}
79
+ <CalendarCreateCard
80
+ isBusy={isWriting}
81
+ problem={createProblem}
82
+ onCreate={(calendar) => {
83
+ setCreateProblem("");
84
+ return createCalendar(calendar).then((outcome) => {
85
+ if (outcome.kind === "written") return true;
86
+ setCreateProblem(outcome.message);
87
+ return false;
88
+ });
89
+ }}
90
+ />
69
91
  </div>
70
92
  )}
71
93
  </SettingsShell>