@remit/web-client 0.0.253 → 0.0.254
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/FlaggedList.tsx +33 -12
- package/src/components/mail/MailboxPane.tsx +38 -16
- package/src/components/mail/MessageList.tsx +12 -5
- package/src/components/mail/filtered-empty-state.render.test.ts +240 -0
- package/src/lib/list-narrowing.test.ts +88 -0
- package/src/lib/list-narrowing.ts +172 -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.254",
|
|
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": {
|
|
@@ -45,8 +45,8 @@ import {
|
|
|
45
45
|
} from "@/lib/brief";
|
|
46
46
|
import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
|
|
47
47
|
import { flaggedCriteria } from "@/lib/flagged-criteria";
|
|
48
|
-
import { categoryLabel } from "@/lib/inbox-filters";
|
|
49
48
|
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
49
|
+
import { listNarrowing } from "@/lib/list-narrowing";
|
|
50
50
|
import { useMailContext } from "@/lib/mail-context";
|
|
51
51
|
import { rowToSearchResult } from "@/lib/search-result";
|
|
52
52
|
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
@@ -126,7 +126,8 @@ export function FlaggedList({
|
|
|
126
126
|
onTriageContextChange,
|
|
127
127
|
onDeleteMessages,
|
|
128
128
|
}: FlaggedListProps) {
|
|
129
|
-
const { searchQuery, resultFolderIndex } =
|
|
129
|
+
const { searchQuery, resultFolderIndex, onSearchClearQuery } =
|
|
130
|
+
useMailContext();
|
|
130
131
|
const tokenContext = useSearchTokenContext();
|
|
131
132
|
const isDesktop = useIsDesktop();
|
|
132
133
|
const wizard = useSelectionWizard();
|
|
@@ -150,10 +151,24 @@ export function FlaggedList({
|
|
|
150
151
|
setActiveFilters(new Set());
|
|
151
152
|
}, []);
|
|
152
153
|
|
|
154
|
+
// The empty state's way out has to clear everything its headline names, or
|
|
155
|
+
// it is a button that leaves the list exactly as narrowed as it found it.
|
|
156
|
+
const clearNarrowing = useCallback(() => {
|
|
157
|
+
clearFilters();
|
|
158
|
+
onSearchClearQuery();
|
|
159
|
+
}, [clearFilters, onSearchClearQuery]);
|
|
160
|
+
|
|
153
161
|
const { freeText: sq, tokens: queryTokens } = parseSearchTokens(
|
|
154
162
|
searchQuery.trim().toLowerCase(),
|
|
155
163
|
tokenContext,
|
|
156
164
|
);
|
|
165
|
+
// The same free text in the reader's own casing. The request's copy is folded
|
|
166
|
+
// to lowercase so equivalent searches share a cache entry; a sentence quoting
|
|
167
|
+
// the query back to the reader must not be.
|
|
168
|
+
const typedFreeText = parseSearchTokens(
|
|
169
|
+
searchQuery.trim(),
|
|
170
|
+
tokenContext,
|
|
171
|
+
).freeText;
|
|
157
172
|
|
|
158
173
|
// The chips and the tokens together, as query parameters: a category typed as
|
|
159
174
|
// `category:personal` narrows the request exactly as the chip does.
|
|
@@ -238,15 +253,19 @@ export function FlaggedList({
|
|
|
238
253
|
// the request. Every chip and every carried token is a column on the row, so
|
|
239
254
|
// the server answered over the whole collection; a residual token, or the
|
|
240
255
|
// snippet half of a free-text search, only ever saw the pages loaded so far.
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
:
|
|
256
|
+
//
|
|
257
|
+
// `is:starred` is dropped: this view is starred mail, so the token restates
|
|
258
|
+
// the collection rather than narrowing it.
|
|
259
|
+
const narrowingTokens = useMemo(
|
|
260
|
+
() => queryTokens.filter((token) => token.type !== "isStarred"),
|
|
261
|
+
[queryTokens],
|
|
262
|
+
);
|
|
263
|
+
const listFilter: MessageListFilter | undefined = listNarrowing({
|
|
264
|
+
chips: { category: selectedCategory, attributes: activeFilters },
|
|
265
|
+
tokens: narrowingTokens,
|
|
266
|
+
reach: residualTokens.length > 0 || sq ? "loaded-pages" : "whole-folder",
|
|
267
|
+
onClear: clearNarrowing,
|
|
268
|
+
});
|
|
250
269
|
|
|
251
270
|
const listState = isLoading
|
|
252
271
|
? "loading"
|
|
@@ -327,7 +346,9 @@ export function FlaggedList({
|
|
|
327
346
|
// Flagged spans accounts and folders, so it is a collection and
|
|
328
347
|
// never "this mailbox". It names itself.
|
|
329
348
|
listScopeLabel="Starred"
|
|
330
|
-
|
|
349
|
+
// The typed tokens narrow the list too, but `listFilter` already
|
|
350
|
+
// names them, and a headline saying one narrowing twice reads as two.
|
|
351
|
+
searchQuery={typedFreeText || undefined}
|
|
331
352
|
errorMessage={isError ? formatErrorMessage(error) : undefined}
|
|
332
353
|
onRetry={() => refetch()}
|
|
333
354
|
onReportError={handleReportError}
|
|
@@ -100,7 +100,6 @@ import { useUpdateAddressFlags } from "@/hooks/useUpdateAddressFlags";
|
|
|
100
100
|
import type { ConversationTarget } from "@/lib/conversation-target";
|
|
101
101
|
import { dedupeThreadMessages } from "@/lib/dedupe-thread-messages";
|
|
102
102
|
import {
|
|
103
|
-
categoryLabel,
|
|
104
103
|
filterReach,
|
|
105
104
|
hasInboxFilter,
|
|
106
105
|
type InboxFilterCriteria,
|
|
@@ -109,6 +108,7 @@ import {
|
|
|
109
108
|
sameInboxFilter,
|
|
110
109
|
} from "@/lib/inbox-filters";
|
|
111
110
|
import { junkDestination } from "@/lib/junk-destination";
|
|
111
|
+
import { listNarrowing } from "@/lib/list-narrowing";
|
|
112
112
|
import { useMailContext } from "@/lib/mail-context";
|
|
113
113
|
import { useMailFreshness } from "@/lib/mail-freshness";
|
|
114
114
|
import { isRescueCandidate } from "@/lib/rescue-candidates";
|
|
@@ -178,11 +178,17 @@ interface MailboxPaneContextValue {
|
|
|
178
178
|
onToggleFilterAttribute: (id: string) => void;
|
|
179
179
|
onClearFilters: () => void;
|
|
180
180
|
/**
|
|
181
|
-
*
|
|
182
|
-
* way out of it, and how much of the mailbox the request reached.
|
|
183
|
-
* when
|
|
181
|
+
* What narrows the list as the empty state renders it — the narrowing named,
|
|
182
|
+
* the way out of it, and how much of the mailbox the request reached.
|
|
183
|
+
* Undefined when nothing narrows the list.
|
|
184
184
|
*/
|
|
185
185
|
listFilter: MessageListFilter | undefined;
|
|
186
|
+
/**
|
|
187
|
+
* The free text the empty state names. The typed tokens narrow the list too,
|
|
188
|
+
* but `listFilter` already names them, and a headline saying the same
|
|
189
|
+
* narrowing twice reads as two.
|
|
190
|
+
*/
|
|
191
|
+
listSearchText: string | undefined;
|
|
186
192
|
onToggleIntelligence: () => void;
|
|
187
193
|
/**
|
|
188
194
|
* Where the mounted reading surface publishes the intelligence commands the
|
|
@@ -277,7 +283,8 @@ function MailboxPaneProvider({
|
|
|
277
283
|
const threadId = thread?.threadId;
|
|
278
284
|
const pointedAtMessageId = thread?.messageId;
|
|
279
285
|
const telemetry = useTelemetry();
|
|
280
|
-
const { accounts, searchQuery, onToggleIntelligence } =
|
|
286
|
+
const { accounts, searchQuery, onToggleIntelligence, onSearchClearQuery } =
|
|
287
|
+
useMailContext();
|
|
281
288
|
const tokenContext = useSearchTokenContext();
|
|
282
289
|
|
|
283
290
|
const normalizedSearchQuery = normalizeSearchQuery(searchQuery);
|
|
@@ -295,6 +302,13 @@ function MailboxPaneProvider({
|
|
|
295
302
|
normalizedSearchQuery,
|
|
296
303
|
tokenContext,
|
|
297
304
|
);
|
|
305
|
+
// The same free text in the reader's own casing. The request's copy is folded
|
|
306
|
+
// to lowercase so equivalent searches share a cache entry; a sentence quoting
|
|
307
|
+
// the query back to the reader must not be.
|
|
308
|
+
const typedFreeText = parseSearchTokens(
|
|
309
|
+
searchQuery.trim(),
|
|
310
|
+
tokenContext,
|
|
311
|
+
).freeText;
|
|
298
312
|
|
|
299
313
|
const [filterCategory, setFilterCategory] = useState("all");
|
|
300
314
|
const [filterAttributes, setFilterAttributes] = useState<ReadonlySet<string>>(
|
|
@@ -445,20 +459,25 @@ function MailboxPaneProvider({
|
|
|
445
459
|
setFilterAttributes(new Set());
|
|
446
460
|
}, []);
|
|
447
461
|
|
|
462
|
+
// The empty state's way out has to clear everything its headline names, or
|
|
463
|
+
// it is a button that leaves the list exactly as narrowed as it found it.
|
|
464
|
+
const onClearNarrowing = useCallback(() => {
|
|
465
|
+
onClearFilters();
|
|
466
|
+
onSearchClearQuery();
|
|
467
|
+
}, [onClearFilters, onSearchClearQuery]);
|
|
468
|
+
|
|
448
469
|
// The empty state has to say how much was read, and the reach comes off the
|
|
449
470
|
// request rather than the call site: the day a chip is answered over a window
|
|
450
471
|
// instead of the whole mailbox, the sentence changes with it.
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
}
|
|
461
|
-
: undefined;
|
|
472
|
+
const listFilter: MessageListFilter | undefined = listNarrowing({
|
|
473
|
+
chips: filterCriteria,
|
|
474
|
+
tokens: searchTokens,
|
|
475
|
+
reach:
|
|
476
|
+
residualTokens.length > 0
|
|
477
|
+
? "loaded-pages"
|
|
478
|
+
: filterReach(searchThreadsQuery),
|
|
479
|
+
onClear: onClearNarrowing,
|
|
480
|
+
});
|
|
462
481
|
|
|
463
482
|
// The row this folder itself lists, preferred because a mutation patches it in
|
|
464
483
|
// place. A thread the loaded pages do not hold — a chip that paged it out, a
|
|
@@ -799,6 +818,7 @@ function MailboxPaneProvider({
|
|
|
799
818
|
onToggleFilterAttribute,
|
|
800
819
|
onClearFilters,
|
|
801
820
|
listFilter,
|
|
821
|
+
listSearchText: typedFreeText || undefined,
|
|
802
822
|
onToggleIntelligence,
|
|
803
823
|
intelligenceRef,
|
|
804
824
|
handleDeselectIfRemoved,
|
|
@@ -875,6 +895,7 @@ function MailboxList() {
|
|
|
875
895
|
onToggleFilterAttribute,
|
|
876
896
|
onClearFilters,
|
|
877
897
|
listFilter,
|
|
898
|
+
listSearchText,
|
|
878
899
|
searchPredicate,
|
|
879
900
|
resultCount,
|
|
880
901
|
} = useMailboxPane();
|
|
@@ -1011,6 +1032,7 @@ function MailboxList() {
|
|
|
1011
1032
|
accountId={mailboxAccountId}
|
|
1012
1033
|
listTitle={listTitle}
|
|
1013
1034
|
listFilter={listFilter}
|
|
1035
|
+
listSearchText={listSearchText}
|
|
1014
1036
|
listScopeLabel={listTitle}
|
|
1015
1037
|
hideHeader
|
|
1016
1038
|
onTriageContextChange={onTriageContextChange}
|
|
@@ -142,12 +142,18 @@ interface MessageListProps {
|
|
|
142
142
|
*/
|
|
143
143
|
listMeta?: string;
|
|
144
144
|
/**
|
|
145
|
-
*
|
|
146
|
-
* it
|
|
147
|
-
*
|
|
148
|
-
*
|
|
145
|
+
* What narrows the list, when anything does. The empty state needs it to say
|
|
146
|
+
* it is filtered and how much of the collection the request reached; without
|
|
147
|
+
* it a narrowed list renders as an empty mailbox, which is the shape #315's
|
|
148
|
+
* bug hid behind.
|
|
149
149
|
*/
|
|
150
150
|
listFilter?: MessageListFilter;
|
|
151
|
+
/**
|
|
152
|
+
* The free text the empty state names, which is `searchQuery` with the typed
|
|
153
|
+
* filter tokens taken out: `listFilter` already names those, and a headline
|
|
154
|
+
* saying one narrowing twice reads as two.
|
|
155
|
+
*/
|
|
156
|
+
listSearchText?: string;
|
|
151
157
|
/** Name of the collection being listed, e.g. "Inbox", for the empty state. */
|
|
152
158
|
listScopeLabel?: string;
|
|
153
159
|
/**
|
|
@@ -233,6 +239,7 @@ export const MessageList = ({
|
|
|
233
239
|
listTitle,
|
|
234
240
|
listMeta,
|
|
235
241
|
listFilter,
|
|
242
|
+
listSearchText,
|
|
236
243
|
listScopeLabel,
|
|
237
244
|
onTriageContextChange,
|
|
238
245
|
commandsRef,
|
|
@@ -1301,7 +1308,7 @@ export const MessageList = ({
|
|
|
1301
1308
|
// The results panel is a body, not a list state: it stands in for the
|
|
1302
1309
|
// rows and must not fall behind a skeleton while the query re-keys.
|
|
1303
1310
|
listState={listHeaderChrome.searchResults ? "ready" : listState}
|
|
1304
|
-
searchQuery={
|
|
1311
|
+
searchQuery={listSearchText}
|
|
1305
1312
|
listFilter={listFilter}
|
|
1306
1313
|
listScopeLabel={listScopeLabel}
|
|
1307
1314
|
errorMessage={errorMessage}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A list narrowed by anything at all says so when it comes back empty (#1126).
|
|
3
|
+
*
|
|
4
|
+
* Both list panes built the empty state's filter from the category chip alone,
|
|
5
|
+
* and `MessageListEmpty` reads that object's presence as "a filter is active".
|
|
6
|
+
* So the Unread chip on its own — or `is:unread` typed in the field — fell
|
|
7
|
+
* through to the unfiltered copy: "No messages in Starred" over a collection
|
|
8
|
+
* holding thousands of starred messages, with no way out offered.
|
|
9
|
+
*
|
|
10
|
+
* Driven through the chips the reader actually presses, in the real panes, so
|
|
11
|
+
* the assertion is about what the screen says rather than what a helper returns.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { afterEach, describe, it } from "node:test";
|
|
16
|
+
import {
|
|
17
|
+
type AnyRouter,
|
|
18
|
+
createMemoryHistory,
|
|
19
|
+
createRootRoute,
|
|
20
|
+
createRoute,
|
|
21
|
+
createRouter,
|
|
22
|
+
Outlet,
|
|
23
|
+
RouterProvider,
|
|
24
|
+
} from "@tanstack/react-router";
|
|
25
|
+
import { type ComponentType, createElement, type ReactNode } from "react";
|
|
26
|
+
import { ComposeProvider } from "@/components/compose/ComposeProvider";
|
|
27
|
+
import { MailContext, type MailContextValue } from "@/lib/mail-context";
|
|
28
|
+
import { MailFreshnessProvider } from "@/lib/mail-freshness";
|
|
29
|
+
import { EMPTY_RESULT_FOLDER_INDEX } from "@/lib/result-folder";
|
|
30
|
+
import { useOpenThreadPath } from "@/routing";
|
|
31
|
+
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
32
|
+
import { type HttpMock, mockFetch } from "@/test-support/http";
|
|
33
|
+
import { FlaggedPane } from "./FlaggedPane";
|
|
34
|
+
import { MailboxPane } from "./MailboxPane";
|
|
35
|
+
|
|
36
|
+
const MAILBOX_ID = "11111111-1111-4111-8111-111111111111";
|
|
37
|
+
const DESKTOP_WIDTH = 1440;
|
|
38
|
+
|
|
39
|
+
let harness: DomHarness | undefined;
|
|
40
|
+
let http: HttpMock | undefined;
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
harness?.close();
|
|
44
|
+
harness = undefined;
|
|
45
|
+
http?.restore();
|
|
46
|
+
http = undefined;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// The router reads `self` at construction; the shared jsdom globals stop at
|
|
50
|
+
// `window`.
|
|
51
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
52
|
+
|
|
53
|
+
interface PaneUnderTest {
|
|
54
|
+
name: string;
|
|
55
|
+
/** The list's own segment under `/mail`, which is its whole route. */
|
|
56
|
+
segment: string;
|
|
57
|
+
/** What the empty state calls the collection. */
|
|
58
|
+
scopeLabel: string;
|
|
59
|
+
List: ComponentType;
|
|
60
|
+
mount: (children: ReactNode) => ReactNode;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const panes: PaneUnderTest[] = [
|
|
64
|
+
{
|
|
65
|
+
name: "Starred",
|
|
66
|
+
segment: "flagged",
|
|
67
|
+
scopeLabel: "Starred",
|
|
68
|
+
List: FlaggedPane.List,
|
|
69
|
+
mount: (children) =>
|
|
70
|
+
createElement(FlaggedPane, {
|
|
71
|
+
thread: useOpenThreadPath(),
|
|
72
|
+
// biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
|
|
73
|
+
children,
|
|
74
|
+
}),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "the mailbox",
|
|
78
|
+
segment: MAILBOX_ID,
|
|
79
|
+
scopeLabel: "Inbox",
|
|
80
|
+
List: MailboxPane.List,
|
|
81
|
+
mount: (children) =>
|
|
82
|
+
createElement(MailboxPane, {
|
|
83
|
+
mailboxId: MAILBOX_ID,
|
|
84
|
+
thread: useOpenThreadPath(),
|
|
85
|
+
// biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
|
|
86
|
+
children,
|
|
87
|
+
}),
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
/** The layout context the panes read the search field out of. */
|
|
92
|
+
const mailContext = (query: string): MailContextValue => ({
|
|
93
|
+
accounts: [],
|
|
94
|
+
mailboxNameIndex: new Map(),
|
|
95
|
+
accountNameIndex: new Map(),
|
|
96
|
+
resultFolderIndex: EMPTY_RESULT_FOLDER_INDEX,
|
|
97
|
+
searchQuery: query,
|
|
98
|
+
searchInput: query,
|
|
99
|
+
searchViewKey: "list",
|
|
100
|
+
onSearchChange: () => undefined,
|
|
101
|
+
onSearchClear: () => undefined,
|
|
102
|
+
onSearchClearQuery: () => undefined,
|
|
103
|
+
intelligenceOpen: false,
|
|
104
|
+
onToggleIntelligence: () => undefined,
|
|
105
|
+
onRaiseIntelligence: () => undefined,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const testRouter = (pane: PaneUnderTest, query: string): AnyRouter => {
|
|
109
|
+
const rootRoute = createRootRoute({
|
|
110
|
+
component: () =>
|
|
111
|
+
createElement(MailFreshnessProvider, {
|
|
112
|
+
accountIds: [],
|
|
113
|
+
// biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
|
|
114
|
+
children: createElement(
|
|
115
|
+
MailContext.Provider,
|
|
116
|
+
{ value: mailContext(query) },
|
|
117
|
+
createElement(ComposeProvider, null, createElement(Outlet)),
|
|
118
|
+
),
|
|
119
|
+
}),
|
|
120
|
+
});
|
|
121
|
+
const mailRoute = createRoute({
|
|
122
|
+
getParentRoute: () => rootRoute,
|
|
123
|
+
path: "/mail",
|
|
124
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
125
|
+
component: Outlet,
|
|
126
|
+
});
|
|
127
|
+
const mailboxRoute = createRoute({
|
|
128
|
+
getParentRoute: () => mailRoute,
|
|
129
|
+
path: "/$mailboxId",
|
|
130
|
+
component: () => pane.mount(createElement(pane.List)),
|
|
131
|
+
});
|
|
132
|
+
const flaggedRoute = createRoute({
|
|
133
|
+
getParentRoute: () => mailRoute,
|
|
134
|
+
path: "/flagged",
|
|
135
|
+
component: () => pane.mount(createElement(pane.List)),
|
|
136
|
+
});
|
|
137
|
+
const routeTree = rootRoute.addChildren([
|
|
138
|
+
mailRoute.addChildren([mailboxRoute, flaggedRoute]),
|
|
139
|
+
]);
|
|
140
|
+
return createRouter({
|
|
141
|
+
routeTree,
|
|
142
|
+
history: createMemoryHistory({
|
|
143
|
+
initialEntries: [`/mail/${pane.segment}`],
|
|
144
|
+
}),
|
|
145
|
+
}) as unknown as AnyRouter;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const mount = async (pane: PaneUnderTest, query = ""): Promise<DomHarness> => {
|
|
149
|
+
http = mockFetch((call) => {
|
|
150
|
+
if (call.path.endsWith("/config")) return { accounts: [] };
|
|
151
|
+
if (call.path.endsWith("/mailboxes")) {
|
|
152
|
+
return {
|
|
153
|
+
items: [{ mailboxId: MAILBOX_ID, name: "Inbox", fullPath: "INBOX" }],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return { items: [] };
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const router = testRouter(pane, query);
|
|
160
|
+
await router.load();
|
|
161
|
+
const mounted = createDomHarness({ viewportWidth: DESKTOP_WIDTH });
|
|
162
|
+
harness = mounted;
|
|
163
|
+
mounted.renderApp(createElement(RouterProvider, { router }));
|
|
164
|
+
await mounted.waitFor(
|
|
165
|
+
() => mounted.text().includes("No "),
|
|
166
|
+
"the empty list to settle",
|
|
167
|
+
);
|
|
168
|
+
return mounted;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/** Press the Unread chip, which is the whole narrowing this test is about. */
|
|
172
|
+
const pressUnread = async (mounted: DomHarness): Promise<void> => {
|
|
173
|
+
mounted.click(mounted.byLabel("Expand filters"));
|
|
174
|
+
await mounted.flush();
|
|
175
|
+
const attributes = mounted.query('fieldset[aria-label="Attributes"]');
|
|
176
|
+
assert.ok(attributes, "the attribute chips are not on screen");
|
|
177
|
+
const unread = [...attributes.querySelectorAll("button")].find(
|
|
178
|
+
(button) => button.textContent === "Unread",
|
|
179
|
+
);
|
|
180
|
+
assert.ok(unread, "there is no Unread chip to press");
|
|
181
|
+
mounted.click(unread);
|
|
182
|
+
await mounted.waitFor(
|
|
183
|
+
() => mounted.text().includes("No "),
|
|
184
|
+
"the narrowed list to answer",
|
|
185
|
+
);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
describe("an empty list narrowed by a chip or a token (#1126)", () => {
|
|
189
|
+
for (const pane of panes) {
|
|
190
|
+
it(`${pane.name} names the narrowing instead of the collection`, async () => {
|
|
191
|
+
const mounted = await mount(pane);
|
|
192
|
+
assert.match(
|
|
193
|
+
mounted.text(),
|
|
194
|
+
new RegExp(`No messages in ${pane.scopeLabel}`),
|
|
195
|
+
"the unfiltered copy is the state this test starts from",
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
await pressUnread(mounted);
|
|
199
|
+
|
|
200
|
+
const shown = mounted.text();
|
|
201
|
+
assert.match(shown, new RegExp(`No unread mail in ${pane.scopeLabel}`));
|
|
202
|
+
assert.doesNotMatch(
|
|
203
|
+
shown,
|
|
204
|
+
new RegExp(`No messages in ${pane.scopeLabel}`),
|
|
205
|
+
"a filtered list told the reader the collection itself was empty",
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it(`${pane.name} answers a token-only search the same way`, async () => {
|
|
210
|
+
const mounted = await mount(pane, "is:unread");
|
|
211
|
+
|
|
212
|
+
const shown = mounted.text();
|
|
213
|
+
assert.match(shown, new RegExp(`No unread mail in ${pane.scopeLabel}`));
|
|
214
|
+
assert.doesNotMatch(
|
|
215
|
+
shown,
|
|
216
|
+
new RegExp(`No messages in ${pane.scopeLabel}`),
|
|
217
|
+
"a search of tokens alone read as an empty collection",
|
|
218
|
+
);
|
|
219
|
+
assert.doesNotMatch(
|
|
220
|
+
shown,
|
|
221
|
+
/No messages match your search/,
|
|
222
|
+
"the narrowing is named, so the generic search line is wrong here",
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it(`${pane.name} says how much was checked and offers the way out`, async () => {
|
|
227
|
+
const mounted = await mount(pane);
|
|
228
|
+
await pressUnread(mounted);
|
|
229
|
+
|
|
230
|
+
const shown = mounted.text();
|
|
231
|
+
assert.match(shown, /Every message in this folder was checked\./);
|
|
232
|
+
assert.ok(
|
|
233
|
+
[...mounted.queryAll("button")].some(
|
|
234
|
+
(button) => button.textContent === "Clear filter",
|
|
235
|
+
),
|
|
236
|
+
"a narrowed empty list offered no way back out of the narrowing",
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { narrowingLabel } from "./list-narrowing.js";
|
|
4
|
+
import { parseSearchTokens, type SearchToken } from "./search-tokens.js";
|
|
5
|
+
|
|
6
|
+
const chips = (
|
|
7
|
+
category = "all",
|
|
8
|
+
attributes: string[] = [],
|
|
9
|
+
): { category: string; attributes: ReadonlySet<string> } => ({
|
|
10
|
+
category,
|
|
11
|
+
attributes: new Set(attributes),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const tokensOf = (query: string): SearchToken[] =>
|
|
15
|
+
parseSearchTokens(query).tokens;
|
|
16
|
+
|
|
17
|
+
describe("narrowingLabel", () => {
|
|
18
|
+
it("says nothing when nothing narrows the list", () => {
|
|
19
|
+
assert.equal(narrowingLabel(chips(), []), undefined);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("treats free text as no narrowing — the search copy already names it", () => {
|
|
23
|
+
assert.equal(narrowingLabel(chips(), tokensOf("invoice")), undefined);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("names the category chip exactly as it always did", () => {
|
|
27
|
+
assert.equal(narrowingLabel(chips("personal"), []), "Personal mail");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("names an attribute chip with no category chosen (#1126)", () => {
|
|
31
|
+
assert.equal(narrowingLabel(chips("all", ["unread"]), []), "unread mail");
|
|
32
|
+
assert.equal(
|
|
33
|
+
narrowingLabel(chips("all", ["attachment"]), []),
|
|
34
|
+
"mail with an attachment",
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("names a token-only search (#1126)", () => {
|
|
39
|
+
assert.equal(narrowingLabel(chips(), tokensOf("is:unread")), "unread mail");
|
|
40
|
+
assert.equal(
|
|
41
|
+
narrowingLabel(chips(), tokensOf("has:attachment")),
|
|
42
|
+
"mail with an attachment",
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("orders every narrowing into one phrase", () => {
|
|
47
|
+
assert.equal(
|
|
48
|
+
narrowingLabel(chips("personal", ["unread", "attachment"]), []),
|
|
49
|
+
"Personal unread mail with an attachment",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("says one narrowing once when a chip and a token agree", () => {
|
|
54
|
+
assert.equal(
|
|
55
|
+
narrowingLabel(chips("all", ["unread"]), tokensOf("is:unread")),
|
|
56
|
+
"unread mail",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("lets the chip win where a token contradicts it, as the request does", () => {
|
|
61
|
+
assert.equal(
|
|
62
|
+
narrowingLabel(chips("personal"), tokensOf("category:newsletter")),
|
|
63
|
+
"Personal mail",
|
|
64
|
+
);
|
|
65
|
+
assert.equal(
|
|
66
|
+
narrowingLabel(chips("all", ["unread"]), tokensOf("is:read")),
|
|
67
|
+
"unread mail",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("names the tokens no request parameter carries", () => {
|
|
72
|
+
assert.equal(
|
|
73
|
+
narrowingLabel(chips(), tokensOf("before:2024-01-01")),
|
|
74
|
+
"mail sent before 2024-01-01",
|
|
75
|
+
);
|
|
76
|
+
assert.equal(
|
|
77
|
+
narrowingLabel(chips(), tokensOf("from:alex@example.com")),
|
|
78
|
+
"mail from alex@example.com",
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("keeps two values of the same token, which narrow to neither one", () => {
|
|
83
|
+
assert.equal(
|
|
84
|
+
narrowingLabel(chips(), tokensOf("from:alex from:priya")),
|
|
85
|
+
"mail from alex from priya",
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a list is narrowed, and by what — one derived fact for every list.
|
|
3
|
+
*
|
|
4
|
+
* `MessageListEmpty` reads the presence of a `MessageListFilter` as "something
|
|
5
|
+
* is filtering this list". Both list panes used to build that object from the
|
|
6
|
+
* category chip alone, so a reader who had narrowed by the Unread chip, the Has
|
|
7
|
+
* attachment chip, or a typed `is:unread` was told the collection itself was
|
|
8
|
+
* empty — "No messages in Starred" over a mailbox full of starred mail (#1126).
|
|
9
|
+
*
|
|
10
|
+
* Every narrowing the two panes offer therefore lands here: the chips the view
|
|
11
|
+
* holds and the tokens parsed out of the search field. A chip beats a token
|
|
12
|
+
* where both set the same parameter, exactly as the request builders resolve
|
|
13
|
+
* them, so the sentence and the query can never disagree.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { FilterReach, MessageListFilter } from "@remit/ui";
|
|
17
|
+
import { categoryLabel, type InboxFilterCriteria } from "./inbox-filters.js";
|
|
18
|
+
import type { SearchToken } from "./search-tokens.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One narrowing, as words. `pre` qualifies "mail" before it ("unread mail"),
|
|
22
|
+
* `post` after it ("mail with an attachment"). `dimension` is what it narrows
|
|
23
|
+
* by, so two sources naming the same thing are said once.
|
|
24
|
+
*/
|
|
25
|
+
interface NarrowingPart {
|
|
26
|
+
dimension: string;
|
|
27
|
+
position: "pre" | "post";
|
|
28
|
+
text: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const UNREAD: NarrowingPart = {
|
|
32
|
+
dimension: "read",
|
|
33
|
+
position: "pre",
|
|
34
|
+
text: "unread",
|
|
35
|
+
};
|
|
36
|
+
const READ: NarrowingPart = {
|
|
37
|
+
dimension: "read",
|
|
38
|
+
position: "pre",
|
|
39
|
+
text: "read",
|
|
40
|
+
};
|
|
41
|
+
const STARRED: NarrowingPart = {
|
|
42
|
+
dimension: "starred",
|
|
43
|
+
position: "pre",
|
|
44
|
+
text: "starred",
|
|
45
|
+
};
|
|
46
|
+
const ATTACHMENT: NarrowingPart = {
|
|
47
|
+
dimension: "attachment",
|
|
48
|
+
position: "post",
|
|
49
|
+
text: "with an attachment",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** The attribute chips, in the order the phrase says them. */
|
|
53
|
+
const ATTRIBUTE_PARTS: readonly (readonly [string, NarrowingPart])[] = [
|
|
54
|
+
["unread", UNREAD],
|
|
55
|
+
["flagged", STARRED],
|
|
56
|
+
["attachment", ATTACHMENT],
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const tokenPart = (token: SearchToken): NarrowingPart => {
|
|
60
|
+
switch (token.type) {
|
|
61
|
+
case "category":
|
|
62
|
+
return {
|
|
63
|
+
dimension: "category",
|
|
64
|
+
position: "pre",
|
|
65
|
+
text: categoryLabel(token.category) ?? token.value,
|
|
66
|
+
};
|
|
67
|
+
case "isUnread":
|
|
68
|
+
return UNREAD;
|
|
69
|
+
case "isRead":
|
|
70
|
+
return READ;
|
|
71
|
+
case "isStarred":
|
|
72
|
+
return STARRED;
|
|
73
|
+
case "hasAttachment":
|
|
74
|
+
return ATTACHMENT;
|
|
75
|
+
case "from":
|
|
76
|
+
return {
|
|
77
|
+
dimension: token.raw,
|
|
78
|
+
position: "post",
|
|
79
|
+
text: `from ${token.value}`,
|
|
80
|
+
};
|
|
81
|
+
case "subject":
|
|
82
|
+
return {
|
|
83
|
+
dimension: token.raw,
|
|
84
|
+
position: "post",
|
|
85
|
+
text: `about “${token.value}”`,
|
|
86
|
+
};
|
|
87
|
+
case "before":
|
|
88
|
+
return {
|
|
89
|
+
dimension: token.raw,
|
|
90
|
+
position: "post",
|
|
91
|
+
text: `sent before ${token.value}`,
|
|
92
|
+
};
|
|
93
|
+
case "after":
|
|
94
|
+
return {
|
|
95
|
+
dimension: token.raw,
|
|
96
|
+
position: "post",
|
|
97
|
+
text: `sent on or after ${token.value}`,
|
|
98
|
+
};
|
|
99
|
+
case "in":
|
|
100
|
+
return {
|
|
101
|
+
dimension: token.raw,
|
|
102
|
+
position: "post",
|
|
103
|
+
text: `in ${token.value}`,
|
|
104
|
+
};
|
|
105
|
+
case "account":
|
|
106
|
+
return {
|
|
107
|
+
dimension: token.raw,
|
|
108
|
+
position: "post",
|
|
109
|
+
text: `in the ${token.value} account`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** Everything narrowing the list, chips first, each dimension said once. */
|
|
115
|
+
const narrowingParts = (
|
|
116
|
+
chips: InboxFilterCriteria,
|
|
117
|
+
tokens: readonly SearchToken[],
|
|
118
|
+
): NarrowingPart[] => {
|
|
119
|
+
const byDimension = new Map<string, NarrowingPart>();
|
|
120
|
+
const add = (part: NarrowingPart) => {
|
|
121
|
+
if (byDimension.has(part.dimension)) return;
|
|
122
|
+
byDimension.set(part.dimension, part);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const category = categoryLabel(chips.category);
|
|
126
|
+
if (category) add({ dimension: "category", position: "pre", text: category });
|
|
127
|
+
for (const [id, part] of ATTRIBUTE_PARTS) {
|
|
128
|
+
if (chips.attributes.has(id)) add(part);
|
|
129
|
+
}
|
|
130
|
+
for (const token of tokens) add(tokenPart(token));
|
|
131
|
+
|
|
132
|
+
return [...byDimension.values()];
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The narrowing as the noun phrase the empty state's headline reads "No " in
|
|
137
|
+
* front of, or `undefined` when nothing narrows the list.
|
|
138
|
+
*/
|
|
139
|
+
export const narrowingLabel = (
|
|
140
|
+
chips: InboxFilterCriteria,
|
|
141
|
+
tokens: readonly SearchToken[],
|
|
142
|
+
): string | undefined => {
|
|
143
|
+
const parts = narrowingParts(chips, tokens);
|
|
144
|
+
if (parts.length === 0) return undefined;
|
|
145
|
+
const said = (position: "pre" | "post") =>
|
|
146
|
+
parts.filter((part) => part.position === position).map((p) => p.text);
|
|
147
|
+
return [...said("pre"), "mail", ...said("post")].join(" ");
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export interface ListNarrowing {
|
|
151
|
+
/** The chip state the view holds. */
|
|
152
|
+
chips: InboxFilterCriteria;
|
|
153
|
+
/** Every token the search field carries, sent as a parameter or not. */
|
|
154
|
+
tokens: readonly SearchToken[];
|
|
155
|
+
/** How much of the collection the request reached, read off the request. */
|
|
156
|
+
reach: FilterReach;
|
|
157
|
+
/** Drops every narrowing the label names — the chips and the query alike. */
|
|
158
|
+
onClear: () => void;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The filter a narrowed list hands its empty state, or `undefined` when it is
|
|
162
|
+
* not narrowed at all. */
|
|
163
|
+
export const listNarrowing = ({
|
|
164
|
+
chips,
|
|
165
|
+
tokens,
|
|
166
|
+
reach,
|
|
167
|
+
onClear,
|
|
168
|
+
}: ListNarrowing): MessageListFilter | undefined => {
|
|
169
|
+
const label = narrowingLabel(chips, tokens);
|
|
170
|
+
if (!label) return undefined;
|
|
171
|
+
return { label, reach, onClear };
|
|
172
|
+
};
|