Skip to content

Commit c4ba997

Browse files
committed
Fix capability form submission contracts
1 parent 9943117 commit c4ba997

2 files changed

Lines changed: 99 additions & 11 deletions

File tree

packages/framework/src/runtime-hooks.ts

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -242,25 +242,43 @@ export function Form<TName extends string = string>(props: FormProps<TName>) {
242242
return;
243243
}
244244

245+
const submitter =
246+
typeof SubmitEvent !== "undefined" && event instanceof SubmitEvent ? event.submitter : null;
247+
const nativeSubmitter =
248+
(submitter instanceof HTMLButtonElement || submitter instanceof HTMLInputElement) &&
249+
submitter.form === form
250+
? submitter
251+
: undefined;
252+
245253
if (capability) {
246254
event.preventDefault();
247-
clearPrefetchCache();
248255
const endpoint = actionAttribute ?? form.action;
249-
const formData = new FormData(form);
256+
const formData = new FormData(form, nativeSubmitter);
257+
258+
if (schema) {
259+
const result = await validateStandardSchema(schema, formDataToRecord(formData), "body");
260+
if (result.issues) {
261+
onValidationIssues?.(result.issues);
262+
return;
263+
}
264+
}
265+
266+
clearPrefetchCache();
250267
// Expose the in-flight submission through useNavigation().
251268
const navigationToken = beginSubmittingNavigation(
252269
createNavigationLocation(endpoint),
253270
formData,
254271
);
255272
let envelope: CapabilityEnvelope;
273+
let response: Response | undefined;
256274
try {
257-
const response = await fetch(endpoint, {
275+
response = await fetch(endpoint, {
258276
method: "POST",
259277
body: formData,
260278
credentials: "same-origin",
261279
});
262280
try {
263-
envelope = (await response.json()) as CapabilityEnvelope;
281+
envelope = (await response.clone().json()) as CapabilityEnvelope;
264282
} catch {
265283
envelope = {
266284
ok: false,
@@ -282,6 +300,9 @@ export function Form<TName extends string = string>(props: FormProps<TName>) {
282300
settleNavigation(navigationToken);
283301
}
284302

303+
if (response) {
304+
onResponse?.(response);
305+
}
285306
if (envelope.ok) {
286307
form.reset();
287308
}
@@ -297,13 +318,6 @@ export function Form<TName extends string = string>(props: FormProps<TName>) {
297318
return;
298319
}
299320

300-
const submitter =
301-
typeof SubmitEvent !== "undefined" && event instanceof SubmitEvent ? event.submitter : null;
302-
const nativeSubmitter =
303-
(submitter instanceof HTMLButtonElement || submitter instanceof HTMLInputElement) &&
304-
submitter.form === form
305-
? submitter
306-
: undefined;
307321
const submitterMethod = nativeSubmitter?.getAttribute("formmethod") || undefined;
308322
const formMethod = (submitterMethod ?? method ?? form.method ?? "post").toUpperCase();
309323
const isSafeMethod = SAFE_METHODS.has(formMethod);

packages/framework/test/form-validation.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,80 @@ describe("<Form> validation", () => {
176176
expect(body.get("action")).toBe("save");
177177
});
178178

179+
it("validates capability forms before submitting", async () => {
180+
const issues: ApiValidationIssue[][] = [];
181+
182+
render(
183+
h(
184+
Form,
185+
{
186+
capability: "items.create",
187+
schema: nameSchema,
188+
onValidationIssues: (found) => issues.push(found),
189+
},
190+
h("input", { name: "name", value: "" }),
191+
),
192+
root,
193+
);
194+
195+
await submit();
196+
197+
expect(fetchSpy).not.toHaveBeenCalled();
198+
expect(issues).toEqual([[{ in: "body", message: "Name is required", path: ["name"] }]]);
199+
});
200+
201+
it("includes the clicked button value in capability submissions", async () => {
202+
fetchSpy.mockResolvedValue(
203+
new Response(JSON.stringify({ ok: true, data: {} }), {
204+
headers: { "content-type": "application/json" },
205+
}),
206+
);
207+
208+
render(
209+
h(Form, { capability: "items.save" }, h("button", { name: "action", value: "save" }, "Save")),
210+
root,
211+
);
212+
213+
const form = root.querySelector("form")!;
214+
const button = root.querySelector("button")!;
215+
form.dispatchEvent(
216+
new SubmitEvent("submit", { bubbles: true, cancelable: true, submitter: button }),
217+
);
218+
await new Promise((resolve) => setTimeout(resolve, 0));
219+
220+
const body = fetchSpy.mock.calls[0][1].body as FormData;
221+
expect(body.get("action")).toBe("save");
222+
});
223+
224+
it("keeps capability response bodies readable in onResponse", async () => {
225+
const responseBody = { ok: true, data: { created: "pracht" } };
226+
fetchSpy.mockResolvedValue(
227+
new Response(JSON.stringify(responseBody), {
228+
status: 201,
229+
headers: { "content-type": "application/json" },
230+
}),
231+
);
232+
const responses: Response[] = [];
233+
234+
render(
235+
h(
236+
Form,
237+
{
238+
capability: "items.create",
239+
onResponse: (response) => responses.push(response),
240+
},
241+
h("input", { name: "name", value: "pracht" }),
242+
),
243+
root,
244+
);
245+
246+
await submit();
247+
248+
expect(responses).toHaveLength(1);
249+
expect(responses[0].status).toBe(201);
250+
await expect(responses[0].json()).resolves.toEqual(responseBody);
251+
});
252+
179253
it("uses the clicked button's formaction for enhanced submissions", async () => {
180254
fetchSpy.mockResolvedValue(new Response(null, { status: 200 }));
181255

0 commit comments

Comments
 (0)