@remit/web-client 0.0.248 → 0.0.249
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/MailboxPane.tsx +28 -4
- package/src/components/mail/MessageList.tsx +10 -17
- package/src/hooks/useResultCount.render.test.ts +161 -0
- package/src/hooks/useResultCount.ts +68 -0
- package/src/lib/result-count.test.ts +97 -0
- package/src/lib/result-count.ts +57 -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.249",
|
|
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": {
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
ReadingPaneEmpty,
|
|
31
31
|
RefreshButton,
|
|
32
32
|
type RescueCandidate,
|
|
33
|
+
type ResultCount,
|
|
33
34
|
type SearchResult,
|
|
34
35
|
} from "@remit/ui";
|
|
35
36
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
@@ -87,6 +88,7 @@ import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
|
87
88
|
import { useMoveMessages } from "@/hooks/useMoveMessages";
|
|
88
89
|
import { useRefreshControl } from "@/hooks/useRefreshControl";
|
|
89
90
|
import { useRescueCandidates } from "@/hooks/useRescueCandidates";
|
|
91
|
+
import { useResultCount } from "@/hooks/useResultCount";
|
|
90
92
|
import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
|
|
91
93
|
import { useSemanticSearch } from "@/hooks/useSemanticSearch";
|
|
92
94
|
import { useThreadActions } from "@/hooks/useThreadActions";
|
|
@@ -111,6 +113,7 @@ import { useMailContext } from "@/lib/mail-context";
|
|
|
111
113
|
import { useMailFreshness } from "@/lib/mail-freshness";
|
|
112
114
|
import { isRescueCandidate } from "@/lib/rescue-candidates";
|
|
113
115
|
import { recordRescueSentToJunk } from "@/lib/rescue-telemetry";
|
|
116
|
+
import { shouldRequestResultCount } from "@/lib/result-count";
|
|
114
117
|
import { normalizeSearchQuery } from "@/lib/search-query";
|
|
115
118
|
import {
|
|
116
119
|
relatedSearchResults,
|
|
@@ -204,6 +207,8 @@ interface MailboxPaneContextValue {
|
|
|
204
207
|
* header, not enough to reproduce the query server-side.
|
|
205
208
|
*/
|
|
206
209
|
searchPredicate: EscalationSearchQuery | undefined;
|
|
210
|
+
/** The server's count of the whole match set, for the result header. */
|
|
211
|
+
resultCount: ResultCount;
|
|
207
212
|
// List actions
|
|
208
213
|
onDeleteMessages: (ids: string[]) => void;
|
|
209
214
|
onMoveMessages: (ids: string[], dest: string) => void;
|
|
@@ -316,15 +321,21 @@ function MailboxPaneProvider({
|
|
|
316
321
|
searchTokens,
|
|
317
322
|
filterParams,
|
|
318
323
|
);
|
|
319
|
-
|
|
324
|
+
// What the list is asking about, with nothing about paging in it. The count
|
|
325
|
+
// is a property of the criteria alone, so it keys on this and a page fetch
|
|
326
|
+
// can never trigger one.
|
|
327
|
+
const searchCriteria = {
|
|
320
328
|
order: "desc" as const,
|
|
321
|
-
// Explicit: an unspecified limit clamps to THREAD_SEARCH_MAX_LIMIT (500),
|
|
322
|
-
// so switching paths without it multiplies the page size by ten.
|
|
323
|
-
limit: THREADS_PAGE_SIZE,
|
|
324
329
|
...(freeText ? { query: freeText } : {}),
|
|
325
330
|
...tokenParams,
|
|
326
331
|
...filterParams,
|
|
327
332
|
};
|
|
333
|
+
const searchThreadsQuery = {
|
|
334
|
+
...searchCriteria,
|
|
335
|
+
// Explicit: an unspecified limit clamps to THREAD_SEARCH_MAX_LIMIT (500),
|
|
336
|
+
// so switching paths without it multiplies the page size by ten.
|
|
337
|
+
limit: THREADS_PAGE_SIZE,
|
|
338
|
+
};
|
|
328
339
|
// What the request actually narrows by, whoever set it. `placeholderData`
|
|
329
340
|
// keeps the previous rows only under the same predicate, and a token
|
|
330
341
|
// narrowing the list is as much that predicate as a chip is.
|
|
@@ -387,6 +398,16 @@ function MailboxPaneProvider({
|
|
|
387
398
|
: undefined,
|
|
388
399
|
});
|
|
389
400
|
|
|
401
|
+
const resultCount = useResultCount({
|
|
402
|
+
mailboxId,
|
|
403
|
+
criteria: searchCriteria,
|
|
404
|
+
enabled: shouldRequestResultCount({
|
|
405
|
+
hasSearchQuery,
|
|
406
|
+
freeText,
|
|
407
|
+
residualTokenCount: residualTokens.length,
|
|
408
|
+
}),
|
|
409
|
+
});
|
|
410
|
+
|
|
390
411
|
const { accountId: mailboxAccountId, isLoading: mailboxAccountLoading } =
|
|
391
412
|
useMailboxAccount(mailboxId);
|
|
392
413
|
const mailboxName = useCurrentMailboxName({ accounts });
|
|
@@ -790,6 +811,7 @@ function MailboxPaneProvider({
|
|
|
790
811
|
hasSearchQuery && residualTokens.length === 0
|
|
791
812
|
? searchThreadsQuery
|
|
792
813
|
: undefined,
|
|
814
|
+
resultCount,
|
|
793
815
|
onDeleteMessages: handleDeleteMessages,
|
|
794
816
|
onMoveMessages: handleMoveMessages,
|
|
795
817
|
isDeleting,
|
|
@@ -854,6 +876,7 @@ function MailboxList() {
|
|
|
854
876
|
onClearFilters,
|
|
855
877
|
listFilter,
|
|
856
878
|
searchPredicate,
|
|
879
|
+
resultCount,
|
|
857
880
|
} = useMailboxPane();
|
|
858
881
|
const { searchQuery, searchInput, accounts, resultFolderIndex } =
|
|
859
882
|
useMailContext();
|
|
@@ -978,6 +1001,7 @@ function MailboxList() {
|
|
|
978
1001
|
onRetry={onRetry}
|
|
979
1002
|
searchQuery={searchQuery}
|
|
980
1003
|
searchPredicate={searchPredicate}
|
|
1004
|
+
resultCount={resultCount}
|
|
981
1005
|
onDeleteMessages={onDeleteMessages}
|
|
982
1006
|
isDeleting={isDeleting}
|
|
983
1007
|
isMoving={isMoving}
|
|
@@ -4,10 +4,12 @@ import {
|
|
|
4
4
|
cn,
|
|
5
5
|
type Density,
|
|
6
6
|
deriveIsMultiSelectMode,
|
|
7
|
+
ListResultHeader,
|
|
7
8
|
type MessageListFilter,
|
|
8
9
|
MessageListLoadingMore,
|
|
9
10
|
MessageListPane,
|
|
10
11
|
nextFocusId,
|
|
12
|
+
type ResultCount,
|
|
11
13
|
type SelectionModifiers,
|
|
12
14
|
SelectionTopBar,
|
|
13
15
|
useSelection,
|
|
@@ -15,7 +17,6 @@ import {
|
|
|
15
17
|
} from "@remit/ui";
|
|
16
18
|
import { useBlocker } from "@tanstack/react-router";
|
|
17
19
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
18
|
-
import { Search } from "lucide-react";
|
|
19
20
|
import type { RefObject } from "react";
|
|
20
21
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
21
22
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
@@ -113,6 +114,12 @@ interface MessageListProps {
|
|
|
113
114
|
* only the display string.
|
|
114
115
|
*/
|
|
115
116
|
searchPredicate?: EscalationSearchQuery;
|
|
117
|
+
/**
|
|
118
|
+
* How many messages the search matches, as the server counted them — the
|
|
119
|
+
* whole match set, not the pages loaded. Omitted where the count was never
|
|
120
|
+
* asked for, which renders the header without a number.
|
|
121
|
+
*/
|
|
122
|
+
resultCount?: ResultCount;
|
|
116
123
|
onDeleteMessages?: (messageIds: string[]) => void;
|
|
117
124
|
isDeleting?: boolean;
|
|
118
125
|
isMoving?: boolean;
|
|
@@ -188,21 +195,6 @@ const readStoredDensity = (): Density => {
|
|
|
188
195
|
return "comfortable";
|
|
189
196
|
};
|
|
190
197
|
|
|
191
|
-
/**
|
|
192
|
-
* Names what the list is showing. No number: the only figure available here is
|
|
193
|
-
* the length of the loaded pages, and a page length presented as a result total
|
|
194
|
-
* contradicts the completeness the filtered empty state states in the same view
|
|
195
|
-
* (#306). The exact count is #307's.
|
|
196
|
-
*/
|
|
197
|
-
const SearchResultsHeader = ({ query }: { query: string }) => (
|
|
198
|
-
<div className="flex items-center gap-2 px-3 py-2 border-b border-line bg-surface-sunken/30">
|
|
199
|
-
<Search className="size-4 text-fg-muted" />
|
|
200
|
-
<span className="text-sm text-fg-muted">
|
|
201
|
-
Results for “{query}”
|
|
202
|
-
</span>
|
|
203
|
-
</div>
|
|
204
|
-
);
|
|
205
|
-
|
|
206
198
|
/**
|
|
207
199
|
* Why Move is withheld from a selection, in the toolbar's own words. Rows from
|
|
208
200
|
* a per-mailbox endpoint carry no account of their own, so the list's own
|
|
@@ -230,6 +222,7 @@ export const MessageList = ({
|
|
|
230
222
|
onRetry,
|
|
231
223
|
searchQuery,
|
|
232
224
|
searchPredicate,
|
|
225
|
+
resultCount = { kind: "unknown" },
|
|
233
226
|
onDeleteMessages,
|
|
234
227
|
isDeleting = false,
|
|
235
228
|
isMoving = false,
|
|
@@ -1227,7 +1220,7 @@ export const MessageList = ({
|
|
|
1227
1220
|
const virtualBody = (
|
|
1228
1221
|
<>
|
|
1229
1222
|
{isSearching && searchQuery && (
|
|
1230
|
-
<
|
|
1223
|
+
<ListResultHeader query={searchQuery} count={resultCount} />
|
|
1231
1224
|
)}
|
|
1232
1225
|
<div
|
|
1233
1226
|
ref={parentRef}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The result count is one question asked of the server, not a walk of the
|
|
3
|
+
* pages (#307).
|
|
4
|
+
*
|
|
5
|
+
* The header used to render `threads.length`, which understated every search
|
|
6
|
+
* matching more than one page and climbed as the reader scrolled. These mount
|
|
7
|
+
* the real hook against the real fetch seam and read what actually left: the
|
|
8
|
+
* whole match set from one request, no second request when the list pages, and
|
|
9
|
+
* nothing at all for a query still being typed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { afterEach, describe, it } from "node:test";
|
|
14
|
+
import type { ResultCount } from "@remit/ui";
|
|
15
|
+
import { createElement } from "react";
|
|
16
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
17
|
+
import { type HttpMock, mockFetch } from "../test-support/http";
|
|
18
|
+
import { type ThreadSearchCriteria, useResultCount } from "./useResultCount.js";
|
|
19
|
+
|
|
20
|
+
const INBOX = "mbx-inbox";
|
|
21
|
+
/** Far more than a page holds — the number the header must not derive itself. */
|
|
22
|
+
const TOTAL_MATCHES = 1284;
|
|
23
|
+
|
|
24
|
+
let harness: DomHarness | undefined;
|
|
25
|
+
let http: HttpMock | undefined;
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
harness?.close();
|
|
29
|
+
harness = undefined;
|
|
30
|
+
http?.restore();
|
|
31
|
+
http = undefined;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** Count-only requests, in order — the ones that asked for a number. */
|
|
35
|
+
const countRequests = () =>
|
|
36
|
+
(http?.to("/threads/search") ?? []).filter(
|
|
37
|
+
(call) =>
|
|
38
|
+
new URL(call.url, "http://localhost").searchParams.get("count") ===
|
|
39
|
+
"true",
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
interface Probe {
|
|
43
|
+
count: () => ResultCount;
|
|
44
|
+
/**
|
|
45
|
+
* Render again on the same client, so a second search reaches the cache the
|
|
46
|
+
* first one filled — which is the only place a stale count can come from.
|
|
47
|
+
*/
|
|
48
|
+
rerender: (next?: {
|
|
49
|
+
criteria?: ThreadSearchCriteria;
|
|
50
|
+
enabled?: boolean;
|
|
51
|
+
}) => Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const mount = async (
|
|
55
|
+
criteria: ThreadSearchCriteria,
|
|
56
|
+
enabled: boolean,
|
|
57
|
+
answer: unknown = { items: [], count: TOTAL_MATCHES },
|
|
58
|
+
): Promise<Probe> => {
|
|
59
|
+
http = mockFetch((call) =>
|
|
60
|
+
call.path.endsWith("/threads/search") ? answer : { items: [] },
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
let value: ResultCount | undefined;
|
|
64
|
+
const CountProbe = ({
|
|
65
|
+
search,
|
|
66
|
+
on,
|
|
67
|
+
}: {
|
|
68
|
+
search: ThreadSearchCriteria;
|
|
69
|
+
on: boolean;
|
|
70
|
+
}) => {
|
|
71
|
+
value = useResultCount({ mailboxId: INBOX, criteria: search, enabled: on });
|
|
72
|
+
return null;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const mounted = createDomHarness();
|
|
76
|
+
harness = mounted;
|
|
77
|
+
let search = criteria;
|
|
78
|
+
let on = enabled;
|
|
79
|
+
const render: Probe["rerender"] = async (next) => {
|
|
80
|
+
if (next?.criteria) search = next.criteria;
|
|
81
|
+
if (next?.enabled !== undefined) on = next.enabled;
|
|
82
|
+
mounted.renderApp(createElement(CountProbe, { search, on }));
|
|
83
|
+
await mounted.flush();
|
|
84
|
+
await mounted.wait(20);
|
|
85
|
+
await mounted.flush();
|
|
86
|
+
};
|
|
87
|
+
await render();
|
|
88
|
+
return {
|
|
89
|
+
count: () => {
|
|
90
|
+
if (!value) throw new Error("hook did not render");
|
|
91
|
+
return value;
|
|
92
|
+
},
|
|
93
|
+
rerender: render,
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
describe("useResultCount (#307)", () => {
|
|
98
|
+
it("answers with the whole match set the server counted", async () => {
|
|
99
|
+
const probe = await mount({ query: "invoice" }, true);
|
|
100
|
+
|
|
101
|
+
assert.deepEqual(probe.count(), { kind: "exact", value: TOTAL_MATCHES });
|
|
102
|
+
assert.equal(countRequests().length, 1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("asks with no cursor, so paging the list never re-counts", async () => {
|
|
106
|
+
const probe = await mount({ query: "invoice" }, true);
|
|
107
|
+
await probe.rerender();
|
|
108
|
+
await probe.rerender();
|
|
109
|
+
|
|
110
|
+
assert.equal(
|
|
111
|
+
countRequests().length,
|
|
112
|
+
1,
|
|
113
|
+
"the count was asked again for a predicate that did not change",
|
|
114
|
+
);
|
|
115
|
+
const asked = new URL(countRequests()[0]?.url ?? "", "http://localhost")
|
|
116
|
+
.searchParams;
|
|
117
|
+
assert.equal(asked.get("continuationToken"), null);
|
|
118
|
+
assert.equal(asked.get("results"), "false");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("issues no request at all while it is disabled", async () => {
|
|
122
|
+
const probe = await mount({ query: "in" }, false);
|
|
123
|
+
|
|
124
|
+
assert.equal(countRequests().length, 0);
|
|
125
|
+
assert.deepEqual(
|
|
126
|
+
probe.count(),
|
|
127
|
+
{ kind: "unknown" },
|
|
128
|
+
"an uncounted search reported a number",
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `enabled` gates the request, not the cache. Two searches reach the same key
|
|
134
|
+
* whenever what separates them never reaches the request — `invoice` and
|
|
135
|
+
* `invoice before:2020` send identical criteria — so the second must not read
|
|
136
|
+
* the first one's answer back out of the cache.
|
|
137
|
+
*/
|
|
138
|
+
it("forgets the number the moment it stops being asked for", async () => {
|
|
139
|
+
const probe = await mount({ query: "invoice" }, true);
|
|
140
|
+
assert.deepEqual(probe.count(), { kind: "exact", value: TOTAL_MATCHES });
|
|
141
|
+
|
|
142
|
+
await probe.rerender({ enabled: false });
|
|
143
|
+
|
|
144
|
+
assert.deepEqual(
|
|
145
|
+
probe.count(),
|
|
146
|
+
{ kind: "unknown" },
|
|
147
|
+
"a count nobody asked for was served from the cache",
|
|
148
|
+
);
|
|
149
|
+
assert.equal(countRequests().length, 1, "the gate stopped gating requests");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("reports no number when the server withholds the count", async () => {
|
|
153
|
+
const probe = await mount({ query: "invoice" }, true, { items: [] });
|
|
154
|
+
|
|
155
|
+
assert.deepEqual(
|
|
156
|
+
probe.count(),
|
|
157
|
+
{ kind: "unknown" },
|
|
158
|
+
"an absent count became a zero",
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { threadOperationsSearchThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import { threadOperationsSearchThreads } from "@remit/api-http-client/sdk.gen.ts";
|
|
3
|
+
import type { ThreadOperationsSearchThreadsData } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
+
import type { ResultCount } from "@remit/ui";
|
|
5
|
+
import { useQuery } from "@tanstack/react-query";
|
|
6
|
+
import { toResultCount } from "@/lib/result-count";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What the list is asking about, with nothing about paging in it. The count is
|
|
10
|
+
* a property of the criteria alone, so a page fetch can never trigger one.
|
|
11
|
+
*/
|
|
12
|
+
export type ThreadSearchCriteria = Omit<
|
|
13
|
+
NonNullable<ThreadOperationsSearchThreadsData["query"]>,
|
|
14
|
+
"continuationToken" | "limit" | "count" | "results"
|
|
15
|
+
>;
|
|
16
|
+
|
|
17
|
+
export interface UseResultCountOptions {
|
|
18
|
+
mailboxId: string;
|
|
19
|
+
criteria: ThreadSearchCriteria;
|
|
20
|
+
/**
|
|
21
|
+
* Whether this search is worth counting at all — see
|
|
22
|
+
* `shouldRequestResultCount`. Disabled, the surface renders no number rather
|
|
23
|
+
* than a stale one from the search before it.
|
|
24
|
+
*/
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A minute: long enough that returning to a search just left costs nothing. */
|
|
29
|
+
const COUNT_STALE_TIME_MS = 60_000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* How many messages the search matches, from the server that resolves it —
|
|
33
|
+
* one request against the criteria, never a walk of the pages (#307).
|
|
34
|
+
*
|
|
35
|
+
* Its own query, keyed on the criteria without the cursor, so the list pages
|
|
36
|
+
* without re-counting. An answer that carries no count — off-row criteria, per
|
|
37
|
+
* #305 — stays absent rather than becoming a zero.
|
|
38
|
+
*/
|
|
39
|
+
export const useResultCount = ({
|
|
40
|
+
mailboxId,
|
|
41
|
+
criteria,
|
|
42
|
+
enabled,
|
|
43
|
+
}: UseResultCountOptions): ResultCount => {
|
|
44
|
+
const query = { ...criteria, count: true, results: false };
|
|
45
|
+
const { data } = useQuery({
|
|
46
|
+
queryKey: threadOperationsSearchThreadsQueryKey({
|
|
47
|
+
path: { mailboxId },
|
|
48
|
+
query,
|
|
49
|
+
}),
|
|
50
|
+
queryFn: async () => {
|
|
51
|
+
const { data: response } = await threadOperationsSearchThreads({
|
|
52
|
+
path: { mailboxId },
|
|
53
|
+
query,
|
|
54
|
+
throwOnError: true,
|
|
55
|
+
});
|
|
56
|
+
return response;
|
|
57
|
+
},
|
|
58
|
+
enabled,
|
|
59
|
+
staleTime: COUNT_STALE_TIME_MS,
|
|
60
|
+
});
|
|
61
|
+
// `enabled` gates the request, not the cache: a criteria set that asks for no
|
|
62
|
+
// count still reads whatever the last identical key left behind. Two searches
|
|
63
|
+
// share a key whenever the terms that separate them reach the request as
|
|
64
|
+
// nothing — `before:`/`after:`/`account:` are stripped from the free text and
|
|
65
|
+
// carry no param — so a disabled count that read `data` would render the
|
|
66
|
+
// previous search's total over this one's rows.
|
|
67
|
+
return enabled ? toResultCount(data?.count) : { kind: "unknown" };
|
|
68
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The exact match count is opt-in because it is a second read over the whole
|
|
3
|
+
* match set (#307). These pin who pays for it and what happens when the server
|
|
4
|
+
* declines to answer.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { describe, test } from "node:test";
|
|
9
|
+
import type { EscalationSearchQuery } from "@/hooks/useEscalatedActions";
|
|
10
|
+
import {
|
|
11
|
+
type ResultCountRequestInput,
|
|
12
|
+
shouldRequestResultCount,
|
|
13
|
+
toResultCount,
|
|
14
|
+
} from "./result-count.js";
|
|
15
|
+
|
|
16
|
+
const request = (input: Partial<ResultCountRequestInput>): boolean =>
|
|
17
|
+
shouldRequestResultCount({
|
|
18
|
+
hasSearchQuery: true,
|
|
19
|
+
freeText: "",
|
|
20
|
+
residualTokenCount: 0,
|
|
21
|
+
...input,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("shouldRequestResultCount", () => {
|
|
25
|
+
test("browsing a mailbox asks for no count", () => {
|
|
26
|
+
assert.equal(request({ hasSearchQuery: false }), false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("a free-text query is not counted while it is being typed", () => {
|
|
30
|
+
for (const freeText of ["k", "ku", " ku "]) {
|
|
31
|
+
assert.equal(
|
|
32
|
+
request({ freeText }),
|
|
33
|
+
false,
|
|
34
|
+
`"${freeText}" was counted per keystroke`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a free-text query long enough for the index is counted", () => {
|
|
40
|
+
assert.equal(request({ freeText: "kub" }), true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a query made only of filter tokens is counted straight away", () => {
|
|
44
|
+
assert.equal(request({}), true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `before:`/`after:`/`account:` reach the request as nothing, so the server
|
|
49
|
+
* would count a wider set than the list shows — and `invoice before:2020`
|
|
50
|
+
* sends criteria identical to `invoice`, which is also the previous search's
|
|
51
|
+
* cache key.
|
|
52
|
+
*/
|
|
53
|
+
test("a term the request could not carry stops the count", () => {
|
|
54
|
+
assert.equal(
|
|
55
|
+
request({ freeText: "invoice", residualTokenCount: 1 }),
|
|
56
|
+
false,
|
|
57
|
+
"the count covered rows the list filters back out",
|
|
58
|
+
);
|
|
59
|
+
assert.equal(
|
|
60
|
+
request({ freeText: "invoice", residualTokenCount: 0 }),
|
|
61
|
+
true,
|
|
62
|
+
"the same search without the residual term must still be counted",
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("toResultCount", () => {
|
|
68
|
+
test("the server's number is the whole match set", () => {
|
|
69
|
+
assert.deepEqual(toResultCount(1284), { kind: "exact", value: 1284 });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("zero matches is a number, not an absent count", () => {
|
|
73
|
+
assert.deepEqual(toResultCount(0), { kind: "exact", value: 0 });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("an absent count stays absent rather than becoming zero", () => {
|
|
77
|
+
assert.deepEqual(toResultCount(undefined), { kind: "unknown" });
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `searchThreads` withholds the count when the criteria carry an off-row term
|
|
83
|
+
* (#305), and the escalated selection reads `count` as a required number to size
|
|
84
|
+
* its run. Neither term can reach that predicate today; this fails the day one
|
|
85
|
+
* is added, so the absent-count state gets designed rather than surfacing as a
|
|
86
|
+
* run that refuses to start.
|
|
87
|
+
*/
|
|
88
|
+
describe("the escalation predicate carries no off-row criterion", () => {
|
|
89
|
+
test("senderTrust and dkimMismatch are not part of it", () => {
|
|
90
|
+
type OffRow = Extract<
|
|
91
|
+
keyof EscalationSearchQuery,
|
|
92
|
+
"senderTrust" | "dkimMismatch"
|
|
93
|
+
>;
|
|
94
|
+
const offRow: [OffRow] extends [never] ? true : false = true;
|
|
95
|
+
assert.equal(offRow, true);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ResultCount } from "@remit/ui";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shortest free text a count is asked for. `npm-scripts/sqlite-search-index.sql`
|
|
5
|
+
* documents that a query under three characters falls out of the trigram index
|
|
6
|
+
* onto a folded `LIKE` scan, so counting one is a mailbox scan per typed
|
|
7
|
+
* character. The list itself still pages — only the count waits.
|
|
8
|
+
*/
|
|
9
|
+
export const MIN_COUNTED_QUERY_LENGTH = 3;
|
|
10
|
+
|
|
11
|
+
export interface ResultCountRequestInput {
|
|
12
|
+
/** True while the list is answering a search rather than plain browsing. */
|
|
13
|
+
hasSearchQuery: boolean;
|
|
14
|
+
/** The literal text of the search, with its filter tokens already taken out. */
|
|
15
|
+
freeText: string;
|
|
16
|
+
/**
|
|
17
|
+
* Tokens the request could not carry — `before:`, `after:`, `account:`, and
|
|
18
|
+
* any second value for a parameter that takes one — applied over the rows
|
|
19
|
+
* after they arrive.
|
|
20
|
+
*/
|
|
21
|
+
residualTokenCount: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether the exact match count is worth one request against these criteria.
|
|
26
|
+
*
|
|
27
|
+
* Chip- and token-driven criteria are counted straight away: they change once
|
|
28
|
+
* per click, and the predicate is one the index answers. Free text is counted
|
|
29
|
+
* only once it is long enough to be indexed, which is what keeps a per-character
|
|
30
|
+
* query from paying for a count it will throw away on the next keystroke.
|
|
31
|
+
*
|
|
32
|
+
* A residual token stops the count outright. The server never saw that term, so
|
|
33
|
+
* it would count a wider set than the list shows — and two searches differing
|
|
34
|
+
* only by one reach the request as the same criteria, so the answer would also
|
|
35
|
+
* be the one the previous search left in the cache.
|
|
36
|
+
*/
|
|
37
|
+
export const shouldRequestResultCount = ({
|
|
38
|
+
hasSearchQuery,
|
|
39
|
+
freeText,
|
|
40
|
+
residualTokenCount,
|
|
41
|
+
}: ResultCountRequestInput): boolean => {
|
|
42
|
+
if (!hasSearchQuery) return false;
|
|
43
|
+
if (residualTokenCount > 0) return false;
|
|
44
|
+
const typed = freeText.trim();
|
|
45
|
+
return typed.length === 0 || typed.length >= MIN_COUNTED_QUERY_LENGTH;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The server's answer, or no number.
|
|
50
|
+
*
|
|
51
|
+
* The only input is `ThreadSearchResponse.count`, which is the whole match set
|
|
52
|
+
* or absent (#305) — never a page length. Absent stays absent: a count the
|
|
53
|
+
* server declined to compute is not zero, and the surface renders nothing
|
|
54
|
+
* rather than a figure it made up.
|
|
55
|
+
*/
|
|
56
|
+
export const toResultCount = (count: number | undefined): ResultCount =>
|
|
57
|
+
count === undefined ? { kind: "unknown" } : { kind: "exact", value: count };
|