@remit/ui 0.0.83 → 0.0.84
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/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
|
});
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
demoSubjectPrefillRule,
|
|
14
14
|
demoVocabularyRule,
|
|
15
15
|
type FilterRule,
|
|
16
|
-
type FolderOption,
|
|
17
16
|
type LabelOption,
|
|
18
17
|
type PreviewCount,
|
|
19
18
|
type RuleClause,
|
|
@@ -24,6 +23,7 @@ import {
|
|
|
24
23
|
FilterRuleEditor,
|
|
25
24
|
type FilterRuleEditorProps,
|
|
26
25
|
} from "./filter-rule-editor.js";
|
|
26
|
+
import type { FolderTreeNode } from "./folder-tree-picker.js";
|
|
27
27
|
|
|
28
28
|
const meta: Meta<typeof FilterRuleEditor> = {
|
|
29
29
|
title: "FilterRuleEditor",
|
|
@@ -75,8 +75,9 @@ function LiveEditor({
|
|
|
75
75
|
labels?: LabelOption[];
|
|
76
76
|
onCreateFolder?: (
|
|
77
77
|
name: string,
|
|
78
|
+
parentPath: string,
|
|
78
79
|
signal?: AbortSignal,
|
|
79
|
-
) => Promise<
|
|
80
|
+
) => Promise<FolderTreeNode>;
|
|
80
81
|
onCreateLabel?: (name: string) => Promise<LabelOption>;
|
|
81
82
|
}) {
|
|
82
83
|
const [rule, setRule] = useState<FilterRule>(initialRule);
|
|
@@ -323,95 +324,184 @@ export const SubjectClauseStaysFreeText: Story = {
|
|
|
323
324
|
),
|
|
324
325
|
};
|
|
325
326
|
|
|
327
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 60));
|
|
328
|
+
|
|
329
|
+
// The play functions fail loudly rather than quietly rendering a story that
|
|
330
|
+
// documents a state it never reached.
|
|
331
|
+
const clickText = (canvasElement: HTMLElement, label: string) => {
|
|
332
|
+
const button = Array.from(
|
|
333
|
+
canvasElement.querySelectorAll<HTMLButtonElement>("button"),
|
|
334
|
+
).find((candidate) => candidate.textContent?.trim() === label);
|
|
335
|
+
if (!button) throw new Error(`no button reading "${label}"`);
|
|
336
|
+
button.click();
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const clickAriaLabel = (canvasElement: HTMLElement, label: string) => {
|
|
340
|
+
const control = canvasElement.querySelector<HTMLElement>(
|
|
341
|
+
`[aria-label="${label}"]`,
|
|
342
|
+
);
|
|
343
|
+
if (!control) throw new Error(`no control labelled "${label}"`);
|
|
344
|
+
control.click();
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const setInputValue = (input: HTMLInputElement, value: string) => {
|
|
348
|
+
Object.getOwnPropertyDescriptor(
|
|
349
|
+
HTMLInputElement.prototype,
|
|
350
|
+
"value",
|
|
351
|
+
)?.set?.call(input, value);
|
|
352
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
/** The create form's name field, found by the label the kit gives it. */
|
|
356
|
+
const typeFolderName = (canvasElement: HTMLElement, name: string) => {
|
|
357
|
+
const label = Array.from(canvasElement.querySelectorAll("label")).find(
|
|
358
|
+
(node) => node.textContent?.trim() === "Folder name",
|
|
359
|
+
);
|
|
360
|
+
const id = label?.getAttribute("for");
|
|
361
|
+
const input = id
|
|
362
|
+
? canvasElement.querySelector<HTMLInputElement>(`input[id="${id}"]`)
|
|
363
|
+
: null;
|
|
364
|
+
if (!input) throw new Error("the folder name field is not on screen");
|
|
365
|
+
setInputValue(input, name);
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
/** Opens the destination tree, which the field keeps closed until it is asked for. */
|
|
369
|
+
const openDestinationTree = async (canvasElement: HTMLElement) => {
|
|
370
|
+
clickText(canvasElement, "Choose a folder");
|
|
371
|
+
await tick();
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
/** Opens a folder in the tree; tapping it both picks it and reveals its children. */
|
|
375
|
+
const openFolder = async (canvasElement: HTMLElement, label: string) => {
|
|
376
|
+
clickAriaLabel(canvasElement, `Move to ${label}`);
|
|
377
|
+
await tick();
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Drive the destination tree into its create form and submit it. `inside` names
|
|
382
|
+
* the folder the new one is made in; omitted makes it at the top level. Used by
|
|
383
|
+
* the pending and error stories below so each lands in the state it documents.
|
|
384
|
+
*/
|
|
385
|
+
async function createFolderFromTree(
|
|
386
|
+
canvasElement: HTMLElement,
|
|
387
|
+
folderName: string,
|
|
388
|
+
inside?: string,
|
|
389
|
+
) {
|
|
390
|
+
await openDestinationTree(canvasElement);
|
|
391
|
+
if (inside) {
|
|
392
|
+
await openFolder(canvasElement, "Inbox");
|
|
393
|
+
await openFolder(canvasElement, inside);
|
|
394
|
+
clickAriaLabel(canvasElement, `New folder inside ${inside}`);
|
|
395
|
+
} else {
|
|
396
|
+
clickAriaLabel(canvasElement, "New folder");
|
|
397
|
+
}
|
|
398
|
+
await tick();
|
|
399
|
+
typeFolderName(canvasElement, folderName);
|
|
400
|
+
await tick();
|
|
401
|
+
clickText(canvasElement, "Create folder");
|
|
402
|
+
}
|
|
403
|
+
|
|
326
404
|
let newFolderSeq = 0;
|
|
327
|
-
const mockCreateFolder = (
|
|
405
|
+
const mockCreateFolder = (
|
|
406
|
+
name: string,
|
|
407
|
+
parentPath: string,
|
|
408
|
+
): Promise<FolderTreeNode> =>
|
|
328
409
|
new Promise((resolve) => {
|
|
329
410
|
newFolderSeq += 1;
|
|
330
411
|
setTimeout(
|
|
331
|
-
() =>
|
|
412
|
+
() =>
|
|
413
|
+
resolve({
|
|
414
|
+
id: `mbx-new-${newFolderSeq}`,
|
|
415
|
+
label: name,
|
|
416
|
+
path: parentPath ? `${parentPath}/${name}` : name,
|
|
417
|
+
}),
|
|
332
418
|
400,
|
|
333
419
|
);
|
|
334
420
|
});
|
|
335
421
|
|
|
336
422
|
/**
|
|
337
|
-
* The
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
* shows — the editor stays data-agnostic.
|
|
423
|
+
* The destination is chosen from the same browsable tree every other picker
|
|
424
|
+
* uses: open a folder to see what is inside it, filter to narrow, and make a
|
|
425
|
+
* new one where you are looking.
|
|
341
426
|
*/
|
|
342
|
-
export const
|
|
427
|
+
export const DestinationTree: Story = {
|
|
428
|
+
name: "Destination — browse the folder tree",
|
|
343
429
|
render: () => (
|
|
344
430
|
<LiveEditor initialRule={demoRule} onCreateFolder={mockCreateFolder} />
|
|
345
431
|
),
|
|
432
|
+
play: async ({ canvasElement }) => {
|
|
433
|
+
await openDestinationTree(canvasElement);
|
|
434
|
+
},
|
|
346
435
|
};
|
|
347
436
|
|
|
348
437
|
/**
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
* below so each lands in the state it documents without a manual click-through.
|
|
438
|
+
* Two folders named Receipts, one at the top level and one inside Travel. The
|
|
439
|
+
* tree tells them apart by where they sit; a list of leaf names could not.
|
|
352
440
|
*/
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
)?.set;
|
|
365
|
-
const select = canvasElement.querySelector<HTMLSelectElement>(
|
|
366
|
-
'select[aria-label="Destination folder"]',
|
|
367
|
-
);
|
|
368
|
-
if (!select) return;
|
|
369
|
-
setSelectValue?.call(select, CREATE_FOLDER_STORY_VALUE);
|
|
370
|
-
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
371
|
-
const input = canvasElement.querySelector<HTMLInputElement>(
|
|
372
|
-
'input[aria-label="New folder name"]',
|
|
373
|
-
);
|
|
374
|
-
if (!input) return;
|
|
375
|
-
setInputValue?.call(input, folderName);
|
|
376
|
-
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
377
|
-
const createButton = Array.from(
|
|
378
|
-
canvasElement.querySelectorAll<HTMLButtonElement>("button"),
|
|
379
|
-
).find((button) => button.textContent?.trim() === "Create folder");
|
|
380
|
-
createButton?.click();
|
|
381
|
-
}
|
|
441
|
+
export const DestinationNestedFolders: Story = {
|
|
442
|
+
name: "Destination — a nested folder and its same-named sibling",
|
|
443
|
+
render: () => (
|
|
444
|
+
<LiveEditor initialRule={demoRule} onCreateFolder={mockCreateFolder} />
|
|
445
|
+
),
|
|
446
|
+
play: async ({ canvasElement }) => {
|
|
447
|
+
await openDestinationTree(canvasElement);
|
|
448
|
+
await openFolder(canvasElement, "Inbox");
|
|
449
|
+
await openFolder(canvasElement, "Travel");
|
|
450
|
+
},
|
|
451
|
+
};
|
|
382
452
|
|
|
383
|
-
/**
|
|
384
|
-
|
|
453
|
+
/**
|
|
454
|
+
* A new folder is made inside the folder the tree is looking at, so a filter can
|
|
455
|
+
* point at `Travel/Car hire` without leaving the editor.
|
|
456
|
+
*/
|
|
457
|
+
export const NewFolderInsideAnother: Story = {
|
|
458
|
+
name: "New folder — inside the folder you opened",
|
|
459
|
+
render: () => (
|
|
460
|
+
<LiveEditor initialRule={demoRule} onCreateFolder={mockCreateFolder} />
|
|
461
|
+
),
|
|
462
|
+
play: async ({ canvasElement }) => {
|
|
463
|
+
await openDestinationTree(canvasElement);
|
|
464
|
+
await openFolder(canvasElement, "Inbox");
|
|
465
|
+
await openFolder(canvasElement, "Travel");
|
|
466
|
+
clickAriaLabel(canvasElement, "New folder inside Travel");
|
|
467
|
+
await tick();
|
|
468
|
+
typeFolderName(canvasElement, "Car hire");
|
|
469
|
+
},
|
|
470
|
+
};
|
|
385
471
|
|
|
386
472
|
/** Mirrors the web-client wait's honest timeout copy. */
|
|
387
473
|
const TIMEOUT_MESSAGE =
|
|
388
474
|
"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.";
|
|
389
475
|
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
const neverResolvesCreateFolder = (): Promise<FolderOption> =>
|
|
393
|
-
new Promise<FolderOption>(() => undefined);
|
|
476
|
+
const neverResolvesCreateFolder = (): Promise<FolderTreeNode> =>
|
|
477
|
+
new Promise<FolderTreeNode>(() => undefined);
|
|
394
478
|
|
|
395
|
-
const rejectingCreateFolder =
|
|
396
|
-
|
|
479
|
+
const rejectingCreateFolder =
|
|
480
|
+
(message: string) => (): Promise<FolderTreeNode> =>
|
|
481
|
+
Promise.reject(new Error(message));
|
|
397
482
|
|
|
398
483
|
/** Rejects the first attempt, resolves the retry — the resume the hook performs. */
|
|
399
484
|
const failThenSucceedCreateFolder = () => {
|
|
400
485
|
let attempts = 0;
|
|
401
|
-
return (name: string): Promise<
|
|
486
|
+
return (name: string, parentPath: string): Promise<FolderTreeNode> => {
|
|
402
487
|
attempts += 1;
|
|
403
488
|
return attempts === 1
|
|
404
489
|
? Promise.reject(new Error(TIMEOUT_MESSAGE))
|
|
405
|
-
: Promise.resolve({
|
|
490
|
+
: Promise.resolve({
|
|
491
|
+
id: "mbx-created",
|
|
492
|
+
label: name,
|
|
493
|
+
path: parentPath ? `${parentPath}/${name}` : name,
|
|
494
|
+
});
|
|
406
495
|
};
|
|
407
496
|
};
|
|
408
497
|
|
|
409
498
|
/** Never resolves on its own; rejects with an AbortError when the signal aborts. */
|
|
410
499
|
const abortAwareCreateFolder = (
|
|
411
500
|
_name: string,
|
|
501
|
+
_parentPath: string,
|
|
412
502
|
signal?: AbortSignal,
|
|
413
|
-
): Promise<
|
|
414
|
-
new Promise<
|
|
503
|
+
): Promise<FolderTreeNode> =>
|
|
504
|
+
new Promise<FolderTreeNode>((_resolve, reject) => {
|
|
415
505
|
signal?.addEventListener("abort", () =>
|
|
416
506
|
reject(new DOMException("Aborted", "AbortError")),
|
|
417
507
|
);
|
|
@@ -420,8 +510,7 @@ const abortAwareCreateFolder = (
|
|
|
420
510
|
/**
|
|
421
511
|
* The folder is a dependent write for the filter, so creating it waits for the
|
|
422
512
|
* mail server to confirm the folder before it can be picked as the destination.
|
|
423
|
-
* The wait
|
|
424
|
-
* just a fast optimistic round-trip.
|
|
513
|
+
* The wait is held in the form, which refuses a second submit while it runs.
|
|
425
514
|
*/
|
|
426
515
|
export const NewFolderCreating: Story = {
|
|
427
516
|
name: "New folder — creating (waiting for the server)",
|
|
@@ -432,7 +521,7 @@ export const NewFolderCreating: Story = {
|
|
|
432
521
|
/>
|
|
433
522
|
),
|
|
434
523
|
play: async ({ canvasElement }) => {
|
|
435
|
-
await
|
|
524
|
+
await createFolderFromTree(canvasElement, "Receipts");
|
|
436
525
|
},
|
|
437
526
|
};
|
|
438
527
|
|
|
@@ -452,7 +541,7 @@ export const NewFolderCreateFailed: Story = {
|
|
|
452
541
|
/>
|
|
453
542
|
),
|
|
454
543
|
play: async ({ canvasElement }) => {
|
|
455
|
-
await
|
|
544
|
+
await createFolderFromTree(canvasElement, "Receipts");
|
|
456
545
|
},
|
|
457
546
|
};
|
|
458
547
|
|
|
@@ -470,7 +559,7 @@ export const NewFolderCreateTimedOut: Story = {
|
|
|
470
559
|
/>
|
|
471
560
|
),
|
|
472
561
|
play: async ({ canvasElement }) => {
|
|
473
|
-
await
|
|
562
|
+
await createFolderFromTree(canvasElement, "Receipts");
|
|
474
563
|
},
|
|
475
564
|
};
|
|
476
565
|
|
|
@@ -489,19 +578,16 @@ export const NewFolderCreateRetrySucceeds: Story = {
|
|
|
489
578
|
/>
|
|
490
579
|
),
|
|
491
580
|
play: async ({ canvasElement }) => {
|
|
492
|
-
await
|
|
581
|
+
await createFolderFromTree(canvasElement, "Receipts", "Travel");
|
|
493
582
|
await tick();
|
|
494
|
-
|
|
495
|
-
canvasElement.querySelectorAll<HTMLButtonElement>("button"),
|
|
496
|
-
).find((button) => button.textContent?.trim() === "Create folder");
|
|
497
|
-
retry?.click();
|
|
583
|
+
clickText(canvasElement, "Create folder");
|
|
498
584
|
},
|
|
499
585
|
};
|
|
500
586
|
|
|
501
587
|
/**
|
|
502
|
-
* Cancelling while
|
|
503
|
-
*
|
|
504
|
-
*
|
|
588
|
+
* Cancelling while the create is in flight aborts the wait: the create promise
|
|
589
|
+
* rejects with an AbortError the form swallows, so no destination binds after
|
|
590
|
+
* the user backed out — the form just closes.
|
|
505
591
|
*/
|
|
506
592
|
export const NewFolderCreateCancelledMidWait: Story = {
|
|
507
593
|
name: "New folder — cancel aborts the wait",
|
|
@@ -512,12 +598,9 @@ export const NewFolderCreateCancelledMidWait: Story = {
|
|
|
512
598
|
/>
|
|
513
599
|
),
|
|
514
600
|
play: async ({ canvasElement }) => {
|
|
515
|
-
await
|
|
601
|
+
await createFolderFromTree(canvasElement, "Receipts");
|
|
516
602
|
await tick();
|
|
517
|
-
|
|
518
|
-
canvasElement.querySelectorAll<HTMLButtonElement>("button"),
|
|
519
|
-
).find((button) => button.textContent?.trim() === "Cancel");
|
|
520
|
-
cancel?.click();
|
|
603
|
+
clickText(canvasElement, "Cancel");
|
|
521
604
|
},
|
|
522
605
|
};
|
|
523
606
|
|
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
type ReactNode,
|
|
4
|
-
useEffect,
|
|
5
|
-
useMemo,
|
|
6
|
-
useRef,
|
|
7
|
-
useState,
|
|
8
|
-
} from "react";
|
|
9
|
-
import { isAbortError } from "../lib/abort.js";
|
|
1
|
+
import { Fragment, type ReactNode, useMemo, useState } from "react";
|
|
2
|
+
import type { FolderTreeNode } from "../lib/folder-tree.js";
|
|
10
3
|
import { Button } from "./button.js";
|
|
11
4
|
import {
|
|
12
5
|
AddChipButton,
|
|
@@ -21,7 +14,6 @@ import {
|
|
|
21
14
|
commitBlockedReason,
|
|
22
15
|
commitLabel,
|
|
23
16
|
type FilterRule,
|
|
24
|
-
type FolderOption,
|
|
25
17
|
type LabelOption,
|
|
26
18
|
type MatchOperator,
|
|
27
19
|
matchJoinWord,
|
|
@@ -32,6 +24,7 @@ import {
|
|
|
32
24
|
type RuleMatchMode,
|
|
33
25
|
type RuleScope,
|
|
34
26
|
} from "./filter-rule.js";
|
|
27
|
+
import { FolderTreePicker } from "./folder-tree-picker.js";
|
|
35
28
|
import { Input } from "./input.js";
|
|
36
29
|
import { LabelChip } from "./label-chip.js";
|
|
37
30
|
import { SegmentedControl } from "./segmented-control.js";
|
|
@@ -47,7 +40,10 @@ export interface ClauseEditState {
|
|
|
47
40
|
|
|
48
41
|
export interface FilterRuleEditorProps {
|
|
49
42
|
rule: FilterRule;
|
|
50
|
-
|
|
43
|
+
/** Destinations as the app has them — labelled, and pathed by the provider. */
|
|
44
|
+
folders: readonly FolderTreeNode[];
|
|
45
|
+
/** The provider's hierarchy separator, which the destination tree nests on. */
|
|
46
|
+
delimiter?: string;
|
|
51
47
|
/** The account's labels the apply-label action can target (issue #26). */
|
|
52
48
|
labels?: LabelOption[];
|
|
53
49
|
preview: PreviewCount;
|
|
@@ -113,16 +109,19 @@ export interface FilterRuleEditorProps {
|
|
|
113
109
|
onChangeMatchOperator?: (operator: MatchOperator) => void;
|
|
114
110
|
onChangeMove?: (mailboxId: string) => void;
|
|
115
111
|
/**
|
|
116
|
-
* Create a new destination folder from within the editor
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* the
|
|
120
|
-
*
|
|
112
|
+
* Create a new destination folder from within the editor, inside the folder
|
|
113
|
+
* the tree is looking at. Creating a folder is an IMAP mutation, so this
|
|
114
|
+
* resolves only once the mail server confirms it
|
|
115
|
+
* (docs/architecture/imap-mutations.md); the picker holds the wait, states a
|
|
116
|
+
* failure where it happened, and aborts the signal on unmount or cancel, so
|
|
117
|
+
* the editor can never commit a filter against a folder that does not exist.
|
|
118
|
+
* Absent renders no create affordance.
|
|
121
119
|
*/
|
|
122
120
|
onCreateFolder?: (
|
|
123
121
|
name: string,
|
|
122
|
+
parentPath: string,
|
|
124
123
|
signal?: AbortSignal,
|
|
125
|
-
) => Promise<
|
|
124
|
+
) => Promise<FolderTreeNode>;
|
|
126
125
|
onChangeLabel?: (labelId: string) => void;
|
|
127
126
|
/**
|
|
128
127
|
* Create a new label from within the editor (issue #26). Given a label
|
|
@@ -153,47 +152,43 @@ const scopeOptions: { value: RuleScope; label: string }[] = [
|
|
|
153
152
|
{ value: "until", label: "Until a date" },
|
|
154
153
|
];
|
|
155
154
|
|
|
156
|
-
const CREATE_FOLDER_VALUE = "__filter_create_folder__";
|
|
157
155
|
const CREATE_LABEL_VALUE = "__filter_create_label__";
|
|
158
156
|
|
|
159
157
|
/**
|
|
160
|
-
* The move-to destination
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* is
|
|
164
|
-
*
|
|
158
|
+
* The move-to destination: the same browsable folder tree every other picker
|
|
159
|
+
* uses, opened from a line that states where matches go now. A folder is
|
|
160
|
+
* labelled as the app labels it, nested where the provider nests it, and a new
|
|
161
|
+
* one is made wherever the tree is looking.
|
|
162
|
+
*
|
|
163
|
+
* Tapping a folder both picks it and opens it, so the rule is re-pointed on an
|
|
164
|
+
* explicit confirmation rather than on the way past — otherwise looking inside
|
|
165
|
+
* a folder on the way to a nested one would rewrite the filter with no undo.
|
|
165
166
|
*
|
|
166
|
-
* The destination is a dependent write
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* confirms rejects with its own message here — the folder is never selected, so
|
|
171
|
-
* the caller cannot commit a filter against a folder that does not exist.
|
|
167
|
+
* The destination is a dependent write — the filter binds to the folder — so a
|
|
168
|
+
* created folder is offered only once the mail server has confirmed it. It is
|
|
169
|
+
* held here as well as returned, so it is pickable before the caller's folder
|
|
170
|
+
* list has refetched.
|
|
172
171
|
*/
|
|
173
172
|
function MoveDestinationField({
|
|
174
173
|
folders,
|
|
175
174
|
value,
|
|
175
|
+
delimiter = "/",
|
|
176
176
|
onChangeMove,
|
|
177
177
|
onCreateFolder,
|
|
178
178
|
}: {
|
|
179
|
-
folders:
|
|
180
|
-
value
|
|
179
|
+
folders: readonly FolderTreeNode[];
|
|
180
|
+
value?: string;
|
|
181
|
+
delimiter?: string;
|
|
181
182
|
onChangeMove?: (mailboxId: string) => void;
|
|
182
183
|
onCreateFolder?: (
|
|
183
184
|
name: string,
|
|
185
|
+
parentPath: string,
|
|
184
186
|
signal?: AbortSignal,
|
|
185
|
-
) => Promise<
|
|
187
|
+
) => Promise<FolderTreeNode>;
|
|
186
188
|
}) {
|
|
187
|
-
const [
|
|
188
|
-
const [
|
|
189
|
-
const [
|
|
190
|
-
const [error, setError] = useState<string>();
|
|
191
|
-
const [createdFolders, setCreatedFolders] = useState<FolderOption[]>([]);
|
|
192
|
-
// The create waits for the mail server to confirm the folder; abort it on
|
|
193
|
-
// unmount or cancel so a late confirmation never binds the destination after
|
|
194
|
-
// the editor is gone or the sub-form dismissed.
|
|
195
|
-
const createAbort = useRef<AbortController | null>(null);
|
|
196
|
-
useEffect(() => () => createAbort.current?.abort(), []);
|
|
189
|
+
const [browsing, setBrowsing] = useState(false);
|
|
190
|
+
const [picked, setPicked] = useState<string>();
|
|
191
|
+
const [createdFolders, setCreatedFolders] = useState<FolderTreeNode[]>([]);
|
|
197
192
|
|
|
198
193
|
const options = useMemo(() => {
|
|
199
194
|
const known = new Set(folders.map((folder) => folder.id));
|
|
@@ -203,127 +198,114 @@ function MoveDestinationField({
|
|
|
203
198
|
];
|
|
204
199
|
}, [folders, createdFolders]);
|
|
205
200
|
|
|
206
|
-
const
|
|
207
|
-
if (
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
: [...prev, folder],
|
|
230
|
-
);
|
|
231
|
-
onChangeMove?.(folder.id);
|
|
232
|
-
setCreating(false);
|
|
233
|
-
setName("");
|
|
234
|
-
setPending(false);
|
|
201
|
+
const createFolder = useMemo(() => {
|
|
202
|
+
if (!onCreateFolder) return undefined;
|
|
203
|
+
return async (name: string, parentPath: string, signal?: AbortSignal) => {
|
|
204
|
+
const created = await onCreateFolder(name, parentPath, signal);
|
|
205
|
+
setCreatedFolders((prev) =>
|
|
206
|
+
prev.some((entry) => entry.id === created.id)
|
|
207
|
+
? prev
|
|
208
|
+
: [...prev, created],
|
|
209
|
+
);
|
|
210
|
+
return created;
|
|
211
|
+
};
|
|
212
|
+
}, [onCreateFolder]);
|
|
213
|
+
|
|
214
|
+
const chosen = options.find((folder) => folder.id === value);
|
|
215
|
+
const pending = options.find((folder) => folder.id === picked);
|
|
216
|
+
|
|
217
|
+
/** Two folders can share a leaf name, so a destination reads as its trail. */
|
|
218
|
+
const trail = (folder: FolderTreeNode): string => {
|
|
219
|
+
const segments = folder.path.split(delimiter);
|
|
220
|
+
return segments
|
|
221
|
+
.map((segment, index) => {
|
|
222
|
+
const path = segments.slice(0, index + 1).join(delimiter);
|
|
223
|
+
return options.find((option) => option.path === path)?.label ?? segment;
|
|
235
224
|
})
|
|
236
|
-
.
|
|
237
|
-
if (isAbortError(error)) return;
|
|
238
|
-
setError(
|
|
239
|
-
error instanceof Error
|
|
240
|
-
? error.message
|
|
241
|
-
: "Couldn't create that folder. Please try again.",
|
|
242
|
-
);
|
|
243
|
-
setPending(false);
|
|
244
|
-
});
|
|
225
|
+
.join(" / ");
|
|
245
226
|
};
|
|
246
227
|
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
setName("");
|
|
251
|
-
setError(undefined);
|
|
252
|
-
setPending(false);
|
|
228
|
+
const close = () => {
|
|
229
|
+
setBrowsing(false);
|
|
230
|
+
setPicked(undefined);
|
|
253
231
|
};
|
|
254
232
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
<
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
<option value={CREATE_FOLDER_VALUE}>+ New folder…</option>
|
|
270
|
-
)}
|
|
271
|
-
</Select>
|
|
272
|
-
{creating && (
|
|
273
|
-
<div className="space-y-2 rounded-md border border-line bg-surface-sunken p-2">
|
|
274
|
-
<Input
|
|
275
|
-
value={name}
|
|
276
|
-
onChange={(event) => setName(event.target.value)}
|
|
277
|
-
placeholder="Folder name"
|
|
278
|
-
aria-label="New folder name"
|
|
279
|
-
disabled={pending}
|
|
280
|
-
autoFocus
|
|
281
|
-
onKeyDown={(event) => {
|
|
282
|
-
if (event.key === "Enter") {
|
|
283
|
-
event.preventDefault();
|
|
284
|
-
submit();
|
|
285
|
-
}
|
|
286
|
-
if (event.key === "Escape") {
|
|
287
|
-
event.preventDefault();
|
|
288
|
-
cancel();
|
|
289
|
-
}
|
|
233
|
+
if (!browsing) {
|
|
234
|
+
return (
|
|
235
|
+
<div className="rounded-md border border-line bg-surface-sunken p-3">
|
|
236
|
+
<p className="text-sm font-medium text-fg">
|
|
237
|
+
{chosen ? trail(chosen) : "No folder yet"}
|
|
238
|
+
</p>
|
|
239
|
+
<div className="mt-1 flex flex-wrap items-center gap-3">
|
|
240
|
+
<Button
|
|
241
|
+
variant="ghost"
|
|
242
|
+
size="sm"
|
|
243
|
+
className="px-0"
|
|
244
|
+
onClick={() => {
|
|
245
|
+
setPicked(value);
|
|
246
|
+
setBrowsing(true);
|
|
290
247
|
}}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
</p>
|
|
296
|
-
)}
|
|
297
|
-
<div className="flex gap-2">
|
|
298
|
-
<Button
|
|
299
|
-
variant="primary"
|
|
300
|
-
size="sm"
|
|
301
|
-
onClick={submit}
|
|
302
|
-
disabled={pending || name.trim() === ""}
|
|
303
|
-
>
|
|
304
|
-
{pending ? "Creating folder…" : "Create folder"}
|
|
305
|
-
</Button>
|
|
248
|
+
>
|
|
249
|
+
Choose a folder
|
|
250
|
+
</Button>
|
|
251
|
+
{chosen && (
|
|
306
252
|
<Button
|
|
307
253
|
variant="ghost"
|
|
308
254
|
size="sm"
|
|
309
|
-
|
|
310
|
-
|
|
255
|
+
className="px-0"
|
|
256
|
+
onClick={() => onChangeMove?.("")}
|
|
311
257
|
>
|
|
312
|
-
|
|
258
|
+
Don't move matches
|
|
313
259
|
</Button>
|
|
314
|
-
|
|
260
|
+
)}
|
|
315
261
|
</div>
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<div className="space-y-2">
|
|
268
|
+
<div className="flex h-72 min-h-0 overflow-hidden rounded-md border border-line bg-surface">
|
|
269
|
+
<FolderTreePicker
|
|
270
|
+
folders={options}
|
|
271
|
+
selectedId={picked}
|
|
272
|
+
delimiter={delimiter}
|
|
273
|
+
onSelect={setPicked}
|
|
274
|
+
onCreateFolder={createFolder}
|
|
275
|
+
onCancel={close}
|
|
276
|
+
labels={{
|
|
277
|
+
createPending: "Waiting for the mail server to confirm the folder…",
|
|
278
|
+
}}
|
|
279
|
+
/>
|
|
280
|
+
</div>
|
|
281
|
+
{pending ? (
|
|
282
|
+
<Button
|
|
283
|
+
variant="primary"
|
|
284
|
+
onClick={() => {
|
|
285
|
+
onChangeMove?.(pending.id);
|
|
286
|
+
close();
|
|
287
|
+
}}
|
|
288
|
+
className="w-full"
|
|
289
|
+
>
|
|
290
|
+
<span className="truncate">{`Move matches to ${trail(pending)}`}</span>
|
|
291
|
+
</Button>
|
|
292
|
+
) : (
|
|
293
|
+
<p className="text-2xs text-fg-subtle">
|
|
294
|
+
Tap a folder to open it, or make a new one where you want it.
|
|
295
|
+
</p>
|
|
316
296
|
)}
|
|
297
|
+
<Button variant="ghost" size="sm" onClick={close} className="w-full">
|
|
298
|
+
Cancel
|
|
299
|
+
</Button>
|
|
317
300
|
</div>
|
|
318
301
|
);
|
|
319
302
|
}
|
|
320
303
|
|
|
321
304
|
/**
|
|
322
305
|
* The apply-label select, plus an inline "New label…" affordance when the
|
|
323
|
-
* consumer wires `onCreateLabel` (issue #26).
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
* label list refetches.
|
|
306
|
+
* consumer wires `onCreateLabel` (issue #26). Selecting the create option
|
|
307
|
+
* reveals a name field, and the created label is added to the local option set
|
|
308
|
+
* and picked immediately, before the caller's label list refetches.
|
|
327
309
|
*/
|
|
328
310
|
function LabelDestinationField({
|
|
329
311
|
labels,
|
|
@@ -468,6 +450,7 @@ function LabelDestinationField({
|
|
|
468
450
|
export function FilterRuleEditor({
|
|
469
451
|
rule,
|
|
470
452
|
folders,
|
|
453
|
+
delimiter,
|
|
471
454
|
labels = [],
|
|
472
455
|
preview,
|
|
473
456
|
notice,
|
|
@@ -622,7 +605,8 @@ export function FilterRuleEditor({
|
|
|
622
605
|
<p className="text-xs font-medium text-fg-muted">Move matches to</p>
|
|
623
606
|
<MoveDestinationField
|
|
624
607
|
folders={folders}
|
|
625
|
-
value={rule.moveMailboxId
|
|
608
|
+
value={rule.moveMailboxId}
|
|
609
|
+
delimiter={delimiter}
|
|
626
610
|
onChangeMove={onChangeMove}
|
|
627
611
|
onCreateFolder={onCreateFolder}
|
|
628
612
|
/>
|
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
demoSenderFallbackRule,
|
|
21
21
|
demoVocabularyRule,
|
|
22
22
|
type FilterRule,
|
|
23
|
-
type FolderOption,
|
|
24
23
|
type LabelOption,
|
|
25
24
|
matchJoinWord,
|
|
26
25
|
matchOperatorLabel,
|
|
@@ -34,14 +33,15 @@ import {
|
|
|
34
33
|
FilterRuleEditor,
|
|
35
34
|
type FilterRuleEditorProps,
|
|
36
35
|
} from "./filter-rule-editor.js";
|
|
36
|
+
import type { FolderTreeNode } from "./folder-tree-picker.js";
|
|
37
37
|
|
|
38
38
|
/** SSR splits interpolations with comment markers; sentences read across them. */
|
|
39
39
|
const render = (element: Parameters<typeof renderToString>[0]) =>
|
|
40
40
|
renderToString(element).replaceAll("<!-- -->", "");
|
|
41
41
|
|
|
42
|
-
const FOLDERS:
|
|
43
|
-
{ id: "mbx-inbox", label: "Inbox" },
|
|
44
|
-
{ id: "mbx-archive", label: "Archive" },
|
|
42
|
+
const FOLDERS: FolderTreeNode[] = [
|
|
43
|
+
{ id: "mbx-inbox", label: "Inbox", path: "INBOX" },
|
|
44
|
+
{ id: "mbx-archive", label: "Archive", path: "INBOX/Archive" },
|
|
45
45
|
];
|
|
46
46
|
|
|
47
47
|
const LABELS: LabelOption[] = [
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* Naming tracks the RFC: rule, clause, widen, scope.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import type { FolderTreeNode } from "../lib/folder-tree.js";
|
|
10
11
|
import type { Suggestion } from "./suggest-list.js";
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -105,11 +106,6 @@ export interface FilterRule {
|
|
|
105
106
|
name?: string;
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
export interface FolderOption {
|
|
109
|
-
id: string;
|
|
110
|
-
label: string;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
109
|
export interface LabelOption {
|
|
114
110
|
id: string;
|
|
115
111
|
name: string;
|
|
@@ -313,12 +309,22 @@ export function commitBlockedReason(
|
|
|
313
309
|
return previewSettledReason(preview);
|
|
314
310
|
}
|
|
315
311
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
{ id: "mbx-
|
|
321
|
-
{ id: "mbx-
|
|
312
|
+
// Two folders named Receipts at different depths, and a Trash labelled by its
|
|
313
|
+
// role rather than its provider leaf: what a flat list of leaf names cannot
|
|
314
|
+
// tell apart.
|
|
315
|
+
export const demoFolders: FolderTreeNode[] = [
|
|
316
|
+
{ id: "mbx-inbox", label: "Inbox", path: "INBOX" },
|
|
317
|
+
{ id: "mbx-archive", label: "Archive", path: "INBOX/Archive" },
|
|
318
|
+
{ id: "mbx-receipts", label: "Receipts", path: "INBOX/Receipts" },
|
|
319
|
+
{ id: "mbx-receipts-2026", label: "2026", path: "INBOX/Receipts/2026" },
|
|
320
|
+
{ id: "mbx-travel", label: "Travel", path: "INBOX/Travel" },
|
|
321
|
+
{
|
|
322
|
+
id: "mbx-travel-receipts",
|
|
323
|
+
label: "Receipts",
|
|
324
|
+
path: "INBOX/Travel/Receipts",
|
|
325
|
+
},
|
|
326
|
+
{ id: "mbx-junk", label: "Junk", path: "INBOX/Junk" },
|
|
327
|
+
{ id: "mbx-trash", label: "Trash", path: "INBOX/Prullenbak" },
|
|
322
328
|
];
|
|
323
329
|
|
|
324
330
|
export const demoLabels: LabelOption[] = [
|
|
@@ -146,7 +146,12 @@ export const FolderTreePicker = ({
|
|
|
146
146
|
}: FolderTreePickerProps) => {
|
|
147
147
|
const text = { ...defaultLabels, ...labels };
|
|
148
148
|
const [query, setQuery] = useState("");
|
|
149
|
-
|
|
149
|
+
// A destination chosen before the picker opened is out of reach behind its
|
|
150
|
+
// ancestors, so the branch holding it starts open and the choice is on screen.
|
|
151
|
+
const [opened, setOpened] = useState<ReadonlySet<string>>(() => {
|
|
152
|
+
const selected = folders.find((folder) => folder.id === selectedId);
|
|
153
|
+
return new Set(selected ? folderAncestors(selected.path, delimiter) : []);
|
|
154
|
+
});
|
|
150
155
|
const [draft, setDraft] = useState<Draft | null>(null);
|
|
151
156
|
const [draftName, setDraftName] = useState("");
|
|
152
157
|
const [draftError, setDraftError] = useState<string>();
|