@remit/web-client 0.0.246 → 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.246",
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,7 +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
- nothingLanded={!report.applied}
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}
569
571
  onBack={goToFile}
570
572
  onNext={() => runImport("apply", "merge")}
571
573
  />
@@ -182,11 +182,11 @@ export const partialImportReport: RemitImapConfigImportReport = report({
182
182
  });
183
183
 
184
184
  /**
185
- * The same stop with no section written: a store that holds the import in one
186
- * transaction undoes the earlier writes, so the report carries no landed item
187
- * and `applied` is false. Warnings go with them.
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
188
  */
189
- export const nothingLandedImportReport: RemitImapConfigImportReport = report({
189
+ export const rolledBackImportReport: RemitImapConfigImportReport = report({
190
190
  applied: false,
191
191
  items: [
192
192
  item(
@@ -19,9 +19,9 @@ import {
19
19
  cleanRunReport,
20
20
  dryRunReport,
21
21
  importedAccounts,
22
- nothingLandedImportReport,
23
22
  partialImportReport,
24
23
  rejectedReports,
24
+ rolledBackImportReport,
25
25
  } from "./config-import.fixtures";
26
26
  import {
27
27
  AccountCredentialsList,
@@ -417,7 +417,6 @@ export const PartiallyLandedImportUnnamed: Story = {
417
417
  results={sectionResults(report)}
418
418
  message={writeFailure(report)?.message ?? ""}
419
419
  raw="import_write_failed: the store refused the write"
420
- nothingLanded={!report.applied}
421
420
  />
422
421
  );
423
422
  },
@@ -430,23 +429,26 @@ export const PartiallyLandedImport: Story = {
430
429
  results={sectionResults(partialImportReport)}
431
430
  message={writeFailure(partialImportReport)?.message ?? ""}
432
431
  raw={`import_write_failed: ${writeFailure(partialImportReport)?.message ?? ""}`}
433
- nothingLanded={!partialImportReport.applied}
434
432
  />
435
433
  ),
436
434
  };
437
435
 
438
436
  /**
439
- * The same stop with no section written — a transaction undid the earlier
440
- * writes, or the first write was the one that failed. No section may carry a
441
- * tick, and the screen counts nothing as landed.
437
+ * The same stop on a transactional backend: nothing landed, and the screen
438
+ * says the import was undone rather than counting sections (#1093).
442
439
  */
443
- export const NothingLandedImport: Story = {
440
+ export const RolledBackImport: Story = {
444
441
  render: () => (
445
442
  <StepPartialImport
446
- results={sectionResults(nothingLandedImportReport)}
447
- message={writeFailure(nothingLandedImportReport)?.message ?? ""}
448
- raw={`import_write_failed: ${writeFailure(nothingLandedImportReport)?.message ?? ""}`}
449
- nothingLanded={!nothingLandedImportReport.applied}
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}
450
452
  />
451
453
  ),
452
454
  };
@@ -704,15 +704,16 @@ export function StepFileRejected({
704
704
  }
705
705
 
706
706
  /**
707
- * A section nobody can vouch for reads as failed, never as pending: it is
708
- * finished, and an outcome the reader has to go and check for themselves is
709
- * not a quiet empty row. Pending is the empty row — nothing from the file went
710
- * into this section, whether the import never reached it or reached it and
711
- * wrote none of it — and it is the state that carries no tick.
707
+ * A section nobody can vouch for reads as failed, never as pending. Pending
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. 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.
712
712
  */
713
713
  function resultState(state: SectionResult["state"]) {
714
714
  if (state === "landed") return "ok" as const;
715
- if (state === "failed" || state === "unknown") return "failed" as const;
715
+ if (state === "failed" || state === "unknown" || state === "rolled-back")
716
+ return "failed" as const;
716
717
  return "pending" as const;
717
718
  }
718
719
 
@@ -720,7 +721,7 @@ export function StepPartialImport({
720
721
  results,
721
722
  message,
722
723
  raw,
723
- nothingLanded,
724
+ undone,
724
725
  onBack,
725
726
  onNext,
726
727
  }: {
@@ -728,8 +729,8 @@ export function StepPartialImport({
728
729
  /** The server's own account of what survived, which only it knows. */
729
730
  message: string;
730
731
  raw: string;
731
- /** No section of the file was written — not that the instance is unchanged. */
732
- nothingLanded: boolean;
732
+ /** `report.applied === false`: the import ran in a transaction and wrote nothing, so no section landed. */
733
+ undone?: boolean;
733
734
  } & StepNav) {
734
735
  const landed = results.filter((result) => result.state === "landed").length;
735
736
  const unknown = results.some((result) => result.state === "unknown");
@@ -737,14 +738,10 @@ export function StepPartialImport({
737
738
  <WizardShell
738
739
  steps={IMPORT_STEPS}
739
740
  activeStep={1}
740
- title={
741
- nothingLanded
742
- ? "The import stopped, and nothing landed"
743
- : "The import stopped part-way"
744
- }
741
+ title="The import stopped part-way"
745
742
  subtitle={
746
- nothingLanded
747
- ? "No section of the file was written."
743
+ undone
744
+ ? "Nothing was written — the whole import was undone."
748
745
  : unknown
749
746
  ? "It did not say how far it got, so check Settings before importing again."
750
747
  : `${landed} of ${results.length} sections landed.`
@@ -755,7 +752,7 @@ export function StepPartialImport({
755
752
  Choose another file
756
753
  </Button>
757
754
  <Button variant="primary" onClick={onNext}>
758
- {nothingLanded ? "Try the import again" : "Retry the rest"}
755
+ Retry the rest
759
756
  </Button>
760
757
  </>
761
758
  }
@@ -151,13 +151,13 @@ 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("an import the server says wrote nothing marks no section landed, whatever ran before the stop", () => {
155
- const results = sectionResults(
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(
156
158
  reportOf({
157
159
  applied: false,
158
- items: [
159
- item("filters", "Receipts", "rejected", "the store refused the write"),
160
- ],
160
+ items: [item("filters", "Receipts", "rejected", "the store refused")],
161
161
  errors: [
162
162
  {
163
163
  code: "import_write_failed",
@@ -169,55 +169,71 @@ test("an import the server says wrote nothing marks no section landed, whatever
169
169
  }),
170
170
  );
171
171
 
172
- const byId = new Map(results.map((result) => [result.section, result]));
173
- assert.equal(byId.get("accounts")?.state, "not-landed");
174
- assert.match(
175
- byId.get("accounts")?.detail ?? "",
176
- /Nothing from this section was written/,
172
+ assert.equal(
173
+ rolled.some((result) => result.state === "landed"),
174
+ false,
177
175
  );
178
- assert.equal(byId.get("labels")?.state, "not-landed");
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");
179
180
  assert.equal(byId.get("filters")?.state, "failed");
180
- assert.equal(byId.get("addressFlags")?.state, "not-attempted");
181
- assert.equal(byId.get("settings")?.state, "not-attempted");
182
- assert.equal(results.filter((result) => result.state === "landed").length, 0);
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");
183
184
  });
184
185
 
185
- test("a first write that fails on a store with no transaction lands nothing either", () => {
186
- const results = sectionResults(
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(
187
192
  reportOf({
188
193
  applied: false,
189
194
  items: [
190
- item("accounts", "a@example.test", "rejected", "the store refused"),
195
+ item("accounts", "a@example.test", "created"),
196
+ item("labels", "Receipts", "created"),
191
197
  ],
192
198
  errors: [
193
199
  {
194
200
  code: "import_write_failed",
195
- message:
196
- "the store refused The items above it were written and remain; the import stopped here.",
197
- details: { section: "accounts", key: "a@example.test" },
201
+ message: "the store refused the write",
202
+ details: { section: "filters", key: "Receipts" },
198
203
  },
199
204
  ],
200
205
  }),
201
206
  );
202
207
 
203
- const byId = new Map(results.map((result) => [result.section, result]));
204
- assert.equal(byId.get("accounts")?.state, "failed");
205
- assert.equal(byId.get("labels")?.state, "not-attempted");
206
- assert.equal(results.filter((result) => result.state === "landed").length, 0);
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
+ );
207
218
  });
208
219
 
209
- test("nothing written and no section named is a known outcome, not an unknown one", () => {
210
- const results = sectionResults(
220
+ test("a rolled-back import that names no section still says what happened", () => {
221
+ const rolled = sectionResults(
211
222
  reportOf({
212
223
  applied: false,
213
224
  errors: [{ code: "import_write_failed", message: "the store refused" }],
214
225
  }),
215
226
  );
216
227
 
217
- assert.deepEqual(
218
- [...new Set(results.map((result) => result.state))],
219
- ["not-landed"],
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,
220
235
  );
236
+ assert.match(rolled[0].detail, /Nothing was written/);
221
237
  });
222
238
 
223
239
  test("a pending folder names the setting waiting on it, and repeats are folded", () => {
@@ -287,9 +287,9 @@ export const writeFailure = (
287
287
 
288
288
  export type SectionOutcome =
289
289
  | "landed"
290
- | "not-landed"
291
290
  | "failed"
292
291
  | "not-attempted"
292
+ | "rolled-back"
293
293
  | "unknown";
294
294
 
295
295
  export interface SectionResult {
@@ -299,24 +299,23 @@ export interface SectionResult {
299
299
  detail: string;
300
300
  }
301
301
 
302
- const NOT_LANDED_DETAIL = "Nothing from this section was written.";
303
-
304
302
  /**
305
- * What a stopped import wrote, section by section. The write order is fixed, so
306
- * the section the failure names splits the document in three: what is before it
307
- * ran, it did not, and what is after it was never reached.
308
- *
309
- * `applied` is the server's answer on whether any of it was written, because
310
- * only the backend knows whether it had a transaction: false means no section
311
- * of the file was written, however far the write order got. It does not say
312
- * why — a transaction undid the earlier writes, or the first one failed — and
313
- * the report carries no flag that does, so this says what the file put in and
314
- * never what the instance now holds.
303
+ * What a stopped import left behind, section by section. The write order is
304
+ * fixed, so the section the failure names splits the document in three: what is
305
+ * before it ran, it did not, and what is after it was never reached. The
306
+ * server's own sentence says whether the earlier writes survived, because only
307
+ * the backend knows whether it had a transaction.
315
308
  *
316
- * A failure that names no section leaves the split undecidable, and every
309
+ * A failure that names no section leaves that split undecidable, and every
317
310
  * section reads `unknown` rather than `landed`. Telling someone their accounts
318
311
  * are in when nothing said so is the one answer this screen must never give:
319
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.
320
319
  */
321
320
  export const sectionResults = (
322
321
  report: RemitImapConfigImportReport,
@@ -324,7 +323,6 @@ export const sectionResults = (
324
323
  const failure = writeFailure(report);
325
324
  const named = failure?.details?.section;
326
325
  const failedSection = named ? asSection(named) : undefined;
327
- const nothingLanded = !report.applied;
328
326
  const counts = new Map<ImportSection, number>();
329
327
  for (const item of report.items) {
330
328
  if (asVerdict(item.verdict) === "rejected") continue;
@@ -333,20 +331,31 @@ export const sectionResults = (
333
331
  counts.set(section, (counts.get(section) ?? 0) + 1);
334
332
  }
335
333
 
336
- const everySection = (state: SectionOutcome, detail: string) =>
337
- IMPORT_SECTION_ORDER.map((section) => ({
334
+ if (failure && report.applied === false) {
335
+ return IMPORT_SECTION_ORDER.map((section) => ({
338
336
  section,
339
337
  title: SECTION_TITLES[section],
340
- state,
341
- detail,
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.",
342
346
  }));
347
+ }
343
348
 
344
349
  if (!failedSection) {
345
- if (nothingLanded) return everySection("not-landed", NOT_LANDED_DETAIL);
346
350
  const detail = named
347
351
  ? `The import stopped in "${named}", which this instance does not recognise, so it cannot say whether this section was written.`
348
352
  : "The import stopped without naming where, so it cannot say whether this section was written. Open Settings to see what is here.";
349
- return everySection("unknown", detail);
353
+ return IMPORT_SECTION_ORDER.map((section) => ({
354
+ section,
355
+ title: SECTION_TITLES[section],
356
+ state: "unknown" as const,
357
+ detail,
358
+ }));
350
359
  }
351
360
 
352
361
  const failedAt = IMPORT_SECTION_ORDER.indexOf(failedSection);
@@ -369,14 +378,6 @@ export const sectionResults = (
369
378
  detail: "Not attempted — the import stopped before this section.",
370
379
  };
371
380
  }
372
- if (nothingLanded) {
373
- return {
374
- section,
375
- title,
376
- state: "not-landed" as const,
377
- detail: NOT_LANDED_DETAIL,
378
- };
379
- }
380
381
  const written = counts.get(section) ?? 0;
381
382
  return {
382
383
  section,