Skip to content

Commit

Permalink
Merge pull request #35224 from dimagi/bmb/htmx-and-alpine
Browse files Browse the repository at this point in the history
Add HTMX and Alpine.js alongside helpful utilities, common js_entry point
  • Loading branch information
biyeun authored Oct 17, 2024
2 parents 831d6cc + 17517be commit 29f2f93
Show file tree
Hide file tree
Showing 29 changed files with 1,404 additions and 29 deletions.
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.')
);
}
});
18 changes: 18 additions & 0 deletions corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/csrf_token.js
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

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 corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/hq_hx_action.js
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 = {
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

0 comments on commit 29f2f93

Please sign in to comment.