@remit/web-client 0.0.279 → 0.0.280
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/DailyBrief.tsx +27 -22
- package/src/components/mail/FlaggedList.tsx +26 -34
- package/src/components/mail/brief-server-sections.render.test.ts +28 -23
- package/src/components/mail/flagged-search-reach.render.test.ts +272 -0
- package/src/hooks/useBriefSections.ts +13 -4
- package/src/hooks/useStarredThreads.ts +13 -36
- package/src/lib/brief-criteria.test.ts +64 -22
- package/src/lib/brief-criteria.ts +36 -17
- package/src/lib/brief.test.ts +0 -234
- package/src/lib/brief.ts +11 -87
- package/src/lib/flagged-criteria.test.ts +19 -4
- package/src/lib/flagged-criteria.ts +16 -15
- package/src/lib/inbox-filters.ts +18 -1
- package/src/lib/thread-search-tokens.ts +4 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.280",
|
|
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": {
|
|
@@ -25,9 +25,12 @@
|
|
|
25
25
|
* over the rows. `FilterPanelProvider` shares that panel's open state between
|
|
26
26
|
* the caret and the sheet, the same shape `MailViewChrome` gives the mailbox and
|
|
27
27
|
* Starred views. The list draws those controls but applies none of them: the
|
|
28
|
-
* category and every chip a parameter can express travel with
|
|
29
|
-
* requests, and the two that cannot ("From contacts", "Today") are
|
|
30
|
-
* alongside the residual tokens (#314). The
|
|
28
|
+
* category, the account pill and every chip a parameter can express travel with
|
|
29
|
+
* the section requests, and the two that cannot ("From contacts", "Today") are
|
|
30
|
+
* applied here alongside the residual tokens (#314). The account pill is a
|
|
31
|
+
* parameter rather than a pass over the rows because a count taken over every
|
|
32
|
+
* account is not the size of a list showing one, which is what used to take the
|
|
33
|
+
* number off every header (#1136). The phone search takeover reads the same
|
|
31
34
|
* selection and the same rows, so a filter set on one surface holds on the
|
|
32
35
|
* other. Under a query the chips are terms of that query — see
|
|
33
36
|
* `briefChipFilters` — so what narrows the list is readable and editable in the
|
|
@@ -112,8 +115,6 @@ import {
|
|
|
112
115
|
BRIEF_CATEGORIES,
|
|
113
116
|
type BriefCategoryResult,
|
|
114
117
|
briefSections,
|
|
115
|
-
briefSectionTotal,
|
|
116
|
-
excludeMutedSenders,
|
|
117
118
|
matchesSearchTokens,
|
|
118
119
|
toThreadRowData,
|
|
119
120
|
} from "@/lib/brief";
|
|
@@ -606,8 +607,14 @@ export function DailyBrief({
|
|
|
606
607
|
// chip decides which sections are on screen rather than narrowing a shared
|
|
607
608
|
// one.
|
|
608
609
|
const { criteria: chipCriteria, residual: residualTokens } = useMemo(
|
|
609
|
-
() =>
|
|
610
|
-
|
|
610
|
+
() =>
|
|
611
|
+
briefCriteria(
|
|
612
|
+
requestCategory,
|
|
613
|
+
requestFilters,
|
|
614
|
+
queryTokens,
|
|
615
|
+
selectedAccountId === "all" ? undefined : selectedAccountId,
|
|
616
|
+
),
|
|
617
|
+
[requestCategory, requestFilters, queryTokens, selectedAccountId],
|
|
611
618
|
);
|
|
612
619
|
const shownCategories = useMemo<RemitImapMessageCategory[]>(
|
|
613
620
|
() =>
|
|
@@ -624,13 +631,13 @@ export function DailyBrief({
|
|
|
624
631
|
}),
|
|
625
632
|
[chipCriteria, sq, requestCategory],
|
|
626
633
|
);
|
|
627
|
-
// The
|
|
628
|
-
//
|
|
629
|
-
//
|
|
634
|
+
// The tokens no parameter carries narrow the rows after they arrive, so while
|
|
635
|
+
// one is active the count is of a wider set than the list and the sections
|
|
636
|
+
// show no number at all. The account pills are no longer among them: the
|
|
637
|
+
// request carries the account, so the count narrows with the list (#1136).
|
|
630
638
|
const counted = briefCountsMatchRows({
|
|
631
639
|
residual: residualTokens,
|
|
632
640
|
attributes: requestFilters,
|
|
633
|
-
accountScoped: selectedAccountId !== "all",
|
|
634
641
|
});
|
|
635
642
|
|
|
636
643
|
const {
|
|
@@ -666,12 +673,12 @@ export function DailyBrief({
|
|
|
666
673
|
refetchSections();
|
|
667
674
|
}, [underQuery, refetchSearch, refetchSections]);
|
|
668
675
|
|
|
669
|
-
// What the request could not carry:
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
// `
|
|
673
|
-
//
|
|
674
|
-
//
|
|
676
|
+
// What the request could not carry: the two chips no endpoint takes a
|
|
677
|
+
// parameter for ("From contacts", "Today"), and the residual tokens
|
|
678
|
+
// (`before:`, `after:`, `in:`, `account:`). Everything else — the account
|
|
679
|
+
// pill, mute, the free text, `from:` and `subject:` — was answered over the
|
|
680
|
+
// whole scope by the request itself, and the order the rows arrived in is
|
|
681
|
+
// kept: narrowing never re-sorts.
|
|
675
682
|
const clientOnlyFilters = useMemo(
|
|
676
683
|
() => briefClientOnlyFilters(requestFilters),
|
|
677
684
|
[requestFilters],
|
|
@@ -682,23 +689,21 @@ export function DailyBrief({
|
|
|
682
689
|
// `count` is thread-distinct, and a list keying on messageId puts twelve
|
|
683
690
|
// rows under a header reading one. Collapsed before anything reads a
|
|
684
691
|
// length, so the rows and the number are the same unit.
|
|
685
|
-
dedupeByThread(
|
|
692
|
+
dedupeByThread(rows)
|
|
686
693
|
.map(toThreadRowData)
|
|
687
694
|
.filter(
|
|
688
695
|
(t) =>
|
|
689
|
-
(selectedAccountId === "all" ||
|
|
690
|
-
t.accountId === selectedAccountId) &&
|
|
691
696
|
matchesBriefFilters(t, clientOnlyFilters) &&
|
|
692
697
|
matchesSearchTokens(t, residualTokens),
|
|
693
698
|
),
|
|
694
|
-
[
|
|
699
|
+
[clientOnlyFilters, residualTokens],
|
|
695
700
|
);
|
|
696
701
|
|
|
697
702
|
const briefRows = useMemo<BriefCategoryResult[]>(
|
|
698
703
|
() =>
|
|
699
704
|
sectionRows.map((section) => ({
|
|
700
705
|
category: section.category,
|
|
701
|
-
total:
|
|
706
|
+
total: section.total,
|
|
702
707
|
atCap: section.atCap,
|
|
703
708
|
loading: section.loading,
|
|
704
709
|
failed: section.failed,
|
|
@@ -18,6 +18,13 @@
|
|
|
18
18
|
* pages the user happened to have loaded, so a category whose mail sat below
|
|
19
19
|
* the newest page showed an empty list, and the count grew with every press of
|
|
20
20
|
* "load more" while being presented as a total.
|
|
21
|
+
*
|
|
22
|
+
* The free text and the `from:` / `subject:` tokens are parameters too (#1128,
|
|
23
|
+
* #1135), so a search is one listing request rather than a second query merged
|
|
24
|
+
* into it. That is what keeps a search's failure loud, its pages continuable
|
|
25
|
+
* and its loading state its own — a separate search query had none of the
|
|
26
|
+
* three, so an expired session read as "no matches", "Load more" did nothing,
|
|
27
|
+
* and a cached listing flashed the empty state.
|
|
21
28
|
*/
|
|
22
29
|
import {
|
|
23
30
|
flaggedFilterConfig,
|
|
@@ -32,17 +39,11 @@ import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
|
32
39
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
33
40
|
import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
|
|
34
41
|
import {
|
|
35
|
-
useStarredTextSearch,
|
|
36
42
|
useStarredThreads,
|
|
37
43
|
useStarredUnreadCount,
|
|
38
44
|
} from "@/hooks/useStarredThreads";
|
|
39
45
|
import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
|
|
40
|
-
import {
|
|
41
|
-
matchesBriefSearch,
|
|
42
|
-
matchesSearchTokens,
|
|
43
|
-
mergeSearchRows,
|
|
44
|
-
toThreadRowData,
|
|
45
|
-
} from "@/lib/brief";
|
|
46
|
+
import { matchesSearchTokens, toThreadRowData } from "@/lib/brief";
|
|
46
47
|
import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
|
|
47
48
|
import { flaggedCriteria } from "@/lib/flagged-criteria";
|
|
48
49
|
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
@@ -65,9 +66,6 @@ import {
|
|
|
65
66
|
useThreadListSelection,
|
|
66
67
|
} from "./ThreadListInteraction";
|
|
67
68
|
|
|
68
|
-
/** One page of the server-filtered text search, merged with the listing below. */
|
|
69
|
-
const TEXT_SEARCH_PAGE_SIZE = 200;
|
|
70
|
-
|
|
71
69
|
/**
|
|
72
70
|
* The wizard this view's verbs walk, and the one its search entry lands on.
|
|
73
71
|
*
|
|
@@ -185,6 +183,10 @@ export function FlaggedList({
|
|
|
185
183
|
[criteria, sq],
|
|
186
184
|
);
|
|
187
185
|
|
|
186
|
+
// One request, free text included. The text is a parameter like every other
|
|
187
|
+
// criterion, so a search is this listing narrowed rather than a second query
|
|
188
|
+
// beside it — which is what gives a search the same error state, the same
|
|
189
|
+
// pagination and the same loading state as the unsearched list.
|
|
188
190
|
const {
|
|
189
191
|
threads,
|
|
190
192
|
isLoading,
|
|
@@ -194,27 +196,17 @@ export function FlaggedList({
|
|
|
194
196
|
fetchNextPage,
|
|
195
197
|
hasNextPage,
|
|
196
198
|
isFetchingNextPage,
|
|
197
|
-
} = useStarredThreads(
|
|
198
|
-
const textMatches = useStarredTextSearch(textCriteria, TEXT_SEARCH_PAGE_SIZE);
|
|
199
|
+
} = useStarredThreads(textCriteria);
|
|
199
200
|
|
|
200
|
-
const rows = useMemo<ThreadRowData[]>(
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
)
|
|
210
|
-
: listed;
|
|
211
|
-
// What is left is what no parameter can carry: `before:`, `after:`, `in:`,
|
|
212
|
-
// `account:`, and a token the chips overruled. The collapse runs last so
|
|
213
|
-
// the two halves of a text search cannot land one conversation twice.
|
|
214
|
-
return dedupeByThread(matched).filter((t) =>
|
|
215
|
-
matchesSearchTokens(t, residualTokens),
|
|
216
|
-
);
|
|
217
|
-
}, [threads, textMatches, sq, residualTokens]);
|
|
201
|
+
const rows = useMemo<ThreadRowData[]>(
|
|
202
|
+
() =>
|
|
203
|
+
// What is left is what no parameter can carry: `before:`, `after:`,
|
|
204
|
+
// `in:`, `account:`, and a token the chips overruled.
|
|
205
|
+
dedupeByThread(threads)
|
|
206
|
+
.map(toThreadRowData)
|
|
207
|
+
.filter((t) => matchesSearchTokens(t, residualTokens)),
|
|
208
|
+
[threads, residualTokens],
|
|
209
|
+
);
|
|
218
210
|
|
|
219
211
|
const openRow = useCallback(
|
|
220
212
|
(id: string, options?: OpenMessageOptions) => {
|
|
@@ -250,9 +242,9 @@ export function FlaggedList({
|
|
|
250
242
|
const unreadCount = useStarredUnreadCount(textCriteria) ?? null;
|
|
251
243
|
|
|
252
244
|
// An empty list has to say how much was looked at, and the answer comes off
|
|
253
|
-
// the request. Every chip and every carried token is a
|
|
254
|
-
// the server answered over the whole collection; a
|
|
255
|
-
//
|
|
245
|
+
// the request. Every chip, the free text and every carried token is a field
|
|
246
|
+
// on the row, so the server answered each over the whole collection; only a
|
|
247
|
+
// residual token — a date, a mailbox, an account — saw the loaded pages.
|
|
256
248
|
//
|
|
257
249
|
// `is:starred` is dropped: this view is starred mail, so the token restates
|
|
258
250
|
// the collection rather than narrowing it.
|
|
@@ -263,7 +255,7 @@ export function FlaggedList({
|
|
|
263
255
|
const listFilter: MessageListFilter | undefined = listNarrowing({
|
|
264
256
|
chips: { category: selectedCategory, attributes: activeFilters },
|
|
265
257
|
tokens: narrowingTokens,
|
|
266
|
-
reach: residualTokens.length > 0
|
|
258
|
+
reach: residualTokens.length > 0 ? "loaded-pages" : "whole-folder",
|
|
267
259
|
onClear: clearNarrowing,
|
|
268
260
|
});
|
|
269
261
|
|
|
@@ -546,33 +546,38 @@ describe("the brief's sections come from the server (#312)", () => {
|
|
|
546
546
|
);
|
|
547
547
|
});
|
|
548
548
|
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
if (!params.getAll("category").includes("marketing")) return undefined;
|
|
557
|
-
const page = (ROWS.marketing ?? []).slice(0, SECTION_ROW_CAP);
|
|
558
|
-
return {
|
|
559
|
-
items: page.map((row, index) =>
|
|
560
|
-
index === 0 ? { ...row, muted: true } : row,
|
|
561
|
-
),
|
|
562
|
-
};
|
|
563
|
-
});
|
|
564
|
-
await settled(dom, renderedSubjects("marketing", SECTION_ROW_CAP)[1]);
|
|
549
|
+
// #1137: mute used to be applied after the rows arrived, so a header counted
|
|
550
|
+
// mail the list dropped and "Show all" opened rows the brief would not
|
|
551
|
+
// render. The brief asks the server to leave muted senders out instead, of
|
|
552
|
+
// the rows and of the count alike.
|
|
553
|
+
it("asks every section and every count to leave muted senders out", async () => {
|
|
554
|
+
const mounted = await mount();
|
|
555
|
+
await settled(mounted, renderedSubjects("marketing", SECTION_ROW_CAP)[0]);
|
|
565
556
|
|
|
566
|
-
const
|
|
567
|
-
assert.
|
|
557
|
+
const asked = threadRequests();
|
|
558
|
+
assert.ok(asked.length > 0, "the brief issued no request");
|
|
559
|
+
for (const call of asked) {
|
|
560
|
+
assert.equal(
|
|
561
|
+
paramsOf(call).get("muted"),
|
|
562
|
+
"false",
|
|
563
|
+
"a brief request counted or listed muted senders",
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
// The number is the server's, and it now answers the same predicate the list
|
|
569
|
+
// does, so it is stated rather than withheld.
|
|
570
|
+
it("states the section's size with mute already taken off it", async () => {
|
|
571
|
+
const mounted = await mount();
|
|
572
|
+
await settled(mounted, renderedSubjects("marketing", SECTION_ROW_CAP)[0]);
|
|
573
|
+
|
|
574
|
+
const marketing = mounted.byText("button", "Marketing");
|
|
575
|
+
assert.match(
|
|
568
576
|
marketing.textContent ?? "",
|
|
569
577
|
new RegExp(String(countOf("marketing"))),
|
|
570
|
-
"
|
|
571
|
-
);
|
|
572
|
-
assert.ok(
|
|
573
|
-
dom.text().includes("Show all"),
|
|
574
|
-
"withholding the number stranded the reader in the section",
|
|
578
|
+
"the section withheld a number the request had narrowed",
|
|
575
579
|
);
|
|
580
|
+
assert.ok(mounted.text().includes("Show all"));
|
|
576
581
|
});
|
|
577
582
|
|
|
578
583
|
// The requests are built from committed state. Typing is not committing: a
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A search of the Starred collection is the listing narrowed, not a second
|
|
3
|
+
* query beside it.
|
|
4
|
+
*
|
|
5
|
+
* It used to be a second query: one uncontinuable page, with no error state and
|
|
6
|
+
* no loading state of its own. Three things followed, and none of them is
|
|
7
|
+
* visible from a unit test of the criteria — only from the screen.
|
|
8
|
+
*
|
|
9
|
+
* A failed search rendered as "no matches". The generated client throws on a
|
|
10
|
+
* 401, the hook swallowed it (`data?.items ?? []`), and an expired session was
|
|
11
|
+
* indistinguishable from a collection holding nothing — the reading the
|
|
12
|
+
* no-silent-auth-failure rule exists to forbid.
|
|
13
|
+
*
|
|
14
|
+
* "Load more" was dead under a query: the button read `hasNextPage` off the
|
|
15
|
+
* listing query while the rows came from the search one, so it either did not
|
|
16
|
+
* appear or fetched a page nothing rendered.
|
|
17
|
+
*
|
|
18
|
+
* And the empty state flashed: `listState` read the listing's loading flag, so
|
|
19
|
+
* a cached listing beside an in-flight search said "no matches" before the
|
|
20
|
+
* search had answered.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import assert from "node:assert/strict";
|
|
24
|
+
import { afterEach, describe, it } from "node:test";
|
|
25
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
26
|
+
import {
|
|
27
|
+
type AnyRouter,
|
|
28
|
+
createMemoryHistory,
|
|
29
|
+
createRootRoute,
|
|
30
|
+
createRoute,
|
|
31
|
+
createRouter,
|
|
32
|
+
Outlet,
|
|
33
|
+
RouterProvider,
|
|
34
|
+
} from "@tanstack/react-router";
|
|
35
|
+
import { createElement } from "react";
|
|
36
|
+
import { ComposeProvider } from "@/components/compose/ComposeProvider";
|
|
37
|
+
import { MailContext, type MailContextValue } from "@/lib/mail-context";
|
|
38
|
+
import { MailFreshnessProvider } from "@/lib/mail-freshness";
|
|
39
|
+
import { EMPTY_RESULT_FOLDER_INDEX } from "@/lib/result-folder";
|
|
40
|
+
import { useOpenThreadPath } from "@/routing";
|
|
41
|
+
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
42
|
+
import { makeThreadMessage } from "@/test-support/fixtures";
|
|
43
|
+
import {
|
|
44
|
+
type HttpCall,
|
|
45
|
+
type HttpMock,
|
|
46
|
+
httpError,
|
|
47
|
+
mockFetch,
|
|
48
|
+
} from "@/test-support/http";
|
|
49
|
+
import { FlaggedPane } from "./FlaggedPane";
|
|
50
|
+
|
|
51
|
+
const QUERY = "concierge";
|
|
52
|
+
const DESKTOP_WIDTH = 1440;
|
|
53
|
+
|
|
54
|
+
let harness: DomHarness | undefined;
|
|
55
|
+
let http: HttpMock | undefined;
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
harness?.close();
|
|
59
|
+
harness = undefined;
|
|
60
|
+
http?.restore();
|
|
61
|
+
http = undefined;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// The router reads `self` at construction; the shared jsdom globals stop at
|
|
65
|
+
// `window`.
|
|
66
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
67
|
+
|
|
68
|
+
const match = (index: number): RemitImapThreadMessageResponse =>
|
|
69
|
+
makeThreadMessage({
|
|
70
|
+
messageId: `match-${index}`,
|
|
71
|
+
threadId: `thread-match-${index}`,
|
|
72
|
+
subject: `Parcel ${index}`,
|
|
73
|
+
snippet: "Your parcel was left with the concierge",
|
|
74
|
+
hasStars: true,
|
|
75
|
+
sentDate: 1_767_225_600_000 - index * 1_000,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/** A full page, so the server hands back a continuation token with it. */
|
|
79
|
+
const FIRST_PAGE = Array.from({ length: 50 }, (_, index) => match(index));
|
|
80
|
+
const SECOND_PAGE = [match(50)];
|
|
81
|
+
|
|
82
|
+
const mailContext = (query: string): MailContextValue => ({
|
|
83
|
+
accounts: [],
|
|
84
|
+
mailboxNameIndex: new Map(),
|
|
85
|
+
accountNameIndex: new Map(),
|
|
86
|
+
resultFolderIndex: EMPTY_RESULT_FOLDER_INDEX,
|
|
87
|
+
searchQuery: query,
|
|
88
|
+
searchInput: query,
|
|
89
|
+
searchViewKey: "list",
|
|
90
|
+
onSearchChange: () => undefined,
|
|
91
|
+
onSearchClear: () => undefined,
|
|
92
|
+
onSearchClearQuery: () => undefined,
|
|
93
|
+
intelligenceOpen: false,
|
|
94
|
+
onToggleIntelligence: () => undefined,
|
|
95
|
+
onRaiseIntelligence: () => undefined,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const testRouter = (query: string): AnyRouter => {
|
|
99
|
+
const rootRoute = createRootRoute({
|
|
100
|
+
component: () =>
|
|
101
|
+
createElement(MailFreshnessProvider, {
|
|
102
|
+
accountIds: [],
|
|
103
|
+
// biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
|
|
104
|
+
children: createElement(
|
|
105
|
+
MailContext.Provider,
|
|
106
|
+
{ value: mailContext(query) },
|
|
107
|
+
createElement(ComposeProvider, null, createElement(Outlet)),
|
|
108
|
+
),
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
111
|
+
const mailRoute = createRoute({
|
|
112
|
+
getParentRoute: () => rootRoute,
|
|
113
|
+
path: "/mail",
|
|
114
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
115
|
+
component: Outlet,
|
|
116
|
+
});
|
|
117
|
+
const flaggedRoute = createRoute({
|
|
118
|
+
getParentRoute: () => mailRoute,
|
|
119
|
+
path: "/flagged",
|
|
120
|
+
component: () =>
|
|
121
|
+
createElement(FlaggedPane, {
|
|
122
|
+
thread: useOpenThreadPath(),
|
|
123
|
+
// biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
|
|
124
|
+
children: createElement(FlaggedPane.List),
|
|
125
|
+
}),
|
|
126
|
+
});
|
|
127
|
+
const routeTree = rootRoute.addChildren([
|
|
128
|
+
mailRoute.addChildren([flaggedRoute]),
|
|
129
|
+
]);
|
|
130
|
+
return createRouter({
|
|
131
|
+
routeTree,
|
|
132
|
+
history: createMemoryHistory({ initialEntries: ["/mail/flagged"] }),
|
|
133
|
+
}) as unknown as AnyRouter;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const threadCalls = (): HttpCall[] =>
|
|
137
|
+
(http?.calls ?? []).filter(
|
|
138
|
+
(call) => new URL(call.url, "http://localhost").pathname === "/threads",
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const paramsOf = (call: HttpCall): URLSearchParams =>
|
|
142
|
+
new URL(call.url, "http://localhost").searchParams;
|
|
143
|
+
|
|
144
|
+
const mount = async (
|
|
145
|
+
answer: (call: HttpCall) => unknown,
|
|
146
|
+
query = QUERY,
|
|
147
|
+
): Promise<DomHarness> => {
|
|
148
|
+
http = mockFetch((call) => {
|
|
149
|
+
if (call.path.endsWith("/config")) return { accounts: [] };
|
|
150
|
+
if (call.path !== "/threads") return { items: [] };
|
|
151
|
+
return answer(call);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const router = testRouter(query);
|
|
155
|
+
await router.load();
|
|
156
|
+
const mounted = createDomHarness({ viewportWidth: DESKTOP_WIDTH });
|
|
157
|
+
harness = mounted;
|
|
158
|
+
mounted.renderApp(createElement(RouterProvider, { router }));
|
|
159
|
+
await mounted.flush();
|
|
160
|
+
await mounted.wait(20);
|
|
161
|
+
await mounted.flush();
|
|
162
|
+
return mounted;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** The rows request: the count reads `results=false` and carries no rows. */
|
|
166
|
+
const isRowRequest = (call: HttpCall): boolean =>
|
|
167
|
+
paramsOf(call).get("results") !== "false";
|
|
168
|
+
|
|
169
|
+
describe("a Starred search reaches the whole collection (#1128, #1135)", () => {
|
|
170
|
+
// The failure the no-silent-auth-failure rule names: a 401 that renders as
|
|
171
|
+
// an empty result is indistinguishable from a collection holding nothing, so
|
|
172
|
+
// the reader concludes their mail is gone rather than their session.
|
|
173
|
+
it("says a failed search failed, never that nothing matched", async () => {
|
|
174
|
+
// Only the search fails. The pane's own unnarrowed listing answers, which
|
|
175
|
+
// is what let the failure hide: the view read its error state off that
|
|
176
|
+
// healthy query while rendering rows from the failed one.
|
|
177
|
+
const mounted = await mount((call) =>
|
|
178
|
+
isRowRequest(call) && paramsOf(call).get("query")
|
|
179
|
+
? httpError(401, "Session expired")
|
|
180
|
+
: { items: [] },
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
await mounted.waitFor(
|
|
184
|
+
() => mounted.text().includes("Couldn't load messages"),
|
|
185
|
+
"the failed search to state itself",
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const shown = mounted.text();
|
|
189
|
+
assert.ok(
|
|
190
|
+
!/No .*matching/i.test(shown) && !/No messages in Starred/.test(shown),
|
|
191
|
+
`a failed search rendered as an empty collection: ${shown.slice(0, 300)}`,
|
|
192
|
+
);
|
|
193
|
+
assert.ok(
|
|
194
|
+
[...mounted.queryAll("button")].some(
|
|
195
|
+
(button) => (button.textContent ?? "").trim() === "Retry",
|
|
196
|
+
),
|
|
197
|
+
"the failure offered no way to retry",
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// The dead button: the rows came from a query with no continuation, so the
|
|
202
|
+
// control that asks for more of them had nothing to ask.
|
|
203
|
+
it("pages a search past its first page", async () => {
|
|
204
|
+
const mounted = await mount((call) => {
|
|
205
|
+
if (!isRowRequest(call)) return { count: FIRST_PAGE.length + 1 };
|
|
206
|
+
return paramsOf(call).get("continuationToken")
|
|
207
|
+
? { items: SECOND_PAGE }
|
|
208
|
+
: { items: FIRST_PAGE, continuationToken: "page-2" };
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
await mounted.waitFor(
|
|
212
|
+
() => mounted.text().includes("Parcel 0"),
|
|
213
|
+
"the first page of matches",
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const loadMore = [...mounted.queryAll("button")].find((button) =>
|
|
217
|
+
(button.textContent ?? "").includes("Load more"),
|
|
218
|
+
);
|
|
219
|
+
assert.ok(loadMore, "a search with more matches offered no way to them");
|
|
220
|
+
|
|
221
|
+
mounted.click(loadMore);
|
|
222
|
+
await mounted.waitFor(
|
|
223
|
+
() => mounted.text().includes("Parcel 50"),
|
|
224
|
+
"the second page of matches",
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const paged = threadCalls().filter(
|
|
228
|
+
(call) => isRowRequest(call) && paramsOf(call).get("continuationToken"),
|
|
229
|
+
);
|
|
230
|
+
assert.equal(paged.length, 1, "the second page was never requested");
|
|
231
|
+
assert.equal(
|
|
232
|
+
paramsOf(paged[0]).get("query"),
|
|
233
|
+
QUERY,
|
|
234
|
+
"the next page dropped the search text",
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// One request answers the rows under a query, so the free text travels with
|
|
239
|
+
// every other criterion rather than in a second query beside them. (The pane
|
|
240
|
+
// keeps its own unnarrowed listing for selection resolution; that one carries
|
|
241
|
+
// no text and is not this.)
|
|
242
|
+
it("asks for the matches once, on the listing request", async () => {
|
|
243
|
+
const mounted = await mount(() => ({ items: FIRST_PAGE.slice(0, 2) }));
|
|
244
|
+
await mounted.waitFor(
|
|
245
|
+
() => mounted.text().includes("Parcel 0"),
|
|
246
|
+
"the matches to render",
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const searched = threadCalls().filter(
|
|
250
|
+
(call) => isRowRequest(call) && paramsOf(call).get("query") === QUERY,
|
|
251
|
+
);
|
|
252
|
+
assert.equal(searched.length, 1, "the search fanned out into two requests");
|
|
253
|
+
assert.equal(paramsOf(searched[0]).get("starred"), "true");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// The reach sentence is read off the request. A narrowing token beside the
|
|
257
|
+
// free text is what puts the sentence on screen, and both are parameters
|
|
258
|
+
// now, so the honest claim is the whole collection — which is only true
|
|
259
|
+
// because the search pages to its end rather than stopping at one window.
|
|
260
|
+
it("claims the whole collection was checked when nothing matched", async () => {
|
|
261
|
+
const mounted = await mount(
|
|
262
|
+
(call) => (isRowRequest(call) ? { items: [] } : { count: 0 }),
|
|
263
|
+
`is:unread ${QUERY}`,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
await mounted.waitFor(
|
|
267
|
+
() => mounted.text().includes("No "),
|
|
268
|
+
"the empty search to settle",
|
|
269
|
+
);
|
|
270
|
+
assert.match(mounted.text(), /Every message in this folder was checked\./);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -47,7 +47,7 @@ import type { ResultCount } from "@remit/ui";
|
|
|
47
47
|
import { useQueries, useQuery } from "@tanstack/react-query";
|
|
48
48
|
import { useCallback, useMemo } from "react";
|
|
49
49
|
import type { BriefSectionParams } from "@/lib/brief-criteria";
|
|
50
|
-
import { type
|
|
50
|
+
import { type ListFilterParams, sameInboxFilter } from "@/lib/inbox-filters";
|
|
51
51
|
import { toResultCount } from "@/lib/result-count";
|
|
52
52
|
|
|
53
53
|
/** A minute: a brief reopened straight away costs nothing. */
|
|
@@ -66,7 +66,7 @@ const UNCOUNTED: ResultCount = { kind: "unknown" };
|
|
|
66
66
|
* is what `sameInboxFilter` was written to stop (design D18).
|
|
67
67
|
*/
|
|
68
68
|
const keepUnderSameFilter =
|
|
69
|
-
(filter:
|
|
69
|
+
(filter: ListFilterParams) =>
|
|
70
70
|
(
|
|
71
71
|
previous: RemitImapThreadSearchResponse | undefined,
|
|
72
72
|
previousQuery: { queryKey: readonly unknown[] } | undefined,
|
|
@@ -75,7 +75,10 @@ const keepUnderSameFilter =
|
|
|
75
75
|
|
|
76
76
|
/** The criteria every section request carries, its own category aside. */
|
|
77
77
|
export interface BriefSectionsCriteria extends BriefSectionParams {
|
|
78
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Committed free text, matched against subject, From and the body preview
|
|
80
|
+
* across folders.
|
|
81
|
+
*/
|
|
79
82
|
query?: string;
|
|
80
83
|
}
|
|
81
84
|
|
|
@@ -111,13 +114,19 @@ const sectionCountQuery = (
|
|
|
111
114
|
const sectionFilter = (
|
|
112
115
|
criteria: BriefSectionsCriteria,
|
|
113
116
|
category: RemitImapMessageCategory,
|
|
114
|
-
):
|
|
117
|
+
): ListFilterParams => ({
|
|
115
118
|
category: [category],
|
|
116
119
|
...(criteria.unread !== undefined ? { unread: criteria.unread } : {}),
|
|
117
120
|
...(criteria.starred !== undefined ? { starred: criteria.starred } : {}),
|
|
118
121
|
...(criteria.attachments !== undefined
|
|
119
122
|
? { attachments: criteria.attachments }
|
|
120
123
|
: {}),
|
|
124
|
+
// The account pill is a scope, not text: switching it must take the previous
|
|
125
|
+
// account's rows off screen rather than render them for one round trip.
|
|
126
|
+
...(criteria.accountId !== undefined
|
|
127
|
+
? { accountId: criteria.accountId }
|
|
128
|
+
: {}),
|
|
129
|
+
...(criteria.muted !== undefined ? { muted: criteria.muted } : {}),
|
|
121
130
|
});
|
|
122
131
|
|
|
123
132
|
export interface BriefSectionRows {
|