@remit/web-client 0.0.51 → 0.0.53
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/BriefPane.selection.test.ts +52 -0
- package/src/components/mail/BriefPane.tsx +7 -9
- package/src/components/mail/MessageCard.tsx +1 -1
- package/src/components/mail/MessageList.selection.test.ts +24 -0
- package/src/components/mail/MessageList.tsx +21 -6
- package/src/components/mail/ThreadListInteraction.test.ts +81 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.53",
|
|
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": {
|
|
@@ -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
|
}
|
|
@@ -212,7 +212,7 @@ const ExpandedCard = ({
|
|
|
212
212
|
return (
|
|
213
213
|
<ExpandedMessage
|
|
214
214
|
message={message}
|
|
215
|
-
isFocused={isFocused}
|
|
215
|
+
isFocused={mobile ? false : isFocused}
|
|
216
216
|
onHeaderClick={onToggle}
|
|
217
217
|
senderBadge={isTrusted ? <TrustedSenderBadge /> : undefined}
|
|
218
218
|
trailing={
|
|
@@ -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
|
|
|
@@ -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
|
+
});
|