@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.
|
|
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(
|
|
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(
|
|
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
|
|
3
|
-
* not name the same message (#845). Read the `code`, never the
|
|
4
|
-
* server's sentence names a uuid and no remedy, and a
|
|
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
|
|
60
|
-
:
|
|
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
|
-
:
|
|
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
|