diff --git a/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_and_alpine.js b/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_and_alpine.js new file mode 100644 index 000000000000..5fc3ee53495a --- /dev/null +++ b/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_and_alpine.js @@ -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(); diff --git a/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_base.js b/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_base.js new file mode 100644 index 000000000000..4d605f48f0dc --- /dev/null +++ b/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_base.js @@ -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.') + ); + } +}); diff --git a/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/csrf_token.js b/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/csrf_token.js new file mode 100644 index 000000000000..73a50e23e594 --- /dev/null +++ b/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/csrf_token.js @@ -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; + } +}); diff --git a/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/errors.js b/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/errors.js new file mode 100644 index 000000000000..289d3e8066db --- /dev/null +++ b/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/errors.js @@ -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 }; diff --git a/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/hq_hx_action.js b/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/hq_hx_action.js new file mode 100644 index 000000000000..5da749fc5e82 --- /dev/null +++ b/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/hq_hx_action.js @@ -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'); + } +}); diff --git a/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/retry_request.js b/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/retry_request.js new file mode 100644 index 000000000000..3b8b96ad4518 --- /dev/null +++ b/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/retry_request.js @@ -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; diff --git a/corehq/apps/hqwebapp/templates/hqwebapp/htmx/error_modal.html b/corehq/apps/hqwebapp/templates/hqwebapp/htmx/error_modal.html new file mode 100644 index 000000000000..3629c323768a --- /dev/null +++ b/corehq/apps/hqwebapp/templates/hqwebapp/htmx/error_modal.html @@ -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 #} + +