@remit/ui 0.0.141 → 0.0.142
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
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import "@remit/test-dom";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
4
|
+
import { act, createElement, useState } from "react";
|
|
5
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
6
|
+
import type { FolderRole } from "./folder-role.js";
|
|
7
|
+
import {
|
|
8
|
+
type CandidateFolder,
|
|
9
|
+
type RoleAppointment,
|
|
10
|
+
RoleAppointmentList,
|
|
11
|
+
} from "./role-appointment-list.js";
|
|
12
|
+
|
|
13
|
+
const folders: CandidateFolder[] = [
|
|
14
|
+
{
|
|
15
|
+
mailboxId: "mb-inbox",
|
|
16
|
+
providerPath: "INBOX",
|
|
17
|
+
hierarchyDelimiter: "/",
|
|
18
|
+
messageCount: 4821,
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
mailboxId: "mb-deleted",
|
|
22
|
+
providerPath: "INBOX/Deleted Messages",
|
|
23
|
+
hierarchyDelimiter: "/",
|
|
24
|
+
messageCount: 512,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
mailboxId: "mb-concepten",
|
|
28
|
+
providerPath: "INBOX/Concepten",
|
|
29
|
+
hierarchyDelimiter: "/",
|
|
30
|
+
messageCount: 340,
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
let container: HTMLElement;
|
|
35
|
+
let root: Root;
|
|
36
|
+
let appointed: Array<[FolderRole, string | null]>;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
appointed = [];
|
|
40
|
+
container = document.createElement("div");
|
|
41
|
+
document.body.append(container);
|
|
42
|
+
root = createRoot(container);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(async () => {
|
|
46
|
+
await act(async () => {
|
|
47
|
+
root.unmount();
|
|
48
|
+
});
|
|
49
|
+
container.remove();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const mount = async (appointments: Record<string, RoleAppointment>) => {
|
|
53
|
+
await act(async () => {
|
|
54
|
+
root.render(
|
|
55
|
+
createElement(RoleAppointmentList, {
|
|
56
|
+
accountEmail: "you@example.com",
|
|
57
|
+
folders,
|
|
58
|
+
appointments,
|
|
59
|
+
displayNames: {},
|
|
60
|
+
onAppoint: (role: FolderRole, mailboxId: string | null) => {
|
|
61
|
+
appointed.push([role, mailboxId]);
|
|
62
|
+
},
|
|
63
|
+
onRename: () => {},
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const buttonNamed = (text: string): HTMLButtonElement | undefined =>
|
|
70
|
+
[...container.querySelectorAll("button")].find(
|
|
71
|
+
(button) => button.textContent?.trim() === text,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const click = async (element: Element | undefined) => {
|
|
75
|
+
assert.ok(element, "the control is rendered");
|
|
76
|
+
await act(async () => {
|
|
77
|
+
element.dispatchEvent(
|
|
78
|
+
new MouseEvent("click", { bubbles: true, cancelable: true }),
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
describe("confirming a proposed folder role", () => {
|
|
84
|
+
it("commits the folder the picker already shows", async () => {
|
|
85
|
+
await mount({
|
|
86
|
+
trash: { mailboxId: "mb-deleted", source: "Proposed" },
|
|
87
|
+
});
|
|
88
|
+
await click(buttonNamed("Set as Trash"));
|
|
89
|
+
assert.deepEqual(appointed, [["trash", "mb-deleted"]]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("offers the commit on every proposed role, naming that role", async () => {
|
|
93
|
+
await mount({
|
|
94
|
+
trash: { mailboxId: "mb-deleted", source: "Proposed" },
|
|
95
|
+
drafts: { mailboxId: "mb-concepten", source: "Proposed" },
|
|
96
|
+
});
|
|
97
|
+
await click(buttonNamed("Set as Drafts"));
|
|
98
|
+
assert.deepEqual(appointed, [["drafts", "mb-concepten"]]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("leaves a vouched row alone — there is nothing to confirm", async () => {
|
|
102
|
+
await mount({
|
|
103
|
+
trash: { mailboxId: "mb-deleted", source: "Appointed" },
|
|
104
|
+
drafts: { mailboxId: "mb-concepten", source: "Flagged" },
|
|
105
|
+
inbox: { mailboxId: "mb-inbox", source: "Reserved" },
|
|
106
|
+
});
|
|
107
|
+
assert.equal(buttonNamed("Set as Trash"), undefined);
|
|
108
|
+
assert.equal(buttonNamed("Set as Drafts"), undefined);
|
|
109
|
+
assert.equal(buttonNamed("Set as Inbox"), undefined);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("offers nothing to commit when the proposal resolves to no folder", async () => {
|
|
113
|
+
await mount({ trash: { mailboxId: null, source: "Proposed" } });
|
|
114
|
+
assert.equal(buttonNamed("Set as Trash"), undefined);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
function Controlled({ accepts }: { accepts: boolean }) {
|
|
119
|
+
const [appointments, setAppointments] = useState<
|
|
120
|
+
Record<string, RoleAppointment>
|
|
121
|
+
>({ trash: { mailboxId: "mb-deleted", source: "Proposed" } });
|
|
122
|
+
return createElement(RoleAppointmentList, {
|
|
123
|
+
accountEmail: "you@example.com",
|
|
124
|
+
folders,
|
|
125
|
+
appointments,
|
|
126
|
+
displayNames: {},
|
|
127
|
+
onAppoint: (role: FolderRole, mailboxId: string | null) => {
|
|
128
|
+
appointed.push([role, mailboxId]);
|
|
129
|
+
if (!accepts || mailboxId === null) return;
|
|
130
|
+
setAppointments({ [role]: { mailboxId, source: "Appointed" } });
|
|
131
|
+
},
|
|
132
|
+
onRename: () => {},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
describe("where focus goes when a proposal is committed", () => {
|
|
137
|
+
const mountControlled = async (accepts: boolean) => {
|
|
138
|
+
await act(async () => {
|
|
139
|
+
root.render(createElement(Controlled, { accepts }));
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const trashPicker = (): Element | null =>
|
|
144
|
+
container.querySelector('select[aria-label="Folder for Trash"]');
|
|
145
|
+
|
|
146
|
+
it("hands focus to the picker once the role re-resolves", async () => {
|
|
147
|
+
await mountControlled(true);
|
|
148
|
+
const button = buttonNamed("Set as Trash");
|
|
149
|
+
button?.focus();
|
|
150
|
+
await click(button);
|
|
151
|
+
assert.equal(
|
|
152
|
+
buttonNamed("Set as Trash"),
|
|
153
|
+
undefined,
|
|
154
|
+
"the commit removes itself",
|
|
155
|
+
);
|
|
156
|
+
assert.equal(document.activeElement, trashPicker());
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("leaves focus on the commit when the role does not re-resolve", async () => {
|
|
160
|
+
await mountControlled(false);
|
|
161
|
+
const button = buttonNamed("Set as Trash");
|
|
162
|
+
button?.focus();
|
|
163
|
+
await click(button);
|
|
164
|
+
assert.equal(buttonNamed("Set as Trash"), button);
|
|
165
|
+
assert.equal(document.activeElement, button);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -199,7 +199,11 @@ export const ReservedSource: Story = {
|
|
|
199
199
|
},
|
|
200
200
|
};
|
|
201
201
|
|
|
202
|
-
/**
|
|
202
|
+
/**
|
|
203
|
+
* `Proposed` — a name matched. Nobody confirmed it, and the row does not
|
|
204
|
+
* pretend otherwise. `Set as Trash` commits the folder the picker already
|
|
205
|
+
* shows, which re-picking that same option cannot do.
|
|
206
|
+
*/
|
|
203
207
|
export const ProposedSource: Story = {
|
|
204
208
|
name: "proposed",
|
|
205
209
|
args: {
|
|
@@ -211,6 +215,26 @@ export const ProposedSource: Story = {
|
|
|
211
215
|
},
|
|
212
216
|
};
|
|
213
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Density check: a server that advertises no SPECIAL-USE flags, so every role
|
|
220
|
+
* but INBOX rests on a name match alone. Each row carries its own commit, so
|
|
221
|
+
* the screen stays a list rather than five stacked callouts.
|
|
222
|
+
*/
|
|
223
|
+
export const ProposedThroughout: Story = {
|
|
224
|
+
name: "proposed throughout",
|
|
225
|
+
args: {
|
|
226
|
+
folders: HOSTNET_FOLDERS,
|
|
227
|
+
initial: {
|
|
228
|
+
inbox: { mailboxId: "mb-inbox", source: "Reserved" },
|
|
229
|
+
drafts: { mailboxId: "mb-concepten", source: "Proposed" },
|
|
230
|
+
sent: { mailboxId: "mb-sent-messages", source: "Proposed" },
|
|
231
|
+
archive: { mailboxId: "mb-archive", source: "Proposed" },
|
|
232
|
+
junk: { mailboxId: "mb-spam", source: "Proposed" },
|
|
233
|
+
trash: { mailboxId: "mb-deleted", source: "Proposed" },
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
|
|
214
238
|
/**
|
|
215
239
|
* `Stale` — the folder the user chose is gone from the mail server. The one row
|
|
216
240
|
* representing a broken decision, so it is a callout with its repair rather
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Check, Folder } from "lucide-react";
|
|
2
|
-
import { useId, useRef, useState } from "react";
|
|
2
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
3
3
|
import { folderLeaf } from "../lib/folder-tree.js";
|
|
4
4
|
import { Banner } from "./banner.js";
|
|
5
5
|
import { Button } from "./button.js";
|
|
@@ -119,6 +119,7 @@ function RoleAppointmentRow({
|
|
|
119
119
|
}: RoleAppointmentRowProps) {
|
|
120
120
|
const [draftName, setDraftName] = useState(displayName);
|
|
121
121
|
const selectRef = useRef<HTMLSelectElement>(null);
|
|
122
|
+
const confirmingProposal = useRef(false);
|
|
122
123
|
const subtitleId = useId();
|
|
123
124
|
const { mailboxId, source, staleFolderPath } = appointment;
|
|
124
125
|
const appointed = folders.find((f) => f.mailboxId === mailboxId) ?? null;
|
|
@@ -126,6 +127,12 @@ function RoleAppointmentRow({
|
|
|
126
127
|
const renameDirty =
|
|
127
128
|
appointed != null && draftName.trim() !== displayName.trim();
|
|
128
129
|
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (!confirmingProposal.current || source === "Proposed") return;
|
|
132
|
+
confirmingProposal.current = false;
|
|
133
|
+
selectRef.current?.focus();
|
|
134
|
+
}, [source]);
|
|
135
|
+
|
|
129
136
|
const subtitle = (() => {
|
|
130
137
|
if (source === "None") {
|
|
131
138
|
const needsOne = role === "trash" ? " Deleting mail needs one." : "";
|
|
@@ -194,6 +201,19 @@ function RoleAppointmentRow({
|
|
|
194
201
|
</option>
|
|
195
202
|
))}
|
|
196
203
|
</Select>
|
|
204
|
+
{source === "Proposed" && appointed && (
|
|
205
|
+
<Button
|
|
206
|
+
variant="secondary"
|
|
207
|
+
size="sm"
|
|
208
|
+
className="shrink-0"
|
|
209
|
+
onClick={() => {
|
|
210
|
+
confirmingProposal.current = true;
|
|
211
|
+
onAppoint(role, appointed.mailboxId);
|
|
212
|
+
}}
|
|
213
|
+
>
|
|
214
|
+
{`Set as ${label}`}
|
|
215
|
+
</Button>
|
|
216
|
+
)}
|
|
197
217
|
{appointed && (
|
|
198
218
|
<>
|
|
199
219
|
<Input
|