@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.143",
3
+ "version": "0.0.144",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -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={demoFolders}
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 { FolderTreeNode } from "../lib/folder-tree.js";
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.split(delimiter);
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
+ });
@@ -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, so the path is its
42
- // own leaf; splitting on "" would return single characters.
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
- if (delimiter.length === 0) return path;
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.split(delimiter).length - 1;
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 parent = folderParent(path, delimiter);
60
- while (parent) {
68
+ let child = path;
69
+ let parent = folderParent(child, delimiter);
70
+ while (parent && parent.length < child.length) {
61
71
  out.push(parent);
62
- parent = folderParent(parent, delimiter);
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