@remit/ui 0.0.40 → 0.0.42

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,230 @@
1
+ /**
2
+ * The filter rule as the user edits it (RFC 038).
3
+ *
4
+ * A rule is literal clauses combined under one match operator, an optional
5
+ * semantic widen, an action, and a scope. This module is the vocabulary the
6
+ * chip editor renders — a design model driven from fixtures, not the API.
7
+ * Naming tracks the RFC: rule, clause, widen, scope.
8
+ */
9
+
10
+ /**
11
+ * The clause fields (RFC 038 D2). `From`, `Subject`, `HasWords` ship now;
12
+ * `ListId` and `FromDomain` arrive with the vocabulary ticket. Every variant
13
+ * renders here so that ticket slots its fields in without touching the chip.
14
+ */
15
+ export type ClauseField =
16
+ | "From"
17
+ | "Subject"
18
+ | "HasWords"
19
+ | "ListId"
20
+ | "FromDomain";
21
+
22
+ export interface RuleClause {
23
+ id: string;
24
+ field: ClauseField;
25
+ value: string;
26
+ /**
27
+ * A `From` clause the server derived from the selection because the widen
28
+ * could not run (#251). It is an ordinary visible, editable chip — this flag
29
+ * only annotates where it came from.
30
+ */
31
+ derived?: boolean;
32
+ }
33
+
34
+ /** How the clauses combine. Maps to the API's `And` / `Or`. */
35
+ export type MatchOperator = "all" | "any";
36
+
37
+ /**
38
+ * When the rule stops (RFC 038 D1). `once` is a one-time action, `standing`
39
+ * persists, `until` expires on a picked date.
40
+ */
41
+ export type RuleScope = "once" | "standing" | "until";
42
+
43
+ /**
44
+ * The semantic widen (RFC 038 D3): one chip backed by the anchor mechanism.
45
+ * Absent from a rule means the chip is not present; a deployment that cannot
46
+ * serve the widen never offers it at all.
47
+ */
48
+ export interface RuleWiden {
49
+ /** Anchors the similarity rides on — "similar to these N". */
50
+ anchorCount: number;
51
+ /**
52
+ * The rule carries an anchor this deployment cannot evaluate — created
53
+ * elsewhere, capability since lost (RFC 038 D4). The chip lists as inactive,
54
+ * the rule matches by its literal clauses only, and nothing claims otherwise.
55
+ */
56
+ inactive?: boolean;
57
+ }
58
+
59
+ export interface FilterRule {
60
+ clauses: RuleClause[];
61
+ matchOperator: MatchOperator;
62
+ widen?: RuleWiden;
63
+ /** The move-to-folder action's destination. Absent leaves mail in place. */
64
+ moveMailboxId?: string;
65
+ scope: RuleScope;
66
+ /** ISO 8601 civil date (`YYYY-MM-DD`) for the `until` scope. */
67
+ until?: string;
68
+ /** Names a standing or timed rule so it can be found in Settings › Filters. */
69
+ name?: string;
70
+ }
71
+
72
+ export interface FolderOption {
73
+ id: string;
74
+ label: string;
75
+ }
76
+
77
+ /**
78
+ * The live match count (RFC 038 D1). `stale` marks a count the editor already
79
+ * moved past — a clause changed after it was counted, so the number on screen
80
+ * is the previous rule's until the next preview lands.
81
+ */
82
+ export type PreviewCount =
83
+ | { status: "loading" }
84
+ | { status: "ready"; count: number; stale?: boolean }
85
+ | { status: "error"; reason: string };
86
+
87
+ const clauseFieldLabels: Record<ClauseField, string> = {
88
+ From: "From",
89
+ Subject: "Subject",
90
+ HasWords: "Has the words",
91
+ ListId: "List",
92
+ FromDomain: "Domain",
93
+ };
94
+
95
+ export function clauseFieldLabel(field: ClauseField): string {
96
+ return clauseFieldLabels[field];
97
+ }
98
+
99
+ /** The fields a new clause can be added as, in menu order. */
100
+ export const clauseFieldOrder: ClauseField[] = [
101
+ "From",
102
+ "Subject",
103
+ "HasWords",
104
+ "ListId",
105
+ "FromDomain",
106
+ ];
107
+
108
+ export function widenChipLabel(widen: RuleWiden): string {
109
+ return `Similar to these ${widen.anchorCount}`;
110
+ }
111
+
112
+ const matchOperatorLabels: Record<MatchOperator, string> = {
113
+ all: "Match all",
114
+ any: "Match any",
115
+ };
116
+
117
+ export function matchOperatorLabel(operator: MatchOperator): string {
118
+ return matchOperatorLabels[operator];
119
+ }
120
+
121
+ /**
122
+ * The join word between chips — "all" reads as "and", "any" as "or". Rendered
123
+ * between clauses so the operator is legible in the rule itself, not only the
124
+ * toggle.
125
+ */
126
+ export function matchJoinWord(operator: MatchOperator): string {
127
+ return operator === "all" ? "and" : "or";
128
+ }
129
+
130
+ export function previewCountSummary(preview: PreviewCount): string {
131
+ if (preview.status === "loading") return "Counting matches…";
132
+ if (preview.status === "error") return preview.reason;
133
+ if (preview.count === 0) return "No mail matches yet";
134
+ const noun = preview.count === 1 ? "message" : "messages";
135
+ const base = `${preview.count} ${noun} match`;
136
+ return preview.stale ? `${base} — recounting` : base;
137
+ }
138
+
139
+ const scopeLabels: Record<RuleScope, string> = {
140
+ once: "Just once",
141
+ standing: "Keep doing this",
142
+ until: "Until a date",
143
+ };
144
+
145
+ export function scopeLabel(scope: RuleScope): string {
146
+ return scopeLabels[scope];
147
+ }
148
+
149
+ export function commitLabel(scope: RuleScope): string {
150
+ if (scope === "once") return "Apply now";
151
+ if (scope === "standing") return "Save rule";
152
+ return "Save until then";
153
+ }
154
+
155
+ /**
156
+ * Why the rule cannot be saved yet, or `undefined` when it is ready. A rule
157
+ * needs at least one live way to match, a folder to move into (the only wired
158
+ * action), and — for the two persisted scopes — a name and, for `until`, a
159
+ * date. It also needs a settled preview: the rule is committable only when the
160
+ * count on screen is the count that will be applied. That makes RFC 038's
161
+ * previewed-set-equals-applied-set contract structural — a consumer cannot save
162
+ * a rule whose match count is still moving. Never disable a control without
163
+ * saying why (ux.md).
164
+ */
165
+ export function commitBlockedReason(
166
+ rule: FilterRule,
167
+ preview: PreviewCount,
168
+ ): string | undefined {
169
+ const hasMatch =
170
+ rule.clauses.length > 0 ||
171
+ (rule.widen !== undefined && !rule.widen.inactive);
172
+ if (!hasMatch) return "Add a clause so the rule has something to match.";
173
+ if (!rule.moveMailboxId)
174
+ return "Pick a folder to move matches into — labeling isn't available yet.";
175
+ if (
176
+ (rule.scope === "standing" || rule.scope === "until") &&
177
+ (rule.name ?? "").trim() === ""
178
+ )
179
+ return "Name this rule so you can find it later.";
180
+ if (rule.scope === "until" && !rule.until)
181
+ return "Pick the date this rule should stop on.";
182
+ if (preview.status === "loading")
183
+ return "Counting matches — save once the count settles.";
184
+ if (preview.status === "ready" && preview.stale)
185
+ return "Recounting matches — save once the count settles.";
186
+ return undefined;
187
+ }
188
+
189
+ export const demoFolders: FolderOption[] = [
190
+ { id: "mbx-inbox", label: "Inbox" },
191
+ { id: "mbx-archive", label: "Archive" },
192
+ { id: "mbx-receipts", label: "Receipts" },
193
+ { id: "mbx-travel", label: "Travel" },
194
+ { id: "mbx-junk", label: "Junk" },
195
+ ];
196
+
197
+ export const demoRule: FilterRule = {
198
+ clauses: [
199
+ { id: "c1", field: "From", value: "notifications@github.com" },
200
+ { id: "c2", field: "Subject", value: "pull request" },
201
+ ],
202
+ matchOperator: "all",
203
+ widen: { anchorCount: 2 },
204
+ moveMailboxId: "mbx-archive",
205
+ scope: "standing",
206
+ name: "GitHub notifications",
207
+ };
208
+
209
+ export const demoVocabularyRule: FilterRule = {
210
+ clauses: [
211
+ { id: "c1", field: "ListId", value: "python-dev.python.org" },
212
+ { id: "c2", field: "FromDomain", value: "python.org" },
213
+ { id: "c3", field: "HasWords", value: "nightly build" },
214
+ ],
215
+ matchOperator: "any",
216
+ moveMailboxId: "mbx-archive",
217
+ scope: "standing",
218
+ name: "Python lists",
219
+ };
220
+
221
+ export const demoSenderFallbackRule: FilterRule = {
222
+ clauses: [
223
+ { id: "c1", field: "From", value: "receipts@stripe.com", derived: true },
224
+ { id: "c2", field: "From", value: "receipts@lyft.com", derived: true },
225
+ ],
226
+ matchOperator: "any",
227
+ moveMailboxId: "mbx-receipts",
228
+ scope: "standing",
229
+ name: "Receipts",
230
+ };
@@ -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 (!installable && state.status !== "rolledBack") {
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"];
package/src/index.ts CHANGED
@@ -121,6 +121,53 @@ export {
121
121
  FieldLabel,
122
122
  type FieldLabelProps,
123
123
  } from "./components/field-label.js";
124
+ export {
125
+ AddChipButton,
126
+ type AddChipButtonProps,
127
+ ClauseChip,
128
+ type ClauseChipProps,
129
+ type ClauseDraft,
130
+ ClauseEditor,
131
+ type ClauseEditorProps,
132
+ WidenChip,
133
+ type WidenChipProps,
134
+ } from "./components/filter-clause-chip.js";
135
+ export {
136
+ FilterPreviewCount,
137
+ type FilterPreviewCountProps,
138
+ } from "./components/filter-preview-count.js";
139
+ export {
140
+ type ClauseField,
141
+ clauseFieldLabel,
142
+ clauseFieldOrder,
143
+ commitBlockedReason,
144
+ commitLabel,
145
+ demoFolders,
146
+ demoRule,
147
+ demoSenderFallbackRule,
148
+ demoVocabularyRule,
149
+ type FilterRule,
150
+ type FolderOption,
151
+ type MatchOperator,
152
+ matchJoinWord,
153
+ matchOperatorLabel,
154
+ type PreviewCount,
155
+ previewCountSummary,
156
+ type RuleClause,
157
+ type RuleScope,
158
+ type RuleWiden,
159
+ scopeLabel,
160
+ widenChipLabel,
161
+ } from "./components/filter-rule.js";
162
+ export {
163
+ type ClauseEditState,
164
+ FilterRuleDialog,
165
+ type FilterRuleDialogProps,
166
+ FilterRuleEditor,
167
+ type FilterRuleEditorProps,
168
+ FilterRuleSheet,
169
+ type FilterRuleSheetProps,
170
+ } from "./components/filter-rule-editor.js";
124
171
  export {
125
172
  FilterSheet,
126
173
  type FilterSheetCategory,