@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
|
@@ -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
|
-
});
|
|
@@ -1,50 +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
|
-
* A back-apply whose status could not be read is still running on the server
|
|
9
|
-
* (#526), so the run screen's affordance looks at that job again rather than
|
|
10
|
-
* queuing a second pass over the same mail.
|
|
11
|
-
*
|
|
12
|
-
* The host wires routing, history and several data hooks together, so — as with
|
|
13
|
-
* this package's other component-level rules (see `SelectionWizardHost.run-exit.test.ts`)
|
|
14
|
-
* — the wiring is read off the source. What it decides is proven where it can be
|
|
15
|
-
* run: `./organize-run-state.test.ts` for the ending the screen shows, and
|
|
16
|
-
* `../../hooks/useOrganizeJob.render.test.ts` for the poll it re-issues.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
-
const source = readFileSync(resolve(here, "SelectionWizardHost.tsx"), "utf8");
|
|
21
|
-
|
|
22
|
-
const retryBody = (): string => {
|
|
23
|
-
const body = source.match(
|
|
24
|
-
/const retry = \(\): void => \{([\s\S]*?)\n\t\};/,
|
|
25
|
-
)?.[1];
|
|
26
|
-
assert.ok(body, "the run screen has no retry");
|
|
27
|
-
return body;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
describe("retrying from an unknown status", () => {
|
|
31
|
-
it("looks at the job it already has", () => {
|
|
32
|
-
const body = retryBody();
|
|
33
|
-
const unknown = body.indexOf('run.state === "statusUnknown"');
|
|
34
|
-
assert.ok(unknown >= 0, "retry does not recognise an unknown status");
|
|
35
|
-
assert.match(body.slice(unknown), /organizeJob\.refreshStatus\(\)/);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("decides that before it can reach anything that queues a second job", () => {
|
|
39
|
-
const body = retryBody();
|
|
40
|
-
assert.ok(
|
|
41
|
-
body.indexOf('run.state === "statusUnknown"') < body.indexOf("startJob("),
|
|
42
|
-
"a second back-apply can be queued over a job that is still running",
|
|
43
|
-
);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("reads what the job is doing rather than that something failed", () => {
|
|
47
|
-
assert.doesNotMatch(source, /organizeJob\.isError/);
|
|
48
|
-
assert.match(source, /organizeRunState\(\{/);
|
|
49
|
-
});
|
|
50
|
-
});
|
|
@@ -1,83 +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
|
-
* Every way off the run screen is the same movement, and none of them ends the
|
|
9
|
-
* run the screen promised would keep going (#521). Ending it is a control of
|
|
10
|
-
* its own, and whichever way the user leaves, the run reports where it got to.
|
|
11
|
-
*
|
|
12
|
-
* The host wires routing, history and several data hooks together, so — as with
|
|
13
|
-
* this package's other component-level rules (see `MessageList.selection.test.ts`)
|
|
14
|
-
* — the wiring is read off the source. What the exits then do is proven where it
|
|
15
|
-
* can be run: `../../hooks/escalated-run-lifetime.render.test.ts` for the run's
|
|
16
|
-
* lifetime, and `@remit/ui`'s `selection-wizard.render.test.ts` for the controls.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
-
const source = readFileSync(resolve(here, "SelectionWizardHost.tsx"), "utf8");
|
|
21
|
-
|
|
22
|
-
describe("leaving the run screen", () => {
|
|
23
|
-
it("routes the header's X and back arrow through the same dismiss the footer uses", () => {
|
|
24
|
-
assert.match(source, /onExit=\{current === "run" \? dismiss : cancel\}/);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("routes hardware back through that dismiss too", () => {
|
|
28
|
-
assert.match(
|
|
29
|
-
source,
|
|
30
|
-
/if \(action !== "BACK"\) return false;\s*\n\s*dismiss\(\);/,
|
|
31
|
-
);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("leaves the run alone on the way out", () => {
|
|
35
|
-
const dismissBody = source.match(
|
|
36
|
-
/const dismiss = useCallback\(\(\) => \{([\s\S]*?)\}, \[/,
|
|
37
|
-
)?.[1];
|
|
38
|
-
assert.ok(dismissBody, "the run screen has no dismiss");
|
|
39
|
-
assert.doesNotMatch(dismissBody, /stop/i);
|
|
40
|
-
});
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
describe("ending the run", () => {
|
|
44
|
-
it("is a control of its own, offered only while a run is in flight", () => {
|
|
45
|
-
assert.match(source, /onCancelRun: runInFlight \? stopRun : undefined/);
|
|
46
|
-
assert.match(
|
|
47
|
-
source,
|
|
48
|
-
/const runInFlight =\s*bulkRun !== undefined &&\s*bulkRun\.outcome === undefined &&\s*bulkRun\.failureReason === undefined;/,
|
|
49
|
-
);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("stops whichever runner is paging — the list's or the wizard's own", () => {
|
|
53
|
-
const stopBody = source.match(
|
|
54
|
-
/const stopRun = useCallback\(\(\) => \{([\s\S]*?)\}, \[/,
|
|
55
|
-
)?.[1];
|
|
56
|
-
assert.ok(stopBody, "there is no way to stop a run");
|
|
57
|
-
assert.match(stopBody, /escalated\.stop\(\)/);
|
|
58
|
-
assert.match(stopBody, /stopBulk\(\)/);
|
|
59
|
-
});
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Both runners report, not just the predicate one: a ticked select-all runs on
|
|
64
|
-
* the wizard's own runner, and the same screen makes the same promise over it.
|
|
65
|
-
*/
|
|
66
|
-
describe("stating how a run ended once the screen is gone", () => {
|
|
67
|
-
it("marks the walk-away where it happens rather than reading it back off the URL", () => {
|
|
68
|
-
assert.match(source, /walkedAway\.current = true;\s*\n\s*closeWizard\(/);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
for (const runner of ["runBulk", "runEscalated"]) {
|
|
72
|
-
it(`reports the run ${runner} drove`, () => {
|
|
73
|
-
const body =
|
|
74
|
-
source.match(
|
|
75
|
-
new RegExp(
|
|
76
|
-
`const ${runner} = useCallback\\(([\\s\\S]*?)\\n\\t\\}, \\[`,
|
|
77
|
-
),
|
|
78
|
-
)?.[1] ?? "";
|
|
79
|
-
assert.match(body, /if \(walkedAway\.current\)/);
|
|
80
|
-
assert.match(body, /onRunEnded\?\.\(action\.kind, /);
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
});
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { readdirSync, 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
|
-
* "Make this a filter" is the wizard's second entry (#477 clause 1.8), and the
|
|
9
|
-
* step it pushes has to be answered on the view that offered it.
|
|
10
|
-
*
|
|
11
|
-
* The affordance is built once by `MailListHeader` and handed down as
|
|
12
|
-
* `makeFilterSlot`, so any surface can put it on screen — but the wizard is
|
|
13
|
-
* mounted per surface, and a surface that renders the row without one leaves
|
|
14
|
-
* the press on a URL nothing answers. That is the dead button clause 1.7 exists
|
|
15
|
-
* to prevent, and it is invisible in review: the row and the host are two files
|
|
16
|
-
* apart, and the affordance keeps working everywhere else.
|
|
17
|
-
*
|
|
18
|
-
* So the pairing is the assertion. A surface that renders the row names where
|
|
19
|
-
* its step is answered, and that file is checked to mount a host. A fourth
|
|
20
|
-
* surface fails here until it does the same.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
24
|
-
|
|
25
|
-
/** How the chrome's slot is read — `MailListHeader` writes it, everyone else reads it. */
|
|
26
|
-
const RENDERS_SLOT = /\.makeFilterSlot/;
|
|
27
|
-
const MOUNTS_HOST = /<SelectionWizardHost/;
|
|
28
|
-
|
|
29
|
-
/** Surface that renders the affordance → the file whose composition answers the step. */
|
|
30
|
-
const ANSWERED_BY: Record<string, string> = {
|
|
31
|
-
"DailyBrief.tsx": "DailyBrief.tsx",
|
|
32
|
-
"MessageList.tsx": "MessageList.tsx",
|
|
33
|
-
// The starred list's bar is shared with the brief's, so its host is mounted
|
|
34
|
-
// by the view rather than beside the bar.
|
|
35
|
-
"ThreadListInteraction.tsx": "FlaggedList.tsx",
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
const read = (file: string): string =>
|
|
39
|
-
readFileSync(resolve(here, file), "utf8");
|
|
40
|
-
|
|
41
|
-
const surfacesRenderingTheAffordance = (): string[] =>
|
|
42
|
-
readdirSync(here)
|
|
43
|
-
.filter((file) => file.endsWith(".tsx"))
|
|
44
|
-
.filter((file) => RENDERS_SLOT.test(read(file)));
|
|
45
|
-
|
|
46
|
-
describe("the make-filter entry", () => {
|
|
47
|
-
it("is rendered only by surfaces that say where its step is answered", () => {
|
|
48
|
-
assert.deepEqual(
|
|
49
|
-
surfacesRenderingTheAffordance().sort(),
|
|
50
|
-
Object.keys(ANSWERED_BY).sort(),
|
|
51
|
-
);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("is answered by a mounted wizard on every one of them", () => {
|
|
55
|
-
for (const [surface, host] of Object.entries(ANSWERED_BY)) {
|
|
56
|
-
assert.match(
|
|
57
|
-
read(host),
|
|
58
|
-
MOUNTS_HOST,
|
|
59
|
-
`${surface} is answered by ${host}`,
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
});
|
|
63
|
-
});
|