@remit/web-client 0.0.74 → 0.0.75

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/web-client",
3
- "version": "0.0.74",
3
+ "version": "0.0.75",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -60,12 +60,17 @@ const filterFixture = (
60
60
 
61
61
  type Responder = (call: HttpCall) => unknown;
62
62
 
63
- /** Fields whose presence in a patch bumps `ruleChangedAt` (RFC 034 Decision 3.2). */
64
- const PREDICATE_OR_ACTION = [
63
+ /**
64
+ * Fields whose presence in a patch bumps `ruleChangedAt` (RFC 034 Decision
65
+ * 3.2, reader #266).
66
+ */
67
+ const RULE_ASSERTION_FIELDS = [
65
68
  "matchOperator",
66
69
  "literalClauses",
67
70
  "actionLabelId",
68
71
  "actionMailboxId",
72
+ "scope",
73
+ "expiresAt",
69
74
  ];
70
75
 
71
76
  /**
@@ -84,7 +89,7 @@ const backend = (
84
89
  }
85
90
  if (call.path.endsWith("/filters/f-1") && call.method === "PATCH") {
86
91
  const body = call.body ?? {};
87
- const bumped = PREDICATE_OR_ACTION.some((field) => field in body);
92
+ const bumped = RULE_ASSERTION_FIELDS.some((field) => field in body);
88
93
  return {
89
94
  ...filter,
90
95
  ...body,
@@ -254,12 +259,12 @@ describe("FilterEditor — degraded semantic filter (RFC 038 D4)", () => {
254
259
  dom.query('[aria-label="Remove the similar-mail widen"]'),
255
260
  null,
256
261
  );
257
- assert.match(dom.text(), /set when a filter is created/i);
262
+ assert.match(dom.text(), /similar-mail match is fixed to the message/i);
258
263
  });
259
264
  });
260
265
 
261
- describe("FilterEditor — scope and expiry are read-only (reader #266)", () => {
262
- it("shows an until-a-date filter's scope statically, with no scope or date control", async () => {
266
+ describe("FilterEditor — scope and expiry are editable (reader #266)", () => {
267
+ it("keeps the scope toggle and date input live, minus the once option", async () => {
263
268
  const dom = mount(
264
269
  filterFixture({
265
270
  scope: "Temporary",
@@ -268,14 +273,56 @@ describe("FilterEditor — scope and expiry are read-only (reader #266)", () =>
268
273
  );
269
274
  await settlePreview(dom);
270
275
 
271
- // The live scope segmented control and the date input are gone — a
272
- // scope/date change cannot be persisted, so it is never offered.
273
- assert.equal(dom.query('input[name="rule-scope"]'), null);
274
- assert.equal(dom.query('[aria-label="Expiry date"]'), null);
275
- assert.match(dom.text(), /Until 2027-09-01/);
276
- assert.match(dom.text(), /set when a filter is created/i);
276
+ assert.ok(dom.query('input[name="rule-scope"]'));
277
+ assert.ok(dom.byLabel("Expiry date"));
278
+ assert.doesNotMatch(dom.text(), /Just once/);
277
279
 
278
- // The name stays editable.
280
+ // The name stays editable too.
279
281
  assert.ok(dom.byLabel("Rule name"));
280
282
  });
283
+
284
+ it("moves a standing filter to until-a-date, patches scope and expiresAt, and offers the re-apply", async () => {
285
+ const dom = mount(filterFixture());
286
+ await settlePreview(dom);
287
+
288
+ dom.click(dom.byText("label", "Until a date"));
289
+ dom.type(dom.byLabel("Expiry date"), "2027-09-01");
290
+ await dom.flush();
291
+
292
+ dom.click(primaryButton(dom, "Save until then"));
293
+ await dom.flush();
294
+
295
+ const patch = patchCalls();
296
+ assert.equal(patch.length, 1);
297
+ assert.equal(patch[0].body?.scope, "Temporary");
298
+ assert.match(String(patch[0].body?.expiresAt ?? ""), /^2027-09-01T/);
299
+ // The predicate/action and the name are untouched, so neither travels.
300
+ assert.equal("matchOperator" in (patch[0].body ?? {}), false);
301
+ assert.equal("name" in (patch[0].body ?? {}), false);
302
+
303
+ // A scope/expiry change is a rule reassertion too — it offers the
304
+ // re-apply exactly like a predicate/action change does.
305
+ assert.match(dom.text(), /Move existing mail/);
306
+ });
307
+
308
+ it("moves an until-a-date filter back to standing and clears the expiry", async () => {
309
+ const dom = mount(
310
+ filterFixture({
311
+ scope: "Temporary",
312
+ expiresAt: "2027-09-01T23:59:59+00:00",
313
+ }),
314
+ );
315
+ await settlePreview(dom);
316
+
317
+ dom.click(dom.byText("label", "Keep doing this"));
318
+ await dom.flush();
319
+
320
+ dom.click(primaryButton(dom, "Save rule"));
321
+ await dom.flush();
322
+
323
+ const patch = patchCalls();
324
+ assert.equal(patch.length, 1);
325
+ assert.equal(patch[0].body?.scope, "Standing");
326
+ assert.equal("expiresAt" in (patch[0].body ?? {}), false);
327
+ });
281
328
  });
@@ -8,6 +8,7 @@ import {
8
8
  type FolderOption,
9
9
  type MatchOperator,
10
10
  previewCountSummary,
11
+ type RuleScope,
11
12
  } from "@remit/ui";
12
13
  import { CheckCircle2, Loader2 } from "lucide-react";
13
14
  import { useMemo, useRef, useState } from "react";
@@ -19,6 +20,7 @@ import {
19
20
  buildUpdateFilterInput,
20
21
  filterToRule,
21
22
  ruleChangesPredicateOrAction,
23
+ ruleChangesScopeOrExpiry,
22
24
  } from "@/lib/organize/filter-edit-model";
23
25
  import {
24
26
  normalizeClauseValue,
@@ -43,11 +45,14 @@ interface FilterEditorProps {
43
45
  /**
44
46
  * Editing a standing filter in the same chip editor the Organize surface uses
45
47
  * (RFC 038 D6). The row's persisted rule opens in the editor — clauses, match
46
- * operator, move action, scope, and the semantic anchor as a widen chip. Saving
47
- * a predicate or action change bumps `ruleChangedAt` and offers, never runs, a
48
- * re-back-apply over existing mail; a cosmetic rename does neither (RFC 034
49
- * Decision 3.2). The re-apply carries exactly the previewed predicate and is
50
- * held behind the same settled-count commit gate as creation.
48
+ * operator, move action, scope, expiry, and the semantic anchor as a widen
49
+ * chip. Saving a predicate, action, scope, or expiry change bumps
50
+ * `ruleChangedAt` and offers, never runs, a re-back-apply over existing mail;
51
+ * a cosmetic rename does neither (RFC 034 Decision 3.2, reader #266). The
52
+ * re-apply carries exactly the previewed predicate and is held behind the
53
+ * same settled-count commit gate as creation. The anchor stays fixed at
54
+ * creation regardless — repointing it would silently change what the filter
55
+ * matches, which deserves a new filter instead.
51
56
  */
52
57
  export function FilterEditor({
53
58
  accountId,
@@ -142,14 +147,26 @@ export function FilterEditor({
142
147
  const changeName = (name: string) =>
143
148
  setRule((current) => ({ ...current, name }));
144
149
 
150
+ const changeScope = (scope: RuleScope) =>
151
+ setRule((current) => ({
152
+ ...current,
153
+ scope,
154
+ until: scope === "until" ? current.until : undefined,
155
+ }));
156
+
157
+ const changeUntil = (until: string) =>
158
+ setRule((current) => ({ ...current, until }));
159
+
145
160
  const commit = () => {
146
- const predicateChanged = ruleChangesPredicateOrAction(rule, original);
161
+ const rulesChanged =
162
+ ruleChangesPredicateOrAction(rule, original) ||
163
+ ruleChangesScopeOrExpiry(rule, original);
147
164
  const body = buildUpdateFilterInput(rule, original);
148
165
  if (Object.keys(body).length === 0) {
149
166
  onClose();
150
167
  return;
151
168
  }
152
- setOfferReapply(predicateChanged);
169
+ setOfferReapply(rulesChanged);
153
170
  update.updateFilter(body);
154
171
  };
155
172
 
@@ -188,13 +205,15 @@ export function FilterEditor({
188
205
  rule={rule}
189
206
  folders={folders}
190
207
  preview={preview}
191
- // The update endpoint carries no anchor, so a widen can be neither added
192
- // nor removed here: the "…and similar" add is never offered, and the
193
- // existing chip is display-only (no onRemoveWiden). `semanticUnavailable`
194
- // only drives the chip's inactive styling via `filterToRule`.
208
+ // The update endpoint carries no anchor field at all (reader #266), so a
209
+ // widen can be neither added nor removed here: the "…and similar" add is
210
+ // never offered, and the existing chip is display-only (no
211
+ // onRemoveWiden anchorLocked enforces that regardless).
212
+ // `semanticUnavailable` only drives the chip's inactive styling via
213
+ // `filterToRule`.
195
214
  semanticAvailable={false}
196
215
  clauseFields={SUPPORTED_CLAUSE_FIELDS}
197
- lifecycleLocked
216
+ anchorLocked
198
217
  clauseEdit={clauseEdit}
199
218
  onStartAddClause={startAddClause}
200
219
  onStartEditClause={startEditClause}
@@ -207,6 +226,8 @@ export function FilterEditor({
207
226
  onChangeMove={changeMove}
208
227
  onCreateFolder={createFolder}
209
228
  onChangeName={changeName}
229
+ onChangeScope={changeScope}
230
+ onChangeUntil={changeUntil}
210
231
  onCommit={commit}
211
232
  onCancel={onClose}
212
233
  />
@@ -7,6 +7,7 @@ import {
7
7
  expiresAtToPickedDate,
8
8
  filterToRule,
9
9
  ruleChangesPredicateOrAction,
10
+ ruleChangesScopeOrExpiry,
10
11
  } from "./filter-edit-model";
11
12
 
12
13
  const filter = (
@@ -132,6 +133,48 @@ describe("ruleChangesPredicateOrAction", () => {
132
133
  });
133
134
  });
134
135
 
136
+ describe("ruleChangesScopeOrExpiry (reader #266)", () => {
137
+ const base = filterToRule(filter());
138
+
139
+ it("is false for an identical rule and for a rename only", () => {
140
+ assert.equal(ruleChangesScopeOrExpiry(base, base), false);
141
+ assert.equal(
142
+ ruleChangesScopeOrExpiry({ ...base, name: "New name" }, base),
143
+ false,
144
+ );
145
+ });
146
+
147
+ it("is true when scope moves to until-a-date", () => {
148
+ assert.equal(
149
+ ruleChangesScopeOrExpiry(
150
+ { ...base, scope: "until", until: "2027-01-01" },
151
+ base,
152
+ ),
153
+ true,
154
+ );
155
+ });
156
+
157
+ it("is true when only the until date changes", () => {
158
+ const untilBase = filterToRule(
159
+ filter({ scope: "Temporary", expiresAt: "2027-01-01T23:59:59+00:00" }),
160
+ );
161
+ assert.equal(
162
+ ruleChangesScopeOrExpiry(
163
+ { ...untilBase, until: "2027-06-01" },
164
+ untilBase,
165
+ ),
166
+ true,
167
+ );
168
+ });
169
+
170
+ it("is false when moving between standing and once-equivalent scopes with no until set", () => {
171
+ assert.equal(
172
+ ruleChangesScopeOrExpiry({ ...base, scope: "standing" }, base),
173
+ false,
174
+ );
175
+ });
176
+ });
177
+
135
178
  describe("buildUpdateFilterInput", () => {
136
179
  const original = filterToRule(filter());
137
180
 
@@ -172,4 +215,47 @@ describe("buildUpdateFilterInput", () => {
172
215
  it("is empty when nothing changed", () => {
173
216
  assert.deepEqual(buildUpdateFilterInput(original, original), {});
174
217
  });
218
+
219
+ it("sends scope Temporary and a derived expiresAt when moving to until-a-date (reader #266)", () => {
220
+ const changed: FilterRule = {
221
+ ...original,
222
+ scope: "until",
223
+ until: "2027-03-04",
224
+ };
225
+ const body = buildUpdateFilterInput(changed, original);
226
+ assert.equal(body.scope, "Temporary");
227
+ assert.match(body.expiresAt ?? "", /^2027-03-04T/);
228
+ assert.equal("matchOperator" in body, false);
229
+ assert.equal("name" in body, false);
230
+ });
231
+
232
+ it("sends scope Standing with no expiresAt when moving off a Temporary filter", () => {
233
+ const temporaryOriginal = filterToRule(
234
+ filter({ scope: "Temporary", expiresAt: "2027-01-01T23:59:59+00:00" }),
235
+ );
236
+ const changed: FilterRule = { ...temporaryOriginal, scope: "standing" };
237
+ const body = buildUpdateFilterInput(changed, temporaryOriginal);
238
+ assert.equal(body.scope, "Standing");
239
+ assert.equal("expiresAt" in body, false);
240
+ });
241
+
242
+ it("sends scope and the new expiresAt when only the date changes", () => {
243
+ const temporaryOriginal = filterToRule(
244
+ filter({ scope: "Temporary", expiresAt: "2027-01-01T23:59:59+00:00" }),
245
+ );
246
+ const changed: FilterRule = { ...temporaryOriginal, until: "2027-06-01" };
247
+ const body = buildUpdateFilterInput(changed, temporaryOriginal);
248
+ assert.equal(body.scope, "Temporary");
249
+ assert.match(body.expiresAt ?? "", /^2027-06-01T/);
250
+ });
251
+
252
+ it("carries no anchor field — the editor never has one to send (reader #266)", () => {
253
+ const changed: FilterRule = {
254
+ ...original,
255
+ scope: "until",
256
+ until: "2027-03-04",
257
+ };
258
+ const body = buildUpdateFilterInput(changed, original);
259
+ assert.equal("anchorMessageId" in body, false);
260
+ });
175
261
  });
@@ -3,6 +3,7 @@ import type {
3
3
  RemitImapUpdateFilterInput,
4
4
  } from "@remit/api-http-client/types.gen.ts";
5
5
  import type { FilterRule, RuleClause } from "@remit/ui";
6
+ import { pickedDateToExpiresAt } from "./filter-status";
6
7
  import { NO_ACTION } from "./organize-model";
7
8
 
8
9
  /**
@@ -79,13 +80,34 @@ export const ruleChangesPredicateOrAction = (
79
80
  original: FilterRule,
80
81
  ): boolean => predicateActionKey(rule) !== predicateActionKey(original);
81
82
 
83
+ const scopeExpiryKey = (rule: FilterRule): string =>
84
+ JSON.stringify({
85
+ scope: rule.scope === "until" ? "until" : "standing",
86
+ until: rule.scope === "until" ? (rule.until ?? "") : "",
87
+ });
88
+
89
+ /**
90
+ * Whether the edited rule changes scope (standing ↔ until-a-date) or the date
91
+ * itself, versus the one it was loaded from (reader #266). Scope and expiry
92
+ * are mutable on an existing filter, unlike the anchor, and a change here
93
+ * bumps `ruleChangedAt` the same way a predicate/action change does — moving a
94
+ * lapsed filter back to Standing (or extending its date) is the same kind of
95
+ * "the user just reasserted this rule" moment, and re-offers the back-apply so
96
+ * mail delivered while the filter sat inactive can be caught up.
97
+ */
98
+ export const ruleChangesScopeOrExpiry = (
99
+ rule: FilterRule,
100
+ original: FilterRule,
101
+ ): boolean => scopeExpiryKey(rule) !== scopeExpiryKey(original);
102
+
82
103
  /**
83
104
  * The PATCH body for an edited filter. A cosmetic rename sends `{ name }` only,
84
- * so the server's `changesPredicateOrAction` guard leaves `ruleChangedAt`
105
+ * so the server's `changesRuleAssertion` guard leaves `ruleChangedAt`
85
106
  * untouched (RFC 034 Decision 3.2). A predicate or action change sends the
86
- * operator, clauses, and move target, which bumps `ruleChangedAt`. Fields the
87
- * editor never touches — the label action, the immutable scope/expiryare
88
- * absent from the patch, so the partial update preserves them.
107
+ * operator, clauses, and move target; a scope or expiry change sends `scope`
108
+ * and, for the `until` scope, `expiresAt` (reader #266)either bumps
109
+ * `ruleChangedAt`. The label action and the anchor are never in the editor's
110
+ * gift, so they never enter the patch; the partial update preserves them.
89
111
  */
90
112
  export const buildUpdateFilterInput = (
91
113
  rule: FilterRule,
@@ -102,5 +124,11 @@ export const buildUpdateFilterInput = (
102
124
  }));
103
125
  body.actionMailboxId = rule.moveMailboxId ?? NO_ACTION;
104
126
  }
127
+ if (ruleChangesScopeOrExpiry(rule, original)) {
128
+ body.scope = rule.scope === "until" ? "Temporary" : "Standing";
129
+ if (rule.scope === "until") {
130
+ body.expiresAt = pickedDateToExpiresAt(rule.until ?? "");
131
+ }
132
+ }
105
133
  return body;
106
134
  };