@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.83",
3
+ "version": "0.0.85",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -1,10 +1,10 @@
1
1
  /**
2
- * The move-to "New folder…" affordance: the option shows only when
3
- * `onCreateFolder` is wired; choosing it reveals a name field, and a resolved
4
- * create picks the new folder as the destination. Mounted against jsdom for the
5
- * select interaction, the inline field state, and the async resolve. React is
6
- * imported after the jsdom globals are installed so the controlled value tracker
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, FolderOption, PreviewCount } from "./filter-rule.js";
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
- const folders: FolderOption[] = [
21
- { id: "mbx-inbox", label: "Inbox" },
22
- { id: "mbx-archive", label: "Archive" },
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
- function destinationSelect(): HTMLSelectElement {
86
- return container.querySelector(
87
- 'select[aria-label="Destination folder"]',
88
- ) as HTMLSelectElement;
89
- }
90
-
91
- function createOption(): HTMLOptionElement | undefined {
92
- return Array.from(destinationSelect().querySelectorAll("option")).find(
93
- (option) => option.textContent?.includes("New folder"),
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
- function chooseCreateOption() {
98
- const select = destinationSelect();
99
- const value = createOption()?.value ?? "";
100
- const setter = Object.getOwnPropertyDescriptor(
101
- dom.window.HTMLSelectElement.prototype,
102
- "value",
103
- )?.set;
104
- setter?.call(select, value);
105
- act(() => {
106
- select.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
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
- function setInput(el: HTMLInputElement, value: string) {
111
- const setter = Object.getOwnPropertyDescriptor(
112
- dom.window.HTMLInputElement.prototype,
113
- "value",
114
- )?.set;
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
- function button(text: string): HTMLButtonElement | undefined {
122
- return Array.from(container.querySelectorAll("button")).find(
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
- function mount(overrides: {
128
- onCreateFolder?: (name: string) => Promise<FolderOption>;
129
- onChangeMove?: (id: string) => void;
130
- }) {
131
- act(() => {
132
- root.render(
133
- createElement(FilterRuleEditor, {
134
- rule,
135
- folders,
136
- preview,
137
- onCommit: () => {},
138
- onCancel: () => {},
139
- ...overrides,
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 new-folder option", () => {
146
- it("does not offer the create option without onCreateFolder", () => {
147
- mount({});
148
- assert.equal(createOption(), undefined);
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("offers the create option when onCreateFolder is wired", () => {
152
- mount({ onCreateFolder: async () => ({ id: "x", label: "x" }) });
153
- assert.ok(createOption(), "the + New folder… option is present");
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("reveals the name field only after the option is chosen", () => {
157
- mount({ onCreateFolder: async () => ({ id: "x", label: "x" }) });
158
- assert.equal(
159
- container.querySelector('input[aria-label="New folder name"]'),
160
- null,
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
- chooseCreateOption();
234
+ await click(nested[1]);
163
235
  assert.ok(
164
- container.querySelector('input[aria-label="New folder name"]'),
165
- "the name field appears",
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("creates the folder and selects it as the destination", async () => {
170
- const moved: string[] = [];
171
- mount({
172
- onChangeMove: (id) => moved.push(id),
173
- onCreateFolder: async (name) => ({ id: "mbx-created", label: name }),
174
- });
175
- chooseCreateOption();
176
- const nameInput = container.querySelector(
177
- 'input[aria-label="New folder name"]',
178
- ) as HTMLInputElement;
179
- setInput(nameInput, "Receipts");
180
- await act(async () => {
181
- button("Create folder")?.click();
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
- assert.deepEqual(moved, ["mbx-created"]);
283
+ await click(byText("Don't move matches"));
284
+ assert.deepEqual(picked, [""]);
184
285
  });
185
286
 
186
- it("keeps the name field open and surfaces the rejection message when create fails", async () => {
187
- const moved: string[] = [];
188
- mount({
189
- onChangeMove: (id) => moved.push(id),
190
- onCreateFolder: async () => {
191
- throw new Error("A folder with that name already exists.");
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
- chooseCreateOption();
195
- const nameInput = container.querySelector(
196
- 'input[aria-label="New folder name"]',
197
- ) as HTMLInputElement;
198
- setInput(nameInput, "Archive");
199
- await act(async () => {
200
- button("Create folder")?.click();
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
- assert.deepEqual(moved, []);
203
- assert.ok(
204
- container.querySelector('input[aria-label="New folder name"]'),
205
- "the name field stays open",
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("falls back to the generic message for a non-Error rejection", async () => {
214
- mount({
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 "opaque";
341
+ throw new Error("A folder with that name already exists.");
217
342
  },
218
343
  });
219
- chooseCreateOption();
220
- const nameInput = container.querySelector(
221
- 'input[aria-label="New folder name"]',
222
- ) as HTMLInputElement;
223
- setInput(nameInput, "Receipts");
224
- await act(async () => {
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
- /Couldn't create that folder/,
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
  });