@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.
@@ -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,