@remit/ui 0.0.57 → 0.0.59

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.
@@ -0,0 +1,478 @@
1
+ /**
2
+ * The selection wizard's vocabulary and derivation (#477).
3
+ *
4
+ * The step list is a function of the verb and the answers so far, and the step
5
+ * is held by id rather than by number: an answer that shortens the list can then
6
+ * never leave a number pointing past its end. Both branching answers — the match
7
+ * door and the scope — are given on a step every variant of the list holds in the
8
+ * same position, and each can only add or drop a step after it.
9
+ */
10
+
11
+ import {
12
+ clauseFieldLabel,
13
+ type FilterRule,
14
+ type MatchOperator,
15
+ matchJoinWord,
16
+ matchModeHint,
17
+ matchModeLabel,
18
+ type PreviewCount,
19
+ previewSettledReason,
20
+ type RuleClause,
21
+ type RuleMatchMode,
22
+ type RuleScope,
23
+ type RuleWiden,
24
+ ruleBlockedCopy,
25
+ unreadableBodyClauses,
26
+ widenChipLabel,
27
+ } from "../components/filter-rule.js";
28
+
29
+ /** What the action does. Delete, Move and Organize carry a glyph on the bar; Junk and Mark read live in the overflow menu. */
30
+ export type Verb = "delete" | "move" | "junk" | "markRead" | "organize";
31
+
32
+ export interface VerbCopy {
33
+ label: string;
34
+ /** While it runs — "Moving". */
35
+ present: string;
36
+ /** Once it has — "Moved". */
37
+ past: string;
38
+ /** Warrants the danger variant on the commit control. */
39
+ destructive: boolean;
40
+ }
41
+
42
+ const VERB_COPY: Record<Verb, VerbCopy> = {
43
+ delete: {
44
+ label: "Delete",
45
+ present: "Deleting",
46
+ past: "Deleted",
47
+ destructive: true,
48
+ },
49
+ move: { label: "Move", present: "Moving", past: "Moved", destructive: false },
50
+ junk: {
51
+ label: "Junk",
52
+ present: "Marking as junk",
53
+ past: "Marked as junk",
54
+ destructive: true,
55
+ },
56
+ markRead: {
57
+ label: "Mark read",
58
+ present: "Marking read",
59
+ past: "Marked read",
60
+ destructive: false,
61
+ },
62
+ organize: {
63
+ label: "Organize",
64
+ present: "Organizing",
65
+ past: "Organized",
66
+ destructive: false,
67
+ },
68
+ };
69
+
70
+ export const verbCopy = (verb: Verb): VerbCopy => VERB_COPY[verb];
71
+
72
+ /**
73
+ * What the action is applied to. The two widened doors are the shipped
74
+ * `RuleMatchMode` values; `selected` is not one of them and is not a match mode
75
+ * at all — it is the bounded list of ticked message ids, which no predicate
76
+ * stands in for.
77
+ */
78
+ export type MatchMode = "selected" | RuleMatchMode;
79
+
80
+ export type StepId =
81
+ | "match"
82
+ | "properties"
83
+ | "folder"
84
+ | "rule"
85
+ | "name"
86
+ | "review"
87
+ | "run";
88
+
89
+ const STEP_LABEL: Record<StepId, string> = {
90
+ match: "Apply to",
91
+ properties: "Properties",
92
+ folder: "Folder",
93
+ rule: "Rule",
94
+ name: "Name",
95
+ review: "Review",
96
+ run: "Run",
97
+ };
98
+
99
+ export const stepLabel = (step: StepId): string => STEP_LABEL[step];
100
+
101
+ export interface WizardAnswers {
102
+ verb: Verb;
103
+ mode: MatchMode;
104
+ scope?: RuleScope;
105
+ /**
106
+ * Entered by converting a search rather than by ticking rows. There is
107
+ * nothing ticked to anchor on and the query has already answered what this
108
+ * applies to, so the match step is dropped rather than opened past.
109
+ */
110
+ fromSearch?: boolean;
111
+ }
112
+
113
+ /**
114
+ * The steps this flow walks, in order. The property door earns an editor step,
115
+ * Move earns a destination step, Organize earns a scope step, and a scope that
116
+ * persists earns a naming step.
117
+ */
118
+ export const stepsFor = ({
119
+ verb,
120
+ mode,
121
+ scope,
122
+ fromSearch,
123
+ }: WizardAnswers): StepId[] => {
124
+ const opening: StepId[] = fromSearch
125
+ ? ["properties"]
126
+ : mode === "properties"
127
+ ? ["match", "properties"]
128
+ : ["match"];
129
+ if (verb === "move") return [...opening, "folder", "review", "run"];
130
+ if (verb === "organize") {
131
+ if (scope === "standing" || scope === "until") {
132
+ return [...opening, "rule", "name", "review", "run"];
133
+ }
134
+ return [...opening, "rule", "review", "run"];
135
+ }
136
+ return [...opening, "review", "run"];
137
+ };
138
+
139
+ /**
140
+ * Where a held step id sits in the list on screen. A step the current answers
141
+ * dropped resolves to the opening step rather than to a number past the end.
142
+ */
143
+ export const stepIndex = (steps: readonly StepId[], step: StepId): number =>
144
+ Math.max(0, steps.indexOf(step));
145
+
146
+ /**
147
+ * Whether Back leaves the wizard rather than moving a step. The opening step has
148
+ * nothing behind it, and the Run step's action has already happened — walking
149
+ * back to Review from there would offer to commit it a second time.
150
+ */
151
+ export const backExits = (steps: readonly StepId[], step: StepId): boolean =>
152
+ stepIndex(steps, step) === 0 || step === "run";
153
+
154
+ /**
155
+ * The rule as the wizard has it so far. The same fields `FilterRule` carries,
156
+ * with the two the wizard has not asked for yet left absent — a rule is only a
157
+ * whole rule from the review step on.
158
+ */
159
+ export interface WizardDraft {
160
+ clauses: readonly RuleClause[];
161
+ matchOperator: MatchOperator;
162
+ /**
163
+ * The semantic widen the similar door rides on. Present makes the widen the
164
+ * matcher, and it reads message bodies — so a body-text clause the user left
165
+ * behind on the property door is readable here, and must not be held against
166
+ * a one-time apply.
167
+ */
168
+ widen?: RuleWiden;
169
+ /** The Move destination. Absent until the folder step is answered. */
170
+ moveMailboxId?: string;
171
+ /** Absent until the scope step is answered. */
172
+ scope?: RuleScope;
173
+ /** ISO 8601 civil date (`YYYY-MM-DD`) the `until` scope stops on. */
174
+ until?: string;
175
+ name?: string;
176
+ }
177
+
178
+ const asRule = (draft: WizardDraft, scope: RuleScope): FilterRule => ({
179
+ clauses: [...draft.clauses],
180
+ matchOperator: draft.matchOperator,
181
+ widen: draft.widen,
182
+ moveMailboxId: draft.moveMailboxId,
183
+ scope,
184
+ until: draft.until,
185
+ name: draft.name,
186
+ });
187
+
188
+ /**
189
+ * The draft's body-text clauses that nothing on its current match path can read.
190
+ * Empty whenever the semantic widen is carrying the match, because the widen
191
+ * reads bodies; empty for a rule with no body-text clause at all.
192
+ */
193
+ export const unreadableDraftClauses = (draft: WizardDraft): RuleClause[] =>
194
+ unreadableBodyClauses(asRule(draft, draft.scope ?? "once"));
195
+
196
+ /**
197
+ * The count the wizard is committing against. `uncounted` is a stated answer,
198
+ * not a missing one: neither widened door carries a count until it has run
199
+ * (#477 3.3), so there is nothing to wait for and nothing to display.
200
+ */
201
+ export type MatchCount = PreviewCount | { status: "uncounted" };
202
+
203
+ /** 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
+ const INCOMPLETE_CLAUSE = "Fill in every property, or take the empty one off.";
205
+ const NO_DESTINATION = "Pick a destination first.";
206
+ const NO_SCOPE = "Choose one of the three first.";
207
+
208
+ /**
209
+ * What the step is still missing, or `undefined` when it is answered. Nothing
210
+ * disables: Continue stays pressable and dimmed, and pressing it says this.
211
+ *
212
+ * Every gap the rule editor also has says it in the rule editor's words
213
+ * (`ruleBlockedCopy`), so the two surfaces cannot drift. Only the three gaps
214
+ * that exist because the wizard asks one question per screen are its own.
215
+ *
216
+ * A `HasWords` clause is only readable by the index-time matcher, so a one-time
217
+ * apply cannot serve it. That is stated on the scope step rather than by
218
+ * refusing the clause on the step that offered it.
219
+ */
220
+ export const stepBlockedReason = (
221
+ step: StepId,
222
+ draft: WizardDraft,
223
+ count: MatchCount,
224
+ ): string | undefined => {
225
+ if (step === "properties") {
226
+ if (draft.clauses.length === 0) return ruleBlockedCopy.noMatch;
227
+ if (draft.clauses.some((clause) => clause.value.trim() === "")) {
228
+ return INCOMPLETE_CLAUSE;
229
+ }
230
+ return undefined;
231
+ }
232
+ if (step === "folder") {
233
+ return draft.moveMailboxId ? undefined : NO_DESTINATION;
234
+ }
235
+ if (step === "rule") {
236
+ if (!draft.scope) return NO_SCOPE;
237
+ if (draft.scope === "once" && unreadableDraftClauses(draft).length > 0) {
238
+ return ruleBlockedCopy.bodyTextOnce;
239
+ }
240
+ if (draft.scope === "until" && !draft.until?.trim()) {
241
+ return ruleBlockedCopy.noUntilDate;
242
+ }
243
+ return undefined;
244
+ }
245
+ if (step === "name" && !draft.name?.trim()) return ruleBlockedCopy.unnamed;
246
+ if (step === "review") {
247
+ return count.status === "uncounted"
248
+ ? undefined
249
+ : previewSettledReason(count);
250
+ }
251
+ return undefined;
252
+ };
253
+
254
+ /**
255
+ * Where the run step lands. Three of these never reach a job: a filter saved
256
+ * with nothing to back-apply, a back-apply whose request never started, and a
257
+ * create that failed outright.
258
+ */
259
+ export type RunState =
260
+ | "saving"
261
+ | "backApplyRunning"
262
+ | "backApplyComplete"
263
+ | "backApplyFailed"
264
+ | "backApplyStartFailed"
265
+ | "filterSaved"
266
+ | "commitFailed";
267
+
268
+ /**
269
+ * Why a sample has no rows (#452). An empty sample with no explanation reads as
270
+ * "this rule matches nothing", which is the wrong conclusion whenever the mail
271
+ * simply has not been indexed yet.
272
+ */
273
+ export type SampleEmptyReason = "noMatch" | "notIndexed";
274
+
275
+ const SAMPLE_EMPTY_COPY: Record<SampleEmptyReason, string> = {
276
+ noMatch:
277
+ "Nothing matches this yet. Widen a property, or match on a different one.",
278
+ notIndexed:
279
+ "This mail isn't indexed yet, so nothing can be counted. The rule still matches once indexing catches up.",
280
+ };
281
+
282
+ export const sampleEmptyCopy = (reason: SampleEmptyReason): string =>
283
+ SAMPLE_EMPTY_COPY[reason];
284
+
285
+ export interface RunOutcome {
286
+ state: RunState;
287
+ verb: Verb;
288
+ scope?: RuleScope;
289
+ /** How many messages the match reached. */
290
+ matched: number;
291
+ /** How many of them the action has covered so far. */
292
+ applied: number;
293
+ /** How many the mail server rejected. */
294
+ failed: number;
295
+ }
296
+
297
+ export interface RunCopy {
298
+ /** The header title. Names the verb while the job runs, then reads as an ending. */
299
+ screenTitle: string;
300
+ title: string;
301
+ detail: string;
302
+ tone: "progress" | "success" | "warning" | "danger";
303
+ /** The pass over existing mail is under way or finished, so it has a bar. */
304
+ showProgress: boolean;
305
+ /** Leaves the wizard. Never absent — there is always a way out. */
306
+ dismissLabel: string;
307
+ /** Offers the part that did not happen again. Absent when nothing is outstanding. */
308
+ retryLabel?: string;
309
+ /** Heads the list of messages the mail server rejected. */
310
+ failureListLabel: string;
311
+ }
312
+
313
+ /**
314
+ * What the run screen says. A saved rule and a one-off run end differently: the
315
+ * rule keeps working on mail that has not arrived yet, and its pass over the
316
+ * mail already in the mailbox can fail on its own without taking the rule down
317
+ * with it.
318
+ */
319
+ export const runCopy = ({
320
+ state,
321
+ verb,
322
+ scope,
323
+ matched,
324
+ applied,
325
+ failed,
326
+ }: RunOutcome): RunCopy => {
327
+ const { label, present, past } = verbCopy(verb);
328
+ const done = past.toLowerCase();
329
+ const standing = scope === "standing" || scope === "until";
330
+ const inFlight = state === "saving" || state === "backApplyRunning";
331
+ const shared = {
332
+ // A create that failed did not finish, so the header does not say it did.
333
+ screenTitle: inFlight || state === "commitFailed" ? label : "Done",
334
+ showProgress:
335
+ state === "backApplyRunning" ||
336
+ state === "backApplyComplete" ||
337
+ state === "backApplyFailed",
338
+ failureListLabel: `Not ${done}`,
339
+ };
340
+
341
+ if (state === "saving") {
342
+ return {
343
+ ...shared,
344
+ title: standing ? "Saving rule…" : "Applying…",
345
+ detail: "Nothing has been changed yet.",
346
+ tone: "progress",
347
+ dismissLabel: "Close",
348
+ };
349
+ }
350
+ if (state === "backApplyRunning") {
351
+ return {
352
+ ...shared,
353
+ title: standing
354
+ ? "Rule saved. Moving the mail already in your mailbox…"
355
+ : `${present} ${matched} messages…`,
356
+ detail: "This keeps running if you close the wizard.",
357
+ tone: "progress",
358
+ dismissLabel: "Close",
359
+ };
360
+ }
361
+ if (state === "backApplyComplete") {
362
+ return {
363
+ ...shared,
364
+ title: standing ? "Rule saved and applied" : `${past} ${applied}`,
365
+ detail: standing
366
+ ? `${applied} of ${matched} already in your mailbox ${done}. New mail follows the rule as it arrives.`
367
+ : `Every message the match reached was ${done}.`,
368
+ tone: "success",
369
+ dismissLabel: "Done",
370
+ };
371
+ }
372
+ if (state === "backApplyFailed") {
373
+ return {
374
+ ...shared,
375
+ title: standing
376
+ ? "Rule saved — some mail stayed put"
377
+ : `Not everything was ${done}`,
378
+ detail: `${applied} of ${matched} ${done} · ${failed} rejected by the mail server.${
379
+ standing
380
+ ? " The rule itself is saved and keeps working on new mail."
381
+ : ""
382
+ }`,
383
+ tone: "warning",
384
+ dismissLabel: "Close",
385
+ retryLabel: `Retry ${failed}`,
386
+ };
387
+ }
388
+ if (state === "backApplyStartFailed") {
389
+ return {
390
+ ...shared,
391
+ title: "Rule saved",
392
+ detail:
393
+ "New mail follows it automatically. The pass over the mail already in your mailbox never started.",
394
+ tone: "warning",
395
+ dismissLabel: "Not now",
396
+ retryLabel: "Run it over existing mail",
397
+ };
398
+ }
399
+ if (state === "filterSaved") {
400
+ return {
401
+ ...shared,
402
+ title: "Filter saved",
403
+ detail:
404
+ "There is no mail in your mailbox to apply it to yet. New mail follows it as it arrives. You can see it, and when it expires, under Settings › Filters.",
405
+ tone: "success",
406
+ dismissLabel: "Done",
407
+ };
408
+ }
409
+ return {
410
+ ...shared,
411
+ title: standing
412
+ ? "Couldn't save the rule"
413
+ : `Couldn't start ${label.toLowerCase()}`,
414
+ detail: "Nothing has changed.",
415
+ tone: "danger",
416
+ dismissLabel: "Not now",
417
+ retryLabel: "Try again",
418
+ };
419
+ };
420
+
421
+ /** One clause as words — `From "noreply@booking.com"`. */
422
+ export const clauseWords = (clause: RuleClause): string =>
423
+ `${clauseFieldLabel(clause.field)} "${clause.value}"`;
424
+
425
+ /** Every clause as one sentence, joined by the word the operator reads as. */
426
+ export const clauseSentence = (
427
+ clauses: readonly RuleClause[],
428
+ operator: MatchOperator,
429
+ ): string => clauses.map(clauseWords).join(` ${matchJoinWord(operator)} `);
430
+
431
+ /** What a match door is called, with the ticked count the widen anchors on. */
432
+ export const matchDoorLabel = (
433
+ mode: MatchMode,
434
+ selectedCount: number,
435
+ ): string => {
436
+ if (mode === "selected") return `These ${selectedCount} messages`;
437
+ if (mode === "similar") return widenChipLabel({ anchorCount: selectedCount });
438
+ return matchModeLabel("properties");
439
+ };
440
+
441
+ /** One line saying what a match door actually does, so the choice is never a guess. */
442
+ export const matchDoorHint = (mode: MatchMode): string =>
443
+ mode === "selected"
444
+ ? "Only the messages ticked in the list."
445
+ : matchModeHint(mode);
446
+
447
+ export interface MatchDescription {
448
+ mode: MatchMode;
449
+ selectedCount: number;
450
+ clauses: readonly RuleClause[];
451
+ matchOperator: MatchOperator;
452
+ }
453
+
454
+ /** The match on the review screen's labelled list — short enough for one row. */
455
+ export const matchSummary = ({
456
+ mode,
457
+ selectedCount,
458
+ clauses,
459
+ matchOperator,
460
+ }: MatchDescription): string =>
461
+ mode === "properties"
462
+ ? clauseSentence(clauses, matchOperator)
463
+ : matchDoorLabel(mode, selectedCount);
464
+
465
+ /** The match inside the review screen's one sentence, in the object position. */
466
+ export const matchPhrase = ({
467
+ mode,
468
+ selectedCount,
469
+ clauses,
470
+ matchOperator,
471
+ }: MatchDescription): string => {
472
+ if (mode === "selected") return `${selectedCount} messages`;
473
+ if (mode === "similar") {
474
+ return `mail ${widenChipLabel({ anchorCount: selectedCount }).toLowerCase()}`;
475
+ }
476
+ if (clauses.length === 0) return "every message";
477
+ return `every message where ${clauseSentence(clauses, matchOperator)}`;
478
+ };