@remit/web-client 0.0.98 → 0.0.100
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/MessageList.run-reporting.test.ts +59 -0
- package/src/components/mail/MessageList.tsx +46 -12
- package/src/components/mail/MoveToTrigger.tsx +17 -32
- package/src/components/mail/SelectionWizardHost.run-exit.test.ts +83 -0
- package/src/components/mail/SelectionWizardHost.tsx +59 -16
- package/src/components/mail/SpamRescue.tsx +17 -30
- package/src/components/settings/DeleteFolderDialog.tsx +11 -15
- package/src/hooks/escalated-run-lifetime.render.test.ts +265 -0
- package/src/hooks/useEscalatedActions.ts +69 -38
- package/src/hooks/useFolderLabelTranslator.ts +19 -0
- package/src/lib/bulk-action-copy.test.ts +24 -0
- package/src/lib/bulk-action-copy.ts +16 -0
- package/src/lib/move-options.test.ts +140 -0
- package/src/lib/move-options.ts +46 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.100",
|
|
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": {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A run states how it ended, to whoever is still there to read it (#521). The
|
|
9
|
+
* run screen invites the user to close it and keep the run going, which leaves
|
|
10
|
+
* the run with no screen of its own — so the list says what it reached, whether
|
|
11
|
+
* that was everything or a hundred out of three thousand.
|
|
12
|
+
*
|
|
13
|
+
* The list wires the virtualizer, routing and several data hooks together, so
|
|
14
|
+
* — as with this package's other component-level rules (see
|
|
15
|
+
* `MessageList.selection.test.ts`) — the wiring is read off the source. The
|
|
16
|
+
* sentences themselves are unit-tested in `../../lib/bulk-action-copy.test.ts`,
|
|
17
|
+
* and which runs reach this seam in `SelectionWizardHost.run-exit.test.ts`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const source = readFileSync(resolve(here, "MessageList.tsx"), "utf8");
|
|
22
|
+
|
|
23
|
+
const reportBody = source.match(
|
|
24
|
+
/const reportRunOutcome = useCallback\(([\s\S]*?)\n\t\t\[pushError\],/,
|
|
25
|
+
)?.[1];
|
|
26
|
+
|
|
27
|
+
describe("reporting how a run ended", () => {
|
|
28
|
+
it("hands the wizard somewhere to report an ending it can no longer show", () => {
|
|
29
|
+
assert.ok(reportBody, "the list reports no run outcome");
|
|
30
|
+
const host = source.match(/<SelectionWizardHost[\s\S]*?\/>/)?.[0] ?? "";
|
|
31
|
+
assert.match(host, /onRunEnded=\{reportRunOutcome\}/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("names both endings: what it covered, and what it stopped short of", () => {
|
|
35
|
+
assert.match(reportBody ?? "", /bulkActionStoppedTitle\(outcome\.done\)/);
|
|
36
|
+
assert.match(
|
|
37
|
+
reportBody ?? "",
|
|
38
|
+
/bulkActionCompletionText\(kind, outcome\.done\)/,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("raises a stopped run as a warning rather than a passing note", () => {
|
|
43
|
+
assert.match(reportBody ?? "", /severity: "warning"/);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("the run the wizard leaves behind", () => {
|
|
48
|
+
it("is stoppable from the wizard, which is not the same as closing it", () => {
|
|
49
|
+
assert.match(source, /stop: escalation\.stop,/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("keeps reporting on the bar from the moment it starts", () => {
|
|
53
|
+
assert.match(
|
|
54
|
+
source,
|
|
55
|
+
/escalation\.progress\?\.total \?\? selectionCount/,
|
|
56
|
+
"a run with no batch finished yet has no denominator to show",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -14,6 +14,7 @@ import { Search } from "lucide-react";
|
|
|
14
14
|
import type { RefObject } from "react";
|
|
15
15
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
16
16
|
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
|
17
|
+
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
17
18
|
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
18
19
|
import { useJunkMailbox } from "@/hooks/useArchiveMailbox";
|
|
19
20
|
import {
|
|
@@ -32,10 +33,14 @@ import {
|
|
|
32
33
|
} from "@/hooks/useSelection";
|
|
33
34
|
import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
|
|
34
35
|
import {
|
|
36
|
+
type BulkActionKind,
|
|
35
37
|
bulkActionCompletionText,
|
|
36
38
|
bulkActionProgressLabel,
|
|
37
39
|
bulkActionProgressTone,
|
|
40
|
+
bulkActionStoppedDetail,
|
|
41
|
+
bulkActionStoppedTitle,
|
|
38
42
|
} from "@/lib/bulk-action-copy";
|
|
43
|
+
import type { BulkRunOutcome } from "@/lib/bulk-actions";
|
|
39
44
|
import {
|
|
40
45
|
describeSearchScope,
|
|
41
46
|
escalatedStatusLabel,
|
|
@@ -241,6 +246,7 @@ export const MessageList = ({
|
|
|
241
246
|
const isSearching = !!searchQuery?.trim();
|
|
242
247
|
const listHeaderChrome = useListHeaderChrome();
|
|
243
248
|
const { labels } = useLabelList(accountId);
|
|
249
|
+
const { pushError } = useErrorBanners();
|
|
244
250
|
|
|
245
251
|
// Roving focus cursor (#429): the keyboard "where am I" pointer, distinct
|
|
246
252
|
// from the open thread (`selectedMessageId` in the URL). j/k move this
|
|
@@ -336,6 +342,31 @@ export const MessageList = ({
|
|
|
336
342
|
searchQuery: searchPredicate ?? {},
|
|
337
343
|
});
|
|
338
344
|
|
|
345
|
+
// How a run ended, for the user who is no longer looking at the run screen.
|
|
346
|
+
// Closing the wizard mid-run is a movement the screen invites, and the run
|
|
347
|
+
// keeps going past it — so the list is what states the ending, whether the
|
|
348
|
+
// run covered everything or stopped short of it. The wizard calls this only
|
|
349
|
+
// once the user has left it, so an ending is never said twice.
|
|
350
|
+
const reportRunOutcome = useCallback(
|
|
351
|
+
(kind: BulkActionKind, matched: number, outcome: BulkRunOutcome) => {
|
|
352
|
+
// A run stopped by a thrown batch is already banner-ed where it threw.
|
|
353
|
+
if (outcome.error !== undefined) return;
|
|
354
|
+
if (outcome.cancelled) {
|
|
355
|
+
pushError({
|
|
356
|
+
severity: "warning",
|
|
357
|
+
title: bulkActionStoppedTitle(outcome.done),
|
|
358
|
+
detail: bulkActionStoppedDetail(kind, outcome.done, matched),
|
|
359
|
+
});
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
pushError({
|
|
363
|
+
severity: "info",
|
|
364
|
+
title: bulkActionCompletionText(kind, outcome.done),
|
|
365
|
+
});
|
|
366
|
+
},
|
|
367
|
+
[pushError],
|
|
368
|
+
);
|
|
369
|
+
|
|
339
370
|
// The one way selection mode ends (#115): cancel, a completed delete or
|
|
340
371
|
// move, a plain click that collapses a range, switching mailboxes, the back
|
|
341
372
|
// gesture. Nothing calls the selection hook's `clearSelection` directly, so
|
|
@@ -625,9 +656,7 @@ export const MessageList = ({
|
|
|
625
656
|
|
|
626
657
|
// The escalated selection as the wizard walks it (#508): the words the bar
|
|
627
658
|
// already names the predicate with, the count it was escalated to, and the
|
|
628
|
-
// chunked runner that re-resolves it.
|
|
629
|
-
// front of that runner and its run screen drives it, so nothing here reports
|
|
630
|
-
// an outcome of its own.
|
|
659
|
+
// chunked runner that re-resolves it.
|
|
631
660
|
const escalatedSelection: EscalatedSelection | undefined =
|
|
632
661
|
escalation.phase.kind === "escalated"
|
|
633
662
|
? {
|
|
@@ -635,6 +664,7 @@ export const MessageList = ({
|
|
|
635
664
|
total: escalation.phase.total,
|
|
636
665
|
searchQuery: searchPredicate ?? {},
|
|
637
666
|
run: (action: EscalatedAction) => escalation.runAction(action),
|
|
667
|
+
stop: escalation.stop,
|
|
638
668
|
}
|
|
639
669
|
: undefined;
|
|
640
670
|
|
|
@@ -1062,11 +1092,15 @@ export const MessageList = ({
|
|
|
1062
1092
|
? escalation.phase.total
|
|
1063
1093
|
: selectedCount;
|
|
1064
1094
|
|
|
1095
|
+
// A run reports on the bar from the moment it starts, not from its first
|
|
1096
|
+
// finished batch: the wizard invites the user back here mid-run, and a run
|
|
1097
|
+
// with no reading yet is still a run — it is counted against what it was
|
|
1098
|
+
// started with until it has covered something of its own.
|
|
1065
1099
|
const selectionStatusLabel = escalation.runningAction
|
|
1066
1100
|
? bulkActionProgressLabel(
|
|
1067
1101
|
escalation.runningAction.kind,
|
|
1068
1102
|
escalation.progress?.done ?? 0,
|
|
1069
|
-
escalation.progress?.total ??
|
|
1103
|
+
escalation.progress?.total ?? selectionCount,
|
|
1070
1104
|
)
|
|
1071
1105
|
: escalation.phase.kind === "counting"
|
|
1072
1106
|
? escalation.phase.countSoFar >= 5000
|
|
@@ -1088,14 +1122,13 @@ export const MessageList = ({
|
|
|
1088
1122
|
}
|
|
1089
1123
|
: undefined;
|
|
1090
1124
|
|
|
1091
|
-
const selectionProgress =
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
: undefined;
|
|
1125
|
+
const selectionProgress = escalation.runningAction
|
|
1126
|
+
? {
|
|
1127
|
+
value: escalation.progress?.done ?? 0,
|
|
1128
|
+
max: escalation.progress?.total ?? selectionCount,
|
|
1129
|
+
tone: bulkActionProgressTone(escalation.runningAction.kind),
|
|
1130
|
+
}
|
|
1131
|
+
: undefined;
|
|
1099
1132
|
|
|
1100
1133
|
// At most one escalation notice at a time, ranked by how actionable it is:
|
|
1101
1134
|
// an in-progress counting/escalated state and its own action always wins;
|
|
@@ -1325,6 +1358,7 @@ export const MessageList = ({
|
|
|
1325
1358
|
escalated={escalatedSelection}
|
|
1326
1359
|
escalatedProgress={escalation.progress}
|
|
1327
1360
|
onFinished={exitSelection}
|
|
1361
|
+
onRunEnded={reportRunOutcome}
|
|
1328
1362
|
/>
|
|
1329
1363
|
</>
|
|
1330
1364
|
);
|
|
@@ -15,9 +15,9 @@ import { Drawer } from "vaul";
|
|
|
15
15
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
16
16
|
import { useFolderAppointments } from "@/hooks/useArchiveMailbox";
|
|
17
17
|
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
18
|
+
import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
|
|
18
19
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
19
|
-
import {
|
|
20
|
-
import { buildMoveTargets } from "@/lib/move-targets";
|
|
20
|
+
import { buildMoveOptions } from "@/lib/move-options";
|
|
21
21
|
import { cn } from "@/lib/utils";
|
|
22
22
|
|
|
23
23
|
interface MoveToTriggerProps {
|
|
@@ -71,15 +71,7 @@ export const MoveToTrigger = ({
|
|
|
71
71
|
const triggerLabel = label ?? "Move to folder";
|
|
72
72
|
const popoverId = useId();
|
|
73
73
|
const { t } = useTranslation("mail", { useSuspense: false });
|
|
74
|
-
|
|
75
|
-
// fallback)` shape; i18next's `t` treats the second argument as an options
|
|
76
|
-
// object — passing it raw breaks fallback behavior. Wrap it the same way
|
|
77
|
-
// the sidebar adapter does, memoized so it's a stable `useMemo` dependency.
|
|
78
|
-
const translator = useCallback(
|
|
79
|
-
(key: string, fallback: string): string =>
|
|
80
|
-
t(key, { defaultValue: fallback }),
|
|
81
|
-
[t],
|
|
82
|
-
);
|
|
74
|
+
const translator = useFolderLabelTranslator();
|
|
83
75
|
|
|
84
76
|
const {
|
|
85
77
|
data: mailboxesResponse,
|
|
@@ -98,28 +90,21 @@ export const MoveToTrigger = ({
|
|
|
98
90
|
const folderAppointments = useFolderAppointments(accountId);
|
|
99
91
|
const { createFolder } = useCreateMailbox(accountId);
|
|
100
92
|
|
|
101
|
-
const options = useMemo<MoveMailboxOption[]>(
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return targets.map((mailbox) => ({
|
|
108
|
-
id: mailbox.mailboxId,
|
|
109
|
-
label: labelForMailbox(
|
|
110
|
-
mailbox,
|
|
111
|
-
roleMap.get(mailbox.mailboxId),
|
|
93
|
+
const options = useMemo<MoveMailboxOption[]>(
|
|
94
|
+
() =>
|
|
95
|
+
buildMoveOptions({
|
|
96
|
+
mailboxes: mailboxesResponse?.items ?? [],
|
|
97
|
+
folderAppointments,
|
|
98
|
+
currentMailboxId,
|
|
112
99
|
translator,
|
|
113
|
-
),
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
translator,
|
|
122
|
-
]);
|
|
100
|
+
}),
|
|
101
|
+
[
|
|
102
|
+
mailboxesResponse?.items,
|
|
103
|
+
folderAppointments,
|
|
104
|
+
currentMailboxId,
|
|
105
|
+
translator,
|
|
106
|
+
],
|
|
107
|
+
);
|
|
123
108
|
|
|
124
109
|
const handleSelect = useCallback(
|
|
125
110
|
(destinationMailboxId: string) => {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every way off the run screen is the same movement, and none of them ends the
|
|
9
|
+
* run the screen promised would keep going (#521). Ending it is a control of
|
|
10
|
+
* its own, and whichever way the user leaves, the run reports where it got to.
|
|
11
|
+
*
|
|
12
|
+
* The host wires routing, history and several data hooks together, so — as with
|
|
13
|
+
* this package's other component-level rules (see `MessageList.selection.test.ts`)
|
|
14
|
+
* — the wiring is read off the source. What the exits then do is proven where it
|
|
15
|
+
* can be run: `../../hooks/escalated-run-lifetime.render.test.ts` for the run's
|
|
16
|
+
* lifetime, and `@remit/ui`'s `selection-wizard.render.test.ts` for the controls.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const source = readFileSync(resolve(here, "SelectionWizardHost.tsx"), "utf8");
|
|
21
|
+
|
|
22
|
+
describe("leaving the run screen", () => {
|
|
23
|
+
it("routes the header's X and back arrow through the same dismiss the footer uses", () => {
|
|
24
|
+
assert.match(source, /onExit=\{current === "run" \? dismiss : cancel\}/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("routes hardware back through that dismiss too", () => {
|
|
28
|
+
assert.match(
|
|
29
|
+
source,
|
|
30
|
+
/if \(action !== "BACK"\) return false;\s*\n\s*dismiss\(\);/,
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("leaves the run alone on the way out", () => {
|
|
35
|
+
const dismissBody = source.match(
|
|
36
|
+
/const dismiss = useCallback\(\(\) => \{([\s\S]*?)\}, \[/,
|
|
37
|
+
)?.[1];
|
|
38
|
+
assert.ok(dismissBody, "the run screen has no dismiss");
|
|
39
|
+
assert.doesNotMatch(dismissBody, /stop/i);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("ending the run", () => {
|
|
44
|
+
it("is a control of its own, offered only while a run is in flight", () => {
|
|
45
|
+
assert.match(source, /onCancelRun: runInFlight \? stopRun : undefined/);
|
|
46
|
+
assert.match(
|
|
47
|
+
source,
|
|
48
|
+
/const runInFlight =\s*bulkRun !== undefined && bulkRun\.outcome === undefined;/,
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("stops whichever runner is paging — the list's or the wizard's own", () => {
|
|
53
|
+
const stopBody = source.match(
|
|
54
|
+
/const stopRun = useCallback\(\(\) => \{([\s\S]*?)\}, \[/,
|
|
55
|
+
)?.[1];
|
|
56
|
+
assert.ok(stopBody, "there is no way to stop a run");
|
|
57
|
+
assert.match(stopBody, /escalated\.stop\(\)/);
|
|
58
|
+
assert.match(stopBody, /stopBulk\(\)/);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Both runners report, not just the predicate one: a ticked select-all runs on
|
|
64
|
+
* the wizard's own runner, and the same screen makes the same promise over it.
|
|
65
|
+
*/
|
|
66
|
+
describe("stating how a run ended once the screen is gone", () => {
|
|
67
|
+
it("marks the walk-away where it happens rather than reading it back off the URL", () => {
|
|
68
|
+
assert.match(source, /walkedAway\.current = true;\s*\n\s*closeWizard\(/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
for (const runner of ["runBulk", "runEscalated"]) {
|
|
72
|
+
it(`reports the run ${runner} drove`, () => {
|
|
73
|
+
const body =
|
|
74
|
+
source.match(
|
|
75
|
+
new RegExp(
|
|
76
|
+
`const ${runner} = useCallback\\(([\\s\\S]*?)\\n\\t\\}, \\[`,
|
|
77
|
+
),
|
|
78
|
+
)?.[1] ?? "";
|
|
79
|
+
assert.match(body, /if \(walkedAway\.current\)/);
|
|
80
|
+
assert.match(body, /onRunEnded\?\.\(action\.kind, /);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
});
|
|
@@ -41,16 +41,16 @@ import {
|
|
|
41
41
|
useEscalatedActions,
|
|
42
42
|
} from "@/hooks/useEscalatedActions";
|
|
43
43
|
import { useCreateFilter } from "@/hooks/useFilters";
|
|
44
|
+
import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
|
|
44
45
|
import { useMatchSample, useSearchMatchSample } from "@/hooks/useMatchSample";
|
|
45
46
|
import { useOrganizeJob } from "@/hooks/useOrganizeJob";
|
|
46
47
|
import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
|
|
47
48
|
import { useRulePreview } from "@/hooks/useRulePreview";
|
|
48
49
|
import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
|
|
49
50
|
import type { BulkActionProgress, BulkRunOutcome } from "@/lib/bulk-actions";
|
|
50
|
-
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
51
51
|
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
52
52
|
import { useMailContext } from "@/lib/mail-context";
|
|
53
|
-
import {
|
|
53
|
+
import { buildMoveOptions } from "@/lib/move-options";
|
|
54
54
|
import {
|
|
55
55
|
buildWizardDraft,
|
|
56
56
|
canBackApplyDraft,
|
|
@@ -106,9 +106,12 @@ export interface EscalatedSelection {
|
|
|
106
106
|
/**
|
|
107
107
|
* Runs one verb over the whole predicate, paging it server-side. The chunked
|
|
108
108
|
* runner the list already owns: the run screen drives it, and the review
|
|
109
|
-
* screen is what now stands in front of it.
|
|
109
|
+
* screen is what now stands in front of it. The run belongs to the list, so
|
|
110
|
+
* it survives the wizard closing over it — as the run screen promises.
|
|
110
111
|
*/
|
|
111
112
|
run: (action: EscalatedAction) => Promise<BulkRunOutcome>;
|
|
113
|
+
/** Ends that run at the next page boundary, which leaving the wizard does not. */
|
|
114
|
+
stop: () => void;
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
export interface SelectionWizardHostProps {
|
|
@@ -133,6 +136,17 @@ export interface SelectionWizardHostProps {
|
|
|
133
136
|
escalatedProgress?: BulkActionProgress;
|
|
134
137
|
/** The wizard is done with the selection, and the list drops it. */
|
|
135
138
|
onFinished: () => void;
|
|
139
|
+
/**
|
|
140
|
+
* How a run ended, once the screen that was reporting on it is gone. The run
|
|
141
|
+
* screen invites the user to close it and keep the run going, so the surface
|
|
142
|
+
* that outlives the wizard is what states the ending. Called only for a run
|
|
143
|
+
* the user walked away from; a run screen still up reports in place.
|
|
144
|
+
*/
|
|
145
|
+
onRunEnded?: (
|
|
146
|
+
kind: EscalatedAction["kind"],
|
|
147
|
+
matched: number,
|
|
148
|
+
outcome: BulkRunOutcome,
|
|
149
|
+
) => void;
|
|
136
150
|
}
|
|
137
151
|
|
|
138
152
|
/**
|
|
@@ -248,6 +262,7 @@ function SelectionWizardSession({
|
|
|
248
262
|
escalatedProgress,
|
|
249
263
|
searchConversion,
|
|
250
264
|
onFinished,
|
|
265
|
+
onRunEnded,
|
|
251
266
|
step,
|
|
252
267
|
goToStep,
|
|
253
268
|
goBack,
|
|
@@ -296,6 +311,11 @@ function SelectionWizardSession({
|
|
|
296
311
|
// cleared, so a failed start can be retried.
|
|
297
312
|
const [backApplyDraft, setBackApplyDraft] = useState<OrganizeDraft>();
|
|
298
313
|
const commitSent = useRef(false);
|
|
314
|
+
// The user left the run screen while it was still reporting. Held here rather
|
|
315
|
+
// than read back off the URL: the rewind the exit starts lands a frame or two
|
|
316
|
+
// later, and an outcome arriving in between would find a screen that is on
|
|
317
|
+
// its way out and say nothing.
|
|
318
|
+
const walkedAway = useRef(false);
|
|
299
319
|
|
|
300
320
|
const messageIds = useMemo(
|
|
301
321
|
() => selection.map((message) => message.id),
|
|
@@ -367,17 +387,16 @@ function SelectionWizardSession({
|
|
|
367
387
|
staleTime: Number.POSITIVE_INFINITY,
|
|
368
388
|
});
|
|
369
389
|
const folderAppointments = useFolderAppointments(accountId);
|
|
390
|
+
const translator = useFolderLabelTranslator();
|
|
370
391
|
const mailboxes = useMemo<MoveMailboxOption[]>(
|
|
371
392
|
() =>
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
),
|
|
380
|
-
[mailboxesData?.items, folderAppointments, mailboxId],
|
|
393
|
+
buildMoveOptions({
|
|
394
|
+
mailboxes: mailboxesData?.items ?? [],
|
|
395
|
+
folderAppointments,
|
|
396
|
+
currentMailboxId: mailboxId,
|
|
397
|
+
translator,
|
|
398
|
+
}),
|
|
399
|
+
[mailboxesData?.items, folderAppointments, mailboxId, translator],
|
|
381
400
|
);
|
|
382
401
|
const { createFolder } = useCreateMailbox(accountId);
|
|
383
402
|
|
|
@@ -575,8 +594,9 @@ function SelectionWizardSession({
|
|
|
575
594
|
setBulkRun({ matched: ids.length });
|
|
576
595
|
const outcome = await runAction(action, [...ids]);
|
|
577
596
|
setBulkRun({ matched: ids.length, outcome });
|
|
597
|
+
if (walkedAway.current) onRunEnded?.(action.kind, ids.length, outcome);
|
|
578
598
|
},
|
|
579
|
-
[verb, named.moveMailboxId, junkMailboxId, runAction],
|
|
599
|
+
[verb, named.moveMailboxId, junkMailboxId, runAction, onRunEnded],
|
|
580
600
|
);
|
|
581
601
|
|
|
582
602
|
// The escalated predicate, run by the chunked runner the list already owns.
|
|
@@ -595,7 +615,10 @@ function SelectionWizardSession({
|
|
|
595
615
|
setBulkRun({ matched: escalated.total });
|
|
596
616
|
const outcome = await escalated.run(action);
|
|
597
617
|
setBulkRun({ matched: escalated.total, outcome });
|
|
598
|
-
|
|
618
|
+
if (walkedAway.current) {
|
|
619
|
+
onRunEnded?.(action.kind, escalated.total, outcome);
|
|
620
|
+
}
|
|
621
|
+
}, [escalated, verb, named.moveMailboxId, junkMailboxId, onRunEnded]);
|
|
599
622
|
|
|
600
623
|
const { start: startJob } = organizeJob;
|
|
601
624
|
const { createFilterAsync } = createFilter;
|
|
@@ -815,12 +838,28 @@ function SelectionWizardSession({
|
|
|
815
838
|
}, [closeWizard, steps, current]);
|
|
816
839
|
|
|
817
840
|
// The run screen's way out. The action has happened, so the rows it was
|
|
818
|
-
// aimed at are no longer a selection waiting to be acted on.
|
|
841
|
+
// aimed at are no longer a selection waiting to be acted on. It leaves the
|
|
842
|
+
// run alone: the screen says so in as many words, and stopping it is a
|
|
843
|
+
// control of its own.
|
|
819
844
|
const dismiss = useCallback(() => {
|
|
845
|
+
walkedAway.current = true;
|
|
820
846
|
closeWizard(steps, current);
|
|
821
847
|
onFinished();
|
|
822
848
|
}, [closeWizard, steps, current, onFinished]);
|
|
823
849
|
|
|
850
|
+
// Ends the run rather than leaving it. The escalated predicate is run by the
|
|
851
|
+
// list's runner and a bounded selection by this wizard's own, so the stop
|
|
852
|
+
// follows whichever one is paging.
|
|
853
|
+
const { stop: stopBulk } = bulk;
|
|
854
|
+
const runInFlight = bulkRun !== undefined && bulkRun.outcome === undefined;
|
|
855
|
+
const stopRun = useCallback(() => {
|
|
856
|
+
if (escalated) {
|
|
857
|
+
escalated.stop();
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
stopBulk();
|
|
861
|
+
}, [escalated, stopBulk]);
|
|
862
|
+
|
|
824
863
|
// Hardware back on the run screen leaves the wizard, the movement the header
|
|
825
864
|
// arrow and the footer already make there (`backExits`). Run is an ordinary
|
|
826
865
|
// pushed entry, so without this the button pops back to Review, where the
|
|
@@ -868,7 +907,10 @@ function SelectionWizardSession({
|
|
|
868
907
|
blockedReason={blockedReason}
|
|
869
908
|
nudged={nudgedStep === current}
|
|
870
909
|
onBack={goBack}
|
|
871
|
-
|
|
910
|
+
// Every exit from the run screen is the same movement: the header's X,
|
|
911
|
+
// the header arrow, the footer and hardware Back all leave a run that
|
|
912
|
+
// has already been sent, and all of them drop the selection with it.
|
|
913
|
+
onExit={current === "run" ? dismiss : cancel}
|
|
872
914
|
onContinue={advance}
|
|
873
915
|
onCommit={commit}
|
|
874
916
|
match={{
|
|
@@ -945,6 +987,7 @@ function SelectionWizardSession({
|
|
|
945
987
|
failures: run.failures,
|
|
946
988
|
onRetry: retry,
|
|
947
989
|
onDismiss: dismiss,
|
|
990
|
+
onCancelRun: runInFlight ? stopRun : undefined,
|
|
948
991
|
}}
|
|
949
992
|
/>
|
|
950
993
|
);
|
|
@@ -14,13 +14,12 @@ import {
|
|
|
14
14
|
useRef,
|
|
15
15
|
useState,
|
|
16
16
|
} from "react";
|
|
17
|
-
import { useTranslation } from "react-i18next";
|
|
18
17
|
import {
|
|
19
18
|
useFolderAppointments,
|
|
20
19
|
useInboxMailbox,
|
|
21
20
|
} from "@/hooks/useArchiveMailbox";
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
21
|
+
import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
|
|
22
|
+
import { buildMoveOptions } from "@/lib/move-options";
|
|
24
23
|
import {
|
|
25
24
|
recordRescueCandidatesSurfaced,
|
|
26
25
|
recordRescueCommitted,
|
|
@@ -51,12 +50,7 @@ export function SpamRescue({
|
|
|
51
50
|
}: SpamRescueProps) {
|
|
52
51
|
const [open, setOpen] = useState(false);
|
|
53
52
|
const telemetry = useTelemetry();
|
|
54
|
-
const
|
|
55
|
-
const translator = useCallback(
|
|
56
|
-
(key: string, fallback: string): string =>
|
|
57
|
-
t(key, { defaultValue: fallback }),
|
|
58
|
-
[t],
|
|
59
|
-
);
|
|
53
|
+
const translator = useFolderLabelTranslator();
|
|
60
54
|
|
|
61
55
|
const { inboxMailboxId } = useInboxMailbox(accountId);
|
|
62
56
|
const folderAppointments = useFolderAppointments(accountId);
|
|
@@ -66,28 +60,21 @@ export function SpamRescue({
|
|
|
66
60
|
staleTime: Infinity,
|
|
67
61
|
});
|
|
68
62
|
|
|
69
|
-
const folders = useMemo<MoveMailboxOption[]>(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return targets.map((mailbox) => ({
|
|
76
|
-
id: mailbox.mailboxId,
|
|
77
|
-
label: labelForMailbox(
|
|
78
|
-
mailbox,
|
|
79
|
-
roleMap.get(mailbox.mailboxId),
|
|
63
|
+
const folders = useMemo<MoveMailboxOption[]>(
|
|
64
|
+
() =>
|
|
65
|
+
buildMoveOptions({
|
|
66
|
+
mailboxes: mailboxesResponse?.items ?? [],
|
|
67
|
+
folderAppointments,
|
|
68
|
+
currentMailboxId,
|
|
80
69
|
translator,
|
|
81
|
-
),
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
translator,
|
|
90
|
-
]);
|
|
70
|
+
}),
|
|
71
|
+
[
|
|
72
|
+
mailboxesResponse?.items,
|
|
73
|
+
folderAppointments,
|
|
74
|
+
currentMailboxId,
|
|
75
|
+
translator,
|
|
76
|
+
],
|
|
77
|
+
);
|
|
91
78
|
|
|
92
79
|
const defaultDestinationId = useMemo(() => {
|
|
93
80
|
if (inboxMailboxId) return inboxMailboxId;
|
|
@@ -13,13 +13,10 @@ import { AlertTriangle, FolderInput, Loader2, Trash2, X } from "lucide-react";
|
|
|
13
13
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
14
14
|
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
15
15
|
import { useDeleteFolder } from "@/hooks/useDeleteFolder";
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
initialStage,
|
|
19
|
-
moveProgressLabel,
|
|
20
|
-
} from "@/lib/delete-folder";
|
|
16
|
+
import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
|
|
17
|
+
import { initialStage, moveProgressLabel } from "@/lib/delete-folder";
|
|
21
18
|
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
22
|
-
import {
|
|
19
|
+
import { buildMoveOptions } from "@/lib/move-options";
|
|
23
20
|
|
|
24
21
|
interface DeleteFolderDialogProps {
|
|
25
22
|
open: boolean;
|
|
@@ -61,6 +58,7 @@ export function DeleteFolderDialog({
|
|
|
61
58
|
initialStage(folder.messageCount),
|
|
62
59
|
);
|
|
63
60
|
const { createFolder } = useCreateMailbox(accountId);
|
|
61
|
+
const translator = useFolderLabelTranslator();
|
|
64
62
|
const {
|
|
65
63
|
phase,
|
|
66
64
|
progress,
|
|
@@ -90,15 +88,13 @@ export function DeleteFolderDialog({
|
|
|
90
88
|
|
|
91
89
|
const destinations = useMemo<MoveMailboxOption[]>(
|
|
92
90
|
() =>
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
})),
|
|
101
|
-
[mailboxes, appointments, folder.mailboxId],
|
|
91
|
+
buildMoveOptions({
|
|
92
|
+
mailboxes,
|
|
93
|
+
folderAppointments: appointments,
|
|
94
|
+
excludeMailboxId: folder.mailboxId,
|
|
95
|
+
translator,
|
|
96
|
+
}),
|
|
97
|
+
[mailboxes, appointments, folder.mailboxId, translator],
|
|
102
98
|
);
|
|
103
99
|
|
|
104
100
|
const handleCreateFolder = async (
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A run outlives the selection it came from (#521). The run screen tells the
|
|
3
|
+
* user "This keeps running if you close the wizard", and closing the wizard
|
|
4
|
+
* drops the escalated selection on the way out — so leaving the selection and
|
|
5
|
+
* ending the run have to be two things.
|
|
6
|
+
*
|
|
7
|
+
* These mount the real hook against the real fetch seam and count the batches
|
|
8
|
+
* that actually left, so what is pinned is the run's own lifetime rather than
|
|
9
|
+
* the wording of whichever screen started it. Every run is parked at a page
|
|
10
|
+
* boundary before the test acts on it: that is where the run reads whether it
|
|
11
|
+
* has been told to stop, and so the only place the difference shows.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
16
|
+
import { act, createElement } from "react";
|
|
17
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
18
|
+
import {
|
|
19
|
+
type EscalationSearchQuery,
|
|
20
|
+
type UseEscalatedActionsResult,
|
|
21
|
+
useEscalatedActions,
|
|
22
|
+
} from "./useEscalatedActions";
|
|
23
|
+
|
|
24
|
+
const INBOX = "mbx-inbox";
|
|
25
|
+
const PAGE_SIZE = 100;
|
|
26
|
+
/** Three full pages and a short one — page boundaries to park a run at. */
|
|
27
|
+
const TOTAL = PAGE_SIZE * 3 + 12;
|
|
28
|
+
|
|
29
|
+
let harness: DomHarness | undefined;
|
|
30
|
+
let server: MailServer | undefined;
|
|
31
|
+
|
|
32
|
+
interface MailServer {
|
|
33
|
+
/** Every message id a delete call carried, in the order they were sent. */
|
|
34
|
+
deleted: () => string[];
|
|
35
|
+
/** Deletes queued at the boundary rather than answered. */
|
|
36
|
+
held: () => number;
|
|
37
|
+
/** Answer everything held, and stop holding. */
|
|
38
|
+
release: () => void;
|
|
39
|
+
restore: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A search that pages `TOTAL` ids at the hook's own page size, keyed by the
|
|
44
|
+
* query, so a run started against one predicate is distinguishable from a run
|
|
45
|
+
* that followed the list onto a later one.
|
|
46
|
+
*/
|
|
47
|
+
const searchPage = (url: URL): unknown => {
|
|
48
|
+
const query = url.searchParams.get("query") ?? "";
|
|
49
|
+
const served = Number(url.searchParams.get("continuationToken") ?? "0");
|
|
50
|
+
const size = Math.min(PAGE_SIZE, Math.max(TOTAL - served, 0));
|
|
51
|
+
return {
|
|
52
|
+
items: Array.from({ length: size }, (_, i) => ({
|
|
53
|
+
messageId: `${query}-${served + i}`,
|
|
54
|
+
})),
|
|
55
|
+
continuationToken:
|
|
56
|
+
served + size < TOTAL ? String(served + size) : undefined,
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Answers the search and the bulk delete, holding every delete after the first
|
|
62
|
+
* so a run can be caught mid-flight with real progress behind it.
|
|
63
|
+
*/
|
|
64
|
+
const startMailServer = (): MailServer => {
|
|
65
|
+
const original = globalThis.fetch;
|
|
66
|
+
const deleted: string[] = [];
|
|
67
|
+
let waiting: Array<() => void> = [];
|
|
68
|
+
let holding = true;
|
|
69
|
+
let served = 0;
|
|
70
|
+
|
|
71
|
+
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
72
|
+
const request = input instanceof Request ? input : undefined;
|
|
73
|
+
const raw = request ? await request.clone().text() : undefined;
|
|
74
|
+
const url = new URL(request ? request.url : String(input), "http://x");
|
|
75
|
+
|
|
76
|
+
if (url.pathname.endsWith("/threads/search")) {
|
|
77
|
+
return Response.json(searchPage(url));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const body = raw
|
|
81
|
+
? (JSON.parse(raw) as { messageIds: string[] })
|
|
82
|
+
: undefined;
|
|
83
|
+
served += 1;
|
|
84
|
+
if (holding && served > 1) {
|
|
85
|
+
await new Promise<void>((resolve) => waiting.push(resolve));
|
|
86
|
+
}
|
|
87
|
+
deleted.push(...(body?.messageIds ?? []));
|
|
88
|
+
return Response.json({ successCount: 0, failureCount: 0 });
|
|
89
|
+
}) as typeof globalThis.fetch;
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
deleted: () => deleted,
|
|
93
|
+
held: () => waiting.length,
|
|
94
|
+
release: () => {
|
|
95
|
+
holding = false;
|
|
96
|
+
const queued = waiting;
|
|
97
|
+
waiting = [];
|
|
98
|
+
for (const resolve of queued) resolve();
|
|
99
|
+
},
|
|
100
|
+
restore: () => {
|
|
101
|
+
globalThis.fetch = original;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const mountEscalation = (
|
|
107
|
+
initialQuery: EscalationSearchQuery,
|
|
108
|
+
): {
|
|
109
|
+
hook: () => UseEscalatedActionsResult;
|
|
110
|
+
setQuery: (next: EscalationSearchQuery) => void;
|
|
111
|
+
} => {
|
|
112
|
+
let value: UseEscalatedActionsResult | undefined;
|
|
113
|
+
const Probe = ({ search }: { search: EscalationSearchQuery }) => {
|
|
114
|
+
value = useEscalatedActions({
|
|
115
|
+
mailboxId: INBOX,
|
|
116
|
+
enabled: true,
|
|
117
|
+
predicateKey: JSON.stringify(search),
|
|
118
|
+
searchQuery: search,
|
|
119
|
+
});
|
|
120
|
+
return null;
|
|
121
|
+
};
|
|
122
|
+
harness = createDomHarness();
|
|
123
|
+
harness.renderApp(createElement(Probe, { search: initialQuery }));
|
|
124
|
+
return {
|
|
125
|
+
hook: () => {
|
|
126
|
+
if (!value) throw new Error("hook did not render");
|
|
127
|
+
return value;
|
|
128
|
+
},
|
|
129
|
+
setQuery: (next) => {
|
|
130
|
+
harness?.renderApp(createElement(Probe, { search: next }));
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** A press: the state it sets belongs to the same commit React would batch. */
|
|
136
|
+
const press = <T>(action: () => T): T => {
|
|
137
|
+
let result: T | undefined;
|
|
138
|
+
act(() => {
|
|
139
|
+
result = action();
|
|
140
|
+
});
|
|
141
|
+
return result as T;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** Enough turns for a sequential page-and-apply run to reach its end. */
|
|
145
|
+
const settle = async (): Promise<void> => {
|
|
146
|
+
if (!harness) throw new Error("nothing mounted");
|
|
147
|
+
for (let round = 0; round < 40; round += 1) await harness.flush();
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** Park the run at the page boundary the second batch is waiting on. */
|
|
151
|
+
const parkAtBoundary = async (): Promise<void> => {
|
|
152
|
+
if (!harness || !server) throw new Error("nothing mounted");
|
|
153
|
+
for (let round = 0; round < 20; round += 1) {
|
|
154
|
+
if (server.held() > 0) return;
|
|
155
|
+
await harness.flush();
|
|
156
|
+
}
|
|
157
|
+
throw new Error("the run never reached a page boundary");
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const escalateTo = async (
|
|
161
|
+
hook: () => UseEscalatedActionsResult,
|
|
162
|
+
): Promise<void> => {
|
|
163
|
+
press(() => hook().escalate());
|
|
164
|
+
await settle();
|
|
165
|
+
assert.deepEqual(hook().phase, { kind: "escalated", total: TOTAL });
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
beforeEach(() => {
|
|
169
|
+
server = startMailServer();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
afterEach(() => {
|
|
173
|
+
harness?.close();
|
|
174
|
+
harness = undefined;
|
|
175
|
+
server?.release();
|
|
176
|
+
server?.restore();
|
|
177
|
+
server = undefined;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("an escalated run the user walks away from", () => {
|
|
181
|
+
it("keeps paging after the selection it came from is dropped", async () => {
|
|
182
|
+
const { hook } = mountEscalation({ query: "npm" });
|
|
183
|
+
await escalateTo(hook);
|
|
184
|
+
|
|
185
|
+
const run = press(() => hook().runAction({ kind: "delete" }));
|
|
186
|
+
await parkAtBoundary();
|
|
187
|
+
// What closing the wizard does on the way out: the list drops the escalated
|
|
188
|
+
// selection. The run is not the selection.
|
|
189
|
+
press(() => hook().clear());
|
|
190
|
+
press(() => server?.release());
|
|
191
|
+
await settle();
|
|
192
|
+
const outcome = await run;
|
|
193
|
+
|
|
194
|
+
assert.equal(outcome.cancelled, false);
|
|
195
|
+
assert.equal(outcome.done, TOTAL);
|
|
196
|
+
assert.equal(server?.deleted().length, TOTAL);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("keeps paging the predicate it was started against when the search moves on", async () => {
|
|
200
|
+
const { hook, setQuery } = mountEscalation({ query: "npm" });
|
|
201
|
+
await escalateTo(hook);
|
|
202
|
+
|
|
203
|
+
const run = press(() => hook().runAction({ kind: "delete" }));
|
|
204
|
+
await parkAtBoundary();
|
|
205
|
+
setQuery({ query: "invoices" });
|
|
206
|
+
press(() => server?.release());
|
|
207
|
+
await settle();
|
|
208
|
+
const outcome = await run;
|
|
209
|
+
|
|
210
|
+
assert.equal(outcome.done, TOTAL);
|
|
211
|
+
assert.deepEqual(
|
|
212
|
+
(server?.deleted() ?? []).filter((id) => !id.startsWith("npm-")),
|
|
213
|
+
[],
|
|
214
|
+
"the run followed the query typed after it started",
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("returns the phase to idle only once the run it owns has ended", async () => {
|
|
219
|
+
const { hook } = mountEscalation({ query: "npm" });
|
|
220
|
+
await escalateTo(hook);
|
|
221
|
+
|
|
222
|
+
const run = press(() => hook().runAction({ kind: "delete" }));
|
|
223
|
+
await parkAtBoundary();
|
|
224
|
+
press(() => hook().clear());
|
|
225
|
+
assert.equal(hook().isRunning, true);
|
|
226
|
+
|
|
227
|
+
press(() => server?.release());
|
|
228
|
+
await settle();
|
|
229
|
+
await run;
|
|
230
|
+
|
|
231
|
+
assert.deepEqual(hook().phase, { kind: "idle" });
|
|
232
|
+
assert.equal(hook().isRunning, false);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("stopping the run, which is a different press", () => {
|
|
237
|
+
it("ends it at the next page boundary and says what it reached", async () => {
|
|
238
|
+
const { hook } = mountEscalation({ query: "npm" });
|
|
239
|
+
await escalateTo(hook);
|
|
240
|
+
|
|
241
|
+
const run = press(() => hook().runAction({ kind: "delete" }));
|
|
242
|
+
await parkAtBoundary();
|
|
243
|
+
press(() => hook().stop());
|
|
244
|
+
press(() => server?.release());
|
|
245
|
+
await settle();
|
|
246
|
+
const outcome = await run;
|
|
247
|
+
|
|
248
|
+
assert.equal(outcome.cancelled, true);
|
|
249
|
+
assert.ok(outcome.done > 0, "the batches already sent still count");
|
|
250
|
+
assert.ok(outcome.done < TOTAL, "the run carried on past the stop");
|
|
251
|
+
assert.equal(server?.deleted().length, outcome.done);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("clearing an escalated selection with nothing running", () => {
|
|
256
|
+
it("drops it back to bounded", async () => {
|
|
257
|
+
const { hook } = mountEscalation({ query: "npm" });
|
|
258
|
+
await escalateTo(hook);
|
|
259
|
+
|
|
260
|
+
press(() => hook().clear());
|
|
261
|
+
await settle();
|
|
262
|
+
|
|
263
|
+
assert.deepEqual(hook().phase, { kind: "idle" });
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -82,10 +82,18 @@ export interface UseEscalatedActionsResult {
|
|
|
82
82
|
phase: EscalationPhase;
|
|
83
83
|
/** Begin paging the predicate's full match set to find its total. */
|
|
84
84
|
escalate: () => void;
|
|
85
|
-
/**
|
|
86
|
-
*
|
|
85
|
+
/**
|
|
86
|
+
* Stop whatever's running — counting or an action — at the next page
|
|
87
|
+
* boundary. A no-op when nothing is running. The only thing that ends a run
|
|
88
|
+
* in flight: leaving the selection, the wizard or the search does not.
|
|
89
|
+
*/
|
|
87
90
|
stop: () => void;
|
|
88
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* Drop an escalated selection back to bounded without confirming anything.
|
|
93
|
+
* A run in flight owns the phase and holds it until it ends, so this is a
|
|
94
|
+
* no-op then — the selection the user is leaving and the run they started
|
|
95
|
+
* from it are two different things.
|
|
96
|
+
*/
|
|
89
97
|
clear: () => void;
|
|
90
98
|
/** True while a chunked run (bounded->100 ids, or the escalated predicate)
|
|
91
99
|
* is in flight. */
|
|
@@ -136,40 +144,49 @@ export const useEscalatedActions = ({
|
|
|
136
144
|
undefined,
|
|
137
145
|
);
|
|
138
146
|
const cancelRef = useRef(false);
|
|
147
|
+
// True from the moment a run starts until its outcome is in hand. A run is
|
|
148
|
+
// mail already leaving the mailbox, so nothing that merely changes what the
|
|
149
|
+
// list is showing gets to end it — only `stop`.
|
|
150
|
+
const runningRef = useRef(false);
|
|
139
151
|
const queryClient = useQueryClient();
|
|
140
152
|
const { pushError } = useErrorBanners();
|
|
141
153
|
|
|
142
154
|
// A different search (or leaving search/desktop) makes any in-flight
|
|
143
155
|
// escalation meaningless — it would otherwise keep counting or offering to
|
|
144
|
-
// act on a predicate the visible list no longer reflects.
|
|
145
|
-
//
|
|
156
|
+
// act on a predicate the visible list no longer reflects. The selection goes;
|
|
157
|
+
// a run already going does not. It pages the predicate it was started
|
|
158
|
+
// against and reports what it reached, wherever the list moved on to.
|
|
159
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: enabled/predicateKey are trigger-only — the reset itself reads neither.
|
|
146
160
|
useEffect(() => {
|
|
147
|
-
cancelRef.current = true;
|
|
161
|
+
if (!runningRef.current) cancelRef.current = true;
|
|
148
162
|
setPhase({ kind: "idle" });
|
|
149
163
|
}, [enabled, predicateKey]);
|
|
150
164
|
|
|
151
165
|
const searchQueryRef = useRef(searchQuery);
|
|
152
166
|
searchQueryRef.current = searchQuery;
|
|
153
167
|
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
...
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
continuationToken: data.continuationToken,
|
|
168
|
-
};
|
|
169
|
-
},
|
|
168
|
+
const fetchPagesOf = useCallback(
|
|
169
|
+
(query: EscalationSearchQuery): FetchIdsPage =>
|
|
170
|
+
async (continuationToken) => {
|
|
171
|
+
const { data } = await threadOperationsSearchThreads({
|
|
172
|
+
path: { mailboxId },
|
|
173
|
+
query: { ...query, continuationToken, limit: PAGE_SIZE },
|
|
174
|
+
throwOnError: true,
|
|
175
|
+
});
|
|
176
|
+
return {
|
|
177
|
+
ids: (data.items ?? []).map((item) => item.messageId),
|
|
178
|
+
continuationToken: data.continuationToken,
|
|
179
|
+
};
|
|
180
|
+
},
|
|
170
181
|
[mailboxId],
|
|
171
182
|
);
|
|
172
183
|
|
|
184
|
+
const fetchIdsPage = useCallback<FetchIdsPage>(
|
|
185
|
+
(continuationToken) =>
|
|
186
|
+
fetchPagesOf(searchQueryRef.current)(continuationToken),
|
|
187
|
+
[fetchPagesOf],
|
|
188
|
+
);
|
|
189
|
+
|
|
173
190
|
const applyBatchFor = useCallback(
|
|
174
191
|
(action: EscalatedAction): ApplyBatch =>
|
|
175
192
|
async (ids: string[]) => {
|
|
@@ -248,6 +265,7 @@ export const useEscalatedActions = ({
|
|
|
248
265
|
}, []);
|
|
249
266
|
|
|
250
267
|
const clear = useCallback(() => {
|
|
268
|
+
if (runningRef.current) return;
|
|
251
269
|
cancelRef.current = true;
|
|
252
270
|
setPhase({ kind: "idle" });
|
|
253
271
|
}, []);
|
|
@@ -258,6 +276,7 @@ export const useEscalatedActions = ({
|
|
|
258
276
|
ids?: string[],
|
|
259
277
|
): Promise<BulkRunOutcome> => {
|
|
260
278
|
cancelRef.current = false;
|
|
279
|
+
runningRef.current = true;
|
|
261
280
|
setRunningAction(action);
|
|
262
281
|
// `honestProgress` widens `total` if `done` overtakes it (#109) — the
|
|
263
282
|
// predicate can match more by the time the run re-pages it than
|
|
@@ -265,22 +284,34 @@ export const useEscalatedActions = ({
|
|
|
265
284
|
const onProgress = (next: BulkActionProgress) =>
|
|
266
285
|
setProgress(honestProgress(next));
|
|
267
286
|
const applyBatch = applyBatchFor(action);
|
|
287
|
+
// The predicate as it read when the run was confirmed. The run outlives
|
|
288
|
+
// the screen that started it, so reading the live query on every page
|
|
289
|
+
// would let a search typed afterwards redirect what is being deleted.
|
|
290
|
+
const runPages = fetchPagesOf(searchQueryRef.current);
|
|
268
291
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
292
|
+
// The one invariant nothing may lose: while `runningRef` is up, both
|
|
293
|
+
// `clear` and the reset effect stand down, so a run that never marked
|
|
294
|
+
// itself finished would leave an escalated selection nobody can leave.
|
|
295
|
+
let outcome: BulkRunOutcome;
|
|
296
|
+
try {
|
|
297
|
+
outcome =
|
|
298
|
+
ids !== undefined
|
|
299
|
+
? await runChunkedAction(
|
|
300
|
+
ids,
|
|
301
|
+
applyBatch,
|
|
302
|
+
onProgress,
|
|
303
|
+
() => cancelRef.current,
|
|
304
|
+
)
|
|
305
|
+
: await runPredicateAction(
|
|
306
|
+
runPages,
|
|
307
|
+
phase.kind === "escalated" ? phase.total : 0,
|
|
308
|
+
applyBatch,
|
|
309
|
+
onProgress,
|
|
310
|
+
() => cancelRef.current,
|
|
311
|
+
);
|
|
312
|
+
} finally {
|
|
313
|
+
runningRef.current = false;
|
|
314
|
+
}
|
|
284
315
|
|
|
285
316
|
setRunningAction(undefined);
|
|
286
317
|
setProgress(undefined);
|
|
@@ -300,7 +331,7 @@ export const useEscalatedActions = ({
|
|
|
300
331
|
}
|
|
301
332
|
return outcome;
|
|
302
333
|
},
|
|
303
|
-
[applyBatchFor,
|
|
334
|
+
[applyBatchFor, fetchPagesOf, phase, pushError, invalidateAfterRun],
|
|
304
335
|
);
|
|
305
336
|
|
|
306
337
|
return {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { useTranslation } from "react-i18next";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `labelForMailbox` expects a positional `(key, fallback)` translator; i18next's
|
|
6
|
+
* `t` reads its second argument as an options object, which drops the fallback.
|
|
7
|
+
* Memoized so callers can hold it as a `useMemo` dependency.
|
|
8
|
+
*/
|
|
9
|
+
export const useFolderLabelTranslator = (): ((
|
|
10
|
+
key: string,
|
|
11
|
+
fallback: string,
|
|
12
|
+
) => string) => {
|
|
13
|
+
const { t } = useTranslation("mail", { useSuspense: false });
|
|
14
|
+
return useCallback(
|
|
15
|
+
(key: string, fallback: string): string =>
|
|
16
|
+
t(key, { defaultValue: fallback }),
|
|
17
|
+
[t],
|
|
18
|
+
);
|
|
19
|
+
};
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
bulkActionFailureTitle,
|
|
8
8
|
bulkActionProgressLabel,
|
|
9
9
|
bulkActionProgressTone,
|
|
10
|
+
bulkActionStoppedDetail,
|
|
11
|
+
bulkActionStoppedTitle,
|
|
10
12
|
} from "./bulk-action-copy.js";
|
|
11
13
|
|
|
12
14
|
const kinds: BulkActionKind[] = ["delete", "move", "markRead"];
|
|
@@ -65,6 +67,27 @@ describe("bulkActionFailureTitle", () => {
|
|
|
65
67
|
});
|
|
66
68
|
});
|
|
67
69
|
|
|
70
|
+
describe("a run that stopped short of its match", () => {
|
|
71
|
+
test("states what it reached and that the rest is untouched", () => {
|
|
72
|
+
assert.equal(bulkActionStoppedTitle(1200), "Stopped after 1,200");
|
|
73
|
+
assert.equal(
|
|
74
|
+
bulkActionStoppedDetail("delete", 1200, 3412),
|
|
75
|
+
"1,200 of 3,412 moved to Trash. Nothing was sent for the rest, so they are untouched.",
|
|
76
|
+
);
|
|
77
|
+
assert.equal(
|
|
78
|
+
bulkActionStoppedDetail("markRead", 1200, 3412),
|
|
79
|
+
"1,200 of 3,412 marked as read. Nothing was sent for the rest, so they are untouched.",
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("never reads as a rejection — nothing was sent", () => {
|
|
84
|
+
assert.doesNotMatch(
|
|
85
|
+
bulkActionStoppedDetail("move", 1, 2),
|
|
86
|
+
/rejected|couldn't/i,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
68
91
|
describe("every action carries its own wording", () => {
|
|
69
92
|
test("no two actions share a sentence", () => {
|
|
70
93
|
const sentences: Array<(kind: BulkActionKind) => string> = [
|
|
@@ -73,6 +96,7 @@ describe("every action carries its own wording", () => {
|
|
|
73
96
|
(kind) => bulkActionFailureTitle(kind, 5),
|
|
74
97
|
bulkActionFailureDetail,
|
|
75
98
|
(kind) => bulkActionProgressLabel(kind, 1, 2),
|
|
99
|
+
(kind) => bulkActionStoppedDetail(kind, 1, 2),
|
|
76
100
|
];
|
|
77
101
|
for (const render of sentences) {
|
|
78
102
|
assert.equal(new Set(kinds.map(render)).size, kinds.length);
|
|
@@ -59,6 +59,22 @@ export const bulkActionCompletionText = (
|
|
|
59
59
|
): string =>
|
|
60
60
|
`${formatNumber(done)} ${pastTense[kind]}. Your mail server is still catching up.`;
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Shown when a run ended before it covered what it was started against. The
|
|
64
|
+
* remainder was never sent, so the mail is where it was and only the user can
|
|
65
|
+
* decide to run it again — which is why this is stated rather than left to a
|
|
66
|
+
* list that quietly stops changing.
|
|
67
|
+
*/
|
|
68
|
+
export const bulkActionStoppedTitle = (done: number): string =>
|
|
69
|
+
`Stopped after ${formatNumber(done)}`;
|
|
70
|
+
|
|
71
|
+
export const bulkActionStoppedDetail = (
|
|
72
|
+
kind: BulkActionKind,
|
|
73
|
+
done: number,
|
|
74
|
+
total: number,
|
|
75
|
+
): string =>
|
|
76
|
+
`${formatNumber(done)} of ${formatNumber(total)} ${pastTense[kind]}. Nothing was sent for the rest, so they are untouched.`;
|
|
77
|
+
|
|
62
78
|
/** Error-banner title for a run stopped by an infrastructure failure. */
|
|
63
79
|
export const bulkActionFailureTitle = (
|
|
64
80
|
kind: BulkActionKind,
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { describe, test } from "node:test";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import type {
|
|
6
|
+
RemitImapFolderAppointment,
|
|
7
|
+
RemitImapMailboxResponse,
|
|
8
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
9
|
+
import { buildMoveOptions } from "./move-options.js";
|
|
10
|
+
|
|
11
|
+
const englishBundle = JSON.parse(
|
|
12
|
+
readFileSync(
|
|
13
|
+
fileURLToPath(
|
|
14
|
+
new URL("../../public/locales/en/mail.json", import.meta.url),
|
|
15
|
+
),
|
|
16
|
+
"utf8",
|
|
17
|
+
),
|
|
18
|
+
) as { sidebar: Record<string, string> };
|
|
19
|
+
|
|
20
|
+
// The picker is handed the same shipped English bundle the app loads, so the
|
|
21
|
+
// expected labels are the product's own, not strings restated by the test.
|
|
22
|
+
const translate = (key: string, fallback: string): string =>
|
|
23
|
+
englishBundle.sidebar[key.replace("sidebar.", "")] ?? fallback;
|
|
24
|
+
|
|
25
|
+
const make = (
|
|
26
|
+
overrides: Partial<RemitImapMailboxResponse> & {
|
|
27
|
+
mailboxId: string;
|
|
28
|
+
fullPath: string;
|
|
29
|
+
},
|
|
30
|
+
): RemitImapMailboxResponse =>
|
|
31
|
+
({
|
|
32
|
+
accountId: "acct-1",
|
|
33
|
+
namespaceType: "personal",
|
|
34
|
+
namespacePrefix: "",
|
|
35
|
+
hierarchyDelimiter: "/",
|
|
36
|
+
messageCount: 0,
|
|
37
|
+
unseenCount: 0,
|
|
38
|
+
deletedCount: 0,
|
|
39
|
+
createdAt: 0,
|
|
40
|
+
updatedAt: 0,
|
|
41
|
+
...overrides,
|
|
42
|
+
}) as RemitImapMailboxResponse;
|
|
43
|
+
|
|
44
|
+
const appoint = (
|
|
45
|
+
role: RemitImapFolderAppointment["role"],
|
|
46
|
+
mailboxId: string,
|
|
47
|
+
): RemitImapFolderAppointment => ({ role, mailboxId });
|
|
48
|
+
|
|
49
|
+
const applePaths = [
|
|
50
|
+
make({ mailboxId: "mb-inbox", fullPath: "INBOX" }),
|
|
51
|
+
make({ mailboxId: "mb-trash", fullPath: "INBOX/Deleted Messages" }),
|
|
52
|
+
make({ mailboxId: "mb-junk", fullPath: "INBOX/Junk" }),
|
|
53
|
+
make({ mailboxId: "mb-receipts", fullPath: "INBOX/Receipts" }),
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const appleAppointments = [
|
|
57
|
+
appoint("Inbox", "mb-inbox"),
|
|
58
|
+
appoint("Trash", "mb-trash"),
|
|
59
|
+
appoint("Junk", "mb-junk"),
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const labelOf = (
|
|
63
|
+
options: ReturnType<typeof buildMoveOptions>,
|
|
64
|
+
id: string,
|
|
65
|
+
): string | undefined => options.find((option) => option.id === id)?.label;
|
|
66
|
+
|
|
67
|
+
describe("buildMoveOptions", () => {
|
|
68
|
+
test("labels appointed folders by role, not by the provider's leaf", () => {
|
|
69
|
+
const options = buildMoveOptions({
|
|
70
|
+
mailboxes: applePaths,
|
|
71
|
+
folderAppointments: appleAppointments,
|
|
72
|
+
translator: translate,
|
|
73
|
+
});
|
|
74
|
+
assert.equal(labelOf(options, "mb-inbox"), "Inbox");
|
|
75
|
+
assert.equal(labelOf(options, "mb-trash"), "Trash");
|
|
76
|
+
assert.equal(labelOf(options, "mb-junk"), "Spam");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a displayNameOverride wins over the role label", () => {
|
|
80
|
+
const options = buildMoveOptions({
|
|
81
|
+
mailboxes: [
|
|
82
|
+
make({
|
|
83
|
+
mailboxId: "mb-trash",
|
|
84
|
+
fullPath: "INBOX/Deleted Messages",
|
|
85
|
+
displayNameOverride: "Bin",
|
|
86
|
+
}),
|
|
87
|
+
],
|
|
88
|
+
folderAppointments: [appoint("Trash", "mb-trash")],
|
|
89
|
+
translator: translate,
|
|
90
|
+
});
|
|
91
|
+
assert.equal(labelOf(options, "mb-trash"), "Bin");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("an unappointed folder keeps its own name", () => {
|
|
95
|
+
const options = buildMoveOptions({
|
|
96
|
+
mailboxes: applePaths,
|
|
97
|
+
folderAppointments: appleAppointments,
|
|
98
|
+
translator: translate,
|
|
99
|
+
});
|
|
100
|
+
assert.equal(labelOf(options, "mb-receipts"), "Receipts");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("the full provider path stays searchable", () => {
|
|
104
|
+
const options = buildMoveOptions({
|
|
105
|
+
mailboxes: applePaths,
|
|
106
|
+
folderAppointments: appleAppointments,
|
|
107
|
+
translator: translate,
|
|
108
|
+
});
|
|
109
|
+
assert.equal(
|
|
110
|
+
options.find((option) => option.id === "mb-trash")?.searchValue,
|
|
111
|
+
"INBOX/Deleted Messages",
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("the current mailbox is marked and nothing else is", () => {
|
|
116
|
+
const options = buildMoveOptions({
|
|
117
|
+
mailboxes: applePaths,
|
|
118
|
+
folderAppointments: appleAppointments,
|
|
119
|
+
currentMailboxId: "mb-junk",
|
|
120
|
+
translator: translate,
|
|
121
|
+
});
|
|
122
|
+
assert.deepStrictEqual(
|
|
123
|
+
options.filter((option) => option.isCurrent).map((option) => option.id),
|
|
124
|
+
["mb-junk"],
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("an excluded mailbox is not offered as a destination", () => {
|
|
129
|
+
const options = buildMoveOptions({
|
|
130
|
+
mailboxes: applePaths,
|
|
131
|
+
folderAppointments: appleAppointments,
|
|
132
|
+
excludeMailboxId: "mb-receipts",
|
|
133
|
+
translator: translate,
|
|
134
|
+
});
|
|
135
|
+
assert.equal(
|
|
136
|
+
options.some((option) => option.id === "mb-receipts"),
|
|
137
|
+
false,
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RemitImapFolderAppointment,
|
|
3
|
+
RemitImapMailboxResponse,
|
|
4
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
5
|
+
import type { MoveMailboxOption } from "@remit/ui";
|
|
6
|
+
import { excludeFolder } from "./delete-folder.js";
|
|
7
|
+
import { buildMailboxRoleMap, labelForMailbox } from "./folder-roles.js";
|
|
8
|
+
import { buildMoveTargets } from "./move-targets.js";
|
|
9
|
+
|
|
10
|
+
type Translator = (key: string, fallback: string) => string;
|
|
11
|
+
|
|
12
|
+
interface MoveOptionsInput {
|
|
13
|
+
mailboxes: readonly RemitImapMailboxResponse[];
|
|
14
|
+
folderAppointments: readonly RemitImapFolderAppointment[];
|
|
15
|
+
/** Rendered as a non-selectable row, so the user sees where mail lives now. */
|
|
16
|
+
currentMailboxId?: string;
|
|
17
|
+
/** Dropped entirely — the folder being deleted is not its own destination. */
|
|
18
|
+
excludeMailboxId?: string;
|
|
19
|
+
translator?: Translator;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The single shaping of a mailbox list into Move-to picker options. Labels
|
|
24
|
+
* follow the account's role appointments (RFC 032, #976) and any
|
|
25
|
+
* `displayNameOverride`, so a picker reads `Inbox`/`Trash` rather than the
|
|
26
|
+
* provider's leaf; the full path stays searchable.
|
|
27
|
+
*/
|
|
28
|
+
export const buildMoveOptions = ({
|
|
29
|
+
mailboxes,
|
|
30
|
+
folderAppointments,
|
|
31
|
+
currentMailboxId,
|
|
32
|
+
excludeMailboxId,
|
|
33
|
+
translator,
|
|
34
|
+
}: MoveOptionsInput): MoveMailboxOption[] => {
|
|
35
|
+
const targets = buildMoveTargets(mailboxes, folderAppointments);
|
|
36
|
+
const roleMap = buildMailboxRoleMap(folderAppointments);
|
|
37
|
+
const destinations = excludeMailboxId
|
|
38
|
+
? excludeFolder(targets, excludeMailboxId)
|
|
39
|
+
: targets;
|
|
40
|
+
return destinations.map((mailbox) => ({
|
|
41
|
+
id: mailbox.mailboxId,
|
|
42
|
+
label: labelForMailbox(mailbox, roleMap.get(mailbox.mailboxId), translator),
|
|
43
|
+
searchValue: mailbox.fullPath,
|
|
44
|
+
isCurrent: mailbox.mailboxId === currentMailboxId,
|
|
45
|
+
}));
|
|
46
|
+
};
|