@remit/web-client 0.0.199 → 0.0.201
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/compose/AddressField.tsx +14 -3
- package/src/components/compose/ComposeForm.tsx +156 -34
- package/src/components/mail/SelectionWizardHost.search-entry.test.ts +71 -30
- package/src/components/mail/SelectionWizardHost.tsx +68 -61
- package/src/components/mail/selection-surface-rollcall.test.ts +270 -0
- package/src/components/mail/selection-wizard-run-controls.test.ts +230 -0
- package/src/components/mail/selection-wizard-run-controls.ts +101 -0
- package/src/components/mail/BriefPane.selection.test.ts +0 -51
- package/src/components/mail/DailyBrief.selection.test.ts +0 -75
- package/src/components/mail/MessageList.selection.test.ts +0 -195
- package/src/components/mail/SelectionWizardHost.job-status.test.ts +0 -50
- package/src/components/mail/SelectionWizardHost.run-exit.test.ts +0 -83
- package/src/components/mail/make-filter-entry.test.ts +0 -63
- package/src/components/mail/selection-verbs.test.ts +0 -285
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
3
|
import {
|
|
3
4
|
type ClauseDraft,
|
|
4
5
|
type ClauseEditState,
|
|
@@ -72,6 +73,12 @@ import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
|
|
|
72
73
|
import { useWizardEntryValue, useWizardStep } from "@/lib/wizard-history";
|
|
73
74
|
import type { WizardSelectionMessage } from "@/lib/wizard-selection";
|
|
74
75
|
import { organizeRunState } from "./organize-run-state";
|
|
76
|
+
import {
|
|
77
|
+
retryIntent,
|
|
78
|
+
runIsInFlight,
|
|
79
|
+
stopRunner,
|
|
80
|
+
wizardExit,
|
|
81
|
+
} from "./selection-wizard-run-controls";
|
|
75
82
|
|
|
76
83
|
const EMPTY_DRAFT: WizardDraft = { clauses: [], matchOperator: "any" };
|
|
77
84
|
|
|
@@ -171,6 +178,34 @@ const NO_CONVERSION: SearchConversion = {
|
|
|
171
178
|
/** A search entry has nothing ticked; its match is the query's clauses (#477 3.2). */
|
|
172
179
|
const EMPTY_SELECTION: readonly WizardSelectionMessage[] = [];
|
|
173
180
|
|
|
181
|
+
/**
|
|
182
|
+
* What a search entry overrides on the props the surface handed over. Its rule
|
|
183
|
+
* belongs to the account the surface named, and to the one the query names when
|
|
184
|
+
* it names one (#524) — reaching past both for the first configured account
|
|
185
|
+
* writes the rule against whichever account happens to be first in the list, and
|
|
186
|
+
* the mail the user was looking at is never filtered.
|
|
187
|
+
*/
|
|
188
|
+
export const searchEntryOverrides = (
|
|
189
|
+
conversion: SearchConversion,
|
|
190
|
+
props: Pick<SelectionWizardHostProps, "accountId">,
|
|
191
|
+
accounts: readonly Pick<RemitImapAccountResponse, "accountId">[],
|
|
192
|
+
): Pick<
|
|
193
|
+
SelectionWizardSessionProps,
|
|
194
|
+
| "verb"
|
|
195
|
+
| "accountId"
|
|
196
|
+
| "selection"
|
|
197
|
+
| "selectionRestriction"
|
|
198
|
+
| "escalated"
|
|
199
|
+
| "searchConversion"
|
|
200
|
+
> => ({
|
|
201
|
+
verb: "organize",
|
|
202
|
+
accountId: searchRuleAccountId(conversion, props.accountId, accounts),
|
|
203
|
+
selection: EMPTY_SELECTION,
|
|
204
|
+
selectionRestriction: undefined,
|
|
205
|
+
escalated: undefined,
|
|
206
|
+
searchConversion: conversion,
|
|
207
|
+
});
|
|
208
|
+
|
|
174
209
|
/** How far a commit has got, whichever of the three ways it took. */
|
|
175
210
|
interface RunSnapshot {
|
|
176
211
|
state: RunState;
|
|
@@ -851,42 +886,39 @@ function SelectionWizardSession({
|
|
|
851
886
|
// walking back to Review, which would push an entry the wizard does not own
|
|
852
887
|
// and leave Cancel rewinding to a step instead of out.
|
|
853
888
|
const retry = (): void => {
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
889
|
+
const intent = retryIntent({
|
|
890
|
+
runState: run.state,
|
|
891
|
+
isEscalated: escalated !== undefined,
|
|
892
|
+
committedScope,
|
|
893
|
+
createFilterFailed: createFilter.isError,
|
|
894
|
+
backApplyPending: backApplyDraft !== undefined,
|
|
895
|
+
widenRunsAsJob: widenedRunsAsJob(verb),
|
|
896
|
+
failedIds: bulkRun?.outcome?.failedIds ?? [],
|
|
897
|
+
sent: bulkRun?.sent ?? bulkTargets,
|
|
898
|
+
});
|
|
899
|
+
switch (intent.kind) {
|
|
900
|
+
case "refreshStatus":
|
|
901
|
+
organizeJob.refreshStatus();
|
|
902
|
+
return;
|
|
903
|
+
case "rerunEscalated":
|
|
904
|
+
void runEscalated();
|
|
905
|
+
return;
|
|
906
|
+
case "resetAndResend":
|
|
869
907
|
createFilter.reset();
|
|
870
908
|
sendCommit();
|
|
871
909
|
return;
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
910
|
+
case "resend":
|
|
911
|
+
sendCommit();
|
|
912
|
+
return;
|
|
913
|
+
case "startBackApply":
|
|
914
|
+
if (backApplyDraft) startJob(backApplyDraft);
|
|
915
|
+
return;
|
|
916
|
+
case "waitOnJob":
|
|
917
|
+
return;
|
|
918
|
+
case "rerunBulk":
|
|
919
|
+
void runBulk(intent.targets);
|
|
920
|
+
return;
|
|
879
921
|
}
|
|
880
|
-
const outstanding = new Set(bulkRun?.outcome?.failedIds ?? []);
|
|
881
|
-
// A run hands back ids, and each one's account came from the batch it was
|
|
882
|
-
// sent in — so a retry re-sends the targets the run was given, filtered to
|
|
883
|
-
// what it never reached.
|
|
884
|
-
const sent = bulkRun?.sent ?? bulkTargets;
|
|
885
|
-
void runBulk(
|
|
886
|
-
outstanding.size > 0
|
|
887
|
-
? sent.filter((target) => outstanding.has(target.id))
|
|
888
|
-
: sent,
|
|
889
|
-
);
|
|
890
922
|
};
|
|
891
923
|
|
|
892
924
|
// Cancel rewinds the entries the wizard owns and leaves the selection where
|
|
@@ -905,21 +937,9 @@ function SelectionWizardSession({
|
|
|
905
937
|
onFinished();
|
|
906
938
|
}, [closeWizard, steps, current, onFinished]);
|
|
907
939
|
|
|
908
|
-
// Ends the run rather than leaving it. The escalated predicate is run by the
|
|
909
|
-
// list's runner and a bounded selection by this wizard's own, so the stop
|
|
910
|
-
// follows whichever one is paging.
|
|
911
940
|
const { stop: stopBulk } = bulk;
|
|
912
|
-
// A commit that never started has nothing paging and nothing to end.
|
|
913
|
-
const runInFlight =
|
|
914
|
-
bulkRun !== undefined &&
|
|
915
|
-
bulkRun.outcome === undefined &&
|
|
916
|
-
bulkRun.failureReason === undefined;
|
|
917
941
|
const stopRun = useCallback(() => {
|
|
918
|
-
|
|
919
|
-
escalated.stop();
|
|
920
|
-
return;
|
|
921
|
-
}
|
|
922
|
-
stopBulk();
|
|
942
|
+
stopRunner(escalated, stopBulk);
|
|
923
943
|
}, [escalated, stopBulk]);
|
|
924
944
|
|
|
925
945
|
// Hardware back on the run screen leaves the wizard, the movement the header
|
|
@@ -971,7 +991,7 @@ function SelectionWizardSession({
|
|
|
971
991
|
// Every exit from the run screen is the same movement: the header's X,
|
|
972
992
|
// the header arrow, the footer and hardware Back all leave a run that
|
|
973
993
|
// has already been sent, and all of them drop the selection with it.
|
|
974
|
-
onExit={current
|
|
994
|
+
onExit={wizardExit(current, { dismiss, cancel })}
|
|
975
995
|
onContinue={advance}
|
|
976
996
|
onCommit={commit}
|
|
977
997
|
match={{
|
|
@@ -1051,7 +1071,7 @@ function SelectionWizardSession({
|
|
|
1051
1071
|
failureReason: run.failureReason,
|
|
1052
1072
|
onRetry: retry,
|
|
1053
1073
|
onDismiss: dismiss,
|
|
1054
|
-
onCancelRun:
|
|
1074
|
+
onCancelRun: runIsInFlight(bulkRun) ? stopRun : undefined,
|
|
1055
1075
|
}}
|
|
1056
1076
|
/>
|
|
1057
1077
|
);
|
|
@@ -1084,20 +1104,7 @@ export function SelectionWizardHost(props: SelectionWizardHostProps) {
|
|
|
1084
1104
|
return (
|
|
1085
1105
|
<SelectionWizardSession
|
|
1086
1106
|
{...props}
|
|
1087
|
-
{...(conversion
|
|
1088
|
-
? {
|
|
1089
|
-
verb: "organize" as const,
|
|
1090
|
-
accountId: searchRuleAccountId(
|
|
1091
|
-
conversion,
|
|
1092
|
-
props.accountId,
|
|
1093
|
-
accounts,
|
|
1094
|
-
),
|
|
1095
|
-
selection: EMPTY_SELECTION,
|
|
1096
|
-
selectionRestriction: undefined,
|
|
1097
|
-
escalated: undefined,
|
|
1098
|
-
searchConversion: conversion,
|
|
1099
|
-
}
|
|
1100
|
-
: {})}
|
|
1107
|
+
{...(conversion ? searchEntryOverrides(conversion, props, accounts) : {})}
|
|
1101
1108
|
step={step}
|
|
1102
1109
|
goToStep={goToStep}
|
|
1103
1110
|
goBack={goBack}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The roll-call no run can take: which surfaces exist, and which function each
|
|
3
|
+
* one's verbs name.
|
|
4
|
+
*
|
|
5
|
+
* Everything a selection surface *does* — the review screen naming what a verb
|
|
6
|
+
* covers, a run reaching the mail server — is driven end to end by
|
|
7
|
+
* `packages/e2e`. What no run observes is a surface that was never wired: a
|
|
8
|
+
* fourth pane growing its own direct handler while every other route was moved,
|
|
9
|
+
* a fourth list rendering the make-filter row with no wizard behind it, or a
|
|
10
|
+
* verb on a bar nobody presses in a spec quietly keeping a direct call. The
|
|
11
|
+
* suite has locators for Delete, Move and Organize and none for Junk or
|
|
12
|
+
* Mark-read, so three of the five are reachable only from here.
|
|
13
|
+
*
|
|
14
|
+
* So this reads the source, and reads it for the census: who raises a bar, what
|
|
15
|
+
* each of its verbs is wired to, who declares a keyboard verb over a selection,
|
|
16
|
+
* who renders the make-filter row. Every census is discovered from the
|
|
17
|
+
* directory rather than listed, so a fourth surface is a failure here until it
|
|
18
|
+
* is wired like the other three.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import assert from "node:assert/strict";
|
|
22
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
23
|
+
import { dirname, resolve } from "node:path";
|
|
24
|
+
import { describe, it } from "node:test";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
|
|
27
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const read = (file: string): string =>
|
|
29
|
+
readFileSync(resolve(here, file), "utf8");
|
|
30
|
+
|
|
31
|
+
const sourceFiles = (): string[] =>
|
|
32
|
+
readdirSync(here).filter((file) => file.endsWith(".tsx"));
|
|
33
|
+
|
|
34
|
+
const BAR_VERB_PROPS = [
|
|
35
|
+
"onDelete",
|
|
36
|
+
"onMove",
|
|
37
|
+
"onOrganize",
|
|
38
|
+
"onJunk",
|
|
39
|
+
"onMarkRead",
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How a surface may answer a verb prop. `startWizard`/`wizard.start`/
|
|
44
|
+
* `startSelectionVerb` open the wizard on the verb; `organizeSelection` and
|
|
45
|
+
* `startFromSearch` open it on the search entry, which is the same wizard
|
|
46
|
+
* reached by its other door (#477 1.8). Anything else routes around the review
|
|
47
|
+
* screen that names what the verb covers (#477 1.4).
|
|
48
|
+
*/
|
|
49
|
+
const OPENS_THE_WIZARD =
|
|
50
|
+
/^(startWizard|wizard\.start|startSelectionVerb|organizeSelection|startFromSearch)\b/;
|
|
51
|
+
|
|
52
|
+
const bracedFrom = (source: string, open: number): string => {
|
|
53
|
+
let depth = 0;
|
|
54
|
+
for (let i = open; i < source.length; i++) {
|
|
55
|
+
if (source[i] === "{") depth++;
|
|
56
|
+
else if (source[i] === "}") {
|
|
57
|
+
depth--;
|
|
58
|
+
if (depth === 0) return source.slice(open, i + 1);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return source.slice(open);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const namedCallbackBody = (source: string, name: string): string => {
|
|
65
|
+
const at = source.indexOf(`const ${name} = useCallback(`);
|
|
66
|
+
if (at === -1) return "";
|
|
67
|
+
const open = source.indexOf("{", at);
|
|
68
|
+
return open === -1 ? "" : bracedFrom(source, open);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The bar element as written, to its own closing tag. Found by matching tags
|
|
73
|
+
* rather than by stopping at the first `/>` on a line: a nested self-closing
|
|
74
|
+
* element matches that too, so the scan would end early and silently skip
|
|
75
|
+
* whatever a surface happened to write after it.
|
|
76
|
+
*/
|
|
77
|
+
const barMarkup = (source: string): string => {
|
|
78
|
+
const start = source.indexOf("<SelectionTopBar");
|
|
79
|
+
if (start === -1) return "";
|
|
80
|
+
let depth = 0;
|
|
81
|
+
for (let i = start; i < source.length; i++) {
|
|
82
|
+
if (source[i] === "<" && /[A-Za-z]/.test(source[i + 1] ?? "")) depth++;
|
|
83
|
+
else if (source.startsWith("/>", i)) {
|
|
84
|
+
depth--;
|
|
85
|
+
if (depth === 0) return source.slice(start, i + 2);
|
|
86
|
+
i++;
|
|
87
|
+
} else if (source.startsWith("</", i)) depth--;
|
|
88
|
+
}
|
|
89
|
+
return "";
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* What a verb prop is wired to, with the whitespace, the guard a verb is
|
|
94
|
+
* offered behind, and the arrow taken off — what is left is the call itself.
|
|
95
|
+
*/
|
|
96
|
+
const verbHandler = (bar: string, prop: string): string | undefined => {
|
|
97
|
+
const assigned = bar.match(new RegExp(`${prop}=\\{([\\s\\S]*?)\\}\\n`))?.[1];
|
|
98
|
+
if (assigned === undefined) return undefined;
|
|
99
|
+
return assigned
|
|
100
|
+
.replace(/\s+/g, " ")
|
|
101
|
+
.replace(/^.*? \? /, "")
|
|
102
|
+
.replace(/ : undefined$/, "")
|
|
103
|
+
.replace(/^\(\)\s*=>\s*/, "")
|
|
104
|
+
.trim();
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const declaredHandlers = (source: string): string[] => {
|
|
108
|
+
const at = source.indexOf("handlers: {");
|
|
109
|
+
if (at === -1) return [];
|
|
110
|
+
return Array.from(
|
|
111
|
+
bracedFrom(source, source.indexOf("{", at)).matchAll(/\n\t{3}(\w+): /g),
|
|
112
|
+
(match) => match[1],
|
|
113
|
+
);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* What a pane does for one keyboard verb: the handler it wired into the triage
|
|
118
|
+
* layer, plus any callback that handler defers to for its target. A handler
|
|
119
|
+
* that offers the list the press and then falls through to a helper picking the
|
|
120
|
+
* selection is exactly as wrong as one picking it inline, so both are read.
|
|
121
|
+
*/
|
|
122
|
+
const paneVerbBody = (source: string, handler: string): string => {
|
|
123
|
+
const at = source.search(new RegExp(`\\n\\t{3}${handler}: `));
|
|
124
|
+
if (at === -1) return "";
|
|
125
|
+
const value =
|
|
126
|
+
source.slice(at).match(new RegExp(`${handler}: (.*)`))?.[1] ?? "";
|
|
127
|
+
const body = value.startsWith("(")
|
|
128
|
+
? bracedFrom(source, source.indexOf("{", at))
|
|
129
|
+
: namedCallbackBody(source, value.replace(/[,\s].*$/, ""));
|
|
130
|
+
const helpers = Array.from(body.matchAll(/\b(\w+MessageIds)\(\)/g), (match) =>
|
|
131
|
+
namedCallbackBody(source, match[1]),
|
|
132
|
+
);
|
|
133
|
+
return [body, ...helpers].join("\n");
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
describe("the surfaces that raise a selection bar", () => {
|
|
137
|
+
const surfaces = (): string[] =>
|
|
138
|
+
sourceFiles()
|
|
139
|
+
.filter((file) => /<SelectionTopBar/.test(read(file)))
|
|
140
|
+
.sort();
|
|
141
|
+
|
|
142
|
+
it("are these three and no others", () => {
|
|
143
|
+
assert.deepEqual(surfaces(), [
|
|
144
|
+
"DailyBrief.tsx",
|
|
145
|
+
"MessageList.tsx",
|
|
146
|
+
"ThreadListInteraction.tsx",
|
|
147
|
+
]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
for (const file of surfaces()) {
|
|
151
|
+
it(`${file} routes every verb it offers through the wizard`, () => {
|
|
152
|
+
const bar = barMarkup(read(file));
|
|
153
|
+
assert.notEqual(bar, "", `${file} renders a SelectionTopBar`);
|
|
154
|
+
const offered = BAR_VERB_PROPS.filter((prop) => bar.includes(`${prop}=`));
|
|
155
|
+
assert.ok(offered.length > 0, `${file} offers at least one verb`);
|
|
156
|
+
for (const prop of offered) {
|
|
157
|
+
const handler = verbHandler(bar, prop);
|
|
158
|
+
assert.ok(handler, `${file}'s ${prop} is wired to something`);
|
|
159
|
+
assert.match(
|
|
160
|
+
handler,
|
|
161
|
+
OPENS_THE_WIZARD,
|
|
162
|
+
`${file}'s ${prop} must open the wizard, not run the verb`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (const file of ["DailyBrief.tsx", "MessageList.tsx"]) {
|
|
169
|
+
it(`${file} drops none of the five verbs from the bar it raises`, () => {
|
|
170
|
+
const bar = barMarkup(read(file));
|
|
171
|
+
for (const prop of BAR_VERB_PROPS) {
|
|
172
|
+
assert.match(bar, new RegExp(`${prop}=`), `${file} offers ${prop}`);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// A pane that runs a verb over `selectedIds` without offering the list the
|
|
179
|
+
// press first is the hole no run can see: the pane's fallback and the list's
|
|
180
|
+
// claim are indistinguishable from outside until a selection outlives the
|
|
181
|
+
// list's commands. That the list claims the press is rendered in
|
|
182
|
+
// `./ThreadListInteraction.test.ts`.
|
|
183
|
+
describe("the panes that answer a keyboard verb over a selection", () => {
|
|
184
|
+
const PANE_VERB_ROUTE: Record<string, string> = {
|
|
185
|
+
delete: "delete",
|
|
186
|
+
toggleRead: "markRead",
|
|
187
|
+
markJunk: "junk",
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const panes = (): string[] =>
|
|
191
|
+
sourceFiles()
|
|
192
|
+
.filter((file) => read(file).includes("handlers: {"))
|
|
193
|
+
.sort();
|
|
194
|
+
|
|
195
|
+
const routed = (): string[] =>
|
|
196
|
+
panes()
|
|
197
|
+
.flatMap((file) =>
|
|
198
|
+
declaredHandlers(read(file))
|
|
199
|
+
.filter((handler) => handler in PANE_VERB_ROUTE)
|
|
200
|
+
.map((handler) => `${file}:${handler}`),
|
|
201
|
+
)
|
|
202
|
+
.sort();
|
|
203
|
+
|
|
204
|
+
it("are these three panes and no others", () => {
|
|
205
|
+
assert.deepEqual(panes(), [
|
|
206
|
+
"BriefPane.tsx",
|
|
207
|
+
"FlaggedPane.tsx",
|
|
208
|
+
"MailboxPane.tsx",
|
|
209
|
+
]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("declare these seven routed handlers and no others", () => {
|
|
213
|
+
assert.deepEqual(routed(), [
|
|
214
|
+
"BriefPane.tsx:delete",
|
|
215
|
+
"BriefPane.tsx:toggleRead",
|
|
216
|
+
"FlaggedPane.tsx:delete",
|
|
217
|
+
"FlaggedPane.tsx:toggleRead",
|
|
218
|
+
"MailboxPane.tsx:delete",
|
|
219
|
+
"MailboxPane.tsx:markJunk",
|
|
220
|
+
"MailboxPane.tsx:toggleRead",
|
|
221
|
+
]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
for (const entry of routed()) {
|
|
225
|
+
const [file, handler] = entry.split(":");
|
|
226
|
+
const verb = PANE_VERB_ROUTE[handler];
|
|
227
|
+
|
|
228
|
+
it(`${entry} offers the list ${verb} before running anything`, () => {
|
|
229
|
+
const body = paneVerbBody(read(file), handler);
|
|
230
|
+
assert.notEqual(body, "", `${handler}'s body was found`);
|
|
231
|
+
assert.match(body, new RegExp(`requestVerb\\("${verb}"\\)`));
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it(`${entry} falls back to the focused row, never to a selection`, () => {
|
|
235
|
+
const body = paneVerbBody(read(file), handler);
|
|
236
|
+
assert.notEqual(body, "", `${handler}'s body was found`);
|
|
237
|
+
assert.doesNotMatch(body, /selectedIds/i);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// The row is built once by `MailListHeader` and handed down as `makeFilterSlot`,
|
|
243
|
+
// but the wizard answering the step it pushes is mounted per surface. A surface
|
|
244
|
+
// that renders the row without one leaves the press on a URL nothing answers.
|
|
245
|
+
describe("the surfaces that render the make-filter row", () => {
|
|
246
|
+
const ANSWERED_BY: Record<string, string> = {
|
|
247
|
+
"DailyBrief.tsx": "DailyBrief.tsx",
|
|
248
|
+
"MessageList.tsx": "MessageList.tsx",
|
|
249
|
+
"ThreadListInteraction.tsx": "FlaggedList.tsx",
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
it("are these three and no others", () => {
|
|
253
|
+
assert.deepEqual(
|
|
254
|
+
sourceFiles()
|
|
255
|
+
.filter((file) => /\.makeFilterSlot/.test(read(file)))
|
|
256
|
+
.sort(),
|
|
257
|
+
Object.keys(ANSWERED_BY).sort(),
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("each name a file that mounts a wizard to answer the step", () => {
|
|
262
|
+
for (const [surface, host] of Object.entries(ANSWERED_BY)) {
|
|
263
|
+
assert.match(
|
|
264
|
+
read(host),
|
|
265
|
+
/<SelectionWizardHost/,
|
|
266
|
+
`${surface} is answered by ${host}`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the run screen's controls decide (#521, #526).
|
|
3
|
+
*
|
|
4
|
+
* The wizard host wires routing, history and several data hooks together, so
|
|
5
|
+
* the decision is taken out of it and asserted by its result — the same move
|
|
6
|
+
* `runEndingBanner` made for the completion banner. What the run screen then
|
|
7
|
+
* shows for each state is `@remit/ui`'s `selection-wizard.render.test.ts`, and
|
|
8
|
+
* the poll a refresh re-issues is `../../hooks/useOrganizeJob.render.test.ts`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { describe, it } from "node:test";
|
|
13
|
+
import type { BulkActionTarget, BulkRunOutcome } from "@/lib/bulk-actions";
|
|
14
|
+
import {
|
|
15
|
+
type RetryContext,
|
|
16
|
+
retryIntent,
|
|
17
|
+
runIsInFlight,
|
|
18
|
+
stopRunner,
|
|
19
|
+
wizardExit,
|
|
20
|
+
} from "./selection-wizard-run-controls";
|
|
21
|
+
|
|
22
|
+
const target = (id: string): BulkActionTarget => ({ id, accountId: "acc-1" });
|
|
23
|
+
|
|
24
|
+
const context = (over: Partial<RetryContext> = {}): RetryContext => ({
|
|
25
|
+
runState: "backApplyComplete",
|
|
26
|
+
isEscalated: false,
|
|
27
|
+
committedScope: "just-these",
|
|
28
|
+
createFilterFailed: false,
|
|
29
|
+
backApplyPending: false,
|
|
30
|
+
widenRunsAsJob: false,
|
|
31
|
+
failedIds: [],
|
|
32
|
+
sent: [target("m1"), target("m2")],
|
|
33
|
+
...over,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("retrying a run whose status could not be read", () => {
|
|
37
|
+
it("looks at the job it already has rather than queuing a second pass", () => {
|
|
38
|
+
assert.deepEqual(retryIntent(context({ runState: "statusUnknown" })), {
|
|
39
|
+
kind: "refreshStatus",
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// The back-apply is still running on the server, so every other branch —
|
|
44
|
+
// each of which starts something — has to lose to this one.
|
|
45
|
+
for (const over of [
|
|
46
|
+
{ isEscalated: true },
|
|
47
|
+
{ committedScope: "standing" as const, backApplyPending: true },
|
|
48
|
+
{ committedScope: "temporary" as const, createFilterFailed: true },
|
|
49
|
+
{ committedScope: "all-like-these" as const, widenRunsAsJob: true },
|
|
50
|
+
{ committedScope: "just-these" as const },
|
|
51
|
+
]) {
|
|
52
|
+
it(`wins over ${JSON.stringify(over)}`, () => {
|
|
53
|
+
assert.deepEqual(
|
|
54
|
+
retryIntent(context({ ...over, runState: "statusUnknown" })).kind,
|
|
55
|
+
"refreshStatus",
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("retrying an escalated predicate", () => {
|
|
62
|
+
it("re-resolves the predicate rather than resuming a page list", () => {
|
|
63
|
+
assert.deepEqual(retryIntent(context({ isEscalated: true })), {
|
|
64
|
+
kind: "rerunEscalated",
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("retrying a filter that was being saved", () => {
|
|
70
|
+
it("clears the failed create before sending the same commit again", () => {
|
|
71
|
+
assert.deepEqual(
|
|
72
|
+
retryIntent(
|
|
73
|
+
context({ committedScope: "standing", createFilterFailed: true }),
|
|
74
|
+
),
|
|
75
|
+
{ kind: "resetAndResend" },
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("starts the pass over existing mail when the create landed", () => {
|
|
80
|
+
assert.deepEqual(
|
|
81
|
+
retryIntent(
|
|
82
|
+
context({ committedScope: "temporary", backApplyPending: true }),
|
|
83
|
+
),
|
|
84
|
+
{ kind: "startBackApply" },
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("starts nothing for a saved filter with no pass behind it", () => {
|
|
89
|
+
assert.deepEqual(retryIntent(context({ committedScope: "standing" })), {
|
|
90
|
+
kind: "waitOnJob",
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("retrying a widened match", () => {
|
|
96
|
+
it("re-sends the commit when the widen runs as a job", () => {
|
|
97
|
+
assert.deepEqual(
|
|
98
|
+
retryIntent(
|
|
99
|
+
context({ committedScope: "all-like-these", widenRunsAsJob: true }),
|
|
100
|
+
),
|
|
101
|
+
{ kind: "resend" },
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("re-sends the resolved ids when it does not", () => {
|
|
106
|
+
assert.deepEqual(
|
|
107
|
+
retryIntent(context({ committedScope: "all-like-these" })),
|
|
108
|
+
{ kind: "rerunBulk", targets: [target("m1"), target("m2")] },
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("retrying a bounded run", () => {
|
|
114
|
+
it("re-sends only what the run never reached", () => {
|
|
115
|
+
assert.deepEqual(
|
|
116
|
+
retryIntent(
|
|
117
|
+
context({ sent: [target("m1"), target("m2")], failedIds: ["m2"] }),
|
|
118
|
+
),
|
|
119
|
+
{ kind: "rerunBulk", targets: [target("m2")] },
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// A run that threw before it handed anything back reports no failed ids, and
|
|
124
|
+
// re-sending nothing is a retry that silently does nothing at all.
|
|
125
|
+
it("re-sends everything when the run named no id it missed", () => {
|
|
126
|
+
assert.deepEqual(retryIntent(context()), {
|
|
127
|
+
kind: "rerunBulk",
|
|
128
|
+
targets: [target("m1"), target("m2")],
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("keeps each id's own account, so a cross-account retry still batches", () => {
|
|
133
|
+
const sent = [
|
|
134
|
+
{ id: "m1", accountId: "acc-work" },
|
|
135
|
+
{ id: "m2", accountId: "acc-personal" },
|
|
136
|
+
];
|
|
137
|
+
assert.deepEqual(retryIntent(context({ sent, failedIds: ["m2"] })), {
|
|
138
|
+
kind: "rerunBulk",
|
|
139
|
+
targets: [{ id: "m2", accountId: "acc-personal" }],
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("whether the run screen offers a way to end the run", () => {
|
|
145
|
+
const outcome: BulkRunOutcome = {
|
|
146
|
+
done: 2,
|
|
147
|
+
failedIds: [],
|
|
148
|
+
cancelled: false,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
it("offers nothing before a commit has been sent", () => {
|
|
152
|
+
assert.equal(runIsInFlight(undefined), false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("offers it while the runner is still paging", () => {
|
|
156
|
+
assert.equal(runIsInFlight({}), true);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("withdraws it once the runner has handed back an outcome", () => {
|
|
160
|
+
assert.equal(runIsInFlight({ outcome }), false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Nothing was sent, so there is nothing paging to end — the screen carries
|
|
164
|
+
// why instead, and a control claiming to stop a run would stop nothing.
|
|
165
|
+
it("withdraws it for a commit that could not start", () => {
|
|
166
|
+
assert.equal(runIsInFlight({ failureReason: "no Junk folder" }), false);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("which runner the stop reaches", () => {
|
|
171
|
+
const spies = () => {
|
|
172
|
+
const called: string[] = [];
|
|
173
|
+
return {
|
|
174
|
+
called,
|
|
175
|
+
escalated: { stop: () => called.push("escalated") },
|
|
176
|
+
stopBulk: () => called.push("bulk"),
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
it("stops the list's runner when the selection is a predicate", () => {
|
|
181
|
+
const { called, escalated, stopBulk } = spies();
|
|
182
|
+
stopRunner(escalated, stopBulk);
|
|
183
|
+
assert.deepEqual(called, ["escalated"]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// A bounded select-all runs on the wizard's own runner and reaches the same
|
|
187
|
+
// control, so a stop that only knows about the predicate leaves that button
|
|
188
|
+
// on screen doing nothing at all.
|
|
189
|
+
it("stops the wizard's own runner for a bounded selection", () => {
|
|
190
|
+
const { called, stopBulk } = spies();
|
|
191
|
+
stopRunner(undefined, stopBulk);
|
|
192
|
+
assert.deepEqual(called, ["bulk"]);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("which movement the wizard's exit is", () => {
|
|
197
|
+
const movements = () => {
|
|
198
|
+
const called: string[] = [];
|
|
199
|
+
return {
|
|
200
|
+
called,
|
|
201
|
+
handlers: {
|
|
202
|
+
dismiss: () => called.push("dismiss"),
|
|
203
|
+
cancel: () => called.push("cancel"),
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
it("walks away from the run on the run screen, leaving it going", () => {
|
|
209
|
+
const { called, handlers } = movements();
|
|
210
|
+
wizardExit("run", handlers)();
|
|
211
|
+
assert.deepEqual(called, ["dismiss"]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Rewinding the entries the wizard owns is only safe before anything is
|
|
215
|
+
// sent; taken off the run screen it offers the commit a second time.
|
|
216
|
+
for (const step of [
|
|
217
|
+
"match",
|
|
218
|
+
"properties",
|
|
219
|
+
"folder",
|
|
220
|
+
"rule",
|
|
221
|
+
"name",
|
|
222
|
+
"review",
|
|
223
|
+
] as const) {
|
|
224
|
+
it(`rewinds the walk from ${step}`, () => {
|
|
225
|
+
const { called, handlers } = movements();
|
|
226
|
+
wizardExit(step, handlers)();
|
|
227
|
+
assert.deepEqual(called, ["cancel"]);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
});
|