@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 +1 -1
- package/src/components/config-import/ConfigImportWizard.tsx +1 -0
- package/src/components/config-import/config-import.fixtures.ts +26 -0
- package/src/components/config-import/config-import.stories.tsx +19 -0
- package/src/components/config-import/steps.tsx +19 -8
- 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 +69 -0
- package/src/lib/config-import.ts +38 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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
|
|
708
|
-
*
|
|
709
|
-
*
|
|
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=
|
|
740
|
+
title={
|
|
741
|
+
nothingLanded
|
|
742
|
+
? "The import stopped, and nothing landed"
|
|
743
|
+
: "The import stopped part-way"
|
|
744
|
+
}
|
|
736
745
|
subtitle={
|
|
737
|
-
|
|
738
|
-
? "
|
|
739
|
-
:
|
|
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({
|
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
|
+
| "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
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
* the
|
|
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
|
|
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
|
-
|
|
324
|
-
|
|
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
|
|
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,
|