-
-
Notifications
You must be signed in to change notification settings - Fork 218
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #35224 from dimagi/bmb/htmx-and-alpine
Add HTMX and Alpine.js alongside helpful utilities, common js_entry point
- Loading branch information
Showing
29 changed files
with
1,404 additions
and
29 deletions.
There are no files selected for viewing
17 changes: 17 additions & 0 deletions
17
corehq/apps/hqwebapp/static/hqwebapp/js/htmx_and_alpine.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
/** | ||
* Include this module as the entry point to use HTMX and Alpine.js on a page without | ||
* any additional configuration. | ||
* | ||
* e.g.: | ||
* | ||
* {% js_entry "hqwebapp/js/htmx_and_alpine" %} | ||
* | ||
* Tips: | ||
* - Use the `HqHtmxActionMixin` to group related HTMX calls and responses as part of one class based view. | ||
* - To show errors encountered by HTMX requests, include the `hqwebapp/htmx/error_modal.html` template | ||
* in the `modals` block of the page, or `include` a template that extends it. | ||
*/ | ||
import 'hqwebapp/js/htmx_base'; | ||
|
||
import Alpine from 'alpinejs'; | ||
Alpine.start(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,69 @@ | ||
/** | ||
* DO NOT include this module as a `js_entry` point! | ||
* Use `hqwebapp/js/htmx_and_alpine` for entry points. | ||
* | ||
* This module requires Alpine to properly display the HTMX error modal. | ||
* | ||
* Instead, use this module as a starting point when you require additional javascript configuration for Alpine | ||
* before `Alpine.start()` is called. For instance, you want to access `Alpine.data()` or other globals. | ||
* | ||
* e.g.: | ||
* | ||
* import 'hqwebapp/js/htmx_base'; | ||
* import Alpine from 'alpinejs'; | ||
* | ||
* // access Alpine globals | ||
* Alpine.data(....); | ||
* | ||
* Alpine.start(); | ||
* | ||
* Tips: | ||
* - Use the `HqHtmxActionMixin` to group related HTMX calls and responses as part of one class based view. | ||
* - To show errors encountered by HTMX requests, include the `hqwebapp/htmx/error_modal.html` template | ||
* in the `modals` block of the page, or `include` a template that extends it. | ||
*/ | ||
import htmx from 'htmx.org'; | ||
|
||
import 'hqwebapp/js/htmx_utils/hq_hx_action'; | ||
import 'hqwebapp/js/htmx_utils/csrf_token'; | ||
import retryHtmxRequest from 'hqwebapp/js/htmx_utils/retry_request'; | ||
import { showHtmxErrorModal } from 'hqwebapp/js/htmx_utils/errors'; | ||
|
||
// By default, there is no timeout and requests hang indefinitely, so update to reasonable value. | ||
htmx.config.timeout = 20000; // 20 seconds, in milliseconds | ||
|
||
const HTTP_BAD_GATEWAY = 504; | ||
const HTTP_REQUEST_TIMEOUT = 408; | ||
|
||
document.body.addEventListener('htmx:responseError', (evt) => { | ||
let errorCode = evt.detail.xhr.status; | ||
if (errorCode === HTTP_BAD_GATEWAY) { | ||
if (!retryHtmxRequest(evt.detail.elt, evt.detail.pathInfo, evt.detail.requestConfig)) { | ||
showHtmxErrorModal( | ||
errorCode, | ||
gettext('Gateway Timeout Error. Max retries exceeded.') | ||
); | ||
} | ||
return; | ||
} | ||
showHtmxErrorModal( | ||
errorCode, | ||
evt.detail.xhr.statusText | ||
); | ||
}); | ||
|
||
document.body.addEventListener('htmx:timeout', (evt) => { | ||
/** | ||
* Safely retry on GET request timeouts. Use caution on other types of requests. | ||
* | ||
* If you want retry on POST requests, please create a new `js_entry` point and add a | ||
* similar event listener there. Also, you may want to adjust the `htmx.config.timeout` | ||
* value as well. | ||
*/ | ||
if (!retryHtmxRequest(evt.detail.elt, evt.detail.pathInfo, evt.detail.requestConfig) && evt.detail.requestConfig.verb === 'get') { | ||
showHtmxErrorModal( | ||
HTTP_REQUEST_TIMEOUT, | ||
gettext('Request timed out. Max retries exceeded.') | ||
); | ||
} | ||
}); |
18 changes: 18 additions & 0 deletions
18
corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/csrf_token.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
/* | ||
Make sure this module is included with HTMX projects so that the | ||
required CSRF Token is always added to the request headers. | ||
Alternatively you can include the `hx-headers` param in a parent element: | ||
``` | ||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' | ||
``` | ||
*/ | ||
import htmx from 'htmx.org'; | ||
|
||
document.body.addEventListener('htmx:configRequest', (evt) => { | ||
// By default, HTMX does not allow cross-origin requests | ||
// We will double-check the config setting here out of an abundance of caution | ||
if (htmx.config.selfRequestsOnly) { | ||
evt.detail.headers['X-CSRFToken'] = document.getElementById('csrfTokenContainer').value; | ||
} | ||
}); |
30 changes: 30 additions & 0 deletions
30
corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/errors.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
import { Modal } from "bootstrap5"; | ||
|
||
const DEFAULT_MODAL_ID = 'htmxRequestErrorModal'; | ||
|
||
/** | ||
* Displays an error modal for HTMX request errors, and dispatches an event to update modal content. | ||
* The default modal template can be found at | ||
* `hqwebapp/htmx/error_modal.html` | ||
* You can extend this template and include it in the `modals` block of a template that extends one | ||
* of HQ's base templates. | ||
* | ||
* @param {number} errorCode - The HTTP error code representing the type of error. | ||
* @param {string} errorText - A descriptive error message to display in the modal. | ||
* @param {string} [errorModalId=DEFAULT_MODAL_ID] - The ID of the modal element in the DOM (default is `DEFAULT_MODAL_ID`). | ||
*/ | ||
const showHtmxErrorModal = (errorCode, errorText, errorModalId = DEFAULT_MODAL_ID) => { | ||
const modalElem = document.getElementById(errorModalId); | ||
if (!modalElem) return; // Exit if modal element is not found | ||
|
||
const errorModal = new Modal(modalElem); | ||
window.dispatchEvent(new CustomEvent('updateHtmxRequestErrorModal', { | ||
detail: { | ||
errorCode: errorCode, | ||
errorText: errorText, | ||
}, | ||
})); | ||
errorModal.show(); | ||
}; | ||
|
||
export { showHtmxErrorModal }; |
22 changes: 22 additions & 0 deletions
22
corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/hq_hx_action.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
/* | ||
To use: | ||
1) In your page's entry point, import HTMX and this module, eg: | ||
import 'htmx.org'; | ||
import 'hqwebapp/js/htmx_utils/hq_hx_action'; | ||
2) Then, make sure your class-based view extends the `HqHtmxActionMixin`. | ||
3) Apply the `@hq_hx_action()` decorator to methods you want to make available to | ||
`hq-hx-action` attributes | ||
4) Reference that method in the `hq-hx-action` attribute alongside `hx-get`, | ||
`hx-post`, or equivalent | ||
*/ | ||
document.body.addEventListener('htmx:configRequest', (evt) => { | ||
// Require that the hq-hx-action attribute is present | ||
if (evt.detail.elt.hasAttribute('hq-hx-action')) { | ||
// insert HQ-HX-Action in the header to be processed by the `HqHtmxActionMixin` | ||
evt.detail.headers['HQ-HX-Action'] = evt.detail.elt.getAttribute('hq-hx-action'); | ||
} | ||
}); |
52 changes: 52 additions & 0 deletions
52
corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/retry_request.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
import htmx from 'htmx.org'; | ||
|
||
const DEFAULT_MAX_RETRIES = 20; | ||
const retryPathCounts = {}; | ||
|
||
/** | ||
* Retries an HTMX request up to a specified max retry count. | ||
* | ||
* @param {Object} elt - The HTMX element object (usually found in evt.detail.elt) | ||
* @param {Object} pathInfo - The HTMX pathInfo object (usually found in evt.detail.pathInfo) | ||
* @param {Object} requestConfig - The HTMX requestConfig object (usually found in evt.detail.requestConfig) | ||
* @param {number} [maxRetries=DEFAULT_MAX_RETRIES] - The maximum number of retries allowed. | ||
* @returns {boolean} - Returns `false` if max retries are exceeded, otherwise `true`. | ||
*/ | ||
const retryHtmxRequest = (elt, pathInfo, requestConfig, maxRetries = DEFAULT_MAX_RETRIES) => { | ||
// Extract values from the HTMX event | ||
const replaceUrl = elt.getAttribute('hx-replace-url'); | ||
const requestPath = pathInfo.finalRequestPath; | ||
|
||
// Initialize retry count if necessary | ||
retryPathCounts[requestPath] = retryPathCounts[requestPath] || 0; | ||
retryPathCounts[requestPath]++; | ||
|
||
// Return false if the max number of retries for that path has been exceeded | ||
if (retryPathCounts[requestPath] > maxRetries) { | ||
return false; | ||
} | ||
|
||
// Prepare the context for the htmx request | ||
const context = { | ||
source: elt, | ||
target: requestConfig.target, | ||
swap: elt.getAttribute('hx-swap'), | ||
headers: requestConfig.headers, | ||
values: JSON.parse(elt.getAttribute('hx-vals')), | ||
}; | ||
|
||
// Make the htmx request and handle URL update if necessary | ||
htmx.ajax(requestConfig.verb, requestPath, context).then(() => { | ||
if (replaceUrl === 'true') { | ||
window.history.pushState(null, '', requestPath); | ||
} else if (replaceUrl) { | ||
const newUrl = `${window.location.origin}${window.location.pathname}${replaceUrl}`; | ||
window.history.pushState(null, '', newUrl); | ||
} | ||
delete retryPathCounts[requestPath]; | ||
}); | ||
|
||
return true; | ||
}; | ||
|
||
export default retryHtmxRequest; |
61 changes: 61 additions & 0 deletions
61
corehq/apps/hqwebapp/templates/hqwebapp/htmx/error_modal.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
{% load i18n %} | ||
{# Use with HTMX and Alpine.js #} | ||
{# To be included on pages using the showHtmxErrorModal utility from hqwebapp/js/htmx_utils/error #} | ||
{# Extend this template for better formatting of modal_content and better modal_title #} | ||
|
||
<div | ||
id="htmxRequestErrorModal" | ||
class="modal fade" | ||
x-data="{ | ||
errorCode: '', | ||
errorText: '', | ||
updateError(evt) { | ||
this.errorCode = evt.detail.errorCode; | ||
this.errorText = evt.detail.errorText; | ||
}, | ||
}" | ||
@update-htmx-request-error-modal.camel.window="updateError" | ||
data-bs-backdrop="static" | ||
data-bs-keyboard="false" | ||
aria-labelledby="htmxRequestErrorModalTitle" | ||
tabindex="-1" | ||
> | ||
|
||
<div class="modal-dialog"> | ||
<div class="modal-content"> | ||
<div class="modal-header"> | ||
<h5 | ||
class="modal-title" | ||
id="htmxRequestErrorModalTitle" | ||
> | ||
|
||
{% block modal_title %} | ||
{% trans "Server Error Encountered" %} | ||
{% endblock %} | ||
|
||
</h5> | ||
<button | ||
type="button" | ||
class="btn-close" | ||
data-bs-dismiss="modal" | ||
aria-label="Close" | ||
></button> | ||
</div> | ||
<div class="modal-body"> | ||
|
||
{% block modal_content %} | ||
<p x-text="errorCode"></p> | ||
<p x-text="errorText"></p> | ||
{% endblock %} | ||
|
||
</div> | ||
<div class="modal-footer"> | ||
<button | ||
type="button" | ||
class="btn btn-outline-primary" | ||
data-bs-dismiss="modal" | ||
>{% trans "Close" %}</button> | ||
</div> | ||
</div> | ||
</div> | ||
</div> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
""" | ||
Leaving this as a module so that prototypes can organize their models here. | ||
e.g. "my_new_prototype" in `prototypes/models/my_new_prototype/...` | ||
""" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
from abc import ABC, abstractmethod | ||
import copy | ||
|
||
from django.core.cache import cache | ||
|
||
|
||
class CacheStore(ABC): | ||
""" | ||
Use this to store and retrieve data in memory for prototyped features, | ||
especially for prototypes using HTMX. | ||
DO NOT USE with released features! Prototypes only. | ||
""" | ||
timeout = 2 * 60 * 60 # equivalent to 2 hours (in seconds) | ||
|
||
def __init__(self, request): | ||
self.username = request.user.username | ||
|
||
@property | ||
@abstractmethod | ||
def slug(self): | ||
raise NotImplementedError("please specify a 'slug'") | ||
|
||
@property | ||
@abstractmethod | ||
def initial_value(self): | ||
""" | ||
Please make sure the initial value can be properly pickled and stored | ||
in cached memory. Safe options are strings, booleans, integers, dicts, | ||
and lists of strings, booleans, and integers. | ||
If you want to store more complicated objects, perhaps it's time to start | ||
using a database. Please remember this is only for prototyping and examples! | ||
""" | ||
raise NotImplementedError("please specify an 'initial_value'") | ||
|
||
@property | ||
def cache_key(self): | ||
return f"{self.username}:prototype:{self.slug}" | ||
|
||
def set(self, data): | ||
cache.set(self.cache_key, data, self.timeout) | ||
|
||
def get(self): | ||
return cache.get(self.cache_key, copy.deepcopy(self.initial_value)) | ||
|
||
def delete(self): | ||
cache.delete(self.cache_key) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.