@remit/web-client 0.0.227 → 0.0.228

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.227",
3
+ "version": "0.0.228",
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": {
@@ -95,6 +95,35 @@ export const WithActionLink: Story = {
95
95
  },
96
96
  };
97
97
 
98
+ /**
99
+ * A delete refused because the message is still being moved on the mail server
100
+ * (#845). Transient: the same press works once the move confirms, so the copy
101
+ * says so rather than sending the user off to report a bug.
102
+ */
103
+ export const PlacementInFlight: Story = {
104
+ args: {
105
+ severity: "warning",
106
+ title: "Couldn't delete this message yet",
107
+ detail:
108
+ "It is still being moved on the mail server. Try again in a moment.",
109
+ action: { label: "Report an issue", href: REPORT_URL },
110
+ },
111
+ };
112
+
113
+ /**
114
+ * The same refusal after the move gave up without confirming. Waiting will
115
+ * never clear this one, so the remedy is a resync instead.
116
+ */
117
+ export const PlacementUnverified: Story = {
118
+ args: {
119
+ severity: "warning",
120
+ title: "Couldn't delete 4 messages yet",
121
+ detail:
122
+ "An earlier move never finished, so where this message sits is unknown. Sync the folder, then delete it again.",
123
+ action: { label: "Report an issue", href: REPORT_URL },
124
+ },
125
+ };
126
+
98
127
  /** The action link on the dark theme. */
99
128
  export const WithActionLinkDark: Story = {
100
129
  name: "With Action Link (dark)",
@@ -0,0 +1,72 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ isPlacementRefusal,
5
+ placementRefusalBanner,
6
+ } from "./placement-refusal.js";
7
+
8
+ const refusal = (reason: string) => ({
9
+ message: "Message 4f1 was not acted on: its folder and uid do not name...",
10
+ code: "message_placement_unsettled",
11
+ details: { accountId: "acc-1", messageId: "msg-1", reason },
12
+ });
13
+
14
+ describe("isPlacementRefusal", () => {
15
+ it("reads the reason and the message off the coded body", () => {
16
+ assert.deepEqual(isPlacementRefusal(refusal("in_flight")), {
17
+ reason: "in_flight",
18
+ messageId: "msg-1",
19
+ });
20
+ });
21
+
22
+ it("ignores every other failure", () => {
23
+ assert.equal(isPlacementRefusal(undefined), undefined);
24
+ assert.equal(isPlacementRefusal(new Error("network")), undefined);
25
+ assert.equal(
26
+ isPlacementRefusal({ ...refusal("in_flight"), code: "other" }),
27
+ undefined,
28
+ );
29
+ assert.equal(isPlacementRefusal(refusal("something_else")), undefined);
30
+ });
31
+
32
+ it("does not match on the message text alone", () => {
33
+ assert.equal(
34
+ isPlacementRefusal(new Error("an earlier move has not settled")),
35
+ undefined,
36
+ );
37
+ });
38
+ });
39
+
40
+ describe("placementRefusalBanner", () => {
41
+ it("tells an in-flight move to wait", () => {
42
+ const banner = placementRefusalBanner(
43
+ { reason: "in_flight", messageId: "msg-1" },
44
+ 1,
45
+ );
46
+ assert.equal(banner.severity, "warning");
47
+ assert.match(banner.detail ?? "", /try again in a moment/i);
48
+ });
49
+
50
+ it("tells an abandoned move to resync, not to wait", () => {
51
+ const banner = placementRefusalBanner(
52
+ { reason: "unverified", messageId: "msg-1" },
53
+ 1,
54
+ );
55
+ assert.match(banner.detail ?? "", /sync the folder/i);
56
+ assert.doesNotMatch(banner.detail ?? "", /try again in a moment/i);
57
+ });
58
+
59
+ it("counts the selection in the title", () => {
60
+ assert.match(
61
+ placementRefusalBanner({ reason: "in_flight", messageId: "m" }, 4).title,
62
+ /4 messages/,
63
+ );
64
+ });
65
+
66
+ it("never repeats the server's uuid at the user", () => {
67
+ for (const reason of ["in_flight", "unverified"] as const) {
68
+ const banner = placementRefusalBanner({ reason, messageId: "msg-1" }, 1);
69
+ assert.doesNotMatch(`${banner.title} ${banner.detail}`, /msg-1/);
70
+ }
71
+ });
72
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The coded 409 a delete is refused with when the message's folder and uid do
3
+ * not name the same message (#845). Read the `code`, never the message: the
4
+ * server's sentence names a uuid and no remedy, and a message-string match
5
+ * would start firing on an unrelated conflict the moment the copy changes.
6
+ */
7
+ import type { ApiError } from "@remit/api-http-client/types.gen.ts";
8
+ import type { PushErrorInput } from "@/components/ui/error-banners";
9
+
10
+ /**
11
+ * `in_flight` clears on its own once the mail server confirms the move;
12
+ * `unverified` does not, because the move gave up without confirming and
13
+ * nothing routine repairs the row.
14
+ */
15
+ export type PlacementRefusalReason = "in_flight" | "unverified";
16
+
17
+ export interface PlacementRefusal {
18
+ reason: PlacementRefusalReason;
19
+ messageId: string;
20
+ }
21
+
22
+ const REASONS: ReadonlySet<string> = new Set<PlacementRefusalReason>([
23
+ "in_flight",
24
+ "unverified",
25
+ ]);
26
+
27
+ const stringAt = (
28
+ details: ApiError["details"],
29
+ key: string,
30
+ ): string | undefined => {
31
+ const value = details?.[key];
32
+ return typeof value === "string" ? value : undefined;
33
+ };
34
+
35
+ export const isPlacementRefusal = (
36
+ error: unknown,
37
+ ): PlacementRefusal | undefined => {
38
+ if (typeof error !== "object" || error === null) return undefined;
39
+ const body = error as Partial<ApiError>;
40
+ if (body.code !== "message_placement_unsettled") return undefined;
41
+ const { details } = body;
42
+ if (typeof details !== "object" || details === null) return undefined;
43
+ const reason = stringAt(details, "reason");
44
+ const messageId = stringAt(details, "messageId");
45
+ if (!reason || !REASONS.has(reason) || !messageId) return undefined;
46
+ return { reason: reason as PlacementRefusalReason, messageId };
47
+ };
48
+
49
+ /**
50
+ * The banner copy. Both reasons say what happened and what to do; neither is a
51
+ * dead end, and neither repeats the server's uuid at the user.
52
+ */
53
+ export const placementRefusalBanner = (
54
+ refusal: PlacementRefusal,
55
+ count: number,
56
+ ): PushErrorInput => ({
57
+ severity: "warning",
58
+ title:
59
+ count > 1
60
+ ? `Couldn't delete ${count} messages yet`
61
+ : "Couldn't delete this message yet",
62
+ detail:
63
+ refusal.reason === "in_flight"
64
+ ? "It is still being moved on the mail server. Try again in a moment."
65
+ : "An earlier move never finished, so where this message sits is unknown. Sync the folder, then delete it again.",
66
+ });
@@ -10,6 +10,10 @@ import { useRoleAppointmentPrompt } from "@/components/mail/RoleAppointmentPromp
10
10
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
11
11
  import { formatErrorDetail } from "@/components/ui/error-banners";
12
12
  import { isFolderRoleRefusal } from "@/components/ui/folder-role-refusal";
13
+ import {
14
+ isPlacementRefusal,
15
+ placementRefusalBanner,
16
+ } from "@/components/ui/placement-refusal";
13
17
  import { resolveMailboxesForMessages } from "@/hooks/useMarkAsRead";
14
18
  import { runChunkedMutation } from "@/lib/bulk-actions";
15
19
  import {
@@ -175,6 +179,11 @@ export const useDeleteMessages = ({
175
179
  return;
176
180
  }
177
181
  const count = vars.body.messageIds?.length ?? 0;
182
+ const placement = isPlacementRefusal(err);
183
+ if (placement) {
184
+ pushError(placementRefusalBanner(placement, count));
185
+ return;
186
+ }
178
187
  pushError({
179
188
  title:
180
189
  count > 1