@remit/web-client 0.0.17 → 0.0.19
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/MailTopBar.tsx +27 -1
- package/src/components/mail/DailyBrief.tsx +4 -2
- package/src/components/mail/FlaggedList.tsx +4 -2
- package/src/components/mail/MailListHeader.tsx +45 -27
- package/src/components/mail/MailSidebarAdapter.tsx +11 -2
- package/src/components/mail/MailViewChrome.tsx +7 -0
- package/src/components/mail/MailboxPane.tsx +19 -12
- package/src/components/mail/OutboxPane.tsx +67 -8
- package/src/hooks/useSearchScope.ts +49 -0
- package/src/hooks/useSearchTokenContext.ts +28 -0
- package/src/hooks/useSemanticSearch.ts +13 -9
- package/src/lib/mail-context.ts +8 -1
- package/src/lib/mail-route.ts +17 -0
- package/src/lib/outbox-search.test.ts +75 -0
- package/src/lib/outbox-search.ts +58 -0
- package/src/lib/search-scope.test.ts +104 -0
- package/src/lib/search-scope.ts +114 -0
- package/src/lib/search-view.test.ts +205 -0
- package/src/lib/search-view.ts +54 -0
- package/src/routes/mail.tsx +57 -16
package/src/lib/mail-route.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
/** A matched route, minimal shape needed for pane detection. */
|
|
16
16
|
export interface MailRouteMatch {
|
|
17
17
|
routeId: string;
|
|
18
|
+
params?: Record<string, string | undefined>;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
/** The leaf route ids the /mail shell branches on. */
|
|
@@ -42,3 +43,19 @@ export function isOutboxRoute(matches: readonly MailRouteMatch[]): boolean {
|
|
|
42
43
|
export function isMailboxRoute(matches: readonly MailRouteMatch[]): boolean {
|
|
43
44
|
return matches.some((m) => m.routeId === MAIL_MAILBOX_ROUTE_ID);
|
|
44
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Identity of the list view the shell is showing — one mailbox, the brief, the
|
|
49
|
+
* flagged list, or the outbox. Two locations share a key when they differ only
|
|
50
|
+
* in search params (opening a result, mirroring `q`), so opening a hit is not a
|
|
51
|
+
* view change while switching mailbox is. `lib/search-view.ts` re-seeds the
|
|
52
|
+
* search field whenever this changes.
|
|
53
|
+
*/
|
|
54
|
+
export function mailViewKey(matches: readonly MailRouteMatch[]): string {
|
|
55
|
+
const mailboxId = matches.find((m) => m.params?.mailboxId)?.params?.mailboxId;
|
|
56
|
+
if (mailboxId) return `${MAIL_MAILBOX_ROUTE_ID}:${mailboxId}`;
|
|
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 "";
|
|
61
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { matchesOutboxSearch, outboxQueryIsUnsupported } from "./outbox-search";
|
|
4
|
+
import { parseSearchTokens } from "./search-tokens";
|
|
5
|
+
|
|
6
|
+
const row = {
|
|
7
|
+
subject: "Q3 Invoice",
|
|
8
|
+
fromAddress: "me@example.com",
|
|
9
|
+
fromName: "Me",
|
|
10
|
+
toAddresses: ["billing@acme.test"],
|
|
11
|
+
ccAddresses: ["accounts@acme.test"],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("matchesOutboxSearch", () => {
|
|
15
|
+
it("keeps every row when there is no query", () => {
|
|
16
|
+
assert.equal(matchesOutboxSearch(row, " "), true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("matches the subject", () => {
|
|
20
|
+
assert.equal(matchesOutboxSearch(row, "invoice"), true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("matches a recipient", () => {
|
|
24
|
+
assert.equal(matchesOutboxSearch(row, "acme"), true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("matches the sender", () => {
|
|
28
|
+
assert.equal(matchesOutboxSearch(row, "me@example"), true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("requires every word, in any order and any field", () => {
|
|
32
|
+
assert.equal(matchesOutboxSearch(row, "invoice billing"), true);
|
|
33
|
+
assert.equal(matchesOutboxSearch(row, "invoice missing"), false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("drops a row that matches nothing", () => {
|
|
37
|
+
assert.equal(matchesOutboxSearch(row, "receipt"), false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("tolerates rows with absent fields", () => {
|
|
41
|
+
assert.equal(matchesOutboxSearch({}, "anything"), false);
|
|
42
|
+
assert.equal(matchesOutboxSearch({}, ""), true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("matches the free text of a query that also carries a token", () => {
|
|
46
|
+
// The token is dropped, but what the user typed alongside it still
|
|
47
|
+
// searches — the row is not lost to an operator the outbox can't serve.
|
|
48
|
+
const { freeText } = parseSearchTokens("Q3 from:billing");
|
|
49
|
+
assert.equal(matchesOutboxSearch(row, freeText), true);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("outboxQueryIsUnsupported", () => {
|
|
54
|
+
it("is true for a query that is only tokens", () => {
|
|
55
|
+
assert.equal(
|
|
56
|
+
outboxQueryIsUnsupported(parseSearchTokens("from:billing")),
|
|
57
|
+
true,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("is false when free text survives the parse", () => {
|
|
62
|
+
assert.equal(
|
|
63
|
+
outboxQueryIsUnsupported(parseSearchTokens("Q3 from:billing")),
|
|
64
|
+
false,
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("is false for plain free text", () => {
|
|
69
|
+
assert.equal(outboxQueryIsUnsupported(parseSearchTokens("invoice")), false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("is false for an empty query", () => {
|
|
73
|
+
assert.equal(outboxQueryIsUnsupported(parseSearchTokens("")), false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query narrowing for the outbox list.
|
|
3
|
+
*
|
|
4
|
+
* The outbox is a small, fully-loaded client-side list of mail that has not
|
|
5
|
+
* left yet, so its search is a filter over the rows already in hand rather
|
|
6
|
+
* than a request. It matches what an outbox row shows — subject, recipients
|
|
7
|
+
* and sender — because that is what the user can see to search for.
|
|
8
|
+
*
|
|
9
|
+
* Filter tokens (`from:`, `is:unread`, dates) are not honored: an outbox row
|
|
10
|
+
* has no read state, no mailbox and no received date, so the operators have
|
|
11
|
+
* nothing to read. Only the free text narrows, so `Q3 from:billing` still
|
|
12
|
+
* matches on "Q3".
|
|
13
|
+
*
|
|
14
|
+
* A query made only of tokens asks for a filter this view cannot apply, and
|
|
15
|
+
* there is no free text left to fall back on. Rather than return every row as
|
|
16
|
+
* if the filter had been honored, that case returns nothing and the list says
|
|
17
|
+
* why — see `outboxQueryIsUnsupported`.
|
|
18
|
+
*/
|
|
19
|
+
import type { ParsedSearchQuery } from "./search-tokens";
|
|
20
|
+
|
|
21
|
+
/** The outbox row fields a query is matched against. */
|
|
22
|
+
export interface OutboxSearchRow {
|
|
23
|
+
subject?: string | null;
|
|
24
|
+
fromAddress?: string | null;
|
|
25
|
+
fromName?: string | null;
|
|
26
|
+
toAddresses?: readonly string[] | null;
|
|
27
|
+
ccAddresses?: readonly string[] | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function matchesOutboxSearch(
|
|
31
|
+
row: OutboxSearchRow,
|
|
32
|
+
freeText: string,
|
|
33
|
+
): boolean {
|
|
34
|
+
const needle = freeText.trim().toLowerCase();
|
|
35
|
+
if (needle.length === 0) return true;
|
|
36
|
+
const haystack = [
|
|
37
|
+
row.subject,
|
|
38
|
+
row.fromAddress,
|
|
39
|
+
row.fromName,
|
|
40
|
+
...(row.toAddresses ?? []),
|
|
41
|
+
...(row.ccAddresses ?? []),
|
|
42
|
+
]
|
|
43
|
+
.filter((value): value is string => typeof value === "string")
|
|
44
|
+
.join(" ")
|
|
45
|
+
.toLowerCase();
|
|
46
|
+
return needle
|
|
47
|
+
.split(/\s+/)
|
|
48
|
+
.every((word) => word.length === 0 || haystack.includes(word));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* True when the query is nothing but filter tokens. The outbox can serve none
|
|
53
|
+
* of them, and matching on the empty free text left over would return every
|
|
54
|
+
* row — indistinguishable from a filter that matched everything.
|
|
55
|
+
*/
|
|
56
|
+
export function outboxQueryIsUnsupported(parsed: ParsedSearchQuery): boolean {
|
|
57
|
+
return parsed.tokens.length > 0 && parsed.freeText.trim().length === 0;
|
|
58
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
MAIL_BRIEF_ROUTE_ID,
|
|
5
|
+
MAIL_FLAGGED_ROUTE_ID,
|
|
6
|
+
MAIL_MAILBOX_ROUTE_ID,
|
|
7
|
+
MAIL_OUTBOX_ROUTE_ID,
|
|
8
|
+
} from "./mail-route";
|
|
9
|
+
import {
|
|
10
|
+
isScopedRoute,
|
|
11
|
+
SEARCH_SCOPE_CHIP_ID,
|
|
12
|
+
scopeLabelForMailboxName,
|
|
13
|
+
searchScopeForRoute,
|
|
14
|
+
} from "./search-scope";
|
|
15
|
+
|
|
16
|
+
const matches = (routeId: string) => [{ routeId: "/mail" }, { routeId }];
|
|
17
|
+
|
|
18
|
+
describe("searchScopeForRoute", () => {
|
|
19
|
+
it("gives the daily brief no scope — it is the global view", () => {
|
|
20
|
+
assert.deepEqual(searchScopeForRoute(matches(MAIL_BRIEF_ROUTE_ID)), {
|
|
21
|
+
kind: "global",
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("scopes a mailbox route to its sidebar label", () => {
|
|
26
|
+
assert.deepEqual(
|
|
27
|
+
searchScopeForRoute(matches(MAIL_MAILBOX_ROUTE_ID), "Spam"),
|
|
28
|
+
{
|
|
29
|
+
kind: "scoped",
|
|
30
|
+
chip: { id: SEARCH_SCOPE_CHIP_ID, label: "in:spam" },
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("is pending, not global, while the mailbox name resolves", () => {
|
|
36
|
+
// The list under the bar is already one mailbox. A chip reading a raw
|
|
37
|
+
// uuid is worse than no chip, but the field must not claim to search
|
|
38
|
+
// everything either.
|
|
39
|
+
assert.deepEqual(
|
|
40
|
+
searchScopeForRoute(matches(MAIL_MAILBOX_ROUTE_ID), null),
|
|
41
|
+
{ kind: "pending" },
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("scopes the starred view", () => {
|
|
46
|
+
const scope = searchScopeForRoute(matches(MAIL_FLAGGED_ROUTE_ID));
|
|
47
|
+
assert.equal(scope.kind === "scoped" && scope.chip.label, "is:starred");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("scopes the outbox", () => {
|
|
51
|
+
const scope = searchScopeForRoute(matches(MAIL_OUTBOX_ROUTE_ID));
|
|
52
|
+
assert.equal(scope.kind === "scoped" && scope.chip.label, "in:outbox");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("prefers the virtual views over a lingering mailbox name", () => {
|
|
56
|
+
const scope = searchScopeForRoute(matches(MAIL_OUTBOX_ROUTE_ID), "Spam");
|
|
57
|
+
assert.equal(scope.kind === "scoped" && scope.chip.label, "in:outbox");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("isScopedRoute", () => {
|
|
62
|
+
// This predicate decides whether a typed `in:` is recognized at all, so
|
|
63
|
+
// every route has to answer it the same way it answers for its chip: a
|
|
64
|
+
// route showing a scope chip must not also honour a competing typed term.
|
|
65
|
+
for (const routeId of [
|
|
66
|
+
MAIL_MAILBOX_ROUTE_ID,
|
|
67
|
+
MAIL_FLAGGED_ROUTE_ID,
|
|
68
|
+
MAIL_OUTBOX_ROUTE_ID,
|
|
69
|
+
]) {
|
|
70
|
+
it(`${routeId} carries a scope`, () => {
|
|
71
|
+
assert.equal(isScopedRoute(matches(routeId)), true);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
it("the daily brief carries none", () => {
|
|
76
|
+
assert.equal(isScopedRoute(matches(MAIL_BRIEF_ROUTE_ID)), false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("agrees with the chip on every route", () => {
|
|
80
|
+
for (const routeId of [
|
|
81
|
+
MAIL_BRIEF_ROUTE_ID,
|
|
82
|
+
MAIL_MAILBOX_ROUTE_ID,
|
|
83
|
+
MAIL_FLAGGED_ROUTE_ID,
|
|
84
|
+
MAIL_OUTBOX_ROUTE_ID,
|
|
85
|
+
]) {
|
|
86
|
+
const scope = searchScopeForRoute(matches(routeId), "Spam");
|
|
87
|
+
assert.equal(
|
|
88
|
+
isScopedRoute(matches(routeId)),
|
|
89
|
+
scope.kind === "scoped",
|
|
90
|
+
`${routeId} disagrees about whether it has a scope`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("scopeLabelForMailboxName", () => {
|
|
97
|
+
it("lower-cases so the chip reads like the operator it mimics", () => {
|
|
98
|
+
assert.equal(scopeLabelForMailboxName("Archive"), "in:archive");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("quotes a multi-word folder so the chip stays one term", () => {
|
|
102
|
+
assert.equal(scopeLabelForMailboxName("Work Stuff"), 'in:"work stuff"');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The search scope is the route.
|
|
3
|
+
*
|
|
4
|
+
* Search is the app's search: one field in the top bar, and by default it
|
|
5
|
+
* covers everything. Navigating the sidebar narrows it, and the narrowing is
|
|
6
|
+
* shown as one chip in the field — `in:spam` while viewing Spam. The chip is
|
|
7
|
+
* derived from the active route, not parsed out of the typed text: the route
|
|
8
|
+
* decides which pane is mounted and which mail that pane reads, and the chip is
|
|
9
|
+
* that decision made visible. Navigating elsewhere replaces the chip because the
|
|
10
|
+
* route changed.
|
|
11
|
+
*
|
|
12
|
+
* Removing the chip means "search everything", which is the daily brief — the
|
|
13
|
+
* cross-account view whose scope is nothing. So removal is a navigation to
|
|
14
|
+
* `/mail/` carrying the query, not an edit of the query text.
|
|
15
|
+
*
|
|
16
|
+
* There is exactly one scope, and it is this one. A typed `in:` term is a
|
|
17
|
+
* second, competing answer to "which mailbox", so it is recognized only where
|
|
18
|
+
* there is no route scope to compete with — see `isScopedRoute` and
|
|
19
|
+
* `useSearchTokenContext`. Every engine parses the query through that one
|
|
20
|
+
* context, so a scoped view cannot show a chip for a term it is ignoring.
|
|
21
|
+
*
|
|
22
|
+
* Pure functions only. `useSearchScope` binds these to the router.
|
|
23
|
+
*/
|
|
24
|
+
import {
|
|
25
|
+
isBriefRoute,
|
|
26
|
+
isFlaggedRoute,
|
|
27
|
+
isMailboxRoute,
|
|
28
|
+
isOutboxRoute,
|
|
29
|
+
type MailRouteMatch,
|
|
30
|
+
} from "./mail-route";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Chip id of the scope chip. The top bar owns exactly one, so a fixed id is
|
|
34
|
+
* enough of a removal handle.
|
|
35
|
+
*/
|
|
36
|
+
export const SEARCH_SCOPE_CHIP_ID = "search-scope";
|
|
37
|
+
|
|
38
|
+
export interface SearchScope {
|
|
39
|
+
id: string;
|
|
40
|
+
/** What the chip reads, e.g. `in:spam`. */
|
|
41
|
+
label: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What the field can say about its own scope.
|
|
46
|
+
*
|
|
47
|
+
* `pending` is the mailbox route before its name has loaded: the list under the
|
|
48
|
+
* bar is already one mailbox, so the field must not claim to search everything,
|
|
49
|
+
* but there is no name yet to put in a chip. It is a real third state, not a
|
|
50
|
+
* flavour of `global`.
|
|
51
|
+
*/
|
|
52
|
+
export type SearchScopeState =
|
|
53
|
+
| { kind: "global" }
|
|
54
|
+
| { kind: "pending" }
|
|
55
|
+
| { kind: "scoped"; chip: SearchScope };
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Render a mailbox name as an `in:` term. Lower-cased so it reads like the
|
|
59
|
+
* operator it mimics, and quoted when the name has whitespace so the chip
|
|
60
|
+
* stays one legible term.
|
|
61
|
+
*/
|
|
62
|
+
export function scopeLabelForMailboxName(name: string): string {
|
|
63
|
+
const collapsed = name.trim().replace(/\s+/g, " ").toLowerCase();
|
|
64
|
+
return /\s/.test(collapsed) ? `in:"${collapsed}"` : `in:${collapsed}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* True on the routes whose pane reads one mailbox or one virtual collection.
|
|
69
|
+
* These carry a scope; the daily brief (and any route not recognized here) does
|
|
70
|
+
* not.
|
|
71
|
+
*/
|
|
72
|
+
export function isScopedRoute(matches: readonly MailRouteMatch[]): boolean {
|
|
73
|
+
return (
|
|
74
|
+
isFlaggedRoute(matches) || isOutboxRoute(matches) || isMailboxRoute(matches)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The scope of the active route.
|
|
80
|
+
*
|
|
81
|
+
* `mailboxName` is the sidebar's own label for the mailbox route (resolved by
|
|
82
|
+
* `useCurrentMailboxName`). Until it resolves the scope is `pending`: no chip,
|
|
83
|
+
* because a chip reading a raw uuid is worse than none, but not `global`
|
|
84
|
+
* either, because the list underneath is already narrowed.
|
|
85
|
+
*/
|
|
86
|
+
export function searchScopeForRoute(
|
|
87
|
+
matches: readonly MailRouteMatch[],
|
|
88
|
+
mailboxName?: string | null,
|
|
89
|
+
): SearchScopeState {
|
|
90
|
+
if (isBriefRoute(matches)) return { kind: "global" };
|
|
91
|
+
if (isFlaggedRoute(matches)) {
|
|
92
|
+
return {
|
|
93
|
+
kind: "scoped",
|
|
94
|
+
chip: { id: SEARCH_SCOPE_CHIP_ID, label: "is:starred" },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (isOutboxRoute(matches)) {
|
|
98
|
+
return {
|
|
99
|
+
kind: "scoped",
|
|
100
|
+
chip: { id: SEARCH_SCOPE_CHIP_ID, label: "in:outbox" },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (isMailboxRoute(matches)) {
|
|
104
|
+
if (!mailboxName) return { kind: "pending" };
|
|
105
|
+
return {
|
|
106
|
+
kind: "scoped",
|
|
107
|
+
chip: {
|
|
108
|
+
id: SEARCH_SCOPE_CHIP_ID,
|
|
109
|
+
label: scopeLabelForMailboxName(mailboxName),
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return { kind: "global" };
|
|
114
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression (#47): the search query must not survive a move to another
|
|
3
|
+
* mailbox. The /mail shell holds the field state and never unmounts between
|
|
4
|
+
* child routes, so without these rules the text — and the query it drives —
|
|
5
|
+
* followed the user from inbox to inbox.
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { describe, it } from "node:test";
|
|
9
|
+
import { type MailRouteMatch, mailViewKey } from "./mail-route.js";
|
|
10
|
+
import {
|
|
11
|
+
committedSearchQuery,
|
|
12
|
+
searchInputForView,
|
|
13
|
+
shouldMirrorQuery,
|
|
14
|
+
} from "./search-view.js";
|
|
15
|
+
|
|
16
|
+
const matches = (routeId: string, mailboxId?: string): MailRouteMatch[] => [
|
|
17
|
+
{ routeId: "__root__" },
|
|
18
|
+
{ routeId: "/mail" },
|
|
19
|
+
{ routeId, ...(mailboxId ? { params: { mailboxId } } : {}) },
|
|
20
|
+
];
|
|
21
|
+
|
|
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
|
+
describe("searchInputForView", () => {
|
|
48
|
+
it("clears the field when the destination carries no query", () => {
|
|
49
|
+
assert.equal(
|
|
50
|
+
searchInputForView(
|
|
51
|
+
"/mail/$mailboxId:inbox-1",
|
|
52
|
+
"/mail/$mailboxId:arch",
|
|
53
|
+
"",
|
|
54
|
+
),
|
|
55
|
+
"",
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("leaves the field alone while the view stays the same", () => {
|
|
60
|
+
// Opening a result and the q-mirror both re-render on the same view; the
|
|
61
|
+
// user's in-flight typing must survive both.
|
|
62
|
+
assert.equal(
|
|
63
|
+
searchInputForView(
|
|
64
|
+
"/mail/$mailboxId:inbox-1",
|
|
65
|
+
"/mail/$mailboxId:inbox-1",
|
|
66
|
+
"",
|
|
67
|
+
),
|
|
68
|
+
undefined,
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("seeds from the destination's own query (deep link, saved search)", () => {
|
|
73
|
+
assert.equal(
|
|
74
|
+
searchInputForView("/mail/$mailboxId:inbox-1", "/mail/", "invoice"),
|
|
75
|
+
"invoice",
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("committedSearchQuery", () => {
|
|
81
|
+
it("commits an empty field immediately, without waiting on the debounce", () => {
|
|
82
|
+
assert.equal(committedSearchQuery("", "stale query"), "");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("debounces everything else", () => {
|
|
86
|
+
assert.equal(committedSearchQuery("inv", ""), "");
|
|
87
|
+
assert.equal(committedSearchQuery("invoice", "inv"), "inv");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("shouldMirrorQuery", () => {
|
|
92
|
+
it("writes a settled query the URL does not have yet", () => {
|
|
93
|
+
assert.equal(shouldMirrorQuery("invoice", "invoice", ""), true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("stays quiet once the URL already says it", () => {
|
|
97
|
+
assert.equal(shouldMirrorQuery("invoice", "invoice", "invoice"), false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("does not strip the query a deep link just arrived with", () => {
|
|
101
|
+
// The field is seeded from the URL and the debounce has not caught up, so
|
|
102
|
+
// the committed query is still the previous view's. Writing it would drop
|
|
103
|
+
// `q` for the length of the debounce — and with it the search the link
|
|
104
|
+
// asked for.
|
|
105
|
+
assert.equal(shouldMirrorQuery("invoice", "", "invoice"), false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("waits for the debounce while the user is still typing", () => {
|
|
109
|
+
assert.equal(shouldMirrorQuery("invoi", "inv", ""), false);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The three rules compose into the shell's behaviour (`routes/mail.tsx`): the
|
|
115
|
+
* field is re-seeded during render on a view change, and the mirror decides
|
|
116
|
+
* separately whether to write back. These check the composition, because the
|
|
117
|
+
* two ways search ends — a view change (#47) and the sidebar's own clear —
|
|
118
|
+
* both have to end it without ever writing over what the user is typing.
|
|
119
|
+
*/
|
|
120
|
+
interface Shell {
|
|
121
|
+
viewKey: string;
|
|
122
|
+
field: string;
|
|
123
|
+
debounced: string;
|
|
124
|
+
url: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** One render of the shell for a location, returning the state it settles on. */
|
|
128
|
+
const render = (shell: Shell, viewKey: string, url: string): Shell => {
|
|
129
|
+
const field =
|
|
130
|
+
viewKey === shell.viewKey
|
|
131
|
+
? shell.field
|
|
132
|
+
: (searchInputForView(shell.viewKey, viewKey, url) ?? shell.field);
|
|
133
|
+
return { ...shell, viewKey, field, url };
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** What the mirror effect would do after that render. */
|
|
137
|
+
const mirror = (shell: Shell): Shell => {
|
|
138
|
+
const committed = committedSearchQuery(shell.field, shell.debounced);
|
|
139
|
+
if (!shouldMirrorQuery(shell.field, committed, shell.url)) return shell;
|
|
140
|
+
return { ...shell, url: committed };
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const typing = (shell: Shell, text: string): Shell => ({
|
|
144
|
+
...shell,
|
|
145
|
+
field: text,
|
|
146
|
+
});
|
|
147
|
+
const settle = (shell: Shell): Shell => ({ ...shell, debounced: shell.field });
|
|
148
|
+
|
|
149
|
+
describe("search across a view change", () => {
|
|
150
|
+
const searching: Shell = {
|
|
151
|
+
viewKey: mailViewKey(matches("/mail/$mailboxId", "inbox-1")),
|
|
152
|
+
field: "invoice",
|
|
153
|
+
debounced: "invoice",
|
|
154
|
+
url: "invoice",
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
it("ends the search when the user leaves the view", () => {
|
|
158
|
+
// The nav link drops `q`, so the destination carries none.
|
|
159
|
+
const next = mirror(
|
|
160
|
+
render(searching, mailViewKey(matches("/mail/$mailboxId", "sent-1")), ""),
|
|
161
|
+
);
|
|
162
|
+
assert.equal(next.field, "");
|
|
163
|
+
assert.equal(next.url, "");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("does not put the query it just left onto the view it landed on", () => {
|
|
167
|
+
// The debounce still holds "invoice" for up to 200ms after the move. The
|
|
168
|
+
// mirror must not write it back — that is #47 returning by another route.
|
|
169
|
+
const landed = render(
|
|
170
|
+
searching,
|
|
171
|
+
mailViewKey(matches("/mail/$mailboxId", "sent-1")),
|
|
172
|
+
"",
|
|
173
|
+
);
|
|
174
|
+
assert.equal(landed.debounced, "invoice");
|
|
175
|
+
assert.equal(mirror(landed).url, "");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("keeps a query the destination carries (scope chip removed)", () => {
|
|
179
|
+
// Dropping the scope chip navigates to the brief carrying the query, so
|
|
180
|
+
// the same words are searched with a wider scope.
|
|
181
|
+
const next = mirror(
|
|
182
|
+
render(searching, mailViewKey(matches("/mail/")), "invoice"),
|
|
183
|
+
);
|
|
184
|
+
assert.equal(next.field, "invoice");
|
|
185
|
+
assert.equal(next.url, "invoice");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("never clobbers characters the user is still typing", () => {
|
|
189
|
+
// Opening a result and the q-mirror both re-render the same view. Neither
|
|
190
|
+
// is a view change, so neither may reach into the field.
|
|
191
|
+
const mailbox = mailViewKey(matches("/mail/$mailboxId", "inbox-1"));
|
|
192
|
+
let shell = typing(
|
|
193
|
+
{ ...searching, field: "", debounced: "", url: "" },
|
|
194
|
+
"i",
|
|
195
|
+
);
|
|
196
|
+
shell = mirror(render(shell, mailbox, shell.url));
|
|
197
|
+
shell = typing(shell, "inv");
|
|
198
|
+
shell = mirror(render(shell, mailbox, shell.url));
|
|
199
|
+
shell = typing(shell, "invoice");
|
|
200
|
+
shell = settle(mirror(render(shell, mailbox, shell.url)));
|
|
201
|
+
shell = mirror(render(shell, mailbox, shell.url));
|
|
202
|
+
assert.equal(shell.field, "invoice");
|
|
203
|
+
assert.equal(shell.url, "invoice");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search is a view-level mode, not a persistent global filter (#47).
|
|
3
|
+
*
|
|
4
|
+
* The query belongs to the location it was typed in: the URL's `q` is what a
|
|
5
|
+
* view is searching for, and the nav links already drop `q` when they switch
|
|
6
|
+
* mailbox. The search field, however, is local state in the /mail shell — which
|
|
7
|
+
* outlives every child route — so it kept the old text and went on querying the
|
|
8
|
+
* new mailbox with it (the confusing Mac Mail behaviour). These rules make the
|
|
9
|
+
* URL win on every view change: the field re-seeds from the location it lands
|
|
10
|
+
* on, so switching mailbox clears a stale query while a deep link or a saved
|
|
11
|
+
* search that carries `q` still arrives with the query intact.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The field text after a view transition, or `undefined` when nothing changes
|
|
16
|
+
* (same view — typing, opening a result, mirroring `q` back to the URL).
|
|
17
|
+
*/
|
|
18
|
+
export function searchInputForView(
|
|
19
|
+
previousViewKey: string,
|
|
20
|
+
viewKey: string,
|
|
21
|
+
urlQuery: string,
|
|
22
|
+
): string | undefined {
|
|
23
|
+
if (previousViewKey === viewKey) return undefined;
|
|
24
|
+
return urlQuery;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The committed query the search APIs run. Typing debounces, but an empty field
|
|
29
|
+
* commits immediately — otherwise a view change fires one more request for the
|
|
30
|
+
* query the user just left behind.
|
|
31
|
+
*/
|
|
32
|
+
export function committedSearchQuery(
|
|
33
|
+
searchInput: string,
|
|
34
|
+
debouncedSearchInput: string,
|
|
35
|
+
): string {
|
|
36
|
+
return searchInput === "" ? "" : debouncedSearchInput;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether the committed query may be written back to the URL. Only a settled
|
|
41
|
+
* one may: mid-debounce the committed value is the *previous* query, and
|
|
42
|
+
* writing it would overwrite the URL that a deep link or a saved search just
|
|
43
|
+
* arrived with — stripping `q` for as long as the debounce lasts, and taking
|
|
44
|
+
* the search with it. The mirror waits for the field and the committed query to
|
|
45
|
+
* agree, then writes only if the URL says something else.
|
|
46
|
+
*/
|
|
47
|
+
export function shouldMirrorQuery(
|
|
48
|
+
searchInput: string,
|
|
49
|
+
committedQuery: string,
|
|
50
|
+
urlQuery: string,
|
|
51
|
+
): boolean {
|
|
52
|
+
if (committedQuery !== searchInput) return false;
|
|
53
|
+
return committedQuery !== urlQuery;
|
|
54
|
+
}
|