@remit/web-client 0.0.221 → 0.0.222
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.
|
|
3
|
+
"version": "0.0.222",
|
|
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": {
|
|
@@ -431,6 +431,21 @@ export const ComposeForm = ({
|
|
|
431
431
|
* commit out — the fields still hold the message that has just been left.
|
|
432
432
|
*/
|
|
433
433
|
const [openDocumentId, setOpenDocumentId] = useState(outboxMessageId);
|
|
434
|
+
/**
|
|
435
|
+
* What the server's copy of this document would autosave as, so a draft that
|
|
436
|
+
* is only being looked at writes nothing.
|
|
437
|
+
*
|
|
438
|
+
* Opening one runs the autosave effect on the render that fills the fields
|
|
439
|
+
* from the read, and that write is no longer free: an edit returns a settled
|
|
440
|
+
* failure to `draft` (#933), so opening a Failed message and pressing Escape
|
|
441
|
+
* would take it out of the Outbox with the reader having changed nothing. The
|
|
442
|
+
* first run after a load records what was loaded; only a payload that differs
|
|
443
|
+
* from it is an edit. Nothing updates it afterwards — once the reader has
|
|
444
|
+
* touched the document, every later run saves as it always did, retries of a
|
|
445
|
+
* failed write included.
|
|
446
|
+
*/
|
|
447
|
+
const loadedPayloadRef = useRef<string | undefined>(undefined);
|
|
448
|
+
const captureLoadedPayloadRef = useRef(false);
|
|
434
449
|
const smtpConfigureRef = useRef<HTMLButtonElement>(null);
|
|
435
450
|
const prevOutboxMessageIdRef = useRef<string | undefined>(outboxMessageId);
|
|
436
451
|
/**
|
|
@@ -528,6 +543,8 @@ export const ComposeForm = ({
|
|
|
528
543
|
setBody({ ...opening, formatting: [] });
|
|
529
544
|
setDocumentGeneration((generation) => generation + 1);
|
|
530
545
|
setDraftLoaded(false);
|
|
546
|
+
loadedPayloadRef.current = undefined;
|
|
547
|
+
captureLoadedPayloadRef.current = false;
|
|
531
548
|
setOpenDocumentId(outboxMessageId);
|
|
532
549
|
}, [outboxMessageId, clearPendingFields]);
|
|
533
550
|
|
|
@@ -619,6 +636,7 @@ export const ComposeForm = ({
|
|
|
619
636
|
setDocumentGeneration((generation) => generation + 1);
|
|
620
637
|
setSelectedAccountId(draftData.accountId);
|
|
621
638
|
setDraftLoaded(true);
|
|
639
|
+
captureLoadedPayloadRef.current = true;
|
|
622
640
|
}, [draftData, draftLoaded]);
|
|
623
641
|
|
|
624
642
|
useEffect(() => {
|
|
@@ -834,7 +852,7 @@ export const ComposeForm = ({
|
|
|
834
852
|
composeLanguage,
|
|
835
853
|
);
|
|
836
854
|
|
|
837
|
-
|
|
855
|
+
const payload = {
|
|
838
856
|
accountId: selectedAccountId,
|
|
839
857
|
toAddresses: toAddresses.map((a) => a.email),
|
|
840
858
|
ccAddresses:
|
|
@@ -844,7 +862,19 @@ export const ComposeForm = ({
|
|
|
844
862
|
subject: subject || undefined,
|
|
845
863
|
textBody,
|
|
846
864
|
htmlBody,
|
|
847
|
-
}
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
// Nor a document nobody has touched. Reopening one is not editing it, and
|
|
868
|
+
// a PATCH is now a status change as well as a content one (#933).
|
|
869
|
+
const fingerprint = JSON.stringify(payload);
|
|
870
|
+
if (captureLoadedPayloadRef.current) {
|
|
871
|
+
captureLoadedPayloadRef.current = false;
|
|
872
|
+
loadedPayloadRef.current = fingerprint;
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
if (loadedPayloadRef.current === fingerprint) return;
|
|
876
|
+
|
|
877
|
+
saveDraft(payload);
|
|
848
878
|
}, [
|
|
849
879
|
selectedAccountId,
|
|
850
880
|
outboxMessageId,
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #933: opening a message is not editing it.
|
|
3
|
+
*
|
|
4
|
+
* Editing a settled failure returns the row to `draft`, which is what gets a
|
|
5
|
+
* message refused for a bad address back on its way. The composer's autosave
|
|
6
|
+
* had no dirty check, though, and its effect runs on the render that fills the
|
|
7
|
+
* fields from the server's copy — so opening a Failed message and pressing
|
|
8
|
+
* Escape wrote a PATCH, and the row left the Outbox with the reader having
|
|
9
|
+
* changed nothing. A status change nobody asked for, on a message they were
|
|
10
|
+
* only reading.
|
|
11
|
+
*
|
|
12
|
+
* The rule pinned here: an untouched document writes nothing, and the first
|
|
13
|
+
* real edit writes as it always did.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { afterEach, describe, it } from "node:test";
|
|
18
|
+
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
19
|
+
import {
|
|
20
|
+
type AnyRouter,
|
|
21
|
+
createMemoryHistory,
|
|
22
|
+
createRootRoute,
|
|
23
|
+
createRoute,
|
|
24
|
+
createRouter,
|
|
25
|
+
RouterContextProvider,
|
|
26
|
+
} from "@tanstack/react-router";
|
|
27
|
+
import { createElement, useState } from "react";
|
|
28
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
29
|
+
import { type HttpMock, mockFetch } from "../../test-support/http";
|
|
30
|
+
import { ComposeForm } from "./ComposeForm";
|
|
31
|
+
import { ComposeProvider } from "./ComposeProvider";
|
|
32
|
+
|
|
33
|
+
const ACCOUNT_ID = "acc-1";
|
|
34
|
+
const OUTBOX_MESSAGE_ID = "ob-933";
|
|
35
|
+
const AUTOSAVE_DEBOUNCE_MS = 2000;
|
|
36
|
+
|
|
37
|
+
const account = {
|
|
38
|
+
accountId: ACCOUNT_ID,
|
|
39
|
+
email: "me@example.com",
|
|
40
|
+
smtpEnabled: true,
|
|
41
|
+
} as unknown as RemitImapAccountResponse;
|
|
42
|
+
|
|
43
|
+
/** The row as the Outbox holds it: refused, and still carrying what was typed. */
|
|
44
|
+
const failedEntry = {
|
|
45
|
+
outboxMessageId: OUTBOX_MESSAGE_ID,
|
|
46
|
+
accountId: ACCOUNT_ID,
|
|
47
|
+
fromAddress: account.email,
|
|
48
|
+
toAddresses: ["typo@exmaple.com"],
|
|
49
|
+
ccAddresses: [],
|
|
50
|
+
bccAddresses: [],
|
|
51
|
+
references: [],
|
|
52
|
+
subject: "Invoice",
|
|
53
|
+
textBody: "Attached.",
|
|
54
|
+
status: "failed",
|
|
55
|
+
lastError: "550 5.1.0 mailbox unavailable",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
let harness: DomHarness | undefined;
|
|
59
|
+
let http: HttpMock | undefined;
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
harness?.close();
|
|
63
|
+
harness = undefined;
|
|
64
|
+
http?.restore();
|
|
65
|
+
http = undefined;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
69
|
+
|
|
70
|
+
const rootRoute = createRootRoute();
|
|
71
|
+
const mailboxRoute = createRoute({
|
|
72
|
+
getParentRoute: () => rootRoute,
|
|
73
|
+
path: "/mail/$mailboxId",
|
|
74
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const testRouter = (): AnyRouter =>
|
|
78
|
+
createRouter({
|
|
79
|
+
routeTree: rootRoute.addChildren([mailboxRoute]),
|
|
80
|
+
history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
|
|
81
|
+
}) as unknown as AnyRouter;
|
|
82
|
+
|
|
83
|
+
const Opened = () => {
|
|
84
|
+
const [draftId, setDraftId] = useState<string | undefined>(OUTBOX_MESSAGE_ID);
|
|
85
|
+
|
|
86
|
+
return createElement(ComposeForm, {
|
|
87
|
+
mode: "new",
|
|
88
|
+
account,
|
|
89
|
+
outboxMessageId: draftId,
|
|
90
|
+
onDraftCreated: setDraftId,
|
|
91
|
+
onClose: () => undefined,
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const writes = () =>
|
|
96
|
+
(http?.calls ?? []).filter(
|
|
97
|
+
(call) => call.method === "PATCH" || call.method === "POST",
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const mount = async (): Promise<void> => {
|
|
101
|
+
http = mockFetch(async (call) => {
|
|
102
|
+
if (call.path.endsWith("/config")) return { accounts: [account] };
|
|
103
|
+
return failedEntry;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
harness = createDomHarness();
|
|
107
|
+
harness.renderApp(
|
|
108
|
+
createElement(RouterContextProvider, {
|
|
109
|
+
router: testRouter(),
|
|
110
|
+
// biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
|
|
111
|
+
children: createElement(ComposeProvider, null, createElement(Opened)),
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
await harness.flush();
|
|
115
|
+
await harness.wait(50);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const subjectField = (): HTMLElement => {
|
|
119
|
+
const field = harness?.query("[data-subject-field]");
|
|
120
|
+
if (!field) throw new Error("the compose subject field is not mounted");
|
|
121
|
+
return field;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
describe("a message reopened in the composer", () => {
|
|
125
|
+
it("writes nothing while nobody has touched it", async () => {
|
|
126
|
+
await mount();
|
|
127
|
+
|
|
128
|
+
await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 200);
|
|
129
|
+
|
|
130
|
+
assert.deepEqual(
|
|
131
|
+
writes().map((call) => call.method),
|
|
132
|
+
[],
|
|
133
|
+
"reopening a message must not change it",
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("writes the first real edit", async () => {
|
|
138
|
+
await mount();
|
|
139
|
+
|
|
140
|
+
harness?.type(subjectField(), "Invoice, corrected");
|
|
141
|
+
await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 200);
|
|
142
|
+
|
|
143
|
+
const written = writes().at(-1);
|
|
144
|
+
assert.equal(written?.method, "PATCH");
|
|
145
|
+
assert.equal(written?.body?.subject, "Invoice, corrected");
|
|
146
|
+
});
|
|
147
|
+
});
|