@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 +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/components/settings/FilterEditor.render.test.ts +281 -0
- package/src/components/settings/FilterEditor.tsx +338 -0
- package/src/components/settings/FilterEditorSurface.tsx +52 -0
- package/src/components/settings/FiltersList.render.test.ts +27 -1
- package/src/components/settings/FiltersList.tsx +33 -5
- package/src/components/settings/settings-filter.stories.tsx +165 -0
- package/src/hooks/useRuleEditorState.ts +174 -0
- package/src/hooks/useRulePreview.ts +10 -5
- package/src/hooks/useSearchFilterSeed.render.test.ts +83 -0
- package/src/hooks/useSearchFilterSeed.ts +61 -0
- package/src/hooks/useUpdateFilter.ts +56 -0
- package/src/lib/organize/filter-edit-model.test.ts +175 -0
- package/src/lib/organize/filter-edit-model.ts +106 -0
- package/src/lib/organize/search-to-rule.test.ts +146 -0
- package/src/lib/organize/search-to-rule.ts +161 -0
- package/src/routes/settings/filters.tsx +26 -1
|
@@ -31,12 +31,17 @@ const filter = (
|
|
|
31
31
|
...overrides,
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
const render = (
|
|
34
|
+
const render = (
|
|
35
|
+
filters: RemitImapFilterResponse[],
|
|
36
|
+
semanticUnavailable = false,
|
|
37
|
+
) =>
|
|
35
38
|
renderToString(
|
|
36
39
|
createElement(FiltersList, {
|
|
37
40
|
filters,
|
|
38
41
|
mailboxName: (id: string) => (id === "mbx-travel" ? "Travel" : undefined),
|
|
42
|
+
onEdit: () => undefined,
|
|
39
43
|
onDelete: () => undefined,
|
|
44
|
+
semanticUnavailable,
|
|
40
45
|
now: NOW,
|
|
41
46
|
}) as never,
|
|
42
47
|
);
|
|
@@ -54,6 +59,27 @@ describe("FiltersList", () => {
|
|
|
54
59
|
assert.match(html, /always/);
|
|
55
60
|
});
|
|
56
61
|
|
|
62
|
+
it("opens the row's rule in the editor", () => {
|
|
63
|
+
const html = render([filter({})]);
|
|
64
|
+
assert.match(html, /Edit filter Travel/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("shows the widen chip for an anchored filter where the deployment can serve it", () => {
|
|
68
|
+
const html = render([filter({ hasAnchor: true })]);
|
|
69
|
+
assert.match(html, /and anything similar/i);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("lists the widen chip inactive on a deployment that cannot evaluate it (D4)", () => {
|
|
73
|
+
const html = render([filter({ hasAnchor: true })], true);
|
|
74
|
+
assert.match(html, /not available here/i);
|
|
75
|
+
assert.doesNotMatch(html, /and anything similar/i);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("shows no widen chip for a purely literal filter", () => {
|
|
79
|
+
const html = render([filter({ hasAnchor: false })]);
|
|
80
|
+
assert.doesNotMatch(html, /similar/i);
|
|
81
|
+
});
|
|
82
|
+
|
|
57
83
|
it("keeps an expired temporary filter visible and marks it Expired (RFC 034 Decision 1.2)", () => {
|
|
58
84
|
const html = render([
|
|
59
85
|
filter({
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
-
import { Badge, Button } from "@remit/ui";
|
|
2
|
+
import { Badge, Button, WidenChip } from "@remit/ui";
|
|
3
3
|
import { Trash2 } from "lucide-react";
|
|
4
4
|
import {
|
|
5
5
|
filterDisplayStatus,
|
|
@@ -11,21 +11,34 @@ interface FiltersListProps {
|
|
|
11
11
|
filters: RemitImapFilterResponse[];
|
|
12
12
|
/** Resolve a destination mailbox id to a folder name for display. */
|
|
13
13
|
mailboxName: (mailboxId: string) => string | undefined;
|
|
14
|
+
/** Open the row's rule in the editor (RFC 038 D6). */
|
|
15
|
+
onEdit: (filterId: string) => void;
|
|
14
16
|
onDelete: (filterId: string) => void;
|
|
15
17
|
deletingFilterId?: string;
|
|
18
|
+
/**
|
|
19
|
+
* This deployment ships no vector pipeline (RFC 038 D4). A filter carrying a
|
|
20
|
+
* semantic anchor lists with its widen chip inactive — it matches by its
|
|
21
|
+
* literal clauses only.
|
|
22
|
+
*/
|
|
23
|
+
semanticUnavailable?: boolean;
|
|
16
24
|
/** Injected for deterministic status in tests; defaults to now. */
|
|
17
25
|
now?: number;
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
/**
|
|
21
|
-
* The account's standing filters
|
|
22
|
-
*
|
|
29
|
+
* The account's standing filters, each opening its rule in the editor (RFC 038
|
|
30
|
+
* D6). Expired temporary filters stay listed and are marked Expired distinctly
|
|
31
|
+
* rather than hidden (RFC 034 Decision 1.2). A filter with a semantic anchor
|
|
32
|
+
* shows the widen chip — inactive where this deployment cannot evaluate it
|
|
33
|
+
* (D4), so the list says honestly that it matches by literal clauses only.
|
|
23
34
|
*/
|
|
24
35
|
export function FiltersList({
|
|
25
36
|
filters,
|
|
26
37
|
mailboxName,
|
|
38
|
+
onEdit,
|
|
27
39
|
onDelete,
|
|
28
40
|
deletingFilterId,
|
|
41
|
+
semanticUnavailable = false,
|
|
29
42
|
now = Date.now(),
|
|
30
43
|
}: FiltersListProps) {
|
|
31
44
|
if (filters.length === 0) {
|
|
@@ -53,7 +66,12 @@ export function FiltersList({
|
|
|
53
66
|
key={filter.filterId}
|
|
54
67
|
className="flex items-start gap-3 px-3 py-2.5"
|
|
55
68
|
>
|
|
56
|
-
<
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
onClick={() => onEdit(filter.filterId)}
|
|
72
|
+
aria-label={`Edit filter ${filter.name}`}
|
|
73
|
+
className="min-w-0 flex-1 rounded-sm text-left hover:opacity-80"
|
|
74
|
+
>
|
|
57
75
|
<div className="flex items-center gap-2">
|
|
58
76
|
<span
|
|
59
77
|
className={`truncate text-sm font-medium ${
|
|
@@ -76,7 +94,17 @@ export function FiltersList({
|
|
|
76
94
|
? " · always"
|
|
77
95
|
: ""}
|
|
78
96
|
</p>
|
|
79
|
-
|
|
97
|
+
{filter.hasAnchor && (
|
|
98
|
+
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
|
99
|
+
<WidenChip
|
|
100
|
+
widen={{
|
|
101
|
+
anchorCount: 1,
|
|
102
|
+
...(semanticUnavailable ? { inactive: true } : {}),
|
|
103
|
+
}}
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
</button>
|
|
80
108
|
<Button
|
|
81
109
|
variant="ghost"
|
|
82
110
|
size="sm"
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
import { BottomSheet, type FolderOption } from "@remit/ui";
|
|
3
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
4
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
5
|
+
import type { ReactNode } from "react";
|
|
6
|
+
import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
|
|
7
|
+
import { FilterEditor } from "./FilterEditor";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `Flows/Settings Filters` — editing a standing filter in the same chip editor
|
|
11
|
+
* the Organize surface uses (RFC 038 D6), rendered from the real web-client
|
|
12
|
+
* component the settings page mounts. Each story opens a persisted filter into
|
|
13
|
+
* the editor: a literal rule, an anchored rule with its widen chip, an
|
|
14
|
+
* until-a-date rule, and the degraded case where the deployment cannot evaluate
|
|
15
|
+
* the anchor and the widen lists inactive (D4).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const ACCOUNT_ID = "acc-1";
|
|
19
|
+
|
|
20
|
+
const FOLDERS: FolderOption[] = [
|
|
21
|
+
{ id: "mbx-inbox", label: "Inbox" },
|
|
22
|
+
{ id: "mbx-archive", label: "Archive" },
|
|
23
|
+
{ id: "mbx-receipts", label: "Receipts" },
|
|
24
|
+
{ id: "mbx-travel", label: "Travel" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const makeFilter = (
|
|
28
|
+
overrides: Partial<RemitImapFilterResponse> = {},
|
|
29
|
+
): RemitImapFilterResponse => ({
|
|
30
|
+
filterId: "f-1",
|
|
31
|
+
accountConfigId: ACCOUNT_ID,
|
|
32
|
+
name: "Receipts",
|
|
33
|
+
scope: "Standing",
|
|
34
|
+
state: "Active",
|
|
35
|
+
hasAnchor: false,
|
|
36
|
+
ruleChangedAt: 0,
|
|
37
|
+
matchOperator: "And",
|
|
38
|
+
literalClauses: [
|
|
39
|
+
{ field: "From", value: "receipts@stripe.com" },
|
|
40
|
+
{ field: "Subject", value: "invoice" },
|
|
41
|
+
],
|
|
42
|
+
actionLabelId: "None",
|
|
43
|
+
actionMailboxId: "mbx-receipts",
|
|
44
|
+
createdAt: 0,
|
|
45
|
+
updatedAt: 0,
|
|
46
|
+
...overrides,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A bare client with no backend. The editor opens on `loading` and its preview
|
|
51
|
+
* settles to the count region's error state — these stories exercise the editor
|
|
52
|
+
* chrome (chips, read-only lifecycle, the widen chip), not the live count.
|
|
53
|
+
*/
|
|
54
|
+
function seededClient(): QueryClient {
|
|
55
|
+
return new QueryClient({
|
|
56
|
+
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Phone frame + a bottom sheet holding the editor, mirroring the mobile home. */
|
|
61
|
+
function SheetStage({ children }: { children: ReactNode }) {
|
|
62
|
+
return (
|
|
63
|
+
<QueryClientProvider client={seededClient()}>
|
|
64
|
+
<ErrorBannerProvider>
|
|
65
|
+
<div className="relative mx-auto h-dvh w-full shrink-0 overflow-hidden bg-surface sm:my-6 sm:h-[760px] sm:w-[390px] sm:rounded-[2rem] sm:border sm:border-line sm:shadow-sm">
|
|
66
|
+
<div className="divide-y divide-line opacity-50">
|
|
67
|
+
{Array.from({ length: 8 }).map((_, index) => (
|
|
68
|
+
<div
|
|
69
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: static backdrop skeleton, no ids
|
|
70
|
+
key={index}
|
|
71
|
+
className="flex items-start gap-3 px-row-inset py-2.5"
|
|
72
|
+
>
|
|
73
|
+
<div className="mt-0.5 size-7 shrink-0 rounded-full bg-surface-sunken" />
|
|
74
|
+
<div className="min-w-0 flex-1 space-y-1">
|
|
75
|
+
<div className="h-2.5 w-1/3 rounded bg-surface-sunken" />
|
|
76
|
+
<div className="h-2 w-2/3 rounded bg-surface-sunken" />
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
<BottomSheet open onClose={() => undefined}>
|
|
82
|
+
{children}
|
|
83
|
+
</BottomSheet>
|
|
84
|
+
</div>
|
|
85
|
+
</ErrorBannerProvider>
|
|
86
|
+
</QueryClientProvider>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const meta: Meta = {
|
|
91
|
+
title: "Flows/Settings Filters",
|
|
92
|
+
parameters: { layout: "fullscreen" },
|
|
93
|
+
};
|
|
94
|
+
export default meta;
|
|
95
|
+
|
|
96
|
+
type Story = StoryObj;
|
|
97
|
+
|
|
98
|
+
/** A standing literal filter opened for editing. */
|
|
99
|
+
export const EditStandingRule: Story = {
|
|
100
|
+
name: "Edit Standing Rule",
|
|
101
|
+
render: () => (
|
|
102
|
+
<SheetStage>
|
|
103
|
+
<FilterEditor
|
|
104
|
+
accountId={ACCOUNT_ID}
|
|
105
|
+
filter={makeFilter()}
|
|
106
|
+
folders={FOLDERS}
|
|
107
|
+
onClose={() => undefined}
|
|
108
|
+
/>
|
|
109
|
+
</SheetStage>
|
|
110
|
+
),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** A filter with a semantic anchor — the widen lists as an active chip. */
|
|
114
|
+
export const EditAnchoredRule: Story = {
|
|
115
|
+
name: "Edit Anchored Rule",
|
|
116
|
+
render: () => (
|
|
117
|
+
<SheetStage>
|
|
118
|
+
<FilterEditor
|
|
119
|
+
accountId={ACCOUNT_ID}
|
|
120
|
+
filter={makeFilter({ hasAnchor: true, name: "GitHub" })}
|
|
121
|
+
folders={FOLDERS}
|
|
122
|
+
onClose={() => undefined}
|
|
123
|
+
/>
|
|
124
|
+
</SheetStage>
|
|
125
|
+
),
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** A temporary filter — the scope loads as until-a-date with the stored date. */
|
|
129
|
+
export const EditUntilADate: Story = {
|
|
130
|
+
name: "Edit Until A Date",
|
|
131
|
+
render: () => (
|
|
132
|
+
<SheetStage>
|
|
133
|
+
<FilterEditor
|
|
134
|
+
accountId={ACCOUNT_ID}
|
|
135
|
+
filter={makeFilter({
|
|
136
|
+
name: "Conference",
|
|
137
|
+
scope: "Temporary",
|
|
138
|
+
expiresAt: "2027-09-01T23:59:59+00:00",
|
|
139
|
+
})}
|
|
140
|
+
folders={FOLDERS}
|
|
141
|
+
onClose={() => undefined}
|
|
142
|
+
/>
|
|
143
|
+
</SheetStage>
|
|
144
|
+
),
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The deployment cannot evaluate the filter's anchor (RFC 038 D4): the widen
|
|
149
|
+
* chip lists inactive, the rule matches by its literal clauses only, and the
|
|
150
|
+
* dead anchor is removable.
|
|
151
|
+
*/
|
|
152
|
+
export const DegradedAnchor: Story = {
|
|
153
|
+
name: "Degraded Anchor",
|
|
154
|
+
render: () => (
|
|
155
|
+
<SheetStage>
|
|
156
|
+
<FilterEditor
|
|
157
|
+
accountId={ACCOUNT_ID}
|
|
158
|
+
filter={makeFilter({ hasAnchor: true, name: "Newsletters" })}
|
|
159
|
+
folders={FOLDERS}
|
|
160
|
+
semanticUnavailable
|
|
161
|
+
onClose={() => undefined}
|
|
162
|
+
/>
|
|
163
|
+
</SheetStage>
|
|
164
|
+
),
|
|
165
|
+
};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ClauseEditState,
|
|
3
|
+
ClauseField,
|
|
4
|
+
FilterRule,
|
|
5
|
+
MatchOperator,
|
|
6
|
+
RuleScope,
|
|
7
|
+
} from "@remit/ui";
|
|
8
|
+
import { useRef, useState } from "react";
|
|
9
|
+
import {
|
|
10
|
+
normalizeClauseValue,
|
|
11
|
+
SUPPORTED_CLAUSE_FIELDS,
|
|
12
|
+
} from "@/lib/organize/rule-model";
|
|
13
|
+
|
|
14
|
+
export interface RuleEditorState {
|
|
15
|
+
rule: FilterRule;
|
|
16
|
+
setRule: (updater: (current: FilterRule) => FilterRule) => void;
|
|
17
|
+
/** The clause-editing and rule-mutation handlers a {@link FilterRuleEditor} binds. */
|
|
18
|
+
handlers: {
|
|
19
|
+
clauseEdit: ClauseEditState | undefined;
|
|
20
|
+
onStartAddClause: () => void;
|
|
21
|
+
onStartEditClause: (clauseId: string) => void;
|
|
22
|
+
onRemoveClause: (clauseId: string) => void;
|
|
23
|
+
onChangeDraftField: (field: ClauseField) => void;
|
|
24
|
+
onChangeDraftValue: (value: string) => void;
|
|
25
|
+
onSubmitClause: () => void;
|
|
26
|
+
onCancelClause: () => void;
|
|
27
|
+
onAddWiden: () => void;
|
|
28
|
+
onRemoveWiden: () => void;
|
|
29
|
+
onChangeMatchOperator: (matchOperator: MatchOperator) => void;
|
|
30
|
+
onChangeMove: (mailboxId: string) => void;
|
|
31
|
+
onChangeScope: (scope: RuleScope) => void;
|
|
32
|
+
onChangeName: (name: string) => void;
|
|
33
|
+
onChangeUntil: (until: string) => void;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface RuleEditorStateInput {
|
|
38
|
+
/** The rule the editor opens on. */
|
|
39
|
+
initialRule: FilterRule;
|
|
40
|
+
/**
|
|
41
|
+
* How many anchors a widen the user adds inline should carry. A search-seeded
|
|
42
|
+
* rule has no message anchor, so its editor never offers the widen and this is
|
|
43
|
+
* unused; the Organize editor passes its selection size.
|
|
44
|
+
*/
|
|
45
|
+
widenAnchorCount?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The clause-editing and rule-mutation state a filter-rule editor owns, shared by
|
|
50
|
+
* every entry point onto the chip editor (RFC 038 D1) so the Organize and
|
|
51
|
+
* search-derived surfaces edit the rule identically. Preview and commit stay with
|
|
52
|
+
* the caller — they differ by entry point (an anchored widen count and back-apply
|
|
53
|
+
* job for Organize, a literal count for a search-derived rule).
|
|
54
|
+
*/
|
|
55
|
+
export const useRuleEditorState = ({
|
|
56
|
+
initialRule,
|
|
57
|
+
widenAnchorCount = 1,
|
|
58
|
+
}: RuleEditorStateInput): RuleEditorState => {
|
|
59
|
+
const [rule, setRuleState] = useState<FilterRule>(initialRule);
|
|
60
|
+
const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>();
|
|
61
|
+
const nextClauseId = useRef(0);
|
|
62
|
+
|
|
63
|
+
const setRule = (updater: (current: FilterRule) => FilterRule) =>
|
|
64
|
+
setRuleState(updater);
|
|
65
|
+
|
|
66
|
+
const startAddClause = () =>
|
|
67
|
+
setClauseEdit({
|
|
68
|
+
mode: "add",
|
|
69
|
+
draft: { field: SUPPORTED_CLAUSE_FIELDS[0], value: "" },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const startEditClause = (clauseId: string) => {
|
|
73
|
+
const clause = rule.clauses.find((entry) => entry.id === clauseId);
|
|
74
|
+
if (!clause) return;
|
|
75
|
+
setClauseEdit({
|
|
76
|
+
mode: "edit",
|
|
77
|
+
clauseId,
|
|
78
|
+
draft: { field: clause.field, value: clause.value },
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const changeDraftField = (field: ClauseField) =>
|
|
83
|
+
setClauseEdit((edit) =>
|
|
84
|
+
edit ? { ...edit, draft: { ...edit.draft, field } } : edit,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const changeDraftValue = (value: string) =>
|
|
88
|
+
setClauseEdit((edit) =>
|
|
89
|
+
edit ? { ...edit, draft: { ...edit.draft, value } } : edit,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const submitClause = () => {
|
|
93
|
+
if (!clauseEdit) return;
|
|
94
|
+
const field = clauseEdit.draft.field;
|
|
95
|
+
const value = normalizeClauseValue(field, clauseEdit.draft.value);
|
|
96
|
+
if (value === "") return;
|
|
97
|
+
setRuleState((current) => {
|
|
98
|
+
if (clauseEdit.mode === "edit" && clauseEdit.clauseId) {
|
|
99
|
+
return {
|
|
100
|
+
...current,
|
|
101
|
+
clauses: current.clauses.map((clause) =>
|
|
102
|
+
clause.id === clauseEdit.clauseId
|
|
103
|
+
? { id: clause.id, field, value }
|
|
104
|
+
: clause,
|
|
105
|
+
),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
nextClauseId.current += 1;
|
|
109
|
+
return {
|
|
110
|
+
...current,
|
|
111
|
+
clauses: [
|
|
112
|
+
...current.clauses,
|
|
113
|
+
{ id: `clause-${nextClauseId.current}`, field, value },
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
setClauseEdit(undefined);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const removeClause = (clauseId: string) =>
|
|
121
|
+
setRuleState((current) => ({
|
|
122
|
+
...current,
|
|
123
|
+
clauses: current.clauses.filter((clause) => clause.id !== clauseId),
|
|
124
|
+
}));
|
|
125
|
+
|
|
126
|
+
const addWiden = () =>
|
|
127
|
+
setRuleState((current) => ({
|
|
128
|
+
...current,
|
|
129
|
+
widen: { anchorCount: Math.max(widenAnchorCount, 1) },
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
const removeWiden = () =>
|
|
133
|
+
setRuleState((current) => ({ ...current, widen: undefined }));
|
|
134
|
+
|
|
135
|
+
const changeMatchOperator = (matchOperator: MatchOperator) =>
|
|
136
|
+
setRuleState((current) => ({ ...current, matchOperator }));
|
|
137
|
+
|
|
138
|
+
const changeMove = (mailboxId: string) =>
|
|
139
|
+
setRuleState((current) => ({
|
|
140
|
+
...current,
|
|
141
|
+
moveMailboxId: mailboxId || undefined,
|
|
142
|
+
}));
|
|
143
|
+
|
|
144
|
+
const changeScope = (scope: RuleScope) =>
|
|
145
|
+
setRuleState((current) => ({ ...current, scope }));
|
|
146
|
+
|
|
147
|
+
const changeName = (name: string) =>
|
|
148
|
+
setRuleState((current) => ({ ...current, name }));
|
|
149
|
+
|
|
150
|
+
const changeUntil = (until: string) =>
|
|
151
|
+
setRuleState((current) => ({ ...current, until }));
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
rule,
|
|
155
|
+
setRule,
|
|
156
|
+
handlers: {
|
|
157
|
+
clauseEdit,
|
|
158
|
+
onStartAddClause: startAddClause,
|
|
159
|
+
onStartEditClause: startEditClause,
|
|
160
|
+
onRemoveClause: removeClause,
|
|
161
|
+
onChangeDraftField: changeDraftField,
|
|
162
|
+
onChangeDraftValue: changeDraftValue,
|
|
163
|
+
onSubmitClause: submitClause,
|
|
164
|
+
onCancelClause: () => setClauseEdit(undefined),
|
|
165
|
+
onAddWiden: addWiden,
|
|
166
|
+
onRemoveWiden: removeWiden,
|
|
167
|
+
onChangeMatchOperator: changeMatchOperator,
|
|
168
|
+
onChangeMove: changeMove,
|
|
169
|
+
onChangeScope: changeScope,
|
|
170
|
+
onChangeName: changeName,
|
|
171
|
+
onChangeUntil: changeUntil,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
};
|
|
@@ -22,20 +22,25 @@ import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
|
|
|
22
22
|
* updates the count, so a slow early preview never overwrites a newer one. The
|
|
23
23
|
* server bounds the match, so a broad predicate over a large mailbox stays a
|
|
24
24
|
* single cheap request; the debounce is what keeps rapid edits from spamming it.
|
|
25
|
+
*
|
|
26
|
+
* `seedCount` is the opening count a widen probe already knows; omit it (as the
|
|
27
|
+
* settings editor does, having no probe) to open on `loading` and preview the
|
|
28
|
+
* loaded predicate immediately.
|
|
25
29
|
*/
|
|
26
30
|
export const useRulePreview = (
|
|
27
31
|
accountId: string | undefined,
|
|
28
32
|
predicate: OrganizeMatchPredicate,
|
|
29
|
-
seedCount
|
|
33
|
+
seedCount?: number,
|
|
30
34
|
): PreviewCount => {
|
|
31
35
|
const mutation = useMutation(organizeOperationsPreviewOrganizeMutation());
|
|
32
36
|
const { mutateAsync } = mutation;
|
|
33
37
|
const currentSignature = predicateSignature(predicate);
|
|
34
38
|
|
|
35
|
-
const [state, setState] = useState<PreviewState>(() =>
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
const [state, setState] = useState<PreviewState>(() =>
|
|
40
|
+
seedCount === undefined
|
|
41
|
+
? {}
|
|
42
|
+
: { count: seedCount, previewedSignature: currentSignature },
|
|
43
|
+
);
|
|
39
44
|
|
|
40
45
|
const predicateRef = useRef(predicate);
|
|
41
46
|
predicateRef.current = predicate;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opening the filter-from-search editor: the seed count comes from the converted
|
|
3
|
+
* literal predicate in one request, under the account the filter targets. No
|
|
4
|
+
* capability probe — the deployment's semantic reach is read from the search's
|
|
5
|
+
* own results on the surface, so a seed success is never blocked by a probe.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { afterEach, describe, it } from "node:test";
|
|
10
|
+
import { createElement } from "react";
|
|
11
|
+
import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
|
|
12
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
13
|
+
import {
|
|
14
|
+
type HttpCall,
|
|
15
|
+
type HttpMock,
|
|
16
|
+
httpError,
|
|
17
|
+
mockFetch,
|
|
18
|
+
} from "../test-support/http";
|
|
19
|
+
import { useSearchFilterSeed } from "./useSearchFilterSeed";
|
|
20
|
+
|
|
21
|
+
let harness: DomHarness | undefined;
|
|
22
|
+
let http: HttpMock | undefined;
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
harness?.close();
|
|
26
|
+
harness = undefined;
|
|
27
|
+
http?.restore();
|
|
28
|
+
http = undefined;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const PREDICATE: OrganizeMatchPredicate = {
|
|
32
|
+
matchOperator: "And",
|
|
33
|
+
literalClauses: [{ field: "HasWords", value: "receipts" }],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function Probe() {
|
|
37
|
+
const seed = useSearchFilterSeed("acc-1", PREDICATE);
|
|
38
|
+
return createElement(
|
|
39
|
+
"div",
|
|
40
|
+
null,
|
|
41
|
+
JSON.stringify({
|
|
42
|
+
seedCount: seed.seedCount ?? null,
|
|
43
|
+
isPending: seed.isPending,
|
|
44
|
+
isError: seed.isError,
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const mount = (responder: (call: HttpCall) => unknown): DomHarness => {
|
|
50
|
+
http = mockFetch(responder);
|
|
51
|
+
harness = createDomHarness();
|
|
52
|
+
harness.renderApp(createElement(Probe));
|
|
53
|
+
return harness;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const state = (dom: DomHarness) => JSON.parse(dom.text());
|
|
57
|
+
|
|
58
|
+
describe("useSearchFilterSeed", () => {
|
|
59
|
+
it("seeds the count from the literal predicate in a single preview", async () => {
|
|
60
|
+
const dom = mount((call) =>
|
|
61
|
+
call.path.endsWith("/organize/preview")
|
|
62
|
+
? { matchedCount: 12, messageIds: [] }
|
|
63
|
+
: {},
|
|
64
|
+
);
|
|
65
|
+
await dom.flush();
|
|
66
|
+
await dom.flush();
|
|
67
|
+
assert.equal(state(dom).seedCount, 12);
|
|
68
|
+
// One preview, carrying the literal clauses and no anchor.
|
|
69
|
+
const previews = http?.to("/organize/preview") ?? [];
|
|
70
|
+
assert.equal(previews.length, 1);
|
|
71
|
+
assert.equal(previews[0].body?.anchorMessageId, undefined);
|
|
72
|
+
assert.deepEqual(previews[0].body?.literalClauses, [
|
|
73
|
+
{ field: "HasWords", value: "receipts" },
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("surfaces the seed error so the caller can offer a retry", async () => {
|
|
78
|
+
const dom = mount(() => httpError(500));
|
|
79
|
+
await dom.flush();
|
|
80
|
+
await dom.flush();
|
|
81
|
+
assert.equal(state(dom).isError, true);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import { useMutation } from "@tanstack/react-query";
|
|
3
|
+
import { useCallback, useEffect } from "react";
|
|
4
|
+
import { buildOrganizeInput } from "@/lib/organize/organize-model";
|
|
5
|
+
import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
|
|
6
|
+
|
|
7
|
+
interface SearchFilterSeed {
|
|
8
|
+
/** The live count for the converted literal predicate, seeding the editor. */
|
|
9
|
+
seedCount?: number;
|
|
10
|
+
isPending: boolean;
|
|
11
|
+
isError: boolean;
|
|
12
|
+
error: unknown;
|
|
13
|
+
retry: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Open the filter-from-search editor: seed the live count from the converted
|
|
18
|
+
* literal predicate. One `POST /organize/preview` under the account the filter
|
|
19
|
+
* targets — the count on screen is the set a literal-only filter applies to.
|
|
20
|
+
*
|
|
21
|
+
* The deployment's semantic reach is not probed here; it is read from the
|
|
22
|
+
* search's own "Related" results on the surface that opens the editor (RFC 038
|
|
23
|
+
* D5), a direct signal that needs no request and cannot hit the wrong account.
|
|
24
|
+
*/
|
|
25
|
+
export const useSearchFilterSeed = (
|
|
26
|
+
accountId: string | undefined,
|
|
27
|
+
literalPredicate: OrganizeMatchPredicate,
|
|
28
|
+
): SearchFilterSeed => {
|
|
29
|
+
const seed = useMutation(organizeOperationsPreviewOrganizeMutation());
|
|
30
|
+
const { mutate, reset } = seed;
|
|
31
|
+
|
|
32
|
+
const run = useCallback(() => {
|
|
33
|
+
if (!accountId) return;
|
|
34
|
+
reset();
|
|
35
|
+
mutate({
|
|
36
|
+
path: { accountId },
|
|
37
|
+
body: buildOrganizeInput({
|
|
38
|
+
matchOperator: literalPredicate.matchOperator,
|
|
39
|
+
literalClauses: literalPredicate.literalClauses,
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
}, [
|
|
43
|
+
accountId,
|
|
44
|
+
literalPredicate.matchOperator,
|
|
45
|
+
literalPredicate.literalClauses,
|
|
46
|
+
mutate,
|
|
47
|
+
reset,
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
run();
|
|
52
|
+
}, [run]);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
seedCount: seed.data?.matchedCount,
|
|
56
|
+
isPending: seed.isPending || seed.data === undefined,
|
|
57
|
+
isError: seed.isError,
|
|
58
|
+
error: seed.error,
|
|
59
|
+
retry: run,
|
|
60
|
+
};
|
|
61
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
filterDetailOperationsGetFilterQueryKey,
|
|
3
|
+
filterDetailOperationsUpdateFilterMutation,
|
|
4
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
5
|
+
import type { RemitImapUpdateFilterInput } from "@remit/api-http-client/types.gen.ts";
|
|
6
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
7
|
+
import { useCallback } from "react";
|
|
8
|
+
import { buildFilterListKey } from "./useFilters";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Patch an existing filter (RFC 038 D6). The server bumps `ruleChangedAt` only
|
|
12
|
+
* when the body touches a predicate or action field, never on a name-only
|
|
13
|
+
* rename (RFC 034 Decision 3.2) — the caller decides which fields to send. On
|
|
14
|
+
* success both the account's filter list and this filter's detail are
|
|
15
|
+
* invalidated so the settings page and any open editor read the saved rule.
|
|
16
|
+
*/
|
|
17
|
+
export const useUpdateFilter = (
|
|
18
|
+
accountId: string | undefined,
|
|
19
|
+
filterId: string | undefined,
|
|
20
|
+
) => {
|
|
21
|
+
const queryClient = useQueryClient();
|
|
22
|
+
const mutation = useMutation({
|
|
23
|
+
...filterDetailOperationsUpdateFilterMutation(),
|
|
24
|
+
onSuccess: () => {
|
|
25
|
+
if (!accountId) return;
|
|
26
|
+
queryClient.invalidateQueries({
|
|
27
|
+
queryKey: buildFilterListKey(accountId),
|
|
28
|
+
});
|
|
29
|
+
if (filterId) {
|
|
30
|
+
queryClient.invalidateQueries({
|
|
31
|
+
queryKey: filterDetailOperationsGetFilterQueryKey({
|
|
32
|
+
path: { accountId, filterId },
|
|
33
|
+
}),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
const { mutate } = mutation;
|
|
39
|
+
|
|
40
|
+
const updateFilter = useCallback(
|
|
41
|
+
(body: RemitImapUpdateFilterInput) => {
|
|
42
|
+
if (!accountId || !filterId) return;
|
|
43
|
+
mutate({ path: { accountId, filterId }, body });
|
|
44
|
+
},
|
|
45
|
+
[accountId, filterId, mutate],
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
updateFilter,
|
|
50
|
+
isPending: mutation.isPending,
|
|
51
|
+
isSuccess: mutation.isSuccess,
|
|
52
|
+
isError: mutation.isError,
|
|
53
|
+
error: mutation.error,
|
|
54
|
+
reset: mutation.reset,
|
|
55
|
+
};
|
|
56
|
+
};
|