@remit/web-client 0.0.200 → 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/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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { RunState, StepId } from "@remit/ui";
|
|
2
|
+
import type { BulkActionTarget, BulkRunOutcome } from "@/lib/bulk-actions";
|
|
3
|
+
import type { OrganizeScope } from "@/lib/organize/organize-model";
|
|
4
|
+
|
|
5
|
+
export interface RetryContext {
|
|
6
|
+
runState: RunState;
|
|
7
|
+
isEscalated: boolean;
|
|
8
|
+
committedScope: OrganizeScope | undefined;
|
|
9
|
+
createFilterFailed: boolean;
|
|
10
|
+
backApplyPending: boolean;
|
|
11
|
+
widenRunsAsJob: boolean;
|
|
12
|
+
failedIds: readonly string[];
|
|
13
|
+
sent: readonly BulkActionTarget[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type RetryIntent =
|
|
17
|
+
| { kind: "refreshStatus" }
|
|
18
|
+
| { kind: "rerunEscalated" }
|
|
19
|
+
| { kind: "resetAndResend" }
|
|
20
|
+
| { kind: "resend" }
|
|
21
|
+
| { kind: "startBackApply" }
|
|
22
|
+
| { kind: "waitOnJob" }
|
|
23
|
+
| { kind: "rerunBulk"; targets: readonly BulkActionTarget[] };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What pressing the run screen's retry asks for, given where the commit got to.
|
|
27
|
+
*
|
|
28
|
+
* A back-apply whose status could not be read is still running on the server
|
|
29
|
+
* (#526), so that one is decided before anything that could queue a second pass
|
|
30
|
+
* over the same mail.
|
|
31
|
+
*/
|
|
32
|
+
export const retryIntent = (context: RetryContext): RetryIntent => {
|
|
33
|
+
if (context.runState === "statusUnknown") return { kind: "refreshStatus" };
|
|
34
|
+
// The predicate is re-resolved, not resumed: every verb it carries is
|
|
35
|
+
// idempotent, so the messages the first pass already reached are a no-op.
|
|
36
|
+
if (context.isEscalated) return { kind: "rerunEscalated" };
|
|
37
|
+
if (
|
|
38
|
+
context.committedScope === "standing" ||
|
|
39
|
+
context.committedScope === "temporary"
|
|
40
|
+
) {
|
|
41
|
+
if (context.createFilterFailed) return { kind: "resetAndResend" };
|
|
42
|
+
return context.backApplyPending
|
|
43
|
+
? { kind: "startBackApply" }
|
|
44
|
+
: { kind: "waitOnJob" };
|
|
45
|
+
}
|
|
46
|
+
if (context.committedScope === "all-like-these" && context.widenRunsAsJob) {
|
|
47
|
+
return { kind: "resend" };
|
|
48
|
+
}
|
|
49
|
+
// A run hands back ids, and each one's account came from the batch it was
|
|
50
|
+
// sent in — so a retry re-sends the targets the run was given, filtered to
|
|
51
|
+
// what it never reached.
|
|
52
|
+
const outstanding = new Set(context.failedIds);
|
|
53
|
+
return {
|
|
54
|
+
kind: "rerunBulk",
|
|
55
|
+
targets:
|
|
56
|
+
outstanding.size > 0
|
|
57
|
+
? context.sent.filter((target) => outstanding.has(target.id))
|
|
58
|
+
: context.sent,
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What a commit has left paging. A commit that never started, one whose runner
|
|
64
|
+
* has handed back its outcome, and one that could not start at all all have
|
|
65
|
+
* nothing to end — so the run screen offers no control that would claim to end
|
|
66
|
+
* them (#521).
|
|
67
|
+
*/
|
|
68
|
+
export const runIsInFlight = (
|
|
69
|
+
bulkRun: { outcome?: BulkRunOutcome; failureReason?: string } | undefined,
|
|
70
|
+
): boolean =>
|
|
71
|
+
bulkRun !== undefined &&
|
|
72
|
+
bulkRun.outcome === undefined &&
|
|
73
|
+
bulkRun.failureReason === undefined;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Ends the run rather than leaving it. An escalated predicate is paged by the
|
|
77
|
+
* list's own runner and a bounded selection by the wizard's, so the stop has to
|
|
78
|
+
* follow whichever one is paging: a stop that reaches only one of them leaves
|
|
79
|
+
* the other's control on screen doing nothing.
|
|
80
|
+
*/
|
|
81
|
+
export const stopRunner = (
|
|
82
|
+
escalated: { stop: () => void } | undefined,
|
|
83
|
+
stopBulk: () => void,
|
|
84
|
+
): void => {
|
|
85
|
+
if (escalated) {
|
|
86
|
+
escalated.stop();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
stopBulk();
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Which of the two movements the wizard's exit is, on the step it is taken
|
|
94
|
+
* from. Everywhere but the run screen it rewinds the entries the wizard owns
|
|
95
|
+
* and leaves the selection where it was; on the run screen the action has
|
|
96
|
+
* already been sent, so leaving walks away from a run that keeps going.
|
|
97
|
+
*/
|
|
98
|
+
export const wizardExit = (
|
|
99
|
+
step: StepId,
|
|
100
|
+
movements: { dismiss: () => void; cancel: () => void },
|
|
101
|
+
): (() => void) => (step === "run" ? movements.dismiss : movements.cancel);
|
|
@@ -1,51 +0,0 @@
|
|
|
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
|
-
* The phone brief must mount the same selection surface the multi-pane list
|
|
9
|
-
* does (#203). `BriefPane` wires the bulk verbs and the selection bar into
|
|
10
|
-
* `DailyBrief` from the `BriefList` slot; the phone view used to render
|
|
11
|
-
* `DailyBrief` directly, without `onDeleteMessages`, so a selection made in the
|
|
12
|
-
* brief on a phone raised no action bar — selectable, but nothing to act with.
|
|
13
|
-
*
|
|
14
|
-
* As with `MessageList.selection.test.ts`, `BriefPane` weaves routing and
|
|
15
|
-
* several data hooks together, so the rule is enforced by reading the source:
|
|
16
|
-
* the phone view reuses the list slot rather than a bare `DailyBrief`.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
-
const source = readFileSync(resolve(here, "BriefPane.tsx"), "utf8");
|
|
21
|
-
|
|
22
|
-
const briefPhoneBody = (): string => {
|
|
23
|
-
const start = source.indexOf("function BriefPhone(");
|
|
24
|
-
assert.notEqual(start, -1, "BriefPhone is defined");
|
|
25
|
-
const next = source.indexOf("\nfunction ", start + 1);
|
|
26
|
-
return source.slice(start, next === -1 ? undefined : next);
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
describe("BriefPane phone selection surface (#203)", () => {
|
|
30
|
-
it("the phone brief reuses the list slot, not a bare DailyBrief", () => {
|
|
31
|
-
const body = briefPhoneBody();
|
|
32
|
-
assert.match(
|
|
33
|
-
body,
|
|
34
|
-
/<BriefList \/>/,
|
|
35
|
-
"phone brief renders the list slot that wires the bulk verbs",
|
|
36
|
-
);
|
|
37
|
-
assert.doesNotMatch(
|
|
38
|
-
body,
|
|
39
|
-
/<DailyBrief\b/,
|
|
40
|
-
"phone brief must not render DailyBrief directly — that drops onDeleteMessages and the selection bar with it",
|
|
41
|
-
);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("the list slot hands DailyBrief the delete verb the selection bar gates on", () => {
|
|
45
|
-
const start = source.indexOf("function BriefList(");
|
|
46
|
-
assert.notEqual(start, -1, "BriefList is defined");
|
|
47
|
-
const next = source.indexOf("\nfunction ", start + 1);
|
|
48
|
-
const body = source.slice(start, next === -1 ? undefined : next);
|
|
49
|
-
assert.match(body, /onDeleteMessages=\{onDeleteMessages\}/);
|
|
50
|
-
});
|
|
51
|
-
});
|
|
@@ -1,75 +0,0 @@
|
|
|
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
|
-
* The brief raises one selection surface at every width, and select-all is on
|
|
9
|
-
* it — wired into the touch sheet alone, the same list could be selected whole
|
|
10
|
-
* on a phone and not on a desktop (#453).
|
|
11
|
-
*
|
|
12
|
-
* As with `MessageList.selection.test.ts`, `DailyBrief` weaves routing, the
|
|
13
|
-
* cursor and several data hooks together, so the rule is enforced by reading
|
|
14
|
-
* the source. `resolveBriefSelectionScope` carries the decisions that can be
|
|
15
|
-
* unit-tested on their own.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
|
-
const source = readFileSync(resolve(here, "DailyBrief.tsx"), "utf8");
|
|
20
|
-
|
|
21
|
-
const chromeBody = (): string => {
|
|
22
|
-
const start = source.indexOf("function BriefSelectionChrome(");
|
|
23
|
-
assert.notEqual(start, -1, "BriefSelectionChrome is defined");
|
|
24
|
-
const next = source.indexOf("\nfunction ", start + 1);
|
|
25
|
-
return source.slice(start, next === -1 ? undefined : next);
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
describe("the brief's select-all", () => {
|
|
29
|
-
it("reaches the one selection bar from one derivation", () => {
|
|
30
|
-
const body = chromeBody();
|
|
31
|
-
const derivations = body.match(/const selectAll = useMemo\(/g) ?? [];
|
|
32
|
-
assert.equal(derivations.length, 1, "one select-all");
|
|
33
|
-
const bars = body.match(/<SelectionTopBar\b/g) ?? [];
|
|
34
|
-
assert.equal(bars.length, 1, "one surface, at every width (#480)");
|
|
35
|
-
const wired = body.match(/selectAll=\{selectAll\}/g) ?? [];
|
|
36
|
-
assert.equal(wired.length, 1, "the bar takes it");
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("is offered only when there are rows to select", () => {
|
|
40
|
-
assert.match(chromeBody(), /orderedIds\.length > 0/);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("mounts the bar unconditionally — it is the list header (#480)", () => {
|
|
44
|
-
assert.match(
|
|
45
|
-
chromeBody(),
|
|
46
|
-
/const selectionBar = \(chrome: ListHeaderChrome\) => \(\s*<SelectionTopBar/,
|
|
47
|
-
);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("carries every verb the bar can offer (#477 1.4)", () => {
|
|
51
|
-
// That each of them opens the wizard is `selection-verbs.test.ts`, over
|
|
52
|
-
// every surface at once. What this pins is that none of them was dropped
|
|
53
|
-
// from the bar the brief raises.
|
|
54
|
-
const body = chromeBody();
|
|
55
|
-
for (const prop of [
|
|
56
|
-
"onDelete",
|
|
57
|
-
"onMove",
|
|
58
|
-
"onOrganize",
|
|
59
|
-
"onJunk",
|
|
60
|
-
"onMarkRead",
|
|
61
|
-
]) {
|
|
62
|
-
assert.match(body, new RegExp(`${prop}=`));
|
|
63
|
-
}
|
|
64
|
-
assert.doesNotMatch(body, /onSomethingElse/);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("drives the wizard and the keyboard layer from one control", () => {
|
|
68
|
-
// Two copies of "which verb is the wizard on" is how the brief came to
|
|
69
|
-
// offer a verb the mailbox list did not, and how its keyboard came to act
|
|
70
|
-
// behind a wizard the bar had opened.
|
|
71
|
-
assert.match(source, /const wizard = useSelectionWizard\(\);/);
|
|
72
|
-
assert.match(source, /onSelectionVerb={wizard.start}/);
|
|
73
|
-
assert.match(source, /wizardOpen={wizard.isOpen}/);
|
|
74
|
-
});
|
|
75
|
-
});
|
|
@@ -1,195 +0,0 @@
|
|
|
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
|
-
* Selection mode has one source of truth and one exit (#115). `MessageList`
|
|
9
|
-
* wires the DOM, the virtualizer, routing and several data hooks together, so
|
|
10
|
-
* — as with this package's other component-level rules (see
|
|
11
|
-
* `../../index.css.test.ts`) — the rule is enforced by reading the source
|
|
12
|
-
* rather than rendering the tree. The decisions themselves are unit-tested in
|
|
13
|
-
* `../../lib/selection-mode.test.ts`.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
17
|
-
const source = readFileSync(resolve(here, "MessageList.tsx"), "utf8");
|
|
18
|
-
|
|
19
|
-
describe("MessageList selection mode", () => {
|
|
20
|
-
it("keeps no selection-mode flag of its own", () => {
|
|
21
|
-
assert.doesNotMatch(source, /useState[^\n]*[iI]sMultiSelectMode/);
|
|
22
|
-
assert.doesNotMatch(source, /setIsMultiSelectMode/);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it("derives the mode from the selection count", () => {
|
|
26
|
-
assert.match(
|
|
27
|
-
source,
|
|
28
|
-
/const isMultiSelectMode = deriveIsMultiSelectMode\(\s*selectedCount,/,
|
|
29
|
-
);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("calls the selection hook's clearSelection from exactly one place", () => {
|
|
33
|
-
const calls = source.match(/\bclearSelection\(\)/g) ?? [];
|
|
34
|
-
assert.equal(calls.length, 1);
|
|
35
|
-
assert.match(
|
|
36
|
-
source,
|
|
37
|
-
/const exitSelection = useCallback\(\(\) => \{[^}]*clearSelection\(\);/,
|
|
38
|
-
);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("exits selection when the mailbox changes", () => {
|
|
42
|
-
assert.match(source, /previousMailboxIdRef\.current === mailboxId/);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("turns the back gesture into an exit, and only while selecting", () => {
|
|
46
|
-
assert.match(
|
|
47
|
-
source,
|
|
48
|
-
/shouldExitSelectionOnNavigate\(action, hasSelection, wizardStep\)/,
|
|
49
|
-
);
|
|
50
|
-
assert.match(source, /disabled: !hasSelection/);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("leaves the back gesture to the wizard while the wizard is open", () => {
|
|
54
|
-
assert.match(
|
|
55
|
-
source,
|
|
56
|
-
/disabled: !hasSelection \|\| wizardStep !== undefined/,
|
|
57
|
-
);
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* An escalated selection is a predicate, and it walks the same wizard every
|
|
63
|
-
* other selection walks (#508). The list hands the wizard the predicate — the
|
|
64
|
-
* words it names it with, the count it was escalated to, and the chunked runner
|
|
65
|
-
* that re-resolves it — and keeps no second confirmation of its own.
|
|
66
|
-
*/
|
|
67
|
-
describe("MessageList escalated actions", () => {
|
|
68
|
-
it("hands the escalated predicate to the wizard rather than running it here", () => {
|
|
69
|
-
assert.match(
|
|
70
|
-
source,
|
|
71
|
-
/const escalatedSelection: EscalatedSelection \| undefined =\s*escalation\.phase\.kind === "escalated"/,
|
|
72
|
-
);
|
|
73
|
-
assert.match(
|
|
74
|
-
source,
|
|
75
|
-
/scope: describeSearchScope\(searchPredicate \?\? \{\}\)/,
|
|
76
|
-
);
|
|
77
|
-
assert.match(source, /total: escalation\.phase\.total/);
|
|
78
|
-
assert.match(
|
|
79
|
-
source,
|
|
80
|
-
/run: \(action: EscalatedAction\) => escalation\.runAction\(action\)/,
|
|
81
|
-
);
|
|
82
|
-
const host = source.match(/<SelectionWizardHost[\s\S]*?\/>/)?.[0] ?? "";
|
|
83
|
-
assert.match(host, /escalated=\{escalatedSelection\}/);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it("keeps no predicate confirmation of its own", () => {
|
|
87
|
-
// The review screen names what the predicate covers; a second dialog
|
|
88
|
-
// asking the same question is the drift the wizard exists to end.
|
|
89
|
-
assert.doesNotMatch(source, /source: "predicate"/);
|
|
90
|
-
assert.doesNotMatch(source, /requestEscalatedDelete/);
|
|
91
|
-
assert.doesNotMatch(source, /pendingDeleteIsEstimate/);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("words the bar's progress per action instead of per delete", () => {
|
|
95
|
-
assert.match(
|
|
96
|
-
source,
|
|
97
|
-
/bulkActionProgressLabel\(\s*escalation\.runningAction\.kind,/,
|
|
98
|
-
);
|
|
99
|
-
});
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Advanced selection is no longer mobile-only (#212): the escalation engine is
|
|
104
|
-
* opened to every width, and the one selection bar renders the
|
|
105
|
-
* offer → counting → escalated → progress → notice states.
|
|
106
|
-
*/
|
|
107
|
-
describe("MessageList escalation reaches desktop (#212)", () => {
|
|
108
|
-
it("no longer gates the escalation engine on the mobile viewport", () => {
|
|
109
|
-
assert.match(
|
|
110
|
-
source,
|
|
111
|
-
/const escalationEnabled = isSearching && !!searchPredicate;/,
|
|
112
|
-
);
|
|
113
|
-
assert.doesNotMatch(
|
|
114
|
-
source,
|
|
115
|
-
/!isDesktop && isSearching && !!searchPredicate/,
|
|
116
|
-
);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("feeds the bar the shared escalation state", () => {
|
|
120
|
-
// Up to the bar's own closing tag, at its own indent — a nested slot
|
|
121
|
-
// element closes first and would cut the match short.
|
|
122
|
-
const bar = source.match(/<SelectionTopBar[\s\S]*?\n\t\t\/>/)?.[0] ?? "";
|
|
123
|
-
assert.match(bar, /statusLabel=\{selectionStatusLabel\}/);
|
|
124
|
-
assert.match(bar, /notice=\{selectionNotice\}/);
|
|
125
|
-
assert.match(bar, /progress=\{selectionProgress\}/);
|
|
126
|
-
assert.match(bar, /selectAll=\{selectionSelectAll\}/);
|
|
127
|
-
assert.match(bar, /isCounting=\{escalation\.phase\.kind === "counting"\}/);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it("raises one selection surface, at every width (#480)", () => {
|
|
131
|
-
const bars = source.match(/<SelectionTopBar\b/g) ?? [];
|
|
132
|
-
assert.equal(bars.length, 1, "one bar, so no two surfaces can drift");
|
|
133
|
-
assert.doesNotMatch(source, /<SelectionSheet\b/);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("mounts that surface unconditionally — it is the list header", () => {
|
|
137
|
-
// Gated on a selection, the bar's zero-ticked state (the mailbox name,
|
|
138
|
-
// and select-all inline from 768px up) reaches nothing.
|
|
139
|
-
assert.match(source, /const activeSelectionBar = \(\s*<SelectionTopBar/);
|
|
140
|
-
assert.match(source, /title=\{listHeaderChrome\.title \|\| listTitle\}/);
|
|
141
|
-
assert.match(source, /navSlot=\{listHeaderChrome\.navSlot\}/);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it("carries every verb the bar can offer (#477 1.4)", () => {
|
|
145
|
-
// That each of them opens the wizard is `selection-verbs.test.ts`, over
|
|
146
|
-
// every surface at once. What this pins is that none of them was dropped
|
|
147
|
-
// from the one bar the mailbox raises.
|
|
148
|
-
const bar = source.match(/<SelectionTopBar[\s\S]*?\n\t\t\/>/)?.[0] ?? "";
|
|
149
|
-
for (const prop of [
|
|
150
|
-
"onDelete",
|
|
151
|
-
"onMove",
|
|
152
|
-
"onOrganize",
|
|
153
|
-
"onJunk",
|
|
154
|
-
"onMarkRead",
|
|
155
|
-
]) {
|
|
156
|
-
assert.match(bar, new RegExp(`${prop}=`));
|
|
157
|
-
}
|
|
158
|
-
// The one selection surface has no second organize entry beside them.
|
|
159
|
-
assert.doesNotMatch(source, /onSomethingElse/);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it("keeps Organize reachable over a predicate rather than dropping it", () => {
|
|
163
|
-
// A verb that vanishes when a selection escalates leaves the user no route
|
|
164
|
-
// at all: the make-filter affordance it defers to is hidden while rows are
|
|
165
|
-
// ticked (#477 1.7).
|
|
166
|
-
assert.match(source, /if \(escalatedSelection\) \{\s*startFromSearch\(\);/);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it("offers the label picker only when the account has labels", () => {
|
|
170
|
-
assert.match(source, /labels\.length > 0 \? \(/);
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* A confirmed delete opens the surviving neighbour. On desktop that fills the
|
|
176
|
-
* reading pane beside the list; on a single-pane mobile layout the same
|
|
177
|
-
* navigation replaced the list with a full-screen message, so the delete read as
|
|
178
|
-
* "jumped into a random message" rather than "the row is gone" (#202). Mobile
|
|
179
|
-
* now stays on the list and raises a completion banner instead.
|
|
180
|
-
*/
|
|
181
|
-
describe("MessageList confirmed delete stays on the list on mobile (#202)", () => {
|
|
182
|
-
it("only the desktop two-pane opens the surviving neighbour after a delete", () => {
|
|
183
|
-
assert.match(
|
|
184
|
-
source,
|
|
185
|
-
/if \(isDesktop\) \{\s*openRow\(nextFocus, \{ replace: true \}\);\s*\}/,
|
|
186
|
-
);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it("raises a completion banner on mobile so the delete is not silent", () => {
|
|
190
|
-
assert.match(
|
|
191
|
-
source,
|
|
192
|
-
/if \(!isDesktop\) \{\s*setCompletionBanner\(\s*bulkActionCompletionText\("delete", ids\.length, deleteOutcome\),?\s*\);\s*\}/,
|
|
193
|
-
);
|
|
194
|
-
});
|
|
195
|
-
});
|