@remit/web-client 0.0.268 → 0.0.269

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.268",
3
+ "version": "0.0.269",
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": {
@@ -50,6 +50,7 @@ describe("placementRefusalBanner", () => {
50
50
  const banner = placementRefusalBanner(
51
51
  { reason: "in_flight", messageId: "msg-1" },
52
52
  1,
53
+ "delete",
53
54
  );
54
55
  assert.equal(banner.severity, "warning");
55
56
  assert.match(banner.detail ?? "", /try again in a moment/i);
@@ -59,6 +60,7 @@ describe("placementRefusalBanner", () => {
59
60
  const banner = placementRefusalBanner(
60
61
  { reason: "unverified", messageId: "msg-1" },
61
62
  1,
63
+ "delete",
62
64
  );
63
65
  assert.match(banner.detail ?? "", /sync the folder/i);
64
66
  assert.doesNotMatch(banner.detail ?? "", /try again in a moment/i);
@@ -66,14 +68,43 @@ describe("placementRefusalBanner", () => {
66
68
 
67
69
  it("counts the selection in the title", () => {
68
70
  assert.match(
69
- placementRefusalBanner({ reason: "in_flight", messageId: "m" }, 4).title,
71
+ placementRefusalBanner(
72
+ { reason: "in_flight", messageId: "m" },
73
+ 4,
74
+ "delete",
75
+ ).title,
70
76
  /4 messages/,
71
77
  );
72
78
  });
73
79
 
80
+ // A move refused mid-flight told the user their delete failed — an action
81
+ // they never took, and a remedy pointing at the wrong verb.
82
+ it("names the action the user pressed, not the delete", () => {
83
+ const title = placementRefusalBanner(
84
+ { reason: "in_flight", messageId: "msg-1" },
85
+ 1,
86
+ "move",
87
+ ).title;
88
+ assert.match(title, /move this message/i);
89
+ assert.doesNotMatch(title, /delete/i);
90
+
91
+ const detail =
92
+ placementRefusalBanner(
93
+ { reason: "unverified", messageId: "msg-1" },
94
+ 1,
95
+ "move",
96
+ ).detail ?? "";
97
+ assert.match(detail, /move it again/i);
98
+ assert.doesNotMatch(detail, /delete/i);
99
+ });
100
+
74
101
  it("never repeats the server's uuid at the user", () => {
75
102
  for (const reason of ["in_flight", "unverified"] as const) {
76
- const banner = placementRefusalBanner({ reason, messageId: "msg-1" }, 1);
103
+ const banner = placementRefusalBanner(
104
+ { reason, messageId: "msg-1" },
105
+ 1,
106
+ "delete",
107
+ );
77
108
  assert.doesNotMatch(`${banner.title} ${banner.detail}`, /msg-1/);
78
109
  }
79
110
  });
@@ -1,7 +1,8 @@
1
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
2
+ * The coded 409 a delete or a move is refused with when the message's folder
3
+ * and uid do not name the same message (#845). Read the `code`, never the
4
+ * message: the server's sentence names a uuid and no remedy, and a
5
+ * message-string match
5
6
  * would start firing on an unrelated conflict the moment the copy changes.
6
7
  */
7
8
  import type { PushErrorInput } from "@/components/ui/error-banners";
@@ -45,6 +46,12 @@ export const isPlacementRefusal = (
45
46
  return { reason: reason as PlacementRefusalReason, messageId };
46
47
  };
47
48
 
49
+ /**
50
+ * What the user pressed. The same refusal answers a delete and a move, and the
51
+ * banner names the action they took rather than one they never asked for.
52
+ */
53
+ export type PlacementRefusalAction = "delete" | "move";
54
+
48
55
  /**
49
56
  * The banner copy. Both reasons say what happened and what to do; neither is a
50
57
  * dead end, and neither repeats the server's uuid at the user.
@@ -52,14 +59,15 @@ export const isPlacementRefusal = (
52
59
  export const placementRefusalBanner = (
53
60
  refusal: PlacementRefusal,
54
61
  count: number,
62
+ action: PlacementRefusalAction,
55
63
  ): PushErrorInput => ({
56
64
  severity: "warning",
57
65
  title:
58
66
  count > 1
59
- ? `Couldn't delete ${count} messages yet`
60
- : "Couldn't delete this message yet",
67
+ ? `Couldn't ${action} ${count} messages yet`
68
+ : `Couldn't ${action} this message yet`,
61
69
  detail:
62
70
  refusal.reason === "in_flight"
63
71
  ? "It is still being moved on the mail server. Try again in a moment."
64
- : "An earlier move never finished, so where this message sits is unknown. Sync the folder, then delete it again.",
72
+ : `An earlier move never finished, so where this message sits is unknown. Sync the folder, then ${action} it again.`,
65
73
  });
@@ -32,15 +32,20 @@ import { createDomHarness, type DomHarness } from "../test-support/dom";
32
32
  import { type HttpMock, mockFetch } from "../test-support/http";
33
33
  import { useDeleteMessages } from "./useDeleteMessages";
34
34
  import { type EmptyTrashState, useEmptyTrash } from "./useEmptyTrash";
35
+ import { useMoveMessages } from "./useMoveMessages";
35
36
  import "@/lib/client";
36
37
 
37
38
  const ACCOUNT = "acc-1";
38
39
  const INBOX = "mbx-inbox";
39
40
  const TRASH = "mbx-trash";
41
+ const ARCHIVE = "mbx-archive";
40
42
 
41
43
  let harness: DomHarness | undefined;
42
44
  let http: HttpMock | undefined;
43
45
  let deleteMessages: ((messageIds: string[]) => void) | undefined;
46
+ let moveMessages:
47
+ | ((messageIds: string[], destinationMailboxId: string) => void)
48
+ | undefined;
44
49
  let trash: EmptyTrashState | undefined;
45
50
 
46
51
  afterEach(() => {
@@ -49,6 +54,7 @@ afterEach(() => {
49
54
  http?.restore();
50
55
  http = undefined;
51
56
  deleteMessages = undefined;
57
+ moveMessages = undefined;
52
58
  trash = undefined;
53
59
  __resetFatalError();
54
60
  });
@@ -77,6 +83,19 @@ const refusal = (): Response =>
77
83
  { status: 409, headers: { "content-type": "application/json" } },
78
84
  );
79
85
 
86
+ /** The move gate's own coded 409: the row's folder and uid disagree (#665). */
87
+ const unsettled = (): Response =>
88
+ new Response(
89
+ JSON.stringify({
90
+ status: 409,
91
+ message:
92
+ "Message msg-1 was not acted on: its folder and uid do not name the same message",
93
+ code: "message_placement_unsettled",
94
+ details: { accountId: ACCOUNT, messageId: "msg-1", reason: "in_flight" },
95
+ }),
96
+ { status: 409, headers: { "content-type": "application/json" } },
97
+ );
98
+
80
99
  const broken = (): Response =>
81
100
  new Response(JSON.stringify({ message: "boom" }), {
82
101
  status: 500,
@@ -88,6 +107,10 @@ const Probe = () => {
88
107
  mailboxId: INBOX,
89
108
  accountId: ACCOUNT,
90
109
  }).deleteMessages;
110
+ moveMessages = useMoveMessages({
111
+ mailboxId: INBOX,
112
+ accountId: ACCOUNT,
113
+ }).moveMessages;
91
114
  trash = useEmptyTrash({ accountId: ACCOUNT, mailboxId: TRASH });
92
115
  return null;
93
116
  };
@@ -172,6 +195,39 @@ describe("a delete refused for its folder role", () => {
172
195
  });
173
196
  });
174
197
 
198
+ describe("a move refused for an unsettled placement", () => {
199
+ it("states the wait in a banner without taking the screen", async () => {
200
+ await mount(responder("/messages/move", unsettled));
201
+
202
+ await act(async () => {
203
+ moveMessages?.(["msg-1"], ARCHIVE);
204
+ });
205
+ await settle();
206
+
207
+ assert.equal(
208
+ getCurrentFatalError(),
209
+ null,
210
+ "a refusal the banner answers is not a fatal error",
211
+ );
212
+ assert.match(harness?.text() ?? "", /Couldn't move this message yet/);
213
+ assert.match(harness?.text() ?? "", /Try again in a moment/);
214
+ });
215
+
216
+ it("still escalates a 500 on the same move", async () => {
217
+ await mount(responder("/messages/move", broken));
218
+
219
+ await act(async () => {
220
+ moveMessages?.(["msg-1"], ARCHIVE);
221
+ });
222
+ await settle();
223
+
224
+ assert.ok(
225
+ getCurrentFatalError(),
226
+ "the 409 opt-out must not cover anything else",
227
+ );
228
+ });
229
+ });
230
+
175
231
  describe("an empty Trash refused for its folder role", () => {
176
232
  it("states the refusal in the pane without taking the screen", async () => {
177
233
  await mount(responder("/trash/empty", refusal));
@@ -186,7 +186,7 @@ export const useDeleteMessages = ({
186
186
  const count = vars.body.messageIds?.length ?? 0;
187
187
  const placement = isPlacementRefusal(err);
188
188
  if (placement) {
189
- pushError(placementRefusalBanner(placement, count));
189
+ pushError(placementRefusalBanner(placement, count, "delete"));
190
190
  return;
191
191
  }
192
192
  pushError({
@@ -8,7 +8,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
8
8
  import { useCallback } from "react";
9
9
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
10
10
  import { formatErrorDetail } from "@/components/ui/error-banners";
11
+ import {
12
+ isPlacementRefusal,
13
+ placementRefusalBanner,
14
+ } from "@/components/ui/placement-refusal";
11
15
  import { runChunkedMutation } from "@/lib/bulk-actions";
16
+ import { softErrorStatuses } from "@/lib/error-classifier";
12
17
  import {
13
18
  cancelThreadListQueries,
14
19
  invalidateThreadListQueries,
@@ -57,6 +62,10 @@ export const useMoveMessages = ({
57
62
 
58
63
  const { mutateAsync, isPending, isError } = useMutation({
59
64
  ...messageBulkOperationsMoveMessagesMutation(),
65
+ // A coded 409 is answered here — a banner — so it must not also take the
66
+ // whole screen. Only 409: a 401, 403 or 5xx on a move is still the fatal
67
+ // page's (#1059).
68
+ meta: softErrorStatuses(409),
60
69
  onMutate: async (variables): Promise<ThreadMutationContext> => {
61
70
  const messageIds = new Set(variables.body.messageIds ?? []);
62
71
 
@@ -136,6 +145,13 @@ export const useMoveMessages = ({
136
145
  restoreThreadListQueries(queryClient, context.previousThreadsList);
137
146
  }
138
147
  const count = vars.body.messageIds?.length ?? 0;
148
+ // A placement refusal is a wait or a resync, not a failure to report as
149
+ // one: the move was never made, and the banner says which.
150
+ const placement = isPlacementRefusal(err);
151
+ if (placement) {
152
+ pushError(placementRefusalBanner(placement, count, "move"));
153
+ return;
154
+ }
139
155
  pushError({
140
156
  title:
141
157
  count > 1