@remit/web-client 0.0.245 → 0.0.246

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.246",
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,7 @@ 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
569
  onBack={goToFile}
569
570
  onNext={() => runImport("apply", "merge")}
570
571
  />
@@ -181,6 +181,32 @@ export const partialImportReport: RemitImapConfigImportReport = report({
181
181
  warnings: folderWarnings,
182
182
  });
183
183
 
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.
188
+ */
189
+ export const nothingLandedImportReport: 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",
@@ -19,6 +19,7 @@ import {
19
19
  cleanRunReport,
20
20
  dryRunReport,
21
21
  importedAccounts,
22
+ nothingLandedImportReport,
22
23
  partialImportReport,
23
24
  rejectedReports,
24
25
  } from "./config-import.fixtures";
@@ -416,6 +417,7 @@ export const PartiallyLandedImportUnnamed: Story = {
416
417
  results={sectionResults(report)}
417
418
  message={writeFailure(report)?.message ?? ""}
418
419
  raw="import_write_failed: the store refused the write"
420
+ nothingLanded={!report.applied}
419
421
  />
420
422
  );
421
423
  },
@@ -428,6 +430,23 @@ export const PartiallyLandedImport: Story = {
428
430
  results={sectionResults(partialImportReport)}
429
431
  message={writeFailure(partialImportReport)?.message ?? ""}
430
432
  raw={`import_write_failed: ${writeFailure(partialImportReport)?.message ?? ""}`}
433
+ nothingLanded={!partialImportReport.applied}
434
+ />
435
+ ),
436
+ };
437
+
438
+ /**
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.
442
+ */
443
+ export const NothingLandedImport: Story = {
444
+ render: () => (
445
+ <StepPartialImport
446
+ results={sectionResults(nothingLandedImportReport)}
447
+ message={writeFailure(nothingLandedImportReport)?.message ?? ""}
448
+ raw={`import_write_failed: ${writeFailure(nothingLandedImportReport)?.message ?? ""}`}
449
+ nothingLanded={!nothingLandedImportReport.applied}
431
450
  />
432
451
  ),
433
452
  };
@@ -704,9 +704,11 @@ export function StepFileRejected({
704
704
  }
705
705
 
706
706
  /**
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.
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.
710
712
  */
711
713
  function resultState(state: SectionResult["state"]) {
712
714
  if (state === "landed") return "ok" as const;
@@ -718,6 +720,7 @@ export function StepPartialImport({
718
720
  results,
719
721
  message,
720
722
  raw,
723
+ nothingLanded,
721
724
  onBack,
722
725
  onNext,
723
726
  }: {
@@ -725,6 +728,8 @@ export function StepPartialImport({
725
728
  /** The server's own account of what survived, which only it knows. */
726
729
  message: string;
727
730
  raw: string;
731
+ /** No section of the file was written — not that the instance is unchanged. */
732
+ nothingLanded: boolean;
728
733
  } & StepNav) {
729
734
  const landed = results.filter((result) => result.state === "landed").length;
730
735
  const unknown = results.some((result) => result.state === "unknown");
@@ -732,11 +737,17 @@ export function StepPartialImport({
732
737
  <WizardShell
733
738
  steps={IMPORT_STEPS}
734
739
  activeStep={1}
735
- title="The import stopped part-way"
740
+ title={
741
+ nothingLanded
742
+ ? "The import stopped, and nothing landed"
743
+ : "The import stopped part-way"
744
+ }
736
745
  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.`
746
+ nothingLanded
747
+ ? "No section of the file was written."
748
+ : unknown
749
+ ? "It did not say how far it got, so check Settings before importing again."
750
+ : `${landed} of ${results.length} sections landed.`
740
751
  }
741
752
  footer={
742
753
  <>
@@ -744,7 +755,7 @@ export function StepPartialImport({
744
755
  Choose another file
745
756
  </Button>
746
757
  <Button variant="primary" onClick={onNext}>
747
- Retry the rest
758
+ {nothingLanded ? "Try the import again" : "Retry the rest"}
748
759
  </Button>
749
760
  </>
750
761
  }
@@ -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,75 @@ 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(
156
+ reportOf({
157
+ applied: false,
158
+ items: [
159
+ item("filters", "Receipts", "rejected", "the store refused the write"),
160
+ ],
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
+ 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/,
177
+ );
178
+ assert.equal(byId.get("labels")?.state, "not-landed");
179
+ 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);
183
+ });
184
+
185
+ test("a first write that fails on a store with no transaction lands nothing either", () => {
186
+ const results = sectionResults(
187
+ reportOf({
188
+ applied: false,
189
+ items: [
190
+ item("accounts", "a@example.test", "rejected", "the store refused"),
191
+ ],
192
+ errors: [
193
+ {
194
+ 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" },
198
+ },
199
+ ],
200
+ }),
201
+ );
202
+
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);
207
+ });
208
+
209
+ test("nothing written and no section named is a known outcome, not an unknown one", () => {
210
+ const results = sectionResults(
211
+ reportOf({
212
+ applied: false,
213
+ errors: [{ code: "import_write_failed", message: "the store refused" }],
214
+ }),
215
+ );
216
+
217
+ assert.deepEqual(
218
+ [...new Set(results.map((result) => result.state))],
219
+ ["not-landed"],
220
+ );
221
+ });
222
+
154
223
  test("a pending folder names the setting waiting on it, and repeats are folded", () => {
155
224
  const folders = pendingFolders(
156
225
  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
+ | "not-landed"
291
+ | "failed"
292
+ | "not-attempted"
293
+ | "unknown";
289
294
 
290
295
  export interface SectionResult {
291
296
  section: ImportSection;
@@ -294,14 +299,21 @@ export interface SectionResult {
294
299
  detail: string;
295
300
  }
296
301
 
302
+ const NOT_LANDED_DETAIL = "Nothing from this section was written.";
303
+
297
304
  /**
298
- * What a stopped import left behind, section by section. The write order is
299
- * fixed, so the section the failure names splits the document in three: what is
300
- * before it ran, it did not, and what is after it was never reached. The
301
- * server's own sentence says whether the earlier writes survived, because only
302
- * the backend knows whether it had a transaction.
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
315
  *
304
- * A failure that names no section leaves that split undecidable, and every
316
+ * A failure that names no section leaves the split undecidable, and every
305
317
  * section reads `unknown` rather than `landed`. Telling someone their accounts
306
318
  * are in when nothing said so is the one answer this screen must never give:
307
319
  * they would stop looking.
@@ -312,6 +324,7 @@ export const sectionResults = (
312
324
  const failure = writeFailure(report);
313
325
  const named = failure?.details?.section;
314
326
  const failedSection = named ? asSection(named) : undefined;
327
+ const nothingLanded = !report.applied;
315
328
  const counts = new Map<ImportSection, number>();
316
329
  for (const item of report.items) {
317
330
  if (asVerdict(item.verdict) === "rejected") continue;
@@ -320,16 +333,20 @@ export const sectionResults = (
320
333
  counts.set(section, (counts.get(section) ?? 0) + 1);
321
334
  }
322
335
 
323
- if (!failedSection) {
324
- const detail = named
325
- ? `The import stopped in "${named}", which this instance does not recognise, so it cannot say whether this section was written.`
326
- : "The import stopped without naming where, so it cannot say whether this section was written. Open Settings to see what is here.";
327
- return IMPORT_SECTION_ORDER.map((section) => ({
336
+ const everySection = (state: SectionOutcome, detail: string) =>
337
+ IMPORT_SECTION_ORDER.map((section) => ({
328
338
  section,
329
339
  title: SECTION_TITLES[section],
330
- state: "unknown" as const,
340
+ state,
331
341
  detail,
332
342
  }));
343
+
344
+ if (!failedSection) {
345
+ if (nothingLanded) return everySection("not-landed", NOT_LANDED_DETAIL);
346
+ const detail = named
347
+ ? `The import stopped in "${named}", which this instance does not recognise, so it cannot say whether this section was written.`
348
+ : "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);
333
350
  }
334
351
 
335
352
  const failedAt = IMPORT_SECTION_ORDER.indexOf(failedSection);
@@ -352,6 +369,14 @@ export const sectionResults = (
352
369
  detail: "Not attempted — the import stopped before this section.",
353
370
  };
354
371
  }
372
+ if (nothingLanded) {
373
+ return {
374
+ section,
375
+ title,
376
+ state: "not-landed" as const,
377
+ detail: NOT_LANDED_DETAIL,
378
+ };
379
+ }
355
380
  const written = counts.get(section) ?? 0;
356
381
  return {
357
382
  section,