@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.
@@ -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<FolderOption>;
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 = (name: string): Promise<FolderOption> =>
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
- () => resolve({ id: `mbx-new-${newFolderSeq}`, label: name }),
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 move destination offers a "+ New folder…" option because `onCreateFolder`
338
- * is wired. Choosing it reveals a name field; on resolve the folder is added to
339
- * the select and picked as the destination. Without the prop the option never
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 WithNewFolderOption: Story = {
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
- * Drive the destination field into its create sub-form: pick "+ New folder…",
350
- * type a name, and press "Create folder". Used by the pending and error stories
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
- async function openCreateAndSubmit(
354
- canvasElement: HTMLElement,
355
- folderName: string,
356
- ) {
357
- const setSelectValue = Object.getOwnPropertyDescriptor(
358
- HTMLSelectElement.prototype,
359
- "value",
360
- )?.set;
361
- const setInputValue = Object.getOwnPropertyDescriptor(
362
- HTMLInputElement.prototype,
363
- "value",
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
- /** Matches the internal CREATE_FOLDER_VALUE option in the destination select. */
384
- const CREATE_FOLDER_STORY_VALUE = "__filter_create_folder__";
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 tick = () => new Promise((resolve) => setTimeout(resolve, 60));
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 = (message: string) => (): Promise<FolderOption> =>
396
- Promise.reject(new Error(message));
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<FolderOption> => {
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({ id: "mbx-created", label: name });
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<FolderOption> =>
414
- new Promise<FolderOption>((_resolve, reject) => {
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 shows as "Creating folder…" held for the whole confirmation, not
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 openCreateAndSubmit(canvasElement, "Receipts");
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 openCreateAndSubmit(canvasElement, "Receipts");
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 openCreateAndSubmit(canvasElement, "Receipts");
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 openCreateAndSubmit(canvasElement, "Receipts");
581
+ await createFolderFromTree(canvasElement, "Receipts", "Travel");
493
582
  await tick();
494
- const retry = Array.from(
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 "Creating folder…" is in flight aborts the wait: the create
503
- * promise rejects with an AbortError the field swallows, so no destination binds
504
- * after the user backed out — the sub-form just closes.
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 openCreateAndSubmit(canvasElement, "Receipts");
601
+ await createFolderFromTree(canvasElement, "Receipts");
516
602
  await tick();
517
- const cancel = Array.from(
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