Skip to content

Commit

Permalink
Merge pull request #691 from seanpdoyle/request-event-listeners
Browse files Browse the repository at this point in the history
Override `FetchOptions` from event listeners
  • Loading branch information
Alberto Fernández-Capel authored Sep 12, 2023
2 parents b87f824 + 3fb6722 commit d9821c4
Show file tree
Hide file tree
Showing 5 changed files with 201 additions and 66 deletions.
85 changes: 42 additions & 43 deletions src/core/drive/form_submission.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FetchRequest, FetchMethod, fetchMethodFromString } from "../../http/fetch_request"
import { FetchRequest, FetchMethod, fetchMethodFromString, fetchEnctypeFromString, isSafe } from "../../http/fetch_request"
import { expandURL } from "../url"
import { dispatch, getAttribute, getMetaContent, hasAttribute } from "../../util"
import { StreamMessage } from "../streams/stream_message"
Expand All @@ -18,17 +18,6 @@ export const FormEnctype = {
plain: "text/plain"
}

function formEnctypeFromString(encoding) {
switch (encoding.toLowerCase()) {
case FormEnctype.multipart:
return FormEnctype.multipart
case FormEnctype.plain:
return FormEnctype.plain
default:
return FormEnctype.urlEncoded
}
}

export class FormSubmission {
state = FormSubmissionState.initialized

Expand All @@ -37,53 +26,48 @@ export class FormSubmission {
}

constructor(delegate, formElement, submitter, mustRedirect = false) {
const method = getMethod(formElement, submitter)
const action = getAction(getFormAction(formElement, submitter), method)
const body = buildFormData(formElement, submitter)
const enctype = getEnctype(formElement, submitter)

this.delegate = delegate
this.formElement = formElement
this.submitter = submitter
this.formData = buildFormData(formElement, submitter)
this.location = expandURL(this.action)
if (this.method == FetchMethod.get) {
mergeFormDataEntries(this.location, [...this.body.entries()])
}
this.fetchRequest = new FetchRequest(this, this.method, this.location, this.body, this.formElement)
this.fetchRequest = new FetchRequest(this, method, action, body, formElement, enctype)
this.mustRedirect = mustRedirect
}

get method() {
const method = this.submitter?.getAttribute("formmethod") || this.formElement.getAttribute("method") || ""
return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get
return this.fetchRequest.method
}

set method(value) {
this.fetchRequest.method = value
}

get action() {
const formElementAction = typeof this.formElement.action === "string" ? this.formElement.action : null
return this.fetchRequest.url.toString()
}

if (this.submitter?.hasAttribute("formaction")) {
return this.submitter.getAttribute("formaction") || ""
} else {
return this.formElement.getAttribute("action") || formElementAction || ""
}
set action(value) {
this.fetchRequest.url = expandURL(value)
}

get body() {
if (this.enctype == FormEnctype.urlEncoded || this.method == FetchMethod.get) {
return new URLSearchParams(this.stringFormData)
} else {
return this.formData
}
return this.fetchRequest.body
}

get enctype() {
return formEnctypeFromString(this.submitter?.getAttribute("formenctype") || this.formElement.enctype)
return this.fetchRequest.enctype
}

get isSafe() {
return this.fetchRequest.isSafe
}

get stringFormData() {
return [...this.formData].reduce((entries, [name, value]) => {
return entries.concat(typeof value == "string" ? [[name, value]] : [])
}, [])
get location() {
return this.fetchRequest.url
}

// The submission process
Expand Down Expand Up @@ -244,16 +228,31 @@ function responseSucceededWithoutRedirect(response) {
return response.statusCode == 200 && !response.redirected
}

function mergeFormDataEntries(url, entries) {
const searchParams = new URLSearchParams()
function getFormAction(formElement, submitter) {
const formElementAction = typeof formElement.action === "string" ? formElement.action : null

for (const [name, value] of entries) {
if (value instanceof File) continue
if (submitter?.hasAttribute("formaction")) {
return submitter.getAttribute("formaction") || ""
} else {
return formElement.getAttribute("action") || formElementAction || ""
}
}

searchParams.append(name, value)
function getAction(formAction, fetchMethod) {
const action = expandURL(formAction)

if (isSafe(fetchMethod)) {
action.search = ""
}

url.search = searchParams.toString()
return action
}

function getMethod(formElement, submitter) {
const method = submitter?.getAttribute("formmethod") || formElement.getAttribute("method") || ""
return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get
}

return url
function getEnctype(formElement, submitter) {
return fetchEnctypeFromString(submitter?.getAttribute("formenctype") || formElement.enctype)
}
125 changes: 107 additions & 18 deletions src/http/fetch_request.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { FetchResponse } from "./fetch_response"
import { expandURL } from "../core/url"
import { dispatch } from "../util"

export function fetchMethodFromString(method) {
Expand All @@ -24,17 +25,80 @@ export const FetchMethod = {
delete: "delete"
}

export function fetchEnctypeFromString(encoding) {
switch (encoding.toLowerCase()) {
case FetchEnctype.multipart:
return FetchEnctype.multipart
case FetchEnctype.plain:
return FetchEnctype.plain
default:
return FetchEnctype.urlEncoded
}
}

export const FetchEnctype = {
urlEncoded: "application/x-www-form-urlencoded",
multipart: "multipart/form-data",
plain: "text/plain"
}

export class FetchRequest {
abortController = new AbortController()
#resolveRequestPromise = (_value) => {}

constructor(delegate, method, location, body = new URLSearchParams(), target = null) {
constructor(delegate, method, location, requestBody = new URLSearchParams(), target = null, enctype = FetchEnctype.urlEncoded) {
const [url, body] = buildResourceAndBody(expandURL(location), method, requestBody, enctype)

this.delegate = delegate
this.method = method
this.headers = this.defaultHeaders
this.body = body
this.url = location
this.url = url
this.target = target
this.fetchOptions = {
credentials: "same-origin",
redirect: "follow",
method: method,
headers: { ...this.defaultHeaders },
body: body,
signal: this.abortSignal,
referrer: this.delegate.referrer?.href
}
this.enctype = enctype
}

get method() {
return this.fetchOptions.method
}

set method(value) {
const fetchBody = this.isSafe ? this.url.searchParams : this.fetchOptions.body || new FormData()
const fetchMethod = fetchMethodFromString(value) || FetchMethod.get

this.url.search = ""

const [url, body] = buildResourceAndBody(this.url, fetchMethod, fetchBody, this.enctype)

this.url = url
this.fetchOptions.body = body
this.fetchOptions.method = fetchMethod
}

get headers() {
return this.fetchOptions.headers
}

set headers(value) {
this.fetchOptions.headers = value
}

get body() {
if (this.isSafe) {
return this.url.searchParams
} else {
return this.fetchOptions.body
}
}

set body(value) {
this.fetchOptions.body = value
}

get location() {
Expand Down Expand Up @@ -90,26 +154,14 @@ export class FetchRequest {
return fetchResponse
}

get fetchOptions() {
return {
method: FetchMethod[this.method].toUpperCase(),
credentials: "same-origin",
headers: this.headers,
redirect: "follow",
body: this.isSafe ? null : this.body,
signal: this.abortSignal,
referrer: this.delegate.referrer?.href
}
}

get defaultHeaders() {
return {
Accept: "text/html, application/xhtml+xml"
}
}

get isSafe() {
return this.method === FetchMethod.get
return isSafe(this.method)
}

get abortSignal() {
Expand All @@ -131,6 +183,7 @@ export class FetchRequest {
},
target: this.target
})
this.url = event.detail.url
if (event.defaultPrevented) await requestInterception
}

Expand All @@ -144,3 +197,39 @@ export class FetchRequest {
return !event.defaultPrevented
}
}

export function isSafe(fetchMethod) {
return fetchMethodFromString(fetchMethod) == FetchMethod.get
}

function buildResourceAndBody(resource, method, requestBody, enctype) {
const searchParams =
Array.from(requestBody).length > 0 ? new URLSearchParams(entriesExcludingFiles(requestBody)) : resource.searchParams

if (isSafe(method)) {
return [mergeIntoURLSearchParams(resource, searchParams), null]
} else if (enctype == FetchEnctype.urlEncoded) {
return [resource, searchParams]
} else {
return [resource, requestBody]
}
}

function entriesExcludingFiles(requestBody) {
const entries = []

for (const [name, value] of requestBody) {
if (value instanceof File) continue
else entries.push([name, value])
}

return entries
}

function mergeIntoURLSearchParams(url, requestBody) {
const searchParams = new URLSearchParams(entriesExcludingFiles(requestBody))

url.search = searchParams.toString()

return url
}
52 changes: 49 additions & 3 deletions src/tests/functional/form_submission_tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,52 @@ test("test standard POST form submission events", async ({ page }) => {
await nextEventNamed(page, "turbo:load")
})

test("test supports transforming a POST submission to a GET in a turbo:submit-start listener", async ({ page }) => {
await page.evaluate(() =>
addEventListener("turbo:submit-start", (({ detail }) => {
detail.formSubmission.method = "get"
detail.formSubmission.action = "/src/tests/fixtures/one.html"
detail.formSubmission.body.set("greeting", "Hello, from an event listener")
}))
)
await page.click("#standard form[method=post] [type=submit]")
await nextEventNamed(page, "turbo:load")

assert.equal(await page.textContent("h1"), "One", "overrides the method and action")
assert.equal(getSearchParam(page.url(), "greeting"), "Hello, from an event listener")
})

test("test supports transforming a GET submission to a POST in a turbo:submit-start listener", async ({ page }) => {
await page.evaluate(() =>
addEventListener("turbo:submit-start", (({ detail }) => {
detail.formSubmission.method = "post"
detail.formSubmission.body.set("path", "/src/tests/fixtures/one.html")
detail.formSubmission.body.set("greeting", "Hello, from an event listener")
}))
)
await page.click("#standard form[method=get] [type=submit]")
await nextEventNamed(page, "turbo:load")

assert.equal(await page.textContent("h1"), "One", "overrides the method and action")
assert.equal(getSearchParam(page.url(), "greeting"), "Hello, from an event listener")
})

test("test supports modifying the submission in a turbo:before-fetch-request listener", async ({ page }) => {
await page.evaluate(() =>
addEventListener("turbo:before-fetch-request", (({ detail }) => {
detail.url = new URL("/src/tests/fixtures/one.html", document.baseURI)
detail.url.search = new URLSearchParams(detail.fetchOptions.body).toString()
detail.fetchOptions.body = null
detail.fetchOptions.method = "get"
}))
)
await page.click("#standard form[method=post] [type=submit]")
await nextEventNamed(page, "turbo:load")

assert.equal(await page.textContent("h1"), "One", "overrides the method and action")
assert.equal(getSearchParam(page.url(), "greeting"), "Hello from a redirect")
})

test("test standard POST form submission merges values from both searchParams and body", async ({ page }) => {
await page.click("#form-action-post-redirect-self-q-b")
await nextBody(page)
Expand Down Expand Up @@ -908,7 +954,7 @@ test("test link method form submission submits a single request", async ({ page
const { fetchOptions } = await nextEventNamed(page, "turbo:before-fetch-request")

assert.ok(await noNextEventNamed(page, "turbo:before-fetch-request"))
assert.equal(fetchOptions.method, "POST", "[data-turbo-method] overrides the GET method")
assert.equal(fetchOptions.method, "post", "[data-turbo-method] overrides the GET method")
assert.equal(requestCounter, 1, "submits a single HTTP request")
})

Expand All @@ -922,7 +968,7 @@ test("test link method form submission inside frame submits a single request", a
const { fetchOptions } = await nextEventNamed(page, "turbo:before-fetch-request")

assert.ok(await noNextEventNamed(page, "turbo:before-fetch-request"))
assert.equal(fetchOptions.method, "POST", "[data-turbo-method] overrides the GET method")
assert.equal(fetchOptions.method, "post", "[data-turbo-method] overrides the GET method")
assert.equal(requestCounter, 1, "submits a single HTTP request")
})

Expand All @@ -936,7 +982,7 @@ test("test link method form submission targeting frame submits a single request"
const { fetchOptions } = await nextEventNamed(page, "turbo:before-fetch-request")

assert.ok(await noNextEventNamed(page, "turbo:before-fetch-request"))
assert.equal(fetchOptions.method, "POST", "[data-turbo-method] overrides the GET method")
assert.equal(fetchOptions.method, "post", "[data-turbo-method] overrides the GET method")
assert.equal(requestCounter, 2, "submits a single HTTP request then follows a redirect")
})

Expand Down
3 changes: 2 additions & 1 deletion src/tests/functional/rendering_tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -554,8 +554,9 @@ test("test before-cache event", async ({ page }) => {
addEventListener("turbo:before-cache", () => (document.body.innerHTML = "Modified"), { once: true })
})
await page.click("#same-origin-link")
await nextBody(page)
await nextEventNamed(page, "turbo:load")
await page.goBack()
await nextEventNamed(page, "turbo:load")

assert.equal(await page.textContent("body"), "Modified")
})
Expand Down
Loading

0 comments on commit d9821c4

Please sign in to comment.