@remit/web-client 0.0.66 → 0.0.68

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.66",
3
+ "version": "0.0.68",
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": {
@@ -39,11 +39,15 @@ import {
39
39
  useAppShellLayout,
40
40
  } from "@remit/ui";
41
41
  import { useNavigate, useRouterState } from "@tanstack/react-router";
42
- import { type ReactNode, useEffect, useRef, useState } from "react";
42
+ import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
43
43
  import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
44
44
  import { useSearchScope } from "@/hooks/useSearchScope";
45
45
  import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
46
46
  import { useMailContext } from "@/lib/mail-context";
47
+ import {
48
+ convertSearchToRule,
49
+ isConvertible,
50
+ } from "@/lib/organize/search-to-rule";
47
51
  import { loadRecentSearches, saveRecentSearch } from "@/lib/recent-searches";
48
52
  import { resultsScopeForRoute, routeMailboxId } from "@/lib/search-scope";
49
53
  import { showInlineSearchResults } from "@/lib/search-surface";
@@ -53,6 +57,7 @@ import {
53
57
  searchTokenLabel,
54
58
  } from "@/lib/search-tokens";
55
59
  import { spamOfferForResults } from "@/lib/spam-offer";
60
+ import { SearchFilterDialog } from "./organize/SearchFilterDialog";
56
61
 
57
62
  interface MailListHeaderProps {
58
63
  title: string;
@@ -120,6 +125,7 @@ export function MailListHeader({
120
125
  const tier = useLayoutTier();
121
126
  const [searchOpen, setSearchOpen] = useState(false);
122
127
  const [recentSearches, setRecentSearches] = useState(loadRecentSearches);
128
+ const [filterOpen, setFilterOpen] = useState(false);
123
129
 
124
130
  // Leaving the view ends the search: the shell drops the query, and the chrome
125
131
  // it opened — the phone takeover, the expanded tablet field — closes with it
@@ -138,12 +144,18 @@ export function MailListHeader({
138
144
  // parse runs through `useSearchTokenContext`, the same one the engines use,
139
145
  // so a chip appears only for a term that is actually being applied — on a
140
146
  // scoped view `in:` is not resolved and so is never chipped here.
141
- const tokenChips = parseSearchTokens(searchInput, tokenContext).tokens.map(
142
- (token) => ({
143
- label: searchTokenLabel(token),
144
- onRemove: () => onSearchChange(removeSearchToken(searchInput, token)),
145
- }),
147
+ // Memoized on the stable name indexes (not the fresh context object) so the
148
+ // filter dialog receives a stable parse and does not re-run its seed preview
149
+ // every render.
150
+ const { mailboxesByName, accountsByName } = tokenContext;
151
+ const parsed = useMemo(
152
+ () => parseSearchTokens(searchInput, { mailboxesByName, accountsByName }),
153
+ [searchInput, mailboxesByName, accountsByName],
146
154
  );
155
+ const tokenChips = parsed.tokens.map((token) => ({
156
+ label: searchTokenLabel(token),
157
+ onRemove: () => onSearchChange(removeSearchToken(searchInput, token)),
158
+ }));
147
159
  const topMatches = searchResults ?? [];
148
160
  const related = relatedResults ?? [];
149
161
  // Always offer both sections while a query is present; the kit drops the empty
@@ -195,6 +207,39 @@ export function MailListHeader({
195
207
  }
196
208
  : routeScope;
197
209
 
210
+ // Make-this-a-filter (RFC 038 D5): convert the current search to a pre-filled
211
+ // rule and open the shared chip editor. The filter is created for the account
212
+ // an `account:` facet names, else the primary account. The literal filter
213
+ // cannot reproduce the search's semantic reach, so the conversion states it
214
+ // whenever the search surfaced a "Related" section — a direct signal, read
215
+ // here from the semantic results, never a capability probe. Offered whenever a
216
+ // query is active, disabled with a reason when the search has no clause to
217
+ // filter on (only non-clause facets, or a bare folder scope).
218
+ const accountToken = parsed.tokens.find((token) => token.type === "account");
219
+ const targetAccountId = accountToken?.accountId ?? accounts[0]?.accountId;
220
+ const searchHadSemanticReach = related.length > 0;
221
+ const makeFilter =
222
+ hasQuery && targetAccountId
223
+ ? {
224
+ onClick: () => setFilterOpen(true),
225
+ disabledReason: isConvertible(
226
+ convertSearchToRule(parsed, { searchHadSemanticReach }),
227
+ )
228
+ ? undefined
229
+ : "Add a sender or words to filter on",
230
+ }
231
+ : undefined;
232
+ const filterDialog =
233
+ filterOpen && targetAccountId ? (
234
+ <SearchFilterDialog
235
+ open={filterOpen}
236
+ accountId={targetAccountId}
237
+ parsed={parsed}
238
+ searchHadSemanticReach={searchHadSemanticReach}
239
+ onClose={() => setFilterOpen(false)}
240
+ />
241
+ ) : null;
242
+
198
243
  if (tier === "phone" && searchOpen) {
199
244
  const handleSelectResult = (result: SearchResult) => {
200
245
  setRecentSearches(saveRecentSearch(searchInput));
@@ -202,23 +247,27 @@ export function MailListHeader({
202
247
  onSelectSearchResult?.(result);
203
248
  };
204
249
  return (
205
- <MobileSearchView
206
- value={searchInput}
207
- onChange={onSearchChange}
208
- onClear={onSearchClear}
209
- onCancel={() => {
210
- setSearchOpen(false);
211
- onSearchClear();
212
- }}
213
- filter={searchFilter}
214
- recentSearches={recentSearches}
215
- onPickRecent={onSearchChange}
216
- sections={sections}
217
- loading={resultsLoading}
218
- onSelectResult={handleSelectResult}
219
- tokens={tokenChips}
220
- scope={resultsScope}
221
- />
250
+ <>
251
+ <MobileSearchView
252
+ value={searchInput}
253
+ onChange={onSearchChange}
254
+ onClear={onSearchClear}
255
+ onCancel={() => {
256
+ setSearchOpen(false);
257
+ onSearchClear();
258
+ }}
259
+ filter={searchFilter}
260
+ recentSearches={recentSearches}
261
+ onPickRecent={onSearchChange}
262
+ makeFilter={makeFilter}
263
+ sections={sections}
264
+ loading={resultsLoading}
265
+ onSelectResult={handleSelectResult}
266
+ tokens={tokenChips}
267
+ scope={resultsScope}
268
+ />
269
+ {filterDialog}
270
+ </>
222
271
  );
223
272
  }
224
273
 
@@ -247,6 +296,7 @@ export function MailListHeader({
247
296
  onSelectResult={handleSelectInlineResult}
248
297
  tokens={tokenChips}
249
298
  scope={resultsScope}
299
+ makeFilter={makeFilter}
250
300
  />
251
301
  );
252
302
  const body = !showInlineResults ? (
@@ -280,6 +330,7 @@ export function MailListHeader({
280
330
  />
281
331
  <div className="min-h-0 flex-1">{body}</div>
282
332
  {footer}
333
+ {filterDialog}
283
334
  </section>
284
335
  );
285
336
  }
@@ -1,29 +1,30 @@
1
1
  import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
2
  import {
3
- Button,
4
- type ClauseEditState,
5
- type ClauseField,
6
3
  type FilterRule,
7
4
  FilterRuleEditor,
8
5
  type FolderOption,
9
- type MatchOperator,
10
6
  type RuleScope,
11
7
  } from "@remit/ui";
12
8
  import { useQuery } from "@tanstack/react-query";
13
- import { CheckCircle2, Loader2 } from "lucide-react";
14
- import { useMemo, useRef, useState } from "react";
9
+ import { useMemo, useState } from "react";
15
10
  import { useCreateFilter } from "@/hooks/useFilters";
16
11
  import { useOrganizeJob } from "@/hooks/useOrganizeJob";
12
+ import { useRuleEditorState } from "@/hooks/useRuleEditorState";
17
13
  import { useRulePreview } from "@/hooks/useRulePreview";
18
14
  import { getMailboxDisplayName } from "@/lib/folder-roles";
19
15
  import { buildMoveTargets } from "@/lib/move-targets";
20
16
  import {
21
17
  buildInitialRule,
22
- normalizeClauseValue,
23
18
  rulePredicate,
24
19
  ruleToDraft,
25
20
  SUPPORTED_CLAUSE_FIELDS,
26
21
  } from "@/lib/organize/rule-model";
22
+ import {
23
+ CommitError,
24
+ FilterSaved,
25
+ JobProgress,
26
+ SavingState,
27
+ } from "./rule-editor-states";
27
28
 
28
29
  interface OrganizeRuleEditorProps {
29
30
  accountId: string;
@@ -66,7 +67,7 @@ export function OrganizeRuleEditor({
66
67
  const anchorMessageId = selectedMessageIds[0];
67
68
  const senderFallback = semanticUnavailable && senders.length > 0;
68
69
 
69
- const [rule, setRule] = useState<FilterRule>(() =>
70
+ const [initialRule] = useState<FilterRule>(() =>
70
71
  buildInitialRule({
71
72
  anchorMessageId,
72
73
  semanticUnavailable,
@@ -76,8 +77,10 @@ export function OrganizeRuleEditor({
76
77
  seedScope,
77
78
  }),
78
79
  );
79
- const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>();
80
- const nextClauseId = useRef(0);
80
+ const { rule, handlers } = useRuleEditorState({
81
+ initialRule,
82
+ widenAnchorCount: selectedMessageIds.length,
83
+ });
81
84
 
82
85
  const { data: mailboxesData } = useQuery({
83
86
  ...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
@@ -102,93 +105,6 @@ export function OrganizeRuleEditor({
102
105
  const organizeJob = useOrganizeJob(accountId);
103
106
  const createFilter = useCreateFilter(accountId);
104
107
 
105
- const startAddClause = () =>
106
- setClauseEdit({
107
- mode: "add",
108
- draft: { field: SUPPORTED_CLAUSE_FIELDS[0], value: "" },
109
- });
110
-
111
- const startEditClause = (clauseId: string) => {
112
- const clause = rule.clauses.find((entry) => entry.id === clauseId);
113
- if (!clause) return;
114
- setClauseEdit({
115
- mode: "edit",
116
- clauseId,
117
- draft: { field: clause.field, value: clause.value },
118
- });
119
- };
120
-
121
- const changeDraftField = (field: ClauseField) =>
122
- setClauseEdit((edit) =>
123
- edit ? { ...edit, draft: { ...edit.draft, field } } : edit,
124
- );
125
-
126
- const changeDraftValue = (value: string) =>
127
- setClauseEdit((edit) =>
128
- edit ? { ...edit, draft: { ...edit.draft, value } } : edit,
129
- );
130
-
131
- const submitClause = () => {
132
- if (!clauseEdit) return;
133
- const field = clauseEdit.draft.field;
134
- const value = normalizeClauseValue(field, clauseEdit.draft.value);
135
- if (value === "") return;
136
- setRule((current) => {
137
- if (clauseEdit.mode === "edit" && clauseEdit.clauseId) {
138
- return {
139
- ...current,
140
- clauses: current.clauses.map((clause) =>
141
- clause.id === clauseEdit.clauseId
142
- ? { id: clause.id, field, value }
143
- : clause,
144
- ),
145
- };
146
- }
147
- nextClauseId.current += 1;
148
- return {
149
- ...current,
150
- clauses: [
151
- ...current.clauses,
152
- { id: `clause-${nextClauseId.current}`, field, value },
153
- ],
154
- };
155
- });
156
- setClauseEdit(undefined);
157
- };
158
-
159
- const removeClause = (clauseId: string) =>
160
- setRule((current) => ({
161
- ...current,
162
- clauses: current.clauses.filter((clause) => clause.id !== clauseId),
163
- }));
164
-
165
- const addWiden = () =>
166
- setRule((current) => ({
167
- ...current,
168
- widen: { anchorCount: Math.max(selectedMessageIds.length, 1) },
169
- }));
170
-
171
- const removeWiden = () =>
172
- setRule((current) => ({ ...current, widen: undefined }));
173
-
174
- const changeMatchOperator = (matchOperator: MatchOperator) =>
175
- setRule((current) => ({ ...current, matchOperator }));
176
-
177
- const changeMove = (mailboxId: string) =>
178
- setRule((current) => ({
179
- ...current,
180
- moveMailboxId: mailboxId || undefined,
181
- }));
182
-
183
- const changeScope = (scope: RuleScope) =>
184
- setRule((current) => ({ ...current, scope }));
185
-
186
- const changeName = (name: string) =>
187
- setRule((current) => ({ ...current, name }));
188
-
189
- const changeUntil = (until: string) =>
190
- setRule((current) => ({ ...current, until }));
191
-
192
108
  const commit = () => {
193
109
  const draft = ruleToDraft(rule, anchorMessageId);
194
110
  if (rule.scope === "once") {
@@ -207,7 +123,11 @@ export function OrganizeRuleEditor({
207
123
  <JobProgress
208
124
  progress={organizeJob.progress}
209
125
  isDone={organizeJob.isDone}
210
- senderFallback={senderFallback}
126
+ runningLabel={
127
+ senderFallback
128
+ ? "Organizing mail from these senders…"
129
+ : "Organizing similar mail…"
130
+ }
211
131
  onClose={onClose}
212
132
  />
213
133
  );
@@ -232,130 +152,9 @@ export function OrganizeRuleEditor({
232
152
  preview={preview}
233
153
  semanticAvailable={!semanticUnavailable}
234
154
  clauseFields={SUPPORTED_CLAUSE_FIELDS}
235
- clauseEdit={clauseEdit}
236
- onStartAddClause={startAddClause}
237
- onStartEditClause={startEditClause}
238
- onRemoveClause={removeClause}
239
- onChangeDraftField={changeDraftField}
240
- onChangeDraftValue={changeDraftValue}
241
- onSubmitClause={submitClause}
242
- onCancelClause={() => setClauseEdit(undefined)}
243
- onAddWiden={addWiden}
244
- onRemoveWiden={removeWiden}
245
- onChangeMatchOperator={changeMatchOperator}
246
- onChangeMove={changeMove}
247
- onChangeScope={changeScope}
248
- onChangeName={changeName}
249
- onChangeUntil={changeUntil}
155
+ {...handlers}
250
156
  onCommit={commit}
251
157
  onCancel={onClose}
252
158
  />
253
159
  );
254
160
  }
255
-
256
- function JobProgress({
257
- progress,
258
- isDone,
259
- senderFallback,
260
- onClose,
261
- }: {
262
- progress: ReturnType<typeof useOrganizeJob>["progress"];
263
- isDone: boolean;
264
- senderFallback: boolean;
265
- onClose: () => void;
266
- }) {
267
- const failed = progress.state === "Failed";
268
- return (
269
- <div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
270
- {!isDone ? (
271
- <Loader2 className="size-8 animate-spin text-accent-2" />
272
- ) : failed ? (
273
- <span className="text-sm font-semibold text-danger">
274
- Organize failed
275
- </span>
276
- ) : (
277
- <CheckCircle2 className="size-8 text-positive" />
278
- )}
279
-
280
- {!isDone && (
281
- <p className="text-sm font-medium text-fg">
282
- {senderFallback
283
- ? "Organizing mail from these senders…"
284
- : "Organizing similar mail…"}
285
- </p>
286
- )}
287
-
288
- {isDone && !failed && (
289
- <div className="text-sm text-fg">
290
- <p className="font-medium">Done</p>
291
- <p className="mt-1 text-xs text-fg-muted">
292
- {progress.appliedCount} of {progress.matchedCount} moved
293
- {progress.failedCount > 0
294
- ? ` · ${progress.failedCount} failed`
295
- : ""}
296
- .
297
- </p>
298
- </div>
299
- )}
300
-
301
- {isDone && failed && (
302
- <p className="max-w-xs text-xs text-fg-muted">
303
- {progress.errorMessage || "Something went wrong. Please try again."}
304
- </p>
305
- )}
306
-
307
- {isDone && (
308
- <Button variant="primary" onClick={onClose} className="mt-2">
309
- Done
310
- </Button>
311
- )}
312
- </div>
313
- );
314
- }
315
-
316
- function SavingState() {
317
- return (
318
- <div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
319
- <Loader2 className="size-8 animate-spin text-accent-2" />
320
- <p className="text-sm font-medium text-fg">Saving rule…</p>
321
- </div>
322
- );
323
- }
324
-
325
- function FilterSaved({ onClose }: { onClose: () => void }) {
326
- return (
327
- <div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
328
- <CheckCircle2 className="size-8 text-positive" />
329
- <p className="text-sm font-medium text-fg">Filter saved</p>
330
- <p className="max-w-xs text-xs text-fg-muted">
331
- You can see it, and when it expires, under Settings › Filters.
332
- </p>
333
- <Button variant="primary" onClick={onClose} className="mt-2">
334
- Done
335
- </Button>
336
- </div>
337
- );
338
- }
339
-
340
- function CommitError({
341
- onRetry,
342
- onClose,
343
- }: {
344
- onRetry: () => void;
345
- onClose: () => void;
346
- }) {
347
- return (
348
- <div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
349
- <p className="text-sm font-medium text-danger">Couldn't save the rule</p>
350
- <p className="max-w-xs text-xs text-fg-muted">Please try again.</p>
351
- <div className="mt-2 flex gap-2">
352
- <Button variant="primary" onClick={onRetry}>
353
- Try again
354
- </Button>
355
- <Button variant="ghost" onClick={onClose}>
356
- Not now
357
- </Button>
358
- </div>
359
- </div>
360
- );
361
- }
@@ -0,0 +1,36 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import React, { createElement } from "react";
5
+ import { renderToString } from "react-dom/server";
6
+ import { parseSearchTokens } from "@/lib/search-tokens";
7
+ import { SearchFilterDialog } from "./SearchFilterDialog";
8
+
9
+ // The node test loader transpiles remit-ui's `.tsx` with the classic JSX
10
+ // runtime, which references a global `React`.
11
+ (globalThis as { React?: typeof React }).React = React;
12
+
13
+ const render = (open: boolean) =>
14
+ renderToString(
15
+ createElement(
16
+ QueryClientProvider,
17
+ { client: new QueryClient() },
18
+ createElement(SearchFilterDialog, {
19
+ open,
20
+ accountId: "acc-1",
21
+ parsed: parseSearchTokens("receipts", {}),
22
+ searchHadSemanticReach: true,
23
+ onClose: () => undefined,
24
+ }),
25
+ ) as never,
26
+ );
27
+
28
+ describe("SearchFilterDialog", () => {
29
+ it("renders nothing when closed", () => {
30
+ assert.equal(render(false), "");
31
+ });
32
+
33
+ it("shows the conversion step while the seed preview is in flight", () => {
34
+ assert.match(render(true), /Turning your search into a filter/);
35
+ });
36
+ });
@@ -0,0 +1,91 @@
1
+ import { Button, Dialog } from "@remit/ui";
2
+ import { Loader2 } from "lucide-react";
3
+ import { useMemo } from "react";
4
+ import { useSearchFilterSeed } from "@/hooks/useSearchFilterSeed";
5
+ import { rulePredicate } from "@/lib/organize/rule-model";
6
+ import {
7
+ buildSearchRule,
8
+ convertSearchToRule,
9
+ } from "@/lib/organize/search-to-rule";
10
+ import type { ParsedSearchQuery } from "@/lib/search-tokens";
11
+ import { SearchFilterEditor } from "./SearchFilterEditor";
12
+
13
+ interface SearchFilterDialogProps {
14
+ open: boolean;
15
+ /** The account the filter is created for (an `account:` facet, else the active account). */
16
+ accountId: string;
17
+ /** The current search, already split into free text and facets. */
18
+ parsed: ParsedSearchQuery;
19
+ /**
20
+ * The search surfaced semantically-similar mail (a non-empty "Related"
21
+ * section). The literal filter cannot reproduce that reach, so the conversion
22
+ * states it — read from the search's own results, never probed (RFC 038 D5).
23
+ */
24
+ searchHadSemanticReach: boolean;
25
+ onClose: () => void;
26
+ }
27
+
28
+ /**
29
+ * "Make this a filter" (RFC 038 D5). Converts the current search to clauses and
30
+ * hands off to the shared chip editor pre-filled. No new endpoint: the seed
31
+ * count rides `POST /organize/preview` and the commit drives the existing filter
32
+ * CRUD.
33
+ */
34
+ export function SearchFilterDialog({
35
+ open,
36
+ accountId,
37
+ parsed,
38
+ searchHadSemanticReach,
39
+ onClose,
40
+ }: SearchFilterDialogProps) {
41
+ const conversion = useMemo(
42
+ () => convertSearchToRule(parsed, { searchHadSemanticReach }),
43
+ [parsed, searchHadSemanticReach],
44
+ );
45
+ const literalPredicate = useMemo(
46
+ () => rulePredicate(buildSearchRule(conversion)),
47
+ [conversion],
48
+ );
49
+
50
+ const { seedCount, isPending, isError, retry } = useSearchFilterSeed(
51
+ open ? accountId : undefined,
52
+ literalPredicate,
53
+ );
54
+
55
+ if (!open) return null;
56
+
57
+ return (
58
+ <Dialog open={open} onClose={onClose} title="Filter rule">
59
+ {isError ? (
60
+ <div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
61
+ <p className="text-sm font-medium text-danger">
62
+ Couldn't build the filter
63
+ </p>
64
+ <p className="max-w-xs text-xs text-fg-muted">Please try again.</p>
65
+ <div className="mt-2 flex gap-2">
66
+ <Button variant="primary" onClick={retry}>
67
+ Try again
68
+ </Button>
69
+ <Button variant="ghost" onClick={onClose}>
70
+ Not now
71
+ </Button>
72
+ </div>
73
+ </div>
74
+ ) : isPending || seedCount === undefined ? (
75
+ <div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
76
+ <Loader2 className="size-8 animate-spin text-accent-2" />
77
+ <p className="text-sm font-medium text-fg">
78
+ Turning your search into a filter…
79
+ </p>
80
+ </div>
81
+ ) : (
82
+ <SearchFilterEditor
83
+ accountId={accountId}
84
+ conversion={conversion}
85
+ seedCount={seedCount}
86
+ onClose={onClose}
87
+ />
88
+ )}
89
+ </Dialog>
90
+ );
91
+ }