@remit/web-client 0.0.172 → 0.0.173

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.172",
3
+ "version": "0.0.173",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -6,10 +6,10 @@ import { ErrorBanner } from "./ErrorBanner";
6
6
  * message list, so it is opaque and it names its own severity out loud rather
7
7
  * than leaving colour to carry the meaning.
8
8
  *
9
- * A failure the user cannot act on still gets an action link, prefilled, so
10
- * reporting it is one click instead of a form they have to assemble. The
11
- * hrefs below stand in for the real report URL, which the app builds from
12
- * build-time constants Storybook has no `define` for.
9
+ * Every error banner carries a prefilled report link, so reporting a failure
10
+ * the user cannot act on is one click instead of a form they have to
11
+ * assemble. The hrefs below stand in for the real report URL, which the app
12
+ * builds from build-time constants Storybook has no `define` for.
13
13
  */
14
14
  const meta: Meta<typeof ErrorBanner> = {
15
15
  title: "Components/ErrorBanner",
@@ -27,21 +27,38 @@ type Story = StoryObj<typeof ErrorBanner>;
27
27
  const REPORT_URL =
28
28
  "https://github.com/remit-mail/reader/issues/new?title=Spellcheck+stopped";
29
29
 
30
- /** A mutation that failed, with the reason underneath. */
30
+ /** A mutation that failed, with the reason underneath and a way out. */
31
31
  export const Failed: Story = {
32
32
  name: "Error",
33
33
  args: {
34
34
  severity: "error",
35
35
  title: "Couldn't move message",
36
36
  detail: "Connection reset by peer",
37
+ action: { label: "Report an issue", href: REPORT_URL },
37
38
  },
38
39
  };
39
40
 
40
- /** Nothing more to say than the title. */
41
+ /** Nothing more to say than the title — the report link still stands. */
41
42
  export const NoDetail: Story = {
42
43
  args: {
43
44
  severity: "error",
44
45
  title: "Couldn't move message",
46
+ action: { label: "Report an issue", href: REPORT_URL },
47
+ },
48
+ };
49
+
50
+ /**
51
+ * Report spam failing because the account has nowhere to file it. The reason
52
+ * names the folder and the fix, so this one is actionable without the report
53
+ * link — which is offered anyway, because the user should not have to decide.
54
+ */
55
+ export const NoJunkFolder: Story = {
56
+ args: {
57
+ severity: "error",
58
+ title: "Couldn't report this message as spam",
59
+ detail:
60
+ "This account has no Junk folder. Create one named Junk or Spam in your mail provider, then report this message again.",
61
+ action: { label: "Report an issue", href: REPORT_URL },
45
62
  },
46
63
  };
47
64
 
@@ -6,6 +6,7 @@ import {
6
6
  useMemo,
7
7
  useState,
8
8
  } from "react";
9
+ import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
9
10
  import { isAlwaysFatal } from "@/lib/error-classifier";
10
11
  import { reportFatalError } from "@/lib/fatal-error";
11
12
  import { ErrorBannerStack } from "./ErrorBannerStack.js";
@@ -13,6 +14,7 @@ import {
13
14
  appendBanner,
14
15
  buildEntry,
15
16
  dismissBanner,
17
+ type ErrorBannerAction,
16
18
  type ErrorBannerEntry,
17
19
  type PushErrorInput,
18
20
  } from "./error-banners.js";
@@ -28,6 +30,37 @@ const ErrorBannerContext = createContext<ErrorBannerContextValue | undefined>(
28
30
  undefined,
29
31
  );
30
32
 
33
+ /**
34
+ * A way out of every failure banner. A soft failure is still a failure the
35
+ * user could not prevent and cannot diagnose, and a banner offering only
36
+ * "Dismiss" is a dead end — the fatal screen has carried a prefilled report
37
+ * since issue #55 and the soft surface never did. The report is seeded from
38
+ * the banner it belongs to, so it names the failure instead of asking the
39
+ * user to describe it, and it carries the same breadcrumbs — failing request,
40
+ * navigation, console — the fatal report does.
41
+ *
42
+ * A caller that already has a better way out keeps it, and a warning or an
43
+ * informational banner gets none: those are not failures to report.
44
+ */
45
+ export const bannerWayOut = (
46
+ input: PushErrorInput,
47
+ ): ErrorBannerAction | undefined => {
48
+ if (input.action) return input.action;
49
+ if ((input.severity ?? "error") !== "error") return undefined;
50
+ return {
51
+ label: "Report an issue",
52
+ href: buildGitHubIssueUrl(
53
+ buildBugReportContext({
54
+ title: `Bug: ${input.title}`,
55
+ errorMessage: input.detail
56
+ ? `${input.title} — ${input.detail}`
57
+ : input.title,
58
+ stack: input.error instanceof Error ? input.error.stack : undefined,
59
+ }),
60
+ ),
61
+ };
62
+ };
63
+
31
64
  const generateId = (): string => {
32
65
  if (
33
66
  typeof globalThis.crypto !== "undefined" &&
@@ -58,7 +91,11 @@ export const ErrorBannerProvider = ({ children }: { children: ReactNode }) => {
58
91
  if (input.error != null && isAlwaysFatal(input.error)) {
59
92
  return reportFatalError(input.error).correlationId;
60
93
  }
61
- const entry = buildEntry(input, generateId(), Date.now());
94
+ const entry = buildEntry(
95
+ { ...input, action: bannerWayOut(input) },
96
+ generateId(),
97
+ Date.now(),
98
+ );
62
99
  setErrors((current) => appendBanner(current, entry));
63
100
  return entry.id;
64
101
  }, []);
@@ -0,0 +1,71 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { ApiError } from "@/lib/api";
4
+ import { bannerWayOut } from "./ErrorBannerProvider";
5
+
6
+ const issueBody = (href: string): string =>
7
+ new URL(href).searchParams.get("body") ?? "";
8
+
9
+ const issueTitle = (href: string): string =>
10
+ new URL(href).searchParams.get("title") ?? "";
11
+
12
+ describe("bannerWayOut — no failure banner is a dead end", () => {
13
+ it("offers a prefilled report on a failure the user cannot act on", () => {
14
+ // The spam report that failed on test.remit.email offered nothing but
15
+ // Dismiss: no reason, no fix, no way to report it.
16
+ const action = bannerWayOut({
17
+ title: "Couldn't report this message as spam",
18
+ detail: "This message could not be processed.",
19
+ error: new ApiError("This message could not be processed.", 422),
20
+ });
21
+
22
+ assert.equal(action?.label, "Report an issue");
23
+ assert.ok(
24
+ action?.href.startsWith(
25
+ "https://github.com/remit-mail/reader/issues/new?",
26
+ ),
27
+ );
28
+ assert.match(
29
+ issueTitle(action.href),
30
+ /Couldn't report this message as spam/,
31
+ );
32
+ assert.match(
33
+ issueBody(action.href),
34
+ /Couldn't report this message as spam/,
35
+ );
36
+ });
37
+
38
+ it("carries the stack of the error that caused the banner", () => {
39
+ const error = new Error("Connection reset by peer");
40
+ const action = bannerWayOut({
41
+ title: "Couldn't move this message",
42
+ error,
43
+ });
44
+
45
+ assert.ok(action !== undefined);
46
+ assert.match(issueBody(action.href), /Stacktrace/);
47
+ });
48
+
49
+ it("keeps an action the call site already chose", () => {
50
+ const action = bannerWayOut({
51
+ title: "Spellcheck stopped",
52
+ action: { label: "Report this", href: "https://example.invalid/report" },
53
+ });
54
+
55
+ assert.deepEqual(action, {
56
+ label: "Report this",
57
+ href: "https://example.invalid/report",
58
+ });
59
+ });
60
+
61
+ it("offers none on a warning or a statement of fact", () => {
62
+ assert.equal(
63
+ bannerWayOut({ severity: "warning", title: "Draft saved locally" }),
64
+ undefined,
65
+ );
66
+ assert.equal(
67
+ bannerWayOut({ severity: "info", title: "Sync finished" }),
68
+ undefined,
69
+ );
70
+ });
71
+ });
@@ -51,7 +51,7 @@ interface UseReportSpamOptions {
51
51
  * despite a non-zero `failureCount`.
52
52
  */
53
53
  export const GENERIC_SPAM_ACTION_FAILURE =
54
- "This message could not be processed. Please try again.";
54
+ "This message could not be processed. Please try again. If it keeps failing, report it.";
55
55
 
56
56
  /**
57
57
  * The server's designed failure text names the message by embedding its raw
@@ -2,10 +2,18 @@
2
2
  * Build-time constants injected by vite.config.ts via `define`.
3
3
  * __APP_SHA__ is the full git SHA (or "dev" in local builds without git).
4
4
  * __APP_BUILD_TIME__ is an ISO timestamp.
5
+ *
6
+ * Read through `typeof` because not every host that renders this app's
7
+ * components applies the `define` — Storybook's test runner mounts them
8
+ * without it — and a bare reference is a ReferenceError at module scope that
9
+ * takes the importing story down with it. An unknown build is the same
10
+ * "dev" the git-less local build already resolves to.
5
11
  */
6
12
 
7
- export const APP_SHA: string = __APP_SHA__;
8
- export const APP_BUILD_TIME: string = __APP_BUILD_TIME__;
13
+ export const APP_SHA: string =
14
+ typeof __APP_SHA__ === "undefined" ? "dev" : __APP_SHA__;
15
+ export const APP_BUILD_TIME: string =
16
+ typeof __APP_BUILD_TIME__ === "undefined" ? "unknown" : __APP_BUILD_TIME__;
9
17
 
10
18
  /** First 7 characters of the SHA, matching git's default short form. */
11
19
  export const APP_SHORT_SHA: string = APP_SHA.slice(0, 7);