@remit/ui 0.0.62 → 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/message-list-pane.tsx +32 -4
- 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 +70 -0
- package/src/components/selection-wizard.tsx +62 -5
- package/src/index.ts +5 -0
- package/src/lib/wizard-steps.test.ts +80 -1
- package/src/lib/wizard-steps.ts +83 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Menu } from "lucide-react";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
|
-
import { useRef, useState } from "react";
|
|
3
|
+
import { useMemo, useRef, useState } from "react";
|
|
4
4
|
import { LIST_ROW_SELECTOR, useRovingFocus } from "../lib/roving-focus.js";
|
|
5
5
|
import type { AppShellProps, TouchSeed } from "./app-shell-types.js";
|
|
6
6
|
import { BriefSections } from "./brief-sections.js";
|
|
@@ -125,7 +125,27 @@ export function MessageListPane({
|
|
|
125
125
|
? new Set(seededRows.slice(0, 2).map((t) => t.id))
|
|
126
126
|
: new Set(),
|
|
127
127
|
);
|
|
128
|
+
// What the fallback bar's verbs have done to the mock rows. A demo bar whose
|
|
129
|
+
// Trash only closes the bar is a Trash that deletes nothing, which is the one
|
|
130
|
+
// thing a selection bar must never be — so these verbs act on the rows the
|
|
131
|
+
// mock owns, the same way `refresh` below fakes a refresh visibly.
|
|
132
|
+
const [trashedIds, setTrashedIds] = useState<ReadonlySet<string>>(new Set());
|
|
133
|
+
const [readIds, setReadIds] = useState<ReadonlySet<string>>(new Set());
|
|
128
134
|
const [refreshing, setRefreshing] = useState(false);
|
|
135
|
+
const touchSections = useMemo(
|
|
136
|
+
() =>
|
|
137
|
+
trashedIds.size === 0 && readIds.size === 0
|
|
138
|
+
? sections
|
|
139
|
+
: sections.map((section) => ({
|
|
140
|
+
...section,
|
|
141
|
+
threads: section.threads
|
|
142
|
+
.filter((thread) => !trashedIds.has(thread.id))
|
|
143
|
+
.map((thread) =>
|
|
144
|
+
readIds.has(thread.id) ? { ...thread, isRead: true } : thread,
|
|
145
|
+
),
|
|
146
|
+
})),
|
|
147
|
+
[sections, trashedIds, readIds],
|
|
148
|
+
);
|
|
129
149
|
const initialPeek: SwipePeek | undefined =
|
|
130
150
|
initialTouchState === "peek-trailing"
|
|
131
151
|
? "trailing"
|
|
@@ -150,6 +170,14 @@ export function MessageListPane({
|
|
|
150
170
|
setSelectionMode(false);
|
|
151
171
|
setCheckedIds(new Set());
|
|
152
172
|
};
|
|
173
|
+
const trashChecked = () => {
|
|
174
|
+
setTrashedIds((prev) => new Set([...prev, ...checkedIds]));
|
|
175
|
+
cancelSelection();
|
|
176
|
+
};
|
|
177
|
+
const markCheckedRead = () => {
|
|
178
|
+
setReadIds((prev) => new Set([...prev, ...checkedIds]));
|
|
179
|
+
cancelSelection();
|
|
180
|
+
};
|
|
153
181
|
const refresh = () => {
|
|
154
182
|
setRefreshing(true);
|
|
155
183
|
setTimeout(() => setRefreshing(false), 1400);
|
|
@@ -168,8 +196,8 @@ export function MessageListPane({
|
|
|
168
196
|
title={listTitle}
|
|
169
197
|
count={checkedIds.size}
|
|
170
198
|
onCancel={cancelSelection}
|
|
171
|
-
onMarkRead={
|
|
172
|
-
onDelete={
|
|
199
|
+
onMarkRead={markCheckedRead}
|
|
200
|
+
onDelete={trashChecked}
|
|
173
201
|
/>
|
|
174
202
|
) : hideHeader ? null : (
|
|
175
203
|
<header className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line px-row-inset">
|
|
@@ -230,7 +258,7 @@ export function MessageListPane({
|
|
|
230
258
|
listBody
|
|
231
259
|
) : touchTriage ? (
|
|
232
260
|
<TouchListBody
|
|
233
|
-
sections={
|
|
261
|
+
sections={touchSections}
|
|
234
262
|
selectedThreadId={selectedThreadId}
|
|
235
263
|
selectionMode={selectionMode}
|
|
236
264
|
checkedIds={checkedIds}
|
|
@@ -236,12 +236,10 @@ export const EscalationAvailable: Story = {
|
|
|
236
236
|
* query's total, not a materialized id count, and the notice offers a way
|
|
237
237
|
* back to the bounded selection.
|
|
238
238
|
*
|
|
239
|
-
* Every verb the bar carries stays available here (#114)
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
* selection that could only be deleted forced anyone wanting to file those
|
|
244
|
-
* messages back to the loaded page.
|
|
239
|
+
* Every verb the bar carries stays available here (#114), and every one of
|
|
240
|
+
* them opens the wizard, which names the predicate and states its count before
|
|
241
|
+
* anything runs (#508). From the bar's side nothing changes, which is the
|
|
242
|
+
* point: an escalated selection is a selection.
|
|
245
243
|
*/
|
|
246
244
|
export const Escalated: Story = {
|
|
247
245
|
args: {
|
|
@@ -306,22 +304,6 @@ export const DeletingWithProgress: Story = {
|
|
|
306
304
|
},
|
|
307
305
|
};
|
|
308
306
|
|
|
309
|
-
/**
|
|
310
|
-
* After a bulk delete finishes with some batches failed: the count reflects
|
|
311
|
-
* only what's still selected — the failures — not the original selection,
|
|
312
|
-
* and Retry is a real button naming how many.
|
|
313
|
-
*/
|
|
314
|
-
export const PartialFailure: Story = {
|
|
315
|
-
args: {
|
|
316
|
-
count: 340,
|
|
317
|
-
notice: {
|
|
318
|
-
tone: "danger",
|
|
319
|
-
text: "3,072 moved to Trash. 340 couldn't be deleted.",
|
|
320
|
-
action: { label: "Retry 340", onClick: () => undefined },
|
|
321
|
-
},
|
|
322
|
-
},
|
|
323
|
-
};
|
|
324
|
-
|
|
325
307
|
/**
|
|
326
308
|
* A move over an escalated selection: same chunked run as a delete, worded for
|
|
327
309
|
* the action that is running and toned as ordinary progress rather than
|
|
@@ -345,18 +327,3 @@ export const MarkingReadWithProgress: Story = {
|
|
|
345
327
|
progress: { value: 1200, max: 3412, tone: "info" },
|
|
346
328
|
},
|
|
347
329
|
};
|
|
348
|
-
|
|
349
|
-
/**
|
|
350
|
-
* Partial failure of a move rather than a delete: the notice names the action
|
|
351
|
-
* that ran, and Retry resends that same action against what is still selected.
|
|
352
|
-
*/
|
|
353
|
-
export const PartialFailureMove: Story = {
|
|
354
|
-
args: {
|
|
355
|
-
count: 340,
|
|
356
|
-
notice: {
|
|
357
|
-
tone: "danger",
|
|
358
|
-
text: "3,072 moved. 340 couldn't be moved.",
|
|
359
|
-
action: { label: "Retry 340", onClick: () => undefined },
|
|
360
|
-
},
|
|
361
|
-
},
|
|
362
|
-
};
|
|
@@ -45,12 +45,6 @@ export interface SelectionTopBarProps {
|
|
|
45
45
|
* a selection spanning accounts, or a surface with no owning mailbox.
|
|
46
46
|
*/
|
|
47
47
|
onMove?: () => void;
|
|
48
|
-
/**
|
|
49
|
-
* The Move verb for a selection the wizard cannot take: an escalated
|
|
50
|
-
* predicate, which no bounded list of ids stands in for, so it keeps the
|
|
51
|
-
* caller's own folder picker. Rendered only in `onMove`'s place.
|
|
52
|
-
*/
|
|
53
|
-
moveSlot?: ReactNode;
|
|
54
48
|
/** Opens Organize for the selection. Omitted where organize cannot be
|
|
55
49
|
* scoped to one account. */
|
|
56
50
|
onOrganize?: () => void;
|
|
@@ -124,7 +118,7 @@ export interface SelectionTopBarProps {
|
|
|
124
118
|
/**
|
|
125
119
|
* Toned status line below the action row, sometimes carrying an action
|
|
126
120
|
* button — a cross-account move restriction, a "Select all N matching…"
|
|
127
|
-
* escalation, a "Stop" during counting
|
|
121
|
+
* escalation, or a "Stop" during counting.
|
|
128
122
|
*/
|
|
129
123
|
notice?: SelectionTopBarNotice;
|
|
130
124
|
}
|
|
@@ -172,7 +166,6 @@ export function SelectionTopBar({
|
|
|
172
166
|
onCancel,
|
|
173
167
|
onDelete,
|
|
174
168
|
onMove,
|
|
175
|
-
moveSlot,
|
|
176
169
|
onOrganize,
|
|
177
170
|
onJunk,
|
|
178
171
|
onMarkRead,
|
|
@@ -278,19 +271,16 @@ export function SelectionTopBar({
|
|
|
278
271
|
aria-busy={isBusy || undefined}
|
|
279
272
|
className="shrink-0"
|
|
280
273
|
/>
|
|
281
|
-
{!isBusy &&
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
) : (
|
|
292
|
-
moveSlot
|
|
293
|
-
))}
|
|
274
|
+
{!isBusy && onMove && (
|
|
275
|
+
<Button
|
|
276
|
+
variant="ghost"
|
|
277
|
+
size="touch"
|
|
278
|
+
icon={<FolderInput className="size-5" />}
|
|
279
|
+
onClick={onMove}
|
|
280
|
+
aria-label="Move selected messages"
|
|
281
|
+
className="shrink-0"
|
|
282
|
+
/>
|
|
283
|
+
)}
|
|
294
284
|
{!isBusy && onOrganize && (
|
|
295
285
|
<Button
|
|
296
286
|
variant="ghost"
|
|
@@ -257,6 +257,36 @@ describe("MatchStepBody", () => {
|
|
|
257
257
|
assert.doesNotMatch(html, /disabled=""/);
|
|
258
258
|
assert.match(html, /matching on the\s+senders instead/);
|
|
259
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
|
+
});
|
|
260
290
|
});
|
|
261
291
|
|
|
262
292
|
describe("PropertiesStepBody", () => {
|
|
@@ -439,6 +469,29 @@ describe("ReviewStepBody", () => {
|
|
|
439
469
|
assert.match(html, /not known until the run finishes/);
|
|
440
470
|
});
|
|
441
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
|
+
|
|
442
495
|
it("names the rule and its stop date once the scope persists", () => {
|
|
443
496
|
const html = renderToString(
|
|
444
497
|
createElement(ReviewStepBody, {
|
|
@@ -486,6 +539,23 @@ describe("RunStepBody", () => {
|
|
|
486
539
|
assert.match(html, /The rule itself is saved/);
|
|
487
540
|
});
|
|
488
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
|
+
|
|
489
559
|
it("ends a one-off run on its own count", () => {
|
|
490
560
|
const html = renderToString(
|
|
491
561
|
createElement(RunStepBody, { ...runProps, scope: "once" }),
|
|
@@ -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,
|
|
@@ -414,7 +419,12 @@ export function FooterNav({
|
|
|
414
419
|
export interface MatchStepProps {
|
|
415
420
|
selectedCount: number;
|
|
416
421
|
mode: MatchMode;
|
|
417
|
-
|
|
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;
|
|
418
428
|
/**
|
|
419
429
|
* Similar-mail matching cannot run right now. A runtime state, not a property
|
|
420
430
|
* of the deployment: the door stays pressable and dimmed.
|
|
@@ -429,6 +439,12 @@ export interface MatchStepProps {
|
|
|
429
439
|
/** The dimmed door was pressed and the senders were filled in instead. */
|
|
430
440
|
semanticFallbackTaken?: boolean;
|
|
431
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;
|
|
432
448
|
sample: SelectionSampleProps;
|
|
433
449
|
}
|
|
434
450
|
|
|
@@ -440,8 +456,24 @@ export function MatchStepBody({
|
|
|
440
456
|
semanticErrorDetail,
|
|
441
457
|
semanticFallbackTaken,
|
|
442
458
|
onSemanticFallback,
|
|
459
|
+
escalatedScope,
|
|
443
460
|
sample,
|
|
444
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
|
+
}
|
|
445
477
|
return (
|
|
446
478
|
<>
|
|
447
479
|
<div className="space-y-2">
|
|
@@ -821,6 +853,8 @@ export interface ReviewStepProps {
|
|
|
821
853
|
until?: string;
|
|
822
854
|
/** Present when the flow reached the naming step. */
|
|
823
855
|
ruleName?: string;
|
|
856
|
+
/** What the escalated predicate covers, in the list's own words. */
|
|
857
|
+
escalatedScope?: string;
|
|
824
858
|
sample: SelectionSampleProps;
|
|
825
859
|
}
|
|
826
860
|
|
|
@@ -834,10 +868,21 @@ export function ReviewStepBody({
|
|
|
834
868
|
scope,
|
|
835
869
|
until,
|
|
836
870
|
ruleName,
|
|
871
|
+
escalatedScope,
|
|
837
872
|
sample,
|
|
838
873
|
}: ReviewStepProps) {
|
|
839
874
|
const { label } = verbCopy(verb);
|
|
840
|
-
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
|
+
};
|
|
841
886
|
const widened = mode !== "selected";
|
|
842
887
|
const persists = scope === "standing" || scope === "until";
|
|
843
888
|
|
|
@@ -860,7 +905,9 @@ export function ReviewStepBody({
|
|
|
860
905
|
{widened && (
|
|
861
906
|
<p className="mt-2 flex items-start gap-1.5 text-xs text-warning">
|
|
862
907
|
<AlertTriangle className="mt-px size-3.5 shrink-0" />
|
|
863
|
-
|
|
908
|
+
{mode === "escalated"
|
|
909
|
+
? ESCALATED_REVIEW_WARNING
|
|
910
|
+
: "This covers messages not shown in the list."}
|
|
864
911
|
</p>
|
|
865
912
|
)}
|
|
866
913
|
</div>
|
|
@@ -950,6 +997,9 @@ export function RunStepBody(props: RunStepProps) {
|
|
|
950
997
|
const { matched, applied, failures } = props;
|
|
951
998
|
const outcome = runOutcomeOf(props);
|
|
952
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";
|
|
953
1003
|
|
|
954
1004
|
return (
|
|
955
1005
|
<div className="space-y-4 pt-2">
|
|
@@ -980,7 +1030,7 @@ export function RunStepBody(props: RunStepProps) {
|
|
|
980
1030
|
{message.subject}
|
|
981
1031
|
</p>
|
|
982
1032
|
<Badge className="mt-1" tone="warning">
|
|
983
|
-
|
|
1033
|
+
{failureBadge}
|
|
984
1034
|
</Badge>
|
|
985
1035
|
</li>
|
|
986
1036
|
))}
|
|
@@ -1148,10 +1198,17 @@ export function SelectionWizard(props: SelectionWizardProps) {
|
|
|
1148
1198
|
return runCopy(runOutcomeOf(stepProps(props.run, step))).screenTitle;
|
|
1149
1199
|
};
|
|
1150
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
|
+
|
|
1151
1208
|
return (
|
|
1152
1209
|
<WizardScreen
|
|
1153
1210
|
title={title()}
|
|
1154
|
-
subtitle={
|
|
1211
|
+
subtitle={subtitle}
|
|
1155
1212
|
steps={steps}
|
|
1156
1213
|
step={step}
|
|
1157
1214
|
onBack={onBack}
|
package/src/index.ts
CHANGED
|
@@ -669,8 +669,13 @@ export {
|
|
|
669
669
|
clauseWords,
|
|
670
670
|
crossAccountDestinationReason,
|
|
671
671
|
crossAccountRuleReason,
|
|
672
|
+
ESCALATED_MATCH_HINT,
|
|
673
|
+
ESCALATED_REVIEW_WARNING,
|
|
674
|
+
ESCALATED_SCOPE_FALLBACK,
|
|
675
|
+
escalatedMatchLabel,
|
|
672
676
|
type MatchCount,
|
|
673
677
|
type MatchDescription,
|
|
678
|
+
type MatchDoor,
|
|
674
679
|
type MatchMode,
|
|
675
680
|
matchDoorHint,
|
|
676
681
|
matchDoorLabel,
|
|
@@ -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({
|
package/src/lib/wizard-steps.ts
CHANGED
|
@@ -70,12 +70,21 @@ const VERB_COPY: Record<Verb, VerbCopy> = {
|
|
|
70
70
|
export const verbCopy = (verb: Verb): VerbCopy => VERB_COPY[verb];
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
|
-
*
|
|
74
|
-
* `RuleMatchMode` values; `selected` is not one of them
|
|
75
|
-
* at all — it is the bounded list of ticked message
|
|
76
|
-
* stands in for.
|
|
73
|
+
* The three choices the match step offers a ticked selection. The two widened
|
|
74
|
+
* doors are the shipped `RuleMatchMode` values; `selected` is not one of them
|
|
75
|
+
* and is not a match mode at all — it is the bounded list of ticked message
|
|
76
|
+
* ids, which no predicate stands in for.
|
|
77
77
|
*/
|
|
78
|
-
export type
|
|
78
|
+
export type MatchDoor = "selected" | RuleMatchMode;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What the action is applied to. Beside the three doors sits `escalated`: the
|
|
82
|
+
* selection the list escalated to a predicate, every message matching the query
|
|
83
|
+
* it is showing rather than the rows on screen. It is offered no doors, because
|
|
84
|
+
* the predicate is already the match and there is nothing left to widen; the
|
|
85
|
+
* step names what it covers instead.
|
|
86
|
+
*/
|
|
87
|
+
export type MatchMode = MatchDoor | "escalated";
|
|
79
88
|
|
|
80
89
|
export type StepId =
|
|
81
90
|
| "match"
|
|
@@ -279,6 +288,12 @@ export const stepBlockedReason = (
|
|
|
279
288
|
* Where the run step lands. Three of these never reach a job: a filter saved
|
|
280
289
|
* with nothing to back-apply, a back-apply whose request never started, and a
|
|
281
290
|
* create that failed outright.
|
|
291
|
+
*
|
|
292
|
+
* `backApplyFailed` and `runStopped` are two different endings and must not be
|
|
293
|
+
* confused. A back-apply that failed was run by the server, which reports how
|
|
294
|
+
* many messages it could not apply the rule to. A run that stopped hit an
|
|
295
|
+
* infrastructure failure part-way: the batches it never reached were never
|
|
296
|
+
* sent, so nothing rejected them and nothing happened to them.
|
|
282
297
|
*/
|
|
283
298
|
export type RunState =
|
|
284
299
|
| "saving"
|
|
@@ -287,6 +302,7 @@ export type RunState =
|
|
|
287
302
|
| "backApplyFailed"
|
|
288
303
|
| "backApplyStartFailed"
|
|
289
304
|
| "filterSaved"
|
|
305
|
+
| "runStopped"
|
|
290
306
|
| "commitFailed";
|
|
291
307
|
|
|
292
308
|
/**
|
|
@@ -358,7 +374,8 @@ export const runCopy = ({
|
|
|
358
374
|
showProgress:
|
|
359
375
|
state === "backApplyRunning" ||
|
|
360
376
|
state === "backApplyComplete" ||
|
|
361
|
-
state === "backApplyFailed"
|
|
377
|
+
state === "backApplyFailed" ||
|
|
378
|
+
state === "runStopped",
|
|
362
379
|
failureListLabel: `Not ${done}`,
|
|
363
380
|
};
|
|
364
381
|
|
|
@@ -399,7 +416,7 @@ export const runCopy = ({
|
|
|
399
416
|
title: standing
|
|
400
417
|
? "Rule saved — some mail stayed put"
|
|
401
418
|
: `Not everything was ${done}`,
|
|
402
|
-
detail: `${applied} of ${matched} ${done} ·
|
|
419
|
+
detail: `${applied} of ${matched} ${done} · the mail server rejected ${failed}.${
|
|
403
420
|
standing
|
|
404
421
|
? " The rule itself is saved and keeps working on new mail."
|
|
405
422
|
: ""
|
|
@@ -409,6 +426,16 @@ export const runCopy = ({
|
|
|
409
426
|
retryLabel: `Retry ${failed}`,
|
|
410
427
|
};
|
|
411
428
|
}
|
|
429
|
+
if (state === "runStopped") {
|
|
430
|
+
return {
|
|
431
|
+
...shared,
|
|
432
|
+
title: `Stopped after ${applied}`,
|
|
433
|
+
detail: `${applied} of ${matched} ${done}. The run stopped before it reached the rest, so nothing was sent for them and nothing has happened to them.`,
|
|
434
|
+
tone: "warning",
|
|
435
|
+
dismissLabel: "Close",
|
|
436
|
+
retryLabel: `Retry ${failed}`,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
412
439
|
if (state === "backApplyStartFailed") {
|
|
413
440
|
return {
|
|
414
441
|
...shared,
|
|
@@ -454,7 +481,7 @@ export const clauseSentence = (
|
|
|
454
481
|
|
|
455
482
|
/** What a match door is called, with the ticked count the widen anchors on. */
|
|
456
483
|
export const matchDoorLabel = (
|
|
457
|
-
mode:
|
|
484
|
+
mode: MatchDoor,
|
|
458
485
|
selectedCount: number,
|
|
459
486
|
): string => {
|
|
460
487
|
if (mode === "selected") return `These ${selectedCount} messages`;
|
|
@@ -463,16 +490,48 @@ export const matchDoorLabel = (
|
|
|
463
490
|
};
|
|
464
491
|
|
|
465
492
|
/** One line saying what a match door actually does, so the choice is never a guess. */
|
|
466
|
-
export const matchDoorHint = (mode:
|
|
493
|
+
export const matchDoorHint = (mode: MatchDoor): string =>
|
|
467
494
|
mode === "selected"
|
|
468
495
|
? "Only the messages ticked in the list."
|
|
469
496
|
: matchModeHint(mode);
|
|
470
497
|
|
|
498
|
+
/**
|
|
499
|
+
* What an escalated predicate covers, in the words the list already used to
|
|
500
|
+
* escalate it — `matching "npm"`. Named where the doors would be, because a
|
|
501
|
+
* predicate that reaches past the loaded rows has nothing left to widen.
|
|
502
|
+
*/
|
|
503
|
+
export const escalatedMatchLabel = (scope: string): string =>
|
|
504
|
+
`Every message ${scope}`;
|
|
505
|
+
|
|
506
|
+
/** The fallback when the list hands over no words for its predicate. */
|
|
507
|
+
export const ESCALATED_SCOPE_FALLBACK = "the list is showing";
|
|
508
|
+
|
|
509
|
+
/** Why the escalated match step states one thing instead of offering three. */
|
|
510
|
+
export const ESCALATED_MATCH_HINT =
|
|
511
|
+
"This reaches every match on the mail server, including the ones the list has not loaded. The search is already the match, so there is nothing to widen.";
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* What the count on an escalated review does and does not promise (#109). The
|
|
515
|
+
* predicate is resolved once for the count and again for the run, so the run
|
|
516
|
+
* covers whatever matches at the moment it runs — which can be more than the
|
|
517
|
+
* number beside it.
|
|
518
|
+
*/
|
|
519
|
+
export const ESCALATED_REVIEW_WARNING =
|
|
520
|
+
"This covers messages not shown in the list, and anything else matching by the time it runs.";
|
|
521
|
+
|
|
471
522
|
export interface MatchDescription {
|
|
472
523
|
mode: MatchMode;
|
|
473
524
|
selectedCount: number;
|
|
474
525
|
clauses: readonly RuleClause[];
|
|
475
526
|
matchOperator: MatchOperator;
|
|
527
|
+
/** What an escalated predicate covers, in the list's own words. */
|
|
528
|
+
escalatedScope?: string;
|
|
529
|
+
/**
|
|
530
|
+
* The server's count of that predicate. Stated with the match rather than
|
|
531
|
+
* left to the sample's footer: an escalated selection is the one match whose
|
|
532
|
+
* size is the whole reason to look before it runs.
|
|
533
|
+
*/
|
|
534
|
+
escalatedCount?: number;
|
|
476
535
|
}
|
|
477
536
|
|
|
478
537
|
/** The match on the review screen's labelled list — short enough for one row. */
|
|
@@ -481,10 +540,15 @@ export const matchSummary = ({
|
|
|
481
540
|
selectedCount,
|
|
482
541
|
clauses,
|
|
483
542
|
matchOperator,
|
|
484
|
-
|
|
485
|
-
|
|
543
|
+
escalatedScope,
|
|
544
|
+
}: MatchDescription): string => {
|
|
545
|
+
if (mode === "escalated") {
|
|
546
|
+
return escalatedMatchLabel(escalatedScope ?? ESCALATED_SCOPE_FALLBACK);
|
|
547
|
+
}
|
|
548
|
+
return mode === "properties"
|
|
486
549
|
? clauseSentence(clauses, matchOperator)
|
|
487
550
|
: matchDoorLabel(mode, selectedCount);
|
|
551
|
+
};
|
|
488
552
|
|
|
489
553
|
/** The match inside the review screen's one sentence, in the object position. */
|
|
490
554
|
export const matchPhrase = ({
|
|
@@ -492,8 +556,16 @@ export const matchPhrase = ({
|
|
|
492
556
|
selectedCount,
|
|
493
557
|
clauses,
|
|
494
558
|
matchOperator,
|
|
559
|
+
escalatedScope,
|
|
560
|
+
escalatedCount,
|
|
495
561
|
}: MatchDescription): string => {
|
|
496
562
|
if (mode === "selected") return `${selectedCount} messages`;
|
|
563
|
+
if (mode === "escalated") {
|
|
564
|
+
const scope = escalatedScope ?? ESCALATED_SCOPE_FALLBACK;
|
|
565
|
+
return escalatedCount === undefined
|
|
566
|
+
? `every message ${scope}`
|
|
567
|
+
: `all ${escalatedCount.toLocaleString()} messages ${scope}`;
|
|
568
|
+
}
|
|
497
569
|
if (mode === "similar") {
|
|
498
570
|
return `mail ${widenChipLabel({ anchorCount: selectedCount }).toLowerCase()}`;
|
|
499
571
|
}
|