@remit/web-client 0.0.144 → 0.0.145
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/layout/MailShell.tsx +98 -0
- package/src/components/mail/BriefPane.tsx +5 -5
- package/src/components/mail/FlaggedPane.tsx +1 -1
- package/src/components/mail/MailSidebarAdapter.tsx +5 -8
- package/src/components/mail/MailboxPane.tsx +5 -5
- package/src/components/ui/FatalErrorOverlay.tsx +4 -1
- package/src/hooks/useSearchMirror.ts +93 -0
- package/src/hooks/useSearchScope.ts +1 -1
- package/src/lib/compose-routes.test.ts +3 -1
- package/src/lib/compose-routes.ts +1 -1
- package/src/lib/mail-route.test.ts +157 -57
- package/src/lib/mail-route.ts +71 -38
- package/src/lib/mail-search.ts +51 -0
- package/src/lib/route-search-query.test.ts +50 -28
- package/src/lib/search-scope.ts +11 -19
- package/src/lib/search-view.test.ts +142 -38
- package/src/lib/search-view.ts +35 -11
- package/src/routeTree.gen.ts +155 -18
- package/src/router.tsx +17 -0
- package/src/routes/index.tsx +1 -1
- package/src/routes/mail/$mailboxId/index.tsx +10 -0
- package/src/routes/mail/$mailboxId.tsx +27 -19
- package/src/routes/mail/brief/index.tsx +14 -0
- package/src/routes/mail/brief.tsx +50 -0
- package/src/routes/mail/flagged/index.tsx +10 -0
- package/src/routes/mail/flagged.tsx +25 -13
- package/src/routes/mail/index.tsx +9 -36
- package/src/routes/mail/outbox/index.tsx +10 -0
- package/src/routes/mail/outbox.tsx +23 -13
- package/src/routes/mail.tsx +35 -246
- package/src/test-support/list-search-binding.ts +34 -0
|
@@ -1,18 +1,28 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* /mail/outbox — one of the four list layouts. Two panes, list and reading;
|
|
3
|
+
* there is no intelligence rail for mail that has not been sent yet.
|
|
4
|
+
*/
|
|
5
|
+
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
|
6
|
+
import { MailShell } from "@/components/layout/MailShell";
|
|
7
|
+
import { OutboxPane } from "@/components/mail/OutboxPane";
|
|
8
|
+
import { useSearchMirror } from "@/hooks/useSearchMirror";
|
|
9
|
+
import { outboxSearchSchema } from "@/lib/mail-search";
|
|
3
10
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
function OutboxLayout() {
|
|
12
|
+
useSearchMirror({ to: "/mail/outbox" });
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<OutboxPane>
|
|
16
|
+
<MailShell
|
|
17
|
+
phone={<OutboxPane.Phone />}
|
|
18
|
+
list={<OutboxPane.List />}
|
|
19
|
+
reading={<Outlet />}
|
|
20
|
+
/>
|
|
21
|
+
</OutboxPane>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
14
24
|
|
|
15
25
|
export const Route = createFileRoute("/mail/outbox")({
|
|
16
|
-
component:
|
|
26
|
+
component: OutboxLayout,
|
|
17
27
|
validateSearch: outboxSearchSchema,
|
|
18
28
|
});
|
package/src/routes/mail.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
configOperationsGetConfigOptions,
|
|
3
3
|
unifiedThreadOperationsListAllThreadsOptions,
|
|
4
4
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { useTriageKeyboard } from "@remit/ui";
|
|
6
6
|
import { useQuery } from "@tanstack/react-query";
|
|
7
7
|
import {
|
|
8
8
|
createFileRoute,
|
|
@@ -11,17 +11,13 @@ import {
|
|
|
11
11
|
useRouterState,
|
|
12
12
|
useSearch,
|
|
13
13
|
} from "@tanstack/react-router";
|
|
14
|
-
import { useCallback, useEffect, useMemo,
|
|
14
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
15
15
|
import { z } from "zod";
|
|
16
16
|
import { useCompose } from "@/components/compose/ComposeProvider";
|
|
17
|
-
import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
|
|
18
17
|
import { ComposeFab } from "@/components/layout/ComposeFab";
|
|
18
|
+
import { MailShellProvider } from "@/components/layout/MailShell";
|
|
19
19
|
import { MailTopBar } from "@/components/layout/MailTopBar";
|
|
20
|
-
import { BriefPane } from "@/components/mail/BriefPane";
|
|
21
|
-
import { FlaggedPane } from "@/components/mail/FlaggedPane";
|
|
22
|
-
import { MailboxPane } from "@/components/mail/MailboxPane";
|
|
23
20
|
import { MailNav } from "@/components/mail/MailNav";
|
|
24
|
-
import { OutboxPane } from "@/components/mail/OutboxPane";
|
|
25
21
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
26
22
|
import { KeyboardShortcutsModal } from "@/components/ui/KeyboardShortcutsModal";
|
|
27
23
|
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
|
@@ -34,18 +30,9 @@ import { hostsComposeSurface } from "@/lib/compose-routes";
|
|
|
34
30
|
import { writeIntelligencePref } from "@/lib/intelligence-pref";
|
|
35
31
|
import { MailContext } from "@/lib/mail-context";
|
|
36
32
|
import { MailFreshnessProvider } from "@/lib/mail-freshness";
|
|
37
|
-
import {
|
|
38
|
-
isBriefRoute,
|
|
39
|
-
isFlaggedRoute,
|
|
40
|
-
isOutboxRoute,
|
|
41
|
-
mailViewKey,
|
|
42
|
-
} from "@/lib/mail-route";
|
|
33
|
+
import { mailViewKey } from "@/lib/mail-route";
|
|
43
34
|
import { buildAccountNameIndex } from "@/lib/search-token-index";
|
|
44
|
-
import {
|
|
45
|
-
committedSearchQuery,
|
|
46
|
-
searchInputForView,
|
|
47
|
-
shouldMirrorQuery,
|
|
48
|
-
} from "@/lib/search-view";
|
|
35
|
+
import { committedSearchQuery, searchInputForView } from "@/lib/search-view";
|
|
49
36
|
import { wizardEntryValue, wizardStepValue } from "@/lib/wizard-history";
|
|
50
37
|
import "@/lib/client";
|
|
51
38
|
|
|
@@ -85,12 +72,12 @@ function MailLayout() {
|
|
|
85
72
|
const { q: searchQuery = "" } = useSearch({ from: "/mail" });
|
|
86
73
|
const navigate = useNavigate();
|
|
87
74
|
const tier = useLayoutTier();
|
|
88
|
-
// Below the reading boundary (phone AND tablet)
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
75
|
+
// Below the reading boundary (phone AND tablet) the shell shows a SINGLE
|
|
76
|
+
// pane — there is no reading pane to host the thread or the compose surface.
|
|
77
|
+
// So both tiers use the single-pane view, which swaps the pane in place
|
|
78
|
+
// between list, open thread, and compose. Keying this off "phone" alone left
|
|
79
|
+
// tablet with no compose surface (compose lives in the reading pane, which
|
|
80
|
+
// tablet doesn't mount) — the "c" shortcut / FAB opened nothing.
|
|
94
81
|
const isSinglePane = isSinglePaneTier(tier);
|
|
95
82
|
const [showShortcuts, setShowShortcuts] = useState(false);
|
|
96
83
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
@@ -108,8 +95,8 @@ function MailLayout() {
|
|
|
108
95
|
|
|
109
96
|
// Within one view, URL `q` seeds the input and is a one-directional write
|
|
110
97
|
// target: the debounced local value drives the search API and is mirrored
|
|
111
|
-
// back
|
|
112
|
-
// below and `lib/search-view.ts` (#47).
|
|
98
|
+
// back by the list route's own `useSearchMirror`. Across views the URL wins
|
|
99
|
+
// again — see the view-change adjustment below and `lib/search-view.ts` (#47).
|
|
113
100
|
const [searchInput, setSearchInput] = useState(searchQuery);
|
|
114
101
|
const debouncedSearchInput = useDebouncedValue(searchInput, 200);
|
|
115
102
|
const committedQuery = committedSearchQuery(
|
|
@@ -117,23 +104,20 @@ function MailLayout() {
|
|
|
117
104
|
debouncedSearchInput,
|
|
118
105
|
);
|
|
119
106
|
|
|
120
|
-
const searchQueryRef = useRef(searchQuery);
|
|
121
|
-
searchQueryRef.current = searchQuery;
|
|
122
|
-
|
|
123
107
|
// Search is a mode of the view it was typed in, so leaving that view re-seeds
|
|
124
108
|
// the field from wherever we land (#47): empty when the sidebar dropped `q`
|
|
125
109
|
// (a folder switch starts that folder's search fresh), and the carried query
|
|
126
110
|
// when the top bar's scope chip was removed and sent the user to the brief to
|
|
127
|
-
// search everything. Views that differ only in
|
|
128
|
-
//
|
|
129
|
-
// them (`searchInputForView`).
|
|
111
|
+
// search everything. Views that differ only in what is open below the list —
|
|
112
|
+
// a thread, a mirrored `q` — are the same view, so in-flight typing survives
|
|
113
|
+
// them (`searchInputForView`, `mailViewKey`).
|
|
130
114
|
//
|
|
131
115
|
// Adjusted during render, not in an effect. This is React's documented
|
|
132
116
|
// "adjusting state when a prop changes" pattern: both updates are to this
|
|
133
117
|
// component's own state and are guarded by a changed value, so React re-runs
|
|
134
118
|
// the render before committing and nothing is painted with the stale query.
|
|
135
119
|
// An effect would commit one frame carrying the previous view's text, which
|
|
136
|
-
// the mirror
|
|
120
|
+
// the mirror then has to be defended against.
|
|
137
121
|
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
|
138
122
|
const viewKey = useRouterState({ select: (s) => mailViewKey(s.matches) });
|
|
139
123
|
const [searchViewKey, setSearchViewKey] = useState(viewKey);
|
|
@@ -143,44 +127,6 @@ function MailLayout() {
|
|
|
143
127
|
if (seeded !== undefined) setSearchInput(seeded);
|
|
144
128
|
}
|
|
145
129
|
|
|
146
|
-
// Mirror the settled search into the URL so links are shareable and a
|
|
147
|
-
// refresh restores the query. Within a view this is one-directional — the
|
|
148
|
-
// URL is not read back into state, so there is no sync loop — and it writes
|
|
149
|
-
// only once the debounce agrees with the field, so a query arriving by URL
|
|
150
|
-
// is never overwritten mid-debounce, and the query the user just left behind
|
|
151
|
-
// is never written onto the view they landed on (`shouldMirrorQuery`).
|
|
152
|
-
// When a query *goes* active, also strip selectedMessageId so the reading
|
|
153
|
-
// pane closes (#539): an open message from the pre-search list is not
|
|
154
|
-
// meaningful in the search result set. Only strip on that transition though —
|
|
155
|
-
// tapping a search result commits the same `q` with the selection, so when
|
|
156
|
-
// `prev.q` already equals the query the result was opened under it (not a
|
|
157
|
-
// pre-search leftover) and must survive. The strip otherwise raced the tap:
|
|
158
|
-
// the row shows before the debounce settles, so this mirror can land just
|
|
159
|
-
// after the open and close it again.
|
|
160
|
-
useEffect(() => {
|
|
161
|
-
if (!shouldMirrorQuery(searchInput, committedQuery, searchQueryRef.current))
|
|
162
|
-
return;
|
|
163
|
-
navigate({
|
|
164
|
-
to: ".",
|
|
165
|
-
search: (prev) => {
|
|
166
|
-
const queryAlreadyActive =
|
|
167
|
-
(prev as { q?: string }).q === committedQuery;
|
|
168
|
-
return {
|
|
169
|
-
...prev,
|
|
170
|
-
q: committedQuery || undefined,
|
|
171
|
-
...(committedQuery && !queryAlreadyActive
|
|
172
|
-
? {
|
|
173
|
-
selectedMessageId: undefined,
|
|
174
|
-
selectedThreadId: undefined,
|
|
175
|
-
selectedMailboxId: undefined,
|
|
176
|
-
}
|
|
177
|
-
: {}),
|
|
178
|
-
};
|
|
179
|
-
},
|
|
180
|
-
replace: true,
|
|
181
|
-
});
|
|
182
|
-
}, [searchInput, committedQuery, navigate]);
|
|
183
|
-
|
|
184
130
|
const {
|
|
185
131
|
data: config,
|
|
186
132
|
isLoading,
|
|
@@ -235,8 +181,8 @@ function MailLayout() {
|
|
|
235
181
|
setSearchInput(query);
|
|
236
182
|
}, []);
|
|
237
183
|
|
|
238
|
-
// Clears the search field; the
|
|
239
|
-
// the debounce settles.
|
|
184
|
+
// Clears the search field; the list route's mirror drops `q` from the URL
|
|
185
|
+
// after the debounce settles.
|
|
240
186
|
const handleSearchClear = useCallback(() => {
|
|
241
187
|
setSearchInput("");
|
|
242
188
|
}, []);
|
|
@@ -266,43 +212,6 @@ function MailLayout() {
|
|
|
266
212
|
[accounts],
|
|
267
213
|
);
|
|
268
214
|
|
|
269
|
-
// Read the current mailbox params and selected message (if any) from the
|
|
270
|
-
// child route so the parent shell can mount the right pane (brief / mailbox /
|
|
271
|
-
// outbox) and forward the open thread into it.
|
|
272
|
-
//
|
|
273
|
-
// `useParams({ strict: false })` resolves against the *nearest* route match
|
|
274
|
-
// in the React component tree — which for this parent layout is /mail (no
|
|
275
|
-
// path params). The child's $mailboxId param never appears there, so
|
|
276
|
-
// mobileMailboxId would always be undefined and the navigate guard would
|
|
277
|
-
// short-circuit every tap. Use `useRouterState` instead: it exposes all
|
|
278
|
-
// currently matched routes, letting us find the one that carries mailboxId.
|
|
279
|
-
const mobileMailboxId = useRouterState({
|
|
280
|
-
select: (s) => {
|
|
281
|
-
const match = s.matches.find(
|
|
282
|
-
(m): m is typeof m & { params: { mailboxId: string } } =>
|
|
283
|
-
"mailboxId" in m.params,
|
|
284
|
-
);
|
|
285
|
-
return match?.params.mailboxId;
|
|
286
|
-
},
|
|
287
|
-
});
|
|
288
|
-
const { selectedMessageId: mobileSelectedMessageId } = useSearch({
|
|
289
|
-
strict: false,
|
|
290
|
-
}) as { selectedMessageId?: string };
|
|
291
|
-
|
|
292
|
-
// Detect which leaf route is active by its own routeId — never the parent
|
|
293
|
-
// /mail layout's pathname (which is "/mail" on EVERY child route and would
|
|
294
|
-
// wrongly route every mailbox through the brief pane, dropping message rows).
|
|
295
|
-
// See lib/mail-route.ts for the contract + regression test.
|
|
296
|
-
const onBriefRoute = useRouterState({
|
|
297
|
-
select: (s) => isBriefRoute(s.matches),
|
|
298
|
-
});
|
|
299
|
-
const onFlaggedRoute = useRouterState({
|
|
300
|
-
select: (s) => isFlaggedRoute(s.matches),
|
|
301
|
-
});
|
|
302
|
-
const onOutboxRoute = useRouterState({
|
|
303
|
-
select: (s) => isOutboxRoute(s.matches),
|
|
304
|
-
});
|
|
305
|
-
|
|
306
215
|
// Auto-trigger a mailbox-list sync for any account whose lastSyncAt is
|
|
307
216
|
// older than 15 minutes (or unset). Fires once per accountId per session
|
|
308
217
|
// — re-mounts of MailLayout will not retrigger. See #205.
|
|
@@ -337,17 +246,18 @@ function MailLayout() {
|
|
|
337
246
|
// own slide-over Dialog (narrow), never both — so there is exactly one
|
|
338
247
|
// "Mailboxes" nav landmark at any width. `MailNav` adds the mobile
|
|
339
248
|
// Settings + bug-report footer when it detects the slide-over context.
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
249
|
+
const shellChrome = {
|
|
250
|
+
isSinglePane,
|
|
251
|
+
isLoading: isLoading || hasNoAccounts,
|
|
252
|
+
intelligenceOpen,
|
|
253
|
+
nav: <MailNav accounts={accounts} onMailboxSelect={handleMailboxSelect} />,
|
|
254
|
+
// Desktop only. Below 1024px the single pane keeps its own header search
|
|
255
|
+
// and the phone takeover; there is no room for a bar spanning panes that do
|
|
256
|
+
// not exist side by side.
|
|
257
|
+
topBar: isSinglePane ? undefined : <MailTopBar accounts={accounts} />,
|
|
258
|
+
// Single-pane only, where the FAB is the compose entry point. Above it the
|
|
259
|
+
// top bar owns compose.
|
|
260
|
+
overlay: isSinglePane ? <ComposeFab /> : undefined,
|
|
351
261
|
navOpen: drawerOpen,
|
|
352
262
|
onOpenNav: () => setDrawerOpen(true),
|
|
353
263
|
onCloseNav: () => setDrawerOpen(false),
|
|
@@ -366,137 +276,16 @@ function MailLayout() {
|
|
|
366
276
|
}}
|
|
367
277
|
/>
|
|
368
278
|
</div>
|
|
369
|
-
) : onBriefRoute ? (
|
|
370
|
-
// Daily brief (/mail/) — no mailboxId param; same 3-pane layout as a
|
|
371
|
-
// mailbox: an open message has an intelligence rail here too (#52).
|
|
372
|
-
<BriefPane selectedMessageId={mobileSelectedMessageId}>
|
|
373
|
-
{isSinglePane ? (
|
|
374
|
-
<AppShellSlotted
|
|
375
|
-
nav={navContent}
|
|
376
|
-
list={<BriefPane.Phone />}
|
|
377
|
-
intelligenceOpen={intelligenceOpen}
|
|
378
|
-
overlay={overlayContent}
|
|
379
|
-
skeleton={<AppShellSkeleton />}
|
|
380
|
-
isLoading={isLoading || hasNoAccounts}
|
|
381
|
-
{...navSlideOver}
|
|
382
|
-
/>
|
|
383
|
-
) : (
|
|
384
|
-
<AppShellSlotted
|
|
385
|
-
nav={navContent}
|
|
386
|
-
topBar={topBar}
|
|
387
|
-
list={<BriefPane.List />}
|
|
388
|
-
reading={<BriefPane.Reading />}
|
|
389
|
-
intelligence={<BriefPane.Intelligence />}
|
|
390
|
-
intelligenceOpen={intelligenceOpen}
|
|
391
|
-
hasThread={Boolean(mobileSelectedMessageId)}
|
|
392
|
-
overlay={overlayContent}
|
|
393
|
-
skeleton={<AppShellSkeleton />}
|
|
394
|
-
isLoading={isLoading || hasNoAccounts}
|
|
395
|
-
{...navSlideOver}
|
|
396
|
-
/>
|
|
397
|
-
)}
|
|
398
|
-
</BriefPane>
|
|
399
|
-
) : onFlaggedRoute ? (
|
|
400
|
-
// Flagged virtual mailbox (/mail/flagged) — flat starred list across
|
|
401
|
-
// accounts; same slots as the brief, intelligence rail included.
|
|
402
|
-
<FlaggedPane selectedMessageId={mobileSelectedMessageId}>
|
|
403
|
-
{isSinglePane ? (
|
|
404
|
-
<AppShellSlotted
|
|
405
|
-
nav={navContent}
|
|
406
|
-
list={<FlaggedPane.Phone />}
|
|
407
|
-
intelligenceOpen={intelligenceOpen}
|
|
408
|
-
overlay={overlayContent}
|
|
409
|
-
skeleton={<AppShellSkeleton />}
|
|
410
|
-
isLoading={isLoading || hasNoAccounts}
|
|
411
|
-
{...navSlideOver}
|
|
412
|
-
/>
|
|
413
|
-
) : (
|
|
414
|
-
<AppShellSlotted
|
|
415
|
-
nav={navContent}
|
|
416
|
-
topBar={topBar}
|
|
417
|
-
list={<FlaggedPane.List />}
|
|
418
|
-
reading={<FlaggedPane.Reading />}
|
|
419
|
-
intelligence={<FlaggedPane.Intelligence />}
|
|
420
|
-
intelligenceOpen={intelligenceOpen}
|
|
421
|
-
hasThread={Boolean(mobileSelectedMessageId)}
|
|
422
|
-
overlay={overlayContent}
|
|
423
|
-
skeleton={<AppShellSkeleton />}
|
|
424
|
-
isLoading={isLoading || hasNoAccounts}
|
|
425
|
-
{...navSlideOver}
|
|
426
|
-
/>
|
|
427
|
-
)}
|
|
428
|
-
</FlaggedPane>
|
|
429
|
-
) : mobileMailboxId ? (
|
|
430
|
-
// Mailbox view (/mail/$mailboxId) — full 4-pane layout.
|
|
431
|
-
<MailboxPane
|
|
432
|
-
mailboxId={mobileMailboxId}
|
|
433
|
-
selectedMessageId={mobileSelectedMessageId}
|
|
434
|
-
>
|
|
435
|
-
{isSinglePane ? (
|
|
436
|
-
<AppShellSlotted
|
|
437
|
-
nav={navContent}
|
|
438
|
-
list={<MailboxPane.Phone />}
|
|
439
|
-
intelligenceOpen={intelligenceOpen}
|
|
440
|
-
overlay={overlayContent}
|
|
441
|
-
skeleton={<AppShellSkeleton />}
|
|
442
|
-
isLoading={isLoading || hasNoAccounts}
|
|
443
|
-
{...navSlideOver}
|
|
444
|
-
/>
|
|
445
|
-
) : (
|
|
446
|
-
<AppShellSlotted
|
|
447
|
-
nav={navContent}
|
|
448
|
-
topBar={topBar}
|
|
449
|
-
list={<MailboxPane.List />}
|
|
450
|
-
reading={<MailboxPane.Reading />}
|
|
451
|
-
intelligence={<MailboxPane.Intelligence />}
|
|
452
|
-
intelligenceOpen={intelligenceOpen}
|
|
453
|
-
hasThread={Boolean(mobileSelectedMessageId)}
|
|
454
|
-
overlay={overlayContent}
|
|
455
|
-
skeleton={<AppShellSkeleton />}
|
|
456
|
-
isLoading={isLoading || hasNoAccounts}
|
|
457
|
-
{...navSlideOver}
|
|
458
|
-
/>
|
|
459
|
-
)}
|
|
460
|
-
</MailboxPane>
|
|
461
|
-
) : onOutboxRoute ? (
|
|
462
|
-
// Outbox — 2-pane layout (list + reading, no intelligence).
|
|
463
|
-
<OutboxPane>
|
|
464
|
-
{isSinglePane ? (
|
|
465
|
-
<AppShellSlotted
|
|
466
|
-
nav={navContent}
|
|
467
|
-
list={<OutboxPane.Phone />}
|
|
468
|
-
intelligenceOpen={false}
|
|
469
|
-
overlay={overlayContent}
|
|
470
|
-
skeleton={<AppShellSkeleton />}
|
|
471
|
-
isLoading={isLoading || hasNoAccounts}
|
|
472
|
-
{...navSlideOver}
|
|
473
|
-
/>
|
|
474
|
-
) : (
|
|
475
|
-
<AppShellSlotted
|
|
476
|
-
nav={navContent}
|
|
477
|
-
topBar={topBar}
|
|
478
|
-
list={<OutboxPane.List />}
|
|
479
|
-
reading={<OutboxPane.Reading />}
|
|
480
|
-
intelligenceOpen={false}
|
|
481
|
-
overlay={overlayContent}
|
|
482
|
-
skeleton={<AppShellSkeleton />}
|
|
483
|
-
isLoading={isLoading || hasNoAccounts}
|
|
484
|
-
{...navSlideOver}
|
|
485
|
-
/>
|
|
486
|
-
)}
|
|
487
|
-
</OutboxPane>
|
|
488
279
|
) : (
|
|
489
|
-
//
|
|
490
|
-
<
|
|
280
|
+
// Each list route mounts its own shell around its own panes.
|
|
281
|
+
<MailShellProvider chrome={shellChrome}>
|
|
282
|
+
<Outlet />
|
|
283
|
+
</MailShellProvider>
|
|
491
284
|
)}
|
|
492
285
|
<KeyboardShortcutsModal
|
|
493
286
|
isOpen={showShortcuts}
|
|
494
287
|
onClose={() => setShowShortcuts(false)}
|
|
495
288
|
/>
|
|
496
|
-
{/* Outlet is required for TanStack Router to activate child routes.
|
|
497
|
-
Routes that manage their own rendering (brief, mailbox, outbox) return
|
|
498
|
-
null from their component — the parent shell owns the layout. */}
|
|
499
|
-
<Outlet />
|
|
500
289
|
</MailFreshnessProvider>
|
|
501
290
|
</MailContext.Provider>
|
|
502
291
|
);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asserts that each mail list route validates its search with the schema
|
|
3
|
+
* `lib/mail-search.ts` declares for that path. Wire a list to another list's
|
|
4
|
+
* schema and it fails here.
|
|
5
|
+
*
|
|
6
|
+
* It runs as its own process, spawned by `lib/route-search-query.test.ts`.
|
|
7
|
+
* Importing a route file pulls in the whole pane it mounts, and every module a
|
|
8
|
+
* test loads counts in the coverage denominator — reading `validateSearch`
|
|
9
|
+
* in-process dropped web-client's line coverage by ten points with nothing
|
|
10
|
+
* rendered to earn it back.
|
|
11
|
+
*/
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { mailListSearchSchemas } from "@/lib/mail-search";
|
|
14
|
+
import { Route as MailboxRoute } from "@/routes/mail/$mailboxId";
|
|
15
|
+
import { Route as BriefRoute } from "@/routes/mail/brief";
|
|
16
|
+
import { Route as FlaggedRoute } from "@/routes/mail/flagged";
|
|
17
|
+
import { Route as OutboxRoute } from "@/routes/mail/outbox";
|
|
18
|
+
|
|
19
|
+
const routes = {
|
|
20
|
+
"/mail/brief": BriefRoute,
|
|
21
|
+
"/mail/flagged": FlaggedRoute,
|
|
22
|
+
"/mail/outbox": OutboxRoute,
|
|
23
|
+
"/mail/$mailboxId": MailboxRoute,
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
for (const [path, route] of Object.entries(routes)) {
|
|
27
|
+
const declared =
|
|
28
|
+
mailListSearchSchemas[path as keyof typeof mailListSearchSchemas];
|
|
29
|
+
assert.equal(
|
|
30
|
+
route.options.validateSearch,
|
|
31
|
+
declared,
|
|
32
|
+
`${path} does not validate its search with the schema declared for it`,
|
|
33
|
+
);
|
|
34
|
+
}
|