@remit/ui 0.0.143 → 0.0.144
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 +24 -1
- package/src/components/filter-rule-editor.stories.tsx +31 -1
- package/src/components/filter-rule-editor.tsx +2 -2
- package/src/components/folder-tree-picker.stories.tsx +23 -0
- package/src/lib/folder-tree.test.ts +48 -0
- package/src/lib/folder-tree.ts +24 -9
package/package.json
CHANGED
|
@@ -29,6 +29,13 @@ const folders: FolderTreeNode[] = [
|
|
|
29
29
|
},
|
|
30
30
|
];
|
|
31
31
|
|
|
32
|
+
// A server that reports no hierarchy delimiter has a flat namespace, where a
|
|
33
|
+
// path carries no separator to read a trail out of.
|
|
34
|
+
const flatFolders: FolderTreeNode[] = [
|
|
35
|
+
{ id: "mbx-inbox", label: "Inbox", path: "INBOX" },
|
|
36
|
+
{ id: "mbx-work", label: "Work", path: "Work" },
|
|
37
|
+
];
|
|
38
|
+
|
|
32
39
|
const rule: FilterRule = {
|
|
33
40
|
clauses: [{ id: "c1", field: "From", value: "a@example.com" }],
|
|
34
41
|
matchOperator: "all",
|
|
@@ -61,6 +68,8 @@ interface MountOptions {
|
|
|
61
68
|
signal?: AbortSignal,
|
|
62
69
|
) => Promise<FolderTreeNode>;
|
|
63
70
|
onChangeMove?: (id: string) => void;
|
|
71
|
+
options?: readonly FolderTreeNode[];
|
|
72
|
+
delimiter?: string;
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
/** Holds the destination the way the app does, so a pick shows on screen. */
|
|
@@ -68,6 +77,8 @@ const mount = async ({
|
|
|
68
77
|
initialDestination,
|
|
69
78
|
onCreateFolder,
|
|
70
79
|
onChangeMove,
|
|
80
|
+
options = folders,
|
|
81
|
+
delimiter,
|
|
71
82
|
}: MountOptions = {}) => {
|
|
72
83
|
const Controlled = () => {
|
|
73
84
|
const [moveMailboxId, setMoveMailboxId] = useState<string | undefined>(
|
|
@@ -75,7 +86,8 @@ const mount = async ({
|
|
|
75
86
|
);
|
|
76
87
|
return createElement(FilterRuleEditor, {
|
|
77
88
|
rule: { ...rule, moveMailboxId },
|
|
78
|
-
folders,
|
|
89
|
+
folders: options,
|
|
90
|
+
delimiter,
|
|
79
91
|
preview,
|
|
80
92
|
onChangeMove: (id: string) => {
|
|
81
93
|
setMoveMailboxId(id || undefined);
|
|
@@ -235,6 +247,17 @@ describe("FilterRuleEditor move destination", () => {
|
|
|
235
247
|
assert.deepEqual(picked, [""]);
|
|
236
248
|
});
|
|
237
249
|
|
|
250
|
+
it("reads a flat-namespace destination as its whole path, not per character", async () => {
|
|
251
|
+
await mount({ options: flatFolders, delimiter: "" });
|
|
252
|
+
await openTree();
|
|
253
|
+
await click(byAriaLabel("Move to Work"));
|
|
254
|
+
assert.ok(
|
|
255
|
+
byText("Move matches to Work"),
|
|
256
|
+
"the whole path is one segment when nothing nests",
|
|
257
|
+
);
|
|
258
|
+
assert.equal(byText("Move matches to W / o / r / k"), undefined);
|
|
259
|
+
});
|
|
260
|
+
|
|
238
261
|
it("offers no create affordance without onCreateFolder", async () => {
|
|
239
262
|
await mount();
|
|
240
263
|
await openTree();
|
|
@@ -60,10 +60,15 @@ function LiveEditor({
|
|
|
60
60
|
propertyRule,
|
|
61
61
|
labels = demoLabels,
|
|
62
62
|
initialClauseEdit,
|
|
63
|
+
folders = demoFolders,
|
|
64
|
+
delimiter,
|
|
63
65
|
onCreateFolder,
|
|
64
66
|
onCreateLabel,
|
|
65
67
|
}: {
|
|
66
68
|
initialRule: FilterRule;
|
|
69
|
+
folders?: FolderTreeNode[];
|
|
70
|
+
/** The provider's hierarchy separator; `""` is a flat namespace. */
|
|
71
|
+
delimiter?: string;
|
|
67
72
|
semanticAvailable?: boolean;
|
|
68
73
|
/** Offers the match-mode control; omit to render the editor without one. */
|
|
69
74
|
initialMatchMode?: RuleMatchMode;
|
|
@@ -179,7 +184,8 @@ function LiveEditor({
|
|
|
179
184
|
return (
|
|
180
185
|
<FilterRuleEditor
|
|
181
186
|
rule={rule}
|
|
182
|
-
folders={
|
|
187
|
+
folders={folders}
|
|
188
|
+
delimiter={delimiter}
|
|
183
189
|
labels={labels}
|
|
184
190
|
preview={preview}
|
|
185
191
|
semanticAvailable={semanticAvailable}
|
|
@@ -459,6 +465,30 @@ export const DestinationNestedFolders: Story = {
|
|
|
459
465
|
},
|
|
460
466
|
};
|
|
461
467
|
|
|
468
|
+
// A server that reports no hierarchy delimiter has a flat namespace: every
|
|
469
|
+
// folder sits at the top level and a path is a name, not a trail.
|
|
470
|
+
const flatFolders: FolderTreeNode[] = [
|
|
471
|
+
{ id: "mbx-inbox", label: "Inbox", path: "INBOX" },
|
|
472
|
+
{ id: "mbx-archive", label: "Archive", path: "Archive" },
|
|
473
|
+
{ id: "mbx-work", label: "Work", path: "Work" },
|
|
474
|
+
{ id: "mbx-workshop", label: "Workshop", path: "Workshop" },
|
|
475
|
+
];
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* A flat namespace, where the chosen destination reads as its whole path —
|
|
479
|
+
* `Work`, not one segment per character.
|
|
480
|
+
*/
|
|
481
|
+
export const DestinationFlatNamespace: Story = {
|
|
482
|
+
name: "Destination — a flat namespace (server reports no delimiter)",
|
|
483
|
+
render: () => (
|
|
484
|
+
<LiveEditor
|
|
485
|
+
initialRule={{ ...demoRule, moveMailboxId: "mbx-work" }}
|
|
486
|
+
folders={flatFolders}
|
|
487
|
+
delimiter=""
|
|
488
|
+
/>
|
|
489
|
+
),
|
|
490
|
+
};
|
|
491
|
+
|
|
462
492
|
/**
|
|
463
493
|
* A new folder is made inside the folder the tree is looking at, so a filter can
|
|
464
494
|
* point at `Travel/Car hire` without leaving the editor.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fragment, type ReactNode, useMemo, useState } from "react";
|
|
2
|
-
import type
|
|
2
|
+
import { type FolderTreeNode, folderPathSegments } from "../lib/folder-tree.js";
|
|
3
3
|
import { Button } from "./button.js";
|
|
4
4
|
import {
|
|
5
5
|
AddChipButton,
|
|
@@ -216,7 +216,7 @@ function MoveDestinationField({
|
|
|
216
216
|
|
|
217
217
|
/** Two folders can share a leaf name, so a destination reads as its trail. */
|
|
218
218
|
const trail = (folder: FolderTreeNode): string => {
|
|
219
|
-
const segments = folder.path
|
|
219
|
+
const segments = folderPathSegments(folder.path, delimiter);
|
|
220
220
|
return segments
|
|
221
221
|
.map((segment, index) => {
|
|
222
222
|
const path = segments.slice(0, index + 1).join(delimiter);
|
|
@@ -47,6 +47,17 @@ const folders: FolderTreeNode[] = [
|
|
|
47
47
|
{ id: "mbx-work-recruiting", label: "Recruiting", path: "Work/Recruiting" },
|
|
48
48
|
];
|
|
49
49
|
|
|
50
|
+
// A server that reports no hierarchy delimiter has a flat namespace: nothing
|
|
51
|
+
// nests, and `Work` is not a parent of `Workshop`.
|
|
52
|
+
const flatFolders: FolderTreeNode[] = [
|
|
53
|
+
{ id: "mbx-inbox", label: "Inbox", path: "INBOX", isCurrent: true },
|
|
54
|
+
{ id: "mbx-archive", label: "Archive", path: "Archive" },
|
|
55
|
+
{ id: "mbx-work", label: "Work", path: "Work" },
|
|
56
|
+
{ id: "mbx-workshop", label: "Workshop", path: "Workshop" },
|
|
57
|
+
{ id: "mbx-sent", label: "Sent", path: "Sent Items" },
|
|
58
|
+
{ id: "mbx-trash", label: "Trash", path: "Deleted Messages" },
|
|
59
|
+
];
|
|
60
|
+
|
|
50
61
|
const longFolders: FolderTreeNode[] = [
|
|
51
62
|
...folders,
|
|
52
63
|
...Array.from({ length: 36 }, (_, i) => ({
|
|
@@ -100,6 +111,7 @@ const rejects = (message: string) => (): Promise<FolderTreeNode> =>
|
|
|
100
111
|
function Picker({
|
|
101
112
|
options = folders,
|
|
102
113
|
onCreateFolder = createFolder,
|
|
114
|
+
delimiter,
|
|
103
115
|
}: {
|
|
104
116
|
options?: FolderTreeNode[];
|
|
105
117
|
onCreateFolder?: (
|
|
@@ -107,6 +119,7 @@ function Picker({
|
|
|
107
119
|
parentPath: string,
|
|
108
120
|
signal?: AbortSignal,
|
|
109
121
|
) => Promise<FolderTreeNode>;
|
|
122
|
+
delimiter?: string;
|
|
110
123
|
}) {
|
|
111
124
|
const [selected, setSelected] = useState<string>();
|
|
112
125
|
const [known, setKnown] = useState(options);
|
|
@@ -115,6 +128,7 @@ function Picker({
|
|
|
115
128
|
<FolderTreePicker
|
|
116
129
|
folders={known}
|
|
117
130
|
selectedId={selected}
|
|
131
|
+
delimiter={delimiter}
|
|
118
132
|
onSelect={setSelected}
|
|
119
133
|
onCreateFolder={(name, parentPath, signal) =>
|
|
120
134
|
onCreateFolder(name, parentPath, signal).then((created) => {
|
|
@@ -160,6 +174,15 @@ export const LongList: Story = {
|
|
|
160
174
|
render: () => <Picker options={longFolders} />,
|
|
161
175
|
};
|
|
162
176
|
|
|
177
|
+
/**
|
|
178
|
+
* A flat namespace: every folder sits at the top level and none of them opens,
|
|
179
|
+
* so a new folder can only be made at the top.
|
|
180
|
+
*/
|
|
181
|
+
export const FlatNamespace: Story = {
|
|
182
|
+
name: "Flat namespace (server reports no delimiter)",
|
|
183
|
+
render: () => <Picker options={flatFolders} delimiter="" />,
|
|
184
|
+
};
|
|
185
|
+
|
|
163
186
|
/** An account with nothing to list: the message states that, not a filter. */
|
|
164
187
|
export const Empty: Story = {
|
|
165
188
|
name: "No folders",
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
folderDepth,
|
|
9
9
|
folderLeaf,
|
|
10
10
|
folderParent,
|
|
11
|
+
folderPathSegments,
|
|
11
12
|
matchesQuery,
|
|
12
13
|
orderFolderNodes,
|
|
13
14
|
queryExpandedPaths,
|
|
@@ -298,3 +299,50 @@ describe("folderLeaf", () => {
|
|
|
298
299
|
assert.equal(folderLeaf("INBOX", "."), "INBOX");
|
|
299
300
|
});
|
|
300
301
|
});
|
|
302
|
+
|
|
303
|
+
describe("a flat namespace", () => {
|
|
304
|
+
const flat: FolderTreeNode[] = [
|
|
305
|
+
node("work", "Work", "Work"),
|
|
306
|
+
node("workshop", "Workshop", "Workshop"),
|
|
307
|
+
node("inbox", "Inbox", "INBOX"),
|
|
308
|
+
];
|
|
309
|
+
|
|
310
|
+
it("keeps a path whole rather than splitting it into characters", () => {
|
|
311
|
+
assert.deepEqual(folderPathSegments("Projects/Q3", ""), ["Projects/Q3"]);
|
|
312
|
+
assert.deepEqual(folderPathSegments("Projects/Q3", "/"), [
|
|
313
|
+
"Projects",
|
|
314
|
+
"Q3",
|
|
315
|
+
]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("makes every folder a root with no parent, depth or ancestors", () => {
|
|
319
|
+
assert.equal(folderParent("Projects/Q3", ""), "");
|
|
320
|
+
assert.equal(folderDepth("Projects/Q3", ""), 0);
|
|
321
|
+
assert.deepEqual(folderAncestors("Projects/Q3", ""), []);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("puts the whole list on screen at the top level", () => {
|
|
325
|
+
const rows = collapseFolderTree(orderFolderNodes(flat, ""), new Set(), "");
|
|
326
|
+
assert.deepEqual(paths(rows), ["Work", "Workshop", "INBOX"]);
|
|
327
|
+
assert.deepEqual(
|
|
328
|
+
rows.map((row) => row.depth),
|
|
329
|
+
[0, 0, 0],
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("offers no create action inside a folder, not even a prefix match", () => {
|
|
334
|
+
const rows = collapseFolderTree(
|
|
335
|
+
orderFolderNodes(flat, ""),
|
|
336
|
+
new Set(["Work"]),
|
|
337
|
+
"",
|
|
338
|
+
);
|
|
339
|
+
assert.deepEqual(
|
|
340
|
+
withCreateRows(rows, "").map((entry) =>
|
|
341
|
+
entry.kind === "create"
|
|
342
|
+
? `new inside ${entry.parent.path}`
|
|
343
|
+
: entry.row.folder.path,
|
|
344
|
+
),
|
|
345
|
+
["Work", "Workshop", "INBOX"],
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
});
|
package/src/lib/folder-tree.ts
CHANGED
|
@@ -38,28 +38,39 @@ export type FolderTreeDisplayRow =
|
|
|
38
38
|
| { kind: "folder"; row: FolderTreeRow; index: number }
|
|
39
39
|
| { kind: "create"; parent: FolderTreeNode; depth: number };
|
|
40
40
|
|
|
41
|
-
// A server that reports no delimiter has a flat namespace
|
|
42
|
-
//
|
|
41
|
+
// A server that reports no delimiter has a flat namespace: the path is its own
|
|
42
|
+
// leaf and every folder is a root. Splitting on "" would return single
|
|
43
|
+
// characters, and `"Inbox".lastIndexOf("")` is 5 rather than -1, so each of
|
|
44
|
+
// these answers the flat case before it touches the path.
|
|
45
|
+
export const folderPathSegments = (
|
|
46
|
+
path: string,
|
|
47
|
+
delimiter: string,
|
|
48
|
+
): string[] => (delimiter.length === 0 ? [path] : path.split(delimiter));
|
|
49
|
+
|
|
43
50
|
export const folderLeaf = (path: string, delimiter: string): string => {
|
|
44
|
-
|
|
45
|
-
const parts = path.split(delimiter);
|
|
51
|
+
const parts = folderPathSegments(path, delimiter);
|
|
46
52
|
return parts[parts.length - 1] || path;
|
|
47
53
|
};
|
|
48
54
|
|
|
49
55
|
export const folderParent = (path: string, delimiter: string): string => {
|
|
56
|
+
if (delimiter.length === 0) return "";
|
|
50
57
|
const cut = path.lastIndexOf(delimiter);
|
|
51
58
|
return cut === -1 ? "" : path.slice(0, cut);
|
|
52
59
|
};
|
|
53
60
|
|
|
54
61
|
export const folderDepth = (path: string, delimiter: string): number =>
|
|
55
|
-
path
|
|
62
|
+
folderPathSegments(path, delimiter).length - 1;
|
|
56
63
|
|
|
64
|
+
// Every step up is strictly shorter than the path below it, so the walk is
|
|
65
|
+
// bounded by the length of the path whatever a parent comes back as.
|
|
57
66
|
export const folderAncestors = (path: string, delimiter: string): string[] => {
|
|
58
67
|
const out: string[] = [];
|
|
59
|
-
let
|
|
60
|
-
|
|
68
|
+
let child = path;
|
|
69
|
+
let parent = folderParent(child, delimiter);
|
|
70
|
+
while (parent && parent.length < child.length) {
|
|
61
71
|
out.push(parent);
|
|
62
|
-
|
|
72
|
+
child = parent;
|
|
73
|
+
parent = folderParent(child, delimiter);
|
|
63
74
|
}
|
|
64
75
|
return out;
|
|
65
76
|
};
|
|
@@ -182,12 +193,16 @@ export const collapseFolderTree = (
|
|
|
182
193
|
|
|
183
194
|
/**
|
|
184
195
|
* Drops a create action at the end of every open folder's children, so "New
|
|
185
|
-
* folder" reads as the last folder inside the one you opened.
|
|
196
|
+
* folder" reads as the last folder inside the one you opened. A flat namespace
|
|
197
|
+
* has no inside, so it gets the rows on their own and creates at the top level.
|
|
186
198
|
*/
|
|
187
199
|
export const withCreateRows = (
|
|
188
200
|
rows: readonly FolderTreeRow[],
|
|
189
201
|
delimiter: string,
|
|
190
202
|
): FolderTreeDisplayRow[] => {
|
|
203
|
+
if (delimiter.length === 0)
|
|
204
|
+
return rows.map((row, index) => ({ kind: "folder", row, index }));
|
|
205
|
+
|
|
191
206
|
const out: FolderTreeDisplayRow[] = [];
|
|
192
207
|
const open: FolderTreeRow[] = [];
|
|
193
208
|
|