@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 +1 -1
- package/src/components/config-import/ConfigImportWizard.tsx +3 -0
- package/src/components/config-import/config-import.fixtures.ts +26 -0
- package/src/components/config-import/config-import.stories.tsx +21 -0
- package/src/components/config-import/steps.tsx +13 -5
- package/src/components/mail/IntelligencePane.render.test.ts +4 -21
- package/src/components/mail/intelligence-semantic-off.render.test.ts +3 -20
- package/src/lib/config-import.test.ts +85 -0
- package/src/lib/config-import.ts +27 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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"
|
|
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
|
-
|
|
738
|
-
? "
|
|
739
|
-
:
|
|
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({
|
package/src/lib/config-import.ts
CHANGED
|
@@ -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 =
|
|
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.`
|