@remit/ui 0.0.67 → 0.0.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,281 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ collapseFolderTree,
5
+ type FolderTreeNode,
6
+ filterFolderTree,
7
+ folderAncestors,
8
+ folderDepth,
9
+ folderParent,
10
+ matchesQuery,
11
+ orderFolderNodes,
12
+ queryExpandedPaths,
13
+ withCreateRows,
14
+ } from "./folder-tree.js";
15
+
16
+ const node = (
17
+ id: string,
18
+ label: string,
19
+ path: string,
20
+ isCurrent?: boolean,
21
+ ): FolderTreeNode => ({ id, label, path, isCurrent });
22
+
23
+ const folders: FolderTreeNode[] = [
24
+ node("inbox", "Inbox", "INBOX", true),
25
+ node("hotels", "Hotels", "Travel/Hotels"),
26
+ node("archive", "Archive", "Archive"),
27
+ node("travel", "Travel", "Travel"),
28
+ node("receipts", "Receipts", "Travel/Hotels/Receipts"),
29
+ node("trash", "Trash", "Deleted Messages"),
30
+ ];
31
+
32
+ const ordered = orderFolderNodes(folders, "/");
33
+
34
+ const paths = (rows: readonly { folder: FolderTreeNode }[]): string[] =>
35
+ rows.map((row) => row.folder.path);
36
+
37
+ describe("folder paths", () => {
38
+ it("reads the parent, the depth and the ancestors off the path", () => {
39
+ assert.equal(folderParent("Travel/Hotels/Receipts", "/"), "Travel/Hotels");
40
+ assert.equal(folderParent("Archive", "/"), "");
41
+ assert.equal(folderDepth("Travel/Hotels/Receipts", "/"), 2);
42
+ assert.deepEqual(folderAncestors("Travel/Hotels/Receipts", "/"), [
43
+ "Travel/Hotels",
44
+ "Travel",
45
+ ]);
46
+ });
47
+
48
+ it("splits on the provider's separator", () => {
49
+ assert.equal(folderParent("Travel.Hotels", "."), "Travel");
50
+ assert.equal(folderDepth("Travel.Hotels", "."), 1);
51
+ assert.deepEqual(folderAncestors("Travel.Hotels", "."), ["Travel"]);
52
+ });
53
+ });
54
+
55
+ describe("folder ordering", () => {
56
+ it("puts every child straight after its parent", () => {
57
+ assert.deepEqual(
58
+ orderFolderNodes(folders, "/").map((folder) => folder.path),
59
+ [
60
+ "INBOX",
61
+ "Archive",
62
+ "Travel",
63
+ "Travel/Hotels",
64
+ "Travel/Hotels/Receipts",
65
+ "Deleted Messages",
66
+ ],
67
+ );
68
+ });
69
+
70
+ it("renders a folder whose parent is absent as a root", () => {
71
+ const orphaned = [
72
+ node("archive", "Archive", "Archive"),
73
+ node("apollo", "Apollo", "Work/Projects/Apollo"),
74
+ ];
75
+ assert.deepEqual(
76
+ orderFolderNodes(orphaned, "/").map((folder) => folder.path),
77
+ ["Archive", "Work/Projects/Apollo"],
78
+ );
79
+ });
80
+
81
+ it("nests on the provider's separator", () => {
82
+ const dotted = [
83
+ node("child", "Hotels", "Travel.Hotels"),
84
+ node("other", "Archive", "Archive"),
85
+ node("parent", "Travel", "Travel"),
86
+ ];
87
+ assert.deepEqual(
88
+ orderFolderNodes(dotted, ".").map((folder) => folder.path),
89
+ ["Archive", "Travel", "Travel.Hotels"],
90
+ );
91
+ });
92
+ });
93
+
94
+ describe("collapsing", () => {
95
+ it("shows the top level and nothing under it", () => {
96
+ const rows = collapseFolderTree(ordered, new Set(), "/");
97
+ assert.deepEqual(paths(rows), [
98
+ "INBOX",
99
+ "Archive",
100
+ "Travel",
101
+ "Deleted Messages",
102
+ ]);
103
+ });
104
+
105
+ it("reveals only the children of what is open", () => {
106
+ const rows = collapseFolderTree(ordered, new Set(["Travel"]), "/");
107
+ assert.deepEqual(paths(rows), [
108
+ "INBOX",
109
+ "Archive",
110
+ "Travel",
111
+ "Travel/Hotels",
112
+ "Deleted Messages",
113
+ ]);
114
+ });
115
+
116
+ it("needs every ancestor open to reach a grandchild", () => {
117
+ assert.deepEqual(
118
+ paths(collapseFolderTree(ordered, new Set(["Travel/Hotels"]), "/")),
119
+ ["INBOX", "Archive", "Travel", "Deleted Messages"],
120
+ );
121
+ assert.deepEqual(
122
+ paths(
123
+ collapseFolderTree(ordered, new Set(["Travel", "Travel/Hotels"]), "/"),
124
+ ),
125
+ [
126
+ "INBOX",
127
+ "Archive",
128
+ "Travel",
129
+ "Travel/Hotels",
130
+ "Travel/Hotels/Receipts",
131
+ "Deleted Messages",
132
+ ],
133
+ );
134
+ });
135
+
136
+ it("marks what is open on the row", () => {
137
+ const rows = collapseFolderTree(ordered, new Set(["Travel"]), "/");
138
+ assert.deepEqual(
139
+ rows.map((row) => row.expanded),
140
+ [false, false, true, false, false],
141
+ );
142
+ });
143
+
144
+ it("shows a folder whose parent is absent from the list", () => {
145
+ const orphaned = orderFolderNodes(
146
+ [
147
+ node("archive", "Archive", "Archive"),
148
+ node("apollo", "Apollo", "Work/Projects/Apollo"),
149
+ ],
150
+ "/",
151
+ );
152
+ assert.deepEqual(paths(collapseFolderTree(orphaned, new Set(), "/")), [
153
+ "Archive",
154
+ "Work/Projects/Apollo",
155
+ ]);
156
+ });
157
+ });
158
+
159
+ describe("filtering", () => {
160
+ it("matches on the label", () => {
161
+ assert.equal(matchesQuery(node("a", "Archive", "Archive"), "arch"), true);
162
+ });
163
+
164
+ it("matches on the path, so a role-labelled folder is still findable", () => {
165
+ assert.equal(
166
+ matchesQuery(node("t", "Trash", "Deleted Messages"), "deleted"),
167
+ true,
168
+ );
169
+ });
170
+
171
+ it("keeps the ancestors of a match on screen", () => {
172
+ const rows = filterFolderTree(ordered, "receipts", "/");
173
+ assert.deepEqual(paths(rows), [
174
+ "Travel",
175
+ "Travel/Hotels",
176
+ "Travel/Hotels/Receipts",
177
+ ]);
178
+ });
179
+
180
+ it("marks an ancestor kept for context as no match of its own", () => {
181
+ const rows = filterFolderTree(ordered, "receipts", "/");
182
+ assert.deepEqual(
183
+ rows.map((row) => row.context),
184
+ [true, true, false],
185
+ );
186
+ });
187
+
188
+ it("keeps a matching ancestor a match", () => {
189
+ const rows = filterFolderTree(ordered, "travel", "/");
190
+ assert.deepEqual(
191
+ rows.map((row) => row.context),
192
+ [false, false, false],
193
+ );
194
+ });
195
+
196
+ it("opens the ancestors a match hides behind", () => {
197
+ assert.deepEqual([...queryExpandedPaths(ordered, "receipts", "/")].sort(), [
198
+ "Travel",
199
+ "Travel/Hotels",
200
+ ]);
201
+ });
202
+
203
+ it("opens nothing on an empty query, so the list stays as it was", () => {
204
+ assert.equal(queryExpandedPaths(ordered, "", "/").size, 0);
205
+ });
206
+
207
+ it("carries the depth the row indents by", () => {
208
+ const rows = filterFolderTree(ordered, "", "/");
209
+ assert.deepEqual(
210
+ rows.map((row) => row.depth),
211
+ [0, 0, 0, 1, 2, 0],
212
+ );
213
+ });
214
+
215
+ it("marks what is open on a filtered row", () => {
216
+ const rows = filterFolderTree(ordered, "hotels", "/", new Set(["Travel"]));
217
+ assert.deepEqual(
218
+ rows.map((row) => row.expanded),
219
+ [true, false, false],
220
+ );
221
+ });
222
+
223
+ it("narrows to nothing when the query matches no folder", () => {
224
+ assert.deepEqual(filterFolderTree(ordered, "zzz", "/"), []);
225
+ });
226
+ });
227
+
228
+ describe("create rows", () => {
229
+ const kinds = (expanded: string[]) =>
230
+ withCreateRows(
231
+ collapseFolderTree(ordered, new Set(expanded), "/"),
232
+ "/",
233
+ ).map((entry) =>
234
+ entry.kind === "create"
235
+ ? `new inside ${entry.parent.path} @${entry.depth}`
236
+ : entry.row.folder.path,
237
+ );
238
+
239
+ it("offers none while everything is closed", () => {
240
+ assert.deepEqual(kinds([]), [
241
+ "INBOX",
242
+ "Archive",
243
+ "Travel",
244
+ "Deleted Messages",
245
+ ]);
246
+ });
247
+
248
+ it("puts the action after the children of the folder it belongs to", () => {
249
+ assert.deepEqual(kinds(["Travel"]), [
250
+ "INBOX",
251
+ "Archive",
252
+ "Travel",
253
+ "Travel/Hotels",
254
+ "new inside Travel @1",
255
+ "Deleted Messages",
256
+ ]);
257
+ });
258
+
259
+ it("closes the deepest branch first when several are open", () => {
260
+ assert.deepEqual(kinds(["Travel", "Travel/Hotels"]), [
261
+ "INBOX",
262
+ "Archive",
263
+ "Travel",
264
+ "Travel/Hotels",
265
+ "Travel/Hotels/Receipts",
266
+ "new inside Travel/Hotels @2",
267
+ "new inside Travel @1",
268
+ "Deleted Messages",
269
+ ]);
270
+ });
271
+
272
+ it("offers the action on an open folder with no children", () => {
273
+ assert.deepEqual(kinds(["Archive"]), [
274
+ "INBOX",
275
+ "Archive",
276
+ "new inside Archive @1",
277
+ "Travel",
278
+ "Deleted Messages",
279
+ ]);
280
+ });
281
+ });
@@ -0,0 +1,201 @@
1
+ export interface FolderTreeNode {
2
+ /** Stable identity passed back to the surface that owns the choice. */
3
+ id: string;
4
+ /**
5
+ * What the row reads as. An appointed folder is labelled by its role, so
6
+ * `Deleted Messages` shows as "Trash" while still nesting under its real
7
+ * path — which is why the label is not derived from the path.
8
+ */
9
+ label: string;
10
+ /** The provider path. Nesting, indentation and filtering all read this. */
11
+ path: string;
12
+ /**
13
+ * Where the messages live now. A "you are here" marker: never a target, and
14
+ * rendered as a marker rather than a disabled control.
15
+ */
16
+ isCurrent?: boolean;
17
+ }
18
+
19
+ export interface FolderTreeRow {
20
+ folder: FolderTreeNode;
21
+ depth: number;
22
+ /**
23
+ * On screen only to keep a match below it in place. It reads as the branch
24
+ * it is, not as an answer to what was typed.
25
+ */
26
+ context: boolean;
27
+ /** Its children and its create action are on screen. */
28
+ expanded: boolean;
29
+ }
30
+
31
+ export type FolderTreeDisplayRow =
32
+ | { kind: "folder"; row: FolderTreeRow; index: number }
33
+ | { kind: "create"; parent: FolderTreeNode; depth: number };
34
+
35
+ export const folderParent = (path: string, delimiter: string): string => {
36
+ const cut = path.lastIndexOf(delimiter);
37
+ return cut === -1 ? "" : path.slice(0, cut);
38
+ };
39
+
40
+ export const folderDepth = (path: string, delimiter: string): number =>
41
+ path.split(delimiter).length - 1;
42
+
43
+ export const folderAncestors = (path: string, delimiter: string): string[] => {
44
+ const out: string[] = [];
45
+ let parent = folderParent(path, delimiter);
46
+ while (parent) {
47
+ out.push(parent);
48
+ parent = folderParent(parent, delimiter);
49
+ }
50
+ return out;
51
+ };
52
+
53
+ /**
54
+ * Puts every child straight after its parent so the list reads as a tree, while
55
+ * leaving the order of unrelated folders alone. A folder whose parent is absent
56
+ * from the list renders as a root rather than disappearing.
57
+ */
58
+ export const orderFolderNodes = (
59
+ folders: readonly FolderTreeNode[],
60
+ delimiter: string,
61
+ ): FolderTreeNode[] => {
62
+ const present = new Set(folders.map((folder) => folder.path));
63
+ const emitted = new Set<string>();
64
+ const out: FolderTreeNode[] = [];
65
+
66
+ const emit = (folder: FolderTreeNode) => {
67
+ if (emitted.has(folder.path)) return;
68
+ emitted.add(folder.path);
69
+ out.push(folder);
70
+ for (const candidate of folders) {
71
+ if (folderParent(candidate.path, delimiter) === folder.path) {
72
+ emit(candidate);
73
+ }
74
+ }
75
+ };
76
+
77
+ for (const folder of folders) {
78
+ const parent = folderParent(folder.path, delimiter);
79
+ if (parent && present.has(parent)) continue;
80
+ emit(folder);
81
+ }
82
+ return out;
83
+ };
84
+
85
+ export const matchesQuery = (folder: FolderTreeNode, query: string): boolean =>
86
+ folder.label.toLowerCase().includes(query) ||
87
+ folder.path.toLowerCase().includes(query);
88
+
89
+ /**
90
+ * The ancestors a query has to open for its matches to be on screen. Held apart
91
+ * from what the user opened by hand, so clearing the filter puts the list back
92
+ * the way they left it.
93
+ */
94
+ export const queryExpandedPaths = (
95
+ folders: readonly FolderTreeNode[],
96
+ query: string,
97
+ delimiter: string,
98
+ ): Set<string> => {
99
+ const out = new Set<string>();
100
+ if (!query) return out;
101
+ for (const folder of folders) {
102
+ if (!matchesQuery(folder, query)) continue;
103
+ for (const ancestor of folderAncestors(folder.path, delimiter)) {
104
+ out.add(ancestor);
105
+ }
106
+ }
107
+ return out;
108
+ };
109
+
110
+ /**
111
+ * The rows a filtered tree shows: every match, plus the ancestors holding it on
112
+ * screen. Depth still comes from the path, so a match stays indented under the
113
+ * branch it belongs to.
114
+ */
115
+ export const filterFolderTree = (
116
+ ordered: readonly FolderTreeNode[],
117
+ query: string,
118
+ delimiter: string,
119
+ expanded: ReadonlySet<string> = new Set(),
120
+ ): FolderTreeRow[] => {
121
+ const row = (folder: FolderTreeNode, context: boolean): FolderTreeRow => ({
122
+ folder,
123
+ depth: folderDepth(folder.path, delimiter),
124
+ context,
125
+ expanded: expanded.has(folder.path),
126
+ });
127
+ if (!query) return ordered.map((folder) => row(folder, false));
128
+
129
+ const matched = new Set<string>();
130
+ for (const folder of ordered) {
131
+ if (matchesQuery(folder, query)) matched.add(folder.path);
132
+ }
133
+ const visible = new Set(matched);
134
+ for (const path of matched) {
135
+ for (const ancestor of folderAncestors(path, delimiter)) {
136
+ visible.add(ancestor);
137
+ }
138
+ }
139
+ return ordered
140
+ .filter((folder) => visible.has(folder.path))
141
+ .map((folder) => row(folder, !matched.has(folder.path)));
142
+ };
143
+
144
+ /**
145
+ * The unfiltered list: roots always, and a child only while every ancestor it
146
+ * has on screen is open. A folder whose parent is absent from the list is a
147
+ * root, so it never hides behind something that was never there.
148
+ */
149
+ export const collapseFolderTree = (
150
+ ordered: readonly FolderTreeNode[],
151
+ expanded: ReadonlySet<string>,
152
+ delimiter: string,
153
+ ): FolderTreeRow[] => {
154
+ const present = new Set(ordered.map((folder) => folder.path));
155
+ return ordered
156
+ .filter((folder) =>
157
+ folderAncestors(folder.path, delimiter).every(
158
+ (ancestor) => !present.has(ancestor) || expanded.has(ancestor),
159
+ ),
160
+ )
161
+ .map((folder) => ({
162
+ folder,
163
+ depth: folderDepth(folder.path, delimiter),
164
+ context: false,
165
+ expanded: expanded.has(folder.path),
166
+ }));
167
+ };
168
+
169
+ /**
170
+ * Drops a create action at the end of every open folder's children, so "New
171
+ * folder" reads as the last folder inside the one you opened.
172
+ */
173
+ export const withCreateRows = (
174
+ rows: readonly FolderTreeRow[],
175
+ delimiter: string,
176
+ ): FolderTreeDisplayRow[] => {
177
+ const out: FolderTreeDisplayRow[] = [];
178
+ const open: FolderTreeRow[] = [];
179
+
180
+ const closeDownTo = (path: string | null) => {
181
+ while (open.length > 0) {
182
+ const last = open[open.length - 1];
183
+ if (!last) break;
184
+ if (path?.startsWith(`${last.folder.path}${delimiter}`)) break;
185
+ open.pop();
186
+ out.push({
187
+ kind: "create",
188
+ parent: last.folder,
189
+ depth: last.depth + 1,
190
+ });
191
+ }
192
+ };
193
+
194
+ rows.forEach((row, index) => {
195
+ closeDownTo(row.folder.path);
196
+ out.push({ kind: "folder", row, index });
197
+ if (row.expanded) open.push(row);
198
+ });
199
+ closeDownTo(null);
200
+ return out;
201
+ };