@remit/web-client 0.0.94 → 0.0.96
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 +1 -1
- package/src/components/mail/BriefPane.selection.test.ts +0 -1
- package/src/components/mail/BriefPane.tsx +7 -17
- package/src/components/mail/DailyBrief.selection.test.ts +21 -3
- package/src/components/mail/DailyBrief.tsx +17 -27
- package/src/components/mail/FlaggedList.tsx +24 -19
- package/src/components/mail/FlaggedPane.tsx +9 -23
- package/src/components/mail/MailboxPane.tsx +24 -11
- package/src/components/mail/MessageList.selection.test.ts +44 -30
- package/src/components/mail/MessageList.tsx +117 -339
- package/src/components/mail/SelectionWizardHost.tsx +187 -39
- package/src/components/mail/ThreadListInteraction.test.ts +74 -4
- package/src/components/mail/ThreadListInteraction.tsx +58 -50
- package/src/components/mail/selection-verbs.test.ts +278 -0
- package/src/components/ui/ConfirmDialog.stories.tsx +0 -15
- package/src/hooks/useEscalatedActions.ts +2 -2
- package/src/hooks/useInitialSyncProgress.render.test.ts +112 -85
- package/src/hooks/useMatchSample.ts +44 -3
- package/src/lib/bulk-action-copy.test.ts +0 -19
- package/src/lib/bulk-action-copy.ts +0 -8
- package/src/lib/bulk-actions.test.ts +0 -60
- package/src/lib/bulk-actions.ts +0 -26
- package/src/lib/format.test.ts +0 -27
- package/src/lib/format.ts +4 -14
- package/src/lib/organize/organize-model.ts +8 -2
- package/src/lib/wizard-history.ts +42 -2
|
@@ -26,12 +26,15 @@ import {
|
|
|
26
26
|
|
|
27
27
|
let harness: DomHarness | undefined;
|
|
28
28
|
let http: HttpMock | undefined;
|
|
29
|
+
let restoreFetch: (() => void) | undefined;
|
|
29
30
|
|
|
30
31
|
afterEach(() => {
|
|
31
32
|
harness?.close();
|
|
32
33
|
harness = undefined;
|
|
33
34
|
http?.restore();
|
|
34
35
|
http = undefined;
|
|
36
|
+
restoreFetch?.();
|
|
37
|
+
restoreFetch = undefined;
|
|
35
38
|
});
|
|
36
39
|
|
|
37
40
|
interface MailboxProgress {
|
|
@@ -72,22 +75,83 @@ const accountOf = (path: string): string => path.split("/")[2] ?? "";
|
|
|
72
75
|
const progress = (dom: DomHarness): InitialSyncProgress =>
|
|
73
76
|
JSON.parse(dom.text());
|
|
74
77
|
|
|
75
|
-
const
|
|
78
|
+
const UNKNOWN: InitialSyncProgress = {
|
|
79
|
+
syncing: false,
|
|
80
|
+
resolved: false,
|
|
81
|
+
synced: 0,
|
|
82
|
+
total: 0,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** How long the hook gets to answer a request that is already in hand. */
|
|
86
|
+
const RESOLVE_CEILING_MS = 2000;
|
|
87
|
+
/** How long the hook is watched for an answer, or a request, it must not give. */
|
|
88
|
+
const QUIET_WINDOW_MS = 500;
|
|
89
|
+
const STEP_MS = 10;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Let real time pass until the hook itself says `done`, or the ceiling runs out.
|
|
93
|
+
*
|
|
94
|
+
* Every wait in this file is a question put to the hook rather than a count of
|
|
95
|
+
* turns or a sleep the answer is assumed to fit inside: the poll and the answer
|
|
96
|
+
* deadline run on real timers, so a loaded machine changes how much work lands
|
|
97
|
+
* per turn, and the ceiling is what keeps a hook that never answers a failure
|
|
98
|
+
* instead of a hang.
|
|
99
|
+
*/
|
|
100
|
+
const settleUntil = async (
|
|
101
|
+
dom: DomHarness,
|
|
102
|
+
done: () => boolean,
|
|
103
|
+
ceilingMs: number,
|
|
104
|
+
): Promise<void> => {
|
|
105
|
+
const deadline = Date.now() + ceilingMs;
|
|
106
|
+
for (;;) {
|
|
107
|
+
await dom.flush();
|
|
108
|
+
if (done() || Date.now() >= deadline) return;
|
|
109
|
+
await dom.wait(STEP_MS);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const mount = (
|
|
76
114
|
accountIds: string[],
|
|
77
115
|
answer: (accountId: string) => unknown,
|
|
78
116
|
enabled = true,
|
|
79
|
-
):
|
|
117
|
+
): DomHarness => {
|
|
80
118
|
http = mockFetch((call) => answer(accountOf(call.path)));
|
|
81
119
|
harness = createDomHarness();
|
|
82
120
|
harness.renderApp(createElement(Probe, { accountIds, enabled }));
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
121
|
+
return harness;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const mountResolved = async (
|
|
125
|
+
accountIds: string[],
|
|
126
|
+
answer: (accountId: string) => unknown,
|
|
127
|
+
): Promise<DomHarness> => {
|
|
128
|
+
const dom = mount(accountIds, answer);
|
|
129
|
+
await settleUntil(dom, () => progress(dom).resolved, RESOLVE_CEILING_MS);
|
|
130
|
+
return dom;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** `acc-2` never answers at all: its request is left hanging for good. */
|
|
134
|
+
const mountSilentAccount = (
|
|
135
|
+
answerOfAccountOne: RemitImapAccountSyncStatusResponse,
|
|
136
|
+
): DomHarness => {
|
|
137
|
+
const original = globalThis.fetch;
|
|
138
|
+
restoreFetch = () => {
|
|
139
|
+
globalThis.fetch = original;
|
|
140
|
+
};
|
|
141
|
+
globalThis.fetch = ((input: RequestInfo | URL) => {
|
|
142
|
+
const url = input instanceof Request ? input.url : String(input);
|
|
143
|
+
if (url.includes("acc-2")) return new Promise<Response>(() => {});
|
|
144
|
+
return Promise.resolve(
|
|
145
|
+
new Response(JSON.stringify(answerOfAccountOne), {
|
|
146
|
+
status: 200,
|
|
147
|
+
headers: { "content-type": "application/json" },
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
}) as typeof globalThis.fetch;
|
|
151
|
+
harness = createDomHarness();
|
|
152
|
+
harness.renderApp(
|
|
153
|
+
createElement(Probe, { accountIds: ["acc-1", "acc-2"], enabled: true }),
|
|
154
|
+
);
|
|
91
155
|
return harness;
|
|
92
156
|
};
|
|
93
157
|
|
|
@@ -112,7 +176,7 @@ describe("isSyncingPhase", () => {
|
|
|
112
176
|
|
|
113
177
|
describe("useInitialSyncProgress", () => {
|
|
114
178
|
it("reports syncing with the counts of the accounts still syncing", async () => {
|
|
115
|
-
const dom = await
|
|
179
|
+
const dom = await mountResolved(["acc-1"], (accountId) =>
|
|
116
180
|
status(accountId, "syncing_inbox", [
|
|
117
181
|
{ synced: 40, total: 100 },
|
|
118
182
|
{ synced: 10, total: 60 },
|
|
@@ -127,7 +191,7 @@ describe("useInitialSyncProgress", () => {
|
|
|
127
191
|
});
|
|
128
192
|
|
|
129
193
|
it("leaves out the counts of an account that has finished", async () => {
|
|
130
|
-
const dom = await
|
|
194
|
+
const dom = await mountResolved(["acc-1", "acc-2"], (accountId) =>
|
|
131
195
|
accountId === "acc-1"
|
|
132
196
|
? status(accountId, "syncing_inbox", [{ synced: 40, total: 100 }])
|
|
133
197
|
: status(accountId, "complete", [{ synced: 900, total: 900 }]),
|
|
@@ -141,7 +205,7 @@ describe("useInitialSyncProgress", () => {
|
|
|
141
205
|
});
|
|
142
206
|
|
|
143
207
|
it("resolves to not-syncing once every account is done", async () => {
|
|
144
|
-
const dom = await
|
|
208
|
+
const dom = await mountResolved(["acc-1", "acc-2"], (accountId) =>
|
|
145
209
|
status(accountId, "complete", [{ synced: 900, total: 900 }]),
|
|
146
210
|
);
|
|
147
211
|
assert.deepEqual(progress(dom), {
|
|
@@ -153,7 +217,7 @@ describe("useInitialSyncProgress", () => {
|
|
|
153
217
|
});
|
|
154
218
|
|
|
155
219
|
it("counts an unreachable account as answered rather than holding the list in limbo", async () => {
|
|
156
|
-
const dom = await
|
|
220
|
+
const dom = await mountResolved(["acc-1", "acc-2"], (accountId) =>
|
|
157
221
|
accountId === "acc-1"
|
|
158
222
|
? status(accountId, "syncing_inbox", [{ synced: 5, total: 50 }])
|
|
159
223
|
: httpError(503),
|
|
@@ -167,39 +231,17 @@ describe("useInitialSyncProgress", () => {
|
|
|
167
231
|
it("knows nothing until every account has answered", async () => {
|
|
168
232
|
// One account answers, the other never does — the hook must not report on
|
|
169
233
|
// the half it has.
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
JSON.stringify(
|
|
178
|
-
status("acc-1", "syncing_inbox", [{ synced: 5, total: 50 }]),
|
|
179
|
-
),
|
|
180
|
-
{ status: 200, headers: { "content-type": "application/json" } },
|
|
181
|
-
),
|
|
182
|
-
);
|
|
183
|
-
}) as typeof globalThis.fetch;
|
|
184
|
-
harness = createDomHarness();
|
|
185
|
-
harness.renderApp(
|
|
186
|
-
createElement(Probe, { accountIds: ["acc-1", "acc-2"], enabled: true }),
|
|
187
|
-
);
|
|
188
|
-
await harness.flush();
|
|
189
|
-
await harness.flush();
|
|
190
|
-
assert.deepEqual(progress(harness), {
|
|
191
|
-
syncing: false,
|
|
192
|
-
resolved: false,
|
|
193
|
-
synced: 0,
|
|
194
|
-
total: 0,
|
|
195
|
-
});
|
|
196
|
-
} finally {
|
|
197
|
-
globalThis.fetch = original;
|
|
198
|
-
}
|
|
234
|
+
const dom = mountSilentAccount(
|
|
235
|
+
status("acc-1", "syncing_inbox", [{ synced: 5, total: 50 }]),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
await settleUntil(dom, () => progress(dom).resolved, QUIET_WINDOW_MS);
|
|
239
|
+
|
|
240
|
+
assert.deepEqual(progress(dom), UNKNOWN);
|
|
199
241
|
});
|
|
200
242
|
|
|
201
243
|
it("answers immediately, and never syncing, for no accounts at all", async () => {
|
|
202
|
-
const dom = await
|
|
244
|
+
const dom = await mountResolved([], () => ({}));
|
|
203
245
|
assert.deepEqual(progress(dom), {
|
|
204
246
|
syncing: false,
|
|
205
247
|
resolved: true,
|
|
@@ -211,24 +253,28 @@ describe("useInitialSyncProgress", () => {
|
|
|
211
253
|
|
|
212
254
|
it("stops asking once every account has answered and none is syncing", async () => {
|
|
213
255
|
// The caught-up user's answer cannot change by being asked again.
|
|
214
|
-
const dom = await
|
|
256
|
+
const dom = await mountResolved(["acc-1", "acc-2"], (accountId) =>
|
|
215
257
|
status(accountId, "complete"),
|
|
216
258
|
);
|
|
217
259
|
const asked = http?.calls.length ?? 0;
|
|
218
260
|
assert.equal(asked, 2);
|
|
219
261
|
|
|
220
|
-
await
|
|
262
|
+
await settleUntil(
|
|
263
|
+
dom,
|
|
264
|
+
() => (http?.calls.length ?? 0) > asked,
|
|
265
|
+
POLL_MS + QUIET_WINDOW_MS,
|
|
266
|
+
);
|
|
221
267
|
|
|
222
268
|
assert.equal(http?.calls.length, asked);
|
|
223
269
|
assert.equal(progress(dom).resolved, true);
|
|
224
270
|
});
|
|
225
271
|
|
|
226
272
|
it("keeps asking while an account is still syncing", async () => {
|
|
227
|
-
const dom = await
|
|
273
|
+
const dom = await mountResolved(["acc-1"], (accountId) =>
|
|
228
274
|
status(accountId, "syncing_inbox", [{ synced: 5, total: 50 }]),
|
|
229
275
|
);
|
|
230
276
|
|
|
231
|
-
await dom.
|
|
277
|
+
await settleUntil(dom, () => (http?.calls.length ?? 0) > 1, POLL_MS * 3);
|
|
232
278
|
|
|
233
279
|
assert.ok(
|
|
234
280
|
(http?.calls.length ?? 0) > 1,
|
|
@@ -237,50 +283,31 @@ describe("useInitialSyncProgress", () => {
|
|
|
237
283
|
});
|
|
238
284
|
|
|
239
285
|
it("answers without an account that never responds", async () => {
|
|
240
|
-
const
|
|
241
|
-
try {
|
|
242
|
-
globalThis.fetch = ((input: RequestInfo | URL) => {
|
|
243
|
-
const url = input instanceof Request ? input.url : String(input);
|
|
244
|
-
if (url.includes("acc-2")) return new Promise<Response>(() => {});
|
|
245
|
-
return Promise.resolve(
|
|
246
|
-
new Response(JSON.stringify(status("acc-1", "complete")), {
|
|
247
|
-
status: 200,
|
|
248
|
-
headers: { "content-type": "application/json" },
|
|
249
|
-
}),
|
|
250
|
-
);
|
|
251
|
-
}) as typeof globalThis.fetch;
|
|
252
|
-
harness = createDomHarness();
|
|
253
|
-
harness.renderApp(
|
|
254
|
-
createElement(Probe, { accountIds: ["acc-1", "acc-2"], enabled: true }),
|
|
255
|
-
);
|
|
256
|
-
await harness.flush();
|
|
257
|
-
assert.equal(progress(harness).resolved, false);
|
|
258
|
-
|
|
259
|
-
await harness.wait(ANSWER_DEADLINE_MS + 500);
|
|
260
|
-
|
|
261
|
-
assert.deepEqual(progress(harness), {
|
|
262
|
-
syncing: false,
|
|
263
|
-
resolved: true,
|
|
264
|
-
synced: 0,
|
|
265
|
-
total: 0,
|
|
266
|
-
});
|
|
267
|
-
} finally {
|
|
268
|
-
globalThis.fetch = original;
|
|
269
|
-
}
|
|
270
|
-
});
|
|
286
|
+
const dom = mountSilentAccount(status("acc-1", "complete"));
|
|
271
287
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
288
|
+
await settleUntil(dom, () => progress(dom).resolved, QUIET_WINDOW_MS);
|
|
289
|
+
assert.equal(progress(dom).resolved, false);
|
|
290
|
+
|
|
291
|
+
await settleUntil(
|
|
292
|
+
dom,
|
|
293
|
+
() => progress(dom).resolved,
|
|
294
|
+
ANSWER_DEADLINE_MS * 2,
|
|
277
295
|
);
|
|
296
|
+
|
|
278
297
|
assert.deepEqual(progress(dom), {
|
|
279
298
|
syncing: false,
|
|
280
|
-
resolved:
|
|
299
|
+
resolved: true,
|
|
281
300
|
synced: 0,
|
|
282
301
|
total: 0,
|
|
283
302
|
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("asks nothing and claims nothing while disabled", async () => {
|
|
306
|
+
const dom = mount(["acc-1"], () => status("acc-1", "syncing_inbox"), false);
|
|
307
|
+
|
|
308
|
+
await settleUntil(dom, () => progress(dom).resolved, QUIET_WINDOW_MS);
|
|
309
|
+
|
|
310
|
+
assert.deepEqual(progress(dom), UNKNOWN);
|
|
284
311
|
assert.deepEqual(http?.calls ?? [], []);
|
|
285
312
|
});
|
|
286
313
|
});
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
messageOperationsDescribeMessageOptions,
|
|
3
|
+
threadOperationsSearchThreadsOptions,
|
|
4
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
5
|
+
import type {
|
|
6
|
+
RemitImapDescribeMessageResponse,
|
|
7
|
+
RemitImapThreadMessageResponse,
|
|
8
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
3
9
|
import { senderLabel, type WizardMessage } from "@remit/ui";
|
|
4
|
-
import { useQueries } from "@tanstack/react-query";
|
|
10
|
+
import { useQueries, useQuery } from "@tanstack/react-query";
|
|
11
|
+
import type { EscalationSearchQuery } from "@/hooks/useEscalatedActions";
|
|
5
12
|
import { formatEmailDate } from "@/lib/format";
|
|
6
13
|
|
|
7
14
|
/**
|
|
@@ -44,6 +51,40 @@ const toWizardMessage = (
|
|
|
44
51
|
};
|
|
45
52
|
};
|
|
46
53
|
|
|
54
|
+
/**
|
|
55
|
+
* The members of an escalated predicate's match (#508). The predicate is the
|
|
56
|
+
* search the list is showing, so the search endpoint that resolved it is what
|
|
57
|
+
* names its members — one bounded request rather than the browser's own loaded
|
|
58
|
+
* rows, which stop at the page the list happens to have reached.
|
|
59
|
+
*/
|
|
60
|
+
export const useSearchMatchSample = (
|
|
61
|
+
mailboxId: string | undefined,
|
|
62
|
+
query: EscalationSearchQuery | undefined,
|
|
63
|
+
): MatchSample => {
|
|
64
|
+
const enabled = mailboxId !== undefined && query !== undefined;
|
|
65
|
+
const { data, isLoading } = useQuery({
|
|
66
|
+
...threadOperationsSearchThreadsOptions({
|
|
67
|
+
path: { mailboxId: mailboxId ?? "" },
|
|
68
|
+
query: { ...query, limit: SAMPLE_LIMIT },
|
|
69
|
+
}),
|
|
70
|
+
enabled,
|
|
71
|
+
staleTime: 30_000,
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
messages: (data?.items ?? []).map(toSearchWizardMessage),
|
|
75
|
+
isPending: enabled && isLoading,
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const toSearchWizardMessage = (
|
|
80
|
+
row: RemitImapThreadMessageResponse,
|
|
81
|
+
): WizardMessage => ({
|
|
82
|
+
id: row.messageId,
|
|
83
|
+
sender: row.fromName || row.fromEmail || "Unknown",
|
|
84
|
+
subject: row.subject ?? "(No subject)",
|
|
85
|
+
date: formatEmailDate(row.sentDate),
|
|
86
|
+
});
|
|
87
|
+
|
|
47
88
|
export const useMatchSample = (messageIds: readonly string[]): MatchSample =>
|
|
48
89
|
useQueries({
|
|
49
90
|
queries: messageIds.slice(0, SAMPLE_LIMIT).map((messageId) => ({
|
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
bulkActionCompletionText,
|
|
6
6
|
bulkActionFailureDetail,
|
|
7
7
|
bulkActionFailureTitle,
|
|
8
|
-
bulkActionPartialText,
|
|
9
8
|
bulkActionProgressLabel,
|
|
10
9
|
bulkActionProgressTone,
|
|
11
10
|
} from "./bulk-action-copy.js";
|
|
@@ -46,23 +45,6 @@ describe("bulkActionCompletionText", () => {
|
|
|
46
45
|
});
|
|
47
46
|
});
|
|
48
47
|
|
|
49
|
-
describe("bulkActionPartialText", () => {
|
|
50
|
-
test("splits what landed from what is still selected", () => {
|
|
51
|
-
assert.equal(
|
|
52
|
-
bulkActionPartialText("delete", 3072, 340),
|
|
53
|
-
"3,072 moved to Trash. 340 couldn't be deleted.",
|
|
54
|
-
);
|
|
55
|
-
assert.equal(
|
|
56
|
-
bulkActionPartialText("move", 3072, 340),
|
|
57
|
-
"3,072 moved. 340 couldn't be moved.",
|
|
58
|
-
);
|
|
59
|
-
assert.equal(
|
|
60
|
-
bulkActionPartialText("markRead", 3072, 340),
|
|
61
|
-
"3,072 marked as read. 340 couldn't be marked as read.",
|
|
62
|
-
);
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
48
|
describe("bulkActionFailureTitle", () => {
|
|
67
49
|
test("reports where a partly-done run stopped", () => {
|
|
68
50
|
assert.equal(
|
|
@@ -87,7 +69,6 @@ describe("every action carries its own wording", () => {
|
|
|
87
69
|
test("no two actions share a sentence", () => {
|
|
88
70
|
const sentences: Array<(kind: BulkActionKind) => string> = [
|
|
89
71
|
(kind) => bulkActionCompletionText(kind, 5),
|
|
90
|
-
(kind) => bulkActionPartialText(kind, 5, 2),
|
|
91
72
|
(kind) => bulkActionFailureTitle(kind, 0),
|
|
92
73
|
(kind) => bulkActionFailureTitle(kind, 5),
|
|
93
74
|
bulkActionFailureDetail,
|
|
@@ -59,14 +59,6 @@ export const bulkActionCompletionText = (
|
|
|
59
59
|
): string =>
|
|
60
60
|
`${formatNumber(done)} ${pastTense[kind]}. Your mail server is still catching up.`;
|
|
61
61
|
|
|
62
|
-
/** Shown when part of a run landed and the rest is still selected for Retry. */
|
|
63
|
-
export const bulkActionPartialText = (
|
|
64
|
-
kind: BulkActionKind,
|
|
65
|
-
succeeded: number,
|
|
66
|
-
remaining: number,
|
|
67
|
-
): string =>
|
|
68
|
-
`${formatNumber(succeeded)} ${pastTense[kind]}. ${formatNumber(remaining)} ${negated[kind]}.`;
|
|
69
|
-
|
|
70
62
|
/** Error-banner title for a run stopped by an infrastructure failure. */
|
|
71
63
|
export const bulkActionFailureTitle = (
|
|
72
64
|
kind: BulkActionKind,
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
countMatches,
|
|
7
7
|
type FetchIdsPageResult,
|
|
8
8
|
honestProgress,
|
|
9
|
-
resolveSelectionAfterRun,
|
|
10
9
|
runChunkedAction,
|
|
11
10
|
runPredicateAction,
|
|
12
11
|
} from "./bulk-actions.js";
|
|
@@ -355,65 +354,6 @@ describe("countMatches", () => {
|
|
|
355
354
|
});
|
|
356
355
|
});
|
|
357
356
|
|
|
358
|
-
describe("resolveSelectionAfterRun", () => {
|
|
359
|
-
test("a clean run with nothing failed exits selection mode", () => {
|
|
360
|
-
assert.deepEqual(
|
|
361
|
-
resolveSelectionAfterRun({
|
|
362
|
-
done: 3412,
|
|
363
|
-
failedIds: [],
|
|
364
|
-
cancelled: false,
|
|
365
|
-
}),
|
|
366
|
-
{ exit: true, retryIds: [] },
|
|
367
|
-
);
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
test("unreached ids stay selected for a precise retry, even alongside a clean stop", () => {
|
|
371
|
-
assert.deepEqual(
|
|
372
|
-
resolveSelectionAfterRun({
|
|
373
|
-
done: 3072,
|
|
374
|
-
failedIds: ["a", "b"],
|
|
375
|
-
cancelled: false,
|
|
376
|
-
}),
|
|
377
|
-
{ exit: false, retryIds: ["a", "b"] },
|
|
378
|
-
);
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
test("a clean cancel with nothing yet confirmed failed leaves nothing to retry, but does not exit", () => {
|
|
382
|
-
assert.deepEqual(
|
|
383
|
-
resolveSelectionAfterRun({
|
|
384
|
-
done: 100,
|
|
385
|
-
failedIds: [],
|
|
386
|
-
cancelled: true,
|
|
387
|
-
}),
|
|
388
|
-
{ exit: false, retryIds: [] },
|
|
389
|
-
);
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
test("an infra failure with nothing left unreached still keeps selection mode open", () => {
|
|
393
|
-
assert.deepEqual(
|
|
394
|
-
resolveSelectionAfterRun({
|
|
395
|
-
done: 0,
|
|
396
|
-
failedIds: [],
|
|
397
|
-
cancelled: false,
|
|
398
|
-
error: new Error("network blip"),
|
|
399
|
-
}),
|
|
400
|
-
{ exit: false, retryIds: [] },
|
|
401
|
-
);
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
test("failedIds wins over cancelled/error when both are present", () => {
|
|
405
|
-
assert.deepEqual(
|
|
406
|
-
resolveSelectionAfterRun({
|
|
407
|
-
done: 10,
|
|
408
|
-
failedIds: ["x"],
|
|
409
|
-
cancelled: true,
|
|
410
|
-
error: new Error("boom"),
|
|
411
|
-
}),
|
|
412
|
-
{ exit: false, retryIds: ["x"] },
|
|
413
|
-
);
|
|
414
|
-
});
|
|
415
|
-
});
|
|
416
|
-
|
|
417
357
|
describe("honestProgress", () => {
|
|
418
358
|
// Regression for #109: `countMatches` and `runPredicateAction` page the
|
|
419
359
|
// same predicate independently, so the delete can outrun the frozen
|
package/src/lib/bulk-actions.ts
CHANGED
|
@@ -275,29 +275,3 @@ export interface BulkRunOutcome {
|
|
|
275
275
|
cancelled: boolean;
|
|
276
276
|
error?: unknown;
|
|
277
277
|
}
|
|
278
|
-
|
|
279
|
-
export interface SelectionAfterRun {
|
|
280
|
-
/** The action reached everything targeted — selection mode should exit. */
|
|
281
|
-
exit: boolean;
|
|
282
|
-
/** The bounded selection to leave in place, empty when exiting. */
|
|
283
|
-
retryIds: string[];
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* What a caller does with selection once a run ends, for any reason. Every id
|
|
288
|
-
* the action never reached — a chunk the bounded run skipped because it was
|
|
289
|
-
* stopped or errored — belongs in `retryIds`: it is exactly what Retry should
|
|
290
|
-
* resend, and it is what stays selected so the count on screen never claims
|
|
291
|
-
* more was done than actually was (#92 requirement 8).
|
|
292
|
-
*/
|
|
293
|
-
export const resolveSelectionAfterRun = (
|
|
294
|
-
outcome: BulkRunOutcome,
|
|
295
|
-
): SelectionAfterRun => {
|
|
296
|
-
if (outcome.failedIds.length > 0) {
|
|
297
|
-
return { exit: false, retryIds: outcome.failedIds };
|
|
298
|
-
}
|
|
299
|
-
if (outcome.cancelled || outcome.error) {
|
|
300
|
-
return { exit: false, retryIds: [] };
|
|
301
|
-
}
|
|
302
|
-
return { exit: true, retryIds: [] };
|
|
303
|
-
};
|
package/src/lib/format.test.ts
CHANGED
|
@@ -119,31 +119,4 @@ describe("formatDeleteToTrashTitle", () => {
|
|
|
119
119
|
"Move 3,412 messages to Trash?",
|
|
120
120
|
);
|
|
121
121
|
});
|
|
122
|
-
|
|
123
|
-
// #109: an escalated-predicate count is paged once by `countMatches` and
|
|
124
|
-
// re-paged independently by the delete itself — never provably the number
|
|
125
|
-
// that gets deleted. `isEstimate` says so instead of stating an exact
|
|
126
|
-
// number the run may not honour.
|
|
127
|
-
describe("isEstimate (#109 — an escalated-predicate count, not a materialized selection)", () => {
|
|
128
|
-
test("prefixes 'about' for a plural estimate", () => {
|
|
129
|
-
assert.strictEqual(
|
|
130
|
-
formatDeleteToTrashTitle(3412, true),
|
|
131
|
-
"Move about 3,412 messages to Trash?",
|
|
132
|
-
);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test("prefixes 'about' for a singular estimate", () => {
|
|
136
|
-
assert.strictEqual(
|
|
137
|
-
formatDeleteToTrashTitle(1, true),
|
|
138
|
-
"Move about 1 message to Trash?",
|
|
139
|
-
);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test("defaults to false — a bounded selection's count stays exact", () => {
|
|
143
|
-
assert.strictEqual(
|
|
144
|
-
formatDeleteToTrashTitle(3412),
|
|
145
|
-
formatDeleteToTrashTitle(3412, false),
|
|
146
|
-
);
|
|
147
|
-
});
|
|
148
|
-
});
|
|
149
122
|
});
|
package/src/lib/format.ts
CHANGED
|
@@ -152,21 +152,11 @@ export const formatEmailDate = (date: Date | string | number): string => {
|
|
|
152
152
|
/**
|
|
153
153
|
* Confirmation title for the move-to-Trash delete flow. Reflects that delete
|
|
154
154
|
* moves messages to Trash (not a permanent delete) and pluralizes on count.
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
* `isEstimate` marks an escalated-predicate count (#109): it was paged to a
|
|
159
|
-
* total once, and the delete itself re-pages the same predicate independently
|
|
160
|
-
* — mail arriving or leaving between the two can make them differ. "about"
|
|
161
|
-
* says so up front instead of stating a number the run may not honour; a
|
|
162
|
-
* materialized (bounded) selection's count is exact and never passes it.
|
|
155
|
+
* The count is always a concrete list of ids — every predicate delete ends on
|
|
156
|
+
* the wizard's review screen instead, which states what the predicate covers.
|
|
163
157
|
*/
|
|
164
|
-
export const formatDeleteToTrashTitle = (
|
|
165
|
-
count: number,
|
|
166
|
-
isEstimate = false,
|
|
167
|
-
): string => {
|
|
158
|
+
export const formatDeleteToTrashTitle = (count: number): string => {
|
|
168
159
|
const quantity = count === 1 ? "1" : formatNumber(count);
|
|
169
160
|
const noun = count === 1 ? "message" : "messages";
|
|
170
|
-
|
|
171
|
-
return `Move ${prefix}${quantity} ${noun} to Trash?`;
|
|
161
|
+
return `Move ${quantity} ${noun} to Trash?`;
|
|
172
162
|
};
|
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
RemitImapOrganizeInput,
|
|
7
7
|
} from "@remit/api-http-client/types.gen.ts";
|
|
8
8
|
import type {
|
|
9
|
-
|
|
9
|
+
MatchDoor,
|
|
10
10
|
MatchOperator,
|
|
11
11
|
RuleClause,
|
|
12
12
|
RuleScope,
|
|
@@ -105,7 +105,13 @@ export const buildOrganizeInput = (
|
|
|
105
105
|
* it holds, and the four `OrganizeScope` values fall out of the pair.
|
|
106
106
|
*/
|
|
107
107
|
export interface WizardCommitAnswers {
|
|
108
|
-
|
|
108
|
+
/**
|
|
109
|
+
* The door the match came through. An escalated predicate is not one: it has
|
|
110
|
+
* no clauses to build a rule from and no anchor to widen, so a scope
|
|
111
|
+
* reconstructed for it would be a rule matching everything. Typed to the
|
|
112
|
+
* three doors so that cannot be written rather than merely not written.
|
|
113
|
+
*/
|
|
114
|
+
mode: MatchDoor;
|
|
109
115
|
/** Absent on the verbs that never reach the scope step — those act once. */
|
|
110
116
|
ruleScope?: RuleScope;
|
|
111
117
|
}
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
* rewinds every entry the wizard owns.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { type StepId, stepIndex } from "@remit/ui";
|
|
20
|
+
import { type StepId, stepIndex, type Verb } from "@remit/ui";
|
|
21
21
|
import { useNavigate, useRouter, useSearch } from "@tanstack/react-router";
|
|
22
|
-
import { useCallback, useEffect, useRef } from "react";
|
|
22
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
23
23
|
import { z } from "zod";
|
|
24
24
|
|
|
25
25
|
const stepId = z.enum([
|
|
@@ -101,6 +101,46 @@ export const useOpenWizard = (): ((
|
|
|
101
101
|
);
|
|
102
102
|
};
|
|
103
103
|
|
|
104
|
+
export interface SelectionWizardControl {
|
|
105
|
+
/** The verb the wizard was opened for, for the host that renders it. */
|
|
106
|
+
verb: Verb;
|
|
107
|
+
/** Opens the wizard on a verb, from the match step — every bar verb, and
|
|
108
|
+
* every keyboard verb aimed at a selection, comes through here. */
|
|
109
|
+
start: (verb: Verb) => void;
|
|
110
|
+
/** Opens it on the search entry instead: the property step, with the query
|
|
111
|
+
* converted (#477 1.8). The route for a verb whose match is a predicate
|
|
112
|
+
* rather than a set of clauses. */
|
|
113
|
+
startFromSearch: () => void;
|
|
114
|
+
/**
|
|
115
|
+
* A step is held, so the wizard owns the screen. Every selection surface
|
|
116
|
+
* suspends its keyboard layer on this: a shortcut acting behind the screen
|
|
117
|
+
* already asking about an action is a second flow nobody asked for.
|
|
118
|
+
*/
|
|
119
|
+
isOpen: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The wizard as a selection surface drives it. The step lives in the URL and
|
|
124
|
+
* the verb beside it in state, in one place rather than one copy per surface —
|
|
125
|
+
* three of them had drifted apart on which verbs they even offered.
|
|
126
|
+
*/
|
|
127
|
+
export const useSelectionWizard = (): SelectionWizardControl => {
|
|
128
|
+
const step = useWizardStepValue();
|
|
129
|
+
const openWizard = useOpenWizard();
|
|
130
|
+
const [verb, setVerb] = useState<Verb>("organize");
|
|
131
|
+
const start = useCallback(
|
|
132
|
+
(next: Verb) => {
|
|
133
|
+
setVerb(next);
|
|
134
|
+
openWizard("match");
|
|
135
|
+
},
|
|
136
|
+
[openWizard],
|
|
137
|
+
);
|
|
138
|
+
const startFromSearch = useCallback(() => {
|
|
139
|
+
openWizard("properties", "search");
|
|
140
|
+
}, [openWizard]);
|
|
141
|
+
return { verb, start, startFromSearch, isOpen: step !== undefined };
|
|
142
|
+
};
|
|
143
|
+
|
|
104
144
|
export interface WizardStepNavigation {
|
|
105
145
|
step: StepId | undefined;
|
|
106
146
|
goToStep: (step: StepId) => void;
|