@remit/web-client 0.0.275 → 0.0.276
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/MailSidebarAdapter.tsx +3 -46
- package/src/lib/search-view.test.ts +1 -1
- package/src/routing/search-mirror-convergence.render.test.ts +1 -1
- package/src/routing/search-navigation.ts +3 -4
- package/src/lib/saved-searches.test.ts +0 -80
- package/src/lib/saved-searches.ts +0 -52
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.276",
|
|
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": {
|
|
@@ -10,7 +10,7 @@ import type {
|
|
|
10
10
|
import type { NavAccount, NavLinkComponent, NavMailboxRole } from "@remit/ui";
|
|
11
11
|
import { NavSidebar } from "@remit/ui";
|
|
12
12
|
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
13
|
-
import { useCallback, useMemo
|
|
13
|
+
import { useCallback, useMemo } from "react";
|
|
14
14
|
import { useTranslation } from "react-i18next";
|
|
15
15
|
import {
|
|
16
16
|
buildMailboxRoleMap,
|
|
@@ -19,12 +19,7 @@ import {
|
|
|
19
19
|
} from "@/lib/folder-roles";
|
|
20
20
|
import { useMailContext } from "@/lib/mail-context";
|
|
21
21
|
import { isOutboxListRow } from "@/lib/outbox-status";
|
|
22
|
-
import {
|
|
23
|
-
loadSavedSearches,
|
|
24
|
-
removeSavedSearch,
|
|
25
|
-
saveSearch,
|
|
26
|
-
} from "@/lib/saved-searches";
|
|
27
|
-
import { NavLink, useSearchEverything, useSelectedNavId } from "@/routing";
|
|
22
|
+
import { NavLink, useSelectedNavId } from "@/routing";
|
|
28
23
|
|
|
29
24
|
interface MailSidebarAdapterProps {
|
|
30
25
|
accounts: RemitImapAccountResponse[];
|
|
@@ -123,10 +118,8 @@ export function MailSidebarAdapter({
|
|
|
123
118
|
variant = "desktop",
|
|
124
119
|
}: MailSidebarAdapterProps) {
|
|
125
120
|
const queryClient = useQueryClient();
|
|
126
|
-
const searchEverything = useSearchEverything();
|
|
127
121
|
const selectedNavId = useSelectedNavId();
|
|
128
|
-
const {
|
|
129
|
-
const [savedSearches, setSavedSearches] = useState(loadSavedSearches);
|
|
122
|
+
const { onSearchChange } = useMailContext();
|
|
130
123
|
const { t } = useTranslation("mail", { useSuspense: false });
|
|
131
124
|
const translator: Translator = useCallback(
|
|
132
125
|
(key, fallback) => t(key, { defaultValue: fallback }),
|
|
@@ -311,37 +304,6 @@ export function MailSidebarAdapter({
|
|
|
311
304
|
onMailboxSelect?.();
|
|
312
305
|
};
|
|
313
306
|
|
|
314
|
-
// Saved searches (#428 follow-up, local-only MVP — see
|
|
315
|
-
// doc/design/flows/06-search.md). A saved query is the raw typed text; the
|
|
316
|
-
// existing token parser re-derives its chips on reselect, so there's nothing
|
|
317
|
-
// else to persist.
|
|
318
|
-
const trimmedSearchInput = searchInput.trim();
|
|
319
|
-
const saveableQuery =
|
|
320
|
-
trimmedSearchInput.length > 0 && !savedSearches.includes(trimmedSearchInput)
|
|
321
|
-
? trimmedSearchInput
|
|
322
|
-
: undefined;
|
|
323
|
-
|
|
324
|
-
const handleSaveCurrentSearch = useCallback(() => {
|
|
325
|
-
if (!saveableQuery) return;
|
|
326
|
-
setSavedSearches(saveSearch(saveableQuery));
|
|
327
|
-
}, [saveableQuery]);
|
|
328
|
-
|
|
329
|
-
const handleRemoveSavedSearch = useCallback((query: string) => {
|
|
330
|
-
setSavedSearches(removeSavedSearch(query));
|
|
331
|
-
}, []);
|
|
332
|
-
|
|
333
|
-
// Running a saved search re-uses the daily brief as the search surface (the
|
|
334
|
-
// cross-account default view) — the same destination the search field
|
|
335
|
-
// itself lands results in once a query is active.
|
|
336
|
-
const handleSelectSavedSearch = useCallback(
|
|
337
|
-
(query: string) => {
|
|
338
|
-
onSearchChange(query);
|
|
339
|
-
searchEverything(query);
|
|
340
|
-
onMailboxSelect?.();
|
|
341
|
-
},
|
|
342
|
-
[onSearchChange, searchEverything, onMailboxSelect],
|
|
343
|
-
);
|
|
344
|
-
|
|
345
307
|
return (
|
|
346
308
|
<NavSidebar
|
|
347
309
|
accounts={navAccounts}
|
|
@@ -350,11 +312,6 @@ export function MailSidebarAdapter({
|
|
|
350
312
|
linkComponent={linkComponent}
|
|
351
313
|
calendarNav="shown"
|
|
352
314
|
variant={variant}
|
|
353
|
-
savedSearches={savedSearches}
|
|
354
|
-
saveableQuery={saveableQuery}
|
|
355
|
-
onSaveCurrentSearch={handleSaveCurrentSearch}
|
|
356
|
-
onSelectSavedSearch={handleSelectSavedSearch}
|
|
357
|
-
onRemoveSavedSearch={handleRemoveSavedSearch}
|
|
358
315
|
/>
|
|
359
316
|
);
|
|
360
317
|
}
|
|
@@ -38,7 +38,7 @@ describe("searchInputForView", () => {
|
|
|
38
38
|
);
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
it("seeds from the destination's own query (deep link
|
|
41
|
+
it("seeds from the destination's own query (deep link)", () => {
|
|
42
42
|
assert.equal(
|
|
43
43
|
searchInputForView(
|
|
44
44
|
"/mail/$mailboxId:inbox-1",
|
|
@@ -184,7 +184,7 @@ describe("an address that drifts from the settled query", () => {
|
|
|
184
184
|
});
|
|
185
185
|
|
|
186
186
|
it("does not fight a query arriving mid-debounce", async () => {
|
|
187
|
-
// A deep link
|
|
187
|
+
// A deep link lands while the field is still catching
|
|
188
188
|
// up. The committed query is the previous one, so the mirror has nothing
|
|
189
189
|
// settled to write and must not strip what just arrived.
|
|
190
190
|
const router = buildRouter(`${LIST_PATH}?q=receipts`);
|
|
@@ -5,10 +5,9 @@ import { useCallback } from "react";
|
|
|
5
5
|
* Search everything, carrying the query.
|
|
6
6
|
*
|
|
7
7
|
* "Everything" is the daily brief — the cross-account view whose scope is
|
|
8
|
-
* nothing — so dropping the scope chip
|
|
9
|
-
*
|
|
10
|
-
* the
|
|
11
|
-
* would leave the chip up and the list still narrowed.
|
|
8
|
+
* nothing — so dropping the scope chip is a navigation rather than an edit of
|
|
9
|
+
* the query text: the chip mirrors the route (see `lib/search-scope.ts`), so
|
|
10
|
+
* editing the text would leave the chip up and the list still narrowed.
|
|
12
11
|
*/
|
|
13
12
|
export function useSearchEverything(): (query: string) => void {
|
|
14
13
|
const navigate = useNavigate();
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert";
|
|
2
|
-
import { beforeEach, describe, test } from "node:test";
|
|
3
|
-
import {
|
|
4
|
-
isSearchSaved,
|
|
5
|
-
loadSavedSearches,
|
|
6
|
-
removeSavedSearch,
|
|
7
|
-
saveSearch,
|
|
8
|
-
} from "./saved-searches.js";
|
|
9
|
-
|
|
10
|
-
function installMemoryStorage(): void {
|
|
11
|
-
const store = new Map<string, string>();
|
|
12
|
-
globalThis.localStorage = {
|
|
13
|
-
getItem: (k: string) => store.get(k) ?? null,
|
|
14
|
-
setItem: (k: string, v: string) => void store.set(k, v),
|
|
15
|
-
removeItem: (k: string) => void store.delete(k),
|
|
16
|
-
clear: () => store.clear(),
|
|
17
|
-
key: () => null,
|
|
18
|
-
length: 0,
|
|
19
|
-
} as Storage;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
describe("saved-searches", () => {
|
|
23
|
-
beforeEach(installMemoryStorage);
|
|
24
|
-
|
|
25
|
-
test("starts empty", () => {
|
|
26
|
-
assert.deepEqual(loadSavedSearches(), []);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test("saves and loads a query", () => {
|
|
30
|
-
assert.deepEqual(saveSearch("from:alice has:attachment"), [
|
|
31
|
-
"from:alice has:attachment",
|
|
32
|
-
]);
|
|
33
|
-
assert.deepEqual(loadSavedSearches(), ["from:alice has:attachment"]);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test("most recent save first, deduplicated", () => {
|
|
37
|
-
saveSearch("alice");
|
|
38
|
-
saveSearch("bob");
|
|
39
|
-
assert.deepEqual(saveSearch("alice"), ["alice", "bob"]);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test("ignores blank queries", () => {
|
|
43
|
-
saveSearch("alice");
|
|
44
|
-
assert.deepEqual(saveSearch(" "), ["alice"]);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test("trims before storing", () => {
|
|
48
|
-
assert.deepEqual(saveSearch(" bob "), ["bob"]);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("survives corrupt storage", () => {
|
|
52
|
-
globalThis.localStorage.setItem("remit.savedSearches", "{not json");
|
|
53
|
-
assert.deepEqual(loadSavedSearches(), []);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("removeSavedSearch drops the exact query", () => {
|
|
57
|
-
saveSearch("alice");
|
|
58
|
-
saveSearch("bob");
|
|
59
|
-
assert.deepEqual(removeSavedSearch("alice"), ["bob"]);
|
|
60
|
-
assert.deepEqual(loadSavedSearches(), ["bob"]);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test("removeSavedSearch is a no-op for a query that isn't saved", () => {
|
|
64
|
-
saveSearch("alice");
|
|
65
|
-
assert.deepEqual(removeSavedSearch("nonexistent"), ["alice"]);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test("isSearchSaved reflects the persisted set, trimmed", () => {
|
|
69
|
-
saveSearch("alice");
|
|
70
|
-
assert.equal(isSearchSaved("alice"), true);
|
|
71
|
-
assert.equal(isSearchSaved(" alice "), true);
|
|
72
|
-
assert.equal(isSearchSaved("bob"), false);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test("caps at MAX_SAVED entries", () => {
|
|
76
|
-
for (let i = 0; i < 30; i++) saveSearch(`query-${i}`);
|
|
77
|
-
assert.equal(loadSavedSearches().length, 25);
|
|
78
|
-
assert.deepEqual(loadSavedSearches()[0], "query-29");
|
|
79
|
-
});
|
|
80
|
-
});
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Saved searches for the mail search bar, persisted in localStorage (#428
|
|
3
|
-
* follow-up, local-only MVP — see doc/design/flows/06-search.md; no backend
|
|
4
|
-
* preference storage yet). Mirrors `recent-searches.ts`: a query already
|
|
5
|
-
* carries its filter chips (`parseSearchTokens` re-derives them), so saving
|
|
6
|
-
* the raw query text is enough to reproduce the full search. Best-effort:
|
|
7
|
-
* storage failures (private mode, quota) degrade to an empty list rather than
|
|
8
|
-
* crashing the search surface.
|
|
9
|
-
*/
|
|
10
|
-
const SAVED_SEARCHES_KEY = "remit.savedSearches";
|
|
11
|
-
const MAX_SAVED = 25;
|
|
12
|
-
|
|
13
|
-
export function loadSavedSearches(): string[] {
|
|
14
|
-
try {
|
|
15
|
-
const raw = localStorage.getItem(SAVED_SEARCHES_KEY);
|
|
16
|
-
if (!raw) return [];
|
|
17
|
-
const parsed: unknown = JSON.parse(raw);
|
|
18
|
-
if (!Array.isArray(parsed)) return [];
|
|
19
|
-
return parsed.filter((q): q is string => typeof q === "string");
|
|
20
|
-
} catch {
|
|
21
|
-
return [];
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function saveSearch(query: string): string[] {
|
|
26
|
-
const trimmed = query.trim();
|
|
27
|
-
if (!trimmed) return loadSavedSearches();
|
|
28
|
-
const next = [
|
|
29
|
-
trimmed,
|
|
30
|
-
...loadSavedSearches().filter((q) => q !== trimmed),
|
|
31
|
-
].slice(0, MAX_SAVED);
|
|
32
|
-
try {
|
|
33
|
-
localStorage.setItem(SAVED_SEARCHES_KEY, JSON.stringify(next));
|
|
34
|
-
} catch {
|
|
35
|
-
return next;
|
|
36
|
-
}
|
|
37
|
-
return next;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function removeSavedSearch(query: string): string[] {
|
|
41
|
-
const next = loadSavedSearches().filter((q) => q !== query);
|
|
42
|
-
try {
|
|
43
|
-
localStorage.setItem(SAVED_SEARCHES_KEY, JSON.stringify(next));
|
|
44
|
-
} catch {
|
|
45
|
-
return next;
|
|
46
|
-
}
|
|
47
|
-
return next;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function isSearchSaved(query: string): boolean {
|
|
51
|
-
return loadSavedSearches().includes(query.trim());
|
|
52
|
-
}
|