@remit/ui 0.0.138 → 0.0.140
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-manager.tsx +1 -1
- package/src/components/folder-role.tsx +5 -1
- package/src/components/folder-row.render.test.ts +14 -0
- package/src/components/folder-row.tsx +15 -0
- package/src/components/folder-tree-picker.render.test.ts +13 -1
- package/src/components/folder-tree-picker.tsx +9 -4
- package/src/components/role-appointment-list.render.test.ts +200 -8
- package/src/components/role-appointment-list.stories.tsx +132 -25
- package/src/components/role-appointment-list.tsx +132 -26
- package/src/components/role-appointment-prompt.render.test.ts +240 -0
- package/src/components/role-appointment-prompt.stories.tsx +172 -0
- package/src/components/role-appointment-prompt.tsx +309 -0
- package/src/components/select.tsx +11 -2
- package/src/index.ts +12 -0
- package/src/lib/folder-tree.test.ts +19 -0
- package/src/lib/folder-tree.ts +14 -0
package/package.json
CHANGED
|
@@ -48,7 +48,11 @@ export function canonicalRoleLabel(role: FolderRole): string {
|
|
|
48
48
|
return ROLE_LABEL[role];
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* Leaf segment of a provider path (`INBOX/Spam` → `Spam`). Only for the two
|
|
53
|
+
* surfaces whose wire payload carries no `hierarchyDelimiter` — quarantine
|
|
54
|
+
* entries and search-result provenance. Everything else: `folderLeaf`.
|
|
55
|
+
*/
|
|
52
56
|
export function providerLeaf(providerPath: string): string {
|
|
53
57
|
const parts = providerPath.split("/");
|
|
54
58
|
return parts[parts.length - 1] || providerPath;
|
|
@@ -81,6 +81,20 @@ describe("FolderRow", () => {
|
|
|
81
81
|
assert.ok(html.indexOf(">Delete<") > html.indexOf("</button>"));
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
+
it("shows how much mail the folder holds, and leaves zero visible", () => {
|
|
85
|
+
assert.match(render({ messageCount: 512 }), />512</);
|
|
86
|
+
assert.match(render({ messageCount: 512 }), />msgs</);
|
|
87
|
+
assert.match(render({ messageCount: 1 }), />msg</);
|
|
88
|
+
assert.match(render({ messageCount: 0 }), />0</);
|
|
89
|
+
assert.doesNotMatch(render({}), /msgs/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("keeps the count out of the accessible name it was not folded into", () => {
|
|
93
|
+
const html = render({ messageCount: 512 });
|
|
94
|
+
assert.match(html, /aria-label="Move to Travel"/);
|
|
95
|
+
assert.match(html, /aria-hidden="true"[^>]*class="[^"]*text-2xs/);
|
|
96
|
+
});
|
|
97
|
+
|
|
84
98
|
it("takes its place in a roving tab order", () => {
|
|
85
99
|
assert.match(render({ tabIndex: 0 }), /tabindex="0"/);
|
|
86
100
|
assert.match(render({ tabIndex: -1 }), /tabindex="-1"/);
|
|
@@ -50,6 +50,12 @@ export interface FolderRowProps {
|
|
|
50
50
|
current?: boolean;
|
|
51
51
|
/** Inline tag shown on the current row, e.g. `current`. */
|
|
52
52
|
currentTag?: string;
|
|
53
|
+
/**
|
|
54
|
+
* How much mail the folder holds, right-aligned. The accessible name is the
|
|
55
|
+
* caller's `ariaLabel`, so a surface that needs the count announced folds it
|
|
56
|
+
* in there rather than relying on this.
|
|
57
|
+
*/
|
|
58
|
+
messageCount?: number;
|
|
53
59
|
/** A hairline under the label, drawn for every row but the last. */
|
|
54
60
|
separated?: boolean;
|
|
55
61
|
/**
|
|
@@ -77,6 +83,7 @@ export const FolderRow = ({
|
|
|
77
83
|
context = false,
|
|
78
84
|
current = false,
|
|
79
85
|
currentTag,
|
|
86
|
+
messageCount,
|
|
80
87
|
separated = false,
|
|
81
88
|
actions,
|
|
82
89
|
tabIndex,
|
|
@@ -96,6 +103,12 @@ export const FolderRow = ({
|
|
|
96
103
|
const icon = (
|
|
97
104
|
<Folder className="size-4 shrink-0 text-fg-subtle" aria-hidden="true" />
|
|
98
105
|
);
|
|
106
|
+
const count =
|
|
107
|
+
messageCount === undefined ? null : (
|
|
108
|
+
<span aria-hidden="true" className="shrink-0 text-2xs text-fg-subtle">
|
|
109
|
+
{messageCount} {messageCount === 1 ? "msg" : "msgs"}
|
|
110
|
+
</span>
|
|
111
|
+
);
|
|
99
112
|
if (context) {
|
|
100
113
|
return (
|
|
101
114
|
<div className="relative flex items-center">
|
|
@@ -112,6 +125,7 @@ export const FolderRow = ({
|
|
|
112
125
|
{chevron}
|
|
113
126
|
{icon}
|
|
114
127
|
<span className="min-w-0 flex-1 truncate">{label}</span>
|
|
128
|
+
{count}
|
|
115
129
|
</div>
|
|
116
130
|
{actions}
|
|
117
131
|
{separated && <FolderRowSeparator depth={depth} />}
|
|
@@ -142,6 +156,7 @@ export const FolderRow = ({
|
|
|
142
156
|
{chevron}
|
|
143
157
|
{icon}
|
|
144
158
|
<span className="min-w-0 flex-1 truncate">{label}</span>
|
|
159
|
+
{count}
|
|
145
160
|
{current && currentTag && (
|
|
146
161
|
<span className="shrink-0 text-xs text-fg-muted">{currentTag}</span>
|
|
147
162
|
)}
|
|
@@ -101,8 +101,20 @@ describe("FolderTreePicker render", () => {
|
|
|
101
101
|
|
|
102
102
|
it("applies caller-supplied labels", () => {
|
|
103
103
|
const html = render({
|
|
104
|
-
labels: { optionLabel: (
|
|
104
|
+
labels: { optionLabel: (folder) => `Verplaats naar ${folder.label}` },
|
|
105
105
|
});
|
|
106
106
|
assert.match(html, /aria-label="Verplaats naar Archive"/);
|
|
107
107
|
});
|
|
108
|
+
|
|
109
|
+
it("announces the message count as part of the row's own name", () => {
|
|
110
|
+
const html = render({
|
|
111
|
+
folders: [{ id: "t", label: "Trash", path: "Trash", messageCount: 512 }],
|
|
112
|
+
labels: {
|
|
113
|
+
optionLabel: (folder) =>
|
|
114
|
+
`Set ${folder.label}, ${folder.messageCount} messages, as Trash`,
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
assert.match(html, /aria-label="Set Trash, 512 messages, as Trash"/);
|
|
118
|
+
assert.match(html, />512</);
|
|
119
|
+
});
|
|
108
120
|
});
|
|
@@ -55,8 +55,12 @@ export interface FolderTreePickerLabels {
|
|
|
55
55
|
emptyMessage?: (query: string) => string;
|
|
56
56
|
/** Shown when there is no folder to list at all, filter or no filter. */
|
|
57
57
|
noFolders?: string;
|
|
58
|
-
/**
|
|
59
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Accessible label for a selectable row. Takes the folder rather than its
|
|
60
|
+
* label, so a surface can announce the message count as part of the name
|
|
61
|
+
* instead of leaving it as decoration beside it.
|
|
62
|
+
*/
|
|
63
|
+
optionLabel?: (folder: FolderTreeNode) => string;
|
|
60
64
|
newFolder?: string;
|
|
61
65
|
newSubfolder?: (label: string) => string;
|
|
62
66
|
nameLabel?: string;
|
|
@@ -113,7 +117,7 @@ const defaultLabels: Required<FolderTreePickerLabels> = {
|
|
|
113
117
|
contextSuffix: "(containing folder)",
|
|
114
118
|
emptyMessage: (query) => `No folders match "${query}"`,
|
|
115
119
|
noFolders: "No folders to show",
|
|
116
|
-
optionLabel: (
|
|
120
|
+
optionLabel: (folder) => `Move to ${folder.label}`,
|
|
117
121
|
newFolder: "New folder",
|
|
118
122
|
newSubfolder: (label) => `New folder inside ${label}`,
|
|
119
123
|
topLevel: "Top level",
|
|
@@ -376,7 +380,7 @@ export const FolderTreePicker = ({
|
|
|
376
380
|
|
|
377
381
|
const rowAriaLabel = (row: FolderTreeRow): string => {
|
|
378
382
|
if (row.context) return `${row.folder.label} ${text.contextSuffix}`;
|
|
379
|
-
if (isSelectable(row)) return text.optionLabel(row.folder
|
|
383
|
+
if (isSelectable(row)) return text.optionLabel(row.folder);
|
|
380
384
|
return `${row.folder.label} ${text.currentSuffix}`;
|
|
381
385
|
};
|
|
382
386
|
|
|
@@ -394,6 +398,7 @@ export const FolderTreePicker = ({
|
|
|
394
398
|
context={row.context}
|
|
395
399
|
current={folder.isCurrent}
|
|
396
400
|
currentTag={text.currentTag}
|
|
401
|
+
messageCount={folder.messageCount}
|
|
397
402
|
selected={selectable && folder.id === selectedId}
|
|
398
403
|
separated={separated}
|
|
399
404
|
actions={rowActions?.(folder)}
|
|
@@ -4,28 +4,46 @@ import { createElement } from "react";
|
|
|
4
4
|
import { renderToString } from "react-dom/server";
|
|
5
5
|
import {
|
|
6
6
|
type CandidateFolder,
|
|
7
|
+
type RoleAppointment,
|
|
7
8
|
RoleAppointmentList,
|
|
8
9
|
} from "./role-appointment-list.js";
|
|
9
10
|
|
|
10
11
|
const noop = () => {};
|
|
11
12
|
|
|
12
13
|
const folders: CandidateFolder[] = [
|
|
13
|
-
{
|
|
14
|
+
{
|
|
15
|
+
mailboxId: "mb-inbox",
|
|
16
|
+
providerPath: "INBOX",
|
|
17
|
+
hierarchyDelimiter: "/",
|
|
18
|
+
messageCount: 4821,
|
|
19
|
+
},
|
|
14
20
|
{
|
|
15
21
|
mailboxId: "mb-concepten",
|
|
16
22
|
providerPath: "INBOX/Concepten",
|
|
23
|
+
hierarchyDelimiter: "/",
|
|
17
24
|
messageCount: 340,
|
|
18
25
|
},
|
|
19
|
-
{
|
|
26
|
+
{
|
|
27
|
+
mailboxId: "mb-drafts",
|
|
28
|
+
providerPath: "INBOX/Drafts",
|
|
29
|
+
hierarchyDelimiter: "/",
|
|
30
|
+
messageCount: 0,
|
|
31
|
+
},
|
|
20
32
|
{
|
|
21
33
|
mailboxId: "mb-news",
|
|
22
34
|
providerPath: "INBOX/Nieuwsbrieven",
|
|
35
|
+
hierarchyDelimiter: "/",
|
|
23
36
|
messageCount: 2870,
|
|
24
37
|
},
|
|
25
38
|
];
|
|
26
39
|
|
|
40
|
+
const appointed = (mailboxId: string): RoleAppointment => ({
|
|
41
|
+
mailboxId,
|
|
42
|
+
source: "Appointed",
|
|
43
|
+
});
|
|
44
|
+
|
|
27
45
|
function render(
|
|
28
|
-
appointments: Record<string,
|
|
46
|
+
appointments: Record<string, RoleAppointment>,
|
|
29
47
|
displayNames: Record<string, string> = {},
|
|
30
48
|
): string {
|
|
31
49
|
return renderToString(
|
|
@@ -42,7 +60,7 @@ function render(
|
|
|
42
60
|
|
|
43
61
|
describe("RoleAppointmentList", () => {
|
|
44
62
|
it("titles the section with the account email", () => {
|
|
45
|
-
const html = render({ inbox: "mb-inbox" });
|
|
63
|
+
const html = render({ inbox: appointed("mb-inbox") });
|
|
46
64
|
assert.match(html, /Folder roles —/);
|
|
47
65
|
assert.match(html, /you@example.com/);
|
|
48
66
|
});
|
|
@@ -69,29 +87,203 @@ describe("RoleAppointmentList", () => {
|
|
|
69
87
|
});
|
|
70
88
|
|
|
71
89
|
it("shows the appointed folder's path and count under the role", () => {
|
|
72
|
-
const html = render({ drafts: "mb-concepten" });
|
|
90
|
+
const html = render({ drafts: appointed("mb-concepten") });
|
|
73
91
|
assert.match(html, /title="INBOX\/Concepten"/);
|
|
74
92
|
assert.match(html, /340/);
|
|
75
93
|
assert.match(html, /messages/);
|
|
76
94
|
});
|
|
77
95
|
|
|
78
96
|
it("renders a rename field only for an appointed role", () => {
|
|
79
|
-
const html = render({ drafts: "mb-concepten" });
|
|
97
|
+
const html = render({ drafts: appointed("mb-concepten") });
|
|
80
98
|
assert.match(html, /Display name for Drafts/);
|
|
81
99
|
assert.doesNotMatch(html, /Display name for Sent/);
|
|
82
100
|
});
|
|
83
101
|
|
|
84
102
|
it("lists unappointed folders under Other folders", () => {
|
|
85
|
-
const html = render({
|
|
103
|
+
const html = render({
|
|
104
|
+
drafts: appointed("mb-concepten"),
|
|
105
|
+
inbox: appointed("mb-inbox"),
|
|
106
|
+
});
|
|
86
107
|
assert.match(html, /Other folders/);
|
|
87
108
|
assert.match(html, /Nieuwsbrieven/);
|
|
88
109
|
assert.match(html, /Drafts/);
|
|
89
110
|
});
|
|
90
111
|
|
|
91
112
|
it("keeps an appointed folder out of the Other folders list", () => {
|
|
92
|
-
const html = render({ drafts: "mb-concepten" });
|
|
113
|
+
const html = render({ drafts: appointed("mb-concepten") });
|
|
93
114
|
const otherIdx = html.indexOf("Other folders");
|
|
94
115
|
assert.ok(otherIdx >= 0);
|
|
95
116
|
assert.doesNotMatch(html.slice(otherIdx), /Concepten/);
|
|
96
117
|
});
|
|
118
|
+
|
|
119
|
+
it("says where each answer came from, in front of the path", () => {
|
|
120
|
+
assert.match(
|
|
121
|
+
render({ drafts: appointed("mb-concepten") }),
|
|
122
|
+
/Chosen by you · INBOX\/Concepten/,
|
|
123
|
+
);
|
|
124
|
+
assert.match(
|
|
125
|
+
render({ drafts: { mailboxId: "mb-concepten", source: "Flagged" } }),
|
|
126
|
+
/The mail server marks it as Drafts · INBOX\/Concepten/,
|
|
127
|
+
);
|
|
128
|
+
assert.match(
|
|
129
|
+
render({ inbox: { mailboxId: "mb-inbox", source: "Reserved" } }),
|
|
130
|
+
/The account's own INBOX · INBOX/,
|
|
131
|
+
);
|
|
132
|
+
assert.match(
|
|
133
|
+
render({ drafts: { mailboxId: "mb-concepten", source: "Proposed" } }),
|
|
134
|
+
/Matched by name, not confirmed · INBOX\/Concepten/,
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("announces the provenance with the control that changes it", () => {
|
|
139
|
+
const html = render({ drafts: appointed("mb-concepten") });
|
|
140
|
+
const described = /aria-describedby="([^"]+)"/.exec(html);
|
|
141
|
+
assert.ok(described);
|
|
142
|
+
assert.match(html, new RegExp(`id="${described[1]}"`));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("reads an unfilled role as a decision waiting, not an error", () => {
|
|
146
|
+
const html = render({ archive: { mailboxId: null, source: "None" } });
|
|
147
|
+
assert.match(
|
|
148
|
+
html,
|
|
149
|
+
/Not set — pick the folder this account uses for Archive\.</,
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("says only Trash gates a verb when it is unfilled", () => {
|
|
154
|
+
const html = render({ trash: { mailboxId: null, source: "None" } });
|
|
155
|
+
assert.match(html, /Deleting mail needs one\./);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("calls out a stale Trash with the folder that vanished and its repair", () => {
|
|
159
|
+
const html = render({
|
|
160
|
+
trash: {
|
|
161
|
+
mailboxId: null,
|
|
162
|
+
source: "Stale",
|
|
163
|
+
staleFolderPath: "INBOX/Prullenbak",
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
assert.match(
|
|
167
|
+
html,
|
|
168
|
+
/The folder you chose for Trash — INBOX\/Prullenbak — is gone from the mail server\./,
|
|
169
|
+
);
|
|
170
|
+
assert.match(html, /Deleting mail is stopped until you pick another one\./);
|
|
171
|
+
assert.match(html, /Pick a folder/);
|
|
172
|
+
assert.match(html, /role="alert"/);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("names what reader fell back to for a stale non-Trash role", () => {
|
|
176
|
+
const html = render({
|
|
177
|
+
drafts: {
|
|
178
|
+
mailboxId: "mb-drafts",
|
|
179
|
+
source: "Stale",
|
|
180
|
+
staleFolderPath: "INBOX/Concepten",
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
assert.match(
|
|
184
|
+
html,
|
|
185
|
+
/The folder you chose for Drafts — INBOX\/Concepten — is gone from the mail server\./,
|
|
186
|
+
);
|
|
187
|
+
assert.match(html, /reader is using Drafts instead\./);
|
|
188
|
+
assert.doesNotMatch(html, /Deleting mail is stopped/);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("drops the name clause when no path was ever recorded", () => {
|
|
192
|
+
const html = render({ trash: { mailboxId: null, source: "Stale" } });
|
|
193
|
+
assert.match(
|
|
194
|
+
html,
|
|
195
|
+
/The folder you chose for Trash is gone from the mail server\./,
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("says nothing about a fallback it cannot name", () => {
|
|
200
|
+
const html = render({
|
|
201
|
+
drafts: {
|
|
202
|
+
mailboxId: null,
|
|
203
|
+
source: "Stale",
|
|
204
|
+
staleFolderPath: "INBOX/Concepten",
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
assert.match(html, /is gone from the mail server\.</);
|
|
208
|
+
assert.doesNotMatch(html, /reader is using/);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("announces the stale callout with the control that repairs it", () => {
|
|
212
|
+
const html = render({
|
|
213
|
+
trash: {
|
|
214
|
+
mailboxId: null,
|
|
215
|
+
source: "Stale",
|
|
216
|
+
staleFolderPath: "INBOX/Prullenbak",
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
const notice = /id="([^"]+)"[^>]*>The folder you chose for Trash/.exec(
|
|
220
|
+
html,
|
|
221
|
+
);
|
|
222
|
+
assert.ok(notice, "the callout carries an id");
|
|
223
|
+
assert.match(
|
|
224
|
+
html,
|
|
225
|
+
new RegExp(`aria-describedby="${notice[1]}"`),
|
|
226
|
+
"the Select that repairs the role points at it",
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("RoleAppointmentList — the server’s own delimiter (#877)", () => {
|
|
232
|
+
const dotted: CandidateFolder[] = [
|
|
233
|
+
{
|
|
234
|
+
mailboxId: "mb-inbox",
|
|
235
|
+
providerPath: "INBOX",
|
|
236
|
+
hierarchyDelimiter: ".",
|
|
237
|
+
messageCount: 4821,
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
mailboxId: "mb-concepten",
|
|
241
|
+
providerPath: "INBOX.Projects.Concepten",
|
|
242
|
+
hierarchyDelimiter: ".",
|
|
243
|
+
messageCount: 340,
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
mailboxId: "mb-news",
|
|
247
|
+
providerPath: "INBOX.Projects.Nieuwsbrieven",
|
|
248
|
+
hierarchyDelimiter: ".",
|
|
249
|
+
messageCount: 2870,
|
|
250
|
+
},
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
const renderDotted = (
|
|
254
|
+
appointments: Record<string, RoleAppointment>,
|
|
255
|
+
): string =>
|
|
256
|
+
renderToString(
|
|
257
|
+
createElement(RoleAppointmentList, {
|
|
258
|
+
accountEmail: "you@example.com",
|
|
259
|
+
folders: dotted,
|
|
260
|
+
appointments,
|
|
261
|
+
displayNames: {},
|
|
262
|
+
onAppoint: noop,
|
|
263
|
+
onRename: noop,
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
it("offers the picker option by its leaf", () => {
|
|
268
|
+
assert.match(renderDotted({}), /Concepten · 340 msgs/);
|
|
269
|
+
assert.doesNotMatch(renderDotted({}), /INBOX.Projects.Concepten · 340/);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("names the stale-appointment fallback by its leaf", () => {
|
|
273
|
+
const html = renderDotted({
|
|
274
|
+
drafts: {
|
|
275
|
+
mailboxId: "mb-concepten",
|
|
276
|
+
source: "Stale",
|
|
277
|
+
staleFolderPath: "INBOX.Projects.Oud",
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
assert.match(html, /reader is using Concepten instead\./);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("lists an unappointed folder by its leaf under Other folders", () => {
|
|
284
|
+
const html = renderDotted({ drafts: appointed("mb-concepten") });
|
|
285
|
+
const other = html.slice(html.indexOf("Other folders"));
|
|
286
|
+
assert.match(other, />Nieuwsbrieven</);
|
|
287
|
+
assert.doesNotMatch(other, />INBOX.Projects.Nieuwsbrieven</);
|
|
288
|
+
});
|
|
97
289
|
});
|
|
@@ -3,6 +3,7 @@ import { useState } from "react";
|
|
|
3
3
|
import type { FolderRole } from "./folder-role.js";
|
|
4
4
|
import {
|
|
5
5
|
type CandidateFolder,
|
|
6
|
+
type RoleAppointment,
|
|
6
7
|
RoleAppointmentList,
|
|
7
8
|
} from "./role-appointment-list.js";
|
|
8
9
|
|
|
@@ -17,57 +18,101 @@ import {
|
|
|
17
18
|
* numbers are wired in.
|
|
18
19
|
*/
|
|
19
20
|
const HOSTNET_FOLDERS: readonly CandidateFolder[] = [
|
|
20
|
-
{
|
|
21
|
+
{
|
|
22
|
+
mailboxId: "mb-inbox",
|
|
23
|
+
providerPath: "INBOX",
|
|
24
|
+
hierarchyDelimiter: "/",
|
|
25
|
+
messageCount: 4821,
|
|
26
|
+
},
|
|
21
27
|
{
|
|
22
28
|
mailboxId: "mb-archive",
|
|
23
29
|
providerPath: "INBOX/Archive",
|
|
30
|
+
hierarchyDelimiter: "/",
|
|
24
31
|
messageCount: 19243,
|
|
25
32
|
},
|
|
26
33
|
{
|
|
27
34
|
mailboxId: "mb-concepten",
|
|
28
35
|
providerPath: "INBOX/Concepten",
|
|
36
|
+
hierarchyDelimiter: "/",
|
|
29
37
|
messageCount: 340,
|
|
30
38
|
},
|
|
31
39
|
{
|
|
32
40
|
mailboxId: "mb-deleted",
|
|
33
41
|
providerPath: "INBOX/Deleted Messages",
|
|
42
|
+
hierarchyDelimiter: "/",
|
|
34
43
|
messageCount: 512,
|
|
35
44
|
},
|
|
36
|
-
{
|
|
45
|
+
{
|
|
46
|
+
mailboxId: "mb-drafts",
|
|
47
|
+
providerPath: "INBOX/Drafts",
|
|
48
|
+
hierarchyDelimiter: "/",
|
|
49
|
+
messageCount: 0,
|
|
50
|
+
},
|
|
37
51
|
{
|
|
38
52
|
mailboxId: "mb-news",
|
|
39
53
|
providerPath: "INBOX/Nieuwsbrieven",
|
|
54
|
+
hierarchyDelimiter: "/",
|
|
40
55
|
messageCount: 2870,
|
|
41
56
|
},
|
|
42
|
-
{
|
|
57
|
+
{
|
|
58
|
+
mailboxId: "mb-sent",
|
|
59
|
+
providerPath: "INBOX/Sent",
|
|
60
|
+
hierarchyDelimiter: "/",
|
|
61
|
+
messageCount: 0,
|
|
62
|
+
},
|
|
43
63
|
{
|
|
44
64
|
mailboxId: "mb-sent-messages",
|
|
45
65
|
providerPath: "INBOX/Sent Messages",
|
|
66
|
+
hierarchyDelimiter: "/",
|
|
46
67
|
messageCount: 6105,
|
|
47
68
|
},
|
|
48
|
-
{
|
|
69
|
+
{
|
|
70
|
+
mailboxId: "mb-spam",
|
|
71
|
+
providerPath: "INBOX/Spam",
|
|
72
|
+
hierarchyDelimiter: "/",
|
|
73
|
+
messageCount: 88,
|
|
74
|
+
},
|
|
49
75
|
];
|
|
50
76
|
|
|
77
|
+
const appointed = (mailboxId: string): RoleAppointment => ({
|
|
78
|
+
mailboxId,
|
|
79
|
+
source: "Appointed",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** Everything except the role each story is about, so one row carries the news. */
|
|
83
|
+
const SETTLED: Record<string, RoleAppointment> = {
|
|
84
|
+
inbox: { mailboxId: "mb-inbox", source: "Reserved" },
|
|
85
|
+
drafts: appointed("mb-concepten"),
|
|
86
|
+
sent: appointed("mb-sent-messages"),
|
|
87
|
+
archive: appointed("mb-archive"),
|
|
88
|
+
junk: appointed("mb-spam"),
|
|
89
|
+
trash: appointed("mb-deleted"),
|
|
90
|
+
};
|
|
91
|
+
|
|
51
92
|
function Harness({
|
|
52
93
|
folders,
|
|
53
94
|
initial,
|
|
54
95
|
}: {
|
|
55
96
|
folders: readonly CandidateFolder[];
|
|
56
|
-
initial: Record<string,
|
|
97
|
+
initial: Record<string, RoleAppointment>;
|
|
57
98
|
}) {
|
|
58
99
|
const [appointments, setAppointments] = useState(initial);
|
|
59
100
|
const [displayNames, setDisplayNames] = useState<Record<string, string>>({});
|
|
60
101
|
|
|
61
102
|
const handleAppoint = (role: FolderRole, mailboxId: string | null) => {
|
|
62
103
|
setAppointments((prev) => {
|
|
63
|
-
const next: Record<string,
|
|
104
|
+
const next: Record<string, RoleAppointment> = {
|
|
64
105
|
...prev,
|
|
65
|
-
[role]: mailboxId
|
|
106
|
+
[role]: mailboxId
|
|
107
|
+
? { mailboxId, source: "Appointed" }
|
|
108
|
+
: { mailboxId: null, source: "None" },
|
|
66
109
|
};
|
|
67
110
|
// Exclusivity: appointing a folder to one role clears it from any other.
|
|
68
111
|
if (mailboxId) {
|
|
69
|
-
for (const
|
|
70
|
-
if (
|
|
112
|
+
for (const other of Object.keys(next)) {
|
|
113
|
+
if (other === role) continue;
|
|
114
|
+
if (next[other]?.mailboxId !== mailboxId) continue;
|
|
115
|
+
next[other] = { mailboxId: null, source: "None" };
|
|
71
116
|
}
|
|
72
117
|
}
|
|
73
118
|
return next;
|
|
@@ -105,34 +150,96 @@ type Story = StoryObj<typeof Harness>;
|
|
|
105
150
|
* The empty look-alikes drop to "Other folders".
|
|
106
151
|
*/
|
|
107
152
|
export const Hostnet: Story = {
|
|
153
|
+
args: { folders: HOSTNET_FOLDERS, initial: SETTLED },
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Flag-first proposal before the user corrects it: detection appointed the
|
|
158
|
+
* `\Drafts`-flagged but empty `INBOX/Drafts` (0) and the empty `INBOX/Sent`.
|
|
159
|
+
* The picker counts reveal the real folders so the user can re-appoint.
|
|
160
|
+
*/
|
|
161
|
+
export const ProposedDefaults: Story = {
|
|
108
162
|
args: {
|
|
109
163
|
folders: HOSTNET_FOLDERS,
|
|
110
164
|
initial: {
|
|
111
|
-
|
|
112
|
-
drafts: "mb-
|
|
113
|
-
sent: "mb-sent
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
165
|
+
...SETTLED,
|
|
166
|
+
drafts: { mailboxId: "mb-drafts", source: "Flagged" },
|
|
167
|
+
sent: { mailboxId: "mb-sent", source: "Flagged" },
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/** `Appointed` — a person decided, and the row says so. */
|
|
173
|
+
export const AppointedSource: Story = {
|
|
174
|
+
name: "appointed",
|
|
175
|
+
args: { folders: HOSTNET_FOLDERS, initial: SETTLED },
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/** `Flagged` — the mail server's own SPECIAL-USE flag, not a guess. */
|
|
179
|
+
export const FlaggedSource: Story = {
|
|
180
|
+
name: "flagged",
|
|
181
|
+
args: {
|
|
182
|
+
folders: HOSTNET_FOLDERS,
|
|
183
|
+
initial: {
|
|
184
|
+
...SETTLED,
|
|
185
|
+
trash: { mailboxId: "mb-deleted", source: "Flagged" },
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** `Reserved` — INBOX is the one role the protocol names for us. */
|
|
191
|
+
export const ReservedSource: Story = {
|
|
192
|
+
name: "reserved",
|
|
193
|
+
args: {
|
|
194
|
+
folders: HOSTNET_FOLDERS,
|
|
195
|
+
initial: {
|
|
196
|
+
...SETTLED,
|
|
197
|
+
inbox: { mailboxId: "mb-inbox", source: "Reserved" },
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/** `Proposed` — a name matched. Nobody confirmed it, and the row does not pretend otherwise. */
|
|
203
|
+
export const ProposedSource: Story = {
|
|
204
|
+
name: "proposed",
|
|
205
|
+
args: {
|
|
206
|
+
folders: HOSTNET_FOLDERS,
|
|
207
|
+
initial: {
|
|
208
|
+
...SETTLED,
|
|
209
|
+
trash: { mailboxId: "mb-deleted", source: "Proposed" },
|
|
117
210
|
},
|
|
118
211
|
},
|
|
119
212
|
};
|
|
120
213
|
|
|
121
214
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
215
|
+
* `Stale` — the folder the user chose is gone from the mail server. The one row
|
|
216
|
+
* representing a broken decision, so it is a callout with its repair rather
|
|
217
|
+
* than a subtitle. Deleting mail is stopped until Trash is repaired.
|
|
125
218
|
*/
|
|
126
|
-
export const
|
|
219
|
+
export const StaleSource: Story = {
|
|
220
|
+
name: "stale",
|
|
221
|
+
args: {
|
|
222
|
+
folders: HOSTNET_FOLDERS,
|
|
223
|
+
initial: {
|
|
224
|
+
...SETTLED,
|
|
225
|
+
trash: {
|
|
226
|
+
mailboxId: null,
|
|
227
|
+
source: "Stale",
|
|
228
|
+
staleFolderPath: "INBOX/Prullenbak",
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/** `None` — a decision waiting to be made. No icon, no danger colour. */
|
|
235
|
+
export const NoneSource: Story = {
|
|
236
|
+
name: "none",
|
|
127
237
|
args: {
|
|
128
238
|
folders: HOSTNET_FOLDERS,
|
|
129
239
|
initial: {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
archive: "mb-archive",
|
|
134
|
-
junk: "mb-spam",
|
|
135
|
-
trash: "mb-deleted",
|
|
240
|
+
...SETTLED,
|
|
241
|
+
trash: { mailboxId: null, source: "None" },
|
|
242
|
+
archive: { mailboxId: null, source: "None" },
|
|
136
243
|
},
|
|
137
244
|
},
|
|
138
245
|
};
|