@remit/web-client 0.0.34 → 0.0.36

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.34",
3
+ "version": "0.0.36",
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": {
@@ -423,7 +423,10 @@ export const MessageList = ({
423
423
  const handleRowSelect = useCallback(
424
424
  (messageId: string, modifiers: SelectionModifiers): boolean => {
425
425
  if (modifiers.shiftKey) {
426
- selectRange(orderedIds, messageId);
426
+ // The open/focused row is the fallback origin when the stored anchor
427
+ // has been filtered or searched out of the visible list, so the first
428
+ // shift-click still ranges from where the user is (#142, #144).
429
+ selectRange(orderedIds, messageId, focusedMessageId);
427
430
  return true;
428
431
  }
429
432
  if (modifiers.metaKey || modifiers.ctrlKey) {
@@ -438,7 +441,14 @@ export const MessageList = ({
438
441
  setAnchor(messageId);
439
442
  return false;
440
443
  },
441
- [orderedIds, selectRange, toggleCheck, clearSelection, setAnchor],
444
+ [
445
+ orderedIds,
446
+ focusedMessageId,
447
+ selectRange,
448
+ toggleCheck,
449
+ clearSelection,
450
+ setAnchor,
451
+ ],
442
452
  );
443
453
 
444
454
  // Open the delete confirmation for an explicit set of ids. All delete
@@ -4,10 +4,43 @@ import {
4
4
  computeRange,
5
5
  intersectSelectedIds,
6
6
  nextFocusId,
7
+ resolveRangeAnchor,
7
8
  } from "./useSelection.js";
8
9
 
9
10
  const ids = ["a", "b", "c", "d", "e"];
10
11
 
12
+ // Mirrors the hook's `selectRange` state transition (anchor + selection) using
13
+ // the same pure helpers it runs, so the mouse/keyboard sequences below exercise
14
+ // the real logic without an interactive DOM. Returns the next {selected, anchor}.
15
+ const applyRange = (
16
+ state: { selected: Set<string>; anchor: string | undefined },
17
+ orderedIds: string[],
18
+ targetId: string,
19
+ fallbackAnchor?: string,
20
+ ): { selected: Set<string>; anchor: string | undefined } => {
21
+ const anchor = resolveRangeAnchor(
22
+ orderedIds,
23
+ state.anchor,
24
+ fallbackAnchor,
25
+ targetId,
26
+ );
27
+ const range = computeRange(orderedIds, anchor, targetId);
28
+ const selected = new Set(state.selected);
29
+ for (const id of range) selected.add(id);
30
+ return { selected, anchor };
31
+ };
32
+
33
+ // Mirrors a cmd/ctrl-click: toggle membership and re-anchor on the clicked row.
34
+ const applyToggle = (
35
+ state: { selected: Set<string>; anchor: string | undefined },
36
+ targetId: string,
37
+ ): { selected: Set<string>; anchor: string | undefined } => {
38
+ const selected = new Set(state.selected);
39
+ if (selected.has(targetId)) selected.delete(targetId);
40
+ else selected.add(targetId);
41
+ return { selected, anchor: targetId };
42
+ };
43
+
11
44
  describe("computeRange", () => {
12
45
  test("forward range: anchor above target selects the inclusive slice", () => {
13
46
  assert.deepStrictEqual(computeRange(ids, "b", "d"), ["b", "c", "d"]);
@@ -44,6 +77,81 @@ describe("computeRange", () => {
44
77
  });
45
78
  });
46
79
 
80
+ describe("resolveRangeAnchor", () => {
81
+ test("keeps a still-visible stored anchor so consecutive shift-clicks extend from it", () => {
82
+ assert.strictEqual(resolveRangeAnchor(ids, "b", undefined, "d"), "b");
83
+ });
84
+
85
+ test("a stored anchor no longer visible falls back to the open/focused row", () => {
86
+ // The stored anchor "z" was filtered/searched out of the visible list; the
87
+ // open row "b" is still visible, so the range anchors there.
88
+ assert.strictEqual(resolveRangeAnchor(ids, "z", "b", "d"), "b");
89
+ });
90
+
91
+ test("with neither a visible stored anchor nor a visible fallback, the target anchors itself", () => {
92
+ assert.strictEqual(resolveRangeAnchor(ids, "z", "y", "d"), "d");
93
+ });
94
+
95
+ test("no stored anchor and no fallback selects just the target", () => {
96
+ assert.strictEqual(resolveRangeAnchor(ids, undefined, undefined, "c"), "c");
97
+ });
98
+
99
+ test("a stored anchor beats the fallback while it stays visible", () => {
100
+ assert.strictEqual(resolveRangeAnchor(ids, "a", "c", "e"), "a");
101
+ });
102
+ });
103
+
104
+ describe("shift-click range over a filtered/search-narrowed list (#142, #144)", () => {
105
+ test("shift-click range with an active filter builds the range within the visible rows", () => {
106
+ // Full inbox is a..h; the "Automated" filter leaves only these rows.
107
+ const filtered = ["b", "d", "f", "g"];
108
+ // The open row "d" is visible; nothing selected yet, no stored anchor.
109
+ let state = {
110
+ selected: new Set<string>(),
111
+ anchor: undefined as string | undefined,
112
+ };
113
+ // Shift-click "g": ranges from the open/focused row "d" to "g".
114
+ state = applyRange(state, filtered, "g", "d");
115
+ assert.deepStrictEqual([...state.selected], ["d", "f", "g"]);
116
+ assert.strictEqual(state.anchor, "d");
117
+ });
118
+
119
+ test("range where the anchor left the visible set re-anchors instead of no-oping", () => {
120
+ const filtered = ["b", "d", "f", "g"];
121
+ // Stored anchor "a" was selected before the filter narrowed the list and is
122
+ // no longer visible; there is no open/focused fallback row.
123
+ let state = {
124
+ selected: new Set<string>(),
125
+ anchor: "a" as string | undefined,
126
+ };
127
+ // First shift-click adopts the clicked row as the new visible anchor.
128
+ state = applyRange(state, filtered, "f");
129
+ assert.deepStrictEqual([...state.selected], ["f"]);
130
+ assert.strictEqual(state.anchor, "f");
131
+ // Second shift-click now builds a real range from that adopted anchor.
132
+ state = applyRange(state, filtered, "b");
133
+ assert.deepStrictEqual([...state.selected].sort(), ["b", "d", "f"]);
134
+ assert.strictEqual(state.anchor, "f");
135
+ });
136
+
137
+ test("multi-select across a search-results list: cmd-click then shift-click", () => {
138
+ // Search "npm" yields these results; the inbox anchor is gone from this set.
139
+ const results = ["m1", "m2", "m3", "m4", "m5"];
140
+ let state = {
141
+ selected: new Set<string>(),
142
+ anchor: "inbox-row" as string | undefined,
143
+ };
144
+ // Cmd-click "m2": toggles it in and re-anchors on it (a visible row).
145
+ state = applyToggle(state, "m2");
146
+ assert.deepStrictEqual([...state.selected], ["m2"]);
147
+ assert.strictEqual(state.anchor, "m2");
148
+ // Shift-click "m4": ranges from the cmd-clicked anchor across the results.
149
+ state = applyRange(state, results, "m4");
150
+ assert.deepStrictEqual([...state.selected].sort(), ["m2", "m3", "m4"]);
151
+ assert.strictEqual(state.anchor, "m2");
152
+ });
153
+ });
154
+
47
155
  describe("nextFocusId", () => {
48
156
  test("moves down one row", () => {
49
157
  assert.strictEqual(nextFocusId(ids, "b", 1), "c");
@@ -37,11 +37,19 @@ interface UseSelectionReturn {
37
37
  /** Toggle selection for all items */
38
38
  toggleAll: (ids: string[]) => void;
39
39
  /**
40
- * Add the contiguous range of ids from the current anchor to `targetId`
41
- * (inclusive) to the selection, using `orderedIds` for display order. If
42
- * no anchor exists, selects only `targetId` and makes it the anchor.
40
+ * Add the contiguous range of ids from the anchor to `targetId` (inclusive)
41
+ * to the selection, using `orderedIds` for display order. The anchor is the
42
+ * stored one when it is still visible in `orderedIds`; otherwise
43
+ * `fallbackAnchor` when that is visible (the open/focused row); otherwise
44
+ * `targetId`. Whatever anchors the range becomes the new stored anchor, so a
45
+ * filtered or search-narrowed list can still build a range within what's
46
+ * visible (#142, #144).
43
47
  */
44
- selectRange: (orderedIds: string[], targetId: string) => void;
48
+ selectRange: (
49
+ orderedIds: string[],
50
+ targetId: string,
51
+ fallbackAnchor?: string,
52
+ ) => void;
45
53
  /**
46
54
  * Set the range anchor without changing the selection set. Used by a plain
47
55
  * click that navigates but should seed the anchor for a later shift-click.
@@ -85,6 +93,34 @@ export const computeRange = (
85
93
  return orderedIds.slice(start, end + 1);
86
94
  };
87
95
 
96
+ /**
97
+ * Resolve which id a shift-range selection anchors from, given the stored
98
+ * anchor and the ids currently visible (`orderedIds`). Pure so the
99
+ * filtered/search anchor behavior can be unit-tested without a DOM.
100
+ *
101
+ * - The stored anchor wins while it is still visible — consecutive shift-clicks
102
+ * keep extending from the same origin (Apple Mail / Gmail).
103
+ * - Once the stored anchor leaves the visible set (filtered out, or a search
104
+ * changed the list), it can't anchor a range in that set, so fall back to
105
+ * `fallbackAnchor` (the open/focused row) when it is visible.
106
+ * - With neither available, the target anchors itself: the clicked row is
107
+ * selected alone and becomes the origin for the next shift-click.
108
+ */
109
+ export const resolveRangeAnchor = (
110
+ orderedIds: string[],
111
+ storedAnchor: string | undefined,
112
+ fallbackAnchor: string | undefined,
113
+ targetId: string,
114
+ ): string => {
115
+ if (storedAnchor !== undefined && orderedIds.includes(storedAnchor)) {
116
+ return storedAnchor;
117
+ }
118
+ if (fallbackAnchor !== undefined && orderedIds.includes(fallbackAnchor)) {
119
+ return fallbackAnchor;
120
+ }
121
+ return targetId;
122
+ };
123
+
88
124
  /**
89
125
  * The ids from `selectedIds` that are still present in `currentIds` — the
90
126
  * survivor set after a list refresh. Only ever narrows: an id absent from
@@ -211,9 +247,15 @@ export const useSelection = <T>(
211
247
  }, []);
212
248
 
213
249
  const selectRange = useCallback(
214
- (orderedIds: string[], targetId: string) => {
250
+ (orderedIds: string[], targetId: string, fallbackAnchor?: string) => {
251
+ const effectiveAnchor = resolveRangeAnchor(
252
+ orderedIds,
253
+ anchorId,
254
+ fallbackAnchor,
255
+ targetId,
256
+ );
215
257
  setSelectedIds((prev) => {
216
- const range = computeRange(orderedIds, anchorId, targetId);
258
+ const range = computeRange(orderedIds, effectiveAnchor, targetId);
217
259
  if (range.length === 0) return prev;
218
260
  const next = new Set(prev);
219
261
  for (const id of range) {
@@ -221,10 +263,12 @@ export const useSelection = <T>(
221
263
  }
222
264
  return next;
223
265
  });
224
- // Keep the original anchor so subsequent shift-clicks extend from the
225
- // same origin (Apple Mail / Gmail behavior). Only seed the anchor when
226
- // none exists yet.
227
- setAnchorId((prev) => prev ?? targetId);
266
+ // Whatever anchored the range becomes the stored anchor. A still-visible
267
+ // stored anchor resolves to itself (unchanged), so consecutive
268
+ // shift-clicks keep extending from the same origin; a stored anchor that
269
+ // left the visible set is replaced by the row the range actually used, so
270
+ // a filtered/search-narrowed list can build a range within what's visible.
271
+ setAnchorId(effectiveAnchor);
228
272
  },
229
273
  [anchorId],
230
274
  );
@@ -4,7 +4,6 @@ import {
4
4
  BULK_DELETE_CHUNK_SIZE,
5
5
  chunkIds,
6
6
  countMatches,
7
- type DeleteBatchResult,
8
7
  type FetchIdsPageResult,
9
8
  honestProgress,
10
9
  resolveSelectionAfterDelete,
@@ -90,36 +89,16 @@ describe("runChunkedDelete", () => {
90
89
  assert.deepEqual(outcome.failedIds, []);
91
90
  });
92
91
 
93
- test("the count deleted matches exactly what the batches reported succeeded", async () => {
92
+ test("a returned batch counts every id in it as accepted", async () => {
94
93
  const input = ids(5);
95
94
  const outcome = await runChunkedDelete(
96
95
  input,
97
- async (chunk) => ({
98
- successCount: chunk.length - 2,
99
- failureCount: 2,
100
- failedIds: chunk.slice(0, 2),
101
- }),
102
- noopProgress,
103
- neverCancelled,
104
- );
105
- assert.equal(outcome.done, 3);
106
- assert.deepEqual(outcome.failedIds, input.slice(0, 2));
107
- });
108
-
109
- test("partial failure: failed ids are reported, not rolled into done", async () => {
110
- const input = ids(3);
111
- const outcome = await runChunkedDelete(
112
- input,
113
- async (chunk) => ({
114
- successCount: 2,
115
- failureCount: 1,
116
- failedIds: [chunk[1]],
117
- }),
96
+ async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
118
97
  noopProgress,
119
98
  neverCancelled,
120
99
  );
121
- assert.equal(outcome.done, 2);
122
- assert.deepEqual(outcome.failedIds, [input[1]]);
100
+ assert.equal(outcome.done, input.length);
101
+ assert.deepEqual(outcome.failedIds, []);
123
102
  });
124
103
 
125
104
  test("cancelling mid-run folds every unreached chunk into failedIds", async () => {
@@ -264,27 +243,6 @@ describe("runPredicateDelete", () => {
264
243
  assert.equal(outcome.done, 4);
265
244
  });
266
245
 
267
- test("partial failure across pages accumulates failedIds without rolling into done", async () => {
268
- const fetch = pagedFetcher([
269
- { ids: ["a", "b"], continuationToken: "t1" },
270
- { ids: ["c"] },
271
- ]);
272
- const outcome = await runPredicateDelete(
273
- fetch,
274
- 3,
275
- async (chunk): Promise<DeleteBatchResult> => {
276
- if (chunk.includes("b")) {
277
- return { successCount: 1, failureCount: 1, failedIds: ["b"] };
278
- }
279
- return { successCount: chunk.length, failureCount: 0 };
280
- },
281
- noopProgress,
282
- neverCancelled,
283
- );
284
- assert.equal(outcome.done, 2);
285
- assert.deepEqual(outcome.failedIds, ["b"]);
286
- });
287
-
288
246
  test("cancelling mid-delete stops paging without inventing failedIds for unfetched pages", async () => {
289
247
  let fetchCalls = 0;
290
248
  let cancelled = false;
@@ -409,7 +367,7 @@ describe("resolveSelectionAfterDelete", () => {
409
367
  );
410
368
  });
411
369
 
412
- test("explicit failures stay selected for a precise retry, even alongside a clean stop", () => {
370
+ test("unreached ids stay selected for a precise retry, even alongside a clean stop", () => {
413
371
  assert.deepEqual(
414
372
  resolveSelectionAfterDelete({
415
373
  done: 3072,
@@ -431,7 +389,7 @@ describe("resolveSelectionAfterDelete", () => {
431
389
  );
432
390
  });
433
391
 
434
- test("an infra failure with no explicit per-item failures still keeps selection mode open", () => {
392
+ test("an infra failure with nothing left unreached still keeps selection mode open", () => {
435
393
  assert.deepEqual(
436
394
  resolveSelectionAfterDelete({
437
395
  done: 0,
@@ -3,9 +3,16 @@
3
3
  *
4
4
  * The bulk endpoint caps a call at 100 ids (`BulkMessageInput.messageIds`,
5
5
  * `@maxItems(100)`); a delete over a search result can run to thousands. These
6
- * helpers sequence the calls and tally what actually happened rather than what
7
- * was requested, so a caller can always ask "what's still not deleted" instead
8
- * of trusting the request it sent.
6
+ * helpers sequence the calls and tally which ids the run actually reached, so a
7
+ * caller can always ask "what's still not deleted" instead of trusting the
8
+ * request it sent.
9
+ *
10
+ * The endpoint enqueues the IMAP delete and returns; it does not apply it. So a
11
+ * returned call means every id in it was accepted for deletion, not that the
12
+ * mail server removed it — there is no per-id success/failure in the response
13
+ * to read. The only failure this layer can observe is a thrown call: an
14
+ * infrastructure failure (auth, the write, or the enqueue) that takes out the
15
+ * whole batch and stops the run.
9
16
  *
10
17
  * Pure, framework-agnostic, and independently testable — no React, no fetch.
11
18
  * `useEscalatedDelete.ts` supplies the real `DeleteBatch`/`FetchIdsPage`
@@ -46,7 +53,6 @@ export const chunkIds = (
46
53
  export interface DeleteBatchResult {
47
54
  successCount: number;
48
55
  failureCount: number;
49
- failedIds?: string[];
50
56
  }
51
57
 
52
58
  /** Sends one bulk-delete call for the given ids (≤100). */
@@ -64,9 +70,12 @@ export interface BulkDeleteOutcome {
64
70
  /** Ids confirmed deleted (server-reported success, not merely "sent"). */
65
71
  done: number;
66
72
  /**
67
- * Ids not confirmed deleted: explicit per-batch failures. Does NOT include
68
- * ids from chunks the run never reached (cancelled or stopped by an
69
- * error) see the per-function doc for how each source handles that.
73
+ * Ids the run did not reach and are therefore not confirmed deleted: on
74
+ * cancellation or a thrown error, the chunks the bounded run never attempted
75
+ * (see `runChunkedDelete`). Empty in the predicate case, which re-resolves on
76
+ * every run rather than handing back a remainder (see `runPredicateDelete`).
77
+ * There is no per-id failure source: a returned batch call counts every id in
78
+ * it as accepted (see the module header).
70
79
  */
71
80
  failedIds: string[];
72
81
  cancelled: boolean;
@@ -106,9 +115,7 @@ export const runChunkedDelete = async (
106
115
  onProgress({ done, total });
107
116
  return { done, failedIds, cancelled: false, error: attempted.error };
108
117
  }
109
- const failedInChunk = new Set(attempted.value.failedIds ?? []);
110
- done += chunk.length - failedInChunk.size;
111
- failedIds.push(...chunk.filter((id) => failedInChunk.has(id)));
118
+ done += chunk.length;
112
119
  onProgress({ done, total });
113
120
  }
114
121
 
@@ -165,9 +172,7 @@ export const runPredicateDelete = async (
165
172
  if (!attempted.ok) {
166
173
  return { done, failedIds, cancelled: false, error: attempted.error };
167
174
  }
168
- const failedInPage = new Set(attempted.value.failedIds ?? []);
169
- done += page.ids.length - failedInPage.size;
170
- failedIds.push(...page.ids.filter((id) => failedInPage.has(id)));
175
+ done += page.ids.length;
171
176
  onProgress({ done, total });
172
177
  }
173
178
 
@@ -250,11 +255,10 @@ export interface SelectionAfterDelete {
250
255
 
251
256
  /**
252
257
  * What a caller does with selection once a run ends, for any reason. Every id
253
- * not confirmed deleted — an explicit failure, or a chunk the run never
254
- * reached because it was stopped or errored — belongs in `retryIds`: it is
255
- * exactly what Retry should resend, and it is what stays selected so the
256
- * count on screen never claims more was deleted than actually was (#92
257
- * requirement 8).
258
+ * not confirmed deleted — a chunk the bounded run never reached because it was
259
+ * stopped or errored — belongs in `retryIds`: it is exactly what Retry should
260
+ * resend, and it is what stays selected so the count on screen never claims
261
+ * more was deleted than actually was (#92 requirement 8).
258
262
  */
259
263
  export const resolveSelectionAfterDelete = (
260
264
  outcome: DeleteRunOutcome,