@remit/web-client 0.0.275 → 0.0.277
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/routes/settings/-senders-remove-flag.render.test.ts +105 -0
- package/src/routes/settings/senders.tsx +7 -3
- 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.277",
|
|
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",
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
|
|
2
|
+
/**
|
|
3
|
+
* Issue #615: the remove control in Settings > Senders sent `{ flags: {
|
|
4
|
+
* [group]: null } }`. The server's openapi-backend validator doesn't honour
|
|
5
|
+
* `nullable: true` on the `allOf`-wrapped flag schema TypeSpec emits for
|
|
6
|
+
* `TrustedFlag | null`, so it rejects the null form as invalid — the same gap
|
|
7
|
+
* `useToggleTrusted` already works around by sending `{ value: false }`
|
|
8
|
+
* instead of `null`. This mock server enforces that same rejection, so the
|
|
9
|
+
* pre-fix `null` body surfaces as a failed mutation (the row never clears)
|
|
10
|
+
* rather than a silently accepted no-op.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { afterEach, describe, it } from "node:test";
|
|
15
|
+
import type { RemitImapAddressResponse } from "@remit/api-http-client/types.gen.ts";
|
|
16
|
+
import { createElement } from "react";
|
|
17
|
+
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
18
|
+
import {
|
|
19
|
+
type HttpCall,
|
|
20
|
+
type HttpMock,
|
|
21
|
+
httpError,
|
|
22
|
+
mockFetch,
|
|
23
|
+
} from "@/test-support/http";
|
|
24
|
+
import { SearchGroupPane } from "./senders";
|
|
25
|
+
|
|
26
|
+
const ADDRESS_ID = "addr-1";
|
|
27
|
+
|
|
28
|
+
let harness: DomHarness | undefined;
|
|
29
|
+
let http: HttpMock | undefined;
|
|
30
|
+
let mutedFlag: { value: boolean; setAt: number } | undefined;
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
harness?.close();
|
|
34
|
+
harness = undefined;
|
|
35
|
+
http?.restore();
|
|
36
|
+
http = undefined;
|
|
37
|
+
mutedFlag = undefined;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const address = (): RemitImapAddressResponse =>
|
|
41
|
+
({
|
|
42
|
+
addressId: ADDRESS_ID,
|
|
43
|
+
displayName: "Alex Rivera",
|
|
44
|
+
normalizedEmail: "alex@example.com",
|
|
45
|
+
flags: mutedFlag ? { muted: mutedFlag } : {},
|
|
46
|
+
}) as RemitImapAddressResponse;
|
|
47
|
+
|
|
48
|
+
const readFlagsPatch = (call: HttpCall): unknown => {
|
|
49
|
+
const body = call.body as { flags?: Record<string, unknown> } | undefined;
|
|
50
|
+
return body?.flags?.muted;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const respond = (call: HttpCall): unknown => {
|
|
54
|
+
if (call.method === "GET" && call.path === "/addresses/search") {
|
|
55
|
+
return { items: [address()] };
|
|
56
|
+
}
|
|
57
|
+
if (call.method === "PATCH" && call.path === `/addresses/${ADDRESS_ID}`) {
|
|
58
|
+
const patch = readFlagsPatch(call);
|
|
59
|
+
if (patch === null) {
|
|
60
|
+
return httpError(400, "flags.muted: nullable removal is not accepted");
|
|
61
|
+
}
|
|
62
|
+
mutedFlag = patch as { value: boolean; setAt: number };
|
|
63
|
+
return address();
|
|
64
|
+
}
|
|
65
|
+
return {};
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const mount = async (): Promise<void> => {
|
|
69
|
+
mutedFlag = { value: true, setAt: 1_700_000_000_000 };
|
|
70
|
+
http = mockFetch(respond);
|
|
71
|
+
harness = createDomHarness();
|
|
72
|
+
harness.renderApp(
|
|
73
|
+
createElement(SearchGroupPane, { group: "muted", query: "alex" }),
|
|
74
|
+
);
|
|
75
|
+
await harness.waitFor(
|
|
76
|
+
() => harness?.text().includes("alex@example.com") ?? false,
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
describe("removing a sender flag (#615)", () => {
|
|
81
|
+
it("sends a request body the server accepts, and the flag clears from the list", async () => {
|
|
82
|
+
await mount();
|
|
83
|
+
if (!harness) throw new Error("nothing mounted");
|
|
84
|
+
|
|
85
|
+
harness.click(harness.byLabel("Remove muted flag"));
|
|
86
|
+
|
|
87
|
+
await harness.waitFor(
|
|
88
|
+
() => !(harness?.text().includes("alex@example.com") ?? true),
|
|
89
|
+
"the unmuted sender to drop out of the muted list",
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const patches = (http?.calls ?? []).filter(
|
|
93
|
+
(call) =>
|
|
94
|
+
call.method === "PATCH" && call.path === `/addresses/${ADDRESS_ID}`,
|
|
95
|
+
);
|
|
96
|
+
assert.equal(patches.length, 1);
|
|
97
|
+
const sentFlag = readFlagsPatch(patches[0]) as
|
|
98
|
+
| { value: boolean; setAt: number }
|
|
99
|
+
| null
|
|
100
|
+
| undefined;
|
|
101
|
+
assert.notEqual(sentFlag, null, "the server rejects a null flag removal");
|
|
102
|
+
assert.equal(sentFlag?.value, false);
|
|
103
|
+
assert.equal(typeof sentFlag?.setAt, "number");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -174,7 +174,7 @@ function VipGroupPane({
|
|
|
174
174
|
/* Muted / Blocked groups: search-based (backend gap noted) */
|
|
175
175
|
/* ------------------------------------------------------------------ */
|
|
176
176
|
|
|
177
|
-
function SearchGroupPane({
|
|
177
|
+
export function SearchGroupPane({
|
|
178
178
|
group,
|
|
179
179
|
query,
|
|
180
180
|
onCount,
|
|
@@ -303,7 +303,9 @@ function SearchGroupPane({
|
|
|
303
303
|
setRetryId(a.addressId);
|
|
304
304
|
removeMutation.mutate({
|
|
305
305
|
path: { addressId: a.addressId },
|
|
306
|
-
body: {
|
|
306
|
+
body: {
|
|
307
|
+
flags: { [group]: { value: false, setAt: Date.now() } },
|
|
308
|
+
},
|
|
307
309
|
});
|
|
308
310
|
}}
|
|
309
311
|
>
|
|
@@ -321,7 +323,9 @@ function SearchGroupPane({
|
|
|
321
323
|
setRetryId(a.addressId);
|
|
322
324
|
removeMutation.mutate({
|
|
323
325
|
path: { addressId: a.addressId },
|
|
324
|
-
body: {
|
|
326
|
+
body: {
|
|
327
|
+
flags: { [group]: { value: false, setAt: Date.now() } },
|
|
328
|
+
},
|
|
325
329
|
});
|
|
326
330
|
}}
|
|
327
331
|
/>
|
|
@@ -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
|
-
}
|