@remit/web-client 0.0.248 → 0.0.250
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 +244 -100
- package/src/components/mail/MailboxPane.tsx +28 -4
- package/src/components/mail/MessageList.tsx +10 -17
- package/src/components/mail/brief-server-sections.render.test.ts +655 -0
- package/src/hooks/useBriefSections.ts +310 -0
- package/src/hooks/useResultCount.render.test.ts +161 -0
- package/src/hooks/useResultCount.ts +68 -0
- package/src/lib/brief-criteria.test.ts +126 -0
- package/src/lib/brief-criteria.ts +98 -0
- package/src/lib/brief.test.ts +215 -148
- package/src/lib/brief.ts +116 -53
- package/src/lib/drafts.ts +1 -1
- package/src/lib/result-count.test.ts +97 -0
- package/src/lib/result-count.ts +57 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daily brief's rows: one `listAllThreads` request per section it shows,
|
|
3
|
+
* scoped to that section's category, plus one count of the whole category.
|
|
4
|
+
*
|
|
5
|
+
* The brief used to be a single non-paginated page of the newest 50 unified
|
|
6
|
+
* rows, and every section was "the Marketing rows among those 50" presented as
|
|
7
|
+
* Marketing. On a mailbox whose Marketing mail is all older than that window the
|
|
8
|
+
* section rendered empty above a header reading zero (#312). A category-scoped
|
|
9
|
+
* request answers over the whole scope, so a section holds that category's
|
|
10
|
+
* newest mail however far down the unified order it sits.
|
|
11
|
+
*
|
|
12
|
+
* The brief does not paginate as a whole; it paginates by section. Each section
|
|
13
|
+
* takes its newest {@link SECTION_ROW_CAP} rows and states its real size, and
|
|
14
|
+
* the way to the rest is the brief's own filtered list for that category — no
|
|
15
|
+
* cursor here, and no request that walks a mailbox.
|
|
16
|
+
*
|
|
17
|
+
* The counts are the second read. One per displayed section, keyed on the
|
|
18
|
+
* criteria alone so no page, cursor or expansion can trigger another, and held
|
|
19
|
+
* for a minute. The criteria are the caller's committed state — the query as it
|
|
20
|
+
* was submitted, and the chips as that query and the panel leave them — so
|
|
21
|
+
* nothing here is on a keystroke path.
|
|
22
|
+
*
|
|
23
|
+
* Each section loads and fails on its own. Seven requests are seven answers, and
|
|
24
|
+
* one category's failed request is not a reason to blank the six that came back:
|
|
25
|
+
* a section carries its own pending and error state, and the brief renders what
|
|
26
|
+
* it has. What reaches that state is a transport failure — a request that never
|
|
27
|
+
* got an answer. A 5xx is the API breaking rather than this section failing, and
|
|
28
|
+
* escalates globally on the fail-fast contract (`shouldEscalate`, #1059); there
|
|
29
|
+
* is no `meta` opt-out here and there must not be one.
|
|
30
|
+
*
|
|
31
|
+
* A refetch under the same predicate keeps the rows already on screen. Under a
|
|
32
|
+
* different one it does not — the previous chip's mail under the new chip is the
|
|
33
|
+
* defect `sameInboxFilter` exists to stop — so only a change of free text holds
|
|
34
|
+
* the previous answer while the next one is in flight.
|
|
35
|
+
*
|
|
36
|
+
* Sections are the unsearched brief only. Under a query the brief is one list in
|
|
37
|
+
* one order — see `useBriefSearchRows`.
|
|
38
|
+
*/
|
|
39
|
+
import { unifiedThreadOperationsListAllThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
40
|
+
import { unifiedThreadOperationsListAllThreads } from "@remit/api-http-client/sdk.gen.ts";
|
|
41
|
+
import type {
|
|
42
|
+
RemitImapMessageCategory,
|
|
43
|
+
RemitImapThreadMessageResponse,
|
|
44
|
+
RemitImapThreadSearchResponse,
|
|
45
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
46
|
+
import type { ResultCount } from "@remit/ui";
|
|
47
|
+
import { useQueries, useQuery } from "@tanstack/react-query";
|
|
48
|
+
import { useCallback, useMemo } from "react";
|
|
49
|
+
import type { BriefSectionParams } from "@/lib/brief-criteria";
|
|
50
|
+
import { type InboxFilterParams, sameInboxFilter } from "@/lib/inbox-filters";
|
|
51
|
+
import { toResultCount } from "@/lib/result-count";
|
|
52
|
+
|
|
53
|
+
/** A minute: a brief reopened straight away costs nothing. */
|
|
54
|
+
const BRIEF_STALE_TIME_MS = 60_000;
|
|
55
|
+
|
|
56
|
+
/** Half a minute: a query being refined re-asks sooner than the brief does. */
|
|
57
|
+
const SEARCH_STALE_TIME_MS = 30_000;
|
|
58
|
+
|
|
59
|
+
/** No number at all, which is what a section shows when nobody counted it. */
|
|
60
|
+
const UNCOUNTED: ResultCount = { kind: "unknown" };
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Hold the previous answer while the next one is in flight, but only where the
|
|
64
|
+
* predicate is unchanged. A chip that changes what is being asked for takes its
|
|
65
|
+
* rows with it: the previous chip's mail under the new chip, for one round trip,
|
|
66
|
+
* is what `sameInboxFilter` was written to stop (design D18).
|
|
67
|
+
*/
|
|
68
|
+
const keepUnderSameFilter =
|
|
69
|
+
(filter: InboxFilterParams) =>
|
|
70
|
+
(
|
|
71
|
+
previous: RemitImapThreadSearchResponse | undefined,
|
|
72
|
+
previousQuery: { queryKey: readonly unknown[] } | undefined,
|
|
73
|
+
): RemitImapThreadSearchResponse | undefined =>
|
|
74
|
+
sameInboxFilter(previousQuery?.queryKey, filter) ? previous : undefined;
|
|
75
|
+
|
|
76
|
+
/** The criteria every section request carries, its own category aside. */
|
|
77
|
+
export interface BriefSectionsCriteria extends BriefSectionParams {
|
|
78
|
+
/** Committed free text, matched against subject and From across folders. */
|
|
79
|
+
query?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const sectionRowsQuery = (
|
|
83
|
+
criteria: BriefSectionsCriteria,
|
|
84
|
+
category: RemitImapMessageCategory,
|
|
85
|
+
limit: number,
|
|
86
|
+
) => ({
|
|
87
|
+
...criteria,
|
|
88
|
+
category: [category],
|
|
89
|
+
limit,
|
|
90
|
+
order: "desc" as const,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// No `limit` and no cursor: the count is a property of the criteria, so growing
|
|
94
|
+
// the page cannot change it and cannot ask for it again.
|
|
95
|
+
const sectionCountQuery = (
|
|
96
|
+
criteria: BriefSectionsCriteria,
|
|
97
|
+
category: RemitImapMessageCategory,
|
|
98
|
+
) => ({
|
|
99
|
+
...criteria,
|
|
100
|
+
category: [category],
|
|
101
|
+
order: "desc" as const,
|
|
102
|
+
count: true,
|
|
103
|
+
results: false,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* What a request under these criteria asks for, as `sameInboxFilter` reads it.
|
|
108
|
+
* Free text is deliberately not part of it: a query being refined is the one
|
|
109
|
+
* change that may keep the previous answer on screen.
|
|
110
|
+
*/
|
|
111
|
+
const sectionFilter = (
|
|
112
|
+
criteria: BriefSectionsCriteria,
|
|
113
|
+
category: RemitImapMessageCategory,
|
|
114
|
+
): InboxFilterParams => ({
|
|
115
|
+
category: [category],
|
|
116
|
+
...(criteria.unread !== undefined ? { unread: criteria.unread } : {}),
|
|
117
|
+
...(criteria.starred !== undefined ? { starred: criteria.starred } : {}),
|
|
118
|
+
...(criteria.attachments !== undefined
|
|
119
|
+
? { attachments: criteria.attachments }
|
|
120
|
+
: {}),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
export interface BriefSectionRows {
|
|
124
|
+
category: RemitImapMessageCategory;
|
|
125
|
+
rows: RemitImapThreadMessageResponse[];
|
|
126
|
+
total: ResultCount;
|
|
127
|
+
/** The request came back full, so the category holds more than these rows. */
|
|
128
|
+
atCap: boolean;
|
|
129
|
+
/** Nothing has arrived for this section yet. */
|
|
130
|
+
loading: boolean;
|
|
131
|
+
/** This section's own request failed; the others are unaffected. */
|
|
132
|
+
failed: boolean;
|
|
133
|
+
/** Ask this one section again. */
|
|
134
|
+
retry: () => void;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface UseBriefSectionsOptions {
|
|
138
|
+
/** The categories the brief is showing, in display order. */
|
|
139
|
+
categories: readonly RemitImapMessageCategory[];
|
|
140
|
+
criteria: BriefSectionsCriteria;
|
|
141
|
+
/** Rows per section. */
|
|
142
|
+
limit: number;
|
|
143
|
+
/**
|
|
144
|
+
* Whether the request saw every criterion on screen. False, no count is asked
|
|
145
|
+
* for and every section reports no number — see `briefCountsMatchRows`.
|
|
146
|
+
*/
|
|
147
|
+
counted: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface UseBriefSections {
|
|
151
|
+
sections: BriefSectionRows[];
|
|
152
|
+
/** Nothing has arrived for any section — the brief has nothing to render. */
|
|
153
|
+
isLoading: boolean;
|
|
154
|
+
/** Every section failed. One failing section is the section's own state. */
|
|
155
|
+
isError: boolean;
|
|
156
|
+
refetch: () => void;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function useBriefSections({
|
|
160
|
+
categories,
|
|
161
|
+
criteria,
|
|
162
|
+
limit,
|
|
163
|
+
counted,
|
|
164
|
+
}: UseBriefSectionsOptions): UseBriefSections {
|
|
165
|
+
const rowQueries = useQueries({
|
|
166
|
+
queries: categories.map((category) => {
|
|
167
|
+
const query = sectionRowsQuery(criteria, category, limit);
|
|
168
|
+
const filter = sectionFilter(criteria, category);
|
|
169
|
+
return {
|
|
170
|
+
queryKey: unifiedThreadOperationsListAllThreadsQueryKey({ query }),
|
|
171
|
+
queryFn: async () => {
|
|
172
|
+
const { data } = await unifiedThreadOperationsListAllThreads({
|
|
173
|
+
query,
|
|
174
|
+
throwOnError: true,
|
|
175
|
+
});
|
|
176
|
+
return data;
|
|
177
|
+
},
|
|
178
|
+
placeholderData: keepUnderSameFilter(filter),
|
|
179
|
+
staleTime: BRIEF_STALE_TIME_MS,
|
|
180
|
+
};
|
|
181
|
+
}),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const countQueries = useQueries({
|
|
185
|
+
queries: categories.map((category) => {
|
|
186
|
+
const query = sectionCountQuery(criteria, category);
|
|
187
|
+
const filter = sectionFilter(criteria, category);
|
|
188
|
+
return {
|
|
189
|
+
queryKey: unifiedThreadOperationsListAllThreadsQueryKey({ query }),
|
|
190
|
+
queryFn: async () => {
|
|
191
|
+
const { data } = await unifiedThreadOperationsListAllThreads({
|
|
192
|
+
query,
|
|
193
|
+
throwOnError: true,
|
|
194
|
+
});
|
|
195
|
+
return data;
|
|
196
|
+
},
|
|
197
|
+
enabled: counted,
|
|
198
|
+
placeholderData: keepUnderSameFilter(filter),
|
|
199
|
+
staleTime: BRIEF_STALE_TIME_MS,
|
|
200
|
+
};
|
|
201
|
+
}),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const sections = useMemo<BriefSectionRows[]>(
|
|
205
|
+
() =>
|
|
206
|
+
categories.map((category, index) => {
|
|
207
|
+
const rows = rowQueries[index];
|
|
208
|
+
const count = countQueries[index];
|
|
209
|
+
const items = rows?.data?.items ?? [];
|
|
210
|
+
return {
|
|
211
|
+
category,
|
|
212
|
+
rows: items,
|
|
213
|
+
// Read off the page rather than the rendered rows: muting and the
|
|
214
|
+
// thread collapse both shrink what is rendered, and neither means the
|
|
215
|
+
// category holds no more.
|
|
216
|
+
atCap: items.length >= limit,
|
|
217
|
+
// `enabled` gates the request, not the cache: a brief that asks for
|
|
218
|
+
// no count must not render the one an earlier, narrower brief left
|
|
219
|
+
// behind.
|
|
220
|
+
total: counted ? toResultCount(count?.data?.count) : UNCOUNTED,
|
|
221
|
+
// Pending, not fetching: a section holding the previous query's rows
|
|
222
|
+
// has something to render, and replacing it with a skeleton takes the
|
|
223
|
+
// brief off screen every time the search field is cleared.
|
|
224
|
+
loading: rows?.isPending ?? true,
|
|
225
|
+
failed: rows?.isError ?? false,
|
|
226
|
+
retry: () => {
|
|
227
|
+
rows?.refetch();
|
|
228
|
+
// `refetch` ignores `enabled`, so an uncounted brief would fetch a
|
|
229
|
+
// count it has already decided not to render.
|
|
230
|
+
if (counted) count?.refetch();
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}),
|
|
234
|
+
[categories, rowQueries, countQueries, counted, limit],
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const refetch = useCallback(() => {
|
|
238
|
+
for (const query of rowQueries) query.refetch();
|
|
239
|
+
for (const query of countQueries) query.refetch();
|
|
240
|
+
}, [rowQueries, countQueries]);
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
sections,
|
|
244
|
+
// The whole-body states are the ones where there is genuinely nothing to
|
|
245
|
+
// show. A section that is still loading, or that failed on its own, says so
|
|
246
|
+
// in its own place while the rest of the brief stands.
|
|
247
|
+
isLoading:
|
|
248
|
+
rowQueries.length > 0 && rowQueries.every((query) => query.isPending),
|
|
249
|
+
isError:
|
|
250
|
+
rowQueries.length > 0 && rowQueries.every((query) => query.isError),
|
|
251
|
+
refetch,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** What a searched brief holds before the reader is told to narrow it. */
|
|
256
|
+
export interface BriefSearchCriteria extends BriefSectionsCriteria {
|
|
257
|
+
/** A category the chips or a `category:` term narrowed the search to. */
|
|
258
|
+
category?: RemitImapMessageCategory[];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export interface UseBriefSearchRows {
|
|
262
|
+
/** Every match the request returned, in the order the server returned them. */
|
|
263
|
+
rows: RemitImapThreadMessageResponse[];
|
|
264
|
+
isLoading: boolean;
|
|
265
|
+
isError: boolean;
|
|
266
|
+
refetch: () => void;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The brief under a search: one request, one globally newest-first list.
|
|
271
|
+
*
|
|
272
|
+
* Not seven category queries stitched back together. A match's category must not
|
|
273
|
+
* lift it above a newer match, and section headers between the rows would do
|
|
274
|
+
* exactly that — an old newsletter above a mail that arrived this morning. The
|
|
275
|
+
* server orders the whole match set; the client renders it in that order (#312).
|
|
276
|
+
*
|
|
277
|
+
* Search mode also widens the scope past INBOX to every non-muted folder of every
|
|
278
|
+
* account, so this is the only request that reaches Archive, Sent and Spam.
|
|
279
|
+
*/
|
|
280
|
+
export function useBriefSearchRows(
|
|
281
|
+
criteria: BriefSearchCriteria,
|
|
282
|
+
limit: number,
|
|
283
|
+
enabled: boolean,
|
|
284
|
+
): UseBriefSearchRows {
|
|
285
|
+
const query = { ...criteria, limit, order: "desc" as const };
|
|
286
|
+
const { data, isLoading, isError, refetch } = useQuery({
|
|
287
|
+
queryKey: unifiedThreadOperationsListAllThreadsQueryKey({ query }),
|
|
288
|
+
queryFn: async () => {
|
|
289
|
+
const { data: page } = await unifiedThreadOperationsListAllThreads({
|
|
290
|
+
query,
|
|
291
|
+
throwOnError: true,
|
|
292
|
+
});
|
|
293
|
+
return page;
|
|
294
|
+
},
|
|
295
|
+
enabled,
|
|
296
|
+
staleTime: SEARCH_STALE_TIME_MS,
|
|
297
|
+
});
|
|
298
|
+
const rows = useMemo(
|
|
299
|
+
() => (enabled ? (data?.items ?? []) : []),
|
|
300
|
+
[data, enabled],
|
|
301
|
+
);
|
|
302
|
+
return {
|
|
303
|
+
rows,
|
|
304
|
+
isLoading: enabled && isLoading,
|
|
305
|
+
isError: enabled && isError,
|
|
306
|
+
refetch: useCallback(() => {
|
|
307
|
+
refetch();
|
|
308
|
+
}, [refetch]),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
@@ -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,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each brief section asks the server for.
|
|
3
|
+
*
|
|
4
|
+
* #312: every section is its own category-scoped request, so the chips and the
|
|
5
|
+
* tokens that ask for the same thing have to reach it as parameters. A criterion
|
|
6
|
+
* evaluated over the ten rows a section fetched would mean "among the newest ten
|
|
7
|
+
* Marketing messages", which is the reading the whole issue removes.
|
|
8
|
+
*/
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { describe, test } from "node:test";
|
|
11
|
+
import {
|
|
12
|
+
BRIEF_TOKEN_PARAMS,
|
|
13
|
+
briefCountsMatchRows,
|
|
14
|
+
briefCriteria,
|
|
15
|
+
} from "./brief-criteria.js";
|
|
16
|
+
import { parseSearchTokens, type SearchToken } from "./search-tokens.js";
|
|
17
|
+
|
|
18
|
+
const tokensOf = (query: string): SearchToken[] =>
|
|
19
|
+
parseSearchTokens(query, {
|
|
20
|
+
accountsByName: new Map([["alice", "acc_1"]]),
|
|
21
|
+
mailboxesByName: new Map([["sent", "mb2"]]),
|
|
22
|
+
}).tokens;
|
|
23
|
+
|
|
24
|
+
const criteriaOf = (query: string, attributes: string[] = []) =>
|
|
25
|
+
briefCriteria("all", new Set(attributes), tokensOf(query)).criteria;
|
|
26
|
+
|
|
27
|
+
const residualTypes = (query: string, attributes: string[] = []) =>
|
|
28
|
+
briefCriteria("all", new Set(attributes), tokensOf(query)).residual.map(
|
|
29
|
+
(t) => t.type,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
describe("briefCriteria", () => {
|
|
33
|
+
test("an unnarrowed brief asks for nothing in particular", () => {
|
|
34
|
+
assert.deepEqual(criteriaOf(""), {});
|
|
35
|
+
assert.deepEqual(residualTypes(""), []);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("the chips travel as parameters", () => {
|
|
39
|
+
assert.deepEqual(criteriaOf("", ["unread", "attachment"]), {
|
|
40
|
+
unread: true,
|
|
41
|
+
attachments: true,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a typed token asks for exactly what its chip asks for", () => {
|
|
46
|
+
assert.deepEqual(criteriaOf("is:unread has:attachment"), {
|
|
47
|
+
unread: true,
|
|
48
|
+
attachments: true,
|
|
49
|
+
});
|
|
50
|
+
assert.deepEqual(residualTypes("is:unread has:attachment"), []);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("is:read narrows the request rather than the rows", () => {
|
|
54
|
+
assert.deepEqual(criteriaOf("is:read"), { unread: false });
|
|
55
|
+
assert.deepEqual(residualTypes("is:read"), []);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("is:starred travels too", () => {
|
|
59
|
+
assert.deepEqual(criteriaOf("is:starred"), { starred: true });
|
|
60
|
+
assert.deepEqual(residualTypes("is:starred"), []);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// The section supplies its own category, so the shared criteria must not
|
|
64
|
+
// carry one — two categories on one request is a request for neither.
|
|
65
|
+
test("the category never travels in the shared criteria", () => {
|
|
66
|
+
assert.deepEqual(criteriaOf("category:personal"), {});
|
|
67
|
+
assert.deepEqual(criteriaOf("", ["unread"]), { unread: true });
|
|
68
|
+
assert.equal(
|
|
69
|
+
Object.hasOwn(
|
|
70
|
+
briefCriteria("personal", new Set(), []).criteria,
|
|
71
|
+
"category",
|
|
72
|
+
),
|
|
73
|
+
false,
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// `listAllThreads` has one text parameter matching subject and From at once,
|
|
78
|
+
// so neither token can be asked for on its own and both stay residue.
|
|
79
|
+
test("from: and subject: have no parameter here and come back as residue", () => {
|
|
80
|
+
assert.deepEqual(criteriaOf("from:alice"), {});
|
|
81
|
+
assert.deepEqual(residualTypes("from:alice"), ["from"]);
|
|
82
|
+
assert.deepEqual(residualTypes("subject:invoice"), ["subject"]);
|
|
83
|
+
assert.deepEqual(BRIEF_TOKEN_PARAMS.includes("from"), false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("dates, the mailbox and the account stay residue", () => {
|
|
87
|
+
assert.deepEqual(residualTypes("before:2024-01-01 in:sent account:alice"), [
|
|
88
|
+
"before",
|
|
89
|
+
"in",
|
|
90
|
+
"account",
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("a chip beats the token that contradicts it", () => {
|
|
95
|
+
assert.deepEqual(criteriaOf("is:read", ["unread"]), { unread: true });
|
|
96
|
+
assert.deepEqual(residualTypes("is:read", ["unread"]), ["isRead"]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("briefCountsMatchRows", () => {
|
|
101
|
+
const reach = (over: Partial<Parameters<typeof briefCountsMatchRows>[0]>) =>
|
|
102
|
+
briefCountsMatchRows({
|
|
103
|
+
residual: [],
|
|
104
|
+
attributes: new Set<string>(),
|
|
105
|
+
accountScoped: false,
|
|
106
|
+
...over,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("a request carrying every criterion on screen counts what it shows", () => {
|
|
110
|
+
assert.equal(reach({}), true);
|
|
111
|
+
assert.equal(reach({ attributes: new Set(["unread"]) }), true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("a residual token makes the count wider than the list", () => {
|
|
115
|
+
assert.equal(reach({ residual: tokensOf("from:alice") }), false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("the account pills scope the rows and nothing else", () => {
|
|
119
|
+
assert.equal(reach({ accountScoped: true }), false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a chip with no parameter makes the count wider than the list", () => {
|
|
123
|
+
assert.equal(reach({ attributes: new Set(["contacts"]) }), false);
|
|
124
|
+
assert.equal(reach({ attributes: new Set(["today"]) }), false);
|
|
125
|
+
});
|
|
126
|
+
});
|