diff --git a/src/htmx.js b/src/htmx.js index b981c89c..7bdfc0ee 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -18,6 +18,7 @@ return (function () { on: addEventListenerImpl, off: removeEventListenerImpl, trigger : triggerEvent, + ajax : ajaxHelper, find : find, findAll : findAll, closest : closest, @@ -469,6 +470,14 @@ return (function () { }); } + function handlePreservedElements(fragment) { + forEach(findAll(fragment, '[hx-preserve], [data-hx-preserve]'), function (preservedElt) { + var id = getAttributeValue(preservedElt, "id"); + var oldElt = getDocument().getElementById(id); + preservedElt.parentNode.replaceChild(oldElt, preservedElt); + }); + } + function handleAttributes(parentNode, fragment, settleInfo) { forEach(fragment.querySelectorAll("[id]"), function (newNode) { if (newNode.id && newNode.id.length > 0) { @@ -655,6 +664,7 @@ return (function () { var fragment = makeFragment(responseText); if (fragment) { handleOutOfBandSwaps(fragment, settleInfo); + handlePreservedElements(fragment); fragment = maybeSelectFromResponse(elt, fragment); return swap(swapStyle, elt, target, fragment, settleInfo); } @@ -838,7 +848,7 @@ return (function () { var nodeData = getInternalData(elt); nodeData.timeout = setTimeout(function () { if (bodyContains(elt) && nodeData.cancelled !== true) { - issueAjaxRequest(elt, verb, path); + issueAjaxRequest(verb, path, elt); processPolling(elt, verb, getAttributeValue(elt, "hx-" + verb), interval); } }, interval); @@ -929,15 +939,15 @@ return (function () { if (triggerSpec.throttle) { elementData.throttle = setTimeout(function(){ - issueAjaxRequest(elt, verb, path, evt.target, evt); + issueAjaxRequest(verb, path, elt, evt); elementData.throttle = null; }, triggerSpec.throttle); } else if (triggerSpec.delay) { elementData.delayed = setTimeout(function(){ - issueAjaxRequest(elt, verb, path, evt.target, evt); + issueAjaxRequest(verb, path, elt, evt); }, triggerSpec.delay); } else { - issueAjaxRequest(elt, verb, path, evt.target, evt); + issueAjaxRequest(verb, path, elt, evt); } } }; @@ -969,7 +979,7 @@ return (function () { var nodeData = getInternalData(elt); if (!nodeData.revealed && isScrolledIntoView(elt)) { nodeData.revealed = true; - issueAjaxRequest(elt, nodeData.verb, nodeData.path); + issueAjaxRequest(nodeData.verb, nodeData.path, elt); } } @@ -1032,7 +1042,7 @@ return (function () { if (webSocketSourceElt) { var webSocket = getInternalData(webSocketSourceElt).webSocket; elt.addEventListener(getTriggerSpecs(elt)[0].trigger, function (evt) { - var headers = getHeaders(elt, webSocketSourceElt, null, elt); + var headers = getHeaders(elt, webSocketSourceElt); var results = getInputValues(elt, 'post'); var errors = results.errors; var rawParameters = results.values; @@ -1121,7 +1131,7 @@ return (function () { var sseListener = function () { if (!maybeCloseSSESource(sseSourceElt)) { if (bodyContains(elt)) { - issueAjaxRequest(elt, verb, path); + issueAjaxRequest(verb, path, elt); } else { sseEventSource.removeEventListener(sseEventName, sseListener); } @@ -1151,7 +1161,7 @@ return (function () { var load = function(){ if (!nodeData.loaded) { nodeData.loaded = true; - issueAjaxRequest(elt, verb, path); + issueAjaxRequest(verb, path, elt); } } if (delay) { @@ -1419,7 +1429,7 @@ return (function () { } function restoreHistory(path) { - saveHistory(currentPathForHistory); + saveHistory(); path = path || location.pathname+location.search; triggerEvent(getDocument().body, "htmx:historyRestore", {path:path}); var cached = getCachedHistory(path); @@ -1496,7 +1506,7 @@ return (function () { return true; } - function processInputValue(processed, values, errors, elt) { + function processInputValue(processed, values, errors, elt, validate) { if (elt == null || haveSeenNode(processed, elt)) { return; } else { @@ -1534,12 +1544,14 @@ return (function () { values[name] = value; } } - validateElement(elt, errors); + if (validate) { + validateElement(elt, errors); + } } if (matches(elt, 'form')) { var inputs = elt.elements; forEach(inputs, function(input) { - processInputValue(processed, values, errors, input); + processInputValue(processed, values, errors, input, validate); }); } } @@ -1556,28 +1568,37 @@ return (function () { function getInputValues(elt, verb) { var processed = []; - var values = {}; + var values = { + form: {}, + element: {}, + includes: {}, + }; var errors = []; + // only validate when form is directly submitted and novalidate is not set + var validate = matches(elt, 'form') && elt.noValidate !== true; + // for a non-GET include the closest form if (verb !== 'get') { - processInputValue(processed, values, errors, closest(elt, 'form')); + processInputValue(processed, values.form, errors, closest(elt, 'form'), validate); } // include the element itself - processInputValue(processed, values, errors, elt); + processInputValue(processed, values.element, errors, elt, validate); // include any explicit includes var includes = getClosestAttributeValue(elt, "hx-include"); if (includes) { var nodes = getDocument().querySelectorAll(includes); forEach(nodes, function(node) { - processInputValue(processed, values, errors, node); + processInputValue(processed, values.includes, errors, node, validate); }); } + var mergedValues = mergeObjects(values.includes, values.element); + mergedValues = mergeObjects(mergedValues, values.form); - return {errors:errors, values:values}; + return {errors:errors, values:mergedValues}; } function appendParam(returnStr, name, realValue) { @@ -1626,7 +1647,7 @@ return (function () { // Ajax //==================================================================== - function getHeaders(elt, target, prompt, eventTarget) { + function getHeaders(elt, target, prompt) { var headers = { "HX-Request" : "true", "HX-Trigger" : getRawAttribute(elt, "id"), @@ -1637,16 +1658,6 @@ return (function () { if (prompt !== undefined) { headers["HX-Prompt"] = prompt; } - if (eventTarget) { - headers["HX-Event-Target"] = getRawAttribute(eventTarget, "id"); - } - if (getDocument().activeElement) { - headers["HX-Active-Element"] = getRawAttribute(getDocument().activeElement, "id"); - headers["HX-Active-Element-Name"] = getRawAttribute(getDocument().activeElement, "name"); - if (getDocument().activeElement.value) { - headers["HX-Active-Element-Value"] = getRawAttribute(getDocument().activeElement, "value"); - } - } return headers; } @@ -1817,19 +1828,38 @@ return (function () { return xhr.getAllResponseHeaders().match(regexp); } - function issueAjaxRequest(elt, verb, path, eventTarget, triggeringEvent) { + function ajaxHelper(verb, path, context) { + if (context) { + if (context instanceof Element || isType(context, 'String')) { + issueAjaxRequest(verb, path, null, null, null, resolveTarget(context)); + } else { + issueAjaxRequest(verb, path, resolveTarget(context.source), context.event, context.handler, resolveTarget(context.target)); + } + } else { + issueAjaxRequest(verb, path); + } + } + + function issueAjaxRequest(verb, path, elt, event, responseHandler, targetOverride) { + if(elt == null) { + elt = getDocument().body; + } + if (responseHandler == null) { + responseHandler = handleAjaxResponse; + } if (!bodyContains(elt)) { - console.log("Body does not contain", elt); return; // do not issue requests for elements removed from the DOM } - var target = getTarget(elt); + var target = targetOverride || getTarget(elt); if (target == null) { triggerErrorEvent(elt, 'htmx:targetError', {target: getAttributeValue(elt, "hx-target")}); return; } var eltData = getInternalData(elt); if (eltData.requestInFlight) { - eltData.queuedRequest = function(){issueAjaxRequest(elt, verb, path, eventTarget, triggeringEvent)}; + eltData.queuedRequest = function(){ + issueAjaxRequest(verb, path, elt, event) + }; return; } else { eltData.requestInFlight = true; @@ -1858,7 +1888,7 @@ return (function () { var xhr = new XMLHttpRequest(); - var headers = getHeaders(elt, target, promptResponse, eventTarget); + var headers = getHeaders(elt, target, promptResponse); var results = getInputValues(elt, verb); var errors = results.errors; var rawParameters = results.values; @@ -1883,7 +1913,7 @@ return (function () { verb:verb, errors:errors, path:path, - triggeringEvent:triggeringEvent + triggeringEvent:event }; if(!triggerEvent(elt, 'htmx:configRequest', requestConfig)) return endRequestLock(); @@ -1931,159 +1961,35 @@ return (function () { } } - var eventDetail = {xhr: xhr, target: target, requestConfig: requestConfig}; + var responseInfo = {xhr: xhr, target: target, requestConfig: requestConfig, pathInfo:{ + path:path, finalPath:finalPathForGet, anchor:anchor + } + }; xhr.onload = function () { try { - if (!triggerEvent(elt, 'htmx:beforeOnLoad', eventDetail)) return; - - if (hasHeader(xhr, /HX-Trigger:/i)) { - handleTrigger(xhr, "HX-Trigger", elt); - } - - if (hasHeader(xhr,/HX-Push:/i)) { - var pushedUrl = xhr.getResponseHeader("HX-Push"); - } - - if (hasHeader(xhr, /HX-Redirect:/i)) { - window.location.href = xhr.getResponseHeader("HX-Redirect"); - return; - } - - if (hasHeader(xhr,/HX-Refresh:/i)) { - if ("true" === xhr.getResponseHeader("HX-Refresh")) { - location.reload(); - return; - } - } - - var shouldSaveHistory = shouldPush(elt) || pushedUrl; - - if (this.status >= 200 && this.status < 400) { - if (this.status === 286) { - cancelPolling(elt); - } - // don't process 'No Content' - if (this.status !== 204) { - if (!triggerEvent(target, 'htmx:beforeSwap', eventDetail)) return; - - var serverResponse = this.response; - withExtensions(elt, function(extension){ - serverResponse = extension.transformResponse(serverResponse, xhr, elt); - }); - - // Save current page - if (shouldSaveHistory) { - saveHistory(); - } - - var swapSpec = getSwapSpecification(elt); - - target.classList.add(htmx.config.swappingClass); - var doSwap = function () { - try { - - var activeElt = document.activeElement; - var selectionInfo = { - elt: activeElt, - start: activeElt ? activeElt.selectionStart : null, - end: activeElt ? activeElt.selectionEnd : null - }; - - var settleInfo = makeSettleInfo(target); - selectAndSwap(swapSpec.swapStyle, target, elt, serverResponse, settleInfo); - - if (selectionInfo.elt && - !bodyContains(selectionInfo.elt) && - selectionInfo.elt.id) { - var newActiveElt = document.getElementById(selectionInfo.elt.id); - if (newActiveElt) { - if (selectionInfo.start && newActiveElt.setSelectionRange) { - newActiveElt.setSelectionRange(selectionInfo.start, selectionInfo.end); - } - newActiveElt.focus(); - } - } - - target.classList.remove(htmx.config.swappingClass); - forEach(settleInfo.elts, function (elt) { - if (elt.classList) { - elt.classList.add(htmx.config.settlingClass); - } - triggerEvent(elt, 'htmx:afterSwap', eventDetail); - }); - if (anchor) { - location.hash = anchor; - } - - if (hasHeader(xhr, /HX-Trigger-After-Swap:/i)) { - handleTrigger(xhr, "HX-Trigger-After-Swap", elt); - } - - var doSettle = function(){ - forEach(settleInfo.tasks, function (task) { - task.call(); - }); - forEach(settleInfo.elts, function (elt) { - if (elt.classList) { - elt.classList.remove(htmx.config.settlingClass); - } - triggerEvent(elt, 'htmx:afterSettle', eventDetail); - }); - // push URL and save new page - if (shouldSaveHistory) { - var pathToPush = pushedUrl || getPushUrl(elt) || getResponseURL(xhr) || finalPathForGet || path; - pushUrlIntoHistory(pathToPush); - triggerEvent(getDocument().body, 'htmx:pushedIntoHistory', {path:pathToPush}); - } - updateScrollState(target, settleInfo.elts, swapSpec); - - if (hasHeader(xhr, /HX-Trigger-After-Settle:/i)) { - handleTrigger(xhr, "HX-Trigger-After-Settle", elt); - } - } - - if (swapSpec.settleDelay > 0) { - setTimeout(doSettle, swapSpec.settleDelay) - } else { - doSettle(); - } - } catch (e) { - triggerErrorEvent(elt, 'htmx:swapError', eventDetail); - throw e; - } - }; - - if (swapSpec.swapDelay > 0) { - setTimeout(doSwap, swapSpec.swapDelay) - } else { - doSwap(); - } - } - } else { - triggerErrorEvent(elt, 'htmx:responseError', mergeObjects({error: "Response Status Error Code " + this.status + " from " + path}, eventDetail)); - } + responseHandler(elt, responseInfo); } catch (e) { - triggerErrorEvent(elt, 'htmx:onLoadError', mergeObjects({error:e}, eventDetail)); + triggerErrorEvent(elt, 'htmx:onLoadError', mergeObjects({error:e}, responseInfo)); throw e; } finally { removeRequestIndicatorClasses(elt); var finalElt = getInternalData(elt).replacedWith || elt; - triggerEvent(finalElt, 'htmx:afterRequest', eventDetail); - triggerEvent(finalElt, 'htmx:afterOnLoad', eventDetail); + triggerEvent(finalElt, 'htmx:afterRequest', responseInfo); + triggerEvent(finalElt, 'htmx:afterOnLoad', responseInfo); endRequestLock(); } } xhr.onerror = function () { removeRequestIndicatorClasses(elt); - triggerErrorEvent(elt, 'htmx:afterRequest', eventDetail); - triggerErrorEvent(elt, 'htmx:sendError', eventDetail); + triggerErrorEvent(elt, 'htmx:afterRequest', responseInfo); + triggerErrorEvent(elt, 'htmx:sendError', responseInfo); endRequestLock(); } xhr.onabort = function() { removeRequestIndicatorClasses(elt); endRequestLock(); } - if(!triggerEvent(elt, 'htmx:beforeRequest', eventDetail)) return endRequestLock(); + if(!triggerEvent(elt, 'htmx:beforeRequest', responseInfo)) return endRequestLock(); addRequestIndicatorClasses(elt); forEach(['loadstart', 'loadend', 'progress', 'abort'], function(eventName) { @@ -2100,6 +2006,140 @@ return (function () { xhr.send(verb === 'get' ? null : encodeParamsForBody(xhr, elt, filteredParameters)); } + function handleAjaxResponse(elt, responseInfo) { + var xhr = responseInfo.xhr; + var target = responseInfo.target; + + if (!triggerEvent(elt, 'htmx:beforeOnLoad', responseInfo)) return; + + if (hasHeader(xhr, /HX-Trigger:/i)) { + handleTrigger(xhr, "HX-Trigger", elt); + } + + if (hasHeader(xhr,/HX-Push:/i)) { + var pushedUrl = xhr.getResponseHeader("HX-Push"); + } + + if (hasHeader(xhr, /HX-Redirect:/i)) { + window.location.href = xhr.getResponseHeader("HX-Redirect"); + return; + } + + if (hasHeader(xhr,/HX-Refresh:/i)) { + if ("true" === xhr.getResponseHeader("HX-Refresh")) { + location.reload(); + return; + } + } + + var shouldSaveHistory = shouldPush(elt) || pushedUrl; + + if (xhr.status >= 200 && xhr.status < 400) { + if (xhr.status === 286) { + cancelPolling(elt); + } + // don't process 'No Content' + if (xhr.status !== 204) { + if (!triggerEvent(target, 'htmx:beforeSwap', responseInfo)) return; + + var serverResponse = xhr.response; + withExtensions(elt, function(extension){ + serverResponse = extension.transformResponse(serverResponse, xhr, elt); + }); + + // Save current page + if (shouldSaveHistory) { + saveHistory(); + } + + var swapSpec = getSwapSpecification(elt); + + target.classList.add(htmx.config.swappingClass); + var doSwap = function () { + try { + + var activeElt = document.activeElement; + var selectionInfo = { + elt: activeElt, + start: activeElt ? activeElt.selectionStart : null, + end: activeElt ? activeElt.selectionEnd : null + }; + + var settleInfo = makeSettleInfo(target); + selectAndSwap(swapSpec.swapStyle, target, elt, serverResponse, settleInfo); + + if (selectionInfo.elt && + !bodyContains(selectionInfo.elt) && + selectionInfo.elt.id) { + var newActiveElt = document.getElementById(selectionInfo.elt.id); + if (newActiveElt) { + if (selectionInfo.start && newActiveElt.setSelectionRange) { + newActiveElt.setSelectionRange(selectionInfo.start, selectionInfo.end); + } + newActiveElt.focus(); + } + } + + target.classList.remove(htmx.config.swappingClass); + forEach(settleInfo.elts, function (elt) { + if (elt.classList) { + elt.classList.add(htmx.config.settlingClass); + } + triggerEvent(elt, 'htmx:afterSwap', responseInfo); + }); + if (responseInfo.pathInfo.anchor) { + location.hash = responseInfo.pathInfo.anchor; + } + + if (hasHeader(xhr, /HX-Trigger-After-Swap:/i)) { + handleTrigger(xhr, "HX-Trigger-After-Swap", elt); + } + + var doSettle = function(){ + forEach(settleInfo.tasks, function (task) { + task.call(); + }); + forEach(settleInfo.elts, function (elt) { + if (elt.classList) { + elt.classList.remove(htmx.config.settlingClass); + } + triggerEvent(elt, 'htmx:afterSettle', responseInfo); + }); + // push URL and save new page + if (shouldSaveHistory) { + var pathToPush = pushedUrl || getPushUrl(elt) || getResponseURL(xhr) || responseInfo.pathInfo.finalPath || responseInfo.pathInfo.path; + pushUrlIntoHistory(pathToPush); + triggerEvent(getDocument().body, 'htmx:pushedIntoHistory', {path:pathToPush}); + } + updateScrollState(target, settleInfo.elts, swapSpec); + + if (hasHeader(xhr, /HX-Trigger-After-Settle:/i)) { + handleTrigger(xhr, "HX-Trigger-After-Settle", elt); + } + } + + if (swapSpec.settleDelay > 0) { + setTimeout(doSettle, swapSpec.settleDelay) + } else { + doSettle(); + } + } catch (e) { + triggerErrorEvent(elt, 'htmx:swapError', responseInfo); + throw e; + } + }; + + if (swapSpec.swapDelay > 0) { + setTimeout(doSwap, swapSpec.swapDelay) + } else { + doSwap(); + } + } + } else { + triggerErrorEvent(elt, 'htmx:responseError', mergeObjects({error: "Response Status Error Code " + xhr.status + " from " + responseInfo.pathInfo.path}, responseInfo)); + } + } + //==================================================================== // Extensions API //==================================================================== diff --git a/test/attributes/hx-include.js b/test/attributes/hx-include.js index 1d00f25d..b7c8a826 100644 --- a/test/attributes/hx-include.js +++ b/test/attributes/hx-include.js @@ -34,6 +34,22 @@ describe("hx-include attribute", function() { div.innerHTML.should.equal("Clicked!"); }); + it('non-GET includes closest form and overrides values included that exist outside the form', function () { + this.server.respondWith("POST", "/include", function (xhr) { + var params = getParameters(xhr); + params['i1'].should.equal("test"); + xhr.respond(200, {}, "Clicked!") + }); + var div = make('
' + + '' + + '
' + + '') + var input = byId("d1") + input.click(); + this.server.respond(); + div.innerHTML.should.equal("Clicked!"); + }); + it('GET does not include closest form by default', function () { this.server.respondWith("GET", "/include", function (xhr) { var params = getParameters(xhr); @@ -150,4 +166,4 @@ describe("hx-include attribute", function() { }); -}); \ No newline at end of file +}); diff --git a/test/attributes/hx-preserve.js b/test/attributes/hx-preserve.js new file mode 100644 index 00000000..3ad01cb7 --- /dev/null +++ b/test/attributes/hx-preserve.js @@ -0,0 +1,21 @@ +describe("hx-preserve attribute", function () { + beforeEach(function () { + this.server = makeServer(); + clearWorkArea(); + }); + afterEach(function () { + this.server.restore(); + clearWorkArea(); + }); + + it('handles basic response properly', function () { + this.server.respondWith("GET", "/test", "
New Content
New Content
"); + var div = make("
Old Content
Old Content
"); + div.click(); + this.server.respond(); + byId("d1").innerHTML.should.equal("Old Content"); + byId("d2").innerHTML.should.equal("New Content"); + }) + +}); + diff --git a/test/core/validation.js b/test/core/validation.js index e191572f..e7f34571 100644 --- a/test/core/validation.js +++ b/test/core/validation.js @@ -26,6 +26,35 @@ describe("Core htmx client side validation tests", function(){ form.textContent.should.equal("Clicked!"); }); + it('Novalidate skips form validation', function() + { + this.server.respondWith("POST", "/test", "Clicked!"); + + var form = make('
' + + 'No Request' + + '' + + '
'); + form.textContent.should.equal("No Request"); + form.click(); + this.server.respond(); + form.textContent.should.equal("Clicked!"); + }); + + it('Validation skipped for indirect form submission', function() + { + this.server.respondWith("POST", "/test", "Clicked!"); + + var form = make('
' + + 'No Request' + + '' + + '' + + '
'); + form.textContent.should.equal("No Request"); + byId("button").click(); + this.server.respond(); + form.textContent.should.equal("Clicked!"); + }); + it('HTML5 pattern validation error prevents request', function() { this.server.respondWith("POST", "/test", "Clicked!"); diff --git a/test/index.html b/test/index.html index 0d39702a..85d279e1 100644 --- a/test/index.html +++ b/test/index.html @@ -92,6 +92,7 @@ + diff --git a/www/docs.md b/www/docs.md index d1c611bf..634725bd 100644 --- a/www/docs.md +++ b/www/docs.md @@ -557,12 +557,7 @@ htmx includes a number of useful headers in requests: | `HX-Trigger` | will be set to the id of the element that triggered the request | `HX-Trigger-Name` | will be set to the name of the element that triggered the request | `HX-Target` | will be set to the id of the target element -| `HX-Current-URL` | will be set to the URL of the browser | `HX-Prompt` | will be set to the value entered by the user when prompted via [hx-prompt](/attributes/hx-prompt) -| `HX-Event-Target` | the id of the original target of the event that triggered the request -| `HX-Active-Element` | the id of the current active element -| `HX-Active-Element-Name` | the name of the current active element -| `HX-Active-Element-Value` | the value of the current active element ### [Response Headers](#response-headers)