@remit/web-client 0.0.200 → 0.0.202
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/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/lib/delete-folder.test.ts +17 -0
- package/src/lib/delete-folder.ts +3 -1
- package/src/lib/new-folder.test.ts +18 -0
- package/src/lib/new-folder.ts +4 -3
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.202",
|
|
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": {
|
|
@@ -1,41 +1,82 @@
|
|
|
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
1
|
/**
|
|
8
2
|
* A filter made from a search belongs to the account the search ran over (#524).
|
|
9
|
-
* The surface hands its account to the host on the props; a search entry that
|
|
10
|
-
* reads past it writes the rule against whichever account happens to be first in
|
|
11
|
-
* the list, and the mail the user was looking at is never filtered.
|
|
12
3
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* `SelectionWizardHost.run-exit.test.ts`).
|
|
4
|
+
* The surface hands its account to the host on the props; a search entry that
|
|
5
|
+
* reaches past it writes the rule against whichever account happens to be first
|
|
6
|
+
* in the list, and the mail the user was looking at is never filtered. Which
|
|
7
|
+
* account wins between the query and the surface is
|
|
8
|
+
* `../../lib/organize/search-to-rule.test.ts`; what is asserted here is the set
|
|
9
|
+
* of props a search entry replaces, the account among them.
|
|
20
10
|
*/
|
|
21
11
|
|
|
22
|
-
|
|
23
|
-
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { describe, it } from "node:test";
|
|
14
|
+
import type { SearchConversion } from "@remit/ui";
|
|
15
|
+
import { searchEntryOverrides } from "./SelectionWizardHost";
|
|
16
|
+
|
|
17
|
+
const conversion = (
|
|
18
|
+
over: Partial<SearchConversion> = {},
|
|
19
|
+
): SearchConversion => ({
|
|
20
|
+
clauses: [],
|
|
21
|
+
matchOperator: "all",
|
|
22
|
+
droppedFacets: [],
|
|
23
|
+
keptTerms: false,
|
|
24
|
+
droppedSemantic: false,
|
|
25
|
+
...over,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const accounts = [{ accountId: "acc-first" }, { accountId: "acc-surface" }];
|
|
29
|
+
|
|
30
|
+
describe("the account a search entry writes its rule against", () => {
|
|
31
|
+
it("is the one the surface handed over", () => {
|
|
32
|
+
assert.equal(
|
|
33
|
+
searchEntryOverrides(conversion(), { accountId: "acc-surface" }, accounts)
|
|
34
|
+
.accountId,
|
|
35
|
+
"acc-surface",
|
|
36
|
+
);
|
|
37
|
+
});
|
|
24
38
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
39
|
+
it("is the one the query names when it names one", () => {
|
|
40
|
+
assert.equal(
|
|
41
|
+
searchEntryOverrides(
|
|
42
|
+
conversion({ targetAccountId: "acc-typed" }),
|
|
43
|
+
{ accountId: "acc-surface" },
|
|
44
|
+
accounts,
|
|
45
|
+
).accountId,
|
|
46
|
+
"acc-typed",
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// The first configured account is the last resort, never the answer while the
|
|
51
|
+
// surface has one of its own.
|
|
52
|
+
it("falls back to the first configured account only when the surface has none", () => {
|
|
53
|
+
assert.equal(
|
|
54
|
+
searchEntryOverrides(conversion(), { accountId: undefined }, accounts)
|
|
55
|
+
.accountId,
|
|
56
|
+
"acc-first",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
28
60
|
|
|
29
|
-
describe("
|
|
30
|
-
it("
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
61
|
+
describe("what else a search entry replaces", () => {
|
|
62
|
+
it("ticks nothing, carries no restriction and drops any escalation", () => {
|
|
63
|
+
const overrides = searchEntryOverrides(
|
|
64
|
+
conversion(),
|
|
65
|
+
{ accountId: "acc-surface" },
|
|
66
|
+
accounts,
|
|
67
|
+
);
|
|
68
|
+
assert.equal(overrides.verb, "organize");
|
|
69
|
+
assert.deepEqual(overrides.selection, []);
|
|
70
|
+
assert.equal(overrides.selectionRestriction, undefined);
|
|
71
|
+
assert.equal(overrides.escalated, undefined);
|
|
35
72
|
});
|
|
36
73
|
|
|
37
|
-
it("
|
|
38
|
-
|
|
39
|
-
assert.
|
|
74
|
+
it("hands the conversion on so the properties step can seed its clauses", () => {
|
|
75
|
+
const seeded = conversion({ keptTerms: true });
|
|
76
|
+
assert.equal(
|
|
77
|
+
searchEntryOverrides(seeded, { accountId: "acc-surface" }, accounts)
|
|
78
|
+
.searchConversion,
|
|
79
|
+
seeded,
|
|
80
|
+
);
|
|
40
81
|
});
|
|
41
82
|
});
|
|
@@ -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
|
+
});
|