@remit/ui 0.0.58 → 0.0.60
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-rule.ts +43 -20
- 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 +208 -73
- package/src/components/selection-top-bar.stories.tsx +156 -42
- package/src/components/selection-top-bar.tsx +237 -72
- package/src/components/selection-wizard.render.test.ts +725 -0
- package/src/components/selection-wizard.tsx +1078 -0
- package/src/index.ts +57 -0
- package/src/lib/rule-name.test.ts +28 -0
- package/src/lib/rule-name.ts +27 -0
- package/src/lib/sender-derivation.test.ts +53 -0
- package/src/lib/sender-derivation.ts +35 -0
- package/src/lib/wizard-steps.test.ts +673 -0
- package/src/lib/wizard-steps.ts +478 -0
|
@@ -0,0 +1,1078 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The selection wizard's shell and its seven step bodies (#477).
|
|
3
|
+
*
|
|
4
|
+
* One responsive surface: full-bleed below 768px, the same screens as a centred
|
|
5
|
+
* modal from 768px up. Every screen is a fixed header, one scrolling body and a
|
|
6
|
+
* fixed footer in the thumb zone, so back and forward are always reachable.
|
|
7
|
+
*
|
|
8
|
+
* Everything here is props-in. The step is held by id, the answers and the
|
|
9
|
+
* callbacks belong to whoever drives it, and nothing touches history.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
AlertTriangle,
|
|
14
|
+
ArrowLeft,
|
|
15
|
+
ArrowRight,
|
|
16
|
+
Check,
|
|
17
|
+
Loader2,
|
|
18
|
+
X,
|
|
19
|
+
} from "lucide-react";
|
|
20
|
+
import { Fragment, type ReactNode, useEffect, useId, useRef } from "react";
|
|
21
|
+
import { cn } from "../lib/cn.js";
|
|
22
|
+
import {
|
|
23
|
+
backExits,
|
|
24
|
+
type MatchCount,
|
|
25
|
+
type MatchMode,
|
|
26
|
+
matchDoorHint,
|
|
27
|
+
matchDoorLabel,
|
|
28
|
+
matchPhrase,
|
|
29
|
+
matchSummary,
|
|
30
|
+
type RunCopy,
|
|
31
|
+
type RunState,
|
|
32
|
+
runCopy,
|
|
33
|
+
type SampleEmptyReason,
|
|
34
|
+
type StepId,
|
|
35
|
+
sampleEmptyCopy,
|
|
36
|
+
stepIndex,
|
|
37
|
+
stepLabel,
|
|
38
|
+
unreadableDraftClauses,
|
|
39
|
+
type Verb,
|
|
40
|
+
verbCopy,
|
|
41
|
+
type WizardDraft,
|
|
42
|
+
} from "../lib/wizard-steps.js";
|
|
43
|
+
import { Badge } from "./badge.js";
|
|
44
|
+
import { Button } from "./button.js";
|
|
45
|
+
import { FieldLabel } from "./field-label.js";
|
|
46
|
+
import {
|
|
47
|
+
AddChipButton,
|
|
48
|
+
ClauseChip,
|
|
49
|
+
type ClauseDraft,
|
|
50
|
+
ClauseEditor,
|
|
51
|
+
} from "./filter-clause-chip.js";
|
|
52
|
+
import {
|
|
53
|
+
type ClauseField,
|
|
54
|
+
commitLabel,
|
|
55
|
+
type MatchOperator,
|
|
56
|
+
matchJoinWord,
|
|
57
|
+
matchOperatorLabel,
|
|
58
|
+
previewCountSummary,
|
|
59
|
+
type RuleClause,
|
|
60
|
+
type RuleScope,
|
|
61
|
+
ruleBlockedCopy,
|
|
62
|
+
scopeLabel,
|
|
63
|
+
} from "./filter-rule.js";
|
|
64
|
+
import type { ClauseEditState } from "./filter-rule-editor.js";
|
|
65
|
+
import { Input } from "./input.js";
|
|
66
|
+
import {
|
|
67
|
+
type MoveMailboxOption,
|
|
68
|
+
MoveMailboxPicker,
|
|
69
|
+
} from "./move-mailbox-picker.js";
|
|
70
|
+
import { ProgressBar } from "./progress-bar.js";
|
|
71
|
+
import type { SearchConversionNotice } from "./search-conversion.js";
|
|
72
|
+
import { SearchConversionNoticeView } from "./search-conversion-notice.js";
|
|
73
|
+
import { SegmentedControl, type SegmentedOption } from "./segmented-control.js";
|
|
74
|
+
import type { Suggestion } from "./suggest-list.js";
|
|
75
|
+
|
|
76
|
+
/** A row of the match, as the sample and the failure list render it. */
|
|
77
|
+
export interface WizardMessage {
|
|
78
|
+
id: string;
|
|
79
|
+
sender: string;
|
|
80
|
+
subject: string;
|
|
81
|
+
date: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface StepRailProps {
|
|
85
|
+
steps: readonly StepId[];
|
|
86
|
+
step: StepId;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function StepRail({ steps, step }: StepRailProps) {
|
|
90
|
+
const active = stepIndex(steps, step);
|
|
91
|
+
return (
|
|
92
|
+
<ol className="flex items-center gap-1.5" aria-label="Progress">
|
|
93
|
+
{steps.map((id, i) => (
|
|
94
|
+
<li key={id} className="flex flex-1 items-center gap-1.5">
|
|
95
|
+
<span
|
|
96
|
+
className={cn(
|
|
97
|
+
"h-1 flex-1 rounded-full transition-colors",
|
|
98
|
+
i <= active ? "bg-accent" : "bg-surface-sunken",
|
|
99
|
+
)}
|
|
100
|
+
aria-current={i === active ? "step" : undefined}
|
|
101
|
+
/>
|
|
102
|
+
</li>
|
|
103
|
+
))}
|
|
104
|
+
</ol>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface WizardScreenProps {
|
|
109
|
+
title: string;
|
|
110
|
+
subtitle?: string;
|
|
111
|
+
steps: readonly StepId[];
|
|
112
|
+
step: StepId;
|
|
113
|
+
onBack: () => void;
|
|
114
|
+
onExit: () => void;
|
|
115
|
+
footer: ReactNode;
|
|
116
|
+
children: ReactNode;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The wizard chrome. The body is the only scrolling region, so the header's back
|
|
121
|
+
* and the footer's controls never leave the screen. From 768px up the same
|
|
122
|
+
* markup centres over the list as a modal rather than becoming a second screen.
|
|
123
|
+
*/
|
|
124
|
+
export function WizardScreen({
|
|
125
|
+
title,
|
|
126
|
+
subtitle,
|
|
127
|
+
steps,
|
|
128
|
+
step,
|
|
129
|
+
onBack,
|
|
130
|
+
onExit,
|
|
131
|
+
footer,
|
|
132
|
+
children,
|
|
133
|
+
}: WizardScreenProps) {
|
|
134
|
+
const active = stepIndex(steps, step);
|
|
135
|
+
return (
|
|
136
|
+
<div className="fixed inset-0 z-50 flex flex-col font-sans text-fg md:items-center md:justify-center md:bg-black/40 md:p-6">
|
|
137
|
+
<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
|
+
<header className="shrink-0 border-b border-line px-3 pb-2 pt-3">
|
|
139
|
+
<div className="flex items-center gap-1">
|
|
140
|
+
<button
|
|
141
|
+
type="button"
|
|
142
|
+
onClick={onBack}
|
|
143
|
+
aria-label="Back"
|
|
144
|
+
className="flex size-11 items-center justify-center rounded-md text-fg-muted"
|
|
145
|
+
>
|
|
146
|
+
<ArrowLeft className="size-5" />
|
|
147
|
+
</button>
|
|
148
|
+
<div className="min-w-0 flex-1 text-center">
|
|
149
|
+
<h1 className="truncate text-sm font-semibold">{title}</h1>
|
|
150
|
+
{subtitle && (
|
|
151
|
+
<p className="truncate text-2xs text-fg-muted">{subtitle}</p>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
onClick={onExit}
|
|
157
|
+
aria-label="Cancel"
|
|
158
|
+
className="flex size-11 items-center justify-center rounded-md text-fg-muted"
|
|
159
|
+
>
|
|
160
|
+
<X className="size-5" />
|
|
161
|
+
</button>
|
|
162
|
+
</div>
|
|
163
|
+
<div className="space-y-1 px-1 pt-2">
|
|
164
|
+
<StepRail steps={steps} step={step} />
|
|
165
|
+
<p className="text-2xs text-fg-subtle">
|
|
166
|
+
Step {active + 1} of {steps.length} · {stepLabel(steps[active])}
|
|
167
|
+
</p>
|
|
168
|
+
</div>
|
|
169
|
+
</header>
|
|
170
|
+
|
|
171
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
|
172
|
+
{children}
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<footer className="shrink-0 border-t border-line px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] md:pb-3">
|
|
176
|
+
{footer}
|
|
177
|
+
</footer>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface ChoiceCardProps {
|
|
184
|
+
selected: boolean;
|
|
185
|
+
title: ReactNode;
|
|
186
|
+
description: ReactNode;
|
|
187
|
+
/** Stays pressable and dimmed; pressing it must take the user somewhere that works. */
|
|
188
|
+
unavailable?: boolean;
|
|
189
|
+
onSelect: () => void;
|
|
190
|
+
children?: ReactNode;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function ChoiceCard({
|
|
194
|
+
selected,
|
|
195
|
+
title,
|
|
196
|
+
description,
|
|
197
|
+
unavailable,
|
|
198
|
+
onSelect,
|
|
199
|
+
children,
|
|
200
|
+
}: ChoiceCardProps) {
|
|
201
|
+
return (
|
|
202
|
+
<div className="space-y-1.5">
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
onClick={onSelect}
|
|
206
|
+
aria-pressed={selected}
|
|
207
|
+
aria-disabled={unavailable || undefined}
|
|
208
|
+
className={cn(
|
|
209
|
+
"flex w-full items-start gap-3 rounded-lg border p-3 text-left transition-colors",
|
|
210
|
+
selected
|
|
211
|
+
? "border-accent bg-accent-soft"
|
|
212
|
+
: "border-line bg-surface hover:bg-surface-sunken",
|
|
213
|
+
unavailable && "opacity-55",
|
|
214
|
+
)}
|
|
215
|
+
>
|
|
216
|
+
<span
|
|
217
|
+
className={cn(
|
|
218
|
+
"mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border",
|
|
219
|
+
selected ? "border-accent bg-accent text-accent-fg" : "border-line",
|
|
220
|
+
)}
|
|
221
|
+
>
|
|
222
|
+
{selected && <Check className="size-3" />}
|
|
223
|
+
</span>
|
|
224
|
+
<span className="min-w-0 flex-1">
|
|
225
|
+
<span className="block text-sm font-medium text-fg">{title}</span>
|
|
226
|
+
<span className="mt-0.5 block text-xs text-fg-muted">
|
|
227
|
+
{description}
|
|
228
|
+
</span>
|
|
229
|
+
</span>
|
|
230
|
+
</button>
|
|
231
|
+
{children}
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface SelectionSampleProps {
|
|
237
|
+
messages: readonly WizardMessage[];
|
|
238
|
+
/**
|
|
239
|
+
* The server's count of what the match covers (#477 5.3), carrying whether it
|
|
240
|
+
* is still moving so nothing commits against a number that is about to change.
|
|
241
|
+
* `uncounted` is the widened door, which carries no count until it has run.
|
|
242
|
+
*/
|
|
243
|
+
count: MatchCount;
|
|
244
|
+
label: string;
|
|
245
|
+
/** Why there are no rows. Defaults to nothing matching, never to a bare empty state. */
|
|
246
|
+
emptyReason?: SampleEmptyReason;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const sampleFooter = (count: MatchCount, shown: number): string => {
|
|
250
|
+
if (count.status === "uncounted") {
|
|
251
|
+
return "The first matches. The total is not known until the run finishes.";
|
|
252
|
+
}
|
|
253
|
+
if (count.status === "ready" && count.count > shown && !count.stale) {
|
|
254
|
+
return `and ${count.count - shown} more`;
|
|
255
|
+
}
|
|
256
|
+
return previewCountSummary(count);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The members of the match, closing every screen that names one. A named match
|
|
261
|
+
* with no members shown is an unseen bulk action, so the rows scroll in their own
|
|
262
|
+
* bounded region rather than truncating.
|
|
263
|
+
*/
|
|
264
|
+
export function SelectionSample({
|
|
265
|
+
messages,
|
|
266
|
+
count,
|
|
267
|
+
label,
|
|
268
|
+
emptyReason,
|
|
269
|
+
}: SelectionSampleProps) {
|
|
270
|
+
return (
|
|
271
|
+
<section className="flex flex-col rounded-lg border border-line bg-surface">
|
|
272
|
+
<h2 className="shrink-0 border-b border-line px-3 py-2 text-2xs font-medium uppercase tracking-wide text-fg-subtle">
|
|
273
|
+
{label}
|
|
274
|
+
</h2>
|
|
275
|
+
{messages.length === 0 ? (
|
|
276
|
+
<p className="px-3 py-4 text-xs text-fg-muted">
|
|
277
|
+
{sampleEmptyCopy(emptyReason ?? "noMatch")}
|
|
278
|
+
</p>
|
|
279
|
+
) : (
|
|
280
|
+
<>
|
|
281
|
+
<ul className="min-h-0 max-h-[45dvh] divide-y divide-line overflow-y-auto overscroll-contain md:max-h-[17rem]">
|
|
282
|
+
{messages.map((message) => (
|
|
283
|
+
<li key={message.id} className="px-3 py-2">
|
|
284
|
+
<div className="flex items-baseline justify-between gap-2">
|
|
285
|
+
<span className="truncate text-xs font-medium text-fg">
|
|
286
|
+
{message.sender}
|
|
287
|
+
</span>
|
|
288
|
+
<span className="shrink-0 text-2xs text-fg-subtle">
|
|
289
|
+
{message.date}
|
|
290
|
+
</span>
|
|
291
|
+
</div>
|
|
292
|
+
<p className="truncate text-xs text-fg-muted">
|
|
293
|
+
{message.subject}
|
|
294
|
+
</p>
|
|
295
|
+
</li>
|
|
296
|
+
))}
|
|
297
|
+
</ul>
|
|
298
|
+
<p className="shrink-0 border-t border-line px-3 py-2 text-2xs text-fg-subtle">
|
|
299
|
+
{sampleFooter(count, messages.length)}
|
|
300
|
+
</p>
|
|
301
|
+
</>
|
|
302
|
+
)}
|
|
303
|
+
</section>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export interface FooterNavProps {
|
|
308
|
+
backLabel?: string;
|
|
309
|
+
onBack: () => void;
|
|
310
|
+
nextLabel: string;
|
|
311
|
+
onNext: () => void;
|
|
312
|
+
nextVariant?: "primary" | "danger";
|
|
313
|
+
/** What the step is still missing. Dims the control; never disables it. */
|
|
314
|
+
blockedReason?: string;
|
|
315
|
+
/** Continue was pressed while blocked, so the reason is on screen. */
|
|
316
|
+
nudged?: boolean;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function FooterNav({
|
|
320
|
+
backLabel = "Back",
|
|
321
|
+
onBack,
|
|
322
|
+
nextLabel,
|
|
323
|
+
onNext,
|
|
324
|
+
nextVariant = "primary",
|
|
325
|
+
blockedReason,
|
|
326
|
+
nudged,
|
|
327
|
+
}: FooterNavProps) {
|
|
328
|
+
return (
|
|
329
|
+
<div className="space-y-2">
|
|
330
|
+
{nudged && blockedReason && (
|
|
331
|
+
<p role="status" className="px-1 text-2xs text-warning">
|
|
332
|
+
{blockedReason}
|
|
333
|
+
</p>
|
|
334
|
+
)}
|
|
335
|
+
<div className="flex items-center gap-3">
|
|
336
|
+
<Button
|
|
337
|
+
variant="ghost"
|
|
338
|
+
size="touch"
|
|
339
|
+
onClick={onBack}
|
|
340
|
+
icon={<ArrowLeft className="size-4" />}
|
|
341
|
+
className="shrink-0"
|
|
342
|
+
>
|
|
343
|
+
{backLabel}
|
|
344
|
+
</Button>
|
|
345
|
+
<Button
|
|
346
|
+
variant={nextVariant}
|
|
347
|
+
size="touch"
|
|
348
|
+
onClick={onNext}
|
|
349
|
+
aria-disabled={blockedReason ? true : undefined}
|
|
350
|
+
className={cn("flex-1", blockedReason && "opacity-55")}
|
|
351
|
+
>
|
|
352
|
+
{nextLabel}
|
|
353
|
+
{nextVariant === "primary" && <ArrowRight className="size-4" />}
|
|
354
|
+
</Button>
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export interface MatchStepProps {
|
|
361
|
+
selectedCount: number;
|
|
362
|
+
mode: MatchMode;
|
|
363
|
+
onModeChange: (mode: MatchMode) => void;
|
|
364
|
+
/**
|
|
365
|
+
* Similar-mail matching cannot run right now. A runtime state, not a property
|
|
366
|
+
* of the deployment: the door stays pressable and dimmed.
|
|
367
|
+
*/
|
|
368
|
+
semanticUnavailable?: boolean;
|
|
369
|
+
/** The dimmed door was pressed and the senders were filled in instead. */
|
|
370
|
+
semanticFallbackTaken?: boolean;
|
|
371
|
+
onSemanticFallback: () => void;
|
|
372
|
+
sample: SelectionSampleProps;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function MatchStepBody({
|
|
376
|
+
selectedCount,
|
|
377
|
+
mode,
|
|
378
|
+
onModeChange,
|
|
379
|
+
semanticUnavailable,
|
|
380
|
+
semanticFallbackTaken,
|
|
381
|
+
onSemanticFallback,
|
|
382
|
+
sample,
|
|
383
|
+
}: MatchStepProps) {
|
|
384
|
+
return (
|
|
385
|
+
<>
|
|
386
|
+
<div className="space-y-2">
|
|
387
|
+
<ChoiceCard
|
|
388
|
+
selected={mode === "selected"}
|
|
389
|
+
onSelect={() => onModeChange("selected")}
|
|
390
|
+
title={matchDoorLabel("selected", selectedCount)}
|
|
391
|
+
description={matchDoorHint("selected")}
|
|
392
|
+
/>
|
|
393
|
+
<ChoiceCard
|
|
394
|
+
selected={mode === "similar"}
|
|
395
|
+
unavailable={semanticUnavailable}
|
|
396
|
+
onSelect={
|
|
397
|
+
semanticUnavailable
|
|
398
|
+
? onSemanticFallback
|
|
399
|
+
: () => onModeChange("similar")
|
|
400
|
+
}
|
|
401
|
+
title={matchDoorLabel("similar", selectedCount)}
|
|
402
|
+
description={matchDoorHint("similar")}
|
|
403
|
+
>
|
|
404
|
+
{semanticFallbackTaken && (
|
|
405
|
+
<p role="status" className="px-1 text-2xs text-fg-subtle">
|
|
406
|
+
Similar-mail matching is unavailable right now — matching on the
|
|
407
|
+
senders instead.
|
|
408
|
+
</p>
|
|
409
|
+
)}
|
|
410
|
+
</ChoiceCard>
|
|
411
|
+
<ChoiceCard
|
|
412
|
+
selected={mode === "properties"}
|
|
413
|
+
onSelect={() => onModeChange("properties")}
|
|
414
|
+
title={matchDoorLabel("properties", selectedCount)}
|
|
415
|
+
description={matchDoorHint("properties")}
|
|
416
|
+
/>
|
|
417
|
+
</div>
|
|
418
|
+
<div className="mt-4">
|
|
419
|
+
<SelectionSample {...sample} />
|
|
420
|
+
</div>
|
|
421
|
+
</>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const MATCH_OPERATOR_OPTIONS: SegmentedOption<MatchOperator>[] = [
|
|
426
|
+
{ value: "all", label: matchOperatorLabel("all") },
|
|
427
|
+
{ value: "any", label: matchOperatorLabel("any") },
|
|
428
|
+
];
|
|
429
|
+
|
|
430
|
+
export interface PropertiesStepProps {
|
|
431
|
+
clauses: readonly RuleClause[];
|
|
432
|
+
matchOperator: MatchOperator;
|
|
433
|
+
onMatchOperatorChange: (operator: MatchOperator) => void;
|
|
434
|
+
/** The clause being added or amended, or absent when none is. */
|
|
435
|
+
clauseEdit?: ClauseEditState;
|
|
436
|
+
onStartAddClause: () => void;
|
|
437
|
+
onStartEditClause: (clauseId: string) => void;
|
|
438
|
+
onRemoveClause: (clauseId: string) => void;
|
|
439
|
+
onChangeDraft: (draft: ClauseDraft) => void;
|
|
440
|
+
onSubmitClause: () => void;
|
|
441
|
+
onCancelClause: () => void;
|
|
442
|
+
/** The fields this deployment can actually match on. Defaults to the whole vocabulary. */
|
|
443
|
+
clauseFields?: ClauseField[];
|
|
444
|
+
/** Values worth offering for the field being edited — never a constraint. */
|
|
445
|
+
clauseSuggestions?: readonly Suggestion[];
|
|
446
|
+
/** What converting a search left behind, when a query is what opened this. */
|
|
447
|
+
conversionNotice?: SearchConversionNotice;
|
|
448
|
+
/** The similar door was dimmed and these are the senders it fell back to. */
|
|
449
|
+
semanticFallbackTaken?: boolean;
|
|
450
|
+
sample: SelectionSampleProps;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* The clauses as the rule editor renders them — the same chips, the same inline
|
|
455
|
+
* editor and the same value suggestions — so a rule reads identically whether it
|
|
456
|
+
* was built here or in Settings.
|
|
457
|
+
*/
|
|
458
|
+
export function PropertiesStepBody({
|
|
459
|
+
clauses,
|
|
460
|
+
matchOperator,
|
|
461
|
+
onMatchOperatorChange,
|
|
462
|
+
clauseEdit,
|
|
463
|
+
onStartAddClause,
|
|
464
|
+
onStartEditClause,
|
|
465
|
+
onRemoveClause,
|
|
466
|
+
onChangeDraft,
|
|
467
|
+
onSubmitClause,
|
|
468
|
+
onCancelClause,
|
|
469
|
+
clauseFields,
|
|
470
|
+
clauseSuggestions,
|
|
471
|
+
conversionNotice,
|
|
472
|
+
semanticFallbackTaken,
|
|
473
|
+
sample,
|
|
474
|
+
}: PropertiesStepProps) {
|
|
475
|
+
const join = matchJoinWord(matchOperator);
|
|
476
|
+
return (
|
|
477
|
+
<div className="space-y-4">
|
|
478
|
+
{semanticFallbackTaken && (
|
|
479
|
+
<p role="status" className="px-1 text-xs text-fg-muted">
|
|
480
|
+
Similar-mail matching is unavailable right now. These are the senders
|
|
481
|
+
of the messages you picked.
|
|
482
|
+
</p>
|
|
483
|
+
)}
|
|
484
|
+
|
|
485
|
+
{conversionNotice && (
|
|
486
|
+
<SearchConversionNoticeView notice={conversionNotice} />
|
|
487
|
+
)}
|
|
488
|
+
|
|
489
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
490
|
+
{clauses.map((clause, index) => (
|
|
491
|
+
<Fragment key={clause.id}>
|
|
492
|
+
{index > 0 && (
|
|
493
|
+
<span className="text-2xs font-medium uppercase text-fg-subtle">
|
|
494
|
+
{join}
|
|
495
|
+
</span>
|
|
496
|
+
)}
|
|
497
|
+
<ClauseChip
|
|
498
|
+
clause={clause}
|
|
499
|
+
onEdit={() => onStartEditClause(clause.id)}
|
|
500
|
+
onRemove={() => onRemoveClause(clause.id)}
|
|
501
|
+
/>
|
|
502
|
+
</Fragment>
|
|
503
|
+
))}
|
|
504
|
+
{!clauseEdit && (
|
|
505
|
+
<AddChipButton label="Add clause" onClick={onStartAddClause} />
|
|
506
|
+
)}
|
|
507
|
+
</div>
|
|
508
|
+
|
|
509
|
+
{clauseEdit && (
|
|
510
|
+
<ClauseEditor
|
|
511
|
+
draft={clauseEdit.draft}
|
|
512
|
+
mode={clauseEdit.mode}
|
|
513
|
+
fields={clauseFields}
|
|
514
|
+
suggestions={clauseSuggestions}
|
|
515
|
+
onChangeField={(field) =>
|
|
516
|
+
onChangeDraft({ ...clauseEdit.draft, field })
|
|
517
|
+
}
|
|
518
|
+
onChangeValue={(value) =>
|
|
519
|
+
onChangeDraft({ ...clauseEdit.draft, value })
|
|
520
|
+
}
|
|
521
|
+
onSubmit={onSubmitClause}
|
|
522
|
+
onCancel={onCancelClause}
|
|
523
|
+
/>
|
|
524
|
+
)}
|
|
525
|
+
|
|
526
|
+
{clauses.length > 1 && (
|
|
527
|
+
<div className="flex items-center gap-2">
|
|
528
|
+
<span className="text-xs text-fg-muted">Match</span>
|
|
529
|
+
<SegmentedControl
|
|
530
|
+
name="wizard-match-operator"
|
|
531
|
+
size="sm"
|
|
532
|
+
aria-label="Match operator"
|
|
533
|
+
options={MATCH_OPERATOR_OPTIONS}
|
|
534
|
+
value={matchOperator}
|
|
535
|
+
onChange={onMatchOperatorChange}
|
|
536
|
+
/>
|
|
537
|
+
</div>
|
|
538
|
+
)}
|
|
539
|
+
|
|
540
|
+
<p className="px-1 text-xs text-fg-muted">
|
|
541
|
+
We started you off from what you were looking at. Change any of it —
|
|
542
|
+
this is where the rule is decided, not the list behind it.
|
|
543
|
+
</p>
|
|
544
|
+
|
|
545
|
+
<SelectionSample {...sample} />
|
|
546
|
+
</div>
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export interface FolderStepProps {
|
|
551
|
+
/**
|
|
552
|
+
* The destinations, already filtered, ordered and labelled by the app
|
|
553
|
+
* (`buildMoveTargets`). The wizard does not reorder them: a second ordering
|
|
554
|
+
* beside the one the Move picker already applies would fight it.
|
|
555
|
+
*/
|
|
556
|
+
mailboxes: readonly MoveMailboxOption[];
|
|
557
|
+
/** The destination chosen so far. */
|
|
558
|
+
mailboxId?: string;
|
|
559
|
+
onSelect: (mailboxId: string) => void;
|
|
560
|
+
/**
|
|
561
|
+
* Creating a folder is an IMAP mutation and the move that follows is a
|
|
562
|
+
* dependent write, so this resolves only once the mail server confirms the
|
|
563
|
+
* folder (docs/architecture/imap-mutations.md). The picker holds the wait,
|
|
564
|
+
* refuses a second submit while it runs, and states a failure where it
|
|
565
|
+
* happened. Absent offers no create.
|
|
566
|
+
*/
|
|
567
|
+
onCreateFolder?: (
|
|
568
|
+
name: string,
|
|
569
|
+
signal?: AbortSignal,
|
|
570
|
+
) => Promise<MoveMailboxOption>;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export function FolderStepBody({
|
|
574
|
+
mailboxes,
|
|
575
|
+
mailboxId,
|
|
576
|
+
onSelect,
|
|
577
|
+
onCreateFolder,
|
|
578
|
+
}: FolderStepProps) {
|
|
579
|
+
const chosen = mailboxes.find((mailbox) => mailbox.id === mailboxId);
|
|
580
|
+
return (
|
|
581
|
+
<div className="flex min-h-0 flex-col gap-3">
|
|
582
|
+
<p className="px-1 text-xs text-fg-muted">
|
|
583
|
+
{chosen
|
|
584
|
+
? `Moving to ${chosen.label}.`
|
|
585
|
+
: "Search for a folder, or type a name that doesn't exist yet to make one."}
|
|
586
|
+
</p>
|
|
587
|
+
<div className="overflow-hidden rounded-lg border border-line bg-surface">
|
|
588
|
+
<MoveMailboxPicker
|
|
589
|
+
mailboxes={mailboxes}
|
|
590
|
+
onSelect={onSelect}
|
|
591
|
+
onCreateFolder={onCreateFolder}
|
|
592
|
+
autoFocus
|
|
593
|
+
labels={{
|
|
594
|
+
searchPlaceholder: "Move to…",
|
|
595
|
+
optionLabel: (label) => `Move to ${label}`,
|
|
596
|
+
createPending: "Waiting for the mail server to confirm the folder…",
|
|
597
|
+
}}
|
|
598
|
+
/>
|
|
599
|
+
</div>
|
|
600
|
+
</div>
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export interface RuleStepProps {
|
|
605
|
+
/**
|
|
606
|
+
* The rule so far. The scope and its stop date are answered here; the rest is
|
|
607
|
+
* read, because whether a one-time apply can serve this rule depends on what
|
|
608
|
+
* is matching it — a body-text clause is unreadable under the literal matcher
|
|
609
|
+
* and perfectly readable under the semantic widen.
|
|
610
|
+
*/
|
|
611
|
+
draft: WizardDraft;
|
|
612
|
+
onScopeChange: (scope: RuleScope) => void;
|
|
613
|
+
onUntilChange: (until: string) => void;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export function RuleStepBody({
|
|
617
|
+
draft,
|
|
618
|
+
onScopeChange,
|
|
619
|
+
onUntilChange,
|
|
620
|
+
}: RuleStepProps) {
|
|
621
|
+
const untilId = useId();
|
|
622
|
+
const { scope, until = "" } = draft;
|
|
623
|
+
const unreadable = unreadableDraftClauses(draft).length > 0;
|
|
624
|
+
|
|
625
|
+
return (
|
|
626
|
+
<div className="space-y-2">
|
|
627
|
+
<ChoiceCard
|
|
628
|
+
selected={scope === "once"}
|
|
629
|
+
onSelect={() => onScopeChange("once")}
|
|
630
|
+
title={scopeLabel("once")}
|
|
631
|
+
description="A one-off tidy-up. Nothing changes for future mail."
|
|
632
|
+
>
|
|
633
|
+
{unreadable && (
|
|
634
|
+
<p className="px-1 text-2xs text-warning">
|
|
635
|
+
{ruleBlockedCopy.bodyTextOnce}
|
|
636
|
+
</p>
|
|
637
|
+
)}
|
|
638
|
+
</ChoiceCard>
|
|
639
|
+
<ChoiceCard
|
|
640
|
+
selected={scope === "standing"}
|
|
641
|
+
onSelect={() => onScopeChange("standing")}
|
|
642
|
+
title={scopeLabel("standing")}
|
|
643
|
+
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
|
+
/>
|
|
645
|
+
<ChoiceCard
|
|
646
|
+
selected={scope === "until"}
|
|
647
|
+
onSelect={() => onScopeChange("until")}
|
|
648
|
+
title={scopeLabel("until")}
|
|
649
|
+
description="The same rule, and it stops on a day you pick."
|
|
650
|
+
>
|
|
651
|
+
{scope === "until" && (
|
|
652
|
+
<div className="rounded-lg border border-line bg-surface p-2">
|
|
653
|
+
<FieldLabel htmlFor={untilId}>Stops on</FieldLabel>
|
|
654
|
+
<Input
|
|
655
|
+
id={untilId}
|
|
656
|
+
type="date"
|
|
657
|
+
value={until}
|
|
658
|
+
onChange={(event) => onUntilChange(event.target.value)}
|
|
659
|
+
/>
|
|
660
|
+
</div>
|
|
661
|
+
)}
|
|
662
|
+
</ChoiceCard>
|
|
663
|
+
</div>
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export interface NameStepProps {
|
|
668
|
+
name: string;
|
|
669
|
+
onNameChange: (name: string) => void;
|
|
670
|
+
/**
|
|
671
|
+
* Continue was pressed while the name was still missing. The field takes
|
|
672
|
+
* focus, so the answer the message asks for is one keystroke away rather than
|
|
673
|
+
* one more tap.
|
|
674
|
+
*/
|
|
675
|
+
nudged?: boolean;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
export function NameStepBody({ name, onNameChange, nudged }: NameStepProps) {
|
|
679
|
+
const nameId = useId();
|
|
680
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
681
|
+
|
|
682
|
+
useEffect(() => {
|
|
683
|
+
if (nudged) inputRef.current?.focus();
|
|
684
|
+
}, [nudged]);
|
|
685
|
+
|
|
686
|
+
return (
|
|
687
|
+
<>
|
|
688
|
+
<FieldLabel htmlFor={nameId}>Rule name</FieldLabel>
|
|
689
|
+
<Input
|
|
690
|
+
id={nameId}
|
|
691
|
+
ref={inputRef}
|
|
692
|
+
value={name}
|
|
693
|
+
placeholder="Travel confirmations"
|
|
694
|
+
onChange={(event) => onNameChange(event.target.value)}
|
|
695
|
+
trailing={
|
|
696
|
+
name ? (
|
|
697
|
+
<button
|
|
698
|
+
type="button"
|
|
699
|
+
aria-label="Clear name"
|
|
700
|
+
onClick={() => {
|
|
701
|
+
onNameChange("");
|
|
702
|
+
inputRef.current?.focus();
|
|
703
|
+
}}
|
|
704
|
+
className="-mr-1 flex size-7 shrink-0 items-center justify-center rounded-full text-fg-subtle hover:bg-surface hover:text-fg"
|
|
705
|
+
>
|
|
706
|
+
<X className="size-4" />
|
|
707
|
+
</button>
|
|
708
|
+
) : undefined
|
|
709
|
+
}
|
|
710
|
+
/>
|
|
711
|
+
<p className="mt-2 text-xs text-fg-muted">
|
|
712
|
+
We suggested one from this selection. Clear it and write your own if it
|
|
713
|
+
doesn't read right.
|
|
714
|
+
</p>
|
|
715
|
+
</>
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
export interface ReviewStepProps {
|
|
720
|
+
verb: Verb;
|
|
721
|
+
mode: MatchMode;
|
|
722
|
+
selectedCount: number;
|
|
723
|
+
clauses: readonly RuleClause[];
|
|
724
|
+
matchOperator: MatchOperator;
|
|
725
|
+
folder?: string;
|
|
726
|
+
scope?: RuleScope;
|
|
727
|
+
until?: string;
|
|
728
|
+
/** Present when the flow reached the naming step. */
|
|
729
|
+
ruleName?: string;
|
|
730
|
+
sample: SelectionSampleProps;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
export function ReviewStepBody({
|
|
734
|
+
verb,
|
|
735
|
+
mode,
|
|
736
|
+
selectedCount,
|
|
737
|
+
clauses,
|
|
738
|
+
matchOperator,
|
|
739
|
+
folder,
|
|
740
|
+
scope,
|
|
741
|
+
until,
|
|
742
|
+
ruleName,
|
|
743
|
+
sample,
|
|
744
|
+
}: ReviewStepProps) {
|
|
745
|
+
const { label } = verbCopy(verb);
|
|
746
|
+
const description = { mode, selectedCount, clauses, matchOperator };
|
|
747
|
+
const widened = mode !== "selected";
|
|
748
|
+
const persists = scope === "standing" || scope === "until";
|
|
749
|
+
|
|
750
|
+
return (
|
|
751
|
+
<div className="space-y-4">
|
|
752
|
+
<div className="rounded-lg border border-line bg-surface p-3">
|
|
753
|
+
<p className="text-sm text-fg">
|
|
754
|
+
<span className="font-semibold">{label}</span>{" "}
|
|
755
|
+
{matchPhrase(description)}
|
|
756
|
+
{verb === "move" && folder ? ` to ${folder}` : ""}
|
|
757
|
+
{persists && (
|
|
758
|
+
<>
|
|
759
|
+
{" "}
|
|
760
|
+
and <span className="font-medium">save a rule</span> that keeps
|
|
761
|
+
doing it
|
|
762
|
+
</>
|
|
763
|
+
)}
|
|
764
|
+
{scope === "until" && until && ` until ${until}`}.
|
|
765
|
+
</p>
|
|
766
|
+
{widened && (
|
|
767
|
+
<p className="mt-2 flex items-start gap-1.5 text-xs text-warning">
|
|
768
|
+
<AlertTriangle className="mt-px size-3.5 shrink-0" />
|
|
769
|
+
This covers messages not shown in the list.
|
|
770
|
+
</p>
|
|
771
|
+
)}
|
|
772
|
+
</div>
|
|
773
|
+
|
|
774
|
+
<dl className="divide-y divide-line overflow-hidden rounded-lg border border-line bg-surface text-xs">
|
|
775
|
+
<div className="flex justify-between gap-3 px-3 py-2">
|
|
776
|
+
<dt className="text-fg-muted">Action</dt>
|
|
777
|
+
<dd className="font-medium">{label}</dd>
|
|
778
|
+
</div>
|
|
779
|
+
<div className="flex justify-between gap-3 px-3 py-2">
|
|
780
|
+
<dt className="shrink-0 text-fg-muted">Apply to</dt>
|
|
781
|
+
<dd className="truncate font-medium">{matchSummary(description)}</dd>
|
|
782
|
+
</div>
|
|
783
|
+
{folder && (
|
|
784
|
+
<div className="flex justify-between gap-3 px-3 py-2">
|
|
785
|
+
<dt className="text-fg-muted">Destination</dt>
|
|
786
|
+
<dd className="font-medium">{folder}</dd>
|
|
787
|
+
</div>
|
|
788
|
+
)}
|
|
789
|
+
{scope && (
|
|
790
|
+
<div className="flex justify-between gap-3 px-3 py-2">
|
|
791
|
+
<dt className="text-fg-muted">Scope</dt>
|
|
792
|
+
<dd className="font-medium">
|
|
793
|
+
{scopeLabel(scope)}
|
|
794
|
+
{scope === "until" && until ? ` · ${until}` : ""}
|
|
795
|
+
</dd>
|
|
796
|
+
</div>
|
|
797
|
+
)}
|
|
798
|
+
{ruleName !== undefined && (
|
|
799
|
+
<div className="flex justify-between gap-3 px-3 py-2">
|
|
800
|
+
<dt className="text-fg-muted">Rule name</dt>
|
|
801
|
+
<dd className="truncate font-medium">{ruleName}</dd>
|
|
802
|
+
</div>
|
|
803
|
+
)}
|
|
804
|
+
</dl>
|
|
805
|
+
|
|
806
|
+
<SelectionSample {...sample} />
|
|
807
|
+
</div>
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
export interface RunStepProps {
|
|
812
|
+
state: RunState;
|
|
813
|
+
verb: Verb;
|
|
814
|
+
scope?: RuleScope;
|
|
815
|
+
/** How many messages the match reached. */
|
|
816
|
+
matched: number;
|
|
817
|
+
/** How many of them the action has covered so far. */
|
|
818
|
+
applied: number;
|
|
819
|
+
/** The messages the mail server rejected, named one by one. */
|
|
820
|
+
failures: readonly WizardMessage[];
|
|
821
|
+
onRetry: () => void;
|
|
822
|
+
onDismiss: () => void;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const runIcon = (tone: RunCopy["tone"]): ReactNode => {
|
|
826
|
+
if (tone === "progress") {
|
|
827
|
+
return <Loader2 className="size-10 animate-spin text-accent" />;
|
|
828
|
+
}
|
|
829
|
+
if (tone === "warning")
|
|
830
|
+
return <AlertTriangle className="size-10 text-warning" />;
|
|
831
|
+
if (tone === "danger")
|
|
832
|
+
return <AlertTriangle className="size-10 text-danger" />;
|
|
833
|
+
return (
|
|
834
|
+
<span className="flex size-12 items-center justify-center rounded-full bg-accent-soft text-accent">
|
|
835
|
+
<Check className="size-6" />
|
|
836
|
+
</span>
|
|
837
|
+
);
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
export function RunStepBody({
|
|
841
|
+
state,
|
|
842
|
+
verb,
|
|
843
|
+
scope,
|
|
844
|
+
matched,
|
|
845
|
+
applied,
|
|
846
|
+
failures,
|
|
847
|
+
}: RunStepProps) {
|
|
848
|
+
const copy = runCopy({
|
|
849
|
+
state,
|
|
850
|
+
verb,
|
|
851
|
+
scope,
|
|
852
|
+
matched,
|
|
853
|
+
applied,
|
|
854
|
+
failed: failures.length,
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
return (
|
|
858
|
+
<div className="space-y-4 pt-2">
|
|
859
|
+
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
|
860
|
+
{runIcon(copy.tone)}
|
|
861
|
+
<p className="text-sm font-medium">{copy.title}</p>
|
|
862
|
+
<p className="max-w-xs text-xs text-fg-muted">{copy.detail}</p>
|
|
863
|
+
</div>
|
|
864
|
+
|
|
865
|
+
{copy.showProgress && (
|
|
866
|
+
<ProgressBar
|
|
867
|
+
value={applied}
|
|
868
|
+
max={matched}
|
|
869
|
+
tone={failures.length > 0 ? "warning" : "success"}
|
|
870
|
+
/>
|
|
871
|
+
)}
|
|
872
|
+
|
|
873
|
+
{failures.length > 0 && (
|
|
874
|
+
<section className="rounded-lg border border-line bg-surface">
|
|
875
|
+
<h2 className="border-b border-line px-3 py-2 text-2xs font-medium uppercase tracking-wide text-fg-subtle">
|
|
876
|
+
{copy.failureListLabel}
|
|
877
|
+
</h2>
|
|
878
|
+
<ul className="divide-y divide-line">
|
|
879
|
+
{failures.map((message) => (
|
|
880
|
+
<li key={message.id} className="px-3 py-2">
|
|
881
|
+
<p className="truncate text-xs font-medium">{message.sender}</p>
|
|
882
|
+
<p className="truncate text-xs text-fg-muted">
|
|
883
|
+
{message.subject}
|
|
884
|
+
</p>
|
|
885
|
+
<Badge className="mt-1" tone="warning">
|
|
886
|
+
Server rejected
|
|
887
|
+
</Badge>
|
|
888
|
+
</li>
|
|
889
|
+
))}
|
|
890
|
+
</ul>
|
|
891
|
+
</section>
|
|
892
|
+
)}
|
|
893
|
+
</div>
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
export function RunFooter({
|
|
898
|
+
state,
|
|
899
|
+
verb,
|
|
900
|
+
scope,
|
|
901
|
+
matched,
|
|
902
|
+
applied,
|
|
903
|
+
failures,
|
|
904
|
+
onRetry,
|
|
905
|
+
onDismiss,
|
|
906
|
+
}: RunStepProps) {
|
|
907
|
+
const copy = runCopy({
|
|
908
|
+
state,
|
|
909
|
+
verb,
|
|
910
|
+
scope,
|
|
911
|
+
matched,
|
|
912
|
+
applied,
|
|
913
|
+
failed: failures.length,
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
if (copy.retryLabel === undefined) {
|
|
917
|
+
return (
|
|
918
|
+
<Button
|
|
919
|
+
variant={copy.tone === "progress" ? "ghost" : "primary"}
|
|
920
|
+
size="touch"
|
|
921
|
+
className="w-full"
|
|
922
|
+
onClick={onDismiss}
|
|
923
|
+
>
|
|
924
|
+
{copy.dismissLabel}
|
|
925
|
+
</Button>
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
return (
|
|
930
|
+
<div className="flex items-center gap-3">
|
|
931
|
+
<Button variant="ghost" size="touch" onClick={onDismiss}>
|
|
932
|
+
{copy.dismissLabel}
|
|
933
|
+
</Button>
|
|
934
|
+
<Button
|
|
935
|
+
variant="primary"
|
|
936
|
+
size="touch"
|
|
937
|
+
className="flex-1"
|
|
938
|
+
onClick={onRetry}
|
|
939
|
+
>
|
|
940
|
+
{copy.retryLabel}
|
|
941
|
+
</Button>
|
|
942
|
+
</div>
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const SCREEN_COPY: Record<StepId, { title?: string; subtitle?: string }> = {
|
|
947
|
+
match: { subtitle: "What should this apply to?" },
|
|
948
|
+
properties: {
|
|
949
|
+
title: "Match properties",
|
|
950
|
+
subtitle: "Which properties have to match?",
|
|
951
|
+
},
|
|
952
|
+
folder: { title: "Move to", subtitle: "Pick a destination" },
|
|
953
|
+
rule: { title: "Organize", subtitle: "How long should this hold?" },
|
|
954
|
+
name: { title: "Name the rule", subtitle: "So you can find it later" },
|
|
955
|
+
review: { title: "Review", subtitle: "Check before it runs" },
|
|
956
|
+
run: {},
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
export interface SelectionWizardProps {
|
|
960
|
+
verb: Verb;
|
|
961
|
+
steps: readonly StepId[];
|
|
962
|
+
/**
|
|
963
|
+
* Held by id, so an answer that shortens the list cannot strand it. A step the
|
|
964
|
+
* current answers dropped resolves to the opening step, and the header, the
|
|
965
|
+
* rail and the body all read that one resolved step.
|
|
966
|
+
*/
|
|
967
|
+
step: StepId;
|
|
968
|
+
/**
|
|
969
|
+
* Moves back one step. Not called on a step where Back leaves the wizard
|
|
970
|
+
* (`backExits`) — the opening step has nothing behind it, and the Run step's
|
|
971
|
+
* action has already happened — where `onExit` is called instead.
|
|
972
|
+
*/
|
|
973
|
+
onBack: () => void;
|
|
974
|
+
onExit: () => void;
|
|
975
|
+
onContinue: () => void;
|
|
976
|
+
onCommit: () => void;
|
|
977
|
+
/** What the current step is still missing. Dims Continue; never disables it. */
|
|
978
|
+
blockedReason?: string;
|
|
979
|
+
/** Continue was pressed while blocked, so the reason belongs on screen. */
|
|
980
|
+
nudged?: boolean;
|
|
981
|
+
match?: MatchStepProps;
|
|
982
|
+
properties?: PropertiesStepProps;
|
|
983
|
+
folder?: FolderStepProps;
|
|
984
|
+
rule?: RuleStepProps;
|
|
985
|
+
name?: NameStepProps;
|
|
986
|
+
review?: ReviewStepProps;
|
|
987
|
+
run?: RunStepProps;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const stepProps = <T,>(props: T | undefined, step: StepId): T => {
|
|
991
|
+
if (props === undefined) {
|
|
992
|
+
throw new Error(
|
|
993
|
+
`The ${stepLabel(step)} step was rendered without its answers.`,
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
return props;
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* The whole wizard, driven from outside. It owns no state: the step, the answers
|
|
1001
|
+
* and every callback come in, so the same screens serve the app and the workbench
|
|
1002
|
+
* without either growing its own copy of them.
|
|
1003
|
+
*/
|
|
1004
|
+
export function SelectionWizard(props: SelectionWizardProps) {
|
|
1005
|
+
const { verb, steps, onExit, onContinue, onCommit } = props;
|
|
1006
|
+
const { label, destructive } = verbCopy(verb);
|
|
1007
|
+
// Resolved once. Rendering the header from the resolved step and the body
|
|
1008
|
+
// from the held one is how a rail and a screen come to disagree.
|
|
1009
|
+
const step = steps[stepIndex(steps, props.step)];
|
|
1010
|
+
const screen = SCREEN_COPY[step];
|
|
1011
|
+
const onBack = backExits(steps, step) ? onExit : props.onBack;
|
|
1012
|
+
|
|
1013
|
+
const body = (): ReactNode => {
|
|
1014
|
+
if (step === "match")
|
|
1015
|
+
return <MatchStepBody {...stepProps(props.match, step)} />;
|
|
1016
|
+
if (step === "properties") {
|
|
1017
|
+
return <PropertiesStepBody {...stepProps(props.properties, step)} />;
|
|
1018
|
+
}
|
|
1019
|
+
if (step === "folder") {
|
|
1020
|
+
return <FolderStepBody {...stepProps(props.folder, step)} />;
|
|
1021
|
+
}
|
|
1022
|
+
if (step === "rule")
|
|
1023
|
+
return <RuleStepBody {...stepProps(props.rule, step)} />;
|
|
1024
|
+
if (step === "name") {
|
|
1025
|
+
return (
|
|
1026
|
+
<NameStepBody {...stepProps(props.name, step)} nudged={props.nudged} />
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
if (step === "review") {
|
|
1030
|
+
return <ReviewStepBody {...stepProps(props.review, step)} />;
|
|
1031
|
+
}
|
|
1032
|
+
return <RunStepBody {...stepProps(props.run, step)} />;
|
|
1033
|
+
};
|
|
1034
|
+
|
|
1035
|
+
const footer = (): ReactNode => {
|
|
1036
|
+
if (step === "run") return <RunFooter {...stepProps(props.run, step)} />;
|
|
1037
|
+
if (step === "review") {
|
|
1038
|
+
const review = stepProps(props.review, step);
|
|
1039
|
+
return (
|
|
1040
|
+
<FooterNav
|
|
1041
|
+
onBack={onBack}
|
|
1042
|
+
nextLabel={review.scope ? commitLabel(review.scope) : label}
|
|
1043
|
+
nextVariant={destructive ? "danger" : "primary"}
|
|
1044
|
+
onNext={onCommit}
|
|
1045
|
+
/>
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
return (
|
|
1049
|
+
<FooterNav
|
|
1050
|
+
onBack={onBack}
|
|
1051
|
+
nextLabel="Continue"
|
|
1052
|
+
onNext={onContinue}
|
|
1053
|
+
blockedReason={props.blockedReason}
|
|
1054
|
+
nudged={props.nudged}
|
|
1055
|
+
/>
|
|
1056
|
+
);
|
|
1057
|
+
};
|
|
1058
|
+
|
|
1059
|
+
const title = (): string => {
|
|
1060
|
+
if (step !== "run") return screen.title ?? label;
|
|
1061
|
+
const run = stepProps(props.run, step);
|
|
1062
|
+
return runCopy({ ...run, failed: run.failures.length }).screenTitle;
|
|
1063
|
+
};
|
|
1064
|
+
|
|
1065
|
+
return (
|
|
1066
|
+
<WizardScreen
|
|
1067
|
+
title={title()}
|
|
1068
|
+
subtitle={screen.subtitle}
|
|
1069
|
+
steps={steps}
|
|
1070
|
+
step={step}
|
|
1071
|
+
onBack={onBack}
|
|
1072
|
+
onExit={onExit}
|
|
1073
|
+
footer={footer()}
|
|
1074
|
+
>
|
|
1075
|
+
{body()}
|
|
1076
|
+
</WizardScreen>
|
|
1077
|
+
);
|
|
1078
|
+
}
|