@remit/web-client 0.0.104 → 0.0.106
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.106",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -150,6 +150,30 @@ const flush = async () => {
|
|
|
150
150
|
}
|
|
151
151
|
};
|
|
152
152
|
|
|
153
|
+
const pickRow = async (label: string) => {
|
|
154
|
+
const row = container.querySelector<HTMLButtonElement>(
|
|
155
|
+
`button[aria-label="Move to ${label}"]`,
|
|
156
|
+
);
|
|
157
|
+
assert.ok(row, `the picker offers ${label}`);
|
|
158
|
+
await act(async () => {
|
|
159
|
+
row.click();
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const confirmMove = async (label: RegExp) => {
|
|
164
|
+
const confirm = buttonByText(label);
|
|
165
|
+
assert.ok(confirm, `the confirm reads ${label}`);
|
|
166
|
+
await act(async () => {
|
|
167
|
+
confirm.click();
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const clickMoveToArchive = async () => {
|
|
172
|
+
act(() => buttonByText(/Move them to another folder/)?.click());
|
|
173
|
+
await pickRow("Archive");
|
|
174
|
+
await confirmMove(/^Move 3 emails to Archive$/);
|
|
175
|
+
};
|
|
176
|
+
|
|
153
177
|
describe("DeleteFolderDialog", () => {
|
|
154
178
|
it("renders nothing when closed", () => {
|
|
155
179
|
render({ open: false, folder: mailboxes[1] as RemitImapMailboxResponse });
|
|
@@ -210,6 +234,123 @@ describe("DeleteFolderDialog", () => {
|
|
|
210
234
|
assert.match(options, /Archive/);
|
|
211
235
|
});
|
|
212
236
|
|
|
237
|
+
it("opens a branch without moving anything, and commits only on confirm", async () => {
|
|
238
|
+
const nested = [
|
|
239
|
+
...mailboxes,
|
|
240
|
+
mailbox({ mailboxId: "work", fullPath: "Work" }),
|
|
241
|
+
mailbox({ mailboxId: "clients", fullPath: "Work/Clients" }),
|
|
242
|
+
];
|
|
243
|
+
const moveCalls: string[][] = [];
|
|
244
|
+
const movedOnServer = new Set<string>();
|
|
245
|
+
let deleted = false;
|
|
246
|
+
route = ({ url, method, body: reqBody }) => {
|
|
247
|
+
if (method === "DELETE") {
|
|
248
|
+
deleted = true;
|
|
249
|
+
return new Response(null, { status: 204 });
|
|
250
|
+
}
|
|
251
|
+
if (url.includes("/messages/move")) {
|
|
252
|
+
const body = JSON.parse(reqBody) as { messageIds: string[] };
|
|
253
|
+
moveCalls.push(body.messageIds);
|
|
254
|
+
for (const id of body.messageIds) movedOnServer.add(id);
|
|
255
|
+
return json({ moved: body.messageIds.length });
|
|
256
|
+
}
|
|
257
|
+
if (url.includes("/threads"))
|
|
258
|
+
return json(threadItems(["m1"].filter((id) => !movedOnServer.has(id))));
|
|
259
|
+
return json({ items: nested });
|
|
260
|
+
};
|
|
261
|
+
render({
|
|
262
|
+
open: true,
|
|
263
|
+
folder: mailboxes[1] as RemitImapMailboxResponse,
|
|
264
|
+
allMailboxes: nested,
|
|
265
|
+
});
|
|
266
|
+
act(() => buttonByText(/Move them to another folder/)?.click());
|
|
267
|
+
assert.equal(
|
|
268
|
+
buttonByText(/^Move 3 emails to/),
|
|
269
|
+
undefined,
|
|
270
|
+
"nothing is armed before a destination is picked",
|
|
271
|
+
);
|
|
272
|
+
await pickRow("Work");
|
|
273
|
+
await flush();
|
|
274
|
+
assert.deepEqual(moveCalls, [], "opening a branch starts no move");
|
|
275
|
+
assert.equal(deleted, false, "opening a branch deletes nothing");
|
|
276
|
+
assert.ok(
|
|
277
|
+
container.querySelector('button[aria-label="Move to Clients"]'),
|
|
278
|
+
"the tap opened the branch so the nested destination is reachable",
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
await pickRow("Clients");
|
|
282
|
+
await confirmMove(/^Move 3 emails to Clients$/);
|
|
283
|
+
await flush();
|
|
284
|
+
assert.deepEqual(moveCalls, [["m1"]], "the confirm commits the move");
|
|
285
|
+
assert.equal(deleted, true, "the emptied folder is deleted");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("arms rather than commits when a folder is created to move into", async () => {
|
|
289
|
+
const filed = mailbox({
|
|
290
|
+
mailboxId: "filed",
|
|
291
|
+
fullPath: "Filed",
|
|
292
|
+
syncStatus: "synced",
|
|
293
|
+
});
|
|
294
|
+
const moveCalls: string[][] = [];
|
|
295
|
+
let deleted = false;
|
|
296
|
+
let createdOnServer = false;
|
|
297
|
+
route = ({ url, method, body: reqBody }) => {
|
|
298
|
+
if (method === "DELETE") {
|
|
299
|
+
deleted = true;
|
|
300
|
+
return new Response(null, { status: 204 });
|
|
301
|
+
}
|
|
302
|
+
if (url.includes("/messages/move")) {
|
|
303
|
+
const body = JSON.parse(reqBody) as { messageIds: string[] };
|
|
304
|
+
moveCalls.push(body.messageIds);
|
|
305
|
+
return json({ moved: body.messageIds.length });
|
|
306
|
+
}
|
|
307
|
+
if (url.includes("/threads")) return json(threadItems(["m1"]));
|
|
308
|
+
if (method === "POST" && url.includes("/mailboxes")) {
|
|
309
|
+
createdOnServer = true;
|
|
310
|
+
return json(filed);
|
|
311
|
+
}
|
|
312
|
+
return json({
|
|
313
|
+
items: createdOnServer ? [...mailboxes, filed] : mailboxes,
|
|
314
|
+
});
|
|
315
|
+
};
|
|
316
|
+
render({ open: true, folder: mailboxes[1] as RemitImapMailboxResponse });
|
|
317
|
+
await flush();
|
|
318
|
+
act(() => buttonByText(/Move them to another folder/)?.click());
|
|
319
|
+
act(() =>
|
|
320
|
+
container
|
|
321
|
+
.querySelector<HTMLButtonElement>('button[aria-label="New folder"]')
|
|
322
|
+
?.click(),
|
|
323
|
+
);
|
|
324
|
+
const nameField = container.querySelector<HTMLInputElement>(
|
|
325
|
+
'input:not([aria-label="Filter folders"])',
|
|
326
|
+
);
|
|
327
|
+
assert.ok(nameField, "the new-folder form is open");
|
|
328
|
+
act(() => {
|
|
329
|
+
Object.getOwnPropertyDescriptor(
|
|
330
|
+
HTMLInputElement.prototype,
|
|
331
|
+
"value",
|
|
332
|
+
)?.set?.call(nameField, "Filed");
|
|
333
|
+
nameField.dispatchEvent(new Event("input", { bubbles: true }));
|
|
334
|
+
});
|
|
335
|
+
await act(async () => {
|
|
336
|
+
buttonByText(/^Create folder$/)?.click();
|
|
337
|
+
});
|
|
338
|
+
await flush();
|
|
339
|
+
assert.deepEqual(moveCalls, [], "a created folder starts no move");
|
|
340
|
+
assert.equal(deleted, false, "a created folder deletes nothing");
|
|
341
|
+
|
|
342
|
+
render({
|
|
343
|
+
open: true,
|
|
344
|
+
folder: mailboxes[1] as RemitImapMailboxResponse,
|
|
345
|
+
allMailboxes: [...mailboxes, filed],
|
|
346
|
+
});
|
|
347
|
+
assert.ok(
|
|
348
|
+
buttonByText(/^Move 3 emails to Filed$/),
|
|
349
|
+
"the created folder is armed as the destination",
|
|
350
|
+
);
|
|
351
|
+
assert.deepEqual(moveCalls, [], "arming it is still not a move");
|
|
352
|
+
});
|
|
353
|
+
|
|
213
354
|
it("deletes an empty folder and closes on success", async () => {
|
|
214
355
|
let closed = false;
|
|
215
356
|
route = ({ method }) => {
|
|
@@ -274,14 +415,7 @@ describe("DeleteFolderDialog", () => {
|
|
|
274
415
|
closed = true;
|
|
275
416
|
},
|
|
276
417
|
});
|
|
277
|
-
|
|
278
|
-
await act(async () => {
|
|
279
|
-
(
|
|
280
|
-
container.querySelector('button[aria-label="Move to Archive"]') as
|
|
281
|
-
| HTMLButtonElement
|
|
282
|
-
| undefined
|
|
283
|
-
)?.click();
|
|
284
|
-
});
|
|
418
|
+
await clickMoveToArchive();
|
|
285
419
|
await flush();
|
|
286
420
|
assert.deepEqual(moved.sort(), ["m1", "m2", "m3"]);
|
|
287
421
|
assert.equal(closed, true);
|
|
@@ -323,30 +457,12 @@ describe("DeleteFolderDialog", () => {
|
|
|
323
457
|
closed = true;
|
|
324
458
|
},
|
|
325
459
|
});
|
|
326
|
-
|
|
327
|
-
await act(async () => {
|
|
328
|
-
(
|
|
329
|
-
container.querySelector('button[aria-label="Move to Archive"]') as
|
|
330
|
-
| HTMLButtonElement
|
|
331
|
-
| undefined
|
|
332
|
-
)?.click();
|
|
333
|
-
});
|
|
460
|
+
await clickMoveToArchive();
|
|
334
461
|
await flush();
|
|
335
462
|
assert.match(container.textContent ?? "", /stay moved/);
|
|
336
463
|
assert.equal(closed, false);
|
|
337
464
|
});
|
|
338
465
|
|
|
339
|
-
const clickMoveToArchive = async () => {
|
|
340
|
-
act(() => buttonByText(/Move them to another folder/)?.click());
|
|
341
|
-
await act(async () => {
|
|
342
|
-
(
|
|
343
|
-
container.querySelector('button[aria-label="Move to Archive"]') as
|
|
344
|
-
| HTMLButtonElement
|
|
345
|
-
| undefined
|
|
346
|
-
)?.click();
|
|
347
|
-
});
|
|
348
|
-
};
|
|
349
|
-
|
|
350
466
|
it("iterates multiple batches and deletes only after the folder drains", async () => {
|
|
351
467
|
let deleted = false;
|
|
352
468
|
const moveCalls: string[][] = [];
|
|
@@ -54,6 +54,7 @@ export function DeleteFolderDialog({
|
|
|
54
54
|
const [stage, setStage] = useState<FateStage>(() =>
|
|
55
55
|
initialStage(folder.messageCount),
|
|
56
56
|
);
|
|
57
|
+
const [destinationId, setDestinationId] = useState<string>();
|
|
57
58
|
const { createFolderIn } = useCreateMailbox(accountId);
|
|
58
59
|
const translator = useFolderLabelTranslator();
|
|
59
60
|
const {
|
|
@@ -76,6 +77,11 @@ export function DeleteFolderDialog({
|
|
|
76
77
|
reset();
|
|
77
78
|
}, [open, folder.messageCount, reset]);
|
|
78
79
|
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!open) return;
|
|
82
|
+
setDestinationId(undefined);
|
|
83
|
+
}, [open]);
|
|
84
|
+
|
|
79
85
|
useEffect(() => cancel, [cancel]);
|
|
80
86
|
|
|
81
87
|
const handleClose = useCallback(() => {
|
|
@@ -94,6 +100,10 @@ export function DeleteFolderDialog({
|
|
|
94
100
|
[mailboxes, appointments, folder.mailboxId, translator],
|
|
95
101
|
);
|
|
96
102
|
|
|
103
|
+
const destination = destinations.find(
|
|
104
|
+
(option) => option.id === destinationId,
|
|
105
|
+
);
|
|
106
|
+
|
|
97
107
|
const name = useMemo(
|
|
98
108
|
() =>
|
|
99
109
|
labelForMailbox(
|
|
@@ -265,18 +275,37 @@ export function DeleteFolderDialog({
|
|
|
265
275
|
Move the {emailCount(folder.messageCount)} in{" "}
|
|
266
276
|
<strong className="text-fg">{name}</strong> to:
|
|
267
277
|
</p>
|
|
268
|
-
<div className="min-h-0 flex-1">
|
|
278
|
+
<div className="flex min-h-0 flex-1 overflow-hidden">
|
|
269
279
|
<FolderTreePicker
|
|
270
280
|
folders={destinations}
|
|
281
|
+
selectedId={destinationId}
|
|
271
282
|
delimiter={mailboxes[0]?.hierarchyDelimiter ?? "/"}
|
|
272
|
-
onSelect={
|
|
273
|
-
moveThenDelete(destinationMailboxId)
|
|
274
|
-
}
|
|
283
|
+
onSelect={setDestinationId}
|
|
275
284
|
onCreateFolder={createFolderIn}
|
|
276
|
-
onCancel={() =>
|
|
285
|
+
onCancel={() => {
|
|
286
|
+
setDestinationId(undefined);
|
|
287
|
+
setStage("choose-fate");
|
|
288
|
+
}}
|
|
277
289
|
labels={{ filterPlaceholder: "Move emails to…" }}
|
|
278
290
|
/>
|
|
279
291
|
</div>
|
|
292
|
+
{/* Tapping a folder both picks it and opens it, so the move and the
|
|
293
|
+
delete wait for this confirmation — otherwise the first tap on the way
|
|
294
|
+
to a nested destination would empty the folder and remove it, with no
|
|
295
|
+
undo. */}
|
|
296
|
+
{destination && (
|
|
297
|
+
<footer className="shrink-0 border-t border-line p-2">
|
|
298
|
+
<Button
|
|
299
|
+
variant="danger"
|
|
300
|
+
onClick={() => moveThenDelete(destination.id)}
|
|
301
|
+
className="h-11 w-full font-semibold"
|
|
302
|
+
>
|
|
303
|
+
<span className="truncate">
|
|
304
|
+
{`Move ${emailCount(folder.messageCount)} to ${destination.label}`}
|
|
305
|
+
</span>
|
|
306
|
+
</Button>
|
|
307
|
+
</footer>
|
|
308
|
+
)}
|
|
280
309
|
</div>
|
|
281
310
|
);
|
|
282
311
|
})();
|
|
@@ -1,32 +1,3 @@
|
|
|
1
|
-
export const threadKeys = {
|
|
2
|
-
all: ["threads"] as const,
|
|
3
|
-
|
|
4
|
-
lists: () => [...threadKeys.all, "list"] as const,
|
|
5
|
-
|
|
6
|
-
list: (
|
|
7
|
-
mailboxId: string,
|
|
8
|
-
filters?: { filter?: string; page?: number; cursor?: string },
|
|
9
|
-
) => [...threadKeys.lists(), mailboxId, filters] as const,
|
|
10
|
-
|
|
11
|
-
details: () => [...threadKeys.all, "detail"] as const,
|
|
12
|
-
|
|
13
|
-
detail: (threadId: string) => [...threadKeys.details(), threadId] as const,
|
|
14
|
-
|
|
15
|
-
messages: (threadId: string) =>
|
|
16
|
-
[...threadKeys.detail(threadId), "messages"] as const,
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
export const mailboxKeys = {
|
|
20
|
-
all: ["mailboxes"] as const,
|
|
21
|
-
|
|
22
|
-
lists: () => [...mailboxKeys.all, "list"] as const,
|
|
23
|
-
|
|
24
|
-
list: (accountId: string) => [...mailboxKeys.lists(), accountId] as const,
|
|
25
|
-
|
|
26
|
-
detail: (mailboxId: string) =>
|
|
27
|
-
[...mailboxKeys.all, "detail", mailboxId] as const,
|
|
28
|
-
};
|
|
29
|
-
|
|
30
1
|
export const messageKeys = {
|
|
31
2
|
all: ["messages"] as const,
|
|
32
3
|
|