@remit/ui 0.0.93 → 0.0.95
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/brief-sections.keyboard.test.ts +2 -7
- package/src/components/message-list-pane.stories.tsx +3 -4
- package/src/components/selection-wizard.render.test.ts +34 -0
- package/src/components/selection-wizard.tsx +7 -0
- package/src/components/swipeable-row.tsx +1 -0
- package/src/index.ts +4 -0
- package/src/lib/use-list-keyboard.test.ts +49 -9
- package/src/lib/use-list-keyboard.ts +23 -9
- package/src/lib/use-rendered-row-ids.ts +56 -0
- package/src/lib/wizard-steps.test.ts +25 -0
- package/src/lib/wizard-steps.ts +13 -3
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
6
|
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
7
7
|
import type { JSDOM } from "jsdom";
|
|
8
|
-
import { act, createElement
|
|
8
|
+
import { act, createElement } from "react";
|
|
9
9
|
import { createRoot, type Root } from "react-dom/client";
|
|
10
10
|
import { LIST_ROW_SELECTOR } from "../lib/roving-focus.js";
|
|
11
11
|
import {
|
|
@@ -76,6 +76,7 @@ before(async () => {
|
|
|
76
76
|
globalThis.HTMLElement = dom.window.HTMLElement;
|
|
77
77
|
globalThis.Element = dom.window.Element;
|
|
78
78
|
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
|
79
|
+
globalThis.MutationObserver = dom.window.MutationObserver;
|
|
79
80
|
Object.defineProperty(globalThis, "navigator", {
|
|
80
81
|
value: dom.window.navigator,
|
|
81
82
|
configurable: true,
|
|
@@ -178,16 +179,10 @@ describe("BriefSections arrow-key traversal", () => {
|
|
|
178
179
|
});
|
|
179
180
|
});
|
|
180
181
|
|
|
181
|
-
const orderedIds = sections.flatMap((section) =>
|
|
182
|
-
section.threads.map((thread) => thread.id),
|
|
183
|
-
);
|
|
184
|
-
|
|
185
182
|
let list: ListKeyboard | undefined;
|
|
186
183
|
|
|
187
184
|
function BriefUnderLayer() {
|
|
188
|
-
const ids = useMemo(() => orderedIds, []);
|
|
189
185
|
const keyboard = useListKeyboard({
|
|
190
|
-
orderedIds: ids,
|
|
191
186
|
isDesktop: true,
|
|
192
187
|
initialFocusedId: "t1",
|
|
193
188
|
});
|
|
@@ -101,8 +101,7 @@ const noKeyboard: MessageListKeyboard = {
|
|
|
101
101
|
* footer offers what is wired.
|
|
102
102
|
*/
|
|
103
103
|
function LiveList({ briefFilters = false }: { briefFilters?: boolean }) {
|
|
104
|
-
const
|
|
105
|
-
const list = useListKeyboard({ orderedIds, isDesktop: true });
|
|
104
|
+
const list = useListKeyboard({ isDesktop: true });
|
|
106
105
|
return (
|
|
107
106
|
<MessageListPane
|
|
108
107
|
listTitle="Inbox"
|
|
@@ -258,9 +257,9 @@ function SelectableList({ isDesktop }: { isDesktop: boolean }) {
|
|
|
258
257
|
readIds.has(thread.id) ? { ...thread, isRead: true } : thread,
|
|
259
258
|
),
|
|
260
259
|
}));
|
|
261
|
-
const
|
|
262
|
-
const list = useListKeyboard({ orderedIds, isDesktop });
|
|
260
|
+
const list = useListKeyboard({ isDesktop });
|
|
263
261
|
const { selection } = list.cursor;
|
|
262
|
+
const { orderedIds } = list;
|
|
264
263
|
const allSelected =
|
|
265
264
|
orderedIds.length > 0 &&
|
|
266
265
|
orderedIds.every((id) => selection.selectedIds.has(id));
|
|
@@ -582,6 +582,24 @@ describe("RunStepBody", () => {
|
|
|
582
582
|
assert.match(html, /Nothing has changed\./);
|
|
583
583
|
});
|
|
584
584
|
|
|
585
|
+
// #522: the commit resolved no destination, which is a cause the screen knows
|
|
586
|
+
// and a setting the user can change.
|
|
587
|
+
it("names why a commit could not start, and where the fix is", () => {
|
|
588
|
+
const html = renderToString(
|
|
589
|
+
createElement(RunStepBody, {
|
|
590
|
+
...runProps,
|
|
591
|
+
state: "commitFailed",
|
|
592
|
+
verb: "junk",
|
|
593
|
+
scope: "once",
|
|
594
|
+
failureReason:
|
|
595
|
+
"This account has no Junk folder appointed, so there is nowhere to file these. Appoint one under Settings › Folders.",
|
|
596
|
+
}),
|
|
597
|
+
);
|
|
598
|
+
assert.match(text(html), /no Junk folder appointed/);
|
|
599
|
+
assert.match(text(html), /Settings › Folders/);
|
|
600
|
+
assert.doesNotMatch(html, /Nothing has changed\./);
|
|
601
|
+
});
|
|
602
|
+
|
|
585
603
|
// A retry that could not be started is not a pass that never ran (#552): the
|
|
586
604
|
// pass that did run keeps its counts and its bar.
|
|
587
605
|
it("keeps a finished pass's counts when its retry could not be started", () => {
|
|
@@ -671,6 +689,22 @@ describe("RunFooter", () => {
|
|
|
671
689
|
assert.match(html, /Close/);
|
|
672
690
|
});
|
|
673
691
|
|
|
692
|
+
it("offers no retry for a commit the same press cannot get past", () => {
|
|
693
|
+
// A Try again here re-sends the identical commit to the same absent
|
|
694
|
+
// destination, forever (#522).
|
|
695
|
+
const html = renderToString(
|
|
696
|
+
createElement(RunFooter, {
|
|
697
|
+
...runProps,
|
|
698
|
+
state: "commitFailed",
|
|
699
|
+
verb: "junk",
|
|
700
|
+
scope: "once",
|
|
701
|
+
failureReason: "This account has no Junk folder appointed.",
|
|
702
|
+
}),
|
|
703
|
+
);
|
|
704
|
+
assert.doesNotMatch(html, /Try again/);
|
|
705
|
+
assert.match(html, /Close/);
|
|
706
|
+
});
|
|
707
|
+
|
|
674
708
|
it("offers only a way out once there is nothing outstanding", () => {
|
|
675
709
|
const html = renderToString(createElement(RunFooter, runProps));
|
|
676
710
|
assert.match(html, /Done/);
|
|
@@ -963,6 +963,12 @@ export interface RunStepProps {
|
|
|
963
963
|
* messages behind it; defaults to the ones named here.
|
|
964
964
|
*/
|
|
965
965
|
failedCount?: number;
|
|
966
|
+
/**
|
|
967
|
+
* Why the commit never started, when the run knows and sending the same one
|
|
968
|
+
* again cannot change it. Stated in place of the generic ending, and in place
|
|
969
|
+
* of the retry that would fail identically (#522).
|
|
970
|
+
*/
|
|
971
|
+
failureReason?: string;
|
|
966
972
|
onRetry: () => void;
|
|
967
973
|
onDismiss: () => void;
|
|
968
974
|
/**
|
|
@@ -981,6 +987,7 @@ const runOutcomeOf = (props: RunStepProps): RunOutcome => ({
|
|
|
981
987
|
matched: props.matched,
|
|
982
988
|
applied: props.applied,
|
|
983
989
|
failed: props.failedCount ?? props.failures.length,
|
|
990
|
+
failureReason: props.failureReason,
|
|
984
991
|
});
|
|
985
992
|
|
|
986
993
|
const runIcon = (tone: RunCopy["tone"]): ReactNode => {
|
|
@@ -325,6 +325,7 @@ export function SwipeableRow({
|
|
|
325
325
|
role={selectionMode ? "checkbox" : undefined}
|
|
326
326
|
aria-checked={selectionMode ? checked : undefined}
|
|
327
327
|
data-message-row
|
|
328
|
+
data-message-id={thread.id}
|
|
328
329
|
{...gestureProps}
|
|
329
330
|
className={interactiveClassName}
|
|
330
331
|
style={interactiveStyle}
|
package/src/index.ts
CHANGED
|
@@ -748,6 +748,10 @@ export {
|
|
|
748
748
|
type UseLongPressResult,
|
|
749
749
|
useLongPress,
|
|
750
750
|
} from "./lib/use-long-press.js";
|
|
751
|
+
export {
|
|
752
|
+
MESSAGE_ROW_SELECTOR,
|
|
753
|
+
useRenderedRowIds,
|
|
754
|
+
} from "./lib/use-rendered-row-ids.js";
|
|
751
755
|
export {
|
|
752
756
|
computeRange,
|
|
753
757
|
deriveIsMultiSelectMode,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The list's keyboard layer as a host mounts it: the keys reach the cursor from
|
|
3
3
|
* inside the element the layer was given and from nowhere else, the row-click
|
|
4
|
-
* path reads its modifiers the same way, the selection follows the rows
|
|
5
|
-
*
|
|
4
|
+
* path reads its modifiers the same way, the selection follows the rows on
|
|
5
|
+
* screen, and the footer offers only the actions the layer registered.
|
|
6
6
|
*/
|
|
7
7
|
import assert from "node:assert/strict";
|
|
8
8
|
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
@@ -18,14 +18,32 @@ let container: HTMLElement;
|
|
|
18
18
|
let root: Root;
|
|
19
19
|
let list: ListKeyboard;
|
|
20
20
|
|
|
21
|
-
function Harness({
|
|
22
|
-
list = useListKeyboard({
|
|
23
|
-
return createElement(
|
|
21
|
+
function Harness({ rowIds }: { rowIds: string[] }) {
|
|
22
|
+
list = useListKeyboard({ isDesktop: true });
|
|
23
|
+
return createElement(
|
|
24
|
+
"section",
|
|
25
|
+
{ id: "pane", ref: list.keyboard.ref },
|
|
26
|
+
...rowIds.map((id) =>
|
|
27
|
+
createElement("button", {
|
|
28
|
+
key: id,
|
|
29
|
+
type: "button",
|
|
30
|
+
"data-message-id": id,
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
);
|
|
24
34
|
}
|
|
25
35
|
|
|
26
|
-
const mount = (
|
|
36
|
+
const mount = (rowIds: string[] = ALL_IDS) => {
|
|
27
37
|
act(() => {
|
|
28
|
-
root.render(createElement(Harness, {
|
|
38
|
+
root.render(createElement(Harness, { rowIds }));
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// The layer reads the rendered rows through a MutationObserver, whose callback
|
|
43
|
+
// lands on the microtask queue — an async act flushes both.
|
|
44
|
+
const rerender = async (rowIds: string[]) => {
|
|
45
|
+
await act(async () => {
|
|
46
|
+
root.render(createElement(Harness, { rowIds }));
|
|
29
47
|
});
|
|
30
48
|
};
|
|
31
49
|
|
|
@@ -82,6 +100,7 @@ before(async () => {
|
|
|
82
100
|
globalThis.HTMLElement = dom.window.HTMLElement;
|
|
83
101
|
globalThis.Element = dom.window.Element;
|
|
84
102
|
globalThis.SVGElement = dom.window.SVGElement;
|
|
103
|
+
globalThis.MutationObserver = dom.window.MutationObserver;
|
|
85
104
|
(
|
|
86
105
|
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
87
106
|
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
@@ -159,14 +178,35 @@ describe("useListKeyboard", () => {
|
|
|
159
178
|
assert.deepEqual(selected(), []);
|
|
160
179
|
});
|
|
161
180
|
|
|
162
|
-
it("drops the ticked rows that leave the
|
|
181
|
+
it("drops the ticked rows that leave the screen, and keeps the rest", async () => {
|
|
163
182
|
press("a", { metaKey: true });
|
|
164
183
|
assert.deepEqual(selected(), ALL_IDS);
|
|
165
|
-
|
|
184
|
+
await rerender(["m1", "m3"]);
|
|
166
185
|
assert.deepEqual(
|
|
167
186
|
selected(),
|
|
168
187
|
["m1", "m3"],
|
|
169
188
|
"a verb acts on the rows that are still on screen",
|
|
170
189
|
);
|
|
171
190
|
});
|
|
191
|
+
|
|
192
|
+
it("walks and takes only the rows the pane is rendering", async () => {
|
|
193
|
+
await rerender(["m1", "m2"]);
|
|
194
|
+
|
|
195
|
+
press("a", { metaKey: true });
|
|
196
|
+
assert.deepEqual(
|
|
197
|
+
selected(),
|
|
198
|
+
["m1", "m2"],
|
|
199
|
+
"⌘A stops at the rows on screen",
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
press("Escape");
|
|
203
|
+
press("j");
|
|
204
|
+
press("j");
|
|
205
|
+
press("j");
|
|
206
|
+
assert.equal(
|
|
207
|
+
list.keyboard.focusedId,
|
|
208
|
+
"m2",
|
|
209
|
+
"the cursor stops at the last rendered row",
|
|
210
|
+
);
|
|
211
|
+
});
|
|
172
212
|
});
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
*
|
|
11
11
|
* The layer binds its keys to the pane element rather than the window, so a
|
|
12
12
|
* page carrying several lists gives each of them only the keys pressed inside
|
|
13
|
-
* it.
|
|
13
|
+
* it. It takes the rows it walks from that same element rather than from the
|
|
14
|
+
* caller's data: a section behind "Show N more", a collapsed header and a
|
|
15
|
+
* category scope all take rows off the screen without touching the data, and a
|
|
16
|
+
* cursor or a count built from the data reaches them anyway.
|
|
14
17
|
*/
|
|
15
18
|
import { useEffect, useMemo, useState } from "react";
|
|
16
19
|
import type {
|
|
@@ -19,11 +22,12 @@ import type {
|
|
|
19
22
|
} from "../components/app-shell-types.js";
|
|
20
23
|
import type { TriageHandlers } from "./keymap.js";
|
|
21
24
|
import { type ListCursor, useListCursor } from "./use-list-cursor.js";
|
|
25
|
+
import { useRenderedRowIds } from "./use-rendered-row-ids.js";
|
|
22
26
|
import { useTriageKeyboard } from "./use-triage-keyboard.js";
|
|
23
27
|
|
|
28
|
+
const NO_ROWS: string[] = [];
|
|
29
|
+
|
|
24
30
|
export interface UseListKeyboardOptions {
|
|
25
|
-
/** Row ids in display order. */
|
|
26
|
-
orderedIds: string[];
|
|
27
31
|
isDesktop: boolean;
|
|
28
32
|
/** Seeds the cursor — normally the open thread. */
|
|
29
33
|
initialFocusedId?: string;
|
|
@@ -35,6 +39,11 @@ export interface UseListKeyboardOptions {
|
|
|
35
39
|
|
|
36
40
|
export interface ListKeyboard {
|
|
37
41
|
cursor: ListCursor;
|
|
42
|
+
/**
|
|
43
|
+
* The rows the pane is rendering, in display order — what the keys walk,
|
|
44
|
+
* what ⌘A takes, and what a select-all checkbox above the list counts.
|
|
45
|
+
*/
|
|
46
|
+
orderedIds: string[];
|
|
38
47
|
/** The pane's `selection` prop. */
|
|
39
48
|
selection: MessageListSelection;
|
|
40
49
|
/** The pane's `keyboard` prop. */
|
|
@@ -42,13 +51,14 @@ export interface ListKeyboard {
|
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
export const useListKeyboard = ({
|
|
45
|
-
orderedIds,
|
|
46
54
|
isDesktop,
|
|
47
55
|
initialFocusedId,
|
|
48
56
|
initialSelectedIds,
|
|
49
57
|
enabled = true,
|
|
50
58
|
}: UseListKeyboardOptions): ListKeyboard => {
|
|
51
59
|
const [pane, setPane] = useState<HTMLElement | null>(null);
|
|
60
|
+
const renderedIds = useRenderedRowIds(pane);
|
|
61
|
+
const orderedIds = renderedIds ?? NO_ROWS;
|
|
52
62
|
|
|
53
63
|
const cursor = useListCursor({
|
|
54
64
|
orderedIds,
|
|
@@ -70,13 +80,16 @@ export const useListKeyboard = ({
|
|
|
70
80
|
};
|
|
71
81
|
useTriageKeyboard({ handlers, enabled, target: pane });
|
|
72
82
|
|
|
73
|
-
// A row that leaves the
|
|
74
|
-
// cannot stay selected, or the count and the
|
|
75
|
-
// see. The same rule the app runs in
|
|
83
|
+
// A row that leaves the screen — a filter, an account pill, a collapsed
|
|
84
|
+
// section, a completed verb — cannot stay selected, or the count and the
|
|
85
|
+
// verbs act on rows nobody can see. The same rule the app runs in
|
|
86
|
+
// `ThreadListInteraction`. Rows that have not been read yet are not rows that
|
|
87
|
+
// left, so the seeded selection survives the first render.
|
|
76
88
|
const { intersectWith } = cursor.selection;
|
|
77
89
|
useEffect(() => {
|
|
78
|
-
|
|
79
|
-
|
|
90
|
+
if (renderedIds === undefined) return;
|
|
91
|
+
intersectWith(renderedIds);
|
|
92
|
+
}, [intersectWith, renderedIds]);
|
|
80
93
|
|
|
81
94
|
const { selectedIds, toggle } = cursor.selection;
|
|
82
95
|
const { handleRowSelect } = cursor;
|
|
@@ -91,6 +104,7 @@ export const useListKeyboard = ({
|
|
|
91
104
|
|
|
92
105
|
return {
|
|
93
106
|
cursor,
|
|
107
|
+
orderedIds,
|
|
94
108
|
selection,
|
|
95
109
|
keyboard: {
|
|
96
110
|
focusedId: cursor.focusedMessageId,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rows a message list is actually showing, read from the DOM.
|
|
3
|
+
*
|
|
4
|
+
* A list narrows itself in ways its data never records: a section caps itself
|
|
5
|
+
* behind "Show N more", collapses from its own header, or falls out of a
|
|
6
|
+
* category scope. The ids a consumer hands down are therefore not the ids on
|
|
7
|
+
* screen, and a cursor or a selection built from them reaches rows nobody can
|
|
8
|
+
* see. Reading the rendered rows is the one answer that holds for every kind of
|
|
9
|
+
* narrowing, wherever it happens.
|
|
10
|
+
*/
|
|
11
|
+
import { useCallback, useEffect, useState } from "react";
|
|
12
|
+
|
|
13
|
+
/** The marker a row carries the id the cursor and the selection know it by. */
|
|
14
|
+
export const MESSAGE_ROW_SELECTOR = "[data-message-id]";
|
|
15
|
+
|
|
16
|
+
const readRowIds = (container: HTMLElement): string[] =>
|
|
17
|
+
Array.from(container.querySelectorAll<HTMLElement>(MESSAGE_ROW_SELECTOR))
|
|
18
|
+
.map((row) => row.dataset.messageId)
|
|
19
|
+
.filter((id): id is string => id !== undefined);
|
|
20
|
+
|
|
21
|
+
const sameIds = (a: string[], b: string[]): boolean =>
|
|
22
|
+
a.length === b.length && a.every((id, index) => id === b[index]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The ids of the rows inside `container`, in document order, kept in step with
|
|
26
|
+
* what it renders. `undefined` until a container has been read — a list whose
|
|
27
|
+
* rows have not been counted yet is a different answer from a list with no
|
|
28
|
+
* rows, and only the second one may empty a selection.
|
|
29
|
+
*
|
|
30
|
+
*/
|
|
31
|
+
export function useRenderedRowIds(
|
|
32
|
+
container: HTMLElement | null,
|
|
33
|
+
): string[] | undefined {
|
|
34
|
+
const [rowIds, setRowIds] = useState<string[] | undefined>(undefined);
|
|
35
|
+
|
|
36
|
+
const sync = useCallback(() => {
|
|
37
|
+
if (!container) return;
|
|
38
|
+
const next = readRowIds(container);
|
|
39
|
+
setRowIds((prev) => (prev && sameIds(prev, next) ? prev : next));
|
|
40
|
+
}, [container]);
|
|
41
|
+
|
|
42
|
+
// Rows this render moved — a chip, an account pill, a completed verb — are in
|
|
43
|
+
// the DOM by the time the commit's effects run.
|
|
44
|
+
useEffect(sync);
|
|
45
|
+
|
|
46
|
+
// Rows a section moves on its own — "Show N more", a collapsing header — never
|
|
47
|
+
// reach this render at all, so nothing but the DOM reports them.
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!container) return;
|
|
50
|
+
const observer = new MutationObserver(sync);
|
|
51
|
+
observer.observe(container, { childList: true, subtree: true });
|
|
52
|
+
return () => observer.disconnect();
|
|
53
|
+
}, [container, sync]);
|
|
54
|
+
|
|
55
|
+
return rowIds;
|
|
56
|
+
}
|
|
@@ -595,6 +595,31 @@ describe("runCopy", () => {
|
|
|
595
595
|
assert.equal(outcome("commitFailed", "once").title, "Couldn't start move");
|
|
596
596
|
});
|
|
597
597
|
|
|
598
|
+
// #522: a commit that resolved no destination fails the same way every time
|
|
599
|
+
// it is sent. Stating "Nothing has changed" over a Try again leaves the user
|
|
600
|
+
// pressing a control that can never work, with nothing naming the setting
|
|
601
|
+
// that would.
|
|
602
|
+
it("carries the reason a commit could not start, in place of a retry", () => {
|
|
603
|
+
const reason =
|
|
604
|
+
"This account has no Junk folder appointed, so there is nowhere to file these. Appoint one under Settings › Folders.";
|
|
605
|
+
const blocked = runCopy({
|
|
606
|
+
state: "commitFailed",
|
|
607
|
+
verb: "junk",
|
|
608
|
+
scope: "once",
|
|
609
|
+
matched: 0,
|
|
610
|
+
applied: 0,
|
|
611
|
+
failed: 0,
|
|
612
|
+
failureReason: reason,
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
assert.equal(blocked.detail, reason);
|
|
616
|
+
assert.doesNotMatch(blocked.detail, /Nothing has changed/);
|
|
617
|
+
assert.equal(blocked.retryLabel, undefined);
|
|
618
|
+
assert.equal(blocked.dismissLabel, "Close");
|
|
619
|
+
assert.equal(blocked.tone, "danger");
|
|
620
|
+
assert.match(blocked.title, /^Couldn't start/);
|
|
621
|
+
});
|
|
622
|
+
|
|
598
623
|
it("shows progress only while a pass over existing mail is under way or finished", () => {
|
|
599
624
|
assert.equal(outcome("saving", "standing").showProgress, false);
|
|
600
625
|
assert.equal(outcome("backApplyRunning", "standing").showProgress, true);
|
package/src/lib/wizard-steps.ts
CHANGED
|
@@ -334,6 +334,13 @@ export interface RunOutcome {
|
|
|
334
334
|
applied: number;
|
|
335
335
|
/** How many the mail server rejected. */
|
|
336
336
|
failed: number;
|
|
337
|
+
/**
|
|
338
|
+
* Why the commit never started, when sending the same one again cannot get
|
|
339
|
+
* past it — no Junk folder appointed, no destination chosen. The ending states
|
|
340
|
+
* this in place of the generic one and offers no retry, because the identical
|
|
341
|
+
* commit fails identically (#522).
|
|
342
|
+
*/
|
|
343
|
+
failureReason?: string;
|
|
337
344
|
}
|
|
338
345
|
|
|
339
346
|
export interface RunCopy {
|
|
@@ -372,6 +379,7 @@ export const runCopy = ({
|
|
|
372
379
|
matched,
|
|
373
380
|
applied,
|
|
374
381
|
failed,
|
|
382
|
+
failureReason,
|
|
375
383
|
}: RunOutcome): RunCopy => {
|
|
376
384
|
const { label, present, past } = verbCopy(verb);
|
|
377
385
|
const done = past.toLowerCase();
|
|
@@ -502,15 +510,17 @@ export const runCopy = ({
|
|
|
502
510
|
dismissLabel: "Done",
|
|
503
511
|
};
|
|
504
512
|
}
|
|
513
|
+
// A stated reason is a failure the same commit cannot get past, so the way
|
|
514
|
+
// out of it is the sentence rather than a retry that fails identically.
|
|
505
515
|
return {
|
|
506
516
|
...shared,
|
|
507
517
|
title: standing
|
|
508
518
|
? "Couldn't save the rule"
|
|
509
519
|
: `Couldn't start ${label.toLowerCase()}`,
|
|
510
|
-
detail: "Nothing has changed.",
|
|
520
|
+
detail: failureReason ?? "Nothing has changed.",
|
|
511
521
|
tone: "danger",
|
|
512
|
-
dismissLabel: "Not now",
|
|
513
|
-
retryLabel: "Try again",
|
|
522
|
+
dismissLabel: failureReason === undefined ? "Not now" : "Close",
|
|
523
|
+
retryLabel: failureReason === undefined ? "Try again" : undefined,
|
|
514
524
|
};
|
|
515
525
|
};
|
|
516
526
|
|