Skip to content

Commit

Permalink
Extract HTMLFormSubmission tuple
Browse files Browse the repository at this point in the history
The `HTMLFormElement, HTMLElement?` [data clump][] occurs in various
places across the internals of the codebase.

This commit extracts that pairing into an `HTMLFormSubmission` class
that knows how to properly extract various pieces of HTML Form
Submission context from the pairing, like the `method`, `action`, or
`target` values (which can be overridden by `formmethod`, `formaction`,
and `target`, respectively).

With the new extraction various call sites can be simplified to
delegate to the object, with the most impactful changes being made in
the `FormSubmission` class (mostly changes to the `constructor` and
the removal of various dynamic properties).

[data clump]: https://refactoring.guru/smells/data-clumps
  • Loading branch information
seanpdoyle committed Jul 21, 2023
1 parent da647a6 commit b76cba8
Show file tree
Hide file tree
Showing 11 changed files with 235 additions and 174 deletions.
113 changes: 22 additions & 91 deletions src/core/drive/form_submission.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { FetchRequest, FetchMethod, fetchMethodFromString } from "../../http/fetch_request"
import { Action } from "../types"
import { FetchRequest, FetchMethod } from "../../http/fetch_request"
import { FetchResponse } from "../../http/fetch_response"
import { expandURL } from "../url"
import { HTMLFormSubmission, FormEnctype } from "./html_form_submission"
import { dispatch, getAttribute, getMetaContent, hasAttribute } from "../../util"
import { StreamMessage } from "../streams/stream_message"

Expand All @@ -23,32 +24,22 @@ export enum FormSubmissionState {
stopped,
}

enum FormEnctype {
urlEncoded = "application/x-www-form-urlencoded",
multipart = "multipart/form-data",
plain = "text/plain",
}

export type TurboSubmitStartEvent = CustomEvent<{ formSubmission: FormSubmission }>
export type TurboSubmitEndEvent = CustomEvent<
{ formSubmission: FormSubmission } & { [K in keyof FormSubmissionResult]?: FormSubmissionResult[K] }
>

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

export class FormSubmission {
readonly delegate: FormSubmissionDelegate
readonly formElement: HTMLFormElement
readonly submitter?: HTMLElement
readonly submission: HTMLFormSubmission
readonly method: FetchMethod
readonly action: string
readonly body: URLSearchParams | FormData
readonly enctype: FormEnctype
readonly visitAction: Action | null
readonly frame: string | null
readonly formData: FormData
readonly location: URL
readonly fetchRequest: FetchRequest
Expand All @@ -65,61 +56,27 @@ export class FormSubmission {
return Promise.resolve(confirm(message))
}

constructor(
delegate: FormSubmissionDelegate,
formElement: HTMLFormElement,
submitter?: HTMLElement,
mustRedirect = false
) {
constructor(delegate: FormSubmissionDelegate, submission: HTMLFormSubmission, mustRedirect = false) {
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.submission = submission
this.formElement = submission.form
this.submitter = submission.submitter
this.formData = submission.formData
this.location = submission.location
this.method = submission.fetchMethod
this.action = submission.action
this.body = submission.body
this.enctype = submission.enctype
this.visitAction = submission.visitAction
this.frame = submission.frame
this.fetchRequest = new FetchRequest(this, this.method, this.location, this.body, this.formElement)
this.mustRedirect = mustRedirect
}

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

get action(): string {
const formElementAction = typeof this.formElement.action === "string" ? this.formElement.action : null

if (this.submitter?.hasAttribute("formaction")) {
return this.submitter.getAttribute("formaction") || ""
} else {
return this.formElement.getAttribute("action") || formElementAction || ""
}
}

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

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

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

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

// The submission process

async start() {
Expand Down Expand Up @@ -251,18 +208,6 @@ export class FormSubmission {
}
}

function buildFormData(formElement: HTMLFormElement, submitter?: HTMLElement): FormData {
const formData = new FormData(formElement)
const name = submitter?.getAttribute("name")
const value = submitter?.getAttribute("value")

if (name) {
formData.append(name, value || "")
}

return formData
}

function getCookieValue(cookieName: string | null) {
if (cookieName != null) {
const cookies = document.cookie ? document.cookie.split("; ") : []
Expand All @@ -277,17 +222,3 @@ function getCookieValue(cookieName: string | null) {
function responseSucceededWithoutRedirect(response: FetchResponse) {
return response.statusCode == 200 && !response.redirected
}

function mergeFormDataEntries(url: URL, entries: [string, FormDataEntryValue][]): URL {
const searchParams = new URLSearchParams()

for (const [name, value] of entries) {
if (value instanceof File) continue

searchParams.append(name, value)
}

url.search = searchParams.toString()

return url
}
115 changes: 115 additions & 0 deletions src/core/drive/html_form_submission.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { Action } from "../types"
import { expandURL } from "../url"
import { getAttribute, getVisitAction } from "../../util"
import { FetchMethod, fetchMethodFromString } from "../../http/fetch_request"

export enum FormEnctype {
urlEncoded = "application/x-www-form-urlencoded",
multipart = "multipart/form-data",
plain = "text/plain",
}

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

export class HTMLFormSubmission {
readonly location: URL

constructor(readonly form: HTMLFormElement, readonly submitter?: HTMLElement) {
const url = expandURL(this.action)

this.location = this.isSafe ? mergeFormDataEntries(url, [...this.body.entries()]) : url
}

get method(): string {
return this.submitter?.getAttribute("formmethod") || this.form.getAttribute("method") || ""
}

get fetchMethod(): FetchMethod {
return fetchMethodFromString(this.method.toLowerCase()) || FetchMethod.get
}

get target(): string | null {
if (this.submitter?.hasAttribute("formtarget") || this.form.hasAttribute("target")) {
return this.submitter?.getAttribute("formtarget") || this.form.getAttribute("target")
} else {
return null
}
}

get action(): string {
const formElementAction = typeof this.form.action === "string" ? this.form.action : null

if (this.submitter?.hasAttribute("formaction")) {
return this.submitter.getAttribute("formaction") || ""
} else {
return this.form.getAttribute("action") || formElementAction || ""
}
}

get formData(): FormData {
const formData = new FormData(this.form)
const name = this.submitter?.getAttribute("name")
const value = this.submitter?.getAttribute("value")

if (name) {
formData.append(name, value || "")
}

return formData
}

get enctype(): FormEnctype {
return formEnctypeFromString(this.submitter?.getAttribute("formenctype") || this.form.enctype)
}

get body(): URLSearchParams | FormData {
if (this.enctype == FormEnctype.urlEncoded || this.fetchMethod == FetchMethod.get) {
const formDataAsStrings = [...this.formData].reduce((entries, [name, value]) => {
return entries.concat(typeof value == "string" ? [[name, value]] : [])
}, [] as [string, string][])

return new URLSearchParams(formDataAsStrings)
} else {
return this.formData
}
}

get visitAction(): Action | null {
return getVisitAction(this.submitter, this.form)
}

get frame(): string | null {
return getAttribute("data-turbo-frame", this.submitter, this.form)
}

get isSafe(): boolean {
return this.fetchMethod === FetchMethod.get
}

closest<E extends Element = Element>(selectors: string): E | null {
return this.form.closest(selectors)
}
}

function mergeFormDataEntries(url: URL, entries: [string, FormDataEntryValue][]): URL {
const searchParams = new URLSearchParams()

for (const [name, value] of entries) {
if (value instanceof File) continue

searchParams.append(name, value)
}

url.search = searchParams.toString()

return url
}
10 changes: 5 additions & 5 deletions src/core/drive/navigator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Action } from "../types"
import { getVisitAction } from "../../util"
import { HTMLFormSubmission } from "./html_form_submission"
import { FetchResponse } from "../../http/fetch_response"
import { FormSubmission } from "./form_submission"
import { expandURL, getAnchor, getRequestURL, Locatable, locationIsVisitable } from "../url"
Expand Down Expand Up @@ -40,9 +40,9 @@ export class Navigator {
this.currentVisit.start()
}

submitForm(form: HTMLFormElement, submitter?: HTMLElement) {
submitForm(submission: HTMLFormSubmission) {
this.stop()
this.formSubmission = new FormSubmission(this, form, submitter, true)
this.formSubmission = new FormSubmission(this, submission, true)

this.formSubmission.start()
}
Expand Down Expand Up @@ -163,7 +163,7 @@ export class Navigator {
return this.history.restorationIdentifier
}

getActionForFormSubmission({ submitter, formElement }: FormSubmission): Action {
return getVisitAction(submitter, formElement) || "advance"
getActionForFormSubmission({ visitAction }: FormSubmission): Action {
return visitAction || "advance"
}
}
Loading

0 comments on commit b76cba8

Please sign in to comment.