@remit/web-client 0.0.52 → 0.0.54
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 +2 -2
- package/src/components/mail/BriefPane.selection.test.ts +52 -0
- package/src/components/mail/BriefPane.tsx +7 -9
- package/src/components/mail/MessageList.selection.test.ts +24 -0
- package/src/components/mail/MessageList.tsx +21 -6
- package/src/components/mail/MoveToTrigger.render.test.ts +153 -0
- package/src/components/mail/SelectionToolbar.render.test.ts +145 -0
- package/src/components/mail/ThreadListInteraction.test.ts +81 -1
- package/src/components/onboarding/OnboardingWizard.flow.test.ts +292 -0
- package/src/components/settings/AccountFormPanel.render.test.ts +246 -0
- package/src/components/ui/ErrorBanner.render.test.ts +104 -0
- package/src/components/ui/ErrorState.render.test.ts +82 -0
- package/src/hooks/mutation-optimism.test.ts +278 -0
- package/src/test-support/dom.ts +180 -0
- package/src/test-support/fixtures.ts +83 -0
- package/src/test-support/http.ts +72 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.54",
|
|
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": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"build:dist": "npm run generate:routes && node --import tsx harness/build.mjs",
|
|
30
30
|
"preview": "vite preview",
|
|
31
31
|
"test:typecheck": "npm run generate:routes && tsgo --noEmit",
|
|
32
|
-
"test:run": "node --import tsx --import ./test-support/register.mjs --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=
|
|
32
|
+
"test:run": "node --import tsx --import ./test-support/register.mjs --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-exclude='src/test-support/**' --test-coverage-lines=86 --test 'src/**/*.test.ts'",
|
|
33
33
|
"test": "npm run test:typecheck && npm run test:run"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
assert.match(body, /onMarkMessagesRead=\{onMarkMessagesRead\}/);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -381,14 +381,12 @@ function BriefPhone() {
|
|
|
381
381
|
const {
|
|
382
382
|
selectedThread,
|
|
383
383
|
conversation,
|
|
384
|
-
selectedMessageId,
|
|
385
384
|
onSelectMessage,
|
|
386
|
-
onSelectSearchResult,
|
|
387
385
|
onCloseThread,
|
|
388
386
|
nextMessageId,
|
|
389
387
|
previousMessageId,
|
|
390
388
|
} = useBriefPane();
|
|
391
|
-
const {
|
|
389
|
+
const { intelligenceOpen, onToggleIntelligence } = useMailContext();
|
|
392
390
|
|
|
393
391
|
if (conversation) {
|
|
394
392
|
return (
|
|
@@ -427,14 +425,14 @@ function BriefPhone() {
|
|
|
427
425
|
);
|
|
428
426
|
}
|
|
429
427
|
|
|
428
|
+
// The same list slot the multi-pane layout mounts, so the phone brief wires
|
|
429
|
+
// the bulk verbs and the selection bar through `DailyBrief` exactly as the
|
|
430
|
+
// desktop list does. Rendering `DailyBrief` here directly dropped
|
|
431
|
+
// `onDeleteMessages`, so a phone selection raised no action bar (#203) — the
|
|
432
|
+
// same reuse `FlaggedPhone` already relies on.
|
|
430
433
|
return (
|
|
431
434
|
<div className="h-full">
|
|
432
|
-
<
|
|
433
|
-
accounts={accounts}
|
|
434
|
-
selectedMessageId={selectedMessageId}
|
|
435
|
-
onSelectMessage={onSelectMessage}
|
|
436
|
-
onSelectSearchResult={onSelectSearchResult}
|
|
437
|
-
/>
|
|
435
|
+
<BriefList />
|
|
438
436
|
</div>
|
|
439
437
|
);
|
|
440
438
|
}
|
|
@@ -91,3 +91,27 @@ describe("MessageList escalated actions", () => {
|
|
|
91
91
|
);
|
|
92
92
|
});
|
|
93
93
|
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A bounded confirm-delete used to open the surviving neighbour by writing
|
|
97
|
+
* `selectedMessageId` into the URL. On desktop that fills the reading pane
|
|
98
|
+
* beside the list; on a single-pane mobile layout the same navigation replaced
|
|
99
|
+
* the list with a full-screen message, so a bulk delete read as "jumped into a
|
|
100
|
+
* random message" rather than "the rows are gone" (#202). Mobile now stays on
|
|
101
|
+
* the list and raises the same completion banner a chunked run does.
|
|
102
|
+
*/
|
|
103
|
+
describe("MessageList bounded delete stays on the list on mobile (#202)", () => {
|
|
104
|
+
it("only the desktop two-pane opens the surviving neighbour after a delete", () => {
|
|
105
|
+
assert.match(
|
|
106
|
+
source,
|
|
107
|
+
/if \(isDesktop\) \{\s*navigate\(\{\s*to: "\/mail\/\$mailboxId",\s*params: \{ mailboxId \},\s*search: \(prev\) => \(\{ \.\.\.prev, selectedMessageId: nextFocus \}\),\s*replace: true,\s*\}\);\s*\}/,
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("raises a completion banner on mobile so the delete is not silent", () => {
|
|
112
|
+
assert.match(
|
|
113
|
+
source,
|
|
114
|
+
/if \(!isDesktop\) \{\s*setCompletionBanner\(\s*bulkActionCompletionText\("delete", ids\.length\),?\s*\);\s*\}/,
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -662,12 +662,26 @@ export const MessageList = ({
|
|
|
662
662
|
pendingDomFocusRef.current = nextFocus;
|
|
663
663
|
cursorMovedByPointerRef.current = false;
|
|
664
664
|
setFocusedMessageId(nextFocus);
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
665
|
+
// Desktop is two-pane: opening the neighbour fills the reading pane
|
|
666
|
+
// beside the list. On a single-pane mobile layout the same navigation
|
|
667
|
+
// replaces the list with a full-screen message, so a bulk delete looks
|
|
668
|
+
// like it opened a random neighbour instead of removing the rows (#202).
|
|
669
|
+
// Mobile keeps the cursor move but stays on the list.
|
|
670
|
+
if (isDesktop) {
|
|
671
|
+
navigate({
|
|
672
|
+
to: "/mail/$mailboxId",
|
|
673
|
+
params: { mailboxId },
|
|
674
|
+
search: (prev) => ({ ...prev, selectedMessageId: nextFocus }),
|
|
675
|
+
replace: true,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Mobile keeps the list up, so it needs its own signal the delete landed —
|
|
681
|
+
// the transient completion banner a chunked run already raises (#202). On
|
|
682
|
+
// desktop the rows leaving the list beside the reading pane is signal enough.
|
|
683
|
+
if (!isDesktop) {
|
|
684
|
+
setCompletionBanner(bulkActionCompletionText("delete", ids.length));
|
|
671
685
|
}
|
|
672
686
|
}, [
|
|
673
687
|
pendingDelete,
|
|
@@ -676,6 +690,7 @@ export const MessageList = ({
|
|
|
676
690
|
exitSelection,
|
|
677
691
|
navigate,
|
|
678
692
|
mailboxId,
|
|
693
|
+
isDesktop,
|
|
679
694
|
runChunkedConfirmDelete,
|
|
680
695
|
]);
|
|
681
696
|
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The move-to-folder picker. It only fetches folders once it is opened, it
|
|
3
|
+
* marks the folder the messages are already in rather than offering it as a
|
|
4
|
+
* destination, and on desktop Escape or a click outside puts it away.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { afterEach, describe, it } from "node:test";
|
|
9
|
+
import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
10
|
+
import type { RemitImapMailboxResponse } from "@remit/api-http-client/types.gen.ts";
|
|
11
|
+
import { createElement } from "react";
|
|
12
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
13
|
+
import { makeMailbox } from "../../test-support/fixtures";
|
|
14
|
+
import { MoveToTrigger } from "./MoveToTrigger";
|
|
15
|
+
|
|
16
|
+
let harness: DomHarness | undefined;
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
harness?.close();
|
|
20
|
+
harness = undefined;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const ACCOUNT_ID = "acc-1";
|
|
24
|
+
|
|
25
|
+
const mount = (
|
|
26
|
+
options: {
|
|
27
|
+
mailboxes?: RemitImapMailboxResponse[];
|
|
28
|
+
currentMailboxId?: string;
|
|
29
|
+
disabledHint?: string;
|
|
30
|
+
onMove?: (destinationMailboxId: string) => void;
|
|
31
|
+
viewportWidth?: number;
|
|
32
|
+
} = {},
|
|
33
|
+
): DomHarness => {
|
|
34
|
+
harness = createDomHarness({ viewportWidth: options.viewportWidth });
|
|
35
|
+
if (options.mailboxes) {
|
|
36
|
+
harness.queryClient.setQueryData(
|
|
37
|
+
mailboxOperationsListMailboxesQueryKey({
|
|
38
|
+
path: { accountId: ACCOUNT_ID },
|
|
39
|
+
}),
|
|
40
|
+
{ items: options.mailboxes },
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
harness.renderApp(
|
|
44
|
+
createElement(MoveToTrigger, {
|
|
45
|
+
accountId: ACCOUNT_ID,
|
|
46
|
+
currentMailboxId: options.currentMailboxId ?? "mbx-inbox",
|
|
47
|
+
onMove: options.onMove ?? (() => undefined),
|
|
48
|
+
disabledHint: options.disabledHint,
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
return harness;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const FOLDERS = [
|
|
55
|
+
makeMailbox({ mailboxId: "mbx-inbox", fullPath: "INBOX" }),
|
|
56
|
+
makeMailbox({ mailboxId: "mbx-work", fullPath: "Work" }),
|
|
57
|
+
makeMailbox({ mailboxId: "mbx-receipts", fullPath: "Receipts" }),
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
describe("MoveToTrigger", () => {
|
|
61
|
+
it("reports itself as collapsed until it is opened", () => {
|
|
62
|
+
const dom = mount({ mailboxes: FOLDERS });
|
|
63
|
+
const trigger = dom.byLabel("Move to folder");
|
|
64
|
+
assert.equal(trigger.getAttribute("aria-expanded"), "false");
|
|
65
|
+
assert.equal(trigger.getAttribute("aria-controls"), null);
|
|
66
|
+
assert.equal(dom.query('[role="listbox"], input'), null);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("opens a listbox of destinations and marks the folder we are in", () => {
|
|
70
|
+
const dom = mount({ mailboxes: FOLDERS });
|
|
71
|
+
dom.click(dom.byLabel("Move to folder"));
|
|
72
|
+
|
|
73
|
+
assert.equal(
|
|
74
|
+
dom.byLabel("Move to folder").getAttribute("aria-expanded"),
|
|
75
|
+
"true",
|
|
76
|
+
);
|
|
77
|
+
const labels = dom
|
|
78
|
+
.queryAll("[role=option]")
|
|
79
|
+
.map((option) => option.textContent ?? "");
|
|
80
|
+
assert.ok(labels.some((label) => label.includes("Work")));
|
|
81
|
+
assert.ok(labels.some((label) => label.includes("Receipts")));
|
|
82
|
+
|
|
83
|
+
// The folder the messages are already in stays in the list, marked as
|
|
84
|
+
// where they are now rather than presented as somewhere to move them.
|
|
85
|
+
const current = labels.find((label) => label.includes("current"));
|
|
86
|
+
assert.ok(current, "the source folder is marked as the current one");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("moves to the folder the user picks and closes itself", () => {
|
|
90
|
+
const moved: string[] = [];
|
|
91
|
+
const dom = mount({
|
|
92
|
+
mailboxes: FOLDERS,
|
|
93
|
+
onMove: (id) => moved.push(id),
|
|
94
|
+
});
|
|
95
|
+
dom.click(dom.byLabel("Move to folder"));
|
|
96
|
+
dom.click(dom.byText("[role=option]", "Work"));
|
|
97
|
+
|
|
98
|
+
assert.deepEqual(moved, ["mbx-work"]);
|
|
99
|
+
assert.equal(
|
|
100
|
+
dom.byLabel("Move to folder").getAttribute("aria-expanded"),
|
|
101
|
+
"false",
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("closes on Escape and on a click outside it", () => {
|
|
106
|
+
const dom = mount({ mailboxes: FOLDERS });
|
|
107
|
+
const isOpen = () =>
|
|
108
|
+
dom.byLabel("Move to folder").getAttribute("aria-expanded") === "true";
|
|
109
|
+
|
|
110
|
+
dom.click(dom.byLabel("Move to folder"));
|
|
111
|
+
assert.equal(isOpen(), true);
|
|
112
|
+
dom.dispatch(
|
|
113
|
+
dom.window.document,
|
|
114
|
+
new dom.window.KeyboardEvent("keydown", { key: "Escape" }),
|
|
115
|
+
);
|
|
116
|
+
assert.equal(isOpen(), false);
|
|
117
|
+
|
|
118
|
+
dom.click(dom.byLabel("Move to folder"));
|
|
119
|
+
assert.equal(isOpen(), true);
|
|
120
|
+
dom.dispatch(
|
|
121
|
+
dom.window.document.body,
|
|
122
|
+
new dom.window.MouseEvent("mousedown", { bubbles: true }),
|
|
123
|
+
);
|
|
124
|
+
assert.equal(isOpen(), false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("refuses to open, and says why, when the selection spans accounts", () => {
|
|
128
|
+
const hint = "Select messages from one account to move them";
|
|
129
|
+
const dom = mount({ mailboxes: FOLDERS, disabledHint: hint });
|
|
130
|
+
const trigger = dom.byLabel("Move to folder");
|
|
131
|
+
assert.equal(trigger.getAttribute("title"), hint);
|
|
132
|
+
|
|
133
|
+
dom.click(trigger);
|
|
134
|
+
assert.equal(trigger.getAttribute("aria-expanded"), "false");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("opens a dialog rather than a popover on a phone", () => {
|
|
138
|
+
const dom = mount({ mailboxes: FOLDERS, viewportWidth: 390 });
|
|
139
|
+
assert.equal(
|
|
140
|
+
dom.byLabel("Move to folder").getAttribute("aria-haspopup"),
|
|
141
|
+
"dialog",
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("asks for the folder list only once the picker is opened", () => {
|
|
146
|
+
const dom = mount();
|
|
147
|
+
assert.equal(dom.queryAll("[role=option]").length, 0);
|
|
148
|
+
dom.click(dom.byLabel("Move to folder"));
|
|
149
|
+
// No cached mailboxes and no network in the test harness: the picker
|
|
150
|
+
// shows its loading state rather than an empty list of destinations.
|
|
151
|
+
assert.match(dom.text(), /Loading folders/);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bulk-action bar. Two rules govern it: actions are never disabled (they
|
|
3
|
+
* no-op and explain — `doc/rules/ux.md`), and Move/Organize only appear when
|
|
4
|
+
* the selection is inside a single account, because neither works across
|
|
5
|
+
* accounts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { afterEach, describe, it } from "node:test";
|
|
10
|
+
import { createElement } from "react";
|
|
11
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
12
|
+
import { SelectionToolbar } from "./SelectionToolbar";
|
|
13
|
+
|
|
14
|
+
let harness: DomHarness | undefined;
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
harness?.close();
|
|
18
|
+
harness = undefined;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
type ToolbarProps = Parameters<typeof SelectionToolbar>[0];
|
|
22
|
+
|
|
23
|
+
const mount = (props: Partial<ToolbarProps> = {}): DomHarness => {
|
|
24
|
+
harness = createDomHarness();
|
|
25
|
+
harness.renderApp(
|
|
26
|
+
createElement(SelectionToolbar, {
|
|
27
|
+
selectedCount: 2,
|
|
28
|
+
onDelete: () => undefined,
|
|
29
|
+
onClearSelection: () => undefined,
|
|
30
|
+
...props,
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
return harness;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe("SelectionToolbar", () => {
|
|
37
|
+
it("renders nothing while nothing is selected", () => {
|
|
38
|
+
const dom = mount({ selectedCount: 0 });
|
|
39
|
+
assert.equal(dom.html(), "");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("counts the selection in words the user reads", () => {
|
|
43
|
+
assert.match(mount({ selectedCount: 1 }).text(), /1 message selected/);
|
|
44
|
+
harness?.close();
|
|
45
|
+
harness = undefined;
|
|
46
|
+
assert.match(mount({ selectedCount: 4 }).text(), /4 messages selected/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("hides Move until both an account and a source mailbox are known", () => {
|
|
50
|
+
const dom = mount({ onMove: () => undefined, accountId: "acc-1" });
|
|
51
|
+
assert.equal(dom.query('[aria-label="Move selected messages"]'), null);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("offers Move and Organize once the selection sits in one account", () => {
|
|
55
|
+
const dom = mount({
|
|
56
|
+
onMove: () => undefined,
|
|
57
|
+
onOrganize: () => undefined,
|
|
58
|
+
accountId: "acc-1",
|
|
59
|
+
currentMailboxId: "mbx-inbox",
|
|
60
|
+
});
|
|
61
|
+
assert.ok(dom.query('[aria-label="Move selected messages"]'));
|
|
62
|
+
assert.ok(dom.query('[aria-label="Organize similar messages"]'));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("withdraws Organize and explains why when the selection spans accounts", () => {
|
|
66
|
+
const dom = mount({
|
|
67
|
+
onMove: () => undefined,
|
|
68
|
+
onOrganize: () => undefined,
|
|
69
|
+
accountId: "acc-1",
|
|
70
|
+
currentMailboxId: "mbx-inbox",
|
|
71
|
+
moveDisabledHint: "Select messages from one account to move them",
|
|
72
|
+
});
|
|
73
|
+
assert.equal(dom.query('[aria-label="Organize similar messages"]'), null);
|
|
74
|
+
assert.match(dom.text(), /Select messages from one account to move them/);
|
|
75
|
+
assert.match(
|
|
76
|
+
dom.query('[role="status"]')?.textContent ?? "",
|
|
77
|
+
/one account/,
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("keeps every action pressable while a mutation is in flight (ux.md)", () => {
|
|
82
|
+
const dom = mount({ isDeleting: true, onMarkAsRead: () => undefined });
|
|
83
|
+
for (const button of dom.queryAll<HTMLButtonElement>("button")) {
|
|
84
|
+
assert.equal(button.disabled, false);
|
|
85
|
+
}
|
|
86
|
+
assert.equal(
|
|
87
|
+
dom.byLabel("Delete selected messages").getAttribute("aria-busy"),
|
|
88
|
+
"true",
|
|
89
|
+
);
|
|
90
|
+
assert.match(dom.text(), /Deleting\.\.\./);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("no-ops rather than firing a second time while busy", () => {
|
|
94
|
+
let deletes = 0;
|
|
95
|
+
let marks = 0;
|
|
96
|
+
const dom = mount({
|
|
97
|
+
isMoving: true,
|
|
98
|
+
onDelete: () => {
|
|
99
|
+
deletes += 1;
|
|
100
|
+
},
|
|
101
|
+
onMarkAsRead: () => {
|
|
102
|
+
marks += 1;
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
dom.click(dom.byLabel("Delete selected messages"));
|
|
106
|
+
dom.click(dom.byLabel("Mark as read"));
|
|
107
|
+
assert.equal(deletes, 0);
|
|
108
|
+
assert.equal(marks, 0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("fires delete, mark-read and clear when idle", () => {
|
|
112
|
+
let cleared = 0;
|
|
113
|
+
let deletes = 0;
|
|
114
|
+
let marks = 0;
|
|
115
|
+
const dom = mount({
|
|
116
|
+
onDelete: () => {
|
|
117
|
+
deletes += 1;
|
|
118
|
+
},
|
|
119
|
+
onClearSelection: () => {
|
|
120
|
+
cleared += 1;
|
|
121
|
+
},
|
|
122
|
+
onMarkAsRead: () => {
|
|
123
|
+
marks += 1;
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
dom.click(dom.byLabel("Delete selected messages"));
|
|
127
|
+
dom.click(dom.byLabel("Mark as read"));
|
|
128
|
+
dom.click(dom.byLabel("Clear selection"));
|
|
129
|
+
assert.deepEqual([deletes, marks, cleared], [1, 1, 1]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("opens the organize flow for the current selection", () => {
|
|
133
|
+
let organized = 0;
|
|
134
|
+
const dom = mount({
|
|
135
|
+
onMove: () => undefined,
|
|
136
|
+
accountId: "acc-1",
|
|
137
|
+
currentMailboxId: "mbx-inbox",
|
|
138
|
+
onOrganize: () => {
|
|
139
|
+
organized += 1;
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
dom.click(dom.byLabel("Organize similar messages"));
|
|
143
|
+
assert.equal(organized, 1);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -13,7 +13,10 @@ import type { JSDOM } from "jsdom";
|
|
|
13
13
|
import { act, createElement, createRef, useState } from "react";
|
|
14
14
|
import { createRoot, type Root } from "react-dom/client";
|
|
15
15
|
import type { MessageListCommands } from "./MessageList";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
ThreadListInteraction,
|
|
18
|
+
useThreadListSelection,
|
|
19
|
+
} from "./ThreadListInteraction";
|
|
17
20
|
|
|
18
21
|
let dom: JSDOM;
|
|
19
22
|
let container: HTMLElement;
|
|
@@ -197,3 +200,80 @@ describe("ThreadListInteraction — delete confirms first", () => {
|
|
|
197
200
|
});
|
|
198
201
|
});
|
|
199
202
|
});
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A background refresh drops the ids that left and keeps every survivor — the
|
|
206
|
+
* same intersect-on-refresh guarantee `useSelection.test.ts` locks for the pure
|
|
207
|
+
* helper (#111), here through the live provider whose effect runs it whenever
|
|
208
|
+
* the rendered rows change (a chip filter, a collapsed section, mail deleted
|
|
209
|
+
* elsewhere).
|
|
210
|
+
*/
|
|
211
|
+
function mountSelectableList(initialIds: string[]) {
|
|
212
|
+
const commandsRef = createRef<MessageListCommands | null>();
|
|
213
|
+
let setIds: ((ids: string[]) => void) | undefined;
|
|
214
|
+
let selected: string[] = [];
|
|
215
|
+
const Probe = () => {
|
|
216
|
+
const { selectedIds } = useThreadListSelection();
|
|
217
|
+
selected = Array.from(selectedIds).sort();
|
|
218
|
+
return null;
|
|
219
|
+
};
|
|
220
|
+
const Harness = () => {
|
|
221
|
+
const [ids, set] = useState(initialIds);
|
|
222
|
+
setIds = set;
|
|
223
|
+
return createElement(
|
|
224
|
+
ThreadListInteraction,
|
|
225
|
+
{ selectedMessageId: undefined, onOpen: () => undefined, commandsRef },
|
|
226
|
+
...rowElements(ids),
|
|
227
|
+
createElement(Probe, { key: "probe" }),
|
|
228
|
+
);
|
|
229
|
+
};
|
|
230
|
+
act(() => root.render(createElement(Harness)));
|
|
231
|
+
return {
|
|
232
|
+
commands: () => {
|
|
233
|
+
const commands = commandsRef.current;
|
|
234
|
+
if (!commands) throw new Error("commands not published");
|
|
235
|
+
return commands;
|
|
236
|
+
},
|
|
237
|
+
render: async (ids: string[]) => {
|
|
238
|
+
await act(async () => {
|
|
239
|
+
setIds?.(ids);
|
|
240
|
+
});
|
|
241
|
+
},
|
|
242
|
+
selected: () => selected,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
describe("ThreadListInteraction — selection survives a background refresh (#111)", () => {
|
|
247
|
+
it("keeps every survivor when a refresh drops one selected row", async () => {
|
|
248
|
+
const list = mountSelectableList(["m1", "m2", "m3"]);
|
|
249
|
+
|
|
250
|
+
// jsdom has no matchMedia, so the provider runs in mobile multi-select
|
|
251
|
+
// mode: focusFirst seeds the cursor, x toggles it in, and focusLast then
|
|
252
|
+
// toggles the row it lands on straight into the selection.
|
|
253
|
+
act(() => list.commands().focusFirst());
|
|
254
|
+
act(() => list.commands().toggleSelect());
|
|
255
|
+
act(() => list.commands().focusLast());
|
|
256
|
+
assert.deepEqual(list.selected(), ["m1", "m3"]);
|
|
257
|
+
|
|
258
|
+
// m3 leaves the rendered set (deleted elsewhere / filtered out); m2 is
|
|
259
|
+
// present but was never selected.
|
|
260
|
+
await list.render(["m1", "m2"]);
|
|
261
|
+
|
|
262
|
+
assert.deepEqual(
|
|
263
|
+
list.selected(),
|
|
264
|
+
["m1"],
|
|
265
|
+
"the survivor stays selected, the departed id is dropped, and nothing new is added",
|
|
266
|
+
);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("empties the selection only when every selected row leaves", async () => {
|
|
270
|
+
const list = mountSelectableList(["m1", "m2"]);
|
|
271
|
+
act(() => list.commands().focusFirst());
|
|
272
|
+
act(() => list.commands().toggleSelect());
|
|
273
|
+
assert.deepEqual(list.selected(), ["m1"]);
|
|
274
|
+
|
|
275
|
+
await list.render(["m9"]);
|
|
276
|
+
|
|
277
|
+
assert.deepEqual(list.selected(), []);
|
|
278
|
+
});
|
|
279
|
+
});
|