@remit/ui 0.0.41 → 0.0.43
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/filter-clause-chip.tsx +223 -0
- package/src/components/filter-preview-count.tsx +57 -0
- package/src/components/filter-rule-editor.stories.tsx +336 -0
- package/src/components/filter-rule-editor.tsx +311 -0
- package/src/components/filter-rule.render.test.ts +604 -0
- package/src/components/filter-rule.ts +247 -0
- package/src/index.ts +48 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The filter rule as the user edits it (RFC 038).
|
|
3
|
+
*
|
|
4
|
+
* A rule is literal clauses combined under one match operator, an optional
|
|
5
|
+
* semantic widen, an action, and a scope. This module is the vocabulary the
|
|
6
|
+
* chip editor renders — a design model driven from fixtures, not the API.
|
|
7
|
+
* Naming tracks the RFC: rule, clause, widen, scope.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The clause fields (RFC 038 D2). `From`, `Subject`, `HasWords` ship now;
|
|
12
|
+
* `ListId` and `FromDomain` arrive with the vocabulary ticket. Every variant
|
|
13
|
+
* renders here so that ticket slots its fields in without touching the chip.
|
|
14
|
+
*/
|
|
15
|
+
export type ClauseField =
|
|
16
|
+
| "From"
|
|
17
|
+
| "Subject"
|
|
18
|
+
| "HasWords"
|
|
19
|
+
| "ListId"
|
|
20
|
+
| "FromDomain";
|
|
21
|
+
|
|
22
|
+
export interface RuleClause {
|
|
23
|
+
id: string;
|
|
24
|
+
field: ClauseField;
|
|
25
|
+
value: string;
|
|
26
|
+
/**
|
|
27
|
+
* A `From` clause the server derived from the selection because the widen
|
|
28
|
+
* could not run (#251). It is an ordinary visible, editable chip — this flag
|
|
29
|
+
* only annotates where it came from.
|
|
30
|
+
*/
|
|
31
|
+
derived?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** How the clauses combine. Maps to the API's `And` / `Or`. */
|
|
35
|
+
export type MatchOperator = "all" | "any";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* When the rule stops (RFC 038 D1). `once` is a one-time action, `standing`
|
|
39
|
+
* persists, `until` expires on a picked date.
|
|
40
|
+
*/
|
|
41
|
+
export type RuleScope = "once" | "standing" | "until";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The semantic widen (RFC 038 D3): one chip backed by the anchor mechanism.
|
|
45
|
+
* Absent from a rule means the chip is not present; a deployment that cannot
|
|
46
|
+
* serve the widen never offers it at all.
|
|
47
|
+
*/
|
|
48
|
+
export interface RuleWiden {
|
|
49
|
+
/** Anchors the similarity rides on — "similar to these N". */
|
|
50
|
+
anchorCount: number;
|
|
51
|
+
/**
|
|
52
|
+
* The rule carries an anchor this deployment cannot evaluate — created
|
|
53
|
+
* elsewhere, capability since lost (RFC 038 D4). The chip lists as inactive,
|
|
54
|
+
* the rule matches by its literal clauses only, and nothing claims otherwise.
|
|
55
|
+
*/
|
|
56
|
+
inactive?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface FilterRule {
|
|
60
|
+
clauses: RuleClause[];
|
|
61
|
+
matchOperator: MatchOperator;
|
|
62
|
+
widen?: RuleWiden;
|
|
63
|
+
/** The move-to-folder action's destination. Absent leaves mail in place. */
|
|
64
|
+
moveMailboxId?: string;
|
|
65
|
+
scope: RuleScope;
|
|
66
|
+
/** ISO 8601 civil date (`YYYY-MM-DD`) for the `until` scope. */
|
|
67
|
+
until?: string;
|
|
68
|
+
/** Names a standing or timed rule so it can be found in Settings › Filters. */
|
|
69
|
+
name?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface FolderOption {
|
|
73
|
+
id: string;
|
|
74
|
+
label: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The live match count (RFC 038 D1). `stale` marks a count the editor already
|
|
79
|
+
* moved past — a clause changed after it was counted, so the number on screen
|
|
80
|
+
* is the previous rule's until the next preview lands.
|
|
81
|
+
*/
|
|
82
|
+
export type PreviewCount =
|
|
83
|
+
| { status: "loading" }
|
|
84
|
+
| { status: "ready"; count: number; stale?: boolean }
|
|
85
|
+
| { status: "error"; reason: string };
|
|
86
|
+
|
|
87
|
+
const clauseFieldLabels: Record<ClauseField, string> = {
|
|
88
|
+
From: "From",
|
|
89
|
+
Subject: "Subject",
|
|
90
|
+
HasWords: "Has the words",
|
|
91
|
+
ListId: "List",
|
|
92
|
+
FromDomain: "Domain",
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export function clauseFieldLabel(field: ClauseField): string {
|
|
96
|
+
return clauseFieldLabels[field];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const clauseFieldHints: Partial<Record<ClauseField, string>> = {
|
|
100
|
+
ListId:
|
|
101
|
+
"The mailing list's List-Id header, matched exactly. New mail is matched as it arrives — mail delivered before this was set up may not carry it yet.",
|
|
102
|
+
FromDomain:
|
|
103
|
+
"The sender's registrable domain — matches anyone at it, subdomains included (a look-alike like example.com.evil.test never matches).",
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A one-line explanation of what a field matches, for the fields whose semantics
|
|
108
|
+
* aren't self-evident from the label. `From`, `Subject`, and `HasWords` read
|
|
109
|
+
* plainly and carry none. Never leave a control unexplained where it could
|
|
110
|
+
* surprise (ux.md).
|
|
111
|
+
*/
|
|
112
|
+
export function clauseFieldHint(field: ClauseField): string | undefined {
|
|
113
|
+
return clauseFieldHints[field];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The fields a new clause can be added as, in menu order. */
|
|
117
|
+
export const clauseFieldOrder: ClauseField[] = [
|
|
118
|
+
"From",
|
|
119
|
+
"Subject",
|
|
120
|
+
"HasWords",
|
|
121
|
+
"ListId",
|
|
122
|
+
"FromDomain",
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
export function widenChipLabel(widen: RuleWiden): string {
|
|
126
|
+
return `Similar to these ${widen.anchorCount}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const matchOperatorLabels: Record<MatchOperator, string> = {
|
|
130
|
+
all: "Match all",
|
|
131
|
+
any: "Match any",
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export function matchOperatorLabel(operator: MatchOperator): string {
|
|
135
|
+
return matchOperatorLabels[operator];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The join word between chips — "all" reads as "and", "any" as "or". Rendered
|
|
140
|
+
* between clauses so the operator is legible in the rule itself, not only the
|
|
141
|
+
* toggle.
|
|
142
|
+
*/
|
|
143
|
+
export function matchJoinWord(operator: MatchOperator): string {
|
|
144
|
+
return operator === "all" ? "and" : "or";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function previewCountSummary(preview: PreviewCount): string {
|
|
148
|
+
if (preview.status === "loading") return "Counting matches…";
|
|
149
|
+
if (preview.status === "error") return preview.reason;
|
|
150
|
+
if (preview.count === 0) return "No mail matches yet";
|
|
151
|
+
const noun = preview.count === 1 ? "message" : "messages";
|
|
152
|
+
const base = `${preview.count} ${noun} match`;
|
|
153
|
+
return preview.stale ? `${base} — recounting` : base;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const scopeLabels: Record<RuleScope, string> = {
|
|
157
|
+
once: "Just once",
|
|
158
|
+
standing: "Keep doing this",
|
|
159
|
+
until: "Until a date",
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export function scopeLabel(scope: RuleScope): string {
|
|
163
|
+
return scopeLabels[scope];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function commitLabel(scope: RuleScope): string {
|
|
167
|
+
if (scope === "once") return "Apply now";
|
|
168
|
+
if (scope === "standing") return "Save rule";
|
|
169
|
+
return "Save until then";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Why the rule cannot be saved yet, or `undefined` when it is ready. A rule
|
|
174
|
+
* needs at least one live way to match, a folder to move into (the only wired
|
|
175
|
+
* action), and — for the two persisted scopes — a name and, for `until`, a
|
|
176
|
+
* date. It also needs a settled preview: the rule is committable only when the
|
|
177
|
+
* count on screen is the count that will be applied. That makes RFC 038's
|
|
178
|
+
* previewed-set-equals-applied-set contract structural — a consumer cannot save
|
|
179
|
+
* a rule whose match count is still moving. Never disable a control without
|
|
180
|
+
* saying why (ux.md).
|
|
181
|
+
*/
|
|
182
|
+
export function commitBlockedReason(
|
|
183
|
+
rule: FilterRule,
|
|
184
|
+
preview: PreviewCount,
|
|
185
|
+
): string | undefined {
|
|
186
|
+
const hasMatch =
|
|
187
|
+
rule.clauses.length > 0 ||
|
|
188
|
+
(rule.widen !== undefined && !rule.widen.inactive);
|
|
189
|
+
if (!hasMatch) return "Add a clause so the rule has something to match.";
|
|
190
|
+
if (!rule.moveMailboxId)
|
|
191
|
+
return "Pick a folder to move matches into — labeling isn't available yet.";
|
|
192
|
+
if (
|
|
193
|
+
(rule.scope === "standing" || rule.scope === "until") &&
|
|
194
|
+
(rule.name ?? "").trim() === ""
|
|
195
|
+
)
|
|
196
|
+
return "Name this rule so you can find it later.";
|
|
197
|
+
if (rule.scope === "until" && !rule.until)
|
|
198
|
+
return "Pick the date this rule should stop on.";
|
|
199
|
+
if (preview.status === "loading")
|
|
200
|
+
return "Counting matches — save once the count settles.";
|
|
201
|
+
if (preview.status === "ready" && preview.stale)
|
|
202
|
+
return "Recounting matches — save once the count settles.";
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export const demoFolders: FolderOption[] = [
|
|
207
|
+
{ id: "mbx-inbox", label: "Inbox" },
|
|
208
|
+
{ id: "mbx-archive", label: "Archive" },
|
|
209
|
+
{ id: "mbx-receipts", label: "Receipts" },
|
|
210
|
+
{ id: "mbx-travel", label: "Travel" },
|
|
211
|
+
{ id: "mbx-junk", label: "Junk" },
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
export const demoRule: FilterRule = {
|
|
215
|
+
clauses: [
|
|
216
|
+
{ id: "c1", field: "From", value: "notifications@github.com" },
|
|
217
|
+
{ id: "c2", field: "Subject", value: "pull request" },
|
|
218
|
+
],
|
|
219
|
+
matchOperator: "all",
|
|
220
|
+
widen: { anchorCount: 2 },
|
|
221
|
+
moveMailboxId: "mbx-archive",
|
|
222
|
+
scope: "standing",
|
|
223
|
+
name: "GitHub notifications",
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
export const demoVocabularyRule: FilterRule = {
|
|
227
|
+
clauses: [
|
|
228
|
+
{ id: "c1", field: "ListId", value: "python-dev.python.org" },
|
|
229
|
+
{ id: "c2", field: "FromDomain", value: "python.org" },
|
|
230
|
+
{ id: "c3", field: "HasWords", value: "nightly build" },
|
|
231
|
+
],
|
|
232
|
+
matchOperator: "any",
|
|
233
|
+
moveMailboxId: "mbx-archive",
|
|
234
|
+
scope: "standing",
|
|
235
|
+
name: "Python lists",
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export const demoSenderFallbackRule: FilterRule = {
|
|
239
|
+
clauses: [
|
|
240
|
+
{ id: "c1", field: "From", value: "receipts@stripe.com", derived: true },
|
|
241
|
+
{ id: "c2", field: "From", value: "receipts@lyft.com", derived: true },
|
|
242
|
+
],
|
|
243
|
+
matchOperator: "any",
|
|
244
|
+
moveMailboxId: "mbx-receipts",
|
|
245
|
+
scope: "standing",
|
|
246
|
+
name: "Receipts",
|
|
247
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -121,6 +121,54 @@ export {
|
|
|
121
121
|
FieldLabel,
|
|
122
122
|
type FieldLabelProps,
|
|
123
123
|
} from "./components/field-label.js";
|
|
124
|
+
export {
|
|
125
|
+
AddChipButton,
|
|
126
|
+
type AddChipButtonProps,
|
|
127
|
+
ClauseChip,
|
|
128
|
+
type ClauseChipProps,
|
|
129
|
+
type ClauseDraft,
|
|
130
|
+
ClauseEditor,
|
|
131
|
+
type ClauseEditorProps,
|
|
132
|
+
WidenChip,
|
|
133
|
+
type WidenChipProps,
|
|
134
|
+
} from "./components/filter-clause-chip.js";
|
|
135
|
+
export {
|
|
136
|
+
FilterPreviewCount,
|
|
137
|
+
type FilterPreviewCountProps,
|
|
138
|
+
} from "./components/filter-preview-count.js";
|
|
139
|
+
export {
|
|
140
|
+
type ClauseField,
|
|
141
|
+
clauseFieldHint,
|
|
142
|
+
clauseFieldLabel,
|
|
143
|
+
clauseFieldOrder,
|
|
144
|
+
commitBlockedReason,
|
|
145
|
+
commitLabel,
|
|
146
|
+
demoFolders,
|
|
147
|
+
demoRule,
|
|
148
|
+
demoSenderFallbackRule,
|
|
149
|
+
demoVocabularyRule,
|
|
150
|
+
type FilterRule,
|
|
151
|
+
type FolderOption,
|
|
152
|
+
type MatchOperator,
|
|
153
|
+
matchJoinWord,
|
|
154
|
+
matchOperatorLabel,
|
|
155
|
+
type PreviewCount,
|
|
156
|
+
previewCountSummary,
|
|
157
|
+
type RuleClause,
|
|
158
|
+
type RuleScope,
|
|
159
|
+
type RuleWiden,
|
|
160
|
+
scopeLabel,
|
|
161
|
+
widenChipLabel,
|
|
162
|
+
} from "./components/filter-rule.js";
|
|
163
|
+
export {
|
|
164
|
+
type ClauseEditState,
|
|
165
|
+
FilterRuleDialog,
|
|
166
|
+
type FilterRuleDialogProps,
|
|
167
|
+
FilterRuleEditor,
|
|
168
|
+
type FilterRuleEditorProps,
|
|
169
|
+
FilterRuleSheet,
|
|
170
|
+
type FilterRuleSheetProps,
|
|
171
|
+
} from "./components/filter-rule-editor.js";
|
|
124
172
|
export {
|
|
125
173
|
FilterSheet,
|
|
126
174
|
type FilterSheetCategory,
|