@remit/web-client 0.0.267 → 0.0.269
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/MailListHeader.chrome-stability.render.test.ts +323 -0
- package/src/components/mail/MailListHeader.tsx +214 -133
- package/src/components/mail/SelectionWizardHost.tsx +2 -2
- package/src/components/ui/placement-refusal.test.ts +33 -2
- package/src/components/ui/placement-refusal.ts +14 -6
- package/src/hooks/refusal-escalation.render.test.ts +56 -0
- package/src/hooks/useDeleteMessages.ts +1 -1
- package/src/hooks/useMoveMessages.ts +16 -0
- package/src/hooks/useSearchScope.ts +5 -2
- package/src/lib/list-header-chrome.ts +5 -10
- package/src/lib/search-conversion.ts +23 -0
- package/src/routing/browsed-list.ts +5 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.269",
|
|
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": {
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chrome the list header hands its body is one object, and the body is a
|
|
3
|
+
* virtualized list that re-renders every row when that object changes (#506).
|
|
4
|
+
*
|
|
5
|
+
* What is typed, where the caret is, which suggestion is highlighted and what
|
|
6
|
+
* the query converts to belong to the field and the wizard, not to the list.
|
|
7
|
+
* None of it changes anything the header shows, so the body must be handed back
|
|
8
|
+
* the object it already has.
|
|
9
|
+
*
|
|
10
|
+
* Driven through the real field and real keystrokes, in the real header, so
|
|
11
|
+
* every assertion is about the object the body receives rather than the shape of
|
|
12
|
+
* any one memo that fed it — `use-suggest-list.test.ts` is what pins the
|
|
13
|
+
* combobox props the field itself reads.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { afterEach, describe, it } from "node:test";
|
|
18
|
+
import {
|
|
19
|
+
type AnyRouter,
|
|
20
|
+
createMemoryHistory,
|
|
21
|
+
createRootRoute,
|
|
22
|
+
createRoute,
|
|
23
|
+
createRouter,
|
|
24
|
+
Outlet,
|
|
25
|
+
RouterProvider,
|
|
26
|
+
} from "@tanstack/react-router";
|
|
27
|
+
import { createElement, Profiler, useCallback, useMemo, useState } from "react";
|
|
28
|
+
import {
|
|
29
|
+
type ListHeaderChrome,
|
|
30
|
+
useListHeaderChrome,
|
|
31
|
+
} from "@/lib/list-header-chrome";
|
|
32
|
+
import { MailContext, type MailContextValue } from "@/lib/mail-context";
|
|
33
|
+
import { EMPTY_RESULT_FOLDER_INDEX } from "@/lib/result-folder";
|
|
34
|
+
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
35
|
+
import { type HttpMock, mockFetch } from "@/test-support/http";
|
|
36
|
+
import { MailListHeader } from "./MailListHeader";
|
|
37
|
+
|
|
38
|
+
const MAILBOX_ID = "11111111-1111-4111-8111-111111111111";
|
|
39
|
+
/** Tablet: the tier whose list header owns the field, rather than the top bar. */
|
|
40
|
+
const TABLET_WIDTH = 900;
|
|
41
|
+
/** A bare word, so the offer is token names and needs no lookup. */
|
|
42
|
+
const TYPED = "fro";
|
|
43
|
+
/** Still a bare word, so the query converts to the same clause and copy. */
|
|
44
|
+
const TYPED_NEXT = `${TYPED}g`;
|
|
45
|
+
|
|
46
|
+
let harness: DomHarness | undefined;
|
|
47
|
+
let http: HttpMock | undefined;
|
|
48
|
+
let chromes: ListHeaderChrome[] = [];
|
|
49
|
+
/** Commits React made in the header's subtree, so "it held" cannot mean "nothing ran". */
|
|
50
|
+
let commits = 0;
|
|
51
|
+
/** Which body the mounted view has: its own rows, or the read-only results panel. */
|
|
52
|
+
let resultsInBody = true;
|
|
53
|
+
/** The address the view mounts on; a `q` on it is a query already committed. */
|
|
54
|
+
let initialPath = `/mail/${MAILBOX_ID}`;
|
|
55
|
+
/**
|
|
56
|
+
* The query the shell has committed, as the /mail shell would hand it down: the
|
|
57
|
+
* field is mirrored to the URL on a debounce, so a view mounted on an address
|
|
58
|
+
* carrying `q` reads a query the next keystroke does not move. Unset, the typed
|
|
59
|
+
* text answers for both, which is the mid-debounce case the other tests are in.
|
|
60
|
+
*/
|
|
61
|
+
let committedQuery: string | undefined;
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
harness?.close();
|
|
65
|
+
harness = undefined;
|
|
66
|
+
http?.restore();
|
|
67
|
+
http = undefined;
|
|
68
|
+
chromes = [];
|
|
69
|
+
commits = 0;
|
|
70
|
+
resultsInBody = true;
|
|
71
|
+
initialPath = `/mail/${MAILBOX_ID}`;
|
|
72
|
+
committedQuery = undefined;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// The router reads `self` at construction; the shared jsdom globals stop at
|
|
76
|
+
// `window`.
|
|
77
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
78
|
+
|
|
79
|
+
/** Stands in for the list: it takes the chrome and renders the field slot. */
|
|
80
|
+
function ChromeProbe() {
|
|
81
|
+
const chrome = useListHeaderChrome();
|
|
82
|
+
chromes.push(chrome);
|
|
83
|
+
return createElement("div", null, chrome.searchField);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function Header() {
|
|
87
|
+
const [query, setQuery] = useState(TYPED);
|
|
88
|
+
const clear = useCallback(() => setQuery(""), []);
|
|
89
|
+
const context = useMemo<MailContextValue>(
|
|
90
|
+
() => ({
|
|
91
|
+
accounts: [],
|
|
92
|
+
mailboxNameIndex: new Map(),
|
|
93
|
+
accountNameIndex: new Map(),
|
|
94
|
+
resultFolderIndex: EMPTY_RESULT_FOLDER_INDEX,
|
|
95
|
+
searchQuery: committedQuery ?? query,
|
|
96
|
+
searchInput: query,
|
|
97
|
+
searchViewKey: "list",
|
|
98
|
+
onSearchChange: setQuery,
|
|
99
|
+
onSearchClear: clear,
|
|
100
|
+
onSearchClearQuery: clear,
|
|
101
|
+
intelligenceOpen: false,
|
|
102
|
+
onToggleIntelligence: () => undefined,
|
|
103
|
+
onRaiseIntelligence: () => undefined,
|
|
104
|
+
}),
|
|
105
|
+
[query, clear],
|
|
106
|
+
);
|
|
107
|
+
return createElement(
|
|
108
|
+
MailContext.Provider,
|
|
109
|
+
{ value: context },
|
|
110
|
+
createElement(
|
|
111
|
+
Profiler,
|
|
112
|
+
{
|
|
113
|
+
id: "list-header",
|
|
114
|
+
onRender: () => {
|
|
115
|
+
commits += 1;
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
createElement(MailListHeader, {
|
|
119
|
+
title: "Inbox",
|
|
120
|
+
unreadCount: null,
|
|
121
|
+
// Set, the body renders the committed search itself and the read-only
|
|
122
|
+
// results panel stays out of the chrome; unset, the panel rides in the
|
|
123
|
+
// chrome, which is the other thing a keystroke must not churn.
|
|
124
|
+
searchResultsInBody: resultsInBody,
|
|
125
|
+
// biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
|
|
126
|
+
children: createElement(ChromeProbe),
|
|
127
|
+
}),
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const testRouter = (): AnyRouter => {
|
|
133
|
+
const rootRoute = createRootRoute({ component: Outlet });
|
|
134
|
+
const mailRoute = createRoute({
|
|
135
|
+
getParentRoute: () => rootRoute,
|
|
136
|
+
path: "/mail",
|
|
137
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
138
|
+
component: Outlet,
|
|
139
|
+
});
|
|
140
|
+
const mailboxRoute = createRoute({
|
|
141
|
+
getParentRoute: () => mailRoute,
|
|
142
|
+
path: "/$mailboxId",
|
|
143
|
+
component: Header,
|
|
144
|
+
});
|
|
145
|
+
return createRouter({
|
|
146
|
+
routeTree: rootRoute.addChildren([mailRoute.addChildren([mailboxRoute])]),
|
|
147
|
+
history: createMemoryHistory({ initialEntries: [initialPath] }),
|
|
148
|
+
}) as unknown as AnyRouter;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** Mount the header with a query typed and the field holding focus. */
|
|
152
|
+
const mount = async (
|
|
153
|
+
options: { resultsPanelInChrome?: boolean; committedQuery?: string } = {},
|
|
154
|
+
): Promise<{
|
|
155
|
+
mounted: DomHarness;
|
|
156
|
+
field: HTMLInputElement;
|
|
157
|
+
}> => {
|
|
158
|
+
resultsInBody = options.resultsPanelInChrome !== true;
|
|
159
|
+
committedQuery = options.committedQuery;
|
|
160
|
+
initialPath = committedQuery
|
|
161
|
+
? `/mail/${MAILBOX_ID}?q=${committedQuery}`
|
|
162
|
+
: `/mail/${MAILBOX_ID}`;
|
|
163
|
+
http = mockFetch(() => ({ items: [] }));
|
|
164
|
+
const router = testRouter();
|
|
165
|
+
await router.load();
|
|
166
|
+
const mounted = createDomHarness({ viewportWidth: TABLET_WIDTH });
|
|
167
|
+
harness = mounted;
|
|
168
|
+
mounted.renderApp(createElement(RouterProvider, { router }));
|
|
169
|
+
await mounted.waitFor(
|
|
170
|
+
() => mounted.query('[aria-label="Search mail"]') !== null,
|
|
171
|
+
"the header's search field",
|
|
172
|
+
);
|
|
173
|
+
const field = mounted.query<HTMLInputElement>('[aria-label="Search mail"]');
|
|
174
|
+
assert.ok(field, "the header rendered no search field");
|
|
175
|
+
mounted.dispatch(field, new Event("focusin", { bubbles: true }));
|
|
176
|
+
await mounted.waitFor(
|
|
177
|
+
() => field.getAttribute("aria-expanded") === "true",
|
|
178
|
+
"the suggestion list to open",
|
|
179
|
+
);
|
|
180
|
+
return { mounted, field };
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const press = (mounted: DomHarness, field: Element, key: string) => {
|
|
184
|
+
mounted.dispatch(
|
|
185
|
+
field,
|
|
186
|
+
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
|
187
|
+
);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
describe("the chrome the list header hands its body (#506)", () => {
|
|
191
|
+
it("is the same object across a keystroke that only moves the field's highlight", async () => {
|
|
192
|
+
const { mounted, field } = await mount();
|
|
193
|
+
const before = chromes.at(-1);
|
|
194
|
+
assert.ok(before, "the body was never handed a chrome");
|
|
195
|
+
const handedBefore = chromes.length;
|
|
196
|
+
const commitsBefore = commits;
|
|
197
|
+
|
|
198
|
+
press(mounted, field, "ArrowDown");
|
|
199
|
+
await mounted.flush();
|
|
200
|
+
|
|
201
|
+
assert.ok(
|
|
202
|
+
commits > commitsBefore,
|
|
203
|
+
"the keystroke committed no render at all, so the memo was never put to the test",
|
|
204
|
+
);
|
|
205
|
+
assert.ok(
|
|
206
|
+
field.getAttribute("aria-activedescendant"),
|
|
207
|
+
"the keystroke did not move the highlight, so nothing was put to the test",
|
|
208
|
+
);
|
|
209
|
+
assert.equal(
|
|
210
|
+
chromes.length,
|
|
211
|
+
handedBefore,
|
|
212
|
+
"a highlight move in the field re-rendered the list below it",
|
|
213
|
+
);
|
|
214
|
+
assert.equal(
|
|
215
|
+
chromes.at(-1),
|
|
216
|
+
before,
|
|
217
|
+
"a highlight move in the field pushed a new chrome at the list below it",
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// The symptom #506 was filed on: a mailbox on tablet, its query already in the
|
|
222
|
+
// URL, its own rows in the body — and a character typed into the field
|
|
223
|
+
// repainting all of them. Everything a keystroke moves here (the parse, the
|
|
224
|
+
// conversion the wizard opens on, the copy the make-this-a-filter row shows)
|
|
225
|
+
// is derived from the text, so the chrome holds only if none of it rides on it.
|
|
226
|
+
//
|
|
227
|
+
// The typed text lives above the header, so the list is re-rendered either
|
|
228
|
+
// way — what it must be handed is the chrome it already has, which is what
|
|
229
|
+
// keeps its own rows off the render path.
|
|
230
|
+
it("is the same object across a character typed over a committed query", async () => {
|
|
231
|
+
const { mounted, field } = await mount({ committedQuery: TYPED });
|
|
232
|
+
const before = chromes.at(-1);
|
|
233
|
+
assert.ok(before, "the body was never handed a chrome");
|
|
234
|
+
const handedBefore = chromes.length;
|
|
235
|
+
const commitsBefore = commits;
|
|
236
|
+
|
|
237
|
+
mounted.type(field, TYPED_NEXT);
|
|
238
|
+
await mounted.flush();
|
|
239
|
+
|
|
240
|
+
assert.equal(
|
|
241
|
+
field.value,
|
|
242
|
+
TYPED_NEXT,
|
|
243
|
+
"the character never reached the field",
|
|
244
|
+
);
|
|
245
|
+
assert.ok(
|
|
246
|
+
commits > commitsBefore,
|
|
247
|
+
"the keystroke committed no render at all, so the memo was never put to the test",
|
|
248
|
+
);
|
|
249
|
+
assert.ok(
|
|
250
|
+
chromes.length > handedBefore,
|
|
251
|
+
"the list was never re-rendered, so it was never handed a second chrome to compare",
|
|
252
|
+
);
|
|
253
|
+
// Identity, not `assert.equal`: a failure there renders a diff of two
|
|
254
|
+
// chromes full of React elements, and that is what runs the reporter out of
|
|
255
|
+
// memory rather than reporting the bug.
|
|
256
|
+
assert.ok(
|
|
257
|
+
chromes.at(-1) === before,
|
|
258
|
+
"a character typed over a committed query pushed a new chrome at the list below it",
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("is the same object for a view whose results panel rides in the chrome", async () => {
|
|
263
|
+
const { mounted, field } = await mount({ resultsPanelInChrome: true });
|
|
264
|
+
const before = chromes.at(-1);
|
|
265
|
+
assert.ok(before?.searchResults, "the panel never reached the chrome");
|
|
266
|
+
const handedBefore = chromes.length;
|
|
267
|
+
const commitsBefore = commits;
|
|
268
|
+
|
|
269
|
+
press(mounted, field, "ArrowDown");
|
|
270
|
+
await mounted.flush();
|
|
271
|
+
|
|
272
|
+
assert.ok(
|
|
273
|
+
commits > commitsBefore,
|
|
274
|
+
"the keystroke committed no render at all, so the memo was never put to the test",
|
|
275
|
+
);
|
|
276
|
+
assert.equal(
|
|
277
|
+
chromes.length,
|
|
278
|
+
handedBefore,
|
|
279
|
+
"a highlight move in the field re-rendered the list below it",
|
|
280
|
+
);
|
|
281
|
+
assert.equal(chromes.at(-1), before);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("is the same object across the field taking and losing focus", async () => {
|
|
285
|
+
const { mounted, field } = await mount();
|
|
286
|
+
const before = chromes.at(-1);
|
|
287
|
+
const handedBefore = chromes.length;
|
|
288
|
+
const commitsBefore = commits;
|
|
289
|
+
|
|
290
|
+
mounted.dispatch(field, new Event("focusout", { bubbles: true }));
|
|
291
|
+
await mounted.flush();
|
|
292
|
+
mounted.dispatch(field, new Event("focusin", { bubbles: true }));
|
|
293
|
+
await mounted.waitFor(
|
|
294
|
+
() => field.getAttribute("aria-expanded") === "true",
|
|
295
|
+
"the suggestion list to open again",
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
assert.ok(
|
|
299
|
+
commits > commitsBefore,
|
|
300
|
+
"focus moved nothing, so the memo was never put to the test",
|
|
301
|
+
);
|
|
302
|
+
assert.equal(
|
|
303
|
+
chromes.length,
|
|
304
|
+
handedBefore,
|
|
305
|
+
"focusing the field re-rendered the list below it",
|
|
306
|
+
);
|
|
307
|
+
assert.equal(chromes.at(-1), before);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// The key handler is stable now, so it has to read the highlight the field
|
|
311
|
+
// is showing rather than the one it was bound with.
|
|
312
|
+
it("still takes the highlighted suggestion on Enter", async () => {
|
|
313
|
+
const { mounted, field } = await mount();
|
|
314
|
+
|
|
315
|
+
press(mounted, field, "ArrowDown");
|
|
316
|
+
await mounted.flush();
|
|
317
|
+
press(mounted, field, "Enter");
|
|
318
|
+
await mounted.flush();
|
|
319
|
+
|
|
320
|
+
assert.notEqual(field.value, TYPED, "Enter left the query as it was typed");
|
|
321
|
+
assert.match(field.value, /^from:/);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
@@ -60,8 +60,10 @@ import {
|
|
|
60
60
|
} from "@remit/ui";
|
|
61
61
|
import { Menu, Search, X } from "lucide-react";
|
|
62
62
|
import {
|
|
63
|
+
createContext,
|
|
63
64
|
type ReactNode,
|
|
64
65
|
useCallback,
|
|
66
|
+
useContext,
|
|
65
67
|
useEffect,
|
|
66
68
|
useMemo,
|
|
67
69
|
useRef,
|
|
@@ -78,6 +80,7 @@ import {
|
|
|
78
80
|
import { useMailContext } from "@/lib/mail-context";
|
|
79
81
|
import { convertSearchToRule } from "@/lib/organize/search-to-rule";
|
|
80
82
|
import { loadRecentSearches, saveRecentSearch } from "@/lib/recent-searches";
|
|
83
|
+
import { SearchConversionContext } from "@/lib/search-conversion";
|
|
81
84
|
import { resultsScopeForRoute, routeMailboxId } from "@/lib/search-scope";
|
|
82
85
|
import { applySearchSuggestion } from "@/lib/search-suggestions";
|
|
83
86
|
import { showInlineSearchResults } from "@/lib/search-surface";
|
|
@@ -157,6 +160,60 @@ export interface MailListHeaderProps {
|
|
|
157
160
|
refreshControl?: ReactNode;
|
|
158
161
|
}
|
|
159
162
|
|
|
163
|
+
interface HeaderSearchState {
|
|
164
|
+
suggest: SearchFieldSuggest;
|
|
165
|
+
/** Drop the query and put the field away. */
|
|
166
|
+
onClose: () => void;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const HeaderSearchContext = createContext<HeaderSearchState | null>(null);
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The expanded field, which subscribes to its own state instead of being handed
|
|
173
|
+
* it (#506).
|
|
174
|
+
*
|
|
175
|
+
* What is typed, where the caret is and which suggestion is highlighted change
|
|
176
|
+
* on every keystroke and belong to the field alone. Building the field into the
|
|
177
|
+
* chrome object made each of those a new chrome, and the chrome reaches the
|
|
178
|
+
* virtualized list through context, so the list re-rendered for a character it
|
|
179
|
+
* does not show. The chrome carries the slot; the slot reads the rest itself.
|
|
180
|
+
*/
|
|
181
|
+
function ListHeaderSearchField() {
|
|
182
|
+
const search = useContext(HeaderSearchContext);
|
|
183
|
+
const { searchInput, onSearchChange, onSearchClear } = useMailContext();
|
|
184
|
+
// Only `MailListHeader` puts this slot on the chrome, and it does so inside
|
|
185
|
+
// its own provider, so there is no reachable path here without one. Say so
|
|
186
|
+
// rather than rendering nothing: a search field that silently disappears is
|
|
187
|
+
// indistinguishable from a view that has no search.
|
|
188
|
+
if (!search)
|
|
189
|
+
throw new Error(
|
|
190
|
+
"ListHeaderSearchField rendered outside MailListHeader's HeaderSearchContext provider",
|
|
191
|
+
);
|
|
192
|
+
return (
|
|
193
|
+
<>
|
|
194
|
+
<div className="min-w-0 flex-1">
|
|
195
|
+
<SearchBar
|
|
196
|
+
value={searchInput}
|
|
197
|
+
onChange={onSearchChange}
|
|
198
|
+
onClear={onSearchClear}
|
|
199
|
+
globalFocusKey={false}
|
|
200
|
+
showClearButton={false}
|
|
201
|
+
suggest={search.suggest}
|
|
202
|
+
/>
|
|
203
|
+
</div>
|
|
204
|
+
<Button
|
|
205
|
+
variant="ghost"
|
|
206
|
+
size="touch"
|
|
207
|
+
icon={<X className="size-5" />}
|
|
208
|
+
onClick={search.onClose}
|
|
209
|
+
aria-label="Close search"
|
|
210
|
+
className="shrink-0"
|
|
211
|
+
/>
|
|
212
|
+
<FilterToggle />
|
|
213
|
+
</>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
160
217
|
export function MailListHeader({
|
|
161
218
|
title,
|
|
162
219
|
unreadCount,
|
|
@@ -241,9 +298,9 @@ export function MailListHeader({
|
|
|
241
298
|
if (suggestion) applySuggestion(suggestion);
|
|
242
299
|
},
|
|
243
300
|
});
|
|
244
|
-
// Stable while nothing about the field changes: it reaches the
|
|
245
|
-
//
|
|
246
|
-
//
|
|
301
|
+
// Stable while nothing about the field changes: it reaches the field through
|
|
302
|
+
// its own context rather than the chrome, so moving the highlight repaints
|
|
303
|
+
// the field and leaves the list under it alone.
|
|
247
304
|
const { comboboxProps, handleKeyDown } = suggest;
|
|
248
305
|
const searchSuggest = useMemo<SearchFieldSuggest>(
|
|
249
306
|
() => ({
|
|
@@ -255,6 +312,14 @@ export function MailListHeader({
|
|
|
255
312
|
}),
|
|
256
313
|
[comboboxProps, handleKeyDown, caretRequest],
|
|
257
314
|
);
|
|
315
|
+
const closeSearch = useCallback(() => {
|
|
316
|
+
onSearchClear();
|
|
317
|
+
setSearchOpen(false);
|
|
318
|
+
}, [onSearchClear]);
|
|
319
|
+
const headerSearch = useMemo<HeaderSearchState>(
|
|
320
|
+
() => ({ suggest: searchSuggest, onClose: closeSearch }),
|
|
321
|
+
[searchSuggest, closeSearch],
|
|
322
|
+
);
|
|
258
323
|
// Under the field and in flow, on both tiers: a phone's soft keyboard owns
|
|
259
324
|
// the lower half of the screen, and a list floating over the field would
|
|
260
325
|
// cover the query it is completing.
|
|
@@ -286,21 +351,29 @@ export function MailListHeader({
|
|
|
286
351
|
() => parseSearchTokens(searchInput, { mailboxesByName, accountsByName }),
|
|
287
352
|
[searchInput, mailboxesByName, accountsByName],
|
|
288
353
|
);
|
|
289
|
-
const tokenChips =
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
354
|
+
const tokenChips = useMemo(
|
|
355
|
+
() =>
|
|
356
|
+
parsed.tokens.map((token) => ({
|
|
357
|
+
label: searchTokenLabel(token),
|
|
358
|
+
onRemove: () => onSearchChange(removeSearchToken(searchInput, token)),
|
|
359
|
+
})),
|
|
360
|
+
[parsed, searchInput, onSearchChange],
|
|
361
|
+
);
|
|
362
|
+
const topMatches = useMemo(() => searchResults ?? [], [searchResults]);
|
|
363
|
+
const related = useMemo(() => relatedResults ?? [], [relatedResults]);
|
|
295
364
|
// Always offer both sections while a query is present; the kit drops the empty
|
|
296
365
|
// ones, so a "Related"-only hit still shows and two empties fall to its empty
|
|
297
366
|
// state. The empty-query case (recent searches) is the kit's job.
|
|
298
|
-
const sections
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
367
|
+
const sections = useMemo<SearchResultSection[]>(
|
|
368
|
+
() =>
|
|
369
|
+
hasQuery
|
|
370
|
+
? [
|
|
371
|
+
{ id: "top", label: searchResultsLabel, results: topMatches },
|
|
372
|
+
{ id: "related", label: relatedResultsLabel, results: related },
|
|
373
|
+
]
|
|
374
|
+
: [],
|
|
375
|
+
[hasQuery, searchResultsLabel, relatedResultsLabel, topMatches, related],
|
|
376
|
+
);
|
|
304
377
|
// Skeleton only while nothing is in yet — once either section has rows, show
|
|
305
378
|
// them and let the other arrive (or not). Keeps the two sources independent.
|
|
306
379
|
const hasAnyResult = topMatches.length + related.length > 0;
|
|
@@ -312,33 +385,43 @@ export function MailListHeader({
|
|
|
312
385
|
const { scope } = useSearchScope(accounts);
|
|
313
386
|
const browsed = useBrowsedList();
|
|
314
387
|
const scopedMailboxId = routeMailboxId(browsed);
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
388
|
+
const scopedRole = scopedMailboxId
|
|
389
|
+
? resultFolderIndex.get(scopedMailboxId)?.role
|
|
390
|
+
: undefined;
|
|
391
|
+
const routeScope = useMemo(
|
|
392
|
+
() => resultsScopeForRoute(browsed, scope, scopedRole),
|
|
393
|
+
[browsed, scope, scopedRole],
|
|
319
394
|
);
|
|
320
395
|
|
|
321
396
|
// The offer counts results for the *committed* query, so that is the query it
|
|
322
397
|
// carries into Spam. The count the banner states is the results list's own,
|
|
323
398
|
// over every row it held out; the app supplies only where "Go to Spam" goes.
|
|
324
|
-
const spamOffer =
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
399
|
+
const spamOffer = useMemo(
|
|
400
|
+
() =>
|
|
401
|
+
routeScope.kind === "global"
|
|
402
|
+
? spamOfferForResults([...topMatches, ...related])
|
|
403
|
+
: undefined,
|
|
404
|
+
[routeScope, topMatches, related],
|
|
405
|
+
);
|
|
406
|
+
const resultsScope = useMemo<SearchScope>(
|
|
407
|
+
() =>
|
|
408
|
+
spamOffer
|
|
409
|
+
? {
|
|
410
|
+
kind: "global",
|
|
411
|
+
onScopeToSpam: () =>
|
|
412
|
+
scopeSearchToMailbox(spamOffer.mailboxId, searchQuery),
|
|
413
|
+
}
|
|
414
|
+
: routeScope,
|
|
415
|
+
[spamOffer, routeScope, scopeSearchToMailbox, searchQuery],
|
|
416
|
+
);
|
|
335
417
|
|
|
336
418
|
// Make-this-a-filter (#477 clause 1.8): the search is the wizard's second
|
|
337
419
|
// entry. It opens on the properties step with the clauses `convertSearchToRule`
|
|
338
420
|
// derives from the query, nothing ticked, and the notice for what the query
|
|
339
|
-
// could not be turned into. The conversion is computed once, here, and
|
|
340
|
-
//
|
|
341
|
-
// clauses the wizard seeds from are the same answer
|
|
421
|
+
// could not be turned into. The conversion is computed once, here, and reaches
|
|
422
|
+
// the wizard through its own context — the reason the affordance gives and the
|
|
423
|
+
// clauses the wizard seeds from are the same answer, without the per-keystroke
|
|
424
|
+
// object riding on the chrome the list reads. The literal filter cannot
|
|
342
425
|
// reproduce the search's semantic reach, so the conversion states it whenever
|
|
343
426
|
// the search surfaced a "Related" section — a direct signal, read here from
|
|
344
427
|
// the semantic results, never a capability probe.
|
|
@@ -358,28 +441,32 @@ export function MailListHeader({
|
|
|
358
441
|
[parsed, searchHadSemanticReach],
|
|
359
442
|
);
|
|
360
443
|
const searchConversion = hasQuery ? conversion : undefined;
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
444
|
+
// The wizard is a full-screen surface, so the phone takeover it was pressed
|
|
445
|
+
// from stands down rather than sitting under it — and the list underneath,
|
|
446
|
+
// which hosts the wizard, is mounted again by the time the step lands.
|
|
447
|
+
const openFilterWizard = useCallback(() => {
|
|
448
|
+
setSearchOpen(false);
|
|
449
|
+
openWizard("properties", "search");
|
|
450
|
+
}, [openWizard]);
|
|
451
|
+
// Keyed on the copy the affordance shows, not on the conversion it is derived
|
|
452
|
+
// from: the conversion is a fresh object for every character typed, while the
|
|
453
|
+
// reason it yields is the same string for all of them, and this row travels to
|
|
454
|
+
// the virtualized list on the chrome (#506).
|
|
455
|
+
const blockedReason = isConvertible(conversion)
|
|
456
|
+
? undefined
|
|
457
|
+
: makeFilterBlockedCopy(
|
|
458
|
+
conversion.droppedFacets.map((facet) => facet.label),
|
|
459
|
+
);
|
|
460
|
+
const makeFilter = useMemo(
|
|
461
|
+
() => (hasQuery ? { onClick: openFilterWizard, blockedReason } : undefined),
|
|
462
|
+
[hasQuery, blockedReason, openFilterWizard],
|
|
463
|
+
);
|
|
378
464
|
// Handed to the bar rather than rendered here: the bar knows whether rows
|
|
379
465
|
// are ticked, and a selection's own verbs own the surface while they are up.
|
|
380
|
-
const makeFilterAction =
|
|
381
|
-
<MakeFilterAction {...makeFilter} />
|
|
382
|
-
|
|
466
|
+
const makeFilterAction = useMemo(
|
|
467
|
+
() => (makeFilter ? <MakeFilterAction {...makeFilter} /> : null),
|
|
468
|
+
[makeFilter],
|
|
469
|
+
);
|
|
383
470
|
|
|
384
471
|
// Tablet + desktop keep the inline toolbar search; while a query is being
|
|
385
472
|
// typed the list-pane body swaps to the same sectioned results the phone
|
|
@@ -395,19 +482,32 @@ export function MailListHeader({
|
|
|
395
482
|
hasCommittedQuery: searchQuery.trim().length > 0,
|
|
396
483
|
bodyRendersCommittedResults: searchResultsInBody,
|
|
397
484
|
});
|
|
398
|
-
const handleSelectInlineResult = (
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
485
|
+
const handleSelectInlineResult = useCallback(
|
|
486
|
+
(result: SearchResult) => {
|
|
487
|
+
setRecentSearches(saveRecentSearch(searchInput));
|
|
488
|
+
onSelectSearchResult?.(result);
|
|
489
|
+
},
|
|
490
|
+
[searchInput, onSelectSearchResult],
|
|
491
|
+
);
|
|
492
|
+
const results = useMemo(
|
|
493
|
+
() => (
|
|
494
|
+
<SearchResults
|
|
495
|
+
value={searchInput}
|
|
496
|
+
sections={sections}
|
|
497
|
+
loading={resultsLoading}
|
|
498
|
+
onSelectResult={handleSelectInlineResult}
|
|
499
|
+
tokens={tokenChips}
|
|
500
|
+
scope={resultsScope}
|
|
501
|
+
/>
|
|
502
|
+
),
|
|
503
|
+
[
|
|
504
|
+
searchInput,
|
|
505
|
+
sections,
|
|
506
|
+
resultsLoading,
|
|
507
|
+
handleSelectInlineResult,
|
|
508
|
+
tokenChips,
|
|
509
|
+
resultsScope,
|
|
510
|
+
],
|
|
411
511
|
);
|
|
412
512
|
// The header lives inside `children` for every view whose selection sits
|
|
413
513
|
// below this one, so swapping the body out from here would take the header
|
|
@@ -416,9 +516,13 @@ export function MailListHeader({
|
|
|
416
516
|
// put it where their own rows go; the brief, whose header this component
|
|
417
517
|
// renders, keeps the plain swap.
|
|
418
518
|
const bodyOwnsHeader = selectionBar === undefined;
|
|
419
|
-
const resultsPane =
|
|
420
|
-
|
|
421
|
-
|
|
519
|
+
const resultsPane = useMemo(
|
|
520
|
+
() =>
|
|
521
|
+
showInlineResults ? (
|
|
522
|
+
<div className="h-full overflow-y-auto">{results}</div>
|
|
523
|
+
) : null,
|
|
524
|
+
[showInlineResults, results],
|
|
525
|
+
);
|
|
422
526
|
const body = resultsPane && !bodyOwnsHeader ? resultsPane : children;
|
|
423
527
|
const chromeResults = resultsPane && bodyOwnsHeader ? resultsPane : null;
|
|
424
528
|
|
|
@@ -435,7 +539,6 @@ export function MailListHeader({
|
|
|
435
539
|
title,
|
|
436
540
|
searchResults: chromeResults,
|
|
437
541
|
makeFilterSlot: makeFilterAction,
|
|
438
|
-
searchConversion,
|
|
439
542
|
navSlot: layout && !layout.showNavPane && (
|
|
440
543
|
<Button
|
|
441
544
|
variant="ghost"
|
|
@@ -467,32 +570,7 @@ export function MailListHeader({
|
|
|
467
570
|
className="shrink-0"
|
|
468
571
|
/>
|
|
469
572
|
),
|
|
470
|
-
searchField: searchExpanded &&
|
|
471
|
-
<>
|
|
472
|
-
<div className="min-w-0 flex-1">
|
|
473
|
-
<SearchBar
|
|
474
|
-
value={searchInput}
|
|
475
|
-
onChange={onSearchChange}
|
|
476
|
-
onClear={onSearchClear}
|
|
477
|
-
globalFocusKey={false}
|
|
478
|
-
showClearButton={false}
|
|
479
|
-
suggest={searchSuggest}
|
|
480
|
-
/>
|
|
481
|
-
</div>
|
|
482
|
-
<Button
|
|
483
|
-
variant="ghost"
|
|
484
|
-
size="touch"
|
|
485
|
-
icon={<X className="size-5" />}
|
|
486
|
-
onClick={() => {
|
|
487
|
-
onSearchClear();
|
|
488
|
-
setSearchOpen(false);
|
|
489
|
-
}}
|
|
490
|
-
aria-label="Close search"
|
|
491
|
-
className="shrink-0"
|
|
492
|
-
/>
|
|
493
|
-
<FilterToggle />
|
|
494
|
-
</>
|
|
495
|
-
),
|
|
573
|
+
searchField: searchExpanded && <ListHeaderSearchField />,
|
|
496
574
|
}),
|
|
497
575
|
[
|
|
498
576
|
title,
|
|
@@ -500,13 +578,8 @@ export function MailListHeader({
|
|
|
500
578
|
layout,
|
|
501
579
|
ownsSearch,
|
|
502
580
|
searchExpanded,
|
|
503
|
-
searchInput,
|
|
504
|
-
onSearchChange,
|
|
505
|
-
onSearchClear,
|
|
506
|
-
searchSuggest,
|
|
507
581
|
chromeResults,
|
|
508
582
|
makeFilterAction,
|
|
509
|
-
searchConversion,
|
|
510
583
|
refreshControl,
|
|
511
584
|
],
|
|
512
585
|
);
|
|
@@ -518,41 +591,49 @@ export function MailListHeader({
|
|
|
518
591
|
onSelectSearchResult?.(result);
|
|
519
592
|
};
|
|
520
593
|
return (
|
|
521
|
-
<
|
|
522
|
-
<
|
|
523
|
-
value={
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
594
|
+
<HeaderSearchContext.Provider value={headerSearch}>
|
|
595
|
+
<SearchConversionContext.Provider value={searchConversion}>
|
|
596
|
+
<ListHeaderChromeContext.Provider value={chrome}>
|
|
597
|
+
<MobileSearchView
|
|
598
|
+
value={searchInput}
|
|
599
|
+
onChange={onSearchChange}
|
|
600
|
+
onClear={onSearchClear}
|
|
601
|
+
onCancel={() => {
|
|
602
|
+
setSearchOpen(false);
|
|
603
|
+
onSearchClear();
|
|
604
|
+
}}
|
|
605
|
+
filter={searchFilter}
|
|
606
|
+
recentSearches={recentSearches}
|
|
607
|
+
onPickRecent={onSearchChange}
|
|
608
|
+
makeFilter={makeFilter}
|
|
609
|
+
sections={sections}
|
|
610
|
+
loading={resultsLoading}
|
|
611
|
+
onSelectResult={handleSelectResult}
|
|
612
|
+
tokens={tokenChips}
|
|
613
|
+
scope={resultsScope}
|
|
614
|
+
suggest={searchSuggest}
|
|
615
|
+
suggestList={suggestList}
|
|
616
|
+
/>
|
|
617
|
+
{paneOverlay}
|
|
618
|
+
</ListHeaderChromeContext.Provider>
|
|
619
|
+
</SearchConversionContext.Provider>
|
|
620
|
+
</HeaderSearchContext.Provider>
|
|
544
621
|
);
|
|
545
622
|
}
|
|
546
623
|
|
|
547
624
|
return (
|
|
548
|
-
<
|
|
549
|
-
<
|
|
550
|
-
{
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
625
|
+
<HeaderSearchContext.Provider value={headerSearch}>
|
|
626
|
+
<SearchConversionContext.Provider value={searchConversion}>
|
|
627
|
+
<ListHeaderChromeContext.Provider value={chrome}>
|
|
628
|
+
<section className="relative flex h-full w-full flex-col bg-surface">
|
|
629
|
+
{selectionBar?.(chrome)}
|
|
630
|
+
{suggestList}
|
|
631
|
+
<div className="min-h-0 flex-1">{body}</div>
|
|
632
|
+
{footer}
|
|
633
|
+
{paneOverlay}
|
|
634
|
+
</section>
|
|
635
|
+
</ListHeaderChromeContext.Provider>
|
|
636
|
+
</SearchConversionContext.Provider>
|
|
637
|
+
</HeaderSearchContext.Provider>
|
|
557
638
|
);
|
|
558
639
|
}
|
|
@@ -57,7 +57,6 @@ import type {
|
|
|
57
57
|
BulkRunOutcome,
|
|
58
58
|
} from "@/lib/bulk-actions";
|
|
59
59
|
import { NO_JUNK_FOLDER_REASON } from "@/lib/junk-destination";
|
|
60
|
-
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
61
60
|
import { useMailContext } from "@/lib/mail-context";
|
|
62
61
|
import { buildMoveOptions, folderDelimiter } from "@/lib/move-options";
|
|
63
62
|
import {
|
|
@@ -73,6 +72,7 @@ import {
|
|
|
73
72
|
} from "@/lib/organize/rule-model";
|
|
74
73
|
import { searchRuleAccountId } from "@/lib/organize/search-to-rule";
|
|
75
74
|
import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
|
|
75
|
+
import { useSearchConversion } from "@/lib/search-conversion";
|
|
76
76
|
import type { WizardSelectionMessage } from "@/lib/wizard-selection";
|
|
77
77
|
import { useWizardEntryValue, useWizardStep } from "@/routing";
|
|
78
78
|
import { organizeRunState } from "./organize-run-state";
|
|
@@ -1105,7 +1105,7 @@ function SelectionWizardSession({
|
|
|
1105
1105
|
*/
|
|
1106
1106
|
export function SelectionWizardHost(props: SelectionWizardHostProps) {
|
|
1107
1107
|
const fromSearch = useWizardEntryValue() === "search";
|
|
1108
|
-
const
|
|
1108
|
+
const searchConversion = useSearchConversion();
|
|
1109
1109
|
const { accounts } = useMailContext();
|
|
1110
1110
|
const { step, goToStep, goBack, closeWizard } = useWizardStep(
|
|
1111
1111
|
fromSearch ? "properties" : "match",
|
|
@@ -50,6 +50,7 @@ describe("placementRefusalBanner", () => {
|
|
|
50
50
|
const banner = placementRefusalBanner(
|
|
51
51
|
{ reason: "in_flight", messageId: "msg-1" },
|
|
52
52
|
1,
|
|
53
|
+
"delete",
|
|
53
54
|
);
|
|
54
55
|
assert.equal(banner.severity, "warning");
|
|
55
56
|
assert.match(banner.detail ?? "", /try again in a moment/i);
|
|
@@ -59,6 +60,7 @@ describe("placementRefusalBanner", () => {
|
|
|
59
60
|
const banner = placementRefusalBanner(
|
|
60
61
|
{ reason: "unverified", messageId: "msg-1" },
|
|
61
62
|
1,
|
|
63
|
+
"delete",
|
|
62
64
|
);
|
|
63
65
|
assert.match(banner.detail ?? "", /sync the folder/i);
|
|
64
66
|
assert.doesNotMatch(banner.detail ?? "", /try again in a moment/i);
|
|
@@ -66,14 +68,43 @@ describe("placementRefusalBanner", () => {
|
|
|
66
68
|
|
|
67
69
|
it("counts the selection in the title", () => {
|
|
68
70
|
assert.match(
|
|
69
|
-
placementRefusalBanner(
|
|
71
|
+
placementRefusalBanner(
|
|
72
|
+
{ reason: "in_flight", messageId: "m" },
|
|
73
|
+
4,
|
|
74
|
+
"delete",
|
|
75
|
+
).title,
|
|
70
76
|
/4 messages/,
|
|
71
77
|
);
|
|
72
78
|
});
|
|
73
79
|
|
|
80
|
+
// A move refused mid-flight told the user their delete failed — an action
|
|
81
|
+
// they never took, and a remedy pointing at the wrong verb.
|
|
82
|
+
it("names the action the user pressed, not the delete", () => {
|
|
83
|
+
const title = placementRefusalBanner(
|
|
84
|
+
{ reason: "in_flight", messageId: "msg-1" },
|
|
85
|
+
1,
|
|
86
|
+
"move",
|
|
87
|
+
).title;
|
|
88
|
+
assert.match(title, /move this message/i);
|
|
89
|
+
assert.doesNotMatch(title, /delete/i);
|
|
90
|
+
|
|
91
|
+
const detail =
|
|
92
|
+
placementRefusalBanner(
|
|
93
|
+
{ reason: "unverified", messageId: "msg-1" },
|
|
94
|
+
1,
|
|
95
|
+
"move",
|
|
96
|
+
).detail ?? "";
|
|
97
|
+
assert.match(detail, /move it again/i);
|
|
98
|
+
assert.doesNotMatch(detail, /delete/i);
|
|
99
|
+
});
|
|
100
|
+
|
|
74
101
|
it("never repeats the server's uuid at the user", () => {
|
|
75
102
|
for (const reason of ["in_flight", "unverified"] as const) {
|
|
76
|
-
const banner = placementRefusalBanner(
|
|
103
|
+
const banner = placementRefusalBanner(
|
|
104
|
+
{ reason, messageId: "msg-1" },
|
|
105
|
+
1,
|
|
106
|
+
"delete",
|
|
107
|
+
);
|
|
77
108
|
assert.doesNotMatch(`${banner.title} ${banner.detail}`, /msg-1/);
|
|
78
109
|
}
|
|
79
110
|
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The coded 409 a delete is refused with when the message's folder
|
|
3
|
-
* not name the same message (#845). Read the `code`, never the
|
|
4
|
-
* server's sentence names a uuid and no remedy, and a
|
|
2
|
+
* The coded 409 a delete or a move is refused with when the message's folder
|
|
3
|
+
* and uid do not name the same message (#845). Read the `code`, never the
|
|
4
|
+
* message: the server's sentence names a uuid and no remedy, and a
|
|
5
|
+
* message-string match
|
|
5
6
|
* would start firing on an unrelated conflict the moment the copy changes.
|
|
6
7
|
*/
|
|
7
8
|
import type { PushErrorInput } from "@/components/ui/error-banners";
|
|
@@ -45,6 +46,12 @@ export const isPlacementRefusal = (
|
|
|
45
46
|
return { reason: reason as PlacementRefusalReason, messageId };
|
|
46
47
|
};
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* What the user pressed. The same refusal answers a delete and a move, and the
|
|
51
|
+
* banner names the action they took rather than one they never asked for.
|
|
52
|
+
*/
|
|
53
|
+
export type PlacementRefusalAction = "delete" | "move";
|
|
54
|
+
|
|
48
55
|
/**
|
|
49
56
|
* The banner copy. Both reasons say what happened and what to do; neither is a
|
|
50
57
|
* dead end, and neither repeats the server's uuid at the user.
|
|
@@ -52,14 +59,15 @@ export const isPlacementRefusal = (
|
|
|
52
59
|
export const placementRefusalBanner = (
|
|
53
60
|
refusal: PlacementRefusal,
|
|
54
61
|
count: number,
|
|
62
|
+
action: PlacementRefusalAction,
|
|
55
63
|
): PushErrorInput => ({
|
|
56
64
|
severity: "warning",
|
|
57
65
|
title:
|
|
58
66
|
count > 1
|
|
59
|
-
? `Couldn't
|
|
60
|
-
:
|
|
67
|
+
? `Couldn't ${action} ${count} messages yet`
|
|
68
|
+
: `Couldn't ${action} this message yet`,
|
|
61
69
|
detail:
|
|
62
70
|
refusal.reason === "in_flight"
|
|
63
71
|
? "It is still being moved on the mail server. Try again in a moment."
|
|
64
|
-
:
|
|
72
|
+
: `An earlier move never finished, so where this message sits is unknown. Sync the folder, then ${action} it again.`,
|
|
65
73
|
});
|
|
@@ -32,15 +32,20 @@ import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
|
32
32
|
import { type HttpMock, mockFetch } from "../test-support/http";
|
|
33
33
|
import { useDeleteMessages } from "./useDeleteMessages";
|
|
34
34
|
import { type EmptyTrashState, useEmptyTrash } from "./useEmptyTrash";
|
|
35
|
+
import { useMoveMessages } from "./useMoveMessages";
|
|
35
36
|
import "@/lib/client";
|
|
36
37
|
|
|
37
38
|
const ACCOUNT = "acc-1";
|
|
38
39
|
const INBOX = "mbx-inbox";
|
|
39
40
|
const TRASH = "mbx-trash";
|
|
41
|
+
const ARCHIVE = "mbx-archive";
|
|
40
42
|
|
|
41
43
|
let harness: DomHarness | undefined;
|
|
42
44
|
let http: HttpMock | undefined;
|
|
43
45
|
let deleteMessages: ((messageIds: string[]) => void) | undefined;
|
|
46
|
+
let moveMessages:
|
|
47
|
+
| ((messageIds: string[], destinationMailboxId: string) => void)
|
|
48
|
+
| undefined;
|
|
44
49
|
let trash: EmptyTrashState | undefined;
|
|
45
50
|
|
|
46
51
|
afterEach(() => {
|
|
@@ -49,6 +54,7 @@ afterEach(() => {
|
|
|
49
54
|
http?.restore();
|
|
50
55
|
http = undefined;
|
|
51
56
|
deleteMessages = undefined;
|
|
57
|
+
moveMessages = undefined;
|
|
52
58
|
trash = undefined;
|
|
53
59
|
__resetFatalError();
|
|
54
60
|
});
|
|
@@ -77,6 +83,19 @@ const refusal = (): Response =>
|
|
|
77
83
|
{ status: 409, headers: { "content-type": "application/json" } },
|
|
78
84
|
);
|
|
79
85
|
|
|
86
|
+
/** The move gate's own coded 409: the row's folder and uid disagree (#665). */
|
|
87
|
+
const unsettled = (): Response =>
|
|
88
|
+
new Response(
|
|
89
|
+
JSON.stringify({
|
|
90
|
+
status: 409,
|
|
91
|
+
message:
|
|
92
|
+
"Message msg-1 was not acted on: its folder and uid do not name the same message",
|
|
93
|
+
code: "message_placement_unsettled",
|
|
94
|
+
details: { accountId: ACCOUNT, messageId: "msg-1", reason: "in_flight" },
|
|
95
|
+
}),
|
|
96
|
+
{ status: 409, headers: { "content-type": "application/json" } },
|
|
97
|
+
);
|
|
98
|
+
|
|
80
99
|
const broken = (): Response =>
|
|
81
100
|
new Response(JSON.stringify({ message: "boom" }), {
|
|
82
101
|
status: 500,
|
|
@@ -88,6 +107,10 @@ const Probe = () => {
|
|
|
88
107
|
mailboxId: INBOX,
|
|
89
108
|
accountId: ACCOUNT,
|
|
90
109
|
}).deleteMessages;
|
|
110
|
+
moveMessages = useMoveMessages({
|
|
111
|
+
mailboxId: INBOX,
|
|
112
|
+
accountId: ACCOUNT,
|
|
113
|
+
}).moveMessages;
|
|
91
114
|
trash = useEmptyTrash({ accountId: ACCOUNT, mailboxId: TRASH });
|
|
92
115
|
return null;
|
|
93
116
|
};
|
|
@@ -172,6 +195,39 @@ describe("a delete refused for its folder role", () => {
|
|
|
172
195
|
});
|
|
173
196
|
});
|
|
174
197
|
|
|
198
|
+
describe("a move refused for an unsettled placement", () => {
|
|
199
|
+
it("states the wait in a banner without taking the screen", async () => {
|
|
200
|
+
await mount(responder("/messages/move", unsettled));
|
|
201
|
+
|
|
202
|
+
await act(async () => {
|
|
203
|
+
moveMessages?.(["msg-1"], ARCHIVE);
|
|
204
|
+
});
|
|
205
|
+
await settle();
|
|
206
|
+
|
|
207
|
+
assert.equal(
|
|
208
|
+
getCurrentFatalError(),
|
|
209
|
+
null,
|
|
210
|
+
"a refusal the banner answers is not a fatal error",
|
|
211
|
+
);
|
|
212
|
+
assert.match(harness?.text() ?? "", /Couldn't move this message yet/);
|
|
213
|
+
assert.match(harness?.text() ?? "", /Try again in a moment/);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("still escalates a 500 on the same move", async () => {
|
|
217
|
+
await mount(responder("/messages/move", broken));
|
|
218
|
+
|
|
219
|
+
await act(async () => {
|
|
220
|
+
moveMessages?.(["msg-1"], ARCHIVE);
|
|
221
|
+
});
|
|
222
|
+
await settle();
|
|
223
|
+
|
|
224
|
+
assert.ok(
|
|
225
|
+
getCurrentFatalError(),
|
|
226
|
+
"the 409 opt-out must not cover anything else",
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
175
231
|
describe("an empty Trash refused for its folder role", () => {
|
|
176
232
|
it("states the refusal in the pane without taking the screen", async () => {
|
|
177
233
|
await mount(responder("/trash/empty", refusal));
|
|
@@ -186,7 +186,7 @@ export const useDeleteMessages = ({
|
|
|
186
186
|
const count = vars.body.messageIds?.length ?? 0;
|
|
187
187
|
const placement = isPlacementRefusal(err);
|
|
188
188
|
if (placement) {
|
|
189
|
-
pushError(placementRefusalBanner(placement, count));
|
|
189
|
+
pushError(placementRefusalBanner(placement, count, "delete"));
|
|
190
190
|
return;
|
|
191
191
|
}
|
|
192
192
|
pushError({
|
|
@@ -8,7 +8,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
|
8
8
|
import { useCallback } from "react";
|
|
9
9
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
10
10
|
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
11
|
+
import {
|
|
12
|
+
isPlacementRefusal,
|
|
13
|
+
placementRefusalBanner,
|
|
14
|
+
} from "@/components/ui/placement-refusal";
|
|
11
15
|
import { runChunkedMutation } from "@/lib/bulk-actions";
|
|
16
|
+
import { softErrorStatuses } from "@/lib/error-classifier";
|
|
12
17
|
import {
|
|
13
18
|
cancelThreadListQueries,
|
|
14
19
|
invalidateThreadListQueries,
|
|
@@ -57,6 +62,10 @@ export const useMoveMessages = ({
|
|
|
57
62
|
|
|
58
63
|
const { mutateAsync, isPending, isError } = useMutation({
|
|
59
64
|
...messageBulkOperationsMoveMessagesMutation(),
|
|
65
|
+
// A coded 409 is answered here — a banner — so it must not also take the
|
|
66
|
+
// whole screen. Only 409: a 401, 403 or 5xx on a move is still the fatal
|
|
67
|
+
// page's (#1059).
|
|
68
|
+
meta: softErrorStatuses(409),
|
|
60
69
|
onMutate: async (variables): Promise<ThreadMutationContext> => {
|
|
61
70
|
const messageIds = new Set(variables.body.messageIds ?? []);
|
|
62
71
|
|
|
@@ -136,6 +145,13 @@ export const useMoveMessages = ({
|
|
|
136
145
|
restoreThreadListQueries(queryClient, context.previousThreadsList);
|
|
137
146
|
}
|
|
138
147
|
const count = vars.body.messageIds?.length ?? 0;
|
|
148
|
+
// A placement refusal is a wait or a resync, not a failure to report as
|
|
149
|
+
// one: the move was never made, and the banner says which.
|
|
150
|
+
const placement = isPlacementRefusal(err);
|
|
151
|
+
if (placement) {
|
|
152
|
+
pushError(placementRefusalBanner(placement, count, "move"));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
139
155
|
pushError({
|
|
140
156
|
title:
|
|
141
157
|
count > 1
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
-
import { useCallback } from "react";
|
|
2
|
+
import { useCallback, useMemo } from "react";
|
|
3
3
|
import { useMailContext } from "@/lib/mail-context";
|
|
4
4
|
import {
|
|
5
5
|
SEARCH_SCOPE_CHIP_ID,
|
|
@@ -26,7 +26,10 @@ export function useSearchScope(accounts: RemitImapAccountResponse[]): {
|
|
|
26
26
|
const { searchInput } = useMailContext();
|
|
27
27
|
const mailboxName = useCurrentMailboxName({ accounts });
|
|
28
28
|
const browsed = useBrowsedList();
|
|
29
|
-
const scope =
|
|
29
|
+
const scope = useMemo(
|
|
30
|
+
() => searchScopeForRoute(browsed, mailboxName),
|
|
31
|
+
[browsed, mailboxName],
|
|
32
|
+
);
|
|
30
33
|
|
|
31
34
|
// Takes the chip id the field removed rather than assuming which chip that
|
|
32
35
|
// was. The bar owns one chip today; keying on the id means a second one
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { SearchConversion } from "@remit/ui";
|
|
2
1
|
import { createContext, type ReactNode, useContext } from "react";
|
|
3
2
|
|
|
4
3
|
/**
|
|
@@ -11,6 +10,11 @@ import { createContext, type ReactNode, useContext } from "react";
|
|
|
11
10
|
* Handing the built nodes down keeps one definition of the chrome and keeps the
|
|
12
11
|
* bar's two states in the same commit: nothing here is derived from a state
|
|
13
12
|
* update that lands after paint.
|
|
13
|
+
*
|
|
14
|
+
* The body is a virtualized list, so it re-renders every row when this object
|
|
15
|
+
* changes. Nothing that moves with a keystroke belongs on it (#506) — what is
|
|
16
|
+
* typed, where the caret is and what the query converts to reach their readers
|
|
17
|
+
* through their own contexts.
|
|
14
18
|
*/
|
|
15
19
|
export interface ListHeaderChrome {
|
|
16
20
|
/** The view's name, carried while nothing is ticked. */
|
|
@@ -32,15 +36,6 @@ export interface ListHeaderChrome {
|
|
|
32
36
|
searchResults: ReactNode;
|
|
33
37
|
/** The search's make-this-a-filter row, up only while nothing is ticked. */
|
|
34
38
|
makeFilterSlot: ReactNode;
|
|
35
|
-
/**
|
|
36
|
-
* The active query as clauses, which is what the wizard's search entry opens
|
|
37
|
-
* on (#484). It travels with the chrome for the same reason the rest does:
|
|
38
|
-
* the query belongs to `MailListHeader` and the wizard is mounted by the body
|
|
39
|
-
* below it, and one conversion computed in one place is what keeps the reason
|
|
40
|
-
* the affordance gives and the clauses the wizard seeds from disagreeing.
|
|
41
|
-
* Absent when no query is up.
|
|
42
|
-
*/
|
|
43
|
-
searchConversion?: SearchConversion;
|
|
44
39
|
}
|
|
45
40
|
|
|
46
41
|
const NO_CHROME: ListHeaderChrome = {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SearchConversion } from "@remit/ui";
|
|
2
|
+
import { createContext, useContext } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The active query as clauses, which is what the wizard's search entry opens on
|
|
6
|
+
* (#484). One conversion, computed once in `MailListHeader`, so the reason the
|
|
7
|
+
* make-this-a-filter affordance gives and the clauses the wizard seeds from
|
|
8
|
+
* cannot disagree.
|
|
9
|
+
*
|
|
10
|
+
* Its own context rather than a field on the list header chrome (#506): the
|
|
11
|
+
* conversion is derived from the query text, so it is a new object on every
|
|
12
|
+
* keystroke, and carrying it on the chrome meant the chrome was too — which
|
|
13
|
+
* re-rendered every row of the virtualized list for a character the list does
|
|
14
|
+
* not show. The wizard is the only reader, and it is mounted once.
|
|
15
|
+
*
|
|
16
|
+
* Absent when no query is up.
|
|
17
|
+
*/
|
|
18
|
+
export const SearchConversionContext = createContext<
|
|
19
|
+
SearchConversion | undefined
|
|
20
|
+
>(undefined);
|
|
21
|
+
|
|
22
|
+
export const useSearchConversion = (): SearchConversion | undefined =>
|
|
23
|
+
useContext(SearchConversionContext);
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
useParams,
|
|
5
5
|
useRouterState,
|
|
6
6
|
} from "@tanstack/react-router";
|
|
7
|
-
import { useCallback } from "react";
|
|
7
|
+
import { useCallback, useMemo } from "react";
|
|
8
8
|
import {
|
|
9
9
|
type BrowsedList,
|
|
10
10
|
locationOpensDetail,
|
|
@@ -19,13 +19,16 @@ export type { BrowsedList };
|
|
|
19
19
|
*
|
|
20
20
|
* Every surface a list can open — compose, a reply, a conversation — reads the
|
|
21
21
|
* same answer, so the four lists cannot disagree about which one is underneath.
|
|
22
|
+
*
|
|
23
|
+
* The same object until the address moves, so a caller can memoize on it.
|
|
22
24
|
*/
|
|
23
25
|
export function useBrowsedList(): BrowsedList {
|
|
24
26
|
const list = useRouterState({
|
|
25
27
|
select: (state) => mailListRoute(state.matches)?.list,
|
|
26
28
|
});
|
|
27
29
|
const mailbox = useParams({ from: "/mail/$mailboxId", shouldThrow: false });
|
|
28
|
-
|
|
30
|
+
const mailboxId = mailbox?.mailboxId;
|
|
31
|
+
return useMemo(() => ({ list, mailboxId }), [list, mailboxId]);
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
/**
|