@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.
|
|
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
|
-
/**
|
|
64
|
-
|
|
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 =
|
|
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(), /
|
|
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
|
|
262
|
-
it("
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
assert.
|
|
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
|
|
47
|
-
* a predicate or
|
|
48
|
-
* re-back-apply over existing mail;
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
|
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(
|
|
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
|
|
192
|
-
// nor removed here: the "…and similar" add is
|
|
193
|
-
// existing chip is display-only (no
|
|
194
|
-
//
|
|
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
|
-
|
|
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 `
|
|
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
|
|
87
|
-
*
|
|
88
|
-
*
|
|
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
|
};
|