diff --git a/src/core/drive/form_submission.js b/src/core/drive/form_submission.js index 9b72131d4..d5aa0a0d5 100644 --- a/src/core/drive/form_submission.js +++ b/src/core/drive/form_submission.js @@ -1,5 +1,4 @@ -import { FetchRequest, FetchMethod, fetchMethodFromString } from "../../http/fetch_request" -import { expandURL } from "../url" +import { FetchRequest } from "../../http/fetch_request" import { dispatch, getAttribute, getMetaContent, hasAttribute } from "../../util" import { StreamMessage } from "../streams/stream_message" @@ -12,23 +11,6 @@ export const FormSubmissionState = { stopped: "stopped" } -export const FormEnctype = { - urlEncoded: "application/x-www-form-urlencoded", - multipart: "multipart/form-data", - 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 @@ -36,56 +18,27 @@ export class FormSubmission { return Promise.resolve(confirm(message)) } - constructor(delegate, formElement, submitter, mustRedirect = false) { + constructor(delegate, submission, 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() { - const method = this.submitter?.getAttribute("formmethod") || this.formElement.getAttribute("method") || "" - return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get - } - - get action() { - 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() { - 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]] : []) - }, []) - } - // The submission process async start() { @@ -217,18 +170,6 @@ export class FormSubmission { } } -function buildFormData(formElement, submitter) { - 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) { if (cookieName != null) { const cookies = document.cookie ? document.cookie.split("; ") : [] @@ -243,17 +184,3 @@ function getCookieValue(cookieName) { function responseSucceededWithoutRedirect(response) { return response.statusCode == 200 && !response.redirected } - -function mergeFormDataEntries(url, entries) { - 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 -} diff --git a/src/core/drive/html_form_submission.js b/src/core/drive/html_form_submission.js new file mode 100644 index 000000000..db6be7efd --- /dev/null +++ b/src/core/drive/html_form_submission.js @@ -0,0 +1,115 @@ +import { expandURL } from "../url" +import { getAttribute, getVisitAction } from "../../util" +import { FetchMethod, fetchMethodFromString } from "../../http/fetch_request" + +export const FormEnctype = { + urlEncoded: "application/x-www-form-urlencoded", + multipart: "multipart/form-data", + plain: "text/plain" +} + +export function formEnctypeFromString(encoding) { + switch (encoding.toLowerCase()) { + case FormEnctype.multipart: + return FormEnctype.multipart + case FormEnctype.plain: + return FormEnctype.plain + default: + return FormEnctype.urlEncoded + } +} + +export class HTMLFormSubmission { + constructor(form, submitter) { + this.form = form + this.submitter = submitter + + const url = expandURL(this.action) + + this.location = this.isSafe ? mergeFormDataEntries(url, [...this.body.entries()]) : url + } + + closest(selectors) { + return this.form.closest(selectors) + } + + get method() { + return this.submitter?.getAttribute("formmethod") || this.form.getAttribute("method") || "" + } + + get fetchMethod() { + return fetchMethodFromString(this.method.toLowerCase()) || FetchMethod.get + } + + get target() { + if (this.submitter?.hasAttribute("formtarget") || this.form.hasAttribute("target")) { + return this.submitter?.getAttribute("formtarget") || this.form.getAttribute("target") + } else { + return null + } + } + + get action() { + 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() { + 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() { + return formEnctypeFromString(this.submitter?.getAttribute("formenctype") || this.form.enctype) + } + + get body() { + 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]] : []) + }, []) + + return new URLSearchParams(formDataAsStrings) + } else { + return this.formData + } + } + + get visitAction() { + return getVisitAction(this.submitter, this.form) + } + + get frame() { + return getAttribute("data-turbo-frame", this.submitter, this.form) + } + + get isSafe() { + return this.fetchMethod === FetchMethod.get + } +} + +function mergeFormDataEntries(url, entries) { + 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 +} diff --git a/src/core/drive/navigator.js b/src/core/drive/navigator.js index be81794fd..2587a98de 100644 --- a/src/core/drive/navigator.js +++ b/src/core/drive/navigator.js @@ -1,4 +1,3 @@ -import { getVisitAction } from "../../util" import { FormSubmission } from "./form_submission" import { expandURL, getAnchor, getRequestURL, locationIsVisitable } from "../url" import { Visit } from "./visit" @@ -28,9 +27,9 @@ export class Navigator { this.currentVisit.start() } - submitForm(form, submitter) { + submitForm(htmlFormSubmission) { this.stop() - this.formSubmission = new FormSubmission(this, form, submitter, true) + this.formSubmission = new FormSubmission(this, htmlFormSubmission, true) this.formSubmission.start() } @@ -151,7 +150,7 @@ export class Navigator { return this.history.restorationIdentifier } - getActionForFormSubmission({ submitter, formElement }) { - return getVisitAction(submitter, formElement) || "advance" + getActionForFormSubmission(htmlFormSubmission) { + return htmlFormSubmission.visitAction || "advance" } } diff --git a/src/core/frames/frame_controller.js b/src/core/frames/frame_controller.js index 6fe1dcbe0..b5af38afc 100644 --- a/src/core/frames/frame_controller.js +++ b/src/core/frames/frame_controller.js @@ -14,7 +14,7 @@ import { } from "../../util" import { FormSubmission } from "../drive/form_submission" import { Snapshot } from "../snapshot" -import { getAction, expandURL, urlsAreEqual, locationIsVisitable } from "../url" +import { expandURL, urlsAreEqual, locationIsVisitable } from "../url" import { FormSubmitObserver } from "../../observers/form_submit_observer" import { FrameView } from "./frame_view" import { LinkInterceptor } from "./link_interceptor" @@ -157,9 +157,10 @@ export class FrameController { return this.#shouldInterceptNavigation(link) } - submittedFormLinkToLocation(link, _location, form) { + submittedFormLinkToLocation(link, _location, htmlFormSubmission) { const frame = this.#findFrameElement(link) - if (frame) form.setAttribute("data-turbo-frame", frame.id) + + if (frame) htmlFormSubmission.form.setAttribute("data-turbo-frame", frame.id) } // Link interceptor delegate @@ -174,16 +175,16 @@ export class FrameController { // Form submit observer delegate - willSubmitForm(element, submitter) { - return element.closest("turbo-frame") == this.element && this.#shouldInterceptNavigation(element, submitter) + willSubmitForm(htmlFormSubmission) { + return htmlFormSubmission.form.closest("turbo-frame") == this.element && this.#shouldInterceptNavigation(htmlFormSubmission) } - formSubmitted(element, submitter) { + formSubmitted(htmlFormSubmission) { if (this.formSubmission) { this.formSubmission.stop() } - this.formSubmission = new FormSubmission(this, element, submitter) + this.formSubmission = new FormSubmission(this, htmlFormSubmission) const { fetchRequest } = this.formSubmission this.prepareRequest(fetchRequest) this.formSubmission.start() @@ -232,13 +233,13 @@ export class FrameController { markAsBusy(formElement, this.#findFrameElement(formElement)) } - formSubmissionSucceededWithResponse(formSubmission, response) { - const frame = this.#findFrameElement(formSubmission.formElement, formSubmission.submitter) + formSubmissionSucceededWithResponse(htmlFormSubmission, response) { + const frame = this.#findFrameElement(htmlFormSubmission) - frame.delegate.proposeVisitIfNavigatedWithAction(frame, formSubmission.formElement, formSubmission.submitter) + frame.delegate.proposeVisitIfNavigatedWithAction(frame, htmlFormSubmission) frame.delegate.loadResponse(response) - if (!formSubmission.isSafe) { + if (!htmlFormSubmission.isSafe) { session.clearCache() } } @@ -337,18 +338,23 @@ export class FrameController { }) } - #navigateFrame(element, url, submitter) { - const frame = this.#findFrameElement(element, submitter) + #navigateFrame(element, url) { + const frame = this.#findFrameElement(element) - frame.delegate.proposeVisitIfNavigatedWithAction(frame, element, submitter) + frame.delegate.proposeVisitIfNavigatedWithAction(frame, element) this.#withCurrentNavigationElement(element, () => { frame.src = url }) } - proposeVisitIfNavigatedWithAction(frame, element, submitter) { - this.action = getVisitAction(submitter, element, frame) + proposeVisitIfNavigatedWithAction(frame, elementOrSubmission) { + const elements = + elementOrSubmission instanceof Element + ? [elementOrSubmission, frame] + : [elementOrSubmission.submitter, elementOrSubmission.formElement, frame] + + this.action = getVisitAction(...elements) if (this.action) { const pageSnapshot = PageSnapshot.fromElement(frame).clone() @@ -430,9 +436,13 @@ export class FrameController { return session.visit(location, { response: { redirected, statusCode, responseHTML } }) } - #findFrameElement(element, submitter) { - const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target") - return getFrameElementById(id) ?? this.element + #findFrameElement(elementOrSubmission) { + const id = + elementOrSubmission instanceof Element + ? getAttribute("data-turbo-frame", elementOrSubmission) + : elementOrSubmission.frame + + return getFrameElementById(id || this.element.getAttribute("target")) ?? this.element } async extractForeignFrameElement(container) { @@ -458,19 +468,26 @@ export class FrameController { return null } - #formActionIsVisitable(form, submitter) { - const action = getAction(form, submitter) - - return locationIsVisitable(expandURL(action), this.rootLocation) + #formActionIsVisitable(htmlFormSubmission) { + return locationIsVisitable(htmlFormSubmission.location, this.rootLocation) } - #shouldInterceptNavigation(element, submitter) { - const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target") - - if (element instanceof HTMLFormElement && !this.#formActionIsVisitable(element, submitter)) { + #shouldInterceptNavigation(elementOrSubmission) { + if (!(elementOrSubmission instanceof Element) && !this.#formActionIsVisitable(elementOrSubmission)) { return false } + let element, submitter + if (elementOrSubmission instanceof Element) { + element = elementOrSubmission + submitter = undefined + } else { + element = elementOrSubmission.form + submitter = elementOrSubmission.submitter + } + + const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target") + if (!this.enabled || id == "_top") { return false } diff --git a/src/core/frames/frame_redirector.js b/src/core/frames/frame_redirector.js index 10aeee6cc..aad2f3c50 100644 --- a/src/core/frames/frame_redirector.js +++ b/src/core/frames/frame_redirector.js @@ -1,7 +1,7 @@ import { FormSubmitObserver } from "../../observers/form_submit_observer" import { FrameElement } from "../../elements/frame_element" import { LinkInterceptor } from "./link_interceptor" -import { expandURL, getAction, locationIsVisitable } from "../url" +import { expandURL, locationIsVisitable } from "../url" export class FrameRedirector { constructor(session, element) { @@ -36,45 +36,48 @@ export class FrameRedirector { // Form submit observer delegate - willSubmitForm(element, submitter) { + willSubmitForm(htmlFormSubmission) { return ( - element.closest("turbo-frame") == null && - this.#shouldSubmit(element, submitter) && - this.#shouldRedirect(element, submitter) + htmlFormSubmission.closest("turbo-frame") == null && + this.#shouldSubmit(htmlFormSubmission) && + this.#shouldRedirect(htmlFormSubmission) ) } - formSubmitted(element, submitter) { - const frame = this.#findFrameElement(element, submitter) + formSubmitted(htmlFormSubmission) { + const frame = this.#findFrameElement(htmlFormSubmission) if (frame) { - frame.delegate.formSubmitted(element, submitter) + frame.delegate.formSubmitted(htmlFormSubmission) } } - #shouldSubmit(form, submitter) { - const action = getAction(form, submitter) + #shouldSubmit(htmlFormSubmission) { const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`) const rootLocation = expandURL(meta?.content ?? "/") - return this.#shouldRedirect(form, submitter) && locationIsVisitable(action, rootLocation) + return this.#shouldRedirect(htmlFormSubmission) && locationIsVisitable(htmlFormSubmission.location, rootLocation) } - #shouldRedirect(element, submitter) { + #shouldRedirect(elementOrSubmission) { const isNavigatable = - element instanceof HTMLFormElement - ? this.session.submissionIsNavigatable(element, submitter) - : this.session.elementIsNavigatable(element) + elementOrSubmission instanceof Element + ? this.session.elementIsNavigatable(elementOrSubmission) + : this.session.submissionIsNavigatable(elementOrSubmission) if (isNavigatable) { - const frame = this.#findFrameElement(element, submitter) - return frame ? frame != element.closest("turbo-frame") : false + const frame = this.#findFrameElement(elementOrSubmission) + return frame ? frame != elementOrSubmission.closest("turbo-frame") : false } else { return false } } - #findFrameElement(element, submitter) { - const id = submitter?.getAttribute("data-turbo-frame") || element.getAttribute("data-turbo-frame") + #findFrameElement(elementOrSubmission) { + const id = + elementOrSubmission instanceof Element + ? elementOrSubmission.getAttribute("data-turbo-frame") + : elementOrSubmission.frame + if (id && id != "_top") { const frame = this.element.querySelector(`#${id}:not([disabled])`) if (frame instanceof FrameElement) { diff --git a/src/core/session.js b/src/core/session.js index 44edc7856..42df1bb8a 100644 --- a/src/core/session.js +++ b/src/core/session.js @@ -5,7 +5,7 @@ import { FrameRedirector } from "./frames/frame_redirector" import { History } from "./drive/history" import { LinkClickObserver } from "../observers/link_click_observer" import { FormLinkClickObserver } from "../observers/form_link_click_observer" -import { getAction, expandURL, locationIsVisitable } from "./url" +import { expandURL, locationIsVisitable } from "./url" import { Navigator } from "./drive/navigator" import { PageObserver } from "../observers/page_observer" import { ScrollObserver } from "../observers/scroll_observer" @@ -207,17 +207,15 @@ export class Session { // Form submit observer delegate - willSubmitForm(form, submitter) { - const action = getAction(form, submitter) - + willSubmitForm(htmlFormSubmission) { return ( - this.submissionIsNavigatable(form, submitter) && - locationIsVisitable(expandURL(action), this.snapshot.rootLocation) + this.submissionIsNavigatable(htmlFormSubmission) && + locationIsVisitable(htmlFormSubmission.location, this.snapshot.rootLocation) ) } - formSubmitted(form, submitter) { - this.navigator.submitForm(form, submitter) + formSubmitted(htmlFormSubmission) { + this.navigator.submitForm(htmlFormSubmission) } // Page observer delegate @@ -361,7 +359,9 @@ export class Session { // Helpers - submissionIsNavigatable(form, submitter) { + submissionIsNavigatable(htmlFormSubmission) { + const { form, submitter } = htmlFormSubmission + if (this.formMode == "off") { return false } else { diff --git a/src/core/url.js b/src/core/url.js index dcd50cf26..7820efe50 100644 --- a/src/core/url.js +++ b/src/core/url.js @@ -12,12 +12,6 @@ export function getAnchor(url) { } } -export function getAction(form, submitter) { - const action = submitter?.getAttribute("formaction") || form.getAttribute("action") || form.action - - return expandURL(action) -} - export function getExtension(url) { return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || "" } diff --git a/src/observers/form_link_click_observer.js b/src/observers/form_link_click_observer.js index bb2e68017..ebca0df20 100644 --- a/src/observers/form_link_click_observer.js +++ b/src/observers/form_link_click_observer.js @@ -1,3 +1,4 @@ +import { HTMLFormSubmission } from "../core/drive/html_form_submission" import { LinkClickObserver } from "./link_click_observer" import { getVisitAction } from "../util" @@ -52,7 +53,7 @@ export class FormLinkClickObserver { const turboStream = link.hasAttribute("data-turbo-stream") if (turboStream) form.setAttribute("data-turbo-stream", "") - this.delegate.submittedFormLinkToLocation(link, location, form) + this.delegate.submittedFormLinkToLocation(link, location, new HTMLFormSubmission(form)) document.body.appendChild(form) form.addEventListener("turbo:submit-end", () => form.remove(), { once: true }) diff --git a/src/observers/form_submit_observer.js b/src/observers/form_submit_observer.js index 1158d59b9..6e784efc2 100644 --- a/src/observers/form_submit_observer.js +++ b/src/observers/form_submit_observer.js @@ -1,3 +1,5 @@ +import { HTMLFormSubmission } from "../core/drive/html_form_submission" + export class FormSubmitObserver { started = false @@ -27,34 +29,32 @@ export class FormSubmitObserver { submitBubbled = (event) => { if (!event.defaultPrevented) { - const form = event.target instanceof HTMLFormElement ? event.target : undefined - const submitter = event.submitter || undefined + const submission = + event.target instanceof HTMLFormElement + ? new HTMLFormSubmission(event.target, event.submitter || undefined) + : undefined if ( - form && - submissionDoesNotDismissDialog(form, submitter) && - submissionDoesNotTargetIFrame(form, submitter) && - this.delegate.willSubmitForm(form, submitter) + submission && + submissionDoesNotDismissDialog(submission) && + submissionDoesNotTargetIFrame(submission) && + this.delegate.willSubmitForm(submission) ) { event.preventDefault() event.stopImmediatePropagation() - this.delegate.formSubmitted(form, submitter) + this.delegate.formSubmitted(submission) } } } } -function submissionDoesNotDismissDialog(form, submitter) { - const method = submitter?.getAttribute("formmethod") || form.getAttribute("method") - - return method != "dialog" +function submissionDoesNotDismissDialog(htmlFormSubmission) { + return htmlFormSubmission.method != "dialog" } -function submissionDoesNotTargetIFrame(form, submitter) { - if (submitter?.hasAttribute("formtarget") || form.hasAttribute("target")) { - const target = submitter?.getAttribute("formtarget") || form.target - - for (const element of document.getElementsByName(target)) { +function submissionDoesNotTargetIFrame(htmlFormSubmission) { + if (htmlFormSubmission.target) { + for (const element of document.getElementsByName(htmlFormSubmission.target)) { if (element instanceof HTMLIFrameElement) return false }