@remit/web-client 0.0.65 → 0.0.67
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/MessageList.tsx +0 -2
- package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +0 -1
- package/src/components/mail/organize/MobileOrganizeFlow.tsx +14 -10
- package/src/components/mail/organize/OrganizeDialog.render.test.ts +0 -1
- package/src/components/mail/organize/OrganizeDialog.tsx +7 -12
- package/src/components/mail/organize/OrganizeRuleEditor.render.test.ts +358 -0
- package/src/components/mail/organize/OrganizeRuleEditor.tsx +361 -0
- package/src/components/mail/organize/smart-organize.stories.tsx +39 -119
- 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/useRulePreview.ts +91 -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/rule-model.test.ts +297 -0
- package/src/lib/organize/rule-model.ts +205 -0
- package/src/routes/settings/filters.tsx +26 -1
- package/src/components/mail/organize/OrganizePanel.progress.test.ts +0 -113
- package/src/components/mail/organize/OrganizePanel.render.test.ts +0 -170
- package/src/components/mail/organize/OrganizePanel.tsx +0 -420
- package/src/lib/organize/organize-copy.test.ts +0 -142
- package/src/lib/organize/organize-copy.ts +0 -95
|
@@ -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,91 @@
|
|
|
1
|
+
import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import type { PreviewCount } from "@remit/ui";
|
|
3
|
+
import { useMutation } from "@tanstack/react-query";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import { buildOrganizeInput } from "@/lib/organize/organize-model";
|
|
6
|
+
import {
|
|
7
|
+
derivePreview,
|
|
8
|
+
PREVIEW_DEBOUNCE_MS,
|
|
9
|
+
type PreviewState,
|
|
10
|
+
predicateSignature,
|
|
11
|
+
} from "@/lib/organize/rule-model";
|
|
12
|
+
import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The live match count for the edited rule (RFC 038 D1). Every predicate change
|
|
16
|
+
* schedules a debounced `POST /organize/preview` and, until it lands, reports
|
|
17
|
+
* the last count as stale so the count never blanks and the commit gate can
|
|
18
|
+
* hold apply. Seeded with the widen probe's count, so opening the editor shows a
|
|
19
|
+
* settled number without an immediate re-fetch.
|
|
20
|
+
*
|
|
21
|
+
* Out-of-order responses are dropped: only the reply to the most recent request
|
|
22
|
+
* updates the count, so a slow early preview never overwrites a newer one. The
|
|
23
|
+
* server bounds the match, so a broad predicate over a large mailbox stays a
|
|
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.
|
|
29
|
+
*/
|
|
30
|
+
export const useRulePreview = (
|
|
31
|
+
accountId: string | undefined,
|
|
32
|
+
predicate: OrganizeMatchPredicate,
|
|
33
|
+
seedCount?: number,
|
|
34
|
+
): PreviewCount => {
|
|
35
|
+
const mutation = useMutation(organizeOperationsPreviewOrganizeMutation());
|
|
36
|
+
const { mutateAsync } = mutation;
|
|
37
|
+
const currentSignature = predicateSignature(predicate);
|
|
38
|
+
|
|
39
|
+
const [state, setState] = useState<PreviewState>(() =>
|
|
40
|
+
seedCount === undefined
|
|
41
|
+
? {}
|
|
42
|
+
: { count: seedCount, previewedSignature: currentSignature },
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const predicateRef = useRef(predicate);
|
|
46
|
+
predicateRef.current = predicate;
|
|
47
|
+
const latestRequested = useRef(currentSignature);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!accountId) return;
|
|
51
|
+
if (currentSignature === state.previewedSignature) return;
|
|
52
|
+
if (currentSignature === state.errorSignature) return;
|
|
53
|
+
|
|
54
|
+
const handle = setTimeout(() => {
|
|
55
|
+
const signature = currentSignature;
|
|
56
|
+
latestRequested.current = signature;
|
|
57
|
+
mutateAsync({
|
|
58
|
+
path: { accountId },
|
|
59
|
+
body: buildOrganizeInput({ ...predicateRef.current }),
|
|
60
|
+
})
|
|
61
|
+
.then((response) => {
|
|
62
|
+
if (latestRequested.current !== signature) return;
|
|
63
|
+
setState({
|
|
64
|
+
count: response.matchedCount,
|
|
65
|
+
previewedSignature: signature,
|
|
66
|
+
});
|
|
67
|
+
})
|
|
68
|
+
.catch((error: unknown) => {
|
|
69
|
+
if (latestRequested.current !== signature) return;
|
|
70
|
+
setState((previous) => ({
|
|
71
|
+
...previous,
|
|
72
|
+
error:
|
|
73
|
+
error instanceof Error
|
|
74
|
+
? error.message
|
|
75
|
+
: "Couldn't count matches.",
|
|
76
|
+
errorSignature: signature,
|
|
77
|
+
}));
|
|
78
|
+
});
|
|
79
|
+
}, PREVIEW_DEBOUNCE_MS);
|
|
80
|
+
|
|
81
|
+
return () => clearTimeout(handle);
|
|
82
|
+
}, [
|
|
83
|
+
accountId,
|
|
84
|
+
currentSignature,
|
|
85
|
+
state.previewedSignature,
|
|
86
|
+
state.errorSignature,
|
|
87
|
+
mutateAsync,
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
return derivePreview(state, currentSignature);
|
|
91
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
+
import type { FilterRule } from "@remit/ui";
|
|
5
|
+
import {
|
|
6
|
+
buildUpdateFilterInput,
|
|
7
|
+
expiresAtToPickedDate,
|
|
8
|
+
filterToRule,
|
|
9
|
+
ruleChangesPredicateOrAction,
|
|
10
|
+
} from "./filter-edit-model";
|
|
11
|
+
|
|
12
|
+
const filter = (
|
|
13
|
+
overrides: Partial<RemitImapFilterResponse> = {},
|
|
14
|
+
): RemitImapFilterResponse => ({
|
|
15
|
+
filterId: "f-1",
|
|
16
|
+
accountConfigId: "acc-1",
|
|
17
|
+
name: "Receipts",
|
|
18
|
+
scope: "Standing",
|
|
19
|
+
state: "Active",
|
|
20
|
+
hasAnchor: false,
|
|
21
|
+
ruleChangedAt: 0,
|
|
22
|
+
matchOperator: "And",
|
|
23
|
+
literalClauses: [{ field: "From", value: "receipts@stripe.com" }],
|
|
24
|
+
actionLabelId: "None",
|
|
25
|
+
actionMailboxId: "mbx-receipts",
|
|
26
|
+
createdAt: 0,
|
|
27
|
+
updatedAt: 0,
|
|
28
|
+
...overrides,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("filterToRule", () => {
|
|
32
|
+
it("loads the persisted predicate, action and name unchanged", () => {
|
|
33
|
+
const rule = filterToRule(
|
|
34
|
+
filter({
|
|
35
|
+
matchOperator: "Or",
|
|
36
|
+
literalClauses: [
|
|
37
|
+
{ field: "From", value: "a@x.com" },
|
|
38
|
+
{ field: "Subject", value: "invoice" },
|
|
39
|
+
],
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
assert.equal(rule.matchOperator, "any");
|
|
43
|
+
assert.equal(rule.name, "Receipts");
|
|
44
|
+
assert.equal(rule.moveMailboxId, "mbx-receipts");
|
|
45
|
+
assert.equal(rule.scope, "standing");
|
|
46
|
+
assert.deepEqual(
|
|
47
|
+
rule.clauses.map((clause) => [clause.field, clause.value]),
|
|
48
|
+
[
|
|
49
|
+
["From", "a@x.com"],
|
|
50
|
+
["Subject", "invoice"],
|
|
51
|
+
],
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("maps the None move sentinel to no action", () => {
|
|
56
|
+
const rule = filterToRule(filter({ actionMailboxId: "None" }));
|
|
57
|
+
assert.equal(rule.moveMailboxId, undefined);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("loads a Temporary filter as an until-a-date rule", () => {
|
|
61
|
+
const rule = filterToRule(
|
|
62
|
+
filter({ scope: "Temporary", expiresAt: "2027-03-04T23:59:59+00:00" }),
|
|
63
|
+
);
|
|
64
|
+
assert.equal(rule.scope, "until");
|
|
65
|
+
assert.match(rule.until ?? "", /^\d{4}-\d{2}-\d{2}$/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("shows a semantic anchor as an active widen chip when the deployment can serve it", () => {
|
|
69
|
+
const rule = filterToRule(filter({ hasAnchor: true }));
|
|
70
|
+
assert.ok(rule.widen);
|
|
71
|
+
assert.equal(rule.widen?.inactive, undefined);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("marks the widen inactive on a deployment that cannot evaluate it (D4)", () => {
|
|
75
|
+
const rule = filterToRule(filter({ hasAnchor: true }), true);
|
|
76
|
+
assert.equal(rule.widen?.inactive, true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("carries no widen when the filter has no anchor", () => {
|
|
80
|
+
assert.equal(filterToRule(filter({ hasAnchor: false })).widen, undefined);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("expiresAtToPickedDate", () => {
|
|
85
|
+
it("returns empty for absent or unparseable input", () => {
|
|
86
|
+
assert.equal(expiresAtToPickedDate(undefined), "");
|
|
87
|
+
assert.equal(expiresAtToPickedDate("not-a-date"), "");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("reads the local calendar day", () => {
|
|
91
|
+
assert.match(
|
|
92
|
+
expiresAtToPickedDate("2027-03-04T12:00:00Z"),
|
|
93
|
+
/^2027-03-0[45]$/,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("ruleChangesPredicateOrAction", () => {
|
|
99
|
+
const base = filterToRule(filter({ hasAnchor: true }));
|
|
100
|
+
|
|
101
|
+
it("is false for an identical rule and for a rename only", () => {
|
|
102
|
+
assert.equal(ruleChangesPredicateOrAction(base, base), false);
|
|
103
|
+
assert.equal(
|
|
104
|
+
ruleChangesPredicateOrAction({ ...base, name: "New name" }, base),
|
|
105
|
+
false,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("is true when a clause, operator, move target or widen changes", () => {
|
|
110
|
+
assert.equal(
|
|
111
|
+
ruleChangesPredicateOrAction({ ...base, matchOperator: "any" }, base),
|
|
112
|
+
true,
|
|
113
|
+
);
|
|
114
|
+
assert.equal(
|
|
115
|
+
ruleChangesPredicateOrAction(
|
|
116
|
+
{ ...base, moveMailboxId: "mbx-other" },
|
|
117
|
+
base,
|
|
118
|
+
),
|
|
119
|
+
true,
|
|
120
|
+
);
|
|
121
|
+
assert.equal(
|
|
122
|
+
ruleChangesPredicateOrAction({ ...base, widen: undefined }, base),
|
|
123
|
+
true,
|
|
124
|
+
);
|
|
125
|
+
assert.equal(
|
|
126
|
+
ruleChangesPredicateOrAction(
|
|
127
|
+
{ ...base, clauses: [{ id: "c", field: "Subject", value: "x" }] },
|
|
128
|
+
base,
|
|
129
|
+
),
|
|
130
|
+
true,
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("buildUpdateFilterInput", () => {
|
|
136
|
+
const original = filterToRule(filter());
|
|
137
|
+
|
|
138
|
+
it("sends only the name for a cosmetic rename — no predicate fields", () => {
|
|
139
|
+
const body = buildUpdateFilterInput(
|
|
140
|
+
{ ...original, name: "Invoices" },
|
|
141
|
+
original,
|
|
142
|
+
);
|
|
143
|
+
assert.deepEqual(body, { name: "Invoices" });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("sends the predicate and action on a rule change, so the server bumps ruleChangedAt", () => {
|
|
147
|
+
const changed: FilterRule = {
|
|
148
|
+
...original,
|
|
149
|
+
matchOperator: "any",
|
|
150
|
+
clauses: [{ id: "c", field: "ListId", value: "list.example.com" }],
|
|
151
|
+
moveMailboxId: "mbx-archive",
|
|
152
|
+
};
|
|
153
|
+
const body = buildUpdateFilterInput(changed, original);
|
|
154
|
+
assert.equal(body.matchOperator, "Or");
|
|
155
|
+
assert.equal(body.actionMailboxId, "mbx-archive");
|
|
156
|
+
assert.deepEqual(body.literalClauses, [
|
|
157
|
+
{ field: "ListId", value: "list.example.com" },
|
|
158
|
+
]);
|
|
159
|
+
assert.equal("name" in body, false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("drops a cleared move target to the None sentinel", () => {
|
|
163
|
+
const changed = {
|
|
164
|
+
...original,
|
|
165
|
+
moveMailboxId: undefined,
|
|
166
|
+
matchOperator: "any" as const,
|
|
167
|
+
};
|
|
168
|
+
const body = buildUpdateFilterInput(changed, original);
|
|
169
|
+
assert.equal(body.actionMailboxId, "None");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("is empty when nothing changed", () => {
|
|
173
|
+
assert.deepEqual(buildUpdateFilterInput(original, original), {});
|
|
174
|
+
});
|
|
175
|
+
});
|