Skip to content

Commit

Permalink
Render 422 Response HTML
Browse files Browse the repository at this point in the history
Related to hotwired/turbo-rails#12
Related to hotwired/turbo-rails#34

Typically, Turbo expects each FormSubmission request to result in a
redirect to a new Location.

When a FormSubmission request fails with an [unprocessable entity][422]
response, render the response HTML. This commit brings the same behavior
to `<turbo-frame>` elements.

[422]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/422
  • Loading branch information
seanpdoyle committed Dec 26, 2020
1 parent 35ce4aa commit eb7ff38
Show file tree
Hide file tree
Showing 8 changed files with 62 additions and 4 deletions.
4 changes: 3 additions & 1 deletion src/core/drive/form_submission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ export class FormSubmission {
}

requestSucceededWithResponse(request: FetchRequest, response: FetchResponse) {
if (this.requestMustRedirect(request) && !response.redirected) {
if (response.unprocessableEntity) {
this.delegate.formSubmissionFailedWithResponse(this, response)
} else if (this.requestMustRedirect(request) && !response.redirected) {
const error = new Error("Form responses must redirect to another location")
this.delegate.formSubmissionErrored(this, error)
} else {
Expand Down
8 changes: 6 additions & 2 deletions src/core/drive/navigator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,12 @@ export class Navigator {
}
}

formSubmissionFailedWithResponse(formSubmission: FormSubmission, fetchResponse: FetchResponse) {
console.error("Form submission failed", formSubmission, fetchResponse)
async formSubmissionFailedWithResponse(formSubmission: FormSubmission, fetchResponse: FetchResponse) {
const responseHTML = await fetchResponse.responseHTML

this.view.clearSnapshotCache()

this.proposeVisit(this.location, { response: { statusCode: fetchResponse.statusCode, responseHTML } })
}

formSubmissionErrored(formSubmission: FormSubmission, error: Error) {
Expand Down
2 changes: 1 addition & 1 deletion src/core/frames/frame_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export class FrameController implements FetchRequestDelegate, FormInterceptorDel
}

formSubmissionFailedWithResponse(formSubmission: FormSubmission, fetchResponse: FetchResponse) {

this.element.controller.loadResponse(fetchResponse)
}

formSubmissionErrored(formSubmission: FormSubmission, error: Error) {
Expand Down
4 changes: 4 additions & 0 deletions src/http/fetch_response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export class FetchResponse {
return !this.succeeded
}

get unprocessableEntity() {
return this.statusCode == 422
}

get redirected() {
return this.response.redirected
}
Expand Down
12 changes: 12 additions & 0 deletions src/tests/fixtures/422.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<html>
<head>
<title>Unprocessable Entity</title>
</head>
<body>
<h1>Unprocessable Entity</h1>

<turbo-frame id="frame">
<h2>Frame: Unprocessable Entity</h2>
</turbo-frame>
</body>
</html>
10 changes: 10 additions & 0 deletions src/tests/fixtures/form.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@
<input type="submit">
</form>
</div>
<div id="reject">
<form action="/__turbo/reject" method="post">
<input type="hidden" name="status" value="422">
<input type="submit">
</form>
</div>
<div id="submitter">
<form action="/src/tests/fixtures/one.html" method="get">
<button type="submit" formmethod="post" formaction="/__turbo/redirect"
Expand All @@ -24,6 +30,10 @@
<input type="hidden" name="path" value="/src/tests/fixtures/frames/form.html">
<input type="submit">
</form>
<form class="reject" action="/__turbo/reject" method="post">
<input type="hidden" name="status" value="422">
<input type="submit">
</form>
<form action="/__turbo/messages" method="post" class="stream">
<input type="hidden" name="type" value="stream">
<input type="hidden" name="content" value="Hello!">
Expand Down
18 changes: 18 additions & 0 deletions src/tests/functional/form_submission_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ export class FormSubmissionTests extends TurboDriveTestCase {
this.assert.equal(await this.visitAction, "advance")
}

async "test invalid form submission with unprocessable entity status"() {
await this.clickSelector("#reject form input[type=submit]")
await this.nextBody

const title = await this.querySelector("h1")
this.assert.equal(await title.getVisibleText(), "Unprocessable Entity", "renders the response HTML")
this.assert.notOk(await this.hasSelector("#frame form.reject"), "replaces entire page")
}

async "test submitter form submission reads button attributes"() {
const button = await this.querySelector("#submitter form button[type=submit]")
await button.click()
Expand All @@ -34,6 +43,15 @@ export class FormSubmissionTests extends TurboDriveTestCase {
this.assert.equal(await this.pathname, "/src/tests/fixtures/form.html")
}

async "test invalid frame form submission with unprocessable entity status"() {
await this.clickSelector("#frame form.reject input[type=submit]")
await this.nextBeat

const title = await this.querySelector("#frame h2")
this.assert.ok(await this.hasSelector("#reject form"), "only replaces frame")
this.assert.equal(await title.getVisibleText(), "Frame: Unprocessable Entity")
}

async "test frame form submission with stream response"() {
const button = await this.querySelector("#frame form.stream input[type=submit]")
await button.click()
Expand Down
8 changes: 8 additions & 0 deletions src/tests/server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Response, Router } from "express"
import multer from "multer"
import path from "path"

const router = Router()
const streamResponses: Set<Response> = new Set
Expand All @@ -11,6 +12,13 @@ router.post("/redirect", (request, response) => {
response.redirect(303, path)
})

router.post("/reject", (request, response) => {
const { status } = request.body
const fixture = path.join(__dirname, `../../src/tests/fixtures/${status}.html`)

response.status(parseInt(status || "422", 10)).sendFile(fixture)
})

router.post("/messages", (request, response) => {
const { content, type } = request.body
if (typeof content == "string") {
Expand Down

0 comments on commit eb7ff38

Please sign in to comment.