@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
|
@@ -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);
|
|
@@ -285,3 +285,20 @@ describe("initialStage", () => {
|
|
|
285
285
|
assert.equal(initialStage(3), "choose-fate");
|
|
286
286
|
});
|
|
287
287
|
});
|
|
288
|
+
|
|
289
|
+
describe("hasChildFolders in a flat namespace", () => {
|
|
290
|
+
it("does not read a shared prefix as a child", () => {
|
|
291
|
+
const target = folder({
|
|
292
|
+
mailboxId: "work",
|
|
293
|
+
fullPath: "Work",
|
|
294
|
+
hierarchyDelimiter: "",
|
|
295
|
+
});
|
|
296
|
+
assert.equal(
|
|
297
|
+
hasChildFolders(target, [
|
|
298
|
+
target,
|
|
299
|
+
{ mailboxId: "shop", fullPath: "Workshop" },
|
|
300
|
+
]),
|
|
301
|
+
false,
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
});
|
package/src/lib/delete-folder.ts
CHANGED
|
@@ -34,12 +34,14 @@ const isInboxPath = (fullPath: string): boolean =>
|
|
|
34
34
|
/**
|
|
35
35
|
* A folder has children when another mailbox's path is nested under it — the
|
|
36
36
|
* folder's own path followed by the hierarchy delimiter. The delimiter matters:
|
|
37
|
-
* `Work` is not a parent of `Workshop`, only of `Work/Shop`.
|
|
37
|
+
* `Work` is not a parent of `Workshop`, only of `Work/Shop`. A server that
|
|
38
|
+
* reports no delimiter has a flat namespace, where nothing nests at all.
|
|
38
39
|
*/
|
|
39
40
|
export const hasChildFolders = (
|
|
40
41
|
folder: Pick<FolderNode, "mailboxId" | "fullPath" | "hierarchyDelimiter">,
|
|
41
42
|
all: readonly Pick<FolderNode, "mailboxId" | "fullPath">[],
|
|
42
43
|
): boolean => {
|
|
44
|
+
if (folder.hierarchyDelimiter.length === 0) return false;
|
|
43
45
|
const prefix = `${folder.fullPath}${folder.hierarchyDelimiter}`;
|
|
44
46
|
return all.some(
|
|
45
47
|
(other) =>
|
|
@@ -107,3 +107,21 @@ describe("validateNewFolderName", () => {
|
|
|
107
107
|
);
|
|
108
108
|
});
|
|
109
109
|
});
|
|
110
|
+
|
|
111
|
+
describe("validateNewFolderName in a flat namespace", () => {
|
|
112
|
+
const flat = { delimiter: "", existingPaths: ["INBOX", "Archive"] };
|
|
113
|
+
|
|
114
|
+
it("accepts a name when the server reports no delimiter", () => {
|
|
115
|
+
assert.equal(
|
|
116
|
+
validateNewFolderName({ ...flat, name: "Receipts" }),
|
|
117
|
+
undefined,
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("still catches a collision", () => {
|
|
122
|
+
assert.match(
|
|
123
|
+
validateNewFolderName({ ...flat, name: "inbox" }) ?? "",
|
|
124
|
+
/already exists/,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
});
|
package/src/lib/new-folder.ts
CHANGED
|
@@ -19,7 +19,7 @@ export function composeFolderPath(name: string, parent?: FolderTarget): string {
|
|
|
19
19
|
* match `inbox/x` against `INBOX/x` without folding case anywhere else.
|
|
20
20
|
*/
|
|
21
21
|
const canonicalizePath = (path: string, delimiter: string): string => {
|
|
22
|
-
const parts = path.split(delimiter);
|
|
22
|
+
const parts = delimiter.length === 0 ? [path] : path.split(delimiter);
|
|
23
23
|
if (parts[0]?.toLowerCase() === "inbox") parts[0] = "INBOX";
|
|
24
24
|
return parts.join(delimiter);
|
|
25
25
|
};
|
|
@@ -29,7 +29,8 @@ const canonicalizePath = (path: string, delimiter: string): string => {
|
|
|
29
29
|
* name must be non-empty, must not contain the hierarchy delimiter (nesting is
|
|
30
30
|
* expressed by the parent select, not by typing a path), and must not collide
|
|
31
31
|
* with an existing folder — INBOX compared case-insensitively, everything else
|
|
32
|
-
* exactly.
|
|
32
|
+
* exactly. A server that reports no delimiter has a flat namespace, so there is
|
|
33
|
+
* no separator to forbid and every name would otherwise contain it.
|
|
33
34
|
*/
|
|
34
35
|
export function validateNewFolderName({
|
|
35
36
|
name,
|
|
@@ -44,7 +45,7 @@ export function validateNewFolderName({
|
|
|
44
45
|
}): string | undefined {
|
|
45
46
|
const trimmed = name.trim();
|
|
46
47
|
if (trimmed === "") return "Enter a folder name.";
|
|
47
|
-
if (trimmed.includes(delimiter))
|
|
48
|
+
if (delimiter.length > 0 && trimmed.includes(delimiter))
|
|
48
49
|
return `A folder name can't contain "${delimiter}".`;
|
|
49
50
|
const candidate = canonicalizePath(
|
|
50
51
|
composeFolderPath(trimmed, parent),
|
|
@@ -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
|
-
});
|