@remit/ui 0.0.42 → 0.0.44
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 +52 -40
- package/src/components/filter-rule-editor.stories.tsx +20 -0
- package/src/components/filter-rule-editor.tsx +42 -9
- package/src/components/filter-rule.render.test.ts +88 -0
- package/src/components/filter-rule.ts +17 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -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="
|
|
170
|
-
<
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
{
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
}
|
|
@@ -227,6 +227,26 @@ export const DegradedStandingWiden: Story = {
|
|
|
227
227
|
},
|
|
228
228
|
};
|
|
229
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Editing a persisted filter (RFC 038 D6): scope and expiry render read-only,
|
|
232
|
+
* with a note that they — and the semantic anchor — are fixed at creation. The
|
|
233
|
+
* name and the literal clauses stay editable; the widen chip is display-only.
|
|
234
|
+
*/
|
|
235
|
+
export const LifecycleLocked: Story = {
|
|
236
|
+
args: {
|
|
237
|
+
rule: {
|
|
238
|
+
...demoRule,
|
|
239
|
+
scope: "until",
|
|
240
|
+
until: "2027-09-01",
|
|
241
|
+
name: "Conference",
|
|
242
|
+
},
|
|
243
|
+
folders: demoFolders,
|
|
244
|
+
preview: READY(31),
|
|
245
|
+
semanticAvailable: false,
|
|
246
|
+
lifecycleLocked: true,
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
|
|
230
250
|
/** Nothing to match yet — the commit says why it is blocked. */
|
|
231
251
|
export const BlockedEmpty: Story = {
|
|
232
252
|
args: {
|
|
@@ -43,6 +43,21 @@ 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[];
|
|
52
|
+
/**
|
|
53
|
+
* Render the scope and expiry read-only (RFC 038 D6). A persisted filter's
|
|
54
|
+
* scope, expiry, and semantic anchor are fixed at creation — the update
|
|
55
|
+
* endpoint carries none of them — so editing a filter shows them as a static
|
|
56
|
+
* summary with a note rather than live controls that would silently discard
|
|
57
|
+
* the change (reader #266 tracks lifting this). The name and the literal
|
|
58
|
+
* predicate stay editable.
|
|
59
|
+
*/
|
|
60
|
+
lifecycleLocked?: boolean;
|
|
46
61
|
/** The inline clause form, when adding or editing a clause. */
|
|
47
62
|
clauseEdit?: ClauseEditState;
|
|
48
63
|
onStartAddClause?: () => void;
|
|
@@ -79,6 +94,8 @@ export function FilterRuleEditor({
|
|
|
79
94
|
folders,
|
|
80
95
|
preview,
|
|
81
96
|
semanticAvailable = false,
|
|
97
|
+
clauseFields,
|
|
98
|
+
lifecycleLocked = false,
|
|
82
99
|
clauseEdit,
|
|
83
100
|
onStartAddClause,
|
|
84
101
|
onStartEditClause,
|
|
@@ -154,6 +171,7 @@ export function FilterRuleEditor({
|
|
|
154
171
|
<ClauseEditor
|
|
155
172
|
draft={clauseEdit.draft}
|
|
156
173
|
mode={clauseEdit.mode}
|
|
174
|
+
fields={clauseFields}
|
|
157
175
|
onChangeField={onChangeDraftField}
|
|
158
176
|
onChangeValue={onChangeDraftValue}
|
|
159
177
|
onSubmit={onSubmitClause}
|
|
@@ -203,14 +221,22 @@ export function FilterRuleEditor({
|
|
|
203
221
|
|
|
204
222
|
<section className="space-y-2">
|
|
205
223
|
<p className="text-xs font-medium text-fg-muted">How long</p>
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
224
|
+
{lifecycleLocked ? (
|
|
225
|
+
<p className="text-xs text-fg">
|
|
226
|
+
{rule.scope === "until"
|
|
227
|
+
? `Until ${rule.until ?? ""}`
|
|
228
|
+
: "Always — runs on matching mail"}
|
|
229
|
+
</p>
|
|
230
|
+
) : (
|
|
231
|
+
<SegmentedControl
|
|
232
|
+
name="rule-scope"
|
|
233
|
+
size="sm"
|
|
234
|
+
aria-label="Rule scope"
|
|
235
|
+
options={scopeOptions}
|
|
236
|
+
value={rule.scope}
|
|
237
|
+
onChange={(value) => onChangeScope?.(value)}
|
|
238
|
+
/>
|
|
239
|
+
)}
|
|
214
240
|
{needsName && (
|
|
215
241
|
<Input
|
|
216
242
|
value={rule.name ?? ""}
|
|
@@ -220,7 +246,7 @@ export function FilterRuleEditor({
|
|
|
220
246
|
className="w-full"
|
|
221
247
|
/>
|
|
222
248
|
)}
|
|
223
|
-
{rule.scope === "until" && (
|
|
249
|
+
{!lifecycleLocked && rule.scope === "until" && (
|
|
224
250
|
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
|
225
251
|
<span>Until</span>
|
|
226
252
|
<Input
|
|
@@ -232,6 +258,13 @@ export function FilterRuleEditor({
|
|
|
232
258
|
/>
|
|
233
259
|
</div>
|
|
234
260
|
)}
|
|
261
|
+
{lifecycleLocked && (
|
|
262
|
+
<p className="text-2xs text-fg-subtle">
|
|
263
|
+
{rule.widen
|
|
264
|
+
? "The scope, expiry, and similar-mail match are set when a filter is created."
|
|
265
|
+
: "The scope and expiry are set when a filter is created."}
|
|
266
|
+
</p>
|
|
267
|
+
)}
|
|
235
268
|
</section>
|
|
236
269
|
|
|
237
270
|
<div className="border-t border-line pt-3">
|
|
@@ -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", () => {
|
|
@@ -497,6 +554,37 @@ describe("FilterRuleEditor", () => {
|
|
|
497
554
|
it("shows the live preview region", () => {
|
|
498
555
|
assert.match(editor(), /47 messages match/);
|
|
499
556
|
});
|
|
557
|
+
|
|
558
|
+
it("renders scope and expiry read-only when the lifecycle is locked", () => {
|
|
559
|
+
const html = editor({
|
|
560
|
+
rule: { ...demoRule, scope: "until", until: "2027-09-01" },
|
|
561
|
+
lifecycleLocked: true,
|
|
562
|
+
});
|
|
563
|
+
// No live scope toggle, no editable date input — a static summary and a note.
|
|
564
|
+
assert.doesNotMatch(html, /aria-label="Rule scope"/);
|
|
565
|
+
assert.doesNotMatch(html, /aria-label="Expiry date"/);
|
|
566
|
+
assert.match(html, /Until 2027-09-01/);
|
|
567
|
+
assert.match(html, /set when a filter is created/);
|
|
568
|
+
// The name stays editable.
|
|
569
|
+
assert.match(html, /aria-label="Rule name"/);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it("names the similar-mail match in the locked note only when a widen is present", () => {
|
|
573
|
+
const withWiden = editor({
|
|
574
|
+
rule: { ...demoRule, widen: { anchorCount: 2 } },
|
|
575
|
+
lifecycleLocked: true,
|
|
576
|
+
});
|
|
577
|
+
assert.match(
|
|
578
|
+
withWiden,
|
|
579
|
+
/similar-mail match are set when a filter is created/,
|
|
580
|
+
);
|
|
581
|
+
const literal = editor({
|
|
582
|
+
rule: { ...demoRule, widen: undefined },
|
|
583
|
+
lifecycleLocked: true,
|
|
584
|
+
});
|
|
585
|
+
assert.match(literal, /scope and expiry are set when a filter is created/);
|
|
586
|
+
assert.doesNotMatch(literal, /similar-mail match/);
|
|
587
|
+
});
|
|
500
588
|
});
|
|
501
589
|
|
|
502
590
|
describe("FilterRuleDialog", () => {
|
|
@@ -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",
|