@remit/ui 0.0.61 → 0.0.63
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/message-list-pane.tsx +32 -4
- 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.stories.tsx +4 -37
- package/src/components/selection-top-bar.tsx +11 -21
- package/src/components/selection-wizard.render.test.ts +104 -12
- package/src/components/selection-wizard.tsx +90 -17
- package/src/index.ts +11 -1
- package/src/lib/search-rule.test.ts +22 -21
- package/src/lib/search-rule.ts +14 -32
- package/src/lib/wizard-steps.test.ts +80 -1
- package/src/lib/wizard-steps.ts +83 -11
|
@@ -4,7 +4,10 @@ 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
6
|
import { stepsFor } from "../lib/wizard-steps.js";
|
|
7
|
-
import
|
|
7
|
+
import {
|
|
8
|
+
type RuleClause,
|
|
9
|
+
UNCOUNTABLE_PREDICATE_REASON,
|
|
10
|
+
} from "./filter-rule.js";
|
|
8
11
|
import {
|
|
9
12
|
FolderStepBody,
|
|
10
13
|
MatchStepBody,
|
|
@@ -172,6 +175,23 @@ describe("SelectionSample", () => {
|
|
|
172
175
|
assert.match(html, /Nothing matches this yet/);
|
|
173
176
|
});
|
|
174
177
|
|
|
178
|
+
// A count that could not be taken is not a count of zero. A body-text clause
|
|
179
|
+
// is the case that makes the difference load-bearing: it matches, once a
|
|
180
|
+
// saved rule is what runs it, so "nothing matches this yet" is wrong rather
|
|
181
|
+
// than merely unexplained (#477 3.5).
|
|
182
|
+
it("says a count could not be taken instead of that nothing matched", () => {
|
|
183
|
+
const html = renderToString(
|
|
184
|
+
createElement(SelectionSample, {
|
|
185
|
+
messages: [],
|
|
186
|
+
count: { status: "error", reason: UNCOUNTABLE_PREDICATE_REASON },
|
|
187
|
+
label: "What this matches",
|
|
188
|
+
emptyReason: "noMatch",
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
assert.match(text(html), /only a saved rule does/);
|
|
192
|
+
assert.doesNotMatch(html, /Nothing matches this yet/);
|
|
193
|
+
});
|
|
194
|
+
|
|
175
195
|
it("says the total is unknown when the match has not run", () => {
|
|
176
196
|
const html = renderToString(
|
|
177
197
|
createElement(SelectionSample, {
|
|
@@ -237,6 +257,36 @@ describe("MatchStepBody", () => {
|
|
|
237
257
|
assert.doesNotMatch(html, /disabled=""/);
|
|
238
258
|
assert.match(html, /matching on the\s+senders instead/);
|
|
239
259
|
});
|
|
260
|
+
|
|
261
|
+
it("names what an escalated predicate covers instead of offering doors", () => {
|
|
262
|
+
const html = renderToString(
|
|
263
|
+
createElement(MatchStepBody, {
|
|
264
|
+
...matchProps,
|
|
265
|
+
mode: "escalated",
|
|
266
|
+
escalatedScope: 'matching "npm"',
|
|
267
|
+
sample: {
|
|
268
|
+
messages,
|
|
269
|
+
count: counted(1284),
|
|
270
|
+
label: "A sample of what matches",
|
|
271
|
+
},
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
274
|
+
assert.match(html, /Every message matching "npm"/);
|
|
275
|
+
assert.match(html, /nothing to widen/);
|
|
276
|
+
// The three doors are gone: a predicate is the match already.
|
|
277
|
+
assert.doesNotMatch(html, /These 2 messages/);
|
|
278
|
+
assert.doesNotMatch(html, /Similar to these/);
|
|
279
|
+
assert.doesNotMatch(html, /Its properties/);
|
|
280
|
+
// The members of the match still close the screen (#477 2.3).
|
|
281
|
+
assert.match(html, /Booking confirmation/);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("falls back to naming the list rather than naming nothing", () => {
|
|
285
|
+
const html = renderToString(
|
|
286
|
+
createElement(MatchStepBody, { ...matchProps, mode: "escalated" }),
|
|
287
|
+
);
|
|
288
|
+
assert.match(html, /Every message the list is showing/);
|
|
289
|
+
});
|
|
240
290
|
});
|
|
241
291
|
|
|
242
292
|
describe("PropertiesStepBody", () => {
|
|
@@ -419,6 +469,29 @@ describe("ReviewStepBody", () => {
|
|
|
419
469
|
assert.match(html, /not known until the run finishes/);
|
|
420
470
|
});
|
|
421
471
|
|
|
472
|
+
it("states an escalated predicate and the count the server gave it", () => {
|
|
473
|
+
const html = renderToString(
|
|
474
|
+
createElement(ReviewStepBody, {
|
|
475
|
+
...reviewProps,
|
|
476
|
+
verb: "delete",
|
|
477
|
+
mode: "escalated",
|
|
478
|
+
escalatedScope: 'matching "npm"',
|
|
479
|
+
folder: undefined,
|
|
480
|
+
sample: {
|
|
481
|
+
messages,
|
|
482
|
+
count: counted(1284),
|
|
483
|
+
label: "A sample of what matches",
|
|
484
|
+
},
|
|
485
|
+
}),
|
|
486
|
+
);
|
|
487
|
+
assert.match(
|
|
488
|
+
text(html),
|
|
489
|
+
/Delete<\/span> all 1,284 messages matching "npm"/,
|
|
490
|
+
);
|
|
491
|
+
// The count is one reading of a live predicate, and the run takes another.
|
|
492
|
+
assert.match(html, /anything else matching by the time it runs/);
|
|
493
|
+
});
|
|
494
|
+
|
|
422
495
|
it("names the rule and its stop date once the scope persists", () => {
|
|
423
496
|
const html = renderToString(
|
|
424
497
|
createElement(ReviewStepBody, {
|
|
@@ -466,6 +539,23 @@ describe("RunStepBody", () => {
|
|
|
466
539
|
assert.match(html, /The rule itself is saved/);
|
|
467
540
|
});
|
|
468
541
|
|
|
542
|
+
it("says a stopped run was never sent rather than rejected", () => {
|
|
543
|
+
const html = renderToString(
|
|
544
|
+
createElement(RunStepBody, {
|
|
545
|
+
...runProps,
|
|
546
|
+
state: "runStopped",
|
|
547
|
+
scope: "once",
|
|
548
|
+
matched: 4,
|
|
549
|
+
applied: 2,
|
|
550
|
+
failures: messages,
|
|
551
|
+
}),
|
|
552
|
+
);
|
|
553
|
+
assert.match(html, /Stopped after 2/);
|
|
554
|
+
assert.match(html, /nothing was sent for them/);
|
|
555
|
+
assert.match(html, /Never sent/);
|
|
556
|
+
assert.doesNotMatch(html, /Server rejected/);
|
|
557
|
+
});
|
|
558
|
+
|
|
469
559
|
it("ends a one-off run on its own count", () => {
|
|
470
560
|
const html = renderToString(
|
|
471
561
|
createElement(RunStepBody, { ...runProps, scope: "once" }),
|
|
@@ -665,33 +755,35 @@ describe("SelectionWizard", () => {
|
|
|
665
755
|
assert.doesNotMatch(html, /<button[^>]*disabled=""/);
|
|
666
756
|
});
|
|
667
757
|
|
|
668
|
-
it("carries the blocked reason on the control before it is pressed, and
|
|
758
|
+
it("carries the blocked reason on the control before it is pressed, and announces it after", () => {
|
|
759
|
+
const reason = "Add a property to match on.";
|
|
669
760
|
const quiet = renderToString(
|
|
670
761
|
createElement(
|
|
671
762
|
SelectionWizard,
|
|
672
|
-
wizardProps({
|
|
673
|
-
step: "properties",
|
|
674
|
-
blockedReason: "Add a property to match on.",
|
|
675
|
-
}),
|
|
763
|
+
wizardProps({ step: "properties", blockedReason: reason }),
|
|
676
764
|
),
|
|
677
765
|
);
|
|
678
|
-
// Described, so it is heard on the control; not on screen
|
|
766
|
+
// Described, so it is heard on the control; not on screen and nothing
|
|
767
|
+
// announced until the control is pressed.
|
|
679
768
|
assert.match(quiet, /aria-describedby="/);
|
|
680
|
-
assert.match(quiet,
|
|
681
|
-
assert.
|
|
769
|
+
assert.match(quiet, /<p id="[^"]*"[^>]*sr-only/);
|
|
770
|
+
assert.match(quiet, /role="status"[^>]*><\/span>/);
|
|
682
771
|
|
|
683
772
|
const nudged = renderToString(
|
|
684
773
|
createElement(
|
|
685
774
|
SelectionWizard,
|
|
686
775
|
wizardProps({
|
|
687
776
|
step: "properties",
|
|
688
|
-
blockedReason:
|
|
777
|
+
blockedReason: reason,
|
|
689
778
|
nudged: true,
|
|
690
779
|
}),
|
|
691
780
|
),
|
|
692
781
|
);
|
|
693
|
-
|
|
694
|
-
|
|
782
|
+
// The reason is written into the live region, which is what a screen
|
|
783
|
+
// reader announces — marking the description live would announce nothing.
|
|
784
|
+
assert.doesNotMatch(nudged, /role="status"[^>]*><\/span>/);
|
|
785
|
+
assert.match(nudged, new RegExp(`role="status"[^>]*>${reason}`));
|
|
786
|
+
assert.doesNotMatch(nudged, /<p id="[^"]*"[^>]*sr-only/);
|
|
695
787
|
});
|
|
696
788
|
|
|
697
789
|
it("commits under the scope's own label, in the verb's own tone", () => {
|
|
@@ -21,7 +21,12 @@ import { Fragment, type ReactNode, useEffect, useId, useRef } from "react";
|
|
|
21
21
|
import { cn } from "../lib/cn.js";
|
|
22
22
|
import {
|
|
23
23
|
backExits,
|
|
24
|
+
ESCALATED_MATCH_HINT,
|
|
25
|
+
ESCALATED_REVIEW_WARNING,
|
|
26
|
+
ESCALATED_SCOPE_FALLBACK,
|
|
27
|
+
escalatedMatchLabel,
|
|
24
28
|
type MatchCount,
|
|
29
|
+
type MatchDoor,
|
|
25
30
|
type MatchMode,
|
|
26
31
|
matchDoorHint,
|
|
27
32
|
matchDoorLabel,
|
|
@@ -42,6 +47,7 @@ import {
|
|
|
42
47
|
type WizardDraft,
|
|
43
48
|
} from "../lib/wizard-steps.js";
|
|
44
49
|
import { Badge } from "./badge.js";
|
|
50
|
+
import { BlockedReason } from "./blocked-reason.js";
|
|
45
51
|
import { Button } from "./button.js";
|
|
46
52
|
import { FieldLabel } from "./field-label.js";
|
|
47
53
|
import {
|
|
@@ -279,6 +285,25 @@ const sampleFooter = (count: MatchCount, shown: number): string => {
|
|
|
279
285
|
return previewCountSummary(count);
|
|
280
286
|
};
|
|
281
287
|
|
|
288
|
+
/**
|
|
289
|
+
* What an empty sample says. A count that could not be taken says so; only a
|
|
290
|
+
* count that came back empty may say nothing matches (#477 3.5).
|
|
291
|
+
*
|
|
292
|
+
* A body-text clause is the case that makes the difference load-bearing: the
|
|
293
|
+
* vector-free matcher refuses to evaluate it, so there is no count and no rows,
|
|
294
|
+
* and "nothing matches this yet" is not merely unexplained but wrong — the rule
|
|
295
|
+
* matches, once a saved rule is what runs it.
|
|
296
|
+
*/
|
|
297
|
+
const sampleEmptyLine = (
|
|
298
|
+
count: MatchCount,
|
|
299
|
+
emptyReason: SampleEmptyReason | undefined,
|
|
300
|
+
loading: boolean | undefined,
|
|
301
|
+
): string => {
|
|
302
|
+
if (loading) return "Fetching the messages this covers…";
|
|
303
|
+
if (count.status === "error") return count.reason;
|
|
304
|
+
return sampleEmptyCopy(emptyReason ?? "noMatch");
|
|
305
|
+
};
|
|
306
|
+
|
|
282
307
|
/**
|
|
283
308
|
* The members of the match, closing every screen that names one. A named match
|
|
284
309
|
* with no members shown is an unseen bulk action, so the rows scroll in their own
|
|
@@ -298,9 +323,7 @@ export function SelectionSample({
|
|
|
298
323
|
</h2>
|
|
299
324
|
{messages.length === 0 ? (
|
|
300
325
|
<p className="px-3 py-4 text-xs text-fg-muted">
|
|
301
|
-
{loading
|
|
302
|
-
? "Fetching the messages this covers…"
|
|
303
|
-
: sampleEmptyCopy(emptyReason ?? "noMatch")}
|
|
326
|
+
{sampleEmptyLine(count, emptyReason, loading)}
|
|
304
327
|
</p>
|
|
305
328
|
) : (
|
|
306
329
|
<>
|
|
@@ -345,9 +368,8 @@ export interface FooterNavProps {
|
|
|
345
368
|
/**
|
|
346
369
|
* Nothing disables (#477 1.7). A Continue with an answer still missing is dimmed
|
|
347
370
|
* and stays pressable — and stays pressable to assistive technology too, which
|
|
348
|
-
* `aria-disabled` would have taken away along with the reason.
|
|
349
|
-
*
|
|
350
|
-
* is what brings the reason on screen.
|
|
371
|
+
* `aria-disabled` would have taken away along with the reason. `BlockedReason`
|
|
372
|
+
* carries the two ways that reason reaches the user.
|
|
351
373
|
*/
|
|
352
374
|
export function FooterNav({
|
|
353
375
|
backLabel = "Back",
|
|
@@ -362,13 +384,12 @@ export function FooterNav({
|
|
|
362
384
|
return (
|
|
363
385
|
<div className="space-y-2">
|
|
364
386
|
{blockedReason && (
|
|
365
|
-
<
|
|
387
|
+
<BlockedReason
|
|
366
388
|
id={reasonId}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
</p>
|
|
389
|
+
reason={blockedReason}
|
|
390
|
+
nudged={nudged}
|
|
391
|
+
className="px-1 text-2xs text-warning"
|
|
392
|
+
/>
|
|
372
393
|
)}
|
|
373
394
|
<div className="flex items-center gap-3">
|
|
374
395
|
<Button
|
|
@@ -398,7 +419,12 @@ export function FooterNav({
|
|
|
398
419
|
export interface MatchStepProps {
|
|
399
420
|
selectedCount: number;
|
|
400
421
|
mode: MatchMode;
|
|
401
|
-
|
|
422
|
+
/**
|
|
423
|
+
* Answers the door. Typed to the three doors rather than to every mode, so
|
|
424
|
+
* no driver can set `escalated` from a screen — the list escalates a
|
|
425
|
+
* selection, the wizard never does.
|
|
426
|
+
*/
|
|
427
|
+
onModeChange: (mode: MatchDoor) => void;
|
|
402
428
|
/**
|
|
403
429
|
* Similar-mail matching cannot run right now. A runtime state, not a property
|
|
404
430
|
* of the deployment: the door stays pressable and dimmed.
|
|
@@ -413,6 +439,12 @@ export interface MatchStepProps {
|
|
|
413
439
|
/** The dimmed door was pressed and the senders were filled in instead. */
|
|
414
440
|
semanticFallbackTaken?: boolean;
|
|
415
441
|
onSemanticFallback: () => void;
|
|
442
|
+
/**
|
|
443
|
+
* What the escalated predicate covers, in the words the list escalated it
|
|
444
|
+
* with. Read only while the mode is `escalated`, where it replaces the three
|
|
445
|
+
* doors: the predicate is the match already.
|
|
446
|
+
*/
|
|
447
|
+
escalatedScope?: string;
|
|
416
448
|
sample: SelectionSampleProps;
|
|
417
449
|
}
|
|
418
450
|
|
|
@@ -424,8 +456,24 @@ export function MatchStepBody({
|
|
|
424
456
|
semanticErrorDetail,
|
|
425
457
|
semanticFallbackTaken,
|
|
426
458
|
onSemanticFallback,
|
|
459
|
+
escalatedScope,
|
|
427
460
|
sample,
|
|
428
461
|
}: MatchStepProps) {
|
|
462
|
+
if (mode === "escalated") {
|
|
463
|
+
return (
|
|
464
|
+
<>
|
|
465
|
+
<div className="rounded-lg border border-accent bg-accent-soft p-3">
|
|
466
|
+
<p className="text-sm font-medium text-fg">
|
|
467
|
+
{escalatedMatchLabel(escalatedScope ?? ESCALATED_SCOPE_FALLBACK)}
|
|
468
|
+
</p>
|
|
469
|
+
<p className="mt-1 text-xs text-fg-muted">{ESCALATED_MATCH_HINT}</p>
|
|
470
|
+
</div>
|
|
471
|
+
<div className="mt-4">
|
|
472
|
+
<SelectionSample {...sample} />
|
|
473
|
+
</div>
|
|
474
|
+
</>
|
|
475
|
+
);
|
|
476
|
+
}
|
|
429
477
|
return (
|
|
430
478
|
<>
|
|
431
479
|
<div className="space-y-2">
|
|
@@ -805,6 +853,8 @@ export interface ReviewStepProps {
|
|
|
805
853
|
until?: string;
|
|
806
854
|
/** Present when the flow reached the naming step. */
|
|
807
855
|
ruleName?: string;
|
|
856
|
+
/** What the escalated predicate covers, in the list's own words. */
|
|
857
|
+
escalatedScope?: string;
|
|
808
858
|
sample: SelectionSampleProps;
|
|
809
859
|
}
|
|
810
860
|
|
|
@@ -818,10 +868,21 @@ export function ReviewStepBody({
|
|
|
818
868
|
scope,
|
|
819
869
|
until,
|
|
820
870
|
ruleName,
|
|
871
|
+
escalatedScope,
|
|
821
872
|
sample,
|
|
822
873
|
}: ReviewStepProps) {
|
|
823
874
|
const { label } = verbCopy(verb);
|
|
824
|
-
const description = {
|
|
875
|
+
const description = {
|
|
876
|
+
mode,
|
|
877
|
+
selectedCount,
|
|
878
|
+
clauses,
|
|
879
|
+
matchOperator,
|
|
880
|
+
escalatedScope,
|
|
881
|
+
// A count still moving is not a number to commit against, and the footer
|
|
882
|
+
// under the review's Continue already says so.
|
|
883
|
+
escalatedCount:
|
|
884
|
+
sample.count.status === "ready" ? sample.count.count : undefined,
|
|
885
|
+
};
|
|
825
886
|
const widened = mode !== "selected";
|
|
826
887
|
const persists = scope === "standing" || scope === "until";
|
|
827
888
|
|
|
@@ -844,7 +905,9 @@ export function ReviewStepBody({
|
|
|
844
905
|
{widened && (
|
|
845
906
|
<p className="mt-2 flex items-start gap-1.5 text-xs text-warning">
|
|
846
907
|
<AlertTriangle className="mt-px size-3.5 shrink-0" />
|
|
847
|
-
|
|
908
|
+
{mode === "escalated"
|
|
909
|
+
? ESCALATED_REVIEW_WARNING
|
|
910
|
+
: "This covers messages not shown in the list."}
|
|
848
911
|
</p>
|
|
849
912
|
)}
|
|
850
913
|
</div>
|
|
@@ -934,6 +997,9 @@ export function RunStepBody(props: RunStepProps) {
|
|
|
934
997
|
const { matched, applied, failures } = props;
|
|
935
998
|
const outcome = runOutcomeOf(props);
|
|
936
999
|
const copy = runCopy(outcome);
|
|
1000
|
+
// A stopped run never sent these, so nothing rejected them.
|
|
1001
|
+
const failureBadge =
|
|
1002
|
+
props.state === "runStopped" ? "Never sent" : "Server rejected";
|
|
937
1003
|
|
|
938
1004
|
return (
|
|
939
1005
|
<div className="space-y-4 pt-2">
|
|
@@ -964,7 +1030,7 @@ export function RunStepBody(props: RunStepProps) {
|
|
|
964
1030
|
{message.subject}
|
|
965
1031
|
</p>
|
|
966
1032
|
<Badge className="mt-1" tone="warning">
|
|
967
|
-
|
|
1033
|
+
{failureBadge}
|
|
968
1034
|
</Badge>
|
|
969
1035
|
</li>
|
|
970
1036
|
))}
|
|
@@ -1132,10 +1198,17 @@ export function SelectionWizard(props: SelectionWizardProps) {
|
|
|
1132
1198
|
return runCopy(runOutcomeOf(stepProps(props.run, step))).screenTitle;
|
|
1133
1199
|
};
|
|
1134
1200
|
|
|
1201
|
+
// An escalated predicate is offered no doors, so the screen states what it
|
|
1202
|
+
// covers rather than asking a question with one answer.
|
|
1203
|
+
const subtitle =
|
|
1204
|
+
step === "match" && props.match?.mode === "escalated"
|
|
1205
|
+
? "What this applies to"
|
|
1206
|
+
: screen.subtitle;
|
|
1207
|
+
|
|
1135
1208
|
return (
|
|
1136
1209
|
<WizardScreen
|
|
1137
1210
|
title={title()}
|
|
1138
|
-
subtitle={
|
|
1211
|
+
subtitle={subtitle}
|
|
1139
1212
|
steps={steps}
|
|
1140
1213
|
step={step}
|
|
1141
1214
|
onBack={onBack}
|
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";
|
|
@@ -628,11 +633,11 @@ export {
|
|
|
628
633
|
} from "./lib/roving-focus.js";
|
|
629
634
|
export { type RuleNameParts, suggestRuleName } from "./lib/rule-name.js";
|
|
630
635
|
export {
|
|
631
|
-
buildSearchRule,
|
|
632
636
|
type DroppedFacet,
|
|
633
637
|
type DroppedFacetType,
|
|
634
638
|
isConvertible,
|
|
635
639
|
type SearchConversion,
|
|
640
|
+
searchConversionNotice,
|
|
636
641
|
} from "./lib/search-rule.js";
|
|
637
642
|
export {
|
|
638
643
|
collapsibleDomain,
|
|
@@ -664,8 +669,13 @@ export {
|
|
|
664
669
|
clauseWords,
|
|
665
670
|
crossAccountDestinationReason,
|
|
666
671
|
crossAccountRuleReason,
|
|
672
|
+
ESCALATED_MATCH_HINT,
|
|
673
|
+
ESCALATED_REVIEW_WARNING,
|
|
674
|
+
ESCALATED_SCOPE_FALLBACK,
|
|
675
|
+
escalatedMatchLabel,
|
|
667
676
|
type MatchCount,
|
|
668
677
|
type MatchDescription,
|
|
678
|
+
type MatchDoor,
|
|
669
679
|
type MatchMode,
|
|
670
680
|
matchDoorHint,
|
|
671
681
|
matchDoorLabel,
|
|
@@ -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
|
});
|
|
@@ -9,6 +9,9 @@ import {
|
|
|
9
9
|
backExits,
|
|
10
10
|
clauseSentence,
|
|
11
11
|
clauseWords,
|
|
12
|
+
ESCALATED_MATCH_HINT,
|
|
13
|
+
ESCALATED_REVIEW_WARNING,
|
|
14
|
+
escalatedMatchLabel,
|
|
12
15
|
type MatchCount,
|
|
13
16
|
type MatchMode,
|
|
14
17
|
matchDoorHint,
|
|
@@ -30,7 +33,7 @@ import {
|
|
|
30
33
|
} from "./wizard-steps.js";
|
|
31
34
|
|
|
32
35
|
const VERBS: Verb[] = ["delete", "move", "junk", "markRead", "organize"];
|
|
33
|
-
const MODES: MatchMode[] = ["selected", "similar", "properties"];
|
|
36
|
+
const MODES: MatchMode[] = ["selected", "similar", "properties", "escalated"];
|
|
34
37
|
const SCOPES: (RuleScope | undefined)[] = [
|
|
35
38
|
undefined,
|
|
36
39
|
"once",
|
|
@@ -475,6 +478,7 @@ describe("runCopy", () => {
|
|
|
475
478
|
"backApplyFailed",
|
|
476
479
|
"backApplyStartFailed",
|
|
477
480
|
"filterSaved",
|
|
481
|
+
"runStopped",
|
|
478
482
|
"commitFailed",
|
|
479
483
|
];
|
|
480
484
|
for (const state of states) {
|
|
@@ -499,6 +503,23 @@ describe("runCopy", () => {
|
|
|
499
503
|
assert.doesNotMatch(once.detail, /rule/);
|
|
500
504
|
});
|
|
501
505
|
|
|
506
|
+
it("separates a run that stopped from a pass the server rejected", () => {
|
|
507
|
+
// The bulk endpoints accept every id in a call that returns, so the only
|
|
508
|
+
// failure the chunked runner can observe is a call that threw — and
|
|
509
|
+
// everything after it was never sent. Saying the mail server rejected
|
|
510
|
+
// those states a cause that did not happen.
|
|
511
|
+
const stopped = outcome("runStopped", "once");
|
|
512
|
+
assert.equal(stopped.title, "Stopped after 10");
|
|
513
|
+
assert.match(stopped.detail, /nothing was sent for them/);
|
|
514
|
+
assert.doesNotMatch(stopped.detail, /rejected/);
|
|
515
|
+
assert.equal(stopped.retryLabel, "Retry 2");
|
|
516
|
+
assert.equal(stopped.showProgress, true);
|
|
517
|
+
|
|
518
|
+
// The back-apply pass is run by the server, which reports what it could
|
|
519
|
+
// not apply — a rejection, and worded as one.
|
|
520
|
+
assert.match(outcome("backApplyFailed", "once").detail, /rejected/);
|
|
521
|
+
});
|
|
522
|
+
|
|
502
523
|
it("ends a one-off run on its own count and a saved rule on the rule", () => {
|
|
503
524
|
assert.equal(outcome("backApplyComplete", "once").title, "Moved 10");
|
|
504
525
|
assert.equal(
|
|
@@ -585,6 +606,15 @@ describe("sample and door vocabulary", () => {
|
|
|
585
606
|
assert.match(sampleEmptyCopy("notIndexed"), /isn't indexed yet/);
|
|
586
607
|
});
|
|
587
608
|
|
|
609
|
+
it("states what an escalated predicate covers instead of a door", () => {
|
|
610
|
+
assert.equal(
|
|
611
|
+
escalatedMatchLabel('matching "npm"'),
|
|
612
|
+
'Every message matching "npm"',
|
|
613
|
+
);
|
|
614
|
+
assert.match(ESCALATED_MATCH_HINT, /nothing to widen/);
|
|
615
|
+
assert.match(ESCALATED_REVIEW_WARNING, /by the time it runs/);
|
|
616
|
+
});
|
|
617
|
+
|
|
588
618
|
it("names each door and what it does", () => {
|
|
589
619
|
assert.equal(matchDoorLabel("selected", 3), "These 3 messages");
|
|
590
620
|
assert.equal(matchDoorLabel("similar", 3), "Similar to these 3");
|
|
@@ -661,6 +691,55 @@ describe("the match as words", () => {
|
|
|
661
691
|
);
|
|
662
692
|
});
|
|
663
693
|
|
|
694
|
+
it("summarises an escalated predicate by what it covers", () => {
|
|
695
|
+
assert.equal(
|
|
696
|
+
matchSummary({
|
|
697
|
+
mode: "escalated",
|
|
698
|
+
selectedCount: 4,
|
|
699
|
+
clauses,
|
|
700
|
+
matchOperator: "all",
|
|
701
|
+
escalatedScope: 'matching "npm"',
|
|
702
|
+
}),
|
|
703
|
+
'Every message matching "npm"',
|
|
704
|
+
);
|
|
705
|
+
assert.equal(
|
|
706
|
+
matchSummary({
|
|
707
|
+
mode: "escalated",
|
|
708
|
+
selectedCount: 4,
|
|
709
|
+
clauses,
|
|
710
|
+
matchOperator: "all",
|
|
711
|
+
}),
|
|
712
|
+
"Every message the list is showing",
|
|
713
|
+
);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it("phrases an escalated predicate with the count the server gave it", () => {
|
|
717
|
+
assert.equal(
|
|
718
|
+
matchPhrase({
|
|
719
|
+
mode: "escalated",
|
|
720
|
+
selectedCount: 4,
|
|
721
|
+
clauses,
|
|
722
|
+
matchOperator: "all",
|
|
723
|
+
escalatedScope: 'matching "npm"',
|
|
724
|
+
escalatedCount: 3412,
|
|
725
|
+
}),
|
|
726
|
+
'all 3,412 messages matching "npm"',
|
|
727
|
+
);
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it("claims no number while the count is still being taken", () => {
|
|
731
|
+
assert.equal(
|
|
732
|
+
matchPhrase({
|
|
733
|
+
mode: "escalated",
|
|
734
|
+
selectedCount: 4,
|
|
735
|
+
clauses,
|
|
736
|
+
matchOperator: "all",
|
|
737
|
+
escalatedScope: 'matching "npm"',
|
|
738
|
+
}),
|
|
739
|
+
'every message matching "npm"',
|
|
740
|
+
);
|
|
741
|
+
});
|
|
742
|
+
|
|
664
743
|
it("phrases each door for the review sentence", () => {
|
|
665
744
|
assert.equal(
|
|
666
745
|
matchPhrase({
|