@remit/web-client 0.0.105 → 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.105",
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
- act(() => buttonByText(/Move them to another folder/)?.click());
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
- act(() => buttonByText(/Move them to another folder/)?.click());
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={(destinationMailboxId) =>
273
- moveThenDelete(destinationMailboxId)
274
- }
283
+ onSelect={setDestinationId}
275
284
  onCreateFolder={createFolderIn}
276
- onCancel={() => setStage("choose-fate")}
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
  })();