@remit/web-client 0.0.65 → 0.0.66

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.
@@ -0,0 +1,86 @@
1
+ import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type { PreviewCount } from "@remit/ui";
3
+ import { useMutation } from "@tanstack/react-query";
4
+ import { useEffect, useRef, useState } from "react";
5
+ import { buildOrganizeInput } from "@/lib/organize/organize-model";
6
+ import {
7
+ derivePreview,
8
+ PREVIEW_DEBOUNCE_MS,
9
+ type PreviewState,
10
+ predicateSignature,
11
+ } from "@/lib/organize/rule-model";
12
+ import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
13
+
14
+ /**
15
+ * The live match count for the edited rule (RFC 038 D1). Every predicate change
16
+ * schedules a debounced `POST /organize/preview` and, until it lands, reports
17
+ * the last count as stale so the count never blanks and the commit gate can
18
+ * hold apply. Seeded with the widen probe's count, so opening the editor shows a
19
+ * settled number without an immediate re-fetch.
20
+ *
21
+ * Out-of-order responses are dropped: only the reply to the most recent request
22
+ * updates the count, so a slow early preview never overwrites a newer one. The
23
+ * server bounds the match, so a broad predicate over a large mailbox stays a
24
+ * single cheap request; the debounce is what keeps rapid edits from spamming it.
25
+ */
26
+ export const useRulePreview = (
27
+ accountId: string | undefined,
28
+ predicate: OrganizeMatchPredicate,
29
+ seedCount: number,
30
+ ): PreviewCount => {
31
+ const mutation = useMutation(organizeOperationsPreviewOrganizeMutation());
32
+ const { mutateAsync } = mutation;
33
+ const currentSignature = predicateSignature(predicate);
34
+
35
+ const [state, setState] = useState<PreviewState>(() => ({
36
+ count: seedCount,
37
+ previewedSignature: currentSignature,
38
+ }));
39
+
40
+ const predicateRef = useRef(predicate);
41
+ predicateRef.current = predicate;
42
+ const latestRequested = useRef(currentSignature);
43
+
44
+ useEffect(() => {
45
+ if (!accountId) return;
46
+ if (currentSignature === state.previewedSignature) return;
47
+ if (currentSignature === state.errorSignature) return;
48
+
49
+ const handle = setTimeout(() => {
50
+ const signature = currentSignature;
51
+ latestRequested.current = signature;
52
+ mutateAsync({
53
+ path: { accountId },
54
+ body: buildOrganizeInput({ ...predicateRef.current }),
55
+ })
56
+ .then((response) => {
57
+ if (latestRequested.current !== signature) return;
58
+ setState({
59
+ count: response.matchedCount,
60
+ previewedSignature: signature,
61
+ });
62
+ })
63
+ .catch((error: unknown) => {
64
+ if (latestRequested.current !== signature) return;
65
+ setState((previous) => ({
66
+ ...previous,
67
+ error:
68
+ error instanceof Error
69
+ ? error.message
70
+ : "Couldn't count matches.",
71
+ errorSignature: signature,
72
+ }));
73
+ });
74
+ }, PREVIEW_DEBOUNCE_MS);
75
+
76
+ return () => clearTimeout(handle);
77
+ }, [
78
+ accountId,
79
+ currentSignature,
80
+ state.previewedSignature,
81
+ state.errorSignature,
82
+ mutateAsync,
83
+ ]);
84
+
85
+ return derivePreview(state, currentSignature);
86
+ };
@@ -0,0 +1,297 @@
1
+ /**
2
+ * The rule ↔ predicate wiring behind the Organize chip editor (RFC 038 D1). The
3
+ * load-bearing contract lives here: the predicate the editor previews is exactly
4
+ * the predicate a commit applies, so the count on screen is the set that moves.
5
+ */
6
+
7
+ import assert from "node:assert/strict";
8
+ import { describe, it } from "node:test";
9
+ import type { FilterRule } from "@remit/ui";
10
+ import { buildOrganizeInput } from "./organize-model";
11
+ import {
12
+ buildInitialRule,
13
+ derivePreview,
14
+ normalizeClauseValue,
15
+ normalizeListId,
16
+ predicateSignature,
17
+ rulePredicate,
18
+ ruleToDraft,
19
+ SUPPORTED_CLAUSE_FIELDS,
20
+ } from "./rule-model";
21
+
22
+ describe("SUPPORTED_CLAUSE_FIELDS", () => {
23
+ it("offers every field the backend matcher now evaluates, ListId and FromDomain included (#262)", () => {
24
+ assert.deepEqual(SUPPORTED_CLAUSE_FIELDS, [
25
+ "From",
26
+ "Subject",
27
+ "HasWords",
28
+ "ListId",
29
+ "FromDomain",
30
+ ]);
31
+ });
32
+ });
33
+
34
+ describe("normalizeListId", () => {
35
+ it("strips the bracketed identifier, trims, and case-folds — matching the backend", () => {
36
+ assert.equal(
37
+ normalizeListId("<Weekly.News.Example.com>"),
38
+ "weekly.news.example.com",
39
+ );
40
+ assert.equal(normalizeListId(" Weekly.News "), "weekly.news");
41
+ assert.equal(normalizeListId(""), "");
42
+ });
43
+
44
+ it("only normalizes the ListId field, trimming the rest", () => {
45
+ assert.equal(
46
+ normalizeClauseValue("ListId", "<List.Example.COM>"),
47
+ "list.example.com",
48
+ );
49
+ assert.equal(normalizeClauseValue("From", " a@x.com "), "a@x.com");
50
+ assert.equal(
51
+ normalizeClauseValue("FromDomain", " github.com "),
52
+ "github.com",
53
+ );
54
+ });
55
+ });
56
+
57
+ describe("buildInitialRule", () => {
58
+ it("opens a semantic-capable rule on the widen anchor with no literal clauses", () => {
59
+ const rule = buildInitialRule({
60
+ anchorMessageId: "msg-1",
61
+ semanticUnavailable: false,
62
+ senders: [],
63
+ selectionCount: 3,
64
+ });
65
+ assert.equal(rule.clauses.length, 0);
66
+ assert.deepEqual(rule.widen, { anchorCount: 3 });
67
+ assert.equal(rule.matchOperator, "all");
68
+ assert.equal(rule.scope, "once");
69
+ });
70
+
71
+ it("opens the sender fallback on visible, editable derived From chips (#251)", () => {
72
+ const rule = buildInitialRule({
73
+ anchorMessageId: "msg-1",
74
+ semanticUnavailable: true,
75
+ senders: ["a@x.com", "b@y.com"],
76
+ selectionCount: 2,
77
+ });
78
+ assert.equal(rule.widen, undefined);
79
+ assert.equal(rule.matchOperator, "any");
80
+ assert.deepEqual(
81
+ rule.clauses.map((clause) => ({
82
+ field: clause.field,
83
+ value: clause.value,
84
+ derived: clause.derived,
85
+ })),
86
+ [
87
+ { field: "From", value: "a@x.com", derived: true },
88
+ { field: "From", value: "b@y.com", derived: true },
89
+ ],
90
+ );
91
+ });
92
+
93
+ it("collapses senders sharing a registrable domain to one derived FromDomain chip (#262)", () => {
94
+ const rule = buildInitialRule({
95
+ semanticUnavailable: true,
96
+ senders: ["npm@github.com", "ci@sub.github.com"],
97
+ selectionCount: 2,
98
+ });
99
+ assert.equal(rule.matchOperator, "any");
100
+ assert.deepEqual(
101
+ rule.clauses.map((clause) => ({
102
+ field: clause.field,
103
+ value: clause.value,
104
+ derived: clause.derived,
105
+ })),
106
+ [{ field: "FromDomain", value: "github.com", derived: true }],
107
+ );
108
+ });
109
+
110
+ it("carries a Something-else seed's folder and scope", () => {
111
+ const rule = buildInitialRule({
112
+ anchorMessageId: "msg-1",
113
+ semanticUnavailable: false,
114
+ senders: [],
115
+ selectionCount: 1,
116
+ seedMailboxId: "mbx-archive",
117
+ seedScope: "standing",
118
+ });
119
+ assert.equal(rule.moveMailboxId, "mbx-archive");
120
+ assert.equal(rule.scope, "standing");
121
+ });
122
+
123
+ it("offers no widen when there is no anchor to ride on", () => {
124
+ const rule = buildInitialRule({
125
+ semanticUnavailable: false,
126
+ senders: [],
127
+ selectionCount: 0,
128
+ });
129
+ assert.equal(rule.widen, undefined);
130
+ });
131
+ });
132
+
133
+ const semanticRule: FilterRule = {
134
+ clauses: [{ id: "c1", field: "Subject", value: "receipt" }],
135
+ matchOperator: "all",
136
+ widen: { anchorCount: 2 },
137
+ moveMailboxId: "mbx-archive",
138
+ scope: "once",
139
+ };
140
+
141
+ describe("rulePredicate", () => {
142
+ it("includes the anchor while the widen is present and active", () => {
143
+ const predicate = rulePredicate(semanticRule, "msg-1");
144
+ assert.equal(predicate.anchorMessageId, "msg-1");
145
+ assert.equal(predicate.matchOperator, "And");
146
+ assert.deepEqual(predicate.literalClauses, [
147
+ { field: "Subject", value: "receipt" },
148
+ ]);
149
+ });
150
+
151
+ it("drops the anchor once the widen is removed", () => {
152
+ const predicate = rulePredicate(
153
+ { ...semanticRule, widen: undefined },
154
+ "msg-1",
155
+ );
156
+ assert.equal(predicate.anchorMessageId, undefined);
157
+ });
158
+
159
+ it("drops the anchor for a widen the deployment cannot evaluate (D4 inactive)", () => {
160
+ const predicate = rulePredicate(
161
+ { ...semanticRule, widen: { anchorCount: 2, inactive: true } },
162
+ "msg-1",
163
+ );
164
+ assert.equal(predicate.anchorMessageId, undefined);
165
+ });
166
+
167
+ it("maps the any operator to Or", () => {
168
+ const predicate = rulePredicate(
169
+ { ...semanticRule, matchOperator: "any" },
170
+ "msg-1",
171
+ );
172
+ assert.equal(predicate.matchOperator, "Or");
173
+ });
174
+ });
175
+
176
+ describe("predicateSignature", () => {
177
+ it("is stable across identical predicates", () => {
178
+ assert.equal(
179
+ predicateSignature(rulePredicate(semanticRule, "msg-1")),
180
+ predicateSignature(rulePredicate(semanticRule, "msg-1")),
181
+ );
182
+ });
183
+
184
+ it("changes when a clause value changes", () => {
185
+ const changed: FilterRule = {
186
+ ...semanticRule,
187
+ clauses: [{ id: "c1", field: "Subject", value: "invoice" }],
188
+ };
189
+ assert.notEqual(
190
+ predicateSignature(rulePredicate(semanticRule, "msg-1")),
191
+ predicateSignature(rulePredicate(changed, "msg-1")),
192
+ );
193
+ });
194
+
195
+ it("changes when the widen is removed", () => {
196
+ assert.notEqual(
197
+ predicateSignature(rulePredicate(semanticRule, "msg-1")),
198
+ predicateSignature(
199
+ rulePredicate({ ...semanticRule, widen: undefined }, "msg-1"),
200
+ ),
201
+ );
202
+ });
203
+ });
204
+
205
+ describe("the previewed set equals the applied set", () => {
206
+ it("carries exactly the previewed predicate into the commit draft", () => {
207
+ const preview = buildOrganizeInput({
208
+ ...rulePredicate(semanticRule, "msg-1"),
209
+ matchOperator: rulePredicate(semanticRule, "msg-1").matchOperator,
210
+ literalClauses: rulePredicate(semanticRule, "msg-1").literalClauses,
211
+ });
212
+ const apply = buildOrganizeInput(ruleToDraft(semanticRule, "msg-1"));
213
+ assert.equal(apply.anchorMessageId, preview.anchorMessageId);
214
+ assert.equal(apply.matchOperator, preview.matchOperator);
215
+ assert.deepEqual(apply.literalClauses, preview.literalClauses);
216
+ });
217
+
218
+ it("keeps them equal for the sender fallback (no anchor, Or)", () => {
219
+ const rule = buildInitialRule({
220
+ semanticUnavailable: true,
221
+ senders: ["a@x.com", "b@y.com"],
222
+ selectionCount: 2,
223
+ });
224
+ const predicate = rulePredicate(rule);
225
+ const apply = buildOrganizeInput(ruleToDraft(rule));
226
+ assert.equal(apply.anchorMessageId, undefined);
227
+ assert.equal(apply.matchOperator, "Or");
228
+ assert.deepEqual(apply.literalClauses, predicate.literalClauses);
229
+ });
230
+
231
+ it("adds the expiry only for the until scope, never touching the match set", () => {
232
+ const once = ruleToDraft(semanticRule, "msg-1");
233
+ assert.equal(once.expiresAt, undefined);
234
+
235
+ const until = ruleToDraft(
236
+ { ...semanticRule, scope: "until", until: "2999-01-02" },
237
+ "msg-1",
238
+ );
239
+ assert.ok(until.expiresAt?.startsWith("2999-01-02"));
240
+ assert.deepEqual(until.literalClauses, once.literalClauses);
241
+ assert.equal(until.anchorMessageId, once.anchorMessageId);
242
+ });
243
+
244
+ it("carries the move destination into the draft", () => {
245
+ assert.equal(
246
+ ruleToDraft(semanticRule, "msg-1").moveMailboxId,
247
+ "mbx-archive",
248
+ );
249
+ });
250
+ });
251
+
252
+ describe("derivePreview", () => {
253
+ const signature = "sig-current";
254
+
255
+ it("is loading before the first count lands", () => {
256
+ assert.deepEqual(derivePreview({}, signature), { status: "loading" });
257
+ });
258
+
259
+ it("is ready when the count was counted for the current predicate", () => {
260
+ assert.deepEqual(
261
+ derivePreview({ count: 42, previewedSignature: signature }, signature),
262
+ { status: "ready", count: 42 },
263
+ );
264
+ });
265
+
266
+ it("goes stale — never blank — when the predicate has moved past the count", () => {
267
+ assert.deepEqual(
268
+ derivePreview({ count: 42, previewedSignature: "sig-old" }, signature),
269
+ { status: "ready", count: 42, stale: true },
270
+ );
271
+ });
272
+
273
+ it("surfaces an error raised for the current predicate", () => {
274
+ assert.deepEqual(
275
+ derivePreview(
276
+ { count: 1, error: "boom", errorSignature: signature },
277
+ signature,
278
+ ),
279
+ { status: "error", reason: "boom" },
280
+ );
281
+ });
282
+
283
+ it("ignores an error raised for a predicate already moved past", () => {
284
+ assert.deepEqual(
285
+ derivePreview(
286
+ {
287
+ count: 5,
288
+ previewedSignature: signature,
289
+ error: "boom",
290
+ errorSignature: "sig-old",
291
+ },
292
+ signature,
293
+ ),
294
+ { status: "ready", count: 5 },
295
+ );
296
+ });
297
+ });
@@ -0,0 +1,205 @@
1
+ import type {
2
+ ClauseField,
3
+ FilterRule,
4
+ MatchOperator,
5
+ PreviewCount,
6
+ RuleClause,
7
+ RuleScope,
8
+ } from "@remit/ui";
9
+ import { pickedDateToExpiresAt } from "./filter-status";
10
+ import type { OrganizeDraft } from "./organize-model";
11
+ import {
12
+ deriveSenderClauses,
13
+ type OrganizeMatchPredicate,
14
+ } from "./sender-fallback";
15
+
16
+ /**
17
+ * The clause fields the Organize editor may offer (RFC 038 D2). Gated on what
18
+ * the backend matcher evaluates, not on what the generated enum carries: every
19
+ * one of these is honored by the literal matcher and the back-apply corpus
20
+ * projection (match.ts), so a chip never previews a set the apply can't
21
+ * reproduce. `ListId` and `FromDomain` joined once their matcher shipped (#262).
22
+ */
23
+ export const SUPPORTED_CLAUSE_FIELDS: ClauseField[] = [
24
+ "From",
25
+ "Subject",
26
+ "HasWords",
27
+ "ListId",
28
+ "FromDomain",
29
+ ];
30
+
31
+ /**
32
+ * Canonical form of a `ListId` clause value, matching the backend's
33
+ * `normalizeListId` (list-id.ts): the bracketed identifier when present,
34
+ * otherwise the whole value, trimmed and case-folded. Normalizing at input keeps
35
+ * the chip the user sees identical to the value the matcher compares, so
36
+ * `<weekly.news.example.com>` and `Weekly.News.Example.com` are one list.
37
+ */
38
+ export const normalizeListId = (value: string): string => {
39
+ const trimmed = value.trim();
40
+ const bracketed = trimmed.match(/<([^>]+)>/);
41
+ return (bracketed ? bracketed[1] : trimmed).trim().toLowerCase();
42
+ };
43
+
44
+ /** Canonicalize a clause value for its field before it becomes a chip. */
45
+ export const normalizeClauseValue = (
46
+ field: ClauseField,
47
+ value: string,
48
+ ): string => (field === "ListId" ? normalizeListId(value) : value.trim());
49
+
50
+ const toApiOperator = (operator: MatchOperator): "And" | "Or" =>
51
+ operator === "all" ? "And" : "Or";
52
+
53
+ export interface InitialRuleInput {
54
+ /** The semantic anchor — the first selected message. */
55
+ anchorMessageId?: string;
56
+ /**
57
+ * The deployment ships no vector pipeline, so the widen cannot run. The rule
58
+ * opens on the sender-derived literal clauses instead of an anchor.
59
+ */
60
+ semanticUnavailable: boolean;
61
+ /** Distinct sender addresses of the selection, for the fallback clauses. */
62
+ senders: readonly string[];
63
+ /** How many messages the selection holds — the widen's anchor count. */
64
+ selectionCount: number;
65
+ /** A folder a "Something else" shortcut pre-picked. */
66
+ seedMailboxId?: string;
67
+ /** A scope a "Something else" shortcut pre-picked. */
68
+ seedScope?: RuleScope;
69
+ }
70
+
71
+ /**
72
+ * The rule the editor opens on, built from the widen probe. A semantic-capable
73
+ * deployment opens on the widen chip (the anchor, no literal clauses); one
74
+ * without the vector pipeline opens on the sender-fallback `From` chips (#251),
75
+ * visible and editable, matched with `Or`. Either way the move destination and
76
+ * scope come from any "Something else" seed.
77
+ */
78
+ export const buildInitialRule = (input: InitialRuleInput): FilterRule => {
79
+ const scope = input.seedScope ?? "once";
80
+ if (input.semanticUnavailable) {
81
+ // The same derivation the widen fallback runs (#251, #262): one `From`
82
+ // clause per sender, or a single `FromDomain` clause when they share a
83
+ // registrable domain. Surfaced as ordinary editable chips.
84
+ const clauses: RuleClause[] = deriveSenderClauses(input.senders).map(
85
+ (clause, index) => ({
86
+ id: `derived-${index}`,
87
+ field: clause.field,
88
+ value: clause.value,
89
+ derived: true,
90
+ }),
91
+ );
92
+ return {
93
+ clauses,
94
+ matchOperator: "any",
95
+ moveMailboxId: input.seedMailboxId,
96
+ scope,
97
+ name: "",
98
+ };
99
+ }
100
+ return {
101
+ clauses: [],
102
+ matchOperator: "all",
103
+ widen: input.anchorMessageId
104
+ ? { anchorCount: Math.max(input.selectionCount, 1) }
105
+ : undefined,
106
+ moveMailboxId: input.seedMailboxId,
107
+ scope,
108
+ name: "",
109
+ };
110
+ };
111
+
112
+ /**
113
+ * The match-relevant projection of a rule — the anchor (only while the widen is
114
+ * present and active), the operator, and the literal clauses. Preview and apply
115
+ * both derive from this, so the set the editor counts is the set a commit acts
116
+ * on. Fields that do not change the match set (folder, scope, name, date) are
117
+ * deliberately absent.
118
+ */
119
+ export const rulePredicate = (
120
+ rule: FilterRule,
121
+ anchorMessageId?: string,
122
+ ): OrganizeMatchPredicate => {
123
+ const widenActive = rule.widen !== undefined && !rule.widen.inactive;
124
+ const literalClauses = rule.clauses.map((clause) => ({
125
+ field: clause.field,
126
+ value: clause.value,
127
+ }));
128
+ return {
129
+ ...(widenActive && anchorMessageId ? { anchorMessageId } : {}),
130
+ matchOperator: toApiOperator(rule.matchOperator),
131
+ literalClauses,
132
+ };
133
+ };
134
+
135
+ /**
136
+ * A stable key for a predicate's match set. Two predicates with the same key
137
+ * match the same messages; a change to the key is what marks the live count
138
+ * stale and schedules the next preview.
139
+ */
140
+ export const predicateSignature = (predicate: OrganizeMatchPredicate): string =>
141
+ JSON.stringify({
142
+ anchor: predicate.anchorMessageId ?? null,
143
+ operator: predicate.matchOperator,
144
+ clauses: predicate.literalClauses.map(
145
+ (clause) => `${clause.field} ${clause.value}`,
146
+ ),
147
+ });
148
+
149
+ /**
150
+ * The commit draft for a rule. The match fields are exactly
151
+ * {@link rulePredicate}'s — the previewed predicate — with the folder action
152
+ * and, for the `until` scope, the derived expiry added. The expiry never enters
153
+ * the match set, so preview and apply still count and act on the same messages.
154
+ */
155
+ export const ruleToDraft = (
156
+ rule: FilterRule,
157
+ anchorMessageId?: string,
158
+ ): OrganizeDraft => {
159
+ const predicate = rulePredicate(rule, anchorMessageId);
160
+ return {
161
+ ...(predicate.anchorMessageId
162
+ ? { anchorMessageId: predicate.anchorMessageId }
163
+ : {}),
164
+ matchOperator: predicate.matchOperator,
165
+ literalClauses: predicate.literalClauses,
166
+ moveMailboxId: rule.moveMailboxId,
167
+ expiresAt:
168
+ rule.scope === "until"
169
+ ? pickedDateToExpiresAt(rule.until ?? "")
170
+ : undefined,
171
+ };
172
+ };
173
+
174
+ export interface PreviewState {
175
+ /** The last count that came back, `undefined` before the first lands. */
176
+ count?: number;
177
+ /** The signature the {@link count} was counted for. */
178
+ previewedSignature?: string;
179
+ /** The signature the last error was raised for. */
180
+ errorSignature?: string;
181
+ error?: string;
182
+ }
183
+
184
+ /**
185
+ * The live count for the rule on screen. The count is `ready` only when it was
186
+ * counted for the current predicate; a predicate change since then reads
187
+ * `stale` (recounting, never blank) so the commit gate can hold apply until it
188
+ * settles.
189
+ */
190
+ export const derivePreview = (
191
+ state: PreviewState,
192
+ currentSignature: string,
193
+ ): PreviewCount => {
194
+ if (state.error !== undefined && state.errorSignature === currentSignature) {
195
+ return { status: "error", reason: state.error };
196
+ }
197
+ if (state.count === undefined) return { status: "loading" };
198
+ if (state.previewedSignature === currentSignature) {
199
+ return { status: "ready", count: state.count };
200
+ }
201
+ return { status: "ready", count: state.count, stale: true };
202
+ };
203
+
204
+ /** How long a rule change settles before the next preview fires. */
205
+ export const PREVIEW_DEBOUNCE_MS = 350;
@@ -1,113 +0,0 @@
1
- /**
2
- * The back-apply progress copy. On a deployment without the vector pipeline the
3
- * "all like these" job moves the sender-matched set, so the in-progress line must
4
- * state the sender semantics — never "similar mail" (#250's honesty
5
- * requirement). The semantic path keeps the "similar mail" wording.
6
- */
7
-
8
- import assert from "node:assert/strict";
9
- import { afterEach, describe, it } from "node:test";
10
- import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
11
- import { createElement } from "react";
12
- import type { OrganizeMatchPredicate } from "../../../lib/organize/sender-fallback";
13
- import { createDomHarness, type DomHarness } from "../../../test-support/dom";
14
- import { makeMailbox } from "../../../test-support/fixtures";
15
- import { type HttpMock, mockFetch } from "../../../test-support/http";
16
- import { OrganizePanel } from "./OrganizePanel";
17
-
18
- let harness: DomHarness | undefined;
19
- let http: HttpMock | undefined;
20
-
21
- afterEach(() => {
22
- harness?.close();
23
- harness = undefined;
24
- http?.restore();
25
- http = undefined;
26
- });
27
-
28
- const ACCOUNT_ID = "acc-1";
29
-
30
- const MAILBOXES = [
31
- makeMailbox({ mailboxId: "mbx-inbox", fullPath: "INBOX" }),
32
- makeMailbox({ mailboxId: "mbx-archive", fullPath: "Archive" }),
33
- ];
34
-
35
- const SEMANTIC_PREDICATE: OrganizeMatchPredicate = {
36
- anchorMessageId: "msg-1",
37
- matchOperator: "And",
38
- literalClauses: [],
39
- };
40
-
41
- const SENDER_PREDICATE: OrganizeMatchPredicate = {
42
- matchOperator: "Or",
43
- literalClauses: [{ field: "From", value: "npm@github.com" }],
44
- };
45
-
46
- const mount = (
47
- props: Partial<Parameters<typeof OrganizePanel>[0]>,
48
- ): DomHarness => {
49
- http = mockFetch((call) => {
50
- if (call.path.endsWith("/organize") && call.method === "POST") {
51
- return { organizeJobId: "job-1", state: "Processing" };
52
- }
53
- if (call.path.endsWith("/organize/job-1") && call.method === "GET") {
54
- return {
55
- organizeJobId: "job-1",
56
- state: "Processing",
57
- matchedCount: 128,
58
- appliedCount: 0,
59
- failedCount: 0,
60
- };
61
- }
62
- return {};
63
- });
64
- harness = createDomHarness();
65
- harness.queryClient.setQueryData(
66
- mailboxOperationsListMailboxesQueryKey({ path: { accountId: ACCOUNT_ID } }),
67
- { items: MAILBOXES },
68
- );
69
- harness.renderApp(
70
- createElement(OrganizePanel, {
71
- accountId: ACCOUNT_ID,
72
- mailboxId: "mbx-inbox",
73
- selectedMessageIds: ["msg-1", "msg-2"],
74
- matchPredicate: SEMANTIC_PREDICATE,
75
- matchedCount: 128,
76
- seedMailboxId: "mbx-archive",
77
- onClose: () => undefined,
78
- ...props,
79
- }),
80
- );
81
- return harness;
82
- };
83
-
84
- async function startJob(dom: DomHarness): Promise<void> {
85
- dom.click(dom.byText("button", "Organize"));
86
- for (let attempt = 0; attempt < 20; attempt += 1) {
87
- await dom.flush();
88
- if (/Organizing/.test(dom.text())) return;
89
- await dom.wait(1);
90
- }
91
- }
92
-
93
- describe("OrganizePanel back-apply progress copy", () => {
94
- it("states the sender semantics while organizing in the sender fallback", async () => {
95
- const dom = mount({
96
- matchPredicate: SENDER_PREDICATE,
97
- semanticUnavailable: true,
98
- senders: ["npm@github.com"],
99
- });
100
- await startJob(dom);
101
-
102
- assert.match(dom.text(), /Organizing mail from these senders/);
103
- assert.doesNotMatch(dom.text(), /Organizing similar mail/);
104
- });
105
-
106
- it("keeps the similar-mail wording on the semantic path", async () => {
107
- const dom = mount({});
108
- await startJob(dom);
109
-
110
- assert.match(dom.text(), /Organizing similar mail/);
111
- assert.doesNotMatch(dom.text(), /from these senders/);
112
- });
113
- });