@remit/ui 0.0.121 → 0.0.122

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/ui",
3
- "version": "0.0.121",
3
+ "version": "0.0.122",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -3,7 +3,11 @@ import { describe, it } from "node:test";
3
3
  import { createElement } from "react";
4
4
  import { renderToString } from "react-dom/server";
5
5
  import type { MatchCount, StepId, WizardDraft } from "../lib/wizard-steps.js";
6
- import { stepsFor } from "../lib/wizard-steps.js";
6
+ import {
7
+ matchDoorsFor,
8
+ stepBlockedReason,
9
+ stepsFor,
10
+ } from "../lib/wizard-steps.js";
7
11
  import {
8
12
  type RuleClause,
9
13
  UNCOUNTABLE_PREDICATE_REASON,
@@ -57,6 +61,7 @@ const sample = { messages, count: counted(2), label: "Your selection" };
57
61
  const matchProps = {
58
62
  selectedCount: 2,
59
63
  mode: "selected" as const,
64
+ accountId: "acc-personal",
60
65
  onModeChange: noop,
61
66
  onSemanticFallback: noop,
62
67
  sample,
@@ -291,6 +296,42 @@ describe("MatchStepBody", () => {
291
296
  });
292
297
  });
293
298
 
299
+ /**
300
+ * Starred, and any other surface whose rows span accounts (#523). Both widened
301
+ * doors are counted through a preview one account answers, so offering them
302
+ * walks the user to a review waiting on a count nobody can take, with Back the
303
+ * only way on. They are withheld, and the step says why.
304
+ */
305
+ describe("MatchStepBody with no single account", () => {
306
+ const spanning = { ...matchProps, accountId: undefined };
307
+
308
+ it("offers the ticked rows alone, and states the restriction", () => {
309
+ const html = text(renderToString(createElement(MatchStepBody, spanning)));
310
+ assert.match(html, /These 2 messages/);
311
+ assert.doesNotMatch(html, /Similar to these/);
312
+ assert.doesNotMatch(html, /Its properties/);
313
+ assert.match(html, /only works within one account/);
314
+ });
315
+
316
+ it("leaves a review that commits rather than one pinned on the count", () => {
317
+ assert.deepEqual([...matchDoorsFor(undefined)], ["selected"]);
318
+ // The only door left is its own count, so nothing about the match is still
319
+ // settling by the time the review is reached.
320
+ const blockedReason = stepBlockedReason("review", draft(), counted(2));
321
+ assert.equal(blockedReason, undefined);
322
+ const html = text(
323
+ renderToString(
324
+ createElement(
325
+ SelectionWizard,
326
+ wizardProps({ step: "review", match: spanning, blockedReason }),
327
+ ),
328
+ ),
329
+ );
330
+ assert.doesNotMatch(html, /Counting matches/);
331
+ assert.match(html, /2 messages/);
332
+ });
333
+ });
334
+
294
335
  describe("PropertiesStepBody", () => {
295
336
  it("renders the rule as the shipped chips, with the join between them", () => {
296
337
  const html = renderToString(
@@ -21,6 +21,7 @@ import { Fragment, type ReactNode, useEffect, useId, useRef } from "react";
21
21
  import { cn } from "../lib/cn.js";
22
22
  import {
23
23
  backExits,
24
+ crossAccountMatchReason,
24
25
  ESCALATED_MATCH_HINT,
25
26
  ESCALATED_REVIEW_WARNING,
26
27
  ESCALATED_SCOPE_FALLBACK,
@@ -30,6 +31,7 @@ import {
30
31
  type MatchMode,
31
32
  matchDoorHint,
32
33
  matchDoorLabel,
34
+ matchDoorsFor,
33
35
  matchPhrase,
34
36
  matchSummary,
35
37
  type RunCopy,
@@ -364,6 +366,13 @@ export function FooterNav({
364
366
  export interface MatchStepProps {
365
367
  selectedCount: number;
366
368
  mode: MatchMode;
369
+ /**
370
+ * The one account this selection belongs to, absent when it spans several
371
+ * (#523). Both widened doors are counted through a preview that account
372
+ * answers, so without one they are withheld and the step states the
373
+ * restriction — the ticked rows are their own match and stay on offer.
374
+ */
375
+ accountId?: string;
367
376
  /**
368
377
  * Answers the door. Typed to the three doors rather than to every mode, so
369
378
  * no driver can set `escalated` from a screen — the list escalates a
@@ -396,6 +405,7 @@ export interface MatchStepProps {
396
405
  export function MatchStepBody({
397
406
  selectedCount,
398
407
  mode,
408
+ accountId,
399
409
  onModeChange,
400
410
  semanticUnavailable,
401
411
  semanticErrorDetail,
@@ -419,44 +429,55 @@ export function MatchStepBody({
419
429
  </>
420
430
  );
421
431
  }
432
+ const doors = matchDoorsFor(accountId);
433
+ const widened = doors.length > 1;
422
434
  return (
423
435
  <>
424
436
  <div className="space-y-2">
437
+ {!widened && (
438
+ <p className="px-1 text-2xs text-warning">
439
+ {crossAccountMatchReason}
440
+ </p>
441
+ )}
425
442
  <ChoiceCard
426
443
  selected={mode === "selected"}
427
444
  onSelect={() => onModeChange("selected")}
428
445
  title={matchDoorLabel("selected", selectedCount)}
429
446
  description={matchDoorHint("selected")}
430
447
  />
431
- <ChoiceCard
432
- selected={mode === "similar"}
433
- unavailable={semanticUnavailable}
434
- onSelect={
435
- semanticUnavailable
436
- ? onSemanticFallback
437
- : () => onModeChange("similar")
438
- }
439
- title={matchDoorLabel("similar", selectedCount)}
440
- description={matchDoorHint("similar")}
441
- >
442
- {semanticErrorDetail && (
443
- <p role="status" className="px-1 text-2xs text-danger">
444
- Couldn't find similar messages: {semanticErrorDetail}
445
- </p>
446
- )}
447
- {semanticFallbackTaken && (
448
- <p role="status" className="px-1 text-2xs text-fg-subtle">
449
- Similar-mail matching is unavailable right now — matching on the
450
- senders instead.
451
- </p>
452
- )}
453
- </ChoiceCard>
454
- <ChoiceCard
455
- selected={mode === "properties"}
456
- onSelect={() => onModeChange("properties")}
457
- title={matchDoorLabel("properties", selectedCount)}
458
- description={matchDoorHint("properties")}
459
- />
448
+ {widened && (
449
+ <ChoiceCard
450
+ selected={mode === "similar"}
451
+ unavailable={semanticUnavailable}
452
+ onSelect={
453
+ semanticUnavailable
454
+ ? onSemanticFallback
455
+ : () => onModeChange("similar")
456
+ }
457
+ title={matchDoorLabel("similar", selectedCount)}
458
+ description={matchDoorHint("similar")}
459
+ >
460
+ {semanticErrorDetail && (
461
+ <p role="status" className="px-1 text-2xs text-danger">
462
+ Couldn't find similar messages: {semanticErrorDetail}
463
+ </p>
464
+ )}
465
+ {semanticFallbackTaken && (
466
+ <p role="status" className="px-1 text-2xs text-fg-subtle">
467
+ Similar-mail matching is unavailable right now — matching on the
468
+ senders instead.
469
+ </p>
470
+ )}
471
+ </ChoiceCard>
472
+ )}
473
+ {widened && (
474
+ <ChoiceCard
475
+ selected={mode === "properties"}
476
+ onSelect={() => onModeChange("properties")}
477
+ title={matchDoorLabel("properties", selectedCount)}
478
+ description={matchDoorHint("properties")}
479
+ />
480
+ )}
460
481
  </div>
461
482
  <div className="mt-4">
462
483
  <SelectionSample {...sample} />
@@ -9,6 +9,7 @@ import {
9
9
  backExits,
10
10
  clauseSentence,
11
11
  clauseWords,
12
+ crossAccountMatchReason,
12
13
  ESCALATED_MATCH_HINT,
13
14
  ESCALATED_REVIEW_WARNING,
14
15
  escalatedMatchLabel,
@@ -16,6 +17,7 @@ import {
16
17
  type MatchMode,
17
18
  matchDoorHint,
18
19
  matchDoorLabel,
20
+ matchDoorsFor,
19
21
  matchPhrase,
20
22
  matchSummary,
21
23
  type RunState,
@@ -683,6 +685,15 @@ describe("sample and door vocabulary", () => {
683
685
  assert.match(ESCALATED_REVIEW_WARNING, /by the time it runs/);
684
686
  });
685
687
 
688
+ it("withholds the widened doors where there is no single account", () => {
689
+ assert.deepEqual(
690
+ [...matchDoorsFor("acc-personal")],
691
+ ["selected", "similar", "properties"],
692
+ );
693
+ assert.deepEqual([...matchDoorsFor(undefined)], ["selected"]);
694
+ assert.match(crossAccountMatchReason, /within one account/);
695
+ });
696
+
686
697
  it("names each door and what it does", () => {
687
698
  assert.equal(matchDoorLabel("selected", 3), "These 3 messages");
688
699
  assert.equal(matchDoorLabel("similar", 3), "Similar to these 3");
@@ -223,6 +223,27 @@ export const crossAccountRuleReason =
223
223
  export const crossAccountDestinationReason =
224
224
  "A destination only works within one account — clear the selection, or pick messages from a single account.";
225
225
 
226
+ /**
227
+ * The same restriction on the step that asks what the action applies to (#523).
228
+ * Both widened doors are resolved by a preview one account answers, so a
229
+ * selection with no single account has nothing to count them against. The ticked
230
+ * rows are their own match and are unaffected.
231
+ */
232
+ export const crossAccountMatchReason =
233
+ "Matching beyond the messages you picked only works within one account — clear the selection, or pick messages from a single account.";
234
+
235
+ /**
236
+ * The doors the match step offers. Withholding the two widened ones is what
237
+ * keeps a selection spanning accounts off a review that waits on a count nobody
238
+ * can take; the step says why in `crossAccountMatchReason`.
239
+ */
240
+ export const matchDoorsFor = (
241
+ accountId: string | undefined,
242
+ ): readonly MatchDoor[] =>
243
+ accountId === undefined
244
+ ? ["selected"]
245
+ : ["selected", "similar", "properties"];
246
+
226
247
  /**
227
248
  * The match reached nothing, so committing it would report a bulk action that
228
249
  * touched no mail. Reachable both from a predicate that matches nothing and from