@remit/ui 0.0.68 → 0.0.70
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/folder-manage-actions.render.test.ts +51 -0
- package/src/components/folder-manage-actions.stories.tsx +94 -0
- package/src/components/folder-manage-actions.tsx +54 -0
- package/src/components/folder-manager.render.test.ts +80 -0
- package/src/components/folder-manager.stories.tsx +184 -0
- package/src/components/folder-manager.tsx +77 -0
- package/src/components/folder-rename-dialog.render.test.ts +51 -0
- package/src/components/folder-rename-dialog.stories.tsx +66 -0
- package/src/components/folder-rename-dialog.tsx +95 -0
- package/src/components/folder-row.render.test.ts +7 -0
- package/src/components/folder-row.tsx +10 -1
- package/src/components/folder-tree-picker.stories.tsx +12 -25
- package/src/components/folder-tree-picker.tsx +13 -4
- package/src/components/rescue-from-spam-flow.render.test.ts +4 -4
- package/src/components/rescue-from-spam-flow.stories.tsx +9 -7
- package/src/components/rescue-from-spam-flow.tsx +20 -16
- package/src/components/selection-wizard.render.test.ts +14 -7
- package/src/components/selection-wizard.tsx +15 -15
- package/src/index.ts +14 -6
- package/src/components/move-mailbox-picker.create.test.ts +0 -210
- package/src/components/move-mailbox-picker.render.test.ts +0 -113
- package/src/components/move-mailbox-picker.stories.tsx +0 -313
- package/src/components/move-mailbox-picker.tsx +0 -404
package/package.json
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import {
|
|
6
|
+
FolderManageActions,
|
|
7
|
+
type FolderManageActionsProps,
|
|
8
|
+
} from "./folder-manage-actions.js";
|
|
9
|
+
|
|
10
|
+
const render = (props: Partial<FolderManageActionsProps>) =>
|
|
11
|
+
renderToString(
|
|
12
|
+
createElement(FolderManageActions, {
|
|
13
|
+
label: "Travel",
|
|
14
|
+
onRename: () => undefined,
|
|
15
|
+
onDelete: () => undefined,
|
|
16
|
+
...props,
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
describe("FolderManageActions", () => {
|
|
21
|
+
it("names both controls after the folder they act on", () => {
|
|
22
|
+
const html = render({});
|
|
23
|
+
assert.match(html, /aria-label="Rename Travel"/);
|
|
24
|
+
assert.match(html, /aria-label="Delete Travel"/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("leaves a deletable folder's delete pressable", () => {
|
|
28
|
+
assert.doesNotMatch(render({}), /disabled=""/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("states why a folder stays, in the control's own name", () => {
|
|
32
|
+
const html = render({
|
|
33
|
+
deleteBlockedReason: "The inbox can't be deleted.",
|
|
34
|
+
});
|
|
35
|
+
assert.match(
|
|
36
|
+
html,
|
|
37
|
+
/aria-label="Delete Travel — The inbox can't be deleted."/,
|
|
38
|
+
);
|
|
39
|
+
assert.match(html, /disabled=""/);
|
|
40
|
+
assert.match(html, /title="The inbox can't be deleted."/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("takes the surface's own wording for each control", () => {
|
|
44
|
+
const html = render({
|
|
45
|
+
renameLabel: (label) => `Hernoem ${label}`,
|
|
46
|
+
deleteLabel: (label) => `Verwijder ${label}`,
|
|
47
|
+
});
|
|
48
|
+
assert.match(html, /aria-label="Hernoem Travel"/);
|
|
49
|
+
assert.match(html, /aria-label="Verwijder Travel"/);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { FolderManageActions } from "./folder-manage-actions.js";
|
|
4
|
+
import { FolderRow } from "./folder-row.js";
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof FolderManageActions> = {
|
|
7
|
+
title: "Mail/FolderManageActions",
|
|
8
|
+
component: FolderManageActions,
|
|
9
|
+
parameters: { layout: "centered" },
|
|
10
|
+
};
|
|
11
|
+
export default meta;
|
|
12
|
+
|
|
13
|
+
type Story = StoryObj<typeof FolderManageActions>;
|
|
14
|
+
|
|
15
|
+
function List({ children }: { children: ReactNode }) {
|
|
16
|
+
return (
|
|
17
|
+
<div className="w-[360px] overflow-hidden rounded-lg border border-line bg-surface font-sans text-fg">
|
|
18
|
+
{children}
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const OnARow: Story = {
|
|
24
|
+
name: "Beside the folder it acts on",
|
|
25
|
+
render: () => (
|
|
26
|
+
<List>
|
|
27
|
+
<FolderRow
|
|
28
|
+
label="Travel"
|
|
29
|
+
depth={0}
|
|
30
|
+
expanded={false}
|
|
31
|
+
ariaLabel="Travel"
|
|
32
|
+
separated
|
|
33
|
+
actions={
|
|
34
|
+
<FolderManageActions
|
|
35
|
+
label="Travel"
|
|
36
|
+
onRename={() => {}}
|
|
37
|
+
onDelete={() => {}}
|
|
38
|
+
/>
|
|
39
|
+
}
|
|
40
|
+
/>
|
|
41
|
+
<FolderRow
|
|
42
|
+
label="Hotels"
|
|
43
|
+
depth={1}
|
|
44
|
+
expanded={false}
|
|
45
|
+
ariaLabel="Hotels"
|
|
46
|
+
actions={
|
|
47
|
+
<FolderManageActions
|
|
48
|
+
label="Hotels"
|
|
49
|
+
onRename={() => {}}
|
|
50
|
+
onDelete={() => {}}
|
|
51
|
+
/>
|
|
52
|
+
}
|
|
53
|
+
/>
|
|
54
|
+
</List>
|
|
55
|
+
),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** A folder the account depends on keeps rename and states why it stays. */
|
|
59
|
+
export const Blocked: Story = {
|
|
60
|
+
name: "A folder that can't be deleted",
|
|
61
|
+
render: () => (
|
|
62
|
+
<List>
|
|
63
|
+
<FolderRow
|
|
64
|
+
label="Inbox"
|
|
65
|
+
depth={0}
|
|
66
|
+
expanded={false}
|
|
67
|
+
ariaLabel="Inbox"
|
|
68
|
+
separated
|
|
69
|
+
actions={
|
|
70
|
+
<FolderManageActions
|
|
71
|
+
label="Inbox"
|
|
72
|
+
deleteBlockedReason="The inbox can't be deleted."
|
|
73
|
+
onRename={() => {}}
|
|
74
|
+
onDelete={() => {}}
|
|
75
|
+
/>
|
|
76
|
+
}
|
|
77
|
+
/>
|
|
78
|
+
<FolderRow
|
|
79
|
+
label="Trash"
|
|
80
|
+
depth={0}
|
|
81
|
+
expanded={false}
|
|
82
|
+
ariaLabel="Trash"
|
|
83
|
+
actions={
|
|
84
|
+
<FolderManageActions
|
|
85
|
+
label="Trash"
|
|
86
|
+
deleteBlockedReason="This folder is your Trash folder. Reassign that role before deleting it."
|
|
87
|
+
onRename={() => {}}
|
|
88
|
+
onDelete={() => {}}
|
|
89
|
+
/>
|
|
90
|
+
}
|
|
91
|
+
/>
|
|
92
|
+
</List>
|
|
93
|
+
),
|
|
94
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Pencil, Trash2 } from "lucide-react";
|
|
2
|
+
import { Button } from "./button.js";
|
|
3
|
+
|
|
4
|
+
export interface FolderManageActionsProps {
|
|
5
|
+
/** The folder as it reads on the row, so each control names its own target. */
|
|
6
|
+
label: string;
|
|
7
|
+
onRename: () => void;
|
|
8
|
+
onDelete: () => void;
|
|
9
|
+
/** Why this folder can't be deleted; absent means it can. */
|
|
10
|
+
deleteBlockedReason?: string;
|
|
11
|
+
renameLabel?: (label: string) => string;
|
|
12
|
+
deleteLabel?: (label: string) => string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What a folder row offers beyond opening: rename it, or delete it. A folder
|
|
17
|
+
* the account depends on states why it stays — as the control's own name, so
|
|
18
|
+
* the reason is announced rather than only hovered.
|
|
19
|
+
*/
|
|
20
|
+
export const FolderManageActions = ({
|
|
21
|
+
label,
|
|
22
|
+
onRename,
|
|
23
|
+
onDelete,
|
|
24
|
+
deleteBlockedReason,
|
|
25
|
+
renameLabel = (name) => `Rename ${name}`,
|
|
26
|
+
deleteLabel = (name) => `Delete ${name}`,
|
|
27
|
+
}: FolderManageActionsProps) => {
|
|
28
|
+
const deleteName = deleteBlockedReason
|
|
29
|
+
? `${deleteLabel(label)} — ${deleteBlockedReason}`
|
|
30
|
+
: deleteLabel(label);
|
|
31
|
+
return (
|
|
32
|
+
<span className="flex shrink-0 items-center gap-0.5 pr-2">
|
|
33
|
+
<Button
|
|
34
|
+
variant="ghost"
|
|
35
|
+
size="sm"
|
|
36
|
+
icon={<Pencil className="size-3.5" />}
|
|
37
|
+
aria-label={renameLabel(label)}
|
|
38
|
+
onClick={onRename}
|
|
39
|
+
/>
|
|
40
|
+
{/* The title sits on the wrapper: a disabled button takes no pointer
|
|
41
|
+
events, so a tooltip on it never shows. */}
|
|
42
|
+
<span title={deleteBlockedReason}>
|
|
43
|
+
<Button
|
|
44
|
+
variant="ghost"
|
|
45
|
+
size="sm"
|
|
46
|
+
icon={<Trash2 className="size-3.5" />}
|
|
47
|
+
aria-label={deleteName}
|
|
48
|
+
disabled={deleteBlockedReason !== undefined}
|
|
49
|
+
onClick={onDelete}
|
|
50
|
+
/>
|
|
51
|
+
</span>
|
|
52
|
+
</span>
|
|
53
|
+
);
|
|
54
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import { FolderManager, type ManagedFolder } from "./folder-manager.js";
|
|
6
|
+
|
|
7
|
+
const folders: ManagedFolder[] = [
|
|
8
|
+
{
|
|
9
|
+
id: "inbox",
|
|
10
|
+
label: "Inbox",
|
|
11
|
+
path: "INBOX",
|
|
12
|
+
deleteBlockedReason: "The inbox can't be deleted.",
|
|
13
|
+
},
|
|
14
|
+
{ id: "travel", label: "Travel", path: "Travel" },
|
|
15
|
+
{ id: "hotels", label: "Hotels", path: "Travel/Hotels" },
|
|
16
|
+
{ id: "trash", label: "Trash", path: "Deleted Messages" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const render = (props: Partial<Parameters<typeof FolderManager>[0]> = {}) =>
|
|
20
|
+
renderToString(
|
|
21
|
+
createElement(FolderManager, {
|
|
22
|
+
folders,
|
|
23
|
+
onRename: () => undefined,
|
|
24
|
+
onDelete: () => undefined,
|
|
25
|
+
...props,
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
describe("FolderManager", () => {
|
|
30
|
+
it("browses the folders as a tree that starts at its top level", () => {
|
|
31
|
+
const html = render();
|
|
32
|
+
assert.match(html, /role="tree"/);
|
|
33
|
+
assert.equal(html.match(/role="treeitem"/g)?.length, 3);
|
|
34
|
+
assert.doesNotMatch(html, /aria-label="Hotels"/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("reads a row as the folder itself, not as a destination", () => {
|
|
38
|
+
const html = render();
|
|
39
|
+
assert.match(html, /aria-label="Travel"/);
|
|
40
|
+
assert.doesNotMatch(html, /Move to/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("labels a folder by its role while nesting it by its real path", () => {
|
|
44
|
+
const html = render();
|
|
45
|
+
assert.match(html, /aria-label="Trash"/);
|
|
46
|
+
assert.doesNotMatch(html, />Deleted Messages</);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("offers rename and delete on every row", () => {
|
|
50
|
+
const html = render();
|
|
51
|
+
assert.match(html, /aria-label="Rename Travel"/);
|
|
52
|
+
assert.match(html, /aria-label="Delete Travel"/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("carries a folder's reason for staying onto its delete control", () => {
|
|
56
|
+
assert.match(
|
|
57
|
+
render(),
|
|
58
|
+
/aria-label="Delete Inbox — The inbox can't be deleted."/,
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("offers no create affordance without onCreateFolder", () => {
|
|
63
|
+
assert.doesNotMatch(render(), /New folder/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("makes a folder where the user is looking once creating is wired", () => {
|
|
67
|
+
const html = render({
|
|
68
|
+
onCreateFolder: () =>
|
|
69
|
+
Promise.resolve({ id: "made", label: "Made", path: "Made" }),
|
|
70
|
+
});
|
|
71
|
+
assert.match(html, /aria-label="New folder"/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("takes the surface's own tree name", () => {
|
|
75
|
+
assert.match(
|
|
76
|
+
render({ labels: { treeAriaLabel: "All folders for a@b.example" } }),
|
|
77
|
+
/aria-label="All folders for a@b.example"/,
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { type ReactNode, useState } from "react";
|
|
3
|
+
import type { FolderTreeNode } from "../lib/folder-tree.js";
|
|
4
|
+
import { FolderManager, type ManagedFolder } from "./folder-manager.js";
|
|
5
|
+
import { FolderRenameDialog } from "./folder-rename-dialog.js";
|
|
6
|
+
|
|
7
|
+
const folders: ManagedFolder[] = [
|
|
8
|
+
{
|
|
9
|
+
id: "mbx-inbox",
|
|
10
|
+
label: "Inbox",
|
|
11
|
+
path: "INBOX",
|
|
12
|
+
deleteBlockedReason: "The inbox can't be deleted.",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: "mbx-archive",
|
|
16
|
+
label: "Archive",
|
|
17
|
+
path: "Archive",
|
|
18
|
+
deleteBlockedReason:
|
|
19
|
+
"This folder is your Archive folder. Reassign that role before deleting it.",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: "mbx-trash",
|
|
23
|
+
label: "Trash",
|
|
24
|
+
path: "Deleted Messages",
|
|
25
|
+
deleteBlockedReason:
|
|
26
|
+
"This folder is your Trash folder. Reassign that role before deleting it.",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "mbx-travel",
|
|
30
|
+
label: "Travel",
|
|
31
|
+
path: "Travel",
|
|
32
|
+
deleteBlockedReason: "This folder has subfolders. Delete them first.",
|
|
33
|
+
},
|
|
34
|
+
{ id: "mbx-travel-flights", label: "Flights", path: "Travel/Flights" },
|
|
35
|
+
{ id: "mbx-travel-hotels", label: "Hotels", path: "Travel/Hotels" },
|
|
36
|
+
{
|
|
37
|
+
id: "mbx-travel-hotels-receipts",
|
|
38
|
+
label: "Receipts",
|
|
39
|
+
path: "Travel/Hotels/Receipts",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "mbx-finance",
|
|
43
|
+
label: "Finance",
|
|
44
|
+
path: "Finance",
|
|
45
|
+
deleteBlockedReason: "This folder has subfolders. Delete them first.",
|
|
46
|
+
},
|
|
47
|
+
{ id: "mbx-finance-invoices", label: "Invoices", path: "Finance/Invoices" },
|
|
48
|
+
{ id: "mbx-finance-tax", label: "Tax", path: "Finance/Tax" },
|
|
49
|
+
{ id: "mbx-family", label: "Family", path: "Family" },
|
|
50
|
+
{ id: "mbx-news", label: "Newsletters", path: "Newsletters" },
|
|
51
|
+
{ id: "mbx-news-tech", label: "Tech", path: "Newsletters/Tech" },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const meta: Meta<typeof FolderManager> = {
|
|
55
|
+
title: "Mail/FolderManager",
|
|
56
|
+
component: FolderManager,
|
|
57
|
+
parameters: { layout: "centered" },
|
|
58
|
+
};
|
|
59
|
+
export default meta;
|
|
60
|
+
|
|
61
|
+
type Story = StoryObj<typeof FolderManager>;
|
|
62
|
+
|
|
63
|
+
function Frame({
|
|
64
|
+
children,
|
|
65
|
+
className = "h-[520px] w-[420px] rounded-lg border border-line shadow-lg",
|
|
66
|
+
}: {
|
|
67
|
+
children: ReactNode;
|
|
68
|
+
className?: string;
|
|
69
|
+
}) {
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
className={`flex flex-col overflow-hidden bg-surface font-sans text-fg ${className}`}
|
|
73
|
+
>
|
|
74
|
+
{children}
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let createdSeq = 0;
|
|
80
|
+
const createFolder = (
|
|
81
|
+
name: string,
|
|
82
|
+
parentPath: string,
|
|
83
|
+
): Promise<FolderTreeNode> =>
|
|
84
|
+
new Promise((resolve) => {
|
|
85
|
+
createdSeq += 1;
|
|
86
|
+
setTimeout(
|
|
87
|
+
() =>
|
|
88
|
+
resolve({
|
|
89
|
+
id: `mbx-created-${createdSeq}`,
|
|
90
|
+
label: name,
|
|
91
|
+
path: parentPath ? `${parentPath}/${name}` : name,
|
|
92
|
+
}),
|
|
93
|
+
400,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
function Manager({
|
|
98
|
+
renaming = false,
|
|
99
|
+
className,
|
|
100
|
+
}: {
|
|
101
|
+
renaming?: boolean;
|
|
102
|
+
className?: string;
|
|
103
|
+
}) {
|
|
104
|
+
const [known, setKnown] = useState<ManagedFolder[]>(folders);
|
|
105
|
+
const [renamed, setRenamed] = useState<ManagedFolder | null>(
|
|
106
|
+
renaming ? (folders[2] as ManagedFolder) : null,
|
|
107
|
+
);
|
|
108
|
+
const [draft, setDraft] = useState("Trash");
|
|
109
|
+
return (
|
|
110
|
+
<>
|
|
111
|
+
<Frame className={className}>
|
|
112
|
+
<FolderManager
|
|
113
|
+
folders={known}
|
|
114
|
+
onCreateFolder={(name, parentPath) =>
|
|
115
|
+
createFolder(name, parentPath).then((created) => {
|
|
116
|
+
setKnown((current) => [...current, created]);
|
|
117
|
+
return created;
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
onRename={(folder) => {
|
|
121
|
+
setRenamed(folder);
|
|
122
|
+
setDraft(folder.label);
|
|
123
|
+
}}
|
|
124
|
+
onDelete={(folder) =>
|
|
125
|
+
setKnown((current) =>
|
|
126
|
+
current.filter((entry) => entry.id !== folder.id),
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
labels={{ treeAriaLabel: "All folders for alice@northwind.example" }}
|
|
130
|
+
/>
|
|
131
|
+
</Frame>
|
|
132
|
+
{renamed && (
|
|
133
|
+
<FolderRenameDialog
|
|
134
|
+
open
|
|
135
|
+
folderLabel={renamed.label}
|
|
136
|
+
defaultLabel="Deleted Messages"
|
|
137
|
+
name={draft}
|
|
138
|
+
onNameChange={setDraft}
|
|
139
|
+
onSubmit={() => setRenamed(null)}
|
|
140
|
+
onClose={() => setRenamed(null)}
|
|
141
|
+
/>
|
|
142
|
+
)}
|
|
143
|
+
</>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The account's folders as they really nest, closed to the top level. Every row
|
|
149
|
+
* opens where you tap it and carries rename and delete; a folder the account
|
|
150
|
+
* depends on keeps rename and states why it stays.
|
|
151
|
+
*/
|
|
152
|
+
export const Default: Story = {
|
|
153
|
+
name: "Default (collapsed to top level)",
|
|
154
|
+
render: () => <Manager />,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** Renaming a folder: the name is Remit's own, and clearing it restores the server's. */
|
|
158
|
+
export const Renaming: Story = {
|
|
159
|
+
name: "Renaming a folder",
|
|
160
|
+
render: () => <Manager renaming />,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/** A first-run account with nothing but its inbox. */
|
|
164
|
+
export const JustTheInbox: Story = {
|
|
165
|
+
name: "A single folder",
|
|
166
|
+
render: () => (
|
|
167
|
+
<Frame>
|
|
168
|
+
<FolderManager
|
|
169
|
+
folders={[folders[0] as ManagedFolder]}
|
|
170
|
+
onCreateFolder={createFolder}
|
|
171
|
+
onRename={() => {}}
|
|
172
|
+
onDelete={() => {}}
|
|
173
|
+
/>
|
|
174
|
+
</Frame>
|
|
175
|
+
),
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/** The surface as it sits on a phone: full width, one branch open at a time. */
|
|
179
|
+
export const Phone: Story = {
|
|
180
|
+
name: "Phone",
|
|
181
|
+
globals: { viewport: { value: "mobile" } },
|
|
182
|
+
parameters: { layout: "fullscreen" },
|
|
183
|
+
render: () => <Manager className="h-screen w-full" />,
|
|
184
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { FolderTreeNode } from "../lib/folder-tree.js";
|
|
2
|
+
import { FolderManageActions } from "./folder-manage-actions.js";
|
|
3
|
+
import {
|
|
4
|
+
FolderTreePicker,
|
|
5
|
+
type FolderTreePickerLabels,
|
|
6
|
+
} from "./folder-tree-picker.js";
|
|
7
|
+
|
|
8
|
+
export interface ManagedFolder extends FolderTreeNode {
|
|
9
|
+
/** Why this folder can't be deleted; absent means it can. */
|
|
10
|
+
deleteBlockedReason?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FolderManagerLabels extends FolderTreePickerLabels {
|
|
14
|
+
rename?: (label: string) => string;
|
|
15
|
+
remove?: (label: string) => string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FolderManagerProps {
|
|
19
|
+
folders: readonly ManagedFolder[];
|
|
20
|
+
onRename: (folder: ManagedFolder) => void;
|
|
21
|
+
onDelete: (folder: ManagedFolder) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Creating a folder is an IMAP mutation, so this resolves only once the mail
|
|
24
|
+
* server confirms it. Absent means no create affordance renders.
|
|
25
|
+
*/
|
|
26
|
+
onCreateFolder?: (
|
|
27
|
+
name: string,
|
|
28
|
+
parentPath: string,
|
|
29
|
+
signal?: AbortSignal,
|
|
30
|
+
) => Promise<FolderTreeNode>;
|
|
31
|
+
/** The provider's hierarchy separator. */
|
|
32
|
+
delimiter?: string;
|
|
33
|
+
labels?: FolderManagerLabels;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The folders as the account holds them, browsed the same way a destination is
|
|
38
|
+
* picked — open a folder to see what is inside it, filter to narrow, make a new
|
|
39
|
+
* one where you are looking — with rename and delete on each row. Nothing is
|
|
40
|
+
* chosen here, so a row opens and closes and nothing else.
|
|
41
|
+
*/
|
|
42
|
+
export const FolderManager = ({
|
|
43
|
+
folders,
|
|
44
|
+
onRename,
|
|
45
|
+
onDelete,
|
|
46
|
+
onCreateFolder,
|
|
47
|
+
delimiter,
|
|
48
|
+
labels,
|
|
49
|
+
}: FolderManagerProps) => {
|
|
50
|
+
const byId = new Map(folders.map((folder) => [folder.id, folder]));
|
|
51
|
+
return (
|
|
52
|
+
<FolderTreePicker
|
|
53
|
+
folders={folders}
|
|
54
|
+
onCreateFolder={onCreateFolder}
|
|
55
|
+
delimiter={delimiter}
|
|
56
|
+
labels={{
|
|
57
|
+
treeAriaLabel: "Your folders",
|
|
58
|
+
optionLabel: (label) => label,
|
|
59
|
+
...labels,
|
|
60
|
+
}}
|
|
61
|
+
rowActions={(folder) => {
|
|
62
|
+
const managed = byId.get(folder.id);
|
|
63
|
+
if (!managed) return null;
|
|
64
|
+
return (
|
|
65
|
+
<FolderManageActions
|
|
66
|
+
label={managed.label}
|
|
67
|
+
deleteBlockedReason={managed.deleteBlockedReason}
|
|
68
|
+
renameLabel={labels?.rename}
|
|
69
|
+
deleteLabel={labels?.remove}
|
|
70
|
+
onRename={() => onRename(managed)}
|
|
71
|
+
onDelete={() => onDelete(managed)}
|
|
72
|
+
/>
|
|
73
|
+
);
|
|
74
|
+
}}
|
|
75
|
+
/>
|
|
76
|
+
);
|
|
77
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import {
|
|
6
|
+
FolderRenameDialog,
|
|
7
|
+
type FolderRenameDialogProps,
|
|
8
|
+
} from "./folder-rename-dialog.js";
|
|
9
|
+
|
|
10
|
+
const render = (props: Partial<FolderRenameDialogProps>) =>
|
|
11
|
+
renderToString(
|
|
12
|
+
createElement(FolderRenameDialog, {
|
|
13
|
+
open: true,
|
|
14
|
+
folderLabel: "Trash",
|
|
15
|
+
defaultLabel: "Deleted Messages",
|
|
16
|
+
name: "Trash",
|
|
17
|
+
onNameChange: () => undefined,
|
|
18
|
+
onSubmit: () => undefined,
|
|
19
|
+
onClose: () => undefined,
|
|
20
|
+
...props,
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
describe("FolderRenameDialog", () => {
|
|
25
|
+
it("renders nothing when closed", () => {
|
|
26
|
+
assert.equal(render({ open: false }), "");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("names the folder it is renaming", () => {
|
|
30
|
+
assert.match(render({}), /Rename Trash/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("states what clearing the name falls back to", () => {
|
|
34
|
+
const html = render({});
|
|
35
|
+
assert.match(html, /Leave it blank to use/);
|
|
36
|
+
assert.match(html, />Deleted Messages</);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("holds the wait while the name is saved", () => {
|
|
40
|
+
const html = render({ pending: true });
|
|
41
|
+
assert.match(html, /Saving…/);
|
|
42
|
+
assert.match(html, /disabled/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("states a failure where it happened", () => {
|
|
46
|
+
assert.match(
|
|
47
|
+
render({ error: "Couldn't save that name." }),
|
|
48
|
+
/role="alert"[^>]*>Couldn't save that name./,
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { FolderRenameDialog } from "./folder-rename-dialog.js";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof FolderRenameDialog> = {
|
|
6
|
+
title: "Mail/FolderRenameDialog",
|
|
7
|
+
component: FolderRenameDialog,
|
|
8
|
+
parameters: { layout: "fullscreen" },
|
|
9
|
+
};
|
|
10
|
+
export default meta;
|
|
11
|
+
|
|
12
|
+
type Story = StoryObj<typeof FolderRenameDialog>;
|
|
13
|
+
|
|
14
|
+
function Live({
|
|
15
|
+
initialName = "Trash",
|
|
16
|
+
pending,
|
|
17
|
+
error,
|
|
18
|
+
}: {
|
|
19
|
+
initialName?: string;
|
|
20
|
+
pending?: boolean;
|
|
21
|
+
error?: string;
|
|
22
|
+
}) {
|
|
23
|
+
const [name, setName] = useState(initialName);
|
|
24
|
+
return (
|
|
25
|
+
<div className="h-screen bg-canvas font-sans">
|
|
26
|
+
<FolderRenameDialog
|
|
27
|
+
open
|
|
28
|
+
folderLabel="Trash"
|
|
29
|
+
defaultLabel="Deleted Messages"
|
|
30
|
+
name={name}
|
|
31
|
+
onNameChange={setName}
|
|
32
|
+
onSubmit={() => {}}
|
|
33
|
+
onClose={() => {}}
|
|
34
|
+
pending={pending}
|
|
35
|
+
error={error}
|
|
36
|
+
/>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const Default: Story = {
|
|
42
|
+
name: "Renaming an appointed folder",
|
|
43
|
+
render: () => <Live />,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Cleared: the folder goes back to what the mail server calls it. */
|
|
47
|
+
export const Cleared: Story = {
|
|
48
|
+
name: "Name cleared",
|
|
49
|
+
render: () => <Live initialName="" />,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const Saving: Story = {
|
|
53
|
+
name: "Saving",
|
|
54
|
+
render: () => <Live pending />,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const Failed: Story = {
|
|
58
|
+
name: "The save failed",
|
|
59
|
+
render: () => <Live error="Couldn't save that name. Please try again." />,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const Phone: Story = {
|
|
63
|
+
name: "Phone",
|
|
64
|
+
globals: { viewport: { value: "mobile" } },
|
|
65
|
+
render: () => <Live />,
|
|
66
|
+
};
|