@remit/web-client 0.0.254 → 0.0.255
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 +12 -33
- package/src/components/mail/MailboxPane.tsx +16 -38
- package/src/components/mail/MessageList.tsx +5 -12
- package/src/components/mail/filtered-empty-state.render.test.ts +0 -240
- package/src/lib/list-narrowing.test.ts +0 -88
- package/src/lib/list-narrowing.ts +0 -172
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.255",
|
|
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";
|
|
48
49
|
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,8 +126,7 @@ export function FlaggedList({
|
|
|
126
126
|
onTriageContextChange,
|
|
127
127
|
onDeleteMessages,
|
|
128
128
|
}: FlaggedListProps) {
|
|
129
|
-
const { searchQuery, resultFolderIndex
|
|
130
|
-
useMailContext();
|
|
129
|
+
const { searchQuery, resultFolderIndex } = useMailContext();
|
|
131
130
|
const tokenContext = useSearchTokenContext();
|
|
132
131
|
const isDesktop = useIsDesktop();
|
|
133
132
|
const wizard = useSelectionWizard();
|
|
@@ -151,24 +150,10 @@ export function FlaggedList({
|
|
|
151
150
|
setActiveFilters(new Set());
|
|
152
151
|
}, []);
|
|
153
152
|
|
|
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
|
-
|
|
161
153
|
const { freeText: sq, tokens: queryTokens } = parseSearchTokens(
|
|
162
154
|
searchQuery.trim().toLowerCase(),
|
|
163
155
|
tokenContext,
|
|
164
156
|
);
|
|
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;
|
|
172
157
|
|
|
173
158
|
// The chips and the tokens together, as query parameters: a category typed as
|
|
174
159
|
// `category:personal` narrows the request exactly as the chip does.
|
|
@@ -253,19 +238,15 @@ export function FlaggedList({
|
|
|
253
238
|
// the request. Every chip and every carried token is a column on the row, so
|
|
254
239
|
// the server answered over the whole collection; a residual token, or the
|
|
255
240
|
// snippet half of a free-text search, only ever saw the pages loaded so far.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
tokens: narrowingTokens,
|
|
266
|
-
reach: residualTokens.length > 0 || sq ? "loaded-pages" : "whole-folder",
|
|
267
|
-
onClear: clearNarrowing,
|
|
268
|
-
});
|
|
241
|
+
const filterLabel = categoryLabel(selectedCategory);
|
|
242
|
+
const listFilter: MessageListFilter | undefined = filterLabel
|
|
243
|
+
? {
|
|
244
|
+
label: filterLabel,
|
|
245
|
+
reach:
|
|
246
|
+
residualTokens.length > 0 || sq ? "loaded-pages" : "whole-folder",
|
|
247
|
+
onClear: clearFilters,
|
|
248
|
+
}
|
|
249
|
+
: undefined;
|
|
269
250
|
|
|
270
251
|
const listState = isLoading
|
|
271
252
|
? "loading"
|
|
@@ -346,9 +327,7 @@ export function FlaggedList({
|
|
|
346
327
|
// Flagged spans accounts and folders, so it is a collection and
|
|
347
328
|
// never "this mailbox". It names itself.
|
|
348
329
|
listScopeLabel="Starred"
|
|
349
|
-
|
|
350
|
-
// names them, and a headline saying one narrowing twice reads as two.
|
|
351
|
-
searchQuery={typedFreeText || undefined}
|
|
330
|
+
searchQuery={sq ? searchQuery : undefined}
|
|
352
331
|
errorMessage={isError ? formatErrorMessage(error) : undefined}
|
|
353
332
|
onRetry={() => refetch()}
|
|
354
333
|
onReportError={handleReportError}
|
|
@@ -100,6 +100,7 @@ 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,
|
|
103
104
|
filterReach,
|
|
104
105
|
hasInboxFilter,
|
|
105
106
|
type InboxFilterCriteria,
|
|
@@ -108,7 +109,6 @@ import {
|
|
|
108
109
|
sameInboxFilter,
|
|
109
110
|
} from "@/lib/inbox-filters";
|
|
110
111
|
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,17 +178,11 @@ interface MailboxPaneContextValue {
|
|
|
178
178
|
onToggleFilterAttribute: (id: string) => void;
|
|
179
179
|
onClearFilters: () => void;
|
|
180
180
|
/**
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
181
|
+
* The active category filter as the empty state renders it — its label, the
|
|
182
|
+
* way out of it, and how much of the mailbox the request reached. Undefined
|
|
183
|
+
* when no category is selected.
|
|
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;
|
|
192
186
|
onToggleIntelligence: () => void;
|
|
193
187
|
/**
|
|
194
188
|
* Where the mounted reading surface publishes the intelligence commands the
|
|
@@ -283,8 +277,7 @@ function MailboxPaneProvider({
|
|
|
283
277
|
const threadId = thread?.threadId;
|
|
284
278
|
const pointedAtMessageId = thread?.messageId;
|
|
285
279
|
const telemetry = useTelemetry();
|
|
286
|
-
const { accounts, searchQuery, onToggleIntelligence
|
|
287
|
-
useMailContext();
|
|
280
|
+
const { accounts, searchQuery, onToggleIntelligence } = useMailContext();
|
|
288
281
|
const tokenContext = useSearchTokenContext();
|
|
289
282
|
|
|
290
283
|
const normalizedSearchQuery = normalizeSearchQuery(searchQuery);
|
|
@@ -302,13 +295,6 @@ function MailboxPaneProvider({
|
|
|
302
295
|
normalizedSearchQuery,
|
|
303
296
|
tokenContext,
|
|
304
297
|
);
|
|
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;
|
|
312
298
|
|
|
313
299
|
const [filterCategory, setFilterCategory] = useState("all");
|
|
314
300
|
const [filterAttributes, setFilterAttributes] = useState<ReadonlySet<string>>(
|
|
@@ -459,25 +445,20 @@ function MailboxPaneProvider({
|
|
|
459
445
|
setFilterAttributes(new Set());
|
|
460
446
|
}, []);
|
|
461
447
|
|
|
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
|
-
|
|
469
448
|
// The empty state has to say how much was read, and the reach comes off the
|
|
470
449
|
// request rather than the call site: the day a chip is answered over a window
|
|
471
450
|
// instead of the whole mailbox, the sentence changes with it.
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
451
|
+
const filterLabel = categoryLabel(filterCategory);
|
|
452
|
+
const listFilter: MessageListFilter | undefined = filterLabel
|
|
453
|
+
? {
|
|
454
|
+
label: filterLabel,
|
|
455
|
+
reach:
|
|
456
|
+
residualTokens.length > 0
|
|
457
|
+
? "loaded-pages"
|
|
458
|
+
: filterReach(searchThreadsQuery),
|
|
459
|
+
onClear: onClearFilters,
|
|
460
|
+
}
|
|
461
|
+
: undefined;
|
|
481
462
|
|
|
482
463
|
// The row this folder itself lists, preferred because a mutation patches it in
|
|
483
464
|
// place. A thread the loaded pages do not hold — a chip that paged it out, a
|
|
@@ -818,7 +799,6 @@ function MailboxPaneProvider({
|
|
|
818
799
|
onToggleFilterAttribute,
|
|
819
800
|
onClearFilters,
|
|
820
801
|
listFilter,
|
|
821
|
-
listSearchText: typedFreeText || undefined,
|
|
822
802
|
onToggleIntelligence,
|
|
823
803
|
intelligenceRef,
|
|
824
804
|
handleDeselectIfRemoved,
|
|
@@ -895,7 +875,6 @@ function MailboxList() {
|
|
|
895
875
|
onToggleFilterAttribute,
|
|
896
876
|
onClearFilters,
|
|
897
877
|
listFilter,
|
|
898
|
-
listSearchText,
|
|
899
878
|
searchPredicate,
|
|
900
879
|
resultCount,
|
|
901
880
|
} = useMailboxPane();
|
|
@@ -1032,7 +1011,6 @@ function MailboxList() {
|
|
|
1032
1011
|
accountId={mailboxAccountId}
|
|
1033
1012
|
listTitle={listTitle}
|
|
1034
1013
|
listFilter={listFilter}
|
|
1035
|
-
listSearchText={listSearchText}
|
|
1036
1014
|
listScopeLabel={listTitle}
|
|
1037
1015
|
hideHeader
|
|
1038
1016
|
onTriageContextChange={onTriageContextChange}
|
|
@@ -142,18 +142,12 @@ interface MessageListProps {
|
|
|
142
142
|
*/
|
|
143
143
|
listMeta?: string;
|
|
144
144
|
/**
|
|
145
|
-
*
|
|
146
|
-
* it is filtered and how much of the collection the request
|
|
147
|
-
* it a narrowed list renders as an empty mailbox, which is
|
|
148
|
-
* bug hid behind.
|
|
145
|
+
* The active category filter, when the view has one. The empty state needs
|
|
146
|
+
* it to say it is filtered and how much of the collection the request
|
|
147
|
+
* reached; without it a narrowed list renders as an empty mailbox, which is
|
|
148
|
+
* the shape #315's 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;
|
|
157
151
|
/** Name of the collection being listed, e.g. "Inbox", for the empty state. */
|
|
158
152
|
listScopeLabel?: string;
|
|
159
153
|
/**
|
|
@@ -239,7 +233,6 @@ export const MessageList = ({
|
|
|
239
233
|
listTitle,
|
|
240
234
|
listMeta,
|
|
241
235
|
listFilter,
|
|
242
|
-
listSearchText,
|
|
243
236
|
listScopeLabel,
|
|
244
237
|
onTriageContextChange,
|
|
245
238
|
commandsRef,
|
|
@@ -1308,7 +1301,7 @@ export const MessageList = ({
|
|
|
1308
1301
|
// The results panel is a body, not a list state: it stands in for the
|
|
1309
1302
|
// rows and must not fall behind a skeleton while the query re-keys.
|
|
1310
1303
|
listState={listHeaderChrome.searchResults ? "ready" : listState}
|
|
1311
|
-
searchQuery={
|
|
1304
|
+
searchQuery={isSearching ? searchQuery : undefined}
|
|
1312
1305
|
listFilter={listFilter}
|
|
1313
1306
|
listScopeLabel={listScopeLabel}
|
|
1314
1307
|
errorMessage={errorMessage}
|
|
@@ -1,240 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,88 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,172 +0,0 @@
|
|
|
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
|
-
};
|