@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.
- 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/components/intelligence-panel.stories.tsx +46 -0
- package/src/index.ts +0 -1
|
@@ -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
|
|