@remit/web-client 0.0.144 → 0.0.146
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/compose/ComposeForm.tsx +169 -120
- package/src/components/compose/ComposeSmtpMissingBanner.tsx +4 -0
- package/src/components/compose/mobile-header-stays-expanded.render.test.ts +182 -0
- 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
package/src/lib/mail-route.ts
CHANGED
|
@@ -1,61 +1,94 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Which list the router is showing, and whether a location still sits on one.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `/mail`
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* pathname routed every mailbox through the brief pane, which renders the
|
|
10
|
-
* unified DailyBrief instead of the mailbox MessageList — so the message
|
|
11
|
-
* rows (the `a[href*=selectedMessageId]` anchors) vanished. These pure
|
|
12
|
-
* predicates pin that contract.
|
|
4
|
+
* Every mail URL is a list plus, below it, whatever that list has open. The
|
|
5
|
+
* list is a layout route, so it is named by a matched route id: the parent
|
|
6
|
+
* `/mail` match carries the pathname "/mail" on every child, and the deepest
|
|
7
|
+
* match is the thread. A thread opened from the brief and the brief itself
|
|
8
|
+
* resolve to the same list.
|
|
13
9
|
*/
|
|
14
10
|
|
|
15
|
-
/** A matched route, minimal shape needed
|
|
11
|
+
/** A matched route, minimal shape needed to identify the list. */
|
|
16
12
|
export interface MailRouteMatch {
|
|
17
13
|
routeId: string;
|
|
18
14
|
params?: Record<string, string | undefined>;
|
|
19
15
|
}
|
|
20
16
|
|
|
21
|
-
/** The
|
|
22
|
-
export const MAIL_BRIEF_ROUTE_ID = "/mail/" as const;
|
|
17
|
+
/** The list layout route ids. */
|
|
18
|
+
export const MAIL_BRIEF_ROUTE_ID = "/mail/brief" as const;
|
|
23
19
|
export const MAIL_MAILBOX_ROUTE_ID = "/mail/$mailboxId" as const;
|
|
24
20
|
export const MAIL_OUTBOX_ROUTE_ID = "/mail/outbox" as const;
|
|
25
21
|
export const MAIL_FLAGGED_ROUTE_ID = "/mail/flagged" as const;
|
|
26
22
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
23
|
+
/**
|
|
24
|
+
* The list a location is browsing. A mailbox route carries its id, which is
|
|
25
|
+
* absent only in the frame before the router has resolved the param.
|
|
26
|
+
*/
|
|
27
|
+
export type MailListRoute =
|
|
28
|
+
| { list: "brief" }
|
|
29
|
+
| { list: "flagged" }
|
|
30
|
+
| { list: "outbox" }
|
|
31
|
+
| { list: "mailbox"; mailboxId: string | undefined };
|
|
31
32
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
/**
|
|
34
|
+
* The list layout among the matched routes, or `undefined` outside the mail
|
|
35
|
+
* shell.
|
|
36
|
+
*
|
|
37
|
+
* Reads the shallowest list match, so anything mounted under a list — the open
|
|
38
|
+
* thread, the message, the compose surface — leaves the answer alone.
|
|
39
|
+
*/
|
|
40
|
+
export function mailListRoute(
|
|
41
|
+
matches: readonly MailRouteMatch[],
|
|
42
|
+
): MailListRoute | undefined {
|
|
43
|
+
for (const match of matches) {
|
|
44
|
+
if (match.routeId === MAIL_BRIEF_ROUTE_ID) return { list: "brief" };
|
|
45
|
+
if (match.routeId === MAIL_FLAGGED_ROUTE_ID) return { list: "flagged" };
|
|
46
|
+
if (match.routeId === MAIL_OUTBOX_ROUTE_ID) return { list: "outbox" };
|
|
47
|
+
if (match.routeId === MAIL_MAILBOX_ROUTE_ID)
|
|
48
|
+
return { list: "mailbox", mailboxId: match.params?.mailboxId };
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
35
51
|
}
|
|
36
52
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Identity of the list view the shell is showing — one mailbox, the brief, the
|
|
55
|
+
* flagged list, or the outbox. Opening a thread is not a view change, so the
|
|
56
|
+
* key of a list and of anything nested under it are equal; `lib/search-view.ts`
|
|
57
|
+
* re-seeds the search field whenever this changes, and a key that moved when a
|
|
58
|
+
* message opened would wipe the query the reader had just typed.
|
|
59
|
+
*/
|
|
60
|
+
export function mailViewKey(matches: readonly MailRouteMatch[]): string {
|
|
61
|
+
const route = mailListRoute(matches);
|
|
62
|
+
if (!route) return "";
|
|
63
|
+
switch (route.list) {
|
|
64
|
+
case "mailbox":
|
|
65
|
+
return route.mailboxId ? mailboxViewKey(route.mailboxId) : "";
|
|
66
|
+
case "flagged":
|
|
67
|
+
return MAIL_FLAGGED_ROUTE_ID;
|
|
68
|
+
case "outbox":
|
|
69
|
+
return MAIL_OUTBOX_ROUTE_ID;
|
|
70
|
+
case "brief":
|
|
71
|
+
return MAIL_BRIEF_ROUTE_ID;
|
|
72
|
+
}
|
|
40
73
|
}
|
|
41
74
|
|
|
42
|
-
/**
|
|
43
|
-
export function
|
|
44
|
-
return
|
|
75
|
+
/** One mailbox's view key. Two mailboxes are two views. */
|
|
76
|
+
export function mailboxViewKey(mailboxId: string): string {
|
|
77
|
+
return `${MAIL_MAILBOX_ROUTE_ID}:${mailboxId}`;
|
|
45
78
|
}
|
|
46
79
|
|
|
47
80
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
81
|
+
* Whether a location sits on a list, or on something the list has open below it.
|
|
82
|
+
*
|
|
83
|
+
* Answers off the pathname rather than the matches on purpose. The router
|
|
84
|
+
* commits the new location before it swaps the matches, so a component leaving
|
|
85
|
+
* the screen sees its own matches under the destination's address for as long as
|
|
86
|
+
* the destination takes to mount. `pathname` is what says where the reader is
|
|
87
|
+
* going; `matches` is what says where they still are.
|
|
88
|
+
*
|
|
89
|
+
* `listPath` is a whole segment: a folder named `briefing` is not the brief.
|
|
53
90
|
*/
|
|
54
|
-
export function
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (isFlaggedRoute(matches)) return MAIL_FLAGGED_ROUTE_ID;
|
|
58
|
-
if (isOutboxRoute(matches)) return MAIL_OUTBOX_ROUTE_ID;
|
|
59
|
-
if (isBriefRoute(matches)) return MAIL_BRIEF_ROUTE_ID;
|
|
60
|
-
return "";
|
|
91
|
+
export function locationIsOnList(pathname: string, listPath: string): boolean {
|
|
92
|
+
if (pathname === listPath) return true;
|
|
93
|
+
return pathname.startsWith(`${listPath}/`);
|
|
61
94
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The search params each mail list validates.
|
|
3
|
+
*
|
|
4
|
+
* `q` lives on the parent `/mail` route, but a child's `validateSearch` is
|
|
5
|
+
* authoritative for its own URL, so a child that omits `q` strips it: typing a
|
|
6
|
+
* query on that list does nothing and the query is lost on the next navigation.
|
|
7
|
+
* Every list extends the same base rather than re-declaring the param, so there
|
|
8
|
+
* is one place for it to be right.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
/** What every list carries, whatever else it carries. */
|
|
13
|
+
const listSearch = z.object({ q: z.string().optional() });
|
|
14
|
+
|
|
15
|
+
export const briefSearchSchema = listSearch.extend({
|
|
16
|
+
selectedMessageId: z.string().optional(),
|
|
17
|
+
// A tapped semantic "Related" hit can point at a message outside the loaded
|
|
18
|
+
// brief list; carrying its thread + mailbox lets the brief open it directly.
|
|
19
|
+
selectedThreadId: z.string().optional(),
|
|
20
|
+
selectedMailboxId: z.string().optional(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const flaggedSearchSchema = listSearch.extend({
|
|
24
|
+
selectedMessageId: z.string().optional(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const outboxSearchSchema = listSearch.extend({
|
|
28
|
+
selectedOutboxMessageId: z.string().optional(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const mailboxSearchSchema = listSearch.extend({
|
|
32
|
+
selectedMessageId: z.string().optional(),
|
|
33
|
+
// A tapped semantic "Related" hit can point at a message outside the loaded
|
|
34
|
+
// list; carrying its thread lets the mailbox open it directly (the mailbox is
|
|
35
|
+
// the route param). See `buildConversationTarget`.
|
|
36
|
+
selectedThreadId: z.string().optional(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `/mail` itself only redirects to the brief, so it accepts what the brief
|
|
41
|
+
* accepts and hands it straight on.
|
|
42
|
+
*/
|
|
43
|
+
export const mailIndexSearchSchema = briefSearchSchema;
|
|
44
|
+
|
|
45
|
+
/** Every list's schema, by the path it validates. */
|
|
46
|
+
export const mailListSearchSchemas = {
|
|
47
|
+
"/mail/brief": briefSearchSchema,
|
|
48
|
+
"/mail/flagged": flaggedSearchSchema,
|
|
49
|
+
"/mail/outbox": outboxSearchSchema,
|
|
50
|
+
"/mail/$mailboxId": mailboxSearchSchema,
|
|
51
|
+
} as const;
|
|
@@ -1,38 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `q` lives on the parent `/mail` route, but every
|
|
3
|
-
* child's `validateSearch` is authoritative for its own URL, so a
|
|
4
|
-
* omits `q` strips it. The top bar mounts a search field on all four
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* `q` lives on the parent `/mail` route, but every list re-declares it: a
|
|
3
|
+
* child's `validateSearch` is authoritative for its own URL, so a list that
|
|
4
|
+
* omits `q` strips it. The top bar mounts a search field on all four lists, so
|
|
5
|
+
* a stripped `q` means typing a query does nothing and the query is lost on the
|
|
6
|
+
* next navigation.
|
|
7
|
+
*
|
|
8
|
+
* Two halves: the schemas carry `q`, and each route is wired to the schema
|
|
9
|
+
* declared for its own path. The second half has to load route files, so it runs
|
|
10
|
+
* out of process — see `test-support/list-search-binding.ts`.
|
|
7
11
|
*/
|
|
8
12
|
import assert from "node:assert/strict";
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
9
14
|
import { describe, it } from "node:test";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import { Route as FlaggedRoute } from "../routes/mail/flagged";
|
|
13
|
-
import { Route as BriefRoute } from "../routes/mail/index";
|
|
14
|
-
import { Route as OutboxRoute } from "../routes/mail/outbox";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { mailIndexSearchSchema, mailListSearchSchemas } from "./mail-search.js";
|
|
15
17
|
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
"/mail/
|
|
19
|
-
"/mail/outbox": OutboxRoute,
|
|
20
|
-
"/mail/$mailboxId": MailboxRoute,
|
|
18
|
+
const schemas = {
|
|
19
|
+
...mailListSearchSchemas,
|
|
20
|
+
"/mail/ (the redirect to the brief)": mailIndexSearchSchema,
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
const schema
|
|
25
|
-
assert.ok(
|
|
26
|
-
schema instanceof z.ZodType,
|
|
27
|
-
"route must validate its search with a zod schema",
|
|
28
|
-
);
|
|
29
|
-
return (search: Record<string, unknown>) => schema.parse(search);
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
describe("every /mail child route carries `q` through its own validation", () => {
|
|
33
|
-
for (const [name, route] of Object.entries(routes)) {
|
|
23
|
+
describe("every /mail list carries `q` through its own validation", () => {
|
|
24
|
+
for (const [name, schema] of Object.entries(schemas)) {
|
|
34
25
|
it(`${name} preserves a query`, () => {
|
|
35
|
-
const parsed = parse(
|
|
26
|
+
const parsed = schema.parse({ q: "invoice" }) as { q?: string };
|
|
36
27
|
assert.equal(
|
|
37
28
|
parsed.q,
|
|
38
29
|
"invoice",
|
|
@@ -41,8 +32,39 @@ describe("every /mail child route carries `q` through its own validation", () =>
|
|
|
41
32
|
});
|
|
42
33
|
|
|
43
34
|
it(`${name} leaves q absent when there is none`, () => {
|
|
44
|
-
const parsed = parse(
|
|
35
|
+
const parsed = schema.parse({}) as { q?: string };
|
|
45
36
|
assert.equal(parsed.q, undefined);
|
|
46
37
|
});
|
|
47
38
|
}
|
|
48
39
|
});
|
|
40
|
+
|
|
41
|
+
describe("every /mail list route uses the schema declared for its path", () => {
|
|
42
|
+
it("holds for all four lists", () => {
|
|
43
|
+
const check = fileURLToPath(
|
|
44
|
+
new URL("../test-support/list-search-binding.ts", import.meta.url),
|
|
45
|
+
);
|
|
46
|
+
const register = fileURLToPath(
|
|
47
|
+
new URL("../../test-support/register.mjs", import.meta.url),
|
|
48
|
+
);
|
|
49
|
+
// The runner re-injects its coverage directory into any child, even one
|
|
50
|
+
// given an environment without it, and coverage from this child would put
|
|
51
|
+
// every pane the route files reach into the denominator — the reason the
|
|
52
|
+
// check is out of process at all. An empty value is the one thing that
|
|
53
|
+
// stops the child writing there.
|
|
54
|
+
assert.doesNotThrow(() =>
|
|
55
|
+
execFileSync(
|
|
56
|
+
process.execPath,
|
|
57
|
+
["--import", "tsx", "--import", register, check],
|
|
58
|
+
{
|
|
59
|
+
stdio: "pipe",
|
|
60
|
+
encoding: "utf8",
|
|
61
|
+
env: {
|
|
62
|
+
...process.env,
|
|
63
|
+
NODE_V8_COVERAGE: "",
|
|
64
|
+
TSX_TSCONFIG_PATH: "./tsconfig.test.json",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
});
|
package/src/lib/search-scope.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Removing the chip means "search everything", which is the daily brief — the
|
|
13
13
|
* cross-account view whose scope is nothing. So removal is a navigation to
|
|
14
|
-
* `/mail
|
|
14
|
+
* `/mail/brief` carrying the query, not an edit of the query text.
|
|
15
15
|
*
|
|
16
16
|
* There is exactly one scope, and it is this one. A typed `in:` term is a
|
|
17
17
|
* second, competing answer to "which mailbox", so it is recognized only where
|
|
@@ -22,14 +22,7 @@
|
|
|
22
22
|
* Pure functions only. `useSearchScope` binds these to the router.
|
|
23
23
|
*/
|
|
24
24
|
import type { FolderRole, SearchScope as ResultsScope } from "@remit/ui";
|
|
25
|
-
import {
|
|
26
|
-
isBriefRoute,
|
|
27
|
-
isFlaggedRoute,
|
|
28
|
-
isMailboxRoute,
|
|
29
|
-
isOutboxRoute,
|
|
30
|
-
MAIL_MAILBOX_ROUTE_ID,
|
|
31
|
-
type MailRouteMatch,
|
|
32
|
-
} from "./mail-route";
|
|
25
|
+
import { type MailRouteMatch, mailListRoute } from "./mail-route";
|
|
33
26
|
|
|
34
27
|
/**
|
|
35
28
|
* Chip id of the scope chip. The top bar owns exactly one, so a fixed id is
|
|
@@ -72,9 +65,8 @@ export function scopeLabelForMailboxName(name: string): string {
|
|
|
72
65
|
* not.
|
|
73
66
|
*/
|
|
74
67
|
export function isScopedRoute(matches: readonly MailRouteMatch[]): boolean {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
);
|
|
68
|
+
const route = mailListRoute(matches);
|
|
69
|
+
return route !== undefined && route.list !== "brief";
|
|
78
70
|
}
|
|
79
71
|
|
|
80
72
|
/**
|
|
@@ -83,8 +75,8 @@ export function isScopedRoute(matches: readonly MailRouteMatch[]): boolean {
|
|
|
83
75
|
export function routeMailboxId(
|
|
84
76
|
matches: readonly MailRouteMatch[],
|
|
85
77
|
): string | undefined {
|
|
86
|
-
|
|
87
|
-
|
|
78
|
+
const route = mailListRoute(matches);
|
|
79
|
+
return route?.list === "mailbox" ? route.mailboxId : undefined;
|
|
88
80
|
}
|
|
89
81
|
|
|
90
82
|
/**
|
|
@@ -128,20 +120,20 @@ export function searchScopeForRoute(
|
|
|
128
120
|
matches: readonly MailRouteMatch[],
|
|
129
121
|
mailboxName?: string | null,
|
|
130
122
|
): SearchScopeState {
|
|
131
|
-
|
|
132
|
-
if (
|
|
123
|
+
const route = mailListRoute(matches);
|
|
124
|
+
if (route?.list === "flagged") {
|
|
133
125
|
return {
|
|
134
126
|
kind: "scoped",
|
|
135
127
|
chip: { id: SEARCH_SCOPE_CHIP_ID, label: "is:starred" },
|
|
136
128
|
};
|
|
137
129
|
}
|
|
138
|
-
if (
|
|
130
|
+
if (route?.list === "outbox") {
|
|
139
131
|
return {
|
|
140
132
|
kind: "scoped",
|
|
141
133
|
chip: { id: SEARCH_SCOPE_CHIP_ID, label: "in:outbox" },
|
|
142
134
|
};
|
|
143
135
|
}
|
|
144
|
-
if (
|
|
136
|
+
if (route?.list === "mailbox") {
|
|
145
137
|
if (!mailboxName) return { kind: "pending" };
|
|
146
138
|
return {
|
|
147
139
|
kind: "scoped",
|
|
@@ -177,6 +169,6 @@ export function resultsScopeForRoute(
|
|
|
177
169
|
role?: FolderRole,
|
|
178
170
|
): ResultsScope {
|
|
179
171
|
if (state.kind === "global") return { kind: "global" };
|
|
180
|
-
if (
|
|
172
|
+
if (mailListRoute(matches)?.list === "flagged") return { kind: "collection" };
|
|
181
173
|
return { kind: "folder", ...(role ? { role } : {}) };
|
|
182
174
|
}
|
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import assert from "node:assert/strict";
|
|
8
8
|
import { describe, it } from "node:test";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
MAIL_BRIEF_ROUTE_ID,
|
|
11
|
+
type MailRouteMatch,
|
|
12
|
+
mailViewKey,
|
|
13
|
+
} from "./mail-route.js";
|
|
10
14
|
import {
|
|
11
15
|
committedSearchQuery,
|
|
12
16
|
searchInputForView,
|
|
@@ -19,31 +23,6 @@ const matches = (routeId: string, mailboxId?: string): MailRouteMatch[] => [
|
|
|
19
23
|
{ routeId, ...(mailboxId ? { params: { mailboxId } } : {}) },
|
|
20
24
|
];
|
|
21
25
|
|
|
22
|
-
describe("mailViewKey", () => {
|
|
23
|
-
it("distinguishes two mailboxes", () => {
|
|
24
|
-
assert.notEqual(
|
|
25
|
-
mailViewKey(matches("/mail/$mailboxId", "inbox-1")),
|
|
26
|
-
mailViewKey(matches("/mail/$mailboxId", "archive-1")),
|
|
27
|
-
);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("gives the same mailbox one key regardless of search params", () => {
|
|
31
|
-
assert.equal(
|
|
32
|
-
mailViewKey(matches("/mail/$mailboxId", "inbox-1")),
|
|
33
|
-
mailViewKey(matches("/mail/$mailboxId", "inbox-1")),
|
|
34
|
-
);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("separates the brief, flagged and outbox views", () => {
|
|
38
|
-
const keys = [
|
|
39
|
-
mailViewKey(matches("/mail/")),
|
|
40
|
-
mailViewKey(matches("/mail/flagged")),
|
|
41
|
-
mailViewKey(matches("/mail/outbox")),
|
|
42
|
-
];
|
|
43
|
-
assert.equal(new Set(keys).size, 3);
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
|
|
47
26
|
describe("searchInputForView", () => {
|
|
48
27
|
it("clears the field when the destination carries no query", () => {
|
|
49
28
|
assert.equal(
|
|
@@ -71,7 +50,11 @@ describe("searchInputForView", () => {
|
|
|
71
50
|
|
|
72
51
|
it("seeds from the destination's own query (deep link, saved search)", () => {
|
|
73
52
|
assert.equal(
|
|
74
|
-
searchInputForView(
|
|
53
|
+
searchInputForView(
|
|
54
|
+
"/mail/$mailboxId:inbox-1",
|
|
55
|
+
MAIL_BRIEF_ROUTE_ID,
|
|
56
|
+
"invoice",
|
|
57
|
+
),
|
|
75
58
|
"invoice",
|
|
76
59
|
);
|
|
77
60
|
});
|
|
@@ -89,12 +72,24 @@ describe("committedSearchQuery", () => {
|
|
|
89
72
|
});
|
|
90
73
|
|
|
91
74
|
describe("shouldMirrorQuery", () => {
|
|
75
|
+
/** A settled query on the brief, with the reader still on it. */
|
|
76
|
+
const onTheBrief = {
|
|
77
|
+
searchInput: "invoice",
|
|
78
|
+
committedQuery: "invoice",
|
|
79
|
+
urlQuery: "",
|
|
80
|
+
pathname: "/mail/brief",
|
|
81
|
+
listPath: "/mail/brief",
|
|
82
|
+
};
|
|
83
|
+
|
|
92
84
|
it("writes a settled query the URL does not have yet", () => {
|
|
93
|
-
assert.equal(shouldMirrorQuery(
|
|
85
|
+
assert.equal(shouldMirrorQuery(onTheBrief), true);
|
|
94
86
|
});
|
|
95
87
|
|
|
96
88
|
it("stays quiet once the URL already says it", () => {
|
|
97
|
-
assert.equal(
|
|
89
|
+
assert.equal(
|
|
90
|
+
shouldMirrorQuery({ ...onTheBrief, urlQuery: "invoice" }),
|
|
91
|
+
false,
|
|
92
|
+
);
|
|
98
93
|
});
|
|
99
94
|
|
|
100
95
|
it("does not strip the query a deep link just arrived with", () => {
|
|
@@ -102,11 +97,71 @@ describe("shouldMirrorQuery", () => {
|
|
|
102
97
|
// the committed query is still the previous view's. Writing it would drop
|
|
103
98
|
// `q` for the length of the debounce — and with it the search the link
|
|
104
99
|
// asked for.
|
|
105
|
-
assert.equal(
|
|
100
|
+
assert.equal(
|
|
101
|
+
shouldMirrorQuery({
|
|
102
|
+
...onTheBrief,
|
|
103
|
+
committedQuery: "",
|
|
104
|
+
urlQuery: "invoice",
|
|
105
|
+
}),
|
|
106
|
+
false,
|
|
107
|
+
);
|
|
106
108
|
});
|
|
107
109
|
|
|
108
110
|
it("waits for the debounce while the user is still typing", () => {
|
|
109
|
-
assert.equal(
|
|
111
|
+
assert.equal(
|
|
112
|
+
shouldMirrorQuery({
|
|
113
|
+
...onTheBrief,
|
|
114
|
+
searchInput: "invoi",
|
|
115
|
+
committedQuery: "inv",
|
|
116
|
+
}),
|
|
117
|
+
false,
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// The list the reader is leaving is still mounted, and its debounce can settle
|
|
122
|
+
// under the address of the list they are going to. Writing then navigates back
|
|
123
|
+
// to this list and replaces the entry they just pushed: they click Inbox and
|
|
124
|
+
// land on the brief carrying the half-typed query.
|
|
125
|
+
it("does not write from the list the reader has just left", () => {
|
|
126
|
+
assert.equal(
|
|
127
|
+
shouldMirrorQuery({ ...onTheBrief, pathname: "/mail/9f1c-abc" }),
|
|
128
|
+
false,
|
|
129
|
+
);
|
|
130
|
+
assert.equal(
|
|
131
|
+
shouldMirrorQuery({
|
|
132
|
+
...onTheBrief,
|
|
133
|
+
pathname: "/mail/flagged",
|
|
134
|
+
}),
|
|
135
|
+
false,
|
|
136
|
+
);
|
|
137
|
+
assert.equal(
|
|
138
|
+
shouldMirrorQuery({ ...onTheBrief, pathname: "/settings/accounts" }),
|
|
139
|
+
false,
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("writes from a mailbox only while that mailbox is the one addressed", () => {
|
|
144
|
+
const onInbox = {
|
|
145
|
+
...onTheBrief,
|
|
146
|
+
pathname: "/mail/inbox-1",
|
|
147
|
+
listPath: "/mail/inbox-1",
|
|
148
|
+
};
|
|
149
|
+
assert.equal(shouldMirrorQuery(onInbox), true);
|
|
150
|
+
assert.equal(
|
|
151
|
+
shouldMirrorQuery({ ...onInbox, pathname: "/mail/archive-1" }),
|
|
152
|
+
false,
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("still writes with a thread open below the list", () => {
|
|
157
|
+
// A thread is the list's own child route, so the reader has not left.
|
|
158
|
+
assert.equal(
|
|
159
|
+
shouldMirrorQuery({
|
|
160
|
+
...onTheBrief,
|
|
161
|
+
pathname: "/mail/brief/thread-1/message-1",
|
|
162
|
+
}),
|
|
163
|
+
true,
|
|
164
|
+
);
|
|
110
165
|
});
|
|
111
166
|
});
|
|
112
167
|
|
|
@@ -119,24 +174,42 @@ describe("shouldMirrorQuery", () => {
|
|
|
119
174
|
*/
|
|
120
175
|
interface Shell {
|
|
121
176
|
viewKey: string;
|
|
177
|
+
/** The path of the list whose mirror is running. */
|
|
178
|
+
listPath: string;
|
|
122
179
|
field: string;
|
|
123
180
|
debounced: string;
|
|
124
181
|
url: string;
|
|
125
182
|
}
|
|
126
183
|
|
|
127
184
|
/** One render of the shell for a location, returning the state it settles on. */
|
|
128
|
-
const render = (
|
|
185
|
+
const render = (
|
|
186
|
+
shell: Shell,
|
|
187
|
+
viewKey: string,
|
|
188
|
+
url: string,
|
|
189
|
+
listPath = shell.listPath,
|
|
190
|
+
): Shell => {
|
|
129
191
|
const field =
|
|
130
192
|
viewKey === shell.viewKey
|
|
131
193
|
? shell.field
|
|
132
194
|
: (searchInputForView(shell.viewKey, viewKey, url) ?? shell.field);
|
|
133
|
-
return { ...shell, viewKey, field, url };
|
|
195
|
+
return { ...shell, viewKey, listPath, field, url };
|
|
134
196
|
};
|
|
135
197
|
|
|
136
|
-
/**
|
|
137
|
-
|
|
198
|
+
/**
|
|
199
|
+
* What the mirror effect would do after that render. `pathname` is where the
|
|
200
|
+
* router says the reader is, which is the destination from the moment they
|
|
201
|
+
* navigate — before the list they are leaving has stopped running effects.
|
|
202
|
+
*/
|
|
203
|
+
const mirror = (shell: Shell, pathname = shell.listPath): Shell => {
|
|
138
204
|
const committed = committedSearchQuery(shell.field, shell.debounced);
|
|
139
|
-
|
|
205
|
+
const mayWrite = shouldMirrorQuery({
|
|
206
|
+
searchInput: shell.field,
|
|
207
|
+
committedQuery: committed,
|
|
208
|
+
urlQuery: shell.url,
|
|
209
|
+
pathname,
|
|
210
|
+
listPath: shell.listPath,
|
|
211
|
+
});
|
|
212
|
+
if (!mayWrite) return shell;
|
|
140
213
|
return { ...shell, url: committed };
|
|
141
214
|
};
|
|
142
215
|
|
|
@@ -149,6 +222,7 @@ const settle = (shell: Shell): Shell => ({ ...shell, debounced: shell.field });
|
|
|
149
222
|
describe("search across a view change", () => {
|
|
150
223
|
const searching: Shell = {
|
|
151
224
|
viewKey: mailViewKey(matches("/mail/$mailboxId", "inbox-1")),
|
|
225
|
+
listPath: "/mail/inbox-1",
|
|
152
226
|
field: "invoice",
|
|
153
227
|
debounced: "invoice",
|
|
154
228
|
url: "invoice",
|
|
@@ -157,12 +231,36 @@ describe("search across a view change", () => {
|
|
|
157
231
|
it("ends the search when the user leaves the view", () => {
|
|
158
232
|
// The nav link drops `q`, so the destination carries none.
|
|
159
233
|
const next = mirror(
|
|
160
|
-
render(
|
|
234
|
+
render(
|
|
235
|
+
searching,
|
|
236
|
+
mailViewKey(matches("/mail/$mailboxId", "sent-1")),
|
|
237
|
+
"",
|
|
238
|
+
"/mail/sent-1",
|
|
239
|
+
),
|
|
161
240
|
);
|
|
162
241
|
assert.equal(next.field, "");
|
|
163
242
|
assert.equal(next.url, "");
|
|
164
243
|
});
|
|
165
244
|
|
|
245
|
+
// The whole failure: the reader types on the brief and clicks Inbox inside the
|
|
246
|
+
// debounce. The brief is still mounted, its debounce settles, and its mirror
|
|
247
|
+
// would navigate to the brief — superseding the load and replacing the entry
|
|
248
|
+
// the reader had just pushed, so Inbox never arrives.
|
|
249
|
+
it("does not navigate back to the list the reader is leaving", () => {
|
|
250
|
+
const brief: Shell = {
|
|
251
|
+
viewKey: mailViewKey(matches(MAIL_BRIEF_ROUTE_ID)),
|
|
252
|
+
listPath: MAIL_BRIEF_ROUTE_ID,
|
|
253
|
+
field: "inv",
|
|
254
|
+
debounced: "",
|
|
255
|
+
url: "",
|
|
256
|
+
};
|
|
257
|
+
// The debounce catches up while the inbox is still loading.
|
|
258
|
+
const leaving = settle(brief);
|
|
259
|
+
assert.equal(mirror(leaving, "/mail/9f1c-abc").url, "");
|
|
260
|
+
// On the brief itself the same settled query is written as before.
|
|
261
|
+
assert.equal(mirror(leaving).url, "inv");
|
|
262
|
+
});
|
|
263
|
+
|
|
166
264
|
it("does not put the query it just left onto the view it landed on", () => {
|
|
167
265
|
// The debounce still holds "invoice" for up to 200ms after the move. The
|
|
168
266
|
// mirror must not write it back — that is #47 returning by another route.
|
|
@@ -170,6 +268,7 @@ describe("search across a view change", () => {
|
|
|
170
268
|
searching,
|
|
171
269
|
mailViewKey(matches("/mail/$mailboxId", "sent-1")),
|
|
172
270
|
"",
|
|
271
|
+
"/mail/sent-1",
|
|
173
272
|
);
|
|
174
273
|
assert.equal(landed.debounced, "invoice");
|
|
175
274
|
assert.equal(mirror(landed).url, "");
|
|
@@ -179,7 +278,12 @@ describe("search across a view change", () => {
|
|
|
179
278
|
// Dropping the scope chip navigates to the brief carrying the query, so
|
|
180
279
|
// the same words are searched with a wider scope.
|
|
181
280
|
const next = mirror(
|
|
182
|
-
render(
|
|
281
|
+
render(
|
|
282
|
+
searching,
|
|
283
|
+
mailViewKey(matches(MAIL_BRIEF_ROUTE_ID)),
|
|
284
|
+
"invoice",
|
|
285
|
+
MAIL_BRIEF_ROUTE_ID,
|
|
286
|
+
),
|
|
183
287
|
);
|
|
184
288
|
assert.equal(next.field, "invoice");
|
|
185
289
|
assert.equal(next.url, "invoice");
|