@remit/ui 0.0.48 → 0.0.49

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.48",
3
+ "version": "0.0.49",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -254,11 +254,13 @@ export const DegradedStandingWiden: Story = {
254
254
  };
255
255
 
256
256
  /**
257
- * Editing a persisted filter (RFC 038 D6): scope and expiry render read-only,
258
- * with a note that they and the semantic anchor are fixed at creation. The
259
- * name and the literal clauses stay editable; the widen chip is display-only.
257
+ * Editing a persisted filter (RFC 038 D6, reader #266): scope and expiry stay
258
+ * live and editable a standing filter can move to "until a date" and back,
259
+ * or its date can change. The semantic anchor is the one thing fixed at
260
+ * creation: the widen chip renders display-only with a one-line note, and
261
+ * "Just once" drops out of the scope toggle since no saved filter can hold it.
260
262
  */
261
- export const LifecycleLocked: Story = {
263
+ export const AnchorLocked: Story = {
262
264
  args: {
263
265
  rule: {
264
266
  ...demoRule,
@@ -269,7 +271,7 @@ export const LifecycleLocked: Story = {
269
271
  folders: demoFolders,
270
272
  preview: READY(31),
271
273
  semanticAvailable: false,
272
- lifecycleLocked: true,
274
+ anchorLocked: true,
273
275
  },
274
276
  };
275
277
 
@@ -56,14 +56,20 @@ export interface FilterRuleEditorProps {
56
56
  */
57
57
  clauseFields?: ClauseField[];
58
58
  /**
59
- * Render the scope and expiry read-only (RFC 038 D6). A persisted filter's
60
- * scope, expiry, and semantic anchor are fixed at creation the update
61
- * endpoint carries none of them so editing a filter shows them as a static
62
- * summary with a note rather than live controls that would silently discard
63
- * the change (reader #266 tracks lifting this). The name and the literal
64
- * predicate stay editable.
59
+ * Whether the semantic anchor is fixed and cannot be added, removed, or
60
+ * repointed (RFC 038 D6, reader #266). True for an existing, persisted
61
+ * filter the update endpoint carries no anchor field at all, so
62
+ * repointing one would silently change what a saved filter matches with
63
+ * nothing visible changing, and that deserves a new filter instead. The
64
+ * widen chip renders display-only (no remove affordance regardless of
65
+ * `onRemoveWiden`) and carries a one-line note explaining why.
66
+ *
67
+ * Scope and expiry are NOT covered by this flag — they stay editable even
68
+ * on an existing filter (reader #266). The only other thing this locks is
69
+ * the "Just once" scope option, meaningless for an already-persisted
70
+ * filter, which is dropped from the scope toggle.
65
71
  */
66
- lifecycleLocked?: boolean;
72
+ anchorLocked?: boolean;
67
73
  /** The inline clause form, when adding or editing a clause. */
68
74
  clauseEdit?: ClauseEditState;
69
75
  onStartAddClause?: () => void;
@@ -252,7 +258,7 @@ export function FilterRuleEditor({
252
258
  notice,
253
259
  semanticAvailable = false,
254
260
  clauseFields,
255
- lifecycleLocked = false,
261
+ anchorLocked = false,
256
262
  clauseEdit,
257
263
  onStartAddClause,
258
264
  onStartEditClause,
@@ -278,6 +284,12 @@ export function FilterRuleEditor({
278
284
  const join = matchJoinWord(rule.matchOperator);
279
285
  const blockedReason = commitBlockedReason(rule, preview);
280
286
  const needsName = rule.scope === "standing" || rule.scope === "until";
287
+ // A persisted filter is always Standing or Temporary — "once" was never a
288
+ // scope a saved row could hold, so an existing filter's editor never offers
289
+ // it (reader #266).
290
+ const availableScopeOptions = anchorLocked
291
+ ? scopeOptions.filter((option) => option.value !== "once")
292
+ : scopeOptions;
281
293
 
282
294
  return (
283
295
  <div className="flex min-h-0 flex-col">
@@ -314,16 +326,22 @@ export function FilterRuleEditor({
314
326
  {join}
315
327
  </span>
316
328
  )}
317
- <WidenChip widen={rule.widen} onRemove={onRemoveWiden} />
329
+ <WidenChip
330
+ widen={rule.widen}
331
+ onRemove={anchorLocked ? undefined : onRemoveWiden}
332
+ />
318
333
  </>
319
334
  )}
320
335
 
321
336
  {!clauseEdit && (
322
337
  <AddChipButton label="Add clause" onClick={onStartAddClause} />
323
338
  )}
324
- {!rule.widen && semanticAvailable && !clauseEdit && (
325
- <AddChipButton label="…and similar" onClick={onAddWiden} />
326
- )}
339
+ {!rule.widen &&
340
+ !anchorLocked &&
341
+ semanticAvailable &&
342
+ !clauseEdit && (
343
+ <AddChipButton label="…and similar" onClick={onAddWiden} />
344
+ )}
327
345
  </div>
328
346
 
329
347
  {clauseEdit && (
@@ -338,6 +356,13 @@ export function FilterRuleEditor({
338
356
  />
339
357
  )}
340
358
 
359
+ {anchorLocked && rule.widen && (
360
+ <p className="text-2xs text-fg-subtle">
361
+ This filter's similar-mail match is fixed to the message it was
362
+ created from — create a new filter to change it.
363
+ </p>
364
+ )}
365
+
341
366
  {showOperator && (
342
367
  <div className="flex items-center gap-2">
343
368
  <span className="text-xs text-fg-muted">Match</span>
@@ -374,22 +399,14 @@ export function FilterRuleEditor({
374
399
 
375
400
  <section className="space-y-2">
376
401
  <p className="text-xs font-medium text-fg-muted">How long</p>
377
- {lifecycleLocked ? (
378
- <p className="text-xs text-fg">
379
- {rule.scope === "until"
380
- ? `Until ${rule.until ?? ""}`
381
- : "Always — runs on matching mail"}
382
- </p>
383
- ) : (
384
- <SegmentedControl
385
- name="rule-scope"
386
- size="sm"
387
- aria-label="Rule scope"
388
- options={scopeOptions}
389
- value={rule.scope}
390
- onChange={(value) => onChangeScope?.(value)}
391
- />
392
- )}
402
+ <SegmentedControl
403
+ name="rule-scope"
404
+ size="sm"
405
+ aria-label="Rule scope"
406
+ options={availableScopeOptions}
407
+ value={rule.scope}
408
+ onChange={(value) => onChangeScope?.(value)}
409
+ />
393
410
  {needsName && (
394
411
  <Input
395
412
  value={rule.name ?? ""}
@@ -399,7 +416,7 @@ export function FilterRuleEditor({
399
416
  className="w-full"
400
417
  />
401
418
  )}
402
- {!lifecycleLocked && rule.scope === "until" && (
419
+ {rule.scope === "until" && (
403
420
  <div className="flex items-center gap-2 text-xs text-fg-muted">
404
421
  <span>Until</span>
405
422
  <Input
@@ -411,13 +428,6 @@ export function FilterRuleEditor({
411
428
  />
412
429
  </div>
413
430
  )}
414
- {lifecycleLocked && (
415
- <p className="text-2xs text-fg-subtle">
416
- {rule.widen
417
- ? "The scope, expiry, and similar-mail match are set when a filter is created."
418
- : "The scope and expiry are set when a filter is created."}
419
- </p>
420
- )}
421
431
  </section>
422
432
 
423
433
  <div className="border-t border-line pt-3">
@@ -555,35 +555,48 @@ describe("FilterRuleEditor", () => {
555
555
  assert.match(editor(), /47 messages match/);
556
556
  });
557
557
 
558
- it("renders scope and expiry read-only when the lifecycle is locked", () => {
558
+ it("keeps scope and expiry live and editable on an anchor-locked (existing) filter (reader #266)", () => {
559
559
  const html = editor({
560
560
  rule: { ...demoRule, scope: "until", until: "2027-09-01" },
561
- lifecycleLocked: true,
561
+ anchorLocked: true,
562
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.
563
+ assert.match(html, /aria-label="Rule scope"/);
564
+ assert.match(html, /aria-label="Expiry date"/);
569
565
  assert.match(html, /aria-label="Rule name"/);
570
566
  });
571
567
 
572
- it("names the similar-mail match in the locked note only when a widen is present", () => {
568
+ it("drops the once option from the scope toggle on an anchor-locked filter", () => {
569
+ const locked = editor({ anchorLocked: true });
570
+ assert.doesNotMatch(locked, />Just once</);
571
+ const unlocked = editor({ anchorLocked: false });
572
+ assert.match(unlocked, />Just once</);
573
+ });
574
+
575
+ it("locks the widen chip and explains why only when one is present", () => {
573
576
  const withWiden = editor({
574
577
  rule: { ...demoRule, widen: { anchorCount: 2 } },
575
- lifecycleLocked: true,
578
+ anchorLocked: true,
576
579
  });
577
- assert.match(
580
+ assert.doesNotMatch(
578
581
  withWiden,
579
- /similar-mail match are set when a filter is created/,
582
+ /aria-label="Remove the similar-mail widen"/,
580
583
  );
584
+ assert.match(withWiden, /similar-mail match is fixed to the message/);
585
+
581
586
  const literal = editor({
582
587
  rule: { ...demoRule, widen: undefined },
583
- lifecycleLocked: true,
588
+ anchorLocked: true,
589
+ });
590
+ assert.doesNotMatch(literal, /similar-mail match is fixed/);
591
+ });
592
+
593
+ it("never offers to add a widen on an anchor-locked filter, even when the deployment can serve it", () => {
594
+ const html = editor({
595
+ rule: { ...demoRule, widen: undefined },
596
+ anchorLocked: true,
597
+ semanticAvailable: true,
584
598
  });
585
- assert.match(literal, /scope and expiry are set when a filter is created/);
586
- assert.doesNotMatch(literal, /similar-mail match/);
599
+ assert.doesNotMatch(html, /…and similar/);
587
600
  });
588
601
  });
589
602