@remit/ui 0.0.13 → 0.0.15
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/button.render.test.ts +29 -0
- package/src/components/button.tsx +5 -1
- package/src/components/folder-role.test.ts +72 -0
- package/src/components/folder-role.tsx +59 -0
- package/src/components/mobile-search-view.render.test.ts +78 -0
- package/src/components/mobile-search-view.stories.tsx +117 -2
- package/src/components/mobile-search-view.tsx +20 -1
- package/src/components/primitives.stories.tsx +6 -0
- package/src/components/progress-bar.render.test.ts +46 -0
- package/src/components/progress-bar.stories.tsx +35 -0
- package/src/components/progress-bar.tsx +62 -0
- package/src/components/search-result-row.tsx +23 -0
- package/src/components/search-results.render.test.ts +181 -0
- package/src/components/search-results.stories.tsx +165 -28
- package/src/components/search-results.tsx +101 -5
- package/src/components/selection-top-bar.render.test.ts +167 -17
- package/src/components/selection-top-bar.stories.tsx +106 -16
- package/src/components/selection-top-bar.tsx +117 -64
- package/src/components/spam-results-offer.render.test.ts +26 -0
- package/src/components/spam-results-offer.stories.tsx +41 -0
- package/src/components/spam-results-offer.tsx +52 -0
- package/src/components/swipeable-row.render.test.ts +29 -0
- package/src/components/swipeable-row.tsx +42 -2
- package/src/components/touch-list-body.render.test.ts +25 -0
- package/src/components/touch-list-body.stories.tsx +15 -0
- package/src/components/touch-list.tsx +26 -15
- package/src/index.ts +15 -0
|
@@ -2,6 +2,7 @@ import { Flag } from "lucide-react";
|
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
import { cn } from "../lib/cn.js";
|
|
4
4
|
import { Badge } from "./badge.js";
|
|
5
|
+
import { provenanceFolderLabel, type ResultFolder } from "./folder-role.js";
|
|
5
6
|
|
|
6
7
|
export type SearchResultTone =
|
|
7
8
|
| "neutral"
|
|
@@ -27,6 +28,12 @@ export interface SearchResult {
|
|
|
27
28
|
threadId?: string;
|
|
28
29
|
/** The mailbox the result lives in; paired with {@link threadId} to open it. */
|
|
29
30
|
mailboxId?: string;
|
|
31
|
+
/**
|
|
32
|
+
* The folder this row was read from. A search that reaches every folder
|
|
33
|
+
* returns rows from all over, so the row says where it came from; see
|
|
34
|
+
* {@link provenanceFolderLabel} for which folders can be named.
|
|
35
|
+
*/
|
|
36
|
+
folder?: ResultFolder;
|
|
30
37
|
/**
|
|
31
38
|
* Why a semantic ("Related") hit matched — a plain-language label derived
|
|
32
39
|
* from `matchedChunkType` (e.g. "body", "subject", "attachment"), so the user
|
|
@@ -43,6 +50,12 @@ export interface SearchResultRowProps {
|
|
|
43
50
|
onClick?: () => void;
|
|
44
51
|
/** When given, literal (case-insensitive) matches are bolded in subject/snippet. */
|
|
45
52
|
query?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Show the folder the row came from. Defaults to true. A search confined to
|
|
55
|
+
* one folder turns it off — every row would carry the same label, which is
|
|
56
|
+
* noise rather than provenance.
|
|
57
|
+
*/
|
|
58
|
+
showFolder?: boolean;
|
|
46
59
|
}
|
|
47
60
|
|
|
48
61
|
function highlight(text: string, query?: string): ReactNode {
|
|
@@ -79,7 +92,12 @@ export function SearchResultRow({
|
|
|
79
92
|
result,
|
|
80
93
|
onClick,
|
|
81
94
|
query,
|
|
95
|
+
showFolder = true,
|
|
82
96
|
}: SearchResultRowProps) {
|
|
97
|
+
const folderLabel =
|
|
98
|
+
showFolder && result.folder
|
|
99
|
+
? provenanceFolderLabel(result.folder)
|
|
100
|
+
: undefined;
|
|
83
101
|
return (
|
|
84
102
|
<button
|
|
85
103
|
type="button"
|
|
@@ -118,6 +136,11 @@ export function SearchResultRow({
|
|
|
118
136
|
<span className="min-w-0 flex-1 truncate text-xs text-fg-subtle">
|
|
119
137
|
{highlight(result.snippet, query)}
|
|
120
138
|
</span>
|
|
139
|
+
{folderLabel && (
|
|
140
|
+
<Badge tone="neutral" className="shrink-0">
|
|
141
|
+
{folderLabel}
|
|
142
|
+
</Badge>
|
|
143
|
+
)}
|
|
121
144
|
{result.category && (
|
|
122
145
|
<Badge tone={result.category.tone ?? "neutral"} className="shrink-0">
|
|
123
146
|
{result.category.label}
|
|
@@ -87,3 +87,184 @@ describe("SearchResults", () => {
|
|
|
87
87
|
assert.doesNotMatch(html, /Remove filter/);
|
|
88
88
|
});
|
|
89
89
|
});
|
|
90
|
+
|
|
91
|
+
const spamResult: SearchResult = {
|
|
92
|
+
id: "s1",
|
|
93
|
+
sender: "billing@unknown-vendor.test",
|
|
94
|
+
subject: "URGENT invoice attached",
|
|
95
|
+
snippet: "Wire the amount below.",
|
|
96
|
+
date: "Feb 11",
|
|
97
|
+
folder: { role: "junk" },
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const archivedResult: SearchResult = {
|
|
101
|
+
...result,
|
|
102
|
+
id: "a1",
|
|
103
|
+
sender: "Mollie",
|
|
104
|
+
folder: { role: "archive" },
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const mixed: SearchResultSection[] = [
|
|
108
|
+
{ id: "results", label: "Results", results: [archivedResult, spamResult] },
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
describe("SearchResults spam handling", () => {
|
|
112
|
+
it("holds spam out of a global search and offers it as a count", () => {
|
|
113
|
+
const html = renderToString(
|
|
114
|
+
createElement(SearchResults, {
|
|
115
|
+
value: "invoice",
|
|
116
|
+
sections: mixed,
|
|
117
|
+
scope: { kind: "global" },
|
|
118
|
+
onScopeToSpam: noop,
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
assert.doesNotMatch(html, /unknown-vendor/);
|
|
122
|
+
assert.match(html, /result from Spam/);
|
|
123
|
+
assert.match(html, /Mollie/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("makes no offer when a global search found nothing in spam", () => {
|
|
127
|
+
const html = renderToString(
|
|
128
|
+
createElement(SearchResults, {
|
|
129
|
+
value: "invoice",
|
|
130
|
+
sections: [
|
|
131
|
+
{ id: "results", label: "Results", results: [archivedResult] },
|
|
132
|
+
],
|
|
133
|
+
scope: { kind: "global" },
|
|
134
|
+
onScopeToSpam: noop,
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
assert.doesNotMatch(html, /from Spam/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("offers spam above the empty state when every match is spam", () => {
|
|
141
|
+
const html = renderToString(
|
|
142
|
+
createElement(SearchResults, {
|
|
143
|
+
value: "invoice",
|
|
144
|
+
sections: [{ id: "results", label: "Results", results: [spamResult] }],
|
|
145
|
+
scope: { kind: "global" },
|
|
146
|
+
onScopeToSpam: noop,
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
assert.match(html, /No matches for/);
|
|
150
|
+
assert.match(html, /result from Spam/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("shows neither spam rows nor an offer when scoped elsewhere", () => {
|
|
154
|
+
const html = renderToString(
|
|
155
|
+
createElement(SearchResults, {
|
|
156
|
+
value: "invoice",
|
|
157
|
+
sections: mixed,
|
|
158
|
+
scope: { kind: "folder", role: "inbox" },
|
|
159
|
+
onScopeToSpam: noop,
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
assert.doesNotMatch(html, /unknown-vendor/);
|
|
163
|
+
assert.doesNotMatch(html, /from Spam/);
|
|
164
|
+
assert.match(html, /Mollie/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("renders spam rows normally and makes no offer when scoped to spam", () => {
|
|
168
|
+
const html = renderToString(
|
|
169
|
+
createElement(SearchResults, {
|
|
170
|
+
value: "invoice",
|
|
171
|
+
sections: mixed,
|
|
172
|
+
scope: { kind: "folder", role: "junk" },
|
|
173
|
+
onScopeToSpam: noop,
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
assert.match(html, /unknown-vendor/);
|
|
177
|
+
assert.doesNotMatch(html, /from Spam/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("makes no offer without a way to scope to spam", () => {
|
|
181
|
+
const html = renderToString(
|
|
182
|
+
createElement(SearchResults, {
|
|
183
|
+
value: "invoice",
|
|
184
|
+
sections: mixed,
|
|
185
|
+
scope: { kind: "global" },
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
assert.doesNotMatch(html, /from Spam/);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("prefers a caller-supplied total over the rows held out of this page", () => {
|
|
192
|
+
const html = renderToString(
|
|
193
|
+
createElement(SearchResults, {
|
|
194
|
+
value: "invoice",
|
|
195
|
+
sections: mixed,
|
|
196
|
+
scope: { kind: "global" },
|
|
197
|
+
spamMatchCount: 42,
|
|
198
|
+
onScopeToSpam: noop,
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
assert.match(html, />42</);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("SearchResults provenance labels", () => {
|
|
206
|
+
it("names the folder each row came from in a global search", () => {
|
|
207
|
+
const html = renderToString(
|
|
208
|
+
createElement(SearchResults, {
|
|
209
|
+
value: "invoice",
|
|
210
|
+
sections: [
|
|
211
|
+
{
|
|
212
|
+
id: "results",
|
|
213
|
+
label: "Results",
|
|
214
|
+
results: [
|
|
215
|
+
archivedResult,
|
|
216
|
+
{
|
|
217
|
+
...result,
|
|
218
|
+
id: "c1",
|
|
219
|
+
folder: { providerPath: "Projects/Books" },
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
scope: { kind: "global" },
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
assert.match(html, /Archive/);
|
|
228
|
+
assert.match(html, /Books/);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("drops the labels when the search is scoped to one folder", () => {
|
|
232
|
+
const html = renderToString(
|
|
233
|
+
createElement(SearchResults, {
|
|
234
|
+
value: "invoice",
|
|
235
|
+
sections: [
|
|
236
|
+
{ id: "results", label: "Results", results: [archivedResult] },
|
|
237
|
+
],
|
|
238
|
+
scope: { kind: "folder", role: "archive" },
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
241
|
+
assert.doesNotMatch(html, /Archive/);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("leaves a row from a view rather than a folder unlabelled", () => {
|
|
245
|
+
const html = renderToString(
|
|
246
|
+
createElement(SearchResults, {
|
|
247
|
+
value: "invoice",
|
|
248
|
+
sections: [
|
|
249
|
+
{
|
|
250
|
+
id: "results",
|
|
251
|
+
label: "Results",
|
|
252
|
+
results: [
|
|
253
|
+
{ ...result, id: "v1", folder: { role: "all" } },
|
|
254
|
+
{ ...result, id: "v2", folder: { role: "flagged" } },
|
|
255
|
+
{
|
|
256
|
+
...result,
|
|
257
|
+
id: "v3",
|
|
258
|
+
folder: { providerPath: "[Gmail]/Important" },
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
scope: { kind: "global" },
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
assert.doesNotMatch(html, /All Mail/);
|
|
267
|
+
assert.doesNotMatch(html, /Starred/);
|
|
268
|
+
assert.doesNotMatch(html, /Important/);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
@@ -73,43 +73,71 @@ const resultSections: SearchResultSection[] = [
|
|
|
73
73
|
];
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
|
-
* Matches from outside the inbox — Archive, Sent
|
|
77
|
-
*
|
|
78
|
-
*
|
|
76
|
+
* Matches from outside the inbox — Archive, Sent and a custom folder — each
|
|
77
|
+
* carrying the folder it was read from. These are the rows a search reaching
|
|
78
|
+
* every folder returns that an INBOX-only one could not.
|
|
79
79
|
*/
|
|
80
80
|
const crossFolderMatches: SearchResult[] = [
|
|
81
81
|
{
|
|
82
82
|
id: "x1",
|
|
83
83
|
sender: "Mollie",
|
|
84
84
|
subject: "Invoice 2026-02 — archived",
|
|
85
|
-
snippet: "Filed
|
|
85
|
+
snippet: "Filed last month; payment already settled.",
|
|
86
86
|
date: "Feb 24",
|
|
87
|
+
folder: { role: "archive" },
|
|
87
88
|
category: { label: "Receipt", tone: "positive" },
|
|
88
89
|
},
|
|
89
90
|
{
|
|
90
91
|
id: "x2",
|
|
91
92
|
sender: "me",
|
|
92
93
|
subject: "Re: invoice query",
|
|
93
|
-
snippet: "
|
|
94
|
+
snippet: "Attaching the invoice you asked for.",
|
|
94
95
|
date: "Feb 18",
|
|
96
|
+
folder: { role: "sent" },
|
|
95
97
|
},
|
|
96
98
|
{
|
|
97
|
-
id: "
|
|
99
|
+
id: "x4",
|
|
100
|
+
sender: "Accountant",
|
|
101
|
+
subject: "Invoices for the quarter",
|
|
102
|
+
snippet: "The quarterly set, filed with the rest of the bookkeeping.",
|
|
103
|
+
date: "Jan 30",
|
|
104
|
+
folder: { providerPath: "Projects/Bookkeeping" },
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Matches that live in the account's `\Junk` folder. A global search holds
|
|
110
|
+
* these out of the sections entirely and offers them as a count instead.
|
|
111
|
+
*/
|
|
112
|
+
const spamMatches: SearchResult[] = [
|
|
113
|
+
{
|
|
114
|
+
id: "s1",
|
|
98
115
|
sender: "billing@unknown-vendor.test",
|
|
99
116
|
subject: "URGENT invoice attached",
|
|
100
|
-
snippet: "
|
|
117
|
+
snippet: "Wire the amount below within 24 hours to avoid suspension.",
|
|
101
118
|
date: "Feb 11",
|
|
102
|
-
|
|
119
|
+
folder: { role: "junk" },
|
|
103
120
|
},
|
|
104
121
|
{
|
|
105
|
-
id: "
|
|
106
|
-
sender: "
|
|
107
|
-
subject: "
|
|
108
|
-
snippet: "
|
|
109
|
-
date: "
|
|
122
|
+
id: "s2",
|
|
123
|
+
sender: "invoices@pay-now.test",
|
|
124
|
+
subject: "Outstanding invoice — final notice",
|
|
125
|
+
snippet: "Your account is overdue. Settle immediately.",
|
|
126
|
+
date: "Feb 4",
|
|
127
|
+
folder: { role: "junk" },
|
|
110
128
|
},
|
|
111
129
|
];
|
|
112
130
|
|
|
131
|
+
/** The literal section a global search returns: inbox rows plus everywhere else. */
|
|
132
|
+
const globalTopMatches: SearchResult[] = [
|
|
133
|
+
...topMatches.map((result) => ({
|
|
134
|
+
...result,
|
|
135
|
+
folder: { role: "inbox" as const },
|
|
136
|
+
})),
|
|
137
|
+
...crossFolderMatches,
|
|
138
|
+
...spamMatches,
|
|
139
|
+
];
|
|
140
|
+
|
|
113
141
|
const emptySections: SearchResultSection[] = [
|
|
114
142
|
{ id: "top", label: "Top matches", results: [] },
|
|
115
143
|
];
|
|
@@ -151,23 +179,45 @@ export const Idle: Story = {
|
|
|
151
179
|
|
|
152
180
|
/**
|
|
153
181
|
* The daily brief's unscoped search: no scope chip in the bar, and the literal
|
|
154
|
-
* section carries matches from every folder — Archive, Sent
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
* than the bar promised.
|
|
182
|
+
* section carries matches from every folder — Archive, Sent and custom folders
|
|
183
|
+
* alongside the inbox. Each row says which folder it came from, because with
|
|
184
|
+
* nothing scoping the search the folder is the only thing placing the result.
|
|
158
185
|
*
|
|
159
|
-
* The
|
|
160
|
-
*
|
|
186
|
+
* The two spam matches in the same data are not in this list. They are held out
|
|
187
|
+
* and offered above it as a count.
|
|
161
188
|
*/
|
|
162
|
-
export const
|
|
189
|
+
export const GlobalAcrossFolders: Story = {
|
|
163
190
|
render: () => (
|
|
164
191
|
<Harness
|
|
165
192
|
value="invoice"
|
|
193
|
+
scope={{ kind: "global" }}
|
|
194
|
+
onScopeToSpam={() => {}}
|
|
195
|
+
sections={[
|
|
196
|
+
{ id: "top", label: "Top matches", results: globalTopMatches },
|
|
197
|
+
{ id: "related", label: "Related", results: related },
|
|
198
|
+
]}
|
|
199
|
+
/>
|
|
200
|
+
),
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The same global search over an account whose Spam folder holds nothing
|
|
205
|
+
* matching. No spam rows to hold out, so no offer — the offer only ever appears
|
|
206
|
+
* because there is something behind it.
|
|
207
|
+
*/
|
|
208
|
+
export const GlobalWithoutSpamMatches: Story = {
|
|
209
|
+
render: () => (
|
|
210
|
+
<Harness
|
|
211
|
+
value="invoice"
|
|
212
|
+
scope={{ kind: "global" }}
|
|
213
|
+
onScopeToSpam={() => {}}
|
|
166
214
|
sections={[
|
|
167
215
|
{
|
|
168
216
|
id: "top",
|
|
169
217
|
label: "Top matches",
|
|
170
|
-
results:
|
|
218
|
+
results: globalTopMatches.filter(
|
|
219
|
+
(result) => result.folder?.role !== "junk",
|
|
220
|
+
),
|
|
171
221
|
},
|
|
172
222
|
{ id: "related", label: "Related", results: related },
|
|
173
223
|
]}
|
|
@@ -176,18 +226,105 @@ export const UnscopedAcrossFolders: Story = {
|
|
|
176
226
|
};
|
|
177
227
|
|
|
178
228
|
/**
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
|
|
229
|
+
* An account with no junk folder at all. Nothing is appointed `\Junk`, so no
|
|
230
|
+
* row can be spam and the component behaves exactly as it does when Spam is
|
|
231
|
+
* simply empty — there is no separate case to handle.
|
|
232
|
+
*/
|
|
233
|
+
export const GlobalAccountWithoutSpamFolder: Story = {
|
|
234
|
+
render: () => (
|
|
235
|
+
<Harness
|
|
236
|
+
value="invoice"
|
|
237
|
+
scope={{ kind: "global" }}
|
|
238
|
+
onScopeToSpam={() => {}}
|
|
239
|
+
sections={[
|
|
240
|
+
{ id: "top", label: "Top matches", results: crossFolderMatches },
|
|
241
|
+
]}
|
|
242
|
+
/>
|
|
243
|
+
),
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* A global search whose only matches are in Spam. The sections are empty, so
|
|
248
|
+
* the empty state stands — with the offer above it, which is the whole reason
|
|
249
|
+
* the user is not left thinking the search found nothing.
|
|
250
|
+
*/
|
|
251
|
+
export const GlobalOnlySpamMatches: Story = {
|
|
252
|
+
render: () => (
|
|
253
|
+
<Harness
|
|
254
|
+
value="invoice"
|
|
255
|
+
scope={{ kind: "global" }}
|
|
256
|
+
onScopeToSpam={() => {}}
|
|
257
|
+
sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
|
|
258
|
+
/>
|
|
259
|
+
),
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Scoped to the inbox (its `in:inbox` chip in the bar), given the very same
|
|
264
|
+
* rows as the global story — spam matches included. Nothing about Spam appears:
|
|
265
|
+
* no rows, no count, no offer. A scoped search shows its own scope and no more,
|
|
266
|
+
* and that asymmetry with the global view is deliberate.
|
|
267
|
+
*
|
|
268
|
+
* The rows also drop their folder labels here. Every row is in the scoped
|
|
269
|
+
* folder, so naming it on each one repeats the chip.
|
|
270
|
+
*/
|
|
271
|
+
export const ScopedToInbox: Story = {
|
|
272
|
+
render: () => (
|
|
273
|
+
<Harness
|
|
274
|
+
value="invoice"
|
|
275
|
+
scope={{ kind: "folder", role: "inbox" }}
|
|
276
|
+
onScopeToSpam={() => {}}
|
|
277
|
+
sections={[
|
|
278
|
+
{ id: "top", label: "Top matches", results: globalTopMatches },
|
|
279
|
+
{ id: "related", label: "Related", results: related },
|
|
280
|
+
]}
|
|
281
|
+
/>
|
|
282
|
+
),
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Scoped to Spam — where taking the offer lands. Ordinary rows, rendered
|
|
287
|
+
* normally, and no offer, because the user is already here. This is the same
|
|
288
|
+
* scoped search reached by navigating to Spam with the query carried over; the
|
|
289
|
+
* offer is a shortcut into it, not a mode of its own.
|
|
290
|
+
*/
|
|
291
|
+
export const ScopedToSpam: Story = {
|
|
292
|
+
render: () => (
|
|
293
|
+
<Harness
|
|
294
|
+
value="invoice"
|
|
295
|
+
scope={{ kind: "folder", role: "junk" }}
|
|
296
|
+
onScopeToSpam={() => {}}
|
|
297
|
+
sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
|
|
298
|
+
/>
|
|
299
|
+
),
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* A folder a search result can be in but never labelled with. All Mail and
|
|
304
|
+
* Starred are views over mail filed elsewhere, and Gmail exposes them as
|
|
305
|
+
* ordinary folders, so a row read from one carries no provenance label rather
|
|
306
|
+
* than a misleading one. The other rows keep theirs.
|
|
183
307
|
*/
|
|
184
|
-
export const
|
|
308
|
+
export const VirtualFoldersGoUnlabelled: Story = {
|
|
185
309
|
render: () => (
|
|
186
310
|
<Harness
|
|
187
311
|
value="invoice"
|
|
312
|
+
scope={{ kind: "global" }}
|
|
188
313
|
sections={[
|
|
189
|
-
{
|
|
190
|
-
|
|
314
|
+
{
|
|
315
|
+
id: "top",
|
|
316
|
+
label: "Top matches",
|
|
317
|
+
results: [
|
|
318
|
+
{ ...topMatches[0], id: "v1", folder: { role: "all" } },
|
|
319
|
+
{ ...topMatches[1], id: "v2", folder: { role: "flagged" } },
|
|
320
|
+
{
|
|
321
|
+
...topMatches[2],
|
|
322
|
+
id: "v3",
|
|
323
|
+
folder: { providerPath: "[Gmail]/Important" },
|
|
324
|
+
},
|
|
325
|
+
...crossFolderMatches.slice(0, 1),
|
|
326
|
+
],
|
|
327
|
+
},
|
|
191
328
|
]}
|
|
192
329
|
/>
|
|
193
330
|
),
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { ChevronDown, Clock } from "lucide-react";
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import { cn } from "../lib/cn.js";
|
|
4
|
+
import type { FolderRole } from "./folder-role.js";
|
|
4
5
|
import { type SearchResult, SearchResultRow } from "./search-result-row.js";
|
|
5
6
|
import { SearchTokenChips } from "./search-token-chip.js";
|
|
7
|
+
import { SpamResultsOffer } from "./spam-results-offer.js";
|
|
6
8
|
|
|
7
9
|
/** Rows shown before the "Show N more" expander kicks in. */
|
|
8
10
|
const SECTION_ROW_CAP = 6;
|
|
@@ -15,6 +17,44 @@ export interface SearchResultSection {
|
|
|
15
17
|
initialCollapsed?: boolean;
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
/**
|
|
21
|
+
* What the search currently covers. `global` is the unscoped search the daily
|
|
22
|
+
* brief runs — every account, every folder, no chip in the bar. `folder` is a
|
|
23
|
+
* search the sidebar narrowed to one place, which the bar shows as a chip.
|
|
24
|
+
*/
|
|
25
|
+
export type SearchScope =
|
|
26
|
+
| { kind: "global" }
|
|
27
|
+
| { kind: "folder"; role?: FolderRole };
|
|
28
|
+
|
|
29
|
+
const GLOBAL_SCOPE: SearchScope = { kind: "global" };
|
|
30
|
+
|
|
31
|
+
const isSpamScope = (scope: SearchScope): boolean =>
|
|
32
|
+
scope.kind === "folder" && scope.role === "junk";
|
|
33
|
+
|
|
34
|
+
const isSpamResult = (result: SearchResult): boolean =>
|
|
35
|
+
result.folder?.role === "junk";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Split spam matches out of the rows a search returned.
|
|
39
|
+
*
|
|
40
|
+
* Spam is identified by the account's `\Junk` special-use appointment, never by
|
|
41
|
+
* folder name, so an account that calls it `Junk`, `Bulk Mail` or nothing at
|
|
42
|
+
* all behaves the same, and an account with no junk folder simply never yields
|
|
43
|
+
* a spam row.
|
|
44
|
+
*/
|
|
45
|
+
export function partitionSpamResults(results: SearchResult[]): {
|
|
46
|
+
kept: SearchResult[];
|
|
47
|
+
spam: SearchResult[];
|
|
48
|
+
} {
|
|
49
|
+
const kept: SearchResult[] = [];
|
|
50
|
+
const spam: SearchResult[] = [];
|
|
51
|
+
for (const result of results) {
|
|
52
|
+
if (isSpamResult(result)) spam.push(result);
|
|
53
|
+
else kept.push(result);
|
|
54
|
+
}
|
|
55
|
+
return { kept, spam };
|
|
56
|
+
}
|
|
57
|
+
|
|
18
58
|
export interface SearchResultsProps {
|
|
19
59
|
/** The current query — narrows what's shown and bolds literal matches. */
|
|
20
60
|
value: string;
|
|
@@ -32,6 +72,19 @@ export interface SearchResultsProps {
|
|
|
32
72
|
* query has no recognized tokens.
|
|
33
73
|
*/
|
|
34
74
|
tokens?: { label: string; onRemove: () => void }[];
|
|
75
|
+
/** What the search covers. Defaults to the unscoped, global search. */
|
|
76
|
+
scope?: SearchScope;
|
|
77
|
+
/**
|
|
78
|
+
* Total spam matches the search found, for when the app knows more than the
|
|
79
|
+
* rows on this page. Defaults to the number of spam rows held out of
|
|
80
|
+
* {@link sections}.
|
|
81
|
+
*/
|
|
82
|
+
spamMatchCount?: number;
|
|
83
|
+
/**
|
|
84
|
+
* Scope the search to Spam. Without it there is no offer, so an app that has
|
|
85
|
+
* nowhere to navigate simply never makes one.
|
|
86
|
+
*/
|
|
87
|
+
onScopeToSpam?: () => void;
|
|
35
88
|
}
|
|
36
89
|
|
|
37
90
|
/**
|
|
@@ -44,10 +97,12 @@ function CollapsibleResultSection({
|
|
|
44
97
|
section,
|
|
45
98
|
query,
|
|
46
99
|
onSelectResult,
|
|
100
|
+
showFolder,
|
|
47
101
|
}: {
|
|
48
102
|
section: SearchResultSection;
|
|
49
103
|
query?: string;
|
|
50
104
|
onSelectResult?: (result: SearchResult) => void;
|
|
105
|
+
showFolder?: boolean;
|
|
51
106
|
}) {
|
|
52
107
|
const [collapsed, setCollapsed] = useState(section.initialCollapsed ?? false);
|
|
53
108
|
const [expanded, setExpanded] = useState(false);
|
|
@@ -87,6 +142,7 @@ function CollapsibleResultSection({
|
|
|
87
142
|
key={result.id}
|
|
88
143
|
result={result}
|
|
89
144
|
query={query}
|
|
145
|
+
showFolder={showFolder}
|
|
90
146
|
onClick={
|
|
91
147
|
onSelectResult ? () => onSelectResult(result) : undefined
|
|
92
148
|
}
|
|
@@ -115,6 +171,18 @@ function CollapsibleResultSection({
|
|
|
115
171
|
* sections — one implementation so both tiers render identical rows. The caller
|
|
116
172
|
* owns the surrounding chrome and scroll container (a `FilterSheet`, the
|
|
117
173
|
* takeover header, or the list-pane body). Presentational and prop-driven.
|
|
174
|
+
*
|
|
175
|
+
* Spam is the one folder that does not inline, and it behaves differently by
|
|
176
|
+
* scope:
|
|
177
|
+
*
|
|
178
|
+
* - **Global** — spam rows are held out of the sections and offered instead, as
|
|
179
|
+
* a count with a way into a Spam-scoped search.
|
|
180
|
+
* - **Scoped to anything else** — spam rows are held out and nothing is offered.
|
|
181
|
+
* A scoped search shows its own scope and no more.
|
|
182
|
+
* - **Scoped to Spam** — ordinary rows, rendered normally, no offer.
|
|
183
|
+
*
|
|
184
|
+
* Provenance labels follow the same logic: only a global search names the
|
|
185
|
+
* folder each row came from, because a scoped one would repeat itself.
|
|
118
186
|
*/
|
|
119
187
|
export function SearchResults({
|
|
120
188
|
value,
|
|
@@ -124,11 +192,11 @@ export function SearchResults({
|
|
|
124
192
|
loading,
|
|
125
193
|
onSelectResult,
|
|
126
194
|
tokens,
|
|
195
|
+
scope = GLOBAL_SCOPE,
|
|
196
|
+
spamMatchCount,
|
|
197
|
+
onScopeToSpam,
|
|
127
198
|
}: SearchResultsProps) {
|
|
128
199
|
const hasQuery = value.trim().length > 0;
|
|
129
|
-
const hasResults = (sections ?? []).some(
|
|
130
|
-
(section) => section.results.length > 0,
|
|
131
|
-
);
|
|
132
200
|
|
|
133
201
|
if (!hasQuery) {
|
|
134
202
|
if (recentSearches && recentSearches.length > 0) {
|
|
@@ -181,10 +249,36 @@ export function SearchResults({
|
|
|
181
249
|
);
|
|
182
250
|
}
|
|
183
251
|
|
|
252
|
+
const spamScoped = isSpamScope(scope);
|
|
253
|
+
const isGlobal = scope.kind === "global";
|
|
254
|
+
|
|
255
|
+
const partitioned = (sections ?? []).map((section) => {
|
|
256
|
+
if (spamScoped) return { section, spam: [] as SearchResult[] };
|
|
257
|
+
const { kept, spam } = partitionSpamResults(section.results);
|
|
258
|
+
return { section: { ...section, results: kept }, spam };
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const visibleSections = partitioned.map((entry) => entry.section);
|
|
262
|
+
const heldOutSpamCount = partitioned.reduce(
|
|
263
|
+
(total, entry) => total + entry.spam.length,
|
|
264
|
+
0,
|
|
265
|
+
);
|
|
266
|
+
const spamCount = spamMatchCount ?? heldOutSpamCount;
|
|
267
|
+
const spamOffer = isGlobal &&
|
|
268
|
+
spamCount > 0 &&
|
|
269
|
+
onScopeToSpam !== undefined && (
|
|
270
|
+
<SpamResultsOffer count={spamCount} onScopeToSpam={onScopeToSpam} />
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const hasResults = visibleSections.some(
|
|
274
|
+
(section) => section.results.length > 0,
|
|
275
|
+
);
|
|
276
|
+
|
|
184
277
|
if (!hasResults) {
|
|
185
278
|
return (
|
|
186
279
|
<div className="flex flex-col">
|
|
187
280
|
{chips}
|
|
281
|
+
{spamOffer}
|
|
188
282
|
<div className="px-row-inset py-10 text-center">
|
|
189
283
|
<p className="text-sm font-medium text-fg">
|
|
190
284
|
No matches for “{value}”
|
|
@@ -200,13 +294,15 @@ export function SearchResults({
|
|
|
200
294
|
return (
|
|
201
295
|
<div className="flex flex-col">
|
|
202
296
|
{chips}
|
|
203
|
-
{
|
|
204
|
-
|
|
297
|
+
{spamOffer}
|
|
298
|
+
{visibleSections
|
|
299
|
+
.filter((section) => section.results.length > 0)
|
|
205
300
|
.map((section) => (
|
|
206
301
|
<CollapsibleResultSection
|
|
207
302
|
key={section.id}
|
|
208
303
|
section={section}
|
|
209
304
|
query={value}
|
|
305
|
+
showFolder={isGlobal}
|
|
210
306
|
onSelectResult={onSelectResult}
|
|
211
307
|
/>
|
|
212
308
|
))}
|