@remit/ui 0.0.39 → 0.0.41
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/auto-moved-badge.render.test.ts +23 -0
- package/src/components/auto-moved-badge.stories.tsx +24 -0
- package/src/components/auto-moved-badge.tsx +25 -4
- package/src/components/self-update-confirm-dialog.tsx +17 -0
- package/src/components/self-update-section.tsx +102 -1
- package/src/components/self-update.render.test.ts +54 -0
- package/src/components/self-update.stories.tsx +51 -0
- package/src/components/self-update.ts +21 -0
package/package.json
CHANGED
|
@@ -39,4 +39,27 @@ describe("AutoMovedBadge", () => {
|
|
|
39
39
|
);
|
|
40
40
|
assert.match(html, /Move back to Junk/);
|
|
41
41
|
});
|
|
42
|
+
|
|
43
|
+
it("renders no filter link when filtersHref is omitted (classifier move)", () => {
|
|
44
|
+
const html = renderToString(
|
|
45
|
+
createElement(AutoMovedBadge, {
|
|
46
|
+
label: "Moved from Junk by Remit",
|
|
47
|
+
onUndo: () => undefined,
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
assert.doesNotMatch(html, /Manage filter/);
|
|
51
|
+
assert.doesNotMatch(html, /settings\/filters/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("renders a Manage filter link to Settings when filtersHref is set", () => {
|
|
55
|
+
const html = renderToString(
|
|
56
|
+
createElement(AutoMovedBadge, {
|
|
57
|
+
label: "Moved from Inbox by Remit",
|
|
58
|
+
onUndo: () => undefined,
|
|
59
|
+
filtersHref: "/settings/filters",
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
assert.match(html, /Manage filter/);
|
|
63
|
+
assert.match(html, /href="\/settings\/filters"/);
|
|
64
|
+
});
|
|
42
65
|
});
|
|
@@ -34,6 +34,24 @@ export const WithoutUndoAction: Story = {
|
|
|
34
34
|
args: { label: "Moved from Junk by Remit", size: "md" },
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
export const FilterMoveWithManageLink: Story = {
|
|
38
|
+
args: {
|
|
39
|
+
label: "Moved from Inbox by Remit",
|
|
40
|
+
size: "md",
|
|
41
|
+
onUndo: () => alert("Undo"),
|
|
42
|
+
filtersHref: "/settings/filters",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const FilterMoveListRow: Story = {
|
|
47
|
+
// The list row is icon + label only — the app gates the Manage link (and
|
|
48
|
+
// Undo) to the reading-view `md` size, so no `filtersHref` here.
|
|
49
|
+
args: {
|
|
50
|
+
label: "Moved from Inbox by Remit",
|
|
51
|
+
size: "sm",
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
37
55
|
export const SideBySide: Story = {
|
|
38
56
|
render: () => (
|
|
39
57
|
<div className="flex flex-col items-start gap-3">
|
|
@@ -43,6 +61,12 @@ export const SideBySide: Story = {
|
|
|
43
61
|
size="md"
|
|
44
62
|
onUndo={() => undefined}
|
|
45
63
|
/>
|
|
64
|
+
<AutoMovedBadge
|
|
65
|
+
label="Moved from Inbox by Remit"
|
|
66
|
+
size="md"
|
|
67
|
+
onUndo={() => undefined}
|
|
68
|
+
filtersHref="/settings/filters"
|
|
69
|
+
/>
|
|
46
70
|
</div>
|
|
47
71
|
),
|
|
48
72
|
};
|
|
@@ -13,21 +13,31 @@ export interface AutoMovedBadgeProps {
|
|
|
13
13
|
*/
|
|
14
14
|
onUndo?: () => void;
|
|
15
15
|
undoLabel?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Settings › Filters link, present only for a standing-filter move: undo
|
|
18
|
+
* returns the message but never disables the filter, so the badge offers a
|
|
19
|
+
* way to reach the filter that keeps moving mail. Omit for a classifier move.
|
|
20
|
+
*/
|
|
21
|
+
filtersHref?: string;
|
|
22
|
+
manageLabel?: string;
|
|
16
23
|
className?: string;
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
/**
|
|
20
27
|
* Unobtrusive indicator that Remit auto-moved this message, with an optional
|
|
21
|
-
* inline one-click undo
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
28
|
+
* inline one-click undo and, for a standing-filter move, a link to the filter
|
|
29
|
+
* in Settings. Purely presentational — the label text, whether the move is
|
|
30
|
+
* still in effect (so the badge should render at all), and whether a filter
|
|
31
|
+
* link applies are the caller's responsibility; this component has no notion of
|
|
32
|
+
* placement/mailbox state.
|
|
25
33
|
*/
|
|
26
34
|
export function AutoMovedBadge({
|
|
27
35
|
label,
|
|
28
36
|
size = "sm",
|
|
29
37
|
onUndo,
|
|
30
38
|
undoLabel = "Undo",
|
|
39
|
+
filtersHref,
|
|
40
|
+
manageLabel = "Manage filter",
|
|
31
41
|
className,
|
|
32
42
|
}: AutoMovedBadgeProps) {
|
|
33
43
|
return (
|
|
@@ -52,6 +62,17 @@ export function AutoMovedBadge({
|
|
|
52
62
|
{undoLabel}
|
|
53
63
|
</button>
|
|
54
64
|
)}
|
|
65
|
+
{filtersHref && (
|
|
66
|
+
<a
|
|
67
|
+
href={filtersHref}
|
|
68
|
+
onClick={(event) => {
|
|
69
|
+
event.stopPropagation();
|
|
70
|
+
}}
|
|
71
|
+
className="font-semibold underline decoration-dotted underline-offset-2 hover:decoration-solid"
|
|
72
|
+
>
|
|
73
|
+
{manageLabel}
|
|
74
|
+
</a>
|
|
75
|
+
)}
|
|
55
76
|
</Badge>
|
|
56
77
|
);
|
|
57
78
|
}
|
|
@@ -7,6 +7,12 @@ export interface SelfUpdateConfirmDialogProps {
|
|
|
7
7
|
open: boolean;
|
|
8
8
|
currentVersion: string;
|
|
9
9
|
release: ReleaseInfo;
|
|
10
|
+
/**
|
|
11
|
+
* Whether installing this release runs a database migration during the
|
|
12
|
+
* offline window. Stated only when the surface reports both schema versions
|
|
13
|
+
* and the new one is higher; silence otherwise.
|
|
14
|
+
*/
|
|
15
|
+
appliesSchemaMigration?: boolean;
|
|
10
16
|
onClose: () => void;
|
|
11
17
|
onConfirm: () => void;
|
|
12
18
|
}
|
|
@@ -26,6 +32,7 @@ export function SelfUpdateConfirmDialog({
|
|
|
26
32
|
open,
|
|
27
33
|
currentVersion,
|
|
28
34
|
release,
|
|
35
|
+
appliesSchemaMigration = false,
|
|
29
36
|
onClose,
|
|
30
37
|
onConfirm,
|
|
31
38
|
}: SelfUpdateConfirmDialogProps) {
|
|
@@ -53,6 +60,16 @@ export function SelfUpdateConfirmDialog({
|
|
|
53
60
|
<span>{line}</span>
|
|
54
61
|
</li>
|
|
55
62
|
))}
|
|
63
|
+
{appliesSchemaMigration && (
|
|
64
|
+
<li className="flex gap-2 text-sm text-fg-muted">
|
|
65
|
+
<span className="mt-1.5 size-1.5 shrink-0 rounded-full bg-fg-subtle" />
|
|
66
|
+
<span>
|
|
67
|
+
This version updates the database while Remit is offline. The
|
|
68
|
+
step forward cannot be undone by hand, but a failed start is
|
|
69
|
+
still rolled back to the version and data you have now.
|
|
70
|
+
</span>
|
|
71
|
+
</li>
|
|
72
|
+
)}
|
|
56
73
|
</ul>
|
|
57
74
|
<p className="text-xs text-fg-subtle">
|
|
58
75
|
Good moment for this: when you are not waiting on a message.
|
|
@@ -82,7 +82,11 @@ export function SelfUpdateSection({
|
|
|
82
82
|
};
|
|
83
83
|
|
|
84
84
|
const handleInstall = () => {
|
|
85
|
-
if (
|
|
85
|
+
if (
|
|
86
|
+
!installable &&
|
|
87
|
+
state.status !== "rolledBack" &&
|
|
88
|
+
state.status !== "abandoned"
|
|
89
|
+
) {
|
|
86
90
|
setNotice(
|
|
87
91
|
"There is no update to install. Check for updates first — if one is found it appears here.",
|
|
88
92
|
);
|
|
@@ -348,6 +352,103 @@ export function SelfUpdateSection({
|
|
|
348
352
|
</SectionRow>
|
|
349
353
|
);
|
|
350
354
|
|
|
355
|
+
case "rollbackFailed":
|
|
356
|
+
return (
|
|
357
|
+
<SectionRow tone="danger">
|
|
358
|
+
<div className="space-y-3">
|
|
359
|
+
<div className="flex items-start gap-2">
|
|
360
|
+
<TriangleAlert
|
|
361
|
+
className="mt-0.5 size-4 shrink-0 text-danger"
|
|
362
|
+
aria-hidden
|
|
363
|
+
/>
|
|
364
|
+
<div className="min-w-0 space-y-1">
|
|
365
|
+
<p className="text-sm font-semibold text-fg">
|
|
366
|
+
Remit {state.attemptedVersion} did not start, and Remit
|
|
367
|
+
could not put {state.previousVersion} back.
|
|
368
|
+
</p>
|
|
369
|
+
<p className="text-sm text-fg-muted">
|
|
370
|
+
This is the one outcome Remit cannot resolve on its own. The
|
|
371
|
+
server is in a half-changed state and needs you at a shell.
|
|
372
|
+
The log below is the only account of where it stopped.
|
|
373
|
+
</p>
|
|
374
|
+
</div>
|
|
375
|
+
</div>
|
|
376
|
+
<div className="space-y-1">
|
|
377
|
+
<p className="text-xs text-fg-subtle">
|
|
378
|
+
What Remit reported as the failure
|
|
379
|
+
</p>
|
|
380
|
+
<code className="block rounded-xs bg-danger-soft px-2 py-1 text-2xs text-danger">
|
|
381
|
+
{state.reason}
|
|
382
|
+
</code>
|
|
383
|
+
</div>
|
|
384
|
+
<div className="space-y-1">
|
|
385
|
+
<p className="text-xs text-fg-subtle">
|
|
386
|
+
Read the full log on the server:
|
|
387
|
+
</p>
|
|
388
|
+
<code className="block rounded-xs bg-surface-sunken px-2 py-1 text-2xs text-fg-muted">
|
|
389
|
+
{state.logsCommand}
|
|
390
|
+
</code>
|
|
391
|
+
</div>
|
|
392
|
+
<div className="flex justify-end">
|
|
393
|
+
<Button variant="ghost" size="sm" onClick={onDismissResult}>
|
|
394
|
+
Dismiss
|
|
395
|
+
</Button>
|
|
396
|
+
</div>
|
|
397
|
+
</div>
|
|
398
|
+
</SectionRow>
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
case "abandoned":
|
|
402
|
+
return (
|
|
403
|
+
<SectionRow tone="danger">
|
|
404
|
+
<div className="space-y-3">
|
|
405
|
+
<div className="flex items-start gap-2">
|
|
406
|
+
<TriangleAlert
|
|
407
|
+
className="mt-0.5 size-4 shrink-0 text-fg-subtle"
|
|
408
|
+
aria-hidden
|
|
409
|
+
/>
|
|
410
|
+
<div className="min-w-0 space-y-1">
|
|
411
|
+
<p className="text-sm font-semibold text-fg">
|
|
412
|
+
Remit {state.attemptedVersion} was not installed. Nothing
|
|
413
|
+
changed.
|
|
414
|
+
</p>
|
|
415
|
+
<p className="text-sm text-fg-muted">
|
|
416
|
+
The update stopped before it altered anything. You are still
|
|
417
|
+
running {state.version}.
|
|
418
|
+
</p>
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
<div className="space-y-1">
|
|
422
|
+
<p className="text-xs text-fg-subtle">What Remit reported</p>
|
|
423
|
+
<code className="block rounded-xs bg-surface-sunken px-2 py-1 text-2xs text-fg-muted">
|
|
424
|
+
{state.reason}
|
|
425
|
+
</code>
|
|
426
|
+
</div>
|
|
427
|
+
<div className="space-y-1">
|
|
428
|
+
<p className="text-xs text-fg-subtle">
|
|
429
|
+
Read the full log before trying again:
|
|
430
|
+
</p>
|
|
431
|
+
<code className="block rounded-xs bg-surface-sunken px-2 py-1 text-2xs text-fg-muted">
|
|
432
|
+
{state.logsCommand}
|
|
433
|
+
</code>
|
|
434
|
+
</div>
|
|
435
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
436
|
+
<Button
|
|
437
|
+
variant="secondary"
|
|
438
|
+
size="sm"
|
|
439
|
+
icon={<RotateCcw className="size-3.5" />}
|
|
440
|
+
onClick={handleInstall}
|
|
441
|
+
>
|
|
442
|
+
Try {state.attemptedVersion} again
|
|
443
|
+
</Button>
|
|
444
|
+
<Button variant="ghost" size="sm" onClick={onDismissResult}>
|
|
445
|
+
Stay on {state.version}
|
|
446
|
+
</Button>
|
|
447
|
+
</div>
|
|
448
|
+
</div>
|
|
449
|
+
</SectionRow>
|
|
450
|
+
);
|
|
451
|
+
|
|
351
452
|
default: {
|
|
352
453
|
const exhaustive: never = state;
|
|
353
454
|
return exhaustive;
|
|
@@ -164,6 +164,41 @@ describe("SelfUpdateSection", () => {
|
|
|
164
164
|
assert.match(html, /Check again/);
|
|
165
165
|
assert.doesNotMatch(html, /disabled=/);
|
|
166
166
|
});
|
|
167
|
+
|
|
168
|
+
it("renders a failed rollback verbatim and never claims a version is running", () => {
|
|
169
|
+
const html = section({
|
|
170
|
+
status: "rollbackFailed",
|
|
171
|
+
runId: demoRunId,
|
|
172
|
+
attemptedVersion: "0.9.4",
|
|
173
|
+
previousVersion: CURRENT,
|
|
174
|
+
reason: "migration 0042 failed and the snapshot restore errored",
|
|
175
|
+
logsCommand: demoLogsCommand,
|
|
176
|
+
});
|
|
177
|
+
assert.match(html, /could not put 0\.9\.3 back/);
|
|
178
|
+
assert.match(html, /needs you at a shell/);
|
|
179
|
+
assert.match(
|
|
180
|
+
html,
|
|
181
|
+
/migration 0042 failed and the snapshot restore errored/,
|
|
182
|
+
);
|
|
183
|
+
assert.match(html, /remit logs --since 10m/);
|
|
184
|
+
// The rollback itself failed — the pane must not assert a running version.
|
|
185
|
+
assert.doesNotMatch(html, /running 0\.9\.3 again/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("renders an abandoned run as a no-op that changed nothing", () => {
|
|
189
|
+
const html = section({
|
|
190
|
+
status: "abandoned",
|
|
191
|
+
runId: demoRunId,
|
|
192
|
+
version: CURRENT,
|
|
193
|
+
attemptedVersion: "0.9.4",
|
|
194
|
+
reason: "manifest fetch timed out before anything was pulled",
|
|
195
|
+
logsCommand: demoLogsCommand,
|
|
196
|
+
});
|
|
197
|
+
assert.match(html, /Nothing changed/);
|
|
198
|
+
assert.match(html, /still\s+running 0\.9\.3/);
|
|
199
|
+
assert.match(html, /manifest fetch timed out/);
|
|
200
|
+
assert.match(html, /Try 0\.9\.4 again/);
|
|
201
|
+
});
|
|
167
202
|
});
|
|
168
203
|
|
|
169
204
|
describe("SelfUpdateConfirmDialog", () => {
|
|
@@ -201,6 +236,25 @@ describe("SelfUpdateConfirmDialog", () => {
|
|
|
201
236
|
"",
|
|
202
237
|
);
|
|
203
238
|
});
|
|
239
|
+
|
|
240
|
+
it("says nothing about a migration by default", () => {
|
|
241
|
+
assert.doesNotMatch(html, /updates the database/);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("warns about a migration only when the release carries one", () => {
|
|
245
|
+
const withMigration = render(
|
|
246
|
+
createElement(SelfUpdateConfirmDialog, {
|
|
247
|
+
open: true,
|
|
248
|
+
currentVersion: CURRENT,
|
|
249
|
+
release: demoRelease,
|
|
250
|
+
appliesSchemaMigration: true,
|
|
251
|
+
onClose: noop,
|
|
252
|
+
onConfirm: noop,
|
|
253
|
+
}),
|
|
254
|
+
);
|
|
255
|
+
assert.match(withMigration, /updates the database while Remit is offline/);
|
|
256
|
+
assert.match(withMigration, /rolled back to the version and data you have/);
|
|
257
|
+
});
|
|
204
258
|
});
|
|
205
259
|
|
|
206
260
|
describe("SelfUpdateProgressOverlay", () => {
|
|
@@ -111,6 +111,39 @@ export const RolledBack: Story = {
|
|
|
111
111
|
}),
|
|
112
112
|
};
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* The new version did not start and the rollback to the old one failed too.
|
|
116
|
+
* The one outcome Remit cannot resolve on its own: it names the failure and the
|
|
117
|
+
* log verbatim, claims no running version, and sends the operator to a shell.
|
|
118
|
+
*/
|
|
119
|
+
export const RollbackFailed: Story = {
|
|
120
|
+
args: withState({
|
|
121
|
+
status: "rollbackFailed",
|
|
122
|
+
runId: demoRunId,
|
|
123
|
+
attemptedVersion: demoRelease.version,
|
|
124
|
+
previousVersion: CURRENT,
|
|
125
|
+
reason:
|
|
126
|
+
"migration 0042_add_thread_index failed and the snapshot restore errored: database is locked",
|
|
127
|
+
logsCommand: demoLogsCommand,
|
|
128
|
+
}),
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The run stopped before it changed anything — a manifest that could not be
|
|
133
|
+
* fetched, a preflight that refused. Nothing was installed and nothing was
|
|
134
|
+
* touched, so the pane says exactly that and offers the retry.
|
|
135
|
+
*/
|
|
136
|
+
export const Abandoned: Story = {
|
|
137
|
+
args: withState({
|
|
138
|
+
status: "abandoned",
|
|
139
|
+
runId: demoRunId,
|
|
140
|
+
version: CURRENT,
|
|
141
|
+
attemptedVersion: demoRelease.version,
|
|
142
|
+
reason: "manifest fetch timed out before anything was pulled",
|
|
143
|
+
logsCommand: demoLogsCommand,
|
|
144
|
+
}),
|
|
145
|
+
};
|
|
146
|
+
|
|
114
147
|
/**
|
|
115
148
|
* An update is running. The blocking screen owns the window; the pane behind it
|
|
116
149
|
* still says what is going on rather than going blank.
|
|
@@ -158,6 +191,24 @@ export const ConfirmBeforeInstalling: Story = {
|
|
|
158
191
|
),
|
|
159
192
|
};
|
|
160
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Consent for a release that also migrates the database. The extra line states
|
|
196
|
+
* the forward step is one-way but a failed start is still rolled back — shown
|
|
197
|
+
* only when the surface reports a higher schema version than the running one.
|
|
198
|
+
*/
|
|
199
|
+
export const ConfirmWithSchemaMigration: Story = {
|
|
200
|
+
render: () => (
|
|
201
|
+
<SelfUpdateConfirmDialog
|
|
202
|
+
open
|
|
203
|
+
currentVersion={CURRENT}
|
|
204
|
+
release={demoRelease}
|
|
205
|
+
appliesSchemaMigration
|
|
206
|
+
onClose={() => {}}
|
|
207
|
+
onConfirm={() => {}}
|
|
208
|
+
/>
|
|
209
|
+
),
|
|
210
|
+
};
|
|
211
|
+
|
|
161
212
|
/**
|
|
162
213
|
* Consent reached from the pane, and declined. The offer stays exactly where
|
|
163
214
|
* it was; declining costs nothing and is not asked about again.
|
|
@@ -69,6 +69,27 @@ export type SelfUpdateState =
|
|
|
69
69
|
attemptedVersion: string;
|
|
70
70
|
elapsedSeconds: number;
|
|
71
71
|
logsCommand: string;
|
|
72
|
+
}
|
|
73
|
+
| {
|
|
74
|
+
status: "rollbackFailed";
|
|
75
|
+
runId: UpdateRunId;
|
|
76
|
+
attemptedVersion: string;
|
|
77
|
+
previousVersion: string;
|
|
78
|
+
/** The server's own account of the failure, shown verbatim. */
|
|
79
|
+
reason: string;
|
|
80
|
+
/** The command to read the full log, shown verbatim. */
|
|
81
|
+
logsCommand: string;
|
|
82
|
+
}
|
|
83
|
+
| {
|
|
84
|
+
status: "abandoned";
|
|
85
|
+
runId: UpdateRunId;
|
|
86
|
+
/** The version still running — the run changed nothing. */
|
|
87
|
+
version: string;
|
|
88
|
+
attemptedVersion: string;
|
|
89
|
+
/** The server's own account of why it stopped, shown verbatim. */
|
|
90
|
+
reason: string;
|
|
91
|
+
/** The command to read the full log, shown verbatim. */
|
|
92
|
+
logsCommand: string;
|
|
72
93
|
};
|
|
73
94
|
|
|
74
95
|
export type SelfUpdateStatus = SelfUpdateState["status"];
|