diff --git a/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_base.js b/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_base.js index 4d605f48f0dc..0f508e778092 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_base.js +++ b/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_base.js @@ -26,6 +26,8 @@ import htmx from 'htmx.org'; import 'hqwebapp/js/htmx_utils/hq_hx_action'; import 'hqwebapp/js/htmx_utils/csrf_token'; +import 'hqwebapp/js/htmx_utils/hq_hx_loading'; +import 'hqwebapp/js/htmx_utils/hq_hx_refresh'; import retryHtmxRequest from 'hqwebapp/js/htmx_utils/retry_request'; import { showHtmxErrorModal } from 'hqwebapp/js/htmx_utils/errors'; diff --git a/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/hq_hx_loading.js b/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/hq_hx_loading.js new file mode 100644 index 000000000000..eab028396e5a --- /dev/null +++ b/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/hq_hx_loading.js @@ -0,0 +1,25 @@ +/* + Adds an `is-loading` class to the element with ID specified in the `hq-hx-loading` attribute. + This `is-loading` class is applied when to that ID before an HTMX request begins, and is + removed after an HTMX swap is completed. + + This is useful for adding loading indicators to elements outside the parent heirarchy available + through using `hx-indicator` alone. Right now, this is used to add an `is-loading` style to a django tables + table, which overlays a loading indicator across the entire table (seen in hqwebapp/tables/bootstrap5_htmx.html) + */ +document.body.addEventListener('htmx:beforeRequest', (evt) => { + if (evt.detail.elt.hasAttribute('hq-hx-loading')) { + let loadingElt = document.getElementById(evt.detail.elt.getAttribute('hq-hx-loading')); + if (loadingElt) { + loadingElt.classList.add('is-loading'); + } + } +}); +document.body.addEventListener('htmx:afterSwap', (evt) => { + if (evt.detail.elt.hasAttribute('hq-hx-loading')) { + let loadingElt = document.getElementById(evt.detail.elt.getAttribute('hq-hx-loading')); + if (loadingElt && loadingElt.classList.contains('is-loading')) { + loadingElt.classList.remove('is-loading'); + } + } +}); diff --git a/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/hq_hx_refresh.js b/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/hq_hx_refresh.js new file mode 100644 index 000000000000..a8fe8b35317a --- /dev/null +++ b/corehq/apps/hqwebapp/static/hqwebapp/js/htmx_utils/hq_hx_refresh.js @@ -0,0 +1,32 @@ +/* + Used to chain an `hqRefresh` event to a related HTMX element making a request. + + The attribute `hq-hx-refresh-after` sends the `hqRefresh` event to the target selector + element on `htmx:afterRequest`. + + THe attribute `hq-hx-refresh-swap` sends the `hqRefresh` event to the target selector + element on `htmx:afterSwap`. + + The value of the attributes should be a css selector--for example, `#element` where element is the CSS id. + + The target element can then apply `hx-trigger="hqRefresh"`, effectively chaining a refresh event to the + original triggering request. + + This is commonly used to trigger a refresh of tabular data with Django Tables using the `BaseHtmxTable` + subclass. However, it can be used to chain other HTMX elements together + */ +import htmx from 'htmx.org'; + +const handleRefresh = (evt, attribute) => { + if (evt.detail.elt.hasAttribute(attribute)) { + htmx.trigger(evt.detail.elt.getAttribute(attribute), 'hqRefresh'); + } +}; + +document.body.addEventListener('htmx:afterRequest', (evt) => { + handleRefresh(evt, 'hq-hx-refresh-after'); +}); + +document.body.addEventListener('htmx:afterSwap', (evt) => { + handleRefresh(evt, 'hq-hx-refresh-swap'); +}); diff --git a/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_tables.scss b/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_tables.scss index 0ac342feac10..6cdca1eb1257 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_tables.scss +++ b/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_tables.scss @@ -76,3 +76,95 @@ .table-editprops-filterval { min-width: 115px; } + +.table thead tr th.orderable { + position: relative; + padding: 0; + + a { + display: block; + background-color: $blue-800; + color: $white; + padding: 0.5rem 0.5rem; + } + &:nth-child(odd) a { + background-color: $blue-700; + } + + &::before, + &::after { + position: absolute; + display: block; + right: 10px; + line-height: 9px; + font-size: .8em; + color: $white; + opacity: 0.3; + } + + &::before { + bottom: 50%; + content: "▲" / ""; + } + + &::after { + top: 50%; + content: "▼" / ""; + } + + &.asc::before { + opacity: 1.0; + } + &.desc::after { + opacity: 1.0; + } +} + +.table thead tr th.select-header { + width: 28px; + background-color: $blue-800; + + &:nth-child(odd) { + background-color: $blue-700; + } +} + +.table-container { + position: relative; + + .table-loading-indicator { + z-index: 1000; + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + background: rgba(255, 255, 255, 0.25); + display: none; + + &.is-loading { + display: block; + } + + .spinner-border { + position: absolute; + border-width: $table-loading-spinner-border-width; + color: rgba(0, 0, 0, 0.25); + width: $table-loading-spinner-size; + height: $table-loading-spinner-size; + $_offset: $table-loading-spinner-size / 2; + left: calc(50% - $_offset); + top: calc(50% - $_offset); + } + + .table-loading-progress { + z-index: 1000; + position: absolute; + top: 0; + left: 0; + width: 100%; + background-color: $white; + font-weight: bold; + } + } +} diff --git a/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_variables.scss b/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_variables.scss index 85ffb9495fb4..91cb8da2be98 100644 --- a/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_variables.scss +++ b/corehq/apps/hqwebapp/static/hqwebapp/scss/commcarehq/_variables.scss @@ -230,3 +230,7 @@ $form-validation-states: ( // Make pagination-lg the same height as other lg inputs $pagination-padding-x-lg: 1.0rem; $pagination-padding-y-lg: 0.5rem; + +// Table loading indicator +$table-loading-spinner-size: 150px; +$table-loading-spinner-border-width: 20px; diff --git a/corehq/apps/hqwebapp/tables/__init__.py b/corehq/apps/hqwebapp/tables/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/corehq/apps/hqwebapp/tables/htmx.py b/corehq/apps/hqwebapp/tables/htmx.py new file mode 100644 index 000000000000..900e8d4dbe92 --- /dev/null +++ b/corehq/apps/hqwebapp/tables/htmx.py @@ -0,0 +1,50 @@ +from django_tables2 import tables + +DEFAULT_HTMX_TEMPLATE = "hqwebapp/tables/bootstrap5_htmx.html" + + +class BaseHtmxTable(tables.Table): + """ + This class provides a starting point for using HTMX with Django Tables. + + usage: + + class MyNewTable(BaseHtmxTable): + + class Meta(BaseHtmxTable.Meta): + pass # or you can override or add table attributes here -- see Django Tables docs + + Optional class properties: + + :container_id: - a string + The `container_id` is used as the css `id` of the parent div surrounding table, + its pagination, and related elements. + + You can then use `hq-hx-refresh="#{{ container_id }}"` to trigger a + refresh on the table from another HTMX request on the page. + + When not specified, `container_id` is the table's class name + + :loading_indicator_id: - a string + By default this is ":container_id:-loading". It is the css id of the table's + loading indicator that covers the whole table. The loading indicator for the table + can be triggered by any element making an HTMX request using + `hq-hx-loading="{{ loading_indicator_id }}"`. + + See `hqwebapp/tables/bootstrap5_htmx.html` and `hqwebapp/tables/bootstrap5.html` for usage of the + above properties and related `hq-hx-` attributes. There is also an example in the styleguide. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if not hasattr(self, 'container_id'): + self.container_id = self.__class__.__name__ + if not hasattr(self, 'loading_indicator_id'): + self.loading_indicator_id = f"{self.container_id}-loading" + + class Meta: + attrs = { + 'class': 'table table-striped', + } + template_name = DEFAULT_HTMX_TEMPLATE diff --git a/corehq/apps/hqwebapp/tables/pagination.py b/corehq/apps/hqwebapp/tables/pagination.py new file mode 100644 index 000000000000..fff122e2d3ca --- /dev/null +++ b/corehq/apps/hqwebapp/tables/pagination.py @@ -0,0 +1,52 @@ +from django.core.paginator import Paginator +from django.views.generic.list import ListView + +from django_tables2 import SingleTableMixin + + +class SelectablePaginator(Paginator): + paging_options = [10, 25, 50, 100] + default_option = 25 + + +class SelectablePaginatedTableMixin(SingleTableMixin): + """ + Use this mixin with django-tables2's SingleTableView + + Specify a `urlname` attribute to assist with naming the pagination cookie, + otherwise the cookie slug will default to using the class name. + """ + # `paginator_class` should always be a subclass of `SelectablePaginator` + paginator_class = SelectablePaginator + + @property + def paginate_by_cookie_slug(self): + slug = getattr(self, "urlname", self.__class__.__name__) + return f'{slug}-paginate_by' + + @property + def default_paginate_by(self): + return self.request.COOKIES.get( + self.paginate_by_cookie_slug, + self.paginator_class.default_option + ) + + @property + def current_paginate_by(self): + return self.request.GET.get('per_page', self.default_paginate_by) + + def get_paginate_by(self, table_data): + return self.current_paginate_by + + +class SelectablePaginatedTableView(SelectablePaginatedTableMixin, ListView): + """ + Based on SingleTableView, which inherits from `SingleTableMixin`, `ListView` + we instead extend the `SingleTableMixin` with `SavedPaginatedTableMixin`. + """ + template_name = "hqwebapp/tables/single_table.html" + + def get(self, request, *args, **kwargs): + response = super().get(request, *args, **kwargs) + response.set_cookie(self.paginate_by_cookie_slug, self.current_paginate_by) + return response diff --git a/corehq/apps/hqwebapp/templates/hqwebapp/tables/bootstrap5.html b/corehq/apps/hqwebapp/templates/hqwebapp/tables/bootstrap5.html new file mode 100644 index 000000000000..0e5ef506fff1 --- /dev/null +++ b/corehq/apps/hqwebapp/templates/hqwebapp/tables/bootstrap5.html @@ -0,0 +1,122 @@ +{% extends 'django_tables2/bootstrap5.html' %} +{% load i18n %} +{% load django_tables2 %} +{% load hq_shared_tags %} +{% load hq_tables_tags %} + +{% block table-wrapper %} +
+ {% blocktrans %}
+ This button will trigger a refresh on the table once its HTMX request is complete:
+ {% endblocktrans %}
+
+
+
- You can use the hx-indicator
attribute
- (see docs here)
- to mark which element gets the htmx-request
class appended to it during a request.
- We've added custom styling to _htmx.scss
to support the common states outlined
+ By default, if an element triggers an HTMX request, it will automatically get an htmx-request
+ CSS class applied to it. We've added custom styling to _htmx.scss
to support the common states outlined
later in this section.
- By default, if an element triggers an HTMX request, it will automatically get the htmx-request
- CSS class applied to it. No extra usage of the hx-indicator
attribute is necessary. The
- example submitting elements below showcase this default behavior without the need for
- specific hx-indicator
usage.
+ If you want to create new types of loading indicators for non-standard elements, you can explore using
+ the hx-indicator
attribute
+ (see docs here).
It's often a great idea to pair button requests with hx-disabled-elt="this"
(see docs for hx-disabled-elt),
- like the examples below, so that the requesting element or related element is disabled during the request.
+ like the examples below, so that the requesting button
is disabled during the request.