@remit/web-client 0.0.67 → 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 +1 -1
- package/src/components/mail/MailListHeader.tsx +74 -23
- package/src/components/mail/organize/OrganizeRuleEditor.tsx +19 -220
- package/src/components/mail/organize/SearchFilterDialog.render.test.ts +36 -0
- package/src/components/mail/organize/SearchFilterDialog.tsx +91 -0
- package/src/components/mail/organize/SearchFilterEditor.render.test.ts +226 -0
- package/src/components/mail/organize/SearchFilterEditor.tsx +132 -0
- package/src/components/mail/organize/rule-editor-states.tsx +112 -0
- package/src/hooks/useRuleEditorState.ts +174 -0
- package/src/hooks/useSearchFilterSeed.render.test.ts +83 -0
- package/src/hooks/useSearchFilterSeed.ts +61 -0
- package/src/lib/organize/search-to-rule.test.ts +146 -0
- package/src/lib/organize/search-to-rule.ts +161 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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 {
|
|
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 [
|
|
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
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|