@remit/ui 0.0.83 → 0.0.85
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/filter-rule-editor.create.test.ts +247 -141
- package/src/components/filter-rule-editor.stories.tsx +154 -71
- package/src/components/filter-rule-editor.tsx +129 -145
- package/src/components/filter-rule.render.test.ts +4 -4
- package/src/components/filter-rule.ts +17 -11
- package/src/components/folder-tree-picker.tsx +6 -1
- package/src/components/intelligence-panel.stories.tsx +46 -0
- package/src/index.ts +0 -1
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The move-to
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* imported after the jsdom globals are installed so the
|
|
7
|
-
* binds to jsdom's prototypes.
|
|
2
|
+
* The move-to destination as the browsable folder tree: what it says before a
|
|
3
|
+
* folder is chosen, that a nested folder is told apart from a same-named
|
|
4
|
+
* sibling, and that a folder can be made inside another one from here. Mounted
|
|
5
|
+
* against jsdom for the tree interaction, the create form state and the async
|
|
6
|
+
* resolve. React is imported after the jsdom globals are installed so the
|
|
7
|
+
* controlled value tracker binds to jsdom's prototypes.
|
|
8
8
|
*/
|
|
9
9
|
import assert from "node:assert/strict";
|
|
10
10
|
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
@@ -12,14 +12,26 @@ import type { JSDOM } from "jsdom";
|
|
|
12
12
|
import type {
|
|
13
13
|
act as reactAct,
|
|
14
14
|
createElement as reactCreateElement,
|
|
15
|
+
useState as reactUseState,
|
|
15
16
|
} from "react";
|
|
16
17
|
import type { Root, createRoot as reactCreateRoot } from "react-dom/client";
|
|
17
|
-
import type { FilterRule,
|
|
18
|
+
import type { FilterRule, PreviewCount } from "./filter-rule.js";
|
|
18
19
|
import type { FilterRuleEditor as FilterRuleEditorType } from "./filter-rule-editor.js";
|
|
20
|
+
import type { FolderTreeNode } from "./folder-tree-picker.js";
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
// `Prullenbak` labelled Trash is the account's own naming; two folders named
|
|
23
|
+
// Receipts at different depths are what a flat list of leaf names cannot tell
|
|
24
|
+
// apart.
|
|
25
|
+
const folders: FolderTreeNode[] = [
|
|
26
|
+
{ id: "mbx-inbox", label: "Inbox", path: "INBOX" },
|
|
27
|
+
{ id: "mbx-trash", label: "Trash", path: "INBOX/Prullenbak" },
|
|
28
|
+
{ id: "mbx-receipts", label: "Receipts", path: "INBOX/Receipts" },
|
|
29
|
+
{ id: "mbx-travel", label: "Travel", path: "INBOX/Travel" },
|
|
30
|
+
{
|
|
31
|
+
id: "mbx-travel-receipts",
|
|
32
|
+
label: "Receipts",
|
|
33
|
+
path: "INBOX/Travel/Receipts",
|
|
34
|
+
},
|
|
23
35
|
];
|
|
24
36
|
|
|
25
37
|
const rule: FilterRule = {
|
|
@@ -35,6 +47,7 @@ let container: HTMLElement;
|
|
|
35
47
|
let root: Root;
|
|
36
48
|
let act: typeof reactAct;
|
|
37
49
|
let createElement: typeof reactCreateElement;
|
|
50
|
+
let useState: typeof reactUseState;
|
|
38
51
|
let createRoot: typeof reactCreateRoot;
|
|
39
52
|
let FilterRuleEditor: typeof FilterRuleEditorType;
|
|
40
53
|
|
|
@@ -56,10 +69,15 @@ before(async () => {
|
|
|
56
69
|
(
|
|
57
70
|
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
58
71
|
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
72
|
+
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", {
|
|
73
|
+
configurable: true,
|
|
74
|
+
value: () => undefined,
|
|
75
|
+
});
|
|
59
76
|
|
|
60
77
|
const react = await import("react");
|
|
61
78
|
act = react.act;
|
|
62
79
|
createElement = react.createElement;
|
|
80
|
+
useState = react.useState;
|
|
63
81
|
({ createRoot } = await import("react-dom/client"));
|
|
64
82
|
({ FilterRuleEditor } = await import("./filter-rule-editor.js"));
|
|
65
83
|
});
|
|
@@ -76,174 +94,262 @@ beforeEach(() => {
|
|
|
76
94
|
root = createRoot(container);
|
|
77
95
|
});
|
|
78
96
|
|
|
79
|
-
afterEach(() => {
|
|
80
|
-
act(() => {
|
|
97
|
+
afterEach(async () => {
|
|
98
|
+
await act(async () => {
|
|
81
99
|
root.unmount();
|
|
82
100
|
});
|
|
83
101
|
});
|
|
84
102
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
);
|
|
103
|
+
interface MountOptions {
|
|
104
|
+
/** The destination the rule already carries when the editor opens. */
|
|
105
|
+
initialDestination?: string;
|
|
106
|
+
onCreateFolder?: (
|
|
107
|
+
name: string,
|
|
108
|
+
parentPath: string,
|
|
109
|
+
signal?: AbortSignal,
|
|
110
|
+
) => Promise<FolderTreeNode>;
|
|
111
|
+
onChangeMove?: (id: string) => void;
|
|
95
112
|
}
|
|
96
113
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
114
|
+
/** Holds the destination the way the app does, so a pick shows on screen. */
|
|
115
|
+
const mount = async ({
|
|
116
|
+
initialDestination,
|
|
117
|
+
onCreateFolder,
|
|
118
|
+
onChangeMove,
|
|
119
|
+
}: MountOptions = {}) => {
|
|
120
|
+
const Controlled = () => {
|
|
121
|
+
const [moveMailboxId, setMoveMailboxId] = useState<string | undefined>(
|
|
122
|
+
initialDestination,
|
|
123
|
+
);
|
|
124
|
+
return createElement(FilterRuleEditor, {
|
|
125
|
+
rule: { ...rule, moveMailboxId },
|
|
126
|
+
folders,
|
|
127
|
+
preview,
|
|
128
|
+
onChangeMove: (id: string) => {
|
|
129
|
+
setMoveMailboxId(id || undefined);
|
|
130
|
+
onChangeMove?.(id);
|
|
131
|
+
},
|
|
132
|
+
onCreateFolder,
|
|
133
|
+
onCommit: () => {},
|
|
134
|
+
onCancel: () => {},
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
await act(async () => {
|
|
138
|
+
root.render(createElement(Controlled));
|
|
107
139
|
});
|
|
108
|
-
}
|
|
140
|
+
};
|
|
109
141
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
setter?.call(el, value);
|
|
116
|
-
act(() => {
|
|
117
|
-
el.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
|
142
|
+
const click = async (element: Element | null | undefined) => {
|
|
143
|
+
if (!(element instanceof dom.window.HTMLElement))
|
|
144
|
+
assert.fail("control not rendered");
|
|
145
|
+
await act(async () => {
|
|
146
|
+
element.click();
|
|
118
147
|
});
|
|
119
|
-
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const byAriaLabel = (label: string): HTMLElement | null =>
|
|
151
|
+
container.querySelector(`[aria-label="${label}"]`);
|
|
120
152
|
|
|
121
|
-
|
|
122
|
-
|
|
153
|
+
const byText = (text: string): HTMLButtonElement | undefined =>
|
|
154
|
+
Array.from(container.querySelectorAll("button")).find(
|
|
123
155
|
(candidate) => candidate.textContent?.trim() === text,
|
|
124
156
|
);
|
|
125
|
-
}
|
|
126
157
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
158
|
+
const rows = (): HTMLElement[] =>
|
|
159
|
+
Array.from(container.querySelectorAll('[role="treeitem"]'));
|
|
160
|
+
|
|
161
|
+
const receiptRows = (): HTMLElement[] =>
|
|
162
|
+
rows().filter((row) => row.getAttribute("aria-label") === "Move to Receipts");
|
|
163
|
+
|
|
164
|
+
const rowLabels = (): string[] =>
|
|
165
|
+
rows().map((row) => row.getAttribute("aria-label") ?? "");
|
|
166
|
+
|
|
167
|
+
const openTree = async () => {
|
|
168
|
+
await click(byText("Choose a folder"));
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const openFolder = async (label: string) => {
|
|
172
|
+
await click(byAriaLabel(`Move to ${label}`));
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const nameField = (): HTMLInputElement | null => {
|
|
176
|
+
const label = Array.from(container.querySelectorAll("label")).find(
|
|
177
|
+
(node) => node.textContent?.trim() === "Folder name",
|
|
178
|
+
);
|
|
179
|
+
const id = label?.getAttribute("for");
|
|
180
|
+
return id
|
|
181
|
+
? (container.querySelector(`input[id="${id}"]`) as HTMLInputElement | null)
|
|
182
|
+
: null;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const typeName = async (value: string) => {
|
|
186
|
+
const input = nameField();
|
|
187
|
+
assert.ok(input, "the folder name field is on screen");
|
|
188
|
+
await act(async () => {
|
|
189
|
+
Object.getOwnPropertyDescriptor(
|
|
190
|
+
dom.window.HTMLInputElement.prototype,
|
|
191
|
+
"value",
|
|
192
|
+
)?.set?.call(input, value);
|
|
193
|
+
input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
|
142
194
|
});
|
|
143
|
-
}
|
|
195
|
+
};
|
|
144
196
|
|
|
145
|
-
describe("FilterRuleEditor
|
|
146
|
-
it("
|
|
147
|
-
mount(
|
|
148
|
-
assert.equal(
|
|
197
|
+
describe("FilterRuleEditor move destination", () => {
|
|
198
|
+
it("keeps the tree closed until a destination is asked for", async () => {
|
|
199
|
+
await mount();
|
|
200
|
+
assert.equal(rows().length, 0);
|
|
201
|
+
assert.match(container.textContent ?? "", /No folder yet/);
|
|
202
|
+
await openTree();
|
|
203
|
+
assert.ok(rows().length > 0, "the folder tree is on screen");
|
|
149
204
|
});
|
|
150
205
|
|
|
151
|
-
it("
|
|
152
|
-
mount(
|
|
153
|
-
|
|
206
|
+
it("names a folder as the account names it, not as the provider paths it", async () => {
|
|
207
|
+
await mount();
|
|
208
|
+
await openTree();
|
|
209
|
+
await openFolder("Inbox");
|
|
210
|
+
assert.ok(
|
|
211
|
+
rowLabels().includes("Move to Trash"),
|
|
212
|
+
"the renamed folder reads as Trash",
|
|
213
|
+
);
|
|
214
|
+
assert.ok(
|
|
215
|
+
!rowLabels().some((label) => label.includes("Prullenbak")),
|
|
216
|
+
"the provider leaf is never what the row reads as",
|
|
217
|
+
);
|
|
154
218
|
});
|
|
155
219
|
|
|
156
|
-
it("
|
|
157
|
-
mount(
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
220
|
+
it("tells a nested folder apart from its same-named sibling", async () => {
|
|
221
|
+
await mount();
|
|
222
|
+
await openTree();
|
|
223
|
+
await openFolder("Inbox");
|
|
224
|
+
// The nested Receipts is out of reach until Travel is opened, so the two
|
|
225
|
+
// same-named folders are never two identical entries in one list.
|
|
226
|
+
assert.equal(receiptRows().length, 1);
|
|
227
|
+
await openFolder("Travel");
|
|
228
|
+
const nested = receiptRows();
|
|
229
|
+
assert.equal(nested.length, 2);
|
|
230
|
+
assert.deepEqual(
|
|
231
|
+
nested.map((row) => row.getAttribute("aria-level")),
|
|
232
|
+
["2", "3"],
|
|
161
233
|
);
|
|
162
|
-
|
|
234
|
+
await click(nested[1]);
|
|
163
235
|
assert.ok(
|
|
164
|
-
|
|
165
|
-
"the
|
|
236
|
+
byText("Move matches to Inbox / Travel / Receipts"),
|
|
237
|
+
"the destination reads as its trail, not a bare leaf name",
|
|
166
238
|
);
|
|
167
239
|
});
|
|
168
240
|
|
|
169
|
-
it("
|
|
170
|
-
const
|
|
171
|
-
mount({
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
241
|
+
it("re-points the rule only on the confirmation, never on the way past", async () => {
|
|
242
|
+
const picked: string[] = [];
|
|
243
|
+
await mount({ onChangeMove: (id) => picked.push(id) });
|
|
244
|
+
await openTree();
|
|
245
|
+
await openFolder("Inbox");
|
|
246
|
+
await openFolder("Travel");
|
|
247
|
+
await click(receiptRows()[1]);
|
|
248
|
+
assert.deepEqual(picked, [], "browsing changes nothing");
|
|
249
|
+
await click(byText("Move matches to Inbox / Travel / Receipts"));
|
|
250
|
+
assert.deepEqual(picked, ["mbx-travel-receipts"]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("leaves the destination alone when the tree is cancelled", async () => {
|
|
254
|
+
const picked: string[] = [];
|
|
255
|
+
await mount({ onChangeMove: (id) => picked.push(id) });
|
|
256
|
+
await openTree();
|
|
257
|
+
await openFolder("Inbox");
|
|
258
|
+
await click(byAriaLabel("Move to Trash"));
|
|
259
|
+
await click(byText("Cancel"));
|
|
260
|
+
assert.deepEqual(picked, []);
|
|
261
|
+
assert.match(container.textContent ?? "", /No folder yet/);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("opens on the branch holding the destination the rule already has", async () => {
|
|
265
|
+
await mount({ initialDestination: "mbx-travel-receipts" });
|
|
266
|
+
assert.match(container.textContent ?? "", /Inbox \/ Travel \/ Receipts/);
|
|
267
|
+
await openTree();
|
|
268
|
+
const selected = rows().filter(
|
|
269
|
+
(row) => row.getAttribute("aria-selected") === "true",
|
|
270
|
+
);
|
|
271
|
+
assert.deepEqual(
|
|
272
|
+
selected.map((row) => row.getAttribute("aria-level")),
|
|
273
|
+
["3"],
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("drops the move action so a rule can apply a label alone", async () => {
|
|
278
|
+
const picked: string[] = [];
|
|
279
|
+
await mount({
|
|
280
|
+
initialDestination: "mbx-receipts",
|
|
281
|
+
onChangeMove: (id) => picked.push(id),
|
|
182
282
|
});
|
|
183
|
-
|
|
283
|
+
await click(byText("Don't move matches"));
|
|
284
|
+
assert.deepEqual(picked, [""]);
|
|
184
285
|
});
|
|
185
286
|
|
|
186
|
-
it("
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
287
|
+
it("offers no create affordance without onCreateFolder", async () => {
|
|
288
|
+
await mount();
|
|
289
|
+
await openTree();
|
|
290
|
+
assert.equal(byAriaLabel("New folder"), null);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("creates a folder inside another and makes it the destination", async () => {
|
|
294
|
+
const created: { name: string; parentPath: string }[] = [];
|
|
295
|
+
const picked: string[] = [];
|
|
296
|
+
await mount({
|
|
297
|
+
onChangeMove: (id) => picked.push(id),
|
|
298
|
+
onCreateFolder: async (name, parentPath) => {
|
|
299
|
+
created.push({ name, parentPath });
|
|
300
|
+
return {
|
|
301
|
+
id: "mbx-created",
|
|
302
|
+
label: name,
|
|
303
|
+
path: `${parentPath}/${name}`,
|
|
304
|
+
};
|
|
192
305
|
},
|
|
193
306
|
});
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
await
|
|
200
|
-
|
|
307
|
+
await openTree();
|
|
308
|
+
await openFolder("Inbox");
|
|
309
|
+
await openFolder("Travel");
|
|
310
|
+
await click(byAriaLabel("New folder inside Travel"));
|
|
311
|
+
await typeName("Car hire");
|
|
312
|
+
await click(byText("Create folder"));
|
|
313
|
+
assert.deepEqual(created, [
|
|
314
|
+
{ name: "Car hire", parentPath: "INBOX/Travel" },
|
|
315
|
+
]);
|
|
316
|
+
await click(byText("Move matches to Inbox / Travel / Car hire"));
|
|
317
|
+
assert.deepEqual(picked, ["mbx-created"]);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("offers a created folder before the caller's folder list refetches", async () => {
|
|
321
|
+
await mount({
|
|
322
|
+
onCreateFolder: async (name, parentPath) => ({
|
|
323
|
+
id: "mbx-created",
|
|
324
|
+
label: name,
|
|
325
|
+
path: parentPath ? `${parentPath}/${name}` : name,
|
|
326
|
+
}),
|
|
201
327
|
});
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
);
|
|
207
|
-
assert.match(
|
|
208
|
-
container.querySelector('[role="alert"]')?.textContent ?? "",
|
|
209
|
-
/already exists/,
|
|
210
|
-
);
|
|
328
|
+
await openTree();
|
|
329
|
+
await click(byAriaLabel("New folder"));
|
|
330
|
+
await typeName("Receipts 2026");
|
|
331
|
+
await click(byText("Create folder"));
|
|
332
|
+
await click(byText("Move matches to Receipts 2026"));
|
|
333
|
+
assert.match(container.textContent ?? "", /Receipts 2026/);
|
|
211
334
|
});
|
|
212
335
|
|
|
213
|
-
it("
|
|
214
|
-
|
|
336
|
+
it("states a failed create where it happened and binds no destination", async () => {
|
|
337
|
+
const picked: string[] = [];
|
|
338
|
+
await mount({
|
|
339
|
+
onChangeMove: (id) => picked.push(id),
|
|
215
340
|
onCreateFolder: async () => {
|
|
216
|
-
throw "
|
|
341
|
+
throw new Error("A folder with that name already exists.");
|
|
217
342
|
},
|
|
218
343
|
});
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
button("Create folder")?.click();
|
|
226
|
-
});
|
|
344
|
+
await openTree();
|
|
345
|
+
await click(byAriaLabel("New folder"));
|
|
346
|
+
await typeName("Receipts");
|
|
347
|
+
await click(byText("Create folder"));
|
|
348
|
+
assert.deepEqual(picked, []);
|
|
349
|
+
assert.ok(nameField(), "the name field stays open");
|
|
227
350
|
assert.match(
|
|
228
351
|
container.querySelector('[role="alert"]')?.textContent ?? "",
|
|
229
|
-
/
|
|
230
|
-
);
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it("cancels the create field and leaves the destination unchanged", () => {
|
|
234
|
-
const moved: string[] = [];
|
|
235
|
-
mount({
|
|
236
|
-
onChangeMove: (id) => moved.push(id),
|
|
237
|
-
onCreateFolder: async (name) => ({ id: "mbx-created", label: name }),
|
|
238
|
-
});
|
|
239
|
-
chooseCreateOption();
|
|
240
|
-
act(() => {
|
|
241
|
-
button("Cancel")?.click();
|
|
242
|
-
});
|
|
243
|
-
assert.equal(
|
|
244
|
-
container.querySelector('input[aria-label="New folder name"]'),
|
|
245
|
-
null,
|
|
352
|
+
/already exists/,
|
|
246
353
|
);
|
|
247
|
-
assert.deepEqual(moved, []);
|
|
248
354
|
});
|
|
249
355
|
});
|