Skip to content
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 30 commits into from
Oct 17, 2024
Merged
Show file tree
Hide file tree
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 Oct 8, 2024
d9015fc
add Alpine.js to javascript dependencies
biyeun Oct 8, 2024
d649ef6
add HtmxActionMixin and support for hq-hx-action attribute
biyeun Oct 16, 2024
9418499
configure HTMX requests to include the CSRF token
biyeun Oct 16, 2024
40490ec
add a utility for triggering HTMX error modal
biyeun Oct 16, 2024
768a9c4
add utility for retrying HTMX requests
biyeun Oct 16, 2024
b10bad0
stitch everything together to create a common entry point for htmx + …
biyeun Oct 16, 2024
62c6a2e
add CacheStore helper model for HTMX demos to prototype app
biyeun Oct 16, 2024
9875e12
add example for using HTMX and Alpine with a simple Crispy Form
biyeun Oct 16, 2024
844d757
adds an example using HtmxActionMixin and alpine for inline editing
biyeun Oct 16, 2024
0c6e760
update the timeout to something more reasonable
biyeun Oct 16, 2024
66bacd3
feedback: make retryPathCounts a const
biyeun Oct 16, 2024
9f752fa
improvement: reorder file so that HtmxActionMixin comes first
biyeun Oct 16, 2024
599aafa
feedback: use constant instead of string with method='auto'
biyeun Oct 16, 2024
e3b5903
feedback: update naming of HtmxActionMixin and related
biyeun Oct 16, 2024
1db74ee
feedback: move Htmx debug tools into its own mixin
biyeun Oct 16, 2024
08a7e59
update response on errors to provide a reason so that HTMX displays t…
biyeun Oct 16, 2024
e694374
feedback: return forbidden response if action isn't callable
biyeun Oct 16, 2024
4b9ce1b
feedback: not every HTMX evt is the same
biyeun Oct 16, 2024
fbb587f
feedback: only retry on GET timeouts
biyeun Oct 16, 2024
796cade
feedback: clarify error codes, messages, and timeout units
biyeun Oct 16, 2024
c8660f6
feedback: default_value is renamed to initial_value
biyeun Oct 16, 2024
59a92be
improvement: add help text to hint what the operative action is
biyeun Oct 16, 2024
ab542e2
rename templates and make paths shorter for htmx examples so that it'…
biyeun Oct 16, 2024
8002cf3
update comments
biyeun Oct 16, 2024
e0f7c90
add django ace language support to styleguide
biyeun Oct 16, 2024
d30e7c4
also disable checkbox when isSubmitting is true
biyeun Oct 16, 2024
cba4207
add styleguide section for htmx and alpine
biyeun Oct 16, 2024
29a4d31
feedback: clarify timeout units and value
biyeun Oct 16, 2024
17517be
Merge branch 'master' of github.com:dimagi/commcare-hq into bmb/htmx-…
biyeun Oct 16, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions corehq/apps/hqwebapp/static/hqwebapp/js/htmx_and_alpine.js
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();
69 changes: 69 additions & 0 deletions corehq/apps/hqwebapp/static/hqwebapp/js/htmx_base.js
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.')
);
}
});
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 corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/errors.js
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

Check failure on line 18 in corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/errors.js

View workflow job for this annotation

GitHub Actions / Lint Javascript

Expected { after 'if' condition

const errorModal = new Modal(modalElem);
window.dispatchEvent(new CustomEvent('updateHtmxRequestErrorModal', {
detail: {
errorCode: errorCode,
errorText: errorText,
},
}));
errorModal.show();
};

export { showHtmxErrorModal };
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');
}
});
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 = {
Copy link
Contributor

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"

Copy link
Member Author

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

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 corehq/apps/hqwebapp/templates/hqwebapp/htmx/error_modal.html
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>
4 changes: 4 additions & 0 deletions corehq/apps/prototype/models/__init__.py
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/...`
"""
48 changes: 48 additions & 0 deletions corehq/apps/prototype/models/cache_store.py
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)
1 change: 1 addition & 0 deletions corehq/apps/styleguide/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def get_navigation_context(current_page):
Page("Code Guidelines", 'styleguide_code_guidelines_b5'),
Page("Bootstrap Migration Guide", 'styleguide_migration_guide_b5'),
Page("Javascript Guide", 'styleguide_javascript_guide_b5'),
Page("HTMX + Alpine.JS", 'styleguide_htmx_and_alpine_b5'),
],
),
NavigationGroup(
Expand Down
Loading
Loading