@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.
- package/package.json +1 -1
- package/src/components/blocked-reason.render.test.ts +44 -0
- package/src/components/blocked-reason.tsx +41 -0
- package/src/components/filter-rule.ts +10 -0
- package/src/components/mobile-search-view.stories.tsx +7 -7
- package/src/components/search-results.render.test.ts +4 -3
- package/src/components/search-results.stories.tsx +6 -3
- package/src/components/search-results.tsx +35 -25
- package/src/components/selection-top-bar.render.test.ts +6 -8
- package/src/components/selection-top-bar.stories.tsx +9 -24
- package/src/components/selection-top-bar.tsx +27 -24
- package/src/components/selection-wizard.render.test.ts +49 -9
- package/src/components/selection-wizard.tsx +138 -52
- package/src/index.ts +8 -11
- package/src/lib/search-rule.test.ts +22 -21
- package/src/lib/search-rule.ts +14 -32
- package/src/lib/wizard-steps.test.ts +37 -8
- package/src/lib/wizard-steps.ts +32 -8
- package/src/components/selection-sheet.render.test.ts +0 -169
- package/src/components/selection-sheet.stories.tsx +0 -179
- package/src/components/selection-sheet.test.ts +0 -88
- package/src/components/selection-sheet.tsx +0 -496
|
@@ -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
|
-
|
|
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-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
331
|
-
<
|
|
332
|
-
{
|
|
333
|
-
|
|
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-
|
|
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
|
|
583
|
-
{
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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={
|
|
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
|
-
|
|
899
|
-
|
|
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
|
-
|
|
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("
|
|
47
|
-
it("
|
|
48
|
-
const
|
|
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
|
-
|
|
52
|
-
|
|
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.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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("
|
|
66
|
-
const
|
|
67
|
-
conversion({ clauses: [{ field: "
|
|
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.
|
|
71
|
-
|
|
68
|
+
assert.deepEqual(notice, {
|
|
69
|
+
scopedOutFolder: undefined,
|
|
70
|
+
droppedFacets: [],
|
|
71
|
+
droppedSemantic: false,
|
|
72
|
+
});
|
|
72
73
|
});
|
|
73
74
|
});
|
package/src/lib/search-rule.ts
CHANGED
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What a search converts to
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
|
72
|
+
export const searchConversionNotice = (
|
|
84
73
|
conversion: SearchConversion,
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
});
|