Skip to content

Commit

Permalink
GET form submissions to the current path
Browse files Browse the repository at this point in the history
Closes #107

The [HTML specification][] outlines the algorithm for handling form
submissions, and describes the steps involved in handling a `method=get`
request with `application/x-www-form-urlencoded` encoding.

However, it doesn't explicitly mention how to handle a `<form
method="get">` submission when the `action` attribute is omitted and the
current URL includes a query parameter that would be included within the
`<form>` element fields' values.

Determining the test cases to reproduce browser default behavior
involved was some trial and error troubleshooting in real browsers with
default (JavaScript-less) `<form>` submission handling.

The algorithm appears to be as follows:

* If there are no query parameters, append all field values as query
   parameters

* If there are existing query parameters whose keys exist as `<form>`
  fields, override them based on the form field's value

  * If there are existing query parameters whose keys exist as
    _multiple_ `<form>` fields, override the first occurrence, and then
    append subsequent values

[HTML specification]: https://html.spec.whatwg.org/#form-submission-algorithm
  • Loading branch information
seanpdoyle committed Jan 15, 2021
1 parent 178c817 commit ac4b0f9
Show file tree
Hide file tree
Showing 4 changed files with 86 additions and 10 deletions.
21 changes: 15 additions & 6 deletions src/http/fetch_request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,22 @@ export class FetchRequest {
}

get url() {
const url = this.location.absoluteURL
const query = this.params.toString()
if (this.isIdempotent && query.length) {
return [url, query].join(url.includes("?") ? "&" : "?")
} else {
return url
const url = new URL(this.location.absoluteURL)

if (this.isIdempotent) {
const currentUrl = new URL(window.location.href)

for (const [ key, value ] of this.params) {
if (currentUrl.searchParams.has(key)) {
currentUrl.searchParams.delete(key)
url.searchParams.set(key, value)
} else {
url.searchParams.append(key, value)
}
}
}

return url.href
}

get params() {
Expand Down
17 changes: 17 additions & 0 deletions src/tests/fixtures/form.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,24 @@
<input type="hidden" name="status" value="204">
<input type="submit" style="">
</form>
<form action="/src/tests/fixtures/form.html" method="get">
<input type="hidden" name="query" value="true">
<input type="submit">
</form>
</div>
<hr>
<div id="no-action">
<form class="single">
<input type="hidden" name="query" value="1">
<input type="submit">
</form>
<form class="multiple">
<input type="hidden" name="query" value="1">
<input type="hidden" name="query" value="2">
<input type="submit">
</form>
</div>
<hr>
<div id="reject">
<form class="unprocessable_entity" action="/__turbo/reject" method="post">
<input type="hidden" name="status" value="422">
Expand Down
54 changes: 50 additions & 4 deletions src/tests/functional/form_submission_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export class FormSubmissionTests extends TurboDriveTestCase {
await this.goToLocation("/src/tests/fixtures/form.html")
}

async "test standard form submission with redirect response"() {
async "test standard POST form submission with redirect response"() {
this.listenForFormSubmissions()
const button = await this.querySelector("#standard form.redirect input[type=submit]")
await button.click()
Expand All @@ -16,6 +16,17 @@ export class FormSubmissionTests extends TurboDriveTestCase {
this.assert.equal(await this.visitAction, "advance")
}

async "test standard GET form submission"() {
this.listenForFormSubmissions()
await this.clickSelector("#standard form[method=get] input[type=submit]")
await this.nextBody

this.assert.ok(this.turboFormSubmitted)
this.assert.equal(await this.pathname, "/src/tests/fixtures/form.html")
this.assert.equal(await this.search, "?query=true")
this.assert.equal(await this.visitAction, "advance")
}

async "test standard form submission with empty created response"() {
const htmlBefore = await this.outerHTMLForSelector("body")
const button = await this.querySelector("#standard form.created input[type=submit]")
Expand All @@ -36,6 +47,42 @@ export class FormSubmissionTests extends TurboDriveTestCase {
this.assert.equal(htmlAfter, htmlBefore)
}

async "test no-action form submission with single parameter"() {
await this.clickSelector("#no-action form.single input[type=submit]")
await this.nextBody

this.assert.equal(await this.pathname, "/src/tests/fixtures/form.html")
this.assert.equal(await this.search, "?query=1")

await this.clickSelector("#no-action form.single input[type=submit]")
await this.nextBody

this.assert.equal(await this.pathname, "/src/tests/fixtures/form.html")
this.assert.equal(await this.search, "?query=1")

await this.goToLocation("/src/tests/fixtures/form.html?query=2")
await this.clickSelector("#no-action form.single input[type=submit]")
await this.nextBody

this.assert.equal(await this.pathname, "/src/tests/fixtures/form.html")
this.assert.equal(await this.search, "?query=1")
}

async "test no-action form submission with multiple parameters"() {
await this.goToLocation("/src/tests/fixtures/form.html?query=2")
await this.clickSelector("#no-action form.multiple input[type=submit]")
await this.nextBody

this.assert.equal(await this.pathname, "/src/tests/fixtures/form.html")
this.assert.equal(await this.search, "?query=1&query=2")

await this.clickSelector("#no-action form.multiple input[type=submit]")
await this.nextBody

this.assert.equal(await this.pathname, "/src/tests/fixtures/form.html")
this.assert.equal(await this.search, "?query=1&query=2")
}

async "test invalid form submission with unprocessable entity status"() {
await this.clickSelector("#reject form.unprocessable_entity input[type=submit]")
await this.nextBody
Expand Down Expand Up @@ -168,10 +215,9 @@ export class FormSubmissionTests extends TurboDriveTestCase {
}

listenForFormSubmissions() {
this.remote.execute(() => addEventListener("turbo:submit-start", function eventListener(event) {
removeEventListener("turbo:submit-start", eventListener, false)
this.remote.execute(() => addEventListener("turbo:submit-start", (event) => {
document.head.insertAdjacentHTML("beforeend", `<meta name="turbo-form-submitted">`)
}, false))
}, { once: true }))
}

get turboFormSubmitted(): Promise<boolean> {
Expand Down
4 changes: 4 additions & 0 deletions src/tests/helpers/functional_test_case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ export class FunctionalTestCase extends InternTestCase {
return this.evaluate(() => location.pathname)
}

get search(): Promise<string> {
return this.evaluate(() => location.search)
}

get hash(): Promise<string> {
return this.evaluate(() => location.hash)
}
Expand Down

0 comments on commit ac4b0f9

Please sign in to comment.