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 #} + + diff --git a/corehq/apps/prototype/models/__init__.py b/corehq/apps/prototype/models/__init__.py new file mode 100644 index 000000000000..66cd67b4c4fa --- /dev/null +++ b/corehq/apps/prototype/models/__init__.py @@ -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/...` +""" diff --git a/corehq/apps/prototype/models/cache_store.py b/corehq/apps/prototype/models/cache_store.py new file mode 100644 index 000000000000..ed134cace6a0 --- /dev/null +++ b/corehq/apps/prototype/models/cache_store.py @@ -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) diff --git a/corehq/apps/styleguide/context.py b/corehq/apps/styleguide/context.py index 6a17848def24..7c1b1e3714f7 100644 --- a/corehq/apps/styleguide/context.py +++ b/corehq/apps/styleguide/context.py @@ -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( diff --git a/corehq/apps/styleguide/examples/bootstrap5/htmx_alpine_form_demo.py b/corehq/apps/styleguide/examples/bootstrap5/htmx_alpine_form_demo.py new file mode 100644 index 000000000000..cb30cc10d03e --- /dev/null +++ b/corehq/apps/styleguide/examples/bootstrap5/htmx_alpine_form_demo.py @@ -0,0 +1,101 @@ +import json + +from crispy_forms import bootstrap as twbscrispy, layout as crispy +from crispy_forms.helper import FormHelper + +from django import forms +from django.utils.translation import gettext_lazy, gettext as _ + + +class MatchType: + EXACT = "exact" + IS_NOT = "is_not" + CONTAINS = "contains" + IS_EMPTY = "is_empty" + + OPTIONS = ( + (EXACT, gettext_lazy("is exactly")), + (IS_NOT, gettext_lazy("is not")), + (CONTAINS, gettext_lazy("contains")), + (IS_EMPTY, gettext_lazy("is empty")), + ) + + MATCHES_WITH_VALUES = ( + EXACT, IS_NOT, CONTAINS, + ) + + +class FilterDemoForm(forms.Form): + slug = forms.ChoiceField( + label=gettext_lazy("Column"), + choices=( + ('name', gettext_lazy("Name")), + ('color', gettext_lazy("Color")), + ('desc', gettext_lazy("Description")), + ), + required=False + ) + match = forms.ChoiceField( + label=gettext_lazy("Match Type"), + choices=MatchType.OPTIONS, + required=False, + help_text=gettext_lazy( + "Hint: select 'is empty' to watch the Value field below disappear" + ) + ) + value = forms.CharField( + label=gettext_lazy("Value"), + strip=False, + required=False + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.helper = FormHelper() + + # We defer to defining the
tag in the template as we will + # use HTMX to load and submit the form. Keeping the HTMX attributes + # local to the template is preferred to maintain context. + self.helper.form_tag = False + + # This form layout uses Alpine to toggle the visibility of + # the "value" field: + self.helper.layout = crispy.Layout( + crispy.Div( + 'slug', + crispy.Field( + 'match', + # We initialize the match value in the alpine + # model defined below: + x_init="match = $el.value", + # and then two-way bind the alpine match + # model variable to this input: + x_model="match", + ), + crispy.Div( + 'value', + # This uses alpine to determine whether to + # show the value field, based on the valueMatches + # list defined in the alpine model below: + x_show="valueMatches.includes(match)", + ), + twbscrispy.StrictButton( + _("Add Filter"), + type="submit", + css_class="btn-primary htmx-loading", + ), + # The Alpine data model is easily bound to the form + # to control hiding/showing the value field: + x_data=json.dumps({ + "match": self.fields['match'].initial, + "valueMatches": MatchType.MATCHES_WITH_VALUES, + }), + ), + ) + + def clean(self): + match = self.cleaned_data['match'] + value = self.cleaned_data['value'] + if match in MatchType.MATCHES_WITH_VALUES and not value: + self.add_error('value', _("Please specify a value.")) diff --git a/corehq/apps/styleguide/examples/bootstrap5/htmx_alpine_form_views.py b/corehq/apps/styleguide/examples/bootstrap5/htmx_alpine_form_views.py new file mode 100644 index 000000000000..04c0d73f182a --- /dev/null +++ b/corehq/apps/styleguide/examples/bootstrap5/htmx_alpine_form_views.py @@ -0,0 +1,68 @@ +from django.urls import reverse +from django.utils.decorators import method_decorator +from django.views.generic import TemplateView + +from corehq.apps.domain.decorators import login_required +from corehq.apps.hqwebapp.decorators import use_bootstrap5 +from corehq.apps.hqwebapp.views import BasePageView +from corehq.apps.styleguide.examples.bootstrap5.htmx_alpine_form_demo import FilterDemoForm + + +@method_decorator(login_required, name='dispatch') +@method_decorator(use_bootstrap5, name='dispatch') +class HtmxAlpineFormDemoView(BasePageView): + """ + This view is just a basic page view which acts as the "container" for a separate + "FilterDemoFormView" that handles the interaction with the "FilterDemoForm". + + This establishes the page as a `js_entry` point for HTMX + Alpine and loads the + form view below asynchronously in the page content. + """ + urlname = "sg_htmx_alpine_form_demo" + template_name = "styleguide/htmx_alpine_crispy/main.html" + + @property + def page_url(self): + return reverse(self.urlname) + + @property + def page_context(self): + return { + "filter_form_url": reverse(FilterDemoFormView.urlname), + } + + +# don't forget to add the same security decorators as the "host" view above! +@method_decorator(login_required, name='dispatch') +# the use_bootstrap5 decorator is needed here for crispy forms to work properly +@method_decorator(use_bootstrap5, name='dispatch') +class FilterDemoFormView(TemplateView): + """ + This view inherits from a simple `TemplateView` because the `template_name` is a + partial HTML template, so we don't need to extend any of the base HQ templates. + """ + urlname = "sg_htmx_alpine_filter_form" + template_name = "styleguide/htmx_alpine_crispy/partial_form.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + filter_form = kwargs.pop('filter_form') if 'filter_form' in kwargs else None + context.update({ + "filter_form": filter_form or FilterDemoForm(), + }) + return context + + def post(self, request, *args, **kwargs): + filter_form = FilterDemoForm(request.POST) + show_success = False + if filter_form.is_valid(): + # do something with filter form data + show_success = True + filter_form = None + return super().get( + request, + filter_form=filter_form, + show_success=show_success, + *args, + **kwargs + ) diff --git a/corehq/apps/styleguide/examples/bootstrap5/htmx_hq_hx_action.py b/corehq/apps/styleguide/examples/bootstrap5/htmx_hq_hx_action.py new file mode 100644 index 000000000000..235133f9b23b --- /dev/null +++ b/corehq/apps/styleguide/examples/bootstrap5/htmx_hq_hx_action.py @@ -0,0 +1,110 @@ +from django.urls import reverse +from django.utils.decorators import method_decorator +from django.utils.translation import gettext as _ + +from corehq.apps.domain.decorators import login_required +from corehq.apps.hqwebapp.decorators import use_bootstrap5 +from corehq.apps.hqwebapp.views import BasePageView +from corehq.apps.prototype.models.cache_store import CacheStore +from corehq.util.htmx_action import HqHtmxActionMixin, hq_hx_action + + +@method_decorator(login_required, name='dispatch') +@method_decorator(use_bootstrap5, name='dispatch') +class TodoListDemoView(HqHtmxActionMixin, BasePageView): + """ + This view demonstrates how we use HqHtmxActionMixin with a view to provide + HTMX responses when HTMX interacts with this view using the `hq-hx-action` attribute. + """ + urlname = "sg_htmx_todo_list_example" + template_name = 'styleguide/htmx_todo/main.html' + + @property + def page_url(self): + return reverse(self.urlname) + + @property + def page_context(self): + return { + "items": self.get_items(), + } + + def get_items(self): + return TodoListStore(self.request).get() + + def save_items(self, items): + TodoListStore(self.request).set(items) + + def update_item(self, item_id, name=None, is_done=None): + items = self.get_items() + for item in items: + if item["id"] == item_id: + item["name"] = name if name is not None else item["name"] + item["is_done"] = is_done if is_done is not None else item["is_done"] + TodoListStore(self.request).set(items) + return item + + def render_item_response(self, request, item): + template = ("styleguide/htmx_todo/item_done_oob_swap.html" if item["is_done"] + else "styleguide/htmx_todo/item.html") + context = { + 'item': item, + } + return self.render_htmx_partial_response(request, template, context) + + # we can now reference `hq-hx-action="create_new_item"` + # alongside a `hx-post` to this view URL + @hq_hx_action('post') + def create_new_item(self, request, *args, **kwargs): + items = self.get_items() + new_item = { + "id": len(items) + 1, + "name": _("New Item"), + "is_done": False, + } + items.insert(0, new_item) + self.save_items(items) + return self.render_item_response(request, new_item) + + @hq_hx_action('post') + def edit_item(self, request, *args, **kwargs): + item = self.update_item( + int(request.POST['itemId']), + name=request.POST['newValue'], + ) + return self.render_item_response(request, item) + + @hq_hx_action('post') + def mark_item_done(self, request, *args, **kwargs): + item = self.update_item( + int(request.POST['itemId']), + is_done=True, + ) + return self.render_item_response(request, item) + + +class TodoListStore(CacheStore): + """ + CacheStore is a helpful prototyping tool when you need to store + data on the server side for prototyping HTMX views. + + Caution: Please don't use this for real features. + """ + slug = 'styleguide-todo-list' + initial_value = [ + { + "id": 1, + "name": "get coat hangers", + "is_done": False, + }, + { + "id": 2, + "name": "water plants", + "is_done": False, + }, + { + "id": 3, + "name": "Review PRs", + "is_done": False, + }, + ] diff --git a/corehq/apps/styleguide/static/styleguide/main.js b/corehq/apps/styleguide/static/styleguide/main.js index d87950f30269..131391408f82 100644 --- a/corehq/apps/styleguide/static/styleguide/main.js +++ b/corehq/apps/styleguide/static/styleguide/main.js @@ -2,7 +2,7 @@ $(function () { 'use strict'; let initialPageData = hqImport("hqwebapp/js/initial_page_data"); ace.config.set('basePath', initialPageData.get('ace_base_path')); - _.each(["python", "html", "js"], function (lang) { + _.each(["python", "html", "js", "django"], function (lang) { $('pre[data-lang="' + lang + '"]').each(function () { let editor = ace.edit($(this).get(0), { showPrintMargin: false, diff --git a/corehq/apps/styleguide/templates/styleguide/bootstrap5/base.html b/corehq/apps/styleguide/templates/styleguide/bootstrap5/base.html index 33dfb39f746d..69e3c398331f 100644 --- a/corehq/apps/styleguide/templates/styleguide/bootstrap5/base.html +++ b/corehq/apps/styleguide/templates/styleguide/bootstrap5/base.html @@ -128,6 +128,7 @@
Quick Links
+ diff --git a/corehq/apps/styleguide/templates/styleguide/bootstrap5/examples/htmx_get_started.html b/corehq/apps/styleguide/templates/styleguide/bootstrap5/examples/htmx_get_started.html new file mode 100644 index 000000000000..fe0829657686 --- /dev/null +++ b/corehq/apps/styleguide/templates/styleguide/bootstrap5/examples/htmx_get_started.html @@ -0,0 +1,13 @@ +{% extends "hqwebapp/bootstrap5/base_navigation.html" %} +{% load hq_shared_tags %} + +{% js_entry "hqwebapp/js/htmx_and_alpine" %} + +{% block content %} + Do HTMX and Alpine things here! +{% endblock %} + +{% block modals %} + {% include "hqwebapp/htmx/error_modal.html" %} + {{ block.super }} +{% endblock modals %} diff --git a/corehq/apps/styleguide/templates/styleguide/bootstrap5/htmx_and_alpine.html b/corehq/apps/styleguide/templates/styleguide/bootstrap5/htmx_and_alpine.html new file mode 100644 index 000000000000..0c71948a417c --- /dev/null +++ b/corehq/apps/styleguide/templates/styleguide/bootstrap5/htmx_and_alpine.html @@ -0,0 +1,244 @@ +{% extends 'styleguide/bootstrap5/base.html' %} + +{% block intro %} +

HTMX and Alpine

+

+ Low JavaScript, high interactivity. +

+{% endblock intro %} + +{% block toc %} +
On this page
+
+ +{% endblock toc %} + +{% block content %} +

+ Overview +

+

+ HTMX and Alpine + are two lightweight JavaScript libraries that work well together to add interactivity to a page without + needing to add a ton of custom javascript. They work through clever usage of HTML attributes. +

+

+ HTMX is particularly suited for a Django environment like ours as it expects asynchronous responses to + be HTML (instead of JSON), meaning we can return partial templates as responses. This allows us to utilize + more of Django's backend power, rather than having to re-create a ton of logic in the front end. +

+

+ Getting Started +

+

+ If you want to quickly get started, you can use this js_entry point + hqwebapp/js/htmx_and_alpine (no additional configuration necessary) and + include hqwebapp/htmx/error_modal.html in the modals block of your page. +

+ {% include 'styleguide/bootstrap5/code_display.html' with content=examples.htmx_get_started %} +

+ Right now HTMX and Alpine are only available on pages using Webpack. If you don't know what this means, + please read the + Overview of Static Files + and JavaScript Bundlers. If you don't know how to proceed after that, read further. +

+
+ Important: HTMX and Alpine can only be included on pages using Webpack as the JavaScript bundler + due to limitations with RequireJS. See the guide for + Migrating RequireJS to Webpack if you have an older page you would like + to use HTMX and Alpine on. If the page is even older (no bundler), we currently do not have a solution + for this. +
+ +

+ Usage with Forms +

+

+ HTMX and Alpine are well suited for sprinkling bits of interactivity into an otherwise very static form. + For instance, we want to hide or show a field depending on the value of a select input. +

+

+ The in-page interactivity is handled by Alpine. HTMX is useful because it allows us to submit (and load) + the form asynchronously. +

+

+ Demo with Crispy Forms +

+

+ Take a look at this simple demo of how + we might use HTMX and Alpine with Crispy Forms. Tip: Make sure you are logged in. +

+

+ Below we have two template views, HtmxAlpineFormDemoView and FilterDemoFormView. + HtmxAlpineFormDemoView is the view that loads when visiting the URL above. It can be considered + the "host" view, and asynchronously loads the partial template content from the FilterDemoFormView + on page load. FilterDemoFormView then controls everything related to the FilterDemoForm. +

+ {% include 'styleguide/bootstrap5/code_display.html' with content=examples.htmx_alpine_form_views %} +

+ Take a look at the template for HtmxAlpineFormDemoView. You can see it uses a common + js_entry entry point hqwebapp/js/htmx_and_alpine, which is appropriate for + most common uses of HTMX and Alpine. +

+ {% include 'styleguide/bootstrap5/code_display.html' with content=examples.form_main_template %} +

+ The template for FilterDemoFormView then looks like this—a very simple form partial! + The magic of what happens after the form is submitted is in the hx- attributes within + the <form> tag that control the HTMX interactions. +

+ {% include 'styleguide/bootstrap5/code_display.html' with content=examples.form_partial_template %} +

+ The interaction within the form is then controlled by Alpine. Take a peek at self.helper.layout + in the code below. All the x_ attributes define the Alpine model and interactions—no + external JavaScript file necessary! +

+ {% include 'styleguide/bootstrap5/code_display.html' with content=examples.htmx_alpine_form %} + +

+ Organization for Complex Pages +

+

+ You can imagine that if a page needs to make a lot of different HTMX requests for multiple page interactions, + the view list needed to process each request might get pretty long. Additionally, it might be challenging to + properly identify these views as related to one main view. +

+

+ That is where HqHtmxActionMixin comes in! You can use this mixin with any TemplateView + (and common HQ subclasses, like BasePageView). Once mixed-in, any method on that view can be + decorated with @hq_hx_action() to make that method name directly available as an "action" + with any hx-get, hx-post, or related hx- request to that View's URL. + This association can be made with the hq-hx-action attribute in the same element that has the + hx- request tag. +

+

+ Not quite following? Yes, it's a bit complex. Let's check out a demo and hopefully that will clear up any + confusion... +

+

+ Using HqHtmxActionMixin +

+

+ To illustrate the usage of HqHtmxActionMixin, here is a simple + To-Do List demo. +

+

+ Let's first start with the TodoListDemoView, a subclass of BasePageView that mixes + in HqHtmxActionMixin. You can see that the @hq_hx_action() decorator is applied + to three methods in this view: create_new_item, edit_item, and + mark_item_done. +

+ {% include 'styleguide/bootstrap5/code_display.html' with content=examples.htmx_hq_hx_action %} +

+ Looking at the main template for this view, we can scan the page for the first hx-post + attribute, which posts to request.path_info. This is URL of the page serving this + template: TodoListDemoView. +

+

+ Below hx-post is the hq-hx-action attribute referencing the + create_new_item method of the TodoListDemoView. Remember, this was one + of the methods decorated with @hq_hx_action(). The rest of the attributes are specific to + HTMX, and you can read more about them on htmx.org. +

+

+ If you scan the rest of the template, you will notice another hq-hx-action attribute which + references the method does_not_exist. As it is aptly named, this method does not exist in + TodoListDemoView. However, that's the point, as this attribute is attached to a button which + triggers the HTMX error modal. :) +

+ {% include 'styleguide/bootstrap5/code_display.html' with content=examples.htmx_todo_main %} +

+ What about the other two actions, edit_item and mark_item_done? To examine the usage + for these, we need to look at the item.html template. +

+

+ In this template, we see hq_hx_action="mark_item_done" applied to the checkbox + input. Scrolling down, we see hq_hx_action="edit_item" applied to the form + that contains the input with the edited form value. +

+

+ However, how is the quick inline-edit interaction happening? That all comes from Alpine! Looking at the + attributes in the li element on that template, we see the simple Alpine model being defined, + setting up variables to track the isEditing and isSubmitting states. Additionally, + the itemValue is defined and initialized from the template variable item.name. This + itemValue is bound to the text input in the edit_item form below, with + Alpine's x-model attribute. +

+

+ The hide/show states of other elements are then controlled with Alpine's x-show attribute, + while other form elements are disabled using Alpine's :disabled shorthand attribute when the + isEditing or isSubmitting states change. +

+ {% include 'styleguide/bootstrap5/code_display.html' with content=examples.htmx_todo_item %} +

+ From the code in TodoListDemoView, you can see that a request to the different + @hq_hx_action() methods returns self.render_item_response. This returns + render_htmx_partial_response, which is part of the HqHtmxActionMixin. + Looking at this render_item_response method, we see two partial templates being returned. +

+

+ The item.html template we've already examined above. However, when the item is marked as + is_done, the method returns the item_done_oob_swap.html partial. Let's take a + look at this template: +

+ {% include 'styleguide/bootstrap5/code_display.html' with content=examples.htmx_todo_item_done_swap %} +

+ As you can see, this template just adds a wrapper around the item_done.html template, but + it has a very interesting HTMX attribute in this wrapper, hx-swap-oob. This was included + to demonstrate a powerful feature of HTMX to do out-of-bounds swaps, which you can + read about here. +

+

+ Lastly, we have the item_done.html partial template. It's very simple: +

+ {% include 'styleguide/bootstrap5/code_display.html' with content=examples.htmx_todo_item_done %} +

+ That's it! Hopefully HqHtmxActionMixin seems less confusing now, + and you are inspired to use it. :) +

+ +

+ Debugging During Development +

+

+ During development of a page using HTMX, you might be interested in seeing how the page + looks like when requests take a long time to load, or when the server is a little flaky. Unfortunately, + when you try to use the browser dev tools to simulate page slowness, you are only delaying sending the + request to your local server, rather than simulating a slow server response. The distinction is important + with HTMX, especially when debugging timeout issues. +

+

+ To enable some advanced debugging features on a view using the HqHtmxActionMixin, you + can also mix-in the HqHtmxDebugMixin. The HqHtmxDebugMixin allows you to + simulate slow server responses by setting simulate_slow_response = True on your view. + You can also simulate intermittent bad gateway errors with simulate_flaky_gateway = True. +

+

+ You can check out the HqHtmxDebugMixin directly for additional documentation and usage + guidance. +

+{% endblock content %} diff --git a/corehq/apps/styleguide/templates/styleguide/htmx_alpine_crispy/main.html b/corehq/apps/styleguide/templates/styleguide/htmx_alpine_crispy/main.html new file mode 100644 index 000000000000..2024e1996363 --- /dev/null +++ b/corehq/apps/styleguide/templates/styleguide/htmx_alpine_crispy/main.html @@ -0,0 +1,25 @@ +{% extends "hqwebapp/bootstrap5/base_navigation.html" %} +{% load hq_shared_tags %} +{% load i18n %} + +{# use the `hqwebapp/js/htmx_and_alpine` entry point to get started with HTMX and Alpine without any extra javascript #} +{% js_entry "hqwebapp/js/htmx_and_alpine" %} + +{% block content %} +
+
+ {% trans "Loading..." %} +
+
+{% endblock %} + +{% block modals %} + {# you can either include this template or include an extension of this template to show HTMX errors to the user #} + {% include "hqwebapp/htmx/error_modal.html" %} + {{ block.super }} +{% endblock modals %} diff --git a/corehq/apps/styleguide/templates/styleguide/htmx_alpine_crispy/partial_form.html b/corehq/apps/styleguide/templates/styleguide/htmx_alpine_crispy/partial_form.html new file mode 100644 index 000000000000..54c3c0e2bb4e --- /dev/null +++ b/corehq/apps/styleguide/templates/styleguide/htmx_alpine_crispy/partial_form.html @@ -0,0 +1,30 @@ +{% load i18n %} +{% load crispy_forms_tags %} + +
+ {% if show_success %} + + {% endif %} + + + {% crispy filter_form %} + +
diff --git a/corehq/apps/styleguide/templates/styleguide/htmx_todo/item.html b/corehq/apps/styleguide/templates/styleguide/htmx_todo/item.html new file mode 100644 index 000000000000..6f9c094caf4f --- /dev/null +++ b/corehq/apps/styleguide/templates/styleguide/htmx_todo/item.html @@ -0,0 +1,95 @@ +{% load i18n %} +
  • +
    + + + {# the element below defines what the text looks like when not in edit mode #} +
    element #} + x-show="!isEditing" + {# @dblclick is shorthand for Alpine's x-on:dblclick #} + @dblclick="isEditing = !isSubmitting" + > + {{ item.name }} + +
    + + {# the element below defines what the inline edit looks like in edit mode #} +
    +
    +
    + + + +
    +
    +
    +
    + +
  • diff --git a/corehq/apps/styleguide/templates/styleguide/htmx_todo/item_done.html b/corehq/apps/styleguide/templates/styleguide/htmx_todo/item_done.html new file mode 100644 index 000000000000..8da854d9d71a --- /dev/null +++ b/corehq/apps/styleguide/templates/styleguide/htmx_todo/item_done.html @@ -0,0 +1,9 @@ +
  • +
    + + {{ item.name }} +
    +
  • diff --git a/corehq/apps/styleguide/templates/styleguide/htmx_todo/item_done_oob_swap.html b/corehq/apps/styleguide/templates/styleguide/htmx_todo/item_done_oob_swap.html new file mode 100644 index 000000000000..ddfc4ee98f9d --- /dev/null +++ b/corehq/apps/styleguide/templates/styleguide/htmx_todo/item_done_oob_swap.html @@ -0,0 +1,4 @@ +{# hx-swap-oob inserts the result of this element in the #done-items