@remit/web-client 0.0.74 → 0.0.76
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/mail/MoveToTrigger.tsx +2 -2
- package/src/components/settings/DeleteFolderDialog.tsx +2 -1
- package/src/components/settings/FilterEditor.render.test.ts +60 -13
- package/src/components/settings/FilterEditor.tsx +33 -12
- package/src/hooks/useCreateMailbox.render.test.ts +109 -9
- package/src/hooks/useCreateMailbox.ts +64 -23
- package/src/lib/mailbox-sync-wait.test.ts +186 -0
- package/src/lib/mailbox-sync-wait.ts +98 -0
- package/src/lib/organize/filter-edit-model.test.ts +86 -0
- package/src/lib/organize/filter-edit-model.ts +32 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.76",
|
|
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": {
|
|
@@ -130,8 +130,8 @@ export const MoveToTrigger = ({
|
|
|
130
130
|
);
|
|
131
131
|
|
|
132
132
|
const handleCreateFolder = useCallback(
|
|
133
|
-
async (name: string): Promise<MoveMailboxOption> => {
|
|
134
|
-
const folder = await createFolder(name);
|
|
133
|
+
async (name: string, signal?: AbortSignal): Promise<MoveMailboxOption> => {
|
|
134
|
+
const folder = await createFolder(name, signal);
|
|
135
135
|
return { id: folder.id, label: folder.label };
|
|
136
136
|
},
|
|
137
137
|
[createFolder],
|
|
@@ -103,8 +103,9 @@ export function DeleteFolderDialog({
|
|
|
103
103
|
|
|
104
104
|
const handleCreateFolder = async (
|
|
105
105
|
name: string,
|
|
106
|
+
signal?: AbortSignal,
|
|
106
107
|
): Promise<MoveMailboxOption> => {
|
|
107
|
-
const created = await createFolder(name);
|
|
108
|
+
const created = await createFolder(name, signal);
|
|
108
109
|
return { id: created.id, label: created.label };
|
|
109
110
|
};
|
|
110
111
|
|
|
@@ -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
|
/>
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* useCreateMailbox.createFolder — the shared create seam the kit surfaces call
|
|
3
|
-
* It validates the typed name against the account's
|
|
4
|
-
* same IMAP-aware rules the settings form uses,
|
|
5
|
-
* human-readable reason before any request
|
|
6
|
-
*
|
|
2
|
+
* useCreateMailbox.createFolder — the shared create seam the kit surfaces call
|
|
3
|
+
* for a dependent write. It validates the typed name against the account's
|
|
4
|
+
* current folders with the same IMAP-aware rules the settings form uses, rejects
|
|
5
|
+
* with the human-readable reason before any request, then waits for the mail
|
|
6
|
+
* server to confirm the folder before resolving — so a filter or a move never
|
|
7
|
+
* binds to a still-pending row. The mailbox list is seeded into the query cache
|
|
8
|
+
* the hook reads, so validation runs against real paths.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import assert from "node:assert/strict";
|
|
@@ -13,8 +15,10 @@ import type {
|
|
|
13
15
|
MailboxOperationsListMailboxesResponse,
|
|
14
16
|
RemitImapMailboxResponse,
|
|
15
17
|
} from "@remit/api-http-client/types.gen.ts";
|
|
18
|
+
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
16
19
|
import type { FolderOption } from "@remit/ui";
|
|
17
20
|
import { act, createElement } from "react";
|
|
21
|
+
import { MAILBOX_SYNC_FAILED_MESSAGE } from "../lib/mailbox-sync-wait";
|
|
18
22
|
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
19
23
|
import { type HttpMock, mockFetch } from "../test-support/http";
|
|
20
24
|
import { useCreateMailbox } from "./useCreateMailbox";
|
|
@@ -23,7 +27,9 @@ const ACCOUNT = "acc-1";
|
|
|
23
27
|
|
|
24
28
|
let harness: DomHarness | undefined;
|
|
25
29
|
let http: HttpMock | undefined;
|
|
26
|
-
let createFolder:
|
|
30
|
+
let createFolder:
|
|
31
|
+
| ((name: string, signal?: AbortSignal) => Promise<FolderOption>)
|
|
32
|
+
| undefined;
|
|
27
33
|
|
|
28
34
|
afterEach(() => {
|
|
29
35
|
harness?.close();
|
|
@@ -49,13 +55,23 @@ function Probe() {
|
|
|
49
55
|
return null;
|
|
50
56
|
}
|
|
51
57
|
|
|
52
|
-
const mount = (
|
|
58
|
+
const mount = (
|
|
59
|
+
items: RemitImapMailboxResponse[],
|
|
60
|
+
createdSyncStatus: RemitImapMailboxResponse["syncStatus"] = MailboxSyncStatus.synced,
|
|
61
|
+
) => {
|
|
62
|
+
const created: RemitImapMailboxResponse[] = [];
|
|
53
63
|
http = mockFetch((call) => {
|
|
54
64
|
if (call.method === "POST") {
|
|
55
65
|
const body = call.body as { fullPath: string };
|
|
66
|
+
created.push({
|
|
67
|
+
mailboxId: `mbx-${body.fullPath}`,
|
|
68
|
+
accountId: ACCOUNT,
|
|
69
|
+
fullPath: body.fullPath,
|
|
70
|
+
syncStatus: createdSyncStatus,
|
|
71
|
+
} as RemitImapMailboxResponse);
|
|
56
72
|
return { mailboxId: `mbx-${body.fullPath}`, fullPath: body.fullPath };
|
|
57
73
|
}
|
|
58
|
-
return { items };
|
|
74
|
+
return { items: [...items, ...created] };
|
|
59
75
|
});
|
|
60
76
|
harness = createDomHarness();
|
|
61
77
|
harness.queryClient.setQueryData<MailboxOperationsListMailboxesResponse>(
|
|
@@ -114,7 +130,7 @@ describe("useCreateMailbox.createFolder validation", () => {
|
|
|
114
130
|
assert.equal(postCount(), 0);
|
|
115
131
|
});
|
|
116
132
|
|
|
117
|
-
it("passes a valid name through
|
|
133
|
+
it("passes a valid name through and resolves once the folder is confirmed synced", async () => {
|
|
118
134
|
mount([mailbox("INBOX", "/")]);
|
|
119
135
|
let result: FolderOption | undefined;
|
|
120
136
|
await act(async () => {
|
|
@@ -126,6 +142,90 @@ describe("useCreateMailbox.createFolder validation", () => {
|
|
|
126
142
|
fullPath: "Taxes",
|
|
127
143
|
namespaceType: "personal",
|
|
128
144
|
});
|
|
145
|
+
// It polled the list after the create to confirm the folder before resolving.
|
|
146
|
+
const gets = (http?.calls ?? []).filter((call) => call.method === "GET");
|
|
147
|
+
assert.ok(gets.length >= 1, "polls the mailbox list for confirmation");
|
|
129
148
|
assert.equal(result?.label, "Taxes");
|
|
130
149
|
});
|
|
150
|
+
|
|
151
|
+
it("rejects — no folder to bind a dependent write to — when the create is reported failed", async () => {
|
|
152
|
+
mount([mailbox("INBOX", "/")], MailboxSyncStatus.failed);
|
|
153
|
+
let caught: unknown;
|
|
154
|
+
await act(async () => {
|
|
155
|
+
caught = await createFolder?.("Taxes").then(
|
|
156
|
+
() => undefined,
|
|
157
|
+
(error: unknown) => error,
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
assert.ok(caught instanceof Error);
|
|
161
|
+
assert.equal(caught.message, MAILBOX_SYNC_FAILED_MESSAGE);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("retry resumes the wait on the folder it already made — no second create, no 'already exists'", async () => {
|
|
165
|
+
// The created folder is reported failed on the first attempt, then synced.
|
|
166
|
+
let status: RemitImapMailboxResponse["syncStatus"] =
|
|
167
|
+
MailboxSyncStatus.failed;
|
|
168
|
+
const created: RemitImapMailboxResponse[] = [];
|
|
169
|
+
http = mockFetch((call) => {
|
|
170
|
+
if (call.method === "POST") {
|
|
171
|
+
const body = call.body as { fullPath: string };
|
|
172
|
+
created.push({
|
|
173
|
+
mailboxId: `mbx-${body.fullPath}`,
|
|
174
|
+
accountId: ACCOUNT,
|
|
175
|
+
fullPath: body.fullPath,
|
|
176
|
+
} as RemitImapMailboxResponse);
|
|
177
|
+
return { mailboxId: `mbx-${body.fullPath}`, fullPath: body.fullPath };
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
items: [
|
|
181
|
+
mailbox("INBOX", "/"),
|
|
182
|
+
...created.map((entry) => ({ ...entry, syncStatus: status })),
|
|
183
|
+
],
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
harness = createDomHarness();
|
|
187
|
+
harness.queryClient.setQueryData<MailboxOperationsListMailboxesResponse>(
|
|
188
|
+
mailboxOperationsListMailboxesQueryKey({ path: { accountId: ACCOUNT } }),
|
|
189
|
+
{ items: [mailbox("INBOX", "/")] },
|
|
190
|
+
);
|
|
191
|
+
harness.renderApp(createElement(Probe));
|
|
192
|
+
|
|
193
|
+
let first: unknown;
|
|
194
|
+
await act(async () => {
|
|
195
|
+
first = await createFolder?.("Taxes").then(
|
|
196
|
+
() => undefined,
|
|
197
|
+
(error: unknown) => error,
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
assert.ok(first instanceof Error);
|
|
201
|
+
assert.equal(first.message, MAILBOX_SYNC_FAILED_MESSAGE);
|
|
202
|
+
|
|
203
|
+
// The server confirms; the user presses "Create folder" again, same name.
|
|
204
|
+
status = MailboxSyncStatus.synced;
|
|
205
|
+
let result: FolderOption | undefined;
|
|
206
|
+
await act(async () => {
|
|
207
|
+
result = await createFolder?.("Taxes");
|
|
208
|
+
});
|
|
209
|
+
assert.equal(result?.label, "Taxes");
|
|
210
|
+
|
|
211
|
+
// Exactly one create across both attempts — the retry resumed, it did not
|
|
212
|
+
// re-validate (which would throw "already exists") or re-POST.
|
|
213
|
+
const posts = (http?.calls ?? []).filter((call) => call.method === "POST");
|
|
214
|
+
assert.equal(posts.length, 1);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("abort stops the wait so a folder that confirms later never resolves", async () => {
|
|
218
|
+
mount([mailbox("INBOX", "/")], MailboxSyncStatus.pending);
|
|
219
|
+
const controller = new AbortController();
|
|
220
|
+
let caught: unknown;
|
|
221
|
+
await act(async () => {
|
|
222
|
+
const promise = createFolder?.("Taxes", controller.signal);
|
|
223
|
+
controller.abort();
|
|
224
|
+
caught = await promise?.then(
|
|
225
|
+
() => undefined,
|
|
226
|
+
(error: unknown) => error,
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
assert.equal((caught as { name?: string })?.name, "AbortError");
|
|
230
|
+
});
|
|
131
231
|
});
|
|
@@ -5,21 +5,40 @@ import {
|
|
|
5
5
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
6
6
|
import type { FolderOption } from "@remit/ui";
|
|
7
7
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
8
|
-
import { useCallback } from "react";
|
|
8
|
+
import { useCallback, useRef } from "react";
|
|
9
9
|
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
10
|
+
import { waitForMailboxSynced } from "@/lib/mailbox-sync-wait";
|
|
10
11
|
import { composeFolderPath, validateNewFolderName } from "@/lib/new-folder";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Creates a mailbox for an account and refreshes the folder list on success.
|
|
14
15
|
* The backend creates the row with a pending sync status and queues the IMAP
|
|
15
|
-
* create
|
|
16
|
+
* create.
|
|
16
17
|
*
|
|
17
|
-
* `createFolder`
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* the
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* `createFolder` is the seam for dependent writes: a folder created so a filter
|
|
19
|
+
* can move mail into it, or so a move can land mail there. It takes a leaf name,
|
|
20
|
+
* validates it against the account's current folders with the same IMAP-aware
|
|
21
|
+
* rules the settings form uses (non-empty, no hierarchy delimiter, no collision —
|
|
22
|
+
* INBOX case-insensitive), and rejects with the human-readable reason before any
|
|
23
|
+
* request. It then WAITS for the mail server to confirm the folder before
|
|
24
|
+
* resolving — a folder is not a valid target until it exists on the server, and
|
|
25
|
+
* binding a filter or a move to a still-pending row races the folder into
|
|
26
|
+
* existence and cannot report a create that fails. It resolves with the confirmed
|
|
27
|
+
* folder (carrying the path the server normalized to), rejects with a distinct
|
|
28
|
+
* message when the create fails or never confirms, and the kit surfaces that
|
|
29
|
+
* render either the "Creating folder…" wait or the failure inline.
|
|
30
|
+
*
|
|
31
|
+
* Retry is a resume, not a re-create: a create that timed out or failed leaves
|
|
32
|
+
* the row already made, so pressing "Create folder" again calls `createFolder`
|
|
33
|
+
* with the same name — which resumes the wait on the mailboxId it already made
|
|
34
|
+
* rather than re-validating (the pending row would collide as "already exists")
|
|
35
|
+
* and re-POSTing. The mailboxId is carried per-name until the folder confirms.
|
|
36
|
+
*
|
|
37
|
+
* `createFolder` takes an `AbortSignal` the surface aborts on unmount/cancel/
|
|
38
|
+
* close, so a folder that confirms after the surface is gone resolves nothing.
|
|
39
|
+
*
|
|
40
|
+
* `mutation` is exposed for callers that drive their own form state and want the
|
|
41
|
+
* optimistic, non-waiting create (the standalone settings create).
|
|
23
42
|
*/
|
|
24
43
|
export function useCreateMailbox(accountId: string) {
|
|
25
44
|
const queryClient = useQueryClient();
|
|
@@ -39,26 +58,48 @@ export function useCreateMailbox(accountId: string) {
|
|
|
39
58
|
},
|
|
40
59
|
});
|
|
41
60
|
|
|
61
|
+
// fullPath -> mailboxId for a folder created but not yet confirmed, so a retry
|
|
62
|
+
// resumes the wait on it instead of re-creating. Cleared once it confirms.
|
|
63
|
+
const pendingByPath = useRef(new Map<string, string>());
|
|
64
|
+
|
|
42
65
|
const createFolder = useCallback(
|
|
43
|
-
async (name: string): Promise<FolderOption> => {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
delimiter
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
66
|
+
async (name: string, signal?: AbortSignal): Promise<FolderOption> => {
|
|
67
|
+
const fullPath = composeFolderPath(name);
|
|
68
|
+
let mailboxId = pendingByPath.current.get(fullPath);
|
|
69
|
+
if (!mailboxId) {
|
|
70
|
+
const items = data?.items ?? [];
|
|
71
|
+
const delimiter = items[0]?.hierarchyDelimiter ?? "/";
|
|
72
|
+
const problem = validateNewFolderName({
|
|
73
|
+
name,
|
|
74
|
+
delimiter,
|
|
75
|
+
existingPaths: items.map((item) => item.fullPath),
|
|
76
|
+
});
|
|
77
|
+
if (problem) throw new Error(problem);
|
|
78
|
+
const mailbox = await mutation.mutateAsync({
|
|
79
|
+
path: { accountId },
|
|
80
|
+
body: { fullPath, namespaceType: "personal" },
|
|
81
|
+
});
|
|
82
|
+
mailboxId = mailbox.mailboxId;
|
|
83
|
+
pendingByPath.current.set(fullPath, mailboxId);
|
|
84
|
+
}
|
|
85
|
+
const confirmed = await waitForMailboxSynced({
|
|
86
|
+
mailboxId,
|
|
87
|
+
signal,
|
|
88
|
+
fetchMailboxes: async () => {
|
|
89
|
+
const response = await queryClient.fetchQuery({
|
|
90
|
+
...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
|
|
91
|
+
staleTime: 0,
|
|
92
|
+
});
|
|
93
|
+
return response.items ?? [];
|
|
94
|
+
},
|
|
55
95
|
});
|
|
96
|
+
pendingByPath.current.delete(fullPath);
|
|
56
97
|
return {
|
|
57
|
-
id:
|
|
58
|
-
label: getMailboxDisplayName(
|
|
98
|
+
id: confirmed.mailboxId,
|
|
99
|
+
label: getMailboxDisplayName(confirmed.fullPath),
|
|
59
100
|
};
|
|
60
101
|
},
|
|
61
|
-
[mutation, accountId, data],
|
|
102
|
+
[mutation, accountId, data, queryClient],
|
|
62
103
|
);
|
|
63
104
|
|
|
64
105
|
return { createFolder, mutation };
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* waitForMailboxSynced — the gate a dependent write (a filter, a move) holds
|
|
3
|
+
* behind while a freshly-created folder is confirmed on the mail server. It
|
|
4
|
+
* resolves only on `synced`, rejects distinctly on `failed` and on timeout, and
|
|
5
|
+
* keeps polling while the row is still `pending` or not yet listed.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { describe, it } from "node:test";
|
|
10
|
+
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
11
|
+
import {
|
|
12
|
+
MAILBOX_SYNC_FAILED_MESSAGE,
|
|
13
|
+
MAILBOX_SYNC_TIMEOUT_MESSAGE,
|
|
14
|
+
type MailboxSyncSignal,
|
|
15
|
+
waitForMailboxSynced,
|
|
16
|
+
} from "./mailbox-sync-wait.js";
|
|
17
|
+
|
|
18
|
+
const row = (
|
|
19
|
+
mailboxId: string,
|
|
20
|
+
syncStatus?: MailboxSyncSignal["syncStatus"],
|
|
21
|
+
extra: Record<string, unknown> = {},
|
|
22
|
+
): MailboxSyncSignal & Record<string, unknown> => ({
|
|
23
|
+
mailboxId,
|
|
24
|
+
syncStatus,
|
|
25
|
+
...extra,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const noDelay = () => Promise.resolve();
|
|
29
|
+
|
|
30
|
+
describe("waitForMailboxSynced", () => {
|
|
31
|
+
it("resolves with the confirmed row once it reaches synced", async () => {
|
|
32
|
+
const responses = [
|
|
33
|
+
[row("mbx-1", MailboxSyncStatus.pending)],
|
|
34
|
+
[row("mbx-1", MailboxSyncStatus.pending)],
|
|
35
|
+
[row("mbx-1", MailboxSyncStatus.synced, { fullPath: "Server/Receipts" })],
|
|
36
|
+
];
|
|
37
|
+
let call = 0;
|
|
38
|
+
const result = await waitForMailboxSynced({
|
|
39
|
+
mailboxId: "mbx-1",
|
|
40
|
+
fetchMailboxes: async () => responses[call++],
|
|
41
|
+
delay: noDelay,
|
|
42
|
+
});
|
|
43
|
+
assert.equal(result.syncStatus, MailboxSyncStatus.synced);
|
|
44
|
+
assert.equal(
|
|
45
|
+
(result as Record<string, unknown>).fullPath,
|
|
46
|
+
"Server/Receipts",
|
|
47
|
+
);
|
|
48
|
+
assert.equal(call, 3);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("keeps polling while the row is not yet listed", async () => {
|
|
52
|
+
const responses = [
|
|
53
|
+
[] as MailboxSyncSignal[],
|
|
54
|
+
[row("other", MailboxSyncStatus.synced)],
|
|
55
|
+
[row("mbx-1", MailboxSyncStatus.synced)],
|
|
56
|
+
];
|
|
57
|
+
let call = 0;
|
|
58
|
+
const result = await waitForMailboxSynced({
|
|
59
|
+
mailboxId: "mbx-1",
|
|
60
|
+
fetchMailboxes: async () => responses[call++],
|
|
61
|
+
delay: noDelay,
|
|
62
|
+
});
|
|
63
|
+
assert.equal(result.mailboxId, "mbx-1");
|
|
64
|
+
assert.equal(call, 3);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("rejects with the failure message when the create is reported failed", async () => {
|
|
68
|
+
await assert.rejects(
|
|
69
|
+
waitForMailboxSynced({
|
|
70
|
+
mailboxId: "mbx-1",
|
|
71
|
+
fetchMailboxes: async () => [row("mbx-1", MailboxSyncStatus.failed)],
|
|
72
|
+
delay: noDelay,
|
|
73
|
+
}),
|
|
74
|
+
(error: unknown) =>
|
|
75
|
+
error instanceof Error && error.message === MAILBOX_SYNC_FAILED_MESSAGE,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("rejects with the timeout message when the row never confirms", async () => {
|
|
80
|
+
let clock = 0;
|
|
81
|
+
let fetches = 0;
|
|
82
|
+
await assert.rejects(
|
|
83
|
+
waitForMailboxSynced({
|
|
84
|
+
mailboxId: "mbx-1",
|
|
85
|
+
fetchMailboxes: async () => {
|
|
86
|
+
fetches += 1;
|
|
87
|
+
return [row("mbx-1", MailboxSyncStatus.pending)];
|
|
88
|
+
},
|
|
89
|
+
timeoutMs: 30_000,
|
|
90
|
+
pollIntervalMs: 1_000,
|
|
91
|
+
now: () => clock,
|
|
92
|
+
delay: async (ms) => {
|
|
93
|
+
clock += ms;
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
(error: unknown) =>
|
|
97
|
+
error instanceof Error &&
|
|
98
|
+
error.message === MAILBOX_SYNC_TIMEOUT_MESSAGE,
|
|
99
|
+
);
|
|
100
|
+
assert.ok(fetches > 1, "polls more than once before timing out");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("does not treat failed as timeout even past the deadline", async () => {
|
|
104
|
+
let clock = 100_000;
|
|
105
|
+
await assert.rejects(
|
|
106
|
+
waitForMailboxSynced({
|
|
107
|
+
mailboxId: "mbx-1",
|
|
108
|
+
fetchMailboxes: async () => [row("mbx-1", MailboxSyncStatus.failed)],
|
|
109
|
+
timeoutMs: 1,
|
|
110
|
+
now: () => clock++,
|
|
111
|
+
delay: noDelay,
|
|
112
|
+
}),
|
|
113
|
+
(error: unknown) =>
|
|
114
|
+
error instanceof Error && error.message === MAILBOX_SYNC_FAILED_MESSAGE,
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const isAbort = (error: unknown): boolean =>
|
|
119
|
+
typeof error === "object" &&
|
|
120
|
+
error !== null &&
|
|
121
|
+
(error as { name?: unknown }).name === "AbortError";
|
|
122
|
+
|
|
123
|
+
it("rejects without polling when the signal is already aborted", async () => {
|
|
124
|
+
const controller = new AbortController();
|
|
125
|
+
controller.abort();
|
|
126
|
+
let fetches = 0;
|
|
127
|
+
await assert.rejects(
|
|
128
|
+
waitForMailboxSynced({
|
|
129
|
+
mailboxId: "mbx-1",
|
|
130
|
+
signal: controller.signal,
|
|
131
|
+
fetchMailboxes: async () => {
|
|
132
|
+
fetches += 1;
|
|
133
|
+
return [row("mbx-1", MailboxSyncStatus.pending)];
|
|
134
|
+
},
|
|
135
|
+
delay: noDelay,
|
|
136
|
+
}),
|
|
137
|
+
isAbort,
|
|
138
|
+
);
|
|
139
|
+
assert.equal(fetches, 0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("stops polling and rejects when the signal aborts mid-wait", async () => {
|
|
143
|
+
const controller = new AbortController();
|
|
144
|
+
let fetches = 0;
|
|
145
|
+
await assert.rejects(
|
|
146
|
+
waitForMailboxSynced({
|
|
147
|
+
mailboxId: "mbx-1",
|
|
148
|
+
signal: controller.signal,
|
|
149
|
+
fetchMailboxes: async () => {
|
|
150
|
+
fetches += 1;
|
|
151
|
+
if (fetches === 2) controller.abort();
|
|
152
|
+
return [row("mbx-1", MailboxSyncStatus.pending)];
|
|
153
|
+
},
|
|
154
|
+
delay: noDelay,
|
|
155
|
+
}),
|
|
156
|
+
isAbort,
|
|
157
|
+
);
|
|
158
|
+
assert.equal(fetches, 2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("resolves across the real timer delay between polls", async () => {
|
|
162
|
+
const responses = [
|
|
163
|
+
[row("mbx-1", MailboxSyncStatus.pending)],
|
|
164
|
+
[row("mbx-1", MailboxSyncStatus.synced)],
|
|
165
|
+
];
|
|
166
|
+
let call = 0;
|
|
167
|
+
const result = await waitForMailboxSynced({
|
|
168
|
+
mailboxId: "mbx-1",
|
|
169
|
+
pollIntervalMs: 1,
|
|
170
|
+
fetchMailboxes: async () => responses[call++],
|
|
171
|
+
});
|
|
172
|
+
assert.equal(result.syncStatus, MailboxSyncStatus.synced);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("aborts an in-progress real timer delay", async () => {
|
|
176
|
+
const controller = new AbortController();
|
|
177
|
+
const pending = waitForMailboxSynced({
|
|
178
|
+
mailboxId: "mbx-1",
|
|
179
|
+
signal: controller.signal,
|
|
180
|
+
pollIntervalMs: 10_000,
|
|
181
|
+
fetchMailboxes: async () => [row("mbx-1", MailboxSyncStatus.pending)],
|
|
182
|
+
});
|
|
183
|
+
setTimeout(() => controller.abort(), 5);
|
|
184
|
+
await assert.rejects(pending, isAbort);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
2
|
+
|
|
3
|
+
type MailboxSyncStatusValue =
|
|
4
|
+
(typeof MailboxSyncStatus)[keyof typeof MailboxSyncStatus];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A folder created for a dependent write — a filter that will move mail into it,
|
|
8
|
+
* or a move that lands mail there — is not usable the instant the create is
|
|
9
|
+
* queued: the row exists locally with `syncStatus: pending`, and the folder does
|
|
10
|
+
* not exist on the mail server until the imap-worker confirms the create and
|
|
11
|
+
* flips it to `synced`. Binding the dependent write to a `pending` row races the
|
|
12
|
+
* folder into existence across separate FIFO queues and cannot report a create
|
|
13
|
+
* that fails. This waits for the confirmation before the dependent write runs.
|
|
14
|
+
*
|
|
15
|
+
* The standalone create (a folder made in settings with no dependent write) does
|
|
16
|
+
* not use this — it may stay optimistic. The wait is only for the dependent case.
|
|
17
|
+
*
|
|
18
|
+
* The wait honours an `AbortSignal`: the surface that started the create passes
|
|
19
|
+
* one and aborts it on unmount/cancel/close, so a folder that confirms after the
|
|
20
|
+
* surface is gone never resolves and never fires the dependent bind or move.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The read fields the wait needs off a mailbox row. */
|
|
24
|
+
export interface MailboxSyncSignal {
|
|
25
|
+
mailboxId: string;
|
|
26
|
+
syncStatus?: MailboxSyncStatusValue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface WaitForMailboxSyncedOptions<T extends MailboxSyncSignal> {
|
|
30
|
+
/** Reads the current mailbox rows; called once per poll (forces a fresh read). */
|
|
31
|
+
fetchMailboxes: () => Promise<readonly T[]>;
|
|
32
|
+
/** The row to wait on. */
|
|
33
|
+
mailboxId: string;
|
|
34
|
+
/** Aborts the wait; a late confirmation after abort resolves nothing. */
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
/** How long to wait for confirmation before giving up. */
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
/** Gap between polls. */
|
|
39
|
+
pollIntervalMs?: number;
|
|
40
|
+
/** Injectable clock/sleep for tests. */
|
|
41
|
+
delay?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
42
|
+
now?: () => number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const MAILBOX_SYNC_TIMEOUT_MS = 30_000;
|
|
46
|
+
export const MAILBOX_SYNC_POLL_INTERVAL_MS = 1_000;
|
|
47
|
+
|
|
48
|
+
export const MAILBOX_SYNC_FAILED_MESSAGE =
|
|
49
|
+
"The folder couldn't be created on the mail server. Please try again.";
|
|
50
|
+
export const MAILBOX_SYNC_TIMEOUT_MESSAGE =
|
|
51
|
+
"The folder was created but the mail server hasn't confirmed it yet, so nothing was attached to it. It's in your folder list — try again in a moment.";
|
|
52
|
+
|
|
53
|
+
const defaultDelay = (ms: number, signal?: AbortSignal): Promise<void> =>
|
|
54
|
+
new Promise((resolve, reject) => {
|
|
55
|
+
if (signal?.aborted) {
|
|
56
|
+
reject(signal.reason);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const timer = setTimeout(() => {
|
|
60
|
+
signal?.removeEventListener("abort", onAbort);
|
|
61
|
+
resolve();
|
|
62
|
+
}, ms);
|
|
63
|
+
const onAbort = () => {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
reject(signal?.reason);
|
|
66
|
+
};
|
|
67
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve with the mailbox row once its `syncStatus` reaches `synced` — the
|
|
72
|
+
* server-confirmed row, carrying the path the server normalized the create to.
|
|
73
|
+
* Reject with a failure message when the create is reported `failed`, with a
|
|
74
|
+
* distinct timeout message when it never confirms within `timeoutMs`, and with
|
|
75
|
+
* the signal's reason (an `AbortError`) when `signal` aborts. A row that is still
|
|
76
|
+
* `pending` (or not yet in the list) keeps the poll running.
|
|
77
|
+
*/
|
|
78
|
+
export async function waitForMailboxSynced<T extends MailboxSyncSignal>({
|
|
79
|
+
fetchMailboxes,
|
|
80
|
+
mailboxId,
|
|
81
|
+
signal,
|
|
82
|
+
timeoutMs = MAILBOX_SYNC_TIMEOUT_MS,
|
|
83
|
+
pollIntervalMs = MAILBOX_SYNC_POLL_INTERVAL_MS,
|
|
84
|
+
delay = defaultDelay,
|
|
85
|
+
now = Date.now,
|
|
86
|
+
}: WaitForMailboxSyncedOptions<T>): Promise<T> {
|
|
87
|
+
const deadline = now() + timeoutMs;
|
|
88
|
+
for (;;) {
|
|
89
|
+
signal?.throwIfAborted();
|
|
90
|
+
const mailboxes = await fetchMailboxes();
|
|
91
|
+
const mailbox = mailboxes.find((entry) => entry.mailboxId === mailboxId);
|
|
92
|
+
if (mailbox?.syncStatus === MailboxSyncStatus.synced) return mailbox;
|
|
93
|
+
if (mailbox?.syncStatus === MailboxSyncStatus.failed)
|
|
94
|
+
throw new Error(MAILBOX_SYNC_FAILED_MESSAGE);
|
|
95
|
+
if (now() >= deadline) throw new Error(MAILBOX_SYNC_TIMEOUT_MESSAGE);
|
|
96
|
+
await delay(pollIntervalMs, signal);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -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
|
};
|