@remit/ui 0.0.82 → 0.0.84
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-editor.create.test.ts +247 -141
- package/src/components/filter-rule-editor.stories.tsx +154 -71
- package/src/components/filter-rule-editor.tsx +129 -145
- package/src/components/filter-rule.render.test.ts +4 -4
- package/src/components/filter-rule.ts +17 -11
- package/src/components/folder-tree-picker.tsx +6 -1
- package/src/components/selection-wizard.render.test.ts +20 -0
- package/src/index.ts +0 -1
- package/src/lib/wizard-steps.test.ts +21 -0
- package/src/lib/wizard-steps.ts +18 -0
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
type ReactNode,
|
|
4
|
-
useEffect,
|
|
5
|
-
useMemo,
|
|
6
|
-
useRef,
|
|
7
|
-
useState,
|
|
8
|
-
} from "react";
|
|
9
|
-
import { isAbortError } from "../lib/abort.js";
|
|
1
|
+
import { Fragment, type ReactNode, useMemo, useState } from "react";
|
|
2
|
+
import type { FolderTreeNode } from "../lib/folder-tree.js";
|
|
10
3
|
import { Button } from "./button.js";
|
|
11
4
|
import {
|
|
12
5
|
AddChipButton,
|
|
@@ -21,7 +14,6 @@ import {
|
|
|
21
14
|
commitBlockedReason,
|
|
22
15
|
commitLabel,
|
|
23
16
|
type FilterRule,
|
|
24
|
-
type FolderOption,
|
|
25
17
|
type LabelOption,
|
|
26
18
|
type MatchOperator,
|
|
27
19
|
matchJoinWord,
|
|
@@ -32,6 +24,7 @@ import {
|
|
|
32
24
|
type RuleMatchMode,
|
|
33
25
|
type RuleScope,
|
|
34
26
|
} from "./filter-rule.js";
|
|
27
|
+
import { FolderTreePicker } from "./folder-tree-picker.js";
|
|
35
28
|
import { Input } from "./input.js";
|
|
36
29
|
import { LabelChip } from "./label-chip.js";
|
|
37
30
|
import { SegmentedControl } from "./segmented-control.js";
|
|
@@ -47,7 +40,10 @@ export interface ClauseEditState {
|
|
|
47
40
|
|
|
48
41
|
export interface FilterRuleEditorProps {
|
|
49
42
|
rule: FilterRule;
|
|
50
|
-
|
|
43
|
+
/** Destinations as the app has them — labelled, and pathed by the provider. */
|
|
44
|
+
folders: readonly FolderTreeNode[];
|
|
45
|
+
/** The provider's hierarchy separator, which the destination tree nests on. */
|
|
46
|
+
delimiter?: string;
|
|
51
47
|
/** The account's labels the apply-label action can target (issue #26). */
|
|
52
48
|
labels?: LabelOption[];
|
|
53
49
|
preview: PreviewCount;
|
|
@@ -113,16 +109,19 @@ export interface FilterRuleEditorProps {
|
|
|
113
109
|
onChangeMatchOperator?: (operator: MatchOperator) => void;
|
|
114
110
|
onChangeMove?: (mailboxId: string) => void;
|
|
115
111
|
/**
|
|
116
|
-
* Create a new destination folder from within the editor
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* the
|
|
120
|
-
*
|
|
112
|
+
* Create a new destination folder from within the editor, inside the folder
|
|
113
|
+
* the tree is looking at. Creating a folder is an IMAP mutation, so this
|
|
114
|
+
* resolves only once the mail server confirms it
|
|
115
|
+
* (docs/architecture/imap-mutations.md); the picker holds the wait, states a
|
|
116
|
+
* failure where it happened, and aborts the signal on unmount or cancel, so
|
|
117
|
+
* the editor can never commit a filter against a folder that does not exist.
|
|
118
|
+
* Absent renders no create affordance.
|
|
121
119
|
*/
|
|
122
120
|
onCreateFolder?: (
|
|
123
121
|
name: string,
|
|
122
|
+
parentPath: string,
|
|
124
123
|
signal?: AbortSignal,
|
|
125
|
-
) => Promise<
|
|
124
|
+
) => Promise<FolderTreeNode>;
|
|
126
125
|
onChangeLabel?: (labelId: string) => void;
|
|
127
126
|
/**
|
|
128
127
|
* Create a new label from within the editor (issue #26). Given a label
|
|
@@ -153,47 +152,43 @@ const scopeOptions: { value: RuleScope; label: string }[] = [
|
|
|
153
152
|
{ value: "until", label: "Until a date" },
|
|
154
153
|
];
|
|
155
154
|
|
|
156
|
-
const CREATE_FOLDER_VALUE = "__filter_create_folder__";
|
|
157
155
|
const CREATE_LABEL_VALUE = "__filter_create_label__";
|
|
158
156
|
|
|
159
157
|
/**
|
|
160
|
-
* The move-to destination
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* is
|
|
164
|
-
*
|
|
158
|
+
* The move-to destination: the same browsable folder tree every other picker
|
|
159
|
+
* uses, opened from a line that states where matches go now. A folder is
|
|
160
|
+
* labelled as the app labels it, nested where the provider nests it, and a new
|
|
161
|
+
* one is made wherever the tree is looking.
|
|
162
|
+
*
|
|
163
|
+
* Tapping a folder both picks it and opens it, so the rule is re-pointed on an
|
|
164
|
+
* explicit confirmation rather than on the way past — otherwise looking inside
|
|
165
|
+
* a folder on the way to a nested one would rewrite the filter with no undo.
|
|
165
166
|
*
|
|
166
|
-
* The destination is a dependent write
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* confirms rejects with its own message here — the folder is never selected, so
|
|
171
|
-
* the caller cannot commit a filter against a folder that does not exist.
|
|
167
|
+
* The destination is a dependent write — the filter binds to the folder — so a
|
|
168
|
+
* created folder is offered only once the mail server has confirmed it. It is
|
|
169
|
+
* held here as well as returned, so it is pickable before the caller's folder
|
|
170
|
+
* list has refetched.
|
|
172
171
|
*/
|
|
173
172
|
function MoveDestinationField({
|
|
174
173
|
folders,
|
|
175
174
|
value,
|
|
175
|
+
delimiter = "/",
|
|
176
176
|
onChangeMove,
|
|
177
177
|
onCreateFolder,
|
|
178
178
|
}: {
|
|
179
|
-
folders:
|
|
180
|
-
value
|
|
179
|
+
folders: readonly FolderTreeNode[];
|
|
180
|
+
value?: string;
|
|
181
|
+
delimiter?: string;
|
|
181
182
|
onChangeMove?: (mailboxId: string) => void;
|
|
182
183
|
onCreateFolder?: (
|
|
183
184
|
name: string,
|
|
185
|
+
parentPath: string,
|
|
184
186
|
signal?: AbortSignal,
|
|
185
|
-
) => Promise<
|
|
187
|
+
) => Promise<FolderTreeNode>;
|
|
186
188
|
}) {
|
|
187
|
-
const [
|
|
188
|
-
const [
|
|
189
|
-
const [
|
|
190
|
-
const [error, setError] = useState<string>();
|
|
191
|
-
const [createdFolders, setCreatedFolders] = useState<FolderOption[]>([]);
|
|
192
|
-
// The create waits for the mail server to confirm the folder; abort it on
|
|
193
|
-
// unmount or cancel so a late confirmation never binds the destination after
|
|
194
|
-
// the editor is gone or the sub-form dismissed.
|
|
195
|
-
const createAbort = useRef<AbortController | null>(null);
|
|
196
|
-
useEffect(() => () => createAbort.current?.abort(), []);
|
|
189
|
+
const [browsing, setBrowsing] = useState(false);
|
|
190
|
+
const [picked, setPicked] = useState<string>();
|
|
191
|
+
const [createdFolders, setCreatedFolders] = useState<FolderTreeNode[]>([]);
|
|
197
192
|
|
|
198
193
|
const options = useMemo(() => {
|
|
199
194
|
const known = new Set(folders.map((folder) => folder.id));
|
|
@@ -203,127 +198,114 @@ function MoveDestinationField({
|
|
|
203
198
|
];
|
|
204
199
|
}, [folders, createdFolders]);
|
|
205
200
|
|
|
206
|
-
const
|
|
207
|
-
if (
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
: [...prev, folder],
|
|
230
|
-
);
|
|
231
|
-
onChangeMove?.(folder.id);
|
|
232
|
-
setCreating(false);
|
|
233
|
-
setName("");
|
|
234
|
-
setPending(false);
|
|
201
|
+
const createFolder = useMemo(() => {
|
|
202
|
+
if (!onCreateFolder) return undefined;
|
|
203
|
+
return async (name: string, parentPath: string, signal?: AbortSignal) => {
|
|
204
|
+
const created = await onCreateFolder(name, parentPath, signal);
|
|
205
|
+
setCreatedFolders((prev) =>
|
|
206
|
+
prev.some((entry) => entry.id === created.id)
|
|
207
|
+
? prev
|
|
208
|
+
: [...prev, created],
|
|
209
|
+
);
|
|
210
|
+
return created;
|
|
211
|
+
};
|
|
212
|
+
}, [onCreateFolder]);
|
|
213
|
+
|
|
214
|
+
const chosen = options.find((folder) => folder.id === value);
|
|
215
|
+
const pending = options.find((folder) => folder.id === picked);
|
|
216
|
+
|
|
217
|
+
/** Two folders can share a leaf name, so a destination reads as its trail. */
|
|
218
|
+
const trail = (folder: FolderTreeNode): string => {
|
|
219
|
+
const segments = folder.path.split(delimiter);
|
|
220
|
+
return segments
|
|
221
|
+
.map((segment, index) => {
|
|
222
|
+
const path = segments.slice(0, index + 1).join(delimiter);
|
|
223
|
+
return options.find((option) => option.path === path)?.label ?? segment;
|
|
235
224
|
})
|
|
236
|
-
.
|
|
237
|
-
if (isAbortError(error)) return;
|
|
238
|
-
setError(
|
|
239
|
-
error instanceof Error
|
|
240
|
-
? error.message
|
|
241
|
-
: "Couldn't create that folder. Please try again.",
|
|
242
|
-
);
|
|
243
|
-
setPending(false);
|
|
244
|
-
});
|
|
225
|
+
.join(" / ");
|
|
245
226
|
};
|
|
246
227
|
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
setName("");
|
|
251
|
-
setError(undefined);
|
|
252
|
-
setPending(false);
|
|
228
|
+
const close = () => {
|
|
229
|
+
setBrowsing(false);
|
|
230
|
+
setPicked(undefined);
|
|
253
231
|
};
|
|
254
232
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
<
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
<option value={CREATE_FOLDER_VALUE}>+ New folder…</option>
|
|
270
|
-
)}
|
|
271
|
-
</Select>
|
|
272
|
-
{creating && (
|
|
273
|
-
<div className="space-y-2 rounded-md border border-line bg-surface-sunken p-2">
|
|
274
|
-
<Input
|
|
275
|
-
value={name}
|
|
276
|
-
onChange={(event) => setName(event.target.value)}
|
|
277
|
-
placeholder="Folder name"
|
|
278
|
-
aria-label="New folder name"
|
|
279
|
-
disabled={pending}
|
|
280
|
-
autoFocus
|
|
281
|
-
onKeyDown={(event) => {
|
|
282
|
-
if (event.key === "Enter") {
|
|
283
|
-
event.preventDefault();
|
|
284
|
-
submit();
|
|
285
|
-
}
|
|
286
|
-
if (event.key === "Escape") {
|
|
287
|
-
event.preventDefault();
|
|
288
|
-
cancel();
|
|
289
|
-
}
|
|
233
|
+
if (!browsing) {
|
|
234
|
+
return (
|
|
235
|
+
<div className="rounded-md border border-line bg-surface-sunken p-3">
|
|
236
|
+
<p className="text-sm font-medium text-fg">
|
|
237
|
+
{chosen ? trail(chosen) : "No folder yet"}
|
|
238
|
+
</p>
|
|
239
|
+
<div className="mt-1 flex flex-wrap items-center gap-3">
|
|
240
|
+
<Button
|
|
241
|
+
variant="ghost"
|
|
242
|
+
size="sm"
|
|
243
|
+
className="px-0"
|
|
244
|
+
onClick={() => {
|
|
245
|
+
setPicked(value);
|
|
246
|
+
setBrowsing(true);
|
|
290
247
|
}}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
</p>
|
|
296
|
-
)}
|
|
297
|
-
<div className="flex gap-2">
|
|
298
|
-
<Button
|
|
299
|
-
variant="primary"
|
|
300
|
-
size="sm"
|
|
301
|
-
onClick={submit}
|
|
302
|
-
disabled={pending || name.trim() === ""}
|
|
303
|
-
>
|
|
304
|
-
{pending ? "Creating folder…" : "Create folder"}
|
|
305
|
-
</Button>
|
|
248
|
+
>
|
|
249
|
+
Choose a folder
|
|
250
|
+
</Button>
|
|
251
|
+
{chosen && (
|
|
306
252
|
<Button
|
|
307
253
|
variant="ghost"
|
|
308
254
|
size="sm"
|
|
309
|
-
|
|
310
|
-
|
|
255
|
+
className="px-0"
|
|
256
|
+
onClick={() => onChangeMove?.("")}
|
|
311
257
|
>
|
|
312
|
-
|
|
258
|
+
Don't move matches
|
|
313
259
|
</Button>
|
|
314
|
-
|
|
260
|
+
)}
|
|
315
261
|
</div>
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<div className="space-y-2">
|
|
268
|
+
<div className="flex h-72 min-h-0 overflow-hidden rounded-md border border-line bg-surface">
|
|
269
|
+
<FolderTreePicker
|
|
270
|
+
folders={options}
|
|
271
|
+
selectedId={picked}
|
|
272
|
+
delimiter={delimiter}
|
|
273
|
+
onSelect={setPicked}
|
|
274
|
+
onCreateFolder={createFolder}
|
|
275
|
+
onCancel={close}
|
|
276
|
+
labels={{
|
|
277
|
+
createPending: "Waiting for the mail server to confirm the folder…",
|
|
278
|
+
}}
|
|
279
|
+
/>
|
|
280
|
+
</div>
|
|
281
|
+
{pending ? (
|
|
282
|
+
<Button
|
|
283
|
+
variant="primary"
|
|
284
|
+
onClick={() => {
|
|
285
|
+
onChangeMove?.(pending.id);
|
|
286
|
+
close();
|
|
287
|
+
}}
|
|
288
|
+
className="w-full"
|
|
289
|
+
>
|
|
290
|
+
<span className="truncate">{`Move matches to ${trail(pending)}`}</span>
|
|
291
|
+
</Button>
|
|
292
|
+
) : (
|
|
293
|
+
<p className="text-2xs text-fg-subtle">
|
|
294
|
+
Tap a folder to open it, or make a new one where you want it.
|
|
295
|
+
</p>
|
|
316
296
|
)}
|
|
297
|
+
<Button variant="ghost" size="sm" onClick={close} className="w-full">
|
|
298
|
+
Cancel
|
|
299
|
+
</Button>
|
|
317
300
|
</div>
|
|
318
301
|
);
|
|
319
302
|
}
|
|
320
303
|
|
|
321
304
|
/**
|
|
322
305
|
* The apply-label select, plus an inline "New label…" affordance when the
|
|
323
|
-
* consumer wires `onCreateLabel` (issue #26).
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
* label list refetches.
|
|
306
|
+
* consumer wires `onCreateLabel` (issue #26). Selecting the create option
|
|
307
|
+
* reveals a name field, and the created label is added to the local option set
|
|
308
|
+
* and picked immediately, before the caller's label list refetches.
|
|
327
309
|
*/
|
|
328
310
|
function LabelDestinationField({
|
|
329
311
|
labels,
|
|
@@ -468,6 +450,7 @@ function LabelDestinationField({
|
|
|
468
450
|
export function FilterRuleEditor({
|
|
469
451
|
rule,
|
|
470
452
|
folders,
|
|
453
|
+
delimiter,
|
|
471
454
|
labels = [],
|
|
472
455
|
preview,
|
|
473
456
|
notice,
|
|
@@ -622,7 +605,8 @@ export function FilterRuleEditor({
|
|
|
622
605
|
<p className="text-xs font-medium text-fg-muted">Move matches to</p>
|
|
623
606
|
<MoveDestinationField
|
|
624
607
|
folders={folders}
|
|
625
|
-
value={rule.moveMailboxId
|
|
608
|
+
value={rule.moveMailboxId}
|
|
609
|
+
delimiter={delimiter}
|
|
626
610
|
onChangeMove={onChangeMove}
|
|
627
611
|
onCreateFolder={onCreateFolder}
|
|
628
612
|
/>
|
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
demoSenderFallbackRule,
|
|
21
21
|
demoVocabularyRule,
|
|
22
22
|
type FilterRule,
|
|
23
|
-
type FolderOption,
|
|
24
23
|
type LabelOption,
|
|
25
24
|
matchJoinWord,
|
|
26
25
|
matchOperatorLabel,
|
|
@@ -34,14 +33,15 @@ import {
|
|
|
34
33
|
FilterRuleEditor,
|
|
35
34
|
type FilterRuleEditorProps,
|
|
36
35
|
} from "./filter-rule-editor.js";
|
|
36
|
+
import type { FolderTreeNode } from "./folder-tree-picker.js";
|
|
37
37
|
|
|
38
38
|
/** SSR splits interpolations with comment markers; sentences read across them. */
|
|
39
39
|
const render = (element: Parameters<typeof renderToString>[0]) =>
|
|
40
40
|
renderToString(element).replaceAll("<!-- -->", "");
|
|
41
41
|
|
|
42
|
-
const FOLDERS:
|
|
43
|
-
{ id: "mbx-inbox", label: "Inbox" },
|
|
44
|
-
{ id: "mbx-archive", label: "Archive" },
|
|
42
|
+
const FOLDERS: FolderTreeNode[] = [
|
|
43
|
+
{ id: "mbx-inbox", label: "Inbox", path: "INBOX" },
|
|
44
|
+
{ id: "mbx-archive", label: "Archive", path: "INBOX/Archive" },
|
|
45
45
|
];
|
|
46
46
|
|
|
47
47
|
const LABELS: LabelOption[] = [
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* Naming tracks the RFC: rule, clause, widen, scope.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import type { FolderTreeNode } from "../lib/folder-tree.js";
|
|
10
11
|
import type { Suggestion } from "./suggest-list.js";
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -105,11 +106,6 @@ export interface FilterRule {
|
|
|
105
106
|
name?: string;
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
export interface FolderOption {
|
|
109
|
-
id: string;
|
|
110
|
-
label: string;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
109
|
export interface LabelOption {
|
|
114
110
|
id: string;
|
|
115
111
|
name: string;
|
|
@@ -313,12 +309,22 @@ export function commitBlockedReason(
|
|
|
313
309
|
return previewSettledReason(preview);
|
|
314
310
|
}
|
|
315
311
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
{ id: "mbx-
|
|
321
|
-
{ id: "mbx-
|
|
312
|
+
// Two folders named Receipts at different depths, and a Trash labelled by its
|
|
313
|
+
// role rather than its provider leaf: what a flat list of leaf names cannot
|
|
314
|
+
// tell apart.
|
|
315
|
+
export const demoFolders: FolderTreeNode[] = [
|
|
316
|
+
{ id: "mbx-inbox", label: "Inbox", path: "INBOX" },
|
|
317
|
+
{ id: "mbx-archive", label: "Archive", path: "INBOX/Archive" },
|
|
318
|
+
{ id: "mbx-receipts", label: "Receipts", path: "INBOX/Receipts" },
|
|
319
|
+
{ id: "mbx-receipts-2026", label: "2026", path: "INBOX/Receipts/2026" },
|
|
320
|
+
{ id: "mbx-travel", label: "Travel", path: "INBOX/Travel" },
|
|
321
|
+
{
|
|
322
|
+
id: "mbx-travel-receipts",
|
|
323
|
+
label: "Receipts",
|
|
324
|
+
path: "INBOX/Travel/Receipts",
|
|
325
|
+
},
|
|
326
|
+
{ id: "mbx-junk", label: "Junk", path: "INBOX/Junk" },
|
|
327
|
+
{ id: "mbx-trash", label: "Trash", path: "INBOX/Prullenbak" },
|
|
322
328
|
];
|
|
323
329
|
|
|
324
330
|
export const demoLabels: LabelOption[] = [
|
|
@@ -146,7 +146,12 @@ export const FolderTreePicker = ({
|
|
|
146
146
|
}: FolderTreePickerProps) => {
|
|
147
147
|
const text = { ...defaultLabels, ...labels };
|
|
148
148
|
const [query, setQuery] = useState("");
|
|
149
|
-
|
|
149
|
+
// A destination chosen before the picker opened is out of reach behind its
|
|
150
|
+
// ancestors, so the branch holding it starts open and the choice is on screen.
|
|
151
|
+
const [opened, setOpened] = useState<ReadonlySet<string>>(() => {
|
|
152
|
+
const selected = folders.find((folder) => folder.id === selectedId);
|
|
153
|
+
return new Set(selected ? folderAncestors(selected.path, delimiter) : []);
|
|
154
|
+
});
|
|
150
155
|
const [draft, setDraft] = useState<Draft | null>(null);
|
|
151
156
|
const [draftName, setDraftName] = useState("");
|
|
152
157
|
const [draftError, setDraftError] = useState<string>();
|
|
@@ -582,6 +582,26 @@ describe("RunStepBody", () => {
|
|
|
582
582
|
assert.match(html, /Nothing has changed\./);
|
|
583
583
|
});
|
|
584
584
|
|
|
585
|
+
// A retry that could not be started is not a pass that never ran (#552): the
|
|
586
|
+
// pass that did run keeps its counts and its bar.
|
|
587
|
+
it("keeps a finished pass's counts when its retry could not be started", () => {
|
|
588
|
+
const html = renderToString(
|
|
589
|
+
createElement(RunStepBody, {
|
|
590
|
+
...runProps,
|
|
591
|
+
state: "backApplyRestartFailed",
|
|
592
|
+
scope: "standing",
|
|
593
|
+
matched: 1284,
|
|
594
|
+
applied: 1200,
|
|
595
|
+
failedCount: 84,
|
|
596
|
+
}),
|
|
597
|
+
);
|
|
598
|
+
assert.match(html, /1200 of 1284 moved/);
|
|
599
|
+
assert.match(html, /rejected 84/);
|
|
600
|
+
assert.match(html, /role="progressbar"/);
|
|
601
|
+
assert.doesNotMatch(html, /Nothing has changed/);
|
|
602
|
+
assert.doesNotMatch(html, /never started/);
|
|
603
|
+
});
|
|
604
|
+
|
|
585
605
|
// A poll that could not be read is not a run that never started (#526): the
|
|
586
606
|
// screen keeps the counts it has and says what it cannot see.
|
|
587
607
|
it("keeps a run that is going when its progress could not be read", () => {
|
package/src/index.ts
CHANGED
|
@@ -477,6 +477,7 @@ describe("runCopy", () => {
|
|
|
477
477
|
"backApplyComplete",
|
|
478
478
|
"backApplyFailed",
|
|
479
479
|
"backApplyStartFailed",
|
|
480
|
+
"backApplyRestartFailed",
|
|
480
481
|
"statusUnknown",
|
|
481
482
|
"filterSaved",
|
|
482
483
|
"runStopped",
|
|
@@ -540,6 +541,26 @@ describe("runCopy", () => {
|
|
|
540
541
|
assert.equal(started.showProgress, false);
|
|
541
542
|
});
|
|
542
543
|
|
|
544
|
+
it("keeps the counts of the pass that ran when its retry could not be started", () => {
|
|
545
|
+
// #552: the retry is a second pass over the same mail, so a retry that
|
|
546
|
+
// failed leaves the first pass's ending exactly where it was.
|
|
547
|
+
const once = outcome("backApplyRestartFailed", "once");
|
|
548
|
+
assert.equal(once.title, "The retry didn't start");
|
|
549
|
+
assert.match(once.detail, /10 of 12 moved/);
|
|
550
|
+
assert.match(once.detail, /rejected 2/);
|
|
551
|
+
assert.match(once.detail, /Check your connection/);
|
|
552
|
+
assert.doesNotMatch(once.detail, /Nothing has changed/);
|
|
553
|
+
assert.equal(once.tone, "warning");
|
|
554
|
+
assert.equal(once.retryLabel, "Retry 2");
|
|
555
|
+
assert.equal(once.showProgress, true);
|
|
556
|
+
|
|
557
|
+
const standing = outcome("backApplyRestartFailed", "standing");
|
|
558
|
+
assert.match(standing.title, /Rule saved/);
|
|
559
|
+
assert.doesNotMatch(standing.detail, /never started/);
|
|
560
|
+
assert.match(standing.detail, /keeps working on new mail/);
|
|
561
|
+
assert.equal(standing.retryLabel, "Retry 2");
|
|
562
|
+
});
|
|
563
|
+
|
|
543
564
|
it("keeps a run that is going when its progress could not be read", () => {
|
|
544
565
|
// A poll that failed says nothing about the job behind it (#526), so the
|
|
545
566
|
// screen never claims the action never started, and the way out of it is a
|
package/src/lib/wizard-steps.ts
CHANGED
|
@@ -301,6 +301,7 @@ export type RunState =
|
|
|
301
301
|
| "backApplyComplete"
|
|
302
302
|
| "backApplyFailed"
|
|
303
303
|
| "backApplyStartFailed"
|
|
304
|
+
| "backApplyRestartFailed"
|
|
304
305
|
| "statusUnknown"
|
|
305
306
|
| "filterSaved"
|
|
306
307
|
| "runStopped"
|
|
@@ -386,6 +387,7 @@ export const runCopy = ({
|
|
|
386
387
|
state === "backApplyRunning" ||
|
|
387
388
|
state === "backApplyComplete" ||
|
|
388
389
|
state === "backApplyFailed" ||
|
|
390
|
+
state === "backApplyRestartFailed" ||
|
|
389
391
|
state === "statusUnknown" ||
|
|
390
392
|
state === "runStopped",
|
|
391
393
|
failureListLabel: `Not ${done}`,
|
|
@@ -463,6 +465,22 @@ export const runCopy = ({
|
|
|
463
465
|
retryLabel: `Retry ${failed}`,
|
|
464
466
|
};
|
|
465
467
|
}
|
|
468
|
+
if (state === "backApplyRestartFailed") {
|
|
469
|
+
return {
|
|
470
|
+
...shared,
|
|
471
|
+
title: standing
|
|
472
|
+
? "Rule saved — the retry didn't start"
|
|
473
|
+
: "The retry didn't start",
|
|
474
|
+
detail: `${applied} of ${matched} ${done} · the mail server rejected ${failed}, which are still where they were.${
|
|
475
|
+
standing
|
|
476
|
+
? " The rule itself is saved and keeps working on new mail."
|
|
477
|
+
: ""
|
|
478
|
+
} Check your connection and try again.`,
|
|
479
|
+
tone: "warning",
|
|
480
|
+
dismissLabel: "Close",
|
|
481
|
+
retryLabel: `Retry ${failed}`,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
466
484
|
if (state === "backApplyStartFailed") {
|
|
467
485
|
return {
|
|
468
486
|
...shared,
|