-
-
Notifications
You must be signed in to change notification settings - Fork 218
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add HTMX and Alpine.js alongside helpful utilities, common js_entry point #35224
Merged
Merged
Changes from all commits
Commits
Show all changes
30 commits
Select commit
Hold shift + click to select a range
e4cd0af
add HTMX to js dependencies
biyeun d9015fc
add Alpine.js to javascript dependencies
biyeun d649ef6
add HtmxActionMixin and support for hq-hx-action attribute
biyeun 9418499
configure HTMX requests to include the CSRF token
biyeun 40490ec
add a utility for triggering HTMX error modal
biyeun 768a9c4
add utility for retrying HTMX requests
biyeun b10bad0
stitch everything together to create a common entry point for htmx + …
biyeun 62c6a2e
add CacheStore helper model for HTMX demos to prototype app
biyeun 9875e12
add example for using HTMX and Alpine with a simple Crispy Form
biyeun 844d757
adds an example using HtmxActionMixin and alpine for inline editing
biyeun 0c6e760
update the timeout to something more reasonable
biyeun 66bacd3
feedback: make retryPathCounts a const
biyeun 9f752fa
improvement: reorder file so that HtmxActionMixin comes first
biyeun 599aafa
feedback: use constant instead of string with method='auto'
biyeun e3b5903
feedback: update naming of HtmxActionMixin and related
biyeun 1db74ee
feedback: move Htmx debug tools into its own mixin
biyeun 08a7e59
update response on errors to provide a reason so that HTMX displays t…
biyeun e694374
feedback: return forbidden response if action isn't callable
biyeun 4b9ce1b
feedback: not every HTMX evt is the same
biyeun fbb587f
feedback: only retry on GET timeouts
biyeun 796cade
feedback: clarify error codes, messages, and timeout units
biyeun c8660f6
feedback: default_value is renamed to initial_value
biyeun 59a92be
improvement: add help text to hint what the operative action is
biyeun ab542e2
rename templates and make paths shorter for htmx examples so that it'…
biyeun 8002cf3
update comments
biyeun e0f7c90
add django ace language support to styleguide
biyeun d30e7c4
also disable checkbox when isSubmitting is true
biyeun cba4207
add styleguide section for htmx and alpine
biyeun 29a4d31
feedback: clarify timeout units and value
biyeun 17517be
Merge branch 'master' of github.com:dimagi/commcare-hq into bmb/htmx-…
biyeun File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does htmx have a way of constructing its own request that we could use here? The answer could be "no"
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
updated the signature of the function to make things easier to read. but, yes, no easy retry option with HTMX sadly