@remit/ui 0.0.59 → 0.0.61
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/filter-sheet.stories.tsx +14 -0
- package/src/components/filter-sheet.tsx +46 -35
- package/src/components/message-list-pane.stories.tsx +21 -10
- package/src/components/message-list-pane.tsx +7 -7
- package/src/components/popover-menu.render.test.ts +34 -0
- package/src/components/popover-menu.stories.tsx +59 -0
- package/src/components/popover-menu.tsx +38 -7
- package/src/components/selection-top-bar.render.test.ts +206 -73
- package/src/components/selection-top-bar.stories.tsx +152 -53
- package/src/components/selection-top-bar.tsx +241 -73
- package/src/components/selection-wizard.render.test.ts +25 -7
- package/src/components/selection-wizard.tsx +120 -50
- package/src/index.ts +3 -10
- 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,
|
|
@@ -133,7 +134,16 @@ export function WizardScreen({
|
|
|
133
134
|
}: WizardScreenProps) {
|
|
134
135
|
const active = stepIndex(steps, step);
|
|
135
136
|
return (
|
|
136
|
-
|
|
137
|
+
// A modal at every width: full-bleed below 768px it covers the list rather
|
|
138
|
+
// than sitting beside it, so the list behind must leave the accessibility
|
|
139
|
+
// tree with it — otherwise the verbs that opened the wizard are still
|
|
140
|
+
// reachable underneath the screen that replaced them.
|
|
141
|
+
<div
|
|
142
|
+
role="dialog"
|
|
143
|
+
aria-modal="true"
|
|
144
|
+
aria-label={title}
|
|
145
|
+
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"
|
|
146
|
+
>
|
|
137
147
|
<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
148
|
<header className="shrink-0 border-b border-line px-3 pb-2 pt-3">
|
|
139
149
|
<div className="flex items-center gap-1">
|
|
@@ -190,6 +200,12 @@ export interface ChoiceCardProps {
|
|
|
190
200
|
children?: ReactNode;
|
|
191
201
|
}
|
|
192
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Nothing disables (#477 1.7). An unavailable choice is dimmed and stays
|
|
205
|
+
* pressable, because pressing it is what takes the user somewhere that works —
|
|
206
|
+
* `aria-disabled` would announce it as a control that does nothing and take that
|
|
207
|
+
* away. What makes it unavailable is announced with it instead.
|
|
208
|
+
*/
|
|
193
209
|
export function ChoiceCard({
|
|
194
210
|
selected,
|
|
195
211
|
title,
|
|
@@ -198,13 +214,14 @@ export function ChoiceCard({
|
|
|
198
214
|
onSelect,
|
|
199
215
|
children,
|
|
200
216
|
}: ChoiceCardProps) {
|
|
217
|
+
const hintId = useId();
|
|
201
218
|
return (
|
|
202
219
|
<div className="space-y-1.5">
|
|
203
220
|
<button
|
|
204
221
|
type="button"
|
|
205
222
|
onClick={onSelect}
|
|
206
223
|
aria-pressed={selected}
|
|
207
|
-
aria-
|
|
224
|
+
aria-describedby={unavailable && children ? hintId : undefined}
|
|
208
225
|
className={cn(
|
|
209
226
|
"flex w-full items-start gap-3 rounded-lg border p-3 text-left transition-colors",
|
|
210
227
|
selected
|
|
@@ -228,7 +245,7 @@ export function ChoiceCard({
|
|
|
228
245
|
</span>
|
|
229
246
|
</span>
|
|
230
247
|
</button>
|
|
231
|
-
{children}
|
|
248
|
+
{children && <div id={hintId}>{children}</div>}
|
|
232
249
|
</div>
|
|
233
250
|
);
|
|
234
251
|
}
|
|
@@ -244,6 +261,12 @@ export interface SelectionSampleProps {
|
|
|
244
261
|
label: string;
|
|
245
262
|
/** Why there are no rows. Defaults to nothing matching, never to a bare empty state. */
|
|
246
263
|
emptyReason?: SampleEmptyReason;
|
|
264
|
+
/**
|
|
265
|
+
* The rows are still being fetched. No rows yet is not the same answer as no
|
|
266
|
+
* rows at all, and "nothing matches" beside a server count that says otherwise
|
|
267
|
+
* is the wrong conclusion (#477 3.5).
|
|
268
|
+
*/
|
|
269
|
+
loading?: boolean;
|
|
247
270
|
}
|
|
248
271
|
|
|
249
272
|
const sampleFooter = (count: MatchCount, shown: number): string => {
|
|
@@ -266,6 +289,7 @@ export function SelectionSample({
|
|
|
266
289
|
count,
|
|
267
290
|
label,
|
|
268
291
|
emptyReason,
|
|
292
|
+
loading,
|
|
269
293
|
}: SelectionSampleProps) {
|
|
270
294
|
return (
|
|
271
295
|
<section className="flex flex-col rounded-lg border border-line bg-surface">
|
|
@@ -274,7 +298,9 @@ export function SelectionSample({
|
|
|
274
298
|
</h2>
|
|
275
299
|
{messages.length === 0 ? (
|
|
276
300
|
<p className="px-3 py-4 text-xs text-fg-muted">
|
|
277
|
-
{
|
|
301
|
+
{loading
|
|
302
|
+
? "Fetching the messages this covers…"
|
|
303
|
+
: sampleEmptyCopy(emptyReason ?? "noMatch")}
|
|
278
304
|
</p>
|
|
279
305
|
) : (
|
|
280
306
|
<>
|
|
@@ -316,6 +342,13 @@ export interface FooterNavProps {
|
|
|
316
342
|
nudged?: boolean;
|
|
317
343
|
}
|
|
318
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Nothing disables (#477 1.7). A Continue with an answer still missing is dimmed
|
|
347
|
+
* and stays pressable — and stays pressable to assistive technology too, which
|
|
348
|
+
* `aria-disabled` would have taken away along with the reason. The reason is on
|
|
349
|
+
* the control the whole time it applies, through `aria-describedby`; pressing it
|
|
350
|
+
* is what brings the reason on screen.
|
|
351
|
+
*/
|
|
319
352
|
export function FooterNav({
|
|
320
353
|
backLabel = "Back",
|
|
321
354
|
onBack,
|
|
@@ -325,10 +358,15 @@ export function FooterNav({
|
|
|
325
358
|
blockedReason,
|
|
326
359
|
nudged,
|
|
327
360
|
}: FooterNavProps) {
|
|
361
|
+
const reasonId = useId();
|
|
328
362
|
return (
|
|
329
363
|
<div className="space-y-2">
|
|
330
|
-
{
|
|
331
|
-
<p
|
|
364
|
+
{blockedReason && (
|
|
365
|
+
<p
|
|
366
|
+
id={reasonId}
|
|
367
|
+
role={nudged ? "status" : undefined}
|
|
368
|
+
className={cn("px-1 text-2xs text-warning", !nudged && "sr-only")}
|
|
369
|
+
>
|
|
332
370
|
{blockedReason}
|
|
333
371
|
</p>
|
|
334
372
|
)}
|
|
@@ -346,7 +384,7 @@ export function FooterNav({
|
|
|
346
384
|
variant={nextVariant}
|
|
347
385
|
size="touch"
|
|
348
386
|
onClick={onNext}
|
|
349
|
-
aria-
|
|
387
|
+
aria-describedby={blockedReason ? reasonId : undefined}
|
|
350
388
|
className={cn("flex-1", blockedReason && "opacity-55")}
|
|
351
389
|
>
|
|
352
390
|
{nextLabel}
|
|
@@ -366,6 +404,12 @@ export interface MatchStepProps {
|
|
|
366
404
|
* of the deployment: the door stays pressable and dimmed.
|
|
367
405
|
*/
|
|
368
406
|
semanticUnavailable?: boolean;
|
|
407
|
+
/**
|
|
408
|
+
* What the mail server said when the widen was asked to run and failed. A
|
|
409
|
+
* failure is a reason the door cannot run, so it dims the door like any other
|
|
410
|
+
* — with the server's own words under it rather than a generic line.
|
|
411
|
+
*/
|
|
412
|
+
semanticErrorDetail?: string;
|
|
369
413
|
/** The dimmed door was pressed and the senders were filled in instead. */
|
|
370
414
|
semanticFallbackTaken?: boolean;
|
|
371
415
|
onSemanticFallback: () => void;
|
|
@@ -377,6 +421,7 @@ export function MatchStepBody({
|
|
|
377
421
|
mode,
|
|
378
422
|
onModeChange,
|
|
379
423
|
semanticUnavailable,
|
|
424
|
+
semanticErrorDetail,
|
|
380
425
|
semanticFallbackTaken,
|
|
381
426
|
onSemanticFallback,
|
|
382
427
|
sample,
|
|
@@ -401,6 +446,11 @@ export function MatchStepBody({
|
|
|
401
446
|
title={matchDoorLabel("similar", selectedCount)}
|
|
402
447
|
description={matchDoorHint("similar")}
|
|
403
448
|
>
|
|
449
|
+
{semanticErrorDetail && (
|
|
450
|
+
<p role="status" className="px-1 text-2xs text-danger">
|
|
451
|
+
Couldn't find similar messages: {semanticErrorDetail}
|
|
452
|
+
</p>
|
|
453
|
+
)}
|
|
404
454
|
{semanticFallbackTaken && (
|
|
405
455
|
<p role="status" className="px-1 text-2xs text-fg-subtle">
|
|
406
456
|
Similar-mail matching is unavailable right now — matching on the
|
|
@@ -568,6 +618,12 @@ export interface FolderStepProps {
|
|
|
568
618
|
name: string,
|
|
569
619
|
signal?: AbortSignal,
|
|
570
620
|
) => Promise<MoveMailboxOption>;
|
|
621
|
+
/**
|
|
622
|
+
* Why there is no folder list to choose from — a selection spanning accounts
|
|
623
|
+
* has no single account whose folders these could be (#477 5.5). Said here
|
|
624
|
+
* rather than left as an empty picker.
|
|
625
|
+
*/
|
|
626
|
+
restriction?: string;
|
|
571
627
|
}
|
|
572
628
|
|
|
573
629
|
export function FolderStepBody({
|
|
@@ -575,14 +631,21 @@ export function FolderStepBody({
|
|
|
575
631
|
mailboxId,
|
|
576
632
|
onSelect,
|
|
577
633
|
onCreateFolder,
|
|
634
|
+
restriction,
|
|
578
635
|
}: FolderStepProps) {
|
|
579
636
|
const chosen = mailboxes.find((mailbox) => mailbox.id === mailboxId);
|
|
580
637
|
return (
|
|
581
638
|
<div className="flex min-h-0 flex-col gap-3">
|
|
582
|
-
<p
|
|
583
|
-
{
|
|
584
|
-
|
|
585
|
-
|
|
639
|
+
<p
|
|
640
|
+
className={cn(
|
|
641
|
+
"px-1 text-xs",
|
|
642
|
+
restriction ? "text-warning" : "text-fg-muted",
|
|
643
|
+
)}
|
|
644
|
+
>
|
|
645
|
+
{restriction ??
|
|
646
|
+
(chosen
|
|
647
|
+
? `Moving to ${chosen.label}.`
|
|
648
|
+
: "Search for a folder, or type a name that doesn't exist yet to make one.")}
|
|
586
649
|
</p>
|
|
587
650
|
<div className="overflow-hidden rounded-lg border border-line bg-surface">
|
|
588
651
|
<MoveMailboxPicker
|
|
@@ -611,16 +674,26 @@ export interface RuleStepProps {
|
|
|
611
674
|
draft: WizardDraft;
|
|
612
675
|
onScopeChange: (scope: RuleScope) => void;
|
|
613
676
|
onUntilChange: (until: string) => void;
|
|
677
|
+
/**
|
|
678
|
+
* Why the two persisting scopes cannot be reached from here — a selection
|
|
679
|
+
* spanning accounts has no single account to create the rule for (#477 5.5).
|
|
680
|
+
* They stay pressable and say this; the one-off scope is unaffected.
|
|
681
|
+
*/
|
|
682
|
+
restriction?: string;
|
|
614
683
|
}
|
|
615
684
|
|
|
616
685
|
export function RuleStepBody({
|
|
617
686
|
draft,
|
|
618
687
|
onScopeChange,
|
|
619
688
|
onUntilChange,
|
|
689
|
+
restriction,
|
|
620
690
|
}: RuleStepProps) {
|
|
621
691
|
const untilId = useId();
|
|
622
692
|
const { scope, until = "" } = draft;
|
|
623
693
|
const unreadable = unreadableDraftClauses(draft).length > 0;
|
|
694
|
+
const restricted = restriction && (
|
|
695
|
+
<p className="px-1 text-2xs text-warning">{restriction}</p>
|
|
696
|
+
);
|
|
624
697
|
|
|
625
698
|
return (
|
|
626
699
|
<div className="space-y-2">
|
|
@@ -638,16 +711,21 @@ export function RuleStepBody({
|
|
|
638
711
|
</ChoiceCard>
|
|
639
712
|
<ChoiceCard
|
|
640
713
|
selected={scope === "standing"}
|
|
714
|
+
unavailable={Boolean(restriction)}
|
|
641
715
|
onSelect={() => onScopeChange("standing")}
|
|
642
716
|
title={scopeLabel("standing")}
|
|
643
717
|
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
|
-
|
|
718
|
+
>
|
|
719
|
+
{restricted}
|
|
720
|
+
</ChoiceCard>
|
|
645
721
|
<ChoiceCard
|
|
646
722
|
selected={scope === "until"}
|
|
723
|
+
unavailable={Boolean(restriction)}
|
|
647
724
|
onSelect={() => onScopeChange("until")}
|
|
648
725
|
title={scopeLabel("until")}
|
|
649
726
|
description="The same rule, and it stops on a day you pick."
|
|
650
727
|
>
|
|
728
|
+
{restricted}
|
|
651
729
|
{scope === "until" && (
|
|
652
730
|
<div className="rounded-lg border border-line bg-surface p-2">
|
|
653
731
|
<FieldLabel htmlFor={untilId}>Stops on</FieldLabel>
|
|
@@ -753,7 +831,7 @@ export function ReviewStepBody({
|
|
|
753
831
|
<p className="text-sm text-fg">
|
|
754
832
|
<span className="font-semibold">{label}</span>{" "}
|
|
755
833
|
{matchPhrase(description)}
|
|
756
|
-
{
|
|
834
|
+
{folder ? ` to ${folder}` : ""}
|
|
757
835
|
{persists && (
|
|
758
836
|
<>
|
|
759
837
|
{" "}
|
|
@@ -818,10 +896,25 @@ export interface RunStepProps {
|
|
|
818
896
|
applied: number;
|
|
819
897
|
/** The messages the mail server rejected, named one by one. */
|
|
820
898
|
failures: readonly WizardMessage[];
|
|
899
|
+
/**
|
|
900
|
+
* How many the mail server rejected, when that is more than can be named. A
|
|
901
|
+
* server-side pass reports its own failure count without handing back the
|
|
902
|
+
* messages behind it; defaults to the ones named here.
|
|
903
|
+
*/
|
|
904
|
+
failedCount?: number;
|
|
821
905
|
onRetry: () => void;
|
|
822
906
|
onDismiss: () => void;
|
|
823
907
|
}
|
|
824
908
|
|
|
909
|
+
const runOutcomeOf = (props: RunStepProps): RunOutcome => ({
|
|
910
|
+
state: props.state,
|
|
911
|
+
verb: props.verb,
|
|
912
|
+
scope: props.scope,
|
|
913
|
+
matched: props.matched,
|
|
914
|
+
applied: props.applied,
|
|
915
|
+
failed: props.failedCount ?? props.failures.length,
|
|
916
|
+
});
|
|
917
|
+
|
|
825
918
|
const runIcon = (tone: RunCopy["tone"]): ReactNode => {
|
|
826
919
|
if (tone === "progress") {
|
|
827
920
|
return <Loader2 className="size-10 animate-spin text-accent" />;
|
|
@@ -837,22 +930,10 @@ const runIcon = (tone: RunCopy["tone"]): ReactNode => {
|
|
|
837
930
|
);
|
|
838
931
|
};
|
|
839
932
|
|
|
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
|
-
});
|
|
933
|
+
export function RunStepBody(props: RunStepProps) {
|
|
934
|
+
const { matched, applied, failures } = props;
|
|
935
|
+
const outcome = runOutcomeOf(props);
|
|
936
|
+
const copy = runCopy(outcome);
|
|
856
937
|
|
|
857
938
|
return (
|
|
858
939
|
<div className="space-y-4 pt-2">
|
|
@@ -866,7 +947,7 @@ export function RunStepBody({
|
|
|
866
947
|
<ProgressBar
|
|
867
948
|
value={applied}
|
|
868
949
|
max={matched}
|
|
869
|
-
tone={
|
|
950
|
+
tone={outcome.failed > 0 ? "warning" : "success"}
|
|
870
951
|
/>
|
|
871
952
|
)}
|
|
872
953
|
|
|
@@ -894,24 +975,9 @@ export function RunStepBody({
|
|
|
894
975
|
);
|
|
895
976
|
}
|
|
896
977
|
|
|
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
|
-
});
|
|
978
|
+
export function RunFooter(props: RunStepProps) {
|
|
979
|
+
const { onRetry, onDismiss } = props;
|
|
980
|
+
const copy = runCopy(runOutcomeOf(props));
|
|
915
981
|
|
|
916
982
|
if (copy.retryLabel === undefined) {
|
|
917
983
|
return (
|
|
@@ -1036,12 +1102,17 @@ export function SelectionWizard(props: SelectionWizardProps) {
|
|
|
1036
1102
|
if (step === "run") return <RunFooter {...stepProps(props.run, step)} />;
|
|
1037
1103
|
if (step === "review") {
|
|
1038
1104
|
const review = stepProps(props.review, step);
|
|
1105
|
+
// The commit is a Continue like any other: a match that reaches nothing,
|
|
1106
|
+
// or a count still moving, dims it and says so rather than committing a
|
|
1107
|
+
// bulk action over nothing and reporting it as done.
|
|
1039
1108
|
return (
|
|
1040
1109
|
<FooterNav
|
|
1041
1110
|
onBack={onBack}
|
|
1042
1111
|
nextLabel={review.scope ? commitLabel(review.scope) : label}
|
|
1043
1112
|
nextVariant={destructive ? "danger" : "primary"}
|
|
1044
1113
|
onNext={onCommit}
|
|
1114
|
+
blockedReason={props.blockedReason}
|
|
1115
|
+
nudged={props.nudged}
|
|
1045
1116
|
/>
|
|
1046
1117
|
);
|
|
1047
1118
|
}
|
|
@@ -1058,8 +1129,7 @@ export function SelectionWizard(props: SelectionWizardProps) {
|
|
|
1058
1129
|
|
|
1059
1130
|
const title = (): string => {
|
|
1060
1131
|
if (step !== "run") return screen.title ?? label;
|
|
1061
|
-
|
|
1062
|
-
return runCopy({ ...run, failed: run.failures.length }).screenTitle;
|
|
1132
|
+
return runCopy(runOutcomeOf(stepProps(props.run, step))).screenTitle;
|
|
1063
1133
|
};
|
|
1064
1134
|
|
|
1065
1135
|
return (
|
package/src/index.ts
CHANGED
|
@@ -469,21 +469,12 @@ export {
|
|
|
469
469
|
type SegmentedOption,
|
|
470
470
|
} from "./components/segmented-control.js";
|
|
471
471
|
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
472
|
export {
|
|
483
473
|
SelectionTopBar,
|
|
484
474
|
type SelectionTopBarNotice,
|
|
485
475
|
type SelectionTopBarNoticeAction,
|
|
486
476
|
type SelectionTopBarProps,
|
|
477
|
+
type SelectionTopBarSelectAll,
|
|
487
478
|
} from "./components/selection-top-bar.js";
|
|
488
479
|
export {
|
|
489
480
|
FolderStepBody,
|
|
@@ -671,6 +662,8 @@ export {
|
|
|
671
662
|
backExits,
|
|
672
663
|
clauseSentence,
|
|
673
664
|
clauseWords,
|
|
665
|
+
crossAccountDestinationReason,
|
|
666
|
+
crossAccountRuleReason,
|
|
674
667
|
type MatchCount,
|
|
675
668
|
type MatchDescription,
|
|
676
669
|
type MatchMode,
|
|
@@ -80,24 +80,25 @@ describe("stepsFor — the step ids for every verb × match mode × scope", () =
|
|
|
80
80
|
]);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
it("gives Organize a scope step, and a naming step only where the scope persists", () => {
|
|
83
|
+
it("gives Organize a destination and a scope step, and a naming step only where the scope persists", () => {
|
|
84
84
|
assert.deepEqual(stepsFor({ verb: "organize", mode: "selected" }), [
|
|
85
85
|
"match",
|
|
86
|
+
"folder",
|
|
86
87
|
"rule",
|
|
87
88
|
"review",
|
|
88
89
|
"run",
|
|
89
90
|
]);
|
|
90
91
|
assert.deepEqual(
|
|
91
92
|
stepsFor({ verb: "organize", mode: "selected", scope: "once" }),
|
|
92
|
-
["match", "rule", "review", "run"],
|
|
93
|
+
["match", "folder", "rule", "review", "run"],
|
|
93
94
|
);
|
|
94
95
|
assert.deepEqual(
|
|
95
96
|
stepsFor({ verb: "organize", mode: "similar", scope: "standing" }),
|
|
96
|
-
["match", "rule", "name", "review", "run"],
|
|
97
|
+
["match", "folder", "rule", "name", "review", "run"],
|
|
97
98
|
);
|
|
98
99
|
assert.deepEqual(
|
|
99
100
|
stepsFor({ verb: "organize", mode: "properties", scope: "until" }),
|
|
100
|
-
["match", "properties", "rule", "name", "review", "run"],
|
|
101
|
+
["match", "properties", "folder", "rule", "name", "review", "run"],
|
|
101
102
|
);
|
|
102
103
|
});
|
|
103
104
|
|
|
@@ -109,7 +110,7 @@ describe("stepsFor — the step ids for every verb × match mode × scope", () =
|
|
|
109
110
|
scope: "standing",
|
|
110
111
|
fromSearch: true,
|
|
111
112
|
}),
|
|
112
|
-
["properties", "rule", "name", "review", "run"],
|
|
113
|
+
["properties", "folder", "rule", "name", "review", "run"],
|
|
113
114
|
);
|
|
114
115
|
assert.deepEqual(
|
|
115
116
|
stepsFor({ verb: "move", mode: "properties", fromSearch: true }),
|
|
@@ -171,14 +172,21 @@ describe("stepsFor — a branching answer only adds or drops a step after itself
|
|
|
171
172
|
mode: "selected",
|
|
172
173
|
scope: "standing",
|
|
173
174
|
});
|
|
174
|
-
assert.deepEqual(standing, [
|
|
175
|
+
assert.deepEqual(standing, [
|
|
176
|
+
"match",
|
|
177
|
+
"folder",
|
|
178
|
+
"rule",
|
|
179
|
+
"name",
|
|
180
|
+
"review",
|
|
181
|
+
"run",
|
|
182
|
+
]);
|
|
175
183
|
|
|
176
184
|
const once = stepsFor({
|
|
177
185
|
verb: "organize",
|
|
178
186
|
mode: "selected",
|
|
179
187
|
scope: "once",
|
|
180
188
|
});
|
|
181
|
-
assert.deepEqual(once, ["match", "rule", "review", "run"]);
|
|
189
|
+
assert.deepEqual(once, ["match", "folder", "rule", "review", "run"]);
|
|
182
190
|
|
|
183
191
|
// The user is on Review when they go back and change the scope to once.
|
|
184
192
|
// Held by number, index 3 lands them on Run — the action taken without the
|
|
@@ -222,7 +230,7 @@ describe("stepsFor — a branching answer only adds or drops a step after itself
|
|
|
222
230
|
const shortened = stepsFor({ verb: "organize", mode: "selected" });
|
|
223
231
|
assert.equal(shortened.includes("properties"), false);
|
|
224
232
|
assert.equal(stepIndex(shortened, "properties"), 0);
|
|
225
|
-
assert.equal(stepIndex(shortened, "rule"),
|
|
233
|
+
assert.equal(stepIndex(shortened, "rule"), 2);
|
|
226
234
|
});
|
|
227
235
|
});
|
|
228
236
|
|
|
@@ -370,6 +378,27 @@ describe("stepBlockedReason", () => {
|
|
|
370
378
|
);
|
|
371
379
|
});
|
|
372
380
|
|
|
381
|
+
it("refuses to commit a match that reaches nothing", () => {
|
|
382
|
+
// A settled zero is a whole answer: committing it would report a bulk
|
|
383
|
+
// action over no mail. The reload path lands here too — the step comes
|
|
384
|
+
// back in the URL and the ticked rows do not come back with it.
|
|
385
|
+
const reason = stepBlockedReason("review", draft(), {
|
|
386
|
+
status: "ready",
|
|
387
|
+
count: 0,
|
|
388
|
+
});
|
|
389
|
+
assert.ok(reason);
|
|
390
|
+
assert.match(reason, /nothing to do/);
|
|
391
|
+
// A zero that is still being recounted is not that answer yet.
|
|
392
|
+
assert.equal(
|
|
393
|
+
stepBlockedReason("review", draft(), {
|
|
394
|
+
status: "ready",
|
|
395
|
+
count: 0,
|
|
396
|
+
stale: true,
|
|
397
|
+
}),
|
|
398
|
+
ruleBlockedCopy.recounting,
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
|
|
373
402
|
it("waits on no count where a widened door never had one", () => {
|
|
374
403
|
assert.equal(stepBlockedReason("review", draft(), UNCOUNTED), undefined);
|
|
375
404
|
});
|
package/src/lib/wizard-steps.ts
CHANGED
|
@@ -112,8 +112,9 @@ export interface WizardAnswers {
|
|
|
112
112
|
|
|
113
113
|
/**
|
|
114
114
|
* The steps this flow walks, in order. The property door earns an editor step,
|
|
115
|
-
* Move
|
|
116
|
-
*
|
|
115
|
+
* Move and Organize both earn a destination step — a rule that moves nothing is
|
|
116
|
+
* a rule with nothing to commit — Organize earns a scope step after it, and a
|
|
117
|
+
* scope that persists earns a naming step.
|
|
117
118
|
*/
|
|
118
119
|
export const stepsFor = ({
|
|
119
120
|
verb,
|
|
@@ -129,9 +130,9 @@ export const stepsFor = ({
|
|
|
129
130
|
if (verb === "move") return [...opening, "folder", "review", "run"];
|
|
130
131
|
if (verb === "organize") {
|
|
131
132
|
if (scope === "standing" || scope === "until") {
|
|
132
|
-
return [...opening, "rule", "name", "review", "run"];
|
|
133
|
+
return [...opening, "folder", "rule", "name", "review", "run"];
|
|
133
134
|
}
|
|
134
|
-
return [...opening, "rule", "review", "run"];
|
|
135
|
+
return [...opening, "folder", "rule", "review", "run"];
|
|
135
136
|
}
|
|
136
137
|
return [...opening, "review", "run"];
|
|
137
138
|
};
|
|
@@ -200,6 +201,27 @@ export const unreadableDraftClauses = (draft: WizardDraft): RuleClause[] =>
|
|
|
200
201
|
*/
|
|
201
202
|
export type MatchCount = PreviewCount | { status: "uncounted" };
|
|
202
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Why a selection spanning accounts cannot become a rule (#477 5.5). A filter
|
|
206
|
+
* belongs to the account it was created for, so the two persisting scopes have
|
|
207
|
+
* nothing to attach to; the one-off scope acts on the messages themselves and is
|
|
208
|
+
* unaffected.
|
|
209
|
+
*/
|
|
210
|
+
export const crossAccountRuleReason =
|
|
211
|
+
"A rule only works within one account — clear the selection, or pick messages from a single account.";
|
|
212
|
+
|
|
213
|
+
/** The same restriction on the step that asks for a folder. */
|
|
214
|
+
export const crossAccountDestinationReason =
|
|
215
|
+
"A destination only works within one account — clear the selection, or pick messages from a single account.";
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The match reached nothing, so committing it would report a bulk action that
|
|
219
|
+
* touched no mail. Reachable both from a predicate that matches nothing and from
|
|
220
|
+
* a wizard restored by a reload, whose ticked rows did not come back with it.
|
|
221
|
+
*/
|
|
222
|
+
const EMPTY_MATCH =
|
|
223
|
+
"This matches no messages, so there is nothing to do. Go back and change what it applies to.";
|
|
224
|
+
|
|
203
225
|
/** A clause chip that was added but never filled in. The rule editor has no equivalent — it holds its draft until the value is typed. */
|
|
204
226
|
const INCOMPLETE_CLAUSE = "Fill in every property, or take the empty one off.";
|
|
205
227
|
const NO_DESTINATION = "Pick a destination first.";
|
|
@@ -244,9 +266,11 @@ export const stepBlockedReason = (
|
|
|
244
266
|
}
|
|
245
267
|
if (step === "name" && !draft.name?.trim()) return ruleBlockedCopy.unnamed;
|
|
246
268
|
if (step === "review") {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
269
|
+
if (count.status === "uncounted") return undefined;
|
|
270
|
+
if (count.status === "ready" && count.count === 0 && !count.stale) {
|
|
271
|
+
return EMPTY_MATCH;
|
|
272
|
+
}
|
|
273
|
+
return previewSettledReason(count);
|
|
250
274
|
}
|
|
251
275
|
return undefined;
|
|
252
276
|
};
|
|
@@ -364,7 +388,7 @@ export const runCopy = ({
|
|
|
364
388
|
title: standing ? "Rule saved and applied" : `${past} ${applied}`,
|
|
365
389
|
detail: standing
|
|
366
390
|
? `${applied} of ${matched} already in your mailbox ${done}. New mail follows the rule as it arrives.`
|
|
367
|
-
: `Every message the match reached was ${done}.`,
|
|
391
|
+
: `Every message the match reached was ${done}. Your mail server is still catching up, so the list can lag behind.`,
|
|
368
392
|
tone: "success",
|
|
369
393
|
dismissLabel: "Done",
|
|
370
394
|
};
|