diff --git a/src/ext/response-targets.js b/src/ext/response-targets.js new file mode 100644 index 00000000..16e9d740 --- /dev/null +++ b/src/ext/response-targets.js @@ -0,0 +1,110 @@ +(function(){ + + /** @type {import("../htmx").HtmxInternalApi} */ + var api; + + const targetAttrPrefix = 'hx-target-'; + const targetAttrMinLen = targetAttrPrefix.length - 1; + + /** + * @param {HTMLElement} elt + * @param {number} respCode + * @returns {HTMLElement | null} + */ + function getRespCodeTarget(elt, respCode) { + if (!elt || !respCode) return null; + + var targetAttr = targetAttrPrefix + respCode; + var targetStr = api.getClosestAttributeValue(elt, targetAttr); + + if (targetStr) { + if (targetStr === "this") { + return api.findThisElement(elt, targetAttr); + } else { + return api.querySelectorExt(elt, targetStr); + } + } else { + for (let l = targetAttr.length - 1; l > targetAttrMinLen; l--) { + targetAttr = targetAttr.substring(0, l) + '*'; + targetStr = api.getClosestAttributeValue(elt, targetAttr); + if (targetStr) break; + } + } + + if (targetStr) { + if (targetStr === "this") { + return api.findThisElement(elt, targetAttr); + } else { + return api.querySelectorExt(elt, targetStr); + } + } else { + return null; + } + } + + /** @param {Event} evt */ + function handleErrorFlag(evt) { + if (evt.detail.isError) { + if (htmx.config.responseTargetUnsetsError) { + evt.detail.isError = false; + } + } else if (htmx.config.responseTargetSetsError) { + evt.detail.isError = true; + } + } + + htmx.defineExtension('response-targets', { + + /** @param {import("../htmx").HtmxInternalApi} apiRef */ + init: function (apiRef) { + api = apiRef; + + if (htmx.config.responseTargetUnsetsError === undefined) { + htmx.config.responseTargetUnsetsError = true; + } + if (htmx.config.responseTargetSetsError === undefined) { + htmx.config.responseTargetSetsError = false; + } + if (htmx.config.responseTargetPrefersExisting === undefined) { + htmx.config.responseTargetPrefersExisting = false; + } + if (htmx.config.responseTargetPrefersRetargetHeader === undefined) { + htmx.config.responseTargetPrefersRetargetHeader = true; + } + }, + + /** + * @param {string} name + * @param {Event} evt + */ + onEvent: function (name, evt) { + if (name === "htmx:beforeSwap" && + evt.detail.xhr && + evt.detail.xhr.status !== 200) { + if (evt.detail.target) { + if (htmx.config.responseTargetPrefersExisting) { + evt.detail.shoudSwap = true; + handleErrorFlag(evt); + return true; + } + if (htmx.config.responseTargetPrefersRetargetHeader && + evt.detail.xhr.getAllResponseHeaders().match(/HX-Retarget:/i)) { + evt.detail.shouldSwap = true; + handleErrorFlag(evt); + return true; + } + } + if (!evt.detail.requestConfig) { + return true; + } + var target = getRespCodeTarget(evt.detail.requestConfig.elt, evt.detail.xhr.status); + if (target) { + handleErrorFlag(evt); + evt.detail.shouldSwap = true; + evt.detail.target = target; + } + return true; + } + } + }); +})(); diff --git a/src/htmx.js b/src/htmx.js index c7ea3f45..1e949792 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -90,9 +90,11 @@ return (function () { addTriggerHandler: addTriggerHandler, bodyContains: bodyContains, canAccessLocalStorage: canAccessLocalStorage, + findThisElement: findThisElement, filterValues: filterValues, hasAttribute: hasAttribute, getAttributeValue: getAttributeValue, + getClosestAttributeValue: getClosestAttributeValue, getClosestMatch: getClosestMatch, getExpressionVars: getExpressionVars, getHeaders: getHeaders, @@ -105,6 +107,7 @@ return (function () { mergeObjects: mergeObjects, makeSettleInfo: makeSettleInfo, oobSwap: oobSwap, + querySelectorExt: querySelectorExt, selectAndSwap: selectAndSwap, settleImmediately: settleImmediately, shouldCancel: shouldCancel, diff --git a/test/ext/response-targets.js b/test/ext/response-targets.js new file mode 100644 index 00000000..4866f96f --- /dev/null +++ b/test/ext/response-targets.js @@ -0,0 +1,224 @@ +describe("response-targets extension", function() { + beforeEach(function() { + this.server = sinon.fakeServer.create(); + clearWorkArea(); + }); + afterEach(function() { + this.server.restore(); + clearWorkArea(); + }); + + it('targets an adjacent element properly', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var btn = make('') + var div1 = make('
') + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal("Not found!"); + }); + + it('targets an adjacent element properly with wildcard', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var btn = make('') + var div1 = make('
') + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal("Not found!"); + }); + + it('targets a parent element properly', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var div1 = make('
') + var btn = byId("b1") + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal("Not found!"); + }); + + it('targets a parent element properly with wildcard', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var div1 = make('
') + var btn = byId("b1") + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal("Not found!"); + }); + + it('targets a `this` element properly', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var div1 = make('
') + var btn = byId("b1") + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal("Not found!"); + }); + + it('targets a `closest` element properly', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var div1 = make('

') + var btn = byId("b1") + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal("Not found!"); + }); + + it('targets a `closest` element properly w/ hyperscript syntax', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var div1 = make('

') + var btn = byId("b1") + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal("Not found!"); + }); + + it('targets a `find` element properly', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var div1 = make('
Click Me!
') + div1.click(); + this.server.respond(); + var span1 = byId("s1") + var span2 = byId("s2") + span1.innerHTML.should.equal("Not found!"); + span2.innerHTML.should.equal(""); + }); + + it('targets a `find` element properly w/ hyperscript syntax', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var div1 = make('
Click Me!
') + div1.click(); + this.server.respond(); + var span1 = byId("s1") + var span2 = byId("s2") + span1.innerHTML.should.equal("Not found!"); + span2.innerHTML.should.equal(""); + }); + + it('targets an inner element properly', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var btn = make('') + var div1 = byId("d1") + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal("Not found!"); + }); + + it('targets an inner element properly w/ hyperscript syntax', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var btn = make('') + var div1 = byId("d1") + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal("Not found!"); + }); + + it('handles bad target gracefully', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var btn = make('') + btn.click(); + this.server.respond(); + btn.innerHTML.should.equal("Click Me!"); + }); + + + it('targets an adjacent element properly w/ data-* prefix', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + var btn = make('') + var div1 = make('
') + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal("Not found!"); + }); + + it('targets a `next` element properly', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + make('
' + + '
' + + ' ' + + '
' + + '
' + + '
') + var btn = byId("b1") + var div1 = byId("d1") + var div2 = byId("d2") + var div3 = byId("d3") + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal("Not found!"); + div2.innerHTML.should.equal(""); + div3.innerHTML.should.equal(""); + }); + + it('targets a `next` element properly w/ hyperscript syntax', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + make('
' + + '
' + + ' ' + + '
' + + '
' + + '
') + var btn = byId("b1") + var div1 = byId("d1") + var div2 = byId("d2") + var div3 = byId("d3") + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal("Not found!"); + div2.innerHTML.should.equal(""); + div3.innerHTML.should.equal(""); + }); + + it('targets a `previous` element properly', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + make('
' + + '
' + + ' ' + + '
' + + '
' + + '
') + var btn = byId("b1") + var div1 = byId("d1") + var div2 = byId("d2") + var div3 = byId("d3") + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal(""); + div2.innerHTML.should.equal(""); + div3.innerHTML.should.equal("Not found!"); + }); + + it('targets a `previous` element properly w/ hyperscript syntax', function() + { + this.server.respondWith("GET", "/test", [404, {}, "Not found!"]); + make('
' + + '
' + + ' ' + + '
' + + '
' + + '
') + var btn = byId("b1") + var div1 = byId("d1") + var div2 = byId("d2") + var div3 = byId("d3") + btn.click(); + this.server.respond(); + div1.innerHTML.should.equal(""); + div2.innerHTML.should.equal(""); + div3.innerHTML.should.equal("Not found!"); + }); +}); diff --git a/test/index.html b/test/index.html index cf5d8518..f77af57c 100644 --- a/test/index.html +++ b/test/index.html @@ -143,6 +143,9 @@ + + + diff --git a/www/content/extensions/_index.md b/www/content/extensions/_index.md index ec0da34b..5b8f9dc7 100644 --- a/www/content/extensions/_index.md +++ b/www/content/extensions/_index.md @@ -79,6 +79,7 @@ See the individual extension documentation for more details. | [`path-deps`](@/extensions/path-deps.md) | an extension for expressing path-based dependencies [similar to intercoolerjs](http://intercoolerjs.org/docs.html#dependencies) | [`preload`](@/extensions/preload.md) | preloads selected `href` and `hx-get` targets based on rules you control. | [`remove-me`](@/extensions/remove-me.md) | allows you to remove an element after a given amount of time +| [`response-targets`](@/extensions/response-targets.md) | allows to specify different target elements to be swapped when different HTTP response codes are received | [`restored`](@/extensions/restored.md) | allows you to trigger events when the back button has been pressed | [`server-sent-events`](@/extensions/server-sent-events.md) | uni-directional server push messaging via [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) | [`web-sockets`](@/extensions/web-sockets.md) | bi-directional connection to WebSocket servers diff --git a/www/content/extensions/response-targets.md b/www/content/extensions/response-targets.md new file mode 100644 index 00000000..796e7fd2 --- /dev/null +++ b/www/content/extensions/response-targets.md @@ -0,0 +1,117 @@ ++++ +title = "response-targets" ++++ + +This extension allows to specify different target elements to be swapped when +different HTTP response codes are received. + +It uses attribute names in a form of ``hx-target-[CODE]`` where `[CODE]` is a numeric +HTTP response code with the optional wildcard character at its end. + +The value of each attribute can be: + +* A CSS query selector of the element to target. +* `this` which indicates that the element that the `hx-target` attribute is on is the target. +* `closest ` which will find the closest parent ancestor that matches the given CSS selector + (e.g. `closest tr` will target the closest table row to the element). +* `find ` which will find the first child descendant element that matches the given CSS selector. +* `next ` which will scan the DOM forward for the first element that matches the given CSS selector. + (e.g. `next .error` will target the closest following sibling element with `error` class) +* `previous ` which will scan the DOM backwards for the first element that matches the given CSS selector. + (e.g `previous .error` will target the closest previous sibling with `error` class) + +## Install + +```html + +``` + +## Configure (optional) + +* When `HX-Retarget` response header is received it disables any lookup that would be + performed by this extension but any responses with error status codes will be + swapped (normally they would not be, even with target set via header) and internal + error flag (`isError`) will be modified. You may change this and choose to ignore + `HX-Retarget` header when `hx-target-…` is in place by setting a configuration flag + `htmx.config.responseTargetPrefersRetargetHeader` to `false` (default is + `true`). Note that this extension only performs a simple check whether the header + is set and target exists. It is not extracting target's value from the header but + trusts it was set by HTMX core logic. + +* Normally, any target which is already established by HTMX built-in functions or + extensions called before will be overwritten if a matching `hx-target-…` tag is + found. You may change it by using a configuration flag + `htmx.config.responseTargetPrefersExisting` to `true` (default is `false`). This is + kinky and risky option. It has a real-life applications similar to a skilled, + full-stack tardigrade eating parentheses when no one is watching. + +* `isError` flag on the `detail` member of an event associated with swapping the + content with `hx-target-[CODE]` will be set to `false` when error response code is + received. This is different from the default behavior. You may change this by + setting a configuration flag `htmx.config.responseTargetUnsetsError` to `false` + (default is `true`). + +* `isError` flag on the `detail` member of an event associated with swapping the + content with `hx-target-[CODE]` will be set to `false` when non-erroneous response + code is received. This is no different from the default behavior. You may change + this by setting a configuration flag `htmx.config.responseTargetSetsError` to + `true` (default is `false`). This setting will not affect the response code 200 + since it is not handled by this extension. + +## Usage + +Here is an example that targets a `div` for normal (200) response but another `div` +for 404 (not found) response, and yet another for all 5xx response codes: + +```html +
+
+ +
+
+
+``` + +* The response from the `/register` URL will replace contents of the `div` with the + `id` `response-div` when response code is 200 (OK). + +* The response from the `/register` URL will replace contents of the `div` with the `id` + `serious-errors` when response code begins with a digit 5 (server errors). + +* The response from the `/register` URL will will replace contents of the `div` with + the `id` `not-found` when response code is 404 (Not Found). + +## Wildcard resolution + +When status response code does not match existing `hx-target-[CODE]` attribute name +then its numeric part expressed as a string is trimmed with last character being +replaced with the asterisk (`*`). This lookup process continues until the attribute +is found or there are no more digits. + +For example, if a browser receives 404 error code, the following attribute names will +be looked up (in the given order): + +* `hx-target-404` +* `hx-target-40*` +* `hx-target-4*` +* `hx-target-*`. + +## Notes + +* `hx-target-…` is inherited and can be placed on a parent element. +* `hx-target-…` cannot be used to handle HTTP response code 200. +* `hx-target-…` will honor `HX-Retarget` by default and will prefer it over any + calculated target but it can be changed by disabling the + `htmx.config.responseTargetPrefersRetargetHeader` configuration option. +* To avoid surprises the `hx-ext` attribute used to enable this extension should be + placed on a parent element containing elements with `hx-target-…` and `hx-target` + attributes. + +## See also + +* [`hx-target`](@/attributes/hx-target.md), specifies the target element to be swapped