@remit/web-client 0.0.245 → 0.0.247

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.245",
3
+ "version": "0.0.247",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -565,6 +565,9 @@ export function ConfigImportWizard({ onDone }: ConfigImportWizardProps) {
565
565
  results={sectionResults(report)}
566
566
  message={failed?.message ?? "The import stopped before it finished."}
567
567
  raw={`${failed?.code ?? WRITE_FAILURE_CODE}: ${failed?.message ?? "no message"}`}
568
+ // `applied` is the server's answer to what survived: false means the
569
+ // write was undone whole, and no section may read as landed.
570
+ undone={!report.applied}
568
571
  onBack={goToFile}
569
572
  onNext={() => runImport("apply", "merge")}
570
573
  />
@@ -181,6 +181,32 @@ export const partialImportReport: RemitImapConfigImportReport = report({
181
181
  warnings: folderWarnings,
182
182
  });
183
183
 
184
+ /**
185
+ * The same stop on a backend that imports inside one transaction (#1093):
186
+ * `applied` is false and `items` holds only the rejected entry, because the
187
+ * rollback emptied the rest. No section may read as landed.
188
+ */
189
+ export const rolledBackImportReport: RemitImapConfigImportReport = report({
190
+ applied: false,
191
+ items: [
192
+ item(
193
+ "filters",
194
+ "Bonnetjes",
195
+ "rejected",
196
+ "the filter store refused the write",
197
+ ),
198
+ ],
199
+ errors: [
200
+ {
201
+ code: "import_write_failed",
202
+ message:
203
+ "the filter store refused the write Nothing was written; the import stopped here.",
204
+ details: { section: "filters", key: "Bonnetjes" },
205
+ },
206
+ ],
207
+ warnings: [],
208
+ });
209
+
184
210
  export const importedAccounts: ImportedAccount[] = [
185
211
  {
186
212
  accountId: "acct-ischen",
@@ -21,6 +21,7 @@ import {
21
21
  importedAccounts,
22
22
  partialImportReport,
23
23
  rejectedReports,
24
+ rolledBackImportReport,
24
25
  } from "./config-import.fixtures";
25
26
  import {
26
27
  AccountCredentialsList,
@@ -432,6 +433,26 @@ export const PartiallyLandedImport: Story = {
432
433
  ),
433
434
  };
434
435
 
436
+ /**
437
+ * The same stop on a transactional backend: nothing landed, and the screen
438
+ * says the import was undone rather than counting sections (#1093).
439
+ */
440
+ export const RolledBackImport: Story = {
441
+ render: () => (
442
+ <StepPartialImport
443
+ results={sectionResults(rolledBackImportReport)}
444
+ message={
445
+ writeFailure(rolledBackImportReport)?.message ??
446
+ "The import stopped before it finished."
447
+ }
448
+ raw={`import_write_failed: ${
449
+ writeFailure(rolledBackImportReport)?.message ?? "no message"
450
+ }`}
451
+ undone={!rolledBackImportReport.applied}
452
+ />
453
+ ),
454
+ };
455
+
435
456
  /** Settings → Advanced: the export half of the pair. */
436
457
  export const ExportCard: Story = {
437
458
  render: () => (
@@ -706,11 +706,14 @@ export function StepFileRejected({
706
706
  /**
707
707
  * A section nobody can vouch for reads as failed, never as pending. Pending
708
708
  * says "still coming"; this section is finished and its outcome is unknown,
709
- * which is the row the reader has to go and check for themselves.
709
+ * which is the row the reader has to go and check for themselves. A rolled-
710
+ * back section is the same answer from the server's side: it is not coming,
711
+ * because the transaction that wrote it undid itself.
710
712
  */
711
713
  function resultState(state: SectionResult["state"]) {
712
714
  if (state === "landed") return "ok" as const;
713
- if (state === "failed" || state === "unknown") return "failed" as const;
715
+ if (state === "failed" || state === "unknown" || state === "rolled-back")
716
+ return "failed" as const;
714
717
  return "pending" as const;
715
718
  }
716
719
 
@@ -718,6 +721,7 @@ export function StepPartialImport({
718
721
  results,
719
722
  message,
720
723
  raw,
724
+ undone,
721
725
  onBack,
722
726
  onNext,
723
727
  }: {
@@ -725,6 +729,8 @@ export function StepPartialImport({
725
729
  /** The server's own account of what survived, which only it knows. */
726
730
  message: string;
727
731
  raw: string;
732
+ /** `report.applied === false`: the import ran in a transaction and wrote nothing, so no section landed. */
733
+ undone?: boolean;
728
734
  } & StepNav) {
729
735
  const landed = results.filter((result) => result.state === "landed").length;
730
736
  const unknown = results.some((result) => result.state === "unknown");
@@ -734,9 +740,11 @@ export function StepPartialImport({
734
740
  activeStep={1}
735
741
  title="The import stopped part-way"
736
742
  subtitle={
737
- unknown
738
- ? "It did not say how far it got, so check Settings before importing again."
739
- : `${landed} of ${results.length} sections landed.`
743
+ undone
744
+ ? "Nothing was written — the whole import was undone."
745
+ : unknown
746
+ ? "It did not say how far it got, so check Settings before importing again."
747
+ : `${landed} of ${results.length} sections landed.`
740
748
  }
741
749
  footer={
742
750
  <>
@@ -58,23 +58,6 @@ const UndoHarness = () => {
58
58
  let harness: DomHarness | undefined;
59
59
  let http: HttpMock;
60
60
 
61
- const waitFor = async (
62
- predicate: () => boolean,
63
- timeoutMs = 2000,
64
- ): Promise<void> => {
65
- if (!harness) throw new Error("nothing mounted");
66
- const deadline = Date.now() + timeoutMs;
67
- while (!predicate()) {
68
- if (Date.now() > deadline) {
69
- throw new Error(
70
- `waitFor: condition never became true within ${timeoutMs}ms`,
71
- );
72
- }
73
- await harness.flush();
74
- await harness.wait(5);
75
- }
76
- };
77
-
78
61
  afterEach(() => {
79
62
  harness?.close();
80
63
  harness = undefined;
@@ -95,14 +78,14 @@ describe("the spam quick action disables itself for the duration of its own requ
95
78
 
96
79
  const button = harness.byText("button", "Report spam") as HTMLButtonElement;
97
80
  harness.click(button);
98
- await waitFor(() => button.textContent === "Reporting…");
81
+ await harness.waitFor(() => button.textContent === "Reporting…");
99
82
 
100
83
  assert.equal(button.disabled, true, "a control mid-request must disable");
101
84
  harness.click(button);
102
85
 
103
86
  assert.ok(resolveRequest, "the request never reached the mock");
104
87
  resolveRequest?.();
105
- await waitFor(() => button.textContent === "Report spam");
88
+ await harness.waitFor(() => button.textContent === "Report spam");
106
89
 
107
90
  assert.equal(http.to("/messages/report-spam").length, 1);
108
91
  });
@@ -120,14 +103,14 @@ describe("the spam quick action disables itself for the duration of its own requ
120
103
 
121
104
  const button = harness.byText("button", "Not spam") as HTMLButtonElement;
122
105
  harness.click(button);
123
- await waitFor(() => button.textContent === "Undoing…");
106
+ await harness.waitFor(() => button.textContent === "Undoing…");
124
107
 
125
108
  assert.equal(button.disabled, true, "a control mid-request must disable");
126
109
  harness.click(button);
127
110
 
128
111
  assert.ok(resolveRequest, "the request never reached the mock");
129
112
  resolveRequest?.();
130
- await waitFor(() => button.textContent === "Not spam");
113
+ await harness.waitFor(() => button.textContent === "Not spam");
131
114
 
132
115
  assert.equal(http.to("/messages/not-spam").length, 1);
133
116
  });
@@ -62,23 +62,6 @@ const respond =
62
62
  let harness: DomHarness | undefined;
63
63
  let http: HttpMock;
64
64
 
65
- const waitFor = async (
66
- predicate: () => boolean,
67
- timeoutMs = 2000,
68
- ): Promise<void> => {
69
- if (!harness) throw new Error("nothing mounted");
70
- const deadline = Date.now() + timeoutMs;
71
- while (!predicate()) {
72
- if (Date.now() > deadline) {
73
- throw new Error(
74
- `waitFor: condition never became true within ${timeoutMs}ms`,
75
- );
76
- }
77
- await harness.flush();
78
- await harness.wait(5);
79
- }
80
- };
81
-
82
65
  afterEach(() => {
83
66
  harness?.close();
84
67
  harness = undefined;
@@ -91,7 +74,7 @@ describe("the Similar messages section on an instance with semantic search off",
91
74
  harness = createDomHarness();
92
75
  harness.renderApp(createElement(SimilarHarness));
93
76
 
94
- await waitFor(
77
+ await harness.waitFor(
95
78
  () => harness?.text().includes("Semantic search is off") === true,
96
79
  );
97
80
 
@@ -107,7 +90,7 @@ describe("the Similar messages section on an instance with semantic search off",
107
90
  harness = createDomHarness();
108
91
  harness.renderApp(createElement(SimilarHarness));
109
92
 
110
- await waitFor(
93
+ await harness.waitFor(
111
94
  () => harness?.text().includes("Semantic search is off") === true,
112
95
  );
113
96
  await harness.wait(30);
@@ -121,7 +104,7 @@ describe("the Similar messages section on an instance with semantic search off",
121
104
  harness = createDomHarness();
122
105
  harness.renderApp(createElement(SimilarHarness));
123
106
 
124
- await waitFor(() => http.to("/search/semantic").length > 0);
107
+ await harness.waitFor(() => http.to("/search/semantic").length > 0);
125
108
 
126
109
  assert.doesNotMatch(harness.text(), /Semantic search is off/);
127
110
  });
@@ -151,6 +151,91 @@ test("a stopped import splits the write order into landed, failed and never reac
151
151
  assert.equal(byId.get("settings")?.state, "not-attempted");
152
152
  });
153
153
 
154
+ test("a rolled-back import lands nothing, however far the items say it got", () => {
155
+ // The shape a transactional backend answers with (#1093): `items` holds
156
+ // only the rejected entry, because the rollback emptied the rest.
157
+ const rolled = sectionResults(
158
+ reportOf({
159
+ applied: false,
160
+ items: [item("filters", "Receipts", "rejected", "the store refused")],
161
+ errors: [
162
+ {
163
+ code: "import_write_failed",
164
+ message:
165
+ "the store refused the write Nothing was written; the import stopped here.",
166
+ details: { section: "filters", key: "Receipts" },
167
+ },
168
+ ],
169
+ }),
170
+ );
171
+
172
+ assert.equal(
173
+ rolled.some((result) => result.state === "landed"),
174
+ false,
175
+ );
176
+ const byId = new Map(rolled.map((result) => [result.section, result]));
177
+ assert.equal(byId.get("accounts")?.state, "rolled-back");
178
+ assert.match(byId.get("accounts")?.detail ?? "", /Nothing was written/);
179
+ assert.equal(byId.get("labels")?.state, "rolled-back");
180
+ assert.equal(byId.get("filters")?.state, "failed");
181
+ assert.match(byId.get("filters")?.detail ?? "", /Nothing was written/);
182
+ assert.equal(byId.get("addressFlags")?.state, "rolled-back");
183
+ assert.equal(byId.get("settings")?.state, "rolled-back");
184
+ });
185
+
186
+ test("item verdicts cannot turn a rolled-back import back into a landed one", () => {
187
+ // `applied` is the server's own answer, and it outranks the counts: a
188
+ // report that claims writes while saying nothing was applied still lands
189
+ // nothing, because "your accounts are in" is the one answer this screen
190
+ // must never give without the server saying so.
191
+ const rolled = sectionResults(
192
+ reportOf({
193
+ applied: false,
194
+ items: [
195
+ item("accounts", "a@example.test", "created"),
196
+ item("labels", "Receipts", "created"),
197
+ ],
198
+ errors: [
199
+ {
200
+ code: "import_write_failed",
201
+ message: "the store refused the write",
202
+ details: { section: "filters", key: "Receipts" },
203
+ },
204
+ ],
205
+ }),
206
+ );
207
+
208
+ assert.equal(
209
+ rolled.some((result) => result.state === "landed"),
210
+ false,
211
+ );
212
+ assert.deepEqual(
213
+ rolled
214
+ .filter((result) => result.section !== "filters")
215
+ .map((result) => result.state),
216
+ ["rolled-back", "rolled-back", "rolled-back", "rolled-back"],
217
+ );
218
+ });
219
+
220
+ test("a rolled-back import that names no section still says what happened", () => {
221
+ const rolled = sectionResults(
222
+ reportOf({
223
+ applied: false,
224
+ errors: [{ code: "import_write_failed", message: "the store refused" }],
225
+ }),
226
+ );
227
+
228
+ assert.equal(
229
+ rolled.some((result) => result.state === "landed"),
230
+ false,
231
+ );
232
+ assert.equal(
233
+ rolled.some((result) => result.state === "unknown"),
234
+ false,
235
+ );
236
+ assert.match(rolled[0].detail, /Nothing was written/);
237
+ });
238
+
154
239
  test("a pending folder names the setting waiting on it, and repeats are folded", () => {
155
240
  const folders = pendingFolders(
156
241
  reportOf({
@@ -285,7 +285,12 @@ export const writeFailure = (
285
285
  ): ApiError | undefined =>
286
286
  report.errors.find((it) => it.code === WRITE_FAILURE_CODE);
287
287
 
288
- export type SectionOutcome = "landed" | "failed" | "not-attempted" | "unknown";
288
+ export type SectionOutcome =
289
+ | "landed"
290
+ | "failed"
291
+ | "not-attempted"
292
+ | "rolled-back"
293
+ | "unknown";
289
294
 
290
295
  export interface SectionResult {
291
296
  section: ImportSection;
@@ -305,6 +310,12 @@ export interface SectionResult {
305
310
  * section reads `unknown` rather than `landed`. Telling someone their accounts
306
311
  * are in when nothing said so is the one answer this screen must never give:
307
312
  * they would stop looking.
313
+ *
314
+ * `applied` says so when the import ran in a transaction: `false` means the
315
+ * write was undone whole, and the item counts — empty precisely because the
316
+ * rollback emptied them — must not turn sections before the failure back into
317
+ * `landed`. Every section then reads `rolled-back` except the one the failure
318
+ * named, which keeps its `failed` row and the server's own sentence.
308
319
  */
309
320
  export const sectionResults = (
310
321
  report: RemitImapConfigImportReport,
@@ -320,6 +331,21 @@ export const sectionResults = (
320
331
  counts.set(section, (counts.get(section) ?? 0) + 1);
321
332
  }
322
333
 
334
+ if (failure && report.applied === false) {
335
+ return IMPORT_SECTION_ORDER.map((section) => ({
336
+ section,
337
+ title: SECTION_TITLES[section],
338
+ state:
339
+ section === failedSection
340
+ ? ("failed" as const)
341
+ : ("rolled-back" as const),
342
+ detail:
343
+ section === failedSection
344
+ ? (failure.message ?? "This section could not be written.")
345
+ : "Nothing was written — the import ran as one transaction and was undone.",
346
+ }));
347
+ }
348
+
323
349
  if (!failedSection) {
324
350
  const detail = named
325
351
  ? `The import stopped in "${named}", which this instance does not recognise, so it cannot say whether this section was written.`