Skip to content

Commit

Permalink
Properly resolve referenced forms. (#3094)
Browse files Browse the repository at this point in the history
* Properly resolve referenced forms.

* Clarify variable.

* Cast elt to avoid TS exceptions.

* Refactor for JSDoc.

* Clarify shouldCancel.

* Remove complicated JSDoc in favor of ts-ignore.

* More coverage for button scenarios.

* Use properties instead of matching.

* Mention reset button change.
  • Loading branch information
geoffrey-eisenbarth authored Jan 9, 2025
1 parent db42b46 commit f46989b
Show file tree
Hide file tree
Showing 5 changed files with 134 additions and 22 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## [2.0.5] - 2025-??-??

* Using `<button hx-verb="/endpoint" type="reset">` will now reset the associated form (after submitting to `/endpoint`).

## [2.0.4] - 2024-12-13

* Calling `htmx.ajax` with no target or source now defaults to body (previously did nothing)
Expand Down
32 changes: 25 additions & 7 deletions src/htmx.js
Original file line number Diff line number Diff line change
Expand Up @@ -2400,8 +2400,9 @@ var htmx = (function() {
if (elt.tagName === 'FORM') {
return true
}
if (matches(elt, 'input[type="submit"], button') &&
(matches(elt, '[form]') || closest(elt, 'form') !== null)) {
// @ts-ignore Do not cancel on buttons that 1) don't have a related form or 2) have a type attribute of 'reset'/'button'.
// The properties will resolve to undefined for elements that don't define 'type' or 'form', which is fine
if (elt.form && elt.type === 'submit') {
return true
}
if (elt instanceof HTMLAnchorElement && elt.href &&
Expand Down Expand Up @@ -2778,7 +2779,7 @@ var htmx = (function() {
* @param {Event} evt
*/
function maybeSetLastButtonClicked(evt) {
const elt = /** @type {HTMLButtonElement|HTMLInputElement} */ (closest(asElement(evt.target), "button, input[type='submit']"))
const elt = getTargetButton(evt.target)
const internalData = getRelatedFormData(evt)
if (internalData) {
internalData.lastButtonClicked = elt
Expand All @@ -2795,16 +2796,33 @@ var htmx = (function() {
}
}

/**
* @param {EventTarget} target
* @returns {HTMLButtonElement|HTMLInputElement|null}
*/
function getTargetButton(target) {
return /** @type {HTMLButtonElement|HTMLInputElement|null} */ (closest(asElement(target), "button, input[type='submit']"))
}

/**
* @param {Element} elt
* @returns {HTMLFormElement|null}
*/
function getRelatedForm(elt) {
// @ts-ignore Get the related form if available, else find the closest parent form
return elt.form || closest(elt, 'form')
}

/**
* @param {Event} evt
* @returns {HtmxNodeInternalData|undefined}
*/
function getRelatedFormData(evt) {
const elt = closest(asElement(evt.target), "button, input[type='submit']")
const elt = getTargetButton(evt.target)
if (!elt) {
return
}
const form = resolveTarget('#' + getRawAttribute(elt, 'form'), elt.getRootNode()) || closest(elt, 'form')
const form = getRelatedForm(elt)
if (!form) {
return
}
Expand Down Expand Up @@ -3521,9 +3539,9 @@ var htmx = (function() {
validate = validate && internalData.lastButtonClicked.formNoValidate !== true
}

// for a non-GET include the closest form
// for a non-GET include the related form, which may or may not be a parent element of elt
if (verb !== 'get') {
processInputValue(processed, priorityFormData, errors, closest(elt, 'form'), validate)
processInputValue(processed, priorityFormData, errors, getRelatedForm(elt), validate)
}

// include the element itself
Expand Down
78 changes: 78 additions & 0 deletions test/core/ajax.js
Original file line number Diff line number Diff line change
Expand Up @@ -1184,6 +1184,48 @@ describe('Core htmx AJAX Tests', function() {
values.should.deep.equal({ t1: 'textValue', b1: ['inputValue', 'buttonValue'] })
})

it('sends referenced form values when a button referencing another form is clicked', function() {
var values
this.server.respondWith('POST', '/test3', function(xhr) {
values = getParameters(xhr)
xhr.respond(205, {}, '')
})

make('<form id="externalForm" hx-post="/test">' +
'<input type="text" name="t1" value="textValue">' +
'<input type="hidden" name="b1" value="inputValue">' +
'</form>' +
'<form hx-post="/test2">' +
'<input type="text" name="t1" value="checkValue">' +
'<button id="submit" form="externalForm" hx-post="/test3" type="submit" name="b1" value="buttonValue">button</button>' +
'</form>')

byId('submit').click()
this.server.respond()
values.should.deep.equal({ t1: 'textValue', b1: ['inputValue', 'buttonValue'] })
})

it('sends referenced form values when a submit input referencing another form is clicked', function() {
var values
this.server.respondWith('POST', '/test3', function(xhr) {
values = getParameters(xhr)
xhr.respond(204, {}, '')
})

make('<form id="externalForm" hx-post="/test">' +
'<input type="text" name="t1" value="textValue">' +
'<input type="hidden" name="b1" value="inputValue">' +
'</form>' +
'<form hx-post="/test2">' +
'<input type="text" name="t1" value="checkValue">' +
'<input id="submit" form="externalForm" hx-post="/test3" type="submit" name="b1" value="buttonValue">' +
'</form>')

byId('submit').click()
this.server.respond()
values.should.deep.equal({ t1: 'textValue', b1: ['inputValue', 'buttonValue'] })
})

it('properly handles inputs external to form', function() {
var values
this.server.respondWith('Post', '/test', function(xhr) {
Expand Down Expand Up @@ -1262,4 +1304,40 @@ describe('Core htmx AJAX Tests', function() {
this.server.respond()
values.should.deep.equal({ name: '', outside: '' })
})

it('properly handles form reset behaviour with a htmx enabled reset button inside a form', function() {
var values
this.server.respondWith('POST', '/reset', function(xhr) {
values = getParameters(xhr)
xhr.respond(204, {}, '')
})

make('<form id="externalForm" hx-post="/test">' +
'<input id="t1" type="text" name="t1" value="defaultValue">' +
'<button hx-post="/reset" id="reset" type="reset" name="b1" value="buttonValue">reset</button>' +
'</form>')
byId('t1').value = 'otherValue'
byId('reset').click()
this.server.respond()
values.should.deep.equal({ b1: 'buttonValue', t1: 'otherValue' })
byId('t1').value.should.equal('defaultValue')
})

it('properly handles form reset behaviour with a htmx enabled reset button outside a form', function() {
var values
this.server.respondWith('POST', '/reset', function(xhr) {
values = getParameters(xhr)
xhr.respond(204, {}, '')
})

make('<form id="externalForm" hx-post="/test">' +
'<input id="t1" type="text" name="t1" value="defaultValue">' +
'</form>' +
'<button hx-post="/reset" id="reset" form="externalForm" type="reset" name="b1" value="buttonValue">reset</button>')
byId('t1').value = 'otherValue'
byId('reset').click()
this.server.respond()
values.should.deep.equal({ b1: 'buttonValue', t1: 'otherValue' })
byId('t1').value.should.equal('defaultValue')
})
})
38 changes: 25 additions & 13 deletions test/core/internals.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,19 +96,31 @@ describe('Core htmx internals Tests', function() {
var form = make('<form></form>')
htmx._('shouldCancel')({ type: 'submit' }, form).should.equal(true)

form = make("<form><input id='i1' type='submit'></form>")
var input = byId('i1')
htmx._('shouldCancel')({ type: 'click' }, input).should.equal(true)

form = make("<form><button id='b1' type='submit'></form>")
var button = byId('b1')
htmx._('shouldCancel')({ type: 'click' }, button).should.equal(true)

form = make("<form id='f1'></form><input id='i1' form='f1' type='submit'><button id='b1' form='f1' type='submit'>")
input = byId('i1')
button = byId('b1')
htmx._('shouldCancel')({ type: 'click' }, input).should.equal(true)
htmx._('shouldCancel')({ type: 'click' }, button).should.equal(true)
form = make('<form id="f1">' +
'<input id="insideInput" type="submit">' +
'<button id="insideFormBtn"></button>' +
'<button id="insideSubmitBtn" type="submit"></button>' +
'<button id="insideResetBtn" type="reset"></button>' +
'<button id="insideButtonBtn" type="button"></button>' +
'</form>' +
'<input id="outsideInput" form="f1" type="submit">' +
'<button id="outsideFormBtn" form="f1"></button>' +
'<button id="outsideSubmitBtn" form="f1" type="submit"></button>")' +
'<button id="outsideButtonBtn" form="f1" type="button"></button>")' +
'<button id="outsideResetBtn" form="f1" type="reset"></button>")' +
'<button id="outsideNoFormBtn"></button>")')
htmx._('shouldCancel')({ type: 'click' }, byId('insideInput')).should.equal(true)
htmx._('shouldCancel')({ type: 'click' }, byId('insideFormBtn')).should.equal(true)
htmx._('shouldCancel')({ type: 'click' }, byId('insideSubmitBtn')).should.equal(true)
htmx._('shouldCancel')({ type: 'click' }, byId('insideResetBtn')).should.equal(false)
htmx._('shouldCancel')({ type: 'click' }, byId('insideButtonBtn')).should.equal(false)

htmx._('shouldCancel')({ type: 'click' }, byId('outsideInput')).should.equal(true)
htmx._('shouldCancel')({ type: 'click' }, byId('outsideFormBtn')).should.equal(true)
htmx._('shouldCancel')({ type: 'click' }, byId('outsideSubmitBtn')).should.equal(true)
htmx._('shouldCancel')({ type: 'click' }, byId('outsideButtonBtn')).should.equal(false)
htmx._('shouldCancel')({ type: 'click' }, byId('outsideResetBtn')).should.equal(false)
htmx._('shouldCancel')({ type: 'click' }, byId('outsideNoFormBtn')).should.equal(false)
})

it('unset properly unsets a given attribute', function() {
Expand Down
4 changes: 2 additions & 2 deletions www/content/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -640,8 +640,8 @@ will include the values of all inputs within it.

As with HTML forms, the `name` attribute of the input is used as the parameter name in the request that htmx sends.

Additionally, if the element causes a non-`GET` request, the values of all the inputs of the nearest enclosing form
will be included.
Additionally, if the element causes a non-`GET` request, the values of all the inputs of the associated form will be
included (typically this is the nearest enclosing form, but could be different if e.g. `<button form="associated-form">` is used).

If you wish to include the values of other elements, you can use the [hx-include](@/attributes/hx-include.md) attribute
with a CSS selector of all the elements whose values you want to include in the request.
Expand Down

0 comments on commit f46989b

Please sign in to comment.