@remit/ui 0.0.60 → 0.0.62

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.
@@ -28,6 +28,7 @@ import {
28
28
  matchPhrase,
29
29
  matchSummary,
30
30
  type RunCopy,
31
+ type RunOutcome,
31
32
  type RunState,
32
33
  runCopy,
33
34
  type SampleEmptyReason,
@@ -41,6 +42,7 @@ import {
41
42
  type WizardDraft,
42
43
  } from "../lib/wizard-steps.js";
43
44
  import { Badge } from "./badge.js";
45
+ import { BlockedReason } from "./blocked-reason.js";
44
46
  import { Button } from "./button.js";
45
47
  import { FieldLabel } from "./field-label.js";
46
48
  import {
@@ -133,7 +135,16 @@ export function WizardScreen({
133
135
  }: WizardScreenProps) {
134
136
  const active = stepIndex(steps, step);
135
137
  return (
136
- <div className="fixed inset-0 z-50 flex flex-col font-sans text-fg md:items-center md:justify-center md:bg-black/40 md:p-6">
138
+ // A modal at every width: full-bleed below 768px it covers the list rather
139
+ // than sitting beside it, so the list behind must leave the accessibility
140
+ // tree with it — otherwise the verbs that opened the wizard are still
141
+ // reachable underneath the screen that replaced them.
142
+ <div
143
+ role="dialog"
144
+ aria-modal="true"
145
+ aria-label={title}
146
+ className="fixed inset-0 z-50 flex flex-col font-sans text-fg md:items-center md:justify-center md:bg-black/40 md:p-6"
147
+ >
137
148
  <div className="flex min-h-0 w-full flex-1 flex-col bg-canvas md:h-[45rem] md:max-h-[calc(100dvh-3rem)] md:w-[35rem] md:max-w-[calc(100vw-3rem)] md:flex-none md:overflow-hidden md:rounded-xl md:border md:border-line md:shadow-lg">
138
149
  <header className="shrink-0 border-b border-line px-3 pb-2 pt-3">
139
150
  <div className="flex items-center gap-1">
@@ -190,6 +201,12 @@ export interface ChoiceCardProps {
190
201
  children?: ReactNode;
191
202
  }
192
203
 
204
+ /**
205
+ * Nothing disables (#477 1.7). An unavailable choice is dimmed and stays
206
+ * pressable, because pressing it is what takes the user somewhere that works —
207
+ * `aria-disabled` would announce it as a control that does nothing and take that
208
+ * away. What makes it unavailable is announced with it instead.
209
+ */
193
210
  export function ChoiceCard({
194
211
  selected,
195
212
  title,
@@ -198,13 +215,14 @@ export function ChoiceCard({
198
215
  onSelect,
199
216
  children,
200
217
  }: ChoiceCardProps) {
218
+ const hintId = useId();
201
219
  return (
202
220
  <div className="space-y-1.5">
203
221
  <button
204
222
  type="button"
205
223
  onClick={onSelect}
206
224
  aria-pressed={selected}
207
- aria-disabled={unavailable || undefined}
225
+ aria-describedby={unavailable && children ? hintId : undefined}
208
226
  className={cn(
209
227
  "flex w-full items-start gap-3 rounded-lg border p-3 text-left transition-colors",
210
228
  selected
@@ -228,7 +246,7 @@ export function ChoiceCard({
228
246
  </span>
229
247
  </span>
230
248
  </button>
231
- {children}
249
+ {children && <div id={hintId}>{children}</div>}
232
250
  </div>
233
251
  );
234
252
  }
@@ -244,6 +262,12 @@ export interface SelectionSampleProps {
244
262
  label: string;
245
263
  /** Why there are no rows. Defaults to nothing matching, never to a bare empty state. */
246
264
  emptyReason?: SampleEmptyReason;
265
+ /**
266
+ * The rows are still being fetched. No rows yet is not the same answer as no
267
+ * rows at all, and "nothing matches" beside a server count that says otherwise
268
+ * is the wrong conclusion (#477 3.5).
269
+ */
270
+ loading?: boolean;
247
271
  }
248
272
 
249
273
  const sampleFooter = (count: MatchCount, shown: number): string => {
@@ -256,6 +280,25 @@ const sampleFooter = (count: MatchCount, shown: number): string => {
256
280
  return previewCountSummary(count);
257
281
  };
258
282
 
283
+ /**
284
+ * What an empty sample says. A count that could not be taken says so; only a
285
+ * count that came back empty may say nothing matches (#477 3.5).
286
+ *
287
+ * A body-text clause is the case that makes the difference load-bearing: the
288
+ * vector-free matcher refuses to evaluate it, so there is no count and no rows,
289
+ * and "nothing matches this yet" is not merely unexplained but wrong — the rule
290
+ * matches, once a saved rule is what runs it.
291
+ */
292
+ const sampleEmptyLine = (
293
+ count: MatchCount,
294
+ emptyReason: SampleEmptyReason | undefined,
295
+ loading: boolean | undefined,
296
+ ): string => {
297
+ if (loading) return "Fetching the messages this covers…";
298
+ if (count.status === "error") return count.reason;
299
+ return sampleEmptyCopy(emptyReason ?? "noMatch");
300
+ };
301
+
259
302
  /**
260
303
  * The members of the match, closing every screen that names one. A named match
261
304
  * with no members shown is an unseen bulk action, so the rows scroll in their own
@@ -266,6 +309,7 @@ export function SelectionSample({
266
309
  count,
267
310
  label,
268
311
  emptyReason,
312
+ loading,
269
313
  }: SelectionSampleProps) {
270
314
  return (
271
315
  <section className="flex flex-col rounded-lg border border-line bg-surface">
@@ -274,7 +318,7 @@ export function SelectionSample({
274
318
  </h2>
275
319
  {messages.length === 0 ? (
276
320
  <p className="px-3 py-4 text-xs text-fg-muted">
277
- {sampleEmptyCopy(emptyReason ?? "noMatch")}
321
+ {sampleEmptyLine(count, emptyReason, loading)}
278
322
  </p>
279
323
  ) : (
280
324
  <>
@@ -316,6 +360,12 @@ export interface FooterNavProps {
316
360
  nudged?: boolean;
317
361
  }
318
362
 
363
+ /**
364
+ * Nothing disables (#477 1.7). A Continue with an answer still missing is dimmed
365
+ * and stays pressable — and stays pressable to assistive technology too, which
366
+ * `aria-disabled` would have taken away along with the reason. `BlockedReason`
367
+ * carries the two ways that reason reaches the user.
368
+ */
319
369
  export function FooterNav({
320
370
  backLabel = "Back",
321
371
  onBack,
@@ -325,12 +375,16 @@ export function FooterNav({
325
375
  blockedReason,
326
376
  nudged,
327
377
  }: FooterNavProps) {
378
+ const reasonId = useId();
328
379
  return (
329
380
  <div className="space-y-2">
330
- {nudged && blockedReason && (
331
- <p role="status" className="px-1 text-2xs text-warning">
332
- {blockedReason}
333
- </p>
381
+ {blockedReason && (
382
+ <BlockedReason
383
+ id={reasonId}
384
+ reason={blockedReason}
385
+ nudged={nudged}
386
+ className="px-1 text-2xs text-warning"
387
+ />
334
388
  )}
335
389
  <div className="flex items-center gap-3">
336
390
  <Button
@@ -346,7 +400,7 @@ export function FooterNav({
346
400
  variant={nextVariant}
347
401
  size="touch"
348
402
  onClick={onNext}
349
- aria-disabled={blockedReason ? true : undefined}
403
+ aria-describedby={blockedReason ? reasonId : undefined}
350
404
  className={cn("flex-1", blockedReason && "opacity-55")}
351
405
  >
352
406
  {nextLabel}
@@ -366,6 +420,12 @@ export interface MatchStepProps {
366
420
  * of the deployment: the door stays pressable and dimmed.
367
421
  */
368
422
  semanticUnavailable?: boolean;
423
+ /**
424
+ * What the mail server said when the widen was asked to run and failed. A
425
+ * failure is a reason the door cannot run, so it dims the door like any other
426
+ * — with the server's own words under it rather than a generic line.
427
+ */
428
+ semanticErrorDetail?: string;
369
429
  /** The dimmed door was pressed and the senders were filled in instead. */
370
430
  semanticFallbackTaken?: boolean;
371
431
  onSemanticFallback: () => void;
@@ -377,6 +437,7 @@ export function MatchStepBody({
377
437
  mode,
378
438
  onModeChange,
379
439
  semanticUnavailable,
440
+ semanticErrorDetail,
380
441
  semanticFallbackTaken,
381
442
  onSemanticFallback,
382
443
  sample,
@@ -401,6 +462,11 @@ export function MatchStepBody({
401
462
  title={matchDoorLabel("similar", selectedCount)}
402
463
  description={matchDoorHint("similar")}
403
464
  >
465
+ {semanticErrorDetail && (
466
+ <p role="status" className="px-1 text-2xs text-danger">
467
+ Couldn't find similar messages: {semanticErrorDetail}
468
+ </p>
469
+ )}
404
470
  {semanticFallbackTaken && (
405
471
  <p role="status" className="px-1 text-2xs text-fg-subtle">
406
472
  Similar-mail matching is unavailable right now — matching on the
@@ -568,6 +634,12 @@ export interface FolderStepProps {
568
634
  name: string,
569
635
  signal?: AbortSignal,
570
636
  ) => Promise<MoveMailboxOption>;
637
+ /**
638
+ * Why there is no folder list to choose from — a selection spanning accounts
639
+ * has no single account whose folders these could be (#477 5.5). Said here
640
+ * rather than left as an empty picker.
641
+ */
642
+ restriction?: string;
571
643
  }
572
644
 
573
645
  export function FolderStepBody({
@@ -575,14 +647,21 @@ export function FolderStepBody({
575
647
  mailboxId,
576
648
  onSelect,
577
649
  onCreateFolder,
650
+ restriction,
578
651
  }: FolderStepProps) {
579
652
  const chosen = mailboxes.find((mailbox) => mailbox.id === mailboxId);
580
653
  return (
581
654
  <div className="flex min-h-0 flex-col gap-3">
582
- <p className="px-1 text-xs text-fg-muted">
583
- {chosen
584
- ? `Moving to ${chosen.label}.`
585
- : "Search for a folder, or type a name that doesn't exist yet to make one."}
655
+ <p
656
+ className={cn(
657
+ "px-1 text-xs",
658
+ restriction ? "text-warning" : "text-fg-muted",
659
+ )}
660
+ >
661
+ {restriction ??
662
+ (chosen
663
+ ? `Moving to ${chosen.label}.`
664
+ : "Search for a folder, or type a name that doesn't exist yet to make one.")}
586
665
  </p>
587
666
  <div className="overflow-hidden rounded-lg border border-line bg-surface">
588
667
  <MoveMailboxPicker
@@ -611,16 +690,26 @@ export interface RuleStepProps {
611
690
  draft: WizardDraft;
612
691
  onScopeChange: (scope: RuleScope) => void;
613
692
  onUntilChange: (until: string) => void;
693
+ /**
694
+ * Why the two persisting scopes cannot be reached from here — a selection
695
+ * spanning accounts has no single account to create the rule for (#477 5.5).
696
+ * They stay pressable and say this; the one-off scope is unaffected.
697
+ */
698
+ restriction?: string;
614
699
  }
615
700
 
616
701
  export function RuleStepBody({
617
702
  draft,
618
703
  onScopeChange,
619
704
  onUntilChange,
705
+ restriction,
620
706
  }: RuleStepProps) {
621
707
  const untilId = useId();
622
708
  const { scope, until = "" } = draft;
623
709
  const unreadable = unreadableDraftClauses(draft).length > 0;
710
+ const restricted = restriction && (
711
+ <p className="px-1 text-2xs text-warning">{restriction}</p>
712
+ );
624
713
 
625
714
  return (
626
715
  <div className="space-y-2">
@@ -638,16 +727,21 @@ export function RuleStepBody({
638
727
  </ChoiceCard>
639
728
  <ChoiceCard
640
729
  selected={scope === "standing"}
730
+ unavailable={Boolean(restriction)}
641
731
  onSelect={() => onScopeChange("standing")}
642
732
  title={scopeLabel("standing")}
643
733
  description="Saves a rule and applies it to new mail as it arrives, and to the mail already in your mailbox. You'll name it next."
644
- />
734
+ >
735
+ {restricted}
736
+ </ChoiceCard>
645
737
  <ChoiceCard
646
738
  selected={scope === "until"}
739
+ unavailable={Boolean(restriction)}
647
740
  onSelect={() => onScopeChange("until")}
648
741
  title={scopeLabel("until")}
649
742
  description="The same rule, and it stops on a day you pick."
650
743
  >
744
+ {restricted}
651
745
  {scope === "until" && (
652
746
  <div className="rounded-lg border border-line bg-surface p-2">
653
747
  <FieldLabel htmlFor={untilId}>Stops on</FieldLabel>
@@ -753,7 +847,7 @@ export function ReviewStepBody({
753
847
  <p className="text-sm text-fg">
754
848
  <span className="font-semibold">{label}</span>{" "}
755
849
  {matchPhrase(description)}
756
- {verb === "move" && folder ? ` to ${folder}` : ""}
850
+ {folder ? ` to ${folder}` : ""}
757
851
  {persists && (
758
852
  <>
759
853
  {" "}
@@ -818,10 +912,25 @@ export interface RunStepProps {
818
912
  applied: number;
819
913
  /** The messages the mail server rejected, named one by one. */
820
914
  failures: readonly WizardMessage[];
915
+ /**
916
+ * How many the mail server rejected, when that is more than can be named. A
917
+ * server-side pass reports its own failure count without handing back the
918
+ * messages behind it; defaults to the ones named here.
919
+ */
920
+ failedCount?: number;
821
921
  onRetry: () => void;
822
922
  onDismiss: () => void;
823
923
  }
824
924
 
925
+ const runOutcomeOf = (props: RunStepProps): RunOutcome => ({
926
+ state: props.state,
927
+ verb: props.verb,
928
+ scope: props.scope,
929
+ matched: props.matched,
930
+ applied: props.applied,
931
+ failed: props.failedCount ?? props.failures.length,
932
+ });
933
+
825
934
  const runIcon = (tone: RunCopy["tone"]): ReactNode => {
826
935
  if (tone === "progress") {
827
936
  return <Loader2 className="size-10 animate-spin text-accent" />;
@@ -837,22 +946,10 @@ const runIcon = (tone: RunCopy["tone"]): ReactNode => {
837
946
  );
838
947
  };
839
948
 
840
- export function RunStepBody({
841
- state,
842
- verb,
843
- scope,
844
- matched,
845
- applied,
846
- failures,
847
- }: RunStepProps) {
848
- const copy = runCopy({
849
- state,
850
- verb,
851
- scope,
852
- matched,
853
- applied,
854
- failed: failures.length,
855
- });
949
+ export function RunStepBody(props: RunStepProps) {
950
+ const { matched, applied, failures } = props;
951
+ const outcome = runOutcomeOf(props);
952
+ const copy = runCopy(outcome);
856
953
 
857
954
  return (
858
955
  <div className="space-y-4 pt-2">
@@ -866,7 +963,7 @@ export function RunStepBody({
866
963
  <ProgressBar
867
964
  value={applied}
868
965
  max={matched}
869
- tone={failures.length > 0 ? "warning" : "success"}
966
+ tone={outcome.failed > 0 ? "warning" : "success"}
870
967
  />
871
968
  )}
872
969
 
@@ -894,24 +991,9 @@ export function RunStepBody({
894
991
  );
895
992
  }
896
993
 
897
- export function RunFooter({
898
- state,
899
- verb,
900
- scope,
901
- matched,
902
- applied,
903
- failures,
904
- onRetry,
905
- onDismiss,
906
- }: RunStepProps) {
907
- const copy = runCopy({
908
- state,
909
- verb,
910
- scope,
911
- matched,
912
- applied,
913
- failed: failures.length,
914
- });
994
+ export function RunFooter(props: RunStepProps) {
995
+ const { onRetry, onDismiss } = props;
996
+ const copy = runCopy(runOutcomeOf(props));
915
997
 
916
998
  if (copy.retryLabel === undefined) {
917
999
  return (
@@ -1036,12 +1118,17 @@ export function SelectionWizard(props: SelectionWizardProps) {
1036
1118
  if (step === "run") return <RunFooter {...stepProps(props.run, step)} />;
1037
1119
  if (step === "review") {
1038
1120
  const review = stepProps(props.review, step);
1121
+ // The commit is a Continue like any other: a match that reaches nothing,
1122
+ // or a count still moving, dims it and says so rather than committing a
1123
+ // bulk action over nothing and reporting it as done.
1039
1124
  return (
1040
1125
  <FooterNav
1041
1126
  onBack={onBack}
1042
1127
  nextLabel={review.scope ? commitLabel(review.scope) : label}
1043
1128
  nextVariant={destructive ? "danger" : "primary"}
1044
1129
  onNext={onCommit}
1130
+ blockedReason={props.blockedReason}
1131
+ nudged={props.nudged}
1045
1132
  />
1046
1133
  );
1047
1134
  }
@@ -1058,8 +1145,7 @@ export function SelectionWizard(props: SelectionWizardProps) {
1058
1145
 
1059
1146
  const title = (): string => {
1060
1147
  if (step !== "run") return screen.title ?? label;
1061
- const run = stepProps(props.run, step);
1062
- return runCopy({ ...run, failed: run.failures.length }).screenTitle;
1148
+ return runCopy(runOutcomeOf(stepProps(props.run, step))).screenTitle;
1063
1149
  };
1064
1150
 
1065
1151
  return (
package/src/index.ts CHANGED
@@ -67,6 +67,10 @@ export {
67
67
  type BannerTone,
68
68
  type BannerVariant,
69
69
  } from "./components/banner.js";
70
+ export {
71
+ BlockedReason,
72
+ type BlockedReasonProps,
73
+ } from "./components/blocked-reason.js";
70
74
  export {
71
75
  BottomSheet,
72
76
  type BottomSheetProps,
@@ -174,6 +178,7 @@ export {
174
178
  type RuleWiden,
175
179
  ruleBlockedCopy,
176
180
  scopeLabel,
181
+ UNCOUNTABLE_PREDICATE_REASON,
177
182
  unreadableBodyClauses,
178
183
  widenChipLabel,
179
184
  } from "./components/filter-rule.js";
@@ -469,16 +474,6 @@ export {
469
474
  type SegmentedOption,
470
475
  } from "./components/segmented-control.js";
471
476
  export { Select, type SelectProps } from "./components/select.js";
472
- export {
473
- resolveSheetSnap,
474
- SELECTION_SHEET_TEASER_HEIGHT,
475
- SelectionSheet,
476
- type SelectionSheetMode,
477
- type SelectionSheetNotice,
478
- type SelectionSheetNoticeAction,
479
- type SelectionSheetProps,
480
- type SheetSnapInput,
481
- } from "./components/selection-sheet.js";
482
477
  export {
483
478
  SelectionTopBar,
484
479
  type SelectionTopBarNotice,
@@ -638,11 +633,11 @@ export {
638
633
  } from "./lib/roving-focus.js";
639
634
  export { type RuleNameParts, suggestRuleName } from "./lib/rule-name.js";
640
635
  export {
641
- buildSearchRule,
642
636
  type DroppedFacet,
643
637
  type DroppedFacetType,
644
638
  isConvertible,
645
639
  type SearchConversion,
640
+ searchConversionNotice,
646
641
  } from "./lib/search-rule.js";
647
642
  export {
648
643
  collapsibleDomain,
@@ -672,6 +667,8 @@ export {
672
667
  backExits,
673
668
  clauseSentence,
674
669
  clauseWords,
670
+ crossAccountDestinationReason,
671
+ crossAccountRuleReason,
675
672
  type MatchCount,
676
673
  type MatchDescription,
677
674
  type MatchMode,
@@ -1,9 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import {
4
- buildSearchRule,
5
4
  isConvertible,
6
5
  type SearchConversion,
6
+ searchConversionNotice,
7
7
  } from "./search-rule.js";
8
8
 
9
9
  const conversion = (
@@ -43,31 +43,32 @@ describe("isConvertible", () => {
43
43
  });
44
44
  });
45
45
 
46
- describe("buildSearchRule", () => {
47
- it("builds a standing rule with stable clause ids, no widen, empty name", () => {
48
- const rule = buildSearchRule(
46
+ describe("searchConversionNotice", () => {
47
+ it("names the folder the search was scoped to and every facet dropped", () => {
48
+ const notice = searchConversionNotice(
49
49
  conversion({
50
- clauses: [
51
- { field: "From", value: "a@b.com" },
52
- { field: "HasWords", value: "nightly" },
53
- ],
50
+ clauses: [{ field: "HasWords", value: "npm" }],
51
+ keptTerms: true,
52
+ scopedOut: { mailboxId: "mbx-archive", label: "Archive" },
53
+ droppedFacets: [{ type: "isUnread", label: "Unread" }],
54
+ droppedSemantic: true,
54
55
  }),
55
56
  );
56
- assert.equal(rule.scope, "standing");
57
- assert.equal(rule.widen, undefined);
58
- assert.equal(rule.name, "");
59
- assert.deepEqual(
60
- rule.clauses.map((clause) => clause.id),
61
- ["search-0", "search-1"],
62
- );
57
+ assert.deepEqual(notice, {
58
+ scopedOutFolder: "Archive",
59
+ droppedFacets: ["Unread"],
60
+ droppedSemantic: true,
61
+ });
63
62
  });
64
63
 
65
- it("honors an explicit scope and move target", () => {
66
- const rule = buildSearchRule(
67
- conversion({ clauses: [{ field: "HasWords", value: "nightly" }] }),
68
- { scope: "once", moveMailboxId: "mbx-archive" },
64
+ it("states nothing for a conversion that carried everything", () => {
65
+ const notice = searchConversionNotice(
66
+ conversion({ clauses: [{ field: "From", value: "a@b.com" }] }),
69
67
  );
70
- assert.equal(rule.scope, "once");
71
- assert.equal(rule.moveMailboxId, "mbx-archive");
68
+ assert.deepEqual(notice, {
69
+ scopedOutFolder: undefined,
70
+ droppedFacets: [],
71
+ droppedSemantic: false,
72
+ });
72
73
  });
73
74
  });
@@ -1,16 +1,12 @@
1
1
  /**
2
- * What a search converts to, and the rule built from it (RFC 038 D5). The shape
3
- * carries the clauses alongside everything the search held that a filter cannot,
4
- * so a conversion can never drop a facet without saying so; `search-conversion.ts`
5
- * beside it owns the copy that states it.
2
+ * What a search converts to (RFC 038 D5). The shape carries the clauses
3
+ * alongside everything the search held that a filter cannot, so a conversion can
4
+ * never drop a facet without saying so; `search-conversion.ts` beside it owns
5
+ * the copy that states it.
6
6
  */
7
7
 
8
- import type {
9
- ClauseField,
10
- FilterRule,
11
- MatchOperator,
12
- RuleScope,
13
- } from "../components/filter-rule.js";
8
+ import type { ClauseField, MatchOperator } from "../components/filter-rule.js";
9
+ import type { SearchConversionNotice } from "../components/search-conversion.js";
14
10
 
15
11
  /**
16
12
  * A search facet a filter has no clause for. Attachment, read state, starred,
@@ -68,29 +64,15 @@ export interface SearchConversion {
68
64
  export const isConvertible = (conversion: SearchConversion): boolean =>
69
65
  conversion.clauses.length > 0;
70
66
 
71
- interface BuildRuleOptions {
72
- scope?: RuleScope;
73
- moveMailboxId?: string;
74
- }
75
-
76
67
  /**
77
- * The rule the editor opens on, from a conversion. A search-derived rule defaults
78
- * to a standing filter "make this a filter" is a request to keep applying it —
79
- * and the editor lets the user drop it back to a one-time apply. It carries no
80
- * widen: a free-text query has no message anchor, so the semantic chip is not
81
- * offered on this surface (its loss is stated in the conversion notice instead).
68
+ * The conversion as the notice reads it. One mapping, beside both shapes, so
69
+ * every surface that opens on a converted search states the same losses in the
70
+ * same words.
82
71
  */
83
- export const buildSearchRule = (
72
+ export const searchConversionNotice = (
84
73
  conversion: SearchConversion,
85
- { scope = "standing", moveMailboxId }: BuildRuleOptions = {},
86
- ): FilterRule => ({
87
- clauses: conversion.clauses.map((clause, index) => ({
88
- id: `search-${index}`,
89
- field: clause.field,
90
- value: clause.value,
91
- })),
92
- matchOperator: conversion.matchOperator,
93
- moveMailboxId,
94
- scope,
95
- name: "",
74
+ ): SearchConversionNotice => ({
75
+ scopedOutFolder: conversion.scopedOut?.label,
76
+ droppedFacets: conversion.droppedFacets.map((facet) => facet.label),
77
+ droppedSemantic: conversion.droppedSemantic,
96
78
  });