@remit/web-client 0.0.93 → 0.0.94
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/DailyBrief.tsx +21 -13
- package/src/components/mail/FlaggedList.tsx +42 -0
- package/src/components/mail/MailListHeader.tsx +41 -39
- package/src/components/mail/SelectionWizardHost.tsx +79 -10
- package/src/components/mail/make-filter-entry.test.ts +63 -0
- package/src/hooks/useRulePreview.ts +1 -2
- package/src/lib/list-header-chrome.ts +10 -0
- package/src/lib/organize/rule-model.ts +0 -4
- package/src/lib/wizard-history.test.ts +31 -0
- package/src/lib/wizard-history.ts +55 -7
- package/src/routes/mail.tsx +3 -1
- package/src/components/mail/organize/SearchFilterDialog.render.test.ts +0 -47
- package/src/components/mail/organize/SearchFilterDialog.tsx +0 -90
- package/src/components/mail/organize/SearchFilterEditor.render.test.ts +0 -260
- package/src/components/mail/organize/SearchFilterEditor.tsx +0 -140
- package/src/components/mail/organize/rule-editor-states.stories.tsx +0 -147
- package/src/components/mail/organize/rule-editor-states.tsx +0 -139
- package/src/hooks/useRuleEditorState.ts +0 -182
- package/src/hooks/useSearchFilterSeed.render.test.ts +0 -118
- package/src/hooks/useSearchFilterSeed.ts +0 -79
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
import { BottomSheet } from "@remit/ui";
|
|
2
|
-
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
-
import type { ReactNode } from "react";
|
|
4
|
-
import {
|
|
5
|
-
BackApplyError,
|
|
6
|
-
CommitError,
|
|
7
|
-
FilterSaved,
|
|
8
|
-
JobProgress,
|
|
9
|
-
SavingState,
|
|
10
|
-
} from "./rule-editor-states";
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* `Flows/Smart Organize/Rule editor states` — the non-editing states the rule
|
|
14
|
-
* editor lands in after a commit, rendered from the real components the Organize
|
|
15
|
-
* surface mounts. Creating a standing filter now runs the retroactive back-apply,
|
|
16
|
-
* so a standing save flows Saving → back-apply in flight → done, the same job
|
|
17
|
-
* states the one-time apply reaches. Every state here is what ships, so the flow
|
|
18
|
-
* cannot drift from the story.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
function SheetStage({ children }: { children: ReactNode }) {
|
|
22
|
-
return (
|
|
23
|
-
<div className="relative mx-auto h-dvh w-full shrink-0 overflow-hidden bg-surface sm:my-6 sm:h-[520px] sm:w-[390px] sm:rounded-[2rem] sm:border sm:border-line sm:shadow-sm">
|
|
24
|
-
<BottomSheet open onClose={() => undefined}>
|
|
25
|
-
{children}
|
|
26
|
-
</BottomSheet>
|
|
27
|
-
</div>
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const meta: Meta = {
|
|
32
|
-
title: "Flows/Smart Organize/Rule editor states",
|
|
33
|
-
parameters: { layout: "fullscreen" },
|
|
34
|
-
};
|
|
35
|
-
export default meta;
|
|
36
|
-
|
|
37
|
-
type Story = StoryObj;
|
|
38
|
-
|
|
39
|
-
/** Saving the filter — held while the create request is in flight. */
|
|
40
|
-
export const Saving: Story = {
|
|
41
|
-
render: () => (
|
|
42
|
-
<SheetStage>
|
|
43
|
-
<SavingState />
|
|
44
|
-
</SheetStage>
|
|
45
|
-
),
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* The back-apply running after a standing filter is created — the pass that
|
|
50
|
-
* reaches the mail already in the mailbox, not only the mail that arrives next.
|
|
51
|
-
*/
|
|
52
|
-
export const BackApplyInFlight: Story = {
|
|
53
|
-
name: "Back-apply in flight",
|
|
54
|
-
render: () => (
|
|
55
|
-
<SheetStage>
|
|
56
|
-
<JobProgress
|
|
57
|
-
progress={{
|
|
58
|
-
state: "Running",
|
|
59
|
-
matchedCount: 24,
|
|
60
|
-
appliedCount: 0,
|
|
61
|
-
failedCount: 0,
|
|
62
|
-
errorMessage: "",
|
|
63
|
-
}}
|
|
64
|
-
isDone={false}
|
|
65
|
-
runningLabel="Organizing mail from these senders…"
|
|
66
|
-
onClose={() => undefined}
|
|
67
|
-
/>
|
|
68
|
-
</SheetStage>
|
|
69
|
-
),
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
/** The back-apply finished — the count moved is stated plainly. */
|
|
73
|
-
export const BackApplyDone: Story = {
|
|
74
|
-
name: "Back-apply done",
|
|
75
|
-
render: () => (
|
|
76
|
-
<SheetStage>
|
|
77
|
-
<JobProgress
|
|
78
|
-
progress={{
|
|
79
|
-
state: "Complete",
|
|
80
|
-
matchedCount: 24,
|
|
81
|
-
appliedCount: 24,
|
|
82
|
-
failedCount: 0,
|
|
83
|
-
errorMessage: "",
|
|
84
|
-
}}
|
|
85
|
-
isDone
|
|
86
|
-
runningLabel="Organizing mail from these senders…"
|
|
87
|
-
onClose={() => undefined}
|
|
88
|
-
/>
|
|
89
|
-
</SheetStage>
|
|
90
|
-
),
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
/** The back-apply failed — the rule is still saved, the move is what did not run. */
|
|
94
|
-
export const BackApplyFailed: Story = {
|
|
95
|
-
name: "Back-apply failed",
|
|
96
|
-
render: () => (
|
|
97
|
-
<SheetStage>
|
|
98
|
-
<JobProgress
|
|
99
|
-
progress={{
|
|
100
|
-
state: "Failed",
|
|
101
|
-
matchedCount: 24,
|
|
102
|
-
appliedCount: 6,
|
|
103
|
-
failedCount: 18,
|
|
104
|
-
errorMessage: "Could not reach the mail server. Please try again.",
|
|
105
|
-
}}
|
|
106
|
-
isDone
|
|
107
|
-
runningLabel="Organizing mail from these senders…"
|
|
108
|
-
onClose={() => undefined}
|
|
109
|
-
/>
|
|
110
|
-
</SheetStage>
|
|
111
|
-
),
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* The filter saved, but the back-apply's own request never got off the ground
|
|
116
|
-
* (a 5xx or dropped connection on start). The rule is live for new mail; moving
|
|
117
|
-
* the existing mail is what failed, and it is offered again — never swallowed
|
|
118
|
-
* behind a plain "saved".
|
|
119
|
-
*/
|
|
120
|
-
export const BackApplyStartFailed: Story = {
|
|
121
|
-
name: "Back-apply start failed",
|
|
122
|
-
render: () => (
|
|
123
|
-
<SheetStage>
|
|
124
|
-
<BackApplyError onRetry={() => undefined} onClose={() => undefined} />
|
|
125
|
-
</SheetStage>
|
|
126
|
-
),
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
/** The filter saved with nothing to back-apply — the plain confirmation. */
|
|
130
|
-
export const FilterSavedState: Story = {
|
|
131
|
-
name: "Filter saved",
|
|
132
|
-
render: () => (
|
|
133
|
-
<SheetStage>
|
|
134
|
-
<FilterSaved onClose={() => undefined} />
|
|
135
|
-
</SheetStage>
|
|
136
|
-
),
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
/** The create failed — retry or dismiss. */
|
|
140
|
-
export const CommitFailed: Story = {
|
|
141
|
-
name: "Commit failed",
|
|
142
|
-
render: () => (
|
|
143
|
-
<SheetStage>
|
|
144
|
-
<CommitError onRetry={() => undefined} onClose={() => undefined} />
|
|
145
|
-
</SheetStage>
|
|
146
|
-
),
|
|
147
|
-
};
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
import { Button } from "@remit/ui";
|
|
2
|
-
import { CheckCircle2, Loader2 } from "lucide-react";
|
|
3
|
-
import type { useOrganizeJob } from "@/hooks/useOrganizeJob";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The non-editing states a filter-rule editor lands in after commit, shared by
|
|
7
|
-
* the Organize and filter-from-search surfaces so a back-apply job, a saved
|
|
8
|
-
* standing rule, and a commit failure read identically wherever the rule was
|
|
9
|
-
* authored (RFC 038 D1).
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
export function JobProgress({
|
|
13
|
-
progress,
|
|
14
|
-
isDone,
|
|
15
|
-
runningLabel,
|
|
16
|
-
onClose,
|
|
17
|
-
}: {
|
|
18
|
-
progress: ReturnType<typeof useOrganizeJob>["progress"];
|
|
19
|
-
isDone: boolean;
|
|
20
|
-
/** In-flight copy — the caller names what it is organizing. */
|
|
21
|
-
runningLabel: string;
|
|
22
|
-
onClose: () => void;
|
|
23
|
-
}) {
|
|
24
|
-
const failed = progress.state === "Failed";
|
|
25
|
-
return (
|
|
26
|
-
<div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
|
|
27
|
-
{!isDone ? (
|
|
28
|
-
<Loader2 className="size-8 animate-spin text-accent-2" />
|
|
29
|
-
) : failed ? (
|
|
30
|
-
<span className="text-sm font-semibold text-danger">
|
|
31
|
-
Organize failed
|
|
32
|
-
</span>
|
|
33
|
-
) : (
|
|
34
|
-
<CheckCircle2 className="size-8 text-positive" />
|
|
35
|
-
)}
|
|
36
|
-
|
|
37
|
-
{!isDone && <p className="text-sm font-medium text-fg">{runningLabel}</p>}
|
|
38
|
-
|
|
39
|
-
{isDone && !failed && (
|
|
40
|
-
<div className="text-sm text-fg">
|
|
41
|
-
<p className="font-medium">Done</p>
|
|
42
|
-
<p className="mt-1 text-xs text-fg-muted">
|
|
43
|
-
{progress.appliedCount} of {progress.matchedCount} moved
|
|
44
|
-
{progress.failedCount > 0
|
|
45
|
-
? ` · ${progress.failedCount} failed`
|
|
46
|
-
: ""}
|
|
47
|
-
.
|
|
48
|
-
</p>
|
|
49
|
-
</div>
|
|
50
|
-
)}
|
|
51
|
-
|
|
52
|
-
{isDone && failed && (
|
|
53
|
-
<p className="max-w-xs text-xs text-fg-muted">
|
|
54
|
-
{progress.errorMessage || "Something went wrong. Please try again."}
|
|
55
|
-
</p>
|
|
56
|
-
)}
|
|
57
|
-
|
|
58
|
-
{isDone && (
|
|
59
|
-
<Button variant="primary" onClick={onClose} className="mt-2">
|
|
60
|
-
Done
|
|
61
|
-
</Button>
|
|
62
|
-
)}
|
|
63
|
-
</div>
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function SavingState() {
|
|
68
|
-
return (
|
|
69
|
-
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
|
|
70
|
-
<Loader2 className="size-8 animate-spin text-accent-2" />
|
|
71
|
-
<p className="text-sm font-medium text-fg">Saving rule…</p>
|
|
72
|
-
</div>
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function FilterSaved({ onClose }: { onClose: () => void }) {
|
|
77
|
-
return (
|
|
78
|
-
<div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
|
|
79
|
-
<CheckCircle2 className="size-8 text-positive" />
|
|
80
|
-
<p className="text-sm font-medium text-fg">Filter saved</p>
|
|
81
|
-
<p className="max-w-xs text-xs text-fg-muted">
|
|
82
|
-
You can see it, and when it expires, under Settings › Filters.
|
|
83
|
-
</p>
|
|
84
|
-
<Button variant="primary" onClick={onClose} className="mt-2">
|
|
85
|
-
Done
|
|
86
|
-
</Button>
|
|
87
|
-
</div>
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export function BackApplyError({
|
|
92
|
-
onRetry,
|
|
93
|
-
onClose,
|
|
94
|
-
}: {
|
|
95
|
-
onRetry: () => void;
|
|
96
|
-
onClose: () => void;
|
|
97
|
-
}) {
|
|
98
|
-
return (
|
|
99
|
-
<div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
|
|
100
|
-
<CheckCircle2 className="size-8 text-positive" />
|
|
101
|
-
<p className="text-sm font-medium text-fg">Rule saved</p>
|
|
102
|
-
<p className="max-w-xs text-xs text-fg-muted">
|
|
103
|
-
New mail follows it automatically. Moving the mail already in your
|
|
104
|
-
mailbox didn't run — try again?
|
|
105
|
-
</p>
|
|
106
|
-
<div className="mt-2 flex gap-2">
|
|
107
|
-
<Button variant="primary" onClick={onRetry}>
|
|
108
|
-
Move existing mail
|
|
109
|
-
</Button>
|
|
110
|
-
<Button variant="ghost" onClick={onClose}>
|
|
111
|
-
Not now
|
|
112
|
-
</Button>
|
|
113
|
-
</div>
|
|
114
|
-
</div>
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export function CommitError({
|
|
119
|
-
onRetry,
|
|
120
|
-
onClose,
|
|
121
|
-
}: {
|
|
122
|
-
onRetry: () => void;
|
|
123
|
-
onClose: () => void;
|
|
124
|
-
}) {
|
|
125
|
-
return (
|
|
126
|
-
<div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
|
|
127
|
-
<p className="text-sm font-medium text-danger">Couldn't save the rule</p>
|
|
128
|
-
<p className="max-w-xs text-xs text-fg-muted">Please try again.</p>
|
|
129
|
-
<div className="mt-2 flex gap-2">
|
|
130
|
-
<Button variant="primary" onClick={onRetry}>
|
|
131
|
-
Try again
|
|
132
|
-
</Button>
|
|
133
|
-
<Button variant="ghost" onClick={onClose}>
|
|
134
|
-
Not now
|
|
135
|
-
</Button>
|
|
136
|
-
</div>
|
|
137
|
-
</div>
|
|
138
|
-
);
|
|
139
|
-
}
|
|
@@ -1,182 +0,0 @@
|
|
|
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
|
-
onChangeLabel: (labelId: string) => void;
|
|
32
|
-
onChangeScope: (scope: RuleScope) => void;
|
|
33
|
-
onChangeName: (name: string) => void;
|
|
34
|
-
onChangeUntil: (until: string) => void;
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
interface RuleEditorStateInput {
|
|
39
|
-
/** The rule the editor opens on. */
|
|
40
|
-
initialRule: FilterRule;
|
|
41
|
-
/**
|
|
42
|
-
* How many anchors a widen the user adds inline should carry. A search-seeded
|
|
43
|
-
* rule has no message anchor, so its editor never offers the widen and this is
|
|
44
|
-
* unused; the Organize editor passes its selection size.
|
|
45
|
-
*/
|
|
46
|
-
widenAnchorCount?: number;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* The clause-editing and rule-mutation state a filter-rule editor owns, shared by
|
|
51
|
-
* every entry point onto the chip editor (RFC 038 D1) so the Organize and
|
|
52
|
-
* search-derived surfaces edit the rule identically. Preview and commit stay with
|
|
53
|
-
* the caller — they differ by entry point (an anchored widen count and back-apply
|
|
54
|
-
* job for Organize, a literal count for a search-derived rule).
|
|
55
|
-
*/
|
|
56
|
-
export const useRuleEditorState = ({
|
|
57
|
-
initialRule,
|
|
58
|
-
widenAnchorCount = 1,
|
|
59
|
-
}: RuleEditorStateInput): RuleEditorState => {
|
|
60
|
-
const [rule, setRuleState] = useState<FilterRule>(initialRule);
|
|
61
|
-
const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>();
|
|
62
|
-
const nextClauseId = useRef(0);
|
|
63
|
-
|
|
64
|
-
const setRule = (updater: (current: FilterRule) => FilterRule) =>
|
|
65
|
-
setRuleState(updater);
|
|
66
|
-
|
|
67
|
-
const startAddClause = () =>
|
|
68
|
-
setClauseEdit({
|
|
69
|
-
mode: "add",
|
|
70
|
-
draft: { field: SUPPORTED_CLAUSE_FIELDS[0], value: "" },
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
const startEditClause = (clauseId: string) => {
|
|
74
|
-
const clause = rule.clauses.find((entry) => entry.id === clauseId);
|
|
75
|
-
if (!clause) return;
|
|
76
|
-
setClauseEdit({
|
|
77
|
-
mode: "edit",
|
|
78
|
-
clauseId,
|
|
79
|
-
draft: { field: clause.field, value: clause.value },
|
|
80
|
-
});
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
const changeDraftField = (field: ClauseField) =>
|
|
84
|
-
setClauseEdit((edit) =>
|
|
85
|
-
edit ? { ...edit, draft: { ...edit.draft, field } } : edit,
|
|
86
|
-
);
|
|
87
|
-
|
|
88
|
-
const changeDraftValue = (value: string) =>
|
|
89
|
-
setClauseEdit((edit) =>
|
|
90
|
-
edit ? { ...edit, draft: { ...edit.draft, value } } : edit,
|
|
91
|
-
);
|
|
92
|
-
|
|
93
|
-
const submitClause = () => {
|
|
94
|
-
if (!clauseEdit) return;
|
|
95
|
-
const field = clauseEdit.draft.field;
|
|
96
|
-
const value = normalizeClauseValue(field, clauseEdit.draft.value);
|
|
97
|
-
if (value === "") return;
|
|
98
|
-
setRuleState((current) => {
|
|
99
|
-
if (clauseEdit.mode === "edit" && clauseEdit.clauseId) {
|
|
100
|
-
return {
|
|
101
|
-
...current,
|
|
102
|
-
clauses: current.clauses.map((clause) =>
|
|
103
|
-
clause.id === clauseEdit.clauseId
|
|
104
|
-
? { id: clause.id, field, value }
|
|
105
|
-
: clause,
|
|
106
|
-
),
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
nextClauseId.current += 1;
|
|
110
|
-
return {
|
|
111
|
-
...current,
|
|
112
|
-
clauses: [
|
|
113
|
-
...current.clauses,
|
|
114
|
-
{ id: `clause-${nextClauseId.current}`, field, value },
|
|
115
|
-
],
|
|
116
|
-
};
|
|
117
|
-
});
|
|
118
|
-
setClauseEdit(undefined);
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
const removeClause = (clauseId: string) =>
|
|
122
|
-
setRuleState((current) => ({
|
|
123
|
-
...current,
|
|
124
|
-
clauses: current.clauses.filter((clause) => clause.id !== clauseId),
|
|
125
|
-
}));
|
|
126
|
-
|
|
127
|
-
const addWiden = () =>
|
|
128
|
-
setRuleState((current) => ({
|
|
129
|
-
...current,
|
|
130
|
-
widen: { anchorCount: Math.max(widenAnchorCount, 1) },
|
|
131
|
-
}));
|
|
132
|
-
|
|
133
|
-
const removeWiden = () =>
|
|
134
|
-
setRuleState((current) => ({ ...current, widen: undefined }));
|
|
135
|
-
|
|
136
|
-
const changeMatchOperator = (matchOperator: MatchOperator) =>
|
|
137
|
-
setRuleState((current) => ({ ...current, matchOperator }));
|
|
138
|
-
|
|
139
|
-
const changeMove = (mailboxId: string) =>
|
|
140
|
-
setRuleState((current) => ({
|
|
141
|
-
...current,
|
|
142
|
-
moveMailboxId: mailboxId || undefined,
|
|
143
|
-
}));
|
|
144
|
-
|
|
145
|
-
const changeLabel = (labelId: string) =>
|
|
146
|
-
setRuleState((current) => ({
|
|
147
|
-
...current,
|
|
148
|
-
labelId: labelId || undefined,
|
|
149
|
-
}));
|
|
150
|
-
|
|
151
|
-
const changeScope = (scope: RuleScope) =>
|
|
152
|
-
setRuleState((current) => ({ ...current, scope }));
|
|
153
|
-
|
|
154
|
-
const changeName = (name: string) =>
|
|
155
|
-
setRuleState((current) => ({ ...current, name }));
|
|
156
|
-
|
|
157
|
-
const changeUntil = (until: string) =>
|
|
158
|
-
setRuleState((current) => ({ ...current, until }));
|
|
159
|
-
|
|
160
|
-
return {
|
|
161
|
-
rule,
|
|
162
|
-
setRule,
|
|
163
|
-
handlers: {
|
|
164
|
-
clauseEdit,
|
|
165
|
-
onStartAddClause: startAddClause,
|
|
166
|
-
onStartEditClause: startEditClause,
|
|
167
|
-
onRemoveClause: removeClause,
|
|
168
|
-
onChangeDraftField: changeDraftField,
|
|
169
|
-
onChangeDraftValue: changeDraftValue,
|
|
170
|
-
onSubmitClause: submitClause,
|
|
171
|
-
onCancelClause: () => setClauseEdit(undefined),
|
|
172
|
-
onAddWiden: addWiden,
|
|
173
|
-
onRemoveWiden: removeWiden,
|
|
174
|
-
onChangeMatchOperator: changeMatchOperator,
|
|
175
|
-
onChangeMove: changeMove,
|
|
176
|
-
onChangeLabel: changeLabel,
|
|
177
|
-
onChangeScope: changeScope,
|
|
178
|
-
onChangeName: changeName,
|
|
179
|
-
onChangeUntil: changeUntil,
|
|
180
|
-
},
|
|
181
|
-
};
|
|
182
|
-
};
|
|
@@ -1,118 +0,0 @@
|
|
|
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 COUNTABLE: OrganizeMatchPredicate = {
|
|
32
|
-
matchOperator: "And",
|
|
33
|
-
literalClauses: [{ field: "From", value: "receipts@shop.example" }],
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* A free-text search converts to a body-content clause, which the vector-free
|
|
38
|
-
* matcher refuses. The seed must not ask.
|
|
39
|
-
*/
|
|
40
|
-
const UNCOUNTABLE: OrganizeMatchPredicate = {
|
|
41
|
-
matchOperator: "And",
|
|
42
|
-
literalClauses: [{ field: "HasWords", value: "receipts" }],
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
function probeFor(predicate: OrganizeMatchPredicate) {
|
|
46
|
-
return function Probe() {
|
|
47
|
-
const seed = useSearchFilterSeed("acc-1", predicate);
|
|
48
|
-
return createElement(
|
|
49
|
-
"div",
|
|
50
|
-
null,
|
|
51
|
-
JSON.stringify({
|
|
52
|
-
seedCount: seed.seedCount ?? null,
|
|
53
|
-
isPending: seed.isPending,
|
|
54
|
-
isError: seed.isError,
|
|
55
|
-
uncountable: seed.uncountable,
|
|
56
|
-
}),
|
|
57
|
-
);
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const mount = (
|
|
62
|
-
responder: (call: HttpCall) => unknown,
|
|
63
|
-
predicate: OrganizeMatchPredicate = COUNTABLE,
|
|
64
|
-
): DomHarness => {
|
|
65
|
-
http = mockFetch(responder);
|
|
66
|
-
harness = createDomHarness();
|
|
67
|
-
harness.renderApp(createElement(probeFor(predicate)));
|
|
68
|
-
return harness;
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
const state = (dom: DomHarness) => JSON.parse(dom.text());
|
|
72
|
-
|
|
73
|
-
describe("useSearchFilterSeed", () => {
|
|
74
|
-
it("seeds the count from the literal predicate in a single preview", async () => {
|
|
75
|
-
const dom = mount((call) =>
|
|
76
|
-
call.path.endsWith("/organize/preview")
|
|
77
|
-
? { matchedCount: 12, messageIds: [] }
|
|
78
|
-
: {},
|
|
79
|
-
);
|
|
80
|
-
await dom.flush();
|
|
81
|
-
await dom.flush();
|
|
82
|
-
assert.equal(state(dom).seedCount, 12);
|
|
83
|
-
// One preview, carrying the literal clauses and no anchor.
|
|
84
|
-
const previews = http?.to("/organize/preview") ?? [];
|
|
85
|
-
assert.equal(previews.length, 1);
|
|
86
|
-
assert.equal(previews[0].body?.anchorMessageId, undefined);
|
|
87
|
-
assert.deepEqual(previews[0].body?.literalClauses, [
|
|
88
|
-
{ field: "From", value: "receipts@shop.example" },
|
|
89
|
-
]);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("surfaces the seed error so the caller can offer a retry", async () => {
|
|
93
|
-
const dom = mount(() => httpError(500));
|
|
94
|
-
await dom.flush();
|
|
95
|
-
await dom.flush();
|
|
96
|
-
assert.equal(state(dom).isError, true);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("never asks for a count the matcher cannot produce", async () => {
|
|
100
|
-
const dom = mount(
|
|
101
|
-
(call) =>
|
|
102
|
-
call.path.endsWith("/organize/preview")
|
|
103
|
-
? { matchedCount: 12, messageIds: [] }
|
|
104
|
-
: {},
|
|
105
|
-
UNCOUNTABLE,
|
|
106
|
-
);
|
|
107
|
-
await dom.flush();
|
|
108
|
-
await dom.flush();
|
|
109
|
-
assert.equal(http?.to("/organize/preview").length, 0);
|
|
110
|
-
// Not pending and not an error — the editor opens, it just has no number.
|
|
111
|
-
assert.deepEqual(state(dom), {
|
|
112
|
-
seedCount: null,
|
|
113
|
-
isPending: false,
|
|
114
|
-
isError: false,
|
|
115
|
-
uncountable: true,
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
});
|
|
@@ -1,79 +0,0 @@
|
|
|
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 { isEvaluablePredicate } from "@/lib/organize/rule-model";
|
|
6
|
-
import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
|
|
7
|
-
|
|
8
|
-
interface SearchFilterSeed {
|
|
9
|
-
/** The live count for the converted literal predicate, seeding the editor. */
|
|
10
|
-
seedCount?: number;
|
|
11
|
-
/**
|
|
12
|
-
* The converted predicate is one the vector-free matcher refuses — a free-text
|
|
13
|
-
* search kept as a `HasWords` clause. There is no count to seed and no request
|
|
14
|
-
* to make.
|
|
15
|
-
*/
|
|
16
|
-
uncountable: boolean;
|
|
17
|
-
isPending: boolean;
|
|
18
|
-
isError: boolean;
|
|
19
|
-
error: unknown;
|
|
20
|
-
retry: () => void;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Open the filter-from-search editor: seed the live count from the converted
|
|
25
|
-
* literal predicate. One `POST /organize/preview` under the account the filter
|
|
26
|
-
* targets — the count on screen is the set a literal-only filter applies to.
|
|
27
|
-
*
|
|
28
|
-
* A search whose terms convert to a `HasWords` clause has no count to seed: the
|
|
29
|
-
* vector-free matcher reads no message bodies and rejects the predicate outright
|
|
30
|
-
* (`assertNoBodyContentClause`), so asking is a 500 and a 500 is not a count.
|
|
31
|
-
* That search is still a legitimate standing filter — the index-time matcher
|
|
32
|
-
* does read bodies — so the editor opens on the uncountable reason and holds only
|
|
33
|
-
* the one-time apply, the same way {@link useRulePreview} handles the clause
|
|
34
|
-
* being added by hand.
|
|
35
|
-
*
|
|
36
|
-
* The deployment's semantic reach is not probed here; it is read from the
|
|
37
|
-
* search's own "Related" results on the surface that opens the editor (RFC 038
|
|
38
|
-
* D5), a direct signal that needs no request and cannot hit the wrong account.
|
|
39
|
-
*/
|
|
40
|
-
export const useSearchFilterSeed = (
|
|
41
|
-
accountId: string | undefined,
|
|
42
|
-
literalPredicate: OrganizeMatchPredicate,
|
|
43
|
-
): SearchFilterSeed => {
|
|
44
|
-
const seed = useMutation(organizeOperationsPreviewOrganizeMutation());
|
|
45
|
-
const { mutate, reset } = seed;
|
|
46
|
-
const countable = isEvaluablePredicate(literalPredicate);
|
|
47
|
-
|
|
48
|
-
const run = useCallback(() => {
|
|
49
|
-
if (!accountId || !countable) return;
|
|
50
|
-
reset();
|
|
51
|
-
mutate({
|
|
52
|
-
path: { accountId },
|
|
53
|
-
body: buildOrganizeInput({
|
|
54
|
-
matchOperator: literalPredicate.matchOperator,
|
|
55
|
-
literalClauses: literalPredicate.literalClauses,
|
|
56
|
-
}),
|
|
57
|
-
});
|
|
58
|
-
}, [
|
|
59
|
-
accountId,
|
|
60
|
-
countable,
|
|
61
|
-
literalPredicate.matchOperator,
|
|
62
|
-
literalPredicate.literalClauses,
|
|
63
|
-
mutate,
|
|
64
|
-
reset,
|
|
65
|
-
]);
|
|
66
|
-
|
|
67
|
-
useEffect(() => {
|
|
68
|
-
run();
|
|
69
|
-
}, [run]);
|
|
70
|
-
|
|
71
|
-
return {
|
|
72
|
-
seedCount: seed.data?.matchedCount,
|
|
73
|
-
uncountable: !countable,
|
|
74
|
-
isPending: countable && (seed.isPending || seed.data === undefined),
|
|
75
|
-
isError: countable && seed.isError,
|
|
76
|
-
error: seed.error,
|
|
77
|
-
retry: run,
|
|
78
|
-
};
|
|
79
|
-
};
|