@remit/ui 0.0.42 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.42",
3
+ "version": "0.0.43",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -3,6 +3,7 @@ import { cn } from "../lib/cn.js";
3
3
  import { Button } from "./button.js";
4
4
  import {
5
5
  type ClauseField,
6
+ clauseFieldHint,
6
7
  clauseFieldLabel,
7
8
  clauseFieldOrder,
8
9
  type RuleClause,
@@ -146,6 +147,12 @@ export interface ClauseEditorProps {
146
147
  draft: ClauseDraft;
147
148
  /** `add` seeds a new clause; `edit` amends an existing one. */
148
149
  mode: "add" | "edit";
150
+ /**
151
+ * The fields offered in the picker, in menu order. Defaults to the whole
152
+ * vocabulary; a consumer narrows it to the fields its deployment can actually
153
+ * match, so the editor never offers a clause the backend cannot evaluate.
154
+ */
155
+ fields?: ClauseField[];
149
156
  onChangeField?: (field: ClauseField) => void;
150
157
  onChangeValue?: (value: string) => void;
151
158
  onSubmit?: () => void;
@@ -160,52 +167,57 @@ export interface ClauseEditorProps {
160
167
  export function ClauseEditor({
161
168
  draft,
162
169
  mode,
170
+ fields = clauseFieldOrder,
163
171
  onChangeField,
164
172
  onChangeValue,
165
173
  onSubmit,
166
174
  onCancel,
167
175
  }: ClauseEditorProps) {
176
+ const hint = clauseFieldHint(draft.field);
168
177
  return (
169
- <div className="flex flex-wrap items-center gap-2 rounded-lg border border-accent-2 bg-surface p-2">
170
- <Select
171
- aria-label="Clause field"
172
- value={draft.field}
173
- onChange={(e) => onChangeField?.(e.target.value as ClauseField)}
174
- className="h-8 w-32 shrink-0"
175
- >
176
- {clauseFieldOrder.map((field) => (
177
- <option key={field} value={field}>
178
- {clauseFieldLabel(field)}
179
- </option>
180
- ))}
181
- </Select>
182
- <Input
183
- aria-label="Clause value"
184
- value={draft.value}
185
- placeholder="value…"
186
- onChange={(e) => onChangeValue?.(e.target.value)}
187
- onKeyDown={(e) => {
188
- if (e.key === "Enter") onSubmit?.();
189
- }}
190
- className="h-8 min-w-40 flex-1"
191
- />
192
- <Button
193
- variant="primary"
194
- size="sm"
195
- onClick={onSubmit}
196
- disabled={draft.value.trim() === ""}
197
- className="shrink-0"
198
- >
199
- {mode === "add" ? "Add" : "Save"}
200
- </Button>
201
- <Button
202
- variant="ghost"
203
- size="sm"
204
- onClick={onCancel}
205
- aria-label="Cancel clause edit"
206
- icon={<X className="size-4" />}
207
- className="shrink-0 px-2"
208
- />
178
+ <div className="space-y-1.5 rounded-lg border border-accent-2 bg-surface p-2">
179
+ <div className="flex flex-wrap items-center gap-2">
180
+ <Select
181
+ aria-label="Clause field"
182
+ value={draft.field}
183
+ onChange={(e) => onChangeField?.(e.target.value as ClauseField)}
184
+ className="h-8 w-32 shrink-0"
185
+ >
186
+ {fields.map((field) => (
187
+ <option key={field} value={field}>
188
+ {clauseFieldLabel(field)}
189
+ </option>
190
+ ))}
191
+ </Select>
192
+ <Input
193
+ aria-label="Clause value"
194
+ value={draft.value}
195
+ placeholder="value…"
196
+ onChange={(e) => onChangeValue?.(e.target.value)}
197
+ onKeyDown={(e) => {
198
+ if (e.key === "Enter") onSubmit?.();
199
+ }}
200
+ className="h-8 min-w-40 flex-1"
201
+ />
202
+ <Button
203
+ variant="primary"
204
+ size="sm"
205
+ onClick={onSubmit}
206
+ disabled={draft.value.trim() === ""}
207
+ className="shrink-0"
208
+ >
209
+ {mode === "add" ? "Add" : "Save"}
210
+ </Button>
211
+ <Button
212
+ variant="ghost"
213
+ size="sm"
214
+ onClick={onCancel}
215
+ aria-label="Cancel clause edit"
216
+ icon={<X className="size-4" />}
217
+ className="shrink-0 px-2"
218
+ />
219
+ </div>
220
+ {hint && <p className="px-0.5 text-2xs text-fg-subtle">{hint}</p>}
209
221
  </div>
210
222
  );
211
223
  }
@@ -43,6 +43,12 @@ export interface FilterRuleEditorProps {
43
43
  * widen still renders, marked inactive.
44
44
  */
45
45
  semanticAvailable?: boolean;
46
+ /**
47
+ * The clause fields the add/edit picker offers, in menu order. Defaults to the
48
+ * whole vocabulary; a consumer narrows it to the fields its deployment can
49
+ * match, so the editor never offers a clause the backend cannot evaluate.
50
+ */
51
+ clauseFields?: ClauseField[];
46
52
  /** The inline clause form, when adding or editing a clause. */
47
53
  clauseEdit?: ClauseEditState;
48
54
  onStartAddClause?: () => void;
@@ -79,6 +85,7 @@ export function FilterRuleEditor({
79
85
  folders,
80
86
  preview,
81
87
  semanticAvailable = false,
88
+ clauseFields,
82
89
  clauseEdit,
83
90
  onStartAddClause,
84
91
  onStartEditClause,
@@ -154,6 +161,7 @@ export function FilterRuleEditor({
154
161
  <ClauseEditor
155
162
  draft={clauseEdit.draft}
156
163
  mode={clauseEdit.mode}
164
+ fields={clauseFields}
157
165
  onChangeField={onChangeDraftField}
158
166
  onChangeValue={onChangeDraftValue}
159
167
  onSubmit={onSubmitClause}
@@ -11,6 +11,7 @@ import {
11
11
  import { FilterPreviewCount } from "./filter-preview-count.js";
12
12
  import {
13
13
  type ClauseField,
14
+ clauseFieldHint,
14
15
  clauseFieldLabel,
15
16
  clauseFieldOrder,
16
17
  commitBlockedReason,
@@ -306,6 +307,62 @@ describe("ClauseEditor", () => {
306
307
  );
307
308
  assert.match(html, />Save</);
308
309
  });
310
+
311
+ it("offers only the fields a consumer allows, so it never proposes a clause the backend can't match", () => {
312
+ const html = render(
313
+ createElement(ClauseEditor, {
314
+ draft: { field: "From", value: "" },
315
+ mode: "add",
316
+ fields: ["From", "Subject", "HasWords"],
317
+ }),
318
+ );
319
+ assert.match(html, />From</);
320
+ assert.match(html, />Subject</);
321
+ assert.doesNotMatch(html, /value="ListId"/);
322
+ assert.doesNotMatch(html, /value="FromDomain"/);
323
+ });
324
+
325
+ it("shows the ListId hint — what it matches and the forward-only caveat", () => {
326
+ const html = render(
327
+ createElement(ClauseEditor, {
328
+ draft: { field: "ListId", value: "" },
329
+ mode: "add",
330
+ }),
331
+ );
332
+ assert.match(html, /List-Id/);
333
+ assert.match(html, /matched as it arrives/i);
334
+ });
335
+
336
+ it("shows the FromDomain hint — the registrable domain, look-alikes excluded", () => {
337
+ const html = render(
338
+ createElement(ClauseEditor, {
339
+ draft: { field: "FromDomain", value: "" },
340
+ mode: "add",
341
+ }),
342
+ );
343
+ assert.match(html, /registrable domain/i);
344
+ });
345
+
346
+ it("carries no hint for the self-evident fields", () => {
347
+ const html = render(
348
+ createElement(ClauseEditor, {
349
+ draft: { field: "Subject", value: "" },
350
+ mode: "add",
351
+ }),
352
+ );
353
+ assert.doesNotMatch(html, /registrable domain/i);
354
+ assert.doesNotMatch(html, /List-Id/);
355
+ });
356
+ });
357
+
358
+ describe("clauseFieldHint", () => {
359
+ it("explains ListId and FromDomain, and leaves the plain fields unexplained", () => {
360
+ assert.match(clauseFieldHint("ListId") ?? "", /List-Id/);
361
+ assert.match(clauseFieldHint("FromDomain") ?? "", /registrable domain/i);
362
+ assert.equal(clauseFieldHint("From"), undefined);
363
+ assert.equal(clauseFieldHint("Subject"), undefined);
364
+ assert.equal(clauseFieldHint("HasWords"), undefined);
365
+ });
309
366
  });
310
367
 
311
368
  describe("FilterPreviewCount", () => {
@@ -96,6 +96,23 @@ export function clauseFieldLabel(field: ClauseField): string {
96
96
  return clauseFieldLabels[field];
97
97
  }
98
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
+
99
116
  /** The fields a new clause can be added as, in menu order. */
100
117
  export const clauseFieldOrder: ClauseField[] = [
101
118
  "From",
package/src/index.ts CHANGED
@@ -138,6 +138,7 @@ export {
138
138
  } from "./components/filter-preview-count.js";
139
139
  export {
140
140
  type ClauseField,
141
+ clauseFieldHint,
141
142
  clauseFieldLabel,
142
143
  clauseFieldOrder,
143
144
  commitBlockedReason,