mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-23 03:27:27 +09:00
fix: Reset Form fields after successful submission (#6583)
After a successful Form submission, a visible Form returns to its authored field defaults. The newly inserted Form starts blank, so visitors can send another message without clearing the previous one. ## Checklist - [x] Call the browser's Form reset after a successful Resource submission. - [x] Preserve entered values after failed submissions and keep Navigation mode behavior unchanged. - [x] Cover text, selection, and file inputs, including authored defaults, in focused tests. - [x] Handle authored fields named `reset` without skipping the reset or resource refresh. - [x] Verify Builder Preview and published-site behavior use the same client path. - [x] Update the Form guide for the changed behavior. - [x] Explain the prototype reset call in a short code comment. - [x] Run focused browser tests, typecheck, formatting, and lint on the follow-up. - [x] Verify required CI on the follow-up commit. The reset test fails when the reset call is removed. The `reset` field test failed before the review fix. Both pass now.
This commit is contained in:
1 parent
26ba4728fa
commit
6d73186a8b
4 files changed
+99
-4
No files matched your search
@@ -51,7 +51,7 @@ When files are attached to an email, the server checks for at most **32 attachme
|
||||
|
||||
Cloud email can also fail because the provider rejects a recipient or Reply to address, suppresses a recipient, reaches an account sending limit, or is temporarily unavailable. The Form reports the resulting resource error and does not claim successful owner delivery. A timeout can leave delivery uncertain; the Form does not retry an email because another send could create a duplicate.
|
||||
|
||||
The inserted Form has **Form content**, **Success message**, and **Error message** sections. When every primary destination succeeds, the success state appears; otherwise the error state appears. A failed visitor-addressed email resource is reported in `errors` but does not change the overall success state. The Form exposes `formState` (the current Form state: initial, success, or error), `results` (responses from selected actions, in order, including each resource's HTTP status code and response body), and `errors` (failed actions in order, including each resource's HTTP status code, response body, message, and an error code for managed email when available). A configured **Success redirect** runs only after overall success. Without a redirect, successful submissions refresh the current page's resources without a full-page reload or a second submission.
|
||||
The inserted Form has **Form content**, **Success message**, and **Error message** sections. When every primary destination succeeds, the success state appears; otherwise the error state appears. A failed visitor-addressed email resource is reported in `errors` but does not change the overall success state. The Form exposes `formState` (the current Form state: initial, success, or error), `results` (responses from selected actions, in order, including each resource's HTTP status code and response body), and `errors` (failed actions in order, including each resource's HTTP status code, response body, message, and an error code for managed email when available). A configured **Success redirect** runs only after overall success. Without a redirect, successful submissions reset the Form to its authored field defaults and refresh the current page's resources without a full-page reload or a second submission. The newly inserted Form has empty defaults. Failed submissions leave the entered values in place.
|
||||
|
||||
## Hosting and plain HTML forms
|
||||
|
||||
|
||||
@@ -53,5 +53,5 @@ export const useFormFeedbackScroll = (
|
||||
[]
|
||||
);
|
||||
|
||||
return { setFormRef, revealFeedback };
|
||||
return { formRef, setFormRef, revealFeedback };
|
||||
};
|
||||
@@ -4,6 +4,9 @@ import { createRoot } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { ResourceForm } from "./resource-form";
|
||||
import { Input } from "./input";
|
||||
import { Textarea } from "./textarea";
|
||||
import { Select } from "./select";
|
||||
import {
|
||||
managedFormRequestParamName,
|
||||
managedFormIdFieldName,
|
||||
@@ -49,6 +52,83 @@ test("a StrictMode Form submits after the effect cleanup probe", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test.each([true, false])(
|
||||
"a visible Form resets edited fields only after success: %s",
|
||||
async (success) => {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
const onManagedSubmit = vi.fn(async () => ({
|
||||
success,
|
||||
status: success ? 200 : 422,
|
||||
results: [],
|
||||
errors: success ? [] : [{ status: 422, body: null, message: "Failed" }],
|
||||
}));
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ResourceForm
|
||||
action={[{ dataSourceId: "resource-one" }]}
|
||||
onManagedSubmit={onManagedSubmit}
|
||||
>
|
||||
<Input name="name" defaultValue="Preset name" />
|
||||
<Input name="reset" defaultValue="Preset reset" />
|
||||
<Textarea name="message" defaultValue="Preset message" />
|
||||
<Select name="category" defaultValue="sales">
|
||||
<option value="sales">Sales</option>
|
||||
<option value="support">Support</option>
|
||||
</Select>
|
||||
<Input name="subscribe" type="checkbox" defaultChecked />
|
||||
<Input name="contact" type="radio" value="email" defaultChecked />
|
||||
<Input name="attachment" type="file" />
|
||||
<button type="submit">Send</button>
|
||||
</ResourceForm>
|
||||
);
|
||||
});
|
||||
const name = container.querySelector<HTMLInputElement>("[name=name]")!;
|
||||
const reset = container.querySelector<HTMLInputElement>("[name=reset]")!;
|
||||
const message =
|
||||
container.querySelector<HTMLTextAreaElement>("[name=message]")!;
|
||||
const category =
|
||||
container.querySelector<HTMLSelectElement>("[name=category]")!;
|
||||
const subscribe =
|
||||
container.querySelector<HTMLInputElement>("[name=subscribe]")!;
|
||||
const contact =
|
||||
container.querySelector<HTMLInputElement>("[name=contact]")!;
|
||||
const attachment =
|
||||
container.querySelector<HTMLInputElement>("[name=attachment]")!;
|
||||
name.value = "Entered name";
|
||||
reset.value = "Entered reset";
|
||||
message.value = "Entered message";
|
||||
category.value = "support";
|
||||
subscribe.checked = false;
|
||||
contact.checked = false;
|
||||
const files = new DataTransfer();
|
||||
files.items.add(new File(["image"], "image.png", { type: "image/png" }));
|
||||
attachment.files = files.files;
|
||||
await act(async () => container.querySelector("button")?.click());
|
||||
await vi.waitFor(() => expect(onManagedSubmit).toHaveBeenCalledOnce());
|
||||
await vi.waitFor(() =>
|
||||
expect(container.querySelector("form")?.dataset.state).toBe(
|
||||
success ? "success" : "error"
|
||||
)
|
||||
);
|
||||
expect(name.value).toBe(success ? "Preset name" : "Entered name");
|
||||
expect(reset.value).toBe(success ? "Preset reset" : "Entered reset");
|
||||
expect(message.value).toBe(
|
||||
success ? "Preset message" : "Entered message"
|
||||
);
|
||||
expect(category.value).toBe(success ? "sales" : "support");
|
||||
expect(subscribe.checked).toBe(success);
|
||||
expect(contact.checked).toBe(success);
|
||||
expect(attachment.files).toHaveLength(success ? 0 : 1);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test("a StrictMode Form aborts an active request on real unmount", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
@@ -144,6 +224,9 @@ test("empty Resource list uses query navigation instead of Resource submission",
|
||||
expect(onQueryNavigate).toHaveBeenCalledExactlyOnceWith(
|
||||
`${window.location.pathname}?query=chairs&tag=wood&tag=blue`
|
||||
);
|
||||
expect(
|
||||
container.querySelector<HTMLInputElement>("[name=query]")?.value
|
||||
).toBe("chairs");
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
@@ -581,6 +664,12 @@ test("managed Form submits by HTTP outside a router provider and reports pending
|
||||
)
|
||||
);
|
||||
await vi.waitFor(() => expect(states).toEqual(["initial", "success"]));
|
||||
expect(container.querySelector<HTMLInputElement>("[name=tag]")?.value).toBe(
|
||||
"a"
|
||||
);
|
||||
expect(
|
||||
container.querySelector<HTMLInputElement>('[name="upload"]')?.files
|
||||
).toHaveLength(0);
|
||||
expect(container.querySelector("form")?.hasAttribute("aria-busy")).toBe(
|
||||
false
|
||||
);
|
||||
|
||||
@@ -80,7 +80,7 @@ export const ResourceForm = forwardRef<
|
||||
submissionController.current ??= new FormSubmissionController(
|
||||
() => callbacks.current!
|
||||
);
|
||||
const { setFormRef, revealFeedback } = useFormFeedbackScroll(ref);
|
||||
const { formRef, setFormRef, revealFeedback } = useFormFeedbackScroll(ref);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
useEffect(() => setHydrated(true), []);
|
||||
useEffect(() => {
|
||||
@@ -120,7 +120,13 @@ export const ResourceForm = forwardRef<
|
||||
onResult: (result) => onResultChange?.(result),
|
||||
onState: reportState,
|
||||
onFeedback: revealFeedback,
|
||||
onRefresh: onSubmissionSuccess,
|
||||
onRefresh: () => {
|
||||
if (formRef.current) {
|
||||
// A field named or identified as "reset" can shadow form.reset.
|
||||
HTMLFormElement.prototype.reset.call(formRef.current);
|
||||
}
|
||||
return onSubmissionSuccess?.();
|
||||
},
|
||||
onRedirect: (destination) => {
|
||||
if (onSuccessRedirect) {
|
||||
return onSuccessRedirect(destination);
|
||||
|
||||
Reference in new issue
Block a user